From dc781fb78dca29e2bdcc2b1ae08c2bce90d5d33c Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Mon, 11 May 2026 22:30:29 +0200 Subject: [PATCH] =?UTF-8?q?event=5Floop:=20client=20binding=20for=20ext-fo?= =?UTF-8?q?reign-toplevel-list-v1=20Adds=20an=20`App`=20callback=20that=20?= =?UTF-8?q?delivers=20the=20live=20list=20of=20open=20toplevels=20from=20t?= =?UTF-8?q?he=20compositor=20=E2=80=94=20the=20data=20source=20a=20shell?= =?UTF-8?q?=20needs=20for=20dock=20running-app=20indicators,=20taskbar=20t?= =?UTF-8?q?iles,=20alt-tab=20and=20any=20other=20"what=20is=20currently=20?= =?UTF-8?q?running"=20UI.=20Hand-wiring=20the=20protocol=20binding=20from?= =?UTF-8?q?=20every=20shell=20that=20wants=20it=20is=20the=20kind=20of=20b?= =?UTF-8?q?oilerplate=20ltk=20should=20absorb=20once:=20this=20is=20that?= =?UTF-8?q?=20move.=20`Cargo.toml`=20adds=20`"staging"`=20to=20`wayland-pr?= =?UTF-8?q?otocols`'=20feature=20list.=20SCTK=200.20=20already=20pulls=20s?= =?UTF-8?q?taging=20in=20transitively=20(it=20carries=20`foreign=5Ftopleve?= =?UTF-8?q?l=5Flist.rs`=20and=20its=20own=20dispatch=20helper),=20so=20thi?= =?UTF-8?q?s=20is=20belt-and-braces=20against=20a=20future=20ltk=20tree=20?= =?UTF-8?q?that=20swaps=20SCTK=20for=20a=20different=20client=20toolkit=20?= =?UTF-8?q?=E2=80=94=20it=20keeps=20the=20protocol=20available=20crate-wid?= =?UTF-8?q?e=20even=20then.=20`src/app.rs`=20introduces=20`ToplevelEvent?= =?UTF-8?q?=20{=20Opened=20{=20id:=20u32,=20app=5Fid:=20String=20},=20Clos?= =?UTF-8?q?ed=20{=20id:=20u32=20}=20}`=20and=20`App::on=5Ftoplevel=5Fevent?= =?UTF-8?q?(=20&self,=20ToplevelEvent=20)=20->=20Option`=20?= =?UTF-8?q?with=20the=20default=20returning=20`None`=20so=20apps=20that=20?= =?UTF-8?q?do=20not=20care=20pay=20nothing=20(no=20allocation,=20no=20disp?= =?UTF-8?q?atch).=20`id`=20is=20the=20Wayland=20protocol=20id=20of=20the?= =?UTF-8?q?=20handle=20proxy=20=E2=80=94=20unique=20per=20session,=20stabl?= =?UTF-8?q?e=20for=20the=20handle's=20lifetime,=20the=20same=20value=20pai?= =?UTF-8?q?red=20across=20`Opened`=20and=20`Closed`.=20`src/lib.rs`=20re-e?= =?UTF-8?q?xports=20`ToplevelEvent`=20from=20the=20public=20prelude.=20`sr?= =?UTF-8?q?c/event=5Floop/app=5Fdata.rs`=20grows=20a=20`pub=20foreign=5Fto?= =?UTF-8?q?plevel=5Flist:=20ForeignToplevelList`=20field.=20`src/event=5Fl?= =?UTF-8?q?oop/mod.rs`=20constructs=20it=20via=20`ForeignToplevelList::new?= =?UTF-8?q?(=20&globals,=20&qh=20)`=20and=20stores=20it=20on=20`AppData`.?= =?UTF-8?q?=20If=20the=20compositor=20does=20not=20advertise=20the=20globa?= =?UTF-8?q?l=20the=20inner=20`GlobalProxy`=20just=20resolves=20to=20"absen?= =?UTF-8?q?t"=20and=20the=20list=20yields=20no=20toplevels=20=E2=80=94=20n?= =?UTF-8?q?o=20error=20path=20needed=20at=20construction.=20`src/event=5Fl?= =?UTF-8?q?oop/handlers.rs`=20adds=20the=20`Proxy`=20import,=20`delegate?= =?UTF-8?q?=5Fforeign=5Ftoplevel=5Flist!(=20@=20AppData=20)`?= =?UTF-8?q?=20next=20to=20the=20rest,=20and=20implements=20`ForeignTopleve?= =?UTF-8?q?lListHandler`=20for=20`AppData`.=20The=20three=20SCTK=20call?= =?UTF-8?q?backs=20(`new=5Ftoplevel`,=20`update=5Ftoplevel`,=20`toplevel?= =?UTF-8?q?=5Fclosed`)=20each=20pull=20the=20handle's=20protocol=20id=20an?= =?UTF-8?q?d=20its=20currently-cached=20`app=5Fid`=20from=20the=20list's?= =?UTF-8?q?=20info=20cache,=20call=20`self.app.on=5Ftoplevel=5Fevent(=20?= =?UTF-8?q?=E2=80=A6=20)`,=20and=20push=20the=20returned=20message=20onto?= =?UTF-8?q?=20`pending=5Fmsgs`=20so=20it=20flows=20through=20the=20normal?= =?UTF-8?q?=20`update`=20cycle=20with=20the=20regular=20`invalidate=5Fafte?= =?UTF-8?q?r`=20scoping=20path.=20`update=5Ftoplevel`=20re-emits=20`Opened?= =?UTF-8?q?`=20with=20the=20latest=20info=20=E2=80=94=20compositors=20fire?= =?UTF-8?q?=20this=20on=20title=20changes=20too,=20not=20just=20`app=5Fid`?= =?UTF-8?q?=20changes,=20but=20apps=20whose=20state=20is=20keyed=20on=20`(?= =?UTF-8?q?id,=20app=5Fid)`=20can=20absorb=20the=20repeat=20idempotently?= =?UTF-8?q?=20and=20apps=20that=20need=20title-change=20granularity=20can?= =?UTF-8?q?=20scope=20via=20`invalidate=5Fafter`.=20The=20wire-up=20is=20g?= =?UTF-8?q?eneric:=20a=20shell=20that=20wants=20finer=20behaviour=20(focus?= =?UTF-8?q?=20follow,=20per-title=20indicators,=20multi-window=20grouping)?= =?UTF-8?q?=20can=20layer=20on=20top=20by=20translating=20the=20event=20in?= =?UTF-8?q?to=20more=20specific=20app=20messages.=20The=20default=20app=20?= =?UTF-8?q?pays=20zero,=20the=20shell=20that=20opts=20in=20gets=20a=20real?= =?UTF-8?q?=20event=20stream=20without=20touching=20`smithay-client-toolki?= =?UTF-8?q?t`=20directly.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- src/app.rs | 31 +++++++++++++ src/event_loop/app_data.rs | 10 +++++ src/event_loop/handlers.rs | 92 +++++++++++++++++++++++++++++++++++--- src/event_loop/mod.rs | 9 ++++ src/lib.rs | 2 +- 6 files changed, 139 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 39d1a9d..d1cb68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ fontdue = "0.9" image = { version = "=0.25.2", default-features = false, features = ["png", "jpeg", "webp"] } resvg = "0.44" rust-i18n = "3" -wayland-protocols = { version = "0.32", features = ["client", "unstable"] } +wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] } wayland-egl = "0.32" khronos-egl = { version = "6", features = ["dynamic"] } glow = "0.17" diff --git a/src/app.rs b/src/app.rs index e3914ae..b2f24a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,22 @@ pub use calloop::channel::Sender as ChannelSender; use crate::widget::Element; +/// Wayland `ext-foreign-toplevel-list-v1` event delivered to apps via +/// [`App::on_toplevel_event`]. `Opened` fires after the compositor +/// commits the first `done` for a new handle (so `app_id` is the value +/// in effect at that point — the protocol allows the compositor to +/// re-commit later, but most don't). `Closed` fires when the +/// compositor sends `closed` and the runtime is about to destroy the +/// handle proxy. Both carry the same `id`, the Wayland protocol id of +/// the handle, unique within the session and stable for the handle's +/// lifetime. +#[ derive( Debug, Clone ) ] +pub enum ToplevelEvent +{ + Opened { id: u32, app_id: String }, + Closed { id: u32 }, +} + /// Wayland shell mode for the application surface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShellMode @@ -405,6 +421,21 @@ pub trait App: 'static /// Called when a text-input widget gains or loses focus. fn on_text_input_focused( &mut self, _active: bool ) {} + /// Translate a Wayland `ext-foreign-toplevel-list-v1` event into an + /// app message. The runtime binds the protocol globally and forwards + /// every open / close it sees here; apps that ignore window state + /// leave the default (return `None`) and pay nothing. + /// + /// The `id` field is the Wayland object id of the toplevel handle — + /// unique within the session, stable for the handle's lifetime, the + /// same value coming in on `Closed` as the matching `Opened`. + /// `app_id` on `Opened` is the toplevel's `app_id` event (may be + /// empty if the compositor never sent one before the first `done`). + fn on_toplevel_event( &self, _event: ToplevelEvent ) -> Option + { + None + } + /// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId). /// The app must return `None` after the first call. fn take_focus_request( &mut self ) -> Option { None } diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 9624fed..c7ea1b4 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -619,6 +619,16 @@ pub struct AppData pub current_cursor_shape: Option, pub text_input_manager: Option, pub text_input: Option, + /// Client-side handle to `ext-foreign-toplevel-list-v1`. Holds the + /// global proxy plus the live list of open toplevels; SCTK fans + /// events through this object into our `ForeignToplevelListHandler` + /// impl in `handlers.rs`, which then routes them to + /// `App::on_toplevel_event`. Present on every session, even when + /// the compositor does not advertise the global — internally the + /// list holds a `GlobalProxy` that simply yields no toplevels in + /// that case. + pub foreign_toplevel_list: + smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList, pub shift_pressed: bool, pub ctrl_pressed: bool, /// Calloop handle for inserting timers / channels. Used by the diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index edadc16..6993df0 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -4,9 +4,11 @@ use smithay_client_toolkit:: { compositor::CompositorHandler, - delegate_compositor, 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_compositor, 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, + foreign_toplevel_list::{ ForeignToplevelList, ForeignToplevelListHandler }, output::{ OutputHandler, OutputState }, registry::{ ProvidesRegistryState, RegistryState }, registry_handlers, @@ -20,6 +22,7 @@ use smithay_client_toolkit:: }, shm::{ Shm, ShmHandler }, }; +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:: @@ -29,7 +32,7 @@ use smithay_client_toolkit::reexports::client:: wl_surface::WlSurface, wl_seat::WlSeat, }, - Connection, Dispatch, QueueHandle, + Connection, Dispatch, Proxy, QueueHandle, }; use wayland_protocols::wp::text_input::zv3::client:: { @@ -37,7 +40,7 @@ use wayland_protocols::wp::text_input::zv3::client:: zwp_text_input_v3::{ self, ZwpTextInputV3 }, }; -use crate::app::App; +use crate::app::{ App, ToplevelEvent }; use super::app_data::AppData; impl CompositorHandler for AppData @@ -382,6 +385,7 @@ delegate_layer!( @ AppData ); delegate_xdg_shell!( @ AppData ); delegate_xdg_window!( @ AppData ); delegate_xdg_popup!( @ AppData ); +delegate_foreign_toplevel_list!( @ AppData ); delegate_registry!( @ AppData ); impl ProvidesRegistryState for AppData @@ -393,6 +397,84 @@ impl ProvidesRegistryState for AppData 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`. +impl ForeignToplevelListHandler for AppData +{ + fn foreign_toplevel_list_state( &mut self ) -> &mut ForeignToplevelList + { + &mut self.foreign_toplevel_list + } + + fn new_toplevel( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + handle: ExtForeignToplevelHandleV1, + ) + { + let id = handle.id().protocol_id(); + let app_id = self.foreign_toplevel_list.info( &handle ) + .map( |i| i.app_id ) + .unwrap_or_default(); + 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, + 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 = self.foreign_toplevel_list.info( &handle ) + .map( |i| i.app_id ) + .unwrap_or_default(); + 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, + 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 Dispatch for AppData diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 1be2b71..b945dc6 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -250,6 +250,14 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> let text_input_manager: Option = globals.bind( &qh, 1..=1, () ).ok(); + // `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the + // bind + dispatch routing; absence of the global is fine, the inner + // `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never + // fires. The list is built unconditionally so apps that override the + // callback always see a consistent stream when the compositor does carry + // the protocol. + let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh ); + let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok(); let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 }; @@ -280,6 +288,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> current_cursor_shape: None, text_input_manager, text_input: None, + foreign_toplevel_list, shift_pressed: false, ctrl_pressed: false, loop_handle: event_loop.handle(), diff --git a/src/lib.rs b/src/lib.rs index d00bd92..c12977b 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -167,7 +167,7 @@ pub mod core; pub use app:: { Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, - InvalidationScope, SurfaceTarget, + InvalidationScope, SurfaceTarget, ToplevelEvent, }; pub use theme:: {