Files
wordcraft/crates/engine/src/cmd/tools/actions.rs
T
Martin BallandClaude Sonnet 5.5 c45945a2ea CP4.3: Options > Language: Your languages with proofing status, Add a language picker, Set as default
The Dictionaries folder moves here from Proofing with the Dictionary files list (#1063) kept. Set as
default sets only the saved default proofing language (the new pure review.language.preferred command), and
proof.packs returns the languages rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 23:59:42 +00:00

344 lines
15 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.
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
/// Options › Language › Set as default: only the saved default proofing language (what future
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
/// document, the selection, the undo history and the modified flag are left alone.
pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
let t = p::str(v, "lang").ok_or_else(|| CmdError::Params("`lang` is required".into()))?;
let l = wordcraft_proof::lang::normalize_tag(t)
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?;
s.prefs.default_language = Some(l.clone());
Ok(json!({"preferred": l}))
}
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?,
),
None => None,
};
let no_proof = p::bool(v, "noProof");
let detect = p::bool(v, "detect");
if let Some(d) = detect {
s.prefs.detect_language = d;
}
if p::bool(v, "default") == Some(true) {
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
s.doc.styles.default_chr.lang = Some(l.clone());
// A Normal style that names its own language follows the new default.
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
&& n.chr.lang.is_some()
{
n.chr.lang = Some(l);
}
}
let lang = match (lang, no_proof, detect) {
(None, None, None) => Some("en-US".to_string()),
(l, ..) => l,
};
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = crate::cmd::format::apply(s, &|c| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
pub(super) fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
let steps: Vec<(String, Value)> = d
.get("steps")
.and_then(Value::as_array)
.map(|a| {
a.iter().filter_map(|st| Some((st.get("command")?.as_str()?.to_string(), st.get("params").cloned().unwrap_or(json!({}))))).collect()
})
.unwrap_or_default();
s.macros.insert(name, steps);
}
if let Some(n) = p::str(v, "delete") {
s.macros.remove(n);
}
if let Some(n) = p::str(v, "run") {
let steps = s.macros.get(n).cloned().ok_or_else(|| CmdError::Params(format!("no macro `{n}`")))?;
for (id, params) in steps.iter().take(10_000) {
if id == "tools.macros" {
continue;
}
s.run(id, params)?;
}
return Ok(json!({"ran": n, "steps": steps.len()}));
}
Ok(Value::Object(
s.macros.iter().map(|(k, v)| (k.clone(), json!(v.iter().map(|(c, p)| json!({"command": c, "params": p})).collect::<Vec<_>>()))).collect(),
))
}
/// Insert › Quick Parts and AutoText: this session's building blocks, and the document's own
/// (`Document::glossary`, kept in its file) in `gallery`. A name is looked up in the session's
/// first, then in the document's (`gallery` first).
pub(super) fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResult {
let in_document = p::str(v, "in") == Some("document");
if let Some(name) = p::str(v, "save") {
let (a, b) = s.sel.ordered();
if a == b {
return Err(CmdError::Params("select the content to save".into()));
}
let frag = s.doc.copy_range(&a, &b);
if in_document {
if name.trim().is_empty() {
return Err(CmdError::Params("a building block needs a name".into()));
}
let blocks: wordcraft_doc::Blocks = frag.blocks.into_iter().map(Arc::new).collect();
match s.doc.glossary.iter_mut().find(|e| e.name == name && e.gallery == gallery) {
Some(e) => e.blocks = blocks,
None => s.doc.glossary.push(wordcraft_doc::BuildingBlock {
name: name.to_string(),
gallery: gallery.to_string(),
category: "General".into(),
blocks,
..Default::default()
}),
}
} else {
s.building_blocks.insert(name.to_string(), frag);
}
}
if let Some(name) = p::str(v, "delete")
&& (in_document || s.building_blocks.remove(name).is_none())
&& let Some(i) = document_entry(&s.doc, name, gallery)
{
s.doc.glossary.remove(i);
}
if let Some(name) = p::str(v, "insert") {
let frag = match s.building_blocks.get(name) {
Some(f) => f.clone(),
None => {
let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i));
let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?;
wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() }
}
};
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
}
let document: Vec<&str> = s.doc.glossary.iter().filter(|e| e.gallery == gallery).map(|e| e.name.as_str()).collect();
let mut parts: Vec<&str> = s.building_blocks.keys().map(String::as_str).collect();
let mut seen: std::collections::HashSet<&str> = parts.iter().copied().collect();
parts.extend(document.iter().copied().filter(|n| seen.insert(n)));
Ok(json!({"parts": parts, "document": document}))
}
/// The index of the document's building block `name`, in `gallery` if there is one there.
fn document_entry(doc: &Document, name: &str, gallery: &str) -> Option<usize> {
let g = &doc.glossary;
g.iter().position(|e| e.name == name && e.gallery == gallery).or_else(|| g.iter().position(|e| e.name == name))
}
pub(super) fn hf_nav(s: &mut Session, dir: i32) -> CmdResult {
let StoryRef::Part(cur) = s.sel.focus.story else { return Err(CmdError::Disabled("edit a header or footer first".into())) };
let mut ids: Vec<u32> = Vec::new();
for (_, sect) in s.doc.sections() {
for id in [sect.headers.default, sect.headers.first, sect.headers.even, sect.footers.default, sect.footers.first, sect.footers.even]
.into_iter()
.flatten()
{
if !ids.contains(&id) {
ids.push(id);
}
}
}
let i = ids.iter().position(|x| *x == cur).unwrap_or(0) as i32;
let n = ids.len().max(1) as i32;
let next = ids.get(((i + dir).rem_euclid(n)) as usize).copied().unwrap_or(cur);
s.sel = Selection::caret(s.doc.start_of(StoryRef::Part(next)));
sel_result(s)
}
pub(super) fn select_similar(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.ordered().0;
let target = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default();
let mut first: Option<Pos> = None;
let mut last: Option<Pos> = None;
let mut count = 0;
for path in s.doc.para_paths(f.story) {
let Some(p) = s.doc.para(f.story, &path) else { continue };
for (r, c) in p.run_ranges() {
if *c == target {
count += 1;
if first.is_none() {
first = Some(Pos { story: f.story, path: path.clone(), off: r.start });
}
last = Some(Pos { story: f.story, path: path.clone(), off: r.end });
}
}
}
if let (Some(a), Some(b)) = (first, last) {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({"runs": count}))
}
/// Tools › Customize Keyboard: read and change the custom keyboard shortcuts ([`crate::KeyMap`]).
/// Without params it opens the dialog; with any, it only does what they ask (no dialog).
pub(super) fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
let reg = s.registry.clone();
let pair = |x: &Value| -> Result<(String, String), CmdError> { Ok((p::req_str(x, "command")?.to_string(), p::req_str(x, "key")?.to_string())) };
let mut out = serde_json::Map::new();
let mut asked = false;
if p::bool(v, "reset") == Some(true) {
asked = true;
s.keymap.reset();
}
if let Some(a) = v.get("assign") {
asked = true;
let (id, key) = pair(a)?;
let previous = s.keymap.assign(&reg, &id, &key).map_err(CmdError::Params)?;
out.insert("replaced".into(), json!(previous));
}
if let Some(r) = v.get("remove") {
asked = true;
let (id, key) = pair(r)?;
out.insert("removed".into(), json!(s.keymap.remove(&reg, &id, &key).map_err(CmdError::Params)?));
}
if let Some(id) = p::str(v, "command") {
asked = true;
if reg.get(id).is_none() {
return Err(CmdError::Params(format!("unknown command `{id}`")));
}
out.insert("keys".into(), json!(s.keymap.keys_for(&reg, id)));
}
if let Some(key) = p::str(v, "key") {
asked = true;
let key = crate::keymap::normalize(key).ok_or_else(|| CmdError::Params(format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)")))?;
out.insert("assignedTo".into(), json!(s.keymap.resolve(&reg, &key).map(|c| c.id)));
out.insert("key".into(), json!(key));
}
out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null));
if !asked {
s.push_ui_request(json!({"open": "customizeKeyboard"}));
}
Ok(Value::Object(out))
}
/// `tools.search`: the commands whose label, location or id hold every word of `query`, or,
/// with no query, the Search box (`ui_requests` → the UI's command search).
pub(super) fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
let Some(query) = p::str(v, "query") else {
s.push_ui_request(json!({"open": "commands"}));
return Ok(json!({"matches": []}));
};
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
let matches: Vec<Value> = if words.is_empty() {
Vec::new()
} else {
s.registry
.all()
.iter()
.filter(|c| {
let hay = format!("{} {} {}", c.label, c.location, c.id).to_lowercase();
words.iter().all(|w| hay.contains(w.as_str()))
})
.take(50)
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut}))
.collect()
};
Ok(json!({"matches": matches}))
}
/// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access
/// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does
/// what they ask (no dialog).
pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
let reg = s.registry.clone();
let l = &mut s.ribbon;
let e = CmdError::Params;
let str_of = |x: &Value, k: &str| x.get(k).and_then(Value::as_str).map(str::to_string);
let req = |x: &Value, k: &str| p::req_str(x, k).map(str::to_string);
let by = |x: &Value| x.get("by").and_then(Value::as_i64).ok_or_else(|| e("`by` (integer, e.g. -1 or 1) is required".into()));
let mut out = serde_json::Map::new();
let mut asked = false;
if let Some(x) = v.get("layout") {
asked = true;
*l = crate::RibbonLayout::from_value(x);
l.retain_known(&reg);
}
if let Some(r) = v.get("reset") {
asked = true;
match (r.as_bool(), r.as_str()) {
(Some(true), _) => *l = crate::RibbonLayout::default(),
(_, Some("ribbon")) => l.reset_ribbon(),
(_, Some("qat")) => l.reset_qat(),
_ => return Err(e("`reset` is true, \"ribbon\" or \"qat\"".into())),
}
}
for (key, hidden) in [("hide", true), ("show", false)] {
if let Some(tab) = p::str(v, key) {
asked = true;
l.set_hidden(tab, hidden).map_err(e)?;
}
}
if let Some(x) = v.get("newTab") {
asked = true;
let name = l.new_tab(str_of(x, "name").as_deref(), str_of(x, "after").as_deref()).map_err(e)?;
out.insert("tab".into(), json!(name));
}
if let Some(x) = v.get("newGroup") {
asked = true;
let name = l.new_group(&req(x, "tab")?, str_of(x, "name").as_deref()).map_err(e)?;
out.insert("group".into(), json!(name));
}
if let Some(x) = v.get("add") {
asked = true;
let added = l.add(&reg, &req(x, "tab")?, &req(x, "group")?, &req(x, "command")?, str_of(x, "after").as_deref()).map_err(e)?;
out.insert("added".into(), json!(added));
}
if let Some(x) = v.get("remove") {
asked = true;
l.remove(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref()).map_err(e)?;
}
if let Some(x) = v.get("rename") {
asked = true;
let name = l.rename(&req(x, "tab")?, str_of(x, "group").as_deref(), &req(x, "to")?).map_err(e)?;
out.insert("name".into(), json!(name));
}
if let Some(x) = v.get("move") {
asked = true;
l.move_by(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref(), by(x)?).map_err(e)?;
}
if let Some(id) = p::str(v, "qatAdd") {
asked = true;
out.insert("added".into(), json!(l.qat_add(&reg, id, None).map_err(e)?));
}
if let Some(id) = p::str(v, "qatRemove") {
asked = true;
out.insert("removed".into(), json!(l.qat_remove(id)));
}
if let Some(x) = v.get("qatMove") {
asked = true;
l.qat_move(&req(x, "command")?, by(x)?).map_err(e)?;
}
out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null));
out.insert("qat".into(), json!(l.qat()));
if !asked {
s.push_ui_request(json!({"open": "customizeRibbon"}));
}
Ok(Value::Object(out))
}