feat(ui): the interface has a font size of its own

The detail panel carried a private run of pixel sizes — 12 for body,
11.5/11 under it — which put its primary text at the size the rest of the
window uses for secondary text, so it read a step smaller than the sidebar
beside it. Its mono values sat at the same px as their sans labels, where a
larger x-height makes them look a size bigger, so a row read as two sizes
instead of one line. `forwards.rs` and `sftp.rs`, both drawn inside that
panel, had copied the same numbers.

Put the panel back on the rem ladder the rest of the chrome already uses,
with mono a notch under the sans it pairs with, and make the rem itself
settable: `ui_font_size` defaults to gpui's own 16, so an existing config
renders unchanged, and every window's root sets it, which reaches the whole
interface at once. The terminal grid is absolute px from `font_size` and
does not move — a display that is not Retina can now have bigger chrome
without touching the text in the panes.
This commit is contained in:
l0ng-ai
2026-08-09 13:09:35 +07:00
parent fee48a4c99
commit 686bd0bcea
10 changed files with 201 additions and 34 deletions
+57
View File
@@ -120,6 +120,15 @@ pub struct Config {
pub font_features: Option<FontFeatures>,
pub font_size: f32,
pub line_height: f32,
/// The interface's root font size, in pixels — everything outside the
/// terminal grid is sized against it.
///
/// It is the CSS-style `rem` the whole chrome is laid out in, not one
/// label's size: raising it scales panel text, section headings and the
/// spacing derived from them together. Its own default matches the size
/// the chrome was drawn at, so an existing config renders unchanged.
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
pub theme: String,
pub theme_preset: String,
pub theme_follow_system: bool,
@@ -431,6 +440,7 @@ impl Default for Config {
font_features: None,
font_size: 15.0,
line_height: 1.4,
ui_font_size: default_ui_font_size(),
theme: "light".to_string(),
theme_preset: "light".to_string(),
theme_follow_system: false,
@@ -529,6 +539,13 @@ impl Config {
self.line_height = Config::default().line_height;
}
self.line_height = self.line_height.clamp(0.5, 4.0);
if !self.ui_font_size.is_finite() || self.ui_font_size <= 0.0 {
self.ui_font_size = default_ui_font_size();
}
// The whole chrome is a multiple of this, so a wild value does not
// shrink one label — it makes the window unusable. Keep the range to
// sizes the layout still holds together at.
self.ui_font_size = self.ui_font_size.clamp(UI_FONT_SIZE_MIN, UI_FONT_SIZE_MAX);
self.scrollback_limit = self.scrollback_limit.clamp(100, MAX_SCROLLBACK);
if !self.mouse_scroll_multiplier.is_finite() || self.mouse_scroll_multiplier <= 0.0 {
self.mouse_scroll_multiplier = Config::default().mouse_scroll_multiplier;
@@ -775,6 +792,17 @@ fn default_right_panel_width() -> f32 {
260.
}
/// The rem the chrome has always been laid out against — gpui's own default,
/// which is what `text_sm()` and `text_xs()` resolve 14px and 12px from. Left
/// alone, the interface looks exactly as it did before the size was settable.
pub const UI_FONT_SIZE_DEFAULT: f32 = 16.0;
pub const UI_FONT_SIZE_MIN: f32 = 12.0;
pub const UI_FONT_SIZE_MAX: f32 = 24.0;
fn default_ui_font_size() -> f32 {
UI_FONT_SIZE_DEFAULT
}
fn default_sidebar_width() -> f32 {
220.0
}
@@ -993,6 +1021,35 @@ mod tests {
assert_eq!(sanitized(15.0, 1.4), (15.0, 1.4));
}
#[test]
fn a_config_written_before_ui_font_size_existed_keeps_the_chrome_it_had() {
// The whole interface is laid out against this, so a missing field
// must not resolve to serde's 0.0 — that collapses every window the
// user already has, on upgrade, without them touching a setting.
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
assert_eq!(cfg.ui_font_size, UI_FONT_SIZE_DEFAULT);
}
#[test]
fn sanitize_holds_ui_font_size_to_sizes_the_layout_survives() {
let sanitized = |ui_font_size: f32| {
let mut cfg = Config {
ui_font_size,
..Config::default()
};
cfg.sanitize();
cfg.ui_font_size
};
assert_eq!(sanitized(0.0), UI_FONT_SIZE_DEFAULT);
assert_eq!(sanitized(f32::NAN), UI_FONT_SIZE_DEFAULT);
assert_eq!(sanitized(-3.0), UI_FONT_SIZE_DEFAULT);
assert_eq!(sanitized(1000.0), UI_FONT_SIZE_MAX);
assert_eq!(sanitized(2.0), UI_FONT_SIZE_MIN);
// A size the user actually picked comes back untouched.
assert_eq!(sanitized(18.0), 18.0);
}
struct TestDir(std::path::PathBuf);
impl TestDir {
+40 -1
View File
@@ -92,6 +92,8 @@ const FONT_SIZE_MIN: f32 = 6.0;
const FONT_SIZE_MAX: f32 = 48.0;
pub(crate) const FONT_SIZE_STEP: f32 = 1.0;
pub(crate) const UI_FONT_SIZE_STEP: f32 = 1.0;
const LINE_HEIGHT_MIN: f32 = 1.0;
const LINE_HEIGHT_MAX: f32 = 2.0;
pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05;
@@ -106,8 +108,12 @@ pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
pub(crate) const TILE_SIZE: f32 = 32.;
pub(crate) const TILE_GLYPH: f32 = 13.;
/// A tile that sits in a body row rather than in chrome: the box shrinks to
/// the minimum hit target, but the glyph keeps the chrome size. An 11px glyph
/// here read as a disabled ornament next to 14px text, and put a second,
/// smaller folder in the same column as the panel's folder tab.
pub(crate) const TILE_SIZE_SM: f32 = 24.;
pub(crate) const TILE_GLYPH_SM: f32 = 11.;
pub(crate) const TILE_GLYPH_SM: f32 = TILE_GLYPH;
pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
@@ -1370,6 +1376,34 @@ impl Tty7App {
self.set_font_size(Config::default().font_size, cx);
}
fn set_ui_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
use crate::core::config::{UI_FONT_SIZE_MAX, UI_FONT_SIZE_MIN};
let size = size.clamp(UI_FONT_SIZE_MIN, UI_FONT_SIZE_MAX);
let cfg = cx.global_mut::<Config>();
if cfg.ui_font_size == size {
return;
}
cfg.ui_font_size = size;
cfg.save();
// Unlike the settings that only redraw the window they were changed
// in, this one re-lays-out every open window, and each reads the new
// rem from the global on its own next frame.
cx.refresh_windows();
cx.notify();
}
pub(crate) fn ui_font_size(&self, cx: &gpui::App) -> f32 {
cx.global::<Config>().ui_font_size
}
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_ui_font_size(self.ui_font_size(cx) + delta, cx);
}
pub(crate) fn reset_ui_font_size(&mut self, cx: &mut Context<Self>) {
self.set_ui_font_size(Config::default().ui_font_size, cx);
}
fn set_line_height(&mut self, mul: f32, cx: &mut Context<Self>) {
let mul = mul.clamp(LINE_HEIGHT_MIN, LINE_HEIGHT_MAX);
self.line_height = mul;
@@ -5511,6 +5545,11 @@ impl Render for Tty7App {
#[cfg(test)]
render_probe::record();
let prof = crate::ui::perf::enabled().then(std::time::Instant::now);
// Every window's root is this view, so setting the rem here is what
// makes `ui_font_size` reach the whole interface — the rem ladder
// (`text_sm`, `text_xs`, `rems(..)`) resolves against it, and the
// terminal grid, sized in absolute px from `font_size`, does not move.
window.set_rem_size(px(cx.global::<Config>().ui_font_size));
self.claim_pending_tab(window, cx);
self.touch_active_tab();
if cx.has_active_drag() {
+7 -6
View File
@@ -1,4 +1,4 @@
use gpui::{AnyElement, Context, Div, Entity, FontWeight, Stateful, div, prelude::*, px};
use gpui::{AnyElement, Context, Div, Entity, FontWeight, Stateful, div, prelude::*, px, rems};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
@@ -7,6 +7,7 @@ use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
use crate::terminal::view::TerminalView;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::{META, TEXT, TEXT_MONO};
/// How far a forward's target endpoint fades when the rule is Dynamic and has
/// no target to name. The rules editor in Settings and the live Forwards panel
@@ -177,7 +178,7 @@ impl Tty7App {
div()
.px(px(CONTENT_INSET))
.py(px(2.))
.text_size(px(12.))
.text_size(rems(TEXT))
.text_color(cx.theme().muted_foreground)
.child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::None)),
)
@@ -258,7 +259,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(if errored { theme.danger } else { muted })
.child(tail),
@@ -268,7 +269,7 @@ impl Tty7App {
this.child(
div()
.truncate()
.text_size(px(11.))
.text_size(rems(META))
.text_color(muted)
.child(desc),
)
@@ -324,12 +325,12 @@ impl Tty7App {
div()
.flex_none()
.w(px(30.))
.text_size(px(11.))
.text_size(rems(META))
.text_color(muted)
.child(label),
)
.child(div().flex_1().min_w_0().child(Input::new(host).xsmall()))
.child(div().text_size(px(11.)).text_color(muted).child(":"))
.child(div().text_size(rems(META)).text_color(muted).child(":"))
.child(div().w(px(52.)).child(Input::new(port).xsmall()))
};
+5
View File
@@ -88,6 +88,11 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsTypography => "Typography",
L10nKey::SettingsFontSize => "Font size",
L10nKey::SettingsFontSizeDesc => "Terminal text size in pixels.",
L10nKey::SettingsUiFontSize => "Interface font size",
L10nKey::SettingsUiFontSizeDesc => {
"Text size everywhere outside the terminal — tabs, panels and settings. \
Raise it on a display that is not Retina."
}
L10nKey::SettingsLineHeight => "Line height",
L10nKey::SettingsLineHeightDesc => "Row spacing as a multiple of the font size.",
L10nKey::SettingsFontFamily => "Font family",
+4
View File
@@ -90,6 +90,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTypography => "タイポグラフィ",
L10nKey::SettingsFontSize => "フォントサイズ",
L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ピクセル)",
L10nKey::SettingsUiFontSize => "インターフェースのフォントサイズ",
L10nKey::SettingsUiFontSizeDesc => {
"ターミナル以外すべての文字サイズ(タブ・パネル・設定)。Retina でないディスプレイでは大きめに"
}
L10nKey::SettingsLineHeight => "行の高さ",
L10nKey::SettingsLineHeightDesc => "フォントサイズに対する行間の倍率",
L10nKey::SettingsFontFamily => "フォントファミリー",
+2
View File
@@ -147,6 +147,8 @@ l10n_keys! {
SettingsTypography,
SettingsFontSize,
SettingsFontSizeDesc,
SettingsUiFontSize,
SettingsUiFontSizeDesc,
SettingsLineHeight,
SettingsLineHeightDesc,
SettingsFontFamily,
+4
View File
@@ -82,6 +82,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsTypography => "字体排版",
L10nKey::SettingsFontSize => "字号",
L10nKey::SettingsFontSizeDesc => "终端文字大小(像素)。",
L10nKey::SettingsUiFontSize => "界面字号",
L10nKey::SettingsUiFontSizeDesc => {
"终端以外所有地方的文字大小——标签页、面板、设置。非 Retina 显示器上可以调大。"
}
L10nKey::SettingsLineHeight => "行高",
L10nKey::SettingsLineHeightDesc => "行间距为字号的倍数。",
L10nKey::SettingsFontFamily => "字体族",
+53 -21
View File
@@ -1,4 +1,4 @@
use gpui::{AnyElement, Context, Window, div, prelude::*, px};
use gpui::{AnyElement, Context, Window, div, prelude::*, px, rems};
use gpui_component::button::Button;
use gpui_component::input::Input;
use gpui_component::{
@@ -24,6 +24,30 @@ pub(crate) const MAX_WIDTH_RATIO: f32 = 0.5;
/// sidebar's and this panel's — are the same target, so they are one number.
pub(crate) const RESIZE_HANDLE_WIDTH: f32 = 8.;
/// The panel's type scale, in rems, on the same ladder as the rest of the
/// window.
///
/// This panel used to carry its own run of pixel sizes — 12 for body, 11.5/11
/// under it — which put its *primary* text at the size everything else uses
/// for *secondary* text, so the panel read a step smaller than the sidebar
/// beside it, and stayed that size when `ui_font_size` moved. In rems `TEXT`
/// and `META` are exactly `text_sm()` and `text_xs()`; they are spelled out
/// only because the mono variants have to be derived from them.
///
/// Mono sits a notch under the sans it pairs with: at an equal size its
/// x-height and stems read a size larger, which turns a label and its value
/// into two sizes instead of one line. The notch is a rem fraction rather than
/// a fixed pixel, so the correction scales with the text it is correcting.
const STEP: f32 = 1. / 16.;
pub(crate) const TEXT: f32 = 14. * STEP;
pub(crate) const TEXT_MONO: f32 = TEXT - STEP;
pub(crate) const META: f32 = 12. * STEP;
pub(crate) const META_MONO: f32 = META - STEP;
/// Uppercase section headings. Deliberately below `META` — it matches the tab
/// sidebar's group headings, which are the same thing one panel over.
const HEADING: f32 = 11. * STEP;
#[derive(Default)]
pub(crate) struct RightPanelState {
pub(crate) diff_cwd: Option<(crate::ui::host_ops::HostId, PathBuf)>,
@@ -54,8 +78,14 @@ fn info_label_column(
window: &mut Window,
cx: &gpui::App,
) -> gpui::Pixels {
const MIN: f32 = 46.;
const MAX: f32 = 108.;
// Shaping needs real pixels, so this is the one place the rem has to be
// resolved by hand. Both bounds were measured against a 12px label, so
// they are carried as multiples of it rather than as pixels — otherwise
// raising `ui_font_size` grows the labels into a clamp fitted to a
// smaller face, and every one of them wraps.
let label_px = TEXT * window.rem_size().as_f32();
let min = 46. / 12. * label_px;
let max = 108. / 12. * label_px;
let font = gpui::Font {
family: cx.theme().font_family.clone(),
features: Default::default(),
@@ -70,7 +100,7 @@ fn info_label_column(
.text_system()
.shape_line(
gpui::SharedString::from(*k),
px(12.),
px(label_px),
&[gpui::TextRun {
len: k.len(),
font: font.clone(),
@@ -84,8 +114,8 @@ fn info_label_column(
.width
.as_f32()
})
.fold(MIN, f32::max);
px(widest.clamp(MIN, MAX).ceil())
.fold(min, f32::max);
px(widest.clamp(min, max).ceil())
}
impl Tty7App {
@@ -311,7 +341,7 @@ impl Tty7App {
.gap(px(7.))
.child(
div()
.text_size(px(11.5))
.text_size(rems(META))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().secondary_foreground)
.child(text.to_uppercase()),
@@ -319,7 +349,7 @@ impl Tty7App {
.when_some(count, |this, c| {
this.child(
div()
.text_size(px(11.))
.text_size(rems(META_MONO))
.font_family(cx.theme().mono_font_family.clone())
.text_color(cx.theme().muted_foreground.opacity(0.75))
.child(c),
@@ -392,12 +422,12 @@ impl Tty7App {
.px(px(CONTENT_INSET))
.py(px(4.))
.gap(px(3.))
.text_size(px(12.))
.text_size(rems(TEXT))
.text_color(muted)
.child(text.to_string())
.children(hint.map(|h| {
div()
.text_size(px(11.))
.text_size(rems(META))
.text_color(muted.opacity(0.75))
.child(h.to_string())
}))
@@ -485,7 +515,7 @@ impl Tty7App {
.items_baseline()
.gap(px(9.))
.py(px(1.))
.text_size(px(12.))
.text_size(rems(TEXT))
.child(
div()
.flex_none()
@@ -505,6 +535,7 @@ impl Tty7App {
h_flex()
.flex_1()
.min_w_0()
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(cx.theme().foreground)
.child(div().min_w_0().flex_shrink(999.).truncate().child(head))
@@ -515,6 +546,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(cx.theme().foreground)
.child(v)
@@ -606,7 +638,7 @@ impl Tty7App {
.pb(px(if trailing.is_some() { 0. } else { 4. }))
.child(
div()
.text_size(px(10.5))
.text_size(rems(HEADING))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().muted_foreground)
.child(text.to_uppercase()),
@@ -633,7 +665,7 @@ impl Tty7App {
.min_w_0()
.truncate()
.pl(px(f32::from(p.depth) * 10.))
.text_size(px(12.))
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(if p.foreground {
cx.theme().foreground
@@ -681,7 +713,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(cx.theme().muted_foreground)
.child(p.name.clone()),
@@ -877,7 +909,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(cx.theme().foreground)
.child(path),
@@ -886,7 +918,7 @@ impl Tty7App {
this.child(
div()
.flex_none()
.text_size(px(11.))
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().success)
.child(format!("+{added}")),
@@ -896,7 +928,7 @@ impl Tty7App {
this.child(
div()
.flex_none()
.text_size(px(11.))
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().danger)
.child(format!("{removed}")),
@@ -910,7 +942,7 @@ impl Tty7App {
div()
.px(px(4.))
.py(px(3.))
.text_size(px(11.5))
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::PanelMoreChangedFiles, rest, &[])),
);
@@ -929,7 +961,7 @@ impl Tty7App {
))
.child(
div()
.text_size(px(11.5))
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::PanelUntracked, untracked, &[])),
),
@@ -1031,7 +1063,7 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri
.flex_none()
.w(px(14.))
.text_center()
.text_size(px(10.5))
.text_size(rems(META_MONO))
.font_family(mono.clone())
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(color)
@@ -1051,7 +1083,7 @@ pub(crate) fn info_chip(
.py(px(1.5))
.rounded(px(4.))
.bg(bg)
.text_size(px(10.5))
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(fg)
.child(text.to_string())
+23 -1
View File
@@ -30,7 +30,7 @@ use crate::core::ssh_profile::{
};
use crate::ui::app::{
FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH_LINE, TILE_SIZE, TITLE_BAR_HEIGHT, ThemeEdit,
Tty7App,
Tty7App, UI_FONT_SIZE_STEP,
};
use crate::ui::host_ops::HostId;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
@@ -1436,6 +1436,22 @@ impl Tty7App {
.on_click(cx.listener(|this, _, _w, cx| this.reset_font_size(cx))),
);
let ui_font_size = self.ui_font_size(cx);
let ui_font_size_control = stepper_row(
step("ui-font-dec", "", 0).on_click(
cx.listener(|this, _, _w, cx| this.change_ui_font_size(-UI_FONT_SIZE_STEP, cx)),
),
format!("{ui_font_size:.0}"),
step("ui-font-inc", "+", 2).on_click(
cx.listener(|this, _, _w, cx| this.change_ui_font_size(UI_FONT_SIZE_STEP, cx)),
),
Button::new("ui-font-reset")
.label(t(L10nKey::Reset))
.ghost()
.small()
.on_click(cx.listener(|this, _, _w, cx| this.reset_ui_font_size(cx))),
);
let line_height = self.line_height;
let line_height_control = stepper_row(
step("lh-dec", "", 0).on_click(
@@ -1525,6 +1541,12 @@ impl Tty7App {
font_size_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsUiFontSize),
t(L10nKey::SettingsUiFontSizeDesc),
ui_font_size_control,
cx,
))
.child(self.settings_row(
t(L10nKey::SettingsLineHeight),
t(L10nKey::SettingsLineHeightDesc),
+6 -5
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use gpui::{
AnyElement, App, Context, Div, ExternalPaths, FontWeight, PathPromptOptions, SharedString,
Stateful, Subscription, Window, div, prelude::*, px,
Stateful, Subscription, Window, div, prelude::*, px, rems,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState};
@@ -22,6 +22,7 @@ use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe
use crate::terminal::RemoteTerminal;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::{META, TEXT};
#[derive(Clone, Copy)]
enum SftpMenuAction {
@@ -1306,7 +1307,7 @@ impl Tty7App {
div()
.px(px(6.))
.py(px(4.))
.text_size(px(12.))
.text_size(rems(TEXT))
.text_color(color)
.child(text)
};
@@ -1573,7 +1574,7 @@ impl Tty7App {
.on_click(cx.listener(|this, _, _w, cx| this.sftp_toggle_tray(cx)))
.child(
div()
.text_size(px(11.))
.text_size(rems(META))
.text_color(muted)
.child(if expanded { "" } else { "" }),
)
@@ -1582,7 +1583,7 @@ impl Tty7App {
.flex_1()
.min_w_0()
.truncate()
.text_size(px(11.5))
.text_size(rems(META))
.text_color(summary_color)
.child(summary),
)
@@ -1619,7 +1620,7 @@ impl Tty7App {
div()
.px(px(CONTENT_INSET))
.py(px(3.))
.text_size(px(11.5))
.text_size(rems(META))
.text_color(muted)
.child(t(L10nKey::SftpNoTransfers)),
)