From 9dfb469d1ba975e59f39f3bb799b019204315784 Mon Sep 17 00:00:00 2001 From: pml68 Date: Mon, 28 Apr 2025 10:57:42 +0200 Subject: feat: switch to modified `iced_fontello` for custom Theme support --- crates/iced_fontello/.gitignore | 2 + crates/iced_fontello/Cargo.toml | 18 ++ crates/iced_fontello/README.md | 109 +++++++++++ crates/iced_fontello/src/lib.rs | 394 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 crates/iced_fontello/.gitignore create mode 100644 crates/iced_fontello/Cargo.toml create mode 100644 crates/iced_fontello/README.md create mode 100644 crates/iced_fontello/src/lib.rs (limited to 'crates') diff --git a/crates/iced_fontello/.gitignore b/crates/iced_fontello/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/crates/iced_fontello/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/crates/iced_fontello/Cargo.toml b/crates/iced_fontello/Cargo.toml new file mode 100644 index 0000000..8c170a1 --- /dev/null +++ b/crates/iced_fontello/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "iced_fontello" +version = "0.13.2" +edition = "2021" +description = "Generate type-safe icon fonts for `iced` at compile time" +repository = "https://github.com/hecrj/iced_fontello" +license = "MIT" +categories = ["gui"] +keywords = ["gui", "ui", "graphics", "interface", "widgets"] +rust-version = "1.85" + +[dependencies] +reqwest = { version = "0.12", features = ["blocking", "json", "multipart"] } +sha2 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +zip = "2" diff --git a/crates/iced_fontello/README.md b/crates/iced_fontello/README.md new file mode 100644 index 0000000..52a59c1 --- /dev/null +++ b/crates/iced_fontello/README.md @@ -0,0 +1,109 @@ +
+ +# iced_fontello + +[![Documentation](https://docs.rs/iced_fontello/badge.svg)](https://docs.rs/iced_fontello) +[![Crates.io](https://img.shields.io/crates/v/iced_fontello.svg)](https://crates.io/crates/iced_fontello) +[![License](https://img.shields.io/crates/l/iced_fontello.svg)](https://github.com/hecrj/iced_fontello/blob/master/LICENSE) +[![Downloads](https://img.shields.io/crates/d/iced_fontello.svg)](https://crates.io/crates/iced_fontello) +[![Test Status](https://img.shields.io/github/actions/workflow/status/hecrj/iced_fontello/test.yml?branch=master&event=push&label=test)](https://github.com/hecrj/iced_fontello/actions) +[![Discourse](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscourse.iced.rs%2Fsite%2Fstatistics.json&query=%24.users_count&suffix=%20users&label=discourse&color=5e7ce2)](https://discourse.iced.rs/) +[![Discord Server](https://img.shields.io/discord/628993209984614400?label=&labelColor=6A7EC2&logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/3xZJ65GAhd) + +A compile-time, type-safe icon font generator for [`iced`]. +Powered by [Fontello]. + +[`iced`]: https://github.com/iced-rs/iced +[Fontello]: https://github.com/fontello/fontello + +
+ +## Usage +Create a `.toml` file somewhere in your crate with the font definition: + +```toml +# fonts/example-icons.toml +module = "icon" + +[glyphs] +edit = "fontawesome-pencil" +save = "entypo-floppy" +trash = "typicons-trash" +``` + +The `module` value defines the Rust module that will be generated in your `src` +directory containing a type-safe API to use the font. + +Each entry in the `[glyphs]` section corresponds to an icon. The keys will be +used as names for the functions of the module of the font; while the values +specify the glyph for that key using the format: `-`. You can browse +the available glyphs in [Fontello] or [the `fonts.json` file](fonts.json). + +Next, add `iced_fontello` to your `build-dependencies`: + +```rust +[build-dependencies] +iced_fontello = "0.13" +``` + +Then, call `iced_fontello::build` in your [build script](https://doc.rust-lang.org/cargo/reference/build-scripts.html), +passing the path of your font definition: + +```rust +pub fn main() { + println!("cargo::rerun-if-changed=fonts/example-icons.toml"); + iced_fontello::build("fonts/example-icons.toml").expect("Build example-icons font"); +} +``` + +The library will generate the font and save its `.ttf` file right next to its definition. +In this example, the library would generate `fonts/example-icons.ttf`. + +Finally, it will generate a type-safe `iced` API that lets you use the font. In our example: + +```rust +// Generated automatically by iced_fontello at build time. +// Do not edit manually. +// d24460a00249b2acd0ccc64c3176452c546ad12d1038974e974d7bdb4cdb4a8f +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT: &[u8] = include_bytes!("../fonts/example-icons.ttf"); + +pub fn edit<'a>() -> Text<'a> { + icon("\u{270E}") +} + +pub fn save<'a>() -> Text<'a> { + icon("\u{1F4BE}") +} + +pub fn trash<'a>() -> Text<'a> { + icon("\u{E10A}") +} + +fn icon<'a>(codepoint: &'a str) -> Text<'a> { + text(codepoint).font(Font::with_name("example-icons")) +} +``` + +Now you can simply add `mod icon;` to your `lib.rs` or `main.rs` file and enjoy your new font: + +```rust +mod icon; + +use iced::widget::row; + +// ... + +row![icon::edit(), icon::save(), icon::trash()].spacing(10) + +// ... +``` + +Check out [the full example](example) to see it all in action. + +## Packaging +If you plan to package your crate, you must make sure you include the generated module +and font file in the final package. `build` is effectively a no-op when the module and +the font already exist and are up-to-date. diff --git a/crates/iced_fontello/src/lib.rs b/crates/iced_fontello/src/lib.rs new file mode 100644 index 0000000..2b39647 --- /dev/null +++ b/crates/iced_fontello/src/lib.rs @@ -0,0 +1,394 @@ +#![allow(clippy::needless_doctest_main)] +//! A compile-time, type-safe icon font generator for [`iced`]. +//! Powered by [Fontello]. +//! +//! [`iced`]: https://github.com/iced-rs/iced +//! [Fontello]: https://github.com/fontello/fontello +//! +//! # Usage +//! Create a `.toml` file somewhere in your crate with the font definition: +//! +//! ```toml +//! # fonts/example-icons.toml +//! module = "icon" +//! +//! [glyphs] +//! edit = "fontawesome-pencil" +//! save = "entypo-floppy" +//! trash = "typicons-trash" +//! ``` +//! +//! The `module` value defines the Rust module that will be generated in your `src` +//! directory containing a type-safe API to use the font. +//! +//! Each entry in the `[glyphs]` section corresponds to an icon. The keys will be +//! used as names for the functions of the module of the font; while the values +//! specify the glyph for that key using the format: `-`. You can browse +//! the available glyphs in [Fontello] or [the `fonts.json` file](fonts.json). +//! +//! Next, add `iced_fontello` to your `build-dependencies`: +//! +//! ```toml +//! [build-dependencies] +//! iced_fontello = "0.13" +//! ``` +//! +//! Then, call `iced_fontello::build` in your [build script](https://doc.rust-lang.org/cargo/reference/build-scripts.html), +//! passing the path of your font definition: +//! +//! ```rust,no_run +//! pub fn main() { +//! println!("cargo::rerun-if-changed=fonts/example-icons.toml"); +//! iced_fontello::build("fonts/example-icons.toml").expect("Build example-icons font"); +//! } +//! ``` +//! +//! The library will generate the font and save its `.ttf` file right next to its definition. +//! In this example, the library would generate `fonts/example-icons.ttf`. +//! +//! Finally, it will generate a type-safe `iced` API that lets you use the font. In our example: +//! +//! ```rust,ignore +//! // Generated automatically by iced_fontello at build time. +//! // Do not edit manually. +//! // d24460a00249b2acd0ccc64c3176452c546ad12d1038974e974d7bdb4cdb4a8f +//! use iced::widget::{text, Text}; +//! use iced::Font; +//! +//! pub const FONT: &[u8] = include_bytes!("../fonts/example-icons.ttf"); +//! +//! pub fn edit<'a>() -> Text<'a> { +//! icon("\u{270E}") +//! } +//! +//! pub fn save<'a>() -> Text<'a> { +//! icon("\u{1F4BE}") +//! } +//! +//! pub fn trash<'a>() -> Text<'a> { +//! icon("\u{E10A}") +//! } +//! +//! fn icon<'a>(codepoint: &'a str) -> Text<'a> { +//! text(codepoint).font(Font::with_name("example-icons")) +//! } +//! ``` +//! +//! Now you can simply add `mod icon;` to your `lib.rs` or `main.rs` file and enjoy your new font: +//! +//! ```rust,ignore +//! mod icon; +//! +//! use iced::widget::row; +//! +//! // ... +//! +//! row![icon::edit(), icon::save(), icon::trash()].spacing(10) +//! +//! // ... +//! ``` +//! +//! Check out [the full example](example) to see it all in action. +//! +//! # Packaging +//! If you plan to package your crate, you must make sure you include the generated module +//! and font file in the final package. `build` is effectively a no-op when the module and +//! the font already exist and are up-to-date. +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use reqwest::blocking as reqwest; +use serde::{Deserialize, Serialize}; + +pub fn build(path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + + let definition: Definition = { + let contents = fs::read_to_string(path).unwrap_or_else(|error| { + panic!( + "Font definition {path} could not be read: {error}", + path = path.display() + ) + }); + + toml::from_str(&contents).unwrap_or_else(|error| { + panic!( + "Font definition {path} is invalid: {error}", + path = path.display() + ) + }) + }; + + let fonts = parse_fonts(); + + let glyphs: BTreeMap = definition + .glyphs + .into_iter() + .map(|(name, id)| { + let Some((font_name, glyph)) = id.split_once('-') else { + panic!( + "Invalid glyph identifier: \"{id}\"\n\ + Glyph identifier must have \"-\" format" + ) + }; + + let Some(font) = fonts.get(font_name) else { + panic!( + "Font \"{font_name}\" was not found. Available fonts are:\n{}", + fonts + .keys() + .map(|name| format!("- {name}")) + .collect::>() + .join("\n") + ); + }; + + let Some(glyph) = font.glyphs.get(glyph) else { + // TODO: Display similarly named candidates + panic!( + "Glyph \"{glyph}\" was not found. Available glyphs are:\n{}", + font.glyphs + .keys() + .map(|name| format!("- {name}")) + .collect::>() + .join("\n") + ); + }; + + ( + name, + ChosenGlyph { + uid: glyph.uid.clone(), + css: glyph.name.clone(), + code: glyph.code, + src: font.name.clone(), + }, + ) + }) + .collect(); + + #[derive(Serialize)] + struct Config { + name: String, + css_prefix_text: &'static str, + css_use_suffix: bool, + hinting: bool, + units_per_em: u32, + ascent: u32, + glyphs: Vec, + } + + #[derive(Clone, Serialize)] + struct ChosenGlyph { + uid: Id, + css: String, + code: u64, + src: String, + } + + let file_name = path + .file_stem() + .expect("Get file stem from definition path") + .to_string_lossy() + .into_owned(); + + let config = Config { + name: file_name.clone(), + css_prefix_text: "icon-", + css_use_suffix: false, + hinting: true, + units_per_em: 1000, + ascent: 850, + glyphs: glyphs.values().cloned().collect(), + }; + + let hash = { + use sha2::Digest as _; + + let mut hasher = sha2::Sha256::new(); + hasher.update( + serde_json::to_string(&config).expect("Serialize config as JSON"), + ); + + format!("{:x}", hasher.finalize()) + }; + + let module_target = PathBuf::new() + .join("src") + .join(definition.module.replace("::", "/")) + .with_extension("rs"); + + let module_contents = + fs::read_to_string(&module_target).unwrap_or_default(); + let module_hash = module_contents + .lines() + .nth(2) + .unwrap_or_default() + .trim_start_matches("// "); + + if hash != module_hash || !path.with_extension("ttf").exists() { + let client = reqwest::Client::new(); + let session = client + .post("https://fontello.com/") + .multipart( + reqwest::multipart::Form::new().part( + "config", + reqwest::multipart::Part::text( + serde_json::to_string(&config) + .expect("Serialize Fontello config"), + ) + .file_name("config.json"), + ), + ) + .send() + .and_then(reqwest::Response::error_for_status) + .and_then(reqwest::Response::text) + .expect("Create Fontello session"); + + let font = client + .get(format!("https://fontello.com/{session}/get")) + .send() + .and_then(reqwest::Response::error_for_status) + .and_then(reqwest::Response::bytes) + .expect("Download Fontello font"); + + let mut archive = zip::ZipArchive::new(io::Cursor::new(font)) + .expect("Parse compressed font"); + + let mut font_file = (0..archive.len()) + .find(|i| { + let file = + archive.by_index(*i).expect("Access zip archive by index"); + + file.name().ends_with(&format!("{file_name}.ttf")) + }) + .and_then(|i| archive.by_index(i).ok()) + .expect("Find font file in zipped archive"); + + io::copy( + &mut font_file, + &mut fs::File::create(path.with_extension("ttf")) + .expect("Create font file"), + ) + .expect("Extract font file"); + } + + let relative_path = PathBuf::from( + std::iter::repeat("../") + .take(definition.module.split("::").count()) + .collect::(), + ); + + let mut module = String::new(); + + module.push_str(&format!( + "// Generated automatically by iced_fontello at build time.\n\ + // Do not edit manually. Source: {source}\n\ + // {hash}\n\ + use iced::Font;\n\ + use iced::widget::text;\n\n\ + use crate::widget::Text;\n\n\ + pub const FONT: &[u8] = include_bytes!(\"{path}\");\n\n", + source = relative_path.join(path.with_extension("toml")).display(), + path = relative_path.join(path.with_extension("ttf")).display() + )); + + for (name, glyph) in glyphs { + module.push_str(&format!( + "\ +pub fn {name}<'a>() -> Text<'a> {{ + icon(\"\\u{{{code:X}}}\") +}}\n\n", + code = glyph.code + )); + } + + module.push_str(&format!( + "\ +fn icon(codepoint: &str) -> Text<'_> {{ + text(codepoint).font(Font::with_name(\"{file_name}\")) +}}\n" + )); + + if module != module_contents { + if let Some(directory) = module_target.parent() { + fs::create_dir_all(directory) + .expect("Create parent directory of font module"); + } + + fs::write(module_target, module).expect("Write font module"); + } + + Ok(()) +} + +#[derive(Debug, Clone)] +pub enum Error {} + +#[derive(Debug, Clone, Deserialize)] +struct Definition { + module: String, + glyphs: BTreeMap, +} + +#[derive(Debug, Clone)] +struct Font { + name: String, + glyphs: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +struct Glyph { + uid: Id, + code: u64, + #[serde(rename = "css")] + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct Id(String); + +fn parse_fonts() -> BTreeMap { + #[derive(Deserialize)] + struct ItemSchema { + font: FontSchema, + glyphs: Vec, + } + + #[derive(Deserialize)] + struct FontSchema { + fontname: String, + } + + let items: Vec = + serde_json::from_str(include_str!("../fonts.json")) + .expect("Deserialize fonts"); + + items + .into_iter() + .map(|item| { + ( + item.font.fontname.clone(), + Font { + name: item.font.fontname, + glyphs: item + .glyphs + .into_iter() + .map(|glyph| (glyph.name.clone(), glyph)) + .collect(), + }, + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_parses_fonts() { + assert!(!parse_fonts().is_empty()); + } +} -- cgit v1.2.3