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