refactor: split every monolithic module into focused submodules

Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::tree::find_handlers;
impl<A: App> AppData<A>
{
/// Copy the currently-selected text into the process-local
/// clipboard. No-op when there is no selection.
pub( crate ) fn handle_copy( &mut self, focus: SurfaceFocus )
{
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
}
}
/// Copy + delete: the selected text lands in the clipboard, then
/// the range is removed from the value.
pub( crate ) fn handle_cut( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
if let Some( text ) = self.focused_selection_text( focus )
{
self.clipboard = text;
}
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
}
}
/// Insert the clipboard contents at the cursor (replacing the
/// selection if there is one). No-op when the clipboard is empty.
pub( crate ) fn handle_paste( &mut self, focus: SurfaceFocus )
{
if self.clipboard.is_empty() { return; }
// Carbon-copy of the clipboard to break the borrow on `self`
// before `handle_text_insert` runs.
let text = self.clipboard.clone();
self.handle_text_insert( focus, &text );
}
}
+189
View File
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::tree::find_handlers;
use crate::types::{ Point, Rect };
/// Built-in context menu for text editing — Copy / Cut / Paste rows
/// drawn on top of the surface content. Shown on a right-click (or
/// long-press) in a [`crate::widget::text_edit::TextEdit`]; dismissed
/// by clicking outside or selecting an action. Lives on
/// [`super::surface::SurfaceState`] so a press inside an overlay shows
/// its own menu in overlay-local coordinates.
#[ derive( Clone, Debug ) ]
pub( crate ) struct ContextMenu
{
/// `widget_rects` index of the TextEdit the menu targets. Used by
/// the action dispatch so we operate on the right field even
/// after focus has moved.
pub widget_idx: usize,
/// Menu rect in surface-local physical pixels.
pub rect: Rect,
pub has_selection: bool,
pub can_paste: bool,
/// Byte offset within the target TextEdit's value that
/// corresponds to the original press point that opened the menu.
/// Paste uses this so the clipboard contents land exactly where
/// the user right-clicked / long-pressed, regardless of where
/// the cursor was beforehand. `None` only when the open path
/// could not snapshot the position (e.g. canvas missing).
pub paste_offset: Option<usize>,
}
/// Number of rows in the built-in clipboard context menu — Copy /
/// Cut / Paste / Delete. Bumped from three when Delete was added so
/// the user has a parallel UI affordance to the Backspace / Delete
/// keys for clearing a selection without touching the clipboard.
pub( crate ) const CONTEXT_MENU_ROWS: usize = 4;
impl ContextMenu
{
/// Vertical band offsets inside [`Self::rect`] for each row. The
/// returned vector always has [`CONTEXT_MENU_ROWS`] entries; the
/// last element is the per-row height. Used by both hit-testing
/// and the renderer so the two stay in sync.
pub fn row_ys( &self ) -> ( Vec<f32>, f32 )
{
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let ys = ( 0..CONTEXT_MENU_ROWS )
.map( |i| self.rect.y + i as f32 * row_h )
.collect();
( ys, row_h )
}
/// Which row, if any, contains `pos`. Returns `0..CONTEXT_MENU_ROWS`
/// (Copy / Cut / Paste / Delete) or `None` if `pos` is outside the
/// menu rect.
pub fn row_at( &self, pos: Point ) -> Option<usize>
{
if !self.rect.contains( pos ) { return None; }
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let i = ( ( pos.y - self.rect.y ) / row_h ).floor() as i32;
if ( 0..CONTEXT_MENU_ROWS as i32 ).contains( &i ) { Some( i as usize ) } else { None }
}
}
impl<A: App> AppData<A>
{
/// Open the built-in Copy / Cut / Paste context menu near `pos`,
/// targeting the TextEdit at `widget_idx`. Clamps the menu rect
/// to the surface so it never goes off-screen on a near-edge
/// click. Idempotent — replaces any existing menu.
pub( crate ) fn show_context_menu( &mut self, focus: SurfaceFocus, widget_idx: usize, pos: Point )
{
const W: f32 = 180.0;
const H: f32 = 44.0 * CONTEXT_MENU_ROWS as f32;
let has_selection = self.focused_selection_text( focus ).is_some();
let can_paste = !self.clipboard.is_empty();
// Snapshot the byte offset the press fell on so a later
// "Paste" lands exactly at the right-click point, even though
// the menu deliberately preserves the existing cursor /
// selection (so Copy / Cut still operate on the prior
// selection).
let paste_offset = self.text_input_geometry( focus, widget_idx )
.and_then( |( rect, value, multiline, secure, align, font_size )|
{
let cursor_pos = self.surface( focus ).cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
let canvas = self.surface( focus ).canvas.as_ref()?;
Some( crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
) )
} );
let ss = self.surface( focus );
let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32;
let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32;
let mut x = pos.x;
let mut y = pos.y;
// Clamp inside the surface with a small breathing margin.
x = x.min( sw - W - 4.0 ).max( 4.0 );
y = y.min( sh - H - 4.0 ).max( 4.0 );
let rect = Rect { x, y, width: W, height: H };
let menu = ContextMenu { widget_idx, rect, has_selection, can_paste, paste_offset };
let ss = self.surface_mut( focus );
ss.context_menu = Some( menu );
ss.request_redraw();
}
/// Dismiss the context menu on the given surface (no-op when
/// none is shown).
pub( crate ) fn hide_context_menu( &mut self, focus: SurfaceFocus )
{
let ss = self.surface_mut( focus );
if ss.context_menu.is_some()
{
ss.context_menu = None;
ss.request_redraw();
}
}
/// Handle a primary-button press while the context menu is open.
/// Returns `true` when the click was consumed by the menu (either
/// a row activation or an outside-click dismissal); the pointer
/// dispatch then skips the regular gesture path for this press.
pub( crate ) fn handle_context_menu_press( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let menu = match self.surface( focus ).context_menu.clone()
{
Some( m ) => m,
None => return false,
};
// Click outside the menu rect → dismiss only.
let row = match menu.row_at( pos )
{
Some( r ) => r,
None =>
{
self.hide_context_menu( focus );
return true;
}
};
// Action gating: rows are clickable only when their
// underlying operation makes sense.
match row
{
0 => if menu.has_selection { self.handle_copy( focus ); } else { return true; }
1 => if menu.has_selection { self.handle_cut( focus ); } else { return true; }
2 =>
{
if !menu.can_paste { return true; }
// Paste lands at the click position the menu was
// opened from, not at whatever the cursor happens to
// be (which may sit at the end of the value because
// the user just focused the field). Move cursor +
// anchor to the snapshotted offset (collapsing any
// prior selection there) and then run the regular
// paste path.
if let Some( ofs ) = menu.paste_offset
{
let ss = self.surface_mut( focus );
ss.cursor_state.insert( menu.widget_idx, ofs );
ss.selection_anchor.insert( menu.widget_idx, ofs );
}
self.handle_paste( focus );
}
3 =>
{
// Delete row — clears the current selection without
// touching the clipboard (the difference vs. Cut).
// Disabled with no selection: nothing to delete.
if !menu.has_selection { return true; }
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, menu.widget_idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
}
}
_ => return true,
}
self.hide_context_menu( focus );
true
}
}
+221
View File
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge;
use super::app_data::AppData;
use super::surface::{ SurfaceFocus, SurfaceKind };
use crate::app::App;
use crate::types::{ CursorShape, Point };
/// Classify a pointer position against a surface of size `w × h` (in
/// physical pixels). Returns the resize edge if the pointer is inside
/// the `border`-thick grab band along any edge or corner.
pub( crate ) fn resize_edge_for_pos( pos: Point, w: f32, h: f32, border: f32 ) -> Option<ResizeEdge>
{
let on_left = pos.x < border;
let on_right = pos.x > w - border;
let on_top = pos.y < border;
let on_bottom = pos.y > h - border;
Some( match ( on_top, on_bottom, on_left, on_right )
{
( true, _, true, _ ) => ResizeEdge::TopLeft,
( true, _, _, true ) => ResizeEdge::TopRight,
( _, true, true, _ ) => ResizeEdge::BottomLeft,
( _, true, _, true ) => ResizeEdge::BottomRight,
( true, _, _, _ ) => ResizeEdge::Top,
( _, true, _, _ ) => ResizeEdge::Bottom,
( _, _, true, _ ) => ResizeEdge::Left,
( _, _, _, true ) => ResizeEdge::Right,
_ => return None,
} )
}
/// Map an [`ResizeEdge`] to the [`CursorShape`](crate::types::CursorShape)
/// to display while hovering / dragging it.
pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape
{
use CursorShape::*;
match edge
{
ResizeEdge::Top => NResize,
ResizeEdge::Bottom => SResize,
ResizeEdge::Left => WResize,
ResizeEdge::Right => EResize,
ResizeEdge::TopLeft => NwResize,
ResizeEdge::TopRight => NeResize,
ResizeEdge::BottomLeft => SwResize,
ResizeEdge::BottomRight => SeResize,
_ => Default,
}
}
impl<A: App> AppData<A>
{
/// Convert a public [`CursorShape`](crate::types::CursorShape) into
/// the wire-protocol [`Shape`] enum the compositor expects.
fn cursor_shape_to_wp( shape: CursorShape )
-> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape
{
use CursorShape as C;
use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S;
match shape
{
C::Default => S::Default,
C::ContextMenu => S::ContextMenu,
C::Help => S::Help,
C::Pointer => S::Pointer,
C::Progress => S::Progress,
C::Wait => S::Wait,
C::Cell => S::Cell,
C::Crosshair => S::Crosshair,
C::Text => S::Text,
C::VerticalText => S::VerticalText,
C::Alias => S::Alias,
C::Copy => S::Copy,
C::Move => S::Move,
C::NoDrop => S::NoDrop,
C::NotAllowed => S::NotAllowed,
C::Grab => S::Grab,
C::Grabbing => S::Grabbing,
C::EResize => S::EResize,
C::NResize => S::NResize,
C::NeResize => S::NeResize,
C::NwResize => S::NwResize,
C::SResize => S::SResize,
C::SeResize => S::SeResize,
C::SwResize => S::SwResize,
C::WResize => S::WResize,
C::EwResize => S::EwResize,
C::NsResize => S::NsResize,
C::NeswResize => S::NeswResize,
C::NwseResize => S::NwseResize,
C::ColResize => S::ColResize,
C::RowResize => S::RowResize,
C::AllScroll => S::AllScroll,
C::ZoomIn => S::ZoomIn,
C::ZoomOut => S::ZoomOut,
}
}
/// Compute the cursor shape that should be active right now and
/// push it to the compositor if it changed since the last
/// dispatch. Precedence:
///
/// 1. [`crate::App::cursor_override`] — application-driven busy
/// state, wins over everything.
/// 2. Active slider drag — `Grabbing` while the gesture machine
/// holds a `dragging_slider`.
/// 3. Cursor declared by the widget under the pointer
/// ([`crate::widget::LaidOutWidget::cursor`]).
/// 4. `Default` — pointer is over surface chrome / empty space.
pub( crate ) fn dispatch_cursor_shape( &mut self, focus: SurfaceFocus )
{
let target = {
let app_override = self.app.cursor_override();
let dragging = self.surface( focus ).gesture.dragging_slider.is_some();
let hover_cursor = self.surface( focus ).hovered_idx
.and_then( |idx| crate::tree::find_widget( &self.surface( focus ).widget_rects, idx ) )
.map( |w| w.cursor )
.unwrap_or( CursorShape::Default );
let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor );
// Resize-edge hover wins over the widget cursor and over a
// `Default` app override, but loses to a non-default app
// override (so a "busy" / "wait" state still trumps chrome).
let allow_chrome = app_override.is_none()
|| app_override == Some( CursorShape::Default );
if let Some( c ) = resize_cursor.filter( |_| allow_chrome )
{
c
} else if let Some( o ) = app_override {
o
} else if dragging {
CursorShape::Grabbing
} else {
hover_cursor
}
};
// Skip when the compositor already shows the right shape.
// `current_cursor_shape == None` means "we have not pushed
// anything since the last `Enter`" — e.g. the pointer just
// arrived from another client's surface, where the prior
// client may have asked for a text I-beam. We MUST push
// unconditionally in that case so the user does not see the
// stranger cursor lingering until they hit a widget.
if self.current_cursor_shape == Some( target ) { return; }
if let Some( device ) = self.cursor_shape_device.as_ref()
{
device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) );
self.current_cursor_shape = Some( target );
}
}
/// Width (logical pixels) of the resize-grab band along each
/// edge of an xdg-toplevel surface. 6 px is the GTK / KWin
/// convention.
pub( crate ) const RESIZE_BORDER_LOGICAL: f32 = 6.0;
/// Return the [`ResizeEdge`] the pointer currently sits on, or
/// `None` if it is not in a resize-grab band. Only fires for the
/// main surface when it is an xdg-toplevel — layer-shell surfaces
/// and overlays cannot be resized by the user.
pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option<ResizeEdge>
{
if !matches!( focus, SurfaceFocus::Main ) { return None; }
if !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; }
let sf = self.surface( focus ).scale_factor.max( 1 ) as f32;
let border = Self::RESIZE_BORDER_LOGICAL * sf;
let w = self.surface( focus ).physical_width() as f32;
let h = self.surface( focus ).physical_height() as f32;
resize_edge_for_pos( self.pointer_pos, w, h, border )
}
}
#[ cfg( test ) ]
mod resize_edge_tests
{
use super::{ resize_edge_for_pos, ResizeEdge };
use crate::types::Point;
const W: f32 = 800.0;
const H: f32 = 600.0;
const B: f32 = 6.0;
fn at( x: f32, y: f32 ) -> Option<ResizeEdge>
{
resize_edge_for_pos( Point { x, y }, W, H, B )
}
#[ test ]
fn middle_is_none()
{
assert!( at( 400.0, 300.0 ).is_none() );
}
#[ test ]
fn just_inside_border_is_none()
{
// 6 px border; (6, 6) is one pixel inside on both axes.
assert!( at( 6.0, 6.0 ).is_none() );
}
#[ test ]
fn corners_take_precedence_over_single_edges()
{
assert_eq!( at( 1.0, 1.0 ), Some( ResizeEdge::TopLeft ) );
assert_eq!( at( W - 1.0, 1.0 ), Some( ResizeEdge::TopRight ) );
assert_eq!( at( 1.0, H - 1.0 ), Some( ResizeEdge::BottomLeft ) );
assert_eq!( at( W - 1.0, H - 1.0 ), Some( ResizeEdge::BottomRight ) );
}
#[ test ]
fn straight_edges()
{
assert_eq!( at( 400.0, 1.0 ), Some( ResizeEdge::Top ) );
assert_eq!( at( 400.0, H - 1.0 ), Some( ResizeEdge::Bottom ) );
assert_eq!( at( 1.0, 300.0 ), Some( ResizeEdge::Left ) );
assert_eq!( at( W - 1.0, 300.0 ), Some( ResizeEdge::Right ) );
}
}
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::{ SurfaceFocus, SurfaceState };
use crate::app::App;
use crate::types::Point;
impl<A: App> AppData<A>
{
/// Fire long-press messages for any surface whose deadline has
/// elapsed. Idempotent — if a press is in-flight but not yet due this
/// does nothing. On fire, the message is pushed to `pending_msgs`,
/// `long_press_fired` is set, and the per-surface long-press slot is
/// cleared so the same press can't fire twice.
pub( crate ) fn check_long_press_deadlines( &mut self )
{
let dur = self.app.long_press_duration();
let now = std::time::Instant::now();
// Returns `(lp_msg_opt, ds_msg_opt, origin, text_idx_opt)`:
// - `lp_msg_opt = Some` → user-set on_long_press fires; push msg
// (opens the context menu in the app).
// - `ds_msg_opt = Some` → user-set on_drag_start fires; push
// msg + seed `on_drag_move(origin)` so the drag arms with
// the right anchor point. Also flips `long_press_fired` so
// subsequent motion / release route through drag / drop.
// - `text_idx_opt = Some` → press was on a TextEdit with
// neither user msg; the runtime opens the built-in Copy /
// Cut / Paste menu instead.
// Any combination can be present: an icon with both menu and
// drag fires both messages in order (menu first, then drag).
let fire = |ss: &mut SurfaceState<A::Message>|
-> Option<( Option<A::Message>, Option<A::Message>, Point, Option<usize> )>
{
let start = ss.gesture.long_press_start?;
if now.duration_since( start ) < dur { return None; }
ss.gesture.long_press_start = None;
let origin = ss.gesture.long_press_origin.unwrap_or_default();
let lp_msg = ss.gesture.long_press_msg.take();
let ds_msg = ss.gesture.drag_start_msg.take();
let text_idx = ss.gesture.long_press_text_idx.take();
if lp_msg.is_none() && ds_msg.is_none() && text_idx.is_none() { return None; }
// Only flip "fired" when the gesture actually transitions
// into drag mode. A menu-only widget (no on_drag_start)
// stays in tap mode, so a release after the menu fires
// still goes through `on_release` cleanly. The built-in
// text menu likewise does not switch to drag, so a
// subsequent motion can still extend a selection.
if ds_msg.is_some() { ss.gesture.long_press_fired = true; }
ss.request_redraw();
Some( ( lp_msg, ds_msg, origin, text_idx ) )
};
// Capture per-surface fires, then post-process so the borrow
// of `self.main` / `self.overlays` is released before we
// call menu helpers (which take &mut self again).
let main_fire = fire( &mut self.main ).map( |x| ( SurfaceFocus::Main, x ) );
let overlay_fires: Vec<_> = self.overlays.iter_mut()
.filter_map( |( id, ss )| fire( ss ).map( |x| ( SurfaceFocus::Overlay( *id ), x ) ) )
.collect();
let mut consumed_anything = false;
for ( focus, ( lp_msg, ds_msg, origin, text_idx ) ) in main_fire.into_iter().chain( overlay_fires )
{
// Push menu first so the app sets up `edit_menu` before
// the drag-arm message lands and starts consuming
// `on_drag_move` callbacks.
if let Some( m ) = lp_msg
{
self.pending_msgs.push( m );
consumed_anything = true;
}
if let Some( m ) = ds_msg
{
self.pending_msgs.push( m );
let ( ox, oy ) = self.surface_offset_for( focus );
let global = Point { x: origin.x + ox, y: origin.y + oy };
self.pending_drag_inits.push( global );
// Drag promotion cancels any held-button repeat — the
// gesture has switched semantics and the timer has
// nothing to fire against any more.
self.stop_button_repeat();
consumed_anything = true;
}
if let Some( idx ) = text_idx
{
// Built-in Copy / Cut / Paste menu near the press.
self.show_context_menu( focus, idx, origin );
}
}
if consumed_anything { self.dirty_caches(); }
}
pub( crate ) fn has_active_long_press_drag( &self ) -> bool
{
self.main.gesture.long_press_fired
|| self.overlays.values().any( |ss| ss.gesture.long_press_fired )
}
pub( crate ) fn clear_long_press_drag( &mut self )
{
let clear = |ss: &mut SurfaceState<A::Message>|
{
ss.gesture.long_press_start = None;
ss.gesture.long_press_origin = None;
ss.gesture.long_press_msg = None;
ss.gesture.drag_start_msg = None;
ss.gesture.long_press_text_idx = None;
ss.gesture.long_press_fired = false;
ss.gesture.mouse_press = false;
};
clear( &mut self.main );
for ss in self.overlays.values_mut()
{
clear( ss );
}
}
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail
/// to bring up the event loop.
///
/// Every variant maps to a fatal failure during init: the Wayland
/// connection, the calloop event loop, or one of the protocol bindings
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
/// `xdg_wm_base`). Once init succeeds, the compositor disconnecting
/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any
/// other runtime error during the dispatch loop still panics, since the
/// surface is already on screen and the state machine cannot be unwound
/// from this entry point.
///
/// Embedders that want a software-rendered fallback or that need to
/// degrade gracefully should call [`crate::try_run`] and match on the
/// variants instead of letting [`crate::run`] panic.
#[ derive( Debug ) ]
pub enum RunError
{
/// `WAYLAND_DISPLAY` is unset, the socket is missing, or the
/// compositor refused the handshake. Includes the detailed reason
/// from the underlying `wayland-client` error.
NoWaylandConnection( String ),
/// The Wayland registry could not be enumerated. Almost always a
/// compositor / driver bug — the registry is the first thing every
/// Wayland client touches.
RegistryInit( String ),
/// `calloop`'s `EventLoop::try_new` or its Wayland source insertion
/// failed (typically an `io` error talking to the kernel).
EventLoop( String ),
/// A required Wayland protocol is missing from the compositor.
/// `name` is the wire-format protocol name; `detail` is the
/// underlying bind error.
MissingProtocol
{
name: &'static str,
detail: String,
},
}
impl std::fmt::Display for RunError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
Self::NoWaylandConnection( d ) =>
write!( f, "Wayland connection failed: {d}" ),
Self::RegistryInit( d ) =>
write!( f, "Wayland registry init failed: {d}" ),
Self::EventLoop( d ) =>
write!( f, "event-loop setup failed: {d}" ),
Self::MissingProtocol { name, detail } =>
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
}
}
}
impl std::error::Error for RunError {}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::client::QueueHandle;
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::{ App, OverlayId };
use crate::tree::find_handlers;
use crate::types::Point;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
/// Push `on_dismiss` for every active xdg-popup overlay whose
/// anchor widget was not hit by a press at `pos` (logical pixels
/// in main-surface space). Used to compensate for compositors
/// (notably Mutter) that route pointer button events to the main
/// surface while a popup grab is technically active, instead of
/// breaking the grab. The check skips presses on the trigger pill
/// itself so the trigger's own `toggle` message keeps owning that
/// transition.
pub( crate ) fn dismiss_main_outside_popups( &mut self, pos: Point )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
// Only xdg-popups need the main-side fallback: they grab
// the seat and rely on the compositor to break the grab on
// outside input, which Mutter does not do reliably. Layer-
// shell overlays own their own `input_region` and dispatch
// dismissal from the overlay tap path, so firing it again
// from a press on the main surface produces a duplicate
// dismiss that races other intents (e.g. forge's topbar
// taps that route a separate IPC into the same overlay).
let Some( anchor_id ) = spec.anchor_widget_id else { continue; };
let anchor_rect = self.main.widget_rects.iter()
.find( | w | w.id == Some( anchor_id ) )
.map( | w | w.rect );
let on_anchor = anchor_rect
.map( | r | pos.x >= r.x && pos.x < r.x + r.width
&& pos.y >= r.y && pos.y < r.y + r.height )
.unwrap_or( false );
if !on_anchor
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Push `on_dismiss` for every active xdg-popup overlay
/// unconditionally (used by keyboard Escape handling — there is
/// no spatial test to do).
pub( crate ) fn dismiss_all_popups( &mut self )
{
if self.overlays.is_empty() { return; }
let specs = self.app.overlays();
for spec in specs
{
if spec.anchor_widget_id.is_some()
{
if let Some( msg ) = spec.on_dismiss
{
self.pending_msgs.push( msg );
}
}
}
}
/// Look up the dismiss message for an overlay (tap on empty area).
/// Returns `None` if the overlay has no `on_dismiss` set or was removed.
pub( crate ) fn overlay_dismiss_msg( &self, id: OverlayId ) -> Option<A::Message>
{
self.app.overlays().into_iter()
.find( |s| s.id == id )
.and_then( |s| s.on_dismiss )
}
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize>, qh: &QueueHandle<Self> )
{
let was_text_input;
let is_text_input;
{
let ss = self.surface_mut( focus );
is_text_input = idx
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
was_text_input = ss.focused_idx
.and_then( |i| find_handlers( &ss.widget_rects, i ) )
.map( |h| h.is_text_input() )
.unwrap_or( false );
// Clear pending text value when losing focus
if let Some( prev_idx ) = ss.focused_idx
{
if idx != Some( prev_idx )
{
ss.pending_text_values.remove( &prev_idx );
}
}
ss.focused_idx = idx;
ss.focused_id = idx.and_then( |i|
{
ss.widget_rects.iter()
.find( |w| w.flat_idx == i )
.and_then( |w| w.id )
} );
ss.request_redraw();
// Sync cursor to end of current value when focusing a text
// input. Default behaviour collapses the selection to the
// cursor — focus changes always discard any prior
// selection state. Fields built with `.select_on_focus(
// true )` instead anchor the selection at `0` so the
// whole value is highlighted, ready to be replaced by the
// next keystroke (typical for numeric pickers).
if is_text_input
{
if let Some( i ) = idx
{
let handler = find_handlers( &ss.widget_rects, i );
let cursor = handler
.and_then( |h| h.current_value() )
.map( |v| v.len() )
.unwrap_or( 0 );
let anchor = match handler
{
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ) => 0,
_ => cursor,
};
ss.cursor_state.insert( i, cursor );
ss.selection_anchor.insert( i, anchor );
}
}
}
if was_text_input && !is_text_input
{
self.deactivate_text_input();
self.app.on_text_input_focused( false );
self.dirty_caches();
}
if is_text_input && !was_text_input
{
self.activate_text_input( qh );
self.app.on_text_input_focused( true );
self.dirty_caches();
}
}
}
+2
View File
@@ -17,6 +17,7 @@ use smithay_client_toolkit::
{
WaylandSurface,
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
xdg::XdgSurface,
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
xdg::window::{ Window, WindowConfigure, WindowHandler },
},
@@ -218,6 +219,7 @@ impl<A: App> WindowHandler for AppData<A>
let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) );
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w );
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
self.on_configure( w, h );
self.app.on_resize( w, h );
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashSet;
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use super::AppData;
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
/// dirty the relevant cache(s) so the next draw rebuilds the view via
/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each
/// affected surface so the run loop's "anything to draw" check picks it up.
///
/// `InvalidationScope::All` (the default returned by `App::invalidate_after`)
/// matches the pre-hook behaviour of broadcasting to every surface.
pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
{
match scope
{
InvalidationScope::All =>
{
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
InvalidationScope::Only( targets ) =>
{
for t in targets
{
match t
{
SurfaceTarget::Main =>
{
data.view_dirty = true;
data.main.request_redraw();
}
SurfaceTarget::Overlay( id ) =>
{
// `overlays()` returns a single Vec, so any
// per-overlay change forces the whole list to be
// re-queried. Coarser than per-overlay caching but
// matches the existing API shape.
data.overlays_dirty = true;
if let Some( ss ) = data.overlays.get_mut( &id )
{
ss.request_redraw();
}
}
}
}
}
}
}
/// Pure overlay-id diff. Given the current set of live overlay ids and the
/// list the app just returned from [`crate::app::App::overlays`], compute
/// `( added, removed )`.
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],
) -> ( Vec<crate::app::OverlayId>, Vec<crate::app::OverlayId> )
{
let current_set: HashSet<crate::app::OverlayId> = current.into_iter().collect();
let next_set: HashSet<crate::app::OverlayId> = next.iter().copied().collect();
let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect();
let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect();
( added, removed )
}
+18 -872
View File
@@ -2,878 +2,24 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
pub( crate ) mod app_data;
pub( crate ) mod clipboard;
pub( crate ) mod context_menu;
pub( crate ) mod cursor_shape;
pub( crate ) mod drag;
pub( crate ) mod focus;
mod handlers;
pub( crate ) mod repeat;
pub( crate ) mod surface;
pub( crate ) mod text_editing;
pub( crate ) mod tooltip;
pub( crate ) use app_data::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
pub( crate ) mod error;
pub( crate ) mod run;
pub( crate ) mod invalidation;
pub( crate ) mod overlays_reconcile;
use smithay_client_toolkit::
{
compositor::{ CompositorState, Surface },
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgPositioner, XdgShell, XdgSurface,
popup::Popup,
window::WindowDecorations,
},
},
shm::Shm,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::{ App, InvalidationScope, SurfaceTarget };
use crate::draw::draw_frame;
use crate::types::{ Point, Rect };
use std::collections::HashSet;
/// Reasons [`crate::try_run`] (and therefore [`crate::run`]) can fail
/// to bring up the event loop.
///
/// Every variant maps to a fatal failure during init: the Wayland
/// connection, the calloop event loop, or one of the protocol bindings
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
/// `xdg_wm_base`). Once init succeeds, the compositor disconnecting
/// (`BrokenPipe` / `ConnectionReset`) is treated as a clean exit; any
/// other runtime error during the dispatch loop still panics, since the
/// surface is already on screen and the state machine cannot be unwound
/// from this entry point.
///
/// Embedders that want a software-rendered fallback or that need to
/// degrade gracefully should call [`crate::try_run`] and match on the
/// variants instead of letting [`crate::run`] panic.
#[ derive( Debug ) ]
pub enum RunError
{
/// `WAYLAND_DISPLAY` is unset, the socket is missing, or the
/// compositor refused the handshake. Includes the detailed reason
/// from the underlying `wayland-client` error.
NoWaylandConnection( String ),
/// The Wayland registry could not be enumerated. Almost always a
/// compositor / driver bug — the registry is the first thing every
/// Wayland client touches.
RegistryInit( String ),
/// `calloop`'s `EventLoop::try_new` or its Wayland source insertion
/// failed (typically an `io` error talking to the kernel).
EventLoop( String ),
/// A required Wayland protocol is missing from the compositor.
/// `name` is the wire-format protocol name; `detail` is the
/// underlying bind error.
MissingProtocol
{
name: &'static str,
detail: String,
},
}
impl std::fmt::Display for RunError
{
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
{
match self
{
Self::NoWaylandConnection( d ) =>
write!( f, "Wayland connection failed: {d}" ),
Self::RegistryInit( d ) =>
write!( f, "Wayland registry init failed: {d}" ),
Self::EventLoop( d ) =>
write!( f, "event-loop setup failed: {d}" ),
Self::MissingProtocol { name, detail } =>
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
}
}
}
impl std::error::Error for RunError {}
/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
let ( globals, queue ) =
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
let qh = queue.handle();
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
WaylandSource::new( conn.clone(), queue )
.insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// Try to bring EGL up. On failure (no libEGL, no compatible config,
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
// reason and every surface falls back to the SHM path.
let egl_context = match crate::egl_context::EglContext::new( &conn )
{
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
Err( reason ) =>
{
crate::egl_context::log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
// Bind layer-shell up front. Both the main surface (when requested via
// ShellMode::Layer) and every overlay returned by App::overlays() share
// this single binding.
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
// Backwards compatibility: window_config() overrides shell_mode()
let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError>
{
XdgShell::bind( globals, qh )
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
};
// Skipped when going fullscreen: Mutter rejects the fullscreen
// request if a min_size constrains it.
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen() { return; }
match app.window_size_hint()
{
Some( ( w, h ) ) =>
{
window.set_min_size( Some( ( w, h ) ) );
window.set_max_size( Some( ( w, h ) ) );
}
None =>
{
window.set_min_size( Some( ( 360, 480 ) ) );
}
}
};
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen()
{
window.set_fullscreen( None );
}
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
ShellMode::Layer( layer ) => {
if layer_shell_opt.is_some()
{
// Defer surface creation until new_output fires: if we create the layer
// surface before the compositor has any output ready (e.g. sway startup),
// the compositor cannot assign it and logs an error.
let cfg = LayerConfig {
layer: layer.to_wlr_layer(),
exclusive_zone: app.exclusive_zone(),
anchor: app.layer_anchor(),
size: app.layer_size(),
keyboard_exclusive: app.keyboard_exclusive(),
namespace: "ltk-sctk",
};
( SurfaceKind::Pending( cfg ), None )
} else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
// fires. The list is built unconditionally so apps that override the
// callback always see a consistent stream when the compositor does carry
// the protocol.
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
let pending_fullscreen = app.start_fullscreen();
let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some();
let mut data = AppData
{
app,
registry_state: RegistryState::new( &globals ),
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
shm,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
keyboard: None,
pointer: None,
touch: None,
pointer_pos: Point::default(),
cursor_shape_manager:
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
cursor_shape_device: None,
last_pointer_enter_serial: 0,
current_cursor_shape: None,
text_input_manager,
text_input: None,
foreign_toplevel_list,
shift_pressed: false,
ctrl_pressed: false,
loop_handle: event_loop.handle(),
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
button_repeat: None,
clipboard: String::new(),
last_press_time: None,
last_press_pos: None,
debug_layout,
pending_msgs: Vec::new(),
pending_drag_inits: Vec::new(),
tooltip_pending: None,
tooltip_visible: None,
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
pending_size_hint_unpin,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
cached_view: None,
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
};
// Register a calloop channel so the app can send messages from any thread.
// Messages sent through the Sender wake the event loop immediately.
{
let ( sender, channel ) = calloop::channel::channel::<A::Message>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( msg ) = event
{
// Just queue the message — the run loop will run
// `App::invalidate_after` and apply the resulting
// scope, which is what decides which surfaces (if
// any) actually need to redraw.
data.pending_msgs.push( msg );
}
},
)
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
data.app.set_channel_sender( sender );
}
// Register a periodic timer if the app wants one (e.g. clock tick every second).
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
|_, _, data: &mut AppData<A>|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
let next = data.app.poll_interval()
.unwrap_or( std::time::Duration::from_secs( 1 ) );
TimeoutAction::ToDuration( next )
},
)
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
}
while !data.exit_requested
{
// Sleep until something interesting fires:
// * Wayland event (input, configure, frame callback, …)
// * calloop timer (poll_external)
// * calloop channel (App::set_channel_sender)
// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
// off-screen / VRR display blocks indefinitely instead of polling
// at a fixed rate. The one exception is a pending long-press:
// cap the wait at its deadline so a perfectly still press still
// fires on time.
let timeout = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
{
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
( a, b ) => a.or( b ),
};
match event_loop.dispatch( timeout, &mut data )
{
Ok( () ) => {},
Err( calloop::Error::IoError( ref e ) )
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
{
// On a fatal `wl_display.error` the compositor closes the
// socket; the backend has the typed error stashed but the
// dispatch result only carries the resulting `BrokenPipe`.
if let Some( pe ) = conn.protocol_error()
{
eprintln!(
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
pe.object_interface, pe.object_id, pe.code, pe.message
);
}
else
{
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
}
data.exit_requested = true;
continue;
}
Err( e ) => panic!( "dispatch: {e:?}" ),
}
// Any surface whose press has now crossed `long_press_duration`
// emits its stored message and flips into drag mode for the rest
// of the gesture.
data.check_long_press_deadlines();
data.check_tooltip_deadline();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
let had_msgs = !msgs.is_empty();
if had_msgs
{
let mut scope = InvalidationScope::Only( Vec::new() );
for msg in msgs
{
scope = scope.union( data.app.invalidate_after( &msg ) );
data.app.update( msg );
}
apply_invalidation( &mut data, scope );
// `update()` may have flipped the busy / loading flag
// the app reads from inside `cursor_override`. Re-sync
// the pointer cursor so the change propagates without
// waiting for the next motion event.
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// Seed `on_drag_move` with the long-press origin for any drag that
// just started. Must run *after* `update()` so the app's drag state
// has already been set up by the paired long-press message — the
// coords would otherwise hit a dragging_app=None shell and be lost.
let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
if !drag_inits.is_empty()
{
for origin in drag_inits
{
data.app.on_drag_move( origin.x, origin.y );
}
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
}
// After update() the app state is the source of truth — discard any
// pending text values so that the next keystroke reads the fresh state
// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
if !data.main.pending_text_values.is_empty()
{
data.main.pending_text_values.clear();
}
for ss in data.overlays.values_mut()
{
ss.pending_text_values.clear();
}
// Reconcile the overlay set against the app's current specs: drop any
// overlays whose id disappeared, create new ones for ids that just
// appeared. Specs are re-queried next frame for drawing.
reconcile_overlays( &mut data );
// Draw any surface that's configured, dirty, and not already waiting
// on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame
// after it ends, so the settle frame paints at full quality
// instead of freezing one step short of the target.
// `wants_low_quality_paint` is the source of truth so
// finger-tracked drags get the same treatment. Slider /
// scroll drags are owned by the runtime gesture machine,
// not by `App::update`, so OR with the gesture state to
// pick up that motion signal too.
let runtime_slider_motion =
data.main.gesture.dragging_slider.is_some()
|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
if runtime_slider_motion
{
data.view_dirty = true;
data.overlays_dirty = true;
}
if data.view_dirty
{
data.cached_view = Some( data.app.view() );
data.view_dirty = false;
}
if data.overlays_dirty
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
data.cached_overlays = Some( specs );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it.
// Walks main + every overlay because the targeted widget can live
// on any of them (a search field on a launcher overlay, a text
// edit on a dialog modal, …).
if let Some( id ) = data.app.take_focus_request()
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
if hit.is_none()
{
for ( ov_id, surf ) in &data.overlays
{
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
{
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
break;
}
}
}
if let Some( ( focus, flat_idx ) ) = hit
{
let qh = data.qh.clone();
data.set_focus( focus, Some( flat_idx ), &qh );
match focus
{
SurfaceFocus::Main => data.main.request_redraw(),
SurfaceFocus::Overlay( id ) =>
{
if let Some( s ) = data.overlays.get_mut( &id )
{
s.request_redraw();
}
}
}
}
}
}
Ok( () )
}
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
/// dirty the relevant cache(s) so the next draw rebuilds the view via
/// [`App::view`] / [`App::overlays`], and call `request_redraw` on each
/// affected surface so the run loop's "anything to draw" check picks it up.
///
/// `InvalidationScope::All` (the default returned by `App::invalidate_after`)
/// matches the pre-hook behaviour of broadcasting to every surface.
fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: InvalidationScope )
{
match scope
{
InvalidationScope::All =>
{
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
for ss in data.overlays.values_mut()
{
ss.request_redraw();
}
}
InvalidationScope::Only( targets ) =>
{
for t in targets
{
match t
{
SurfaceTarget::Main =>
{
data.view_dirty = true;
data.main.request_redraw();
}
SurfaceTarget::Overlay( id ) =>
{
// `overlays()` returns a single Vec, so any
// per-overlay change forces the whole list to be
// re-queried. Coarser than per-overlay caching but
// matches the existing API shape.
data.overlays_dirty = true;
if let Some( ss ) = data.overlays.get_mut( &id )
{
ss.request_redraw();
}
}
}
}
}
}
}
/// Pure overlay-id diff. Given the current set of live overlay ids and the
/// list the app just returned from [`crate::app::App::overlays`], compute
/// `( added, removed )`.
///
/// `added` preserves the order of `next` (so creation order is deterministic);
/// `removed` is unordered (driven by HashMap iteration in the caller).
pub fn diff_overlay_ids(
current: impl IntoIterator<Item = crate::app::OverlayId>,
next: &[ crate::app::OverlayId ],
) -> ( Vec<crate::app::OverlayId>, Vec<crate::app::OverlayId> )
{
let current_set: HashSet<crate::app::OverlayId> = current.into_iter().collect();
let next_set: HashSet<crate::app::OverlayId> = next.iter().copied().collect();
let added: Vec<_> = next.iter().copied().filter( |id| !current_set.contains( id ) ).collect();
let removed: Vec<_> = current_set.into_iter().filter( |id| !next_set.contains( id ) ).collect();
( added, removed )
}
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
/// destroy any overlay whose id disappeared and create a fresh `SurfaceState`
/// for every id that just appeared. Created surfaces are materialized
/// immediately if an output is already available; otherwise they stay
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
// Drop overlays that disappeared from the spec list. If the overlay had
// an in-flight drag (long-press fired), migrate that state to `main` so
// the motion / release that follows still routes through the app's drag
// handlers — apps typically hide the overlay *because* of the long-press,
// and we must not lose the drag state with the surface.
let removed: Vec<_> = data.overlays.keys()
.filter( |id| !wanted.contains( id ) )
.copied()
.collect();
for id in removed
{
if let Some( ss ) = data.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
}
// Clear any stale per-device focus pointing at a destroyed overlay.
if let SurfaceFocus::Overlay( id ) = data.pointer_focus
{
if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( id ) = data.keyboard_focus
{
if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; }
}
// Rewrite touch_focus entries pointing at a destroyed overlay to Main
// rather than dropping them. Dropping would make subsequent motion/up
// events for the same touch id default to Main *without* the long-press
// drag state, turning a release into a stray tap.
for f in data.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( id ) = *f
{
if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; }
}
}
// Create overlays that just appeared. Uses field-level borrow splitting so
// the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state`
// / `output_state` / `qh` can coexist with the mutable borrow of
// `overlays` inside the loop.
let layer_shell_opt = data.layer_shell.as_ref();
let xdg_shell_opt = data.xdg_shell.as_ref();
let output_opt = data.output_state.outputs().next();
let cs = &data.compositor_state;
let qh = &data.qh;
let grab_seat = data.seat_state.seats().next();
let grab_serial = data.last_input_serial;
// Snapshot the parent xdg_surface (only an xdg toplevel can host
// xdg-popups; layer-shell parents would need a different code path
// via `LayerSurface::get_popup`, which we do not currently support).
let parent_xdg = match data.main.surface
{
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
_ => None,
};
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
// Snapshot the previous-frame anchor lookup table so we can resolve
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
// across the overlay-mut loop below.
let main_widget_rects = &data.main.widget_rects;
let overlays_m = &mut data.overlays;
for spec in &specs
{
if let Some( ss ) = overlays_m.get_mut( &spec.id )
{
// Already-live overlay: propagate size changes to the layer
// surface so apps can animate an overlay's dimensions (e.g. a
// slide-down panel whose height grows each frame). The very
// first size was applied at `materialize` time and recorded in
// `last_requested_size`, so we only commit when it actually
// differs. Commit is needed after `set_size` so the compositor
// sends a configure; the usual `on_configure` path picks up the
// new dimensions and drives the redraw. Popups don't grow /
// shrink mid-life — close and reopen instead.
if spec.size != ss.last_requested_size
{
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
layer_surface.commit();
ss.last_requested_size = spec.size;
}
}
// Compare the anchor at integer logical-pixel resolution:
// floating-point jitter would otherwise call `reposition`
// every frame, which several compositors react to by
// dropping the popup grab.
if let SurfaceKind::Popup( ref popup ) = ss.surface
{
if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt )
{
if let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
{
let to_logical = | r: Rect |
{
(
( r.x / parent_scale ).round() as i32,
( r.y / parent_scale ).round() as i32,
( r.width / parent_scale ).round().max( 1.0 ) as i32,
( r.height / parent_scale ).round().max( 1.0 ) as i32,
)
};
let new_q = to_logical( anchor_rect );
let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true );
if moved
{
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{
let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 );
popup.reposition( &positioner, ss.popup_reposition_token );
ss.last_popup_anchor = Some( anchor_rect );
}
}
}
}
}
continue;
}
// `anchor_widget_id = Some(_)` → xdg-popup path; `None` →
// wlr-layer-shell path.
if let Some( anchor_id ) = spec.anchor_widget_id
{
let Some( xdg_shell ) = xdg_shell_opt else
{
eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id );
continue;
};
let Some( ref parent ) = parent_xdg else
{
eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id );
continue;
};
let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
else
{
eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id );
continue;
};
let positioner = match XdgPositioner::new( xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id );
continue;
}
};
// Convert the requested popup size and the anchor rect from
// physical pixels (the layout coordinate space) to logical
// pixels — the positioner expresses everything in window
// geometry, which is logical. `size.0 == 0` is the
// "match anchor width" convention (paralleling the
// layer-shell `0 = fill` semantic): the popup is sized to
// the trigger pill so combos / selects render flush with
// the field they belong to.
let ax = ( anchor_rect.x / parent_scale ).round() as i32;
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
// xdg_popup.grab must be issued before the first commit
// (error 0 `invalid_grab` otherwise). `Popup::new` commits
// internally, so the lower-level `from_surface` path is
// the only one that lets the grab land in time.
let surface = match Surface::new( cs, qh )
{
Ok( s ) => s,
Err( e ) =>
{
eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id );
continue;
}
};
let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id );
continue;
}
};
if let Some( ref seat ) = grab_seat
{
popup.xdg_popup().grab( seat, grab_serial );
}
popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.last_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss );
continue;
}
// wlr-layer-shell path.
let Some( layer_shell ) = layer_shell_opt else
{
eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id );
continue;
};
let cfg = LayerConfig
{
layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.size,
keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay",
};
let mut surface = SurfaceKind::Pending( cfg );
if let Some( ref output ) = output_opt
{
surface.materialize( cs, layer_shell, qh, output );
}
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss );
}
}
pub( crate ) use app_data::AppData;
pub( crate ) use surface::{ LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
pub use error::RunError;
pub( crate ) use run::{ run, try_run };
pub use invalidation::diff_overlay_ids;
+289
View File
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::collections::HashSet;
use smithay_client_toolkit::
{
compositor::Surface,
shell::
{
WaylandSurface,
xdg::
{
XdgPositioner, XdgSurface,
popup::Popup,
},
},
};
use wayland_protocols::xdg::shell::client::xdg_positioner::
{
Anchor as PositionerAnchor,
ConstraintAdjustment,
Gravity,
};
use crate::app::App;
use crate::types::Rect;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
/// destroy any overlay whose id disappeared and create a fresh `SurfaceState`
/// for every id that just appeared. Created surfaces are materialized
/// immediately if an output is already available; otherwise they stay
/// [`SurfaceKind::Pending`] and the `new_output` handler will bring them up.
pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
let next_ids: Vec<_> = specs.iter().map( |s| s.id ).collect();
let wanted: HashSet<crate::app::OverlayId> = next_ids.iter().copied().collect();
// Drop overlays that disappeared from the spec list. If the overlay had
// an in-flight drag (long-press fired), migrate that state to `main` so
// the motion / release that follows still routes through the app's drag
// handlers — apps typically hide the overlay *because* of the long-press,
// and we must not lose the drag state with the surface.
let removed: Vec<_> = data.overlays.keys()
.filter( |id| !wanted.contains( id ) )
.copied()
.collect();
for id in removed
{
if let Some( ss ) = data.overlays.remove( &id )
{
if ss.gesture.long_press_fired
{
data.main.gesture.long_press_fired = true;
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
}
}
}
// Clear any stale per-device focus pointing at a destroyed overlay.
if let SurfaceFocus::Overlay( id ) = data.pointer_focus
{
if !data.overlays.contains_key( &id ) { data.pointer_focus = SurfaceFocus::Main; }
}
if let SurfaceFocus::Overlay( id ) = data.keyboard_focus
{
if !data.overlays.contains_key( &id ) { data.keyboard_focus = SurfaceFocus::Main; }
}
// Rewrite touch_focus entries pointing at a destroyed overlay to Main
// rather than dropping them. Dropping would make subsequent motion/up
// events for the same touch id default to Main *without* the long-press
// drag state, turning a release into a stray tap.
for f in data.touch_focus.values_mut()
{
if let SurfaceFocus::Overlay( id ) = *f
{
if !data.overlays.contains_key( &id ) { *f = SurfaceFocus::Main; }
}
}
// Create overlays that just appeared. Uses field-level borrow splitting so
// the shared borrows of `layer_shell` / `xdg_shell` / `compositor_state`
// / `output_state` / `qh` can coexist with the mutable borrow of
// `overlays` inside the loop.
let layer_shell_opt = data.layer_shell.as_ref();
let xdg_shell_opt = data.xdg_shell.as_ref();
let output_opt = data.output_state.outputs().next();
let cs = &data.compositor_state;
let qh = &data.qh;
let grab_seat = data.seat_state.seats().next();
let grab_serial = data.last_input_serial;
// Snapshot the parent xdg_surface (only an xdg toplevel can host
// xdg-popups; layer-shell parents would need a different code path
// via `LayerSurface::get_popup`, which we do not currently support).
let parent_xdg = match data.main.surface
{
SurfaceKind::Window( ref w ) => Some( w.xdg_surface().clone() ),
_ => None,
};
let parent_scale = data.main.scale_factor.max( 1 ) as f32;
// Snapshot the previous-frame anchor lookup table so we can resolve
// `anchor_widget_id` → `Rect` without holding a borrow on `data.main`
// across the overlay-mut loop below.
let main_widget_rects = &data.main.widget_rects;
let overlays_m = &mut data.overlays;
for spec in &specs
{
if let Some( ss ) = overlays_m.get_mut( &spec.id )
{
// Already-live overlay: propagate size changes to the layer
// surface so apps can animate an overlay's dimensions (e.g. a
// slide-down panel whose height grows each frame). The very
// first size was applied at `materialize` time and recorded in
// `last_requested_size`, so we only commit when it actually
// differs. Commit is needed after `set_size` so the compositor
// sends a configure; the usual `on_configure` path picks up the
// new dimensions and drives the redraw. Popups don't grow /
// shrink mid-life — close and reopen instead.
if spec.size != ss.last_requested_size
{
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
{
layer_surface.set_size( spec.size.0, spec.size.1 );
layer_surface.commit();
ss.last_requested_size = spec.size;
}
}
// Compare the anchor at integer logical-pixel resolution:
// floating-point jitter would otherwise call `reposition`
// every frame, which several compositors react to by
// dropping the popup grab.
if let SurfaceKind::Popup( ref popup ) = ss.surface
{
if let ( Some( anchor_id ), Some( xdg_shell ) ) = ( spec.anchor_widget_id, xdg_shell_opt )
{
if let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
{
let to_logical = | r: Rect |
{
(
( r.x / parent_scale ).round() as i32,
( r.y / parent_scale ).round() as i32,
( r.width / parent_scale ).round().max( 1.0 ) as i32,
( r.height / parent_scale ).round().max( 1.0 ) as i32,
)
};
let new_q = to_logical( anchor_rect );
let moved = ss.last_popup_anchor.map( |r| to_logical( r ) != new_q ).unwrap_or( true );
if moved
{
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
{
let ( ax, ay, aw, ah ) = new_q;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
ss.popup_reposition_token = ss.popup_reposition_token.wrapping_add( 1 );
popup.reposition( &positioner, ss.popup_reposition_token );
ss.last_popup_anchor = Some( anchor_rect );
}
}
}
}
}
continue;
}
// `anchor_widget_id = Some(_)` → xdg-popup path; `None` →
// wlr-layer-shell path.
if let Some( anchor_id ) = spec.anchor_widget_id
{
let Some( xdg_shell ) = xdg_shell_opt else
{
eprintln!( "ltk: ignoring popup overlay {:?} — main surface is not an xdg-shell window", spec.id );
continue;
};
let Some( ref parent ) = parent_xdg else
{
eprintln!( "ltk: ignoring popup overlay {:?} — no xdg parent surface", spec.id );
continue;
};
let Some( anchor_rect ) = main_widget_rects.iter()
.find( |w| w.id == Some( anchor_id ) )
.map( |w| w.rect )
else
{
eprintln!( "ltk: popup overlay {:?} could not find anchor widget id {:?}", spec.id, anchor_id );
continue;
};
let positioner = match XdgPositioner::new( xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: XdgPositioner::new failed for popup {:?}: {e}", spec.id );
continue;
}
};
// Convert the requested popup size and the anchor rect from
// physical pixels (the layout coordinate space) to logical
// pixels — the positioner expresses everything in window
// geometry, which is logical. `size.0 == 0` is the
// "match anchor width" convention (paralleling the
// layer-shell `0 = fill` semantic): the popup is sized to
// the trigger pill so combos / selects render flush with
// the field they belong to.
let ax = ( anchor_rect.x / parent_scale ).round() as i32;
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
let ( spec_w, spec_h ) = spec.size;
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
let popup_h = spec_h.max( 1 ) as i32;
positioner.set_size( popup_w, popup_h );
positioner.set_anchor_rect( ax, ay, aw, ah );
positioner.set_anchor( PositionerAnchor::Bottom );
positioner.set_gravity( Gravity::Bottom );
positioner.set_constraint_adjustment(
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX,
);
// xdg_popup.grab must be issued before the first commit
// (error 0 `invalid_grab` otherwise). `Popup::new` commits
// internally, so the lower-level `from_surface` path is
// the only one that lets the grab land in time.
let surface = match Surface::new( cs, qh )
{
Ok( s ) => s,
Err( e ) =>
{
eprintln!( "ltk: Surface::new failed for popup overlay {:?}: {e}", spec.id );
continue;
}
};
let popup = match Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell )
{
Ok( p ) => p,
Err( e ) =>
{
eprintln!( "ltk: Popup::from_surface failed for overlay {:?}: {e}", spec.id );
continue;
}
};
if let Some( ref seat ) = grab_seat
{
popup.xdg_popup().grab( seat, grab_serial );
}
popup.wl_surface().commit();
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.last_popup_anchor = Some( anchor_rect );
overlays_m.insert( spec.id, ss );
continue;
}
// wlr-layer-shell path.
let Some( layer_shell ) = layer_shell_opt else
{
eprintln!( "ltk: ignoring layer-shell overlay {:?} — wlr-layer-shell not available", spec.id );
continue;
};
let cfg = LayerConfig
{
layer: spec.layer.to_wlr_layer(),
exclusive_zone: spec.exclusive_zone,
anchor: spec.anchor,
size: spec.size,
keyboard_exclusive: spec.keyboard_exclusive,
namespace: "ltk-overlay",
};
let mut surface = SurfaceKind::Pending( cfg );
if let Some( ref output ) = output_opt
{
surface.materialize( cs, layer_shell, qh, output );
}
let mut ss = SurfaceState::<A::Message>::new( surface, 0.0, String::new() );
ss.last_requested_size = spec.size;
ss.layer_anchor = Some( spec.anchor );
overlays_m.insert( spec.id, ss );
}
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use calloop::RegistrationToken;
use smithay_client_toolkit::seat::keyboard::KeyEvent;
/// Snapshot of the key whose repeat timer is currently armed.
///
/// Stored on [`super::app_data::AppData::key_repeat`] between press and
/// release of a held-down key. The timer's calloop callback reads this
/// back to re-dispatch the same `KeyEvent` against the same focus, so a
/// held arrow key keeps moving the cursor in the originally-focused text
/// input even if the focus migrates mid-repeat (which it should not —
/// release cancels the timer, and focus changes only happen via Tab /
/// pointer events that arrive after `release_key` is processed).
pub( crate ) struct KeyRepeatState
{
pub event: KeyEvent,
pub token: RegistrationToken,
}
/// Snapshot of the button whose press-and-hold repeat timer is
/// currently armed. Mirrors [`KeyRepeatState`] for pointer / touch
/// presses on a [`crate::widget::button::Button`] built with
/// `.repeating( true )`. The timer's callback re-runs the press
/// against the *current* widget tree: it looks the pressed widget
/// up by `flat_idx` on the focus surface, reads its `on_press`
/// fresh, and pushes that into the pending queue. Re-resolving each
/// tick is what makes a stepper button work — the message a stepper
/// builds is `"go to value + step"` computed at view-build time, so
/// replaying the captured message would re-issue the same target
/// after the first fire and the user would see the value freeze
/// after one step. Reading the live `on_press` instead picks up the
/// new target the time picker just rebuilt with the updated value.
///
/// Cancelled on release, pointer leave, gesture cancel, focus loss
/// and long-press promotion. Also self-cancels on the first tick
/// where the widget at `idx` no longer exists or no longer carries
/// an `on_press` message — view restructuring (e.g. tab switch)
/// invalidates the snapshot and we stop rather than firing the
/// wrong widget's message.
pub( crate ) struct ButtonRepeatState
{
/// calloop registration handle so [`super::app_data::AppData::stop_button_repeat`]
/// can remove the timer from the event loop without leaking the
/// source. The surface focus and pressed `flat_idx` live in
/// the timer's closure capture instead of here — the only
/// caller that needs to act on them is the closure itself.
pub token: RegistrationToken,
}
+482
View File
@@ -0,0 +1,482 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorState,
output::OutputState,
registry::RegistryState,
seat::SeatState,
shell::
{
WaylandSurface,
wlr_layer::LayerShell,
xdg::
{
XdgShell,
window::WindowDecorations,
},
},
shm::Shm,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
use crate::app::{ App, InvalidationScope };
use crate::draw::draw_frame;
use crate::types::Point;
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use super::error::RunError;
use super::invalidation::apply_invalidation;
use super::overlays_reconcile::reconcile_overlays;
/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
let ( globals, queue ) =
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
let qh = queue.handle();
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
WaylandSource::new( conn.clone(), queue )
.insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
// Try to bring EGL up. On failure (no libEGL, no compatible config,
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
// reason and every surface falls back to the SHM path.
let egl_context = match crate::egl_context::EglContext::new( &conn )
{
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
Err( reason ) =>
{
crate::egl_context::log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
// Bind layer-shell up front. Both the main surface (when requested via
// ShellMode::Layer) and every overlay returned by App::overlays() share
// this single binding.
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
// Backwards compatibility: window_config() overrides shell_mode()
let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError>
{
XdgShell::bind( globals, qh )
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
};
// Skipped when going fullscreen: Mutter rejects the fullscreen
// request if a min_size constrains it.
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen() { return; }
match app.window_size_hint()
{
Some( ( w, h ) ) =>
{
window.set_min_size( Some( ( w, h ) ) );
window.set_max_size( Some( ( w, h ) ) );
}
None =>
{
window.set_min_size( Some( ( 360, 480 ) ) );
}
}
};
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
{
if app.start_fullscreen()
{
window.set_fullscreen( None );
}
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() );
window.set_app_id( app_id.as_str() );
apply_size_hint( &window );
apply_fullscreen( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
} else {
// Use shell_mode() to determine surface type
use crate::app::ShellMode;
match app.shell_mode()
{
ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
ShellMode::Layer( layer ) => {
if layer_shell_opt.is_some()
{
// Defer surface creation until new_output fires: if we create the layer
// surface before the compositor has any output ready (e.g. sway startup),
// the compositor cannot assign it and logs an error.
let cfg = LayerConfig {
layer: layer.to_wlr_layer(),
exclusive_zone: app.exclusive_zone(),
anchor: app.layer_anchor(),
size: app.layer_size(),
keyboard_exclusive: app.keyboard_exclusive(),
namespace: "ltk-sctk",
};
( SurfaceKind::Pending( cfg ), None )
} else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) )
}
}
}
};
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
// fires. The list is built unconditionally so apps that override the
// callback always see a consistent stream when the compositor does carry
// the protocol.
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
let pending_fullscreen = app.start_fullscreen();
let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some();
let mut data = AppData
{
app,
registry_state: RegistryState::new( &globals ),
seat_state: SeatState::new( &globals, &qh ),
output_state: OutputState::new( &globals, &qh ),
compositor_state: compositor,
shm,
egl_context,
xdg_shell,
layer_shell: layer_shell_opt,
keyboard: None,
pointer: None,
touch: None,
pointer_pos: Point::default(),
cursor_shape_manager:
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
cursor_shape_device: None,
last_pointer_enter_serial: 0,
current_cursor_shape: None,
text_input_manager,
text_input: None,
foreign_toplevel_list,
shift_pressed: false,
ctrl_pressed: false,
loop_handle: event_loop.handle(),
compositor_repeat_rate: 0,
compositor_repeat_delay: 0,
key_repeat: None,
button_repeat: None,
clipboard: String::new(),
last_press_time: None,
last_press_pos: None,
debug_layout,
pending_msgs: Vec::new(),
pending_drag_inits: Vec::new(),
tooltip_pending: None,
tooltip_visible: None,
qh: qh.clone(),
last_pointer_serial: 0,
last_input_serial: 0,
exit_requested: false,
pending_fullscreen,
pending_size_hint_unpin,
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(),
pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(),
cached_view: None,
cached_overlays: None,
view_dirty: true,
overlays_dirty: true,
};
// Register a calloop channel so the app can send messages from any thread.
// Messages sent through the Sender wake the event loop immediately.
{
let ( sender, channel ) = calloop::channel::channel::<A::Message>();
event_loop.handle()
.insert_source(
channel,
|event, _, data: &mut AppData<A>|
{
if let calloop::channel::Event::Msg( msg ) = event
{
// Just queue the message — the run loop will run
// `App::invalidate_after` and apply the resulting
// scope, which is what decides which surfaces (if
// any) actually need to redraw.
data.pending_msgs.push( msg );
}
},
)
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
data.app.set_channel_sender( sender );
}
// Register a periodic timer if the app wants one (e.g. clock tick every second).
// The timer fires independently of Wayland events, waking the event loop on schedule.
if let Some( dur ) = data.app.poll_interval()
{
event_loop.handle()
.insert_source(
Timer::from_duration( dur ),
|_, _, data: &mut AppData<A>|
{
let msgs = data.app.poll_external();
data.pending_msgs.extend( msgs );
let next = data.app.poll_interval()
.unwrap_or( std::time::Duration::from_secs( 1 ) );
TimeoutAction::ToDuration( next )
},
)
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
}
while !data.exit_requested
{
// Sleep until something interesting fires:
// * Wayland event (input, configure, frame callback, …)
// * calloop timer (poll_external)
// * calloop channel (App::set_channel_sender)
// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
// off-screen / VRR display blocks indefinitely instead of polling
// at a fixed rate. The one exception is a pending long-press:
// cap the wait at its deadline so a perfectly still press still
// fires on time.
let timeout = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
{
( Some( a ), Some( b ) ) => Some( a.min( b ) ),
( a, b ) => a.or( b ),
};
match event_loop.dispatch( timeout, &mut data )
{
Ok( () ) => {},
Err( calloop::Error::IoError( ref e ) )
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
{
// On a fatal `wl_display.error` the compositor closes the
// socket; the backend has the typed error stashed but the
// dispatch result only carries the resulting `BrokenPipe`.
if let Some( pe ) = conn.protocol_error()
{
eprintln!(
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
pe.object_interface, pe.object_id, pe.code, pe.message
);
}
else
{
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
}
data.exit_requested = true;
continue;
}
Err( e ) => panic!( "dispatch: {e:?}" ),
}
// Any surface whose press has now crossed `long_press_duration`
// emits its stored message and flips into drag mode for the rest
// of the gesture.
data.check_long_press_deadlines();
data.check_tooltip_deadline();
// Poll external messages (immediate async results, e.g. PAM auth channel)
let ext: Vec<_> = data.app.poll_external();
data.pending_msgs.extend( ext );
// Process pending messages, folding their per-message invalidation
// scopes into a single decision before applying it.
let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
let had_msgs = !msgs.is_empty();
if had_msgs
{
let mut scope = InvalidationScope::Only( Vec::new() );
for msg in msgs
{
scope = scope.union( data.app.invalidate_after( &msg ) );
data.app.update( msg );
}
apply_invalidation( &mut data, scope );
// `update()` may have flipped the busy / loading flag
// the app reads from inside `cursor_override`. Re-sync
// the pointer cursor so the change propagates without
// waiting for the next motion event.
let pf = data.pointer_focus;
data.dispatch_cursor_shape( pf );
}
// Seed `on_drag_move` with the long-press origin for any drag that
// just started. Must run *after* `update()` so the app's drag state
// has already been set up by the paired long-press message — the
// coords would otherwise hit a dragging_app=None shell and be lost.
let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
if !drag_inits.is_empty()
{
for origin in drag_inits
{
data.app.on_drag_move( origin.x, origin.y );
}
data.view_dirty = true;
data.overlays_dirty = true;
data.main.request_redraw();
}
// After update() the app state is the source of truth — discard any
// pending text values so that the next keystroke reads the fresh state
// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
if !data.main.pending_text_values.is_empty()
{
data.main.pending_text_values.clear();
}
for ss in data.overlays.values_mut()
{
ss.pending_text_values.clear();
}
// Reconcile the overlay set against the app's current specs: drop any
// overlays whose id disappeared, create new ones for ids that just
// appeared. Specs are re-queried next frame for drawing.
reconcile_overlays( &mut data );
// Draw any surface that's configured, dirty, and not already waiting
// on a frame callback. The compositor decides our cadence — when no
// surface qualifies we just loop back to `dispatch(None)` and sleep.
let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending );
if any_drawable
{
// Rebuild while motion is in progress and on the first frame
// after it ends, so the settle frame paints at full quality
// instead of freezing one step short of the target.
// `wants_low_quality_paint` is the source of truth so
// finger-tracked drags get the same treatment. Slider /
// scroll drags are owned by the runtime gesture machine,
// not by `App::update`, so OR with the gesture state to
// pick up that motion signal too.
let runtime_slider_motion =
data.main.gesture.dragging_slider.is_some()
|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
if runtime_slider_motion
{
data.view_dirty = true;
data.overlays_dirty = true;
}
if data.view_dirty
{
data.cached_view = Some( data.app.view() );
data.view_dirty = false;
}
if data.overlays_dirty
{
let mut specs = data.app.overlays();
if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
data.cached_overlays = Some( specs );
data.overlays_dirty = false;
}
draw_frame( &mut data );
}
// Focus the widget with the requested WidgetId if the app requests it.
// Walks main + every overlay because the targeted widget can live
// on any of them (a search field on a launcher overlay, a text
// edit on a dialog modal, …).
if let Some( id ) = data.app.take_focus_request()
{
let mut hit: Option<( SurfaceFocus, usize )> = data.main.widget_rects.iter()
.find( |w| w.id == Some( id ) )
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
if hit.is_none()
{
for ( ov_id, surf ) in &data.overlays
{
if let Some( w ) = surf.widget_rects.iter().find( |w| w.id == Some( id ) )
{
hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
break;
}
}
}
if let Some( ( focus, flat_idx ) ) = hit
{
let qh = data.qh.clone();
data.set_focus( focus, Some( flat_idx ), &qh );
match focus
{
SurfaceFocus::Main => data.main.request_redraw(),
SurfaceFocus::Overlay( id ) =>
{
if let Some( s ) = data.overlays.get_mut( &id )
{
s.request_redraw();
}
}
}
}
}
}
Ok( () )
}
+380
View File
@@ -0,0 +1,380 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::
{
compositor::CompositorState,
shell::
{
WaylandSurface,
wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface },
xdg::{ popup::Popup, window::Window },
},
shm::{ Shm, slot::SlotPool },
};
use smithay_client_toolkit::reexports::client::
{
protocol::wl_surface::WlSurface,
QueueHandle,
};
use std::collections::HashMap;
use std::sync::Arc;
use super::app_data::AppData;
use crate::app::OverlayId;
use crate::egl_context::{ EglContext, EglSurface };
use crate::input::GestureState;
use crate::render::Canvas;
use crate::types::{ Point, Rect, WidgetId };
use crate::widget::LaidOutWidget;
/// Identifies which surface an event, focus, or draw call belongs to.
///
/// `Main` refers to the application's main surface (xdg window or layer
/// shell). `Overlay( id )` refers to an auxiliary layer-shell surface created
/// from an entry in [`crate::app::App::overlays`].
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
pub( crate ) enum SurfaceFocus
{
Main,
#[allow( dead_code )]
Overlay( OverlayId ),
}
/// Configuration for a layer-shell surface, used both for the main surface
/// (when the app uses [`crate::app::ShellMode::Layer`]) and for each overlay
/// returned by [`crate::app::App::overlays`].
#[derive( Clone )]
pub( crate ) struct LayerConfig
{
pub layer: Layer,
pub exclusive_zone: i32,
pub anchor: crate::app::Anchor,
pub size: ( u32, u32 ),
pub keyboard_exclusive: bool,
/// Wayland surface role namespace, sent to the compositor for debugging.
pub namespace: &'static str,
}
pub( crate ) enum SurfaceKind
{
/// Layer shell surface, fully created and committed.
Layer( LayerSurface ),
/// XDG window fallback (no layer shell compositor support).
Window( Window ),
/// Layer shell is available but we are waiting for an output to be
/// advertised before creating the Wayland surface.
Pending( LayerConfig ),
/// XDG popup, child of the main window. Used for combo dropdowns,
/// context menus, tooltips. The compositor positions it relative
/// to an anchor rect specified at creation time and may flip it
/// (drop-up vs drop-down) when constrained.
Popup( Popup ),
}
impl SurfaceKind
{
pub( crate ) fn wl_surface( &self ) -> &WlSurface
{
match self
{
SurfaceKind::Layer( l ) => l.wl_surface(),
SurfaceKind::Window( w ) => w.wl_surface(),
SurfaceKind::Popup( p ) => p.wl_surface(),
// Unreachable: draw_frame is only called when configured == true,
// which only becomes true after on_configure, which requires a real surface.
SurfaceKind::Pending( .. ) => unreachable!( "surface not yet created" ),
}
}
/// Same as [`wl_surface`] but returns `None` for a [`SurfaceKind::Pending`]
/// surface instead of panicking. Used by event dispatch to look up which
/// [`SurfaceState`] an incoming Wayland event belongs to without risk of
/// crashing during startup.
pub( crate ) fn try_wl_surface( &self ) -> Option<&WlSurface>
{
match self
{
SurfaceKind::Layer( l ) => Some( l.wl_surface() ),
SurfaceKind::Window( w ) => Some( w.wl_surface() ),
SurfaceKind::Popup( p ) => Some( p.wl_surface() ),
SurfaceKind::Pending( .. ) => None,
}
}
/// Create the actual layer surface on `output` using `layer_shell`. Returns
/// true if the surface was created (i.e., was `Pending` before).
pub( crate ) fn materialize(
&mut self,
compositor: &CompositorState,
layer_shell: &LayerShell,
qh: &QueueHandle<AppData<impl crate::app::App>>,
output: &smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput,
) -> bool
{
if let SurfaceKind::Pending( ref cfg ) = *self
{
let surface = compositor.create_surface( qh );
let layer_surface = layer_shell.create_layer_surface(
qh,
surface,
cfg.layer,
Some( cfg.namespace ),
Some( output ),
);
layer_surface.set_exclusive_zone( cfg.exclusive_zone );
layer_surface.set_anchor( cfg.anchor.to_wlr_anchor() );
let interactivity = if cfg.keyboard_exclusive
{
KeyboardInteractivity::Exclusive
} else {
KeyboardInteractivity::OnDemand
};
layer_surface.set_keyboard_interactivity( interactivity );
layer_surface.set_size( cfg.size.0, cfg.size.1 );
layer_surface.commit();
*self = SurfaceKind::Layer( layer_surface );
true
} else {
false
}
}
}
/// Per-surface render + interaction state.
///
/// Each Wayland surface managed by the event loop (main surface and any
/// active overlays) owns an instance of this struct, so multiple surfaces
/// can coexist without interfering.
pub( crate ) struct SurfaceState<Msg: Clone>
{
pub pool: Option<SlotPool>,
/// EGL window surface for the GPU path. Populated on the first configure
/// when an [`EglContext`] is available; mutually exclusive with `pool`
/// (the path is locked after the first configure).
pub egl_surface: Option<EglSurface>,
pub surface: SurfaceKind,
pub canvas: Option<Canvas>,
pub width: u32,
pub height: u32,
pub configured: bool,
pub needs_redraw: bool,
/// Set to `true` when the app's view may have changed in a way that rects
/// don't capture (text content, progress value, slider thumb position, etc).
/// Interaction-only transitions (hover in/out, pressed in/out) leave this
/// `false` so the draw path can take the partial-redraw fast path.
/// Always reset to `false` at the end of a successful draw.
pub content_dirty: bool,
pub last_draw: std::time::Instant,
pub focused_idx: Option<usize>,
pub focused_id: Option<WidgetId>,
pub hovered_idx: Option<usize>,
pub widget_rects: Vec<LaidOutWidget<Msg>>,
pub cursor_state: HashMap<usize, usize>,
/// Selection anchor (byte offset). When equal to the cursor, there
/// is no active selection. Mutated jointly with `cursor_state`:
/// plain arrow keys collapse the selection by setting anchor =
/// cursor; Shift+arrow extends by leaving anchor put while moving
/// cursor; pointer drags set both ends.
pub selection_anchor: HashMap<usize, usize>,
pub pending_text_values: HashMap<usize, String>,
pub scroll_offsets: HashMap<usize, f32>,
pub scroll_rects: Vec<( Rect, usize )>,
pub scroll_canvases: HashMap<usize, Canvas>,
/// Per-scroll list of `(flat_idx, content_y, height)` entries for
/// every interactive item the scroll's child laid out, in document
/// order. Includes items currently scrolled off-screen so keyboard
/// arrow handlers can step `hovered_idx` item-by-item without
/// regenerating the layout pass. Y is in pre-translation,
/// pre-offset content coordinates.
pub scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
/// Built-in Copy / Cut / Paste context menu when shown on this
/// surface. `None` means no menu is up.
pub context_menu: Option<super::context_menu::ContextMenu>,
/// Per-surface gesture state machine. Owns the press →
/// (long-press? / drag? / scroll? / swipe?) → release lifecycle,
/// driven by both pointer and touch handlers. The input layer
/// (`src/input/`) calls the lifecycle methods; the long-press
/// deadline poller reaches into `gesture.long_press_*` directly.
pub gesture: GestureState<Msg>,
/// Previous frame interaction state for damage tracking
pub prev_focused: Option<usize>,
pub prev_hovered: Option<usize>,
pub prev_pressed: Option<usize>,
/// Height of the client-side title bar (0 for layer-shell surfaces).
pub titlebar_height: f32,
/// Title text shown in the client-side title bar.
pub titlebar_title: String,
/// Rect of the close button in the title bar (for hit testing).
pub titlebar_close_rect: Rect,
/// Compositor scale factor (integer, e.g. 1 or 2). Used to create
/// high-DPI buffers and set `dpi_scale` on the canvas.
pub scale_factor: i32,
/// Last size requested from the compositor via `layer_surface.set_size`.
/// Compared against the app's current [`crate::app::OverlaySpec::size`]
/// each reconcile pass so that overlays can resize after creation (used
/// for slide-down / grow animations). `(0, 0)` for non-layer surfaces
/// and for layer surfaces before their first configure.
pub last_requested_size: ( u32, u32 ),
pub layer_anchor: Option<crate::app::Anchor>,
/// Anchor rect the xdg-popup positioner was last configured with.
/// `None` for non-popup surfaces.
pub last_popup_anchor: Option<Rect>,
/// Monotonic counter for `xdg_popup.reposition` tokens.
pub popup_reposition_token: u32,
/// `true` after the surface has been committed and we are waiting for the
/// matching `wl_surface.frame` callback to fire. Drawing is gated on this
/// being `false`: even if `needs_redraw` flips back to `true` we sit on
/// the next frame until the compositor signals it's time to commit again,
/// so the per-surface pacing follows the compositor (and the display
/// refresh / VRR / "screen is off") instead of a fixed-period timer.
pub frame_pending: bool,
}
impl<Msg: Clone> SurfaceState<Msg>
{
pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self
{
Self {
pool: None,
egl_surface: None,
surface,
canvas: None,
width: 0,
height: 0,
configured: false,
needs_redraw: false,
content_dirty: true,
last_draw: std::time::Instant::now() - std::time::Duration::from_millis( 100 ),
focused_idx: None,
focused_id: None,
hovered_idx: None,
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
pending_text_values: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_navigable_items: HashMap::new(),
context_menu: None,
scroll_canvases: HashMap::new(),
gesture: GestureState::new(),
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
titlebar_height,
titlebar_title,
titlebar_close_rect: Rect::default(),
scale_factor: 1,
last_requested_size: ( 0, 0 ),
layer_anchor: None,
last_popup_anchor: None,
popup_reposition_token: 0,
frame_pending: false,
}
}
/// Convert a logical (surface-local) position to physical pixel coordinates.
pub( crate ) fn to_physical( &self, x: f64, y: f64 ) -> Point
{
let sf = self.scale_factor.max( 1 ) as f32;
Point { x: x as f32 * sf, y: y as f32 * sf }
}
/// Surface width in physical pixels (logical × buffer scale). Matches the
/// coordinate space used by pointer/touch callbacks and layout, so swipe
/// thresholds can be compared against physical `dx` / `start.x` without
/// unit mixing.
pub( crate ) fn physical_width( &self ) -> u32
{
self.width * self.scale_factor.max( 1 ) as u32
}
/// Surface height in physical pixels. See [`Self::physical_width`].
pub( crate ) fn physical_height( &self ) -> u32
{
self.height * self.scale_factor.max( 1 ) as u32
}
/// Configure the surface with a new logical size. Picks the rendering path
/// on the very first configure (GPU when `egl_context` is `Some` and the
/// EGL window can be created, SHM otherwise) and just resizes the existing
/// path on subsequent configures. The two paths are mutually exclusive
/// (`pool.is_some()` ⇔ SHM, `egl_surface.is_some()` ⇔ GPU) and locked
/// after the first configure — switching backends mid-life would require
/// tearing down the canvas as well.
pub( crate ) fn on_configure(
&mut self,
shm: &Shm,
egl_context: Option<&Arc<EglContext>>,
w: u32,
h: u32,
)
{
self.width = w;
self.height = h;
let scale = self.scale_factor.max( 1 ) as u32;
let pw = w * scale;
let ph = h * scale;
if self.egl_surface.is_some()
{
// GPU path: resize the wl_egl_window so the next eglSwapBuffers
// publishes a buffer of the new size. The canvas FBO is reallocated
// by `Canvas::resize`, but only after we eglMakeCurrent — the draw
// path will do that, so we just record the pending size here.
if let Some( ref es ) = self.egl_surface
{
es.resize( pw as i32, ph as i32 );
}
} else if self.pool.is_some() {
// SHM path: reallocate the pool to fit the new buffer size.
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
} else {
// First configure — pick the path. Try GPU when the bootstrap
// succeeded; fall back to SHM if surface creation fails.
let mut chose_gpu = false;
if let Some( ctx ) = egl_context
{
match ctx.create_surface( self.surface.wl_surface(), pw as i32, ph as i32 )
{
Ok( es ) =>
{
self.egl_surface = Some( es );
chose_gpu = true;
}
Err( e ) =>
{
eprintln!( "[ltk] eglCreateWindowSurface failed: {e} — using SHM" );
}
}
}
if !chose_gpu
{
self.pool = Some(
SlotPool::new( ( pw * ph * 4 ) as usize, shm ).expect( "pool" ),
);
}
}
self.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
self.configured = true;
self.needs_redraw = true;
self.content_dirty = true;
}
/// Request a redraw and mark the surface's content as dirty.
///
/// Use this from every site that potentially changes what the app's view
/// returns (text edits, scroll offset, slider value, app messages, animation
/// tick, configure). Pure hover/pressed transitions should leave
/// `content_dirty` alone and only set `needs_redraw` directly so the draw
/// pass can take the cheap partial-redraw path.
pub( crate ) fn request_redraw( &mut self )
{
self.needs_redraw = true;
self.content_dirty = true;
}
}
+193
View File
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
impl<A: App> AppData<A>
{
/// Read the current text value of the focused widget — preferring
/// the pending typed-but-not-yet-applied value when present, falling
/// back to the value snapshot from the per-frame handler list.
pub( crate ) fn focused_text_value( &self, focus: SurfaceFocus ) -> Option<( usize, String )>
{
let idx = self.surface( focus ).focused_idx?;
let value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
crate::tree::find_handlers( &self.surface( focus ).widget_rects, idx )?
.current_value()
.map( |s| s.to_string() )
.unwrap_or_default()
};
Some( ( idx, value ) )
}
/// Move the text cursor one codepoint to the left. With `extend`,
/// the selection anchor stays put (Shift+Left widens the
/// selection); without it, the selection is collapsed to the new
/// cursor position. No-op when no text input is focused.
pub( crate ) fn handle_cursor_left( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
// Without shift: if a selection exists, collapse to its start.
if !extend && anchor != cursor
{
let s = cursor.min( anchor );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = s;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = s;
ss.request_redraw();
return;
}
if cursor == 0 { return; }
let prev_char = value[..cursor].chars().last();
let step = prev_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
let new_cursor = cursor.saturating_sub( step );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the text cursor one codepoint to the right.
pub( crate ) fn handle_cursor_right( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if !extend && anchor != cursor
{
let e = cursor.max( anchor );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = e;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = e;
ss.request_redraw();
return;
}
if cursor >= value.len() { return; }
let next_char = value[cursor..].chars().next();
let step = next_char.map( |c| c.len_utf8() ).unwrap_or( 1 );
let new_cursor = ( cursor + step ).min( value.len() );
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the text cursor up one *visual* row. With `extend`, the
/// selection anchor stays put.
///
/// In multiline mode the move follows the soft-wrapped layout — a
/// long logical line that wraps onto N visual rows lets Up step
/// through each of those rows in turn, instead of jumping straight
/// over them to the previous hard `\n`. Single-line inputs and
/// secure inputs both render as one visual row, so Up always
/// returns `false` and the runtime falls through to sibling-widget
/// keyboard navigation.
pub( crate ) fn handle_cursor_up( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
{
Some( g ) => g,
None => return false,
};
if !multiline || secure { return false; }
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false };
let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor )
{
Some( n ) => n,
None => return false,
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
true
}
/// Move the text cursor down one *visual* row. Mirror of
/// [`Self::handle_cursor_up`].
pub( crate ) fn handle_cursor_down( &mut self, focus: SurfaceFocus, extend: bool ) -> bool
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let ( rect, _, multiline, secure, _align, _font_size ) = match self.text_input_geometry( focus, idx )
{
Some( g ) => g,
None => return false,
};
if !multiline || secure { return false; }
let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false };
let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor )
{
Some( n ) => n,
None => return false,
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
true
}
/// Move the cursor to the start of the current *visual* row (Home
/// key). On a soft-wrapped logical line that lands at the wrap
/// point of the row the caret is on — a second Home press from
/// there does not move further, since the visual row already
/// starts at that boundary.
pub( crate ) fn handle_cursor_home( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let new_cursor = match self.text_input_geometry( focus, idx )
{
Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref()
{
Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor ),
None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
},
_ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ),
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
/// Move the cursor to the end of the current *visual* row (End
/// key). For a soft-wrapped row that's the wrap point; for a row
/// terminated by `\n` it's the byte just before the newline.
pub( crate ) fn handle_cursor_end( &mut self, focus: SurfaceFocus, extend: bool )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let new_cursor = match self.text_input_geometry( focus, idx )
{
Some( ( rect, _, true, false, _, _ ) ) => match self.surface( focus ).canvas.as_ref()
{
Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor ),
None => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
},
_ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ),
};
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = new_cursor;
if !extend { *ss.selection_anchor.entry( idx ).or_insert( 0 ) = new_cursor; }
ss.request_redraw();
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use smithay_client_toolkit::reexports::client::QueueHandle;
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3;
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_widget;
use crate::types::Rect;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self> )
{
if let ( Some( manager ), None ) = ( &self.text_input_manager, &self.text_input )
{
let seats: Vec<_> = self.seat_state.seats().collect();
if let Some( seat ) = seats.into_iter().next()
{
let ti = manager.get_text_input( &seat, qh, () );
ti.enable();
ti.set_content_type(
zwp_text_input_v3::ContentHint::None,
zwp_text_input_v3::ContentPurpose::Normal,
);
ti.commit();
self.text_input = Some( ti );
}
}
}
pub( crate ) fn deactivate_text_input( &mut self )
{
if let Some( ti ) = self.text_input.take()
{
ti.disable();
ti.commit();
ti.destroy();
}
}
/// Snapshot a focused-widget's geometry needed by the pointer
/// hit-testers for text editing. Returns `None` when the widget
/// isn't a TextEdit or its rect is missing — the helper above
/// short-circuits in that case.
pub( crate ) fn text_input_geometry(
&self,
focus: SurfaceFocus,
idx: usize,
) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, f32 )>
{
let ss = self.surface( focus );
let widget = find_widget( &ss.widget_rects, idx )?;
let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers
{
WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } =>
( value.clone(), *multiline, *secure, *align, *font_size ),
_ => return None,
};
// Prefer the *pending* value (typed-but-not-yet-applied) when
// it exists — that's what the user sees right now and what
// the cursor measurements should be relative to.
let value = ss.pending_text_values.get( &idx )
.cloned()
.unwrap_or( value_handler );
Some( ( widget.rect, value, multiline, secure, align, font_size ) )
}
}
@@ -0,0 +1,194 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_handlers;
use crate::widget::WidgetHandlers;
impl<A: App> AppData<A>
{
pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
// If a selection is active, replace it. The deletion path
// folds anchor=cursor and updates `pending_text_values`, so
// the rest of this fn keeps the same shape after.
let _ = self.delete_selection( focus );
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
// `select_on_focus` fields keep the cursor pinned to the end
// of the *displayed* value. The pending value the user just
// typed (e.g. "2") may render shorter than the post-update
// display value (e.g. "02" after a `format!("{:02}")`
// normalisation in the app's on_change handler), so anchoring
// the cursor at "end of pending" would land it mid-string in
// the next render. `usize::MAX` is a sentinel: every consumer
// (`draw`, arrow-key handlers, `byte_offset_at`) clamps via
// `cursor.min( value.len() )`, which resolves to "end of
// whatever value we eventually render". Without this, typing
// a second digit would insert into the middle of the
// normalised string ("0|2" + "3" → "032" → 32 instead of 23).
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let new_value;
{
let ss = self.surface_mut( focus );
let cursor = ss.cursor_state.entry( idx ).or_insert( current_value.len() );
let safe_cursor = (*cursor).min( current_value.len() );
let mut v = current_value.clone();
v.insert_str( safe_cursor, text );
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor + text.len() };
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, v.clone() );
ss.request_redraw();
new_value = v;
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
/// Forward delete — the `Delete` (Supr) key. Mirrors
/// [`Self::handle_backspace`] but removes the character *after*
/// the cursor instead of before. Selection-aware: if a range is
/// active it is removed in one step, exactly like backspace.
pub( crate ) fn handle_delete_forward( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
return;
}
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
let safe_cursor_pre = cursor_val.min( current_value.len() );
if safe_cursor_pre >= current_value.len() { return; }
// Width of the char *starting* at the cursor — UTF-8 aware so
// `é` / `🦀` come out as one keypress.
let next_char = current_value[safe_cursor_pre..].chars().next();
let step = match next_char { Some( c ) => c.len_utf8(), None => return };
let mut new_value = current_value.clone();
new_value.replace_range( safe_cursor_pre..safe_cursor_pre + step, "" );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { safe_cursor_pre };
{
let ss = self.surface_mut( focus );
// Cursor stays put — the char to its right is gone, so the
// remaining tail shifts left under it.
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
pub( crate ) fn handle_backspace( &mut self, focus: SurfaceFocus )
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return };
// Selection-aware: if a selection is active, backspace just
// deletes the range. The on_change message is emitted from
// here so the app sees a single text update.
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
return;
}
let current_value = if let Some( pending ) = self.surface( focus ).pending_text_values.get( &idx )
{
pending.clone()
} else {
find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.current_value() )
.map( |s| s.to_string() )
.unwrap_or_default()
};
// Scoped block to release the immutable borrow of `self` (via
// `self.surface( focus )`) before taking a mutable one below.
let cursor_val = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( current_value.len() );
if cursor_val == 0 { return; }
let safe_cursor = cursor_val.min( current_value.len() );
let chars: Vec<char> = current_value[..safe_cursor].chars().collect();
if chars.is_empty() { return; }
let removed_char = *chars.last().unwrap();
let new_cursor = safe_cursor - removed_char.len_utf8();
let mut new_value = current_value.clone();
new_value.remove( new_cursor );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { new_cursor };
{
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
}
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg
{
self.pending_msgs.push( m );
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Text-editing dispatch on [`super::app_data::AppData`]: cursor
//! movement, insert/delete, selection, IME activation and clipboard
//! integration. Lives on the runtime side; the corresponding widget
//! (`crate::widget::text_edit`) only owns the layout / draw path.
pub( crate ) mod cursor;
pub( crate ) mod insert_delete;
pub( crate ) mod selection;
pub( crate ) mod ime;
+263
View File
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::app::App;
use crate::event_loop::app_data::AppData;
use crate::event_loop::surface::SurfaceFocus;
use crate::tree::find_handlers;
use crate::types::Point;
use crate::widget::WidgetHandlers;
/// Compute the byte range of the "word" surrounding `byte_offset` in
/// `value`. A word is a maximal run of alphanumeric / underscore
/// chars; whitespace and punctuation count as separators. When
/// `byte_offset` falls *between* two non-word chars, both bounds
/// collapse to that offset (zero-width selection — the caller is
/// expected to either accept that or fall back to a click-position
/// cursor).
fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize )
{
if value.is_empty() { return ( 0, 0 ); }
let bo = byte_offset.min( value.len() );
let is_word = | c: char | c.is_alphanumeric() || c == '_';
// Walk left from `bo` until we cross a non-word boundary.
let mut start = bo;
{
let mut iter = value[..bo].char_indices().rev();
while let Some( ( i, c ) ) = iter.next()
{
if is_word( c ) { start = i; } else { break; }
}
}
// Walk right from `bo` until the same.
let mut end = bo;
for ( i, c ) in value[bo..].char_indices()
{
if is_word( c ) { end = bo + i + c.len_utf8(); } else { break; }
}
( start, end )
}
impl<A: App> AppData<A>
{
/// `true` when this press should be treated as the second click
/// of a double-click pair. Looks at the timestamp + position of
/// the previous press and compares against a 400 ms / 6 px
/// threshold (matching the GTK / Cocoa defaults). Always updates
/// the snapshot so the next press can pair with this one.
pub( crate ) fn note_press_for_double_click( &mut self, pos: Point ) -> bool
{
const WINDOW_MS: u128 = 400;
const RADIUS: f32 = 6.0;
let now = std::time::Instant::now();
let is_double = match ( self.last_press_time, self.last_press_pos )
{
( Some( t ), Some( p ) ) =>
{
let elapsed = now.duration_since( t ).as_millis();
let dx = pos.x - p.x;
let dy = pos.y - p.y;
elapsed <= WINDOW_MS && dx.hypot( dy ) <= RADIUS
}
_ => false,
};
if is_double
{
// Consume the snapshot so a third quick click is not
// re-paired with the same first click.
self.last_press_time = None;
self.last_press_pos = None;
} else {
self.last_press_time = Some( now );
self.last_press_pos = Some( pos );
}
is_double
}
/// Select the word under the press. The "word" is the maximal
/// run of alphanumeric / underscore chars surrounding the byte
/// offset that `pos` falls on; everything else (whitespace,
/// punctuation) counts as a separator. Empty value → no-op.
pub( crate ) fn handle_text_select_word( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// `select_on_focus` fields are short-form: the whole value is
// already the natural selection unit, so a double-click does
// not need to add a word-bound selection on top.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
if value.is_empty() { return; }
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let click_byte = {
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ( start, end ) = word_bounds_at( &value, click_byte );
let ss = self.surface_mut( focus );
ss.selection_anchor.insert( idx, start );
ss.cursor_state.insert( idx, end );
ss.request_redraw();
}
/// Position the text cursor at the click and start a fresh
/// selection (anchor = cursor). Called from the pointer / touch
/// press handler after focus has been moved to the TextEdit.
pub( crate ) fn handle_text_pointer_down( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// Fields with `select_on_focus` (numeric pickers, short-form
// inputs) keep the whole-value selection that `set_focus`
// just installed — clicking is the *focus* gesture and the
// next keystroke is meant to replace, not insert. Without
// this guard the click-to-position below would collapse the
// selection right after `set_focus` produced it.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ss = self.surface_mut( focus );
ss.cursor_state.insert( idx, byte_offset );
ss.selection_anchor.insert( idx, byte_offset );
ss.request_redraw();
}
/// Extend the selection by moving the cursor to the new pointer
/// position. Called from the pointer / touch motion handler while
/// a press is active and the original press landed on a TextEdit.
/// The selection anchor (set on press) stays put.
pub( crate ) fn handle_text_pointer_drag( &mut self, focus: SurfaceFocus, idx: usize, pos: Point )
{
// Same `select_on_focus` guard as the press-down path: the
// short-form field stays whole-selected even if the user
// drags the pointer over the digits.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
if select_on_focus { return; }
let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx )
{
Some( v ) => v,
None => return,
};
let cursor_pos = self.surface( focus ).cursor_state.get( &idx ).copied().unwrap_or( 0 );
let byte_offset =
{
let ss = self.surface( focus );
let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return };
crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
)
};
let ss = self.surface_mut( focus );
ss.cursor_state.insert( idx, byte_offset );
ss.request_redraw();
}
/// Collapse any active selection on the focused text input to its
/// cursor position. Returns `true` when a collapse happened so
/// the caller (Esc handler) can short-circuit other behaviour.
pub( crate ) fn collapse_selection_if_any( &mut self, focus: SurfaceFocus ) -> bool
{
let idx = match self.surface( focus ).focused_idx { Some( i ) => i, None => return false };
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied();
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied();
let ( c, a ) = match ( cursor, anchor )
{
( Some( c ), Some( a ) ) if c != a => ( c, a ),
_ => return false,
};
let _ = a;
let ss = self.surface_mut( focus );
ss.selection_anchor.insert( idx, c );
ss.request_redraw();
true
}
/// Select the entire value of the focused text input. Used by
/// Ctrl+A.
pub( crate ) fn handle_select_all( &mut self, focus: SurfaceFocus )
{
let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return };
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = value.len();
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = 0;
ss.request_redraw();
}
/// Snapshot the currently-selected text for the focused widget.
/// Returns `None` when no text input is focused or the selection
/// is empty.
pub( crate ) fn focused_selection_text( &self, focus: SurfaceFocus ) -> Option<String>
{
let ( idx, value ) = self.focused_text_value( focus )?;
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if anchor == cursor { return None; }
let s = cursor.min( anchor );
let e = cursor.max( anchor );
Some( value[s..e].to_string() )
}
/// Delete the current selection (if non-empty), updating the
/// pending text value and cursor / anchor. Returns the resulting
/// string when a deletion happened so the caller can dispatch the
/// `on_change` message; returns `None` when there was no selection
/// to delete.
pub( crate ) fn delete_selection( &mut self, focus: SurfaceFocus ) -> Option<String>
{
let ( idx, value ) = self.focused_text_value( focus )?;
let cursor = self.surface( focus ).cursor_state.get( &idx ).copied()
.unwrap_or( value.len() ).min( value.len() );
let anchor = self.surface( focus ).selection_anchor.get( &idx ).copied()
.unwrap_or( cursor ).min( value.len() );
if anchor == cursor { return None; }
let s = cursor.min( anchor );
let e = cursor.max( anchor );
let mut new_value = value;
new_value.replace_range( s..e, "" );
// `select_on_focus` fields keep the cursor at the end of the
// post-update displayed value via the `usize::MAX` sentinel —
// see `handle_text_insert` for the reasoning.
let select_on_focus = matches!(
find_handlers( &self.surface( focus ).widget_rects, idx ),
Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ),
);
let next_cursor = if select_on_focus { usize::MAX } else { s };
let ss = self.surface_mut( focus );
*ss.cursor_state.entry( idx ).or_insert( 0 ) = next_cursor;
*ss.selection_anchor.entry( idx ).or_insert( 0 ) = next_cursor;
ss.pending_text_values.insert( idx, new_value.clone() );
ss.request_redraw();
Some( new_value )
}
}
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::types::Rect;
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
#[derive( Clone )]
pub struct TooltipPending
{
pub focus: SurfaceFocus,
pub flat_idx: usize,
pub deadline: std::time::Instant,
pub text: String,
pub anchor: Rect,
}
#[derive( Clone )]
pub struct TooltipVisible
{
pub focus: SurfaceFocus,
pub anchor: Rect,
pub text: String,
}
impl<A: App> AppData<A>
{
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
{
let ss = self.surface( focus );
let Some( w ) = crate::tree::find_widget( &ss.widget_rects, flat_idx ) else { return };
let Some( text ) = w.tooltip.clone() else
{
self.tooltip_pending = None;
return;
};
let anchor = w.rect;
if let Some( ref p ) = self.tooltip_pending
{
if p.focus == focus && p.flat_idx == flat_idx { return; }
}
self.tooltip_pending = Some( TooltipPending
{
focus,
flat_idx,
deadline: std::time::Instant::now() + TOOLTIP_DELAY,
text,
anchor,
} );
if self.tooltip_visible.is_some()
{
self.tooltip_visible = None;
self.overlays_dirty = true;
}
}
pub( crate ) fn cancel_tooltip( &mut self )
{
let was_visible = self.tooltip_visible.take().is_some();
self.tooltip_pending = None;
if was_visible { self.overlays_dirty = true; }
}
pub( crate ) fn next_tooltip_wakeup( &self ) -> Option<std::time::Duration>
{
let p = self.tooltip_pending.as_ref()?;
Some( p.deadline.saturating_duration_since( std::time::Instant::now() ) )
}
pub( crate ) fn check_tooltip_deadline( &mut self )
{
let Some( p ) = self.tooltip_pending.as_ref() else { return };
if std::time::Instant::now() < p.deadline { return; }
let p = self.tooltip_pending.take().unwrap();
self.tooltip_visible = Some( TooltipVisible
{
focus: p.focus,
anchor: p.anchor,
text: p.text,
} );
self.overlays_dirty = true;
self.main.request_redraw();
}
pub( crate ) fn tooltip_overlay( &self ) -> Option<crate::app::OverlaySpec<A::Message>>
{
let v = self.tooltip_visible.as_ref()?;
let ( ox, oy ) = self.surface_offset_for( v.focus );
let scale = self.surface( v.focus ).scale_factor as f32;
let anchor_x = ox + v.anchor.x / scale;
let anchor_y = oy + v.anchor.y / scale;
let anchor_w = v.anchor.width / scale;
let anchor_h = v.anchor.height / scale;
let palette = crate::theme::palette();
let bg_col = crate::types::Color::rgba( palette.text_primary.r, palette.text_primary.g, palette.text_primary.b, 0.95 );
let pill: crate::widget::Element<A::Message> = crate::widget::container(
crate::widget::text( v.text.clone() )
.size( 13.0 )
.color( palette.bg )
)
.background( bg_col )
.padding_h( 12.0 )
.padding_v( 6.0 )
.radius( 8.0 )
.into();
let estimated_w = ( v.text.chars().count() as f32 * 7.5 + 24.0 ).clamp( 40.0, 320.0 );
let estimated_h = 28.0_f32;
let mut x = anchor_x + ( anchor_w - estimated_w ) / 2.0;
let mut y = anchor_y - estimated_h - 6.0;
let sw = self.main.width as f32;
let sh = self.main.height as f32;
x = x.clamp( 4.0, ( sw - estimated_w - 4.0 ).max( 4.0 ) );
if y < 4.0 { y = anchor_y + anchor_h + 6.0; }
if y + estimated_h > sh - 4.0 { y = ( sh - estimated_h - 4.0 ).max( 4.0 ); }
let view: crate::widget::Element<A::Message> = crate::layout::stack::stack()
.push_translated( pill, crate::layout::stack::HAlign::Start, crate::layout::stack::VAlign::Top, x, y )
.into();
use std::hash::{ Hash, Hasher };
let mut hasher = std::collections::hash_map::DefaultHasher::new();
"ltk-tooltip".hash( &mut hasher );
let id = crate::app::OverlayId( hasher.finish() as u32 );
Some( crate::app::OverlaySpec
{
id,
layer: crate::app::Layer::Overlay,
anchor: crate::app::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: -1,
keyboard_exclusive: false,
input_region: Some( Vec::new() ),
view,
on_dismiss: None,
anchor_widget_id: None,
} )
}
}