wordcraft/crates/engine/src/cmd/edit.rs

538 lines
22 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.

//! Undo, clipboard, find & replace, Go To, Format Painter.
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, StoryRef};
use super::{delete_selection, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
if s.step_lifts_protection(false) {
return Err(CmdError::Disabled("edit.undo: use Stop Protection to lift the document's protection".into()));
}
s.undo();
sel_result(s)
})
.key("Mod+Z")
.pure(),
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
if s.step_lifts_protection(true) {
return Err(CmdError::Disabled("edit.redo: use Stop Protection to lift the document's protection".into()));
}
s.redo();
sel_result(s)
})
.key("Mod+Y / Mod+Shift+Z")
.pure(),
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
.params(r#"{"text"?: string}"#)
.key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
Ok(json!({"value": on, "count": s.clip_history.len()}))
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips),
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
let i = clip_index(s, v)?;
s.clip_history.items.remove(i);
Ok(s.clip_history.describe())
})
.params(r#"{"index": n (0 = newest)}"#)
.when(has_clips)
.pure(),
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
s.clip_history.items.clear();
Ok(s.clip_history.describe())
})
.pure(),
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
.key("Mod+F")
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
.pure(),
CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#)
.pure(),
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3 / Mod+PageDown").pure(),
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1))
// Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results.
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
.pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
.pure(),
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
s.sel = Selection::caret(p);
s.goal_x = None;
sel_result(s)
})
.key("Shift+F5")
.pure(),
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
painter_pick(s);
sel_result(s)
})
.key("Mod+Shift+C")
.pure(),
CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"),
]
}
fn copy(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
s.clipboard_text = f.plain_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
// A tracked cut of a paragraph or sentence may become a move when pasted.
if !super::moves::cut_tracked(s)? {
delete_selection(s)?;
}
super::controls::restore_placeholder(s)?;
Ok(r)
}
fn paste(s: &mut Session, v: &Value) -> CmdResult {
// An explicit text param (from the system clipboard) wins unless it matches our own copy.
let frag = match (p::str(v, "text"), &s.clipboard) {
(Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(),
(Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")),
(None, Some(f)) => f.clone(),
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
};
super::paste::insert(s, frag)?;
sel_result(s)
}
/// Most items the Clipboard pane keeps.
pub const CLIP_MAX_ITEMS: usize = 24;
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
const CLIP_ITEM_BYTES: usize = 4 << 20;
/// All collected items together stay under this; the oldest go first.
const CLIP_TOTAL_BYTES: usize = 24 << 20;
/// Characters of preview per item.
const CLIP_PREVIEW_CHARS: usize = 120;
/// One item collected by Copy or Cut.
#[derive(Clone, Debug)]
pub struct ClipItem {
pub fragment: Fragment,
/// Its plain text.
pub text: String,
/// Rough memory use.
bytes: usize,
}
impl ClipItem {
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
pub fn preview(&self) -> String {
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
let mut out = String::new();
let mut n = 0;
for w in words {
if !out.is_empty() {
out.push(' ');
n += 1;
}
for c in w.chars() {
if n >= CLIP_PREVIEW_CHARS {
out.push('…');
return out;
}
out.push(c);
n += 1;
}
}
out
}
}
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
#[derive(Clone, Debug, Default)]
pub struct ClipHistory {
items: Vec<ClipItem>,
}
impl ClipHistory {
pub fn items(&self) -> &[ClipItem] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
return;
}
// Copying the same thing again doesn't add a second item.
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
return;
}
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
self.items.truncate(CLIP_MAX_ITEMS);
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
self.items.pop();
}
}
/// The items for agents and the pane, newest first.
fn describe(&self) -> Value {
Value::Array(
self.items
.iter()
.enumerate()
.map(|(i, it)| {
json!({
"index": i,
"preview": it.preview(),
"chars": it.text.chars().count(),
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
})
})
.collect(),
)
}
}
fn has_clips(s: &Session) -> Option<&'static str> {
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
}
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
usize::try_from(i)
.ok()
.filter(|i| *i < s.clip_history.len())
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
}
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
let saved = s.clipboard.replace(f);
let r = paste(s, &json!({}));
s.clipboard = saved;
r
}
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
let i = clip_index(s, v)?;
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
paste_clip(s, f)
}
/// Every collected item, in the order they were copied (one undo step).
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
let mut r = sel_result(s)?;
for f in all {
r = paste_clip(s, f)?;
}
Ok(r)
}
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
let text = match p::str(v, "text") {
Some(t) => t.to_string(),
None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(),
};
super::type_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?;
sel_result(s)
}
/// All matches of the find state in the current story. Text marked deleted by
/// Track Changes is no longer part of the document, so the search reads each
/// paragraph without it: a match lies within one stretch of live text, and word
/// boundaries and anchors see the neighbouring live text, not the deleted text.
/// Otherwise Replace would find its own deleted text again.
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
use regex_automata::{Input, meta, util::syntax};
let f = &s.find;
if f.query.is_empty() {
return Ok(Vec::new());
}
let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) };
let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat };
let re = meta::Regex::builder()
.syntax(syntax::Config::new().case_insensitive(!f.match_case))
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&pat)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let mut out = Vec::new();
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
// The paragraph as it reads, and its stretches of live text as
// (start in `live`, start in the paragraph, length).
let mut live = String::new();
let mut spans: Vec<(usize, usize, usize)> = Vec::new();
for (r, c) in p.run_ranges() {
let Some(t) = p.text.get(r.clone()).filter(|_| c.del.is_none()) else { continue };
match spans.last_mut() {
Some((_, raw, n)) if *raw + *n == r.start => *n += t.len(),
_ => spans.push((live.len(), r.start, t.len())),
}
live.push_str(t);
}
for (at, raw, n) in spans {
for m in re.find_iter(Input::new(&live).range(at..at + n)) {
if m.is_empty() {
continue;
}
let (a, b) = (raw + m.start().saturating_sub(at), raw + m.end().saturating_sub(at));
out.push((Pos { story, path: path.clone(), off: a }, Pos { story, path: path.clone(), off: b }));
if out.len() >= 100_000 {
return Ok(out);
}
}
}
}
Ok(out)
}
fn read_opts(s: &mut Session, v: &Value) {
// A new search changes what Reading Highlight marks.
s.find.highlights = None;
if let Some(t) = p::str(v, "text") {
s.find.query = t.to_string();
}
if let Some(b) = p::bool(v, "matchCase") {
s.find.match_case = b;
}
if let Some(b) = p::bool(v, "wholeWord") {
s.find.whole_word = b;
}
if let Some(b) = p::bool(v, "regex") {
s.find.regex = b;
}
if let Some(t) = p::str(v, "with") {
s.find.replace = t.to_string();
}
}
fn find(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
if p::str(v, "text").is_none() {
s.view.nav_pane = true;
s.ui_requests.push(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
let story = s.sel.focus.story;
s.find.results = search(s, story)?;
// Select the first match after the caret.
let caret = s.sel.focus.clone();
let idx = s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0);
s.find.current = idx;
if let Some((a, b)) = s.find.results.get(idx).cloned() {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({
"count": s.find.results.len(),
"matches": s.find.results.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect::<Vec<_>>(),
}))
}
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
s.ui_requests.push(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
read_opts(s, v);
let within = match p::str(v, "in").unwrap_or("main") {
"main" => None,
"selection" if !s.sel.is_collapsed() => Some(s.sel.ordered()),
"selection" => return Err(CmdError::Params("`in: selection` needs a selection".into())),
other => return Err(CmdError::Params(format!("`in` is main or selection, not `{other}`"))),
};
let story = within.as_ref().map(|(a, _)| a.story).unwrap_or(StoryRef::Body);
let mut found = search(s, story)?;
if let Some((a, b)) = &within {
found.retain(|(x, y)| x >= a && y <= b);
}
if let Some(h) = p::bool(v, "highlight") {
s.find.highlight = h;
s.find.highlights = None;
}
let n = found.len();
let matches: Vec<Value> = found.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect();
s.find.current = 0;
s.find.results = found;
Ok(json!({"count": n, "matches": matches, "highlight": s.find.highlight}))
}
fn step(s: &mut Session, dir: i64) -> CmdResult {
let story = s.sel.focus.story;
s.find.results = search(s, story)?;
let n = s.find.results.len();
if n == 0 {
return Err(CmdError::Failed("no matches".into()));
}
let caret = if dir > 0 { s.sel.ordered().1 } else { s.sel.ordered().0 };
let idx = if dir > 0 {
s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0)
} else {
s.find.results.iter().rposition(|(_, b)| *b <= caret).unwrap_or(n - 1)
};
s.find.current = idx;
if let Some((a, b)) = s.find.results.get(idx).cloned() {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({"index": idx, "count": n}))
}
fn replace(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let (a, b) = s.sel.ordered();
// Replace the current match if selected, then move to the next.
if results.iter().any(|(x, y)| *x == a && *y == b) {
let with = s.find.replace.clone();
super::type_text(s, &with)?;
}
let _ = step(s, 1);
Ok(json!({"remaining": search(s, story)?.len()}))
}
fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let with = s.find.replace.clone();
let n = results.len();
// Replace from the end so earlier positions stay valid.
for (a, b) in results.into_iter().rev() {
if s.doc.settings.track_changes {
// Same path as a single replace, so the change is a reviewable revision.
s.sel = Selection { anchor: a, focus: b };
super::type_text(s, &with)?;
continue;
}
let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default();
s.doc.delete_range(&a, &b)?;
s.doc.insert_text(&a, &with, &props)?;
}
s.status = format!("All done. We made {n} replacements.");
Ok(json!({"replaced": n}))
}
fn goto(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pg) = p::u64(v, "page") {
let l = s.layout();
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
let target = l.pages.get(idx).and_then(|page| {
page.items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => {
para.lines.get(*l0).map(|ln| Pos { story: *story, path: path.clone(), off: ln.start })
}
_ => None,
})
});
if let Some(t) = target {
s.sel = Selection::caret(t);
s.page_hint = idx;
}
s.ui_requests.push(json!({"scrollTo": "caret"}));
return sel_result(s);
}
if let Some(name) = p::str(v, "bookmark") {
let bm = s.doc.bookmarks().into_iter().find(|(n, _)| n == name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?;
s.sel = Selection::caret(bm.1);
return sel_result(s);
}
if let Some(n) = p::u64(v, "paragraph") {
let paths = s.doc.para_paths(StoryRef::Body);
if let Some(path) = paths.get((n.max(1) - 1) as usize) {
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
}
return sel_result(s);
}
s.ui_requests.push(json!({"open": "goto"}));
sel_result(s)
}
fn painter_pick(s: &mut Session) {
let f = s.sel.ordered().0;
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default();
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
s.painter = Some((chr, para, false));
}
fn painter(s: &mut Session, v: &Value) -> CmdResult {
if s.painter.is_some() {
s.painter = None;
return Ok(json!({"active": false}));
}
painter_pick(s);
if let Some((_, _, sticky)) = s.painter.as_mut() {
*sticky = p::bool(v, "sticky").unwrap_or(false);
}
Ok(json!({"active": true}))
}
/// Apply the format painter's formatting to the selection.
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
let (a, b) = s.sel.ordered();
if a == b {
// No selection: apply paragraph formatting (and word formatting like Word).
let f = s.sel.focus.clone();
if let Some(pp) = s.doc.para_at(&f) {
let (x, y) = pp.word_at(f.off);
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
}
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
} else {
s.doc.format_range(&a, &b, &|c| {
let link = c.link.clone();
*c = c.cleared().overlaid(&chr);
c.link = link;
})?;
if a.path != b.path || a.off == 0 {
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
}
}
if !sticky {
s.painter = None;
}
sel_result(s)
}