Skip to main content

slt/context/widgets_interactive/
rich_markdown.rs

1use super::*;
2use crate::{RichLogState, DEFAULT_CHORD_TIMEOUT_TICKS};
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            if let MdInline::Text(ref t) = items[0] {
877                let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
878                if segs.len() <= 1 {
879                    self.text(text)
880                        .wrap()
881                        .fg(text_style.fg.unwrap_or(Color::Reset));
882                } else {
883                    self.line_wrap(|ui| {
884                        for (s, st) in segs {
885                            ui.styled(s, st);
886                        }
887                    });
888                }
889                return;
890            }
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] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
957                if let Some((alt, _url, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
958                    if !current.is_empty() {
959                        items.push(MdInline::Text(std::mem::take(&mut current)));
960                    }
961                    items.push(MdInline::Image { alt });
962                    i += 1 + consumed;
963                    continue;
964                }
965            }
966            // Link: [text](url)
967            if chars[i] == '[' {
968                if let Some((link_text, url, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
969                    if !current.is_empty() {
970                        items.push(MdInline::Text(std::mem::take(&mut current)));
971                    }
972                    items.push(MdInline::Link {
973                        text: link_text,
974                        url,
975                    });
976                    i += consumed;
977                    continue;
978                }
979            }
980            current.push(chars[i]);
981            i += 1;
982        }
983        if !current.is_empty() {
984            items.push(MdInline::Text(current));
985        }
986        if items.is_empty() {
987            items.push(MdInline::Text(String::new()));
988        }
989        items
990    }
991
992    /// Parse `[text](url)` starting at `chars[start]` which must be `[`.
993    /// Returns `(text, url, chars_consumed)` or `None` if no match.
994    fn parse_md_bracket_paren(chars: &[char], start: usize) -> Option<(String, String, usize)> {
995        if start >= chars.len() || chars[start] != '[' {
996            return None;
997        }
998        // Find closing ]
999        let mut depth = 0i32;
1000        let mut bracket_end = None;
1001        for (j, &ch) in chars.iter().enumerate().skip(start) {
1002            if ch == '[' {
1003                depth += 1;
1004            } else if ch == ']' {
1005                depth -= 1;
1006                if depth == 0 {
1007                    bracket_end = Some(j);
1008                    break;
1009                }
1010            }
1011        }
1012        let bracket_end = bracket_end?;
1013        // Must be followed by (
1014        if bracket_end + 1 >= chars.len() || chars[bracket_end + 1] != '(' {
1015            return None;
1016        }
1017        // Find closing )
1018        let paren_start = bracket_end + 2;
1019        let mut paren_end = None;
1020        let mut paren_depth = 1i32;
1021        for (j, &ch) in chars.iter().enumerate().skip(paren_start) {
1022            if ch == '(' {
1023                paren_depth += 1;
1024            } else if ch == ')' {
1025                paren_depth -= 1;
1026                if paren_depth == 0 {
1027                    paren_end = Some(j);
1028                    break;
1029                }
1030            }
1031        }
1032        let paren_end = paren_end?;
1033        let text: String = chars[start + 1..bracket_end].iter().collect();
1034        let url: String = chars[paren_start..paren_end].iter().collect();
1035        let consumed = paren_end - start + 1;
1036        Some((text, url, consumed))
1037    }
1038
1039    /// Strip markdown inline formatting, returning plain display text.
1040    ///
1041    /// `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`,
1042    /// `[text](url)` → `text`, `![alt](url)` → `alt`.
1043    fn md_strip(text: &str) -> String {
1044        // Bracket/paren parsing for links/images still uses a `Vec<char>`
1045        // because the helper takes a char-slice; pre-build it once and reuse
1046        // the precomputed char→byte mapping for both code paths.
1047        let chars: Vec<char> = text.chars().collect();
1048        let char_to_byte = {
1049            let mut v = Vec::with_capacity(chars.len() + 1);
1050            let mut acc = 0usize;
1051            v.push(0);
1052            for ch in &chars {
1053                acc += ch.len_utf8();
1054                v.push(acc);
1055            }
1056            v
1057        };
1058        let bytes = text.as_bytes();
1059        let mut result = String::with_capacity(text.len());
1060        let mut ci: usize = 0;
1061
1062        while ci < chars.len() {
1063            // Image: ![alt](url) — char-based bracket scanner is reused as-is.
1064            if chars[ci] == '!' && ci + 1 < chars.len() && chars[ci + 1] == '[' {
1065                if let Some((alt, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci + 1) {
1066                    result.push_str(&alt);
1067                    ci += 1 + consumed;
1068                    continue;
1069                }
1070            }
1071            // Link: [text](url)
1072            if chars[ci] == '[' {
1073                if let Some((link_text, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci) {
1074                    result.push_str(&link_text);
1075                    ci += consumed;
1076                    continue;
1077                }
1078            }
1079
1080            let bi = char_to_byte[ci];
1081
1082            // Bold: **text**
1083            if bytes[bi] == b'*' && bi + 1 < bytes.len() && bytes[bi + 1] == b'*' {
1084                let after_open = bi + 2;
1085                if let Some(rel_end) = text[after_open..].find("**") {
1086                    let close = after_open + rel_end;
1087                    let inner = &text[after_open..close];
1088                    result.push_str(inner);
1089                    ci += 2 + inner.chars().count() + 2;
1090                    continue;
1091                }
1092            }
1093
1094            // Italic: *text* — skipped inside a `**` run.
1095            if bytes[bi] == b'*'
1096                && (bi + 1 >= bytes.len() || bytes[bi + 1] != b'*')
1097                && (bi == 0 || bytes[bi - 1] != b'*')
1098            {
1099                let after_open = bi + 1;
1100                if let Some(rel_end) = text[after_open..].find('*') {
1101                    let close = after_open + rel_end;
1102                    let inner = &text[after_open..close];
1103                    result.push_str(inner);
1104                    ci += 1 + inner.chars().count() + 1;
1105                    continue;
1106                }
1107            }
1108
1109            // Inline code: `text`
1110            if bytes[bi] == b'`' {
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            result.push(chars[ci]);
1122            ci += 1;
1123        }
1124        result
1125    }
1126
1127    // ── key chord (cross-frame multi-key sequence) ───────────────────
1128
1129    /// Match a multi-key sequence whose keystrokes may span multiple frames
1130    /// (vi `gg`, leader keys).
1131    ///
1132    /// Unlike a single-frame matcher, `key_chord` buffers partial input in
1133    /// [`FrameState`](crate::FrameState) across frames: typing `g` on one frame
1134    /// and `g` on the next returns `true` on the second frame. The partial
1135    /// prefix is cleared on a non-matching key press (vi semantics: `g` then
1136    /// `x` cancels a pending `gg`) or after
1137    /// [`DEFAULT_CHORD_TIMEOUT_TICKS`](crate::DEFAULT_CHORD_TIMEOUT_TICKS) of
1138    /// inactivity (measured on the same tick clock as notifications/animation).
1139    ///
1140    /// Returns `true` exactly once, on the frame that completes the sequence;
1141    /// the completing key event is consumed so downstream widgets in the same
1142    /// frame do not also handle it. It does not re-fire on later frames without
1143    /// new input.
1144    ///
1145    /// # Leader notation
1146    ///
1147    /// A leading `<space>` or `<leader>` token (or a literal space) matches the
1148    /// space key, e.g. `key_chord("<space>ff")`, `key_chord("<leader>ff")`, and
1149    /// `key_chord(" ff")` are equivalent. Only `<space>` / `<leader>` are
1150    /// recognized as special tokens; every other character is matched
1151    /// literally. Modifier-aware chords (`C-x C-s`) are out of scope.
1152    ///
1153    /// An empty sequence always returns `false`.
1154    ///
1155    /// # Example
1156    ///
1157    /// ```no_run
1158    /// slt::run(|ui: &mut slt::Context| {
1159    ///     if ui.key_chord("gg") {
1160    ///         // vi-style: jump to the top
1161    ///     }
1162    ///     if ui.key_chord("<space>ff") {
1163    ///         // leader key: open a file finder
1164    ///     }
1165    /// });
1166    /// ```
1167    pub fn key_chord(&mut self, seq: &str) -> bool {
1168        self.key_chord_timeout(seq, DEFAULT_CHORD_TIMEOUT_TICKS)
1169    }
1170
1171    /// [`key_chord`](Self::key_chord) with an explicit per-call timeout in ticks.
1172    ///
1173    /// A partial sequence is abandoned if `timeout_ticks` elapse on the tick
1174    /// clock without a matching next key. Use this when a chord should be more
1175    /// forgiving (large value) or stricter (small value) than the
1176    /// [`DEFAULT_CHORD_TIMEOUT_TICKS`](crate::DEFAULT_CHORD_TIMEOUT_TICKS)
1177    /// default. All other behavior matches [`key_chord`](Self::key_chord).
1178    ///
1179    /// # Example
1180    ///
1181    /// ```no_run
1182    /// slt::run(|ui: &mut slt::Context| {
1183    ///     // Require the second `g` within ~0.25s at 60Hz.
1184    ///     if ui.key_chord_timeout("gg", 15) {
1185    ///         // jump to top
1186    ///     }
1187    /// });
1188    /// ```
1189    pub fn key_chord_timeout(&mut self, seq: &str, timeout_ticks: u64) -> bool {
1190        let target = parse_chord(seq);
1191        if target.is_empty() {
1192            return false;
1193        }
1194        // Modal guard parity with the (deprecated) `key_seq`: suppress chords
1195        // while a modal owns input and no overlay is layered on top.
1196        if (self.rollback.modal_active || self.prev_modal_active)
1197            && self.rollback.overlay_depth == 0
1198        {
1199            return false;
1200        }
1201
1202        // Expire a stale prefix before processing this frame's keys.
1203        if self.tick.saturating_sub(self.chord.last_tick) > timeout_ticks {
1204            self.chord.pending.clear();
1205        }
1206
1207        // Snapshot this frame's unconsumed char presses up front so the
1208        // immutable borrow from `available_key_presses` is released before we
1209        // mutate `self.chord` / call `consume_indices`.
1210        let char_presses: Vec<(usize, char)> = self
1211            .available_key_presses()
1212            .filter_map(|(i, key)| match key.code {
1213                KeyCode::Char(c) => Some((i, c)),
1214                _ => None,
1215            })
1216            .collect();
1217
1218        let tick = self.tick;
1219        let mut completed_index: Option<usize> = None;
1220        let mut buf: Vec<char> = self.chord.pending.chars().collect();
1221
1222        for (i, c) in char_presses {
1223            buf.push(c);
1224            // Keep only the longest suffix of `buf` that is a prefix of
1225            // `target`, giving vi-style overlap semantics (typing `gxg` still
1226            // arms `gg` from the trailing `g`).
1227            retain_longest_prefix(&mut buf, &target);
1228            self.chord.last_tick = tick;
1229            if buf.len() == target.len() {
1230                completed_index = Some(i);
1231                buf.clear();
1232                break;
1233            }
1234        }
1235
1236        self.chord.pending = buf.into_iter().collect();
1237        if let Some(i) = completed_index {
1238            self.consume_indices([i]);
1239            true
1240        } else {
1241            false
1242        }
1243    }
1244
1245    /// Check if a sequence of character keys was pressed.
1246    ///
1247    /// Deprecated alias for [`key_chord`](Self::key_chord). The original
1248    /// `key_seq` only matched when every key arrived in a single poll batch
1249    /// (i.e. physically simultaneous keypresses), so vi `gg` / leader keys
1250    /// were unreachable at any human typing speed. It now delegates to
1251    /// [`key_chord`](Self::key_chord) and matches across frames.
1252    #[deprecated(
1253        since = "0.21.0",
1254        note = "renamed to `key_chord`; now matches across frames"
1255    )]
1256    pub fn key_seq(&mut self, seq: &str) -> bool {
1257        self.key_chord(seq)
1258    }
1259}
1260
1261/// Expand `<space>` / `<leader>` tokens in a chord spec into the characters
1262/// the matcher compares against. Everything else is taken literally. The only
1263/// special tokens are `<space>` and `<leader>` (both map to a literal space);
1264/// a literal space in the input is preserved as-is.
1265fn parse_chord(seq: &str) -> Vec<char> {
1266    let mut out = Vec::new();
1267    let mut rest = seq;
1268    while !rest.is_empty() {
1269        if let Some(tail) = rest.strip_prefix("<space>") {
1270            out.push(' ');
1271            rest = tail;
1272        } else if let Some(tail) = rest.strip_prefix("<leader>") {
1273            out.push(' ');
1274            rest = tail;
1275        } else {
1276            let c = rest.chars().next().expect("rest is non-empty");
1277            out.push(c);
1278            rest = &rest[c.len_utf8()..];
1279        }
1280    }
1281    out
1282}
1283
1284/// Shrink `buf` to the longest suffix that is still a prefix of `target`.
1285///
1286/// This gives vi-style overlap semantics: after a mismatch the matcher does
1287/// not reset to empty but keeps any trailing characters that could begin a
1288/// fresh match. For example, with `target = ['g', 'g']`, the input `g x g`
1289/// leaves `buf = ['g']` (the trailing `g` re-arms the chord) rather than
1290/// discarding it.
1291fn retain_longest_prefix(buf: &mut Vec<char>, target: &[char]) {
1292    // Try progressively shorter suffixes of `buf`; the first that is a prefix
1293    // of `target` wins. An empty suffix is always a prefix, so this terminates.
1294    let mut start = 0;
1295    while start < buf.len() {
1296        if buf[start..].iter().zip(target).all(|(b, t)| b == t) {
1297            break;
1298        }
1299        start += 1;
1300    }
1301    if start > 0 {
1302        buf.drain(0..start);
1303    }
1304}
1305
1306// ── variable-height virtual_list helpers ─────────────────────────────────
1307//
1308// These operate on the per-item `row_prefix` cached in `ListState` so the
1309// visible range and page jumps are computed in *rows*, not items, while the
1310// public `viewport_offset` keeps its "top item index" meaning. All lookups are
1311// O(log n) (binary search) or O(visible) (bounded linear accumulation).
1312
1313/// Largest item index `i` such that `row_prefix[i] <= target_row` — i.e. the
1314/// item containing (or starting at) `target_row`. Result is in `0..n`.
1315fn item_at_row(row_prefix: &[u32], target_row: u32, n: usize) -> usize {
1316    // `row_prefix` has `n + 1` entries; entry `i` is the first row of item `i`.
1317    // partition_point returns the count of entries `<= target_row`; subtract one
1318    // to get the index of the item that owns that row, clamped to the last item.
1319    if n == 0 {
1320        return 0;
1321    }
1322    let count = row_prefix.partition_point(|&r| r <= target_row);
1323    count.saturating_sub(1).min(n - 1)
1324}
1325
1326/// Compute the `[start, end)` item range for the variable-height path.
1327///
1328/// Clamps `state.viewport_offset` (top item index) so `selected` is fully
1329/// visible by *rows*, then accumulates item heights from the top until the
1330/// viewport is filled. `viewport_row_offset` is kept in sync with the top
1331/// item's starting row. `end` always covers at least one item, so an item
1332/// taller than the viewport renders from its top instead of being skipped
1333/// (no zero-progress loop).
1334fn row_visible_range(state: &mut ListState, vh: usize) -> (usize, usize) {
1335    state.ensure_row_prefix();
1336    let n = state.items.len();
1337    if n == 0 || vh == 0 {
1338        state.viewport_offset = state.viewport_offset.min(n.saturating_sub(1));
1339        state.viewport_row_offset = 0;
1340        return (state.viewport_offset, state.viewport_offset);
1341    }
1342
1343    let vh_rows = vh as u32;
1344    let row_prefix = state.row_prefix();
1345    // `row_prefix[i]` is the top row of item `i`.
1346    let sel = state.selected.min(n - 1);
1347    let sel_top = row_prefix[sel];
1348    let sel_bottom = row_prefix[sel + 1]; // exclusive bottom row of `selected`
1349
1350    let mut top = state.viewport_offset.min(n - 1);
1351
1352    // Scroll up: if the selection's top row is above the viewport top row,
1353    // pull the viewport up to the selected item.
1354    if sel_top < row_prefix[top] {
1355        top = sel;
1356    }
1357
1358    // Scroll down: while the selection's bottom row falls past the viewport
1359    // window, advance the top item. Each step makes progress (top increases),
1360    // so this terminates. Stop once `selected` fits or the selected item is
1361    // itself the top (a single item taller than the viewport).
1362    while top < sel && sel_bottom.saturating_sub(row_prefix[top]) > vh_rows {
1363        top += 1;
1364    }
1365
1366    // Accumulate items from `top` until adding the next item would overflow
1367    // `vh` rows; the rendered items then sum to at most `vh` rows (a partially
1368    // clipped item is excluded). Always include at least the top item so a
1369    // tall item is never skipped (it renders from its top).
1370    let top_row = row_prefix[top];
1371    let target_bottom = top_row.saturating_add(vh_rows);
1372    // Largest exclusive `end` such that `row_prefix[end] <= target_bottom`,
1373    // i.e. items `top..end` fully fit within `vh` rows (their cumulative bottom
1374    // row does not exceed the viewport bottom). `partition_point` returns the
1375    // count of entries `<= target_bottom` (one past the largest matching
1376    // index), so subtract one to get the inclusive prefix index = exclusive
1377    // item end. Clamp to `[top + 1, n]` so at least one item always renders
1378    // (a tall item that overflows `vh` shows alone, from its top).
1379    let end = row_prefix
1380        .partition_point(|&r| r <= target_bottom)
1381        .saturating_sub(1)
1382        .clamp(top + 1, n);
1383
1384    state.viewport_offset = top;
1385    state.viewport_row_offset = top_row as usize;
1386    (top, end)
1387}
1388
1389/// Item index reached by paging *down* one viewport (`vh` rows) from `from`.
1390/// Advances by the count of items whose cumulative height fills `vh` rows,
1391/// guaranteeing forward progress of at least one item.
1392fn page_down_target(state: &mut ListState, from: usize, visible_height: u32) -> usize {
1393    state.ensure_row_prefix();
1394    let n = state.items.len();
1395    if n == 0 {
1396        return 0;
1397    }
1398    let from = from.min(n - 1);
1399    let row_prefix = state.row_prefix();
1400    let from_top = row_prefix[from];
1401    let target = from_top.saturating_add(visible_height.max(1));
1402    let next = item_at_row(row_prefix, target, n);
1403    next.max(from + 1).min(n - 1)
1404}
1405
1406/// Item index reached by paging *up* one viewport (`vh` rows) from `from`.
1407/// Retreats by the count of items whose cumulative height fills `vh` rows,
1408/// guaranteeing backward progress of at least one item (until index 0).
1409fn page_up_target(state: &mut ListState, from: usize, visible_height: u32) -> usize {
1410    state.ensure_row_prefix();
1411    let n = state.items.len();
1412    if n == 0 {
1413        return 0;
1414    }
1415    let from = from.min(n - 1);
1416    let row_prefix = state.row_prefix();
1417    let from_bottom = row_prefix[from + 1];
1418    let target = from_bottom.saturating_sub(visible_height.max(1));
1419    let prev = item_at_row(row_prefix, target, n);
1420    prev.min(from.saturating_sub(1))
1421}