Skip to main content

max / alloy_tui

6.0 KB · 183 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 { theme, from_y, to_y }
29 }
30 }
31
32 impl Widget for AlloyConnector<'_> {
33 fn render(self, area: Rect, buf: &mut Buffer) {
34 // Needs a column to leave, a column to run down, and a column to
35 // arrive; narrower than that and there is nothing to draw.
36 if area.width < 3 || area.height == 0 {
37 return;
38 }
39
40 let top = area.y;
41 let bottom = area.y + area.height - 1;
42 // A row scrolled out of its pane has nothing to point at. Clamping
43 // instead would draw a connector to a row the user cannot see, which
44 // reads as a link to whatever happens to be at the edge.
45 if !(top..=bottom).contains(&self.from_y) || !(top..=bottom).contains(&self.to_y) {
46 return;
47 }
48
49 let style = Style::default()
50 .fg(self.theme.border_strong)
51 .bg(self.theme.surface_page);
52 let mid = area.x + area.width / 2;
53 let last = area.x + area.width - 1;
54
55 let mut put = |x: u16, y: u16, glyph: &str| {
56 buf[(x, y)].set_symbol(glyph).set_style(style);
57 };
58
59 if self.from_y == self.to_y {
60 for x in area.x..=last {
61 put(x, self.from_y, "");
62 }
63 return;
64 }
65
66 // Leave the left pane.
67 for x in area.x..mid {
68 put(x, self.from_y, "");
69 }
70 // Enter the right pane.
71 for x in (mid + 1)..=last {
72 put(x, self.to_y, "");
73 }
74
75 let (upper, lower) = if self.from_y < self.to_y {
76 (self.from_y, self.to_y)
77 } else {
78 (self.to_y, self.from_y)
79 };
80 for y in (upper + 1)..lower {
81 put(mid, y, "");
82 }
83
84 // Corners: the glyph at each end depends on which way the run turns.
85 let (from_corner, to_corner) = if self.from_y < self.to_y {
86 ("", "")
87 } else {
88 ("", "")
89 };
90 put(mid, self.from_y, from_corner);
91 put(mid, self.to_y, to_corner);
92 }
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::*;
98 use crate::theme::Mode;
99 use ratatui::style::Color;
100
101 fn theme() -> Theme {
102 Theme {
103 mode: Mode::Dark,
104 surface_page: Color::Rgb(0, 0, 0),
105 surface_raised: Color::Rgb(1, 1, 1),
106 surface_sunken: Color::Rgb(2, 2, 2),
107 surface_overlay: Color::Rgb(3, 3, 3),
108 content_primary: Color::Rgb(4, 4, 4),
109 content_secondary: Color::Rgb(5, 5, 5),
110 content_muted: Color::Rgb(6, 6, 6),
111 action_primary: Color::Rgb(7, 7, 7),
112 status_danger: Color::Rgb(8, 8, 8),
113 status_success: Color::Rgb(9, 9, 9),
114 status_warning: Color::Rgb(10, 10, 10),
115 status_info: Color::Rgb(11, 11, 11),
116 line_border: Color::Rgb(12, 12, 12),
117 border_subtle: Color::Rgb(13, 13, 13),
118 border_strong: Color::Rgb(14, 14, 14),
119 category: [Color::Rgb(15, 15, 15); 6],
120 }
121 }
122
123 /// Render a connector into a `width`x`height` gutter and read it back as
124 /// rows of text.
125 fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec<String> {
126 let theme = theme();
127 let area = Rect::new(0, 0, width, height);
128 let mut buf = Buffer::empty(area);
129 AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf);
130
131 (0..height)
132 .map(|y| {
133 (0..width)
134 .map(|x| buf[(x, y)].symbol())
135 .collect::<String>()
136 })
137 .collect()
138 }
139
140 #[test]
141 fn aligned_rows_draw_a_straight_run() {
142 assert_eq!(render(1, 1, 3, 3), [" ", "───", " "]);
143 }
144
145 // Descending: leave the left pane, turn down, arrive on the lower row.
146 #[test]
147 fn descending_link_turns_down() {
148 assert_eq!(render(0, 2, 3, 3), ["─┐ ", "", " └─"]);
149 }
150
151 // Ascending is the mirror image. Reusing the descending corners here would
152 // draw an elbow pointing the wrong way, which is the kind of thing that
153 // looks fine until you see it next to its opposite.
154 #[test]
155 fn ascending_link_turns_up() {
156 assert_eq!(render(2, 0, 3, 3), [" ┌─", "", "─┘ "]);
157 }
158
159 #[test]
160 fn adjacent_rows_need_no_vertical_run() {
161 assert_eq!(render(0, 1, 3, 2), ["─┐ ", " └─"]);
162 }
163
164 // A row scrolled out of view must not be drawn to. Clamping would point
165 // the connector at whatever sits at the pane edge.
166 #[test]
167 fn out_of_range_rows_draw_nothing() {
168 assert_eq!(render(0, 9, 3, 3), [" ", " ", " "]);
169 assert_eq!(render(9, 0, 3, 3), [" ", " ", " "]);
170 }
171
172 #[test]
173 fn a_gutter_too_narrow_to_hold_an_elbow_draws_nothing() {
174 assert_eq!(render(0, 1, 2, 2), [" ", " "]);
175 }
176
177 // Wider gutters keep the vertical run centered and extend the horizontals.
178 #[test]
179 fn wider_gutters_extend_the_horizontal_runs() {
180 assert_eq!(render(0, 2, 5, 3), ["──┐ ", "", " └──"]);
181 }
182 }
183