diff --git a/AGENTS.md b/AGENTS.md index d80f29b..f39ac4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,9 +38,10 @@ dependency runs one way. `--recurse-submodules` or run `git submodule update --init`. - `docs/` — design and working documents. -Nerd Font icons are a committed subset. `iris/core/build-icon-font.sh` -produces `iris/core/assets/fonts/nerd_icons.ttf`; its codepoints must match -`iris/core/src/icon.rs`. Body and monospace fonts come from the platform. +Nerd Font icons are an app-owned committed subset. `app/build-icon-font.sh` +produces `app/assets/fonts/nerd_icons.ttf`; its codepoints must match +`app/src/ui/icon.rs`. The app registers it with Iris at startup. Body and +monospace fonts come from the platform; Iris ships no font assets. ## Checking work diff --git a/app/Cargo.lock b/app/Cargo.lock index 28b49dc..14de6bb 100644 --- a/app/Cargo.lock +++ b/app/Cargo.lock @@ -180,6 +180,7 @@ dependencies = [ "pulldown-cmark", "serde", "serde_json", + "swash", "tempfile", "tokio", "ureq", diff --git a/app/Cargo.toml b/app/Cargo.toml index bd3804d..921c007 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -56,6 +56,7 @@ force-gles = ["screens", "iris/force-gles"] [dev-dependencies] tempfile = "3" tokio = { version = "1.53.1", features = ["rt", "time"] } +swash = "0.2.10" # APK builds select these profiles explicitly. [profile.android-release] diff --git a/iris/core/assets/fonts/NERD_FONTS_LICENSE.txt b/app/assets/fonts/NERD_FONTS_LICENSE.txt similarity index 100% rename from iris/core/assets/fonts/NERD_FONTS_LICENSE.txt rename to app/assets/fonts/NERD_FONTS_LICENSE.txt diff --git a/iris/core/assets/fonts/nerd_icons.ttf b/app/assets/fonts/nerd_icons.ttf similarity index 100% rename from iris/core/assets/fonts/nerd_icons.ttf rename to app/assets/fonts/nerd_icons.ttf diff --git a/iris/core/build-icon-font.sh b/app/build-icon-font.sh similarity index 91% rename from iris/core/build-icon-font.sh rename to app/build-icon-font.sh index dc46542..0d78791 100755 --- a/iris/core/build-icon-font.sh +++ b/app/build-icon-font.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Rebuilds iris/core/assets/fonts/nerd_icons.ttf. +# Rebuilds app/assets/fonts/nerd_icons.ttf. # -# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather +# ai-app draws its icons as glyphs in a Nerd Fonts subset it ships, rather # than as ordinary Unicode out of whatever the platform resolved. Unicode's # own geometric shapes are what this replaced: `tool.rs` set its disclosure # mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts @@ -11,7 +11,7 @@ # # The whole symbols font is 3 MB for the handful below, so what is # committed is a subset. Add a codepoint to GLYPHS *and* to -# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in +# `app/src/ui/icon.rs` (the two lists have to agree -- a codepoint in # the Rust that this script did not subset is a glyph that silently isn't # there), then run this and commit the result. # diff --git a/app/src/android/bench_client.rs b/app/src/android/bench_client.rs index 3d55003..1a0f547 100644 --- a/app/src/android/bench_client.rs +++ b/app/src/android/bench_client.rs @@ -114,6 +114,7 @@ fn battery_line(samples: &[i32]) -> String { impl BenchClient { pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { + crate::ui::register_fonts(&mut rsc.ui); let content = WidgetPtr::new().add(rsc); let loading = placeholder(rsc, "Loading fixture..."); content(rsc).set(loading); @@ -146,7 +147,7 @@ impl BenchClient { let font = rsc.ui.text.font_diagnostics(); log::info!( "iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \ - bold={:?} italic={:?} mono={:?}, icons={:?}", + bold={:?} italic={:?} mono={:?}", font.families_found, font.default_family, font.default_mono_family, @@ -154,7 +155,6 @@ impl BenchClient { font.bold_resolved, font.italic_resolved, font.mono_resolved, - font.icon_family, ); let mut client = Self { diff --git a/app/src/android/transcript_client.rs b/app/src/android/transcript_client.rs index 88e1cb2..d5ef93b 100644 --- a/app/src/android/transcript_client.rs +++ b/app/src/android/transcript_client.rs @@ -91,6 +91,7 @@ fn frame_report_controls(rsc: &mut StdRsc) -> WeakWidget { impl TranscriptClient { pub(super) fn new(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Self { + crate::ui::register_fonts(&mut rsc.ui); let content = WidgetPtr::new().add(rsc); let loading = placeholder(rsc, "Loading sessions..."); content(rsc).set(loading); diff --git a/app/src/desktop/app.rs b/app/src/desktop/app.rs index 0fd5a3e..d2ef641 100644 --- a/app/src/desktop/app.rs +++ b/app/src/desktop/app.rs @@ -57,6 +57,7 @@ struct Client { impl DesktopAppState for Client { fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { + crate::ui::register_fonts(&mut rsc.ui); let (server, ca_pem) = super::startup::load_startup_config().unwrap_or_else(|e| { eprintln!("desktop-app: {e}"); process::exit(2); diff --git a/app/src/ui/icon.rs b/app/src/ui/icon.rs new file mode 100644 index 0000000..c6a31a7 --- /dev/null +++ b/app/src/ui/icon.rs @@ -0,0 +1,30 @@ +pub const OPEN: &str = "\u{F035D}"; + +pub const CLOSED: &str = "\u{F035F}"; + +pub const COLLAPSE: &str = "\u{F0360}"; + +#[cfg(test)] +mod tests { + use super::*; + use swash::FontRef; + + #[test] + fn every_icon_is_in_the_application_font() { + let font = FontRef::from_index(super::super::ICON_FONT, 0) + .expect("the application icon font parses"); + let charmap = font.charmap(); + for (name, glyph) in [("OPEN", OPEN), ("CLOSED", CLOSED), ("COLLAPSE", COLLAPSE)] { + let mut chars = glyph.chars(); + let ch = chars.next().expect("an icon is one character"); + assert!(chars.next().is_none(), "{name} is more than one character"); + assert_ne!( + charmap.map(ch), + 0, + "{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \ + build-icon-font.sh's GLYPHS and rerun the script", + ch as u32 + ); + } + } +} diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 8b262f4..7d2e8d6 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -2,6 +2,7 @@ pub mod composer; // Keep the 1.9 MB fixture out of ordinary APKs. #[cfg(feature = "fixture")] pub mod fixture; +mod icon; pub mod markdown; pub mod row; pub(crate) mod tap; @@ -13,6 +14,16 @@ use iris::prelude::*; use std::{mem, rc::Rc}; use theme::Theme; +const ICON_FONT: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); + +pub(crate) fn register_fonts(ui: &mut Ui) { + if ui.is_font_registered(&Family::Icons) { + return; + } + ui.register_font(Family::Icons, ICON_FONT) + .expect("the ai-app icon font must register before text is drawn"); +} + pub struct TranscriptScreen { /// The transcript's own `LazySpan` -- the layout *and* the scroll /// position, since a lazy span owns a `ScrollController` of its own @@ -241,6 +252,7 @@ pub fn build_tree( where Rsc::State: FocusHost + OpenUrl, { + register_fonts(rsc.ui_mut()); let theme = Rc::new(Theme::new(&mut rsc.ui_mut().paints)); let list = LazySpan::new(Dir::DOWN, Pin::End).add(rsc); list.controller( diff --git a/app/src/ui/tool.rs b/app/src/ui/tool.rs index 91af5d7..eb8b81c 100644 --- a/app/src/ui/tool.rs +++ b/app/src/ui/tool.rs @@ -1,6 +1,7 @@ use crate::client::text_cap::{VERBATIM_BYTES, VERBATIM_LINES, cut, show_all_label}; use crate::client::tool_summary::{ToolInput, parse_tool_input}; use crate::client::transcript_fold::{ToolState, TranscriptItem}; +use crate::ui::icon; use crate::ui::markdown::highlight_into; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::theme::Theme; diff --git a/docs/EXPLORER.md b/docs/EXPLORER.md index aa59d08..02082c7 100644 --- a/docs/EXPLORER.md +++ b/docs/EXPLORER.md @@ -247,8 +247,8 @@ is a unit test with exactly those names in it. ### 12. Icons -Add these to `iris/core/src/icon.rs` **and** -`iris/core/build-icon-font.sh`, then rerun the script and commit its output: +Add these to `app/src/ui/icon.rs` **and** +`app/build-icon-font.sh`, then rerun the script and commit its output: `md-folder` U+F024B (the header button and directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB, `md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus diff --git a/docs/PLAN.md b/docs/PLAN.md index 66f584c..4d40332 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -813,6 +813,13 @@ widget changed. Winit's user-event proxy and Android's posted callback are private implementations of that wake; applications do not define platform event types or manually request redraws. +**Iris ships no fonts.** Applications register their own font bytes on `Ui` +before the first text shape and select them through the same `Family` used by +text widgets. `Family::Icons` is a semantic application-configured role, not a +particular icon set owned by the framework. ai-app owns its Nerd Fonts subset, +its codepoints, its license and the script that rebuilds it; body and monospace +families continue to come from the platform. + `cargo-iris` is an installable Cargo subcommand, rather than a script callers must find inside an Iris checkout. `cargo iris apk` builds the Rust `cdylib` with cargo-ndk and packages it directly with the installed Android SDK tools: diff --git a/iris/core/src/icon.rs b/iris/core/src/icon.rs deleted file mode 100644 index 728f641..0000000 --- a/iris/core/src/icon.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub const OPEN: &str = "\u{F035D}"; - -pub const CLOSED: &str = "\u{F035F}"; - -pub const COLLAPSE: &str = "\u{F0360}"; diff --git a/iris/core/src/lib.rs b/iris/core/src/lib.rs index f93a844..3122f82 100644 --- a/iris/core/src/lib.rs +++ b/iris/core/src/lib.rs @@ -19,7 +19,6 @@ mod render; mod ui; mod widget; -pub mod icon; pub mod util; pub use attr::*; diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index 011aa41..c49031e 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -2,18 +2,15 @@ use crate::{Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Text use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, - fontique::Blob, + fontique::{Blob, FontInfoOverride}, }; -use std::ops::Range; -use std::sync::Arc; +use std::{collections::HashMap, fmt, ops::Range, sync::Arc}; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, }; -const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); - #[derive(Clone, Debug)] pub struct FontDiagnostics { pub families_found: usize, @@ -23,13 +20,42 @@ pub struct FontDiagnostics { pub bold_resolved: Option, pub italic_resolved: Option, pub mono_resolved: Option, - /// The family the bundled icon font registered under, or `None` if - /// registering it failed. Reported rather than assumed: it is the one - /// font iris ships, so `None` is a broken build and must not look - /// like a device that happens to lack a face. - pub icon_family: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FontRegistrationError { + UnsupportedFamily(Family), + AlreadyRegistered(Family), + TextAlreadyShaped, + InvalidFont(Family), +} + +impl fmt::Display for FontRegistrationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedFamily(family) => write!( + f, + "cannot register font data for {family:?}; use Family::Icons or Family::Named" + ), + Self::AlreadyRegistered(family) => { + write!(f, "font data is already registered for {family:?}") + } + Self::TextAlreadyShaped => write!( + f, + "cannot register font data after text has been shaped; register application fonts before the first draw" + ), + Self::InvalidFont(family) => { + write!( + f, + "font data registered for {family:?} contains no usable fonts" + ) + } + } + } +} + +impl std::error::Error for FontRegistrationError {} + pub struct TextData { pub font_cx: FontContext, pub layout_cx: LayoutContext, @@ -46,41 +72,28 @@ pub struct TextData { /// truth would mean carrying a `Painter` (or output size) into every /// input handler for the sake of one field. pub density: f32, - /// The family name [`NERD_ICONS`] registered under, which is what - /// [`Family::Icons`] resolves to. `None` only if registering the - /// bundled font failed, which is a broken build rather than a - /// platform difference -- said in the startup diagnostics rather than - /// silently drawn as tofu. - pub icon_family: Option, + registered_families: HashMap, + next_registered_family: u64, + shaping_started: bool, } impl Default for TextData { fn default() -> Self { let mut font_cx = FontContext::new(); patch_android_monospace(&mut font_cx); - let icon_family = register_icon_font(&mut font_cx); Self { font_cx, layout_cx: LayoutContext::new(), scale_cx: ScaleContext::new(), atlas: GlyphAtlas::default(), density: 1.0, - icon_family, + registered_families: HashMap::new(), + next_registered_family: 0, + shaping_started: false, } } } -fn register_icon_font(font_cx: &mut FontContext) -> Option { - let blob = Blob::new(Arc::new(NERD_ICONS)); - let id = font_cx - .collection - .register_fonts(blob, None) - .into_iter() - .map(|(id, _)| id) - .next()?; - font_cx.collection.family_name(id).map(str::to_string) -} - /// So this reads `fonts.xml` itself (already on-device, already the /// authority Compose's own `Typeface.MONOSPACE` resolves through) for the /// filename that declaration names, then finds which of fontique's @@ -154,16 +167,56 @@ fn android_monospace_font_filename() -> Option { fn patch_android_monospace(_font_cx: &mut FontContext) {} impl TextData { + pub(crate) fn register_font( + &mut self, + family: Family, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), FontRegistrationError> { + if !matches!(family, Family::Icons | Family::Named(_)) { + return Err(FontRegistrationError::UnsupportedFamily(family)); + } + if self.shaping_started { + return Err(FontRegistrationError::TextAlreadyShaped); + } + if self.registered_families.contains_key(&family) { + return Err(FontRegistrationError::AlreadyRegistered(family)); + } + + // Parley selects fonts by family rather than by a face handle. Give + // application data a private family name so selecting it cannot find + // a system font that happens to carry the same embedded metadata. + let private_name = format!("__iris_registered_font_{}__", self.next_registered_family); + let fonts = self.font_cx.collection.register_fonts( + Blob::new(Arc::new(data)), + Some(FontInfoOverride { + family_name: Some(&private_name), + ..Default::default() + }), + ); + if fonts.is_empty() { + return Err(FontRegistrationError::InvalidFont(family)); + } + self.next_registered_family += 1; + self.registered_families.insert(family, private_name); + Ok(()) + } + + pub(crate) fn is_font_registered(&self, family: &Family) -> bool { + self.registered_families.contains_key(family) + } + /// Cloned rather than borrowed because the caller needs it while the - /// layout builder holds `&mut self` -- a `String` per shaped icon run, - /// paid only when the layout is rebuilt. + /// layout builder holds `&mut self` -- a `String` per shaped registered + /// run, paid only when the layout is rebuilt. pub fn resolve_family(&self, family: &Family) -> Family { + if let Some(name) = self.registered_families.get(family) { + return Family::Named(name.clone()); + } match family { - Family::Icons => self - .icon_family - .clone() - .map_or(Family::Icons, Family::Named), - other => other.clone(), + Family::Icons => panic!( + "Family::Icons has no font; register application font data with Ui::register_font before the first draw" + ), + _ => family.clone(), } } @@ -236,7 +289,6 @@ impl TextData { bold_resolved, italic_resolved, mono_resolved, - icon_family: self.icon_family.clone(), } } @@ -339,15 +391,13 @@ impl TextData { /// Which family to ask for. Kept as an owned name rather than parley's /// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime. -#[derive(Clone, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Family { SansSerif, Serif, Monospace, - /// The bundled icon font -- see [`crate::icon`] for what is in it. - /// Named as an intention rather than as a font name because only - /// [`TextData`] knows what the file registered as; it resolves this - /// during shaping ([`TextData::resolve_family`]). + /// The icon font supplied by the application through + /// [`crate::Ui::register_font`]. Icons, Named(String), } @@ -507,6 +557,7 @@ impl TextBuffer { width: Option, density: f32, ) { + data.shaping_started = true; if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { return; } @@ -586,38 +637,24 @@ pub struct RenderedText { #[cfg(test)] mod tests { use super::*; - use crate::icon; #[test] - fn every_icon_is_in_the_bundled_font() { - let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses"); - let charmap = font.charmap(); - for (name, glyph) in [ - ("OPEN", icon::OPEN), - ("CLOSED", icon::CLOSED), - ("COLLAPSE", icon::COLLAPSE), - ] { - let mut chars = glyph.chars(); - let ch = chars.next().expect("an icon is one character"); - assert!(chars.next().is_none(), "{name} is more than one character"); - assert_ne!( - charmap.map(ch), - 0, - "{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \ - build-icon-font.sh's GLYPHS and rerun the script", - ch as u32 - ); - } + fn invalid_font_data_is_reported() { + let mut data = TextData::default(); + assert_eq!( + data.register_font(Family::Icons, b"not a font" as &'static [u8]), + Err(FontRegistrationError::InvalidFont(Family::Icons)) + ); } #[test] - fn the_icon_family_registers_and_resolves() { - let data = TextData::default(); - let family = data.resolve_family(&Family::Icons); - assert!( - matches!(family, Family::Named(_)), - "the bundled icon font did not register: {:?}", - data.icon_family + fn registering_after_shaping_is_reported() { + let mut data = TextData::default(); + let mut buffer = TextBuffer::new("ordinary platform text"); + buffer.shape(&mut data, &TextAttrs::default(), None, 1.0); + assert_eq!( + data.register_font(Family::Icons, b"not a font" as &'static [u8]), + Err(FontRegistrationError::TextAlreadyShaped) ); } } diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs index c7575ad..a9c158b 100644 --- a/iris/core/src/ui/mod.rs +++ b/iris/core/src/ui/mod.rs @@ -66,6 +66,24 @@ pub struct Ui { } impl Ui { + /// Register application-owned font data for a semantic or named family. + /// + /// This must happen before the first text shape. Existing text layouts + /// cache their resolved faces, so accepting a later registration would + /// leave already-shaped widgets displaying the old result. + #[track_caller] + pub fn register_font( + &mut self, + family: crate::Family, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), crate::FontRegistrationError> { + self.data.text.register_font(family, data) + } + + pub fn is_font_registered(&self, family: &crate::Family) -> bool { + self.data.text.is_font_registered(family) + } + /// A read-only handle to the retained result of the last completed frame. /// The handle is owned so a caller may keep its read guard while mutating /// unrelated resources on the `Rsc` that owns this `Ui`. diff --git a/iris/src/android/render.rs b/iris/src/android/render.rs index 7dcda75..ccd9f89 100644 --- a/iris/src/android/render.rs +++ b/iris/src/android/render.rs @@ -321,7 +321,6 @@ impl AndroidRenderer { mono={default_mono_family:?}\n\ fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \ mono={mono:?}\n\ - icon font: {icons:?}\n\ wgpu errors since surface creation:\n {errors_text}\n\n\ {frame_report}", name = self.adapter_name, @@ -338,7 +337,6 @@ impl AndroidRenderer { bold = font.bold_resolved, italic = font.italic_resolved, mono = font.mono_resolved, - icons = font.icon_family, ) }