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:
+8
-1901
File diff suppressed because it is too large
Load Diff
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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( () )
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 )
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
} )
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user