openpencil/crates/op-editor-core/src/text_edit.rs
Kayshen-X 38708228e6 Merge remote-tracking branch 'origin/codex/jian-widgets-plan' into v0.8.0-new
# Conflicts:
#	crates/op-editor-core/src/render_backend.rs
#	crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs
#	crates/op-editor-ui/src/widgets/ai_chat_panel/tests_transcript.rs
#	crates/op-editor-ui/src/widgets/variables_panel/geometry.rs
#	crates/op-host-desktop/src/chat_session.rs
#	crates/op-host-native/src/widget_host/scroll.rs
#	crates/op-host-native/src/widget_host/variables_panel_tests.rs
2026-06-14 13:56:56 +08:00

769 lines
28 KiB
Rust

//! Inline canvas text-edit session — the TS editor's styled
//! `<textarea>` overlay (`apps/web/src/canvas/skia/skia-canvas.tsx`)
//! re-implemented as an explicit editing model, since the Rust shell
//! has no DOM to host a native text field.
//!
//! ## Session
//!
//! `start_text_edit` enters the session on a `Text` node and seeds
//! `ui.text_edit_input` with the node's plain content. The
//! `TextInputState` owns the draft string, caret, selection,
//! select-all state, IME composition, and blink anchor; each mutation
//! syncs the draft back into the Text node so the rest of the editor
//! continues reading canonical document content.
//!
//! ## Keys (TS textarea parity)
//!
//! Enter INSERTS a newline (`text_edit_insert("\n", …)`); only
//! Escape and an outside click commit. Arrows move the caret;
//! Shift+arrows extend the selection. Up/Down move by VISUAL line:
//! the host passes the painted wrap's byte ranges and the caret
//! keeps its CHARACTER column — an approximation of "same x" that
//! ignores proportional glyph widths (see
//! [`EditorState::text_edit_caret_vertical`]).
//!
//! ## History
//!
//! To keep a typing burst as one undoable step, history opens
//! lazily: the first mutation (or any mutation after a > 500 ms
//! pause) snapshots the pre-edit state and pushes it. The
//! last-keystroke timestamp lives on `ui.text_edit_last_ms`.
use crate::node_id::NodeId;
use crate::state::EditorState;
use jian_core::text_input::TextInputState;
use jian_ops_schema::node::{PenNode, TextContent};
/// Coalesce window for text-edit history bursts (ms). A pause longer
/// than this opens a fresh undo entry.
const TEXT_COALESCE_MS: u64 = 500;
impl EditorState {
/// Enter inline text-edit mode on `id`. `false` when the node
/// isn't a `Text` node or doesn't exist. The caret seeds at the
/// end of the content; history opens lazily on the first edit.
pub fn start_text_edit(&mut self, id: NodeId) -> bool {
let Some(node) = crate::walkers::find_node(self.active_children(), &id) else {
return false;
};
if !matches!(node, PenNode::Text(_)) {
return false;
}
let content = text_content(node).unwrap_or("").to_owned();
self.ui.text_editing = Some(id);
self.ui.text_edit_input = TextInputState::with_text(content);
self.ui.text_edit_last_ms = 0;
self.ui.pending_text_edit_history = None;
true
}
/// The edited node's plain content, or `None` when no session is
/// active / the node vanished / the content is `Styled`.
pub fn text_edit_content(&self) -> Option<&str> {
let id = self.ui.text_editing.as_ref()?;
let node = crate::walkers::find_node(self.active_children(), id)?;
text_content(node)?;
Some(self.ui.text_edit_input.text())
}
/// Insert `text` at the caret, replacing the active selection (or
/// the whole content after Cmd/Ctrl+A). `now_ms` lets a typing
/// burst coalesce into one undo step. `false` when no session is
/// active or the node has vanished.
pub fn text_edit_insert(&mut self, text: &str, now_ms: u64) -> bool {
let Some(id) = self.text_edit_plain_node_id() else {
return false;
};
let mut input = self.ui.text_edit_input.clone();
input.clear_composition();
let before = input.text().to_owned();
input.insert_str(text, now_ms);
let changed = input.text() != before;
if changed {
self.maybe_open_text_edit_history(now_ms);
}
self.ui.text_edit_input = input;
if changed {
if !self.text_edit_sync_input_to_node(&id) {
return false;
}
self.ui.text_edit_last_ms = now_ms;
}
true
}
/// Delete the selection, or the character BEFORE the caret.
/// `false` when no session is active, the node has vanished, or
/// the caret is already at the start with no selection.
pub fn text_edit_backspace(&mut self, now_ms: u64) -> bool {
self.text_edit_delete(now_ms, false)
}
/// Delete the selection, or the character AFTER the caret (the
/// Delete key's forward deletion). `false` when nothing deletes.
pub fn text_edit_delete_forward(&mut self, now_ms: u64) -> bool {
self.text_edit_delete(now_ms, true)
}
fn text_edit_delete(&mut self, now_ms: u64, forward: bool) -> bool {
let Some(id) = self.text_edit_plain_node_id() else {
return false;
};
let mut input = self.ui.text_edit_input.clone();
input.clear_composition();
let before = input.text().to_owned();
if forward {
input.delete_forward(now_ms);
} else {
input.backspace(now_ms);
}
let changed = input.text() != before;
self.ui.text_edit_input = input;
if !changed {
return false;
}
self.maybe_open_text_edit_history(now_ms);
if !self.text_edit_sync_input_to_node(&id) {
return false;
}
self.ui.text_edit_last_ms = now_ms;
true
}
/// Update the in-flight IME composition without committing it to
/// the Text node. If the composition starts over an active
/// selection, the selection replacement is synced and undoable.
pub fn text_edit_set_composition(&mut self, text: &str, cursor: usize, now_ms: u64) -> bool {
let Some(id) = self.text_edit_plain_node_id() else {
return false;
};
let mut input = self.ui.text_edit_input.clone();
let before = input.text().to_owned();
input.set_composition(text, cursor, now_ms);
let changed = input.text() != before;
if changed {
self.maybe_open_text_edit_history(now_ms);
}
self.ui.text_edit_input = input;
if changed {
if !self.text_edit_sync_input_to_node(&id) {
return false;
}
self.ui.text_edit_last_ms = now_ms;
}
true
}
pub fn text_edit_clear_composition(&mut self) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
}
let had = self.ui.text_edit_input.composition().is_some();
self.ui.text_edit_input.clear_composition();
had
}
pub fn text_edit_commit_composition(&mut self, now_ms: u64) -> bool {
let Some(id) = self.text_edit_plain_node_id() else {
return false;
};
if self.ui.text_edit_input.composition().is_none() {
return false;
}
let mut input = self.ui.text_edit_input.clone();
let before = input.text().to_owned();
input.commit_composition(now_ms);
let changed = input.text() != before;
if changed {
self.maybe_open_text_edit_history(now_ms);
}
self.ui.text_edit_input = input;
if changed {
if !self.text_edit_sync_input_to_node(&id) {
return false;
}
self.ui.text_edit_last_ms = now_ms;
}
true
}
/// Exit text-edit mode. History snapshots were pushed lazily by
/// the editing ops, so commit only clears the session state.
/// `true` when a session was active.
pub fn text_edit_commit(&mut self) -> bool {
self.ui.pending_text_edit_history = None;
self.ui.text_edit_last_ms = 0;
self.ui.text_edit_input = TextInputState::default();
self.ui.text_editing.take().is_some()
}
/// Place the caret at byte `offset` (clamped to a char boundary).
/// `extend == true` keeps / establishes the selection anchor
/// (Shift+click); otherwise the selection collapses.
pub fn text_edit_set_caret(&mut self, offset: usize, extend: bool, now_ms: u64) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
if extend {
self.ui.text_edit_input.drag_to(offset, now_ms);
} else {
self.ui.text_edit_input.set_caret(offset, now_ms);
}
true
}
/// Drag-select: anchor stays at the press offset, caret follows
/// the cursor. Both clamp to char boundaries.
pub fn text_edit_select_range(&mut self, anchor: usize, focus: usize, now_ms: u64) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
self.ui.text_edit_input.set_caret(anchor, now_ms);
self.ui.text_edit_input.drag_to(focus, now_ms);
true
}
/// Select the whole content: anchor 0, caret at the end.
pub fn text_edit_select_all_now(&mut self, now_ms: u64) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
self.ui.text_edit_input.select_all();
self.ui.text_edit_input.touch(now_ms);
true
}
/// Left / Right arrow. With an active selection and no Shift the
/// caret collapses to the selection edge (textarea behaviour);
/// otherwise it moves one char, clamped at the content bounds.
/// `extend` (Shift) keeps / establishes the anchor.
pub fn text_edit_caret_horizontal(&mut self, forward: bool, extend: bool, now_ms: u64) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
if forward {
self.ui.text_edit_input.move_right(extend, now_ms);
} else {
self.ui.text_edit_input.move_left(extend, now_ms);
}
true
}
/// Up / Down arrow — move by VISUAL line through the painted
/// wrap. `line_ranges` are the byte `(start, end)` ranges of each
/// painted line within the content, in paint order (the host
/// derives them from the same wrap the canvas painter uses).
///
/// APPROXIMATION: the caret keeps its CHARACTER column (chars
/// from line start), clamped to the target line's char count —
/// not its pixel x. With proportional fonts or mixed CJK/Latin
/// lines the landing column can differ from a pixel-exact mapping
/// by a few glyphs; in exchange the op stays measurer-free and
/// deterministic. Past the first/last line the caret clamps to
/// the content start/end (textarea behaviour).
pub fn text_edit_caret_vertical(
&mut self,
down: bool,
extend: bool,
line_ranges: &[(usize, usize)],
now_ms: u64,
) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
let content = self.ui.text_edit_input.text().to_owned();
let len = content.len();
let mut caret = self.text_edit_clamped_caret(len);
if !extend {
// Collapse an active selection to the moving edge first.
if let Some((start, end)) = self.text_edit_effective_selection(len) {
caret = if down { end } else { start };
}
}
if line_ranges.is_empty() {
let next = if down { len } else { 0 };
self.text_edit_move_caret_to(next, extend, now_ms);
return true;
}
let line_idx = line_index_for_offset(line_ranges, caret);
let (line_start, _) = line_ranges[line_idx];
let column = content[line_start.min(len)..caret.min(len)].chars().count();
let next = if down {
match line_ranges.get(line_idx + 1) {
Some(range) => offset_at_column(&content, *range, column),
None => len,
}
} else if line_idx == 0 {
0
} else {
offset_at_column(&content, line_ranges[line_idx - 1], column)
};
self.text_edit_move_caret_to(next, extend, now_ms);
true
}
/// Cmd+Left / Cmd+Right — jump to the start / end of the caret's
/// visual line (same `line_ranges` contract as
/// [`Self::text_edit_caret_vertical`]).
pub fn text_edit_line_edge(
&mut self,
forward: bool,
extend: bool,
line_ranges: &[(usize, usize)],
now_ms: u64,
) -> bool {
if self.text_edit_plain_node_id().is_none() {
return false;
};
let len = self.ui.text_edit_input.text().len();
let caret = self.text_edit_clamped_caret(len);
let next = if line_ranges.is_empty() {
if forward {
len
} else {
0
}
} else {
let (start, end) = line_ranges[line_index_for_offset(line_ranges, caret)];
if forward {
end.min(len)
} else {
start.min(len)
}
};
self.text_edit_move_caret_to(next, extend, now_ms);
true
}
fn text_edit_move_caret_to(&mut self, next: usize, extend: bool, now_ms: u64) {
if extend {
self.ui.text_edit_input.drag_to(next, now_ms);
} else {
self.ui.text_edit_input.set_caret(next, now_ms);
}
}
/// Active selection as an ordered byte range. The select-all flag
/// counts as `0..len`; a collapsed anchor==caret pair is `None`.
pub fn text_edit_effective_selection(&self, len: usize) -> Option<(usize, usize)> {
self.ui
.text_edit_input
.highlight_range()
.map(|(start, end)| (start.min(len), end.min(len)))
.filter(|(start, end)| start != end)
}
/// The caret byte offset clamped to `len`.
fn text_edit_clamped_caret(&self, len: usize) -> usize {
self.ui.text_edit_input.caret().min(len)
}
fn text_edit_plain_node_id(&self) -> Option<NodeId> {
let id = self.ui.text_editing.as_ref()?;
let node = crate::walkers::find_node(self.active_children(), id)?;
text_content(node)?;
Some(id.clone())
}
fn text_edit_sync_input_to_node(&mut self, id: &NodeId) -> bool {
let text = self.ui.text_edit_input.text().to_owned();
let Some(content) = self.text_edit_content_mut(id) else {
return false;
};
*content = text;
true
}
fn text_edit_content_mut(&mut self, id: &NodeId) -> Option<&mut String> {
let node = crate::walkers::find_node_mut(self.active_children_mut(), id)?;
text_content_node_mut(node)
}
/// Open a fresh history burst when the first keystroke since the
/// session started, or > 500 ms since the last one. Snapshots the
/// pre-mutation state and pushes it onto the undo stack.
fn maybe_open_text_edit_history(&mut self, now_ms: u64) {
let last = self.ui.text_edit_last_ms;
let elapsed = now_ms.saturating_sub(last);
if last == 0 || elapsed > TEXT_COALESCE_MS {
let snap = self.snapshot_for_history();
self.ui.pending_text_edit_history = None;
self.history_push_past(snap);
}
}
}
// --- Free helpers ----------------------------------------------------
/// Read-only handle to a `Text` node's plain content. `None` for a
/// non-Text node or a `Styled` content variant (the inline editor
/// only handles plain text).
fn text_content(node: &PenNode) -> Option<&str> {
match node {
PenNode::Text(t) => match &t.content {
TextContent::Plain(s) => Some(s),
TextContent::Styled(_) => None,
},
_ => None,
}
}
/// Mutable counterpart of [`text_content`].
fn text_content_node_mut(node: &mut PenNode) -> Option<&mut String> {
match node {
PenNode::Text(t) => match &mut t.content {
TextContent::Plain(s) => Some(s),
TextContent::Styled(_) => None,
},
_ => None,
}
}
/// Index of the visual line containing byte `offset`. An offset in
/// the gap between two lines (a wrap-dropped space or a `\n`) counts
/// as the EARLIER line's end; offsets past the last line clamp to it.
fn line_index_for_offset(line_ranges: &[(usize, usize)], offset: usize) -> usize {
for (i, (start, end)) in line_ranges.iter().enumerate() {
if offset < *start {
// In the separator gap before this line — belongs to the
// previous line's end.
return i.saturating_sub(1);
}
if offset <= *end {
return i;
}
}
line_ranges.len().saturating_sub(1)
}
/// Byte offset of the `column`-th character within `range` of
/// `content`, clamped to the range end.
fn offset_at_column(content: &str, range: (usize, usize), column: usize) -> usize {
let (start, end) = (range.0.min(content.len()), range.1.min(content.len()));
let line = &content[start..end];
for (taken, (i, _)) in line.char_indices().enumerate() {
if taken == column {
return start + i;
}
}
end
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{rect, state_with, text};
fn doc() -> EditorState {
state_with(vec![
rect("n1", "Rectangle", 0.0, 0.0, 40.0, 30.0),
text("n2", "Label", 0.0, 50.0, 100.0, 20.0, "Hi"),
])
}
fn content(s: &EditorState) -> String {
let node =
crate::walkers::find_node(s.active_children(), &NodeId::new("n2")).expect("text node");
match node {
PenNode::Text(t) => match &t.content {
TextContent::Plain(p) => p.clone(),
TextContent::Styled(_) => panic!("styled"),
},
_ => panic!("not text"),
}
}
fn edit(initial: &str) -> EditorState {
let mut s = state_with(vec![text("n2", "Label", 0.0, 50.0, 100.0, 20.0, initial)]);
assert!(s.start_text_edit(NodeId::new("n2")));
s
}
#[test]
fn text_edit_uses_shared_text_input_state_for_draft_and_selection() {
let mut s = edit("old text");
assert_eq!(s.ui.text_edit_input.text(), "old text");
assert_eq!(s.ui.text_edit_input.caret(), "old text".len());
assert!(s.text_edit_select_all_now(0));
assert!(s.ui.text_edit_input.is_select_all());
assert!(s.text_edit_insert("new", 100));
assert_eq!(content(&s), "new");
assert_eq!(s.ui.text_edit_input.text(), "new");
assert_eq!(s.ui.text_edit_input.caret(), 3);
}
// --- Insert / delete ---------------------------------------------
#[test]
fn insert_lands_at_end_after_start() {
let mut s = doc();
assert!(s.start_text_edit(NodeId::new("n2")));
assert_eq!(s.ui.text_edit_input.caret(), 2); // after "Hi"
assert!(s.text_edit_insert("!", 100));
assert_eq!(content(&s), "Hi!");
assert_eq!(s.ui.text_edit_input.caret(), 3);
assert!(s.text_edit_backspace(150));
assert_eq!(content(&s), "Hi");
assert!(s.text_edit_commit());
assert!(s.ui.text_editing.is_none());
}
#[test]
fn insert_at_mid_caret() {
let mut s = edit("Hi");
assert!(s.text_edit_caret_horizontal(false, false, 0)); // caret 1
assert!(s.text_edit_insert("X", 100));
assert_eq!(content(&s), "HXi");
assert_eq!(s.ui.text_edit_input.caret(), 2);
}
#[test]
fn backspace_at_mid_caret_removes_prev_char() {
let mut s = edit("abc");
assert!(s.text_edit_set_caret(2, false, 0));
assert!(s.text_edit_backspace(100));
assert_eq!(content(&s), "ac");
assert_eq!(s.ui.text_edit_input.caret(), 1);
// At the start — nothing to remove.
assert!(s.text_edit_set_caret(0, false, 0));
assert!(!s.text_edit_backspace(150));
assert_eq!(content(&s), "ac");
}
#[test]
fn delete_forward_removes_next_char() {
let mut s = edit("abc");
assert!(s.text_edit_set_caret(1, false, 0));
assert!(s.text_edit_delete_forward(100));
assert_eq!(content(&s), "ac");
assert_eq!(s.ui.text_edit_input.caret(), 1);
assert!(s.text_edit_set_caret(2, false, 0));
assert!(!s.text_edit_delete_forward(150), "at end — no-op");
}
#[test]
fn insert_replaces_active_selection() {
let mut s = edit("hello world");
assert!(s.text_edit_select_range(6, 11, 0)); // "world"
assert!(s.text_edit_insert("there", 100));
assert_eq!(content(&s), "hello there");
assert_eq!(s.ui.text_edit_input.caret(), 11);
assert_eq!(s.ui.text_edit_input.highlight_range(), None);
}
#[test]
fn backspace_deletes_selection_as_one_unit() {
let mut s = edit("hello world");
assert!(s.text_edit_select_range(11, 5, 0)); // reversed order
assert!(s.text_edit_backspace(100));
assert_eq!(content(&s), "hello");
assert_eq!(s.ui.text_edit_input.caret(), 5);
}
#[test]
fn newline_inserts_instead_of_committing() {
let mut s = edit("ab");
assert!(s.text_edit_set_caret(1, false, 0));
assert!(s.text_edit_insert("\n", 100));
assert_eq!(content(&s), "a\nb");
assert!(s.ui.text_editing.is_some(), "session stays open");
assert_eq!(s.ui.text_edit_input.caret(), 2);
}
#[test]
fn select_all_then_insert_replaces_everything() {
let mut s = edit("old text");
assert!(s.text_edit_select_all_now(0));
assert!(s.ui.text_edit_input.is_select_all());
assert_eq!(
s.ui.text_edit_input.selection().ordered(),
(0, "old text".len())
);
assert!(s.text_edit_insert("n", 100));
assert_eq!(content(&s), "n");
assert!(!s.ui.text_edit_input.is_select_all());
}
#[test]
fn select_all_then_backspace_clears() {
let mut s = edit("old text");
assert!(s.text_edit_select_all_now(0));
assert!(s.text_edit_backspace(100));
assert_eq!(content(&s), "");
}
#[test]
fn cjk_caret_moves_on_char_boundaries() {
let mut s = edit("首页a");
assert!(s.text_edit_caret_horizontal(false, false, 0)); // before 'a' (byte 6)
assert_eq!(s.ui.text_edit_input.caret(), 6);
assert!(s.text_edit_caret_horizontal(false, false, 0)); // byte 3
assert_eq!(s.ui.text_edit_input.caret(), 3);
assert!(s.text_edit_insert("X", 100));
assert_eq!(content(&s), "首X页a");
}
// --- Horizontal caret --------------------------------------------
#[test]
fn horizontal_arrows_clamp_at_bounds() {
let mut s = edit("ab");
for _ in 0..5 {
assert!(s.text_edit_caret_horizontal(false, false, 0));
}
assert_eq!(s.ui.text_edit_input.caret(), 0);
for _ in 0..5 {
assert!(s.text_edit_caret_horizontal(true, false, 0));
}
assert_eq!(s.ui.text_edit_input.caret(), 2);
}
#[test]
fn plain_arrow_collapses_selection_to_edge() {
let mut s = edit("hello");
assert!(s.text_edit_select_range(1, 4, 0));
assert!(s.text_edit_caret_horizontal(false, false, 0));
assert_eq!(s.ui.text_edit_input.caret(), 1, "left collapses to start");
assert_eq!(s.ui.text_edit_input.highlight_range(), None);
assert!(s.text_edit_select_range(1, 4, 0));
assert!(s.text_edit_caret_horizontal(true, false, 0));
assert_eq!(s.ui.text_edit_input.caret(), 4, "right collapses to end");
}
#[test]
fn shift_arrows_extend_then_collapse_selection() {
let mut s = edit("abcd");
assert!(s.text_edit_set_caret(1, false, 0));
assert!(s.text_edit_caret_horizontal(true, true, 0));
assert!(s.text_edit_caret_horizontal(true, true, 0));
assert_eq!(s.ui.text_edit_input.selection().anchor, 1);
assert_eq!(s.ui.text_edit_input.caret(), 3);
assert_eq!(s.text_edit_effective_selection(4), Some((1, 3)));
// Shrinking back to the anchor drops the selection.
assert!(s.text_edit_caret_horizontal(false, true, 0));
assert!(s.text_edit_caret_horizontal(false, true, 0));
assert_eq!(s.ui.text_edit_input.highlight_range(), None);
assert_eq!(s.text_edit_effective_selection(4), None);
}
// --- Vertical caret (visual lines) ---------------------------------
/// "hello\nworld wide\nhi" painted as three lines. The byte
/// ranges skip the `\n` separators, mirroring the wrap mapping
/// the host derives from the painted lines.
const THREE_LINES: &[(usize, usize)] = &[(0, 5), (6, 16), (17, 19)];
#[test]
fn vertical_down_keeps_char_column() {
let mut s = edit("hello\nworld wide\nhi");
assert!(s.text_edit_set_caret(3, false, 0)); // line 0, col 3
assert!(s.text_edit_caret_vertical(true, false, THREE_LINES, 0));
assert_eq!(s.ui.text_edit_input.caret(), 9, "line 1 col 3");
assert!(s.text_edit_caret_vertical(true, false, THREE_LINES, 0));
// Line 2 has 2 chars — clamps to its end.
assert_eq!(s.ui.text_edit_input.caret(), 19);
}
#[test]
fn vertical_up_from_first_line_goes_to_start() {
let mut s = edit("hello\nworld");
let ranges = &[(0, 5), (6, 11)];
assert!(s.text_edit_set_caret(3, false, 0));
assert!(s.text_edit_caret_vertical(false, false, ranges, 0));
assert_eq!(s.ui.text_edit_input.caret(), 0);
}
#[test]
fn vertical_down_past_last_line_goes_to_end() {
let mut s = edit("hello\nworld");
let ranges = &[(0, 5), (6, 11)];
assert!(s.text_edit_set_caret(8, false, 0)); // line 1
assert!(s.text_edit_caret_vertical(true, false, ranges, 0));
assert_eq!(s.ui.text_edit_input.caret(), 11);
}
#[test]
fn vertical_with_shift_extends_selection() {
let mut s = edit("hello\nworld wide\nhi");
assert!(s.text_edit_set_caret(2, false, 0));
assert!(s.text_edit_caret_vertical(true, true, THREE_LINES, 0));
assert_eq!(s.ui.text_edit_input.selection().anchor, 2);
assert_eq!(s.ui.text_edit_input.caret(), 8);
assert_eq!(s.text_edit_effective_selection(19), Some((2, 8)));
}
#[test]
fn vertical_caret_in_separator_gap_maps_to_earlier_line() {
let mut s = edit("hello\nworld");
let ranges = &[(0, 5), (6, 11)];
// Caret at byte 5 == end of line 0 (the `\n` gap edge).
assert!(s.text_edit_set_caret(5, false, 0));
assert!(s.text_edit_caret_vertical(true, false, ranges, 0));
assert_eq!(s.ui.text_edit_input.caret(), 11, "line 1 col 5 == its end");
}
#[test]
fn line_edge_jumps_to_line_start_and_end() {
let mut s = edit("hello\nworld wide");
let ranges = &[(0, 5), (6, 16)];
assert!(s.text_edit_set_caret(9, false, 0)); // line 1, col 3
assert!(s.text_edit_line_edge(false, false, ranges, 0));
assert_eq!(s.ui.text_edit_input.caret(), 6);
assert!(s.text_edit_line_edge(true, false, ranges, 0));
assert_eq!(s.ui.text_edit_input.caret(), 16);
}
// --- Session / history ---------------------------------------------
#[test]
fn text_edit_rejects_non_text_node() {
let mut s = doc();
assert!(!s.start_text_edit(NodeId::new("n1")));
assert!(!s.text_edit_insert("x", 0));
}
#[test]
fn text_edit_history_coalesces_within_window() {
let mut s = doc();
assert!(s.start_text_edit(NodeId::new("n2")));
// First keystroke opens one history entry.
s.text_edit_insert("a", 100);
assert_eq!(s.history.past.len(), 1);
// Within 500 ms — no new entry.
s.text_edit_insert("b", 300);
assert_eq!(s.history.past.len(), 1);
// After a > 500 ms pause — a fresh entry.
s.text_edit_insert("c", 1000);
assert_eq!(s.history.past.len(), 2);
}
#[test]
fn text_edit_undo_restores_pre_session_text() {
let mut s = doc();
assert!(s.start_text_edit(NodeId::new("n2")));
s.text_edit_insert("!!!", 100);
assert!(s.text_edit_commit());
assert!(s.undo());
assert_eq!(content(&s), "Hi");
}
#[test]
fn caret_moves_push_no_history() {
let mut s = edit("hello");
assert!(s.text_edit_caret_horizontal(false, false, 0));
assert!(s.text_edit_set_caret(2, false, 0));
assert!(s.text_edit_caret_vertical(true, false, &[(0, 5)], 0));
assert_eq!(s.history.past.len(), 0);
}
#[test]
fn click_set_caret_with_shift_extends() {
let mut s = edit("hello");
assert!(s.text_edit_set_caret(1, false, 0));
assert!(s.text_edit_set_caret(4, true, 0));
assert_eq!(s.text_edit_effective_selection(5), Some((1, 4)));
}
}