diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 8c58dcaf4..f27530feb 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -254,20 +254,17 @@ tokio = { version = "1", default-features = false, features = [ futures = { version = "0.3", default-features = false, features = ["std"] } # Native menu bar (`src/menu.rs`). winit owns the window but has no -# menu primitive, so `muda` builds the NSMenu / Win32 menu and routes -# selections through its global event channel. Gated to macOS / -# Windows: `muda`'s Linux backend needs a GTK window, but this -# binary's winit is built for the x11 / wayland backends (no GTK), so -# Linux falls back to the in-canvas File menu. `default-features = -# false` drops the Linux-only `gtk` / `libxdo` feature deps. -[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] -muda = { version = "0.17", default-features = false } - +# menu primitive, so `muda` builds the NSMenu and routes selections +# through its global event channel. macOS only — Windows and Linux +# fall back to the in-canvas File menu. `default-features = false` +# drops the Linux-only `gtk` / `libxdo` feature deps. +# # macOS-only — set the running app's Dock icon + process name at # startup so the dev (non-bundled) binary shows "OpenPencil" + the # brand icon instead of the bare executable name. Pinned to the # objc2 0.2 line winit / casement already use. [target.'cfg(target_os = "macos")'.dependencies] +muda = { version = "0.17", default-features = false } objc2 = "0.5" objc2-app-kit = { version = "0.2", default-features = false, features = [ "std", diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index af0fe4d4c..6a56e8cfd 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -846,6 +846,26 @@ impl ApplicationHandler for DesktopApp { self.viewport_height, ); if let Some(window) = self.window.as_ref() { + // Borderless (Windows / Linux) windows have no OS-provided + // edge-resize band — synthesize a resize cursor over the + // outer ring, ahead of every panel / canvas hint. macOS keeps + // its native decorations, so it's left untouched. + #[cfg(not(target_os = "macos"))] + if !self.host.is_dragging_node() && !window.is_maximized() { + let vw = window.inner_size().width as f32 / self.dpi; + let vh = window.inner_size().height as f32 / self.dpi; + if let Some(dir) = crate::window_resize::window_resize_direction( + self.cursor_x, + self.cursor_y, + vw, + vh, + ) { + window.set_cursor(winit::window::CursorIcon::from(dir)); + self.pending_cursor_move = Some((self.cursor_x, self.cursor_y)); + self.request_redraw(false); + return; + } + } if self.host.is_dragging_node() { window.set_cursor(winit::window::CursorIcon::Move); } else if over_layer_panel { @@ -905,6 +925,25 @@ impl ApplicationHandler for DesktopApp { if self.drain_pending_cursor_move() { self.redraw_dirty = true; } + // Borderless-window edge resize (Windows / Linux). A press on the + // outer ring hands the drag to the OS, ahead of the TopBar + // window-drag and every app hit-test. macOS keeps native edges. + #[cfg(not(target_os = "macos"))] + if let Some(w) = self.window.as_ref() { + if !w.is_maximized() { + let vw = w.inner_size().width as f32 / self.dpi; + let vh = w.inner_size().height as f32 / self.dpi; + if let Some(dir) = crate::window_resize::window_resize_direction( + self.cursor_x, + self.cursor_y, + vw, + vh, + ) { + let _ = w.drag_resize_window(dir); + return; + } + } + } // Custom window chrome — the native title bar is // hidden, so a press on the TopBar's window-control // dots drives the window, and a press on the bar's diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index 9a36ea37c..59286075e 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -60,6 +60,7 @@ mod sub_agent_session; mod tcc_selftest; mod theme_preset_host; mod update_check; +mod window_resize; mod window_state; use op_host_native::{NativeBackend, SharedSkiaContext, SharedSkiaError, WidgetHostNative}; diff --git a/crates/op-host-desktop/src/menu.rs b/crates/op-host-desktop/src/menu.rs index dcdcecb16..af5582150 100644 --- a/crates/op-host-desktop/src/menu.rs +++ b/crates/op-host-desktop/src/menu.rs @@ -1,17 +1,15 @@ -//! Native application menu bar. +//! Native application menu bar (macOS only). //! -//! winit owns the window but has no menu primitive, so the native -//! menu bar is built with `muda` (the tao/tauri menu crate) and -//! attached to the running NSApp (macOS) / window (Windows). Menu -//! selections arrive on `muda`'s global event channel; [`poll`] -//! drains them into a [`MenuAction`] the runner maps onto the same -//! `WidgetHostNative` calls the keyboard shortcuts use. +//! On macOS the native menu bar is built with `muda` and attached to +//! the running NSApp. Menu selections arrive on `muda`'s global event +//! channel; [`poll`] drains them into a [`MenuAction`] the runner maps +//! onto the same `WidgetHostNative` calls the keyboard shortcuts use. //! -//! Linux: `muda` needs a GTK window, but this build's winit is -//! configured for the x11 / wayland backends (no GTK), so the menu -//! is a no-op there — the in-canvas File menu covers Linux. The -//! `muda` dependency is itself gated to macOS / Windows in -//! `Cargo.toml`, so this module compiles to stubs elsewhere. +//! Windows and Linux have no native menu — the in-canvas File menu is +//! the primary menu surface. On Windows the window is borderless (custom +//! chrome), and a native `muda` menu drawn inside the client area would +//! flash during `drag_resize_window()` when the OS repaints the window. +//! `muda` is gated to macOS in `Cargo.toml`; other platforms get stubs. /// A menu selection, decoupled from `muda` so the runner matches on /// a plain enum. Each variant maps onto an existing host action. @@ -21,7 +19,7 @@ /// variant is unconstructed by design. Silence `-D dead_code` /// there without weakening the lint on the platforms that actually /// build the menu. -#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuAction { New, @@ -48,9 +46,9 @@ pub enum MenuAction { } // -------------------------------------------------------------------- -// macOS / Windows — the real `muda` backend. +// macOS — the real `muda` backend. // -------------------------------------------------------------------- -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(target_os = "macos")] mod backend { use super::MenuAction; use muda::accelerator::{Accelerator, Code, Modifiers}; @@ -115,16 +113,8 @@ mod backend { _menu: Menu, } - /// Primary command modifier — Cmd on macOS, Ctrl on Windows. fn primary() -> Modifiers { - #[cfg(target_os = "macos")] - { - Modifiers::META - } - #[cfg(not(target_os = "macos"))] - { - Modifiers::CONTROL - } + Modifiers::META } fn accel(code: Code) -> Accelerator { @@ -142,8 +132,8 @@ mod backend { impl AppMenu { /// Build the menu and attach it to the running app / window. - /// macOS needs the NSApp to exist, Windows needs the window — - /// so this is called from `resumed`, after window creation. + /// Must be called from `resumed`, after window creation — macOS + /// needs the NSApp to exist. pub fn install(window: &winit::window::Window) -> Self { let menu = Menu::new(); @@ -151,7 +141,6 @@ mod backend { // conventional first submenu macOS labels with the app // name. Quit is custom-id'd so the runner drives the same // clean-shutdown path as the window-close button. - #[cfg(target_os = "macos")] { let app_menu = Submenu::new("OpenPencil", true); let _ = app_menu.append_items(&[ @@ -183,12 +172,6 @@ mod backend { Some(accel_shift(Code::KeyP)), ), ]); - // Windows has no app menu — Quit lives at the File-menu foot. - #[cfg(not(target_os = "macos"))] - { - let _ = file.append(&PredefinedMenuItem::separator()); - let _ = file.append(&item(ID_QUIT, "Quit", Some(accel(Code::KeyQ)))); - } let _ = menu.append(&file); // Edit menu — custom items routed to the host's own @@ -213,16 +196,8 @@ mod backend { // View menu. let view = Submenu::new("View", true); - let fullscreen_accel = { - #[cfg(target_os = "macos")] - { - Accelerator::new(Some(Modifiers::META | Modifiers::CONTROL), Code::KeyF) - } - #[cfg(not(target_os = "macos"))] - { - Accelerator::new(None, Code::F11) - } - }; + let fullscreen_accel = + Accelerator::new(Some(Modifiers::META | Modifiers::CONTROL), Code::KeyF); let _ = view.append(&item( ID_FULLSCREEN, "Toggle Full Screen", @@ -244,26 +219,13 @@ mod backend { let _ = menu.append(&help); // Attach to the platform. - #[cfg(target_os = "macos")] - { - let _ = window; // not needed — macOS attaches to the NSApp - menu.init_for_nsapp(); - } - #[cfg(target_os = "windows")] - { - if let Some(hwnd) = win32_hwnd(window) { - // SAFETY: `hwnd` is the live handle of the window - // winit just created; `muda` subclasses it to - // route `WM_COMMAND` to the menu event channel. - let _ = unsafe { menu.init_for_hwnd(hwnd) }; - } - } + let _ = window; // not needed — macOS attaches to the NSApp + menu.init_for_nsapp(); Self { _menu: menu } } } - #[cfg(target_os = "macos")] fn about_metadata() -> muda::AboutMetadata { muda::AboutMetadata { name: Some("OpenPencil".to_string()), @@ -272,16 +234,6 @@ mod backend { } } - /// Extract the Win32 `HWND` from a winit window (rwh_06 handle). - #[cfg(target_os = "windows")] - fn win32_hwnd(window: &winit::window::Window) -> Option { - use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - match window.window_handle().ok()?.as_raw() { - RawWindowHandle::Win32(h) => Some(h.hwnd.get()), - _ => None, - } - } - /// Drain one pending menu selection, if any. pub fn poll() -> Option { while let Ok(event) = MenuEvent::receiver().try_recv() { @@ -337,10 +289,10 @@ mod backend { } // -------------------------------------------------------------------- -// Other targets (Linux) — no native menu; the in-canvas File menu -// is the menu surface there. +// Other targets (Windows / Linux) — no native menu; the in-canvas +// File menu is the menu surface there. // -------------------------------------------------------------------- -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(not(target_os = "macos"))] mod backend { use super::MenuAction; diff --git a/crates/op-host-desktop/src/window_resize.rs b/crates/op-host-desktop/src/window_resize.rs new file mode 100644 index 000000000..6b8287b53 --- /dev/null +++ b/crates/op-host-desktop/src/window_resize.rs @@ -0,0 +1,112 @@ +//! Window-edge resize hit-testing for borderless (Windows / Linux) windows. +//! +//! macOS keeps its native `NSWindow` decorations (a transparent titlebar over a +//! real window), so the OS already paints edge-resize cursors and owns the drag. +//! Windows / Linux create the window with `with_decorations(false)` +//! (`app_handler.rs`) — a truly borderless window with no OS-provided resize +//! band — so we synthesize one: an invisible ring around the window edge that +//! maps to a `ResizeDirection`, driving both the hover cursor and a +//! `Window::drag_resize_window` grab on press. +//! +//! The pure hit-test below is compiled on every platform so its unit tests run +//! on the macOS dev machine; only the `app_handler` call sites are cfg-gated to +//! non-macOS (macOS must keep the native edges untouched). +#![cfg_attr(target_os = "macos", allow(dead_code))] + +use winit::window::ResizeDirection; + +/// Logical-pixel thickness of the invisible edge-resize band. +pub const RESIZE_BORDER: f32 = 6.0; + +/// Map a window-relative cursor position (logical px) to the edge-resize +/// direction it falls in, or `None` for interior points. `w` / `h` are the +/// window's logical size. Corner squares take priority over straight edges so a +/// corner grab resizes diagonally. +pub fn window_resize_direction(x: f32, y: f32, w: f32, h: f32) -> Option { + window_resize_direction_with_border(x, y, w, h, RESIZE_BORDER) +} + +fn window_resize_direction_with_border( + x: f32, + y: f32, + w: f32, + h: f32, + border: f32, +) -> Option { + // Guard degenerate sizes (both edge bands would overlap) and out-of-window + // coordinates so a stray negative / overshoot event can't report a phantom + // edge. The 640×400 min inner size means the size guard never fires in + // practice — it just keeps the function total. + if w <= border * 2.0 || h <= border * 2.0 { + return None; + } + if x < 0.0 || y < 0.0 || x > w || y > h { + return None; + } + let west = x <= border; + let east = x >= w - border; + let north = y <= border; + let south = y >= h - border; + Some(match (north, south, west, east) { + (true, _, true, _) => ResizeDirection::NorthWest, + (true, _, _, true) => ResizeDirection::NorthEast, + (_, true, true, _) => ResizeDirection::SouthWest, + (_, true, _, true) => ResizeDirection::SouthEast, + (true, ..) => ResizeDirection::North, + (_, true, ..) => ResizeDirection::South, + (_, _, true, _) => ResizeDirection::West, + (.., true) => ResizeDirection::East, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use winit::window::ResizeDirection::*; + + const W: f32 = 1200.0; + const H: f32 = 800.0; + + #[test] + fn interior_point_is_not_a_resize() { + assert_eq!(window_resize_direction(W / 2.0, H / 2.0, W, H), None); + // Just inside the band on every side. + assert_eq!(window_resize_direction(RESIZE_BORDER + 1.0, H / 2.0, W, H), None); + assert_eq!(window_resize_direction(W - RESIZE_BORDER - 1.0, H / 2.0, W, H), None); + assert_eq!(window_resize_direction(W / 2.0, RESIZE_BORDER + 1.0, W, H), None); + assert_eq!(window_resize_direction(W / 2.0, H - RESIZE_BORDER - 1.0, W, H), None); + } + + #[test] + fn straight_edges_map_to_axis_directions() { + assert_eq!(window_resize_direction(0.0, H / 2.0, W, H), Some(West)); + assert_eq!(window_resize_direction(W, H / 2.0, W, H), Some(East)); + assert_eq!(window_resize_direction(W / 2.0, 0.0, W, H), Some(North)); + assert_eq!(window_resize_direction(W / 2.0, H, W, H), Some(South)); + } + + #[test] + fn corners_take_priority_and_resize_diagonally() { + assert_eq!(window_resize_direction(0.0, 0.0, W, H), Some(NorthWest)); + assert_eq!(window_resize_direction(W, 0.0, W, H), Some(NorthEast)); + assert_eq!(window_resize_direction(0.0, H, W, H), Some(SouthWest)); + assert_eq!(window_resize_direction(W, H, W, H), Some(SouthEast)); + } + + #[test] + fn band_boundary_is_inclusive() { + // Exactly `RESIZE_BORDER` from the edge still counts as the edge. + assert_eq!(window_resize_direction(RESIZE_BORDER, H / 2.0, W, H), Some(West)); + assert_eq!(window_resize_direction(W - RESIZE_BORDER, H / 2.0, W, H), Some(East)); + } + + #[test] + fn out_of_window_or_degenerate_is_none() { + assert_eq!(window_resize_direction(-1.0, H / 2.0, W, H), None); + assert_eq!(window_resize_direction(W + 1.0, H / 2.0, W, H), None); + assert_eq!(window_resize_direction(W / 2.0, -1.0, W, H), None); + // A window narrower than both bands combined never reports an edge. + assert_eq!(window_resize_direction_with_border(1.0, 1.0, 8.0, 8.0, 6.0), None); + } +}