Skip to main content

perspective_viewer/
renderer.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! `Renderer` owns the JavaScript Custom Element plugin, as well as
14//! associated state such as column restrictions and `plugin_config`
15//! (de-)serialization.
16//!
17//! `Renderer` wraps a smart pointer and is meant to be shared among many
18//! references throughout the application.
19
20mod activate;
21pub mod limits;
22mod plugin_store;
23mod props;
24mod registry;
25mod render_timer;
26
27use std::cell::{Ref, RefCell};
28use std::collections::HashMap;
29use std::future::Future;
30use std::ops::Deref;
31use std::pin::Pin;
32use std::rc::Rc;
33
34use futures::future::{join_all, select_all};
35use perspective_client::utils::*;
36use perspective_client::{View, ViewWindow};
37use perspective_js::json;
38use perspective_js::utils::{ApiResult, ResultTApiErrorExt};
39use wasm_bindgen::prelude::*;
40use web_sys::*;
41use yew::html::ImplicitClone;
42use yew::prelude::*;
43
44use self::activate::*;
45pub use self::limits::RenderLimits;
46use self::limits::*;
47use self::plugin_store::*;
48pub use self::props::RendererProps;
49pub use self::registry::*;
50use self::render_timer::*;
51use crate::config::*;
52use crate::js::plugin::*;
53use crate::presentation::ColumnConfigMap;
54use crate::utils::*;
55
56/// Immutable state
57pub struct RendererData {
58    plugin_data: RefCell<RendererMutData>,
59    draw_lock: DebounceMutex,
60    pub plugin_changed: PubSub<JsPerspectiveViewerPlugin>,
61    pub style_changed: PubSub<()>,
62    pub reset_changed: PubSub<()>,
63
64    /// Injected callback from the root component, replacing the former
65    /// `render_limits_changed: PubSub` field.  Fires after every draw/update
66    /// with the computed render limits.
67    pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
68}
69
70/// Mutable state
71pub struct RendererMutData {
72    viewer_elem: HtmlElement,
73    metadata: ViewConfigRequirements,
74    plugin_store: PluginStore,
75    plugins_idx: Option<usize>,
76    timer: MovingWindowRenderTimer,
77    selection: Option<ViewWindow>,
78    pending_plugin: Option<usize>,
79}
80
81/// The state object responsible for the active [`JsPerspectiveViewerPlugin`].
82#[derive(Clone)]
83pub struct Renderer(Rc<RendererData>);
84
85impl Deref for Renderer {
86    type Target = RendererData;
87
88    fn deref(&self) -> &Self::Target {
89        &self.0
90    }
91}
92
93impl PartialEq for Renderer {
94    fn eq(&self, other: &Self) -> bool {
95        Rc::ptr_eq(&self.0, &other.0)
96    }
97}
98
99impl ImplicitClone for Renderer {}
100
101impl Deref for RendererData {
102    type Target = RefCell<RendererMutData>;
103
104    fn deref(&self) -> &Self::Target {
105        &self.plugin_data
106    }
107}
108
109type TaskResult = ApiResult<JsValue>;
110type TimeoutTask<'a> = Pin<Box<dyn Future<Output = Option<TaskResult>> + 'a>>;
111
112/// How long to await a call to the plugin's `draw()` before resizing.
113static PRESIZE_TIMEOUT: i32 = 500;
114
115impl Renderer {
116    pub fn new(viewer_elem: &HtmlElement) -> Self {
117        Self(Rc::new(RendererData {
118            plugin_data: RefCell::new(RendererMutData {
119                viewer_elem: viewer_elem.clone(),
120                metadata: ViewConfigRequirements::default(),
121                plugin_store: PluginStore::default(),
122                plugins_idx: None,
123                selection: None,
124                timer: MovingWindowRenderTimer::default(),
125                pending_plugin: None,
126            }),
127            draw_lock: Default::default(),
128            plugin_changed: Default::default(),
129            style_changed: Default::default(),
130            reset_changed: Default::default(),
131            on_render_limits_changed: Default::default(),
132        }))
133    }
134
135    pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> {
136        self.0.borrow_mut().plugins_idx = None;
137        if let Ok(plugin) = self.get_active_plugin() {
138            plugin.restore(&json!({}), columns_config)?;
139        }
140
141        Ok(())
142    }
143
144    pub fn delete(&self) -> ApiResult<()> {
145        self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
146        self.plugin_data.borrow().viewer_elem.set_inner_text("");
147        let new_state = Self::new(&self.plugin_data.borrow().viewer_elem);
148        std::mem::swap(
149            &mut *self.plugin_data.borrow_mut(),
150            &mut *new_state.plugin_data.borrow_mut(),
151        );
152
153        Ok(())
154    }
155
156    pub fn metadata(&self) -> Ref<'_, ViewConfigRequirements> {
157        Ref::map(self.borrow(), |x| &x.metadata)
158    }
159
160    pub fn is_chart(&self) -> bool {
161        let plugin = self.get_active_plugin().unwrap();
162        plugin.name().as_str() != "Datagrid"
163    }
164
165    /// Return all plugin instances, whether they are active or not.  Useful
166    /// for configuring all or specific plugins at application init.
167    pub fn get_all_plugins(&self) -> Vec<JsPerspectiveViewerPlugin> {
168        self.0.borrow_mut().plugin_store.plugins().clone()
169    }
170
171    /// Return all plugin names, whether they are active or not.
172    pub fn get_all_plugin_categories(&self) -> HashMap<String, Vec<String>> {
173        self.0.borrow_mut().plugin_store.plugin_records().clone()
174    }
175
176    /// Gets the currently active plugin.  Calling this method before a plugin
177    /// has been selected will cause the default (first) plugin to be
178    /// selected, and doing so when no plugins have been registered is an
179    /// error.
180    pub fn get_active_plugin(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
181        if self.0.borrow().plugins_idx.is_none() {
182            let _ = self.apply_pending_plugin()?;
183        }
184
185        let idx = self.0.borrow().plugins_idx.unwrap_or(0);
186        let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
187        Ok(result.ok_or("No Plugin")?)
188    }
189
190    /// Gets a specific `JsPerspectiveViewerPlugin` by name.
191    ///
192    /// # Arguments
193    /// - `name` The plugin name to lookup.
194    pub fn get_plugin(&self, name: &str) -> ApiResult<JsPerspectiveViewerPlugin> {
195        let idx = self.find_plugin_idx(name);
196        let idx = idx.ok_or_else(|| JsValue::from(format!("No Plugin `{name}`")))?;
197        let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
198        Ok(result.unwrap())
199    }
200
201    pub fn is_plugin_activated(&self) -> ApiResult<bool> {
202        Ok(self
203            .get_active_plugin()?
204            .unchecked_ref::<HtmlElement>()
205            .is_connected())
206    }
207
208    pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
209        let plugins = self.get_all_plugins();
210        let tasks = plugins
211            .iter()
212            .map(|plugin| plugin.restyle(view.clone().into()));
213
214        join_all(tasks)
215            .await
216            .into_iter()
217            .collect::<Result<Vec<_>, _>>()
218            .map(|_| JsValue::UNDEFINED)
219    }
220
221    pub fn set_throttle(&self, val: Option<f64>) {
222        self.0.borrow_mut().timer.set_throttle(val);
223    }
224
225    pub fn set_selection(&self, window: Option<ViewWindow>) {
226        self.borrow_mut().selection = window
227    }
228
229    pub fn get_selection(&self) -> Option<ViewWindow> {
230        self.borrow().selection.clone()
231    }
232
233    pub fn disable_active_plugin_render_warning(&self) {
234        self.borrow_mut().metadata.render_warning = false;
235        self.get_active_plugin().unwrap().set_render_warning(false);
236    }
237
238    pub fn get_next_plugin_metadata(
239        &self,
240        update: &PluginUpdate,
241    ) -> Option<ViewConfigRequirements> {
242        let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
243        let name = match update {
244            PluginUpdate::Missing => return None,
245            PluginUpdate::SetDefault => default_plugin_name.as_str(),
246            PluginUpdate::Update(plugin) => plugin,
247        };
248
249        let idx = self.find_plugin_idx(name)?;
250        let changed = !matches!(
251            self.0.borrow().plugins_idx,
252            Some(selected_idx) if selected_idx == idx
253        );
254
255        if changed {
256            self.borrow_mut().pending_plugin = Some(idx);
257            self.get_plugin(name)
258                .and_then(|x| x.get_requirements())
259                .ok()
260        } else {
261            None
262        }
263    }
264
265    pub fn apply_pending_plugin(&self) -> ApiResult<bool> {
266        let xxx = self.borrow_mut().pending_plugin.take();
267        if let Some(idx) = xxx {
268            let changed = !matches!(
269                self.0.borrow().plugins_idx,
270                Some(selected_idx) if selected_idx == idx
271            );
272
273            if changed {
274                self.borrow_mut().plugins_idx = Some(idx);
275                let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
276                self.borrow_mut().metadata = plugin.get_requirements()?;
277                self.plugin_changed.emit(plugin);
278            }
279
280            Ok(changed)
281        } else {
282            if self.0.borrow().plugins_idx.is_none() {
283                self.set_plugin(Some(&PLUGIN_REGISTRY.default_plugin_name()))?;
284            }
285
286            Ok(false)
287        }
288    }
289
290    fn set_plugin(&self, name: Option<&str>) -> ApiResult<bool> {
291        self.borrow_mut().pending_plugin = None;
292        let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
293        let name = name.unwrap_or(default_plugin_name.as_str());
294        let idx = self
295            .find_plugin_idx(name)
296            .ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
297
298        let changed = !matches!(
299            self.0.borrow().plugins_idx,
300            Some(selected_idx) if selected_idx == idx
301        );
302
303        if changed {
304            self.borrow_mut().plugins_idx = Some(idx);
305            let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
306            self.borrow_mut().metadata = plugin.get_requirements()?;
307            self.plugin_changed.emit(plugin);
308        }
309
310        Ok(changed)
311    }
312
313    pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
314        let draw_mutex = self.draw_lock();
315        draw_mutex.lock(task).await
316    }
317
318    pub async fn resize(&self) -> ApiResult<()> {
319        let draw_mutex = self.draw_lock();
320        let timer = self.render_timer();
321        draw_mutex
322            .debounce(async {
323                set_timeout(timer.get_throttle()).await?;
324                let jsplugin = self.get_active_plugin()?;
325                jsplugin.resize().await?;
326                Ok(())
327            })
328            .await
329    }
330
331    pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> {
332        let draw_mutex = self.draw_lock();
333        let timer = self.render_timer();
334        draw_mutex
335            .debounce(async {
336                set_timeout(timer.get_throttle()).await?;
337                let plugin = self.get_active_plugin()?;
338                let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
339                let rect = main_panel.get_bounding_client_rect();
340                if (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5 {
341                    let new_width = format!("{}px", width);
342                    let new_height = format!("{}px", height);
343                    main_panel.style().set_property("width", &new_width)?;
344                    main_panel.style().set_property("height", &new_height)?;
345                    let result = plugin.resize().await;
346                    main_panel.style().set_property("width", "")?;
347                    main_panel.style().set_property("height", "")?;
348                    result?;
349                }
350
351                Ok(())
352            })
353            .await
354    }
355
356    /// This will take a future which _should_ create a new view and then will
357    /// draw it. As the `session` closure is asynchronous, it can be cancelled
358    /// by returning `None`.
359    pub async fn draw(
360        &self,
361        session: impl Future<Output = ApiResult<Option<View>>>,
362    ) -> ApiResult<()> {
363        self.draw_plugin(session, false).await
364    }
365
366    /// This will update an already existing view
367    pub async fn update(&self, session: Option<View>) -> ApiResult<()> {
368        self.draw_plugin(async { Ok(session) }, true).await
369    }
370
371    async fn draw_plugin(
372        &self,
373        session: impl Future<Output = ApiResult<Option<View>>>,
374        is_update: bool,
375    ) -> ApiResult<()> {
376        let timer = self.render_timer();
377        let task = async move {
378            if is_update {
379                set_timeout(timer.get_throttle()).await?;
380            }
381
382            if let Some(view) = session.await? {
383                timer.capture_time(self.draw_view(&view, is_update)).await
384            } else {
385                tracing::debug!("Render skipped, no `View` attached");
386                Ok(())
387            }
388        };
389
390        let draw_mutex = self.draw_lock();
391        if is_update {
392            draw_mutex.debounce(task).await
393        } else {
394            draw_mutex.lock(task).await
395        }
396    }
397
398    async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
399        let plugin = self.get_active_plugin()?;
400        let meta = self.metadata().clone();
401        let mut limits = get_row_and_col_limits(view, &meta).await?;
402        limits.is_update = is_update;
403        if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
404            cb.emit(limits);
405        }
406
407        let viewer_elem = &self.0.borrow().viewer_elem.clone();
408        let result = if is_update {
409            let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
410            activate_plugin(viewer_elem, &plugin, task).await
411        } else {
412            let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
413            activate_plugin(viewer_elem, &plugin, task).await
414        };
415
416        if let Err(error) = result.ignore_view_delete() {
417            tracing::warn!("{}", error);
418        }
419
420        remove_inactive_plugin(
421            viewer_elem,
422            &plugin,
423            self.plugin_data.borrow_mut().plugin_store.plugins(),
424        )
425    }
426
427    /// Decide whether to draw plugin or self first based on whether the panel
428    /// is opening or closing, then draw with a timeout.  If the timeout
429    /// triggers, draw self and resolve `on_toggle` but still await the
430    /// completion of the draw task.
431    pub async fn presize(
432        &self,
433        open: bool,
434        panel_task: impl Future<Output = ApiResult<()>>,
435    ) -> ApiResult<JsValue> {
436        let render_task = self.resize_with_timeout(open);
437        let result = if open {
438            panel_task.await?;
439            render_task.await
440        } else {
441            let result = render_task.await;
442            panel_task.await?;
443            result
444        };
445
446        match result {
447            Ok(x) => x,
448            Err(cont) => {
449                tracing::warn!("Presize took longer than {}ms", PRESIZE_TIMEOUT);
450                cont.await.unwrap()
451            },
452        }
453    }
454
455    /// Lock on `resize()` task, in parallel with a timeout.  In the return
456    /// type, `Result::Err` contains the continuation task, which must be
457    /// awaited lest the plugin draw itself never trigger.
458    async fn resize_with_timeout(&self, open: bool) -> Result<TaskResult, TimeoutTask<'_>> {
459        let task = async move {
460            if open {
461                self.get_active_plugin()?.resize().await
462            } else {
463                self.resize_with_explicit_dimensions().await
464            }
465        };
466
467        let draw_lock = self.draw_lock();
468        let tasks: [TimeoutTask<'_>; 2] = [
469            Box::pin(async move { Some(draw_lock.lock(task).await) }),
470            Box::pin(async {
471                set_timeout(PRESIZE_TIMEOUT).await.unwrap();
472                None
473            }),
474        ];
475
476        let (x, _, y) = select_all(tasks.into_iter()).await;
477        x.ok_or_else(|| y.into_iter().next().unwrap())
478    }
479
480    /// Resize the `<div>` offscreen, then resize the plugin
481    async fn resize_with_explicit_dimensions(&self) -> TaskResult {
482        let plugin = self.get_active_plugin()?;
483        let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
484        let new_width = format!("{}px", &self.0.borrow().viewer_elem.client_width());
485        let new_height = format!("{}px", &self.0.borrow().viewer_elem.client_height());
486        main_panel.style().set_property("width", &new_width)?;
487        main_panel.style().set_property("height", &new_height)?;
488        let result = plugin.resize().await;
489        main_panel.style().set_property("width", "")?;
490        main_panel.style().set_property("height", "")?;
491        result
492    }
493
494    fn draw_lock(&self) -> DebounceMutex {
495        self.draw_lock.clone()
496    }
497
498    pub fn render_timer(&self) -> MovingWindowRenderTimer {
499        self.0.borrow().timer.clone()
500    }
501
502    fn find_plugin_idx(&self, name: &str) -> Option<usize> {
503        let short_name = make_short_name(name);
504        self.0
505            .borrow_mut()
506            .plugin_store
507            .plugins()
508            .iter()
509            .position(|elem| make_short_name(&elem.name()).contains(&short_name))
510    }
511}
512
513fn make_short_name(name: &str) -> String {
514    name.to_lowercase()
515        .chars()
516        .filter(|x| x.is_alphabetic())
517        .collect()
518}
519
520impl Renderer {
521    /// Snapshot the current renderer state as a [`RendererProps`] value
522    /// suitable for passing as a Yew prop.  Called by the root component
523    /// whenever a renderer-related PubSub event fires.
524    pub fn to_props(&self, render_limits: Option<RenderLimits>) -> RendererProps {
525        // Guard: don't touch the PluginStore if no plugin has been explicitly
526        // selected yet.  Calling `get_active_plugin()` or `get_all_plugins()`
527        // triggers `PluginStore::init_lazy()`, which snapshots the
528        // PLUGIN_REGISTRY.  If this happens during component `create()` —
529        // before JavaScript has called `registerPlugin()` — the cache will
530        // only contain the default Debug plugin and custom plugins registered
531        // later will never be found.
532        let has_plugin = self.0.borrow().plugins_idx.is_some();
533        if has_plugin {
534            let plugin_name = self.get_active_plugin().ok().map(|p| p.name());
535            let requirements = self.metadata().clone();
536            let available_plugins = self
537                .get_all_plugins()
538                .into_iter()
539                .map(|p| p.name())
540                .collect::<Vec<_>>()
541                .into();
542
543            RendererProps {
544                plugin_name,
545                requirements,
546                render_limits,
547                available_plugins,
548            }
549        } else {
550            RendererProps {
551                plugin_name: None,
552                requirements: ViewConfigRequirements::default(),
553                render_limits,
554                available_plugins: PtrEqRc::new(vec![]),
555            }
556        }
557    }
558}