Implement Lazy Text Visualisation. (#3910)

Implements [#183453466](https://www.pivotaltracker.com/story/show/183453466).

https://user-images.githubusercontent.com/1428930/203870063-dd9c3941-ce79-4ce9-a772-a2014e900b20.mp4

# Important Notes
* the best laziness is used for `Text` type, which makes use of its internal representation to send data
* any type will first compute its default string representation and then send the content of that lazy to the IDE
* special handling of files and their content will be implemented in the future
* size of the displayed text can be updated dynamically based on best effort information: if the backend does not yet know the full width/height of the text, it can update the IDE at any time and this will be handled gracefully by updating the scrollbar position and sizes.
This commit is contained in:
Michael Mauderer 2023-01-24 20:55:36 +00:00 committed by GitHub
parent bf9508603f
commit 38906b39da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1415 additions and 694 deletions

View File

@ -73,6 +73,9 @@
scrollbar.][3824]
- [Added scroll bounce animation][3836] which activates when scrolling past the
end of scrollable content.
- [The default text visualisation now loads its content lazily from the
backend][3910]. This means that the visualisation cannot be overwhelmed by
large amounts of data.
- [Added project snapshot saving on shortcut][3923]
- [The color of the displayed project name indicates whether the project's
current state is saved in a snapshot.][3950] The project name is darker when
@ -271,6 +274,8 @@
- [Added support for milli and micro seconds, new short form for rename_columns
and fixed issue with compare_to versus Nothing][3874]
- [Aligned `Text.match`/`Text.locate` API][3841]
- [There is a new API to lazily feed visualisation information to the
IDE.][3910]
- [Added `transpose` and `cross_tab` to the In-Memory Table.][3919]
- [Improvements to JSON, Pair, Statistics and other minor tweaks.][3964]
- [Overhauled the JSON support (now based of JavaScript), `Data.fetch` and other
@ -427,6 +432,7 @@
[3852]: https://github.com/enso-org/enso/pull/3852
[3841]: https://github.com/enso-org/enso/pull/3841
[3885]: https://github.com/enso-org/enso/pull/3885
[3910]: https://github.com/enso-org/enso/pull/3910
[3919]: https://github.com/enso-org/enso/pull/3919
[3923]: https://github.com/enso-org/enso/pull/3923
[3950]: https://github.com/enso-org/enso/pull/3950

View File

@ -17,7 +17,7 @@
use ensogl::prelude::*;
use wasm_bindgen::prelude::*;
use crate::text_grid::TextGrid;
use crate::text_visualization::TextGrid;
use ensogl::animation;
use ensogl::application::Application;
@ -26,7 +26,8 @@ use ensogl::system::web;
use ensogl::system::web::traits::DocumentOps;
use ensogl::system::web::traits::ElementOps;
use ensogl_text_msdf::run_once_initialized;
use ide_view::graph_editor::builtin::visualization::native::text_grid;
use ide_view::graph_editor::builtin::visualization::native::text_visualization;
use ide_view::graph_editor::builtin::visualization::native::text_visualization::text_provider;
@ -94,7 +95,8 @@ fn init(app: &Application) {
let camera = scene.camera();
let navigator = Navigator::new(scene, &camera);
let text_source = sample_text();
let sample_text_data = sample_text();
let text_source = text_provider::StringTextProvider::new(sample_text_data);
let grid = TextGrid::new(app.clone_ref());
grid.set_text_provider(text_source);

View File

@ -8,9 +8,8 @@
pub mod bubble_chart;
#[warn(missing_docs)]
pub mod error;
pub mod raw_text;
pub mod text_grid;
pub mod text_visualization;
pub use bubble_chart::BubbleChart;
pub use error::Error;
pub use raw_text::RawText;
pub use text_visualization::TextVisualisation;

View File

@ -1,243 +0,0 @@
//! Example visualisation showing the provided data as text.
use crate::component::visualization::*;
use crate::prelude::*;
use ensogl::system::web::traits::*;
use crate::component::visualization;
use enso_frp as frp;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::scene::Scene;
use ensogl::display::shape::primitive::StyleWatch;
use ensogl::display::DomSymbol;
use ensogl::system::web;
use ensogl_hardcoded_theme;
// =================
// === Constants ===
// =================
const PADDING_TEXT: f32 = 10.0;
// ===============
// === RawText ===
// ===============
/// Sample visualization that renders the given data as text. Useful for debugging and testing.
#[derive(Debug, Shrinkwrap)]
#[allow(missing_docs)]
pub struct RawText {
#[shrinkwrap(main_field)]
model: RawTextModel,
frp: visualization::instance::Frp,
network: frp::Network,
}
impl RawText {
/// Definition of this visualization.
pub fn definition() -> Definition {
let path = Path::builtin("JSON");
Definition::new(Signature::new_for_any_type(path, Format::Json), |app| {
Ok(Self::new(app.clone_ref()).into())
})
}
/// Constructor.
pub fn new(app: Application) -> Self {
let network = frp::Network::new("js_visualization_raw_text");
let frp = visualization::instance::Frp::new(&network);
let model = RawTextModel::new(app);
Self { model, frp, network }.init()
}
fn init(self) -> Self {
let network = &self.network;
let model = self.model.clone_ref();
let frp = self.frp.clone_ref();
frp::extend! { network
eval frp.set_size ((size) model.set_size(*size));
eval frp.send_data ([frp,model](data) {
if let Err(e) = model.receive_data(data) {
frp.data_receive_error.emit(Some(e));
}
});
eval frp.set_layer ((layer) model.set_layer(*layer));
}
self
}
}
#[derive(Clone, CloneRef, Debug)]
#[allow(missing_docs)]
pub struct RawTextModel {
logger: Logger,
dom: DomSymbol,
size: Rc<Cell<Vector2>>,
scene: Scene,
}
impl RawTextModel {
/// Constructor.
fn new(app: Application) -> Self {
let scene = app.display.default_scene.clone_ref();
let logger = Logger::new("RawText");
let div = web::document.create_div_or_panic();
let dom = DomSymbol::new(&div);
let size = Rc::new(Cell::new(Vector2(200.0, 200.0)));
// FIXME : StyleWatch is unsuitable here, as it was designed as an internal tool for shape
// system (#795)
let styles = StyleWatch::new(&scene.style_sheet);
let text_color =
styles.get_color(ensogl_hardcoded_theme::graph_editor::visualization::text);
let _red = text_color.red * 255.0;
let _green = text_color.green * 255.0;
let _blue = text_color.blue * 255.0;
let text_color = format!("rgba({},{},{},{})", _red, _green, _blue, text_color.alpha);
let padding_text = format!("{}px", PADDING_TEXT);
dom.dom().set_attribute_or_warn("class", "visualization scrollable");
dom.dom().set_style_or_warn("white-space", "pre");
dom.dom().set_style_or_warn("overflow-y", "auto");
dom.dom().set_style_or_warn("overflow-x", "auto");
dom.dom().set_style_or_warn("font-family", "DejaVuSansMonoBook");
dom.dom().set_style_or_warn("font-size", "12px");
dom.dom().set_style_or_warn("padding-left", &padding_text);
dom.dom().set_style_or_warn("padding-top", &padding_text);
dom.dom().set_style_or_warn("color", text_color);
dom.dom().set_style_or_warn("pointer-events", "auto");
scene.dom.layers.back.manage(&dom);
RawTextModel { logger, dom, size, scene }.init()
}
fn init(self) -> Self {
self.reload_style();
self
}
fn set_size(&self, size: Vector2) {
let x_mod = size.x - PADDING_TEXT;
let y_mod = size.y - PADDING_TEXT;
let size = Vector2(x_mod, y_mod);
self.size.set(size);
self.reload_style();
}
fn receive_data(&self, data: &Data) -> Result<(), DataError> {
let data_inner = match data {
Data::Json { content } => content,
_ => todo!(), // FIXME
};
self.dom.dom().set_inner_html("");
let data_str = serde_json::to_string_pretty(&**data_inner);
let data_str = data_str.unwrap_or_else(|e| format!("<Cannot render data: {}>", e));
let max_line_size = 1024;
if data_str.len() > max_line_size {
split_long_lines(&data_str, max_line_size, &mut |line| {
let node = web::document.create_div_or_panic();
node.set_inner_text(line);
let res = self.dom.dom().append_child(&node);
if res.is_err() {
Err(DataError::InternalComputationError)
} else {
Ok(())
}
})
} else {
self.dom.dom().set_inner_text(&data_str);
Ok(())
}
}
fn reload_style(&self) {
self.dom.set_dom_size(self.size.get());
}
fn set_layer(&self, layer: Layer) {
layer.apply_for_html_component(&self.scene, &self.dom)
}
}
fn split_long_lines(
data_str: &str,
max_line_size: usize,
process_line: &mut impl FnMut(&str) -> Result<(), DataError>,
) -> Result<(), DataError> {
let chunks = data_str.char_indices().chunks(max_line_size);
let chunk_boundaries = chunks
.into_iter()
.filter_map(|mut chunk| chunk.next().map(|(ix, _)| ix))
.chain(std::iter::once(data_str.len()));
for (start, end) in chunk_boundaries.into_iter().tuple_windows() {
process_line(&data_str[start..end])?;
}
Ok(())
}
impl From<RawText> for Instance {
fn from(t: RawText) -> Self {
Self::new(&t, &t.frp, &t.network, Some(t.model.dom.clone_ref()))
}
}
impl display::Object for RawText {
fn display_object(&self) -> &display::object::Instance {
self.dom.display_object()
}
}
#[cfg(test)]
mod tests {
use crate::component::visualization::DataError;
#[test]
fn test_split_long_lines() {
let str = "ABCDEFGH".to_string().repeat(1024);
let mut cnt = 0;
let res = super::split_long_lines(&str, 512, &mut |l| {
assert_eq!(l.len(), 512);
assert_eq!(&l[0..1], "A");
cnt += 1;
Ok(())
});
assert!(res.is_ok());
assert_eq!(cnt, 16);
}
#[test]
fn test_split_long_lines_with_failure() {
let str = "ABCDEFGH".to_string().repeat(1024);
let mut cnt = 0;
let res = super::split_long_lines(&str, 128, &mut |l| {
assert_eq!(l.len(), 128);
assert_eq!(&l[0..1], "A");
cnt += 1;
if cnt >= 4 {
Err(DataError::InvalidJsonText)
} else {
Ok(())
}
});
assert!(res.is_err());
assert_eq!(cnt, 4);
}
#[test]
fn test_emoticons() {
let str = "👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧👨‍👨‍👧‍👧"
.to_string()
.repeat(1024);
let res = super::split_long_lines(&str, 512, &mut |l| {
assert_eq!(l.chars().count(), 512);
Ok(())
});
assert!(res.is_ok());
}
}

View File

@ -1,441 +1,2 @@
//! Example visualisation showing the provided data as text.
use crate::prelude::*;
use ensogl::system::web::traits::*;
use crate::component::visualization;
use crate::StyleWatchFrp;
use enso_frp as frp;
use ensogl::application::command::FrpNetworkProvider;
use ensogl::application::frp::API;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::DomSymbol;
use ensogl::system::web;
use ensogl::system::web::CanvasRenderingContext2d;
use ensogl_component::grid_view;
use ensogl_component::grid_view::GridView;
use ensogl_component::scrollbar::Scrollbar;
use ensogl_hardcoded_theme as theme;
// =================
// === Constants ===
// =================
const CHARS_PER_CELL: f32 = 15.0;
// =============
// === Entry ===
// =============
mod entry {
use super::*;
use crate::display;
use crate::display::DomSymbol;
use crate::web;
use crate::Application;
use ensogl::prelude::*;
use ensogl_component::grid_view;
use ensogl_component::grid_view::entry::EntryFrp;
/// Model that contains the data that is required to populate the data in an `Entry`.
#[derive(Clone, Debug, Default)]
pub struct Model {
pub text: String,
}
/// Parameters that are required to set up an `Entry`.
#[derive(Clone, Debug, Default)]
pub struct Params {
/// DOM parent of the Entry. The text element in the `Entry` must be a child of the
/// `parent` to appear correctly.
pub parent: Option<DomSymbol>,
/// Name of the font to be used in the `Entry`.
pub font_name: ImString,
/// Font size in pixels.
pub font_size: f32,
}
/// Entry for use in GridView. Contains a dom element with a text, the Entry frp, and a dummy
/// display object for compatibility with `GridView`. The `dummy_root` is not used for
/// displaying anything, all that is visible is the `text` element, which is updates through
/// the FRP.
#[derive(Clone, CloneRef, Debug)]
pub struct Entry {
text: Rc<DomSymbol>,
frp: Rc<EntryFrp<Self>>,
}
impl Entry {
fn set_model(&self, model: &Model) {
self.text.set_inner_text(&model.text);
}
fn set_params(&self, params: &Params) {
if let Some(parent) = &params.parent {
parent.append_or_warn(self.text.dom());
}
self.text.set_style_or_warn("font-family", params.font_name.clone());
self.text.set_style_or_warn("font-size", format!("{}px", params.font_size as u32));
}
fn set_position_and_size(&self, pos: &Vector2, size: &Vector2) {
self.text.set_xy(*pos);
self.text.set_style_or_warn("left", format!("{}px", pos.x - size.x / 2.0));
self.text.set_style_or_warn("top", format!("{}px", -pos.y - size.y / 2.0));
self.text.set_style_or_warn("width", format!("{}px", size.x as u32));
self.text.set_style_or_warn("height", format!("{}px", size.y as u32));
}
}
impl display::Object for Entry {
fn display_object(&self) -> &display::object::Instance {
self.text.display_object()
}
}
impl grid_view::Entry for Entry {
type Model = Model;
type Params = Params;
fn new(app: &Application, _text_layer: Option<&display::scene::Layer>) -> Self {
let scene = &app.display.default_scene;
let text_div = web::document.create_div_or_panic();
let text = DomSymbol::new(&text_div);
scene.dom.layers.back.manage(&text);
text.set_style_or_warn("white-space", "nowrap");
text.set_style_or_warn("pointer-events", "auto");
text.set_style_or_warn("white-space", "pre");
let new_entry = Self { text: Rc::new(text), frp: default() };
let input = &new_entry.frp.private().input;
let network = new_entry.frp.network();
enso_frp::extend! { network
init <- source_();
eval input.set_model((model) new_entry.set_model(model));
eval input.set_params((params) new_entry.set_params(params));
pos_size <- all(&input.position_set, &input.set_size);
eval pos_size (((pos, size)) new_entry.set_position_and_size(pos, size));
}
init.emit(());
new_entry
}
fn frp(&self) -> &EntryFrp<Self> {
&self.frp
}
}
}
pub use entry::Entry;
// =============
// === Model ===
// =============
#[derive(Debug)]
#[allow(missing_docs)]
pub struct Model<T> {
app: Application,
size: Rc<Cell<Vector2>>,
root: display::object::Instance,
// Note that we are using a simple `GridView` and our own scrollbar, instead of the
// `scrollable::GridView` to avoid adding `ScrollAreas` to the scene, as the clipping they
// provide though the `mask::View` is not free in terms of performance (they add a draw call
// cost) and we don't need it here because we need to clip DOM elements anyway.
text_grid: GridView<Entry>,
dom_entry_root: DomSymbol,
clipping_div: DomSymbol,
scroll_bar_horizontal: Scrollbar,
scroll_bar_vertical: Scrollbar,
text_provider: Rc<RefCell<Option<T>>>,
}
impl<T: TextProvider> Model<T> {
/// Constructor.
fn new(app: Application) -> Self {
let scene = &app.display.default_scene;
let root = display::object::Instance::new();
let clipping_div = web::document.create_div_or_panic();
let clipping_div = DomSymbol::new(&clipping_div);
let dom_entry_root = web::document.create_div_or_panic();
let dom_entry_root = DomSymbol::new(&dom_entry_root);
let size = Rc::new(Cell::new(Vector2(200.0, 200.0)));
let text_provider = default();
clipping_div.set_style_or_warn("overflow", "hidden");
clipping_div.set_style_or_warn("class", "dom_root");
scene.dom.layers.back.manage(&clipping_div);
scene.dom.layers.back.manage(&dom_entry_root);
root.add_child(&clipping_div);
clipping_div.append_or_warn(&dom_entry_root);
let text_grid: GridView<Entry> = GridView::new(&app);
dom_entry_root.add_child(&text_grid);
let scroll_bar_horizontal = Scrollbar::new(&app);
root.add_child(&scroll_bar_horizontal);
let scroll_bar_vertical = Scrollbar::new(&app);
root.add_child(&scroll_bar_vertical);
scroll_bar_vertical.set_rotation_z(-90.0_f32.to_radians());
Model {
app,
text_grid,
clipping_div,
size,
dom_entry_root,
scroll_bar_horizontal,
scroll_bar_vertical,
root,
text_provider,
}
}
fn set_size(&self, size: Vector2) {
self.scroll_bar_horizontal.set_y(-size.y / 2.0);
self.scroll_bar_horizontal.set_length(size.x);
self.scroll_bar_vertical.set_x(size.x / 2.0);
self.scroll_bar_vertical.set_length(size.y);
self.clipping_div.set_dom_size(size);
self.size.set(size);
}
fn receive_data(&self, _data: &visualization::Data) -> Result<(), visualization::DataError> {
// TODO[MM]: Will be implemented as part of https://www.pivotaltracker.com/story/show/183453466
Ok(())
}
fn get_string_for_cell(&self, row: usize, column: usize) -> String {
let width = CHARS_PER_CELL as usize;
self.text_provider
.borrow()
.as_ref()
.and_then(|text| text.get_slice(row, width, column))
.unwrap_or_default()
}
/// Set the text provider. Updates the grid according to text dimensions.
pub fn set_text_provider(&self, text_provider: T) {
let lines = text_provider.line_count();
let max_line_length = text_provider.longest_line();
self.text_provider.replace(Some(text_provider));
let max_columns = (max_line_length / CHARS_PER_CELL as usize) + 1;
// self.text_grid.set_entries_size(Vector2(60.0, 8.0));
self.text_grid.reset_entries(lines, max_columns);
self.text_grid.resize_grid(lines, max_columns);
self.text_grid.request_model_for_visible_entries();
}
}
// ================
// === TextGrid ===
// ================
/// Sample visualization that renders the given data as text. Useful for debugging and testing.
#[derive(Debug, Shrinkwrap)]
#[allow(missing_docs)]
pub struct TextGrid<T> {
#[shrinkwrap(main_field)]
model: Rc<Model<T>>,
pub frp: visualization::instance::Frp,
network: frp::Network,
}
impl<T: 'static + TextProvider> TextGrid<T> {
/// Definition of this visualization.
pub fn definition() -> visualization::Definition {
let path = visualization::Path::builtin("JSON");
visualization::Definition::new(
visualization::Signature::new_for_any_type(path, visualization::Format::Json),
|app| Ok(Self::new(app.clone_ref()).into()),
)
}
/// Constructor.
pub fn new(app: Application) -> Self {
let network = frp::Network::new("visualization_text_grid");
let frp = visualization::instance::Frp::new(&network);
let model = Rc::new(Model::new(app));
Self { model, frp, network }.init()
}
fn init(self) -> Self {
let network = &self.network;
let model = self.model.clone_ref();
let frp = self.frp.clone_ref();
let text_grid = &self.model.text_grid;
let dom_entry_root = &self.model.dom_entry_root;
let scrollbar_h = &self.model.scroll_bar_horizontal;
let scrollbar_v = &self.model.scroll_bar_vertical;
let scene = &model.app.display.default_scene;
let style_watch = StyleWatchFrp::new(&scene.style_sheet);
use theme::graph_editor::visualization::text_grid as text_grid_style;
let font_name = style_watch.get_text(text_grid_style::font);
let font_size = style_watch.get_number(text_grid_style::font_size);
frp::extend! { network
// === Visualisation API Inputs ===
eval frp.set_size ((size) model.set_size(*size));
eval frp.send_data ([frp,model](data) {
if let Err(e) = model.receive_data(data) {
frp.data_receive_error.emit(Some(e));
}
});
// === Text Grid API ===
requested_entry <- text_grid.model_for_entry_needed.map2(&text_grid.grid_size,
f!([model]((row, col), _grid_size) {
let text = model.get_string_for_cell(*row,*col);
let model = entry::Model{text};
(*row, *col, model)
})
);
text_grid.model_for_entry <+ requested_entry;
// === Scrolling ===
scroll_positition <- all(&scrollbar_h.thumb_position, &scrollbar_v.thumb_position);
trace scroll_positition;
viewport <- all_with3(&scroll_positition, &frp.set_size, &text_grid.content_size, f!([dom_entry_root](scroll_position, vis_size, content_size) {
let (scroll_x, scroll_y) = *scroll_position;
let top = -scroll_y * content_size.y;
let bottom = top - vis_size.y;
let left = scroll_x * content_size.x;
let right = left + vis_size.x;
dom_entry_root.set_style_or_warn("top", format!("{}px", top));
dom_entry_root.set_style_or_warn("left", format!("{}px", -left));
grid_view::Viewport {top,bottom,left,right}
}));
text_grid.set_viewport <+ viewport;
}
// === Style ===
frp::extend! { network
init <- source::<()>();
theme_update <- all(init, font_name, font_size);
text_grid.set_entries_params <+ theme_update.map(f!([dom_entry_root]((_, font_name, font_size)) {
let parent = Some(dom_entry_root.clone_ref());
let font_name = font_name.clone();
let font_size = *font_size;
entry::Params { parent, font_name, font_size}
}));
item_width_update <- all(init, font_name, font_size);
item_width <- item_width_update.map(f!([]((_, font_name, font_size)) {
let font_size = *font_size;
let char_width = measure_character_width(font_name, font_size);
CHARS_PER_CELL * char_width
})).on_change();
item_update <- all(init, item_width, font_size);
text_grid.set_entries_size <+ item_update.map(f!([]((_, item_width, item_height)) {
Vector2::new(*item_width, *item_height)
}));
}
init.emit(());
self
}
}
impl<T> From<TextGrid<T>> for visualization::Instance {
fn from(t: TextGrid<T>) -> Self {
Self::new(&t, &t.frp, &t.network, Some(t.model.dom_entry_root.clone_ref()))
}
}
impl<T> display::Object for TextGrid<T> {
fn display_object(&self) -> &display::object::Instance {
&self.root
}
}
// ====================
// === TextProvider ===
// ====================
/// Trait for providing text for the TextGrid.
pub trait TextProvider {
/// Return a slice of the text.
///
/// The slice is indexed by the line and the "chunk", where a chunk is a sequence of characters
/// of fixed length, into which the line is divided. For example, for "abcdef" there could
/// be chunks of size two: ["ab", "cd", "ef"], or of size three ["abc", "def"].
fn get_slice(&self, line: usize, chunk_size: usize, chunk_index: usize) -> Option<String>;
/// Return the number of lines.
fn line_count(&self) -> usize;
/// Return the length of the longest line in characters.
fn longest_line(&self) -> usize;
}
impl TextProvider for String {
fn get_slice(&self, line: usize, chunk_size: usize, chunk_index: usize) -> Option<String> {
self.lines().nth(line).and_then(|line| {
line.chars()
.chunks(chunk_size)
.into_iter()
.nth(chunk_index)
.map(|chunk| chunk.collect::<String>())
})
}
/// Return the number of lines in the text.
fn line_count(&self) -> usize {
self.lines().count()
}
/// Return the number of characters in the longest line.
fn longest_line(&self) -> usize {
self.lines().map(|line| line.chars().count()).max().unwrap_or(0)
}
}
// =================================
// === Text Processing Utilities ===
// =================================
// Return the width of a character in the default monospaced font defined in `FONT_NAME`.
fn measure_character_width(font_name: &str, font_size: f32) -> f32 {
// We expect the font to be monospaced, so we can measure the width of any character.
let sample_text = "";
let canvas = web::document.create_canvas_or_panic();
let context = canvas.get_context("2d").unwrap().unwrap();
let context: CanvasRenderingContext2d = context.dyn_into().unwrap();
context.set_font(&format!("{}px {}", font_size as u32, font_name));
context.fill_text(sample_text, 0.0, 0.0).unwrap();
let text_metrics = context.measure_text(sample_text).unwrap();
let width = text_metrics.actual_bounding_box_right() + text_metrics.actual_bounding_box_left();
2.4 * width as f32 / sample_text.len() as f32
}

View File

@ -0,0 +1,521 @@
//! Text visualisation that can show text based data from the backend. If the text is larger than
//! the available space, it will use lazy loading to request only a subset of the data to
//! display. This is useful for large texts to avoid overwhelming the visualisation.
//!
//! The visualisation is made up of text `chunks` that are cached and will be requested form the
//! backend. The size of the chunks is determined by the `chunk_size` parameter and each hunk is
//! shown as a cell in a grid.
//!
//! Example:
//! ```text
//! ┌──────────┬──────────┬────
//! │chunk(0,0)│chunk(1,0)│ ...
//! ├──────────┼──────────┼───
//! │chunk(0,1 │chunk(1,1)│ ...
//! ├──────────┼──────────┼───
//! │ ... │ .... │ ...
//! ```
mod grid_cache;
mod grid_view_entry;
pub mod text_provider;
use crate::prelude::*;
use ensogl::system::web::traits::*;
use crate::component::visualization;
use crate::StyleWatchFrp;
use enso_frp as frp;
use enso_prelude::serde_reexports::Deserialize;
use enso_prelude::serde_reexports::Serialize;
use ensogl::application::frp::API;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::DomSymbol;
use ensogl::prelude::FrpNetworkProvider;
use ensogl::system::web;
use ensogl::system::web::CanvasRenderingContext2d;
use ensogl_component::grid_view;
use ensogl_component::grid_view::GridView;
use ensogl_component::scrollbar;
use ensogl_component::scrollbar::Scrollbar;
use ensogl_hardcoded_theme as theme;
pub use entry::Entry;
// =================
// === Constants ===
// =================
/// Number of characters that can be displayed in one grid cell and will be requested together from
/// the engine. Also referred to as `chunk`. This value can be changed to tweak the size of the
/// messages sent to the visualisation as well as the caching performance. A larger value will
/// result in fewer, smaller messages, but the visualisation might have to load more data that is
/// not needed, as it will be cropped. For example, a value of 100, would mean that the
/// visualisation would request 100 characters per chunk, even if it can only show 10 characters at
/// once in the available viewport.
const CHARS_PER_CHUNK: usize = 20;
/// Extra chunks to load around the visible grid to ensure smooth scrolling. Extra chunks are
/// loaded in each direction around the visible grid. So a value of 5 with a base grid of 20x10 will
/// load 25x15 grid.
const CACHE_PADDING: u32 = 15;
const PADDING_TEXT: f32 = 5.0;
// =============================
// === Grid Coordinate Types ===
// =============================
type GridPosition = Vector2<i32>;
type GridSize = Vector2<i32>;
type GridVector = Vector2<i32>;
/// Position and size of a sub-grid within a karger grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct GridWindow {
position: GridPosition,
size: GridSize,
}
use text_provider::BackendTextProvider;
use text_provider::TextProvider;
// =============
// === Model ===
// =============
#[derive(Debug)]
#[allow(missing_docs)]
pub struct Model<T> {
app: Application,
size: Rc<Cell<Vector2>>,
root: display::object::Instance,
// Note that we are using a simple `GridView` and our own scrollbar, instead of the
// `scrollable::GridView` to avoid adding `ScrollAreas` to the scene, as the clipping they
// provide though the `mask::View` is not free in terms of performance (they add a draw call
// cost) and we don't need it here because we need to clip DOM elements anyway.
text_grid: GridView<grid_view_entry::Entry>,
dom_entry_root: web::HtmlDivElement,
clipping_div: DomSymbol,
scroll_bar_horizontal: Scrollbar,
scroll_bar_vertical: Scrollbar,
text_provider: Rc<RefCell<Option<T>>>,
}
impl<T: 'static> Model<T> {
/// Constructor.
fn new(app: Application) -> Self {
let root = display::object::Instance::new();
let clipping_div = web::document.create_div_or_panic();
let clipping_div = DomSymbol::new(&clipping_div);
let dom_entry_root = web::document.create_div_or_panic();
let size = default();
let text_provider = default();
let text_grid: GridView<grid_view_entry::Entry> = GridView::new(&app);
root.add_child(&text_grid);
let scroll_bar_horizontal = Scrollbar::new(&app);
let scroll_bar_vertical = Scrollbar::new(&app);
Model {
app,
text_grid,
clipping_div,
size,
dom_entry_root,
scroll_bar_horizontal,
scroll_bar_vertical,
root,
text_provider,
}
.init()
}
fn init(self) -> Self {
self.init_dom();
self.init_scrollbars();
self
}
fn init_dom(&self) {
let scene = &self.app.display.default_scene;
// The `clipping_div` needs to cut of its content.
self.clipping_div.set_style_or_warn("overflow", "hidden");
self.clipping_div.set_style_or_warn("z-index", "2");
scene.dom.layers.back.manage(&self.clipping_div);
self.root.add_child(&self.clipping_div);
// The `dom_entry_root` is a container for the elements and its position is not managed
// through the display object hierarchy to a avoid issues with mixing the DOM and EnsoGL
// object hierarchies. See https://www.pivotaltracker.com/n/projects/2539304/stories/184051310
// for more ramification.
self.dom_entry_root.set_style_or_warn("position", "absolute");
self.clipping_div.append_or_warn(&self.dom_entry_root);
}
fn init_scrollbars(&self) {
self.root.add_child(&self.scroll_bar_horizontal);
self.root.add_child(&self.scroll_bar_vertical);
self.scroll_bar_vertical.set_rotation_z(-90.0_f32.to_radians());
self.app.display.default_scene.layers.above_nodes_text.add(&self.scroll_bar_horizontal);
self.app.display.default_scene.layers.above_nodes_text.add(&self.scroll_bar_vertical);
}
fn set_size(&self, size: Vector2) {
self.scroll_bar_horizontal.set_y(-size.y / 2.0);
self.scroll_bar_horizontal.set_length(size.x);
let scrollbar_width = scrollbar::WIDTH - scrollbar::PADDING;
self.scroll_bar_horizontal.modify_y(|y| *y += scrollbar_width / 2.0);
self.scroll_bar_vertical.set_x(size.x / 2.0);
self.scroll_bar_vertical.set_length(size.y);
self.scroll_bar_vertical.modify_x(|x| *x -= scrollbar_width / 2.0);
let text_padding = Vector2::new(PADDING_TEXT, PADDING_TEXT);
self.clipping_div.set_dom_size(size - 2.0 * text_padding);
self.size.set(size);
}
fn set_text_provider(&self, text_provider: T) {
self.text_provider.replace(Some(text_provider));
}
}
impl<T: TextProvider> Model<T> {
fn get_string_for_cell(&self, row: usize, column: usize) -> String {
self.text_provider
.borrow()
.as_ref()
.and_then(|text_provider| text_provider.get_slice(row, column))
.unwrap_or_default()
}
}
// ================
// === TextGrid ===
// ================
/// Sample visualization that renders the given data as text. Useful for debugging and testing.
#[derive(Debug, Shrinkwrap)]
#[allow(missing_docs)]
pub struct TextGrid<T> {
#[shrinkwrap(main_field)]
model: Rc<Model<T>>,
pub frp: visualization::instance::Frp,
network: frp::Network,
fonts_loaded_notifier: FontLoadedNotifier,
}
impl<T: TextProvider + 'static> TextGrid<T> {
/// Constructor.
pub fn new(app: Application) -> Self {
let network = frp::Network::new("visualization_text_grid");
let frp = visualization::instance::Frp::new(&network);
let model = Rc::new(Model::new(app));
let fonts_loaded_notifier = FontLoadedNotifier::new(&network);
Self { model, frp, network, fonts_loaded_notifier }
}
fn init_frp(&self, text_provider: &dyn TextProvider) {
let network = &self.network;
let frp = &self.frp;
let text_provider = text_provider.frp();
let model = &self.model;
frp::extend! { network
init <- source::<()>();
on_data_update <- frp.send_data.constant(());
eval frp.set_size ((size) model.set_size(*size));
}
self.init_text_grid_api(&init, &on_data_update);
self.init_scrolling(&init, text_provider, &on_data_update);
self.init_style(&init);
init.emit(());
}
fn init_style(&self, init: &frp::Source) {
let init = init.clone_ref();
let scene = &self.model.app.display.default_scene;
let style_watch = StyleWatchFrp::new(&scene.style_sheet);
use theme::graph_editor::visualization::text_grid as text_grid_style;
let font_name = style_watch.get_text(text_grid_style::font);
let font_size = style_watch.get_number(text_grid_style::font_size);
let network = &self.network;
let fonts_loaded = self.fonts_loaded_notifier.on_fonts_loaded.clone_ref();
let dom_entry_root = &self.dom_entry_root;
let text_grid = &self.text_grid;
frp::extend! { network
theme_update <- all(init, font_name, font_size);
text_grid.set_entries_params <+ theme_update.map(
f!([dom_entry_root]((_, font_name, font_size)) {
dom_entry_root.set_style_or_warn("font-family", font_name.clone());
dom_entry_root.set_style_or_warn("font-size", format!("{}px", *font_size as u32));
let parent = Some(dom_entry_root.clone_ref());
grid_view_entry::Params { parent }
})
);
item_width_update <- all(init, fonts_loaded, font_name, font_size);
item_width <- item_width_update.map(f!([]((_, _, font_name, font_size)) {
let font_size = *font_size;
let char_width = measure_character_width(font_name, font_size);
CHARS_PER_CHUNK as f32 * char_width
})).on_change();
item_update <- all(init, item_width, font_size);
text_grid.set_entries_size <+ item_update.map(f!([]((_, item_width, item_height)) {
Vector2::new(*item_width, *item_height)
}));
}
}
fn init_text_grid_api(&self, init: &frp::Source, on_data_update: &frp::Stream) {
let network = &self.network;
let text_grid = &self.text_grid;
let model = &self.model;
let init = init.clone_ref();
let on_data_update = on_data_update.clone_ref();
frp::extend! { network
text_grid.request_model_for_visible_entries <+ any(on_data_update,init);
requested_entry <- text_grid.model_for_entry_needed.map2(&text_grid.grid_size,
f!([model]((row, col), _grid_size) {
let text = model.get_string_for_cell(*row,*col);
let model = grid_view_entry::Model{text};
(*row, *col, model)
})
);
text_grid.model_for_entry <+ requested_entry;
}
}
fn init_scrolling(
&self,
init: &frp::Source,
text_provider: &text_provider::Frp,
on_data_update: &frp::Stream,
) {
let network = &self.network;
let text_grid = &self.text_grid;
let scrollbar_h = &self.model.scroll_bar_horizontal;
let scrollbar_v = &self.model.scroll_bar_vertical;
let dom_entry_root = &self.model.dom_entry_root;
let on_data_update = on_data_update.clone_ref();
let frp = &self.frp;
let init = init.clone_ref();
frp::extend! { network
scroll_positition <- all(&scrollbar_h.thumb_position, &scrollbar_v.thumb_position);
longest_line_with_init <- all(&init, &text_provider.longest_line)._1();
lines_with_init <- all(&init, &text_provider.line_count)._1();
longest_line <- longest_line_with_init.on_change();
line_count <- lines_with_init.on_change();
content_size <- all(on_data_update, longest_line, line_count).map(
|(_, width,height)| {
let columns = (*width as usize / CHARS_PER_CHUNK) + 1;
let rows = *height as usize;
( rows.max(1), columns.max(1) )
}).on_change();
text_grid.resize_grid <+ content_size;
text_grid.reset_entries <+ content_size;
text_grid.request_model_for_visible_entries <+ text_provider.data_refresh;
text_grid_content_size_x <- text_grid.content_size.map(|size| size.x).on_change();
text_grid_content_size_x_previous <- text_grid_content_size_x.previous();
text_grid_content_size_y <- text_grid.content_size.map(|size| size.y).on_change();
text_grid_content_size_y_previous <- text_grid_content_size_y.previous();
horizontal_scrollbar_change_args <- all(
text_grid_content_size_x,
text_grid_content_size_x_previous,
scrollbar_h.thumb_position
);
on_content_size_x_change <- horizontal_scrollbar_change_args
.sample(&text_grid_content_size_x);
scrollbar_h.jump_to <+ on_content_size_x_change.map(
|(content_size_x, content_size_x_previous, thumb_position)| {
thumb_position * content_size_x_previous / content_size_x
});
vertical_scrollbar_change_args <- all(text_grid_content_size_y,
text_grid_content_size_y_previous,
scrollbar_v.thumb_position
);
on_content_size_y_change <- vertical_scrollbar_change_args
.sample(&text_grid_content_size_y);
scrollbar_v.jump_to <+ on_content_size_y_change.map(
|(content_size_y, content_size_y_previous, thumb_position)| {
thumb_position * content_size_y_previous / content_size_y
});
size_update <- all(&frp.set_size, &text_grid.content_size);
scrollbar_sizes <- size_update.map(|(vis_size, content_size)| {
vis_size.iter().zip(content_size.iter()).map(|(vis_size, content_size)| {
if *content_size > 0.0 {
(vis_size / content_size).clamp(0.0,1.0)
} else {
0.0
}
}).collect_tuple::<(f32,f32)>()
}).unwrap();
scrollbar_h.set_thumb_size <+ scrollbar_sizes._0();
scrollbar_v.set_thumb_size <+ scrollbar_sizes._1();
viewport <- all_with4(
&init,
&scroll_positition,
&frp.set_size,
&text_grid.content_size,
f!([dom_entry_root](_, scroll_position, vis_size, content_size) {
let (scroll_x, scroll_y) = *scroll_position;
let top = -scroll_y * content_size.y;
let bottom = top - vis_size.y;
let left = scroll_x * content_size.x;
let right = left + vis_size.x;
// Set DOM element size.
dom_entry_root.set_style_or_warn("top", format!("{}px", top + PADDING_TEXT));
dom_entry_root.set_style_or_warn("left", format!("{}px", -left + PADDING_TEXT));
// Output viewport.
let viewport = grid_view::Viewport {top, bottom, left, right};
viewport
})
);
text_grid.set_viewport <+ viewport;
}
}
/// Set the text provider.
pub fn set_text_provider(&self, text_provider: T) {
self.model.set_text_provider(text_provider);
self.init_frp(self.model.text_provider.borrow().as_ref().unwrap());
}
}
impl<T> From<TextGrid<T>> for visualization::Instance {
fn from(t: TextGrid<T>) -> Self {
Self::new(&t, &t.frp, &t.network, Some(t.model.clipping_div.clone_ref()))
}
}
impl<T> display::Object for TextGrid<T> {
fn display_object(&self) -> &display::object::Instance {
&self.root
}
}
// ========================
// === Font Measurement ===
// ========================
// Return the width of a character in the default monospaced font defined in `FONT_NAME`.
fn measure_character_width(font_name: &str, font_size: f32) -> f32 {
// We expect the font to be monospaced, so we can measure the width of any character.
let sample_text = "";
let canvas = web::document.create_canvas_or_panic();
let context = canvas.get_context("2d").unwrap().unwrap();
let context: CanvasRenderingContext2d = context.dyn_into().unwrap();
let font = format!("{font_size}px {font_name}");
context.set_font(&font);
let text_metrics = context.measure_text(sample_text).unwrap();
let text_length = sample_text.chars().count() as f32;
let width = text_metrics.width();
let result = width as f32 / text_length;
result
}
// =============================
// === Font Loading Notifier ===
// =============================
#[derive(Debug)]
struct FontLoadedNotifier {
pub on_fonts_loaded: enso_frp::Source,
}
impl FontLoadedNotifier {
fn new(network: &enso_frp::Network) -> Self {
enso_frp::extend! { network
on_fonts_loaded <- source::<()>();
}
let callback: Rc<RefCell<Option<web::Closure<_>>>> = default();
let _closure = web::Closure::new(f_!([on_fonts_loaded, callback]{
on_fonts_loaded.emit(());
// Release the self-reference after being called, so the closure can be dropped.
*callback.borrow_mut() = None;
}));
callback.set(_closure);
let _promise = match web::document.fonts().ready() {
Ok(promise) => callback.borrow().as_ref().map(|closure| promise.then(closure)),
Err(e) => {
warn!("Could not set up font loading event because of error: {:?}.", e);
None
}
};
Self { on_fonts_loaded }
}
}
// ===========================
// === Visualisation Types ===
// ===========================
/// A text grid backed by a `String`. Used for testing and backend agnostic development and demos.
/// Should not be used in production as it is not optimized for performance.
pub type DebugTextGridVisualisation = TextGrid<String>;
/// A text grid backed by a the engine. Requests data from the engine on demand and renders it.
pub type TextVisualisation = TextGrid<BackendTextProvider>;
/// Return definition of a lazy text visualisation.
pub fn text_visualisation() -> visualization::Definition {
let path = visualization::Path::builtin("JSON");
visualization::Definition::new(
visualization::Signature::new_for_any_type(path, visualization::Format::Json),
|app| {
let grid = TextVisualisation::new(app.clone_ref());
grid.set_text_provider(BackendTextProvider::new(
grid.frp.inputs.send_data.clone_ref(),
grid.frp.preprocessor_change.clone_ref(),
));
Ok(grid.into())
},
)
}

