Skip to main content

slt/context/widgets_interactive/
rich_markdown.rs

1use super::*;
2use crate::{DEFAULT_CHORD_TIMEOUT_TICKS, RichLogState};
3
4impl Context {
5    /// Render a scrollable rich log view with styled entries.
6    pub fn rich_log(&mut self, state: &mut RichLogState) -> Response {
7        let focused = self.register_focusable();
8        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
9
10        let widget_height = if response.rect.height > 0 {
11            response.rect.height as usize
12        } else {
13            self.area_height as usize
14        };
15        let viewport_height = widget_height.saturating_sub(2);
16        let effective_height = if viewport_height == 0 {
17            state.len().max(1)
18        } else {
19            viewport_height
20        };
21        let show_indicator = state.len() > effective_height;
22        let visible_rows = if show_indicator {
23            effective_height.saturating_sub(1).max(1)
24        } else {
25            effective_height
26        };
27        let max_offset = state.len().saturating_sub(visible_rows);
28        if state.auto_scroll && state.scroll_offset == usize::MAX {
29            state.scroll_offset = max_offset;
30        } else {
31            state.scroll_offset = state.scroll_offset.min(max_offset);
32        }
33        let old_offset = state.scroll_offset;
34
35        if focused {
36            let mut consumed_indices = Vec::new();
37            for (i, key) in self.available_key_presses() {
38                match key.code {
39                    KeyCode::Up | KeyCode::Char('k') => {
40                        state.scroll_offset = state.scroll_offset.saturating_sub(1);
41                        consumed_indices.push(i);
42                    }
43                    KeyCode::Down | KeyCode::Char('j') => {
44                        state.scroll_offset = (state.scroll_offset + 1).min(max_offset);
45                        consumed_indices.push(i);
46                    }
47                    KeyCode::PageUp => {
48                        state.scroll_offset = state.scroll_offset.saturating_sub(10);
49                        consumed_indices.push(i);
50                    }
51                    KeyCode::PageDown => {
52                        state.scroll_offset = (state.scroll_offset + 10).min(max_offset);
53                        consumed_indices.push(i);
54                    }
55                    KeyCode::Home => {
56                        state.scroll_offset = 0;
57                        consumed_indices.push(i);
58                    }
59                    KeyCode::End => {
60                        state.scroll_offset = max_offset;
61                        consumed_indices.push(i);
62                    }
63                    _ => {}
64                }
65            }
66            self.consume_indices(consumed_indices);
67        }
68
69        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
70            let mut consumed = Vec::new();
71            for (i, mouse) in self.mouse_events_in_rect(rect) {
72                let delta = self.scroll_lines_per_event as usize;
73                match mouse.kind {
74                    MouseKind::ScrollUp => {
75                        state.scroll_offset = state.scroll_offset.saturating_sub(delta);
76                        consumed.push(i);
77                    }
78                    MouseKind::ScrollDown => {
79                        state.scroll_offset = (state.scroll_offset + delta).min(max_offset);
80                        consumed.push(i);
81                    }
82                    _ => {}
83                }
84            }
85            self.consume_indices(consumed);
86        }
87
88        state.scroll_offset = state.scroll_offset.min(max_offset);
89        let start = state
90            .scroll_offset
91            .min(state.len().saturating_sub(visible_rows));
92        let end = (start + visible_rows).min(state.len());
93
94        self.commands
95            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
96                direction: Direction::Column,
97                gap: 0,
98                align: Align::Start,
99                align_self: None,
100                justify: Justify::Start,
101                border: Some(Border::Single),
102                border_sides: BorderSides::all(),
103                border_style: Style::new().fg(self.theme.border),
104                bg_color: None,
105                padding: Padding::default(),
106                margin: Margin::default(),
107                constraints: Constraints::default(),
108                title: None,
109                grow: 0,
110                group_name: None,
111            })));
112
113        for entry in state.entries().skip(start).take(end.saturating_sub(start)) {
114            self.commands.push(Command::RichText {
115                segments: entry.segments.clone(),
116                wrap: false,
117                align: Align::Start,
118                margin: Margin::default(),
119                constraints: Constraints::default(),
120            });
121        }
122
123        if show_indicator {
124            let end_pos = end.min(state.len());
125            let line = format!("{}-{} / {}", start.saturating_add(1), end_pos, state.len());
126            self.styled(line, Style::new().dim().fg(self.theme.text_dim));
127        }
128
129        self.commands.push(Command::EndContainer);
130        self.rollback.last_text_idx = None;
131        response.changed = state.scroll_offset != old_offset;
132        response
133    }
134
135    // ── virtual list ─────────────────────────────────────────────────
136
137    /// Render a virtual list that only renders visible items.
138    ///
139    /// `total` is the number of items. `visible_height` limits how many rows
140    /// are rendered. The closure `f` is called only for visible indices.
141    ///
142    /// This is the uniform fixed-height fast path: every item is treated as
143    /// exactly one row. For chat/feed bubbles of differing heights see
144    /// [`virtual_list_variable`](Context::virtual_list_variable).
145    pub fn virtual_list(
146        &mut self,
147        state: &mut ListState,
148        visible_height: u32,
149        f: impl Fn(&mut Context, usize),
150    ) -> Response {
151        self.virtual_list_impl(state, visible_height, false, f)
152    }
153
154    /// Variable-height variant of [`virtual_list`](Context::virtual_list).
155    ///
156    /// Each item's height (in rows) comes from
157    /// [`ListState::set_item_heights`](crate::widgets::ListState::set_item_heights);
158    /// the visible range is computed so the rendered items fill at most
159    /// `visible_height` rows starting from the current viewport. This is the
160    /// chat/feed use case where bubbles vary in height (a one-line reply next
161    /// to a 30-line code block). When no per-item heights are set it falls back
162    /// to the uniform fast path and produces output identical to
163    /// [`virtual_list`](Context::virtual_list). Rendering remains `O(visible)`:
164    /// only items in the computed range invoke `f`, prefix-sum lookups are
165    /// `O(log n)` and the prefix-sum rebuild is `O(n)` gated behind a dirty
166    /// flag.
167    ///
168    /// An item taller than `visible_height` renders from its top and is never
169    /// skipped. `PageUp`/`PageDown` move the selection by the number of *items*
170    /// that fill `visible_height` *rows* from the current position. The
171    /// "↑ N more / ↓ N more" affordances continue to count *items*.
172    ///
173    /// # Example
174    ///
175    /// ```no_run
176    /// use slt::widgets::ListState;
177    ///
178    /// let mut state = ListState::new(vec!["short reply", "long\ncode\nblock", "ok"])
179    ///     .with_item_heights(vec![1, 3, 1]);
180    ///
181    /// slt::run(|ui| {
182    ///     ui.virtual_list_variable(&mut state, 10, |ui, idx| {
183    ///         ui.text(format!("bubble {idx}"));
184    ///     });
185    /// })?;
186    /// # Ok::<(), std::io::Error>(())
187    /// ```
188    ///
189    /// Available since `0.21.0`.
190    pub fn virtual_list_variable(
191        &mut self,
192        state: &mut ListState,
193        visible_height: u32,
194        f: impl Fn(&mut Context, usize),
195    ) -> Response {
196        self.virtual_list_impl(state, visible_height, true, f)
197    }
198
199    fn virtual_list_impl(
200        &mut self,
201        state: &mut ListState,
202        visible_height: u32,
203        variable: bool,
204        f: impl Fn(&mut Context, usize),
205    ) -> Response {
206        if state.is_empty() {
207            return Response::none();
208        }
209        state.selected = state.selected.min(state.len().saturating_sub(1));
210        let use_heights = variable && state.has_item_heights();
211        let focused = self.register_focusable();
212        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
213        let old_selected = state.selected;
214
215        if focused {
216            let mut consumed_indices = Vec::new();
217            for (i, key) in self.available_key_presses() {
218                match key.code {
219                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
220                        let max_index = state.len().saturating_sub(1);
221                        let _ =
222                            handle_vertical_nav(&mut state.selected, max_index, key.code.clone());
223                        consumed_indices.push(i);
224                    }
225                    KeyCode::PageUp => {
226                        state.selected = if use_heights {
227                            page_up_target(state, state.selected, visible_height)
228                        } else {
229                            state.selected.saturating_sub(visible_height as usize)
230                        };
231                        consumed_indices.push(i);
232                    }
233                    KeyCode::PageDown => {
234                        state.selected = if use_heights {
235                            page_down_target(state, state.selected, visible_height)
236                        } else {
237                            (state.selected + visible_height as usize)
238                                .min(state.len().saturating_sub(1))
239                        };
240                        consumed_indices.push(i);
241                    }
242                    KeyCode::Home => {
243                        state.selected = 0;
244                        consumed_indices.push(i);
245                    }
246                    KeyCode::End => {
247                        state.selected = state.len().saturating_sub(1);
248                        consumed_indices.push(i);
249                    }
250                    _ => {}
251                }
252            }
253            self.consume_indices(consumed_indices);
254        }
255
256        let vh = visible_height as usize;
257        let (start, end) = if use_heights {
258            row_visible_range(state, vh)
259        } else {
260            // Uniform fixed-height path — byte-identical to the original
261            // `virtual_list`: one item == one row.
262            //
263            // Clamp viewport_offset so `selected` stays inside [offset, offset + vh)
264            // without forcing the cursor onto the bottom row when scrolling down.
265            if state.selected < state.viewport_offset {
266                state.viewport_offset = state.selected;
267            }
268            if vh > 0 && state.selected >= state.viewport_offset + vh {
269                state.viewport_offset = state.selected - vh + 1;
270            }
271            let start = state.viewport_offset;
272            (start, (start + vh).min(state.len()))
273        };
274
275        self.commands
276            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
277                direction: Direction::Column,
278                gap: 0,
279                align: Align::Start,
280                align_self: None,
281                justify: Justify::Start,
282                border: None,
283                border_sides: BorderSides::all(),
284                border_style: Style::new().fg(self.theme.border),
285                bg_color: None,
286                padding: Padding::default(),
287                margin: Margin::default(),
288                constraints: Constraints::default(),
289                title: None,
290                grow: 0,
291                group_name: None,
292            })));
293
294        if start > 0 {
295            let hidden = start.to_string();
296            let mut line = String::with_capacity(hidden.len() + 10);
297            line.push_str("  ↑ ");
298            line.push_str(&hidden);
299            line.push_str(" more");
300            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
301        }
302
303        for idx in start..end {
304            f(self, idx);
305        }
306
307        let remaining = state.len().saturating_sub(end);
308        if remaining > 0 {
309            let hidden = remaining.to_string();
310            let mut line = String::with_capacity(hidden.len() + 10);
311            line.push_str("  ↓ ");
312            line.push_str(&hidden);
313            line.push_str(" more");
314            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
315        }
316
317        self.commands.push(Command::EndContainer);
318        self.rollback.last_text_idx = None;
319        response.changed = state.selected != old_selected;
320        response
321    }
322
323    // ── command palette ──────────────────────────────────────────────
324
325    /// Render a command palette overlay.
326    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Response {
327        if !state.open {
328            return Response::none();
329        }
330
331        state.last_selected = None;
332        let interaction_id = self.next_interaction_id();
333
334        let filtered: Vec<usize> = state.filtered_indices_cached().to_vec();
335        let sel = state.selected().min(filtered.len().saturating_sub(1));
336        state.set_selected(sel);
337
338        let mut consumed_indices = Vec::new();
339
340        for (i, key) in self.available_key_presses() {
341            match key.code {
342                KeyCode::Esc => {
343                    state.open = false;
344                    consumed_indices.push(i);
345                }
346                KeyCode::Up => {
347                    let s = state.selected();
348                    state.set_selected(s.saturating_sub(1));
349                    consumed_indices.push(i);
350                }
351                KeyCode::Down => {
352                    let filtered_len = state.filtered_indices_cached().len();
353                    let s = state.selected();
354                    state.set_selected((s + 1).min(filtered_len.saturating_sub(1)));
355                    consumed_indices.push(i);
356                }
357                KeyCode::Enter => {
358                    let filtered = state.filtered_indices_cached().to_vec();
359                    if let Some(&cmd_idx) = filtered.get(state.selected()) {
360                        state.last_selected = Some(cmd_idx);
361                        state.open = false;
362                    }
363                    consumed_indices.push(i);
364                }
365                KeyCode::Backspace => {
366                    if state.cursor > 0 {
367                        let byte_idx = byte_index_for_grapheme(&state.input, state.cursor - 1);
368                        let end_idx = byte_index_for_grapheme(&state.input, state.cursor);
369                        state.input.replace_range(byte_idx..end_idx, "");
370                        state.cursor -= 1;
371                        state.set_selected(0);
372                    }
373                    consumed_indices.push(i);
374                }
375                KeyCode::Char(ch) if !has_global_shortcut_modifier(key.modifiers) => {
376                    let byte_idx = byte_index_for_grapheme(&state.input, state.cursor);
377                    state.input.insert(byte_idx, ch);
378                    state.cursor = grapheme_count(&state.input[..byte_idx + ch.len_utf8()]);
379                    state.set_selected(0);
380                    consumed_indices.push(i);
381                }
382                _ => {}
383            }
384        }
385        for (i, text) in self.available_pastes() {
386            let inserted = text
387                .graphemes(true)
388                .filter(|cluster| {
389                    cluster
390                        .chars()
391                        .all(|ch| (ch as u32) >= 0x20 && ch != '\u{7f}')
392                })
393                .collect::<String>();
394            if !inserted.is_empty() {
395                let byte_idx = byte_index_for_grapheme(&state.input, state.cursor);
396                let inserted_end = byte_idx + inserted.len();
397                state.input.insert_str(byte_idx, &inserted);
398                state.cursor = grapheme_count(&state.input[..inserted_end]);
399                state.set_selected(0);
400            }
401            consumed_indices.push(i);
402        }
403        self.consume_indices(consumed_indices);
404
405        let filtered: Vec<usize> = state.filtered_indices_cached().to_vec();
406
407        let _ = self.modal(|ui| {
408            let primary = ui.theme.primary;
409            let palette_pad = ui.theme.spacing.xs();
410            let palette_input_padx = ui.theme.spacing.xs();
411            let _ = ui
412                .container()
413                .border(Border::Rounded)
414                .border_style(Style::new().fg(primary))
415                .p(palette_pad)
416                .max_w(60)
417                .col(|ui| {
418                    let border_color = ui.theme.primary;
419                    let _ = ui
420                        .bordered(Border::Rounded)
421                        .border_style(Style::new().fg(border_color))
422                        .px(palette_input_padx)
423                        .col(|ui| {
424                            let display = if state.input.is_empty() {
425                                "Type to search...".to_string()
426                            } else {
427                                state.input.clone()
428                            };
429                            let style = if state.input.is_empty() {
430                                Style::new().dim().fg(ui.theme.text_dim)
431                            } else {
432                                Style::new().fg(ui.theme.text)
433                            };
434                            ui.styled(display, style);
435                        });
436
437                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
438                        let cmd = &state.commands()[cmd_idx];
439                        let is_selected = list_idx == state.selected();
440                        let style = if is_selected {
441                            Style::new().bold().fg(ui.theme.primary)
442                        } else {
443                            Style::new().fg(ui.theme.text)
444                        };
445                        let prefix = if is_selected { "▸ " } else { "  " };
446                        let shortcut_text = cmd
447                            .shortcut
448                            .as_deref()
449                            .map(|s| {
450                                let mut text = String::with_capacity(s.len() + 4);
451                                text.push_str("  (");
452                                text.push_str(s);
453                                text.push(')');
454                                text
455                            })
456                            .unwrap_or_default();
457                        let mut line = String::with_capacity(
458                            prefix.len() + cmd.label.len() + shortcut_text.len(),
459                        );
460                        line.push_str(prefix);
461                        line.push_str(&cmd.label);
462                        line.push_str(&shortcut_text);
463                        ui.styled(line, style);
464                        if is_selected && !cmd.description.is_empty() {
465                            let mut desc = String::with_capacity(4 + cmd.description.len());
466                            desc.push_str("    ");
467                            desc.push_str(&cmd.description);
468                            ui.styled(desc, Style::new().dim().fg(ui.theme.text_dim));
469                        }
470                    }
471
472                    if filtered.is_empty() {
473                        ui.styled(
474                            "  No matching commands",
475                            Style::new().dim().fg(ui.theme.text_dim),
476                        );
477                    }
478                });
479        });
480
481        let mut response = self.response_for(interaction_id);
482        response.changed = state.last_selected.is_some();
483        response
484    }
485
486    // ── markdown ─────────────────────────────────────────────────────
487
488    /// Render a markdown string with basic formatting.
489    ///
490    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
491    /// unordered lists (`-`/`*`), ordered lists (`1.`), blockquotes (`>`),
492    /// horizontal rules (`---`), links (`[text](url)`), image placeholders
493    /// (`![alt](url)`), code blocks with syntax highlighting, and GFM-style
494    /// pipe tables. Paragraph text auto-wraps to container width.
495    pub fn markdown(&mut self, text: &str) -> Response {
496        self.commands
497            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
498                direction: Direction::Column,
499                gap: 0,
500                align: Align::Start,
501                align_self: None,
502                justify: Justify::Start,
503                border: None,
504                border_sides: BorderSides::all(),
505                border_style: Style::new().fg(self.theme.border),
506                bg_color: None,
507                padding: Padding::default(),
508                margin: Margin::default(),
509                constraints: Constraints::default(),
510                title: None,
511                grow: 0,
512                group_name: None,
513            })));
514        self.skip_interaction_slot();
515
516        let text_style = Style::new().fg(self.theme.text);
517        let bold_style = Style::new().fg(self.theme.text).bold();
518        let code_style = Style::new().fg(self.theme.accent);
519        let border_style = Style::new().fg(self.theme.border).dim();
520
521        let mut in_code_block = false;
522        let mut code_block_lang = String::new();
523        let mut code_block_lines: Vec<String> = Vec::new();
524        let mut table_lines: Vec<String> = Vec::new();
525
526        for line in text.lines() {
527            let trimmed = line.trim();
528
529            if in_code_block {
530                if trimmed.starts_with("```") {
531                    in_code_block = false;
532                    let code_content = code_block_lines.join("\n");
533                    let theme = self.theme;
534                    let code_pad = theme.spacing.xs();
535                    let highlighted: Option<Vec<Vec<(String, Style)>>> =
536                        crate::syntax::highlight_code(&code_content, &code_block_lang, &theme);
537                    let _ = self.container().bg(theme.surface).p(code_pad).col(|ui| {
538                        if let Some(ref hl_lines) = highlighted {
539                            for segs in hl_lines {
540                                if segs.is_empty() {
541                                    ui.text(" ");
542                                } else {
543                                    ui.line(|ui| {
544                                        for (t, s) in segs {
545                                            ui.styled(t, *s);
546                                        }
547                                    });
548                                }
549                            }
550                        } else {
551                            for cl in &code_block_lines {
552                                ui.styled(cl, code_style);
553                            }
554                        }
555                    });
556                    code_block_lang.clear();
557                    code_block_lines.clear();
558                } else {
559                    code_block_lines.push(line.to_string());
560                }
561                continue;
562            }
563
564            // Table row detection — collect lines starting with `|`
565            if trimmed.starts_with('|') && trimmed.matches('|').count() >= 2 {
566                table_lines.push(trimmed.to_string());
567                continue;
568            }
569            // Flush accumulated table rows when a non-table line is encountered
570            if !table_lines.is_empty() {
571                self.render_markdown_table(
572                    &table_lines,
573                    text_style,
574                    bold_style,
575                    code_style,
576                    border_style,
577                );
578                table_lines.clear();
579            }
580
581            if trimmed.is_empty() {
582                self.text(" ");
583                continue;
584            }
585            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
586                self.styled("─".repeat(40), border_style);
587                continue;
588            }
589            if let Some(quote) = trimmed.strip_prefix("> ") {
590                let quote_style = Style::new().fg(self.theme.text_dim).italic();
591                let bar_style = Style::new().fg(self.theme.border);
592                self.line(|ui| {
593                    ui.styled("│ ", bar_style);
594                    ui.styled(quote, quote_style);
595                });
596            } else if let Some(heading) = trimmed.strip_prefix("### ") {
597                self.styled(heading, Style::new().bold().fg(self.theme.accent));
598            } else if let Some(heading) = trimmed.strip_prefix("## ") {
599                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
600            } else if let Some(heading) = trimmed.strip_prefix("# ") {
601                self.styled(heading, Style::new().bold().fg(self.theme.primary));
602            } else if let Some(item) = trimmed
603                .strip_prefix("- ")
604                .or_else(|| trimmed.strip_prefix("* "))
605            {
606                self.line_wrap(|ui| {
607                    ui.styled("  • ", text_style);
608                    Self::render_md_inline_into(ui, item, text_style, bold_style, code_style);
609                });
610            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
611                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
612                if parts.len() == 2 {
613                    self.line_wrap(|ui| {
614                        let mut prefix = String::with_capacity(4 + parts[0].len());
615                        prefix.push_str("  ");
616                        prefix.push_str(parts[0]);
617                        prefix.push_str(". ");
618                        ui.styled(prefix, text_style);
619                        Self::render_md_inline_into(
620                            ui, parts[1], text_style, bold_style, code_style,
621                        );
622                    });
623                } else {
624                    self.text(trimmed);
625                }
626            } else if let Some(lang) = trimmed.strip_prefix("```") {
627                in_code_block = true;
628                code_block_lang = lang.trim().to_string();
629            } else {
630                self.render_md_inline(trimmed, text_style, bold_style, code_style);
631            }
632        }
633
634        if in_code_block && !code_block_lines.is_empty() {
635            for cl in &code_block_lines {
636                self.styled(cl, code_style);
637            }
638        }
639
640        // Flush any remaining table rows at end of input
641        if !table_lines.is_empty() {
642            self.render_markdown_table(
643                &table_lines,
644                text_style,
645                bold_style,
646                code_style,
647                border_style,
648            );
649        }
650
651        self.commands.push(Command::EndContainer);
652        self.rollback.last_text_idx = None;
653        Response::none()
654    }
655
656    /// Render a GFM-style pipe table collected from markdown lines.
657    fn render_markdown_table(
658        &mut self,
659        lines: &[String],
660        text_style: Style,
661        bold_style: Style,
662        code_style: Style,
663        border_style: Style,
664    ) {
665        if lines.is_empty() {
666            return;
667        }
668
669        // Separate header, separator, and data rows
670        let is_separator = |line: &str| -> bool {
671            let inner = line.trim_matches('|').trim();
672            !inner.is_empty()
673                && inner
674                    .chars()
675                    .all(|c| c == '-' || c == ':' || c == '|' || c == ' ')
676        };
677
678        let parse_row = |line: &str| -> Vec<String> {
679            let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
680            trimmed.split('|').map(|c| c.trim().to_string()).collect()
681        };
682
683        let mut header: Option<Vec<String>> = None;
684        let mut data_rows: Vec<Vec<String>> = Vec::new();
685        let mut found_separator = false;
686
687        for (i, line) in lines.iter().enumerate() {
688            if is_separator(line) {
689                found_separator = true;
690                continue;
691            }
692            if i == 0 && !found_separator {
693                header = Some(parse_row(line));
694            } else {
695                data_rows.push(parse_row(line));
696            }
697        }
698
699        // If no separator found, treat first row as header anyway
700        if !found_separator && header.is_none() && !data_rows.is_empty() {
701            header = Some(data_rows.remove(0));
702        }
703
704        // Calculate column count and widths
705        let all_rows: Vec<&Vec<String>> = header.iter().chain(data_rows.iter()).collect();
706        let col_count = all_rows.iter().map(|r| r.len()).max().unwrap_or(0);
707        if col_count == 0 {
708            return;
709        }
710        let mut col_widths = vec![0usize; col_count];
711        // Strip markdown formatting for accurate display-width calculation
712        let stripped_rows: Vec<Vec<String>> = all_rows
713            .iter()
714            .map(|row| row.iter().map(|c| Self::md_strip(c)).collect())
715            .collect();
716        for row in &stripped_rows {
717            for (i, cell) in row.iter().enumerate() {
718                if i < col_count {
719                    col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
720                }
721            }
722        }
723
724        // Top border ┌───┬───┐
725        let mut top = String::from("┌");
726        for (i, &w) in col_widths.iter().enumerate() {
727            for _ in 0..w + 2 {
728                top.push('─');
729            }
730            top.push(if i < col_count - 1 { '┬' } else { '┐' });
731        }
732        self.styled(&top, border_style);
733
734        // Header row │ H1 │ H2 │
735        if let Some(ref hdr) = header {
736            self.line(|ui| {
737                ui.styled("│", border_style);
738                for (i, w) in col_widths.iter().enumerate() {
739                    let raw = hdr.get(i).map(String::as_str).unwrap_or("");
740                    let display_text = Self::md_strip(raw);
741                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
742                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
743                    ui.styled(" ", bold_style);
744                    ui.styled(&display_text, bold_style);
745                    ui.styled(padding, bold_style);
746                    ui.styled(" │", border_style);
747                }
748            });
749
750            // Separator ├───┼───┤
751            let mut sep = String::from("├");
752            for (i, &w) in col_widths.iter().enumerate() {
753                for _ in 0..w + 2 {
754                    sep.push('─');
755                }
756                sep.push(if i < col_count - 1 { '┼' } else { '┤' });
757            }
758            self.styled(&sep, border_style);
759        }
760
761        // Data rows — render with inline formatting (bold, italic, code, links)
762        for row in &data_rows {
763            self.line(|ui| {
764                ui.styled("│", border_style);
765                for (i, w) in col_widths.iter().enumerate() {
766                    let raw = row.get(i).map(String::as_str).unwrap_or("");
767                    let display_text = Self::md_strip(raw);
768                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
769                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
770                    ui.styled(" ", text_style);
771                    Self::render_md_inline_into(ui, raw, text_style, bold_style, code_style);
772                    ui.styled(padding, text_style);
773                    ui.styled(" │", border_style);
774                }
775            });
776        }
777
778        // Bottom border └───┴───┘
779        let mut bot = String::from("└");
780        for (i, &w) in col_widths.iter().enumerate() {
781            for _ in 0..w + 2 {
782                bot.push('─');
783            }
784            bot.push(if i < col_count - 1 { '┴' } else { '┘' });
785        }
786        self.styled(&bot, border_style);
787    }
788
789    pub(crate) fn parse_inline_segments(
790        text: &str,
791        base: Style,
792        bold: Style,
793        code: Style,
794    ) -> Vec<(String, Style)> {
795        // All inline markers (`**`, `*`, `` ` ``) are single-byte ASCII, so
796        // byte-index slicing of `text` is safe — multi-byte chars in `inner`
797        // are never split. Avoids the `chars().collect::<Vec<_>>()` allocation
798        // and per-match `String` reconstructions of the prior implementation.
799        let mut segments: Vec<(String, Style)> = Vec::new();
800        let bytes = text.as_bytes();
801        let mut current = String::new();
802        let mut i: usize = 0;
803
804        while i < bytes.len() {
805            // Bold: **text**
806            if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
807                let after_open = i + 2;
808                if let Some(rel_end) = text[after_open..].find("**") {
809                    let close = after_open + rel_end;
810                    if !current.is_empty() {
811                        segments.push((std::mem::take(&mut current), base));
812                    }
813                    let inner = text[after_open..close].to_string();
814                    segments.push((inner, bold));
815                    i = close + 2;
816                    continue;
817                }
818            }
819
820            // Italic: *text* — skipped if part of a `**` run.
821            if bytes[i] == b'*'
822                && (i + 1 >= bytes.len() || bytes[i + 1] != b'*')
823                && (i == 0 || bytes[i - 1] != b'*')
824            {
825                let after_open = i + 1;
826                if let Some(rel_end) = text[after_open..].find('*') {
827                    let close = after_open + rel_end;
828                    if !current.is_empty() {
829                        segments.push((std::mem::take(&mut current), base));
830                    }
831                    let inner = text[after_open..close].to_string();
832                    segments.push((inner, base.italic()));
833                    i = close + 1;
834                    continue;
835                }
836            }
837
838            // Inline code: `text`
839            if bytes[i] == b'`' {
840                let after_open = i + 1;
841                if let Some(rel_end) = text[after_open..].find('`') {
842                    let close = after_open + rel_end;
843                    if !current.is_empty() {
844                        segments.push((std::mem::take(&mut current), base));
845                    }
846                    let inner = text[after_open..close].to_string();
847                    segments.push((inner, code));
848                    i = close + 1;
849                    continue;
850                }
851            }
852
853            // No marker — append one whole character (possibly multi-byte)
854            // and advance past it.
855            let ch = text[i..]
856                .chars()
857                .next()
858                .expect("non-empty tail past bounds check");
859            current.push(ch);
860            i += ch.len_utf8();
861        }
862
863        if !current.is_empty() {
864            segments.push((current, base));
865        }
866        segments
867    }
868
869    /// Render a markdown line with link/image support.
870    ///
871    /// Parses `[text](url)` as clickable OSC 8 links and `![alt](url)` as
872    /// image placeholders, delegating the rest to `parse_inline_segments`.
873    fn render_md_inline(
874        &mut self,
875        text: &str,
876        text_style: Style,
877        bold_style: Style,
878        code_style: Style,
879    ) {
880        let items = Self::split_md_links(text);
881
882        // Fast path: no links/images found
883        if items.len() == 1
884            && let MdInline::Text(ref t) = items[0]
885        {
886            let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
887            if segs.len() <= 1 {
888                self.text(text)
889                    .wrap()
890                    .fg(text_style.fg.unwrap_or(Color::Reset));
891            } else {
892                self.line_wrap(|ui| {
893                    for (s, st) in segs {
894                        ui.styled(s, st);
895                    }
896                });
897            }
898            return;
899        }
900
901        // Mixed content — line_wrap collects both Text and Link commands
902        self.line_wrap(|ui| {
903            for item in &items {
904                match item {
905                    MdInline::Text(t) => {
906                        let segs =
907                            Self::parse_inline_segments(t, text_style, bold_style, code_style);
908                        for (s, st) in segs {
909                            ui.styled(s, st);
910                        }
911                    }
912                    MdInline::Link { text, url } => {
913                        ui.link(text.clone(), url.clone());
914                    }
915                    MdInline::Image { alt, .. } => {
916                        // Render alt text only — matches md_strip() output for width consistency
917                        ui.styled(alt.as_str(), code_style);
918                    }
919                }
920            }
921        });
922    }
923
924    /// Emit inline markdown segments into an existing context.
925    ///
926    /// Unlike `render_md_inline` which wraps in its own `line_wrap`,
927    /// this emits raw commands into `ui` so callers can prepend a bullet
928    /// or prefix before calling this inside their own `line_wrap`.
929    fn render_md_inline_into(
930        ui: &mut Context,
931        text: &str,
932        text_style: Style,
933        bold_style: Style,
934        code_style: Style,
935    ) {
936        let items = Self::split_md_links(text);
937        for item in &items {
938            match item {
939                MdInline::Text(t) => {
940                    let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
941                    for (s, st) in segs {
942                        ui.styled(s, st);
943                    }
944                }
945                MdInline::Link { text, url } => {
946                    ui.link(text.clone(), url.clone());
947                }
948                MdInline::Image { alt, .. } => {
949                    ui.styled(alt.as_str(), code_style);
950                }
951            }
952        }
953    }
954
955    /// Split a markdown line into text, link, and image segments.
956    fn split_md_links(text: &str) -> Vec<MdInline> {
957        let chars: Vec<char> = text.chars().collect();
958        let mut items: Vec<MdInline> = Vec::new();
959        let mut current = String::new();
960        let mut i = 0;
961
962        while i < chars.len() {
963            // Image: ![alt](url)
964            if chars[i] == '!'
965                && i + 1 < chars.len()
966                && chars[i + 1] == '['
967                && let Some((alt, _url, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1)
968            {
969                if !current.is_empty() {
970                    items.push(MdInline::Text(std::mem::take(&mut current)));
971                }
972                items.push(MdInline::Image { alt });
973                i += 1 + consumed;
974                continue;
975            }
976            // Link: [text](url)
977            if chars[i] == '['
978                && let Some((link_text, url, consumed)) = Self::parse_md_bracket_paren(&chars, i)
979            {
980                if !current.is_empty() {
981                    items.push(MdInline::Text(std::mem::take(&mut current)));
982                }
983                items.push(MdInline::Link {
984                    text: link_text,
985                    url,
986                });
987                i += consumed;
988                continue;
989            }
990            current.push(chars[i]);
991            i += 1;
992        }
993        if !current.is_empty() {
994            items.push(MdInline::Text(current));
995        }
996        if items.is_empty() {
997            items.push(MdInline::Text(String::new()));
998        }
999        items
1000    }
1001
1002    /// Parse `[text](url)` starting at `chars[start]` which must be `[`.
1003    /// Returns `(text, url, chars_consumed)` or `None` if no match.
1004    fn parse_md_bracket_paren(chars: &[char], start: usize) -> Option<(String, String, usize)> {
1005        if start >= chars.len() || chars[start] != '[' {
1006            return None;
1007        }
1008        // Find closing ]
1009        let mut depth = 0i32;
1010        let mut bracket_end = None;
1011        for (j, &ch) in chars.iter().enumerate().skip(start) {
1012            if ch == '[' {
1013                depth += 1;
1014            } else if ch == ']' {
1015                depth -= 1;
1016                if depth == 0 {
1017                    bracket_end = Some(j);
1018                    break;
1019                }
1020            }
1021        }
1022        let bracket_end = bracket_end?;
1023        // Must be followed by (
1024        if bracket_end + 1 >= chars.len() || chars[bracket_end + 1] != '(' {
1025            return None;
1026        }
1027        // Find closing )
1028        let paren_start = bracket_end + 2;
1029        let mut paren_end = None;
1030        let mut paren_depth = 1i32;
1031        for (j, &ch) in chars.iter().enumerate().skip(paren_start) {
1032            if ch == '(' {
1033                paren_depth += 1;
1034            } else if ch == ')' {
1035                paren_depth -= 1;
1036                if paren_depth == 0 {
1037                    paren_end = Some(j);
1038                    break;
1039                }
1040            }
1041        }
1042        let paren_end = paren_end?;
1043        let text: String = chars[start + 1..bracket_end].iter().collect();
1044        let url: String = chars[paren_start..paren_end].iter().collect();
1045        let consumed = paren_end - start + 1;
1046        Some((text, url, consumed))
1047    }
1048
1049    /// Strip markdown inline formatting, returning plain display text.
1050    ///
1051    /// `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`,
1052    /// `[text](url)` → `text`, `![alt](url)` → `alt`.
1053    fn md_strip(text: &str) -> String {
1054        // Bracket/paren parsing for links/images still uses a `Vec<char>`
1055        // because the helper takes a char-slice; pre-build it once and reuse
1056        // the precomputed char→byte mapping for both code paths.
1057        let chars: Vec<char> = text.chars().collect();
1058        let char_to_byte = {
1059            let mut v = Vec::with_capacity(chars.len() + 1);
1060            let mut acc = 0usize;
1061            v.push(0);
1062            for ch in &chars {
1063                acc += ch.len_utf8();
1064                v.push(acc);
1065            }
1066            v
1067        };
1068        let bytes = text.as_bytes();
1069        let mut result = String::with_capacity(text.len());
1070        let mut ci: usize = 0;
1071
1072        while ci < chars.len() {
1073            // Image: ![alt](url) — char-based bracket scanner is reused as-is.
1074            if chars[ci] == '!'
1075                && ci + 1 < chars.len()
1076                && chars[ci + 1] == '['
1077                && let Some((alt, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci + 1)
1078            {
1079                result.push_str(&alt);
1080                ci += 1 + consumed;
1081                continue;
1082            }
1083            // Link: [text](url)
1084            if chars[ci] == '['
1085                && let Some((link_text, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci)
1086            {
1087                result.push_str(&link_text);
1088                ci += consumed;
1089                continue;
1090            }
1091
1092            let bi = char_to_byte[ci];
1093
1094            // Bold: **text**
1095            if bytes[bi] == b'*' && bi + 1 < bytes.len() && bytes[bi + 1] == b'*' {
1096                let after_open = bi + 2;
1097                if let Some(rel_end) = text[after_open..].find("**") {
1098                    let close = after_open + rel_end;
1099                    let inner = &text[after_open..close];
1100                    result.push_str(inner);
1101                    ci += 2 + inner.chars().count() + 2;
1102                    continue;
1103                }
1104            }
1105
1106            // Italic: *text* — skipped inside a `**` run.
1107            if bytes[bi] == b'*'
1108                && (bi + 1 >= bytes.len() || bytes[bi + 1] != b'*')
1109                && (bi == 0 || bytes[bi - 1] != b'*')
1110            {
1111                let after_open = bi + 1;
1112                if let Some(rel_end) = text[after_open..].find('*') {
1113                    let close = after_open + rel_end;
1114                    let inner = &text[after_open..close];
1115                    result.push_str(inner);
1116                    ci += 1 + inner.chars().count() + 1;
1117                    continue;
1118                }
1119            }
1120
1121            // Inline code: `text`
1122            if bytes[bi] == b'`' {
1123                let after_open = bi + 1;
1124                if let Some(rel_end) = text[after_open..].find('`') {
1125                    let close = after_open + rel_end;
1126                    let inner = &text[after_open..close];
1127                    result.push_str(inner);
1128                    ci += 1 + inner.chars().count() + 1;
1129                    continue;
1130                }
1131            }
1132
1133            result.push(chars[ci]);
1134            ci += 1;
1135        }
1136        result
1137    }
1138
1139    // ── key chord (cross-frame multi-key sequence) ───────────────────
1140
1141    /// Match a multi-key sequence whose keystrokes may span multiple frames
1142    /// (vi `gg`, leader keys).
1143    ///
1144    /// Unlike a single-frame matcher, `key_chord` buffers partial input in
1145    /// crate-internal `FrameState` across frames: typing `g` on one frame
1146    /// and `g` on the next returns `true` on the second frame. The partial
1147    /// prefix is cleared on a non-matching key press (vi semantics: `g` then
1148    /// `x` cancels a pending `gg`) or after
1149    /// [`DEFAULT_CHORD_TIMEOUT_TICKS`](crate::DEFAULT_CHORD_TIMEOUT_TICKS) of
1150    /// inactivity (measured on the same tick clock as notifications/animation).
1151    ///
1152    /// Returns `true` exactly once, on the frame that completes the sequence;
1153    /// the completing key event is consumed so downstream widgets in the same
1154    /// frame do not also handle it. It does not re-fire on later frames without
1155    /// new input.
1156    ///
1157    /// # Leader notation
1158    ///
1159    /// A leading `<space>` or `<leader>` token (or a literal space) matches the
1160    /// space key, e.g. `key_chord("<space>ff")`, `key_chord("<leader>ff")`, and
1161    /// `key_chord(" ff")` are equivalent. Only `<space>` / `<leader>` are
1162    /// recognized as special tokens; every other character is matched
1163    /// literally. Modifier-aware chords (`C-x C-s`) are out of scope.
1164    ///
1165    /// An empty sequence always returns `false`.
1166    ///
1167    /// # Example
1168    ///
1169    /// ```no_run
1170    /// slt::run(|ui: &mut slt::Context| {
1171    ///     if ui.key_chord("gg") {
1172    ///         // vi-style: jump to the top
1173    ///     }
1174    ///     if ui.key_chord("<space>ff") {
1175    ///         // leader key: open a file finder
1176    ///     }
1177    /// });
1178    /// ```
1179    pub fn key_chord(&mut self, seq: &str) -> bool {
1180        self.key_chord_timeout(seq, DEFAULT_CHORD_TIMEOUT_TICKS)
1181    }
1182
1183    /// [`key_chord`](Self::key_chord) with an explicit per-call timeout in ticks.
1184    ///
1185    /// A partial sequence is abandoned if `timeout_ticks` elapse on the tick
1186    /// clock without a matching next key. Use this when a chord should be more
1187    /// forgiving (large value) or stricter (small value) than the
1188    /// [`DEFAULT_CHORD_TIMEOUT_TICKS`](crate::DEFAULT_CHORD_TIMEOUT_TICKS)
1189    /// default. All other behavior matches [`key_chord`](Self::key_chord).
1190    ///
1191    /// # Example
1192    ///
1193    /// ```no_run
1194    /// slt::run(|ui: &mut slt::Context| {
1195    ///     // Require the second `g` within ~0.25s at 60Hz.
1196    ///     if ui.key_chord_timeout("gg", 15) {
1197    ///         // jump to top
1198    ///     }
1199    /// });
1200    /// ```
1201    pub fn key_chord_timeout(&mut self, seq: &str, timeout_ticks: u64) -> bool {
1202        let target = parse_chord(seq);
1203        if target.is_empty() {
1204            return false;
1205        }
1206        // Modal guard parity with the (deprecated) `key_seq`: suppress chords
1207        // while a modal owns input and no overlay is layered on top.
1208        if (self.rollback.modal_active || self.prev_modal_active)
1209            && self.rollback.overlay_depth == 0
1210        {
1211            return false;
1212        }
1213
1214        // Expire a stale prefix before processing this frame's keys.
1215        if self.tick.saturating_sub(self.chord.last_tick) > timeout_ticks {
1216            self.chord.pending.clear();
1217        }
1218
1219        // Snapshot this frame's unconsumed char presses up front so the
1220        // immutable borrow from `available_key_presses` is released before we
1221        // mutate `self.chord` / call `consume_indices`.
1222        let char_presses: Vec<(usize, char)> = self
1223            .available_key_presses()
1224            .filter_map(|(i, key)| match key.code {
1225                KeyCode::Char(c) => Some((i, c)),
1226                _ => None,
1227            })
1228            .collect();
1229
1230        let tick = self.tick;
1231        let mut completed_index: Option<usize> = None;
1232        let mut buf: Vec<char> = self.chord.pending.chars().collect();
1233
1234        for (i, c) in char_presses {
1235            buf.push(c);
1236            // Keep only the longest suffix of `buf` that is a prefix of
1237            // `target`, giving vi-style overlap semantics (typing `gxg` still
1238            // arms `gg` from the trailing `g`).
1239            retain_longest_prefix(&mut buf, &target);
1240            self.chord.last_tick = tick;
1241            if buf.len() == target.len() {
1242                completed_index = Some(i);
1243                buf.clear();
1244                break;
1245            }
1246        }
1247
1248        self.chord.pending = buf.into_iter().collect();
1249        if let Some(i) = completed_index {
1250            self.consume_indices([i]);
1251            true
1252        } else {
1253            false
1254        }
1255    }
1256
1257    /// Check if a sequence of character keys was pressed.
1258    ///
1259    /// Deprecated alias for [`key_chord`](Self::key_chord). The original
1260    /// `key_seq` only matched when every key arrived in a single poll batch
1261    /// (i.e. physically simultaneous keypresses), so vi `gg` / leader keys
1262    /// were unreachable at any human typing speed. It now delegates to
1263    /// [`key_chord`](Self::key_chord) and matches across frames.
1264    #[deprecated(
1265        since = "0.21.0",
1266        note = "renamed to `key_chord`; now matches across frames"
1267    )]
1268    pub fn key_seq(&mut self, seq: &str) -> bool {
1269        self.key_chord(seq)
1270    }
1271}
1272
1273/// Expand `<space>` / `<leader>` tokens in a chord spec into the characters
1274/// the matcher compares against. Everything else is taken literally. The only
1275/// special tokens are `<space>` and `<leader>` (both map to a literal space);
1276/// a literal space in the input is preserved as-is.
1277fn parse_chord(seq: &str) -> Vec<char> {
1278    let mut out = Vec::new();
1279    let mut rest = seq;
1280    while !rest.is_empty() {
1281        if let Some(tail) = rest.strip_prefix("<space>") {
1282            out.push(' ');
1283            rest = tail;
1284        } else if let Some(tail) = rest.strip_prefix("<leader>") {
1285            out.push(' ');
1286            rest = tail;
1287        } else {
1288            let c = rest.chars().next().expect("rest is non-empty");
1289            out.push(c);
1290            rest = &rest[c.len_utf8()..];
1291        }
1292    }
1293    out
1294}
1295
1296/// Shrink `buf` to the longest suffix that is still a prefix of `target`.
1297///
1298/// This gives vi-style overlap semantics: after a mismatch the matcher does
1299/// not reset to empty but keeps any trailing characters that could begin a
1300/// fresh match. For example, with `target = ['g', 'g']`, the input `g x g`
1301/// leaves `buf = ['g']` (the trailing `g` re-arms the chord) rather than
1302/// discarding it.
1303fn retain_longest_prefix(buf: &mut Vec<char>, target: &[char]) {
1304    // Try progressively shorter suffixes of `buf`; the first that is a prefix
1305    // of `target` wins. An empty suffix is always a prefix, so this terminates.
1306    let mut start = 0;
1307    while start < buf.len() {
1308        if buf[start..].iter().zip(target).all(|(b, t)| b == t) {
1309            break;
1310        }
1311        start += 1;
1312    }
1313    if start > 0 {
1314        buf.drain(0..start);
1315    }
1316}
1317
1318// ── variable-height virtual_list helpers ─────────────────────────────────
1319//
1320// These operate on the per-item `row_prefix` cached in `ListState` so the
1321// visible range and page jumps are computed in *rows*, not items, while the
1322// public `viewport_offset` keeps its "top item index" meaning. All lookups are
1323// O(log n) (binary search) or O(visible) (bounded linear accumulation).
1324
1325/// Largest item index `i` such that `row_prefix[i] <= target_row` — i.e. the
1326/// item containing (or starting at) `target_row`. Result is in `0..n`.
1327fn item_at_row(row_prefix: &[u32], target_row: u32, n: usize) -> usize {
1328    // `row_prefix` has `n + 1` entries; entry `i` is the first row of item `i`.
1329    // partition_point returns the count of entries `<= target_row`; subtract one
1330    // to get the index of the item that owns that row, clamped to the last item.
1331    if n == 0 {
1332        return 0;
1333    }
1334    let count = row_prefix.partition_point(|&r| r <= target_row);
1335    count.saturating_sub(1).min(n - 1)
1336}
1337
1338/// Compute the `[start, end)` item range for the variable-height path.
1339///
1340/// Clamps `state.viewport_offset` (top item index) so `selected` is fully
1341/// visible by *rows*, then accumulates item heights from the top until the
1342/// viewport is filled. `viewport_row_offset` is kept in sync with the top
1343/// item's starting row. `end` always covers at least one item, so an item
1344/// taller than the viewport renders from its top instead of being skipped
1345/// (no zero-progress loop).
1346fn row_visible_range(state: &mut ListState, vh: usize) -> (usize, usize) {
1347    state.ensure_row_prefix();
1348    let n = state.len();
1349    if n == 0 || vh == 0 {
1350        state.viewport_offset = state.viewport_offset.min(n.saturating_sub(1));
1351        state.viewport_row_offset = 0;
1352        return (state.viewport_offset, state.viewport_offset);
1353    }
1354
1355    let vh_rows = vh as u32;
1356    let row_prefix = state.row_prefix();
1357    // `row_prefix[i]` is the top row of item `i`.
1358    let sel = state.selected.min(n - 1);
1359    let sel_top = row_prefix[sel];
1360    let sel_bottom = row_prefix[sel + 1]; // exclusive bottom row of `selected`
1361
1362    let mut top = state.viewport_offset.min(n - 1);
1363
1364    // Scroll up: if the selection's top row is above the viewport top row,
1365    // pull the viewport up to the selected item.
1366    if sel_top < row_prefix[top] {
1367        top = sel;
1368    }
1369
1370    // Scroll down: while the selection's bottom row falls past the viewport
1371    // window, advance the top item. Each step makes progress (top increases),
1372    // so this terminates. Stop once `selected` fits or the selected item is
1373    // itself the top (a single item taller than the viewport).
1374    while top < sel && sel_bottom.saturating_sub(row_prefix[top]) > vh_rows {
1375        top += 1;
1376    }
1377
1378    // Accumulate items from `top` until adding the next item would overflow
1379    // `vh` rows; the rendered items then sum to at most `vh` rows (a partially
1380    // clipped item is excluded). Always include at least the top item so a
1381    // tall item is never skipped (it renders from its top).
1382    let top_row = row_prefix[top];
1383    let target_bottom = top_row.saturating_add(vh_rows);
1384    // Largest exclusive `end` such that `row_prefix[end] <= target_bottom`,
1385    // i.e. items `top..end` fully fit within `vh` rows (their cumulative bottom
1386    // row does not exceed the viewport bottom). `partition_point` returns the
1387    // count of entries `<= target_bottom` (one past the largest matching
1388    // index), so subtract one to get the inclusive prefix index = exclusive
1389    // item end. Clamp to `[top + 1, n]` so at least one item always renders
1390    // (a tall item that overflows `vh` shows alone, from its top).
1391    let end = row_prefix
1392        .partition_point(|&r| r <= target_bottom)
1393        .saturating_sub(1)
1394        .clamp(top + 1, n);
1395
1396    state.viewport_offset = top;
1397    state.viewport_row_offset = top_row as usize;
1398    (top, end)
1399}
1400
1401/// Item index reached by paging *down* one viewport (`vh` rows) from `from`.
1402/// Advances by the count of items whose cumulative height fills `vh` rows,
1403/// guaranteeing forward progress of at least one item.
1404fn page_down_target(state: &mut ListState, from: usize, visible_height: u32) -> usize {
1405    state.ensure_row_prefix();
1406    let n = state.len();
1407    if n == 0 {
1408        return 0;
1409    }
1410    let from = from.min(n - 1);
1411    let row_prefix = state.row_prefix();
1412    let from_top = row_prefix[from];
1413    let target = from_top.saturating_add(visible_height.max(1));
1414    let next = item_at_row(row_prefix, target, n);
1415    next.max(from + 1).min(n - 1)
1416}
1417
1418/// Item index reached by paging *up* one viewport (`vh` rows) from `from`.
1419/// Retreats by the count of items whose cumulative height fills `vh` rows,
1420/// guaranteeing backward progress of at least one item (until index 0).
1421fn page_up_target(state: &mut ListState, from: usize, visible_height: u32) -> usize {
1422    state.ensure_row_prefix();
1423    let n = state.len();
1424    if n == 0 {
1425        return 0;
1426    }
1427    let from = from.min(n - 1);
1428    let row_prefix = state.row_prefix();
1429    let from_bottom = row_prefix[from + 1];
1430    let target = from_bottom.saturating_sub(visible_height.max(1));
1431    let prev = item_at_row(row_prefix, target, n);
1432    prev.min(from.saturating_sub(1))
1433}