use super::translate::{
translate_ime, translate_key, translate_modifiers, translate_mouse_button,
translate_physical_pos,
};
#[cfg(target_arch = "wasm32")]
use super::web;
use crate::backend::WindowBackend;
use crate::presenter::Presenter;
use retroglyph_core::backend::{Input, Output};
use retroglyph_core::event::{
Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, PhysicalPos,
};
use retroglyph_core::grid::HasSize;
use retroglyph_core::terminal::Terminal;
use std::cell::Cell;
use std::fmt;
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::{Window, WindowId};
pub struct EventProxy<T: Send + 'static = u64>(winit::event_loop::EventLoopProxy<T>);
impl<T: Send + 'static> Clone for EventProxy<T> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T: Send + 'static> fmt::Debug for EventProxy<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("EventProxy").field(&self.0).finish()
}
}
impl<T: Send + 'static> EventProxy<T> {
pub fn send_event(&self, payload: T) -> Result<(), EventProxyClosed<T>> {
self.0
.send_event(payload)
.map_err(|e| EventProxyClosed(e.0))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EventProxyClosed<T = u64>(T);
impl<T> EventProxyClosed<T> {
#[must_use]
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> fmt::Display for EventProxyClosed<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "event loop closed")
}
}
impl<T: fmt::Debug> std::error::Error for EventProxyClosed<T> {}
#[allow(clippy::struct_excessive_bools)]
pub struct WindowConfig {
title: String,
width: u32,
height: u32,
target_fps: Option<u32>,
event_driven: bool,
fill_viewport: bool,
resizable: bool,
decorations: bool,
min_size: Option<(u32, u32)>,
max_size: Option<(u32, u32)>,
initial_position: Option<(i32, i32)>,
fullscreen: bool,
transparency: bool,
}
impl WindowConfig {
#[must_use]
pub fn fit<P: Presenter>(
presenter: &P,
title: impl Into<String>,
target_fps: Option<u32>,
event_driven: bool,
) -> Self {
let grid = presenter.size();
let (cell_w, cell_h) = presenter.cell_size();
Self {
title: title.into(),
width: u32::from(grid.width()) * cell_w,
height: u32::from(grid.height()) * cell_h,
target_fps,
event_driven,
fill_viewport: false,
resizable: true,
decorations: true,
min_size: None,
max_size: None,
initial_position: None,
fullscreen: false,
transparency: false,
}
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub const fn width(&self) -> u32 {
self.width
}
#[must_use]
pub const fn height(&self) -> u32 {
self.height
}
#[must_use]
pub fn animated<P: Presenter>(presenter: &P, title: impl Into<String>, fps: u32) -> Self {
Self::fit(presenter, title, Some(fps), false)
}
#[must_use]
pub const fn target_fps(&self) -> Option<u32> {
self.target_fps
}
#[must_use]
pub const fn event_driven(&self) -> bool {
self.event_driven
}
#[must_use]
pub const fn with_run_options(mut self, options: retroglyph_core::app::RunOptions) -> Self {
self.target_fps = options.target_fps();
self.event_driven = options.is_event_driven();
self
}
#[must_use]
pub const fn fill_viewport(mut self, fill_viewport: bool) -> Self {
self.fill_viewport = fill_viewport;
self
}
#[must_use]
pub const fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
#[must_use]
pub const fn decorations(mut self, decorations: bool) -> Self {
self.decorations = decorations;
self
}
#[must_use]
pub const fn min_size(mut self, width: u32, height: u32) -> Self {
self.min_size = Some((width, height));
self
}
#[must_use]
pub const fn max_size(mut self, width: u32, height: u32) -> Self {
self.max_size = Some((width, height));
self
}
#[must_use]
pub const fn initial_position(mut self, x: i32, y: i32) -> Self {
self.initial_position = Some((x, y));
self
}
#[must_use]
pub const fn fullscreen(mut self, fullscreen: bool) -> Self {
self.fullscreen = fullscreen;
self
}
#[must_use]
pub const fn transparency(mut self, transparency: bool) -> Self {
self.transparency = transparency;
self
}
}
pub fn run_windowed<P, F>(
config: WindowConfig,
presenter: P,
app_loop: F,
) -> Result<(), winit::error::EventLoopError>
where
P: Presenter + 'static,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
{
run_windowed_with_proxy(config, presenter, app_loop, |_proxy| {})
}
pub fn run_windowed_with_proxy<P, F, O>(
config: WindowConfig,
presenter: P,
app_loop: F,
on_proxy: O,
) -> Result<(), winit::error::EventLoopError>
where
P: Presenter + 'static,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
O: FnOnce(EventProxy),
{
run_windowed_with_typed_proxy_and_exit_flag(
config,
Terminal::new(WindowBackend::new(presenter)),
app_loop,
on_proxy,
push_custom_event,
Rc::new(Cell::new(false)),
Rc::new(Cell::new(false)),
)
}
pub fn run_windowed_with_typed_proxy<T, P, F, O, D>(
config: WindowConfig,
presenter: P,
app_loop: F,
on_proxy: O,
on_custom_event: D,
) -> Result<(), winit::error::EventLoopError>
where
T: Send + 'static,
P: Presenter + 'static,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
O: FnOnce(EventProxy<T>),
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
run_windowed_with_typed_proxy_and_exit_flag(
config,
Terminal::new(WindowBackend::new(presenter)),
app_loop,
on_proxy,
on_custom_event,
Rc::new(Cell::new(false)),
Rc::new(Cell::new(false)),
)
}
fn push_custom_event<P: Presenter>(id: u64, term: &mut Terminal<WindowBackend<P>>) {
term.backend_mut().push_event(Event::Custom(id));
}
fn run_windowed_with_typed_proxy_and_exit_flag<T, P, F, O, D>(
config: WindowConfig,
terminal: Terminal<WindowBackend<P>>,
app_loop: F,
on_proxy: O,
on_custom_event: D,
exit_requested: Rc<Cell<bool>>,
skip_present: Rc<Cell<bool>>,
) -> Result<(), winit::error::EventLoopError>
where
T: Send + 'static,
P: Presenter + 'static,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
O: FnOnce(EventProxy<T>),
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
let event_loop = EventLoop::<T>::with_user_event().build()?;
on_proxy(EventProxy(event_loop.create_proxy()));
let frame_interval = config
.target_fps
.filter(|&fps| fps != 0)
.map(|fps| Duration::from_secs_f64(1.0 / f64::from(fps)));
let attrs = WindowAttrs::from(&config);
let app = WindowApp {
terminal: Some(terminal),
app_loop,
on_custom_event,
window: None,
title: config.title,
init_size: InitWindowSize {
width: config.width,
height: config.height,
},
attrs,
#[cfg(target_arch = "wasm32")]
fill_viewport: config.fill_viewport,
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval,
event_driven: config.event_driven,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested,
skip_present,
needs_redraw: true,
consecutive_present_errors: 0,
_user_event: PhantomData,
};
#[cfg(not(target_arch = "wasm32"))]
{
let mut app = app;
event_loop.run_app(&mut app)
}
#[cfg(target_arch = "wasm32")]
{
use winit::platform::web::EventLoopExtWebSys;
event_loop.spawn_app(app);
Ok(())
}
}
pub fn run_app<P, A>(
config: WindowConfig,
presenter: P,
app: A,
) -> Result<(), winit::error::EventLoopError>
where
P: Presenter + 'static,
A: retroglyph_core::app::App<WindowBackend<P>> + 'static,
{
run_app_with_proxy(config, presenter, app, |_proxy| {})
}
pub fn run_app_on<P, A>(
config: WindowConfig,
terminal: Terminal<WindowBackend<P>>,
app: A,
) -> Result<(), winit::error::EventLoopError>
where
P: Presenter + 'static,
A: retroglyph_core::app::App<WindowBackend<P>> + 'static,
{
run_app_on_with_typed_proxy(
config,
terminal,
app,
|_proxy: EventProxy| {},
push_custom_event,
)
}
pub fn run_app_with_proxy<P, A, O>(
config: WindowConfig,
presenter: P,
app: A,
on_proxy: O,
) -> Result<(), winit::error::EventLoopError>
where
P: Presenter + 'static,
A: retroglyph_core::app::App<WindowBackend<P>> + 'static,
O: FnOnce(EventProxy),
{
run_app_with_typed_proxy(config, presenter, app, on_proxy, push_custom_event)
}
pub fn run_app_with_typed_proxy<T, P, A, O, D>(
config: WindowConfig,
presenter: P,
app: A,
on_proxy: O,
on_custom_event: D,
) -> Result<(), winit::error::EventLoopError>
where
T: Send + 'static,
P: Presenter + 'static,
A: retroglyph_core::app::App<WindowBackend<P>> + 'static,
O: FnOnce(EventProxy<T>),
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
run_app_on_with_typed_proxy(
config,
Terminal::new(WindowBackend::new(presenter)),
app,
on_proxy,
on_custom_event,
)
}
fn run_app_on_with_typed_proxy<T, P, A, O, D>(
config: WindowConfig,
terminal: Terminal<WindowBackend<P>>,
mut app: A,
on_proxy: O,
on_custom_event: D,
) -> Result<(), winit::error::EventLoopError>
where
T: Send + 'static,
P: Presenter + 'static,
A: retroglyph_core::app::App<WindowBackend<P>> + 'static,
O: FnOnce(EventProxy<T>),
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
let mut frame_count = 0u64;
let mut last = web_time::Instant::now();
let exit_requested = Rc::new(Cell::new(false));
let exit_requested_in_loop = exit_requested.clone();
let skip_present = Rc::new(Cell::new(false));
let skip_present_in_loop = skip_present.clone();
run_windowed_with_typed_proxy_and_exit_flag(
config,
terminal,
move |term| {
let now = web_time::Instant::now();
let delta = now.duration_since(last);
last = now;
let frame = retroglyph_core::app::Frame {
delta,
frame: frame_count,
};
frame_count = frame_count.wrapping_add(1);
match app.update(term, &frame) {
retroglyph_core::app::Flow::Exit => exit_requested_in_loop.set(true),
retroglyph_core::app::Flow::Idle => skip_present_in_loop.set(true),
_ => {}
}
},
on_proxy,
on_custom_event,
exit_requested,
skip_present,
)
}
struct InitWindowSize {
width: u32,
height: u32,
}
#[allow(clippy::struct_excessive_bools)]
struct WindowAttrs {
resizable: bool,
decorations: bool,
min_size: Option<(u32, u32)>,
max_size: Option<(u32, u32)>,
initial_position: Option<(i32, i32)>,
fullscreen: bool,
transparency: bool,
}
impl From<&WindowConfig> for WindowAttrs {
fn from(config: &WindowConfig) -> Self {
Self {
resizable: config.resizable,
decorations: config.decorations,
min_size: config.min_size,
max_size: config.max_size,
initial_position: config.initial_position,
fullscreen: config.fullscreen,
transparency: config.transparency,
}
}
}
impl Default for WindowAttrs {
fn default() -> Self {
Self {
resizable: true,
decorations: true,
min_size: None,
max_size: None,
initial_position: None,
fullscreen: false,
transparency: false,
}
}
}
const BUTTON_MASK_LEFT: u8 = 1 << 0;
const BUTTON_MASK_RIGHT: u8 = 1 << 1;
const BUTTON_MASK_MIDDLE: u8 = 1 << 2;
const fn button_mask(button: MouseButton) -> u8 {
match button {
MouseButton::Left => BUTTON_MASK_LEFT,
MouseButton::Right => BUTTON_MASK_RIGHT,
MouseButton::Middle => BUTTON_MASK_MIDDLE,
_ => 0,
}
}
struct WindowApp<P: Presenter, F, T, D> {
terminal: Option<Terminal<WindowBackend<P>>>,
app_loop: F,
on_custom_event: D,
_user_event: PhantomData<fn(T)>,
window: Option<Arc<Window>>,
title: String,
init_size: InitWindowSize,
attrs: WindowAttrs,
#[cfg(target_arch = "wasm32")]
fill_viewport: bool,
current_modifiers: KeyModifiers,
cursor_px: (f64, f64),
active_touch: Option<u64>,
held_buttons: u8,
frame_interval: Option<Duration>,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant,
event_driven: bool,
exit_requested: Rc<Cell<bool>>,
skip_present: Rc<Cell<bool>>,
needs_redraw: bool,
consecutive_present_errors: u32,
}
impl<P: Presenter, F, T, D> WindowApp<P, F, T, D> {
fn create_window_and_surface(&mut self, event_loop: &ActiveEventLoop) -> Option<Arc<Window>> {
#[cfg(not(target_arch = "wasm32"))]
let physical_size =
winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height);
#[cfg(target_arch = "wasm32")]
let physical_size = if self.fill_viewport {
web::web_viewport_layout_physical_size().unwrap_or_else(|| {
winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
})
} else {
winit::dpi::PhysicalSize::new(self.init_size.width, self.init_size.height)
};
#[cfg(target_arch = "wasm32")]
let surface_physical_size = if self.fill_viewport {
web::web_viewport_surface_physical_size().unwrap_or(physical_size)
} else {
physical_size
};
#[cfg(not(target_arch = "wasm32"))]
let surface_physical_size = physical_size;
let attrs = Window::default_attributes()
.with_title(&self.title)
.with_inner_size(physical_size)
.with_resizable(self.attrs.resizable)
.with_decorations(self.attrs.decorations)
.with_transparent(self.attrs.transparency);
let attrs = match self.attrs.min_size {
Some((w, h)) => attrs.with_min_inner_size(winit::dpi::PhysicalSize::new(w, h)),
None => attrs,
};
let attrs = match self.attrs.max_size {
Some((w, h)) => attrs.with_max_inner_size(winit::dpi::PhysicalSize::new(w, h)),
None => attrs,
};
let attrs = match self.attrs.initial_position {
Some((x, y)) => attrs.with_position(winit::dpi::PhysicalPosition::new(x, y)),
None => attrs,
};
let attrs = if self.attrs.fullscreen {
attrs.with_fullscreen(Some(winit::window::Fullscreen::Borderless(None)))
} else {
attrs
};
#[cfg(target_family = "wasm")]
let attrs = {
use winit::platform::web::WindowAttributesExtWebSys;
attrs.with_append(true)
};
let window = Arc::new(match event_loop.create_window(attrs) {
Ok(w) => w,
Err(e) => {
log::error!("window creation failed: {e}");
event_loop.exit();
return None;
}
});
window.set_ime_allowed(true);
if let Some(term) = self.terminal.as_mut() {
let handle: Arc<dyn crate::presenter::WindowHandle> = window.clone();
if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
log::error!("surface init failed: {e}");
event_loop.exit();
return None;
}
term.backend_mut()
.presenter_mut()
.resize_surface(surface_physical_size.width, surface_physical_size.height);
}
#[cfg(target_arch = "wasm32")]
if self.fill_viewport {
web::install_viewport_resize_listener(&window);
}
if let Some(theme) = window.theme()
&& let Some(term) = self.terminal.as_mut()
{
term.backend_mut().push_event(system_theme_event(theme));
}
Some(window)
}
}
const PRESENT_FAILURE_RECOVERY_THRESHOLD: u32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PresentFailureAction {
Ok { was_failing: bool },
Log { at_error_level: bool },
Recover,
Fatal,
}
const fn present_failure_action(
consecutive_failures: u32,
succeeded: bool,
recoverable: bool,
) -> PresentFailureAction {
if succeeded {
return PresentFailureAction::Ok {
was_failing: consecutive_failures > 0,
};
}
if !recoverable {
return PresentFailureAction::Fatal;
}
if (consecutive_failures + 1).is_multiple_of(PRESENT_FAILURE_RECOVERY_THRESHOLD) {
return PresentFailureAction::Recover;
}
PresentFailureAction::Log {
at_error_level: consecutive_failures == 0,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn next_frame_deadline(
now: std::time::Instant,
next_frame: std::time::Instant,
interval: Duration,
) -> Option<std::time::Instant> {
if next_frame > now {
return None;
}
Some((next_frame + interval).max(now))
}
const fn system_theme_event(theme: winit::window::Theme) -> Event {
use retroglyph_core::event::SystemTheme;
match theme {
winit::window::Theme::Light => Event::ThemeChanged(SystemTheme::Light),
winit::window::Theme::Dark => Event::ThemeChanged(SystemTheme::Dark),
}
}
impl<P, F, T, D> ApplicationHandler<T> for WindowApp<P, F, T, D>
where
P: Presenter,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
T: 'static,
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if let Some(window) = self.create_window_and_surface(event_loop) {
self.window = Some(window);
}
self.needs_redraw = true;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
self.handle_window_event(event);
if self.exit_requested.get() {
event_loop.exit();
}
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: T) {
self.handle_user_event(event);
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if self.event_driven && !self.needs_redraw {
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
return;
}
let Some(interval) = self.frame_interval else {
self.needs_redraw = false;
self.request_redraw();
return;
};
#[cfg(not(target_arch = "wasm32"))]
match next_frame_deadline(std::time::Instant::now(), self.next_frame, interval) {
None => {
event_loop
.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(self.next_frame));
return;
}
Some(advanced) => self.next_frame = advanced,
}
#[cfg(target_arch = "wasm32")]
let _ = interval;
self.needs_redraw = false;
self.request_redraw();
}
}
impl<P, F, T, D> WindowApp<P, F, T, D>
where
P: Presenter,
F: FnMut(&mut Terminal<WindowBackend<P>>) + 'static,
D: FnMut(T, &mut Terminal<WindowBackend<P>>) + 'static,
{
fn request_redraw(&self) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn handle_user_event(&mut self, event: T) {
if let Some(term) = self.terminal.as_mut() {
(self.on_custom_event)(event, term);
}
self.needs_redraw = true;
}
fn handle_window_event(&mut self, event: WindowEvent) {
if !matches!(event, WindowEvent::RedrawRequested) {
self.needs_redraw = true;
}
match event {
WindowEvent::CloseRequested => {
if let Some(term) = self.terminal.as_mut() {
term.backend_mut().push_event(Event::Close);
}
}
WindowEvent::Resized(size) => self.on_resized(size),
WindowEvent::CursorMoved { position, .. } => self.on_cursor_moved(position),
WindowEvent::MouseInput { state, button, .. } => self.on_mouse_input(state, button),
WindowEvent::MouseWheel { delta, .. } => self.on_mouse_wheel(delta),
WindowEvent::Touch(touch) => self.on_touch(touch),
WindowEvent::ModifiersChanged(mods) => {
self.current_modifiers = translate_modifiers(mods.state());
}
WindowEvent::ThemeChanged(theme) => {
if let Some(term) = self.terminal.as_mut() {
term.backend_mut().push_event(system_theme_event(theme));
}
}
WindowEvent::Focused(gained) => self.on_focus_changed(gained),
WindowEvent::KeyboardInput { event, .. } => {
if let Some(term) = self.terminal.as_mut()
&& let Some(e) = translate_key(event, self.current_modifiers)
{
term.backend_mut().push_event(e);
}
}
WindowEvent::Ime(ime) => {
if let Some(term) = self.terminal.as_mut()
&& let Some(e) = translate_ime(ime)
{
term.backend_mut().push_event(e);
}
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.on_scale_factor_changed(scale_factor);
}
WindowEvent::RedrawRequested => self.handle_redraw_requested(),
_ => {}
}
}
fn handle_redraw_requested(&mut self) {
let Some(term) = self.terminal.as_mut() else {
return;
};
self.skip_present.set(false);
let present_count_before = term.present_count();
(self.app_loop)(term);
if !self.skip_present.get()
&& term.present_count() == present_count_before
&& let Err(e) = term.present()
{
log::error!("automatic terminal present failed: {e}");
}
let result = term.backend_mut().presenter_mut().present();
let succeeded = result.is_ok();
let recoverable = result
.as_ref()
.err()
.is_none_or(crate::presenter::RecoverableError::is_recoverable);
match present_failure_action(self.consecutive_present_errors, succeeded, recoverable) {
PresentFailureAction::Ok { was_failing } => {
if was_failing {
log::info!(
"frame present recovered after {} consecutive failures",
self.consecutive_present_errors
);
}
self.consecutive_present_errors = 0;
}
PresentFailureAction::Log { at_error_level } => {
self.consecutive_present_errors += 1;
let e = result.unwrap_err();
if at_error_level {
log::error!("frame present failed: {e}");
} else {
log::debug!("frame present still failing: {e}");
}
}
PresentFailureAction::Recover => {
self.consecutive_present_errors += 1;
let e = result.unwrap_err();
log::warn!(
"frame present failed {} times consecutively ({e}); attempting surface recovery",
self.consecutive_present_errors
);
self.try_recover_surface();
}
PresentFailureAction::Fatal => {
self.consecutive_present_errors += 1;
let e = result.unwrap_err();
log::error!("frame present failed with an unrecoverable error: {e}");
}
}
}
fn try_recover_surface(&mut self) {
let Some(window) = self.window.clone() else {
return;
};
let Some(term) = self.terminal.as_mut() else {
return;
};
let handle: Arc<dyn crate::presenter::WindowHandle> = window;
if let Err(e) = term.backend_mut().presenter_mut().init_surface(handle) {
log::error!("surface recovery failed: {e}");
}
}
fn on_resized(&mut self, size: winit::dpi::PhysicalSize<u32>) {
#[cfg(target_arch = "wasm32")]
let size = if self.fill_viewport {
web::web_viewport_surface_physical_size().unwrap_or(size)
} else {
size
};
self.resize_to(size);
}
fn on_scale_factor_changed(&mut self, scale_factor: f64) {
if let Some(term) = self.terminal.as_mut() {
term.backend_mut()
.presenter_mut()
.scale_factor_changed(scale_factor);
}
let Some(window) = self.window.clone() else {
return;
};
self.resize_to(window.inner_size());
}
fn resize_to(&mut self, size: winit::dpi::PhysicalSize<u32>) {
let Some(term) = self.terminal.as_mut() else {
return;
};
let (cell_w, cell_h) = term.backend().presenter().cell_size();
let cols = (size.width / cell_w).max(1);
let rows = (size.height / cell_h).max(1);
term.backend_mut()
.presenter_mut()
.resize_surface(cols * cell_w, rows * cell_h);
#[allow(clippy::cast_possible_truncation)]
let (cols, rows) = (cols as u16, rows as u16);
term.backend_mut()
.resize(retroglyph_core::grid::Size::new(cols, rows));
term.backend_mut().push_event(Event::Resize(cols, rows));
}
fn on_cursor_moved(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
#[cfg(target_arch = "wasm32")]
let scale = if self.fill_viewport {
web::wasm_pointer_scale()
} else {
1.0
};
#[cfg(not(target_arch = "wasm32"))]
let scale = 1.0;
let (x, y) = (position.x * scale, position.y * scale);
self.cursor_px = (x, y);
let px = translate_physical_pos(x, y);
let Some(term) = self.terminal.as_mut() else {
return;
};
let pos = term.backend().presenter().geometry().pixel_to_cell(x, y);
let kind = if self.held_buttons & BUTTON_MASK_LEFT != 0 {
MouseEventKind::Drag(MouseButton::Left)
} else if self.held_buttons & BUTTON_MASK_RIGHT != 0 {
MouseEventKind::Drag(MouseButton::Right)
} else if self.held_buttons & BUTTON_MASK_MIDDLE != 0 {
MouseEventKind::Drag(MouseButton::Middle)
} else {
MouseEventKind::Moved
};
term.backend_mut()
.push_event(Event::Mouse(MouseEvent::with_pixel_position(
kind,
pos,
self.current_modifiers,
px,
)));
}
fn on_mouse_input(
&mut self,
state: winit::event::ElementState,
button: winit::event::MouseButton,
) {
let Some(btn) = translate_mouse_button(button) else {
return;
};
let px = self.cursor_physical_pos();
let Some(term) = self.terminal.as_mut() else {
return;
};
let pos = term
.backend()
.presenter()
.geometry()
.pixel_to_cell(self.cursor_px.0, self.cursor_px.1);
let kind = if state.is_pressed() {
self.held_buttons |= button_mask(btn);
MouseEventKind::Down(btn)
} else {
self.held_buttons &= !button_mask(btn);
MouseEventKind::Up(btn)
};
term.backend_mut()
.push_event(Event::Mouse(MouseEvent::with_pixel_position(
kind,
pos,
self.current_modifiers,
px,
)));
}
fn on_mouse_wheel(&mut self, delta: winit::event::MouseScrollDelta) {
let px = self.cursor_physical_pos();
let Some(term) = self.terminal.as_mut() else {
return;
};
let pos = term
.backend()
.presenter()
.geometry()
.pixel_to_cell(self.cursor_px.0, self.cursor_px.1);
let (scroll_x, scroll_y) = match delta {
winit::event::MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
winit::event::MouseScrollDelta::PixelDelta(p) => (p.x, p.y),
};
if scroll_x == 0.0 && scroll_y == 0.0 {
return;
}
#[allow(clippy::cast_possible_truncation)]
let kind = MouseEventKind::Scroll {
dx: scroll_x as f32,
dy: scroll_y as f32,
};
term.backend_mut()
.push_event(Event::Mouse(MouseEvent::with_pixel_position(
kind,
pos,
self.current_modifiers,
px,
)));
}
fn on_touch(&mut self, touch: winit::event::Touch) {
use winit::event::TouchPhase;
match touch.phase {
TouchPhase::Started => {
if self.active_touch.is_some() {
return; }
self.active_touch = Some(touch.id);
self.on_cursor_moved(touch.location);
self.on_mouse_input(
winit::event::ElementState::Pressed,
winit::event::MouseButton::Left,
);
}
TouchPhase::Moved => {
if self.active_touch == Some(touch.id) {
self.on_cursor_moved(touch.location);
}
}
TouchPhase::Ended | TouchPhase::Cancelled => {
if self.active_touch != Some(touch.id) {
return;
}
self.active_touch = None;
self.on_cursor_moved(touch.location);
self.on_mouse_input(
winit::event::ElementState::Released,
winit::event::MouseButton::Left,
);
}
}
}
const fn cursor_physical_pos(&self) -> PhysicalPos {
translate_physical_pos(self.cursor_px.0, self.cursor_px.1)
}
fn on_focus_changed(&mut self, gained: bool) {
if let Some(term) = self.terminal.as_mut() {
let event = if gained {
Event::FocusGained
} else {
Event::FocusLost
};
term.backend_mut().push_event(event);
}
if !gained {
self.current_modifiers = KeyModifiers::NONE;
if self.active_touch.take().is_some() {
self.on_mouse_input(
winit::event::ElementState::Released,
winit::event::MouseButton::Left,
);
}
self.held_buttons = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use retroglyph_core::backend::DrawCell;
use retroglyph_core::backend::Output;
use retroglyph_core::event::{MouseButton, MouseEvent, MouseEventKind};
use retroglyph_core::grid::{Pos, Size};
use std::cell::RefCell;
use std::time::Duration;
#[test]
fn fit_defaults_match_winit_defaults() {
let presenter = MockPresenter::default();
let config = WindowConfig::fit(&presenter, "test", None, true);
assert!(config.resizable);
assert!(config.decorations);
assert_eq!(config.min_size, None);
assert_eq!(config.max_size, None);
assert_eq!(config.initial_position, None);
assert!(!config.fullscreen);
assert!(!config.transparency);
assert!(!config.fill_viewport);
}
#[test]
fn fit_width_height_are_physical_pixels_not_rescaled() {
let mut presenter = MockPresenter::default();
presenter.resize(Size::new(80, 25));
let config = WindowConfig::fit(&presenter, "test", None, true);
assert_eq!(config.width, 80 * 8);
assert_eq!(config.height, 25 * 16);
}
#[test]
fn builder_chain_sets_each_attribute() {
let presenter = MockPresenter::default();
let config = WindowConfig::fit(&presenter, "test", None, true)
.resizable(false)
.decorations(false)
.min_size(320, 240)
.max_size(1920, 1080)
.initial_position(10, 20)
.fullscreen(true)
.transparency(true);
assert!(!config.resizable);
assert!(!config.decorations);
assert_eq!(config.min_size, Some((320, 240)));
assert_eq!(config.max_size, Some((1920, 1080)));
assert_eq!(config.initial_position, Some((10, 20)));
assert!(config.fullscreen);
assert!(config.transparency);
}
#[test]
fn window_attrs_from_config_copies_all_fields() {
let presenter = MockPresenter::default();
let config = WindowConfig::fit(&presenter, "test", None, true)
.resizable(false)
.decorations(false)
.min_size(1, 2)
.max_size(3, 4)
.initial_position(5, 6)
.fullscreen(true)
.transparency(true);
let attrs = WindowAttrs::from(&config);
assert!(!attrs.resizable);
assert!(!attrs.decorations);
assert_eq!(attrs.min_size, Some((1, 2)));
assert_eq!(attrs.max_size, Some((3, 4)));
assert_eq!(attrs.initial_position, Some((5, 6)));
assert!(attrs.fullscreen);
assert!(attrs.transparency);
}
#[test]
fn present_success_with_no_prior_failures_is_plain_ok() {
assert_eq!(
present_failure_action(0, true, true),
PresentFailureAction::Ok { was_failing: false }
);
}
#[test]
fn present_success_after_a_failure_streak_reports_recovery() {
assert_eq!(
present_failure_action(5, true, true),
PresentFailureAction::Ok { was_failing: true }
);
}
#[test]
fn first_failure_in_a_streak_logs_at_error_level() {
assert_eq!(
present_failure_action(0, false, true),
PresentFailureAction::Log {
at_error_level: true
}
);
}
#[test]
fn subsequent_failures_below_threshold_log_below_error_level() {
for count in 1..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
assert_eq!(
present_failure_action(count, false, true),
PresentFailureAction::Log {
at_error_level: false
},
"consecutive_failures = {count}"
);
}
}
#[test]
fn failure_crossing_the_threshold_triggers_recovery() {
assert_eq!(
present_failure_action(PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, true),
PresentFailureAction::Recover
);
}
#[test]
fn failure_recovers_again_every_full_threshold_after_the_first() {
assert_eq!(
present_failure_action(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, true),
PresentFailureAction::Recover
);
for count in
PRESENT_FAILURE_RECOVERY_THRESHOLD..(2 * PRESENT_FAILURE_RECOVERY_THRESHOLD - 1)
{
assert_eq!(
present_failure_action(count, false, true),
PresentFailureAction::Log {
at_error_level: false
},
"consecutive_failures = {count}"
);
}
}
#[test]
fn unrecoverable_failure_is_fatal_immediately_regardless_of_streak_length() {
assert_eq!(
present_failure_action(0, false, false),
PresentFailureAction::Fatal
);
}
#[test]
fn unrecoverable_failure_stays_fatal_mid_streak() {
assert_eq!(
present_failure_action(5, false, false),
PresentFailureAction::Fatal
);
assert_eq!(
present_failure_action(PRESENT_FAILURE_RECOVERY_THRESHOLD - 1, false, false),
PresentFailureAction::Fatal
);
}
#[test]
fn recoverable_flag_is_ignored_on_success() {
assert_eq!(
present_failure_action(3, true, false),
PresentFailureAction::Ok { was_failing: true }
);
}
struct MockPresenter {
last_scale_factor: Cell<Option<f64>>,
size: Cell<Size>,
}
impl Default for MockPresenter {
fn default() -> Self {
Self {
last_scale_factor: Cell::new(None),
size: Cell::new(Size::new(10, 5)),
}
}
}
impl Output for MockPresenter {
type Error = core::convert::Infallible;
fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn size(&self) -> Size {
self.size.get()
}
fn clear(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn resize(&mut self, size: Size) {
self.size.set(size);
}
}
impl Presenter for MockPresenter {
type SurfaceError = core::convert::Infallible;
fn init_surface(
&mut self,
_window: Arc<dyn crate::presenter::WindowHandle>,
) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn resize_surface(&mut self, _width: u32, _height: u32) {}
fn present(&mut self) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn cell_size(&self) -> (u32, u32) {
(8, 16)
}
fn scale_factor_changed(&mut self, scale_factor: f64) {
self.last_scale_factor.set(Some(scale_factor));
}
}
#[derive(Default)]
struct RecordingPresenter {
resize_calls: Rc<RefCell<Vec<(u32, u32)>>>,
}
impl Output for RecordingPresenter {
type Error = core::convert::Infallible;
fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn size(&self) -> Size {
Size::new(10, 5)
}
fn clear(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn resize(&mut self, _size: Size) {}
}
impl Presenter for RecordingPresenter {
type SurfaceError = core::convert::Infallible;
fn init_surface(
&mut self,
_window: Arc<dyn crate::presenter::WindowHandle>,
) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn resize_surface(&mut self, width: u32, height: u32) {
self.resize_calls.borrow_mut().push((width, height));
}
fn present(&mut self) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn cell_size(&self) -> (u32, u32) {
(8, 16)
}
}
#[derive(Default)]
struct FailingPresenter {
failing: Rc<Cell<bool>>,
init_surface_calls: Rc<Cell<u32>>,
}
impl Output for FailingPresenter {
type Error = core::convert::Infallible;
fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn size(&self) -> Size {
Size::new(10, 5)
}
fn clear(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn resize(&mut self, _size: Size) {}
}
impl Presenter for FailingPresenter {
type SurfaceError = &'static str;
fn init_surface(
&mut self,
_window: Arc<dyn crate::presenter::WindowHandle>,
) -> Result<(), Self::SurfaceError> {
self.init_surface_calls
.set(self.init_surface_calls.get() + 1);
Ok(())
}
fn resize_surface(&mut self, _width: u32, _height: u32) {}
fn present(&mut self) -> Result<(), Self::SurfaceError> {
if self.failing.get() {
Err("simulated present failure")
} else {
Ok(())
}
}
fn cell_size(&self) -> (u32, u32) {
(8, 16)
}
}
impl crate::presenter::RecoverableError for &'static str {}
#[derive(Debug)]
struct UnrecoverableError(&'static str);
impl core::fmt::Display for UnrecoverableError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
impl crate::presenter::RecoverableError for UnrecoverableError {
fn is_recoverable(&self) -> bool {
false
}
}
#[derive(Default)]
struct FatalPresenter {
failing: Rc<Cell<bool>>,
init_surface_calls: Rc<Cell<u32>>,
}
impl Output for FatalPresenter {
type Error = core::convert::Infallible;
fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn draw_layers<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn size(&self) -> Size {
Size::new(10, 5)
}
fn clear(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn resize(&mut self, _size: Size) {}
}
impl Presenter for FatalPresenter {
type SurfaceError = UnrecoverableError;
fn init_surface(
&mut self,
_window: Arc<dyn crate::presenter::WindowHandle>,
) -> Result<(), Self::SurfaceError> {
self.init_surface_calls
.set(self.init_surface_calls.get() + 1);
Ok(())
}
fn resize_surface(&mut self, _width: u32, _height: u32) {}
fn present(&mut self) -> Result<(), Self::SurfaceError> {
if self.failing.get() {
Err(UnrecoverableError(
"simulated unrecoverable present failure",
))
} else {
Ok(())
}
}
fn cell_size(&self) -> (u32, u32) {
(8, 16)
}
}
type MockApp = WindowApp<
MockPresenter,
fn(&mut Terminal<WindowBackend<MockPresenter>>),
u64,
fn(u64, &mut Terminal<WindowBackend<MockPresenter>>),
>;
fn test_window_app() -> MockApp {
let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
WindowApp {
terminal: Some(terminal),
app_loop: |_| {},
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
}
}
fn poll(app: &mut MockApp) -> Option<Event> {
app.terminal
.as_mut()
.unwrap()
.backend_mut()
.poll_event(Duration::ZERO)
}
#[test]
fn mouse_event_round_trips_through_event_buffer() {
let mut backend = WindowBackend::new(MockPresenter::default());
let ev = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Pos { x: 3, y: 1 },
KeyModifiers::NONE,
));
backend.push_event(ev.clone());
assert_eq!(backend.poll_event(Duration::ZERO), Some(ev));
assert_eq!(backend.poll_event(Duration::ZERO), None);
}
#[test]
fn multiple_mouse_events_preserve_fifo_order() {
let mut backend = WindowBackend::new(MockPresenter::default());
let moved = Event::Mouse(MouseEvent::new(
MouseEventKind::Moved,
Pos { x: 1, y: 2 },
KeyModifiers::NONE,
));
let clicked = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Pos { x: 1, y: 2 },
KeyModifiers::NONE,
));
backend.push_event(moved.clone());
backend.push_event(clicked.clone());
assert_eq!(backend.poll_event(Duration::ZERO), Some(moved));
assert_eq!(backend.poll_event(Duration::ZERO), Some(clicked));
}
#[test]
fn cursor_moved_pushes_moved_event_at_correct_cell() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(20.0_f64, 32.0_f64),
});
assert_eq!(
poll(&mut app),
Some(Event::Mouse(MouseEvent::with_pixel_position(
MouseEventKind::Moved,
Pos { x: 2, y: 2 },
KeyModifiers::NONE,
PhysicalPos { x: 20, y: 32 },
)))
);
}
#[test]
fn cursor_moved_caches_position_for_subsequent_click() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(16.0_f64, 16.0_f64),
});
let _ = poll(&mut app); app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
assert_eq!(
poll(&mut app),
Some(Event::Mouse(MouseEvent::with_pixel_position(
MouseEventKind::Down(MouseButton::Left),
Pos { x: 2, y: 1 },
KeyModifiers::NONE,
PhysicalPos { x: 16, y: 16 },
)))
);
}
#[test]
fn mouse_button_release_produces_up_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Released,
button: winit::event::MouseButton::Right,
});
assert_eq!(
poll(&mut app),
Some(Event::Mouse(MouseEvent::with_pixel_position(
MouseEventKind::Up(MouseButton::Right),
Pos { x: 0, y: 0 },
KeyModifiers::NONE,
PhysicalPos { x: 0, y: 0 },
)))
);
}
#[test]
fn unknown_mouse_button_produces_no_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Other(99),
});
assert_eq!(poll(&mut app), None);
}
fn touch(id: u64, phase: winit::event::TouchPhase, x: f64, y: f64) -> WindowEvent {
WindowEvent::Touch(winit::event::Touch {
device_id: winit::event::DeviceId::dummy(),
phase,
location: winit::dpi::PhysicalPosition::new(x, y),
force: None,
id,
})
}
#[test]
fn touch_tap_synthesizes_left_click() {
use winit::event::TouchPhase;
let mut app = test_window_app();
app.handle_window_event(touch(7, TouchPhase::Started, 20.0, 18.0));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
position: Pos { x: 2, y: 1 },
..
}))
));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
position: Pos { x: 2, y: 1 },
..
}))
));
app.handle_window_event(touch(7, TouchPhase::Ended, 20.0, 18.0));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
..
}))
));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
position: Pos { x: 2, y: 1 },
..
}))
));
assert_eq!(poll(&mut app), None);
}
#[test]
fn touch_drag_synthesizes_moves_between_down_and_up() {
use winit::event::TouchPhase;
let mut app = test_window_app();
app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
poll(&mut app); poll(&mut app);
app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
position: Pos { x: 5, y: 2 },
..
}))
));
app.handle_window_event(touch(1, TouchPhase::Cancelled, 40.0, 32.0));
poll(&mut app); assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
..
}))
));
}
#[test]
fn second_finger_is_ignored_while_first_is_down() {
use winit::event::TouchPhase;
let mut app = test_window_app();
app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
poll(&mut app); poll(&mut app);
app.handle_window_event(touch(2, TouchPhase::Started, 80.0, 80.0));
app.handle_window_event(touch(2, TouchPhase::Moved, 88.0, 80.0));
app.handle_window_event(touch(2, TouchPhase::Ended, 88.0, 80.0));
assert_eq!(poll(&mut app), None);
app.handle_window_event(touch(1, TouchPhase::Ended, 8.0, 0.0));
poll(&mut app); assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
position: Pos { x: 1, y: 0 },
..
}))
));
}
#[test]
fn scroll_up_line_delta() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::LineDelta(0.0, 1.0),
phase: winit::event::TouchPhase::Moved,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Scroll { dx: 0.0, dy },
..
}) if dy > 0.0
));
}
#[test]
fn scroll_down_line_delta() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::LineDelta(0.0, -1.0),
phase: winit::event::TouchPhase::Moved,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Scroll { dx: 0.0, dy },
..
}) if dy < 0.0
));
}
#[test]
fn scroll_up_pixel_delta() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
0.0_f64, 15.0_f64,
)),
phase: winit::event::TouchPhase::Moved,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Scroll { dx: 0.0, dy },
..
}) if dy > 0.0
));
}
#[test]
fn scroll_right_line_delta() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::LineDelta(1.0, 0.0),
phase: winit::event::TouchPhase::Moved,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Scroll { dx, dy: 0.0 },
..
}) if dx > 0.0
));
}
#[test]
fn scroll_left_pixel_delta() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::PixelDelta(winit::dpi::PhysicalPosition::new(
-15.0_f64, 0.0_f64,
)),
phase: winit::event::TouchPhase::Moved,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Scroll { dx, dy: 0.0 },
..
}) if dx < 0.0
));
}
#[test]
fn scroll_with_zero_delta_on_both_axes_pushes_no_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseWheel {
device_id: winit::event::DeviceId::dummy(),
delta: winit::event::MouseScrollDelta::LineDelta(0.0, 0.0),
phase: winit::event::TouchPhase::Moved,
});
assert_eq!(poll(&mut app), None);
}
#[test]
fn modifiers_propagate_to_mouse_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::ModifiersChanged(
winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
));
let _ = poll(&mut app); app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent {
modifiers,
..
}) if modifiers.contains(KeyModifiers::SHIFT)
));
}
#[test]
fn cursor_moved_with_no_button_held_emits_moved() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(8.0_f64, 16.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
..
}))
));
}
#[test]
fn cursor_moved_while_button_held_emits_drag_not_moved() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let _ = poll(&mut app);
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
..
}))
));
}
#[test]
fn cursor_moved_after_button_release_goes_back_to_moved() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let _ = poll(&mut app); app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Released,
button: winit::event::MouseButton::Left,
});
let _ = poll(&mut app);
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
..
}))
));
}
#[test]
fn right_button_drag_reports_right_not_left() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Right,
});
let _ = poll(&mut app);
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Right),
..
}))
));
}
#[test]
fn left_button_takes_priority_over_right_when_both_are_held() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Right,
});
let _ = poll(&mut app); app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let _ = poll(&mut app);
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
..
}))
));
}
#[test]
fn touch_drag_produces_drag_left_not_moved() {
use winit::event::TouchPhase;
let mut app = test_window_app();
app.handle_window_event(touch(1, TouchPhase::Started, 0.0, 0.0));
poll(&mut app); poll(&mut app);
app.handle_window_event(touch(1, TouchPhase::Moved, 40.0, 32.0));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
..
}))
));
}
#[test]
fn focus_lost_clears_held_button_so_refocus_move_is_not_a_stale_drag() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let _ = poll(&mut app);
app.handle_window_event(WindowEvent::Focused(false));
assert_eq!(poll(&mut app), Some(Event::FocusLost));
assert_eq!(app.held_buttons, 0);
app.handle_window_event(WindowEvent::Focused(true));
assert_eq!(poll(&mut app), Some(Event::FocusGained));
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(40.0_f64, 32.0_f64),
});
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
..
}))
));
}
#[test]
fn user_event_pushes_custom_event() {
let mut app = test_window_app();
app.handle_user_event(42);
assert_eq!(poll(&mut app), Some(Event::Custom(42)));
}
#[test]
fn multiple_user_events_preserve_fifo_order() {
let mut app = test_window_app();
app.handle_user_event(1);
app.handle_user_event(2);
assert_eq!(poll(&mut app), Some(Event::Custom(1)));
assert_eq!(poll(&mut app), Some(Event::Custom(2)));
assert_eq!(poll(&mut app), None);
}
#[test]
fn user_events_interleave_with_window_events_in_arrival_order() {
let mut app = test_window_app();
app.handle_user_event(7);
app.handle_window_event(WindowEvent::CloseRequested);
assert_eq!(poll(&mut app), Some(Event::Custom(7)));
assert_eq!(poll(&mut app), Some(Event::Close));
}
#[test]
fn event_proxy_closed_reports_the_undelivered_id() {
let err = EventProxyClosed(42);
assert_eq!(err.into_inner(), 42);
assert_eq!(err.to_string(), "event loop closed");
}
#[test]
fn event_proxy_closed_round_trips_a_non_u64_payload() {
let err = EventProxyClosed(String::from("asset.bin"));
assert_eq!(err.to_string(), "event loop closed");
assert_eq!(err.into_inner(), "asset.bin");
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AssetLoaded {
name: String,
bytes: usize,
}
type TypedAppLoop = fn(&mut Terminal<WindowBackend<MockPresenter>>);
type TypedHandler = Box<dyn FnMut(AssetLoaded, &mut Terminal<WindowBackend<MockPresenter>>)>;
type TypedApp = WindowApp<MockPresenter, TypedAppLoop, AssetLoaded, TypedHandler>;
fn test_typed_window_app(on_custom_event: TypedHandler) -> TypedApp {
let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
WindowApp {
terminal: Some(terminal),
app_loop: |_| {},
on_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
}
}
#[test]
fn typed_user_event_reaches_the_custom_handler_not_event_custom() {
let received: Rc<RefCell<Vec<AssetLoaded>>> = Rc::new(RefCell::new(Vec::new()));
let received_in_handler = received.clone();
let handler: TypedHandler = Box::new(move |payload, _term| {
received_in_handler.borrow_mut().push(payload);
});
let mut app = test_typed_window_app(handler);
let payload = AssetLoaded {
name: "asset.bin".to_string(),
bytes: 4096,
};
app.handle_user_event(payload.clone());
assert_eq!(received.borrow().as_slice(), &[payload]);
assert_eq!(
app.terminal
.as_mut()
.unwrap()
.backend_mut()
.poll_event(Duration::ZERO),
None
);
}
#[test]
fn typed_user_event_still_sets_needs_redraw() {
let handler: TypedHandler = Box::new(|_payload, _term| {});
let mut app = test_typed_window_app(handler);
assert!(!app.needs_redraw);
app.handle_user_event(AssetLoaded {
name: "asset.bin".to_string(),
bytes: 4096,
});
assert!(app.needs_redraw);
}
#[test]
fn close_requested_pushes_close_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::CloseRequested);
assert_eq!(poll(&mut app), Some(Event::Close));
}
#[test]
fn ime_commit_pushes_paste_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Commit(
"pasted".to_string(),
)));
assert_eq!(poll(&mut app), Some(Event::Paste("pasted".to_string())));
}
#[test]
fn ime_preedit_and_enabled_push_no_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Enabled));
app.handle_window_event(WindowEvent::Ime(winit::event::Ime::Preedit(
"nihon".to_string(),
Some((0, 5)),
)));
assert_eq!(poll(&mut app), None);
}
type BoxedAppLoop = Box<dyn FnMut(&mut Terminal<WindowBackend<MockPresenter>>)>;
type BoxedApp = WindowApp<
MockPresenter,
BoxedAppLoop,
u64,
fn(u64, &mut Terminal<WindowBackend<MockPresenter>>),
>;
#[test]
fn redraw_requested_runs_app_loop_and_does_not_set_exit_by_default() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::RedrawRequested);
assert!(!app.exit_requested.get());
}
#[test]
fn app_loop_setting_exit_requested_is_observed_after_redraw() {
let terminal = Terminal::new(WindowBackend::new(MockPresenter::default()));
let exit_requested = Rc::new(Cell::new(false));
let exit_requested_in_loop = exit_requested.clone();
let app_loop: BoxedAppLoop = Box::new(move |_term| exit_requested_in_loop.set(true));
let mut app: BoxedApp = WindowApp {
terminal: Some(terminal),
app_loop,
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested,
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
};
assert!(!app.exit_requested.get());
app.handle_window_event(WindowEvent::RedrawRequested);
assert!(app.exit_requested.get());
}
#[test]
fn theme_changed_pushes_mapped_system_theme_event() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Light));
assert_eq!(
poll(&mut app),
Some(Event::ThemeChanged(
retroglyph_core::event::SystemTheme::Light
))
);
app.handle_window_event(WindowEvent::ThemeChanged(winit::window::Theme::Dark));
assert_eq!(
poll(&mut app),
Some(Event::ThemeChanged(
retroglyph_core::event::SystemTheme::Dark
))
);
}
#[test]
fn focused_pushes_focus_gained_and_lost_events() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Focused(true));
assert_eq!(poll(&mut app), Some(Event::FocusGained));
app.handle_window_event(WindowEvent::Focused(false));
assert_eq!(poll(&mut app), Some(Event::FocusLost));
}
#[test]
fn focus_lost_resets_stuck_modifiers() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::ModifiersChanged(
winit::event::Modifiers::from(winit::keyboard::ModifiersState::SHIFT),
));
let _ = poll(&mut app); assert_eq!(app.current_modifiers, KeyModifiers::SHIFT);
app.handle_window_event(WindowEvent::Focused(false));
assert_eq!(poll(&mut app), Some(Event::FocusLost));
assert_eq!(app.current_modifiers, KeyModifiers::NONE);
app.handle_window_event(WindowEvent::Focused(true));
assert_eq!(poll(&mut app), Some(Event::FocusGained));
app.handle_window_event(WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: winit::event::ElementState::Pressed,
button: winit::event::MouseButton::Left,
});
let ev = poll(&mut app).unwrap();
assert!(matches!(
ev,
Event::Mouse(MouseEvent { modifiers, .. }) if modifiers == KeyModifiers::NONE
));
}
#[test]
fn focus_lost_releases_stuck_active_touch() {
use winit::event::TouchPhase;
let mut app = test_window_app();
app.handle_window_event(touch(3, TouchPhase::Started, 20.0, 18.0));
poll(&mut app); poll(&mut app); assert_eq!(app.active_touch, Some(3));
app.handle_window_event(WindowEvent::Focused(false));
assert_eq!(poll(&mut app), Some(Event::FocusLost));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
..
}))
));
assert_eq!(poll(&mut app), None);
assert_eq!(app.active_touch, None);
app.handle_window_event(WindowEvent::Focused(true));
assert_eq!(poll(&mut app), Some(Event::FocusGained));
app.handle_window_event(touch(4, TouchPhase::Started, 40.0, 32.0));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
..
}))
));
assert!(matches!(
poll(&mut app),
Some(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
..
}))
));
assert_eq!(app.active_touch, Some(4));
}
#[test]
fn focus_lost_without_active_touch_pushes_no_extra_events() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Focused(false));
assert_eq!(poll(&mut app), Some(Event::FocusLost));
assert_eq!(poll(&mut app), None);
}
#[test]
fn resized_pushes_resize_event_in_cells() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(88, 80)));
assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
}
#[test]
fn scale_factor_changed_notifies_presenter() {
let mut app = test_window_app();
app.on_scale_factor_changed(2.0);
assert_eq!(
app.terminal
.as_ref()
.unwrap()
.backend()
.presenter()
.last_scale_factor
.get(),
Some(2.0)
);
}
#[test]
fn scale_factor_changed_without_a_window_is_a_no_op_resize() {
let mut app = test_window_app();
app.on_scale_factor_changed(2.0);
assert_eq!(poll(&mut app), None);
}
#[test]
fn resize_to_clamps_to_whole_cells_and_pushes_resize_event() {
let mut app = test_window_app();
app.resize_to(winit::dpi::PhysicalSize::new(90, 81));
assert_eq!(poll(&mut app), Some(Event::Resize(11, 5)));
}
#[test]
fn resize_to_updates_backend_size_immediately() {
let mut app = test_window_app();
assert_eq!(
app.terminal.as_ref().unwrap().backend().size(),
Size::new(10, 5)
);
app.resize_to(winit::dpi::PhysicalSize::new(90, 81));
assert_eq!(
app.terminal.as_ref().unwrap().backend().size(),
Size::new(11, 5)
);
assert_eq!(app.terminal.as_ref().unwrap().size(), Size::new(10, 5));
}
#[test]
fn resized_below_one_cell_clamps_surface_and_event_to_1x1() {
type RecordingApp = WindowApp<
RecordingPresenter,
fn(&mut Terminal<WindowBackend<RecordingPresenter>>),
u64,
fn(u64, &mut Terminal<WindowBackend<RecordingPresenter>>),
>;
let resize_calls = Rc::new(RefCell::new(Vec::new()));
let presenter = RecordingPresenter {
resize_calls: resize_calls.clone(),
};
let terminal = Terminal::new(WindowBackend::new(presenter));
let mut app: RecordingApp = WindowApp {
terminal: Some(terminal),
app_loop: |_| {},
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
};
app.handle_window_event(WindowEvent::Resized(winit::dpi::PhysicalSize::new(4, 4)));
assert_eq!(resize_calls.borrow().as_slice(), &[(8, 16)]);
assert_eq!(
app.terminal
.as_mut()
.unwrap()
.backend_mut()
.poll_event(Duration::ZERO),
Some(Event::Resize(1, 1))
);
}
#[test]
fn fresh_app_does_not_need_a_redraw() {
let app = test_window_app();
assert!(!app.needs_redraw);
}
#[test]
fn window_event_sets_needs_redraw() {
let mut app = test_window_app();
assert!(!app.needs_redraw);
app.handle_window_event(WindowEvent::CursorMoved {
device_id: winit::event::DeviceId::dummy(),
position: winit::dpi::PhysicalPosition::new(1.0_f64, 1.0_f64),
});
assert!(app.needs_redraw);
}
#[test]
fn redraw_requested_does_not_itself_set_needs_redraw() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::RedrawRequested);
assert!(!app.needs_redraw);
}
#[test]
fn user_event_sets_needs_redraw() {
let mut app = test_window_app();
assert!(!app.needs_redraw);
app.handle_user_event(1);
assert!(app.needs_redraw);
}
#[test]
fn unhandled_window_events_still_set_needs_redraw() {
let mut app = test_window_app();
app.handle_window_event(WindowEvent::Occluded(true));
assert!(app.needs_redraw);
}
#[test]
fn target_fps_none_is_redraw_on_demand() {
let presenter = MockPresenter::default();
assert_eq!(
WindowConfig::fit(&presenter, "test", None, true).target_fps(),
None
);
}
#[test]
fn target_fps_some_survives_to_the_config() {
let presenter = MockPresenter::default();
assert_eq!(
WindowConfig::fit(&presenter, "test", Some(60), false).target_fps(),
Some(60)
);
}
#[test]
fn event_driven_accessor_reflects_the_config() {
let presenter = MockPresenter::default();
assert!(WindowConfig::fit(&presenter, "test", None, true).event_driven());
assert!(!WindowConfig::fit(&presenter, "test", None, false).event_driven());
}
#[test]
fn target_fps_and_event_driven_combine_independently() {
let presenter = MockPresenter::default();
let config = WindowConfig::fit(&presenter, "test", None, false);
assert_eq!(config.target_fps(), None);
assert!(!config.event_driven());
}
#[test]
fn animated_is_sugar_for_continuous_capped_fit() {
let presenter = MockPresenter::default();
let config = WindowConfig::animated(&presenter, "test", 60);
assert_eq!(config.target_fps(), Some(60));
assert!(!config.event_driven());
}
#[test]
fn with_run_options_overwrites_target_fps_and_event_driven() {
let presenter = MockPresenter::default();
let options = retroglyph_core::app::RunOptions::default()
.with_target_fps(30)
.event_driven(false);
let config = WindowConfig::fit(&presenter, "test", None, true).with_run_options(options);
assert_eq!(config.target_fps(), Some(30));
assert!(!config.event_driven());
}
#[test]
fn with_run_options_wins_when_applied_after_fit() {
let presenter = MockPresenter::default();
let config = WindowConfig::fit(&presenter, "test", Some(60), false)
.with_run_options(retroglyph_core::app::RunOptions::default());
assert_eq!(config.target_fps(), None);
assert!(config.event_driven());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn frame_deadline_in_the_future_parks_the_loop() {
let now = std::time::Instant::now();
let next = now + Duration::from_millis(10);
assert_eq!(
next_frame_deadline(now, next, Duration::from_millis(16)),
None
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn frame_deadline_reached_advances_by_exactly_one_interval() {
let interval = Duration::from_millis(16);
let next = std::time::Instant::now();
let now = next + Duration::from_micros(200);
assert_eq!(
next_frame_deadline(now, next, interval),
Some(next + interval)
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn overrun_frame_deadline_clamps_to_now_instead_of_bursting() {
let interval = Duration::from_millis(16);
let next = std::time::Instant::now();
let now = next + Duration::from_millis(500);
assert_eq!(next_frame_deadline(now, next, interval), Some(now));
}
type FailingApp = WindowApp<
FailingPresenter,
fn(&mut Terminal<WindowBackend<FailingPresenter>>),
u64,
fn(u64, &mut Terminal<WindowBackend<FailingPresenter>>),
>;
fn failing_app() -> (FailingApp, Rc<Cell<bool>>, Rc<Cell<u32>>) {
let failing = Rc::new(Cell::new(false));
let init_surface_calls = Rc::new(Cell::new(0));
let presenter = FailingPresenter {
failing: failing.clone(),
init_surface_calls: init_surface_calls.clone(),
};
let terminal = Terminal::new(WindowBackend::new(presenter));
let app: FailingApp = WindowApp {
terminal: Some(terminal),
app_loop: (|_| {}) as fn(&mut Terminal<WindowBackend<FailingPresenter>>),
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
};
(app, failing, init_surface_calls)
}
#[test]
fn successful_presents_never_increment_the_failure_counter() {
let (mut app, _failing, _init_calls) = failing_app();
for _ in 0..5 {
app.handle_redraw_requested();
}
assert_eq!(app.consecutive_present_errors, 0);
}
#[test]
fn failing_presents_increment_the_counter_and_stop_short_of_recovery() {
let (mut app, failing, init_calls) = failing_app();
failing.set(true);
for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD - 1 {
app.handle_redraw_requested();
}
assert_eq!(
app.consecutive_present_errors,
PRESENT_FAILURE_RECOVERY_THRESHOLD - 1
);
assert_eq!(init_calls.get(), 0);
}
#[test]
fn counter_resets_after_recovering_from_a_failure_streak() {
let (mut app, failing, _init_calls) = failing_app();
failing.set(true);
for _ in 0..5 {
app.handle_redraw_requested();
}
assert_eq!(app.consecutive_present_errors, 5);
failing.set(false);
app.handle_redraw_requested();
assert_eq!(app.consecutive_present_errors, 0);
}
#[test]
fn crossing_the_recovery_threshold_attempts_recovery_without_panicking() {
let (mut app, failing, init_calls) = failing_app();
failing.set(true);
for _ in 0..PRESENT_FAILURE_RECOVERY_THRESHOLD {
app.handle_redraw_requested();
}
assert_eq!(
app.consecutive_present_errors,
PRESENT_FAILURE_RECOVERY_THRESHOLD
);
assert_eq!(
init_calls.get(),
0,
"no window means try_recover_surface's guard skips init_surface"
);
}
#[test]
fn try_recover_surface_without_a_window_is_a_no_op() {
let (mut app, _failing, init_calls) = failing_app();
app.try_recover_surface();
assert_eq!(init_calls.get(), 0);
}
#[derive(Default)]
struct GridRecordingPresenter {
cells: RefCell<std::collections::HashMap<(u16, u16), char>>,
draw_calls: Cell<u32>,
}
impl Output for GridRecordingPresenter {
type Error = core::convert::Infallible;
fn draw<'a, I>(&mut self, _content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
Ok(())
}
fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = DrawCell<'a>>,
{
self.draw_calls.set(self.draw_calls.get() + 1);
let mut cells = self.cells.borrow_mut();
for cell in content {
cells.insert((cell.pos.x, cell.pos.y), cell.tile.glyph());
}
Ok(())
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn size(&self) -> Size {
Size::new(10, 5)
}
fn clear(&mut self) -> Result<(), Self::Error> {
Ok(())
}
fn resize(&mut self, _size: Size) {}
}
impl Presenter for GridRecordingPresenter {
type SurfaceError = core::convert::Infallible;
fn init_surface(
&mut self,
_window: Arc<dyn crate::presenter::WindowHandle>,
) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn resize_surface(&mut self, _width: u32, _height: u32) {}
fn present(&mut self) -> Result<(), Self::SurfaceError> {
Ok(())
}
fn cell_size(&self) -> (u32, u32) {
(8, 16)
}
}
type GridRecordingApp = WindowApp<
GridRecordingPresenter,
fn(&mut Terminal<WindowBackend<GridRecordingPresenter>>),
u64,
fn(u64, &mut Terminal<WindowBackend<GridRecordingPresenter>>),
>;
type BoxedGridRecordingAppLoop =
Box<dyn FnMut(&mut Terminal<WindowBackend<GridRecordingPresenter>>)>;
type BoxedGridRecordingApp = WindowApp<
GridRecordingPresenter,
BoxedGridRecordingAppLoop,
u64,
fn(u64, &mut Terminal<WindowBackend<GridRecordingPresenter>>),
>;
fn recording_app(
app_loop: fn(&mut Terminal<WindowBackend<GridRecordingPresenter>>),
) -> GridRecordingApp {
let terminal = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
WindowApp {
terminal: Some(terminal),
app_loop,
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
}
}
#[test]
fn app_loop_that_never_presents_is_still_drawn_by_the_automatic_present() {
let mut app = recording_app(|term| {
term.surface()
.put((0, 0), '@', retroglyph_core::color::Style::default());
});
app.handle_redraw_requested();
let term = app.terminal.as_ref().unwrap();
let presenter = term.backend().presenter();
assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
assert_eq!(
presenter.draw_calls.get(),
1,
"exactly one present this frame"
);
}
#[test]
fn app_loop_that_already_presents_itself_is_not_double_drawn() {
let mut app = recording_app(|term| {
term.surface()
.put((0, 0), '@', retroglyph_core::color::Style::default());
term.present().expect("app_loop's own present");
});
app.handle_redraw_requested();
let term = app.terminal.as_ref().unwrap();
let presenter = term.backend().presenter();
assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
assert_eq!(
presenter.draw_calls.get(),
1,
"the driver must detect app_loop's own present and skip its automatic one"
);
}
#[test]
fn skip_present_set_inside_app_loop_suppresses_the_automatic_present() {
let terminal = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
let skip_present = Rc::new(Cell::new(false));
let skip_present_in_loop = skip_present.clone();
let app_loop: BoxedGridRecordingAppLoop =
Box::new(move |_term| skip_present_in_loop.set(true));
let mut app: BoxedGridRecordingApp = WindowApp {
terminal: Some(terminal),
app_loop,
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present,
needs_redraw: false,
consecutive_present_errors: 0,
};
app.handle_redraw_requested();
let term = app.terminal.as_ref().unwrap();
let presenter = term.backend().presenter();
assert_eq!(
presenter.draw_calls.get(),
0,
"no present reaches the backend when app_loop sets skip_present"
);
}
#[test]
fn skip_present_does_not_carry_over_to_the_next_redraw() {
let mut app = recording_app(|term| {
term.surface()
.put((0, 0), '@', retroglyph_core::color::Style::default());
});
app.skip_present.set(true); app.handle_redraw_requested();
let term = app.terminal.as_ref().unwrap();
let presenter = term.backend().presenter();
assert_eq!(presenter.cells.borrow().get(&(0, 0)), Some(&'@'));
assert_eq!(presenter.draw_calls.get(), 1);
}
#[test]
fn present_count_advances_once_per_present_call() {
let mut term = Terminal::new(WindowBackend::new(GridRecordingPresenter::default()));
assert_eq!(term.present_count(), 0);
term.present().expect("present");
assert_eq!(term.present_count(), 1);
term.present().expect("present");
assert_eq!(term.present_count(), 2);
}
type FatalApp = WindowApp<
FatalPresenter,
fn(&mut Terminal<WindowBackend<FatalPresenter>>),
u64,
fn(u64, &mut Terminal<WindowBackend<FatalPresenter>>),
>;
fn fatal_app() -> (FatalApp, Rc<Cell<bool>>, Rc<Cell<u32>>) {
let failing = Rc::new(Cell::new(false));
let init_surface_calls = Rc::new(Cell::new(0));
let presenter = FatalPresenter {
failing: failing.clone(),
init_surface_calls: init_surface_calls.clone(),
};
let terminal = Terminal::new(WindowBackend::new(presenter));
let app: FatalApp = WindowApp {
terminal: Some(terminal),
app_loop: (|_| {}) as fn(&mut Terminal<WindowBackend<FatalPresenter>>),
on_custom_event: push_custom_event,
_user_event: PhantomData,
window: None,
title: String::new(),
init_size: InitWindowSize {
width: 80,
height: 80,
},
attrs: WindowAttrs::default(),
current_modifiers: KeyModifiers::NONE,
cursor_px: (0.0, 0.0),
active_touch: None,
held_buttons: 0,
frame_interval: None,
event_driven: true,
#[cfg(not(target_arch = "wasm32"))]
next_frame: std::time::Instant::now(),
exit_requested: Rc::new(Cell::new(false)),
skip_present: Rc::new(Cell::new(false)),
needs_redraw: false,
consecutive_present_errors: 0,
};
(app, failing, init_surface_calls)
}
#[test]
fn unrecoverable_present_failure_never_attempts_recovery_even_past_the_threshold() {
let (mut app, failing, init_calls) = fatal_app();
failing.set(true);
for _ in 0..2 * PRESENT_FAILURE_RECOVERY_THRESHOLD {
app.handle_redraw_requested();
}
assert_eq!(init_calls.get(), 0);
}
#[test]
fn unrecoverable_present_failure_does_not_panic_and_keeps_counting() {
let (mut app, failing, _init_calls) = fatal_app();
failing.set(true);
for _ in 0..5 {
app.handle_redraw_requested();
}
assert_eq!(app.consecutive_present_errors, 5);
}
#[test]
fn recovering_from_an_unrecoverable_failure_streak_still_resets_the_counter() {
let (mut app, failing, _init_calls) = fatal_app();
failing.set(true);
for _ in 0..3 {
app.handle_redraw_requested();
}
assert_eq!(app.consecutive_present_errors, 3);
failing.set(false);
app.handle_redraw_requested();
assert_eq!(app.consecutive_present_errors, 0);
}
}