Skip to main content

slt/context/widgets_interactive/
rich_markdown.rs

1use super::*;
2use crate::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    pub fn virtual_list(
152        &mut self,
153        state: &mut ListState,
154        visible_height: u32,
155        f: impl Fn(&mut Context, usize),
156    ) -> Response {
157        if state.items.is_empty() {
158            return Response::none();
159        }
160        state.selected = state.selected.min(state.items.len().saturating_sub(1));
161        let focused = self.register_focusable();
162        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
163        let old_selected = state.selected;
164
165        if focused {
166            let mut consumed_indices = Vec::new();
167            for (i, key) in self.available_key_presses() {
168                match key.code {
169                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
170                        let _ = handle_vertical_nav(
171                            &mut state.selected,
172                            state.items.len().saturating_sub(1),
173                            key.code.clone(),
174                        );
175                        consumed_indices.push(i);
176                    }
177                    KeyCode::PageUp => {
178                        state.selected = state.selected.saturating_sub(visible_height as usize);
179                        consumed_indices.push(i);
180                    }
181                    KeyCode::PageDown => {
182                        state.selected = (state.selected + visible_height as usize)
183                            .min(state.items.len().saturating_sub(1));
184                        consumed_indices.push(i);
185                    }
186                    KeyCode::Home => {
187                        state.selected = 0;
188                        consumed_indices.push(i);
189                    }
190                    KeyCode::End => {
191                        state.selected = state.items.len().saturating_sub(1);
192                        consumed_indices.push(i);
193                    }
194                    _ => {}
195                }
196            }
197            self.consume_indices(consumed_indices);
198        }
199
200        let vh = visible_height as usize;
201        // Clamp viewport_offset so `selected` stays inside [offset, offset + vh)
202        // without forcing the cursor onto the bottom row when scrolling down.
203        if state.selected < state.viewport_offset {
204            state.viewport_offset = state.selected;
205        }
206        if vh > 0 && state.selected >= state.viewport_offset + vh {
207            state.viewport_offset = state.selected - vh + 1;
208        }
209        let start = state.viewport_offset;
210        let end = (start + vh).min(state.items.len());
211
212        self.commands
213            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
214                direction: Direction::Column,
215                gap: 0,
216                align: Align::Start,
217                align_self: None,
218                justify: Justify::Start,
219                border: None,
220                border_sides: BorderSides::all(),
221                border_style: Style::new().fg(self.theme.border),
222                bg_color: None,
223                padding: Padding::default(),
224                margin: Margin::default(),
225                constraints: Constraints::default(),
226                title: None,
227                grow: 0,
228                group_name: None,
229            })));
230
231        if start > 0 {
232            let hidden = start.to_string();
233            let mut line = String::with_capacity(hidden.len() + 10);
234            line.push_str("  ↑ ");
235            line.push_str(&hidden);
236            line.push_str(" more");
237            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
238        }
239
240        for idx in start..end {
241            f(self, idx);
242        }
243
244        let remaining = state.items.len().saturating_sub(end);
245        if remaining > 0 {
246            let hidden = remaining.to_string();
247            let mut line = String::with_capacity(hidden.len() + 10);
248            line.push_str("  ↓ ");
249            line.push_str(&hidden);
250            line.push_str(" more");
251            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
252        }
253
254        self.commands.push(Command::EndContainer);
255        self.rollback.last_text_idx = None;
256        response.changed = state.selected != old_selected;
257        response
258    }
259
260    // ── command palette ──────────────────────────────────────────────
261
262    /// Render a command palette overlay.
263    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Response {
264        if !state.open {
265            return Response::none();
266        }
267
268        state.last_selected = None;
269        let interaction_id = self.next_interaction_id();
270
271        let filtered: Vec<usize> = state.filtered_indices_cached().to_vec();
272        let sel = state.selected().min(filtered.len().saturating_sub(1));
273        state.set_selected(sel);
274
275        let mut consumed_indices = Vec::new();
276
277        for (i, key) in self.available_key_presses() {
278            match key.code {
279                KeyCode::Esc => {
280                    state.open = false;
281                    consumed_indices.push(i);
282                }
283                KeyCode::Up => {
284                    let s = state.selected();
285                    state.set_selected(s.saturating_sub(1));
286                    consumed_indices.push(i);
287                }
288                KeyCode::Down => {
289                    let s = state.selected();
290                    state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
291                    consumed_indices.push(i);
292                }
293                KeyCode::Enter => {
294                    if let Some(&cmd_idx) = filtered.get(state.selected()) {
295                        state.last_selected = Some(cmd_idx);
296                        state.open = false;
297                    }
298                    consumed_indices.push(i);
299                }
300                KeyCode::Backspace => {
301                    if state.cursor > 0 {
302                        let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
303                        let end_idx = byte_index_for_char(&state.input, state.cursor);
304                        state.input.replace_range(byte_idx..end_idx, "");
305                        state.cursor -= 1;
306                        state.set_selected(0);
307                    }
308                    consumed_indices.push(i);
309                }
310                KeyCode::Char(ch) => {
311                    let byte_idx = byte_index_for_char(&state.input, state.cursor);
312                    state.input.insert(byte_idx, ch);
313                    state.cursor += 1;
314                    state.set_selected(0);
315                    consumed_indices.push(i);
316                }
317                _ => {}
318            }
319        }
320        self.consume_indices(consumed_indices);
321
322        let filtered: Vec<usize> = state.filtered_indices_cached().to_vec();
323
324        let _ = self.modal(|ui| {
325            let primary = ui.theme.primary;
326            let palette_pad = ui.theme.spacing.xs();
327            let palette_input_padx = ui.theme.spacing.xs();
328            let _ = ui
329                .container()
330                .border(Border::Rounded)
331                .border_style(Style::new().fg(primary))
332                .p(palette_pad)
333                .max_w(60)
334                .col(|ui| {
335                    let border_color = ui.theme.primary;
336                    let _ = ui
337                        .bordered(Border::Rounded)
338                        .border_style(Style::new().fg(border_color))
339                        .px(palette_input_padx)
340                        .col(|ui| {
341                            let display = if state.input.is_empty() {
342                                "Type to search...".to_string()
343                            } else {
344                                state.input.clone()
345                            };
346                            let style = if state.input.is_empty() {
347                                Style::new().dim().fg(ui.theme.text_dim)
348                            } else {
349                                Style::new().fg(ui.theme.text)
350                            };
351                            ui.styled(display, style);
352                        });
353
354                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
355                        let cmd = &state.commands[cmd_idx];
356                        let is_selected = list_idx == state.selected();
357                        let style = if is_selected {
358                            Style::new().bold().fg(ui.theme.primary)
359                        } else {
360                            Style::new().fg(ui.theme.text)
361                        };
362                        let prefix = if is_selected { "▸ " } else { "  " };
363                        let shortcut_text = cmd
364                            .shortcut
365                            .as_deref()
366                            .map(|s| {
367                                let mut text = String::with_capacity(s.len() + 4);
368                                text.push_str("  (");
369                                text.push_str(s);
370                                text.push(')');
371                                text
372                            })
373                            .unwrap_or_default();
374                        let mut line = String::with_capacity(
375                            prefix.len() + cmd.label.len() + shortcut_text.len(),
376                        );
377                        line.push_str(prefix);
378                        line.push_str(&cmd.label);
379                        line.push_str(&shortcut_text);
380                        ui.styled(line, style);
381                        if is_selected && !cmd.description.is_empty() {
382                            let mut desc = String::with_capacity(4 + cmd.description.len());
383                            desc.push_str("    ");
384                            desc.push_str(&cmd.description);
385                            ui.styled(desc, Style::new().dim().fg(ui.theme.text_dim));
386                        }
387                    }
388
389                    if filtered.is_empty() {
390                        ui.styled(
391                            "  No matching commands",
392                            Style::new().dim().fg(ui.theme.text_dim),
393                        );
394                    }
395                });
396        });
397
398        let mut response = self.response_for(interaction_id);
399        response.changed = state.last_selected.is_some();
400        response
401    }
402
403    // ── markdown ─────────────────────────────────────────────────────
404
405    /// Render a markdown string with basic formatting.
406    ///
407    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
408    /// unordered lists (`-`/`*`), ordered lists (`1.`), blockquotes (`>`),
409    /// horizontal rules (`---`), links (`[text](url)`), image placeholders
410    /// (`![alt](url)`), code blocks with syntax highlighting, and GFM-style
411    /// pipe tables. Paragraph text auto-wraps to container width.
412    pub fn markdown(&mut self, text: &str) -> Response {
413        self.commands
414            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
415                direction: Direction::Column,
416                gap: 0,
417                align: Align::Start,
418                align_self: None,
419                justify: Justify::Start,
420                border: None,
421                border_sides: BorderSides::all(),
422                border_style: Style::new().fg(self.theme.border),
423                bg_color: None,
424                padding: Padding::default(),
425                margin: Margin::default(),
426                constraints: Constraints::default(),
427                title: None,
428                grow: 0,
429                group_name: None,
430            })));
431        self.skip_interaction_slot();
432
433        let text_style = Style::new().fg(self.theme.text);
434        let bold_style = Style::new().fg(self.theme.text).bold();
435        let code_style = Style::new().fg(self.theme.accent);
436        let border_style = Style::new().fg(self.theme.border).dim();
437
438        let mut in_code_block = false;
439        let mut code_block_lang = String::new();
440        let mut code_block_lines: Vec<String> = Vec::new();
441        let mut table_lines: Vec<String> = Vec::new();
442
443        for line in text.lines() {
444            let trimmed = line.trim();
445
446            if in_code_block {
447                if trimmed.starts_with("```") {
448                    in_code_block = false;
449                    let code_content = code_block_lines.join("\n");
450                    let theme = self.theme;
451                    let code_pad = theme.spacing.xs();
452                    let highlighted: Option<Vec<Vec<(String, Style)>>> =
453                        crate::syntax::highlight_code(&code_content, &code_block_lang, &theme);
454                    let _ = self.container().bg(theme.surface).p(code_pad).col(|ui| {
455                        if let Some(ref hl_lines) = highlighted {
456                            for segs in hl_lines {
457                                if segs.is_empty() {
458                                    ui.text(" ");
459                                } else {
460                                    ui.line(|ui| {
461                                        for (t, s) in segs {
462                                            ui.styled(t, *s);
463                                        }
464                                    });
465                                }
466                            }
467                        } else {
468                            for cl in &code_block_lines {
469                                ui.styled(cl, code_style);
470                            }
471                        }
472                    });
473                    code_block_lang.clear();
474                    code_block_lines.clear();
475                } else {
476                    code_block_lines.push(line.to_string());
477                }
478                continue;
479            }
480
481            // Table row detection — collect lines starting with `|`
482            if trimmed.starts_with('|') && trimmed.matches('|').count() >= 2 {
483                table_lines.push(trimmed.to_string());
484                continue;
485            }
486            // Flush accumulated table rows when a non-table line is encountered
487            if !table_lines.is_empty() {
488                self.render_markdown_table(
489                    &table_lines,
490                    text_style,
491                    bold_style,
492                    code_style,
493                    border_style,
494                );
495                table_lines.clear();
496            }
497
498            if trimmed.is_empty() {
499                self.text(" ");
500                continue;
501            }
502            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
503                self.styled("─".repeat(40), border_style);
504                continue;
505            }
506            if let Some(quote) = trimmed.strip_prefix("> ") {
507                let quote_style = Style::new().fg(self.theme.text_dim).italic();
508                let bar_style = Style::new().fg(self.theme.border);
509                self.line(|ui| {
510                    ui.styled("│ ", bar_style);
511                    ui.styled(quote, quote_style);
512                });
513            } else if let Some(heading) = trimmed.strip_prefix("### ") {
514                self.styled(heading, Style::new().bold().fg(self.theme.accent));
515            } else if let Some(heading) = trimmed.strip_prefix("## ") {
516                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
517            } else if let Some(heading) = trimmed.strip_prefix("# ") {
518                self.styled(heading, Style::new().bold().fg(self.theme.primary));
519            } else if let Some(item) = trimmed
520                .strip_prefix("- ")
521                .or_else(|| trimmed.strip_prefix("* "))
522            {
523                self.line_wrap(|ui| {
524                    ui.styled("  • ", text_style);
525                    Self::render_md_inline_into(ui, item, text_style, bold_style, code_style);
526                });
527            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
528                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
529                if parts.len() == 2 {
530                    self.line_wrap(|ui| {
531                        let mut prefix = String::with_capacity(4 + parts[0].len());
532                        prefix.push_str("  ");
533                        prefix.push_str(parts[0]);
534                        prefix.push_str(". ");
535                        ui.styled(prefix, text_style);
536                        Self::render_md_inline_into(
537                            ui, parts[1], text_style, bold_style, code_style,
538                        );
539                    });
540                } else {
541                    self.text(trimmed);
542                }
543            } else if let Some(lang) = trimmed.strip_prefix("```") {
544                in_code_block = true;
545                code_block_lang = lang.trim().to_string();
546            } else {
547                self.render_md_inline(trimmed, text_style, bold_style, code_style);
548            }
549        }
550
551        if in_code_block && !code_block_lines.is_empty() {
552            for cl in &code_block_lines {
553                self.styled(cl, code_style);
554            }
555        }
556
557        // Flush any remaining table rows at end of input
558        if !table_lines.is_empty() {
559            self.render_markdown_table(
560                &table_lines,
561                text_style,
562                bold_style,
563                code_style,
564                border_style,
565            );
566        }
567
568        self.commands.push(Command::EndContainer);
569        self.rollback.last_text_idx = None;
570        Response::none()
571    }
572
573    /// Render a GFM-style pipe table collected from markdown lines.
574    fn render_markdown_table(
575        &mut self,
576        lines: &[String],
577        text_style: Style,
578        bold_style: Style,
579        code_style: Style,
580        border_style: Style,
581    ) {
582        if lines.is_empty() {
583            return;
584        }
585
586        // Separate header, separator, and data rows
587        let is_separator = |line: &str| -> bool {
588            let inner = line.trim_matches('|').trim();
589            !inner.is_empty()
590                && inner
591                    .chars()
592                    .all(|c| c == '-' || c == ':' || c == '|' || c == ' ')
593        };
594
595        let parse_row = |line: &str| -> Vec<String> {
596            let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
597            trimmed.split('|').map(|c| c.trim().to_string()).collect()
598        };
599
600        let mut header: Option<Vec<String>> = None;
601        let mut data_rows: Vec<Vec<String>> = Vec::new();
602        let mut found_separator = false;
603
604        for (i, line) in lines.iter().enumerate() {
605            if is_separator(line) {
606                found_separator = true;
607                continue;
608            }
609            if i == 0 && !found_separator {
610                header = Some(parse_row(line));
611            } else {
612                data_rows.push(parse_row(line));
613            }
614        }
615
616        // If no separator found, treat first row as header anyway
617        if !found_separator && header.is_none() && !data_rows.is_empty() {
618            header = Some(data_rows.remove(0));
619        }
620
621        // Calculate column count and widths
622        let all_rows: Vec<&Vec<String>> = header.iter().chain(data_rows.iter()).collect();
623        let col_count = all_rows.iter().map(|r| r.len()).max().unwrap_or(0);
624        if col_count == 0 {
625            return;
626        }
627        let mut col_widths = vec![0usize; col_count];
628        // Strip markdown formatting for accurate display-width calculation
629        let stripped_rows: Vec<Vec<String>> = all_rows
630            .iter()
631            .map(|row| row.iter().map(|c| Self::md_strip(c)).collect())
632            .collect();
633        for row in &stripped_rows {
634            for (i, cell) in row.iter().enumerate() {
635                if i < col_count {
636                    col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
637                }
638            }
639        }
640
641        // Top border ┌───┬───┐
642        let mut top = String::from("┌");
643        for (i, &w) in col_widths.iter().enumerate() {
644            for _ in 0..w + 2 {
645                top.push('─');
646            }
647            top.push(if i < col_count - 1 { '┬' } else { '┐' });
648        }
649        self.styled(&top, border_style);
650
651        // Header row │ H1 │ H2 │
652        if let Some(ref hdr) = header {
653            self.line(|ui| {
654                ui.styled("│", border_style);
655                for (i, w) in col_widths.iter().enumerate() {
656                    let raw = hdr.get(i).map(String::as_str).unwrap_or("");
657                    let display_text = Self::md_strip(raw);
658                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
659                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
660                    ui.styled(" ", bold_style);
661                    ui.styled(&display_text, bold_style);
662                    ui.styled(padding, bold_style);
663                    ui.styled(" │", border_style);
664                }
665            });
666
667            // Separator ├───┼───┤
668            let mut sep = String::from("├");
669            for (i, &w) in col_widths.iter().enumerate() {
670                for _ in 0..w + 2 {
671                    sep.push('─');
672                }
673                sep.push(if i < col_count - 1 { '┼' } else { '┤' });
674            }
675            self.styled(&sep, border_style);
676        }
677
678        // Data rows — render with inline formatting (bold, italic, code, links)
679        for row in &data_rows {
680            self.line(|ui| {
681                ui.styled("│", border_style);
682                for (i, w) in col_widths.iter().enumerate() {
683                    let raw = row.get(i).map(String::as_str).unwrap_or("");
684                    let display_text = Self::md_strip(raw);
685                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
686                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
687                    ui.styled(" ", text_style);
688                    Self::render_md_inline_into(ui, raw, text_style, bold_style, code_style);
689                    ui.styled(padding, text_style);
690                    ui.styled(" │", border_style);
691                }
692            });
693        }
694
695        // Bottom border └───┴───┘
696        let mut bot = String::from("└");
697        for (i, &w) in col_widths.iter().enumerate() {
698            for _ in 0..w + 2 {
699                bot.push('─');
700            }
701            bot.push(if i < col_count - 1 { '┴' } else { '┘' });
702        }
703        self.styled(&bot, border_style);
704    }
705
706    pub(crate) fn parse_inline_segments(
707        text: &str,
708        base: Style,
709        bold: Style,
710        code: Style,
711    ) -> Vec<(String, Style)> {
712        // All inline markers (`**`, `*`, `` ` ``) are single-byte ASCII, so
713        // byte-index slicing of `text` is safe — multi-byte chars in `inner`
714        // are never split. Avoids the `chars().collect::<Vec<_>>()` allocation
715        // and per-match `String` reconstructions of the prior implementation.
716        let mut segments: Vec<(String, Style)> = Vec::new();
717        let bytes = text.as_bytes();
718        let mut current = String::new();
719        let mut i: usize = 0;
720
721        while i < bytes.len() {
722            // Bold: **text**
723            if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
724                let after_open = i + 2;
725                if let Some(rel_end) = text[after_open..].find("**") {
726                    let close = after_open + rel_end;
727                    if !current.is_empty() {
728                        segments.push((std::mem::take(&mut current), base));
729                    }
730                    let inner = text[after_open..close].to_string();
731                    segments.push((inner, bold));
732                    i = close + 2;
733                    continue;
734                }
735            }
736
737            // Italic: *text* — skipped if part of a `**` run.
738            if bytes[i] == b'*'
739                && (i + 1 >= bytes.len() || bytes[i + 1] != b'*')
740                && (i == 0 || bytes[i - 1] != b'*')
741            {
742                let after_open = i + 1;
743                if let Some(rel_end) = text[after_open..].find('*') {
744                    let close = after_open + rel_end;
745                    if !current.is_empty() {
746                        segments.push((std::mem::take(&mut current), base));
747                    }
748                    let inner = text[after_open..close].to_string();
749                    segments.push((inner, base.italic()));
750                    i = close + 1;
751                    continue;
752                }
753            }
754
755            // Inline code: `text`
756            if bytes[i] == b'`' {
757                let after_open = i + 1;
758                if let Some(rel_end) = text[after_open..].find('`') {
759                    let close = after_open + rel_end;
760                    if !current.is_empty() {
761                        segments.push((std::mem::take(&mut current), base));
762                    }
763                    let inner = text[after_open..close].to_string();
764                    segments.push((inner, code));
765                    i = close + 1;
766                    continue;
767                }
768            }
769
770            // No marker — append one whole character (possibly multi-byte)
771            // and advance past it.
772            let ch = text[i..]
773                .chars()
774                .next()
775                .expect("non-empty tail past bounds check");
776            current.push(ch);
777            i += ch.len_utf8();
778        }
779
780        if !current.is_empty() {
781            segments.push((current, base));
782        }
783        segments
784    }
785
786    /// Render a markdown line with link/image support.
787    ///
788    /// Parses `[text](url)` as clickable OSC 8 links and `![alt](url)` as
789    /// image placeholders, delegating the rest to `parse_inline_segments`.
790    fn render_md_inline(
791        &mut self,
792        text: &str,
793        text_style: Style,
794        bold_style: Style,
795        code_style: Style,
796    ) {
797        let items = Self::split_md_links(text);
798
799        // Fast path: no links/images found
800        if items.len() == 1 {
801            if let MdInline::Text(ref t) = items[0] {
802                let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
803                if segs.len() <= 1 {
804                    self.text(text)
805                        .wrap()
806                        .fg(text_style.fg.unwrap_or(Color::Reset));
807                } else {
808                    self.line_wrap(|ui| {
809                        for (s, st) in segs {
810                            ui.styled(s, st);
811                        }
812                    });
813                }
814                return;
815            }
816        }
817
818        // Mixed content — line_wrap collects both Text and Link commands
819        self.line_wrap(|ui| {
820            for item in &items {
821                match item {
822                    MdInline::Text(t) => {
823                        let segs =
824                            Self::parse_inline_segments(t, text_style, bold_style, code_style);
825                        for (s, st) in segs {
826                            ui.styled(s, st);
827                        }
828                    }
829                    MdInline::Link { text, url } => {
830                        ui.link(text.clone(), url.clone());
831                    }
832                    MdInline::Image { alt, .. } => {
833                        // Render alt text only — matches md_strip() output for width consistency
834                        ui.styled(alt.as_str(), code_style);
835                    }
836                }
837            }
838        });
839    }
840
841    /// Emit inline markdown segments into an existing context.
842    ///
843    /// Unlike `render_md_inline` which wraps in its own `line_wrap`,
844    /// this emits raw commands into `ui` so callers can prepend a bullet
845    /// or prefix before calling this inside their own `line_wrap`.
846    fn render_md_inline_into(
847        ui: &mut Context,
848        text: &str,
849        text_style: Style,
850        bold_style: Style,
851        code_style: Style,
852    ) {
853        let items = Self::split_md_links(text);
854        for item in &items {
855            match item {
856                MdInline::Text(t) => {
857                    let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
858                    for (s, st) in segs {
859                        ui.styled(s, st);
860                    }
861                }
862                MdInline::Link { text, url } => {
863                    ui.link(text.clone(), url.clone());
864                }
865                MdInline::Image { alt, .. } => {
866                    ui.styled(alt.as_str(), code_style);
867                }
868            }
869        }
870    }
871
872    /// Split a markdown line into text, link, and image segments.
873    fn split_md_links(text: &str) -> Vec<MdInline> {
874        let chars: Vec<char> = text.chars().collect();
875        let mut items: Vec<MdInline> = Vec::new();
876        let mut current = String::new();
877        let mut i = 0;
878
879        while i < chars.len() {
880            // Image: ![alt](url)
881            if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
882                if let Some((alt, _url, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
883                    if !current.is_empty() {
884                        items.push(MdInline::Text(std::mem::take(&mut current)));
885                    }
886                    items.push(MdInline::Image { alt });
887                    i += 1 + consumed;
888                    continue;
889                }
890            }
891            // Link: [text](url)
892            if chars[i] == '[' {
893                if let Some((link_text, url, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
894                    if !current.is_empty() {
895                        items.push(MdInline::Text(std::mem::take(&mut current)));
896                    }
897                    items.push(MdInline::Link {
898                        text: link_text,
899                        url,
900                    });
901                    i += consumed;
902                    continue;
903                }
904            }
905            current.push(chars[i]);
906            i += 1;
907        }
908        if !current.is_empty() {
909            items.push(MdInline::Text(current));
910        }
911        if items.is_empty() {
912            items.push(MdInline::Text(String::new()));
913        }
914        items
915    }
916
917    /// Parse `[text](url)` starting at `chars[start]` which must be `[`.
918    /// Returns `(text, url, chars_consumed)` or `None` if no match.
919    fn parse_md_bracket_paren(chars: &[char], start: usize) -> Option<(String, String, usize)> {
920        if start >= chars.len() || chars[start] != '[' {
921            return None;
922        }
923        // Find closing ]
924        let mut depth = 0i32;
925        let mut bracket_end = None;
926        for (j, &ch) in chars.iter().enumerate().skip(start) {
927            if ch == '[' {
928                depth += 1;
929            } else if ch == ']' {
930                depth -= 1;
931                if depth == 0 {
932                    bracket_end = Some(j);
933                    break;
934                }
935            }
936        }
937        let bracket_end = bracket_end?;
938        // Must be followed by (
939        if bracket_end + 1 >= chars.len() || chars[bracket_end + 1] != '(' {
940            return None;
941        }
942        // Find closing )
943        let paren_start = bracket_end + 2;
944        let mut paren_end = None;
945        let mut paren_depth = 1i32;
946        for (j, &ch) in chars.iter().enumerate().skip(paren_start) {
947            if ch == '(' {
948                paren_depth += 1;
949            } else if ch == ')' {
950                paren_depth -= 1;
951                if paren_depth == 0 {
952                    paren_end = Some(j);
953                    break;
954                }
955            }
956        }
957        let paren_end = paren_end?;
958        let text: String = chars[start + 1..bracket_end].iter().collect();
959        let url: String = chars[paren_start..paren_end].iter().collect();
960        let consumed = paren_end - start + 1;
961        Some((text, url, consumed))
962    }
963
964    /// Strip markdown inline formatting, returning plain display text.
965    ///
966    /// `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`,
967    /// `[text](url)` → `text`, `![alt](url)` → `alt`.
968    fn md_strip(text: &str) -> String {
969        // Bracket/paren parsing for links/images still uses a `Vec<char>`
970        // because the helper takes a char-slice; pre-build it once and reuse
971        // the precomputed char→byte mapping for both code paths.
972        let chars: Vec<char> = text.chars().collect();
973        let char_to_byte = {
974            let mut v = Vec::with_capacity(chars.len() + 1);
975            let mut acc = 0usize;
976            v.push(0);
977            for ch in &chars {
978                acc += ch.len_utf8();
979                v.push(acc);
980            }
981            v
982        };
983        let bytes = text.as_bytes();
984        let mut result = String::with_capacity(text.len());
985        let mut ci: usize = 0;
986
987        while ci < chars.len() {
988            // Image: ![alt](url) — char-based bracket scanner is reused as-is.
989            if chars[ci] == '!' && ci + 1 < chars.len() && chars[ci + 1] == '[' {
990                if let Some((alt, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci + 1) {
991                    result.push_str(&alt);
992                    ci += 1 + consumed;
993                    continue;
994                }
995            }
996            // Link: [text](url)
997            if chars[ci] == '[' {
998                if let Some((link_text, _, consumed)) = Self::parse_md_bracket_paren(&chars, ci) {
999                    result.push_str(&link_text);
1000                    ci += consumed;
1001                    continue;
1002                }
1003            }
1004
1005            let bi = char_to_byte[ci];
1006
1007            // Bold: **text**
1008            if bytes[bi] == b'*' && bi + 1 < bytes.len() && bytes[bi + 1] == b'*' {
1009                let after_open = bi + 2;
1010                if let Some(rel_end) = text[after_open..].find("**") {
1011                    let close = after_open + rel_end;
1012                    let inner = &text[after_open..close];
1013                    result.push_str(inner);
1014                    ci += 2 + inner.chars().count() + 2;
1015                    continue;
1016                }
1017            }
1018
1019            // Italic: *text* — skipped inside a `**` run.
1020            if bytes[bi] == b'*'
1021                && (bi + 1 >= bytes.len() || bytes[bi + 1] != b'*')
1022                && (bi == 0 || bytes[bi - 1] != b'*')
1023            {
1024                let after_open = bi + 1;
1025                if let Some(rel_end) = text[after_open..].find('*') {
1026                    let close = after_open + rel_end;
1027                    let inner = &text[after_open..close];
1028                    result.push_str(inner);
1029                    ci += 1 + inner.chars().count() + 1;
1030                    continue;
1031                }
1032            }
1033
1034            // Inline code: `text`
1035            if bytes[bi] == b'`' {
1036                let after_open = bi + 1;
1037                if let Some(rel_end) = text[after_open..].find('`') {
1038                    let close = after_open + rel_end;
1039                    let inner = &text[after_open..close];
1040                    result.push_str(inner);
1041                    ci += 1 + inner.chars().count() + 1;
1042                    continue;
1043                }
1044            }
1045
1046            result.push(chars[ci]);
1047            ci += 1;
1048        }
1049        result
1050    }
1051
1052    // ── key sequence ─────────────────────────────────────────────────
1053
1054    /// Check if a sequence of character keys was pressed across recent frames.
1055    ///
1056    /// Matches when each character in `seq` appears in consecutive unconsumed
1057    /// key events within this frame. For single-frame sequences only (e.g., "gg").
1058    pub fn key_seq(&self, seq: &str) -> bool {
1059        if seq.is_empty() {
1060            return false;
1061        }
1062        if (self.rollback.modal_active || self.prev_modal_active)
1063            && self.rollback.overlay_depth == 0
1064        {
1065            return false;
1066        }
1067        let target: Vec<char> = seq.chars().collect();
1068        let mut matched = 0;
1069        for (_, key) in self.available_key_presses() {
1070            if let KeyCode::Char(c) = key.code {
1071                if c == target[matched] {
1072                    matched += 1;
1073                    if matched == target.len() {
1074                        return true;
1075                    }
1076                } else {
1077                    matched = 0;
1078                    if c == target[0] {
1079                        matched = 1;
1080                    }
1081                }
1082            }
1083        }
1084        false
1085    }
1086}