mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 04:22:46 +00:00
- A shortcut parity table (`crates/engine/src/shortcut_parity_tests.rs`): one row per shortcut in
the public documentation (help-centre feature articles: keyboard layouts, text properties,
alignment, frames, components, prototypes, plugins, keyboard navigation), with the macOS and the
Windows/Linux keys, what handles it here (keymap command, held key/gesture listed in the panel,
vector edit tool key, or missing), and a note in our own words. Rows the documentation is silent
on pin today's keys, so every default binding is in the table.
- 129 documented rows: 126 bound (110 before), 1 moved off a documented conflict (Run last
plugin on Windows: the docs also give Ctrl+Alt+P to pixel preview; the plugin keeps
Ctrl+Alt+Shift+P), 2 missing (moving the rotation origin with Alt+R; placing an object with
Enter after choosing a tool from the toolbar), 2 kept by a platform (Ctrl+Space with some CJK
input methods; F6 in browsers). 48 undocumented bindings pinned.
- Tests: every row fires its command on both platforms and sits in the right shortcuts-panel tab;
every default binding has a row; no binding uses a chord the OS keeps (Cmd+H/M/Q/Tab/Space,
Ctrl+Space, screenshot keys on macOS; Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete, Linux
workspace and VT keys); macOS keycaps show Cmd/Option glyphs only and Windows/Linux keycaps
Ctrl/Alt words only; vector edit tool keys; counts pinned for the docs.
- Newly bound: A (Frame tool), Shift+' (pixel grid; Cmd+' kept, the articles disagree),
Alt+Cmd+\ / Ctrl+Alt+\ (others' cursors in a live session), Ctrl+F6 (toolbar focus on
Windows/Linux), Shift+Cmd+U (link text; Cmd+K kept), Cmd/Ctrl+] and [ (list levels while
editing), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection; F8 kept,
Cmd/Ctrl+arrows draw the box out), Ctrl+Shift+A / Ctrl+Alt+Shift+A (suggest auto layout),
Ctrl+Alt+Cmd+K / Ctrl+Alt+Shift+K (go to main component).
- New commands: `view.multiplayerCursors {on?}` (View menu item, menu breadth 207/207),
`view.pan {dx, dy, big?}` (arrows pan the view by 20 screen px, 200 with Shift, when nothing is
selected; the canvas and MCP `key` both use it), `text.listIndent` / `text.listOutdent`.
- Behaviour: Control held while dragging suspends snapping on macOS too (Cmd/Ctrl already did);
the presenter takes Space and N for the next frame, C for comments (Shift+C kept) and F for full
screen, after the prototype's own key triggers; the paint bucket's tooltip shows Shift+B (B still
works).
- Keyboard layouts: shortcuts follow the character typed, named as on a US keyboard. A symbol
another layout types with Shift (`/` is Shift+7 on German keyboards) now counts without the
Shift (canvas, text editing and F6 navigation share one lookup). Characters egui has no key for
already arrive as the key at that position.
- Keyboard shortcuts panel: new rows for panning with the arrows, suspending snapping, moving along
the toolbar, and the presentation keys, in the tabs the documentation groups them in (en/es
strings).
- Docs: ui-parity.md gets a Keyboard shortcuts section with the counts; target-app-parity.md,
ROADMAP.md, gaps.md (gap 4 landed list), mcp.md, parity-checklist.md (regenerated).
479 lines
23 KiB
Rust
479 lines
23 KiB
Rust
//! Text menu and text editing commands.
|
|
|
|
use figcraft_doc::*;
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use super::props::{styled_name, weight_name};
|
|
use super::{CommandSpec, ok};
|
|
use crate::params::*;
|
|
use crate::{Result, Session, bad, spec, textedit};
|
|
|
|
fn finite(v: f64) -> f64 {
|
|
if v.is_finite() { v } else { 0.0 }
|
|
}
|
|
|
|
/// Apply a style change to the edited range, or to whole text layers in the selection.
|
|
fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value> {
|
|
if let Some((id, a, b)) = textedit::selected_range(s) {
|
|
let ds = s.ds_mut()?;
|
|
let before = ds.doc.clone();
|
|
let doc = ds.doc_mut();
|
|
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
|
if a == b {
|
|
t.restyle(0, t.char_count(), &f);
|
|
} else {
|
|
t.restyle(a, b, &f);
|
|
}
|
|
}
|
|
crate::normalize(doc, &before);
|
|
return ok();
|
|
}
|
|
let ids = req_targets("text", s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
for i in ids {
|
|
for d in doc.subtree(i) {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
|
let n = t.char_count();
|
|
t.restyle(0, n, &f);
|
|
}
|
|
}
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn para(s: &mut Session, p: &Value, f: impl Fn(&mut TextData)) -> Result<Value> {
|
|
let ids = match textedit::selected_range(s) {
|
|
Some((id, _, _)) => vec![id],
|
|
None => req_targets("text", s, p)?,
|
|
};
|
|
let ds = s.ds_mut()?;
|
|
let before = ds.doc.clone();
|
|
let doc = ds.doc_mut();
|
|
for i in ids {
|
|
for d in doc.subtree(i) {
|
|
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
|
f(t);
|
|
}
|
|
}
|
|
}
|
|
crate::normalize(doc, &before);
|
|
ok()
|
|
}
|
|
|
|
/// Run `f` on each targeted text with the chars whose paragraphs a list command changes: the
|
|
/// edited paragraphs while editing, else all of them.
|
|
fn para_range(s: &mut Session, p: &Value, f: impl Fn(&mut TextData, usize, usize)) -> Result<Value> {
|
|
let range = textedit::selected_range(s);
|
|
para(s, p, |t| {
|
|
let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b));
|
|
f(t, a, b)
|
|
})
|
|
}
|
|
|
|
/// A list command: every targeted paragraph becomes `l`, or (`toggle`) plain when all already are.
|
|
fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Value> {
|
|
let range = textedit::selected_range(s);
|
|
let ids = range.map_or_else(|| targets(s, p), |(id, _, _)| vec![id]);
|
|
let doc = s.doc()?;
|
|
let all = ids.iter().all(|i| {
|
|
doc.node(*i).and_then(|n| n.kind.text()).is_some_and(|t| {
|
|
let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b));
|
|
t.common_para_list(a, b).is_some_and(|pl| pl.list == l)
|
|
})
|
|
});
|
|
let l = if toggle && all { ListStyle::None } else { l };
|
|
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
|
|
}
|
|
|
|
/// Whether every targeted char has `pred` (to decide toggle direction).
|
|
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
|
let Ok(doc) = s.doc() else { return false };
|
|
if let Some((id, a, b)) = textedit::selected_range(s) {
|
|
if let Some(NodeKind::Text(t)) = doc.node(id).map(|n| &n.kind) {
|
|
let (a, b) = if a == b { (0, t.char_count()) } else { (a, b) };
|
|
return (a..b.max(a + 1)).all(|i| pred(t.style_at(i)));
|
|
}
|
|
return false;
|
|
}
|
|
targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.spans().iter().all(|(_, _, st)| pred(st))))
|
|
}
|
|
|
|
fn toggle_bold(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let bold = all_have(s, p, |st| st.weight() >= 700);
|
|
style(s, p, |st| {
|
|
let italic = st.italic();
|
|
st.set_font_style(styled_name(if bold { "Regular" } else { "Bold" }, italic));
|
|
})
|
|
}
|
|
|
|
fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let italic = all_have(s, p, TextStyle::italic);
|
|
style(s, p, |st| {
|
|
let w = weight_name(st.weight());
|
|
st.set_font_style(styled_name(w, !italic));
|
|
})
|
|
}
|
|
|
|
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
|
style(s, p, |st| {
|
|
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
|
|
let italic = st.italic();
|
|
st.set_font_style(styled_name(weight_name(w), italic));
|
|
})
|
|
}
|
|
|
|
/// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and
|
|
/// the path layer is removed.
|
|
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "text.onPath";
|
|
let doc = s.doc()?;
|
|
let is_text = |i: &NodeId| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)));
|
|
let sel = targets(s, p);
|
|
let text =
|
|
id(p, "text").or_else(|| sel.iter().copied().find(is_text)).ok_or_else(|| bad(CMD, "select a text layer and a path (or pass `text`)"))?;
|
|
let path = id(p, "path")
|
|
.or_else(|| sel.iter().copied().find(|i| *i != text && !is_text(i)))
|
|
.ok_or_else(|| bad(CMD, "select a path or shape too (or pass `path`)"))?;
|
|
let pn = doc.get(path)?;
|
|
let geom = figcraft_doc::geometry::stroke_geometry(pn).filter(|g| !g.elements().is_empty()).ok_or_else(|| bad(CMD, "that layer has no path"))?;
|
|
if !is_text(&text) || doc.is_ancestor(path, text) {
|
|
return Err(bad(CMD, "`text` must be a text layer outside the path layer"));
|
|
}
|
|
let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path));
|
|
let (transform, w, h) = (pn.transform, pn.width, pn.height);
|
|
let mut tp = TextPath::new(&geom);
|
|
tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
|
tp.flip = boolean(p, "flip").unwrap_or(false);
|
|
let doc = s.doc_mut()?;
|
|
doc.reparent(text, parent, index)?;
|
|
doc.remove(path)?;
|
|
let n = doc.get_mut(text)?;
|
|
n.transform = transform;
|
|
(n.width, n.height) = (w, h);
|
|
if let NodeKind::Text(t) = &mut n.kind {
|
|
t.path = Some(Box::new(tp));
|
|
}
|
|
s.set_selection(vec![text]);
|
|
Ok(json!({ "id": text }))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"text.onPath",
|
|
"Text on path",
|
|
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}",
|
|
true,
|
|
on_path
|
|
),
|
|
spec!("text.pathOptions", "Text path options", "{start? (0..1 along the path), flip?, ids?} for text on a path", true, |s, p| {
|
|
let start = num(p, "start").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
|
|
let flip = boolean(p, "flip");
|
|
para(s, p, |t| {
|
|
if let Some(tp) = &mut t.path {
|
|
tp.start = start.unwrap_or(tp.start);
|
|
tp.flip = flip.unwrap_or(tp.flip);
|
|
}
|
|
})
|
|
}),
|
|
spec!("text.removeFromPath", "Remove text from path", "{ids?} text on a path back to a line of auto-width text", true, |s, p| {
|
|
para(s, p, |t| {
|
|
if t.path.take().is_some() {
|
|
t.auto_resize = TextAutoResize::WidthAndHeight;
|
|
}
|
|
})
|
|
}),
|
|
spec!("text.bold", "Bold", "{ids?}", true, toggle_bold),
|
|
spec!("text.italic", "Italic", "{ids?}", true, toggle_italic),
|
|
spec!("text.underline", "Underline", "{ids?}", true, |s, p| {
|
|
let on = all_have(s, p, |st| st.decoration == TextDecoration::Underline);
|
|
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Underline })
|
|
}),
|
|
spec!("text.strikethrough", "Strikethrough", "{ids?}", true, |s, p| {
|
|
let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough);
|
|
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough })
|
|
}),
|
|
spec!("text.link", "Create link", "{url, ids?}", true, |s, p| {
|
|
let url = req_string("text.link", p, "url")?;
|
|
style(s, p, |st| st.link = Some(url.clone()))
|
|
}),
|
|
spec!("text.bulletList", "Bulleted list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list(
|
|
s,
|
|
p,
|
|
ListStyle::Bulleted,
|
|
true
|
|
)),
|
|
spec!("text.numberList", "Numbered list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list(
|
|
s,
|
|
p,
|
|
ListStyle::Numbered,
|
|
true
|
|
)),
|
|
spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)),
|
|
spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)),
|
|
spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)),
|
|
spec!("text.alignJustify", "Justify", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Justified)),
|
|
spec!("text.sizeUp", "Increase font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size + 1.0).min(5000.0))),
|
|
spec!("text.sizeDown", "Decrease font size", "{ids?}", true, |s, p| style(s, p, |st| st.font_size = (st.font_size - 1.0).max(1.0))),
|
|
spec!("text.weightUp", "Increase font weight", "{ids?}", true, |s, p| step_weight(s, p, true)),
|
|
spec!("text.weightDown", "Decrease font weight", "{ids?}", true, |s, p| step_weight(s, p, false)),
|
|
spec!("text.lineHeightUp", "Increase line height", "{ids?}", true, |s, p| {
|
|
style(s, p, |st| st.line_height = LineHeight::Pixels(st.line_height.resolve(st.font_size, 1.21).round() + 1.0))
|
|
}),
|
|
spec!("text.lineHeightDown", "Decrease line height", "{ids?}", true, |s, p| {
|
|
style(s, p, |st| st.line_height = LineHeight::Pixels((st.line_height.resolve(st.font_size, 1.21).round() - 1.0).max(0.0)))
|
|
}),
|
|
spec!("text.trackingUp", "Increase letter spacing", "{ids?}", true, |s, p| {
|
|
style(s, p, |st| {
|
|
st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
|
|
LetterSpacing::Percent(v) => v + 1.0,
|
|
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 + 1.0,
|
|
})
|
|
})
|
|
}),
|
|
spec!("text.trackingDown", "Decrease letter spacing", "{ids?}", true, |s, p| {
|
|
style(s, p, |st| {
|
|
st.letter_spacing = LetterSpacing::Percent(match st.letter_spacing {
|
|
LetterSpacing::Percent(v) => v - 1.0,
|
|
LetterSpacing::Pixels(v) => v / st.font_size.max(1.0) * 100.0 - 1.0,
|
|
})
|
|
})
|
|
}),
|
|
spec!("text.caseOriginal", "Original case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Original)),
|
|
spec!("text.caseUpper", "Uppercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Upper)),
|
|
spec!("text.caseLower", "Lowercase", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Lower)),
|
|
spec!("text.caseTitle", "Title case", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::Title)),
|
|
spec!("text.caseSmallCaps", "Small caps", "{ids?}", true, |s, p| style(s, p, |st| st.case = TextCase::SmallCaps)),
|
|
spec!("text.case", "Letter case", "{case: original|upper|lower|title|smallCaps, ids?}", true, |s, p| {
|
|
let c: TextCase =
|
|
get(p, "case").and_then(super::props::enum_of).ok_or_else(|| bad("text.case", "`case`: original|upper|lower|title|smallCaps"))?;
|
|
style(s, p, |st| st.case = c)
|
|
}),
|
|
spec!("text.decoration", "Decoration", "{decoration: none|underline|strikethrough, ids?}", true, |s, p| {
|
|
let d: TextDecoration = get(p, "decoration")
|
|
.and_then(super::props::enum_of)
|
|
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
|
|
style(s, p, |st| st.decoration = d)
|
|
}),
|
|
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
|
|
let l: ListStyle = get(p, "style")
|
|
.or_else(|| get(p, "list"))
|
|
.and_then(super::props::enum_of)
|
|
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
|
set_list(s, p, l, false)
|
|
}),
|
|
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
|
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
|
para(s, p, |t| t.paragraph_indent = v)
|
|
}),
|
|
spec!("text.paragraphSpacing", "Paragraph spacing", "{spacing (px), ids?}", true, |s, p| {
|
|
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
|
|
para(s, p, |t| t.paragraph_spacing = v)
|
|
}),
|
|
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
|
|
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
|
para(s, p, |t| {
|
|
t.max_lines = v;
|
|
if v > 0 {
|
|
t.auto_resize = TextAutoResize::Truncate;
|
|
}
|
|
})
|
|
}),
|
|
spec!(
|
|
"text.setFeatures",
|
|
"OpenType features",
|
|
"{features: {tag: bool | null (font default)}, replace? (clear others first), id?/ids?}",
|
|
true,
|
|
|s, p| {
|
|
let f = super::props::features_of("text.setFeatures", get(p, "features").unwrap_or(&Value::Null))?;
|
|
let replace = boolean(p, "replace").unwrap_or(false);
|
|
para(s, p, |t| {
|
|
if replace {
|
|
t.features.clear();
|
|
}
|
|
for (k, on) in &f {
|
|
match on {
|
|
Some(on) => {
|
|
t.features.insert(k.clone(), *on);
|
|
}
|
|
None => {
|
|
t.features.remove(k);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
),
|
|
spec!(
|
|
"text.setVariations",
|
|
"Variable font axes",
|
|
"{variations: {axis: number | null (style default)}, replace? (clear others first), id?/ids?}; while editing: the selected range",
|
|
true,
|
|
|s, p| {
|
|
let vars = super::props::variations_of("text.setVariations", get(p, "variations").unwrap_or(&Value::Bool(false)))?;
|
|
let replace = boolean(p, "replace").unwrap_or(false);
|
|
style(s, p, |st| {
|
|
if replace {
|
|
st.variations.clear();
|
|
}
|
|
for (k, v) in &vars {
|
|
match v {
|
|
Some(v) => {
|
|
st.variations.insert(k.clone(), *v);
|
|
}
|
|
None => {
|
|
st.variations.remove(k);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
),
|
|
spec!(
|
|
"text.preview",
|
|
"Preview text change",
|
|
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
|
|
false,
|
|
|s, p| {
|
|
let c = req_string("text.preview", p, "command")?;
|
|
let params = get(p, "params").cloned().unwrap_or_else(|| json!({}));
|
|
crate::textpreview::show(s, &c, ¶ms)?;
|
|
Ok(json!({"previewing": crate::textpreview::active(s)}))
|
|
}
|
|
),
|
|
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
|
|
Ok(json!({"ended": crate::textpreview::end(s)}))
|
|
}),
|
|
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
|
|
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
|
let ids = req_targets("text.set", s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
for i in ids {
|
|
let mut m = Map::new();
|
|
m.insert("characters".into(), json!(c));
|
|
super::props::apply("text.set", doc, i, &m)?;
|
|
}
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"text.style",
|
|
"Style text range",
|
|
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
|
|
true,
|
|
|s, p| {
|
|
let ids = req_targets("text.style", s, p)?;
|
|
let m = p.as_object().cloned().unwrap_or_default();
|
|
let doc = s.doc_mut()?;
|
|
for i in ids {
|
|
let n = doc.get_mut(i)?;
|
|
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
|
let len = t.char_count();
|
|
let a = num(p, "start").map(|v| v.max(0.0) as usize).unwrap_or(0).min(len);
|
|
let b = num(p, "end").map(|v| v.max(0.0) as usize).unwrap_or(len).min(len);
|
|
for (k, v) in &m {
|
|
if matches!(k.as_str(), "id" | "ids" | "start" | "end") {
|
|
continue;
|
|
}
|
|
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
|
}
|
|
}
|
|
ok()
|
|
}
|
|
),
|
|
spec!("text.edit.begin", "Edit text", "{id, caret? (char index; default select all)}", false, |s, p| {
|
|
let id = req_id("text.edit.begin", p, "id")?;
|
|
let caret = num(p, "caret").map(|c| c.max(0.0) as usize);
|
|
textedit::begin(s, id, caret, false)?;
|
|
ok()
|
|
}),
|
|
spec!("text.edit.insert", "Type text", "{text}", true, |s, p| {
|
|
let t = string(p, "text").unwrap_or_default();
|
|
textedit::insert(s, &t)?;
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"text.edit.key",
|
|
"Text key",
|
|
"{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Tab|Escape|SelectAll, shift?, alt?, cmd?} in a list, Tab nests the item and Shift+Tab, Enter on an empty item or Backspace at its start move it out",
|
|
true,
|
|
|s, p| {
|
|
let k = req_string("text.edit.key", p, "key")?;
|
|
textedit::key(s, &k, boolean(p, "shift").unwrap_or(false), boolean(p, "alt").unwrap_or(false), boolean(p, "cmd").unwrap_or(false))?;
|
|
ok()
|
|
}
|
|
),
|
|
spec!("text.listIndent", "Indent list", "{} while editing: the selected list items a level deeper", true, |s, _| {
|
|
Ok(json!({"changed": textedit::list_indent(s, false)?}))
|
|
}),
|
|
spec!("text.listOutdent", "Outdent list", "{} while editing: the selected list items a level out", true, |s, _| {
|
|
Ok(json!({"changed": textedit::list_indent(s, true)?}))
|
|
}),
|
|
spec!("text.edit.caret", "Set caret", "{index, extend?}", false, |s, p| {
|
|
let i = req_num("text.edit.caret", p, "index")?.max(0.0) as usize;
|
|
textedit::set_caret(s, i, boolean(p, "extend").unwrap_or(false))?;
|
|
ok()
|
|
}),
|
|
spec!("text.edit.selectWord", "Select word", "{index}", false, |s, p| {
|
|
let i = finite(req_num("text.edit.selectWord", p, "index")?).max(0.0) as usize;
|
|
textedit::select_word(s, i)?;
|
|
ok()
|
|
}),
|
|
spec!("text.edit.selectLine", "Select line", "{index}", false, |s, p| {
|
|
let i = finite(req_num("text.edit.selectLine", p, "index")?).max(0.0) as usize;
|
|
textedit::select_line(s, i)?;
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"text.edit.preedit",
|
|
"Compose text (IME)",
|
|
"{text (empty = cancel), cursor? (chars into text)}; shown at the caret, committed by text.edit.insert",
|
|
false,
|
|
|s, p| {
|
|
let cursor = num(p, "cursor").map(|c| c.max(0.0) as usize);
|
|
textedit::preedit(s, &string(p, "text").unwrap_or_default(), cursor)?;
|
|
ok()
|
|
}
|
|
),
|
|
spec!("text.edit.selection", "Text selection", "{}", false, |s, _| {
|
|
Ok(match s.ds()?.text_edit.clone() {
|
|
Some(te) => json!({
|
|
"id": te.id, "caret": te.caret, "anchor": te.anchor, "text": textedit::selected_text(s),
|
|
"preedit": te.preedit.map(|p| json!({"text": p.text, "cursor": p.cursor})),
|
|
}),
|
|
None => Value::Null,
|
|
})
|
|
}),
|
|
spec!("text.edit.end", "Stop editing text", "{}", false, |s, _| {
|
|
textedit::end(s)?;
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"text.fonts",
|
|
"List fonts",
|
|
"{family? (list its styles), style?, features? (the face's OpenType features), axes? (the face's variation axes and named instances)}",
|
|
false,
|
|
|_, p| {
|
|
let face = |f: &str| figcraft_text::find_face(f, &string(p, "style").unwrap_or_else(|| "Regular".into()));
|
|
Ok(match string(p, "family") {
|
|
Some(f) if boolean(p, "features").unwrap_or(false) => json!(face(&f).map(|face| face.feature_tags()).unwrap_or_default()),
|
|
Some(f) if boolean(p, "axes").unwrap_or(false) => match face(&f) {
|
|
Some(face) => json!({
|
|
"family": face.family,
|
|
"axes": face.axes.iter().map(|a| json!({
|
|
"tag": a.tag, "name": a.name, "min": a.min, "default": a.default, "max": a.max, "hidden": a.hidden
|
|
})).collect::<Vec<_>>(),
|
|
"instances": face.instances.iter().map(|i| json!({
|
|
"name": i.name,
|
|
"variations": face.axes.iter().zip(&i.coords).map(|(a, c)| (a.tag.clone(), json!(c))).collect::<Map<String, Value>>()
|
|
})).collect::<Vec<_>>(),
|
|
}),
|
|
None => Value::Null,
|
|
},
|
|
Some(f) => json!(figcraft_text::family_styles(&f)),
|
|
None => json!(figcraft_text::family_names()),
|
|
})
|
|
}
|
|
),
|
|
]
|
|
}
|