View File

@ -0,0 +1,234 @@
//! A cache that stores a grid of items. The cache contains a grid of items, and a padding around
//! the inner grid. The padding is used to store items that are adjacent to the inner grid, and are
//! expected to be accessed next when moving the location of the inner grid to the left/right or
//! up/down. Once an item outside of the inner grid is requested, the position of the inner grids
//! is moved to contain the accessed item. Then further new items are requested to the cache
//! according to the new grid location.
//!
//! Example
//! -------
//! Consider a grid of the sie 4x1, where the current visible window is at position (0,0) and has
//! the size 2x1, with a padding of 1. The cache will contain the following items:
//! ```text
//! +---+---+---+---+---+
//! | 1 | 2 | 3 | | |
//! +---+---+---+---+---+
//! ```
//! Any access to item (0,0) or (1,0), will return the items content and not affect the cache.
//! Access to item (2,0) will move the inner grid to position (1,0) and request a new item for
//! position (3,0). The cache will then look like this:
//! ```text
//! +---+---+---+---+---+
//! | 1 | 2 | 3 | 4 | |
//! +---+---+---+---+---+
//! ```
//! The item at position (0,0) is now outside of the inner grid but will be kept due to the padding.
//! After access to item (0,4) the cache will look like this:
//! ```text
//! +---+---+---+---+---+
//! | | 2 | 3 | 4 | 5 |
//! +---+---+---+---+---+
//! ```
//! The inner window is now at position (2,0) and the item at position (0,0) is no longer in the
//! case as it is outside of the padded area.
use crate::prelude::*;
use super::GridPosition;
use super::GridSize;
use super::GridVector;
use super::GridWindow;
// =================
// === GridCache ===
// =================
#[derive(Derivative)]
#[derivative(Debug(bound = ""))]
/// A cache that stores a grid of items. The cache contains a grid of items, and a padding around
/// the inner grid. The padding is used to store items that are adjacent to the inner grid. For
/// fore details see the module documentation.
pub struct GridCache<T> {
cached_grid_pos: GridPosition,
cached_grid_size: GridSize,
#[derivative(Debug = "ignore")]
data: HashMap<GridPosition, T>,
/// Number of row/columns that should be fetched, which are not visible.
cache_padding: i32,
#[derivative(Debug = "ignore")]
/// A callback that is called when the cache requires an update.
request_fn: Box<dyn Fn(GridWindow)>,
}
impl<T: Clone> GridCache<T> {
/// Create a new `GridCache`.
pub fn new(
starting_pos: GridPosition,
starting_size: GridSize,
cache_padding: u32,
request_fn: Box<dyn Fn(GridWindow)>,
) -> Self {
let data = HashMap::new();
Self {
cached_grid_pos: starting_pos,
cached_grid_size: starting_size,
data,
cache_padding: cache_padding as i32,
request_fn,
}
.init()
}
fn init(self) -> Self {
let x_start = self.cached_grid_pos.x - self.cache_padding;
let x_end = self.cached_grid_pos.x + self.cached_grid_size.x + self.cache_padding;
let y_start = self.cached_grid_pos.y - self.cache_padding;
let y_end = self.cached_grid_pos.y + self.cached_grid_size.y + self.cache_padding;
(self.request_fn)(GridWindow {
position: Vector2::new(x_start, y_start),
size: Vector2::new(x_end - x_start, y_end - y_start),
});
self
}
/// Get the item at the given position. If the item is not in the cache, it will be requested.
pub fn get_item(&mut self, index: GridPosition) -> Option<T> {
self.register_cache_access(index);
let item = self.data.get(&index).cloned();
if item.is_none() {
self.request_data_update();
}
item
}
/// Add an item to the cache at the given position.
pub fn add_item(&mut self, index: GridPosition, item: T) {
self.data.insert(index, item);
}
fn register_cache_access(&mut self, index: GridPosition) {
if let Some(offset) = self.distance_from_displayed_grid(index) {
debug_assert!(
offset != Vector2::new(0, 0),
"The index {} should not be in the displayed grid with pos {} and size {}.",
index,
self.cached_grid_pos,
self.cached_grid_size
);
let is_large_offset =
offset.iter().zip(self.cached_grid_size.iter()).any(|(a, b)| a.abs() > b / 4);
if is_large_offset {
let old_grid_pos: HashSet<_> = self.iter_full_grid().collect();
self.cached_grid_pos += offset;
let new_grid: HashSet<_> = self.iter_full_grid().collect();
let to_remove = old_grid_pos.difference(&new_grid);
for pos in to_remove {
self.data.remove(pos);
}
self.request_data_update()
}
}
}
fn request_data_update(&self) {
(self.request_fn)(self.padded_grid_window());
}
fn padded_grid_window(&self) -> GridWindow {
let delta = GridVector::new(self.cache_padding, self.cache_padding);
let position = self.cached_grid_pos - delta / 2;
let size = self.cached_grid_size + delta;
GridWindow { position, size }
}
/// Iterate the full grid including the cached padding.
fn iter_full_grid(&self) -> impl Iterator<Item = GridPosition> {
let x_start = self.cached_grid_pos.x - self.cache_padding;
let x_end = self.cached_grid_pos.x + self.cached_grid_size.x + self.cache_padding;
let y_start = self.cached_grid_pos.y - self.cache_padding;
let y_end = self.cached_grid_pos.y + self.cached_grid_size.y + self.cache_padding;
(x_start..x_end).cartesian_product(y_start..y_end).map(|(x, y)| GridPosition::new(x, y))
}
/// Get the distance of the given index from the displayed grid. If the index is in the
/// displayed grid, `None` is returned.
fn distance_from_displayed_grid(&self, index: GridPosition) -> Option<GridVector> {
let bottom_right = self.cached_grid_pos + self.cached_grid_size;
if index >= self.cached_grid_pos && index <= bottom_right {
None
} else {
let cached_grid_pos = self.cached_grid_pos;
let cached_grid_size = self.cached_grid_size;
let dx = distance_from_segment(cached_grid_pos.x, cached_grid_size.x, index.x);
let dy = distance_from_segment(cached_grid_pos.y, cached_grid_size.y, index.y);
debug_assert!(
dx != 0 || dy != 0,
"The index {} should not be in the displayed grid with pos {} and size {}.",
index,
self.cached_grid_pos,
self.cached_grid_size
);
Some(GridVector::new(dx, dy))
}
}
/// Clear the cached data.
pub fn clear(&mut self) {
self.data.clear();
}
}
/// Get the distance of the given index from the segment defined by the start and size.
/// For example, if the segment is [0, 10[ (start is 0, size is 10) and the value is 15
/// the distance is 6.
fn distance_from_segment(start: i32, size: i32, value: i32) -> i32 {
let delta = value - start;
if value >= start && value < start + size {
0
} else if delta > 0 {
delta - size + 1
} else {
delta
}
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_distance_from_segment() {
assert_eq!(distance_from_segment(0, 2, 0), 0);
assert_eq!(distance_from_segment(0, 2, 1), 0);
assert_eq!(distance_from_segment(0, 2, 2), 1);
assert_eq!(distance_from_segment(0, 2, 3), 2);
assert_eq!(distance_from_segment(0, 2, 4), 3);
assert_eq!(distance_from_segment(0, 2, -1), -1);
assert_eq!(distance_from_segment(0, 2, -2), -2);
assert_eq!(distance_from_segment(0, 2, -3), -3);
assert_eq!(distance_from_segment(2, 4, 0), -2);
assert_eq!(distance_from_segment(2, 4, 1), -1);
assert_eq!(distance_from_segment(2, 4, 2), 0);
assert_eq!(distance_from_segment(2, 4, 3), 0);
assert_eq!(distance_from_segment(2, 4, 4), 0);
assert_eq!(distance_from_segment(2, 4, 5), 0);
assert_eq!(distance_from_segment(2, 4, 6), 1);
assert_eq!(distance_from_segment(2, 4, 7), 2);
}
}

