diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a9506..3a81f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Document facade rendering now uses the same concealed wrapping path for display and full-height `--render` snapshots, preventing dropped wrap-boundary characters and preserving continuation indentation for facade list markers. +- Markdown shortcut typing now remaps the cursor through hidden syntax, so heading and inline formatting conversions do not jump to the wrong facade column. +- Typing `- [ ]` through the rendered bullet facade now converts to a checklist item. +- Empty rendered bullet items now exit cleanly on Enter instead of leaving stray bullet glyphs. +- Backspacing through rendered list and checklist markers now clears or unwraps the structural item instead of corrupting the marker text. +- Drag selection now copies once on mouse release instead of copying repeatedly while dragging. +- Visual selection yanking now supports `y` and the clipboard register form `"+y`. +- Copied full-block selections no longer insert extra blank lines between serialized Markdown blocks. +- Rendered table cells now stay editable after being cleared, and typing at table edges updates the nearest model cell instead of corrupting table source text. +- Table cell editing now escapes typed pipe characters so they remain cell content instead of splitting the row. +- Large document jumps now reuse wrap calculations and keep the viewport responsive when moving to distant lines. + ## [0.1.8] - 2026-05-21 Full commit range: [`v0.1.7...v0.1.8`](https://github.com/pacificcodeinc/glass/compare/v0.1.7...v0.1.8) diff --git a/ISSUES.md b/ISSUES.md index aed206e..d9cd34a 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -1,40 +1,15 @@ -# Issues +# Current Glass Issues -Bug & feature log for glass. +## Test Coverage -## Rendering +- Add app-level typing tests that drive Glass through key and mouse events. +- Use render snapshots for static layout checks and app harness tests for editing behavior. -- [ ] **Fenced code blocks**: highlight the language identifier (e.g., `rust` in ` ```rust `). -- [x] **Tables**: render Markdown tables. -- [ ] **Strikethrough**: render `~~text~~`, including inside list items. -- [ ] **URL display**: strip unnecessary parts (e.g., `https://`) from bare URLs that lack pretty titles. -- [ ] **Link expansion**: only expand URLs to their real Markdown form on hover, not whenever their line is active in Normal mode. -- [ ] **Inline elements**: fix broken behavior of inline elements across line breaks. -- [ ] **Wiki-links**: render `[[File.md]]` distinctly and support jumping to the linked file. +## Recently Addressed In The v0.2.0 PR -## Editor & Input - -- [x] **Mouse support**: click anywhere in the editor to move the cursor. -- [ ] **Vim motions**: achieve full parity with standard Vim motions. -- [ ] **Simple mode**: add a non-Vim editing mode (post-1.0). -- [ ] **Spell check**: add spell-checking support. -- [ ] **Line breaking**: fix general line-breaking bugs. -- [ ] **Search**: in Normal mode, pressing `/` opens a bottom popup to search for text in the current document. -- [ ] **Command bar**: typing `:` in Normal mode opens the status bar for command input. Show a fuzzy-searchable suggestion popup above the status bar with all available commands; use Tab to cycle completions and Up/Down to navigate. Example: `:table` inserts a Markdown table at the cursor. Or `:read` sets the view to read-only without expanding the markdwon when you're active on the line. - -## File Management - -- [ ] **File picker**: rewrite the fuzzy-find / Command+P picker from scratch. -- [x] **Old picker cleanup**: remove the fuzzy finder, command palette overlay, and Command+P binding before the rewrite. -- [x] **Lazy file creation**: when opening a new file via `glass .md`, only create it on disk after `:w` is used. -- [ ] **New file UI**: show the unsaved-change indicator and the target filename (instead of `[no note]`) when opening a non-existent file. - -## UI / Status Bar - -- [x] **Sidebar branding**: fix the "Glass" text in the sidebar randomly turning red. -- [ ] **Unsaved indicator**: use a white (instead of red) unsaved-change icon next to the filename in the status bar. - -## Cursor Behavior - -- [x] **`G` motion**: preserve the current column, like `dd` does. -- [x] **Column preservation**: preserve the cursor column when jumping lines with `h`/`j`/arrows/`g`/`gg`. +- Markdown shortcuts now convert reliably through the document model, including `# ` headings and `- [ ]` checklist items. +- Inline Markdown syntax edits route through the shared surface layer instead of corrupting facade text. +- Drag selection copies once on mouse release, and `"+y` copies through the clipboard/register path. +- Full-block Markdown selections no longer add extra blank lines between serialized blocks. +- Rendered table cells stay editable after being cleared, empty cells remain focusable, and typing at table row edges updates the nearest model cell. +- `G` and large visual jumps reuse wrap calculations so larger documents respond more quickly. diff --git a/benchmark.md b/benchmark.md index 5ec3b7e..9eb525a 100644 --- a/benchmark.md +++ b/benchmark.md @@ -25,7 +25,7 @@ Indented headings should stay plain: ## Paragraphs And Wrapping -This paragraph is intentionally long so it wraps across several visual rows in a normal terminal width. It includes plain words, punctuation, and a useful search target: glass benchmark needle. Cursor movement should preserve the intended visual column when moving through this wrapped paragraph, and selection should copy the selected text immediately. +This paragraph is intentionally long so it wraps across several visual rows in a normal terminal width. It includes plain words, punctuation, and a useful search target: glass benchmark needle. Cursor movement should preserve the intended visual column when moving through this wrapped paragraph, and selection should copy the selected text when dragging finishes. This query is split across a physical line break for search testing: multi diff --git a/src/app.rs b/src/app.rs index daf3df0..cbad90f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use std::{ + cell::RefCell, path::{Path, PathBuf}, process::Command as ProcessCommand, time::{Duration, Instant}, @@ -14,16 +15,17 @@ use crossterm::event::{ use crate::{ config::theme::Theme, + document::{SurfaceLine, SurfaceMode, surface::wrap_surface_or_facade_line}, editor::{ buffer::DocumentBuffer, 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::{highlight::concealed_wrap_line, table::TableLayout}, + markdown::inline::LinkKind, + markdown::table::TableLayout, }; const STATUS_MESSAGE_TTL: Duration = Duration::from_secs(3); @@ -40,6 +42,8 @@ pub enum Mode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandPrompt { Command, + File, + Palette, Search, } @@ -117,6 +121,13 @@ pub struct TextSelection { pub head: Cursor, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SurfaceEdit { + Insert(char), + Backspace, + Delete, +} + impl TextSelection { pub fn ordered(self) -> (Cursor, Cursor) { if cursor_before_or_equal(self.anchor, self.head) { @@ -171,11 +182,56 @@ pub struct App { pub visual_line_anchor: Option, preferred_column: Option, preferred_visual_column: Option, + surface_cursor: Option<(usize, usize, usize)>, pending_g: bool, pending_delete: bool, pending_change: bool, + pending_register_prefix: bool, + pending_register: Option, mouse_anchor: Option, last_copied_selection: Option, + wrap_cache: RefCell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WrapCacheKey { + line: usize, + width: usize, + cursor_line: usize, + cursor_column: usize, + surface_column: Option, +} + +#[derive(Debug, Clone)] +struct WrapCacheEntry { + key: WrapCacheKey, + value: (Vec<(usize, usize)>, usize), +} + +#[derive(Debug, Default)] +struct WrapCache { + entries: Vec, +} + +impl WrapCache { + fn get(&self, key: WrapCacheKey) -> Option<(Vec<(usize, usize)>, usize)> { + self.entries + .iter() + .rev() + .find(|entry| entry.key == key) + .map(|entry| entry.value.clone()) + } + + fn insert(&mut self, key: WrapCacheKey, value: (Vec<(usize, usize)>, usize)) { + if self.entries.len() >= 256 { + self.entries.remove(0); + } + self.entries.push(WrapCacheEntry { key, value }); + } + + fn clear(&mut self) { + self.entries.clear(); + } } impl App { @@ -208,21 +264,27 @@ impl App { visual_line_anchor: None, preferred_column: None, preferred_visual_column: None, + surface_cursor: None, pending_g: false, pending_delete: false, pending_change: false, + pending_register_prefix: false, + pending_register: None, mouse_anchor: None, last_copied_selection: None, + wrap_cache: RefCell::new(WrapCache::default()), }) } pub fn handle_event(&mut self, event: Event) -> Result<()> { + self.clear_wrap_cache(); match event { Event::Key(key) => self.handle_key_event(key)?, Event::Mouse(mouse) => self.handle_mouse_event(mouse)?, _ => {} } + self.clear_wrap_cache(); self.keep_cursor_visible(); Ok(()) } @@ -253,8 +315,13 @@ impl App { return Ok(()); } - if self.mode != Mode::CommandLine && is_command_sheet_shortcut(key) { - self.enter_command_sheet(CommandPrompt::Command); + if self.mode != Mode::CommandLine && is_file_picker_shortcut(key) { + self.enter_command_sheet(CommandPrompt::File); + return Ok(()); + } + + if self.mode != Mode::CommandLine && is_command_palette_shortcut(key) { + self.enter_command_sheet(CommandPrompt::Palette); return Ok(()); } @@ -273,16 +340,22 @@ impl App { } pub fn resize_viewport(&mut self, visible_height: usize, visible_width: usize) { + self.clear_wrap_cache(); self.viewport.visible_height = visible_height.max(1); self.viewport.visible_width = visible_width.max(1); self.keep_cursor_visible(); } pub fn move_viewport_to(&mut self, x: u16, y: u16) { + self.clear_wrap_cache(); self.viewport.x = x; self.viewport.y = y; } + fn clear_wrap_cache(&self) { + self.wrap_cache.borrow_mut().clear(); + } + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> { if self.mode == Mode::CommandLine { return Ok(()); @@ -290,11 +363,15 @@ impl App { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { - let Some(cursor) = self.cursor_for_mouse_position(mouse.column, mouse.row) else { + let Some((cursor, surface_column)) = + self.cursor_for_mouse_position(mouse.column, mouse.row) + else { return Ok(()); }; self.cursor = cursor; + self.surface_cursor = + surface_column.map(|column| (cursor.line, cursor.column, column)); self.clear_text_selection(); self.cancel_pending_operators(); self.reset_preferred_column(); @@ -309,11 +386,15 @@ impl App { let Some(anchor) = self.mouse_anchor else { return Ok(()); }; - let Some(cursor) = self.cursor_for_mouse_position(mouse.column, mouse.row) else { + let Some((cursor, surface_column)) = + self.cursor_for_mouse_position(mouse.column, mouse.row) + else { return Ok(()); }; self.cursor = cursor; + self.surface_cursor = + surface_column.map(|column| (cursor.line, cursor.column, column)); self.update_text_selection(anchor, cursor); self.cancel_pending_operators(); self.reset_preferred_column(); @@ -324,8 +405,12 @@ impl App { anchor, self.cursor_for_mouse_position(mouse.column, mouse.row), ) { - self.cursor = cursor; - self.update_text_selection(anchor, cursor); + self.cursor = cursor.0; + self.surface_cursor = cursor + .1 + .map(|column| (self.cursor.line, self.cursor.column, column)); + self.update_text_selection(anchor, self.cursor); + self.copy_current_text_selection(); self.cancel_pending_operators(); self.reset_preferred_column(); } @@ -350,6 +435,8 @@ impl App { self.pending_g = false; self.pending_delete = false; self.pending_change = false; + self.pending_register_prefix = false; + self.pending_register = None; } fn clear_text_selection(&mut self) { @@ -366,9 +453,16 @@ impl App { } self.text_selection = Some(TextSelection { anchor, head }); + } + + fn copy_current_text_selection(&mut self) { let Some(selected_text) = self.selected_text() else { return; }; + self.copy_text(selected_text); + } + + fn copy_text(&mut self, selected_text: String) { if self.last_copied_selection.as_ref() == Some(&selected_text) { return; } @@ -384,26 +478,41 @@ impl App { } } + fn copy_visual_selection(&mut self) { + let anchor = self.visual_line_anchor.unwrap_or(self.cursor.line); + let start_line = anchor.min(self.cursor.line); + let end_line = anchor.max(self.cursor.line); + let start = Cursor { + line: start_line, + column: 0, + }; + let end = Cursor { + line: end_line, + column: self.buffer.line_len_chars(end_line), + }; + if let Some(selected_text) = self.buffer.selected_markdown(start, end) { + self.copy_text(selected_text); + } + self.mode = Mode::Normal; + self.visual_line_anchor = None; + self.cancel_pending_operators(); + } + 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<()> { + if self.pending_register_prefix { + self.pending_register_prefix = false; + if let KeyCode::Char(register) = key.code { + self.pending_register = Some(register); + } + return Ok(()); + } + if self.pending_delete { self.pending_delete = false; self.delete_motion(key); @@ -434,6 +543,10 @@ impl App { } match key.code { + KeyCode::Char('"') => { + self.pending_register_prefix = true; + self.set_status("register"); + } KeyCode::Char(':') => { self.enter_command_line(); } @@ -479,13 +592,23 @@ impl App { self.mode = Mode::Insert; } KeyCode::Char('h') | KeyCode::Left => { - motions::left(&mut self.cursor); + if !self.move_table_cursor_horizontally(-1) + && !self.move_surface_cursor_horizontally(-1) + { + motions::left(&mut self.cursor); + self.surface_cursor = None; + } 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) + && !self.move_surface_cursor_horizontally(1) + { + motions::right(&self.buffer, &mut self.cursor); + self.surface_cursor = None; + } self.reset_preferred_column(); } KeyCode::Char('w') => { @@ -542,13 +665,35 @@ impl App { self.pending_delete = true; self.set_status("delete"); } + KeyCode::Char('y') if self.pending_register == Some('+') => { + let line = self.cursor.line; + let start = Cursor { line, column: 0 }; + let end = Cursor { + line, + column: self.buffer.line_len_chars(line), + }; + if let Some(selected_text) = self.buffer.selected_markdown(start, end) { + self.copy_text(selected_text); + } + self.cancel_pending_operators(); + } KeyCode::Char('G') => self.document_end_preserving_column(), KeyCode::Char('g') => self.pending_g = true, KeyCode::Char('n') => self.jump_search_match(1), KeyCode::Char('N') => self.jump_search_match(-1), KeyCode::Char('u') => self.undo(), KeyCode::Char('x') | KeyCode::Delete => { - self.buffer.delete_char(&mut self.cursor); + if self.buffer.delete_table_char(&mut self.cursor, false) { + self.surface_cursor = None; + } else if self + .buffer + .delete_structural_list_marker_at_cursor(&mut self.cursor, false) + { + self.surface_cursor = None; + } else if !self.edit_active_surface(SurfaceEdit::Delete) { + self.surface_cursor = None; + self.buffer.delete_char(&mut self.cursor); + } self.reset_preferred_column(); } _ => {} @@ -561,7 +706,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 => { @@ -569,7 +716,15 @@ impl App { self.delete_to_line_start(); } else if key.modifiers.contains(KeyModifiers::ALT) { self.delete_word_backward(); - } else { + } else if self.buffer.delete_table_char(&mut self.cursor, true) { + self.surface_cursor = None; + } else if self + .buffer + .delete_structural_list_marker_at_cursor(&mut self.cursor, true) + { + self.surface_cursor = None; + } else if !self.edit_active_surface(SurfaceEdit::Backspace) { + self.surface_cursor = None; self.buffer.delete_previous_char(&mut self.cursor); } self.reset_preferred_column(); @@ -577,13 +732,27 @@ impl App { KeyCode::Delete => { if key.modifiers.contains(KeyModifiers::SUPER) { self.delete_to_line_end(); - } else { + } else if self.buffer.delete_table_char(&mut self.cursor, false) { + self.surface_cursor = None; + } else if self + .buffer + .delete_structural_list_marker_at_cursor(&mut self.cursor, false) + { + self.surface_cursor = None; + } else if !self.edit_active_surface(SurfaceEdit::Delete) { + self.surface_cursor = None; self.buffer.delete_char(&mut self.cursor); } 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) => { @@ -595,15 +764,26 @@ impl App { self.reset_preferred_column(); } KeyCode::Char(ch) if is_text_input_key(key) => { - self.buffer.insert_char(&mut self.cursor, ch); + if self.buffer.insert_table_char(&mut self.cursor, ch) { + self.surface_cursor = None; + } else if !self.edit_active_surface(SurfaceEdit::Insert(ch)) { + self.surface_cursor = None; + self.buffer.insert_char(&mut self.cursor, ch); + } self.reset_preferred_column(); } KeyCode::Left => { - motions::left(&mut self.cursor); + if !self.move_surface_cursor_horizontally(-1) { + motions::left(&mut self.cursor); + self.surface_cursor = None; + } self.reset_preferred_column(); } KeyCode::Right => { - motions::right(&self.buffer, &mut self.cursor); + if !self.move_surface_cursor_horizontally(1) { + motions::right(&self.buffer, &mut self.cursor); + self.surface_cursor = None; + } self.reset_preferred_column(); } KeyCode::Up => self.move_line_up_preserving_column(), @@ -621,6 +801,14 @@ impl App { } fn handle_visual_key(&mut self, key: KeyEvent) -> Result<()> { + if self.pending_register_prefix { + self.pending_register_prefix = false; + if let KeyCode::Char(register) = key.code { + self.pending_register = Some(register); + } + return Ok(()); + } + if self.pending_g { self.pending_g = false; match key.code { @@ -639,6 +827,10 @@ impl App { } match key.code { + KeyCode::Char('"') => { + self.pending_register_prefix = true; + self.set_status("register"); + } KeyCode::Char(':') => { self.enter_command_line(); } @@ -653,17 +845,22 @@ impl App { self.mode = Mode::Normal; self.visual_line_anchor = None; } + KeyCode::Char('y') => self.copy_visual_selection(), KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Delete | KeyCode::Backspace => { 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') => { @@ -991,6 +1188,33 @@ impl App { return self.execute_search_query(&input, selected); } + if matches!(self.sheet.prompt, CommandPrompt::File) { + if let Some(item) = selected { + return self.execute_sheet_item(item); + } + if let Some(path) = resolve_command_path_input(&input, &self.notes_dir) { + self.finish_command_sheet(); + return self.open_path(&path); + } + self.finish_command_sheet(); + self.set_status(format!("No file match: {input}")); + return Ok(()); + } + + if matches!(self.sheet.prompt, CommandPrompt::Palette) { + let command = parse_command(&input); + if !matches!(command, Command::Unknown(_)) { + self.finish_command_sheet(); + return self.execute_command(command); + } + if let Some(item) = selected { + return self.execute_sheet_item(item); + } + self.finish_command_sheet(); + self.set_status(format!("No command match: {input}")); + return Ok(()); + } + if let Some(search_query) = input.strip_prefix('/') { return self.execute_search_query(search_query.trim(), selected); } @@ -1105,6 +1329,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}")); } @@ -1200,7 +1450,7 @@ impl App { self.viewport.visible_width.saturating_sub(gutter).max(1) } - fn cursor_for_mouse_position(&self, column: u16, row: u16) -> Option { + fn cursor_for_mouse_position(&self, column: u16, row: u16) -> Option<(Cursor, Option)> { if !self.viewport_contains(column, row) { return None; } @@ -1220,7 +1470,11 @@ impl App { local_x < self.viewport.visible_width && local_y < self.viewport.visible_height } - fn cursor_for_viewport_position(&self, local_x: usize, local_y: usize) -> Option { + fn cursor_for_viewport_position( + &self, + local_x: usize, + local_y: usize, + ) -> Option<(Cursor, Option)> { let gutter = (self.buffer.line_count().to_string().len() + 1) as usize; let text_x = local_x.saturating_sub(gutter); let width = self.wrap_width(); @@ -1232,31 +1486,76 @@ 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) + let mode = if line_num == self.cursor.line { + SurfaceMode::Active { + cursor_column: self.cursor.column, + } + } else { + SurfaceMode::Inactive + }; + wrap_surface_or_facade_line( + self.buffer.block_for_line(line_num), + text, + width, + mode, + ) } }, ); let Some(row) = rows.get(local_y) else { let line = self.buffer.line_count().saturating_sub(1); - return Some(Cursor { - line, - column: self.buffer.line_len_chars(line), - }); + return Some(( + Cursor { + line, + column: self.buffer.line_len_chars(line), + }, + None, + )); }; let text_x = text_x.saturating_sub(row.continuation_indent); + if row.line_number == self.cursor.line + && !table_layout.is_table_row(row.line_number) + && self.buffer.block_for_line(row.line_number).is_some() + { + let surface = self.buffer.surface_line( + row.line_number, + SurfaceMode::Active { + cursor_column: self.cursor.column, + }, + ); + let display_segments = surface.wrap_display_segments(width); + let (display_start, display_end) = display_segments + .get(row.wrap_index) + .copied() + .unwrap_or((0, surface.display_len())); + let surface_column = + display_start + text_x.min(display_end.saturating_sub(display_start)); + let column = surface + .source_column_for_display_column(surface_column) + .min(self.buffer.line_len_chars(row.line_number)); + return Some(( + Cursor { + line: row.line_number, + column, + }, + Some(surface_column.min(surface.display_len())), + )); + } + let segment_len = row.source_end.saturating_sub(row.source_start); let column = row.source_start + text_x.min(segment_len); - Some(Cursor { - line: row.line_number, - column: column.min(self.buffer.line_len_chars(row.line_number)), - }) + Some(( + Cursor { + line: row.line_number, + column: column.min(self.buffer.line_len_chars(row.line_number)), + }, + None, + )) } fn scroll_visual_rows(&mut self, delta: isize) { @@ -1296,11 +1595,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 +1625,26 @@ 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; + } + + if let Some(surface) = self.active_surface_line() + && surface.has_virtual_chars() + { + let surface_column = self.active_surface_column(&surface); + let display_segments = surface.wrap_display_segments(width); + for (start, end) in display_segments { + if surface_column >= start && surface_column <= end { + return surface_column.saturating_sub(start); + } + } + } + + let (segment_start, _) = + self.visual_line_bounds(self.cursor.line, self.cursor.column, width); self.cursor.column.saturating_sub(segment_start) } @@ -1356,9 +1669,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,17 +1694,66 @@ 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; + } + + if let Some(surface) = self.active_surface_line() + && surface.has_virtual_chars() + { + let surface_column = self.active_surface_column(&surface); + for (start, end) in surface.wrap_display_segments(width) { + if surface_column >= start && surface_column <= end { + self.cursor.column = surface + .source_column_for_display_column(start) + .min(self.buffer.line_len_chars(self.cursor.line)); + self.surface_cursor = Some((self.cursor.line, self.cursor.column, start)); + return; + } + } + } + + let (seg_start, _) = self.visual_line_bounds(self.cursor.line, self.cursor.column, width); self.cursor.column = seg_start; + self.surface_cursor = None; } 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; + } + + if let Some(surface) = self.active_surface_line() + && surface.has_virtual_chars() + { + let surface_column = self.active_surface_column(&surface); + for (start, end) in surface.wrap_display_segments(width) { + if surface_column >= start && surface_column <= end { + self.cursor.column = surface + .source_column_for_display_column(end) + .min(self.buffer.line_len_chars(self.cursor.line)); + self.surface_cursor = Some((self.cursor.line, self.cursor.column, end)); + 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)); + self.surface_cursor = None; } fn handle_navigation_modifier(&mut self, key: KeyEvent) -> bool { @@ -1497,6 +1863,111 @@ impl App { self.preferred_visual_column = None; } + pub(crate) fn surface_cursor_column_for_line(&self, line: usize) -> Option { + let (cursor_line, source_column, column) = self.surface_cursor?; + if cursor_line != line || source_column != self.cursor.column { + return None; + } + Some( + column.min( + self.buffer + .surface_line( + line, + SurfaceMode::Active { + cursor_column: self.cursor.column, + }, + ) + .display_len(), + ), + ) + } + + fn active_surface_line(&self) -> Option { + if self.is_table_row(self.cursor.line) + || self.buffer.block_for_line(self.cursor.line).is_none() + { + return None; + } + Some(self.buffer.surface_line( + self.cursor.line, + SurfaceMode::Active { + cursor_column: self.cursor.column, + }, + )) + } + + fn active_surface_column(&self, surface: &SurfaceLine) -> usize { + self.surface_cursor_column_for_line(self.cursor.line) + .unwrap_or_else(|| surface.display_column_for_source_column(self.cursor.column)) + .min(surface.display_len()) + } + + fn move_surface_cursor_horizontally(&mut self, delta: isize) -> bool { + let Some(surface) = self.active_surface_line() else { + return false; + }; + if !surface.has_virtual_chars() { + return false; + } + + let current = self.active_surface_column(&surface); + let target = if delta < 0 { + current.saturating_sub(1) + } else { + current.saturating_add(1).min(surface.display_len()) + }; + if target == current { + return false; + } + + self.cursor.column = surface + .source_column_for_display_column(target) + .min(self.buffer.line_len_chars(self.cursor.line)); + self.surface_cursor = Some((self.cursor.line, self.cursor.column, target)); + true + } + + fn edit_active_surface(&mut self, edit: SurfaceEdit) -> bool { + let Some(surface) = self.active_surface_line() else { + return false; + }; + if !surface.has_virtual_chars() { + return false; + } + + let mut surface_column = self.active_surface_column(&surface); + let mut chars = surface.text.chars().collect::>(); + match edit { + SurfaceEdit::Insert(ch) => { + chars.insert(surface_column, ch); + surface_column += 1; + } + SurfaceEdit::Backspace => { + if surface_column == 0 { + return false; + } + surface_column -= 1; + chars.remove(surface_column); + } + SurfaceEdit::Delete => { + if surface_column >= chars.len() { + return false; + } + chars.remove(surface_column); + } + } + + let surface_text = chars.into_iter().collect::(); + let display_column = self.buffer.replace_line_from_surface( + self.cursor.line, + &surface_text, + surface_column, + &mut self.cursor, + ); + self.surface_cursor = Some((self.cursor.line, self.cursor.column, display_column)); + true + } + fn move_to_line_preserving_column(&mut self, line: usize) { let column = self.preferred_column(); self.cursor.line = line.min(self.buffer.line_count().saturating_sub(1)); @@ -1539,8 +2010,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 +2029,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), @@ -1657,16 +2151,29 @@ impl App { fn refresh_sheet_items(&mut self) { let input = self.command_line.trim().to_string(); - let mut items = if matches!(self.sheet.prompt, CommandPrompt::Search) { - self.preview_search(&input); - search_sheet_items(&self.buffer, &input) - } else if let Some(search_query) = input.strip_prefix('/') { - let query = search_query.trim(); - self.preview_search(query); - search_sheet_items(&self.buffer, query) - } else { - self.clear_search(); - command_sheet_items(&input, &self.notes_dir, &self.file_tree) + let mut items = match self.sheet.prompt { + CommandPrompt::Search => { + self.preview_search(&input); + search_sheet_items(&self.buffer, &input) + } + CommandPrompt::File => { + self.clear_search(); + file_sheet_items(&input, &self.notes_dir, &self.file_tree) + } + CommandPrompt::Palette => { + self.clear_search(); + palette_sheet_items(&input) + } + CommandPrompt::Command => { + if let Some(search_query) = input.strip_prefix('/') { + let query = search_query.trim(); + self.preview_search(query); + search_sheet_items(&self.buffer, query) + } else { + self.clear_search(); + command_sheet_items(&input, &self.notes_dir, &self.file_tree) + } + } }; items.truncate(128); @@ -1684,9 +2191,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 +2241,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, @@ -1752,6 +2253,17 @@ impl App { return; } + if self.cursor.line > self.viewport.top_line + && self.cursor.line.saturating_sub(self.viewport.top_line) + >= self.viewport.visible_height + { + let (line, wrap_index) = self.visual_position_for_cursor_bottom(cursor_wrap, width); + self.viewport.top_line = line; + self.viewport.top_wrap_index = wrap_index; + self.normalize_viewport(width); + return; + } + let offset = self .visual_offset_from_viewport(self.cursor.line, cursor_wrap, width) .unwrap_or(usize::MAX); @@ -1847,42 +2359,243 @@ impl App { (line, wrap) } - fn line_wrap_count(&self, line: usize, width: usize) -> usize { - let line_text = self.buffer.line(line); + 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 surface_column = self + .surface_cursor + .and_then(|(cursor_line, source, column)| { + (cursor_line == line && source == self.cursor.column).then_some(column) + }); + let key = WrapCacheKey { + line, + width, + cursor_line: self.cursor.line, + cursor_column: self.cursor.column, + surface_column, + }; + if let Some(value) = self.wrap_cache.borrow().get(key) { + return value; + } + + 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) { + let value = if table_layout.is_table_row(line) { table_layout.wrap_line(line, trimmed, width) } else { - concealed_wrap_line(trimmed, width) + let mode = if line == self.cursor.line { + SurfaceMode::Active { + cursor_column: self.cursor.column, + } + } else { + SurfaceMode::Inactive + }; + wrap_surface_or_facade_line(self.buffer.block_for_line(line), trimmed, width, mode) }; - segments.len().max(1) + self.wrap_cache.borrow_mut().insert(key, value.clone()); + value + } + + 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 } } @@ -1918,17 +2631,30 @@ fn is_text_input_key(key: KeyEvent) -> bool { .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) } -fn is_command_sheet_shortcut(key: KeyEvent) -> bool { +fn is_file_picker_shortcut(key: KeyEvent) -> bool { + is_primary_p_shortcut(key) && !key.modifiers.contains(KeyModifiers::SHIFT) && !is_upper_p(key) +} + +fn is_command_palette_shortcut(key: KeyEvent) -> bool { + is_primary_p_shortcut(key) && (key.modifiers.contains(KeyModifiers::SHIFT) || is_upper_p(key)) +} + +fn is_primary_p_shortcut(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')) && key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) } +fn is_upper_p(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Char('P')) +} + #[derive(Debug, Clone, Copy)] struct CommandCandidate { replacement: &'static str, label: &'static str, + command_label: &'static str, detail: &'static str, aliases: &'static [&'static str], action: CommandCandidateAction, @@ -1944,46 +2670,76 @@ enum CommandCandidateAction { const COMMAND_CANDIDATES: &[CommandCandidate] = &[ CommandCandidate { replacement: "w", - label: "write", + label: "Save File", + command_label: ":w", detail: "Save current file", aliases: &["w", "write", "save"], action: CommandCandidateAction::Command("w"), }, CommandCandidate { replacement: "q", - label: "quit", + label: "Quit", + command_label: ":q", detail: "Quit if there are no unsaved changes", aliases: &["q", "quit", "close"], action: CommandCandidateAction::Command("q"), }, CommandCandidate { replacement: "q!", - label: "quit!", + label: "Force Quit", + command_label: ":q!", detail: "Quit and discard unsaved changes", aliases: &["q!", "quit!", "force quit"], action: CommandCandidateAction::Command("q!"), }, CommandCandidate { replacement: "wq", - label: "write quit", + label: "Save And Quit", + command_label: ":wq", detail: "Save current file and quit", aliases: &["wq", "x", "write quit", "save quit"], action: CommandCandidateAction::Command("wq"), }, CommandCandidate { replacement: "e ", - label: "edit", + label: "Open File", + command_label: ":edit", detail: "Open a file path", aliases: &["e", "edit", "open", "file"], action: CommandCandidateAction::Complete("e "), }, CommandCandidate { replacement: "/", - label: "search", + label: "Find In Document", + command_label: "/", detail: "Find text in the current document", aliases: &["/", "search", "find"], action: CommandCandidateAction::BeginSearch, }, + CommandCandidate { + replacement: "table", + label: "Insert Table", + command_label: ":table", + detail: "Insert a 2x2 table", + aliases: &["table", "insert table", "grid"], + action: CommandCandidateAction::Command("table"), + }, + CommandCandidate { + replacement: "row", + label: "Insert Table Row", + command_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: "Insert Table Column", + command_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( @@ -1992,34 +2748,95 @@ fn command_sheet_items( file_tree: &FileTree, ) -> Vec<(usize, SheetItem)> { let mut items = Vec::new(); + let is_editing_path = is_edit_command_input(input); + + if !is_editing_path { + for candidate in COMMAND_CANDIDATES { + if let Some(score) = score_command_candidate(input, candidate) { + items.push((score, command_sheet_item(candidate, false))); + } + } + } + + let file_query = file_query_for_command_input(input); + if is_editing_path { + for (score, mut item) in file_sheet_items(file_query, notes_dir, file_tree) { + item.replacement = edit_replacement(input, &item.replacement); + items.push((score, item)); + } + } + + items.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.label.cmp(&right.1.label)) + .then_with(|| left.1.detail.cmp(&right.1.detail)) + }); + items +} +fn palette_sheet_items(input: &str) -> Vec<(usize, SheetItem)> { + let mut items = Vec::new(); for candidate in COMMAND_CANDIDATES { + if matches!(candidate.action, CommandCandidateAction::Complete(_)) { + continue; + } if let Some(score) = score_command_candidate(input, candidate) { - items.push((1_000 + score, command_sheet_item(candidate))); + items.push((score, command_sheet_item(candidate, true))); } } - let file_query = file_query_for_command_input(input); + items.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.label.cmp(&right.1.label)) + }); + items +} + +fn file_sheet_items( + input: &str, + notes_dir: &Path, + file_tree: &FileTree, +) -> Vec<(usize, SheetItem)> { + let mut items = Vec::new(); + let query = input.trim(); + for entry in file_tree.entries.iter().filter(|entry| !entry.is_dir) { - if let Some(score) = score_file_entry(file_query, notes_dir, entry) { + if let Some(score) = score_file_entry(query, notes_dir, entry) { let relative = relative_path_label(notes_dir, &entry.path); items.push(( score, SheetItem { kind: SheetItemKind::File, label: entry.display_name.clone(), - detail: relative.clone(), - replacement: if input.starts_with("e ") || input.starts_with("edit ") { - format!("e {relative}") - } else { - relative - }, + detail: file_detail(notes_dir, entry, &relative), + replacement: relative, action: SheetAction::File(entry.path.clone()), }, )); } } + if let Some(path) = resolve_command_path_input(query, notes_dir) { + let already_listed = items + .iter() + .any(|(_, item)| item.action == SheetAction::File(path.clone())); + if !already_listed { + let relative = relative_path_label(notes_dir, &path); + items.push(( + 0, + SheetItem { + kind: SheetItemKind::File, + label: relative.clone(), + detail: "Create new file".to_string(), + replacement: relative, + action: SheetAction::File(path), + }, + )); + } + } + items.sort_by(|left, right| { left.0 .cmp(&right.0) @@ -2029,7 +2846,7 @@ fn command_sheet_items( items } -fn command_sheet_item(candidate: &CommandCandidate) -> SheetItem { +fn command_sheet_item(candidate: &CommandCandidate, palette: bool) -> SheetItem { let action = match candidate.action { CommandCandidateAction::Command(command) => SheetAction::Command(command.to_string()), CommandCandidateAction::Complete(value) => SheetAction::Complete(value.to_string()), @@ -2038,7 +2855,12 @@ fn command_sheet_item(candidate: &CommandCandidate) -> SheetItem { SheetItem { kind: SheetItemKind::Command, - label: candidate.label.to_string(), + label: if palette { + candidate.label + } else { + candidate.command_label + } + .to_string(), detail: candidate.detail.to_string(), replacement: candidate.replacement.to_string(), action, @@ -2068,6 +2890,37 @@ fn file_query_for_command_input(input: &str) -> &str { input } +fn is_edit_command_input(input: &str) -> bool { + let input = input.trim(); + if input == "e" || input == "edit" { + return true; + } + + input + .split_once(' ') + .is_some_and(|(command, _)| matches!(command, "e" | "edit")) +} + +fn edit_replacement(input: &str, path: &str) -> String { + let command = input + .trim() + .split_once(' ') + .map(|(command, _)| command) + .filter(|command| matches!(*command, "e" | "edit")) + .unwrap_or("e"); + format!("{command} {path}") +} + +fn file_detail(notes_dir: &Path, entry: &crate::fs::tree::TreeEntry, relative: &str) -> String { + entry + .path + .parent() + .and_then(|parent| parent.strip_prefix(notes_dir).ok()) + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.display().to_string()) + .unwrap_or_else(|| relative.to_string()) +} + fn resolve_command_path_input(input: &str, notes_dir: &Path) -> Option { let input = input.trim(); if input.is_empty() || input.contains(' ') { @@ -2500,7 +3353,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 +3375,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.chars().count() - item_content.chars().count(), + content: item_content, + }); + } + } + if let Some(rest) = content .strip_prefix("- [ ]") .or_else(|| content.strip_prefix("- [x]")) @@ -2532,7 +3401,7 @@ fn parse_list_item(content: &str) -> Option> { { return Some(ListItem { kind: ListItemKind::Checkbox, - marker_len: content.len() - item_content.len(), + marker_len: content.chars().count() - item_content.chars().count(), content: item_content, }); } @@ -2541,17 +3410,17 @@ fn parse_list_item(content: &str) -> Option> { if let Some((number, item_content)) = parse_numbered_list(content) { return Some(ListItem { kind: ListItemKind::Numbered(number.parse().unwrap_or(1)), - marker_len: content.len() - item_content.len(), + marker_len: content.chars().count() - item_content.chars().count(), content: item_content, }); } - for marker in ['-', '*', '+'] { + for marker in ['•', '◦', '-', '*', '+'] { let prefix = [marker, ' '].iter().collect::(); if let Some(item_content) = content.strip_prefix(&prefix) { return Some(ListItem { kind: ListItemKind::Bullet(marker), - marker_len: prefix.len(), + marker_len: prefix.chars().count(), content: item_content, }); } @@ -2689,11 +3558,15 @@ mod tests { visual_line_anchor: None, preferred_column: None, preferred_visual_column: None, + surface_cursor: None, pending_g: false, pending_delete: false, pending_change: false, + pending_register_prefix: false, + pending_register: None, mouse_anchor: None, last_copied_selection: None, + wrap_cache: RefCell::new(WrapCache::default()), } } @@ -2791,6 +3664,44 @@ mod tests { assert_eq!(app.mode, Mode::Normal); } + #[test] + fn visual_mode_y_copies_selected_lines() { + let mut app = test_app("# Heading\n- [ ] todo"); + app.resize_viewport(5, 40); + + press(&mut app, KeyCode::Char('V')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Char('y')); + + assert_eq!( + app.last_copied_selection.as_deref(), + Some("# Heading\n- [ ] todo") + ); + assert_eq!(app.status_message, "Copied selection"); + assert_eq!(app.mode, Mode::Normal); + assert_eq!(app.visual_line_anchor, None); + } + + #[test] + fn visual_mode_clipboard_register_y_copies_selected_lines() { + let mut app = test_app("# Heading\n- [ ] todo"); + app.resize_viewport(5, 40); + + press(&mut app, KeyCode::Char('V')); + press(&mut app, KeyCode::Char('j')); + press(&mut app, KeyCode::Char('"')); + press(&mut app, KeyCode::Char('+')); + press(&mut app, KeyCode::Char('y')); + + assert_eq!( + app.last_copied_selection.as_deref(), + Some("# Heading\n- [ ] todo") + ); + assert_eq!(app.status_message, "Copied selection"); + assert_eq!(app.mode, Mode::Normal); + assert_eq!(app.visual_line_anchor, None); + } + #[test] fn normal_mode_a_enters_insert_at_line_end() { let mut app = test_app("abc"); @@ -2922,7 +3833,7 @@ mod tests { } #[test] - fn mouse_drag_selects_text_and_copies_immediately() { + fn mouse_drag_selects_text_and_copies_on_release() { let mut app = test_app("bravo"); app.resize_viewport(5, 20); @@ -2931,6 +3842,10 @@ mod tests { assert_eq!(app.cursor, Cursor { line: 0, column: 4 }); assert_eq!(app.selected_text().as_deref(), Some("rav")); + assert_eq!(app.last_copied_selection, None); + + release(&mut app, 6, 0); + assert_eq!(app.status_message, "Copied selection"); assert_eq!(app.last_copied_selection.as_deref(), Some("rav")); } @@ -2959,6 +3874,22 @@ mod tests { assert_eq!(app.mouse_anchor, None); } + #[test] + fn starting_new_mouse_selection_clears_previous_copy_state() { + let mut app = test_app("bravo"); + app.resize_viewport(5, 20); + + click(&mut app, 3, 0); + drag(&mut app, 6, 0); + release(&mut app, 6, 0); + assert_eq!(app.last_copied_selection.as_deref(), Some("rav")); + + click(&mut app, 1, 0); + + assert_eq!(app.text_selection, None); + assert_eq!(app.last_copied_selection, None); + } + #[test] fn temporary_status_messages_expire() { let mut app = test_app("text"); @@ -3195,16 +4126,15 @@ mod tests { } #[test] - fn primary_p_opens_command_sheet_and_esc_closes_it() { + fn primary_p_opens_file_picker_and_esc_closes_it() { let mut app = test_app("text"); app.status_message = "ready".to_string(); press_modified(&mut app, KeyCode::Char('p'), KeyModifiers::SUPER); assert_eq!(app.mode, Mode::CommandLine); - assert_eq!(app.sheet.prompt, CommandPrompt::Command); + assert_eq!(app.sheet.prompt, CommandPrompt::File); assert_eq!(app.command_line, ""); - assert!(!app.sheet.items.is_empty()); press(&mut app, KeyCode::Esc); @@ -3214,7 +4144,27 @@ mod tests { } #[test] - fn command_sheet_filters_matches_and_opens_selected_file() { + fn shifted_primary_p_opens_command_palette() { + let mut app = test_app("text"); + + press_modified( + &mut app, + KeyCode::Char('P'), + KeyModifiers::SUPER | KeyModifiers::SHIFT, + ); + + assert_eq!(app.mode, Mode::CommandLine); + assert_eq!(app.sheet.prompt, CommandPrompt::Palette); + assert!( + app.sheet + .items + .iter() + .any(|item| item.label == "Insert Table") + ); + } + + #[test] + fn file_picker_filters_matches_and_opens_selected_file() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![ @@ -3243,7 +4193,7 @@ mod tests { } #[test] - fn command_sheet_lists_files_before_commands() { + fn command_line_lists_files_only_after_edit_command() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![TreeEntry { @@ -3254,6 +4204,16 @@ mod tests { press(&mut app, KeyCode::Char(':')); + assert!( + app.sheet + .items + .iter() + .all(|item| item.kind == SheetItemKind::Command) + ); + + press(&mut app, KeyCode::Char('e')); + press(&mut app, KeyCode::Char(' ')); + assert_eq!(app.sheet.items[0].kind, SheetItemKind::File); assert_eq!(app.sheet.items[0].label, "CHANGELOG.md"); } @@ -3312,7 +4272,7 @@ mod tests { } #[test] - fn command_sheet_can_complete_selected_files_into_the_input() { + fn command_line_can_complete_selected_files_after_edit_command() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![TreeEntry { @@ -3322,12 +4282,14 @@ mod tests { }]; press(&mut app, KeyCode::Char(':')); + press(&mut app, KeyCode::Char('e')); + press(&mut app, KeyCode::Char(' ')); for ch in "gla".chars() { press(&mut app, KeyCode::Char(ch)); } press(&mut app, KeyCode::Right); - assert_eq!(app.command_line, "projects/glass.md"); + assert_eq!(app.command_line, "e projects/glass.md"); } #[test] @@ -3448,6 +4410,154 @@ 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 insert_mode_types_into_empty_table_cell() { + let mut app = test_app("| A | B |\n| --- | --- |\n| | y |"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 2, column: 6 }; + + press(&mut app, KeyCode::Char('x')); + + assert_eq!( + app.buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| x | y |" + ); + assert_eq!(app.cursor, Cursor { line: 2, column: 3 }); + } + + #[test] + fn cleared_table_cell_stays_editable() { + let mut app = test_app("| A | B |\n| --- | --- |\n| x | y |"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 2, column: 3 }; + + press(&mut app, KeyCode::Backspace); + assert_eq!( + app.buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| | y |" + ); + assert_eq!(app.cursor, Cursor { line: 2, column: 6 }); + + press(&mut app, KeyCode::Char('z')); + + assert_eq!( + app.buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| z | y |" + ); + assert_eq!(app.cursor, Cursor { line: 2, column: 3 }); + } + + #[test] + fn typing_at_table_row_edge_updates_nearest_cell() { + let mut app = test_app("| A | B |\n| --- | --- |\n| x | y |"); + app.mode = Mode::Insert; + app.cursor = Cursor { + line: 2, + column: app.buffer.line_len_chars(2), + }; + + press(&mut app, KeyCode::Char('z')); + + assert_eq!( + app.buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| x | yz |" + ); + assert!(!app.buffer.line(2).ends_with("|z")); + } + + #[test] + fn typing_pipe_inside_table_cell_escapes_it() { + let mut app = test_app("| A | B |\n| --- | --- |\n| x | y |"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 2, column: 3 }; + + press(&mut app, KeyCode::Char('|')); + + assert_eq!( + app.buffer.markdown_string(), + "| A | B |\n| --- | --- |\n| x\\| | y |" + ); + assert_eq!(app.buffer.line_count(), 3); + } + + #[test] + fn typing_on_table_delimiter_does_not_edit_raw_source() { + let mut app = test_app("| A | B |\n| --- | --- |\n| x | y |"); + let original = app.buffer.markdown_string(); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 1, column: 2 }; + + press(&mut app, KeyCode::Char('z')); + press(&mut app, KeyCode::Backspace); + press(&mut app, KeyCode::Delete); + + assert_eq!(app.buffer.markdown_string(), original); + assert_eq!(app.buffer.line_count(), 3); + } + + #[test] + fn empty_table_cells_have_visual_cursor_targets() { + let mut app = test_app("| A | B |\n| --- | --- |\n| | |"); + app.resize_viewport(5, 40); + let width = app.wrap_width(); + + assert!(app.table_visual_position(2, 6, width).is_some()); + assert!(app.table_visual_position(2, 12, width).is_some()); + } + #[test] fn normal_mode_u_undoes_last_insert() { let mut app = test_app(""); @@ -3468,10 +4578,39 @@ 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] + fn normal_mode_can_edit_revealed_heading_marker() { + let mut app = test_app("## Heading"); + + press(&mut app, KeyCode::Left); + press(&mut app, KeyCode::Left); + press(&mut app, KeyCode::Delete); + + assert_eq!(app.buffer.as_string(), "Heading"); + assert_eq!(app.buffer.markdown_string(), "# Heading"); + assert_eq!(app.cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn insert_mode_can_edit_revealed_link_target() { + let mut app = test_app("[README](README.md)"); + app.mode = Mode::Insert; + + for _ in 0..8 { + press(&mut app, KeyCode::Right); + } + press(&mut app, KeyCode::Char('X')); + + assert_eq!(app.buffer.as_string(), "README"); + assert_eq!(app.buffer.markdown_string(), "[README](XREADME.md)"); } #[test] @@ -3538,11 +4677,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 +4708,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,10 +4729,92 @@ 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 }); } + #[test] + fn enter_exits_empty_rendered_bullet_item() { + assert_eq!( + list_continuation_after_enter("• ", 2), + ListContinuation::EndList { + delete_to_column: 2 + } + ); + } + + #[test] + fn double_enter_exits_bullet_list_at_document_end() { + let mut app = test_app("- todo"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 0, column: 6 }; + + press(&mut app, KeyCode::Enter); + assert_eq!(app.buffer.as_string(), "• todo\n• "); + assert_eq!(app.cursor, Cursor { line: 1, column: 2 }); + + press(&mut app, KeyCode::Enter); + + assert_eq!(app.buffer.as_string(), "• todo\n\n"); + assert_eq!(app.buffer.markdown_string(), "- todo\n\n"); + assert_eq!(app.cursor, Cursor { line: 1, column: 0 }); + } + + #[test] + fn backspace_at_empty_checklist_marker_clears_line() { + let mut app = test_app("- [ ] "); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 0, column: 4 }; + + press(&mut app, KeyCode::Backspace); + + assert_eq!(app.buffer.as_string(), ""); + assert_eq!(app.buffer.markdown_string(), ""); + assert_eq!(app.cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn backspacing_through_checklist_content_clears_marker_without_artifacts() { + let mut app = test_app("- [ ] my"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 0, column: 6 }; + + press(&mut app, KeyCode::Backspace); + press(&mut app, KeyCode::Backspace); + press(&mut app, KeyCode::Backspace); + + assert_eq!(app.buffer.as_string(), ""); + assert_eq!(app.buffer.markdown_string(), ""); + assert_eq!(app.cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn backspace_at_checklist_content_start_converts_to_paragraph() { + let mut app = test_app("- [ ] my name"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 0, column: 4 }; + + press(&mut app, KeyCode::Backspace); + + assert_eq!(app.buffer.as_string(), "my name"); + assert_eq!(app.buffer.markdown_string(), "my name"); + assert_eq!(app.cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn delete_at_checklist_content_start_deletes_content_not_marker() { + let mut app = test_app("- [ ] my"); + app.mode = Mode::Insert; + app.cursor = Cursor { line: 0, column: 4 }; + + press(&mut app, KeyCode::Delete); + + assert_eq!(app.buffer.as_string(), "[ ] y"); + assert_eq!(app.buffer.markdown_string(), "- [ ] y"); + assert_eq!(app.cursor, Cursor { line: 0, column: 4 }); + } + #[test] fn checkbox_marker_requires_separator_or_end() { assert_eq!( diff --git a/src/debug_render.rs b/src/debug_render.rs index 95997bb..6728822 100644 --- a/src/debug_render.rs +++ b/src/debug_render.rs @@ -8,12 +8,7 @@ use ratatui::{ style::{Color, Modifier}, }; -use crate::{ - app::App, - editor::render::{visible_rows, wrap_line}, - markdown::{highlight::concealed_wrap_line, table::TableLayout}, - ui, -}; +use crate::{app::App, editor::render::visible_rows, markdown::table::TableLayout, ui}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CellStyle { @@ -65,13 +60,14 @@ fn full_render_height(app: &App, width: u16) -> u16 { usize::MAX, 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) { - table_layout.wrap_line(line_num, text, w) - } else { - concealed_wrap_line(text, w) - } + ui::editor::surface_wrap_line( + &app.buffer, + &table_layout, + Some((app.cursor.line, app.cursor.column)), + line_num, + text, + w, + ) }, ); rows.len().saturating_add(1).min(u16::MAX as usize) as u16 diff --git a/src/document/markdown.rs b/src/document/markdown.rs new file mode 100644 index 0000000..36f7d25 --- /dev/null +++ b/src/document/markdown.rs @@ -0,0 +1,851 @@ +use crate::{ + document::model::{ + Block, Document, Inline, ListMarker, TableAlignment, TableCell, TableRow, inline_plain_text, + }, + 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 trimmed.starts_with('>') { + let level = trimmed.chars().take_while(|ch| *ch == '>').count() as u8; + let rest = trimmed[level as usize..].trim_start(); + if !rest.is_empty() { + return Block::Quote { + 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("◦ ")) + && let Some((checked, content)) = parse_facade_checkbox(rest) + { + return Block::ChecklistItem { + indent: leading, + checked, + content: parse_inlines(content), + }; + } + + 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_facade_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) +} + +pub(crate) fn inline_plain_column_for_source_column(source: &str, source_column: usize) -> usize { + let source_column = source_column.min(source.chars().count()); + let parsed_links = links(source); + let mut index = 0usize; + let mut plain_column = 0usize; + + for link in parsed_links { + if link.source_start > index { + let segment = slice_chars(source, index, link.source_start); + if source_column <= link.source_start { + return plain_column + + styled_plain_column_for_source_column( + &segment, + source_column.saturating_sub(index), + ); + } + plain_column += inline_plain_text(&parse_styled_text(&segment)) + .chars() + .count(); + } + + if source_column < link.source_end { + return match link.kind { + LinkKind::Markdown => { + let label_start = link.label_start.unwrap_or(link.source_start); + let label_end = link.label_end.unwrap_or(label_start); + let label = link.label.as_deref().unwrap_or(&link.target); + if source_column <= label_start { + plain_column + } else if source_column <= label_end { + plain_column + + styled_plain_column_for_source_column( + label, + source_column.saturating_sub(label_start), + ) + } else { + plain_column + inline_plain_text(&parse_styled_text(label)).chars().count() + } + } + LinkKind::Wiki => { + plain_column + + source_column + .saturating_sub(link.target_start) + .min(link.target.chars().count()) + } + LinkKind::Url => { + plain_column + + source_column + .saturating_sub(link.target_start) + .min(link.target.chars().count()) + } + }; + } + + plain_column += match link.kind { + LinkKind::Markdown => { + let label = link.label.as_deref().unwrap_or(&link.target); + inline_plain_text(&parse_styled_text(label)).chars().count() + } + LinkKind::Wiki | LinkKind::Url => link.target.chars().count(), + }; + index = link.source_end; + } + + if index < source.chars().count() { + let segment = slice_chars(source, index, source.chars().count()); + plain_column + + styled_plain_column_for_source_column(&segment, source_column.saturating_sub(index)) + } else { + plain_column + } +} + +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, "**") + && end > index + 2 + { + result.push(Inline::Strong(parse_styled_text( + &chars[index + 2..end].iter().collect::(), + ))); + index = end + 2; + continue; + } + + if (chars[index] == '*' || chars[index] == '_') + && chars.get(index + 1) != Some(&chars[index]) + && index + .checked_sub(1) + .and_then(|previous| chars.get(previous)) + != Some(&chars[index]) + && let Some(end) = find_next(&chars, index + 1, chars[index]) + && end > index + 1 + { + 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 styled_plain_column_for_source_column(source: &str, source_column: usize) -> usize { + let chars = source.chars().collect::>(); + let source_column = source_column.min(chars.len()); + let mut index = 0usize; + let mut plain_column = 0usize; + + while index < chars.len() { + if chars[index] == '`' + && let Some(end) = find_next(&chars, index + 1, '`') + { + if source_column <= index { + return plain_column; + } + let content_len = end.saturating_sub(index + 1); + if source_column <= end { + return plain_column + source_column.saturating_sub(index + 1).min(content_len); + } + if source_column <= end + 1 { + return plain_column + content_len; + } + plain_column += content_len; + index = end + 1; + continue; + } + + if starts_with(&chars, index, "**") + && let Some(end) = find_token(&chars, index + 2, "**") + && end > index + 2 + { + if source_column <= index + 2 { + return plain_column; + } + let content = chars[index + 2..end].iter().collect::(); + let content_plain_len = inline_plain_text(&parse_styled_text(&content)) + .chars() + .count(); + if source_column <= end { + return plain_column + + styled_plain_column_for_source_column( + &content, + source_column.saturating_sub(index + 2), + ); + } + if source_column <= end + 2 { + return plain_column + content_plain_len; + } + plain_column += content_plain_len; + index = end + 2; + continue; + } + + if (chars[index] == '*' || chars[index] == '_') + && chars.get(index + 1) != Some(&chars[index]) + && index + .checked_sub(1) + .and_then(|previous| chars.get(previous)) + != Some(&chars[index]) + && let Some(end) = find_next(&chars, index + 1, chars[index]) + && end > index + 1 + { + if source_column <= index + 1 { + return plain_column; + } + let content = chars[index + 1..end].iter().collect::(); + let content_plain_len = inline_plain_text(&parse_styled_text(&content)) + .chars() + .count(); + if source_column <= end { + return plain_column + + styled_plain_column_for_source_column( + &content, + source_column.saturating_sub(index + 1), + ); + } + if source_column <= end + 1 { + return plain_column + content_plain_len; + } + plain_column += content_plain_len; + index = end + 1; + continue; + } + + let next = next_special(&chars, index + 1).unwrap_or(chars.len()); + if source_column <= next { + return plain_column + source_column.saturating_sub(index); + } + plain_column += next.saturating_sub(index); + index = next; + } + + plain_column +} + +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 parses_checkbox_shortcut_after_facade_bullet_marker() { + let document = MarkdownCodec::parse_plain("• [ ] todo"); + + assert_eq!(document.plain_text(), "[ ] todo"); + assert_eq!(MarkdownCodec::serialize(&document), "- [ ] todo"); + } + + #[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)"); + } + + #[test] + fn inline_plain_column_maps_source_syntax_to_plain_text() { + assert_eq!(inline_plain_column_for_source_column("**bold**", 8), 4); + assert_eq!( + inline_plain_column_for_source_column("a **bold** tail", 10), + 6 + ); + assert_eq!( + inline_plain_column_for_source_column("[README](guide.md)", 17), + 6 + ); + } +} diff --git a/src/document/mod.rs b/src/document/mod.rs new file mode 100644 index 0000000..9e1fab1 --- /dev/null +++ b/src/document/mod.rs @@ -0,0 +1,7 @@ +pub mod markdown; +pub mod model; +pub mod surface; + +pub use markdown::MarkdownCodec; +pub use model::{Block, DocLink, DocRange, Document, Inline, TableAlignment, TableCell, TableRow}; +pub use surface::{SurfaceLine, SurfaceMode}; diff --git a/src/document/model.rs b/src/document/model.rs new file mode 100644 index 0000000..6508597 --- /dev/null +++ b/src/document/model.rs @@ -0,0 +1,507 @@ +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('\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}."), + } + } + + pub(crate) 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(table_cell_markdown(cell).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(table_cell_markdown) + .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_cell_markdown(cell: &TableCell) -> String { + escape_table_pipes(&inline_markdown(&cell.content)) +} + +fn escape_table_pipes(value: &str) -> String { + let mut escaped = String::new(); + let mut backslashes = 0usize; + for ch in value.chars() { + if ch == '|' && backslashes % 2 == 0 { + escaped.push('\\'); + } + escaped.push(ch); + if ch == '\\' { + backslashes += 1; + } else { + backslashes = 0; + } + } + escaped +} + +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/document/surface.rs b/src/document/surface.rs new file mode 100644 index 0000000..89bf9bd --- /dev/null +++ b/src/document/surface.rs @@ -0,0 +1,498 @@ +use crate::document::model::{Block, Inline, inline_plain_text}; +use crate::editor::render::word_wrap_segments; +use crate::markdown::highlight::concealed_wrap_line; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceMode { + Inactive, + Active { cursor_column: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceLine { + pub text: String, + pub source_map: Vec>, + source_to_display: Vec, + pub marker_width: usize, + pub editable: bool, + revealed: bool, +} + +impl SurfaceLine { + pub fn plain(source: &str) -> Self { + let mut builder = SurfaceBuilder::new(source.chars().count(), 0, true); + builder.push_mapped(source, 0); + builder.finish() + } + + pub fn for_block(block: &Block, source: &str, mode: SurfaceMode) -> Self { + let source_len = source.chars().count(); + let active_cursor = match mode { + SurfaceMode::Inactive => None, + SurfaceMode::Active { cursor_column } => Some(cursor_column), + }; + + match (block, active_cursor) { + (Block::Heading { level, content }, Some(_)) => { + let marker = format!("{} ", "#".repeat(*level as usize)); + let mut builder = SurfaceBuilder::new(source_len, 0, true); + builder.push_virtual(&marker); + builder.push_inlines(content, 0, Some(usize::MAX), true); + builder.finish() + } + (Block::Quote { level, content }, Some(cursor)) => { + let marker = format!("{} ", ">".repeat(*level as usize)); + let marker_len = marker.chars().count(); + let mut builder = SurfaceBuilder::new(source_len, marker_len, true); + builder.revealed = true; + builder.push_mapped(&marker, 0); + builder.push_inlines(content, marker_len, cursor.checked_sub(marker_len), false); + builder.finish() + } + ( + Block::ListItem { + indent, + marker, + content, + }, + Some(cursor), + ) => { + let marker_text = + format!("{}{} ", " ".repeat(*indent), marker.plain_marker(*indent)); + let marker_len = marker_text.chars().count(); + let mut builder = SurfaceBuilder::new(source_len, marker_len, true); + builder.push_mapped(&marker_text, 0); + builder.push_inlines(content, marker_len, cursor.checked_sub(marker_len), false); + builder.finish() + } + ( + Block::ChecklistItem { + indent, + checked, + content, + }, + Some(cursor), + ) => { + let marker_text = format!( + "{}{} ", + " ".repeat(*indent), + if *checked { "[x]" } else { "[ ]" } + ); + let marker_len = marker_text.chars().count(); + let mut builder = SurfaceBuilder::new(source_len, marker_len, true); + builder.push_mapped(&marker_text, 0); + builder.push_inlines(content, marker_len, cursor.checked_sub(marker_len), false); + builder.finish() + } + (Block::Paragraph(content), Some(cursor)) => { + let mut builder = SurfaceBuilder::new(source_len, 0, true); + builder.push_inlines(content, 0, Some(cursor), false); + builder.finish() + } + (Block::CodeFence { .. }, Some(_)) | (Block::RawMarkdown(_), Some(_)) => { + Self::plain(source) + } + _ => { + let marker_width = match block { + Block::ListItem { indent, marker, .. } => { + indent + marker.plain_marker(*indent).chars().count() + 1 + } + Block::ChecklistItem { indent, .. } => indent + 4, + _ => 0, + }; + let mut builder = SurfaceBuilder::new(source_len, marker_width, false); + builder.push_mapped(source, 0); + builder.finish() + } + } + } + + pub fn display_len(&self) -> usize { + self.text.chars().count() + } + + pub fn has_virtual_chars(&self) -> bool { + self.source_map.iter().any(Option::is_none) + } + + pub fn has_revealed_syntax(&self) -> bool { + self.revealed || self.has_virtual_chars() + } + + pub fn display_column_for_source_column(&self, source_column: usize) -> usize { + self.source_to_display + .get(source_column) + .copied() + .unwrap_or_else(|| self.text.chars().count()) + } + + pub fn source_column_for_display_column(&self, display_column: usize) -> usize { + let display_len = self.text.chars().count(); + let source_len = self.source_to_display.len().saturating_sub(1); + if display_column >= display_len { + return source_len; + } + + if let Some(Some(source)) = self.source_map.get(display_column) { + return *source; + } + + if let Some(source) = self + .source_map + .iter() + .skip(display_column) + .flatten() + .copied() + .next() + { + return source; + } + + self.source_map + .iter() + .take(display_column.saturating_add(1)) + .rev() + .flatten() + .copied() + .next() + .unwrap_or(source_len) + } + + pub fn wrap_source_segments(&self, width: usize) -> (Vec<(usize, usize)>, usize) { + let display_segments = self.wrap_display_segments(width); + let source_len = self.source_to_display.len().saturating_sub(1); + let segments = display_segments + .into_iter() + .map(|(start, end)| { + let source_start = self.source_column_for_display_boundary(start); + let source_end = self + .source_column_for_display_boundary(end) + .max(source_start); + (source_start.min(source_len), source_end.min(source_len)) + }) + .collect::>(); + (segments, self.marker_width) + } + + pub fn wrap_display_segments(&self, width: usize) -> Vec<(usize, usize)> { + let width = width.max(1); + if self.marker_width == 0 || self.marker_width >= width { + return word_wrap_segments(&self.text, width); + } + + let display_len = self.display_len(); + let content = char_slice(&self.text, self.marker_width, display_len); + if content.is_empty() { + return vec![(0, display_len)]; + } + + let content_width = width - self.marker_width; + let content_segments = word_wrap_segments(&content, content_width); + let mut segments = Vec::new(); + for (index, (start, end)) in content_segments.into_iter().enumerate() { + if index == 0 { + segments.push((0, self.marker_width + end)); + } else { + segments.push((self.marker_width + start, self.marker_width + end)); + } + } + segments + } + + pub fn source_map_for_display_range(&self, start: usize, end: usize) -> Vec> { + self.source_map + .iter() + .skip(start) + .take(end.saturating_sub(start)) + .copied() + .collect() + } + + fn source_column_for_display_boundary(&self, boundary: usize) -> usize { + if boundary == 0 { + return self.source_column_for_display_column(0); + } + if boundary >= self.text.chars().count() { + return self.source_to_display.len().saturating_sub(1); + } + + let left = self + .source_map + .get(boundary.saturating_sub(1)) + .copied() + .flatten(); + let right = self.source_map.get(boundary).copied().flatten(); + match (left, right) { + (Some(left), Some(right)) if right > left => right, + (Some(left), _) => left.saturating_add(1), + (_, Some(right)) => right, + _ => self.source_column_for_display_column(boundary), + } + } +} + +pub fn wrap_surface_or_facade_line( + block: Option<&Block>, + source: &str, + width: usize, + mode: SurfaceMode, +) -> (Vec<(usize, usize)>, usize) { + match (block, mode) { + (Some(block), SurfaceMode::Active { .. }) => { + let surface = SurfaceLine::for_block(block, source, mode); + if surface.has_revealed_syntax() { + surface.wrap_source_segments(width) + } else { + concealed_wrap_line(source, width) + } + } + _ => concealed_wrap_line(source, width), + } +} + +struct SurfaceBuilder { + text: String, + source_map: Vec>, + source_len: usize, + marker_width: usize, + editable: bool, + revealed: bool, +} + +impl SurfaceBuilder { + fn new(source_len: usize, marker_width: usize, editable: bool) -> Self { + Self { + text: String::new(), + source_map: Vec::new(), + source_len, + marker_width, + editable, + revealed: false, + } + } + + fn finish(self) -> SurfaceLine { + let mut source_to_display = vec![usize::MAX; self.source_len.saturating_add(1)]; + for (display_index, source) in self.source_map.iter().copied().enumerate() { + if let Some(source) = source + && let Some(slot) = source_to_display.get_mut(source) + && *slot == usize::MAX + { + *slot = display_index; + } + } + + let display_len = self.text.chars().count(); + source_to_display[self.source_len] = display_len; + let mut next = display_len; + for index in (0..source_to_display.len()).rev() { + if source_to_display[index] == usize::MAX { + source_to_display[index] = next; + } else { + next = source_to_display[index]; + } + } + + SurfaceLine { + text: self.text, + source_map: self.source_map, + source_to_display, + marker_width: self.marker_width, + editable: self.editable, + revealed: self.revealed, + } + } + + fn push_virtual(&mut self, text: &str) { + self.revealed = true; + for ch in text.chars() { + self.text.push(ch); + self.source_map.push(None); + } + } + + fn push_mapped(&mut self, text: &str, source_start: usize) { + for (offset, ch) in text.chars().enumerate() { + self.text.push(ch); + self.source_map.push(Some(source_start + offset)); + } + } + + fn push_inlines( + &mut self, + inlines: &[Inline], + source_start: usize, + cursor_column: Option, + reveal_all: bool, + ) { + let mut source_cursor = source_start; + for inline in inlines { + let plain = inline_plain_text(std::slice::from_ref(inline)); + let plain_len = plain.chars().count(); + let local_cursor = cursor_column.and_then(|cursor| { + let inline_start = source_cursor.saturating_sub(source_start); + (cursor >= inline_start && cursor <= inline_start + plain_len) + .then(|| cursor - inline_start) + }); + let reveal = reveal_all || local_cursor.is_some(); + self.push_inline(inline, source_cursor, local_cursor, reveal); + source_cursor += plain_len; + } + } + + fn push_inline( + &mut self, + inline: &Inline, + source_start: usize, + cursor_column: Option, + reveal: bool, + ) { + match inline { + Inline::Text(text) => self.push_mapped(text, source_start), + Inline::BareUrl(text) => { + if reveal { + self.revealed = true; + } + self.push_mapped(text, source_start); + } + Inline::Code(code) if reveal => { + self.push_virtual("`"); + self.push_mapped(code, source_start); + self.push_virtual("`"); + } + Inline::Code(code) => self.push_mapped(code, source_start), + Inline::Strong(children) if reveal => { + self.push_virtual("**"); + self.push_inlines(children, source_start, cursor_column, true); + self.push_virtual("**"); + } + Inline::Strong(children) => { + self.push_inlines(children, source_start, cursor_column, false) + } + Inline::Emphasis(children) if reveal => { + self.push_virtual("*"); + self.push_inlines(children, source_start, cursor_column, true); + self.push_virtual("*"); + } + Inline::Emphasis(children) => { + self.push_inlines(children, source_start, cursor_column, false) + } + Inline::Link { + label, + target, + kind, + } if reveal => match kind { + crate::markdown::inline::LinkKind::Markdown => { + self.push_virtual("["); + self.push_inlines(label, source_start, cursor_column, true); + self.push_virtual("]("); + self.push_virtual(target); + self.push_virtual(")"); + } + crate::markdown::inline::LinkKind::Wiki => { + self.push_virtual("[["); + self.push_inlines(label, source_start, cursor_column, true); + self.push_virtual("]]"); + } + crate::markdown::inline::LinkKind::Url => self.push_mapped(target, source_start), + }, + Inline::Link { + label, + target, + kind, + } => { + if label.is_empty() && !matches!(kind, crate::markdown::inline::LinkKind::Wiki) { + self.push_mapped(target, source_start); + } else { + self.push_inlines(label, source_start, cursor_column, false); + } + } + } + } +} + +fn char_slice(text: &str, start: usize, end: usize) -> String { + text.chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::MarkdownCodec; + + #[test] + fn active_heading_reveals_marker() { + let document = MarkdownCodec::parse("# Heading"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = + SurfaceLine::for_block(block, "Heading", SurfaceMode::Active { cursor_column: 0 }); + + assert_eq!(surface.text, "# Heading"); + assert_eq!(surface.display_column_for_source_column(0), 2); + } + + #[test] + fn active_link_reveals_markdown_when_cursor_is_inside_label() { + let document = MarkdownCodec::parse("[README](README.md)"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = + SurfaceLine::for_block(block, "README", SurfaceMode::Active { cursor_column: 2 }); + + assert_eq!(surface.text, "[README](README.md)"); + assert_eq!(surface.source_column_for_display_column(1), 0); + } + + #[test] + fn active_checklist_keeps_facade_marker() { + let document = MarkdownCodec::parse("- [ ] task"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = + SurfaceLine::for_block(block, "[ ] task", SurfaceMode::Active { cursor_column: 0 }); + + assert_eq!(surface.text, "[ ] task"); + assert!(!surface.text.starts_with("- [ ]")); + } + + #[test] + fn inactive_text_stays_plain() { + let document = MarkdownCodec::parse("# Heading"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = SurfaceLine::for_block(block, "Heading", SurfaceMode::Inactive); + + assert_eq!(surface.text, "Heading"); + } + + #[test] + fn wrap_segments_map_back_to_source() { + let document = MarkdownCodec::parse("# Heading with many words"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = SurfaceLine::for_block( + block, + "Heading with many words", + SurfaceMode::Active { cursor_column: 0 }, + ); + let (segments, _) = surface.wrap_source_segments(10); + + assert!(segments.len() > 1); + assert_eq!(segments[0].0, 0); + } + + #[test] + fn active_list_wraps_with_marker_indent() { + let document = MarkdownCodec::parse("- one two three four"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = SurfaceLine::for_block( + block, + "• one two three four", + SurfaceMode::Active { cursor_column: 4 }, + ); + let (segments, marker_width) = surface.wrap_source_segments(10); + + assert_eq!(marker_width, 2); + assert!(segments.len() > 1); + assert!(segments[1].0 >= marker_width); + } +} diff --git a/src/editor/buffer.rs b/src/editor/buffer.rs index f91dd07..93d7ef3 100644 --- a/src/editor/buffer.rs +++ b/src/editor/buffer.rs @@ -3,31 +3,48 @@ 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, SurfaceLine, SurfaceMode, + TableAlignment, TableCell, TableRow, + markdown::{inline_plain_column_for_source_column, parse_inlines}, + model::{inline_markdown, inline_plain_text}, + }, + 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 +52,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 +71,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 +85,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 +122,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 +129,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 +138,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 +169,6 @@ impl DocumentBuffer { None }; self.text.remove(previous..end); - self.update_dirty(); if cursor.column > 0 { cursor.column -= 1; @@ -147,6 +176,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 +187,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 +218,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 +248,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 +299,409 @@ 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 surface_line(&self, line: usize, mode: SurfaceMode) -> SurfaceLine { + let source = self.line(line); + let source = source.trim_end_matches(['\r', '\n']); + self.block_for_line(line) + .map(|block| SurfaceLine::for_block(block, source, mode)) + .unwrap_or_else(|| SurfaceLine::plain(source)) + } + + pub fn replace_line_from_surface( + &mut self, + line: usize, + surface_text: &str, + surface_column: usize, + cursor: &mut Cursor, + ) -> usize { + let line = line.min(self.line_count().saturating_sub(1)); + let block_index = self.block_index_for_line(line); + let parsed = MarkdownCodec::parse_plain(surface_text); + let replacement = parsed.blocks.into_iter().next().unwrap_or(Block::Blank); + + self.push_undo_snapshot(*cursor); + if let Some(block) = self.document.blocks.get_mut(block_index) { + *block = replacement; + } else { + self.document.blocks.push(replacement); + } + + self.text = Rope::from_str(&self.document.plain_text()); + cursor.line = line.min(self.line_count().saturating_sub(1)); + let active_surface = self.surface_line( + cursor.line, + SurfaceMode::Active { + cursor_column: self.line_len_chars(cursor.line), + }, + ); + let display_column = surface_column.min(active_surface.display_len()); + cursor.column = active_surface + .source_column_for_display_column(display_column) + .min(self.line_len_chars(cursor.line)); + self.update_dirty(); + display_column + } + + 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 delete_structural_list_marker_at_cursor( + &mut self, + cursor: &mut Cursor, + include_content_boundary: bool, + ) -> bool { + let line = cursor.line.min(self.line_count().saturating_sub(1)); + let block_index = self.block_index_for_line(line); + let Some(block) = self.document.blocks.get(block_index) else { + return false; + }; + + let Some((marker_width, content)) = structural_list_marker(block) else { + return false; + }; + let content_is_empty = inline_plain_text(&content).is_empty(); + let cursor_is_in_marker = cursor.column < marker_width + || (cursor.column == marker_width && (include_content_boundary || content_is_empty)); + if !cursor_is_in_marker { + return false; + } + + self.push_undo_snapshot(*cursor); + let replacement = if content_is_empty { + Block::Blank + } else { + Block::Paragraph(content) + }; + if let Some(block) = self.document.blocks.get_mut(block_index) { + *block = replacement; + } + + self.text = Rope::from_str(&self.document.plain_text()); + cursor.line = line.min(self.line_count().saturating_sub(1)); + cursor.column = 0; + self.update_dirty(); + true + } + + 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 insert_table_char(&mut self, cursor: &mut Cursor, ch: char) -> bool { + if ch == '\n' { + return false; + } + let Some(location) = self.table_edit_location_at_cursor(*cursor) else { + return self.cursor_is_inside_table_block(*cursor); + }; + + let Some(mut text) = self.table_cell_markdown(location) else { + return self.cursor_is_inside_table_block(*cursor); + }; + self.push_undo_snapshot(*cursor); + insert_char_at_column(&mut text, location.cell_column, ch); + let Some(cell) = self.table_cell_mut(location) else { + return self.cursor_is_inside_table_block(*cursor); + }; + cell.content = parse_inlines(&text); + + self.rebuild_facade_preserving_cursor(cursor); + *cursor = self.cursor_for_table_cell(location, location.cell_column + 1); + self.update_dirty(); + true + } + + pub fn delete_table_char(&mut self, cursor: &mut Cursor, backspace: bool) -> bool { + let Some(location) = self.table_edit_location_at_cursor(*cursor) else { + return self.cursor_is_inside_table_block(*cursor); + }; + + let Some(cell_text) = self.table_cell_markdown(location) else { + return self.cursor_is_inside_table_block(*cursor); + }; + let text_len = cell_text.chars().count(); + let delete_column = if backspace { + let Some(column) = location.cell_column.checked_sub(1) else { + return true; + }; + column + } else if location.cell_column < text_len { + location.cell_column + } else { + return true; + }; + + self.push_undo_snapshot(*cursor); + let mut text = cell_text; + remove_char_at_column(&mut text, delete_column); + let Some(cell) = self.table_cell_mut(location) else { + return self.cursor_is_inside_table_block(*cursor); + }; + cell.content = parse_inlines(&text); + + self.rebuild_facade_preserving_cursor(cursor); + *cursor = self.cursor_for_table_cell(location, delete_column); + self.update_dirty(); + 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 +716,200 @@ 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 source_line = self + .line(cursor.line) + .trim_end_matches(['\r', '\n']) + .to_string(); + let source_column = cursor.column; + let parsed = MarkdownCodec::parse_plain(&self.text.to_string()); + self.document = reconcile_facade_document(&old_document, parsed); + self.rebuild_facade_after_parse(cursor, &source_line, source_column); + self.update_dirty(); + } + + fn rebuild_facade_after_parse( + &mut self, + cursor: &mut Cursor, + source_line: &str, + source_column: usize, + ) { + let line = cursor.line; + self.text = Rope::from_str(&self.document.plain_text()); + cursor.line = line.min(self.text.len_lines().saturating_sub(1)); + cursor.column = self + .document + .block_for_plain_line(cursor.line) + .map(|block| remap_facade_cursor_after_parse(source_line, source_column, block)) + .unwrap_or(source_column) + .min(self.line_len_chars(cursor.line)); + } + + 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 table_edit_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; + if local_line == 1 { + return None; + } + let row_index = + table_row_for_source_line(local_line).min(rows.len().saturating_sub(1)); + let ranges = table_cell_ranges(&self.line(cursor.line)); + let cell_count = rows + .get(row_index) + .map(|row| row.cells.len()) + .unwrap_or_default() + .max(ranges.len()); + if cell_count == 0 { + return None; + } + let column_index = + table_column_for_source_column(&ranges, cursor.column, cell_count); + let (cell_start, cell_end) = + ranges.get(column_index).copied().unwrap_or_else(|| { + let line_len = self.line_len_chars(cursor.line); + (line_len, line_len) + }); + let cell_column = cursor + .column + .saturating_sub(cell_start) + .min(cell_end.saturating_sub(cell_start)); + return Some(TableEditLocation { + block_index, + block_start: plain_line, + row_index, + column_index, + cell_column, + }); + } + plain_line = next; + } + None + } + + fn cursor_is_inside_table_block(&self, cursor: Cursor) -> bool { + let mut plain_line = 0usize; + for block in &self.document.blocks { + let line_count = block.plain_line_count(); + let next = plain_line + line_count; + if cursor.line >= plain_line && cursor.line < next { + return matches!(block, Block::Table { .. }); + } + plain_line = next; + } + false + } + + fn table_cell_markdown(&self, location: TableEditLocation) -> Option { + let Block::Table { rows, .. } = self.document.blocks.get(location.block_index)? else { + return None; + }; + let cell = rows + .get(location.row_index)? + .cells + .get(location.column_index)?; + Some(inline_markdown(&cell.content)) + } + + fn table_cell_mut(&mut self, location: TableEditLocation) -> Option<&mut TableCell> { + let Block::Table { rows, .. } = self.document.blocks.get_mut(location.block_index)? else { + return None; + }; + let column_count = rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(location.column_index + 1) + .max(location.column_index + 1); + let row = rows.get_mut(location.row_index)?; + while row.cells.len() < column_count { + row.cells.push(empty_table_cell()); + } + row.cells.get_mut(location.column_index) + } + + fn cursor_for_table_cell(&self, location: TableEditLocation, cell_column: usize) -> Cursor { + let line = (location.block_start + table_source_line_for_row(location.row_index)) + .min(self.line_count().saturating_sub(1)); + let column = table_cell_ranges(&self.line(line)) + .get(location.column_index) + .map(|(start, end)| start + cell_column.min(end.saturating_sub(*start))) + .unwrap_or_else(|| self.line_len_chars(line)); + Cursor { line, column } } fn visible_len_lines(&self) -> usize { @@ -315,10 +927,358 @@ impl DocumentBuffer { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TableCursorLocation { + block_index: usize, + block_start: usize, + row_index: usize, + column_index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TableEditLocation { + block_index: usize, + block_start: usize, + row_index: usize, + column_index: usize, + cell_column: 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 table_column_for_source_column( + ranges: &[(usize, usize)], + column: usize, + cell_count: usize, +) -> usize { + ranges + .iter() + .position(|(start, end)| column >= *start && column <= *end) + .or_else(|| ranges.iter().position(|(start, _)| column < *start)) + .unwrap_or_else(|| ranges.len().saturating_sub(1)) + .min(cell_count.saturating_sub(1)) +} + +fn insert_char_at_column(text: &mut String, column: usize, ch: char) { + let byte_index = byte_index_for_char_column(text, column); + text.insert(byte_index, ch); +} + +fn remove_char_at_column(text: &mut String, column: usize) { + let start = byte_index_for_char_column(text, column); + let end = byte_index_for_char_column(text, column + 1); + if start < end { + text.replace_range(start..end, ""); + } +} + +fn byte_index_for_char_column(text: &str, column: usize) -> usize { + text.char_indices() + .nth(column) + .map(|(index, _)| index) + .unwrap_or(text.len()) +} + +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 structural_list_marker(block: &Block) -> Option<(usize, Vec)> { + match block { + Block::ListItem { + indent, + marker, + content, + } => Some(( + *indent + marker.plain_marker(*indent).chars().count() + 1, + content.clone(), + )), + Block::ChecklistItem { + indent, content, .. + } => Some((*indent + 4, content.clone())), + _ => None, + } +} + +fn remap_facade_cursor_after_parse( + source_line: &str, + source_column: usize, + block: &Block, +) -> usize { + match block { + Block::Heading { level, .. } => { + let Some(source_content_start) = heading_content_start(source_line, *level) else { + return inline_plain_column_for_source_column(source_line, source_column); + }; + if source_column <= source_content_start { + return 0; + } + inline_plain_column_for_source_column( + char_slice_from(source_line, source_content_start), + source_column.saturating_sub(source_content_start), + ) + } + Block::Quote { level, .. } => { + let Some((source_content_start, output_content_start)) = + quote_content_columns(source_line, *level) + else { + return inline_plain_column_for_source_column(source_line, source_column); + }; + if source_column <= source_content_start { + return source_column.min(output_content_start); + } + output_content_start + + inline_plain_column_for_source_column( + char_slice_from(source_line, source_content_start), + source_column.saturating_sub(source_content_start), + ) + } + Block::ListItem { indent, marker, .. } => { + let Some((source_content_start, output_content_start)) = list_content_columns( + source_line, + *indent, + marker.plain_marker(*indent).chars().count() + 1, + ) else { + return inline_plain_column_for_source_column(source_line, source_column); + }; + if source_column <= source_content_start { + return source_column.min(output_content_start); + } + output_content_start + + inline_plain_column_for_source_column( + char_slice_from(source_line, source_content_start), + source_column.saturating_sub(source_content_start), + ) + } + Block::ChecklistItem { indent, .. } => { + let Some((source_content_start, output_content_start)) = + checklist_content_columns(source_line, *indent) + else { + return inline_plain_column_for_source_column(source_line, source_column); + }; + if source_column <= source_content_start { + let removed = source_content_start.saturating_sub(output_content_start); + return source_column + .saturating_sub(removed) + .min(output_content_start); + } + output_content_start + + inline_plain_column_for_source_column( + char_slice_from(source_line, source_content_start), + source_column.saturating_sub(source_content_start), + ) + } + Block::Paragraph(_) => inline_plain_column_for_source_column(source_line, source_column), + _ => source_column, + } +} + +fn heading_content_start(source_line: &str, level: u8) -> Option { + if leading_whitespace_chars(source_line) != 0 { + return None; + } + let marker_len = level as usize + 1; + let marker = format!("{} ", "#".repeat(level as usize)); + source_line.starts_with(&marker).then_some(marker_len) +} + +fn quote_content_columns(source_line: &str, level: u8) -> Option<(usize, usize)> { + let leading = leading_whitespace_chars(source_line); + let trimmed = source_line.trim_start(); + let quote_len = trimmed.chars().take_while(|ch| *ch == '>').count(); + if quote_len == 0 || quote_len != level as usize { + return None; + } + let spaces = trimmed + .chars() + .skip(quote_len) + .take_while(|ch| ch.is_whitespace()) + .count(); + Some((leading + quote_len + spaces, leading + quote_len + 1)) +} + +fn list_content_columns( + source_line: &str, + indent: usize, + output_marker_len: usize, +) -> Option<(usize, usize)> { + let leading = leading_whitespace_chars(source_line); + let trimmed = source_line.trim_start(); + let source_marker_len = bullet_marker_len(trimmed).or_else(|| ordered_marker_len(trimmed))?; + Some((leading + source_marker_len, indent + output_marker_len)) +} + +fn checklist_content_columns(source_line: &str, indent: usize) -> Option<(usize, usize)> { + let leading = leading_whitespace_chars(source_line); + let trimmed = source_line.trim_start(); + let source_marker_len = marker_len( + trimmed, + &[ + "- [ ] ", "- [x] ", "[ ] ", "[x] ", "• [ ] ", "• [x] ", "◦ [ ] ", "◦ [x] ", + ], + )?; + Some((leading + source_marker_len, indent + 4)) +} + +fn bullet_marker_len(trimmed: &str) -> Option { + marker_len(trimmed, &["- ", "* ", "+ ", "• ", "◦ "]) +} + +fn ordered_marker_len(trimmed: &str) -> Option { + let bytes = trimmed.as_bytes(); + let mut index = 0usize; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + (index > 0 && trimmed.get(index..index + 2) == Some(". ")).then_some(index + 2) +} + +fn marker_len(source: &str, markers: &[&str]) -> Option { + markers + .iter() + .find(|marker| source.starts_with(**marker)) + .map(|marker| marker.chars().count()) +} + +fn leading_whitespace_chars(source: &str) -> usize { + source.chars().take_while(|ch| ch.is_whitespace()).count() +} + +fn char_slice_from(source: &str, start: usize) -> &str { + let byte_index = source + .char_indices() + .nth(start) + .map(|(index, _)| index) + .unwrap_or(source.len()); + &source[byte_index..] +} + 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 +1342,218 @@ 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 heading_shortcut_before_existing_text_maps_cursor_to_content_start() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "Heading"); + cursor = Cursor { line: 0, column: 0 }; + + buffer.insert_char(&mut cursor, '#'); + buffer.insert_char(&mut cursor, ' '); + + assert_eq!(buffer.as_string(), "Heading"); + assert_eq!(buffer.markdown_string(), "# Heading"); + assert_eq!(cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn typed_checkbox_shortcut_after_bullet_facade_converts_to_checklist() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + + for ch in "- [ ] todo".chars() { + buffer.insert_char(&mut cursor, ch); + } + + assert_eq!(buffer.as_string(), "[ ] todo"); + assert_eq!(buffer.markdown_string(), "- [ ] todo"); + assert_eq!(cursor, Cursor { line: 0, column: 8 }); + } + + #[test] + fn inline_markdown_typing_remaps_cursor_to_plain_text() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + + buffer.insert_str(&mut cursor, "a **bold** tail"); + + assert_eq!(buffer.as_string(), "a bold tail"); + assert_eq!(buffer.markdown_string(), "a **bold** tail"); + assert_eq!( + cursor, + Cursor { + line: 0, + column: 11 + } + ); + } + + #[test] + fn surface_edit_changes_heading_level() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "## Heading"); + cursor = Cursor { line: 0, column: 0 }; + + let display_column = buffer.replace_line_from_surface(0, "# Heading", 2, &mut cursor); + + assert_eq!(buffer.as_string(), "Heading"); + assert_eq!(buffer.markdown_string(), "# Heading"); + assert_eq!(display_column, 2); + assert_eq!(cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn surface_edit_removing_heading_marker_converts_to_paragraph() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "# Heading"); + cursor = Cursor { line: 0, column: 0 }; + + buffer.replace_line_from_surface(0, "Heading", 0, &mut cursor); + + assert_eq!(buffer.as_string(), "Heading"); + assert_eq!(buffer.markdown_string(), "Heading"); + assert_eq!(cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn surface_edit_updates_link_target() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "[README](README.md)"); + cursor = Cursor { line: 0, column: 2 }; + + buffer.replace_line_from_surface(0, "[README](guide.md)", 17, &mut cursor); + + assert_eq!(buffer.as_string(), "README"); + assert_eq!(buffer.markdown_string(), "[README](guide.md)"); + } + + #[test] + fn invalid_inline_surface_edit_degrades_to_plain_text() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "**bold**"); + cursor = Cursor { line: 0, column: 2 }; + + buffer.replace_line_from_surface(0, "**bold*", 7, &mut cursor); + + assert_eq!(buffer.as_string(), "**bold*"); + assert_eq!(buffer.markdown_string(), "**bold*"); + } + + #[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 selected_full_blocks_do_not_insert_extra_blank_lines() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "# Heading\n- [ ] todo"); + + let selected = + buffer.selected_markdown(Cursor { line: 0, column: 0 }, Cursor { line: 1, column: 8 }); + + assert_eq!(selected.as_deref(), Some("# Heading\n- [ ] todo")); + } + + #[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 76b41ef..fd24eb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod config; mod debug_render; +mod document; mod editor; mod fs; mod markdown; @@ -262,7 +263,7 @@ NORMAL MODE KEYS: : open command line / search the current file h j k l move left/down/up/right - w b move word forward/backward + w b word forward/backward 0 ^ $ line start, first non-blank, line end gg G document top/bottom n N next/previous search result @@ -276,7 +277,9 @@ NORMAL MODE KEYS: INSERT MODE KEYS: Esc return to Normal mode - Tab insert four spaces + Tab move to next table cell, or insert four spaces + Shift-Tab move to previous table cell + Enter move to next table row, continue a list, or insert newline Backspace delete previous character Option-Left move one word left Option-Right move one word right @@ -302,9 +305,18 @@ COMMANDS: Open a file path relative to the notes directory, or create it on save if it does not exist yet. + :table [rows]x[columns] + Insert a Markdown table. Defaults to 2x2. + + :row, :row above + Insert a table row below or above the current row. + + :column, :column left + Insert a table column right or left of the current cell. + MOUSE: Click move the cursor - Drag select text and copy it immediately + Drag select text and copy when released Wheel scroll through wrapped visual rows Cmd-click open the link under the pointer @@ -426,6 +438,7 @@ mod tests { assert!(help.contains("ends with the status bar")); assert!(help.contains("stdout is redirected or piped")); assert!(help.contains(":e , :edit ")); + assert!(help.contains(":table [rows]x[columns]")); assert!(help.contains("Cmd-click")); } 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/markdown/table.rs b/src/markdown/table.rs index 8baf89f..4a8fe92 100644 --- a/src/markdown/table.rs +++ b/src/markdown/table.rs @@ -22,6 +22,7 @@ impl Default for TableAlignment { pub struct TableCell { pub text: String, pub source_indices: Vec, + pub insertion_index: usize, } #[derive(Debug, Clone)] @@ -137,6 +138,7 @@ impl TableLayout { let empty_cell = TableCell { text: String::new(), source_indices: Vec::new(), + insertion_index: source.chars().count(), }; let row_height = (0..block.column_count()) .map(|column| { @@ -175,6 +177,7 @@ impl TableLayout { let empty_cell = TableCell { text: String::new(), source_indices: Vec::new(), + insertion_index: source.chars().count(), }; let wrapped_cells = (0..block.column_count()) .map(|column| { @@ -480,6 +483,7 @@ fn parse_cell(chars: &[char], mut start: usize, mut end: usize) -> TableCell { TableCell { text, source_indices, + insertion_index: start, } } @@ -497,6 +501,10 @@ fn is_escaped(chars: &[char], index: usize) -> bool { } fn wrap_cell(cell: &TableCell, width: usize, alignment: TableAlignment) -> Vec { + if cell.text.is_empty() { + return vec![empty_fitted_cell(width.max(1), cell.insertion_index)]; + } + cell_wrap_segments(cell, width.max(1)) .into_iter() .map(|(start, end)| { @@ -590,6 +598,17 @@ fn blank_cell(width: usize) -> FittedCell { } } +fn empty_fitted_cell(width: usize, insertion_index: usize) -> FittedCell { + let mut source_map = std::iter::repeat_n(None, width).collect::>(); + if let Some(first) = source_map.first_mut() { + *first = Some(insertion_index); + } + FittedCell { + text: " ".repeat(width), + source_map, + } +} + fn append_span( spans: &mut Vec>, source_map: &mut Vec>, diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 322a631..488b55a 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,7 +1,7 @@ use ratatui::{ Frame, layout::{Position, Rect}, - style::Style, + style::{Modifier, Style}, text::{Line, Span, Text}, widgets::Paragraph, }; @@ -9,15 +9,14 @@ 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, - }, - markdown::{ - highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, - table::TableLayout, - }, + document::{Block, SurfaceMode, surface::wrap_surface_or_facade_line}, + editor::{buffer::DocumentBuffer, render::visible_rows}, + markdown::{highlight::render_markdown_segment_with_completion, table::TableLayout}, }; +#[cfg(test)] +use crate::markdown::highlight::{concealed_wrap_line, concealed_wrap_segments}; + const ARTICLE_WIDTH: u16 = 82; pub fn page_area(area: Rect) -> Rect { @@ -53,13 +52,14 @@ 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) { - table_layout.wrap_line(line_num, text, w) - } else { - concealed_wrap_line(text, w) - } + surface_wrap_line( + &app.buffer, + &table_layout, + Some((app.cursor.line, app.cursor.column)), + line_num, + text, + w, + ) }, ); let visual_range = app.visual_line_anchor.map(|anchor| { @@ -69,9 +69,17 @@ 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, _) = surface_wrap_line( + &app.buffer, + &table_layout, + Some((app.cursor.line, app.cursor.column)), + 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 +89,66 @@ 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 display_range = None; let (mut line, source_map) = if let Some(rendered) = table_row { (rendered.line, Some(rendered.source_map)) + } else if active && let Some(block) = app.buffer.block_for_line(row.line_number) { + let surface = app.buffer.surface_line( + row.line_number, + SurfaceMode::Active { + cursor_column: app.cursor.column, + }, + ); + if surface.has_revealed_syntax() { + let display_segments = surface.wrap_display_segments(text_width); + let (display_start, display_end) = display_segments + .get(row.wrap_index) + .copied() + .unwrap_or((0, surface.display_len())); + display_range = Some((display_start, display_end)); + ( + render_surface_segment( + block, + &surface.text, + display_start, + display_end, + theme, + row.wrap_index, + row.completed && row.wrap_index > 0, + ), + Some(surface.source_map_for_display_range(display_start, display_end)), + ) + } else { + ( + render_document_segment( + block, + &row.full_text, + row.source_start, + row.source_end, + theme, + ), + None, + ) + } + } 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 +215,36 @@ 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((display_start, display_end)) = display_range { + let surface_column = app + .surface_cursor_column_for_line(row.line_number) + .unwrap_or_else(|| { + app.buffer + .surface_line( + row.line_number, + SurfaceMode::Active { + cursor_column: app.cursor.column, + }, + ) + .display_column_for_source_column(app.cursor.column) + }); + cursor_visual_x = Some( + gutter_width + + surface_column + .saturating_sub(display_start) + .min(display_end.saturating_sub(display_start)) + as u16, + ); + } else 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; } @@ -173,18 +257,229 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { if app.mode != Mode::CommandLine { let cursor_indent = if wrap_index_of_cursor > 0 { - detect_list_marker(&cursor_line_text) + facade_list_marker_len(&cursor_line_text) } 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)); } } +pub(crate) fn surface_wrap_line( + buffer: &DocumentBuffer, + table_layout: &TableLayout, + active_line: Option<(usize, usize)>, + 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) { + return table_layout.wrap_line(line_number, trimmed, width); + } + + let mode = active_line + .filter(|(line, _)| *line == line_number) + .map(|(_, cursor_column)| SurfaceMode::Active { cursor_column }) + .unwrap_or(SurfaceMode::Inactive); + wrap_surface_or_facade_line(buffer.block_for_line(line_number), trimmed, width, mode) +} + +fn render_surface_segment( + block: &Block, + source: &str, + segment_start: usize, + segment_end: usize, + theme: Theme, + wrap_index: usize, + completed: bool, +) -> Line<'static> { + match block { + Block::Heading { .. } => { + let mut line = render_markdown_segment_with_completion( + source, + segment_start, + segment_end, + theme, + true, + wrap_index, + completed, + ); + for span in &mut line.spans { + span.style = span.style.patch(theme.heading); + } + line + } + Block::CodeFence { .. } => Line::from(Span::styled( + char_slice(source, segment_start, segment_end), + theme.inline_code, + )), + _ => render_markdown_segment_with_completion( + source, + segment_start, + segment_end, + theme, + true, + wrap_index, + completed, + ), + } +} + +#[cfg(test)] +fn wrap_facade_text_line(text: &str, width: usize) -> (Vec<(usize, usize)>, usize) { + let marker_len = facade_list_marker_len(text); + let width = width.max(1); + if marker_len == 0 || marker_len >= width { + return concealed_wrap_line(text, width); + } + + let source_len = text.chars().count(); + let content = char_slice(text, marker_len, source_len); + if content.is_empty() { + return (vec![(0, source_len)], marker_len); + } + + let content_width = width - marker_len; + let content_segments = concealed_wrap_segments(&content, content_width); + let mut segments = Vec::new(); + for (index, (start, end)) in content_segments.into_iter().enumerate() { + if index == 0 { + segments.push((0, marker_len + end)); + } else { + segments.push((marker_len + start, marker_len + end)); + } + } + + (segments, marker_len) +} + +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 { .. } => render_markdown_segment_with_completion( + source, + segment_start, + segment_end, + theme, + false, + 0, + false, + ), + 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); + let completed = matches!(block, Block::ChecklistItem { checked: true, .. }); + if marker_len <= segment_start { + return render_markdown_segment_with_completion( + source, + segment_start, + segment_end, + theme, + false, + 0, + completed, + ); + } + if marker_len >= segment_end { + let marker_style = if completed { + theme.list_marker.add_modifier(Modifier::BOLD) + } else { + theme.list_marker + }; + return Line::from(Span::styled(text, marker_style)); + } + + let marker_style = if completed { + theme.list_marker.add_modifier(Modifier::BOLD) + } else { + theme.list_marker + }; + let mut spans = vec![Span::styled( + char_slice(source, segment_start, marker_len), + marker_style, + )]; + spans.extend( + render_markdown_segment_with_completion( + source, + marker_len, + segment_end, + theme, + false, + 0, + completed, + ) + .spans, + ); + Line::from(spans) + } + _ => render_markdown_segment_with_completion( + source, + segment_start, + segment_end, + theme, + false, + 0, + false, + ), + } +} + +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; + } + + 0 +} + fn selected_line(mut line: Line<'static>, theme: Theme) -> Line<'static> { line.style = theme.selection; for span in &mut line.spans { @@ -480,4 +775,97 @@ 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"); + } + + #[test] + fn active_heading_surface_reveals_marker() { + let theme = Theme::monochrome_for_tests(); + let document = crate::document::MarkdownCodec::parse("# Heading"); + let block = document.block_for_plain_line(0).unwrap(); + let surface = crate::document::SurfaceLine::for_block( + block, + "Heading", + SurfaceMode::Active { cursor_column: 0 }, + ); + + let line = render_surface_segment( + block, + &surface.text, + 0, + surface.display_len(), + theme, + 0, + false, + ); + + assert_eq!(line_text(&line), "# Heading"); + } + + #[test] + fn paragraph_render_matches_concealed_wrap_boundaries() { + let theme = Theme::monochrome_for_tests(); + let block = Block::Paragraph(Vec::new()); + let source = "Final long wrapped line with many constructs: bold words, inline code, a link, a bare URL https://example.com/final-check, and enough plain text to wrap several times in a narrow viewport."; + let (segments, _) = wrap_facade_text_line(source, 78); + + let rendered = segments + .into_iter() + .map(|(start, end)| { + line_text(&render_document_segment(&block, source, start, end, theme)) + }) + .collect::>(); + let joined = rendered.join("\n"); + + assert!(joined.contains("to wrap")); + assert!(!joined.contains("to wra\nseveral")); + } + + #[test] + fn facade_list_wrapping_reports_marker_indent() { + let (segments, marker_len) = + wrap_facade_text_line(" ◦ Another nested bullet item that wraps for a while", 24); + + assert_eq!(marker_len, 4); + assert!(segments.len() > 1); + assert!(segments[1].0 >= marker_len); + } + + fn line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + } } diff --git a/src/ui/sheet.rs b/src/ui/sheet.rs index ad4955a..61e313c 100644 --- a/src/ui/sheet.rs +++ b/src/ui/sheet.rs @@ -6,7 +6,7 @@ use ratatui::{ }; use crate::{ - app::{App, SheetItemKind}, + app::{App, CommandPrompt, SheetItemKind}, config::theme::Theme, }; @@ -40,25 +40,53 @@ fn sheet_items(app: &App, theme: Theme, start: usize, end: usize) -> Vec "CMD", - SheetItemKind::File => "FILE", - SheetItemKind::Search => "FIND", + let line = match app.sheet.prompt { + CommandPrompt::File => file_item_line(item, theme), + CommandPrompt::Palette => action_item_line(item, theme), + CommandPrompt::Search => search_item_line(item, theme), + CommandPrompt::Command => command_item_line(item, theme), }; - let action = match item.kind { - SheetItemKind::Command => item.label.clone(), - SheetItemKind::File => "navigate".to_string(), - SheetItemKind::Search => item.label.clone(), - }; - ListItem::new(Line::from(vec![ - Span::styled(format!("{kind:<4} "), theme.status), - Span::styled(action, theme.status), - Span::styled(format!(" {}", item.detail), theme.status), - ])) + ListItem::new(line) }) .collect() } +fn file_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + if item.detail.is_empty() || item.detail == item.label { + return Line::from(Span::styled(item.label.clone(), theme.status)); + } + + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn action_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn search_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn command_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + match item.kind { + SheetItemKind::Command => Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]), + SheetItemKind::File => file_item_line(item, theme), + SheetItemKind::Search => search_item_line(item, theme), + } +} + fn sheet_window(app: &App, visible_height: usize) -> (usize, usize) { let len = app.sheet.items.len(); if len == 0 { @@ -76,9 +104,14 @@ fn sheet_window(app: &App, visible_height: usize) -> (usize, usize) { } fn empty_message(app: &App) -> &'static str { - if app.command_line.trim().is_empty() { - "Type a command, file, or search query" - } else { - "No matches" + if !app.command_line.trim().is_empty() { + return "No matches"; + } + + match app.sheet.prompt { + CommandPrompt::Command => "Type a command", + CommandPrompt::File => "Type a file name", + CommandPrompt::Palette => "Type an action", + CommandPrompt::Search => "Type a search query", } } diff --git a/src/ui/status.rs b/src/ui/status.rs index acec863..37eaeef 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -72,6 +72,8 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { fn command_prompt(app: &App) -> (&'static str, u16) { match app.sheet.prompt { CommandPrompt::Command => (":", 1), + CommandPrompt::File => ("Open ", 5), + CommandPrompt::Palette => ("> ", 2), CommandPrompt::Search => ("/", 1), } }