diff --git a/src/app.rs b/src/app.rs index daf3df0..ddb14d8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,10 +19,10 @@ use crate::{ commands::{Command, parse_command}, cursor::Cursor, motions, - render::{visible_rows, visual_line_bounds, wrap_index_for_column, wrap_line}, + render::visible_rows, }, fs::tree::FileTree, - markdown::inline::{LinkKind, link_at_column}, + markdown::inline::LinkKind, markdown::{highlight::concealed_wrap_line, table::TableLayout}, }; @@ -387,20 +387,7 @@ impl App { fn selected_text(&self) -> Option { let selection = self.text_selection?; let (start, end) = selection.ordered(); - let start = self.buffer.char_index(start); - let end = self.buffer.char_index(end); - if start == end { - return None; - } - - Some( - self.buffer - .as_string() - .chars() - .skip(start) - .take(end.saturating_sub(start)) - .collect(), - ) + self.buffer.selected_markdown(start, end) } fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> { @@ -479,13 +466,17 @@ impl App { self.mode = Mode::Insert; } KeyCode::Char('h') | KeyCode::Left => { - motions::left(&mut self.cursor); + if !self.move_table_cursor_horizontally(-1) { + motions::left(&mut self.cursor); + } self.reset_preferred_column(); } KeyCode::Char('j') | KeyCode::Down => self.visual_line_down(), KeyCode::Char('k') | KeyCode::Up => self.visual_line_up(), KeyCode::Char('l') | KeyCode::Right => { - motions::right(&self.buffer, &mut self.cursor); + if !self.move_table_cursor_horizontally(1) { + motions::right(&self.buffer, &mut self.cursor); + } self.reset_preferred_column(); } KeyCode::Char('w') => { @@ -561,7 +552,9 @@ impl App { match key.code { KeyCode::Esc => self.mode = Mode::Normal, KeyCode::Enter => { - insert_newline_with_list_continuation(&mut self.buffer, &mut self.cursor); + if !self.buffer.enter_table_cell(&mut self.cursor) { + insert_newline_with_list_continuation(&mut self.buffer, &mut self.cursor); + } self.reset_preferred_column(); } KeyCode::Backspace => { @@ -583,7 +576,13 @@ impl App { self.reset_preferred_column(); } KeyCode::Tab => { - self.buffer.insert_str(&mut self.cursor, " "); + if !self.buffer.move_table_cell(&mut self.cursor, 1) { + self.buffer.insert_str(&mut self.cursor, " "); + } + self.reset_preferred_column(); + } + KeyCode::BackTab => { + self.buffer.move_table_cell(&mut self.cursor, -1); self.reset_preferred_column(); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -657,13 +656,17 @@ impl App { self.delete_visual_lines(); } KeyCode::Char('h') | KeyCode::Left => { - motions::left(&mut self.cursor); + if !self.move_table_cursor_horizontally(-1) { + motions::left(&mut self.cursor); + } self.reset_preferred_column(); } KeyCode::Char('j') | KeyCode::Down => self.visual_line_down(), KeyCode::Char('k') | KeyCode::Up => self.visual_line_up(), KeyCode::Char('l') | KeyCode::Right => { - motions::right(&self.buffer, &mut self.cursor); + if !self.move_table_cursor_horizontally(1) { + motions::right(&self.buffer, &mut self.cursor); + } self.reset_preferred_column(); } KeyCode::Char('w') => { @@ -1105,6 +1108,32 @@ impl App { self.should_quit = true; } Command::Edit(path) => self.open_path(&path)?, + Command::Table { rows, columns } => { + self.buffer.insert_table(rows, columns, &mut self.cursor); + self.set_status(format!("Inserted {rows}x{columns} table")); + } + Command::TableRow { placement } => { + if self + .buffer + .insert_table_row_at_cursor(&mut self.cursor, placement) + { + self.set_status("Inserted table row"); + self.reset_preferred_column(); + } else { + self.set_status("Not in a table"); + } + } + Command::TableColumn { placement } => { + if self + .buffer + .insert_table_column_at_cursor(&mut self.cursor, placement) + { + self.set_status("Inserted table column"); + self.reset_preferred_column(); + } else { + self.set_status("Not in a table"); + } + } Command::Unknown(value) => { self.set_status(format!("Unknown command: {value}")); } @@ -1232,9 +1261,7 @@ impl App { self.viewport.visible_height, width, |line_num, text, width| { - if line_num == self.cursor.line { - wrap_line(text, width) - } else if table_layout.is_table_row(line_num) { + if table_layout.is_table_row(line_num) { table_layout.wrap_line(line_num, text, width) } else { concealed_wrap_line(text, width) @@ -1296,11 +1323,7 @@ impl App { } fn keep_cursor_in_scrolled_viewport(&mut self, width: usize) { - let cursor_wrap = wrap_index_for_column( - &self.buffer.line(self.cursor.line), - self.cursor.column, - width, - ); + let cursor_wrap = self.wrap_index_for_column(self.cursor.line, self.cursor.column, width); let preferred_column = self.current_visual_column(width); if visual_position_before( @@ -1330,8 +1353,14 @@ impl App { } fn current_visual_column(&self, width: usize) -> usize { - let line_text = self.buffer.line(self.cursor.line); - let (segment_start, _) = visual_line_bounds(&line_text, self.cursor.column, width); + if let Some((_, column)) = + self.table_visual_position(self.cursor.line, self.cursor.column, width) + { + return column; + } + + let (segment_start, _) = + self.visual_line_bounds(self.cursor.line, self.cursor.column, width); self.cursor.column.saturating_sub(segment_start) } @@ -1356,9 +1385,13 @@ impl App { preferred_column: usize, ) -> Cursor { let line = line.min(self.buffer.line_count().saturating_sub(1)); - let line_text = self.buffer.line(line); - let trimmed = line_text.trim_end_matches(['\r', '\n']); - let (segments, _) = wrap_line(trimmed, width); + if let Some(column) = + self.table_source_column_for_visual_position(line, wrap_index, preferred_column, width) + { + return Cursor { line, column }; + } + + let (segments, _) = self.line_wrap_segments(line, width); let segment_index = wrap_index.min(segments.len().saturating_sub(1)); let Some(&(start, end)) = segments.get(segment_index) else { return Cursor { line, column: 0 }; @@ -1377,16 +1410,33 @@ impl App { } fn visual_line_start(&mut self) { - let line_text = self.buffer.line(self.cursor.line); let width = self.wrap_width(); - let (seg_start, _) = visual_line_bounds(&line_text, self.cursor.column, width); + if let Some(column) = self.table_source_column_for_visual_position( + self.cursor.line, + self.wrap_index_for_column(self.cursor.line, self.cursor.column, width), + 0, + width, + ) { + self.cursor.column = column; + return; + } + + let (seg_start, _) = self.visual_line_bounds(self.cursor.line, self.cursor.column, width); self.cursor.column = seg_start; } fn visual_line_end(&mut self) { - let line_text = self.buffer.line(self.cursor.line); let width = self.wrap_width(); - let (_, seg_end) = visual_line_bounds(&line_text, self.cursor.column, width); + if let Some(column) = self.table_source_column_for_visual_end( + self.cursor.line, + self.wrap_index_for_column(self.cursor.line, self.cursor.column, width), + width, + ) { + self.cursor.column = column; + return; + } + + let (_, seg_end) = self.visual_line_bounds(self.cursor.line, self.cursor.column, width); self.cursor.column = seg_end.min(self.buffer.line_len_chars(self.cursor.line)); } @@ -1539,8 +1589,7 @@ impl App { width: usize, ) { let line = line.min(self.buffer.line_count().saturating_sub(1)); - let line_text = self.buffer.line(line); - let (segments, _) = wrap_line(line_text.trim_end_matches(['\r', '\n']), width); + let (segments, _) = self.line_wrap_segments(line, width); let Some(&(start, end)) = segments.get(segment_index) else { self.cursor.line = line; self.cursor.column = self.buffer.line_len_chars(line); @@ -1559,61 +1608,85 @@ impl App { } fn visual_line_down(&mut self) { - let line_text = self.buffer.line(self.cursor.line); let width = self.wrap_width(); - let (segments, _) = wrap_line(line_text.trim_end_matches(['\r', '\n']), width); - let current_seg = wrap_index_for_column(&line_text, self.cursor.column, width); - let segment_start = segments - .get(current_seg) - .map(|(start, _)| *start) - .unwrap_or_default(); + let (segments, _) = self.line_wrap_segments(self.cursor.line, width); + let current_seg = self.wrap_index_for_column(self.cursor.line, self.cursor.column, width); + let visual_column = self.current_visual_column(width); + let segment_start = if self.is_table_row(self.cursor.line) { + self.cursor.column.saturating_sub(visual_column) + } else { + segments + .get(current_seg) + .map(|(start, _)| *start) + .unwrap_or_default() + }; let rel = self.preferred_visual_column(segment_start); if current_seg + 1 < segments.len() { - let (next_start, next_end) = segments[current_seg + 1]; - let max_col = visual_segment_max_column( - &segments, + if let Some(column) = self.table_source_column_for_visual_position( + self.cursor.line, current_seg + 1, - self.buffer.line_len_chars(self.cursor.line), - next_end, - ); - self.cursor.column = if next_start + rel > max_col { - max_col + rel, + width, + ) { + self.cursor.column = column; } else { - next_start + rel - }; + let (next_start, next_end) = segments[current_seg + 1]; + let max_col = visual_segment_max_column( + &segments, + current_seg + 1, + self.buffer.line_len_chars(self.cursor.line), + next_end, + ); + self.cursor.column = if next_start + rel > max_col { + max_col + } else { + next_start + rel + }; + } } else if self.cursor.line + 1 < self.buffer.line_count() { self.move_to_visual_segment_preserving_column(self.cursor.line + 1, 0, width); } } fn visual_line_up(&mut self) { - let line_text = self.buffer.line(self.cursor.line); let width = self.wrap_width(); - let (segments, _) = wrap_line(line_text.trim_end_matches(['\r', '\n']), width); - let current_seg = wrap_index_for_column(&line_text, self.cursor.column, width); - let segment_start = segments - .get(current_seg) - .map(|(start, _)| *start) - .unwrap_or_default(); + let (segments, _) = self.line_wrap_segments(self.cursor.line, width); + let current_seg = self.wrap_index_for_column(self.cursor.line, self.cursor.column, width); + let visual_column = self.current_visual_column(width); + let segment_start = if self.is_table_row(self.cursor.line) { + self.cursor.column.saturating_sub(visual_column) + } else { + segments + .get(current_seg) + .map(|(start, _)| *start) + .unwrap_or_default() + }; let rel = self.preferred_visual_column(segment_start); if current_seg > 0 { - let (prev_start, prev_end) = segments[current_seg - 1]; - let max_col = visual_segment_max_column( - &segments, + if let Some(column) = self.table_source_column_for_visual_position( + self.cursor.line, current_seg - 1, - self.buffer.line_len_chars(self.cursor.line), - prev_end, - ); - self.cursor.column = if prev_start + rel > max_col { - max_col + rel, + width, + ) { + self.cursor.column = column; } else { - prev_start + rel - }; + let (prev_start, prev_end) = segments[current_seg - 1]; + let max_col = visual_segment_max_column( + &segments, + current_seg - 1, + self.buffer.line_len_chars(self.cursor.line), + prev_end, + ); + self.cursor.column = if prev_start + rel > max_col { + max_col + } else { + prev_start + rel + }; + } } else if self.cursor.line > 0 { let previous_line = self.cursor.line - 1; - let previous_text = self.buffer.line(previous_line); - let (previous_segments, _) = - wrap_line(previous_text.trim_end_matches(['\r', '\n']), width); + let (previous_segments, _) = self.line_wrap_segments(previous_line, width); self.move_to_visual_segment_preserving_column( previous_line, previous_segments.len().saturating_sub(1), @@ -1684,9 +1757,7 @@ impl App { } fn follow_link_under_cursor(&mut self) -> Result<()> { - let line = self.buffer.line(self.cursor.line); - let source = line.trim_end_matches(['\r', '\n']); - let Some(link) = link_at_column(source, self.cursor.column) else { + let Some(link) = self.buffer.link_at_cursor(self.cursor) else { self.set_status("No link under cursor"); return Ok(()); }; @@ -1736,11 +1807,7 @@ impl App { let width = self.wrap_width(); self.normalize_viewport(width); - let cursor_wrap = wrap_index_for_column( - &self.buffer.line(self.cursor.line), - self.cursor.column, - width, - ); + let cursor_wrap = self.wrap_index_for_column(self.cursor.line, self.cursor.column, width); if visual_position_before( self.cursor.line, cursor_wrap, @@ -1847,42 +1914,218 @@ impl App { (line, wrap) } + fn is_table_row(&self, line: usize) -> bool { + TableLayout::new(&self.buffer).is_table_row(line) + } + + fn table_visual_position( + &self, + line: usize, + column: usize, + width: usize, + ) -> Option<(usize, usize)> { + let layout = TableLayout::new(&self.buffer); + if !layout.is_table_row(line) { + return None; + } + + let source = self.buffer.line(line); + let trimmed = source.trim_end_matches(['\r', '\n']); + let wrap_count = self.line_wrap_count(line, width); + let mut fallback = None; + for wrap_index in 0..wrap_count { + let Some(rendered) = + layout.render_row_segment(line, trimmed, width, self.theme, wrap_index) + else { + continue; + }; + + let first_source = rendered.source_map.iter().flatten().copied().min(); + let last_source = rendered.source_map.iter().flatten().copied().max(); + if let (Some(first), Some(last)) = (first_source, last_source) { + if column >= first && column <= last.saturating_add(1) { + let visual_column = rendered + .source_map + .iter() + .position(|source_index| source_index.is_some_and(|index| index >= column)) + .or_else(|| rendered.source_map.iter().rposition(Option::is_some)) + .unwrap_or_default(); + return Some((wrap_index, visual_column)); + } + + if fallback.is_none() && column < first { + fallback = rendered + .source_map + .iter() + .position(Option::is_some) + .map(|visual_column| (wrap_index, visual_column)); + } + } + } + + fallback.or_else(|| { + (0..wrap_count).rev().find_map(|wrap_index| { + layout + .render_row_segment(line, trimmed, width, self.theme, wrap_index) + .and_then(|rendered| { + rendered + .source_map + .iter() + .rposition(Option::is_some) + .map(|visual_column| (wrap_index, visual_column)) + }) + }) + }) + } + + fn table_source_column_for_visual_position( + &self, + line: usize, + wrap_index: usize, + visual_column: usize, + width: usize, + ) -> Option { + let layout = TableLayout::new(&self.buffer); + if !layout.is_table_row(line) { + return None; + } + + let source = self.buffer.line(line); + let trimmed = source.trim_end_matches(['\r', '\n']); + let rendered = layout.render_row_segment(line, trimmed, width, self.theme, wrap_index)?; + rendered + .source_map + .get(visual_column) + .copied() + .flatten() + .or_else(|| { + rendered + .source_map + .iter() + .skip(visual_column) + .flatten() + .copied() + .next() + }) + .or_else(|| { + rendered + .source_map + .iter() + .take(visual_column.saturating_add(1)) + .rev() + .flatten() + .copied() + .next() + }) + } + + fn table_source_column_for_visual_end( + &self, + line: usize, + wrap_index: usize, + width: usize, + ) -> Option { + let layout = TableLayout::new(&self.buffer); + if !layout.is_table_row(line) { + return None; + } + + let source = self.buffer.line(line); + let trimmed = source.trim_end_matches(['\r', '\n']); + let rendered = layout.render_row_segment(line, trimmed, width, self.theme, wrap_index)?; + rendered.source_map.iter().flatten().copied().last() + } + + fn move_table_cursor_horizontally(&mut self, delta: isize) -> bool { + if delta == 0 { + return false; + } + + let width = self.wrap_width(); + let Some((wrap_index, visual_column)) = + self.table_visual_position(self.cursor.line, self.cursor.column, width) + else { + return false; + }; + + let target_column = if delta > 0 { + visual_column.saturating_add(1) + } else { + visual_column.saturating_sub(1) + }; + if let Some(column) = self.table_source_column_for_visual_position( + self.cursor.line, + wrap_index, + target_column, + width, + ) { + self.cursor.column = column; + return true; + } + + false + } + fn line_wrap_count(&self, line: usize, width: usize) -> usize { + let (segments, _) = self.line_wrap_segments(line, width); + segments.len().max(1) + } + + fn line_wrap_segments(&self, line: usize, width: usize) -> (Vec<(usize, usize)>, usize) { let line_text = self.buffer.line(line); let trimmed = line_text.trim_end_matches(['\r', '\n']); let table_layout = TableLayout::new(&self.buffer); - let (segments, _) = if line == self.cursor.line { - wrap_line(trimmed, width) - } else if table_layout.is_table_row(line) { + if table_layout.is_table_row(line) { table_layout.wrap_line(line, trimmed, width) } else { concealed_wrap_line(trimmed, width) - }; - segments.len().max(1) + } + } + + fn wrap_index_for_column(&self, line: usize, column: usize, width: usize) -> usize { + if let Some((wrap_index, _)) = self.table_visual_position(line, column, width) { + return wrap_index; + } + + let (segments, _) = self.line_wrap_segments(line, width); + for (index, &(start, end)) in segments.iter().enumerate() { + if column >= start && column < end { + return index; + } + } + segments.len().saturating_sub(1) + } + + fn visual_line_bounds(&self, line: usize, column: usize, width: usize) -> (usize, usize) { + if let Some((wrap_index, _)) = self.table_visual_position(line, column, width) { + let start = self + .table_source_column_for_visual_position(line, wrap_index, 0, width) + .unwrap_or_default(); + let end = self + .table_source_column_for_visual_end(line, wrap_index, width) + .map(|column| column.saturating_add(1)) + .unwrap_or(start); + return (start, end); + } + + let (segments, _) = self.line_wrap_segments(line, width); + for &(start, end) in &segments { + if column >= start && column < end { + return (start, end); + } + } + segments.last().copied().unwrap_or((0, 0)) } fn toggle_checkbox(&mut self) -> bool { - let original_cursor = self.cursor; - let line = self.buffer.line(original_cursor.line); - let trimmed = line.trim_end_matches(['\r', '\n']); - let leading_ws_len = trimmed.len() - trimmed.trim_start().len(); - let content = &trimmed[leading_ws_len..]; - - let col = leading_ws_len + 3; - - if content.starts_with("- [ ] ") || content.starts_with("- [x] ") { - let unchecked = content.starts_with("- [ ] "); - let start = self.buffer.char_index(Cursor { - line: original_cursor.line, - column: col, - }); - let replacement = if unchecked { "x" } else { " " }; - self.buffer - .replace_range(start, start + 1, replacement, &mut self.cursor); - self.cursor = original_cursor; + let mut cursor = self.cursor; + if self + .buffer + .toggle_checkbox_at_line(self.cursor.line, &mut cursor) + { + self.cursor = cursor; return true; } - false } } @@ -1984,6 +2227,27 @@ const COMMAND_CANDIDATES: &[CommandCandidate] = &[ aliases: &["/", "search", "find"], action: CommandCandidateAction::BeginSearch, }, + CommandCandidate { + replacement: "table", + label: "table", + detail: "Insert a Markdown table", + aliases: &["table", "grid"], + action: CommandCandidateAction::Command("table"), + }, + CommandCandidate { + replacement: "row", + label: "row", + detail: "Insert a row below the current table row", + aliases: &["row", "table row", "insert row"], + action: CommandCandidateAction::Command("row"), + }, + CommandCandidate { + replacement: "column", + label: "column", + detail: "Insert a column right of the current table cell", + aliases: &["column", "col", "table column", "insert column"], + action: CommandCandidateAction::Command("column"), + }, ]; fn command_sheet_items( @@ -2500,7 +2764,7 @@ fn list_continuation_after_enter(line: &str, column: usize) -> ListContinuation } let prefix = match item.kind { - ListItemKind::Checkbox => format!("{leading_ws}- [ ] "), + ListItemKind::Checkbox => format!("{leading_ws}[ ] "), ListItemKind::Bullet(marker) => format!("{leading_ws}{marker} "), ListItemKind::Numbered(number) => format!("{leading_ws}{}. ", number + 1), }; @@ -2522,6 +2786,22 @@ struct ListItem<'a> { } fn parse_list_item(content: &str) -> Option> { + if let Some(rest) = content + .strip_prefix("[ ]") + .or_else(|| content.strip_prefix("[x]")) + { + if let Some(item_content) = rest + .strip_prefix(' ') + .or_else(|| rest.is_empty().then_some("")) + { + return Some(ListItem { + kind: ListItemKind::Checkbox, + marker_len: content.len() - item_content.len(), + content: item_content, + }); + } + } + if let Some(rest) = content .strip_prefix("- [ ]") .or_else(|| content.strip_prefix("- [x]")) @@ -2546,7 +2826,7 @@ fn parse_list_item(content: &str) -> Option> { }); } - for marker in ['-', '*', '+'] { + for marker in ['•', '◦', '-', '*', '+'] { let prefix = [marker, ' '].iter().collect::(); if let Some(item_content) = content.strip_prefix(&prefix) { return Some(ListItem { @@ -3448,6 +3728,59 @@ mod tests { assert_eq!(app.sheet_panel_height(20), 0); } + #[test] + fn active_line_uses_facade_wrapping() { + let mut app = test_app("visit https://github.com/pacificcodeinc/glass/issues/123."); + app.cursor = Cursor { line: 0, column: 0 }; + + let raw_wraps = crate::editor::render::wrap_line(&app.buffer.line(0), 20) + .0 + .len(); + let facade_wraps = crate::markdown::highlight::concealed_wrap_line(&app.buffer.line(0), 20) + .0 + .len(); + + assert!(facade_wraps < raw_wraps); + assert_eq!(app.line_wrap_count(0, 20), facade_wraps); + } + + #[test] + fn hjkl_moves_through_rendered_table_rows() { + let mut app = + test_app("| Name | Notes |\n| --- | --- |\n| Ada | one two three four five six |"); + app.resize_viewport(10, 28); + app.cursor = Cursor { line: 2, column: 8 }; + + let width = app.wrap_width(); + let start = app.table_visual_position(app.cursor.line, app.cursor.column, width); + assert_eq!(start, Some((0, 9))); + + press(&mut app, KeyCode::Char('l')); + assert_eq!( + app.table_visual_position(app.cursor.line, app.cursor.column, width), + Some((0, 10)) + ); + + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.cursor.line, 2); + assert_eq!( + app.table_visual_position(app.cursor.line, app.cursor.column, width), + Some((1, 10)) + ); + + press(&mut app, KeyCode::Char('k')); + assert_eq!( + app.table_visual_position(app.cursor.line, app.cursor.column, width), + Some((0, 10)) + ); + + press(&mut app, KeyCode::Char('h')); + assert_eq!( + app.table_visual_position(app.cursor.line, app.cursor.column, width), + Some((0, 9)) + ); + } + #[test] fn normal_mode_u_undoes_last_insert() { let mut app = test_app(""); @@ -3468,10 +3801,12 @@ mod tests { app.cursor = Cursor { line: 0, column: 0 }; press(&mut app, KeyCode::Enter); - assert_eq!(app.buffer.as_string(), "- [x] todo"); + assert_eq!(app.buffer.as_string(), "[x] todo"); + assert_eq!(app.buffer.markdown_string(), "- [x] todo"); press(&mut app, KeyCode::Char('u')); - assert_eq!(app.buffer.as_string(), "- [ ] todo"); + assert_eq!(app.buffer.as_string(), "[ ] todo"); + assert_eq!(app.buffer.markdown_string(), "- [ ] todo"); } #[test] @@ -3538,11 +3873,11 @@ mod tests { fn enter_continues_checkbox_items_at_end_and_middle() { assert_eq!( list_continuation_after_enter("- [ ] todo", 10), - ListContinuation::Continue("- [ ] ".to_string()) + ListContinuation::Continue("[ ] ".to_string()) ); assert_eq!( list_continuation_after_enter("- [x] todo", 6), - ListContinuation::Continue("- [ ] ".to_string()) + ListContinuation::Continue("[ ] ".to_string()) ); } @@ -3569,13 +3904,15 @@ mod tests { buffer.insert_str(&mut cursor, "- [ ] todo"); insert_newline_with_list_continuation(&mut buffer, &mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n- [ ] "); - assert_eq!(cursor, Cursor { line: 1, column: 6 }); + assert_eq!(buffer.as_string(), "[ ] todo\n[ ] "); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n- [ ] "); + assert_eq!(cursor, Cursor { line: 1, column: 4 }); insert_newline_with_list_continuation(&mut buffer, &mut cursor); buffer.clamp_cursor(&mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n\n"); + assert_eq!(buffer.as_string(), "[ ] todo\n\n"); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n\n"); assert_eq!(cursor, Cursor { line: 1, column: 0 }); } @@ -3588,7 +3925,8 @@ mod tests { insert_newline_with_list_continuation(&mut buffer, &mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n\nafter"); + assert_eq!(buffer.as_string(), "[ ] todo\n\nafter"); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n\nafter"); assert_eq!(cursor, Cursor { line: 1, column: 0 }); } diff --git a/src/debug_render.rs b/src/debug_render.rs new file mode 100644 index 0000000..c4426b0 --- /dev/null +++ b/src/debug_render.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; + +use anyhow::Result; +use ratatui::{ + Terminal, + backend::TestBackend, + style::{Color, Modifier}, +}; + +use crate::{app::App, ui}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CellStyle { + fg: Color, + bg: Color, + modifier: Modifier, +} + +impl Default for CellStyle { + fn default() -> Self { + Self { + fg: Color::Reset, + bg: Color::Reset, + modifier: Modifier::empty(), + } + } +} + +pub fn render_path_to_ansi( + notes_dir: PathBuf, + initial_file: Option, + width: u16, + height: u16, +) -> Result { + let mut app = App::new(notes_dir, initial_file)?; + let backend = TestBackend::new(width.max(1), height.max(1)); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| ui::draw(frame, &mut app))?; + Ok(buffer_to_ansi(terminal.backend().buffer())) +} + +fn buffer_to_ansi(buffer: &ratatui::buffer::Buffer) -> String { + let mut out = String::new(); + let area = buffer.area; + for y in area.top()..area.bottom() { + let mut current = CellStyle::default(); + for x in area.left()..area.right() { + let Some(cell) = buffer.cell((x, y)) else { + continue; + }; + let next = CellStyle { + fg: cell.fg, + bg: cell.bg, + modifier: cell.modifier, + }; + if next != current { + out.push_str(&style_ansi(next)); + current = next; + } + out.push_str(cell.symbol()); + } + out.push_str("\x1b[0m"); + if y + 1 < area.bottom() { + out.push('\n'); + } + } + out +} + +fn style_ansi(style: CellStyle) -> String { + let mut sequence = String::from("\x1b[0m"); + sequence.push_str(&fg_ansi(style.fg)); + sequence.push_str(&bg_ansi(style.bg)); + if style.modifier.contains(Modifier::BOLD) { + sequence.push_str("\x1b[1m"); + } + if style.modifier.contains(Modifier::DIM) { + sequence.push_str("\x1b[2m"); + } + if style.modifier.contains(Modifier::ITALIC) { + sequence.push_str("\x1b[3m"); + } + if style.modifier.contains(Modifier::UNDERLINED) { + sequence.push_str("\x1b[4m"); + } + if style.modifier.contains(Modifier::REVERSED) { + sequence.push_str("\x1b[7m"); + } + if style.modifier.contains(Modifier::CROSSED_OUT) { + sequence.push_str("\x1b[9m"); + } + sequence +} + +fn fg_ansi(color: Color) -> String { + color_ansi(color, true) +} + +fn bg_ansi(color: Color) -> String { + color_ansi(color, false) +} + +fn color_ansi(color: Color, foreground: bool) -> String { + let base = if foreground { 30 } else { 40 }; + let bright_base = if foreground { 90 } else { 100 }; + match color { + Color::Reset => format!("\x1b[{}m", if foreground { 39 } else { 49 }), + Color::Black => format!("\x1b[{base}m"), + Color::Red => format!("\x1b[{}m", base + 1), + Color::Green => format!("\x1b[{}m", base + 2), + Color::Yellow => format!("\x1b[{}m", base + 3), + Color::Blue => format!("\x1b[{}m", base + 4), + Color::Magenta => format!("\x1b[{}m", base + 5), + Color::Cyan => format!("\x1b[{}m", base + 6), + Color::Gray | Color::White => format!("\x1b[{}m", base + 7), + Color::DarkGray => format!("\x1b[{bright_base}m"), + Color::LightRed => format!("\x1b[{}m", bright_base + 1), + Color::LightGreen => format!("\x1b[{}m", bright_base + 2), + Color::LightYellow => format!("\x1b[{}m", bright_base + 3), + Color::LightBlue => format!("\x1b[{}m", bright_base + 4), + Color::LightMagenta => format!("\x1b[{}m", bright_base + 5), + Color::LightCyan => format!("\x1b[{}m", bright_base + 6), + Color::Rgb(r, g, b) => { + format!("\x1b[{};2;{r};{g};{b}m", if foreground { 38 } else { 48 }) + } + Color::Indexed(index) => { + format!("\x1b[{};5;{index}m", if foreground { 38 } else { 48 }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn style_ansi_includes_rgb_and_modifiers() { + let ansi = style_ansi(CellStyle { + fg: Color::Rgb(1, 2, 3), + bg: Color::Reset, + modifier: Modifier::BOLD | Modifier::UNDERLINED, + }); + + assert!(ansi.contains("\x1b[38;2;1;2;3m")); + assert!(ansi.contains("\x1b[1m")); + assert!(ansi.contains("\x1b[4m")); + } +} diff --git a/src/document/markdown.rs b/src/document/markdown.rs new file mode 100644 index 0000000..2e48ff5 --- /dev/null +++ b/src/document/markdown.rs @@ -0,0 +1,625 @@ +use crate::{ + document::model::{Block, Document, Inline, ListMarker, TableAlignment, TableCell, TableRow}, + markdown::inline::{LinkKind, links}, +}; + +pub struct MarkdownCodec; + +impl MarkdownCodec { + pub fn parse(source: &str) -> Document { + let lines = source.lines().collect::>(); + let mut blocks = Vec::new(); + let mut line = 0usize; + + while line < lines.len() { + let current = lines[line]; + + if current.trim().is_empty() { + blocks.push(Block::Blank); + line += 1; + continue; + } + + if let Some((raw, next_line)) = parse_html_block(&lines, line) { + blocks.push(Block::RawMarkdown(raw)); + line = next_line; + continue; + } + + if is_unsupported_raw_line(current) { + blocks.push(Block::RawMarkdown(current.to_string())); + line += 1; + continue; + } + + if let Some((language, code, next_line)) = parse_code_fence(&lines, line) { + blocks.push(Block::CodeFence { language, code }); + line = next_line; + continue; + } + + if let Some((table, next_line)) = parse_table(&lines, line) { + blocks.push(table); + line = next_line; + continue; + } + + blocks.push(parse_line_block(current)); + line += 1; + } + + if blocks.is_empty() { + blocks.push(Block::Blank); + } + + Document { blocks } + } + + pub fn parse_plain(source: &str) -> Document { + let lines = source.lines().collect::>(); + let mut blocks = Vec::new(); + let mut line = 0usize; + while line < lines.len() { + if let Some((table, next_line)) = parse_table(&lines, line) { + blocks.push(table); + line = next_line; + continue; + } + blocks.push(parse_facade_line_block(lines[line])); + line += 1; + } + if source.ends_with('\n') { + blocks.push(Block::Blank); + } + if blocks.is_empty() { + blocks.push(Block::Blank); + } + Document { blocks } + } + + pub fn serialize(document: &Document) -> String { + let mut out = String::new(); + for (index, block) in document.blocks.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&block.to_markdown()); + } + out + } +} + +fn parse_line_block(line: &str) -> Block { + let leading = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + + if leading == 0 + && let Some((level, rest)) = parse_heading(trimmed) + { + return Block::Heading { + level, + content: parse_inlines(rest), + }; + } + + if leading == 0 && trimmed.starts_with('>') { + let level = trimmed.chars().take_while(|ch| *ch == '>').count() as u8; + let rest = trimmed[level as usize..].trim_start(); + return Block::Quote { + level, + content: parse_inlines(rest), + }; + } + + if let Some((checked, rest)) = parse_markdown_checkbox(trimmed) { + return Block::ChecklistItem { + indent: leading, + checked, + content: parse_inlines(rest), + }; + } + + if let Some((number, rest)) = parse_numbered_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Ordered(number), + content: parse_inlines(rest), + }; + } + + if let Some(rest) = parse_bullet_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Bullet, + content: parse_inlines(rest), + }; + } + + Block::Paragraph(parse_inlines(line)) +} + +fn parse_facade_line_block(line: &str) -> Block { + let leading = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + if trimmed.is_empty() { + return Block::Blank; + } + + if let Some((level, rest)) = parse_heading(trimmed) { + return Block::Heading { + level, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("> ") { + return Block::Quote { + level: 1, + content: parse_inlines(rest), + }; + } + + if let Some((checked, rest)) = parse_markdown_checkbox(trimmed) { + return Block::ChecklistItem { + indent: leading, + checked, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("[ ] ") { + return Block::ChecklistItem { + indent: leading, + checked: false, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("[x] ") { + return Block::ChecklistItem { + indent: leading, + checked: true, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed + .strip_prefix("• ") + .or_else(|| trimmed.strip_prefix("◦ ")) + .or_else(|| parse_bullet_marker(trimmed)) + { + return Block::ListItem { + indent: leading, + marker: ListMarker::Bullet, + content: parse_inlines(rest), + }; + } + + if let Some((number, rest)) = parse_numbered_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Ordered(number), + content: parse_inlines(rest), + }; + } + + Block::Paragraph(parse_inlines(line)) +} + +fn parse_heading(trimmed: &str) -> Option<(u8, &str)> { + let level = trimmed.chars().take_while(|ch| *ch == '#').count(); + if !(1..=6).contains(&level) || trimmed.chars().nth(level) != Some(' ') { + return None; + } + Some((level as u8, trimmed[level + 1..].trim_start())) +} + +fn parse_markdown_checkbox(trimmed: &str) -> Option<(bool, &str)> { + trimmed + .strip_prefix("- [ ] ") + .map(|rest| (false, rest)) + .or_else(|| trimmed.strip_prefix("- [x] ").map(|rest| (true, rest))) +} + +fn parse_bullet_marker(trimmed: &str) -> Option<&str> { + trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) +} + +fn parse_numbered_marker(trimmed: &str) -> Option<(usize, &str)> { + let bytes = trimmed.as_bytes(); + let mut i = 0usize; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == 0 || trimmed.get(i..i + 2) != Some(". ") { + return None; + } + Some((trimmed[..i].parse().unwrap_or(1), &trimmed[i + 2..])) +} + +fn parse_code_fence(lines: &[&str], start: usize) -> Option<(Option, String, usize)> { + let opener = lines.get(start)?.trim_start(); + let language = opener.strip_prefix("```")?.trim(); + let mut code = String::new(); + let mut line = start + 1; + while line < lines.len() { + if lines[line].trim_start().starts_with("```") { + return Some(( + (!language.is_empty()).then(|| language.to_string()), + code.trim_end_matches('\n').to_string(), + line + 1, + )); + } + code.push_str(lines[line]); + code.push('\n'); + line += 1; + } + Some(( + (!language.is_empty()).then(|| language.to_string()), + code.trim_end_matches('\n').to_string(), + line, + )) +} + +fn parse_html_block(lines: &[&str], start: usize) -> Option<(String, usize)> { + let current = lines.get(start)?; + let trimmed = current.trim_start(); + if !trimmed.starts_with('<') || !trimmed.ends_with('>') || trimmed.starts_with(""); + if trimmed.contains(&close) { + return Some((current.to_string(), start + 1)); + } + + let mut raw = String::new(); + let mut line = start; + while line < lines.len() { + if !raw.is_empty() { + raw.push('\n'); + } + raw.push_str(lines[line]); + line += 1; + if lines[line - 1].trim_end().contains(&close) { + break; + } + } + Some((raw, line)) +} + +fn html_block_tag(trimmed: &str) -> Option { + let after_open = trimmed.strip_prefix('<')?; + if after_open.starts_with('/') || after_open.starts_with('!') || after_open.starts_with('?') { + return None; + } + let tag = after_open + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric()) + .collect::(); + (!tag.is_empty()).then_some(tag) +} + +fn is_unsupported_raw_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("![") || trimmed.contains("[^") || is_reference_definition(trimmed) +} + +fn is_reference_definition(trimmed: &str) -> bool { + if !trimmed.starts_with('[') { + return false; + } + let Some(close) = trimmed.find("]:") else { + return false; + }; + close > 1 +} + +fn parse_table(lines: &[&str], start: usize) -> Option<(Block, usize)> { + if start + 1 >= lines.len() { + return None; + } + let header = parse_table_cells(lines[start])?; + let alignments = parse_delimiter(lines[start + 1])?; + if header.len() < 2 || alignments.len() < 2 { + return None; + } + + let mut rows = vec![TableRow { cells: header }]; + let mut line = start + 2; + while line < lines.len() { + let Some(cells) = parse_table_cells(lines[line]) else { + break; + }; + if cells.len() < 2 { + break; + } + rows.push(TableRow { cells }); + line += 1; + } + + Some((Block::Table { alignments, rows }, line)) +} + +fn parse_table_cells(line: &str) -> Option> { + let trimmed = line.trim(); + if !trimmed.contains('|') { + return None; + } + + let mut cells = split_table_cells(trimmed) + .into_iter() + .map(|cell| TableCell { + content: parse_inlines(cell.trim()), + }) + .collect::>(); + if cells.is_empty() { + None + } else { + Some(std::mem::take(&mut cells)) + } +} + +fn split_table_cells(line: &str) -> Vec { + let trimmed = line.trim().trim_matches('|'); + let mut cells = Vec::new(); + let mut current = String::new(); + let mut escaped = false; + for ch in trimmed.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + current.push(ch); + escaped = true; + continue; + } + if ch == '|' { + cells.push(current); + current = String::new(); + } else { + current.push(ch); + } + } + cells.push(current); + cells +} + +fn parse_delimiter(line: &str) -> Option> { + let cells = line.trim().trim_matches('|').split('|'); + cells + .map(|cell| { + let value = cell.trim(); + let left = value.starts_with(':'); + let right = value.ends_with(':'); + let dashes = value.trim_matches(':'); + if dashes.len() < 3 || !dashes.chars().all(|ch| ch == '-') { + return None; + } + Some(match (left, right) { + (true, true) => TableAlignment::Center, + (false, true) => TableAlignment::Right, + _ => TableAlignment::Left, + }) + }) + .collect() +} + +pub fn parse_inlines(source: &str) -> Vec { + let mut result = Vec::new(); + let parsed_links = links(source); + let mut index = 0usize; + + for link in parsed_links { + if link.source_start > index { + result.extend(parse_styled_text(&slice_chars( + source, + index, + link.source_start, + ))); + } + + match link.kind { + LinkKind::Markdown => result.push(Inline::Link { + label: parse_styled_text(link.label.as_deref().unwrap_or(&link.target)), + target: link.target, + kind: LinkKind::Markdown, + }), + LinkKind::Wiki => result.push(Inline::Link { + label: vec![Inline::Text( + link.label.clone().unwrap_or_else(|| link.target.clone()), + )], + target: link.target, + kind: LinkKind::Wiki, + }), + LinkKind::Url => result.push(Inline::BareUrl(link.target)), + } + index = link.source_end; + } + + if index < source.chars().count() { + result.extend(parse_styled_text(&slice_chars( + source, + index, + source.chars().count(), + ))); + } + + merge_text(result) +} + +fn parse_styled_text(source: &str) -> Vec { + let chars = source.chars().collect::>(); + let mut result = Vec::new(); + let mut index = 0usize; + + while index < chars.len() { + if chars[index] == '`' + && let Some(end) = find_next(&chars, index + 1, '`') + { + result.push(Inline::Code(chars[index + 1..end].iter().collect())); + index = end + 1; + continue; + } + + if starts_with(&chars, index, "**") + && let Some(end) = find_token(&chars, index + 2, "**") + { + result.push(Inline::Strong(parse_styled_text( + &chars[index + 2..end].iter().collect::(), + ))); + index = end + 2; + continue; + } + + if (chars[index] == '*' || chars[index] == '_') + && let Some(end) = find_next(&chars, index + 1, chars[index]) + { + result.push(Inline::Emphasis(parse_styled_text( + &chars[index + 1..end].iter().collect::(), + ))); + index = end + 1; + continue; + } + + let next = next_special(&chars, index + 1).unwrap_or(chars.len()); + result.push(Inline::Text(chars[index..next].iter().collect())); + index = next; + } + + merge_text(result) +} + +fn merge_text(inlines: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + for inline in inlines { + if let (Some(Inline::Text(left)), Inline::Text(right)) = (merged.last_mut(), &inline) { + left.push_str(right); + } else { + merged.push(inline); + } + } + merged +} + +fn find_next(chars: &[char], start: usize, needle: char) -> Option { + (start..chars.len()).find(|index| chars[*index] == needle) +} + +fn find_token(chars: &[char], start: usize, token: &str) -> Option { + (start..chars.len()).find(|index| starts_with(chars, *index, token)) +} + +fn starts_with(chars: &[char], index: usize, token: &str) -> bool { + token + .chars() + .enumerate() + .all(|(offset, ch)| chars.get(index + offset) == Some(&ch)) +} + +fn next_special(chars: &[char], start: usize) -> Option { + (start..chars.len()) + .find(|index| chars[*index] == '`' || chars[*index] == '*' || chars[*index] == '_') +} + +fn slice_chars(source: &str, start: usize, end: usize) -> String { + source + .chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::model::{inline_markdown, inline_plain_text}; + + #[test] + fn parses_and_serializes_common_blocks() { + let source = "# Title\n\n- [x] done\n\n> quote\n\n[README](README.md)"; + let document = MarkdownCodec::parse(source); + + assert_eq!( + MarkdownCodec::serialize(&document), + "# Title\n\n- [x] done\n\n> quote\n\n[README](README.md)" + ); + assert_eq!( + document.plain_text(), + "Title\n\n[x] done\n\n> quote\n\nREADME" + ); + } + + #[test] + fn parses_and_serializes_tables_canonically() { + let source = "| Name | Role |\n| --- | --- |\n| Ada | Editor |"; + let document = MarkdownCodec::parse(source); + + assert_eq!( + MarkdownCodec::serialize(&document), + "| Name | Role |\n| ---- | ------ |\n| Ada | Editor |" + ); + } + + #[test] + fn preserves_raw_markdown_blocks() { + let source = "
raw
"; + let document = MarkdownCodec::parse(source); + + assert_eq!(MarkdownCodec::serialize(&document), source); + } + + #[test] + fn preserves_multiline_html_blocks() { + let source = "
\nHTML details summary\n\ncontent\n
"; + let document = MarkdownCodec::parse(source); + + assert_eq!(MarkdownCodec::serialize(&document), source); + } + + #[test] + fn preserves_unsupported_reference_lines() { + let source = + "![Alt](asset.png)\n\nFootnote[^one]\n\n[^one]: body\n[glass]: https://example.com"; + let document = MarkdownCodec::parse(source); + + assert_eq!(MarkdownCodec::serialize(&document), source); + } + + #[test] + fn escaped_pipes_stay_inside_table_cells() { + let source = "| Pattern | Meaning |\n| --- | --- |\n| `A \\| B` | Escaped pipe |"; + let document = MarkdownCodec::parse(source); + + assert_eq!( + MarkdownCodec::serialize(&document), + "| Pattern | Meaning |\n| -------- | ------------ |\n| `A \\| B` | Escaped pipe |" + ); + } + + #[test] + fn parses_facade_shortcuts() { + let document = MarkdownCodec::parse_plain("# Heading\n[ ] todo\n• item"); + + assert_eq!( + MarkdownCodec::serialize(&document), + "# Heading\n- [ ] todo\n- item" + ); + } + + #[test] + fn inline_plain_text_hides_syntax() { + let inlines = parse_inlines("a **bold** [link](target.md)"); + + assert_eq!(inline_plain_text(&inlines), "a bold link"); + assert_eq!(inline_markdown(&inlines), "a **bold** [link](target.md)"); + } +} diff --git a/src/document/mod.rs b/src/document/mod.rs new file mode 100644 index 0000000..cd052b5 --- /dev/null +++ b/src/document/mod.rs @@ -0,0 +1,5 @@ +pub mod markdown; +pub mod model; + +pub use markdown::MarkdownCodec; +pub use model::{Block, DocLink, DocRange, Document, Inline, TableAlignment, TableCell, TableRow}; diff --git a/src/document/model.rs b/src/document/model.rs new file mode 100644 index 0000000..38578a0 --- /dev/null +++ b/src/document/model.rs @@ -0,0 +1,486 @@ +use crate::markdown::inline::LinkKind; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Document { + pub blocks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Block { + Blank, + Paragraph(Vec), + Heading { + level: u8, + content: Vec, + }, + Quote { + level: u8, + content: Vec, + }, + ListItem { + indent: usize, + marker: ListMarker, + content: Vec, + }, + ChecklistItem { + indent: usize, + checked: bool, + content: Vec, + }, + CodeFence { + language: Option, + code: String, + }, + Table { + alignments: Vec, + rows: Vec, + }, + RawMarkdown(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListMarker { + Bullet, + Ordered(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Inline { + Text(String), + Emphasis(Vec), + Strong(Vec), + Code(String), + Link { + label: Vec, + target: String, + kind: LinkKind, + }, + BareUrl(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableAlignment { + Left, + Center, + Right, +} + +impl Default for TableAlignment { + fn default() -> Self { + Self::Left + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableRow { + pub cells: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableCell { + pub content: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DocRange { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocLink { + pub target: String, + pub kind: LinkKind, +} + +impl Document { + pub fn plain_text(&self) -> String { + self.blocks + .iter() + .flat_map(Block::plain_lines) + .collect::>() + .join("\n") + } + + pub fn block_for_plain_line(&self, line: usize) -> Option<&Block> { + let mut cursor = 0usize; + for block in &self.blocks { + let count = block.plain_line_count(); + if line < cursor + count { + return Some(block); + } + cursor += count; + } + None + } + + pub fn link_at_plain_position(&self, line: usize, column: usize) -> Option { + let block = self.block_for_plain_line(line)?; + block.link_at_column(column) + } + + pub fn serialize_range(&self, range: DocRange) -> String { + let range_start = range.start.min(range.end); + let range_end = range.end.max(range.start); + if range_start == range_end { + return String::new(); + } + + let mut result = String::new(); + let mut plain_cursor = 0usize; + for block in &self.blocks { + let plain = block.plain_lines().join("\n"); + let block_start = plain_cursor; + let block_end = block_start + plain.chars().count(); + if ranges_overlap(range_start, range_end, block_start, block_end) { + if range_start <= block_start && range_end >= block_end { + if !result.is_empty() { + result.push_str("\n\n"); + } + result.push_str(&block.to_markdown()); + } else { + let local_start = range_start.saturating_sub(block_start); + let local_end = range_end.min(block_end).saturating_sub(block_start); + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&slice_chars(&plain, local_start, local_end)); + } + } + plain_cursor = block_end + 1; + } + + result + } +} + +impl Block { + pub fn plain_line_count(&self) -> usize { + self.plain_lines().len().max(1) + } + + pub fn plain_lines(&self) -> Vec { + match self { + Block::Blank => vec![String::new()], + Block::Paragraph(content) => vec![inline_plain_text(content)], + Block::Heading { content, .. } => vec![inline_plain_text(content)], + Block::Quote { level, content } => { + vec![format!( + "{} {}", + quote_marker(*level), + inline_plain_text(content) + )] + } + Block::ListItem { + indent, + marker, + content, + } => vec![format!( + "{}{} {}", + " ".repeat(*indent), + marker.plain_marker(*indent), + inline_plain_text(content) + )], + Block::ChecklistItem { + indent, + checked, + content, + } => vec![format!( + "{}{} {}", + " ".repeat(*indent), + if *checked { "[x]" } else { "[ ]" }, + inline_plain_text(content) + )], + Block::CodeFence { code, .. } => { + if code.is_empty() { + vec![String::new()] + } else { + code.lines().map(ToOwned::to_owned).collect() + } + } + Block::Table { rows, .. } => rows + .is_empty() + .then(Vec::new) + .unwrap_or_else(|| self.to_markdown().lines().map(ToOwned::to_owned).collect()), + Block::RawMarkdown(markdown) => markdown.lines().map(ToOwned::to_owned).collect(), + } + } + + pub fn to_markdown(&self) -> String { + match self { + Block::Blank => String::new(), + Block::Paragraph(content) => inline_markdown(content), + Block::Heading { level, content } => { + format!( + "{} {}", + "#".repeat(*level as usize), + inline_markdown(content) + ) + } + Block::Quote { level, content } => { + format!( + "{} {}", + ">".repeat(*level as usize), + inline_markdown(content) + ) + } + Block::ListItem { + indent, + marker, + content, + } => format!( + "{}{} {}", + " ".repeat(*indent), + marker.markdown_marker(), + inline_markdown(content) + ), + Block::ChecklistItem { + indent, + checked, + content, + } => format!( + "{}- [{}] {}", + " ".repeat(*indent), + if *checked { "x" } else { " " }, + inline_markdown(content) + ), + Block::CodeFence { language, code } => { + format!( + "```{}\n{}\n```", + language.as_deref().unwrap_or_default(), + code.trim_end_matches('\n') + ) + } + Block::Table { alignments, rows } => table_markdown(alignments, rows), + Block::RawMarkdown(markdown) => markdown.clone(), + } + } + + fn link_at_column(&self, column: usize) -> Option { + match self { + Block::Paragraph(content) + | Block::Heading { content, .. } + | Block::Quote { content, .. } + | Block::ListItem { content, .. } + | Block::ChecklistItem { content, .. } => inline_link_at_column(content, column), + Block::Table { rows, .. } => { + let mut cursor = 0usize; + for row in rows { + for cell in &row.cells { + let text = inline_plain_text(&cell.content); + if column >= cursor && column < cursor + text.chars().count() { + return inline_link_at_column(&cell.content, column - cursor); + } + cursor += text.chars().count() + 2; + } + } + None + } + _ => None, + } + } +} + +impl ListMarker { + fn markdown_marker(self) -> String { + match self { + ListMarker::Bullet => "-".to_string(), + ListMarker::Ordered(number) => format!("{number}."), + } + } + + fn plain_marker(self, indent: usize) -> String { + match self { + ListMarker::Bullet => { + if indent >= 2 { + "◦".to_string() + } else { + "•".to_string() + } + } + ListMarker::Ordered(number) => format!("{number}."), + } + } +} + +pub fn inline_plain_text(inlines: &[Inline]) -> String { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) | Inline::Code(value) | Inline::BareUrl(value) => { + text.push_str(value) + } + Inline::Emphasis(children) | Inline::Strong(children) => { + text.push_str(&inline_plain_text(children)) + } + Inline::Link { + label, + target, + kind, + } => { + if label.is_empty() && !matches!(kind, LinkKind::Wiki) { + text.push_str(target); + } else { + text.push_str(&inline_plain_text(label)); + } + } + } + } + text +} + +pub fn inline_markdown(inlines: &[Inline]) -> String { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) => text.push_str(value), + Inline::Emphasis(children) => { + text.push('*'); + text.push_str(&inline_markdown(children)); + text.push('*'); + } + Inline::Strong(children) => { + text.push_str("**"); + text.push_str(&inline_markdown(children)); + text.push_str("**"); + } + Inline::Code(value) => { + text.push('`'); + text.push_str(value); + text.push('`'); + } + Inline::Link { + label, + target, + kind, + } => match kind { + LinkKind::Markdown => { + text.push('['); + text.push_str(&inline_markdown(label)); + text.push_str("]("); + text.push_str(target); + text.push(')'); + } + LinkKind::Wiki => { + text.push_str("[["); + text.push_str(target); + text.push_str("]]"); + } + LinkKind::Url => text.push_str(target), + }, + Inline::BareUrl(value) => text.push_str(value), + } + } + text +} + +fn inline_link_at_column(inlines: &[Inline], column: usize) -> Option { + let mut cursor = 0usize; + for inline in inlines { + let len = inline_plain_text(std::slice::from_ref(inline)) + .chars() + .count(); + match inline { + Inline::Link { target, kind, .. } => { + if column >= cursor && column < cursor + len { + return Some(DocLink { + target: target.clone(), + kind: kind.clone(), + }); + } + } + Inline::BareUrl(target) => { + if column >= cursor && column < cursor + len { + return Some(DocLink { + target: target.clone(), + kind: LinkKind::Url, + }); + } + } + Inline::Emphasis(children) | Inline::Strong(children) => { + if column >= cursor + && column < cursor + len + && let Some(link) = inline_link_at_column(children, column - cursor) + { + return Some(link); + } + } + _ => {} + } + cursor += len; + } + None +} + +fn table_markdown(alignments: &[TableAlignment], rows: &[TableRow]) -> String { + if rows.is_empty() { + return String::new(); + } + + let column_count = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + if column_count == 0 { + return String::new(); + } + + let mut widths = vec![3usize; column_count]; + for row in rows { + for (index, cell) in row.cells.iter().enumerate() { + widths[index] = widths[index].max(inline_markdown(&cell.content).chars().count()); + } + } + + let mut lines = Vec::new(); + for (row_index, row) in rows.iter().enumerate() { + lines.push(table_row_markdown(row, &widths)); + if row_index == 0 { + lines.push(table_delimiter_markdown(alignments, &widths)); + } + } + lines.join("\n") +} + +fn table_row_markdown(row: &TableRow, widths: &[usize]) -> String { + let mut line = String::from("|"); + for (index, width) in widths.iter().enumerate() { + let value = row + .cells + .get(index) + .map(|cell| inline_markdown(&cell.content)) + .unwrap_or_default(); + line.push(' '); + line.push_str(&value); + line.push_str(&" ".repeat(width.saturating_sub(value.chars().count()))); + line.push_str(" |"); + } + line +} + +fn table_delimiter_markdown(alignments: &[TableAlignment], widths: &[usize]) -> String { + let mut line = String::from("|"); + for (index, width) in widths.iter().enumerate() { + let marker = match alignments.get(index).copied().unwrap_or_default() { + TableAlignment::Left => format!(" {}", "-".repeat(*width)), + TableAlignment::Center => format!(":{}:", "-".repeat((*width).max(3))), + TableAlignment::Right => format!("{}:", "-".repeat(width.saturating_sub(1).max(3))), + }; + line.push_str(&marker); + line.push_str(" |"); + } + line +} + +fn quote_marker(level: u8) -> String { + ">".repeat(level.max(1) as usize) +} + +fn ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool { + a_start < b_end && b_start < a_end +} + +fn slice_chars(source: &str, start: usize, end: usize) -> String { + source + .chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} diff --git a/src/editor/buffer.rs b/src/editor/buffer.rs index f91dd07..62761d2 100644 --- a/src/editor/buffer.rs +++ b/src/editor/buffer.rs @@ -3,31 +3,46 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use ropey::Rope; -use crate::{editor::cursor::Cursor, fs::persistence, markdown::parse::parse_markdown}; +use crate::{ + document::{ + Block, DocLink, DocRange, Document, Inline, MarkdownCodec, TableAlignment, TableCell, + TableRow, + }, + editor::{ + commands::{TableColumnPlacement, TableRowPlacement}, + cursor::Cursor, + }, + fs::persistence, + markdown::parse::parse_markdown, +}; #[derive(Debug, Clone)] pub struct DocumentBuffer { pub path: Option, + document: Document, text: Rope, pub dirty: bool, - saved_text: Rope, + saved_markdown: String, undo_stack: Vec, } #[derive(Debug, Clone)] struct BufferSnapshot { + document: Document, text: Rope, cursor: Cursor, } impl DocumentBuffer { pub fn empty() -> Self { - let text = Rope::new(); + let document = Document::default(); + let text = Rope::from_str(&document.plain_text()); Self { path: None, - text: text.clone(), - saved_text: text, + document, + text, dirty: false, + saved_markdown: String::new(), undo_stack: Vec::new(), } } @@ -35,12 +50,15 @@ impl DocumentBuffer { pub fn from_path(path: &Path) -> Result { let contents = persistence::load_utf8(path)?; parse_markdown(&contents)?; - let text = Rope::from_str(&contents); + let document = MarkdownCodec::parse(&contents); + let saved_markdown = MarkdownCodec::serialize(&document); + let text = Rope::from_str(&document.plain_text()); Ok(Self { path: Some(path.to_path_buf()), - saved_text: text.clone(), + document, text, dirty: false, + saved_markdown, undo_stack: Vec::new(), }) } @@ -51,9 +69,10 @@ impl DocumentBuffer { } else { Ok(Self { path: Some(path.to_path_buf()), - saved_text: Rope::new(), + document: Document::default(), text: Rope::new(), dirty: false, + saved_markdown: String::new(), undo_stack: Vec::new(), }) } @@ -64,8 +83,9 @@ impl DocumentBuffer { return Ok(()); }; - persistence::save_atomic(path, &self.text.to_string())?; - self.saved_text = self.text.clone(); + let markdown = self.markdown_string(); + persistence::save_atomic(path, &markdown)?; + self.saved_markdown = markdown; self.dirty = false; Ok(()) } @@ -100,7 +120,6 @@ impl DocumentBuffer { fn insert_char_raw(&mut self, cursor: &mut Cursor, ch: char) { let index = self.char_index(*cursor); self.text.insert_char(index, ch); - self.update_dirty(); if ch == '\n' { cursor.line += 1; @@ -108,6 +127,7 @@ impl DocumentBuffer { } else { cursor.column += 1; } + self.sync_document_from_facade(cursor); } pub fn insert_str(&mut self, cursor: &mut Cursor, value: &str) { @@ -116,9 +136,17 @@ impl DocumentBuffer { } self.push_undo_snapshot(*cursor); + let index = self.char_index(*cursor); + self.text.insert(index, value); for ch in value.chars() { - self.insert_char_raw(cursor, ch); + if ch == '\n' { + cursor.line += 1; + cursor.column = 0; + } else { + cursor.column += 1; + } } + self.sync_document_from_facade(cursor); } pub fn delete_previous_char(&mut self, cursor: &mut Cursor) { @@ -139,7 +167,6 @@ impl DocumentBuffer { None }; self.text.remove(previous..end); - self.update_dirty(); if cursor.column > 0 { cursor.column -= 1; @@ -147,6 +174,7 @@ impl DocumentBuffer { cursor.line = cursor.line.saturating_sub(1); cursor.column = previous_line_len.unwrap_or_default(); } + self.sync_document_from_facade(cursor); } pub fn delete_char(&mut self, cursor: &mut Cursor) { @@ -157,7 +185,7 @@ impl DocumentBuffer { self.push_undo_snapshot(*cursor); self.text.remove(start..start + 1); - self.update_dirty(); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -188,8 +216,8 @@ impl DocumentBuffer { self.push_undo_snapshot(*cursor); } self.text.remove(start..end); - self.update_dirty(); *cursor = self.cursor_from_char_index(start); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -218,8 +246,8 @@ impl DocumentBuffer { if !replacement.is_empty() { self.text.insert(start, replacement); } - self.update_dirty(); *cursor = self.cursor_from_char_index(start + replacement.chars().count()); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -269,14 +297,270 @@ impl DocumentBuffer { self.text.to_string() } + pub fn markdown_string(&self) -> String { + MarkdownCodec::serialize(&self.document) + } + + pub fn selected_markdown(&self, start: Cursor, end: Cursor) -> Option { + let start = self.char_index(start); + let end = self.char_index(end); + if start == end { + return None; + } + + Some(self.document.serialize_range(DocRange { start, end })) + } + + pub fn link_at_cursor(&self, cursor: Cursor) -> Option { + self.document + .link_at_plain_position(cursor.line, cursor.column) + } + + pub fn block_for_line(&self, line: usize) -> Option<&Block> { + self.document.block_for_plain_line(line) + } + + pub fn toggle_checkbox_at_line(&mut self, line: usize, cursor: &mut Cursor) -> bool { + let mut plain_line = 0usize; + for index in 0..self.document.blocks.len() { + let block = &self.document.blocks[index]; + let line_count = block.plain_line_count(); + if line >= plain_line && line < plain_line + line_count { + if matches!(block, Block::ChecklistItem { .. }) { + self.push_undo_snapshot(*cursor); + let Block::ChecklistItem { checked, .. } = &mut self.document.blocks[index] + else { + unreachable!(); + }; + *checked = !*checked; + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + return true; + } + return false; + } + plain_line += line_count; + } + false + } + + pub fn insert_table(&mut self, rows: usize, columns: usize, cursor: &mut Cursor) { + self.push_undo_snapshot(*cursor); + let rows = rows.max(2); + let columns = columns.max(2); + let mut table_rows = Vec::new(); + for row in 0..rows { + table_rows.push(TableRow { + cells: (0..columns) + .map(|column| TableCell { + content: vec![Inline::Text(if row == 0 { + format!("Column {}", column + 1) + } else { + String::new() + })], + }) + .collect(), + }); + } + + let insert_at = self.block_index_for_line(cursor.line); + self.document.blocks.insert( + insert_at, + Block::Table { + alignments: vec![TableAlignment::Left; columns], + rows: table_rows, + }, + ); + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + } + + pub fn insert_table_row_at_cursor( + &mut self, + cursor: &mut Cursor, + placement: TableRowPlacement, + ) -> bool { + let Some(location) = self.table_location_at_cursor(*cursor) else { + return false; + }; + + self.push_undo_snapshot(*cursor); + let Block::Table { rows, .. } = &mut self.document.blocks[location.block_index] else { + return false; + }; + + let column_count = rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(location.column_index + 1) + .max(1); + let insert_at = match placement { + TableRowPlacement::Above => location.row_index, + TableRowPlacement::Below => location.row_index + 1, + } + .min(rows.len()); + rows.insert( + insert_at, + TableRow { + cells: empty_table_cells(column_count), + }, + ); + + self.rebuild_facade_preserving_cursor(cursor); + cursor.line = (location.block_start + table_source_line_for_row(insert_at)) + .min(self.line_count().saturating_sub(1)); + cursor.column = table_cell_ranges(&self.line(cursor.line)) + .get(location.column_index.min(column_count.saturating_sub(1))) + .map(|(start, _)| *start) + .unwrap_or_default(); + self.update_dirty(); + true + } + + pub fn insert_table_column_at_cursor( + &mut self, + cursor: &mut Cursor, + placement: TableColumnPlacement, + ) -> bool { + let Some(location) = self.table_location_at_cursor(*cursor) else { + return false; + }; + + self.push_undo_snapshot(*cursor); + let Block::Table { alignments, rows } = &mut self.document.blocks[location.block_index] + else { + return false; + }; + + let column_count = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + let insert_at = match placement { + TableColumnPlacement::Left => location.column_index, + TableColumnPlacement::Right => location.column_index + 1, + } + .min(column_count); + + for (row_index, row) in rows.iter_mut().enumerate() { + while row.cells.len() < column_count { + row.cells.push(empty_table_cell()); + } + row.cells.insert( + insert_at, + TableCell { + content: vec![Inline::Text(if row_index == 0 { + format!("Column {}", insert_at + 1) + } else { + String::new() + })], + }, + ); + } + alignments.resize(column_count, TableAlignment::Left); + alignments.insert(insert_at, TableAlignment::Left); + + self.rebuild_facade_preserving_cursor(cursor); + cursor.line = (location.block_start + table_source_line_for_row(location.row_index)) + .min(self.line_count().saturating_sub(1)); + cursor.column = table_cell_ranges(&self.line(cursor.line)) + .get(insert_at) + .map(|(start, _)| *start) + .unwrap_or_default(); + self.update_dirty(); + true + } + + pub fn enter_table_cell(&mut self, cursor: &mut Cursor) -> bool { + let Some(location) = self.table_location_at_cursor(*cursor) else { + return false; + }; + let Some(Block::Table { rows, .. }) = self.document.blocks.get(location.block_index) else { + return false; + }; + + if location.row_index + 1 >= rows.len() { + return self.insert_table_row_at_cursor(cursor, TableRowPlacement::Below); + } + + cursor.line = (location.block_start + table_source_line_for_row(location.row_index + 1)) + .min(self.line_count().saturating_sub(1)); + cursor.column = table_cell_ranges(&self.line(cursor.line)) + .get(location.column_index) + .map(|(start, _)| *start) + .unwrap_or_default(); + true + } + + pub fn move_table_cell(&self, cursor: &mut Cursor, delta: isize) -> bool { + if delta == 0 || !is_table_content_line(&self.line(cursor.line)) { + return false; + } + + let line = self.line(cursor.line); + let cells = table_cell_ranges(&line); + if cells.len() < 2 { + return false; + } + + let current = cells + .iter() + .position(|(start, end)| cursor.column >= *start && cursor.column <= *end) + .unwrap_or_else(|| { + cells + .iter() + .position(|(start, _)| cursor.column < *start) + .unwrap_or(cells.len() - 1) + }); + let target = current as isize + delta; + if target >= 0 && (target as usize) < cells.len() { + cursor.column = cells[target as usize].0; + return true; + } + + let mut next_line = cursor.line; + loop { + next_line = if delta > 0 { + next_line + 1 + } else { + next_line.saturating_sub(1) + }; + if next_line == cursor.line || next_line >= self.line_count() { + return false; + } + if is_table_content_line(&self.line(next_line)) { + break; + } + if !self.line(next_line).contains('|') { + return false; + } + } + + let next_text = self.line(next_line); + if !is_table_content_line(&next_text) { + return false; + } + let next_cells = table_cell_ranges(&next_text); + if next_cells.is_empty() { + return false; + } + + cursor.line = next_line; + cursor.column = if delta > 0 { + next_cells[0].0 + } else { + next_cells[next_cells.len() - 1].0 + }; + true + } + pub fn undo(&mut self, cursor: &mut Cursor) -> bool { let Some(snapshot) = self.undo_stack.pop() else { return false; }; + self.document = snapshot.document; self.text = snapshot.text; - self.update_dirty(); *cursor = snapshot.cursor; + self.update_dirty(); self.clamp_cursor(cursor); true } @@ -291,13 +575,79 @@ impl DocumentBuffer { } self.undo_stack.push(BufferSnapshot { + document: self.document.clone(), text: self.text.clone(), cursor, }); } fn update_dirty(&mut self) { - self.dirty = self.text != self.saved_text; + self.dirty = self.markdown_string() != self.saved_markdown; + } + + fn sync_document_from_facade(&mut self, cursor: &mut Cursor) { + let old_document = self.document.clone(); + let parsed = MarkdownCodec::parse_plain(&self.text.to_string()); + self.document = reconcile_facade_document(&old_document, parsed); + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + } + + fn rebuild_facade_preserving_cursor(&mut self, cursor: &mut Cursor) { + let line = cursor.line; + let column = cursor.column; + self.text = Rope::from_str(&self.document.plain_text()); + cursor.line = line.min(self.text.len_lines().saturating_sub(1)); + cursor.column = column.min(self.line_len_chars(cursor.line)); + } + + fn block_index_for_line(&self, line: usize) -> usize { + let mut plain_line = 0usize; + for (index, block) in self.document.blocks.iter().enumerate() { + let next = plain_line + block.plain_line_count(); + if line <= plain_line || line < next { + return index; + } + plain_line = next; + } + self.document.blocks.len() + } + + fn table_location_at_cursor(&self, cursor: Cursor) -> Option { + let mut plain_line = 0usize; + for (block_index, block) in self.document.blocks.iter().enumerate() { + let line_count = block.plain_line_count(); + let next = plain_line + line_count; + if cursor.line >= plain_line && cursor.line < next { + let Block::Table { rows, .. } = block else { + return None; + }; + let local_line = cursor.line - plain_line; + let row_index = + table_row_for_source_line(local_line).min(rows.len().saturating_sub(1)); + let column_index = table_cell_ranges(&self.line(cursor.line)) + .iter() + .position(|(start, end)| cursor.column >= *start && cursor.column <= *end) + .unwrap_or_else(|| { + table_cell_ranges(&self.line(cursor.line)) + .iter() + .position(|(start, _)| cursor.column < *start) + .unwrap_or_else(|| { + rows.get(row_index) + .map(|row| row.cells.len().saturating_sub(1)) + .unwrap_or_default() + }) + }); + return Some(TableCursorLocation { + block_index, + block_start: plain_line, + row_index, + column_index, + }); + } + plain_line = next; + } + None } fn visible_len_lines(&self) -> usize { @@ -315,10 +665,146 @@ impl DocumentBuffer { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TableCursorLocation { + block_index: usize, + block_start: usize, + row_index: usize, + column_index: usize, +} + +fn empty_table_cell() -> TableCell { + TableCell { + content: vec![Inline::Text(String::new())], + } +} + +fn empty_table_cells(count: usize) -> Vec { + (0..count).map(|_| empty_table_cell()).collect() +} + +fn table_row_for_source_line(local_line: usize) -> usize { + match local_line { + 0 | 1 => 0, + line => line - 1, + } +} + +fn table_source_line_for_row(row_index: usize) -> usize { + if row_index == 0 { 0 } else { row_index + 1 } +} + +fn reconcile_facade_document(old: &Document, parsed: Document) -> Document { + if old.blocks.len() != parsed.blocks.len() { + return parsed; + } + + let blocks = parsed + .blocks + .into_iter() + .enumerate() + .map(|(index, parsed_block)| { + let Some(old_block) = old.blocks.get(index) else { + return parsed_block; + }; + reconcile_block(old_block, parsed_block) + }) + .collect(); + + Document { blocks } +} + +fn reconcile_block(old: &Block, parsed: Block) -> Block { + match (&old, parsed) { + (_, semantic @ Block::Heading { .. }) + | (_, semantic @ Block::Quote { .. }) + | (_, semantic @ Block::ListItem { .. }) + | (_, semantic @ Block::ChecklistItem { .. }) + | (_, semantic @ Block::CodeFence { .. }) + | (_, semantic @ Block::Table { .. }) + | (_, semantic @ Block::RawMarkdown(_)) => semantic, + (Block::Heading { level, .. }, Block::Paragraph(content)) => Block::Heading { + level: *level, + content, + }, + (Block::Quote { level, .. }, Block::Paragraph(content)) => Block::Quote { + level: *level, + content, + }, + (Block::ListItem { indent, marker, .. }, Block::Paragraph(content)) => Block::ListItem { + indent: *indent, + marker: *marker, + content, + }, + ( + Block::ChecklistItem { + indent, checked, .. + }, + Block::Paragraph(content), + ) => Block::ChecklistItem { + indent: *indent, + checked: *checked, + content, + }, + (_, block) => block, + } +} + fn trim_line_ending_len(line: &str) -> usize { line.trim_end_matches(['\r', '\n']).chars().count() } +fn is_table_content_line(line: &str) -> bool { + let trimmed = line.trim_end_matches(['\r', '\n']).trim(); + if !trimmed.contains('|') || is_table_delimiter_line(trimmed) { + return false; + } + table_cell_ranges(trimmed).len() >= 2 +} + +fn is_table_delimiter_line(line: &str) -> bool { + let cells = line + .trim_matches('|') + .split('|') + .map(str::trim) + .collect::>(); + !cells.is_empty() + && cells.iter().all(|cell| { + let dashes = cell.trim_matches(':'); + dashes.len() >= 3 && dashes.chars().all(|ch| ch == '-') + }) +} + +fn table_cell_ranges(line: &str) -> Vec<(usize, usize)> { + let chars = line + .trim_end_matches(['\r', '\n']) + .chars() + .collect::>(); + let pipes = chars + .iter() + .enumerate() + .filter_map(|(index, ch)| (*ch == '|').then_some(index)) + .collect::>(); + if pipes.len() < 2 { + return Vec::new(); + } + + pipes + .windows(2) + .filter_map(|window| { + let mut start = window[0] + 1; + let mut end = window[1]; + while start < end && chars[start].is_whitespace() { + start += 1; + } + while end > start && chars[end - 1].is_whitespace() { + end -= 1; + } + Some((start, end)) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -382,15 +868,104 @@ mod tests { buffer.insert_str(&mut cursor, "- [ ] todo"); buffer.undo_stack.clear(); buffer.dirty = false; - cursor = Cursor { line: 0, column: 3 }; - let start = buffer.char_index(cursor); + cursor = Cursor { line: 0, column: 0 }; - buffer.replace_range(start, start + 1, "x", &mut cursor); - assert_eq!(buffer.as_string(), "- [x] todo"); + assert!(buffer.toggle_checkbox_at_line(0, &mut cursor)); + assert_eq!(buffer.as_string(), "[x] todo"); + assert_eq!(buffer.markdown_string(), "- [x] todo"); assert!(buffer.undo(&mut cursor)); - assert_eq!(buffer.as_string(), "- [ ] todo"); - assert_eq!(cursor, Cursor { line: 0, column: 3 }); + assert_eq!(buffer.as_string(), "[ ] todo"); + assert_eq!(buffer.markdown_string(), "- [ ] todo"); + assert_eq!(cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn markdown_shortcut_becomes_facade_heading() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + + buffer.insert_str(&mut cursor, "# Heading"); + + assert_eq!(buffer.as_string(), "Heading"); + assert_eq!(buffer.markdown_string(), "# Heading"); + assert_eq!(cursor, Cursor { line: 0, column: 7 }); + } + + #[test] + fn selected_range_serializes_back_to_markdown() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "# Heading"); + + let selected = + buffer.selected_markdown(Cursor { line: 0, column: 0 }, Cursor { line: 0, column: 7 }); + + assert_eq!(selected.as_deref(), Some("# Heading")); + } + + #[test] + fn table_cell_navigation_moves_between_content_cells() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "| A | B |\n| --- | --- |\n| x | y |"); + cursor = Cursor { line: 0, column: 2 }; + + assert!(buffer.move_table_cell(&mut cursor, 1)); + assert_eq!(cursor, Cursor { line: 0, column: 8 }); + + assert!(buffer.move_table_cell(&mut cursor, 1)); + assert_eq!(cursor, Cursor { line: 2, column: 2 }); + + assert!(buffer.move_table_cell(&mut cursor, -1)); + assert_eq!(cursor, Cursor { line: 0, column: 8 }); + } + + #[test] + fn inserts_table_row_below_current_row() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "| A | B |\n| --- | --- |\n| x | y |"); + cursor = Cursor { line: 2, column: 2 }; + + assert!(buffer.insert_table_row_at_cursor(&mut cursor, TableRowPlacement::Below)); + + assert_eq!( + buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| x | y |\n| | |" + ); + assert_eq!(cursor.line, 3); + } + + #[test] + fn inserts_table_column_right_of_current_cell() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "| A | B |\n| --- | --- |\n| x | y |"); + cursor = Cursor { line: 2, column: 2 }; + + assert!(buffer.insert_table_column_at_cursor(&mut cursor, TableColumnPlacement::Right)); + + assert_eq!( + buffer.markdown_string(), + "| A | Column 2 | B |\n| --- | -------- | --- |\n| x | | y |" + ); + assert_eq!(cursor.line, 2); + } + + #[test] + fn enter_moves_to_next_table_row_or_adds_one() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "| A | B |\n| --- | --- |\n| x | y |"); + cursor = Cursor { line: 0, column: 2 }; + + assert!(buffer.enter_table_cell(&mut cursor)); + assert_eq!(cursor.line, 2); + + assert!(buffer.enter_table_cell(&mut cursor)); + assert_eq!(cursor.line, 3); + assert!(buffer.markdown_string().ends_with("\n| | |")); } #[test] diff --git a/src/editor/commands.rs b/src/editor/commands.rs index 331991f..7c8c354 100644 --- a/src/editor/commands.rs +++ b/src/editor/commands.rs @@ -6,9 +6,24 @@ pub enum Command { Quit { force: bool }, WriteQuit, Edit(PathBuf), + Table { rows: usize, columns: usize }, + TableRow { placement: TableRowPlacement }, + TableColumn { placement: TableColumnPlacement }, Unknown(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableRowPlacement { + Above, + Below, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableColumnPlacement { + Left, + Right, +} + pub fn parse_command(input: &str) -> Command { let trimmed = input.trim(); @@ -17,6 +32,33 @@ pub fn parse_command(input: &str) -> Command { "q" | "quit" => Command::Quit { force: false }, "q!" | "quit!" => Command::Quit { force: true }, "wq" | "x" => Command::WriteQuit, + "table" => Command::Table { + rows: 2, + columns: 2, + }, + "row" | "table row" | "row below" | "table row below" => Command::TableRow { + placement: TableRowPlacement::Below, + }, + "row above" | "table row above" => Command::TableRow { + placement: TableRowPlacement::Above, + }, + "col" | "column" | "table column" | "col right" | "column right" | "table column right" => { + Command::TableColumn { + placement: TableColumnPlacement::Right, + } + } + "col left" | "column left" | "table column left" => Command::TableColumn { + placement: TableColumnPlacement::Left, + }, + _ if trimmed.starts_with("table ") => { + let spec = trimmed + .split_once(' ') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + parse_table_size(spec) + .map(|(rows, columns)| Command::Table { rows, columns }) + .unwrap_or_else(|| Command::Unknown(trimmed.to_string())) + } _ if trimmed.starts_with("e ") || trimmed.starts_with("edit ") => { let path = trimmed .split_once(' ') @@ -28,6 +70,13 @@ pub fn parse_command(input: &str) -> Command { } } +fn parse_table_size(spec: &str) -> Option<(usize, usize)> { + let (rows, columns) = spec.split_once('x').or_else(|| spec.split_once('X'))?; + let rows = rows.trim().parse().ok()?; + let columns = columns.trim().parse().ok()?; + Some((rows, columns)) +} + #[cfg(test)] mod tests { use super::*; @@ -37,4 +86,50 @@ mod tests { assert_eq!(parse_command("wq"), Command::WriteQuit); assert_eq!(parse_command("q!"), Command::Quit { force: true }); } + + #[test] + fn parses_table_commands() { + assert_eq!( + parse_command("table"), + Command::Table { + rows: 2, + columns: 2 + } + ); + assert_eq!( + parse_command("table 3x4"), + Command::Table { + rows: 3, + columns: 4 + } + ); + } + + #[test] + fn parses_table_mutation_commands() { + assert_eq!( + parse_command("row"), + Command::TableRow { + placement: TableRowPlacement::Below + } + ); + assert_eq!( + parse_command("row above"), + Command::TableRow { + placement: TableRowPlacement::Above + } + ); + assert_eq!( + parse_command("column"), + Command::TableColumn { + placement: TableColumnPlacement::Right + } + ); + assert_eq!( + parse_command("col left"), + Command::TableColumn { + placement: TableColumnPlacement::Left + } + ); + } } diff --git a/src/editor/render.rs b/src/editor/render.rs index bdb9db4..1cb2a09 100644 --- a/src/editor/render.rs +++ b/src/editor/render.rs @@ -68,11 +68,13 @@ pub fn visible_rows( } fn is_checked_checkbox(text: &str) -> bool { - text.trim_start().starts_with("- [x] ") + let text = text.trim_start(); + text.starts_with("- [x] ") || text.starts_with("[x] ") } /// Returns the wrap segment index (0-based) that contains the given column. /// Uses the same word-boundary algorithm as `visible_rows`. +#[cfg(test)] pub fn wrap_index_for_column(line_text: &str, column: usize, width: usize) -> usize { let trimmed = line_text.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { @@ -89,6 +91,7 @@ pub fn wrap_index_for_column(line_text: &str, column: usize, width: usize) -> us } /// Returns the column position within the wrap segment. +#[cfg(test)] pub fn column_in_wrap_segment(line_text: &str, column: usize, width: usize) -> usize { let trimmed = line_text.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { @@ -108,6 +111,7 @@ pub fn column_in_wrap_segment(line_text: &str, column: usize, width: usize) -> u } /// Returns (start, end) character bounds of the wrap segment that contains `column`. +#[cfg(test)] pub fn visual_line_bounds(line_text: &str, column: usize, width: usize) -> (usize, usize) { let trimmed = line_text.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { @@ -131,6 +135,9 @@ pub fn detect_list_marker(text: &str) -> usize { if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") { return ws + 6; } + if trimmed.starts_with("[ ] ") || trimmed.starts_with("[x] ") { + return ws + 4; + } if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { return ws + 2; } @@ -151,6 +158,7 @@ pub fn detect_list_marker(text: &str) -> usize { /// For list items the first segment includes the marker and subsequent /// segments are wrapped with a reduced width so they can be indented. /// Returns the segments and the marker length (0 for non-list lines). +#[cfg(test)] pub fn wrap_line(text: &str, width: usize) -> (Vec<(usize, usize)>, usize) { let marker_len = detect_list_marker(text); @@ -226,7 +234,7 @@ mod tests { assert_eq!(rows.len(), 2); assert_eq!(rows[1].line_number, 1); - assert_eq!(rows[1].full_text, "- [ ] "); + assert_eq!(rows[1].full_text, "[ ] "); assert_eq!(rows[1].wrap_index, 0); assert!(!rows[1].completed); } diff --git a/src/main.rs b/src/main.rs index e26cd23..6e4368c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod app; mod config; +mod debug_render; +mod document; mod editor; mod fs; mod markdown; @@ -10,18 +12,54 @@ use std::{env, path::PathBuf}; use anyhow::{Context, Result, bail}; -use crate::{app::App, terminal::TerminalSession}; +use crate::{app::App, debug_render::render_path_to_ansi, terminal::TerminalSession}; const VERSION: &str = env!("CARGO_PKG_VERSION"); fn main() -> Result<()> { - let (notes_dir, initial_file) = parse_args()?; - let app = App::new(notes_dir, initial_file)?; - TerminalSession::run(app) + match parse_args()? { + LaunchMode::App { + notes_dir, + initial_file, + } => { + let app = App::new(notes_dir, initial_file)?; + TerminalSession::run(app) + } + LaunchMode::Render { + notes_dir, + initial_file, + width, + height, + } => { + print!( + "{}", + render_path_to_ansi(notes_dir, initial_file, width, height)? + ); + Ok(()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LaunchMode { + App { + notes_dir: PathBuf, + initial_file: Option, + }, + Render { + notes_dir: PathBuf, + initial_file: Option, + width: u16, + height: u16, + }, } -fn parse_args() -> Result<(PathBuf, Option)> { - let mut args = env::args_os(); +fn parse_args() -> Result { + parse_args_os(env::args_os().collect()) +} + +fn parse_args_os(args: Vec) -> Result { + let mut args = args.into_iter(); let program = args .next() .and_then(|arg| arg.into_string().ok()) @@ -31,17 +69,55 @@ fn parse_args() -> Result<(PathBuf, Option)> { bail!("usage: {program} "); }; - if args.next().is_some() { - bail!("usage: {program} "); - } - let arg_str = arg.to_string_lossy(); if arg_str == "--version" || arg_str == "-v" { println!("glass {VERSION}"); std::process::exit(0); } - parse_path_arg(PathBuf::from(arg)) + if arg_str == "--render" || arg_str == "--dump-render" { + let mut width = terminal_size_from_env("COLUMNS").unwrap_or(100); + let mut height = terminal_size_from_env("LINES").unwrap_or(40); + let mut path = None; + let rest = args.collect::>(); + let mut index = 0usize; + while index < rest.len() { + let value = rest[index].to_string_lossy(); + match value.as_ref() { + "--width" => { + index += 1; + width = parse_u16_arg(rest.get(index), "--width")?; + } + "--height" => { + index += 1; + height = parse_u16_arg(rest.get(index), "--height")?; + } + _ if path.is_none() => path = Some(PathBuf::from(&rest[index])), + _ => bail!("usage: {program} --render [--width n] [--height n] "), + } + index += 1; + } + let Some(path) = path else { + bail!("usage: {program} --render [--width n] [--height n] "); + }; + let (notes_dir, initial_file) = parse_path_arg(path)?; + return Ok(LaunchMode::Render { + notes_dir, + initial_file, + width, + height, + }); + } + + if args.next().is_some() { + bail!("usage: {program} "); + } + + let (notes_dir, initial_file) = parse_path_arg(PathBuf::from(arg))?; + Ok(LaunchMode::App { + notes_dir, + initial_file, + }) } fn parse_path_arg(path: PathBuf) -> Result<(PathBuf, Option)> { @@ -59,6 +135,25 @@ fn parse_path_arg(path: PathBuf) -> Result<(PathBuf, Option)> { } } +fn terminal_size_from_env(name: &str) -> Option { + env::var(name) + .ok()? + .parse::() + .ok() + .filter(|value| *value > 0) +} + +fn parse_u16_arg(value: Option<&std::ffi::OsString>, label: &str) -> Result { + let Some(value) = value else { + bail!("missing value for {label}"); + }; + let parsed = value + .to_string_lossy() + .parse::() + .with_context(|| format!("invalid value for {label}"))?; + Ok(parsed.max(1)) +} + #[cfg(test)] mod tests { use super::*; @@ -77,4 +172,35 @@ mod tests { std::fs::remove_dir(dir)?; Ok(()) } + + #[test] + fn render_args_parse_dimensions() -> Result<()> { + let mode = parse_args_from([ + "glass", + "--render", + "--width", + "80", + "--height", + "24", + "README.md", + ])?; + + assert!(matches!( + mode, + LaunchMode::Render { + width: 80, + height: 24, + .. + } + )); + Ok(()) + } + + fn parse_args_from(args: [&str; N]) -> Result { + parse_args_os( + args.iter() + .map(std::ffi::OsString::from) + .collect::>(), + ) + } } diff --git a/src/markdown/inline.rs b/src/markdown/inline.rs index 1a1a4c9..f55486f 100644 --- a/src/markdown/inline.rs +++ b/src/markdown/inline.rs @@ -19,6 +19,7 @@ pub struct InlineLink { } impl InlineLink { + #[cfg(test)] pub fn contains_column(&self, column: usize) -> bool { column >= self.source_start && column < self.source_end } @@ -72,6 +73,7 @@ pub fn links(source: &str) -> Vec { links } +#[cfg(test)] pub fn link_at_column(source: &str, column: usize) -> Option { links(source) .into_iter() diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 322a631..854379d 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -9,9 +9,8 @@ use ratatui::{ use crate::{ app::{App, Mode, SearchMatch, TextSelection}, config::theme::Theme, - editor::render::{ - column_in_wrap_segment, detect_list_marker, visible_rows, wrap_index_for_column, wrap_line, - }, + document::Block, + editor::render::{detect_list_marker, visible_rows}, markdown::{ highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, table::TableLayout, @@ -53,9 +52,7 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { page.height as usize, text_width, |line_num, text, w| { - if line_num == app.cursor.line { - wrap_line(text, w) - } else if table_layout.is_table_row(line_num) { + if table_layout.is_table_row(line_num) { table_layout.wrap_line(line_num, text, w) } else { concealed_wrap_line(text, w) @@ -69,9 +66,15 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { }); let cursor_line_text = app.buffer.line(app.cursor.line); - let wrap_index_of_cursor = - wrap_index_for_column(&cursor_line_text, app.cursor.column, text_width); + let (cursor_segments, _) = facade_wrap_line( + &table_layout, + app.cursor.line, + &cursor_line_text, + text_width, + ); + let wrap_index_of_cursor = wrap_index_for_segments(&cursor_segments, app.cursor.column); let mut cursor_visual_y: usize = 0; + let mut cursor_visual_x: Option = None; let mut cursor_found = false; let height = page.height as usize; @@ -81,20 +84,27 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; let active = row.line_number == app.cursor.line; - let table_row = (!active) - .then(|| { - table_layout.render_row_segment( - row.line_number, - &row.full_text, - text_width, - theme, - row.wrap_index, - ) - }) - .flatten(); + let table_row = table_layout.render_row_segment( + row.line_number, + &row.full_text, + text_width, + theme, + row.wrap_index, + ); let (mut line, source_map) = if let Some(rendered) = table_row { (rendered.line, Some(rendered.source_map)) + } else if let Some(block) = app.buffer.block_for_line(row.line_number) { + ( + render_document_segment( + block, + &row.full_text, + row.source_start, + row.source_end, + theme, + ), + None, + ) } else { ( render_markdown_segment_with_completion( @@ -161,6 +171,16 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { if is_cursor_row && !cursor_found && lines.len() < height { cursor_visual_y = lines.len(); + if let Some(source_map) = &source_map { + let source_column = app.cursor.column; + let mapped_column = source_map + .iter() + .position(|source_index| { + source_index.is_some_and(|index| index >= source_column) + }) + .unwrap_or(source_map.len()); + cursor_visual_x = Some(gutter_width + mapped_column as u16); + } cursor_found = true; } @@ -177,14 +197,108 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { } else { 0 }; - let x = column_in_wrap_segment(&cursor_line_text, app.cursor.column, text_width) as u16 - + gutter_width - + cursor_indent as u16; + let x = cursor_visual_x.unwrap_or_else(|| { + column_in_segments(&cursor_segments, app.cursor.column) as u16 + + gutter_width + + cursor_indent as u16 + }); let y = cursor_visual_y as u16; frame.set_cursor_position(Position::new(page.x + x, page.y + y)); } } +fn facade_wrap_line( + table_layout: &TableLayout, + line_number: usize, + text: &str, + width: usize, +) -> (Vec<(usize, usize)>, usize) { + let trimmed = text.trim_end_matches(['\r', '\n']); + if table_layout.is_table_row(line_number) { + table_layout.wrap_line(line_number, trimmed, width) + } else { + concealed_wrap_line(trimmed, width) + } +} + +fn wrap_index_for_segments(segments: &[(usize, usize)], column: usize) -> usize { + for (index, &(start, end)) in segments.iter().enumerate() { + if column >= start && column < end { + return index; + } + } + segments.len().saturating_sub(1) +} + +fn column_in_segments(segments: &[(usize, usize)], column: usize) -> usize { + for &(start, end) in segments { + if column >= start && column < end { + return column.saturating_sub(start); + } + } + + if let Some(&(start, end)) = segments.last() { + return (end - start).min(column.saturating_sub(start)); + } + + 0 +} + +fn render_document_segment( + block: &Block, + source: &str, + segment_start: usize, + segment_end: usize, + theme: Theme, +) -> Line<'static> { + let text = char_slice(source, segment_start, segment_end); + match block { + Block::Heading { .. } => Line::from(Span::styled(text, theme.heading)), + Block::Quote { .. } => Line::from(Span::styled(text, theme.quote)), + Block::CodeFence { .. } => Line::from(Span::styled(text, theme.inline_code)), + Block::RawMarkdown(_) => Line::from(Span::styled(text, theme.muted)), + Block::ListItem { .. } | Block::ChecklistItem { .. } => { + let marker_len = facade_list_marker_len(source); + if marker_len <= segment_start { + return Line::from(Span::styled(text, Style::default().fg(theme.text))); + } + if marker_len >= segment_end { + return Line::from(Span::styled(text, theme.list_marker)); + } + + Line::from(vec![ + Span::styled( + char_slice(source, segment_start, marker_len), + theme.list_marker, + ), + Span::styled( + char_slice(source, marker_len, segment_end), + Style::default().fg(theme.text), + ), + ]) + } + _ => Line::from(Span::styled(text, Style::default().fg(theme.text))), + } +} + +fn facade_list_marker_len(source: &str) -> usize { + let leading = source.chars().take_while(|ch| ch.is_whitespace()).count(); + let trimmed = char_slice(source, leading, source.chars().count()); + if trimmed.starts_with("[ ] ") || trimmed.starts_with("[x] ") { + return leading + 4; + } + if trimmed.starts_with("• ") || trimmed.starts_with("◦ ") { + return leading + 2; + } + + let digits = trimmed.chars().take_while(|ch| ch.is_ascii_digit()).count(); + if digits > 0 && char_slice(&trimmed, digits, digits + 2) == ". " { + return leading + digits + 2; + } + + leading +} + fn selected_line(mut line: Line<'static>, theme: Theme) -> Line<'static> { line.style = theme.selection; for span in &mut line.spans { @@ -480,4 +594,37 @@ mod tests { vec![(1, 3), (6, 8)] ); } + + #[test] + fn unicode_list_marker_does_not_style_body_prefix() { + let theme = Theme::monochrome_for_tests(); + let block = Block::ListItem { + indent: 0, + marker: crate::document::model::ListMarker::Bullet, + content: Vec::new(), + }; + + let line = render_document_segment(&block, "• inactive", 0, 10, theme); + + assert_eq!(line.spans[0].content.as_ref(), "• "); + assert_eq!(line.spans[0].style, theme.list_marker); + assert_eq!(line.spans[1].content.as_ref(), "inactive"); + assert_eq!(line.spans[1].style, Style::default().fg(theme.text)); + } + + #[test] + fn checklist_marker_is_styled_as_one_unit() { + let theme = Theme::monochrome_for_tests(); + let block = Block::ChecklistItem { + indent: 0, + checked: false, + content: Vec::new(), + }; + + let line = render_document_segment(&block, "[ ] task", 0, 8, theme); + + assert_eq!(line.spans[0].content.as_ref(), "[ ] "); + assert_eq!(line.spans[0].style, theme.list_marker); + assert_eq!(line.spans[1].content.as_ref(), "task"); + } }