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:
parent
5328f69db4
commit
2aa788dadf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
112
crates/op-host-desktop/src/window_resize.rs
Normal file
112
crates/op-host-desktop/src/window_resize.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue