Skip to main content

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}