Skip to main content

telar_ui_core/
line_gutter.rs

1use std::rc::Rc;
2
3use geometry_core::Rect;
4use layout_core::{LayoutError, LayoutStyle};
5use platform_core::Event;
6use reactive_core::{Effect, effect};
7use renderer_core::TextStyle;
8use ui_tree::{Component, EventResult, RenderNode};
9
10use crate::context::{mark_dirty, new_measured_leaf};
11use crate::impl_leaf_widget;
12use crate::layout_leaf::LayoutLeaf;
13
14/// A width so large the shaper never soft-wraps a line number.
15const NO_WRAP_WIDTH: f32 = 1.0e6;
16
17/// A line-number gutter for a code editor: the column "1\n2\n3…" drawn top-aligned with the same line height a
18/// [`TextArea`](crate::TextArea) uses, so line *n* here sits exactly on line *n* of the editor. Place it beside
19/// the editor inside the same scroll (so they scroll together) and give both the same `font_size`. It measures
20/// its own width from the widest number and its height from the line count, re-measuring reactively as the
21/// count changes. Toggle it by collapsing its node (`set_display`) inside a [`ClippedItem`](crate::ClippedItem)
22/// so a hidden gutter both takes no width and draws nothing.
23pub struct LineGutter {
24    line_count: Rc<dyn Fn() -> usize>,
25    style: Rc<dyn Fn() -> TextStyle>,
26    leaf: LayoutLeaf,
27    // Re-measures the leaf whenever the line count changes (a digit more → wider; a line more → taller), so the
28    // gutter tracks the editor as it grows. Kept alive for the widget's life.
29    _remeasure: Effect,
30}
31
32impl LineGutter {
33    pub fn new(
34        line_count: impl Fn() -> usize + 'static,
35        layout_style: LayoutStyle,
36        style_fn: impl Fn() -> TextStyle + 'static,
37    ) -> Result<Self, LayoutError> {
38        let line_count: Rc<dyn Fn() -> usize> = Rc::new(line_count);
39        let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
40
41        let measure_count = Rc::clone(&line_count);
42        let measure_style = Rc::clone(&style);
43        let measure = Box::new(move |_max_width: f32| {
44            let s = (measure_style)();
45            let line_h = s.font_size * renderer_text::LINE_HEIGHT_FACTOR;
46            let n = (measure_count)().max(1);
47            // Width of the widest (last) number; height from the line count — matching the editor's own metric.
48            let width = renderer_text::measure_text(&n.to_string(), NO_WRAP_WIDTH, &s).0;
49            (width, n as f32 * line_h)
50        });
51        let (node, rect) = new_measured_leaf(layout_style, measure)?;
52        let remeasure = {
53            let line_count = Rc::clone(&line_count);
54            effect(move || {
55                // Tracked read of the count source, so a change re-measures the leaf.
56                let _ = (line_count)();
57                mark_dirty(node).ok();
58            })
59        };
60        Ok(Self {
61            line_count,
62            style,
63            leaf: LayoutLeaf { node, rect },
64            _remeasure: remeasure,
65        })
66    }
67}
68
69impl Component for LineGutter {
70    fn view(&self) -> RenderNode {
71        let style = (self.style)();
72        let line_h = style.font_size * renderer_text::LINE_HEIGHT_FACTOR;
73        let n = (self.line_count)().max(1);
74        let mut numbers = String::new();
75        for i in 1..=n {
76            if i > 1 {
77                numbers.push('\n');
78            }
79            numbers.push_str(&i.to_string());
80        }
81        let r = self.leaf.rect.get();
82        // Draw from the leaf's top-left (like a `TextArea`), each line at `line * line_h` — never optically
83        // centered — so the numbers line up with the editor even when the leaf is stretched taller than them.
84        let full = Rect {
85            x: 0.0,
86            y: 0.0,
87            width: r.width.max(1.0),
88            height: (n as f32 * line_h).max(line_h),
89        };
90        self.leaf
91            .at_layout_position(RenderNode::text(numbers, full, style))
92    }
93
94    fn on_event(&mut self, _event: &Event) -> EventResult {
95        EventResult::Ignored
96    }
97
98    fn debug_name(&self) -> &'static str {
99        "LineGutter"
100    }
101}
102
103impl_leaf_widget!(LineGutter);
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::context::{compute_layout, new_container, reset_layout_runtime};
109    use layout_core::AvailableSpace;
110    use reactive_core::{RwSignal, signal};
111    use renderer_core::Color;
112
113    fn gutter(count: RwSignal<usize>) -> LineGutter {
114        LineGutter::new(
115            move || count.get(),
116            LayoutStyle::new(),
117            || TextStyle::new(14.0, Color::BLACK),
118        )
119        .unwrap()
120    }
121
122    // The gutter's measured height tracks the line count at the editor's line height, so its rows stay in step
123    // with the editor as lines are added.
124    #[test]
125    fn height_tracks_line_count() {
126        reset_layout_runtime();
127        let count = signal(3usize);
128        let g = gutter(count.clone());
129        let rect = g.leaf.rect;
130        let root = new_container(
131            LayoutStyle::new().flex_column().width(200.0),
132            &[g.leaf.node],
133        )
134        .unwrap();
135        let line_h = 14.0 * renderer_text::LINE_HEIGHT_FACTOR;
136        compute_layout(
137            root,
138            AvailableSpace::Definite(200.0),
139            AvailableSpace::MaxContent,
140        )
141        .unwrap();
142        assert!(
143            (rect.get().height - 3.0 * line_h).abs() < 0.5,
144            "3 lines: {:?}",
145            rect.get()
146        );
147
148        count.set(10);
149        compute_layout(
150            root,
151            AvailableSpace::Definite(200.0),
152            AvailableSpace::MaxContent,
153        )
154        .unwrap();
155        assert!(
156            (rect.get().height - 10.0 * line_h).abs() < 0.5,
157            "grew to 10 lines: {:?}",
158            rect.get()
159        );
160        // A two-digit count is wider than a one-digit one, so the gutter reserved more width.
161        assert!(
162            rect.get().width > 0.0,
163            "gutter reserves a width: {:?}",
164            rect.get()
165        );
166    }
167}