View File

@ -0,0 +1,118 @@
//! This module contains the `Entry` used in the `TextGrid` visualizations well as associated
//! structs.
use super::*;
use ensogl::prelude::*;
use crate::display;
use crate::web;
use crate::Application;
use ensogl_component::grid_view;
use ensogl_component::grid_view::entry::EntryFrp;
use std::fmt::Write;
// =============
// === Model ===
// =============
/// Model that contains the data that is required to populate the data in an `Entry`.
#[derive(Clone, Debug, Default)]
pub struct Model {
pub text: String,
}
// ==============
// === Params ===
// ==============
/// Parameters that are required to set up an `Entry`.
#[derive(Clone, Debug, Default)]
pub struct Params {
/// DOM parent of the Entry. The text element in the `Entry` must be a child of the
/// `parent` to appear correctly.
pub parent: Option<web::HtmlDivElement>,
}
// =============
// === Entry ===
// =============
/// Entry for use in GridView. Contains a dom element with a text, the Entry frp, and a dummy
/// display object for compatibility with `GridView`. The `dummy_root` is not used for
/// displaying anything, all that is visible is the `text` element, which is updates through
/// the FRP.
#[derive(Clone, CloneRef, Debug)]
pub struct Entry {
// Needed to provide a dummy display object for the `display::Object` trait. Not used, as the
// text element is created as HTML Element and positioned manually in `set_position_and_size`.
dummy_root: display::object::Instance,
text: Rc<web::HtmlDivElement>,
frp: Rc<EntryFrp<Self>>,
}
impl Entry {
fn set_model(&self, model: &Model) {
self.text.set_inner_text(&model.text);
}
fn set_params(&self, params: &Params) {
if let Some(parent) = &params.parent {
parent.append_or_warn(&self.text);
}
}
fn set_position_and_size(&self, pos: &Vector2, size: &Vector2) {
let left = pos.x - size.x / 2.0;
let top = -pos.y - size.y / 2.0;
let width = size.x as u32;
let height = size.y as u32;
let mut style = "position: absolute; white-space: pre; pointer-events: auto;".to_string();
write!(style, "left: {}px; top: {}px;", left, top).ok();
write!(style, "width: {}px; height: {}px;", width, height).ok();
self.text.set_attribute_or_warn("style", style);
}
}
impl display::Object for Entry {
fn display_object(&self) -> &display::object::Instance {
&self.dummy_root
}
}
impl grid_view::Entry for Entry {
type Model = Model;
type Params = Params;
fn new(_app: &Application, _text_layer: Option<&display::scene::Layer>) -> Self {
let text = web::document.create_div_or_panic();
let dummy_root = display::object::Instance::new();
let new_entry = Self { dummy_root, text: Rc::new(text), frp: default() };
let input = &new_entry.frp.private().input;
let network = new_entry.frp.network();
enso_frp::extend! { network
init <- source_();
eval input.set_model((model) new_entry.set_model(model));
eval input.set_params((params) new_entry.set_params(params));
pos_size <- all(&input.position_set, &input.set_size);
eval pos_size (((pos, size)) new_entry.set_position_and_size(pos, size));
}
init.emit(());
new_entry
}
fn frp(&self) -> &EntryFrp<Self> {
&self.frp
}
}

