mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
Rebased onto main as one commit (the branch had merge commits from main). Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
404 lines
15 KiB
Rust
404 lines
15 KiB
Rust
//! Caret movement and selection.
|
||
|
||
use serde_json::Value;
|
||
use wordcraft_doc::{Pos, StoryRef};
|
||
|
||
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.
|
||
CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_right } else { word_left }))
|
||
.key("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("Mod+Right / Alt+Right")
|
||
.params(EXTEND)
|
||
.pure(),
|
||
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(),
|
||
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("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|paragraph", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||
.pure(),
|
||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).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)
|
||
}
|
||
|
||
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
|
||
let ext = extend(v);
|
||
let l = s.layout();
|
||
let from = s.sel.focus.clone();
|
||
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)
|
||
}
|
||
|
||
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();
|
||
s.page_hint = pg as usize;
|
||
l.hit(pg as usize, 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, _: &Value) -> CmdResult {
|
||
let st = s.sel.focus.story;
|
||
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
|
||
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) };
|
||
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.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Unit {
|
||
Word,
|
||
Sentence,
|
||
Paragraph,
|
||
}
|
||
|
||
impl Unit {
|
||
pub fn parse(s: &str) -> Option<Unit> {
|
||
match s {
|
||
"word" => Some(Unit::Word),
|
||
"sentence" => Some(Unit::Sentence),
|
||
"paragraph" => Some(Unit::Paragraph),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The word, sentence or paragraph 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).
|
||
pub fn unit_range(s: &Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
|
||
let (a, b) = match unit {
|
||
Unit::Word => p.word_at(at.off),
|
||
Unit::Sentence => p.sentence_at(at.off),
|
||
Unit::Paragraph => {
|
||
let start = Pos { off: 0, ..at.clone() };
|
||
let end = 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: p.len(), ..at.clone() },
|
||
};
|
||
return (start, end);
|
||
}
|
||
};
|
||
(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 };
|
||
}
|
||
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)
|
||
}
|
||
|
||
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||
select_unit(s, Unit::Paragraph)
|
||
}
|
||
|
||
/// 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 or paragraph".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 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)
|
||
}
|
||
|
||
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
|
||
let l = s.layout();
|
||
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
|
||
s.sel = Selection { anchor: a, focus: b };
|
||
}
|
||
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() } };
|
||
return sel_result(s);
|
||
}
|
||
}
|
||
}
|
||
let _ = StoryRef::Body;
|
||
Err(CmdError::Failed(format!("`{needle}` not found")))
|
||
}
|