From d8f09128d58b6fbec70f453b6374a3fabaca2e38 Mon Sep 17 00:00:00 2001 From: id3v1669 Date: Sun, 20 Sep 2026 00:57:35 +0800 Subject: [PATCH 1/2] feat: add window controls and typed cursors --- exwlshellev/README.md | 4 +- exwlshellev/examples/simpleshell.rs | 5 +- exwlshellev/src/events.rs | 35 ++- exwlshellev/src/lib.rs | 285 +++++++++++++++++++---- exwlshellev/src/seat.rs | 3 +- exwlshellev/src/strtoshape.rs | 86 ------- iced_exwlshell/src/conversion.rs | 49 ++-- iced_exwlshell/src/event.rs | 4 + iced_exwlshell/src/multi_window.rs | 185 ++++++++++++++- iced_exwlshell/src/multi_window/state.rs | 17 ++ 10 files changed, 503 insertions(+), 170 deletions(-) delete mode 100644 exwlshellev/src/strtoshape.rs diff --git a/exwlshellev/README.md b/exwlshellev/README.md index 51e13b2f..93790d05 100644 --- a/exwlshellev/README.md +++ b/exwlshellev/README.md @@ -79,8 +79,8 @@ fn main() { LayerShellEvent::RequestMessages(DispatchMessage::MouseButton { .. }) => ReturnData::None, LayerShellEvent::RequestMessages(DispatchMessage::MouseEnter { pointer, .. - }) => ReturnData::RequestSetCursorShape(( - "crosshair".to_owned(), + }) => ReturnData::RequestSetCursor(( + Cursor::Shape(CursorShape::Crosshair), pointer.clone(), )), LayerShellEvent::RequestMessages(DispatchMessage::MouseMotion { diff --git a/exwlshellev/examples/simpleshell.rs b/exwlshellev/examples/simpleshell.rs index 9dd6943e..d570711e 100644 --- a/exwlshellev/examples/simpleshell.rs +++ b/exwlshellev/examples/simpleshell.rs @@ -69,7 +69,10 @@ fn main() { ReturnData::None } ExWlShellEvent::RequestMessages(DispatchMessage::MouseEnter { pointer, .. }) => { - ReturnData::RequestSetCursorShape(("crosshair".to_owned(), pointer.clone())) + ReturnData::RequestSetCursor(( + Cursor::Shape(CursorShape::Crosshair), + pointer.clone(), + )) } ExWlShellEvent::RequestMessages(DispatchMessage::MouseMotion { time, diff --git a/exwlshellev/src/events.rs b/exwlshellev/src/events.rs index 1590573d..33dce1ee 100644 --- a/exwlshellev/src/events.rs +++ b/exwlshellev/src/events.rs @@ -21,6 +21,7 @@ use wayland_client::{ }, }; +use crate::CursorShape; use crate::size::{LayerSize, PixelSize}; use crate::{blur::BlurOption, id, xkb_keyboard::KeyEvent}; @@ -172,6 +173,19 @@ pub struct NewXdgWindowSettings { pub client_side_decorations: bool, } +/// Window state reported by `xdg_toplevel::configure`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ToplevelState { + /// The surface is maximized. + pub maximized: bool, + /// The surface is fullscreen. + pub fullscreen: bool, + /// At least one edge is tiled against another surface or the output. + pub tiled: bool, + /// The compositor considers this surface active. + pub activated: bool, +} + /// input panel settings to create a new input panel surface #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewInputPanelSettings { @@ -201,6 +215,15 @@ impl Default for NewLayerShellSettings { } } +/// A standard cursor shape or a named cursor from the current theme. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Cursor { + /// Use the [cursor-shape](https://wayland.app/protocols/cursor-shape-v1#wp_cursor_shape_device_v1:enum:shape) protocol, with the matching theme cursor as a fallback. + Shape(CursorShape), + /// Load a cursor by its exact Xcursor name, even when the cursor-shape protocol is available. + ThemeName(String), +} + /// the return data /// Note: when event is RequestBuffer, you must return WlBuffer /// Note: when receive InitRequest, you can request to bind extra wayland-protocols. this time you @@ -210,8 +233,8 @@ impl Default for NewLayerShellSettings { /// /// When send RequestExit, it will tell the event to finish. /// -/// When send RequestSetCursorShape, you can set current pointer shape. please take -/// [cursor-shape](https://wayland.app/protocols/cursor-shape-v1#wp_cursor_shape_device_v1:enum:shape) as reference. +/// Use `RequestSetCursor` with [`Cursor::Shape`] for standard shapes or [`Cursor::ThemeName`] for +/// an exact cursor name from the theme. /// /// None means nothing will happened, no request, and no return data #[derive(Debug, PartialEq, Eq)] @@ -224,7 +247,7 @@ pub enum ReturnData { RequestCompositor, RedrawAllRequest, RedrawIndexRequest(Id), - RequestSetCursorShape((String, WlPointer)), + RequestSetCursor((Cursor, WlPointer)), NewLayerShell((NewLayerShellSettings, id::Id, Option)), NewPopUp((NewPopUpSettings, id::Id, Option)), PopUpReposition((PopUpRepositionSettings, id::Id)), @@ -365,6 +388,7 @@ pub(crate) enum DispatchMessageInner { Locked, LockFinished, Ime(Ime), + ToplevelStateChanged(ToplevelState), } /// This tell the DispatchMessage by dispatch @@ -473,6 +497,8 @@ pub enum DispatchMessage { LockDenied, LockFinished, Closed, + /// The compositor changed the state of an xdg toplevel window. + ToplevelStateChanged(ToplevelState), } impl From for DispatchMessage { @@ -584,6 +610,9 @@ impl From for DispatchMessage { DispatchMessageInner::OutputChanged(output) => DispatchMessage::OutputChanged(output), DispatchMessageInner::Locked => DispatchMessage::Locked, DispatchMessageInner::LockFinished => DispatchMessage::LockFinished, + DispatchMessageInner::ToplevelStateChanged(state) => { + DispatchMessage::ToplevelStateChanged(state) + } } } } diff --git a/exwlshellev/src/lib.rs b/exwlshellev/src/lib.rs index c63d29cb..738b6c58 100644 --- a/exwlshellev/src/lib.rs +++ b/exwlshellev/src/lib.rs @@ -58,8 +58,8 @@ //! ExWlShellEvent::RequestMessages(DispatchMessage::MouseButton { .. }) => ReturnData::None, //! ExWlShellEvent::RequestMessages(DispatchMessage::MouseEnter { //! pointer, .. -//! }) => ReturnData::RequestSetCursorShape(( -//! "crosshair".to_owned(), +//! }) => ReturnData::RequestSetCursor(( +//! Cursor::Shape(CursorShape::Crosshair), //! pointer.clone(), //! )), //! ExWlShellEvent::RequestMessages(DispatchMessage::MouseMotion { @@ -105,6 +105,7 @@ pub use events::NewInputPanelSettings; pub use events::NewLayerShellSettings; pub use events::NewXdgWindowSettings; pub use events::OutputOption; +pub use events::ToplevelState; pub use events::{NewPopUpSettings, PopUpRepositionSettings, PopupPlacement}; pub use sctk::output::OutputInfo; pub use waycrate_xkbkeycode::keyboard; @@ -114,7 +115,6 @@ pub mod dpi; mod events; mod seat; mod size; -mod strtoshape; use events::DispatchMessageInner; use size::warn_if_exclusive_zone_ignored; @@ -122,9 +122,8 @@ pub use size::{Extent, LayerSize, PixelSize}; pub mod id; -pub use events::{AxisScroll, DispatchMessage, ExWlShellEvent, Ime, ReturnData}; - -use strtoshape::str_to_shape; +pub use events::{AxisScroll, Cursor, DispatchMessage, ExWlShellEvent, Ime, ReturnData}; +pub use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as CursorShape; use waycrate_xkbkeycode::xkb_keyboard::ElementState; use waycrate_xkbkeycode::xkb_keyboard::RepeatInfo; @@ -220,6 +219,7 @@ use calloop::{ timer::{TimeoutAction, Timer}, }; use calloop_wayland_source::WaylandSource; +use std::borrow::Cow; use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; @@ -287,7 +287,6 @@ pub mod reexport { }; } pub mod wp_cursor_shape_device_v1 { - pub use crate::strtoshape::ShapeName; pub use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; } pub mod xdg_toplevel { @@ -449,6 +448,7 @@ impl WindowStateUnitBuilder { configured, blur_option: BlurOption::None, pending_reposition: None, + toplevel_state: ToplevelState::default(), effect: None, // Unknown why it is 120 scale: 120, @@ -564,6 +564,9 @@ pub struct WindowStateUnit { /// Only meaningful for PopUp pending_reposition: Option, + /// Last configure state. Only used for `XdgTopLevel` windows. + toplevel_state: ToplevelState, + scale: u32, request_flag: WindowStateUnitRequestFlag, present_available_state: PresentAvailableState, @@ -841,6 +844,55 @@ impl WindowStateUnit { } } + /// State reported by the last `xdg_toplevel::configure` event. + pub fn toplevel_state(&self) -> ToplevelState { + self.toplevel_state + } + + pub fn is_maximized(&self) -> bool { + self.toplevel_state.maximized + } + + pub fn is_fullscreen(&self) -> bool { + self.toplevel_state.fullscreen + } + + /// Request compositor to maximize or unmaximize this toplevel. + pub fn set_maximized(&self, maximized: bool) { + let Some(toplevel) = self.shell.top_level() else { + return; + }; + if maximized { + toplevel.set_maximized(); + } else { + toplevel.unset_maximized(); + } + } + + /// Request compositor to minimize this xdg toplevel. + pub fn set_minimized(&self) { + let Some(toplevel) = self.shell.top_level() else { + return; + }; + toplevel.set_minimized(); + } + + /// Request compositor to start an interactive move of this xdg toplevel. + pub fn start_move(&self, seat: &WlSeat, serial: u32) { + let Some(toplevel) = self.shell.top_level() else { + return; + }; + toplevel._move(seat, serial); + } + + /// Request compositor to show the window menu for this xdg toplevel. + pub fn show_window_menu(&self, seat: &WlSeat, serial: u32, x: i32, y: i32) { + let Some(toplevel) = self.shell.top_level() else { + return; + }; + toplevel.show_window_menu(seat, serial, x, y); + } + /// you can use this function to set a binding data. the message passed back contain /// a index, you can use that to get the unit. It will be very useful, because you can /// use the binding data to operate the file binding to the buffer. you can take @@ -1106,7 +1158,7 @@ pub struct WindowState { return_data: Vec>, finger_locations: HashMap, enter_serial: Option, - button_serial: Option, + popup_grab_serial: Option, start_mode: StartMode, init_finished: bool, @@ -1127,9 +1179,14 @@ impl WindowState { self.return_data.push(data); } + /// Read the latest button press or touch down serial without consuming it. + pub fn popup_grab_serial(&self) -> Option { + self.popup_grab_serial + } + /// Take the serial to use for the next popup grab, consuming it. pub fn take_popup_grab_serial(&mut self) -> Option { - self.button_serial.take().or(self.enter_serial) + self.popup_grab_serial.take() } /// Compute the minimum dispatch timeout across all window units. @@ -1667,7 +1724,7 @@ impl Default for WindowState { return_data: Vec::new(), finger_locations: HashMap::new(), enter_serial: None, - button_serial: None, + popup_grab_serial: None, start_mode: StartMode::Active, init_finished: false, @@ -1850,6 +1907,48 @@ impl WindowState { .map(WindowStateUnit::request_close); } + /// Request compositor to move window `id` with the pointer. + pub fn request_move(&self, id: id::Id, serial: u32) { + let Some(seat) = self.seat_back.as_ref() else { + log::warn!(target: "exwlshellev", "no seat, cannot move {id:?}"); + return; + }; + if let Some(unit) = self.get_unit_with_id(id) { + unit.start_move(seat, serial); + } + } + + /// Request compositor to maximize or unmaximize window `id`. + pub fn request_maximized(&self, id: id::Id, maximized: bool) { + if let Some(unit) = self.get_unit_with_id(id) { + unit.set_maximized(maximized); + } + } + + /// Request compositor to minimize window `id`. + pub fn request_minimized(&self, id: id::Id) { + if let Some(unit) = self.get_unit_with_id(id) { + unit.set_minimized(); + } + } + + /// Request compositor to show the menu for window `id` at the given surface coordinates. + pub fn request_show_window_menu(&self, id: id::Id, serial: u32, (x, y): (i32, i32)) { + let Some(seat) = self.seat_back.as_ref() else { + log::warn!(target: "exwlshellev", "no seat, cannot show the window menu for {id:?}"); + return; + }; + if let Some(unit) = self.get_unit_with_id(id) { + unit.show_window_menu(seat, serial, x, y); + } + } + + /// State from the last `xdg_toplevel::configure` event for window `id`. + pub fn toplevel_state(&self, id: id::Id) -> Option { + self.get_unit_with_id(id) + .map(WindowStateUnit::toplevel_state) + } + pub fn get_binding_mut(&mut self, id: id::Id) -> Option<&mut T> { self.get_mut_unit_with_id(id) .and_then(WindowStateUnit::get_binding_mut) @@ -2010,6 +2109,27 @@ impl Dispatch for WindowState< } } +fn toplevel_state_from_configure(states: &[u8]) -> ToplevelState { + let mut toplevel_state = ToplevelState::default(); + for raw in states.as_chunks::<4>().0 { + let raw = u32::from_ne_bytes([raw[0], raw[1], raw[2], raw[3]]); + let Ok(state) = xdg_toplevel::State::try_from(raw) else { + continue; + }; + match state { + xdg_toplevel::State::Maximized => toplevel_state.maximized = true, + xdg_toplevel::State::Fullscreen => toplevel_state.fullscreen = true, + xdg_toplevel::State::Activated => toplevel_state.activated = true, + xdg_toplevel::State::TiledLeft + | xdg_toplevel::State::TiledRight + | xdg_toplevel::State::TiledTop + | xdg_toplevel::State::TiledBottom => toplevel_state.tiled = true, + _ => {} + } + } + toplevel_state +} + impl Dispatch for WindowState { fn event( state: &mut Self, @@ -2021,7 +2141,11 @@ impl Dispatch for WindowState { ) { let unit_index = state.units.iter().position(|unit| unit.shell == *surface); match event { - xdg_toplevel::Event::Configure { width, height, .. } => { + xdg_toplevel::Event::Configure { + width, + height, + states, + } => { let Some(unit_index) = unit_index else { return; }; @@ -2029,6 +2153,16 @@ impl Dispatch for WindowState { state.units[unit_index].size = (width as u32, height as u32); } + let toplevel_state = toplevel_state_from_configure(&states); + if state.units[unit_index].toplevel_state != toplevel_state { + state.units[unit_index].toplevel_state = toplevel_state; + let id = state.units[unit_index].id; + state.message.push(( + Some(id), + DispatchMessageInner::ToplevelStateChanged(toplevel_state), + )); + } + state.units[unit_index].request_refresh(RefreshRequest::NextFrame); } xdg_toplevel::Event::Close => { @@ -2468,7 +2602,7 @@ impl WindowState { self.wmbase = Some(wmbase); let cursor_manager = globals - .bind::(&qh, 1..=1, ()) + .bind::(&qh, 1..=2, ()) .ok(); let viewporter = globals.bind::(&qh, 1..=1, ()).ok(); @@ -2734,7 +2868,7 @@ impl WindowState { qh: qh.clone(), connection: connection.clone(), shm: shm.clone(), - wmcompositer: wmcompositer.clone(), + cursor_surface: wmcompositer.create_surface(&qh, ()), }; while !matches!(init_event, Some(ReturnData::None)) { @@ -3137,11 +3271,11 @@ impl WindowState { } LockLifecycle::Unlocked => {} }, - ReturnData::RequestSetCursorShape((shape_name, pointer)) => { + ReturnData::RequestSetCursor((cursor, pointer)) => { let Some(serial) = window_state.enter_serial else { continue; }; - set_cursor_shape(&cursor_update_context, shape_name, pointer, serial); + set_cursor(&cursor_update_context, cursor, pointer, serial); } ReturnData::NewLayerShell(( NewLayerShellSettings { @@ -3278,10 +3412,12 @@ impl WindowState { &qh, (), ), + Shell::XdgTopLevel((_, parent_xdg_surface, _)) => wl_xdg_surface + .get_popup(Some(parent_xdg_surface), &positioner, &qh, ()), _ => { log::warn!( target: "exwlshellev", - "popup parent {:?} is neither a layer surface nor a popup; skipping popup creation", + "cannot create popup: parent {:?} must be a layer surface, an xdg_toplevel or a popup", id ); positioner.destroy(); @@ -3822,54 +3958,105 @@ fn build_positioner( } fn get_cursor_buffer( - shape: &str, + name: &str, connection: &Connection, shm: &WlShm, ) -> Option { let mut cursor_theme = CursorTheme::load(connection, shm.clone(), 23).ok()?; - let cursor = cursor_theme.get_cursor(shape); - Some(cursor?[0].clone()) + let cursor = cursor_theme.get_cursor(name)?; + Some(cursor[0].clone()) } -/// avoid too_many_arguments alert in `set_cursor_shape` struct CursorUpdateContext { cursor_manager: Option, qh: QueueHandle>, connection: Connection, shm: WlShm, - wmcompositer: WlCompositor, + cursor_surface: WlSurface, } -fn set_cursor_shape( +fn set_cursor( context: &CursorUpdateContext, - shape_name: String, + cursor: Cursor, pointer: WlPointer, serial: u32, ) { - if let Some(cursor_manager) = &context.cursor_manager { - let Some(shape) = str_to_shape(&shape_name) else { - log::error!("Not supported shape"); - return; - }; - let device = cursor_manager.get_pointer(&pointer, &context.qh, ()); - device.set_shape(serial, shape); - device.destroy(); - } else { - let Some(cursor_buffer) = get_cursor_buffer(&shape_name, &context.connection, &context.shm) - else { - log::error!("Cannot find cursor {shape_name}"); - return; - }; - let cursor_surface = context.wmcompositer.create_surface(&context.qh, ()); - cursor_surface.attach(Some(&cursor_buffer), 0, 0); - // and create a surface. if two or more, - let (hotspot_x, hotspot_y) = cursor_buffer.hotspot(); - pointer.set_cursor( - serial, - Some(&cursor_surface), - hotspot_x as i32, - hotspot_y as i32, - ); - cursor_surface.commit(); - } + let theme_name = match cursor { + Cursor::Shape(shape) => { + let name = match shape { + CursorShape::Default => "default", + CursorShape::ContextMenu => "context-menu", + CursorShape::Help => "help", + CursorShape::Pointer => "pointer", + CursorShape::Progress => "progress", + CursorShape::Wait => "wait", + CursorShape::Cell => "cell", + CursorShape::Crosshair => "crosshair", + CursorShape::Text => "text", + CursorShape::VerticalText => "vertical-text", + CursorShape::Alias => "alias", + CursorShape::Copy => "copy", + CursorShape::Move => "move", + CursorShape::NoDrop => "no-drop", + CursorShape::NotAllowed => "not-allowed", + CursorShape::Grab => "grab", + CursorShape::Grabbing => "grabbing", + CursorShape::EResize => "e-resize", + CursorShape::NResize => "n-resize", + CursorShape::NeResize => "ne-resize", + CursorShape::EwResize => "ew-resize", + CursorShape::NwResize => "nw-resize", + CursorShape::SResize => "s-resize", + CursorShape::SeResize => "se-resize", + CursorShape::SwResize => "sw-resize", + CursorShape::WResize => "w-resize", + CursorShape::NsResize => "ns-resize", + CursorShape::NeswResize => "nesw-resize", + CursorShape::NwseResize => "nwse-resize", + CursorShape::ColResize => "col-resize", + CursorShape::RowResize => "row-resize", + CursorShape::AllScroll => "all-scroll", + CursorShape::ZoomIn => "zoom-in", + CursorShape::ZoomOut => "zoom-out", + CursorShape::DndAsk => "dnd-ask", + CursorShape::AllResize => "all-resize", + _ => { + log::warn!("Unsupported cursor shape: {shape:?}"); + return; + } + }; + let required_version = if matches!(shape, CursorShape::DndAsk | CursorShape::AllResize) + { + 2 + } else { + 1 + }; + if let Some(manager) = &context.cursor_manager + && manager.version() >= required_version + { + let device = manager.get_pointer(&pointer, &context.qh, ()); + device.set_shape(serial, shape); + device.destroy(); + return; + } + Cow::Borrowed(name) + } + Cursor::ThemeName(name) => Cow::Owned(name), + }; + let Some(cursor_buffer) = get_cursor_buffer(&theme_name, &context.connection, &context.shm) + else { + log::error!("Cannot find cursor {theme_name}"); + return; + }; + let cursor_surface = &context.cursor_surface; + cursor_surface.attach(Some(&cursor_buffer), 0, 0); + cursor_surface.damage(0, 0, i32::MAX, i32::MAX); + let (hotspot_x, hotspot_y) = cursor_buffer.hotspot(); + pointer.set_cursor( + serial, + Some(cursor_surface), + hotspot_x as i32, + hotspot_y as i32, + ); + cursor_surface.commit(); } diff --git a/exwlshellev/src/seat.rs b/exwlshellev/src/seat.rs index 92722f17..3ff798a3 100644 --- a/exwlshellev/src/seat.rs +++ b/exwlshellev/src/seat.rs @@ -435,6 +435,7 @@ impl Dispatch for WindowState { x, y, } => { + state.popup_grab_serial = Some(serial); state.finger_locations.insert(id, (x, y)); let surface_id = state.get_id_from_surface(&surface); state @@ -663,7 +664,7 @@ impl Dispatch for WindowState { time, } => { if matches!(btnstate, WEnum::Value(wl_pointer::ButtonState::Pressed)) { - state.button_serial = Some(serial); + state.popup_grab_serial = Some(serial); } if let Some(mouse_surface) = mouse_surface.cloned() { state.update_active_output(&mouse_surface); diff --git a/exwlshellev/src/strtoshape.rs b/exwlshellev/src/strtoshape.rs deleted file mode 100644 index ddb4c54e..00000000 --- a/exwlshellev/src/strtoshape.rs +++ /dev/null @@ -1,86 +0,0 @@ -use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; - -pub(crate) fn str_to_shape(shape_name: &str) -> Option { - match shape_name { - "default" => Some(Shape::Default), - "contenx_menu" => Some(Shape::ContextMenu), - "help" => Some(Shape::Help), - "pointer" => Some(Shape::Pointer), - "progress" => Some(Shape::Progress), - "wait" => Some(Shape::Wait), - "cell" => Some(Shape::Cell), - "crosshair" => Some(Shape::Crosshair), - "text" => Some(Shape::Text), - "vertical_text" => Some(Shape::VerticalText), - "alias" => Some(Shape::Alias), - "copy" => Some(Shape::Copy), - "move" => Some(Shape::Move), - "no_drop" => Some(Shape::NoDrop), - "not_allowed" => Some(Shape::NotAllowed), - "grab" => Some(Shape::Grab), - "grabbing" => Some(Shape::Grabbing), - "e_resize" => Some(Shape::EResize), - "n_resize" => Some(Shape::NResize), - "ne_resize" => Some(Shape::NeResize), - "nw_resize" => Some(Shape::NwResize), - "s_resize" => Some(Shape::SResize), - "se_resize" => Some(Shape::SeResize), - "sw_resize" => Some(Shape::SwResize), - "w_resize" => Some(Shape::WResize), - "ew_resize" => Some(Shape::EwResize), - "ns_resize" => Some(Shape::NsResize), - "nesw_resize" => Some(Shape::NeswResize), - "nwse_resize" => Some(Shape::NwseResize), - "col_resize" => Some(Shape::ColResize), - "row_resize" => Some(Shape::RowResize), - "all_scroll" => Some(Shape::AllScroll), - "zoom_in" => Some(Shape::ZoomIn), - "zoom_out" => Some(Shape::ZoomOut), - _ => None, - } -} - -pub trait ShapeName { - fn name(&self) -> &str; -} - -impl ShapeName for Shape { - fn name(&self) -> &str { - match self { - Self::Default => "default", - Self::ContextMenu => "contenx_menu", - Self::Help => "help", - Self::Pointer => "pointer", - Self::Progress => "progress", - Self::Wait => "wait", - Self::Cell => "cell", - Self::Crosshair => "crosshair", - Self::Text => "text", - Self::VerticalText => "vertical_text", - Self::Alias => "alias", - Self::Copy => "copy", - Self::Move => "move", - Self::NoDrop => "no_drop", - Self::NotAllowed => "not_allowed", - Self::Grab => "grab", - Self::Grabbing => "grabbing", - Self::EResize => "e_resize", - Self::NResize => "n_resize", - Self::EwResize => "ew_resize", - Self::NwResize => "nw_resize", - Self::SResize => "s_resize", - Self::SeResize => "se_resize", - Self::SwResize => "sw_resize", - Self::WResize => "w_resize", - Self::NsResize => "ns_resize", - Self::NeswResize => "nesw_resize", - Self::NwseResize => "nesw_resize", - Self::ColResize => "col_resize", - Self::RowResize => "row_resize", - Self::AllScroll => "all_scroll", - Self::ZoomIn => "zoom_in", - Self::ZoomOut => "zoom_out", - _ => "default", - } - } -} diff --git a/iced_exwlshell/src/conversion.rs b/iced_exwlshell/src/conversion.rs index fdaf1505..cb51bbc4 100644 --- a/iced_exwlshell/src/conversion.rs +++ b/iced_exwlshell/src/conversion.rs @@ -167,30 +167,35 @@ pub fn ime_purpose(purpose: input_method::Purpose) -> exwlshellev::ImePurpose { } } -pub(crate) fn mouse_interaction(interaction: mouse::Interaction) -> String { - use exwlshellev::reexport::wp_cursor_shape_device_v1::{Shape, ShapeName}; +pub(crate) fn mouse_interaction(interaction: mouse::Interaction) -> exwlshellev::CursorShape { + use exwlshellev::CursorShape as Shape; use mouse::Interaction; match interaction { - Interaction::None => Shape::Default.name().to_owned(), - Interaction::Idle => Shape::Wait.name().to_owned(), - Interaction::Wait => Shape::Wait.name().to_owned(), - Interaction::Pointer => Shape::Pointer.name().to_owned(), - Interaction::Grab => Shape::Grab.name().to_owned(), - Interaction::Text => Shape::Text.name().to_owned(), - Interaction::ZoomIn => Shape::ZoomIn.name().to_owned(), - Interaction::Grabbing => Shape::Grabbing.name().to_owned(), - Interaction::Crosshair => Shape::Crosshair.name().to_owned(), - Interaction::NotAllowed => Shape::NotAllowed.name().to_owned(), - Interaction::ResizingVertically => Shape::NsResize.name().to_owned(), - Interaction::ResizingHorizontally => Shape::EwResize.name().to_owned(), - Interaction::Cell => Shape::Cell.name().to_owned(), - Interaction::Move => Shape::Move.name().to_owned(), - Interaction::Copy => Shape::Copy.name().to_owned(), - Interaction::Help => Shape::Help.name().to_owned(), - Interaction::ZoomOut => Shape::ZoomOut.name().to_owned(), - Interaction::ResizingDiagonallyUp => Shape::NwseResize.name().to_owned(), - Interaction::ResizingDiagonallyDown => Shape::NwseResize.name().to_owned(), - _ => Shape::Default.name().to_owned(), + Interaction::None | Interaction::Idle | Interaction::Hidden => Shape::Default, + Interaction::ContextMenu => Shape::ContextMenu, + Interaction::Help => Shape::Help, + Interaction::Pointer => Shape::Pointer, + Interaction::Progress => Shape::Progress, + Interaction::Wait => Shape::Wait, + Interaction::Cell => Shape::Cell, + Interaction::Crosshair => Shape::Crosshair, + Interaction::Text => Shape::Text, + Interaction::Alias => Shape::Alias, + Interaction::Copy => Shape::Copy, + Interaction::Move => Shape::Move, + Interaction::NoDrop => Shape::NoDrop, + Interaction::NotAllowed => Shape::NotAllowed, + Interaction::Grab => Shape::Grab, + Interaction::Grabbing => Shape::Grabbing, + Interaction::ResizingHorizontally => Shape::EwResize, + Interaction::ResizingVertically => Shape::NsResize, + Interaction::ResizingDiagonallyUp => Shape::NeswResize, + Interaction::ResizingDiagonallyDown => Shape::NwseResize, + Interaction::ResizingColumn => Shape::ColResize, + Interaction::ResizingRow => Shape::RowResize, + Interaction::AllScroll => Shape::AllScroll, + Interaction::ZoomIn => Shape::ZoomIn, + Interaction::ZoomOut => Shape::ZoomOut, } } diff --git a/iced_exwlshell/src/event.rs b/iced_exwlshell/src/event.rs index a7fb93d5..5b650784 100644 --- a/iced_exwlshell/src/event.rs +++ b/iced_exwlshell/src/event.rs @@ -108,6 +108,7 @@ pub enum WindowEvent { Locked, LockDenied, LockFinished, + ToplevelStateChanged(exwlshellev::ToplevelState), } #[derive(Debug)] @@ -218,6 +219,9 @@ impl WindowEvent { DispatchMessage::Locked => WindowEvent::Locked, DispatchMessage::LockDenied => WindowEvent::LockDenied, DispatchMessage::LockFinished => WindowEvent::LockFinished, + DispatchMessage::ToplevelStateChanged(state) => { + WindowEvent::ToplevelStateChanged(*state) + } } } } diff --git a/iced_exwlshell/src/multi_window.rs b/iced_exwlshell/src/multi_window.rs index b59bb399..67055ccc 100644 --- a/iced_exwlshell/src/multi_window.rs +++ b/iced_exwlshell/src/multi_window.rs @@ -17,11 +17,11 @@ use crate::{ settings::Settings, }; use exwlshellev::{ - DisplayWrapper, ExWlShellEvent, NewPopUpSettings, PopUpRepositionSettings, PopupPlacement, - RefreshRequest, ReturnData, WindowState, WindowWrapper, + DispatchMessage, DisplayWrapper, ExWlShellEvent, NewPopUpSettings, PopUpRepositionSettings, + PopupPlacement, RefreshRequest, ReturnData, WindowState, WindowWrapper, id::Id as LayerShellId, reexport::{ - wayland_client::{WlCompositor, WlRegion}, + wayland_client::{ButtonState, WEnum, WlCompositor, WlRegion}, zwp_virtual_keyboard_v1, }, }; @@ -228,6 +228,11 @@ where } } ExWlShellEvent::RequestMessages(message) => { + if let (ContextState::Context(context), Some(serial)) = + (&mut context_state, action_serial(message)) + { + context.action_serial = Some(serial); + } let window_event = ExwlShellWindowEvent::from_dispatch(message, ev); waiting_layer_shell_events .push_back((layer_shell_id, IcedWlShellEvent::Window(window_event))); @@ -277,6 +282,19 @@ enum ContextState { Context(Context), } +/// Input serial for xdg_shell move, window menu and popup grab requests. +fn action_serial(message: &DispatchMessage) -> Option { + match message { + DispatchMessage::MouseButton { + state: WEnum::Value(ButtonState::Pressed), + serial, + .. + } + | DispatchMessage::TouchDown { serial, .. } => Some(*serial), + _ => None, + } +} + struct Context where P: IcedProgram + 'static, @@ -298,6 +316,8 @@ where wl_input_region: Option, user_interfaces: UserInterfaces

, waiting_layer_shell_actions: Vec<(Option, ExwlShellCustomAction)>, + action_serial: Option, + pending_window_controls: Vec<(IcedId, PendingWindowControl)>, iced_events: Vec<(IcedId, IcedEvent)>, messages: Vec, proxy: IcedProxy>, @@ -342,6 +362,8 @@ where wl_input_region: Default::default(), user_interfaces: UserInterfaces::new(application), waiting_layer_shell_actions: Default::default(), + action_serial: None, + pending_window_controls: Default::default(), iced_events: Default::default(), messages: Default::default(), proxy, @@ -448,6 +470,7 @@ where return; }; let unit_id = ex_wlshell_window.id(); + let toplevel_state = ex_wlshell_window.toplevel_state(); let (width, height) = ex_wlshell_window.get_size(); let scale_float = ex_wlshell_window.scale_float(); // events may not be handled after RequestRefreshWithWrapper in the same @@ -544,6 +567,14 @@ where .expect("It should have been created"), self.system_theme, ); + window.state.set_toplevel_state(toplevel_state); + let (ready, still_pending) = mem::take(&mut self.pending_window_controls) + .into_iter() + .partition(|(id, _)| *id == iced_id); + self.pending_window_controls = still_pending; + for (_, control) in ready { + apply_window_control(ev, window, control); + } iced_debug::theme_changed(|| { if is_first { @@ -720,6 +751,8 @@ where self.iced_events.retain(|(id, _)| *id != iced_id); self.waiting_layer_shell_actions .retain(|(id, _)| *id != Some(iced_id)); + self.pending_window_controls + .retain(|(id, _)| *id != iced_id); self.shell_broadcast.forget(iced_id); self.runtime .broadcast(iced_futures::subscription::Event::Interaction { @@ -813,6 +846,8 @@ where &mut self.system_theme, &mut self.runtime, ev, + self.action_serial, + &mut self.pending_window_controls, ); if should_exit { ev.append_return_data(ReturnData::RequestExit); @@ -975,7 +1010,7 @@ where let Some(parent_layer_id) = parent_layer_id else { return; }; - let grab_serial = ev.take_popup_grab_serial(); + let grab_serial = self.action_serial; let popup_settings = NewPopUpSettings { size, id: parent_layer_id, @@ -1250,8 +1285,10 @@ where // Only the window that contains the pointer can change cursor if ev.pointer_surface_id() == Some(window.id) { for pointer in ev.get_pointers() { - ev.append_return_data(ReturnData::RequestSetCursorShape(( - conversion::mouse_interaction(mouse_interaction), + ev.append_return_data(ReturnData::RequestSetCursor(( + exwlshellev::Cursor::Shape(conversion::mouse_interaction( + mouse_interaction, + )), pointer, ))); } @@ -1301,6 +1338,52 @@ pub(crate) fn update( runtime.track(recipes); } +/// Window controls that can be requested before the window is mapped. +pub(crate) enum PendingWindowControl { + Maximize(bool), + ToggleMaximize, + Minimize, +} + +fn apply_window_control( + ev: &WindowState, + window: &Window, + control: PendingWindowControl, +) where + P: IcedProgram + 'static, + C: Compositor + 'static, + P::Theme: DefaultStyle, +{ + match control { + PendingWindowControl::Maximize(maximized) => ev.request_maximized(window.id, maximized), + PendingWindowControl::ToggleMaximize => { + ev.request_maximized(window.id, !window.state.is_maximized()) + } + PendingWindowControl::Minimize => ev.request_minimized(window.id), + } +} + +/// Check whether window `id` has been requested and is still waiting to register in `window_manager`. +fn window_in_flight( + id: IcedId, + waiting_layer_shell_actions: &[(Option, ExwlShellCustomAction)], + ev: &WindowState, +) -> bool { + waiting_layer_shell_actions.iter().any(|(_, action)| { + matches!( + action, + ExwlShellCustomAction::NewBaseWindow { id: new_id, .. } + | ExwlShellCustomAction::NewLayerShell { id: new_id, .. } + | ExwlShellCustomAction::NewPopUp { id: new_id, .. } + | ExwlShellCustomAction::NewMenu { id: new_id, .. } + | ExwlShellCustomAction::NewInputPanel { id: new_id, .. } + if *new_id == id + ) + }) || ev + .get_unit_iter() + .any(|unit| unit.get_binding() == Some(&id)) +} + #[allow(clippy::too_many_arguments)] pub(crate) fn run_action( user_interfaces: &mut UserInterfaces

, @@ -1314,6 +1397,8 @@ pub(crate) fn run_action( system_theme: &mut iced_core::theme::Mode, runtime: &mut MultiRuntime, ev: &mut WindowState, + action_serial: Option, + pending_window_controls: &mut Vec<(IcedId, PendingWindowControl)>, ) where P: IcedProgram + 'static, C: Compositor + 'static, @@ -1416,6 +1501,94 @@ pub(crate) fn run_action( let _ = channel.send(window.state.wayland_scale_factor() as f32); }; } + WindowAction::Drag(id) => { + let (Some(window), Some(serial)) = (window_manager.get(id), action_serial) else { + tracing::warn!("window::drag: unknown window or no input serial for {id:?}"); + return; + }; + ev.request_move(window.id, serial); + } + WindowAction::Maximize(id, maximized) => match window_manager.get(id) { + Some(window) => { + apply_window_control(ev, window, PendingWindowControl::Maximize(maximized)) + } + None if window_in_flight(id, waiting_layer_shell_actions, ev) => { + pending_window_controls.push((id, PendingWindowControl::Maximize(maximized))) + } + None => tracing::warn!("window::maximize: unknown window {id:?}, dropping"), + }, + WindowAction::ToggleMaximize(id) => match window_manager.get(id) { + Some(window) => { + apply_window_control(ev, window, PendingWindowControl::ToggleMaximize) + } + None if window_in_flight(id, waiting_layer_shell_actions, ev) => { + pending_window_controls.push((id, PendingWindowControl::ToggleMaximize)) + } + None => tracing::warn!("window::toggle_maximize: unknown window {id:?}, dropping"), + }, + WindowAction::GetMaximized(id, channel) => { + let _ = channel.send( + window_manager + .get(id) + .is_some_and(|window| window.state.is_maximized()), + ); + } + WindowAction::Minimize(id, minimized) => { + if !minimized { + tracing::warn!( + "xdg_shell does not support window::minimize(false); ignoring request for {id:?}" + ); + } else if let Some(window) = window_manager.get(id) { + apply_window_control(ev, window, PendingWindowControl::Minimize); + } else if window_in_flight(id, waiting_layer_shell_actions, ev) { + pending_window_controls.push((id, PendingWindowControl::Minimize)); + } else { + tracing::warn!("window::minimize: unknown window {id:?}, dropping"); + } + } + WindowAction::GetMinimized(_id, channel) => { + let _ = channel.send(None); + } + WindowAction::GetMode(id, channel) => { + let mode = if window_manager + .get(id) + .is_some_and(|window| window.state.is_fullscreen()) + { + iced_core::window::Mode::Fullscreen + } else { + iced_core::window::Mode::Windowed + }; + let _ = channel.send(mode); + } + WindowAction::ShowSystemMenu(id) => { + let (Some(window), Some(serial)) = (window_manager.get(id), action_serial) else { + tracing::warn!( + "window::show_system_menu: unknown window or no input serial for {id:?}" + ); + return; + }; + // Convert the pointer position back to surface coordinates for the menu. + let scale = window.state.application_scale_factor(); + let Some(point) = window.state.mouse_position() else { + tracing::warn!("window::show_system_menu: no pointer position for {id:?}"); + return; + }; + ev.request_show_window_menu( + window.id, + serial, + ( + (point.x as f64 * scale) as i32, + (point.y as f64 * scale) as i32, + ), + ); + } + WindowAction::Run(id, f) => { + if let Some(exshell_id) = window_manager.get(id).map(|window| window.id) + && let Some(unit) = ev.get_unit_with_id(exshell_id) + { + f(unit); + } + } _ => {} }, Action::System(action) => match action { diff --git a/iced_exwlshell/src/multi_window/state.rs b/iced_exwlshell/src/multi_window/state.rs index 635b7b1b..c0e74c8e 100644 --- a/iced_exwlshell/src/multi_window/state.rs +++ b/iced_exwlshell/src/multi_window/state.rs @@ -28,6 +28,7 @@ where modifiers: ModifiersState, wpviewport: WpViewport, toplevel: Option, + toplevel_state: exwlshellev::ToplevelState, title: String, } @@ -83,6 +84,7 @@ where modifiers: ModifiersState::default(), wpviewport, toplevel, + toplevel_state: exwlshellev::ToplevelState::default(), title: "".to_owned(), } } @@ -149,6 +151,18 @@ where self.mouse_position.as_ref() } + pub fn is_maximized(&self) -> bool { + self.toplevel_state.maximized + } + + pub fn is_fullscreen(&self) -> bool { + self.toplevel_state.fullscreen + } + + pub(crate) fn set_toplevel_state(&mut self, toplevel_state: exwlshellev::ToplevelState) { + self.toplevel_state = toplevel_state; + } + pub fn cursor(&self) -> IcedMouse::Cursor { self.mouse_position .map(IcedMouse::Cursor::Available) @@ -180,6 +194,9 @@ where self.wayland_scale_factor = *scale_float; self.resize_viewport(); } + WindowEvent::ToplevelStateChanged(toplevel_state) => { + self.toplevel_state = *toplevel_state; + } WindowEvent::ThemeChanged(mode) => { self.default_theme = ::default(*mode); if self.theme.is_none() { From a604be98375d06ada67127c7bf0394435cea0014 Mon Sep 17 00:00:00 2001 From: id3v1669 Date: Sun, 20 Sep 2026 03:00:27 +0800 Subject: [PATCH 2/2] chore: update CHANGELOG.md --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 166e1e36..bb66a963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed (breaking) + +- Replace `RequestSetCursorShape` with `RequestSetCursor`, using `Cursor::Shape` or `Cursor::ThemeName`. Remove `ShapeName` and string-based shape parsing +- Remove the pointer-enter fallback from `take_popup_grab_serial` + +### Changed + +- Improve xdg toplevel support with compositor state tracking and window controls in `exwlshellev` and `iced_exwlshell` +- Allow xdg toplevel windows to parent popups +- Add `popup_grab_serial` alongside `take_popup_grab_serial` +- Support custom theme cursors, correct cursor mappings and fallbacks, and reuse the cursor surface + ## [0.20.1] - 2026-09-11 ### Hot Fix - Fix: iced_exdevtools cannot compiled due to the new winit-core