use std::any::Any;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use accesskit_winit::{
Adapter, Event as AccessibilityEvent, WindowEvent as AccessibilityWindowEvent,
};
use anyhow::Result;
use frust_core::FrameTime;
use frust_core::RenderRoot;
use frust_core::SemanticsUpdate;
use frust_core::accesskit::{Tree, TreeId, TreeUpdate};
use frust_core::event::{
CursorIcon, EditCommand, EventOutcome, ImeContentType, ImeEvent, InputEvent, Key, KeyEvent,
Modifiers, NamedKey, PointerButton, PointerEvent, PointerPhase, ScaleEvent, ScalePhase,
ScrollDelta,
};
use frust_core::input::WHEEL_LINE_PX;
use frust_core::insets::WindowInsets;
use frust_core::view::View;
use frust_reactive::{FrameWaker, ReactiveRuntime, TrackedScope, provide_context};
use frust_scene::{Scene, SceneBuilder};
use frust_shell_common::font_registry::FontRegistryWatcher;
use frust_shell_common::perf::UiSpans;
use frust_shell_common::{
SurfaceSize, ThemeOverrideWatcher, WindowMetricsPublisher, default_theme,
effective_brightness_for_platform_change, render_thread_enabled,
};
use frust_text::TextContext;
use frust_theme::{Brightness, Theme};
use kurbo::{Affine, Point, Size};
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalPosition, LogicalSize};
use winit::event::{ElementState, Ime, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{Key as WinitKey, ModifiersState, NamedKey as WinitNamedKey};
use winit::window::{
CursorIcon as WinitCursorIcon, ImePurpose, Theme as WinitTheme, Window, WindowAttributes,
WindowId,
};
use crate::config::DesktopConfig;
use crate::extensions::{CloseAction, DesktopExtensions, NoExtensions};
use crate::paced_wake::{ControlFlowIntent, next_paced_wake, paced_wake_action};
use crate::platform_view::DesktopPlatformViews;
use crate::render::FrameExecutor;
const INITIAL_SIZE: LogicalSize<u32> = LogicalSize::new(800, 600);
const WINDOW_SIZE_VAR: &str = "FRUST_WINDOW_SIZE";
const WINDOW_MAXIMIZED_VAR: &str = "FRUST_WINDOW_MAXIMIZED";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum WindowKnobSource {
Default,
Define,
Env,
}
impl std::fmt::Display for WindowKnobSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
WindowKnobSource::Default => "default",
WindowKnobSource::Define => "define",
WindowKnobSource::Env => "env",
})
}
}
fn resolved_window_knob(
compile_time: Option<&'static str>,
runtime: Option<String>,
) -> (Option<String>, WindowKnobSource) {
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|v| !v.is_empty())
}
if let Some(value) = non_empty(runtime) {
(Some(value), WindowKnobSource::Env)
} else if let Some(value) = non_empty(compile_time.map(str::to_string)) {
(Some(value), WindowKnobSource::Define)
} else {
(None, WindowKnobSource::Default)
}
}
fn parse_window_dimension(raw: &str) -> Option<u32> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
raw.parse::<u32>().ok().filter(|value| *value >= 1)
}
fn parse_window_size(raw: Option<&str>) -> LogicalSize<u32> {
let Some(trimmed) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
return INITIAL_SIZE;
};
if let Some((width_raw, height_raw)) = trimmed.split_once('x')
&& let (Some(width), Some(height)) = (
parse_window_dimension(width_raw),
parse_window_dimension(height_raw),
)
{
return LogicalSize::new(width, height);
}
log::warn!(
"frust-shell-desktop: invalid {WINDOW_SIZE_VAR} value {trimmed:?}, expected \
<width>x<height> in logical pixels (e.g. 1280x720) with both at least 1, falling back \
to {}x{}",
INITIAL_SIZE.width,
INITIAL_SIZE.height
);
INITIAL_SIZE
}
fn parse_window_maximized(raw: Option<&str>) -> bool {
raw.map(str::trim)
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true"))
}
fn window_size_config() -> (LogicalSize<u32>, bool, WindowKnobSource) {
static CONFIG: OnceLock<(LogicalSize<u32>, bool, WindowKnobSource)> = OnceLock::new();
*CONFIG.get_or_init(|| {
let (size_raw, size_source) = resolved_window_knob(
option_env!("FRUST_WINDOW_SIZE"),
std::env::var(WINDOW_SIZE_VAR).ok(),
);
let (maximized_raw, maximized_source) = resolved_window_knob(
option_env!("FRUST_WINDOW_MAXIMIZED"),
std::env::var(WINDOW_MAXIMIZED_VAR).ok(),
);
let size = parse_window_size(size_raw.as_deref());
let maximized = parse_window_maximized(maximized_raw.as_deref());
let source = size_source.max(maximized_source);
log::info!(
"frust-shell-desktop window size={}x{} logical maximized={maximized} source={source}",
size.width,
size.height
);
(size, maximized, source)
})
}
#[doc(hidden)]
#[derive(Debug)]
pub enum ShellUserEvent {
SignalsDirty,
Accessibility(AccessibilityEvent),
RenderNeedsRedraw,
RenderRecreateSurface,
RenderFatal(anyhow::Error),
ClipboardPaste(PasteText),
#[cfg(feature = "devtools")]
Devtools,
}
impl From<AccessibilityEvent> for ShellUserEvent {
fn from(event: AccessibilityEvent) -> Self {
ShellUserEvent::Accessibility(event)
}
}
#[doc(hidden)]
pub struct PasteText(String);
impl PasteText {
fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Debug for PasteText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("PasteText").field(&"<redacted>").finish()
}
}
const CLIPBOARD_SHUTDOWN_WAIT: Duration = Duration::from_millis(500);
enum ClipboardRequest {
Read,
Write(String),
Shutdown,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClipboardFailure {
Unavailable,
Unanswered,
ReadFailed,
WriteFailed,
}
struct ClipboardError {
kind: ClipboardFailure,
detail: String,
}
impl ClipboardError {
fn new(kind: ClipboardFailure, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
}
#[derive(Default)]
struct ClipboardWarnings {
unavailable: bool,
unanswered: bool,
read_failed: bool,
write_failed: bool,
}
impl ClipboardWarnings {
fn first_time(&mut self, kind: ClipboardFailure) -> bool {
let seen = match kind {
ClipboardFailure::Unavailable => &mut self.unavailable,
ClipboardFailure::Unanswered => &mut self.unanswered,
ClipboardFailure::ReadFailed => &mut self.read_failed,
ClipboardFailure::WriteFailed => &mut self.write_failed,
};
let first = !*seen;
*seen = true;
first
}
}
trait ClipboardAccess {
fn read(&mut self) -> Result<String, ClipboardError>;
fn write(&mut self, text: &str) -> Result<(), ClipboardError>;
}
#[derive(Default)]
struct ArboardClipboard {
clipboard: Option<arboard::Clipboard>,
}
impl ArboardClipboard {
fn get(&mut self) -> Result<&mut arboard::Clipboard, ClipboardError> {
if self.clipboard.is_none() {
self.clipboard = Some(arboard::Clipboard::new().map_err(|err| {
ClipboardError::new(
ClipboardFailure::Unavailable,
format!("the host clipboard is unavailable ({err})"),
)
})?);
}
self.clipboard.as_mut().ok_or_else(|| {
ClipboardError::new(
ClipboardFailure::Unavailable,
"the host clipboard is unavailable",
)
})
}
}
impl ClipboardAccess for ArboardClipboard {
fn read(&mut self) -> Result<String, ClipboardError> {
match self.get()?.get_text() {
Ok(text) => Ok(text),
Err(arboard::Error::ContentNotAvailable) => Err(ClipboardError::new(
ClipboardFailure::Unanswered,
"the host reported no text in the requested format",
)),
Err(err) => Err(ClipboardError::new(
ClipboardFailure::ReadFailed,
err.to_string(),
)),
}
}
fn write(&mut self, text: &str) -> Result<(), ClipboardError> {
self.get()?
.set_text(text)
.map_err(|err| ClipboardError::new(ClipboardFailure::WriteFailed, err.to_string()))
}
}
fn clipboard_warning(err: &ClipboardError, elapsed: Duration) -> String {
let ms = elapsed.as_millis();
match err.kind {
ClipboardFailure::Unavailable => format!(
"frust-shell-desktop: {}; copy/cut/paste no-op until it comes back \
(logged once)",
err.detail
),
ClipboardFailure::Unanswered => format!(
"frust-shell-desktop: the host clipboard produced no text for a paste after {ms} ms \
({}); the host reports the same error whether the clipboard is empty or its \
selection owner never answered, so the paste is dropped rather than guessed at \
(logged once)",
err.detail
),
ClipboardFailure::ReadFailed => format!(
"frust-shell-desktop: failed to read the host clipboard for a paste after {ms} ms \
({}); the paste is dropped (logged once)",
err.detail
),
ClipboardFailure::WriteFailed => format!(
"frust-shell-desktop: failed to write the host clipboard ({}); the copy/cut is lost \
(logged once)",
err.detail
),
}
}
struct ReadGate<'a>(&'a AtomicBool);
impl Drop for ReadGate<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
fn clipboard_loop<A: ClipboardAccess>(
requests: &Receiver<ClipboardRequest>,
access: &mut A,
answer: &mut dyn FnMut(ShellUserEvent),
read_in_flight: &AtomicBool,
) {
let mut warnings = ClipboardWarnings::default();
while let Ok(request) = requests.recv() {
let started = Instant::now();
match request {
ClipboardRequest::Read => {
let _gate = ReadGate(read_in_flight);
match access.read() {
Ok(text) => answer(ShellUserEvent::ClipboardPaste(PasteText(text))),
Err(err) => {
if warnings.first_time(err.kind) {
log::warn!("{}", clipboard_warning(&err, started.elapsed()));
}
}
}
}
ClipboardRequest::Write(text) => {
if let Err(err) = access.write(&text)
&& warnings.first_time(err.kind)
{
log::warn!("{}", clipboard_warning(&err, started.elapsed()));
}
}
ClipboardRequest::Shutdown => break,
}
}
}
fn spawn_clipboard_worker(
proxy: winit::event_loop::EventLoopProxy<ShellUserEvent>,
) -> Option<ClipboardWorker> {
let (requests_tx, requests_rx) = channel::<ClipboardRequest>();
let (stopped_tx, stopped_rx) = channel::<()>();
let read_in_flight = Arc::new(AtomicBool::new(false));
let worker_in_flight = Arc::clone(&read_in_flight);
let spawned = std::thread::Builder::new()
.name("frust-clipboard".to_string())
.spawn(move || {
let mut access = ArboardClipboard::default();
clipboard_loop(
&requests_rx,
&mut access,
&mut |event| {
let _ = proxy.send_event(event);
},
&worker_in_flight,
);
drop(access);
let _ = stopped_tx.send(());
});
match spawned {
Ok(_detached) => Some(ClipboardWorker {
requests: requests_tx,
stopped: stopped_rx,
read_in_flight,
}),
Err(err) => {
log::warn!(
"frust-shell-desktop: could not spawn the clipboard worker thread ({err}); \
copy/cut/paste no-op for the rest of this run"
);
None
}
}
}
struct ClipboardWorker {
requests: Sender<ClipboardRequest>,
stopped: Receiver<()>,
read_in_flight: Arc<AtomicBool>,
}
fn paste_input_event(text: PasteText) -> InputEvent {
InputEvent::EditCommand(EditCommand::Paste(text.into_string()))
}
fn paste_answer_dispatch(asked_in: Option<u64>, live: u64, text: PasteText) -> Option<InputEvent> {
(asked_in == Some(live)).then(move || paste_input_event(text))
}
pub fn run_desktop<State, Build, V>(state: State, build: Build) -> Result<()>
where
State: 'static,
V: View<State>,
Build: FnMut(&mut State) -> V + 'static,
{
run_desktop_with(state, build, DesktopConfig::default(), NoExtensions)
}
pub fn run_desktop_with<State, Build, V, E>(
state: State,
build: Build,
config: DesktopConfig,
mut extensions: E,
) -> Result<()>
where
State: 'static,
V: View<State>,
Build: FnMut(&mut State) -> V + 'static,
E: DesktopExtensions,
{
crate::logger::init_once();
let epoch = Instant::now();
let mut builder = EventLoop::<ShellUserEvent>::with_user_event();
extensions.on_event_loop_builder(&mut builder);
let event_loop = builder.build()?;
event_loop.set_control_flow(ControlFlow::Wait);
let proxy = event_loop.create_proxy();
let accesskit_proxy = proxy.clone();
let clipboard_proxy = proxy.clone();
let waker: FrameWaker = Arc::new(move || {
let _ = proxy.send_event(ShellUserEvent::SignalsDirty);
});
let runtime = ReactiveRuntime::init(waker);
#[cfg(feature = "devtools")]
{
let devtools_proxy = event_loop.create_proxy();
frust_shell_common::devtools::start(
frust_shell_common::devtools::app_name_from_process(),
Some(Box::new(move || {
let _ = devtools_proxy.send_event(ShellUserEvent::Devtools);
})),
);
}
let executor = if render_thread_enabled() {
let render_proxy = event_loop.create_proxy();
FrameExecutor::Split(crate::render::spawn_render_thread(render_proxy))
} else {
FrameExecutor::Inline(Box::default())
};
let mut handler = ShellHandler {
state,
build,
config,
extensions,
runtime,
scope: TrackedScope::new(),
root: RenderRoot::new(),
text_ctx: TextContext::new(),
executor,
scene: Scene::new(),
platform_views: DesktopPlatformViews::new(),
cursor: Point::ZERO,
modifiers: Modifiers::default(),
secondary_down_delivered: false,
compose: ComposeLatch::default(),
ime_sync: ImeSync::default(),
cursor_icon: CursorIcon::Default,
window: None,
accesskit_proxy,
clipboard_proxy,
adapter: None,
semantics_seen: 0,
fatal: None,
epoch,
theme: base_theme(default_theme()),
theme_seeded: false,
brightness_notified: None,
theme_override: ThemeOverrideWatcher::new(),
theme_override_active: false,
font_registry: FontRegistryWatcher::new(),
paced_wake: None,
anim_pacing: !frust_shell_common::anim_pacing_kill_switch_engaged(),
window_metrics: WindowMetricsPublisher::new(),
clipboard: None,
clipboard_unreachable_logged: false,
paste_session: None,
};
handler.font_registry.drain_into(&mut handler.text_ctx);
let loop_result = event_loop.run_app(&mut handler);
handler.shutdown_clipboard();
loop_result?;
finish(handler.fatal)
}
fn base_theme(seeded: Option<Theme>) -> Theme {
seeded.unwrap_or_else(Theme::neutral)
}
fn reverted_theme(seeded: Option<Theme>, platform: Brightness) -> Theme {
let mut theme = base_theme(seeded);
theme.brightness = platform;
theme
}
fn theme_after_override_poll(
polled: Option<Option<Theme>>,
seeded: impl FnOnce() -> Option<Theme>,
platform: impl FnOnce() -> Brightness,
) -> Option<(Theme, bool)> {
match polled {
Some(Some(theme)) => Some((theme, true)),
Some(None) => Some((reverted_theme(seeded(), platform()), false)),
None => None,
}
}
fn follow_platform_brightness(theme: &mut Theme, override_active: bool, platform: Brightness) {
theme.brightness =
effective_brightness_for_platform_change(override_active, theme.brightness, platform);
}
fn apply_window_size(
attributes: WindowAttributes,
size: LogicalSize<u32>,
maximized: bool,
) -> WindowAttributes {
let attributes = attributes.with_inner_size(size);
if maximized {
attributes.with_maximized(true)
} else {
attributes
}
}
fn window_attributes(
config: &DesktopConfig,
extensions: &mut impl DesktopExtensions,
) -> WindowAttributes {
let (size, maximized, _source) = window_size_config();
let attributes = WindowAttributes::default()
.with_title(config.window_title())
.with_visible(false);
let attributes = apply_window_size(attributes, size, maximized);
extensions.on_window_attributes(attributes)
}
fn brightness_change_to_notify(
last: Option<Brightness>,
current: Brightness,
) -> Option<Brightness> {
(last != Some(current)).then_some(current)
}
fn finish(fatal: Option<anyhow::Error>) -> Result<()> {
match fatal {
Some(err) => Err(err),
None => Ok(()),
}
}
fn apply_control_flow(event_loop: &ActiveEventLoop, intent: ControlFlowIntent) {
match intent {
ControlFlowIntent::Wait => event_loop.set_control_flow(ControlFlow::Wait),
ControlFlowIntent::WaitUntil(deadline) => {
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline))
}
ControlFlowIntent::Unchanged => {}
}
}
fn physical_to_logical(x: f64, y: f64, scale: f64) -> Point {
Point::new(x / scale, y / scale)
}
fn map_scroll_delta(delta: MouseScrollDelta, scale: f64) -> ScrollDelta {
match delta {
MouseScrollDelta::LineDelta(x, y) => ScrollDelta::Lines(-(x as f64), -(y as f64)),
MouseScrollDelta::PixelDelta(px) => ScrollDelta::Pixels(-px.x / scale, -px.y / scale),
}
}
const WHEEL_SCALE_RATE_PER_LINE: f64 = 0.1;
fn map_wheel_scale_delta(delta: MouseScrollDelta, scale: f64) -> f64 {
let lines = match delta {
MouseScrollDelta::LineDelta(_, y) => y as f64,
MouseScrollDelta::PixelDelta(px) => (px.y / scale) / WHEEL_LINE_PX,
};
(WHEEL_SCALE_RATE_PER_LINE * lines).exp()
}
fn wheel_zoom_chord_held(modifiers: Modifiers, is_macos: bool) -> bool {
if is_macos {
modifiers.meta
} else {
modifiers.ctrl
}
}
fn map_gesture_phase(phase: TouchPhase) -> ScalePhase {
match phase {
TouchPhase::Started => ScalePhase::Begin,
TouchPhase::Moved => ScalePhase::Update,
TouchPhase::Ended | TouchPhase::Cancelled => ScalePhase::End,
}
}
fn map_mouse_button(button: MouseButton) -> Option<PointerButton> {
match button {
MouseButton::Left => Some(PointerButton::Primary),
MouseButton::Right => Some(PointerButton::Secondary),
_ => None,
}
}
fn mouse_button_should_dispatch(
button: PointerButton,
phase: PointerPhase,
pointer_captured: bool,
secondary_down_delivered: &mut bool,
) -> bool {
if button != PointerButton::Secondary {
return true;
}
match phase {
PointerPhase::Down => {
let deliver = !pointer_captured;
*secondary_down_delivered = deliver;
deliver
}
PointerPhase::Up => {
let deliver = *secondary_down_delivered;
*secondary_down_delivered = false;
deliver
}
PointerPhase::Move | PointerPhase::Cancel => true,
}
}
fn map_named_key(key: WinitNamedKey) -> Option<NamedKey> {
Some(match key {
WinitNamedKey::Enter => NamedKey::Enter,
WinitNamedKey::Backspace => NamedKey::Backspace,
WinitNamedKey::Delete => NamedKey::Delete,
WinitNamedKey::ArrowLeft => NamedKey::ArrowLeft,
WinitNamedKey::ArrowRight => NamedKey::ArrowRight,
WinitNamedKey::ArrowUp => NamedKey::ArrowUp,
WinitNamedKey::ArrowDown => NamedKey::ArrowDown,
WinitNamedKey::Home => NamedKey::Home,
WinitNamedKey::End => NamedKey::End,
WinitNamedKey::Escape => NamedKey::Escape,
WinitNamedKey::Tab => NamedKey::Tab,
WinitNamedKey::Copy => NamedKey::Copy,
WinitNamedKey::Cut => NamedKey::Cut,
WinitNamedKey::Paste => NamedKey::Paste,
WinitNamedKey::Insert => NamedKey::Insert,
_ => return None,
})
}
fn brightness_from_winit(theme: Option<WinitTheme>) -> Brightness {
match theme {
Some(WinitTheme::Dark) => Brightness::Dark,
_ => Brightness::Light,
}
}
fn map_modifiers(state: ModifiersState) -> Modifiers {
Modifiers {
shift: state.shift_key(),
ctrl: state.control_key(),
alt: state.alt_key(),
meta: state.super_key(),
}
}
fn map_key_event(
logical_key: &WinitKey,
text: Option<&str>,
state: ElementState,
repeat: bool,
modifiers: Modifiers,
composing: bool,
) -> Option<KeyEvent> {
if state != ElementState::Pressed {
return None;
}
let key = match logical_key {
WinitKey::Named(WinitNamedKey::Space) => {
if composing {
return None;
}
Key::Character(text.unwrap_or(" ").to_string())
}
WinitKey::Named(named) => Key::Named(map_named_key(*named)?),
WinitKey::Character(resolved) if text.is_none() && (modifiers.ctrl || modifiers.meta) => {
if composing {
return None;
}
Key::Character(resolved.to_string())
}
_ => {
if composing {
return None;
}
Key::Character(text?.to_string())
}
};
Some(KeyEvent {
key,
modifiers,
repeat,
})
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct ComposeLatch {
active: bool,
}
impl ComposeLatch {
fn observe(&mut self, ime: &Ime) -> ImeEvent {
match ime {
Ime::Preedit(text, cursor) => {
self.active = !text.is_empty();
ImeEvent::Compose {
text: text.clone(),
cursor: *cursor,
}
}
Ime::Commit(text) => {
self.active = false;
ImeEvent::Commit(text.clone())
}
Ime::Enabled => {
self.active = false;
ImeEvent::Enabled
}
Ime::Disabled => {
self.active = false;
ImeEvent::Disabled
}
}
}
fn is_composing(&self) -> bool {
self.active
}
}
fn ime_purpose_for(content_type: ImeContentType) -> ImePurpose {
match content_type {
ImeContentType::Terminal => ImePurpose::Terminal,
other if other.is_secret() => ImePurpose::Password,
_ => ImePurpose::Normal,
}
}
fn winit_cursor_for(icon: CursorIcon) -> WinitCursorIcon {
match icon {
CursorIcon::Default => WinitCursorIcon::Default,
CursorIcon::Pointer => WinitCursorIcon::Pointer,
CursorIcon::Text => WinitCursorIcon::Text,
CursorIcon::Grab => WinitCursorIcon::Grab,
CursorIcon::Grabbing => WinitCursorIcon::Grabbing,
CursorIcon::ColResize => WinitCursorIcon::ColResize,
CursorIcon::RowResize => WinitCursorIcon::RowResize,
CursorIcon::NotAllowed => WinitCursorIcon::NotAllowed,
_ => WinitCursorIcon::Default,
}
}
fn cursor_change_to_apply(last: CursorIcon, current: CursorIcon) -> Option<WinitCursorIcon> {
(last != current).then(|| winit_cursor_for(current))
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
struct ImeSync {
allowed: bool,
cursor_area: Option<(LogicalPosition<f64>, LogicalSize<f64>)>,
purpose: ImePurpose,
}
struct ShellHandler<State: 'static, Build, V: View<State>, E> {
state: State,
build: Build,
config: DesktopConfig,
extensions: E,
runtime: &'static ReactiveRuntime,
scope: TrackedScope,
root: RenderRoot<State, V>,
text_ctx: TextContext,
executor: FrameExecutor,
scene: Scene,
platform_views: DesktopPlatformViews,
cursor: Point,
modifiers: Modifiers,
secondary_down_delivered: bool,
compose: ComposeLatch,
ime_sync: ImeSync,
cursor_icon: CursorIcon,
window: Option<Arc<Window>>,
accesskit_proxy: winit::event_loop::EventLoopProxy<ShellUserEvent>,
clipboard_proxy: winit::event_loop::EventLoopProxy<ShellUserEvent>,
adapter: Option<Adapter>,
semantics_seen: u64,
fatal: Option<anyhow::Error>,
epoch: Instant,
theme: Theme,
theme_seeded: bool,
brightness_notified: Option<Brightness>,
theme_override: ThemeOverrideWatcher,
theme_override_active: bool,
font_registry: FontRegistryWatcher,
paced_wake: Option<Instant>,
anim_pacing: bool,
window_metrics: WindowMetricsPublisher,
clipboard: Option<ClipboardWorker>,
clipboard_unreachable_logged: bool,
paste_session: Option<u64>,
}
impl<State, Build, V, E> ShellHandler<State, Build, V, E>
where
State: 'static,
V: View<State>,
Build: FnMut(&mut State) -> V + 'static,
E: DesktopExtensions,
{
fn dispatch(&mut self, window: &Window, event: InputEvent) {
let outcome = event_under_owner(self.runtime, &mut self.root, &mut self.state, &event);
self.sync_ime(window);
self.sync_cursor(window);
self.sync_clipboard();
if outcome.needs_redraw {
window.request_redraw();
}
}
fn sync_cursor(&mut self, window: &Window) {
let resolved = self.root.cursor();
if let Some(winit_icon) = cursor_change_to_apply(self.cursor_icon, resolved) {
window.set_cursor(winit_icon);
self.cursor_icon = resolved;
}
}
fn sync_ime(&mut self, window: &Window) {
let ime_state = self.root.ime_state();
let active = ime_state.as_ref().is_some_and(|s| s.active);
let purpose = ime_state
.as_ref()
.map_or(ImePurpose::Normal, |s| ime_purpose_for(s.content_type));
if active != self.ime_sync.allowed {
window.set_ime_allowed(active);
self.ime_sync.allowed = active;
if !active {
self.ime_sync.cursor_area = None;
}
}
if purpose != self.ime_sync.purpose {
window.set_ime_purpose(purpose);
self.ime_sync.purpose = purpose;
}
if active && let Some(caret) = ime_state.and_then(|s| s.caret) {
let area = (
LogicalPosition::new(caret.x0, caret.y0),
LogicalSize::new(caret.width(), caret.height()),
);
if self.ime_sync.cursor_area != Some(area) {
window.set_ime_cursor_area(area.0, area.1);
self.ime_sync.cursor_area = Some(area);
}
}
}
fn ensure_clipboard_worker(&mut self) {
if self.clipboard.is_none() && !self.clipboard_unreachable_logged {
self.clipboard = spawn_clipboard_worker(self.clipboard_proxy.clone());
self.clipboard_unreachable_logged = self.clipboard.is_none();
}
}
fn send_clipboard_request(&mut self, request: ClipboardRequest) {
self.ensure_clipboard_worker();
let worker_lost = self
.clipboard
.as_ref()
.is_some_and(|worker| worker.requests.send(request).is_err());
if worker_lost {
self.clipboard = None;
self.clipboard_unreachable_logged = true;
log::warn!(
"frust-shell-desktop: the clipboard worker thread has stopped; copy/cut/paste \
no-op for the rest of this run"
);
}
}
fn request_clipboard_read(&mut self) {
self.ensure_clipboard_worker();
self.paste_session = Some(self.root.focus_epoch());
let outstanding = self
.clipboard
.as_ref()
.is_some_and(|worker| worker.read_in_flight.swap(true, Ordering::AcqRel));
if outstanding {
log::debug!(
"frust-shell-desktop: a clipboard read is still outstanding; coalescing this \
paste request into it"
);
return;
}
self.send_clipboard_request(ClipboardRequest::Read);
}
fn sync_clipboard(&mut self) {
if let Some(text) = self.root.take_clipboard_write() {
self.send_clipboard_request(ClipboardRequest::Write(text));
}
if self.root.take_paste_request() {
self.request_clipboard_read();
}
}
fn shutdown_clipboard(&mut self) {
let Some(worker) = self.clipboard.take() else {
return;
};
if worker.requests.send(ClipboardRequest::Shutdown).is_ok() {
let _ = worker.stopped.recv_timeout(CLIPBOARD_SHUTDOWN_WAIT);
}
}
fn apply_theme(&mut self, window: &Window) {
self.root.set_theme(Box::new(self.theme.clone()));
let theme = self.theme.clone();
self.runtime.with_owner(move || provide_context(theme));
if let Some(brightness) =
brightness_change_to_notify(self.brightness_notified, self.theme.brightness)
{
self.brightness_notified = Some(brightness);
self.extensions.on_theme_brightness_changed(brightness);
}
window.request_redraw();
}
fn push_window_metrics(&mut self, physical: (u32, u32), scale: f64) {
let Some(metrics) = self
.window_metrics
.poll(physical, scale, WindowInsets::default())
else {
return; };
self.runtime.with_owner(move || provide_context(metrics));
}
fn handle_accessibility_event(&mut self, event: AccessibilityEvent) {
let Some(adapter) = self.adapter.as_mut() else {
return;
};
match event.window_event {
AccessibilityWindowEvent::InitialTreeRequested => {
let update = self.root.semantics();
self.semantics_seen = self.root.semantics_generation();
let scale = self
.window
.as_ref()
.map_or(1.0, |window| window.scale_factor());
adapter.update_if_active(|| build_tree_update(&update, scale));
}
AccessibilityWindowEvent::ActionRequested(request) => {
let runtime = self.runtime;
let outcome = runtime.with_owner(|| {
self.root.perform_accessibility_action(
&mut self.state,
request.target_node,
request.action,
)
});
if outcome.needs_redraw
&& let Some(window) = self.window.as_ref()
{
window.request_redraw();
}
}
AccessibilityWindowEvent::AccessibilityDeactivated => {}
}
}
}
fn event_under_owner<State: 'static, V: View<State>>(
runtime: &ReactiveRuntime,
root: &mut RenderRoot<State, V>,
state: &mut State,
event: &InputEvent,
) -> EventOutcome {
runtime.with_owner(|| root.event(state, event))
}
fn build_tree_update(update: &SemanticsUpdate, scale: f64) -> TreeUpdate {
let mut nodes = update.nodes.clone();
if scale != 1.0
&& let Some((_, root_node)) = nodes.iter_mut().find(|(id, _)| *id == update.root)
{
root_node.set_transform(Box::new(frust_core::accesskit::Affine::scale(scale)));
}
TreeUpdate {
nodes,
tree: Some(Tree::new(update.root)),
tree_id: TreeId::ROOT,
focus: update.focus_id(),
}
}
#[cfg(feature = "devtools")]
impl<State, Build, V, E> frust_shell_common::devtools::DevtoolsUi
for ShellHandler<State, Build, V, E>
where
State: 'static,
V: View<State>,
Build: FnMut(&mut State) -> V + 'static,
E: DesktopExtensions,
{
fn inspect(&self) -> Vec<frust_core::InspectNode> {
self.root.inspect()
}
fn dispatch(&mut self, event: InputEvent) {
if let Some(window) = self.window.clone() {
ShellHandler::dispatch(self, &window, event);
}
}
}
impl<State, Build, V, E> ApplicationHandler<ShellUserEvent> for ShellHandler<State, Build, V, E>
where
State: 'static,
V: View<State>,
Build: FnMut(&mut State) -> V + 'static,
E: DesktopExtensions,
{
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: ShellUserEvent) {
self.runtime.pump_local();
match event {
ShellUserEvent::SignalsDirty | ShellUserEvent::RenderNeedsRedraw => {
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
ShellUserEvent::Accessibility(event) => self.handle_accessibility_event(event),
ShellUserEvent::ClipboardPaste(text) => {
let asked_in = self.paste_session.take();
if let Some(window) = self.window.clone()
&& let Some(event) =
paste_answer_dispatch(asked_in, self.root.focus_epoch(), text)
{
self.dispatch(&window, event);
}
}
ShellUserEvent::RenderRecreateSurface => {
if let Some(window) = self.window.clone() {
let size = window.inner_size();
self.executor.recreate_surface(
&window,
SurfaceSize {
width: size.width.max(1),
height: size.height.max(1),
scale: window.scale_factor(),
},
);
self.platform_views.on_surface_recreated();
window.request_redraw();
}
}
ShellUserEvent::RenderFatal(err) => {
self.fatal = Some(err);
event_loop.exit();
}
#[cfg(feature = "devtools")]
ShellUserEvent::Devtools => frust_shell_common::devtools::pump(self),
}
}
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_none() {
let attrs = window_attributes(&self.config, &mut self.extensions);
match event_loop.create_window(attrs) {
Ok(window) => {
self.adapter = Some(Adapter::with_event_loop_proxy(
event_loop,
&window,
self.accesskit_proxy.clone(),
));
let window = Arc::new(window);
self.extensions.on_window_created(&window);
window.set_visible(true);
self.window = Some(window);
}
Err(err) => {
self.fatal =
Some(anyhow::Error::from(err).context("frust: failed to create window"));
event_loop.exit();
return;
}
}
}
let window = self
.window
.clone()
.expect("window was just created or already present");
if !self.executor.has_surface() {
let size = window.inner_size();
let surface_size = SurfaceSize {
width: size.width.max(1),
height: size.height.max(1),
scale: window.scale_factor(),
};
if let Err(err) = self.executor.ensure_surface(&window, surface_size) {
self.fatal = Some(err);
event_loop.exit();
return;
}
self.platform_views.on_surface_recreated();
}
if !self.theme_seeded {
self.theme.brightness = brightness_from_winit(window.theme());
self.theme_seeded = true;
self.apply_theme(&window);
} else {
window.request_redraw();
}
let physical = window.inner_size();
self.push_window_metrics((physical.width, physical.height), window.scale_factor());
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
self.platform_views.suspend(&mut self.extensions);
self.executor.destroy_surface();
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
self.runtime.pump_local();
let decision = paced_wake_action(self.paced_wake, Instant::now());
self.paced_wake = decision.paced_wake;
if decision.request_redraw
&& let Some(window) = self.window.as_ref()
{
window.request_redraw();
}
apply_control_flow(event_loop, decision.control_flow);
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
let Some(window) = self.window.clone() else {
return;
};
if let Some(adapter) = self.adapter.as_mut() {
adapter.process_event(&window, &event);
}
match event {
WindowEvent::CloseRequested => match self.extensions.on_close_requested() {
CloseAction::Exit => {
self.platform_views.suspend(&mut self.extensions);
event_loop.exit();
}
CloseAction::KeepRunning => {}
},
WindowEvent::Resized(size) => {
self.executor.resize_surface(SurfaceSize {
width: size.width,
height: size.height,
scale: window.scale_factor(),
});
self.push_window_metrics((size.width, size.height), window.scale_factor());
window.request_redraw();
}
WindowEvent::ThemeChanged(winit_theme) => {
follow_platform_brightness(
&mut self.theme,
self.theme_override_active,
brightness_from_winit(Some(winit_theme)),
);
self.apply_theme(&window);
}
WindowEvent::CursorMoved { position, .. } => {
let scale = window.scale_factor();
self.cursor = physical_to_logical(position.x, position.y, scale);
self.dispatch(
&window,
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Move,
position: self.cursor,
button: PointerButton::Primary,
}),
);
}
WindowEvent::MouseInput { state, button, .. } => {
let phase = match state {
ElementState::Pressed => PointerPhase::Down,
ElementState::Released => PointerPhase::Up,
};
if let Some(mapped_button) = map_mouse_button(button)
&& mouse_button_should_dispatch(
mapped_button,
phase,
self.root.is_pointer_captured(),
&mut self.secondary_down_delivered,
)
{
self.dispatch(
&window,
InputEvent::Pointer(PointerEvent {
phase,
position: self.cursor,
button: mapped_button,
}),
);
}
}
WindowEvent::MouseWheel { delta, phase, .. } => {
if wheel_zoom_chord_held(self.modifiers, cfg!(target_os = "macos")) {
let scale_delta = map_wheel_scale_delta(delta, window.scale_factor());
self.dispatch(
&window,
InputEvent::Scale(ScaleEvent {
phase: map_gesture_phase(phase),
scale_delta,
focal: self.cursor,
velocity: 0.0,
}),
);
} else {
let scroll = map_scroll_delta(delta, window.scale_factor());
self.dispatch(
&window,
InputEvent::Scroll {
position: self.cursor,
delta: scroll,
},
);
}
}
WindowEvent::PinchGesture { delta, phase, .. } => {
self.dispatch(
&window,
InputEvent::Scale(ScaleEvent {
phase: map_gesture_phase(phase),
scale_delta: 1.0 + delta,
focal: self.cursor,
velocity: 0.0,
}),
);
}
WindowEvent::ModifiersChanged(modifiers) => {
self.modifiers = map_modifiers(modifiers.state());
}
WindowEvent::KeyboardInput { event, .. } => {
if let Some(key_event) = map_key_event(
&event.logical_key,
event.text.as_deref(),
event.state,
event.repeat,
self.modifiers,
self.compose.is_composing(),
) {
self.dispatch(&window, InputEvent::Key(key_event));
}
}
WindowEvent::Ime(ime) => {
let mapped = self.compose.observe(&ime);
self.dispatch(&window, InputEvent::Ime(mapped));
}
WindowEvent::RedrawRequested => {
self.runtime.pump_local();
self.extensions.pump();
if let Some((theme, override_active)) =
theme_after_override_poll(self.theme_override.poll(), default_theme, || {
brightness_from_winit(window.theme())
})
{
self.theme = theme;
self.theme_override_active = override_active;
self.apply_theme(&window);
}
if self.font_registry.drain_into(&mut self.text_ctx) {
self.root.set_theme(Box::new(self.theme.clone()));
}
let rebuild_start = Instant::now();
let runtime = self.runtime;
let scope = &self.scope;
let root = &mut self.root;
let build = &mut self.build;
let state = &mut self.state;
let _flags = runtime.with_owner(|| scope.track(|| root.rebuild(build, state)));
let rebuild_dur = rebuild_start.elapsed();
self.executor.record_first_rebuild();
self.sync_ime(&window);
let physical = window.inner_size();
let scale = window.scale_factor();
let logical = Size::new(
physical.width as f64 / scale,
physical.height as f64 / scale,
);
let text_ctx: &mut dyn Any = &mut self.text_ctx;
let layout_start = Instant::now();
self.root.layout_with_text(logical, text_ctx);
let layout_dur = layout_start.elapsed();
if let Some(adapter) = self.adapter.as_mut()
&& let Some(update) = self.root.semantics_if_changed(self.semantics_seen)
{
self.semantics_seen = self.root.semantics_generation();
adapter.update_if_active(|| build_tree_update(&update, scale));
}
self.scene.reset();
let frame_time = FrameTime::from_nanos(self.epoch.elapsed().as_nanos() as u64);
self.root
.set_presented_frames(self.executor.presented_frames());
let paint_start = Instant::now();
let paint_outcome = {
let mut builder = SceneBuilder::new(&mut self.scene);
builder.push_transform(Affine::scale(scale));
let outcome = self.root.paint(&mut builder, frame_time);
builder.pop_transform();
outcome
};
let paint_dur = paint_start.elapsed();
self.platform_views.after_paint(&mut self.root);
let decision = next_paced_wake(
paint_outcome.needs_frame,
paint_outcome.needs_frame_paced_only,
self.anim_pacing,
Instant::now(),
self.theme.motion.cosmetic_loop_rate.hz(),
paint_outcome.paced_interval,
);
self.paced_wake = decision.paced_wake;
if decision.request_redraw {
window.request_redraw();
}
apply_control_flow(event_loop, decision.control_flow);
if self.scope.is_dirty() {
window.request_redraw();
}
let ui_spans = UiSpans {
rebuild: rebuild_dur,
layout: layout_dur,
paint: paint_dur,
skipped: false,
};
let base_color = self.theme.scheme().surface;
let size = SurfaceSize {
width: physical.width,
height: physical.height,
scale,
};
self.executor.submit_frame(
&window,
&mut self.scene,
base_color,
ui_spans,
frame_time,
size,
);
self.platform_views
.drain(&mut self.extensions, &window, scale);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::{
ClipboardAccess, ClipboardError, ClipboardFailure, ClipboardRequest, ClipboardWarnings,
ComposeLatch, DesktopConfig, DesktopExtensions, ElementState, Ime, ImeSync, LogicalSize,
MouseScrollDelta, NoExtensions, PasteText, ShellUserEvent, Tree, TreeId,
WHEEL_SCALE_RATE_PER_LINE, WindowKnobSource, WinitCursorIcon, WinitKey, WinitNamedKey,
WinitTheme, apply_window_size, base_theme, brightness_change_to_notify,
brightness_from_winit, build_tree_update, clipboard_loop, clipboard_warning,
cursor_change_to_apply, default_theme, finish, follow_platform_brightness, ime_purpose_for,
map_gesture_phase, map_key_event, map_modifiers, map_mouse_button, map_named_key,
map_scroll_delta, map_wheel_scale_delta, mouse_button_should_dispatch,
parse_window_maximized, parse_window_size, paste_answer_dispatch, paste_input_event,
physical_to_logical, resolved_window_knob, theme_after_override_poll,
wheel_zoom_chord_held, window_attributes, winit_cursor_for,
};
use frust_core::SemanticsUpdate;
use frust_core::accesskit::{
Affine as AccessKitAffine, Node, NodeId, Rect as AccessKitRect, Role,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use frust_core::event::{
CursorIcon, EditCommand, ImeContentType, ImeEvent, Key, KeyEvent, Modifiers, NamedKey,
PointerButton, ScalePhase, ScrollDelta,
};
use frust_theme::{Brightness, DesignLanguage, Theme};
use kurbo::Point;
use winit::dpi::PhysicalPosition;
use winit::event::MouseButton;
use winit::event::TouchPhase;
use winit::keyboard::ModifiersState;
use winit::window::{ImePurpose, WindowAttributes};
#[derive(Default)]
struct FakeClipboard {
read: Option<Result<String, ClipboardFailure>>,
written: Vec<String>,
reads: usize,
}
impl FakeClipboard {
fn answering(read: Result<String, ClipboardFailure>) -> Self {
Self {
read: Some(read),
..Self::default()
}
}
}
impl ClipboardAccess for FakeClipboard {
fn read(&mut self) -> Result<String, ClipboardError> {
self.reads += 1;
match self.read.clone() {
Some(Ok(text)) => Ok(text),
Some(Err(kind)) => Err(ClipboardError::new(kind, "fake host failure")),
None => Err(ClipboardError::new(
ClipboardFailure::Unanswered,
"fake host with nothing set",
)),
}
}
fn write(&mut self, text: &str) -> Result<(), ClipboardError> {
self.written.push(text.to_string());
Ok(())
}
}
fn run_clipboard_loop(
requests: Vec<ClipboardRequest>,
access: &mut FakeClipboard,
read_in_flight: &AtomicBool,
) -> Vec<ShellUserEvent> {
let (tx, rx) = std::sync::mpsc::channel();
for request in requests {
tx.send(request).expect("the receiver is still alive");
}
drop(tx);
let mut answers = Vec::new();
clipboard_loop(
&rx,
access,
&mut |event| answers.push(event),
read_in_flight,
);
answers
}
struct PanickingClipboard;
impl ClipboardAccess for PanickingClipboard {
fn read(&mut self) -> Result<String, ClipboardError> {
panic!("the platform clipboard backend panicked");
}
fn write(&mut self, _text: &str) -> Result<(), ClipboardError> {
Ok(())
}
}
#[test]
fn a_panicking_read_still_reopens_the_coalescing_gate() {
let read_in_flight = AtomicBool::new(true);
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(ClipboardRequest::Read)
.expect("the receiver is still alive");
drop(tx);
clipboard_loop(&rx, &mut PanickingClipboard, &mut |_| {}, &read_in_flight);
}));
std::panic::set_hook(hook);
assert!(
unwound.is_err(),
"the backend panic must propagate and end the worker, not be swallowed",
);
assert!(
!read_in_flight.load(Ordering::Acquire),
"the coalescing gate must re-open on an unwind, so the next paste request \
reaches send_clipboard_request and its failing send reports the dead worker",
);
}
#[test]
fn a_successful_clipboard_read_answers_exactly_one_paste() {
let mut access = FakeClipboard::answering(Ok("copied text".to_string()));
let read_in_flight = AtomicBool::new(true);
let answers =
run_clipboard_loop(vec![ClipboardRequest::Read], &mut access, &read_in_flight);
assert_eq!(answers.len(), 1, "one read, one answer: {answers:?}");
let text = match answers.into_iter().next() {
Some(ShellUserEvent::ClipboardPaste(text)) => text,
other => panic!("expected a ClipboardPaste answer, got {other:?}"),
};
match paste_input_event(text) {
InputEvent::EditCommand(EditCommand::Paste(pasted)) => {
assert_eq!(pasted, "copied text");
}
other => panic!("expected an EditCommand::Paste dispatch, got {other:?}"),
}
assert!(
!read_in_flight.load(Ordering::Acquire),
"the coalescing gate must re-open for the next paste"
);
}
#[test]
fn a_failed_clipboard_read_dispatches_nothing() {
for kind in [
ClipboardFailure::Unavailable,
ClipboardFailure::Unanswered,
ClipboardFailure::ReadFailed,
] {
let mut access = FakeClipboard::answering(Err(kind));
let read_in_flight = AtomicBool::new(true);
let answers =
run_clipboard_loop(vec![ClipboardRequest::Read], &mut access, &read_in_flight);
assert!(answers.is_empty(), "{kind:?} dispatched {answers:?}");
assert_eq!(access.reads, 1, "{kind:?} must still have tried once");
assert!(
!read_in_flight.load(Ordering::Acquire),
"{kind:?} must re-open the coalescing gate, or pasting stops for the run"
);
}
}
#[test]
fn a_clipboard_write_reaches_the_host_and_answers_nothing() {
let mut access = FakeClipboard::default();
let read_in_flight = AtomicBool::new(false);
let answers = run_clipboard_loop(
vec![ClipboardRequest::Write("copied".to_string())],
&mut access,
&read_in_flight,
);
assert!(answers.is_empty(), "a write is not an answer: {answers:?}");
assert_eq!(access.written, vec!["copied".to_string()]);
assert_eq!(access.reads, 0);
}
#[test]
fn clipboard_shutdown_abandons_what_was_queued_behind_it() {
let mut access = FakeClipboard::answering(Ok("late".to_string()));
let read_in_flight = AtomicBool::new(false);
let answers = run_clipboard_loop(
vec![ClipboardRequest::Shutdown, ClipboardRequest::Read],
&mut access,
&read_in_flight,
);
assert!(answers.is_empty(), "{answers:?}");
assert_eq!(
access.reads, 0,
"the read queued behind Shutdown never runs"
);
}
#[test]
fn clipboard_warnings_fire_once_per_kind() {
let mut warnings = ClipboardWarnings::default();
assert!(warnings.first_time(ClipboardFailure::Unanswered));
assert!(!warnings.first_time(ClipboardFailure::Unanswered));
assert!(warnings.first_time(ClipboardFailure::WriteFailed));
assert!(!warnings.first_time(ClipboardFailure::WriteFailed));
assert!(warnings.first_time(ClipboardFailure::Unavailable));
assert!(warnings.first_time(ClipboardFailure::ReadFailed));
}
#[test]
fn the_unanswered_warning_refuses_the_hosts_empty_clipboard_claim() {
let line = clipboard_warning(
&ClipboardError::new(ClipboardFailure::Unanswered, "the host reported no text"),
Duration::from_millis(24_005),
);
assert!(line.contains("24005 ms"), "{line}");
assert!(line.contains("never answered"), "{line}");
assert!(line.contains("dropped"), "{line}");
}
#[test]
fn a_paste_answer_never_logs_the_clipboard_text() {
let rendered = format!(
"{:?}",
ShellUserEvent::ClipboardPaste(PasteText("hunter2".to_string()))
);
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
}
struct PasteField {
pasted: Rc<RefCell<Vec<String>>>,
}
impl Widget for PasteField {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(100.0, 30.0))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
match event {
InputEvent::Pointer(p) if p.phase == PointerPhase::Down => {
ctx.request_focus();
EventResult::Handled
}
InputEvent::EditCommand(EditCommand::Paste(text)) => {
self.pasted.borrow_mut().push(text.clone());
EventResult::Handled
}
_ => EventResult::Ignored,
}
}
}
struct PasteFields {
first: frust_core::widget::ChildPod,
second: frust_core::widget::ChildPod,
}
impl Widget for PasteFields {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
self.first.layout_child(ctx, bc);
self.first.set_origin(Point::ZERO);
self.second.layout_child(ctx, bc);
self.second.set_origin(Point::new(0.0, 30.0));
bc.max()
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if event.is_focus_routed() {
for pod in [&mut self.first, &mut self.second] {
if pod.is_focused() {
return pod.event_child(ctx, event);
}
}
return EventResult::Ignored;
}
let position = event.position();
let blurs = matches!(
event,
InputEvent::Pointer(p) if p.phase == PointerPhase::Down
);
let mut result = EventResult::Ignored;
let mut hit = false;
for pod in [&mut self.first, &mut self.second] {
if !hit && pod.contains(position) {
hit = true;
result = pod.event_child(ctx, event);
} else if blurs {
pod.set_focused(false);
}
}
result
}
}
#[derive(Clone)]
struct PasteFieldsView {
first: Rc<RefCell<Vec<String>>>,
second: Rc<RefCell<Vec<String>>>,
}
impl View<()> for PasteFieldsView {
type Element = PasteFields;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> PasteFields {
PasteFields {
first: frust_core::widget::ChildPod::new(Box::new(PasteField {
pasted: self.first.clone(),
})),
second: frust_core::widget::ChildPod::new(Box::new(PasteField {
pasted: self.second.clone(),
})),
}
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut PasteFields,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
#[derive(Clone, Copy)]
enum PasteTarget {
First,
Second,
}
struct PasteHarness {
root: RenderRoot<(), PasteFieldsView>,
state: (),
first: Rc<RefCell<Vec<String>>>,
second: Rc<RefCell<Vec<String>>>,
}
impl PasteHarness {
fn new() -> Self {
let mut root: RenderRoot<(), PasteFieldsView> = RenderRoot::new();
let mut state = ();
let view = PasteFieldsView {
first: Rc::new(RefCell::new(Vec::new())),
second: Rc::new(RefCell::new(Vec::new())),
};
let (first, second) = (view.first.clone(), view.second.clone());
root.rebuild(&mut move |_: &mut ()| view.clone(), &mut state);
root.layout(Size::new(100.0, 60.0));
PasteHarness {
root,
state,
first,
second,
}
}
fn press(&mut self, target: PasteTarget) {
let y = match target {
PasteTarget::First => 15.0,
PasteTarget::Second => 45.0,
};
let press = InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: Point::new(50.0, y),
button: PointerButton::Primary,
});
self.root.event(&mut self.state, &press);
}
fn dispatch(&mut self, event: &InputEvent) {
self.root.event(&mut self.state, event);
}
fn pasted(&self) -> (Vec<String>, Vec<String>) {
(self.first.borrow().clone(), self.second.borrow().clone())
}
}
#[test]
fn a_paste_answer_still_lands_when_the_focus_session_never_moved() {
let mut harness = PasteHarness::new();
harness.press(PasteTarget::First);
let asked_in = Some(harness.root.focus_epoch());
let dispatch = paste_answer_dispatch(
asked_in,
harness.root.focus_epoch(),
PasteText("copied text".to_string()),
)
.expect("an answer arriving in the session that asked must still dispatch");
harness.dispatch(&dispatch);
let (first, second) = harness.pasted();
assert_eq!(
first,
vec!["copied text".to_string()],
"exactly one paste, with the text the worker read, into the field that asked"
);
assert!(second.is_empty(), "{second:?}");
}
#[test]
fn a_paste_answer_is_withheld_once_the_focus_session_has_moved_on() {
let mut harness = PasteHarness::new();
harness.press(PasteTarget::First);
let asked_in = Some(harness.root.focus_epoch());
harness.press(PasteTarget::Second);
assert_ne!(
Some(harness.root.focus_epoch()),
asked_in,
"fixture precondition: an honoured focus claim moves the session"
);
assert!(
paste_answer_dispatch(
asked_in,
harness.root.focus_epoch(),
PasteText("copied text".to_string()),
)
.is_none(),
"an answer whose session is gone must not become a dispatch"
);
let (first, second) = harness.pasted();
assert!(
first.is_empty() && second.is_empty(),
"nothing may be pasted at all: {first:?} {second:?}"
);
harness.dispatch(&paste_input_event(PasteText("copied text".to_string())));
let (first, second) = harness.pasted();
assert!(
first.is_empty(),
"the field that asked cannot be reached once focus has left it: {first:?}"
);
assert_eq!(
second,
vec!["copied text".to_string()],
"unguarded, the clip is typed into the field that never asked"
);
}
#[test]
fn brightness_from_winit_maps_dark_light_and_defaults() {
assert_eq!(
brightness_from_winit(Some(WinitTheme::Dark)),
Brightness::Dark
);
assert_eq!(
brightness_from_winit(Some(WinitTheme::Light)),
Brightness::Light
);
assert_eq!(brightness_from_winit(None), Brightness::Light);
}
#[test]
fn re_providing_theme_context_lets_a_live_flip_win() {
use frust_reactive::{Owner, provide_context, use_context};
let owner = Owner::new();
let resolved = owner.with(|| {
let mut light = Theme::neutral();
light.brightness = Brightness::Light;
provide_context(light);
let mut dark = Theme::neutral();
dark.brightness = Brightness::Dark;
provide_context(dark);
use_context::<Theme>()
});
assert_eq!(resolved.map(|t| t.brightness), Some(Brightness::Dark));
}
#[test]
fn physical_to_logical_divides_by_scale() {
assert_eq!(
physical_to_logical(200.0, 100.0, 2.0),
Point::new(100.0, 50.0)
);
}
#[test]
fn physical_to_logical_is_identity_at_unit_scale() {
assert_eq!(physical_to_logical(37.0, 12.0, 1.0), Point::new(37.0, 12.0));
}
#[test]
fn map_mouse_button_maps_left_to_primary() {
assert_eq!(
map_mouse_button(MouseButton::Left),
Some(PointerButton::Primary)
);
}
#[test]
fn map_mouse_button_maps_right_to_secondary() {
assert_eq!(
map_mouse_button(MouseButton::Right),
Some(PointerButton::Secondary)
);
}
#[test]
fn map_mouse_button_drops_middle_back_and_forward() {
assert_eq!(map_mouse_button(MouseButton::Middle), None);
assert_eq!(map_mouse_button(MouseButton::Back), None);
assert_eq!(map_mouse_button(MouseButton::Forward), None);
}
fn secondary(phase: PointerPhase, captured: bool, latch: &mut bool) -> bool {
mouse_button_should_dispatch(PointerButton::Secondary, phase, captured, latch)
}
#[test]
fn an_uncaptured_secondary_press_and_its_release_both_dispatch() {
let mut latch = false;
assert!(secondary(PointerPhase::Down, false, &mut latch));
assert!(secondary(PointerPhase::Up, false, &mut latch));
assert!(!latch);
}
#[test]
fn a_secondary_press_is_dropped_while_captured_and_so_is_its_release() {
let mut latch = false;
assert!(!secondary(PointerPhase::Down, true, &mut latch));
assert!(!secondary(PointerPhase::Up, true, &mut latch));
assert!(!secondary(PointerPhase::Up, false, &mut latch));
}
#[test]
fn a_delivered_secondary_press_gets_its_release_even_if_a_capture_opened() {
let mut latch = false;
assert!(secondary(PointerPhase::Down, false, &mut latch));
assert!(secondary(PointerPhase::Up, true, &mut latch));
assert!(!latch);
}
#[test]
fn a_secondary_release_without_a_delivered_press_never_dispatches() {
let mut latch = false;
assert!(!secondary(PointerPhase::Up, false, &mut latch));
assert!(!secondary(PointerPhase::Up, true, &mut latch));
}
#[test]
fn a_second_secondary_press_re_arms_the_latch_rather_than_stacking() {
let mut latch = false;
assert!(secondary(PointerPhase::Down, false, &mut latch));
assert!(!secondary(PointerPhase::Down, true, &mut latch));
assert!(!secondary(PointerPhase::Up, false, &mut latch));
}
#[test]
fn primary_always_dispatches_captured_or_not_and_leaves_the_latch_alone() {
let mut latch = true;
for captured in [false, true] {
for phase in [PointerPhase::Down, PointerPhase::Up] {
assert!(mouse_button_should_dispatch(
PointerButton::Primary,
phase,
captured,
&mut latch
));
}
}
assert!(latch);
}
#[test]
fn map_scroll_delta_negates_wheel_lines() {
assert_eq!(
map_scroll_delta(MouseScrollDelta::LineDelta(0.0, 1.0), 1.0),
ScrollDelta::Lines(0.0, -1.0)
);
assert_eq!(
map_scroll_delta(MouseScrollDelta::LineDelta(2.0, -3.0), 1.0),
ScrollDelta::Lines(-2.0, 3.0)
);
}
#[test]
fn map_scroll_delta_negates_and_scales_pixel_deltas() {
assert_eq!(
map_scroll_delta(
MouseScrollDelta::PixelDelta(PhysicalPosition::new(20.0, 60.0)),
2.0
),
ScrollDelta::Pixels(-10.0, -30.0)
);
}
#[test]
fn map_wheel_scale_delta_wheel_up_zooms_in() {
let zoom_in = map_wheel_scale_delta(MouseScrollDelta::LineDelta(0.0, 1.0), 1.0);
assert!(zoom_in > 1.0, "wheel up must zoom in, got {zoom_in}");
assert_eq!(zoom_in, (WHEEL_SCALE_RATE_PER_LINE).exp());
}
#[test]
fn map_wheel_scale_delta_wheel_down_zooms_out() {
let zoom_out = map_wheel_scale_delta(MouseScrollDelta::LineDelta(0.0, -1.0), 1.0);
assert!(zoom_out < 1.0, "wheel down must zoom out, got {zoom_out}");
assert_eq!(zoom_out, (-WHEEL_SCALE_RATE_PER_LINE).exp());
}
#[test]
fn map_wheel_scale_delta_is_a_no_op_at_zero_lines() {
assert_eq!(
map_wheel_scale_delta(MouseScrollDelta::LineDelta(0.0, 0.0), 1.0),
1.0
);
}
#[test]
fn map_wheel_scale_delta_scales_pixel_deltas_by_the_line_height() {
let one_line = map_wheel_scale_delta(
MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 80.0)),
2.0,
);
assert_eq!(one_line, WHEEL_SCALE_RATE_PER_LINE.exp());
}
#[test]
fn wheel_zoom_chord_held_gates_on_meta_for_macos_and_ctrl_elsewhere() {
let ctrl_only = Modifiers {
ctrl: true,
..Modifiers::default()
};
let meta_only = Modifiers {
meta: true,
..Modifiers::default()
};
assert!(wheel_zoom_chord_held(meta_only, true));
assert!(!wheel_zoom_chord_held(ctrl_only, true));
assert!(wheel_zoom_chord_held(ctrl_only, false));
assert!(!wheel_zoom_chord_held(meta_only, false));
assert!(!wheel_zoom_chord_held(Modifiers::default(), true));
assert!(!wheel_zoom_chord_held(Modifiers::default(), false));
}
#[test]
fn map_gesture_phase_brackets_started_moved_ended() {
assert_eq!(map_gesture_phase(TouchPhase::Started), ScalePhase::Begin);
assert_eq!(map_gesture_phase(TouchPhase::Moved), ScalePhase::Update);
assert_eq!(map_gesture_phase(TouchPhase::Ended), ScalePhase::End);
assert_eq!(map_gesture_phase(TouchPhase::Cancelled), ScalePhase::End);
}
#[test]
fn finish_propagates_fatal_error_with_context() {
let err = anyhow::anyhow!("boom").context("frust: failed to create window");
let result = finish(Some(err));
let err = result.expect_err("a stashed fatal error must surface as Err");
assert_eq!(
err.to_string(),
"frust: failed to create window",
"the outermost context string must be preserved"
);
assert_eq!(
err.chain().last().unwrap().to_string(),
"boom",
"the underlying cause must still be reachable via the error chain"
);
}
#[test]
fn finish_is_ok_on_the_happy_path() {
assert!(finish(None).is_ok());
}
#[test]
fn map_named_key_covers_every_editing_named_key() {
assert_eq!(map_named_key(WinitNamedKey::Enter), Some(NamedKey::Enter));
assert_eq!(
map_named_key(WinitNamedKey::Backspace),
Some(NamedKey::Backspace)
);
assert_eq!(map_named_key(WinitNamedKey::Delete), Some(NamedKey::Delete));
assert_eq!(
map_named_key(WinitNamedKey::ArrowLeft),
Some(NamedKey::ArrowLeft)
);
assert_eq!(
map_named_key(WinitNamedKey::ArrowRight),
Some(NamedKey::ArrowRight)
);
assert_eq!(
map_named_key(WinitNamedKey::ArrowUp),
Some(NamedKey::ArrowUp)
);
assert_eq!(
map_named_key(WinitNamedKey::ArrowDown),
Some(NamedKey::ArrowDown)
);
assert_eq!(map_named_key(WinitNamedKey::Home), Some(NamedKey::Home));
assert_eq!(map_named_key(WinitNamedKey::End), Some(NamedKey::End));
assert_eq!(map_named_key(WinitNamedKey::Escape), Some(NamedKey::Escape));
assert_eq!(map_named_key(WinitNamedKey::Tab), Some(NamedKey::Tab));
assert_eq!(map_named_key(WinitNamedKey::Copy), Some(NamedKey::Copy));
assert_eq!(map_named_key(WinitNamedKey::Cut), Some(NamedKey::Cut));
assert_eq!(map_named_key(WinitNamedKey::Paste), Some(NamedKey::Paste));
assert_eq!(map_named_key(WinitNamedKey::Insert), Some(NamedKey::Insert));
}
#[test]
fn map_named_key_is_none_for_unmapped_named_keys() {
assert_eq!(map_named_key(WinitNamedKey::F1), None);
assert_eq!(map_named_key(WinitNamedKey::Alt), None);
assert_eq!(map_named_key(WinitNamedKey::CapsLock), None);
}
#[test]
fn map_modifiers_translates_each_flag_independently() {
assert_eq!(
map_modifiers(ModifiersState::SHIFT),
Modifiers {
shift: true,
ctrl: false,
alt: false,
meta: false,
}
);
assert_eq!(
map_modifiers(ModifiersState::CONTROL | ModifiersState::SUPER),
Modifiers {
shift: false,
ctrl: true,
alt: false,
meta: true,
}
);
assert_eq!(map_modifiers(ModifiersState::empty()), Modifiers::default());
}
fn mods() -> Modifiers {
Modifiers {
shift: true,
..Modifiers::default()
}
}
#[test]
fn map_key_event_maps_a_named_key() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Backspace),
None,
ElementState::Pressed,
false,
mods(),
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Named(NamedKey::Backspace),
modifiers: mods(),
repeat: false,
})
);
}
#[test]
fn map_key_event_maps_resolved_text_as_character() {
let event = map_key_event(
&WinitKey::Character("a".into()),
Some("a"),
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Character("a".to_string()),
modifiers: Modifiers::default(),
repeat: false,
})
);
}
#[test]
fn map_key_event_falls_back_to_the_logical_key_when_ctrl_withholds_text() {
let event = map_key_event(
&WinitKey::Character("c".into()),
None,
ElementState::Pressed,
false,
Modifiers {
ctrl: true,
..Modifiers::default()
},
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Character("c".to_string()),
modifiers: Modifiers {
ctrl: true,
..Modifiers::default()
},
repeat: false,
})
);
}
#[test]
fn map_key_event_falls_back_to_the_logical_key_when_meta_withholds_text() {
let event = map_key_event(
&WinitKey::Character("c".into()),
None,
ElementState::Pressed,
false,
Modifiers {
meta: true,
..Modifiers::default()
},
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Character("c".to_string()),
modifiers: Modifiers {
meta: true,
..Modifiers::default()
},
repeat: false,
})
);
}
#[test]
fn map_key_event_drops_a_textless_character_with_no_ctrl_or_meta() {
let event = map_key_event(
&WinitKey::Character("c".into()),
None,
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(event, None);
}
#[test]
fn map_key_event_ctrl_fallback_still_drops_while_composing() {
let event = map_key_event(
&WinitKey::Character("c".into()),
None,
ElementState::Pressed,
false,
Modifiers {
ctrl: true,
..Modifiers::default()
},
true, );
assert_eq!(event, None);
}
#[test]
fn map_key_event_passes_repeat_through_unfiltered() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::ArrowLeft),
None,
ElementState::Pressed,
true,
Modifiers::default(),
false,
);
assert!(event.expect("named key maps").repeat);
}
#[test]
fn map_key_event_is_none_on_release() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Enter),
None,
ElementState::Released,
false,
Modifiers::default(),
false,
);
assert_eq!(event, None);
}
#[test]
fn map_key_event_drops_character_while_composing() {
let event = map_key_event(
&WinitKey::Character("a".into()),
Some("a"),
ElementState::Pressed,
false,
Modifiers::default(),
true, );
assert_eq!(event, None);
}
#[test]
fn map_key_event_still_maps_named_keys_while_composing() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Escape),
None,
ElementState::Pressed,
false,
Modifiers::default(),
true, );
assert_eq!(
event,
Some(KeyEvent {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
})
);
}
#[test]
fn map_key_event_maps_space_to_a_character() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Space),
Some(" "),
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Character(" ".to_string()),
modifiers: Modifiers::default(),
repeat: false,
})
);
}
#[test]
fn map_key_event_maps_space_without_a_text_payload() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Space),
None,
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(
event,
Some(KeyEvent {
key: Key::Character(" ".to_string()),
modifiers: Modifiers::default(),
repeat: false,
})
);
}
#[test]
fn map_key_event_drops_space_while_composing() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::Space),
Some(" "),
ElementState::Pressed,
false,
Modifiers::default(),
true, );
assert_eq!(event, None);
}
#[test]
fn map_key_event_is_none_for_unmapped_named_key_with_no_text() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::F1),
None,
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(event, None);
}
#[test]
fn map_key_event_is_none_for_unmapped_named_key_carrying_text() {
let event = map_key_event(
&WinitKey::Named(WinitNamedKey::F1),
Some("\u{f704}"),
ElementState::Pressed,
false,
Modifiers::default(),
false,
);
assert_eq!(event, None);
}
#[test]
fn compose_latch_opens_on_nonempty_preedit() {
let mut latch = ComposeLatch::default();
assert!(!latch.is_composing());
let mapped = latch.observe(&Ime::Preedit("n".to_string(), Some((0, 1))));
assert!(latch.is_composing());
assert_eq!(
mapped,
ImeEvent::Compose {
text: "n".to_string(),
cursor: Some((0, 1)),
}
);
}
#[test]
fn compose_latch_closes_on_empty_preedit_preceding_commit() {
let mut latch = ComposeLatch::default();
latch.observe(&Ime::Preedit("n".to_string(), Some((0, 1))));
assert!(latch.is_composing());
let mapped = latch.observe(&Ime::Preedit(String::new(), None));
assert!(!latch.is_composing());
assert_eq!(
mapped,
ImeEvent::Compose {
text: String::new(),
cursor: None,
}
);
}
#[test]
fn compose_latch_closes_on_commit() {
let mut latch = ComposeLatch::default();
latch.observe(&Ime::Preedit("n".to_string(), Some((0, 1))));
let mapped = latch.observe(&Ime::Commit("\u{306a}".to_string()));
assert!(!latch.is_composing());
assert_eq!(mapped, ImeEvent::Commit("\u{306a}".to_string()));
}
#[test]
fn compose_latch_closes_on_enabled_and_disabled() {
let mut latch = ComposeLatch::default();
latch.observe(&Ime::Preedit("n".to_string(), Some((0, 1))));
let mapped = latch.observe(&Ime::Disabled);
assert!(!latch.is_composing());
assert_eq!(mapped, ImeEvent::Disabled);
latch.observe(&Ime::Preedit("n".to_string(), Some((0, 1))));
let mapped = latch.observe(&Ime::Enabled);
assert!(!latch.is_composing());
assert_eq!(mapped, ImeEvent::Enabled);
}
#[test]
fn ime_purpose_maps_password_to_the_winit_password_purpose() {
assert_eq!(
ime_purpose_for(ImeContentType::Password),
ImePurpose::Password
);
}
#[test]
fn ime_purpose_maps_terminal_to_the_winit_terminal_purpose() {
assert_eq!(
ime_purpose_for(ImeContentType::Terminal),
ImePurpose::Terminal
);
}
#[test]
fn ime_purpose_has_no_distinct_mapping_for_normal_or_no_suggestions() {
assert_eq!(ime_purpose_for(ImeContentType::Normal), ImePurpose::Normal);
assert_eq!(
ime_purpose_for(ImeContentType::NoSuggestions),
ImePurpose::Normal
);
}
#[test]
fn ime_sync_defaults_to_the_normal_purpose() {
assert_eq!(ImeSync::default().purpose, ImePurpose::Normal);
}
#[test]
fn every_framework_cursor_maps_to_its_winit_counterpart() {
for (ours, theirs) in [
(CursorIcon::Default, WinitCursorIcon::Default),
(CursorIcon::Pointer, WinitCursorIcon::Pointer),
(CursorIcon::Text, WinitCursorIcon::Text),
(CursorIcon::Grab, WinitCursorIcon::Grab),
(CursorIcon::Grabbing, WinitCursorIcon::Grabbing),
(CursorIcon::ColResize, WinitCursorIcon::ColResize),
(CursorIcon::RowResize, WinitCursorIcon::RowResize),
(CursorIcon::NotAllowed, WinitCursorIcon::NotAllowed),
] {
assert_eq!(
winit_cursor_for(ours),
theirs,
"{ours:?} must reach the platform as {theirs:?}"
);
}
}
#[test]
fn the_cursor_is_pushed_to_winit_only_on_a_change() {
assert_eq!(
cursor_change_to_apply(CursorIcon::Default, CursorIcon::Default),
None,
"an unmoved cursor issues no winit call"
);
assert_eq!(
cursor_change_to_apply(CursorIcon::Pointer, CursorIcon::Pointer),
None
);
assert_eq!(
cursor_change_to_apply(CursorIcon::Default, CursorIcon::Pointer),
Some(WinitCursorIcon::Pointer)
);
assert_eq!(
cursor_change_to_apply(CursorIcon::Pointer, CursorIcon::Default),
Some(WinitCursorIcon::Default),
"dropping a request restores the platform arrow"
);
}
#[test]
fn build_tree_update_assembles_nodes_tree_and_focus() {
let root_id = NodeId(1);
let child_id = NodeId(2);
let mut root_node = Node::new(Role::Window);
root_node.set_children(vec![child_id]);
let child_node = Node::new(Role::Button);
let update = SemanticsUpdate {
nodes: vec![(root_id, root_node), (child_id, child_node)],
root: root_id,
focus: Some(child_id),
};
let tree_update = build_tree_update(&update, 1.0);
assert_eq!(tree_update.nodes.len(), 2);
assert_eq!(tree_update.tree, Some(Tree::new(root_id)));
assert_eq!(tree_update.tree_id, TreeId::ROOT);
assert_eq!(tree_update.focus, child_id);
}
#[test]
fn build_tree_update_defaults_focus_to_root_when_nothing_focused() {
let root_id = NodeId(1);
let update = SemanticsUpdate {
nodes: vec![(root_id, Node::new(Role::Window))],
root: root_id,
focus: None,
};
let tree_update = build_tree_update(&update, 1.0);
assert_eq!(tree_update.focus, root_id);
}
#[test]
fn build_tree_update_at_scale_one_stamps_no_transform() {
let root_id = NodeId(1);
let update = SemanticsUpdate {
nodes: vec![(root_id, Node::new(Role::Window))],
root: root_id,
focus: None,
};
let tree_update = build_tree_update(&update, 1.0);
let (_, root_node) = &tree_update.nodes[0];
assert!(
root_node.transform().is_none(),
"scale 1.0 must not stamp a transform onto the root"
);
}
#[test]
fn build_tree_update_at_scale_two_stamps_root_transform_only() {
let root_id = NodeId(1);
let child_id = NodeId(2);
let mut root_node = Node::new(Role::Window);
root_node.set_children(vec![child_id]);
let mut child_node = Node::new(Role::Button);
let child_bounds = AccessKitRect::new(1.0, 2.0, 3.0, 4.0);
child_node.set_bounds(child_bounds);
let update = SemanticsUpdate {
nodes: vec![(root_id, root_node), (child_id, child_node)],
root: root_id,
focus: None,
};
let tree_update = build_tree_update(&update, 2.0);
let (found_root_id, found_root_node) = &tree_update.nodes[0];
assert_eq!(*found_root_id, root_id);
assert_eq!(
found_root_node.transform(),
Some(&AccessKitAffine::scale(2.0)),
"scale 2.0 must stamp Affine::scale(2.0) onto the root"
);
let (found_child_id, found_child_node) = &tree_update.nodes[1];
assert_eq!(*found_child_id, child_id);
assert!(
found_child_node.transform().is_none(),
"only the root gets a transform — children inherit it through the \
ancestor chain rather than being rescaled directly"
);
assert_eq!(found_child_node.bounds(), Some(child_bounds));
}
fn seeded_design_system_theme() -> Theme {
Theme::builder(Theme::neutral())
.design_language(DesignLanguage::Custom("test-design-system"))
.build()
}
#[test]
fn an_unseeded_shell_starts_on_the_builtin_fallback_at_the_platform_brightness() {
let mut theme = base_theme(None);
assert_eq!(theme, Theme::neutral());
follow_platform_brightness(&mut theme, false, brightness_from_winit(None));
assert_eq!(theme, Theme::neutral().with_brightness(Brightness::Light));
follow_platform_brightness(
&mut theme,
false,
brightness_from_winit(Some(WinitTheme::Dark)),
);
assert_eq!(theme, Theme::neutral().with_brightness(Brightness::Dark));
}
#[test]
fn a_seeded_default_is_the_base_and_still_follows_platform_brightness() {
let seeded = seeded_design_system_theme();
let mut theme = base_theme(Some(seeded.clone()));
assert_eq!(theme, seeded);
assert_ne!(theme, Theme::neutral());
follow_platform_brightness(
&mut theme,
false,
brightness_from_winit(Some(WinitTheme::Dark)),
);
assert_eq!(theme, seeded.clone().with_brightness(Brightness::Dark));
follow_platform_brightness(
&mut theme,
false,
brightness_from_winit(Some(WinitTheme::Light)),
);
assert_eq!(theme, seeded.with_brightness(Brightness::Light));
}
#[test]
fn an_app_theme_override_beats_a_seeded_default_and_pins_brightness() {
let forced = Theme::neutral().with_brightness(Brightness::Light);
let decided = theme_after_override_poll(
Some(Some(forced.clone())),
|| panic!("an active override must not consult the seeded default"),
|| panic!("an active override must not consult the platform brightness"),
);
assert_eq!(decided, Some((forced, true)));
let mut active = Theme::neutral().with_brightness(Brightness::Light);
follow_platform_brightness(&mut active, true, Brightness::Dark);
assert_eq!(active.brightness, Brightness::Light);
}
#[test]
fn clearing_an_override_reverts_to_the_seeded_default_not_the_builtin() {
let seeded = seeded_design_system_theme();
let decided = theme_after_override_poll(
Some(None),
|| Some(seeded.clone()),
|| brightness_from_winit(Some(WinitTheme::Dark)),
);
assert_eq!(
decided,
Some((seeded.with_brightness(Brightness::Dark), false))
);
assert_ne!(
decided.map(|(theme, _)| theme),
Some(Theme::neutral().with_brightness(Brightness::Dark))
);
}
#[test]
fn clearing_an_override_with_nothing_seeded_reverts_to_the_builtin() {
let decided = theme_after_override_poll(
Some(None),
|| None,
|| brightness_from_winit(Some(WinitTheme::Dark)),
);
assert_eq!(
decided,
Some((Theme::neutral().with_brightness(Brightness::Dark), false))
);
}
#[test]
fn a_poll_reporting_no_change_leaves_the_active_theme_alone() {
let decided = theme_after_override_poll(
None,
|| panic!("an unchanged poll must not read the process-global default slot"),
|| panic!("an unchanged poll must not query the window's appearance"),
);
assert_eq!(decided, None);
}
#[test]
fn a_seeded_default_reaches_both_ladder_sites_through_the_process_global_slot() {
assert_eq!(
default_theme(),
None,
"another test in this binary seeded the process-global default slot first — \
the slot has no reset, so tests that read it cannot be order-independent"
);
let seeded = seeded_design_system_theme();
frust_shell_common::set_default_theme(seeded.clone());
assert_eq!(base_theme(default_theme()), seeded);
assert_eq!(
theme_after_override_poll(Some(None), default_theme, || Brightness::Dark),
Some((seeded.with_brightness(Brightness::Dark), false))
);
}
#[test]
fn the_default_slot_has_exactly_one_test_writer() {
let needle = concat!("set_default_theme", "(");
let writers = include_str!("app_handler.rs")
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.filter(|line| line.contains(needle))
.count();
assert_eq!(
writers, 1,
"the process-global default-theme slot has no reset, so exactly one test in this \
binary may write it (found {writers}); serialize them behind a lock the way \
`frust-shell-common::theme_override`'s own tests do, or fold the new assertion \
into the existing writer"
);
}
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use frust_core::event::{EventCtx, EventResult, PointerEvent, PointerPhase};
use frust_core::layout::BoxConstraints;
use frust_core::view::BuildCtx;
use frust_core::widget::{LayoutCtx, PaintCtx, Widget};
use frust_core::{ChangeFlags, PaintScene, RenderRoot, View};
use frust_reactive::{FrameWaker, ReactiveRuntime, RwSignal, TrackedScope, use_context};
use kurbo::Size;
use reactive_graph::owner::expect_context;
use reactive_graph::traits::{Get, Set};
use super::{InputEvent, event_under_owner, provide_context};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ProbeCtx(u32);
#[derive(Debug, Default)]
struct Observed {
used: Option<u32>,
expect_panic: Option<String>,
}
type Seen = Rc<RefCell<Observed>>;
struct ProbeWidget {
seen: Seen,
handler_signal: RwSignal<u32>,
}
impl Widget for ProbeWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(40.0, 20.0))
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
fn event(&mut self, _ctx: &mut EventCtx, _event: &InputEvent) -> EventResult {
let used = use_context::<ProbeCtx>().map(|c| c.0);
let expect_panic =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(expect_context::<ProbeCtx>))
.err()
.map(|payload| match payload.downcast::<String>() {
Ok(msg) => *msg,
Err(_) => "<non-string panic payload>".to_string(),
});
let _ = self.handler_signal.get();
*self.seen.borrow_mut() = Observed { used, expect_panic };
EventResult::Handled
}
}
struct ProbeView {
seen: Seen,
handler_signal: RwSignal<u32>,
}
impl View<()> for ProbeView {
type Element = ProbeWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> ProbeWidget {
ProbeWidget {
seen: self.seen.clone(),
handler_signal: self.handler_signal,
}
}
fn rebuild(
&self,
_prev: &Self,
element: &mut ProbeWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.seen = self.seen.clone();
ChangeFlags::NONE
}
}
fn press() -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Down,
position: kurbo::Point::new(20.0, 10.0),
button: PointerButton::Primary,
})
}
#[expect(
clippy::type_complexity,
reason = "test fixture tuple, read once below"
)]
fn fixture() -> (
&'static ReactiveRuntime,
RenderRoot<(), ProbeView>,
Seen,
TrackedScope,
RwSignal<u32>,
RwSignal<u32>,
) {
let waker: FrameWaker = Arc::new(|| {});
let runtime = ReactiveRuntime::init(waker);
runtime.with_owner(|| provide_context(ProbeCtx(7)));
let (view_signal, handler_signal) =
runtime.with_owner(|| (RwSignal::new(0u32), RwSignal::new(0u32)));
let seen: Seen = Rc::new(RefCell::new(Observed::default()));
let mut root: RenderRoot<(), ProbeView> = RenderRoot::new();
let scope = TrackedScope::new();
let seen_for_logic = seen.clone();
let mut build = move |_state: &mut ()| {
let _ = view_signal.get();
ProbeView {
seen: seen_for_logic.clone(),
handler_signal,
}
};
runtime.with_owner(|| scope.track(|| root.rebuild(&mut build, &mut ())));
root.layout(Size::new(100.0, 100.0));
(runtime, root, seen, scope, view_signal, handler_signal)
}
#[test]
fn event_pass_without_the_owner_wrap_sees_no_context() {
let (_runtime, mut root, seen, _scope, _view_signal, _handler_signal) = fixture();
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = root.event(&mut (), &press());
std::panic::set_hook(hook);
assert!(outcome.handled, "the probe widget consumed the press");
let seen = seen.borrow();
assert_eq!(
seen.used, None,
"pre-fix: an unwrapped event pass has no current Owner, so \
use_context resolves None even for a context the shell provided"
);
let panic_msg = seen
.expect_panic
.as_deref()
.expect("pre-fix: expect_context must panic with no ambient Owner");
assert!(
panic_msg.ends_with("to be present") && panic_msg.contains("expected context of type"),
"unexpected panic message: {panic_msg}"
);
}
#[test]
fn event_pass_under_the_owner_wrap_resolves_root_context() {
let (runtime, mut root, seen, _scope, _view_signal, _handler_signal) = fixture();
let outcome = event_under_owner(runtime, &mut root, &mut (), &press());
assert!(outcome.handled, "the probe widget consumed the press");
let seen = seen.borrow();
assert_eq!(
seen.used,
Some(7),
"a handler must resolve a context the shell provided under the root \
owner"
);
assert_eq!(
seen.expect_panic, None,
"the panicking form must no longer panic from an event handler"
);
}
#[test]
fn event_pass_neither_adds_nor_drops_rebuild_dependencies() {
let (runtime, mut root, _seen, scope, view_signal, handler_signal) = fixture();
assert!(
!scope.is_dirty(),
"fixture precondition: the rebuild scope is clean"
);
event_under_owner(runtime, &mut root, &mut (), &press());
assert!(
!scope.is_dirty(),
"the event pass itself must not dirty the frame scope"
);
handler_signal.set(1);
assert!(
!scope.is_dirty(),
"a signal read only inside an event handler must not subscribe the \
frame scope — that would schedule a rebuild the pre-fix shell never \
scheduled (a rebuild storm)"
);
view_signal.set(1);
assert!(
scope.is_dirty(),
"the rebuild's tracked read must still wake the frame loop after an \
event pass"
);
}
#[derive(Debug, Default)]
struct SpyExtensions {
attributes_calls: usize,
override_title: Option<&'static str>,
}
impl DesktopExtensions for SpyExtensions {
fn on_window_attributes(&mut self, attributes: WindowAttributes) -> WindowAttributes {
self.attributes_calls += 1;
match self.override_title {
Some(title) => attributes.with_title(title),
None => attributes,
}
}
}
#[test]
fn window_attributes_default_to_the_historical_preview_window() {
let attributes = window_attributes(&DesktopConfig::default(), &mut NoExtensions);
assert_eq!(attributes.title, "Frust");
assert_eq!(attributes.inner_size, Some(super::INITIAL_SIZE.into()));
assert!(
!attributes.visible,
"the window must be created hidden — the accessibility adapter is \
constructed before it is shown"
);
}
#[test]
fn window_attributes_take_the_title_from_the_config() {
let config = DesktopConfig::new().with_app_name("Huddle");
let attributes = window_attributes(&config, &mut NoExtensions);
assert_eq!(attributes.title, "Huddle");
}
#[test]
fn window_attributes_run_the_extension_hook_last() {
let mut extensions = SpyExtensions {
override_title: Some("overridden"),
..SpyExtensions::default()
};
let config = DesktopConfig::new().with_app_name("Huddle");
let attributes = window_attributes(&config, &mut extensions);
assert_eq!(extensions.attributes_calls, 1);
assert_eq!(
attributes.title, "overridden",
"the hook sees the core's attributes and its result wins — the only \
chance a per-OS shell gets at pre-create-only attributes"
);
}
#[test]
fn parse_window_size_valid() {
assert_eq!(
parse_window_size(Some("2560x1440")),
LogicalSize::new(2560, 1440)
);
assert_eq!(
parse_window_size(Some(" 1280x720 ")),
LogicalSize::new(1280, 720)
);
assert_eq!(parse_window_size(Some("1x1")), LogicalSize::new(1, 1));
}
#[test]
fn parse_window_size_missing_falls_back_silently() {
assert_eq!(parse_window_size(None), super::INITIAL_SIZE);
assert_eq!(parse_window_size(Some("")), super::INITIAL_SIZE);
assert_eq!(parse_window_size(Some(" ")), super::INITIAL_SIZE);
}
#[test]
fn parse_window_size_zero_component_falls_back() {
assert_eq!(parse_window_size(Some("0x600")), super::INITIAL_SIZE);
assert_eq!(parse_window_size(Some("800x0")), super::INITIAL_SIZE);
assert_eq!(parse_window_size(Some("0x0")), super::INITIAL_SIZE);
}
#[test]
fn parse_window_size_invalid_falls_back() {
for invalid in [
"800", "800x", "x600", "800x600x400", "800X600", "800.5x600", "-800x600", "800 x 600", "eightx600", "800xsix", "99999999999999999999x600", ] {
assert_eq!(
parse_window_size(Some(invalid)),
super::INITIAL_SIZE,
"expected {invalid:?} to fall back to the default size"
);
}
}
#[test]
fn parse_window_maximized_recognises_one_and_true() {
assert!(parse_window_maximized(Some("1")));
assert!(parse_window_maximized(Some("true")));
assert!(parse_window_maximized(Some("TRUE")));
assert!(parse_window_maximized(Some(" True ")));
}
#[test]
fn parse_window_maximized_defaults_to_false() {
assert!(!parse_window_maximized(None));
assert!(!parse_window_maximized(Some("")));
assert!(!parse_window_maximized(Some("0")));
assert!(!parse_window_maximized(Some("yes")));
assert!(!parse_window_maximized(Some("2")));
}
#[test]
fn resolved_window_knob_runtime_wins_over_compile_time() {
assert_eq!(
resolved_window_knob(Some("800x600"), Some("2560x1440".to_string())),
(Some("2560x1440".to_string()), WindowKnobSource::Env)
);
}
#[test]
fn resolved_window_knob_empty_runtime_falls_through_to_compile_time() {
assert_eq!(
resolved_window_knob(Some("800x600"), Some(String::new())),
(Some("800x600".to_string()), WindowKnobSource::Define)
);
}
#[test]
fn resolved_window_knob_neither_set_is_default() {
assert_eq!(
resolved_window_knob(None, None),
(None, WindowKnobSource::Default)
);
}
#[test]
fn apply_window_size_sets_inner_size_and_leaves_maximized_off_by_default() {
let attributes = apply_window_size(
WindowAttributes::default(),
LogicalSize::new(1280, 720),
false,
);
assert_eq!(
attributes.inner_size,
Some(LogicalSize::new(1280, 720).into())
);
assert!(!attributes.maximized);
}
#[test]
fn apply_window_size_sets_maximized_when_requested() {
let attributes = apply_window_size(WindowAttributes::default(), super::INITIAL_SIZE, true);
assert!(
attributes.maximized,
"FRUST_WINDOW_MAXIMIZED=1 must set WindowAttributes::maximized — the size is still \
applied (documented), but the OS ignores it for a window maximized at creation"
);
}
#[test]
fn the_first_resolved_brightness_is_always_reported() {
assert_eq!(
brightness_change_to_notify(None, Brightness::Light),
Some(Brightness::Light)
);
}
#[test]
fn an_unchanged_brightness_reports_nothing() {
assert_eq!(
brightness_change_to_notify(Some(Brightness::Dark), Brightness::Dark),
None
);
}
#[test]
fn a_changed_brightness_is_reported() {
assert_eq!(
brightness_change_to_notify(Some(Brightness::Light), Brightness::Dark),
Some(Brightness::Dark)
);
assert_eq!(
brightness_change_to_notify(Some(Brightness::Dark), Brightness::Light),
Some(Brightness::Light)
);
}
}