Skip to main content

quadraui/gtk/
services.rs

1//! GTK implementation of [`quadraui::PlatformServices`].
2//!
3//! Clipboard uses `arboard` for synchronous system clipboard access
4//! (GTK's native read API is async, incompatible with the sync trait).
5//! `open_url` uses GIO. File dialogs use `gtk4::FileDialog`, and message
6//! dialogs use `gtk4::AlertDialog` — `gtk4::MessageDialog`
7//! has been deprecated since GTK 4.10, and the fleet is on 4.14.5 — both
8//! behind the same nested-mainloop adapter (see [`pump_until_ready`]) so
9//! the trait's synchronous signatures can be honored even though GTK4
10//! only exposes async dialog APIs.
11//!
12//! ## `shell.*` parity
13//!
14//! `reveal_in_file_manager` calls `org.freedesktop.FileManager1.ShowItems`
15//! over the session D-Bus directly (no dedicated GIO wrapper exists for
16//! this interface — see [`GtkPlatformServices::reveal_in_file_manager`]'s
17//! doc). `open_path` reuses the same `gio::AppInfo::launch_default_for_uri`
18//! call `open_url` makes, fed a `file://` URI instead of a caller-supplied
19//! URL string. `move_to_trash` delegates to
20//! [`crate::desktop::move_to_trash`] — the cross-platform `trash` crate,
21//! not a hand-rolled `gio::File::trash` — see that function's doc for why
22//! every backend shares it. `beep` is `gdk::Display::beep`.
23//!
24//! ## Notifications
25//!
26//! `send_notification` is a real `gio::Notification` sent through the
27//! `gtk4::Application` the window belongs to
28//! (`GtkWindowExt::application`) — no async trait shape was needed after
29//! all; `gio::Application::send_notification` is itself synchronous
30//! (fire-and-forget, like every other `PlatformServices` method). This
31//! is the one backend [`crate::backend::BackendCaps::notifications`]
32//! reports `true` for — see that field's own doc and
33//! `compose::notification::notify_or_toast` for how a caller that wants
34//! to work on every backend degrades on the other three.
35//!
36//! Action buttons and the notification body itself route back through a
37//! single app-scoped `GAction` (`app.quadraui-notification-activated`,
38//! [`NOTIFICATION_ACTION_ID`]), registered once per `Application` by
39//! [`GtkPlatformServices::ensure_notification_action`] and never
40//! per-notification — which `Notification`/action a given activation
41//! belongs to travels in the `GAction`'s own `(ss)` target `Variant`
42//! ([`notification_target`]/[`decode_notification_target`]), not in the
43//! action's identity. The activation handler decodes that target and
44//! pushes [`crate::UiEvent::NotificationActivated`] onto the same shared
45//! event queue [`crate::gtk::backend::GtkBackend::poll_events`] drains —
46//! wired via [`GtkPlatformServices::set_events_handle`], called once from
47//! [`crate::gtk::backend::GtkBackend::new`] right after both the queue
48//! and the services exist, mirroring [`GtkPlatformServices::set_window`].
49//!
50//! `n.icon()` maps to `gio::BytesIcon`/`gio::FileIcon` — GIO's own
51//! `GLoadableIcon` machinery decodes/loads it, not this crate's image
52//! pipeline, so no pixel decoding happens in-process for a notification
53//! icon the way [`Backend::draw_image`][crate::Backend::draw_image] does
54//! for a painted one. `n.is_silent()` has nothing to bind to: GTK4's
55//! `gio::Notification` carries no sound-control property at all — the
56//! desktop shell/notification daemon decides, same as
57//! [`Self::show_message_dialog`]'s `opts.severity` having nowhere to go
58//! on `gtk4::AlertDialog`.
59//!
60//! ## Tray / status-bar icon — deliberately not implemented
61//! here
62//!
63//! `GtkBackend::tray` (see [`crate::backend::Backend::tray`]) is left at
64//! the trait's `None` default rather than gaining a `GtkTrayService` in
65//! this file. Unlike every other gap this module documents, this one
66//! isn't "stubbed pending a trait shape" — it's "no GTK4 API exists at
67//! all": GTK4 itself ships no status-icon widget (`GtkStatusIcon` was
68//! removed in the GTK3→4 transition), so a real implementation needs
69//! StatusNotifierItem over D-Bus (the `ksni` crate, or a hand-rolled
70//! implementation) or `libayatana-appindicator`, and must additionally
71//! report honest `Unsupported`/`None` on desktops with no SNI host
72//! running at all (stock GNOME without an extension, notably) rather
73//! than silently no-oping. That's a materially larger, separate piece of
74//! work than the macOS (`NSStatusBar`) and Win (`Shell_NotifyIconW`)
75//! implementations, which reuse existing in-tree image-decode and
76//! `ContextMenu`-rendering machinery — see `macos::tray`/`win::tray`'s
77//! module docs for those. Tracked as GTK follow-up, not silently
78//! dropped: `Backend::tray` returning `None` here is the honest,
79//! structural "not yet" this crate's docs consistently prefer over a
80//! capability flag nobody set.
81//!
82//! ## Displays
83//!
84//! `displays` reads `gdk::Display::monitors()` +
85//! `Monitor::geometry()`/`scale_factor()`. `cursor_screen_point` stays
86//! `Err(BackendError::Unsupported)` — GDK4 has no global pointer-position
87//! query at all, a real Wayland protocol restriction, not a missing
88//! binding. See [`GtkPlatformServices::displays`]/
89//! [`GtkPlatformServices::cursor_screen_point`]'s own docs for the full
90//! detail, including the GDK4 work-area and primary-monitor gaps.
91//!
92//! ## Re-entrancy guard (#427 follow-up)
93//!
94//! `pump_until_ready` is called from inside `AppLogic::handle`, which
95//! `quadraui::gtk::run` invokes while holding the shared
96//! `Rc<RefCell<GtkBackend>>` mutably borrowed for the whole call. Pumping
97//! `glib::MainContext::iteration(true)` in that state lets *any* pending
98//! GLib source run — including the runner's own 33ms idle-drain timer and
99//! every input event controller, all of which also do
100//! `backend.borrow_mut()`. Left unguarded, that second borrow panics with
101//! "already borrowed", and because it happens inside a non-unwindable GLib
102//! callback frame, the panic aborts the whole process instead of
103//! propagating. `pumping` (a depth counter, not a bool, so nested dialogs
104//! stay guarded until the outermost pump finishes) lets those callbacks
105//! detect "a dialog pump is in flight further up the stack" and no-op
106//! instead of touching the backend. See `GtkBackend::pump_depth` /
107//! `quadraui::gtk::run::activate`.
108//!
109//! The depth counter and its RAII guard are the backend-neutral
110//! [`crate::desktop::ModalPumpDepth`] / [`crate::desktop::ModalPumpGuard`]
111//! — extracted here first and generalised because AppKit's
112//! `runModal` and Win32's `IFileOpenDialog::Show` have the identical
113//! nested-pump re-entrancy hazard.
114//!
115//! ## Native-dialog CSS theming
116//!
117//! `gtk4::FileDialog`'s fallback widgets — the places sidebar, the
118//! scrollbars, and popover-hosted content (the filter dropdown, the
119//! "create folder" popover) — are real GTK chrome this crate doesn't
120//! rasterise itself, so they only ever pick up the desktop's own GTK
121//! theme. Every GTK consumer used to have to ship its own CSS + display
122//! provider purely to make those widgets match the app's [`crate::Theme`]
123//! (vimcode's `PlatformCssProvider`, ~216 lines, reloaded by hand on
124//! every colourscheme change). [`GtkPlatformServices`] now owns that
125//! provider itself: [`GtkPlatformServices::set_theme`] rebuilds
126//! [`dialog_css`] from the current `Theme` and reloads it into
127//! [`GtkPlatformServices::css_provider`], so every GTK consumer's native
128//! dialogs stay in sync with the app's colours with zero per-consumer
129//! CSS. See [`dialog_css`]'s own doc for exactly which `Theme` fields
130//! back which rule, and [`GtkPlatformServices::ensure_css_provider_attached`]
131//! for why attaching the provider to a live `gdk::Display` is decoupled
132//! from loading its CSS (theme and window can each arrive first,
133//! depending on the consumer's startup order).
134
135use std::cell::{Cell, RefCell};
136use std::collections::VecDeque;
137use std::path::{Path, PathBuf};
138use std::rc::Rc;
139use std::sync::atomic::{AtomicU64, Ordering};
140
141use gtk4::gio;
142use gtk4::gio::prelude::*;
143use gtk4::glib;
144use gtk4::prelude::{GtkWindowExt, WidgetExt};
145
146use crate::backend::{
147    BackendError, Clipboard, Display, FileDialogOptions, MessageDialogButton, MessageDialogChoice,
148    MessageDialogOptions, Notification, RgbaImage, ServiceResult, SystemTheme,
149};
150use crate::desktop::{ModalPumpDepth, ModalPumpGuard};
151use crate::event::{Point, Rect, UiEvent};
152use crate::primitives::image::ImageSource;
153use crate::types::WidgetId;
154use crate::PlatformServices;
155
156/// The event queue `GtkBackend::poll_events` drains — the same handle
157/// `GtkBackend::events_handle` returns. Aliased so [`GtkPlatformServices::events`]'s
158/// type stays under clippy's `type_complexity` threshold.
159type EventQueueHandle = Rc<RefCell<VecDeque<UiEvent>>>;
160
161/// GTK platform-services impl. Clipboard is backed by `arboard` for
162/// cross-platform synchronous access. File dialogs use `gtk4::FileDialog`
163/// pumped through a nested main-loop iteration (see module docs).
164/// Notifications use a real `gio::Notification` (see module docs).
165pub struct GtkPlatformServices {
166    clipboard: GtkClipboard,
167    /// Top-level window used to parent file dialogs, so they open modal
168    /// to (and centered on) the app window instead of floating
169    /// unparented. `None` until [`Self::set_window`] is called (and in
170    /// unit tests, which never call it) — dialogs opened before that
171    /// point, or in tests, still work, just without a parent.
172    window: Rc<RefCell<Option<gtk4::ApplicationWindow>>>,
173    /// Depth counter, `> 0` while a [`pump_until_ready`] nested-mainloop
174    /// wait is in flight (possibly several, if a dialog is opened
175    /// re-entrantly from inside another dialog's pump). Shared (via
176    /// [`Self::pump_depth`]) with `quadraui::gtk::run`'s event
177    /// controllers and idle-drain timer so they can detect the
178    /// re-entrant-pump condition and skip touching the backend's
179    /// `RefCell` — see the module-level re-entrancy note.
180    pumping: ModalPumpDepth,
181    /// The queue `GtkBackend::poll_events` drains, shared via
182    /// [`Self::set_events_handle`] — `None` until that's
183    /// called (and in unit tests, which never call it), mirroring
184    /// `window`'s own "`None` until wired" shape. A notification action
185    /// activated before this is set (or in a test-only
186    /// `GtkPlatformServices` that never wires it) simply has nowhere to
187    /// deliver [`crate::UiEvent::NotificationActivated`] — see
188    /// [`Self::ensure_notification_action`].
189    events: Rc<RefCell<Option<EventQueueHandle>>>,
190    /// Whether [`Self::ensure_notification_action`] has already
191    /// registered `app.quadraui-notification-activated` on the current
192    /// `Application`. Checked first so `send_notification` doesn't pay
193    /// for `ActionMap::lookup_action` on every call once it's
194    /// registered.
195    action_registered: Cell<bool>,
196    /// Display-level CSS provider theming quadraui's own native-dialog
197    /// fallback widgets — see the module doc's "Native-dialog
198    /// CSS theming" section and [`dialog_css`]'s doc for the full
199    /// rationale. Reloaded from scratch on every [`Self::set_theme`] call
200    /// rather than diffed, mirroring how cheaply every other backend
201    /// re-derives its whole per-frame `current_theme` cache.
202    ///
203    /// `None` until the first [`Self::set_theme`] call that runs with GTK
204    /// already initialized — `gtk4::CssProvider::new()` itself asserts a
205    /// live main-thread GTK runtime (`assert_initialized_main_thread!`),
206    /// which a bare `GtkPlatformServices::new()` (every unit test in this
207    /// module, and any consumer that constructs one before `gtk4::init()`
208    /// runs) deliberately does not have — see this file's own doc for why
209    /// that must keep working. Lazily constructed instead of eagerly in
210    /// [`Self::new`] for exactly that reason.
211    css_provider: RefCell<Option<gtk4::CssProvider>>,
212    /// Whether [`Self::css_provider`] has already been attached to a live
213    /// `gdk::Display` (see [`Self::ensure_css_provider_attached`]).
214    /// `gtk4::style_context_add_provider_for_display` has no "already
215    /// attached" no-op of its own — calling it twice on the same display
216    /// just stacks the same rules twice — so this guards against that
217    /// instead of relying on GTK to dedupe it.
218    css_provider_attached: Cell<bool>,
219    /// The CSS string most recently loaded into [`Self::css_provider`] —
220    /// `gtk4::CssProvider` keeps no getter back to its own loaded source,
221    /// so this is kept alongside it purely so [`Self::set_theme`]'s effect
222    /// is assertable (by [`Self::dialog_css_for_test`]) without a live
223    /// `gdk::Display`. `set_theme` is a user-triggered colourscheme
224    /// change, not a per-frame call, so the extra `String` isn't on any
225    /// hot path.
226    last_dialog_css: RefCell<String>,
227}
228
229impl GtkPlatformServices {
230    pub fn new() -> Self {
231        Self {
232            clipboard: GtkClipboard::new(),
233            window: Rc::new(RefCell::new(None)),
234            pumping: ModalPumpDepth::new(),
235            events: Rc::new(RefCell::new(None)),
236            action_registered: Cell::new(false),
237            css_provider: RefCell::new(None),
238            css_provider_attached: Cell::new(false),
239            last_dialog_css: RefCell::new(String::new()),
240        }
241    }
242
243    /// Store the top-level window handle so file dialogs can be parented
244    /// to it. Called once by `GtkBackend::set_window` right after the
245    /// window is constructed.
246    ///
247    /// Also gives [`Self::ensure_css_provider_attached`] another chance to
248    /// run: a consumer that calls `Backend::set_theme`
249    /// before the window/display exists yet (theme-then-window ordering)
250    /// otherwise never gets its dialog CSS attached, since
251    /// [`Self::set_theme`]'s own attach attempt would have found no
252    /// display and given up.
253    pub(crate) fn set_window(&self, window: gtk4::ApplicationWindow) {
254        *self.window.borrow_mut() = Some(window);
255        self.ensure_css_provider_attached();
256    }
257
258    /// Share the backend's event queue so a notification-action
259    /// activation can push [`crate::UiEvent::NotificationActivated`]
260    /// onto it. Called once by [`crate::gtk::backend::GtkBackend::new`]
261    /// right after both the queue and `self` exist — see the module doc.
262    pub(crate) fn set_events_handle(&self, events: EventQueueHandle) {
263        *self.events.borrow_mut() = Some(events);
264    }
265
266    /// Register the shared `app.quadraui-notification-activated`
267    /// [`gio::SimpleAction`] on `app`, exactly once. Idempotent two ways:
268    /// [`Self::action_registered`] short-circuits repeat calls from this
269    /// `GtkPlatformServices`, and `app.lookup_action` guards against a
270    /// second `GtkPlatformServices` (or a second call before the first
271    /// one's `events` handle was wired — see below) sharing the same
272    /// `Application`, e.g. across two windows of the same app.
273    ///
274    /// A no-op when [`Self::events`] hasn't been wired yet
275    /// ([`Self::set_events_handle`] not called) — the next
276    /// `send_notification` retries. This only happens before
277    /// `GtkBackend::new` finishes, or in a unit test that constructs
278    /// `GtkPlatformServices` directly and calls `send_notification`
279    /// without a backend at all; a real app always has both wired before
280    /// the event loop starts.
281    fn ensure_notification_action(&self, app: &gtk4::Application) {
282        if self.action_registered.get() {
283            return;
284        }
285        if app.lookup_action(NOTIFICATION_ACTION_ID).is_some() {
286            self.action_registered.set(true);
287            return;
288        }
289        let Some(events) = self.events.borrow().clone() else {
290            return;
291        };
292        let param_type = <(String, String)>::static_variant_type();
293        let action = gio::SimpleAction::new(NOTIFICATION_ACTION_ID, Some(&*param_type));
294        action.connect_activate(move |_action, parameter| {
295            let Some(parameter) = parameter else {
296                return;
297            };
298            let Some((tag, action_id)) = decode_notification_target(parameter) else {
299                return;
300            };
301            events
302                .borrow_mut()
303                .push_back(UiEvent::NotificationActivated {
304                    tag,
305                    action: action_id,
306                });
307        });
308        app.add_action(&action);
309        self.action_registered.set(true);
310    }
311
312    /// Clone of the pump-depth counter (see the `pumping` field docs).
313    /// `quadraui::gtk::run::activate` fetches this once, before installing
314    /// any event controllers, and clones it into each closure that would
315    /// otherwise call `backend.borrow_mut()` — so they can check
316    /// `depth.is_pumping()` and no-op while a dialog's nested pump is live.
317    pub(crate) fn pump_depth(&self) -> ModalPumpDepth {
318        self.pumping.clone()
319    }
320
321    /// Concrete (non-trait-object) handle on the clipboard, so in-crate
322    /// tests can call [`GtkClipboard::install_test_contents`] — the
323    /// `&dyn Clipboard` returned by [`PlatformServices::clipboard`]
324    /// can't reach an inherent method. Test-only.
325    #[cfg(test)]
326    pub(crate) fn gtk_clipboard(&self) -> &GtkClipboard {
327        &self.clipboard
328    }
329
330    /// Rebuilds [`Self::css_provider`]'s rules from `theme` and reloads
331    /// them. Called from `GtkBackend::set_theme` every time the app
332    /// changes its [`crate::Theme`].
333    ///
334    /// [`dialog_css`] is a pure string builder that updates
335    /// [`Self::last_dialog_css`] regardless of whether GTK is initialized.
336    /// Only the GTK-object half ([`Self::css_provider`]'s lazy construction
337    /// + `load_from_data`) is gated on `gtk4::is_initialized_main_thread()`.
338    pub(crate) fn set_theme(&self, theme: &crate::Theme) {
339        let css = dialog_css(theme);
340        *self.last_dialog_css.borrow_mut() = css.clone();
341
342        if !gtk4::is_initialized_main_thread() {
343            return;
344        }
345        {
346            let mut provider = self.css_provider.borrow_mut();
347            // `load_from_string` needs gtk4-rs's `v4_12` feature, which
348            // this crate doesn't enable (only `v4_10` — see Cargo.toml);
349            // `load_from_data` is the always-available equivalent (only
350            // `#[deprecated]` under `v4_12`, so no lint fires here).
351            provider
352                .get_or_insert_with(gtk4::CssProvider::new)
353                .load_from_data(&css);
354        }
355        self.ensure_css_provider_attached();
356    }
357
358    /// Attach [`Self::css_provider`] to whichever `gdk::Display` is
359    /// reachable right now — the parented window's own display once
360    /// [`Self::set_window`] has run, falling back to
361    /// [`gtk4::gdk::Display::default`] otherwise, the same fallback chain
362    /// [`Self::beep`]/[`Self::displays`] use. A no-op when the provider
363    /// hasn't been created yet ([`Self::set_theme`] never called), when
364    /// it's already attached, or when neither display source exists yet
365    /// (headless CI, or a call that races the window/theme the other way
366    /// — see [`Self::set_window`]'s doc for how both orderings still end
367    /// up attached).
368    fn ensure_css_provider_attached(&self) {
369        if self.css_provider_attached.get() {
370            return;
371        }
372        let Some(provider) = self.css_provider.borrow().clone() else {
373            return;
374        };
375        let display = match self.window.borrow().as_ref() {
376            Some(w) => Some(WidgetExt::display(w)),
377            None => gtk4::gdk::Display::default(),
378        };
379        if let Some(display) = display {
380            gtk4::style_context_add_provider_for_display(
381                &display,
382                &provider,
383                gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
384            );
385            self.css_provider_attached.set(true);
386        }
387    }
388
389    /// The CSS string most recently loaded via [`Self::set_theme`] —
390    /// for test inspection, since `gtk4::CssProvider` itself keeps no
391    /// getter back to its loaded source.
392    #[cfg(test)]
393    pub(crate) fn dialog_css_for_test(&self) -> String {
394        self.last_dialog_css.borrow().clone()
395    }
396}
397
398impl Default for GtkPlatformServices {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404impl PlatformServices for GtkPlatformServices {
405    fn clipboard(&self) -> &dyn Clipboard {
406        &self.clipboard
407    }
408
409    fn show_file_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
410        let dialog = build_file_dialog(&opts, None);
411        let window = self.window.borrow().clone();
412        let result = Rc::new(RefCell::new(None));
413        let result_cb = Rc::clone(&result);
414        dialog.open(window.as_ref(), gio::Cancellable::NONE, move |res| {
415            *result_cb.borrow_mut() = Some(res);
416        });
417        pump_until_ready(&result, &self.pumping)
418            .ok()
419            .and_then(|file| gtk4::prelude::FileExt::path(&file))
420    }
421
422    fn show_file_save_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
423        let initial_name = opts.initial_filename.clone();
424        let dialog = build_file_dialog(&opts, initial_name.as_deref());
425        let window = self.window.borrow().clone();
426        let result = Rc::new(RefCell::new(None));
427        let result_cb = Rc::clone(&result);
428        dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
429            *result_cb.borrow_mut() = Some(res);
430        });
431        pump_until_ready(&result, &self.pumping)
432            .ok()
433            .and_then(|file| gtk4::prelude::FileExt::path(&file))
434    }
435
436    /// `gtk4::FileDialog::select_folder` — opens a native folder picker
437    /// with the same builder and nested-pump adapter as the file dialogs.
438    fn show_folder_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
439        let dialog = build_file_dialog(&opts, None);
440        let window = self.window.borrow().clone();
441        let result = Rc::new(RefCell::new(None));
442        let result_cb = Rc::clone(&result);
443        dialog.select_folder(window.as_ref(), gio::Cancellable::NONE, move |res| {
444            *result_cb.borrow_mut() = Some(res);
445        });
446        pump_until_ready(&result, &self.pumping)
447            .ok()
448            .and_then(|file| gtk4::prelude::FileExt::path(&file))
449    }
450
451    /// `gtk4::AlertDialog::choose()` — see the module docs
452    /// for why `AlertDialog`, not the deprecated `MessageDialog`. Driven
453    /// through the same [`pump_until_ready`] + `pump_depth` guard the
454    /// file dialogs use above; this does **not** hand-roll a second
455    /// nested loop.
456    fn show_message_dialog(&self, opts: MessageDialogOptions) -> Option<MessageDialogChoice> {
457        let order = hig_button_order(&opts.buttons);
458        let labels: Vec<&str> = order
459            .iter()
460            .map(|&i| opts.buttons[i].label.as_str())
461            .collect();
462
463        let dialog = gtk4::AlertDialog::default();
464        dialog.set_message(&opts.title);
465        dialog.set_detail(&opts.body);
466        dialog.set_modal(true);
467        dialog.set_buttons(&labels);
468        // `opts.severity` is intentionally unread here: gtk4-rs 0.7.3's
469        // `AlertDialog` (`auto/alert_dialog.rs`) exposes no icon-setting
470        // API at all, so there is nothing to map it onto. Not a bug —
471        // `MessageDialogOptions::severity`'s doc already says "backends
472        // *may* use this" — but worth calling out so the next reader
473        // doesn't go looking for where it's supposed to go.
474        if let Some(pos) = order.iter().position(|&i| opts.buttons[i].is_cancel) {
475            dialog.set_cancel_button(pos as i32);
476        }
477        if let Some(pos) = order.iter().position(|&i| opts.buttons[i].is_default) {
478            dialog.set_default_button(pos as i32);
479        }
480
481        let window = self.window.borrow().clone();
482        let result = Rc::new(RefCell::new(None));
483        let result_cb = Rc::clone(&result);
484        dialog.choose(window.as_ref(), gio::Cancellable::NONE, move |res| {
485            *result_cb.borrow_mut() = Some(res);
486        });
487        let idx = pump_until_ready(&result, &self.pumping).ok()?;
488        let pos = usize::try_from(idx).ok()?;
489        let orig = *order.get(pos)?;
490        Some(opts.buttons[orig].id)
491    }
492
493    /// `gio::Notification` sent through the window's owning
494    /// `gtk4::Application` — see the module doc for the full
495    /// design (action routing, icon decode, the `silent` gap). A no-op
496    /// when there is no window yet, or the window has no `Application`
497    /// (a `GtkPlatformServices` used outside `quadraui::gtk::run`, e.g.
498    /// directly in a unit test) — the same "nothing to parent this to
499    /// yet" degrade `show_file_open_dialog` et al. already have via
500    /// `self.window.borrow().clone()`.
501    fn send_notification(&self, n: Notification) {
502        let Some(window) = self.window.borrow().clone() else {
503            return;
504        };
505        let Some(app) = window.application() else {
506            return;
507        };
508        self.ensure_notification_action(&app);
509
510        let notification = gio::Notification::new(&n.title);
511        notification.set_body(Some(&n.body));
512        notification.set_priority(if n.urgent {
513            gio::NotificationPriority::Urgent
514        } else {
515            gio::NotificationPriority::Normal
516        });
517        if let Some(icon_source) = n.icon() {
518            notification.set_icon(&decode_gio_icon(icon_source));
519        }
520
521        let tag = n.tag();
522        notification.set_default_action_and_target_value(
523            NOTIFICATION_ACTION_DETAILED,
524            Some(&notification_target(tag, None)),
525        );
526        for (id, label) in n.actions() {
527            notification.add_button_with_target_value(
528                label,
529                NOTIFICATION_ACTION_DETAILED,
530                Some(&notification_target(tag, Some(id.as_str()))),
531            );
532        }
533
534        // `gio::Application::send_notification`'s `id` both dedupes
535        // repeat calls (a second `send_notification` with the same id
536        // replaces the on-screen notification instead of stacking a
537        // second one) and is what a later
538        // `gio::Application::withdraw_notification(id)` would target —
539        // `Notification::with_tag`'s doc names this as the intended use.
540        // Untagged notifications each get a fresh id from the counter
541        // below so they never collide with each other.
542        let owned_tag;
543        let notif_id: &str = match tag {
544            Some(tag) => tag,
545            None => {
546                owned_tag = format!(
547                    "quadraui-notification-{}",
548                    NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed)
549                );
550                &owned_tag
551            }
552        };
553        app.send_notification(Some(notif_id), &notification);
554    }
555
556    fn open_url(&self, url: &str) {
557        let _ = self.open_url_result(url);
558    }
559
560    /// Fallible twin of [`Self::open_url`]: reports
561    /// `gio::AppInfo::launch_default_for_uri`'s real outcome instead of
562    /// `open_url`'s discarded one — before this override,
563    /// `open_url_result` fell to
564    /// [`crate::backend::PlatformServices::open_url_result`]'s default
565    /// (`open_url` then always `Ok(())`), so a URL scheme with no
566    /// registered handler (or no session D-Bus at all) was silently
567    /// reported as success.
568    fn open_url_result(&self, url: &str) -> ServiceResult<()> {
569        gtk4::gio::AppInfo::launch_default_for_uri(url, None::<&gtk4::gio::AppLaunchContext>)
570            .map_err(|e| BackendError::PlatformFailure {
571                context: format!("gio::AppInfo::launch_default_for_uri: {e}"),
572            })
573    }
574
575    /// `org.freedesktop.FileManager1.ShowItems` over the session D-Bus
576    /// — the standard method every major Linux file manager
577    /// (Nautilus/Files, Dolphin, Nemo, Thunar via a plugin) implements
578    /// for "reveal this file, selected, in a file-manager window." GIO
579    /// ships no dedicated wrapper for this interface the way it does for
580    /// [`Self::open_path`]'s `AppInfo::launch_default_for_uri`, so this
581    /// drives it directly through [`gio::DBusConnection::call_sync`] —
582    /// the same synchronous session-bus machinery GIO's own generated
583    /// D-Bus proxies use internally. `Err(BackendError::Unsupported)`
584    /// only on `gio::bus_get_sync` itself failing (no session bus at
585    /// all — e.g. a bare TTY with no desktop session); a live bus with no
586    /// `FileManager1` listener (headless container, minimal window
587    /// manager) instead surfaces as `BackendError::PlatformFailure` from
588    /// the `ShowItems` call itself, since that's a real answer from a
589    /// real bus rather than "this backend has no implementation."
590    fn reveal_in_file_manager(&self, path: &Path) -> ServiceResult<()> {
591        let uri = gio::File::for_path(path).uri();
592        let connection = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE)
593            .map_err(|_| BackendError::Unsupported)?;
594        let params = (vec![uri.to_string()], String::new()).to_variant();
595        connection
596            .call_sync(
597                Some("org.freedesktop.FileManager1"),
598                "/org/freedesktop/FileManager1",
599                "org.freedesktop.FileManager1",
600                "ShowItems",
601                Some(&params),
602                None,
603                gio::DBusCallFlags::NONE,
604                -1,
605                gio::Cancellable::NONE,
606            )
607            .map(|_| ())
608            .map_err(|e| BackendError::PlatformFailure {
609                context: format!("org.freedesktop.FileManager1.ShowItems: {e}"),
610            })
611    }
612
613    /// `gio::AppInfo::launch_default_for_uri` on `path`'s `file://` URI
614    /// — the same launch call [`Self::open_url`] makes,
615    /// just fed a path converted to a URI (`gio::File::for_path(..).uri()`)
616    /// instead of a caller-supplied URL string directly.
617    fn open_path(&self, path: &Path) -> ServiceResult<()> {
618        let uri = gio::File::for_path(path).uri();
619        gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>).map_err(|e| {
620            BackendError::PlatformFailure {
621                context: format!("gio::AppInfo::launch_default_for_uri: {e}"),
622            }
623        })
624    }
625
626    /// [`crate::desktop::move_to_trash`] — see that
627    /// function's doc for why every backend, GTK included, shares this
628    /// one `trash`-crate-backed implementation instead of hand-rolling
629    /// `gio::File::trash` here.
630    fn move_to_trash(&self, path: &Path) -> ServiceResult<()> {
631        crate::desktop::move_to_trash(path)
632    }
633
634    /// `gdk::Display::beep` — the parented window's own
635    /// display when one is attached (mirrors
636    /// [`Self::show_file_open_dialog`]'s parenting), falling back to
637    /// [`gtk4::gdk::Display::default`] otherwise (unit tests, or a
638    /// `GtkPlatformServices` used before [`Self::set_window`] is called).
639    /// `Err(BackendError::Unsupported)` only when neither exists — no
640    /// window *and* no default display at all, i.e. no live GTK display
641    /// connection (headless CI, no X11/Wayland).
642    fn beep(&self) -> ServiceResult<()> {
643        use gtk4::gdk::prelude::DisplayExt;
644        let display = match self.window.borrow().as_ref() {
645            Some(window) => WidgetExt::display(window),
646            None => gtk4::gdk::Display::default().ok_or(BackendError::Unsupported)?,
647        };
648        display.beep();
649        Ok(())
650    }
651
652    /// `gtk4::Settings`' dark-preference + theme-name
653    /// properties. `Settings::default()` returns `None` only when GTK has
654    /// no default display connection at all (headless dev box, CI) — the
655    /// same condition this module's own `require_gtk` test helper skips
656    /// its dialog tests on — so that's the one case this reports
657    /// `Unsupported` rather than a guessed value.
658    fn system_theme(&self) -> ServiceResult<SystemTheme> {
659        let settings = gtk4::Settings::default().ok_or(BackendError::Unsupported)?;
660        let dark = settings.is_gtk_application_prefer_dark_theme();
661        let theme_name = settings.gtk_theme_name();
662        Ok(system_theme_from_gtk_settings(dark, theme_name.as_deref()))
663    }
664
665    /// `gdk::Display::monitors()` + `Monitor::geometry()`/
666    /// `scale_factor()` — the parented window's own display
667    /// when one is attached, falling back to [`gtk4::gdk::Display::default`]
668    /// otherwise, the exact same fallback chain [`Self::beep`] uses (see
669    /// that method's doc).
670    ///
671    /// No work-area API exists in GDK4 **at all** — removed from GDK3
672    /// entirely, not merely absent on Wayland — so [`Display::work_area`]
673    /// is always a copy of [`Display::bounds`] here, on every desktop,
674    /// X11 included, rather than faking a value on some and not others.
675    /// GDK4 also dropped GDK3's `is_primary` monitor flag entirely, so
676    /// [`Display::primary`] is a best-effort proxy: index `0` in
677    /// `monitors()`'s enumeration order — documented, not a native
678    /// signal (see [`Display::primary`]'s own doc).
679    ///
680    /// `Err(BackendError::Unsupported)` only when neither exists — no
681    /// window *and* no default display at all, i.e. no live GTK display
682    /// connection (headless CI, no X11/Wayland), same condition
683    /// [`Self::beep`]/[`Self::system_theme`] already guard against.
684    fn displays(&self) -> ServiceResult<Vec<Display>> {
685        use gtk4::gdk::prelude::{DisplayExt, MonitorExt};
686        use gtk4::glib::prelude::Cast;
687
688        let display = match self.window.borrow().as_ref() {
689            Some(window) => WidgetExt::display(window),
690            None => gtk4::gdk::Display::default().ok_or(BackendError::Unsupported)?,
691        };
692        let monitors = display.monitors();
693        let count = monitors.n_items();
694        let mut out = Vec::with_capacity(count as usize);
695        for i in 0..count {
696            let Some(monitor) = monitors
697                .item(i)
698                .and_then(|o| o.downcast::<gtk4::gdk::Monitor>().ok())
699            else {
700                continue;
701            };
702            let geom = monitor.geometry();
703            let bounds = Rect::new(
704                geom.x() as f32,
705                geom.y() as f32,
706                geom.width() as f32,
707                geom.height() as f32,
708            );
709            out.push(Display {
710                bounds,
711                work_area: bounds,
712                scale: monitor.scale_factor() as f32,
713                primary: i == 0,
714            });
715        }
716        Ok(out)
717    }
718
719    /// **Deliberately not overridden** — GDK4 removed the global/root-window
720    /// pointer-position query entirely. `Surface::device_position` only
721    /// returns a surface-relative position with no way to translate to a
722    /// global point. This is a Wayland protocol restriction, not a missing
723    /// binding. `Err(BackendError::Unsupported)` is the correct answer.
724    fn cursor_screen_point(&self) -> ServiceResult<Point> {
725        Err(BackendError::Unsupported)
726    }
727
728    fn platform_name(&self) -> &'static str {
729        "gtk"
730    }
731}
732
733// ── Notifications ────────────────────────────────────────────────────────
734
735/// Counter backing an untagged notification's `gio::Application::send_notification`
736/// id — a fresh value per call, so two untagged notifications never
737/// collide and replace each other the way two calls with the same `tag`
738/// deliberately do. Mirrors `win::services::NEXT_NOTIFICATION_ID`'s exact
739/// role for the Win-GUI balloon-tip path.
740static NEXT_NOTIFICATION_ID: AtomicU64 = AtomicU64::new(1);
741
742/// Bare name [`gio::SimpleAction::new`]/`ActionMap::add_action` register
743/// under — no `app.` prefix, since that's implied by which action group
744/// (`Application`'s own) it's added to.
745const NOTIFICATION_ACTION_ID: &str = "quadraui-notification-activated";
746
747/// The same action, `app.`-prefixed, as
748/// [`gio::Notification::set_default_action_and_target_value`]/
749/// `add_button_with_target_value` expect — those two are NOT "detailed
750/// action name" parsers (no `::target` suffix syntax); they take a plain
751/// action name scoped by group prefix, which for an app-registered
752/// action is always `app.`.
753const NOTIFICATION_ACTION_DETAILED: &str = "app.quadraui-notification-activated";
754
755/// Encode `(tag, action)` as the `(ss)` `Variant` the shared
756/// notification action's target carries — empty string standing in for
757/// `None` on each side (see [`decode_notification_target`]'s doc for the
758/// resulting edge case). A tuple over a custom `GVariant` dict/struct
759/// because `(ss)` is exactly two fixed fields, no future third one
760/// anticipated, and glib's tuple `ToVariant`/`FromVariant` impls need no
761/// extra ceremony to round-trip through.
762fn notification_target(tag: Option<&str>, action: Option<&str>) -> glib::Variant {
763    (tag.unwrap_or_default(), action.unwrap_or_default()).to_variant()
764}
765
766/// Inverse of [`notification_target`] — decodes a notification action's
767/// activation `Variant` back into `(tag, action)`. `None` on a
768/// non-`(ss)` payload (shouldn't happen: this crate is the only writer
769/// of this action's target), rather than panicking on a malformed
770/// `Variant` from a source this module doesn't control.
771///
772/// An empty string on either side round-trips as `None` — so a
773/// `Notification` tagged `""` (rather than `with_tag` never called) or
774/// an action registered with `WidgetId::new("")` is indistinguishable
775/// from having no tag/action at all. Both are degenerate inputs no
776/// caller in this crate constructs; documented here rather than guarded
777/// against, matching this module's existing posture on similarly
778/// unreachable-in-practice edge cases (e.g. `hig_button_order`'s
779/// declared-order fallback).
780fn decode_notification_target(
781    variant: &glib::Variant,
782) -> Option<(Option<String>, Option<WidgetId>)> {
783    let (tag, action) = variant.get::<(String, String)>()?;
784    let tag = if tag.is_empty() { None } else { Some(tag) };
785    let action = if action.is_empty() {
786        None
787    } else {
788        Some(WidgetId::new(action))
789    };
790    Some((tag, action))
791}
792
793/// Decode a [`ImageSource`] into a `gio::Icon` for
794/// [`gio::Notification::set_icon`]. GIO does its own format sniffing
795/// (`GLoadableIcon`) for `Bytes`; `Path` hands the file path straight to
796/// the notification daemon via `gio::FileIcon` rather than this process
797/// reading the bytes itself — the daemon needs the file to still exist
798/// when it gets around to rendering the notification either way, so
799/// there's no correctness difference, only one less read in this
800/// process.
801fn decode_gio_icon(source: &ImageSource) -> gio::Icon {
802    match source {
803        ImageSource::Bytes(bytes) => {
804            gio::BytesIcon::new(&glib::Bytes::from_owned(bytes.clone())).upcast()
805        }
806        ImageSource::Path(path) => gio::FileIcon::new(&gio::File::for_path(path)).upcast(),
807    }
808}
809
810/// Pure mapping from `gtk4::Settings`' two theme properties to
811/// [`SystemTheme`] — split out from `system_theme` so it's unit-testable
812/// without a live GTK display, mirroring this module's
813/// `hig_button_order`/`native_button_order`-style helpers.
814///
815/// No accent-colour source: GTK4 itself exposes none (that lives on
816/// libadwaita's `AdwStyleManager`, which this crate doesn't depend on), so
817/// `accent` is always `None` here.
818fn system_theme_from_gtk_settings(dark: bool, theme_name: Option<&str>) -> SystemTheme {
819    let high_contrast = theme_name
820        .map(|name| name.to_ascii_lowercase().contains("highcontrast"))
821        .unwrap_or(false);
822    SystemTheme {
823        dark,
824        accent: None,
825        high_contrast,
826    }
827}
828
829// ── Native-dialog CSS theming ────────────────────────────────────────────
830
831/// CSS rules [`GtkPlatformServices::set_theme`] loads into
832/// [`GtkPlatformServices::css_provider`] to theme the GTK chrome
833/// in native dialogs: `gtk4::FileDialog`'s places sidebar (`.sidebar`),
834/// scrollbars, and popover-hosted content — the filter dropdown and
835/// "create folder" popover. See the module doc's "Native-dialog CSS
836/// theming" section for context.
837///
838/// Uses [`crate::Theme::surface_bg`]/[`crate::Theme::surface_fg`] for
839/// sidebar and popover backgrounds/text, plus [`crate::Theme::muted_fg`]
840/// for scrollbar slider. Pure and GTK-object-free, unit-testable and
841/// kept current via [`GtkPlatformServices::last_dialog_css`].
842fn dialog_css(theme: &crate::Theme) -> String {
843    let surface_bg = css_hex(theme.surface_bg);
844    let surface_fg = css_hex(theme.surface_fg);
845    let slider = css_hex(theme.muted_fg);
846    format!(
847        "\
848.sidebar {{
849    background-color: {surface_bg};
850    color: {surface_fg};
851}}
852scrollbar slider {{
853    background-color: {slider};
854}}
855popover contents {{
856    background-color: {surface_bg};
857    color: {surface_fg};
858}}
859"
860    )
861}
862
863/// `"#rrggbb"` — none of [`dialog_css`]'s three rules need alpha, so
864/// [`crate::types::Color::a`] is dropped rather than emitted as an
865/// `#rrggbbaa` GTK CSS never asked for.
866fn css_hex(color: crate::types::Color) -> String {
867    format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
868}
869
870/// Build a `gtk4::FileDialog` from the backend-agnostic
871/// [`FileDialogOptions`]. `initial_name` is passed separately (rather
872/// than read off `opts.initial_filename`) because it only applies to
873/// the save dialog — `show_file_open_dialog` calls this with `None`.
874fn build_file_dialog(opts: &FileDialogOptions, initial_name: Option<&str>) -> gtk4::FileDialog {
875    let dialog = gtk4::FileDialog::new();
876    if let Some(ref title) = opts.title {
877        dialog.set_title(title);
878    }
879    if let Some(ref dir) = opts.initial_dir {
880        dialog.set_initial_folder(Some(&gio::File::for_path(dir)));
881    }
882    if let Some(name) = initial_name {
883        dialog.set_initial_name(Some(name));
884    }
885    if !opts.filters.is_empty() {
886        let filters = gio::ListStore::new::<gtk4::FileFilter>();
887        for (name, extensions) in &opts.filters {
888            let filter = gtk4::FileFilter::new();
889            filter.set_name(Some(name));
890            for ext in extensions {
891                filter.add_suffix(ext);
892            }
893            filters.append(&filter);
894        }
895        dialog.set_filters(Some(&filters));
896    }
897    dialog
898}
899
900/// Nested-mainloop adapter: `gtk4::FileDialog::open`/`save` and
901/// `gtk4::AlertDialog::choose` are async-only (GTK4
902/// dropped the blocking `FileChooserDialog` API and never had a blocking
903/// alert), but [`PlatformServices::show_file_open_dialog`] /
904/// `show_file_save_dialog` / `show_message_dialog` are synchronous
905/// across every backend (the signature macOS's `NSOpenPanel::runModal` /
906/// `NSAlert::runModal` and Win32's `comdlg32` / `MessageBoxEx` map onto
907/// directly). This closes the gap the same way GTK itself closes it
908/// internally for modal dialogs (e.g. the legacy `gtk_dialog_run`): pump
909/// `glib::MainContext::iteration(true)` — which blocks until *some*
910/// source is ready and dispatches it — in a loop until the async
911/// operation's completion callback has stashed a result in `result`.
912/// Generic over the result type so every blocking-dialog call site
913/// (file open/save, message/alert) shares this one pump instead of each
914/// hand-rolling its own nested loop.
915///
916/// # Re-entrancy note
917///
918/// Pumping the main loop here lets *any* pending GLib source run,
919/// including redraw/timer/other-widget callbacks that would normally
920/// wait their turn — the same way a native nested loop (or GTK's old
921/// `gtk_dialog_run`) does. If one of those callbacks itself triggers
922/// another dialog, the inner call's `iteration(true)` loop will also
923/// service the outer dialog's completion source, which is safe but
924/// means dialogs can resolve out of call order. Apps should avoid
925/// opening a second dialog from inside a callback that runs while one is
926/// already open.
927///
928/// Critically, this is called while `quadraui::gtk::run`'s caller (an
929/// `AppLogic::handle` invocation) still holds the shared
930/// `GtkBackend`'s `RefCell` mutably borrowed. `pumping` — bumped for the
931/// duration of this call — is how the runner's other backend-touching
932/// callbacks (idle-drain timer, input controllers, draw func) detect
933/// that and skip their own `backend.borrow_mut()` instead of panicking
934/// on a double-borrow.
935fn pump_until_ready<T>(result: &Rc<RefCell<Option<T>>>, pumping: &ModalPumpDepth) -> T {
936    let _guard = ModalPumpGuard::new(pumping);
937    let ctx = glib::MainContext::default();
938    while result.borrow().is_none() {
939        ctx.iteration(true);
940    }
941    result
942        .borrow_mut()
943        .take()
944        .expect("loop above only exits once `result` is Some")
945}
946
947/// GNOME HIG button ordering for `gtk4::AlertDialog::set_buttons`: the
948/// cancel button (if any) goes first (leftmost); the default/primary
949/// button (if any) goes last (rightmost); every other button keeps its
950/// original relative order in between. Returns indices into `buttons`,
951/// in the order to hand to `set_buttons` — `show_message_dialog` maps
952/// the chosen index back through this same `Vec` to recover the
953/// original [`MessageDialogButton::id`].
954///
955/// A button with both `is_default` and `is_cancel` set (a single-button
956/// "OK" dialog) is treated as the cancel slot here — see below, both
957/// `set_cancel_button` and `set_default_button` are still pointed at its
958/// position afterward regardless of which bucket placed it.
959fn hig_button_order(buttons: &[MessageDialogButton]) -> Vec<usize> {
960    let mut cancel_idx = None;
961    let mut default_idx = None;
962    let mut middle = Vec::new();
963    for (i, b) in buttons.iter().enumerate() {
964        if b.is_cancel && cancel_idx.is_none() {
965            cancel_idx = Some(i);
966        } else if b.is_default && default_idx.is_none() {
967            default_idx = Some(i);
968        } else {
969            middle.push(i);
970        }
971    }
972    let mut order = Vec::with_capacity(buttons.len());
973    order.extend(cancel_idx);
974    order.extend(middle);
975    if let Some(d) = default_idx {
976        order.push(d);
977    }
978    order
979}
980
981// The RAII bump/decrement for the shared pump-depth counter is defined
982// in `crate::desktop::ModalPumpGuard` — extracted there for use by all backends.
983
984/// In-memory stand-in for the two OS selections, installed by
985/// [`GtkClipboard::install_test_contents`] so unit tests can exercise the
986/// clipboard-paste code paths **without** depending on whatever the host
987/// running the tests happens to have on its real clipboard.
988///
989/// Test-only on purpose: without it, a test asserting "nothing to paste"
990/// passes on a headless box (where `arboard::Clipboard::new()` fails) and
991/// fails on any developer machine or CI runner with a live display and a
992/// non-empty clipboard — a genuinely flaky, environment-dependent
993/// assertion rather than a statement about quadraui's behaviour.
994#[cfg(test)]
995#[derive(Clone, Debug, Default)]
996pub(crate) struct TestClipboardContents {
997    /// Contents of the CLIPBOARD selection — what Ctrl-V / Ctrl-Shift-V
998    /// reads. `None` models "nothing has been copied".
999    pub clipboard: Option<String>,
1000    /// Contents of the PRIMARY selection — what middle-click reads.
1001    /// `None` models "nothing is selected anywhere".
1002    pub primary: Option<String>,
1003}
1004
1005/// System clipboard via `arboard`. The handle is kept alive for the
1006/// process lifetime so Linux clipboard serving threads persist.
1007pub struct GtkClipboard {
1008    inner: RefCell<Option<arboard::Clipboard>>,
1009    /// When `Some`, every read/write below is served from this in-memory
1010    /// fake instead of the OS, making clipboard-dependent unit tests
1011    /// deterministic on any host. Only ever populated by
1012    /// [`Self::install_test_contents`]; production builds don't compile
1013    /// the field at all.
1014    #[cfg(test)]
1015    test_contents: RefCell<Option<TestClipboardContents>>,
1016}
1017
1018impl GtkClipboard {
1019    fn new() -> Self {
1020        Self {
1021            inner: RefCell::new(arboard::Clipboard::new().ok()),
1022            #[cfg(test)]
1023            test_contents: RefCell::new(None),
1024        }
1025    }
1026
1027    /// Swap this clipboard over to an in-memory fake seeded with
1028    /// `contents`. Every subsequent read and write goes to the fake and
1029    /// the OS clipboard is left untouched — so a test can assert on the
1030    /// "there is something to paste" and "there is nothing to paste"
1031    /// branches independently of the host.
1032    #[cfg(test)]
1033    pub(crate) fn install_test_contents(&self, contents: TestClipboardContents) {
1034        *self.test_contents.borrow_mut() = Some(contents);
1035    }
1036
1037    /// OS-level PRIMARY-selection read, split out from the trait method
1038    /// so the trait method itself stays uncfg'd (and therefore honours
1039    /// [`Self::install_test_contents`] on every target).
1040    ///
1041    /// Gated to the same `cfg` `arboard` itself uses for `GetExtLinux`
1042    /// (see `arboard::lib`). Windows/macOS have no PRIMARY-selection
1043    /// concept, and quadraui's `gtk` feature only ships a Linux backend
1044    /// in practice, so there the OS read is simply `None`.
1045    #[cfg(all(
1046        unix,
1047        not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))
1048    ))]
1049    fn read_os_primary_selection(&self) -> Option<String> {
1050        use arboard::{GetExtLinux, LinuxClipboardKind};
1051        self.inner
1052            .borrow_mut()
1053            .as_mut()?
1054            .get()
1055            .clipboard(LinuxClipboardKind::Primary)
1056            .text()
1057            .ok()
1058    }
1059
1060    /// Non-Linux counterpart of [`Self::read_os_primary_selection`] —
1061    /// there is no PRIMARY selection to read.
1062    #[cfg(not(all(
1063        unix,
1064        not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))
1065    )))]
1066    fn read_os_primary_selection(&self) -> Option<String> {
1067        None
1068    }
1069}
1070
1071impl Clipboard for GtkClipboard {
1072    fn read_text(&self) -> Option<String> {
1073        #[cfg(test)]
1074        {
1075            if let Some(fake) = self.test_contents.borrow().as_ref() {
1076                return fake.clipboard.clone();
1077            }
1078        }
1079        self.inner.borrow_mut().as_mut()?.get_text().ok()
1080    }
1081
1082    fn write_text(&self, text: &str) {
1083        #[cfg(test)]
1084        {
1085            if let Some(fake) = self.test_contents.borrow_mut().as_mut() {
1086                fake.clipboard = Some(text.to_string());
1087                return;
1088            }
1089        }
1090        if let Some(cb) = self.inner.borrow_mut().as_mut() {
1091            let _ = cb.set_text(text);
1092        }
1093    }
1094
1095    /// PRIMARY selection (middle-click paste source) — only meaningful on
1096    /// X11/Wayland, where `arboard`'s Linux extension trait exposes it as
1097    /// a distinct selection from CLIPBOARD. See
1098    /// [`GtkClipboard::read_os_primary_selection`] for the per-target
1099    /// split.
1100    fn read_primary_selection(&self) -> Option<String> {
1101        #[cfg(test)]
1102        {
1103            if let Some(fake) = self.test_contents.borrow().as_ref() {
1104                return fake.primary.clone();
1105            }
1106        }
1107        self.read_os_primary_selection()
1108    }
1109
1110    /// Decoded RGBA pixels via `arboard::Clipboard::get_image`.
1111    /// Not covered by [`Self::install_test_contents`]'s fake — that only
1112    /// modeled text — so this always goes to the real OS clipboard.
1113    fn read_image(&self) -> ServiceResult<RgbaImage> {
1114        let mut inner = self.inner.borrow_mut();
1115        let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
1116            context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
1117        })?;
1118        let img = cb
1119            .get_image()
1120            .map_err(|e| map_arboard_error("arboard::get_image", e))?;
1121        Ok(RgbaImage {
1122            width: img.width as u32,
1123            height: img.height as u32,
1124            pixels: img.bytes.into_owned(),
1125        })
1126    }
1127
1128    fn write_image(&self, image: &RgbaImage) -> ServiceResult<()> {
1129        let mut inner = self.inner.borrow_mut();
1130        let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
1131            context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
1132        })?;
1133        let data = arboard::ImageData {
1134            width: image.width as usize,
1135            height: image.height as usize,
1136            bytes: std::borrow::Cow::Borrowed(&image.pixels),
1137        };
1138        cb.set_image(data)
1139            .map_err(|e| map_arboard_error("arboard::set_image", e))
1140    }
1141
1142    fn write_html(&self, html: &str, alt_text: &str) -> ServiceResult<()> {
1143        let mut inner = self.inner.borrow_mut();
1144        let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
1145            context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
1146        })?;
1147        cb.set_html(html, Some(alt_text))
1148            .map_err(|e| map_arboard_error("arboard::set_html", e))
1149    }
1150
1151    fn read_file_list(&self) -> ServiceResult<Vec<PathBuf>> {
1152        let mut inner = self.inner.borrow_mut();
1153        let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
1154            context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
1155        })?;
1156        cb.get()
1157            .file_list()
1158            .map_err(|e| map_arboard_error("arboard::get().file_list()", e))
1159    }
1160
1161    fn clear(&self) -> ServiceResult<()> {
1162        let mut inner = self.inner.borrow_mut();
1163        let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
1164            context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
1165        })?;
1166        cb.clear()
1167            .map_err(|e| map_arboard_error("arboard::clear", e))
1168    }
1169}
1170
1171/// Context string used when there is no live `arboard::Clipboard` handle
1172/// at all (construction failed — e.g. headless, no display) rather than a
1173/// native call on a real handle failing.
1174const NO_ARBOARD_HANDLE_CONTEXT: &str = "arboard::Clipboard::new (no clipboard available)";
1175
1176/// Map an `arboard::Error` from a named native call into a
1177/// [`BackendError::PlatformFailure`] — the one
1178/// implementation shared with `macos::services`; see
1179/// [`crate::desktop::map_arboard_error`]'s own doc for the full
1180/// rationale.
1181use crate::desktop::map_arboard_error;
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use gtk4::prelude::ListModelExt;
1187    use std::sync::OnceLock;
1188
1189    // ── Clipboard image/html/file-list/clear ───────────────────────────
1190
1191    /// Real OS-clipboard round trip through all clipboard methods.
1192    /// Needs no `gtk4::init()` (arboard talks directly to the display
1193    /// server) but does need a live clipboard session. Skips gracefully
1194    /// on headless CI runners with no X11/Wayland, the same way as
1195    /// `build_file_dialog_behaviors`. Single test so parallel threads
1196    /// don't fight over the systemwide clipboard.
1197    #[allow(clippy::print_stderr)]
1198    #[test]
1199    fn clipboard_image_html_file_list_and_clear_round_trip() {
1200        let svc = GtkPlatformServices::new();
1201        let cb = svc.clipboard();
1202
1203        let image = RgbaImage {
1204            width: 2,
1205            height: 2,
1206            pixels: vec![
1207                255, 0, 0, 255, // red
1208                0, 255, 0, 255, // green
1209                0, 0, 255, 255, // blue
1210                255, 255, 0, 255, // yellow
1211            ],
1212        };
1213        if let Err(e) = cb.write_image(&image) {
1214            eprintln!("skipping: no live OS clipboard in this environment ({e:?})");
1215            return;
1216        }
1217        let read_back = cb
1218            .read_image()
1219            .expect("read_image should see what write_image just wrote");
1220        assert_eq!(read_back.width, image.width);
1221        assert_eq!(read_back.height, image.height);
1222        assert_eq!(
1223            read_back.pixels.len(),
1224            (image.width * image.height * 4) as usize
1225        );
1226
1227        cb.write_html("<b>hi</b>", "hi").expect(
1228            "write_html should succeed once write_image already proved the clipboard is live",
1229        );
1230
1231        let tmp = std::env::temp_dir().join("quadraui-954-gtk-clipboard-test-file.txt");
1232        std::fs::write(&tmp, b"quadraui#954").expect("write temp file");
1233        arboard::Clipboard::new()
1234            .expect("a second arboard handle should also see the live clipboard")
1235            .set()
1236            .file_list(&[&tmp])
1237            .expect("seeding the clipboard with a file list should succeed");
1238        let files = cb
1239            .read_file_list()
1240            .expect("read_file_list should see the seeded file");
1241        assert_eq!(
1242            files
1243                .iter()
1244                .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
1245                .collect::<Vec<_>>(),
1246            vec![tmp.canonicalize().expect("temp file should exist")]
1247        );
1248        let _ = std::fs::remove_file(&tmp);
1249
1250        cb.write_text("some text #954");
1251        assert_eq!(cb.read_text(), Some("some text #954".to_string()));
1252        cb.clear()
1253            .expect("clear should succeed once the clipboard has proven live");
1254        assert_eq!(cb.read_text(), None);
1255    }
1256
1257    /// Constructing a real `gtk4::FileDialog` requires `gtk4::init()` to
1258    /// have succeeded, which needs a display connection (X11/Wayland).
1259    /// CI and headless dev boxes don't have one, so these tests degrade
1260    /// to a no-op skip there rather than fail — mirrors how the crate's
1261    /// other GTK-object tests avoid needing a live display (see
1262    /// `gtk::tab_bar` — Cairo `ImageSurface` tests intentionally sidestep
1263    /// this same problem). Cached in a `OnceLock` since `gtk4::init()`
1264    /// after a failed first attempt keeps failing.
1265    ///
1266    /// # Thread affinity
1267    ///
1268    /// gtk4-rs's thread-affinity guard is a per-thread flag
1269    /// (`IS_MAIN_THREAD`, a `thread_local!` in gtk4-rs's `rt.rs`) set only
1270    /// on whichever thread's call actually ran `gtk_init_check()`. Rust's
1271    /// default test harness runs every `#[test]` fn on its own spawned OS
1272    /// thread, so if more than one `#[test]` fn calls `require_gtk()`,
1273    /// only the winner of this `OnceLock` race — one specific thread —
1274    /// is ever GTK-safe; every other test's thread reads the cached
1275    /// `true` without ever calling `gtk4::init()` itself, then panics
1276    /// with "GTK may only be used from the main thread" the moment it
1277    /// touches a real GTK object. gtk4-rs ships `#[gtk4::test]` /
1278    /// `test_synced` for exactly this (marshal every GTK-touching test
1279    /// onto one dedicated worker thread) — not used here because its
1280    /// `Lazy` worker-thread init calls `gtk4::init().expect(...)`
1281    /// unconditionally, with no graceful-skip path, which would turn a
1282    /// headless dev box's "no display" case into a hard panic instead of
1283    /// the skip this helper is designed to give it. So instead, every
1284    /// `#[test]` fn that needs a real `gtk4::FileDialog` lives in
1285    /// [`build_file_dialog_behaviors`] below — one function body, hence
1286    /// one OS thread, guaranteeing it's the same thread that (first) runs
1287    /// `gtk4::init()`. Do **not** add another separate `#[test]` fn that
1288    /// calls `require_gtk()` and then constructs a real GTK object —
1289    /// fold its assertions into `build_file_dialog_behaviors` instead, or
1290    /// this panic comes back.
1291    ///
1292    /// # macOS main-thread affinity
1293    ///
1294    /// On macOS, gtk4-rs's `gtk4::init()` asserts main-thread affinity
1295    /// itself, *before* it can fail gracefully: `rt.rs::set_initialized`
1296    /// calls the C `pthread_main_np()` and hard-panics with "Attempted to
1297    /// initialize GTK on OSX from non-main thread" rather than returning
1298    /// `Err`. Rust's default test harness never runs a `#[test]` fn on
1299    /// the process's actual OS main thread (that thread stays inside the
1300    /// harness, dispatching tests to a worker pool), so unconditionally
1301    /// calling `gtk4::init()` here would panic on every macOS run instead
1302    /// of degrading to the same "no display" skip this guard already
1303    /// gives headless Linux boxes. [`is_main_thread`] checks the same
1304    /// `pthread_main_np()` symbol gtk4-rs itself calls (declared directly
1305    /// rather than pulling in the `libc` crate, which the `gtk` feature
1306    /// doesn't otherwise depend on) so this guard can report `false`
1307    /// *before* ever touching `gtk4::init()` on a non-main thread.
1308    fn require_gtk() -> bool {
1309        static INIT: OnceLock<bool> = OnceLock::new();
1310        *INIT.get_or_init(|| {
1311            #[cfg(target_os = "macos")]
1312            if !is_main_thread() {
1313                return false;
1314            }
1315            gtk4::init().is_ok()
1316        })
1317    }
1318
1319    /// True when called from the process's actual OS main thread.
1320    /// Declares the same C symbol gtk4-rs's own `rt.rs` calls internally
1321    /// (`pthread_main_np`, macOS-only — see [`require_gtk`]'s doc)
1322    /// instead of adding a `libc` dependency to the `gtk` feature for one
1323    /// FFI call.
1324    #[cfg(target_os = "macos")]
1325    fn is_main_thread() -> bool {
1326        unsafe extern "C" {
1327            fn pthread_main_np() -> i32;
1328        }
1329        unsafe { pthread_main_np() != 0 }
1330    }
1331
1332    /// `require_gtk()` must degrade to a graceful `false`
1333    /// on macOS instead of panicking inside `gtk4::init()` — `cargo test`
1334    /// never runs a `#[test]` fn on the process's real OS main thread, so
1335    /// this is a deterministic assertion here (not an environment-gap
1336    /// no-op like `build_file_dialog_behaviors`'s display skip): the
1337    /// guard itself must not panic regardless of which test thread wins
1338    /// the `OnceLock` race.
1339    #[cfg(target_os = "macos")]
1340    #[test]
1341    fn require_gtk_does_not_panic_off_main_thread_on_macos() {
1342        assert!(
1343            !require_gtk(),
1344            "cargo test never runs on the OS main thread, so require_gtk() \
1345             must report false here instead of calling gtk4::init() and \
1346             hitting gtk4-rs's own main-thread panic"
1347        );
1348    }
1349
1350    /// Covers every `build_file_dialog` behavior that requires
1351    /// constructing a real `gtk4::FileDialog`. Deliberately one `#[test]`
1352    /// fn (not three) — see the thread-affinity note on [`require_gtk`]:
1353    /// splitting these across separate `#[test]` fns lets the
1354    /// Rust test harness run them on different OS threads, which
1355    /// deterministically panics every thread except whichever one wins
1356    /// the `require_gtk()` / `gtk4::init()` race.
1357    // Exempt from the crate-wide `print_stderr` deny. `cargo test`
1358    // output, not code that runs inside a host's live UI session — this
1359    // line only ever executes on a headless dev box with no display,
1360    // where it's the reason the test reports "ok" without covering
1361    // anything.
1362    #[allow(clippy::print_stderr)]
1363    #[test]
1364    fn build_file_dialog_behaviors() {
1365        if !require_gtk() {
1366            eprintln!("skipping: GTK failed to initialize (no display available)");
1367            return;
1368        }
1369
1370        // The dialog builder must apply every `FileDialogOptions` field
1371        // GTK has a settable property for, so a future maintainer
1372        // changing the wiring can't silently drop one (e.g. forgetting
1373        // `set_filters`).
1374        {
1375            let opts = FileDialogOptions {
1376                title: Some("Open File".to_string()),
1377                initial_dir: Some(PathBuf::from("/tmp")),
1378                initial_filename: None,
1379                filters: vec![("Rust files".to_string(), vec!["rs".to_string()])],
1380            };
1381            let dialog = build_file_dialog(&opts, None);
1382            assert_eq!(dialog.title(), "Open File");
1383            assert_eq!(
1384                gtk4::prelude::FileExt::path(&dialog.initial_folder().unwrap()),
1385                Some(PathBuf::from("/tmp"))
1386            );
1387            let filters = dialog.filters().expect("filters should be set");
1388            assert_eq!(filters.n_items(), 1);
1389        }
1390
1391        // `initial_name` only applies when explicitly passed (the
1392        // save-dialog path) — `show_file_open_dialog` always passes
1393        // `None` regardless of `opts.initial_filename`, since that field
1394        // is documented save-only.
1395        {
1396            let opts = FileDialogOptions {
1397                initial_filename: Some("untitled.txt".to_string()),
1398                ..Default::default()
1399            };
1400            let without = build_file_dialog(&opts, None);
1401            assert_eq!(without.initial_name(), None);
1402
1403            let with = build_file_dialog(&opts, opts.initial_filename.as_deref());
1404            assert_eq!(with.initial_name().as_deref(), Some("untitled.txt"));
1405        }
1406
1407        // No filters configured → `set_filters` must not be called (a
1408        // `Some` empty list would still change the dialog's
1409        // filter-picker UI).
1410        {
1411            let dialog = build_file_dialog(&FileDialogOptions::default(), None);
1412            assert!(dialog.filters().is_none());
1413        }
1414
1415        // `beep` also needs a real live GTK/GDK object
1416        // (`gdk4::Display::default()`, since no window is attached to
1417        // this bare `GtkPlatformServices`) — folded in here, despite this
1418        // function's file-dialog-flavoured name, for the exact
1419        // thread-affinity reason `require_gtk`'s doc comment gives: a
1420        // separate `#[test]` fn touching a real GTK/GDK object would run
1421        // on its own OS thread and panic unless it happens to be the one
1422        // that won the `gtk4::init()` race.
1423        {
1424            let services = GtkPlatformServices::new();
1425            assert_eq!(services.beep(), Ok(()));
1426        }
1427
1428        // `displays` needs the same real `gdk4::Display`
1429        // `beep` above does — folded in for the identical thread-affinity
1430        // reason. A live display always has at least one monitor; every
1431        // returned `Display` must report the crate-wide "no work-area
1432        // API on GTK" and "index-0-is-primary" degrades this backend's
1433        // own doc promises (see `GtkPlatformServices::displays`).
1434        {
1435            let services = GtkPlatformServices::new();
1436            let displays = services.displays().expect("a live display has monitors");
1437            assert!(!displays.is_empty());
1438            assert!(displays[0].primary);
1439            for d in &displays {
1440                assert_eq!(d.work_area, d.bounds);
1441            }
1442        }
1443    }
1444
1445    /// `set_window` stores the handle used to parent future dialogs.
1446    /// Guards against a panic/wrong-field regression in the setter.
1447    #[test]
1448    fn set_window_is_none_until_called() {
1449        let services = GtkPlatformServices::new();
1450        assert!(services.window.borrow().is_none());
1451    }
1452
1453    /// GDK4 has no global pointer-position query at all —
1454    /// `cursor_screen_point` always reports `Unsupported`, on every host
1455    /// this runs on (no `gtk4::init()`/display needed, unlike `displays`
1456    /// above, so this is an ordinary `#[test]` fn with no
1457    /// `require_gtk()` guard).
1458    #[test]
1459    fn cursor_screen_point_always_reports_unsupported_on_gtk() {
1460        let services = GtkPlatformServices::new();
1461        assert_eq!(
1462            services.cursor_screen_point(),
1463            Err(BackendError::Unsupported)
1464        );
1465    }
1466
1467    /// `open_url_result` reports real failures when there's no registered
1468    /// handler for a URL's scheme. A made-up scheme no desktop registers
1469    /// is the deterministic way to test this without a live display/session.
1470    /// GIO's scheme lookup needs no `gtk4::init()`, so this runs unconditionally.
1471    #[test]
1472    fn open_url_result_reports_failure_for_an_unregistered_scheme() {
1473        let services = GtkPlatformServices::new();
1474        assert!(services
1475            .open_url_result("quadraui-1087-nonexistent-scheme://x")
1476            .is_err());
1477    }
1478
1479    // Regression test for #427 ("depth counter stays positive until the
1480    // outermost re-entrant pump unwinds") used to live here directly
1481    // against a local `PumpGuard`; that behavior is now covered once,
1482    // backend-neutrally, by `crate::desktop`'s own `modal_pump_tests`.
1483    // The test below stays GTK-specific: it pins that
1484    // `GtkPlatformServices::pump_depth()`'s returned handle really does
1485    // share state with the services' own counter (same `Rc`, not an
1486    // independent clone).
1487
1488    /// `GtkPlatformServices::pump_depth()` — the handle
1489    /// `quadraui::gtk::run::activate` clones into its event
1490    /// controllers — must observe mutations `pump_until_ready` makes
1491    /// through the services' own copy (same underlying counter via
1492    /// `ModalPumpDepth`'s `Rc`, not an independent one).
1493    #[test]
1494    fn pump_depth_handle_observes_guard_mutations_on_the_services_copy() {
1495        let services = GtkPlatformServices::new();
1496        let handle = services.pump_depth();
1497        assert_eq!(handle.get(), 0);
1498        let _guard = ModalPumpGuard::new(&services.pumping);
1499        assert_eq!(
1500            handle.get(),
1501            1,
1502            "handle must observe the mutation made through services.pumping"
1503        );
1504    }
1505
1506    // ── hig_button_order ───────────────────────────────────────────────
1507
1508    fn msg_btn(id: &str, is_default: bool, is_cancel: bool) -> MessageDialogButton {
1509        MessageDialogButton {
1510            id: crate::types::WidgetId::new(id),
1511            label: id.to_string(),
1512            is_default,
1513            is_cancel,
1514        }
1515    }
1516
1517    #[test]
1518    fn hig_button_order_puts_cancel_first_and_default_last() {
1519        let buttons = vec![
1520            msg_btn("save", true, false),
1521            msg_btn("dont-save", false, false),
1522            msg_btn("cancel", false, true),
1523        ];
1524        // Declared order is default, other, cancel — HIG order must
1525        // reshuffle to cancel, other, default.
1526        assert_eq!(hig_button_order(&buttons), vec![2, 1, 0]);
1527    }
1528
1529    #[test]
1530    fn hig_button_order_preserves_middle_button_relative_order() {
1531        let buttons = vec![
1532            msg_btn("cancel", false, true),
1533            msg_btn("a", false, false),
1534            msg_btn("b", false, false),
1535            msg_btn("save", true, false),
1536        ];
1537        assert_eq!(hig_button_order(&buttons), vec![0, 1, 2, 3]);
1538    }
1539
1540    #[test]
1541    fn hig_button_order_single_button_both_default_and_cancel() {
1542        // A single "OK" button dialog: is_cancel is checked first, so it
1543        // lands in the cancel slot — but `show_message_dialog` still
1544        // points both `set_cancel_button` and `set_default_button` at
1545        // its position since `is_default` also reads true on it.
1546        let buttons = vec![msg_btn("ok", true, true)];
1547        assert_eq!(hig_button_order(&buttons), vec![0]);
1548    }
1549
1550    #[test]
1551    fn hig_button_order_no_default_or_cancel_keeps_declared_order() {
1552        let buttons = vec![msg_btn("a", false, false), msg_btn("b", false, false)];
1553        assert_eq!(hig_button_order(&buttons), vec![0, 1]);
1554    }
1555
1556    // ── system_theme_from_gtk_settings ──────────────────────────────────
1557
1558    #[test]
1559    fn system_theme_from_gtk_settings_reports_dark() {
1560        let theme = system_theme_from_gtk_settings(true, Some("Adwaita-dark"));
1561        assert!(theme.dark);
1562        assert_eq!(theme.accent, None);
1563        assert!(!theme.high_contrast);
1564    }
1565
1566    #[test]
1567    fn system_theme_from_gtk_settings_reports_light() {
1568        let theme = system_theme_from_gtk_settings(false, Some("Adwaita"));
1569        assert!(!theme.dark);
1570        assert!(!theme.high_contrast);
1571    }
1572
1573    #[test]
1574    fn system_theme_from_gtk_settings_detects_high_contrast_case_insensitively() {
1575        assert!(system_theme_from_gtk_settings(false, Some("HighContrast")).high_contrast);
1576        assert!(system_theme_from_gtk_settings(false, Some("HighContrastInverse")).high_contrast);
1577        assert!(system_theme_from_gtk_settings(true, Some("highcontrast")).high_contrast);
1578    }
1579
1580    #[test]
1581    fn system_theme_from_gtk_settings_no_theme_name_is_not_high_contrast() {
1582        assert!(!system_theme_from_gtk_settings(true, None).high_contrast);
1583    }
1584
1585    // ── Native-dialog CSS theming ────────────────────────────────────────
1586
1587    /// [`dialog_css`] is a pure string builder — needs no `gtk4::init()`
1588    /// — so it's tested directly for the exact colours/selectors the
1589    /// module doc promises: `.sidebar` and `popover contents` both track
1590    /// `surface_bg`/`surface_fg`, `scrollbar slider` tracks `muted_fg`.
1591    #[test]
1592    fn dialog_css_reflects_theme_surface_and_muted_colors() {
1593        let theme = crate::Theme {
1594            surface_bg: crate::types::Color::rgb(0x11, 0x22, 0x33),
1595            surface_fg: crate::types::Color::rgb(0xaa, 0xbb, 0xcc),
1596            muted_fg: crate::types::Color::rgb(0x44, 0x55, 0x66),
1597            ..crate::Theme::default()
1598        };
1599        let css = dialog_css(&theme);
1600        assert!(css.contains(".sidebar"));
1601        assert!(css.contains("scrollbar slider"));
1602        assert!(css.contains("popover contents"));
1603        assert!(
1604            css.contains("#112233"),
1605            "sidebar/popover bg should be surface_bg: {css}"
1606        );
1607        assert!(
1608            css.contains("#aabbcc"),
1609            "sidebar/popover fg should be surface_fg: {css}"
1610        );
1611        assert!(
1612            css.contains("#445566"),
1613            "scrollbar slider should be muted_fg: {css}"
1614        );
1615    }
1616
1617    /// Asserts the provider's CSS reflects a changed `Theme` background
1618    /// after `set_theme`. Runs with no `gtk4::init()` — `set_theme` always
1619    /// refreshes [`GtkPlatformServices::last_dialog_css`] regardless of
1620    /// whether a live GTK runtime exists, so this covers every consumer's
1621    /// headless test suite.
1622    #[test]
1623    fn set_theme_updates_the_dialog_css_provider_when_theme_background_changes() {
1624        let services = GtkPlatformServices::new();
1625
1626        let dark = crate::Theme {
1627            surface_bg: crate::types::Color::rgb(0x10, 0x10, 0x10),
1628            ..crate::Theme::default()
1629        };
1630        services.set_theme(&dark);
1631        let dark_css = services.dialog_css_for_test();
1632        assert!(dark_css.contains("#101010"));
1633
1634        let light = crate::Theme {
1635            surface_bg: crate::types::Color::rgb(0xf0, 0xf0, 0xf0),
1636            ..crate::Theme::default()
1637        };
1638        services.set_theme(&light);
1639        let light_css = services.dialog_css_for_test();
1640        assert!(light_css.contains("#f0f0f0"));
1641        assert!(
1642            !light_css.contains("#101010"),
1643            "stale CSS from the previous theme must not linger: {light_css}"
1644        );
1645    }
1646
1647    // ── Notification target encode/decode + icon decode ─────────────────
1648    //
1649    // Pure functions — no `gtk4::init()`/display needed (unlike
1650    // `build_file_dialog_behaviors` above), so these run as ordinary
1651    // `#[test]` fns with no `require_gtk()` guard. The full
1652    // `send_notification` → real `gio::Notification` → on-screen click
1653    // path needs a live desktop notification daemon and is covered by
1654    // `examples/gtk_platform_services.rs`'s manual smoke test instead.
1655
1656    #[test]
1657    fn notification_target_round_trips_tag_and_action() {
1658        let variant = notification_target(Some("build-status"), Some("open-problems"));
1659        assert_eq!(
1660            decode_notification_target(&variant),
1661            Some((
1662                Some("build-status".to_string()),
1663                Some(WidgetId::new("open-problems"))
1664            ))
1665        );
1666    }
1667
1668    #[test]
1669    fn notification_target_no_tag_or_action_decodes_to_none_none() {
1670        let variant = notification_target(None, None);
1671        assert_eq!(decode_notification_target(&variant), Some((None, None)));
1672    }
1673
1674    #[test]
1675    fn notification_target_tag_only() {
1676        let variant = notification_target(Some("t"), None);
1677        assert_eq!(
1678            decode_notification_target(&variant),
1679            Some((Some("t".to_string()), None))
1680        );
1681    }
1682
1683    #[test]
1684    fn decode_gio_icon_bytes_produces_a_bytes_icon() {
1685        let icon = decode_gio_icon(&ImageSource::Bytes(vec![1, 2, 3]));
1686        assert!(icon.is::<gio::BytesIcon>());
1687    }
1688
1689    #[test]
1690    fn decode_gio_icon_path_produces_a_file_icon() {
1691        let icon = decode_gio_icon(&ImageSource::Path(PathBuf::from(
1692            "/tmp/quadraui-955-icon.png",
1693        )));
1694        assert!(icon.is::<gio::FileIcon>());
1695    }
1696
1697    /// No window wired ⇒ `send_notification` returns before touching any
1698    /// GTK/GIO object that would need a live display — safe to run
1699    /// headlessly, unlike `build_file_dialog_behaviors`.
1700    #[test]
1701    fn send_notification_without_a_window_is_a_no_op() {
1702        let services = GtkPlatformServices::new();
1703        services.send_notification(Notification::new("t", "b"));
1704        // The early-return path taken above never touches `window` —
1705        // assert it's still exactly what `new()` left it as, so a
1706        // future refactor that accidentally starts constructing a
1707        // window on this path (rather than bailing out first) fails
1708        // this test instead of only being caught by a live-display run.
1709        assert!(
1710            services.window.borrow().is_none(),
1711            "no window was ever set, so send_notification must not have created one"
1712        );
1713    }
1714
1715    #[test]
1716    fn notification_action_ids_agree_on_the_app_prefix() {
1717        // `NOTIFICATION_ACTION_DETAILED` must be exactly
1718        // `NOTIFICATION_ACTION_ID` with `"app."` prepended — a drift
1719        // between the two would mean `send_notification`'s buttons
1720        // target an action name that doesn't match what
1721        // `ensure_notification_action` actually registers, and clicks
1722        // would silently do nothing.
1723        assert_eq!(
1724            NOTIFICATION_ACTION_DETAILED,
1725            format!("app.{NOTIFICATION_ACTION_ID}")
1726        );
1727    }
1728}