diff --git a/CHANGELOG.md b/CHANGELOG.md index e419344..0f478b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Ctrl+N (Cmd+N on macOS) opens a new Draft; the current Document is flushed first and stays active when saving fails or a conflict is unresolved. - Ctrl+P (Cmd+P on macOS) opens a temporary centered Document selector over the Writing surface, composed by a new application shell; `Escape` or a pointer click on the overlay dismisses it without forcing a save or changing the active Document, and the existing flush-or-block New Draft transition is available from inside the selector. - The Document selector catalogs direct-child `.txt` Files as ordered cards with previews, active-buffer overlays, and duplicate-title filename disambiguation. +- The interface uses the bundled iA Writer Quattro variable Typeface from the first frame, so selector and feedback chrome share a stable interface typography without any download. - Minimal Floem application scaffold for editor-focused development. - Standalone local persistence boundary for UTF-8 plain-text Documents with atomic File writes. - Debounced autosave with synchronous shutdown flush and retryable save feedback. @@ -21,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The OS window title reflects the visible Document title: the first content line, or the `Untitled` placeholder for empty Documents. - Un-vetoable termination (macOS Cmd+Q, system quit) preserves conflicted local content as a distinct collision-safe copy so hard quit cannot lose writing. - Typed Preferences domain with Appearance defaults (System Theme mapping Paper and Ink, Comfortable Writing width, zero Letter spacing, System monospace Typeface, 16px Font size, Regular Body and Bold Document-title weights), a title-heavier-than-body validation invariant, and atomic persistence; the app config now keeps session bookkeeping and Appearance in separate typed sections and migrates existing configs on their next write without losing the last-opened File (Phase 4.2). +- Curated editor Typefaces are delivered on demand through a fixed manifest pinned to canonical upstream releases or commits (Geist Mono, JetBrains Mono, iA Writer Mono, Fira Code, Source Code Pro): SHA-256-verified downloads, an app-local cache under the platform cache directory, and retryable, offline-safe behavior — nothing is installed into the operating system (Phase 4.3). ### Changed diff --git a/Cargo.lock b/Cargo.lock index 10a892b..69a7f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -634,6 +643,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -681,6 +701,17 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "dlib" version = "0.5.3" @@ -1122,6 +1153,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.33" @@ -1433,6 +1473,109 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "im" version = "15.1.0" @@ -1635,9 +1778,12 @@ dependencies = [ "floem", "serde", "serde_json", + "sha2", "signal-hook", "time", "unicode-normalization", + "ureq", + "zip", ] [[package]] @@ -1692,6 +1838,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -2214,6 +2366,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2466,6 +2627,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -2504,6 +2679,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2853,6 +3063,12 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2887,6 +3103,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -2941,6 +3163,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -3083,6 +3316,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -3265,6 +3508,40 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.43.0" @@ -3292,6 +3569,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -3558,6 +3841,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -3750,6 +4051,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3961,6 +4271,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11-clipboard" version = "0.9.3" @@ -4046,6 +4362,29 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeno" version = "0.2.3" @@ -4072,12 +4411,101 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.19", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 2eae6a0..a1488ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ ink-dev = [] [dependencies] dirs = "6.0" serde = { version = "1", features = ["derive"] } +sha2 = "0.10" unicode-normalization = "0.1" time = { version = "0.3", features = ["formatting", "local-offset"] } serde_json = "1" +ureq = { version = "2", features = ["tls"] } +zip = { version = "2", default-features = false, features = ["deflate"] } floem = { version = "0.2", default-features = false, features = ["editor", "vger"] } [target.'cfg(unix)'.dependencies] diff --git a/assets/fonts/iA-Writer-Quattro-LICENSE.md b/assets/fonts/iA-Writer-Quattro-LICENSE.md new file mode 100644 index 0000000..5cd41aa --- /dev/null +++ b/assets/fonts/iA-Writer-Quattro-LICENSE.md @@ -0,0 +1,100 @@ +# iA Writer Typeface + +Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer" + +# Based on IBM Plex Typeface + +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +# License + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/iAWriterQuattroV.ttf b/assets/fonts/iAWriterQuattroV.ttf new file mode 100644 index 0000000..6d50c14 Binary files /dev/null and b/assets/fonts/iAWriterQuattroV.ttf differ diff --git a/docs/font-delivery-research.md b/docs/font-delivery-research.md index 2fdbbc7..4e21260 100644 --- a/docs/font-delivery-research.md +++ b/docs/font-delivery-research.md @@ -36,6 +36,10 @@ A font downloaded from its official URL and held only in a user's Lettera-local | Source Code Pro | Editor | [Adobe TTF release asset](https://github.com/adobe-fonts/source-code-pro/releases/download/2.042R-u/1.062R-i/1.026R-vf/TTF-source-code-pro-2.042R-u_1.062R-i.zip) | `2.042R-u/1.062R-i/1.026R-vf` | | iA Writer Quattro | UI | [iA official source](https://github.com/iaolo/iA-Fonts/tree/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Quattro) | commit `f32c04c3058a75d7ce28919ce70fe8800817491b` | +### Manifest sources (Phase 4.3) + +The FontDelivery manifest (`src/font_delivery.rs`) serves the static TTFs directly from the upstream repositories at the same immutable pins as the candidate table above, instead of the release ZIP assets, for Geist Mono, JetBrains Mono, and Source Code Pro. These are the same canonical upstream files at the same pinned revisions; the manifest hashes were captured from them on 2026-08-05. Fira Code publishes no TTF in its release tree, so it remains pinned to its official release archive; iA Writer Mono is served from its pinned commit as recorded. Serving raw tree files avoids downloading and extracting release archives where a static TTF exists at the pin. + ## Spike verification (Phase 4.1) > Date: 2026-08-05, branch `feat/phase-4.1-font-spike`, Arch Linux. @@ -53,7 +57,17 @@ Remapping `fontdb`'s generic family with `set_monospace_family` makes direct `db `floem::text::FONT_SYSTEM.lock().db_mut().load_font_data(bytes)` accepted real TTF bytes from disk, grew the face count (973 → 974), and the newly registered face resolved by its own family name afterwards; `db_mut()` clears cosmic-text's font-match cache. Real-weight matching selects distinct faces (e.g. GeistMono Nerd Font Mono regular vs bold IDs differ; Adwaita Mono regular 4 vs bold 1), so no synthetic bold is needed when separate Regular and Bold faces exist. -### Not verified in this spike +#### Bundled Interface typeface: variable over static (Phase 4.4) + +Phase 4.4 bundles iA Writer Quattro for the interface. The original plan was the static Regular/Bold pair (`iAWriterQuattroS-Regular.ttf` / `-Bold.ttf`), but verification showed those two faces cannot express weight in this stack: + +- Both static faces report the same `usWeightClass` (608 in the OS/2 table; fontdb normalises both to weight 400). fontdb queries for `Weight::NORMAL` and `Weight::BOLD` return the **same face id**, so interface bold would render as regular. +- The static files do carry distinct `fsSelection` bits (Regular 64, Bold 32), but fontdb/ttf-parser derive weight from `usWeightClass` only. +- Modifying the font bytes to fix the weight class would be an OFL modification under iA's Reserved Font Names — not acceptable. + +Resolution: bundle the **variable font** `iAWriterQuattroV.ttf` (family `iA Writer Quattro V`, `wght` axis 400–700, default 700) instead of the static pair. Verified by shaping: normal shapes at 91.2 px vs bold at 92.7 px for the same 16px sample — cosmic-text applies the `wght` variation per requested weight. This is a recorded deviation from the phase-4 spec text (“static Regular and Bold are bundled”); the variable file is still a single pinned upstream TTF asset under ADR 0006. + +## Not verified in this spike - macOS and Windows: OS monospace resolution is a stub returning `None`; the production adapter must implement and record per-platform evidence (e.g. `NSFont` monospaced system font on macOS). - Full visual editor relayout and caret preservation after a live Typeface change: the shaping-level mechanism is verified above, but widget-level reflow, scroll, and caret behavior remain a manual check on each platform. diff --git a/docs/phase-4-settings-and-appearance.md b/docs/phase-4-settings-and-appearance.md index 2fefe97..002d447 100644 --- a/docs/phase-4-settings-and-appearance.md +++ b/docs/phase-4-settings-and-appearance.md @@ -79,7 +79,7 @@ Appearance provides curated Themes, Writing width, Letter spacing, Typeface, Fon - Theme mode has System, Paper, and Ink values. System resolves separately mapped light and dark Themes, defaults to Paper and Ink respectively, and listens for supported operating-system appearance changes. Mapping controls stay hidden until a choice has more than one valid Theme. - Writing width remains the existing Narrow, Comfortable, Wide, and Unlimited set. Unlimited keeps 48px horizontal padding per side. Letter spacing remains the existing five discrete values. Font size is limited to 12px through 24px in 1px increments, defaulting to 16px. - A Typeface is either System monospace or one of the five curated editor Typefaces: Geist Mono, JetBrains Mono, iA Writer Mono, Fira Code, and Source Code Pro. System monospace means the OS-configured monospaced Typeface, never an accidental renderer generic fallback. -- iA Writer Quattro static Regular and Bold are bundled as the Interface typeface. Its required copyright and OFL notice ship with the distribution. It is never exposed as a Writing-surface Preference. +- iA Writer Quattro is bundled as the Interface typeface. The variable font (`iA Writer Quattro V`) is used instead of the static Regular/Bold pair because the static faces share one fontdb weight and cannot express interface bold; weight variation on the `wght` axis is verified by shaping (see the font-delivery research note). Its required copyright and OFL notice ship with the distribution. It is never exposed as a Writing-surface Preference. - Curated editor Typefaces are downloaded only after user selection. They are cached in Lettera's platform cache directory, not globally installed, and loaded from verified bytes into Lettera's process-local font database. - FontDelivery owns the fixed manifest, app-local cache, download, hash validation, licence metadata, cache invalidation, and retry result. The manifest uses only canonical upstream sources, pinned release versions or commits, static TTF assets, SHA-256 hashes, and licence records. It does not follow mutable branches or silently update fonts. - A cache miss or invalid entry keeps writing available in the current Typeface or System monospace, retains the desired Typeface Preference, requests a background restore when allowed, and presents retryable Settings feedback on failure. Download success triggers live use only after verification. diff --git a/src/editor.rs b/src/editor.rs index 8dae633..8c07f79 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -29,13 +29,20 @@ use floem::views::editor::{ id::EditorId, view::cursor_caret, CurrentLineColor, Editor, EditorStyle, SelectionColor, }; use floem::views::scroll::ScrollCustomStyle; -use floem::views::{button, container, dyn_container, empty, h_stack, static_label}; +use floem::views::{button, container, dyn_container, empty, h_stack, static_label, Label}; const DEFAULT_FONT_SIZE: f64 = 16.0; const REFERENCE_SAMPLE: &str = "0000000000"; const HORIZONTAL_PADDING: f64 = 48.0; const VERTICAL_PADDING: f64 = 40.0; +/// A static label styled with the bundled Interface typeface. Every +/// non-editor surface (selector, feedback, Settings) uses this so Lettera's +/// interface typography is consistent without touching editor content fonts. +pub(crate) fn interface_label(text: impl Into) -> Label { + static_label(text).style(|s| s.font_family(crate::fonts::INTERFACE_TYPEFACE_FAMILY.to_owned())) +} + pub(crate) type SharedPersistence = Rc>; /// Mutable hook the writing surface calls when a shortcut fires. @@ -747,8 +754,8 @@ pub(crate) fn writing_surface( let session = Rc::clone(&overwrite_session); let feedback = overwrite_feedback; h_stack(( - static_label("Overwrite external changes?"), - button("Confirm").action(move || { + interface_label("Overwrite external changes?"), + button(interface_label("Confirm")).action(move || { let resolved = session.borrow_mut().resolve_keep_local(); match resolved { Ok(()) => { @@ -758,14 +765,14 @@ pub(crate) fn writing_surface( Err(error) => feedback.set(Some(error)), } }), - button("Cancel").action(move || { + button(interface_label("Cancel")).action(move || { confirm_overwrite.set(false); }), )) .into_any() } else { let confirm_overwrite = confirm_overwrite; - button("Overwrite") + button(interface_label("Overwrite")) .action(move || confirm_overwrite.set(true)) .into_any() } @@ -779,9 +786,9 @@ pub(crate) fn writing_surface( let save_copy_feedback = feedback; h_stack(( - static_label(message), + interface_label(message), overwrite_confirmation, - button("Load external").action(move || { + button(interface_label("Load external")).action(move || { let resolved = load_session.borrow_mut().resolve_load_external(); match resolved { Ok(Some(content)) => { @@ -798,7 +805,7 @@ pub(crate) fn writing_surface( Err(error) => load_feedback.set(Some(error)), } }), - button("Save copy").action(move || { + button(interface_label("Save copy")).action(move || { let resolved = save_copy_session.borrow_mut().resolve_save_copy(); match resolved { Ok(()) => { @@ -813,8 +820,8 @@ pub(crate) fn writing_surface( .into_any() } else { h_stack(( - static_label(message), - button("Retry").action(move || { + interface_label(message), + button(interface_label("Retry")).action(move || { let request = session.borrow_mut().begin_retry(); if let Some(request) = request { start_background_save(Rc::clone(&session), feedback, request, None); diff --git a/src/font_delivery.rs b/src/font_delivery.rs new file mode 100644 index 0000000..8ea2704 --- /dev/null +++ b/src/font_delivery.rs @@ -0,0 +1,937 @@ +//! FontDelivery (Phase 4.3): curated editor Typefaces on demand. +//! +//! A fixed, verifiable manifest pins each curated Typeface to its canonical +//! upstream source at an immutable release version or commit. Downloads are +//! cached under Lettera's platform cache directory, accepted only after +//! SHA-256 verification, and never installed into the operating system. A +//! missing, cleared, or invalid cache entry keeps the writer on the current +//! Typeface, retains the desired Typeface Preference, and can restore the +//! Typeface in the background: `ensure` is a single retryable operation and a +//! failed download never blocks launching or writing. +//! +//! The manifest covers the five curated editor Typefaces from `preferences`; +//! System monospace is not downloadable. Four faces are served as raw TTF +//! files at their pinned tag or commit; Fira Code publishes no TTF in its +//! release tree, so it is pinned to the official release archive and its TTF +//! assets are extracted from it. Hashes are captured from the canonical +//! upstream files at pin time and are immutable with the pin. + +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::atomic; +use crate::preferences::{FontWeight, Typeface}; + +/// A SHA-256 digest of a verified font asset. +pub type Sha256Digest = [u8; 32]; + +/// Licence record pinned with every curated Typeface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LicenceRecord { + /// Licence identifier (every curated face is SIL Open Font License 1.1). + pub name: &'static str, + /// Copyright and attribution notice from the official licence file. + pub notice: &'static str, + /// Canonical licence file at the pinned revision. + pub source: &'static str, +} + +/// One verified static TTF asset of a curated Typeface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontAsset { + /// Real weight this asset provides (Regular or Bold). + pub weight: FontWeight, + /// Canonical upstream URL at an immutable pin. Either a raw TTF file, or + /// a release archive when [`Self::archive_asset`] is set. + pub source: &'static str, + /// Immutable release version or commit the pin is anchored to. Never a + /// mutable branch. + pub pin: &'static str, + /// Path of the TTF inside the archive when `source` is a release archive. + pub archive_asset: Option<&'static str>, + /// SHA-256 of the exact TTF bytes, captured from the canonical upstream + /// file at pin time. + pub sha256: Sha256Digest, +} + +/// A curated editor Typeface in the fixed manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ManifestEntry { + pub typeface: Typeface, + pub licence: LicenceRecord, + /// The Regular asset every curated face provides. + pub regular: FontAsset, + /// The Bold asset every curated face provides. + pub bold: FontAsset, +} + +/// The fixed manifest of curated editor Typefaces. +pub fn manifest() -> &'static [ManifestEntry] { + &MANIFEST +} + +/// Manifest entry for a curated Typeface, or `None` for System monospace, +/// which is not downloadable. +pub fn entry_for(typeface: Typeface) -> Option<&'static ManifestEntry> { + find_entry(manifest(), typeface) +} + +fn find_entry(manifest: &[ManifestEntry], typeface: Typeface) -> Option<&ManifestEntry> { + manifest.iter().find(|entry| entry.typeface == typeface) +} + +/// Downloads the bytes of a canonical URL. +/// +/// The production implementation is a plain HTTPS request; tests substitute a +/// fake fetcher with controlled bytes and failures, so no test contacts a +/// public font host or the network. +pub trait Fetcher { + fn fetch(&self, url: &str) -> Result, String>; +} + +/// Upper bound for any single download or extracted archive entry. Real fonts +/// are a few megabytes at most; the cap keeps a misbehaving source from +/// ballooning memory before hash verification rejects its bytes. +const MAX_FONT_BYTES: usize = 64 * 1024 * 1024; + +/// Production fetcher: a plain HTTPS download with `ureq`. +pub struct HttpFetcher; + +impl Fetcher for HttpFetcher { + fn fetch(&self, url: &str) -> Result, String> { + let response = ureq::get(url) + .call() + .map_err(|error| format!("could not download {url}: {error}"))?; + let mut reader = response.into_reader().take(MAX_FONT_BYTES as u64 + 1); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| format!("could not read download {url}: {error}"))?; + if bytes.len() > MAX_FONT_BYTES { + return Err(format!("download of {url} exceeds the size limit")); + } + Ok(bytes) + } +} + +/// A failure in Typeface delivery. Every error is actionable and leaves the +/// writer on the current Typeface; none blocks launching or writing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeliveryError { + /// The download failed (network, status, or read error). Retryable. + Fetch(String), + /// Downloaded bytes did not match the pinned SHA-256. Redownloadable. + Invalid(String), + /// The platform cache directory is unavailable or could not be written. + Cache(String), + /// The Typeface is not downloadable (System monospace). + NotDownloadable, +} + +impl fmt::Display for DeliveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fetch(message) => write!(formatter, "could not download Typeface: {message}"), + Self::Invalid(message) => { + write!(formatter, "downloaded Typeface is invalid: {message}") + } + Self::Cache(message) => write!(formatter, "Typeface cache failure: {message}"), + Self::NotDownloadable => formatter.write_str("this Typeface is not downloadable"), + } + } +} + +/// Verified TTF bytes of a Typeface's Regular and Bold assets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedAssets { + pub regular: Vec, + pub bold: Vec, +} + +/// Domain seam coordinating manifest, app-local cache, download, hash +/// validation, and retry for curated editor Typefaces. +pub struct FontDelivery<'manifest, F> { + fetcher: F, + cache: Option, + manifest: &'manifest [ManifestEntry], +} + +impl<'manifest, F: Fetcher> FontDelivery<'manifest, F> { + /// Delivery against an explicit cache directory (test seam). + pub fn at(cache: impl Into, fetcher: F, manifest: &'manifest [ManifestEntry]) -> Self { + Self { + fetcher, + cache: Some(cache.into()), + manifest, + } + } + + fn entry(&self, typeface: Typeface) -> Option<&ManifestEntry> { + find_entry(self.manifest, typeface) + } + + /// Verified cached assets for the Typeface, if both are present and match + /// the pinned hashes. Never touches the network. + pub fn cached(&self, typeface: Typeface) -> Option { + let entry = self.entry(typeface)?; + let cache = self.cache.as_ref()?; + self.cached_assets(cache, entry) + } + + fn cached_assets(&self, cache: &Path, entry: &ManifestEntry) -> Option { + let regular = read_verified( + &cache_path(cache, entry.typeface, entry.regular.weight), + entry.regular.sha256, + )?; + let bold = read_verified( + &cache_path(cache, entry.typeface, entry.bold.weight), + entry.bold.sha256, + )?; + Some(VerifiedAssets { regular, bold }) + } + + /// Ensures the Typeface's verified assets are cached, downloading and + /// validating them when needed, and returns the verified TTF bytes. + /// + /// Idempotent: a valid cache entry is reused. A failed asset leaves the + /// successful one cached; retrying the same call resumes where it stopped. + pub fn ensure(&self, typeface: Typeface) -> Result { + let entry = self.entry(typeface).ok_or(DeliveryError::NotDownloadable)?; + let cache = self.cache.as_deref().ok_or_else(|| { + DeliveryError::Cache("no platform cache directory is available".to_owned()) + })?; + if let Some(cached) = self.cached_assets(cache, entry) { + return Ok(cached); + } + // When both assets share one release archive, download it once instead + // of fetching the same archive twice. + if entry.regular.archive_asset.is_some() + && entry.bold.archive_asset.is_some() + && entry.regular.source == entry.bold.source + { + let archive = self + .fetcher + .fetch(entry.regular.source) + .map_err(DeliveryError::Fetch)?; + let regular = self.store_verified(cache, entry, &entry.regular, &archive)?; + let bold = self.store_verified(cache, entry, &entry.bold, &archive)?; + return Ok(VerifiedAssets { regular, bold }); + } + let regular = self.ensure_asset(cache, entry, &entry.regular)?; + let bold = self.ensure_asset(cache, entry, &entry.bold)?; + Ok(VerifiedAssets { regular, bold }) + } + + fn ensure_asset( + &self, + cache: &Path, + entry: &ManifestEntry, + asset: &FontAsset, + ) -> Result, DeliveryError> { + let path = cache_path(cache, entry.typeface, asset.weight); + if let Some(verified) = read_verified(&path, asset.sha256) { + return Ok(verified); + } + let downloaded = self + .fetcher + .fetch(asset.source) + .map_err(DeliveryError::Fetch)?; + self.store_verified(cache, entry, asset, &downloaded) + } + + fn store_verified( + &self, + cache: &Path, + entry: &ManifestEntry, + asset: &FontAsset, + downloaded: &[u8], + ) -> Result, DeliveryError> { + let bytes = match asset.archive_asset { + Some(inner) => { + extract_archive_asset(downloaded, inner).map_err(DeliveryError::Invalid)? + } + None => downloaded.to_vec(), + }; + if digest(&bytes) != asset.sha256 { + return Err(DeliveryError::Invalid(format!( + "{} {} does not match its pinned SHA-256", + typeface_slug(entry.typeface), + weight_slug(asset.weight) + ))); + } + let path = cache_path(cache, entry.typeface, asset.weight); + fs::create_dir_all(cache).map_err(|error| { + DeliveryError::Cache(format!("could not create {}: {error}", cache.display())) + })?; + atomic::write(&path, &bytes).map_err(|error| { + DeliveryError::Cache(format!("could not write {}: {error}", path.display())) + })?; + Ok(bytes) + } + + /// Removes the Typeface's cached assets. Harmless: a later `ensure` + /// refetches verified bytes. + pub fn clear_cache(&self, typeface: Typeface) -> Result<(), DeliveryError> { + let Some(cache) = &self.cache else { + return Ok(()); + }; + let Some(entry) = self.entry(typeface) else { + return Ok(()); + }; + for asset in [entry.regular, entry.bold] { + let path = cache_path(cache, typeface, asset.weight); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(DeliveryError::Cache(format!( + "could not clear cached Typeface {}: {error}", + path.display() + ))); + } + } + } + Ok(()) + } +} + +impl FontDelivery<'static, HttpFetcher> { + /// Production delivery against the platform cache directory. + pub fn new() -> Self { + Self { + fetcher: HttpFetcher, + cache: default_cache_directory(), + manifest: manifest(), + } + } +} + +impl Default for FontDelivery<'static, HttpFetcher> { + fn default() -> Self { + Self::new() + } +} + +/// Platform-standard cache directory for downloaded Typefaces. +pub(crate) fn default_cache_directory() -> Option { + dirs::cache_dir().map(|directory| { + directory + .join(crate::preferences::CONFIG_DIR_NAME) + .join("fonts") + }) +} + +fn cache_path(cache: &Path, typeface: Typeface, weight: FontWeight) -> PathBuf { + cache.join(format!( + "{}-{}.ttf", + typeface_slug(typeface), + weight_slug(weight) + )) +} + +fn read_verified(path: &Path, expected: Sha256Digest) -> Option> { + let bytes = fs::read(path).ok()?; + (digest(&bytes) == expected).then_some(bytes) +} + +fn digest(bytes: &[u8]) -> Sha256Digest { + Sha256::digest(bytes).into() +} + +fn extract_archive_asset(archive: &[u8], asset: &str) -> Result, String> { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive)) + .map_err(|error| format!("could not read release archive: {error}"))?; + let mut file = archive + .by_name(asset) + .map_err(|error| format!("could not find {asset} in the release archive: {error}"))?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_FONT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("could not read {asset} from the release archive: {error}"))?; + if bytes.len() > MAX_FONT_BYTES { + return Err(format!("{asset} exceeds the size limit")); + } + Ok(bytes) +} + +fn typeface_slug(typeface: Typeface) -> &'static str { + match typeface { + Typeface::System => "system", + Typeface::GeistMono => "geist_mono", + Typeface::JetBrainsMono => "jetbrains_mono", + Typeface::IaWriterMono => "ia_writer_mono", + Typeface::FiraCode => "fira_code", + Typeface::SourceCodePro => "source_code_pro", + } +} + +fn weight_slug(weight: FontWeight) -> &'static str { + match weight { + FontWeight::Regular => "regular", + FontWeight::Bold => "bold", + _ => panic!("manifest asset weight must be Regular or Bold"), + } +} + +/// The fixed, verified manifest. Every asset is anchored to an immutable +/// release or commit; hashes were captured from the canonical upstream files. +/// Four faces are served as raw TTF files at their pinned tag or commit; Fira +/// Code publishes no TTF in its release tree, so it is pinned to the official +/// release archive and its assets are extracted from it. +#[rustfmt::skip] +const MANIFEST: [ManifestEntry; 5] = [ + ManifestEntry { + typeface: Typeface::GeistMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Copyright © 2023 Vercel with basement.studio", + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/LICENSE.txt", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/fonts/GeistMono/ttf/GeistMono-Regular.ttf", + pin: "v1.7.2", + archive_asset: None, + sha256: parse_digest("5a0de4b3d54ab272f76a1d8c84b7fb24c67bbec6591d5300e61c7bc10094b6c8"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/vercel/geist-font/v1.7.2/fonts/GeistMono/ttf/GeistMono-Bold.ttf", + pin: "v1.7.2", + archive_asset: None, + sha256: parse_digest("325de0913317e63c9b0084d3f571c6d1c9279e776e9260c62be19e1c12d1be3c"), + }, + }, + ManifestEntry { + typeface: Typeface::JetBrainsMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "JetBrains Mono Project Authors", + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/OFL.txt", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/fonts/ttf/JetBrainsMono-Regular.ttf", + pin: "v2.304", + archive_asset: None, + sha256: parse_digest("a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/JetBrains/JetBrainsMono/v2.304/fonts/ttf/JetBrainsMono-Bold.ttf", + pin: "v2.304", + archive_asset: None, + sha256: parse_digest("5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb"), + }, + }, + ManifestEntry { + typeface: Typeface::IaWriterMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Information Architects Inc. and IBM Corp. — Reserved Font Names: iA Writer, Plex", + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/LICENSE.md", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/Static/iAWriterMonoS-Regular.ttf", + pin: "f32c04c3058a75d7ce28919ce70fe8800817491b", + archive_asset: None, + sha256: parse_digest("929605302a57250e712908cb5f6e1ce80c7d0accd5fd2555345f29a5e8d4e30b"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/iaolo/iA-Fonts/f32c04c3058a75d7ce28919ce70fe8800817491b/iA%20Writer%20Mono/Static/iAWriterMonoS-Bold.ttf", + pin: "f32c04c3058a75d7ce28919ce70fe8800817491b", + archive_asset: None, + sha256: parse_digest("76aa5b5b4f9091a9c686a2a6fe5ff3495bb791994d7079857e5b24ae98063743"), + }, + }, + ManifestEntry { + typeface: Typeface::FiraCode, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Fira Code Project Authors", + source: "https://raw.githubusercontent.com/tonsky/FiraCode/6.2/LICENSE", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://github.com/tonsky/FiraCode/releases/download/6.2/Fira_Code_v6.2.zip", + pin: "6.2", + archive_asset: Some("ttf/FiraCode-Regular.ttf"), + sha256: parse_digest("5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://github.com/tonsky/FiraCode/releases/download/6.2/Fira_Code_v6.2.zip", + pin: "6.2", + archive_asset: Some("ttf/FiraCode-Bold.ttf"), + sha256: parse_digest("41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be"), + }, + }, + ManifestEntry { + typeface: Typeface::SourceCodePro, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Copyright © 2023 Adobe — Reserved Font Name: Source", + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/LICENSE.md", + }, + regular: FontAsset { + weight: FontWeight::Regular, + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/TTF/SourceCodePro-Regular.ttf", + pin: "2.042R-u/1.062R-i/1.026R-vf", + archive_asset: None, + sha256: parse_digest("74bd80d3e42a08517cd7e1108ba3d86f2da29ac0f3065be95e0357956ab9db37"), + }, + bold: FontAsset { + weight: FontWeight::Bold, + source: "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/2.042R-u%2F1.062R-i%2F1.026R-vf/TTF/SourceCodePro-Bold.ttf", + pin: "2.042R-u/1.062R-i/1.026R-vf", + archive_asset: None, + sha256: parse_digest("b2095e0d657e6d28dc32444a9dacabab0c9241d0bf39d96371756cc9bdbc3a5f"), + }, + }, +]; + +/// Parses a 64-character lowercase hex digest into 32 bytes at compile time. +const fn parse_digest(hex: &str) -> Sha256Digest { + let bytes = hex.as_bytes(); + let mut digest = [0u8; 32]; + let mut index = 0; + while index < 32 { + let high = hex_value(bytes[index * 2]); + let low = hex_value(bytes[index * 2 + 1]); + digest[index] = (high << 4) | low; + index += 1; + } + digest +} + +const fn hex_value(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("manifest digest must be lowercase hex"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::temporary_directory; + use std::cell::Cell; + use std::collections::HashMap; + use std::fs; + use std::io::Write; + use std::rc::Rc; + + const REGULAR_BYTES: &[u8] = b"fake geist regular bytes"; + const BOLD_BYTES: &[u8] = b"fake geist bold bytes"; + + fn test_digest(bytes: &[u8]) -> Sha256Digest { + Sha256::digest(bytes).into() + } + + fn raw_asset(weight: FontWeight, name: &str) -> FontAsset { + FontAsset { + weight, + source: format!("https://example.invalid/{name}").leak(), + pin: "v1.7.2", + archive_asset: None, + sha256: test_digest(if weight == FontWeight::Regular { + REGULAR_BYTES + } else { + BOLD_BYTES + }), + } + } + + fn test_entry() -> ManifestEntry { + ManifestEntry { + typeface: Typeface::GeistMono, + licence: LicenceRecord { + name: "OFL-1.1", + notice: "Test Authors", + source: "https://example.invalid/OFL.txt", + }, + regular: raw_asset(FontWeight::Regular, "GeistMono-Regular.ttf"), + bold: raw_asset(FontWeight::Bold, "GeistMono-Bold.ttf"), + } + } + + #[derive(Clone)] + struct FakeFetcher { + responses: Rc, String>>>, + calls: Rc>, + } + + impl FakeFetcher { + fn new(responses: HashMap, String>>) -> Self { + Self { + responses: Rc::new(responses), + calls: Rc::new(Cell::new(0)), + } + } + + fn calls(&self) -> usize { + self.calls.get() + } + } + + impl Fetcher for FakeFetcher { + fn fetch(&self, url: &str) -> Result, String> { + self.calls.set(self.calls.get() + 1); + self.responses + .get(url) + .cloned() + .unwrap_or_else(|| Err(format!("unexpected fetch of {url}"))) + } + } + + #[test] + fn manifest_pins_the_five_curated_editor_typefaces() { + let faces: Vec = manifest().iter().map(|entry| entry.typeface).collect(); + assert_eq!( + faces, + vec![ + Typeface::GeistMono, + Typeface::JetBrainsMono, + Typeface::IaWriterMono, + Typeface::FiraCode, + Typeface::SourceCodePro, + ] + ); + assert_eq!(entry_for(Typeface::System), None); + } + + #[test] + fn manifest_pins_immutable_releases_or_commits() { + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + assert!(!asset.pin.is_empty()); + assert!( + !matches!(asset.pin, "master" | "main" | "latest" | "develop" | "HEAD"), + "{:?} must not follow a mutable branch", + entry.typeface + ); + assert!(asset.source.starts_with("https://")); + } + } + // The untagged iA source is anchored to a full commit hash. + let ia = entry_for(Typeface::IaWriterMono).unwrap(); + for asset in [ia.regular, ia.bold] { + assert_eq!(asset.pin.len(), 40); + assert!(asset.pin.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[test] + fn manifest_assets_are_static_ttf_with_real_weights() { + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + let name = asset + .archive_asset + .unwrap_or_else(|| asset.source.rsplit('/').next().unwrap()); + assert!(name.ends_with(".ttf"), "asset {name} is not a TTF"); + } + assert_eq!(entry.regular.weight, FontWeight::Regular); + assert_eq!(entry.bold.weight, FontWeight::Bold); + } + } + + #[test] + fn manifest_hashes_are_well_formed_and_distinct() { + let mut digests = std::collections::HashSet::new(); + for entry in manifest() { + for asset in [entry.regular, entry.bold] { + assert_ne!(asset.sha256, [0u8; 32]); + assert!(digests.insert(asset.sha256), "duplicate digest in manifest"); + } + } + } + + #[test] + fn manifest_records_a_licence_for_every_typeface() { + for entry in manifest() { + assert_eq!(entry.licence.name, "OFL-1.1"); + assert!(!entry.licence.notice.is_empty()); + assert!(entry.licence.source.starts_with("https://")); + } + } + + #[test] + fn ensure_fetches_verifies_and_caches_once() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + + let assets = delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(assets.regular, REGULAR_BYTES); + assert_eq!(assets.bold, BOLD_BYTES); + assert_eq!(fetcher.calls(), 2); + + // The second ensure reads the verified cache: no new fetches. + assert_eq!(delivery.ensure(Typeface::GeistMono).unwrap(), assets); + assert_eq!(fetcher.calls(), 2); + + // The cache holds exactly the verified TTF files, with no leftovers. + let mut files: Vec = fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + files.sort(); + assert_eq!(files, vec!["geist_mono-bold.ttf", "geist_mono-regular.ttf"]); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn corrupt_download_is_rejected_and_not_cached() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(b"corrupted".to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(b"corrupted".to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Invalid(_))); + assert!(fs::read_dir(&directory).unwrap().next().is_none()); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn fetch_failure_is_visible_and_retryable() { + let directory = temporary_directory("font-delivery"); + let failing = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Err("offline".to_owned()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Err("offline".to_owned()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, failing, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Fetch(_))); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + + // The writer stays on the current Typeface; retry recovers offline. + let recovered = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let delivery = FontDelivery::at(&directory, recovered, &entries); + assert!(delivery.ensure(Typeface::GeistMono).is_ok()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cleared_or_invalid_cache_is_refetched_and_corrected() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Ok(BOLD_BYTES.to_vec()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 2); + + // Cache cleanup is harmless: the next ensure refetches verified bytes. + delivery.clear_cache(Typeface::GeistMono).unwrap(); + assert!(delivery.cached(Typeface::GeistMono).is_none()); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 4); + + // A stale or corrupted cache entry is treated as missing and repaired. + fs::write(directory.join("geist_mono-regular.ttf"), b"stale bytes").unwrap(); + delivery.ensure(Typeface::GeistMono).unwrap(); + assert_eq!(fetcher.calls(), 5); + assert_eq!( + fs::read(directory.join("geist_mono-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn missing_cache_reports_cleanly() { + let directory = temporary_directory("font-delivery"); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, FakeFetcher::new(HashMap::new()), &entries); + + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn archive_assets_share_one_download() { + let directory = temporary_directory("font-delivery"); + let archive = zip_with(&[ + ("ttf/FiraCode-Regular.ttf", REGULAR_BYTES), + ("ttf/FiraCode-Bold.ttf", BOLD_BYTES), + ]); + let mut entry = test_entry(); + entry.typeface = Typeface::FiraCode; + let zip_url = "https://example.invalid/FiraCode.zip"; + entry.regular.source = zip_url; + entry.bold.source = zip_url; + entry.regular.archive_asset = Some("ttf/FiraCode-Regular.ttf"); + entry.bold.archive_asset = Some("ttf/FiraCode-Bold.ttf"); + let fetcher = FakeFetcher::new(HashMap::from([(zip_url.to_owned(), Ok(archive))])); + let entries = [entry]; + let delivery = FontDelivery::at(&directory, fetcher.clone(), &entries); + + let assets = delivery.ensure(Typeface::FiraCode).unwrap(); + assert_eq!(assets.regular, REGULAR_BYTES); + assert_eq!(assets.bold, BOLD_BYTES); + assert_eq!( + fetcher.calls(), + 1, + "the shared archive must be downloaded once" + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn missing_archive_entry_fails_cleanly_and_keeps_the_successful_asset() { + let directory = temporary_directory("font-delivery"); + let archive = zip_with(&[("ttf/FiraCode-Regular.ttf", REGULAR_BYTES)]); + let mut entry = test_entry(); + entry.typeface = Typeface::FiraCode; + let zip_url = "https://example.invalid/FiraCode.zip"; + entry.regular.source = zip_url; + entry.bold.source = zip_url; + entry.regular.archive_asset = Some("ttf/FiraCode-Regular.ttf"); + entry.bold.archive_asset = Some("ttf/FiraCode-Bold.ttf"); + let fetcher = FakeFetcher::new(HashMap::from([(zip_url.to_owned(), Ok(archive))])); + let entries = [entry]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + // Only the Regular asset is present in the archive; Bold fails its + // extraction, so the Regular bytes are still extracted and cached. + let error = delivery.ensure(Typeface::FiraCode).unwrap_err(); + assert!(matches!(error, DeliveryError::Invalid(_))); + assert_eq!( + fs::read(directory.join("fira_code-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cache_lives_under_the_platform_cache_directory() { + let expected = dirs::cache_dir().map(|directory| { + directory + .join(crate::preferences::CONFIG_DIR_NAME) + .join("fonts") + }); + assert_eq!(default_cache_directory(), expected); + } + + #[test] + fn system_monospace_is_not_downloadable() { + let directory = temporary_directory("font-delivery"); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, FakeFetcher::new(HashMap::new()), &entries); + + let error = delivery.ensure(Typeface::System).unwrap_err(); + assert_eq!(error, DeliveryError::NotDownloadable); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn partial_failure_keeps_the_successful_asset_cached() { + let directory = temporary_directory("font-delivery"); + let fetcher = FakeFetcher::new(HashMap::from([ + ( + "https://example.invalid/GeistMono-Regular.ttf".to_owned(), + Ok(REGULAR_BYTES.to_vec()), + ), + ( + "https://example.invalid/GeistMono-Bold.ttf".to_owned(), + Err("offline".to_owned()), + ), + ])); + let entries = [test_entry()]; + let delivery = FontDelivery::at(&directory, fetcher, &entries); + + let error = delivery.ensure(Typeface::GeistMono).unwrap_err(); + assert!(matches!(error, DeliveryError::Fetch(_))); + // The verified Regular asset is cached; the full set is not yet ready. + assert_eq!( + fs::read(directory.join("geist_mono-regular.ttf")).unwrap(), + REGULAR_BYTES + ); + assert_eq!(delivery.cached(Typeface::GeistMono), None); + fs::remove_dir_all(directory).unwrap(); + } + + /// Dev-time verification of the manifest pins: downloads every pinned + /// asset through the production fetcher and checks its SHA-256. Ignored + /// by default because it contacts the public font hosts; run explicitly + /// after changing the manifest. + #[test] + #[ignore] + fn dev_verify_manifest_pins_against_canonical_sources() { + let directory = temporary_directory("font-delivery-verify"); + let delivery = FontDelivery::at(&directory, HttpFetcher, manifest()); + for entry in manifest() { + delivery + .ensure(entry.typeface) + .unwrap_or_else(|error| panic!("{:?}: {error}", entry.typeface)); + delivery.clear_cache(entry.typeface).unwrap(); + } + fs::remove_dir_all(directory).unwrap(); + } + + fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + for (name, bytes) in entries { + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap().into_inner() + } +} diff --git a/src/fonts.rs b/src/fonts.rs index e69bb38..0804fe3 100644 --- a/src/fonts.rs +++ b/src/fonts.rs @@ -22,6 +22,27 @@ //! `docs/font-delivery-research.md` until Phase 4.5 hardens the adapter. //! Nothing in the application calls this yet. +use floem::text::FONT_SYSTEM; + +/// Family name of the bundled Interface typeface (iA Writer Quattro variable +/// font). Weight variation on the `wght` axis is verified by the +/// `bundled_interface_typeface_shapes_distinct_weights` test. +pub const INTERFACE_TYPEFACE_FAMILY: &str = "iA Writer Quattro V"; + +const INTERFACE_TYPEFACE: &[u8] = include_bytes!("../assets/fonts/iAWriterQuattroV.ttf"); + +/// Register the bundled Interface typeface into the process font database so +/// interface text can use it from the first frame. +/// +/// Failure is non-fatal: interface text falls back to the renderer default. +/// The bundled licence notice ships at `assets/fonts/iA-Writer-Quattro-LICENSE.md`. +pub fn register_bundled_interface_typeface() { + FONT_SYSTEM + .lock() + .db_mut() + .load_font_data(INTERFACE_TYPEFACE.to_vec()); +} + /// Resolve the monospaced Typeface the operating system has configured for /// ordinary local applications. /// @@ -74,6 +95,9 @@ mod tests { use floem::text::{Attrs, AttrsList, Family, FamilyOwned, TextLayout, FONT_SYSTEM}; #[cfg(target_os = "linux")] use std::collections::HashMap; + // Interface typeface consts are used by the two cross-platform tests + // (bundled bytes register everywhere), so they are imported ungated. + use super::{INTERFACE_TYPEFACE, INTERFACE_TYPEFACE_FAMILY}; /// Monospace glyph sample wide enough to expose metric differences. const SAMPLE: &str = "iiiiiiiiii"; @@ -388,4 +412,68 @@ mod tests { "shaping must follow the explicitly named family" ); } + + #[test] + fn bundled_interface_typeface_registers_and_resolves() { + use floem::text::{fontdb, Family, Stretch, Style, Weight}; + let mut fresh = fontdb::Database::new(); + fresh.load_font_data(INTERFACE_TYPEFACE.to_vec()); + + let resolved = fresh.query(&fontdb::Query { + families: &[Family::Name(INTERFACE_TYPEFACE_FAMILY)], + weight: Weight::NORMAL, + stretch: Stretch::Normal, + style: Style::Normal, + }); + assert!( + resolved.is_some(), + "bundled Interface typeface must resolve from its bytes alone" + ); + let face = fresh + .face(resolved.expect("checked above")) + .expect("face exists"); + let family = face + .families + .first() + .map(|(name, _)| name.clone()) + .expect("family name"); + assert_eq!( + family, INTERFACE_TYPEFACE_FAMILY, + "resolved family must match the Interface typeface constant" + ); + } + + /// iA's static Regular/Bold faces both report the same fontdb weight, so + /// the pair cannot express interface bold. The variable font's `wght` + /// axis is verified here by shaping: bold must produce different metrics + /// than normal for the same family. + #[test] + fn bundled_interface_typeface_shapes_distinct_weights() { + use floem::text::{Attrs, AttrsList, FamilyOwned, TextLayout, Weight}; + FONT_SYSTEM + .lock() + .db_mut() + .load_font_data(INTERFACE_TYPEFACE.to_vec()); + + let fam = [FamilyOwned::Name(INTERFACE_TYPEFACE_FAMILY.to_owned())]; + let width = |weight| { + let mut layout = TextLayout::new(); + layout.set_text( + "Hello World", + AttrsList::new(Attrs::new().family(&fam).weight(weight).font_size(16.0)), + ); + layout.size().width + }; + let normal = width(Weight::NORMAL); + let bold = width(Weight::BOLD); + eprintln!( + "EVIDENCE interface-weights normal={normal} bold={bold} family={INTERFACE_TYPEFACE_FAMILY}" + ); + // 0.1 px is far beyond shaping noise for a 16px proportional sample + // and small enough to catch any regression where bold stops applying. + assert!( + (normal - bold).abs() > 0.1, + "interface bold must shape with heavier glyphs than normal" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 47d57bf..c0dae29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub(crate) mod atomic; pub mod autosave; pub mod document_store; +pub mod font_delivery; pub mod persistence; pub mod preferences; pub mod selector; diff --git a/src/main.rs b/src/main.rs index 1acf672..98219f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod fonts; mod shell; fn main() { + fonts::register_bundled_interface_typeface(); let smoke_test = std::env::args().any(|arg| arg == "--smoke-test"); if smoke_test { floem::launch(shell::smoke_test_view); diff --git a/src/shell.rs b/src/shell.rs index 5768ba2..b6d4e86 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -27,9 +27,9 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::thread; use crate::editor::{ - active_palette, replace_editor_content, start_background_save, switch_to_new_draft, - sync_autosave_feedback, writing_surface, LetterSpacing, SurfaceHandles, ThemePalette, - WritingWidth, + active_palette, interface_label, replace_editor_content, start_background_save, + switch_to_new_draft, sync_autosave_feedback, writing_surface, LetterSpacing, SurfaceHandles, + ThemePalette, WritingWidth, }; type SharedPersistence = Rc>; @@ -77,20 +77,20 @@ fn selector_card( let filename = entry.display_filename().map(ToOwned::to_owned); let active = entry.is_active(); container(v_stack(( - static_label(title).style(move |s| { + interface_label(title).style(move |s| { s.font_size(17.0) .font_bold() .height(42.0) .text_clip() .color(palette.text) }), - static_label(preview).style(move |s| { + interface_label(preview).style(move |s| { s.font_size(13.0) .height(38.0) .text_clip() .color(palette.text) }), - static_label(if selected && active { + interface_label(if selected && active { "Selected · Active" } else if selected { "Selected" @@ -101,8 +101,8 @@ fn selector_card( }) .style(move |s| s.font_size(11.0).color(palette.selection)), h_stack(( - static_label(date).style(move |s| s.font_size(11.0).color(palette.text)), - static_label(filename.unwrap_or_default()) + interface_label(date).style(move |s| s.font_size(11.0).color(palette.text)), + interface_label(filename.unwrap_or_default()) .style(move |s| s.font_size(11.0).color(palette.text)), )) .style(|s| s.justify_between().width_pct(100.0)), @@ -145,23 +145,23 @@ fn selector_content( let retry_for_error_view = Rc::clone(&retry); let error_view = match selector_error { Some(error) => h_stack(( - static_label(error).style(move |s| s.font_size(12.0).color(palette.selection)), - button("Retry").action(move || retry_for_error_view()), + interface_label(error).style(move |s| s.font_size(12.0).color(palette.selection)), + button(interface_label("Retry")).action(move || retry_for_error_view()), )) .into_any(), None => floem::views::empty().into_any(), }; match state { CatalogState::Loading => v_stack(( - static_label("Loading Documents…").style(move |s| s.color(palette.text)), - static_label("Scanning saved Files in background…") + interface_label("Loading Documents…").style(move |s| s.color(palette.text)), + interface_label("Scanning saved Files in background…") .style(move |s| s.font_size(13.0).color(palette.text)), error_view, )), CatalogState::Error(error) => v_stack(( - static_label("Could not load Documents").style(move |s| s.color(palette.text)), - static_label(error).style(move |s| s.font_size(13.0).color(palette.text)), - button("Retry") + interface_label("Could not load Documents").style(move |s| s.color(palette.text)), + interface_label(error).style(move |s| s.font_size(13.0).color(palette.text)), + button(interface_label("Retry")) .style(move |s| { s.padding(8.0) .background(palette.selection) @@ -185,8 +185,8 @@ fn selector_content( "" }; v_stack(( - static_label(warning).style(move |s| s.font_size(12.0).color(palette.selection)), - static_label(empty).style(move |s| s.font_size(15.0).color(palette.text)), + interface_label(warning).style(move |s| s.font_size(12.0).color(palette.selection)), + interface_label(empty).style(move |s| s.font_size(15.0).color(palette.text)), error_view, scroll(v_stack_from_iter(entries.chunks(columns.max(1)).map( |row| { @@ -225,6 +225,7 @@ fn build_selector_modal( .padding(8.0) .border(1.0) .border_color(palette.border) + .font_family(crate::fonts::INTERFACE_TYPEFACE_FAMILY.to_owned()) }); let search_id = search.id(); create_effect(move |_| { @@ -270,11 +271,11 @@ fn build_selector_modal( }, ); let view = container(v_stack(( - static_label("Open document") + interface_label("Open document") .style(move |s| s.font_size(20.0).color(palette.text).padding_bottom(8.0)), search, content, - button("New Draft") + button(interface_label("New Draft")) .style(move |s| { s.margin_top(12.0) .padding_horiz(16.0)