From bd4da47abb4b14b38689b7ad6a9bfe063bf69dea Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Jul 2026 10:53:55 -0700 Subject: [PATCH 1/2] fix: firefox now scales properly --- .../src/compositor/handlers.rs | 60 ++++++++++++++++++- .../src/compositor/mod.rs | 15 +++++ .../src/compositor/output.rs | 18 ++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs index e5446cf..1e11a43 100644 --- a/crates/hearthspace-compositor/src/compositor/handlers.rs +++ b/crates/hearthspace-compositor/src/compositor/handlers.rs @@ -10,14 +10,16 @@ use smithay::{ pointer::{CursorImageStatus, Focus}, }, reexports::{calloop::Interest, wayland_server::protocol::wl_seat}, - utils::{Serial, Size}, + utils::{Point, Serial, Size}, wayland::{ buffer::BufferHandler, compositor::{ BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, - SurfaceAttributes, add_blocker, add_pre_commit_hook, with_states, + SurfaceAttributes, TraversalAction, add_blocker, add_pre_commit_hook, with_states, + with_surface_tree_downward, }, dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, get_dmabuf}, + fractional_scale::{FractionalScaleHandler, with_fractional_scale}, output::OutputHandler, selection::{ SelectionHandler, @@ -475,3 +477,57 @@ impl SeatHandler for App { self.request_redraw(); } } + +impl FractionalScaleHandler for App { + fn new_fractional_scale(&mut self, surface: WlSurface) { + // Advertise the preferred fractional scale so HiDPI toolkits (GTK, + // Firefox) render their buffers at the monitor scale and declare their + // logical size through `wp_viewport`, instead of rendering an oversized + // integer-scaled buffer that the compositor would then scale again. + let scale = self.preferred_fractional_scale(&surface); + with_states(&surface, |states| { + with_fractional_scale(states, |fractional_scale| { + fractional_scale.set_preferred_scale(scale); + }); + }); + } +} + +impl App { + /// The fractional scale to advertise for `surface`: the integer scale of the + /// output its window sits on, or the primary output's scale when the surface + /// is not (yet) a managed window. + pub(super) fn preferred_fractional_scale(&self, surface: &WlSurface) -> f64 { + let scale = self + .window_index_for_surface(surface) + .map(|index| { + let position = self.windows[index].position; + let screen = self + .canvas_to_screen(Point::from((f64::from(position.x), f64::from(position.y)))); + self.output_scale_at(screen) + }) + .unwrap_or_else(|| self.primary_output_scale()); + f64::from(scale.max(1)) + } + + /// Re-advertise the preferred fractional scale for every managed window's + /// surface tree. Called when the output layout (and thus per-output scale) + /// changes so already-mapped clients update their buffers. + pub(super) fn refresh_fractional_scales(&self) { + for window in &self.windows { + let root = window.surface.wl_surface(); + let scale = self.preferred_fractional_scale(root); + with_surface_tree_downward( + root, + (), + |_, _, _| TraversalAction::DoChildren(()), + |_, states, _| { + with_fractional_scale(states, |fractional_scale| { + fractional_scale.set_preferred_scale(scale); + }); + }, + |_, _, _| true, + ); + } + } +} diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 3782271..ba2dd61 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -71,11 +71,13 @@ use smithay::{ wayland::{ compositor::CompositorState, dmabuf::{DmabufFeedbackBuilder, DmabufGlobal, DmabufState, ImportNotifier}, + fractional_scale::FractionalScaleManagerState, output::OutputManagerState, selection::data_device::DataDeviceState, shell::xdg::{ToplevelSurface, XdgShellState, decoration::XdgDecorationState}, shm::ShmState, socket::ListeningSocketSource, + viewporter::ViewporterState, }, }; use tracing::{debug, error, info}; @@ -160,6 +162,14 @@ struct App { xdg_shell_state: XdgShellState, _xdg_decoration_state: XdgDecorationState, _output_manager_state: OutputManagerState, + /// `wp_viewporter`: lets clients declare a surface's logical destination + /// size independently of its buffer, which HiDPI toolkits (GTK/Firefox) use + /// together with fractional scale to avoid rendering an oversized buffer. + _viewporter_state: ViewporterState, + /// `wp_fractional_scale_v1`: advertises the preferred scale to clients so + /// they render buffers at the monitor scale instead of falling back to a + /// mismatched integer buffer scale. + _fractional_scale_manager_state: FractionalScaleManagerState, shm_state: ShmState, seat_state: SeatState, data_device_state: DataDeviceState, @@ -229,6 +239,7 @@ impl App { if refresh.output_layout_changed { self.configure_shell_bars(); self.reconcile_pointer_after_output_geometry_change(); + self.refresh_fractional_scales(); } if self.background_mode != settings.background_mode { @@ -438,6 +449,8 @@ pub(in crate::compositor) fn initialize_app( let compositor_state = CompositorState::new::(&dh); let xdg_decoration_state = XdgDecorationState::new::(&dh); let output_manager_state = OutputManagerState::new_with_xdg_output::(&dh); + let viewporter_state = ViewporterState::new::(&dh); + let fractional_scale_manager_state = FractionalScaleManagerState::new::(&dh); let shm_state = ShmState::new::(&dh, vec![]); let mut seat_state = SeatState::new(); let mut seat = seat_state.new_wl_seat(&dh, "hearthspace"); @@ -475,6 +488,8 @@ pub(in crate::compositor) fn initialize_app( ), _xdg_decoration_state: xdg_decoration_state, _output_manager_state: output_manager_state, + _viewporter_state: viewporter_state, + _fractional_scale_manager_state: fractional_scale_manager_state, shm_state, seat_state, data_device_state: DataDeviceState::new::(&dh), diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index f20094a..7682cd9 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -295,6 +295,17 @@ impl App { self.outputs.primary.scale.max(1) } + /// The integer scale of whichever output contains `point` (in logical + /// screen space), falling back to the primary output's scale. Used to pick + /// the preferred fractional scale advertised to a surface's clients. + pub(super) fn output_scale_at(&self, point: Point) -> i32 { + std::iter::once(&self.outputs.primary) + .chain(self.outputs.secondary.iter()) + .find(|output| point_in_rect(point, output.logical_rect())) + .map(|output| output.scale.max(1)) + .unwrap_or_else(|| self.primary_output_scale()) + } + pub(in crate::compositor) fn set_output_layout(&mut self, layout: OutputLayout) -> bool { self.outputs.set_layout(layout) } @@ -516,6 +527,13 @@ fn scaled_client_dimension(size: i32, scale: i32) -> i32 { ((size + scale - 1) / scale).max(1) } +fn point_in_rect(point: Point, rect: Rectangle) -> bool { + point.x >= f64::from(rect.loc.x) + && point.y >= f64::from(rect.loc.y) + && point.x < f64::from(rect.loc.x + rect.size.w) + && point.y < f64::from(rect.loc.y + rect.size.h) +} + fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { a.loc.x < b.loc.x + b.size.w && a.loc.x + a.size.w > b.loc.x -- 2.51.2 From b2f764f17815ef3ed2bab4322aae6ad010867d47 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 5 Jul 2026 11:06:12 -0700 Subject: [PATCH 2/2] fix: recalcs firefox scaling on monitor move --- .../src/compositor/handlers.rs | 44 +++++++++++-------- .../src/compositor/mod.rs | 5 +++ .../src/compositor/rendering.rs | 1 + 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs index 1e11a43..62c53cc 100644 --- a/crates/hearthspace-compositor/src/compositor/handlers.rs +++ b/crates/hearthspace-compositor/src/compositor/handlers.rs @@ -83,6 +83,7 @@ impl XdgShellHandler for App { decoration: decoration_for_new_window(kind), titlebar: None, content_bbox_size: Size::default(), + advertised_fractional_scale: 0.0, }); if kind == ManagedWindowKind::Normal { self.idle_daemon.register_window(id); @@ -498,27 +499,34 @@ impl App { /// output its window sits on, or the primary output's scale when the surface /// is not (yet) a managed window. pub(super) fn preferred_fractional_scale(&self, surface: &WlSurface) -> f64 { - let scale = self - .window_index_for_surface(surface) - .map(|index| { - let position = self.windows[index].position; - let screen = self - .canvas_to_screen(Point::from((f64::from(position.x), f64::from(position.y)))); - self.output_scale_at(screen) - }) - .unwrap_or_else(|| self.primary_output_scale()); - f64::from(scale.max(1)) + self.window_index_for_surface(surface) + .map(|index| self.window_fractional_scale(index)) + .unwrap_or_else(|| f64::from(self.primary_output_scale().max(1))) } - /// Re-advertise the preferred fractional scale for every managed window's - /// surface tree. Called when the output layout (and thus per-output scale) - /// changes so already-mapped clients update their buffers. - pub(super) fn refresh_fractional_scales(&self) { - for window in &self.windows { - let root = window.surface.wl_surface(); - let scale = self.preferred_fractional_scale(root); + /// The scale of the output the window at `index` currently sits on. + fn window_fractional_scale(&self, index: usize) -> f64 { + let position = self.windows[index].position; + let screen = + self.canvas_to_screen(Point::from((f64::from(position.x), f64::from(position.y)))); + f64::from(self.output_scale_at(screen).max(1)) + } + + /// Re-advertise the preferred fractional scale for every managed window whose + /// output scale changed since the last frame. Runs each frame so a window + /// dragged onto a differently-scaled monitor re-renders at the new scale, and + /// is also triggered on output-layout changes. The surface tree is only + /// walked when the scale actually changes, so the steady state is cheap. + pub(super) fn refresh_fractional_scales(&mut self) { + for index in 0..self.windows.len() { + let scale = self.window_fractional_scale(index); + if (self.windows[index].advertised_fractional_scale - scale).abs() < f64::EPSILON { + continue; + } + self.windows[index].advertised_fractional_scale = scale; + let root = self.windows[index].surface.wl_surface().clone(); with_surface_tree_downward( - root, + &root, (), |_, _, _| TraversalAction::DoChildren(()), |_, states, _| { diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index ba2dd61..4fd7a96 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -97,6 +97,11 @@ struct ManagedWindow { /// Bounding-box size of the window's surface tree, cached on commit so the /// per-frame rendering and hit-testing paths don't re-walk the tree. content_bbox_size: Size, + /// Fractional scale most recently advertised to this window's surface tree. + /// Compared each frame so the preferred scale is only re-sent when the + /// window moves onto an output with a different scale (e.g. dragged across + /// monitors). `0.0` is a sentinel meaning "nothing advertised yet". + advertised_fractional_scale: f64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/hearthspace-compositor/src/compositor/rendering.rs b/crates/hearthspace-compositor/src/compositor/rendering.rs index af90f5f..d545559 100644 --- a/crates/hearthspace-compositor/src/compositor/rendering.rs +++ b/crates/hearthspace-compositor/src/compositor/rendering.rs @@ -140,6 +140,7 @@ impl App { output_rect: Rectangle, ) -> Vec { self.refresh_normal_window_outputs(); + self.refresh_fractional_scales(); let mut elements = Vec::new(); for element in self.software_cursor_elements(renderer, output_rect) { -- 2.51.2