use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use gtk4::gio;
use gtk4::gio::prelude::*;
use gtk4::glib;
use gtk4::prelude::{GtkWindowExt, WidgetExt};
use crate::backend::{
BackendError, Clipboard, Display, FileDialogOptions, MessageDialogButton, MessageDialogChoice,
MessageDialogOptions, Notification, RgbaImage, ServiceResult, SystemTheme,
};
use crate::desktop::{ModalPumpDepth, ModalPumpGuard};
use crate::event::{Point, Rect, UiEvent};
use crate::primitives::image::ImageSource;
use crate::types::WidgetId;
use crate::PlatformServices;
type EventQueueHandle = Rc<RefCell<VecDeque<UiEvent>>>;
pub struct GtkPlatformServices {
clipboard: GtkClipboard,
window: Rc<RefCell<Option<gtk4::ApplicationWindow>>>,
pumping: ModalPumpDepth,
events: Rc<RefCell<Option<EventQueueHandle>>>,
action_registered: Cell<bool>,
css_provider: RefCell<Option<gtk4::CssProvider>>,
css_provider_attached: Cell<bool>,
last_dialog_css: RefCell<String>,
}
impl GtkPlatformServices {
pub fn new() -> Self {
Self {
clipboard: GtkClipboard::new(),
window: Rc::new(RefCell::new(None)),
pumping: ModalPumpDepth::new(),
events: Rc::new(RefCell::new(None)),
action_registered: Cell::new(false),
css_provider: RefCell::new(None),
css_provider_attached: Cell::new(false),
last_dialog_css: RefCell::new(String::new()),
}
}
pub(crate) fn set_window(&self, window: gtk4::ApplicationWindow) {
*self.window.borrow_mut() = Some(window);
self.ensure_css_provider_attached();
}
pub(crate) fn set_events_handle(&self, events: EventQueueHandle) {
*self.events.borrow_mut() = Some(events);
}
fn ensure_notification_action(&self, app: >k4::Application) {
if self.action_registered.get() {
return;
}
if app.lookup_action(NOTIFICATION_ACTION_ID).is_some() {
self.action_registered.set(true);
return;
}
let Some(events) = self.events.borrow().clone() else {
return;
};
let param_type = <(String, String)>::static_variant_type();
let action = gio::SimpleAction::new(NOTIFICATION_ACTION_ID, Some(&*param_type));
action.connect_activate(move |_action, parameter| {
let Some(parameter) = parameter else {
return;
};
let Some((tag, action_id)) = decode_notification_target(parameter) else {
return;
};
events
.borrow_mut()
.push_back(UiEvent::NotificationActivated {
tag,
action: action_id,
});
});
app.add_action(&action);
self.action_registered.set(true);
}
pub(crate) fn pump_depth(&self) -> ModalPumpDepth {
self.pumping.clone()
}
#[cfg(test)]
pub(crate) fn gtk_clipboard(&self) -> &GtkClipboard {
&self.clipboard
}
pub(crate) fn set_theme(&self, theme: &crate::Theme) {
let css = dialog_css(theme);
*self.last_dialog_css.borrow_mut() = css.clone();
if !gtk4::is_initialized_main_thread() {
return;
}
{
let mut provider = self.css_provider.borrow_mut();
provider
.get_or_insert_with(gtk4::CssProvider::new)
.load_from_data(&css);
}
self.ensure_css_provider_attached();
}
fn ensure_css_provider_attached(&self) {
if self.css_provider_attached.get() {
return;
}
let Some(provider) = self.css_provider.borrow().clone() else {
return;
};
let display = match self.window.borrow().as_ref() {
Some(w) => Some(WidgetExt::display(w)),
None => gtk4::gdk::Display::default(),
};
if let Some(display) = display {
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
self.css_provider_attached.set(true);
}
}
#[cfg(test)]
pub(crate) fn dialog_css_for_test(&self) -> String {
self.last_dialog_css.borrow().clone()
}
}
impl Default for GtkPlatformServices {
fn default() -> Self {
Self::new()
}
}
impl PlatformServices for GtkPlatformServices {
fn clipboard(&self) -> &dyn Clipboard {
&self.clipboard
}
fn show_file_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let dialog = build_file_dialog(&opts, None);
let window = self.window.borrow().clone();
let result = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
dialog.open(window.as_ref(), gio::Cancellable::NONE, move |res| {
*result_cb.borrow_mut() = Some(res);
});
pump_until_ready(&result, &self.pumping)
.ok()
.and_then(|file| gtk4::prelude::FileExt::path(&file))
}
fn show_file_save_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let initial_name = opts.initial_filename.clone();
let dialog = build_file_dialog(&opts, initial_name.as_deref());
let window = self.window.borrow().clone();
let result = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
*result_cb.borrow_mut() = Some(res);
});
pump_until_ready(&result, &self.pumping)
.ok()
.and_then(|file| gtk4::prelude::FileExt::path(&file))
}
fn show_folder_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf> {
let dialog = build_file_dialog(&opts, None);
let window = self.window.borrow().clone();
let result = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
dialog.select_folder(window.as_ref(), gio::Cancellable::NONE, move |res| {
*result_cb.borrow_mut() = Some(res);
});
pump_until_ready(&result, &self.pumping)
.ok()
.and_then(|file| gtk4::prelude::FileExt::path(&file))
}
fn show_message_dialog(&self, opts: MessageDialogOptions) -> Option<MessageDialogChoice> {
let order = hig_button_order(&opts.buttons);
let labels: Vec<&str> = order
.iter()
.map(|&i| opts.buttons[i].label.as_str())
.collect();
let dialog = gtk4::AlertDialog::default();
dialog.set_message(&opts.title);
dialog.set_detail(&opts.body);
dialog.set_modal(true);
dialog.set_buttons(&labels);
if let Some(pos) = order.iter().position(|&i| opts.buttons[i].is_cancel) {
dialog.set_cancel_button(pos as i32);
}
if let Some(pos) = order.iter().position(|&i| opts.buttons[i].is_default) {
dialog.set_default_button(pos as i32);
}
let window = self.window.borrow().clone();
let result = Rc::new(RefCell::new(None));
let result_cb = Rc::clone(&result);
dialog.choose(window.as_ref(), gio::Cancellable::NONE, move |res| {
*result_cb.borrow_mut() = Some(res);
});
let idx = pump_until_ready(&result, &self.pumping).ok()?;
let pos = usize::try_from(idx).ok()?;
let orig = *order.get(pos)?;
Some(opts.buttons[orig].id)
}
fn send_notification(&self, n: Notification) {
let Some(window) = self.window.borrow().clone() else {
return;
};
let Some(app) = window.application() else {
return;
};
self.ensure_notification_action(&app);
let notification = gio::Notification::new(&n.title);
notification.set_body(Some(&n.body));
notification.set_priority(if n.urgent {
gio::NotificationPriority::Urgent
} else {
gio::NotificationPriority::Normal
});
if let Some(icon_source) = n.icon() {
notification.set_icon(&decode_gio_icon(icon_source));
}
let tag = n.tag();
notification.set_default_action_and_target_value(
NOTIFICATION_ACTION_DETAILED,
Some(¬ification_target(tag, None)),
);
for (id, label) in n.actions() {
notification.add_button_with_target_value(
label,
NOTIFICATION_ACTION_DETAILED,
Some(¬ification_target(tag, Some(id.as_str()))),
);
}
let owned_tag;
let notif_id: &str = match tag {
Some(tag) => tag,
None => {
owned_tag = format!(
"quadraui-notification-{}",
NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed)
);
&owned_tag
}
};
app.send_notification(Some(notif_id), ¬ification);
}
fn open_url(&self, url: &str) {
let _ = self.open_url_result(url);
}
fn open_url_result(&self, url: &str) -> ServiceResult<()> {
gtk4::gio::AppInfo::launch_default_for_uri(url, None::<>k4::gio::AppLaunchContext>)
.map_err(|e| BackendError::PlatformFailure {
context: format!("gio::AppInfo::launch_default_for_uri: {e}"),
})
}
fn reveal_in_file_manager(&self, path: &Path) -> ServiceResult<()> {
let uri = gio::File::for_path(path).uri();
let connection = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE)
.map_err(|_| BackendError::Unsupported)?;
let params = (vec![uri.to_string()], String::new()).to_variant();
connection
.call_sync(
Some("org.freedesktop.FileManager1"),
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1",
"ShowItems",
Some(¶ms),
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
)
.map(|_| ())
.map_err(|e| BackendError::PlatformFailure {
context: format!("org.freedesktop.FileManager1.ShowItems: {e}"),
})
}
fn open_path(&self, path: &Path) -> ServiceResult<()> {
let uri = gio::File::for_path(path).uri();
gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>).map_err(|e| {
BackendError::PlatformFailure {
context: format!("gio::AppInfo::launch_default_for_uri: {e}"),
}
})
}
fn move_to_trash(&self, path: &Path) -> ServiceResult<()> {
crate::desktop::move_to_trash(path)
}
fn beep(&self) -> ServiceResult<()> {
use gtk4::gdk::prelude::DisplayExt;
let display = match self.window.borrow().as_ref() {
Some(window) => WidgetExt::display(window),
None => gtk4::gdk::Display::default().ok_or(BackendError::Unsupported)?,
};
display.beep();
Ok(())
}
fn system_theme(&self) -> ServiceResult<SystemTheme> {
let settings = gtk4::Settings::default().ok_or(BackendError::Unsupported)?;
let dark = settings.is_gtk_application_prefer_dark_theme();
let theme_name = settings.gtk_theme_name();
Ok(system_theme_from_gtk_settings(dark, theme_name.as_deref()))
}
fn displays(&self) -> ServiceResult<Vec<Display>> {
use gtk4::gdk::prelude::{DisplayExt, MonitorExt};
use gtk4::glib::prelude::Cast;
let display = match self.window.borrow().as_ref() {
Some(window) => WidgetExt::display(window),
None => gtk4::gdk::Display::default().ok_or(BackendError::Unsupported)?,
};
let monitors = display.monitors();
let count = monitors.n_items();
let mut out = Vec::with_capacity(count as usize);
for i in 0..count {
let Some(monitor) = monitors
.item(i)
.and_then(|o| o.downcast::<gtk4::gdk::Monitor>().ok())
else {
continue;
};
let geom = monitor.geometry();
let bounds = Rect::new(
geom.x() as f32,
geom.y() as f32,
geom.width() as f32,
geom.height() as f32,
);
out.push(Display {
bounds,
work_area: bounds,
scale: monitor.scale_factor() as f32,
primary: i == 0,
});
}
Ok(out)
}
fn cursor_screen_point(&self) -> ServiceResult<Point> {
Err(BackendError::Unsupported)
}
fn platform_name(&self) -> &'static str {
"gtk"
}
}
static NEXT_NOTIFICATION_ID: AtomicU64 = AtomicU64::new(1);
const NOTIFICATION_ACTION_ID: &str = "quadraui-notification-activated";
const NOTIFICATION_ACTION_DETAILED: &str = "app.quadraui-notification-activated";
fn notification_target(tag: Option<&str>, action: Option<&str>) -> glib::Variant {
(tag.unwrap_or_default(), action.unwrap_or_default()).to_variant()
}
fn decode_notification_target(
variant: &glib::Variant,
) -> Option<(Option<String>, Option<WidgetId>)> {
let (tag, action) = variant.get::<(String, String)>()?;
let tag = if tag.is_empty() { None } else { Some(tag) };
let action = if action.is_empty() {
None
} else {
Some(WidgetId::new(action))
};
Some((tag, action))
}
fn decode_gio_icon(source: &ImageSource) -> gio::Icon {
match source {
ImageSource::Bytes(bytes) => {
gio::BytesIcon::new(&glib::Bytes::from_owned(bytes.clone())).upcast()
}
ImageSource::Path(path) => gio::FileIcon::new(&gio::File::for_path(path)).upcast(),
}
}
fn system_theme_from_gtk_settings(dark: bool, theme_name: Option<&str>) -> SystemTheme {
let high_contrast = theme_name
.map(|name| name.to_ascii_lowercase().contains("highcontrast"))
.unwrap_or(false);
SystemTheme {
dark,
accent: None,
high_contrast,
}
}
fn dialog_css(theme: &crate::Theme) -> String {
let surface_bg = css_hex(theme.surface_bg);
let surface_fg = css_hex(theme.surface_fg);
let slider = css_hex(theme.muted_fg);
format!(
"\
.sidebar {{
background-color: {surface_bg};
color: {surface_fg};
}}
scrollbar slider {{
background-color: {slider};
}}
popover contents {{
background-color: {surface_bg};
color: {surface_fg};
}}
"
)
}
fn css_hex(color: crate::types::Color) -> String {
format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
}
fn build_file_dialog(opts: &FileDialogOptions, initial_name: Option<&str>) -> gtk4::FileDialog {
let dialog = gtk4::FileDialog::new();
if let Some(ref title) = opts.title {
dialog.set_title(title);
}
if let Some(ref dir) = opts.initial_dir {
dialog.set_initial_folder(Some(&gio::File::for_path(dir)));
}
if let Some(name) = initial_name {
dialog.set_initial_name(Some(name));
}
if !opts.filters.is_empty() {
let filters = gio::ListStore::new::<gtk4::FileFilter>();
for (name, extensions) in &opts.filters {
let filter = gtk4::FileFilter::new();
filter.set_name(Some(name));
for ext in extensions {
filter.add_suffix(ext);
}
filters.append(&filter);
}
dialog.set_filters(Some(&filters));
}
dialog
}
fn pump_until_ready<T>(result: &Rc<RefCell<Option<T>>>, pumping: &ModalPumpDepth) -> T {
let _guard = ModalPumpGuard::new(pumping);
let ctx = glib::MainContext::default();
while result.borrow().is_none() {
ctx.iteration(true);
}
result
.borrow_mut()
.take()
.expect("loop above only exits once `result` is Some")
}
fn hig_button_order(buttons: &[MessageDialogButton]) -> Vec<usize> {
let mut cancel_idx = None;
let mut default_idx = None;
let mut middle = Vec::new();
for (i, b) in buttons.iter().enumerate() {
if b.is_cancel && cancel_idx.is_none() {
cancel_idx = Some(i);
} else if b.is_default && default_idx.is_none() {
default_idx = Some(i);
} else {
middle.push(i);
}
}
let mut order = Vec::with_capacity(buttons.len());
order.extend(cancel_idx);
order.extend(middle);
if let Some(d) = default_idx {
order.push(d);
}
order
}
#[cfg(test)]
#[derive(Clone, Debug, Default)]
pub(crate) struct TestClipboardContents {
pub clipboard: Option<String>,
pub primary: Option<String>,
}
pub struct GtkClipboard {
inner: RefCell<Option<arboard::Clipboard>>,
#[cfg(test)]
test_contents: RefCell<Option<TestClipboardContents>>,
}
impl GtkClipboard {
fn new() -> Self {
Self {
inner: RefCell::new(arboard::Clipboard::new().ok()),
#[cfg(test)]
test_contents: RefCell::new(None),
}
}
#[cfg(test)]
pub(crate) fn install_test_contents(&self, contents: TestClipboardContents) {
*self.test_contents.borrow_mut() = Some(contents);
}
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))
))]
fn read_os_primary_selection(&self) -> Option<String> {
use arboard::{GetExtLinux, LinuxClipboardKind};
self.inner
.borrow_mut()
.as_mut()?
.get()
.clipboard(LinuxClipboardKind::Primary)
.text()
.ok()
}
#[cfg(not(all(
unix,
not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))
)))]
fn read_os_primary_selection(&self) -> Option<String> {
None
}
}
impl Clipboard for GtkClipboard {
fn read_text(&self) -> Option<String> {
#[cfg(test)]
{
if let Some(fake) = self.test_contents.borrow().as_ref() {
return fake.clipboard.clone();
}
}
self.inner.borrow_mut().as_mut()?.get_text().ok()
}
fn write_text(&self, text: &str) {
#[cfg(test)]
{
if let Some(fake) = self.test_contents.borrow_mut().as_mut() {
fake.clipboard = Some(text.to_string());
return;
}
}
if let Some(cb) = self.inner.borrow_mut().as_mut() {
let _ = cb.set_text(text);
}
}
fn read_primary_selection(&self) -> Option<String> {
#[cfg(test)]
{
if let Some(fake) = self.test_contents.borrow().as_ref() {
return fake.primary.clone();
}
}
self.read_os_primary_selection()
}
fn read_image(&self) -> ServiceResult<RgbaImage> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
})?;
let img = cb
.get_image()
.map_err(|e| map_arboard_error("arboard::get_image", e))?;
Ok(RgbaImage {
width: img.width as u32,
height: img.height as u32,
pixels: img.bytes.into_owned(),
})
}
fn write_image(&self, image: &RgbaImage) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
})?;
let data = arboard::ImageData {
width: image.width as usize,
height: image.height as usize,
bytes: std::borrow::Cow::Borrowed(&image.pixels),
};
cb.set_image(data)
.map_err(|e| map_arboard_error("arboard::set_image", e))
}
fn write_html(&self, html: &str, alt_text: &str) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
})?;
cb.set_html(html, Some(alt_text))
.map_err(|e| map_arboard_error("arboard::set_html", e))
}
fn read_file_list(&self) -> ServiceResult<Vec<PathBuf>> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
})?;
cb.get()
.file_list()
.map_err(|e| map_arboard_error("arboard::get().file_list()", e))
}
fn clear(&self) -> ServiceResult<()> {
let mut inner = self.inner.borrow_mut();
let cb = inner.as_mut().ok_or(BackendError::PlatformFailure {
context: NO_ARBOARD_HANDLE_CONTEXT.to_string(),
})?;
cb.clear()
.map_err(|e| map_arboard_error("arboard::clear", e))
}
}
const NO_ARBOARD_HANDLE_CONTEXT: &str = "arboard::Clipboard::new (no clipboard available)";
use crate::desktop::map_arboard_error;
#[cfg(test)]
mod tests {
use super::*;
use gtk4::prelude::ListModelExt;
use std::sync::OnceLock;
#[allow(clippy::print_stderr)]
#[test]
fn clipboard_image_html_file_list_and_clear_round_trip() {
let svc = GtkPlatformServices::new();
let cb = svc.clipboard();
let image = RgbaImage {
width: 2,
height: 2,
pixels: vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ],
};
if let Err(e) = cb.write_image(&image) {
eprintln!("skipping: no live OS clipboard in this environment ({e:?})");
return;
}
let read_back = cb
.read_image()
.expect("read_image should see what write_image just wrote");
assert_eq!(read_back.width, image.width);
assert_eq!(read_back.height, image.height);
assert_eq!(
read_back.pixels.len(),
(image.width * image.height * 4) as usize
);
cb.write_html("<b>hi</b>", "hi").expect(
"write_html should succeed once write_image already proved the clipboard is live",
);
let tmp = std::env::temp_dir().join("quadraui-954-gtk-clipboard-test-file.txt");
std::fs::write(&tmp, b"quadraui#954").expect("write temp file");
arboard::Clipboard::new()
.expect("a second arboard handle should also see the live clipboard")
.set()
.file_list(&[&tmp])
.expect("seeding the clipboard with a file list should succeed");
let files = cb
.read_file_list()
.expect("read_file_list should see the seeded file");
assert_eq!(
files
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect::<Vec<_>>(),
vec![tmp.canonicalize().expect("temp file should exist")]
);
let _ = std::fs::remove_file(&tmp);
cb.write_text("some text #954");
assert_eq!(cb.read_text(), Some("some text #954".to_string()));
cb.clear()
.expect("clear should succeed once the clipboard has proven live");
assert_eq!(cb.read_text(), None);
}
fn require_gtk() -> bool {
static INIT: OnceLock<bool> = OnceLock::new();
*INIT.get_or_init(|| {
#[cfg(target_os = "macos")]
if !is_main_thread() {
return false;
}
gtk4::init().is_ok()
})
}
#[cfg(target_os = "macos")]
fn is_main_thread() -> bool {
unsafe extern "C" {
fn pthread_main_np() -> i32;
}
unsafe { pthread_main_np() != 0 }
}
#[cfg(target_os = "macos")]
#[test]
fn require_gtk_does_not_panic_off_main_thread_on_macos() {
assert!(
!require_gtk(),
"cargo test never runs on the OS main thread, so require_gtk() \
must report false here instead of calling gtk4::init() and \
hitting gtk4-rs's own main-thread panic"
);
}
#[allow(clippy::print_stderr)]
#[test]
fn build_file_dialog_behaviors() {
if !require_gtk() {
eprintln!("skipping: GTK failed to initialize (no display available)");
return;
}
{
let opts = FileDialogOptions {
title: Some("Open File".to_string()),
initial_dir: Some(PathBuf::from("/tmp")),
initial_filename: None,
filters: vec![("Rust files".to_string(), vec!["rs".to_string()])],
};
let dialog = build_file_dialog(&opts, None);
assert_eq!(dialog.title(), "Open File");
assert_eq!(
gtk4::prelude::FileExt::path(&dialog.initial_folder().unwrap()),
Some(PathBuf::from("/tmp"))
);
let filters = dialog.filters().expect("filters should be set");
assert_eq!(filters.n_items(), 1);
}
{
let opts = FileDialogOptions {
initial_filename: Some("untitled.txt".to_string()),
..Default::default()
};
let without = build_file_dialog(&opts, None);
assert_eq!(without.initial_name(), None);
let with = build_file_dialog(&opts, opts.initial_filename.as_deref());
assert_eq!(with.initial_name().as_deref(), Some("untitled.txt"));
}
{
let dialog = build_file_dialog(&FileDialogOptions::default(), None);
assert!(dialog.filters().is_none());
}
{
let services = GtkPlatformServices::new();
assert_eq!(services.beep(), Ok(()));
}
{
let services = GtkPlatformServices::new();
let displays = services.displays().expect("a live display has monitors");
assert!(!displays.is_empty());
assert!(displays[0].primary);
for d in &displays {
assert_eq!(d.work_area, d.bounds);
}
}
}
#[test]
fn set_window_is_none_until_called() {
let services = GtkPlatformServices::new();
assert!(services.window.borrow().is_none());
}
#[test]
fn cursor_screen_point_always_reports_unsupported_on_gtk() {
let services = GtkPlatformServices::new();
assert_eq!(
services.cursor_screen_point(),
Err(BackendError::Unsupported)
);
}
#[test]
fn open_url_result_reports_failure_for_an_unregistered_scheme() {
let services = GtkPlatformServices::new();
assert!(services
.open_url_result("quadraui-1087-nonexistent-scheme://x")
.is_err());
}
#[test]
fn pump_depth_handle_observes_guard_mutations_on_the_services_copy() {
let services = GtkPlatformServices::new();
let handle = services.pump_depth();
assert_eq!(handle.get(), 0);
let _guard = ModalPumpGuard::new(&services.pumping);
assert_eq!(
handle.get(),
1,
"handle must observe the mutation made through services.pumping"
);
}
fn msg_btn(id: &str, is_default: bool, is_cancel: bool) -> MessageDialogButton {
MessageDialogButton {
id: crate::types::WidgetId::new(id),
label: id.to_string(),
is_default,
is_cancel,
}
}
#[test]
fn hig_button_order_puts_cancel_first_and_default_last() {
let buttons = vec![
msg_btn("save", true, false),
msg_btn("dont-save", false, false),
msg_btn("cancel", false, true),
];
assert_eq!(hig_button_order(&buttons), vec![2, 1, 0]);
}
#[test]
fn hig_button_order_preserves_middle_button_relative_order() {
let buttons = vec![
msg_btn("cancel", false, true),
msg_btn("a", false, false),
msg_btn("b", false, false),
msg_btn("save", true, false),
];
assert_eq!(hig_button_order(&buttons), vec![0, 1, 2, 3]);
}
#[test]
fn hig_button_order_single_button_both_default_and_cancel() {
let buttons = vec![msg_btn("ok", true, true)];
assert_eq!(hig_button_order(&buttons), vec![0]);
}
#[test]
fn hig_button_order_no_default_or_cancel_keeps_declared_order() {
let buttons = vec![msg_btn("a", false, false), msg_btn("b", false, false)];
assert_eq!(hig_button_order(&buttons), vec![0, 1]);
}
#[test]
fn system_theme_from_gtk_settings_reports_dark() {
let theme = system_theme_from_gtk_settings(true, Some("Adwaita-dark"));
assert!(theme.dark);
assert_eq!(theme.accent, None);
assert!(!theme.high_contrast);
}
#[test]
fn system_theme_from_gtk_settings_reports_light() {
let theme = system_theme_from_gtk_settings(false, Some("Adwaita"));
assert!(!theme.dark);
assert!(!theme.high_contrast);
}
#[test]
fn system_theme_from_gtk_settings_detects_high_contrast_case_insensitively() {
assert!(system_theme_from_gtk_settings(false, Some("HighContrast")).high_contrast);
assert!(system_theme_from_gtk_settings(false, Some("HighContrastInverse")).high_contrast);
assert!(system_theme_from_gtk_settings(true, Some("highcontrast")).high_contrast);
}
#[test]
fn system_theme_from_gtk_settings_no_theme_name_is_not_high_contrast() {
assert!(!system_theme_from_gtk_settings(true, None).high_contrast);
}
#[test]
fn dialog_css_reflects_theme_surface_and_muted_colors() {
let theme = crate::Theme {
surface_bg: crate::types::Color::rgb(0x11, 0x22, 0x33),
surface_fg: crate::types::Color::rgb(0xaa, 0xbb, 0xcc),
muted_fg: crate::types::Color::rgb(0x44, 0x55, 0x66),
..crate::Theme::default()
};
let css = dialog_css(&theme);
assert!(css.contains(".sidebar"));
assert!(css.contains("scrollbar slider"));
assert!(css.contains("popover contents"));
assert!(
css.contains("#112233"),
"sidebar/popover bg should be surface_bg: {css}"
);
assert!(
css.contains("#aabbcc"),
"sidebar/popover fg should be surface_fg: {css}"
);
assert!(
css.contains("#445566"),
"scrollbar slider should be muted_fg: {css}"
);
}
#[test]
fn set_theme_updates_the_dialog_css_provider_when_theme_background_changes() {
let services = GtkPlatformServices::new();
let dark = crate::Theme {
surface_bg: crate::types::Color::rgb(0x10, 0x10, 0x10),
..crate::Theme::default()
};
services.set_theme(&dark);
let dark_css = services.dialog_css_for_test();
assert!(dark_css.contains("#101010"));
let light = crate::Theme {
surface_bg: crate::types::Color::rgb(0xf0, 0xf0, 0xf0),
..crate::Theme::default()
};
services.set_theme(&light);
let light_css = services.dialog_css_for_test();
assert!(light_css.contains("#f0f0f0"));
assert!(
!light_css.contains("#101010"),
"stale CSS from the previous theme must not linger: {light_css}"
);
}
#[test]
fn notification_target_round_trips_tag_and_action() {
let variant = notification_target(Some("build-status"), Some("open-problems"));
assert_eq!(
decode_notification_target(&variant),
Some((
Some("build-status".to_string()),
Some(WidgetId::new("open-problems"))
))
);
}
#[test]
fn notification_target_no_tag_or_action_decodes_to_none_none() {
let variant = notification_target(None, None);
assert_eq!(decode_notification_target(&variant), Some((None, None)));
}
#[test]
fn notification_target_tag_only() {
let variant = notification_target(Some("t"), None);
assert_eq!(
decode_notification_target(&variant),
Some((Some("t".to_string()), None))
);
}
#[test]
fn decode_gio_icon_bytes_produces_a_bytes_icon() {
let icon = decode_gio_icon(&ImageSource::Bytes(vec![1, 2, 3]));
assert!(icon.is::<gio::BytesIcon>());
}
#[test]
fn decode_gio_icon_path_produces_a_file_icon() {
let icon = decode_gio_icon(&ImageSource::Path(PathBuf::from(
"/tmp/quadraui-955-icon.png",
)));
assert!(icon.is::<gio::FileIcon>());
}
#[test]
fn send_notification_without_a_window_is_a_no_op() {
let services = GtkPlatformServices::new();
services.send_notification(Notification::new("t", "b"));
assert!(
services.window.borrow().is_none(),
"no window was ever set, so send_notification must not have created one"
);
}
#[test]
fn notification_action_ids_agree_on_the_app_prefix() {
assert_eq!(
NOTIFICATION_ACTION_DETAILED,
format!("app.{NOTIFICATION_ACTION_ID}")
);
}
}