feat(desktop): window-edge resize for borderless Windows/Linux (#171)

* feat(desktop): add window-edge resize for borderless Windows/Linux

   macOS keeps its native NSWindow decorations, so edge-resize cursors and
   drags come for free. Windows/Linux create the window with
   decorations(false) — truly borderless, no OS-provided resize band — so
   the four edges couldn't be dragged and the cursor never changed.

   Synthesize a 6px edge-resize ring: a pure hit-test maps the cursor to a
   ResizeDirection (corners take priority for diagonal grabs), CursorMoved
   shows the matching resize cursor ahead of every panel/canvas hint, and a
   press hands the drag to the OS via drag_resize_window. Both call sites
   are gated to non-macOS and skip the maximized state; macOS is untouched.

* feat(desktop): remove native muda menu on Windows

Gate muda native menu bar to macOS only. Windows creates a borderless
window with custom chrome; the native menu drawn inside the client area
would flash during drag_resize_window() when the OS repaints the window.
Windows now uses the in-canvas File menu instead (same as Linux).
This commit is contained in:
leinaldo 2026-07-05 11:12:19 +08:00 committed by GitHub
parent 5328f69db4
commit 2aa788dadf
5 changed files with 181 additions and 80 deletions

View file

@ -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",

View file

@ -846,6 +846,26 @@ impl ApplicationHandler<DesktopEvent> 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<DesktopEvent> 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

View file

@ -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};

View file

@ -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<isize> {
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<MenuAction> {
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;

View file

@ -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<ResizeDirection> {
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<ResizeDirection> {
// 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);
}
}