diff --git a/AGENTS.md b/AGENTS.md index 2bddd42..10103a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,18 @@ the **Mono** face, where every glyph is one em square, which is what makes two icon buttons the same width without either being given one — and why `GLYPH_SIZE` is smaller than it looks like it should be. +**The Rust app does the same, from its own subset**: +`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`, +with the codepoints named in `iris/core/src/icon.rs` and drawn as text +with `Family::Icons`. Same rule about the two lists agreeing (there is a +test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material +Design family so an icon means the same thing in both apps. Its subset is +separate rather than shared because subsetting only what one app draws is +the point. This is the **only** font iris bundles — body and monospace +text come from the platform (docs/DECISIONS.md, 2026-09-07), and an icon +is the opposite case: a small closed set of codepoints no system font is +guaranteed to have. + ## Checking your work - **Server**: `./run-tests.sh` from the repo root (or `cargo test` from diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3f4a3bc..b61294a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5,6 +5,31 @@ they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md for iris API changes); this file is only the summary. Newest first. Items marked **DEFERRED** are ones the agent chose not to decide alone. +## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted) + +- **Directed by Iris.** Her question on seeing `widget::mark`: "why does + mark exist? The font should be working if it's working for compose and + nerd fonts are bundled." It was not: the Compose app draws its icons + from **its own committed Nerd Fonts subset**, while iris was setting + the disclosure mark with bare Unicode geometric codepoints + (U+25B8/25BE/25B4) out of whatever face the platform resolved -- an + empty box on her phone, a dot on this VM. The 2026-09-07 entry below, + which said "iris had no equivalent icon font to keep", is what left + that gap: iris had no icon font because it had never had one, not + because it needed none. +- **So iris now bundles the same kind of subset**: + `iris/core/build-icon-font.sh` writes + `iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, three Material + Design glyphs today), `iris::icon` names the codepoints, and + `Family::Icons` draws them. This does **not** reopen the platform-fonts + decision: body and monospace text still come from the platform, and an + icon is the opposite case -- a small, closed, known set of codepoints, + which is exactly the division the Compose app already makes. +- **`iris::widget::mark` is deleted** (added earlier the same day). It + drew a correct triangle, but only a triangle, and every further icon + would have been another bespoke rasteriser. An icon as text also takes + the size, colour and baseline of the line it sits in for free. + ## 2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere) - **Directed by Iris, carried out here**: "make sure the setup uses GL for diff --git a/docs/IRIS.md b/docs/IRIS.md index 0901958..77ab6a7 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -1422,3 +1422,47 @@ which is exactly the run nobody needs. `LogRing::try_tail_text`, because a panic raised while the ring's own lock was held would otherwise deadlock the hook and hang the process instead of aborting it. + +## 2026-09-08: iris ships an icon font, and `widget::mark` is gone + +Iris's question -- "why does mark exist? The font should be working if +it's working for compose and nerd fonts are bundled" -- and its answer: +the Compose app draws icons from its own committed Nerd Fonts subset, +while iris was setting the disclosure mark with bare Unicode geometric +codepoints out of whatever face the platform resolved. So iris now does +what Compose does. + +- **`iris::icon`** (new module): the codepoints iris draws, one constant + each -- `OPEN`, `CLOSED`, `COLLAPSE` today. Every one has to have a + matching entry in `iris/core/build-icon-font.sh`'s `GLYPHS`, which is + what builds the shipped `iris/core/assets/fonts/nerd_icons.ttf` (992 + bytes, Material Design, Mono face). `every_icon_is_in_the_bundled_font` + fails the build if the two lists drift. +- **`Family::Icons`** (new variant): how any text asks for that family. + Before/after: + + // was + mark(if open { Dir::DOWN } else { Dir::RIGHT }, 9.0, MUTED) + // now + text(if open { icon::OPEN } else { icon::CLOSED }, 9.0, MUTED) + .family(Family::Icons) + + It names an intention, not a font name: only `TextData` knows what the + bundled file registered as, and it resolves the variant during shaping + (`TextData::resolve_family`, also public). A *named* family rather than + a generic one, so nothing falls back into it for ordinary text and an + icon cannot fall back out of it onto a system face that happens to have + the codepoint. +- **`iris::widget::mark` is removed** -- added earlier the same day and + superseded within it. It drew one correct triangle; every further icon + would have been another rasteriser, and an icon as text takes the size, + colour and baseline of the line it sits in for free. +- **`FontDiagnostics::icon_family`** (new field), in the startup log line + and the Diagnostics pane: which family the icons resolved to, so a + build whose bundled font failed to register says so instead of drawing + tofu. + +This does not reopen the 2026-09-07 platform-fonts decision. Body and +monospace text still come from the platform's own collection; an icon is +the opposite case, a small closed set of codepoints no system font is +guaranteed to have, and it is the same division the Compose app makes. diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index a39519a..709d12f 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -819,23 +819,25 @@ has the fuller account. nothing on screen says it was cut. Whichever end is cut has to be a choice when this lands: a path is identified by its tail, a command by its head. -- [x] **A drawn chevron.** **Done 2026-09-08**: `iris::widget::mark(dir, - dp, colour)` rasterises an antialiased triangle into the ordinary - texture path and scales it into the box asked for, so no line/path - primitive was needed after all. `tool.rs` uses it for all three marks. - The original entry, for the record: *the bundled fonts - were removed on 2026-09-07 in favour of the platform collection, so the - mark is a codepoint the phone's own faces may not have -- Iris's - 2026-09-08 screenshot shows an empty box where it should be, and the - desktop render draws it as a small dot. UI_RULES: "don't rely on - characters the platform might not have." iris has rects, text and - textures, so the smallest honest fix is a generated texture rather than - a new primitive.* `Chevron.kt` draws its own strokes precisely - because a chevron from a font is a glyph a system font may not have -- - and the bundled `NotoSans-Regular.ttf` indeed has no U+25B8/25BE/25B4, - while `NotoSansMono-Regular.ttf` does. `tool.rs` sets the mark in the - monospace face as a result. A real fix needs a line/path primitive; - iris has rects, text and textures only. +- [x] **A chevron the platform cannot fail to have.** **Done + 2026-09-08**, twice. First as `iris::widget::mark(dir, dp, colour)`, + which rasterised an antialiased triangle into the ordinary texture path + -- correct, but one bespoke shape, and it built a texture *per widget*, + which is what crashed the bench (RUST.md's 2026-09-08 evening entry). + Then, on Iris's question -- "why does mark exist? The font should be + working if it's working for compose and nerd fonts are bundled" -- as + what the Compose app has always done: **iris ships its own Nerd Fonts + subset** (`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/ + nerd_icons.ttf`, 992 bytes, three Material Design glyphs), named in + `iris::icon` and drawn with `Family::Icons`. `mark` is deleted. That + serves every future icon rather than one triangle, and an icon is text, + so it takes the size, colour and baseline of the line it sits in for + free. The original entry, for the record: *the bundled fonts were + removed on 2026-09-07 in favour of the platform collection, so the mark + is a codepoint the phone's own faces may not have -- Iris's 2026-09-08 + screenshot shows an empty box where it should be, and the desktop render + draws it as a small dot. UI_RULES: "don't rely on characters the + platform might not have."* - [ ] **A tool card's text is not selectable.** `Selection` is keyed `(RowKey, block index)` and a card has no markdown blocks, so nothing in a card registers. Compose's `SelectionContainer` covers tool output, diff --git a/docs/RUST.md b/docs/RUST.md index 0cb6202..119cbdc 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -8143,16 +8143,48 @@ recorded anywhere. It is the crash this code had; the devlog fix above is what will say whether it was hers. Ask for the Runtime tab after the next bench APK. -### Open: why `mark` exists at all (Iris asked, 2026-09-08) +### Why `mark` existed, and what replaced it (Iris asked, 2026-09-08) Her question, mid-fix: "why does mark exist? The font should be working if -it's working for compose and nerd fonts are bundled." The Compose app -draws its chevron from **its own committed Nerd Fonts subset** -(`app/build-icon-font.sh`, `NerdIcons.kt`); iris bundles no font at all -since 2026-09-07, and `tool.rs` was using bare geometric codepoints -(U+25B8/25BE/25B4) out of whatever system face resolved -- which her phone -had none for. So the two apps were never doing the same thing, and the -2026-09-07 note that "iris had no equivalent icon font to keep" is what -left the gap. The alternative to `mark` is to bundle the same ~100-glyph -subset in iris and take icons from it, which would serve every future icon -rather than one triangle. Not done: it is hers to choose. +it's working for compose and nerd fonts are bundled." It was not working, +and the two apps were never doing the same thing. The Compose app draws +its icons from **its own committed Nerd Fonts subset** +(`app/build-icon-font.sh`, `NerdIcons.kt`); iris bundles no font since +2026-09-07, and `tool.rs` was setting its mark with bare geometric +codepoints (U+25B8/25BE/25B4) out of whatever system face resolved -- +which her phone had none for. The 2026-09-07 note that "iris had no +equivalent icon font to keep" is the gap: iris had none because it had +never had one. + +**Done, on her instruction.** iris ships the same kind of subset: + +- `iris/core/build-icon-font.sh` -- a copy of the Compose app's script + with its own `GLYPHS` list, writing + `iris/core/assets/fonts/nerd_icons.ttf`. **992 bytes** for three + Material Design glyphs (`md-menu_down`, `md-menu_right`, `md-menu_up`), + from the **Mono** face for the reason the Compose script gives. +- `iris::icon` names the codepoints, one constant each, and + `iris_core::Family::Icons` is how a `TextBuilder` asks for the family. + The name behind it is read back from the collection at registration + rather than written down, and resolved during shaping + (`TextData::resolve_family`), so nothing outside `TextData` needs to + know what the file calls itself. A *named* family, never a generic one: + nothing should fall back into it for text, and an icon must not fall + back out of it onto a system face that happens to have the codepoint. +- `iris::widget::mark` and `src/widget/mark.rs` are **deleted**. + `tool.rs`'s three call sites are `disclosure(icon::OPEN | CLOSED | + COLLAPSE)`, which is `text(...).family(Family::Icons)`. +- `every_icon_is_in_the_bundled_font` is the guard the script's comment + asks for: it maps each `icon::` constant through the shipped font's own + charmap, so a constant added on one side and not the other fails a test + instead of drawing nothing. +- The startup font line and the Diagnostics pane now say which family the + icons resolved to, so a build whose font failed to register says so + rather than drawing tofu. On the emulator: + `icons=Some("Symbols Nerd Font Mono")`. + +Looked at, per UI_RULES, rather than only compiled: the closed and open +marks in `run-headless.sh phone --phone` (before and after a tap that +opens the card), and the collapse bar's up mark in `IRIS_TOOLS_EXPANDED=1 +run-headless.sh transcript`. All three draw, at the size and alignment the +drawn triangle had. diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index 9bb2127..7ee06c6 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -261,7 +261,7 @@ impl AndroidAppState for BenchClient { let font = rsc.ui.text.font_diagnostics(); log::info!( "iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \ - bold={:?} italic={:?} mono={:?}", + bold={:?} italic={:?} mono={:?}, icons={:?}", font.families_found, font.default_family, font.default_mono_family, @@ -269,6 +269,7 @@ impl AndroidAppState for BenchClient { font.bold_resolved, font.italic_resolved, font.mono_resolved, + font.icon_family, ); let mut client = Self { diff --git a/iris/core/assets/fonts/NERD_FONTS_LICENSE.txt b/iris/core/assets/fonts/NERD_FONTS_LICENSE.txt new file mode 100644 index 0000000..06eb073 --- /dev/null +++ b/iris/core/assets/fonts/NERD_FONTS_LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/iris/core/assets/fonts/nerd_icons.ttf b/iris/core/assets/fonts/nerd_icons.ttf new file mode 100644 index 0000000..5395b94 Binary files /dev/null and b/iris/core/assets/fonts/nerd_icons.ttf differ diff --git a/iris/core/build-icon-font.sh b/iris/core/build-icon-font.sh new file mode 100755 index 0000000..8b0c36f --- /dev/null +++ b/iris/core/build-icon-font.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Rebuilds iris/core/assets/fonts/nerd_icons.ttf. +# +# iris 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 +# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a +# dot. UI_RULES: "don't rely on characters the platform might not have -- +# ship the glyph or the asset rather than hoping." +# +# 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 +# the Rust that this script did not subset is a glyph that silently isn't +# there), then run this and commit the result. +# +# Needs python3 and network access; fontTools is fetched into a temporary +# venv, so nothing has to be installed on the machine. +# +# The same arrangement as the Compose app's `app/build-icon-font.sh`, which +# this is copied from -- including the Mono face and the Material Design +# family, so an icon means the same thing in both apps. Copied rather than +# shared because most of it is the GLYPHS list, which has to differ: the +# point of subsetting is to ship only the codepoints one app draws. +set -euo pipefail + +# Codepoint, then the Nerd Fonts glyph name it came from. Material Design +# Icons, as in the Compose app. +GLYPHS=( + U+F035D # md-menu_down -- a card that is open + U+F035F # md-menu_right -- a card that opens + U+F0360 # md-menu_up -- collapse this group again +) + +url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip +here="$(cd "$(dirname "$0")" && pwd)" +out="$here/assets/fonts/nerd_icons.ttf" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +echo "Fetching $url" +curl -fsSL -o "$work/nf.zip" "$url" +python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work" + +python3 -m venv "$work/venv" +"$work/venv/bin/pip" -q install fonttools + +unicodes="$(IFS=,; echo "${GLYPHS[*]}")" +mkdir -p "$(dirname "$out")" +# The Mono face, where every glyph is one em wide and one em tall, so two +# icons at the same font size are the same size without either being given +# one -- the same reason the Compose app's script takes it. It is also what +# makes an icon's box predictable beside a line of text. +"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \ + --unicodes="$unicodes" \ + --layout-features= \ + --drop-tables+=DSIG \ + --output-file="$out" +cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt" + +echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs" diff --git a/iris/core/src/icon.rs b/iris/core/src/icon.rs new file mode 100644 index 0000000..b26e833 --- /dev/null +++ b/iris/core/src/icon.rs @@ -0,0 +1,39 @@ +//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships. +//! +//! **Why a bundled font rather than ordinary Unicode**: the disclosure +//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform +//! resolved, and once iris stopped bundling fonts (DECISIONS.md, +//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a +//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is +//! also what the Compose app has always done for its icons +//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design +//! family, so an icon means the same thing in both apps. +//! +//! **Why not vector assets or drawn shapes**: an icon beside a line of +//! text wants that line's size, colour and baseline, and text gets all +//! three for free. This replaced `iris::widget::mark`, which drew the +//! triangle into a texture: correct, but one shape, and every further icon +//! would have been another bespoke rasteriser. +//! +//! Each constant here has to have a matching codepoint in +//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the +//! script did not subset is a glyph that silently isn't there. The subset +//! is the font's **Mono** face, where every glyph is one em wide and one +//! em tall, so two icons at one font size are one size without either +//! being given one -- and why an icon looks smaller than text at the same +//! size, since the glyph is drawn inside that em rather than filling it. +//! +//! Draw one with [`crate::Family::Icons`]: +//! +//! ```ignore +//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons) +//! ``` + +/// `md-menu_down` -- a filled triangle pointing down: this card is open. +pub const OPEN: &str = "\u{F035D}"; + +/// `md-menu_right` -- pointing right: this card opens. +pub const CLOSED: &str = "\u{F035F}"; + +/// `md-menu_up` -- pointing up: fold this group of cards away again. +pub const COLLAPSE: &str = "\u{F0360}"; diff --git a/iris/core/src/lib.rs b/iris/core/src/lib.rs index 1feb954..195706a 100644 --- a/iris/core/src/lib.rs +++ b/iris/core/src/lib.rs @@ -19,6 +19,7 @@ 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 588eff9..1bc24e7 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -2,14 +2,27 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiC use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, + fontique::Blob, }; use std::ops::Range; +use std::sync::Arc; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, }; +/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built +/// by `iris/core/build-icon-font.sh`, holding only the codepoints +/// `crate::icon` names (992 bytes for three glyphs today). +/// +/// This is the one font bundled here, and it is not a text font: body and +/// monospace text still come from the platform's own collection +/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small, +/// closed set of codepoints no system font is guaranteed to have -- which +/// is the same division the Compose app makes. +const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); + /// What starting up found about text rendering, for the on-screen /// Diagnostics page and the one startup log line (RUST.md's P0 box, "log /// once at startup ... the number of font families found, the default @@ -37,6 +50,11 @@ 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, } /// Everything text needs that outlives one string: the font collection, the @@ -57,6 +75,12 @@ 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, } impl Default for TextData { @@ -76,16 +100,39 @@ 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, } } } +/// Registers the bundled icon font as an ordinary named family and +/// answers the name it registered under -- read back from the collection +/// rather than written down here, so the name cannot drift from the file +/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release +/// ships). +/// +/// A *named* family rather than a generic one: nothing should fall back +/// to it for ordinary text, and nothing should fall back out of it for an +/// icon -- a system face that happens to have one of these codepoints +/// would draw somebody else's picture. +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) +} + /// Works around `fontique` 0.11.1's Android backend never resolving /// `GenericFamily::Monospace` (confirmed against /// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still @@ -184,6 +231,22 @@ fn android_monospace_font_filename() -> Option { fn patch_android_monospace(_font_cx: &mut FontContext) {} impl TextData { + /// [`Family::Icons`] as the name the bundled font actually registered + /// under; everything else unchanged. + /// + /// 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. + pub fn resolve_family(&self, family: &Family) -> Family { + match family { + Family::Icons => self + .icon_family + .clone() + .map_or(Family::Icons, Family::Named), + other => other.clone(), + } + } + /// Builds the startup report -- see `FontDiagnostics`. Queries the /// collection directly (`fontique::Query`) rather than shaping a real /// string, since all that's needed is which family each axis lands on. @@ -261,6 +324,7 @@ impl TextData { bold_resolved, italic_resolved, mono_resolved, + icon_family: self.icon_family.clone(), } } } @@ -272,6 +336,11 @@ 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`]). + Icons, Named(String), } @@ -281,6 +350,11 @@ impl Family { Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif), Self::Serif => FontFamilyName::Generic(GenericFamily::Serif), Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace), + // Only reachable if `resolve_family` did not run, which no + // shaping path allows -- and sans-serif is the honest answer + // for a build whose icon font failed to register: the reader + // gets the platform's own tofu rather than a wrong picture. + Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif), Self::Named(name) => FontFamilyName::Named(name.as_str().into()), }; FontFamily::Single(name) @@ -479,21 +553,29 @@ impl TextBuffer { if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { return; } + // Resolved before the builder borrows `data`: `Family::Icons` + // names an intention, and the name behind it lives on `TextData`. + let base_family = data.resolve_family(&attrs.family); + let span_families: Vec> = self + .spans + .iter() + .map(|span| span.family.as_ref().map(|f| data.resolve_family(f))) + .collect(); let mut builder = data .layout_cx .ranged_builder(&mut data.font_cx, &self.text, 1.0, true); - builder.push_default(StyleProperty::FontFamily(attrs.family.family())); + builder.push_default(StyleProperty::FontFamily(base_family.family())); builder.push_default(StyleProperty::FontSize(attrs.font_size * density)); builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( attrs.line_height * density, ))); builder.push_default(StyleProperty::Brush(attrs.color)); - for span in &self.spans { + for (span, family) in self.spans.iter().zip(&span_families) { let range = span.range.clone(); if let Some(color) = span.color { builder.push(StyleProperty::Brush(color), range.clone()); } - if let Some(family) = &span.family { + if let Some(family) = family { builder.push(StyleProperty::FontFamily(family.family()), range.clone()); } if let Some(size) = span.font_size { @@ -647,3 +729,49 @@ impl TextData { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::icon; + + /// Every codepoint `icon` names is actually in the subset the script + /// built. This is the failure `build-icon-font.sh`'s own comment warns + /// about -- a constant added on one side and not the other is a glyph + /// that silently isn't there -- and it is invisible at runtime, + /// because a missing glyph draws as nothing rather than as an error. + #[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 + ); + } + } + + /// The font registers, so `Family::Icons` resolves to a real family + /// rather than falling through to sans-serif and drawing tofu. + #[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 + ); + } +} diff --git a/iris/src/android/render.rs b/iris/src/android/render.rs index d790be2..97f940c 100644 --- a/iris/src/android/render.rs +++ b/iris/src/android/render.rs @@ -357,6 +357,7 @@ 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, @@ -371,6 +372,7 @@ impl AndroidRenderer { bold = font.bold_resolved, italic = font.italic_resolved, mono = font.mono_resolved, + icons = font.icon_family, ) } diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index ebfac1b..be97be4 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -862,8 +862,8 @@ impl ViewPeer for IrisViewPeer { // // It replaces clearing them, which threw away the *slot // numbering* as well as the pixels: every `TextureHandle` - // a live widget still held -- one per `widget::mark`, so - // one per folded card on the transcript screen -- then + // a live widget still held -- one per icon or image on + // screen, and one per folded card at the time -- then // named a slot nothing recognised, and the next frame // panicked in `image_bind_group` ("texture slot 89 is not // a live standalone image: None"). Re-uploading also keeps diff --git a/iris/src/widget/mark.rs b/iris/src/widget/mark.rs deleted file mode 100644 index b5d76d0..0000000 --- a/iris/src/widget/mark.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! A small filled triangle, drawn rather than typed -- a disclosure -//! marker, and the first thing in iris that draws a shape text cannot. -//! -//! It exists because the alternative was a font codepoint. `tool.rs` used -//! U+25B8/25BE/25B4 in whatever face resolved, which worked only while -//! iris bundled its own fonts; once text moved to the platform's -//! collection (2026-09-07) Iris's phone drew an empty box where the mark -//! should be, and this machine drew a dot. UI_RULES: "don't rely on -//! characters the platform might not have -- ship the glyph or the asset -//! rather than hoping." -//! -//! Rasterised into iris's ordinary texture path rather than needing a new -//! primitive: iris has rects, text and textures, and a triangle is not -//! expressible as any number of rects without a staircase edge. One -//! oversampled bitmap per *shape* is drawn scaled into the box the caller -//! asks for, so the same texture is correct at any density -- which is -//! also why it is built at construction, where the density is not known -//! yet, and scaled at draw, where it is. -//! -//! Per shape, not per widget: the bitmap depends only on the direction and -//! the colour, so it goes through [`Textures::shared`] and a screen full of -//! folded cards draws the three marks it actually has rather than one -//! texture, bind group and draw call per card. - -use crate::prelude::*; -use image::{Rgba, RgbaImage}; -use iris_core::SharedTextureKey; - -/// The bitmap's own size. Generous enough that a 12dp mark at density 3 -/// (36px) is still sampling *down*, which is what keeps the diagonal -/// clean; small enough that the handful the program has cost nothing -/// (48x48 RGBA is 9 KB). -const TEXTURE_PX: u32 = 48; -/// Subsamples per pixel per axis when measuring how much of a pixel the -/// triangle covers. 4x4 is the point where the edge stops looking stepped -/// at these sizes. -const SUBSAMPLES: u32 = 4; - -pub struct Mark { - handle: TextureHandle, - /// The box the triangle is drawn into, in dp -- resolved against the - /// density at draw time, so one mark is the same physical size on any - /// screen. - size_dp: f32, -} - -impl Widget for Mark { - fn draw(&mut self, painter: &mut Painter) -> Size { - let px = self.size_dp * painter.density(); - let size = Vec2::new(px, px); - painter.texture_within(&self.handle, size.align(Align::CENTER)); - Size::abs(size) - } - - fn is_size_independent(&self) -> bool { - true // its size is its own, not a share of what it was offered - } -} - -/// A filled triangle `size_dp` across, pointing along `dir`, in `color` -- -/// what a row uses to say "this opens" and "this is open". -/// -/// `size_dp` is not part of what is rasterised (the bitmap is scaled at -/// draw), so two marks differing only in size share one texture. -pub fn mark(dir: Dir, size_dp: f32, color: UiColor) -> impl WidgetFn { - move |state| Mark { - handle: state - .ui_mut() - .textures - .shared(key(dir, color), || rasterise(dir, color).into()), - size_dp, - } -} - -/// The mark's whole description, packed exactly: the axis and sign of the -/// direction and the four colour channels, one byte each, so distinct -/// marks are distinct keys and equal ones are equal. -fn key(dir: Dir, color: UiColor) -> SharedTextureKey { - let axis = match dir.axis { - Axis::X => 0u64, - Axis::Y => 1, - }; - let sign = match dir.sign { - Sign::Neg => 0u64, - Sign::Pos => 1, - }; - SharedTextureKey { - owner: "mark", - id: u64::from_le_bytes([ - axis as u8, sign as u8, color.r, color.g, color.b, color.a, 0, 0, - ]), - } -} - -/// The triangle, as coverage: for each pixel, how much of it the shape -/// covers, measured by subsampling rather than by an analytic edge -/// function -- one bitmap is built per mark in the whole program, so the -/// simple method is the right one. -fn rasterise(dir: Dir, color: UiColor) -> RgbaImage { - let corners = corners(dir); - let n = TEXTURE_PX as f32; - let step = 1.0 / SUBSAMPLES as f32; - RgbaImage::from_fn(TEXTURE_PX, TEXTURE_PX, |x, y| { - let mut inside = 0u32; - for sy in 0..SUBSAMPLES { - for sx in 0..SUBSAMPLES { - let p = Vec2::new( - (x as f32 + (sx as f32 + 0.5) * step) / n, - (y as f32 + (sy as f32 + 0.5) * step) / n, - ); - if contains(&corners, p) { - inside += 1; - } - } - } - let coverage = inside as f32 / (SUBSAMPLES * SUBSAMPLES) as f32; - // Premultiplied is wrong for this pipeline (`Textures::add` takes - // ordinary RGBA and the shader samples it straight), so the colour - // stays put and only alpha carries the coverage. - Rgba([ - color.r, - color.g, - color.b, - (color.a as f32 * coverage).round() as u8, - ]) - }) -} - -/// The triangle's three corners in unit space, inset a little from the -/// bitmap's edge so its own antialiasing is never clipped by the texture -/// border, and squat rather than equilateral -- the proportions of the -/// disclosure triangles this replaces. -fn corners(dir: Dir) -> [Vec2; 3] { - const NEAR: f32 = 0.12; - const FAR: f32 = 0.88; - // Across the direction of travel, the base spans the full width; along - // it, the tip is at the far end. - let (base, tip) = match dir.sign { - Sign::Pos => (NEAR, FAR), - Sign::Neg => (FAR, NEAR), - }; - let mid = 0.5; - match dir.axis { - Axis::Y => [ - Vec2::new(NEAR, base), - Vec2::new(FAR, base), - Vec2::new(mid, tip), - ], - Axis::X => [ - Vec2::new(base, NEAR), - Vec2::new(base, FAR), - Vec2::new(tip, mid), - ], - } -} - -/// Whether `p` is inside the triangle, by the sign of the cross product -/// against each edge. The corners above are given in a consistent winding -/// per direction, so "all three the same sign" is the test -- written as -/// "never both signs" so a point exactly on an edge counts as inside -/// rather than falling through a crack between two triangles. -fn contains(t: &[Vec2; 3], p: Vec2) -> bool { - let side = |a: Vec2, b: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); - let (d0, d1, d2) = (side(t[0], t[1]), side(t[1], t[2]), side(t[2], t[0])); - let neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0; - let pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0; - !(neg && pos) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The tip points where it was asked to, and the base is at the other - /// end -- checked as coverage rather than by eye, because the whole - /// reason this widget exists is that nobody looked at the glyph it - /// replaces on the device that lacked it. - #[test] - fn a_mark_points_along_its_direction() { - let at = |f: f32| (TEXTURE_PX as f32 * f) as u32; - let (mid, near_tip, past_base) = (at(0.5), at(0.6), at(0.02)); - let (far_tip, far_base) = (at(0.4), at(0.98)); - // Not the tip pixel itself, which is a point and covers nothing: - // a little back from it, where the triangle has width. And just - // *outside* the base, which no part of the shape reaches. - for (name, dir, tip, base) in [ - ("down", Dir::DOWN, (mid, near_tip), (mid, past_base)), - ("up", Dir::UP, (mid, far_tip), (mid, far_base)), - ("right", Dir::RIGHT, (near_tip, mid), (past_base, mid)), - ("left", Dir::LEFT, (far_tip, mid), (far_base, mid)), - ] { - let img = rasterise(dir, UiColor::WHITE); - // Just behind the tip is solid; the same distance past the - // base's outer edge is empty. A triangle drawn the wrong way - // round passes neither. - let inside = img.get_pixel(tip.0, tip.1)[3]; - let outside = img.get_pixel(base.0, base.1)[3]; - assert!( - inside > 200, - "{name}: the pixel at the tip should be covered, alpha={inside}" - ); - assert!( - outside < 40, - "{name}: the pixel past the base should be clear, alpha={outside}" - ); - } - } - - /// Two marks of the same shape share a texture and two of different - /// shapes do not -- the whole of what [`Textures::shared`] is being - /// keyed on, checked here rather than through a widget so it does not - /// need a resource tree. - #[test] - fn a_marks_key_is_its_direction_and_its_colour() { - assert_eq!(key(Dir::UP, UiColor::WHITE), key(Dir::UP, UiColor::WHITE)); - for (name, other) in [ - ("down", Dir::DOWN), - ("left", Dir::LEFT), - ("right", Dir::RIGHT), - ] { - assert_ne!( - key(Dir::UP, UiColor::WHITE), - key(other, UiColor::WHITE), - "{name} shares a key with up" - ); - } - assert_ne!( - key(Dir::UP, UiColor::WHITE), - key(Dir::UP, UiColor::new(255, 255, 255, 128)), - "two colours differing only in alpha share a key" - ); - } - - /// A corner of the bitmap is never covered, whichever way the mark - /// points -- what says the shape is a triangle rather than a filled - /// box, and that the inset in `corners` is keeping its antialiasing - /// inside the texture. - #[test] - fn a_mark_leaves_its_corners_clear() { - for dir in [Dir::DOWN, Dir::UP, Dir::LEFT, Dir::RIGHT] { - let img = rasterise(dir, UiColor::WHITE); - let last = TEXTURE_PX - 1; - for (x, y) in [(0, 0), (last, 0), (0, last), (last, last)] { - assert_eq!(img.get_pixel(x, y)[3], 0, "corner ({x},{y}) is covered"); - } - } - } -} diff --git a/iris/src/widget/mod.rs b/iris/src/widget/mod.rs index 8892759..9131788 100644 --- a/iris/src/widget/mod.rs +++ b/iris/src/widget/mod.rs @@ -1,6 +1,5 @@ mod image; mod list; -mod mark; mod mask; mod position; mod ptr; @@ -10,7 +9,6 @@ mod trait_fns; pub use image::*; pub use list::*; -pub use mark::*; pub use mask::*; pub use position::*; pub use ptr::*; diff --git a/iris/transcript-ui/src/tool.rs b/iris/transcript-ui/src/tool.rs index 031e377..b7825b2 100644 --- a/iris/transcript-ui/src/tool.rs +++ b/iris/transcript-ui/src/tool.rs @@ -107,27 +107,15 @@ const OUTPUT_BYTES: usize = 4096; /// Checked at compile time, since both are constants. const _: () = assert!(OUTPUT_LINES > 0 && OUTPUT_BYTES > 0); -/// The mark that says a card opens, always drawn from the **monospace** -/// face. +/// The size of the disclosure mark, as a font size in dp. /// -/// Not a style choice, historically: with the bundled Noto Sans this crate -/// used to embed, the sans face every other string here is set in had no -/// glyph at U+25B8/U+25BE/U+25B4 at all, while the bundled monospace face -/// did. As of 2026-09-07 iris takes both faces from the platform's own -/// collection instead (DECISIONS.md's 2026-09-07 entry, matching what -/// Compose ships), so this is no longer a checked fact about a specific -/// font's `cmap` -- it is a bet that whatever the platform resolves for -/// `Family::Monospace` covers these three codepoints, same as it was -/// before. If a platform's monospace face turns out not to, the fallback -/// chain still applies (see `TextData::default`'s doc comment) and the -/// glyph should still land, just not necessarily monospaced. IRIS_TODO's -/// "a drawn chevron" has the real fix, which needs a line primitive iris -/// does not have. -/// The disclosure marks, drawn (`iris::widget::mark`) rather than typed. -/// They were the codepoints U+25B8/25BE/25B4 until 2026-09-08, which is a -/// bet on the platform's fonts having them -- Iris's phone drew an empty -/// box and this machine drew a dot once iris stopped bundling its own -/// faces. +/// The mark is a glyph in the icon font iris ships (`iris::icon`, built by +/// `iris/core/build-icon-font.sh`) -- not a codepoint out of whatever the +/// platform resolved, which is what U+25B8/25BE/25B4 were until +/// 2026-09-08: Iris's phone drew an empty box for them and this VM drew a +/// dot once iris stopped bundling faces. The font's Mono face draws each +/// glyph inside a full em, so this reads a little larger than the same +/// number would as text. const MARK_DP: f32 = 9.0; /// Which cards the reader has opened, and which have had their whole @@ -252,6 +240,13 @@ fn text(content: impl Into, size: f32, color: UiColor) -> TextBuild .text_align(Align::LEFT) } +/// One of `iris::icon`'s disclosure marks, at [`MARK_DP`] in the muted +/// colour -- the one place this crate names the icon family, so a second +/// icon is a second constant rather than a second way of asking. +fn disclosure(glyph: &'static str) -> TextBuilder { + text(glyph, MARK_DP, MUTED_COLOR).family(Family::Icons) +} + /// A verbatim block: monospace on the surface everything verbatim in this /// app sits on, not wrapped, panning sideways on a finger. /// @@ -452,13 +447,9 @@ where let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP)); header.push( - mark( - if open { Dir::DOWN } else { Dir::RIGHT }, - MARK_DP, - MUTED_COLOR, - ) - .add_strong(rsc) - .any(), + disclosure(if open { icon::OPEN } else { icon::CLOSED }) + .add_strong(rsc) + .any(), ); header.push( text(tool.clone(), NAME_SIZE, NAME_COLOR) @@ -617,8 +608,8 @@ where { let strong = WidgetPtr::new().add_strong(rsc); let ptr = strong.weak(); - let mark = mark(Dir::UP, MARK_DP, MUTED_COLOR) - .center() + let mark = disclosure(icon::COLLAPSE) + .center_text() .width(rest(1)) .pad(dp(CARD_PAD_DP)) // Anything shown only as a mark still needs a name: this is what