Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion crates/zgui-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions crates/zgui-desktop/examples/native_message_pump.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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");
}
72 changes: 62 additions & 10 deletions crates/zgui-desktop/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,17 @@ pub struct WindowContext {
menu_action: Option<Box<dyn FnMut(crate::MenuAction)>>,
}
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<Arc<Window>> {
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();
Expand Down Expand Up @@ -466,6 +472,8 @@ impl WindowContext {
}
}
pub struct Application {
#[cfg(any(target_os = "windows", target_os = "macos"))]
message_pump: Option<Box<dyn FnMut()>>,
fonts: Vec<zgui_gpu::text::FontData>,
options: WindowOptions,
menus: Vec<crate::AppMenu>,
Expand All @@ -477,6 +485,8 @@ type ApplicationEventHandler = Box<dyn FnMut(crate::ApplicationEvent, &WindowFac
impl Application {
pub fn new() -> Self {
Self {
#[cfg(any(target_os = "windows", target_os = "macos"))]
message_pump: None,
fonts: Vec::new(),
options: WindowOptions::default(),
menus: Vec::new(),
Expand All @@ -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<String>,
Expand All @@ -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<dyn Error>> {
self.message_pump = Some(Box::new(pump));
self.run(build)
}

pub fn run(self, build: impl FnOnce(&mut WindowContext)) -> Result<(), Box<dyn Error>> {
let event_loop = EventLoop::<Event>::with_user_event().build()?;
let mut builder = EventLoop::<Event>::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();
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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();
Expand Down Expand Up @@ -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());
Expand All @@ -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));
Expand All @@ -2691,11 +2744,10 @@ mod tests {
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
let event_loop = EventLoop::<Event>::with_user_event()
.with_x11()
.with_any_thread(true)
.build()
.unwrap();
let mut builder = EventLoop::<Event>::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")]
Expand Down
2 changes: 1 addition & 1 deletion crates/zgui-desktop/src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
54 changes: 51 additions & 3 deletions crates/zgui-desktop/src/file_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = Result<Option<T>, FileDialogError>;
#[derive(Clone, Copy)]
pub(crate) enum Kind {
Expand All @@ -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,
Expand All @@ -123,6 +126,19 @@ pub struct FileDialogs {
cancellations: Rc<RefCell<Vec<Weak<std::sync::atomic::AtomicBool>>>>,
}
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<Arc<Window>> {
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,
Expand Down Expand Up @@ -287,6 +303,38 @@ async fn native_select(
) -> FileDialogResult<Vec<PathBuf>> {
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<Window>,
_cancel: Arc<std::sync::atomic::AtomicBool>,
) -> FileDialogResult<Vec<PathBuf>> {
// 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,
Expand Down
4 changes: 3 additions & 1 deletion crates/zgui-desktop/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")]
Expand Down
Loading