From 59577264213a567857b4e97dacab6346d5f34fa9 Mon Sep 17 00:00:00 2001 From: Joe Bellus Date: Sun, 16 Oct 2022 18:14:55 +0000 Subject: [PATCH] Foundational UI (#1) ore Foundational Commit Implmeneted basic application functionality: Editor Output Engine integration Scripting Save/Open Dialogs Multiple Block Management Keybinds Modal Editing Block Renaming File Serialization/Deserialization --- Cargo.lock | 321 ++++++++++++++- abacus-core/Cargo.toml | 1 + abacus-core/src/dataframe.rs | 202 +++++++++- abacus-core/src/engine.rs | 79 ++-- abacus-core/src/lib.rs | 4 + abacus-core/src/save_file.rs | 45 +++ abacus-core/test/data.csv | 4 + abacus-ui/Cargo.toml | 7 +- abacus-ui/assets/abacus.svg | 38 ++ abacus-ui/assets/ban.svg | 38 ++ abacus-ui/assets/floppy-disk.svg | 38 ++ abacus-ui/assets/folder-open.svg | 38 ++ abacus-ui/assets/play.svg | 38 ++ abacus-ui/assets/plus.svg | 38 ++ abacus-ui/assets/trash.svg | 38 ++ abacus-ui/src/app_delegate.rs | 86 ++++ abacus-ui/src/app_header.rs | 188 +++++++++ abacus-ui/src/commands.rs | 9 + abacus-ui/src/data/app_data.rs | 96 +++++ abacus-ui/src/data/editor_data.rs | 377 ++++++++++++++++++ abacus-ui/src/data/mod.rs | 7 + abacus-ui/src/data/modals.rs | 13 + abacus-ui/src/editor.rs | 638 ++++++++++++++++++++++++++---- abacus-ui/src/main.rs | 58 +-- abacus-ui/src/modal_container.rs | 256 ++++++++++++ abacus-ui/src/output.rs | 1 + abacus-ui/src/output_block.rs | 130 ++++++ abacus-ui/src/piet_plotters.rs | 336 ++++++++++++++++ abacus-ui/src/plot_view.rs | 60 +++ abacus-ui/src/toolbar_button.rs | 68 ++++ drone.yml | 27 ++ frame | 8 + 32 files changed, 3126 insertions(+), 161 deletions(-) create mode 100644 abacus-core/src/save_file.rs create mode 100644 abacus-core/test/data.csv create mode 100644 abacus-ui/assets/abacus.svg create mode 100644 abacus-ui/assets/ban.svg create mode 100644 abacus-ui/assets/floppy-disk.svg create mode 100644 abacus-ui/assets/folder-open.svg create mode 100644 abacus-ui/assets/play.svg create mode 100644 abacus-ui/assets/plus.svg create mode 100644 abacus-ui/assets/trash.svg create mode 100644 abacus-ui/src/app_delegate.rs create mode 100644 abacus-ui/src/app_header.rs create mode 100644 abacus-ui/src/commands.rs create mode 100644 abacus-ui/src/data/app_data.rs create mode 100644 abacus-ui/src/data/editor_data.rs create mode 100644 abacus-ui/src/data/mod.rs create mode 100644 abacus-ui/src/data/modals.rs create mode 100644 abacus-ui/src/modal_container.rs create mode 100644 abacus-ui/src/output.rs create mode 100644 abacus-ui/src/output_block.rs create mode 100644 abacus-ui/src/piet_plotters.rs create mode 100644 abacus-ui/src/plot_view.rs create mode 100644 abacus-ui/src/toolbar_button.rs create mode 100644 drone.yml create mode 100644 frame diff --git a/Cargo.lock b/Cargo.lock index 469de53..502ff2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "polars", "rhai", "serde", + "serde_json", "syntect", "tracing-subscriber", ] @@ -18,7 +19,10 @@ name = "abacus-ui" version = "0.1.0" dependencies = [ "abacus-core", + "clipboard", "druid", + "ropey", + "syntect", ] [[package]] @@ -158,6 +162,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block" version = "0.1.6" @@ -250,6 +263,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "clipboard" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7" +dependencies = [ + "clipboard-win", + "objc", + "objc-foundation", + "objc_id", + "x11-clipboard", +] + +[[package]] +name = "clipboard-win" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b" +dependencies = [ + "winapi", +] + [[package]] name = "cocoa" version = "0.24.0" @@ -442,6 +477,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + [[package]] name = "dirs" version = "4.0.0" @@ -474,12 +518,14 @@ dependencies = [ "fluent-langneg", "fluent-syntax", "fnv", + "im", "instant", "tracing", "tracing-subscriber", "tracing-wasm", "unic-langid", "unicode-segmentation", + "usvg", "xi-unicode", ] @@ -576,6 +622,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75224bec9bfe1a65e2d34132933f2de7fe79900c96a0174307554244ece8150e" + [[package]] name = "fluent-bundle" version = "0.15.2" @@ -616,6 +668,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fontdb" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58903f4f8d5b58c7d300908e4ebe5289c1bfdf5587964330f12023b8ff17fd1" +dependencies = [ + "log", + "memmap2 0.2.3", + "ttf-parser 0.12.3", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -951,6 +1014,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" version = "1.9.1" @@ -1181,6 +1258,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "memmap2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723e3ebdcdc5c023db1df315364573789f8857c11b631a2fdfad7c00f5c046b4" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.5.7" @@ -1355,6 +1441,26 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" version = "1.15.0" @@ -1474,6 +1580,12 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pico-args" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8bcd96cb740d03149cbad5518db9fd87126a10ab519c011893b1754134c468" + [[package]] name = "piet" version = "0.5.0" @@ -1657,7 +1769,7 @@ dependencies = [ "lexical", "lexical-core", "memchr", - "memmap2", + "memmap2 0.5.7", "num", "once_cell", "polars-arrow", @@ -1818,6 +1930,15 @@ dependencies = [ "rand", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "rayon" version = "1.5.3" @@ -1842,6 +1963,12 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "rctree" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9e29cb19c8fe84169fcb07f8f11e66bc9e6e0280efd4715c54818296f8a4a8" + [[package]] name = "redox_syscall" version = "0.2.16" @@ -1905,6 +2032,25 @@ dependencies = [ "syn", ] +[[package]] +name = "ropey" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd22239fafefc42138ca5da064f3c17726a80d2379d817a3521240e78dd0064" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b" +dependencies = [ + "xmlparser", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -1926,6 +2072,22 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" +[[package]] +name = "rustybuzz" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab463a295d00f3692e0974a0bfd83c7a9bcd119e27e07c2beecdb1b44a09d10" +dependencies = [ + "bitflags", + "bytemuck", + "smallvec", + "ttf-parser 0.9.0", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-general-category", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.11" @@ -1999,9 +2161,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "41feea4228a6f1cd09ec7a3593a682276702cd67b5273544757dae23c096f074" dependencies = [ "itoa", "ryu", @@ -2053,6 +2215,31 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" +[[package]] +name = "simplecss" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a11be7c62927d9427e9f40f3444d5499d868648e2edbc4e2116de69e7ec0e89d" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" + +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.7" @@ -2085,6 +2272,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9199fa80c817e074620be84374a520062ebac833f358d74b37060ce4a0f2c0" + [[package]] name = "strength_reduce" version = "0.2.3" @@ -2128,6 +2321,16 @@ dependencies = [ "syn", ] +[[package]] +name = "svgtypes" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c536faaff1a10837cfe373142583f6e27d81e96beba339147e77b67c9f260ff" +dependencies = [ + "float-cmp", + "siphasher", +] + [[package]] name = "syn" version = "1.0.102" @@ -2315,6 +2518,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ttf-parser" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ddb402ac6c2af6f7a2844243887631c4e94b51585b229fcfddb43958cd55ca" + +[[package]] +name = "ttf-parser" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae2f58a822f08abdaf668897e96a5656fe72f5a9ce66422423e8849384872e6" + [[package]] name = "type-map" version = "0.4.0" @@ -2324,6 +2539,12 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "ucd-trie" version = "0.1.5" @@ -2399,24 +2620,87 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" + +[[package]] +name = "unicode-ccc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" + +[[package]] +name = "unicode-general-category" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9af028e052a610d99e066b33304625dea9613170a2563314490a4e6ec5cf7f" + [[package]] name = "unicode-ident" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" +[[package]] +name = "unicode-script" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d817255e1bed6dfd4ca47258685d14d2bdcfbc64fdc9e3819bd5848057b8ecc" + [[package]] name = "unicode-segmentation" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +[[package]] +name = "usvg" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8352f317d8f9a918ba5154797fb2a93e2730244041cf7d5be35148266adfa5" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "kurbo", + "log", + "memmap2 0.2.3", + "pico-args", + "rctree", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "svgtypes", + "ttf-parser 0.12.3", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf16_lit" version = "2.0.2" @@ -2611,6 +2895,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "x11-clipboard" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea" +dependencies = [ + "xcb", +] + +[[package]] +name = "xcb" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de" +dependencies = [ + "libc", + "log", +] + [[package]] name = "xi-unicode" version = "0.3.0" @@ -2623,6 +2926,18 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" +[[package]] +name = "xmlparser" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114ba2b24d2167ef6d67d7d04c8cc86522b87f490025f39f0303b7db5bf5e3d8" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/abacus-core/Cargo.toml b/abacus-core/Cargo.toml index d67445f..deddaab 100644 --- a/abacus-core/Cargo.toml +++ b/abacus-core/Cargo.toml @@ -11,5 +11,6 @@ syntect = "5.0.0" rhai = "1.10.1" polars = { version = "0.24.3", features = ["lazy", "rows"] } tracing-subscriber = "0.3" +serde_json = "1.0.86" diff --git a/abacus-core/src/dataframe.rs b/abacus-core/src/dataframe.rs index 14dbd26..c4f6c8e 100644 --- a/abacus-core/src/dataframe.rs +++ b/abacus-core/src/dataframe.rs @@ -6,7 +6,7 @@ use std::{ str::FromStr, }; -use rhai::EvalAltResult; +use rhai::{Dynamic, EvalAltResult, EvalContext, Expression, Position}; pub fn setup_engine(engine: &mut rhai::Engine) { //polar data frame @@ -14,20 +14,36 @@ pub fn setup_engine(engine: &mut rhai::Engine) { engine.register_fn("dataframe", script_functions::dataframe); engine.register_fn("select", DataFrame::s_select); engine.register_fn("load_csv", DataFrame::load_csv); + engine.register_fn("column", DataFrame::s_column); + engine.register_indexer_get(DataFrame::s_column); + engine.register_indexer_set(DataFrame::append_column); // polar series engine.register_type::(); engine.register_indexer_get(Series::s_get); engine.register_fn("series", script_functions::series); + engine.register_fn("to_series", script_functions::to_series); + engine.register_fn("to_series", script_functions::to_series_unnamed); + engine.register_fn("series", script_functions::series_unnamed); engine.register_fn("head", Series::s_head); - engine.register_fn("+", Series::add); engine.register_fn("sort", Series::s_sort); engine.register_fn("sum", Series::s_sum); engine.register_fn("add", Series::s_op_add); - engine.register_fn("column", DataFrame::s_column); + + engine.register_fn("+", Series::add); engine.register_fn("+", script_functions::add_series_i64); engine.register_fn("+", script_functions::add_series_f64); + + engine.register_fn("-", script_functions::subtract_series_series); engine.register_fn("-", script_functions::subtract_series_i64); engine.register_fn("-", script_functions::subtract_series_f64); + + engine.register_fn("*", script_functions::multiply_series_series); + engine.register_fn("*", script_functions::multiply_series_i64); + engine.register_fn("*", script_functions::multiply_series_f64); + + engine.register_fn("/", script_functions::div_series_series); + engine.register_fn("/", script_functions::div_series_i64); + engine.register_fn("/", script_functions::div_series_f64); // polar expressions engine.register_type::(); engine.register_fn("sum", DataFrameExpression::sum); @@ -49,9 +65,22 @@ pub fn setup_engine(engine: &mut rhai::Engine) { engine.register_fn("min", script_functions::min); engine.register_fn("max", script_functions::max); engine.register_fn("first", script_functions::first); + let _ = engine.register_custom_operator("gt", 200); + let _ = engine.register_custom_operator("gte", 200); + let _ = engine.register_custom_operator("<<", 200); + engine.register_fn("gt", script_functions::gt_op); + engine.register_fn("gte", script_functions::gte_op); + + engine + .register_custom_syntax( + ["from", "$ident$", "$expr$", ":", "$expr$"], // the custom syntax + true, // variables declared within this custom syntax + implementation_df_select, + ) + .unwrap(); } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct DataFrame(polars::frame::DataFrame); impl Deref for DataFrame { @@ -72,6 +101,8 @@ impl DataFrame { pub fn load_csv(path: &str) -> ScriptResult { let df = polars::io::csv::CsvReader::from_path(path) .map_err(|e| e.to_string())? + .infer_schema(Some(1)) + .with_ignore_parser_errors(true) .has_header(true) .finish() .map_err(|e| e.to_string())?; @@ -93,6 +124,12 @@ impl DataFrame { .map_err(|e| e.to_string())?, )) } + + pub fn append_column(&mut self, idx: &str, mut b: Series) -> ScriptResult<()> { + b.0.rename(idx); + self.0.with_column(b.0).map_err(|e| e.to_string())?; + Ok(()) + } } #[derive(Clone, Debug)] @@ -158,7 +195,7 @@ impl From for DataFrameExpression { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Series(polars::series::Series); impl Deref for Series { @@ -242,6 +279,34 @@ mod script_functions { Series(a.0 - b) } + pub fn subtract_series_series(a: Series, b: Series) -> Series { + Series(a.0 - b.0) + } + + pub fn multiply_series_i64(a: Series, b: i64) -> Series { + Series(a.0 * b) + } + + pub fn multiply_series_f64(a: Series, b: f64) -> Series { + Series(a.0 * b) + } + + pub fn multiply_series_series(a: Series, b: Series) -> Series { + Series(a.0 * b.0) + } + + pub fn div_series_i64(a: Series, b: i64) -> Series { + Series(a.0 / b) + } + + pub fn div_series_f64(a: Series, b: f64) -> Series { + Series(a.0 / b) + } + + pub fn div_series_series(a: Series, b: Series) -> Series { + Series(a.0 / b.0) + } + pub fn column(name: &str) -> DataFrameExpression { DataFrameExpression(polars::prelude::col(name)) } @@ -288,27 +353,42 @@ mod script_functions { } } + pub fn series_unnamed(arr: rhai::Array) -> std::result::Result> { + series("Unnamed", arr) + } + + pub fn to_series_unnamed(arr: rhai::Array) -> std::result::Result> { + series("Unnamed", arr) + } + + pub fn to_series( + arr: rhai::Array, + name: &str, + ) -> std::result::Result> { + series(name, arr) + } + pub fn series(name: &str, arr: rhai::Array) -> std::result::Result> { if let Some(i) = arr.first() { let series = if i.type_id() == TypeId::of::() { polars::series::Series::new( name, arr.into_iter() - .map(|i| i.cast::()) + .filter_map(|i| i.try_cast::()) .collect::>(), ) } else if i.type_id() == TypeId::of::() { polars::series::Series::new( name, arr.into_iter() - .map(|i| i.cast::()) + .filter_map(|i| i.try_cast::()) .collect::>(), ) } else { polars::series::Series::new( name, arr.into_iter() - .map(|i| i.cast::()) + .filter_map(|i| i.try_cast::()) .collect::>(), ) }; @@ -319,6 +399,90 @@ mod script_functions { Ok(Series(s)) } } + pub fn gt_op(a: &str, b: rhai::Dynamic) -> DataFrameExpression { + DataFrameExpression(polars::prelude::col(a).gt(DataFrameExpression::from(b))) + } + + pub fn gte_op(a: &str, b: rhai::Dynamic) -> DataFrameExpression { + DataFrameExpression(polars::prelude::col(a).gt_eq(DataFrameExpression::from(b))) + } +} + +fn implementation_df_select( + context: &mut EvalContext, + inputs: &[Expression], +) -> Result> { + let df_name = inputs[0].get_string_value().ok_or_else(|| { + Box::new(EvalAltResult::ErrorVariableNotFound( + "variable not found".to_string(), + Position::default(), + )) + })?; + + let df = context + .scope() + .get(df_name) + .ok_or_else(|| { + Box::new(EvalAltResult::ErrorVariableNotFound( + format!("{} not found", df_name), + Position::default(), + )) + })? + .clone() + .try_cast::() + .ok_or_else(|| { + Box::new(EvalAltResult::ErrorVariableNotFound( + format!("{} not found", df_name), + Position::default(), + )) + })?; + + let raw_filter_array = context.eval_expression_tree(&inputs[2])?; + let filter_array = raw_filter_array + .into_array() + .map_err(|e| { + Box::new(EvalAltResult::ErrorVariableNotFound( + format!("{} value not an array", e), + Position::default(), + )) + })? + .into_iter() + .map(|i| i.cast::()) + .collect::>(); + + let raw_select_array = context.eval_expression_tree(&inputs[1])?; + let select_array = raw_select_array.into_array().map_err(|e| { + Box::new(EvalAltResult::ErrorVariableNotFound( + format!("{} value not an array", e), + Position::default(), + )) + })?; + + let select_expressions = select_array + .iter() + .filter_map(|s| { + let select_expr = if s.is::() { + polars::prelude::col(&s.to_string()) + } else if s.is::() { + s.clone().cast::().0 + } else { + return None; + }; + + Some( + filter_array + .iter() + .fold(select_expr, |acc, i| acc.filter(i.0.clone())), + ) + }) + .collect::>(); + + Ok(Dynamic::from(DataFrame( + df.0.lazy() + .select(&select_expressions) + .collect() + .map_err(|e| e.to_string())?, + ))) } #[cfg(test)] @@ -474,4 +638,26 @@ s1 + s2 .collect::>(); assert_eq!(s, vec![Some(18)]); } + + #[test] + pub fn test_dataframe_select_syntax() { + let res = process( + r#" +let data = load_csv("test/data.csv"); +from data ["age"] : ["age" gt 18]; +"#, + ); + + dbg!(&res); + + let s = res + .into_frame() + .column("age") + .unwrap() + .i64() + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(s, vec![Some(22), Some(32)]); + } } diff --git a/abacus-core/src/engine.rs b/abacus-core/src/engine.rs index 7a17714..6735121 100644 --- a/abacus-core/src/engine.rs +++ b/abacus-core/src/engine.rs @@ -1,12 +1,12 @@ use crate::dataframe; #[derive(Debug)] -pub struct Engine { - pub blocks: Vec, +pub struct Engine<'a> { engine: rhai::Engine, + scope: rhai::Scope<'a>, } -impl Default for Engine { +impl<'a> Default for Engine<'a> { fn default() -> Self { let mut engine = rhai::Engine::new(); engine.set_fast_operators(false); @@ -14,30 +14,28 @@ impl Default for Engine { Self { engine, - blocks: vec![Block::default()], + scope: rhai::Scope::new(), } } } -impl Engine { - pub fn process(&mut self) { - for block in self.blocks.iter_mut() { - match self.engine.eval::(&block.script) { - Ok(res) if res.is::() => { - let frame = rhai::Dynamic::cast::(res); - block.output = Output::DataFrame(frame); - } - Ok(res) if res.is::() => { - let frame = rhai::Dynamic::cast::(res); - block.output = Output::Series(frame); - } - Ok(res) => { - block.output = Output::Scalar(res); - } - Err(e) => { - block.output = Output::Error(e.to_string()); - } +impl<'a> Engine<'a> { + pub fn process_script(&mut self, script: &str) -> Output { + match self + .engine + .eval_with_scope::(&mut self.scope, script) + { + Ok(res) if res.is::() => { + let frame = rhai::Dynamic::cast::(res); + Output::DataFrame(frame) } + Ok(res) if res.is::<()>() => Output::None, + Ok(res) if res.is::() => { + let series = rhai::Dynamic::cast::(res); + Output::Series(series) + } + Ok(res) => Output::Scalar(res), + Err(e) => Output::Error(e.to_string()), } } } @@ -51,6 +49,24 @@ pub enum Output { Error(String), } +impl Default for Output { + fn default() -> Self { + Output::None + } +} + +impl PartialEq for Output { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Scalar(l0), Self::Scalar(r0)) => l0.to_string() == r0.to_string(), + (Self::DataFrame(l0), Self::DataFrame(r0)) => l0 == r0, + (Self::Series(l0), Self::Series(r0)) => l0 == r0, + (Self::Error(l0), Self::Error(r0)) => l0 == r0, + _ => core::mem::discriminant(self) == core::mem::discriminant(other), + } + } +} + impl Output { pub fn into_frame(self) -> dataframe::DataFrame { if let Self::DataFrame(v) = self { @@ -72,29 +88,12 @@ impl Output { } } -#[derive(Debug)] -pub struct Block { - pub script: String, - pub output: Output, -} - -impl Default for Block { - fn default() -> Self { - Self { - script: Default::default(), - output: Output::None, - } - } -} - #[cfg(test)] pub mod tests { use super::*; pub fn process(script: &str) -> Output { let mut engine = Engine::default(); - engine.blocks[0].script = script.into(); - engine.process(); - engine.blocks[0].output.clone() + engine.process_script(script) } } diff --git a/abacus-core/src/lib.rs b/abacus-core/src/lib.rs index ee4f77d..a310baa 100644 --- a/abacus-core/src/lib.rs +++ b/abacus-core/src/lib.rs @@ -1,7 +1,11 @@ mod dataframe; mod engine; +mod save_file; pub use engine::Engine; +pub use engine::Output; +pub use polars::prelude::AnyValue; +pub use save_file::{SaveBlock, SaveFile}; use rhai::EvalAltResult; type ScriptResult = std::result::Result>; diff --git a/abacus-core/src/save_file.rs b/abacus-core/src/save_file.rs new file mode 100644 index 0000000..7da372f --- /dev/null +++ b/abacus-core/src/save_file.rs @@ -0,0 +1,45 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct SaveBlock { + pub name: String, + pub content: String, +} + +impl SaveBlock { + pub fn new(name: &str, content: S) -> Self { + Self { + name: name.to_string(), + content: content.to_string(), + } + } +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct SaveFile { + #[serde(skip)] + pub filepath: String, + pub blocks: Vec, +} + +impl SaveFile { + pub fn new(filepath: String, blocks: Vec) -> Self { + Self { filepath, blocks } + } + + pub fn open(filepath: &str) -> Result { + let data = std::fs::read_to_string(filepath)?; + let mut slf = serde_json::from_str::(&data) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + slf.filepath = filepath.to_string(); + Ok(slf) + } + + pub fn save(&self) -> Result<(), std::io::Error> { + let s = serde_json::to_vec_pretty(&self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + std::fs::write(&self.filepath, s) + } +} diff --git a/abacus-core/test/data.csv b/abacus-core/test/data.csv new file mode 100644 index 0000000..ade1481 --- /dev/null +++ b/abacus-core/test/data.csv @@ -0,0 +1,4 @@ +name,age, +alice,18 +sasha,22 +lacey,32 \ No newline at end of file diff --git a/abacus-ui/Cargo.toml b/abacus-ui/Cargo.toml index 6cdea7d..e3dbeec 100644 --- a/abacus-ui/Cargo.toml +++ b/abacus-ui/Cargo.toml @@ -6,5 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -druid = { git = "https://github.com/linebender/druid.git" } -abacus-core = { path = "../abacus-core" } \ No newline at end of file +druid = { git = "https://github.com/linebender/druid.git", features=["im", "svg"] } +abacus-core = { path = "../abacus-core" } +syntect = "5.0.0" +ropey = "1.5.0" +clipboard = "0.5.0" \ No newline at end of file diff --git a/abacus-ui/assets/abacus.svg b/abacus-ui/assets/abacus.svg new file mode 100644 index 0000000..aa78e93 --- /dev/null +++ b/abacus-ui/assets/abacus.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/ban.svg b/abacus-ui/assets/ban.svg new file mode 100644 index 0000000..1db0217 --- /dev/null +++ b/abacus-ui/assets/ban.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/floppy-disk.svg b/abacus-ui/assets/floppy-disk.svg new file mode 100644 index 0000000..0c27a3b --- /dev/null +++ b/abacus-ui/assets/floppy-disk.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/folder-open.svg b/abacus-ui/assets/folder-open.svg new file mode 100644 index 0000000..c966947 --- /dev/null +++ b/abacus-ui/assets/folder-open.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/play.svg b/abacus-ui/assets/play.svg new file mode 100644 index 0000000..b89eab3 --- /dev/null +++ b/abacus-ui/assets/play.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/plus.svg b/abacus-ui/assets/plus.svg new file mode 100644 index 0000000..438f12d --- /dev/null +++ b/abacus-ui/assets/plus.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/assets/trash.svg b/abacus-ui/assets/trash.svg new file mode 100644 index 0000000..b25a4a6 --- /dev/null +++ b/abacus-ui/assets/trash.svg @@ -0,0 +1,38 @@ + + + + + + + diff --git a/abacus-ui/src/app_delegate.rs b/abacus-ui/src/app_delegate.rs new file mode 100644 index 0000000..3739476 --- /dev/null +++ b/abacus-ui/src/app_delegate.rs @@ -0,0 +1,86 @@ +use druid::AppDelegate; + +use crate::{ + commands, + data::{AppData, Block}, +}; + +pub struct Delegate; + +impl AppDelegate for Delegate { + fn command( + &mut self, + _ctx: &mut druid::DelegateCtx, + _target: druid::Target, + cmd: &druid::Command, + data: &mut AppData, + _env: &druid::Env, + ) -> druid::Handled { + if let Some(file_info) = cmd.get(druid::commands::SAVE_FILE_AS) { + let filepath = file_info.path().as_os_str().to_str().unwrap().to_string(); + data.filename = Some(filepath); + data.save(); + return druid::Handled::Yes; + } + + if let Some(file_info) = cmd.get(druid::commands::OPEN_FILE) { + let filepath = file_info.path().as_os_str().to_str().unwrap(); + match abacus_core::SaveFile::open(filepath) { + Ok(save_file) => { + data.blocks = save_file + .blocks + .iter() + .enumerate() + .map(|(i, b)| Block::new_with_content(&b.name, i, &b.content)) + .collect(); + } + Err(e) => { + println!("{}", e); + } + } + data.filename = Some(filepath.to_string()); + return druid::Handled::Yes; + } + + if cmd.is(commands::PROCESS_WORKBOOK) { + data.process(); + return druid::Handled::Yes; + } + + if cmd.is(commands::PROCESS_BLOCK) { + if let Some(index) = cmd.get(commands::PROCESS_BLOCK) { + data.process_block(*index); + return druid::Handled::Yes; + } + } + + if cmd.is(commands::DELETE_BLOCK) { + if let Some(index) = cmd.get(commands::DELETE_BLOCK) { + data.remove_block(*index); + return druid::Handled::Yes; + } + } + + druid::Handled::No + } + + fn event( + &mut self, + _ctx: &mut druid::DelegateCtx, + _window_id: druid::WindowId, + event: druid::Event, + data: &mut AppData, + _env: &druid::Env, + ) -> Option { + if let druid::Event::KeyDown(ref e) = event { + if druid::keyboard_types::Key::Character(String::from("n")) == e.key && e.mods.ctrl() { + data.blocks.push_back(Block::new( + &format!("Block #{}", data.blocks.len() + 1), + data.blocks.len(), + )); + return None; + } + } + Some(event) + } +} diff --git a/abacus-ui/src/app_header.rs b/abacus-ui/src/app_header.rs new file mode 100644 index 0000000..dbbb2bc --- /dev/null +++ b/abacus-ui/src/app_header.rs @@ -0,0 +1,188 @@ +use druid::{ + widget::{Container, Controller, Flex, Label, Padding, Painter, Svg, SvgData}, + Color, Data, LifeCycle, RenderContext, Widget, WidgetExt, +}; + +use crate::AppData; + +pub fn app_header_ui() -> impl Widget { + let open_svg = include_str!("../assets/folder-open.svg") + .parse::() + .unwrap_or_default(); + + let disk_svg = include_str!("../assets/floppy-disk.svg") + .parse::() + .unwrap_or_default(); + + let run_svg = include_str!("../assets/play.svg") + .parse::() + .unwrap_or_default(); + + let plus_svg = include_str!("../assets/plus.svg") + .parse::() + .unwrap_or_default(); + + let abacus_svg = include_str!("../assets/abacus.svg") + .parse::() + .unwrap_or_default(); + + Container::new( + Flex::row() + .must_fill_main_axis(true) + .with_spacer(10.0) + .with_child( + Container::new(Padding::new(5.0, Svg::new(abacus_svg).fix_width(20.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|_ctx, _, _| {}), + ) + .with_spacer(20.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(open_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|ctx, _, _| { + let abacus = druid::FileSpec::new("Abacus File", &["abacus"]); + let json = druid::FileSpec::new("JSON File", &["json"]); + + let open_dialog_options = druid::FileDialogOptions::new() + .allowed_types(vec![abacus, json]) + .default_type(abacus) + .name_label("Source") + .title("Open Workbook") + .button_text("Open"); + + ctx.submit_command( + druid::commands::SHOW_OPEN_PANEL.with(open_dialog_options), + ); + }), + ) + .with_spacer(10.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(disk_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|ctx, data: &mut AppData, _| { + let abacus = druid::FileSpec::new("Abacus File", &["abacus"]); + let json = druid::FileSpec::new("JSON File", &["json"]); + if data.filename.is_some() { + data.save(); + } else { + let save_dialog_options = druid::FileDialogOptions::new() + .allowed_types(vec![abacus, json]) + .default_type(abacus) + .name_label("Target") + .title("Save workbook") + .button_text("Save"); + + ctx.submit_command( + druid::commands::SHOW_SAVE_PANEL.with(save_dialog_options), + ); + } + }), + ) + .with_spacer(20.0) + .with_flex_child( + Padding::new( + 5.0, + Container::new(Padding::new( + 3.0, + Label::dynamic(|data: &AppData, _| { + data.filename + .clone() + .unwrap_or_else(|| String::from("Scratch file")) + }) + .with_text_size(12.0) + .center(), + )) + .background(Color::rgb8(35, 35, 35)) + .rounded(4.0) + .border(Color::rgb8(10, 10, 10), 1.0) + .expand_width(), + ), + 1.0, + ) + .with_spacer(20.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(plus_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|_ctx, data: &mut AppData, _env| { + data.blocks.push_back(crate::Block::new( + &format!("Block #{}", data.blocks.len() + 1), + data.blocks.len(), + )); + }), + ) + .with_spacer(10.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(run_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|_ctx, data: &mut AppData, _env| data.process()), + ) + .with_spacer(20.0) + .expand_width(), + ) + .background(Color::rgb8(20, 20, 20)) +} + +pub fn header_separater() -> impl Widget { + Painter::new(|ctx, _data, _env| { + let rect = ctx.size().to_rect(); + ctx.fill(rect, &Color::rgb8(0, 0, 0)) + }) + .fix_height(1.0) +} + +pub struct ToolbarButtonController { + color: Color, +} + +impl ToolbarButtonController { + pub fn new(color: Color) -> Self { + Self { color } + } +} + +impl Controller> for ToolbarButtonController { + fn event( + &mut self, + child: &mut Container, + ctx: &mut druid::EventCtx, + event: &druid::Event, + data: &mut T, + env: &druid::Env, + ) { + ctx.set_cursor(&druid::Cursor::Pointer); + child.event(ctx, event, data, env) + } + + fn lifecycle( + &mut self, + child: &mut Container, + ctx: &mut druid::LifeCycleCtx, + event: &druid::LifeCycle, + data: &T, + env: &druid::Env, + ) { + match event { + LifeCycle::HotChanged(true) => { + child.set_background(self.color.clone()); + ctx.request_paint(); + } + LifeCycle::HotChanged(false) => { + child.set_background(Color::TRANSPARENT); + ctx.request_paint(); + } + _ => {} + } + child.lifecycle(ctx, event, data, env) + } + + fn update( + &mut self, + child: &mut Container, + ctx: &mut druid::UpdateCtx, + old_data: &T, + data: &T, + env: &druid::Env, + ) { + child.update(ctx, old_data, data, env) + } +} diff --git a/abacus-ui/src/commands.rs b/abacus-ui/src/commands.rs new file mode 100644 index 0000000..a12184e --- /dev/null +++ b/abacus-ui/src/commands.rs @@ -0,0 +1,9 @@ +use druid::Selector; + +pub const PROCESS_WORKBOOK: Selector<()> = Selector::new("process-workbook"); +pub const PROCESS_BLOCK: Selector = Selector::new("process-block"); +pub const DELETE_BLOCK: Selector = Selector::new("delete-block"); + +pub const RENAME_BLOCK: Selector = Selector::new("rename-block"); + +pub const CLOSE_MODAL: Selector<()> = Selector::new("close-modal"); diff --git a/abacus-ui/src/data/app_data.rs b/abacus-ui/src/data/app_data.rs new file mode 100644 index 0000000..d2d0b5a --- /dev/null +++ b/abacus-ui/src/data/app_data.rs @@ -0,0 +1,96 @@ +use abacus_core::Output; +use druid::{ + im::{vector, Vector}, + Data, Lens, +}; + +use super::{EditorData, Modals}; + +#[derive(Clone, Data, Lens, Debug)] +pub struct AppData { + #[data(same_fn = "PartialEq::eq")] + pub filename: Option, + pub blocks: Vector, + pub modals: Modals, +} + +impl AppData { + pub fn save(&self) { + if let Some(ref filepath) = self.filename { + let _ = abacus_core::SaveFile::new( + filepath.clone(), + self.blocks + .iter() + .map(|d| abacus_core::SaveBlock::new(&d.name, &d.editor_data.content)) + .collect(), + ) + .save(); + } + } + + pub fn remove_block(&mut self, idx: usize) { + self.blocks.remove(idx); + for (idx, block) in self.blocks.iter_mut().enumerate() { + block.index = idx; + } + } +} + +impl Default for AppData { + fn default() -> Self { + Self { + filename: None, + blocks: vector![Block::new("Block #1", 0)], + modals: Modals::default(), + } + } +} + +impl AppData { + pub fn process(&mut self) { + let mut engine = abacus_core::Engine::default(); + + for mut block in self.blocks.iter_mut() { + let start = std::time::Instant::now(); + block.output = engine.process_script(&block.editor_data.content.to_string()); + println!("block executed in {}", start.elapsed().as_millis()); + } + } + pub fn process_block(&mut self, index: usize) { + let mut engine = abacus_core::Engine::default(); + if let Some(block) = self.blocks.get_mut(index) { + let start = std::time::Instant::now(); + block.output = engine.process_script(&block.editor_data.content.to_string()); + println!("block executed in {}", start.elapsed().as_millis()); + } + } +} + +#[derive(Clone, Data, Lens, Default, PartialEq, Debug)] +pub struct Block { + pub name: String, + pub editor_data: EditorData, + #[data(same_fn = "PartialEq::eq")] + pub output: Output, + pub index: usize, +} + +impl Block { + pub fn new(name: &str, index: usize) -> Self { + Self { + name: name.to_string(), + editor_data: Default::default(), + output: Default::default(), + index, + } + } + + pub fn new_with_content(name: &str, index: usize, content: &str) -> Self { + Self { + name: name.to_string(), + editor_data: super::EditorData::new(content), + output: Default::default(), + index, + } + } +} diff --git a/abacus-ui/src/data/editor_data.rs b/abacus-ui/src/data/editor_data.rs new file mode 100644 index 0000000..b057225 --- /dev/null +++ b/abacus-ui/src/data/editor_data.rs @@ -0,0 +1,377 @@ +use std::ops::Range; + +use druid::{Data, Lens}; +use ropey::Rope; + +#[derive(Clone, Data, PartialEq, Eq, Debug)] +pub enum EditMode { + Normal, + Insert, +} + +#[derive(Data, Lens, Clone, PartialEq, Debug)] +pub struct EditorData { + #[data(same_fn = "PartialEq::eq")] + pub content: Rope, + pub cursor_pos: usize, + pub mode: EditMode, + pub cursor_opactiy: f64, + pub cursor_fade: f64, + pub selection_pos: Option, +} + +impl Default for EditorData { + fn default() -> Self { + Self { + content: Rope::from_str(""), + cursor_pos: 0, + selection_pos: None, + mode: EditMode::Normal, + cursor_opactiy: 255.0, + cursor_fade: 1.0, + } + } +} + +impl EditorData { + pub fn new(content: &str) -> Self { + Self { + content: Rope::from_str(content), + ..Default::default() + } + } + + pub fn move_cursor(&mut self, idx: usize) { + self.cursor_pos = idx; + } + + pub fn select_range(&self) -> Range { + if let Some(selection_pos) = self.selection_pos { + if self.cursor_pos > selection_pos { + selection_pos..self.cursor_pos + } else { + self.cursor_pos..selection_pos + } + } else { + self.cursor_pos..self.cursor_pos + } + } + + pub fn push_str(&mut self, s: &str) { + if self.selection_pos.is_some() { + self.content.remove(self.select_range()); + self.content.insert(self.select_range().start, s); + } else { + self.content.insert(self.cursor_pos, s); + } + self.move_cursor(self.select_range().start + s.len()); + } + + pub fn push(&mut self, c: char) { + self.content.insert_char(self.cursor_pos, c); + self.cursor_right(); + } + + // pub fn cursor_to_end(&mut self) { + // self.move_cursor(self.content.len_chars()); + // } + + pub fn deselect(&mut self) { + self.selection_pos = None; + } + + pub fn delete_current_line(&mut self) { + if self.current_line_index() < self.content.len_lines() { + self.content.remove( + self.current_line_start() + ..(self.current_line_start() + + self + .current_line() + .len_chars() + .min(self.content.len_chars())), + ); + } else { + self.cursor_up(); + self.content.remove( + self.current_line_start()..(self.cursor_pos + self.current_line().len_chars()), + ); + } + if self.cursor_pos > self.content.len_chars() && self.content.len_chars() > 0 { + self.cursor_pos = self.content.len_chars() - 1; + } else if self.cursor_pos > self.content.len_chars() { + self.cursor_pos = 0; + } + self.deselect(); + } + + pub fn word_scan_forward(&mut self) { + while self.cursor_pos < self.content.len_chars() { + self.cursor_pos += 1; + if !self + .current_char() + .map(|c| c.is_alphanumeric()) + .unwrap_or(false) + { + break; + } + self.deselect(); + } + } + + pub fn word_scan_backward(&mut self) { + if self.cursor_pos > 0 { + self.cursor_pos -= 1; + } + while self.cursor_pos > 0 { + if !self + .content + .get_char(self.cursor_pos - 1) + .map(|c| c.is_alphanumeric()) + .unwrap_or(false) + { + break; + } else { + self.cursor_pos -= 1; + } + self.deselect(); + } + } + + pub fn delete_char_forward(&mut self) { + if !self.select_range().is_empty() { + let range = self.select_range(); + self.content.remove(self.select_range()); + self.move_cursor(range.start); + self.deselect(); + return; + } + + if self.cursor_pos < self.content.len_chars() { + self.content.remove((self.cursor_pos)..self.cursor_pos + 1); + } + } + + pub fn delete_char_back(&mut self) { + // Delete selection + if !self.select_range().is_empty() { + let range = self.select_range(); + self.content.remove(self.select_range()); + self.move_cursor(range.start); + self.deselect(); + return; + } + + // Cant delete character sif we are at the start of the buffer + if self.cursor_pos > 0 { + self.content.remove((self.cursor_pos - 1)..self.cursor_pos); + self.cursor_left(); + } + } + + pub fn delete_to_eol(&mut self) { + self.content.remove( + self.cursor_pos + ..(self.current_line_start() + self.current_line().to_string().len() - 1), + ) + } + + pub fn cursor_left(&mut self) { + match self.mode { + EditMode::Insert => { + if self.cursor_pos > 0 { + self.move_cursor(self.cursor_pos - 1); + } + } + EditMode::Normal => { + if self.cursor_pos > self.current_line_start() { + self.move_cursor(self.cursor_pos - 1); + } + } + } + } + + pub fn cursor_up(&mut self) { + let line_idx = self.content.char_to_line(self.cursor_pos); + + if line_idx > 0 { + let up_line_start = self.content.line_to_char(line_idx - 1); + let up_line = self.content.line(line_idx - 1); + self.move_cursor(up_line_start + self.current_column().min(up_line.len_chars() - 1)); + } + } + + pub fn select_up(&mut self) { + let line_idx = self.content.char_to_line(self.cursor_pos); + + if line_idx > 0 { + let start_of_current_line = self.content.line_to_char(line_idx); + let line_pos = self.cursor_pos - start_of_current_line; + let up_line_start = self.content.line_to_char(line_idx - 1); + let up_line = self.content.line(line_idx - 1); + self.cursor_pos = up_line_start + line_pos.min(up_line.len_chars() - 1); + } + } + + pub fn cursor_down(&mut self) { + let line_idx = self.content.char_to_line(self.cursor_pos); + if line_idx < self.content.len_lines() - 1 { + let start_of_next_line = self.content.line_to_char(self.current_line_index() + 1); + let next_line_len = self.content.line(line_idx + 1).len_chars().max(1) - 1; + + let new_pos = start_of_next_line + self.current_column().min(next_line_len); + + self.move_cursor(new_pos); + } + } + + pub fn select_down(&mut self) { + //12 -> 11 + let line_idx = self.content.char_to_line(self.cursor_pos); + if line_idx < self.content.len_lines() - 1 { + let start_of_current_line = self.content.line_to_char(line_idx); + let line_pos = self.cursor_pos - start_of_current_line; + let start_of_next_line = self.content.line_to_char(line_idx + 1) + 1; + let next_line_len = self.content.line(line_idx + 1).len_chars(); + + self.cursor_pos = + (start_of_next_line + line_pos.min(next_line_len)).min(start_of_next_line); + } + } + + pub fn cursor_right(&mut self) { + if self.cursor_pos < self.content.len_chars() { + self.move_cursor(self.cursor_pos + 1); + } + } + + pub fn select_to_end_of_line(&mut self) { + self.selection_pos = Some(self.cursor_pos); + self.cursor_to_end_of_line(); + } + + pub fn cursor_to_end_of_line(&mut self) { + let line_idx = self.content.char_to_line(self.cursor_pos); + let start_of_line = self.content.line_to_char(line_idx); + let line = self.content.line(line_idx); + if line_idx == self.content.len_lines() - 1 { + self.move_cursor(start_of_line + line.len_chars()); + } else { + self.move_cursor(start_of_line + line.len_chars() - 1); + } + } + + pub fn select_to_start_of_line(&mut self) { + self.selection_pos = Some(self.cursor_pos); + self.cursor_to_start_of_line(); + } + + pub fn cursor_to_start_of_line(&mut self) { + let start_of_line = self + .content + .line_to_char(self.content.char_to_line(self.cursor_pos)); + + self.move_cursor(start_of_line); + } + + pub fn normal_mode(&mut self) { + self.mode = EditMode::Normal; + if self.cursor_pos == self.content.len_chars() && self.content.len_chars() != 0 { + self.cursor_pos -= 1; + } + self.deselect(); + } + + pub fn select_all(&mut self) { + self.selection_pos = Some(0); + self.cursor_pos = self.content.len_chars(); + } + + pub fn select_left(&mut self) { + if self.cursor_pos > 0 { + self.cursor_pos -= 1 + } + } + + pub fn select_right(&mut self) { + if self.cursor_pos < self.content.len_chars() { + self.cursor_pos += 1; + } + } + + pub fn current_line(&self) -> ropey::RopeSlice { + self.content + .get_slice( + self.current_line_start() + ..self + .content + .line_to_char(self.current_line_index() + 1) + .min(self.content.len_chars()), + ) + .unwrap() + } + + pub fn current_line_index(&self) -> usize { + self.content.char_to_line(self.cursor_pos) + } + + pub fn current_line_start(&self) -> usize { + self.content + .line_to_char(self.content.char_to_line(self.cursor_pos)) + } + + pub fn current_column(&self) -> usize { + self.cursor_pos - self.current_line_start() + } + + pub fn current_char(&self) -> Option { + self.content.get_char(self.cursor_pos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_left_normal_empty_line() { + let mut data = EditorData { + mode: EditMode::Normal, + ..Default::default() + }; + data.push_str("0123456789\n\n1234"); + data.cursor_pos = 12; + data.cursor_left(); + assert_eq!(data.cursor_pos, 11); + data.cursor_left(); + assert_eq!(data.cursor_pos, 10); + } + + #[test] + fn cursor_left_normal_double_empty_line() { + let mut data = EditorData { + mode: EditMode::Normal, + ..Default::default() + }; + data.push_str("0123456789\n\n\n1234"); + data.cursor_pos = 14; + data.cursor_left(); + assert_eq!(data.cursor_pos, 12); + data.cursor_left(); + assert_eq!(data.cursor_pos, 11); + data.cursor_left(); + assert_eq!(data.cursor_pos, 10); + } + + #[test] + fn cursor_left_normal_end_of_line() { + let mut data = EditorData { + mode: EditMode::Normal, + ..Default::default() + }; + data.push_str("0123456789\n1234"); + data.cursor_pos = 12; + assert_eq!(data.current_char().unwrap(), '1'); + data.cursor_left(); + assert_eq!(data.cursor_pos, 10); + } +} diff --git a/abacus-ui/src/data/mod.rs b/abacus-ui/src/data/mod.rs new file mode 100644 index 0000000..46e1012 --- /dev/null +++ b/abacus-ui/src/data/mod.rs @@ -0,0 +1,7 @@ +mod app_data; +mod editor_data; +mod modals; + +pub use app_data::{AppData, Block}; +pub use editor_data::{EditMode, EditorData}; +pub use modals::*; diff --git a/abacus-ui/src/data/modals.rs b/abacus-ui/src/data/modals.rs new file mode 100644 index 0000000..3c76655 --- /dev/null +++ b/abacus-ui/src/data/modals.rs @@ -0,0 +1,13 @@ +use druid::{Data, Lens}; + +#[derive(Clone, Data, Lens, Default, Debug)] +pub struct RenameBlock { + pub name: String, + pub input: String, + pub block_index: usize, +} + +#[derive(Clone, Data, Lens, Default, Debug)] +pub struct Modals { + pub rename_block: RenameBlock, +} diff --git a/abacus-ui/src/editor.rs b/abacus-ui/src/editor.rs index b96dc32..f7b28c7 100644 --- a/abacus-ui/src/editor.rs +++ b/abacus-ui/src/editor.rs @@ -1,84 +1,258 @@ +use abacus_core::Output; +use clipboard::ClipboardProvider; use druid::{ - piet::{Text, TextLayout, TextLayoutBuilder}, - Color, Data, Event, FontFamily, Lens, LifeCycle, RenderContext, Widget, + piet::{CairoTextLayout, Text, TextAttribute, TextLayout, TextLayoutBuilder}, + widget::{Container, Flex, Label, Padding, Svg, SvgData}, + Color, Event, FontDescriptor, FontFamily, FontWeight, LifeCycle, PaintCtx, Rect, RenderContext, + Target, Widget, WidgetExt, }; -#[derive(Clone, Data, PartialEq, Eq)] -pub enum EditMode { - Normal, - Insert, -} +use syntect::easy::HighlightLines; +use syntect::highlighting::{Style, ThemeSet}; +use syntect::parsing::SyntaxSet; -pub struct AbacusEditor; +use crate::{ + app_header::ToolbarButtonController, + data::{EditMode, EditorData}, + Block, +}; -#[derive(Data, Lens, Clone)] -pub struct EditorData { - pub raw_content: String, - pub cursor_pos: usize, - pub mode: EditMode, -} +const FONT_SIZE: f64 = 16.0; -impl Default for EditorData { - fn default() -> Self { - Self { - raw_content: "let x = 1;\nx+1".to_string(), - cursor_pos: 5, - mode: EditMode::Normal, +mod keymap { + pub const LINE_ABOVE: &str = "O"; + pub const LINE_BELOW: &str = "o"; + pub const DELETE_LINE: &str = "dd"; + pub const EDIT_EOL: &str = "A"; + pub const EDIT: &str = "i"; + pub const EDIT_BOL: &str = "I"; + pub const CURSOR_LEFT: &str = "h"; + pub const CURSOR_RIGHT: &str = "l"; + pub const CURSOR_UP: &str = "k"; + pub const CURSOR_DOWN: &str = "j"; + pub const DELETE_CHAR: &str = "x"; + pub const DELETE_TO_EOL: &str = "D"; + pub const WORD_FORWARD: &str = "w"; + pub const WORD_BACK: &str = "b"; + pub const SELECT_MODE: &str = "v"; + + #[derive(Debug, Default)] + pub struct KeyList { + input_value: String, + commands: Vec<&'static str>, + } + + impl KeyList { + pub fn new() -> Self { + Self { + input_value: String::new(), + commands: vec![ + LINE_ABOVE, + LINE_BELOW, + DELETE_LINE, + EDIT_EOL, + EDIT, + EDIT_BOL, + CURSOR_LEFT, + CURSOR_RIGHT, + CURSOR_UP, + CURSOR_DOWN, + DELETE_CHAR, + DELETE_TO_EOL, + WORD_FORWARD, + WORD_BACK, + SELECT_MODE, + ], + } + } + + pub fn push(&mut self, s: &str) { + self.input_value.push_str(s); + if !self.valid() { + self.clear(); + } + } + + fn valid(&self) -> bool { + self.commands + .iter() + .any(|i| i.starts_with(&self.input_value)) + } + + pub fn clear(&mut self) { + self.input_value.clear(); + } + + pub fn get(&mut self) -> Option<&str> { + let v = self + .commands + .iter() + .find(|i| **i == self.input_value) + .cloned(); + if v.is_some() { + self.clear(); + } + v } } } -impl EditorData { - pub fn push_str(&mut self, s: &str) { - self.raw_content.push_str(s); - self.cursor_to_end(); +pub struct AbacusEditor { + syntax_set: SyntaxSet, + theme_set: ThemeSet, + key_list: keymap::KeyList, +} + +impl Default for AbacusEditor { + fn default() -> Self { + Self { + syntax_set: SyntaxSet::load_defaults_newlines(), + theme_set: ThemeSet::load_defaults(), + key_list: keymap::KeyList::new(), + } + } +} + +impl AbacusEditor { + fn paint_cursor(&self, ctx: &mut PaintCtx, data: &EditorData, layout: &CairoTextLayout) { + if data.mode == EditMode::Insert { + if data.cursor_pos == 0 { + let rects = layout.rects_for_range(0..1); + let rect = rects.first().unwrap(); + let rect = Rect::new( + rect.min_x() - 1.0, + rect.min_y(), + rect.min_x() + 1.0, + rect.max_y(), + ); + ctx.fill( + rect, + &Color::rgba8(255, 255, 255, data.cursor_opactiy.floor() as u8), + ); + } else if let Some(char_rect) = layout + .rects_for_range((data.cursor_pos.max(1) - 1)..data.cursor_pos) + .last() + { + let cursor_rect = Rect::new( + char_rect.max_x() - 1.0, + char_rect.min_y(), + char_rect.max_x() + 1.0, + char_rect.max_y(), + ); + + ctx.fill( + cursor_rect, + &Color::rgba8(255, 255, 255, data.cursor_opactiy.floor() as u8), + ); + } + } + + if data.mode == EditMode::Normal { + let char_rect = if data.content.len_chars() == 0 { + let rects = layout.rects_for_range(0..1); + rects.first().cloned() + } else if data.current_char() == Some('\n') + || data.cursor_pos == data.content.len_chars() + { + let range = (data.cursor_pos.max(1) - 1)..data.cursor_pos; + layout.rects_for_range(range).last().cloned().map(|rect| { + Rect::new(rect.max_x(), rect.min_y(), rect.max_x() + 10., rect.max_y()) + }) + } else { + let range = data.cursor_pos..(data.cursor_pos + 1); + layout.rects_for_range(range).last().cloned() + }; + + if let Some(char_rect) = char_rect { + if char_rect.width() == 0. { + let rect = Rect::new( + char_rect.min_x(), + char_rect.min_y(), + char_rect.min_x() + 10., + char_rect.max_y(), + ); + ctx.fill( + rect, + &Color::rgba8(255, 255, 255, data.cursor_opactiy.floor() as u8), + ); + } else { + ctx.fill( + char_rect, + &Color::rgba8(255, 255, 255, data.cursor_opactiy.floor() as u8), + ); + }; + } + } } - pub fn push(&mut self, c: char) { - self.raw_content.push(c); - self.cursor_to_end(); - } + pub fn build_highlighted_layout( + &self, + ctx: &mut PaintCtx, + data: &EditorData, + ) -> CairoTextLayout { + let syntax = self.syntax_set.find_syntax_by_extension("rs").unwrap(); + let mut h = HighlightLines::new(syntax, &self.theme_set.themes["base16-mocha.dark"]); - pub fn cursor_to_end(&mut self) { - self.cursor_pos = self.raw_content.len(); - } + let mut layout = ctx + .text() + .new_text_layout(if data.content.len_chars() == 0 { + String::from(" ") + } else { + data.content.to_string() + }) + .font(FontFamily::MONOSPACE, FONT_SIZE); - pub fn delete_char_back(&mut self) { - self.raw_content.pop(); - self.cursor_to_end(); + let mut pos = 0; + for line in data.content.lines() { + let s = line.to_string(); + let ranges: Vec<(Style, &str)> = h.highlight_line(&s, &self.syntax_set).unwrap(); + + for (style, txt) in ranges { + layout = layout.range_attribute( + pos..(pos + txt.len()), + TextAttribute::TextColor(Color::rgba8( + style.foreground.r, + style.foreground.g, + style.foreground.b, + style.foreground.a, + )), + ); + pos += txt.len(); + } + } + + layout.build().unwrap() } } impl Widget for AbacusEditor { - fn paint(&mut self, ctx: &mut druid::PaintCtx, data: &EditorData, env: &druid::Env) { - let size = ctx.size(); - let rect = size.to_rect(); + fn paint(&mut self, ctx: &mut druid::PaintCtx, data: &EditorData, _env: &druid::Env) { + let layout = self.build_highlighted_layout(ctx, data); - if ctx.is_focused() { - ctx.fill(rect, &Color::rgb8(10, 10, 10)); - } else { - ctx.fill(rect, &Color::rgb8(20, 20, 20)); + if data.selection_pos.is_some() { + let rects = layout.rects_for_range(data.select_range()); + for rect in rects.iter() { + if rect.width() == 0.0 { + ctx.fill( + Rect::new( + rect.min_x(), + rect.min_y(), + rect.max_x() + 10.0, + rect.max_y(), + ), + &Color::rgb8(90, 90, 90), + ); + } else { + ctx.fill(rect, &Color::rgb8(90, 90, 90)); + } + } } - let layout = ctx - .text() - .new_text_layout(data.raw_content.clone()) - .font(FontFamily::MONOSPACE, 24.0) - .text_color(Color::rgb8(255, 255, 255)) - .build() - .unwrap(); + if ctx.has_focus() { + self.paint_cursor(ctx, data, &layout); + } - dbg!(layout.rects_for_range((data.cursor_pos - 1)..data.cursor_pos)); - ctx.fill( - layout - .rects_for_range((data.cursor_pos - 1)..data.cursor_pos) - .last() - .unwrap(), - &Color::rgb8(50, 50, 50), - ); - ctx.draw_text(&layout, (1.0, 1.0)); - - dbg!(ctx.is_focused()); + ctx.draw_text(&layout, (0.0, 0.0)); } fn event( @@ -86,33 +260,226 @@ impl Widget for AbacusEditor { ctx: &mut druid::EventCtx, event: &druid::Event, data: &mut EditorData, - env: &druid::Env, + _env: &druid::Env, ) { match event { - Event::KeyUp(e) => match &e.key { - druid::keyboard_types::Key::Character(c) => { - data.push_str(c); - ctx.request_paint(); + Event::KeyDown(e) => { + match &e.key { + druid::keyboard_types::Key::Character(ch) if e.mods.ctrl() => { + match ch.as_ref() { + "a" => { + if e.mods.ctrl() { + data.select_all(); + } + } + "c" => { + if data.selection_pos.is_some() { + let mut cb: clipboard::ClipboardContext = + clipboard::ClipboardProvider::new().unwrap(); + if let Some(slice) = data.content.get_slice(data.select_range()) + { + let _ = cb.set_contents(slice.to_string()); + } + } + } + "v" => { + let mut cb: clipboard::ClipboardContext = + clipboard::ClipboardProvider::new().unwrap(); + data.push_str(&cb.get_contents().unwrap_or_default()); + ctx.request_paint(); + ctx.request_layout(); + } + _ => {} + } + } + druid::keyboard_types::Key::Character(c) if data.mode == EditMode::Insert => { + data.push_str(c); + ctx.request_paint(); + } + druid::keyboard_types::Key::Character(ch) if data.mode == EditMode::Normal => { + self.key_list.push(ch); + + match self.key_list.get() { + Some(keymap::DELETE_LINE) => { + data.delete_current_line(); + data.deselect(); + } + Some(keymap::DELETE_CHAR) => { + data.delete_char_forward(); + data.deselect(); + } + Some(keymap::EDIT) => { + data.mode = EditMode::Insert; + data.deselect(); + } + Some(keymap::LINE_ABOVE) => { + data.mode = EditMode::Insert; + data.cursor_to_start_of_line(); + data.content.insert(data.cursor_pos, "\n"); + data.deselect(); + } + Some(keymap::LINE_BELOW) => { + data.mode = EditMode::Insert; + data.cursor_to_end_of_line(); + data.push_str("\n"); + data.deselect(); + } + Some(keymap::EDIT_EOL) => { + data.mode = EditMode::Insert; + data.cursor_to_end_of_line(); + data.deselect(); + } + Some(keymap::EDIT_BOL) => { + data.cursor_to_start_of_line(); + data.mode = EditMode::Insert; + data.deselect(); + } + Some(keymap::CURSOR_LEFT) => { + data.cursor_left(); + } + Some(keymap::CURSOR_DOWN) => { + data.cursor_down(); + } + Some(keymap::CURSOR_UP) => { + data.cursor_up(); + } + Some(keymap::CURSOR_RIGHT) => { + data.cursor_right(); + } + Some(keymap::DELETE_TO_EOL) => { + data.delete_to_eol(); + data.deselect(); + } + Some(keymap::WORD_FORWARD) => { + data.word_scan_forward(); + } + Some(keymap::WORD_BACK) => { + data.word_scan_backward(); + } + Some(keymap::SELECT_MODE) => { + data.selection_pos = Some(data.cursor_pos); + } + _ => {} + } + } + druid::keyboard_types::Key::Enter if e.mods.ctrl() => { + ctx.submit_command(crate::commands::PROCESS_WORKBOOK.to(Target::Global)); + } + druid::keyboard_types::Key::Enter if data.mode == EditMode::Insert => { + data.push('\n'); + ctx.request_layout(); + data.deselect(); + } + druid::keyboard_types::Key::Enter if data.mode == EditMode::Normal => { + data.cursor_down(); + data.deselect(); + } + druid::keyboard_types::Key::Backspace => { + data.delete_char_back(); + ctx.request_layout(); + data.deselect(); + } + druid::keyboard_types::Key::Delete => { + data.delete_char_forward(); + ctx.request_layout(); + data.deselect(); + } + druid::keyboard_types::Key::Escape => { + data.normal_mode(); + data.deselect(); + } + + druid::keyboard_types::Key::ArrowLeft if e.mods.shift() => { + data.select_left(); + } + druid::keyboard_types::Key::ArrowRight if e.mods.shift() => { + data.select_right(); + } + druid::keyboard_types::Key::ArrowLeft if !e.mods.shift() => { + data.cursor_left(); + } + druid::keyboard_types::Key::ArrowRight if !e.mods.shift() => { + data.cursor_right(); + data.deselect(); + } + druid::keyboard_types::Key::ArrowUp if !e.mods.shift() => { + data.cursor_up(); + data.deselect(); + } + druid::keyboard_types::Key::ArrowDown if !e.mods.shift() => { + data.cursor_down(); + data.deselect(); + } + + druid::keyboard_types::Key::ArrowUp if e.mods.shift() => { + data.select_up(); + data.deselect(); + } + druid::keyboard_types::Key::ArrowDown if e.mods.shift() => { + data.select_down(); + } + druid::keyboard_types::Key::Tab => { + data.push_str(" "); + data.deselect(); + } + druid::keyboard_types::Key::End if e.mods.shift() => { + data.select_to_end_of_line(); + } + druid::keyboard_types::Key::Home if e.mods.shift() => { + data.select_to_start_of_line(); + } + druid::keyboard_types::Key::End => { + data.cursor_to_end_of_line(); + data.deselect(); + } + druid::keyboard_types::Key::Home => { + data.cursor_to_start_of_line(); + data.deselect(); + } + e => { + dbg!(e); + } } - druid::keyboard_types::Key::Enter => { - data.push('\n'); - ctx.request_paint(); - } - druid::keyboard_types::Key::Backspace => { - data.delete_char_back(); - ctx.request_paint(); - } - druid::keyboard_types::Key::Escape => { - data.mode == EditMode::Normal; - } - e => { - dbg!(e); - } - }, - Event::MouseDown(_) => { + ctx.request_paint(); + } + Event::MouseDown(e) => { if !ctx.is_focused() { ctx.request_focus(); } + let layout = ctx + .text() + .new_text_layout(data.content.to_string()) + .font(FontFamily::MONOSPACE, FONT_SIZE) + .build() + .unwrap(); + let pos = layout.hit_test_point(e.pos); + if pos.idx != data.cursor_pos { + data.cursor_pos = pos.idx; + data.deselect(); + ctx.request_paint(); + } + } + Event::MouseMove(e) => { + let layout = ctx + .text() + .new_text_layout(data.content.to_string()) + .font(FontFamily::MONOSPACE, FONT_SIZE) + .text_color(Color::rgb8(255, 255, 255)) + .build() + .unwrap(); + let pos = layout.hit_test_point(e.pos); + if e.buttons.has_left() { + if data.selection_pos.is_none() { + data.selection_pos = Some(data.cursor_pos); + } + let new_pos = (pos.idx + 1).min(data.content.len_chars()); + if new_pos > data.cursor_pos { + data.cursor_pos = pos.idx.min(data.content.len_chars()); + } else { + data.cursor_pos = pos.idx.max(1) - 1; + } + ctx.request_paint(); + } } _ => {} } @@ -122,8 +489,8 @@ impl Widget for AbacusEditor { &mut self, ctx: &mut druid::LifeCycleCtx, event: &druid::LifeCycle, - data: &EditorData, - env: &druid::Env, + _data: &EditorData, + _env: &druid::Env, ) { match event { LifeCycle::FocusChanged(_) => { @@ -144,8 +511,12 @@ impl Widget for AbacusEditor { ctx: &mut druid::UpdateCtx, old_data: &EditorData, data: &EditorData, - env: &druid::Env, + _env: &druid::Env, ) { + if old_data != data { + ctx.request_paint(); + ctx.request_layout(); + } } fn layout( @@ -153,8 +524,105 @@ impl Widget for AbacusEditor { ctx: &mut druid::LayoutCtx, bc: &druid::BoxConstraints, data: &EditorData, - env: &druid::Env, + _env: &druid::Env, ) -> druid::Size { - bc.max() + let layout = ctx + .text() + .new_text_layout(data.content.to_string()) + .font(FontFamily::MONOSPACE, FONT_SIZE) + .build() + .unwrap(); + (bc.max().width, layout.size().height).into() } } + +pub fn editor_header() -> impl Widget { + let ban_svg = include_str!("../assets/ban.svg") + .parse::() + .unwrap_or_default(); + let trash_svg = include_str!("../assets/trash.svg") + .parse::() + .unwrap_or_default(); + let run_svg = include_str!("../assets/play.svg") + .parse::() + .unwrap_or_default(); + + Container::new( + Flex::row() + .must_fill_main_axis(true) + .with_spacer(20.0) + .with_child( + Label::dynamic(|data: &Block, _| data.name.clone()) + .with_font( + FontDescriptor::new(FontFamily::SANS_SERIF) + .with_weight(FontWeight::BOLD) + .with_size(14.0), + ) + .padding(5.0) + .on_click(|ctx, data, _| { + ctx.submit_command(crate::commands::RENAME_BLOCK.with(data.index)); + }), + ) + .with_flex_spacer(1.0) + .with_child( + Label::dynamic(|data: &Block, _| { + match data.editor_data.mode { + EditMode::Insert => "EDIT", + EditMode::Normal => "", + } + .to_string() + }) + .with_font( + FontDescriptor::new(FontFamily::SANS_SERIF) + .with_size(14.0) + .with_weight(FontWeight::BOLD), + ) + .padding(5.0), + ) + .with_spacer(10.0) + .with_child( + Label::dynamic(|data: &Block, _| { + format!( + "{}:{}", + data.editor_data.current_line_index() + 1, + data.editor_data.current_column() + 1, + ) + }) + .with_font(FontDescriptor::new(FontFamily::SANS_SERIF).with_size(14.0)) + .padding(5.0), + ) + .with_spacer(20.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(run_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|ctx, data: &mut Block, _env| { + ctx.submit_command( + crate::commands::PROCESS_BLOCK + .with(data.index) + .to(Target::Global), + ); + }), + ) + .with_spacer(10.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(ban_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|_ctx, data: &mut Block, _env| data.output = Output::None), + ) + .with_spacer(10.0) + .with_child( + Container::new(Padding::new(10.0, Svg::new(trash_svg).fix_width(10.0))) + .controller(ToolbarButtonController::new(Color::rgb8(50, 50, 50))) + .on_click(|ctx, data: &mut Block, _env| { + ctx.submit_command( + crate::commands::DELETE_BLOCK + .with(data.index) + .to(Target::Global), + ); + }), + ) + .with_spacer(20.0), + ) + .background(Color::rgb8(35, 35, 35)) + .fix_height(30.0) +} diff --git a/abacus-ui/src/main.rs b/abacus-ui/src/main.rs index aeac267..0c12354 100644 --- a/abacus-ui/src/main.rs +++ b/abacus-ui/src/main.rs @@ -1,35 +1,47 @@ +mod app_delegate; +mod app_header; +mod commands; +mod data; mod editor; +mod modal_container; +mod output_block; -use druid::widget::{Align, Flex, Label, Padding, RawLabel}; -use druid::{AppLauncher, Color, Data, PlatformError, Widget, WidgetExt, WindowDesc}; -use editor::EditorData; +use data::{AppData, Block}; +use druid::widget::{Container, Flex, List, Padding, Scroll}; +use druid::{AppLauncher, PlatformError, Widget, WidgetExt, WindowDesc}; +use modal_container::ModalContainer; -fn build_ui() -> impl Widget { - Padding::new( - 10.0, - Flex::row() +fn build_ui() -> impl Widget { + ModalContainer::new( + Flex::column() + .with_child(app_header::app_header_ui()) + .with_child(app_header::header_separater()) .with_flex_child( - Flex::column().with_flex_child(editor::AbacusEditor, 1.0), - 1.0, - ) - .with_flex_child( - Flex::column() - .with_flex_child( - Label::new("top right").background(Color::rgb8(0, 0, 255)), - 1.0, - ) - .with_flex_child(Align::centered(Label::new("bottom right")), 1.0), + Scroll::new( + List::new(|| { + Flex::column() + .with_child(editor::editor_header()) + .with_child(Container::new(Padding::new( + 10.0, + editor::AbacusEditor::default().lens(Block::editor_data), + ))) + .with_child(output_block::output_block()) + }) + .lens(AppData::blocks), + ) + .vertical(), 1.0, ), ) } fn main() -> Result<(), PlatformError> { - AppLauncher::with_window(WindowDesc::new(build_ui())).launch(editor::EditorData::default())?; + AppLauncher::with_window( + WindowDesc::new(build_ui()) + .resizable(true) + .window_size((600.0, 800.0)), + ) + .delegate(app_delegate::Delegate) + .launch(AppData::default())?; Ok(()) } - -#[derive(Clone, Data)] -struct CodeEditor { - code: String, -} diff --git a/abacus-ui/src/modal_container.rs b/abacus-ui/src/modal_container.rs new file mode 100644 index 0000000..5a3132f --- /dev/null +++ b/abacus-ui/src/modal_container.rs @@ -0,0 +1,256 @@ +use crate::data; +use druid::widget::{Controller, Padding}; +use druid::{ + widget::{Container, Flex, Label, LensWrap, TextBox}, + Color, FontDescriptor, FontFamily, FontWeight, RenderContext, Widget, WidgetExt, WidgetPod, +}; +use druid::{Data, LifeCycle, TextAlignment}; + +pub struct ModalContainer { + child: WidgetPod>>, + modal: Option>>>, +} + +impl ModalContainer { + pub fn new(child: impl Widget + 'static) -> Self { + Self { + child: WidgetPod::new(child).boxed(), + modal: None, + } + } +} + +impl Widget for ModalContainer { + fn event( + &mut self, + ctx: &mut druid::EventCtx, + event: &druid::Event, + data: &mut data::AppData, + env: &druid::Env, + ) { + if let druid::Event::Notification(n) = event { + if n.is(crate::commands::CLOSE_MODAL) { + self.modal = None; + data.modals.rename_block = data::RenameBlock::default(); + ctx.children_changed(); + return; + } + } + + if let druid::Event::Command(c) = event { + if let Some(idx) = c.get(crate::commands::RENAME_BLOCK) { + data.modals.rename_block = data::RenameBlock { + name: data + .blocks + .get(*idx) + .map(|i| i.name.clone()) + .unwrap_or_default(), + input: data + .blocks + .get(*idx) + .map(|i| i.name.clone()) + .unwrap_or_default(), + block_index: *idx, + }; + self.modal = Some(WidgetPod::new(rename_block()).boxed()); + ctx.children_changed(); + return; + } + } + + if let Some(m) = self.modal.as_mut() { + m.event(ctx, event, data, env); + } else { + self.child.event(ctx, event, data, env); + } + } + + fn lifecycle( + &mut self, + ctx: &mut druid::LifeCycleCtx, + event: &druid::LifeCycle, + data: &data::AppData, + env: &druid::Env, + ) { + if let Some(modal) = self.modal.as_mut() { + modal.lifecycle(ctx, event, data, env); + } + self.child.lifecycle(ctx, event, data, env); + } + + fn update( + &mut self, + ctx: &mut druid::UpdateCtx, + _old_data: &data::AppData, + data: &data::AppData, + env: &druid::Env, + ) { + if let Some(modal) = self.modal.as_mut() { + modal.update(ctx, data, env); + } + self.child.update(ctx, data, env); + } + + fn layout( + &mut self, + ctx: &mut druid::LayoutCtx, + bc: &druid::BoxConstraints, + data: &data::AppData, + env: &druid::Env, + ) -> druid::Size { + if let Some(modal) = self.modal.as_mut() { + modal.layout(ctx, bc, data, env); + } + self.child.layout(ctx, bc, data, env) + } + + fn paint(&mut self, ctx: &mut druid::PaintCtx, data: &data::AppData, env: &druid::Env) { + self.child.paint(ctx, data, env); + let full_rect = ctx.size().to_rect(); + if let Some(m) = self.modal.as_mut() { + ctx.fill(full_rect, &druid::Color::rgba8(0, 0, 0, 100)); + m.paint(ctx, data, env) + } + } +} + +fn rename_block() -> impl Widget { + modal_container( + "Rename Block", + (300.0, 150.0), + Flex::column() + .with_child( + Label::new("Block name") + .with_text_color(druid::Color::WHITE) + .with_text_alignment(TextAlignment::Start) + .with_font( + FontDescriptor::new(FontFamily::SYSTEM_UI) + .with_weight(FontWeight::BOLD) + .with_size(14.0), + ) + .fix_width(300.0), + ) + .with_spacer(5.0) + .with_child(LensWrap::new( + LensWrap::new( + TextBox::default() + .lens(data::RenameBlock::input) + .fix_width(300.0), + data::Modals::rename_block, + ), + data::AppData::modals, + )) + .with_spacer(20.0) + .with_child( + Flex::row() + .must_fill_main_axis(true) + .with_child(modal_action("Cancel").on_click(|ctx, _, _| { + ctx.submit_notification(crate::commands::CLOSE_MODAL) + })) + .with_flex_spacer(1.0) + .with_child(modal_action("Rename").on_click( + |ctx, data: &mut data::AppData, _| { + let input = data.modals.rename_block.input.clone(); + if let Some(blk) = + data.blocks.get_mut(data.modals.rename_block.block_index) + { + blk.name = input; + data.modals.rename_block = data::RenameBlock::default(); + ctx.submit_notification(crate::commands::CLOSE_MODAL) + } + }, + )) + .fix_width(300.0), + ), + ) +} + +fn modal_container>( + title: &str, + size: S, + child: impl Widget + 'static, +) -> impl Widget { + let size = size.into(); + Flex::column() + .with_child(modal_title(title, size.width)) + .with_child(Padding::new(15.0, child)) + .background(Color::rgb8(20, 20, 20)) + .center() + .fix_size(size.width, size.height) +} + +fn modal_title(title: &str, width: f64) -> impl Widget { + Container::new(Padding::new( + 8.0, + Flex::row() + .with_spacer(10.0) + .with_child( + Label::new(title) + .with_text_color(Color::rgb8(150, 150, 150)) + .with_text_alignment(TextAlignment::Start) + .with_font( + FontDescriptor::new(FontFamily::SYSTEM_UI) + .with_weight(FontWeight::BOLD) + .with_size(14.0), + ) + .fix_width(width), + ) + .with_spacer(10.0), + )) + .background(Color::rgb8(10, 10, 10)) +} + +fn modal_action(text: &str) -> impl Widget { + Container::new(Padding::new(5.0, Label::new(text))) + .rounded(4.0) + .controller(ModalActionController) +} + +pub struct ModalActionController; + +impl Controller> for ModalActionController { + fn event( + &mut self, + child: &mut Container, + ctx: &mut druid::EventCtx, + event: &druid::Event, + data: &mut T, + env: &druid::Env, + ) { + ctx.set_cursor(&druid::Cursor::Pointer); + child.event(ctx, event, data, env) + } + + fn lifecycle( + &mut self, + child: &mut Container, + ctx: &mut druid::LifeCycleCtx, + event: &druid::LifeCycle, + data: &T, + env: &druid::Env, + ) { + match event { + LifeCycle::HotChanged(true) => { + child.set_background(Color::rgb8(100, 100, 100)); + ctx.request_paint(); + } + LifeCycle::HotChanged(false) => { + child.set_background(Color::TRANSPARENT); + ctx.request_paint(); + } + _ => {} + } + child.lifecycle(ctx, event, data, env) + } + + fn update( + &mut self, + child: &mut Container, + ctx: &mut druid::UpdateCtx, + old_data: &T, + data: &T, + env: &druid::Env, + ) { + child.update(ctx, old_data, data, env) + } +} diff --git a/abacus-ui/src/output.rs b/abacus-ui/src/output.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/abacus-ui/src/output.rs @@ -0,0 +1 @@ + diff --git a/abacus-ui/src/output_block.rs b/abacus-ui/src/output_block.rs new file mode 100644 index 0000000..04661bf --- /dev/null +++ b/abacus-ui/src/output_block.rs @@ -0,0 +1,130 @@ +use abacus_core::Output; +use druid::{ + widget::{Container, Flex, Label, Padding, Scroll, ViewSwitcher}, + Color, FontDescriptor, FontFamily, FontWeight, Widget, WidgetExt, +}; + +use crate::data::Block; + +const OUTPUT_FONT_SIZE: f64 = 16.0; + +pub fn output_block() -> impl Widget { + ViewSwitcher::new( + |data: &Block, _env| data.clone(), + |selector: &Block, _data, _env| match &selector.output { + Output::Scalar(v) => { + let str = match v { + _ if v.is::() => v.clone().cast::(), + _ if v.is::<&str>() => v.clone().cast::<&str>().to_string(), + v => v.to_string(), + }; + Box::new(Padding::new( + 25.0, + Label::new(str) + .with_font( + FontDescriptor::new(FontFamily::MONOSPACE) + .with_weight(FontWeight::BOLD), + ) + .with_text_size(OUTPUT_FONT_SIZE) + .padding(10.0) + .background(Color::rgb8(30, 30, 30)) + .rounded(4.0) + .expand_width(), + )) + } + Output::Error(e) => Box::new(Padding::new( + 25.0, + Label::new(e.to_string()) + .with_font( + FontDescriptor::new(FontFamily::MONOSPACE).with_weight(FontWeight::BOLD), + ) + .with_text_alignment(druid::TextAlignment::Center) + .with_line_break_mode(druid::widget::LineBreaking::WordWrap) + .with_text_size(OUTPUT_FONT_SIZE) + .padding(10.0) + .background(Color::rgb8(30, 10, 10)) + .expand_width(), + )), + Output::Series(series) => { + let mut flex = Flex::row(); + + for v in series.iter() { + flex.add_child( + Label::new(v.to_string()) + .with_font( + FontDescriptor::new(FontFamily::MONOSPACE) + .with_weight(FontWeight::BOLD), + ) + .with_text_size(OUTPUT_FONT_SIZE) + .padding(3.0) + .border(Color::rgb8(60, 60, 60), 1.0), + ); + } + + Box::new(Padding::new( + 25.0, + flex.padding(10.0) + .background(Color::rgb8(30, 30, 30)) + .rounded(4.0) + .expand_width(), + )) + } + Output::DataFrame(frame) => { + let mut flex = Flex::row(); + for series in frame.iter() { + let mut col = Flex::column() + .cross_axis_alignment(druid::widget::CrossAxisAlignment::Fill); + col.add_child( + Label::new(series.name()) + .with_font( + FontDescriptor::new(FontFamily::MONOSPACE) + .with_weight(FontWeight::BLACK), + ) + .with_text_size(OUTPUT_FONT_SIZE) + .with_text_color(Color::rgb8(150, 150, 150)) + .padding(3.0) + .border(Color::rgb8(50, 50, 50), 1.0) + .background(Color::rgb8(40, 40, 40)), + ); + + for v in series.iter() { + col.add_child( + Label::new(format_dataframe_value(v)) + .with_font( + FontDescriptor::new(FontFamily::MONOSPACE) + .with_weight(FontWeight::MEDIUM), + ) + .with_text_size(OUTPUT_FONT_SIZE) + .padding(3.0) + .border(Color::rgb8(50, 50, 50), 1.0), + ); + } + + flex.add_child(col); + } + + Box::new(Padding::new( + 25.0, + Container::new( + Scroll::new(flex.background(Color::rgb8(30, 30, 30)).expand_width()) + .horizontal() + .expand_width(), + ) + .rounded(4.0) + .border(Color::rgb8(30, 30, 30), 5.0) + .background(Color::rgb8(30, 30, 30)) + .expand_width(), + )) + } + Output::None => Box::new(Container::new(Label::new(""))), + }, + ) +} + +fn format_dataframe_value(value: abacus_core::AnyValue) -> String { + match value { + abacus_core::AnyValue::Utf8(v) => v.to_string(), + abacus_core::AnyValue::Utf8Owned(v) => v, + _ => value.to_string(), + } +} diff --git a/abacus-ui/src/piet_plotters.rs b/abacus-ui/src/piet_plotters.rs new file mode 100644 index 0000000..4061904 --- /dev/null +++ b/abacus-ui/src/piet_plotters.rs @@ -0,0 +1,336 @@ +use piet_common::{kurbo, Color, LineCap, Piet, RenderContext, StrokeDash, StrokeStyle}; +use plotters_backend::{BackendColor, BackendCoord, DrawingBackend, DrawingErrorKind}; + +#[derive(Debug, PartialEq, Eq)] +pub struct Error {} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "plotters-piet error") + } +} + +impl std::error::Error for Error {} + +/// The piet backend. +/// +/// Note that the size of the piet context has to be specified here. +pub struct PietBackend<'a, 'b> { + pub size: (u32, u32), + pub render_ctx: &'a mut Piet<'b>, +} + +impl<'a, 'b> std::fmt::Debug for PietBackend<'a, 'b> { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.debug_struct("PietBackend") + .field("size", &self.size) + .field("render_ctx", &"(not printable)") + .finish() + } +} + +impl<'a, 'b> DrawingBackend for PietBackend<'a, 'b> { + type ErrorType = Error; + + fn get_size(&self) -> (u32, u32) { + self.size + } + + fn ensure_prepared(&mut self) -> Result<(), DrawingErrorKind> { + Ok(()) + } + + fn present(&mut self) -> Result<(), DrawingErrorKind> { + self.render_ctx + .finish() + .map_err(|_| DrawingErrorKind::DrawingError(Error {})) + } + + fn draw_pixel( + &mut self, + point: BackendCoord, + color: BackendColor, + ) -> Result<(), DrawingErrorKind> { + let x = point.0 as f64; + let y = point.1 as f64; + self.render_ctx.fill( + kurbo::Rect::new(x, y, x + 1., y + 1.), + &plotters_color_to_piet(&color), + ); + Ok(()) + } + + fn draw_line( + &mut self, + from: BackendCoord, + to: BackendCoord, + style: &S, + ) -> Result<(), DrawingErrorKind> { + let from = plotters_point_to_kurbo_mid(from); + let to = plotters_point_to_kurbo_mid(to); + + self.render_ctx.stroke_styled( + kurbo::Line::new(from, to), + &plotters_color_to_piet(&style.color()), + style.stroke_width() as f64, + &STROKE_STYLE_SQUARE_CAP, + ); + Ok(()) + } + + fn draw_rect( + &mut self, + upper_left: BackendCoord, + bottom_right: BackendCoord, + style: &S, + fill: bool, + ) -> Result<(), DrawingErrorKind> { + let color = plotters_color_to_piet(&style.color()); + + if fill { + let upper_left = plotters_point_to_kurbo_corner(upper_left); + let mut bottom_right = plotters_point_to_kurbo_corner(bottom_right); + bottom_right.x += 1.; + bottom_right.y += 1.; + let rect = kurbo::Rect::new(upper_left.x, upper_left.y, bottom_right.x, bottom_right.y); + + self.render_ctx.fill(rect, &color); + } else { + let upper_left = plotters_point_to_kurbo_mid(upper_left); + let bottom_right = plotters_point_to_kurbo_mid(bottom_right); + let rect = kurbo::Rect::new(upper_left.x, upper_left.y, bottom_right.x, bottom_right.y); + + self.render_ctx + .stroke(rect, &color, style.stroke_width() as f64); + } + + Ok(()) + } + + fn draw_path>( + &mut self, + path: I, + style: &S, + ) -> Result<(), DrawingErrorKind> { + if style.color().alpha == 0.0 { + return Ok(()); + } + + let path: Vec = plotters_path_to_kurbo(path).collect(); + + self.render_ctx.stroke_styled( + &*path, + &plotters_color_to_piet(&style.color()), + style.stroke_width() as f64, + &STROKE_STYLE_SQUARE_CAP, + ); + Ok(()) + } + + fn draw_circle( + &mut self, + center: BackendCoord, + radius: u32, + style: &S, + fill: bool, + ) -> Result<(), DrawingErrorKind> { + let center = plotters_point_to_kurbo_mid(center); + let color = plotters_color_to_piet(&style.color()); + let circle = kurbo::Circle::new(center, radius as f64); + + if fill { + self.render_ctx.fill(circle, &color); + } else { + self.render_ctx + .stroke(circle, &color, style.stroke_width() as f64); + } + Ok(()) + } + + fn fill_polygon>( + &mut self, + vert: I, + style: &S, + ) -> Result<(), DrawingErrorKind> { + if style.color().alpha == 0.0 { + return Ok(()); + } + + let path: Vec = plotters_path_to_kurbo(vert) + .chain(std::iter::once(kurbo::PathEl::ClosePath)) + .collect(); + self.render_ctx + .fill(&*path, &plotters_color_to_piet(&style.color())); + Ok(()) + } + + // For now we use the default text drawing provided by plotters. This is definitely slower, + // but at least we don't have to worry about matching the font size and offset which turns + // out to be trickier than expected. + // fn draw_text( + // &mut self, + // text: &str, + // style: &TStyle, + // pos: BackendCoord, + // ) -> Result<(), DrawingErrorKind> { + // let pos = plotters_point_to_kurbo(pos); + // let color = plotters_color_to_piet(&style.color()); + + // let text_api = self.render_ctx.text(); + // let font_family = match style.family() { + // plotters_backend::FontFamily::Serif => Ok(FontFamily::SERIF), + // plotters_backend::FontFamily::SansSerif => Ok(FontFamily::SANS_SERIF), + // plotters_backend::FontFamily::Monospace => Ok(FontFamily::MONOSPACE), + // plotters_backend::FontFamily::Name(name) => text_api + // .font_family(name) + // .ok_or(piet_common::Error::MissingFont), + // }; + + // let (font_style, weight) = match style.style() { + // plotters_backend::FontStyle::Normal => (FontStyle::Regular, FontWeight::REGULAR), + // plotters_backend::FontStyle::Oblique => (FontStyle::Italic, FontWeight::REGULAR), + // plotters_backend::FontStyle::Italic => (FontStyle::Italic, FontWeight::REGULAR), + // plotters_backend::FontStyle::Bold => (FontStyle::Regular, FontWeight::BOLD), + // }; + + // let alignment = match style.anchor().h_pos { + // plotters_backend::text_anchor::HPos::Left => TextAlignment::Start, + // plotters_backend::text_anchor::HPos::Right => TextAlignment::End, + // plotters_backend::text_anchor::HPos::Center => TextAlignment::Center, + // }; + + // let layout = text_api + // .new_text_layout(String::from(text)) + // .font(font_family.unwrap(), style.size()) + // .text_color(color) + // .alignment(alignment) + // .default_attribute(TextAttribute::Style(font_style)) + // .default_attribute(TextAttribute::Weight(weight)) + // .build() + // .unwrap(); + + // // todo: style.anchor().v_pos + // // todo: style.transform() + + // self.render_ctx.draw_text(&layout, pos); + // Ok(()) + // } +} + +fn plotters_color_to_piet(col: &BackendColor) -> piet_common::Color { + Color::rgba8(col.rgb.0, col.rgb.1, col.rgb.2, (col.alpha * 256.) as u8) +} + +fn plotters_point_to_kurbo_mid((x, y): BackendCoord) -> kurbo::Point { + kurbo::Point { + x: x as f64 + 0.5, + y: y as f64 + 0.5, + } +} + +fn plotters_point_to_kurbo_corner((x, y): BackendCoord) -> kurbo::Point { + kurbo::Point { + x: x as f64, + y: y as f64, + } +} + +/// This is basically just an iterator map that applies a different function on +/// the first item as on the later items. +/// We need this because the piet direct2d backend doesn't like it if a path +/// consists entirely of `LineTo` entries, it requires the first entry to be +/// a `MoveTo` entry. +struct PlottersPathToKurbo { + iter: I, + first: bool, +} + +impl PlottersPathToKurbo { + fn new(path: I) -> PlottersPathToKurbo { + PlottersPathToKurbo { + iter: path, + first: true, + } + } +} + +impl Iterator for PlottersPathToKurbo +where + I: Iterator, +{ + type Item = kurbo::PathEl; + + fn next(&mut self) -> Option { + self.iter.next().map(|point| { + let point = plotters_point_to_kurbo_mid(point); + + if self.first { + self.first = false; + kurbo::PathEl::MoveTo(point) + } else { + kurbo::PathEl::LineTo(point) + } + }) + } +} + +fn plotters_path_to_kurbo( + path: impl IntoIterator, +) -> impl Iterator { + PlottersPathToKurbo::new(path.into_iter()) +} + +const STROKE_STYLE_SQUARE_CAP: StrokeStyle = StrokeStyle::new().line_cap(LineCap::Square); + +#[cfg(test)] +mod tests { + use super::*; + use piet_common::RenderContext; + use plotters::prelude::*; + + #[test] + fn fill_root_white() { + let width = 3; + let height = 2; + + let mut device = piet_common::Device::new().unwrap(); + let mut bitmap = device.bitmap_target(width, height, 1.0).unwrap(); + + { + let mut render_ctx = bitmap.render_context(); + + let piet_backend = PietBackend { + size: (width as u32, height as u32), + render_ctx: &mut render_ctx, + }; + + let root = piet_backend.into_drawing_area(); + root.fill(&WHITE).unwrap(); + + render_ctx.finish().unwrap(); + } + + let mut buf = [0; 6 * 4]; + bitmap + .copy_raw_pixels(piet_common::ImageFormat::RgbaPremul, &mut buf) + .unwrap(); + + assert_eq!(buf, [255; 6 * 4]); + } + + #[test] + fn test_plotters_path_to_kurbo() { + let path = vec![(1, 2), (3, 4), (5, 6)]; + + let kurbo_path: Vec = plotters_path_to_kurbo(path).collect(); + + assert_eq!( + kurbo_path, + vec![ + kurbo::PathEl::MoveTo(kurbo::Point { x: 1.5, y: 2.5 }), + kurbo::PathEl::LineTo(kurbo::Point { x: 3.5, y: 4.5 }), + kurbo::PathEl::LineTo(kurbo::Point { x: 5.5, y: 6.5 }), + ] + ); + } +} diff --git a/abacus-ui/src/plot_view.rs b/abacus-ui/src/plot_view.rs new file mode 100644 index 0000000..62253a2 --- /dev/null +++ b/abacus-ui/src/plot_view.rs @@ -0,0 +1,60 @@ +use crate::piet_plotters::PietBackend; +use druid::{Data, Widget}; +use plotters::{ + coord::Shift, + prelude::{DrawingArea, IntoDrawingArea}, +}; + +pub struct Plot { + #[allow(clippy::type_complexity)] + plot: Box)>, +} + +impl Plot { + pub fn new(f: impl Fn((u32, u32), &T, &DrawingArea) + 'static) -> Plot { + Plot { plot: Box::new(f) } + } +} + +impl Widget for Plot +where + T: Data, +{ + fn event(&mut self, _: &mut druid::EventCtx, _: &druid::Event, _: &mut T, _: &druid::Env) {} + + fn lifecycle( + &mut self, + _: &mut druid::LifeCycleCtx, + _: &druid::LifeCycle, + _: &T, + _: &druid::Env, + ) { + } + + fn update(&mut self, ctx: &mut druid::UpdateCtx, old_data: &T, data: &T, _env: &druid::Env) { + if !old_data.same(data) { + ctx.request_paint(); + } + } + + fn layout( + &mut self, + _: &mut druid::LayoutCtx, + bc: &druid::BoxConstraints, + _: &T, + _: &druid::Env, + ) -> druid::Size { + bc.max() + } + + fn paint(&mut self, ctx: &mut druid::PaintCtx, data: &T, _: &druid::Env) { + let druid::Size { width, height } = ctx.size(); + let size = (width as u32, height as u32); + let backend = PietBackend { + size, + render_ctx: ctx.render_ctx, + }; + + (self.plot)(size, data, &backend.into_drawing_area()); + } +} diff --git a/abacus-ui/src/toolbar_button.rs b/abacus-ui/src/toolbar_button.rs new file mode 100644 index 0000000..faec7da --- /dev/null +++ b/abacus-ui/src/toolbar_button.rs @@ -0,0 +1,68 @@ +use druid::{ + kurbo::Line, + widget::{FillStrat, SvgData}, + Color, Data, Rect, RenderContext, Widget, +}; + +pub struct ToolbarItem { + icon: SvgData, +} + +impl Widget for ToolbarItem { + fn event( + &mut self, + _ctx: &mut druid::EventCtx, + _event: &druid::Event, + _data: &mut T, + _env: &druid::Env, + ) { + } + + fn lifecycle( + &mut self, + ctx: &mut druid::LifeCycleCtx, + event: &druid::LifeCycle, + _data: &T, + _env: &druid::Env, + ) { + if let druid::LifeCycle::HotChanged(true) = event { + ctx.request_paint(); + } + } + + fn update(&mut self, _ctx: &mut druid::UpdateCtx, _old_data: &T, _data: &T, _env: &druid::Env) { + } + + fn layout( + &mut self, + _ctx: &mut druid::LayoutCtx, + bc: &druid::BoxConstraints, + _data: &T, + _env: &druid::Env, + ) -> druid::Size { + druid::Size::new(bc.max().height * 1.6, bc.max().height) + } + + fn paint(&mut self, ctx: &mut druid::PaintCtx, _data: &T, _env: &druid::Env) { + let bg_rect = Rect::ZERO.with_size(ctx.size()); + let icon_rect = Rect::from_center_size(bg_rect.center(), (15.0, 15.0)); + let offset_matrix = FillStrat::default().affine_to_fill(icon_rect.size(), self.icon.size()); + + if ctx.is_hot() { + ctx.fill(bg_rect, &Color::rgba8(50, 50, 50, 255)); + ctx.stroke( + Line::new( + (0.0, bg_rect.height() - 2.0), + (bg_rect.width(), bg_rect.height() - 2.0), + ), + &Color::rgb8(100, 100, 100), + 2.0, + ); + } + + ctx.with_child_ctx(icon_rect, |ctx| { + ctx.transform(druid::Affine::translate((icon_rect.x0, icon_rect.y0))); + self.icon.to_piet(offset_matrix, ctx); + }); + } +} diff --git a/drone.yml b/drone.yml new file mode 100644 index 0000000..0a80e56 --- /dev/null +++ b/drone.yml @@ -0,0 +1,27 @@ + +kind: pipeline +name: default + +steps: +- name: test + image: rust:latest + commands: + - rustup component add clippy + - cargo clippy + - cargo test --all + +- name: deploy + image: rust:latest + commands: + - cargo build --release + - tar cvzf conductor.tar.gz -C target/release conductor + - wget https://dl.min.io/client/mc/release/linux-amd64/mc + - chmod +x mc + - ./mc alias set fivesigma https://objects.5sigma.io $MINIOID $MINIOSECRET + - ./mc cp conductor.tar.gz fivesigma/public/conductor.tar.gz + # when: + # event: + # - promote + # target: + # - staging + # - production diff --git a/frame b/frame new file mode 100644 index 0000000..82b6468 --- /dev/null +++ b/frame @@ -0,0 +1,8 @@ +{ + "blocks": [ + { + "name": "Block #1", + "content": "dataframe(#{ names: [\"Alice\", \"Bob\", \"Charles\"], ages: [18,21,35] })" + } + ] +} \ No newline at end of file