use crate::desktop::{is_paste_keypress, PasteModifier};
use crate::focus::FocusManager;
use crate::runner::AppLogic;
use crate::text_selection::ActiveTextSelection;
use crate::{
AcceleratorId, ActivityBarEvent, Key, Modifiers, MouseButton, NamedKey, Point, UiEvent,
WidgetId,
};
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
use std::time::Duration;
#[cfg(any(
feature = "tui",
all(feature = "win", target_os = "windows"),
all(feature = "macos", target_os = "macos")
))]
use crate::event::Viewport;
use crate::runner::Reaction;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EventOutcome {
Continue,
Redraw,
RedrawAfter(Duration),
Exit,
}
impl EventOutcome {
#[cfg_attr(
not(any(feature = "win", all(feature = "macos", target_os = "macos"))),
allow(dead_code)
)]
pub(crate) fn merge(self, next: EventOutcome) -> EventOutcome {
match (self, next) {
(EventOutcome::Exit, _) | (_, EventOutcome::Exit) => EventOutcome::Exit,
(EventOutcome::Redraw, _) | (_, EventOutcome::Redraw) => EventOutcome::Redraw,
(EventOutcome::RedrawAfter(a), EventOutcome::RedrawAfter(b)) => {
EventOutcome::RedrawAfter(a.min(b))
}
(EventOutcome::RedrawAfter(d), EventOutcome::Continue)
| (EventOutcome::Continue, EventOutcome::RedrawAfter(d)) => {
EventOutcome::RedrawAfter(d)
}
(EventOutcome::Continue, EventOutcome::Continue) => EventOutcome::Continue,
}
}
}
impl From<Reaction> for EventOutcome {
fn from(r: Reaction) -> Self {
match r {
Reaction::Continue => EventOutcome::Continue,
Reaction::Redraw => EventOutcome::Redraw,
Reaction::RedrawAfter(d) => EventOutcome::RedrawAfter(d),
Reaction::Exit => EventOutcome::Exit,
}
}
}
impl From<EventOutcome> for Reaction {
fn from(o: EventOutcome) -> Self {
match o {
EventOutcome::Continue => Reaction::Continue,
EventOutcome::Redraw => Reaction::Redraw,
EventOutcome::RedrawAfter(d) => Reaction::RedrawAfter(d),
EventOutcome::Exit => Reaction::Exit,
}
}
}
#[cfg(any(feature = "gtk", all(feature = "macos", target_os = "macos")))]
pub(crate) trait ReactionSink {
fn request_redraw(&self);
fn request_exit(&self);
fn request_frame_in(&self, delay: Duration);
}
#[cfg(any(feature = "gtk", all(feature = "macos", target_os = "macos")))]
pub(crate) fn apply_outcome(outcome: impl Into<EventOutcome>, sink: &impl ReactionSink) {
match outcome.into() {
EventOutcome::Continue => {}
EventOutcome::Redraw => sink.request_redraw(),
EventOutcome::RedrawAfter(d) => sink.request_frame_in(d),
EventOutcome::Exit => sink.request_exit(),
}
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) trait PreprocessBackend {
fn active_text_selection(&self) -> Option<&ActiveTextSelection>;
fn set_active_text_selection(&mut self, region: WidgetId, anchor: Point, focus: Point);
fn clear_text_selection(&mut self);
fn clear_selection_display(&mut self);
fn select_all_text_region(&mut self) -> bool;
fn selection_text_for_copy(&self) -> String;
fn focused_activity_bar_id(&self) -> Option<&WidgetId>;
fn focus_manager_mut(&mut self) -> &mut FocusManager;
fn match_keypress(&self, key: &Key, modifiers: Modifiers) -> Option<AcceleratorId>;
fn fold_double_click(&mut self, ev: UiEvent) -> UiEvent;
fn paste_modifier(&self) -> PasteModifier {
PasteModifier::Ctrl
}
fn is_copy_keypress(&self, key: &Key, modifiers: &Modifiers) -> bool {
matches!(key, Key::Char('c'))
&& modifiers.ctrl
&& !modifiers.shift
&& !modifiers.alt
&& !modifiers.cmd
}
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn preprocess_event<B, A>(event: UiEvent, backend: &mut B, app: &mut A) -> EventOutcome
where
B: PreprocessBackend + crate::Backend,
A: AppLogic,
{
let event = backend.fold_double_click(event);
if let UiEvent::KeyPressed {
ref key, modifiers, ..
} = event
{
if let Some(bar_id) = backend.focused_activity_bar_id().cloned() {
let key_str = crate::primitives::activity_bar::key_to_activity_bar_string(key);
let bar_ev = UiEvent::ActivityBar(
bar_id,
ActivityBarEvent::KeyPressed {
key: key_str,
modifiers,
},
);
return app.handle(bar_ev, backend).into();
}
}
if let UiEvent::KeyPressed {
key: Key::Named(named @ (NamedKey::Tab | NamedKey::BackTab)),
modifiers,
..
} = &event
{
if !modifiers.ctrl && !modifiers.alt && !modifiers.cmd {
let stops = app.tab_stops(A::AreaId::default());
if !stops.is_empty() {
backend.focus_manager_mut().sync_tab_order(&stops);
let retreat = matches!(named, NamedKey::BackTab) || modifiers.shift;
let fm = backend.focus_manager_mut();
let changed = if retreat {
fm.focus_prev()
} else {
fm.focus_next()
};
if changed {
let focused = backend.focus_manager().focused().cloned();
return app.handle(UiEvent::FocusChanged(focused), backend).into();
}
return EventOutcome::Continue;
}
}
}
let event = if let UiEvent::KeyPressed { key, modifiers, .. } = &event {
match backend.match_keypress(key, *modifiers) {
Some(id) => UiEvent::Accelerator(id, *modifiers),
None => event,
}
} else {
event
};
if let UiEvent::KeyPressed {
ref key,
ref modifiers,
..
} = event
{
if backend.is_copy_keypress(key, modifiers) && backend.active_text_selection().is_some() {
let text = backend.selection_text_for_copy();
backend.services().clipboard().write_text(&text);
backend.clear_text_selection();
return app.handle(UiEvent::TextCopied(text), backend).into();
}
}
if let UiEvent::KeyPressed {
ref key,
ref modifiers,
..
} = event
{
if is_paste_keypress(key, modifiers, backend.paste_modifier()) {
return match backend.services().clipboard().read_text() {
Some(text) => app.handle(UiEvent::ClipboardPaste(text), backend).into(),
None => EventOutcome::Continue,
};
}
}
if let UiEvent::MouseDown {
button: MouseButton::Middle,
..
} = &event
{
if let Some(text) = backend.services().clipboard().read_primary_selection() {
return app.handle(UiEvent::ClipboardPaste(text), backend).into();
}
}
if let UiEvent::KeyPressed {
key: Key::Char('a') | Key::Char('A'),
modifiers,
..
} = &event
{
if modifiers.ctrl
&& !modifiers.shift
&& !modifiers.alt
&& !modifiers.cmd
&& backend.select_all_text_region()
{
return EventOutcome::Redraw;
}
}
if matches!(
event,
UiEvent::MouseDown { .. } | UiEvent::DoubleClick { .. }
) {
backend.clear_selection_display();
}
let mut force_redraw = false;
if let UiEvent::TextSelectionChanged {
ref region,
anchor,
focus,
} = event
{
backend.set_active_text_selection(*region, anchor, focus);
force_redraw = true;
}
if matches!(event, UiEvent::DpiChanged(_)) {
force_redraw = true;
}
let outcome: EventOutcome = app.handle(event, backend).into();
if force_redraw {
match outcome {
EventOutcome::Continue | EventOutcome::RedrawAfter(_) => EventOutcome::Redraw,
other => other,
}
} else {
outcome
}
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn focused_stop_rect<B, A>(
backend: &B,
app: &A,
area: A::AreaId,
) -> Option<crate::event::Rect>
where
B: crate::Backend,
A: AppLogic,
{
backend.focus_manager().focused().cloned().and_then(|id| {
app.tab_stops(area)
.into_iter()
.find(|(stop_id, _)| *stop_id == id)
.map(|(_, rect)| rect)
})
}
#[cfg(any(
feature = "tui",
feature = "gtk",
all(feature = "win", target_os = "windows"),
all(feature = "macos", target_os = "macos")
))]
pub(crate) const RESIZE_SETTLE: Duration = Duration::from_millis(120);
#[cfg(any(
feature = "tui",
feature = "gtk",
all(feature = "macos", target_os = "macos"),
all(feature = "win", target_os = "windows")
))]
pub(crate) const IDLE_POLL_CEILING: Duration = Duration::from_millis(250);
#[cfg(any(
feature = "tui",
all(feature = "win", target_os = "windows"),
all(feature = "macos", target_os = "macos")
))]
pub(crate) struct ResizeDebouncer {
pending: Option<Viewport>,
}
#[cfg(any(
feature = "tui",
all(feature = "win", target_os = "windows"),
all(feature = "macos", target_os = "macos")
))]
impl ResizeDebouncer {
pub(crate) const fn new() -> Self {
Self { pending: None }
}
pub(crate) fn note(&mut self, viewport: Viewport) {
self.pending = Some(viewport);
}
pub(crate) fn take(&mut self) -> Option<Viewport> {
self.pending.take()
}
}
#[cfg(any(feature = "tui", feature = "win"))]
pub(crate) struct FrameScheduler {
deadline: std::cell::Cell<Option<std::time::Instant>>,
requests: std::cell::Cell<u64>,
}
#[cfg(any(feature = "tui", feature = "win"))]
impl FrameScheduler {
pub(crate) const fn new() -> Self {
Self {
deadline: std::cell::Cell::new(None),
requests: std::cell::Cell::new(0),
}
}
pub(crate) fn request(&self, delay: Duration) {
self.requests.set(self.requests.get().saturating_add(1));
let candidate = std::time::Instant::now() + delay;
self.deadline.set(Some(match self.deadline.get() {
Some(d) if d <= candidate => d,
_ => candidate,
}));
}
pub(crate) fn requests(&self) -> u64 {
self.requests.get()
}
pub(crate) fn pending_delay(&self) -> Option<Duration> {
self.deadline
.get()
.map(|d| d.saturating_duration_since(std::time::Instant::now()))
}
}
#[cfg(feature = "tui")]
impl FrameScheduler {
pub(crate) fn poll_timeout(&self, ceiling: Duration) -> Duration {
match self.deadline.get() {
Some(d) => d
.saturating_duration_since(std::time::Instant::now())
.min(ceiling),
None => ceiling,
}
}
pub(crate) fn clear_if_due(&self) {
if self
.deadline
.get()
.is_some_and(|d| std::time::Instant::now() >= d)
{
self.deadline.set(None);
}
}
}
pub(crate) struct UserEventQueue {
inbox: std::sync::Mutex<
std::collections::VecDeque<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
>,
}
impl UserEventQueue {
pub(crate) fn new() -> std::sync::Arc<Self> {
std::sync::Arc::new(Self {
inbox: std::sync::Mutex::new(std::collections::VecDeque::new()),
})
}
pub(crate) fn push(&self, payload: std::sync::Arc<dyn std::any::Any + Send + Sync>) {
let mut inbox = self.inbox.lock().unwrap_or_else(|e| e.into_inner());
inbox.push_back(payload);
}
pub(crate) fn drain_into(&self, out: &mut Vec<UiEvent>) {
let mut inbox = self.inbox.lock().unwrap_or_else(|e| e.into_inner());
out.extend(
inbox
.drain(..)
.map(|payload| UiEvent::User(crate::event::UserPayload::from_arc(payload))),
);
}
}
#[cfg(test)]
mod user_event_queue_tests {
use super::UserEventQueue;
use crate::UiEvent;
use std::sync::Arc;
#[test]
fn drain_into_is_empty_when_nothing_pushed() {
let q = UserEventQueue::new();
let mut out = Vec::new();
q.drain_into(&mut out);
assert!(out.is_empty());
}
#[test]
fn push_then_drain_round_trips_fifo() {
let q = UserEventQueue::new();
q.push(Arc::new(1_i32));
q.push(Arc::new(2_i32));
let mut out = Vec::new();
q.drain_into(&mut out);
assert_eq!(out.len(), 2);
let values: Vec<i32> = out
.iter()
.map(|ev| match ev {
UiEvent::User(payload) => *payload.downcast_ref::<i32>().unwrap(),
other => panic!("expected UiEvent::User, got {other:?}"),
})
.collect();
assert_eq!(values, vec![1, 2], "payloads must drain in push order");
let mut out2 = Vec::new();
q.drain_into(&mut out2);
assert!(out2.is_empty());
}
#[test]
fn push_from_background_thread_is_observed_after_join() {
let q = UserEventQueue::new();
let q2 = Arc::clone(&q);
let handle = std::thread::spawn(move || {
q2.push(Arc::new("from background".to_string()));
});
handle.join().unwrap();
let mut out = Vec::new();
q.drain_into(&mut out);
assert_eq!(out.len(), 1);
match &out[0] {
UiEvent::User(payload) => {
assert_eq!(
payload.downcast_ref::<String>().map(String::as_str),
Some("from background")
);
}
other => panic!("expected UiEvent::User, got {other:?}"),
}
}
}
#[cfg(all(
test,
any(feature = "gtk", all(feature = "macos", target_os = "macos"))
))]
mod reaction_sink_tests {
use super::*;
struct RecordingSink {
redraws: std::cell::Cell<u32>,
exits: std::cell::Cell<u32>,
frame_requests: std::cell::RefCell<Vec<Duration>>,
}
impl RecordingSink {
fn new() -> Self {
Self {
redraws: std::cell::Cell::new(0),
exits: std::cell::Cell::new(0),
frame_requests: std::cell::RefCell::new(Vec::new()),
}
}
}
impl ReactionSink for RecordingSink {
fn request_redraw(&self) {
self.redraws.set(self.redraws.get() + 1);
}
fn request_exit(&self) {
self.exits.set(self.exits.get() + 1);
}
fn request_frame_in(&self, delay: Duration) {
self.frame_requests.borrow_mut().push(delay);
}
}
#[test]
fn apply_outcome_continue_touches_nothing() {
let sink = RecordingSink::new();
apply_outcome(EventOutcome::Continue, &sink);
assert_eq!(sink.redraws.get(), 0);
assert_eq!(sink.exits.get(), 0);
assert!(sink.frame_requests.borrow().is_empty());
}
#[test]
fn apply_outcome_redraw_calls_request_redraw() {
let sink = RecordingSink::new();
apply_outcome(EventOutcome::Redraw, &sink);
assert_eq!(sink.redraws.get(), 1);
assert_eq!(sink.exits.get(), 0);
}
#[test]
fn apply_outcome_exit_calls_request_exit() {
let sink = RecordingSink::new();
apply_outcome(EventOutcome::Exit, &sink);
assert_eq!(sink.redraws.get(), 0);
assert_eq!(sink.exits.get(), 1);
}
#[test]
fn apply_outcome_accepts_a_raw_reaction() {
let sink = RecordingSink::new();
apply_outcome(Reaction::Redraw, &sink);
assert_eq!(sink.redraws.get(), 1);
}
#[test]
fn apply_outcome_redraw_after_calls_request_frame_in_not_redraw() {
let sink = RecordingSink::new();
apply_outcome(EventOutcome::RedrawAfter(Duration::from_millis(100)), &sink);
assert_eq!(
sink.redraws.get(),
0,
"RedrawAfter must not force an immediate redraw"
);
assert_eq!(
*sink.frame_requests.borrow(),
vec![Duration::from_millis(100)]
);
}
#[test]
fn apply_outcome_accepts_a_raw_redraw_after_reaction() {
let sink = RecordingSink::new();
apply_outcome(Reaction::RedrawAfter(Duration::from_millis(50)), &sink);
assert_eq!(
*sink.frame_requests.borrow(),
vec![Duration::from_millis(50)]
);
}
}
#[cfg(all(
test,
any(
feature = "tui",
all(feature = "win", target_os = "windows"),
all(feature = "macos", target_os = "macos")
)
))]
mod resize_debouncer_tests {
use super::*;
#[test]
fn resize_debouncer_starts_empty() {
let mut d = ResizeDebouncer::new();
assert!(d.take().is_none());
}
#[test]
fn resize_debouncer_take_clears_pending() {
let mut d = ResizeDebouncer::new();
d.note(Viewport::new(100.0, 50.0, 1.0));
assert_eq!(d.take(), Some(Viewport::new(100.0, 50.0, 1.0)));
assert!(d.take().is_none(), "take() must clear the pending value");
}
#[test]
fn resize_debouncer_later_note_supersedes_earlier() {
let mut d = ResizeDebouncer::new();
d.note(Viewport::new(100.0, 50.0, 1.0));
d.note(Viewport::new(200.0, 80.0, 1.0));
assert_eq!(d.take(), Some(Viewport::new(200.0, 80.0, 1.0)));
}
#[test]
fn burst_of_resize_events_collapses_to_one_dispatch() {
let mut d = ResizeDebouncer::new();
let mut dispatched = 0u32;
for i in 1..=50u32 {
d.note(Viewport::new(i as f32, i as f32, 1.0));
}
if let Some(viewport) = d.take() {
dispatched += 1;
assert_eq!(
viewport,
Viewport::new(50.0, 50.0, 1.0),
"the settled dispatch must carry the final size, not an \
intermediate one"
);
}
assert_eq!(
dispatched, 1,
"a 50-event resize burst must collapse to exactly one \
dispatched WindowResized"
);
assert!(
d.take().is_none(),
"take() must not yield a second dispatch for the same burst"
);
}
}
#[cfg(all(test, feature = "tui"))]
mod frame_scheduler_tests {
use super::*;
#[test]
fn no_request_polls_the_full_ceiling() {
let s = FrameScheduler::new();
assert_eq!(
s.poll_timeout(Duration::from_millis(250)),
Duration::from_millis(250)
);
}
#[test]
fn a_request_shorter_than_the_ceiling_shortens_the_poll_timeout() {
let s = FrameScheduler::new();
s.request(Duration::from_millis(10));
let timeout = s.poll_timeout(Duration::from_millis(250));
assert!(
timeout <= Duration::from_millis(10) && timeout > Duration::ZERO,
"expected a short bounded timeout, got {timeout:?}"
);
}
#[test]
fn a_request_longer_than_the_ceiling_is_clamped_to_it() {
let s = FrameScheduler::new();
s.request(Duration::from_secs(10));
assert_eq!(
s.poll_timeout(Duration::from_millis(250)),
Duration::from_millis(250)
);
}
#[test]
fn coalesces_to_the_earliest_deadline() {
let s = FrameScheduler::new();
s.request(Duration::from_secs(10));
s.request(Duration::from_millis(5));
let timeout = s.poll_timeout(Duration::from_secs(60));
assert!(
timeout <= Duration::from_millis(5),
"a sooner request must shorten the deadline, got {timeout:?}"
);
}
#[test]
fn a_later_looser_request_does_not_override_a_sooner_one() {
let s = FrameScheduler::new();
s.request(Duration::from_millis(5));
s.request(Duration::from_secs(10));
let timeout = s.poll_timeout(Duration::from_secs(60));
assert!(
timeout <= Duration::from_millis(5),
"the earlier, sooner deadline must still govern, got {timeout:?}"
);
}
#[test]
fn clear_if_due_is_a_noop_before_the_deadline() {
let s = FrameScheduler::new();
s.request(Duration::from_secs(60));
s.clear_if_due();
assert!(s.poll_timeout(Duration::from_millis(1)) <= Duration::from_millis(1));
assert!(s.deadline.get().is_some());
}
#[test]
fn clear_if_due_clears_a_past_deadline() {
let s = FrameScheduler::new();
s.request(Duration::ZERO);
std::thread::sleep(Duration::from_millis(1));
s.clear_if_due();
assert!(s.deadline.get().is_none());
assert_eq!(
s.poll_timeout(Duration::from_millis(250)),
Duration::from_millis(250)
);
}
#[test]
fn idle_wakeups_over_one_second_are_bounded_by_the_ceiling_not_the_old_16ms_poll() {
let s = FrameScheduler::new();
let ceiling = IDLE_POLL_CEILING;
let interval = Duration::from_secs(1);
let old_poll_timeout_ms = 16u128; let old_wakeups = interval.as_millis() / old_poll_timeout_ms;
let new_wakeups = interval.as_millis() / s.poll_timeout(ceiling).as_millis();
assert_eq!(old_wakeups, 62, "sanity check on the pre-#832 baseline");
assert!(
new_wakeups < old_wakeups / 4,
"expected the new idle ceiling ({ceiling:?}) to cut wakeups \
over {interval:?} to well under a quarter of the old \
unconditional-poll baseline ({old_wakeups}); got {new_wakeups}"
);
}
}
#[cfg(test)]
mod event_outcome_merge_tests {
use super::*;
#[test]
fn exit_beats_everything_either_order() {
assert!(matches!(
EventOutcome::Continue.merge(EventOutcome::Exit),
EventOutcome::Exit
));
assert!(matches!(
EventOutcome::Exit.merge(EventOutcome::Redraw),
EventOutcome::Exit
));
assert!(matches!(
EventOutcome::RedrawAfter(Duration::from_millis(5)).merge(EventOutcome::Exit),
EventOutcome::Exit
));
}
#[test]
fn redraw_beats_redraw_after_either_order() {
assert!(matches!(
EventOutcome::RedrawAfter(Duration::from_millis(5)).merge(EventOutcome::Redraw),
EventOutcome::Redraw
));
assert!(matches!(
EventOutcome::Redraw.merge(EventOutcome::RedrawAfter(Duration::from_millis(5))),
EventOutcome::Redraw
));
}
#[test]
fn continue_is_the_identity() {
assert!(matches!(
EventOutcome::Continue.merge(EventOutcome::Continue),
EventOutcome::Continue
));
assert!(matches!(
EventOutcome::Continue.merge(EventOutcome::RedrawAfter(Duration::from_millis(5))),
EventOutcome::RedrawAfter(d) if d == Duration::from_millis(5)
));
}
#[test]
fn two_redraw_afters_keep_the_earlier_deadline_regardless_of_arrival_order() {
let long = EventOutcome::RedrawAfter(Duration::from_millis(100));
let short = EventOutcome::RedrawAfter(Duration::from_millis(5));
assert!(matches!(
long.merge(short),
EventOutcome::RedrawAfter(d) if d == Duration::from_millis(5)
));
assert!(matches!(
short.merge(long),
EventOutcome::RedrawAfter(d) if d == Duration::from_millis(5)
));
}
}