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