mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 00:47:28 -06: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>
203 lines
7.9 KiB
Rust
203 lines
7.9 KiB
Rust
//! 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()));
|
|
}
|
|
}
|