View File

@ -0,0 +1,383 @@
//! Text providers that can be used to back a text grid visualization.
//!
//! Contains a dummy implementation [`StringTextProvider`] that is backed by a `String` that can be
//! used for testing, and the text provider `BackendTextProvider` used for communicating with the
//! backend.
use crate::builtin::visualization::native::text_visualization::*;
use crate::prelude::*;
use crate::builtin::visualization::native::text_visualization::grid_cache::GridCache;
use crate::builtin::visualization::native::text_visualization::CACHE_PADDING;
use crate::component::visualization;
use crate::component::visualization::instance::PreprocessorConfiguration;
use super::GridPosition;
use super::GridSize;
use super::GridWindow;
use enso_prelude::serde_reexports::Deserialize;
// ===========================
// === Text Provider Trait ===
// ===========================
/// Trait for providing text for the TextGrid.
pub trait TextProvider {
/// Return a slice of the text.
///
/// The slice is indexed by the line and the "chunk", where a chunk is a sequence of characters
/// of fixed length, into which the line is divided. For example, for "abcdef" there could
/// be chunks of size two: ["ab", "cd", "ef"], or of size three ["abc", "def"].
fn get_slice(&self, line: usize, chunk_index: usize) -> Option<String>;
/// Return the FRP api for the text provider.
fn frp(&self) -> &Frp;
}
// ===========================
// === Text Provider FRP ===
// ===========================
ensogl::define_endpoints_2! {
Input {}
Output {
line_count(u32),
longest_line(u32),
data_refresh(),
}
}
// =============================
// === String Text Provider ====
// =============================
/// A text provider that is backed by a string.
#[derive(Debug)]
pub struct StringTextProvider {
text: String,
frp: Frp,
}
impl StringTextProvider {
/// Create a new [`StringTextProvider`].
pub fn new(text: String) -> Self {
let frp = Frp::new();
let line_count = text.lines().count() as u32;
frp.private().output.line_count.emit(line_count);
let longest_line_frp = &frp.private().output.longest_line;
let longest_line = text.lines().map(|line| line.chars().count()).max().unwrap_or(0) as u32;
longest_line_frp.emit(longest_line);
Self { text, frp }
}
}
impl TextProvider for StringTextProvider {
fn get_slice(&self, line: usize, chunk_index: usize) -> Option<String> {
self.text.lines().nth(line).and_then(|line| {
line.chars()
.chunks(CHARS_PER_CHUNK)
.into_iter()
.nth(chunk_index)
.map(|chunk| chunk.collect::<String>())
})
}
fn frp(&self) -> &Frp {
&self.frp
}
}
// =============================
// === Backend Text Provider ===
// =============================
const DEFAULT_GRID_SIZE: i32 = 20;
/// A cache for a grid of Strings.
///
/// The cache is backed by the engine and will communicate via the FRP endpoints passed in the
/// constructor.
#[derive(Debug, Clone)]
pub struct BackendTextProvider {
frp: Frp,
text_cache: Rc<RefCell<GridCache<String>>>,
register_access: frp::Any,
}
impl BackendTextProvider {
/// Create a new `BackendTextProvider`.
pub fn new(
receive_data: frp::Source<visualization::Data>,
preprocessor_update: frp::Any<PreprocessorConfiguration>,
) -> Self {
let frp = Frp::new();
let network = frp.network();
let output = frp.private().output.clone_ref();
let longest_observed_line: Rc<Cell<u32>> = default();
let max_observed_lines: Rc<Cell<u32>> = default();
frp::extend! { network
grid_window <- any_mut::<GridWindow>();
}
let grid_cache_update = f!((new_window) grid_window.emit(new_window));
let text_cache = Rc::new(RefCell::new(GridCache::<String>::new(
GridPosition::default(),
GridSize::new(DEFAULT_GRID_SIZE, CHARS_PER_CHUNK as i32 * DEFAULT_GRID_SIZE),
CACHE_PADDING,
Box::new(grid_cache_update),
)));
frp::extend! { network
register_access <- any_mut();
update_preprocessor <- all(register_access, grid_window);
update_preprocessor <- update_preprocessor._1().on_change();
preprocessor_update <+ update_preprocessor.map(|grid_window| {
let grid_posititon = grid_window.position.map(|value| value.max(0));
let grid_size = grid_window.size;
if grid_size == GridSize::default() {
None
} else {
Some(text_preprocessor(grid_posititon, grid_size))
}
}).unwrap();
grid_data_update <- receive_data.map(|data| {
LazyGridDataUpdate::try_from(data.clone()).ok()
});
grid_data_update <- grid_data_update.map(|data| data.clone()).unwrap();
needs_refresh <- grid_data_update.map(f!([text_cache,longest_observed_line,max_observed_lines](update) {
let needs_refresh = update.update_type == UpdateType::FullUpdate;
if needs_refresh {
text_cache.borrow_mut().clear();
longest_observed_line.set(1);
max_observed_lines.set(1);
}
for (pos, text) in &update.data.chunks {
if let Some(text) = text {
text_cache.borrow_mut().add_item(*pos, text.clone());
} else {
text_cache.borrow_mut().add_item(*pos, "".to_string());
}
}
needs_refresh
}));
output.data_refresh <+ needs_refresh.on_true().constant(());
line_count <- grid_data_update.map(|grid_data| grid_data.data.line_count);
longest_line <- grid_data_update.map(|grid_data| grid_data.data.longest_line);
output.longest_line <+ longest_line.map(f!([longest_observed_line](longest_line) {
let observed_value = longest_observed_line.get();
let longest_line = observed_value.max(*longest_line);
longest_observed_line.set(longest_line);
longest_line
})).on_change();
output.line_count <+ line_count.map(f!([max_observed_lines](line_count) {
let observed_value = max_observed_lines.get();
let max_lines = observed_value.max(*line_count);
max_observed_lines.set(max_lines);
max_lines
})).on_change();
}
Self { frp, text_cache, register_access }
}
}
impl TextProvider for BackendTextProvider {
fn get_slice(&self, line: usize, chunk_index: usize) -> Option<String> {
let y = line as i32;
let x = chunk_index as i32;
let result = self.text_cache.borrow_mut().get_item(Vector2::new(x, y));
self.register_access.emit(());
result
}
fn frp(&self) -> &Frp {
&self.frp
}
}
/// Crate a preprocessor configuration for the lazy text preprocessor.
fn text_preprocessor(
grid_position: GridPosition,
grids_size: GridSize,
) -> PreprocessorConfiguration {
PreprocessorConfiguration::new(
"Standard.Visualization.Preprocessor",
"lazy_preprocessor",
vec![
serde_json::to_string(&grid_position)
.expect("Could not serialise [`GridPosition`] as JSON string."),
serde_json::to_string(&grids_size)
.expect("Could not serialise [`GridSize`] as JSON string."),
serde_json::to_string(&(CHARS_PER_CHUNK as u32))
.expect("Could not serialise [`u32`] as JSON string."),
],
)
}
// =============================
// === Lazy Grid Data Update ===
// =============================
type Chunk = (GridPosition, Option<String>);
/// Struct for deserialising the data sent from the engine.
#[derive(Clone, Debug, Deserialize, Serialize, Default)]
struct LazyGridData {
pub chunks: Vec<Chunk>,
pub line_count: u32,
pub longest_line: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum UpdateType {
FullUpdate,
PartialUpdate,
}
impl Default for UpdateType {
fn default() -> Self {
Self::FullUpdate
}
}
#[derive(Clone, Debug, Default)]
struct LazyGridDataUpdate {
pub data: LazyGridData,
update_type: UpdateType,
}
impl TryFrom<visualization::Data> for LazyGridDataUpdate {
type Error = visualization::DataError;
fn try_from(data: visualization::Data) -> Result<Self, Self::Error> {
if let visualization::Data::Json { content } = data {
let grid_data = serde_json::from_value(content.deref().clone());
let (data, update_type) = match grid_data {
Ok(data) => (data, UpdateType::PartialUpdate),
Err(_) => {
let data_str = if content.is_string() {
// We need to access the content `as_str` to preserve newlines. Just using
// `content.to_string()` would turn them into the characters `\n` in the
// output. The unwrap can never fail, as we just
// checked that the content is a string.
Ok(content.as_str().map(|s| s.to_owned()).unwrap_or_default())
} else {
serde_json::to_string_pretty(&*content)
};
let data_str =
data_str.unwrap_or_else(|e| format!("<Cannot render data: {}.>", e));
(data_str.into(), UpdateType::FullUpdate)
}
};
Ok(LazyGridDataUpdate { data, update_type })
} else {
Err(visualization::DataError::BinaryNotSupported)
}
}
}
impl From<String> for LazyGridData {
fn from(content: String) -> Self {
let lines = content.lines();
let numbered_lines = lines.enumerate();
let chunks = numbered_lines
.flat_map(|(line_ix, line)| {
let chunks = line.chars().chunks(CHARS_PER_CHUNK);
let numbered_chunks = chunks.into_iter().enumerate();
let chunks_with_position = numbered_chunks.map(move |(chunk_ix, chunk)| {
let chunk = chunk.collect::<String>();
let pos = GridPosition::new(chunk_ix as i32, line_ix as i32);
(pos, Some(chunk))
});
chunks_with_position.collect_vec()
})
.collect();
let line_count = content.lines().count() as u32;
let longest_line = content.lines().map(|l| l.len()).max().unwrap_or_default() as u32;
let chunks = fill_emtpy_chunks(chunks);
LazyGridData { chunks, line_count, longest_line }
}
}
/// Take a vector of chunks and fill the empty spaces of the bounding grid with `None`. The bounding
/// grid is determined by the maximum x and y values of the chunks.
fn fill_emtpy_chunks(chunks: Vec<Chunk>) -> Vec<Chunk> {
let grid_width = chunks.iter().map(|(pos, _)| pos.x).max().unwrap_or_default() + 1;
let grid_height = chunks.iter().map(|(pos, _)| pos.y).max().unwrap_or_default() + 1;
let chunk_map: HashMap<GridPosition, Option<String>> = chunks.into_iter().collect();
let full_grid_coordinates = (0..grid_width).cartesian_product(0..grid_height);
full_grid_coordinates
.map(|(x, y)| {
let pos = GridPosition::new(x, y);
let chunk = chunk_map.get(&pos).cloned().flatten();
(pos, chunk)
})
.collect()
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_message_deserialization() {
let sample_message =
r#"{"chunks":[[[0, 0], "ABCDE"], [[0, 1], "12345"]],"line_count":2,"longest_line":19}"#;
let json =
serde_json::from_str(sample_message).expect("Text example contains invalid JSON.");
let result: Result<LazyGridData, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Deserialization failed with error: {:?}.", result.err());
}
#[test]
fn test_backend_message_with_null_deserialization() {
let sample_message =
r#"{"chunks":[[[0, 0], "ABCDE"], [[0, 1], null]],"line_count":2,"longest_line":19}"#;
let result: Result<LazyGridData, _> = sample_message.to_string().try_into();
assert!(result.is_ok(), "Deserialization failed with error: {:?}.", result.err());
}
#[test]
fn test_backend_message_multiline_string_deserialization() {
let sample_message = r#""Just a simple string
with two lines.""#;
let result: Result<LazyGridData, _> = sample_message.to_string().try_into();
assert!(result.is_ok(), "Deserialization failed with error: {:?}.", result.err());
let lazy_grid = result.unwrap();
assert_eq!(lazy_grid.line_count, 2);
}
#[test]
fn test_backend_message_simple_string_deserialization() {
let sample_message = "10";
let result: Result<LazyGridData, _> = sample_message.to_string().try_into();
assert!(result.is_ok(), "Deserialization failed with error: {:?}.", result.err());
let lazy_grid = result.unwrap();
assert_eq!(lazy_grid.line_count, 1);
}
}

View File

@ -154,7 +154,7 @@ pub struct Frp {
/// a function called on the Engine side before sending data to IDE, allowing us to do some
/// compression or filtering for the best performance. See also _Lazy Visualization_ section
/// [here](http://dev.enso.org/docs/ide/product/visualizations.html).
pub preprocessor_change: frp::Source<PreprocessorConfiguration>,
pub preprocessor_change: frp::Any<PreprocessorConfiguration>,
}
impl FrpInputs {
@ -176,7 +176,7 @@ impl Frp {
pub fn new(network: &frp::Network) -> Self {
let inputs = FrpInputs::new(network);
frp::extend! { network
def preprocessor_change = source();
def preprocessor_change = any_mut();
on_preprocessor_change <- preprocessor_change.sampler();
def data_receive_error = source();
is_active <- bool(&inputs.deactivate,&inputs.activate);

View File

@ -100,7 +100,7 @@ impl Registry {
/// Add default visualizations to the registry.
pub fn add_default_visualizations(&self) {
self.add(builtin::visualization::native::RawText::definition());
self.add(builtin::visualization::native::text_visualization::text_visualisation());
self.try_add_java_script(builtin::visualization::java_script::scatter_plot_visualization());
self.try_add_java_script(builtin::visualization::java_script::histogram_visualization());
self.try_add_java_script(builtin::visualization::java_script::heatmap_visualization());
@ -113,7 +113,7 @@ impl Registry {
/// Return a default visualisation definition.
pub fn default_visualisation() -> visualization::Definition {
builtin::visualization::native::RawText::definition()
builtin::visualization::native::text_visualization::text_visualisation()
}
}

View File

@ -1,6 +1,6 @@
# Options intended to be common for all developers.
wasm-size-limit: 15.47 MiB
wasm-size-limit: 15.50 MiB
required-versions:
cargo-watch: ^8.1.1

View File

@ -7,6 +7,7 @@ import Standard.Table.Data.Row.Row
import Standard.Table.Data.Storage.Storage
import project.Id.Id
from project.Text import get_lazy_visualisation_text_window
## UNSTABLE
ADVANCED
@ -234,6 +235,42 @@ Column.is_numeric : Boolean
Column.is_numeric self =
[Storage.Integer,Storage.Decimal].contains self.storage_type
## PRIVATE
Returns the data requested to render a lazy view of the default visualisation. Decides
whether to return a simplified version of the lazy data format.
make_lazy_visualisation_data : Text -> Vector Integer -> Vector Integer -> Text
make_lazy_visualisation_data text text_window_position text_window_size chunk_size =
min_length_for_laziness = chunk_size * (text_window_size.first) * (text_window_size.second)
if text.length < min_length_for_laziness then text else
get_lazy_visualisation_text_window text text_window_position text_window_size chunk_size
## UNSTABLE
ADVANCED
Returns the data requested to render a lazy view of the default visualisation.
Any.to_lazy_visualization_data : Vector Integer -> Vector Integer -> Text
Any.to_lazy_visualization_data self text_window_position text_window_size chunk_size =
## Workaround so that the JS String is converted to a Text
https://www.pivotaltracker.com/story/show/184061302
"" + make_lazy_visualisation_data self.to_default_visualization_data text_window_position text_window_size chunk_size
## UNSTABLE
ADVANCED
Returns the data requested to render a lazy view of the default visualisation.
Text.to_default_visualization_data : Text
Text.to_default_visualization_data self =
self.to_lazy_visualization_data [0,0] [10,10] 20
## UNSTABLE
ADVANCED
Returns the data requested to render a lazy view of the default visualisation.
Text.to_lazy_visualization_data : Vector Integer -> Vector Integer -> Integer -> Text
Text.to_lazy_visualization_data self text_window_position text_window_size chunk_size =
min_length_for_laziness = chunk_size * (text_window_size.first) * (text_window_size.second)
if self.length < min_length_for_laziness then "" + self.to_json else
## Workaround so that the JS String is converted to a Text
https://www.pivotaltracker.com/story/show/184061302
"" + get_lazy_visualisation_text_window self text_window_position text_window_size chunk_size
## UNSTABLE
ADVANCED

View File

@ -8,3 +8,4 @@ import project.Helpers
from project.File_Upload export file_uploading
export project.Id.Id
export project.Helpers

View File

@ -5,6 +5,13 @@ import project.Helpers
Default visualization preprocessor.
default_preprocessor x = x.to_default_visualization_data
## PRIVATE
Lazy visualization preprocessor.
lazy_preprocessor x = x.to_lazy_visualization_data
## PRIVATE
Error visualization preprocessor.
error_preprocessor x =

View File

@ -0,0 +1,64 @@
from Standard.Base import all
from Standard.Base.Data.Text.Extensions import slice_text
## PRIVATE
Message to be sent to the IDE.
type Message
Value chunks line_count max_line_length
## PRIVATE
Generate JSON that can be consumed by the visualization.
to_json : Json
to_json self =
chunks = ["chunks", self.chunks]
line_count = ["line_count", self.line_count]
max_line_length = ["longest_line", self.max_line_length]
Json.from_pairs [chunks, line_count, max_line_length]
## Return a sub-window of a string. The window is defined by line/chunk coordinates. The size of
a chunk is defined by `chunk_width`. The output is formatted as a message that can be sent to
the IDE's lazy text visualisation.
get_lazy_visualisation_text_window text pos size chunk_width =
get_text_chunk = get_item_from text chunk_width
lines = text.lines.length
pos_x = Math.max pos.first 0
pos_y = Math.max pos.second 0
size_x = size.first
size_y = size.second
x_range = pos_x.up_to (pos_x + size_x)
y_range = pos_y.up_to (Math.min (pos_y + size_y) lines)
coordinates = x_range.map (x -> y_range.map (y -> [x,y])) . flatten
chunks = coordinates.map (ix -> [ix, (get_text_chunk ix)])
active_lines = y_range.map text.lines.at
max_line_length = (active_lines.map (line -> line.length)).fold 0 (l -> r -> Math.max l r)
make_grid_visualisation_response chunks lines max_line_length
## PRIVATE
Format a chunk of text and meta information for the lazy visualisation.
make_grid_visualisation_response chunks lines max_line_length =
message = Message.Value chunks lines max_line_length
message.to_json + ""
## PRIVATE
Return a chunk of text from a string. The chunk is defined by a its size and a line/chunk index
coordinate.
get_item_from text chunk_size index =
line_ix = index.second
if line_ix >= text.lines.length then Nothing else
chunk_ix = index.first
line = text.lines.at line_ix
get_chunk_from_line line chunk_size chunk_ix
## PRIVATE
Return a chunk of text from a line. The chunk is defined by a its size and a chunk index.
get_chunk_from_line text chunk_size ix =
upper_bound = text.length
start = ix * chunk_size
end = Math.min (start + chunk_size) upper_bound
range = start.up_to end
if start > text.length then Nothing else
slice_text text [range]

View File

@ -578,7 +578,7 @@ define_themes! { [light:0, dark:1]
offset = 0.0 , 0.0;
}
text_grid {
font = "DejaVuSansMonoBook" , "DejaVuSansMonoBook";
font = "DejaVu Sans Mono" , "DejaVu Sans Mono";
font_size = 12.0 , 12.0;
}

View File

@ -0,0 +1,29 @@
from Standard.Base import all
from Standard.Visualization import all
import Standard.Examples
import Standard.Visualization.Text as TextVis
import Standard.Visualization.Preprocessor as Preprocessor
from Standard.Test import Test
import Standard.Test.Extensions
sample_text_single_line = "ABCDEFGHIJKLMNOPQRS"
sample_text_multi_line = """
ABCDEFGHIJKLMNOPQRS
1234567890
spec = Test.group "Lazy Text Visualization" <|
Test.specify "Should provide the correct chunk data" <|
(Preprocessor.lazy_preprocessor sample_text_multi_line [0,0] [1,1] 5).should_equal '{"chunks":[[[0,0],"ABCDE"]],"line_count":2,"longest_line":19}'
(Preprocessor.lazy_preprocessor sample_text_multi_line [1,1] [1,1] 5).should_equal '{"chunks":[[[1,1],"67890"]],"line_count":2,"longest_line":10}'
(Preprocessor.lazy_preprocessor sample_text_multi_line [0,0] [2,1] 5).should_equal '{"chunks":[[[0,0],"ABCDE"],[[1,0],"FGHIJ"]],"line_count":2,"longest_line":19}'
(Preprocessor.lazy_preprocessor sample_text_multi_line [0,0] [1,2] 5).should_equal '{"chunks":[[[0,0],"ABCDE"],[[0,1],"12345"]],"line_count":2,"longest_line":19}'
Test.specify "Should provide a simple string for small data" <|
(Preprocessor.lazy_preprocessor 10 [0,0] [1,1] 5).should_equal '10'
(Preprocessor.lazy_preprocessor 'Just A Simple String' [0,0] [5,1] 15).should_equal '"Just A Simple String"'
Test.specify "Should provide null for out of bounds data" <|
(Preprocessor.lazy_preprocessor sample_text_multi_line [100,0] [1,1] 5).should_equal '{"chunks":[[[100,0],null]],"line_count":2,"longest_line":19}'

View File

@ -6,6 +6,7 @@ import project.Geo_Map_Spec
import project.Helpers_Spec
import project.Histogram_Spec
import project.Id_Spec
import project.Lazy_Text_Spec
import project.Scatter_Plot_Spec
import project.SQL_Spec
import project.Table_Spec
@ -16,6 +17,7 @@ main = Test_Suite.run_main <|
Helpers_Spec.spec
Histogram_Spec.spec
Id_Spec.spec
Lazy_Text_Spec.spec
Scatter_Plot_Spec.spec
SQL_Spec.spec
Table_Spec.spec