mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 18:01:38 +03:00
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:
parent
bf9508603f
commit
38906b39da
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
@ -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) = ¶ms.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
|
||||
}
|
||||
|
@ -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())
|
||||
},
|
||||
)
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) = ¶ms.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
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -8,3 +8,4 @@ import project.Helpers
|
||||
from project.File_Upload export file_uploading
|
||||
export project.Id.Id
|
||||
export project.Helpers
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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]
|
@ -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;
|
||||
|
||||
}
|
||||
|
29
test/Visualization_Tests/src/Lazy_Text_Spec.enso
Normal file
29
test/Visualization_Tests/src/Lazy_Text_Spec.enso
Normal 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}'
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user