diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1397a1d..52f66e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -22,6 +22,7 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libxkbcommon-dev libfontconfig1-dev pkg-config - name: Benchmark sampler failure evidence + if: runner.os != 'Windows' run: python3 scripts/test_compare.py -v - run: cargo fmt --all -- --check - run: cargo check --workspace --all-targets --locked diff --git a/README.md b/README.md index 9ed9f4b..21bba44 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # zgui -A retained Rust GUI framework focused on **Linux and macOS**, fine-grained updates, bounded rendering resources, and low idle CPU usage. It has a platform-independent core, a wgpu renderer, and a desktop host with native input, clipboard, IME, accessibility, and multiple windows. +A retained Rust GUI framework for **Linux, macOS and Windows**, focused on fine-grained updates, bounded rendering resources, and low idle CPU usage. It has a platform-independent core, a wgpu renderer, and a desktop host with native input, clipboard, IME, accessibility, and multiple windows. ```sh cargo run --release -p zgui-desktop --example components @@ -33,7 +33,10 @@ Linux builds need the native development libraries for fontconfig, xkbcommon and D-Bus, plus `pkg-config` (for example `libfontconfig1-dev libxkbcommon-dev libdbus-1-dev pkg-config` on Debian/Ubuntu). File dialogs use the desktop's XDG portal; prompts use `zenity`. macOS builds use the system frameworks and Xcode -command-line tools. See [native services](docs/platform-services.md) for backend +command-line tools. Windows builds use the stable MSVC Rust toolchain and Visual +Studio Build Tools with the C++ workload and Windows SDK; no GTK installation is +needed. See [Windows support](docs/windows.md) for validation and limitations, +and [native services](docs/platform-services.md) for backend behavior and cancellation guarantees. Applications describe components and children. They mount once; reactive properties update only their retained targets: diff --git a/crates/zgui-desktop/Cargo.toml b/crates/zgui-desktop/Cargo.toml index 930fbb7..ba4538b 100644 --- a/crates/zgui-desktop/Cargo.toml +++ b/crates/zgui-desktop/Cargo.toml @@ -33,7 +33,7 @@ wayland-protocols = { version = "=0.32.13", features = ["client", "unstable"] } muda = { version = "=0.18.0", default-features = false } objc2 = "=0.6.4" objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSView", "NSWindow", "NSTextInputContext"] } -objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSObject", "NSString", "NSURL", "NSAppleEventManager", "NSAppleEventDescriptor", "objc2-core-services"] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSObject", "NSString", "NSURL", "NSDate", "NSTimer", "NSAppleEventManager", "NSAppleEventDescriptor", "objc2-core-services"] } [[bin]] name = "zgui-demo" diff --git a/crates/zgui-desktop/examples/native_message_pump.rs b/crates/zgui-desktop/examples/native_message_pump.rs new file mode 100644 index 0000000..3fd8e78 --- /dev/null +++ b/crates/zgui-desktop/examples/native_message_pump.rs @@ -0,0 +1,31 @@ +//! Runnable native smoke check: cargo run -p zgui-desktop --example native_message_pump +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn main() -> Result<(), Box> { + use std::{cell::Cell, rc::Rc, time::Duration}; + use zgui::compose::prelude::*; + use zgui_desktop::Application; + + let calls = Rc::new(Cell::new(0)); + let observed = calls.clone(); + Application::new().run_with_pump( + move || observed.set(observed.get() + 1), + |cx| { + cx.render(text("Native message pump smoke check")); + let window = cx.window.clone(); + cx.tasks.spawn(async move { + for _ in 0..20 { + zgui::timer::sleep(Duration::from_millis(10)).await; + } + window.close(); + }); + }, + )?; + assert!(calls.get() > 0, "native message pump was never invoked"); + println!("native message pump: {} calls", calls.get()); + Ok(()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn main() { + eprintln!("This smoke check requires Windows or macOS"); +} diff --git a/crates/zgui-desktop/src/application.rs b/crates/zgui-desktop/src/application.rs index 2da86ac..1d0f223 100644 --- a/crates/zgui-desktop/src/application.rs +++ b/crates/zgui-desktop/src/application.rs @@ -397,11 +397,17 @@ pub struct WindowContext { menu_action: Option>, } impl WindowContext { + /// The live native window; unavailable during initial UI construction. + /// Clone `dialogs` and call its `native_window()` later for deferred access. + pub fn native_window(&self) -> Option> { + self.dialogs.native_window() + } + /// Application command fallback after focused component action routing. pub fn on_menu_action(&mut self, handler: impl FnMut(crate::MenuAction) + 'static) { self.menu_action = Some(Box::new(handler)); } - /// A rendered menu bar for Linux; macOS normally uses the system menu bar. + /// A rendered menu bar for Linux and Windows; macOS uses the system menu bar. pub fn app_menu_bar(&self) -> zgui::compose::View { let window = self.window.clone(); let model = self.menu_model.clone(); @@ -466,6 +472,8 @@ impl WindowContext { } } pub struct Application { + #[cfg(any(target_os = "windows", target_os = "macos"))] + message_pump: Option>, fonts: Vec, options: WindowOptions, menus: Vec, @@ -477,6 +485,8 @@ type ApplicationEventHandler = Box Self { Self { + #[cfg(any(target_os = "windows", target_os = "macos"))] + message_pump: None, fonts: Vec::new(), options: WindowOptions::default(), menus: Vec::new(), @@ -503,6 +513,7 @@ impl Application { /// macOS owns GetURL, OpenDocuments, and Reopen AppleEvent routes for the /// event loop lifetime while preserving its NSApplication delegate. Do not /// install competing handlers for these routes while this service is active. + /// Windows has no activation backend; opting in makes `run` return an error. pub fn application_id( mut self, id: impl Into, @@ -525,8 +536,35 @@ impl Application { self.quit_on_last_window_close = quit; self } + /// Run with a native-message pump, such as `CefDoMessageLoopWork`. + /// + /// On Windows the pump runs before native message dispatch, with no + /// application callback active. Calling a foreign pump from a UI task can + /// otherwise starve painting: winit requeues paints during nested callbacks. + /// The caller must arrange wakeups while idle, for example with a timed task. + /// On macOS a 10 ms default-mode timer invokes it outside winit callbacks; + /// pumping pauses during native modal and tracking loops. + #[cfg(any(target_os = "windows", target_os = "macos"))] + pub fn run_with_pump( + mut self, + pump: impl FnMut() + 'static, + build: impl FnOnce(&mut WindowContext), + ) -> Result<(), Box> { + self.message_pump = Some(Box::new(pump)); + self.run(build) + } + pub fn run(self, build: impl FnOnce(&mut WindowContext)) -> Result<(), Box> { - let event_loop = EventLoop::::with_user_event().build()?; + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "windows")] + if let Some(mut pump) = self.message_pump { + use winit::platform::windows::EventLoopBuilderExtWindows; + builder.with_msg_hook(move |_| { + pump(); + false + }); + } + let event_loop = builder.build()?; let proxy = event_loop.create_proxy(); let native_events = if let Some(id) = &self.application_id { let wake = proxy.clone(); @@ -581,7 +619,14 @@ impl Application { active: false, error: None, }; - event_loop.run_app(&mut host)?; + #[cfg(target_os = "macos")] + let message_pump = self + .message_pump + .map(crate::message_pump_macos::MessagePump::new); + let result = event_loop.run_app(&mut host); + #[cfg(target_os = "macos")] + drop(message_pump); + result?; if let Some(e) = host.error.take() { return Err(e.into()); } @@ -2608,10 +2653,13 @@ mod tests { assert!(retained.pending.borrow().is_empty()); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] #[test] - #[ignore = "requires an owned X11 display and GPU; run alone with --test-threads=1"] + #[ignore = "requires a desktop and GPU; run alone with --test-threads=1"] fn host_suspend_recreates_native_resources_and_retains_editor_state() { + #[cfg(target_os = "windows")] + use winit::platform::windows::EventLoopBuilderExtWindows; + #[cfg(target_os = "linux")] use winit::platform::x11::EventLoopBuilderExtX11; struct Probe { factory: WindowFactory, @@ -2621,6 +2669,8 @@ mod tests { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let handle = self.factory.allocate(); let mut host = Host::new(WindowOptions::default(), handle, self.factory.clone()); + let native_provider = host.context.dialogs.clone(); + assert!(native_provider.native_window().is_none()); let value = host.context.ui.signal(String::from("retained document")); let editor = host.context.ui.text_input( host.context.ui.root(), @@ -2632,6 +2682,7 @@ mod tests { host.create_window(event_loop).unwrap(); let window = Arc::downgrade(host.window.as_ref().unwrap()); let first_id = host.window.as_ref().unwrap().id(); + assert_eq!(native_provider.native_window().unwrap().id(), first_id); host.create_window(event_loop).unwrap(); assert_eq!(host.window.as_ref().unwrap().id(), first_id); host.window_event(WindowEvent::Focused(true)).unwrap(); @@ -2660,6 +2711,7 @@ mod tests { host.suspend(); // Platform callbacks may repeat. assert!(host.window.is_none() && host.renderer.is_none() && host.adapter.is_none()); assert!(window.upgrade().is_none()); + assert!(native_provider.native_window().is_none()); assert!(!host.native_focused && !host.ime_active); assert!(host.ime_target.is_none() && host.ime_geometry.is_none()); assert!(host.context.ui.input.captured().is_none()); @@ -2672,6 +2724,7 @@ mod tests { assert_eq!(editor.editor.borrow().text(), "updated while suspended"); host.create_window(event_loop).unwrap(); assert!(host.window.is_some() && host.renderer.is_some() && host.adapter.is_some()); + assert!(native_provider.native_window().is_some()); assert!(host.force); host.window_event(WindowEvent::Focused(true)).unwrap(); assert_eq!(host.context.ui.input.focused(), Some(editor.node)); @@ -2691,11 +2744,10 @@ mod tests { } fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} } - let event_loop = EventLoop::::with_user_event() - .with_x11() - .with_any_thread(true) - .build() - .unwrap(); + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "linux")] + builder.with_x11(); + let event_loop = builder.with_any_thread(true).build().unwrap(); let factory = WindowFactory { fonts: Rc::new(Vec::new()), #[cfg(target_os = "linux")] diff --git a/crates/zgui-desktop/src/clipboard.rs b/crates/zgui-desktop/src/clipboard.rs index 6c3e5c4..105cb3a 100644 --- a/crates/zgui-desktop/src/clipboard.rs +++ b/crates/zgui-desktop/src/clipboard.rs @@ -1,7 +1,7 @@ //! Clipboard selection follows the actual native display, never environment guesses. //! //! Wayland uses the application's connection and standard data-device protocol; -//! X11 and macOS retain arboard. Construct before the first input event so the +//! X11, macOS and Windows retain arboard. Construct before the first input event so the //! Wayland worker can observe seat focus and the serial needed for selection. use std::error::Error; use winit::event_loop::OwnedDisplayHandle; diff --git a/crates/zgui-desktop/src/file_dialog.rs b/crates/zgui-desktop/src/file_dialog.rs index 750b9fd..99a27ef 100644 --- a/crates/zgui-desktop/src/file_dialog.rs +++ b/crates/zgui-desktop/src/file_dialog.rs @@ -96,8 +96,8 @@ impl fmt::Display for FileDialogError { } } impl std::error::Error for FileDialogError {} -/// `None` means no selection. Linux reports portal failures as errors; the macOS -/// backend may also return no selection when a native picker is unavailable. +/// `None` means no selection. Linux reports portal failures as errors; macOS and +/// Windows may also return no selection when a native picker is unavailable. pub type FileDialogResult = Result, FileDialogError>; #[derive(Clone, Copy)] pub(crate) enum Kind { @@ -109,12 +109,15 @@ pub(crate) enum Kind { } /// Cloneable service for one window. Invoke from its UI executor after native creation. -/// Linux uses an owned XDG Desktop Portal request; macOS uses native sheets. +/// Linux uses an owned XDG Desktop Portal request; macOS uses native sheets; +/// Windows uses native file dialogs owned by the window. /// /// On Linux, dropping a file-picker future or closing its owner requests portal /// dismissal and releases the native parent. On macOS, closing or suspending the /// owner dismisses its native sheets, including prompts. Dropping an individual /// macOS request future cancels delivery; its sheet remains until dismissed. +/// On Windows, cancellation invalidates delivery but does not dismiss the native +/// dialog. Its parent remains alive until the dialog is dismissed. #[derive(Clone)] pub struct FileDialogs { owner: crate::WindowHandle, @@ -123,6 +126,19 @@ pub struct FileDialogs { cancellations: Rc>>>, } impl FileDialogs { + /// Return the live native window for host integrations such as embedded views. + /// + /// Returns `None` before native creation, while suspended, and after closure. + /// Clone this service during UI construction and call it later on the UI + /// executor. Release the returned `Arc` when finished: retaining it also + /// retains the native window after its logical owner closes. + pub fn native_window(&self) -> Option> { + if self.owner.is_closed() { + return None; + } + self.native.borrow().as_ref().and_then(Weak::upgrade) + } + pub(crate) fn new(owner: crate::WindowHandle) -> Self { Self { owner, @@ -287,6 +303,38 @@ async fn native_select( ) -> FileDialogResult> { background(move || crate::portal_dialog::select(kind, options, parent, cancel))?.await? } +#[cfg(target_os = "windows")] +async fn native_select( + kind: Kind, + options: FileDialogOptions, + parent: Arc, + _cancel: Arc, +) -> FileDialogResult> { + // The worker owns the parent even when the UI future is cancelled; rfd + // stores only its raw handle, which must remain valid until Show returns. + background(move || { + let mut dialog = rfd::FileDialog::new() + .set_parent(parent.as_ref()) + .set_title(options.title); + if let Some(directory) = options.directory { + dialog = dialog.set_directory(directory); + } + if let Some(name) = options.file_name { + dialog = dialog.set_file_name(name); + } + for filter in options.filters { + dialog = dialog.add_filter(filter.name, &filter.extensions); + } + match kind { + Kind::OpenFile => dialog.pick_file().map(|p| vec![p]), + Kind::OpenFiles => dialog.pick_files(), + Kind::Folder => dialog.pick_folder().map(|p| vec![p]), + Kind::Folders => dialog.pick_folders(), + Kind::Save => dialog.save_file().map(|p| vec![p]), + } + })? + .await +} #[cfg(target_os = "macos")] async fn native_select( kind: Kind, diff --git a/crates/zgui-desktop/src/lib.rs b/crates/zgui-desktop/src/lib.rs index 2ca0d41..b2dfc61 100644 --- a/crates/zgui-desktop/src/lib.rs +++ b/crates/zgui-desktop/src/lib.rs @@ -1,4 +1,4 @@ -//! Native Linux/macOS application host with GPU rendering, accessibility, clipboard, +//! Native Linux/macOS/Windows host with GPU rendering, accessibility, clipboard, //! and IME. A software raster backend is available for reference and headless tests. //! //! ```no_run @@ -22,6 +22,8 @@ pub mod app_menu; mod clipboard; pub mod file_dialog; +#[cfg(target_os = "macos")] +mod message_pump_macos; mod native_events; mod native_prompt; #[cfg(target_os = "linux")] diff --git a/crates/zgui-desktop/src/message_pump_macos.rs b/crates/zgui-desktop/src/message_pump_macos.rs new file mode 100644 index 0000000..4aa5a11 --- /dev/null +++ b/crates/zgui-desktop/src/message_pump_macos.rs @@ -0,0 +1,61 @@ +use std::cell::RefCell; + +use objc2::{ + DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, rc::Retained, sel, +}; +use objc2_foundation::{NSObject, NSObjectProtocol, NSTimer}; + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[ivars = RefCell>] + struct PumpTarget; + + impl PumpTarget { + #[unsafe(method(tick:))] + fn tick(&self, _timer: &NSTimer) { + // A pump may enter a nested run loop. Never invoke the same closure + // reentrantly while it holds mutable browser state. + if let Ok(mut pump) = self.ivars().try_borrow_mut() { + pump(); + } + } + } + + unsafe impl NSObjectProtocol for PumpTarget {} +); + +pub(crate) struct MessagePump { + timer: Retained, + _target: Retained, +} + +impl MessagePump { + pub(crate) fn new(pump: Box) -> Self { + let mtm = MainThreadMarker::new().expect("The message pump must run on the main thread"); + let target = PumpTarget::alloc(mtm).set_ivars(RefCell::new(pump)); + let target: Retained = unsafe { msg_send![super(target), init] }; + // NSTimer dispatches outside winit's ApplicationHandler borrow, while + // run_app keeps its handler registered. Default mode deliberately excludes + // modal/tracking loops entered from application callbacks. + let timer = unsafe { + NSTimer::scheduledTimerWithTimeInterval_target_selector_userInfo_repeats( + 0.01, + &target, + sel!(tick:), + None, + true, + ) + }; + Self { + timer, + _target: target, + } + } +} + +impl Drop for MessagePump { + fn drop(&mut self) { + self.timer.invalidate(); + } +} diff --git a/crates/zgui-desktop/src/native_events.rs b/crates/zgui-desktop/src/native_events.rs index d26a74f..4d202d9 100644 --- a/crates/zgui-desktop/src/native_events.rs +++ b/crates/zgui-desktop/src/native_events.rs @@ -245,11 +245,35 @@ mod platform { } } } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "windows")] +mod platform { + use super::*; + + pub(crate) struct NativeApplicationEvents; + impl NativeApplicationEvents { + pub(crate) fn new(id: &str, _handler: Handler) -> Result { + validate_id(id)?; + Err(ApplicationEventError( + "Windows URL activation is not registered by zgui; handle launch arguments in the application".into(), + )) + } + } +} +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] pub(crate) use platform::NativeApplicationEvents; #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "windows")] + #[test] + fn windows_activation_is_explicitly_unsupported() { + let result = NativeApplicationEvents::new("org.example.Editor", Arc::new(|_| {})); + let Err(error) = result else { + panic!("Windows activation must not silently succeed without a backend"); + }; + assert!(error.0.contains("Windows URL activation")); + } + #[test] fn application_ids_are_valid_bus_names_and_paths() { assert!(validate_id("org.example.Editor").is_ok()); diff --git a/crates/zgui-desktop/src/native_prompt.rs b/crates/zgui-desktop/src/native_prompt.rs index 0b58e2c..72ffe52 100644 --- a/crates/zgui-desktop/src/native_prompt.rs +++ b/crates/zgui-desktop/src/native_prompt.rs @@ -92,16 +92,17 @@ pub(crate) async fn show( parent: Arc, options: PromptOptions, ) -> Result { - // Linux Zenity is unparented; do not retain a native owner it never uses. + #[cfg(target_os = "linux")] drop(parent); crate::file_dialog::background(move || { - rfd::MessageDialog::new() + let dialog = rfd::MessageDialog::new() .set_title(options.title) .set_description(options.description) .set_level(options.level.into()) - .set_buttons(options.buttons.into()) - .show() - .into() + .set_buttons(options.buttons.into()); + #[cfg(target_os = "windows")] + let dialog = dialog.set_parent(parent.as_ref()); + dialog.show().into() })? .await } diff --git a/docs/platform-services.md b/docs/platform-services.md index 968346e..cbad54a 100644 --- a/docs/platform-services.md +++ b/docs/platform-services.md @@ -3,7 +3,8 @@ `WindowContext::dialogs` provides asynchronous open-file, open-files, open-folder, open-folders, and save-file selection. Linux uses XDG Desktop Portal with native Wayland/X11 parent identifiers and owned cancellable requests. An unavailable -portal reports `BackendUnavailable`. macOS uses native AppKit sheets. The UI thread remains +portal reports `BackendUnavailable`. macOS uses native AppKit sheets; Windows +uses native owned file dialogs. The UI thread remains available while the user chooses a path. Selecting a save destination does not create or overwrite the file. @@ -38,6 +39,10 @@ Dropping an individual request future cancels delivery but does not itself dismi its sheet. Backend failures there may also appear as no selection. Prompts have the separate backend limitations described below. No callback runs on a worker. +On Windows, cancelling a future or closing/suspending the owner invalidates +delivery but does not dismiss the native dialog. The backend retains its parent +until the dialog is dismissed, and backend errors may also appear as no selection. + The portal cancellation contract follows the [official Request interface](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html). Filters contain extensions without a leading dot. Native platform extension @@ -51,7 +56,7 @@ alone does not provide file selection. `Application::menus` accepts validated `AppMenu` trees containing actions, submenus, and separators. Action identifiers must be nonempty and unique across the tree. Items can be disabled. macOS presents this model in its native -application menu. Linux has no universal native app menubar for a winit window; +application menu. Linux and Windows use the rendered menu implementation; mount `cx.app_menu_bar()` in the content layout for retained, accessible menus with keyboard traversal, disabled-item skipping, Escape dismissal, focus restoration, and nested submenus. `has_native_app_menu()` exposes the distinction. @@ -86,9 +91,9 @@ close during replacement. accessible checked state and native macOS checkmark. Update its state in the command callback; choosing the item does not mutate the application model. `MenuAccelerator::primary(Key::Character("s".into()))` uses Command on macOS and -Control on Linux. Explicit logical key/modifier combinations are also supported; +Control on Linux and Windows. Explicit logical key/modifier combinations are also supported; letters/digits, named navigation keys, and F1–F35 are accepted. Duplicate shortcuts -are rejected. Linux shortcuts register through scoped root keymaps, preserving +are rejected. Linux and Windows shortcuts register through scoped root keymaps, preserving focused component bindings and typed-action precedence; disabled entries have no binding and replacement unregisters previous shortcuts. macOS displays and handles the same shortcuts in the native menu. Standard native OS-role items @@ -110,10 +115,18 @@ Dependencies include exact `rfd 0.17.2` (MIT), `open 5.4.4`, Linux `dbus 0.9.12` Native prompts use `cx.dialogs.prompt(PromptOptions::new(title, body).buttons(PromptButtons::OkCancel)).await`. Results are typed `PromptResponse` values and retain the same owner-generation cancellation rules as file pickers. On Linux this uses the `zenity` executable from the user's desktop installation; unlike file pickers it is not an XDG portal and rfd's Linux prompt backend does not attach the native parent. A missing prompt backend can be reported as cancellation by rfd. macOS uses an AppKit alert attached to the parent window. Applications requiring strict modal blocking or backend-failure distinction should not infer those guarantees from this API. +Windows prompts use an owned native message box on a worker, keeping the native +parent alive until dismissal. Cancellation invalidates result delivery without +dismissing that message box. + `open_url("https://example.com").await` asks the OS default handler to open a URI on a worker. It rejects malformed schemes and control characters. Success means the launcher accepted the request, not that the remote resource loaded. `Application::application_id("org.example.Editor")?.on_application_event(...)` delivers typed `ApplicationEvent::OpenUrls` and `Reopen` callbacks on the UI thread. Linux owns the corresponding session-bus name and implements `org.freedesktop.Application.Open` and `Activate` at the application ID's slash-separated object path. Install the application's `.desktop` file, URI MIME associations, and D-Bus activation service separately for OS launching; this library does not modify the user's registration. macOS owns AppleEvent handlers for URL/document opens and reopen while preserving winit's application delegate. Routes are installed again after native launch initialization and removed on teardown. URL schemes still require the application's bundle `CFBundleURLTypes` registration. These callbacks are opt-in through the application ID. Native Mac reopen and OS URL launch/delivery have been observed: a test receiver registered in the user's Applications directory received the exact `zgui-smoke:accepted` URL in its retained UI after the launcher returned success. Earlier missing-registration failures are preserved. This test-only registration does not add application packaging to the framework. +Windows has no native application-activation backend. Opting into +`Application::application_id` makes `run` return an explicit error; applications +must handle launch arguments and register URI schemes themselves. + Applications that remain available after their last window closes can set `quit_on_last_window_close(false)` and create windows through the event callback's `WindowFactory`; `WindowFactory::quit()` exits explicitly. Result delivery to closed windows is suppressed. Retaining the application does not retain a closed window's UI. `WindowOptions` supports `bounds`, `display`, `min_size`, `fullscreen`, and `decorations`. `WindowBounds` uses physical pixels; minimum sizes and ordinary width/height options use logical pixels. `WindowHandle` exposes bounds, fullscreen, decorations, minimum-size and drag commands. Call `drag_window()` from the primary pointer-down handler of a custom titlebar. Fullscreen restoration is handled by the native window system. `WindowContext::window_info` provides reactive current bounds, displays, fullscreen state, capabilities and native request errors; `refresh_window_info()` requests a fresh native snapshot. diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 0000000..fe70e64 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,64 @@ +# Windows desktop support + +Use stable Rust with the `x86_64-pc-windows-msvc` target and Visual Studio Build +Tools (Desktop development with C++, including the Windows SDK). From PowerShell: + +```powershell +cargo run -p zgui-desktop --example components --locked +cargo run -p zgui-desktop --example platform_services --locked +``` + +Windows uses the existing winit event loop and wgpu renderer (DX12/Vulkan), +arboard clipboard, AccessKit Windows accessibility backend, and rfd native file +pickers and message boxes. Both file pickers and prompts receive their owning +native window. Application menus use `cx.app_menu_bar()` and Control shortcuts. +No additional Windows-only dependency is needed. + +`cx.dialogs.clone()` gives host integrations a deferred native-window provider. +Call `dialogs.native_window()` on the UI executor after window creation. It +returns `None` during initial UI construction, suspension, and after closure. +The returned `Arc` exposes current size, scale, and raw +window handles; retaining it prolongs the native window lifetime, so release it +when the integration is detached. `cx.native_window()` is a convenience accessor. + +For a foreign native event loop such as Chromium Embedded Framework, call +`Application::run_with_pump(pump, build)` instead of invoking its pump from a UI +task. On Windows the callback runs before native message dispatch while winit's +application handler is installed and idle. A nested pump inside an application +callback can repeatedly consume paints that winit defers, preventing the pump +from ever reaching idle. An outer `pump_app_events` loop alone is also unsuitable: +winit removes its handler between calls. Arrange periodic wakeups while idle, +for example a UI task awaiting a 10 ms timer, to keep the foreign loop advancing. +`cargo run -p zgui-desktop --example native_message_pump --locked` checks the +native callback and shutdown path. This probe ran successfully on Windows. + +## Limitations + +- `Application::application_id` opts into OS activation callbacks, which are not + implemented on Windows. Running an application with an ID returns an explicit + error. Handle launch arguments and protocol registration in the application. +- Cancelling a file-picker or prompt future invalidates result delivery but + does not dismiss its native dialog. A pending native dialog can retain its + parent until dismissed. Backend failures can appear as cancellation. +- Rendered application menus are supported; native Win32 menu bars are not. +- The Linux benchmark sampler and platform-specific smoke scripts do not run + on Windows. + +## Validation + +On a Windows host, the desktop library tests and the following native lifecycle +test passed. The latter creates real windows and GPU surfaces, renders an +editor, suspends and recreates the host, verifies retained text and input/IME +cleanup, and checks the deferred native-window provider across those transitions: + +```powershell +cargo test -p zgui-desktop --lib --locked +cargo test -p zgui-desktop --lib host_suspend_recreates_native_resources_and_retains_editor_state --locked -- --ignored --test-threads=1 +``` + +The standard CI matrix includes Windows compilation, library/example tests, +doctests, formatting and Clippy. The native lifecycle probe requires a desktop +and GPU and remains opt-in. Passing it does not establish real keyboard/IME +candidate interaction, dialog interaction, cross-process clipboard exchange, +screen-reader behavior, DPI transitions, or GPU performance; those need separate +native qualification.