Add source caret and block indentation keyboard editing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user