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