1use std::sync::{Arc, Mutex, PoisonError};
24
25use std::collections::HashMap;
26
27use fenestra_core::{
28 AccessNode, App, Element, Frame, FrameState, InputEvent, KeyInput, MAIN_WINDOW, Proxy, Query,
29 Theme, build_frame, dispatch,
30};
31use image::RgbaImage;
32
33use crate::element_render::with_fonts;
34use crate::with_headless;
35
36struct WindowSlot<Msg> {
39 state: FrameState,
40 view: Element<Msg>,
41 frame: Frame,
42 logical: (f32, f32),
43 size: (u32, u32),
44}
45
46pub struct Harness<A: App> {
48 app: A,
49 theme: Theme,
50 clock: f64,
52 msgs: Vec<A::Msg>,
54 pending: Arc<Mutex<Vec<A::Msg>>>,
55 slots: HashMap<String, WindowSlot<A::Msg>>,
58 active: String,
60}
61
62impl<A: App> Harness<A>
63where
64 A::Msg: Send,
65{
66 pub fn new(mut app: A, theme: Theme, size: (u32, u32)) -> Self {
73 let size =
74 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
75 let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
76 let sink = Arc::clone(&pending);
77 app.init(Proxy::new(move |msg| {
78 sink.lock()
79 .unwrap_or_else(PoisonError::into_inner)
80 .push(msg);
81 }));
82 Self::drain(&mut app, &pending);
83 let mut harness = Self {
84 app,
85 theme,
86 clock: 0.0,
87 msgs: Vec::new(),
88 pending,
89 slots: HashMap::new(),
90 active: MAIN_WINDOW.to_owned(),
91 };
92 harness.slots.insert(
93 MAIN_WINDOW.to_owned(),
94 Self::new_slot(&harness.app, &harness.theme, MAIN_WINDOW, size, 0.0),
95 );
96 harness.rebuild();
97 harness
98 }
99
100 fn new_slot(
101 app: &A,
102 theme: &Theme,
103 key: &str,
104 size: (u32, u32),
105 clock: f64,
106 ) -> WindowSlot<A::Msg> {
107 let size =
108 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
109 let mut state = FrameState::new();
110 state.reduced_motion = true;
111 state.tick(clock);
112 #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
113 let logical = (size.0 as f32, size.1 as f32);
114 let view = app.view_for(key);
115 let frame = with_fonts(|fonts| build_frame(&view, theme, fonts, &mut state, logical, 1.0));
116 WindowSlot {
117 state,
118 view,
119 frame,
120 logical,
121 size,
122 }
123 }
124
125 fn drain(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
126 let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
127 for msg in msgs {
128 app.update(msg);
129 }
130 }
131
132 pub fn rebuild(&mut self) {
138 Self::drain(&mut self.app, &self.pending);
139 let descs = self.app.windows();
140 self.slots
141 .retain(|key, _| key == MAIN_WINDOW || descs.iter().any(|d| &d.key == key));
142 for desc in &descs {
143 if !self.slots.contains_key(&desc.key) {
144 #[expect(
145 clippy::cast_possible_truncation,
146 clippy::cast_sign_loss,
147 reason = "logical window sizes are small positive numbers"
148 )]
149 let size = (desc.size.0.max(1.0) as u32, desc.size.1.max(1.0) as u32);
150 let slot = Self::new_slot(&self.app, &self.theme, &desc.key, size, self.clock);
151 self.slots.insert(desc.key.clone(), slot);
152 }
153 }
154 if !self.slots.contains_key(&self.active) {
155 self.active = MAIN_WINDOW.to_owned();
156 }
157 let keys: Vec<String> = self.slots.keys().cloned().collect();
158 for key in keys {
159 let slot = self.slots.get_mut(&key).expect("slot exists");
160 slot.view = self.app.view_for(&key);
161 slot.state.tick(self.clock);
162 slot.frame = with_fonts(|fonts| {
163 build_frame(
164 &slot.view,
165 &self.theme,
166 fonts,
167 &mut slot.state,
168 slot.logical,
169 1.0,
170 )
171 });
172 }
173 }
174
175 fn slot(&self) -> &WindowSlot<A::Msg> {
176 self.slots.get(&self.active).expect("active slot exists")
177 }
178
179 pub fn activate_window(&mut self, key: &str) {
185 assert!(
186 self.slots.contains_key(key),
187 "no open window {key:?}; open windows: {:?}",
188 self.window_keys()
189 );
190 self.active = key.to_owned();
191 }
192
193 pub fn window_keys(&self) -> Vec<String> {
195 let mut keys: Vec<String> = self.slots.keys().cloned().collect();
196 keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
197 keys
198 }
199
200 pub fn input(&mut self, event: InputEvent) {
204 let slot = self
205 .slots
206 .get_mut(&self.active)
207 .expect("active slot exists");
208 let result =
209 with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
210 for msg in result.msgs {
211 self.msgs.push(msg.clone());
212 self.app.update(msg);
213 }
214 self.rebuild();
215 }
216
217 fn center(&self, q: &Query) -> (f32, f32) {
218 let node = self.slot().frame.get(q);
219 let c = node.rect.center();
220 #[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
221 (c.x as f32, c.y as f32)
222 }
223
224 pub fn hover(&mut self, q: &Query) {
229 let (x, y) = self.center(q);
230 self.input(InputEvent::PointerMove { x, y });
231 }
232
233 pub fn click(&mut self, q: &Query) {
238 self.hover(q);
239 self.input(InputEvent::PointerDown);
240 self.input(InputEvent::PointerUp);
241 }
242
243 pub fn right_click(&mut self, q: &Query) {
248 self.hover(q);
249 self.input(InputEvent::RightDown);
250 self.input(InputEvent::RightUp);
251 }
252
253 pub fn double_click(&mut self, q: &Query) {
259 self.click(q);
260 self.click(q);
261 }
262
263 pub fn type_text(&mut self, text: impl Into<String>) {
265 self.input(InputEvent::Text(text.into()));
266 }
267
268 pub fn key(&mut self, key: KeyInput) {
270 self.input(InputEvent::Key(key));
271 }
272
273 pub fn tab(&mut self) {
275 self.input(InputEvent::Tab);
276 }
277
278 pub fn shift_tab(&mut self) {
280 self.input(InputEvent::ShiftTab);
281 }
282
283 pub fn focus(&mut self, q: &Query) {
289 let slot = self
290 .slots
291 .get_mut(&self.active)
292 .expect("active slot exists");
293 let id = slot.frame.get(q).id;
294 slot.state.set_focus(Some(id));
295 self.rebuild();
296 }
297
298 pub fn drag(&mut self, from: &Query, to: &Query) {
304 self.hover(from);
305 self.input(InputEvent::PointerDown);
306 let (x, y) = self.center(to);
307 self.input(InputEvent::PointerMove { x, y });
308 self.input(InputEvent::PointerUp);
309 }
310
311 pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
316 self.hover(q);
317 self.input(InputEvent::FileDrop(path.into()));
318 }
319
320 pub fn wheel(&mut self, q: &Query, dy: f32) {
326 self.hover(q);
327 self.input(InputEvent::Wheel { dy });
328 }
329
330 pub fn pump(&mut self, ms: f64) {
333 self.clock += ms / 1000.0;
334 self.rebuild();
335 }
336
337 pub fn update(&mut self, msg: A::Msg) {
340 self.app.update(msg);
341 self.rebuild();
342 }
343
344 pub fn get(&self, q: &Query) -> AccessNode {
350 self.slot().frame.get(q)
351 }
352
353 pub fn query(&self, q: &Query) -> Option<AccessNode> {
358 self.slot().frame.query(q)
359 }
360
361 pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
363 self.slot().frame.get_all(q)
364 }
365
366 pub fn take_messages(&mut self) -> Vec<A::Msg> {
370 std::mem::take(&mut self.msgs)
371 }
372
373 pub fn frame(&self) -> &Frame {
376 &self.slot().frame
377 }
378
379 pub fn app(&self) -> &A {
381 &self.app
382 }
383
384 pub fn app_mut(&mut self) -> &mut A {
386 &mut self.app
387 }
388
389 pub fn render(&mut self) -> RgbaImage {
395 let key = self.active.clone();
396 self.render_window(&key)
397 }
398
399 pub fn render_window(&mut self, key: &str) -> RgbaImage {
404 assert!(
405 self.slots.contains_key(key),
406 "no open window {key:?}; open windows: {:?}",
407 self.window_keys()
408 );
409 let bg = self.theme.bg;
410 let slot = self.slots.get_mut(key).expect("checked above");
411 let scene = with_fonts(|fonts| slot.frame.paint(fonts, &mut slot.state));
412 with_headless(|h| h.render(&scene, slot.size.0, slot.size.1, bg))
413 .expect("headless renderer unavailable")
414 .expect("headless render failed")
415 }
416}