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/// Whether macOS may merge the application's windows into tabs (AppKit's
220/// automatic window tabbing, `NSWindow.allowsAutomaticWindowTabbing`), which
221/// also decides whether the Window menu carries its tab rows (Show Previous
222/// Tab, Merge All Windows and the rest); nothing on other platforms.
223///
224/// Process-wide. Call it on the main thread before the first window is
225/// created; off the main thread it does nothing.
226#[doc(hidden)]
227pub fn set_automatic_window_tabbing(allowed: bool) {
228 #[cfg(target_os = "macos")]
229 {
230 if let Some(mtm) = objc2::MainThreadMarker::new() {
231 objc2_app_kit::NSWindow::setAllowsAutomaticWindowTabbing(allowed, mtm);
232 }
233 }
234
235 #[cfg(not(target_os = "macos"))]
236 {
237 let _ = allowed;
238 }
239}
240
241/// Keep `window` out of the Window menu's window list and the Dock icon's
242/// menu on macOS, or list it there again; nothing on other platforms.
243///
244/// AppKit lists every titled window it orders in, and `teksilo-app` keeps
245/// a modal dialog and a window kept out of the taskbar (a palette) out, as
246/// AppKit's own sheets and panels are: the list is for the windows a user
247/// switches between, and a dialog comes forward with the window it blocks. Call it before the window is first shown, so its
248/// row never appears.
249#[doc(hidden)]
250pub fn set_listed_in_window_menu(window: &winit::window::Window, listed: bool) {
251 #[cfg(target_os = "macos")]
252 {
253 use objc2::rc::Retained;
254 use objc2_app_kit::NSView;
255 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
256
257 let Ok(handle) = window.window_handle() else {
258 return;
259 };
260 let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
261 return;
262 };
263 // SAFETY: the ns_view pointer is valid while `window` is alive, and
264 // the caller holds it for the duration of this call.
265 let view: Option<Retained<NSView>> =
266 unsafe { Retained::retain(appkit.ns_view.as_ptr().cast()) };
267 if let Some(ns_window) = view.and_then(|view| view.window()) {
268 ns_window.setExcludedFromWindowsMenu(!listed);
269 }
270 }
271
272 #[cfg(not(target_os = "macos"))]
273 {
274 let _ = (window, listed);
275 }
276}
277
278/// Make `child` a transient window of `parent` on X11 (ICCCM
279/// `WM_TRANSIENT_FOR`), before `child` is shown for the first time; nothing
280/// on other window systems.
281///
282/// winit 0.30 has no call for it: its `with_parent_window` creates an X11
283/// window *inside* the parent, confined to and clipped by its client area,
284/// and is ignored on Wayland, where no protocol open to every client sets a
285/// parent. The window manager keeps a transient window above its parent and
286/// treats it as a dialog. The property is written before the first map, as
287/// [`skip_taskbar_before_show`](crate::taskbar::skip_taskbar_before_show)
288/// writes its own. A failure is reported on stderr and leaves `child` a window
289/// of its own.
290pub fn set_transient_for_before_show(
291 child: &winit::window::Window,
292 parent: &winit::window::Window,
293) {
294 #[cfg(all(unix, not(target_os = "macos")))]
295 {
296 let (Some(child), Some(parent)) = (x11_window_id(child), x11_window_id(parent)) else {
297 return;
298 };
299 if let Err(err) = crate::x11::ewmh::set_transient_for(child, parent) {
300 eprintln!(
301 "teksilo-platform: could not make the window transient for its parent ({err}); \
302 the window manager will treat it as a window of its own"
303 );
304 }
305 }
306 #[cfg(not(all(unix, not(target_os = "macos"))))]
307 let _ = (child, parent);
308}
309
310/// Make `window`, a modal dialog, modal to the window manager on X11
311/// (`_NET_WM_STATE_MODAL`), before it is shown for the first time; nothing on
312/// other window systems. With `WM_TRANSIENT_FOR`
313/// ([`set_transient_for_before_show`]) and the dialog window type, it is how
314/// GTK marks a modal dialog. A failure is reported on stderr and leaves the
315/// dialog a plain transient window.
316#[doc(hidden)]
317pub fn mark_modal_before_show(window: &winit::window::Window) {
318 #[cfg(all(unix, not(target_os = "macos")))]
319 {
320 let Some(xid) = x11_window_id(window) else {
321 return;
322 };
323 if let Err(err) = crate::x11::ewmh::mark_modal(xid) {
324 eprintln!(
325 "teksilo-platform: could not mark the dialog modal ({err}); the window manager \
326 will treat it as a plain transient window"
327 );
328 }
329 }
330 #[cfg(not(all(unix, not(target_os = "macos"))))]
331 let _ = window;
332}
333
334/// The X11 window behind `window`, `None` on any other window system.
335#[cfg(all(unix, not(target_os = "macos")))]
336pub(crate) fn x11_window_id(window: &winit::window::Window) -> Option<u32> {
337 use winit::raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawWindowHandle};
338
339 let display = window.display_handle().ok()?;
340 if window_system_for_display_handle(&display.as_raw()) != WindowSystem::X11 {
341 return None;
342 }
343 match window.window_handle().ok()?.as_raw() {
344 RawWindowHandle::Xlib(handle) => Some(handle.window as u32),
345 RawWindowHandle::Xcb(handle) => Some(handle.window.get()),
346 _ => None,
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 // The env-detection table below is winit 0.30's own precedence
355 // (`platform_impl/linux/mod.rs`): Wayland vars win over `DISPLAY`, empty
356 // strings count as unset. If a winit upgrade changes that order, these
357 // tests are the tripwire — Teksilo predicting a different backend than
358 // winit creates means custom chrome is requested for the wrong protocol.
359 #[cfg(all(unix, not(target_os = "macos")))]
360 #[test]
361 fn prefers_wayland_when_both_displays_exist() {
362 assert_eq!(
363 detect_from_env(Some("wayland-0"), None, Some(":0")),
364 WindowSystem::Wayland
365 );
366 }
367
368 #[cfg(all(unix, not(target_os = "macos")))]
369 #[test]
370 fn wayland_socket_also_selects_wayland() {
371 assert_eq!(
372 detect_from_env(None, Some("4"), Some(":0")),
373 WindowSystem::Wayland
374 );
375 }
376
377 #[cfg(all(unix, not(target_os = "macos")))]
378 #[test]
379 fn detects_x11_from_display() {
380 assert_eq!(detect_from_env(None, None, Some(":0")), WindowSystem::X11);
381 }
382
383 /// The XWayland escape hatch: clearing `WAYLAND_DISPLAY` in a Wayland
384 /// session makes winit create a genuine X11 client. Teksilo must follow,
385 /// which is exactly why `XDG_SESSION_TYPE` (still "wayland" here) is not
386 /// consulted.
387 #[cfg(all(unix, not(target_os = "macos")))]
388 #[test]
389 fn empty_wayland_display_falls_back_to_x11() {
390 assert_eq!(
391 detect_from_env(Some(""), Some(""), Some(":0")),
392 WindowSystem::X11
393 );
394 }
395
396 #[cfg(all(unix, not(target_os = "macos")))]
397 #[test]
398 fn returns_unknown_without_display_hints() {
399 assert_eq!(detect_from_env(None, None, None), WindowSystem::Unknown);
400 }
401
402 #[cfg(all(unix, not(target_os = "macos")))]
403 #[test]
404 fn returns_unknown_when_all_hints_are_empty() {
405 assert_eq!(
406 detect_from_env(Some(""), Some(""), Some("")),
407 WindowSystem::Unknown
408 );
409 }
410
411 #[test]
412 fn display_handle_discriminates_the_live_backend() {
413 use raw_window_handle::{RawDisplayHandle, XcbDisplayHandle, XlibDisplayHandle};
414
415 assert_eq!(
416 window_system_for_display_handle(&RawDisplayHandle::Xlib(XlibDisplayHandle::new(
417 None, 0
418 ))),
419 WindowSystem::X11
420 );
421 assert_eq!(
422 window_system_for_display_handle(&RawDisplayHandle::Xcb(XcbDisplayHandle::new(
423 None, 0
424 ))),
425 WindowSystem::X11
426 );
427 }
428
429 /// Linux/BSD never gets a native modal window: Wayland has no protocol for
430 /// it and X11's `_NET_WM_STATE_MODAL` is advisory. See the fn docs.
431 #[cfg(all(unix, not(target_os = "macos")))]
432 #[test]
433 fn linux_family_never_uses_native_modals() {
434 assert!(!supports_native_modal_windows());
435 }
436}