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",