Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved. New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md. Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event. Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
757 lines
22 KiB
Rust
757 lines
22 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
use smithay_client_toolkit::
|
|
{
|
|
compositor::CompositorHandler,
|
|
delegate_compositor, delegate_subcompositor, delegate_foreign_toplevel_list, delegate_layer,
|
|
delegate_output, delegate_registry, delegate_seat, delegate_keyboard,
|
|
delegate_pointer, delegate_touch, delegate_shm, delegate_xdg_popup,
|
|
delegate_xdg_shell, delegate_xdg_window, delegate_session_lock,
|
|
foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler },
|
|
output::{ OutputHandler, OutputState },
|
|
registry::{ ProvidesRegistryState, RegistryState },
|
|
registry_handlers,
|
|
seat::{ Capability, SeatHandler, SeatState },
|
|
shell::
|
|
{
|
|
WaylandSurface,
|
|
wlr_layer::{ LayerShellHandler, LayerSurface, LayerSurfaceConfigure },
|
|
xdg::XdgSurface,
|
|
xdg::popup::{ Popup, PopupConfigure, PopupHandler },
|
|
xdg::window::{ DecorationMode, Window, WindowConfigure, WindowHandler },
|
|
},
|
|
shm::{ Shm, ShmHandler },
|
|
session_lock::{ SessionLock, SessionLockHandler, SessionLockSurface, SessionLockSurfaceConfigure },
|
|
};
|
|
use smithay_client_toolkit::reexports::protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1;
|
|
use smithay_client_toolkit::reexports::client::
|
|
{
|
|
protocol::
|
|
{
|
|
wl_callback::{ self, WlCallback },
|
|
wl_output::{ self, WlOutput },
|
|
wl_surface::WlSurface,
|
|
wl_seat::WlSeat,
|
|
},
|
|
Connection, Dispatch, Proxy, QueueHandle,
|
|
};
|
|
use wayland_protocols::wp::text_input::zv3::client::
|
|
{
|
|
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
|
zwp_text_input_v3::{ self, ZwpTextInputV3 },
|
|
};
|
|
|
|
use crate::app::{ App, ToplevelEvent };
|
|
use super::app_data::AppData;
|
|
|
|
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 { return };
|
|
let ( pw, ph ) = {
|
|
let shm = &self.shm;
|
|
let ss = match focus
|
|
{
|
|
super::SurfaceFocus::Main => &mut self.main,
|
|
super::SurfaceFocus::Overlay( id ) => match self.overlays.get_mut( &id )
|
|
{
|
|
Some( s ) => s,
|
|
None => return,
|
|
},
|
|
};
|
|
if new_factor == ss.scale_factor { return; }
|
|
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;
|
|
// Resize whichever rendering target is active. The two are mutually
|
|
// exclusive so only one branch runs.
|
|
if let Some( ref es ) = ss.egl_surface
|
|
{
|
|
es.resize( pw as i32, ph as i32 );
|
|
// Canvas FBO reallocation is deferred to the draw path: it needs
|
|
// `eglMakeCurrent` first.
|
|
} else {
|
|
ss.pool = Some(
|
|
smithay_client_toolkit::shm::slot::SlotPool::new(
|
|
( pw * ph * 4 ) as usize, shm,
|
|
).expect( "pool" ),
|
|
);
|
|
if let Some( ref mut canvas ) = ss.canvas
|
|
{
|
|
canvas.resize( pw, ph );
|
|
}
|
|
}
|
|
ss.request_redraw();
|
|
( pw, ph )
|
|
};
|
|
// Notify the app of the new physical dimensions. The previous
|
|
// `on_resize` it received was scaled with the OLD factor, so any
|
|
// app-side state keyed off those pixels is now stale. Only fire
|
|
// for the main surface — overlay resizes don't go through
|
|
// `App::on_resize`.
|
|
if matches!( focus, super::SurfaceFocus::Main )
|
|
{
|
|
crate::types::set_viewport_size( pw, ph );
|
|
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>, _: &WlSurface, _: &WlOutput ) {}
|
|
|
|
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,
|
|
)
|
|
{
|
|
match self.focus_for_surface( layer.wl_surface() )
|
|
{
|
|
Some( super::SurfaceFocus::Main ) | None =>
|
|
{
|
|
if self.app.on_close_requested()
|
|
{
|
|
self.exit_requested = true;
|
|
}
|
|
}
|
|
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
|
{
|
|
// Compositor asked us to destroy this overlay. Drop it
|
|
// synchronously *and* rewrite every per-device focus that
|
|
// pointed at it — otherwise the next event in this same
|
|
// dispatch (touch up, IME done, pointer leave) lands on a
|
|
// freed surface and `surface()` / `surface_mut()` panic.
|
|
// `reconcile_overlays` will still run afterwards and skip
|
|
// re-creating the entry as long as the app stops returning
|
|
// its id from `overlays()`.
|
|
self.discard_overlay( id );
|
|
}
|
|
}
|
|
}
|
|
|
|
fn configure(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
layer: &LayerSurface,
|
|
configure: LayerSurfaceConfigure,
|
|
_serial: u32,
|
|
)
|
|
{
|
|
let ( w, h ) = configure.new_size;
|
|
let ( w, h ) = ( w.max( 1 ), h.max( 1 ) );
|
|
match self.focus_for_surface( layer.wl_surface() )
|
|
{
|
|
Some( super::SurfaceFocus::Main ) | None =>
|
|
{
|
|
self.on_configure( w, h );
|
|
}
|
|
Some( super::SurfaceFocus::Overlay( id ) ) =>
|
|
{
|
|
if let Some( ss ) = self.overlays.get_mut( &id )
|
|
{
|
|
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: App> WindowHandler for AppData<A>
|
|
{
|
|
fn request_close(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
_window: &Window,
|
|
)
|
|
{
|
|
if self.app.on_close_requested()
|
|
{
|
|
self.exit_requested = true;
|
|
}
|
|
}
|
|
|
|
fn configure(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
window: &Window,
|
|
configure: WindowConfigure,
|
|
_serial: u32,
|
|
)
|
|
{
|
|
// Mutter ignores set_fullscreen sent before the surface is
|
|
// mapped, so reapply on the first configure.
|
|
if self.pending_fullscreen
|
|
{
|
|
window.set_fullscreen( None );
|
|
self.pending_fullscreen = false;
|
|
}
|
|
if self.pending_size_hint_unpin
|
|
{
|
|
window.set_max_size( None );
|
|
self.pending_size_hint_unpin = false;
|
|
}
|
|
let tb = if configure.decoration_mode == DecorationMode::Server { 0.0 }
|
|
else { self.main.titlebar_base };
|
|
if self.main.titlebar_height != tb
|
|
{
|
|
self.main.titlebar_height = tb;
|
|
self.main.content_dirty = true;
|
|
self.view_dirty = true;
|
|
}
|
|
let ( hint_w, hint_h ) = self.app.window_size_hint().unwrap_or( ( 800, 600 ) );
|
|
let w = configure.new_size.0.map( |v| v.get() ).unwrap_or( hint_w );
|
|
let h = configure.new_size.1.map( |v| v.get() ).unwrap_or( hint_h );
|
|
window.xdg_surface().set_window_geometry( 0, 0, w as i32, h as i32 );
|
|
self.on_configure( w, h );
|
|
}
|
|
}
|
|
|
|
impl<A: App> ShmHandler for AppData<A>
|
|
{
|
|
fn shm_state( &mut self ) -> &mut Shm
|
|
{
|
|
&mut self.shm
|
|
}
|
|
}
|
|
|
|
impl<A: App> OutputHandler for AppData<A>
|
|
{
|
|
fn output_state( &mut self ) -> &mut OutputState
|
|
{
|
|
&mut self.output_state
|
|
}
|
|
|
|
fn new_output( &mut self, _: &Connection, qh: &QueueHandle<Self>, output: WlOutput )
|
|
{
|
|
// If we were waiting for an output to assign the layer surface to, create it now.
|
|
// Same for any overlays that were created before an output existed.
|
|
if let Some( ref layer_shell ) = self.layer_shell
|
|
{
|
|
self.main.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
|
|
for ss in self.overlays.values_mut()
|
|
{
|
|
ss.surface.materialize( &self.compositor_state, layer_shell, qh, &output );
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_output( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
|
|
fn output_destroyed( &mut self, _: &Connection, _: &QueueHandle<Self>, _: WlOutput ) {}
|
|
}
|
|
|
|
impl<A: App> SeatHandler for AppData<A>
|
|
{
|
|
fn seat_state( &mut self ) -> &mut SeatState
|
|
{
|
|
&mut self.seat_state
|
|
}
|
|
|
|
fn new_seat(
|
|
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
|
|
)
|
|
{
|
|
// First seat → first data device. We only need one device
|
|
// regardless of how many seats appear (the typical desktop
|
|
// session has exactly one); additional seats reuse the same
|
|
// device-bound clipboard semantics by construction because
|
|
// `wl_data_device_manager.get_data_device` takes the seat as
|
|
// argument and the compositor manages routing.
|
|
if self.data_device.is_none()
|
|
{
|
|
if let Some( ref ddm ) = self.data_device_manager
|
|
{
|
|
self.data_device = Some( ddm.get_data_device( qh, &seat ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
fn new_capability(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
qh: &QueueHandle<Self>,
|
|
seat: WlSeat,
|
|
capability: Capability,
|
|
)
|
|
{
|
|
match capability
|
|
{
|
|
Capability::Keyboard if self.keyboard.is_none() =>
|
|
{
|
|
self.keyboard = Some(
|
|
self.seat_state
|
|
.get_keyboard( qh, &seat, None )
|
|
.expect( "keyboard" ),
|
|
);
|
|
}
|
|
Capability::Pointer if self.pointer.is_none() =>
|
|
{
|
|
let pointer = self.seat_state
|
|
.get_pointer( qh, &seat )
|
|
.expect( "pointer" );
|
|
// Create a per-pointer cursor-shape device when the
|
|
// compositor advertises wp_cursor_shape_v1. The device
|
|
// outlives the WlPointer we just created and is what
|
|
// `set_shape(serial, shape)` is called on.
|
|
if let Some( ref mgr ) = self.cursor_shape_manager
|
|
{
|
|
self.cursor_shape_device = Some( mgr.get_shape_device( &pointer, qh ) );
|
|
}
|
|
self.pointer = Some( pointer );
|
|
}
|
|
Capability::Touch if self.touch.is_none() =>
|
|
{
|
|
// Touch coming back (resume on devices that power the
|
|
// touchscreen down at suspend): clear anything the removal
|
|
// path didn't, so the first post-resume gesture starts clean.
|
|
if self.reset_touch_state()
|
|
{
|
|
eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" );
|
|
}
|
|
self.touch = Some(
|
|
self.seat_state
|
|
.get_touch( qh, &seat )
|
|
.expect( "touch" ),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn remove_capability(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
_seat: WlSeat,
|
|
capability: Capability,
|
|
)
|
|
{
|
|
match capability
|
|
{
|
|
Capability::Keyboard => { self.keyboard = None; }
|
|
Capability::Pointer => { self.pointer = None; }
|
|
Capability::Touch =>
|
|
{
|
|
self.touch = None;
|
|
// The touchscreen is gone before any pending up / cancel for
|
|
// the last touch could arrive; drop the stale gesture state
|
|
// now rather than stranding it until the next clean release.
|
|
if self.reset_touch_state()
|
|
{
|
|
eprintln!( "[ltk] touch capability removed mid-gesture — reset stale touch state" );
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn remove_seat(
|
|
&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat,
|
|
) {}
|
|
}
|
|
|
|
impl<A: App> PopupHandler for AppData<A>
|
|
{
|
|
fn configure(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
popup: &Popup,
|
|
config: PopupConfigure,
|
|
)
|
|
{
|
|
let ( w, h ) = ( config.width.max( 1 ) as u32, config.height.max( 1 ) as u32 );
|
|
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
|
|
{
|
|
if let Some( ss ) = self.overlays.get_mut( &id )
|
|
{
|
|
ss.on_configure( &self.shm, self.egl_context.as_ref(), w, h );
|
|
}
|
|
}
|
|
}
|
|
|
|
fn done(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
popup: &Popup,
|
|
)
|
|
{
|
|
if let Some( super::SurfaceFocus::Overlay( id ) ) = self.focus_for_surface( popup.wl_surface() )
|
|
{
|
|
if let Some( msg ) = self.overlay_dismiss_msg( id )
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
}
|
|
// Synchronous teardown plus per-device focus cleanup. See
|
|
// `discard_overlay` for the panic case this prevents.
|
|
self.discard_overlay( id );
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Delegate macros ---
|
|
|
|
impl<A: App> SessionLockHandler for AppData<A>
|
|
{
|
|
fn locked( &mut self, _conn: &Connection, qh: &QueueHandle<Self>, session_lock: SessionLock )
|
|
{
|
|
if let Some( output ) = self.output_state.outputs().next()
|
|
{
|
|
let surface = self.compositor_state.create_surface( qh );
|
|
let lock_surface = session_lock.create_lock_surface( surface, &output, qh );
|
|
self.main.surface = super::SurfaceKind::Lock( lock_surface );
|
|
}
|
|
self.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;
|
|
self.on_configure( w.max( 1 ), h.max( 1 ) );
|
|
}
|
|
}
|
|
|
|
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> );
|
|
|
|
// --- `xdg-activation-v1` handler ---
|
|
//
|
|
// A token carrying a `RequestData::app_id` was requested by the app via
|
|
// [`App::take_activation_requests`]: echo the tag + token back through
|
|
// [`App::on_activation_token`] so the app can pass it to a child it is
|
|
// launching. A token with no tag was issued for our own surface, so we
|
|
// self-activate.
|
|
impl<A: App> smithay_client_toolkit::activation::ActivationHandler for AppData<A>
|
|
{
|
|
type RequestData = smithay_client_toolkit::activation::RequestData;
|
|
fn new_token( &mut self, token: String, data: &Self::RequestData )
|
|
{
|
|
use smithay_client_toolkit::activation::RequestDataExt;
|
|
if let Some( tag ) = data.app_id()
|
|
{
|
|
if let Some( msg ) = self.app.on_activation_token( tag.to_string(), token )
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
}
|
|
return;
|
|
}
|
|
if let ( Some( ref activation ), Some( wl ) ) = ( self.activation_state.as_ref(), self.main.surface.try_wl_surface() )
|
|
{
|
|
activation.activate::<Self>( &wl, token );
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: App> ProvidesRegistryState for AppData<A>
|
|
{
|
|
fn registry( &mut self ) -> &mut RegistryState
|
|
{
|
|
&mut self.registry_state
|
|
}
|
|
registry_handlers![ OutputState, SeatState ];
|
|
}
|
|
|
|
// --- `ext-foreign-toplevel-list-v1` handler ---
|
|
//
|
|
// SCTK does the wire-level dispatch (open / close / done / app_id /
|
|
// title / identifier events arrive on the inner `ForeignToplevelList`
|
|
// helper); we surface the high-level open / update / close callbacks
|
|
// to apps through [`App::on_toplevel_event`]. Returned messages join
|
|
// `pending_msgs` and flow through the regular `update` cycle, so the
|
|
// invalidation pipeline (`App::invalidate_after`) decides which
|
|
// surfaces to redraw — apps that only need the dock to refresh can
|
|
// scope to `SurfaceTarget::Main` and skip overlays.
|
|
//
|
|
// The `id` field reported to apps is the Wayland protocol id of the
|
|
// handle proxy — unique per session and stable for the handle's
|
|
// lifetime, the same value paired across `Opened` and the matching
|
|
// `Closed`.
|
|
/// Cascade for the per-toplevel display string crustace-style shells use to
|
|
/// register a window: prefer `app_id` (sets `.desktop` matching), fall back
|
|
/// to `title` (visible to the user), and finally to the protocol-issued
|
|
/// `identifier` (always present, unique per handle). Without the cascade,
|
|
/// any client that never set `app_id` (e.g. winit-windowed compositors
|
|
/// before they bind their xdg surface) was silently invisible to the dock.
|
|
fn toplevel_display_id(
|
|
list: &ForeignToplevelList,
|
|
handle: &ExtForeignToplevelHandleV1,
|
|
) -> String
|
|
{
|
|
// 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 Some( info ) = list.info( handle ) else { return String::new(); };
|
|
info.app_id
|
|
}
|
|
|
|
impl<A: App> ForeignToplevelListHandler for AppData<A>
|
|
{
|
|
fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList
|
|
{
|
|
&mut self.foreign_toplevel_list
|
|
}
|
|
|
|
fn new_toplevel(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
handle: ExtForeignToplevelHandleV1,
|
|
)
|
|
{
|
|
let id = handle.id().protocol_id();
|
|
let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle );
|
|
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
|
{
|
|
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 id = handle.id().protocol_id();
|
|
let app_id = toplevel_display_id( &self.foreign_toplevel_list, &handle );
|
|
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Opened { id, app_id } )
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
}
|
|
}
|
|
|
|
fn toplevel_closed(
|
|
&mut self,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
handle: ExtForeignToplevelHandleV1,
|
|
)
|
|
{
|
|
let id = handle.id().protocol_id();
|
|
if let Some( msg ) = self.app.on_toplevel_event( ToplevelEvent::Closed { id } )
|
|
{
|
|
self.pending_msgs.push( msg );
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Dispatch impls for zwp_text_input_v3 ---
|
|
|
|
impl<A: App> Dispatch<ZwpTextInputManagerV3, ()> for AppData<A>
|
|
{
|
|
fn event(
|
|
_state: &mut Self,
|
|
_proxy: &ZwpTextInputManagerV3,
|
|
_event: <ZwpTextInputManagerV3 as smithay_client_toolkit::reexports::client::Proxy>::Event,
|
|
_data: &(),
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
// no events from manager
|
|
}
|
|
}
|
|
|
|
// Frame-callback routing.
|
|
//
|
|
// Each `draw_*` path requests a `wl_surface.frame` with `SurfaceFocus` user-
|
|
// data identifying which `SurfaceState` it belongs to. The compositor fires
|
|
// the callback when the surface is ready for its next commit (display refresh,
|
|
// VRR cadence, "screen is on", …). Clearing `frame_pending` unblocks the run
|
|
// loop so the next iteration is allowed to draw that surface again.
|
|
//
|
|
// While the app is animating we re-arm the surface for redraw inline so the
|
|
// loop keeps ticking at the compositor's pace without needing a fixed-period
|
|
// timer.
|
|
impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
|
|
{
|
|
fn event(
|
|
state: &mut Self,
|
|
_proxy: &WlCallback,
|
|
_event: wl_callback::Event,
|
|
focus: &super::SurfaceFocus,
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
let is_animating = state.app.is_animating();
|
|
match *focus
|
|
{
|
|
super::SurfaceFocus::Main =>
|
|
{
|
|
state.main.frame_pending = false;
|
|
if is_animating
|
|
{
|
|
if state.app.subsurface_motion_only()
|
|
{
|
|
// The main buffer is unchanged; only the subsurface
|
|
// moves (repositioned by the per-frame reconcile).
|
|
// Keep the vsync cadence with a bare frame callback +
|
|
// commit — no buffer attach, no re-raster.
|
|
if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
|
{
|
|
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
|
wl.commit();
|
|
state.main.frame_pending = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Perf guardrails: run the opt-in diagnostics and apply
|
|
// the software animation-rate cap (~30 Hz). A throttled
|
|
// frame keeps the vsync cadence with a bare callback but
|
|
// skips the re-raster.
|
|
let software = crate::render::is_software_render();
|
|
let cap = state.app.cap_software_animation();
|
|
if state.perf.animated_frame( software, cap )
|
|
{
|
|
state.view_dirty = true;
|
|
state.main.request_redraw();
|
|
}
|
|
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
|
{
|
|
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
|
wl.commit();
|
|
state.main.frame_pending = true;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
state.perf.animation_stopped();
|
|
}
|
|
}
|
|
super::SurfaceFocus::Overlay( id ) =>
|
|
{
|
|
if let Some( ss ) = state.overlays.get_mut( &id )
|
|
{
|
|
ss.frame_pending = false;
|
|
if is_animating
|
|
{
|
|
state.overlays_dirty = true;
|
|
ss.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: App> Dispatch<ZwpTextInputV3, ()> for AppData<A>
|
|
{
|
|
fn event(
|
|
state: &mut Self,
|
|
_proxy: &ZwpTextInputV3,
|
|
event: zwp_text_input_v3::Event,
|
|
_data: &(),
|
|
_conn: &Connection,
|
|
_qh: &QueueHandle<Self>,
|
|
)
|
|
{
|
|
let focus = state.keyboard_focus;
|
|
match event
|
|
{
|
|
zwp_text_input_v3::Event::CommitString { text } =>
|
|
{
|
|
if let Some( text ) = text
|
|
{
|
|
if !text.is_empty()
|
|
{
|
|
state.handle_text_insert( focus, &text );
|
|
}
|
|
}
|
|
}
|
|
zwp_text_input_v3::Event::DeleteSurroundingText { before_length, after_length } =>
|
|
{
|
|
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();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|