diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 1e80ec40..107caacd 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -18,6 +18,8 @@ mod hosted; mod reference_authoring; #[cfg(test)] mod review_navigation_tests; +#[cfg(test)] +mod source_indentation_tests; pub(crate) mod source_rename; mod source_symbols; pub(crate) mod text_workspace; @@ -427,6 +429,7 @@ pub enum Message { Pan(iced::Vector), Fit, SourceEdit(text_editor::Action), + SourceIndent(crate::source_navigation::SymbolContext, bool), ApplySource, RevertSource, SourceUndo, @@ -3314,10 +3317,16 @@ impl App { self.pan += delta; } Message::Fit => self.fit(), - Message::SourceEdit(action) => { + Message::SourceEdit(_) | Message::SourceIndent(..) => { self.source_jump = None; let before = self.source_editor.text(); - self.source_editor.perform(action); + match message { + Message::SourceEdit(action) => self.source_editor.perform(action), + Message::SourceIndent(_, outdent) => { + crate::source_indentation::apply(&mut self.source_editor, outdent); + } + _ => unreachable!(), + } let after = self.source_editor.text(); if after != before { self.source_navigation.refresh(&after); diff --git a/crates/oa-desktop/src/app/source_indentation_tests.rs b/crates/oa-desktop/src/app/source_indentation_tests.rs new file mode 100644 index 00000000..7477ab2f --- /dev/null +++ b/crates/oa-desktop/src/app/source_indentation_tests.rs @@ -0,0 +1,77 @@ +//! Controller acceptance: one indentation gesture is one draft transaction. +use super::*; + +fn fixture(source: &str) -> App { + let mut app = App::with_project(Project::empty()); + app.page = Page::Text; + app.source_workspace = Workspace::new( + oa_core::source_project::SourceBundle { + entry: "project.arcadia".into(), + files: std::collections::BTreeMap::from([("project.arcadia".into(), source.into())]), + }, + true, + ); + app.show_source_file(false); + app +} +fn indent(app: &mut App, outdent: bool) { + let message = Message::SourceIndent(app.source_symbol_context(), outdent); + let _ = app.update(message); +} + +#[test] +fn indentation_updates_draft_snapshot_version_and_single_undo_redo_without_model_change() { + let source = "雨\r\n🚀\r\n"; + let mut app = fixture(source); + app.source_editor.perform(text_editor::Action::SelectAll); + let version = app.source_version; + let revision = app.source_workspace.revision(); + let model = app.document.revision(); + indent(&mut app, false); + let after = " 雨\r\n 🚀\r\n"; + assert_eq!(app.source_editor.text(), after); + assert_eq!(app.source_snapshot.as_ref(), after); + assert_eq!(app.source_workspace.files["project.arcadia"].text, after); + assert!(app.source_workspace.has_edits()); + assert_eq!(app.source_version, version + 1); + assert_eq!(app.source_workspace.revision(), revision + 1); + assert_eq!(app.document.revision(), model); + let _ = app.update(Message::SourceUndo); + assert_eq!(app.source_editor.text(), source); + assert!(!app.source_workspace.has_edits()); + let _ = app.update(Message::SourceRedo); + assert_eq!(app.source_editor.text(), after); + assert_eq!(app.document.revision(), model); +} + +#[test] +fn stale_hidden_and_modal_indentation_is_rejected_before_navigation_invalidation() { + for rejected in 0..7 { + let mut app = fixture("alpha"); + let mut context = app.source_symbol_context(); + match rejected { + 0 => context.path = "elsewhere.arcadia".into(), + 1 => context.session = Id::new_v4(), + 2 => app.page = Page::Home, + 3 => app.busy = true, + 4 => app.delete_pending = true, + 5 => app.hosted.visible = true, + _ => app.pending = Some(Pending::New), + } + let interaction = app.source_symbols.interaction; + let version = app.source_version; + let _ = app.update(Message::SourceIndent(context, false)); + assert_eq!(app.source_editor.text(), "alpha"); + assert_eq!(app.source_version, version); + assert_eq!(app.source_symbols.interaction, interaction); + } +} + +#[test] +fn no_op_outdent_does_not_add_history_or_increment_source_version() { + let mut app = fixture("alpha"); + let version = app.source_version; + indent(&mut app, true); + assert_eq!(app.source_version, version); + assert!(!app.source_workspace.undo(false)); +} diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index 2b1d52de..e3e75caf 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -33,6 +33,9 @@ fn admission_notice(error: query::AdmissionError) -> String { impl App { pub(super) fn source_input_envelope(&self, message: &Message) -> bool { match message { + Message::SourceIndent(context, _) => { + context == &self.source_symbol_context() && self.source_navigation_available() + } Message::SourceSymbols(context, event) => { context.session == self.source_workspace.session() && context.path == self.source_workspace.active diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 282766b3..02bf1b9e 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -28,6 +28,7 @@ mod recovery; mod route_edit; mod semantic_review; mod source_annotations; +mod source_indentation; mod source_navigation; mod source_rename; mod source_tree; diff --git a/crates/oa-desktop/src/source_indentation.rs b/crates/oa-desktop/src/source_indentation.rs new file mode 100644 index 00000000..901de20a --- /dev/null +++ b/crates/oa-desktop/src/source_indentation.rs @@ -0,0 +1,94 @@ +//! Source indentation is one workspace edit, with physical line endings intact. +use iced::{ + advanced::{graphics, text}, + widget::text_editor::{Action, Content, Cursor, Edit, Position}, +}; +use std::sync::Arc; + +const INDENT: &str = " "; + +/// Plain Tab inserts one canonical four-space level at a caret, or prefixes +/// every selected line. Shift+Tab removes one leading tab or up to four spaces. +/// A selection ending at column zero excludes that final line. All patches are +/// local to lines, so CRLF, LF and Unicode bytes outside indentation survive. +/// The caller records the resulting buffer once in workspace undo/history. +pub fn apply(content: &mut Content, outdent: bool) +where + R: text::Renderer, +{ + let raw = content.cursor(); + let selected = content.selection().is_some_and(|s| !s.is_empty()); + if !selected && !outdent { + content.perform(Action::Edit(Edit::Paste(Arc::new(INDENT.into())))); + return; + } + // Determine affected lines before normalizing Word/Line mode. Those modes + // expand columns within their endpoint lines; the copied LF count reveals + // whether a zero-column endpoint includes the next line or only its edge. + let anchor = raw.selection.unwrap_or(raw.position); + let first = raw.position.line.min(anchor.line); + let mut last = raw.position.line.max(anchor.line); + if selected { + let selected_text = content.selection().unwrap(); + let line_breaks = selected_text.bytes().filter(|b| *b == b'\n').count(); + // Effective selection starts on the minimum raw endpoint line. + last = first + line_breaks; + if last > first && selected_text.ends_with('\n') { + last -= 1; + } + } + let patches: Vec<_> = (first..=last) + .filter_map(|line| { + let removed = if outdent { + let text = content.line(line)?; + if text.text.starts_with('\t') { + 1 + } else { + text.text.bytes().take(4).take_while(|b| *b == b' ').count() + } + } else { + 0 + }; + (!outdent || removed > 0).then_some((line, removed)) + }) + .collect(); + if patches.is_empty() { + return; + } + let cursor = crate::source_navigation::selection::capture_for_edit(content); + let replacement = Arc::new(if outdent { + String::new() + } else { + INDENT.into() + }); + // Descending order leaves Iced's earliest changed line at the first patch. + for &(line, removed) in patches.iter().rev() { + content.move_to(Cursor { + position: Position { line, column: 0 }, + selection: Some(Position { + line, + column: removed, + }), + }); + content.perform(Action::Edit(Edit::Paste(replacement.clone()))); + } + let transform = |mut position: Position| { + if let Some((_, removed)) = patches.iter().find(|(line, _)| *line == position.line) { + position.column = if outdent { + position.column.saturating_sub(*removed) + } else { + position.column + INDENT.len() + }; + } + position + }; + content.move_to(Cursor { + position: transform(cursor.position), + selection: cursor.selection.map(transform), + }); +} + +#[cfg(all(test, debug_assertions))] +mod keyboard_tests; +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/source_indentation/keyboard_tests.rs b/crates/oa-desktop/src/source_indentation/keyboard_tests.rs new file mode 100644 index 00000000..16121512 --- /dev/null +++ b/crates/oa-desktop/src/source_indentation/keyboard_tests.rs @@ -0,0 +1,105 @@ +//! Real mounted Iced keyboard dispatch, using the production binding function. +use crate::{app::Message, source_navigation::SymbolContext, views::source_tests::FontLayout}; +use iced::{ + Element, Font, Rectangle, Size, + advanced::{Layout, Shell, layout, text, widget as core_widget}, + keyboard::{ + self, Key, Modifiers, + key::{Code, Named, Physical}, + }, + widget::{ + self, + text_editor::{Content, State}, + }, +}; + +#[test] +fn mounted_tab_dispatches_only_for_focused_editable_editor_and_plain_shift_variants() { + for focused in [false, true] { + for editable in [false, true] { + for modifiers in [ + Modifiers::empty(), + Modifiers::SHIFT, + Modifiers::CTRL, + Modifiers::ALT, + Modifiers::LOGO, + ] { + let context = SymbolContext { + session: oa_core::Id::new_v4(), + path: "project.arcadia".into(), + }; + let content = Content::::with_text("rain"); + let mut editor = widget::text_editor(&content) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(iced::Fill) + .key_binding(|press| crate::views::source_binding(press, &context, None)); + if editable { + editor = editor.on_action(Message::SourceEdit); + } + let mut view: Element<'_, Message, iced::Theme, FontLayout> = editor.into(); + let mut tree = core_widget::Tree::new(view.as_widget()); + let size = Size::new(600., 240.); + let node = view.as_widget_mut().layout( + &mut tree, + &FontLayout, + &layout::Limits::new(Size::ZERO, size), + ); + if focused { + tree.state + .downcast_mut::>() + .focus(); + } + let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(Named::Tab), + modified_key: Key::Named(Named::Tab), + physical_key: Physical::Code(Code::Tab), + location: keyboard::Location::Standard, + modifiers, + text: None, + repeat: false, + }); + let mut messages = Vec::new(); + let captured; + { + let mut shell = Shell::new(&mut messages); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(size), + ); + captured = shell.is_event_captured(); + } + let plain = modifiers.is_empty() || modifiers == Modifiers::SHIFT; + let expected = focused && editable && plain; + let indentation: Vec<_> = messages + .iter() + .filter_map(|message| match message { + Message::SourceIndent(emitted, outdent) => Some((emitted, outdent)), + _ => None, + }) + .collect(); + assert_eq!( + indentation.len(), + usize::from(expected), + "focused={focused}, editable={editable}, modifiers={modifiers:?}" + ); + if expected { + assert!(captured, "Tab must not escape to global focus traversal"); + assert_eq!(indentation[0], (&context, &modifiers.shift())); + } else if !focused || !editable { + assert!( + !captured, + "non-editing Tab must remain available to focus traversal" + ); + } + } + } + } +} diff --git a/crates/oa-desktop/src/source_indentation/tests.rs b/crates/oa-desktop/src/source_indentation/tests.rs new file mode 100644 index 00000000..70a17821 --- /dev/null +++ b/crates/oa-desktop/src/source_indentation/tests.rs @@ -0,0 +1,112 @@ +use super::*; +use iced::widget::text_editor::Motion; + +type Buffer = Content; +fn at(line: usize, column: usize) -> Position { + Position { line, column } +} +fn range(start: Position, end: Position, reverse: bool) -> Cursor { + Cursor { + position: if reverse { start } else { end }, + selection: Some(if reverse { end } else { start }), + } +} + +#[test] +fn caret_inserts_four_spaces_and_outdent_removes_only_leading_whitespace() { + let mut content = Buffer::with_text(" 雨🚀\r\nnext"); + content.move_to(Cursor { + position: at(0, " 雨".len()), + selection: None, + }); + apply(&mut content, false); + assert_eq!(content.text(), " 雨 🚀\r\nnext"); + assert_eq!(content.cursor().position, at(0, " 雨 ".len())); + apply(&mut content, true); + assert_eq!(content.text(), "雨 🚀\r\nnext"); + assert_eq!(content.cursor().position, at(0, "雨 ".len())); + apply(&mut content, true); + assert_eq!(content.text(), "雨 🚀\r\nnext"); +} + +#[test] +fn directional_blocks_preserve_unicode_endings_and_exclude_final_zero_column() { + let source = "雨\r\n🚀\nlast\r\n"; + for reverse in [false, true] { + let mut content = Buffer::with_text(source); + let original = range(at(0, 0), at(2, 0), reverse); + content.move_to(original); + apply(&mut content, false); + assert_eq!(content.text(), " 雨\r\n 🚀\nlast\r\n"); + assert_eq!(content.cursor(), range(at(0, 4), at(2, 0), reverse)); + apply(&mut content, true); + assert_eq!(content.text(), source); + assert_eq!(content.cursor(), original); + } +} + +#[test] +fn outdent_handles_tabs_partial_spaces_and_clamps_endpoint_inside_removed_indent() { + for reverse in [false, true] { + let mut content = Buffer::with_text("\t雨\r\n 🚀\n fin"); + content.move_to(range(at(0, 0), at(2, 2), reverse)); + apply(&mut content, true); + assert_eq!(content.text(), "雨\r\n🚀\nfin"); + assert_eq!(content.cursor(), range(at(0, 0), at(2, 0), reverse)); + } +} + +#[test] +fn word_and_line_selections_indent_effective_range_in_both_directions() { + for line_mode in [false, true] { + for reverse in [false, true] { + let mut content = Buffer::with_text("one alpha\r\ntwo beta\nthree gamma"); + content.move_to(Cursor { + position: if reverse { at(2, 8) } else { at(0, 6) }, + selection: None, + }); + content.perform(if line_mode { + Action::SelectLine + } else { + Action::SelectWord + }); + content.perform(Action::Select(if reverse { + Motion::DocumentStart + } else { + Motion::DocumentEnd + })); + let selected = content.selection().unwrap(); + apply(&mut content, false); + assert_eq!( + content.text(), + " one alpha\r\n two beta\n three gamma" + ); + let cursor = content.cursor(); + assert_eq!( + cursor.position.line < cursor.selection.unwrap().line, + reverse + ); + apply(&mut content, true); + assert_eq!(content.text(), "one alpha\r\ntwo beta\nthree gamma"); + assert_eq!(content.selection(), Some(selected)); + } + } +} + +#[test] +fn no_op_outdent_preserves_semantic_word_selection_and_last_empty_line_is_excluded() { + let mut content = Buffer::with_text("alpha tail\r\n"); + content.move_to(Cursor { + position: at(0, 2), + selection: None, + }); + content.perform(Action::SelectWord); + let raw = content.cursor(); + apply(&mut content, true); + assert_eq!(content.cursor(), raw); + assert_eq!(content.selection().as_deref(), Some("alpha")); + content.perform(Action::SelectAll); + apply(&mut content, false); + assert_eq!(content.text(), " alpha tail\r\n"); + assert_eq!(content.cursor().position, at(1, 0)); +} diff --git a/crates/oa-desktop/src/source_navigation/selection.rs b/crates/oa-desktop/src/source_navigation/selection.rs index 40f24b00..27944779 100644 --- a/crates/oa-desktop/src/source_navigation/selection.rs +++ b/crates/oa-desktop/src/source_navigation/selection.rs @@ -1,10 +1,12 @@ -//! Effective selected-byte ranges for a committed departure from a source view. +//! Effective selected-byte ranges for committed source departures and edits. //! //! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw //! endpoints can therefore save an empty range for a visibly selected word/line. -//! Capture only after navigation admission and destination validation succeed, -//! immediately before replacing/leaving the live editor. Never use this to inspect -//! pending queries, failed/no-op navigation, or restore focus to the same editor. +//! Departure capture requires navigation admission and destination validation, +//! immediately before replacing/leaving the live editor. Edit capture requires +//! an admitted edit with a nonempty patch plan. Neither is a passive query: +//! never use these to inspect pending queries, failed/no-op navigation, or restore +//! focus to the same editor. use iced::advanced::{graphics, text}; use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position}; @@ -21,6 +23,23 @@ use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position}; /// selection joins editor lines with LF regardless of physical CRLF/LF endings. /// Only the selected text is temporarily copied; retained state is one Cursor. pub fn capture(content: &mut Content) -> Cursor +where + R: text::Renderer, +{ + effective_range(content) +} + +/// Capture immediately before a committed indentation edit. Like departure, +/// this converts Word/Line selections to their effective directional byte range. +/// The caller restores the transformed range after editing the same Content. +pub fn capture_for_edit(content: &mut Content) -> Cursor +where + R: text::Renderer, +{ + effective_range(content) +} + +fn effective_range(content: &mut Content) -> Cursor where R: text::Renderer, { diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index a355c65c..5f0f88eb 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1178,6 +1178,15 @@ pub(crate) fn source_binding( use crate::source_navigation::{SymbolEvent, query::Action}; use iced::keyboard::{Key, key::Named}; use widget::text_editor::{Binding, Status}; + if matches!(press.status, Status::Focused { .. }) + && press.key == Key::Named(Named::Tab) + && (press.modifiers.is_empty() || press.modifiers == iced::keyboard::Modifiers::SHIFT) + { + return Some(Binding::Custom(Message::SourceIndent( + context.clone(), + press.modifiers.shift(), + ))); + } if matches!(press.status, Status::Focused { .. }) && press.modifiers.is_empty() && press.key == Key::Named(Named::F2)