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:
8 files changed
+194
-13
No files matched your search
@@ -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
@@ -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
@@ -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}"),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user