Skip to main content

slt/context/widgets_display/
gutter.rs

1// Scrollable container variant with a per-line left gutter and search-style
2// highlight rendering.
3//
4// Introduced in v0.20.0 (#235). Companion to the existing `scrollable` /
5// `scroll_col` / `scroll_row` widgets in `layout.rs` and the `ScrollState`
6// highlight extensions in `widgets/collections.rs`.
7//
8// API consistency pass (v0.20.0): the four+ positional args were collapsed
9// into a [`GutterOpts<G>`] struct so callers don't have to remember argument
10// order. The 90% case (line numbers) gets a [`GutterOpts::line_numbers`]
11// shortcut so most callers never write the closure manually.
12
13use super::*;
14
15/// Options for [`Context::scrollable_with_gutter`].
16///
17/// Carries the bookkeeping arguments together so call sites become readable:
18///
19/// ```no_run
20/// # use slt::{GutterOpts, ScrollState};
21/// # let mut scroll = ScrollState::default();
22/// # slt::run(|ui: &mut slt::Context| {
23/// // 90% case — automatic line numbers.
24/// ui.scrollable_with_gutter(
25///     &mut scroll,
26///     GutterOpts::line_numbers(120, 24),
27///     |ui, line| { ui.text(format!("line {line}")); },
28/// );
29///
30/// // Custom gutter labels.
31/// ui.scrollable_with_gutter(
32///     &mut scroll,
33///     GutterOpts::new(120, 24, |i| if i == 7 { "!".to_string() } else { String::new() }),
34///     |ui, line| { ui.text(format!("line {line}")); },
35/// );
36/// # });
37/// ```
38pub struct GutterOpts<G> {
39    /// Total number of content lines.
40    pub total_lines: usize,
41    /// Viewport height in rows.
42    pub viewport_height: u32,
43    /// Closure that returns the gutter label for a given absolute line index.
44    pub gutter_fn: G,
45}
46
47impl<G> GutterOpts<G>
48where
49    G: Fn(usize) -> String,
50{
51    /// Build options with an explicit gutter labeling closure.
52    pub fn new(total_lines: usize, viewport_height: u32, gutter_fn: G) -> Self {
53        Self {
54            total_lines,
55            viewport_height,
56            gutter_fn,
57        }
58    }
59}
60
61impl GutterOpts<fn(usize) -> String> {
62    /// Shortcut for the 90% case: render 1-based line numbers in the gutter.
63    ///
64    /// Equivalent to `GutterOpts::new(total, viewport, |i| format!("{}", i + 1))`
65    /// but uses a function pointer to avoid forcing the caller to name the
66    /// closure type.
67    pub fn line_numbers(total_lines: usize, viewport_height: u32) -> Self {
68        fn label(i: usize) -> String {
69            format!("{}", i + 1)
70        }
71        Self {
72            total_lines,
73            viewport_height,
74            gutter_fn: label,
75        }
76    }
77}
78
79impl Context {
80    /// Scrollable column with a left gutter rendered per visible line.
81    ///
82    /// `state` is the active scroll state. `opts` carries `total_lines`,
83    /// `viewport_height`, and the gutter labeling closure (use
84    /// [`GutterOpts::line_numbers`] for the common case). `body_fn` is invoked
85    /// for each visible line and renders that line's content. Highlighted
86    /// lines (set via [`ScrollState::set_highlights`]) receive an accent
87    /// background.
88    ///
89    /// Returns a [`GutterResponse`] with the current highlight index and
90    /// total highlight count for callers wiring up `n` / `N` search-result
91    /// navigation keys.
92    ///
93    /// # Example
94    ///
95    /// ```no_run
96    /// # use slt::{GutterOpts, HighlightRange, ScrollState};
97    /// # let mut scroll = ScrollState::new();
98    /// # scroll.set_highlights(&[HighlightRange::line(7), HighlightRange::line(15)]);
99    /// # let lines: Vec<&str> = vec![];
100    /// # slt::run(|ui: &mut slt::Context| {
101    /// let r = ui.scrollable_with_gutter(
102    ///     &mut scroll,
103    ///     GutterOpts::line_numbers(lines.len(), 10),
104    ///     |ui, abs_line| {
105    ///         if let Some(line) = lines.get(abs_line) {
106    ///             ui.text(*line);
107    ///         }
108    ///     },
109    /// );
110    /// if let Some(i) = r.current_highlight {
111    ///     // show "match i of N" status
112    /// }
113    /// # });
114    /// ```
115    pub fn scrollable_with_gutter<G, F>(
116        &mut self,
117        state: &mut ScrollState,
118        opts: GutterOpts<G>,
119        mut f: F,
120    ) -> GutterResponse
121    where
122        G: Fn(usize) -> String,
123        F: FnMut(&mut Context, usize),
124    {
125        let GutterOpts {
126            total_lines,
127            viewport_height,
128            gutter_fn,
129        } = opts;
130
131        // Sync state's bounds and clamp offset.
132        state.set_bounds(total_lines as u32, viewport_height);
133        let max_offset = total_lines.saturating_sub(viewport_height as usize);
134        state.offset = state.offset.min(max_offset);
135
136        // Wheel scroll consumption — mirror the standard `scrollable` widget.
137        let next_id = self.rollback.interaction_count;
138        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
139            self.gutter_consume_wheel(rect, state);
140        }
141
142        // Compute gutter width across visible lines.
143        let visible_count =
144            (viewport_height as usize).min(total_lines.saturating_sub(state.offset));
145        let mut gutter_w = 1usize;
146        for i in 0..visible_count {
147            let abs = state.offset + i;
148            let label = gutter_fn(abs);
149            let w = UnicodeWidthStr::width(label.as_str());
150            if w > gutter_w {
151                gutter_w = w;
152            }
153        }
154
155        let highlights: Vec<HighlightRange> = state.highlights().to_vec();
156        let current = state.current_highlight();
157        let theme = self.theme;
158
159        let response = self.row(|ui| {
160            // Gutter column.
161            let _ = ui.container().w(gutter_w as u32 + 1).col(|ui| {
162                for i in 0..visible_count {
163                    let abs = state.offset + i;
164                    let label = gutter_fn(abs);
165                    let label_w = UnicodeWidthStr::width(label.as_str());
166                    let pad = gutter_w.saturating_sub(label_w);
167                    let mut padded = String::with_capacity(label.len() + pad + 1);
168                    for _ in 0..pad {
169                        padded.push(' ');
170                    }
171                    padded.push_str(&label);
172                    padded.push(' ');
173
174                    let hit = highlights.iter().enumerate().find(|(_, h)| h.contains(abs));
175                    let style = match hit {
176                        Some((idx, _)) if Some(idx) == current => {
177                            Style::new().fg(theme.bg).bg(theme.accent).bold()
178                        }
179                        Some(_) => Style::new().fg(theme.text).bg(theme.surface_hover),
180                        None => Style::new().fg(theme.text_dim),
181                    };
182                    ui.styled(padded, style);
183                }
184            });
185
186            // Content column. Each visible line is rendered by the closure;
187            // highlights receive a background accent on the entire row.
188            let _ = ui.container().grow(1).col(|ui| {
189                for i in 0..visible_count {
190                    let abs = state.offset + i;
191                    let hit = highlights.iter().enumerate().find(|(_, h)| h.contains(abs));
192                    match hit {
193                        Some((idx, _)) if Some(idx) == current => {
194                            let _ = ui.container().bg(theme.surface_hover).row(|ui| f(ui, abs));
195                        }
196                        Some(_) => {
197                            let _ = ui.container().bg(theme.surface).row(|ui| f(ui, abs));
198                        }
199                        None => {
200                            let _ = ui.row(|ui| f(ui, abs));
201                        }
202                    }
203                }
204            });
205        });
206
207        GutterResponse {
208            response,
209            current_highlight: current,
210            total_highlights: highlights.len(),
211        }
212    }
213
214    fn gutter_consume_wheel(&mut self, rect: Rect, state: &mut ScrollState) {
215        let mut consumed: Vec<usize> = Vec::new();
216        let delta = self.scroll_lines_per_event as usize;
217        for (i, mouse) in self.mouse_events_in_rect(rect) {
218            match mouse.kind {
219                MouseKind::ScrollUp => {
220                    state.scroll_up(delta);
221                    consumed.push(i);
222                }
223                MouseKind::ScrollDown => {
224                    state.scroll_down(delta);
225                    consumed.push(i);
226                }
227                _ => {}
228            }
229        }
230        self.consume_indices(consumed);
231    }
232}