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