Skip to main content

max / alloy_tui

5.1 KB · 163 lines History Blame Raw
1 //! The link between two panes: an elbow drawn through the gutter, joining a
2 //! row on the left to the row it is paired with on the right.
3 //!
4 //! One connector at a time, deliberately. Drawing every pairing at once is the
5 //! obvious reading of the matching-quiz idea and it does not survive contact
6 //! with a terminal: lines cross, a cell can only hold one glyph, and past
7 //! three or four pairs the picture is unreadable. Lighting only the focused
8 //! pairing gives the same "these two are joined" reading, never crosses
9 //! anything, and needs no crossing-glyph logic.
10
11 use ratatui::buffer::Buffer;
12 use ratatui::layout::Rect;
13 use ratatui::style::Style;
14 use ratatui::widgets::Widget;
15
16 use crate::theme::Theme;
17
18 /// A connector between `from_y` on the left and `to_y` on the right, both in
19 /// absolute buffer rows.
20 pub struct AlloyConnector<'a> {
21 theme: &'a Theme,
22 from_y: u16,
23 to_y: u16,
24 }
25
26 impl<'a> AlloyConnector<'a> {
27 pub fn new(theme: &'a Theme, from_y: u16, to_y: u16) -> Self {
28 Self {
29 theme,
30 from_y,
31 to_y,
32 }
33 }
34 }
35
36 impl Widget for AlloyConnector<'_> {
37 fn render(self, area: Rect, buf: &mut Buffer) {
38 // Needs a column to leave, a column to run down, and a column to
39 // arrive; narrower than that and there is nothing to draw.
40 if area.width < 3 || area.height == 0 {
41 return;
42 }
43
44 let top = area.y;
45 let bottom = area.y + area.height - 1;
46 // A row scrolled out of its pane has nothing to point at. Clamping
47 // instead would draw a connector to a row the user cannot see, which
48 // reads as a link to whatever happens to be at the edge.
49 if !(top..=bottom).contains(&self.from_y) || !(top..=bottom).contains(&self.to_y) {
50 return;
51 }
52
53 let style = Style::default()
54 .fg(self.theme.border_strong)
55 .bg(self.theme.makeover.surface_page);
56 let mid = area.x + area.width / 2;
57 let last = area.x + area.width - 1;
58
59 let mut put = |x: u16, y: u16, glyph: &str| {
60 buf[(x, y)].set_symbol(glyph).set_style(style);
61 };
62
63 if self.from_y == self.to_y {
64 for x in area.x..=last {
65 put(x, self.from_y, "");
66 }
67 return;
68 }
69
70 // Leave the left pane.
71 for x in area.x..mid {
72 put(x, self.from_y, "");
73 }
74 // Enter the right pane.
75 for x in (mid + 1)..=last {
76 put(x, self.to_y, "");
77 }
78
79 let (upper, lower) = if self.from_y < self.to_y {
80 (self.from_y, self.to_y)
81 } else {
82 (self.to_y, self.from_y)
83 };
84 for y in (upper + 1)..lower {
85 put(mid, y, "");
86 }
87
88 // Corners: the glyph at each end depends on which way the run turns.
89 let (from_corner, to_corner) = if self.from_y < self.to_y {
90 ("", "")
91 } else {
92 ("", "")
93 };
94 put(mid, self.from_y, from_corner);
95 put(mid, self.to_y, to_corner);
96 }
97 }
98
99 #[cfg(test)]
100 mod tests {
101 use super::*;
102
103 fn theme() -> Theme {
104 crate::theme::test_theme(crate::theme::Mode::Dark)
105 }
106
107 /// Render a connector into a `width`x`height` gutter and read it back as
108 /// rows of text.
109 fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec<String> {
110 let theme = theme();
111 let area = Rect::new(0, 0, width, height);
112 let mut buf = Buffer::empty(area);
113 AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf);
114
115 (0..height)
116 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
117 .collect()
118 }
119
120 #[test]
121 fn aligned_rows_draw_a_straight_run() {
122 assert_eq!(render(1, 1, 3, 3), [" ", "───", " "]);
123 }
124
125 // Descending: leave the left pane, turn down, arrive on the lower row.
126 #[test]
127 fn descending_link_turns_down() {
128 assert_eq!(render(0, 2, 3, 3), ["─┐ ", "", " └─"]);
129 }
130
131 // Ascending is the mirror image. Reusing the descending corners here would
132 // draw an elbow pointing the wrong way, which is the kind of thing that
133 // looks fine until you see it next to its opposite.
134 #[test]
135 fn ascending_link_turns_up() {
136 assert_eq!(render(2, 0, 3, 3), [" ┌─", "", "─┘ "]);
137 }
138
139 #[test]
140 fn adjacent_rows_need_no_vertical_run() {
141 assert_eq!(render(0, 1, 3, 2), ["─┐ ", " └─"]);
142 }
143
144 // A row scrolled out of view must not be drawn to. Clamping would point
145 // the connector at whatever sits at the pane edge.
146 #[test]
147 fn out_of_range_rows_draw_nothing() {
148 assert_eq!(render(0, 9, 3, 3), [" ", " ", " "]);
149 assert_eq!(render(9, 0, 3, 3), [" ", " ", " "]);
150 }
151
152 #[test]
153 fn a_gutter_too_narrow_to_hold_an_elbow_draws_nothing() {
154 assert_eq!(render(0, 1, 2, 2), [" ", " "]);
155 }
156
157 // Wider gutters keep the vertical run centered and extend the horizontals.
158 #[test]
159 fn wider_gutters_extend_the_horizontal_runs() {
160 assert_eq!(render(0, 2, 5, 3), ["──┐ ", "", " └──"]);
161 }
162 }
163