// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge; use super::app_data::AppData; use super::surface::{ SurfaceFocus, SurfaceKind }; use crate::app::App; use crate::types::{ CursorShape, Point }; /// Classify a pointer position against a surface of size `w × h` (in /// physical pixels). Returns the resize edge if the pointer is inside /// the `border`-thick grab band along any edge or corner. pub( crate ) fn resize_edge_for_pos( pos: Point, w: f32, h: f32, border: f32 ) -> Option { let on_left = pos.x < border; let on_right = pos.x > w - border; let on_top = pos.y < border; let on_bottom = pos.y > h - border; Some( match ( on_top, on_bottom, on_left, on_right ) { ( true, _, true, _ ) => ResizeEdge::TopLeft, ( true, _, _, true ) => ResizeEdge::TopRight, ( _, true, true, _ ) => ResizeEdge::BottomLeft, ( _, true, _, true ) => ResizeEdge::BottomRight, ( true, _, _, _ ) => ResizeEdge::Top, ( _, true, _, _ ) => ResizeEdge::Bottom, ( _, _, true, _ ) => ResizeEdge::Left, ( _, _, _, true ) => ResizeEdge::Right, _ => return None, } ) } /// Map an [`ResizeEdge`] to the [`CursorShape`](crate::types::CursorShape) /// to display while hovering / dragging it. pub( crate ) fn resize_edge_to_cursor( edge: ResizeEdge ) -> CursorShape { use CursorShape::*; match edge { ResizeEdge::Top => NResize, ResizeEdge::Bottom => SResize, ResizeEdge::Left => WResize, ResizeEdge::Right => EResize, ResizeEdge::TopLeft => NwResize, ResizeEdge::TopRight => NeResize, ResizeEdge::BottomLeft => SwResize, ResizeEdge::BottomRight => SeResize, _ => Default, } } impl AppData { /// Convert a public [`CursorShape`](crate::types::CursorShape) into /// the wire-protocol [`Shape`] enum the compositor expects. fn cursor_shape_to_wp( shape: CursorShape ) -> smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape { use CursorShape as C; use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape as S; match shape { C::Default => S::Default, C::ContextMenu => S::ContextMenu, C::Help => S::Help, C::Pointer => S::Pointer, C::Progress => S::Progress, C::Wait => S::Wait, C::Cell => S::Cell, C::Crosshair => S::Crosshair, C::Text => S::Text, C::VerticalText => S::VerticalText, C::Alias => S::Alias, C::Copy => S::Copy, C::Move => S::Move, C::NoDrop => S::NoDrop, C::NotAllowed => S::NotAllowed, C::Grab => S::Grab, C::Grabbing => S::Grabbing, C::EResize => S::EResize, C::NResize => S::NResize, C::NeResize => S::NeResize, C::NwResize => S::NwResize, C::SResize => S::SResize, C::SeResize => S::SeResize, C::SwResize => S::SwResize, C::WResize => S::WResize, C::EwResize => S::EwResize, C::NsResize => S::NsResize, C::NeswResize => S::NeswResize, C::NwseResize => S::NwseResize, C::ColResize => S::ColResize, C::RowResize => S::RowResize, C::AllScroll => S::AllScroll, C::ZoomIn => S::ZoomIn, C::ZoomOut => S::ZoomOut, } } /// Compute the cursor shape that should be active right now and /// push it to the compositor if it changed since the last /// dispatch. Precedence: /// /// 1. [`crate::App::cursor_override`] — application-driven busy /// state, wins over everything. /// 2. Active slider drag — `Grabbing` while the gesture machine /// holds a `dragging_slider`. /// 3. Cursor declared by the widget under the pointer /// ([`crate::widget::LaidOutWidget::cursor`]). /// 4. `Default` — pointer is over surface chrome / empty space. pub( crate ) fn dispatch_cursor_shape( &mut self, focus: SurfaceFocus ) { let target = { let app_override = self.app.cursor_override(); let dragging = self.surface( focus ).gesture.dragging_slider.is_some(); let hover_cursor = self.surface( focus ).hovered_idx .and_then( |idx| crate::tree::find_widget( &self.surface( focus ).frame.widget_rects, idx ) ) .map( |w| w.cursor ) .unwrap_or( CursorShape::Default ); let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor ); // Resize-edge hover wins over the widget cursor and over a // `Default` app override, but loses to a non-default app // override (so a "busy" / "wait" state still trumps chrome). let allow_chrome = app_override.is_none() || app_override == Some( CursorShape::Default ); if let Some( c ) = resize_cursor.filter( |_| allow_chrome ) { c } else if let Some( o ) = app_override { o } else if dragging { CursorShape::Grabbing } else { hover_cursor } }; // Skip when the compositor already shows the right shape. // `current_cursor_shape == None` means "we have not pushed // anything since the last `Enter`" — e.g. the pointer just // arrived from another client's surface, where the prior // client may have asked for a text I-beam. We MUST push // unconditionally in that case so the user does not see the // stranger cursor lingering until they hit a widget. if self.current_cursor_shape == Some( target ) { return; } if let Some( device ) = self.cursor_shape_device.as_ref() { device.set_shape( self.last_pointer_enter_serial, Self::cursor_shape_to_wp( target ) ); self.current_cursor_shape = Some( target ); } } /// Width (logical pixels) of the resize-grab band along each /// edge of an xdg-toplevel surface. 6 px is the GTK / KWin /// convention. pub( crate ) const RESIZE_BORDER_LOGICAL: f32 = 6.0; /// Return the [`ResizeEdge`] the pointer currently sits on, or /// `None` if it is not in a resize-grab band. Only fires for the /// main surface when it is an xdg-toplevel — layer-shell surfaces /// and overlays cannot be resized by the user. pub( crate ) fn resize_edge_under_pointer( &self, focus: SurfaceFocus ) -> Option { if !matches!( focus, SurfaceFocus::Main ) { return None; } if !matches!( self.main.surface, SurfaceKind::Window( _ ) ) { return None; } let sf = self.surface( focus ).scale_factor.max( 1 ) as f32; let border = Self::RESIZE_BORDER_LOGICAL * sf; let w = self.surface( focus ).physical_width() as f32; let h = self.surface( focus ).physical_height() as f32; resize_edge_for_pos( self.pointer_pos, w, h, border ) } } #[ cfg( test ) ] mod resize_edge_tests { use super::{ resize_edge_for_pos, ResizeEdge }; use crate::types::Point; const W: f32 = 800.0; const H: f32 = 600.0; const B: f32 = 6.0; fn at( x: f32, y: f32 ) -> Option { resize_edge_for_pos( Point { x, y }, W, H, B ) } #[ test ] fn middle_is_none() { assert!( at( 400.0, 300.0 ).is_none() ); } #[ test ] fn just_inside_border_is_none() { // 6 px border; (6, 6) is one pixel inside on both axes. assert!( at( 6.0, 6.0 ).is_none() ); } #[ test ] fn corners_take_precedence_over_single_edges() { assert_eq!( at( 1.0, 1.0 ), Some( ResizeEdge::TopLeft ) ); assert_eq!( at( W - 1.0, 1.0 ), Some( ResizeEdge::TopRight ) ); assert_eq!( at( 1.0, H - 1.0 ), Some( ResizeEdge::BottomLeft ) ); assert_eq!( at( W - 1.0, H - 1.0 ), Some( ResizeEdge::BottomRight ) ); } #[ test ] fn straight_edges() { assert_eq!( at( 400.0, 1.0 ), Some( ResizeEdge::Top ) ); assert_eq!( at( 400.0, H - 1.0 ), Some( ResizeEdge::Bottom ) ); assert_eq!( at( 1.0, 300.0 ), Some( ResizeEdge::Left ) ); assert_eq!( at( W - 1.0, 300.0 ), Some( ResizeEdge::Right ) ); } }