use crate::{EditorHook, HookContext, HookOutcome, KeyCode, KeyEvent, Point, Selection};
use super::config::MarkdownConfig;
use super::table::{
TableRowKind, clean_table_line, find_unescaped_pipes, split_table_cells, table_block_at,
};
#[derive(Debug, Clone)]
pub struct MarkdownHook {
interactive_tasks: bool,
table_navigation: bool,
}
impl Default for MarkdownHook {
fn default() -> Self {
Self::new()
}
}
impl MarkdownHook {
pub fn new() -> Self {
Self {
interactive_tasks: true,
table_navigation: true,
}
}
pub fn with_config(config: MarkdownConfig) -> Self {
Self {
interactive_tasks: config.interactive_tasks,
table_navigation: config.table_navigation,
}
}
pub fn set_interactive_tasks(&mut self, interactive: bool) {
self.interactive_tasks = interactive;
}
pub fn interactive_tasks(&self) -> bool {
self.interactive_tasks
}
pub fn set_table_navigation(&mut self, enabled: bool) {
self.table_navigation = enabled;
}
pub fn table_navigation(&self) -> bool {
self.table_navigation
}
fn toggle_marker_at_row(ctx: &mut HookContext, row: usize) -> bool {
if row >= ctx.buffer.len_lines() {
return false;
}
let line = ctx.buffer.line_to_string(row);
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
let old_cursor = ctx.buffer.cursor_offset();
for (empty, checked) in [("- [ ] ", "- [x] "), ("* [ ] ", "* [x] ")] {
if let Some(idx) = line.find(empty) {
let s = line_start + idx;
ctx.buffer.replace_range(s..s + 6, checked);
ctx.buffer.set_cursor_offset(old_cursor);
return true;
}
}
for (checked, empty) in [
("- [x] ", "- [ ] "),
("- [X] ", "- [ ] "),
("* [x] ", "- [ ] "),
("* [X] ", "- [ ] "),
] {
if let Some(idx) = line.find(checked) {
let s = line_start + idx;
ctx.buffer.replace_range(s..s + 6, empty);
ctx.buffer.set_cursor_offset(old_cursor);
return true;
}
}
false
}
fn toggle_checkbox(ctx: &mut HookContext) -> bool {
let row = ctx.buffer.cursor_point().row;
let line = ctx.buffer.line_to_string(row);
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
if let Some(idx) = line.find("- [ ] ") {
let target_start = line_start + idx;
ctx.buffer
.replace_range(target_start..target_start + 6, "- [x] ");
return true;
} else if let Some(idx) = line.find("- [x] ") {
let target_start = line_start + idx;
ctx.buffer
.replace_range(target_start..target_start + 6, "- [ ] ");
return true;
} else if let Some(idx) = line.find("* [ ] ") {
let target_start = line_start + idx;
ctx.buffer
.replace_range(target_start..target_start + 6, "* [x] ");
return true;
} else if let Some(idx) = line.find("* [x] ") {
let target_start = line_start + idx;
ctx.buffer
.replace_range(target_start..target_start + 6, "* [ ] ");
return true;
}
false
}
fn table_cell_starts(stripped: &str) -> Vec<usize> {
let bytes = stripped.as_bytes();
let mut starts = Vec::new();
if !stripped.trim_start().starts_with('|') {
starts.push(0);
}
for p in find_unescaped_pipes(stripped) {
let mut s = (p + 1).min(stripped.len());
while s < stripped.len() && (bytes[s] == b' ' || bytes[s] == b'\t') {
s += 1;
}
starts.push(s);
}
starts
}
fn table_tab_target(ctx: &mut HookContext, backwards: bool) -> Option<usize> {
let row = ctx.buffer.cursor_point().row;
let block = table_block_at(ctx.buffer, row)?;
if !matches!(
block.kind_at(row)?,
TableRowKind::Header | TableRowKind::Body
) {
return None;
}
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
let stripped = clean_table_line(&ctx.buffer.line_to_string(row)).to_string();
let cursor_col = ctx
.buffer
.cursor_offset()
.saturating_sub(line_start)
.min(stripped.len());
let starts = Self::table_cell_starts(&stripped);
if !backwards {
if let Some(&s) = starts.iter().find(|&&s| s > cursor_col) {
return Some(line_start + s);
}
for r in row + 1..=block.end_row {
if matches!(
block.kind_at(r),
Some(TableRowKind::Header) | Some(TableRowKind::Body)
) {
let next_start = ctx.buffer.point_to_offset(Point::new(r, 0));
let next_stripped = clean_table_line(&ctx.buffer.line_to_string(r)).to_string();
let next_cells = Self::table_cell_starts(&next_stripped);
return Some(next_start + next_cells.first().copied().unwrap_or(0));
}
}
let indent_len = stripped.len() - stripped.trim_start().len();
let indent = &stripped[..indent_len];
let skeleton = format!("{}|{}", indent, " |".repeat(block.col_count));
let line_end = line_start + stripped.len();
ctx.buffer.set_cursor_offset(line_end);
ctx.buffer.insert(&format!("\n{skeleton}"));
return Some(line_end + 1 + indent_len + 2);
}
if let Some(&s) = starts.iter().rev().find(|&&s| s < cursor_col) {
return Some(line_start + s);
}
for r in (block.header_row..row).rev() {
if matches!(
block.kind_at(r),
Some(TableRowKind::Header) | Some(TableRowKind::Body)
) {
let prev_start = ctx.buffer.point_to_offset(Point::new(r, 0));
let prev_stripped = clean_table_line(&ctx.buffer.line_to_string(r)).to_string();
let prev_cells = Self::table_cell_starts(&prev_stripped);
return Some(prev_start + prev_cells.last().copied().unwrap_or(0));
}
}
None
}
}
impl EditorHook for MarkdownHook {
fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome {
if event.modifiers.ctrl || event.modifiers.meta {
match &event.code {
KeyCode::Char('b') => {
if let Some(sel) = ctx.selection.take() {
let range = sel.byte_range();
let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
let wrapped = format!("**{}**", text);
ctx.buffer.replace_range(range.clone(), &wrapped);
*ctx.selection = Some(Selection::range(range.start + 2, range.end + 2));
} else {
ctx.buffer.insert("****");
ctx.buffer.move_cursor_left();
ctx.buffer.move_cursor_left();
}
return HookOutcome::Consumed;
}
KeyCode::Char('i') => {
if let Some(sel) = ctx.selection.take() {
let range = sel.byte_range();
let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
let wrapped = format!("*{}*", text);
ctx.buffer.replace_range(range.clone(), &wrapped);
*ctx.selection = Some(Selection::range(range.start + 1, range.end + 1));
} else {
ctx.buffer.insert("**");
ctx.buffer.move_cursor_left();
}
return HookOutcome::Consumed;
}
KeyCode::Char('k') => {
if let Some(sel) = ctx.selection.take() {
let range = sel.byte_range();
let text = ctx.buffer.text().byte_slice(range.clone()).to_string();
let wrapped = format!("[{}](url)", text);
ctx.buffer.replace_range(range.clone(), &wrapped);
let url_start = range.start + 1 + text.len() + 2;
*ctx.selection = Some(Selection::range(url_start, url_start + 3));
} else {
ctx.buffer.insert("[](url)");
ctx.buffer.move_cursor_left();
ctx.buffer.move_cursor_left();
ctx.buffer.move_cursor_left();
ctx.buffer.move_cursor_left();
ctx.buffer.move_cursor_left();
}
return HookOutcome::Consumed;
}
KeyCode::Enter if Self::toggle_checkbox(ctx) => {
return HookOutcome::Consumed;
}
_ => {}
}
}
if event.code == KeyCode::Enter && !event.modifiers.shift {
let cursor = ctx.buffer.cursor_offset();
let row = ctx.buffer.cursor_point().row;
let line = ctx.buffer.line_to_string(row);
let trimmed = line.trim_start();
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
if self.table_navigation
&& let Some(block) = table_block_at(ctx.buffer, row)
&& let Some(kind) = block.kind_at(row)
&& matches!(kind, TableRowKind::Header | TableRowKind::Body)
{
let stripped = clean_table_line(&line).to_string();
let (_, cells) = split_table_cells(&stripped);
let all_empty = cells.iter().all(|c| {
stripped
.get(c.clone())
.map(|s| s.trim().is_empty())
.unwrap_or(true)
});
if all_empty {
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
ctx.buffer.delete_range(line_start..cursor);
return HookOutcome::Consumed;
}
let table_indent_len = stripped.len() - stripped.trim_start().len();
let table_indent = &stripped[..table_indent_len];
let skeleton = format!("{}|{}", table_indent, " |".repeat(block.col_count));
ctx.buffer.insert(&format!("\n{skeleton}"));
return HookOutcome::Consumed;
}
if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") {
if trimmed == "- [ ] \n"
|| trimmed == "- [ ] \r\n"
|| trimmed == "- [ ] "
|| trimmed == "- [x] \n"
|| trimmed == "- [x] \r\n"
|| trimmed == "- [x] "
{
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
ctx.buffer.delete_range(line_start..cursor);
return HookOutcome::Consumed;
}
ctx.buffer.insert(&format!("\n{}- [ ] ", indent));
return HookOutcome::Consumed;
}
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
let bullet = &trimmed[..2];
if trimmed == "- \n"
|| trimmed == "- \r\n"
|| trimmed == "- "
|| trimmed == "* \n"
|| trimmed == "* \r\n"
|| trimmed == "* "
|| trimmed == "+ \n"
|| trimmed == "+ \r\n"
|| trimmed == "+ "
{
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
ctx.buffer.delete_range(line_start..cursor);
return HookOutcome::Consumed;
}
ctx.buffer.insert(&format!("\n{}{}", indent, bullet));
return HookOutcome::Consumed;
}
if let Some(dot_idx) = trimmed.find(". ") {
let num_str = &trimmed[..dot_idx];
if let Ok(num) = num_str.parse::<usize>() {
let rest = &trimmed[dot_idx + 2..];
if rest == "\n" || rest == "\r\n" || rest.is_empty() {
let line_start = ctx.buffer.point_to_offset(Point::new(row, 0));
ctx.buffer.delete_range(line_start..cursor);
return HookOutcome::Consumed;
}
ctx.buffer.insert(&format!("\n{}{}. ", indent, num + 1));
return HookOutcome::Consumed;
}
}
}
if event.code == KeyCode::Tab
&& !event.modifiers.ctrl
&& !event.modifiers.meta
&& !event.modifiers.alt
&& self.table_navigation
&& let Some(target) = Self::table_tab_target(ctx, event.modifiers.shift)
{
ctx.buffer.set_cursor_offset(target);
*ctx.selection = None;
return HookOutcome::Consumed;
}
HookOutcome::PassThrough
}
fn on_click(&mut self, ctx: &mut HookContext, row: usize, _col: usize) -> HookOutcome {
if !self.interactive_tasks {
return HookOutcome::PassThrough;
}
if Self::toggle_marker_at_row(ctx, row) {
return HookOutcome::Consumed;
}
HookOutcome::PassThrough
}
fn status_text(&self) -> Option<&str> {
Some("MARKDOWN")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EditorBuffer, HookContext, HookOutcome, KeyEvent, PromptState, Selection};
#[test]
fn test_markdown_hook_bold_wrapping() {
let mut buffer = EditorBuffer::new("hello world");
let mut selection = Some(Selection::range(0, 5));
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
let event = KeyEvent {
code: KeyCode::Char('b'),
modifiers: crate::Modifiers {
ctrl: true,
..Default::default()
},
};
let outcome = hook.on_key(&mut ctx, &event);
assert_eq!(outcome, HookOutcome::Consumed);
assert_eq!(ctx.buffer.text().to_string(), "**hello** world");
assert_eq!(ctx.selection.unwrap().byte_range(), 2..7);
}
#[test]
fn test_markdown_hook_checkbox_toggle() {
let mut buffer = EditorBuffer::new("- [ ] Task item");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
let event = KeyEvent {
code: KeyCode::Enter,
modifiers: crate::Modifiers {
ctrl: true,
..Default::default()
},
};
let outcome = hook.on_key(&mut ctx, &event);
assert_eq!(outcome, HookOutcome::Consumed);
assert_eq!(ctx.buffer.text().to_string(), "- [x] Task item");
let outcome2 = hook.on_key(&mut ctx, &event);
assert_eq!(outcome2, HookOutcome::Consumed);
assert_eq!(ctx.buffer.text().to_string(), "- [ ] Task item");
}
#[test]
fn test_markdown_hook_numbered_list_continuation() {
let mut buffer = EditorBuffer::new("1. First item");
buffer.set_cursor_offset(13);
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
let event = KeyEvent::plain(KeyCode::Enter);
let outcome = hook.on_key(&mut ctx, &event);
assert_eq!(outcome, HookOutcome::Consumed);
assert_eq!(ctx.buffer.text().to_string(), "1. First item\n2. ");
}
#[test]
fn test_markdown_hook_on_click_toggles_task() {
let mut buffer = EditorBuffer::new("- [ ] Task one\n- [x] Task two");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
buffer.set_cursor_offset(0);
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_click(&mut ctx, 1, 0), HookOutcome::Consumed);
assert_eq!(
ctx.buffer.text().to_string(),
"- [ ] Task one\n- [ ] Task two"
);
assert_eq!(ctx.buffer.cursor_offset(), 0);
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_click(&mut ctx, 0, 2), HookOutcome::Consumed);
assert_eq!(
ctx.buffer.text().to_string(),
"- [x] Task one\n- [ ] Task two"
);
ctx.buffer.replace_range(0..14, "- [X] Task one");
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_click(&mut ctx, 0, 3), HookOutcome::Consumed);
assert_eq!(
ctx.buffer.text().to_string(),
"- [ ] Task one\n- [ ] Task two"
);
let mut plain = EditorBuffer::new("hello");
let mut ctx = HookContext::new(
&mut plain,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_click(&mut ctx, 0, 0), HookOutcome::PassThrough);
}
#[test]
fn test_markdown_hook_on_click_respects_config() {
let mut buffer = EditorBuffer::new("- [ ] Task");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::with_config(MarkdownConfig {
interactive_tasks: false,
..Default::default()
});
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_click(&mut ctx, 0, 0), HookOutcome::PassThrough);
assert_eq!(ctx.buffer.text().to_string(), "- [ ] Task");
}
#[test]
fn test_table_hook_tab_moves_between_cells() {
let mut buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
buffer.set_cursor_offset(0);
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
HookOutcome::Consumed
);
assert_eq!(ctx.buffer.cursor_offset(), 2);
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
HookOutcome::Consumed
);
assert_eq!(ctx.buffer.cursor_offset(), 6);
let back = KeyEvent {
code: KeyCode::Tab,
modifiers: crate::Modifiers {
shift: true,
..Default::default()
},
};
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(hook.on_key(&mut ctx, &back), HookOutcome::Consumed);
assert_eq!(ctx.buffer.cursor_offset(), 2);
}
#[test]
fn test_table_hook_tab_appends_row_at_end() {
let mut buffer = EditorBuffer::new("| a |\n| --- |\n| b |");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
buffer.set_cursor_offset(buffer.len_bytes());
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
HookOutcome::Consumed
);
assert_eq!(ctx.buffer.text().to_string(), "| a |\n| --- |\n| b |\n| |");
}
#[test]
fn test_table_hook_tab_passthrough_outside_tables() {
let mut buffer = EditorBuffer::new("plain text");
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
buffer.set_cursor_offset(3);
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
HookOutcome::PassThrough
);
let mut disabled = EditorBuffer::new("| a |\n| --- |\n| b |");
disabled.set_cursor_offset(0);
let mut hook_off = MarkdownHook::with_config(MarkdownConfig {
table_navigation: false,
..Default::default()
});
let mut ctx = HookContext::new(
&mut disabled,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook_off.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Tab)),
HookOutcome::PassThrough
);
}
#[test]
fn test_table_hook_enter_continues_and_exits() {
let mut buffer = EditorBuffer::new("| a | b |\n| --- | --- |\n| c | d |");
buffer.set_cursor_offset(buffer.len_bytes());
let mut selection = None;
let mut cursor_style = crate::CursorStyle::Bar;
let mut prompt = PromptState::new();
let mut effects = Vec::new();
let mut hook = MarkdownHook::new();
let mut ctx = HookContext::new(
&mut buffer,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
HookOutcome::Consumed
);
assert_eq!(
ctx.buffer.text().to_string(),
"| a | b |\n| --- | --- |\n| c | d |\n| | |"
);
let mut empty = EditorBuffer::new("| a |\n| --- |\n| |");
empty.set_cursor_offset(empty.len_bytes());
let mut ctx = HookContext::new(
&mut empty,
&mut selection,
&mut cursor_style,
&mut prompt,
&mut effects,
);
assert_eq!(
hook.on_key(&mut ctx, &KeyEvent::plain(KeyCode::Enter)),
HookOutcome::Consumed
);
assert_eq!(ctx.buffer.text().to_string(), "| a |\n| --- |\n");
}
}