An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools
ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there. The platform layer. Input reaches the runtime as platform-neutral events: `input/event.rs` defines `KeyInput`, `PointerInput` and `TouchInput`, and the pointer, touch and keyboard handlers take them through a few entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`). The sctk handler implementations moved to `platform/wayland/input.rs`, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms the `platform::Platform` trait, with its surface type as `Platform::Surface`. The protocol state moved from `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) from `SurfaceState` into `WaylandSurfaceState`, held as `SurfaceState::platform`. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`. Drawing is split from presenting: `draw/paint.rs` paints a surface in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths of `draw/software.rs` and `draw/gles.rs`, and the backend presents it (`platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`). The runtime is generic over the platform, `AppData<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, resolved at compile time. The backend-neutral half of a loop iteration is `event_loop/step.rs` (`process_messages`, `draw_due`, `next_wakeup`, `before_draw`, `after_draw`), and `event_loop/setup.rs` builds the state and the event sources every backend has; the accessibility tree and its actions moved to `event_loop/a11y.rs`. `platform/mock.rs` is a platform that logs what is asked of it, and `event_loop/tests.rs` drives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full. `ltk::Keysym` now comes from `xkeysym`, the crate sctk re-exported it from, so it is the same type. Features. `wayland`, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them. `android` brings android-activity 0.6, ndk 0.9 and jni 0.21. A new `build.rs` refuses both at once and sets `cfg( backend )` when one is on, which builds the runtime behind `run` and `try_run`. Without a backend only the runtime-free core builds: `UiSurface`, widgets, layout, theming and rendering. The Android backend. `ltk::run` runs inside a NativeActivity once `android_main` has handed its `AndroidApp`, re-exported as `ltk::AndroidApp`, to the new `ltk::set_android_app`; without it `run` fails with `RunError::NoAndroidApp`. The first call in the process sends standard output and error, panics included, to logcat, sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (`platform/android/java.rs`): copies go to `ClipboardManager` and its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app with `moveTaskToBack` or closes it, as `App::back_at_root` says, which the new `Platform::back_leaves` decides per backend; and the window's insets come from `getRootWindowInsets()` on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed, since a second activity started in a process whose `android_main` has returned never came up; an app should end the process after `run` returns. `ltk::keep_running( Some( text ) )` starts a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen, and `None` stops it. Surface insets. `SurfaceState::insets` holds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero. Resources in a package. `ltk::set_resource_dir( dir )` names an app's own copy of ltk's resources and `ltk::resource_dir()` returns it, so an app also finds its own bundled files there. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, its `keyboards/` before `/usr/share`, and a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's paths works unchanged. On Android `set_android_app` unpacks `assets/ltk/` into the internal storage and uses it as the resource directory; `assets/ltk/MANIFEST` lists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from `/system/fonts` and the primary face is the embedded Sora. APKs. `scripts/android-assets.sh` builds that tree from installed themes, without their cursors, with the fonts each `theme.json` names and ltk's keyboard layouts. `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` with Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded as `libmain.so`, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it against `android-framework-res`; adds the assets and the library; aligns and signs, creating a debug keystore when there is none. `ICON` takes an SVG, rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` compiles `java/net/liberux/ltk/KeepAlive.java` with javac and `dx` from art-standalone against the platform 34 `android.jar`, against which aapt then links too, since Debian's framework-res predates the `specialUse` service type, and declares the service with `KEEP_ALIVE_USE` as its reason and the permissions it needs. `examples/android_hello.rs` is the smoke test: `make android-check` typechecks the backend with Debian's rustc, `rust-src` and NDK through `-Zbuild-std`, `make android-apk` and `make android-install` package and install the example, `make deps` and `make android-deps` install what the targets need, and `make install` now copies `examples`, `scripts`, `java` and `build.rs` into the registry. The built-in keyboard for an app's own text input. While `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose`, so a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows, comes back when the serial changes and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method. Clippy. `make clippy` is clean again: derived `Default`s replace the hand-written ones, `div_ceil`, `then_some`, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes. `README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the features, the backend, the events and their units, and what stays with a backend. Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The runtime's side of accessibility: the tree handed to the adapter after a draw, and the actions an assistive technology sends back.
|
||||
|
||||
use accesskit::{ Action, ActionData, ActionRequest };
|
||||
|
||||
use crate::a11y::tree::{ build_tree, parse_id, SurfaceView };
|
||||
use crate::app::{ App, OverlayId };
|
||||
use crate::platform::Platform;
|
||||
use crate::types::Point;
|
||||
use crate::widget::WidgetHandlers;
|
||||
|
||||
use super::{ AppData, SurfaceFocus };
|
||||
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Hand the adapter the tree of the main surface and of every overlay over it. The tree is only built while an assistive technology listens.
|
||||
pub( crate ) fn publish_a11y_tree( &mut self )
|
||||
{
|
||||
let Some( mut a11y ) = self.a11y.take() else { return };
|
||||
let main_w = self.main.width as f32;
|
||||
let main_h = self.main.height as f32;
|
||||
let mut overlay_meta: Vec<( u8, OverlayId, f32, f32 )> = Vec::new();
|
||||
let mut next_id: u8 = 1;
|
||||
for ( id, ss ) in self.overlays.iter()
|
||||
{
|
||||
if next_id == 0 { break; }
|
||||
let ( ox, oy ) = self.surface_offset_for( SurfaceFocus::Overlay( *id ) );
|
||||
let ( w, h ) = ( ss.width as f32, ss.height as f32 );
|
||||
if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; }
|
||||
overlay_meta.push( ( next_id, *id, ox, oy ) );
|
||||
next_id = next_id.saturating_add( 1 );
|
||||
}
|
||||
let kb_focus_id = match self.keyboard_focus
|
||||
{
|
||||
SurfaceFocus::Main => 0u8,
|
||||
SurfaceFocus::Overlay( id ) =>
|
||||
overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ),
|
||||
};
|
||||
let mut surfaces: Vec<SurfaceView<A::Message>> = Vec::new();
|
||||
surfaces.push( SurfaceView
|
||||
{
|
||||
focus_id: 0,
|
||||
is_main: true,
|
||||
label: None,
|
||||
widget_rects: &self.main.frame.widget_rects,
|
||||
extras: &self.main.frame.accessible_extras,
|
||||
focused_idx: self.main.focused_idx,
|
||||
pressed_idx: self.main.gesture.pressed_idx,
|
||||
pending_text_values: &self.main.pending_text_values,
|
||||
cursor_state: &self.main.frame.cursor_state,
|
||||
width: main_w,
|
||||
height: main_h,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
} );
|
||||
for ( fid, id, ox, oy ) in &overlay_meta
|
||||
{
|
||||
let Some( ss ) = self.overlays.get( id ) else { continue };
|
||||
surfaces.push( SurfaceView
|
||||
{
|
||||
focus_id: *fid,
|
||||
is_main: false,
|
||||
label: None,
|
||||
widget_rects: &ss.frame.widget_rects,
|
||||
extras: &ss.frame.accessible_extras,
|
||||
focused_idx: ss.focused_idx,
|
||||
pressed_idx: ss.gesture.pressed_idx,
|
||||
pending_text_values: &ss.pending_text_values,
|
||||
cursor_state: &ss.frame.cursor_state,
|
||||
width: ss.width as f32,
|
||||
height: ss.height as f32,
|
||||
offset_x: *ox,
|
||||
offset_y: *oy,
|
||||
} );
|
||||
}
|
||||
let app_name = self.app.app_id();
|
||||
a11y.update( || build_tree( &surfaces, kb_focus_id, app_name ) );
|
||||
self.a11y = Some( a11y );
|
||||
}
|
||||
|
||||
/// Turn each pending action of an assistive technology (a screen reader pressing a button, a switch device moving the focus) into a press, a focus, a value or a scroll of its widget. Actions on widgets that went away since are dropped.
|
||||
pub( crate ) fn apply_a11y_actions( &mut self )
|
||||
{
|
||||
let requests: Vec<ActionRequest> = match self.a11y
|
||||
{
|
||||
Some( ref a ) => a.action_rx.try_iter().collect(),
|
||||
None => return,
|
||||
};
|
||||
if requests.is_empty() { return; }
|
||||
let overlay_keys: Vec<OverlayId> = self.overlays.keys().copied().collect();
|
||||
for req in requests
|
||||
{
|
||||
let Some( r ) = parse_id( req.target ) else { continue };
|
||||
if r.kind != 0 { continue; }
|
||||
let idx = r.idx as usize;
|
||||
let focus_target = if r.focus == 0
|
||||
{
|
||||
SurfaceFocus::Main
|
||||
} else {
|
||||
let oi = ( r.focus as usize ).saturating_sub( 1 );
|
||||
let Some( id ) = overlay_keys.get( oi ).copied() else { continue };
|
||||
SurfaceFocus::Overlay( id )
|
||||
};
|
||||
let widget = self.try_surface( focus_target )
|
||||
.and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() );
|
||||
match req.action
|
||||
{
|
||||
Action::Click =>
|
||||
{
|
||||
if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
Action::Focus => self.set_focus( focus_target, Some( idx ) ),
|
||||
Action::SetValue =>
|
||||
{
|
||||
let Some( w ) = widget else { continue };
|
||||
let msg = match ( &w.handlers, req.data )
|
||||
{
|
||||
( WidgetHandlers::Slider { .. }, Some( ActionData::NumericValue( v ) ) ) => w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 ),
|
||||
( WidgetHandlers::TextEdit { .. }, Some( ActionData::Value( s ) ) ) => w.handlers.text_change_msg( &s ),
|
||||
_ => None,
|
||||
};
|
||||
self.pending_msgs.extend( msg );
|
||||
}
|
||||
Action::Increment | Action::Decrement =>
|
||||
{
|
||||
let Some( w ) = widget else { continue };
|
||||
let WidgetHandlers::Slider { value, .. } = &w.handlers else { continue };
|
||||
let step = if matches!( req.action, Action::Increment ) { 0.05 } else { -0.05 };
|
||||
if let Some( msg ) = w.handlers.slider_change_msg( ( *value + step ).clamp( 0.0, 1.0 ) )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
Action::ScrollUp | Action::ScrollDown | Action::ScrollLeft | Action::ScrollRight =>
|
||||
{
|
||||
const STEP: f32 = 80.0;
|
||||
let ( dx, dy ) = match req.action
|
||||
{
|
||||
Action::ScrollLeft => ( -STEP, 0.0 ),
|
||||
Action::ScrollRight => ( STEP, 0.0 ),
|
||||
Action::ScrollUp => ( 0.0, -STEP ),
|
||||
_ => ( 0.0, STEP ),
|
||||
};
|
||||
let Some( ss ) = self.try_surface_mut( focus_target ) else { continue };
|
||||
let Some( &( _, scroll_idx, _ ) ) = ss.frame.scroll_rects.last() else { continue };
|
||||
let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
entry.0 = ( entry.0 + dx ).max( 0.0 );
|
||||
entry.1 = ( entry.1 + dy ).max( 0.0 );
|
||||
ss.request_redraw();
|
||||
}
|
||||
Action::ScrollIntoView =>
|
||||
{
|
||||
let Some( w ) = widget else { continue };
|
||||
let Some( ss ) = self.try_surface_mut( focus_target ) else { continue };
|
||||
let target = w.rect;
|
||||
let probe = Point { x: target.x + 1.0, y: target.y + 1.0 };
|
||||
let Some( ( r, idx, _ ) ) = ss.frame.scroll_rects.iter().rev()
|
||||
.find( |( r, _, _ )| r.contains( probe ) )
|
||||
.copied()
|
||||
else { continue };
|
||||
let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
if target.y < r.y { entry.1 -= r.y - target.y; }
|
||||
if target.y + target.height > r.y + r.height
|
||||
{
|
||||
entry.1 += ( target.y + target.height ) - ( r.y + r.height );
|
||||
}
|
||||
if target.x < r.x { entry.0 -= r.x - target.x; }
|
||||
if target.x + target.width > r.x + r.width
|
||||
{
|
||||
entry.0 += ( target.x + target.width ) - ( r.x + r.width );
|
||||
}
|
||||
entry.0 = entry.0.max( 0.0 );
|
||||
entry.1 = entry.1.max( 0.0 );
|
||||
ss.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-229
@@ -1,83 +1,22 @@
|
||||
// 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::{ wlr_layer::LayerShell, xdg::XdgShell },
|
||||
shm::Shm,
|
||||
session_lock::SessionLock,
|
||||
subcompositor::SubcompositorState,
|
||||
};
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::
|
||||
{
|
||||
wl_keyboard::WlKeyboard,
|
||||
wl_output::WlOutput,
|
||||
wl_pointer::WlPointer,
|
||||
wl_surface::WlSurface,
|
||||
wl_touch::WlTouch,
|
||||
},
|
||||
QueueHandle,
|
||||
};
|
||||
use wayland_protocols::wp::text_input::zv3::client::
|
||||
{
|
||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
zwp_text_input_v3::ZwpTextInputV3,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::{ App, OverlayId, SubsurfaceId };
|
||||
use crate::egl_context::EglContext;
|
||||
use crate::app::{ App, OverlayId };
|
||||
use crate::platform::{ NativePlatform, Platform };
|
||||
use crate::types::Point;
|
||||
|
||||
use super::repeat::{ ButtonRepeatState, KeyRepeatState };
|
||||
use super::subsurface::SubsurfaceSlot;
|
||||
use super::surface::{ SurfaceFocus, SurfaceState };
|
||||
use super::tooltip::{ TooltipPending, TooltipVisible };
|
||||
|
||||
pub struct AppData<A: App>
|
||||
/// The runtime's state for one app, on the platform `P`: the build's backend unless a test supplies another.
|
||||
pub struct AppData<A: App, P: Platform = NativePlatform<A>>
|
||||
{
|
||||
pub app: A,
|
||||
pub registry_state: RegistryState,
|
||||
pub seat_state: SeatState,
|
||||
pub output_state: OutputState,
|
||||
pub compositor_state: CompositorState,
|
||||
/// `wl_subcompositor` binding for [`crate::app::App::subsurfaces`].
|
||||
/// `None` when the compositor does not advertise the protocol.
|
||||
pub subcompositor: Option<SubcompositorState>,
|
||||
pub shm: Shm,
|
||||
pub session_lock: Option<SessionLock>,
|
||||
/// Process-wide EGL context (display + GLES context). `None` when EGL
|
||||
/// failed to initialise or `LTK_FORCE_SOFTWARE=1` — every surface then
|
||||
/// falls back to the SHM path.
|
||||
pub egl_context: Option<Arc<EglContext>>,
|
||||
#[ allow( dead_code ) ]
|
||||
pub xdg_shell: Option<XdgShell>,
|
||||
/// Shared layer-shell binding used for the main surface and every
|
||||
/// overlay. `None` when the compositor does not advertise the protocol.
|
||||
pub layer_shell: Option<LayerShell>,
|
||||
pub keyboard: Option<WlKeyboard>,
|
||||
pub pointer: Option<WlPointer>,
|
||||
pub touch: Option<WlTouch>,
|
||||
pub platform: P,
|
||||
pub pointer_pos: Point,
|
||||
/// Process-wide cursor-shape manager (`wp_cursor_shape_v1`).
|
||||
/// `None` when the compositor does not advertise the protocol —
|
||||
/// the runtime then leaves cursor shape to the compositor's
|
||||
/// defaults.
|
||||
pub cursor_shape_manager: Option<smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager>,
|
||||
/// Per-pointer cursor-shape device, populated when the compositor
|
||||
/// supports cursor-shape AND the seat has a pointer capability.
|
||||
pub cursor_shape_device: Option<smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
|
||||
/// Last `Enter` serial seen on a pointer event. Required by
|
||||
/// `wp_cursor_shape_device_v1::set_shape` per spec — the
|
||||
/// compositor tags every cursor change with the entry serial.
|
||||
pub last_pointer_enter_serial: u32,
|
||||
/// The cursor shape currently active on the pointer. `None`
|
||||
/// means "we have not pushed any shape since the last
|
||||
/// `wl_pointer.enter`" — the compositor is showing whatever the
|
||||
@@ -86,8 +25,6 @@ pub struct AppData<A: App>
|
||||
/// surface. `Some(s)` lets the dispatch short-circuit when the
|
||||
/// target equals what we already sent.
|
||||
pub current_cursor_shape: Option<crate::types::CursorShape>,
|
||||
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
||||
pub text_input: Option<ZwpTextInputV3>,
|
||||
/// No input method to ask for: the runtime draws its own keyboard under a focused text field.
|
||||
pub builtin_osk: bool,
|
||||
/// Whether the currently focused text field is `secure` (password).
|
||||
@@ -96,53 +33,11 @@ pub struct AppData<A: App>
|
||||
pub text_input_purpose: crate::osk::Purpose,
|
||||
/// Serial of the [`App::external_text_input`] request the text-input is held for.
|
||||
pub external_text_input: Option<u64>,
|
||||
/// `xdg-activation-v1`. Present when the compositor advertises the
|
||||
/// global. Used on first configure to honour an incoming
|
||||
/// `XDG_ACTIVATION_TOKEN` (a launcher that spawned us wants the
|
||||
/// main surface raised to focus) and exposed via
|
||||
/// [`App::request_activation_token`] for outbound requests.
|
||||
pub activation_state:
|
||||
Option<smithay_client_toolkit::activation::ActivationState>,
|
||||
/// Honour the activation token exactly once — after the first
|
||||
/// successful configure of the main surface. Subsequent configures
|
||||
/// (resizes, scale changes) must not re-activate.
|
||||
pub activation_token_pending: Option<String>,
|
||||
|
||||
/// `xdg-session-management-v1` proxies plus the on-disk store; the
|
||||
/// store is `None` when persistence is off (layer / lock surfaces,
|
||||
/// unusable app_id, concurrent instance, or after `replaced`).
|
||||
pub session: super::session::SessionRuntime,
|
||||
|
||||
/// `wl_data_device_manager` binding. `None` when the compositor
|
||||
/// does not advertise the global, in which case copy / paste stays
|
||||
/// process-local and inbound selections from other clients are
|
||||
/// invisible.
|
||||
pub data_device_manager:
|
||||
Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
|
||||
/// Per-seat `wl_data_device` handle, created the first time a seat
|
||||
/// with a keyboard or pointer capability appears. Required for
|
||||
/// `set_selection` and to receive inbound `selection` events.
|
||||
pub data_device:
|
||||
Option<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
|
||||
/// Currently-published outbound selection source. Held alive
|
||||
/// across [`DataSourceHandler::send_request`] so the cached
|
||||
/// clipboard text can be re-served on each paste from the peer.
|
||||
pub clipboard_source:
|
||||
Option<smithay_client_toolkit::data_device_manager::data_source::CopyPasteSource>,
|
||||
/// Sender half of the cross-thread channel used to ferry inbound
|
||||
/// selection bytes (a worker thread drains the read pipe).
|
||||
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
|
||||
/// Receiver half — drained once per run loop iteration.
|
||||
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
|
||||
pub primary: super::primary_selection::PrimarySelection,
|
||||
|
||||
/// Cross-application drag-and-drop: best mime negotiated on
|
||||
/// `enter` and the last pointer position (in logical surface
|
||||
/// coords). Cleared on `leave` and after `drop_performed`.
|
||||
pub drop_position: Option<( f64, f64 )>,
|
||||
pub drop_mime: Option<String>,
|
||||
pub drop_inbox_tx: std::sync::mpsc::Sender<super::data_device::DropPayload>,
|
||||
pub drop_inbox_rx: std::sync::mpsc::Receiver<super::data_device::DropPayload>,
|
||||
/// The selection the middle button pastes, ours or another application's.
|
||||
pub primary_selection: String,
|
||||
/// The selection of the focused text field when it was last looked at.
|
||||
pub primary_field: Option<String>,
|
||||
|
||||
/// AccessKit / AT-SPI2 adapter. `None` when the platform adapter
|
||||
/// could not be created (no AT-SPI2 daemon on the session bus,
|
||||
@@ -152,16 +47,6 @@ pub struct AppData<A: App>
|
||||
/// the per-frame tree update and the per-iteration action
|
||||
/// inbox drain.
|
||||
pub a11y: Option<crate::a11y::A11yState>,
|
||||
/// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the
|
||||
/// global proxy plus the live list of open toplevels; SCTK fans
|
||||
/// events through this object into our `ForeignToplevelListHandler`
|
||||
/// impl in `handlers.rs`, which then routes them to
|
||||
/// `App::on_toplevel_event`. Present on every session, even when
|
||||
/// the compositor does not advertise the global — internally the
|
||||
/// list holds a `GlobalProxy` that simply yields no toplevels in
|
||||
/// that case.
|
||||
pub foreign_toplevel_list:
|
||||
smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList,
|
||||
pub shift_pressed: bool,
|
||||
pub ctrl_pressed: bool,
|
||||
pub alt_pressed: bool,
|
||||
@@ -187,12 +72,9 @@ pub struct AppData<A: App>
|
||||
/// long-press promotion.
|
||||
pub button_repeat: Option<ButtonRepeatState>,
|
||||
/// Clipboard buffer. Copy / Cut store the selected text here;
|
||||
/// Paste retrieves from here. Synchronised with the Wayland
|
||||
/// selection through `wl_data_device_manager` when the compositor
|
||||
/// advertises the global: outbound copies publish a
|
||||
/// `CopyPasteSource`, and inbound selections from other clients
|
||||
/// land here through a worker thread (see
|
||||
/// [`super::data_device`]).
|
||||
/// Paste retrieves from here. Outbound copies are offered to other
|
||||
/// applications through [`Platform::publish_clipboard`]; the backend
|
||||
/// writes their selections here.
|
||||
pub clipboard: String,
|
||||
/// Timestamp of the previous press (pointer or touch). Combined
|
||||
/// with [`Self::last_press_pos`] it lets the press handler
|
||||
@@ -211,32 +93,16 @@ pub struct AppData<A: App>
|
||||
pub pending_drag_inits: Vec<Point>,
|
||||
pub tooltip_pending: Option<TooltipPending>,
|
||||
pub tooltip_visible: Option<TooltipVisible>,
|
||||
pub qh: QueueHandle<Self>,
|
||||
/// Last pointer serial (needed for interactive move).
|
||||
pub last_pointer_serial: u32,
|
||||
/// Last serial from any input event. Required by `xdg_popup.grab`,
|
||||
/// which only honours serials from recent input events on the same
|
||||
/// seat.
|
||||
pub last_input_serial: u32,
|
||||
/// Set to true when the app has accepted a close request.
|
||||
pub exit_requested: bool,
|
||||
/// First-configure latch for `App::start_fullscreen`.
|
||||
pub pending_fullscreen: bool,
|
||||
/// Last value of `App::fullscreen` sent to the compositor.
|
||||
pub applied_fullscreen: Option<bool>,
|
||||
/// First-configure latch: clears `max_size` after the compositor
|
||||
/// has honoured the pinned initial size.
|
||||
pub pending_size_hint_unpin: bool,
|
||||
/// First-configure latch: check the size hint against the screen.
|
||||
pub pending_size_hint_fit: bool,
|
||||
/// The size hint once shrunk to fit the screen; replaces the app's.
|
||||
pub fitted_size_hint: Option<( u32, u32 )>,
|
||||
/// Main application surface.
|
||||
pub main: SurfaceState<A::Message>,
|
||||
pub main: SurfaceState<A::Message, P::Surface>,
|
||||
/// Auxiliary layer-shell surfaces keyed by their stable [`OverlayId`].
|
||||
/// Populated and reconciled by the run loop from [`App::overlays`]. Always
|
||||
/// empty until overlay diffing is wired up.
|
||||
pub overlays: HashMap<OverlayId, SurfaceState<A::Message>>,
|
||||
pub overlays: HashMap<OverlayId, SurfaceState<A::Message, P::Surface>>,
|
||||
/// Surfaces covering every output beyond the first, paired with the
|
||||
/// output each one belongs to: session-lock surfaces while locked (the
|
||||
/// compositor blanks any output without one), background layer surfaces
|
||||
@@ -244,20 +110,7 @@ pub struct AppData<A: App>
|
||||
/// extended outputs). These render [`App::secondary_view`] and take no
|
||||
/// input (the input handlers find no focus for them, and keyboard falls
|
||||
/// through to `Main`).
|
||||
pub secondaries: Vec<( WlOutput, SurfaceState<A::Message> )>,
|
||||
/// Output the main surface was created on, so a later `new_output` for
|
||||
/// the same output does not add a duplicate secondary surface.
|
||||
pub main_output: Option<WlOutput>,
|
||||
/// Input-transparent child surfaces keyed by their stable
|
||||
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
|
||||
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
|
||||
/// Shared GLES canvas reused across every subsurface raster. Held here
|
||||
/// (not per-slot) so a subsurface that is dropped and re-created — e.g. a
|
||||
/// lazily-emitted sliding panel — does not recompile the ~14 shader
|
||||
/// programs (hundreds of ms on a mobile GPU) every time it reappears.
|
||||
pub subsurface_gles_canvas: Option<crate::render::Canvas>,
|
||||
/// GLES canvases of destroyed overlays, reused by the next overlay.
|
||||
pub canvas_pool: Vec<crate::render::Canvas>,
|
||||
pub secondaries: Vec<( P::Output, SurfaceState<A::Message, P::Surface> )>,
|
||||
/// Surface currently receiving pointer events (updated on each pointer
|
||||
/// event via its `surface` field).
|
||||
pub pointer_focus: SurfaceFocus,
|
||||
@@ -296,61 +149,31 @@ pub struct AppData<A: App>
|
||||
pub perf: super::perf::PerfState,
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Forward a change of [`App::fullscreen`] to the main toplevel.
|
||||
/// Whether the main surface is an application window the user can move, resize and minimise.
|
||||
pub( crate ) fn main_is_toplevel( &self ) -> bool
|
||||
{
|
||||
self.platform.is_toplevel( &self.main.platform )
|
||||
}
|
||||
|
||||
/// Forward a change of [`App::fullscreen`] to the main window.
|
||||
pub( crate ) fn sync_fullscreen( &mut self )
|
||||
{
|
||||
let Some( wanted ) = self.app.fullscreen() else { return };
|
||||
if self.applied_fullscreen == Some( wanted ) { return; }
|
||||
let super::SurfaceKind::Window( ref window ) = self.main.surface else { return };
|
||||
if wanted
|
||||
if self.platform.set_fullscreen( &self.main.platform, wanted )
|
||||
{
|
||||
window.set_fullscreen( None );
|
||||
} else {
|
||||
window.unset_fullscreen();
|
||||
self.applied_fullscreen = Some( wanted );
|
||||
}
|
||||
self.applied_fullscreen = Some( wanted );
|
||||
}
|
||||
|
||||
/// Find which [`SurfaceFocus`] owns the given `WlSurface`, or `None` if it
|
||||
/// does not correspond to any tracked surface. Safe even when the main
|
||||
/// surface is still `Pending` (returns `None`).
|
||||
pub( crate ) fn focus_for_surface(
|
||||
&self,
|
||||
wl: &WlSurface,
|
||||
) -> Option<SurfaceFocus>
|
||||
{
|
||||
if self.main.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
return Some( SurfaceFocus::Main );
|
||||
}
|
||||
for ( id, ss ) in self.overlays.iter()
|
||||
{
|
||||
if ss.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
return Some( SurfaceFocus::Overlay( *id ) );
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `wl` is one of the secondary per-output surfaces. Pointer and
|
||||
/// touch events landing on them must be dropped rather than falling back
|
||||
/// to `Main` — their coordinates would hit-test the main surface's
|
||||
/// widgets. Keyboard keeps the `Main` fallback so typing works wherever
|
||||
/// the compositor puts the focus.
|
||||
pub( crate ) fn is_secondary_surface( &self, wl: &WlSurface ) -> bool
|
||||
{
|
||||
self.secondaries.iter().any( |( _, ss )| ss.surface.try_wl_surface() == Some( wl ) )
|
||||
}
|
||||
|
||||
/// Borrow the [`SurfaceState`] identified by `focus`. Panics if `focus`
|
||||
/// refers to an overlay that is not currently registered — callers must
|
||||
/// only pass focus values obtained from [`focus_for_surface`] or from the
|
||||
/// only pass focus values obtained from the backend's surface lookup or from the
|
||||
/// per-device focus fields, which the run loop keeps in sync.
|
||||
#[ allow( dead_code ) ]
|
||||
pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState<A::Message>
|
||||
pub( crate ) fn surface( &self, focus: SurfaceFocus ) -> &SurfaceState<A::Message, P::Surface>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
@@ -363,7 +186,7 @@ impl<A: App> AppData<A>
|
||||
/// Non-panicking variant of [`surface`]. Returns `None` when `focus`
|
||||
/// refers to an overlay that has already been removed — callers on
|
||||
/// async dispatch paths (IME Done, tooltip arm) must use this.
|
||||
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message>>
|
||||
pub( crate ) fn try_surface( &self, focus: SurfaceFocus ) -> Option<&SurfaceState<A::Message, P::Surface>>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
@@ -374,7 +197,7 @@ impl<A: App> AppData<A>
|
||||
|
||||
/// Mutable counterpart of [`surface`].
|
||||
#[ allow( dead_code ) ]
|
||||
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message>
|
||||
pub( crate ) fn surface_mut( &mut self, focus: SurfaceFocus ) -> &mut SurfaceState<A::Message, P::Surface>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
@@ -385,7 +208,7 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
|
||||
/// Non-panicking variant of [`surface_mut`].
|
||||
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message>>
|
||||
pub( crate ) fn try_surface_mut( &mut self, focus: SurfaceFocus ) -> Option<&mut SurfaceState<A::Message, P::Surface>>
|
||||
{
|
||||
match focus
|
||||
{
|
||||
@@ -447,37 +270,20 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the main surface size, (re)allocate its rendering target, and
|
||||
// request a redraw. Routes to the GPU or SHM path inside `SurfaceState`
|
||||
// according to whether `self.egl_context` is available.
|
||||
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
|
||||
/// Tell the app and the accessibility tree the main surface is now `w × h` logical pixels at its scale, once the backend has resized it.
|
||||
pub( crate ) fn on_main_resized( &mut self, w: u32, h: u32 )
|
||||
{
|
||||
self.main.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
if let Some( ref mut a ) = self.a11y
|
||||
{
|
||||
let sf = self.main.scale_factor.max( 1 ) as f64;
|
||||
a.set_window_bounds( ( w as f64 ) * sf, ( h as f64 ) * sf );
|
||||
a.set_window_focus( true );
|
||||
}
|
||||
// First-configure latch: honour an incoming
|
||||
// `XDG_ACTIVATION_TOKEN` exactly once, after the surface has
|
||||
// been mapped (`xdg_activation_v1.activate` is only meaningful
|
||||
// against a configured surface). Compositors that don't
|
||||
// advertise the global leave `activation_state` as `None` and
|
||||
// the call drops the token silently.
|
||||
if let Some( token ) = self.activation_token_pending.take()
|
||||
{
|
||||
if let ( Some( ref activation ), Some( wl ) ) =
|
||||
( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( &wl, token );
|
||||
}
|
||||
}
|
||||
// `on_resize` is documented to deliver **physical** pixels, matching the
|
||||
// coordinate space that the layout passes and the pointer/touch
|
||||
// callbacks (`on_drag_move`, `on_drop`) work in. Wayland's
|
||||
// configure.new_size is surface-local (logical), so multiply by the
|
||||
// current buffer scale before handing the dimensions to the app.
|
||||
// callbacks (`on_drag_move`, `on_drop`) work in. A configure's size is
|
||||
// logical, so multiply by the current buffer scale before handing the
|
||||
// dimensions to the app.
|
||||
let sf = self.main.scale_factor.max( 1 ) as u32;
|
||||
crate::types::set_viewport_size( w * sf, h * sf );
|
||||
crate::types::set_density( sf as f32 );
|
||||
|
||||
@@ -4,18 +4,45 @@
|
||||
use super::app_data::AppData;
|
||||
use super::surface::SurfaceFocus;
|
||||
use crate::app::App;
|
||||
use crate::platform::Platform;
|
||||
use crate::tree::find_handlers;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Copy the currently-selected text into the process-local
|
||||
/// clipboard. No-op when there is no selection.
|
||||
/// Make `text` the clipboard, for other applications as well when the system allows.
|
||||
pub( crate ) fn publish_clipboard( &mut self, text: String )
|
||||
{
|
||||
self.platform.publish_clipboard( &text );
|
||||
self.clipboard = text;
|
||||
}
|
||||
|
||||
/// Make `text` the selection the middle button pastes, for other applications as well when the system allows.
|
||||
pub( crate ) fn publish_primary_selection( &mut self, text: String )
|
||||
{
|
||||
self.platform.publish_primary_selection( &text );
|
||||
self.primary_selection = text;
|
||||
}
|
||||
|
||||
/// Publish a new selection in the focused text field once the press that makes it ends; a password field's never leaves it.
|
||||
pub( crate ) fn sync_text_field( &mut self )
|
||||
{
|
||||
let focus = self.keyboard_focus;
|
||||
if self.try_surface( focus ).is_none_or( |ss| ss.gesture.pressed_idx.is_some() ) { return; }
|
||||
let secure = self.surface( focus ).focused_idx
|
||||
.and_then( |idx| self.text_input_geometry( focus, idx ) )
|
||||
.is_some_and( |( _, _, _, secure, _, _ )| secure );
|
||||
let selected = if secure { None } else { self.focused_selection_text( focus ) };
|
||||
if selected == self.primary_field { return; }
|
||||
self.primary_field = selected.clone();
|
||||
if let Some( text ) = selected { self.publish_primary_selection( text ); }
|
||||
}
|
||||
|
||||
/// Copy the selected text to the 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;
|
||||
self.publish_clipboard_selection();
|
||||
self.publish_clipboard( text );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +53,7 @@ impl<A: App> AppData<A>
|
||||
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;
|
||||
self.publish_clipboard_selection();
|
||||
self.publish_clipboard( text );
|
||||
}
|
||||
if let Some( new_value ) = self.delete_selection( focus )
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::surface::SurfaceFocus;
|
||||
use crate::app::App;
|
||||
use crate::tree::find_handlers;
|
||||
use crate::types::{ Point, Rect };
|
||||
use crate::platform::Platform;
|
||||
|
||||
/// Built-in context menu for text editing — Copy / Cut / Paste rows
|
||||
/// drawn on top of the surface content. Shown on a right-click (or
|
||||
@@ -66,7 +67,7 @@ impl ContextMenu
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Open the built-in Copy / Cut / Paste context menu near `pos`,
|
||||
/// targeting the TextEdit at `widget_idx`. Clamps the menu rect
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
// 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 super::surface::SurfaceFocus;
|
||||
use crate::app::App;
|
||||
use crate::platform::Platform;
|
||||
use crate::types::{ CursorShape, Point };
|
||||
|
||||
/// The edge or corner of the window an interactive resize drags.
|
||||
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
|
||||
pub( crate ) enum ResizeEdge
|
||||
{
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -46,58 +59,11 @@ pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape
|
||||
ResizeEdge::TopRight => NeResize,
|
||||
ResizeEdge::BottomLeft => SwResize,
|
||||
ResizeEdge::BottomRight => SeResize,
|
||||
_ => Default,
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// 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:
|
||||
@@ -148,12 +114,7 @@ impl<A: App> AppData<A>
|
||||
// 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 );
|
||||
}
|
||||
if self.platform.set_cursor_shape( target ) { self.current_cursor_shape = Some( target ); }
|
||||
}
|
||||
|
||||
/// Width (logical pixels) of the resize-grab band along each
|
||||
@@ -168,7 +129,7 @@ impl<A: App> AppData<A>
|
||||
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; }
|
||||
if !self.main_is_toplevel() { 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;
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `wl_data_device_manager` integration — cross-application
|
||||
//! clipboard.
|
||||
//!
|
||||
//! This module bridges ltk's process-local `clipboard: String` to the
|
||||
//! Wayland selection so that:
|
||||
//!
|
||||
//! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is
|
||||
//! populated, [`AppData::publish_clipboard_selection`] creates a
|
||||
//! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and
|
||||
//! installs it as the seat's selection. When a peer pastes,
|
||||
//! [`DataSourceHandler::send`] writes the cached string into the
|
||||
//! fd the peer hands us.
|
||||
//!
|
||||
//! * **Inbound (Ctrl+V from another app)** — when the compositor
|
||||
//! advertises a new selection through
|
||||
//! [`DataDeviceHandler::selection`], we ask for it as UTF-8 text
|
||||
//! via `WlDataOffer::receive`, spawn a tiny thread to drain the
|
||||
//! read pipe, and post the result back to the event loop through
|
||||
//! the runtime's existing `ChannelSender`-style channel. The
|
||||
//! message just updates `self.clipboard`; subsequent local pastes
|
||||
//! pick the new value up the next time the user hits Ctrl+V.
|
||||
//!
|
||||
//! Drag-and-drop across applications uses the same protocol but
|
||||
//! involves enter / leave / motion / drop_performed wiring with
|
||||
//! widget hit-tests, surface coordinates and drop targets that are a
|
||||
//! separate concern. The handlers in this file accept those events
|
||||
//! but currently route them to no-ops; an explicit follow-up will
|
||||
//! cover DnD targets.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use smithay_client_toolkit::data_device_manager::data_device::{ DataDeviceData, DataDeviceHandler };
|
||||
use smithay_client_toolkit::data_device_manager::data_offer::{ DataOfferHandler, DragOffer };
|
||||
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
|
||||
use smithay_client_toolkit::data_device_manager::{ ReadPipe, WritePipe };
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::
|
||||
{
|
||||
wl_data_device::WlDataDevice,
|
||||
wl_data_device_manager::DndAction,
|
||||
wl_data_source::WlDataSource,
|
||||
wl_surface::WlSurface,
|
||||
},
|
||||
Connection, Proxy, QueueHandle,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use super::app_data::AppData;
|
||||
|
||||
/// MIME type we advertise on outbound copies and request on inbound
|
||||
/// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit
|
||||
/// baseline (GTK, Qt, electron, Firefox all carry it); `UTF8_STRING`
|
||||
/// is the X11-era legacy alias still emitted by some compositors.
|
||||
pub( super ) const CLIPBOARD_MIMES: &[ &str ] = &[ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
|
||||
|
||||
/// Mimes accepted for inter-application drag-and-drop. URI lists
|
||||
/// (file managers, browsers) come first because they are the most
|
||||
/// useful payload for typical desktop drops.
|
||||
pub( super ) const DND_MIMES: &[ &str ] = &[ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ];
|
||||
|
||||
/// Payload delivered from the DnD worker thread back to the run loop.
|
||||
pub( crate ) struct DropPayload
|
||||
{
|
||||
pub mime: String,
|
||||
pub text: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender<DropPayload>, std::sync::mpsc::Receiver<DropPayload> )
|
||||
{
|
||||
std::sync::mpsc::channel()
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// Publish the current `self.clipboard` value as the seat's
|
||||
/// selection so other applications can paste it. Called by the
|
||||
/// copy / cut handlers after they populate the local buffer.
|
||||
/// No-op when the compositor does not advertise
|
||||
/// `wl_data_device_manager` or when no seat has appeared yet.
|
||||
pub( crate ) fn publish_clipboard_selection( &mut self )
|
||||
{
|
||||
let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return };
|
||||
let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() );
|
||||
source.set_selection( dd, self.last_input_serial );
|
||||
// Hold the source alive: the compositor only keeps the
|
||||
// selection while the `WlDataSource` is not dropped. We also
|
||||
// need to hang on to it so `DataSourceHandler::send` can find
|
||||
// the cached text via `self.clipboard`.
|
||||
self.clipboard_source = Some( source );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> DataDeviceHandler for AppData<A>
|
||||
{
|
||||
fn enter(
|
||||
&mut self,
|
||||
_c: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
d: &WlDataDevice,
|
||||
x: f64,
|
||||
y: f64,
|
||||
_s: &WlSurface,
|
||||
)
|
||||
{
|
||||
let Some( data ) = d.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.drag_offer() else { return };
|
||||
let mimes = offer.with_mime_types( |m| m.to_vec() );
|
||||
let accepted = DND_MIMES.iter().copied()
|
||||
.find( |c| mimes.iter().any( |m| m == *c ) )
|
||||
.map( |s| s.to_string() );
|
||||
offer.accept_mime_type( self.last_input_serial, accepted.clone() );
|
||||
offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy,
|
||||
smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy );
|
||||
self.drop_position = Some( ( x, y ) );
|
||||
self.drop_mime = accepted;
|
||||
self.app.on_drop_motion( x as f32, y as f32 );
|
||||
}
|
||||
|
||||
fn leave( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice )
|
||||
{
|
||||
if self.drop_position.is_some() { self.app.on_drop_leave(); }
|
||||
self.drop_position = None;
|
||||
self.drop_mime = None;
|
||||
}
|
||||
|
||||
fn motion( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice, x: f64, y: f64 )
|
||||
{
|
||||
self.drop_position = Some( ( x, y ) );
|
||||
self.app.on_drop_motion( x as f32, y as f32 );
|
||||
}
|
||||
|
||||
fn drop_performed( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, d: &WlDataDevice )
|
||||
{
|
||||
let Some( data ) = d.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.drag_offer() else { return };
|
||||
let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return };
|
||||
let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) );
|
||||
let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return };
|
||||
offer.finish();
|
||||
let tx = self.drop_inbox_tx.clone();
|
||||
std::thread::spawn( move ||
|
||||
{
|
||||
use std::io::Read;
|
||||
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
|
||||
let mut buf = String::new();
|
||||
let mut bounded = ( &mut reader ).take( 16 * 1024 * 1024 );
|
||||
let _ = bounded.read_to_string( &mut buf );
|
||||
let _ = tx.send( DropPayload { mime, text: buf, x, y } );
|
||||
} );
|
||||
self.drop_position = None;
|
||||
self.drop_mime = None;
|
||||
}
|
||||
|
||||
/// A new selection arrived from another client. Negotiate
|
||||
/// UTF-8 text on a background thread (the receive side of the
|
||||
/// data offer is a blocking read pipe) and post the result back
|
||||
/// into the event loop through `clipboard_inbox`. The main loop
|
||||
/// drains the inbox once per iteration and copies the bytes
|
||||
/// into `self.clipboard`.
|
||||
fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, data_device: &WlDataDevice )
|
||||
{
|
||||
let Some( data ) = data_device.data::<DataDeviceData>() else { return };
|
||||
let Some( offer ) = data.selection_offer() else { return };
|
||||
let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return };
|
||||
let Ok( pipe ) = offer.receive( mime ) else { return };
|
||||
read_selection( pipe, self.clipboard_inbox_tx.clone() );
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain an offered selection on a thread of its own and post the text to `tx`.
|
||||
pub( super ) fn read_selection( pipe: ReadPipe, tx: mpsc::Sender<String> )
|
||||
{
|
||||
std::thread::spawn( move ||
|
||||
{
|
||||
let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
|
||||
let mut buf = String::new();
|
||||
// 16 MiB cap. Compositors are free to keep typing into the
|
||||
// pipe forever; a runaway selection should not exhaust
|
||||
// memory or stall the worker thread indefinitely.
|
||||
let mut bounded = (&mut reader).take( 16 * 1024 * 1024 );
|
||||
let _ = bounded.read_to_string( &mut buf );
|
||||
let _ = tx.send( buf );
|
||||
} );
|
||||
}
|
||||
|
||||
/// Serve `text` to the peer that asked for one of our selections.
|
||||
pub( super ) fn write_selection( pipe: WritePipe, text: &str )
|
||||
{
|
||||
// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
|
||||
// (no buffering — selection payloads are small). Errors (peer
|
||||
// closed early, broken pipe) are ignored: we cannot recover and
|
||||
// the compositor will issue a fresh source on the next copy anyway.
|
||||
use std::io::Write;
|
||||
let mut writer = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) );
|
||||
let _ = writer.write_all( text.as_bytes() );
|
||||
}
|
||||
|
||||
impl<A: App> DataOfferHandler for AppData<A>
|
||||
{
|
||||
fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
|
||||
fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
|
||||
}
|
||||
|
||||
impl<A: App> DataSourceHandler for AppData<A>
|
||||
{
|
||||
fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _mime: Option<String> ) {}
|
||||
|
||||
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource, _mime: String, fd: WritePipe )
|
||||
{
|
||||
// Only honour requests for our own active source — if a stale
|
||||
// source is still hanging around (e.g. between a new copy and
|
||||
// the compositor dropping the previous selection) the peer
|
||||
// might still ask for its data; we just write nothing rather
|
||||
// than leak the new clipboard contents through an old source.
|
||||
let is_active = self.clipboard_source.as_ref()
|
||||
.map( |s| s.inner() == source )
|
||||
.unwrap_or( false );
|
||||
if !is_active { return; }
|
||||
write_selection( fd, &self.clipboard );
|
||||
}
|
||||
|
||||
fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource )
|
||||
{
|
||||
// Compositor dropped our source (another client claimed the
|
||||
// selection). Drop the cached handle so a future copy creates
|
||||
// a fresh one.
|
||||
if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false )
|
||||
{
|
||||
self.clipboard_source = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn dnd_dropped( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
|
||||
fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
|
||||
fn action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _action: DndAction ) {}
|
||||
}
|
||||
|
||||
/// Pick the best-supported MIME type from those a selection offers.
|
||||
/// Order matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text,
|
||||
/// then the legacy `UTF8_STRING`.
|
||||
pub( super ) fn pick_text_mime( mimes: &[ String ] ) -> Option<String>
|
||||
{
|
||||
CLIPBOARD_MIMES.iter()
|
||||
.find( |c| mimes.iter().any( |m| m == *c ) )
|
||||
.map( |s| s.to_string() )
|
||||
}
|
||||
|
||||
/// Buffered channel pair used to ferry inbound-selection bytes from
|
||||
/// the read-pipe worker thread to the event loop. The receiver is
|
||||
/// drained once per iteration inside the main run loop.
|
||||
pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender<String>, mpsc::Receiver<String> )
|
||||
{
|
||||
mpsc::channel()
|
||||
}
|
||||
@@ -5,8 +5,9 @@ use super::app_data::AppData;
|
||||
use super::surface::{ SurfaceFocus, SurfaceState };
|
||||
use crate::app::App;
|
||||
use crate::types::Point;
|
||||
use crate::platform::Platform;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Fire long-press messages for any surface whose deadline has
|
||||
/// elapsed. Idempotent — if a press is in-flight but not yet due this
|
||||
@@ -30,7 +31,7 @@ impl<A: App> AppData<A>
|
||||
// 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>|
|
||||
let fire = |ss: &mut SurfaceState<A::Message, P::Surface>|
|
||||
-> Option<( Option<A::Message>, Option<A::Message>, Point, Option<usize> )>
|
||||
{
|
||||
let start = ss.gesture.long_press_start?;
|
||||
@@ -100,7 +101,7 @@ impl<A: App> AppData<A>
|
||||
|
||||
pub( crate ) fn clear_long_press_drag( &mut self )
|
||||
{
|
||||
let clear = |ss: &mut SurfaceState<A::Message>|
|
||||
let clear = |ss: &mut SurfaceState<A::Message, P::Surface>|
|
||||
{
|
||||
ss.gesture.long_press_start = None;
|
||||
ss.gesture.long_press_origin = None;
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
/// 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
|
||||
/// connection, the calloop event loop, one of the protocol bindings
|
||||
/// the runtime cannot operate without (`wl_compositor`, `wl_shm`,
|
||||
/// `xdg_wm_base`). Once init succeeds, the compositor disconnecting
|
||||
/// `xdg_wm_base`), or on Android the missing `AndroidApp`. 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
|
||||
@@ -38,6 +38,9 @@ pub enum RunError
|
||||
name: &'static str,
|
||||
detail: String,
|
||||
},
|
||||
/// On Android, `ltk::run` was called before `android_main` handed
|
||||
/// its `AndroidApp` to `ltk::set_android_app`.
|
||||
NoAndroidApp,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunError
|
||||
@@ -54,6 +57,8 @@ impl std::fmt::Display for RunError
|
||||
write!( f, "event-loop setup failed: {d}" ),
|
||||
Self::MissingProtocol { name, detail } =>
|
||||
write!( f, "Wayland protocol `{name}` unavailable: {detail}" ),
|
||||
Self::NoAndroidApp =>
|
||||
write!( f, "no AndroidApp: call ltk::set_android_app from android_main before ltk::run" ),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// 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 };
|
||||
@@ -10,8 +8,9 @@ use crate::tree::find_handlers;
|
||||
use crate::types::{ Point, WidgetId };
|
||||
use crate::widget::WidgetHandlers;
|
||||
use crate::widget::scroll::offset_into_view;
|
||||
use crate::platform::Platform;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Push `on_dismiss` for every active xdg-popup overlay whose
|
||||
/// anchor widget was not hit by a press at `pos` (logical pixels
|
||||
@@ -98,7 +97,7 @@ impl<A: App> AppData<A>
|
||||
self.dirty_caches();
|
||||
}
|
||||
|
||||
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize>, qh: &QueueHandle<Self> )
|
||||
pub( crate ) fn set_focus( &mut self, focus: SurfaceFocus, idx: Option<usize> )
|
||||
{
|
||||
let was_text_input;
|
||||
let is_text_input;
|
||||
@@ -192,7 +191,7 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
else if is_text_input && !was_text_input
|
||||
{
|
||||
self.activate_text_input( qh, secure, purpose );
|
||||
self.activate_text_input( secure, purpose );
|
||||
self.show_osk( focus );
|
||||
self.app.on_text_input_focused( true );
|
||||
self.dirty_caches();
|
||||
@@ -201,7 +200,7 @@ impl<A: App> AppData<A>
|
||||
{
|
||||
// Focus moved between text fields: refresh the content type so a
|
||||
// password field is flagged even without re-activating.
|
||||
self.activate_text_input( qh, secure, purpose );
|
||||
self.activate_text_input( secure, purpose );
|
||||
self.show_osk( focus );
|
||||
}
|
||||
}
|
||||
@@ -231,7 +230,7 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
|
||||
/// Whether the surface's focused widget is a text input in its current frame.
|
||||
pub( crate ) fn surface_focused_text_input<Msg: Clone>( ss: &super::surface::SurfaceState<Msg> ) -> bool
|
||||
pub( crate ) fn surface_focused_text_input<Msg: Clone, S>( ss: &super::surface::SurfaceState<Msg, S> ) -> bool
|
||||
{
|
||||
let by_id = ss.focused_id.and_then( |id|
|
||||
{
|
||||
|
||||
+35
-86
@@ -1,41 +1,21 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use std::sync::Arc;
|
||||
use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
|
||||
use smithay_client_toolkit::shm::Shm;
|
||||
|
||||
use crate::app::{ App, SurfaceTarget };
|
||||
use crate::egl_context::EglContext;
|
||||
use crate::platform::{ Platform, Presentation };
|
||||
use crate::types::{ Color, Rect };
|
||||
use crate::widget::Element;
|
||||
|
||||
use super::AppData;
|
||||
use super::focus::surface_focused_text_input;
|
||||
use super::surface::{ SurfaceFocus, SurfaceState };
|
||||
use crate::draw::gles::{ draw_surface_full_gpu, draw_surface_partial_gpu };
|
||||
use crate::draw::software::{ draw_surface_full, draw_surface_partial };
|
||||
use crate::draw::{ paint_full, paint_partial };
|
||||
use crate::draw::damage::compute_interaction_dirty_rects;
|
||||
|
||||
/// Pick the best wl_shm format for our RGBA-premultiplied pixmap.
|
||||
/// Abgr8888 matches tiny-skia's byteorder on little-endian systems (plain
|
||||
/// memcpy). Falls back to mandatory Argb8888 when not advertised.
|
||||
/// Returns `(format, swap_rb)`.
|
||||
pub( crate ) fn pick_shm_format( shm: &Shm ) -> ( wl_shm::Format, bool )
|
||||
{
|
||||
if shm.formats().contains( &wl_shm::Format::Abgr8888 )
|
||||
{
|
||||
( wl_shm::Format::Abgr8888, false )
|
||||
} else {
|
||||
( wl_shm::Format::Argb8888, true )
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when this call performed the very first commit on the main
|
||||
/// surface — the run loop uses that to fire `App::on_first_frame_committed`
|
||||
/// exactly once, after the borrows held by draw_frame have been released.
|
||||
pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
pub( crate ) fn draw_frame<A: App, P: Platform>( data: &mut AppData<A, P> ) -> bool
|
||||
{
|
||||
let main_view = data.cached_view.as_ref().expect( "view cache populated" );
|
||||
let overlays = data.cached_overlays.as_ref().expect( "overlays cache populated" );
|
||||
@@ -43,9 +23,7 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
let main_bg = data.app.background_color();
|
||||
let main_region = data.app.input_region();
|
||||
let debug_layout = data.debug_layout;
|
||||
let ( format, swap_rb ) = pick_shm_format( &data.shm );
|
||||
let egl_ctx = data.egl_context.as_ref();
|
||||
let qh = &data.qh;
|
||||
let platform = &data.platform;
|
||||
|
||||
let mut first_commit_now = false;
|
||||
let mut text_focus_rebuilt: Vec<SurfaceFocus> = Vec::new();
|
||||
@@ -55,19 +33,15 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
{
|
||||
text_focus_rebuilt.push( SurfaceFocus::Main );
|
||||
}
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, SurfaceFocus::Main ); };
|
||||
draw_surface::<A::Message>(
|
||||
draw_surface(
|
||||
platform,
|
||||
&mut data.main,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
main_view,
|
||||
main_bg,
|
||||
main_region.as_deref(),
|
||||
None,
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
Some( SurfaceFocus::Main ),
|
||||
);
|
||||
data.main.needs_redraw = false;
|
||||
data.main.last_draw = std::time::Instant::now();
|
||||
@@ -92,20 +66,7 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
// No frame callback: these surfaces are static between invalidations,
|
||||
// so there is no per-frame pacing to keep — and a callback that never
|
||||
// gets requested again would leave `frame_pending` stuck.
|
||||
let req_frame = | _: &WlSurface | {};
|
||||
draw_surface::<A::Message>(
|
||||
ss,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
&view,
|
||||
main_bg,
|
||||
None,
|
||||
None,
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
);
|
||||
draw_surface( platform, ss, &view, main_bg, None, None, debug_layout, None );
|
||||
ss.needs_redraw = false;
|
||||
ss.frame_pending = false;
|
||||
ss.last_draw = std::time::Instant::now();
|
||||
@@ -121,21 +82,17 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
{
|
||||
text_focus_rebuilt.push( focus );
|
||||
}
|
||||
let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); };
|
||||
let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 );
|
||||
let opaque = data.app.overlay_opaque_region( spec.id );
|
||||
draw_surface::<A::Message>(
|
||||
draw_surface(
|
||||
platform,
|
||||
ss,
|
||||
&data.compositor_state,
|
||||
egl_ctx,
|
||||
&spec.view,
|
||||
bg,
|
||||
spec.input_region.as_deref(),
|
||||
opaque.as_deref(),
|
||||
debug_layout,
|
||||
format,
|
||||
swap_rb,
|
||||
&req_frame,
|
||||
Some( focus ),
|
||||
);
|
||||
ss.needs_redraw = false;
|
||||
ss.last_draw = std::time::Instant::now();
|
||||
@@ -155,18 +112,16 @@ pub( crate ) fn draw_frame<A: App>( data: &mut AppData<A> ) -> bool
|
||||
first_commit_now
|
||||
}
|
||||
|
||||
fn draw_surface<Msg: Clone>(
|
||||
ss: &mut SurfaceState<Msg>,
|
||||
compositor: &CompositorState,
|
||||
egl_ctx: Option<&Arc<EglContext>>,
|
||||
/// Paint `ss` and present it, under the rects whose interaction state changed when they cover less than half of it, else all of it. `frame` is the surface the next frame callback is reported for, `None` for one not paced by frames.
|
||||
fn draw_surface<P: Platform, Msg: Clone>(
|
||||
platform: &P,
|
||||
ss: &mut SurfaceState<Msg, P::Surface>,
|
||||
view: &Element<Msg>,
|
||||
bg: Color,
|
||||
input_region: Option<&[Rect]>,
|
||||
opaque_region: Option<&[Rect]>,
|
||||
debug_layout: bool,
|
||||
shm_format: wl_shm::Format,
|
||||
swap_rb: bool,
|
||||
request_frame: &dyn Fn( &WlSurface ),
|
||||
frame: Option<SurfaceFocus>,
|
||||
)
|
||||
{
|
||||
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||
@@ -183,6 +138,7 @@ fn draw_surface<Msg: Clone>(
|
||||
&& canvas_ready
|
||||
&& !ss.frame.widget_rects.is_empty();
|
||||
|
||||
let mut partial = None;
|
||||
if partial_eligible
|
||||
{
|
||||
let dirty_rects = compute_interaction_dirty_rects(
|
||||
@@ -193,34 +149,27 @@ fn draw_surface<Msg: Clone>(
|
||||
);
|
||||
if dirty_rects.is_empty() { return; }
|
||||
let total: f32 = dirty_rects.iter().map( |r| r.width * r.height ).sum();
|
||||
if total < pw as f32 * ph as f32 * 0.5
|
||||
{
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
{
|
||||
draw_surface_partial_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region, opaque_region,
|
||||
dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
} else {
|
||||
draw_surface_partial(
|
||||
ss, compositor, view, bg, input_region, opaque_region,
|
||||
shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if total < pw as f32 * ph as f32 * 0.5 { partial = Some( dirty_rects ); }
|
||||
}
|
||||
|
||||
if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() )
|
||||
if !platform.prepare_canvas( &mut ss.platform, &mut ss.canvas, pw, ph, scale ) { return; }
|
||||
let damage = match partial
|
||||
{
|
||||
draw_surface_full_gpu(
|
||||
ss, compositor, ctx, view, bg, input_region, opaque_region, debug_layout,
|
||||
pw, ph, scale, request_frame,
|
||||
);
|
||||
Some( dirty_rects ) => paint_partial( ss, view, bg, dirty_rects, pw, ph, scale ),
|
||||
None =>
|
||||
{
|
||||
let precise = platform.precise_damage( &ss.platform );
|
||||
paint_full( ss, view, bg, debug_layout, precise, pw, ph, scale )
|
||||
}
|
||||
};
|
||||
|
||||
let presentation = Presentation { damage: &damage, bg, opaque_region, input_region, width: w, height: h, scale, frame };
|
||||
let Some( canvas ) = ss.canvas.as_mut() else { return };
|
||||
if platform.present( &mut ss.platform, canvas, &presentation )
|
||||
{
|
||||
ss.frame_pending = true;
|
||||
} else {
|
||||
draw_surface_full(
|
||||
ss, compositor, view, bg, input_region, opaque_region, debug_layout,
|
||||
shm_format, swap_rb, pw, ph, scale, request_frame,
|
||||
);
|
||||
// What was painted never reached the screen, so the next frame cannot build on it.
|
||||
ss.content_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,942 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use smithay_client_toolkit::
|
||||
{
|
||||
compositor::CompositorHandler,
|
||||
delegate_compositor, delegate_subcompositor, delegate_foreign_toplevel_list, delegate_layer,
|
||||
delegate_output, delegate_registry, delegate_seat, delegate_keyboard,
|
||||
delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup,
|
||||
delegate_xdg_shell, delegate_xdg_window, delegate_session_lock,
|
||||
foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler },
|
||||
output::{ OutputHandler, OutputState },
|
||||
registry::{ ProvidesRegistryState, RegistryState },
|
||||
registry_handlers,
|
||||
seat::{ Capability, SeatHandler, SeatState },
|
||||
shell::
|
||||
{
|
||||
WaylandSurface,
|
||||
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
|
||||
xdg::XdgSurface,
|
||||
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
|
||||
xdg::window::{ DecorationMode, Window, WindowConfigure, WindowHandler },
|
||||
},
|
||||
shm::{ Shm, ShmHandler },
|
||||
session_lock::{ SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure },
|
||||
};
|
||||
use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1;
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::
|
||||
{
|
||||
wl_callback::{ self, WlCallback },
|
||||
wl_output::{ self, WlOutput },
|
||||
wl_surface::WlSurface,
|
||||
wl_seat::WlSeat,
|
||||
},
|
||||
Connection, Dispatch, Proxy, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::wp::text_input::zv3::client::
|
||||
{
|
||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
zwp_text_input_v3::{ self, ZwpTextInputV3 },
|
||||
};
|
||||
|
||||
use crate::app::{ App, ToplevelEvent };
|
||||
use super::app_data::AppData;
|
||||
|
||||
/// Adopt a new buffer scale on `ss`: resize whichever rendering target is
|
||||
/// active and request a redraw. Returns the new physical size, or `None`
|
||||
/// when the factor is unchanged.
|
||||
fn apply_surface_scale<Msg: Clone>(
|
||||
ss: &mut super::SurfaceState<Msg>,
|
||||
shm: &Shm,
|
||||
surface: &WlSurface,
|
||||
new_factor: i32,
|
||||
) -> Option<( u32, u32 )>
|
||||
{
|
||||
if new_factor == ss.scale_factor { return None; }
|
||||
ss.scale_factor = new_factor;
|
||||
surface.set_buffer_scale( new_factor );
|
||||
if let Some( ref mut canvas ) = ss.canvas
|
||||
{
|
||||
canvas.set_dpi_scale( new_factor as f32 );
|
||||
}
|
||||
let pw = ss.width * new_factor as u32;
|
||||
let ph = ss.height * new_factor as u32;
|
||||
// The two targets are mutually exclusive so only one branch runs.
|
||||
if let Some( ref es ) = ss.egl_surface
|
||||
{
|
||||
es.resize( pw as i32, ph as i32 );
|
||||
// Canvas FBO reallocation is deferred to the draw path: it needs
|
||||
// `eglMakeCurrent` first.
|
||||
} else if ss.pool.is_some() {
|
||||
// Before the first configure neither path is chosen yet; creating a
|
||||
// pool here would lock the surface to SHM.
|
||||
ss.pool = Some(
|
||||
smithay_client_toolkit::shm::slot::SlotPool::new(
|
||||
( pw * ph * 4 ) as usize, shm,
|
||||
).expect( "pool" ),
|
||||
);
|
||||
if let Some( ref mut canvas ) = ss.canvas
|
||||
{
|
||||
canvas.resize( pw, ph );
|
||||
}
|
||||
}
|
||||
ss.request_redraw();
|
||||
Some( ( pw, ph ) )
|
||||
}
|
||||
|
||||
impl<A: App> CompositorHandler for AppData<A>
|
||||
{
|
||||
fn scale_factor_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, new_factor: i32 )
|
||||
{
|
||||
if new_factor <= 0 { return; }
|
||||
let Some( focus ) = self.focus_for_surface( surface ) else
|
||||
{
|
||||
// Secondary per-output surfaces live outside the focus map.
|
||||
let shm = &self.shm;
|
||||
if let Some( ( _, ss ) ) = self.secondaries.iter_mut()
|
||||
.find( |( _, ss )| ss.surface.try_wl_surface() == Some( surface ) )
|
||||
{
|
||||
apply_surface_scale( ss, shm, surface, new_factor );
|
||||
}
|
||||
return;
|
||||
};
|
||||
let ( pw, ph ) = {
|
||||
let shm = &self.shm;
|
||||
let ss = match focus
|
||||
{
|
||||
super::SurfaceFocus::Main => &mut self.main,
|
||||
super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id )
|
||||
{
|
||||
Some( s ) => s,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
match apply_surface_scale( ss, shm, surface, new_factor )
|
||||
{
|
||||
Some( dims ) => dims,
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
// Anchors and baked overlay geometry are in the old scale's
|
||||
// physical pixels — drop the tooltip and rebuild the specs.
|
||||
self.cancel_tooltip();
|
||||
self.overlays_dirty = true;
|
||||
// Notify the app of the new physical dimensions. The previous
|
||||
// `on_resize` it received was scaled with the OLD factor, so any
|
||||
// app-side state keyed off those pixels is now stale. Only fire
|
||||
// for the main surface — overlay resizes don't go through
|
||||
// `App::on_resize`.
|
||||
if matches!( focus, super::SurfaceFocus::Main )
|
||||
{
|
||||
crate::types::set_viewport_size( pw, ph );
|
||||
crate::types::set_density( new_factor as f32 );
|
||||
self.app.on_scale_changed( new_factor as u32 );
|
||||
self.app.on_resize( pw, ph );
|
||||
self.dirty_caches();
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_changed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: wl_output::Transform ) {}
|
||||
|
||||
fn frame(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_surface: &WlSurface,
|
||||
_time: u32,
|
||||
)
|
||||
{
|
||||
// Do NOT set needs_redraw here — that would create an infinite 60 fps loop
|
||||
// (commit → frame callback → redraw → commit → ...). Redraws are driven
|
||||
// exclusively by input events, poll_external messages, and configure events.
|
||||
}
|
||||
|
||||
fn surface_enter( &mut self, _: &Connection, _: &QueueHandle<Self>, surface: &WlSurface, output: &WlOutput )
|
||||
{
|
||||
if self.main.surface.try_wl_surface() == Some( surface )
|
||||
{
|
||||
self.publish_output_size( Some( output ) );
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_leave( &mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlSurface, _: &WlOutput ) {}
|
||||
}
|
||||
|
||||
impl<A: App> LayerShellHandler for AppData<A>
|
||||
{
|
||||
fn closed(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
layer: &LayerSurface,
|
||||
)
|
||||
{
|
||||
if self.is_secondary_surface( layer.wl_surface() )
|
||||
{
|
||||
self.secondaries.retain( |( _, ss )| ss.surface.try_wl_surface() != Some( layer.wl_surface() ) );
|
||||
return;
|
||||
}
|
||||
match self.focus_for_surface( layer.wl_surface() )
|
||||
{
|
||||
Some( super::SurfaceFocus::Main ) | None =>
|
||||
{
|
||||
if self.app.on_close_requested()
|
||||
{
|
||||
self.exit_requested = true;
|
||||
}
|
||||
}
|
||||
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
||||
{
|
||||
// Compositor asked us to destroy this overlay. Drop it
|
||||
// synchronously *and* rewrite every per-device focus that
|
||||
// pointed at it — otherwise the next event in this same
|
||||
// dispatch (touch up, IME done, pointer leave) lands on a
|
||||
// freed surface and `surface()` / `surface_mut()` panic.
|
||||
// `reconcile_overlays` will still run afterwards and skip
|
||||
// re-creating the entry as long as the app stops returning
|
||||
// its id from `overlays()`.
|
||||
self.discard_overlay( id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
layer: &LayerSurface,
|
||||
configure: LayerSurfaceConfigure,
|
||||
_serial: u32,
|
||||
)
|
||||
{
|
||||
let ( w, h ) = configure.new_size;
|
||||
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
|
||||
// Secondaries live outside the focus map; without this branch their
|
||||
// configure would fall into the `None` arm and resize the main
|
||||
// surface instead.
|
||||
for ( _, ss ) in self.secondaries.iter_mut()
|
||||
{
|
||||
if ss.surface.try_wl_surface() == Some( layer.wl_surface() )
|
||||
{
|
||||
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
return;
|
||||
}
|
||||
}
|
||||
match self.focus_for_surface( layer.wl_surface() )
|
||||
{
|
||||
Some( super::SurfaceFocus::Main ) | None =>
|
||||
{
|
||||
self.on_configure( w, h );
|
||||
}
|
||||
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
||||
{
|
||||
if let Some( ss ) = self.overlays.get_mut( &id )
|
||||
{
|
||||
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> WindowHandler for AppData<A>
|
||||
{
|
||||
fn request_close(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
window: &Window,
|
||||
)
|
||||
{
|
||||
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() )
|
||||
{
|
||||
if let Some( msg ) = self.overlay_dismiss_msg( id )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.app.on_close_requested()
|
||||
{
|
||||
self.exit_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
window: &Window,
|
||||
configure: WindowConfigure,
|
||||
_serial: u32,
|
||||
)
|
||||
{
|
||||
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( window.wl_surface() )
|
||||
{
|
||||
let Some( ss ) = self.overlays.get_mut( &id ) else { return };
|
||||
let sf = ss.scale_factor.max( 1 ) as f32;
|
||||
let ( rw, rh ) = ss.last_requested_size;
|
||||
let fallback = ( ( rw as f32 / sf ).round().max( 1.0 ) as u32, ( rh as f32 / sf ).round().max( 1.0 ) as u32 );
|
||||
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( fallback.0 );
|
||||
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( fallback.1 );
|
||||
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
|
||||
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
return;
|
||||
}
|
||||
// Mutter ignores set_fullscreen sent before the surface is
|
||||
// mapped, so reapply on the first configure.
|
||||
if self.pending_fullscreen
|
||||
{
|
||||
window.set_fullscreen( None );
|
||||
self.pending_fullscreen = false;
|
||||
}
|
||||
if self.pending_size_hint_unpin
|
||||
{
|
||||
window.set_min_size( None );
|
||||
window.set_max_size( None );
|
||||
self.pending_size_hint_unpin = false;
|
||||
}
|
||||
let tb = if configure.decoration_mode == DecorationMode::Server || configure.is_fullscreen() { 0.0 }
|
||||
else { self.main.titlebar_base };
|
||||
if self.main.titlebar_height != tb
|
||||
{
|
||||
self.main.titlebar_height = tb;
|
||||
self.main.content_dirty = true;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
let fitted = self.fit_size_hint( window, configure.suggested_bounds );
|
||||
let ( hint_w, hint_h ) = self.fitted_size_hint.or( self.app.window_size_hint() ).unwrap_or( ( 800, 600 ) );
|
||||
let ( w, h ) = if fitted
|
||||
{
|
||||
( hint_w, hint_h )
|
||||
} else {
|
||||
(
|
||||
configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w ),
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> ShmHandler for AppData<A>
|
||||
{
|
||||
fn shm_state( &mut self ) -> &mut Shm
|
||||
{
|
||||
&mut self.shm
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> OutputHandler for AppData<A>
|
||||
{
|
||||
fn output_state( &mut self ) -> &mut OutputState
|
||||
{
|
||||
&mut self.output_state
|
||||
}
|
||||
|
||||
fn new_output( &mut self, _: &Connection, qh: &QueueHandle<Self>, output: WlOutput )
|
||||
{
|
||||
self.publish_output_size( None );
|
||||
// If we were waiting for an output to assign the layer surface to, create it now.
|
||||
// Same for any overlays that were created before an output existed.
|
||||
if let Some( ref layer_shell ) = self.layer_shell
|
||||
{
|
||||
if self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output )
|
||||
{
|
||||
self.main_output = Some( output.clone() );
|
||||
}
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
|
||||
}
|
||||
// A background shell covers every output: each one beyond the
|
||||
// main's gets its own background layer surface, painted with
|
||||
// `App::secondary_view` — the wallpaper without any chrome.
|
||||
if self.app.shell_mode() == crate::app::ShellMode::Layer( crate::app::Layer::Background )
|
||||
&& self.main_output.is_some()
|
||||
&& self.main_output.as_ref() != Some( &output )
|
||||
&& !self.secondaries.iter().any( |( o, _ )| o == &output )
|
||||
{
|
||||
use smithay_client_toolkit::shell::wlr_layer;
|
||||
let surface = self.compositor_state.create_surface( qh );
|
||||
let layer_surface = layer_shell.create_layer_surface(
|
||||
qh,
|
||||
surface,
|
||||
wlr_layer::Layer::Background,
|
||||
Some( "ltk-secondary" ),
|
||||
Some( &output ),
|
||||
);
|
||||
layer_surface.set_anchor(
|
||||
wlr_layer::Anchor::TOP | wlr_layer::Anchor::BOTTOM
|
||||
| wlr_layer::Anchor::LEFT | wlr_layer::Anchor::RIGHT,
|
||||
);
|
||||
layer_surface.set_exclusive_zone( 0 );
|
||||
layer_surface.set_keyboard_interactivity( wlr_layer::KeyboardInteractivity::None );
|
||||
layer_surface.set_size( 0, 0 );
|
||||
layer_surface.commit();
|
||||
self.secondaries.push( (
|
||||
output.clone(),
|
||||
super::SurfaceState::new( super::SurfaceKind::Layer( layer_surface ), 0.0, String::new() ),
|
||||
) );
|
||||
}
|
||||
}
|
||||
// An output hotplugged while the session is locked would be blanked by
|
||||
// the compositor until it gets a lock surface of its own.
|
||||
if let Some( ref lock ) = self.session_lock
|
||||
{
|
||||
if matches!( self.main.surface, super::SurfaceKind::Lock( .. ) )
|
||||
&& self.main_output.as_ref() != Some( &output )
|
||||
&& !self.secondaries.iter().any( |( o, _ )| o == &output )
|
||||
{
|
||||
let surface = self.compositor_state.create_surface( qh );
|
||||
let lock_surface = lock.create_lock_surface( surface, &output, qh );
|
||||
self.secondaries.push( (
|
||||
output,
|
||||
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
|
||||
) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput )
|
||||
{
|
||||
self.publish_output_size( None );
|
||||
}
|
||||
|
||||
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, output: WlOutput )
|
||||
{
|
||||
self.secondaries.retain( |( o, _ )| o != &output );
|
||||
if self.main_output.as_ref() == Some( &output )
|
||||
{
|
||||
self.main_output = None;
|
||||
}
|
||||
self.publish_output_size( None );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> SeatHandler for AppData<A>
|
||||
{
|
||||
fn seat_state( &mut self ) -> &mut SeatState
|
||||
{
|
||||
&mut self.seat_state
|
||||
}
|
||||
|
||||
fn new_seat(
|
||||
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
|
||||
)
|
||||
{
|
||||
// First seat → first data device. We only need one device
|
||||
// regardless of how many seats appear (the typical desktop
|
||||
// session has exactly one); additional seats reuse the same
|
||||
// device-bound clipboard semantics by construction because
|
||||
// `wl_data_device_manager.get_data_device` takes the seat as
|
||||
// argument and the compositor manages routing.
|
||||
if self.data_device.is_none()
|
||||
{
|
||||
if let Some( ref ddm ) = self.data_device_manager
|
||||
{
|
||||
self.data_device = Some( ddm.get_data_device( qh, &seat ) );
|
||||
}
|
||||
}
|
||||
self.bind_primary_selection( qh, &seat );
|
||||
}
|
||||
|
||||
fn new_capability(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
seat: WlSeat,
|
||||
capability: Capability,
|
||||
)
|
||||
{
|
||||
match capability
|
||||
{
|
||||
Capability::Keyboard if self.keyboard.is_none() =>
|
||||
{
|
||||
self.keyboard = Some(
|
||||
self.seat_state
|
||||
.get_keyboard( qh, &seat, None )
|
||||
.expect( "keyboard" ),
|
||||
);
|
||||
}
|
||||
Capability::Pointer if self.pointer.is_none() =>
|
||||
{
|
||||
let pointer = self.seat_state
|
||||
.get_pointer( qh, &seat )
|
||||
.expect( "pointer" );
|
||||
// Create a per-pointer cursor-shape device when the
|
||||
// compositor advertises wp_cursor_shape_v1. The device
|
||||
// outlives the WlPointer we just created and is what
|
||||
// `set_shape(serial, shape)` is called on.
|
||||
if let Some( ref mgr ) = self.cursor_shape_manager
|
||||
{
|
||||
self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) );
|
||||
}
|
||||
self.pointer = Some( pointer );
|
||||
}
|
||||
Capability::Touch if self.touch.is_none() =>
|
||||
{
|
||||
// Touch coming back (resume on devices that power the
|
||||
// touchscreen down at suspend): clear anything the removal
|
||||
// path didn't, so the first post-resume gesture starts clean.
|
||||
if self.reset_touch_state()
|
||||
{
|
||||
eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" );
|
||||
}
|
||||
self.touch = Some(
|
||||
self.seat_state
|
||||
.get_touch( qh, &seat )
|
||||
.expect( "touch" ),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_capability(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_seat: WlSeat,
|
||||
capability: Capability,
|
||||
)
|
||||
{
|
||||
match capability
|
||||
{
|
||||
Capability::Keyboard => { self.keyboard = None; }
|
||||
Capability::Pointer => { self.pointer = None; }
|
||||
Capability::Touch =>
|
||||
{
|
||||
self.touch = None;
|
||||
// The touchscreen is gone before any pending up / cancel for
|
||||
// the last touch could arrive; drop the stale gesture state
|
||||
// now rather than stranding it until the next clean release.
|
||||
if self.reset_touch_state()
|
||||
{
|
||||
eprintln!( "[ltk] touch capability removed mid-gesture — reset stale touch state" );
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_seat(
|
||||
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
|
||||
) {}
|
||||
}
|
||||
|
||||
impl<A: App> PopupHandler for AppData<A>
|
||||
{
|
||||
fn configure(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
popup: &Popup,
|
||||
config: PopupConfigure,
|
||||
)
|
||||
{
|
||||
let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 );
|
||||
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
|
||||
{
|
||||
if let Some( ss ) = self.overlays.get_mut( &id )
|
||||
{
|
||||
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn done(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
popup: &Popup,
|
||||
)
|
||||
{
|
||||
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
|
||||
{
|
||||
if let Some( msg ) = self.overlay_dismiss_msg( id )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
// Synchronous teardown plus per-device focus cleanup. See
|
||||
// `discard_overlay` for the panic case this prevents.
|
||||
self.discard_overlay( id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delegate macros ---
|
||||
|
||||
impl<A: App> SessionLockHandler for AppData<A>
|
||||
{
|
||||
fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock )
|
||||
{
|
||||
// Every output needs its own lock surface — the compositor blanks any
|
||||
// output left without one. The first hosts the app's main surface;
|
||||
// the rest render `App::secondary_view`.
|
||||
let outputs: Vec<WlOutput> = self.output_state.outputs().collect();
|
||||
let mut outputs = outputs.into_iter();
|
||||
if let Some( output ) = outputs.next()
|
||||
{
|
||||
let surface = self.compositor_state.create_surface( qh );
|
||||
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
||||
self.main.surface = super::SurfaceKind::Lock( lock_surface );
|
||||
self.main_output = Some( output );
|
||||
}
|
||||
for output in outputs
|
||||
{
|
||||
let surface = self.compositor_state.create_surface( qh );
|
||||
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
||||
self.secondaries.push( (
|
||||
output,
|
||||
super::SurfaceState::new( super::SurfaceKind::Lock( lock_surface ), 0.0, String::new() ),
|
||||
) );
|
||||
}
|
||||
self.session_lock = Some( session_lock );
|
||||
}
|
||||
|
||||
fn finished( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _session_lock: SessionLock )
|
||||
{
|
||||
// Compositor refused the lock or ended it; nothing left to show.
|
||||
self.exit_requested = true;
|
||||
}
|
||||
|
||||
fn configure(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
surface: SessionLockSurface,
|
||||
configure: SessionLockSurfaceConfigure,
|
||||
_serial: u32,
|
||||
)
|
||||
{
|
||||
let ( w, h ) = configure.new_size;
|
||||
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
|
||||
let wl = surface.wl_surface();
|
||||
if self.main.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
self.on_configure( w, h );
|
||||
return;
|
||||
}
|
||||
for ( _, ss ) in self.secondaries.iter_mut()
|
||||
{
|
||||
if ss.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_compositor!( @<A: App> AppData<A> );
|
||||
delegate_subcompositor!( @<A: App> AppData<A> );
|
||||
delegate_output!( @<A: App> AppData<A> );
|
||||
delegate_shm!( @<A: App> AppData<A> );
|
||||
delegate_seat!( @<A: App> AppData<A> );
|
||||
delegate_keyboard!( @<A: App> AppData<A> );
|
||||
delegate_pointer!( @<A: App> AppData<A> );
|
||||
delegate_touch!( @<A: App> AppData<A> );
|
||||
delegate_layer!( @<A: App> AppData<A> );
|
||||
delegate_session_lock!( @<A: App> AppData<A> );
|
||||
delegate_xdg_shell!( @<A: App> AppData<A> );
|
||||
delegate_xdg_window!( @<A: App> AppData<A> );
|
||||
delegate_xdg_popup!( @<A: App> AppData<A> );
|
||||
delegate_foreign_toplevel_list!( @<A: App> AppData<A> );
|
||||
delegate_registry!( @<A: App> AppData<A> );
|
||||
smithay_client_toolkit::delegate_activation!( @<A: App> AppData<A> );
|
||||
smithay_client_toolkit::delegate_data_device!( @<A: App> AppData<A> );
|
||||
smithay_client_toolkit::delegate_primary_selection!( @<A: App> AppData<A> );
|
||||
|
||||
// --- `xdg-activation-v1` handler ---
|
||||
//
|
||||
// A token carrying a `RequestData::app_id` was requested by the app via
|
||||
// [`App::take_activation_requests`]: echo the tag + token back through
|
||||
// [`App::on_activation_token`] so the app can pass it to a child it is
|
||||
// launching. A token with no tag was issued for our own surface, so we
|
||||
// self-activate.
|
||||
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
|
||||
{
|
||||
type RequestData = smithay_client_toolkit::activation::RequestData;
|
||||
fn new_token( &mut self, token: String, data: &Self::RequestData )
|
||||
{
|
||||
use smithay_client_toolkit::activation::RequestDataExt;
|
||||
if let Some( tag ) = data.app_id()
|
||||
{
|
||||
if let Some( msg ) = self.app.on_activation_token( tag.to_string(), token )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( &wl, token );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> ProvidesRegistryState for AppData<A>
|
||||
{
|
||||
fn registry( &mut self ) -> &mut RegistryState
|
||||
{
|
||||
&mut self.registry_state
|
||||
}
|
||||
registry_handlers![ OutputState, SeatState ];
|
||||
}
|
||||
|
||||
// --- `ext-foreign-toplevel-list-v1` handler ---
|
||||
//
|
||||
// SCTK does the wire-level dispatch (open / close / done / app_id /
|
||||
// title / identifier events arrive on the inner `ForeignToplevelList`
|
||||
// helper); we surface the high-level open / update / close callbacks
|
||||
// to apps through [`App::on_toplevel_event`]. Returned messages join
|
||||
// `pending_msgs` and flow through the regular `update` cycle, so the
|
||||
// invalidation pipeline (`App::invalidate_after`) decides which
|
||||
// surfaces to redraw — apps that only need the dock to refresh can
|
||||
// scope to `SurfaceTarget::Main` and skip overlays.
|
||||
//
|
||||
// The `id` field reported to apps is the Wayland protocol id of the
|
||||
// handle proxy — unique per session and stable for the handle's
|
||||
// lifetime, the same value paired across `Opened` and the matching
|
||||
// `Closed`.
|
||||
/// Cascade for the per-toplevel display string crustace-style shells use to
|
||||
/// register a window: prefer `app_id` (sets `.desktop` matching), fall back
|
||||
/// to `title` (visible to the user), and finally to the protocol-issued
|
||||
/// `identifier` (always present, unique per handle). Without the cascade,
|
||||
/// any client that never set `app_id` (e.g. winit-windowed compositors
|
||||
/// before they bind their xdg surface) was silently invisible to the dock.
|
||||
fn toplevel_opened(
|
||||
list: &ForeignToplevelList,
|
||||
handle: &ExtForeignToplevelHandleV1,
|
||||
) -> ToplevelEvent
|
||||
{
|
||||
// Only ever surface the client's `app_id`. The protocol-level
|
||||
// `identifier` is a server-internal random opaque token, not an
|
||||
// app handle; smithay creates the toplevel handle with an empty
|
||||
// `app_id` and `init_new_instance` flushes a `done` immediately,
|
||||
// so apps subscribing through this binding would otherwise see
|
||||
// the random identifier as their "app id" until the client's
|
||||
// real `set_app_id` lands. `title` is also unsuitable — it
|
||||
// changes every time the window's title text updates.
|
||||
let id = handle.id().protocol_id();
|
||||
match list.info( handle )
|
||||
{
|
||||
Some( info ) => ToplevelEvent::Opened { id, app_id: info.app_id, identifier: info.identifier, title: info.title },
|
||||
None => ToplevelEvent::Opened { id, app_id: String::new(), identifier: String::new(), title: String::new() },
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
||||
{
|
||||
fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList
|
||||
{
|
||||
&mut self.foreign_toplevel_list
|
||||
}
|
||||
|
||||
fn new_toplevel(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
handle: ExtForeignToplevelHandleV1,
|
||||
)
|
||||
{
|
||||
let event = toplevel_opened( &self.foreign_toplevel_list, &handle );
|
||||
if let Some( msg ) = self.app.on_toplevel_event( event )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
|
||||
fn update_toplevel(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
handle: ExtForeignToplevelHandleV1,
|
||||
)
|
||||
{
|
||||
// Re-emit `Opened` with the latest info. Compositors usually
|
||||
// only fire this on title changes; the `app_id` is committed
|
||||
// once on the initial `done` and never replaced. Apps whose
|
||||
// `RunningApps`-style bookkeeping is keyed by `(id, app_id)`
|
||||
// can treat repeated opens as idempotent and pay nothing here,
|
||||
// but the app_id-can-change-mid-life case still produces a
|
||||
// correct sequence (old refcount drops, new one bumps).
|
||||
let event = toplevel_opened( &self.foreign_toplevel_list, &handle );
|
||||
if let Some( msg ) = self.app.on_toplevel_event( event )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
|
||||
fn toplevel_closed(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
handle: ExtForeignToplevelHandleV1,
|
||||
)
|
||||
{
|
||||
let id = handle.id().protocol_id();
|
||||
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Closed { id } )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dispatch impls for zwp_text_input_v3 ---
|
||||
|
||||
impl<A: App> Dispatch<ZwpTextInputManagerV3, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &ZwpTextInputManagerV3,
|
||||
_event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
// no events from manager
|
||||
}
|
||||
}
|
||||
|
||||
// Frame-callback routing.
|
||||
//
|
||||
// Each `draw_*` path requests a `wl_surface.frame` with `SurfaceFocus` user-
|
||||
// data identifying which `SurfaceState` it belongs to. The compositor fires
|
||||
// the callback when the surface is ready for its next commit (display refresh,
|
||||
// VRR cadence, "screen is on", …). Clearing `frame_pending` unblocks the run
|
||||
// loop so the next iteration is allowed to draw that surface again.
|
||||
//
|
||||
// While the app is animating we re-arm the surface for redraw inline so the
|
||||
// loop keeps ticking at the compositor's pace without needing a fixed-period
|
||||
// timer.
|
||||
impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &WlCallback,
|
||||
_event: wl_callback::Event,
|
||||
focus: &super::SurfaceFocus,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
let is_animating = state.app.is_animating();
|
||||
match *focus
|
||||
{
|
||||
super::SurfaceFocus::Main =>
|
||||
{
|
||||
state.main.frame_pending = false;
|
||||
if is_animating
|
||||
{
|
||||
if state.app.subsurface_motion_only()
|
||||
{
|
||||
// The main buffer is unchanged; the subsurface
|
||||
// reconcile applies the latest move on this tick and
|
||||
// re-arms the callback.
|
||||
state.main.frame_tick_due = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Perf guardrails: run the opt-in diagnostics and apply
|
||||
// the software animation-rate cap (~30 Hz). A throttled
|
||||
// frame keeps the vsync cadence with a bare callback but
|
||||
// skips the re-raster.
|
||||
let software = crate::render::is_software_render();
|
||||
let cap = state.app.cap_software_animation();
|
||||
if state.perf.animated_frame( software, cap )
|
||||
{
|
||||
state.view_dirty = true;
|
||||
state.main.request_redraw();
|
||||
}
|
||||
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
||||
{
|
||||
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
||||
wl.commit();
|
||||
state.main.frame_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state.perf.animation_stopped();
|
||||
}
|
||||
}
|
||||
super::SurfaceFocus::Overlay( id ) =>
|
||||
{
|
||||
let hosts_subsurface = state.subsurfaces.values()
|
||||
.any( |s| s.parent() == crate::app::SubsurfaceParent::Overlay( id ) );
|
||||
if let Some( ss ) = state.overlays.get_mut( &id )
|
||||
{
|
||||
ss.frame_pending = false;
|
||||
if is_animating
|
||||
{
|
||||
if hosts_subsurface && state.app.subsurface_motion_only()
|
||||
{
|
||||
ss.frame_tick_due = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.overlays_dirty = true;
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ZwpTextInputV3,
|
||||
event: zwp_text_input_v3::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
let focus = state.keyboard_focus;
|
||||
let external = state.external_text_input.is_some() && !state.focused_text_input( focus );
|
||||
match event
|
||||
{
|
||||
zwp_text_input_v3::Event::CommitString { text } =>
|
||||
{
|
||||
if let Some( text ) = text.filter( |t| !t.is_empty() )
|
||||
{
|
||||
if external { state.app.on_external_text_commit( &text ); }
|
||||
else { state.handle_text_insert( focus, &text ); }
|
||||
}
|
||||
}
|
||||
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } =>
|
||||
{
|
||||
if external
|
||||
{
|
||||
state.app.on_external_text_delete( before_length, after_length );
|
||||
} else {
|
||||
state.handle_delete_surrounding( focus, before_length, after_length );
|
||||
}
|
||||
}
|
||||
zwp_text_input_v3::Event::Done { .. } =>
|
||||
{
|
||||
if let Some( ss ) = state.try_surface_mut( focus )
|
||||
{
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
zwp_text_input_v3::Event::Enter { .. } =>
|
||||
{
|
||||
state.reenable_text_input();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::app::{ App, InvalidationScope, SurfaceTarget };
|
||||
use crate::platform::Platform;
|
||||
use super::AppData;
|
||||
|
||||
/// Apply a folded [`InvalidationScope`] from one message-batch iteration:
|
||||
@@ -14,7 +15,7 @@ use super::AppData;
|
||||
///
|
||||
/// `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 )
|
||||
pub( super ) fn apply_invalidation<A: App, P: Platform>( data: &mut AppData<A, P>, scope: InvalidationScope )
|
||||
{
|
||||
match scope
|
||||
{
|
||||
|
||||
+7
-11
@@ -1,36 +1,32 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
pub( crate ) mod a11y;
|
||||
pub( crate ) mod app_data;
|
||||
pub( crate ) mod clipboard;
|
||||
pub( crate ) mod data_device;
|
||||
pub( crate ) mod context_menu;
|
||||
pub( crate ) mod cursor_shape;
|
||||
pub( crate ) mod drag;
|
||||
pub( crate ) mod focus;
|
||||
mod handlers;
|
||||
pub( crate ) mod osk;
|
||||
pub( crate ) mod output_probe;
|
||||
pub( crate ) mod perf;
|
||||
pub( crate ) mod primary_selection;
|
||||
pub( crate ) mod repeat;
|
||||
pub( crate ) mod session;
|
||||
pub( crate ) mod subsurface;
|
||||
pub( crate ) mod setup;
|
||||
pub( crate ) mod surface;
|
||||
pub( crate ) mod text_editing;
|
||||
pub( crate ) mod text_scale;
|
||||
pub( crate ) mod tooltip;
|
||||
pub( crate ) mod window_fit;
|
||||
|
||||
pub( crate ) mod error;
|
||||
pub( crate ) mod frame;
|
||||
pub( crate ) mod run;
|
||||
pub( crate ) mod step;
|
||||
pub( crate ) mod invalidation;
|
||||
pub( crate ) mod overlays_reconcile;
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests;
|
||||
|
||||
pub( crate ) use app_data::AppData;
|
||||
pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
pub( crate ) use surface::{ FrameState, SurfaceFocus, SurfaceState };
|
||||
pub use error::RunError;
|
||||
pub( crate ) use run::{ run, try_run };
|
||||
#[ cfg( feature = "test-support" ) ]
|
||||
pub use invalidation::diff_overlay_ids;
|
||||
|
||||
+58
-12
@@ -5,40 +5,50 @@
|
||||
|
||||
use std::time::{ Duration, Instant };
|
||||
|
||||
use smithay_client_toolkit::seat::keyboard::KeyEvent;
|
||||
|
||||
use super::app_data::AppData;
|
||||
use super::surface::SurfaceFocus;
|
||||
use super::surface::{ Insets, SurfaceFocus };
|
||||
use crate::app::App;
|
||||
use crate::input::event::KeyInput;
|
||||
use crate::osk::{ Event, Modifiers, Panel };
|
||||
use crate::platform::Platform;
|
||||
use crate::Keysym;
|
||||
use crate::tree::find_handlers;
|
||||
use crate::types::{ Point, Rect };
|
||||
|
||||
/// Fit a surface's panel to the bottom of its `pw` by `ph` pixels; returns the height it takes.
|
||||
pub( crate ) fn place_osk( osk: &mut Option<Panel>, pw: u32, ph: u32, sf: f32 ) -> f32
|
||||
/// Fit a surface's panel to the bottom of its `pw` by `ph` pixels, inside the `insets` the system covers; returns the height it takes.
|
||||
pub( crate ) fn place_osk( osk: &mut Option<Panel>, pw: u32, ph: u32, sf: f32, insets: Insets ) -> f32
|
||||
{
|
||||
let Some( panel ) = osk.as_mut() else { return 0.0 };
|
||||
let h = panel.height_within( pw as f32, sf, ph as f32 );
|
||||
panel.set_rect( Rect { x: 0.0, y: ph as f32 - h, width: pw as f32, height: h }, sf );
|
||||
let width = ( pw as f32 - ( insets.left + insets.right ) * sf ).max( 0.0 );
|
||||
let bottom = ph as f32 - insets.bottom * sf;
|
||||
let h = panel.height_within( width, sf, ph as f32 );
|
||||
panel.set_rect( Rect { x: insets.left * sf, y: bottom - h, width, height: h }, sf );
|
||||
h
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
fn builtin_keyboard( &self ) -> bool
|
||||
{
|
||||
self.builtin_osk || self.app.builtin_keyboard()
|
||||
}
|
||||
|
||||
/// The panel for the focused text field, laid out for its purpose.
|
||||
pub( crate ) fn show_osk( &mut self, focus: SurfaceFocus )
|
||||
{
|
||||
if !self.builtin_keyboard() { return; }
|
||||
let ss = self.surface_mut( focus );
|
||||
let ss = self.surface( focus );
|
||||
let purpose = ss.focused_idx
|
||||
.and_then( |i| find_handlers( &ss.frame.widget_rects, i ) )
|
||||
.map( |h| h.input_purpose() )
|
||||
.unwrap_or_default();
|
||||
self.show_osk_for( focus, purpose );
|
||||
}
|
||||
|
||||
/// The panel on `focus` for text of `purpose`, also for an app's own text input ([`App::external_text_input`]).
|
||||
pub( crate ) fn show_osk_for( &mut self, focus: SurfaceFocus, purpose: crate::osk::Purpose )
|
||||
{
|
||||
if !self.builtin_keyboard() { return; }
|
||||
let ss = self.surface_mut( focus );
|
||||
if ss.osk.is_none()
|
||||
{
|
||||
let lang = std::env::var( "LANG" ).unwrap_or_default();
|
||||
@@ -47,7 +57,7 @@ impl<A: App> AppData<A>
|
||||
if let Some( panel ) = ss.osk.as_mut() { panel.set_purpose( purpose ); }
|
||||
let sf = ss.scale_factor.max( 1 ) as f32;
|
||||
let ( pw, ph ) = ( ss.width * ss.scale_factor.max( 1 ) as u32, ss.height * ss.scale_factor.max( 1 ) as u32 );
|
||||
place_osk( &mut ss.osk, pw, ph, sf );
|
||||
place_osk( &mut ss.osk, pw, ph, sf, ss.insets );
|
||||
ss.request_redraw();
|
||||
}
|
||||
|
||||
@@ -124,6 +134,11 @@ impl<A: App> AppData<A>
|
||||
|
||||
fn apply_osk_event( &mut self, focus: SurfaceFocus, event: Event )
|
||||
{
|
||||
if self.external_text_input.is_some() && !self.focused_text_input( focus )
|
||||
{
|
||||
self.apply_osk_event_to_app( focus, event );
|
||||
return;
|
||||
}
|
||||
match event
|
||||
{
|
||||
Event::Text( text ) => self.handle_text_insert( focus, &text ),
|
||||
@@ -136,11 +151,42 @@ impl<A: App> AppData<A>
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
}
|
||||
|
||||
/// For an app's own text input every key reaches the app as a physical one would, through [`App::on_raw_key_event`].
|
||||
fn apply_osk_event_to_app( &mut self, focus: SurfaceFocus, event: Event )
|
||||
{
|
||||
match event
|
||||
{
|
||||
Event::Text( text ) =>
|
||||
{
|
||||
let keysym = text.chars().next().map_or( Keysym::NoSymbol, Keysym::from_char );
|
||||
self.osk_app_key( keysym, Some( text ), Modifiers::default() );
|
||||
}
|
||||
Event::Backspace => self.osk_app_key( Keysym::BackSpace, None, Modifiers::default() ),
|
||||
Event::Enter => self.osk_app_key( Keysym::Return, None, Modifiers::default() ),
|
||||
Event::Key( keysym, mods ) => self.osk_app_key( keysym, None, mods ),
|
||||
Event::Hide => self.hide_osk( focus ),
|
||||
Event::Changed => {}
|
||||
}
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
}
|
||||
|
||||
/// Press and release `keysym` with the panel's modifiers held for it. Without modifiers a key that has a character types it, as on a physical keyboard.
|
||||
fn osk_app_key( &mut self, keysym: Keysym, text: Option<String>, mods: Modifiers )
|
||||
{
|
||||
let text = text.or_else( || ( !mods.any() ).then( || keysym.key_char() ).flatten().map( String::from ) );
|
||||
let held = ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed );
|
||||
self.modifiers_changed( mods.ctrl, held.1, mods.alt );
|
||||
let key = KeyInput::synthetic( keysym, text );
|
||||
self.key_press( key.clone() );
|
||||
self.key_release( &key );
|
||||
self.modifiers_changed( held.0, held.1, held.2 );
|
||||
}
|
||||
|
||||
/// A key the panel cannot type as text goes through the same dispatch as a physical one.
|
||||
fn osk_key( &mut self, focus: SurfaceFocus, keysym: Keysym, mods: Modifiers )
|
||||
{
|
||||
let utf8 = ( !mods.any() ).then( || keysym.key_char() ).flatten().map( String::from );
|
||||
let event = KeyEvent { time: 0, raw_code: 0, keysym, utf8 };
|
||||
let event = KeyInput::synthetic( keysym, utf8 );
|
||||
let ctrl = std::mem::replace( &mut self.ctrl_pressed, mods.ctrl );
|
||||
self.dispatch_key( focus, event );
|
||||
self.ctrl_pressed = ctrl;
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The screen's logical size before any surface exists, read on an event queue of its own.
|
||||
|
||||
use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, Proxy, QueueHandle, WEnum };
|
||||
use smithay_client_toolkit::reexports::client::globals::GlobalList;
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_output::{ self, WlOutput };
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1;
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1::{ self, ZxdgOutputV1 };
|
||||
|
||||
struct Output
|
||||
{
|
||||
mode: Option<( i32, i32 )>,
|
||||
scale: i32,
|
||||
logical: Option<( i32, i32 )>,
|
||||
}
|
||||
|
||||
struct Probe
|
||||
{
|
||||
outputs: Vec<Output>,
|
||||
}
|
||||
|
||||
/// Logical size of the first output: `xdg_output`'s when the compositor offers it, else the current mode over the scale.
|
||||
pub( crate ) fn output_logical_size( conn: &Connection, globals: &GlobalList ) -> Option<( u32, u32 )>
|
||||
{
|
||||
let mut queue = conn.new_event_queue::<Probe>();
|
||||
let qh = queue.handle();
|
||||
let registry = globals.registry();
|
||||
let ( outputs, manager ) = globals.contents().with_list( |list|
|
||||
{
|
||||
let outputs: Vec<WlOutput> = list.iter()
|
||||
.filter( |g| g.interface == WlOutput::interface().name )
|
||||
.enumerate()
|
||||
.map( |( i, g )| registry.bind::<WlOutput, _, _>( g.name, g.version.min( 4 ), &qh, i ) )
|
||||
.collect();
|
||||
let manager = list.iter()
|
||||
.find( |g| g.interface == ZxdgOutputManagerV1::interface().name )
|
||||
.map( |g| registry.bind::<ZxdgOutputManagerV1, _, _>( g.name, g.version.min( 3 ), &qh, () ) );
|
||||
( outputs, manager )
|
||||
} );
|
||||
if outputs.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut xdg_outputs = Vec::new();
|
||||
if let Some( manager ) = &manager
|
||||
{
|
||||
for ( i, output ) in outputs.iter().enumerate()
|
||||
{
|
||||
xdg_outputs.push( manager.get_xdg_output( output, &qh, i ) );
|
||||
}
|
||||
}
|
||||
|
||||
let mut probe = Probe { outputs: outputs.iter().map( |_| Output { mode: None, scale: 1, logical: None } ).collect() };
|
||||
let answered = queue.roundtrip( &mut probe ).is_ok();
|
||||
|
||||
for xdg_output in xdg_outputs
|
||||
{
|
||||
xdg_output.destroy();
|
||||
}
|
||||
if let Some( manager ) = manager
|
||||
{
|
||||
manager.destroy();
|
||||
}
|
||||
for output in outputs
|
||||
{
|
||||
if output.version() >= 3
|
||||
{
|
||||
output.release();
|
||||
}
|
||||
}
|
||||
if !answered
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
probe.outputs.iter().find_map( |o|
|
||||
{
|
||||
let scale = o.scale.max( 1 );
|
||||
let ( w, h ) = o.logical.or_else( || o.mode.map( |( w, h )| ( w / scale, h / scale ) ) )?;
|
||||
( w > 0 && h > 0 ).then_some( ( w as u32, h as u32 ) )
|
||||
} )
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, usize> for Probe
|
||||
{
|
||||
fn event( state: &mut Self, _: &WlOutput, event: wl_output::Event, index: &usize, _: &Connection, _: &QueueHandle<Self> )
|
||||
{
|
||||
let Some( output ) = state.outputs.get_mut( *index ) else { return };
|
||||
match event
|
||||
{
|
||||
wl_output::Event::Mode { flags: WEnum::Value( flags ), width, height, .. } if flags.contains( wl_output::Mode::Current ) =>
|
||||
{
|
||||
output.mode = Some( ( width, height ) );
|
||||
}
|
||||
wl_output::Event::Scale { factor } => output.scale = factor,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgOutputManagerV1, ()> for Probe
|
||||
{
|
||||
fn event( _: &mut Self, _: &ZxdgOutputManagerV1, _: <ZxdgOutputManagerV1 as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self> ) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgOutputV1, usize> for Probe
|
||||
{
|
||||
fn event( state: &mut Self, _: &ZxdgOutputV1, event: zxdg_output_v1::Event, index: &usize, _: &Connection, _: &QueueHandle<Self> )
|
||||
{
|
||||
if let ( Some( output ), zxdg_output_v1::Event::LogicalSize { width, height } ) = ( state.outputs.get_mut( *index ), event )
|
||||
{
|
||||
output.logical = Some( ( width, height ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
// 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,
|
||||
window::WindowDecorations,
|
||||
},
|
||||
},
|
||||
};
|
||||
use wayland_protocols::xdg::shell::client::xdg_positioner::
|
||||
{
|
||||
Anchor as PositionerAnchor,
|
||||
ConstraintAdjustment,
|
||||
Gravity,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::{ Length, Rect };
|
||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
use super::tooltip::tooltip_id;
|
||||
|
||||
/// Logical pixels between a tooltip popup and its widget.
|
||||
const TOOLTIP_GAP: i32 = 6;
|
||||
|
||||
/// 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 tooltip_popup = data.tooltip_popup();
|
||||
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( mut ss ) = data.overlays.remove( &id )
|
||||
{
|
||||
// A GLES canvas carries compiled programs, the glyph atlas and
|
||||
// the image cache: keep it for the next overlay instead of
|
||||
// paying all of that again on every open.
|
||||
if let Some( canvas ) = ss.canvas.take()
|
||||
{
|
||||
if matches!( canvas, crate::render::Canvas::Gles( _ ) )
|
||||
{
|
||||
data.canvas_pool.push( canvas );
|
||||
}
|
||||
}
|
||||
if ss.gesture.long_press_fired
|
||||
{
|
||||
data.main.gesture.long_press_fired = true;
|
||||
data.main.gesture.long_press_origin = ss.gesture.long_press_origin;
|
||||
// Keep the primary touch slot with the migrated drag so motion / release still route through the gesture machine.
|
||||
if data.main.primary_touch_id.is_none()
|
||||
{
|
||||
data.main.primary_touch_id = ss.primary_touch_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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_i = data.main.scale_factor.max( 1 );
|
||||
let parent_scale = parent_scale_i as f32;
|
||||
// `OverlaySpec::size` carries `Length`s resolved against the main
|
||||
// surface's physical viewport (the app's layout space), so an overlay
|
||||
// sized with `Length::widget( … )` scales with the display exactly like
|
||||
// any other widget. `Length::px( n )` keeps a fixed size.
|
||||
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
|
||||
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
|
||||
{
|
||||
let density = crate::types::density();
|
||||
(
|
||||
s.0.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32,
|
||||
s.1.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32,
|
||||
)
|
||||
};
|
||||
// `OverlaySpec::size` is physical pixels (the app's layout space); the
|
||||
// layer-shell `set_size` is logical. They only coincide at scale 1, so
|
||||
// convert here — without it a scale-2 overlay requests a surface twice
|
||||
// its intended size. `0 = fill` survives the divide.
|
||||
let to_logical_size = move | ( w, h ): ( u32, u32 ) | -> ( u32, u32 )
|
||||
{
|
||||
(
|
||||
( w as f32 / parent_scale ).round() as u32,
|
||||
( h as f32 / parent_scale ).round() as u32,
|
||||
)
|
||||
};
|
||||
// The exclusive zone measures the same band the surface occupies, so it
|
||||
// lives in the same space and needs the same conversion. `-1` (ignore
|
||||
// other zones) and `0` (reserve nothing) are sentinels, not distances.
|
||||
let to_logical_zone = move | z: i32 | -> i32
|
||||
{
|
||||
if z > 0 { ( z as f32 / parent_scale ).round() as i32 } else { z }
|
||||
};
|
||||
// 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.frame.widget_rects;
|
||||
let app = &data.app;
|
||||
let overlays_m = &mut data.overlays;
|
||||
let canvas_pool = &mut data.canvas_pool;
|
||||
let has_egl = data.egl_context.is_some();
|
||||
for spec in &specs
|
||||
{
|
||||
let resolved_size = resolve_size( &spec.size );
|
||||
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.
|
||||
let resolved_zone = to_logical_zone( spec.exclusive_zone );
|
||||
if resolved_size != ss.last_requested_size || resolved_zone != ss.last_requested_zone
|
||||
{
|
||||
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
|
||||
{
|
||||
if resolved_size != ss.last_requested_size
|
||||
{
|
||||
let ( lw, lh ) = to_logical_size( resolved_size );
|
||||
layer_surface.set_size( lw, lh );
|
||||
ss.last_requested_size = resolved_size;
|
||||
}
|
||||
if resolved_zone != ss.last_requested_zone
|
||||
{
|
||||
layer_surface.set_exclusive_zone( resolved_zone );
|
||||
ss.last_requested_zone = resolved_zone;
|
||||
}
|
||||
layer_surface.commit();
|
||||
}
|
||||
}
|
||||
// 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 ) = resolved_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;
|
||||
}
|
||||
// The tooltip in a window is a popup beside its widget, without a grab so it never takes the pointer from it.
|
||||
if spec.id == tooltip_id()
|
||||
{
|
||||
if let ( Some( ( anchor, ( w, h ) ) ), Some( xdg_shell ), Some( parent ) ) = ( tooltip_popup, xdg_shell_opt, parent_xdg.as_ref() )
|
||||
{
|
||||
let Ok( positioner ) = XdgPositioner::new( xdg_shell ) else { continue };
|
||||
positioner.set_size( w.max( 1 ), h.max( 1 ) );
|
||||
positioner.set_anchor_rect(
|
||||
( anchor.x / parent_scale ).round() as i32,
|
||||
( anchor.y / parent_scale ).round() as i32,
|
||||
( anchor.width / parent_scale ).round().max( 1.0 ) as i32,
|
||||
( anchor.height / parent_scale ).round().max( 1.0 ) as i32,
|
||||
);
|
||||
positioner.set_anchor( PositionerAnchor::Top );
|
||||
positioner.set_gravity( Gravity::Top );
|
||||
positioner.set_offset( 0, -TOOLTIP_GAP );
|
||||
positioner.set_constraint_adjustment(
|
||||
ConstraintAdjustment::FlipY | ConstraintAdjustment::SlideX | ConstraintAdjustment::SlideY,
|
||||
);
|
||||
let Ok( surface ) = Surface::new( cs, qh ) else { continue };
|
||||
let Ok( popup ) = Popup::from_surface( Some( parent ), &positioner, qh, surface, xdg_shell ) else { continue };
|
||||
popup.wl_surface().commit();
|
||||
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
|
||||
ss.last_requested_size = resolved_size;
|
||||
overlays_m.insert( spec.id, ss );
|
||||
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 ) = resolved_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 = resolved_size;
|
||||
ss.last_popup_anchor = Some( anchor_rect );
|
||||
overlays_m.insert( spec.id, ss );
|
||||
continue;
|
||||
}
|
||||
// xdg-toplevel path: a plain window among the applications.
|
||||
if spec.layer == crate::app::Layer::Window
|
||||
{
|
||||
let Some( xdg_shell ) = xdg_shell_opt else
|
||||
{
|
||||
eprintln!( "ltk: ignoring window overlay {:?} — xdg_wm_base not available", spec.id );
|
||||
continue;
|
||||
};
|
||||
let ( w, h ) = to_logical_size( resolved_size );
|
||||
let ( w, h ) = ( if w == 0 { 480 } else { w }, if h == 0 { 320 } else { h } );
|
||||
let surface = cs.create_surface( qh );
|
||||
let window = xdg_shell.create_window( surface, WindowDecorations::RequestServer, qh );
|
||||
let app_id = app.app_id().to_string();
|
||||
window.set_title( app.overlay_title( spec.id ).unwrap_or_else( || app_id.clone() ) );
|
||||
window.set_app_id( app_id );
|
||||
window.set_min_size( Some( ( w, h ) ) );
|
||||
window.set_max_size( Some( ( w, h ) ) );
|
||||
window.commit();
|
||||
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Window( window ), 0.0, String::new() );
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.last_requested_size = resolved_size;
|
||||
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: to_logical_zone( spec.exclusive_zone ),
|
||||
anchor: spec.anchor,
|
||||
size: to_logical_size( resolved_size ),
|
||||
keyboard_exclusive: spec.keyboard_exclusive,
|
||||
namespace: app.overlay_namespace( spec.id ).unwrap_or( "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() );
|
||||
// Inherit the parent's scale so the first configure allocates a
|
||||
// HiDPI buffer and lays out at the right size from frame one,
|
||||
// instead of rendering at scale 1 until `scale_factor_changed`
|
||||
// lands a frame or two later.
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.last_requested_size = resolved_size;
|
||||
ss.last_requested_zone = to_logical_zone( spec.exclusive_zone );
|
||||
ss.layer_anchor = Some( spec.anchor );
|
||||
if has_egl { ss.canvas = canvas_pool.pop(); }
|
||||
overlays_m.insert( spec.id, ss );
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `zwp_primary_selection_device_manager_v1`: the selection the middle
|
||||
//! button pastes, kept in step with other applications as the clipboard
|
||||
//! is in [`super::data_device`].
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use smithay_client_toolkit::data_device_manager::WritePipe;
|
||||
use smithay_client_toolkit::primary_selection::PrimarySelectionManagerState;
|
||||
use smithay_client_toolkit::primary_selection::device::{ PrimarySelectionDevice, PrimarySelectionDeviceData, PrimarySelectionDeviceHandler };
|
||||
use smithay_client_toolkit::primary_selection::selection::{ PrimarySelectionSource, PrimarySelectionSourceHandler };
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::wl_seat::WlSeat,
|
||||
Connection, Proxy, QueueHandle,
|
||||
};
|
||||
use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::
|
||||
{
|
||||
zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
|
||||
zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use super::app_data::AppData;
|
||||
use super::data_device::{ pick_text_mime, read_selection, write_selection, CLIPBOARD_MIMES };
|
||||
|
||||
pub( crate ) struct PrimarySelection
|
||||
{
|
||||
/// `None` when the compositor does not offer primary selections.
|
||||
manager: Option<PrimarySelectionManagerState>,
|
||||
device: Option<PrimarySelectionDevice>,
|
||||
/// Ours while we own the selection; dropping it gives the selection up.
|
||||
source: Option<PrimarySelectionSource>,
|
||||
pub text: String,
|
||||
/// The selection of the focused text field when it was last looked at.
|
||||
field: Option<String>,
|
||||
inbox_tx: mpsc::Sender<String>,
|
||||
inbox_rx: mpsc::Receiver<String>,
|
||||
}
|
||||
|
||||
impl PrimarySelection
|
||||
{
|
||||
pub( crate ) fn new( manager: Option<PrimarySelectionManagerState> ) -> Self
|
||||
{
|
||||
let ( inbox_tx, inbox_rx ) = mpsc::channel();
|
||||
Self { manager, device: None, source: None, text: String::new(), field: None, inbox_tx, inbox_rx }
|
||||
}
|
||||
|
||||
/// Take what the reader threads brought from other applications.
|
||||
pub( crate ) fn drain( &mut self )
|
||||
{
|
||||
while let Ok( text ) = self.inbox_rx.try_recv()
|
||||
{
|
||||
self.text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
pub( crate ) fn bind_primary_selection( &mut self, qh: &QueueHandle<Self>, seat: &WlSeat )
|
||||
{
|
||||
if self.primary.device.is_some() { return; }
|
||||
let Some( manager ) = &self.primary.manager else { return };
|
||||
self.primary.device = Some( manager.get_selection_device( qh, seat ) );
|
||||
}
|
||||
|
||||
/// Make `text` the primary selection, for other applications as well when the compositor allows.
|
||||
pub( crate ) fn publish_primary_selection( &mut self, text: String )
|
||||
{
|
||||
self.primary.text = text;
|
||||
let ( Some( manager ), Some( device ) ) = ( &self.primary.manager, &self.primary.device ) else { return };
|
||||
let source = manager.create_selection_source( &self.qh, CLIPBOARD_MIMES.iter().copied() );
|
||||
source.set_selection( device, self.last_input_serial );
|
||||
self.primary.source = Some( source );
|
||||
}
|
||||
|
||||
/// Publish a new selection in the focused text field once the press that makes it ends; a password field's never leaves it.
|
||||
pub( crate ) fn sync_text_field( &mut self )
|
||||
{
|
||||
let focus = self.keyboard_focus;
|
||||
if self.try_surface( focus ).is_none_or( |ss| ss.gesture.pressed_idx.is_some() ) { return; }
|
||||
let secure = self.surface( focus ).focused_idx
|
||||
.and_then( |idx| self.text_input_geometry( focus, idx ) )
|
||||
.is_some_and( |( _, _, _, secure, _, _ )| secure );
|
||||
let selected = if secure { None } else { self.focused_selection_text( focus ) };
|
||||
if selected == self.primary.field { return; }
|
||||
self.primary.field = selected.clone();
|
||||
if let Some( text ) = selected { self.publish_primary_selection( text ); }
|
||||
}
|
||||
|
||||
fn owns_primary( &self, source: &ZwpPrimarySelectionSourceV1 ) -> bool
|
||||
{
|
||||
self.primary.source.as_ref().is_some_and( |own| own.inner() == source )
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> PrimarySelectionDeviceHandler for AppData<A>
|
||||
{
|
||||
fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, device: &ZwpPrimarySelectionDeviceV1 )
|
||||
{
|
||||
let Some( offer ) = device.data::<PrimarySelectionDeviceData>().and_then( |data| data.selection_offer() ) else { return };
|
||||
let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return };
|
||||
let Ok( pipe ) = offer.receive( mime ) else { return };
|
||||
read_selection( pipe, self.primary.inbox_tx.clone() );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> PrimarySelectionSourceHandler for AppData<A>
|
||||
{
|
||||
fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &ZwpPrimarySelectionSourceV1, _mime: String, pipe: WritePipe )
|
||||
{
|
||||
if self.owns_primary( source ) { write_selection( pipe, &self.primary.text ); }
|
||||
}
|
||||
|
||||
fn cancelled( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &ZwpPrimarySelectionSourceV1 )
|
||||
{
|
||||
if self.owns_primary( source ) { self.primary.source = None; }
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,21 @@
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use calloop::RegistrationToken;
|
||||
use smithay_client_toolkit::seat::keyboard::KeyEvent;
|
||||
|
||||
use crate::input::event::KeyInput;
|
||||
|
||||
/// 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
|
||||
/// back to re-dispatch the same `KeyInput` 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 event: KeyInput,
|
||||
pub token: RegistrationToken,
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,342 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `xdg-session-management-v1` client glue plus the runtime hooks that
|
||||
//! persist `App::save_state` and turn signals into a clean exit.
|
||||
|
||||
use std::os::fd::{ AsRawFd, FromRawFd, OwnedFd };
|
||||
use std::sync::atomic::{ AtomicI32, Ordering };
|
||||
use std::time::Duration;
|
||||
|
||||
use calloop::timer::{ TimeoutAction, Timer };
|
||||
use calloop::LoopHandle;
|
||||
use smithay_client_toolkit::reexports::client::globals::GlobalList;
|
||||
use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, QueueHandle };
|
||||
use smithay_client_toolkit::shell::xdg::window::Window;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::protocol::xdg_session_management_v1::
|
||||
{
|
||||
xdg_session_manager_v1::{ self, XdgSessionManagerV1 },
|
||||
xdg_session_v1::{ self, XdgSessionV1 },
|
||||
xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 },
|
||||
};
|
||||
use crate::session_state::{ RestoreReason, Startup, StateStore };
|
||||
|
||||
use super::error::RunError;
|
||||
use super::AppData;
|
||||
|
||||
pub( crate ) const TOPLEVEL_NAME: &str = "main";
|
||||
pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 );
|
||||
pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE";
|
||||
|
||||
pub( crate ) struct SessionRuntime
|
||||
{
|
||||
pub session: Option<XdgSessionV1>,
|
||||
pub toplevel_session: Option<XdgToplevelSessionV1>,
|
||||
/// `None` when persistence is off: layer / lock surfaces, an unusable
|
||||
/// app_id, a concurrent instance, or after `replaced`.
|
||||
pub store: Option<StateStore>,
|
||||
pub reason: RestoreReason,
|
||||
pub replaced: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime
|
||||
{
|
||||
pub fn disabled() -> Self
|
||||
{
|
||||
Self {
|
||||
session: None,
|
||||
toplevel_session: None,
|
||||
store: None,
|
||||
reason: RestoreReason::Launch,
|
||||
replaced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1, before any window exists: read the restore hint from the
|
||||
/// environment, decide the reason, hand saved bytes to the app and mark
|
||||
/// the run as live.
|
||||
pub fn bootstrap<A: App>( app: &mut A ) -> Self
|
||||
{
|
||||
let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" );
|
||||
if std::env::var_os( RESTORE_ENV ).is_some()
|
||||
{
|
||||
// SAFETY: removing an env var is sound only when no other thread
|
||||
// is reading the environment concurrently. We are still in the
|
||||
// init phase before `set_channel_sender`, so the app has had
|
||||
// no opportunity to spawn worker threads yet.
|
||||
unsafe { std::env::remove_var( RESTORE_ENV ); }
|
||||
}
|
||||
|
||||
let mut rt = Self::disabled();
|
||||
let Some( mut store ) = StateStore::open( app.app_id() ) else { return rt };
|
||||
match store.decide( env_restore )
|
||||
{
|
||||
Startup::Concurrent =>
|
||||
{
|
||||
eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() );
|
||||
return rt;
|
||||
}
|
||||
Startup::Reason( reason ) => rt.reason = reason,
|
||||
}
|
||||
if rt.reason != RestoreReason::Launch
|
||||
{
|
||||
if let Some( bytes ) = store.load_state()
|
||||
{
|
||||
app.restore_state( bytes );
|
||||
}
|
||||
}
|
||||
store.mark_running();
|
||||
rt.store = Some( store );
|
||||
rt
|
||||
}
|
||||
|
||||
/// Phase 2: bind the manager and open the session. Silent when the
|
||||
/// compositor lacks the global. The manager proxy is not kept: it has
|
||||
/// no state of its own and the session outlives it server-side.
|
||||
pub fn bind<A: App>( &mut self, globals: &GlobalList, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
let Some( store ) = &self.store else { return };
|
||||
let manager: Option<XdgSessionManagerV1> = globals.bind( qh, 1..=1, () ).ok();
|
||||
let Some( manager ) = manager else { return };
|
||||
let reason = match self.reason
|
||||
{
|
||||
RestoreReason::Launch => xdg_session_manager_v1::Reason::Launch,
|
||||
RestoreReason::Recover => xdg_session_manager_v1::Reason::Recover,
|
||||
RestoreReason::SessionRestore => xdg_session_manager_v1::Reason::SessionRestore,
|
||||
};
|
||||
self.session = Some( manager.get_session( reason, store.session_id(), qh, () ) );
|
||||
}
|
||||
|
||||
/// Phase 3: register the main toplevel. Must run before the window's
|
||||
/// first commit or the compositor raises `already_mapped`.
|
||||
pub fn attach_toplevel<A: App>( &mut self, window: &Window, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
let Some( session ) = &self.session else { return };
|
||||
self.toplevel_session =
|
||||
Some( session.restore_toplevel( window.xdg_toplevel(), TOPLEVEL_NAME.to_string(), qh, () ) );
|
||||
}
|
||||
|
||||
pub fn periodic_save<A: App>( &mut self, app: &A )
|
||||
{
|
||||
if self.replaced { return; }
|
||||
if let Some( store ) = &mut self.store
|
||||
{
|
||||
store.save_state_if_changed( app.save_state() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_exit<A: App>( &mut self, app: &A )
|
||||
{
|
||||
if self.replaced { return; }
|
||||
if let Some( store ) = &mut self.store
|
||||
{
|
||||
store.mark_clean_exit( app.save_state() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_replaced( &mut self )
|
||||
{
|
||||
eprintln!( "ltk: session taken over by another instance; this one stops persisting state" );
|
||||
if let Some( t ) = self.toplevel_session.take() { t.destroy(); }
|
||||
if let Some( s ) = self.session.take() { s.destroy(); }
|
||||
self.replaced = true;
|
||||
self.store = None;
|
||||
}
|
||||
}
|
||||
|
||||
static SIGNAL_PIPE_WR: AtomicI32 = AtomicI32::new( -1 );
|
||||
|
||||
/// Async-signal-safe: an atomic load and a `write`, errno preserved.
|
||||
extern "C" fn on_termination_signal( sig: libc::c_int )
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
let errno = *libc::__errno_location();
|
||||
let fd = SIGNAL_PIPE_WR.load( Ordering::Acquire );
|
||||
if fd >= 0
|
||||
{
|
||||
let byte = sig as u8;
|
||||
let _ = libc::write( fd, ( &byte as *const u8 ).cast(), 1 );
|
||||
}
|
||||
*libc::__errno_location() = errno;
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-pipe rather than a signalfd: a `sigaction` handler is process-
|
||||
/// wide, so the clean exit works no matter which thread the kernel
|
||||
/// picks, without blocking the signal in every thread — a mask that
|
||||
/// children would inherit across exec.
|
||||
pub( crate ) fn install_signal_source<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
||||
{
|
||||
use calloop::generic::Generic;
|
||||
use calloop::{ Interest, Mode, PostAction };
|
||||
|
||||
let mut fds = [ -1i32; 2 ];
|
||||
if unsafe { libc::pipe2( fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK ) } != 0
|
||||
{
|
||||
return Err( RunError::EventLoop( format!( "signal pipe2: {}", std::io::Error::last_os_error() ) ) );
|
||||
}
|
||||
SIGNAL_PIPE_WR.store( fds[ 1 ], Ordering::Release );
|
||||
|
||||
unsafe
|
||||
{
|
||||
let mut action: libc::sigaction = std::mem::zeroed();
|
||||
action.sa_sigaction = on_termination_signal as usize;
|
||||
action.sa_flags = libc::SA_RESTART;
|
||||
libc::sigemptyset( &mut action.sa_mask );
|
||||
for sig in [ libc::SIGTERM, libc::SIGINT ]
|
||||
{
|
||||
if libc::sigaction( sig, &action, std::ptr::null_mut() ) != 0
|
||||
{
|
||||
return Err( RunError::EventLoop( format!( "sigaction: {}", std::io::Error::last_os_error() ) ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let read_end = unsafe { OwnedFd::from_raw_fd( fds[ 0 ] ) };
|
||||
handle
|
||||
.insert_source( Generic::new( read_end, Interest::READ, Mode::Level ), |_, fd, data: &mut AppData<A>|
|
||||
{
|
||||
let mut sig = None;
|
||||
let mut buf = [ 0u8; 16 ];
|
||||
loop
|
||||
{
|
||||
let n = unsafe { libc::read( fd.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len() ) };
|
||||
if n <= 0 { break; }
|
||||
sig = Some( buf[ 0 ] as i32 );
|
||||
}
|
||||
if let Some( sig ) = sig
|
||||
{
|
||||
let name = match sig
|
||||
{
|
||||
libc::SIGTERM => "SIGTERM",
|
||||
libc::SIGINT => "SIGINT",
|
||||
_ => "signal",
|
||||
};
|
||||
eprintln!( "ltk: {name} received, exiting cleanly" );
|
||||
data.exit_requested = true;
|
||||
}
|
||||
Ok( PostAction::Continue )
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "signal pipe insert_source: {e:?}" ) ) )?;
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
pub( crate ) fn install_save_timer<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
||||
{
|
||||
handle
|
||||
.insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData<A>|
|
||||
{
|
||||
data.session.periodic_save( &data.app );
|
||||
TimeoutAction::ToDuration( SAVE_INTERVAL )
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?;
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionManagerV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgSessionManagerV1,
|
||||
_event: xdg_session_manager_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &XdgSessionV1,
|
||||
event: xdg_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
match event
|
||||
{
|
||||
xdg_session_v1::Event::Created { session_id } =>
|
||||
{
|
||||
if let Some( store ) = &mut state.session.store
|
||||
{
|
||||
store.set_session_id( session_id );
|
||||
}
|
||||
}
|
||||
xdg_session_v1::Event::Restored => {}
|
||||
xdg_session_v1::Event::Replaced => state.session.on_replaced(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgToplevelSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgToplevelSessionV1,
|
||||
_event: xdg_toplevel_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
fn read_one( fd: i32 ) -> Option<u8>
|
||||
{
|
||||
let mut buf = [ 0u8; 4 ];
|
||||
let n = unsafe { libc::read( fd, buf.as_mut_ptr().cast(), buf.len() ) };
|
||||
( n == 1 ).then( || buf[ 0 ] )
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn handler_writes_signal_to_pipe()
|
||||
{
|
||||
let mut fds = [ -1i32; 2 ];
|
||||
assert_eq!( unsafe { libc::pipe2( fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK ) }, 0 );
|
||||
SIGNAL_PIPE_WR.store( fds[ 1 ], Ordering::Release );
|
||||
|
||||
on_termination_signal( libc::SIGTERM );
|
||||
assert_eq!( read_one( fds[ 0 ] ), Some( libc::SIGTERM as u8 ) );
|
||||
|
||||
// The real wiring: raise() delivers synchronously to this
|
||||
// thread, so the handler has run before it returns.
|
||||
unsafe
|
||||
{
|
||||
let mut action: libc::sigaction = std::mem::zeroed();
|
||||
action.sa_sigaction = on_termination_signal as usize;
|
||||
action.sa_flags = libc::SA_RESTART;
|
||||
libc::sigemptyset( &mut action.sa_mask );
|
||||
assert_eq!( libc::sigaction( libc::SIGTERM, &action, std::ptr::null_mut() ), 0 );
|
||||
libc::raise( libc::SIGTERM );
|
||||
}
|
||||
assert_eq!( read_one( fds[ 0 ] ), Some( libc::SIGTERM as u8 ) );
|
||||
|
||||
unsafe
|
||||
{
|
||||
let mut dfl: libc::sigaction = std::mem::zeroed();
|
||||
dfl.sa_sigaction = libc::SIG_DFL;
|
||||
libc::sigemptyset( &mut dfl.sa_mask );
|
||||
libc::sigaction( libc::SIGTERM, &dfl, std::ptr::null_mut() );
|
||||
}
|
||||
SIGNAL_PIPE_WR.store( -1, Ordering::Release );
|
||||
unsafe
|
||||
{
|
||||
libc::close( fds[ 0 ] );
|
||||
libc::close( fds[ 1 ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Building the runtime's state for an app, and the event sources every backend installs on its loop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use calloop::LoopHandle;
|
||||
use calloop::channel::{ self, Event };
|
||||
use calloop::timer::{ TimeoutAction, Timer };
|
||||
|
||||
use crate::a11y::A11yState;
|
||||
use crate::app::App;
|
||||
use crate::platform::Platform;
|
||||
use crate::types::Point;
|
||||
|
||||
use super::{ AppData, RunError, SurfaceFocus, SurfaceState };
|
||||
use super::perf::PerfState;
|
||||
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// `app` on `platform` with `main` as its only surface, nothing focused, pressed or queued.
|
||||
pub( crate ) fn new( app: A, platform: P, main: SurfaceState<A::Message, P::Surface>, loop_handle: LoopHandle<'static, Self>, builtin_osk: bool, a11y: Option<A11yState> ) -> Self
|
||||
{
|
||||
let fullscreen = app.start_fullscreen();
|
||||
Self
|
||||
{
|
||||
app,
|
||||
platform,
|
||||
pointer_pos: Point::default(),
|
||||
current_cursor_shape: None,
|
||||
builtin_osk,
|
||||
text_input_secure: false,
|
||||
text_input_purpose: crate::osk::Purpose::Normal,
|
||||
external_text_input: None,
|
||||
primary_selection: String::new(),
|
||||
primary_field: None,
|
||||
a11y,
|
||||
shift_pressed: false,
|
||||
ctrl_pressed: false,
|
||||
alt_pressed: false,
|
||||
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: std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok(),
|
||||
pending_msgs: Vec::new(),
|
||||
pending_drag_inits: Vec::new(),
|
||||
tooltip_pending: None,
|
||||
tooltip_visible: None,
|
||||
exit_requested: false,
|
||||
applied_fullscreen: Some( fullscreen ),
|
||||
main,
|
||||
overlays: HashMap::new(),
|
||||
secondaries: Vec::new(),
|
||||
pointer_focus: SurfaceFocus::Main,
|
||||
keyboard_focus: SurfaceFocus::Main,
|
||||
press_on_focused: false,
|
||||
touch_focus: HashMap::new(),
|
||||
cached_view: None,
|
||||
cached_overlays: None,
|
||||
view_dirty: true,
|
||||
overlays_dirty: true,
|
||||
first_frame_committed: false,
|
||||
focus_retry: None,
|
||||
perf: PerfState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Let the app send messages from any thread, follow the desktop's text scale and wake on [`App::poll_interval`].
|
||||
pub( crate ) fn install_sources( &mut self ) -> Result<(), RunError>
|
||||
{
|
||||
let handle = self.loop_handle.clone();
|
||||
|
||||
// Only queued: the run loop's `App::invalidate_after` decides which surfaces the message redraws.
|
||||
let ( sender, messages ) = channel::channel::<A::Message>();
|
||||
handle.insert_source( messages, |event, _, data: &mut Self|
|
||||
{
|
||||
if let Event::Msg( msg ) = event { data.pending_msgs.push( msg ); }
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
|
||||
self.app.set_channel_sender( sender );
|
||||
|
||||
// Font resolution multiplies by `text_scale()` at paint time, so a new factor only needs a repaint.
|
||||
let ( tx, factors ) = channel::channel::<f32>();
|
||||
handle.insert_source( factors, |event, _, data: &mut Self|
|
||||
{
|
||||
let Event::Msg( factor ) = event else { return };
|
||||
if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; }
|
||||
crate::types::set_text_scale( factor );
|
||||
data.dirty_caches();
|
||||
data.main.request_redraw();
|
||||
for ss in data.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
}
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?;
|
||||
super::text_scale::spawn_watcher( tx );
|
||||
|
||||
if let Some( dur ) = self.app.poll_interval()
|
||||
{
|
||||
self.perf.warn_poll_interval( dur );
|
||||
handle.insert_source( Timer::from_duration( dur ), |_, _, data: &mut Self|
|
||||
{
|
||||
let msgs = data.app.poll_external();
|
||||
data.pending_msgs.extend( msgs );
|
||||
TimeoutAction::ToDuration( data.app.poll_interval().unwrap_or( std::time::Duration::from_secs( 1 ) ) )
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
|
||||
}
|
||||
Ok( () )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The parts of a run-loop iteration that are the same on every backend. After waking, a backend handles what its system delivered, then calls [`AppData::before_draw`], [`AppData::draw_due`] and [`AppData::after_draw`], and sleeps at most [`AppData::next_wakeup`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::{ App, InvalidationScope, SelectionKind };
|
||||
use crate::platform::Platform;
|
||||
|
||||
use super::{ AppData, SurfaceFocus };
|
||||
use super::frame::draw_frame;
|
||||
use super::invalidation::apply_invalidation;
|
||||
|
||||
/// Drawn frames a focus request waits for its target to appear: one for the rebuilt view, one per scroll viewport moved to reveal the widget.
|
||||
const FOCUS_RETRY_FRAMES: u8 = 4;
|
||||
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// The longest the backend may sleep with no event: until a still press turns long, a tooltip shows or the built-in keyboard acts. Frames are paced by the system, so nothing else needs a deadline.
|
||||
pub( crate ) fn next_wakeup( &self ) -> Option<Duration>
|
||||
{
|
||||
[ self.next_long_press_wakeup(), self.next_tooltip_wakeup(), self.next_osk_wakeup() ]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Everything between handling the system's events and drawing: deadlines that passed, the app's external messages, the update, and what the update asks of the window.
|
||||
pub( crate ) fn before_draw( &mut self )
|
||||
{
|
||||
self.check_long_press_deadlines();
|
||||
self.check_tooltip_deadline();
|
||||
self.check_osk_deadlines();
|
||||
|
||||
let ext: Vec<_> = self.app.poll_external();
|
||||
self.pending_msgs.extend( ext );
|
||||
|
||||
self.process_messages();
|
||||
if self.app.requested_exit() { self.exit_requested = true; }
|
||||
self.sync_fullscreen();
|
||||
self.sync_external_text_input();
|
||||
|
||||
// After `update()`, so the app's drag state set up by the paired long-press message is there to receive the origin.
|
||||
let drag_inits: Vec<_> = self.pending_drag_inits.drain( .. ).collect();
|
||||
if !drag_inits.is_empty()
|
||||
{
|
||||
for origin in drag_inits
|
||||
{
|
||||
self.app.on_drag_move( origin.x, origin.y );
|
||||
}
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
}
|
||||
// The app state is now the source of truth, so the next keystroke must read it rather than a stale buffer (a password cleared on a failed login).
|
||||
self.main.pending_text_values.clear();
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.pending_text_values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything after drawing: the accessibility tree and its actions, then the app's requests to blur, copy, paste and move the focus. `drawn` is what [`Self::draw_due`] returned.
|
||||
pub( crate ) fn after_draw( &mut self, drawn: bool )
|
||||
{
|
||||
if drawn { self.publish_a11y_tree(); }
|
||||
self.apply_a11y_actions();
|
||||
|
||||
if self.app.take_blur_request()
|
||||
{
|
||||
let focus = self.keyboard_focus;
|
||||
self.set_focus( focus, None );
|
||||
self.focus_retry = None;
|
||||
}
|
||||
|
||||
if let Some( text ) = self.app.take_clipboard_request()
|
||||
{
|
||||
self.publish_clipboard( text );
|
||||
}
|
||||
self.sync_text_field();
|
||||
if let Some( text ) = self.app.take_primary_request()
|
||||
{
|
||||
self.publish_primary_selection( text );
|
||||
}
|
||||
if let Some( kind ) = self.app.take_paste_request()
|
||||
{
|
||||
let text = match kind
|
||||
{
|
||||
SelectionKind::Clipboard => self.clipboard.clone(),
|
||||
SelectionKind::Primary => self.primary_selection.clone(),
|
||||
};
|
||||
if !text.is_empty() { self.app.on_paste( &text ); }
|
||||
}
|
||||
|
||||
self.apply_focus_request( drawn );
|
||||
}
|
||||
|
||||
/// What follows the system's word that the last frame of `focus` is on screen: an animation draws its next frame, or, when the app animates only by moving subsurfaces `focus` hosts, leaves the cadence to them. Under the software animation cap a skipped frame keeps the cadence with a bare frame request.
|
||||
pub( crate ) fn frame_presented( &mut self, focus: SurfaceFocus, hosts_subsurfaces: bool )
|
||||
{
|
||||
let animating = self.app.is_animating();
|
||||
match focus
|
||||
{
|
||||
SurfaceFocus::Main =>
|
||||
{
|
||||
self.main.frame_pending = false;
|
||||
if !animating
|
||||
{
|
||||
self.perf.animation_stopped();
|
||||
return;
|
||||
}
|
||||
if hosts_subsurfaces && self.app.subsurface_motion_only()
|
||||
{
|
||||
self.main.frame_tick_due = true;
|
||||
return;
|
||||
}
|
||||
let cap = self.app.cap_software_animation();
|
||||
if self.perf.animated_frame( crate::render::is_software_render(), cap )
|
||||
{
|
||||
self.view_dirty = true;
|
||||
self.main.request_redraw();
|
||||
} else if self.platform.request_frame( &self.main.platform, SurfaceFocus::Main ) {
|
||||
self.main.frame_pending = true;
|
||||
}
|
||||
}
|
||||
SurfaceFocus::Overlay( id ) =>
|
||||
{
|
||||
let motion_only = hosts_subsurfaces && self.app.subsurface_motion_only();
|
||||
let Some( ss ) = self.overlays.get_mut( &id ) else { return };
|
||||
ss.frame_pending = false;
|
||||
if !animating { return; }
|
||||
if motion_only
|
||||
{
|
||||
ss.frame_tick_due = true;
|
||||
} else {
|
||||
ss.request_redraw();
|
||||
self.overlays_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus the widget the app asked for, on the main surface or any overlay. One not laid out yet, or clipped by a scroll viewport, is waited for over a few drawn frames.
|
||||
fn apply_focus_request( &mut self, drawn: bool )
|
||||
{
|
||||
let retry = self.focus_retry.take();
|
||||
let Some( ( id, frames_left ) ) = self.app.take_focus_request()
|
||||
.map( |id| ( id, FOCUS_RETRY_FRAMES ) )
|
||||
.or( retry )
|
||||
else { return };
|
||||
|
||||
let mut hit: Option<( SurfaceFocus, usize )> = self.main.frame.widget_rects.iter()
|
||||
.find( |w| w.id == Some( id ) )
|
||||
.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
|
||||
if hit.is_none()
|
||||
{
|
||||
hit = self.overlays.iter().find_map( |( ov_id, surf )|
|
||||
{
|
||||
surf.frame.widget_rects.iter()
|
||||
.find( |w| w.id == Some( id ) )
|
||||
.map( |w| ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) )
|
||||
} );
|
||||
}
|
||||
if let Some( ( focus, flat_idx ) ) = hit
|
||||
{
|
||||
self.set_focus( focus, Some( flat_idx ) );
|
||||
if let Some( ss ) = self.try_surface_mut( focus ) { ss.request_redraw(); }
|
||||
return;
|
||||
}
|
||||
let frames_left = if drawn { frames_left.saturating_sub( 1 ) } else { frames_left };
|
||||
if frames_left > 0
|
||||
{
|
||||
self.reveal_clipped_widget( id );
|
||||
self.focus_retry = Some( ( id, frames_left ) );
|
||||
self.view_dirty = true;
|
||||
self.main.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the queued messages to [`App::update`], folding their invalidation scopes into one, and re-sync the cursor the update may have changed.
|
||||
pub( crate ) fn process_messages( &mut self )
|
||||
{
|
||||
let msgs: Vec<_> = self.pending_msgs.drain( .. ).collect();
|
||||
if msgs.is_empty() { return; }
|
||||
let mut scope = InvalidationScope::Only( Vec::new() );
|
||||
for msg in msgs
|
||||
{
|
||||
scope = scope.union( self.app.invalidate_after( &msg ) );
|
||||
self.app.update( msg );
|
||||
}
|
||||
apply_invalidation( self, 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 = self.pointer_focus;
|
||||
self.dispatch_cursor_shape( pf );
|
||||
}
|
||||
|
||||
/// Draw every surface that is configured, dirty and not waiting on a frame callback, rebuilding the cached view and overlays first when they are stale. Returns whether any surface was due.
|
||||
pub( crate ) fn draw_due( &mut self ) -> bool
|
||||
{
|
||||
let any_drawable = ( self.main.configured && self.main.needs_redraw && !self.main.frame_pending )
|
||||
|| self.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending )
|
||||
|| self.secondaries.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending );
|
||||
if !any_drawable { return false; }
|
||||
|
||||
// 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 =
|
||||
self.main.gesture.dragging_slider.is_some()
|
||||
|| self.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
|
||||
if runtime_slider_motion
|
||||
{
|
||||
self.view_dirty = true;
|
||||
self.overlays_dirty = true;
|
||||
}
|
||||
if self.view_dirty
|
||||
{
|
||||
self.cached_view = Some( self.app.view() );
|
||||
self.view_dirty = false;
|
||||
}
|
||||
if self.overlays_dirty
|
||||
{
|
||||
let mut specs = self.app.overlays();
|
||||
if let Some( ts ) = self.tooltip_overlay() { specs.push( ts ); }
|
||||
self.cached_overlays = Some( specs );
|
||||
self.overlays_dirty = false;
|
||||
}
|
||||
if draw_frame( self )
|
||||
{
|
||||
self.app.on_first_frame_committed();
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Input-transparent child surfaces ([`crate::app::SubsurfaceSpec`]).
|
||||
//!
|
||||
//! Each spec maps to one `wl_subsurface` sized to the main surface, with an
|
||||
//! empty input region so all input falls through to the parent — the host
|
||||
//! keeps a single gesture/input model. The content buffer is rasterised only
|
||||
//! when the size or the spec's `content_version` changes; a position change
|
||||
//! emits `wl_subsurface.set_position` plus a bare parent commit, so an animated
|
||||
//! slide costs a compositor recomposite, not a client re-raster.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use smithay_client_toolkit::compositor::CompositorState;
|
||||
use smithay_client_toolkit::shm::Shm;
|
||||
use smithay_client_toolkit::shm::slot::SlotPool;
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_shm;
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
|
||||
|
||||
use crate::app::{ App, SubsurfaceParent };
|
||||
use crate::egl_context::{ EglContext, EglSurface };
|
||||
use crate::draw::DrawCtx;
|
||||
use crate::draw::chrome::apply_input_region;
|
||||
use crate::draw::layout_and_draw;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::Rect;
|
||||
use crate::widget::Element;
|
||||
|
||||
use super::frame::pick_shm_format;
|
||||
use super::AppData;
|
||||
|
||||
/// Convert a layout (physical) position to the logical position the
|
||||
/// compositor expects for `wl_subsurface.set_position`.
|
||||
fn to_logical( x: i32, y: i32, scale: u32 ) -> ( i32, i32 )
|
||||
{
|
||||
let s = scale.max( 1 ) as i32;
|
||||
( x / s, y / s )
|
||||
}
|
||||
|
||||
/// Live state for one subsurface: its Wayland objects, its own SHM pool and
|
||||
/// canvas, and the last (size, version, position) we committed so the per-frame
|
||||
/// pass can tell a re-raster from a cheap reposition.
|
||||
pub( crate ) struct SubsurfaceSlot
|
||||
{
|
||||
subsurface: WlSubsurface,
|
||||
surface: WlSurface,
|
||||
/// SHM pool for the software raster path. `None` on the GLES path.
|
||||
pool: Option<SlotPool>,
|
||||
/// EGL window surface for the GLES raster path (so the `surface-panel`
|
||||
/// backdrop blur, a GLES-only pass, renders). `None` on the software path.
|
||||
egl_surface: Option<EglSurface>,
|
||||
canvas: Option<Canvas>,
|
||||
last_pos: ( i32, i32 ),
|
||||
last_size: ( u32, u32 ),
|
||||
last_version: u64,
|
||||
rastered: bool,
|
||||
parent: SubsurfaceParent,
|
||||
scroll_canvases: HashMap<usize, Canvas>,
|
||||
viewport_canvases: HashMap<usize, Canvas>,
|
||||
}
|
||||
|
||||
impl SubsurfaceSlot
|
||||
{
|
||||
pub( crate ) fn parent( &self ) -> SubsurfaceParent
|
||||
{
|
||||
self.parent
|
||||
}
|
||||
|
||||
fn destroy( mut self, egl: Option<&Arc<EglContext>> )
|
||||
{
|
||||
// GLES sub-canvases free their objects on drop and need the context current.
|
||||
if let ( Some( ctx ), Some( es ) ) = ( egl, self.egl_surface.as_ref() )
|
||||
{
|
||||
let _ = ctx.make_current( es );
|
||||
}
|
||||
self.scroll_canvases.clear();
|
||||
self.viewport_canvases.clear();
|
||||
// Drop the EGL surface before the wl_surface its wl_egl_window wraps.
|
||||
self.egl_surface = None;
|
||||
self.subsurface.destroy();
|
||||
self.surface.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_of( parent: SubsurfaceParent ) -> super::SurfaceFocus
|
||||
{
|
||||
match parent
|
||||
{
|
||||
SubsurfaceParent::Main => super::SurfaceFocus::Main,
|
||||
SubsurfaceParent::Overlay( id ) => super::SurfaceFocus::Overlay( id ),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_frame_pending<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> bool
|
||||
{
|
||||
match parent
|
||||
{
|
||||
SubsurfaceParent::Main => data.main.frame_pending,
|
||||
SubsurfaceParent::Overlay( id ) => data.overlays.get( &id ).is_some_and( |ss| ss.frame_pending ),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a frame callback on `parent` ahead of the commit that applies a
|
||||
/// subsurface move, unless one is already outstanding.
|
||||
fn arm_parent_frame<A: App>( data: &mut AppData<A>, parent: SubsurfaceParent, wl: &WlSurface )
|
||||
{
|
||||
let ss = match parent
|
||||
{
|
||||
SubsurfaceParent::Main => &mut data.main,
|
||||
SubsurfaceParent::Overlay( id ) => match data.overlays.get_mut( &id ) { Some( s ) => s, None => return },
|
||||
};
|
||||
ss.frame_tick_due = false;
|
||||
if ss.frame_pending { return; }
|
||||
let _ = wl.frame( &data.qh, focus_of( parent ) );
|
||||
ss.frame_pending = true;
|
||||
}
|
||||
|
||||
/// Keep the frame cadence on a parent whose callback fired while the app
|
||||
/// animates by subsurface motion only, when this pass moved nothing on it.
|
||||
fn keep_parent_cadence<A: App>( data: &mut AppData<A>, parent: SubsurfaceParent, animating: bool )
|
||||
{
|
||||
let ss = match parent
|
||||
{
|
||||
SubsurfaceParent::Main => &mut data.main,
|
||||
SubsurfaceParent::Overlay( id ) => match data.overlays.get_mut( &id ) { Some( s ) => s, None => return },
|
||||
};
|
||||
if !std::mem::take( &mut ss.frame_tick_due ) { return; }
|
||||
if ss.frame_pending || !animating { return; }
|
||||
let Some( wl ) = ss.surface.try_wl_surface().cloned() else { return };
|
||||
let _ = wl.frame( &data.qh, focus_of( parent ) );
|
||||
wl.commit();
|
||||
ss.frame_pending = true;
|
||||
}
|
||||
|
||||
/// Resolve a subsurface's parent surface to `( wl_surface, phys_w, phys_h,
|
||||
/// scale )`. Returns `None` when an [`SubsurfaceParent::Overlay`] target is
|
||||
/// absent, not yet configured, or zero-sized — the caller skips the spec for
|
||||
/// that frame. The `WlSurface` is cloned (an `Arc`) so the render / reposition
|
||||
/// loop can run without holding a borrow on `data.overlays` / `data.main`.
|
||||
fn resolve_parent<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Option<( WlSurface, u32, u32, u32 )>
|
||||
{
|
||||
let ss = match parent
|
||||
{
|
||||
SubsurfaceParent::Main => &data.main,
|
||||
SubsurfaceParent::Overlay( id ) => data.overlays.get( &id )?,
|
||||
};
|
||||
if !ss.configured { return None; }
|
||||
if ss.width == 0 || ss.height == 0 { return None; }
|
||||
let surface = ss.surface.try_wl_surface()?.clone();
|
||||
let scale = ss.scale_factor.max( 1 ) as u32;
|
||||
Some( ( surface, ss.width * scale, ss.height * scale, scale ) )
|
||||
}
|
||||
|
||||
/// Record a parent surface that needs a commit this frame, deduped by parent
|
||||
/// identity so each is committed once.
|
||||
fn mark_parent_dirty( list: &mut Vec<( SubsurfaceParent, WlSurface )>, parent: SubsurfaceParent, wl: &WlSurface )
|
||||
{
|
||||
if !list.iter().any( |( p, _ )| *p == parent )
|
||||
{
|
||||
list.push( ( parent, wl.clone() ) );
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile the live subsurfaces against [`App::subsurfaces`], render content
|
||||
/// where it changed and reposition where it moved. Cheap when nothing but a
|
||||
/// position changed. Each spec is composited under its own parent surface
|
||||
/// (main or an overlay), so a slide can ride above app windows. Must run after
|
||||
/// the parent surface is configured.
|
||||
pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
|
||||
{
|
||||
if data.subcompositor.is_none() { return; }
|
||||
if !data.main.configured { return; }
|
||||
|
||||
let ( format, swap_rb ) = pick_shm_format( &data.shm );
|
||||
let specs: Vec<crate::app::SubsurfaceSpec<A::Message>> = data.app.subsurfaces();
|
||||
|
||||
// Parents touched this frame; each committed once at the end so the
|
||||
// placement / mapping actually lands.
|
||||
let mut dirty_parents: Vec<( SubsurfaceParent, WlSurface )> = Vec::new();
|
||||
let mut committed: Vec<crate::app::SubsurfaceId> = Vec::new();
|
||||
|
||||
// Drop subsurfaces whose id disappeared from the spec list.
|
||||
let present: std::collections::HashSet<crate::app::SubsurfaceId> =
|
||||
specs.iter().map( |s| s.id ).collect();
|
||||
let stale: Vec<crate::app::SubsurfaceId> = data.subsurfaces.keys()
|
||||
.copied()
|
||||
.filter( |id| !present.contains( id ) )
|
||||
.collect();
|
||||
for id in stale
|
||||
{
|
||||
if let Some( slot ) = data.subsurfaces.remove( &id )
|
||||
{
|
||||
let parent = slot.parent;
|
||||
slot.destroy( data.egl_context.as_ref() );
|
||||
if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent )
|
||||
{
|
||||
mark_parent_dirty( &mut dirty_parents, parent, &wl );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for spec in &specs
|
||||
{
|
||||
let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue };
|
||||
|
||||
// Create on first sight.
|
||||
if !data.subsurfaces.contains_key( &spec.id )
|
||||
{
|
||||
let ( subsurface, surface ) = {
|
||||
let sc = data.subcompositor.as_ref().unwrap();
|
||||
sc.create_subsurface( parent_wl.clone(), &data.qh )
|
||||
};
|
||||
// Independent buffer commits; placement still applies on the
|
||||
// parent commit we issue below.
|
||||
subsurface.set_desync();
|
||||
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
|
||||
subsurface.set_position( lx, ly );
|
||||
data.subsurfaces.insert( spec.id, SubsurfaceSlot {
|
||||
subsurface,
|
||||
surface,
|
||||
pool: None,
|
||||
egl_surface: None,
|
||||
canvas: None,
|
||||
last_pos: ( spec.x, spec.y ),
|
||||
last_size: ( 0, 0 ),
|
||||
last_version: u64::MAX,
|
||||
rastered: false,
|
||||
parent: spec.parent,
|
||||
scroll_canvases: HashMap::new(),
|
||||
viewport_canvases: HashMap::new(),
|
||||
} );
|
||||
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||
}
|
||||
let parent_pending = parent_frame_pending( data, spec.parent );
|
||||
|
||||
// Tracked on the slot (not the canvas) because the GLES canvas lives
|
||||
// in `data.subsurface_gles_canvas`, shared across all subsurfaces.
|
||||
let size_changed = data.subsurfaces.get( &spec.id )
|
||||
.map( |s| s.last_size != ( pw, ph ) )
|
||||
.unwrap_or( true );
|
||||
let needs_raster = {
|
||||
let slot = data.subsurfaces.get( &spec.id ).unwrap();
|
||||
!slot.rastered || size_changed || slot.last_version != spec.content_version
|
||||
};
|
||||
|
||||
if needs_raster
|
||||
{
|
||||
// GLES only when the spec asks for it (glass content); otherwise
|
||||
// the cheaper-to-create software rasteriser.
|
||||
let egl = if spec.gpu { data.egl_context.as_ref() } else { None };
|
||||
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
|
||||
render_slot::<A::Message>(
|
||||
slot, egl, &mut data.subsurface_gles_canvas,
|
||||
&data.shm, &data.compositor_state, &spec.view,
|
||||
pw, ph, scale, format, swap_rb, size_changed,
|
||||
);
|
||||
slot.rastered = true;
|
||||
slot.last_version = spec.content_version;
|
||||
slot.last_size = ( pw, ph );
|
||||
committed.push( spec.id );
|
||||
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||
}
|
||||
|
||||
// A move waits for the parent's outstanding frame callback, so a drag
|
||||
// commits once per vblank with the latest position instead of once
|
||||
// per input sample.
|
||||
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
|
||||
if slot.last_pos != ( spec.x, spec.y ) && !parent_pending
|
||||
{
|
||||
let ( lx, ly ) = to_logical( spec.x, spec.y, scale );
|
||||
slot.subsurface.set_position( lx, ly );
|
||||
// Desync subsurface state (the position) is applied on the child
|
||||
// surface's own commit, not the parent's; commit it here so the
|
||||
// move actually lands. The parent commit below applies placement.
|
||||
slot.surface.commit();
|
||||
slot.last_pos = ( spec.x, spec.y );
|
||||
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
|
||||
}
|
||||
}
|
||||
|
||||
// Applies pending subsurface placement / mapping without re-attaching the
|
||||
// parent buffer — the cheap per-frame move.
|
||||
for ( parent, wl ) in dirty_parents
|
||||
{
|
||||
// A bufferless commit is a protocol error on a session-lock surface.
|
||||
if parent == SubsurfaceParent::Main && !data.first_frame_committed { continue; }
|
||||
arm_parent_frame( data, parent, &wl );
|
||||
wl.commit();
|
||||
}
|
||||
|
||||
let animating = data.app.is_animating();
|
||||
keep_parent_cadence( data, SubsurfaceParent::Main, animating );
|
||||
let overlay_ids: Vec<crate::app::OverlayId> = data.overlays.keys().copied().collect();
|
||||
for id in overlay_ids
|
||||
{
|
||||
keep_parent_cadence( data, SubsurfaceParent::Overlay( id ), animating );
|
||||
}
|
||||
|
||||
for id in committed
|
||||
{
|
||||
data.app.on_subsurface_committed( id );
|
||||
}
|
||||
}
|
||||
|
||||
/// Input-transparent subsurfaces never carry focus / hover / scroll state,
|
||||
/// so they draw with an empty context.
|
||||
fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
|
||||
{
|
||||
DrawCtx
|
||||
{
|
||||
focused_idx: None,
|
||||
hovered_idx: None,
|
||||
pressed_idx: None,
|
||||
cursor_state: HashMap::new(),
|
||||
selection_anchor: HashMap::new(),
|
||||
text_scroll: HashMap::new(),
|
||||
widget_rects: Vec::new(),
|
||||
debug_layout: false,
|
||||
scroll_offsets: HashMap::new(),
|
||||
scroll_rects: Vec::new(),
|
||||
scroll_canvases: HashMap::new(),
|
||||
viewport_canvases: HashMap::new(),
|
||||
viewport_seq: 0,
|
||||
scroll_navigable_items: HashMap::new(),
|
||||
scroll_clipped: Vec::new(),
|
||||
previous_widget_rects: Vec::new(),
|
||||
accessible_extras: Vec::new(),
|
||||
live_depth: 0,
|
||||
origin: ( 0.0, 0.0 ),
|
||||
blur_regions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_draw_ctx<Msg: Clone>( scroll: &mut HashMap<usize, Canvas>, viewport: &mut HashMap<usize, Canvas> ) -> DrawCtx<Msg>
|
||||
{
|
||||
let mut ctx = empty_draw_ctx::<Msg>();
|
||||
ctx.scroll_canvases = std::mem::take( scroll );
|
||||
ctx.viewport_canvases = std::mem::take( viewport );
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Raster a subsurface's content. Uses the GLES path when an [`EglContext`]
|
||||
/// is available — only there does the `surface-panel` backdrop blur render;
|
||||
/// the software fallback paints the same widgets without the blur.
|
||||
#[ allow( clippy::too_many_arguments ) ]
|
||||
fn render_slot<Msg: Clone>(
|
||||
slot: &mut SubsurfaceSlot,
|
||||
egl_ctx: Option<&Arc<EglContext>>,
|
||||
gles_canvas: &mut Option<Canvas>,
|
||||
shm: &Shm,
|
||||
compositor: &CompositorState,
|
||||
view: &Element<Msg>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
shm_format: wl_shm::Format,
|
||||
swap_rb: bool,
|
||||
size_changed: bool,
|
||||
)
|
||||
{
|
||||
if let Some( ctx ) = egl_ctx
|
||||
{
|
||||
render_slot_gpu::<Msg>( slot, ctx, gles_canvas, compositor, view, pw, ph, scale, size_changed );
|
||||
}
|
||||
else
|
||||
{
|
||||
render_slot_software::<Msg>( slot, shm, compositor, view, pw, ph, scale, shm_format, swap_rb, size_changed );
|
||||
}
|
||||
}
|
||||
|
||||
#[ allow( clippy::too_many_arguments ) ]
|
||||
fn render_slot_gpu<Msg: Clone>(
|
||||
slot: &mut SubsurfaceSlot,
|
||||
egl_ctx: &Arc<EglContext>,
|
||||
gles_canvas: &mut Option<Canvas>,
|
||||
compositor: &CompositorState,
|
||||
view: &Element<Msg>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
size_changed: bool,
|
||||
)
|
||||
{
|
||||
// (Re)create the EGL window surface on first sight or a size change.
|
||||
if slot.egl_surface.is_none() || size_changed
|
||||
{
|
||||
match egl_ctx.create_surface( &slot.surface, pw as i32, ph as i32 )
|
||||
{
|
||||
Ok( es ) => slot.egl_surface = Some( es ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "ltk: subsurface EGL surface creation failed: {e}" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let es = slot.egl_surface.as_ref().unwrap();
|
||||
if egl_ctx.make_current( es ).is_err() { return; }
|
||||
let trace = super::perf::RasterTrace::begin();
|
||||
|
||||
// The canvas (and its compiled shader programs) is shared across all
|
||||
// subsurface rasters, so a lazily re-created panel doesn't recompile.
|
||||
let canvas = gles_canvas.get_or_insert_with( ||
|
||||
{
|
||||
let mut c = Canvas::new_gles( Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph );
|
||||
c.set_dpi_scale( scale as f32 );
|
||||
if let Some( reg ) = crate::theme::build_font_registry()
|
||||
{
|
||||
c.set_font_registry( Arc::new( reg ) );
|
||||
}
|
||||
c
|
||||
} );
|
||||
if canvas.size() != ( pw, ph )
|
||||
{
|
||||
canvas.resize( pw, ph );
|
||||
canvas.set_dpi_scale( scale as f32 );
|
||||
}
|
||||
canvas.clear_clip();
|
||||
canvas.clear();
|
||||
|
||||
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
|
||||
let mut ctx = slot_draw_ctx::<Msg>( &mut slot.scroll_canvases, &mut slot.viewport_canvases );
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
canvas.present();
|
||||
if let Some( t ) = trace { t.end( egl_ctx.gl(), "subsurface", pw, ph ); }
|
||||
slot.scroll_canvases = ctx.scroll_canvases;
|
||||
slot.viewport_canvases = ctx.viewport_canvases;
|
||||
|
||||
let wl = &slot.surface;
|
||||
wl.set_buffer_scale( scale as i32 );
|
||||
// Empty input region: pointer/touch fall through to the parent.
|
||||
apply_input_region( wl, compositor, Some( &[] ), scale );
|
||||
// Implicitly commits the wl_surface with the freshly rendered buffer.
|
||||
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
|
||||
}
|
||||
|
||||
#[ allow( clippy::too_many_arguments ) ]
|
||||
fn render_slot_software<Msg: Clone>(
|
||||
slot: &mut SubsurfaceSlot,
|
||||
shm: &Shm,
|
||||
compositor: &CompositorState,
|
||||
view: &Element<Msg>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
shm_format: wl_shm::Format,
|
||||
swap_rb: bool,
|
||||
size_changed: bool,
|
||||
)
|
||||
{
|
||||
if slot.pool.is_none() || size_changed
|
||||
{
|
||||
match SlotPool::new( ( pw * ph * 4 ) as usize, shm )
|
||||
{
|
||||
Ok( p ) => slot.pool = Some( p ),
|
||||
Err( _ ) => return,
|
||||
}
|
||||
}
|
||||
let pool = slot.pool.as_mut().unwrap();
|
||||
let stride = pw * 4;
|
||||
let ( buffer, canvas_buf ) = match pool.create_buffer(
|
||||
pw as i32, ph as i32, stride as i32, shm_format,
|
||||
)
|
||||
{
|
||||
Ok( r ) => r,
|
||||
Err( _ ) => return,
|
||||
};
|
||||
|
||||
let canvas = slot.canvas.get_or_insert_with( || {
|
||||
let mut c = Canvas::new( pw, ph );
|
||||
c.set_dpi_scale( scale as f32 );
|
||||
if let Some( reg ) = crate::theme::build_font_registry()
|
||||
{
|
||||
c.set_font_registry( Arc::new( reg ) );
|
||||
}
|
||||
c
|
||||
} );
|
||||
if canvas.size() != ( pw, ph )
|
||||
{
|
||||
canvas.resize( pw, ph );
|
||||
canvas.set_dpi_scale( scale as f32 );
|
||||
}
|
||||
canvas.clear_clip();
|
||||
canvas.clear();
|
||||
|
||||
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
|
||||
let mut ctx = slot_draw_ctx::<Msg>( &mut slot.scroll_canvases, &mut slot.viewport_canvases );
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
slot.scroll_canvases = ctx.scroll_canvases;
|
||||
slot.viewport_canvases = ctx.viewport_canvases;
|
||||
|
||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||
|
||||
let wl = &slot.surface;
|
||||
if buffer.attach_to( wl ).is_err() { return; }
|
||||
wl.set_buffer_scale( scale as i32 );
|
||||
wl.damage_buffer( 0, 0, pw as i32, ph as i32 );
|
||||
// Empty input region: pointer/touch fall through to the parent.
|
||||
apply_input_region( wl, compositor, Some( &[] ), scale );
|
||||
wl.commit();
|
||||
}
|
||||
+29
-258
@@ -1,30 +1,11 @@
|
||||
// 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 smithay_client_toolkit::session_lock::SessionLockSurface;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::app_data::AppData;
|
||||
use crate::app::{ OverlayId, SurfaceTarget };
|
||||
use crate::egl_context::{ EglContext, EglSurface };
|
||||
use crate::input::GestureState;
|
||||
use crate::platform::NativeSurface;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::{ Point, Rect, WidgetId };
|
||||
use crate::widget::LaidOutWidget;
|
||||
@@ -54,113 +35,6 @@ impl SurfaceFocus
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ),
|
||||
/// `ext-session-lock-v1` lock surface, created in `SessionLockHandler::locked`.
|
||||
Lock( SessionLockSurface ),
|
||||
/// Session lock requested; waiting for the compositor's `locked` event
|
||||
/// before the lock surface exists.
|
||||
PendingLock,
|
||||
}
|
||||
|
||||
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(),
|
||||
SurfaceKind::Lock( s ) => s.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( .. ) | SurfaceKind::PendingLock => 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::Lock( s ) => Some( s.wl_surface() ),
|
||||
SurfaceKind::Pending( .. ) | SurfaceKind::PendingLock => 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of per-surface state the draw pass owns and threads through
|
||||
/// [`crate::draw::DrawCtx`]: the laid-out widget rects it produces, the scroll /
|
||||
/// text-edit / a11y state carried across frames, and the previous frame's
|
||||
@@ -230,19 +104,25 @@ impl<Msg: Clone> FrameState<Msg>
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical pixels along each edge of a surface that the system covers: status and navigation bars, a keyboard.
|
||||
#[ derive( Clone, Copy, Debug, Default, PartialEq ) ]
|
||||
pub( crate ) struct Insets
|
||||
{
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
/// 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( crate ) struct SurfaceState<Msg: Clone, S = NativeSurface>
|
||||
{
|
||||
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,
|
||||
/// The platform's half: on Wayland the role object, the SHM pool or EGL window surface, and what the compositor was last told.
|
||||
pub platform: S,
|
||||
pub canvas: Option<Canvas>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
@@ -288,9 +168,8 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
||||
pub touch_slots: HashMap<i32, crate::types::Point>,
|
||||
/// Height of the client-side title bar (0 for layer-shell surfaces).
|
||||
pub titlebar_height: f32,
|
||||
/// Title bar height before decoration negotiation; restored when the
|
||||
/// compositor hands decorations back to the client.
|
||||
pub titlebar_base: f32,
|
||||
/// Where the view is not laid out because the system covers it; the background still fills it.
|
||||
pub insets: Insets,
|
||||
/// 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).
|
||||
@@ -298,23 +177,7 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
||||
/// 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 ),
|
||||
/// Logical exclusive zone last sent with `set_exclusive_zone`. Tracked
|
||||
/// alongside the size because a zone that stays at its creation-time
|
||||
/// value while the surface keeps resizing leaves the compositor
|
||||
/// reserving a band that no longer matches what the overlay paints.
|
||||
pub last_requested_zone: i32,
|
||||
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
|
||||
@@ -326,18 +189,14 @@ pub( crate ) struct SurfaceState<Msg: Clone>
|
||||
/// subsurfaces only; the subsurface reconcile keeps the cadence
|
||||
/// with a bare frame request + commit when nothing moved.
|
||||
pub frame_tick_due: bool,
|
||||
/// Opaque rects (physical) last applied to the surface.
|
||||
pub opaque_full: Option<Vec<Rect>>,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> SurfaceState<Msg>
|
||||
impl<Msg: Clone, S> SurfaceState<Msg, S>
|
||||
{
|
||||
pub( crate ) fn new( surface: SurfaceKind, titlebar_height: f32, titlebar_title: String ) -> Self
|
||||
pub( crate ) fn new( platform: S, titlebar_height: f32, titlebar_title: String ) -> Self
|
||||
{
|
||||
Self {
|
||||
pool: None,
|
||||
egl_surface: None,
|
||||
surface,
|
||||
platform,
|
||||
canvas: None,
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -356,18 +215,13 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
osk: None,
|
||||
gesture: GestureState::new(),
|
||||
titlebar_height,
|
||||
titlebar_base: titlebar_height,
|
||||
insets: Insets::default(),
|
||||
titlebar_title,
|
||||
titlebar_close_rect: Rect::default(),
|
||||
scale_factor: 1,
|
||||
last_requested_size: ( 0, 0 ),
|
||||
last_requested_zone: 0,
|
||||
layer_anchor: None,
|
||||
last_popup_anchor: None,
|
||||
popup_reposition_token: 0,
|
||||
frame_pending: false,
|
||||
frame_tick_due: false,
|
||||
opaque_full: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,98 +247,6 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
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,
|
||||
)
|
||||
{
|
||||
let scale = self.scale_factor.max( 1 ) as u32;
|
||||
let pw = w.saturating_mul( scale );
|
||||
let ph = h.saturating_mul( scale );
|
||||
let pool_bytes = (pw as u64)
|
||||
.saturating_mul( ph as u64 )
|
||||
.saturating_mul( 4 );
|
||||
|
||||
const MAX_POOL: u64 = 256 * 1024 * 1024;
|
||||
if pool_bytes > MAX_POOL || pw == 0 || ph == 0
|
||||
{
|
||||
eprintln!( "[ltk] on_configure: size {w}×{h} (physical {pw}×{ph}) out of range — ignoring" );
|
||||
return;
|
||||
}
|
||||
let pool_bytes = pool_bytes as usize;
|
||||
|
||||
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.
|
||||
match SlotPool::new( pool_bytes, shm )
|
||||
{
|
||||
Ok( pool ) => self.pool = Some( pool ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "[ltk] SlotPool resize failed: {e} — keeping previous allocation" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
} 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
|
||||
{
|
||||
match SlotPool::new( pool_bytes, shm )
|
||||
{
|
||||
Ok( pool ) => self.pool = Some( pool ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "[ltk] SlotPool init failed: {e}" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
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
|
||||
@@ -497,4 +259,13 @@ impl<Msg: Clone> SurfaceState<Msg>
|
||||
self.needs_redraw = true;
|
||||
self.content_dirty = true;
|
||||
}
|
||||
|
||||
/// Take `w × h` logical pixels as the surface's size once the backend has resized its buffers, and redraw it whole.
|
||||
pub( crate ) fn set_size( &mut self, w: u32, h: u32 )
|
||||
{
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
self.configured = true;
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The runtime driven on [`MockPlatform`]: input through the backend-neutral entry points, a step of the run loop, and what reached the platform.
|
||||
|
||||
use std::sync::MutexGuard;
|
||||
|
||||
use calloop::EventLoop;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::input::event::{ KeyInput, PointerButton, PointerInput, PointerKind };
|
||||
use crate::osk::Purpose;
|
||||
use crate::platform::mock::{ MockPlatform, Request };
|
||||
use crate::types::Rect;
|
||||
use crate::widget::WidgetHandlers;
|
||||
use crate::{ button, column, text_edit, Element, Keysym };
|
||||
|
||||
use super::{ AppData, SurfaceFocus, SurfaceState };
|
||||
use super::surface::Insets;
|
||||
|
||||
const WIDTH: u32 = 400;
|
||||
const HEIGHT: u32 = 300;
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum Msg
|
||||
{
|
||||
Pressed,
|
||||
Typed( String ),
|
||||
}
|
||||
|
||||
#[ derive( Default ) ]
|
||||
struct TestApp
|
||||
{
|
||||
value: String,
|
||||
secure: bool,
|
||||
purpose: Purpose,
|
||||
presses: u32,
|
||||
}
|
||||
|
||||
impl App for TestApp
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.liberux.ltk.Test" }
|
||||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column()
|
||||
.push( button( "OK" ).on_press( Msg::Pressed ) )
|
||||
.push( text_edit( "", self.value.clone() ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn update( &mut self, msg: Msg )
|
||||
{
|
||||
match msg
|
||||
{
|
||||
Msg::Pressed => self.presses += 1,
|
||||
Msg::Typed( text ) => self.value = text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Field order matters: `data` holds a handle into `event_loop` and drops first.
|
||||
struct Harness
|
||||
{
|
||||
data: AppData<TestApp, MockPlatform>,
|
||||
_event_loop: EventLoop<'static, AppData<TestApp, MockPlatform>>,
|
||||
_globals: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl Harness
|
||||
{
|
||||
/// `app` on a configured `WIDTH × HEIGHT` main surface at scale 1, not drawn yet.
|
||||
fn new( app: TestApp ) -> Self
|
||||
{
|
||||
let globals = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||
let event_loop: EventLoop<'static, AppData<TestApp, MockPlatform>> = EventLoop::try_new().expect( "event loop" );
|
||||
let main = SurfaceState::new( (), 0.0, String::new() );
|
||||
let mut data = AppData::new( app, MockPlatform::default(), main, event_loop.handle(), false, None );
|
||||
data.main.set_size( WIDTH, HEIGHT );
|
||||
data.on_main_resized( WIDTH, HEIGHT );
|
||||
Self { data, _event_loop: event_loop, _globals: globals }
|
||||
}
|
||||
|
||||
/// One iteration of the run loop's backend-neutral part. Returns what reached the platform since the last step.
|
||||
fn step( &mut self ) -> Vec<Request>
|
||||
{
|
||||
self.data.process_messages();
|
||||
self.data.draw_due();
|
||||
self.data.platform.take_requests()
|
||||
}
|
||||
|
||||
/// What the backend does when the system says the last frame is on screen.
|
||||
fn frame_done( &mut self )
|
||||
{
|
||||
self.data.main.frame_pending = false;
|
||||
}
|
||||
|
||||
fn rect_of( &self, pick: impl Fn( &WidgetHandlers<Msg> ) -> bool ) -> Rect
|
||||
{
|
||||
self.data.main.frame.widget_rects.iter()
|
||||
.find( |w| pick( &w.handlers ) )
|
||||
.map( |w| w.rect )
|
||||
.expect( "widget laid out" )
|
||||
}
|
||||
|
||||
fn button_rect( &self ) -> Rect
|
||||
{
|
||||
self.rect_of( |h| matches!( h, WidgetHandlers::Button { .. } ) )
|
||||
}
|
||||
|
||||
fn field_rect( &self ) -> Rect
|
||||
{
|
||||
self.rect_of( |h| h.is_text_input() )
|
||||
}
|
||||
|
||||
fn pointer( &mut self, kind: PointerKind, r: Rect )
|
||||
{
|
||||
let position = ( ( r.x + r.width / 2.0 ) as f64, ( r.y + r.height / 2.0 ) as f64 );
|
||||
self.data.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } );
|
||||
}
|
||||
|
||||
fn hover( &mut self, r: Rect )
|
||||
{
|
||||
self.pointer( PointerKind::Enter, r );
|
||||
self.pointer( PointerKind::Motion, r );
|
||||
}
|
||||
|
||||
fn click( &mut self, r: Rect )
|
||||
{
|
||||
self.hover( r );
|
||||
self.pointer( PointerKind::Press( PointerButton::Left ), r );
|
||||
self.pointer( PointerKind::Release( PointerButton::Left ), r );
|
||||
}
|
||||
|
||||
fn key( &mut self, keysym: Keysym, text: &str )
|
||||
{
|
||||
let key = KeyInput::synthetic( keysym, Some( text.to_string() ) );
|
||||
self.data.key_press( key.clone() );
|
||||
self.data.key_release( &key );
|
||||
}
|
||||
}
|
||||
|
||||
/// The damage of every presentation among `requests`.
|
||||
fn presented( requests: &[Request] ) -> Vec<Vec<Rect>>
|
||||
{
|
||||
requests.iter().filter_map( |r| match r
|
||||
{
|
||||
Request::Present { damage, .. } => Some( damage.clone() ),
|
||||
_ => None,
|
||||
} ).collect()
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn the_first_frame_is_presented_whole_and_asks_for_the_next()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
let requests = h.step();
|
||||
assert!( requests.contains( &Request::Present { frame: Some( SurfaceFocus::Main ), damage: Vec::new(), size: ( WIDTH, HEIGHT ) } ) );
|
||||
assert!( h.data.main.frame_pending );
|
||||
assert!( !h.data.main.content_dirty );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn nothing_is_drawn_while_a_frame_is_pending_or_when_nothing_changed()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
h.step();
|
||||
let button = h.button_rect();
|
||||
h.hover( button );
|
||||
assert!( presented( &h.step() ).is_empty() );
|
||||
h.frame_done();
|
||||
assert_eq!( presented( &h.step() ).len(), 1 );
|
||||
h.frame_done();
|
||||
assert!( presented( &h.step() ).is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_click_on_a_button_reaches_update()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
h.step();
|
||||
let button = h.button_rect();
|
||||
h.click( button );
|
||||
h.step();
|
||||
assert_eq!( h.data.app.presses, 1 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn hovering_a_widget_presents_only_its_rect()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
h.step();
|
||||
h.frame_done();
|
||||
let button = h.button_rect();
|
||||
h.hover( button );
|
||||
let damage = presented( &h.step() );
|
||||
assert_eq!( damage.len(), 1 );
|
||||
let rects = &damage[ 0 ];
|
||||
assert!( !rects.is_empty(), "a hover should repaint part of the surface, not all of it" );
|
||||
let area: f32 = rects.iter().map( |r| r.width * r.height ).sum();
|
||||
assert!( area < ( WIDTH * HEIGHT ) as f32 / 2.0 );
|
||||
assert!( rects.iter().all( |r| r.x >= 0.0 && r.y >= 0.0 && r.x + r.width <= WIDTH as f32 && r.y + r.height <= HEIGHT as f32 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_failed_presentation_leaves_the_next_frame_to_repaint_whole()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
h.data.platform.fail_present = true;
|
||||
assert!( presented( &h.step() ).is_empty() );
|
||||
assert!( !h.data.main.frame_pending );
|
||||
assert!( h.data.main.content_dirty );
|
||||
|
||||
h.data.platform.fail_present = false;
|
||||
let button = h.button_rect();
|
||||
h.hover( button );
|
||||
assert_eq!( presented( &h.step() ), vec![ Vec::new() ] );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_secure_field_asks_the_input_method_for_a_password()
|
||||
{
|
||||
let mut h = Harness::new( TestApp { secure: true, ..TestApp::default() } );
|
||||
h.step();
|
||||
let field = h.field_rect();
|
||||
h.click( field );
|
||||
assert!( h.step().contains( &Request::TextInput { secure: true, purpose: Purpose::Normal } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_field_hands_its_purpose_to_the_input_method()
|
||||
{
|
||||
let mut h = Harness::new( TestApp { purpose: Purpose::Terminal, ..TestApp::default() } );
|
||||
h.step();
|
||||
let field = h.field_rect();
|
||||
h.click( field );
|
||||
assert!( h.step().contains( &Request::TextInput { secure: false, purpose: Purpose::Terminal } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn the_view_is_laid_out_inside_the_insets()
|
||||
{
|
||||
let mut h = Harness::new( TestApp::default() );
|
||||
h.data.main.insets = Insets { top: 40.0, right: 0.0, bottom: 30.0, left: 10.0 };
|
||||
h.step();
|
||||
let button = h.button_rect();
|
||||
assert!( button.y >= 40.0 && button.x >= 10.0, "button at {button:?}" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn copying_a_selection_publishes_the_clipboard()
|
||||
{
|
||||
let mut h = Harness::new( TestApp { value: "hola".to_string(), ..TestApp::default() } );
|
||||
h.step();
|
||||
let field = h.field_rect();
|
||||
h.click( field );
|
||||
h.data.modifiers_changed( true, false, false );
|
||||
h.key( Keysym::a, "a" );
|
||||
h.key( Keysym::c, "c" );
|
||||
assert!( h.step().contains( &Request::Clipboard( "hola".to_string() ) ) );
|
||||
assert_eq!( h.data.clipboard, "hola" );
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
use crate::app::App;
|
||||
use crate::event_loop::app_data::AppData;
|
||||
use crate::event_loop::surface::SurfaceFocus;
|
||||
use crate::platform::Platform;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// Read the current text value of the focused widget — preferring
|
||||
/// the pending typed-but-not-yet-applied value when present, falling
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
// 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::platform::Platform;
|
||||
use crate::tree::find_widget;
|
||||
use crate::types::{ Length, Rect };
|
||||
use crate::widget::WidgetHandlers;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
pub( crate ) fn activate_text_input( &mut self, qh: &QueueHandle<Self>, secure: bool, purpose: crate::osk::Purpose )
|
||||
pub( crate ) fn activate_text_input( &mut self, secure: bool, purpose: crate::osk::Purpose )
|
||||
{
|
||||
self.text_input_secure = secure;
|
||||
self.text_input_purpose = purpose;
|
||||
let ( hint, purpose ) = content_type( secure, purpose );
|
||||
match ( &self.text_input_manager, &self.text_input )
|
||||
{
|
||||
( Some( manager ), None ) =>
|
||||
{
|
||||
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( hint, purpose );
|
||||
ti.commit();
|
||||
self.text_input = Some( ti );
|
||||
} else {
|
||||
eprintln!( "ltk: activate_text_input: no seat available" );
|
||||
}
|
||||
}
|
||||
( None, _ ) =>
|
||||
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
|
||||
// A press on another text field, or on the focused one again: renew
|
||||
// the enable so a compositor that folded its keyboard raises it.
|
||||
( Some( _ ), Some( ti ) ) =>
|
||||
{
|
||||
ti.enable();
|
||||
ti.set_content_type( hint, purpose );
|
||||
ti.commit();
|
||||
}
|
||||
}
|
||||
self.platform.activate_text_input( secure, purpose );
|
||||
}
|
||||
|
||||
pub( crate ) fn deactivate_text_input( &mut self )
|
||||
{
|
||||
if let Some( ti ) = self.text_input.take()
|
||||
{
|
||||
ti.disable();
|
||||
ti.commit();
|
||||
ti.destroy();
|
||||
}
|
||||
self.platform.deactivate_text_input();
|
||||
}
|
||||
|
||||
pub( crate ) fn reenable_text_input( &self )
|
||||
{
|
||||
self.platform.reenable_text_input( self.text_input_secure, self.text_input_purpose );
|
||||
}
|
||||
|
||||
/// Follow [`App::external_text_input`] while no ltk text widget holds the focus.
|
||||
@@ -68,11 +39,10 @@ impl<A: App> AppData<A>
|
||||
let wanted = self.app.external_text_input();
|
||||
match ( wanted, self.external_text_input )
|
||||
{
|
||||
( Some( _ ), _ ) if self.text_input.is_none() =>
|
||||
( Some( _ ), _ ) if !self.platform.text_input_active() =>
|
||||
{
|
||||
let qh = self.qh.clone();
|
||||
let purpose = self.app.external_text_purpose();
|
||||
self.activate_text_input( &qh, false, purpose );
|
||||
self.activate_text_input( false, purpose );
|
||||
}
|
||||
( Some( new ), Some( old ) ) if new != old =>
|
||||
{
|
||||
@@ -82,18 +52,15 @@ impl<A: App> AppData<A>
|
||||
( None, Some( _ ) ) => self.deactivate_text_input(),
|
||||
_ => {}
|
||||
}
|
||||
self.external_text_input = wanted;
|
||||
}
|
||||
|
||||
pub( crate ) fn reenable_text_input( &self )
|
||||
{
|
||||
if let Some( ti ) = &self.text_input
|
||||
// The built-in keyboard follows the request itself, not the input method: shown when it starts or renews, hidden when it ends.
|
||||
let focus = self.keyboard_focus;
|
||||
match ( wanted, self.external_text_input )
|
||||
{
|
||||
let ( hint, purpose ) = content_type( self.text_input_secure, self.text_input_purpose );
|
||||
ti.enable();
|
||||
ti.set_content_type( hint, purpose );
|
||||
ti.commit();
|
||||
( Some( new ), old ) if old != Some( new ) => self.show_osk_for( focus, self.app.external_text_purpose() ),
|
||||
( None, Some( _ ) ) => self.hide_osk( focus ),
|
||||
_ => {}
|
||||
}
|
||||
self.external_text_input = wanted;
|
||||
}
|
||||
|
||||
/// Snapshot a focused-widget's geometry needed by the pointer
|
||||
@@ -133,23 +100,3 @@ impl<A: App> AppData<A>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a field's `secure` flag to the text-input-v3 content type. Secure
|
||||
/// fields are flagged `Password` with `SensitiveData | HiddenText` so the
|
||||
/// IME/OSK skips prediction, autocorrect and storing the value.
|
||||
fn content_type( secure: bool, purpose: crate::osk::Purpose ) -> ( zwp_text_input_v3::ContentHint, zwp_text_input_v3::ContentPurpose )
|
||||
{
|
||||
if secure
|
||||
{
|
||||
return (
|
||||
zwp_text_input_v3::ContentHint::SensitiveData | zwp_text_input_v3::ContentHint::HiddenText,
|
||||
zwp_text_input_v3::ContentPurpose::Password,
|
||||
);
|
||||
}
|
||||
let purpose = match purpose
|
||||
{
|
||||
crate::osk::Purpose::Normal => zwp_text_input_v3::ContentPurpose::Normal,
|
||||
crate::osk::Purpose::Terminal => zwp_text_input_v3::ContentPurpose::Terminal,
|
||||
};
|
||||
( zwp_text_input_v3::ContentHint::None, purpose )
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::event_loop::app_data::AppData;
|
||||
use crate::event_loop::surface::SurfaceFocus;
|
||||
use crate::tree::find_handlers;
|
||||
use crate::widget::WidgetHandlers;
|
||||
use crate::platform::Platform;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
pub( crate ) fn handle_text_insert( &mut self, focus: SurfaceFocus, text: &str )
|
||||
{
|
||||
|
||||
@@ -7,8 +7,9 @@ use crate::event_loop::surface::SurfaceFocus;
|
||||
use crate::tree::find_widget_at;
|
||||
use crate::types::Point;
|
||||
use crate::widget::text_edit::scroll::{ TextScroll, Window, WHEEL_LINES };
|
||||
use crate::platform::Platform;
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
pub( crate ) fn text_scroll( &self, focus: SurfaceFocus, idx: usize ) -> TextScroll
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::event_loop::surface::SurfaceFocus;
|
||||
use crate::tree::find_handlers;
|
||||
use crate::types::Point;
|
||||
use crate::widget::WidgetHandlers;
|
||||
use crate::platform::Platform;
|
||||
|
||||
/// Compute the byte range of the "word" surrounding `byte_offset` in
|
||||
/// `value`. A word is a maximal run of alphanumeric / underscore
|
||||
@@ -23,12 +24,9 @@ fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize )
|
||||
|
||||
// Walk left from `bo` until we cross a non-word boundary.
|
||||
let mut start = bo;
|
||||
for ( i, c ) in value[..bo].char_indices().rev()
|
||||
{
|
||||
let mut iter = value[..bo].char_indices().rev();
|
||||
while let Some( ( i, c ) ) = iter.next()
|
||||
{
|
||||
if is_word( c ) { start = i; } else { break; }
|
||||
}
|
||||
if is_word( c ) { start = i; } else { break; }
|
||||
}
|
||||
|
||||
// Walk right from `bo` until the same.
|
||||
@@ -41,7 +39,7 @@ fn word_bounds_at( value: &str, byte_offset: usize ) -> ( usize, usize )
|
||||
( start, end )
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
/// `true` when this press should be treated as the second click
|
||||
/// of a double-click pair. Looks at the timestamp + position of
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use super::app_data::AppData;
|
||||
use super::surface::{ SurfaceFocus, SurfaceKind };
|
||||
use super::surface::SurfaceFocus;
|
||||
use crate::app::App;
|
||||
use crate::types::{ Length, Rect };
|
||||
use crate::platform::Platform;
|
||||
|
||||
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
|
||||
|
||||
@@ -26,7 +27,7 @@ pub struct TooltipVisible
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
impl<A: App, P: Platform> AppData<A, P>
|
||||
{
|
||||
pub( crate ) fn arm_tooltip( &mut self, focus: SurfaceFocus, flat_idx: usize )
|
||||
{
|
||||
@@ -90,7 +91,7 @@ impl<A: App> AppData<A>
|
||||
pub( crate ) fn tooltip_popup( &self ) -> Option<( Rect, ( i32, i32 ) )>
|
||||
{
|
||||
let v = self.tooltip_visible.as_ref()?;
|
||||
if v.focus != SurfaceFocus::Main || !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; }
|
||||
if v.focus != SurfaceFocus::Main || !self.main_is_toplevel() { return None; }
|
||||
let ( w, h ) = tooltip_size( &v.text );
|
||||
Some( ( v.anchor, ( w.ceil() as i32, h.ceil() as i32 ) ) )
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Keeps [`App::window_size_hint`](crate::App::window_size_hint) inside the screen.
|
||||
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput;
|
||||
use smithay_client_toolkit::shell::xdg::window::Window;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
use super::AppData;
|
||||
|
||||
/// Share of an output a window may take when the compositor sent no `configure_bounds`.
|
||||
pub( crate ) const OUTPUT_SHARE: f32 = 0.9;
|
||||
|
||||
/// The part of an output of logical size `output` a window may take.
|
||||
pub( crate ) fn output_share( output: ( u32, u32 ) ) -> ( u32, u32 )
|
||||
{
|
||||
( ( output.0 as f32 * OUTPUT_SHARE ) as u32, ( output.1 as f32 * OUTPUT_SHARE ) as u32 )
|
||||
}
|
||||
|
||||
/// `size` scaled down, keeping its aspect ratio, until it fits in `bounds`.
|
||||
pub( crate ) fn fit_within( size: ( u32, u32 ), bounds: ( u32, u32 ) ) -> ( u32, u32 )
|
||||
{
|
||||
let ( w, h ) = size;
|
||||
let ( bw, bh ) = bounds;
|
||||
if w == 0 || h == 0 || bw == 0 || bh == 0 { return size; }
|
||||
if w <= bw && h <= bh { return size; }
|
||||
let scale = ( bw as f32 / w as f32 ).min( bh as f32 / h as f32 );
|
||||
( ( ( w as f32 * scale ) as u32 ).max( 1 ), ( ( h as f32 * scale ) as u32 ).max( 1 ) )
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
/// On the first configure, shrink a size hint larger than the screen. Returns whether it did.
|
||||
pub( crate ) fn fit_size_hint( &mut self, window: &Window, suggested: Option<( u32, u32 )> ) -> bool
|
||||
{
|
||||
if !std::mem::take( &mut self.pending_size_hint_fit ) { return false; }
|
||||
let Some( hint ) = self.app.window_size_hint() else { return false };
|
||||
let bounds = suggested.filter( |b| b.0 > 0 && b.1 > 0 ).or_else( || self.output_bounds() );
|
||||
let Some( bounds ) = bounds else { return false };
|
||||
let fitted = fit_within( hint, bounds );
|
||||
if fitted == hint { return false; }
|
||||
window.set_min_size( Some( fitted ) );
|
||||
if !self.app.window_resizable() { window.set_max_size( Some( fitted ) ); }
|
||||
self.fitted_size_hint = Some( fitted );
|
||||
true
|
||||
}
|
||||
|
||||
fn output_bounds( &self ) -> Option<( u32, u32 )>
|
||||
{
|
||||
self.output_logical_size( None ).map( output_share )
|
||||
}
|
||||
|
||||
fn output_logical_size( &self, preferred: Option<&WlOutput> ) -> Option<( u32, u32 )>
|
||||
{
|
||||
let info = preferred
|
||||
.or( self.main_output.as_ref() )
|
||||
.and_then( |o| self.output_state.info( o ) )
|
||||
.or_else( || self.output_state.outputs().find_map( |o| self.output_state.info( &o ) ) )?;
|
||||
let ( w, h ) = info.logical_size.or_else( ||
|
||||
{
|
||||
let mode = info.modes.iter().find( |m| m.current )?;
|
||||
let scale = info.scale_factor.max( 1 );
|
||||
Some( ( mode.dimensions.0 / scale, mode.dimensions.1 / scale ) )
|
||||
} )?;
|
||||
( w > 0 && h > 0 ).then_some( ( w as u32, h as u32 ) )
|
||||
}
|
||||
|
||||
pub( crate ) fn publish_output_size( &self, preferred: Option<&WlOutput> )
|
||||
{
|
||||
let ( w, h ) = self.output_logical_size( preferred ).unwrap_or( ( 0, 0 ) );
|
||||
crate::types::set_output_logical_size( w, h );
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn a_size_that_fits_is_kept()
|
||||
{
|
||||
assert_eq!( fit_within( ( 800, 600 ), ( 1920, 1080 ) ), ( 800, 600 ) );
|
||||
assert_eq!( fit_within( ( 1920, 1080 ), ( 1920, 1080 ) ), ( 1920, 1080 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_tall_window_shrinks_to_the_height_and_keeps_its_shape()
|
||||
{
|
||||
assert_eq!( fit_within( ( 720, 1440 ), ( 1920, 1080 ) ), ( 540, 1080 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_wide_window_shrinks_to_the_width()
|
||||
{
|
||||
assert_eq!( fit_within( ( 1600, 900 ), ( 800, 1280 ) ), ( 800, 450 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn degenerate_input_is_left_alone()
|
||||
{
|
||||
assert_eq!( fit_within( ( 720, 1440 ), ( 0, 1080 ) ), ( 720, 1440 ) );
|
||||
assert_eq!( fit_within( ( 0, 1440 ), ( 1920, 1080 ) ), ( 0, 1440 ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user