Skip to main content

renamite_player_ui/
lib.rs

1//! Repose embed widget for `renamite_player::Player`.
2//!
3//! A thin spine-style runtime shell: [`PlayerHost`] owns the player plus the
4//! per-surface view/renderer state, and [`RenamitePlayer`] is a drop-in
5//! `repose_canvas::Canvas` that fits the artboard, paints the evaluated
6//! `Scene`, forwards pointer input, and drives playback while playing.
7//!
8//! ```no_run
9//! use renamite_player_ui::{PlayerHost, RenamitePlayer};
10//! use repose_core::RenderContext;
11//! use std::cell::RefCell;
12//! use std::rc::Rc;
13//!
14//! let host = Rc::new(RefCell::new(
15//!     PlayerHost::from_ren_str("(document ...)").unwrap(),
16//! ));
17//! // host.play(); // or host.toggle(); buttons keep ticking while playing.
18//! // In the view tree, with a live `RenderContext`:
19//! let render_context = RenderContext::new();
20//! RenamitePlayer(host, render_context);
21//! ```
22//!
23//! Only the caveat that pointer input is routed in *world* coordinates: the
24//! widget converts surface px through the current [`ViewTransform`], so hit
25//! tests (machine listeners) match what the user sees.
26
27use std::cell::RefCell;
28use std::rc::Rc;
29
30use glam::DVec2;
31use renamite_behavior_common::fit_exact_view;
32use renamite_player::{Player, PlayerError};
33use renamite_render_bridge::SceneRenderer;
34use repose_canvas::{Canvas, DrawScope};
35use repose_core::input::PointerEvent;
36use repose_core::{Modifier, RenderContext, Vec2, View, request_frame, theme};
37use web_time::Instant;
38
39/// Shared host handle: the embed owner builds it and passes it to the widget.
40pub type PlayerHostRef = Rc<RefCell<PlayerHost>>;
41
42/// Re-exported for embedders that paint chrome or read the view transform
43/// without depending on `renamite-behavior-common` directly.
44pub use renamite_behavior_common::{FitState, ViewTransform};
45
46/// Per-surface state for one embedded player: the engine, the tessellator
47/// (`SceneRenderer`), the viewport fit/zoom mapping, and playback control.
48pub struct PlayerHost {
49    pub player: Player,
50    pub renderer: SceneRenderer,
51    /// Screen - world mapping for the embed surface.
52    pub view: ViewTransform,
53    /// True while the widget keeps ticking playback each frame.
54    pub playing: bool,
55    pub last_tick: Instant,
56    /// Set when image assets changed. Uploaded to `RenderContext` on the next
57    /// draw. Constructors mark it dirty so a fresh renderer uploads once.
58    pub dirty_images: bool,
59    /// Most recent pointer position in surface px (wheel-zoom anchor).
60    pub last_pointer: DVec2,
61    /// Fit-state tracker: remembers the last fitted surface (+ artboard)
62    /// and only refits on resize or explicit invalidation, so interactive
63    /// zoom survives redraws.
64    pub fit: FitState,
65}
66
67impl PlayerHost {
68    pub fn new(player: Player) -> Self {
69        Self {
70            player,
71            renderer: SceneRenderer::new(),
72            view: ViewTransform::identity(),
73            playing: true,
74            last_tick: Instant::now(),
75            dirty_images: true,
76            last_pointer: DVec2::ZERO,
77            fit: FitState::new(),
78        }
79    }
80
81    pub fn from_ren_str(text: &str) -> Result<Self, PlayerError> {
82        Ok(Self::new(Player::from_ren_str(text)?))
83    }
84
85    #[cfg(feature = "binary")]
86    pub fn from_ren_bytes(bytes: &[u8]) -> Result<Self, PlayerError> {
87        Ok(Self::new(Player::from_ren_bytes(bytes)?))
88    }
89
90    /// Artboard size of the active composition.
91    pub fn artboard(&self) -> DVec2 {
92        let comp = self.player.composition();
93        match self.player.project.document.compositions.get(comp) {
94            Some(c) => DVec2::new(c.size.0 as f64, c.size.1 as f64),
95            None => DVec2::ZERO,
96        }
97    }
98
99    /// Mark image assets dirty so the next draw re-uploads them to
100    /// `RenderContext`. Call after replacing the player's project.
101    pub fn mark_images_dirty(&mut self) {
102        self.dirty_images = true;
103    }
104
105    pub fn play(&mut self) {
106        self.playing = true;
107        self.last_tick = Instant::now();
108        request_frame();
109    }
110
111    pub fn pause(&mut self) {
112        self.playing = false;
113    }
114
115    pub fn toggle(&mut self) {
116        if self.playing {
117            self.pause();
118        } else {
119            self.play();
120        }
121    }
122
123    /// Advance playback by the wall-clock delta since the last call. Returns
124    /// true while playing so callers (or hosts with their own loop) can keep
125    /// requesting frames. Zero-cost whenever paused.
126    pub fn tick_playback(&mut self) -> bool {
127        if !self.playing {
128            return false;
129        }
130        let now = Instant::now();
131        let dt = now.duration_since(self.last_tick).as_secs_f64().min(0.05);
132        self.last_tick = now;
133        let _ = self.player.tick(dt);
134        true
135    }
136
137    /// Advance playback by a fixed `dt_secs`, for sim-driven embeds where
138    /// pause must freeze playback (no wall clock, no 50 ms clamp).
139    /// Returns true while playing so callers can keep requesting frames.
140    /// Zero-cost whenever paused.
141    pub fn tick_playback_fixed(&mut self, dt_secs: f64) -> bool {
142        if !self.playing {
143            return false;
144        }
145        let dt = dt_secs.max(0.0);
146        self.last_tick = Instant::now();
147        let _ = self.player.tick(dt);
148        true
149    }
150
151    /// Refit on resize via the shared [`FitState`] tracker, so interactive
152    /// zoom survives redraws. Pan gestures opt out by skipping this call.
153    fn ensure_fit(&mut self, surface: DVec2) {
154        let artboard = self.artboard();
155        self.fit.ensure(&mut self.view, surface, artboard, true);
156    }
157
158    /// Fit the artboard inside `surface` with a margin, centering it.
159    /// Records `surface` so [`zoom_at`](Self::zoom_at) /
160    /// [`zoom_centered`](Self::zoom_centered) work after an explicit fit.
161    pub fn fit(&mut self, surface: DVec2) {
162        let artboard = self.artboard();
163        self.fit.request();
164        if surface.x > 1.0 && surface.y > 1.0 {
165            self.fit.ensure(&mut self.view, surface, artboard, true);
166        }
167    }
168
169    /// Exact-fit the artboard with no margin (presentational embeds):
170    /// records `surface` and centers, letterboxing on aspect mismatch.
171    pub fn fit_exact(&mut self, surface: DVec2) {
172        let artboard = self.artboard();
173        if surface.x > 1.0 && surface.y > 1.0 {
174            self.fit = FitState::with_surface(surface);
175            fit_exact_view(&mut self.view, surface, artboard);
176        }
177    }
178
179    pub fn zoom_centered(&mut self, factor: f64) {
180        self.fit.zoom_centered(&mut self.view, factor);
181    }
182
183    /// Zoom by `factor` keeping the world point under `screen_pos` anchored.
184    pub fn zoom_at(&mut self, screen_pos: DVec2, factor: f64) {
185        self.fit.zoom_at(&mut self.view, screen_pos, factor);
186    }
187
188    /// Last fitted surface size (zero before first layout).
189    pub fn surface_size(&self) -> DVec2 {
190        self.fit.surface_size()
191    }
192}
193
194fn pe_position(pe: &PointerEvent) -> DVec2 {
195    DVec2::new(pe.position.x as f64, pe.position.y as f64)
196}
197
198#[allow(non_snake_case)]
199/// Drop-in embed: a full-surface `Canvas` that fits and paints the active
200/// composition of `host`'s player, forwards pointer/scroll input, and ticks
201/// playback each frame while `host.playing`.
202pub fn RenamitePlayer(host: PlayerHostRef, render_context: RenderContext) -> View {
203    let draw = host.clone();
204
205    Canvas(
206        Modifier::new()
207            .fill_max_size()
208            .background(theme().surface_container_lowest)
209            .on_scroll({
210                let host = host.clone();
211                move |delta: Vec2| {
212                    let mut h = host.borrow_mut();
213                    let factor = (1.0 + (-delta.y as f64) * 0.002).clamp(0.5, 2.0);
214                    let anchor = h.last_pointer;
215                    h.zoom_at(anchor, factor);
216                    request_frame();
217                    Vec2::ZERO
218                }
219            })
220            .on_pointer_down({
221                let host = host.clone();
222                move |pe: PointerEvent| {
223                    let mut h = host.borrow_mut();
224                    h.last_pointer = pe_position(&pe);
225                    let world = h.view.screen_to_world(h.last_pointer);
226                    h.player.pointer_down(world);
227                    request_frame();
228                }
229            })
230            .on_pointer_up({
231                let host = host.clone();
232                move |pe: PointerEvent| {
233                    let mut h = host.borrow_mut();
234                    let world = h.view.screen_to_world(pe_position(&pe));
235                    h.player.pointer_up(world);
236                    request_frame();
237                }
238            })
239            .on_pointer_move({
240                let host = host.clone();
241                move |pe: PointerEvent| {
242                    let mut h = host.borrow_mut();
243                    h.last_pointer = pe_position(&pe);
244                    let world = h.view.screen_to_world(h.last_pointer);
245                    h.player.pointer_move(world);
246                    request_frame();
247                }
248            })
249            .on_pointer_leave({
250                let host = host.clone();
251                move |_pe: PointerEvent| {
252                    host.borrow_mut().player.pointer_leave();
253                    request_frame();
254                }
255            }),
256        move |scope| {
257            let mut h = draw.borrow_mut();
258
259            if h.tick_playback() {
260                request_frame();
261            }
262
263            let surface = DVec2::new(scope.size.width as f64, scope.size.height as f64);
264            h.ensure_fit(surface);
265
266            if h.dirty_images {
267                let PlayerHost {
268                    renderer, player, ..
269                } = &mut *h;
270                renderer.sync_document_images(&player.project.document, &render_context);
271                h.dirty_images = false;
272            }
273
274            paint_artboard(scope, h.artboard(), &h.view);
275
276            let scene = h.player.scene().clone();
277            let view = h.view;
278            let prepared = h.renderer.prepare(&scene, &view);
279            h.renderer.paint_prepared(&prepared, scope);
280        },
281    )
282}
283
284/// Artboard backplate shared with the editor viewport
285/// (see `SceneRenderer::paint_artboard_chrome`).
286fn paint_artboard(scope: &mut DrawScope, artboard: DVec2, view: &ViewTransform) {
287    SceneRenderer::paint_artboard_chrome(scope, artboard, view);
288}