diff --git a/src/bin/pure.rs b/src/bin/pure.rs index ff72c2f..96814e1 100644 --- a/src/bin/pure.rs +++ b/src/bin/pure.rs @@ -703,6 +703,67 @@ impl App { } } + fn indent_selection_or_cursor(&mut self) -> bool { + if let Some(selection) = self.current_selection() { + if self.display.indent_selection(&selection) { + self.selection_anchor = None; + self.mark_dirty(); + self.display.set_preferred_column(None); + return true; + } + return false; + } + + if self.display.indent_current_paragraph() { + self.selection_anchor = None; + self.mark_dirty(); + self.display.set_preferred_column(None); + return true; + } + + false + } + + fn unindent_selection_or_cursor(&mut self) -> bool { + if let Some(selection) = self.current_selection() { + if self.display.unindent_selection(&selection) { + self.selection_anchor = None; + self.mark_dirty(); + self.display.set_preferred_column(None); + return true; + } + return false; + } + + if self.display.unindent_current_paragraph() { + self.selection_anchor = None; + self.mark_dirty(); + self.display.set_preferred_column(None); + return true; + } + + false + } + + fn insert_char_with_selection(&mut self, ch: char) -> bool { + let mut selection_changed = false; + if let Some(selection) = self.current_selection() { + if !self.display.remove_selection(&selection) { + return false; + } + self.selection_anchor = None; + selection_changed = true; + } + + let inserted = self.display.insert_char(ch); + if selection_changed || inserted { + self.mark_dirty(); + self.display.set_preferred_column(None); + } + + inserted + } + fn capture_reveal_toggle_snapshot(&self) -> RevealToggleSnapshot { let viewport = self.display.last_view_height().max(1); let max_scroll = self @@ -1101,8 +1162,25 @@ impl App { fn execute_menu_action(&mut self, action: MenuAction) -> bool { match action { MenuAction::SetParagraphType(kind) => { - if self.display.set_paragraph_type(kind) { + let handled = if let Some(selection) = self.current_selection() { + if self + .display + .set_paragraph_type_for_selection(&selection, kind) + { + self.mark_dirty(); + self.selection_anchor = None; + true + } else { + false + } + } else if self.display.set_paragraph_type(kind) { self.mark_dirty(); + true + } else { + false + }; + + if handled { self.display.set_preferred_column(None); } true @@ -1118,19 +1196,11 @@ impl App { true } MenuAction::IndentMore => { - if self.display.indent_current_paragraph() { - self.selection_anchor = None; - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.indent_selection_or_cursor(); true } MenuAction::IndentLess => { - if self.display.unindent_current_paragraph() { - self.selection_anchor = None; - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.unindent_selection_or_cursor(); true } } @@ -1640,20 +1710,10 @@ impl App { self.should_quit = true; } (KeyCode::Char(']'), m) if m.contains(KeyModifiers::CONTROL) => { - self.prepare_selection(false); - if self.display.indent_current_paragraph() { - self.selection_anchor = None; - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.indent_selection_or_cursor(); } (KeyCode::Char('['), m) if m.contains(KeyModifiers::CONTROL) => { - self.prepare_selection(false); - if self.display.unindent_current_paragraph() { - self.selection_anchor = None; - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.unindent_selection_or_cursor(); } (KeyCode::Left, m) if m.contains(KeyModifiers::SHIFT | KeyModifiers::CONTROL) => @@ -1728,10 +1788,7 @@ impl App { self.display.move_to_visual_line_start(); } (KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => { - if self.display.insert_char('\n') { - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.insert_char_with_selection('\n'); } (KeyCode::Char('p'), m) if m.contains(KeyModifiers::CONTROL) => { self.prepare_selection(false); @@ -1780,28 +1837,19 @@ impl App { } (KeyCode::Enter, m) => { if m.contains(KeyModifiers::SHIFT) || m.contains(KeyModifiers::CONTROL) { - if self.display.insert_char('\n') { - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.insert_char_with_selection('\n'); } else if self.insert_paragraph_break() { self.mark_dirty(); self.display.set_preferred_column(None); } } (KeyCode::Tab, _) => { - if self.display.insert_char('\t') { - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.insert_char_with_selection('\t'); } (KeyCode::Char(ch), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => { - if self.display.insert_char(ch) { - self.mark_dirty(); - self.display.set_preferred_column(None); - } + self.insert_char_with_selection(ch); } (KeyCode::Up, m) if m.contains(KeyModifiers::SHIFT) => { self.prepare_selection(true); diff --git a/src/editor.rs b/src/editor.rs index fa02934..5b7eeca 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -252,6 +252,190 @@ pub struct DocumentEditor { } impl DocumentEditor { + fn normalize_selection( + &self, + selection: &(CursorPointer, CursorPointer), + ) -> Option<(CursorPointer, CursorPointer)> { + let mut start = selection.0.clone(); + let mut end = selection.1.clone(); + + let ordering = self.compare_pointers(&start, &end)?; + match ordering { + Ordering::Less => {} + Ordering::Equal => return None, + Ordering::Greater => { + std::mem::swap(&mut start, &mut end); + } + } + + Some((start, end)) + } + + pub(crate) fn selection_paragraph_targets( + &self, + selection: &(CursorPointer, CursorPointer), + ) -> Option> { + if self.segments.is_empty() { + return None; + } + + let (start, end) = self.normalize_selection(selection)?; + let start_key = self.pointer_key(&start)?; + let end_key = self.pointer_key(&end)?; + + let mut targets: Vec = Vec::new(); + + for segment_index in start_key.segment_index..=end_key.segment_index { + let Some(segment) = self.segments.get(segment_index) else { + continue; + }; + if segment.kind != SegmentKind::Text { + continue; + } + + let len = segment.len; + let seg_start = if segment_index == start_key.segment_index { + start_key.offset.min(len) + } else { + 0 + }; + let seg_end = if segment_index == end_key.segment_index { + end_key.offset.min(len) + } else { + len + }; + + let touched = if start_key.segment_index == end_key.segment_index { + seg_start < seg_end + } else if segment_index == start_key.segment_index { + seg_start < len || segment_index < end_key.segment_index + } else if segment_index == end_key.segment_index { + seg_end > 0 + } else { + true + }; + + if !touched { + continue; + } + + if targets + .iter() + .any(|pointer| pointer.paragraph_path == segment.paragraph_path) + { + continue; + } + + targets.push(CursorPointer { + paragraph_path: segment.paragraph_path.clone(), + span_path: segment.span_path.clone(), + offset: if segment_index == start_key.segment_index { + seg_start + } else { + 0 + }, + segment_kind: SegmentKind::Text, + }); + } + + if targets.is_empty() { + return None; + } + + let mut keyed: Vec<(PointerKey, CursorPointer)> = Vec::new(); + let mut unkeyed: Vec = Vec::new(); + for pointer in targets { + if let Some(key) = self.pointer_key(&pointer) { + keyed.push((key, pointer)); + } else { + unkeyed.push(pointer); + } + } + + keyed.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut ordered: Vec = keyed.into_iter().map(|(_, ptr)| ptr).collect(); + ordered.extend(unkeyed); + + Some(ordered) + } + + pub(crate) fn pointer_for_character(&self, target: char) -> Option { + for segment in &self.segments { + if segment.kind != SegmentKind::Text { + continue; + } + let Some(text) = self.segment_text(segment) else { + continue; + }; + for (idx, ch) in text.chars().enumerate() { + if ch == target { + return Some(CursorPointer { + paragraph_path: segment.paragraph_path.clone(), + span_path: segment.span_path.clone(), + offset: idx, + segment_kind: SegmentKind::Text, + }); + } + } + } + None + } + + pub(crate) fn remove_char_at_pointer(&mut self, pointer: &CursorPointer) -> bool { + if remove_char_at(&mut self.document, pointer, pointer.offset) { + self.update_segments_for_paragraph(&pointer.paragraph_path); + true + } else { + false + } + } + + pub fn remove_selection(&mut self, selection: &(CursorPointer, CursorPointer)) -> bool { + if self.segments.is_empty() { + return false; + } + + let (start, end) = match self.normalize_selection(selection) { + Some(bounds) => bounds, + None => return false, + }; + + let mut moved = self.move_to_pointer(&end); + if !moved { + moved = + self.fallback_move_to_text(&end, false) || self.fallback_move_to_text(&end, true); + } + if !moved { + return false; + } + + loop { + match self.compare_pointers(&self.cursor, &start) { + Some(Ordering::Greater) => { + if !self.backspace() { + return false; + } + } + Some(Ordering::Equal) => break, + Some(Ordering::Less) => { + let mut moved_to_start = self.move_to_pointer(&start); + if !moved_to_start { + moved_to_start = self.fallback_move_to_text(&start, false) + || self.fallback_move_to_text(&start, true); + } + if !moved_to_start { + return false; + } + break; + } + None => return false, + } + } + + true + } + pub fn new(mut document: Document) -> Self { ensure_document_initialized(&mut document); let mut editor = Self { diff --git a/src/editor/content.rs b/src/editor/content.rs index 56504d5..e868271 100644 --- a/src/editor/content.rs +++ b/src/editor/content.rs @@ -40,18 +40,7 @@ pub(crate) fn remove_char_at( let Some(span) = span_mut_from_item(item, &pointer.span_path) else { return false; }; - let char_len = span.text.chars().count(); - if offset >= char_len { - return false; - } - let byte_idx = char_to_byte_idx(&span.text, offset); - if let Some(ch) = span.text.chars().nth(offset) { - for _ in 0..ch.len_utf8() { - span.text.remove(byte_idx); - } - return true; - } - return false; + return remove_char_from_text(&mut span.text, offset); } let Some(paragraph) = paragraph_mut(document, &pointer.paragraph_path) else { @@ -60,16 +49,20 @@ pub(crate) fn remove_char_at( let Some(span) = span_mut(paragraph, &pointer.span_path) else { return false; }; - let char_len = span.text.chars().count(); + remove_char_from_text(&mut span.text, offset) +} + +fn remove_char_from_text(text: &mut String, offset: usize) -> bool { + let char_len = text.chars().count(); if offset >= char_len { return false; } - let start = char_to_byte_idx(&span.text, offset); - let end = char_to_byte_idx(&span.text, offset + 1); - if start >= end || end > span.text.len() { + let start = char_to_byte_idx(text, offset); + let end = char_to_byte_idx(text, offset + 1); + if start >= end || end > text.len() { return false; } - span.text.drain(start..end); + text.drain(start..end); true } diff --git a/src/editor_display.rs b/src/editor_display.rs index ed2ca0c..71ef5e1 100644 --- a/src/editor_display.rs +++ b/src/editor_display.rs @@ -42,7 +42,15 @@ pub struct EditorDisplay { theme: Theme, } +enum SelectionIterationOrder { + TopDown, + BottomUp, +} + impl EditorDisplay { + const CURSOR_MARKER: char = '\u{F8F0}'; + const SELECTION_MARKER_BASE: u32 = 0x10F000; + /// Create a new EditorDisplay with the given editor pub fn new(editor: DocumentEditor) -> Self { Self { @@ -223,6 +231,7 @@ impl EditorDisplay { self.wrap_width, self.left_padding, prefix, + false, &reveal_tags, crate::render::DirectCursorTracking { cursor: Some(&self.editor.cursor_pointer()), @@ -443,6 +452,7 @@ impl EditorDisplay { self.wrap_width, self.left_padding, "", + false, &reveal_tags, DirectCursorTracking { cursor: Some(&cursor_pointer), @@ -1150,33 +1160,77 @@ impl EditorDisplay { /// Focus on a specific pointer pub fn focus_pointer(&mut self, pointer: &CursorPointer) { - if self.editor.move_to_pointer(pointer) { - // Search for the visual position of this pointer in paragraph_lines - let found = self - .layout - .as_ref() - .unwrap() - .paragraph_lines - .iter() - .find_map(|info| { - info.positions - .iter() - .find(|(p, _)| p == pointer) - .map(|(_, pos)| { - // Convert relative position to absolute - let mut absolute_pos = *pos; - absolute_pos.line = info.start_line + pos.line; - absolute_pos - }) - }); - - if let Some(position) = found { - self.preferred_column = Some(position.content_column); - } else { - self.preferred_column = None; - } + let mut moved = self.editor.move_to_pointer(pointer); + if !moved { + moved = self.editor.fallback_move_to_text(pointer, false) + || self.editor.fallback_move_to_text(pointer, true); + } + if !moved { + return; + } + + let Some(layout) = self.layout.as_ref() else { + self.preferred_column = None; self.cursor_following = true; - self.update_cursor_visual_position(); + return; + }; + + // Search for the visual position of this pointer in paragraph_lines + let found = layout.paragraph_lines.iter().find_map(|info| { + info.positions + .iter() + .find(|(p, _)| p == pointer) + .map(|(_, pos)| { + // Convert relative position to absolute + let mut absolute_pos = *pos; + absolute_pos.line = info.start_line + pos.line; + absolute_pos + }) + }); + + if let Some(position) = found { + self.preferred_column = Some(position.content_column); + } else { + self.preferred_column = None; + } + self.cursor_following = true; + self.update_cursor_visual_position(); + } + + fn place_temporary_cursor_marker(&mut self, pointer: &CursorPointer) -> bool { + let mut moved = self.editor.move_to_pointer(pointer); + if !moved { + moved = self.editor.fallback_move_to_text(pointer, false) + || self.editor.fallback_move_to_text(pointer, true); + } + if !moved { + return false; + } + if !self.editor.insert_char(Self::CURSOR_MARKER) { + return false; + } + true + } + + fn place_marker_at_pointer(&mut self, pointer: &CursorPointer, marker: char) -> bool { + let mut moved = self.editor.move_to_pointer(pointer); + if !moved { + moved = self.editor.fallback_move_to_text(pointer, false) + || self.editor.fallback_move_to_text(pointer, true); + } + if !moved { + return false; + } + self.editor.insert_char(marker) + } + + fn remove_temporary_cursor_marker(&mut self) { + let Some(pointer) = self.editor.pointer_for_character(Self::CURSOR_MARKER) else { + return; + }; + let focus_pointer = pointer.clone(); + if self.editor.remove_char_at_pointer(&pointer) { + self.focus_pointer(&focus_pointer); } } @@ -1373,6 +1427,97 @@ impl EditorDisplay { result } + fn apply_selection_paragraph_operation( + &mut self, + selection: &(CursorPointer, CursorPointer), + order: SelectionIterationOrder, + mut operation: F, + ) -> bool + where + F: FnMut(&mut DocumentEditor) -> bool, + { + let Some(mut paragraph_targets) = self.editor.selection_paragraph_targets(selection) else { + return false; + }; + + if paragraph_targets.is_empty() { + return false; + } + + if matches!(order, SelectionIterationOrder::TopDown) { + paragraph_targets.reverse(); + } + + let marker_inserted = self.place_temporary_cursor_marker(&selection.1); + let mut changed = false; + + let mut markers: Vec = Vec::new(); + for (idx, pointer) in paragraph_targets.iter().enumerate() { + let marker_char = match std::char::from_u32(Self::SELECTION_MARKER_BASE + idx as u32) { + Some(ch) => ch, + None => break, + }; + if self.place_marker_at_pointer(pointer, marker_char) { + markers.push(marker_char); + } + } + + for marker in &markers { + let Some(marker_pointer) = self.editor.pointer_for_character(*marker) else { + continue; + }; + + self.focus_pointer(&marker_pointer); + + if self.editor.remove_char_at_pointer(&marker_pointer) { + self.focus_pointer(&marker_pointer); + } else { + continue; + } + + if operation(&mut self.editor) { + changed = true; + } + } + + for marker in markers { + while let Some(marker_pointer) = self.editor.pointer_for_character(marker) { + if !self.editor.remove_char_at_pointer(&marker_pointer) { + break; + } + } + } + + if marker_inserted { + self.remove_temporary_cursor_marker(); + } else { + self.focus_pointer(&selection.1); + } + + if changed { + self.force_full_relayout(); + self.clear_render_cache(); + } + + changed + } + + pub fn indent_selection(&mut self, selection: &(CursorPointer, CursorPointer)) -> bool { + self.apply_selection_paragraph_operation( + selection, + SelectionIterationOrder::TopDown, + |editor| editor.indent_current_paragraph(), + ) + } + + pub fn unindent_selection(&mut self, selection: &(CursorPointer, CursorPointer)) -> bool { + self.apply_selection_paragraph_operation( + selection, + SelectionIterationOrder::BottomUp, + |editor| editor.unindent_current_paragraph(), + ) + } + /// Set checklist item checked state with layout update pub fn set_current_checklist_item_checked(&mut self, checked: bool) -> bool { // Get the root paragraph index before modifying @@ -1424,6 +1569,60 @@ impl EditorDisplay { self.clear_render_cache(); result } + + pub fn set_paragraph_type_for_selection( + &mut self, + selection: &(CursorPointer, CursorPointer), + target: tdoc::ParagraphType, + ) -> bool { + let Some(paragraph_targets) = self.editor.selection_paragraph_targets(selection) else { + return false; + }; + + if paragraph_targets.is_empty() { + return false; + } + + let marker_inserted = self.place_temporary_cursor_marker(&selection.1); + let mut changed = false; + + for pointer in paragraph_targets { + let mut moved = self.editor.move_to_pointer(&pointer); + if !moved { + moved = self.editor.fallback_move_to_text(&pointer, false) + || self.editor.fallback_move_to_text(&pointer, true); + } + if !moved { + continue; + } + + if self.editor.set_paragraph_type(target) { + changed = true; + } + } + + if marker_inserted { + self.remove_temporary_cursor_marker(); + } else { + self.focus_pointer(&selection.1); + } + + if changed { + self.force_full_relayout(); + self.clear_render_cache(); + } + + changed + } + + pub fn remove_selection(&mut self, selection: &(CursorPointer, CursorPointer)) -> bool { + let result = self.editor.remove_selection(selection); + if result { + self.force_full_relayout(); + self.clear_render_cache(); + } + result + } } impl Deref for EditorDisplay { @@ -1475,8 +1674,8 @@ fn inline_style_label(style: InlineStyle) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::editor::DocumentEditor; - use tdoc::{Document, Paragraph, Span, ftml}; + use crate::editor::{CursorPointer, DocumentEditor, ParagraphPath, SegmentKind, SpanPath}; + use tdoc::{ChecklistItem, Document, Paragraph, Span, ftml}; fn create_test_display() -> EditorDisplay { let mut doc = Document::new(); @@ -1496,6 +1695,15 @@ mod tests { EditorDisplay::new(editor) } + fn pointer_for_path(path: ParagraphPath, offset: usize) -> CursorPointer { + CursorPointer { + paragraph_path: path, + span_path: SpanPath::new(vec![0]), + offset, + segment_kind: SegmentKind::Text, + } + } + #[test] fn test_move_cursor_vertical_down() { let mut display = create_test_display(); @@ -2546,6 +2754,197 @@ mod tests { ); } + #[test] + fn indent_selection_moves_following_paragraphs_into_list_entry() { + let doc = ftml! { + ul { + li { p { "Existing" } } + } + p { "First addition" } + p { "Second addition" } + }; + + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let start = pointer_for_path(ParagraphPath::new_root(1), 0); + let end_offset = "Second addition".chars().count(); + let end = pointer_for_path(ParagraphPath::new_root(2), end_offset); + let selection = (start.clone(), end.clone()); + + assert!(display.indent_selection(&selection)); + + let expected = + Document::new().with_paragraphs(vec![Paragraph::new_unordered_list().with_entries( + vec![vec![ + Paragraph::new_text().with_content(vec![Span::new_text("Existing")]), + Paragraph::new_text().with_content(vec![Span::new_text("First addition")]), + Paragraph::new_text().with_content(vec![Span::new_text("Second addition")]), + ]], + )]); + + assert_eq!(display.document(), &expected); + } + + #[test] + fn indent_selection_converts_ordered_list_range_into_nested_list() { + let doc = ftml! { + ol { + li { p { "First" } } + li { p { "Second" } } + li { p { "Third" } } + } + }; + + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let mut start_path = ParagraphPath::new_root(0); + start_path.push_entry(1, 0); + let mut end_path = ParagraphPath::new_root(0); + end_path.push_entry(2, 0); + let end_offset = "Third".chars().count(); + let selection = ( + pointer_for_path(start_path, 0), + pointer_for_path(end_path, end_offset), + ); + + assert!(display.indent_selection(&selection)); + + let expected = + Document::new().with_paragraphs(vec![Paragraph::new_ordered_list().with_entries( + vec![vec![ + Paragraph::new_text().with_content(vec![Span::new_text("First")]), + Paragraph::new_ordered_list().with_entries(vec![ + vec![Paragraph::new_text().with_content(vec![Span::new_text("Second")])], + vec![Paragraph::new_text().with_content(vec![Span::new_text("Third")])], + ]), + ]], + )]); + + assert_eq!(display.document(), &expected); + } + + #[test] + fn indent_selection_converts_checklist_range_into_children() { + let doc = ftml! { + checklist { + todo { "One" } + todo { "Two" } + todo { "Three" } + } + }; + + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let mut start_path = ParagraphPath::new_root(0); + start_path.push_checklist_item(vec![1]); + let mut end_path = ParagraphPath::new_root(0); + end_path.push_checklist_item(vec![2]); + let end_offset = "Three".chars().count(); + let selection = ( + pointer_for_path(start_path, 0), + pointer_for_path(end_path, end_offset), + ); + + assert!(display.indent_selection(&selection)); + + let expected = + Document::new().with_paragraphs(vec![Paragraph::new_checklist().with_checklist_items( + vec![ + ChecklistItem::new(false) + .with_content(vec![Span::new_text("One")]) + .with_children(vec![ + ChecklistItem::new(false) + .with_content(vec![Span::new_text("Two")]), + ChecklistItem::new(false) + .with_content(vec![Span::new_text("Three")]), + ]), + ], + )]); + + assert_eq!(display.document(), &expected); + } + + #[test] + fn unindent_selection_promotes_nested_list_range() { + let doc = ftml! { + ul { + li { + p { "Parent" } + ul { + li { p { "Child one" } } + li { p { "Child two" } } + } + } + li { p { "Sibling" } } + } + }; + + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let mut start_path = ParagraphPath::new_root(0); + start_path.push_entry(0, 1); + start_path.push_entry(0, 0); + let mut end_path = ParagraphPath::new_root(0); + end_path.push_entry(0, 1); + end_path.push_entry(1, 0); + let end_offset = "Child two".chars().count(); + let selection = ( + pointer_for_path(start_path, 0), + pointer_for_path(end_path, end_offset), + ); + + assert!(display.unindent_selection(&selection)); + + let expected = ftml! { + ul { + li { p { "Parent" } } + li { p { "Child one" } } + li { p { "Child two" } } + li { p { "Sibling" } } + } + }; + + assert_eq!(display.document(), &expected); + } + + #[test] + fn indent_selection_nests_adjacent_list_items_under_previous_entry() { + let doc = ftml! { + ul { + li { p { "one" } } + li { p { "two" } } + li { p { "three" } } + } + }; + + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let mut start_path = ParagraphPath::new_root(0); + start_path.push_entry(1, 0); + let mut end_path = ParagraphPath::new_root(0); + end_path.push_entry(2, 0); + let selection = ( + pointer_for_path(start_path, 1), + pointer_for_path(end_path, 2), + ); + + assert!(display.indent_selection(&selection)); + + let expected = ftml! { + ul { + li { + p { "one" } + ul { + li { p { "two" } } + li { p { "three" } } + } + } + } + }; + + assert_eq!(display.document(), &expected); + } + #[test] fn test_check_checklist_item_updates_screen() { use tdoc::{ChecklistItem, Document, Paragraph, Span as DocSpan}; diff --git a/src/editor_tests.rs b/src/editor_tests.rs index 6892e52..4a0f781 100644 --- a/src/editor_tests.rs +++ b/src/editor_tests.rs @@ -2356,6 +2356,180 @@ fn delete_joins_regular_paragraphs_and_maintains_cursor() { ); } +#[test] +fn set_paragraph_type_for_selection_updates_all_touched_paragraphs() { + use crate::editor_display::EditorDisplay; + + let doc = Document::new().with_paragraphs(vec![ + text_paragraph("First"), + text_paragraph("Second"), + text_paragraph("Third"), + ]); + let editor = DocumentEditor::new(doc); + let mut display = EditorDisplay::new(editor); + + let start = display + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(0), 0) + .expect("start pointer"); + let end = display + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(2), 0) + .expect("end pointer"); + + assert!( + display.set_paragraph_type_for_selection( + &(start.clone(), end.clone()), + ParagraphType::Header1, + ) + ); + + match &display.document().paragraphs[0] { + Paragraph::Header1 { .. } => {} + other => panic!("First paragraph should be Header1, got {:?}", other), + } + match &display.document().paragraphs[1] { + Paragraph::Header1 { .. } => {} + other => panic!("Second paragraph should be Header1, got {:?}", other), + } + match &display.document().paragraphs[2] { + Paragraph::Text { .. } => {} + other => panic!("Third paragraph should remain Text, got {:?}", other), + } +} + +#[test] +fn converting_full_selection_to_checklist_preserves_all_items() { + use crate::editor_display::EditorDisplay; + + let doc = Document::new().with_paragraphs(vec![ + text_paragraph("First"), + text_paragraph("Second"), + text_paragraph("Third"), + ]); + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let start = display + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(0), 0) + .expect("start pointer"); + let last_len = display.document().paragraphs[2] + .content() + .iter() + .map(|span| span.text.chars().count()) + .sum(); + let end = display + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(2), last_len) + .expect("end pointer"); + + assert!( + display.set_paragraph_type_for_selection( + &(start.clone(), end.clone()), + ParagraphType::Checklist + ), + "selection-to-checklist conversion should succeed" + ); + + assert_eq!( + display.document().paragraphs.len(), + 1, + "all paragraphs should merge into a single checklist" + ); + + match &display.document().paragraphs[0] { + Paragraph::Checklist { items } => { + let contents: Vec = items + .iter() + .map(|item| { + item.content + .iter() + .map(|span| span.text.clone()) + .collect::() + }) + .collect(); + assert_eq!(contents, vec!["First", "Second", "Third"]); + } + other => panic!("document should become a checklist, got {:?}", other), + } +} + +#[test] +fn converting_selection_to_text_keeps_cursor_position() { + use crate::editor_display::EditorDisplay; + + let list = Paragraph::UnorderedList { + entries: vec![ + vec![Paragraph::new_text().with_content(vec![Span::new_text("First bullet")])], + vec![Paragraph::new_text().with_content(vec![Span::new_text("Second bullet")])], + vec![Paragraph::new_text().with_content(vec![Span::new_text("Third bullet")])], + ], + }; + + let doc = Document::new().with_paragraphs(vec![list]); + let mut display = EditorDisplay::new(DocumentEditor::new(doc)); + + let mut start = pointer_to_entry_span(0, 0, 0); + start.offset = 3; + let mut end = pointer_to_entry_span(0, 2, 0); + end.offset = 3; + + assert!(display.move_to_pointer(&end)); + + assert!( + display + .set_paragraph_type_for_selection(&(start.clone(), end.clone()), ParagraphType::Text) + ); + + // After conversion, the list should become three text paragraphs and the cursor + // should remain where the selection ended (middle of the last paragraph). + assert_eq!(display.document().paragraphs.len(), 3); + let cursor = display.cursor_pointer(); + assert_eq!(cursor.paragraph_path.root_index(), Some(2)); + assert_eq!(cursor.offset, end.offset); +} + +#[test] +fn remove_selection_across_paragraphs_merges_and_allows_insertion() { + let doc = Document::new().with_paragraphs(vec![ + text_paragraph("Alpha"), + text_paragraph("Beta"), + text_paragraph("Gamma"), + ]); + let mut editor = DocumentEditor::new(doc); + + let start = editor + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(0), 2) + .expect("pointer for start"); + let end = editor + .pointer_at_paragraph_char_offset(&ParagraphPath::new_root(2), 2) + .expect("pointer for end"); + + assert!(editor.remove_selection(&(start.clone(), end.clone()))); + + let paragraphs = &editor.document().paragraphs; + assert_eq!( + paragraphs.len(), + 1, + "Selection should merge into single paragraph" + ); + + let merged_text = match ¶graphs[0] { + Paragraph::Text { content } => content + .iter() + .map(|span| span.text.clone()) + .collect::(), + other => panic!("Expected text paragraph, got {:?}", other), + }; + assert_eq!(merged_text, "Almma"); + + assert!(editor.insert_char('X')); + let updated_text = match &editor.document().paragraphs[0] { + Paragraph::Text { content } => content + .iter() + .map(|span| span.text.clone()) + .collect::(), + other => panic!("Expected text paragraph after insertion, got {:?}", other), + }; + assert_eq!(updated_text, "AlXmma"); +} + #[test] fn delete_joins_checklist_items() { let mut doc = Document::new(); diff --git a/src/render.rs b/src/render.rs index d8aafde..07c5531 100644 --- a/src/render.rs +++ b/src/render.rs @@ -65,6 +65,8 @@ pub struct ParagraphLayout { pub cursor: Option, /// Number of lines rendered pub line_count: usize, + /// Whether selection is still active after finishing this paragraph + pub selection_active_end: bool, } #[derive(Debug, Clone)] @@ -113,6 +115,7 @@ pub fn render_document_direct( reveal_tags, direct_tracking, theme, + false, ); renderer.render_document(document); renderer.finish() @@ -128,6 +131,7 @@ pub fn layout_paragraph( wrap_width: usize, left_padding: usize, prefix: &str, + selection_active: bool, reveal_tags: &[RevealTagRef], direct_tracking: DirectCursorTracking, theme: &Theme, @@ -139,6 +143,7 @@ pub fn layout_paragraph( reveal_tags, direct_tracking, theme, + selection_active, ); renderer.current_paragraph_index = paragraph_index; @@ -186,6 +191,7 @@ pub fn layout_paragraph( positions, cursor, line_count, + selection_active_end: renderer.selection_active, } } @@ -221,6 +227,7 @@ struct DirectRenderer<'a> { // Theme for styling theme: Theme, + selection_active: bool, } impl<'a> DirectRenderer<'a> { @@ -231,6 +238,7 @@ impl<'a> DirectRenderer<'a> { reveal_tags: &[RevealTagRef], direct_tracking: DirectCursorTracking<'a>, theme: &Theme, + selection_active: bool, ) -> Self { let wrap_limit = if wrap_width > 1 { wrap_width - 1 } else { 1 }; let padding = if left_padding > 0 { @@ -264,10 +272,12 @@ impl<'a> DirectRenderer<'a> { marker_to_pointer: HashMap::new(), paragraph_lines: Vec::new(), theme: theme.clone(), + selection_active, } } fn render_document(&mut self, document: &Document) { + let mut selection_active = false; for (idx, paragraph) in document.paragraphs.iter().enumerate() { if idx > 0 { self.push_plain_line("", true); @@ -296,11 +306,14 @@ impl<'a> DirectRenderer<'a> { self.wrap_width, self.left_padding, "", + selection_active, &reveal_tags, direct_tracking, &self.theme, ); + selection_active = layout.selection_active_end; + // Add the lines and metrics to our result self.lines.extend(layout.lines); self.line_metrics.extend(layout.line_metrics); @@ -1070,7 +1083,7 @@ impl<'a> DirectRenderer<'a> { } fn wrap_fragments_direct( - &self, + &mut self, fragments: &[FragmentItem], first_prefix: &str, continuation_prefix: &str, @@ -1078,13 +1091,16 @@ impl<'a> DirectRenderer<'a> { ) -> Vec { // Fragments are already converted to Fragment type in tokenize_text_direct, // so we can just call wrap_fragments directly - wrap_fragments( + let (outputs, selection_active_end) = wrap_fragments( fragments, first_prefix, continuation_prefix, width, + self.selection_active, &self.theme, - ) + ); + self.selection_active = selection_active_end; + outputs } fn consume_lines_direct(&mut self, outputs: Vec) { @@ -1563,10 +1579,11 @@ fn wrap_fragments( first_prefix: &str, continuation_prefix: &str, width: usize, + mut selection_active: bool, theme: &Theme, -) -> Vec { +) -> (Vec, bool) { let mut outputs = Vec::new(); - let mut builder = LineBuilder::new(first_prefix.to_string(), width, false, theme); + let mut builder = LineBuilder::new(first_prefix.to_string(), width, selection_active, theme); let mut pending_whitespace: Vec = Vec::new(); for fragment in fragments { @@ -1574,11 +1591,12 @@ fn wrap_fragments( FragmentItem::LineBreak => { builder.consume_pending(&mut pending_whitespace); let (line, active_selection) = builder.build_line(); + selection_active = active_selection; outputs.push(line); builder = LineBuilder::new( continuation_prefix.to_string(), width, - active_selection, + selection_active, theme, ); } @@ -1596,11 +1614,12 @@ fn wrap_fragments( { builder.consume_pending(&mut pending_whitespace); let (line, active_selection) = builder.build_line(); + selection_active = active_selection; outputs.push(line); builder = LineBuilder::new( continuation_prefix.to_string(), width, - active_selection, + selection_active, theme, ); continue; @@ -1617,11 +1636,12 @@ fn wrap_fragments( let (head, tail_opt) = split_fragment(token, split_limit); builder.append_with_pending(head, &mut pending_whitespace); let (line, active_selection) = builder.build_line(); + selection_active = active_selection; outputs.push(line); builder = LineBuilder::new( continuation_prefix.to_string(), width, - active_selection, + selection_active, theme, ); if let Some(tail) = tail_opt { @@ -1641,9 +1661,9 @@ fn wrap_fragments( } builder.consume_pending(&mut pending_whitespace); - let (line, _) = builder.build_line(); + let (line, active_selection) = builder.build_line(); outputs.push(line); - outputs + (outputs, active_selection) } fn split_fragment(fragment: Fragment, limit: usize) -> (Fragment, Option) {