Skip to main content

twrite_core/batteries/markdown/
hook.rs

1use crate::{EditorHook, HookContext, HookOutcome, KeyCode, KeyEvent, Point, Selection};
2
3use super::config::MarkdownConfig;
4use super::table::{
5    TableRowKind, clean_table_line, find_unescaped_pipes, split_table_cells, table_block_at,
6};
7
8/// An editor hook providing Markdown shortcuts (Ctrl+B, Ctrl+I, Ctrl+K), smart list continuation, and task list toggles.
9#[derive(Debug, Clone)]
10pub struct MarkdownHook {
11    interactive_tasks: bool,
12    table_navigation: bool,
13}
14
15impl Default for MarkdownHook {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl MarkdownHook {
22    /// Creates a new Markdown editing hook.
23    pub fn new() -> Self {
24        Self {
25            interactive_tasks: true,
26            table_navigation: true,
27        }
28    }
29
30    /// Creates a hook honoring the given Markdown configuration.
31    pub fn with_config(config: MarkdownConfig) -> Self {
32        Self {
33            interactive_tasks: config.interactive_tasks,
34            table_navigation: config.table_navigation,
35        }
36    }
37
38    /// Updates whether mouse clicks toggle task checkboxes.
39    pub fn set_interactive_tasks(&mut self, interactive: bool) {
40        self.interactive_tasks = interactive;
41    }
42
43    /// Returns whether mouse clicks toggle task checkboxes.
44    pub fn interactive_tasks(&self) -> bool {
45        self.interactive_tasks
46    }
47
48    /// Updates whether `Tab` / `Shift+Tab` move between table cells.
49    pub fn set_table_navigation(&mut self, enabled: bool) {
50        self.table_navigation = enabled;
51    }
52
53    /// Returns whether table cell navigation is enabled.
54    pub fn table_navigation(&self) -> bool {
55        self.table_navigation
56    }
57
58    fn toggle_marker_at_row(ctx: &mut HookContext, row: usize) -> bool {
59        if row >= ctx.buffer.len_lines() {
60            return false;
61        }
62        let line = ctx.buffer.line_to_string(row);
63        let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
64        let old_cursor = ctx.buffer.cursor_offset();
65
66        // Unchecked -> checked (lowercase x, matching Ctrl+Enter behavior).
67        for (empty, checked) in [("- [ ] ", "- [x] "), ("* [ ] ", "* [x] ")] {
68            if let Some(idx) = line.find(empty) {
69                let s = line_start + idx;
70                ctx.buffer.replace_range(s..s + 6, checked);
71                ctx.buffer.set_cursor_offset(old_cursor);
72                return true;
73            }
74        }
75        // Checked (x or X) -> unchecked.
76        for (checked, empty) in [
77            ("- [x] ", "- [ ] "),
78            ("- [X] ", "- [ ] "),
79            ("* [x] ", "- [ ] "),
80            ("* [X] ", "- [ ] "),
81        ] {
82            if let Some(idx) = line.find(checked) {
83                let s = line_start + idx;
84                ctx.buffer.replace_range(s..s + 6, empty);
85                ctx.buffer.set_cursor_offset(old_cursor);
86                return true;
87            }
88        }
89        false
90    }
91
92    fn toggle_checkbox(ctx: &mut HookContext) -> bool {
93        let row = ctx.buffer.cursor_point().row;
94        let line = ctx.buffer.line_to_string(row);
95        let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
96
97        if let Some(idx) = line.find("- [ ] ") {
98            let target_start = line_start + idx;
99            ctx.buffer
100                .replace_range(target_start..target_start + 6, "- [x] ");
101            return true;
102        } else if let Some(idx) = line.find("- [x] ") {
103            let target_start = line_start + idx;
104            ctx.buffer
105                .replace_range(target_start..target_start + 6, "- [ ] ");
106            return true;
107        } else if let Some(idx) = line.find("* [ ] ") {
108            let target_start = line_start + idx;
109            ctx.buffer
110                .replace_range(target_start..target_start + 6, "* [x] ");
111            return true;
112        } else if let Some(idx) = line.find("* [x] ") {
113            let target_start = line_start + idx;
114            ctx.buffer
115                .replace_range(target_start..target_start + 6, "* [ ] ");
116            return true;
117        }
118        false
119    }
120
121    /// Cell-content starts for a stripped table line: byte offset just after
122    /// each separator pipe (skipping one run of padding spaces), plus offset
123    /// `0` when the line does not open with a pipe.
124    fn table_cell_starts(stripped: &str) -> Vec<usize> {
125        let bytes = stripped.as_bytes();
126        let mut starts = Vec::new();
127        if !stripped.trim_start().starts_with('|') {
128            starts.push(0);
129        }
130        for p in find_unescaped_pipes(stripped) {
131            let mut s = (p + 1).min(stripped.len());
132            while s < stripped.len() && (bytes[s] == b' ' || bytes[s] == b'\t') {
133                s += 1;
134            }
135            starts.push(s);
136        }
137        starts
138    }
139
140    /// Computes the cursor target for `Tab` (forward) / `Shift+Tab` (backward)
141    /// inside GFM table header/body rows, appending a skeleton row when
142    /// tabbing past the last cell. Returns `None` to fall through to the
143    /// default handler (e.g. outside tables, on delimiter rows).
144    fn table_tab_target(ctx: &mut HookContext, backwards: bool) -> Option<usize> {
145        let row = ctx.buffer.cursor_point().row;
146        let block = table_block_at(ctx.buffer, row)?;
147        if !matches!(
148            block.kind_at(row)?,
149            TableRowKind::Header | TableRowKind::Body
150        ) {
151            return None;
152        }
153        let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
154        let stripped = clean_table_line(&ctx.buffer.line_to_string(row)).to_string();
155        let cursor_col = ctx
156            .buffer
157            .cursor_offset()
158            .saturating_sub(line_start)
159            .min(stripped.len());
160        let starts = Self::table_cell_starts(&stripped);
161
162        if !backwards {
163            if let Some(&s) = starts.iter().find(|&&s| s > cursor_col) {
164                return Some(line_start + s);
165            }
166            // Last cell: move into the next data row, else append a skeleton.
167            for r in row + 1..=block.end_row {
168                if matches!(
169                    block.kind_at(r),
170                    Some(TableRowKind::Header) | Some(TableRowKind::Body)
171                ) {
172                    let next_start = ctx.buffer.point_to_offset(Point::new(r, 0));
173                    let next_stripped = clean_table_line(&ctx.buffer.line_to_string(r)).to_string();
174                    let next_cells = Self::table_cell_starts(&next_stripped);
175                    return Some(next_start + next_cells.first().copied().unwrap_or(0));
176                }
177            }
178            let indent_len = stripped.len() - stripped.trim_start().len();
179            let indent = &stripped[..indent_len];
180            let skeleton = format!("{}|{}", indent, " |".repeat(block.col_count));
181            let line_end = line_start + stripped.len();
182            ctx.buffer.set_cursor_offset(line_end);
183            ctx.buffer.insert(&format!("\n{skeleton}"));
184            return Some(line_end + 1 + indent_len + 2);
185        }
186
187        if let Some(&s) = starts.iter().rev().find(|&&s| s < cursor_col) {
188            return Some(line_start + s);
189        }
190        // First cell: move into the previous data row's last cell.
191        for r in (block.header_row..row).rev() {
192            if matches!(
193                block.kind_at(r),
194                Some(TableRowKind::Header) | Some(TableRowKind::Body)
195            ) {
196                let prev_start = ctx.buffer.point_to_offset(Point::new(r, 0));
197                let prev_stripped = clean_table_line(&ctx.buffer.line_to_string(r)).to_string();
198                let prev_cells = Self::table_cell_starts(&prev_stripped);
199                return Some(prev_start + prev_cells.last().copied().unwrap_or(0));
200            }
201        }
202        None
203    }
204}
205
206impl EditorHook for MarkdownHook {
207    fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
208        if event.modifiers.ctrl || event.modifiers.meta {
209            match &event.code {
210                KeyCode::Char('b') => {
211                    if let Some(sel) = ctx.selection.take() {
212                        let range = sel.byte_range();
213                        let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
214                        let wrapped = format!("**{}**", text);
215                        ctx.buffer.replace_range(range.clone(), &wrapped);
216                        *ctx.selection = Some(Selection::range(range.start + 2, range.end + 2));
217                    } else {
218                        ctx.buffer.insert("****");
219                        ctx.buffer.move_cursor_left();
220                        ctx.buffer.move_cursor_left();
221                    }
222                    return HookOutcome::Consumed;
223                }
224                KeyCode::Char('i') => {
225                    if let Some(sel) = ctx.selection.take() {
226                        let range = sel.byte_range();
227                        let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
228                        let wrapped = format!("*{}*", text);
229                        ctx.buffer.replace_range(range.clone(), &wrapped);
230                        *ctx.selection = Some(Selection::range(range.start + 1, range.end + 1));
231                    } else {
232                        ctx.buffer.insert("**");
233                        ctx.buffer.move_cursor_left();
234                    }
235                    return HookOutcome::Consumed;
236                }
237                KeyCode::Char('k') => {
238                    if let Some(sel) = ctx.selection.take() {
239                        let range = sel.byte_range();
240                        let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
241                        let wrapped = format!("[{}](url)", text);
242                        ctx.buffer.replace_range(range.clone(), &wrapped);
243                        let url_start = range.start + 1 + text.len() + 2;
244                        *ctx.selection = Some(Selection::range(url_start, url_start + 3));
245                    } else {
246                        ctx.buffer.insert("[](url)");
247                        ctx.buffer.move_cursor_left();
248                        ctx.buffer.move_cursor_left();
249                        ctx.buffer.move_cursor_left();
250                        ctx.buffer.move_cursor_left();
251                        ctx.buffer.move_cursor_left();
252                    }
253                    return HookOutcome::Consumed;
254                }
255                KeyCode::Enter if Self::toggle_checkbox(ctx) => {
256                    return HookOutcome::Consumed;
257                }
258                _ => {}
259            }
260        }
261
262        if event.code == KeyCode::Enter && !event.modifiers.shift {
263            let cursor = ctx.buffer.cursor_offset();
264            let row = ctx.buffer.cursor_point().row;
265            let line = ctx.buffer.line_to_string(row);
266            let trimmed = line.trim_start();
267            let indent_len = line.len() - trimmed.len();
268            let indent = &line[..indent_len];
269
270            // GFM table row continuation (before list handling: table rows
271            // start with `|`, never with a list marker).
272            if self.table_navigation
273                && let Some(block) = table_block_at(ctx.buffer, row)
274                && let Some(kind) = block.kind_at(row)
275                && matches!(kind, TableRowKind::Header | TableRowKind::Body)
276            {
277                let stripped = clean_table_line(&line).to_string();
278                let (_, cells) = split_table_cells(&stripped);
279                let all_empty = cells.iter().all(|c| {
280                    stripped
281                        .get(c.clone())
282                        .map(|s| s.trim().is_empty())
283                        .unwrap_or(true)
284                });
285                if all_empty {
286                    // Empty row exits the table, mirroring empty list items.
287                    let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
288                    ctx.buffer.delete_range(line_start..cursor);
289                    return HookOutcome::Consumed;
290                }
291                let table_indent_len = stripped.len() - stripped.trim_start().len();
292                let table_indent = &stripped[..table_indent_len];
293                let skeleton = format!("{}|{}", table_indent, " |".repeat(block.col_count));
294                ctx.buffer.insert(&format!("\n{skeleton}"));
295                return HookOutcome::Consumed;
296            }
297
298            if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") {
299                if trimmed == "- [ ] \n"
300                    || trimmed == "- [ ] \r\n"
301                    || trimmed == "- [ ] "
302                    || trimmed == "- [x] \n"
303                    || trimmed == "- [x] \r\n"
304                    || trimmed == "- [x] "
305                {
306                    let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
307                    ctx.buffer.delete_range(line_start..cursor);
308                    return HookOutcome::Consumed;
309                }
310                ctx.buffer.insert(&format!("\n{}- [ ] ", indent));
311                return HookOutcome::Consumed;
312            }
313
314            if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
315                let bullet = &trimmed[..2];
316                if trimmed == "- \n"
317                    || trimmed == "- \r\n"
318                    || trimmed == "- "
319                    || trimmed == "* \n"
320                    || trimmed == "* \r\n"
321                    || trimmed == "* "
322                    || trimmed == "+ \n"
323                    || trimmed == "+ \r\n"
324                    || trimmed == "+ "
325                {
326                    let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
327                    ctx.buffer.delete_range(line_start..cursor);
328                    return HookOutcome::Consumed;
329                }
330                ctx.buffer.insert(&format!("\n{}{}", indent, bullet));
331                return HookOutcome::Consumed;
332            }
333
334            if let Some(dot_idx) = trimmed.find(". ") {
335                let num_str = &trimmed[..dot_idx];
336                if let Ok(num) = num_str.parse::<usize>() {
337                    let rest = &trimmed[dot_idx + 2..];
338                    if rest == "\n" || rest == "\r\n" || rest.is_empty() {
339                        let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
340                        ctx.buffer.delete_range(line_start..cursor);
341                        return HookOutcome::Consumed;
342                    }
343                    ctx.buffer.insert(&format!("\n{}{}. ", indent, num + 1));
344                    return HookOutcome::Consumed;
345                }
346            }
347        }
348
349        // `Tab` / `Shift+Tab` cell navigation inside GFM tables. Runs after
350        // `Enter` handling so plain indent-Tab still applies outside tables;
351        // returning `Consumed` overrides the editor's default tab-size spaces.
352        if event.code == KeyCode::Tab
353            && !event.modifiers.ctrl
354            && !event.modifiers.meta
355            && !event.modifiers.alt
356            && self.table_navigation
357            && let Some(target) = Self::table_tab_target(ctx, event.modifiers.shift)
358        {
359            ctx.buffer.set_cursor_offset(target);
360            *ctx.selection = None;
361            return HookOutcome::Consumed;
362        }
363
364        HookOutcome::PassThrough
365    }
366
367    fn on_click(&mut self, ctx: &mut HookContext, row: usize, _col: usize) -> HookOutcome {
368        if !self.interactive_tasks {
369            return HookOutcome::PassThrough;
370        }
371        if Self::toggle_marker_at_row(ctx, row) {
372            return HookOutcome::Consumed;
373        }
374        HookOutcome::PassThrough
375    }
376
377    fn status_text(&self) -> Option<&str> {
378        Some("MARKDOWN")
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::{EditorBuffer, HookContext, HookOutcome, KeyEvent, PromptState, Selection};
386
387    #[test]
388    fn test_markdown_hook_bold_wrapping() {
389        let mut buffer = EditorBuffer::new("hello world");
390        let mut selection = Some(Selection::range(0, 5));
391        let mut cursor_style = crate::CursorStyle::Bar;
392        let mut prompt = PromptState::new();
393        let mut effects = Vec::new();
394        let mut hook = MarkdownHook::new();
395
396        let mut ctx = HookContext::new(
397            &mut buffer,
398            &mut selection,
399            &mut cursor_style,
400            &mut prompt,
401            &mut effects,
402        );
403        let event = KeyEvent {
404            code: KeyCode::Char('b'),
405            modifiers: crate::Modifiers {
406                ctrl: true,
407                ..Default::default()
408            },
409        };
410
411        let outcome = hook.on_key(&mut ctx, &event);
412        assert_eq!(outcome, HookOutcome::Consumed);
413        assert_eq!(ctx.buffer.text().to_string(), "**hello** world");
414        assert_eq!(ctx.selection.unwrap().byte_range(), 2..7);
415    }
416
417    #[test]
418    fn test_markdown_hook_checkbox_toggle() {
419        let mut buffer = EditorBuffer::new("- [ ] Task item");
420        let mut selection = None;
421        let mut cursor_style = crate::CursorStyle::Bar;
422        let mut prompt = PromptState::new();
423        let mut effects = Vec::new();
424        let mut hook = MarkdownHook::new();
425
426        let mut ctx = HookContext::new(
427            &mut buffer,
428            &mut selection,
429            &mut cursor_style,
430            &mut prompt,
431            &mut effects,
432        );
433        let event = KeyEvent {
434            code: KeyCode::Enter,
435            modifiers: crate::Modifiers {
436                ctrl: true,
437                ..Default::default()
438            },
439        };
440
441        let outcome = hook.on_key(&mut ctx, &event);
442        assert_eq!(outcome, HookOutcome::Consumed);
443        assert_eq!(ctx.buffer.text().to_string(), "- [x] Task item");
444
445        let outcome2 = hook.on_key(&mut ctx, &event);
446        assert_eq!(outcome2, HookOutcome::Consumed);
447        assert_eq!(ctx.buffer.text().to_string(), "- [ ] Task item");
448    }
449
450    #[test]
451    fn test_markdown_hook_numbered_list_continuation() {
452        let mut buffer = EditorBuffer::new("1. First item");
453        buffer.set_cursor_offset(13);
454        let mut selection = None;
455        let mut cursor_style = crate::CursorStyle::Bar;
456        let mut prompt = PromptState::new();
457        let mut effects = Vec::new();
458        let mut hook = MarkdownHook::new();
459
460        let mut ctx = HookContext::new(
461            &mut buffer,
462            &mut selection,
463            &mut cursor_style,
464            &mut prompt,
465            &mut effects,
466        );
467        let event = KeyEvent::plain(KeyCode::Enter);
468
469        let outcome = hook.on_key(&mut ctx, &event);
470        assert_eq!(outcome, HookOutcome::Consumed);
471        assert_eq!(ctx.buffer.text().to_string(), "1. First item\n2. ");
472    }
473
474    #[test]
475    fn test_markdown_hook_on_click_toggles_task() {
476        let mut buffer = EditorBuffer::new("- [ ] Task one\n- [x] Task two");
477        let mut selection = None;
478        let mut cursor_style = crate::CursorStyle::Bar;
479        let mut prompt = PromptState::new();
480        let mut effects = Vec::new();
481        let mut hook = MarkdownHook::new();
482
483        // Click row 1 (checked -> unchecked), cursor stays put.
484        buffer.set_cursor_offset(0);
485        let mut ctx = HookContext::new(
486            &mut buffer,
487            &mut selection,
488            &mut cursor_style,
489            &mut prompt,
490            &mut effects,
491        );
492        assert_eq!(hook.on_click(&mut ctx, 1, 0), HookOutcome::Consumed);
493        assert_eq!(
494            ctx.buffer.text().to_string(),
495            "- [ ] Task one\n- [ ] Task two"
496        );
497        assert_eq!(ctx.buffer.cursor_offset(), 0);
498
499        // Click row 0 (unchecked -> checked).
500        let mut ctx = HookContext::new(
501            &mut buffer,
502            &mut selection,
503            &mut cursor_style,
504            &mut prompt,
505            &mut effects,
506        );
507        assert_eq!(hook.on_click(&mut ctx, 0, 2), HookOutcome::Consumed);
508        assert_eq!(
509            ctx.buffer.text().to_string(),
510            "- [x] Task one\n- [ ] Task two"
511        );
512
513        // Uppercase [X] also toggles.
514        ctx.buffer.replace_range(0..14, "- [X] Task one");
515        let mut ctx = HookContext::new(
516            &mut buffer,
517            &mut selection,
518            &mut cursor_style,
519            &mut prompt,
520            &mut effects,
521        );
522        assert_eq!(hook.on_click(&mut ctx, 0, 3), HookOutcome::Consumed);
523        assert_eq!(
524            ctx.buffer.text().to_string(),
525            "- [ ] Task one\n- [ ] Task two"
526        );
527
528        // Plain line passes through.
529        let mut plain = EditorBuffer::new("hello");
530        let mut ctx = HookContext::new(
531            &mut plain,
532            &mut selection,
533            &mut cursor_style,
534            &mut prompt,
535            &mut effects,
536        );
537        assert_eq!(hook.on_click(&mut ctx, 0, 0), HookOutcome::PassThrough);
538    }
539
540    #[test]
541    fn test_markdown_hook_on_click_respects_config() {
542        let mut buffer = EditorBuffer::new("- [ ] Task");
543        let mut selection = None;
544        let mut cursor_style = crate::CursorStyle::Bar;
545        let mut prompt = PromptState::new();
546        let mut effects = Vec::new();
547        let mut hook = MarkdownHook::with_config(MarkdownConfig {
548            interactive_tasks: false,
549            ..Default::default()
550        });
551        let mut ctx = HookContext::new(
552            &mut buffer,
553            &mut selection,
554            &mut cursor_style,
555            &mut prompt,
556            &mut effects,
557        );
558        assert_eq!(hook.on_click(&mut ctx, 0, 0), HookOutcome::PassThrough);
559        assert_eq!(ctx.buffer.text().to_string(), "- [ ] Task");
560    }
561
562    #[test]
563    fn test_table_hook_tab_moves_between_cells() {
564        let mut buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |");
565        let mut selection = None;
566        let mut cursor_style = crate::CursorStyle::Bar;
567        let mut prompt = PromptState::new();
568        let mut effects = Vec::new();
569        let mut hook = MarkdownHook::new();
570        buffer.set_cursor_offset(0);
571
572        let mut ctx = HookContext::new(
573            &mut buffer,
574            &mut selection,
575            &mut cursor_style,
576            &mut prompt,
577            &mut effects,
578        );
579        assert_eq!(
580            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
581            HookOutcome::Consumed
582        );
583        assert_eq!(ctx.buffer.cursor_offset(), 2); // start of `a`
584
585        let mut ctx = HookContext::new(
586            &mut buffer,
587            &mut selection,
588            &mut cursor_style,
589            &mut prompt,
590            &mut effects,
591        );
592        assert_eq!(
593            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
594            HookOutcome::Consumed
595        );
596        assert_eq!(ctx.buffer.cursor_offset(), 6); // start of `b`
597
598        // Shift+Tab goes back.
599        let back = KeyEvent {
600            code: KeyCode::Tab,
601            modifiers: crate::Modifiers {
602                shift: true,
603                ..Default::default()
604            },
605        };
606        let mut ctx = HookContext::new(
607            &mut buffer,
608            &mut selection,
609            &mut cursor_style,
610            &mut prompt,
611            &mut effects,
612        );
613        assert_eq!(hook.on_key(&mut ctx, &back), HookOutcome::Consumed);
614        assert_eq!(ctx.buffer.cursor_offset(), 2);
615    }
616
617    #[test]
618    fn test_table_hook_tab_appends_row_at_end() {
619        let mut buffer = EditorBuffer::new("| a |\n| --- |\n| b |");
620        let mut selection = None;
621        let mut cursor_style = crate::CursorStyle::Bar;
622        let mut prompt = PromptState::new();
623        let mut effects = Vec::new();
624        let mut hook = MarkdownHook::new();
625        buffer.set_cursor_offset(buffer.len_bytes());
626
627        let mut ctx = HookContext::new(
628            &mut buffer,
629            &mut selection,
630            &mut cursor_style,
631            &mut prompt,
632            &mut effects,
633        );
634        assert_eq!(
635            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
636            HookOutcome::Consumed
637        );
638        assert_eq!(ctx.buffer.text().to_string(), "| a |\n| --- |\n| b |\n| |");
639    }
640
641    #[test]
642    fn test_table_hook_tab_passthrough_outside_tables() {
643        let mut buffer = EditorBuffer::new("plain text");
644        let mut selection = None;
645        let mut cursor_style = crate::CursorStyle::Bar;
646        let mut prompt = PromptState::new();
647        let mut effects = Vec::new();
648        let mut hook = MarkdownHook::new();
649        buffer.set_cursor_offset(3);
650
651        let mut ctx = HookContext::new(
652            &mut buffer,
653            &mut selection,
654            &mut cursor_style,
655            &mut prompt,
656            &mut effects,
657        );
658        assert_eq!(
659            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
660            HookOutcome::PassThrough
661        );
662
663        let mut disabled = EditorBuffer::new("| a |\n| --- |\n| b |");
664        disabled.set_cursor_offset(0);
665        let mut hook_off = MarkdownHook::with_config(MarkdownConfig {
666            table_navigation: false,
667            ..Default::default()
668        });
669        let mut ctx = HookContext::new(
670            &mut disabled,
671            &mut selection,
672            &mut cursor_style,
673            &mut prompt,
674            &mut effects,
675        );
676        assert_eq!(
677            hook_off.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
678            HookOutcome::PassThrough
679        );
680    }
681
682    #[test]
683    fn test_table_hook_enter_continues_and_exits() {
684        // Continuation inserts a skeleton row at the cursor.
685        let mut buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |");
686        buffer.set_cursor_offset(buffer.len_bytes());
687        let mut selection = None;
688        let mut cursor_style = crate::CursorStyle::Bar;
689        let mut prompt = PromptState::new();
690        let mut effects = Vec::new();
691        let mut hook = MarkdownHook::new();
692        let mut ctx = HookContext::new(
693            &mut buffer,
694            &mut selection,
695            &mut cursor_style,
696            &mut prompt,
697            &mut effects,
698        );
699        assert_eq!(
700            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
701            HookOutcome::Consumed
702        );
703        assert_eq!(
704            ctx.buffer.text().to_string(),
705            "| a | b |\n| --- | --- |\n| c | d |\n| | |"
706        );
707
708        // An all-blank row exits the table like empty list items do.
709        let mut empty = EditorBuffer::new("| a |\n| --- |\n| |");
710        empty.set_cursor_offset(empty.len_bytes());
711        let mut ctx = HookContext::new(
712            &mut empty,
713            &mut selection,
714            &mut cursor_style,
715            &mut prompt,
716            &mut effects,
717        );
718        assert_eq!(
719            hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
720            HookOutcome::Consumed
721        );
722        assert_eq!(ctx.buffer.text().to_string(), "| a |\n| --- |\n");
723    }
724}