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}