@@ -37,7 +37,6 @@ use roost_linux::reconcile;
3737
3838use crate :: agent_palette;
3939use crate :: agent_palette:: SidebarAgentRow ;
40- use crate :: cell_metrics:: DEFAULT_FONT_SIZE_PT ;
4140use crate :: clipboard;
4241use crate :: config;
4342use crate :: config:: { ClipboardWrite , CopyOnSelect , RoostConfig } ;
@@ -58,6 +57,7 @@ use crate::tab_session::{TabOutput, TabSession};
5857use crate :: terminal_view:: TerminalView ;
5958use crate :: theme:: Theme ;
6059use 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.
6363struct 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-
66556609pub 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 {
67186672mod 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}
0 commit comments