use std::sync::{Arc, Mutex, PoisonError};
use std::collections::HashMap;
use fenestra_core::{
AccessNode, App, Element, Frame, FrameState, InputEvent, KeyInput, MAIN_WINDOW, Proxy, Query,
Theme, build_frame, dispatch,
};
use image::RgbaImage;
use crate::element_render::with_fonts;
use crate::with_headless;
struct WindowSlot<Msg> {
state: FrameState,
view: Element<Msg>,
frame: Frame,
logical: (f32, f32),
size: (u32, u32),
}
pub struct Harness<A: App> {
app: A,
theme: Theme,
clock: f64,
msgs: Vec<A::Msg>,
pending: Arc<Mutex<Vec<A::Msg>>>,
slots: HashMap<String, WindowSlot<A::Msg>>,
active: String,
}
impl<A: App> Harness<A>
where
A::Msg: Send,
{
pub fn new(mut app: A, theme: Theme, size: (u32, u32)) -> Self {
let size =
with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&pending);
app.init(Proxy::new(move |msg| {
sink.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(msg);
}));
Self::drain(&mut app, &pending);
let mut harness = Self {
app,
theme,
clock: 0.0,
msgs: Vec::new(),
pending,
slots: HashMap::new(),
active: MAIN_WINDOW.to_owned(),
};
harness.slots.insert(
MAIN_WINDOW.to_owned(),
Self::new_slot(&harness.app, &harness.theme, MAIN_WINDOW, size, 0.0),
);
harness.rebuild();
harness
}
fn new_slot(
app: &A,
theme: &Theme,
key: &str,
size: (u32, u32),
clock: f64,
) -> WindowSlot<A::Msg> {
let size =
with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
let mut state = FrameState::new();
state.reduced_motion = true;
state.tick(clock);
#[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
let logical = (size.0 as f32, size.1 as f32);
let view = app.view_for(key);
let frame = with_fonts(|fonts| build_frame(&view, theme, fonts, &mut state, logical, 1.0));
WindowSlot {
state,
view,
frame,
logical,
size,
}
}
fn drain(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
for msg in msgs {
app.update(msg);
}
}
pub fn rebuild(&mut self) {
Self::drain(&mut self.app, &self.pending);
let descs = self.app.windows();
self.slots
.retain(|key, _| key == MAIN_WINDOW || descs.iter().any(|d| &d.key == key));
for desc in &descs {
if !self.slots.contains_key(&desc.key) {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "logical window sizes are small positive numbers"
)]
let size = (desc.size.0.max(1.0) as u32, desc.size.1.max(1.0) as u32);
let slot = Self::new_slot(&self.app, &self.theme, &desc.key, size, self.clock);
self.slots.insert(desc.key.clone(), slot);
}
}
if !self.slots.contains_key(&self.active) {
self.active = MAIN_WINDOW.to_owned();
}
let keys: Vec<String> = self.slots.keys().cloned().collect();
for key in keys {
let slot = self.slots.get_mut(&key).expect("slot exists");
slot.view = self.app.view_for(&key);
slot.state.tick(self.clock);
slot.frame = with_fonts(|fonts| {
build_frame(
&slot.view,
&self.theme,
fonts,
&mut slot.state,
slot.logical,
1.0,
)
});
}
}
fn slot(&self) -> &WindowSlot<A::Msg> {
self.slots.get(&self.active).expect("active slot exists")
}
pub fn activate_window(&mut self, key: &str) {
assert!(
self.slots.contains_key(key),
"no open window {key:?}; open windows: {:?}",
self.window_keys()
);
self.active = key.to_owned();
}
pub fn window_keys(&self) -> Vec<String> {
let mut keys: Vec<String> = self.slots.keys().cloned().collect();
keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
keys
}
pub fn input(&mut self, event: InputEvent) {
let slot = self
.slots
.get_mut(&self.active)
.expect("active slot exists");
let result =
with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
for msg in result.msgs {
self.msgs.push(msg.clone());
self.app.update(msg);
}
self.rebuild();
}
fn center(&self, q: &Query) -> (f32, f32) {
let node = self.slot().frame.get(q);
let c = node.rect.center();
#[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
(c.x as f32, c.y as f32)
}
pub fn hover(&mut self, q: &Query) {
let (x, y) = self.center(q);
self.input(InputEvent::PointerMove { x, y });
}
pub fn click(&mut self, q: &Query) {
self.hover(q);
self.input(InputEvent::PointerDown);
self.input(InputEvent::PointerUp);
}
pub fn right_click(&mut self, q: &Query) {
self.hover(q);
self.input(InputEvent::RightDown);
self.input(InputEvent::RightUp);
}
pub fn double_click(&mut self, q: &Query) {
self.click(q);
self.click(q);
}
pub fn type_text(&mut self, text: impl Into<String>) {
self.input(InputEvent::Text(text.into()));
}
pub fn key(&mut self, key: KeyInput) {
self.input(InputEvent::Key(key));
}
pub fn tab(&mut self) {
self.input(InputEvent::Tab);
}
pub fn shift_tab(&mut self) {
self.input(InputEvent::ShiftTab);
}
pub fn focus(&mut self, q: &Query) {
let slot = self
.slots
.get_mut(&self.active)
.expect("active slot exists");
let id = slot.frame.get(q).id;
slot.state.set_focus(Some(id));
self.rebuild();
}
pub fn drag(&mut self, from: &Query, to: &Query) {
self.hover(from);
self.input(InputEvent::PointerDown);
let (x, y) = self.center(to);
self.input(InputEvent::PointerMove { x, y });
self.input(InputEvent::PointerUp);
}
pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
self.hover(q);
self.input(InputEvent::FileDrop(path.into()));
}
pub fn wheel(&mut self, q: &Query, dy: f32) {
self.hover(q);
self.input(InputEvent::Wheel { dy });
}
pub fn pump(&mut self, ms: f64) {
self.clock += ms / 1000.0;
self.rebuild();
}
pub fn update(&mut self, msg: A::Msg) {
self.app.update(msg);
self.rebuild();
}
pub fn get(&self, q: &Query) -> AccessNode {
self.slot().frame.get(q)
}
pub fn query(&self, q: &Query) -> Option<AccessNode> {
self.slot().frame.query(q)
}
pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
self.slot().frame.get_all(q)
}
pub fn take_messages(&mut self) -> Vec<A::Msg> {
std::mem::take(&mut self.msgs)
}
pub fn frame(&self) -> &Frame {
&self.slot().frame
}
pub fn app(&self) -> &A {
&self.app
}
pub fn app_mut(&mut self) -> &mut A {
&mut self.app
}
pub fn render(&mut self) -> RgbaImage {
let key = self.active.clone();
self.render_window(&key)
}
pub fn render_window(&mut self, key: &str) -> RgbaImage {
assert!(
self.slots.contains_key(key),
"no open window {key:?}; open windows: {:?}",
self.window_keys()
);
let bg = self.theme.bg;
let slot = self.slots.get_mut(key).expect("checked above");
let scene = with_fonts(|fonts| slot.frame.paint(fonts, &mut slot.state));
with_headless(|h| h.render(&scene, slot.size.0, slot.size.1, bg))
.expect("headless renderer unavailable")
.expect("headless render failed")
}
}