teksilo_platform/window_system.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Which windowing system a Linux/BSD session is running on, and what that
5//! implies for window chrome and modal windows.
6//!
7//! Two detectors live here and they are **not** interchangeable:
8//!
9//! - [`active_window_system`] predicts, from the environment, which backend
10//! winit will pick. It is only valid *before* a window exists — its one job
11//! is deciding `WindowAttributes::with_decorations(..)`, which has to be
12//! chosen at window-construction time. It mirrors winit's own precedence
13//! exactly; any divergence means Teksilo and winit
14//! disagree about the session and the chrome comes out wrong.
15//! - [`window_system_for_display_handle`] reads the *live* handle of an
16//! already-created window. It is authoritative and should be preferred
17//! everywhere a window (or a `ParentHandle`) is in reach — the title-bar host
18//! factory and the external-DnD backend both dispatch on it, so they can
19//! never disagree with each other or with winit.
20
21/// The windowing system backing a window (or the session, when predicted).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum WindowSystem {
24 Wayland,
25 X11,
26 Unknown,
27}
28
29/// Predict winit's backend choice from the environment.
30///
31/// This mirrors winit 0.30's `platform_impl::linux::EventLoop::new` precedence
32/// verbatim: a non-empty `WAYLAND_DISPLAY` **or** `WAYLAND_SOCKET` selects
33/// Wayland; otherwise a non-empty `DISPLAY` selects X11.
34///
35/// `XDG_SESSION_TYPE` is deliberately **not** consulted — winit ignores it, and
36/// honouring it here would make Teksilo disagree with the backend that actually
37/// gets created. Concretely: a Wayland session where the user clears
38/// `WAYLAND_DISPLAY` to force an X11 client still reports
39/// `XDG_SESSION_TYPE=wayland`, so trusting it would claim Wayland for a window
40/// winit created on X11.
41#[cfg(all(unix, not(target_os = "macos")))]
42fn detect_from_env(
43 wayland_display: Option<&str>,
44 wayland_socket: Option<&str>,
45 display: Option<&str>,
46) -> WindowSystem {
47 let non_empty = |value: Option<&str>| value.is_some_and(|value| !value.is_empty());
48
49 if non_empty(wayland_display) || non_empty(wayland_socket) {
50 WindowSystem::Wayland
51 } else if non_empty(display) {
52 WindowSystem::X11
53 } else {
54 WindowSystem::Unknown
55 }
56}
57
58/// The window system winit is expected to use for windows created in this
59/// process. See the module docs: this is a *prediction* for the pre-window-
60/// creation decisions only. Once a window exists, prefer
61/// [`window_system_for_display_handle`].
62pub fn active_window_system() -> WindowSystem {
63 #[cfg(all(unix, not(target_os = "macos")))]
64 {
65 detect_from_env(
66 std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
67 std::env::var("WAYLAND_SOCKET").ok().as_deref(),
68 std::env::var("DISPLAY").ok().as_deref(),
69 )
70 }
71
72 #[cfg(not(all(unix, not(target_os = "macos"))))]
73 {
74 WindowSystem::Unknown
75 }
76}
77
78/// The window system a live window actually runs on, read from its raw display
79/// handle. Authoritative — unlike [`active_window_system`] it cannot disagree
80/// with winit, because the handle *is* winit's answer.
81///
82/// Every consumer that has a window (or a
83/// [`ParentHandle`](teksilo_core::raw_handle::ParentHandle)) in hand should
84/// dispatch on this so the title-bar host and the DnD backend never diverge.
85pub fn window_system_for_display_handle(
86 handle: &raw_window_handle::RawDisplayHandle,
87) -> WindowSystem {
88 use raw_window_handle::RawDisplayHandle;
89
90 match handle {
91 RawDisplayHandle::Wayland(_) => WindowSystem::Wayland,
92 RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => WindowSystem::X11,
93 _ => WindowSystem::Unknown,
94 }
95}
96
97/// Whether a modal dialog should be presented as a real OS window rather than
98/// an in-tree overlay.
99///
100/// `false` for the whole Linux/BSD family. On Wayland there is no protocol for
101/// a client to make another surface input-blocking at all; on X11
102/// `_NET_WM_STATE_MODAL` is a *hint* — window managers use it for stacking and
103/// focus policy, but none of them enforce input blocking on the parent, so a
104/// "native" modal there would look modal without behaving modally. An in-tree
105/// modal is genuinely modal on both, so both use it.
106pub fn supports_native_modal_windows() -> bool {
107 #[cfg(all(unix, not(target_os = "macos")))]
108 {
109 false
110 }
111
112 #[cfg(not(all(unix, not(target_os = "macos"))))]
113 {
114 true
115 }
116}
117
118/// Show `window`, built hidden, without making it the key or focused window:
119/// on macOS `orderFront:`, where winit's `set_visible(true)` is
120/// `makeKeyAndOrderFront:`. Elsewhere winit's `set_visible(true)`, which on
121/// Windows shows a window built with `with_active(false)` with
122/// `SW_SHOWNOACTIVATE` the first time, and on X11 and Wayland leaves the
123/// focus to the window manager or compositor.
124pub(crate) fn show_without_activating(window: &winit::window::Window) {
125 #[cfg(target_os = "macos")]
126 {
127 use objc2::rc::Retained;
128 use objc2_app_kit::NSView;
129 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
130
131 if let Ok(handle) = window.window_handle()
132 && let RawWindowHandle::AppKit(appkit) = handle.as_raw()
133 {
134 // SAFETY: the ns_view pointer is valid while `window` is alive,
135 // which the caller holds for this call; winit runs on the main
136 // thread, as AppKit requires.
137 unsafe {
138 if let Some(view) = Retained::<NSView>::retain(appkit.ns_view.as_ptr().cast())
139 && let Some(ns_window) = view.window()
140 {
141 ns_window.orderFront(None);
142 return;
143 }
144 }
145 }
146 window.set_visible(true);
147 }
148
149 #[cfg(not(target_os = "macos"))]
150 window.set_visible(true);
151}
152
153/// Attach `child` as a child window of `parent` using
154/// `NSWindowOrderingMode::Above` on macOS. Call this *after* both
155/// windows' AccessKit adapters have been created (and thus after the
156/// child becomes visible). Works for modal and non-modal parented
157/// windows alike — popover-as-window, inspector palettes, floating
158/// tool panels are all expected to route through this path as the
159/// multi-window system matures.
160///
161/// On Windows the parent relationship is the owner window winit sets at
162/// creation, and on X11 it is [`set_transient_for_before_show`], so this is a
163/// no-op there. macOS needs the deferred call because AppKit's
164/// `-[NSWindow addChildWindow:ordered:]` orders the child window
165/// front (making it visible), which conflicts with AccessKit's
166/// requirement that its adapter be created while the window is still
167/// hidden.
168pub fn attach_child_window(parent: &winit::window::Window, child: &winit::window::Window) {
169 #[cfg(target_os = "macos")]
170 {
171 use objc2::rc::Retained;
172 use objc2_app_kit::{NSView, NSWindowOrderingMode};
173 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
174
175 let Ok(parent_handle) = parent.window_handle() else {
176 return;
177 };
178 let Ok(child_handle) = child.window_handle() else {
179 return;
180 };
181
182 let (RawWindowHandle::AppKit(p), RawWindowHandle::AppKit(c)) =
183 (parent_handle.as_raw(), child_handle.as_raw())
184 else {
185 return;
186 };
187
188 // SAFETY: the ns_view pointers are valid while `parent` /
189 // `child` are alive; both are held by the caller (typically
190 // the WindowManager) for the lifetime of this call.
191 unsafe {
192 let Some(parent_view): Option<Retained<NSView>> =
193 Retained::retain(p.ns_view.as_ptr().cast())
194 else {
195 return;
196 };
197 let Some(child_view): Option<Retained<NSView>> =
198 Retained::retain(c.ns_view.as_ptr().cast())
199 else {
200 return;
201 };
202 if let (Some(pw), Some(cw)) = (parent_view.window(), child_view.window()) {
203 // Just above the parent first: a dialog shown without
204 // activating went in front of every window but the active
205 // application's front one, and its parent came up with it,
206 // above the active application's other windows.
207 cw.orderWindow_relativeTo(NSWindowOrderingMode::Above, pw.windowNumber());
208 pw.addChildWindow_ordered(&cw, NSWindowOrderingMode::Above);
209 }
210 }
211 }
212
213 #[cfg(not(target_os = "macos"))]
214 {
215 let _ = (parent, child);
216 }
217}
218
219/// Keep `window` out of the Window menu's window list and the Dock icon's
220/// menu on macOS, or list it there again; nothing on other platforms.
221///
222/// AppKit lists every titled window it orders in, and `teksilo-app` keeps
223/// a modal dialog and a window kept out of the taskbar (a palette) out, as
224/// AppKit's own sheets and panels are: the list is for the windows a user
225/// switches between, and a dialog comes forward with the window it blocks. Call it before the window is first shown, so its
226/// row never appears.
227#[doc(hidden)]
228pub fn set_listed_in_window_menu(window: &winit::window::Window, listed: bool) {
229 #[cfg(target_os = "macos")]
230 {
231 use objc2::rc::Retained;
232 use objc2_app_kit::NSView;
233 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
234
235 let Ok(handle) = window.window_handle() else {
236 return;
237 };
238 let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
239 return;
240 };
241 // SAFETY: the ns_view pointer is valid while `window` is alive, and
242 // the caller holds it for the duration of this call.
243 let view: Option<Retained<NSView>> =
244 unsafe { Retained::retain(appkit.ns_view.as_ptr().cast()) };
245 if let Some(ns_window) = view.and_then(|view| view.window()) {
246 ns_window.setExcludedFromWindowsMenu(!listed);
247 }
248 }
249
250 #[cfg(not(target_os = "macos"))]
251 {
252 let _ = (window, listed);
253 }
254}
255
256/// Make `child` a transient window of `parent` on X11 (ICCCM
257/// `WM_TRANSIENT_FOR`), before `child` is shown for the first time; nothing
258/// on other window systems.
259///
260/// winit 0.30 has no call for it: its `with_parent_window` creates an X11
261/// window *inside* the parent, confined to and clipped by its client area,
262/// and is ignored on Wayland, where no protocol open to every client sets a
263/// parent. The window manager keeps a transient window above its parent and
264/// treats it as a dialog. The property is written before the first map, as
265/// [`skip_taskbar_before_show`](crate::taskbar::skip_taskbar_before_show)
266/// writes its own. A failure is reported on stderr and leaves `child` a window
267/// of its own.
268pub fn set_transient_for_before_show(
269 child: &winit::window::Window,
270 parent: &winit::window::Window,
271) {
272 #[cfg(all(unix, not(target_os = "macos")))]
273 {
274 let (Some(child), Some(parent)) = (x11_window_id(child), x11_window_id(parent)) else {
275 return;
276 };
277 if let Err(err) = crate::x11::ewmh::set_transient_for(child, parent) {
278 eprintln!(
279 "teksilo-platform: could not make the window transient for its parent ({err}); \
280 the window manager will treat it as a window of its own"
281 );
282 }
283 }
284 #[cfg(not(all(unix, not(target_os = "macos"))))]
285 let _ = (child, parent);
286}
287
288/// Make `window`, a modal dialog, modal to the window manager on X11
289/// (`_NET_WM_STATE_MODAL`), before it is shown for the first time; nothing on
290/// other window systems. With `WM_TRANSIENT_FOR`
291/// ([`set_transient_for_before_show`]) and the dialog window type, it is how
292/// GTK marks a modal dialog. A failure is reported on stderr and leaves the
293/// dialog a plain transient window.
294#[doc(hidden)]
295pub fn mark_modal_before_show(window: &winit::window::Window) {
296 #[cfg(all(unix, not(target_os = "macos")))]
297 {
298 let Some(xid) = x11_window_id(window) else {
299 return;
300 };
301 if let Err(err) = crate::x11::ewmh::mark_modal(xid) {
302 eprintln!(
303 "teksilo-platform: could not mark the dialog modal ({err}); the window manager \
304 will treat it as a plain transient window"
305 );
306 }
307 }
308 #[cfg(not(all(unix, not(target_os = "macos"))))]
309 let _ = window;
310}
311
312/// The X11 window behind `window`, `None` on any other window system.
313#[cfg(all(unix, not(target_os = "macos")))]
314pub(crate) fn x11_window_id(window: &winit::window::Window) -> Option<u32> {
315 use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawWindowHandle};
316
317 let display = window.display_handle().ok()?;
318 if window_system_for_display_handle(&display.as_raw()) != WindowSystem::X11 {
319 return None;
320 }
321 match window.window_handle().ok()?.as_raw() {
322 RawWindowHandle::Xlib(handle) => Some(handle.window as u32),
323 RawWindowHandle::Xcb(handle) => Some(handle.window.get()),
324 _ => None,
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 // The env-detection table below is winit 0.30's own precedence
333 // (`platform_impl/linux/mod.rs`): Wayland vars win over `DISPLAY`, empty
334 // strings count as unset. If a winit upgrade changes that order, these
335 // tests are the tripwire — Teksilo predicting a different backend than
336 // winit creates means custom chrome is requested for the wrong protocol.
337 #[cfg(all(unix, not(target_os = "macos")))]
338 #[test]
339 fn prefers_wayland_when_both_displays_exist() {
340 assert_eq!(
341 detect_from_env(Some("wayland-0"), None, Some(":0")),
342 WindowSystem::Wayland
343 );
344 }
345
346 #[cfg(all(unix, not(target_os = "macos")))]
347 #[test]
348 fn wayland_socket_also_selects_wayland() {
349 assert_eq!(
350 detect_from_env(None, Some("4"), Some(":0")),
351 WindowSystem::Wayland
352 );
353 }
354
355 #[cfg(all(unix, not(target_os = "macos")))]
356 #[test]
357 fn detects_x11_from_display() {
358 assert_eq!(detect_from_env(None, None, Some(":0")), WindowSystem::X11);
359 }
360
361 /// The XWayland escape hatch: clearing `WAYLAND_DISPLAY` in a Wayland
362 /// session makes winit create a genuine X11 client. Teksilo must follow,
363 /// which is exactly why `XDG_SESSION_TYPE` (still "wayland" here) is not
364 /// consulted.
365 #[cfg(all(unix, not(target_os = "macos")))]
366 #[test]
367 fn empty_wayland_display_falls_back_to_x11() {
368 assert_eq!(
369 detect_from_env(Some(""), Some(""), Some(":0")),
370 WindowSystem::X11
371 );
372 }
373
374 #[cfg(all(unix, not(target_os = "macos")))]
375 #[test]
376 fn returns_unknown_without_display_hints() {
377 assert_eq!(detect_from_env(None, None, None), WindowSystem::Unknown);
378 }
379
380 #[cfg(all(unix, not(target_os = "macos")))]
381 #[test]
382 fn returns_unknown_when_all_hints_are_empty() {
383 assert_eq!(
384 detect_from_env(Some(""), Some(""), Some("")),
385 WindowSystem::Unknown
386 );
387 }
388
389 #[test]
390 fn display_handle_discriminates_the_live_backend() {
391 use raw_window_handle::{RawDisplayHandle, XcbDisplayHandle, XlibDisplayHandle};
392
393 assert_eq!(
394 window_system_for_display_handle(&RawDisplayHandle::Xlib(XlibDisplayHandle::new(
395 None, 0
396 ))),
397 WindowSystem::X11
398 );
399 assert_eq!(
400 window_system_for_display_handle(&RawDisplayHandle::Xcb(XcbDisplayHandle::new(
401 None, 0
402 ))),
403 WindowSystem::X11
404 );
405 }
406
407 /// Linux/BSD never gets a native modal window: Wayland has no protocol for
408 /// it and X11's `_NET_WM_STATE_MODAL` is advisory. See the fn docs.
409 #[cfg(all(unix, not(target_os = "macos")))]
410 #[test]
411 fn linux_family_never_uses_native_modals() {
412 assert!(!supports_native_modal_windows());
413 }
414}