authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 12:46:28-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 12:54:23-07:00
log931a2dbdbbff49d90d4b2cca30f1bd7488a2859b
treeabd26aa63bbad9483c38169b00c302068bc4e33a
parentf68c9103fa67fd96322bb45c6ae66086d53bc18d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Insert Symbol picks from an in-app picker on every platform

Insert Symbol ran gnome-characters through PATH on Linux, which fails where it is installed only as a Flatpak (#56); Windows ran charmap.exe and macOS the Character Viewer. It now works as OneNote 2010 does (Windows 7 lab, 2026-10-01): the Symbol button opens a gallery of the 20 symbols inserted lately, filled with OneNote's defaults, and More Symbols opens a dialog with a search by Unicode name, a subset jump, a grid of the characters the interface fonts draw, the recently used row, and a character code field. Insert, Enter or a double click inserts at the caret as an undo step of its own and leaves the dialog open. Recent symbols persist in the settings. macOS keeps Emoji & Symbols in the Edit menu. Assisted-by: claude-opus-5.5

14 files changed, 1219 insertions(+), 51 deletions(-)

Cargo.lock+105-7
...@@ -36,7 +36,7 @@ dependencies = [...@@ -36,7 +36,7 @@ dependencies = [
36 "accesskit",36 "accesskit",
37 "accesskit_consumer",37 "accesskit_consumer",
38 "atspi-common",38 "atspi-common",
39 "phf",39 "phf 0.14.0",
40 "serde",40 "serde",
41 "zvariant",41 "zvariant",
42]42]
...@@ -876,7 +876,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"...@@ -876,7 +876,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
876dependencies = [876dependencies = [
877 "getrandom 0.4.3",877 "getrandom 0.4.3",
878 "hybrid-array",878 "hybrid-array",
879 "rand_core",879 "rand_core 0.10.1",
880]880]
881881
882[[package]]882[[package]]
...@@ -1318,7 +1318,7 @@ dependencies = [...@@ -1318,7 +1318,7 @@ dependencies = [
1318 "cfg-if",1318 "cfg-if",
1319 "libc",1319 "libc",
1320 "r-efi 6.0.0",1320 "r-efi 6.0.0",
1321 "rand_core",1321 "rand_core 0.10.1",
1322]1322]
13231323
1324[[package]]1324[[package]]
...@@ -2613,6 +2613,15 @@ version = "2.3.2"...@@ -2613,6 +2613,15 @@ version = "2.3.2"
2613source = "registry+https://github.com/rust-lang/crates.io-index"2613source = "registry+https://github.com/rust-lang/crates.io-index"
2614checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2614checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
26152615
2616[[package]]
2617name = "phf"
2618version = "0.11.3"
2619source = "registry+https://github.com/rust-lang/crates.io-index"
2620checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
2621dependencies = [
2622 "phf_shared 0.11.3",
2623]
2624
2616[[package]]2625[[package]]
2617name = "phf"2626name = "phf"
2618version = "0.14.0"2627version = "0.14.0"
...@@ -2620,10 +2629,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -2620,10 +2629,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2620checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"2629checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
2621dependencies = [2630dependencies = [
2622 "phf_macros",2631 "phf_macros",
2623 "phf_shared",2632 "phf_shared 0.14.0",
2624 "serde",2633 "serde",
2625]2634]
26262635
2636[[package]]
2637name = "phf_codegen"
2638version = "0.11.3"
2639source = "registry+https://github.com/rust-lang/crates.io-index"
2640checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
2641dependencies = [
2642 "phf_generator 0.11.3",
2643 "phf_shared 0.11.3",
2644]
2645
2646[[package]]
2647name = "phf_generator"
2648version = "0.11.3"
2649source = "registry+https://github.com/rust-lang/crates.io-index"
2650checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
2651dependencies = [
2652 "phf_shared 0.11.3",
2653 "rand",
2654]
2655
2627[[package]]2656[[package]]
2628name = "phf_generator"2657name = "phf_generator"
2629version = "0.14.0"2658version = "0.14.0"
...@@ -2631,7 +2660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -2631,7 +2660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2631checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"2660checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
2632dependencies = [2661dependencies = [
2633 "fastrand",2662 "fastrand",
2634 "phf_shared",2663 "phf_shared 0.14.0",
2635]2664]
26362665
2637[[package]]2666[[package]]
...@@ -2640,13 +2669,22 @@ version = "0.14.0"...@@ -2640,13 +2669,22 @@ version = "0.14.0"
2640source = "registry+https://github.com/rust-lang/crates.io-index"2669source = "registry+https://github.com/rust-lang/crates.io-index"
2641checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"2670checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
2642dependencies = [2671dependencies = [
2643 "phf_generator",2672 "phf_generator 0.14.0",
2644 "phf_shared",2673 "phf_shared 0.14.0",
2645 "proc-macro2",2674 "proc-macro2",
2646 "quote",2675 "quote",
2647 "syn 2.0.119",2676 "syn 2.0.119",
2648]2677]
26492678
2679[[package]]
2680name = "phf_shared"
2681version = "0.11.3"
2682source = "registry+https://github.com/rust-lang/crates.io-index"
2683checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
2684dependencies = [
2685 "siphasher",
2686]
2687
2650[[package]]2688[[package]]
2651name = "phf_shared"2689name = "phf_shared"
2652version = "0.14.0"2690version = "0.14.0"
...@@ -2796,6 +2834,15 @@ version = "0.2.0"...@@ -2796,6 +2834,15 @@ version = "0.2.0"
2796source = "registry+https://github.com/rust-lang/crates.io-index"2834source = "registry+https://github.com/rust-lang/crates.io-index"
2797checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"2835checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
27982836
2837[[package]]
2838name = "ppv-lite86"
2839version = "0.2.21"
2840source = "registry+https://github.com/rust-lang/crates.io-index"
2841checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2842dependencies = [
2843 "zerocopy",
2844]
2845
2799[[package]]2846[[package]]
2800name = "presser"2847name = "presser"
2801version = "0.3.1"2848version = "0.3.1"
...@@ -2868,6 +2915,36 @@ version = "6.0.0"...@@ -2868,6 +2915,36 @@ version = "6.0.0"
2868source = "registry+https://github.com/rust-lang/crates.io-index"2915source = "registry+https://github.com/rust-lang/crates.io-index"
2869checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"2916checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
28702917
2918[[package]]
2919name = "rand"
2920version = "0.8.8"
2921source = "registry+https://github.com/rust-lang/crates.io-index"
2922checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
2923dependencies = [
2924 "libc",
2925 "rand_chacha",
2926 "rand_core 0.6.4",
2927]
2928
2929[[package]]
2930name = "rand_chacha"
2931version = "0.3.1"
2932source = "registry+https://github.com/rust-lang/crates.io-index"
2933checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
2934dependencies = [
2935 "ppv-lite86",
2936 "rand_core 0.6.4",
2937]
2938
2939[[package]]
2940name = "rand_core"
2941version = "0.6.4"
2942source = "registry+https://github.com/rust-lang/crates.io-index"
2943checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2944dependencies = [
2945 "getrandom 0.2.17",
2946]
2947
2871[[package]]2948[[package]]
2872name = "rand_core"2949name = "rand_core"
2873version = "0.10.1"2950version = "0.10.1"
...@@ -3433,6 +3510,7 @@ dependencies = [...@@ -3433,6 +3510,7 @@ dependencies = [
3433 "serde_json",3510 "serde_json",
3434 "smithay-clipboard",3511 "smithay-clipboard",
3435 "ui",3512 "ui",
3513 "unicode_names2",
3436 "ureq",3514 "ureq",
3437 "wayland-backend",3515 "wayland-backend",
3438 "wayland-client",3516 "wayland-client",
...@@ -3860,6 +3938,26 @@ version = "0.2.2"...@@ -3860,6 +3938,26 @@ version = "0.2.2"
3860source = "registry+https://github.com/rust-lang/crates.io-index"3938source = "registry+https://github.com/rust-lang/crates.io-index"
3861checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"3939checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
38623940
3941[[package]]
3942name = "unicode_names2"
3943version = "4.0.0"
3944source = "registry+https://github.com/rust-lang/crates.io-index"
3945checksum = "8ba646312acace3c9ec35d7404b58214b6fbf17cbb0d040f35a88407c424447f"
3946dependencies = [
3947 "phf 0.11.3",
3948 "unicode_names2_generator",
3949]
3950
3951[[package]]
3952name = "unicode_names2_generator"
3953version = "4.0.0"
3954source = "registry+https://github.com/rust-lang/crates.io-index"
3955checksum = "3afbb72a8cff40859641ae4e56679215a15764e5c155a46f0f534611bdc08e0e"
3956dependencies = [
3957 "phf_codegen",
3958 "rand",
3959]
3960
3863[[package]]3961[[package]]
3864name = "universal-hash"3962name = "universal-hash"
3865version = "0.6.1"3963version = "0.6.1"
crates/canvas/src/editor.rs+40
...@@ -3502,6 +3502,19 @@ impl CanvasEditor {...@@ -3502,6 +3502,19 @@ impl CanvasEditor {
3502 }3502 }
3503 }3503 }
35043504
3505 /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the
3506 /// typing before it nor takes the typing after.
3507 pub fn insert_symbol(
3508 &mut self,
3509 engine: &mut TextEngine,
3510 symbol: char,
3511 ) -> Result<(), EditorError> {
3512 self.typing = None;
3513 self.insert(engine, symbol.encode_utf8(&mut [0; 4]))?;
3514 self.typing = None;
3515 Ok(())
3516 }
3517
3505 fn insert_text(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {3518 fn insert_text(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
3506 if self.page_selected() {3519 if self.page_selected() {
3507 return self.grouped(|editor| {3520 return self.grouped(|editor| {
...@@ -5918,6 +5931,33 @@ mod tests {...@@ -5918,6 +5931,33 @@ mod tests {
5918 assert!(shown.is_empty());5931 assert!(shown.is_empty());
5919 }5932 }
59205933
5934 #[test]
5935 fn a_symbol_is_an_undo_step_between_typing_runs() {
5936 let mut engine = TextEngine::default();
5937 let source =
5938 TextDocument::new(vec![Paragraph::new("Cost".into(), Format::default())]).unwrap();
5939 let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap();
5940 let text = |editor: &CanvasEditor| editor.active_outline().shown_text();
5941 editor
5942 .select(
5943 [TextPosition {
5944 paragraph: 0,
5945 offset: 4,
5946 }; 2]
5947 .into(),
5948 )
5949 .unwrap();
5950 editor.insert(&mut engine, " 5").unwrap();
5951 editor.insert_symbol(&mut engine, '€').unwrap();
5952 editor.insert_symbol(&mut engine, '±').unwrap();
5953 editor.insert(&mut engine, "1").unwrap();
5954 assert_eq!(text(&editor), "Cost 5€±1");
5955 for shown in ["Cost 5€±", "Cost 5€", "Cost 5", "Cost"] {
5956 assert!(editor.undo(&mut engine).unwrap());
5957 assert_eq!(text(&editor), shown);
5958 }
5959 }
5960
5921 #[test]5961 #[test]
5922 fn table_horizontal_arrows_follow_reading_order() {5962 fn table_horizontal_arrows_follow_reading_order() {
5923 let mut engine = TextEngine::default();5963 let mut engine = TextEngine::default();
crates/canvas/src/interaction/mod.rs+9
...@@ -1470,6 +1470,15 @@ impl PageView {...@@ -1470,6 +1470,15 @@ impl PageView {
1470 self.commit_text(text)1470 self.commit_text(text)
1471 }1471 }
14721472
1473 /// Insert Symbol: see [`CanvasEditor::insert_symbol`].
1474 pub fn insert_symbol(&mut self, symbol: char) -> Result<Response> {
1475 if self.edits_wait() {
1476 return Ok(Response::default());
1477 }
1478 self.editor.insert_symbol(&mut self.engine, symbol)?;
1479 self.edited()
1480 }
1481
1473 /// Text committed by an input method, line breaks included.1482 /// Text committed by an input method, line breaks included.
1474 pub fn commit_text(&mut self, text: String) -> Result<Response> {1483 pub fn commit_text(&mut self, text: String) -> Result<Response> {
1475 if !self.accepts_text() {1484 if !self.accepts_text() {
crates/snowbound/Cargo.toml+2
...@@ -33,6 +33,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] }...@@ -33,6 +33,8 @@ ureq = { version = "3.4", default-features = false, features = ["rustls"] }
33ring = "0.17"33ring = "0.17"
34# Clears passwords typed into the password dialogs.34# Clears passwords typed into the password dialogs.
35zeroize = "1.9.0"35zeroize = "1.9.0"
36# Names characters in More Symbols, Insert Symbol's dialog.
37unicode_names2 = "4.0.0"
3638
37[target.'cfg(target_os = "macos")'.dependencies]39[target.'cfg(target_os = "macos")'.dependencies]
38libc = "0.2"40libc = "0.2"
crates/snowbound/src/commands.rs+24-10
...@@ -144,6 +144,8 @@ pub enum Choice {...@@ -144,6 +144,8 @@ pub enum Choice {
144 Art(Option<&'static str>),144 Art(Option<&'static str>),
145 /// A place in the pen gallery, which Pen then draws with.145 /// A place in the pen gallery, which Pen then draws with.
146 Pen(usize),146 Pen(usize),
147 /// A symbol to insert, or `None` for More Symbols.
148 Symbol(Option<char>),
147}149}
148150
149#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
...@@ -658,7 +660,8 @@ pub fn title(choice: Choice) -> String {...@@ -658,7 +660,8 @@ pub fn title(choice: Choice) -> String {
658 | Choice::PageColor(_)660 | Choice::PageColor(_)
659 | Choice::RuleLines(_)661 | Choice::RuleLines(_)
660 | Choice::Art(_)662 | Choice::Art(_)
661 | Choice::Pen(_) => {663 | Choice::Pen(_)
664 | Choice::Symbol(_) => {
662 unreachable!("Only the toolbar offers these")665 unreachable!("Only the toolbar offers these")
663 }666 }
664 }667 }
...@@ -1104,6 +1107,7 @@ impl State {...@@ -1104,6 +1107,7 @@ impl State {
1104 Choice::Table(_) => Id::Table,1107 Choice::Table(_) => Id::Table,
1105 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,1108 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
1106 Choice::Pen(_) => Id::Pen,1109 Choice::Pen(_) => Id::Pen,
1110 Choice::Symbol(_) => Id::Symbol,
1107 };1111 };
1108 let tool = self.view.tool();1112 let tool = self.view.tool();
1109 match id {1113 match id {
...@@ -1369,6 +1373,20 @@ impl State {...@@ -1369,6 +1373,20 @@ impl State {
1369 self.save_settings();1373 self.save_settings();
1370 Id::Pen1374 Id::Pen
1371 }1375 }
1376 Choice::Symbol(None) => {
1377 self.open_symbols();
1378 return Ok(());
1379 }
1380 Choice::Symbol(Some(symbol)) => {
1381 let recent = &mut self.toolbar.symbols;
1382 recent.retain(|known| *known != symbol);
1383 recent.insert(0, symbol);
1384 recent.truncate(crate::symbol::GALLERY);
1385 self.save_settings();
1386 let response = self.view.insert_symbol(symbol)?;
1387 self.respond(response);
1388 return Ok(());
1389 }
1372 };1390 };
1373 let field = self.ui.focused_field();1391 let field = self.ui.focused_field();
1374 let response = match id {1392 let response = match id {
...@@ -1530,11 +1548,11 @@ impl State {...@@ -1530,11 +1548,11 @@ impl State {
1530 return Ok(());1548 return Ok(());
1531 }1549 }
1532 // The menu bar and keys open the toolbar's gallery.1550 // The menu bar and keys open the toolbar's gallery.
1533 Id::Table | Id::PageColor => {1551 Id::Table | Id::PageColor | Id::Symbol => {
1534 let name = if id == Id::Table {1552 let name = match id {
1535 "table"1553 Id::Table => "table",
1536 } else {1554 Id::Symbol => "symbol",
1537 "page color"1555 _ => "page color",
1538 };1556 };
1539 self.ui.open_popup(crate::toolbar_popup(name));1557 self.ui.open_popup(crate::toolbar_popup(name));
1540 return Ok(());1558 return Ok(());
...@@ -1557,10 +1575,6 @@ impl State {...@@ -1557,10 +1575,6 @@ impl State {
1557 platform::clip_screen(self.proxy.clone());1575 platform::clip_screen(self.proxy.clone());
1558 return Ok(());1576 return Ok(());
1559 }1577 }
1560 Id::Symbol => {
1561 platform::character_palette();
1562 return Ok(());
1563 }
1564 Id::HideSpelling => {1578 Id::HideSpelling => {
1565 self.hide_spelling = !self.hide_spelling;1579 self.hide_spelling = !self.hide_spelling;
1566 self.show_spelling();1580 self.show_spelling();
crates/snowbound/src/linux.rs-7
...@@ -1176,13 +1176,6 @@ pub fn open_file(path: &std::path::Path) {...@@ -1176,13 +1176,6 @@ pub fn open_file(path: &std::path::Path) {
1176 reveal(path);1176 reveal(path);
1177}1177}
11781178
1179/// Opens GNOME's character map, whose picks the user copies in.
1180pub fn character_palette() {
1181 if let Err(error) = Command::new("gnome-characters").spawn() {
1182 eprintln!("Cannot open the character map: {error}");
1183 }
1184}
1185
1186/// Opens `target`, a folder or a link's URL, with the desktop's handler.1179/// Opens `target`, a folder or a link's URL, with the desktop's handler.
1187pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {1180pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1188 let target = target.as_ref();1181 let target = target.as_ref();
crates/snowbound/src/macos.rs-6
...@@ -857,12 +857,6 @@ pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {...@@ -857,12 +857,6 @@ pub fn clip_screen(proxy: EventLoopProxy<crate::UserEvent>) {
857 });857 });
858}858}
859859
860/// Shows the system's character palette, whose picks arrive as inserted text.
861pub fn character_palette() {
862 let mtm = MainThreadMarker::new().expect("AppKit belongs to the main thread");
863 NSApplication::sharedApplication(mtm).orderFrontCharacterPalette(None);
864}
865
866/// Tells the user something they asked for could not be done: `message`, then what to do.860/// Tells the user something they asked for could not be done: `message`, then what to do.
867pub fn alert(message: &str, detail: &str) {861pub fn alert(message: &str, detail: &str) {
868 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");862 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
crates/snowbound/src/main.rs+24-1
...@@ -62,6 +62,7 @@ mod spell;...@@ -62,6 +62,7 @@ mod spell;
62#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]62#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
63#[cfg_attr(windows, path = "surface_windows.rs")]63#[cfg_attr(windows, path = "surface_windows.rs")]
64mod surface;64mod surface;
65mod symbol;
65mod sync;66mod sync;
66mod tags;67mod tags;
67mod templates;68mod templates;
...@@ -705,6 +706,7 @@ struct State {...@@ -705,6 +706,7 @@ struct State {
705 /// The Link dialog's fields while it is open.706 /// The Link dialog's fields while it is open.
706 link: Option<link::LinkDialog>,707 link: Option<link::LinkDialog>,
707 properties: Option<properties::Properties>,708 properties: Option<properties::Properties>,
709 symbols: Option<symbol::Symbols>,
708 /// Save As while it is open.710 /// Save As while it is open.
709 save_as: Option<save_as::Dialog>,711 save_as: Option<save_as::Dialog>,
710 /// Unpack Notebook while it is open.712 /// Unpack Notebook while it is open.
...@@ -1077,6 +1079,7 @@ impl State {...@@ -1077,6 +1079,7 @@ impl State {
1077 after_open: None,1079 after_open: None,
1078 link: None,1080 link: None,
1079 properties: None,1081 properties: None,
1082 symbols: None,
1080 save_as: None,1083 save_as: None,
1081 unpacking: None,1084 unpacking: None,
1082 reads,1085 reads,
...@@ -1251,6 +1254,7 @@ impl State {...@@ -1251,6 +1254,7 @@ impl State {
1251 self.new_icloud_dialog();1254 self.new_icloud_dialog();
1252 self.link_dialog()?;1255 self.link_dialog()?;
1253 self.properties_dialog();1256 self.properties_dialog();
1257 self.symbols_dialog();
1254 self.save_as_dialog();1258 self.save_as_dialog();
1255 self.unpack_dialog();1259 self.unpack_dialog();
1256 self.password_dialog()?;1260 self.password_dialog()?;
...@@ -2501,7 +2505,6 @@ impl State {...@@ -2501,7 +2505,6 @@ impl State {
2501 Cmd::InsertSpace,2505 Cmd::InsertSpace,
2502 Cmd::Time,2506 Cmd::Time,
2503 Cmd::DateTime,2507 Cmd::DateTime,
2504 Cmd::Symbol,
2505 Cmd::RecordAudio,2508 Cmd::RecordAudio,
2506 Cmd::RecordVideo,2509 Cmd::RecordVideo,
2507 ]2510 ]
...@@ -2537,6 +2540,25 @@ impl State {...@@ -2537,6 +2540,25 @@ impl State {
2537 for id in inserted {2540 for id in inserted {
2538 choice = tool(ui, id, status_of(id)).or(choice);2541 choice = tool(ui, id, status_of(id)).or(choice);
2539 }2542 }
2543 if status_of(Cmd::Symbol).enabled {
2544 let anchor = ui::shell::menu_button(
2545 ui,
2546 "symbol",
2547 art::SYMBOL,
2548 None,
2549 toolbar_popup("symbol"),
2550 );
2551 tip(ui, Cmd::Symbol);
2552 let symbols = symbol::recent(&pens.symbols);
2553 if let Some(pick) =
2554 symbol::gallery(ui, toolbar_popup("symbol"), anchor, &symbols)
2555 {
2556 choice = Some(Choice::Symbol(pick));
2557 }
2558 } else {
2559 ui::shell::unavailable(ui, "symbol", art::SYMBOL, text, true);
2560 tip(ui, Cmd::Symbol);
2561 }
2540 let mut entries = more.clone();2562 let mut entries = more.clone();
2541 entries.push(Rule);2563 entries.push(Rule);
2542 entries.extend(drawing.clone());2564 entries.extend(drawing.clone());
...@@ -2552,6 +2574,7 @@ impl State {...@@ -2552,6 +2574,7 @@ impl State {
2552 |ui| {2574 |ui| {
2553 let mut entries = vec![Open("table", Cmd::Table)];2575 let mut entries = vec![Open("table", Cmd::Table)];
2554 entries.extend(inserted.map(Run));2576 entries.extend(inserted.map(Run));
2577 entries.push(Open("symbol", Cmd::Symbol));
2555 entries.push(Rule);2578 entries.push(Rule);
2556 entries.extend(more.iter().copied());2579 entries.extend(more.iter().copied());
2557 entries.push(Rule);2580 entries.push(Rule);
crates/snowbound/src/settings.rs+4
...@@ -84,6 +84,8 @@ pub struct Toolbar {...@@ -84,6 +84,8 @@ pub struct Toolbar {
84 pub numbering: Vec<usize>,84 pub numbering: Vec<usize>,
85 /// The place in the pen gallery Pen draws with: the accent pen first.85 /// The place in the pen gallery Pen draws with: the accent pen first.
86 pub pen: usize,86 pub pen: usize,
87 /// Symbols inserted lately, latest first.
88 pub symbols: Vec<char>,
87}89}
8890
89impl Default for Toolbar {91impl Default for Toolbar {
...@@ -95,6 +97,7 @@ impl Default for Toolbar {...@@ -95,6 +97,7 @@ impl Default for Toolbar {
95 bullets: Vec::new(),97 bullets: Vec::new(),
96 numbering: Vec::new(),98 numbering: Vec::new(),
97 pen: 0,99 pen: 0,
100 symbols: Vec::new(),
98 }101 }
99 }102 }
100}103}
...@@ -233,6 +236,7 @@ mod tests {...@@ -233,6 +236,7 @@ mod tests {
233 bullets: vec![3, 0],236 bullets: vec![3, 0],
234 numbering: vec![16],237 numbering: vec![16],
235 pen: 6,238 pen: 6,
239 symbols: vec!['⅔', '€'],
236 },240 },
237 user_name: Some("Snowbound Test".into()),241 user_name: Some("Snowbound Test".into()),
238 color_scheme: ColorScheme::Dark,242 color_scheme: ColorScheme::Dark,
crates/snowbound/src/symbol.rs created+969
...@@ -0,0 +1,969 @@
1//! Insert Symbol, as OneNote 2010's: a gallery of the symbols inserted lately, and More
2//! Symbols, which finds any character by subset, name or code.
3
4use crate::{Command, State, art, commands::Choice};
5use std::sync::OnceLock;
6use ui::{Anchor, Axis, Flags, Id, Rows, Spec, Ui, children, fill, fit, popup::Item, px};
7use winit::keyboard::NamedKey;
8
9const TITLE: &str = "Symbol";
10/// Symbols the gallery holds and the settings keep.
11pub const GALLERY: usize = 20;
12/// The dialog's grid: 16 columns of cells, as OneNote's, with a scrollbar beside them.
13const COLUMNS: usize = 16;
14const ROWS: usize = 6;
15const CELL: f32 = 30.0;
16/// The grid's width, with room for its scrollbar.
17const GRID: f32 = COLUMNS as f32 * CELL + 12.0;
18
19/// OneNote 2010's gallery before any symbol is inserted (Windows 7 lab, 2026-10-01).
20const DEFAULTS: [char; GALLERY] = [
21 '€', '£', '¥', '©', '®', '™', '±', '≠', '≤', '≥', '÷', '×', '∞', 'µ', 'α', 'β', 'π', 'Ω', '∑',
22 '☺',
23];
24
25/// More Symbols while it is open.
26pub struct Symbols {
27 search: String,
28 /// The search, upper case, and the characters it found; none while it is empty.
29 found: Option<(String, Vec<char>)>,
30 selected: char,
31 /// What the Character code field holds, a hex code point.
32 code: String,
33 /// Cancel reads Close once a symbol is inserted, as in OneNote.
34 inserted: bool,
35}
36
37impl Symbols {
38 fn select(&mut self, symbol: char) {
39 self.selected = symbol;
40 self.code = code(symbol);
41 }
42}
43
44/// `symbol`'s code point in hex, as the Character code field shows it.
45fn code(symbol: char) -> String {
46 format!("{:04X}", u32::from(symbol))
47}
48
49fn id() -> Id {
50 Id::ROOT.child("symbols")
51}
52
53fn search_field() -> Id {
54 id().child("search")
55}
56
57fn code_field() -> Id {
58 id().child("code")
59}
60
61fn grid() -> Id {
62 id().child("grid")
63}
64
65fn subsets() -> Id {
66 id().child("subsets")
67}
68
69/// The gallery's symbols: those inserted lately, then OneNote's defaults.
70pub fn recent(inserted: &[char]) -> Vec<char> {
71 let mut shown = inserted.to_vec();
72 shown.extend(DEFAULTS.iter().filter(|symbol| !inserted.contains(symbol)));
73 shown.truncate(GALLERY);
74 shown
75}
76
77/// Builds popup `id` below `anchor` while it is open as the toolbar's Symbol gallery:
78/// `symbols`, five to a row, then More Symbols. Returns the symbol chosen, or `None` for
79/// More Symbols.
80pub fn gallery(ui: &mut Ui, id: Id, anchor: Anchor, symbols: &[char]) -> Option<Option<char>> {
81 const SIZE: f32 = 32.0;
82 let groups = [
83 ui::popup::Group {
84 heading: "",
85 cells: symbols.len(),
86 columns: 5,
87 size: [SIZE; 2],
88 },
89 ui::popup::Group {
90 heading: "",
91 cells: 1,
92 columns: 1,
93 size: [5.0 * SIZE, 28.0],
94 },
95 ];
96 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &[], |ui, index| {
97 match symbols.get(index) {
98 Some(&symbol) => glyph(ui, symbol, None),
99 None => {
100 ui.leaf(
101 "more",
102 Spec {
103 size: [fill(), fill()],
104 text: Some("More Symbols…"),
105 icon: Some(art::SYMBOL),
106 gap: 6.0,
107 ..Spec::default()
108 },
109 );
110 }
111 }
112 })?;
113 Some(symbols.get(chosen).copied())
114}
115
116/// A cell's `symbol`, centred, named for assistive technology by its Unicode name.
117fn glyph(ui: &mut Ui, symbol: char, color: Option<[f32; 4]>) {
118 let text = symbol.to_string();
119 ui.leaf(
120 "glyph",
121 Spec {
122 size: [fill(), fill()],
123 text: Some(&text),
124 font_size: Some(18.0),
125 color,
126 center: true,
127 ..Spec::default()
128 },
129 );
130 let cell = ui.current();
131 if let Some(node) = ui.access(cell) {
132 node.set_label(name(symbol));
133 }
134}
135
136/// `symbol`'s Unicode name as OneNote shows it, "EURO SIGN" as "Euro Sign"; empty where it
137/// has none.
138fn name(symbol: char) -> String {
139 let Some(name) = unicode_names2::name(symbol) else {
140 return String::new();
141 };
142 // Codes, as in "CJK UNIFIED IDEOGRAPH-4E00", stay as they are.
143 let part = |part: &str| match part.split_at_checked(1) {
144 Some((first, rest)) if !part.bytes().any(|byte| byte.is_ascii_digit()) => {
145 first.to_owned() + &rest.to_ascii_lowercase()
146 }
147 _ => part.to_owned(),
148 };
149 let word = |word: &str| word.split('-').map(part).collect::<Vec<_>>().join("-");
150 let name = name.to_string();
151 name.split(' ').map(word).collect::<Vec<_>>().join(" ")
152}
153
154/// A code point typed in hex, perhaps after "U+" or "0x".
155fn code_point(text: &str) -> Option<char> {
156 let text = text.trim();
157 let digits = ["U+", "u+", "0x", "0X"]
158 .iter()
159 .find_map(|prefix| text.strip_prefix(prefix))
160 .unwrap_or(text);
161 if digits.is_empty() || digits.len() > 6 {
162 return None;
163 }
164 char::from_u32(u32::from_str_radix(digits, 16).ok()?)
165}
166
167/// Every named character of the subsets that the interface's fonts draw, in code point order.
168fn characters(ui: &mut Ui) -> &'static [char] {
169 static CHARACTERS: OnceLock<Vec<char>> = OnceLock::new();
170 CHARACTERS.get_or_init(|| {
171 BLOCKS
172 .iter()
173 .flat_map(|&(start, end, _)| start..=end)
174 .filter_map(char::from_u32)
175 .filter(|symbol| {
176 unicode_names2::name(*symbol).is_some() && ui.shows(symbol.encode_utf8(&mut [0; 4]))
177 })
178 .collect()
179 })
180}
181
182/// The characters `search` finds: the one it is, or names a code point of with "U+", then
183/// those whose names have words starting with each of its words.
184fn find(ui: &mut Ui, search: &str) -> Vec<char> {
185 let mut found = Vec::new();
186 let mut letters = search.chars();
187 if let (Some(symbol), None) = (letters.next(), letters.next()) {
188 found.push(symbol);
189 }
190 if search.starts_with(['U', 'u']) {
191 found.extend(code_point(search));
192 }
193 let typed = found.len();
194 let words: Vec<String> = search.split_whitespace().map(str::to_uppercase).collect();
195 for &symbol in characters(ui) {
196 let Some(name) = unicode_names2::name(symbol) else {
197 continue;
198 };
199 let name = name.to_string();
200 let named = name.split([' ', '-']);
201 if words
202 .iter()
203 .all(|word| named.clone().any(|part| part.starts_with(word.as_str())))
204 && !found[..typed].contains(&symbol)
205 {
206 found.push(symbol);
207 }
208 }
209 found
210}
211
212/// The subset holding `symbol`, as an index into `BLOCKS`.
213fn subset(symbol: char) -> Option<usize> {
214 let point = u32::from(symbol);
215 BLOCKS
216 .iter()
217 .position(|&(start, end, _)| (start..=end).contains(&point))
218}
219
220/// The grid's rows of `COLUMNS` characters.
221struct Grid<'a>(&'a [char]);
222
223impl Rows for Grid<'_> {
224 fn count(&self) -> usize {
225 self.0.len().div_ceil(COLUMNS)
226 }
227
228 fn key(&self, index: usize) -> u64 {
229 index as u64
230 }
231
232 fn find(&self, key: u64) -> Option<usize> {
233 (key < self.count() as u64).then_some(key as usize)
234 }
235
236 // Cells take the clicks, not rows.
237 fn selectable(&self, _: usize) -> bool {
238 false
239 }
240}
241
242/// What the dialog's cells did this frame.
243#[derive(Default)]
244struct Picked {
245 symbol: Option<char>,
246 /// A double click, which inserts it.
247 inserted: bool,
248}
249
250/// A grid cell showing `symbol`, filled while `selected`.
251fn cell(ui: &mut Ui, symbol: char, selected: bool, picked: &mut Picked) {
252 let theme = ui.theme.clone();
253 let signal = ui.open(
254 ("cell", u32::from(symbol)),
255 Spec {
256 flags: Flags::CLICKABLE,
257 axis: Axis::Y,
258 size: [px(CELL), px(CELL)],
259 fill: selected.then_some(theme.accent),
260 hover_fill: (!selected).then(|| theme.hover()),
261 border: Some(theme.chip),
262 role: Some(accesskit::Role::GridCell),
263 ..Spec::default()
264 },
265 );
266 let signal = ui.signal(signal);
267 if signal.pressed {
268 picked.symbol = Some(symbol);
269 picked.inserted |= signal.unit != draw::edit::SelectionUnit::Grapheme;
270 }
271 glyph(ui, symbol, selected.then_some([1.0; 4]));
272 if selected && let Some(node) = ui.access(ui.current()) {
273 node.set_selected(true);
274 }
275 ui.close();
276}
277
278impl State {
279 /// Opens More Symbols on the symbol inserted last, its search taking typing.
280 pub(crate) fn open_symbols(&mut self) {
281 let last = recent(&self.toolbar.symbols)[0];
282 self.symbols = Some(Symbols {
283 search: String::new(),
284 found: None,
285 selected: last,
286 code: code(last),
287 inserted: false,
288 });
289 self.ui.open_popup(id());
290 self.ui.set_focus(Some(search_field()));
291 }
292
293 /// Builds More Symbols while it is open: a search by name, a subset to jump to, the grid
294 /// of characters, the recently used ones, and the selection's name and code. Insert,
295 /// Enter or a double click inserts the selection and leaves the dialog open, as OneNote's.
296 pub(crate) fn symbols_dialog(&mut self) {
297 let Some(dialog) = &mut self.symbols else {
298 return;
299 };
300 let ui = &mut self.ui;
301 if !ui.popup_open(id()) {
302 self.symbols = None;
303 return;
304 }
305 let theme = ui.theme.clone();
306 let row = theme.font_size * 2.0;
307 let mut moves = ui::popup::navigation(
308 ui,
309 &[grid()],
310 &[
311 NamedKey::ArrowLeft,
312 NamedKey::ArrowRight,
313 NamedKey::Home,
314 NamedKey::End,
315 ],
316 );
317 moves.extend(ui::popup::navigation(
318 ui,
319 &[grid(), search_field()],
320 &[
321 NamedKey::ArrowUp,
322 NamedKey::ArrowDown,
323 NamedKey::PageUp,
324 NamedKey::PageDown,
325 ],
326 ));
327 let entered = !ui::popup::navigation(
328 ui,
329 &[grid(), search_field(), code_field()],
330 &[NamedKey::Enter],
331 )
332 .is_empty();
333
334 let search = dialog.search.trim().to_owned();
335 if search.is_empty() {
336 dialog.found = None;
337 } else if dialog
338 .found
339 .as_ref()
340 .is_none_or(|(found, _)| *found != search)
341 {
342 let found = find(ui, &search);
343 if let Some(&first) = found.first() {
344 dialog.select(first);
345 }
346 dialog.found = Some((search, found));
347 }
348 let cells = dialog
349 .found
350 .as_ref()
351 .map_or(characters(ui), |(_, found)| found.as_slice());
352 let mut at = cells.iter().position(|symbol| *symbol == dialog.selected);
353 if let Some(last) = cells.len().checked_sub(1) {
354 for key in moves {
355 let from = at.unwrap_or(0);
356 let page = COLUMNS * ROWS;
357 let to = match key {
358 NamedKey::ArrowLeft => from.saturating_sub(1),
359 NamedKey::ArrowRight => from + 1,
360 NamedKey::ArrowUp => from.saturating_sub(COLUMNS),
361 NamedKey::ArrowDown => from + COLUMNS,
362 NamedKey::PageUp => from.saturating_sub(page),
363 NamedKey::PageDown => from + page,
364 NamedKey::Home => 0,
365 _ => last,
366 };
367 at = Some(to.min(last));
368 }
369 }
370 if let Some(at) = at
371 && cells[at] != dialog.selected
372 {
373 dialog.selected = cells[at];
374 dialog.code = code(cells[at]);
375 }
376
377 ui.open_as(
378 id(),
379 Spec {
380 axis: Axis::Y,
381 size: [px(GRID + 32.0), children()],
382 fill: Some(theme.popup),
383 border: Some(theme.chip),
384 shadow: Some(theme.shadow),
385 radius: 8.0,
386 pad: [16.0, 12.0],
387 gap: 6.0,
388 anchor: Some(Anchor::Dialog),
389 role: Some(accesskit::Role::Dialog),
390 ..Spec::default()
391 },
392 );
393 if let Some(node) = ui.access(id()) {
394 node.set_label(TITLE);
395 }
396 ui.leaf(
397 "title",
398 Spec {
399 size: [fill(), px(row)],
400 text: Some(TITLE),
401 bold: true,
402 role: Some(accesskit::Role::Heading),
403 ..Spec::default()
404 },
405 );
406 let field = Spec {
407 size: [fill(), px(row)],
408 fill: Some(theme.base),
409 border: Some(theme.accent),
410 radius: 4.0,
411 pad: [6.0, 0.0],
412 ..Spec::default()
413 };
414 let label = |ui: &mut Ui, text: &str| {
415 ui.leaf(
416 text,
417 Spec {
418 size: [fit(), px(row)],
419 text: Some(text),
420 ..Spec::default()
421 },
422 );
423 };
424 ui.open(
425 "find",
426 Spec {
427 size: [fill(), children()],
428 gap: 8.0,
429 ..Spec::default()
430 },
431 );
432 ui::text_field(
433 ui,
434 search_field(),
435 &mut dialog.search,
436 "Search by name",
437 field.clone(),
438 );
439 if let Some(node) = ui.access(search_field()) {
440 node.set_label("Search by name");
441 }
442 label(ui, "Subset:");
443 let current = subset(dialog.selected);
444 let named = current.map_or("", |index| BLOCKS[index].2);
445 let combo = ui.id("subset");
446 ui::shell::combo(ui, "subset", "Subset", named, 220.0, subsets(), true);
447 ui.close();
448
449 let mut picked = Picked::default();
450 let selected = dialog.selected;
451 let mut row_selected = at.map(|at| (at / COLUMNS) as u64);
452 ui::list(
453 ui,
454 grid(),
455 Spec {
456 axis: Axis::Y,
457 size: [px(GRID), px(ROWS as f32 * CELL)],
458 border: Some(theme.chip),
459 role: Some(accesskit::Role::Grid),
460 ..Spec::default()
461 },
462 ui::List {
463 rows: &Grid(cells),
464 row: CELL,
465 keys: &[],
466 hover_selects: false,
467 },
468 &mut row_selected,
469 |ui, row| {
470 for &symbol in cells.iter().skip(row.index * COLUMNS).take(COLUMNS) {
471 cell(ui, symbol, symbol == selected, &mut picked);
472 }
473 },
474 );
475 if let Some(node) = ui.access(grid()) {
476 node.set_label("Characters");
477 }
478 if cells.is_empty() {
479 ui.leaf(
480 "none",
481 Spec {
482 size: [fill(), px(row)],
483 text: Some("No characters have that name."),
484 color: Some(theme.text_dim),
485 ..Spec::default()
486 },
487 );
488 }
489
490 label(ui, "Recently used symbols:");
491 ui.open(
492 "recent",
493 Spec {
494 size: [children(), px(CELL)],
495 role: Some(accesskit::Role::Grid),
496 ..Spec::default()
497 },
498 );
499 for symbol in recent(&self.toolbar.symbols).into_iter().take(COLUMNS) {
500 cell(ui, symbol, symbol == selected, &mut picked);
501 }
502 ui.close();
503 if let Some(node) = ui.access(ui.id("recent")) {
504 node.set_label("Recently used symbols");
505 }
506
507 ui.open(
508 "code",
509 Spec {
510 size: [fill(), children()],
511 gap: 8.0,
512 ..Spec::default()
513 },
514 );
515 ui.leaf(
516 "name",
517 Spec {
518 flags: Flags::CLIP,
519 size: [fill(), px(row)],
520 text: Some(&name(selected)),
521 ..Spec::default()
522 },
523 );
524 label(ui, "Character code:");
525 ui::text_field(
526 ui,
527 code_field(),
528 &mut dialog.code,
529 "",
530 Spec {
531 size: [px(80.0), px(row)],
532 ..field
533 },
534 );
535 if let Some(node) = ui.access(code_field()) {
536 node.set_label("Character code, Unicode hex");
537 }
538 label(ui, "Unicode (hex)");
539 ui.close();
540
541 crate::buttons(ui);
542 let insert = ui::button(ui, "insert", "Insert").clicked || entered;
543 let close = if dialog.inserted { "Close" } else { "Cancel" };
544 let closed = ui::button(ui, "cancel", close).clicked;
545 ui.close();
546 ui.close();
547
548 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
549 if ui.popup_open(subsets()) {
550 // Each subset that shows a character, and its first.
551 let all = characters(ui);
552 let shown: Vec<(usize, char)> = BLOCKS
553 .iter()
554 .enumerate()
555 .filter_map(|(index, &(start, end, _))| {
556 let first = all.partition_point(|symbol| u32::from(*symbol) < start);
557 let symbol = *all.get(first)?;
558 (u32::from(symbol) <= end).then_some((index, symbol))
559 })
560 .collect();
561 let items: Vec<Item> = shown
562 .iter()
563 .map(|&(index, _)| Item {
564 text: BLOCKS[index].2,
565 current: Some(index) == current,
566 ..Item::default()
567 })
568 .collect();
569 if let Some(chosen) = ui::popup::menu(ui, subsets(), anchor, &items, Some("Subset")) {
570 dialog.search.clear();
571 dialog.found = None;
572 dialog.select(shown[chosen].1);
573 }
574 }
575
576 if let Some(symbol) = picked.symbol {
577 dialog.select(symbol);
578 ui.set_focus(Some(grid()));
579 }
580 if let Some(symbol) = code_point(&dialog.code)
581 && symbol != dialog.selected
582 {
583 dialog.selected = symbol;
584 }
585 if insert || picked.inserted {
586 dialog.inserted = true;
587 let symbol = dialog.selected;
588 self.commands
589 .push(Command::Choose(Choice::Symbol(Some(symbol))));
590 } else if closed {
591 self.ui.close_popup(id());
592 self.symbols = None;
593 }
594 }
595}
596
597/// Unicode 17's blocks, but those of ideographs, Hangul syllables, surrogates, private use,
598/// variation selectors and tags, which a symbol is not picked from.
599const BLOCKS: &[(u32, u32, &str)] = &[
600 (0x0000, 0x007F, "Basic Latin"),
601 (0x0080, 0x00FF, "Latin-1 Supplement"),
602 (0x0100, 0x017F, "Latin Extended-A"),
603 (0x0180, 0x024F, "Latin Extended-B"),
604 (0x0250, 0x02AF, "IPA Extensions"),
605 (0x02B0, 0x02FF, "Spacing Modifier Letters"),
606 (0x0300, 0x036F, "Combining Diacritical Marks"),
607 (0x0370, 0x03FF, "Greek and Coptic"),
608 (0x0400, 0x04FF, "Cyrillic"),
609 (0x0500, 0x052F, "Cyrillic Supplement"),
610 (0x0530, 0x058F, "Armenian"),
611 (0x0590, 0x05FF, "Hebrew"),
612 (0x0600, 0x06FF, "Arabic"),
613 (0x0700, 0x074F, "Syriac"),
614 (0x0750, 0x077F, "Arabic Supplement"),
615 (0x0780, 0x07BF, "Thaana"),
616 (0x07C0, 0x07FF, "NKo"),
617 (0x0800, 0x083F, "Samaritan"),
618 (0x0840, 0x085F, "Mandaic"),
619 (0x0860, 0x086F, "Syriac Supplement"),
620 (0x0870, 0x089F, "Arabic Extended-B"),
621 (0x08A0, 0x08FF, "Arabic Extended-A"),
622 (0x0900, 0x097F, "Devanagari"),
623 (0x0980, 0x09FF, "Bengali"),
624 (0x0A00, 0x0A7F, "Gurmukhi"),
625 (0x0A80, 0x0AFF, "Gujarati"),
626 (0x0B00, 0x0B7F, "Oriya"),
627 (0x0B80, 0x0BFF, "Tamil"),
628 (0x0C00, 0x0C7F, "Telugu"),
629 (0x0C80, 0x0CFF, "Kannada"),
630 (0x0D00, 0x0D7F, "Malayalam"),
631 (0x0D80, 0x0DFF, "Sinhala"),
632 (0x0E00, 0x0E7F, "Thai"),
633 (0x0E80, 0x0EFF, "Lao"),
634 (0x0F00, 0x0FFF, "Tibetan"),
635 (0x1000, 0x109F, "Myanmar"),
636 (0x10A0, 0x10FF, "Georgian"),
637 (0x1100, 0x11FF, "Hangul Jamo"),
638 (0x1200, 0x137F, "Ethiopic"),
639 (0x1380, 0x139F, "Ethiopic Supplement"),
640 (0x13A0, 0x13FF, "Cherokee"),
641 (0x1400, 0x167F, "Unified Canadian Aboriginal Syllabics"),
642 (0x1680, 0x169F, "Ogham"),
643 (0x16A0, 0x16FF, "Runic"),
644 (0x1700, 0x171F, "Tagalog"),
645 (0x1720, 0x173F, "Hanunoo"),
646 (0x1740, 0x175F, "Buhid"),
647 (0x1760, 0x177F, "Tagbanwa"),
648 (0x1780, 0x17FF, "Khmer"),
649 (0x1800, 0x18AF, "Mongolian"),
650 (
651 0x18B0,
652 0x18FF,
653 "Unified Canadian Aboriginal Syllabics Extended",
654 ),
655 (0x1900, 0x194F, "Limbu"),
656 (0x1950, 0x197F, "Tai Le"),
657 (0x1980, 0x19DF, "New Tai Lue"),
658 (0x19E0, 0x19FF, "Khmer Symbols"),
659 (0x1A00, 0x1A1F, "Buginese"),
660 (0x1A20, 0x1AAF, "Tai Tham"),
661 (0x1AB0, 0x1AFF, "Combining Diacritical Marks Extended"),
662 (0x1B00, 0x1B7F, "Balinese"),
663 (0x1B80, 0x1BBF, "Sundanese"),
664 (0x1BC0, 0x1BFF, "Batak"),
665 (0x1C00, 0x1C4F, "Lepcha"),
666 (0x1C50, 0x1C7F, "Ol Chiki"),
667 (0x1C80, 0x1C8F, "Cyrillic Extended-C"),
668 (0x1C90, 0x1CBF, "Georgian Extended"),
669 (0x1CC0, 0x1CCF, "Sundanese Supplement"),
670 (0x1CD0, 0x1CFF, "Vedic Extensions"),
671 (0x1D00, 0x1D7F, "Phonetic Extensions"),
672 (0x1D80, 0x1DBF, "Phonetic Extensions Supplement"),
673 (0x1DC0, 0x1DFF, "Combining Diacritical Marks Supplement"),
674 (0x1E00, 0x1EFF, "Latin Extended Additional"),
675 (0x1F00, 0x1FFF, "Greek Extended"),
676 (0x2000, 0x206F, "General Punctuation"),
677 (0x2070, 0x209F, "Superscripts and Subscripts"),
678 (0x20A0, 0x20CF, "Currency Symbols"),
679 (0x20D0, 0x20FF, "Combining Diacritical Marks for Symbols"),
680 (0x2100, 0x214F, "Letterlike Symbols"),
681 (0x2150, 0x218F, "Number Forms"),
682 (0x2190, 0x21FF, "Arrows"),
683 (0x2200, 0x22FF, "Mathematical Operators"),
684 (0x2300, 0x23FF, "Miscellaneous Technical"),
685 (0x2400, 0x243F, "Control Pictures"),
686 (0x2440, 0x245F, "Optical Character Recognition"),
687 (0x2460, 0x24FF, "Enclosed Alphanumerics"),
688 (0x2500, 0x257F, "Box Drawing"),
689 (0x2580, 0x259F, "Block Elements"),
690 (0x25A0, 0x25FF, "Geometric Shapes"),
691 (0x2600, 0x26FF, "Miscellaneous Symbols"),
692 (0x2700, 0x27BF, "Dingbats"),
693 (0x27C0, 0x27EF, "Miscellaneous Mathematical Symbols-A"),
694 (0x27F0, 0x27FF, "Supplemental Arrows-A"),
695 (0x2800, 0x28FF, "Braille Patterns"),
696 (0x2900, 0x297F, "Supplemental Arrows-B"),
697 (0x2980, 0x29FF, "Miscellaneous Mathematical Symbols-B"),
698 (0x2A00, 0x2AFF, "Supplemental Mathematical Operators"),
699 (0x2B00, 0x2BFF, "Miscellaneous Symbols and Arrows"),
700 (0x2C00, 0x2C5F, "Glagolitic"),
701 (0x2C60, 0x2C7F, "Latin Extended-C"),
702 (0x2C80, 0x2CFF, "Coptic"),
703 (0x2D00, 0x2D2F, "Georgian Supplement"),
704 (0x2D30, 0x2D7F, "Tifinagh"),
705 (0x2D80, 0x2DDF, "Ethiopic Extended"),
706 (0x2DE0, 0x2DFF, "Cyrillic Extended-A"),
707 (0x2E00, 0x2E7F, "Supplemental Punctuation"),
708 (0x2E80, 0x2EFF, "CJK Radicals Supplement"),
709 (0x2F00, 0x2FDF, "Kangxi Radicals"),
710 (0x2FF0, 0x2FFF, "Ideographic Description Characters"),
711 (0x3000, 0x303F, "CJK Symbols and Punctuation"),
712 (0x3040, 0x309F, "Hiragana"),
713 (0x30A0, 0x30FF, "Katakana"),
714 (0x3100, 0x312F, "Bopomofo"),
715 (0x3130, 0x318F, "Hangul Compatibility Jamo"),
716 (0x3190, 0x319F, "Kanbun"),
717 (0x31A0, 0x31BF, "Bopomofo Extended"),
718 (0x31C0, 0x31EF, "CJK Strokes"),
719 (0x31F0, 0x31FF, "Katakana Phonetic Extensions"),
720 (0x3200, 0x32FF, "Enclosed CJK Letters and Months"),
721 (0x3300, 0x33FF, "CJK Compatibility"),
722 (0x4DC0, 0x4DFF, "Yijing Hexagram Symbols"),
723 (0xA000, 0xA48F, "Yi Syllables"),
724 (0xA490, 0xA4CF, "Yi Radicals"),
725 (0xA4D0, 0xA4FF, "Lisu"),
726 (0xA500, 0xA63F, "Vai"),
727 (0xA640, 0xA69F, "Cyrillic Extended-B"),
728 (0xA6A0, 0xA6FF, "Bamum"),
729 (0xA700, 0xA71F, "Modifier Tone Letters"),
730 (0xA720, 0xA7FF, "Latin Extended-D"),
731 (0xA800, 0xA82F, "Syloti Nagri"),
732 (0xA830, 0xA83F, "Common Indic Number Forms"),
733 (0xA840, 0xA87F, "Phags-pa"),
734 (0xA880, 0xA8DF, "Saurashtra"),
735 (0xA8E0, 0xA8FF, "Devanagari Extended"),
736 (0xA900, 0xA92F, "Kayah Li"),
737 (0xA930, 0xA95F, "Rejang"),
738 (0xA960, 0xA97F, "Hangul Jamo Extended-A"),
739 (0xA980, 0xA9DF, "Javanese"),
740 (0xA9E0, 0xA9FF, "Myanmar Extended-B"),
741 (0xAA00, 0xAA5F, "Cham"),
742 (0xAA60, 0xAA7F, "Myanmar Extended-A"),
743 (0xAA80, 0xAADF, "Tai Viet"),
744 (0xAAE0, 0xAAFF, "Meetei Mayek Extensions"),
745 (0xAB00, 0xAB2F, "Ethiopic Extended-A"),
746 (0xAB30, 0xAB6F, "Latin Extended-E"),
747 (0xAB70, 0xABBF, "Cherokee Supplement"),
748 (0xABC0, 0xABFF, "Meetei Mayek"),
749 (0xD7B0, 0xD7FF, "Hangul Jamo Extended-B"),
750 (0xFB00, 0xFB4F, "Alphabetic Presentation Forms"),
751 (0xFB50, 0xFDFF, "Arabic Presentation Forms-A"),
752 (0xFE10, 0xFE1F, "Vertical Forms"),
753 (0xFE20, 0xFE2F, "Combining Half Marks"),
754 (0xFE30, 0xFE4F, "CJK Compatibility Forms"),
755 (0xFE50, 0xFE6F, "Small Form Variants"),
756 (0xFE70, 0xFEFF, "Arabic Presentation Forms-B"),
757 (0xFF00, 0xFFEF, "Halfwidth and Fullwidth Forms"),
758 (0xFFF0, 0xFFFF, "Specials"),
759 (0x10000, 0x1007F, "Linear B Syllabary"),
760 (0x10080, 0x100FF, "Linear B Ideograms"),
761 (0x10100, 0x1013F, "Aegean Numbers"),
762 (0x10140, 0x1018F, "Ancient Greek Numbers"),
763 (0x10190, 0x101CF, "Ancient Symbols"),
764 (0x101D0, 0x101FF, "Phaistos Disc"),
765 (0x10280, 0x1029F, "Lycian"),
766 (0x102A0, 0x102DF, "Carian"),
767 (0x102E0, 0x102FF, "Coptic Epact Numbers"),
768 (0x10300, 0x1032F, "Old Italic"),
769 (0x10330, 0x1034F, "Gothic"),
770 (0x10350, 0x1037F, "Old Permic"),
771 (0x10380, 0x1039F, "Ugaritic"),
772 (0x103A0, 0x103DF, "Old Persian"),
773 (0x10400, 0x1044F, "Deseret"),
774 (0x10450, 0x1047F, "Shavian"),
775 (0x10480, 0x104AF, "Osmanya"),
776 (0x104B0, 0x104FF, "Osage"),
777 (0x10500, 0x1052F, "Elbasan"),
778 (0x10530, 0x1056F, "Caucasian Albanian"),
779 (0x10570, 0x105BF, "Vithkuqi"),
780 (0x105C0, 0x105FF, "Todhri"),
781 (0x10600, 0x1077F, "Linear A"),
782 (0x10780, 0x107BF, "Latin Extended-F"),
783 (0x10800, 0x1083F, "Cypriot Syllabary"),
784 (0x10840, 0x1085F, "Imperial Aramaic"),
785 (0x10860, 0x1087F, "Palmyrene"),
786 (0x10880, 0x108AF, "Nabataean"),
787 (0x108E0, 0x108FF, "Hatran"),
788 (0x10900, 0x1091F, "Phoenician"),
789 (0x10920, 0x1093F, "Lydian"),
790 (0x10940, 0x1095F, "Sidetic"),
791 (0x10980, 0x1099F, "Meroitic Hieroglyphs"),
792 (0x109A0, 0x109FF, "Meroitic Cursive"),
793 (0x10A00, 0x10A5F, "Kharoshthi"),
794 (0x10A60, 0x10A7F, "Old South Arabian"),
795 (0x10A80, 0x10A9F, "Old North Arabian"),
796 (0x10AC0, 0x10AFF, "Manichaean"),
797 (0x10B00, 0x10B3F, "Avestan"),
798 (0x10B40, 0x10B5F, "Inscriptional Parthian"),
799 (0x10B60, 0x10B7F, "Inscriptional Pahlavi"),
800 (0x10B80, 0x10BAF, "Psalter Pahlavi"),
801 (0x10C00, 0x10C4F, "Old Turkic"),
802 (0x10C80, 0x10CFF, "Old Hungarian"),
803 (0x10D00, 0x10D3F, "Hanifi Rohingya"),
804 (0x10D40, 0x10D8F, "Garay"),
805 (0x10E60, 0x10E7F, "Rumi Numeral Symbols"),
806 (0x10E80, 0x10EBF, "Yezidi"),
807 (0x10EC0, 0x10EFF, "Arabic Extended-C"),
808 (0x10F00, 0x10F2F, "Old Sogdian"),
809 (0x10F30, 0x10F6F, "Sogdian"),
810 (0x10F70, 0x10FAF, "Old Uyghur"),
811 (0x10FB0, 0x10FDF, "Chorasmian"),
812 (0x10FE0, 0x10FFF, "Elymaic"),
813 (0x11000, 0x1107F, "Brahmi"),
814 (0x11080, 0x110CF, "Kaithi"),
815 (0x110D0, 0x110FF, "Sora Sompeng"),
816 (0x11100, 0x1114F, "Chakma"),
817 (0x11150, 0x1117F, "Mahajani"),
818 (0x11180, 0x111DF, "Sharada"),
819 (0x111E0, 0x111FF, "Sinhala Archaic Numbers"),
820 (0x11200, 0x1124F, "Khojki"),
821 (0x11280, 0x112AF, "Multani"),
822 (0x112B0, 0x112FF, "Khudawadi"),
823 (0x11300, 0x1137F, "Grantha"),
824 (0x11380, 0x113FF, "Tulu-Tigalari"),
825 (0x11400, 0x1147F, "Newa"),
826 (0x11480, 0x114DF, "Tirhuta"),
827 (0x11580, 0x115FF, "Siddham"),
828 (0x11600, 0x1165F, "Modi"),
829 (0x11660, 0x1167F, "Mongolian Supplement"),
830 (0x11680, 0x116CF, "Takri"),
831 (0x116D0, 0x116FF, "Myanmar Extended-C"),
832 (0x11700, 0x1174F, "Ahom"),
833 (0x11800, 0x1184F, "Dogra"),
834 (0x118A0, 0x118FF, "Warang Citi"),
835 (0x11900, 0x1195F, "Dives Akuru"),
836 (0x119A0, 0x119FF, "Nandinagari"),
837 (0x11A00, 0x11A4F, "Zanabazar Square"),
838 (0x11A50, 0x11AAF, "Soyombo"),
839 (
840 0x11AB0,
841 0x11ABF,
842 "Unified Canadian Aboriginal Syllabics Extended-A",
843 ),
844 (0x11AC0, 0x11AFF, "Pau Cin Hau"),
845 (0x11B00, 0x11B5F, "Devanagari Extended-A"),
846 (0x11B60, 0x11B7F, "Sharada Supplement"),
847 (0x11BC0, 0x11BFF, "Sunuwar"),
848 (0x11C00, 0x11C6F, "Bhaiksuki"),
849 (0x11C70, 0x11CBF, "Marchen"),
850 (0x11D00, 0x11D5F, "Masaram Gondi"),
851 (0x11D60, 0x11DAF, "Gunjala Gondi"),
852 (0x11DB0, 0x11DEF, "Tolong Siki"),
853 (0x11EE0, 0x11EFF, "Makasar"),
854 (0x11F00, 0x11F5F, "Kawi"),
855 (0x11FB0, 0x11FBF, "Lisu Supplement"),
856 (0x11FC0, 0x11FFF, "Tamil Supplement"),
857 (0x12000, 0x123FF, "Cuneiform"),
858 (0x12400, 0x1247F, "Cuneiform Numbers and Punctuation"),
859 (0x12480, 0x1254F, "Early Dynastic Cuneiform"),
860 (0x12F90, 0x12FFF, "Cypro-Minoan"),
861 (0x13000, 0x1342F, "Egyptian Hieroglyphs"),
862 (0x13430, 0x1345F, "Egyptian Hieroglyph Format Controls"),
863 (0x13460, 0x143FF, "Egyptian Hieroglyphs Extended-A"),
864 (0x14400, 0x1467F, "Anatolian Hieroglyphs"),
865 (0x16100, 0x1613F, "Gurung Khema"),
866 (0x16800, 0x16A3F, "Bamum Supplement"),
867 (0x16A40, 0x16A6F, "Mro"),
868 (0x16A70, 0x16ACF, "Tangsa"),
869 (0x16AD0, 0x16AFF, "Bassa Vah"),
870 (0x16B00, 0x16B8F, "Pahawh Hmong"),
871 (0x16D40, 0x16D7F, "Kirat Rai"),
872 (0x16E40, 0x16E9F, "Medefaidrin"),
873 (0x16EA0, 0x16EDF, "Beria Erfe"),
874 (0x16F00, 0x16F9F, "Miao"),
875 (0x16FE0, 0x16FFF, "Ideographic Symbols and Punctuation"),
876 (0x1AFF0, 0x1AFFF, "Kana Extended-B"),
877 (0x1B000, 0x1B0FF, "Kana Supplement"),
878 (0x1B100, 0x1B12F, "Kana Extended-A"),
879 (0x1B130, 0x1B16F, "Small Kana Extension"),
880 (0x1B170, 0x1B2FF, "Nushu"),
881 (0x1BC00, 0x1BC9F, "Duployan"),
882 (0x1BCA0, 0x1BCAF, "Shorthand Format Controls"),
883 (0x1CC00, 0x1CEBF, "Symbols for Legacy Computing Supplement"),
884 (0x1CEC0, 0x1CEFF, "Miscellaneous Symbols Supplement"),
885 (0x1CF00, 0x1CFCF, "Znamenny Musical Notation"),
886 (0x1D000, 0x1D0FF, "Byzantine Musical Symbols"),
887 (0x1D100, 0x1D1FF, "Musical Symbols"),
888 (0x1D200, 0x1D24F, "Ancient Greek Musical Notation"),
889 (0x1D2C0, 0x1D2DF, "Kaktovik Numerals"),
890 (0x1D2E0, 0x1D2FF, "Mayan Numerals"),
891 (0x1D300, 0x1D35F, "Tai Xuan Jing Symbols"),
892 (0x1D360, 0x1D37F, "Counting Rod Numerals"),
893 (0x1D400, 0x1D7FF, "Mathematical Alphanumeric Symbols"),
894 (0x1D800, 0x1DAAF, "Sutton SignWriting"),
895 (0x1DF00, 0x1DFFF, "Latin Extended-G"),
896 (0x1E000, 0x1E02F, "Glagolitic Supplement"),
897 (0x1E030, 0x1E08F, "Cyrillic Extended-D"),
898 (0x1E100, 0x1E14F, "Nyiakeng Puachue Hmong"),
899 (0x1E290, 0x1E2BF, "Toto"),
900 (0x1E2C0, 0x1E2FF, "Wancho"),
901 (0x1E4D0, 0x1E4FF, "Nag Mundari"),
902 (0x1E5D0, 0x1E5FF, "Ol Onal"),
903 (0x1E6C0, 0x1E6FF, "Tai Yo"),
904 (0x1E7E0, 0x1E7FF, "Ethiopic Extended-B"),
905 (0x1E800, 0x1E8DF, "Mende Kikakui"),
906 (0x1E900, 0x1E95F, "Adlam"),
907 (0x1EC70, 0x1ECBF, "Indic Siyaq Numbers"),
908 (0x1ED00, 0x1ED4F, "Ottoman Siyaq Numbers"),
909 (0x1EE00, 0x1EEFF, "Arabic Mathematical Alphabetic Symbols"),
910 (0x1F000, 0x1F02F, "Mahjong Tiles"),
911 (0x1F030, 0x1F09F, "Domino Tiles"),
912 (0x1F0A0, 0x1F0FF, "Playing Cards"),
913 (0x1F100, 0x1F1FF, "Enclosed Alphanumeric Supplement"),
914 (0x1F200, 0x1F2FF, "Enclosed Ideographic Supplement"),
915 (0x1F300, 0x1F5FF, "Miscellaneous Symbols and Pictographs"),
916 (0x1F600, 0x1F64F, "Emoticons"),
917 (0x1F650, 0x1F67F, "Ornamental Dingbats"),
918 (0x1F680, 0x1F6FF, "Transport and Map Symbols"),
919 (0x1F700, 0x1F77F, "Alchemical Symbols"),
920 (0x1F780, 0x1F7FF, "Geometric Shapes Extended"),
921 (0x1F800, 0x1F8FF, "Supplemental Arrows-C"),
922 (0x1F900, 0x1F9FF, "Supplemental Symbols and Pictographs"),
923 (0x1FA00, 0x1FA6F, "Chess Symbols"),
924 (0x1FA70, 0x1FAFF, "Symbols and Pictographs Extended-A"),
925 (0x1FB00, 0x1FBFF, "Symbols for Legacy Computing"),
926];
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn the_gallery_follows_inserts_with_onenotes_defaults() {
934 assert_eq!(recent(&[]), DEFAULTS);
935 let shown = recent(&['⅔', '±']);
936 assert_eq!(shown[..4], ['⅔', '±', '€', '£']);
937 assert_eq!(shown.len(), GALLERY);
938 assert_eq!(shown.iter().filter(|symbol| **symbol == '±').count(), 1);
939 }
940
941 #[test]
942 fn names_and_codes_read_as_onenote_shows_them() {
943 assert_eq!(name('€'), "Euro Sign");
944 assert_eq!(name('⅔'), "Vulgar Fraction Two Thirds");
945 assert_eq!(name('\u{4e00}'), "Cjk Unified Ideograph-4E00");
946 assert_eq!(name('\n'), "");
947 assert_eq!(code('€'), "20AC");
948 for typed in ["20ac", "U+20AC", "0x20AC"] {
949 assert_eq!(code_point(typed), Some('€'));
950 }
951 assert_eq!(code_point("D800"), None);
952 assert_eq!(code_point("1234567"), None);
953 }
954
955 #[test]
956 fn subsets_are_ordered_and_whole_rows() {
957 for pair in BLOCKS.windows(2) {
958 assert!(pair[0].1 < pair[1].0);
959 }
960 for &(start, end, _) in BLOCKS {
961 assert_eq!((start % 16, end % 16), (0, 15));
962 }
963 assert_eq!(
964 subset('€').map(|index| BLOCKS[index].2),
965 Some("Currency Symbols")
966 );
967 assert_eq!(subset('\u{e000}'), None);
968 }
969}
crates/snowbound/src/windows.rs-7
...@@ -1159,13 +1159,6 @@ pub fn open_file(path: &std::path::Path) {...@@ -1159,13 +1159,6 @@ pub fn open_file(path: &std::path::Path) {
1159 reveal(path);1159 reveal(path);
1160}1160}
11611161
1162/// Opens Character Map, whose picks the user copies in.
1163pub fn character_palette() {
1164 if let Err(error) = std::process::Command::new("charmap.exe").spawn() {
1165 eprintln!("Cannot open Character Map: {error}");
1166 }
1167}
1168
1169/// No shell icon lookup yet; the page draws a blank page for the file.1162/// No shell icon lookup yet; the page draws a blank page for the file.
1170pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {1163pub fn file_icon(_: &std::path::Path) -> Option<Vec<u8>> {
1171 None1164 None
crates/ui/src/lib.rs+5
...@@ -1297,6 +1297,11 @@ impl Ui {...@@ -1297,6 +1297,11 @@ impl Ui {
1297 self.texts.preview_font(data, family);1297 self.texts.preview_font(data, family);
1298 }1298 }
12991299
1300 /// Whether the interface's fonts draw every glyph of `text`.
1301 pub fn shows(&mut self, text: &str) -> bool {
1302 self.texts.shows(text, self.frame)
1303 }
1304
1300 /// The size of `text` as a label, in logical pixels.1305 /// The size of `text` as a label, in logical pixels.
1301 pub fn measure(&mut self, text: &str) -> [f32; 2] {1306 pub fn measure(&mut self, text: &str) -> [f32; 2] {
1302 self.texts1307 self.texts
crates/ui/src/popup.rs+15-13
...@@ -963,7 +963,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op...@@ -963,7 +963,7 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op
963 None963 None
964}964}
965965
966/// A gallery's run of `cells` under `heading`, in rows of `columns` cells `size` large.966/// A gallery's run of `cells` under `heading`, if any, in rows of `columns` cells `size` large.
967#[derive(Clone, Copy, Debug)]967#[derive(Clone, Copy, Debug)]
968pub struct Group<'a> {968pub struct Group<'a> {
969 pub heading: &'a str,969 pub heading: &'a str,
...@@ -1026,18 +1026,20 @@ pub fn gallery(...@@ -1026,18 +1026,20 @@ pub fn gallery(
1026 );1026 );
1027 let mut index = 0;1027 let mut index = 0;
1028 for (number, group) in groups.iter().enumerate() {1028 for (number, group) in groups.iter().enumerate() {
1029 ui.leaf(1029 if !group.heading.is_empty() {
1030 ("heading", number),1030 ui.leaf(
1031 Spec {1031 ("heading", number),
1032 size: [fill(), px(MENU_ROW)],1032 Spec {
1033 text: Some(group.heading),1033 size: [fill(), px(MENU_ROW)],
1034 font_size: Some(theme.font_size - 2.0),1034 text: Some(group.heading),
1035 bold: true,1035 font_size: Some(theme.font_size - 2.0),
1036 color: Some(theme.text_dim),1036 bold: true,
1037 pad: [PAD, 0.0],1037 color: Some(theme.text_dim),
1038 ..Spec::default()1038 pad: [PAD, 0.0],
1039 },1039 ..Spec::default()
1040 );1040 },
1041 );
1042 }
1041 for row in 0..group.cells.div_ceil(group.columns) {1043 for row in 0..group.cells.div_ceil(group.columns) {
1042 ui.open(1044 ui.open(
1043 ("row", number, row),1045 ("row", number, row),
crates/ui/src/text.rs+22
...@@ -21,6 +21,8 @@ pub(crate) struct Texts {...@@ -21,6 +21,8 @@ pub(crate) struct Texts {
21 context: LayoutContext<()>,21 context: LayoutContext<()>,
22 /// By text, size, weight and preferred family, with the frame each was last used.22 /// By text, size, weight and preferred family, with the frame each was last used.
23 cache: HashMap<LabelKey, (Rc<Label>, u64)>,23 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
24 /// The Last Resort font's data, once looked for.
25 last_resort: Option<Option<u64>>,
24}26}
2527
26impl Texts {28impl Texts {
...@@ -104,6 +106,26 @@ impl Texts {...@@ -104,6 +106,26 @@ impl Texts {
104 self.styled(&cut, size, *bold, *italic, font, frame)106 self.styled(&cut, size, *bold, *italic, font, frame)
105 }107 }
106108
109 /// Whether the interface's fonts or their fallbacks draw every glyph of `text`, not the
110 /// missing glyph nor the placeholders of macOS's Last Resort font.
111 pub fn shows(&mut self, text: &str, frame: u64) -> bool {
112 let last_resort = *self.last_resort.get_or_insert_with(|| {
113 let family = self.fonts.collection.family_by_name("LastResort")?;
114 let font = family.fonts().first()?;
115 Some(font.load(Some(&mut self.fonts.source_cache))?.id())
116 });
117 let label = self.styled(text, 16.0, false, false, None, frame);
118 label.layout.lines().all(|line| {
119 line.items().all(|item| match item {
120 PositionedLayoutItem::GlyphRun(run) => {
121 Some(run.run().font().font.data.id()) != last_resort
122 && run.glyphs().all(|glyph| glyph.id != 0)
123 }
124 PositionedLayoutItem::InlineBox(_) => true,
125 })
126 })
127 }
128
107 /// Makes `family` the interface's font, where fontique doesn't know the system's.129 /// Makes `family` the interface's font, where fontique doesn't know the system's.
108 pub fn set_system_font(&mut self, family: &str) {130 pub fn set_system_font(&mut self, family: &str) {
109 let collection = &mut self.fonts.collection;131 let collection = &mut self.fonts.collection;