mirror of
https://github.com/aphrody-labs/deckcraft.git
synced 2026-10-14 03:29:07 +00:00
* Text: input method composition as commands, one undo step per composition (#101) text.compose shows an input method's preedit in place at the insertion point and replaces it on each update; an empty preedit cancels and restores the text and selection. text.commit replaces the composition with the result as one undo step. Any other edit, key or click settles an open composition first. The composition is underlined under the baseline (thicker under the part being converted), and the caret line is measured for empty placeholders too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * UI: Korean, Japanese and Chinese input and interface text (#101) The slide turns the input method on while it takes typing, with the candidate window at the caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul, Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text. Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before focus leaves in the same frame (Escape, another slide). Control channel: ui.ime. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
23a3b6eba2
commit
ea3b1159d5
19 files changed
+1062
-18
No files matched your search
@@ -129,6 +129,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
|
||||
@@ -17,6 +17,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit),
|
||||
cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert),
|
||||
cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete),
|
||||
// The input method's composition: shown in place, an undo step only once committed.
|
||||
CommandSpec {
|
||||
id: "text.compose",
|
||||
label: "Compose Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it",
|
||||
enabled: has_text,
|
||||
run: compose,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(
|
||||
noundo "text.commit",
|
||||
"Commit Composition",
|
||||
[],
|
||||
None,
|
||||
"{text} — the input method's result replaces its composition, as one undo step",
|
||||
has_text,
|
||||
commit
|
||||
),
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
@@ -144,6 +165,33 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayo
|
||||
))
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is
|
||||
/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes and
|
||||
/// table cells, which aren't laid out on the slide here.
|
||||
pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let mut body = sh.text.clone().unwrap_or_default();
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
&body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
);
|
||||
let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?;
|
||||
let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -340,6 +388,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own).
|
||||
fn ime_text(s: &str) -> String {
|
||||
s.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
/// Is the composition still where it was put (the paragraph holds all of it)?
|
||||
fn composition_fits(st: &DocState, c: &crate::Composition) -> bool {
|
||||
let Some(t) = &st.selection.text else { return false };
|
||||
let Some(body) = body_of(st, t) else { return false };
|
||||
body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len())
|
||||
}
|
||||
|
||||
/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection)
|
||||
/// or replace the composition with the new preedit. An empty preedit cancels: the presentation
|
||||
/// goes back to how it was before the composition began.
|
||||
fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?);
|
||||
let n = text.chars().count();
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x < y).then_some((x.min(n), y.min(n)))
|
||||
});
|
||||
let current = s.doc()?.composition.clone();
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
};
|
||||
match current {
|
||||
None if text.is_empty() => Ok(json!({"composing": false})),
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
let (before, before_selection) = (st.doc.clone(), st.selection.clone());
|
||||
let (start, props) = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) };
|
||||
let c = ed::insert(body, at, &text, props.clone());
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
(at, props)
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
Some(c) if text.is_empty() => {
|
||||
let st = s.doc_mut()?;
|
||||
st.doc = c.before;
|
||||
st.selection = c.before_selection;
|
||||
st.composition = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"composing": false}))
|
||||
}
|
||||
Some(c) => {
|
||||
let (start, len, props) = (c.start, c.len, c.props.clone());
|
||||
edit_text(s, |body, t| {
|
||||
ed::delete(body, start, (start.0, start.1 + len));
|
||||
let e = ed::insert(body, start, &text, props);
|
||||
t.anchor = e;
|
||||
t.caret = e;
|
||||
})?;
|
||||
s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c });
|
||||
Ok(json!({"composing": true, "start": [start.0, start.1], "len": n}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.commit`: the input method's result replaces the composition (or is typed at the
|
||||
/// insertion point when there is none) and the whole composition becomes one undo step.
|
||||
fn commit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?);
|
||||
if s.doc()?.composition.is_some() || !text.is_empty() {
|
||||
compose(s, &json!({"text": text}))?;
|
||||
}
|
||||
s.end_composition();
|
||||
let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default();
|
||||
Ok(json!({"caret": [caret.0, caret.1]}))
|
||||
}
|
||||
|
||||
/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to,
|
||||
/// thick)` per line, thick under the part being converted. Empty without a composition or when
|
||||
/// the edited text isn't laid out on the slide (notes, table cells).
|
||||
pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> {
|
||||
let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] };
|
||||
let Some(sh) = st.shape(t.shape) else { return vec![] };
|
||||
let Some(l) = layout_for(st, t) else { return vec![] };
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch };
|
||||
let mut out = vec![];
|
||||
let mut add = |a: usize, b: usize, thick: bool| {
|
||||
for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) {
|
||||
out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick));
|
||||
}
|
||||
};
|
||||
add(0, c.len, false);
|
||||
if let Some((a, b)) = c.active {
|
||||
add(a, b.min(c.len), true);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||
insert_text(s, &text, None)
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Session {
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
@@ -151,6 +152,45 @@ impl Session {
|
||||
self.execute("text.insert", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// An input method's preedit (#101). On a selected shape that isn't being edited, the first
|
||||
/// one starts editing its text and replaces it, as typing does ([`Session::type_text`]).
|
||||
/// `active` is the part being converted, in characters of `text`.
|
||||
pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
let mut p = json!({"text": text});
|
||||
if let Some((a, b)) = active {
|
||||
p["active"] = json!([a, b]);
|
||||
}
|
||||
self.execute("text.compose", &p)
|
||||
}
|
||||
|
||||
/// An input method's result (#101): replaces the composition as one undo step.
|
||||
pub fn commit_text(&mut self, text: &str) -> Result<Value> {
|
||||
if !self.start_typing(text)? {
|
||||
return Ok(json!({"handled": false}));
|
||||
}
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
fn start_typing(&mut self, text: &str) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) };
|
||||
if st.selection.shapes.len() != 1 || text.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.execute("text.edit", &json!({"id": id}))?;
|
||||
self.execute("text.selectAll", &json!({}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
||||
|
||||
@@ -131,6 +131,24 @@ pub struct Interaction {
|
||||
pub selection: Selection,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
/// for `len` characters, until it is committed, replaced or cancelled. The presentation from
|
||||
/// before it began makes the whole composition one undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Composition {
|
||||
/// Where the composed text starts: (paragraph, char).
|
||||
pub start: (usize, usize),
|
||||
/// Its length in characters (it never spans paragraphs).
|
||||
pub len: usize,
|
||||
/// The part the input method is converting, relative to `start` (drawn with a thicker line).
|
||||
pub active: Option<(usize, usize)>,
|
||||
/// The formatting the composed text is typed with (as `text.insert` would choose).
|
||||
pub props: Option<deckcraft_model::text::RunProps>,
|
||||
/// The presentation and selection before the composition began.
|
||||
pub before: Arc<Presentation>,
|
||||
pub before_selection: Selection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Presentation>,
|
||||
@@ -145,6 +163,8 @@ pub struct DocState {
|
||||
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||
/// documents); the app mentions them once.
|
||||
pub missing_fonts: Vec<String>,
|
||||
/// An input method's composition in progress ([`Composition`]).
|
||||
pub composition: Option<Composition>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -163,6 +183,7 @@ impl DocState {
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
title,
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -403,6 +424,10 @@ impl Session {
|
||||
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
if !named {
|
||||
@@ -453,6 +478,16 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
pub fn end_composition(&mut self) {
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
|
||||
@@ -606,3 +606,171 @@ fn shapes_off_the_slide_can_be_selected_and_moved() {
|
||||
s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::<Vec<_>>(), vec![half]);
|
||||
}
|
||||
|
||||
fn title_text(s: &Session) -> String {
|
||||
let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ →
|
||||
/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the
|
||||
/// committed text is one undo step, and nothing on the way is an undo step of its own.
|
||||
#[test]
|
||||
fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "A "})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
s.compose_text(pre, None).unwrap();
|
||||
assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place");
|
||||
let c = s.doc().unwrap().composition.clone().unwrap();
|
||||
assert_eq!((c.start, c.len), ((0, 2), pre.chars().count()));
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing");
|
||||
}
|
||||
s.commit_text("한글").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A ");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글");
|
||||
|
||||
// Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit,
|
||||
// then a new preedit.
|
||||
s.compose_text("ㄴ", None).unwrap();
|
||||
s.compose_text("", None).unwrap();
|
||||
s.commit_text("ㄴ").unwrap();
|
||||
s.compose_text("ㅁ", None).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴㅁ");
|
||||
s.commit_text("마").unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ마");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "A 한글ㄴ");
|
||||
}
|
||||
|
||||
/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo):
|
||||
/// the text and the selection it replaced are as they were, with no undo step.
|
||||
#[test]
|
||||
fn ime_cancel_restores_the_text_and_selection() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hello world"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.compose_text("せかい", Some((0, 3))).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection");
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3)));
|
||||
s.compose_text("世界", Some((0, 9))).unwrap();
|
||||
assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit");
|
||||
s.compose_text("", None).unwrap();
|
||||
assert_eq!(title_text(&s), "Hello world");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
assert!(s.doc().unwrap().revision > rev, "redrawn");
|
||||
// A commit of nothing without a composition is nothing.
|
||||
s.commit_text("").unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0);
|
||||
// A commit without a preedit (some input methods send only the result) is one step.
|
||||
s.commit_text("世界").unwrap();
|
||||
assert_eq!(title_text(&s), "Hello 世界");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
}
|
||||
|
||||
/// #101: anything else settles the composition first: its text stays and is its own undo step.
|
||||
#[test]
|
||||
fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let undo0 = s.doc().unwrap().history.undo.len();
|
||||
s.compose_text("안", None).unwrap();
|
||||
s.key("Right", Mods::default()).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none());
|
||||
assert_eq!(title_text(&s), "안");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
|
||||
|
||||
s.compose_text("녕", None).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
|
||||
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.compose_text("하", None).unwrap();
|
||||
s.execute("text.get", &json!({"id": title})).unwrap();
|
||||
assert!(s.doc().unwrap().composition.is_some(), "a query doesn't");
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하!");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕하");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(title_text(&s), "안녕");
|
||||
|
||||
// Text changed under the composition (here behind its back): a new one starts, no panic.
|
||||
s.compose_text("ㄱ", None).unwrap();
|
||||
s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99;
|
||||
s.compose_text("가", None).unwrap();
|
||||
assert!(title_text(&s).ends_with('가'));
|
||||
// Control characters (an Enter sent as text) never split the paragraph.
|
||||
s.commit_text("가\n\r\u{b}").unwrap();
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing
|
||||
/// does; with nothing selected there is nowhere to compose.
|
||||
#[test]
|
||||
fn ime_on_a_selected_shape_replaces_its_text() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing");
|
||||
s.compose_text("新", None).unwrap();
|
||||
s.commit_text("新しい").unwrap();
|
||||
let v = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes.clear();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.compose_text("x", None).unwrap()["handled"], false);
|
||||
assert_eq!(s.commit_text("x").unwrap()["handled"], false);
|
||||
}
|
||||
|
||||
/// #101: the composition is underlined in place, under the composed characters, with a thicker
|
||||
/// line under the part being converted.
|
||||
#[test]
|
||||
fn ime_composition_underlines_follow_the_text() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
s.compose_text("にほんご", Some((2, 4))).unwrap();
|
||||
let u = cmd::text::composition_underlines(s.doc().unwrap());
|
||||
let thin: Vec<_> = u.iter().filter(|x| !x.2).collect();
|
||||
let thick: Vec<_> = u.iter().filter(|x| x.2).collect();
|
||||
assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}");
|
||||
let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone();
|
||||
let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh);
|
||||
for (a, b, _) in &u {
|
||||
assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}");
|
||||
assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box");
|
||||
}
|
||||
assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end");
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
@@ -265,6 +265,10 @@ impl Session {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.end_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
|
||||
@@ -1831,6 +1831,33 @@ impl TextLayout {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per
|
||||
/// visual span, a little below each line's baseline (PowerPoint underlines the composition
|
||||
/// in place). A composition that wraps gets one underline per line.
|
||||
pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> {
|
||||
let (s, e) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut out = vec![];
|
||||
if s == e {
|
||||
return out;
|
||||
}
|
||||
for l in &self.lines {
|
||||
let lp = Pos { para: l.para, ch: l.start };
|
||||
let le = Pos { para: l.para, ch: l.end };
|
||||
if le <= s || lp >= e {
|
||||
continue;
|
||||
}
|
||||
let from = if s > lp { s.ch } else { l.start };
|
||||
let to = if e < le { e.ch } else { l.end };
|
||||
// Under the baseline, in the descent, never below the line.
|
||||
let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom);
|
||||
for (x0, x1) in l.spans(from, to) {
|
||||
if x1 > x0 {
|
||||
out.push((x0, x1, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||||
let Some(li) = self.line_of(pos) else { return pos };
|
||||
|
||||
@@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() {
|
||||
assert_eq!(r.len(), 1);
|
||||
}
|
||||
|
||||
/// #101: the text an input method is composing is underlined in place, under the baseline of each
|
||||
/// line it occupies, and only over the composed characters.
|
||||
#[test]
|
||||
fn composition_is_underlined_per_line() {
|
||||
let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0);
|
||||
let l = lay(&p, &sh, 600.0, 100.0);
|
||||
let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 });
|
||||
let u = l.composition_underlines(a, b);
|
||||
assert_eq!(u.len(), 1, "{u:?}");
|
||||
let (x0, x1, y) = u[0];
|
||||
let (cx0, ..) = l.caret(a).unwrap();
|
||||
let (cx1, ..) = l.caret(b).unwrap();
|
||||
assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}");
|
||||
let line = &l.lines[0];
|
||||
assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom);
|
||||
// Reversed ends give the same; an empty composition, nothing.
|
||||
assert_eq!(l.composition_underlines(b, a), u);
|
||||
assert!(l.composition_underlines(a, a).is_empty());
|
||||
|
||||
// Wrapped: one underline per line.
|
||||
let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0);
|
||||
let l = lay(&p, &sh, 120.0, 400.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 });
|
||||
assert!(u.len() >= 2, "{u:?}");
|
||||
assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}");
|
||||
// Positions past the text never panic.
|
||||
let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_center_and_right() {
|
||||
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
||||
|
||||
@@ -164,6 +164,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
pointer(app, ui, &resp, xf);
|
||||
overlays(app, ui, &painter, xf, &t);
|
||||
ime_output(app, ui, xf);
|
||||
media_bar(app, ui, xf);
|
||||
gif_buttons(app, ui, xf, &gifs);
|
||||
context_menu(app, &resp);
|
||||
@@ -185,6 +186,47 @@ fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_rend
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an input method composes for the slide (#101), on screen: at the insertion point while
|
||||
/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is
|
||||
/// nothing to type into.
|
||||
pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
let st = app.session.active()?;
|
||||
if let Some(ts) = &st.selection.text {
|
||||
if ts.notes {
|
||||
return None;
|
||||
}
|
||||
if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0)));
|
||||
}
|
||||
let sh = st.shape(ts.shape)?;
|
||||
return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)));
|
||||
}
|
||||
if st.selection.shapes.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(*st.selection.shapes.first()?)?;
|
||||
if sh.is_line() {
|
||||
return None;
|
||||
}
|
||||
Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh)))
|
||||
}
|
||||
|
||||
/// Turn the input method on for the slide while it takes typing, with its candidate window at
|
||||
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||
let ctx = ui.ctx();
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
|
||||
let r = to_global * r;
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
|
||||
/// Play/pause on an animated GIF under the pointer or selected on its own.
|
||||
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -453,12 +495,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||
}
|
||||
if !ts.is_range() {
|
||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
|
||||
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
|
||||
// An empty placeholder shows the caret where typing begins too.
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
let Some(t) = s("text") else { return err("missing `text`") };
|
||||
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
||||
}
|
||||
"ui.ime" => {
|
||||
// An input method's event, through egui like a real one: it reaches the slide's text
|
||||
// or a focused text field (#101).
|
||||
let ev = if let Some(t) = s("commit") {
|
||||
egui::ImeEvent::Commit(t.into())
|
||||
} else if let Some(t) = s("preedit") {
|
||||
let active = p.get("active").and_then(Value::as_array).and_then(|a| {
|
||||
let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize);
|
||||
(x <= y).then_some(x..y)
|
||||
});
|
||||
egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active }
|
||||
} else {
|
||||
return err("missing `preedit` or `commit`");
|
||||
};
|
||||
app.synthetic.push(egui::Event::Ime(ev));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.tool.select" => {
|
||||
let Some(name) = s("tool") else { return err("missing `tool`") };
|
||||
match tool_kind(name, s("preset")) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and
|
||||
//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with
|
||||
//! real glyphs rather than boxes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui::{Event, FontId, ImeEvent};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn preedit(text: &str) -> Event {
|
||||
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
|
||||
}
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let sh = &app.session.active().unwrap().doc.slides[0].shapes[0];
|
||||
sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn undo_len(app: &SlideApp) -> usize {
|
||||
app.session.active().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the
|
||||
/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method
|
||||
/// is on while the slide's text is edited, with its window at the insertion point.
|
||||
#[test]
|
||||
fn korean_composes_in_a_slide_text_box() {
|
||||
let mut h = harness(Session::with_new());
|
||||
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off");
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
|
||||
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
|
||||
let caret0 = ime.rect;
|
||||
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last");
|
||||
assert_eq!(undo_len(h.state()), undo0, "no undo step while composing");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
|
||||
}
|
||||
h.run_steps(1);
|
||||
let ime = h.output().platform_output.ime.unwrap();
|
||||
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
|
||||
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step");
|
||||
|
||||
// Escape cancels a composition: an empty preedit, and the text is as it was.
|
||||
h.event(preedit("ㅁ"));
|
||||
h.event(preedit(""));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "한글");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "", "one undo removes the whole word");
|
||||
}
|
||||
|
||||
/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a
|
||||
/// composition the input method left open.
|
||||
#[test]
|
||||
fn japanese_conversion_shows_the_active_part() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) }));
|
||||
h.run_steps(2);
|
||||
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
|
||||
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
|
||||
let undo0 = undo_len(h.state());
|
||||
h.key_press(egui::Key::End);
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本ご");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
}
|
||||
|
||||
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
|
||||
/// the input method is on for it.
|
||||
#[test]
|
||||
fn composing_on_a_selected_shape_starts_editing_it() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().selection.text.is_none());
|
||||
assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing");
|
||||
h.event(preedit("新"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("新しい".into())));
|
||||
h.run_steps(2);
|
||||
let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["text"], "新しい");
|
||||
}
|
||||
|
||||
/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the
|
||||
/// candidate window is at its caret, and the notes are one undo step when it loses focus.
|
||||
#[test]
|
||||
fn korean_composes_in_the_notes_pane() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let id = egui::Id::new("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)));
|
||||
let undo0 = undo_len(h.state());
|
||||
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, "노트");
|
||||
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let notes = h.state().session.active().unwrap().doc.slides[0].notes_text();
|
||||
assert_eq!(notes, "노트");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트"));
|
||||
|
||||
// Composing, then going to another slide: the notes stay with the slide they were typed for.
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("slide.go", json!({"index": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.event(preedit("ㅇ"));
|
||||
h.event(Event::Ime(ImeEvent::Commit("요".into())));
|
||||
h.run_steps(1);
|
||||
h.state_mut().run("slide.go", json!({"index": 1})).unwrap();
|
||||
h.run_steps(2);
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.doc.slides[0].notes_text(), "노트요");
|
||||
assert_eq!(d.doc.slides[1].notes_text(), "");
|
||||
}
|
||||
|
||||
/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines
|
||||
/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either.
|
||||
fn system_has(s: &str) -> bool {
|
||||
s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color()))
|
||||
}
|
||||
|
||||
/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are
|
||||
/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces
|
||||
/// that draw them are borrowed from the font database. A Latin presentation borrows nothing.
|
||||
#[test]
|
||||
fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||
let h = harness(Session::with_new());
|
||||
assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title());
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let korean = "한국어 발표 자료";
|
||||
let japanese = "日本語のスライド";
|
||||
let chinese = "简体中文演示文稿";
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
|
||||
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
let mut h = harness(s);
|
||||
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().title().contains(korean));
|
||||
let mut checked = 0;
|
||||
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
|
||||
if !system_has(text) {
|
||||
eprintln!("skipped {text:?}: no font on this machine draws it");
|
||||
continue;
|
||||
}
|
||||
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
|
||||
assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if cfg!(windows) || cfg!(target_os = "macos") {
|
||||
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
|
||||
}
|
||||
// Text typed later (a dialog field, a new comment) is looked at too.
|
||||
let typed = "ไทย";
|
||||
h.event(Event::Text(typed.into()));
|
||||
h.run_steps(2);
|
||||
if system_has(typed) {
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub mod textures;
|
||||
pub mod theme;
|
||||
pub mod thumbs;
|
||||
pub mod toast;
|
||||
pub mod uifonts;
|
||||
pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -214,6 +215,8 @@ pub struct SlideApp {
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||
}
|
||||
@@ -253,6 +256,7 @@ impl SlideApp {
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
}
|
||||
}
|
||||
@@ -545,7 +549,7 @@ impl SlideApp {
|
||||
ctx.set_zoom_factor(scale);
|
||||
}
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||
self.styled = true;
|
||||
self.restyle = true;
|
||||
} else {
|
||||
@@ -568,6 +572,9 @@ impl SlideApp {
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.fonts_ready {
|
||||
self.note_ui_text(ctx);
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
// Closing the window with unsaved changes asks first.
|
||||
@@ -599,6 +606,52 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow faces for interface text in scripts the interface fonts lack (#101): the open
|
||||
/// presentations' names and text, recent files, and what is typed or composed this frame.
|
||||
fn note_ui_text(&mut self, ctx: &egui::Context) {
|
||||
let typed: String = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
self.ui_fonts.note(ctx, &typed);
|
||||
for d in self.session.documents() {
|
||||
self.ui_fonts.note(ctx, &d.title());
|
||||
}
|
||||
for r in &self.ui.recent {
|
||||
self.ui_fonts.note(ctx, r);
|
||||
}
|
||||
if let Some(st) = self.session.active() {
|
||||
self.ui_fonts.note_doc(ctx, st);
|
||||
}
|
||||
self.ui_fonts.apply(ctx);
|
||||
}
|
||||
|
||||
/// An input method's event for the slide's text (#101): the preedit is shown in the text and
|
||||
/// replaced on each update; the commit replaces it as one undo step.
|
||||
fn ime_event(&mut self, e: &egui::ImeEvent) {
|
||||
let r = match e {
|
||||
egui::ImeEvent::Preedit { text, active_range_chars } => {
|
||||
self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end)))
|
||||
}
|
||||
egui::ImeEvent::Commit(text) => self.session.commit_text(text),
|
||||
#[allow(deprecated)]
|
||||
egui::ImeEvent::Disabled => {
|
||||
self.session.end_composition();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
@@ -646,6 +699,7 @@ impl SlideApp {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(ime) => self.ime_event(&ime),
|
||||
egui::Event::Copy => {
|
||||
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
||||
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
||||
@@ -889,6 +943,9 @@ fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
@@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
app.notes_buf = (uid, idx, current.clone());
|
||||
}
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if !focused && app.notes_buf.2 != current {
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
|
||||
@@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
install_fonts_with(ctx, &[]);
|
||||
}
|
||||
|
||||
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
|
||||
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
|
||||
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
|
||||
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
ctx.set_fonts(font_definitions(extra));
|
||||
}
|
||||
|
||||
/// The interface font definitions; see [`install_fonts_with`].
|
||||
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
|
||||
let mut defs = FontDefinitions::default();
|
||||
let mut names = vec![];
|
||||
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
||||
@@ -178,7 +190,24 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
|
||||
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
||||
ctx.set_fonts(defs);
|
||||
add_fallback_faces(&mut defs, extra);
|
||||
defs
|
||||
}
|
||||
|
||||
/// Add `faces` after every other font of each interface family (the last fallbacks).
|
||||
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
|
||||
for f in faces {
|
||||
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
|
||||
if defs.font_data.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let mut data = FontData::from_owned(f.data().to_vec());
|
||||
data.index = f.index();
|
||||
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
|
||||
for list in defs.families.values_mut() {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(size: f32) -> egui::FontId {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic,
|
||||
//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without
|
||||
//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each
|
||||
//! character on screen that they lack, a face that has it is borrowed from the font database (the
|
||||
//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto
|
||||
//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where
|
||||
//! there are no installed fonts, only the embedded faces are used.
|
||||
//!
|
||||
//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from
|
||||
//! the composition and caption to every text the interface draws from the presentation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_fonts::{FontDb, FontFace};
|
||||
|
||||
/// Faces larger than this are left out (egui keeps a copy of each face it is given).
|
||||
const MAX_FACE_BYTES: usize = 48 << 20;
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
/// Faces added after the interface fonts, in the order they were found.
|
||||
pub faces: Vec<Arc<FontFace>>,
|
||||
/// Characters already looked at (drawn, given a face, or found in no face).
|
||||
seen: HashSet<char>,
|
||||
/// The presentation last looked through: (uid, revision).
|
||||
doc: Option<(u64, u64)>,
|
||||
/// Faces were added since the fonts were last installed.
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Characters worth a lookup: not ASCII, a space, a control or a private-use character.
|
||||
fn wanted(c: char) -> bool {
|
||||
!(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..))
|
||||
}
|
||||
|
||||
/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the
|
||||
/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and
|
||||
/// very large faces are skipped (egui draws neither well).
|
||||
pub fn faces_for_text(
|
||||
text: &str,
|
||||
have: &[Arc<FontFace>],
|
||||
seen: &mut HashSet<char>,
|
||||
mut covered: impl FnMut(char) -> bool,
|
||||
lookup: impl Fn(char) -> Option<Arc<FontFace>>,
|
||||
) -> Vec<Arc<FontFace>> {
|
||||
let mut out: Vec<Arc<FontFace>> = Vec::new();
|
||||
for c in text.chars().filter(|c| wanted(*c)) {
|
||||
if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl UiFonts {
|
||||
/// Look at `text` the interface is about to draw; borrow faces for what it can't draw.
|
||||
pub fn note(&mut self, ctx: &egui::Context, text: &str) {
|
||||
if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) {
|
||||
return;
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
self.faces.extend(new);
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Look through the text of a presentation the interface shows (once per revision): its
|
||||
/// title, slide text and names, notes, comments, sections and layout names.
|
||||
pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) {
|
||||
if self.doc == Some((st.uid, st.revision)) {
|
||||
return;
|
||||
}
|
||||
self.doc = Some((st.uid, st.revision));
|
||||
let text = ui_text(st);
|
||||
self.note(ctx, &text);
|
||||
}
|
||||
|
||||
/// Install the interface fonts again when faces were added. True if they were.
|
||||
pub fn apply(&mut self, ctx: &egui::Context) -> bool {
|
||||
if !std::mem::take(&mut self.changed) {
|
||||
return false;
|
||||
}
|
||||
crate::theme::install_fonts_with(ctx, &self.faces);
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a presentation that the interface can show (not the slides' pictures of it).
|
||||
pub fn ui_text(st: &deckcraft_engine::DocState) -> String {
|
||||
let mut out = st.title();
|
||||
let mut add = |s: &str| {
|
||||
if s.chars().any(wanted) {
|
||||
out.push('\n');
|
||||
out.push_str(s);
|
||||
}
|
||||
};
|
||||
let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| {
|
||||
for p in &b.paragraphs {
|
||||
for r in &p.runs {
|
||||
add(&r.text);
|
||||
}
|
||||
}
|
||||
};
|
||||
let doc = &st.doc;
|
||||
add(&doc.props.title);
|
||||
for sec in &doc.sections {
|
||||
add(&sec.name);
|
||||
}
|
||||
let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| {
|
||||
deckcraft_model::walk(list, &mut |sh, _| {
|
||||
add(&sh.name);
|
||||
add(&sh.title);
|
||||
add(&sh.descr);
|
||||
if let Some(t) = &sh.text {
|
||||
body(t, add);
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
for row in &tb.rows {
|
||||
for cell in &row.cells {
|
||||
body(&cell.text, add);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
for s in &doc.slides {
|
||||
add(&s.name);
|
||||
shapes(&s.shapes, &mut add);
|
||||
body(&s.notes, &mut add);
|
||||
let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect();
|
||||
while let Some(c) = stack.pop() {
|
||||
add(&c.author);
|
||||
add(&c.text);
|
||||
stack.extend(c.replies.iter());
|
||||
}
|
||||
}
|
||||
for m in &doc.masters {
|
||||
add(&m.name);
|
||||
for l in &m.layouts {
|
||||
add(&l.name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn face(family: &str) -> Arc<FontFace> {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
fn one_face_per_script_and_each_character_once() {
|
||||
let inter = face("Inter");
|
||||
let looked = std::cell::Cell::new(0);
|
||||
let lookup = |_: char| {
|
||||
looked.set(looked.get() + 1);
|
||||
Some(inter.clone())
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup);
|
||||
assert_eq!(got.len(), 1, "one face, not one per character");
|
||||
// Inter doesn't cover Hangul, so every character was looked up once.
|
||||
assert_eq!(looked.get(), 5);
|
||||
assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen");
|
||||
assert_eq!(looked.get(), 5);
|
||||
let mut seen = HashSet::new();
|
||||
assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it");
|
||||
assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use");
|
||||
assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_text_of_a_presentation() {
|
||||
let mut s = deckcraft_engine::Session::with_new();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap();
|
||||
s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap();
|
||||
let t = ui_text(s.doc().unwrap());
|
||||
assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}");
|
||||
assert!(t.starts_with(&s.doc().unwrap().title()));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
|
||||
| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) |
|
||||
| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) |
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
|
||||
26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
|
||||
infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
|
||||
- **No IME composition in text editing** (CJK input). 3–6 h.
|
||||
- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
|
||||
(Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
|
||||
outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
|
||||
Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h.
|
||||
- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h.
|
||||
- **UI isn't mirrored for right-to-left languages.** 6–10 h.
|
||||
- **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h.
|
||||
|
||||
@@ -251,6 +255,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
|
||||
|
||||
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
|
||||
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
|
||||
@@ -46,7 +46,7 @@ because it mostly follows from hardware use.
|
||||
| Pinch to zoom, two-finger gestures | yes | no |
|
||||
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
|
||||
| Touch screens (Windows) | yes | basic pointer only |
|
||||
| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) |
|
||||
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
|
||||
| Dictation | yes (macOS) | no |
|
||||
|
||||
## Performance
|
||||
@@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME input methods (#101) |
|
||||
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
|
||||
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
|
||||
@@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review.
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
|
||||
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
|
||||
| French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
|
||||
| Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
|
||||
| Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||
| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
|
||||
| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 |
|
||||
| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
|
||||
| Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
|
||||
|
||||
Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
|
||||
@@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
|
||||
Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
|
||||
sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
|
||||
the system (#15), plural and number formatting, and an `xtask` check that every key has a
|
||||
translation. IME composition (3–6 h) is counted once, under Simplified Chinese.
|
||||
translation. IME composition landed with #101 (slide text, notes and text fields; one undo step
|
||||
per composition); the hours that included it are kept until the next re-measure.
|
||||
|
||||
## Proofing languages
|
||||
|
||||
@@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
|
||||
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||
Reference in new issue
Block a user