iris: load application-owned fonts

This commit is contained in:
iris committed 2026-09-11 14:14:41 -04:00
1 parent d1da066547
commit beaf24c75d
8 files changed
+123 -153

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
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.
Binary file not shown.
-56
View File
@@ -1,56 +0,0 @@
#!/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 Mono face makes icon metrics stable; the Material Design family keeps
# their meanings conventional.
set -euo pipefail
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, which 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"
-5
View File
@@ -1,5 +0,0 @@
pub const OPEN: &str = "\u{F035D}";
pub const CLOSED: &str = "\u{F035F}";
pub const COLLAPSE: &str = "\u{F0360}";
-1
View File
@@ -19,7 +19,6 @@ mod render;
mod ui;
mod widget;
pub mod icon;
pub mod util;
pub use attr::*;
+105 -68
View File
@@ -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<String>,
pub italic_resolved: Option<String>,
pub mono_resolved: Option<String>,
/// 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<String>,
}
#[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<PaintId>,
@@ -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<String>,
registered_families: HashMap<Family, String>,
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<String> {
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<String> {
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<f32>,
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)
);
}
}
+18
View File
@@ -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`.
-2
View File
@@ -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,
)
}