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
@@ -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));
}