diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 00f0753f..67f45461 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -687,6 +687,46 @@ pub fn focus_active_tab_in_pane(pane_widget: >k::Widget) -> bool { true } +/// Returns the active tab id in the given pane, if any. +pub fn active_tab_in_pane(pane_widget: >k::Widget) -> Option { + let internals = find_pane_internals(pane_widget)?; + let tab_state = internals.tab_state.borrow(); + tab_state.active_tab.clone() +} + +/// Returns the number of tabs in the given pane. +pub fn tab_count_in_pane(pane_widget: >k::Widget) -> usize { + let Some(internals) = find_pane_internals(pane_widget) else { + return 0; + }; + let count = internals.tab_state.borrow().tabs.len(); + count +} + +/// Closes the tab with the given id in the pane. If the tab is pinned, it is ignored. +pub fn close_tab_in_pane(pane_widget: >k::Widget, tab_id: &str) { + let Some(internals) = find_pane_internals(pane_widget) else { + return; + }; + let is_pinned = internals + .tab_state + .borrow() + .tabs + .iter() + .any(|entry| entry.id == tab_id && entry.pinned); + if !is_pinned { + remove_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + tab_id, + &internals.callbacks, + &internals.pane_outer, + PaneEmptyReason::ClosedLastTab, + ); + } +} + pub fn refresh_terminal_displays_in_root(root: >k::Widget) { for internals in pane_internals_for_root(root) { for entry in &internals.tab_state.borrow().tabs { diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index 4bd00101..3bbd336a 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -412,15 +412,29 @@ fn request_terminal_focus(gl_area: >k::GLArea, had_focus: &Cell) { gl_area.grab_focus(); } -fn refresh_surface_display(surface: ghostty_surface_t, gl_area: >k::GLArea) { +/// Convert a GLArea's logical (CSS-pixel) allocation to physical (device) pixels. +fn gl_area_device_size(gl_area: >k::GLArea) -> (u32, u32) { let alloc = gl_area.allocation(); - let w = alloc.width() as u32; - let h = alloc.height() as u32; - if w > 0 && h > 0 { + let scale = gl_area.scale_factor() as f64; + let w = (alloc.width() as f64 * scale).round().max(0.0) as u32; + let h = (alloc.height() as f64 * scale).round().max(0.0) as u32; + (w, h) +} + +/// Push the current content scale and physical size into Ghostty. +/// `device_width` and `device_height` must be in physical (device) pixels, +/// not CSS/logical pixels. +fn refresh_surface_display( + surface: ghostty_surface_t, + gl_area: >k::GLArea, + device_width: u32, + device_height: u32, +) { + if device_width > 0 && device_height > 0 { let scale = gl_area.scale_factor() as f64; unsafe { ghostty_surface_set_content_scale(surface, scale, scale); - ghostty_surface_set_size(surface, w, h); + ghostty_surface_set_size(surface, device_width, device_height); } } unsafe { ghostty_surface_refresh(surface) }; @@ -434,7 +448,8 @@ fn refresh_realized_surface_display(surface: ghostty_surface_t, gl_area: >k::G unsafe { ghostty_surface_display_realized(surface) }; } } - refresh_surface_display(surface, gl_area); + let (w, h) = gl_area_device_size(gl_area); + refresh_surface_display(surface, gl_area, w, h); } fn clear_ghostty_preedit(surface: ghostty_surface_t) { @@ -1380,13 +1395,11 @@ pub fn create_terminal( } } - // Set initial size — GLArea gives unscaled CSS pixels, - // Ghostty handles scaling internally via content_scale. - let alloc = gl_area.allocation(); - let w = alloc.width() as u32; - let h = alloc.height() as u32; + // Set initial size — GLArea allocation is CSS (logical) pixels, + // but ghostty_surface_set_size expects physical (device) pixels. + let (w, h) = gl_area_device_size(gl_area); if w > 0 && h > 0 { - refresh_surface_display(surface, gl_area); + refresh_surface_display(surface, gl_area, w, h); } let surface_key = surface as usize; @@ -1483,7 +1496,8 @@ pub fn create_terminal( let w = width as u32; let h = height as u32; if w > 0 && h > 0 { - refresh_surface_display(surface, gl_area); + // GLArea::resize passes physical (device) pixels directly. + refresh_surface_display(surface, gl_area, w, h); } } @@ -1496,6 +1510,28 @@ pub fn create_terminal( }); } + // On scale-factor change: update Ghostty's content scale and size. + // GTK4 usually emits resize when the backing surface resizes, but + // fractional-scale transitions can leave the allocation unchanged + // while the device-pixel size changes, so we refresh explicitly here. + { + let surface_cell = surface_cell.clone(); + let last_device_size: Rc>> = Rc::new(Cell::new(None)); + gl_area.connect_scale_factor_notify(move |gl_area| { + if let Some(surface) = *surface_cell.borrow() { + let (w, h) = gl_area_device_size(gl_area); + if w > 0 && h > 0 { + let size = (w, h); + if last_device_size.get() == Some(size) { + return; + } + last_device_size.set(Some(size)); + refresh_surface_display(surface, gl_area, w, h); + } + } + }); + } + // Keyboard input // // Send key events with the text field populated. Ghostty uses the diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index d089b3b5..00d1e728 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -5098,10 +5098,6 @@ fn split_pane( Some(new_pane.upcast()) } -fn remove_pane(state: &State, ws_id: &str, pane_widget: >k::Widget) { - remove_pane_internal(state, ws_id, pane_widget, true); -} - fn remove_pane_internal(state: &State, ws_id: &str, pane_widget: >k::Widget, persist: bool) { let container = { let s = state.borrow(); @@ -5386,15 +5382,19 @@ fn cycle_focused_pane_tab(state: &State, delta: i32) { } fn close_focused_tab(state: &State) { - if let Some((ws_id, pane_widget)) = find_focused_pane(state) { - let parent = pane_widget.parent(); - // If this is the only pane (parent is Stack), don't close — keep workspace alive - if let Some(ref p) = parent { - if p.downcast_ref::().is_some() { - return; - } + let Some((_ws_id, pane_widget)) = find_focused_pane(state) else { + return; + }; + // If this is the only pane in the workspace, keep it alive when it has + // a single tab — mirroring the original guard that prevented removing the + // last pane and tearing down the workspace. + if let Some(ref p) = pane_widget.parent() { + if p.downcast_ref::().is_some() && pane::tab_count_in_pane(&pane_widget) <= 1 { + return; } - remove_pane(state, &ws_id, &pane_widget); + } + if let Some(tab_id) = pane::active_tab_in_pane(&pane_widget) { + pane::close_tab_in_pane(&pane_widget, &tab_id); } }