event_loop: client binding for ext-foreign-toplevel-list-v1

Adds an `App` callback that delivers the live list of open toplevels from the compositor — the data source a shell needs for dock running-app indicators, taskbar tiles, alt-tab and any other "what is currently running" UI. Hand-wiring the protocol binding from every shell that wants it is the kind of boilerplate ltk should absorb once: this is that move.
`Cargo.toml` adds `"staging"` to `wayland-protocols`' feature list. SCTK 0.20 already pulls staging in transitively (it carries `foreign_toplevel_list.rs` and its own dispatch helper), so this is belt-and-braces against a future ltk tree that swaps SCTK for a different client toolkit — it keeps the protocol available crate-wide even then. `src/app.rs` introduces `ToplevelEvent { Opened { id: u32, app_id: String }, Closed { id: u32 } }` and `App::on_toplevel_event( &self, ToplevelEvent ) -> Option<Self::Message>` with the default returning `None` so apps that do not care pay nothing (no allocation, no dispatch). `id` is the Wayland protocol id of the handle proxy — unique per session, stable for the handle's lifetime, the same value paired across `Opened` and `Closed`. `src/lib.rs` re-exports `ToplevelEvent` from the public prelude.
`src/event_loop/app_data.rs` grows a `pub foreign_toplevel_list: ForeignToplevelList` field. `src/event_loop/mod.rs` constructs it via `ForeignToplevelList::new( &globals, &qh )` and stores it on `AppData`. If the compositor does not advertise the global the inner `GlobalProxy` just resolves to "absent" and the list yields no toplevels — no error path needed at construction. `src/event_loop/handlers.rs` adds the `Proxy` import, `delegate_foreign_toplevel_list!( @<A: App> AppData<A> )` next to the rest, and implements `ForeignToplevelListHandler` for `AppData<A>`. The three SCTK callbacks (`new_toplevel`, `update_toplevel`, `toplevel_closed`) each pull the handle's protocol id and its currently-cached `app_id` from the list's info cache, call `self.app.on_toplevel_event( … )`, and push the returned message onto `pending_msgs` so it flows through the normal `update` cycle with the regular `invalidate_after` scoping path. `update_toplevel` re-emits `Opened` with the latest info — compositors fire this on title changes too, not just `app_id` changes, but apps whose state is keyed on `(id, app_id)` can absorb the repeat idempotently and apps that need title-change granularity can scope via `invalidate_after`.
The wire-up is generic: a shell that wants finer behaviour (focus follow, per-title indicators, multi-window grouping) can layer on top by translating the event into more specific app messages. The default app pays zero, the shell that opts in gets a real event stream without touching `smithay-client-toolkit` directly.
This commit is contained in:
2026-05-11 22:30:29 +02:00
parent 39fbafec24
commit dc781fb78d
6 changed files with 139 additions and 7 deletions

View File

@@ -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<Self::Message>
{
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<crate::types::WidgetId> { None }

View File

@@ -619,6 +619,16 @@ pub struct AppData<A: App>
pub current_cursor_shape: Option<crate::types::CursorShape>,
pub text_input_manager: Option<ZwpTextInputManagerV3>,
pub text_input: Option<ZwpTextInputV3>,
/// 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

View File

@@ -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<A: App> CompositorHandler for AppData<A>
@@ -382,6 +385,7 @@ delegate_layer!( @<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> );
impl<A: App> ProvidesRegistryState for AppData<A>
@@ -393,6 +397,84 @@ impl<A: App> ProvidesRegistryState for AppData<A>
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<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 = 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<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 = 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<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>

View File

@@ -250,6 +250,14 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
// bind + dispatch routing; absence of the global is fine, the inner
// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
// fires. The list is built unconditionally so apps that override the
// callback always see a consistent stream when the compositor does carry
// the protocol.
let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );
let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();
let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
@@ -280,6 +288,7 @@ pub( crate ) fn try_run<A: App>( 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(),

View File

@@ -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::
{