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: >k4::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(¬ification_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(¬ification_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), ¬ification);
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::<>k4::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(¶ms),
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}