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,58 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::CursorShape;
|
||||
|
||||
use super::WaylandPlatform;
|
||||
|
||||
pub( super ) fn set_shape<A: App>( platform: &WaylandPlatform<A>, shape: CursorShape ) -> bool
|
||||
{
|
||||
let Some( device ) = platform.cursor_shape_device.as_ref() else { return false };
|
||||
device.set_shape( platform.last_pointer_enter_serial, wp_shape( shape ) );
|
||||
true
|
||||
}
|
||||
|
||||
fn wp_shape( shape: CursorShape ) -> Shape
|
||||
{
|
||||
use CursorShape as C;
|
||||
match shape
|
||||
{
|
||||
C::Default => Shape::Default,
|
||||
C::ContextMenu => Shape::ContextMenu,
|
||||
C::Help => Shape::Help,
|
||||
C::Pointer => Shape::Pointer,
|
||||
C::Progress => Shape::Progress,
|
||||
C::Wait => Shape::Wait,
|
||||
C::Cell => Shape::Cell,
|
||||
C::Crosshair => Shape::Crosshair,
|
||||
C::Text => Shape::Text,
|
||||
C::VerticalText => Shape::VerticalText,
|
||||
C::Alias => Shape::Alias,
|
||||
C::Copy => Shape::Copy,
|
||||
C::Move => Shape::Move,
|
||||
C::NoDrop => Shape::NoDrop,
|
||||
C::NotAllowed => Shape::NotAllowed,
|
||||
C::Grab => Shape::Grab,
|
||||
C::Grabbing => Shape::Grabbing,
|
||||
C::EResize => Shape::EResize,
|
||||
C::NResize => Shape::NResize,
|
||||
C::NeResize => Shape::NeResize,
|
||||
C::NwResize => Shape::NwResize,
|
||||
C::SResize => Shape::SResize,
|
||||
C::SeResize => Shape::SeResize,
|
||||
C::SwResize => Shape::SwResize,
|
||||
C::WResize => Shape::WResize,
|
||||
C::EwResize => Shape::EwResize,
|
||||
C::NsResize => Shape::NsResize,
|
||||
C::NeswResize => Shape::NeswResize,
|
||||
C::NwseResize => Shape::NwseResize,
|
||||
C::ColResize => Shape::ColResize,
|
||||
C::RowResize => Shape::RowResize,
|
||||
C::AllScroll => Shape::AllScroll,
|
||||
C::ZoomIn => Shape::ZoomIn,
|
||||
C::ZoomOut => Shape::ZoomOut,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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, [`publish_clipboard`] 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 crate::event_loop::AppData;
|
||||
|
||||
use super::WaylandPlatform;
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Offer the clipboard as the seat's selection so other applications can paste it; `AppData::clipboard` holds its text. No-op when the compositor does not advertise `wl_data_device_manager` or when no seat has appeared yet.
|
||||
pub( crate ) fn publish_clipboard<A: App>( platform: &mut WaylandPlatform<A> )
|
||||
{
|
||||
let ( Some( ddm ), Some( dd ) ) = ( platform.data_device_manager.as_ref(), platform.data_device.as_ref() ) else { return };
|
||||
let source = ddm.create_copy_paste_source( &platform.qh, CLIPBOARD_MIMES.iter().copied() );
|
||||
source.set_selection( dd, platform.last_input_serial );
|
||||
// The compositor keeps the selection only while the source lives, and `DataSourceHandler::send` serves pastes through it.
|
||||
platform.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.platform.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.platform.drop_position = Some( ( x, y ) );
|
||||
self.platform.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.platform.drop_position.is_some() { self.app.on_drop_leave(); }
|
||||
self.platform.drop_position = None;
|
||||
self.platform.drop_mime = None;
|
||||
}
|
||||
|
||||
fn motion( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _d: &WlDataDevice, x: f64, y: f64 )
|
||||
{
|
||||
self.platform.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.platform.drop_mime.clone() else { offer.finish(); return };
|
||||
let ( x, y ) = self.platform.drop_position.unwrap_or( ( 0.0, 0.0 ) );
|
||||
let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return };
|
||||
offer.finish();
|
||||
let tx = self.platform.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.platform.drop_position = None;
|
||||
self.platform.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.platform.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.platform.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.platform.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false )
|
||||
{
|
||||
self.platform.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() )
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
// 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 crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
|
||||
|
||||
use super::{ SurfaceKind, WaylandSurfaceState };
|
||||
|
||||
/// 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 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.platform.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.platform.pool.is_some() {
|
||||
// Before the first configure neither path is chosen yet; creating a
|
||||
// pool here would lock the surface to SHM.
|
||||
ss.platform.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.platform.shm;
|
||||
if let Some( ( _, ss ) ) = self.secondaries.iter_mut()
|
||||
.find( |( _, ss )| ss.platform.surface.try_wl_surface() == Some( surface ) )
|
||||
{
|
||||
apply_surface_scale( ss, shm, surface, new_factor );
|
||||
}
|
||||
return;
|
||||
};
|
||||
let ( pw, ph ) = {
|
||||
let shm = &self.platform.shm;
|
||||
let ss = match focus
|
||||
{
|
||||
SurfaceFocus::Main => &mut self.main,
|
||||
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, 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.platform.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.platform.surface.try_wl_surface() != Some( layer.wl_surface() ) );
|
||||
return;
|
||||
}
|
||||
match self.focus_for_surface( layer.wl_surface() )
|
||||
{
|
||||
Some( SurfaceFocus::Main ) | None =>
|
||||
{
|
||||
if self.app.on_close_requested()
|
||||
{
|
||||
self.exit_requested = true;
|
||||
}
|
||||
}
|
||||
Some( 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.platform.surface.try_wl_surface() == Some( layer.wl_surface() )
|
||||
{
|
||||
ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h );
|
||||
return;
|
||||
}
|
||||
}
|
||||
match self.focus_for_surface( layer.wl_surface() )
|
||||
{
|
||||
Some( SurfaceFocus::Main ) | None =>
|
||||
{
|
||||
self.on_configure( w, h );
|
||||
}
|
||||
Some( SurfaceFocus::Overlay( id ) ) =>
|
||||
{
|
||||
if let Some( ss ) = self.overlays.get_mut( &id )
|
||||
{
|
||||
ss.on_configure( &self.platform.shm, self.platform.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( 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( 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.platform.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.platform.shm, self.platform.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.platform.pending_fullscreen
|
||||
{
|
||||
window.set_fullscreen( None );
|
||||
self.platform.pending_fullscreen = false;
|
||||
}
|
||||
if self.platform.pending_size_hint_unpin
|
||||
{
|
||||
window.set_min_size( None );
|
||||
window.set_max_size( None );
|
||||
self.platform.pending_size_hint_unpin = false;
|
||||
}
|
||||
let tb = if configure.decoration_mode == DecorationMode::Server || configure.is_fullscreen() { 0.0 }
|
||||
else { self.main.platform.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.platform.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.platform.shm
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> OutputHandler for AppData<A>
|
||||
{
|
||||
fn output_state( &mut self ) -> &mut OutputState
|
||||
{
|
||||
&mut self.platform.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.platform.layer_shell
|
||||
{
|
||||
if self.main.platform.surface.materialize( &self.platform.compositor_state, layer_shell, qh, &output )
|
||||
{
|
||||
self.platform.main_output = Some( output.clone() );
|
||||
}
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.platform.surface.materialize( &self.platform.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.platform.main_output.is_some()
|
||||
&& self.platform.main_output.as_ref() != Some( &output )
|
||||
&& !self.secondaries.iter().any( |( o, _ )| o == &output )
|
||||
{
|
||||
use smithay_client_toolkit::shell::wlr_layer;
|
||||
let surface = self.platform.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(),
|
||||
SurfaceState::new( WaylandSurfaceState::from( 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.platform.session_lock
|
||||
{
|
||||
if matches!( self.main.platform.surface, SurfaceKind::Lock( .. ) )
|
||||
&& self.platform.main_output.as_ref() != Some( &output )
|
||||
&& !self.secondaries.iter().any( |( o, _ )| o == &output )
|
||||
{
|
||||
let surface = self.platform.compositor_state.create_surface( qh );
|
||||
let lock_surface = lock.create_lock_surface( surface, &output, qh );
|
||||
self.secondaries.push( (
|
||||
output,
|
||||
SurfaceState::new( WaylandSurfaceState::from( 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.platform.main_output.as_ref() == Some( &output )
|
||||
{
|
||||
self.platform.main_output = None;
|
||||
}
|
||||
self.publish_output_size( None );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> SeatHandler for AppData<A>
|
||||
{
|
||||
fn seat_state( &mut self ) -> &mut SeatState
|
||||
{
|
||||
&mut self.platform.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.platform.data_device.is_none()
|
||||
{
|
||||
if let Some( ref ddm ) = self.platform.data_device_manager
|
||||
{
|
||||
self.platform.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.platform.keyboard.is_none() =>
|
||||
{
|
||||
self.platform.keyboard = Some(
|
||||
self.platform.seat_state
|
||||
.get_keyboard( qh, &seat, None )
|
||||
.expect( "keyboard" ),
|
||||
);
|
||||
}
|
||||
Capability::Pointer if self.platform.pointer.is_none() =>
|
||||
{
|
||||
let pointer = self.platform.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.platform.cursor_shape_manager
|
||||
{
|
||||
self.platform.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) );
|
||||
}
|
||||
self.platform.pointer = Some( pointer );
|
||||
}
|
||||
Capability::Touch if self.platform.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.platform.touch = Some(
|
||||
self.platform.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.platform.keyboard = None; }
|
||||
Capability::Pointer => { self.platform.pointer = None; }
|
||||
Capability::Touch =>
|
||||
{
|
||||
self.platform.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( SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
|
||||
{
|
||||
if let Some( ss ) = self.overlays.get_mut( &id )
|
||||
{
|
||||
ss.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn done(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
popup: &Popup,
|
||||
)
|
||||
{
|
||||
if let Some( 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.platform.output_state.outputs().collect();
|
||||
let mut outputs = outputs.into_iter();
|
||||
if let Some( output ) = outputs.next()
|
||||
{
|
||||
let surface = self.platform.compositor_state.create_surface( qh );
|
||||
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
||||
self.main.platform.surface = SurfaceKind::Lock( lock_surface );
|
||||
self.platform.main_output = Some( output );
|
||||
}
|
||||
for output in outputs
|
||||
{
|
||||
let surface = self.platform.compositor_state.create_surface( qh );
|
||||
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
||||
self.secondaries.push( (
|
||||
output,
|
||||
SurfaceState::new( WaylandSurfaceState::from( SurfaceKind::Lock( lock_surface ) ), 0.0, String::new() ),
|
||||
) );
|
||||
}
|
||||
self.platform.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.platform.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
self.on_configure( w, h );
|
||||
return;
|
||||
}
|
||||
for ( _, ss ) in self.secondaries.iter_mut()
|
||||
{
|
||||
if ss.platform.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
ss.on_configure( &self.platform.shm, self.platform.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( activation ), Some( wl ) ) = ( self.platform.activation_state.as_ref(), self.main.platform.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( wl, token );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> ProvidesRegistryState for AppData<A>
|
||||
{
|
||||
fn registry( &mut self ) -> &mut RegistryState
|
||||
{
|
||||
&mut self.platform.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.platform.foreign_toplevel_list
|
||||
}
|
||||
|
||||
fn new_toplevel(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
handle: ExtForeignToplevelHandleV1,
|
||||
)
|
||||
{
|
||||
let event = toplevel_opened( &self.platform.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.platform.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, SurfaceFocus> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &WlCallback,
|
||||
_event: wl_callback::Event,
|
||||
focus: &SurfaceFocus,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
// The main surface is taken to host subsurfaces, as it always was: the subsurface reconcile re-arms its callback.
|
||||
let hosts_subsurfaces = match *focus
|
||||
{
|
||||
SurfaceFocus::Main => true,
|
||||
SurfaceFocus::Overlay( id ) => state.platform.subsurfaces.values()
|
||||
.any( |s| s.parent() == crate::app::SubsurfaceParent::Overlay( id ) ),
|
||||
};
|
||||
state.frame_presented( *focus, hosts_subsurfaces );
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The Wayland backend's input: `wl_keyboard`, `wl_pointer` and `wl_touch` turned into the events of [`crate::input::event`].
|
||||
//!
|
||||
//! Each handler records what only later Wayland requests read (the serials a window move or resize, a selection, a popup grab and the cursor shape quote back to the compositor), resolves the `wl_surface` to a [`SurfaceFocus`], drops pointer and touch events on the secondary per-output surfaces, whose coordinates would hit-test the main surface's widgets, and forwards the rest to the runtime's entry points. No input logic lives here, so this file is the template for another platform's backend.
|
||||
|
||||
use smithay_client_toolkit::seat::keyboard::
|
||||
{
|
||||
KeyboardHandler, KeyEvent, Keysym, Modifiers, RawModifiers, RepeatInfo,
|
||||
};
|
||||
use smithay_client_toolkit::seat::pointer::
|
||||
{
|
||||
AxisScroll, PointerEvent, PointerEventKind, PointerHandler,
|
||||
BTN_BACK, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, BTN_SIDE,
|
||||
};
|
||||
use smithay_client_toolkit::seat::touch::TouchHandler;
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::
|
||||
{
|
||||
wl_keyboard::WlKeyboard,
|
||||
wl_pointer::{ self, WlPointer },
|
||||
wl_surface::WlSurface,
|
||||
wl_touch::WlTouch,
|
||||
},
|
||||
Connection, QueueHandle,
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, SurfaceFocus };
|
||||
use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase };
|
||||
|
||||
fn key_input( event: KeyEvent ) -> KeyInput
|
||||
{
|
||||
KeyInput { keysym: event.keysym, keycode: event.raw_code, text: event.utf8 }
|
||||
}
|
||||
|
||||
fn pointer_button( code: u32 ) -> PointerButton
|
||||
{
|
||||
match code
|
||||
{
|
||||
BTN_LEFT => PointerButton::Left,
|
||||
BTN_RIGHT => PointerButton::Right,
|
||||
BTN_MIDDLE => PointerButton::Middle,
|
||||
BTN_SIDE | BTN_BACK => PointerButton::Back,
|
||||
_ => PointerButton::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// High-resolution detents when the compositor sends them, the older whole ones otherwise.
|
||||
fn axis( scroll: AxisScroll ) -> Axis
|
||||
{
|
||||
let notches = if scroll.value120 != 0
|
||||
{
|
||||
scroll.value120 as f32 / 120.0
|
||||
} else {
|
||||
scroll.discrete as f32
|
||||
};
|
||||
Axis { distance: scroll.absolute, notches }
|
||||
}
|
||||
|
||||
fn axis_source( source: Option<wl_pointer::AxisSource> ) -> AxisSource
|
||||
{
|
||||
match source
|
||||
{
|
||||
Some( wl_pointer::AxisSource::Wheel ) => AxisSource::Wheel,
|
||||
Some( wl_pointer::AxisSource::Finger | wl_pointer::AxisSource::Continuous ) => AxisSource::Continuous,
|
||||
_ => AxisSource::Other,
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> KeyboardHandler for AppData<A>
|
||||
{
|
||||
fn enter(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
surface: &WlSurface,
|
||||
_serial: u32,
|
||||
_raw: &[u32],
|
||||
_keysyms: &[Keysym],
|
||||
)
|
||||
{
|
||||
let focus = self.focus_for_surface( surface ).unwrap_or( SurfaceFocus::Main );
|
||||
self.keyboard_enter( focus );
|
||||
}
|
||||
|
||||
fn leave(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
_surface: &WlSurface,
|
||||
_serial: u32,
|
||||
)
|
||||
{
|
||||
self.keyboard_leave();
|
||||
}
|
||||
|
||||
fn press_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
serial: u32,
|
||||
event: KeyEvent,
|
||||
)
|
||||
{
|
||||
self.platform.last_input_serial = serial;
|
||||
self.key_press( key_input( event ) );
|
||||
}
|
||||
|
||||
fn release_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
_serial: u32,
|
||||
event: KeyEvent,
|
||||
)
|
||||
{
|
||||
self.key_release( &key_input( event ) );
|
||||
}
|
||||
|
||||
fn update_modifiers(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
_serial: u32,
|
||||
modifiers: Modifiers,
|
||||
_raw_modifiers: RawModifiers,
|
||||
_layout: u32,
|
||||
)
|
||||
{
|
||||
self.modifiers_changed( modifiers.ctrl, modifiers.shift, modifiers.alt );
|
||||
}
|
||||
|
||||
fn update_repeat_info(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
info: RepeatInfo,
|
||||
)
|
||||
{
|
||||
match info
|
||||
{
|
||||
RepeatInfo::Repeat { rate, delay } => self.set_repeat_info( rate.get(), delay ),
|
||||
RepeatInfo::Disable => self.set_repeat_info( 0, 0 ),
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_key(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_keyboard: &WlKeyboard,
|
||||
_serial: u32,
|
||||
event: KeyEvent,
|
||||
)
|
||||
{
|
||||
self.key_repeated( key_input( event ) );
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> PointerHandler for AppData<A>
|
||||
{
|
||||
fn pointer_frame(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_pointer: &WlPointer,
|
||||
events: &[PointerEvent],
|
||||
)
|
||||
{
|
||||
for event in events
|
||||
{
|
||||
if self.is_secondary_surface( &event.surface ) { continue; }
|
||||
let focus = self.focus_for_surface( &event.surface ).unwrap_or( SurfaceFocus::Main );
|
||||
let kind = match event.kind
|
||||
{
|
||||
PointerEventKind::Enter { serial } =>
|
||||
{
|
||||
self.platform.last_pointer_enter_serial = serial;
|
||||
PointerKind::Enter
|
||||
}
|
||||
PointerEventKind::Leave { .. } => PointerKind::Leave,
|
||||
PointerEventKind::Motion { .. } => PointerKind::Motion,
|
||||
PointerEventKind::Press { button, serial, .. } =>
|
||||
{
|
||||
let button = pointer_button( button );
|
||||
if matches!( button, PointerButton::Left | PointerButton::Middle )
|
||||
{
|
||||
self.platform.last_pointer_serial = serial;
|
||||
self.platform.last_input_serial = serial;
|
||||
}
|
||||
PointerKind::Press( button )
|
||||
}
|
||||
PointerEventKind::Release { button, .. } => PointerKind::Release( pointer_button( button ) ),
|
||||
PointerEventKind::Axis { horizontal, vertical, source, .. } => PointerKind::Axis
|
||||
{
|
||||
horizontal: axis( horizontal ),
|
||||
vertical: axis( vertical ),
|
||||
source: axis_source( source ),
|
||||
},
|
||||
};
|
||||
self.pointer_input( &PointerInput { focus, position: event.position, kind } );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> TouchHandler for AppData<A>
|
||||
{
|
||||
fn down(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
serial: u32,
|
||||
time: u32,
|
||||
surface: WlSurface,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
)
|
||||
{
|
||||
self.platform.last_input_serial = serial;
|
||||
if self.is_secondary_surface( &surface ) { return; }
|
||||
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
|
||||
self.touch_input( &TouchInput { id, time, phase: TouchPhase::Down { focus, position } } );
|
||||
}
|
||||
|
||||
fn up(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_serial: u32,
|
||||
time: u32,
|
||||
id: i32,
|
||||
)
|
||||
{
|
||||
self.touch_input( &TouchInput { id, time, phase: TouchPhase::Up } );
|
||||
}
|
||||
|
||||
fn motion(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
time: u32,
|
||||
id: i32,
|
||||
position: ( f64, f64 ),
|
||||
)
|
||||
{
|
||||
self.touch_input( &TouchInput { id, time, phase: TouchPhase::Motion { position } } );
|
||||
}
|
||||
|
||||
fn shape(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_id: i32,
|
||||
_major: f64,
|
||||
_minor: f64,
|
||||
) {}
|
||||
|
||||
fn orientation(
|
||||
&mut self,
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
_touch: &WlTouch,
|
||||
_id: i32,
|
||||
_orientation: f64,
|
||||
) {}
|
||||
|
||||
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
|
||||
{
|
||||
self.reset_touch_state();
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn evdev_buttons_are_named()
|
||||
{
|
||||
assert_eq!( pointer_button( 0x110 ), PointerButton::Left );
|
||||
assert_eq!( pointer_button( 0x111 ), PointerButton::Right );
|
||||
assert_eq!( pointer_button( 0x112 ), PointerButton::Middle );
|
||||
assert_eq!( pointer_button( 0x113 ), PointerButton::Back );
|
||||
assert_eq!( pointer_button( 0x116 ), PointerButton::Back );
|
||||
assert_eq!( pointer_button( 0x114 ), PointerButton::Other );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn high_resolution_detents_win()
|
||||
{
|
||||
let scroll = AxisScroll { absolute: 15.0, discrete: 1, value120: 60, ..AxisScroll::default() };
|
||||
assert_eq!( axis( scroll ), Axis { distance: 15.0, notches: 0.5 } );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn whole_detents_from_an_older_compositor()
|
||||
{
|
||||
let scroll = AxisScroll { absolute: -30.0, discrete: -2, ..AxisScroll::default() };
|
||||
assert_eq!( axis( scroll ), Axis { distance: -30.0, notches: -2.0 } );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_touchpad_scrolls_without_detents()
|
||||
{
|
||||
let scroll = AxisScroll { absolute: 3.5, ..AxisScroll::default() };
|
||||
assert_eq!( axis( scroll ), Axis { distance: 3.5, notches: 0.0 } );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn axis_sources_are_named()
|
||||
{
|
||||
assert_eq!( axis_source( Some( wl_pointer::AxisSource::Wheel ) ), AxisSource::Wheel );
|
||||
assert_eq!( axis_source( Some( wl_pointer::AxisSource::Finger ) ), AxisSource::Continuous );
|
||||
assert_eq!( axis_source( Some( wl_pointer::AxisSource::Continuous ) ), AxisSource::Continuous );
|
||||
assert_eq!( axis_source( Some( wl_pointer::AxisSource::WheelTilt ) ), AxisSource::Other );
|
||||
assert_eq!( axis_source( None ), AxisSource::Other );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_key_event_keeps_its_code_and_text()
|
||||
{
|
||||
let event = KeyEvent { time: 7, raw_code: 30, keysym: Keysym::a, utf8: Some( "a".into() ) };
|
||||
assert_eq!( key_input( event ), KeyInput { keysym: Keysym::a, keycode: 30, text: Some( "a".into() ) } );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! The Wayland backend.
|
||||
//!
|
||||
//! | File | What it does for the runtime |
|
||||
//! | --- | --- |
|
||||
//! | `state.rs` | [`WaylandPlatform`], the protocol state and the EGL context `AppData::platform` holds |
|
||||
//! | `surface.rs` | [`WaylandSurfaceState`], each surface's role object, SHM pool or EGL window surface, held by `SurfaceState::platform`; configuring it |
|
||||
//! | `handlers.rs` | The compositor, shell, output, seat, session-lock, foreign-toplevel, frame-callback and text-input handlers |
|
||||
//! | `data_device.rs`, `primary_selection.rs` | The clipboard and the primary selection shared with other clients, and drops from them |
|
||||
//! | `present.rs` | Putting a painted canvas on screen through an SHM buffer or `eglSwapBuffers`, with its damage, opaque and input regions and frame callback |
|
||||
//! | `input.rs` | `wl_keyboard`, `wl_pointer` and `wl_touch` turned into [`crate::input::event`] |
|
||||
//! | `window.rs` | Interactive move and resize, minimise, fullscreen and a bare frame request on the main xdg-toplevel |
|
||||
//! | `cursor.rs` | The cursor's shape through `wp_cursor_shape_v1` |
|
||||
//! | `text_input.rs` | The input method through `zwp_text_input_v3` |
|
||||
//! | `run.rs` | `ltk::run`: connecting, binding the globals, creating the main surface and the loop that dispatches Wayland events around the runtime's steps |
|
||||
//! | `overlays_reconcile.rs` | Creating and destroying the layer surfaces, popups and windows of [`App::overlays`] as the list changes |
|
||||
//! | `subsurface.rs` | The input-transparent `wl_subsurface`s of [`App::subsurfaces`], each drawn and presented on its own |
|
||||
//! | `session.rs` | `xdg-session-management-v1`, the periodic save of [`App::save_state`] and the signal source that ends the loop on `SIGTERM` |
|
||||
//! | `output_probe.rs`, `window_fit.rs` | The size of the first output before the first commit, and a size hint shrunk to fit it |
|
||||
|
||||
mod cursor;
|
||||
pub( crate ) mod data_device;
|
||||
mod handlers;
|
||||
mod input;
|
||||
mod output_probe;
|
||||
mod overlays_reconcile;
|
||||
pub( crate ) mod present;
|
||||
pub( crate ) mod primary_selection;
|
||||
pub( crate ) mod run;
|
||||
mod session;
|
||||
mod state;
|
||||
mod subsurface;
|
||||
mod surface;
|
||||
mod text_input;
|
||||
mod window;
|
||||
mod window_fit;
|
||||
|
||||
pub( crate ) use state::WaylandPlatform;
|
||||
pub( crate ) use surface::{ LayerConfig, SurfaceKind, WaylandSurfaceState };
|
||||
|
||||
use smithay_client_toolkit::reexports::client::protocol::wl_output::WlOutput;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::SurfaceFocus;
|
||||
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||
use crate::osk::Purpose;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::CursorShape;
|
||||
|
||||
use super::{ Platform, Presentation };
|
||||
|
||||
impl<A: App> Platform for WaylandPlatform<A>
|
||||
{
|
||||
type Surface = WaylandSurfaceState;
|
||||
type Output = WlOutput;
|
||||
|
||||
fn is_toplevel( &self, main: &WaylandSurfaceState ) -> bool
|
||||
{
|
||||
window::window( main ).is_some()
|
||||
}
|
||||
|
||||
fn back_leaves( &self, main: &WaylandSurfaceState ) -> bool
|
||||
{
|
||||
self.is_toplevel( main )
|
||||
}
|
||||
|
||||
fn begin_window_move( &self, main: &WaylandSurfaceState )
|
||||
{
|
||||
window::begin_move( self, main );
|
||||
}
|
||||
|
||||
fn begin_window_resize( &self, main: &WaylandSurfaceState, edge: ResizeEdge )
|
||||
{
|
||||
window::begin_resize( self, main, edge );
|
||||
}
|
||||
|
||||
fn minimize_window( &self, main: &WaylandSurfaceState )
|
||||
{
|
||||
if let Some( window ) = window::window( main ) { window.set_minimized(); }
|
||||
}
|
||||
|
||||
fn set_fullscreen( &self, main: &WaylandSurfaceState, fullscreen: bool ) -> bool
|
||||
{
|
||||
window::set_fullscreen( main, fullscreen )
|
||||
}
|
||||
|
||||
fn request_frame( &self, surface: &WaylandSurfaceState, focus: SurfaceFocus ) -> bool
|
||||
{
|
||||
window::request_frame( self, surface, focus )
|
||||
}
|
||||
|
||||
fn precise_damage( &self, surface: &WaylandSurfaceState ) -> bool
|
||||
{
|
||||
self.egl_context.is_none() || surface.egl_surface.is_none()
|
||||
}
|
||||
|
||||
fn prepare_canvas( &self, surface: &mut WaylandSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool
|
||||
{
|
||||
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
|
||||
}
|
||||
|
||||
fn present( &self, surface: &mut WaylandSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool
|
||||
{
|
||||
present::present( self, surface, canvas, frame )
|
||||
}
|
||||
|
||||
fn publish_clipboard( &mut self, _text: &str )
|
||||
{
|
||||
data_device::publish_clipboard( self );
|
||||
}
|
||||
|
||||
fn publish_primary_selection( &mut self, _text: &str )
|
||||
{
|
||||
primary_selection::publish( self );
|
||||
}
|
||||
|
||||
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
|
||||
{
|
||||
cursor::set_shape( self, shape )
|
||||
}
|
||||
|
||||
fn text_input_active( &self ) -> bool
|
||||
{
|
||||
self.text_input.is_some()
|
||||
}
|
||||
|
||||
fn activate_text_input( &mut self, secure: bool, purpose: Purpose )
|
||||
{
|
||||
text_input::activate( self, secure, purpose );
|
||||
}
|
||||
|
||||
fn deactivate_text_input( &mut self )
|
||||
{
|
||||
text_input::deactivate( self );
|
||||
}
|
||||
|
||||
fn reenable_text_input( &self, secure: bool, purpose: Purpose )
|
||||
{
|
||||
text_input::reenable( self, secure, purpose );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// 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::event_loop::{ AppData, SurfaceFocus, SurfaceState };
|
||||
use crate::event_loop::tooltip::tooltip_id;
|
||||
use crate::types::{ Length, Rect };
|
||||
|
||||
use super::{ LayerConfig, SurfaceKind, WaylandSurfaceState };
|
||||
|
||||
/// 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.platform.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.platform.layer_shell.as_ref();
|
||||
let xdg_shell_opt = data.platform.xdg_shell.as_ref();
|
||||
let output_opt = data.platform.output_state.outputs().next();
|
||||
let cs = &data.platform.compositor_state;
|
||||
let qh = &data.platform.qh;
|
||||
let grab_seat = data.platform.seat_state.seats().next();
|
||||
let grab_serial = data.platform.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.platform.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.platform.canvas_pool;
|
||||
let has_egl = data.platform.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.platform.last_requested_size || resolved_zone != ss.platform.last_requested_zone
|
||||
{
|
||||
if let SurfaceKind::Layer( ref layer_surface ) = ss.platform.surface
|
||||
{
|
||||
if resolved_size != ss.platform.last_requested_size
|
||||
{
|
||||
let ( lw, lh ) = to_logical_size( resolved_size );
|
||||
layer_surface.set_size( lw, lh );
|
||||
ss.platform.last_requested_size = resolved_size;
|
||||
}
|
||||
if resolved_zone != ss.platform.last_requested_zone
|
||||
{
|
||||
layer_surface.set_exclusive_zone( resolved_zone );
|
||||
ss.platform.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.platform.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.platform.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.platform.popup_reposition_token = ss.platform.popup_reposition_token.wrapping_add( 1 );
|
||||
popup.reposition( &positioner, ss.platform.popup_reposition_token );
|
||||
ss.platform.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::new( WaylandSurfaceState::from( SurfaceKind::Popup( popup ) ), 0.0, String::new() );
|
||||
ss.platform.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::new( WaylandSurfaceState::from( SurfaceKind::Popup( popup ) ), 0.0, String::new() );
|
||||
ss.platform.last_requested_size = resolved_size;
|
||||
ss.platform.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::new( WaylandSurfaceState::from( SurfaceKind::Window( window ) ), 0.0, String::new() );
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.platform.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::new( WaylandSurfaceState::from( 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.platform.last_requested_size = resolved_size;
|
||||
ss.platform.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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Putting a painted canvas on screen: through a `wl_shm` buffer for the software renderer, through `eglSwapBuffers` for the GPU one, together with the damage, opaque and input regions and the frame callback of the commit.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use smithay_client_toolkit::compositor::{ CompositorState, Region };
|
||||
use smithay_client_toolkit::reexports::client::protocol::{ wl_shm, wl_surface::WlSurface };
|
||||
use smithay_client_toolkit::shm::Shm;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::draw::fit_canvas;
|
||||
use crate::egl_context::egl_damage;
|
||||
use crate::event_loop::perf::RasterTrace;
|
||||
use crate::platform::Presentation;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::{ Color, Rect };
|
||||
|
||||
use super::{ WaylandPlatform, WaylandSurfaceState };
|
||||
|
||||
/// 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 )
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU path when both the process and the surface have EGL, the SHM pool otherwise.
|
||||
pub( super ) fn prepare_canvas<A: App>
|
||||
(
|
||||
platform: &WaylandPlatform<A>,
|
||||
surface: &mut WaylandSurfaceState,
|
||||
canvas: &mut Option<Canvas>,
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
scale: u32,
|
||||
) -> bool
|
||||
{
|
||||
if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() )
|
||||
{
|
||||
if ctx.make_current( es ).is_err() { return false; }
|
||||
surface.raster_trace = RasterTrace::begin();
|
||||
fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( ctx.gl() ), ctx.version, pw, ph ) );
|
||||
return true;
|
||||
}
|
||||
if surface.pool.is_none() { return false; }
|
||||
fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) );
|
||||
true
|
||||
}
|
||||
|
||||
pub( super ) fn present<A: App>
|
||||
(
|
||||
platform: &WaylandPlatform<A>,
|
||||
surface: &mut WaylandSurfaceState,
|
||||
canvas: &mut Canvas,
|
||||
frame: &Presentation<'_>,
|
||||
) -> bool
|
||||
{
|
||||
let ( pw, ph ) = canvas.size();
|
||||
let Some( ( ctx, es ) ) = platform.egl_context.as_ref().zip( surface.egl_surface.as_ref() ) else
|
||||
{
|
||||
return present_shm( platform, surface, canvas, frame );
|
||||
};
|
||||
canvas.present();
|
||||
if let Some( t ) = surface.raster_trace.take()
|
||||
{
|
||||
t.end( ctx.gl(), if frame.damage.is_empty() { "full" } else { "partial" }, pw, ph );
|
||||
}
|
||||
describe( platform, surface.surface.wl_surface(), &mut surface.opaque_full, frame );
|
||||
// Attaches, damages and commits in one go.
|
||||
let _ = ctx.swap_buffers_with_damage( es, &egl_damage( frame.damage, pw, ph ) );
|
||||
true
|
||||
}
|
||||
|
||||
fn present_shm<A: App>
|
||||
(
|
||||
platform: &WaylandPlatform<A>,
|
||||
surface: &mut WaylandSurfaceState,
|
||||
canvas: &mut Canvas,
|
||||
frame: &Presentation<'_>,
|
||||
) -> bool
|
||||
{
|
||||
let ( pw, ph ) = canvas.size();
|
||||
let ( format, swap_rb ) = pick_shm_format( &platform.shm );
|
||||
let Some( pool ) = surface.pool.as_mut() else { return false };
|
||||
let Ok( ( buffer, pixels ) ) = pool.create_buffer( pw as i32, ph as i32, ( pw * 4 ) as i32, format ) else { return false };
|
||||
canvas.write_to_wayland_buf( pixels, swap_rb );
|
||||
|
||||
let wl_surface = surface.surface.wl_surface();
|
||||
buffer.attach_to( wl_surface ).expect( "attach" );
|
||||
if frame.damage.is_empty()
|
||||
{
|
||||
wl_surface.damage_buffer( 0, 0, pw as i32, ph as i32 );
|
||||
} else {
|
||||
for r in frame.damage
|
||||
{
|
||||
wl_surface.damage_buffer( r.x as i32, r.y as i32, r.width as i32, r.height as i32 );
|
||||
}
|
||||
}
|
||||
describe( platform, wl_surface, &mut surface.opaque_full, frame );
|
||||
wl_surface.commit();
|
||||
true
|
||||
}
|
||||
|
||||
/// The state that rides on the commit besides the buffer. The frame callback must be asked before the commit so the compositor ties it to this frame.
|
||||
fn describe<A: App>( platform: &WaylandPlatform<A>, wl_surface: &WlSurface, opaque_applied: &mut Option<Vec<Rect>>, frame: &Presentation<'_> )
|
||||
{
|
||||
sync_opaque_region( wl_surface, &platform.compositor_state, opaque_applied, frame.bg, frame.opaque_region, frame.width, frame.height, frame.scale );
|
||||
apply_input_region( wl_surface, &platform.compositor_state, frame.input_region, frame.scale );
|
||||
if let Some( focus ) = frame.frame
|
||||
{
|
||||
let _ = wl_surface.frame( &platform.qh, focus );
|
||||
}
|
||||
}
|
||||
|
||||
/// The opaque part of a `width × height` logical surface at `scale`, in physical rects: all of it when `bg` is opaque, else `region`.
|
||||
fn opaque_rects( bg: Color, region: Option<&[Rect]>, width: u32, height: u32, scale: u32 ) -> Vec<Rect>
|
||||
{
|
||||
let s = scale.max( 1 ) as f32;
|
||||
if bg.a >= 1.0
|
||||
{
|
||||
vec![ Rect { x: 0.0, y: 0.0, width: width as f32 * s, height: height as f32 * s } ]
|
||||
} else {
|
||||
region.map( |r| r.to_vec() ).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare the opaque part of the surface so the compositor can skip blending it and repainting what lies underneath. `applied` remembers the rects last sent.
|
||||
pub( crate ) fn sync_opaque_region(
|
||||
wl_surface: &WlSurface,
|
||||
compositor: &CompositorState,
|
||||
applied: &mut Option<Vec<Rect>>,
|
||||
bg: Color,
|
||||
region: Option<&[Rect]>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: u32,
|
||||
)
|
||||
{
|
||||
let rects = opaque_rects( bg, region, width, height, scale );
|
||||
if applied.as_ref() == Some( &rects ) { return; }
|
||||
if rects.is_empty()
|
||||
{
|
||||
wl_surface.set_opaque_region( None );
|
||||
} else if let Ok( wl_region ) = Region::new( compositor ) {
|
||||
let s = scale.max( 1 ) as f32;
|
||||
for r in &rects
|
||||
{
|
||||
wl_region.add(
|
||||
( r.x / s ).round() as i32,
|
||||
( r.y / s ).round() as i32,
|
||||
( r.width / s ).round() as i32,
|
||||
( r.height / s ).round() as i32,
|
||||
);
|
||||
}
|
||||
wl_surface.set_opaque_region( Some( wl_region.wl_region() ) );
|
||||
}
|
||||
*applied = Some( rects );
|
||||
}
|
||||
|
||||
/// Apply or clear the surface's input region (set by
|
||||
/// `App::input_region`). `None` restores the default of "receive
|
||||
/// input everywhere"; `Some(&[])` makes the surface pointer-through.
|
||||
pub( crate ) fn apply_input_region(
|
||||
wl_surface: &WlSurface,
|
||||
compositor: &CompositorState,
|
||||
input_region: Option<&[Rect]>,
|
||||
scale: u32,
|
||||
)
|
||||
{
|
||||
if let Some( regions ) = input_region
|
||||
{
|
||||
if let Ok( region ) = Region::new( compositor )
|
||||
{
|
||||
// `Rect`s are physical (layout space); `wl_region` is logical.
|
||||
let s = scale.max( 1 ) as f32;
|
||||
for r in regions
|
||||
{
|
||||
region.add(
|
||||
( r.x / s ).round() as i32,
|
||||
( r.y / s ).round() as i32,
|
||||
( r.width / s ).round() as i32,
|
||||
( r.height / s ).round() as i32,
|
||||
);
|
||||
}
|
||||
wl_surface.set_input_region( Some( region.wl_region() ) );
|
||||
}
|
||||
} else {
|
||||
wl_surface.set_input_region( None );
|
||||
}
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::opaque_rects;
|
||||
use crate::types::{ Color, Rect };
|
||||
|
||||
fn rect( x: f32, y: f32, width: f32, height: f32 ) -> Rect
|
||||
{
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn an_opaque_background_covers_the_physical_surface()
|
||||
{
|
||||
let region = [ rect( 0.0, 0.0, 1.0, 1.0 ) ];
|
||||
assert_eq!( opaque_rects( Color::rgb( 0.0, 0.0, 0.0 ), Some( ®ion ), 400, 300, 2 ), vec![ rect( 0.0, 0.0, 800.0, 600.0 ) ] );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn a_translucent_background_takes_the_app_region()
|
||||
{
|
||||
let region = [ rect( 0.0, 0.0, 100.0, 50.0 ) ];
|
||||
assert_eq!( opaque_rects( Color::rgba( 0.0, 0.0, 0.0, 0.5 ), Some( ®ion ), 400, 300, 1 ), region.to_vec() );
|
||||
assert!( opaque_rects( Color::rgba( 0.0, 0.0, 0.0, 0.0 ), None, 400, 300, 1 ).is_empty() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 crate::event_loop::AppData;
|
||||
use super::WaylandPlatform;
|
||||
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>,
|
||||
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, inbox_tx, inbox_rx }
|
||||
}
|
||||
|
||||
/// The latest selection the reader threads brought from other applications, if any arrived.
|
||||
pub( crate ) fn drain( &mut self ) -> Option<String>
|
||||
{
|
||||
self.inbox_rx.try_iter().last()
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer the primary selection to other applications; `AppData::primary_selection` holds its text.
|
||||
pub( crate ) fn publish<A: App>( platform: &mut WaylandPlatform<A> )
|
||||
{
|
||||
let ( Some( manager ), Some( device ) ) = ( &platform.primary.manager, &platform.primary.device ) else { return };
|
||||
let source = manager.create_selection_source( &platform.qh, CLIPBOARD_MIMES.iter().copied() );
|
||||
source.set_selection( device, platform.last_input_serial );
|
||||
platform.primary.source = Some( source );
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
{
|
||||
pub( crate ) fn bind_primary_selection( &mut self, qh: &QueueHandle<Self>, seat: &WlSeat )
|
||||
{
|
||||
let primary = &mut self.platform.primary;
|
||||
if primary.device.is_some() { return; }
|
||||
let Some( manager ) = &primary.manager else { return };
|
||||
primary.device = Some( manager.get_selection_device( qh, seat ) );
|
||||
}
|
||||
|
||||
fn owns_primary( &self, source: &ZwpPrimarySelectionSourceV1 ) -> bool
|
||||
{
|
||||
self.platform.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.platform.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_selection ); }
|
||||
}
|
||||
|
||||
fn cancelled( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &ZwpPrimarySelectionSourceV1 )
|
||||
{
|
||||
if self.owns_primary( source ) { self.platform.primary.source = None; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use smithay_client_toolkit::
|
||||
{
|
||||
compositor::CompositorState,
|
||||
output::OutputState,
|
||||
registry::RegistryState,
|
||||
seat::SeatState,
|
||||
shell::
|
||||
{
|
||||
WaylandSurface,
|
||||
wlr_layer::LayerShell,
|
||||
xdg::
|
||||
{
|
||||
XdgShell,
|
||||
window::WindowDecorations,
|
||||
},
|
||||
},
|
||||
shm::Shm,
|
||||
subcompositor::SubcompositorState,
|
||||
};
|
||||
use smithay_client_toolkit::reexports::client::Connection;
|
||||
use smithay_client_toolkit::reexports::calloop::EventLoop;
|
||||
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
|
||||
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::{ AppData, RunError, SurfaceState };
|
||||
|
||||
use super::{ LayerConfig, SurfaceKind, WaylandPlatform, WaylandSurfaceState };
|
||||
use super::overlays_reconcile::reconcile_overlays;
|
||||
use super::subsurface::reconcile_subsurfaces;
|
||||
|
||||
/// Run the application, panicking on init failure. Thin wrapper over
|
||||
/// [`try_run`] kept for backwards-compatibility — embedders that need
|
||||
/// to recover from a missing compositor or a stripped-down driver
|
||||
/// should call [`try_run`] instead.
|
||||
pub( crate ) fn run<A: App>( app: A )
|
||||
{
|
||||
if let Err( e ) = try_run( app )
|
||||
{
|
||||
panic!( "ltk::run failed during init: {e}" );
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the application, returning a typed error on init failure.
|
||||
/// The dispatch loop's runtime errors still panic — they are non-
|
||||
/// recoverable once the surface is on screen, and the surface state
|
||||
/// machine cannot be unwound cleanly from this entry point.
|
||||
pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
|
||||
{
|
||||
let conn = Connection::connect_to_env()
|
||||
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
|
||||
let ( globals, queue ) =
|
||||
smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
|
||||
.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
|
||||
let qh = queue.handle();
|
||||
|
||||
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
|
||||
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
|
||||
WaylandSource::new( conn.clone(), queue )
|
||||
.insert( event_loop.handle() )
|
||||
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
|
||||
|
||||
super::session::install_signal_source( &event_loop.handle() )?;
|
||||
|
||||
let compositor = CompositorState::bind( &globals, &qh )
|
||||
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
|
||||
let shm = Shm::bind( &globals, &qh )
|
||||
.map_err( |e| RunError::MissingProtocol { name: "wl_shm", detail: format!( "{e:?}" ) } )?;
|
||||
|
||||
// Optional: compositors lacking wl_subcompositor leave this None and
|
||||
// App::subsurfaces() silently degrades to no subsurfaces.
|
||||
let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok();
|
||||
|
||||
// Try to bring EGL up. On failure (no libEGL, no compatible config,
|
||||
// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
|
||||
// reason and every surface falls back to the SHM path.
|
||||
let egl_context = match crate::egl_context::EglContext::new( &conn )
|
||||
{
|
||||
// Shared by the surfaces of this thread only; `Arc` is what the surfaces and subsurfaces hold.
|
||||
#[ allow( clippy::arc_with_non_send_sync ) ]
|
||||
Ok( ctx ) => Some( std::sync::Arc::new( ctx ) ),
|
||||
Err( reason ) =>
|
||||
{
|
||||
crate::egl_context::log_software_fallback( &reason );
|
||||
None
|
||||
}
|
||||
};
|
||||
crate::render::set_software_render( egl_context.is_none() );
|
||||
|
||||
// Bind layer-shell up front. Both the main surface (when requested via
|
||||
// ShellMode::Layer) and every overlay returned by App::overlays() share
|
||||
// this single binding.
|
||||
let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();
|
||||
|
||||
// Backwards compatibility: window_config() overrides shell_mode()
|
||||
let force_window = app.window_config()
|
||||
.map( |( t, id )| ( t.to_string(), id.to_string() ) );
|
||||
|
||||
let session_enabled = force_window.is_some() || matches!( app.shell_mode(), crate::app::ShellMode::Window );
|
||||
let mut session = if session_enabled
|
||||
{
|
||||
super::session::SessionRuntime::bootstrap( &mut app )
|
||||
} else {
|
||||
super::session::SessionRuntime::disabled()
|
||||
};
|
||||
if session_enabled
|
||||
{
|
||||
session.bind( &globals, &qh );
|
||||
}
|
||||
let app_id = app.app_id().to_string();
|
||||
if let Some( ( _, ref cfg_id ) ) = force_window
|
||||
{
|
||||
if cfg_id != &app_id
|
||||
{
|
||||
eprintln!( "ltk: window_config app_id {cfg_id:?} differs from App::app_id {app_id:?}; App::app_id wins" );
|
||||
}
|
||||
}
|
||||
|
||||
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
|
||||
-> Result<XdgShell, RunError>
|
||||
{
|
||||
XdgShell::bind( globals, qh )
|
||||
.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
|
||||
};
|
||||
|
||||
// Skipped when going fullscreen: Mutter rejects the fullscreen
|
||||
// request if a min_size constrains it.
|
||||
// Fitted before the first commit, a hint too big for the screen never reaches a compositor that would rescale the window to honour it.
|
||||
let prefitted_size_hint = app.window_size_hint()
|
||||
.filter( |_| !app.start_fullscreen() )
|
||||
.and_then( |hint| super::output_probe::output_logical_size( &conn, &globals ).map( |output| super::window_fit::fit_within( hint, super::window_fit::output_share( output ) ) ) );
|
||||
let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
|
||||
{
|
||||
if app.start_fullscreen() { return; }
|
||||
match prefitted_size_hint.or( app.window_size_hint() )
|
||||
{
|
||||
Some( ( w, h ) ) =>
|
||||
{
|
||||
window.set_min_size( Some( ( w, h ) ) );
|
||||
window.set_max_size( Some( ( w, h ) ) );
|
||||
}
|
||||
None =>
|
||||
{
|
||||
window.set_min_size( Some( ( 360, 480 ) ) );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
|
||||
{
|
||||
if app.start_fullscreen()
|
||||
{
|
||||
window.set_fullscreen( None );
|
||||
}
|
||||
};
|
||||
|
||||
// `restore_toplevel` must precede the first commit, so the session
|
||||
// attach sits right before `commit()`.
|
||||
let make_window = |xdg: &XdgShell, title: &str, session: &mut super::session::SessionRuntime, attach: bool|
|
||||
{
|
||||
let surface = compositor.create_surface( &qh );
|
||||
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
|
||||
window.set_title( title );
|
||||
window.set_app_id( app_id.as_str() );
|
||||
apply_size_hint( &window );
|
||||
apply_fullscreen( &window );
|
||||
if attach
|
||||
{
|
||||
session.attach_toplevel( &window, &qh );
|
||||
}
|
||||
window.commit();
|
||||
window
|
||||
};
|
||||
|
||||
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, _ ) ) = force_window
|
||||
{
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let window = make_window( &xdg, title.as_str(), &mut session, true );
|
||||
( SurfaceKind::Window( window ), Some( xdg ) )
|
||||
} else {
|
||||
// Use shell_mode() to determine surface type
|
||||
use crate::app::ShellMode;
|
||||
match app.shell_mode()
|
||||
{
|
||||
ShellMode::SessionLock => {
|
||||
// Lock surface is created in SessionLockHandler::locked once the
|
||||
// compositor grants the lock; until then a placeholder.
|
||||
( SurfaceKind::PendingLock, None )
|
||||
}
|
||||
ShellMode::Window => {
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let window = make_window( &xdg, "ltk", &mut session, true );
|
||||
( SurfaceKind::Window( window ), Some( xdg ) )
|
||||
}
|
||||
ShellMode::Layer( layer ) => {
|
||||
if layer_shell_opt.is_some()
|
||||
{
|
||||
// Defer surface creation until new_output fires: if we create the layer
|
||||
// surface before the compositor has any output ready (e.g. sway startup),
|
||||
// the compositor cannot assign it and logs an error.
|
||||
let cfg = LayerConfig {
|
||||
layer: layer.to_wlr_layer(),
|
||||
exclusive_zone: app.exclusive_zone(),
|
||||
anchor: app.layer_anchor(),
|
||||
size: app.layer_size(),
|
||||
keyboard_exclusive: app.keyboard_exclusive(),
|
||||
namespace: "ltk-sctk",
|
||||
};
|
||||
( SurfaceKind::Pending( cfg ), None )
|
||||
} else {
|
||||
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let window = make_window( &xdg, "ltk", &mut session, false );
|
||||
( SurfaceKind::Window( window ), Some( xdg ) )
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Layer-shell apps still get xdg_wm_base when the compositor has it, so
|
||||
// a `Layer::Window` overlay can be created later.
|
||||
let xdg_shell = xdg_shell.or_else( || XdgShell::bind( &globals, &qh ).ok() );
|
||||
|
||||
// Bind the session-lock manager and request the lock. We don't keep the
|
||||
// `SessionLockState` (the manager) around: it has no `Drop`, so the
|
||||
// manager object persists in the connection, and the lock lifecycle runs
|
||||
// entirely off the returned `SessionLock` + its surfaces.
|
||||
let session_lock =
|
||||
if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock )
|
||||
{
|
||||
smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh )
|
||||
.lock( &qh )
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
|
||||
let builtin_osk = text_input_manager.is_none() || std::env::var_os( "LTK_BUILTIN_OSK" ).is_some_and( |v| v == "1" );
|
||||
// wl_data_device_manager: optional. Required for cross-application
|
||||
// copy/paste; absence means the clipboard stays process-local.
|
||||
let data_device_manager =
|
||||
smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok();
|
||||
let primary = super::primary_selection::PrimarySelection::new(
|
||||
smithay_client_toolkit::primary_selection::PrimarySelectionManagerState::bind( &globals, &qh ).ok() );
|
||||
let ( clipboard_inbox_tx, clipboard_inbox_rx ) = std::sync::mpsc::channel();
|
||||
let ( drop_inbox_tx, drop_inbox_rx ) = super::data_device::drop_inbox();
|
||||
// AT-SPI2 / accessibility. `try_new` returns `None` when the
|
||||
// platform adapter cannot be created (no daemon on the bus,
|
||||
// headless CI, etc.) — the runtime then runs with no
|
||||
// accessibility tree, which is the previous behaviour.
|
||||
let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id );
|
||||
// xdg-activation-v1: optional. Compositors that don't carry the
|
||||
// global leave `activation_state` as `None` and the inbound /
|
||||
// outbound activation paths silently degrade to no-ops.
|
||||
let activation_state =
|
||||
smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok();
|
||||
// `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an
|
||||
// incoming activation token (the launcher that spawned us set it
|
||||
// in the new process's environment). Honoured exactly once, after
|
||||
// the main surface receives its first configure — that is the
|
||||
// earliest moment we can call `xdg_activation_v1.activate`.
|
||||
let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() );
|
||||
// Avoid the token leaking into any child process the app spawns —
|
||||
// it is single-use and would be invalid the second time anyway.
|
||||
if activation_token_pending.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( "XDG_ACTIVATION_TOKEN" ); }
|
||||
}
|
||||
|
||||
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
|
||||
// bind + dispatch routing; absence of the global is fine, the inner
|
||||
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
|
||||
// fires. The list is built unconditionally so apps that override the
|
||||
// callback always see a consistent stream when the compositor does carry
|
||||
// the protocol.
|
||||
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
|
||||
|
||||
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
|
||||
let titlebar_title = force_window.map( |( t, _ )| t ).unwrap_or_default();
|
||||
|
||||
let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable();
|
||||
let pending_size_hint_fit = !app.start_fullscreen() && app.window_size_hint().is_some() && prefitted_size_hint.is_none();
|
||||
|
||||
let platform = WaylandPlatform
|
||||
{
|
||||
registry_state: RegistryState::new( &globals ),
|
||||
seat_state: SeatState::new( &globals, &qh ),
|
||||
output_state: OutputState::new( &globals, &qh ),
|
||||
compositor_state: compositor,
|
||||
subcompositor,
|
||||
shm,
|
||||
egl_context,
|
||||
session_lock,
|
||||
xdg_shell,
|
||||
layer_shell: layer_shell_opt,
|
||||
keyboard: None,
|
||||
pointer: None,
|
||||
touch: None,
|
||||
cursor_shape_manager:
|
||||
smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
|
||||
cursor_shape_device: None,
|
||||
last_pointer_enter_serial: 0,
|
||||
last_pointer_serial: 0,
|
||||
last_input_serial: 0,
|
||||
text_input_manager,
|
||||
text_input: None,
|
||||
activation_state,
|
||||
activation_token_pending,
|
||||
session,
|
||||
data_device_manager,
|
||||
data_device: None,
|
||||
clipboard_source: None,
|
||||
primary,
|
||||
clipboard_inbox_tx,
|
||||
clipboard_inbox_rx,
|
||||
drop_position: None,
|
||||
drop_mime: None,
|
||||
drop_inbox_tx,
|
||||
drop_inbox_rx,
|
||||
pending_fullscreen: app.start_fullscreen(),
|
||||
pending_size_hint_unpin,
|
||||
pending_size_hint_fit,
|
||||
fitted_size_hint: prefitted_size_hint,
|
||||
canvas_pool: Vec::new(),
|
||||
foreign_toplevel_list,
|
||||
main_output: None,
|
||||
subsurfaces: std::collections::HashMap::new(),
|
||||
subsurface_gles_canvas: None,
|
||||
qh: qh.clone(),
|
||||
};
|
||||
|
||||
let mut main_wl = WaylandSurfaceState::from( surface_kind );
|
||||
main_wl.titlebar_base = titlebar_height;
|
||||
let main = SurfaceState::new( main_wl, titlebar_height, titlebar_title );
|
||||
let mut data = AppData::new( app, platform, main, event_loop.handle(), builtin_osk, a11y );
|
||||
data.install_sources()?;
|
||||
|
||||
if data.platform.session.store.is_some()
|
||||
{
|
||||
super::session::install_save_timer( &event_loop.handle() )?;
|
||||
}
|
||||
|
||||
while !data.exit_requested
|
||||
{
|
||||
// Sleep until a Wayland event, a calloop timer or channel, or a
|
||||
// deadline of the runtime's. Pacing is driven by `wl_surface.frame`
|
||||
// callbacks, so an idle / off-screen / VRR display blocks
|
||||
// indefinitely instead of polling at a fixed rate.
|
||||
let timeout = data.next_wakeup();
|
||||
match event_loop.dispatch( timeout, &mut data )
|
||||
{
|
||||
Ok( () ) => {},
|
||||
Err( calloop::Error::IoError( ref e ) )
|
||||
if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
|
||||
{
|
||||
// On a fatal `wl_display.error` the compositor closes the
|
||||
// socket; the backend has the typed error stashed but the
|
||||
// dispatch result only carries the resulting `BrokenPipe`.
|
||||
if let Some( pe ) = conn.protocol_error()
|
||||
{
|
||||
eprintln!(
|
||||
"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
|
||||
pe.object_interface, pe.object_id, pe.code, pe.message
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
eprintln!( "ltk: wayland connection lost ({e}); exiting" );
|
||||
}
|
||||
data.exit_requested = true;
|
||||
continue;
|
||||
}
|
||||
Err( calloop::Error::OtherError( ref e ) ) =>
|
||||
{
|
||||
// wayland-client surfaces the closed-socket condition as an
|
||||
// OtherError wrapping a WaylandError::Io(BrokenPipe) — one
|
||||
// level deeper than a direct io::Error. Walk the source()
|
||||
// chain so we catch it regardless of how many wrapper types
|
||||
// sit between calloop and the raw io::Error.
|
||||
let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() );
|
||||
let mut is_closed = false;
|
||||
while let Some( err ) = src
|
||||
{
|
||||
if let Some( io ) = err.downcast_ref::<std::io::Error>()
|
||||
{
|
||||
if matches!( io.kind(),
|
||||
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset )
|
||||
{
|
||||
is_closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
src = err.source();
|
||||
}
|
||||
if is_closed
|
||||
{
|
||||
eprintln!( "ltk: wayland connection lost; exiting" );
|
||||
data.exit_requested = true;
|
||||
continue;
|
||||
}
|
||||
panic!( "dispatch: {e:?}" );
|
||||
}
|
||||
Err( e ) => panic!( "dispatch: {e:?}" ),
|
||||
}
|
||||
|
||||
// Latest writer wins; payloads are capped at 16 MiB by the worker that reads them.
|
||||
while let Ok( text ) = data.platform.clipboard_inbox_rx.try_recv()
|
||||
{
|
||||
data.clipboard = text;
|
||||
}
|
||||
if let Some( text ) = data.platform.primary.drain()
|
||||
{
|
||||
data.primary_selection = text;
|
||||
}
|
||||
while let Ok( p ) = data.platform.drop_inbox_rx.try_recv()
|
||||
{
|
||||
data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text );
|
||||
}
|
||||
request_activation_tokens( &mut data );
|
||||
|
||||
data.before_draw();
|
||||
|
||||
// For a session-lock surface, unlock first: exiting without
|
||||
// unlocking leaves the compositor's lock in place by design.
|
||||
if data.app.requested_exit()
|
||||
{
|
||||
if let Some( lock ) = data.platform.session_lock.take()
|
||||
{
|
||||
lock.unlock();
|
||||
let _ = conn.roundtrip();
|
||||
}
|
||||
}
|
||||
|
||||
// Drop any overlays whose id disappeared and create new ones for ids
|
||||
// that just appeared. Specs are re-queried next frame for drawing.
|
||||
reconcile_overlays( &mut data );
|
||||
|
||||
// Subsurfaces must map with the main surface's first buffer, not a frame later.
|
||||
if !data.first_frame_committed
|
||||
{
|
||||
reconcile_subsurfaces( &mut data );
|
||||
}
|
||||
|
||||
// The compositor decides our cadence: when no surface is due we just
|
||||
// loop back to `dispatch( None )` and sleep.
|
||||
let drawn = data.draw_due();
|
||||
|
||||
// Every iteration, decoupled from the main redraw cadence: a finger
|
||||
// drag emits motion events faster than frame callbacks clear
|
||||
// `frame_pending`, so gating this on a main draw would pin the
|
||||
// subsurface between frames. A position-only change is just
|
||||
// set_position + a bare parent commit; no-ops when nothing moved.
|
||||
reconcile_subsurfaces( &mut data );
|
||||
|
||||
data.after_draw( drawn );
|
||||
}
|
||||
|
||||
data.platform.session.on_exit( &data.app );
|
||||
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
/// Issue the xdg-activation tokens the app asked for. The tag rides in
|
||||
/// `RequestData::app_id` and comes back through
|
||||
/// `ActivationHandler::new_token` as the token. When the compositor never
|
||||
/// advertised the global, answer with an empty token so the caller still
|
||||
/// proceeds (falling back to its own matching).
|
||||
fn request_activation_tokens<A: App>( data: &mut AppData<A> )
|
||||
{
|
||||
for tag in data.app.take_activation_requests()
|
||||
{
|
||||
match data.platform.activation_state
|
||||
{
|
||||
Some( ref activation ) =>
|
||||
{
|
||||
let req = smithay_client_toolkit::activation::RequestData {
|
||||
app_id: Some( tag ),
|
||||
seat_and_serial: None,
|
||||
surface: data.main.platform.surface.try_wl_surface().cloned(),
|
||||
};
|
||||
activation.request_token::<AppData<A>>( &data.platform.qh, req );
|
||||
}
|
||||
None =>
|
||||
{
|
||||
if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
|
||||
{
|
||||
data.pending_msgs.push( msg );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// 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::event_loop::{ AppData, RunError };
|
||||
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 };
|
||||
|
||||
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.platform.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.platform.session.store
|
||||
{
|
||||
store.set_session_id( session_id );
|
||||
}
|
||||
}
|
||||
xdg_session_v1::Event::Restored => {}
|
||||
xdg_session_v1::Event::Replaced => state.platform.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,117 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use smithay_client_toolkit::
|
||||
{
|
||||
activation::ActivationState,
|
||||
compositor::CompositorState,
|
||||
data_device_manager::{ DataDeviceManagerState, data_device::DataDevice, data_source::CopyPasteSource },
|
||||
foreign_toplevel_list::ForeignToplevelList,
|
||||
output::OutputState,
|
||||
registry::RegistryState,
|
||||
seat::{ SeatState, pointer::cursor_shape::CursorShapeManager },
|
||||
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_touch::WlTouch },
|
||||
QueueHandle,
|
||||
};
|
||||
use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1;
|
||||
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;
|
||||
use crate::egl_context::EglContext;
|
||||
use crate::app::SubsurfaceId;
|
||||
use crate::event_loop::AppData;
|
||||
use crate::render::Canvas;
|
||||
|
||||
use super::data_device::DropPayload;
|
||||
use super::primary_selection::PrimarySelection;
|
||||
use super::session::SessionRuntime;
|
||||
use super::subsurface::SubsurfaceSlot;
|
||||
|
||||
/// The Wayland protocol state the runtime's requests go through: globals, seat devices, the serials later requests quote and the queue new objects are created on.
|
||||
pub( crate ) struct WaylandPlatform<A: App>
|
||||
{
|
||||
pub registry_state: RegistryState,
|
||||
pub seat_state: SeatState,
|
||||
pub output_state: OutputState,
|
||||
pub compositor_state: CompositorState,
|
||||
/// `None` when the compositor does not advertise `wl_subcompositor`.
|
||||
pub subcompositor: Option<SubcompositorState>,
|
||||
pub shm: Shm,
|
||||
/// Process-wide EGL context. `None` when EGL failed to initialise or `LTK_FORCE_SOFTWARE=1`; every surface then draws through the SHM pool.
|
||||
pub egl_context: Option<Arc<EglContext>>,
|
||||
pub session_lock: Option<SessionLock>,
|
||||
#[ allow( dead_code ) ]
|
||||
pub xdg_shell: Option<XdgShell>,
|
||||
/// Shared by 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>,
|
||||
/// `None` when the compositor does not advertise `wp_cursor_shape_v1`; the compositor's default cursor then stays.
|
||||
pub cursor_shape_manager: Option<CursorShapeManager>,
|
||||
/// Present once the compositor supports cursor shapes and the seat has a pointer.
|
||||
pub cursor_shape_device: Option<WpCursorShapeDeviceV1>,
|
||||
/// Last `wl_pointer.enter` serial, which every `set_shape` must quote.
|
||||
pub last_pointer_enter_serial: u32,
|
||||
/// Last left or middle press, which an interactive move or resize quotes.
|
||||
pub last_pointer_serial: u32,
|
||||
/// Last serial of any input event, which `xdg_popup.grab` and setting a selection quote.
|
||||
pub last_input_serial: u32,
|
||||
pub text_input_manager: Option<ZwpTextInputManagerV3>,
|
||||
pub text_input: Option<ZwpTextInputV3>,
|
||||
/// `xdg-activation-v1`, used on the first configure to honour `XDG_ACTIVATION_TOKEN` and for [`App::request_activation_token`].
|
||||
pub activation_state: Option<ActivationState>,
|
||||
/// Honoured once, after the first configure of the main surface.
|
||||
pub activation_token_pending: Option<String>,
|
||||
/// `xdg-session-management-v1` proxies and the on-disk store; the store is `None` when persistence is off (layer or lock surfaces, unusable app id, concurrent instance, or after `replaced`).
|
||||
pub session: SessionRuntime,
|
||||
/// `None` when the compositor does not advertise `wl_data_device_manager`; copy and paste then stay within the process.
|
||||
pub data_device_manager: Option<DataDeviceManagerState>,
|
||||
/// Created with the first seat that has a keyboard or a pointer.
|
||||
pub data_device: Option<DataDevice>,
|
||||
/// The selection we publish, held so each paste from another client can be served the clipboard text.
|
||||
pub clipboard_source: Option<CopyPasteSource>,
|
||||
pub primary: PrimarySelection,
|
||||
/// Selections other clients set, read by a worker thread and drained into `AppData::clipboard` once per iteration.
|
||||
pub clipboard_inbox_tx: std::sync::mpsc::Sender<String>,
|
||||
pub clipboard_inbox_rx: std::sync::mpsc::Receiver<String>,
|
||||
/// A drag from another client: where it is over our surface, in logical coordinates, and the type negotiated on `enter`. Cleared on `leave` and after the drop.
|
||||
pub drop_position: Option<( f64, f64 )>,
|
||||
pub drop_mime: Option<String>,
|
||||
/// What other clients drop on our surfaces, read by a worker thread.
|
||||
pub drop_inbox_tx: std::sync::mpsc::Sender<DropPayload>,
|
||||
pub drop_inbox_rx: std::sync::mpsc::Receiver<DropPayload>,
|
||||
/// First-configure latch for `App::start_fullscreen`: Mutter ignores a fullscreen request sent before the window is mapped.
|
||||
pub pending_fullscreen: bool,
|
||||
/// First-configure latch: clears `max_size` once 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 )>,
|
||||
/// GLES canvases of destroyed overlays, reused by the next overlay.
|
||||
pub canvas_pool: Vec<Canvas>,
|
||||
/// `ext-foreign-toplevel-list-v1`; yields no toplevels when the compositor does not advertise it.
|
||||
pub foreign_toplevel_list: ForeignToplevelList,
|
||||
/// 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>,
|
||||
/// One GLES canvas for every subsurface raster, so a subsurface dropped and re-created (a lazily emitted sliding panel) does not recompile the shader programs, hundreds of ms on a mobile GPU, each time it reappears.
|
||||
pub subsurface_gles_canvas: Option<Canvas>,
|
||||
pub qh: QueueHandle<AppData<A>>,
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// 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::collections::hash_map::Entry;
|
||||
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::layout_and_draw;
|
||||
use crate::event_loop::{ AppData, SurfaceFocus };
|
||||
use crate::event_loop::perf::RasterTrace;
|
||||
use crate::render::Canvas;
|
||||
use crate::types::Rect;
|
||||
use crate::widget::Element;
|
||||
|
||||
use super::present::{ apply_input_region, pick_shm_format };
|
||||
|
||||
/// 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 ) -> SurfaceFocus
|
||||
{
|
||||
match parent
|
||||
{
|
||||
SubsurfaceParent::Main => SurfaceFocus::Main,
|
||||
SubsurfaceParent::Overlay( id ) => 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.platform.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.platform.surface.try_wl_surface().cloned() else { return };
|
||||
let _ = wl.frame( &data.platform.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.platform.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.platform.subcompositor.is_none() { return; }
|
||||
if !data.main.configured { return; }
|
||||
|
||||
let ( format, swap_rb ) = pick_shm_format( &data.platform.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.platform.subsurfaces.keys()
|
||||
.copied()
|
||||
.filter( |id| !present.contains( id ) )
|
||||
.collect();
|
||||
for id in stale
|
||||
{
|
||||
if let Some( slot ) = data.platform.subsurfaces.remove( &id )
|
||||
{
|
||||
let parent = slot.parent;
|
||||
slot.destroy( data.platform.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 let Entry::Vacant( vacant ) = data.platform.subsurfaces.entry( spec.id )
|
||||
{
|
||||
let ( subsurface, surface ) = {
|
||||
let sc = data.platform.subcompositor.as_ref().unwrap();
|
||||
sc.create_subsurface( parent_wl.clone(), &data.platform.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 );
|
||||
vacant.insert( 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.platform.subsurface_gles_canvas`, shared across all subsurfaces.
|
||||
let size_changed = data.platform.subsurfaces.get( &spec.id )
|
||||
.map( |s| s.last_size != ( pw, ph ) )
|
||||
.unwrap_or( true );
|
||||
let needs_raster = {
|
||||
let slot = data.platform.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.platform.egl_context.as_ref() } else { None };
|
||||
let slot = data.platform.subsurfaces.get_mut( &spec.id ).unwrap();
|
||||
render_slot::<A::Message>(
|
||||
slot, egl, &mut data.platform.subsurface_gles_canvas,
|
||||
&data.platform.shm, &data.platform.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.platform.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 = 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();
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// 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 as _,
|
||||
wlr_layer::{ KeyboardInteractivity, Layer, LayerShell, LayerSurface },
|
||||
xdg::{ popup::Popup, window::Window },
|
||||
},
|
||||
shm::{ Shm, slot::SlotPool },
|
||||
};
|
||||
use smithay_client_toolkit::reexports::client::
|
||||
{
|
||||
protocol::{ wl_output::WlOutput, wl_surface::WlSurface },
|
||||
QueueHandle,
|
||||
};
|
||||
use smithay_client_toolkit::session_lock::SessionLockSurface;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::egl_context::{ EglContext, EglSurface };
|
||||
use crate::event_loop::{ AppData, SurfaceFocus, SurfaceState };
|
||||
use crate::event_loop::perf::RasterTrace;
|
||||
use crate::types::Rect;
|
||||
|
||||
/// 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 App>>,
|
||||
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 Wayland half of a surface: its role object and what the compositor was last told about it.
|
||||
pub( crate ) struct WaylandSurfaceState
|
||||
{
|
||||
pub surface: SurfaceKind,
|
||||
/// SHM buffers for the software path; mutually exclusive with `egl_surface`, and the path is locked after the first configure.
|
||||
pub pool: Option<SlotPool>,
|
||||
/// EGL window surface for the GPU path, created on the first configure when an [`EglContext`] is available.
|
||||
pub egl_surface: Option<EglSurface>,
|
||||
/// Last size sent with `layer_surface.set_size`, compared each reconcile against the overlay's size so overlays can resize after creation. `(0, 0)` for other surfaces and before the first configure.
|
||||
pub last_requested_size: ( u32, u32 ),
|
||||
/// Logical exclusive zone last sent with `set_exclusive_zone`, kept in step with the size so the compositor never reserves a band the overlay no longer paints.
|
||||
pub last_requested_zone: i32,
|
||||
/// Anchor rect the xdg-popup positioner was last configured with; `None` for other surfaces.
|
||||
pub last_popup_anchor: Option<Rect>,
|
||||
/// Monotonic counter for `xdg_popup.reposition` tokens.
|
||||
pub popup_reposition_token: u32,
|
||||
/// Opaque rects (physical) last applied to the surface.
|
||||
pub opaque_full: Option<Vec<Rect>>,
|
||||
/// `LTK_PERF_RASTER` timing of the GPU frame being drawn, from preparing the canvas to presenting it.
|
||||
pub raster_trace: Option<RasterTrace>,
|
||||
/// Title bar height before decoration negotiation; restored when the compositor hands decorations back to the client.
|
||||
pub titlebar_base: f32,
|
||||
}
|
||||
|
||||
impl From<SurfaceKind> for WaylandSurfaceState
|
||||
{
|
||||
fn from( surface: SurfaceKind ) -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
surface,
|
||||
pool: None,
|
||||
egl_surface: None,
|
||||
last_requested_size: ( 0, 0 ),
|
||||
last_requested_zone: 0,
|
||||
last_popup_anchor: None,
|
||||
popup_reposition_token: 0,
|
||||
opaque_full: None,
|
||||
raster_trace: None,
|
||||
titlebar_base: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Msg: Clone> SurfaceState<Msg>
|
||||
{
|
||||
/// 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;
|
||||
|
||||
let wl = &mut self.platform;
|
||||
if wl.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 ) = wl.egl_surface
|
||||
{
|
||||
es.resize( pw as i32, ph as i32 );
|
||||
}
|
||||
} else if wl.pool.is_some() {
|
||||
// SHM path: reallocate the pool to fit the new buffer size.
|
||||
match SlotPool::new( pool_bytes, shm )
|
||||
{
|
||||
Ok( pool ) => wl.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( wl.surface.wl_surface(), pw as i32, ph as i32 )
|
||||
{
|
||||
Ok( es ) =>
|
||||
{
|
||||
wl.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 ) => wl.pool = Some( pool ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!( "[ltk] SlotPool init failed: {e}" );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wl.surface.wl_surface().set_buffer_scale( self.scale_factor.max( 1 ) );
|
||||
self.set_size( w, h );
|
||||
}
|
||||
}
|
||||
|
||||
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.platform.egl_context` is available.
|
||||
pub( crate ) fn on_configure( &mut self, w: u32, h: u32 )
|
||||
{
|
||||
self.main.on_configure( &self.platform.shm, self.platform.egl_context.as_ref(), w, h );
|
||||
// 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.platform.activation_token_pending.take()
|
||||
{
|
||||
if let ( Some( activation ), Some( wl ) ) =
|
||||
( self.platform.activation_state.as_ref(), self.main.platform.surface.try_wl_surface() )
|
||||
{
|
||||
activation.activate::<Self>( wl, token );
|
||||
}
|
||||
}
|
||||
self.on_main_resized( w, h );
|
||||
}
|
||||
|
||||
/// 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.platform.surface.try_wl_surface() == Some( wl )
|
||||
{
|
||||
return Some( SurfaceFocus::Main );
|
||||
}
|
||||
for ( id, ss ) in self.overlays.iter()
|
||||
{
|
||||
if ss.platform.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.platform.surface.try_wl_surface() == Some( wl ) )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3::{ ContentHint, ContentPurpose };
|
||||
|
||||
use crate::app::App;
|
||||
use crate::osk::Purpose;
|
||||
|
||||
use super::WaylandPlatform;
|
||||
|
||||
pub( super ) fn activate<A: App>( platform: &mut WaylandPlatform<A>, secure: bool, purpose: Purpose )
|
||||
{
|
||||
let ( hint, purpose ) = content_type( secure, purpose );
|
||||
match ( &platform.text_input_manager, &platform.text_input )
|
||||
{
|
||||
( Some( manager ), None ) =>
|
||||
{
|
||||
let Some( seat ) = platform.seat_state.seats().next() else
|
||||
{
|
||||
eprintln!( "ltk: activate_text_input: no seat available" );
|
||||
return;
|
||||
};
|
||||
let ti = manager.get_text_input( &seat, &platform.qh, () );
|
||||
ti.enable();
|
||||
ti.set_content_type( hint, purpose );
|
||||
ti.commit();
|
||||
platform.text_input = Some( ti );
|
||||
}
|
||||
( None, _ ) =>
|
||||
eprintln!( "ltk: activate_text_input: no text_input_manager (compositor did not advertise zwp_text_input_manager_v3)" ),
|
||||
( Some( _ ), Some( ti ) ) =>
|
||||
{
|
||||
ti.enable();
|
||||
ti.set_content_type( hint, purpose );
|
||||
ti.commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub( super ) fn deactivate<A: App>( platform: &mut WaylandPlatform<A> )
|
||||
{
|
||||
if let Some( ti ) = platform.text_input.take()
|
||||
{
|
||||
ti.disable();
|
||||
ti.commit();
|
||||
ti.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
pub( super ) fn reenable<A: App>( platform: &WaylandPlatform<A>, secure: bool, purpose: Purpose )
|
||||
{
|
||||
if let Some( ti ) = &platform.text_input
|
||||
{
|
||||
let ( hint, purpose ) = content_type( secure, purpose );
|
||||
ti.enable();
|
||||
ti.set_content_type( hint, purpose );
|
||||
ti.commit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Secure fields are flagged `Password` with `SensitiveData | HiddenText` so the input method skips prediction, autocorrect and storing the value.
|
||||
fn content_type( secure: bool, purpose: Purpose ) -> ( ContentHint, ContentPurpose )
|
||||
{
|
||||
if secure
|
||||
{
|
||||
return ( ContentHint::SensitiveData | ContentHint::HiddenText, ContentPurpose::Password );
|
||||
}
|
||||
let purpose = match purpose
|
||||
{
|
||||
Purpose::Normal => ContentPurpose::Normal,
|
||||
Purpose::Terminal => ContentPurpose::Terminal,
|
||||
};
|
||||
( ContentHint::None, purpose )
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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;
|
||||
use smithay_client_toolkit::shell::xdg::window::Window;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::event_loop::SurfaceFocus;
|
||||
use crate::event_loop::cursor_shape::ResizeEdge;
|
||||
|
||||
use super::{ SurfaceKind, WaylandPlatform, WaylandSurfaceState };
|
||||
|
||||
pub( super ) fn window( surface: &WaylandSurfaceState ) -> Option<&Window>
|
||||
{
|
||||
match surface.surface
|
||||
{
|
||||
SurfaceKind::Window( ref window ) => Some( window ),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub( super ) fn begin_move<A: App>( platform: &WaylandPlatform<A>, main: &WaylandSurfaceState )
|
||||
{
|
||||
let Some( window ) = window( main ) else { return };
|
||||
let Some( seat ) = platform.seat_state.seats().next() else { return };
|
||||
window.move_( &seat, platform.last_pointer_serial );
|
||||
}
|
||||
|
||||
pub( super ) fn begin_resize<A: App>( platform: &WaylandPlatform<A>, main: &WaylandSurfaceState, edge: ResizeEdge )
|
||||
{
|
||||
let Some( window ) = window( main ) else { return };
|
||||
let Some( seat ) = platform.seat_state.seats().next() else { return };
|
||||
window.resize( &seat, platform.last_pointer_serial, xdg_edge( edge ) );
|
||||
}
|
||||
|
||||
pub( super ) fn set_fullscreen( main: &WaylandSurfaceState, fullscreen: bool ) -> bool
|
||||
{
|
||||
let Some( window ) = window( main ) else { return false };
|
||||
if fullscreen
|
||||
{
|
||||
window.set_fullscreen( None );
|
||||
} else {
|
||||
window.unset_fullscreen();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub( super ) fn request_frame<A: App>( platform: &WaylandPlatform<A>, surface: &WaylandSurfaceState, focus: SurfaceFocus ) -> bool
|
||||
{
|
||||
let Some( wl ) = surface.surface.try_wl_surface() else { return false };
|
||||
let _ = wl.frame( &platform.qh, focus );
|
||||
wl.commit();
|
||||
true
|
||||
}
|
||||
|
||||
fn xdg_edge( edge: ResizeEdge ) -> xdg_toplevel::ResizeEdge
|
||||
{
|
||||
match edge
|
||||
{
|
||||
ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top,
|
||||
ResizeEdge::Bottom => xdg_toplevel::ResizeEdge::Bottom,
|
||||
ResizeEdge::Left => xdg_toplevel::ResizeEdge::Left,
|
||||
ResizeEdge::Right => xdg_toplevel::ResizeEdge::Right,
|
||||
ResizeEdge::TopLeft => xdg_toplevel::ResizeEdge::TopLeft,
|
||||
ResizeEdge::TopRight => xdg_toplevel::ResizeEdge::TopRight,
|
||||
ResizeEdge::BottomLeft => xdg_toplevel::ResizeEdge::BottomLeft,
|
||||
ResizeEdge::BottomRight => xdg_toplevel::ResizeEdge::BottomRight,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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 crate::event_loop::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.platform.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.platform.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.platform.main_output.as_ref() )
|
||||
.and_then( |o| self.platform.output_state.info( o ) )
|
||||
.or_else( || self.platform.output_state.outputs().find_map( |o| self.platform.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