Files
wordcraft/crates/engine/src/cmd/caret.rs
T
Aritz VelascoandClaude Opus 5.5 746577406c Links: Ctrl+K on a selection shows the changed Text to display (#5497)
* Ctrl+K on a selection shows the changed Text to display

With text selected, the Link dialog's Text to display was ignored:
insert.link only linked the selection. A changed text now retypes the
selection (keeping its formatting, tracked with Track Changes on) and
links it. The same text, text with a line break, or a selection over
several paragraphs or holding a picture or field keeps its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Links: keep selections a changed Text to display can't replace

Review of the Ctrl+K retype: a selection holding a manual line break,
page or column break, or sitting in a field's result is only linked, so
neither both lines nor the field's result are lost. `select.text` clears
the caret formatting as `select.word` does, so a second retyped link
keeps its selection's bold instead of the first link's default props.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Selections: select.all, select.range and select.word drop pending formatting

A link leaves plain formatting pending for what is typed after it. These
commands kept it, so retyping the next selection took that instead of the
selection's own formatting, as select.text and select.word's caret moves
already avoid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:51:45 +01:00

502 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Caret movement and selection.
use serde_json::Value;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_layout::ViewMode;
use super::{parse_pos, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
const EXTEND: &str = r#"{"extend"?: bool}"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("caret.left", "Left", "Navigation", |s, v| lr(s, v, true)).key("Left").params(EXTEND).pure(),
CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(),
CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(),
CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(),
// In a right-to-left paragraph the word to the left is the next one. On macOS ⌘←/⌘→ go
// to the line's start and end, as in Word for Mac and every Mac text field (#954).
CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_right } else { word_left }))
.key(super::mac_or("Alt+Left", "Mod+Left / Alt+Left"))
.params(EXTEND)
.pure(),
CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_left } else { word_right }))
.key(super::mac_or("Alt+Right", "Mod+Right / Alt+Right"))
.params(EXTEND)
.pure(),
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true))
.key(super::mac_or("Home / Mod+Left", "Home"))
.params(EXTEND)
.pure(),
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false))
.key(super::mac_or("End / Mod+Right", "End"))
.params(EXTEND)
.pure(),
CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(),
CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down))
.key("Mod+Down / Alt+Down")
.params(EXTEND)
.pure(),
CommandSpec::new("caret.docStart", "Document Start", "Navigation", |s, v| mv(s, v, |s, _| s.doc.start_of(s.sel.focus.story)))
.key("Mod+Home")
.params(EXTEND)
.pure(),
CommandSpec::new("caret.docEnd", "Document End", "Navigation", |s, v| mv(s, v, |s, _| s.doc.end_of(s.sel.focus.story)))
.key("Mod+End")
.params(EXTEND)
.pure(),
CommandSpec::new("caret.pageUp", "Page Up", "Navigation", |s, v| page(s, v, -1)).key("PageUp").params(EXTEND).pure(),
CommandSpec::new("caret.pageDown", "Page Down", "Navigation", |s, v| page(s, v, 1)).key("PageDown").params(EXTEND).pure(),
CommandSpec::new("caret.set", "Set Caret", "Navigation", set)
.params(r#"{"pos": Pos, "extend"?: bool} | {"page": n, "x": pt, "y": pt}"#)
.pure(),
CommandSpec::new("select.all", "Select All", "Home › Editing › Select", select_all).key("Mod+A").pure(),
CommandSpec::new("select.range", "Select Range", "Navigation", select_range).params(r#"{"anchor": Pos, "focus": Pos}"#).pure(),
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
.pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).pure(),
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
let to_end = p::bool(v, "end").unwrap_or(false);
let (a, b) = s.sel.ordered();
s.sel = Selection::caret(if to_end { b } else { a });
sel_result(s)
})
.key("Escape")
.pure(),
]
}
fn extend(v: &Value) -> bool {
p::bool(v, "extend").unwrap_or(false)
}
fn apply(s: &mut Session, to: Pos, ext: bool) {
if ext {
s.sel.focus = to;
} else {
s.sel = Selection::caret(to);
}
s.pending = None;
}
fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult {
let ext = extend(v);
s.goal_x = None;
let from = s.sel.focus.clone();
let to = f(s, &from);
apply(s, to, ext);
sel_result(s)
}
/// Does the caret's paragraph read right to left?
fn rtl_para(s: &Session) -> bool {
s.doc.para_at(&s.sel.focus).is_some_and(|p| s.doc.styles.resolve_para(&p.props).bidi)
}
/// Is the caret's paragraph bidirectional (right to left, or with right-to-left text)? Arrow keys
/// move visually there.
fn bidi_para(s: &Session) -> bool {
s.doc.para_at(&s.sel.focus).is_some_and(|p| wordcraft_doc::bidi::has_rtl(&p.text) || s.doc.styles.resolve_para(&p.props).bidi)
}
/// Left/Right: with a selection and no Shift, collapse to its edge.
fn lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult {
if !extend(v) && !s.sel.is_collapsed() {
let (a, b) = s.sel.ordered();
// The left edge of a selection in right-to-left text is its logical end.
let to_start = is_left != rtl_para(s);
s.sel = Selection::caret(if to_start { a } else { b });
s.goal_x = None;
return sel_result(s);
}
if bidi_para(s) {
return visual_lr(s, v, is_left);
}
mv(s, v, if is_left { left } else { right })
}
/// Left/Right in bidirectional text: one caret position to the left or right on the screen.
/// Past the line's visual end the caret goes to the neighbouring line in reading order (the
/// previous line when moving toward the paragraph's start edge).
fn visual_lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult {
let ext = extend(v);
s.goal_x = None;
let from = s.sel.focus.clone();
let l = s.layout();
let to = match l.visual_step(&from, is_left, s.page_hint) {
Some(wordcraft_layout::VisualStep::Moved(p)) => p,
Some(wordcraft_layout::VisualStep::Edge { start, stop, last_line, rtl }) => {
let at = |off| Pos { off, ..from.clone() };
if is_left != rtl {
match s.doc.para_at(&from) {
Some(para) if start > 0 => at(para.prev_boundary(start)),
_ => left(s, &at(0)),
}
} else if !last_line {
at(stop)
} else {
let len = s.doc.para_at(&from).map_or(stop, |p| p.len());
right(s, &at(len))
}
}
None if is_left => left(s, &from),
None => right(s, &from),
};
apply(s, to, ext);
sel_result(s)
}
fn left(s: &mut Session, p: &Pos) -> Pos {
if let Some(para) = s.doc.para_at(p)
&& p.off > 0
{
return Pos { off: para.prev_boundary(p.off), ..p.clone() };
}
match s.doc.prev_para(p.story, &p.path) {
Some(q) => {
let off = s.doc.para(p.story, &q).map(|x| x.len()).unwrap_or(0);
Pos { story: p.story, path: q, off }
}
None => p.clone(),
}
}
fn right(s: &mut Session, p: &Pos) -> Pos {
if let Some(para) = s.doc.para_at(p)
&& p.off < para.len()
{
return Pos { off: para.next_boundary(p.off), ..p.clone() };
}
match s.doc.next_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => p.clone(),
}
}
fn word_left(s: &mut Session, p: &Pos) -> Pos {
match s.doc.para_at(p) {
Some(para) if p.off > 0 => Pos { off: para.word_start(p.off), ..p.clone() },
_ => left(s, p),
}
}
fn word_right(s: &mut Session, p: &Pos) -> Pos {
match s.doc.para_at(p) {
Some(para) if p.off < para.len() => Pos { off: para.word_end(p.off), ..p.clone() },
_ => right(s, p),
}
}
fn para_up(s: &mut Session, p: &Pos) -> Pos {
if p.off > 0 {
return Pos { off: 0, ..p.clone() };
}
match s.doc.prev_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => p.clone(),
}
}
fn para_down(s: &mut Session, p: &Pos) -> Pos {
match s.doc.next_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => {
let off = s.doc.para_at(p).map(|x| x.len()).unwrap_or(0);
Pos { off, ..p.clone() }
}
}
}
fn vert(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
let to = match l.vertical(&from, s.goal_x, dir, s.page_hint) {
Some((p, gx)) => {
s.goal_x = Some(gx);
p
}
None => {
// Top/bottom of the story: go to its start/end.
if dir < 0 { s.doc.start_of(from.story) } else { s.doc.end_of(from.story) }
}
};
if let Some(c) = l.caret_on(&to, s.page_hint) {
s.page_hint = c.page;
}
let gx = s.goal_x;
apply(s, to, ext);
s.goal_x = gx;
sel_result(s)
}
fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
let to = match l.line_bounds(&from, s.page_hint) {
Some((a, b)) => {
if home {
a
} else {
b
}
}
None => from,
};
s.goal_x = None;
apply(s, to, ext);
sel_result(s)
}
/// Page Up (`dir < 0`) / Page Down: the same place on the page before or after, or in the views
/// that are one long page, the line a printed page's text above or below.
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
// Web Layout, Draft and Outline lay the document out as one long page (#5290).
if !s.view.read_mode && matches!(s.view.mode, ViewMode::Web | ViewMode::Draft | ViewMode::Outline) {
// A selection can end in text a collapsed outline heading hides: page from where it shows.
let at = if l.caret_on(&from, s.page_hint).is_none() { super::outline::shown_pos(s, &from, false) } else { None };
let at = at.unwrap_or_else(|| from.clone());
let Some(c) = l.caret_on(&at, s.page_hint) else { return sel_result(s) };
let page_h = l.pages.get(c.page).map_or(0.0, |p| p.h);
let y = (c.top + 1.0 + dir as f32 * page_step(s, &at)).min(page_h).max(0.0);
let to = l.line_toward(&at, s.page_hint, c.x, y, dir).unwrap_or(from);
apply(s, to, ext);
return sel_result(s);
}
let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };
let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };
let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);
s.page_hint = np;
apply(s, to, ext);
sel_result(s)
}
/// How far Page Up/Down move in the continuous views: the height of the text area of the
/// section `at` is in (the first section when `at` isn't in the body), at least an inch.
fn page_step(s: &Session, at: &Pos) -> f32 {
let block = if at.story == StoryRef::Body { at.path.0.first().map_or(0, |&i| i as usize) } else { 0 };
let sections = s.doc.sections();
let sect = sections.iter().find(|(end, _)| *end >= block).or(sections.last()).map(|(_, p)| *p);
let body = sect.map_or(0.0, |p| p.page_h - p.margin_top.abs() - p.margin_bottom.abs());
if body.is_finite() { body.max(72.0) } else { 72.0 }
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let ext = extend(v);
let to = if let Some(pv) = v.get("pos") {
parse_pos(pv).ok_or_else(|| CmdError::Params("bad `pos`".into()))?
} else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
let story = super::story_param(s, v);
let l = s.layout();
let page = usize::try_from(pg).unwrap_or(usize::MAX);
s.page_hint = page;
l.hit(page, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
} else {
return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into()));
};
let to = s.doc.clamp(&to);
s.goal_x = None;
apply(s, to, ext);
sel_result(s)
}
fn select_all(s: &mut Session, v: &Value) -> CmdResult {
if s.selects_whole_story() {
s.column = None;
s.column_mode = false;
return sel_result(s);
}
if !p::bool(v, "whole").unwrap_or(false)
&& let Some(r) = super::table::grow_select_all(s)
{
return r;
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
}
fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
/// selection bar (the left margin) selects a line; triple-click there the whole document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unit {
Word,
Sentence,
Line,
Paragraph,
Document,
}
impl Unit {
pub fn parse(s: &str) -> Option<Unit> {
match s {
"word" => Some(Unit::Word),
"sentence" => Some(Unit::Sentence),
"line" => Some(Unit::Line),
"paragraph" => Some(Unit::Paragraph),
"document" => Some(Unit::Document),
_ => None,
}
}
}
/// The word, sentence, line, paragraph or story around `at`, as (start, end). A paragraph
/// includes its mark (it ends at the start of the next paragraph in the same container, when
/// there is one), and so does its last line.
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
let len = p.len();
let mark_end = |s: &Session| match s.doc.next_para(at.story, &at.path) {
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
_ => Pos { off: len, ..at.clone() },
};
let (a, b) = match unit {
Unit::Word => p.word_at(at.off),
Unit::Sentence => p.sentence_at(at.off),
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
Unit::Line => {
let hint = s.page_hint;
return match s.layout().line_span(at, hint) {
Some((a, _, true)) => (a, mark_end(s)),
Some((a, b, false)) => (a, b),
None => (at.clone(), at.clone()),
};
}
};
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
}
fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
let f = s.sel.focus.clone();
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Word)
}
fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Sentence)
}
/// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word
/// takes its end mark too, which shades it), so its borders then move on their own
/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected.
fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult {
let r = select_unit(s, Unit::Paragraph);
if v.get("cell").and_then(Value::as_bool) != Some(true) {
return r;
}
let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone());
let n = a.path.0.len();
if a.story == b.story
&& a.path.0.len() == b.path.0.len()
&& let Some(tp) = a.path.0.get(..n.saturating_sub(3))
&& !tp.is_empty()
&& a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1))
&& let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2)))
{
let tp = wordcraft_doc::Path(tp.to_vec());
let (row, cell) = (row as usize, cell as usize);
let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1));
let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l));
let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell));
if let (true, Some(cols)) = (only, grid) {
return super::table::select_range(s, a.story, &tp, (row, row), cols);
}
}
r
}
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pos) = v.get("pos").and_then(parse_pos) {
s.sel = Selection::caret(s.doc.clamp(&pos));
}
select_unit(s, Unit::Line)
}
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
/// anchors at its end, as in Word.
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
let unit = p::str(v, "unit")
.and_then(Unit::parse)
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".into()))?;
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
let (start, end) = if start <= end { (start, end) } else { (end, start) };
// Only within one story (the pointer over another story keeps the selection).
if at.story != start.story || end.story != start.story {
return sel_result(s);
}
let (us, ue) = unit_range(s, &at, unit);
s.sel = if unit == Unit::Document {
Selection { anchor: us, focus: ue }
} else if at >= start {
Selection { focus: ue.max(end.clone()), anchor: start }
} else {
Selection { focus: us.min(start), anchor: end }
};
s.goal_x = None;
s.pending = None;
sel_result(s)
}
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
let needle = p::req_str(v, "text")?;
let n = p::u64(v, "occurrence").unwrap_or(1).max(1) as usize;
if needle.is_empty() {
return Err(CmdError::Params("`text` is empty".into()));
}
let mut count = 0;
let story = super::story_param(s, v);
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
for (i, _) in p.text.match_indices(needle) {
count += 1;
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
}
let _ = StoryRef::Body;
Err(CmdError::Failed(format!("`{needle}` not found")))
}