use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use gtk4::cairo::Context;
use gtk4::glib;
use gtk4::glib::prelude::StaticType;
use gtk4::prelude::*;
use gtk4::{
pango as pg, Application, ApplicationWindow, DrawingArea, EventControllerKey,
EventControllerMotion, EventControllerScroll, EventControllerScrollFlags, GestureClick,
};
use pangocairo::functions as pcfn;
use super::backend::GtkBackend;
use super::events::{
gdk_button_to_quadraui, gdk_key_to_uievent, gdk_modifiers_to_quadraui, gdk_resize_to_uievent,
gdk_scroll_to_uievent_with_direction, gtk_drop_to_uievent, gtk_open_to_uievent,
};
use crate::backend::Backend;
use crate::desktop::{smoke_clipboard_round_trip_ok, smoke_size_ok, SmokeConfig};
use crate::dispatch::route_pointer;
use crate::runner::{AppLogic, Reaction};
use crate::runtime::{self, ReactionSink, RESIZE_SETTLE};
pub(crate) use crate::runtime::EventOutcome;
use crate::{ButtonMask, Key, Modifiers, MouseButton, Point, UiEvent};
const DEFAULT_WINDOW_WIDTH: i32 = 800;
const DEFAULT_WINDOW_HEIGHT: i32 = 600;
const SMOKE_MIN_WIDTH: i32 = 200;
const SMOKE_MIN_HEIGHT: i32 = 150;
const SMOKE_MS_VAR: &str = "QUADRAUI_GTK_SMOKE_MS";
const SMOKE_PASTE_VAR: &str = "QUADRAUI_GTK_SMOKE_PASTE";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunConfig {
pub app_id: String,
pub title: String,
pub icon_name: Option<String>,
pub single_instance: bool,
}
impl RunConfig {
pub fn new(app_id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
app_id: app_id.into(),
title: title.into(),
icon_name: None,
single_instance: true,
}
}
pub fn with_single_instance(mut self, single_instance: bool) -> Self {
self.single_instance = single_instance;
self
}
pub fn with_icon_name(mut self, icon_name: impl Into<String>) -> Self {
self.icon_name = Some(icon_name.into());
self
}
}
pub fn app_id_is_valid(app_id: &str) -> bool {
gtk4::gio::Application::id_is_valid(app_id)
}
impl Default for RunConfig {
fn default() -> Self {
Self {
app_id: "org.quadraui.app".to_string(),
title: "quadraui app".to_string(),
icon_name: None,
single_instance: true,
}
}
}
pub fn run<A: AppLogic + 'static>(app: A) -> std::process::ExitCode {
run_with(app, RunConfig::default())
}
pub fn run_with<A: AppLogic + 'static>(app: A, config: RunConfig) -> std::process::ExitCode {
let app = Rc::new(RefCell::new(app));
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let smoke = SmokeConfig::from_env(SMOKE_MS_VAR, SMOKE_PASTE_VAR);
let smoke_failed = Rc::new(Cell::new(false));
let title = config.title;
let icon_name = config.icon_name;
let events_handle = backend.borrow().events_handle();
let mut flags = gtk4::gio::ApplicationFlags::HANDLES_OPEN;
if !config.single_instance {
flags |= gtk4::gio::ApplicationFlags::NON_UNIQUE;
}
let gapp = Application::builder()
.application_id(config.app_id)
.flags(flags)
.build();
{
let app = app.clone();
let backend = backend.clone();
let smoke = smoke.clone();
let smoke_failed = smoke_failed.clone();
let title = title.clone();
let icon_name = icon_name.clone();
gapp.connect_activate(move |gapp| {
activate(
gapp,
app.clone(),
backend.clone(),
smoke.clone(),
smoke_failed.clone(),
title.clone(),
icon_name.clone(),
);
});
}
{
let app = app.clone();
let backend = backend.clone();
let smoke = smoke.clone();
let smoke_failed = smoke_failed.clone();
let title = title.clone();
let icon_name = icon_name.clone();
let events_handle = events_handle.clone();
gapp.connect_open(move |gapp, files, _hint| {
if gapp.windows().is_empty() {
activate(
gapp,
app.clone(),
backend.clone(),
smoke.clone(),
smoke_failed.clone(),
title.clone(),
icon_name.clone(),
);
}
events_handle
.borrow_mut()
.push_back(gtk_open_to_uievent(files));
if let Some(window) = gapp.windows().first() {
window.present();
activate_macos_process();
}
});
}
let glib_code = gapp.run();
if smoke_failed.get() {
return std::process::ExitCode::FAILURE;
}
std::process::ExitCode::from(glib_code)
}
pub use crate::runner::StepOutcome;
pub struct GtkRunner {
gapp: Application,
context: glib::MainContext,
smoke_failed: Rc<Cell<bool>>,
}
pub fn display_available() -> bool {
#[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 }
}
impl GtkRunner {
pub fn new<A: AppLogic + 'static>(app: A) -> Self {
Self::new_with(app, RunConfig::default())
}
pub fn new_with<A: AppLogic + 'static>(app: A, config: RunConfig) -> Self {
let app = Rc::new(RefCell::new(app));
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let smoke = SmokeConfig::from_env(SMOKE_MS_VAR, SMOKE_PASTE_VAR);
let smoke_failed = Rc::new(Cell::new(false));
let mut flags = gtk4::gio::ApplicationFlags::HANDLES_OPEN;
if !config.single_instance {
flags |= gtk4::gio::ApplicationFlags::NON_UNIQUE;
}
let gapp = Application::builder()
.application_id(config.app_id)
.flags(flags)
.build();
let _ = gapp.register(gtk4::gio::Cancellable::NONE);
let smoke_failed_handle = smoke_failed.clone();
activate(
&gapp,
app,
backend,
smoke,
smoke_failed,
config.title,
config.icon_name,
);
Self {
gapp,
context: glib::MainContext::default(),
smoke_failed: smoke_failed_handle,
}
}
fn window_open(&self) -> bool {
!self.gapp.windows().is_empty()
}
fn outcome(&self) -> StepOutcome {
if self.window_open() {
StepOutcome::Continue
} else {
StepOutcome::Exited
}
}
pub fn smoke_failed(&self) -> bool {
self.smoke_failed.get()
}
pub fn step(&mut self) -> StepOutcome {
if self.window_open() {
self.context.iteration(false);
}
self.outcome()
}
pub fn pump(&mut self, timeout: Duration) -> StepOutcome {
if !self.window_open() {
return StepOutcome::Exited;
}
let timed_out = Rc::new(Cell::new(false));
let timed_out_cb = timed_out.clone();
let timer_id = glib::source::timeout_add_local_once(timeout, move || {
timed_out_cb.set(true);
});
self.context.iteration(true);
if !timed_out.get() {
timer_id.remove();
}
self.outcome()
}
pub fn request_exit(&self) {
for window in self.gapp.windows() {
window.destroy();
}
}
}
#[cfg(target_os = "macos")]
fn activate_macos_process() {
let Some(mtm) = objc2::MainThreadMarker::new() else {
return;
};
let ns_app = objc2_app_kit::NSApplication::sharedApplication(mtm);
let _ = ns_app.setActivationPolicy(objc2_app_kit::NSApplicationActivationPolicy::Regular);
#[allow(deprecated)]
ns_app.activateIgnoringOtherApps(true);
}
#[cfg(not(target_os = "macos"))]
fn activate_macos_process() {}
fn activate<A: AppLogic + 'static>(
gapp: &Application,
app: Rc<RefCell<A>>,
backend: Rc<RefCell<GtkBackend>>,
smoke: Option<SmokeConfig>,
smoke_failed: Rc<Cell<bool>>,
title: String,
icon_name: Option<String>,
) {
let mut window_builder = ApplicationWindow::builder()
.application(gapp)
.title(title)
.default_width(DEFAULT_WINDOW_WIDTH)
.default_height(DEFAULT_WINDOW_HEIGHT);
if let Some(icon_name) = icon_name {
window_builder = window_builder.icon_name(icon_name);
}
let window = window_builder.build();
backend.borrow_mut().set_window(window.clone());
let pump_depth = backend.borrow().pump_depth();
let events_handle = backend.borrow().events_handle();
let da = DrawingArea::new();
da.set_hexpand(true);
da.set_vexpand(true);
da.set_focusable(true);
da.set_can_focus(true);
window.set_child(Some(&da));
{
let pctx = da.pango_context();
let font_desc = pg::FontDescription::from_string("Sans 11");
pctx.set_font_description(&font_desc);
backend.borrow_mut().set_pango_context(pctx);
}
{
let mut backend_mut = backend.borrow_mut();
backend_mut.begin_frame(crate::Viewport::new(
DEFAULT_WINDOW_WIDTH as f32,
DEFAULT_WINDOW_HEIGHT as f32,
1.0,
));
let mut app_mut = app.borrow_mut();
app_mut.setup(&mut *backend_mut);
}
setup_draw_func(&da, &app, &backend, &pump_depth);
setup_key_controller(&window, &da, &app, &backend, &pump_depth);
let cursor_pos = Rc::new(Cell::new((0.0_f64, 0.0_f64)));
setup_click_controller(&da, &window, &app, &backend, &pump_depth);
setup_motion_controller(&da, &window, &app, &backend, &pump_depth, &cursor_pos);
setup_scroll_controller(&da, &window, &app, &backend, &pump_depth, &cursor_pos);
setup_resize_controller(&da, &window, &app, &backend, &pump_depth);
setup_hidpi_controller(&da, &window, &app, &backend, &pump_depth);
setup_file_drop_controller(&da, &window, &app, &backend, &pump_depth);
setup_close_request(&window, &da, &app, &backend, &pump_depth, &events_handle);
setup_event_drain(&da, &window, &app, &backend, &pump_depth, &events_handle);
setup_caret_blink(&da, &backend);
window.present();
activate_macos_process();
da.grab_focus();
if let Some(cfg) = smoke {
schedule_smoke_check(cfg, da, backend, app, window, smoke_failed);
}
}
fn setup_draw_func<A: AppLogic + 'static>(
da: &DrawingArea,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let app = app.clone();
let backend = backend.clone();
let pump_depth = pump_depth.clone();
da.set_draw_func(move |_da, cr, w, h| {
if pump_depth.is_pumping() {
return;
}
let mut backend_mut = backend.borrow_mut();
let app_ref = app.borrow();
render_frame(&mut backend_mut, &*app_ref, cr, w, h);
});
}
fn setup_key_controller<A: AppLogic + 'static>(
window: &ApplicationWindow,
da: &DrawingArea,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let key_ctrl = EventControllerKey::new();
{
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let pump_depth = pump_depth.clone();
key_ctrl.connect_key_pressed(move |_ctrl, key, _code, modifier| {
if pump_depth.is_pumping() {
return glib::Propagation::Proceed;
}
let Some(ev) = gdk_key_to_uievent(key, modifier, false) else {
return glib::Propagation::Proceed;
};
let Some(outcome) = try_dispatch(&backend, &app, ev) else {
return glib::Propagation::Proceed;
};
apply_event_outcome(
outcome,
&backend.borrow(),
&da_for_redraw,
&window_for_close,
);
glib::Propagation::Stop
});
}
window.add_controller(key_ctrl);
}
fn setup_click_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let click = GestureClick::builder().button(0).build();
setup_click_pressed(&click, da, window, app, backend, pump_depth);
setup_click_released(&click, da, window, app, backend, pump_depth);
da.add_controller(click);
}
fn setup_click_pressed<A: AppLogic + 'static>(
click: &GestureClick,
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let pump_depth = pump_depth.clone();
click.connect_pressed(move |gesture, _n_press, x, y| {
if pump_depth.is_pumping() {
return;
}
let gdk_button = gesture.current_button();
let modifier = gesture.current_event_state();
let button = gdk_button_to_quadraui(gdk_button);
let modifiers = gdk_modifiers_to_quadraui(modifier);
let position = Point::new(x as f32, y as f32);
if let Some(event) = gesture.current_event() {
if let Some(device) = event.device() {
if let Ok(mut backend_mut) = backend.try_borrow_mut() {
backend_mut.stash_window_press(device, gdk_button as i32, x, y, event.time());
}
}
}
let Ok(mut backend_mut) = backend.try_borrow_mut() else {
return;
};
let events = {
let stack_rc = backend_mut.modal_stack_handle();
let drag_rc = backend_mut.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend_mut.text_selection_state_mut(),
UiEvent::MouseDown {
widget: None,
button,
position,
modifiers,
},
)
};
let mut needs_redraw = false;
for ev in events {
let Some(outcome) = try_dispatch_borrowed(&mut backend_mut, &app, ev) else {
break;
};
match outcome {
EventOutcome::Continue => {}
EventOutcome::Redraw => needs_redraw = true,
EventOutcome::RedrawAfter(delay) => backend_mut.request_frame_in(delay),
EventOutcome::Exit => {
window_for_close.destroy();
return;
}
}
}
if needs_redraw {
da_for_redraw.queue_draw();
}
});
}
fn setup_click_released<A: AppLogic + 'static>(
click: &GestureClick,
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let pump_depth = pump_depth.clone();
click.connect_released(move |gesture, _n_press, x, y| {
if pump_depth.is_pumping() {
return;
}
let Ok(mut backend_mut) = backend.try_borrow_mut() else {
return;
};
backend_mut.discard_armed_window_drag();
let position = Point::new(x as f32, y as f32);
let button: MouseButton = gdk_button_to_quadraui(gesture.current_button());
let events = {
let stack_rc = backend_mut.modal_stack_handle();
let drag_rc = backend_mut.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend_mut.text_selection_state_mut(),
UiEvent::MouseUp {
widget: None,
button,
position,
},
)
};
for ev in events {
let Some(outcome) = try_dispatch_borrowed(&mut backend_mut, &app, ev) else {
break;
};
apply_event_outcome(outcome, &backend_mut, &da_for_redraw, &window_for_close);
}
});
}
fn setup_motion_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
cursor_pos: &Rc<Cell<(f64, f64)>>,
) {
let motion = EventControllerMotion::new();
{
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let cursor_pos = cursor_pos.clone();
let pump_depth = pump_depth.clone();
motion.connect_motion(move |ctrl, x, y| {
cursor_pos.set((x, y));
if pump_depth.is_pumping() {
return;
}
let Ok(mut probe_backend_mut) = backend.try_borrow_mut() else {
return;
};
if probe_backend_mut.try_commit_window_drag(x, y) {
return;
}
drop(probe_backend_mut);
let modifier = ctrl.current_event_state();
let buttons = ButtonMask {
left: modifier.contains(gtk4::gdk::ModifierType::BUTTON1_MASK),
middle: modifier.contains(gtk4::gdk::ModifierType::BUTTON2_MASK),
right: modifier.contains(gtk4::gdk::ModifierType::BUTTON3_MASK),
};
let position = Point::new(x as f32, y as f32);
let Ok(mut backend_mut) = backend.try_borrow_mut() else {
return;
};
let events = {
let stack_rc = backend_mut.modal_stack_handle();
let drag_rc = backend_mut.drag_state_handle();
let stack = stack_rc.borrow();
let mut drag = drag_rc.borrow_mut();
route_pointer(
&stack,
&mut drag,
backend_mut.text_selection_state_mut(),
UiEvent::MouseMoved { position, buttons },
)
};
let mut needs_redraw = false;
for ev in events {
let Some(outcome) = try_dispatch_borrowed(&mut backend_mut, &app, ev) else {
break;
};
match outcome {
EventOutcome::Continue => {}
EventOutcome::Redraw => needs_redraw = true,
EventOutcome::RedrawAfter(delay) => backend_mut.request_frame_in(delay),
EventOutcome::Exit => {
window_for_close.destroy();
return;
}
}
}
if needs_redraw {
da_for_redraw.queue_draw();
}
});
}
da.add_controller(motion);
}
fn setup_scroll_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
cursor_pos: &Rc<Cell<(f64, f64)>>,
) {
let scroll = EventControllerScroll::new(EventControllerScrollFlags::BOTH_AXES);
{
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let pump_depth = pump_depth.clone();
let cursor_pos = cursor_pos.clone();
scroll.connect_scroll(move |_ctrl, dx, dy| {
if pump_depth.is_pumping() {
return glib::Propagation::Proceed;
}
let (x, y) = cursor_pos.get();
let natural_scroll = app
.try_borrow()
.map(|a| a.natural_scroll())
.unwrap_or(false);
let ev = gdk_scroll_to_uievent_with_direction(dx, dy, x, y, natural_scroll);
let Some(outcome) = try_dispatch(&backend, &app, ev) else {
return glib::Propagation::Stop;
};
apply_event_outcome(
outcome,
&backend.borrow(),
&da_for_redraw,
&window_for_close,
);
glib::Propagation::Stop
});
}
da.add_controller(scroll);
}
fn setup_resize_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let resize_timer: Rc<Cell<Option<glib::SourceId>>> = Rc::new(Cell::new(None));
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let window_for_close = window.clone();
let pump_depth = pump_depth.clone();
da.connect_resize(move |da, _width, _height| {
if pump_depth.is_pumping() {
return;
}
da.queue_draw();
if let Some(id) = resize_timer.take() {
id.remove();
}
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da_for_redraw.clone();
let window_for_close = window_for_close.clone();
let pump_depth = pump_depth.clone();
let resize_timer_inner = resize_timer.clone();
let id = glib::source::timeout_add_local_once(RESIZE_SETTLE, move || {
resize_timer_inner.set(None);
if pump_depth.is_pumping() {
return;
}
let width = da_for_redraw.width();
let height = da_for_redraw.height();
let scale = da_for_redraw.scale_factor() as f32;
let ev = gdk_resize_to_uievent(width, height, scale);
let Some(outcome) = try_dispatch_with(
&backend,
&app,
|backend_mut| {
backend_mut.set_dpi_scale(scale);
},
ev,
) else {
return;
};
apply_event_outcome(
outcome,
&backend.borrow(),
&da_for_redraw,
&window_for_close,
);
});
resize_timer.set(Some(id));
});
}
fn setup_hidpi_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let backend = backend.clone();
let app = app.clone();
let da_for_dpi = da.clone();
let window_for_dpi = window.clone();
let pump_depth = pump_depth.clone();
da.connect_scale_factor_notify(move |da| {
if pump_depth.is_pumping() {
return;
}
let scale = da.scale_factor() as f32;
let Some(outcome) = try_dispatch_with(
&backend,
&app,
|backend_mut| backend_mut.set_dpi_scale(scale),
UiEvent::DpiChanged(scale),
) else {
return;
};
apply_event_outcome(outcome, &backend.borrow(), &da_for_dpi, &window_for_dpi);
});
}
fn setup_caret_blink(da: &DrawingArea, backend: &Rc<RefCell<GtkBackend>>) {
let period = backend.borrow().caret_blink_period();
let da = da.clone();
let backend = backend.clone();
glib::source::timeout_add_local(period, move || {
let Ok(mut backend_mut) = backend.try_borrow_mut() else {
return glib::ControlFlow::Continue;
};
let flipped = backend_mut.tick_caret_blink(std::time::Instant::now());
drop(backend_mut);
if flipped {
da.queue_draw();
}
glib::ControlFlow::Continue
});
}
fn setup_file_drop_controller<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
) {
let backend = backend.clone();
let app = app.clone();
let da_for_drop = da.clone();
let window_for_drop = window.clone();
let pump_depth = pump_depth.clone();
let drop_target = gtk4::DropTarget::new(
gtk4::gdk::FileList::static_type(),
gtk4::gdk::DragAction::COPY,
);
drop_target.connect_drop(move |_target, value, x, y| {
if pump_depth.is_pumping() {
return false;
}
let Ok(file_list) = value.get::<gtk4::gdk::FileList>() else {
return false;
};
let paths: Vec<std::path::PathBuf> =
file_list.files().iter().filter_map(|f| f.path()).collect();
if paths.is_empty() {
return false;
}
let ev = gtk_drop_to_uievent(paths, x, y);
let Some(outcome) = try_dispatch(&backend, &app, ev) else {
return false;
};
apply_event_outcome(outcome, &backend.borrow(), &da_for_drop, &window_for_drop);
true
});
da.add_controller(drop_target);
}
fn setup_close_request<A: AppLogic + 'static>(
window: &ApplicationWindow,
da: &DrawingArea,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
events_handle: &Rc<RefCell<std::collections::VecDeque<UiEvent>>>,
) {
let backend = backend.clone();
let app = app.clone();
let da_for_redraw = da.clone();
let pump_depth = pump_depth.clone();
let events_handle = events_handle.clone();
window.connect_close_request(move |_window| {
if pump_depth.is_pumping() {
return glib::Propagation::Proceed;
}
let outcome = match try_dispatch(&backend, &app, UiEvent::WindowClose) {
Some(outcome) => outcome,
None => {
events_handle.borrow_mut().push_back(UiEvent::WindowClose);
return glib::Propagation::Stop;
}
};
match outcome {
EventOutcome::Exit => glib::Propagation::Proceed,
EventOutcome::Redraw => {
da_for_redraw.queue_draw();
glib::Propagation::Stop
}
EventOutcome::RedrawAfter(delay) => {
backend.borrow_mut().request_frame_in(delay);
glib::Propagation::Stop
}
EventOutcome::Continue => glib::Propagation::Stop,
}
});
}
fn setup_event_drain<A: AppLogic + 'static>(
da: &DrawingArea,
window: &ApplicationWindow,
app: &Rc<RefCell<A>>,
backend: &Rc<RefCell<GtkBackend>>,
pump_depth: &crate::desktop::ModalPumpDepth,
events_handle: &Rc<RefCell<std::collections::VecDeque<UiEvent>>>,
) {
let drain_da = da.clone();
let drain_window = window.clone();
let drain_app = app.clone();
let drain_backend = backend.clone();
let drain_pump_depth = pump_depth.clone();
let drain_events_handle = events_handle.clone();
let drain_and_tick: Rc<dyn Fn()> = Rc::new(move || {
if drain_pump_depth.is_pumping() {
return;
}
let Ok(mut drain_backend_mut) = drain_backend.try_borrow_mut() else {
return;
};
let events = drain_backend_mut.poll_events();
drop(drain_backend_mut);
let mut events_iter = events.into_iter();
while let Some(ev) = events_iter.next() {
let Some((mut backend_mut, mut app_mut)) = try_borrow_both(&drain_backend, &drain_app)
else {
let mut q = drain_events_handle.borrow_mut();
q.push_back(ev);
q.extend(events_iter);
break;
};
let outcome = dispatch_event(ev, &mut backend_mut, &mut *app_mut);
drop(backend_mut);
drop(app_mut);
apply_event_outcome(outcome, &drain_backend.borrow(), &drain_da, &drain_window);
}
if let Some((mut backend_mut, mut app_mut)) = try_borrow_both(&drain_backend, &drain_app) {
let tick_reaction = app_mut.tick(&mut *backend_mut);
drop(backend_mut);
drop(app_mut);
apply_reaction(
tick_reaction,
&drain_backend.borrow(),
&drain_da,
&drain_window,
);
}
});
backend
.borrow()
.set_wake_callback(Rc::clone(&drain_and_tick));
glib::timeout_add_local(crate::runtime::IDLE_POLL_CEILING, move || {
drain_and_tick();
glib::ControlFlow::Continue
});
}
#[allow(clippy::print_stderr)]
fn schedule_smoke_check<A: AppLogic + 'static>(
cfg: SmokeConfig,
da: DrawingArea,
backend: Rc<RefCell<GtkBackend>>,
app: Rc<RefCell<A>>,
window: ApplicationWindow,
smoke_failed: Rc<Cell<bool>>,
) {
glib::source::timeout_add_local_once(Duration::from_millis(cfg.after_ms), move || {
let width = da.width();
let height = da.height();
if !smoke_size_ok(width, height, SMOKE_MIN_WIDTH, SMOKE_MIN_HEIGHT) {
eprintln!(
"quadraui smoke: DrawingArea size looks broken ({width}x{height}px, \
expected at least {SMOKE_MIN_WIDTH}x{SMOKE_MIN_HEIGHT}px) — \
this is the quadraui#437 tiny-window regression class"
);
smoke_failed.set(true);
}
if !da.is_focus() {
eprintln!(
"quadraui smoke: DrawingArea never became GTK's focus widget — \
this is the quadraui#235 missing-grab_focus regression class"
);
smoke_failed.set(true);
}
if let Some(text) = &cfg.paste_text {
let read_back = {
let backend_ref = backend.borrow();
let clipboard = backend_ref.services().clipboard();
clipboard.write_text(text);
clipboard.read_text()
};
if !smoke_clipboard_round_trip_ok(text, read_back.as_deref()) {
eprintln!(
"quadraui smoke: OS clipboard round-trip failed — wrote {text:?}, \
read back {read_back:?} (needs a real DISPLAY, e.g. Xvfb — \
the Broadway GDK backend has no OS clipboard to round-trip through)"
);
smoke_failed.set(true);
} else {
let ev = UiEvent::KeyPressed {
key: Key::Char('v'),
modifiers: Modifiers {
ctrl: true,
shift: false,
alt: false,
cmd: false,
},
repeat: false,
};
let mut backend_mut = backend.borrow_mut();
let mut app_mut = app.borrow_mut();
dispatch_event(ev, &mut backend_mut, &mut *app_mut);
}
}
window.destroy();
});
}
struct GtkSink<'a> {
backend: &'a GtkBackend,
da: &'a DrawingArea,
window: &'a ApplicationWindow,
}
impl ReactionSink for GtkSink<'_> {
fn request_redraw(&self) {
self.da.queue_draw();
}
fn request_exit(&self) {
self.window.destroy();
}
fn request_frame_in(&self, delay: Duration) {
self.backend.request_frame_in(delay);
}
}
fn apply_reaction(
reaction: Reaction,
backend: &GtkBackend,
da: &DrawingArea,
window: &ApplicationWindow,
) {
runtime::apply_outcome(
reaction,
&GtkSink {
backend,
da,
window,
},
);
}
fn apply_event_outcome(
outcome: EventOutcome,
backend: &GtkBackend,
da: &DrawingArea,
window: &ApplicationWindow,
) {
runtime::apply_outcome(
outcome,
&GtkSink {
backend,
da,
window,
},
);
}
pub(crate) fn render_frame<A: AppLogic>(
backend: &mut GtkBackend,
app: &A,
cr: &Context,
width: i32,
height: i32,
) {
let pango_ctx = pcfn::create_context(cr);
let layout = pg::Layout::new(&pango_ctx);
let font_desc_str = backend.editor_font_pango_string();
let font_desc = pg::FontDescription::from_string(&font_desc_str);
layout.set_font_description(Some(&font_desc));
layout.set_width(-1);
let metrics = pango_ctx.metrics(Some(&font_desc), None);
let line_h = (metrics.ascent() + metrics.descent()) as f64 / pg::SCALE as f64;
let char_w = super::backend::measure_char_width_px(&layout);
backend.begin_frame(crate::Viewport::new(
width as f32,
height as f32,
backend.dpi_scale(),
));
backend.set_current_line_height(line_h);
backend.set_current_char_width(char_w);
backend.refresh_chrome_char_width(&layout);
let bg = backend.current_theme().background;
cr.set_source_rgb(
bg.r as f64 / 255.0,
bg.g as f64 / 255.0,
bg.b as f64 / 255.0,
);
cr.paint().ok();
let focus_ring_rect = runtime::focused_stop_rect(backend, app, A::AreaId::default());
backend.enter_frame_scope(cr, &layout, |b| {
app.render(b, A::AreaId::default());
if let Some(rect) = focus_ring_rect {
b.draw_focus_ring(rect);
}
});
backend.apply_selection_highlight(cr);
backend.end_frame();
}
pub(crate) fn dispatch_event<A: AppLogic>(
event: UiEvent,
backend: &mut GtkBackend,
app: &mut A,
) -> EventOutcome {
if matches!(event, UiEvent::KeyPressed { .. } | UiEvent::CharTyped(_)) {
backend.note_caret_activity(std::time::Instant::now());
}
crate::runtime::preprocess_event(event, backend, app)
}
fn try_borrow_both<'a, A>(
backend: &'a Rc<RefCell<GtkBackend>>,
app: &'a Rc<RefCell<A>>,
) -> Option<(std::cell::RefMut<'a, GtkBackend>, std::cell::RefMut<'a, A>)> {
let backend_mut = backend.try_borrow_mut().ok()?;
let app_mut = app.try_borrow_mut().ok()?;
Some((backend_mut, app_mut))
}
fn try_dispatch_with<A: AppLogic>(
backend: &Rc<RefCell<GtkBackend>>,
app: &Rc<RefCell<A>>,
pre: impl FnOnce(&mut GtkBackend),
ev: UiEvent,
) -> Option<EventOutcome> {
let (mut backend_mut, mut app_mut) = try_borrow_both(backend, app)?;
pre(&mut backend_mut);
Some(dispatch_event(ev, &mut backend_mut, &mut *app_mut))
}
fn try_dispatch<A: AppLogic>(
backend: &Rc<RefCell<GtkBackend>>,
app: &Rc<RefCell<A>>,
ev: UiEvent,
) -> Option<EventOutcome> {
try_dispatch_with(backend, app, |_| {}, ev)
}
fn try_dispatch_borrowed<A: AppLogic>(
backend_mut: &mut GtkBackend,
app: &Rc<RefCell<A>>,
ev: UiEvent,
) -> Option<EventOutcome> {
let mut app_mut = app.try_borrow_mut().ok()?;
Some(dispatch_event(ev, backend_mut, &mut *app_mut))
}
#[cfg(test)]
mod run_config_tests {
use super::*;
#[test]
fn default_matches_runs_previous_hardcoded_values() {
let config = RunConfig::default();
assert_eq!(config.app_id, "org.quadraui.app");
assert_eq!(config.title, "quadraui app");
assert_eq!(config.icon_name, None);
assert!(config.single_instance, "single-instance dedup defaults on");
}
#[test]
fn new_sets_both_fields() {
let config = RunConfig::new("io.github.jdonaghy.kubeui-gtk", "kubeui");
assert_eq!(config.app_id, "io.github.jdonaghy.kubeui-gtk");
assert_eq!(config.title, "kubeui");
assert_eq!(config.icon_name, None);
assert!(config.single_instance);
}
#[test]
fn with_single_instance_overrides_the_default() {
let config = RunConfig::new("a.b.c", "Title").with_single_instance(false);
assert!(!config.single_instance);
}
#[test]
fn new_accepts_owned_and_borrowed_strings() {
let owned = RunConfig::new(String::from("a.b.c"), String::from("Title"));
let borrowed = RunConfig::new("a.b.c", "Title");
assert_eq!(owned, borrowed);
}
#[test]
fn with_icon_name_sets_the_icon() {
let config = RunConfig::new("a.b.c", "Title").with_icon_name("io.github.jdonaghy.vimcode");
assert_eq!(
config.icon_name,
Some("io.github.jdonaghy.vimcode".to_string())
);
}
}
#[cfg(target_os = "macos")]
#[cfg(test)]
mod activate_macos_process_tests {
use super::*;
#[test]
fn activate_macos_process_is_a_no_op_off_the_main_thread() {
activate_macos_process();
}
}
#[cfg(test)]
mod smoke_tests {
use super::*;
#[test]
fn smoke_size_ok_accepts_the_default_window_size() {
assert!(smoke_size_ok(
DEFAULT_WINDOW_WIDTH,
DEFAULT_WINDOW_HEIGHT,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
}
#[test]
fn smoke_size_ok_accepts_exactly_the_floor() {
assert!(smoke_size_ok(
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
}
#[test]
fn smoke_size_ok_rejects_the_437_tiny_window_class() {
assert!(!smoke_size_ok(
8,
DEFAULT_WINDOW_HEIGHT,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
assert!(!smoke_size_ok(
DEFAULT_WINDOW_WIDTH,
8,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
assert!(!smoke_size_ok(8, 8, SMOKE_MIN_WIDTH, SMOKE_MIN_HEIGHT));
}
#[test]
fn smoke_size_ok_rejects_just_under_the_floor() {
assert!(!smoke_size_ok(
SMOKE_MIN_WIDTH - 1,
SMOKE_MIN_HEIGHT,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
assert!(!smoke_size_ok(
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT - 1,
SMOKE_MIN_WIDTH,
SMOKE_MIN_HEIGHT
));
}
#[test]
fn clipboard_round_trip_ok_when_read_back_matches() {
assert!(smoke_clipboard_round_trip_ok(
"quadraui smoke",
Some("quadraui smoke")
));
}
#[test]
fn clipboard_round_trip_rejects_a_missing_or_mismatched_read() {
assert!(!smoke_clipboard_round_trip_ok("quadraui smoke", None));
assert!(!smoke_clipboard_round_trip_ok(
"quadraui smoke",
Some("something else")
));
}
#[test]
fn smoke_config_from_env_reads_gtks_own_var_names() {
std::env::remove_var(SMOKE_MS_VAR);
std::env::remove_var(SMOKE_PASTE_VAR);
assert_eq!(SmokeConfig::from_env(SMOKE_MS_VAR, SMOKE_PASTE_VAR), None);
std::env::set_var(SMOKE_MS_VAR, "500");
let cfg = SmokeConfig::from_env(SMOKE_MS_VAR, SMOKE_PASTE_VAR)
.expect("QUADRAUI_GTK_SMOKE_MS set and parseable");
assert_eq!(cfg.after_ms, 500);
assert_eq!(cfg.paste_text, None);
std::env::remove_var(SMOKE_MS_VAR);
}
}
#[cfg(test)]
mod paste_tests {
use super::*;
use crate::desktop::{is_paste_keypress, PasteModifier};
use crate::gtk::services::TestClipboardContents;
use crate::gtk::testing::GtkDriver;
#[derive(Default)]
struct RecordingApp {
events: Vec<UiEvent>,
}
impl AppLogic for RecordingApp {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.push(event);
Reaction::Continue
}
}
#[test]
fn plain_ctrl_v_is_a_paste_keypress() {
let mods = Modifiers {
ctrl: true,
..Modifiers::default()
};
assert!(is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
assert!(is_paste_keypress(
&Key::Char('V'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn ctrl_shift_v_is_a_paste_keypress() {
let mods = Modifiers {
ctrl: true,
shift: true,
..Modifiers::default()
};
assert!(is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn ctrl_alt_v_is_not_a_paste_keypress() {
let mods = Modifiers {
ctrl: true,
alt: true,
..Modifiers::default()
};
assert!(!is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn ctrl_cmd_v_is_not_a_paste_keypress() {
let mods = Modifiers {
ctrl: true,
cmd: true,
..Modifiers::default()
};
assert!(!is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn plain_super_v_is_not_a_paste_keypress() {
let mods = Modifiers {
cmd: true,
..Modifiers::default()
};
assert!(!is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn shift_v_without_ctrl_is_not_a_paste_keypress() {
let mods = Modifiers {
shift: true,
..Modifiers::default()
};
assert!(!is_paste_keypress(
&Key::Char('v'),
&mods,
PasteModifier::Ctrl
));
}
#[test]
fn plain_v_is_not_a_paste_keypress() {
assert!(!is_paste_keypress(
&Key::Char('v'),
&Modifiers::default(),
PasteModifier::Ctrl
));
}
fn driver_with_clipboard(
clipboard: Option<&str>,
primary: Option<&str>,
) -> GtkDriver<RecordingApp> {
let driver = GtkDriver::new(RecordingApp::default(), 100, 30);
driver
.backend()
.install_test_clipboard(TestClipboardContents {
clipboard: clipboard.map(str::to_string),
primary: primary.map(str::to_string),
});
driver
}
fn press_v(driver: &mut GtkDriver<RecordingApp>, modifiers: Modifiers) {
driver.dispatch(UiEvent::KeyPressed {
key: Key::Char('v'),
modifiers,
repeat: false,
});
}
fn middle_click(driver: &mut GtkDriver<RecordingApp>) {
driver.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Middle,
position: Point::new(5.0, 5.0),
modifiers: Modifiers::default(),
});
}
fn only_event(driver: &GtkDriver<RecordingApp>) -> &UiEvent {
let events = &driver.app().events;
assert_eq!(
events.len(),
1,
"expected exactly one event to reach app.handle, got {events:?}"
);
&events[0]
}
#[test]
fn ctrl_v_delivers_the_clipboard_selection_as_a_paste() {
let mut driver = driver_with_clipboard(Some("copied text"), None);
press_v(
&mut driver,
Modifiers {
ctrl: true,
..Modifiers::default()
},
);
assert!(
matches!(only_event(&driver), UiEvent::ClipboardPaste(t) if t == "copied text"),
"Ctrl-V should deliver ClipboardPaste with the CLIPBOARD contents, got {:?}",
driver.app().events
);
}
#[test]
fn ctrl_shift_v_delivers_the_clipboard_selection_as_a_paste() {
let mut driver = driver_with_clipboard(Some("copied text"), None);
press_v(
&mut driver,
Modifiers {
ctrl: true,
shift: true,
..Modifiers::default()
},
);
assert!(
matches!(only_event(&driver), UiEvent::ClipboardPaste(t) if t == "copied text"),
"Ctrl-Shift-V should deliver ClipboardPaste, got {:?}",
driver.app().events
);
}
#[test]
fn ctrl_shift_v_is_intercepted_not_forwarded_as_raw_v() {
let mut driver = driver_with_clipboard(None, None);
press_v(
&mut driver,
Modifiers {
ctrl: true,
shift: true,
..Modifiers::default()
},
);
assert!(
driver.app().events.is_empty(),
"Ctrl-Shift-V should be intercepted as a paste attempt, not \
forwarded to app.handle as a raw keypress: {:?}",
driver.app().events
);
}
#[test]
fn ctrl_alt_v_falls_through_to_app_unmodified() {
let mut driver = driver_with_clipboard(Some("copied text"), None);
press_v(
&mut driver,
Modifiers {
ctrl: true,
alt: true,
..Modifiers::default()
},
);
assert!(
matches!(
only_event(&driver),
UiEvent::KeyPressed {
key: Key::Char('v'),
..
}
),
"Ctrl-Alt-V is not a paste trigger and should reach app.handle unmodified, got {:?}",
driver.app().events
);
}
#[test]
fn middle_click_delivers_the_primary_selection_as_a_paste() {
let mut driver = driver_with_clipboard(None, Some("selected text"));
middle_click(&mut driver);
assert!(
matches!(only_event(&driver), UiEvent::ClipboardPaste(t) if t == "selected text"),
"middle-click should deliver ClipboardPaste with the PRIMARY selection, got {:?}",
driver.app().events
);
}
#[test]
fn middle_click_reads_primary_selection_not_the_clipboard() {
let mut driver = driver_with_clipboard(Some("CLIPBOARD"), Some("PRIMARY"));
middle_click(&mut driver);
assert!(
matches!(only_event(&driver), UiEvent::ClipboardPaste(t) if t == "PRIMARY"),
"middle-click pasted the wrong selection: {:?}",
driver.app().events
);
}
#[test]
fn middle_click_without_primary_selection_falls_through_to_app() {
let mut driver = driver_with_clipboard(Some("CLIPBOARD"), None);
middle_click(&mut driver);
assert!(
matches!(
only_event(&driver),
UiEvent::MouseDown {
button: MouseButton::Middle,
..
}
),
"middle-click with no PRIMARY selection should fall through, got {:?}",
driver.app().events
);
}
#[test]
fn second_mouse_down_at_same_spot_folds_into_double_click() {
let mut driver = driver_with_clipboard(None, None);
driver.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(5.0, 5.0),
modifiers: Modifiers::default(),
});
driver.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(5.0, 5.0),
modifiers: Modifiers::default(),
});
assert_eq!(
driver.app().events.len(),
2,
"expected exactly two events (MouseDown, DoubleClick), got {:?}",
driver.app().events
);
assert!(
matches!(driver.app().events[0], UiEvent::MouseDown { .. }),
"first press should reach the app as a plain MouseDown, got {:?}",
driver.app().events[0]
);
assert!(
matches!(driver.app().events[1], UiEvent::DoubleClick { .. }),
"second press at the same spot should fold to DoubleClick, got {:?}",
driver.app().events[1]
);
}
#[test]
fn second_mouse_down_far_away_stays_two_mouse_downs() {
let mut driver = driver_with_clipboard(None, None);
driver.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(5.0, 5.0),
modifiers: Modifiers::default(),
});
driver.dispatch(UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(80.0, 40.0),
modifiers: Modifiers::default(),
});
assert_eq!(driver.app().events.len(), 2);
assert!(
driver
.app()
.events
.iter()
.all(|e| matches!(e, UiEvent::MouseDown { .. })),
"clicks far apart must never fold into a DoubleClick, got {:?}",
driver.app().events
);
}
}
#[cfg(test)]
mod window_close_tests {
use super::*;
use crate::gtk::testing::GtkDriver;
#[derive(Default)]
struct RecordingApp {
events: Vec<UiEvent>,
reaction: Option<Reaction>,
}
impl AppLogic for RecordingApp {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
self.events.push(event);
self.reaction.unwrap_or(Reaction::Continue)
}
}
#[test]
fn window_close_reaches_app_handle_unmodified() {
let mut driver = GtkDriver::new(RecordingApp::default(), 400, 300);
let reaction = driver.dispatch(UiEvent::WindowClose);
assert_eq!(reaction, Reaction::Continue, "no veto ⇒ Continue, not Exit");
assert_eq!(
driver.app().events,
vec![UiEvent::WindowClose],
"WindowClose must not be rewritten (e.g. into Accelerator) or swallowed \
by any of dispatch_event's pre-processing branches"
);
}
#[test]
fn window_close_can_be_accepted() {
let mut driver = GtkDriver::new(
RecordingApp {
reaction: Some(Reaction::Exit),
..Default::default()
},
400,
300,
);
assert_eq!(driver.dispatch(UiEvent::WindowClose), Reaction::Exit);
}
#[test]
fn window_close_can_be_vetoed() {
let mut driver = GtkDriver::new(
RecordingApp {
reaction: Some(Reaction::Redraw),
..Default::default()
},
400,
300,
);
assert_eq!(driver.dispatch(UiEvent::WindowClose), Reaction::Redraw);
}
}
#[cfg(test)]
mod reentrancy_backstop_tests {
use super::*;
#[derive(Default)]
struct NoopApp;
impl AppLogic for NoopApp {
type AreaId = ();
fn render(&self, _backend: &mut dyn Backend, _area: ()) {}
fn handle(&mut self, _event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
Reaction::Continue
}
}
#[test]
fn try_borrow_both_succeeds_when_neither_is_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
assert!(try_borrow_both(&backend, &app).is_some());
}
#[test]
fn try_borrow_both_degrades_instead_of_panicking_when_backend_is_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let _outer_borrow = backend.borrow_mut();
assert!(
try_borrow_both(&backend, &app).is_none(),
"a double borrow_mut() on backend must degrade to None, not panic"
);
}
#[test]
fn try_borrow_both_degrades_instead_of_panicking_when_app_is_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let _outer_borrow = app.borrow_mut();
assert!(try_borrow_both(&backend, &app).is_none());
}
#[test]
fn try_dispatch_degrades_when_backend_already_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let _outer_borrow = backend.borrow_mut();
assert!(try_dispatch(&backend, &app, UiEvent::WindowClose).is_none());
}
#[test]
fn try_dispatch_with_degrades_when_app_already_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let _outer_borrow = app.borrow_mut();
let mut pre_ran = false;
let outcome = try_dispatch_with(
&backend,
&app,
|_backend_mut| pre_ran = true,
UiEvent::WindowClose,
);
assert!(outcome.is_none());
assert!(
!pre_ran,
"try_borrow_both borrows backend *and* app before `pre` ever runs, so a \
failed app borrow must short-circuit before any backend mutation \
(e.g. set_dpi_scale) happens — no partial side effect from a dispatch \
that never completes"
);
}
#[test]
fn try_dispatch_with_runs_pre_then_dispatches_when_neither_is_borrowed() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let mut pre_ran = false;
let outcome = try_dispatch_with(
&backend,
&app,
|_backend_mut| pre_ran = true,
UiEvent::WindowClose,
);
assert!(outcome.is_some());
assert!(pre_ran);
}
#[test]
fn try_dispatch_borrowed_degrades_when_app_already_borrowed() {
let mut backend = GtkBackend::new();
let app = Rc::new(RefCell::new(NoopApp));
let _outer_borrow = app.borrow_mut();
assert!(try_dispatch_borrowed(&mut backend, &app, UiEvent::WindowClose).is_none());
}
#[test]
fn close_request_degrade_path_defers_the_event_instead_of_dropping_it() {
let backend = Rc::new(RefCell::new(GtkBackend::new()));
let app = Rc::new(RefCell::new(NoopApp));
let events_handle = backend.borrow().events_handle();
{
let _outer_borrow = backend.borrow_mut();
let outcome = try_dispatch(&backend, &app, UiEvent::WindowClose);
assert!(
outcome.is_none(),
"must degrade, not panic, on the double borrow"
);
events_handle.borrow_mut().push_back(UiEvent::WindowClose);
}
let drained = backend.borrow_mut().poll_events();
assert_eq!(
drained,
vec![UiEvent::WindowClose],
"the deferred WindowClose must reach the app on the next drain tick, not be lost"
);
}
}