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 {
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.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 use crate::theme::Mode;
103 use ratatui::style::Color;
104
105 fn theme() -> Theme {
106 Theme {
107 mode: Mode::Dark,
108 surface_page: Color::Rgb(0, 0, 0),
109 surface_raised: Color::Rgb(1, 1, 1),
110 surface_sunken: Color::Rgb(2, 2, 2),
111 surface_overlay: Color::Rgb(3, 3, 3),
112 content_primary: Color::Rgb(4, 4, 4),
113 content_secondary: Color::Rgb(5, 5, 5),
114 content_muted: Color::Rgb(6, 6, 6),
115 action_primary: Color::Rgb(7, 7, 7),
116 status_danger: Color::Rgb(8, 8, 8),
117 status_success: Color::Rgb(9, 9, 9),
118 status_warning: Color::Rgb(10, 10, 10),
119 status_info: Color::Rgb(11, 11, 11),
120 line_border: Color::Rgb(12, 12, 12),
121 border_subtle: Color::Rgb(13, 13, 13),
122 border_strong: Color::Rgb(14, 14, 14),
123 category: [Color::Rgb(15, 15, 15); 6],
124 }
125 }
126
127 /// Render a connector into a `width`x`height` gutter and read it back as
128 /// rows of text.
129 fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec<String> {
130 let theme = theme();
131 let area = Rect::new(0, 0, width, height);
132 let mut buf = Buffer::empty(area);
133 AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf);
134
135 (0..height)
136 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
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