qframe/icons/nerd_font/mod.rs
1//! Installing the Nerd Font symbols, so a terminal can draw the Nerd glyphs of the icon sets.
2//!
3//! Only "Symbols Nerd Font Mono" is installed: a font of symbols and nothing else. The user keeps
4//! the font their terminal draws text with; a terminal that takes the glyphs its font lacks from
5//! other installed fonts (through fontconfig on Linux, the system's font fallback elsewhere)
6//! finds them in this one. A terminal that does not do that still shows boxes or question marks,
7//! which is why an application shows [`GlyphSample`](super::GlyphSample)s after the install and
8//! says what to do then, see [`after_install_text`].
9//!
10//! The archive comes from a fixed Nerd Fonts release, [`RELEASE`], and its SHA-256 is written
11//! here; a download that does not match is deleted. The system's own programs do the work, so
12//! the framework carries no network or archive code: `curl` downloads, `sha256sum` or
13//! `shasum -a 256` (`certutil` on Windows) checks, and `tar` unpacks. Everything runs in the
14//! background as a [`Task`] that reports [`Progress`].
15//!
16//! The font goes into the user's own font folder, see [`target_dir`], so nothing needs an
17//! administrator. Undoing the install is deleting one path, which [`Progress::Done`] names.
18//!
19//! ```no_run
20//! use qframe::icons::nerd_font::{self, Progress};
21//! use qframe::runtime::Command;
22//!
23//! enum Msg {
24//! Install,
25//! Font(Progress),
26//! }
27//!
28//! fn update(msg: Msg) -> Command<Msg> {
29//! match msg {
30//! Msg::Install if !nerd_font::installed() => nerd_font::install(Msg::Font),
31//! // Show `progress.text()` while it runs; after `Progress::Done`, show sample
32//! // glyphs and `nerd_font::after_install_text()`.
33//! _ => Command::none(),
34//! }
35//! }
36//! ```
37
38mod steps;
39
40use std::path::{Path, PathBuf};
41
42use super::detect::{contains_nerd_font, default_font_dirs};
43use crate::i18n::translate_active;
44use crate::runtime::{Command, Task};
45
46/// The Nerd Fonts release the font is installed from.
47pub const RELEASE: &str = "v3.5.1";
48
49/// The family name of the installed font, as terminals and font pickers list it.
50pub const FAMILY: &str = "Symbols Nerd Font Mono";
51
52/// The one file taken from the archive and installed.
53pub const FONT_FILE: &str = "SymbolsNerdFontMono-Regular.ttf";
54
55/// The folder of its own the font goes into on Linux, inside the user's font folder.
56const LINUX_FOLDER: &str = "QuvytaNerdFont";
57
58/// Where the release archives are published.
59const RELEASE_URL: &str = "https://github.com/ryanoasis/nerd-fonts/releases/download";
60
61/// The `.tar.xz` archive: GNU tar on Linux and bsdtar on macOS open it.
62const TAR_XZ: (&str, &str) =
63 ("NerdFontsSymbolsOnly.tar.xz", "01172f37db8543edb102e5cb5c64101c9f4686630804d49b419aa07b23a69996");
64
65/// The `.zip` archive: the `tar` of Windows opens zip files but not every build opens xz.
66const ZIP: (&str, &str) =
67 ("NerdFontsSymbolsOnly.zip", "fdca3682534f6f65e1ccb2345b0362ccf67d9b8eca7c8025330946e93e2473bc");
68
69/// The operating system, for the choices that differ: folders, archive, checksum tool and
70/// registration. Unix systems other than macOS follow Linux.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Os {
73 Linux,
74 Mac,
75 Windows,
76}
77
78impl Os {
79 fn current() -> Self {
80 if cfg!(windows) {
81 Self::Windows
82 } else if cfg!(target_os = "macos") {
83 Self::Mac
84 } else {
85 Self::Linux
86 }
87 }
88}
89
90fn process_env(name: &str) -> Option<String> {
91 std::env::var(name).ok()
92}
93
94/// Whether a Nerd Font file lies in the font folders of this system: any file whose name
95/// contains "nerd", the way glyph detection looks. A font on disk is not proof that the terminal
96/// draws with it; the user's eye on a [`GlyphSample`](super::GlyphSample) is.
97#[must_use]
98pub fn installed() -> bool {
99 installed_in(&default_font_dirs(process_env))
100}
101
102/// Whether a Nerd Font file lies in one of `dirs`, looking up to three folders deep.
103#[must_use]
104pub fn installed_in(dirs: &[PathBuf]) -> bool {
105 dirs.iter().any(|dir| contains_nerd_font(dir, 3))
106}
107
108/// Where [`install`] puts the font, or `None` when the system names no folder for the user.
109///
110/// - Linux and other Unix systems: `$XDG_DATA_HOME/fonts/QuvytaNerdFont`, or
111/// `~/.local/share/fonts/QuvytaNerdFont` when the variable is unset or not absolute.
112/// - macOS: `~/Library/Fonts`.
113/// - Windows: `%LOCALAPPDATA%\Microsoft\Windows\Fonts`.
114///
115/// Asking does not create the folder.
116#[must_use]
117pub fn target_dir() -> Option<PathBuf> {
118 target_dir_for(Os::current(), process_env)
119}
120
121fn target_dir_for(os: Os, env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
122 let absolute = |name: &str| env(name).map(PathBuf::from).filter(|path| path.is_absolute());
123 match os {
124 Os::Linux => absolute("XDG_DATA_HOME")
125 .or_else(|| absolute("HOME").map(|home| home.join(".local").join("share")))
126 .map(|data| data.join("fonts").join(LINUX_FOLDER)),
127 Os::Mac => absolute("HOME").map(|home| home.join("Library").join("Fonts")),
128 Os::Windows => absolute("LOCALAPPDATA").map(|local| local.join("Microsoft").join("Windows").join("Fonts")),
129 }
130}
131
132/// An archive to install from: where to download it and the SHA-256 it must have.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Archive {
135 url: String,
136 sha256: String,
137}
138
139impl Archive {
140 /// The archive of [`RELEASE`] that this system's `tar` can open: `.tar.xz` on Linux and
141 /// macOS, `.zip` on Windows.
142 #[must_use]
143 pub fn release() -> Self {
144 release_for(Os::current())
145 }
146
147 /// An archive at `url`, which `curl` understands (`https://` or `file://`), expected to have
148 /// the SHA-256 `sha256`, in hexadecimal. The archive must hold [`FONT_FILE`] at its top.
149 #[must_use]
150 pub fn new(url: impl Into<String>, sha256: &str) -> Self {
151 Self { url: url.into(), sha256: sha256.trim().to_ascii_lowercase() }
152 }
153
154 /// Where the archive is downloaded from.
155 #[must_use]
156 pub fn url(&self) -> &str {
157 &self.url
158 }
159
160 /// The SHA-256 the download must have, in lowercase hexadecimal.
161 #[must_use]
162 pub fn sha256(&self) -> &str {
163 &self.sha256
164 }
165}
166
167fn release_for(os: Os) -> Archive {
168 let (name, sha256) = if os == Os::Windows { ZIP } else { TAR_XZ };
169 Archive::new(format!("{RELEASE_URL}/{RELEASE}/{name}"), sha256)
170}
171
172/// How to install the font: the archive, the folder and whether the system is told.
173///
174/// [`Install::new`] is what [`install`] runs. The other methods are for installing elsewhere,
175/// such as into a test's temporary folder.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct Install {
178 archive: Archive,
179 target: Option<PathBuf>,
180 register: bool,
181 /// Where the download folder is made: the system's temporary folder.
182 staging: PathBuf,
183}
184
185impl Default for Install {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191impl Install {
192 /// The release archive into [`target_dir`], telling the system about the new font.
193 #[must_use]
194 pub fn new() -> Self {
195 Self { archive: Archive::release(), target: target_dir(), register: true, staging: std::env::temp_dir() }
196 }
197
198 /// Installs from `archive` instead of the release.
199 #[must_use]
200 pub fn archive(mut self, archive: Archive) -> Self {
201 self.archive = archive;
202 self
203 }
204
205 /// Installs into `dir` instead of [`target_dir`]. The folder is created when missing.
206 #[must_use]
207 pub fn target(mut self, dir: impl Into<PathBuf>) -> Self {
208 self.target = Some(dir.into());
209 self
210 }
211
212 /// Whether the system is told about the new font, on by default: on Linux `fc-cache -f`
213 /// reads the folder (skipped when fontconfig is not installed), on Windows the font is
214 /// entered under `HKCU\Software\Microsoft\Windows NT\CurrentVersion\Fonts`, and macOS needs
215 /// nothing. Turn it off for a folder the system does not look at.
216 #[must_use]
217 pub fn register(mut self, register: bool) -> Self {
218 self.register = register;
219 self
220 }
221
222 /// The folder the font goes into, if there is one.
223 #[must_use]
224 pub fn target_dir(&self) -> Option<&Path> {
225 self.target.as_deref()
226 }
227
228 /// Downloads, verifies and installs, on the calling thread, handing every step to
229 /// `on_progress`; the last one is [`Progress::Done`] or [`Progress::Failed`]. Returns the
230 /// path [`Progress::Done`] names.
231 ///
232 /// `cancel` is asked between steps and while a program runs; when it turns true the program
233 /// is stopped and nothing more is written. The download is kept in a folder of its own under
234 /// the system's temporary folder and removed at the end, whatever the outcome.
235 ///
236 /// # Errors
237 ///
238 /// Every way the install can fail is an [`InstallError`], also handed to `on_progress` as
239 /// [`Progress::Failed`] unless the install was cancelled.
240 pub fn run(
241 self,
242 cancel: &dyn Fn() -> bool,
243 on_progress: &mut dyn FnMut(Progress),
244 ) -> Result<PathBuf, InstallError> {
245 let result = steps::run(&self, Os::current(), cancel, on_progress);
246 match &result {
247 Ok(path) => on_progress(Progress::Done { path: path.clone() }),
248 Err(InstallError::Cancelled) => {}
249 Err(error) => on_progress(Progress::Failed(error.clone())),
250 }
251 result
252 }
253
254 /// The install as a background [`Task`]. Every step arrives as `on_progress(step)`, and the
255 /// task's own result is `on_progress(Progress::Done { .. })`. A failure arrives as
256 /// [`Progress::Failed`] and also fails the task with the same text, so a
257 /// [`TaskList`](crate::widgets::TaskList) shows it; the task's label and notes are
258 /// translated when this is called, so call it where the application's language is active,
259 /// such as in `update`.
260 #[must_use]
261 pub fn task<Msg: Send + 'static>(self, on_progress: impl Fn(Progress) -> Msg + Send + Sync + 'static) -> Task<Msg> {
262 let texts = steps::Texts::capture();
263 Task::new(translate_active("quvyta.nerd-font.task", &[]), move |cx| {
264 let mut noted = false;
265 let mut report = |progress: Progress| {
266 match &progress {
267 Progress::Downloading { fraction } => {
268 if !noted {
269 noted = true;
270 cx.note(texts.get("quvyta.nerd-font.downloading", ""));
271 }
272 if let Some(fraction) = fraction {
273 // The download is most of the time the install takes.
274 cx.progress(fraction * 0.9);
275 }
276 }
277 Progress::Verifying => cx.note(texts.get("quvyta.nerd-font.verifying", "")),
278 Progress::Installing => cx.note(texts.get("quvyta.nerd-font.installing", "")),
279 Progress::Done { .. } | Progress::Failed(_) => return,
280 }
281 cx.send(on_progress(progress));
282 };
283 let result = self.run(&|| cx.is_cancelled(), &mut report);
284 match result {
285 Ok(path) => {
286 cx.progress(1.0);
287 Ok(on_progress(Progress::Done { path }))
288 }
289 Err(error) => {
290 let (key, detail) = error.key_and_detail();
291 let reason = texts.get(key, detail);
292 if error != InstallError::Cancelled {
293 cx.send(on_progress(Progress::Failed(error)));
294 }
295 Err(reason)
296 }
297 }
298 })
299 }
300}
301
302/// Installs the font in the background with [`Install::new`], delivering every step as
303/// `on_progress(step)`. See [`Install::task`] for a task to cancel or to follow in a task list.
304#[must_use]
305pub fn install<Msg: Send + 'static>(on_progress: impl Fn(Progress) -> Msg + Send + Sync + 'static) -> Command<Msg> {
306 Command::task(Install::new().task(on_progress))
307}
308
309/// One step of an install.
310#[derive(Debug, Clone, PartialEq)]
311pub enum Progress {
312 /// The archive is downloading; `fraction` from 0 to 1 once `curl` knows the size.
313 Downloading {
314 /// The downloaded share, when known.
315 fraction: Option<f32>,
316 },
317 /// The download is checked against its SHA-256.
318 Verifying,
319 /// The font is unpacked, copied into its folder and registered.
320 Installing,
321 /// The font is installed. `path` is what to delete to undo it: the folder of its own on
322 /// Linux, the font file where the folder is shared with other fonts (macOS, Windows; there
323 /// the entry under `HKCU\…\Fonts` stays and names a missing file, which Windows ignores).
324 Done {
325 /// What to delete to undo the install.
326 path: PathBuf,
327 },
328 /// The install failed; nothing was left half copied.
329 Failed(InstallError),
330}
331
332impl Progress {
333 /// The step in the active language, such as "Checking the download".
334 #[must_use]
335 pub fn text(&self) -> String {
336 match self {
337 Self::Downloading { fraction: None } => translate_active("quvyta.nerd-font.downloading", &[]),
338 Self::Downloading { fraction: Some(fraction) } => {
339 // A percentage of 0 to 100 always fits.
340 #[allow(clippy::cast_possible_truncation)]
341 let percent = (fraction.clamp(0.0, 1.0) * 100.0).round() as i32;
342 translate_active("quvyta.nerd-font.downloading-share", &[("percent", percent.into())])
343 }
344 Self::Verifying => translate_active("quvyta.nerd-font.verifying", &[]),
345 Self::Installing => translate_active("quvyta.nerd-font.installing", &[]),
346 Self::Done { path } => {
347 translate_active("quvyta.nerd-font.done", &[("path", path.display().to_string().into())])
348 }
349 Self::Failed(error) => error.text(),
350 }
351 }
352}
353
354/// Why an install failed.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum InstallError {
357 /// The system names no font folder for the user, see [`target_dir`].
358 NoFolder,
359 /// A program the install needs is missing; its name.
360 MissingTool(String),
361 /// `curl` could not download the archive; its message.
362 Download(String),
363 /// The checksum program failed; its message.
364 Verify(String),
365 /// The download does not have the SHA-256 written in the source, so it was deleted.
366 Checksum {
367 /// The SHA-256 the archive should have.
368 expected: String,
369 /// The SHA-256 the download has.
370 actual: String,
371 },
372 /// `tar` could not unpack the font; its message.
373 Extract(String),
374 /// The font could not be copied into its folder; the system's message.
375 Copy(String),
376 /// The font is in its folder, but the system could not be told; the program's message.
377 Register(String),
378 /// The install was cancelled.
379 Cancelled,
380}
381
382impl InstallError {
383 /// The locale key of the message and what fills its `{detail}`.
384 fn key_and_detail(&self) -> (&'static str, &str) {
385 match self {
386 Self::NoFolder => ("quvyta.nerd-font.error-folder", ""),
387 Self::MissingTool(tool) => ("quvyta.nerd-font.error-tool", tool),
388 Self::Download(detail) => ("quvyta.nerd-font.error-download", detail),
389 Self::Verify(detail) => ("quvyta.nerd-font.error-verify", detail),
390 Self::Checksum { .. } => ("quvyta.nerd-font.error-checksum", ""),
391 Self::Extract(detail) => ("quvyta.nerd-font.error-extract", detail),
392 Self::Copy(detail) => ("quvyta.nerd-font.error-copy", detail),
393 Self::Register(detail) => ("quvyta.nerd-font.error-register", detail),
394 Self::Cancelled => ("quvyta.nerd-font.cancelled", ""),
395 }
396 }
397
398 /// The reason in the active language, with the program's own message where there is one.
399 #[must_use]
400 pub fn text(&self) -> String {
401 let (key, detail) = self.key_and_detail();
402 translate_active(key, &[("detail", detail.into())])
403 }
404}
405
406/// What to say once the font is installed, in the active language: look at the sample glyphs,
407/// and if they are still boxes, install JetBrainsMono Nerd Font and choose it in the terminal's
408/// settings. Show it beside [`GlyphSample`](super::GlyphSample)s drawn again after the install.
409#[must_use]
410pub fn after_install_text() -> String {
411 translate_active("quvyta.nerd-font.after-install", &[])
412}
413
414/// Whether a Nerd Font was found on this machine, as a sentence in the active language.
415#[must_use]
416pub fn status_text(installed: bool) -> String {
417 let key = if installed { "quvyta.nerd-font.found" } else { "quvyta.nerd-font.missing" };
418 translate_active(key, &[])
419}
420
421#[cfg(test)]
422mod tests;