//! The Character Map window (E5, View › Character map…, Ctrl+Shift+M): //! the characters of the font of the text being typed; a click inserts one //! at the caret through the `text.insert_char` command (which stores the //! font's own code for Symbol and ZapfDingbats). Ordinary fonts show Unicode //! blocks; the Symbol and ZapfDingbats fonts show their own tables. Only //! the characters the font can draw are listed (its own face or the //! fallback face), each drawn in that font. use crate::App; use rustfig_text::symbol_tables::{self, FONT_DINGBATS, FONT_SYMBOL}; use serde_json::json; /// A block of Unicode shown for ordinary fonts: name and code point range. pub const BLOCKS: &[(&str, u32, u32)] = &[ ("Latin-1", 0xA0, 0xFF), ("Latin Extended-A", 0x100, 0x17F), ("Greek", 0x370, 0x3FF), ("Punctuation", 0x2010, 0x205E), ("Super- and subscripts", 0x2070, 0x209F), ("Currency", 0x20A0, 0x20BF), ("Arrows", 0x2190, 0x21FF), ("Mathematical operators", 0x2200, 0x22FF), ("Box drawing", 0x2500, 0x257F), ("Geometric shapes", 0x25A0, 0x25FF), ]; /// State of the Character Map window. #[derive(Default)] pub struct CharMap { pub open: bool, /// The selected block of `BLOCKS`. pub block: usize, /// The search field: a character or a hexadecimal code point. pub filter: String, /// The character last pointed at (shown below the table). pub hover: Option<(char, Option)>, /// The characters last listed and what they were listed for (font, /// PostScript, block, search): shaping every character each frame is /// too slow. cache: Option<(ListKey, Vec)>, } /// What the character list was made for: font, PostScript, block, search. type ListKey = (i32, bool, usize, String); /// A listed character and its stored code for Symbol and ZapfDingbats. type Entry = (char, Option); /// The characters to list: the block's or the search's, without those the /// font draws as the missing-glyph box. fn drawable(font: i32, ps: bool, block: usize, filter: &str) -> Vec<(char, Option)> { let chars = if filter.trim().is_empty() { table(font, block) } else { found(font, filter) }; let req = rustfig_text::FontRequest::for_fig(font, ps); let ok = rustfig_text::coverage::covered(&req, chars.iter().map(|(c, _)| *c)); chars.into_iter().filter(|(c, _)| ok.contains(c)).collect() } /// The characters of `font`'s table: (character, stored code for the /// Symbol and Dingbats fonts). fn table(font: i32, block: usize) -> Vec<(char, Option)> { if symbol_tables::table_for(font).is_some() { return symbol_tables::glyphs(font) .into_iter() // The private-use pieces of large brackets have no glyphs. .filter(|(_, c)| !('\u{F000}'..='\u{F8FF}').contains(c)) .map(|(b, c)| (c, Some(b))) .collect(); } let (_, lo, hi) = BLOCKS.get(block).copied().unwrap_or(BLOCKS[0]); let mut chars: Vec = (lo..=hi).filter_map(char::from_u32).collect(); // Superscript one to three are Latin-1 characters (U+00B9, U+00B2, // U+00B3), not in the superscripts block: list them after ⁰ too. if lo == 0x2070 { chars.splice(1..1, ['¹', '²', '³']); } chars.into_iter().map(|c| (c, None)).collect() } /// Whether `c` matches the search text: the character itself or its code /// point in hex ("2211", "U+2211", "0x2211"). fn matches(filter: &str, c: char, code: Option) -> bool { let f = filter.trim(); if f.is_empty() { return true; } if f.chars().count() == 1 && f.starts_with(c) { return true; } let hex = f .trim_start_matches("U+") .trim_start_matches("u+") .trim_start_matches("0x") .to_lowercase(); // Leading zeros don't count ("U+00e9" is "e9"). let hex = hex.trim_start_matches('0'); if hex.is_empty() { return false; } let cp = format!("{:x}", u32::from(c)); let stored = code.map(|b| format!("{b:x}")); cp.starts_with(hex) || stored.is_some_and(|s| s == hex) } /// The characters from all tables that match the search, for the font. fn found(font: i32, filter: &str) -> Vec<(char, Option)> { if symbol_tables::table_for(font).is_some() { return table(font, 0) .into_iter() .filter(|(c, b)| matches(filter, *c, *b)) .collect(); } (0..BLOCKS.len()) .flat_map(|b| table(font, b)) .filter(|(c, b)| matches(filter, *c, *b)) .collect() } fn font_name(font: i32) -> Option<&'static str> { match font { FONT_SYMBOL => Some("Symbol"), FONT_DINGBATS => Some("ZapfDingbats"), _ => None, } } /// Show the window when it is open. pub fn show(app: &mut App, ctx: &egui::Context) { if !app.charmap.open { return; } let typing_font = app.engine.typing_font_postscript(); let (font, ps) = typing_font.unwrap_or((app.engine.indicator.font, app.engine.indicator.postscript)); let typing = typing_font.is_some(); let mut open = true; let mut insert: Option = None; let mut block = app.charmap.block.min(BLOCKS.len() - 1); let mut filter = std::mem::take(&mut app.charmap.filter); let mut hover = None; let mut cache = app.charmap.cache.take(); egui::Window::new("Character Map") .open(&mut open) .resizable(false) .collapsible(false) .show(ctx, |ui| { crate::dialog_body(ui, |ui| { match font_name(font) { Some(n) => { ui.label(format!("{n} font: the characters are stored as its own codes.")); } None => { egui::ComboBox::from_label("Block") .selected_text(BLOCKS.get(block).map_or("", |b| b.0)) .show_ui(ui, |ui| { for (i, (n, _, _)) in BLOCKS.iter().enumerate() { ui.selectable_value(&mut block, i, *n); } }); } } ui.horizontal(|ui| { ui.label("Search"); let r = ui.text_edit_singleline(&mut filter); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Character search")); ui.weak("a character or its code, e.g. 2211"); }); let key = (font, ps, block, filter.clone()); let chars = match &cache { Some((k, chars)) if *k == key => chars.clone(), _ => { let chars = drawable(font, ps, block, &filter); cache = Some((key, chars.clone())); chars } }; egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); ui.horizontal_wrapped(|ui| { ui.set_max_width(12.0 * 30.0); for (c, code) in &chars { let text = c.to_string(); let ink = ui.visuals().text_color(); let b = match crate::font_chooser::sample_image(ui.ctx(), font, ps, &text, 16.0) { Some(img) => egui::Button::image(img.tint(ink)), None => egui::Button::new(text), } .min_size(egui::vec2(28.0, 28.0)); let r = ui.add_enabled(typing, b); let label = format!("Insert {c}"); r.widget_info(move || { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone()) }); if r.hovered() { hover = Some((*c, *code)); } if r.clicked() { insert = Some(*c); } } if chars.is_empty() { ui.label("No such character."); } }); }); ui.separator(); match app.charmap.hover { Some((c, Some(b))) => ui.label(format!("U+{:04X}, code {b:#04X} (octal \\{b:03o})", u32::from(c))), Some((c, None)) => ui.label(format!("U+{:04X}", u32::from(c))), None => ui.label(" "), }; if !typing { ui.weak("Choose Text mode and click in the drawing to start typing; then click a character."); } }) }); app.charmap.cache = cache; app.charmap.open = open; app.charmap.block = block; app.charmap.filter = filter; if hover.is_some() { app.charmap.hover = hover; } if let Some(c) = insert { app.run("text.insert_char", json!({"text": c.to_string()})); } } #[cfg(test)] mod tests { use super::*; #[test] fn symbol_table_has_sum_and_search_finds_it() { let t = table(FONT_SYMBOL, 0); assert!(t.contains(&('∑', Some(0xE5)))); assert!(found(FONT_SYMBOL, "2211").contains(&('∑', Some(0xE5)))); assert!(found(FONT_SYMBOL, "∑").contains(&('∑', Some(0xE5)))); assert!(found(0, "U+00e9").iter().any(|(c, _)| *c == 'é')); assert!(found(0, "zzzz").is_empty()); } /// Characters the font can't draw (the missing-glyph box) aren't listed. #[test] fn only_drawable_characters_are_listed() { let all: usize = (0..BLOCKS.len()).map(|b| table(0, b).len()).sum(); let shown: usize = (0..BLOCKS.len()).map(|b| drawable(0, true, b, "").len()).sum(); assert!(shown > 0 && shown <= all); assert!(drawable(0, true, 0, "").iter().any(|(c, _)| *c == 'é')); assert!(drawable(0, true, 0, "E000").is_empty()); } /// Probe (F1): how many characters of each block the installed fonts /// (and the built-in ones) can draw. Run with /// `cargo test -p rustfig-ui-egui charmap_coverage -- --ignored --nocapture`. #[test] #[ignore = "prints the character-map coverage of the installed fonts"] fn charmap_coverage_probe() { for builtin in [false, true] { rustfig_text::set_builtin_fonts(builtin); println!("== {} fonts ==", if builtin { "built-in" } else { "system" }); for (font, name) in [(0, "Times"), (32, "Symbol"), (34, "ZapfDingbats")] { if symbol_tables::table_for(font).is_some() { println!( "{name:14} {}/{}", drawable(font, true, 0, "").len(), table(font, 0).len() ); } else { for (b, (n, ..)) in BLOCKS.iter().enumerate() { println!( "{name:8} {n:24} {}/{}", drawable(font, true, b, "").len(), table(font, b).len() ); } } } } } /// The superscripts block lists ¹²³ (Latin-1) with ⁰ and ⁴ to ⁹. #[test] fn superscripts_one_to_three_are_listed() { let b = BLOCKS.iter().position(|(n, ..)| n.starts_with("Super")).unwrap(); let s: String = table(0, b).iter().map(|(c, _)| *c).take(6).collect(); assert!(s.starts_with("⁰¹²³"), "{s}"); } } .