GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
M1: the Mac port — an AppKit shell over Vulkan on MoltenVK (type-checked)
The fourth shell runs an Application in an AppKit window over the same
Driver, Pacer, build_frame and Vulkan renderer, presenting to a
CAMetalLayer through MoltenVK. engine::run is this shell's run on macOS,
so a client's main does not change.
- vk: SurfaceTarget (Wayland { display, surface } | Metal { layer });
VkRenderer::try_new_for / attach_surface_to and VkCore::new_for_surface
/ create_surface take one, the Wayland forms forward to them. The
instance enables VK_EXT_metal_surface when offered and, on macOS only,
portability enumeration; a device offering VK_KHR_portability_subset
enables it. A Linux instance and device are unchanged.
- cfg: the Wayland shell and its crates are now
not(any(wasm32, macos)); the renderer and native services stay
not(wasm32). AppSender is an mpsc sender with a process-wide wake hook
on macOS.
- backend::appkit: AppKit's key codes, characters, scroll deltas and
phases, modifier flags and buttons in the driver's terms; portable and
tested on Linux.
- src/mac: the window (transparent, full-size content), a flipped
layer-hosting view, main-queue pacing, Quit and close as an exit
request, the Command keyUp AppKit never sends synthesized, scroll
momentum dropped, the system's natural-scrolling setting honoured.
- driver: PressSite::own_edges — a window system that resizes from its
own edges makes the CSD resize band the app's.
- macOS loads the system font set by default.
- scripts/check-mac type-checks lib, demo, examples and tests for
aarch64-apple-darwin; four examples moved from the legacy new(qh, ..)
to create, and tests reach the tessellator at backend::tessellate.
Nothing in src/mac has run: an Apple target can be type-checked on Linux
but not linked. Linux is unchanged: the suite (2 known failures), the
plate golden and the 24-step sway run (0 px) all as before; check-wasm ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 59 ++-
Cargo.lock | 19 +
Cargo.toml | 16 +-
examples/carve_union_demo.rs | 5 +-
examples/frost_pair.rs | 5 +-
examples/grouped_recess_probe.rs | 5 +-
examples/trigger_near_roll_warning.rs | 8 +-
scripts/check-mac | 18 +
src/backend/app.rs | 65 ++-
src/backend/appkit.rs | 242 ++++++++++
src/backend/driver.rs | 18 +-
src/backend/mod.rs | 14 +-
src/backend/window_runner.rs | 1 +
src/engine.rs | 6 +-
src/input.rs | 5 +-
src/lib.rs | 24 +-
src/mac/mod.rs | 879 ++++++++++++++++++++++++++++++++++
src/vk/core.rs | 136 ++++--
src/vk/mod.rs | 2 +-
src/vk/plate_probe.rs | 6 +-
src/vk/renderer.rs | 45 +-
src/web/shell.rs | 2 +-
tests/lattice_probe.rs | 4 +-
tests/plate_golden.rs | 2 +-
24 files changed, 1481 insertions(+), 105 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 81a71bc..a68c2c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,13 @@ wayland-*, libc, rfd) are `cfg(not(target_arch = "wasm32"))`; so are the Wayland
of the client contract: `Application::new(qh, …)`, `layer()` and `LayerSettings`,
`register_sources`, and `renderer_init` / `stage_renderer`, which take a `VkRenderer`.
`WindowAction::Resize` takes `app::WindowEdge` — xdg's `ResizeEdge` on Linux, as before.
+Since macOS joined (2026-10-05) "native" is two things: the Vulkan renderer and the native
+services (`vk`, `ipc`, `file_dialog`; ash, libc, rfd) are `cfg(not(target_arch = "wasm32"))`,
+and the WAYLAND shell — `backend::{window_runner, menu_popup, dnd}`, `wayland`, `protocol`,
+`mcp`, the crates smithay / calloop / wayland-* / xkeysym, and the contract's `new(qh, …)`,
+`layer()`, `LayerSettings` and `register_sources` — is
+`cfg(not(any(target_arch = "wasm32", target_os = "macos")))`. `renderer_init` /
+`stage_renderer` are on macOS too, since it has the Vulkan renderer.
Portable code keeps time with `web_time::Instant` (std's own type natively; std's panics in
the browser), and reaches what a renderer draws through `crate::draw`, not `crate::vk`. A
change that makes portable code call into a native module fails `check-wasm` first — put
@@ -216,6 +223,54 @@ Chromium needs `--use-angle=swiftshader --enable-unsafe-swiftshader
compositing it has no shared-image backing for a WebGPU canvas and loses the device on the
first present ("A valid external Instance reference no longer exists").
+**And on a Mac, type-checked only** (`src/mac`, since 2026-10-05). The fourth shell is an
+AppKit window over the same `Driver`, `Pacer`, `build_frame` and **Vulkan renderer, on
+Metal through MoltenVK**: `vk::SurfaceTarget` names what a window's `VkSurfaceKHR` is made
+from — `Wayland { display, surface }` or `Metal { layer }` (a `CAMetalLayer`) —
+`VkRenderer::try_new_for` / `attach_surface_to` and `VkCore::new_for_surface` /
+`create_surface` take one, and the Wayland-pointer forms forward to them unchanged. The
+instance enables VK_EXT_metal_surface when the loader offers it, and on macOS only
+VK_KHR_portability_enumeration (the loader lists MoltenVK to no instance that does not ask);
+a device that offers VK_KHR_portability_subset gets it enabled, as the spec requires, and
+no Linux driver offers it — so a Linux instance and device are the ones they always were.
+`engine::run::<App>()` is the AppKit shell's `run` on macOS, so a client's `main` does not
+change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
+
+- **The window**: transparent, its titlebar transparent over full-size content, so the root
+ plate fills it with the traffic lights on its corner. AppKit resizes from its own window
+ edges, so the driver's CSD resize band is the app's (`PressSite::own_edges`, new; false
+ on the other shells); a press the driver reads as a move drags the window
+ (`performWindowDragWithEvent:`).
+- **Events**: a flipped, layer-hosting `NSView` maps mouse, scroll, magnify and keys through
+ `backend::appkit` (portable, tested on Linux like `dom`): named keys from the hardware key
+ code (AppKit spells them in private-use characters, and Backspace as DEL), the rest from
+ `characters`, or with ⌘/Ctrl from `charactersIgnoringModifiers` so ⌘Z is z typing ^Z;
+ Command reads as `ctrl`, as in a page on a Mac; a Control-click is a right click. AppKit
+ sends NO keyUp for a ⌘-combination, so the shell releases one as it presses it (else the
+ driver repeats ⌘Z until focus is lost). Its key repeats are dropped (the driver repeats)
+ and so is the system's scroll MOMENTUM (the toolkit coasts a flick itself; both would
+ coast twice). The system has applied natural scrolling to the wheel too, where a Linux
+ compositor applies it to the trackpad alone, so a wheel notch is turned back to the
+ wheel's own direction, and each trackpad event's `isDirectionInvertedFromDevice` sets
+ `input::force_natural_scroll` on the main thread: the system setting rules, not input.kdl's.
+- **Pacing**: main-queue dispatches (`dispatch2`); an event or any `AppSender::send`, from
+ any thread (`app::set_wake`, process-wide on macOS, per thread in a page), asks for a turn
+ at most one ACTIVE frame after the last; between, the pacer's sleep. A superseded turn is
+ dropped by its generation. Quit (⌘Q) and the close button ask the app to exit as a
+ compositor's close does; the run loop is stopped once it has.
+- **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
+ cosmic-text's macOS fallback list names.
+
+Not there yet: the clipboard, IME (`NSTextInputClient`), drag and drop, the context menu
+in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
+a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
+type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
+example and the tests for `aarch64-apple-darwin` (`rustup target add aarch64-apple-darwin`);
+the four examples that still used the legacy `new(qh, …)` moved to `create`, and
+`plate_probe` / two integration tests reach the tessellator at `backend::tessellate` rather
+than through `window_runner`. On a Mac, MoltenVK and the Vulkan loader must be installed
+(the LunarG SDK, or Homebrew's `molten-vk` and `vulkan-loader`); `cargo run` is the test.
+
Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
`src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
no `build.rs` and no codegen step to run.
@@ -1259,7 +1314,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
outside-press popover close, held-button release on a lost pointer, the scroll phase,
the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
`dom.rs` (the DOM's key and wheel vocabulary as the driver's: `map_key`, `wheel_frame` —
- portable, so tested natively), `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
+ portable, so tested natively), `appkit.rs` (AppKit's, likewise: key codes and
+ characters, scroll deltas and phases, modifier flags and buttons), `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
widget shaping, text and the popover-occlusion rects, tessellated into a `BuiltFrame` the
renderer draws — no window system in it, tested with no GPU), `shell.rs` (the `Shell`
trait — a window system's side of the run loop: exit, size requests, per-turn sync,
@@ -1306,6 +1362,7 @@ cce-system-interface) to confirm behavior, not just the test suite.
`compute.rs`, the async
`ComputeDevice`; and `request_device`, the adapter and device every one of them asks
for (with the limits a caller names raised to the adapter's).
+- `mac/` — macOS only: the AppKit shell, `run` (see "And on a Mac, type-checked only").
- `protocol.rs` — inline-generated Wayland protocol bindings.
- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/Cargo.lock b/Cargo.lock
index e4b50b8..54883e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -360,6 +360,7 @@ dependencies = [
"cce-vault",
"cosmic-text",
"cursor-icon",
+ "dispatch2",
"glam",
"gpu-allocator",
"js-sys",
@@ -367,6 +368,10 @@ dependencies = [
"libc",
"log",
"naga",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-quartz-core",
"png",
"raw-window-handle",
"resvg",
@@ -1352,7 +1357,9 @@ dependencies = [
"bitflags 2.13.1",
"block2",
"objc2",
+ "objc2-core-foundation",
"objc2-foundation",
+ "objc2-quartz-core",
]
[[package]]
@@ -1383,6 +1390,18 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
diff --git a/Cargo.toml b/Cargo.toml
index 962a607..6de8039 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,6 +58,12 @@ raw-window-handle = "0.6"
ash = "0.38"
gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
naga = { version = "24", features = ["wgsl-in", "spv-out"] }
+libc = "0.2"
+rfd = "0.15"
+
+# The Wayland shell: every native target but macOS, which has its own shell
+# (`src/mac`, AppKit) over the same Vulkan renderer through MoltenVK.
+[target.'cfg(not(any(target_arch = "wasm32", target_os = "macos")))'.dependencies]
smithay-client-toolkit = { version = "0.19.2", features = ["calloop"] }
calloop = "0.13.0"
calloop-wayland-source = "0.3.0"
@@ -66,8 +72,14 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
wayland-backend = "0.3"
wayland-scanner = "0.31"
xkeysym = "0.2"
-libc = "0.2"
-rfd = "0.15"
+
+# The AppKit shell (`src/mac`): the window, its CAMetalLayer, the run loop.
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6"
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSNotification", "NSObject", "NSDate", "NSDictionary"] }
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
+objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAMetalLayer", "objc2-core-foundation"] }
+dispatch2 = "0.3"
# The browser shell and renderer (W2/W3 of the web port): WebGPU through
# web-sys, the page through wasm-bindgen.
diff --git a/examples/carve_union_demo.rs b/examples/carve_union_demo.rs
index 5cc69ef..833419d 100644
--- a/examples/carve_union_demo.rs
+++ b/examples/carve_union_demo.rs
@@ -5,12 +5,11 @@
//! the union exists to remove. Run inside a Wayland session (a cce-shadow
//! instance works): `cargo run --release -p cce-ui --example carve_union_demo`.
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{AppSender, Application, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{DisplayList, PaintCtx};
use cce_ui::scene::Material;
use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
-use wayland_client::QueueHandle;
struct DemoApp;
@@ -33,7 +32,7 @@ fn plus_shape(x: f32, y: f32) -> Vec<(Rect, (f32, f32, f32, f32))> {
impl Application for DemoApp {
type Message = ();
- fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<()>) -> Self {
+ fn create(_sender: AppSender<()>) -> Self {
Self
}
diff --git a/examples/frost_pair.rs b/examples/frost_pair.rs
index 5da31c5..15a8d92 100644
--- a/examples/frost_pair.rs
+++ b/examples/frost_pair.rs
@@ -12,12 +12,11 @@
//! Run it ONLY in a shadow session (`cce-shadow spawn`), never from the live
//! shell.
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{AppSender, Application, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{DisplayList, PaintCtx, PlateSpec};
use cce_ui::scene::{Frost, Material};
use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
-use wayland_client::QueueHandle;
/// Logical layout: 12 checker columns of `COL` px; three plates in thirds.
pub const COL: f32 = 35.0;
@@ -43,7 +42,7 @@ struct FrostPair;
impl Application for FrostPair {
type Message = ();
- fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
+ fn create(_sender: AppSender<Self::Message>) -> Self {
FrostPair
}
diff --git a/examples/grouped_recess_probe.rs b/examples/grouped_recess_probe.rs
index 848c092..d24834c 100644
--- a/examples/grouped_recess_probe.rs
+++ b/examples/grouped_recess_probe.rs
@@ -8,12 +8,11 @@
//! Run inside a Wayland session (a cce-shadow instance works):
//! `cargo run --release -p cce-ui --example grouped_recess_probe`.
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{AppSender, Application, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{DisplayList, PaintCtx};
use cce_ui::scene::Material;
use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
-use wayland_client::QueueHandle;
struct ProbeApp;
@@ -35,7 +34,7 @@ fn recesses(x: f32, y: f32) -> Vec<(Rect, f32, f32)> {
impl Application for ProbeApp {
type Message = ();
- fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<()>) -> Self {
+ fn create(_sender: AppSender<()>) -> Self {
Self
}
diff --git a/examples/trigger_near_roll_warning.rs b/examples/trigger_near_roll_warning.rs
index 2aae8c6..e6c7f86 100644
--- a/examples/trigger_near_roll_warning.rs
+++ b/examples/trigger_near_roll_warning.rs
@@ -14,12 +14,11 @@
//! compiles the warning out and prints nothing. Exits by itself after a few
//! frames.
-use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::engine::{AppSender, Application, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{DisplayList, PaintCtx};
use cce_ui::scene::Material;
use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
-use wayland_client::QueueHandle;
struct TriggerApp {
ticks: u32,
@@ -28,10 +27,7 @@ struct TriggerApp {
impl Application for TriggerApp {
type Message = ();
- fn new(
- _qh: &QueueHandle<EngineState<Self>>,
- _sender: calloop::channel::Sender<Self::Message>,
- ) -> Self {
+ fn create(_sender: AppSender<Self::Message>) -> Self {
Self { ticks: 0 }
}
diff --git a/scripts/check-mac b/scripts/check-mac
new file mode 100755
index 0000000..cae13ae
--- /dev/null
+++ b/scripts/check-mac
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# check-mac — does cce-ui still build for macOS? Type-checks the library,
+# the demo, the examples and the tests for aarch64-apple-darwin, with and
+# without the optional features. On Linux nothing here can be LINKED (no
+# macOS SDK, no Apple linker), so this is a type-check only: the AppKit
+# shell (src/mac) and the MoltenVK path of the Vulkan renderer are checked
+# against their bindings, never run. On a Mac, `cargo run` is the real test.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+target=aarch64-apple-darwin
+if ! rustup target list --installed 2>/dev/null | grep -qx "$target"; then
+ echo "check-mac: the $target target is not installed:" >&2
+ echo " rustup target add $target" >&2
+ exit 2
+fi
+cargo check --target "$target" --lib --bins --examples --tests
+cargo check --target "$target" --lib --features markdown,doc_editor
+echo "check-mac: ok"
diff --git a/src/backend/app.rs b/src/backend/app.rs
index cdcfe97..e543d3c 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -5,11 +5,11 @@
use cosmic_text::FontSystem;
use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
use wayland_client::QueueHandle;
#[cfg(not(target_arch = "wasm32"))]
use crate::vk::VkRenderer;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
use super::window_runner::EngineState;
use cursor_icon::CursorIcon;
use super::tessellate::Vertex;
@@ -27,9 +27,9 @@ pub struct WindowSettings {
/// The edge a [`WindowAction::Resize`] grabs. On Wayland it is
/// `xdg_toplevel`'s own enum, as it always was; elsewhere the driver's.
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub use smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge as WindowEdge;
-#[cfg(target_arch = "wasm32")]
+#[cfg(any(target_arch = "wasm32", target_os = "macos"))]
pub use super::driver::ResizeEdge as WindowEdge;
/// A compositor-side window operation requested by the app: an interactive
@@ -43,7 +43,7 @@ pub enum WindowAction {
// Re-export the wlr-layer-shell types apps need to describe a layer surface.
// Layer surfaces are a Wayland (wlr) notion: native only, like `layer()`.
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub use smithay_client_toolkit::shell::wlr_layer::{
Anchor as LayerAnchor, KeyboardInteractivity as LayerKeyboardInteractivity, Layer as LayerKind,
};
@@ -51,7 +51,7 @@ pub use smithay_client_toolkit::shell::wlr_layer::{
/// Opt-in configuration for running an [`Application`] on a wlr-layer-shell
/// surface (panels, overlays, notifications) instead of an xdg toplevel.
/// Return one from [`Application::layer`] to select layer-shell.
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
#[derive(Debug, Clone)]
pub struct LayerSettings {
pub layer: LayerKind,
@@ -100,15 +100,15 @@ pub struct RenderContext<'a> {
/// It names no window system. Handed to [`Application::create`], it is what
/// a client written against it can be run by any shell with; the Wayland
/// runner backs it with its calloop channel, and `From` converts both ways
-/// for a client that still keeps a `calloop::channel::Sender` somewhere. In
-/// the browser it is a `std::sync::mpsc` sender whose receiver the shell
-/// drains every turn — still `Send`, so app code that hands it to a worker
-/// compiles unchanged — and a send wakes the browser shell's loop through
-/// [`set_wake`], as a calloop channel wakes the Wayland one.
+/// for a client that still keeps a `calloop::channel::Sender` somewhere.
+/// Elsewhere (the browser, macOS) it is a `std::sync::mpsc` sender whose
+/// receiver the shell drains every turn — still `Send`, so app code that
+/// hands it to a worker compiles unchanged — and a send wakes the shell's
+/// loop through `set_wake`, as a calloop channel wakes the Wayland one.
pub struct AppSender<M> {
- #[cfg(not(target_arch = "wasm32"))]
+ #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
inner: calloop::channel::Sender<M>,
- #[cfg(target_arch = "wasm32")]
+ #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
inner: std::sync::mpsc::Sender<M>,
}
@@ -117,7 +117,7 @@ impl<M> AppSender<M> {
/// gone: the app has exited.
pub fn send(&self, msg: M) -> Result<(), std::sync::mpsc::SendError<M>> {
self.inner.send(msg)?;
- #[cfg(target_arch = "wasm32")]
+ #[cfg(any(target_arch = "wasm32", target_os = "macos"))]
wake();
Ok(())
}
@@ -147,6 +147,29 @@ pub fn set_wake(wake: Option<Box<dyn Fn()>>) {
WAKE.with(|w| *w.borrow_mut() = wake);
}
+/// Call the hook [`set_wake`] installed, if any, from whatever thread sent.
+#[cfg(target_os = "macos")]
+pub(crate) fn wake() {
+ let hook = WAKE.read().ok().and_then(|w| w.clone());
+ if let Some(wake) = hook {
+ wake();
+ }
+}
+
+#[cfg(target_os = "macos")]
+static WAKE: std::sync::RwLock<Option<std::sync::Arc<dyn Fn() + Send + Sync>>> = std::sync::RwLock::new(None);
+
+/// What an [`AppSender::send`] calls after posting: the AppKit shell's "turn
+/// the loop soon". Process-wide, unlike the browser's: an AppKit app's
+/// workers send from their own threads, and the hook hops to the main thread
+/// itself, as a calloop channel's ping wakes the Wayland loop from anywhere.
+#[cfg(target_os = "macos")]
+pub fn set_wake(wake: Option<std::sync::Arc<dyn Fn() + Send + Sync>>) {
+ if let Ok(mut w) = WAKE.write() {
+ *w = wake;
+ }
+}
+
impl<M> Clone for AppSender<M> {
fn clone(&self) -> Self {
Self { inner: self.inner.clone() }
@@ -159,21 +182,21 @@ impl<M> std::fmt::Debug for AppSender<M> {
}
}
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
impl<M> From<calloop::channel::Sender<M>> for AppSender<M> {
fn from(inner: calloop::channel::Sender<M>) -> Self {
Self { inner }
}
}
-#[cfg(target_arch = "wasm32")]
+#[cfg(any(target_arch = "wasm32", target_os = "macos"))]
impl<M> From<std::sync::mpsc::Sender<M>> for AppSender<M> {
fn from(inner: std::sync::mpsc::Sender<M>) -> Self {
Self { inner }
}
}
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
impl<M> From<AppSender<M>> for calloop::channel::Sender<M> {
fn from(sender: AppSender<M>) -> Self {
sender.inner
@@ -202,7 +225,7 @@ pub trait Application: Sized + 'static {
/// view: the session's Wayland queue handle (no client ever used it) and
/// its calloop sender. Prefer [`create`](Self::create); the default here
/// forwards to it. Native only: it names the Wayland queue.
- #[cfg(not(target_arch = "wasm32"))]
+ #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
fn new(qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
let _ = qh;
Self::create(AppSender::from(sender))
@@ -210,7 +233,7 @@ pub trait Application: Sized + 'static {
fn settings(&self) -> WindowSettings;
/// Return `Some(..)` to run on a wlr-layer-shell surface (overlay/panel)
/// instead of an xdg toplevel. Defaults to `None` (a normal window).
- #[cfg(not(target_arch = "wasm32"))]
+ #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
fn layer(&self) -> Option<LayerSettings> {
None
}
@@ -340,7 +363,7 @@ pub trait Application: Sized + 'static {
[0.0, 0.0, 0.0, 0.0]
}
- #[cfg(not(target_arch = "wasm32"))]
+ #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {}
fn adjust_size(&self, width: f32, height: f32) -> (f32, f32) {
@@ -577,7 +600,7 @@ pub trait Application: Sized + 'static {
}
-#[cfg(all(test, not(target_arch = "wasm32")))]
+#[cfg(all(test, not(any(target_arch = "wasm32", target_os = "macos"))))]
mod app_sender_tests {
use super::AppSender;
use calloop::channel::{channel, Event};
diff --git a/src/backend/appkit.rs b/src/backend/appkit.rs
new file mode 100644
index 0000000..96f5cf2
--- /dev/null
+++ b/src/backend/appkit.rs
@@ -0,0 +1,242 @@
+//! AppKit's input vocabulary in cce-ui's terms: an `NSEvent`'s key code and
+//! characters as a [`Key`] and the text it types, its scroll deltas and
+//! phases as a [`ScrollFrame`], its modifier flags as the driver's
+//! [`Modifiers`]. The macOS shell (`mac::shell`) feeds these to the
+//! [`Driver`](super::driver::Driver), as the browser shell feeds `dom`'s.
+//! Plain functions of numbers and strings, so they build and are tested on
+//! every target — the shell itself can only be built on a Mac.
+
+use super::dom;
+use super::driver::{Modifiers, ScrollFrame, ScrollSource};
+use crate::widget::{Key, MouseButton, NamedKey};
+
+/// `NSEventModifierFlags` bits the shell reads.
+pub mod flags {
+ pub const SHIFT: u64 = 1 << 17;
+ pub const CONTROL: u64 = 1 << 18;
+ pub const OPTION: u64 = 1 << 19;
+ pub const COMMAND: u64 = 1 << 20;
+}
+
+/// `NSEventPhase` bits: a trackpad gesture's place in its life.
+pub mod phase {
+ pub const BEGAN: u64 = 1;
+ pub const STATIONARY: u64 = 2;
+ pub const CHANGED: u64 = 4;
+ pub const ENDED: u64 = 8;
+ pub const CANCELLED: u64 = 16;
+ pub const MAY_BEGIN: u64 = 32;
+}
+
+/// The modifier flags as the driver's. Command is the shortcut key, as the
+/// browser shell has it on a Mac (⌘Z is undo, matched as the `ctrl+z`
+/// chord): it and Control both read as `ctrl`, and there is no `logo`.
+pub fn modifiers(f: u64) -> Modifiers {
+ Modifiers {
+ ctrl: f & (flags::CONTROL | flags::COMMAND) != 0,
+ shift: f & flags::SHIFT != 0,
+ alt: f & flags::OPTION != 0,
+ logo: false,
+ }
+}
+
+/// An `NSEvent.buttonNumber` as the driver's button, given the flags it came
+/// with: a Control-click is a right click, as every Mac app reads it (one
+/// button is still a common mouse). The extra buttons are not the toolkit's.
+pub fn button(number: i64, f: u64) -> Option<MouseButton> {
+ match number {
+ 0 if f & flags::CONTROL != 0 && f & flags::COMMAND == 0 => Some(MouseButton::Right),
+ 0 => Some(MouseButton::Left),
+ 1 => Some(MouseButton::Right),
+ 2 => Some(MouseButton::Middle),
+ _ => None,
+ }
+}
+
+/// A key as cce-ui's key and the text it types — what xkb's `utf8` would
+/// carry on Wayland, as `dom::map_key` gives it in a page. The named keys
+/// are read from the hardware `key_code` (`kVK_*`), since AppKit spells
+/// them in private-use characters (and Backspace as DEL); everything else
+/// from the characters: `characters` as typed (Option and dead keys
+/// applied: Option-e e is "é"), or with `accel` — Command or Control held —
+/// `unmodified` (`charactersIgnoringModifiers`), so ⌘Z is the letter z
+/// typing its control code, as Ctrl+Z is on Linux. `None` for a key the
+/// toolkit has no use for: a dead key (no characters yet), a function key
+/// past F12.
+pub fn map_key(key_code: u16, characters: &str, unmodified: &str, accel: bool) -> Option<(Key, Option<String>)> {
+ let named = |n| Some((Key::Named(n), None));
+ let typing = |n, text: &str| Some((Key::Named(n), Some(text.to_string())));
+ match key_code {
+ 0x24 | 0x4C => typing(NamedKey::Enter, "\r"),
+ 0x30 => typing(NamedKey::Tab, "\t"),
+ 0x31 => typing(NamedKey::Space, if accel { "\0" } else { " " }),
+ 0x33 => typing(NamedKey::Backspace, "\u{8}"),
+ 0x35 => typing(NamedKey::Escape, "\u{1b}"),
+ 0x75 => typing(NamedKey::Delete, "\u{7f}"),
+ 0x7B => named(NamedKey::ArrowLeft),
+ 0x7C => named(NamedKey::ArrowRight),
+ 0x7D => named(NamedKey::ArrowDown),
+ 0x7E => named(NamedKey::ArrowUp),
+ 0x73 => named(NamedKey::Home),
+ 0x77 => named(NamedKey::End),
+ 0x74 => named(NamedKey::PageUp),
+ 0x79 => named(NamedKey::PageDown),
+ 0x37 | 0x36 => named(NamedKey::Super),
+ 0x38 | 0x3C => named(NamedKey::Shift),
+ 0x3A | 0x3D => named(NamedKey::Alt),
+ 0x3B | 0x3E => named(NamedKey::Control),
+ 0x7A => named(NamedKey::F1),
+ 0x78 => named(NamedKey::F2),
+ 0x63 => named(NamedKey::F3),
+ 0x76 => named(NamedKey::F4),
+ 0x60 => named(NamedKey::F5),
+ 0x61 => named(NamedKey::F6),
+ 0x62 => named(NamedKey::F7),
+ 0x64 => named(NamedKey::F8),
+ 0x65 => named(NamedKey::F9),
+ 0x6D => named(NamedKey::F10),
+ 0x67 => named(NamedKey::F11),
+ 0x6F => named(NamedKey::F12),
+ _ => {
+ let chars = if accel { unmodified } else { characters };
+ // AppKit's function-key characters (NSUpArrowFunctionKey …) are
+ // private use: a key named above, or one the toolkit has no name for.
+ if chars.chars().any(|c| ('\u{F700}'..='\u{F8FF}').contains(&c)) {
+ return None;
+ }
+ dom::map_key(chars, accel)
+ }
+ }
+}
+
+/// A scroll event's deltas (`scrollingDeltaX` / `Y`) as a frame in the
+/// driver's units — a `wl_pointer` axis frame's: positive down and right, a
+/// finger in px, a wheel notch a discrete step plus ten units — or `None`
+/// for an event that carries nothing to scroll by.
+///
+/// - **Which device**: `precise` (`hasPreciseScrollingDeltas`) is a
+/// trackpad or a Magic Mouse, its deltas in points: a finger. Otherwise a
+/// wheel, its deltas in lines, accelerated: a notch each whole line, and
+/// at least one each way a delta goes.
+/// - **Which way**: AppKit's delta is what the CONTENT moves by, the
+/// opposite of what a list scrolls by. And the system has applied the
+/// user's natural-scrolling setting (`inverted`,
+/// `isDirectionInvertedFromDevice`) to both devices, where a Linux
+/// compositor applies it to the trackpad alone; so a wheel is turned back
+/// to its own direction, as a Wayland wheel is, and a value control reads
+/// the notch as it does there. A finger keeps the system's direction, and
+/// the shell tells `input::natural_scroll` what it is.
+/// - **When it ends**: a finger's frame with `phase` ENDED or CANCELLED is
+/// the lift. What follows a lift is the system's own momentum
+/// (`momentum` nonzero), dropped: the toolkit coasts a flick itself
+/// (`ScrollMotion`), and taking both would coast twice. A gesture's
+/// touch-down (MAY_BEGIN) and a still finger carry nothing.
+pub fn scroll_frame(dx: f64, dy: f64, precise: bool, inverted: bool, phase: u64, momentum: u64) -> Option<ScrollFrame> {
+ if momentum != 0 {
+ return None;
+ }
+ if precise {
+ let stop = phase & (phase::ENDED | phase::CANCELLED) != 0;
+ if dx == 0.0 && dy == 0.0 && !stop {
+ return None;
+ }
+ return Some(ScrollFrame { h: -dx, v: -dy, source: Some(ScrollSource::Finger), stop, ..Default::default() });
+ }
+ // A wheel, in its own direction: down the list is positive.
+ let device = if inverted { 1.0 } else { -1.0 };
+ let notches = |d: f64| -> i32 {
+ match d.round() as i32 {
+ _ if d == 0.0 => 0,
+ 0 => d.signum() as i32,
+ n => n.clamp(-100, 100),
+ }
+ };
+ let (nh, nv) = (notches(dx * device), notches(dy * device));
+ if nh == 0 && nv == 0 {
+ return None;
+ }
+ Some(ScrollFrame {
+ h: nh as f64 * 10.0,
+ v: nv as f64 * 10.0,
+ discrete_h: nh,
+ discrete_v: nv,
+ source: Some(ScrollSource::Wheel),
+ stop: false,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn named_keys_come_from_the_key_code_and_type_what_xkb_types() {
+ // Backspace: AppKit's characters are DEL, xkb's text is BS.
+ assert_eq!(map_key(0x33, "\u{7f}", "\u{7f}", false), Some((Key::Named(NamedKey::Backspace), Some("\u{8}".into()))));
+ assert_eq!(map_key(0x75, "\u{F728}", "\u{F728}", false), Some((Key::Named(NamedKey::Delete), Some("\u{7f}".into()))));
+ assert_eq!(map_key(0x24, "\r", "\r", false), Some((Key::Named(NamedKey::Enter), Some("\r".into()))));
+ assert_eq!(map_key(0x4C, "\u{3}", "\u{3}", false), Some((Key::Named(NamedKey::Enter), Some("\r".into()))));
+ assert_eq!(map_key(0x30, "\t", "\t", false), Some((Key::Named(NamedKey::Tab), Some("\t".into()))));
+ assert_eq!(map_key(0x7B, "\u{F702}", "\u{F702}", false), Some((Key::Named(NamedKey::ArrowLeft), None)));
+ assert_eq!(map_key(0x60, "\u{F708}", "\u{F708}", false), Some((Key::Named(NamedKey::F5), None)));
+ // F13: a function key with no name here.
+ assert_eq!(map_key(0x69, "\u{F710}", "\u{F710}", false), None);
+ }
+
+ #[test]
+ fn a_command_shortcut_is_its_letter_typing_its_control_code() {
+ // ⌘Z: the letter, unmodified, typing ^Z as Ctrl+Z does on Linux.
+ assert_eq!(map_key(0x06, "z", "z", true), Some((Key::Character("z".into()), Some("\u{1a}".into()))));
+ // ⇧⌘Z.
+ assert_eq!(map_key(0x06, "Z", "Z", true), Some((Key::Character("Z".into()), Some("\u{1a}".into()))));
+ // Control-A: AppKit's characters are already ^A; the key is still a.
+ assert_eq!(map_key(0x00, "\u{1}", "a", true), Some((Key::Character("a".into()), Some("\u{1}".into()))));
+ assert_eq!(modifiers(flags::COMMAND | flags::SHIFT), Modifiers { ctrl: true, shift: true, alt: false, logo: false });
+ }
+
+ #[test]
+ fn option_types_what_it_composes_and_a_dead_key_types_nothing_yet() {
+ assert_eq!(map_key(0x00, "å", "a", false), Some((Key::Character("å".into()), Some("å".into()))));
+ assert_eq!(map_key(0x0E, "", "e", false), None);
+ assert_eq!(map_key(0x0E, "é", "e", false), Some((Key::Character("é".into()), Some("é".into()))));
+ }
+
+ #[test]
+ fn a_control_click_is_a_right_click() {
+ assert_eq!(button(0, 0), Some(MouseButton::Left));
+ assert_eq!(button(0, flags::CONTROL), Some(MouseButton::Right));
+ assert_eq!(button(0, flags::CONTROL | flags::COMMAND), Some(MouseButton::Left));
+ assert_eq!(button(1, 0), Some(MouseButton::Right));
+ assert_eq!(button(2, 0), Some(MouseButton::Middle));
+ assert_eq!(button(3, 0), None);
+ }
+
+ #[test]
+ fn a_trackpad_is_a_finger_scrolling_the_list_and_its_momentum_is_dropped() {
+ // Fingers moving up under natural scrolling: content up, list down.
+ let f = scroll_frame(0.0, -4.5, true, true, phase::CHANGED, 0).unwrap();
+ assert_eq!((f.v, f.h, f.source, f.stop), (4.5, 0.0, Some(ScrollSource::Finger), false));
+ // The lift.
+ let f = scroll_frame(0.0, 0.0, true, true, phase::ENDED, 0).unwrap();
+ assert!(f.stop);
+ // The touch-down, and the system's coast after the lift.
+ assert!(scroll_frame(0.0, 0.0, true, true, phase::MAY_BEGIN, 0).is_none());
+ assert!(scroll_frame(0.0, -12.0, true, true, 0, phase::CHANGED).is_none());
+ }
+
+ #[test]
+ fn a_wheel_notch_is_discrete_and_in_the_wheels_own_direction() {
+ // Rolled toward the user (down the list), natural scrolling off:
+ // AppKit says the content moves up.
+ let f = scroll_frame(0.0, -1.0, false, false, 0, 0).unwrap();
+ assert_eq!((f.discrete_v, f.v, f.source), (1, 10.0, Some(ScrollSource::Wheel)));
+ // The same roll with natural scrolling on: AppKit flips the sign,
+ // the frame does not.
+ let f = scroll_frame(0.0, 1.0, false, true, 0, 0).unwrap();
+ assert_eq!(f.discrete_v, 1);
+ // Accelerated: three lines, three notches; a fraction is one.
+ assert_eq!(scroll_frame(0.0, 3.2, false, false, 0, 0).unwrap().discrete_v, -3);
+ assert_eq!(scroll_frame(0.0, 0.1, false, false, 0, 0).unwrap().discrete_v, -1);
+ assert!(scroll_frame(0.0, 0.0, false, false, 0, 0).is_none());
+ }
+}
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index de5f978..5b305e8 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -92,6 +92,11 @@ pub struct PressSite {
/// a grab is the app's instead — which is what the Wayland runner did on
/// a layer surface, and what a shell with no grabs at all always does.
pub can_grab: bool,
+ /// The window system resizes the window from edges of its own (AppKit's
+ /// window frame), and offers no way to start a resize from a press: the
+ /// CSD resize band is then no grab, and a press there goes on to the
+ /// move checks and the app.
+ pub own_edges: bool,
}
/// Where a scroll came from, as far as the phase cares.
@@ -316,7 +321,7 @@ impl Driver {
// Resize borders off: the compositor's own band outside the
// window handles it; the move checks still run, so drag-to-move
// still works.
- if t.app.csd_resize_borders() {
+ if t.app.csd_resize_borders() && !site.own_edges {
if let Some(edge) = csd_edge(lx, ly, site.size) {
return Press::Resize(edge);
}
@@ -822,7 +827,7 @@ mod tests {
const SIZE: LogicalSize = LogicalSize { width: 400.0, height: 300.0 };
fn site(can_grab: bool) -> PressSite {
- PressSite { size: SIZE, on_popup: false, can_grab }
+ PressSite { size: SIZE, on_popup: false, can_grab, own_edges: false }
}
#[test]
@@ -865,6 +870,15 @@ mod tests {
assert_eq!(r, Press::Dispatched);
assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
+ // A window system that resizes from its own edges: the band is no
+ // grab, and the press is the app's.
+ app.seen.clear();
+ let own = PressSite { own_edges: true, ..site(true) };
+ assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, own), Press::Dispatched);
+ assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
+ // But the titlebar band still moves the window.
+ assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, own), Press::Move);
+
// Nor is a press through the menu popup ever a grab.
app.seen.clear();
let popup = PressSite { on_popup: true, ..site(true) };
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index ea8c729..fe6be70 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,21 +1,25 @@
pub mod app;
+pub mod appkit;
pub mod dom;
pub mod driver;
pub mod frame;
pub mod shell;
pub mod tessellate;
pub mod text;
-// The Wayland shell: native only.
-#[cfg(not(target_arch = "wasm32"))]
+// The Wayland shell: native, but for macOS.
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod dnd;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod menu_popup;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod window_runner;
+
pub use app::{Application, AppSender, LogicalPosition, LogicalSize, WindowSettings};
pub use driver::PressedKey;
pub use tessellate::{LineCap, Vertex};
pub use text::get_text_buffer;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub use window_runner::{run, EngineState};
+#[cfg(target_os = "macos")]
+pub use crate::mac::run;
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index ac51e57..7758eb3 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -970,6 +970,7 @@ impl<A: Application> PointerHandler for EngineState<A> {
size: self.logical_size(),
on_popup,
can_grab: self.window.is_some() && seat.is_some(),
+ own_edges: false,
};
let (driver, t) = self.turn();
let press = driver.pointer_press(t, btn, pos, site);
diff --git a/src/engine.rs b/src/engine.rs
index 6669e62..2a4b648 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -21,7 +21,9 @@ pub use crate::backend::tessellate::{
pub use crate::backend::text::{get_text_buffer_laid_out, shaped_cluster_offsets, shaping_for};
pub use cursor_icon::CursorIcon;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub use crate::backend::app::{LayerAnchor, LayerKeyboardInteractivity, LayerKind, LayerSettings};
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub use crate::backend::window_runner::{run, xdg_toplevel, EngineState};
+#[cfg(target_os = "macos")]
+pub use crate::mac::run;
diff --git a/src/input.rs b/src/input.rs
index 90537e9..910539b 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -411,7 +411,10 @@ thread_local! {
/// Force what [`natural_scroll`] answers on this thread, for a test that
/// drives a value control with a finger; `None` lifts it. Thread-local,
/// because a suite runs its tests in parallel and a process-wide override
-/// set by one would race every other test's read.
+/// set by one would race every other test's read. The macOS shell sets it
+/// too, on the main thread its events arrive on, from each trackpad
+/// event's `isDirectionInvertedFromDevice`: there the system's setting is
+/// the one in force, not input.kdl's.
pub fn force_natural_scroll(natural: Option<bool>) {
NATURAL_OVERRIDE.with(|f| f.set(natural));
}
diff --git a/src/lib.rs b/src/lib.rs
index c9de488..6234a45 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,7 +1,9 @@
-// Modules under `cfg(not(target_arch = "wasm32"))` are the native shell and
-// renderer (Wayland, Vulkan, the compositor IPC, file dialogs). Everything
-// else builds for the browser too: `cargo check --lib --target
-// wasm32-unknown-unknown` is the check.
+// Modules under `cfg(not(target_arch = "wasm32"))` are the native renderer
+// and services (Vulkan, the compositor IPC, file dialogs); those under
+// `cfg(not(any(target_arch = "wasm32", target_os = "macos")))` are the
+// Wayland shell's, which macOS replaces with `mac` (AppKit). Everything else
+// builds for the browser too: `scripts/check-wasm` is the check, and
+// `scripts/check-mac` the macOS one.
pub mod color;
pub mod compute;
pub mod widget;
@@ -10,9 +12,9 @@ pub mod input;
pub mod history;
pub mod layout;
pub mod relief_spec;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod wayland;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod protocol;
pub mod engine;
pub mod scale;
@@ -26,13 +28,15 @@ pub mod file_dialog;
pub mod icon;
#[cfg(not(target_arch = "wasm32"))]
pub mod ipc;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
pub mod mcp;
pub mod motion;
#[cfg(not(target_arch = "wasm32"))]
pub mod vk;
#[cfg(target_arch = "wasm32")]
pub mod web;
+#[cfg(target_os = "macos")]
+pub mod mac;
pub mod colors {
pub use crate::color::*;
@@ -261,7 +265,11 @@ fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
{
db.load_fonts_dir(fonts_dir());
load_fallback_fonts(&mut db);
- if load_system_fonts || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
+ // On macOS the system set is always loaded: it is a curated one,
+ // and what cosmic-text's fallback list there names ("Apple Color
+ // Emoji", "PingFang SC", …) — where on Linux the probe above finds
+ // the Noto faces in its place.
+ if load_system_fonts || cfg!(target_os = "macos") || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
db.load_system_fonts();
}
// An empty database guarantees a panic on the first shaped glyph
diff --git a/src/mac/mod.rs b/src/mac/mod.rs
new file mode 100644
index 0000000..5667946
--- /dev/null
+++ b/src/mac/mod.rs
@@ -0,0 +1,879 @@
+//! The macOS shell: an [`Application`] run in an AppKit window, the way the
+//! Wayland shell runs one on a surface and the browser shell on a canvas. It
+//! is the third shell over the shared pieces — [`Driver`] routes what input
+//! means, [`Pacer::turn`] decides what a turn does, [`build_frame`] builds
+//! the frame — and the renderer is the Vulkan one, on Metal through
+//! MoltenVK (a `CAMetalLayer` is a [`SurfaceTarget::Metal`]). What is left
+//! here is AppKit's side:
+//!
+//! - **The window.** An `NSWindow` whose content is the app's root plate:
+//! transparent, its titlebar transparent over full-size content, so the
+//! plate fills the window and the traffic lights stand on its corner. The
+//! window keeps AppKit's own resize edges, so the driver's CSD resize band
+//! is the app's (`PressSite::own_edges`); a press it reads as the
+//! window's (the titlebar band, a movable root plate) drags the window
+//! (`performWindowDragWithEvent:`).
+//! - **Events in.** A layer-hosting `NSView` (flipped, so y runs down as
+//! everywhere else in the toolkit) takes the mouse, scroll, magnify and
+//! key events and hands them, mapped by `backend::appkit`, to the driver.
+//! Command is the shortcut key (⌘Z is undo). AppKit's own key repeats
+//! and scroll momentum are dropped: the driver repeats a held key and the
+//! toolkit coasts a flick, as on Wayland.
+//! - **Turns.** Main-queue dispatches (`dispatch2`): any event, and any
+//! `AppSender::send` from any thread (`set_wake`), asks for a turn at most
+//! one ACTIVE frame after the last; between, the loop sleeps for whatever
+//! the pacer said. A turn superseded by an earlier one is dropped by its
+//! generation.
+//! - **Frames out.** [`build_frame`] at the view's size in points and the
+//! window's backing scale, drawn by the [`VkRenderer`]; `stage_renderer`
+//! (and so the portable `stage_3d`) runs before each draw, `renderer_init`
+//! once.
+//!
+//! The menu is a minimal one (Quit, ⌘Q), whose quit — like the close
+//! button — asks the app to exit the way a compositor's close does.
+//!
+//! Not there yet: the clipboard, IME composition (`NSTextInputClient`),
+//! drag and drop, the context menu in a popup window (it is drawn in the
+//! window and kept inside it, as on a layer surface), and blur behind the
+//! window (`NSVisualEffectView`).
+//!
+//! **This module has never been run.** It is written against objc2's
+//! AppKit bindings and type-checked for `aarch64-apple-darwin`
+//! (`scripts/check-mac`) on Linux, where nothing can be linked or run: it
+//! needs a Mac, with MoltenVK installed (the Vulkan SDK, or Homebrew's
+//! `molten-vk` and `vulkan-loader`).
+
+use std::cell::RefCell;
+use std::sync::mpsc::Receiver;
+use std::time::Duration;
+
+use cursor_icon::CursorIcon;
+use dispatch2::{DispatchQueue, DispatchTime};
+use objc2::rc::Retained;
+use objc2::runtime::{NSObject, ProtocolObject};
+use objc2::{define_class, msg_send, sel, AnyThread, MainThreadOnly};
+use objc2_app_kit::{
+ NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSApplicationTerminateReply,
+ NSBackingStoreType, NSColor, NSCursor, NSEvent, NSEventModifierFlags, NSEventType, NSMenu, NSMenuItem,
+ NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
+ NSWindowTitleVisibility,
+};
+use objc2_foundation::{
+ ns_string, MainThreadMarker, NSNotification, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString,
+};
+use objc2_quartz_core::CAMetalLayer;
+use web_time::Instant;
+
+use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
+use crate::backend::appkit;
+use crate::backend::driver::{Driver, Press, PressSite, Turn};
+use crate::backend::frame::build_frame;
+use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
+use crate::vk::{SurfaceTarget, VkRenderer};
+use crate::widget::{context_menu, ElementState, Key, TextItem};
+
+/// Run `A` in a window until it exits. Must be called on the main thread
+/// (a process's `main`), as AppKit requires.
+pub fn run<A: Application>() {
+ let mtm = MainThreadMarker::new().expect("cce-ui's macOS shell runs on the main thread");
+ let ns_app = NSApplication::sharedApplication(mtm);
+ ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
+
+ let (tx, rx) = std::sync::mpsc::channel();
+ // The scale is known before the app is built, as on the other shells: a
+ // widget may read it as it is made.
+ let main_scale = objc2_app_kit::NSScreen::mainScreen(mtm).map_or(2.0, |s| s.backingScaleFactor());
+ crate::scale::set_scale_factor(main_scale as f32);
+ let mut app = A::create(AppSender::from(tx));
+ let settings = app.settings();
+ crate::scale::set_app_id(settings.app_id.clone());
+
+ let delegate = Delegate::new(mtm);
+ ns_app.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+ ns_app.setMainMenu(Some(&main_menu(mtm, &settings.title)));
+
+ let style = NSWindowStyleMask::Titled
+ | NSWindowStyleMask::Closable
+ | NSWindowStyleMask::Miniaturizable
+ | NSWindowStyleMask::Resizable
+ | NSWindowStyleMask::FullSizeContentView;
+ let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(settings.width as f64, settings.height as f64));
+ // SAFETY: released-when-closed is turned off at once, as a window made
+ // outside a window controller must be.
+ let window = unsafe {
+ NSWindow::initWithContentRect_styleMask_backing_defer(
+ NSWindow::alloc(mtm),
+ rect,
+ style,
+ NSBackingStoreType::Buffered,
+ false,
+ )
+ };
+ unsafe { window.setReleasedWhenClosed(false) };
+ window.setTitle(&NSString::from_str(&settings.title));
+ window.setTitlebarAppearsTransparent(true);
+ window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
+ window.setOpaque(false);
+ window.setBackgroundColor(Some(&NSColor::clearColor()));
+ window.setAcceptsMouseMovedEvents(true);
+ if let Some((w, h)) = settings.min_size {
+ window.setContentMinSize(NSSize::new(w as f64, h as f64));
+ }
+ window.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+
+ let view = CceView::new(mtm, rect);
+ let layer = CAMetalLayer::new();
+ layer.setOpaque(false);
+ let scale = window.backingScaleFactor();
+ layer.setContentsScale(scale);
+ // A layer-HOSTING view: the layer is set before the view wants one.
+ view.setLayer(Some(&layer));
+ view.setWantsLayer(true);
+ window.setContentView(Some(&view));
+ let _ = window.makeFirstResponder(Some(&view));
+ window.center();
+
+ let fs = if app.load_system_fonts() {
+ crate::create_font_system_with_system_fonts()
+ } else {
+ crate::create_font_system()
+ };
+ let (pw, ph) = physical((settings.width as f32, settings.height as f32), scale);
+ let layer_ptr: *const std::ffi::c_void = Retained::as_ptr(&layer).cast();
+ // SAFETY: the layer is held by the shell, which drops the renderer first.
+ let mut renderer = match unsafe { VkRenderer::try_new_for(SurfaceTarget::Metal { layer: layer_ptr }, pw, ph, 0.0) } {
+ Ok(r) => r,
+ Err(e) => {
+ log::error!("[mac] no renderer for the window: {e}");
+ return;
+ }
+ };
+ app.renderer_init(&mut renderer);
+
+ let shell = MacShell {
+ renderer,
+ app,
+ driver: Driver::new(),
+ redraw: true,
+ exit: false,
+ rx,
+ fs,
+ swash: cosmic_text::SwashCache::new(),
+ items: Vec::new(),
+ damage_owed: true,
+ logical: (0.0, 0.0),
+ scale,
+ just_configured: true,
+ cursor: CursorIcon::Default,
+ pinch: 1.0,
+ pacer: Pacer::new(settings.title),
+ generation: 0,
+ due: None,
+ last_turn: Instant::now(),
+ stopped: false,
+ mtm,
+ layer,
+ view: view.clone(),
+ window: window.clone(),
+ };
+ let shell = RefCell::new(shell);
+ shell.borrow_mut().measure();
+ SINK.with(|s| *s.borrow_mut() = Some(Box::new(move |ev| shell.borrow_mut().event(ev))));
+ // A send from any thread hops to the main queue and asks for a turn.
+ set_wake(Some(std::sync::Arc::new(|| DispatchQueue::main().exec_async(|| {
+ send(Ev::Wake);
+ }))));
+
+ window.makeKeyAndOrderFront(None);
+ #[allow(deprecated)]
+ ns_app.activateIgnoringOtherApps(true);
+ send(Ev::Wake);
+ ns_app.run();
+
+ // `stop:` returned the run loop: the app has exited. The shell — the
+ // renderer before the layer it presents to — goes with the sink.
+ set_wake(None);
+ SINK.with(|s| s.borrow_mut().take());
+ drop(view);
+ drop(window);
+}
+
+/// What the view, the delegate and the loop's dispatches hand the shell, in
+/// plain values: the shell is generic over the app and the AppKit classes
+/// cannot be, so they reach it through [`SINK`].
+enum Ev {
+ Moved { x: f32, y: f32 },
+ Entered { x: f32, y: f32 },
+ Exited,
+ /// Answers true when the press is a window drag the view must start.
+ Pressed { number: i64, flags: u64, x: f32, y: f32 },
+ Released { number: i64, flags: u64, x: f32, y: f32 },
+ Scrolled { dx: f64, dy: f64, precise: bool, inverted: bool, phase: u64, momentum: u64, flags: u64, x: f32, y: f32 },
+ Magnified { phase: u64, magnification: f64 },
+ Keyed { code: u16, chars: String, unmodified: String, flags: u64, down: bool },
+ FlagsChanged { code: u16, flags: u64 },
+ Focused(bool),
+ /// The window's size or backing scale changed.
+ Resized,
+ /// The close button, or Quit: the app is asked to exit.
+ CloseRequested,
+ /// A turn scheduled under this generation is due.
+ Turn(u64),
+ /// Turn soon.
+ Wake,
+}
+
+thread_local! {
+ static SINK: RefCell<Option<Box<dyn FnMut(Ev) -> bool>>> = const { RefCell::new(None) };
+}
+
+/// Hand `ev` to the shell. An event raised from inside a dispatch (AppKit
+/// calling back while the app runs) finds the shell busy and is dropped,
+/// as the browser shell drops one; so is everything after the exit.
+fn send(ev: Ev) -> bool {
+ SINK.with(|s| match s.try_borrow_mut() {
+ Ok(mut sink) => sink.as_mut().is_some_and(|f| f(ev)),
+ Err(_) => false,
+ })
+}
+
+/// The window's side of the run loop, as the Wayland shell's `EngineState`
+/// is the compositor's. Fields drop in order: the renderer before the layer
+/// it presents to.
+struct MacShell<A: Application> {
+ renderer: VkRenderer,
+ app: A,
+ driver: Driver,
+ redraw: bool,
+ exit: bool,
+ rx: Receiver<A::Message>,
+ fs: cosmic_text::FontSystem,
+ swash: cosmic_text::SwashCache,
+ items: Vec<TextItem>,
+ damage_owed: bool,
+ /// The view's size in points, and the window's backing scale.
+ logical: (f32, f32),
+ scale: f64,
+ just_configured: bool,
+ cursor: CursorIcon,
+ /// The pinch's cumulative scale (AppKit reports each step's change).
+ pinch: f32,
+ pacer: Pacer,
+ /// The generation of the turn that is due; older dispatches are stale.
+ generation: u64,
+ due: Option<Instant>,
+ last_turn: Instant,
+ stopped: bool,
+ mtm: MainThreadMarker,
+ layer: Retained<CAMetalLayer>,
+ view: Retained<CceView>,
+ window: Retained<NSWindow>,
+}
+
+impl<A: Application> MacShell<A> {
+ fn event(&mut self, ev: Ev) -> bool {
+ if self.stopped {
+ return false;
+ }
+ let mut drag = false;
+ match ev {
+ Ev::Turn(g) => {
+ if g == self.generation {
+ self.take_turn();
+ }
+ return false;
+ }
+ Ev::Wake => {}
+ Ev::Moved { x, y } => {
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_motion(t, pos);
+ self.update_cursor();
+ }
+ Ev::Entered { x, y } => {
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_enter(t, pos);
+ self.update_cursor();
+ }
+ Ev::Exited => {
+ let (driver, t) = self.turn();
+ driver.pointer_leave(t);
+ }
+ Ev::Pressed { number, flags, x, y } => {
+ let Some(btn) = appkit::button(number, flags) else { return false };
+ self.sync_mods(flags);
+ let pos = self.at(x, y);
+ // AppKit resizes from its own window edges; a move is a drag.
+ let site = PressSite { size: self.size(), on_popup: false, can_grab: true, own_edges: true };
+ let (driver, t) = self.turn();
+ drag = driver.pointer_press(t, btn, pos, site) == Press::Move;
+ }
+ Ev::Released { number, flags, x, y } => {
+ let Some(btn) = appkit::button(number, flags) else { return false };
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.pointer_release(t, btn, pos);
+ self.update_cursor();
+ }
+ Ev::Scrolled { dx, dy, precise, inverted, phase, momentum, flags, x, y } => {
+ // The system's natural-scrolling setting is the one in force
+ // here, not input.kdl's: value controls read it through this.
+ if precise {
+ crate::input::force_natural_scroll(Some(inverted));
+ }
+ let Some(frame) = appkit::scroll_frame(dx, dy, precise, inverted, phase, momentum) else { return false };
+ self.sync_mods(flags);
+ let pos = self.at(x, y);
+ let (driver, t) = self.turn();
+ driver.scroll(t, frame, pos);
+ }
+ Ev::Magnified { phase, magnification } => {
+ if phase & appkit::phase::BEGAN != 0 {
+ self.pinch = 1.0;
+ self.driver.pinch_begin();
+ }
+ if magnification != 0.0 {
+ self.pinch *= 1.0 + magnification as f32;
+ let pinch = self.pinch;
+ let (driver, t) = self.turn();
+ driver.pinch_update(t, pinch);
+ }
+ if phase & (appkit::phase::ENDED | appkit::phase::CANCELLED) != 0 {
+ self.driver.pinch_end();
+ }
+ }
+ Ev::Keyed { code, chars, unmodified, flags, down } => {
+ let accel = flags & (appkit::flags::COMMAND | appkit::flags::CONTROL) != 0;
+ let Some((key, text)) = appkit::map_key(code, &chars, &unmodified, accel) else { return false };
+ self.sync_mods(flags);
+ let state = if down { ElementState::Pressed } else { ElementState::Released };
+ let (driver, t) = self.turn();
+ driver.key(t, key, text, state);
+ }
+ Ev::FlagsChanged { code, flags } => {
+ // A modifier key went down or up: the new state, and the key
+ // itself as the other shells report it.
+ self.sync_mods(flags);
+ let bit = match code {
+ 0x37 | 0x36 => appkit::flags::COMMAND,
+ 0x38 | 0x3C => appkit::flags::SHIFT,
+ 0x3A | 0x3D => appkit::flags::OPTION,
+ 0x3B | 0x3E => appkit::flags::CONTROL,
+ _ => 0,
+ };
+ if let (true, Some((key @ Key::Named(_), _))) = (bit != 0, appkit::map_key(code, "", "", false)) {
+ let state = if flags & bit != 0 { ElementState::Pressed } else { ElementState::Released };
+ let (driver, t) = self.turn();
+ driver.key(t, key, None, state);
+ }
+ }
+ Ev::Focused(focused) => {
+ let (driver, t) = self.turn();
+ driver.keyboard_focus(t, focused);
+ }
+ Ev::Resized => {
+ if self.measure() {
+ self.just_configured = true;
+ }
+ }
+ Ev::CloseRequested => self.exit = true,
+ }
+ self.wake();
+ drag
+ }
+
+ /// Ask for a turn one ACTIVE frame after the last at the latest, unless
+ /// one is due sooner already.
+ fn wake(&mut self) {
+ let at = (self.last_turn + ACTIVE_DISPATCH).max(Instant::now());
+ if self.due.is_some_and(|d| d <= at) {
+ return;
+ }
+ self.schedule(at.saturating_duration_since(Instant::now()));
+ }
+
+ fn schedule(&mut self, after: Duration) {
+ self.generation += 1;
+ self.due = Some(Instant::now() + after);
+ let g = self.generation;
+ let when = DispatchTime::try_from(after).unwrap_or(DispatchTime::NOW);
+ let _ = DispatchQueue::main().after(when, move || {
+ send(Ev::Turn(g));
+ });
+ }
+
+ fn take_turn(&mut self) {
+ self.due = None;
+ self.last_turn = Instant::now();
+ while let Ok(msg) = self.rx.try_recv() {
+ let mut rebuild = false;
+ self.app.update(msg, &mut rebuild, &mut self.exit);
+ self.redraw |= rebuild;
+ }
+ let mut pacer = std::mem::replace(&mut self.pacer, Pacer::new(String::new()));
+ let step = pacer.turn(self);
+ self.pacer = pacer;
+ match step {
+ Step::Exit => self.finish(),
+ Step::Sleep(d) => {
+ if self.due.is_none() {
+ self.schedule(d);
+ }
+ }
+ }
+ }
+
+ /// The app exited: let it take its leave, then return the run loop.
+ fn finish(&mut self) {
+ self.stopped = true;
+ self.app.on_exit();
+ let ns_app = NSApplication::sharedApplication(self.mtm);
+ ns_app.stop(None);
+ // `stop:` takes effect after the next event; this is it.
+ if let Some(ev) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
+ NSEventType::ApplicationDefined,
+ NSPoint::new(0.0, 0.0),
+ NSEventModifierFlags::empty(),
+ 0.0,
+ 0,
+ None,
+ 0,
+ 0,
+ 0,
+ ) {
+ ns_app.postEvent_atStart(&ev, true);
+ }
+ }
+
+ /// Read the view's size and the backing scale, and size the drawable to
+ /// them. Whether either changed.
+ fn measure(&mut self) -> bool {
+ let bounds = self.view.bounds();
+ let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
+ let scale = self.window.backingScaleFactor();
+ if (w, h) == self.logical && scale == self.scale {
+ return false;
+ }
+ self.logical = (w, h);
+ if scale != self.scale {
+ self.scale = scale;
+ crate::scale::set_scale_factor(scale as f32);
+ }
+ // MoltenVK reports the layer's bounds times its contents scale as
+ // the surface's extent.
+ self.layer.setContentsScale(scale);
+ let (pw, ph) = physical(self.logical, self.scale);
+ self.renderer.resize(pw, ph);
+ self.redraw = true;
+ true
+ }
+
+ fn size(&self) -> LogicalSize {
+ LogicalSize::new(self.logical.0, self.logical.1)
+ }
+
+ fn at(&mut self, x: f32, y: f32) -> LogicalPosition {
+ self.driver.cursor_pos = (x, y);
+ LogicalPosition::new(x, y)
+ }
+
+ fn sync_mods(&mut self, flags: u64) {
+ let mods = appkit::modifiers(flags);
+ if mods != self.driver.mods {
+ self.driver.set_modifiers(&mut self.app, mods);
+ }
+ }
+
+ fn update_cursor(&mut self) {
+ let (x, y) = self.driver.cursor_pos;
+ let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
+ if icon != self.cursor {
+ self.cursor = icon;
+ ns_cursor(icon).set();
+ }
+ }
+}
+
+impl<A: Application> Shell for MacShell<A> {
+ type App = A;
+
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
+ }
+
+ fn app(&self) -> &A {
+ &self.app
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ if (w as f32, h as f32) != self.logical {
+ self.window.setContentSize(NSSize::new(w as f64, h as f64));
+ self.measure();
+ }
+ }
+
+ fn sync(&mut self) {
+ // No popup window to host the menu yet: it is drawn in the window
+ // and kept inside it, frames asked for while a page turn animates.
+ if context_menu::is_visible() {
+ if context_menu::is_turning() {
+ self.redraw = true;
+ }
+ context_menu::set_hosted(false);
+ context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
+ }
+ }
+
+ fn set_title(&mut self, title: &str) {
+ self.window.setTitle(&NSString::from_str(title));
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ false
+ }
+
+ fn configured(&self) -> bool {
+ self.logical.0 > 0.0 && self.logical.1 > 0.0
+ }
+
+ fn present(&mut self, _fresh: bool) {
+ let size = self.size();
+ let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
+ if frame.dl_text {
+ let spans = frame.text_spans(&self.items);
+ self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
+ }
+ let (pw, ph) = physical(self.logical, self.scale);
+ let e = self.renderer.pending_extent();
+ if (e.width, e.height) != (pw, ph) {
+ self.renderer.resize(pw, ph);
+ }
+ if self.app.stage_renderer(&mut self.renderer, size, self.scale) {
+ self.redraw = true;
+ }
+ if self.renderer.draw_frame_2d(frame.frame2d()) {
+ self.damage_owed = false;
+ } else {
+ self.redraw = true;
+ }
+ }
+}
+
+fn physical(logical: (f32, f32), scale: f64) -> (u32, u32) {
+ let s = scale as f32;
+ ((logical.0 * s).round().max(1.0) as u32, (logical.1 * s).round().max(1.0) as u32)
+}
+
+/// The toolkit's cursor as AppKit's. AppKit has no public diagonal resize
+/// cursors before macOS 15, so those are the arrow; the two straight ones
+/// are deprecated there, for the directional cursors macOS 15 added, and
+/// still what every earlier release has.
+#[allow(deprecated)]
+fn ns_cursor(icon: CursorIcon) -> Retained<NSCursor> {
+ match icon {
+ CursorIcon::Pointer => NSCursor::pointingHandCursor(),
+ CursorIcon::Text | CursorIcon::VerticalText => NSCursor::IBeamCursor(),
+ CursorIcon::Crosshair | CursorIcon::Cell => NSCursor::crosshairCursor(),
+ CursorIcon::Grab => NSCursor::openHandCursor(),
+ CursorIcon::Grabbing | CursorIcon::Move | CursorIcon::AllScroll => NSCursor::closedHandCursor(),
+ CursorIcon::NotAllowed | CursorIcon::NoDrop => NSCursor::operationNotAllowedCursor(),
+ CursorIcon::EResize | CursorIcon::WResize | CursorIcon::EwResize | CursorIcon::ColResize => {
+ NSCursor::resizeLeftRightCursor()
+ }
+ CursorIcon::NResize | CursorIcon::SResize | CursorIcon::NsResize | CursorIcon::RowResize => {
+ NSCursor::resizeUpDownCursor()
+ }
+ CursorIcon::ContextMenu => NSCursor::contextualMenuCursor(),
+ CursorIcon::Copy => NSCursor::dragCopyCursor(),
+ CursorIcon::Alias => NSCursor::dragLinkCursor(),
+ _ => NSCursor::arrowCursor(),
+ }
+}
+
+/// An application menu with Quit (⌘Q) in it: every Mac app has one, and
+/// without it ⌘Q does nothing.
+fn main_menu(mtm: MainThreadMarker, title: &str) -> Retained<NSMenu> {
+ let bar = NSMenu::new(mtm);
+ let app_item = NSMenuItem::new(mtm);
+ let app_menu = NSMenu::new(mtm);
+ // SAFETY: `terminate:` is NSApplication's, which answers it through the
+ // delegate's `applicationShouldTerminate:` below.
+ let quit = unsafe {
+ NSMenuItem::initWithTitle_action_keyEquivalent(
+ NSMenuItem::alloc(mtm),
+ &NSString::from_str(&format!("Quit {title}")),
+ Some(sel!(terminate:)),
+ ns_string!("q"),
+ )
+ };
+ app_menu.addItem(&quit);
+ app_item.setSubmenu(Some(&app_menu));
+ bar.addItem(&app_item);
+ bar
+}
+
+/// Where an event's pointer is, in the view's (flipped) coordinates.
+fn location(view: &NSView, event: &NSEvent) -> (f32, f32) {
+ let p = view.convertPoint_fromView(event.locationInWindow(), None);
+ (p.x as f32, p.y as f32)
+}
+
+define_class!(
+ // SAFETY: NSView has no subclassing requirements, and CceView no Drop.
+ #[unsafe(super(NSView, objc2_app_kit::NSResponder, NSObject))]
+ #[thread_kind = MainThreadOnly]
+ #[name = "CceUiView"]
+ struct CceView;
+
+ unsafe impl NSObjectProtocol for CceView {}
+
+ impl CceView {
+ #[unsafe(method(isFlipped))]
+ fn is_flipped(&self) -> bool {
+ true
+ }
+
+ #[unsafe(method(acceptsFirstResponder))]
+ fn accepts_first_responder(&self) -> bool {
+ true
+ }
+
+ #[unsafe(method(acceptsFirstMouse:))]
+ fn accepts_first_mouse(&self, _event: Option<&NSEvent>) -> bool {
+ true
+ }
+
+ #[unsafe(method(mouseMoved:))]
+ fn mouse_moved(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(mouseDragged:))]
+ fn mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(rightMouseDragged:))]
+ fn right_mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(otherMouseDragged:))]
+ fn other_mouse_dragged(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Moved { x, y });
+ }
+
+ #[unsafe(method(mouseEntered:))]
+ fn mouse_entered(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Entered { x, y });
+ }
+
+ #[unsafe(method(mouseExited:))]
+ fn mouse_exited(&self, _event: &NSEvent) {
+ send(Ev::Exited);
+ }
+
+ #[unsafe(method(mouseDown:))]
+ fn mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(rightMouseDown:))]
+ fn right_mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(otherMouseDown:))]
+ fn other_mouse_down(&self, event: &NSEvent) {
+ self.press(event);
+ }
+
+ #[unsafe(method(mouseUp:))]
+ fn mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(rightMouseUp:))]
+ fn right_mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(otherMouseUp:))]
+ fn other_mouse_up(&self, event: &NSEvent) {
+ self.release(event);
+ }
+
+ #[unsafe(method(scrollWheel:))]
+ fn scroll_wheel(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Scrolled {
+ dx: event.scrollingDeltaX(),
+ dy: event.scrollingDeltaY(),
+ precise: event.hasPreciseScrollingDeltas(),
+ inverted: event.isDirectionInvertedFromDevice(),
+ phase: event.phase().0 as u64,
+ momentum: event.momentumPhase().0 as u64,
+ flags: event.modifierFlags().0 as u64,
+ x,
+ y,
+ });
+ }
+
+ #[unsafe(method(magnifyWithEvent:))]
+ fn magnify(&self, event: &NSEvent) {
+ send(Ev::Magnified { phase: event.phase().0 as u64, magnification: event.magnification() });
+ }
+
+ #[unsafe(method(keyDown:))]
+ fn key_down(&self, event: &NSEvent) {
+ self.key(event, true);
+ }
+
+ #[unsafe(method(keyUp:))]
+ fn key_up(&self, event: &NSEvent) {
+ self.key(event, false);
+ }
+
+ #[unsafe(method(flagsChanged:))]
+ fn flags_changed(&self, event: &NSEvent) {
+ send(Ev::FlagsChanged { code: event.keyCode(), flags: event.modifierFlags().0 as u64 });
+ }
+ }
+);
+
+impl CceView {
+ fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
+ let this = Self::alloc(mtm).set_ivars(());
+ // SAFETY: NSView's designated initializer.
+ let view: Retained<Self> = unsafe { msg_send![super(this), initWithFrame: frame] };
+ // Enter, exit and motion wherever the view is, window key or not.
+ let options = NSTrackingAreaOptions::MouseEnteredAndExited
+ | NSTrackingAreaOptions::MouseMoved
+ | NSTrackingAreaOptions::ActiveAlways
+ | NSTrackingAreaOptions::InVisibleRect;
+ // SAFETY: the view owns the area and outlives it; no user info.
+ let area = unsafe {
+ NSTrackingArea::initWithRect_options_owner_userInfo(
+ NSTrackingArea::alloc(),
+ NSRect::ZERO,
+ options,
+ Some(&view),
+ None,
+ )
+ };
+ view.addTrackingArea(&area);
+ view
+ }
+
+ fn press(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ let ev = Ev::Pressed { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y };
+ if send(ev) {
+ if let Some(window) = self.window() {
+ window.performWindowDragWithEvent(event);
+ }
+ }
+ }
+
+ fn release(&self, event: &NSEvent) {
+ let (x, y) = location(self, event);
+ send(Ev::Released { number: event.buttonNumber() as i64, flags: event.modifierFlags().0 as u64, x, y });
+ }
+
+ fn key(&self, event: &NSEvent, down: bool) {
+ // The driver repeats a held key itself, as it does on Wayland.
+ if event.isARepeat() {
+ return;
+ }
+ let text = |s: Option<Retained<NSString>>| s.map(|s| s.to_string()).unwrap_or_default();
+ let flags = event.modifierFlags().0 as u64;
+ let keyed = |down| Ev::Keyed {
+ code: event.keyCode(),
+ chars: text(event.characters()),
+ unmodified: text(event.charactersIgnoringModifiers()),
+ flags,
+ down,
+ };
+ send(keyed(down));
+ // AppKit sends no keyUp for a key pressed with Command held, so a ⌘
+ // shortcut is released as it is pressed: otherwise the driver would
+ // take the key as held, and repeat ⌘Z until the window lost focus.
+ if down && flags & appkit::flags::COMMAND != 0 {
+ send(keyed(false));
+ }
+ }
+}
+
+define_class!(
+ // SAFETY: NSObject has no subclassing requirements, and Delegate no Drop.
+ #[unsafe(super(NSObject))]
+ #[thread_kind = MainThreadOnly]
+ #[name = "CceUiDelegate"]
+ struct Delegate;
+
+ unsafe impl NSObjectProtocol for Delegate {}
+
+ unsafe impl NSApplicationDelegate for Delegate {
+ /// Quit asks the app to exit, as a close does; the shell stops the
+ /// run loop once it has.
+ #[unsafe(method(applicationShouldTerminate:))]
+ fn should_terminate(&self, _sender: &NSApplication) -> NSApplicationTerminateReply {
+ send(Ev::CloseRequested);
+ NSApplicationTerminateReply::TerminateCancel
+ }
+ }
+
+ unsafe impl NSWindowDelegate for Delegate {
+ #[unsafe(method(windowShouldClose:))]
+ fn window_should_close(&self, _sender: &NSWindow) -> bool {
+ send(Ev::CloseRequested);
+ false
+ }
+
+ #[unsafe(method(windowDidResize:))]
+ fn window_did_resize(&self, _notification: &NSNotification) {
+ send(Ev::Resized);
+ }
+
+ #[unsafe(method(windowDidChangeBackingProperties:))]
+ fn window_did_change_backing(&self, _notification: &NSNotification) {
+ send(Ev::Resized);
+ }
+
+ #[unsafe(method(windowDidBecomeKey:))]
+ fn window_did_become_key(&self, _notification: &NSNotification) {
+ send(Ev::Focused(true));
+ }
+
+ #[unsafe(method(windowDidResignKey:))]
+ fn window_did_resign_key(&self, _notification: &NSNotification) {
+ send(Ev::Focused(false));
+ }
+ }
+);
+
+impl Delegate {
+ fn new(mtm: MainThreadMarker) -> Retained<Self> {
+ let this = Self::alloc(mtm).set_ivars(());
+ // SAFETY: NSObject's `init`.
+ unsafe { msg_send![super(this), init] }
+ }
+}
diff --git a/src/vk/core.rs b/src/vk/core.rs
index a8a08fa..dba60a3 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -2,8 +2,9 @@
//! validation layers), physical device + graphics queue, gpu-allocator, and
//! the shared command pool.
//!
-//! Two ways in: [`VkCore::new_for_wayland_surface`] picks a present-capable
-//! device for a window (what `VkRenderer` uses), and [`VkCore::new_headless`]
+//! Two ways in: [`VkCore::new_for_surface`] picks a present-capable device
+//! for a window — a Wayland surface, or on macOS a `CAMetalLayer` through
+//! MoltenVK (what `VkRenderer` uses) — and [`VkCore::new_headless`]
//! builds the same core with no surface at all — for offscreen consumers
//! (thumbnail rendering, previews, the future RT engine) that render into
//! images instead of a swapchain.
@@ -86,6 +87,8 @@ struct SharedInstance {
_debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
/// VK_KHR_surface + VK_KHR_wayland_surface were available and enabled.
has_wayland_surface: bool,
+ /// VK_KHR_surface + VK_EXT_metal_surface were (MoltenVK, on macOS).
+ has_metal_surface: bool,
api_version: u32,
}
@@ -135,14 +138,29 @@ fn shared_instance() -> &'static SharedInstance {
.iter()
.any(|e| CStr::from_ptr(e.extension_name.as_ptr()) == name)
};
- let has_wayland_surface =
- has_inst_ext(ash::khr::surface::NAME) && has_inst_ext(ash::khr::wayland_surface::NAME);
+ let has_surface = has_inst_ext(ash::khr::surface::NAME);
+ let has_wayland_surface = has_surface && has_inst_ext(ash::khr::wayland_surface::NAME);
+ let has_metal_surface = has_surface && has_inst_ext(ash::ext::metal_surface::NAME);
+ // MoltenVK is a PORTABILITY driver: since loader 1.3.216 the loader
+ // lists one only to an instance that asks for portability
+ // enumeration, and a device of one must enable
+ // VK_KHR_portability_subset (below). macOS only, so the instance a
+ // Linux driver sees is the one it always saw.
+ let portability = cfg!(target_os = "macos") && has_inst_ext(ash::khr::portability_enumeration::NAME);
let mut extension_names: Vec<*const i8> = Vec::new();
- if has_wayland_surface {
+ if has_wayland_surface || has_metal_surface {
extension_names.push(ash::khr::surface::NAME.as_ptr());
+ }
+ if has_wayland_surface {
extension_names.push(ash::khr::wayland_surface::NAME.as_ptr());
}
+ if has_metal_surface {
+ extension_names.push(ash::ext::metal_surface::NAME.as_ptr());
+ }
+ if portability {
+ extension_names.push(ash::khr::portability_enumeration::NAME.as_ptr());
+ }
if validation_available {
extension_names.push(ash::ext::debug_utils::NAME.as_ptr());
}
@@ -156,6 +174,11 @@ fn shared_instance() -> &'static SharedInstance {
let instance = entry
.create_instance(
&vk::InstanceCreateInfo::default()
+ .flags(if portability {
+ vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR
+ } else {
+ vk::InstanceCreateFlags::empty()
+ })
.application_info(&app_info)
.enabled_extension_names(&extension_names)
.enabled_layer_names(&layer_names),
@@ -193,11 +216,52 @@ fn shared_instance() -> &'static SharedInstance {
instance,
_debug: debug,
has_wayland_surface,
+ has_metal_surface,
api_version,
}
})
}
+/// What a window's `VkSurfaceKHR` is made from: the window system's own
+/// handles, as raw pointers.
+#[derive(Debug, Clone, Copy)]
+pub enum SurfaceTarget {
+ /// A `wl_display` and a `wl_surface` on it.
+ Wayland { display: *mut c_void, surface: *mut c_void },
+ /// A `CAMetalLayer` (macOS, through MoltenVK's VK_EXT_metal_surface).
+ Metal { layer: *const c_void },
+}
+
+impl SurfaceTarget {
+ /// Make the surface on `instance`, or fail as [`SurfaceLost`] (a dead
+ /// display connection). A loader with no extension for this kind of
+ /// window is a broken install, and panics naming it.
+ unsafe fn create(self, instance: &ash::Instance) -> Result<vk::SurfaceKHR, SurfaceLost> {
+ let shared = shared_instance();
+ match self {
+ SurfaceTarget::Wayland { display, surface } => {
+ if !shared.has_wayland_surface {
+ panic!("Vulkan loader offers no VK_KHR_wayland_surface but a window was requested");
+ }
+ ash::khr::wayland_surface::Instance::new(&shared.entry, instance)
+ .create_wayland_surface(
+ &vk::WaylandSurfaceCreateInfoKHR::default().display(display).surface(surface),
+ None,
+ )
+ .map_err(|result| SurfaceLost { call: "vkCreateWaylandSurfaceKHR", result })
+ }
+ SurfaceTarget::Metal { layer } => {
+ if !shared.has_metal_surface {
+ panic!("Vulkan loader offers no VK_EXT_metal_surface (is MoltenVK installed?) but a window was requested");
+ }
+ ash::ext::metal_surface::Instance::new(&shared.entry, instance)
+ .create_metal_surface(&vk::MetalSurfaceCreateInfoEXT::default().layer(layer.cast()), None)
+ .map_err(|result| SurfaceLost { call: "vkCreateMetalSurfaceEXT", result })
+ }
+ }
+ }
+}
+
/// A Vulkan call on a window surface failed — in practice
/// `ERROR_SURFACE_LOST_KHR`: the display connection under the surface is dead,
/// because the compositor exited (a logout) or the transport broke. Mesa's
@@ -273,7 +337,17 @@ impl VkCore {
display_ptr: *mut c_void,
surface_ptr: *mut c_void,
) -> Result<(Self, vk::SurfaceKHR), SurfaceLost> {
- let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)))?;
+ Self::new_for_surface(SurfaceTarget::Wayland { display: display_ptr, surface: surface_ptr })
+ }
+
+ /// A core bound to a window's surface, whatever the window system: the
+ /// general form of [`new_for_wayland_surface`](Self::new_for_wayland_surface).
+ ///
+ /// # Safety
+ /// The target's pointers must be live and outlive the core and
+ /// everything created from it.
+ pub unsafe fn new_for_surface(target: SurfaceTarget) -> Result<(Self, vk::SurfaceKHR), SurfaceLost> {
+ let (core, surface) = Self::new_inner(Some(target))?;
Ok((core, surface.expect("surface requested but not created")))
}
@@ -289,16 +363,16 @@ impl VkCore {
display_ptr: *mut c_void,
surface_ptr: *mut c_void,
) -> Result<vk::SurfaceKHR, SurfaceLost> {
- let shared = shared_instance();
- let wayland_loader = ash::khr::wayland_surface::Instance::new(&shared.entry, &self.instance);
- let surface = wayland_loader
- .create_wayland_surface(
- &vk::WaylandSurfaceCreateInfoKHR::default()
- .display(display_ptr)
- .surface(surface_ptr),
- None,
- )
- .map_err(|result| SurfaceLost { call: "vkCreateWaylandSurfaceKHR", result })?;
+ self.create_surface(SurfaceTarget::Wayland { display: display_ptr, surface: surface_ptr })
+ }
+
+ /// A new `VkSurfaceKHR` on another window, from this core's instance:
+ /// the general form of [`create_wayland_surface`](Self::create_wayland_surface).
+ ///
+ /// # Safety
+ /// The target's pointers must be live and outlive the returned surface.
+ pub unsafe fn create_surface(&self, target: SurfaceTarget) -> Result<vk::SurfaceKHR, SurfaceLost> {
+ let surface = target.create(&self.instance)?;
// The device was chosen for the FIRST surface's present support; a
// later surface on the same display is presentable from the same
// family on every driver this runs on, but say so if not.
@@ -320,7 +394,7 @@ impl VkCore {
}
unsafe fn new_inner(
- wayland: Option<(*mut c_void, *mut c_void)>,
+ window: Option<SurfaceTarget>,
) -> Result<(Self, Option<vk::SurfaceKHR>), SurfaceLost> {
// CCE_VK_DEVICE: "integrated" (the default), "discrete", or a device
// name substring. An explicit request also lifts a session-wide ICD
@@ -340,27 +414,12 @@ impl VkCore {
let entry = &shared.entry;
let instance = shared.instance.clone();
let api_version = shared.api_version;
- if wayland.is_some() && !shared.has_wayland_surface {
- panic!("Vulkan loader offers no VK_KHR_wayland_surface but a window was requested");
- }
// Instance-level loader; only usable when VK_KHR_surface was enabled.
let surface_loader = ash::khr::surface::Instance::new(entry, &instance);
- let surface = match wayland {
- Some((display_ptr, surface_ptr)) => {
- let wayland_loader = ash::khr::wayland_surface::Instance::new(entry, &instance);
- Some(
- wayland_loader
- .create_wayland_surface(
- &vk::WaylandSurfaceCreateInfoKHR::default()
- .display(display_ptr)
- .surface(surface_ptr),
- None,
- )
- .map_err(|result| SurfaceLost { call: "vkCreateWaylandSurfaceKHR", result })?,
- )
- }
+ let surface = match window {
+ Some(target) => Some(target.create(&instance)?),
None => None,
};
@@ -468,7 +527,7 @@ impl VkCore {
let queue_infos = [vk::DeviceQueueCreateInfo::default()
.queue_family_index(queue_family)
.queue_priorities(&queue_priorities)];
- let mut device_extensions: Vec<*const i8> = if wayland.is_some() {
+ let mut device_extensions: Vec<*const i8> = if window.is_some() {
vec![ash::khr::swapchain::NAME.as_ptr()]
} else {
Vec::new()
@@ -548,7 +607,12 @@ impl VkCore {
.queue_create_infos(&queue_infos)
.enabled_features(&enabled_features);
let incremental_present =
- wayland.is_some() && has_ext(ash::khr::incremental_present::NAME);
+ window.is_some() && has_ext(ash::khr::incremental_present::NAME);
+ // A portability driver's device (MoltenVK) MUST enable the subset
+ // extension it offers; no Linux driver offers it.
+ if has_ext(ash::khr::portability_subset::NAME) {
+ device_extensions.push(ash::khr::portability_subset::NAME.as_ptr());
+ }
if incremental_present {
device_extensions.push(ash::khr::incremental_present::NAME.as_ptr());
}
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 41bd80e..50d368d 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -49,7 +49,7 @@ mod scene;
mod text;
pub use compute::{workgroups, BindKind, Binding, ComputeDevice, Kernel, MAX_BINDINGS};
-pub use core::{SurfaceLost, VkCore};
+pub use core::{SurfaceLost, SurfaceTarget, VkCore};
pub use image::{
free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
upload_rgba_mipmapped, ImageQuad, PixelFormat,
diff --git a/src/vk/plate_probe.rs b/src/vk/plate_probe.rs
index 41d62df..2d6e966 100644
--- a/src/vk/plate_probe.rs
+++ b/src/vk/plate_probe.rs
@@ -48,8 +48,8 @@ impl Rendered {
pub(crate) fn render(dl: &DisplayList, logical_w: f32, logical_h: f32, scale: f32) -> Option<Rendered> {
let mut core = std::panic::catch_unwind(VkCore::new_headless).ok()?;
let (verts, dl_batches, _images, features) =
- crate::backend::window_runner::tessellate_display_list(dl, logical_w, logical_h, scale);
- let batches = crate::backend::window_runner::dl_batches_2d(&dl_batches, scale);
+ crate::backend::tessellate::tessellate_display_list(dl, logical_w, logical_h, scale);
+ let batches = crate::backend::tessellate::dl_batches_2d(&dl_batches, scale);
assert!(
batches.iter().all(|b| !b.blur_behind),
"plate_probe draws no blur-behind batch: give the plates an unfrosted material"
@@ -376,7 +376,7 @@ mod tests {
let (w, h, scale) = (540.0f32, 280.0f32, 2.0f32);
let dl = two_plates(w, h);
// Not vacuous: the left plate's five carves really are grouped.
- let (_, _, _, features) = crate::backend::window_runner::tessellate_display_list(&dl, w, h, scale);
+ let (_, _, _, features) = crate::backend::tessellate::tessellate_display_list(&dl, w, h, scale);
assert_eq!(features.len(), 5, "the left plate's carves should group");
let Some(img) = render(&dl, w, h, scale) else {
eprintln!("skipping: no Vulkan device");
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 8e0768d..f7ed21c 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -589,11 +589,30 @@ impl VkRenderer {
width: u32,
height: u32,
corner_radius_px: f32,
+ ) -> Result<Self, SurfaceLost> {
+ Self::try_new_for(
+ super::core::SurfaceTarget::Wayland { display: display_ptr, surface: surface_ptr },
+ width,
+ height,
+ corner_radius_px,
+ )
+ }
+
+ /// A renderer presenting to any window [`SurfaceTarget`](super::core::SurfaceTarget)
+ /// names — a Wayland surface, or on macOS a `CAMetalLayer` — on the same
+ /// terms as [`try_new`](Self::try_new).
+ ///
+ /// # Safety
+ /// The target's pointers must be live and outlive the renderer.
+ pub unsafe fn try_new_for(
+ target: super::core::SurfaceTarget,
+ width: u32,
+ height: u32,
+ corner_radius_px: f32,
) -> Result<Self, SurfaceLost> {
let t_new = std::time::Instant::now();
- let (mut core, surface) =
- super::core::VkCore::new_for_wayland_surface(display_ptr, surface_ptr)?;
- log::debug!("[timing] VkCore::new_for_wayland_surface: {:?}", t_new.elapsed());
+ let (mut core, surface) = super::core::VkCore::new_for_surface(target)?;
+ log::debug!("[timing] VkCore::new_for_surface: {:?}", t_new.elapsed());
let t_rest = std::time::Instant::now();
// Locals over the core for the setup below (methods use self.core.*).
let device = core.device.clone();
@@ -1632,11 +1651,29 @@ impl VkRenderer {
surface_ptr: *mut c_void,
width: u32,
height: u32,
+ ) -> Result<(), SurfaceLost> {
+ self.attach_surface_to(
+ super::core::SurfaceTarget::Wayland { display: display_ptr, surface: surface_ptr },
+ width,
+ height,
+ )
+ }
+
+ /// [`attach_surface`](Self::attach_surface) for any window
+ /// [`SurfaceTarget`](super::core::SurfaceTarget).
+ ///
+ /// # Safety
+ /// The target's pointers must be live and outlive the attachment.
+ pub unsafe fn attach_surface_to(
+ &mut self,
+ target: super::core::SurfaceTarget,
+ width: u32,
+ height: u32,
) -> Result<(), SurfaceLost> {
if self.surface != vk::SurfaceKHR::null() {
self.detach_surface();
}
- self.surface = self.core.create_wayland_surface(display_ptr, surface_ptr)?;
+ self.surface = self.core.create_surface(target)?;
self.surface_lost = false;
self.resize(width, height);
self.swapchain_dirty = true;
diff --git a/src/web/shell.rs b/src/web/shell.rs
index e471b49..7397f6a 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -522,7 +522,7 @@ impl<A: Application> Loop<A> {
let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
s.sync_mods(mods);
let pos = s.pointer_pos(&e);
- let site = PressSite { size: s.size(), on_popup: false, can_grab: false };
+ let site = PressSite { size: s.size(), on_popup: false, can_grab: false, own_edges: false };
let (driver, t) = s.turn();
driver.pointer_press(t, btn, pos, site);
})
diff --git a/tests/lattice_probe.rs b/tests/lattice_probe.rs
index e7af4a9..6e0a179 100644
--- a/tests/lattice_probe.rs
+++ b/tests/lattice_probe.rs
@@ -13,7 +13,7 @@ fn lattice_survives_tessellation_inside_a_clip() {
let kinds: Vec<String> = dl.items.iter().map(|i| format!("{:?}", std::mem::discriminant(&i.prim))).collect();
let n_lattice = dl.items.iter().filter(|i| matches!(i.prim, Prim::Lattice { .. })).count();
assert_eq!(n_lattice, 1, "display list: {kinds:?}");
- let (verts, batches, _, _) = cce_ui::backend::window_runner::tessellate_display_list(&dl, 1280.0, 720.0, 2.0);
+ let (verts, batches, _, _) = cce_ui::backend::tessellate::tessellate_display_list(&dl, 1280.0, 720.0, 2.0);
let modes: Vec<f32> = batches.iter().filter_map(|b| b.plate.as_ref().map(|p| p.mode)).collect();
eprintln!("verts={} batches={} plate modes={modes:?}", verts.len(), batches.len());
assert!(modes.contains(&13.0), "no lattice batch; modes={modes:?}");
@@ -83,7 +83,7 @@ fn grout_tessellates_to_a_mode_15_batch_in_its_colour() {
let clip = Rect { x: 40.0, y: 40.0, width: 600.0, height: 300.0 };
pc.clip(clip, |pc| pc.grout(clip, (85.0, 43.0), (60.0, 60.0), (71.0, 31.0), 15.5, [0.5, 0.5, 0.5, 0.1]));
let dl = pc.finish();
- let (verts, batches, _, _) = cce_ui::backend::window_runner::tessellate_display_list(&dl, 1280.0, 720.0, 2.0);
+ let (verts, batches, _, _) = cce_ui::backend::tessellate::tessellate_display_list(&dl, 1280.0, 720.0, 2.0);
let modes: Vec<f32> = batches.iter().filter_map(|b| b.plate.as_ref().map(|p| p.mode)).collect();
assert_eq!(modes, vec![15.0]);
assert_eq!(verts.len(), 6);
diff --git a/tests/plate_golden.rs b/tests/plate_golden.rs
index 615d421..92ad843 100644
--- a/tests/plate_golden.rs
+++ b/tests/plate_golden.rs
@@ -134,7 +134,7 @@ fn scene(sw: f32, sh: f32) -> cce_ui::scene::paint::DisplayList {
fn dump(out: &mut String, label: &str, dl: &cce_ui::scene::paint::DisplayList, sw: f32, sh: f32, scale: f32) {
let (verts, batches, images, features) =
- cce_ui::backend::window_runner::tessellate_display_list(dl, sw, sh, scale);
+ cce_ui::backend::tessellate::tessellate_display_list(dl, sw, sh, scale);
writeln!(out, "== {label} scale={scale} verts={} batches={} images={} features={}",
verts.len(), batches.len(), images.len(), features.len()).unwrap();
for (i, v) in verts.iter().enumerate() {