Compare commits
2 Commits
main
...
fix/korean
| Author | SHA1 | Date |
|---|---|---|
|
|
d91be2e6d7 | |
|
|
128b93738e |
|
|
@ -129,6 +129,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||||
| Date | What landed |
|
| 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 | 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-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-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) |
|
| 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!(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.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),
|
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.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.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),
|
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> {
|
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||||
let a = p.get(key)?.as_array()?;
|
let a = p.get(key)?.as_array()?;
|
||||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
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()
|
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> {
|
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();
|
let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string();
|
||||||
insert_text(s, &text, None)
|
insert_text(s, &text, None)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ impl Session {
|
||||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||||
/// Returns `{handled: bool, …}`.
|
/// Returns `{handled: bool, …}`.
|
||||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
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 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 has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||||
let k = key.to_ascii_lowercase();
|
let k = key.to_ascii_lowercase();
|
||||||
|
|
@ -151,6 +152,45 @@ impl Session {
|
||||||
self.execute("text.insert", &json!({"text": text}))
|
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<()> {
|
fn cycle_selection(&mut self, forward: bool) -> Result<()> {
|
||||||
let st = self.doc()?;
|
let st = self.doc()?;
|
||||||
let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect();
|
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,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DocState {
|
pub struct DocState {
|
||||||
pub doc: Arc<Presentation>,
|
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
|
/// Fonts the file's text uses that aren't installed, found when it was opened (empty for new
|
||||||
/// documents); the app mentions them once.
|
/// documents); the app mentions them once.
|
||||||
pub missing_fonts: Vec<String>,
|
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);
|
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),
|
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||||
title,
|
title,
|
||||||
missing_fonts: Vec::new(),
|
missing_fonts: Vec::new(),
|
||||||
|
composition: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_dirty(&self) -> bool {
|
pub fn is_dirty(&self) -> bool {
|
||||||
|
|
@ -403,6 +424,10 @@ impl Session {
|
||||||
|
|
||||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
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) {
|
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());
|
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||||
if !named {
|
if !named {
|
||||||
|
|
@ -453,6 +478,16 @@ impl Session {
|
||||||
Ok(r)
|
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).
|
/// Change only the selection (no undo step).
|
||||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||||
let st = self.doc_mut()?;
|
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();
|
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]);
|
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()) {
|
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||||
return Ok(Value::Null);
|
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 {
|
match ev.kind {
|
||||||
PointerKind::Move => {
|
PointerKind::Move => {
|
||||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||||
|
|
|
||||||
|
|
@ -1831,6 +1831,33 @@ impl TextLayout {
|
||||||
}
|
}
|
||||||
out
|
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`.
|
/// Vertical caret movement: the position on the line above/below closest to `x`.
|
||||||
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos {
|
||||||
let Some(li) = self.line_of(pos) else { return 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);
|
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]
|
#[test]
|
||||||
fn alignment_center_and_right() {
|
fn alignment_center_and_right() {
|
||||||
let (p, mut sh) = setup("Hi", 400.0, 100.0);
|
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);
|
pointer(app, ui, &resp, xf);
|
||||||
overlays(app, ui, &painter, xf, &t);
|
overlays(app, ui, &painter, xf, &t);
|
||||||
|
ime_output(app, ui, xf);
|
||||||
media_bar(app, ui, xf);
|
media_bar(app, ui, xf);
|
||||||
gif_buttons(app, ui, xf, &gifs);
|
gif_buttons(app, ui, xf, &gifs);
|
||||||
context_menu(app, &resp);
|
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.
|
/// 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]) {
|
fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) {
|
||||||
let Some(d) = app.session.active() else { return };
|
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));
|
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));
|
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() {
|
if !ts.is_range() {
|
||||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
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 }) {
|
// An empty placeholder shows the caret where typing begins too.
|
||||||
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
|
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||||
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
|
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`") };
|
let Some(t) = s("text") else { return err("missing `text`") };
|
||||||
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
|
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" => {
|
"ui.tool.select" => {
|
||||||
let Some(name) = s("tool") else { return err("missing `tool`") };
|
let Some(name) = s("tool") else { return err("missing `tool`") };
|
||||||
match tool_kind(name, s("preset")) {
|
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 theme;
|
||||||
pub mod thumbs;
|
pub mod thumbs;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
pub mod uifonts;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
@ -214,6 +215,8 @@ pub struct SlideApp {
|
||||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||||
pub quit_confirmed: bool,
|
pub quit_confirmed: bool,
|
||||||
pub(crate) notes_buf: (u64, usize, String),
|
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).
|
/// Thumbnail pane drag (slide index, current drop index).
|
||||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +256,7 @@ impl SlideApp {
|
||||||
last_recovery: 0.0,
|
last_recovery: 0.0,
|
||||||
quit_confirmed: false,
|
quit_confirmed: false,
|
||||||
notes_buf: (0, usize::MAX, String::new()),
|
notes_buf: (0, usize::MAX, String::new()),
|
||||||
|
ui_fonts: uifonts::UiFonts::default(),
|
||||||
thumb_drag: None,
|
thumb_drag: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -545,7 +549,7 @@ impl SlideApp {
|
||||||
ctx.set_zoom_factor(scale);
|
ctx.set_zoom_factor(scale);
|
||||||
}
|
}
|
||||||
if !self.styled {
|
if !self.styled {
|
||||||
theme::install_fonts(ctx);
|
theme::install_fonts_with(ctx, &self.ui_fonts.faces);
|
||||||
self.styled = true;
|
self.styled = true;
|
||||||
self.restyle = true;
|
self.restyle = true;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -568,6 +572,9 @@ impl SlideApp {
|
||||||
if !self.synthetic.is_empty() {
|
if !self.synthetic.is_empty() {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if self.fonts_ready {
|
||||||
|
self.note_ui_text(ctx);
|
||||||
|
}
|
||||||
self.collect_screenshots(ctx);
|
self.collect_screenshots(ctx);
|
||||||
self.issue_screenshots(ctx);
|
self.issue_screenshots(ctx);
|
||||||
// Closing the window with unsaved changes asks first.
|
// 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).
|
/// Inject synthetic events (one pointer event per frame).
|
||||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||||
if self.synthetic.is_empty() {
|
if self.synthetic.is_empty() {
|
||||||
|
|
@ -646,6 +699,7 @@ impl SlideApp {
|
||||||
self.set_status(e.to_string());
|
self.set_status(e.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
egui::Event::Ime(ime) => self.ime_event(&ime),
|
||||||
egui::Event::Copy => {
|
egui::Event::Copy => {
|
||||||
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
|
||||||
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
&& let Some(t) = v.get("text").and_then(Value::as_str)
|
||||||
|
|
@ -889,6 +943,9 @@ fn js_now() -> f64 {
|
||||||
js_sys::Date::now()
|
js_sys::Date::now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ime_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||||
ui.painter()
|
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));
|
.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.
|
// 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 id = egui::Id::new("notes_edit");
|
||||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
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();
|
app.notes_buf.2 = current.clone();
|
||||||
}
|
}
|
||||||
let mut buf = app.notes_buf.2.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.
|
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
|
||||||
pub fn install_fonts(ctx: &egui::Context) {
|
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 defs = FontDefinitions::default();
|
||||||
let mut names = vec![];
|
let mut names = vec![];
|
||||||
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
|
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();
|
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());
|
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
|
||||||
defs.families.insert(FontFamily::Name("bold".into()), bold);
|
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 {
|
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.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.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.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.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.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"`) |
|
| `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"`) |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Where DeckCraft falls short of PowerPoint
|
# 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
|
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
|
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
|
- **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
|
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).
|
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.
|
- **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.
|
- **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 |
|
| 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-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 | 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 |
|
| 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
|
# 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%
|
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
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Dictation | yes (macOS) | no |
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| 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 |
|
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Localization parity with PowerPoint
|
# 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
|
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
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
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
|
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
|
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
|
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
|
## Proofing languages
|
||||||
|
|
||||||
|
|
@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
|
||||||
|
|
||||||
| Date | Change | Summary |
|
| 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 |
|
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue