T60: fallback fonts, which a user asked for after 0.1.6: font_fallbacks lists installed families the terminal tries in order for a character Font family lacks, through gpui's Font::fallbacks; each must load as the terminal draws it; Settings › Appearance lists them under Family, each a FontPicker with a remove button, then Add fallback

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 17:08:54 -04:00
1 parent 5cba2ebc72
commit cb32f00623
8 files changed
+194 -13

No files matched your search

+24
View File
@@ -60,6 +60,8 @@ pub struct Settings {
/// The theme while the system is dark; none keeps `theme`.
pub dark_theme: Option<String>,
pub font_family: String,
/// Families tried in order for a character Font family lacks.
pub font_fallbacks: Vec<String>,
pub font_size: f32,
pub line_height: f32,
pub ligatures: bool,
@@ -97,6 +99,7 @@ impl Default for Settings {
theme: themes::default_id().into(),
dark_theme: None,
font_family: "JetBrains Mono".into(),
font_fallbacks: Vec::new(),
font_size: 13.0,
line_height: 1.2,
ligatures: true,
@@ -155,6 +158,10 @@ impl Settings {
}
}
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
anyhow::ensure!(
!self.font_fallbacks.iter().any(String::is_empty),
"font_fallbacks holds an empty family"
);
if !self.shell.is_empty() {
program_path(&self.shell)?;
}
@@ -269,6 +276,23 @@ mod tests {
assert_eq!(error.to_string(), "panel_width is 1000, outside 160 to 960");
}
#[test]
fn fallback_fonts_are_none_unless_named_and_none_is_empty() {
let older: Settings = serde_json::from_str(r#"{"font_family":"Menlo"}"#).expect("parses");
assert!(older.font_fallbacks.is_empty(), "a file from before");
let named: Settings =
serde_json::from_str(r#"{"font_fallbacks":["Kaiti SC","Noto Sans CJK SC"]}"#)
.expect("parses");
assert_eq!(named.font_fallbacks, ["Kaiti SC", "Noto Sans CJK SC"]);
named.validate().expect("named families pass");
let empty = Settings {
font_fallbacks: vec!["Kaiti SC".into(), String::new()],
..Settings::default()
};
let error = empty.validate().expect_err("an empty family");
assert_eq!(error.to_string(), "font_fallbacks holds an empty family");
}
#[test]
fn a_shell_names_a_program_or_none() {
let shell = |shell: &str| Settings {
+17 -6
View File
@@ -24,7 +24,7 @@ impl SettingsStore {
let folder = dirs::config_dir().context("settings: the platform names no config folder")?;
let path = folder.join("ELYTerminal").join("settings.json");
let settings = load(&path)?;
loads(&settings.font_family, cx)
fonts(&settings, cx)
.with_context(|| format!("settings: {} holds a bad value", path.display()))?;
cx.set_global(Self { settings, path });
Ok(())
@@ -43,8 +43,10 @@ impl SettingsStore {
return Ok(());
}
next.validate()?;
if next.font_family != store.settings.font_family {
loads(&next.font_family, cx)?;
if (&next.font_family, &next.font_fallbacks)
!= (&store.settings.font_family, &store.settings.font_fallbacks)
{
fonts(&next, cx)?;
}
save(&store.path, &next)?;
cx.global_mut::<Self>().settings = next;
@@ -52,9 +54,18 @@ impl SettingsStore {
}
}
/// Checks the terminal can load `family`.
fn loads(family: &str, cx: &App) -> anyhow::Result<()> {
crate::term::load_font(&gpui::font(family.to_string()), cx.text_system()).map(|_| ())
/// Checks the terminal can load its family and each fallback; the error names the field.
fn fonts(settings: &Settings, cx: &App) -> anyhow::Result<()> {
let fallbacks = settings
.font_fallbacks
.iter()
.map(|family| ("font_fallbacks", family));
for (field, family) in std::iter::once(("font_family", &settings.font_family)).chain(fallbacks)
{
crate::term::load_font(&gpui::font(family.clone()), cx.text_system())
.with_context(|| field.to_string())?;
}
Ok(())
}
/// The file's settings; defaults when there is no file. A file that fails to read or check is an error.
+10 -1
View File
@@ -96,6 +96,10 @@ pub struct SettingsWindow {
/// Refusals so far; a field keyed by it drops its draft and shows the settings again.
drafts: usize,
font: Entity<TextInput>,
/// A picker's input for each fallback family, and one for the family being added.
fallbacks: Vec<Entity<TextInput>>,
/// The empty picker that adds a fallback shows.
adding: bool,
/// Whether the cursor preview shows, as it blinks.
lit: bool,
_typed: Subscription,
@@ -131,6 +135,8 @@ impl SettingsWindow {
failed: None,
drafts: 0,
font,
fallbacks: Vec::new(),
adding: false,
lit: true,
_typed: typed,
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
@@ -285,7 +291,10 @@ fn set(
impl Render for SettingsWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let page = match self.section.as_ref() {
"appearance" => self.appearance(window, cx).into_any_element(),
"appearance" => {
self.grow_fallbacks(window, cx);
self.appearance(window, cx).into_any_element()
}
"terminal" => self.terminal(cx).into_any_element(),
other => unreachable!("settings: no section {other}"),
};
+134 -1
View File
@@ -1,8 +1,10 @@
use std::ops::RangeInclusive;
use ely_gpui_component::{
forms::{FontPicker, NumberInput, RadioCard, Slider, Switch},
buttons::{Button, ButtonVariant, IconButton},
forms::{FontPicker, NumberInput, RadioCard, Slider, Switch, TextInput},
layout::SimpleGrid,
primitives::IconName,
settings::{SettingsRow, SettingsSection},
theme::{ActiveTheme, ControlSize, Mix, Radius, TextSize},
};
@@ -72,8 +74,12 @@ impl SettingsWindow {
}
}
});
let fallbacks = SettingsRow::new("Fallbacks")
.description("Tried in order for characters the family lacks")
.control(self.fallback_list(cx));
let font = SettingsSection::new("Font")
.row(SettingsRow::new("Family").control(div().w(theme.prose_width()).child(family)))
.row(fallbacks)
.row(SettingsRow::new("Size").control(self.number(
"font_size",
settings.font_size,
@@ -173,6 +179,94 @@ impl SettingsWindow {
.child(background)
}
/// An input for each fallback picker and the one that adds a family.
pub(super) fn grow_fallbacks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let wanted = SettingsStore::get(cx).font_fallbacks.len() + 1;
while self.fallbacks.len() < wanted {
let input = cx.new(|cx| TextInput::new(window, cx));
self.fallbacks.push(input);
}
}
/// Each fallback family, a picker that replaces it and a button that removes it, then Add or the empty picker it opens.
fn fallback_list(&self, cx: &Context<Self>) -> Div {
let this = cx.entity();
let families = SettingsStore::get(cx).font_fallbacks.clone();
let width = cx.theme().prose_width();
let row = |index: usize, picker: FontPicker, remove: IconButton| {
let tip = if index < families.len() {
"Remove"
} else {
"Cancel"
};
div()
.flex()
.items_start()
.gap_2()
.child(div().w(width).child(picker))
.child(
remove
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip),
)
};
let rows = families.iter().enumerate().map(|(index, family)| {
let picker = FontPicker::new(("font_fallback", index), &self.fallbacks[index])
.selected(family.clone())
.on_change(pick(&this, index));
let remove = IconButton::new(("remove_fallback", index), IconName::X).on_click({
let (this, family) = (this.clone(), family.clone());
move |_, _, cx| {
set(
&this,
format!("font_fallbacks drops {family:?}"),
cx,
|settings| {
settings.font_fallbacks.remove(index);
},
);
}
});
row(index, picker, remove)
});
let next = families.len();
let add = if self.adding {
let picker = FontPicker::new(("font_fallback", next), &self.fallbacks[next])
.on_change(pick(&this, next));
let cancel = IconButton::new("cancel_fallback", IconName::X).on_click({
let this = this.clone();
move |_, _, cx| this.update(cx, |this, cx| this.leave_adding(cx))
});
row(next, picker, cancel)
} else {
let add = Button::new("add_fallback", "Add fallback")
.icon(IconName::Plus)
.size(ControlSize::Sm)
.on_click({
let this = this.clone();
move |_, _, cx| {
this.update(cx, |this, cx| {
this.adding = true;
cx.notify();
})
}
});
div().child(add)
};
div().flex().flex_col().gap_2().children(rows).child(add)
}
/// Closes the empty picker, its text gone.
fn leave_adding(&mut self, cx: &mut Context<Self>) {
let next = SettingsStore::get(cx).font_fallbacks.len();
if let Some(input) = self.fallbacks.get(next) {
input.update(cx, |input, cx| input.set_text("", cx));
}
self.adding = false;
cx.notify();
}
/// A field over `range`, by `step` to `places`, that saves each committed number through `put`; a refusal starts it again from the settings.
fn number(
&self,
@@ -198,6 +292,45 @@ impl SettingsWindow {
}
}
/// Saves the family picked at fallback `index`, past the last adds one; a refusal shows the saved family again, the list closed.
fn pick(
this: &Entity<SettingsWindow>,
index: usize,
) -> impl Fn(&SharedString, &mut Window, &mut App) + 'static {
let this = this.clone();
move |family, window, cx| {
let family = family.to_string();
let adds = index == SettingsStore::get(cx).font_fallbacks.len();
let saved = set(
&this,
format!("font_fallbacks[{index}] is {family:?}"),
cx,
|settings| {
if adds {
settings.font_fallbacks.push(family)
} else {
settings.font_fallbacks[index] = family
}
},
);
if adds && saved {
this.update(cx, |this, cx| {
this.adding = false;
cx.notify();
});
} else if !saved {
let shown = SettingsStore::get(cx)
.font_fallbacks
.get(index)
.cloned()
.unwrap_or_default();
let input = this.read(cx).fallbacks[index].clone();
input.update(cx, |input, cx| input.set_text(shown, cx));
window.blur(cx);
}
}
}
/// `fraction` in whole percents; the slider moves by them, so a saved fraction carries no float noise.
fn percent(fraction: f32) -> f64 {
(f64::from(fraction) * 100.).round()
+5 -3
View File
@@ -1,7 +1,8 @@
//! The terminal's font, cell and metrics.
use gpui::{
Font, FontFeatures, FontId, FontStyle, FontWeight, Pixels, TextSystem, Window, px, size,
Font, FontFallbacks, FontFeatures, FontId, FontStyle, FontWeight, Pixels, TextSystem, Window,
px, size,
};
use super::{ink::Ink, paint::Look};
@@ -19,7 +20,7 @@ pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
let loaded = system.get_font_for_id(id);
anyhow::ensure!(
loaded.is_some_and(|loaded| loaded.family == font.family),
"font_family is {:?}, which the terminal cannot load",
"the terminal cannot load {:?}",
font.family
);
Ok(id)
@@ -39,7 +40,8 @@ pub(super) fn look(
} else {
FontFeatures::disable_ligatures()
},
fallbacks: None,
fallbacks: (!settings.font_fallbacks.is_empty())
.then(|| FontFallbacks::from_fonts(settings.font_fallbacks.clone())),
weight: FontWeight::NORMAL,
style: FontStyle::Normal,
};