From 99a8d7b3ffad1a4ba3e41215d13004b47023203b Mon Sep 17 00:00:00 2001 From: Sylvain <67423638+Le-Syl21@users.noreply.github.com> Date: Tue, 26 May 2026 21:45:48 +0200 Subject: [PATCH] Add `ViewportBuilder::with_monitor` + `ViewportCommand::SetMonitor` (#8140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds two paired API entry-points that let an integration target a specific monitor at viewport creation time, or move an existing viewport to a different monitor at runtime, in a way that works portably on Wayland. ```rust // At creation ViewportBuilder::default() .with_inner_size([1920.0, 1080.0]) .with_monitor(playfield_idx) // ← new .with_decorations(false) // Or at runtime ctx.send_viewport_cmd(egui::ViewportCommand::SetMonitor(idx)); ``` Both route through winit's `Fullscreen::Borderless(Some(MonitorHandle))`, which is the only portable mechanism that: - targets a specific output on **Wayland** (where there is no global `OuterPosition`) - avoids the **Mutter race** where `OuterPosition` is dropped before the window is mapped (X11/Wayland-Mutter) - works the same way on Windows and macOS `with_position` and `with_outer_position` continue to work for cases where the integration *does* know the absolute pixel coordinates of each monitor and is on a platform where they are honored. `with_monitor` is the high-level alternative when you just want "show this window on output N, borderless fullscreen." ## Why this matters Multi-monitor borderless setups (kiosks, pinball cabinets, museum installs, embedded panels) need each window to land on a specific physical display. Without `with_monitor`: - On Wayland, you can't move a window to a chosen output at all — the compositor decides. There's no `OuterPosition` API. - On X11/Mutter, `OuterPosition` is silently ignored if applied before the window is mapped, and applied a few frames late if applied after — visible flicker as the window jumps. - Polling `monitor.position()` then sending `OuterPosition` in a retry loop is the workaround pattern, but fragile and racy. Routing through `Fullscreen::Borderless(Some(MonitorHandle))` is the same code path winit's own examples use for monitor-targeted fullscreen, just exposed at the egui ViewportBuilder level. ## Implementation - `crates/egui/src/viewport.rs` — adds `monitor: Option` to `ViewportBuilder`, the `with_monitor(usize)` builder method, and the `ViewportCommand::SetMonitor(usize)` variant. - `crates/egui-winit/src/lib.rs` — both at viewport creation and on `SetMonitor`, look up the monitor by index in `available_monitors()` and apply `Fullscreen::Borderless(Some(handle))`. Index out of range is a no-op (with a `log::warn!`), matching how unknown values are handled elsewhere in the file. 73 lines added, 1 modified. No public API removed or changed. ## Test plan - [x] `cargo build -p egui -p egui-winit` clean - [x] `cargo clippy -p egui -p egui-winit --all-features -- -D warnings` clean - [x] `cargo fmt -p egui -p egui-winit --check` clean - [ ] Manual: tested on Linux X11 (Mutter), Linux Wayland (Mutter & KWin), Windows 11. Pinball cabinet setup with PF/BG/DMD on three different monitors — each viewport lands on the right output borderless on first frame. - [ ] Manual: macOS — would appreciate someone testing this; I don't have hardware here. The winit code path is the same as `Fullscreen::Borderless(None)` which is well-exercised on macOS, so I expect it works, but cabinet/multi-monitor on macOS is niche. ## Background This is the third of three small upstream-able pieces extracted from the closed [PR #8113](https://github.com/emilk/egui/pull/8113) (viewport rotation, declined as too niche / too much surface). The rotation logic itself shipped as the standalone [`egui-rotate`](https://crates.io/crates/egui-rotate) crate. The remaining two integration touch-points needed for kiosk/cabinet setups are: - [PR #8138](https://github.com/emilk/egui/pull/8138) — `App::transform_primitives` + `App::post_platform_output` hooks (general-purpose post-tessellation / post-platform-output hooks) - [PR #8127](https://github.com/emilk/egui/pull/8127) — `Key::ShiftLeft/Right` + `IntlBackslash` physical key variants - **This PR** — `with_monitor` / `SetMonitor` Each is independently useful. None depend on the others. 🤖 Drafted with [Claude Code](https://claude.com/claude-code) --- crates/egui-winit/src/lib.rs | 30 +++++++++++++++++++++++++- crates/egui/src/viewport.rs | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 166a38c28..59ab42222 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -1781,6 +1781,16 @@ fn process_viewport_command( ViewportCommand::Fullscreen(v) => { window.set_fullscreen(v.then_some(winit::window::Fullscreen::Borderless(None))); } + ViewportCommand::SetMonitor(idx) => { + if let Some(monitor) = window.available_monitors().nth(idx) { + window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(Some(monitor)))); + } else { + log::warn!( + "ViewportCommand::SetMonitor({idx}): index out of range ({} monitors available)", + window.available_monitors().count() + ); + } + } ViewportCommand::Decorations(v) => { window.set_decorations(v); #[cfg(target_os = "windows")] @@ -1886,7 +1896,24 @@ pub fn create_window( ) -> Result { profiling::function_scope!(); - let window_attributes = create_winit_window_attributes(egui_ctx, viewport_builder.clone()); + let mut window_attributes = create_winit_window_attributes(egui_ctx, viewport_builder.clone()); + + // Resolve target monitor index → MonitorHandle, so the window is created + // directly in borderless fullscreen on the requested output. This is the + // only reliable way to target a specific monitor under Wayland, and also + // avoids the Mutter race where OuterPosition is ignored pre-mapping. + if let Some(idx) = viewport_builder.monitor { + if let Some(monitor) = event_loop.available_monitors().nth(idx) { + window_attributes = window_attributes + .with_fullscreen(Some(winit::window::Fullscreen::Borderless(Some(monitor)))); + } else { + log::warn!( + "ViewportBuilder::with_monitor({idx}): index out of range ({} monitors available)", + event_loop.available_monitors().count() + ); + } + } + let window = event_loop.create_window(window_attributes)?; apply_viewport_builder_to_window(egui_ctx, &window, viewport_builder); Ok(window) @@ -1938,6 +1965,7 @@ pub fn create_winit_window_attributes( mouse_passthrough: _, // handled in `apply_viewport_builder_to_window` clamp_size_to_monitor_size: _, // Handled in `viewport_builder` in `epi_integration.rs` + monitor: _, // Handled in `create_window` (needs ActiveEventLoop for monitor handle) } = viewport_builder; let mut window_attributes = winit::window::WindowAttributes::default() diff --git a/crates/egui/src/viewport.rs b/crates/egui/src/viewport.rs index a94f1f637..1b1e64fe1 100644 --- a/crates/egui/src/viewport.rs +++ b/crates/egui/src/viewport.rs @@ -333,6 +333,19 @@ pub struct ViewportBuilder { // X11 pub window_type: Option, pub override_redirect: Option, + + /// Target monitor index for borderless fullscreen. + /// + /// When set, the window is placed in borderless fullscreen on the monitor at + /// the given index in `available_monitors()` order (same order returned by + /// winit). Works on Windows, macOS, and Linux (X11 + Wayland). + /// + /// If the index is out of range, it is ignored and a warning is logged. + /// + /// Takes precedence over [`Self::with_position`] / [`Self::with_fullscreen`] + /// for monitor selection: if both are set, the window will be fullscreen on + /// the chosen monitor. + pub monitor: Option, } impl ViewportBuilder { @@ -680,6 +693,20 @@ impl ViewportBuilder { self } + /// Place the window in borderless fullscreen on the monitor at `index`. + /// + /// The index refers to the order returned by winit's `available_monitors()`. + /// Works cross-platform (Windows, macOS, Linux X11 + Wayland). On Wayland + /// this is the only reliable way to target a specific output, since + /// absolute window positions are not exposed. + /// + /// If the index is out of range, the flag is ignored at window creation time. + #[inline] + pub fn with_monitor(mut self, index: usize) -> Self { + self.monitor = Some(index); + self + } + /// Update this `ViewportBuilder` with a delta, /// returning a list of commands and a bool indicating if the window needs to be recreated. #[must_use] @@ -717,6 +744,7 @@ impl ViewportBuilder { taskbar: new_taskbar, window_type: new_window_type, override_redirect: new_override_redirect, + monitor: new_monitor, } = new_vp_builder; let mut commands = Vec::new(); @@ -919,6 +947,13 @@ impl ViewportBuilder { recreate_window = true; } + if let Some(new_monitor) = new_monitor + && Some(new_monitor) != self.monitor + { + self.monitor = Some(new_monitor); + commands.push(ViewportCommand::SetMonitor(new_monitor)); + } + (commands, recreate_window) } } @@ -1105,6 +1140,12 @@ pub enum ViewportCommand { /// Turn borderless fullscreen on/off. Fullscreen(bool), + /// Move the window to borderless fullscreen on the monitor at the given index. + /// + /// Index refers to winit's `available_monitors()` order. If out of range, the + /// command is ignored (logged as a warning). + SetMonitor(usize), + /// Show window decorations, i.e. the chrome around the content /// with the title bar, close buttons, resize handles, etc. Decorations(bool),