Skip to content

Commit 2ea3632

Browse files
committed
refactor(ui-model): share terminal typography policy
1 parent 733b45a commit 2ea3632

6 files changed

Lines changed: 652 additions & 128 deletions

File tree

crates/roost-linux/src/app.rs

Lines changed: 56 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ use roost_linux::reconcile;
3737

3838
use crate::agent_palette;
3939
use crate::agent_palette::SidebarAgentRow;
40-
use crate::cell_metrics::DEFAULT_FONT_SIZE_PT;
4140
use crate::clipboard;
4241
use crate::config;
4342
use crate::config::{ClipboardWrite, CopyOnSelect, RoostConfig};
@@ -58,6 +57,7 @@ use crate::tab_session::{TabOutput, TabSession};
5857
use crate::terminal_view::TerminalView;
5958
use crate::theme::Theme;
6059
use roost_engine::git_metrics;
60+
use roost_ui_model::typography::{self, FamilyApply, TerminalTypography};
6161

6262
/// One per project: sidebar row + tab strip + tab content stack.
6363
struct ProjectUi {
@@ -229,26 +229,13 @@ pub struct App {
229229
/// Count badge overlaid on the HeaderBar notifications bell. Hidden
230230
/// at zero; `refresh_notif_badge` keeps it in sync with the inbox.
231231
notif_badge: gtk4::Label,
232-
/// Optional font-family override from config. `RefCell` because
233-
/// the command palette swaps it live (Select Font…); new tabs
234-
/// read the current value at spawn so confirm + revert propagate
235-
/// forward, matching the theme story.
236-
font_family: RefCell<Option<String>>,
232+
/// Toolkit-neutral live family/size state. GTK retains Pango measurement
233+
/// and palette presentation; shared transitions live in roost-ui-model.
234+
typography: RefCell<TerminalTypography>,
237235
/// Font family captured when the palette opened, restored on
238236
/// dismiss-without-confirm so an in-flight live preview reverts.
239237
/// `None` while the palette is closed or before the first open.
240238
font_family_at_open: RefCell<Option<Option<String>>>,
241-
/// Optional font-size override from config (points). Snapshot
242-
/// of the value read at boot; the live size (with FontIncrease /
243-
/// FontDecrease / FontReset adjustments) lives in
244-
/// `current_font_size_pt`.
245-
font_size_pt: Option<f64>,
246-
/// Live font size for the active session. Starts at
247-
/// `font_size_pt.unwrap_or(DEFAULT_FONT_SIZE_PT)` and shifts by
248-
/// ±1 on each FontIncrease / FontDecrease; FontReset snaps back
249-
/// to that baseline. Applied to every TerminalView via
250-
/// `apply_font_size_to_all`.
251-
current_font_size_pt: RefCell<f64>,
252239
/// `copy-on-select` from `~/.config/roost/config.conf` (default
253240
/// `True`). `RefCell` so a future config-reload path can update it
254241
/// without rebuilding the App; new tabs read the current value
@@ -810,10 +797,11 @@ impl App {
810797
metrics_cache: RefCell::new(git_metrics::MetricsCache::default()),
811798
notification_inbox: RefCell::new(NotificationInbox::new()),
812799
notif_badge: notif_badge.clone(),
813-
font_family: RefCell::new(cfg.font_family.clone()),
800+
typography: RefCell::new(TerminalTypography::new(
801+
cfg.font_family.clone(),
802+
cfg.font_size,
803+
)),
814804
font_family_at_open: RefCell::new(None),
815-
font_size_pt: cfg.font_size,
816-
current_font_size_pt: RefCell::new(cfg.font_size.unwrap_or(DEFAULT_FONT_SIZE_PT)),
817805
copy_on_select: RefCell::new(cfg.copy_on_select),
818806
clipboard_write_policy: RefCell::new(cfg.clipboard_write),
819807
word_break_chars: RefCell::new(cfg.word_break_chars.clone()),
@@ -2530,10 +2518,17 @@ impl App {
25302518
// tab opened after the user has zoomed in matches the
25312519
// existing tabs, rather than snapping back to the config
25322520
// baseline.
2521+
let (font_family, font_size_pt) = {
2522+
let typography = self.typography.borrow();
2523+
(
2524+
typography.family().map(str::to_owned),
2525+
typography.current_size_pt(),
2526+
)
2527+
};
25332528
let terminal = Rc::new(TerminalView::with_theme_font_and_copy(
25342529
self.theme.borrow().clone(),
2535-
self.font_family.borrow().as_deref(),
2536-
Some(*self.current_font_size_pt.borrow()),
2530+
font_family.as_deref(),
2531+
Some(font_size_pt),
25372532
*self.copy_on_select.borrow(),
25382533
self.word_break_chars.borrow().clone(),
25392534
self.link_modifier,
@@ -3630,18 +3625,10 @@ impl App {
36303625
KeybindAction::FontIncrease => self.adjust_font_size(1.0),
36313626
KeybindAction::FontDecrease => self.adjust_font_size(-1.0),
36323627
KeybindAction::FontReset => {
3633-
let baseline = self.font_size_pt.unwrap_or(DEFAULT_FONT_SIZE_PT);
3634-
let current = *self.current_font_size_pt.borrow();
3635-
// No-op when the live size already matches the baseline.
3636-
// Skipping the apply call also skips its config write —
3637-
// otherwise a stray Cmd+0 on an unconfigured user would
3638-
// materialize `font-size = <default>` into a config that
3639-
// never had a font-size line.
3640-
if (current - baseline).abs() < 0.01 {
3641-
return;
3628+
let reset = self.typography.borrow_mut().reset_size();
3629+
if let Some(size_pt) = reset {
3630+
self.apply_font_size_to_all(size_pt);
36423631
}
3643-
*self.current_font_size_pt.borrow_mut() = baseline;
3644-
self.apply_font_size_to_all(baseline);
36453632
}
36463633
KeybindAction::SwitchProject(n) => self.switch_project_by_index(n as usize),
36473634
KeybindAction::SwitchTab(n) => self.switch_tab_by_index(n as usize),
@@ -3728,25 +3715,19 @@ impl App {
37283715
/// daemon doesn't know or care about font size — it's purely
37293716
/// a UI concern — so no RPC fires.
37303717
fn adjust_font_size(self: &Rc<Self>, delta: f64) {
3731-
let new = {
3732-
let mut size = self.current_font_size_pt.borrow_mut();
3733-
let new = (*size + delta).clamp(6.0, 72.0);
3734-
if (new - *size).abs() < 0.01 {
3735-
return;
3736-
}
3737-
*size = new;
3738-
new
3739-
};
3740-
self.apply_font_size_to_all(new);
3718+
let adjusted = self.typography.borrow_mut().adjust_size(delta);
3719+
if let Some(size_pt) = adjusted {
3720+
self.apply_font_size_to_all(size_pt);
3721+
}
37413722
}
37423723

37433724
/// Push `size_pt` to every TerminalView in every project. Reuses
37443725
/// each view's existing `apply_font` path so cell metrics get
37453726
/// remeasured + a redraw is queued automatically.
37463727
fn apply_font_size_to_all(self: &Rc<Self>, size_pt: f64) {
3728+
let family = self.typography.borrow().family().map(str::to_owned);
37473729
{
37483730
let projects = self.projects.borrow();
3749-
let family = self.font_family.borrow();
37503731
for ui in projects.values() {
37513732
let tabs = ui.tabs.borrow();
37523733
for tab_ui in tabs.values() {
@@ -3814,17 +3795,16 @@ impl App {
38143795
/// family slot to support size-only updates, so we must pass an
38153796
/// explicit family string to revert visually.
38163797
fn set_active_font_family(self: &Rc<Self>, family: Option<String>) {
3817-
// Clone the to-be-applied family BEFORE moving into the
3818-
// RefCell so the per-tab loop below can read it without
3819-
// holding a live borrow across arbitrary view code (any
3820-
// future apply_font side-effect that re-enters would
3821-
// otherwise trip a BorrowError).
3822-
let applied: String = family
3823-
.as_deref()
3824-
.unwrap_or(crate::cell_metrics::DEFAULT_FONT_FAMILY)
3825-
.to_string();
3826-
*self.font_family.borrow_mut() = family;
3827-
let size = *self.current_font_size_pt.borrow();
3798+
// Resolve owned values and drop the model borrow before invoking any
3799+
// TerminalView code; renderer callbacks must never run under it.
3800+
let (applied, size) = {
3801+
let mut typography = self.typography.borrow_mut();
3802+
typography.set_family(family);
3803+
(
3804+
typography.effective_family().to_string(),
3805+
typography.current_size_pt(),
3806+
)
3807+
};
38283808
let projects = self.projects.borrow();
38293809
for ui in projects.values() {
38303810
let tabs = ui.tabs.borrow();
@@ -3848,27 +3828,19 @@ impl App {
38483828
/// against the live value would still drop the fallback.
38493829
fn commit_font_family(self: &Rc<Self>, name: &str) {
38503830
let opened = self.font_family_at_open.borrow().clone().flatten();
3851-
let opened_primary = opened
3852-
.as_deref()
3853-
.and_then(|s| s.split(',').map(str::trim).find(|t| !t.is_empty()));
3854-
if opened_primary
3855-
.map(|p| p.eq_ignore_ascii_case(name))
3856-
.unwrap_or(false)
3857-
{
3858-
// No-op confirm: restore the opened chain to live state
3859-
// (an interim preview may have replaced it with the bare
3860-
// primary) and DON'T rewrite the file — it already has
3861-
// the chain the user opened with.
3862-
if *self.font_family.borrow() != opened {
3863-
self.set_active_font_family(opened);
3864-
}
3865-
return;
3831+
let live = self.typography.borrow().family().map(str::to_owned);
3832+
let confirmation = typography::confirm_family(opened.as_deref(), live.as_deref(), name);
3833+
match confirmation.apply {
3834+
FamilyApply::Keep => {}
3835+
FamilyApply::Set(family) => self.set_active_font_family(family),
38663836
}
3867-
self.set_active_font_family(Some(name.to_string()));
3868-
if let Err(e) = write_back_font_family(name) {
3837+
let Some(persist) = confirmation.persist else {
3838+
return;
3839+
};
3840+
if let Err(e) = write_back_font_family(&persist) {
38693841
tracing::warn!(
38703842
error = %e,
3871-
family = name,
3843+
family = persist,
38723844
"failed to persist font-family to config.conf"
38733845
);
38743846
}
@@ -3884,7 +3856,8 @@ impl App {
38843856
return;
38853857
}
38863858
*self.theme_name_at_open.borrow_mut() = Some(self.active_theme_name.borrow().clone());
3887-
*self.font_family_at_open.borrow_mut() = Some(self.font_family.borrow().clone());
3859+
*self.font_family_at_open.borrow_mut() =
3860+
Some(self.typography.borrow().family().map(str::to_owned));
38883861

38893862
// Reverse map (action → shortcut label) from the canonicalized
38903863
// bindings, so each command row shows its keybind hint. First
@@ -4919,11 +4892,7 @@ impl App {
49194892
/// live family.
49204893
fn font_frame(self: &Rc<Self>) -> PaletteFrame {
49214894
let families = self.available_font_families();
4922-
let active = self
4923-
.font_family
4924-
.borrow()
4925-
.clone()
4926-
.unwrap_or_else(|| crate::cell_metrics::DEFAULT_FONT_FAMILY.to_string());
4895+
let active = self.typography.borrow().effective_family().to_string();
49274896
// Match against the primary entry of a comma list (e.g. the
49284897
// default `"JetBrains Mono, Monospace"` should pre-select
49294898
// "JetBrains Mono"). Fall back to row 0 if not found.
@@ -4971,9 +4940,9 @@ impl App {
49714940
/// already active).
49724941
fn preview_font_family(self: &Rc<Self>, name: &str) {
49734942
let already = self
4974-
.font_family
4943+
.typography
49754944
.borrow()
4976-
.as_deref()
4945+
.family()
49774946
.map(|s| s == name)
49784947
.unwrap_or(false);
49794948
if already {
@@ -4990,7 +4959,7 @@ impl App {
49904959
let Some(target) = self.font_family_at_open.borrow().clone() else {
49914960
return;
49924961
};
4993-
let current = self.font_family.borrow().clone();
4962+
let current = self.typography.borrow().family().map(str::to_owned);
49944963
if current == target {
49954964
return;
49964965
}
@@ -6606,7 +6575,7 @@ fn write_back_font_family(name: &str) -> std::io::Result<()> {
66066575
let Some(path) = config::config_path() else {
66076576
return Ok(());
66086577
};
6609-
let quoted = format!("\"{}\"", name);
6578+
let quoted = typography::quote_font_family(name);
66106579
config::set_key(&path, "font-family", &quoted)
66116580
}
66126581

@@ -6617,7 +6586,7 @@ fn write_back_font_size(size_pt: f64) -> std::io::Result<()> {
66176586
let Some(path) = config::config_path() else {
66186587
return Ok(());
66196588
};
6620-
let formatted = format_font_size(size_pt);
6589+
let formatted = typography::format_font_size(size_pt);
66216590
config::set_key(&path, "font-size", &formatted)
66226591
}
66236592

@@ -6637,21 +6606,6 @@ fn write_back_show_sidebar_agents(value: bool) -> std::io::Result<()> {
66376606
)
66386607
}
66396608

6640-
/// Format a font size in points for the config file. Whole numbers
6641-
/// render as integers; non-whole values keep up to two decimal places
6642-
/// so a `font-size = 14.5` round-trip cleanly. Split out for testing
6643-
/// (no I/O).
6644-
fn format_font_size(size_pt: f64) -> String {
6645-
if (size_pt.round() - size_pt).abs() < 0.001 {
6646-
format!("{}", size_pt.round() as i64)
6647-
} else {
6648-
// Two decimals is plenty for point sizes; trim trailing zeros.
6649-
let s = format!("{:.2}", size_pt);
6650-
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
6651-
trimmed.to_string()
6652-
}
6653-
}
6654-
66556609
pub fn parse_tab_id_from_page(page: &libadwaita::TabPage) -> Option<i64> {
66566610
let name = page.child().widget_name().to_string();
66576611
name.strip_prefix("tab-").and_then(|n| n.parse().ok())
@@ -6718,9 +6672,9 @@ fn agent_rows_visible(toggle_on: bool, dragging: bool) -> bool {
67186672
mod tests {
67196673
use super::{
67206674
activation_target, agent_rows_visible, compute_insert_idx, drain_server_driven_marker,
6721-
format_font_size, is_already_attached_or_pending, pick_next_active_project,
6722-
resolve_launch_cwd, restore_open_specs, reveal_scroll_value, tilde_abbreviate_with_home,
6723-
ActivationTarget, RestoreTab,
6675+
is_already_attached_or_pending, pick_next_active_project, resolve_launch_cwd,
6676+
restore_open_specs, reveal_scroll_value, tilde_abbreviate_with_home, ActivationTarget,
6677+
RestoreTab,
67246678
};
67256679
use std::cell::RefCell;
67266680
use std::collections::{HashMap, HashSet};
@@ -6994,21 +6948,4 @@ mod tests {
69946948
// Both empty stays empty (open_tab then resolves project → $HOME).
69956949
assert_eq!(resolve_launch_cwd(None, ""), "");
69966950
}
6997-
6998-
#[test]
6999-
fn format_font_size_whole_renders_as_integer() {
7000-
assert_eq!(format_font_size(14.0), "14");
7001-
assert_eq!(format_font_size(8.0), "8");
7002-
// Floating-point fuzz like 14.0000000001 still rounds.
7003-
assert_eq!(format_font_size(14.0 + f64::EPSILON), "14");
7004-
}
7005-
7006-
#[test]
7007-
fn format_font_size_keeps_decimals_when_needed() {
7008-
assert_eq!(format_font_size(14.5), "14.5");
7009-
// Trailing zeros are trimmed (no "14.50").
7010-
assert_eq!(format_font_size(14.50), "14.5");
7011-
// Two-decimal precision is preserved.
7012-
assert_eq!(format_font_size(13.25), "13.25");
7013-
}
70146951
}

crates/roost-linux/src/cell_metrics.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,7 @@
1212
1313
use gtk4::pango::{self, FontDescription};
1414
use gtk4::prelude::{FontFamilyExt, FontMapExt};
15-
16-
/// Default font family chain. JetBrains Mono is preferred when
17-
/// installed; falls through to system monospace via Pango's
18-
/// `Monospace` alias. The full font-family fallback resolution
19-
/// lands in commit 11.
20-
pub const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono, Monospace";
21-
22-
/// Default font size in points. Matches the Mac UI default.
23-
pub const DEFAULT_FONT_SIZE_PT: f64 = 13.0;
15+
pub use roost_ui_model::typography::{DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE_PT};
2416

2517
/// Pango-measured cell metrics.
2618
#[derive(Debug, Clone, Copy)]

crates/roost-ui-model/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ pub mod provider;
1212
pub mod rollup;
1313
pub mod shell_escape;
1414
pub mod theme;
15+
pub mod typography;
1516
pub mod word_selection;

0 commit comments

Comments
 (0)