From baed9bc98c733a3b0bcc6871d5385eb9d325cee6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 11:41:30 +0300 Subject: [PATCH] wayland: render at fractional scale via viewporter, integer fallback Signed-off-by: NotAShelf Change-Id: I930684c15213a3e3b7de6b74dfb9da076a6a6964 --- Cargo.lock | 1 + Cargo.toml | 5 + src/wayland.rs | 245 ++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 218 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69ecaff..cea4340 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ dependencies = [ "unicode-width", "vte", "wayland-client", + "wayland-protocols", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 81c909d..e63dc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,11 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2.2" vte = "0.15.0" wayland-client = "0.31.14" +wayland-protocols = { version = "0.32.13", features = [ + "client", + "staging", + "unstable", +] } [lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/src/wayland.rs b/src/wayland.rs index 71aed49..6808332 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -70,14 +70,21 @@ use smithay_client_toolkit::{ }, }; use wayland_client::{ - Connection, QueueHandle, - globals::registry_queue_init, + Connection, Dispatch, Proxy, QueueHandle, + globals::{GlobalList, registry_queue_init}, protocol::{ wl_data_device::WlDataDevice, wl_data_device_manager::DndAction, wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface, }, }; +use wayland_protocols::wp::fractional_scale::v1::client::{ + wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1, + wp_fractional_scale_v1::{self, WpFractionalScaleV1}, +}; +use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, +}; /// MIME types beer offers and accepts for clipboard text. const TEXT_MIMES: &[&str] = &[ @@ -195,6 +202,20 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R window.set_title("beer"); window.set_app_id("dev.notashelf.beer"); window.set_min_size(Some((1, 1))); + + // Decorrelate buffer pixels from surface size so we can render at the + // compositor's preferred fractional scale (crisp glyphs on a 150% output) + // and present at the logical size. Both are optional; without them the + // window falls back to integer buffer scaling via `scale_factor_changed`. + let viewport = bind_global::(&globals, &qh) + .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ())); + // Fractional scaling needs a viewport to present the scaled buffer back at + // the logical size; without one we can only do integer buffer scaling. + let fractional_scale = viewport.as_ref().and_then(|_| { + bind_global::(&globals, &qh) + .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ())) + }); + // First commit with no buffer kicks off the initial configure. window.commit(); @@ -231,6 +252,9 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R primary_manager, cursor_shape_manager, cursor_shape_device: None, + viewport, + fractional_scale, + scale120: 120, data_device: None, primary_device: None, copy_source: None, @@ -312,6 +336,22 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R Ok(app.exit_code) } +/// Bind a singleton global at version 1 with `()` user-data, or `None` if the +/// compositor does not advertise it. +fn bind_global(globals: &GlobalList, qh: &QueueHandle) -> Option +where + I: Proxy + 'static, + App: Dispatch, +{ + match globals.bind(qh, 1..=1, ()) { + Ok(global) => Some(global), + Err(err) => { + tracing::debug!("bind {}: {err}", I::interface().name); + None + } + } +} + /// Columns and rows that fit a `width`×`height` px window at `metrics`, after /// reserving `2 * pad` pixels of inner padding on each axis. fn grid_size( @@ -363,6 +403,12 @@ struct App { /// Sets the pointer to an I-beam over the window (cursor-shape-v1). cursor_shape_manager: Option, cursor_shape_device: Option, + /// Presents a scaled buffer at the logical surface size (viewporter). + viewport: Option, + /// Per-surface fractional-scale object; kept alive to receive scale events. + fractional_scale: Option, + /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5). + scale120: u32, data_device: Option, primary_device: Option, /// Held while we own the clipboard / primary selection, serving paste reads. @@ -432,12 +478,7 @@ struct App { impl App { /// Spawn the shell at the current window size and start reading its output. fn spawn_session(&mut self) { - let (cols, rows) = grid_size( - self.renderer.metrics(), - self.width, - self.height, - (self.config.main.pad_x, self.config.main.pad_y), - ); + let (cols, rows) = self.grid_dims(); let pty = match Pty::spawn(cols, rows, &self.config.main.term) { Ok(pty) => pty, Err(err) => { @@ -596,13 +637,17 @@ impl App { let new = Config::load(self.config_path.as_deref()); self.bindings = crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings); - self.renderer.set_padding(new.main.pad_x, new.main.pad_y); - if new.main.font != self.config.main.font || new.main.font_size != self.font_size { - match self.renderer.set_font(&new.main.font, new.main.font_size) { - Ok(()) => self.font_size = new.main.font_size, - Err(err) => tracing::warn!("reload font: {err:#}"), - } + let font_changed = new.main.font != self.config.main.font + || new.main.font_size != self.config.main.font_size; + if font_changed { + self.font_size = new.main.font_size; } + self.config.main.font = new.main.font.clone(); + self.config.main.font_size = new.main.font_size; + self.config.main.pad_x = new.main.pad_x; + self.config.main.pad_y = new.main.pad_y; + // Re-rasterize at the active scale and update padding in one place. + self.rescale_render(); if let Some(session) = self.session.as_mut() { session .term @@ -628,11 +673,8 @@ impl App { if new_size == self.font_size { return; } - if let Err(err) = self.renderer.set_font(&self.config.main.font, new_size) { - tracing::warn!("resize font: {err:#}"); - return; - } self.font_size = new_size; + self.rescale_render(); self.frames.clear(); self.resize_grid(); self.needs_draw = true; @@ -687,19 +729,86 @@ impl App { self.needs_draw = true; } - /// Inner padding `(x, y)` in pixels. + /// Scale a logical pixel length to physical (buffer) pixels at the current + /// fractional scale, rounding to nearest. + fn to_phys(&self, v: u32) -> u32 { + ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32 + } + + /// Physical (buffer) pixel size at the current scale. + fn phys_dims(&self) -> (u32, u32) { + ( + self.to_phys(self.width).max(1), + self.to_phys(self.height).max(1), + ) + } + + /// Columns and rows for the current physical size, metrics, and padding. + /// Scale-invariant: every term scales together so the cell count is stable. + fn grid_dims(&self) -> (u16, u16) { + let (pw, ph) = self.phys_dims(); + grid_size( + self.renderer.metrics(), + pw, + ph, + ( + self.to_phys(self.config.main.pad_x), + self.to_phys(self.config.main.pad_y), + ), + ) + } + + /// Re-rasterize the font and padding at the current scale × the logical + /// font size, so glyphs are crisp at fractional scales. + fn rescale_render(&mut self) { + self.renderer.set_padding( + self.to_phys(self.config.main.pad_x), + self.to_phys(self.config.main.pad_y), + ); + let px = self.to_phys(self.font_size).max(1); + if let Err(err) = self.renderer.set_font(&self.config.main.font, px) { + tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120); + } + } + + /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid + /// geometry, and update the viewport so the logical size stays put. + fn set_scale(&mut self, scale120: u32) { + let scale120 = scale120.max(1); + if scale120 == self.scale120 { + return; + } + self.scale120 = scale120; + self.rescale_render(); + self.frames.clear(); + self.buf_dims = (0, 0); + if let Some(vp) = &self.viewport { + vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32); + } + self.resize_grid(); + self.needs_draw = true; + } + + /// Inner padding `(x, y)` in physical pixels (pointer coords are converted + /// to physical before use). fn padding(&self) -> (f64, f64) { ( - f64::from(self.config.main.pad_x), - f64::from(self.config.main.pad_y), + f64::from(self.to_phys(self.config.main.pad_x)), + f64::from(self.to_phys(self.config.main.pad_y)), ) } + /// Convert a logical surface coordinate to a physical buffer coordinate. + fn to_phys_f(&self, v: f64) -> f64 { + v * f64::from(self.scale120) / 120.0 + } + /// Map window pixel coordinates to an absolute `(row, col)` grid point. fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> { let session = self.session.as_ref()?; let m = self.renderer.metrics(); let (pad_x, pad_y) = self.padding(); + let (px, py) = (self.to_phys_f(px), self.to_phys_f(py)); let grid = session.term.grid(); let col = ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1)); @@ -825,10 +934,14 @@ impl App { let session = self.session.as_ref()?; let m = self.renderer.metrics(); let (pad_x, pad_y) = self.padding(); + let (ppx, ppy) = ( + self.to_phys_f(self.pointer_pos.0), + self.to_phys_f(self.pointer_pos.1), + ); let grid = session.term.grid(); - let col = ((self.pointer_pos.0 - pad_x).max(0.0) as usize / m.width as usize) - .min(grid.cols().saturating_sub(1)); - let row = ((self.pointer_pos.1 - pad_y).max(0.0) as usize / m.height as usize) + let col = + ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1)); + let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize) .min(grid.rows().saturating_sub(1)); Some((col, row)) } @@ -1135,12 +1248,7 @@ impl App { /// Recompute the grid size for the current window and tell the grid and the /// PTY about it if it changed. fn resize_grid(&mut self) { - let (cols, rows) = grid_size( - self.renderer.metrics(), - self.width, - self.height, - (self.config.main.pad_x, self.config.main.pad_y), - ); + let (cols, rows) = self.grid_dims(); let Some(session) = self.session.as_mut() else { return; }; @@ -1213,7 +1321,9 @@ impl App { /// them, damage just those rows, and commit with a frame-callback request. fn present(&mut self) { self.needs_draw = false; - let (w, h) = (self.width, self.height); + // Render into a buffer sized in physical pixels (logical × scale); the + // viewport presents it back at the logical surface size. + let (w, h) = self.phys_dims(); let m = self.renderer.metrics(); let (focused, blink_on) = (self.focused, self.blink_on); @@ -1299,7 +1409,7 @@ impl App { // A buffer used for the first time has uninitialized margins; paint the // whole thing (background + padding) once, then damage it in full below. let fresh = self.frames[idx].rows.is_empty(); - let pad_y = self.config.main.pad_y as i32; + let pad_y = self.to_phys(self.config.main.pad_y) as i32; let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else { return; }; @@ -1329,6 +1439,14 @@ impl App { tracing::error!("attach buffer: {err}"); return; } + // With a viewport the buffer is presented at the logical destination, so + // its own scale stays 1; without one, fall back to integer buffer scale. + if let Some(vp) = &self.viewport { + surface.set_buffer_scale(1); + vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32); + } else { + surface.set_buffer_scale((self.scale120 / 120).max(1) as i32); + } if fresh { surface.damage_buffer(0, 0, w as i32, h as i32); } else { @@ -1349,8 +1467,13 @@ impl CompositorHandler for App { _: &Connection, _: &QueueHandle, _: &wl_surface::WlSurface, - _: i32, + factor: i32, ) { + // Integer fallback for compositors without fractional-scale-v1; ignored + // when the fractional-scale object drives the scale instead. + if self.fractional_scale.is_none() { + self.set_scale((factor.max(1) as u32) * 120); + } } fn transform_changed( @@ -1403,6 +1526,9 @@ impl WindowHandler for App { if let (Some(w), Some(h)) = configure.new_size { self.width = w.get(); self.height = h.get(); + if let Some(vp) = &self.viewport { + vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32); + } } self.focused = configure.is_activated(); if self.session.is_none() { @@ -1854,6 +1980,59 @@ impl PrimarySelectionSourceHandler for App { } } +// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by +// hand. Only the fractional-scale object carries an event we act on. +impl Dispatch for App { + fn event( + state: &mut Self, + _: &WpFractionalScaleV1, + event: wp_fractional_scale_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event { + state.set_scale(scale); + } + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpFractionalScaleManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpViewporter, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpViewport, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + delegate_compositor!(App); delegate_output!(App); delegate_shm!(App);