iris: load application-owned fonts
This commit is contained in:
1 parent
d1da066547
commit
beaf24c75d
8 files changed
+124
-154
No files matched your search
@@ -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.
@@ -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"
|
||||
@@ -1,5 +0,0 @@
|
||||
pub const OPEN: &str = "\u{F035D}";
|
||||
|
||||
pub const CLOSED: &str = "\u{F035F}";
|
||||
|
||||
pub const COLLAPSE: &str = "\u{F0360}";
|
||||
@@ -19,7 +19,6 @@ mod render;
|
||||
mod ui;
|
||||
mod widget;
|
||||
|
||||
pub mod icon;
|
||||
pub mod util;
|
||||
|
||||
pub use attr::*;
|
||||
|
||||
+106
-69
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user