mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
1004 lines
43 KiB
Rust
1004 lines
43 KiB
Rust
//! On-canvas text editing (Type tool): the edit session (layer, selection, insertion style), caret
|
|
//! movement, typing, deleting, the text clipboard and Paste Text and Match Formatting / Paste Text
|
|
//! Formatting Only. Every change to the text goes through an undoable edit of Source Text at the
|
|
//! CTI; the selection lives in [`EditorState::text_edit`] so agents and the UI share it.
|
|
//!
|
|
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
|
|
|
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
|
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
|
|
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
|
|
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
|
|
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
|
|
|
pub(super) fn next_edit_session() -> u64 {
|
|
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
}
|
|
|
|
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
|
|
///
|
|
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
|
|
/// one process-global counter means neither a second session nor a rollback can reuse a value.
|
|
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
|
|
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
|
|
/// receipt rather than claiming a mutation with a reused revision.
|
|
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
|
|
pub(crate) fn next_ime_revision() -> Option<u64> {
|
|
allocate_ime_revision(&NEXT_IME_REVISION)
|
|
}
|
|
|
|
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
|
|
use std::sync::atomic::Ordering::Relaxed;
|
|
let mut current = counter.load(Relaxed);
|
|
loop {
|
|
let next = current.checked_add(1)?;
|
|
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
|
|
Ok(_) => return Some(next),
|
|
Err(observed) => current = observed,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
|
|
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
|
let counter = AtomicU64::new(u64::MAX - 1);
|
|
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
|
|
assert_eq!(allocate_ime_revision(&counter), None);
|
|
assert_eq!(allocate_ime_revision(&counter), None);
|
|
assert_eq!(counter.load(Relaxed), u64::MAX);
|
|
}
|
|
|
|
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
|
|
use crate::{EditorState, EngineError, Result, Session, cmd};
|
|
|
|
/// An on-canvas text editing session.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct TextEdit {
|
|
pub layer: LayerId,
|
|
pub anchor: usize,
|
|
pub caret: usize,
|
|
/// Character panel changes made with only a caret: the style the next typed text takes.
|
|
#[serde(default)]
|
|
pub pending: Option<CharStyle>,
|
|
/// The layer was made by a Type tool click; leaving it empty deletes it.
|
|
#[serde(default)]
|
|
pub created: bool,
|
|
/// Line position kept by repeated up / down moves (layout space).
|
|
#[serde(default)]
|
|
pub goal_x: Option<f64>,
|
|
/// This editing session's identity.
|
|
///
|
|
/// It changes when the editor is started and when its layer or document is replaced, by
|
|
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
|
|
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
|
|
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
|
|
/// It is deliberately not serialized: a saved token belongs to a previous process and
|
|
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
|
|
/// composition is never inherited by a recreated layer or a replaced document.
|
|
#[serde(skip, default = "next_edit_session")]
|
|
pub session: u64,
|
|
}
|
|
|
|
impl Default for TextEdit {
|
|
fn default() -> Self {
|
|
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
|
|
}
|
|
}
|
|
|
|
impl TextEdit {
|
|
pub fn range(&self) -> std::ops::Range<usize> {
|
|
self.anchor.min(self.caret)..self.anchor.max(self.caret)
|
|
}
|
|
pub fn is_empty(&self) -> bool {
|
|
self.anchor == self.caret
|
|
}
|
|
}
|
|
|
|
/// The Source Text document of a layer in the active comp at the CTI (keyframed value).
|
|
pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
|
|
let l = s.active_comp()?.layer(lid)?;
|
|
match l.props.prop("text/sourceText")?.value_at(l.layer_time(s.time())) {
|
|
KV::Text(d) => Some(*d),
|
|
KV::Str(t) => Some(TextDoc::plain(&t)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The largest committed-text window an IME observation copies.
|
|
///
|
|
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
|
|
/// reported as unavailable rather than silently truncated.
|
|
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
|
|
|
/// The editor's committed-text revision for an IME receipt whose surrounding text is
|
|
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
|
|
///
|
|
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
|
|
/// they cannot make an active composition look stale.
|
|
pub fn ime_stamp_revision(s: &Session) -> u64 {
|
|
s.ime_revision
|
|
}
|
|
|
|
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
|
|
///
|
|
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
|
|
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ImeSurrounding {
|
|
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
|
|
pub text: String,
|
|
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
|
|
pub cursor: usize,
|
|
/// The other selection end; larger than `cursor` for a reversed selection.
|
|
pub anchor: usize,
|
|
}
|
|
|
|
impl ImeSurrounding {
|
|
/// A bounded window of `text` around the selection `cursor`/`anchor`.
|
|
///
|
|
/// Returns `None` when an offset is past the text or inside a character, or when the
|
|
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
|
|
/// surrounding text as unavailable instead of sending a truncated selection.
|
|
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
|
|
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
|
|
return None;
|
|
}
|
|
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
|
|
let selection = end - start;
|
|
if selection > MAX_IME_TEXT_BYTES {
|
|
return None;
|
|
}
|
|
// Spend the remaining budget on both sides of the selection, giving unused room on one
|
|
// side to the other. Only this bounded window is copied.
|
|
let budget = MAX_IME_TEXT_BYTES - selection;
|
|
let mut before = (budget / 2).min(start);
|
|
let mut after = (budget - before).min(text.len() - end);
|
|
before = (budget - after).min(start);
|
|
after = (budget - before).min(text.len() - end);
|
|
let mut window_start = start - before;
|
|
let mut window_end = end + after;
|
|
// Byte budgets can land inside a multi-byte character: shrink the window so it never
|
|
// splits one and the selection stays inside it.
|
|
while window_start < start && !text.is_char_boundary(window_start) {
|
|
window_start += 1;
|
|
}
|
|
while end < window_end && !text.is_char_boundary(window_end) {
|
|
window_end -= 1;
|
|
}
|
|
Some(Self {
|
|
text: text.get(window_start..window_end)?.to_owned(),
|
|
cursor: cursor.checked_sub(window_start)?,
|
|
anchor: anchor.checked_sub(window_start)?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The edited layer's committed text around its selection, or `None` when there is no edit
|
|
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
|
|
///
|
|
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
|
|
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
|
|
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
|
|
let e = s.state.text_edit.as_ref()?;
|
|
let comp = s.active_comp()?;
|
|
let layer = comp.layer(e.layer)?;
|
|
let prop = layer.props.prop("text/sourceText")?;
|
|
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
|
|
// be evaluated into a temporary. Either way only the bounded window is copied.
|
|
if !prop.is_animated() {
|
|
return match &prop.value {
|
|
KV::Text(doc) => {
|
|
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
|
|
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
|
}
|
|
KV::Str(text) => {
|
|
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
|
|
ImeSurrounding::from_text(text, caret, anchor)
|
|
}
|
|
_ => None,
|
|
};
|
|
}
|
|
match prop.value_at(layer.layer_time(s.time())) {
|
|
KV::Text(doc) => {
|
|
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
|
|
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
|
}
|
|
KV::Str(text) => {
|
|
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
|
|
ImeSurrounding::from_text(&text, caret, anchor)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
|
|
///
|
|
/// The second position may be before the first; the returned pair keeps the caller's order.
|
|
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
|
|
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
|
|
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
|
|
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
|
|
if hi != 0 {
|
|
for (i, (b, _)) in text.char_indices().enumerate() {
|
|
if i == lo {
|
|
lo_byte = b;
|
|
}
|
|
if i == hi {
|
|
hi_byte = b;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
|
|
}
|
|
|
|
/// Byte offsets of the editor's caret and anchor in a document, in that order.
|
|
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
|
|
char_bytes(&doc.text, caret, anchor)
|
|
}
|
|
|
|
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
|
|
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
|
|
let cid = comp_id(s, p)?;
|
|
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
|
let lid = match p.get("layer") {
|
|
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
|
|
None => match &s.state.text_edit {
|
|
Some(e) => e.layer,
|
|
None => s
|
|
.state
|
|
.selected_layers
|
|
.iter()
|
|
.copied()
|
|
.find(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text)))
|
|
.ok_or_else(|| bad(cmd, "select a text layer"))?,
|
|
},
|
|
};
|
|
match comp.layer(lid) {
|
|
Some(l) if matches!(l.source, LayerSource::Text) => Ok(lid),
|
|
_ => Err(bad(cmd, "not a text layer")),
|
|
}
|
|
}
|
|
|
|
/// The edit session on `lid` (started with the caret at the end when there is none).
|
|
fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
|
match &s.state.text_edit {
|
|
Some(e) if e.layer == lid => e.clone(),
|
|
_ => {
|
|
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
|
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
|
|
s.state.selected_layers = vec![lid];
|
|
s.state.text_edit = Some(e.clone());
|
|
e
|
|
}
|
|
}
|
|
}
|
|
|
|
fn first_line_name(text: &str) -> String {
|
|
text.lines().next().unwrap_or("").chars().take(40).collect()
|
|
}
|
|
|
|
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
|
/// session's selection.
|
|
pub(crate) fn edit_doc(
|
|
s: &mut Session,
|
|
label: &str,
|
|
merge: Option<&str>,
|
|
lid: LayerId,
|
|
f: impl FnOnce(&mut TextDoc, &mut EditorState) -> Result<()>,
|
|
) -> Result<()> {
|
|
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
|
let t = s.time();
|
|
let revision = s.revision;
|
|
s.edit_text_doc(label, merge, |proj, st| {
|
|
let l = layer_mut(proj, cid, lid)?;
|
|
let lt = l.layer_time(t);
|
|
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
|
|
let mut doc = match pr.value_at(lt) {
|
|
KV::Text(d) => *d,
|
|
KV::Str(t) => TextDoc::plain(&t),
|
|
_ => TextDoc::default(),
|
|
};
|
|
let original = doc.clone();
|
|
f(&mut doc, st)?;
|
|
// A no-op (an IME deletion with nothing around the selection, a set to the value it
|
|
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
|
// would change the project and record an empty undo step.
|
|
if doc != original {
|
|
let before = first_line_name(&original.text);
|
|
let after = first_line_name(&doc.text);
|
|
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
|
// Text layers are named after their text until renamed.
|
|
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
|
l.name = after;
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
// The editor's unavailable-text receipt identity follows only its own document: an edit to
|
|
// another text layer while this editor is open must not cancel the composition.
|
|
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
|
|
s.note_text_edit_change();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
|
let a = p.get("range")?.as_array()?;
|
|
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
|
|
let (x, y) = (g(0)?, g(1).or(g(0))?);
|
|
Some(x.min(y)..x.max(y))
|
|
}
|
|
|
|
fn state_json(s: &Session) -> Value {
|
|
match &s.state.text_edit {
|
|
Some(e) => json!({"layer": e.layer.0, "anchor": e.anchor, "caret": e.caret, "start": e.range().start, "end": e.range().end}),
|
|
None => Value::Null,
|
|
}
|
|
}
|
|
|
|
/// `text.edit`: start editing a text layer.
|
|
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.edit")?;
|
|
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
|
let (anchor, caret) = match p.get("select") {
|
|
Some(Value::Array(a)) => {
|
|
let g = |i: usize| a.get(i).and_then(Value::as_u64).map_or(n, |x| (x as usize).min(n));
|
|
(g(0), g(1))
|
|
}
|
|
Some(Value::String(x)) if x == "none" => (n, n),
|
|
_ => match p.get("caret").and_then(Value::as_u64) {
|
|
Some(c) => ((c as usize).min(n), (c as usize).min(n)),
|
|
None => (0, n),
|
|
},
|
|
};
|
|
let created = b_p(p, "created").unwrap_or(false);
|
|
s.state.selected_layers = vec![lid];
|
|
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
|
|
s.bump();
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
|
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
|
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
|
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
|
s.edit("Delete Empty Text Layer", None, |proj, st| {
|
|
if let Some(c) = proj.comp_mut(cid) {
|
|
c.layers.retain(|l| l.id != e.layer);
|
|
}
|
|
st.selected_layers.retain(|l| *l != e.layer);
|
|
Ok(())
|
|
})?;
|
|
}
|
|
s.bump();
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn set_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.setSelection")?;
|
|
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
|
let mut e = session_on(s, lid);
|
|
let g = |k: &str| p.get(k).and_then(Value::as_u64).map(|x| (x as usize).min(n));
|
|
let (a, c) = match (g("start").or(g("anchor")), g("end").or(g("caret"))) {
|
|
(Some(a), Some(c)) => (a, c),
|
|
(Some(a), None) | (None, Some(a)) => (a, a),
|
|
(None, None) => match str_p(p, "select") {
|
|
Some("all") => (0, n),
|
|
_ => return Err(bad("text.setSelection", "give start/end (or anchor/caret), or select: \"all\"")),
|
|
},
|
|
};
|
|
if (e.anchor, e.caret) != (a, c) {
|
|
e.pending = None;
|
|
}
|
|
e.anchor = a;
|
|
e.caret = c;
|
|
e.goal_x = None;
|
|
s.state.text_edit = Some(e);
|
|
s.bump();
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Caret targets for `text.moveCaret`.
|
|
fn move_target(s: &Session, e: &TextEdit, doc: &TextDoc, to: &str) -> Result<(usize, Option<f64>)> {
|
|
let n = doc.char_len();
|
|
let (lo, hi) = (e.range().start, e.range().end);
|
|
let collapse = !e.is_empty();
|
|
let lay = || effectcraft_text::layout_doc(doc);
|
|
Ok(match to {
|
|
"left" if collapse => (lo, None),
|
|
"right" if collapse => (hi, None),
|
|
"left" => (e.caret.saturating_sub(1), None),
|
|
"right" => ((e.caret + 1).min(n), None),
|
|
"wordLeft" => (effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret), None),
|
|
"wordRight" => (effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret), None),
|
|
"lineStart" | "home" => (lay().line_span(e.caret).0, None),
|
|
"lineEnd" => (lay().line_span(e.caret).1, None),
|
|
"up" | "down" => {
|
|
let (c, g) = lay().move_vertical(e.caret, if to == "up" { -1 } else { 1 }, e.goal_x);
|
|
(c, Some(g))
|
|
}
|
|
"paraStart" | "paraUp" => {
|
|
let r = &doc.para_ranges()[doc.para_of(e.caret)];
|
|
let pi = doc.para_of(e.caret);
|
|
if e.caret == r.start && pi > 0 { (doc.para_ranges()[pi - 1].start, None) } else { (r.start, None) }
|
|
}
|
|
"paraEnd" | "paraDown" => {
|
|
let ranges = doc.para_ranges();
|
|
let pi = doc.para_of(e.caret);
|
|
if e.caret == ranges[pi].end && pi + 1 < ranges.len() { (ranges[pi + 1].end, None) } else { (ranges[pi].end, None) }
|
|
}
|
|
"start" => (0, None),
|
|
"end" | "docEnd" => (n, None),
|
|
_ => {
|
|
let _ = s;
|
|
return Err(bad("text.moveCaret", format!("unknown target {to}")));
|
|
}
|
|
})
|
|
}
|
|
|
|
fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.moveCaret")?;
|
|
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.moveCaret", "not a text layer"))?;
|
|
let e = session_on(s, lid);
|
|
let to =
|
|
str_p(p, "to").ok_or_else(|| bad("text.moveCaret", "give to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end"))?;
|
|
let extend = b_p(p, "extend").unwrap_or(false);
|
|
// Extending moves the caret itself, not the collapsed selection edge.
|
|
let probe = if extend { TextEdit { anchor: e.caret, ..e.clone() } } else { e.clone() };
|
|
let (c, goal) = move_target(s, &probe, &doc, to)?;
|
|
let mut e = e;
|
|
e.caret = c;
|
|
if !extend {
|
|
e.anchor = c;
|
|
}
|
|
e.goal_x = goal;
|
|
e.pending = None;
|
|
s.state.text_edit = Some(e);
|
|
s.bump();
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Replace the selection (or `range`) with `text`; the caret goes after it.
|
|
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.insert")?;
|
|
let text = str_p(p, "text").ok_or_else(|| bad("text.insert", "give text"))?.replace("\r\n", "\n").replace('\r', "\n");
|
|
let e = session_on(s, lid);
|
|
let r = range_p(p).unwrap_or_else(|| e.range());
|
|
let pending = e.pending.clone();
|
|
let merge = str_p(p, "merge").map(str::to_string);
|
|
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
|
let n = doc.char_len();
|
|
let r = r.start.min(n)..r.end.min(n);
|
|
doc.replace_range(r.clone(), &text, pending.as_ref());
|
|
let c = r.start + text.chars().count();
|
|
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
|
ed.anchor = c;
|
|
ed.caret = c;
|
|
ed.pending = None;
|
|
ed.goal_x = None;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Delete the selection, or one character / word before (backward) or after the caret.
|
|
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.delete")?;
|
|
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.delete", "not a text layer"))?;
|
|
let e = session_on(s, lid);
|
|
let n = doc.char_len();
|
|
let forward = str_p(p, "direction") == Some("forward");
|
|
let word = b_p(p, "word").unwrap_or(false);
|
|
let r = match range_p(p) {
|
|
Some(r) => r.start.min(n)..r.end.min(n),
|
|
None if !e.is_empty() => e.range(),
|
|
None if forward => e.caret..if word { effectcraft_keyframe::text_doc::word_right(&doc.text, e.caret) } else { (e.caret + 1).min(n) },
|
|
None => (if word { effectcraft_keyframe::text_doc::word_left(&doc.text, e.caret) } else { e.caret.saturating_sub(1) })..e.caret,
|
|
};
|
|
if r.is_empty() {
|
|
return Ok(state_json(s));
|
|
}
|
|
let merge = str_p(p, "merge").map(str::to_string);
|
|
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
|
doc.replace_range(r.clone(), "", None);
|
|
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
|
ed.anchor = r.start;
|
|
ed.caret = r.start;
|
|
ed.pending = None;
|
|
ed.goal_x = None;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
|
|
///
|
|
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
|
|
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
|
|
/// malformed or platform-specific offset never splits or corrupts the document.
|
|
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
|
let r = r.start.min(n)..r.end.min(n);
|
|
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
|
|
let from = start_byte.saturating_sub(before_bytes);
|
|
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
|
|
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
|
|
(before.min(r.start), after.min(n.saturating_sub(r.end)))
|
|
}
|
|
|
|
/// Delete the text an input method marks for replacement around the selection
|
|
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
|
|
///
|
|
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
|
|
/// start and after its end are removed in two separate ranges, and the following `Commit`
|
|
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
|
|
/// otherwise); a count that ends inside a character leaves that character.
|
|
///
|
|
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
|
|
/// Source Text and a large static document are not copied or evaluated again just to decide what
|
|
/// the input method asked to replace.
|
|
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let lid = target(s, p, "text.deleteSurrounding")?;
|
|
// Only an open edit session has a selection to keep: without one there is nothing an input
|
|
// method could be replacing.
|
|
let e = match &s.state.text_edit {
|
|
Some(e) if e.layer == lid => e.clone(),
|
|
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
|
|
};
|
|
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
|
|
let (before_count, after_count) = (count("before"), count("after"));
|
|
let bytes = b_p(p, "bytes").unwrap_or(false);
|
|
let merge = str_p(p, "merge").map(str::to_string);
|
|
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
|
let n = doc.char_len();
|
|
let r = e.range().start.min(n)..e.range().end.min(n);
|
|
let (before, after) = if bytes {
|
|
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
|
|
} else {
|
|
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
|
|
};
|
|
let start = r.start.saturating_sub(before);
|
|
let end = r.end.saturating_add(after).min(n);
|
|
if start == r.start && end == r.end {
|
|
// Deletion-only batches with an empty count, or a document with nothing around the
|
|
// selection: no empty undo step.
|
|
return Ok(());
|
|
}
|
|
// The selection keeps its text: remove after it first, then before it, so the two ranges
|
|
// stay valid and the caret/selection shift by the characters before it.
|
|
if end > r.end {
|
|
doc.replace_range(r.end..end, "", None);
|
|
}
|
|
if r.start > start {
|
|
doc.replace_range(start..r.start, "", None);
|
|
}
|
|
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
|
let shift = r.start.saturating_sub(start);
|
|
ed.anchor = ed.anchor.saturating_sub(shift);
|
|
ed.caret = ed.caret.saturating_sub(shift);
|
|
ed.pending = None;
|
|
ed.goal_x = None;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Selected text (plain) of the session, and its formatted fragment.
|
|
fn selection_doc(s: &Session) -> Option<TextDoc> {
|
|
let e = s.state.text_edit.as_ref()?;
|
|
let doc = layer_doc(s, e.layer)?;
|
|
(!e.is_empty()).then(|| doc.slice(e.range()))
|
|
}
|
|
|
|
/// Edit ▸ Copy while editing text.
|
|
pub(crate) fn copy(s: &mut Session) -> Result<Value> {
|
|
let frag = selection_doc(s).ok_or_else(|| bad("edit.copy", "select some text first"))?;
|
|
let text = frag.text.clone();
|
|
s.state.text_clipboard = Some(frag);
|
|
Ok(json!({"text": text}))
|
|
}
|
|
|
|
/// Edit ▸ Cut while editing text.
|
|
pub(crate) fn cut(s: &mut Session) -> Result<Value> {
|
|
let r = copy(s)?;
|
|
delete(s, &json!({}))?;
|
|
Ok(r)
|
|
}
|
|
|
|
/// Edit ▸ Paste while editing text: the copied text with its formatting, or `text` (from the
|
|
/// system clipboard) in the caret's style when it's something else.
|
|
pub(crate) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let Some(e) = s.state.text_edit.clone() else { return Err(bad("edit.paste", "not editing text")) };
|
|
let clip = s.state.text_clipboard.clone();
|
|
match (str_p(p, "text"), clip) {
|
|
(Some(t), Some(c)) if t.replace("\r\n", "\n") != c.text => insert(s, &json!({"text": t})),
|
|
(_, Some(c)) => {
|
|
let r = e.range();
|
|
edit_doc(s, "Paste", None, e.layer, |doc, st| {
|
|
doc.insert_doc(r.clone(), &c);
|
|
let at = r.start + c.char_len();
|
|
if let Some(ed) = st.text_edit.as_mut() {
|
|
ed.anchor = at;
|
|
ed.caret = at;
|
|
ed.pending = None;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(state_json(s))
|
|
}
|
|
(Some(t), None) => insert(s, &json!({"text": t})),
|
|
(None, None) => Err(bad("edit.paste", "nothing to paste")),
|
|
}
|
|
}
|
|
|
|
fn editing_or_text_layer(s: &Session) -> std::result::Result<(), String> {
|
|
if s.state.text_edit.is_some() {
|
|
return Ok(());
|
|
}
|
|
super::has_comp(s)?;
|
|
let comp = s.active_comp().ok_or("no composition")?;
|
|
if s.state.selected_layers.iter().any(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))) {
|
|
Ok(())
|
|
} else {
|
|
Err("edit text or select a text layer".into())
|
|
}
|
|
}
|
|
|
|
fn has_text_clip(s: &Session) -> std::result::Result<(), String> {
|
|
editing_or_text_layer(s)?;
|
|
if s.state.text_clipboard.is_some() { Ok(()) } else { Err("copy some text first".into()) }
|
|
}
|
|
|
|
/// Edit ▸ Paste Text and Match Formatting: the clipboard's plain text in the style at the caret.
|
|
fn paste_match(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let text = match str_p(p, "text") {
|
|
Some(t) => t.to_string(),
|
|
None => s.state.text_clipboard.as_ref().map(|c| c.text.clone()).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "nothing to paste"))?,
|
|
};
|
|
if s.state.text_edit.is_none() {
|
|
// Not editing: replace the selected text layers' text, keeping their formatting.
|
|
let lid = target(s, p, "edit.pasteTextMatchFormatting")?;
|
|
edit_doc(s, "Paste Text and Match Formatting", None, lid, |doc, _| {
|
|
doc.set_text(&text);
|
|
Ok(())
|
|
})?;
|
|
return Ok(Value::Null);
|
|
}
|
|
let e = session_on(s, target(s, p, "edit.pasteTextMatchFormatting")?);
|
|
let doc = layer_doc(s, e.layer).ok_or_else(|| bad("edit.pasteTextMatchFormatting", "not a text layer"))?;
|
|
let style = e.pending.clone().unwrap_or_else(|| if e.is_empty() { doc.insertion_style(e.caret) } else { doc.style_at(e.range().start) });
|
|
let r = e.range();
|
|
edit_doc(s, "Paste Text and Match Formatting", None, e.layer, |doc, st| {
|
|
doc.replace_range(r.clone(), &text, Some(&style));
|
|
let at = r.start + text.chars().count();
|
|
if let Some(ed) = st.text_edit.as_mut() {
|
|
ed.anchor = at;
|
|
ed.caret = at;
|
|
ed.pending = None;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Edit ▸ Paste Text Formatting Only: the copied text's character style (its first character)
|
|
/// and paragraph settings on the selected text, or on the whole of the selected text layers.
|
|
fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let clip = s.state.text_clipboard.clone().ok_or_else(|| bad("edit.pasteTextFormattingOnly", "copy some text first"))?;
|
|
let style = clip.style_at(0);
|
|
let para = clip.para(0);
|
|
let targets: Vec<(LayerId, Option<std::ops::Range<usize>>)> = match &s.state.text_edit {
|
|
Some(e) => vec![(e.layer, (!e.is_empty()).then(|| e.range()))],
|
|
None => {
|
|
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
|
let ids: Vec<LayerId> = match p.get("layer") {
|
|
Some(_) => vec![target(s, p, "edit.pasteTextFormattingOnly")?],
|
|
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Text))).collect(),
|
|
};
|
|
ids.into_iter().map(|l| (l, None)).collect()
|
|
}
|
|
};
|
|
if let Some(e) = s.state.text_edit.as_mut()
|
|
&& e.is_empty()
|
|
{
|
|
// Caret only: the next typed text takes the formatting.
|
|
e.pending = Some(style);
|
|
return Ok(state_json(s));
|
|
}
|
|
for (lid, r) in targets {
|
|
edit_doc(s, "Paste Text Formatting Only", None, lid, |doc, _| {
|
|
let n = doc.char_len();
|
|
let r = r.clone().unwrap_or(0..n.max(1));
|
|
doc.apply_style(r.clone(), |st| *st = style.clone());
|
|
doc.apply_para(r, |pp| *pp = para.clone());
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(state_json(s))
|
|
}
|
|
|
|
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
|
|
pub(crate) fn invalidate_session(s: &mut Session) {
|
|
let replaced = s.state.text_edit.is_some();
|
|
if let Some(e) = s.state.text_edit.as_mut() {
|
|
e.session = next_edit_session();
|
|
}
|
|
if replaced {
|
|
s.note_text_edit_change();
|
|
}
|
|
}
|
|
|
|
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
|
|
///
|
|
/// This is the one replacement path for document changes outside `edit_doc`:
|
|
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
|
|
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
|
|
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
|
|
/// before a history restore or rollback.
|
|
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
|
|
/// deletes instead of being swallowed by an out-of-range selection.
|
|
pub(crate) fn document_replaced(s: &mut Session) {
|
|
sanitize(s);
|
|
invalidate_session(s);
|
|
}
|
|
|
|
/// The identity and document of the active on-canvas editor at one moment.
|
|
///
|
|
/// The composition and the editor session are part of the identity: a restoration must not keep
|
|
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
|
|
/// that was restarted during the operation being rolled back.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct EditorSnapshot {
|
|
/// The composition the editor was attached to.
|
|
pub comp: ItemId,
|
|
/// The edited layer.
|
|
pub layer: LayerId,
|
|
/// The editor session token, unique across the process.
|
|
pub session: u64,
|
|
/// The Source Text the editor was showing.
|
|
pub doc: TextDoc,
|
|
}
|
|
|
|
/// The identity and document the active on-canvas editor is showing.
|
|
///
|
|
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
|
|
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
|
|
/// write to another property, layer or composition, a move to a time showing the same text, or a
|
|
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
|
|
/// single `Option` check, with no document copy.
|
|
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
|
|
let e = s.state.text_edit.as_ref()?;
|
|
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
|
|
}
|
|
|
|
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
|
|
/// selection and start a new IME epoch.
|
|
///
|
|
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
|
|
/// mismatch is not a replacement here.
|
|
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
|
|
let Some(before) = before else { return };
|
|
let Some(e) = s.state.text_edit.as_ref() else { return };
|
|
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
|
|
return;
|
|
}
|
|
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
|
document_replaced(s);
|
|
}
|
|
}
|
|
|
|
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
|
|
/// only when the restored state still has the same composition, layer and editor session and
|
|
/// shows byte/style-equal text.
|
|
///
|
|
/// The restored session token is the one saved in the replaced state, so an intermediate
|
|
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
|
|
/// to be the editor's own change.
|
|
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
|
|
let Some(before) = before else {
|
|
// No editor was open when the snapshot was taken: a restored editor must not inherit a
|
|
// composition from the state the replacement replaced.
|
|
if s.state.text_edit.is_some() {
|
|
document_replaced(s);
|
|
}
|
|
return;
|
|
};
|
|
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
|
|
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
|
document_replaced(s);
|
|
}
|
|
}
|
|
|
|
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
|
|
/// layer is gone or not in the active comp.
|
|
///
|
|
/// This is also called after project-item cleanup that leaves the edited document alone, so it
|
|
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
|
|
/// a document replacement and interrupt the active IME composition. Replacement paths call
|
|
/// [`document_replaced`] instead.
|
|
pub(crate) fn sanitize(s: &mut Session) {
|
|
let Some(e) = s.state.text_edit.clone() else { return };
|
|
match layer_doc(s, e.layer) {
|
|
Some(d) => {
|
|
let n = d.char_len();
|
|
if let Some(ed) = s.state.text_edit.as_mut() {
|
|
ed.anchor = ed.anchor.min(n);
|
|
ed.caret = ed.caret.min(n);
|
|
}
|
|
}
|
|
None => s.state.text_edit = None,
|
|
}
|
|
}
|
|
|
|
/// The OpenType features of a font (`font` / `style`, else the target text layer's style at the
|
|
/// caret or its first character), and which Character panel options it sets with its own glyphs
|
|
/// (the others are synthesized or have no effect).
|
|
pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (family, style) = match p.get("font").and_then(Value::as_str) {
|
|
Some(f) => (f.to_string(), p.get("style").and_then(Value::as_str).unwrap_or("Regular").to_string()),
|
|
None => {
|
|
let lid = target(s, p, "text.fontFeatures")?;
|
|
let doc = layer_doc(s, lid).ok_or_else(|| bad("text.fontFeatures", "not a text layer"))?;
|
|
let at = s.state.text_edit.as_ref().filter(|e| e.layer == lid).map_or(0, |e| e.range().start);
|
|
let st = doc.style_at(at);
|
|
(st.font, st.style)
|
|
}
|
|
};
|
|
let r = effectcraft_text::resolve(&family, &style);
|
|
let face = effectcraft_text::fonts::face(r.face);
|
|
let feats = face.features();
|
|
let has = |t: &str| feats.iter().any(|f| f == t);
|
|
let sets: Vec<u32> = (1..=20u32).filter(|n| has(&format!("ss{n:02}"))).collect();
|
|
Ok(json!({
|
|
"family": face.info.family, "style": face.info.style, "missing": r.missing,
|
|
"features": feats,
|
|
"options": {
|
|
"kerning": has("kern"), "ligatures": has("liga") || has("clig"), "discretionaryLigatures": has("dlig"),
|
|
"contextualAlternates": has("calt"), "stylisticAlternates": has("salt"), "stylisticSets": sets,
|
|
"swash": has("swsh"), "titling": has("titl"), "ordinals": has("ordn"), "fractions": has("frac"),
|
|
"smallCaps": has("smcp"), "allSmallCaps": has("c2sc"), "superscript": has("sups"), "subscript": has("subs"),
|
|
"oldStyleFigures": has("onum"), "liningFigures": has("lnum"), "tabularFigures": has("tnum"), "proportionalFigures": has("pnum"),
|
|
},
|
|
}))
|
|
}
|
|
|
|
/// The installed font families (bundled and system), with their styles, where each comes from
|
|
/// and its name in the font's own language. `query` keeps the families whose English or native
|
|
/// name contains it (case-insensitive); `rescan` first looks for fonts installed since the app
|
|
/// started.
|
|
pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
|
use effectcraft_text::fonts;
|
|
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
|
|
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
|
|
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
|
|
let native = fonts::native_families();
|
|
let variable = fonts::variable_families();
|
|
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
|
|
for f in fonts::all_faces() {
|
|
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
|
|
}
|
|
let list: Vec<Value> = effectcraft_text::families()
|
|
.into_iter()
|
|
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
|
|
.filter(|(f, _)| !only_variable || variable.contains(f))
|
|
.map(|(f, styles)| {
|
|
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
|
|
if let Some(n) = native.get(&f) {
|
|
v["nativeName"] = json!(n);
|
|
}
|
|
v
|
|
})
|
|
.collect();
|
|
Ok(json!({"count": list.len(), "families": list, "added": added}))
|
|
}
|
|
|
|
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
fn editing(s: &Session) -> std::result::Result<(), String> {
|
|
if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) }
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"text.edit",
|
|
"Edit Text",
|
|
[],
|
|
None,
|
|
"{layer?, select?: [anchor, caret]|\"none\", caret?, created?} → {layer, anchor, caret}",
|
|
editing_or_text_layer,
|
|
edit
|
|
),
|
|
cmd!("text.endEdit", "Exit Text Editing", [], None, "{}", always_ok, end_edit),
|
|
cmd!(
|
|
"text.fontFeatures",
|
|
"OpenType Features",
|
|
[],
|
|
None,
|
|
"{layer?, font?, style?} → {family, style, features: [tags], options: {smallCaps, superscript, stylisticSets: [n], fractions, …}} (what the font sets with its own glyphs)",
|
|
always_ok,
|
|
font_features
|
|
),
|
|
cmd!(
|
|
"text.fonts",
|
|
"List Fonts",
|
|
[],
|
|
None,
|
|
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
|
|
always_ok,
|
|
fonts
|
|
),
|
|
cmd!(
|
|
"text.setSelection",
|
|
"Set Text Selection",
|
|
[],
|
|
None,
|
|
"{layer?, start, end?} | {anchor, caret} | {select: \"all\"} (character indices)",
|
|
editing_or_text_layer,
|
|
set_selection
|
|
),
|
|
cmd!(
|
|
"text.moveCaret",
|
|
"Move Text Caret",
|
|
[],
|
|
None,
|
|
"{to: left|right|wordLeft|wordRight|lineStart|lineEnd|up|down|paraStart|paraEnd|start|end, extend?}",
|
|
editing,
|
|
move_caret
|
|
),
|
|
cmd!("text.insert", "Type Text", [], None, "{layer?, text, range?: [start, end], merge?} (replaces the selection)", editing_or_text_layer, insert),
|
|
cmd!(
|
|
"text.delete",
|
|
"Delete Text",
|
|
[],
|
|
None,
|
|
"{layer?, direction?: backward|forward, word?, range?: [start, end], merge?}",
|
|
editing_or_text_layer,
|
|
delete
|
|
),
|
|
cmd!(
|
|
"text.deleteSurrounding",
|
|
"Delete Text Around Selection",
|
|
[],
|
|
None,
|
|
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
|
|
editing_or_text_layer,
|
|
delete_surrounding
|
|
),
|
|
cmd!(
|
|
"edit.pasteTextMatchFormatting",
|
|
"Paste Text and Match Formatting",
|
|
["Edit"],
|
|
None,
|
|
"{text?} (the clipboard's text in the style at the caret)",
|
|
editing_or_text_layer,
|
|
paste_match
|
|
),
|
|
cmd!(
|
|
"edit.pasteTextFormattingOnly",
|
|
"Paste Text Formatting Only",
|
|
["Edit"],
|
|
None,
|
|
"{layer?} (the copied text's formatting on the selected text or text layers)",
|
|
has_text_clip,
|
|
paste_formatting
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Whether a layer is being edited (for the Character / Paragraph panels).
|
|
pub fn editing_layer(s: &Session) -> Option<LayerId> {
|
|
s.state.text_edit.as_ref().map(|e| e.layer)
|
|
}
|