//! The link between two panes: an elbow drawn through the gutter, joining a //! row on the left to the row it is paired with on the right. //! //! One connector at a time, deliberately. Drawing every pairing at once is the //! obvious reading of the matching-quiz idea and it does not survive contact //! with a terminal: lines cross, a cell can only hold one glyph, and past //! three or four pairs the picture is unreadable. Lighting only the focused //! pairing gives the same "these two are joined" reading, never crosses //! anything, and needs no crossing-glyph logic. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::widgets::Widget; use crate::theme::Theme; /// A connector between `from_y` on the left and `to_y` on the right, both in /// absolute buffer rows. pub struct AlloyConnector<'a> { theme: &'a Theme, from_y: u16, to_y: u16, } impl<'a> AlloyConnector<'a> { pub fn new(theme: &'a Theme, from_y: u16, to_y: u16) -> Self { Self { theme, from_y, to_y, } } } impl Widget for AlloyConnector<'_> { fn render(self, area: Rect, buf: &mut Buffer) { // Needs a column to leave, a column to run down, and a column to // arrive; narrower than that and there is nothing to draw. if area.width < 3 || area.height == 0 { return; } let top = area.y; let bottom = area.y + area.height - 1; // A row scrolled out of its pane has nothing to point at. Clamping // instead would draw a connector to a row the user cannot see, which // reads as a link to whatever happens to be at the edge. if !(top..=bottom).contains(&self.from_y) || !(top..=bottom).contains(&self.to_y) { return; } let style = Style::default() .fg(self.theme.border_strong) .bg(self.theme.surface_page); let mid = area.x + area.width / 2; let last = area.x + area.width - 1; let mut put = |x: u16, y: u16, glyph: &str| { buf[(x, y)].set_symbol(glyph).set_style(style); }; if self.from_y == self.to_y { for x in area.x..=last { put(x, self.from_y, "─"); } return; } // Leave the left pane. for x in area.x..mid { put(x, self.from_y, "─"); } // Enter the right pane. for x in (mid + 1)..=last { put(x, self.to_y, "─"); } let (upper, lower) = if self.from_y < self.to_y { (self.from_y, self.to_y) } else { (self.to_y, self.from_y) }; for y in (upper + 1)..lower { put(mid, y, "│"); } // Corners: the glyph at each end depends on which way the run turns. let (from_corner, to_corner) = if self.from_y < self.to_y { ("┐", "└") } else { ("┘", "┌") }; put(mid, self.from_y, from_corner); put(mid, self.to_y, to_corner); } } #[cfg(test)] mod tests { use super::*; use crate::theme::Mode; use ratatui::style::Color; fn theme() -> Theme { Theme { mode: Mode::Dark, surface_page: Color::Rgb(0, 0, 0), surface_raised: Color::Rgb(1, 1, 1), surface_sunken: Color::Rgb(2, 2, 2), surface_overlay: Color::Rgb(3, 3, 3), content_primary: Color::Rgb(4, 4, 4), content_secondary: Color::Rgb(5, 5, 5), content_muted: Color::Rgb(6, 6, 6), action_primary: Color::Rgb(7, 7, 7), status_danger: Color::Rgb(8, 8, 8), status_success: Color::Rgb(9, 9, 9), status_warning: Color::Rgb(10, 10, 10), status_info: Color::Rgb(11, 11, 11), line_border: Color::Rgb(12, 12, 12), border_subtle: Color::Rgb(13, 13, 13), border_strong: Color::Rgb(14, 14, 14), category: [Color::Rgb(15, 15, 15); 6], } } /// Render a connector into a `width`x`height` gutter and read it back as /// rows of text. fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec { let theme = theme(); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf); (0..height) .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) .collect() } #[test] fn aligned_rows_draw_a_straight_run() { assert_eq!(render(1, 1, 3, 3), [" ", "───", " "]); } // Descending: leave the left pane, turn down, arrive on the lower row. #[test] fn descending_link_turns_down() { assert_eq!(render(0, 2, 3, 3), ["─┐ ", " │ ", " └─"]); } // Ascending is the mirror image. Reusing the descending corners here would // draw an elbow pointing the wrong way, which is the kind of thing that // looks fine until you see it next to its opposite. #[test] fn ascending_link_turns_up() { assert_eq!(render(2, 0, 3, 3), [" ┌─", " │ ", "─┘ "]); } #[test] fn adjacent_rows_need_no_vertical_run() { assert_eq!(render(0, 1, 3, 2), ["─┐ ", " └─"]); } // A row scrolled out of view must not be drawn to. Clamping would point // the connector at whatever sits at the pane edge. #[test] fn out_of_range_rows_draw_nothing() { assert_eq!(render(0, 9, 3, 3), [" ", " ", " "]); assert_eq!(render(9, 0, 3, 3), [" ", " ", " "]); } #[test] fn a_gutter_too_narrow_to_hold_an_elbow_draws_nothing() { assert_eq!(render(0, 1, 2, 2), [" ", " "]); } // Wider gutters keep the vertical run centered and extend the horizontals. #[test] fn wider_gutters_extend_the_horizontal_runs() { assert_eq!(render(0, 2, 5, 3), ["──┐ ", " │ ", " └──"]); } }