From 88f69ebffed735a88a5a3222dd6963d1c18d84b2 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Wed, 30 Sep 2026 10:17:22 +0200 Subject: [PATCH] A horizontal swipe commits on a fling; `App::overlay_namespace`; flat Quick Settings slots in the default theme; the on-screen keyboard's rows twice as far apart, with presses between keys kept; `LTK_PERF_RASTER` Flings. A horizontal swipe committed only past `swipe_horizontal_threshold` of the surface width, so a quick flick that stopped short, the usual way to turn a page on a phone, snapped back. The new `input/gesture/fling.rs` holds a `FlingTracker` that measures the finger's speed along one axis over the last 100 ms from the events' own timestamps, so a finger that rested before lifting reads as still. `GestureState` keeps one for the x axis, `fling_x`, cleared on every press; the touch handler feeds it on down, motion and up with the `wl_touch` time, which it used to ignore. On release a horizontal swipe also commits when the finger leaves at 500 logical px/s or more in the direction of travel after at least 24 px, and does not commit when it leaves that fast the other way, whatever the distance; both thresholds scale with the surface. Pointer drags and the vertical swipes keep the distance rule, and the gesture tests, which never feed the tracker, are unchanged. Two tests cover the speed and a rest before lifting. `App::overlay_namespace`. Every layer overlay was created with the namespace `ltk-overlay`, so a compositor could not tell one panel from another. The new method returns the namespace of overlay `id`, read when its surface is created, and `None`, the default, keeps `ltk-overlay`. It follows `overlay_title` and `overlay_opaque_region` and breaks no app. Crustace names its Quick Settings and launcher overlays with it, and forge draws a blurred backdrop behind them. Flat Quick Settings slots. The default theme gains `surface-qs-tile`, `surface-qs-tile-active`, `surface-qs-slider-track` and `surface-qs-slider-fill` in both modes: a solid translucent fill, white or navy for the tiles and tracks and cyan for the active tile and the fill, with no shadows, no inset shadows and no gradients. The glass slots the panel used put a blurred shadow, one to five inset shadows, some with blend modes, and a gradient under each of its nineteen surfaces, all rasterised on every frame of its slide. The shared slots are untouched, so the dock and other applications look as before. The on-screen keyboard. The rows are now `ROW_GAP`, twice `GAP`, apart, 12 logical px against the 6 between keys; `height` counts it, so the panel grows by 18 to 24 px depending on its rows and a host that reserves its height follows. A press between keys, which the wider gap would have made more common, no longer goes nowhere: `key_at` gives it to the nearest key within one row gap. `LTK_PERF_RASTER`. With it set, every GLES raster, full, partial or subsurface, logs its surface size, the time to submit and, after a `glFinish`, the time until the GPU finished. The `glFinish` stalls the pipeline, so it is for measuring only; it gave the cost of the old Quick Settings panel, 27 to 40 ms a raster on the Librem 5 with peaks of 57 to 143 ms. --- src/app.rs | 5 ++ src/draw/gles.rs | 5 ++ src/event_loop/overlays_reconcile.rs | 2 +- src/event_loop/perf.rs | 34 ++++++++++++ src/event_loop/subsurface.rs | 2 + src/input/gesture/fling.rs | 77 ++++++++++++++++++++++++++++ src/input/gesture/mod.rs | 14 ++++- src/input/touch/mod.rs | 10 ++-- src/osk/mod.rs | 35 +++++++++---- themes/default/theme.json | 48 +++++++++++++++++ 10 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 src/input/gesture/fling.rs diff --git a/src/app.rs b/src/app.rs index 1b41775..6d80b52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -561,6 +561,11 @@ pub trait App: 'static /// in the compositor's window lists. Default: the [`app_id`](Self::app_id). fn overlay_title( &self, _id: OverlayId ) -> Option { None } + /// wlr-layer-shell namespace of a layer overlay, so the compositor can + /// tell it apart from the rest. Read when the surface is created. + /// Default: `"ltk-overlay"`. + fn overlay_namespace( &self, _id: OverlayId ) -> Option<&'static str> { None } + /// Describe the input-transparent child surfaces composited over the main /// surface this frame. Each becomes a `wl_subsurface` the compositor moves /// by position; see [`SubsurfaceSpec`]. Diffed across frames by diff --git a/src/draw/gles.rs b/src/draw/gles.rs index b2765cb..fe66351 100644 --- a/src/draw/gles.rs +++ b/src/draw/gles.rs @@ -25,6 +25,7 @@ use smithay_client_toolkit::compositor::CompositorState; use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; use crate::egl_context::EglContext; +use crate::event_loop::perf::RasterTrace; use crate::event_loop::SurfaceState; use crate::render::Canvas; use crate::types::{ Color, Rect }; @@ -61,6 +62,7 @@ pub( crate ) fn draw_surface_full_gpu( { let Some( es ) = ss.egl_surface.as_ref() else { return }; if egl_ctx.make_current( es ).is_err() { return; } + let trace = RasterTrace::begin(); let canvas = ss.canvas.get_or_insert_with( || { @@ -112,6 +114,7 @@ pub( crate ) fn draw_surface_full_gpu( draw_fallback_banner( canvas, pw, sf ); canvas.present(); + if let Some( t ) = trace { t.end( egl_ctx.gl(), "full", pw, ph ); } commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); ss.content_dirty = false; @@ -152,6 +155,7 @@ pub( crate ) fn draw_surface_partial_gpu( { let Some( es ) = ss.egl_surface.as_ref() else { return }; if egl_ctx.make_current( es ).is_err() { return; } + let trace = RasterTrace::begin(); let canvas = ss.canvas.as_mut().expect( "partial path requires existing canvas" ); if canvas.size() != ( pw, ph ) @@ -189,6 +193,7 @@ pub( crate ) fn draw_surface_partial_gpu( canvas.clear_clip(); canvas.present(); + if let Some( t ) = trace { t.end( egl_ctx.gl(), "partial", pw, ph ); } commit_draw_ctx( &mut ss.frame, ss.focused_idx, ss.hovered_idx, ss.gesture.pressed_idx, ctx ); ss.content_dirty = false; diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index 2559527..d5ee1da 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -360,7 +360,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) anchor: spec.anchor, size: to_logical_size( resolved_size ), keyboard_exclusive: spec.keyboard_exclusive, - namespace: "ltk-overlay", + namespace: app.overlay_namespace( spec.id ).unwrap_or( "ltk-overlay" ), }; let mut surface = SurfaceKind::Pending( cfg ); if let Some( ref output ) = output_opt diff --git a/src/event_loop/perf.rs b/src/event_loop/perf.rs index d78e323..2c70654 100644 --- a/src/event_loop/perf.rs +++ b/src/event_loop/perf.rs @@ -37,6 +37,40 @@ fn perf_warn_enabled() -> bool ) } +/// Wall time of one GLES raster, split at submission and at GPU completion. +/// Opt-in via `LTK_PERF_RASTER`; the `glFinish` it adds stalls the pipeline. +pub( crate ) struct RasterTrace +{ + start: Instant, +} + +impl RasterTrace +{ + pub( crate ) fn begin() -> Option + { + static ENABLED: OnceLock = OnceLock::new(); + let enabled = *ENABLED.get_or_init( || + std::env::var( "LTK_PERF_RASTER" ) + .map( |v| !v.is_empty() && v != "0" ) + .unwrap_or( false ) + ); + enabled.then( || Self { start: Instant::now() } ) + } + + pub( crate ) fn end( self, gl: &glow::Context, label: &str, pw: u32, ph: u32 ) + { + use glow::HasContext; + let submitted = self.start.elapsed(); + unsafe { gl.finish(); } + let done = self.start.elapsed(); + eprintln!( + "[ltk][raster] {label} {pw}x{ph} cpu={:.2}ms gpu_done={:.2}ms", + submitted.as_secs_f64() * 1000.0, + done.as_secs_f64() * 1000.0, + ); + } +} + /// Per-runtime performance-guard state. Lives on `AppData`. pub struct PerfState { diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs index 31df86b..6301782 100644 --- a/src/event_loop/subsurface.rs +++ b/src/event_loop/subsurface.rs @@ -395,6 +395,7 @@ fn render_slot_gpu( } let es = slot.egl_surface.as_ref().unwrap(); if egl_ctx.make_current( es ).is_err() { return; } + let trace = super::perf::RasterTrace::begin(); // The canvas (and its compiled shader programs) is shared across all // subsurface rasters, so a lazily re-created panel doesn't recompile. @@ -420,6 +421,7 @@ fn render_slot_gpu( let mut ctx = slot_draw_ctx::( &mut slot.scroll_canvases, &mut slot.viewport_canvases ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); canvas.present(); + if let Some( t ) = trace { t.end( egl_ctx.gl(), "subsurface", pw, ph ); } slot.scroll_canvases = ctx.scroll_canvases; slot.viewport_canvases = ctx.viewport_canvases; diff --git a/src/input/gesture/fling.rs b/src/input/gesture/fling.rs new file mode 100644 index 0000000..7df57eb --- /dev/null +++ b/src/input/gesture/fling.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Release velocity of a swipe, so a quick flick commits it whatever its +//! travel and a quick flick back cancels it. + +use std::collections::VecDeque; + +/// Release speed, logical px/s, that decides a swipe on its own. +pub const FLING_VELOCITY: f32 = 500.0; +/// Travel a forward fling needs, logical px, so a twitch does not commit. +pub const FLING_MIN_TRAVEL: f32 = 24.0; + +/// Speed of a finger along one axis over the last `WINDOW_MS` of input, +/// from the events' own timestamps. A finger that rested before lifting +/// has no samples left in the window and reads as still. +#[ derive( Debug, Clone, Default ) ] +pub struct FlingTracker +{ + samples: VecDeque<( u32, f32 )>, +} + +impl FlingTracker +{ + const WINDOW_MS: u32 = 100; + + pub fn clear( &mut self ) { self.samples.clear(); } + + pub fn push( &mut self, time_ms: u32, pos: f32 ) + { + self.samples.push_back( ( time_ms, pos ) ); + while let Some( &( t, _ ) ) = self.samples.front() + { + if time_ms.wrapping_sub( t ) <= Self::WINDOW_MS { break; } + self.samples.pop_front(); + } + } + + /// Px/s in the unit of the pushed positions; 0 when the window spans + /// too little time to tell. + pub fn velocity( &self ) -> f32 + { + let ( Some( &( t0, p0 ) ), Some( &( t1, p1 ) ) ) = ( self.samples.front(), self.samples.back() ) else + { + return 0.0; + }; + let dt = t1.wrapping_sub( t0 ); + if dt < 8 { return 0.0; } + ( p1 - p0 ) * 1000.0 / dt as f32 + } +} + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + #[ test ] + fn measures_recent_speed() + { + let mut t = FlingTracker::default(); + t.push( 0, 0.0 ); + t.push( 20, 10.0 ); + t.push( 40, 20.0 ); + assert!( ( t.velocity() - 500.0 ).abs() < 1e-3 ); + } + + #[ test ] + fn forgets_a_rest_before_lifting() + { + let mut t = FlingTracker::default(); + t.push( 0, 0.0 ); + t.push( 20, 40.0 ); + t.push( 300, 40.0 ); + assert_eq!( t.velocity(), 0.0 ); + } +} diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index d1ad570..713da9f 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -38,8 +38,10 @@ use crate::tree::{ find_handlers, find_widget, find_widget_at }; use crate::types::{ Point, Rect }; use crate::widget::LaidOutWidget; +mod fling; mod outcomes; +pub( crate ) use fling::FlingTracker; pub( crate ) use outcomes::*; #[ cfg( test ) ] @@ -147,6 +149,8 @@ pub struct GestureState /// Pattern-grid node the finger was last over, so `on_node` fires /// once per node rather than once per motion event. pub last_pattern_node: Option, + /// Horizontal release velocity, fed by the touch handler with event times. + pub fling_x: FlingTracker, } impl Default for GestureState @@ -180,6 +184,7 @@ impl GestureState overscroll_y: 0.0, mouse_press: false, last_pattern_node: None, + fling_x: FlingTracker::default(), } } @@ -231,6 +236,7 @@ impl GestureState self.horizontal_drag_started = false; self.vertical_drag_started = false; self.swipe_axis = None; + self.fling_x.clear(); self.pressed_idx = hit; // A fresh press resets any stale long-press / source state // from a prior gesture that never released cleanly. The @@ -596,8 +602,14 @@ impl GestureState { let dx = pos.x - start.x; let width = swipe.surface_width; + let scale = swipe.scale.max( 1.0 ); + let v = self.fling_x.velocity(); + let fast = v.abs() >= fling::FLING_VELOCITY * scale; + let flung = fast && v.signum() == dx.signum() && dx.abs() >= fling::FLING_MIN_TRAVEL * scale; + let flung_back = fast && v.signum() != dx.signum(); let committed = width > 0 - && dx.abs() >= width as f32 * swipe.horizontal_thresh; + && !flung_back + && ( flung || dx.abs() >= width as f32 * swipe.horizontal_thresh ); if committed { self.start = None; diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index d20f45e..2fbfc7a 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -53,7 +53,7 @@ impl TouchHandler for AppData qh: &QueueHandle, _touch: &WlTouch, serial: u32, - _time: u32, + time: u32, surface: WlSurface, id: i32, position: ( f64, f64 ), @@ -157,6 +157,7 @@ impl TouchHandler for AppData { let ss = self.surface_mut( focus ); let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects ); + ss.gesture.fling_x.push( time, pos.x ); ss.needs_redraw = true; result }; @@ -222,7 +223,7 @@ impl TouchHandler for AppData _qh: &QueueHandle, _touch: &WlTouch, _serial: u32, - _time: u32, + time: u32, id: i32, ) { @@ -257,6 +258,8 @@ impl TouchHandler for AppData return; } + let x = self.pointer_pos.x; + self.surface_mut( focus ).gesture.fling_x.push( time, x ); self.release_primary( focus ); } @@ -265,7 +268,7 @@ impl TouchHandler for AppData _conn: &Connection, _qh: &QueueHandle, _touch: &WlTouch, - _time: u32, + time: u32, id: i32, position: ( f64, f64 ), ) @@ -308,6 +311,7 @@ impl TouchHandler for AppData let outcome = { let ss = self.surface_mut( focus ); + ss.gesture.fling_x.push( time, pp.x ); ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag ) }; self.apply_move_outcome( focus, outcome ); diff --git a/src/osk/mod.rs b/src/osk/mod.rs index 02ebac0..6119461 100644 --- a/src/osk/mod.rs +++ b/src/osk/mod.rs @@ -22,6 +22,7 @@ const REPEAT_INTERVAL: Duration = Duration::from_millis( 60 ); const SHIFT_DOUBLE_TAP: Duration = Duration::from_millis( 350 ); /// Design sizes in logical pixels; the panel multiplies them by its scale. const GAP: f32 = 6.0; +const ROW_GAP: f32 = 2.0 * GAP; const PADDING: f32 = 6.0; /// What the field being typed into is for; picks the extra row above the layout. @@ -224,7 +225,7 @@ impl Panel pub fn height( &self, width: f32, scale: f32 ) -> f32 { let rows = ( self.bar_rows().len() + self.layout().rows( &self.layer ).len() ) as f32; - rows * self.key_unit( width, scale ) + ( ( rows - 1.0 ).max( 0.0 ) * GAP + 2.0 * PADDING ) * scale + rows * self.key_unit( width, scale ) + ( ( rows - 1.0 ).max( 0.0 ) * ROW_GAP + 2.0 * PADDING ) * scale } /// Width of one key unit of the layout's rows, `width` pixels wide: the @@ -246,11 +247,12 @@ impl Panel self.rect = rect; self.scale = scale; self.keys.clear(); - let gap = GAP * scale; - let pad = PADDING * scale; - let key_h = self.key_unit( rect.width, scale ); - let avail = rect.width - 2.0 * pad; - let mut y = rect.y + pad; + let gap = GAP * scale; + let row_gap = ROW_GAP * scale; + let pad = PADDING * scale; + let key_h = self.key_unit( rect.width, scale ); + let avail = rect.width - 2.0 * pad; + let mut y = rect.y + pad; let bar: Vec> = self.bar_rows().to_vec(); for row in &bar @@ -258,7 +260,7 @@ impl Panel let units: f32 = row.iter().map( |k| k.width ).sum::().max( 1.0 ); let unit = ( avail - gap * row.len().saturating_sub( 1 ) as f32 ) / units; self.push_row( row, rect.x + pad, y, unit, key_h, gap ); - y += key_h + gap; + y += key_h + row_gap; } let rows: Vec> = self.layout().rows( &self.layer ).iter() @@ -270,7 +272,7 @@ impl Panel let units: f32 = row.iter().map( |k| k.width ).sum(); let row_w = units * unit + ( row.len().saturating_sub( 1 ) ) as f32 * gap; self.push_row( row, rect.x + pad + ( avail - row_w ) / 2.0, y, unit, key_h, gap ); - y += key_h + gap; + y += key_h + row_gap; } self.popup = None; } @@ -297,7 +299,22 @@ impl Panel fn key_at( &self, pos: Point ) -> Option { - self.keys.iter().position( |k| k.rect.contains( pos ) ) + if let Some( idx ) = self.keys.iter().position( |k| k.rect.contains( pos ) ) + { + return Some( idx ); + } + let reach = ROW_GAP * self.scale; + self.keys.iter() + .enumerate() + .map( |( i, k )| + { + let dx = ( k.rect.x - pos.x ).max( pos.x - ( k.rect.x + k.rect.width ) ).max( 0.0 ); + let dy = ( k.rect.y - pos.y ).max( pos.y - ( k.rect.y + k.rect.height ) ).max( 0.0 ); + ( i, dx.hypot( dy ) ) + } ) + .filter( |&( _, d )| d <= reach ) + .min_by( |a, b| a.1.total_cmp( &b.1 ) ) + .map( |( i, _ )| i ) } /// A press at `pos`; `None` when it is outside the panel and the host should treat it as its own. diff --git a/themes/default/theme.json b/themes/default/theme.json index ca5e278..b3de1a4 100644 --- a/themes/default/theme.json +++ b/themes/default/theme.json @@ -184,6 +184,30 @@ "meta": { "semantic": "effect/glass/panel" } }, + "surface-qs-tile": { + "type": "surface", + "fill": { "type": "solid", "color": "@white/B3" }, + "meta": { "semantic": "effect/qs/tile" } + }, + + "surface-qs-tile-active": { + "type": "surface", + "fill": { "type": "solid", "color": "@cyan" }, + "meta": { "semantic": "effect/qs/tile-active" } + }, + + "surface-qs-slider-track": { + "type": "surface", + "fill": { "type": "solid", "color": "@white/80" }, + "meta": { "semantic": "effect/qs/slider-track" } + }, + + "surface-qs-slider-fill": { + "type": "surface", + "fill": { "type": "solid", "color": "@cyan" }, + "meta": { "semantic": "effect/qs/slider-fill" } + }, + "surface-card-flat": { "type": "surface", "fill": { @@ -305,6 +329,30 @@ "meta": { "semantic": "effect/glass/panel" } }, + "surface-qs-tile": { + "type": "surface", + "fill": { "type": "solid", "color": "@navy/B3" }, + "meta": { "semantic": "effect/qs/tile" } + }, + + "surface-qs-tile-active": { + "type": "surface", + "fill": { "type": "solid", "color": "@cyan" }, + "meta": { "semantic": "effect/qs/tile-active" } + }, + + "surface-qs-slider-track": { + "type": "surface", + "fill": { "type": "solid", "color": "@navy/80" }, + "meta": { "semantic": "effect/qs/slider-track" } + }, + + "surface-qs-slider-fill": { + "type": "surface", + "fill": { "type": "solid", "color": "@cyan" }, + "meta": { "semantic": "effect/qs/slider-fill" } + }, + "surface-card-flat": { "type": "surface", "fill": "@surface-glass-dark",