Add source caret and block indentation keyboard editing

This commit is contained in:
2026-09-22 11:50:45 -07:00
parent 136ed6e586
commit 3733a657bb
9 changed files with 435 additions and 6 deletions
+11 -2
View File
@@ -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);
@@ -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));
}
@@ -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
+1
View File
@@ -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;
@@ -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<R>(content: &mut Content<R>, outdent: bool)
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
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;
@@ -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::<FontLayout>::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::<State<text::highlighter::PlainText>>()
.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"
);
}
}
}
}
}
@@ -0,0 +1,112 @@
use super::*;
use iced::widget::text_editor::Motion;
type Buffer = Content<iced::Renderer>;
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));
}
@@ -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<R>(content: &mut Content<R>) -> Cursor
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
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<R>(content: &mut Content<R>) -> Cursor
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
effective_range(content)
}
fn effective_range<R>(content: &mut Content<R>) -> Cursor
where
R: text::Renderer<Editor = graphics::text::Editor>,
{
+9
View File
@@ -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)