chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
# ┃ This file is part of the Perspective library, distributed under the terms ┃
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
[package]
name = "perspective-viewer"
version = "4.5.2"
authors = ["Andrew Stein <steinlink@gmail.com>"]
edition = "2024"
description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets."
repository = "https://github.com/perspective-dev/perspective"
license = "Apache-2.0"
homepage = "https://perspective-dev.github.io"
keywords = ["experimental"]
build = "build.rs"
include = ["build.rs", "src/**/*", "Cargo.toml", "package.json", "docs/**/*"]
[package.metadata.docs.rs]
rustc-args = ["--cfg", "web_sys_unstable_apis"]
rustdoc-args = ["--html-in-header", "docs/index.html"]
[lib]
crate-type = ["cdylib", "rlib"]
path = "src/rust/lib.rs"
[features]
external-bootstrap = []
metadata = []
default = []
talc-allocator = ["perspective-js/talc-allocator"]
trace-allocator = ["perspective-js/trace-allocator"]
[build-dependencies]
lightningcss = "1.0.0-alpha.71"
glob = "0.3.0"
anyhow = "1.0.66"
[dev-dependencies]
wasm-bindgen-test = "0.3.13"
[dependencies]
perspective-client = { version = "4.5.2" }
perspective-js = { version = "4.5.2" }
# Provides async `Mutex` for locked sections such as `render`
async-lock = "2.5.0"
# Encode HTML export
base64 = "0.22.1"
console_error_panic_hook = "0.1.6"
# Timezone correction
chrono = "0.4"
# More derivable traits
derivative = "2.2.0"
# Easy way to define & implement a singleton trait (type extension)
extend = "1.1.2"
# General async tools
futures = "0.3.31"
# General iterator improvements
itertools = "0.14.0"
# JavaScript stdlib bindings
js-sys = "0.3.85"
macro_rules_attribute = "0.2.2"
# Parse ExprTK for syntax highlighting.
nom = "7.1.1"
# Serialization for tokens and JS APIs
serde = { version = "1.0", features = ["derive"] }
# Support for "unknown"/dictionary types such as `plugin_config`
serde_json = { version = "1.0.107", features = ["raw_value"] }
# Faster struct serialize/deserialize
serde-wasm-bindgen = "0.6.0"
strum = { version = "0.26.1", features = ["derive"] }
thiserror = { version = "1.0.55" }
# specta = { version = "2.0.0-rc.9", features = ["typescript"] }
ts-rs = { version = "11.1.0", features = [
"serde-json-impl",
"no-serde-warnings",
] }
# Async-aware logging that can be disabled at compile-time.
tracing = { version = ">=0.1.36" }
tracing-subscriber = "0.3.15"
# Browser API bindings
wasm-bindgen = { version = "=0.2.108", features = ["enable-interning"] }
# Pass perspective-js arguments to viewer
wasm-bindgen-derive = "0.3.0"
# Browser `Promise` bindings
wasm-bindgen-futures = "0.4.41"
# Web framework
yew = { version = "0.22.0", features = ["csr"] }
# Browser stdlib bindings
web-sys.version = "0.3.85"
# Browser stdlib bindings
web-sys.features = [
"Blob",
"Clipboard",
"CssStyleDeclaration",
"CssStyleSheet",
"CssRuleList",
"CssRule",
"CssStyleRule",
"CustomEvent",
"CustomEventInit",
"DataTransfer",
"DomRect",
"DomStringMap",
"DomTokenList",
"Element",
"Event",
"EventTarget",
"EventListener",
"FontFace",
"FontFaceSet",
"FontFaceSetIterator",
"FontFaceSetIteratorResult",
"HtmlCollection",
"HtmlElement",
"HtmlTextAreaElement",
"HtmlStyleElement",
"HtmlSelectElement",
"InputEvent",
"KeyboardEvent",
"MutationObserver",
"MutationObserverInit",
"MutationRecord",
"Navigator",
"Node",
"NodeList",
"Performance",
"PerformanceMark",
"Range",
"Selection",
"ShadowRoot",
"ShadowRootMode",
"ShadowRootInit",
"StyleSheetList",
"Url",
"Window",
]
+4
View File
@@ -0,0 +1,4 @@
# `perspective-viewer`
This crate provides the `<perspective-viewer>` Web Component, via JavaScript
bindings generated from `wasm_bindgen`.
+192
View File
@@ -0,0 +1,192 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { execSync } from "child_process";
import { build } from "@perspective-dev/esbuild-plugin/build.js";
import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
import * as fs from "node:fs";
import * as path from "node:path";
import {
bundle as bundleCss,
composeVisitors,
bundleAsync as bundleAsyncCss,
} from "lightningcss";
import { compress } from "pro_self_extracting_wasm";
import { get_host, inlineUrlVisitor, resolveNPM } from "./tools.mjs";
const IS_DEBUG =
!!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0;
const INHERIT = {
stdio: "inherit",
stderr: "inherit",
};
export async function build_all() {
if (!process.env.PSP_SKIP_WASM) {
execSync(
`cargo bundle --target=${get_host()} -- perspective_viewer ${IS_DEBUG ? "" : "--release"}`,
INHERIT,
);
await compress(
"dist/wasm/perspective-viewer.wasm",
"dist/wasm/perspective-viewer.wasm",
);
}
// JavaScript
const BUILD = [
// WASM assets inlined into a single monolithic `.js` file. No special
// loades required, this version of Perspective should be the easiest
// to use but also the least performant at load time.
// {
// 'Import via `<script type="module">`': true,
// "Requires WASM bootstrap": false,
// "Load as binary": false,
// "Bundler friendly": true,
// },
{
entryPoints: ["src/ts/perspective-viewer.inline.ts"],
format: "esm",
loader: { ".wasm": "binary" },
outfile: "dist/esm/perspective-viewer.inline.js",
plugins: [
WorkerPlugin({
inline: !process.env.PSP_DEBUG,
// plugins: [GlslMinify(), LightningCssMinify()],
// loader: {
// ".css": "text",
// ".glsl": "text",
// },
}),
],
},
// No WASM assets inlined or linked.
// {
// 'Import via `<script type="module">`': true, // *****
// "Requires WASM bootstrap": true,
// "Load as binary": true,
// "Bundler friendly": true,
// },
{
entryPoints: ["src/ts/perspective-viewer.ts"],
format: "esm",
external: ["*.wasm"],
outdir: "dist/esm",
plugins: [
WorkerPlugin({
inline: true,
// plugins: [GlslMinify(), LightningCssMinify()],
// loader: {
// ".css": "text",
// ".glsl": "text",
// },
}),
],
},
// WASM assets linked to relative path via `fetch()`. This efficiently
// loading build is great for `<script>` tags but will give many
// bundlers trouble.
// {
// 'Import via `<script type="module">`': true,
// "Requires WASM bootstrap": false,
// "Load as binary": true,
// "Bundler friendly": false,
// },
{
entryPoints: ["src/ts/perspective-viewer.cdn.ts"],
format: "esm",
loader: { ".wasm": "file" },
outfile: "dist/cdn/perspective-viewer.js",
plugins: [
WorkerPlugin({
inline: true,
// plugins: [GlslMinify(), LightningCssMinify()],
// loader: {
// ".css": "text",
// ".glsl": "text",
// },
}),
],
},
];
await Promise.all(BUILD.map(build)).catch(() => process.exit(1));
// This is dumb. `splitting` param for `esbuild` outputs a `__require`/
// `__exports`/`__esModule` polyfill and does not tree-shake it; this <1kb
// file blocks downloading of the wasm asset until after alot of JavaScript has
// parsed due to this multi-step download+eval. Luckily `esbuild` is quite fast
// enough to just run another build to inline this one file `chunk.js`.
const POSTBUILD = [
{
entryPoints: ["dist/cdn/perspective-viewer.js"],
format: "esm",
plugins: [NodeModulesExternal()],
external: ["*.wasm", "*.worker.js", "*.main.js"],
outdir: "dist/cdn",
allowOverwrite: true,
},
];
await Promise.all(POSTBUILD.map(build)).catch(() => process.exit(1));
// Typecheck
execSync("tsc --project tsconfig.json", INHERIT);
// Generate themes via lightningcss bundling.
fs.mkdirSync("./dist/css/intl", { recursive: true });
const themes = [
"icons",
"intl",
"pro",
"pro-dark",
"botanical",
"monokai",
"phosphor",
"solarized",
"solarized-dark",
"vaporwave",
"gruvbox",
"gruvbox-dark",
"dracula",
"themes",
];
for (const name of themes) {
const filename = `./src/themes/${name}.css`;
const { code } = await bundleAsyncCss({
filename,
minify: true,
visitor: inlineUrlVisitor(filename),
resolver: resolveNPM(import.meta.url),
});
fs.writeFileSync(`dist/css/${name}.css`, code);
}
const intl_langs = ["de", "es", "fr", "ja", "pt", "zh"];
for (const lang of intl_langs) {
const filename = `./src/themes/intl/${lang}.css`;
const { code } = await bundleAsyncCss({
filename,
minify: true,
visitor: inlineUrlVisitor(filename),
});
fs.writeFileSync(`dist/css/intl/${lang}.css`, code);
}
}
build_all();
+41
View File
@@ -0,0 +1,41 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use glob::glob;
use lightningcss::bundler::{Bundler, FileProvider};
use lightningcss::stylesheet::PrinterOptions;
fn main() -> Result<(), anyhow::Error> {
let out_dir = std::env::var("OUT_DIR").unwrap();
let out_path = std::path::Path::new(&out_dir);
let fs: &'static FileProvider = Box::leak(Box::new(FileProvider::new()));
for entry in glob("./src/css/**/*.css")? {
let entry = entry?;
let relative = entry
.strip_prefix("./src/css/")
.or_else(|_| entry.strip_prefix("src/css/"))?;
let mut bundler = Bundler::new(fs, None, Default::default());
let stylesheet = bundler.bundle(&entry)?;
let output = stylesheet.to_css(PrinterOptions {
minify: true,
..Default::default()
})?;
let out_file = out_path.join("css").join(relative);
std::fs::create_dir_all(out_file.parent().unwrap())?;
std::fs::write(&out_file, output.code)?;
println!("cargo:rerun-if-changed={}", entry.display());
}
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import * as fs from "node:fs";
fs.rmSync("dist", { recursive: true, force: true });
fs.rmSync("build", { recursive: true, force: true });
+15
View File
@@ -0,0 +1,15 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import "zx/globals";
await $`typedoc --tsconfig tsconfig.json --out ../../docs/static/viewer`;
+26
View File
@@ -0,0 +1,26 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/monokai.min.css" />
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/languages/javascript.min.js"></script>
<script type="module">
document.querySelectorAll("pre:not(.rust) code:not(.rust)").forEach((block) => {
hljs.highlightElement(block);
});
</script>
<style>
code.language-python,
code.language-bash,
code.language-xml,
code.language-javascript,
pre {
background-color: #2e2e2e;
}
pre code.hljs {
padding: 0;
}
pre.rust,
pre.language-python,
div.rust,
div.python {
display: none;
}
</style>
+312
View File
@@ -0,0 +1,312 @@
The JavaScript language bindings for`<perspective-viewer>` Custom Element, the
main UI for [Perspective](https://perspective-dev.github.io).
<div class="warning">
The examples in this module are in JavaScript. See <a href="https://docs.rs/crate/perspective/latest"><code>perspective</code></a> docs for the Rust API.
</div>
## `<perspective-viewer>` Custom Element library
`<perspective-viewer>` provides a complete graphical UI for configuring the
`perspective` library and formatting its output to the provided visualization
plugins.
If you are using `esbuild` or another bundler which supports ES6 modules, you
only need to import the `perspective-viewer` libraries somewhere in your
application - these modules export nothing, but rather register the components
for use within your site's regular HTML:
```javascript
import "@perspective-dev/viewer";
import "@perspective-dev/viewer-datagrid";
import "@perspective-dev/viewer-charts";
```
Once imported, the `<perspective-viewer>` Web Component will be available in any
standard HTML on your site. A simple example:
```html
<perspective-viewer id="view1"></perspective-viewer>
```
or
```javascript
const viewer = document.createElement("perspective-viewer");
```
### Theming
Theming is supported in `perspective-viewer` and its accompanying plugins. A
number of themes come bundled with `perspective-viewer`; you can import any of
these themes directly into your app, and the `perspective-viewer`s will be
themed accordingly:
```javascript
// Themes based on Thought Merchants's Prospective design
import "@perspective-dev/viewer/dist/css/pro.css";
import "@perspective-dev/viewer/dist/css/pro-dark.css";
// Other themes
import "@perspective-dev/viewer/dist/css/solarized.css";
import "@perspective-dev/viewer/dist/css/solarized-dark.css";
import "@perspective-dev/viewer/dist/css/monokai.css";
import "@perspective-dev/viewer/dist/css/vaporwave.css";
```
Alternatively, you may use `themes.css`, which bundles all default themes
```javascript
import "@perspective-dev/viewer/dist/css/themes.css";
```
If you choose not to bundle the themes yourself, they are available through
[CDN](https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/). These can
be directly linked in your HTML file:
```html
<link
rel="stylesheet"
crossorigin="anonymous"
href="https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/pro.css"
/>
```
Note the `crossorigin="anonymous"` attribute. When including a theme from a
cross-origin context, this attribute may be required to allow
`<perspective-viewer>` to detect the theme. If this fails, additional themes are
added to the `document` after `<perspective-viewer>` init, or for any other
reason theme auto-detection fails, you may manually inform
`<perspective-viewer>` of the available theme names with the `.resetThemes()`
method.
```javascript
// re-auto-detect themes
viewer.resetThemes();
// Set available themes explicitly (they still must be imported as CSS!)
viewer.resetThemes(["Pro Light", "Pro Dark"]);
```
`<perspective-viewer>` will default to the first loaded theme when initialized.
You may override this via `.restore()`, or provide an initial theme by setting
the `theme` attribute:
```html
<perspective-viewer theme="Pro Light"></perspective-viewer>
```
or
```javascript
const viewer = document.querySelector("perspective-viewer");
await viewer.restore({ theme: "Pro Dark" });
```
### Loading data into `<perspective-viewer>`
Data can be loaded into `<perspective-viewer>` in the form of a `Table()` or a
`Promise<Table>` via the `load()` method.
```javascript
// Create a new worker, then a new table promise on that worker.
const worker = await perspective.worker();
const table = await worker.table(data);
// Bind a viewer element to this table.
await viewer.load(table);
```
### Sharing a `table()` between multiple `perspective-viewer`s
Multiple `perspective-viewer`s can share a `table()` by passing the `table()`
into the `load()` method of each viewer. Each `perspective-viewer` will update
when the underlying `table()` is updated, but `table.delete()` will fail until
all `perspective-viewer` instances referencing it are also deleted:
```javascript
const viewer1 = document.getElementById("viewer1");
const viewer2 = document.getElementById("viewer2");
// Create a new WebWorker
const worker = await perspective.worker();
// Create a table in this worker
const table = await worker.table(data);
// Load the same table in 2 different <perspective-viewer> elements
await viewer1.load(table);
await viewer2.load(table);
// Both `viewer1` and `viewer2` will reflect this update
await table.update([{ x: 5, y: "e", z: true }]);
```
### Server-only via `WebSocketServer()` and Node.js
Loading a virtual (server-only) [`Table`] works just like loading a local/Web
Worker [`Table`] - just pass the virtual [`Table`] to `viewer.load()`:
In the browser:
```javascript
const elem = document.getElementsByTagName("perspective-viewer")[0];
// Bind to the server's worker instead of instantiating a Web Worker.
const websocket = await perspective.websocket(
window.location.origin.replace("http", "ws"),
);
// Bind the viewer to the preloaded data source. `table` and `view` objects
// live on the server.
const server_table = await websocket.open_table("table_one");
await elem.load(server_table);
// Or load data from a table using a view. The browser now also has a copy of
// this view in its own `table`, as well as its updates transferred to the
// browser using Apache Arrow.
const worker = await perspective.worker();
const server_view = await server_table.view();
const client_table = worker.table(server_view);
await elem.load(client_table);
```
`<perspective-viewer>` instances bound in this way are otherwise no different
than `<perspective-viewer>`s which rely on a Web Worker, and can even share a
host application with Web Worker-bound `table()`s. The same `promise`-based API
is used to communicate with the server-instantiated `view()`, only in this case
it is over a websocket.
### Persistent `<perspective-viewer>` configuration via `save()`/`restore()`.
`<perspective-viewer>` is _persistent_, in that its entire state (sans the data
itself) can be serialized or deserialized. This include all column, filter,
pivot, expressions, etc. properties, as well as datagrid style settings, config
panel visibility, and more. This overloaded feature covers a range of use cases:
- Setting a `<perspective-viewer>`'s initial state after a `load()` call.
- Updating a single or subset of properties, without modifying others.
- Resetting some or all properties to their data-relative default.
- Persisting a user's configuration to `localStorage` or a server.
#### Serializing and deserializing the viewer state
To retrieve the entire state as a JSON-ready JavaScript object, use the `save()`
method. `save()` also supports a few other formats such as `"arraybuffer"` and
`"string"` (base64, not JSON), which you may choose for size at the expense of
easy migration/manual-editing.
```javascript
const json_token = await elem.save();
const string_token = await elem.save("string");
```
For any format, the serialized token can be restored to any
`<perspective-viewer>` with a `Table` of identical schema, via the `restore()`
method. Note that while the data for a token returned from `save()` may differ,
generally its schema may not, as many other settings depend on column names and
types.
```javascript
await elem.restore(json_token);
await elem.restore(string_token);
```
As `restore()` dispatches on the token's type, it is important to make sure that
these types match! A common source of error occurs when passing a
JSON-stringified token to `restore()`, which will assume base64-encoded msgpack
when a string token is used.
```javascript
// This will error!
await elem.restore(JSON.stringify(json_token));
```
#### Updating individual properties
Using the JSON format, every facet of a `<perspective-viewer>`'s configuration
can be manipulated from JavaScript using the `restore()` method. The valid
structure of properties is described via the
[`ViewerConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa/rust/perspective-viewer/src/ts/viewer.ts#L16)
and embedded
[`ViewConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa19435a2a57d4687be7e428a4efc759b/packages/perspective/index.d.ts#L140)
type declarations, and [`View`](view.md) chapter of the documentation which has
several interactive examples for each `ViewConfig` property.
```javascript
// Set the plugin (will also update `columns` to plugin-defaults)
await elem.restore({ plugin: "X Bar" });
// Update plugin and columns (only draws once)
await elem.restore({ plugin: "X Bar", columns: ["Sales"] });
// Open the config panel
await elem.restore({ settings: true });
// Create an expression
await elem.restore({
columns: ['"Sales" + 100'],
expressions: { "New Column": '"Sales" + 100' },
});
// ERROR if the column does not exist in the schema or expressions
// await elem.restore({columns: ["\"Sales\" + 100"], expressions: {}});
// Add a filter
await elem.restore({ filter: [["Sales", "<", 100]] });
// Add a sort, don't remove filter
await elem.restore({ sort: [["Prodit", "desc"]] });
// Reset just filter, preserve sort
await elem.restore({ filter: undefined });
// Reset all properties to default e.g. after `load()`
await elem.reset();
```
Another effective way to quickly create a token for a desired configuration is
to simply copy the token returned from `save()` after settings the view manually
in the browser. The JSON format is human-readable and should be quite easy to
tweak once generated, as `save()` will return even the default settings for all
properties. You can call `save()` in your application code, or e.g. through the
Chrome developer console:
```javascript
// Copy to clipboard
copy(await document.querySelector("perspective-viewer").save());
```
### Update events
Whenever a `<perspective-viewer>`s underlying `table()` is changed via the
`load()` or `update()` methods, a `perspective-view-update` DOM event is fired.
Similarly, `view()` updates instigated either through the Attribute API or
through user interaction will fire a `perspective-config-update` event:
```javascript
elem.addEventListener("perspective-config-update", function (event) {
var config = elem.save();
console.log("The view() config has changed to " + JSON.stringify(config));
});
```
### Click events
Whenever a `<perspective-viewer>`'s grid or chart is clicked, a
`perspective-click` DOM event is fired containing a detail object with `config`,
`column_names`, and `row`.
The `config` object contains an array of `filters` that can be applied to a
`<perspective-viewer>` through the use of `restore()` updating it to show the
filtered subset of data.
The `column_names` property contains an array of matching columns, and the `row`
property returns the associated row data.
```javascript
elem.addEventListener("perspective-click", function (event) {
var config = event.detail.config;
elem.restore(config);
});
```
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@perspective-dev/viewer",
"version": "4.5.2",
"description": "The `<perspective-viewer>` Custom Element, frontend for Perspective",
"keywords": [
"perspective",
"data",
"analytics",
"visualization",
"datagrid",
"charts",
"web-component",
"wasm",
"arrow"
],
"homepage": "https://perspective.finos.org",
"repository": {
"type": "git",
"url": "https://github.com/perspective-dev/perspective"
},
"type": "module",
"license": "Apache-2.0",
"sideEffects": true,
"unpkg": "dist/cdn/perspective-viewer.js",
"jsdelivr": "dist/cdn/perspective-viewer.js",
"exports": {
".": {
"types": "./dist/esm/perspective-viewer.d.ts",
"import": "./dist/esm/perspective-viewer.js",
"default": "./dist/esm/perspective-viewer.js"
},
"./inline": {
"types": "./dist/esm/perspective-viewer.d.ts",
"import": "./dist/esm/perspective-viewer.inline.js",
"default": "./dist/esm/perspective-viewer.inline.js"
},
"./themes": "./dist/css/themes.css",
"./themes/*.css": "./dist/css/*.css",
"./themes/intl/*.css": "./dist/css/intl/*.css",
"./dist/*": "./dist/*",
"./src/*": "./src/*",
"./test/*": "./test/*",
"./package.json": "./package.json",
"./tsconfig.json": "./tsconfig.json",
"./tools.mjs": "./tools.mjs"
},
"files": [
"dist/**/*",
"src/**/*",
"tsconfig.json"
],
"types": "dist/esm/perspective-viewer.d.ts",
"scripts": {
"build": "node ./build.mjs",
"clean": "node ./clean.mjs",
"docs": "node ./docs.mjs"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@perspective-dev/client": "workspace:",
"pro_self_extracting_wasm": "catalog:"
},
"devDependencies": {
"@perspective-dev/esbuild-plugin": "workspace:",
"@perspective-dev/test": "workspace:",
"@perspective-dev/metadata": "workspace:",
"lightningcss": "catalog:",
"@playwright/test": "catalog:",
"@playwright/experimental-ct-react": "catalog:",
"@types/react": "catalog:",
"@types/node": "catalog:",
"@types/react-dom": "catalog:",
"prettier": "catalog:",
"typedoc": "catalog:",
"typescript": "catalog:",
"zx": "catalog:"
}
}
@@ -0,0 +1,14 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
@import "column-dropdown.css";
@@ -0,0 +1,14 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
@import "containers/dropdown-menu.css";
@@ -0,0 +1,14 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
@import "filter-dropdown.css";
@@ -0,0 +1,14 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
@import "function-dropdown.css";
@@ -0,0 +1,41 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
@import "dom/checkbox.css";
@import "dom/scrollbar.css";
@import "dom/select.css";
@import "containers/split-panel.css";
@import "containers/scroll-panel.css";
@import "containers/pairs-list.css";
@import "containers/tabs.css";
@import "type-icon.css";
@import "aggregate-selector.css";
@import "empty-column.css";
@import "filter-item.css";
@import "config-selector.css";
@import "column-selector.css";
@import "column-style.css";
@import "column-symbol-attributes.css";
@import "column-settings-panel.css";
@import "expression-editor.css";
@import "plugin-selector.css";
@import "plugin-settings-panel.css";
@import "render-warning.css";
@import "status-bar.css";
@import "form/code-editor.css";
@import "form/debug.css";
@import "viewer.css";
@@ -0,0 +1,75 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
/* Wrapper prevents the width of the selector options from increasing the width of */
/* the container it is embedded in. */
.aggregate-selector-wrapper {
height: 19px;
display: flex;
padding-left: 6px;
width: 68px;
min-width: 68px;
max-width: 68px;
flex: 0 0 68px;
label {
font-size: 10px;
white-space: nowrap;
height: 19px;
display: flex;
align-items: center;
margin-right: 3px;
max-width: 60px;
overflow: hidden;
}
.aggregate-selector {
border-bottom-width: 0px;
margin-top: 0px;
height: 19px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 12px;
border: 1px solid transparent;
select {
font-size: 0.8em;
}
&:hover {
border: 1px solid var(--psp--color);
border-radius: 2px;
transition: none;
}
}
label + .aggregate-selector {
display: none;
}
&:hover {
label + .aggregate-selector {
display: flex;
}
label {
display: none;
}
}
.dropdown-width-container:after {
content: none !important;
}
}
}
@@ -0,0 +1,109 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
box-sizing: border-box;
position: fixed;
z-index: 10000;
outline: none;
font-size: 0.75em;
border: inherit;
/* box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%); */
user-select: none;
background-color: var(--psp--color, #fff) !important;
color: var(--psp--background-color, #333) !important;
/* padding: 6px 8px; */
border: 1px solid var(--psp--color) !important;
max-width: 300px;
overflow: hidden;
max-height: 600px;
overflow: auto;
&:hover {
--fix: ;
}
&::-webkit-scrollbar-thumb {
border-radius: 2px;
border: 2px solid transparent;
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
}
&:hover::-webkit-scrollbar-thumb {
border: 1px solid transparent;
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
}
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
background-color: transparent;
width: 6px;
height: 6px;
}
display: flex;
flex-direction: column;
.selected {
background-color: var(--psp--background-color) !important;
color: var(--psp--color) !important;
}
span {
cursor: pointer;
padding: 4px 5px;
}
.no-results {
padding: 3px 24px 6px 11px;
color: var(--psp-error--color);
font-size: 8px;
}
.no-results:before {
content: var(--psp-label--no-results--content, "Invalid Column");
}
#add-expression {
.icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-flex;
-webkit-mask-size: cover;
mask-size: cover;
margin-right: 5px;
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
mask-image: var(--psp-icon--add-expression--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--add-expression--mask-image);
visibility: hidden;
}
}
}
}
:host(:hover) {
.selected {
background-color: var(--psp--color, #fff) !important;
color: var(--psp--background-color, #333) !important;
}
span:hover,
span.selected:hover {
background-color: var(--psp--background-color) !important;
color: var(--psp--color) !important;
}
}
:host(.no-results) {
overflow: hidden;
background-color: transparent !important;
border: none !important;
}
@@ -0,0 +1,554 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#add-expression-button:before {
content: var(--psp-label--add-expression-button--content, "New Column");
text-transform: uppercase;
font-size: 0.8333333em;
}
.column-selector-column-title {
display: flex;
width: 100%;
flex-direction: row !important;
align-items: center;
}
.column_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
padding-left: 6px;
display: inline-block;
align-items: center;
line-height: normal;
&:only-child {
padding-right: 12px;
}
&.none:only-child {
padding-left: 12px;
}
}
.column-selector--spacer {
width: 67px;
flex: 0 100000000 67px;
}
/* Inactive columns need to account for expression buttons, which won't be */
/* the last child if it has a button */
#sub-columns .column-selector--spacer:last-child {
width: 93px;
flex: 0 100000000 93px;
}
.show-aggregate .column-selector--spacer {
width: 0px;
}
.show-aggregate {
.column_name:only-child {
padding-right: 12px;
}
}
#sub-columns .column_name:only-child {
padding-right: 12px;
}
}
:host {
#add-expression {
border: var(--column-add--border, 1px solid transparent);
min-height: 24px;
flex-direction: row;
background-color: var(--psp-placeholder--background);
border-radius: 2px;
display: flex;
align-items: center;
margin-right: 7px;
cursor: pointer;
margin-bottom: 4px;
/* Button icon */
& > .icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-flex;
-webkit-mask-size: cover;
mask-size: cover;
margin-left: 9px;
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
mask-image: var(--psp-icon--add-expression--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--add-expression--mask-image);
visibility: hidden;
}
}
/* Text label of the button e.g. "New Column" */
& > span {
align-self: center;
margin-left: 7.5px;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
&.modal-target,
&.dragdrop-hover {
color: var(--psp--background-color);
background-color: var(--psp--color);
& > .icon {
background-color: var(--psp--background-color);
}
}
}
.dragdrop-highlight {
.pivot-column-border,
.column-selector-column-border {
border: 1px solid var(--psp--color);
}
}
.column-selector-column-border,
.pivot-column-border {
&.snap-drag-image {
background-color: var(--psp--background-color) !important;
border: 1px solid var(--psp--color) !important;
position: absolute !important;
pointer-events: none;
left: 0;
z-index: -100000;
width: 100%;
height: 24px;
.sort-icon,
label,
.dropdown-width-container {
display: none;
}
.aggregate-selector-wrapper {
min-width: 0px;
max-width: 0px;
}
}
}
.column-selector-column-border {
flex: 1 1 auto;
overflow: hidden;
position: relative;
display: inline-flex;
border: 1px solid transparent;
border-radius: 2px;
}
.column-selector-border.snap-drag-image {
& > *:not(.column_name) {
display: none;
}
.column_name {
padding-left: 6px !important;
}
}
.dragdrop-highlight .column-selector-column .column-selector-border,
.dragdrop-highlight
.column-selector-column
.column-selector-draggable.empty-named,
.dragdrop-highlight
#top_panel
.pivot-column.column-empty
.pivot-column-draggable {
border-color: var(--psp--color);
}
.column-selector-column {
display: flex;
flex-direction: row-reverse;
align-items: center;
/* Expression column toolbar buttons */
span.expression-edit-button,
span.expression-delete-button {
padding-top: 1px;
font-family: var(--psp-button--font-family, inherit);
cursor: pointer;
padding: 2px 4px 0 4px;
margin-right: 4px;
margin-left: auto;
border-radius: 3px;
&:hover,
&.is-editing {
background-color: var(--psp--color);
.icon {
background-color: var(--psp--background-color);
}
}
}
span.expression-edit-button.disabled {
opacity: 0;
pointer-events: none;
}
span.expression-delete-button {
padding-left: 5px;
margin-right: 8px;
margin-left: auto;
&:before {
content: var(--column-close--content, "close");
}
}
span.expression-delete-button + span.expression-edit-button {
margin-left: 0px;
}
span.expression-edit-button .icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(--psp-icon--column-settings--mask-image);
mask-image: var(--psp-icon--column-settings--mask-image);
&:before {
content: var(--psp-icon--column-settings--mask-image);
visibility: hidden;
}
}
.column-selector-draggable {
cursor: move;
display: flex;
align-items: start;
flex-grow: 1;
color: inherit;
box-sizing: border-box;
flex-direction: row;
align-items: stretch;
position: relative;
min-height: 28px;
padding-bottom: 4px;
&.show-aggregate {
.column_name {
padding-left: 5px;
}
}
&.empty-named {
cursor: auto;
padding-bottom: 0px;
margin-bottom: 4px;
min-height: 24px;
}
&:not(.empty-named) .column-selector-column-border > .drag-handle {
margin: 0px 0 0 6px;
flex: 0 0 auto;
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
mask-image: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
&:before {
content: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
visibility: hidden;
}
}
}
}
.is_column_active.inactive {
min-width: 0px;
margin: 0 6px 0 0;
}
.is_column_active {
display: inline-flex;
overflow: hidden;
margin-bottom: 4px;
flex: 0 0 auto;
cursor: pointer;
font-size: var(--psp-column-selector--font-size, 14px);
font-family: var(
--column-selector--font-family,
var(--psp-button--font-family, inherit)
) !important;
&,
&.select-mode,
&.toggle-mode {
margin: 0 6.5px 4px 6.5px;
}
}
#selected-columns {
display: flex;
flex-direction: column;
overflow: hidden;
}
#active-columns,
#sub-columns {
display: flex;
flex: 0 1 auto;
overflow-x: hidden !important;
overflow-y: scroll;
min-height: 20%;
}
#sub-columns .scroll-panel-container:before {
font-size: var(--label--font-size, 0.75em);
padding: var(--column_type--padding, 0px 0px 0px 0px);
position: absolute;
height: 28px;
top: -30px;
display: inline-flex;
align-items: flex-end;
content: var(--psp-label--all-columns--content, "All Columns");
}
#sub-columns .scroll-panel-container {
margin-top: 30px;
margin-bottom: 4px;
}
/* #sub-acolumns .scroll-panel-container #add-expression {
padding-top: 28px;
margin-top: 0;
} */
#sub-columns .column-selector-column.column-selector-column-hidden {
overflow: hidden;
height: 0;
margin-bottom: 0px;
}
#sub-columns {
position: relative;
flex: 0 10000000 auto;
}
#active-columns {
display: flex;
padding-bottom: 8px;
padding-top: 6px;
}
#active-columns,
#column_settings_sidebar,
#sub-columns {
.is_column_active {
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
}
}
#active-columns,
#column_settings_sidebar {
width: 100%;
.is_column_active.select-mode {
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
mask-image: var(--psp-icon--radio-on--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--radio-on--mask-image);
visibility: hidden;
}
&:not(.required):hover {
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
mask-image: var(--psp-icon--radio-hover--mask-image);
}
}
.is_column_active.toggle-mode {
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
mask-image: var(--psp-icon--checkbox-on--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--checkbox-on--mask-image);
visibility: hidden;
}
&:not(.required):hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
.is_column_active {
&.required {
opacity: 0.3;
cursor: initial;
}
&.inactive {
background: none;
}
}
.column-selector-column {
position: relative;
.column-selector-draggable {
width: calc(100% - 27px);
}
.column-selector-column-border {
border-color: var(--psp-inactive--color);
background-color: var(--psp--background-color);
}
&:before {
font-size: var(--label--font-size, 0.75em);
left: 0px;
padding: var(--column_type--padding, 0px 0px 0px 0px);
position: absolute;
margin-top: -43px;
content: var(--default-column-title, attr(data-label));
}
&[data-label] {
margin-top: 22px;
}
}
}
.dragover,
.column-selector-column .column-selector-draggable.empty-named {
background-color: var(--psp-placeholder--background);
border-color: transparent;
border-radius: 2px;
}
.dragover-container .is_column_active {
opacity: 0;
margin: 0 6px 0 0;
min-width: 0px;
width: 0px;
}
.column-selector-column .column-selector-draggable.dragover {
border: 1px solid var(--psp--color);
position: relative;
&:before {
content: " ";
position: absolute;
z-index: -1;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
border-radius: 2px;
background-color: var(--psp--color);
opacity: 0.3;
}
& > * {
opacity: 0;
}
}
.column-selector-column {
.column-selector-column-border {
background-color: var(--psp-placeholder--background);
border: 1px solid transparent;
align-items: center;
}
}
#active-columns .column-selector-column.dragdrop-hover,
#active-columns.dragdrop-highlight .column-selector-column,
.column-selector-column.dragdrop-hover {
.column-selector-column-border {
border-color: var(--psp--color);
}
}
/* Inactive Columns are the columns in the column selector not currently */
/* selected for the `columns` field of the `ViewConfig`. */
#sub-columns {
padding-bottom: 8px;
&:empty {
display: none;
margin: 0px;
}
.is_column_active.select-mode {
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
mask-image: var(--psp-icon--radio-off--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--radio-off--mask-image);
visibility: hidden;
}
&:hover {
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
mask-image: var(--psp-icon--radio-hover--mask-image);
}
}
.is_column_active.toggle-mode {
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
mask-image: var(--psp-icon--checkbox-off--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--checkbox-off--mask-image);
visibility: hidden;
}
&:hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
.column-selector-column {
.column-selector-draggable {
width: calc(100% - 27px);
}
}
}
}
@@ -0,0 +1,301 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#column_settings_sidebar {
overflow: hidden;
text-overflow: ellipsis;
z-index: 4;
#attributes-button-panel {
flex: 0 0 auto;
margin-top: 4px;
}
#attributes-tab {
flex-direction: column;
display: flex;
overflow: hidden;
}
#attributes-tab > .split-panel-child {
min-height: 200px;
flex: 0 1 auto;
}
/* NOTE: These should probably make their way to global form styling eventually. */
.errored {
outline-color: var(--psp-error--color);
}
.item_title {
flex: 0 0 auto;
font-size: var(--label--font-size, 0.75em);
}
input {
&[type="text"],
&[type="search"] {
outline: 1px solid;
outline-color: var(--psp-inactive--color);
background-color: var(--psp--background-color);
border: none;
margin-bottom: 0.5em;
font-family: inherit;
font-size: 1em;
&:disabled {
background-color: var(--psp-inactive--color);
}
}
&[type="search"] {
min-height: 24px;
border-radius: 2px;
}
&::-webkit-search-cancel-button {
cursor: pointer;
}
}
.sidebar_header_contents {
display: flex;
margin: 8px;
align-items: center;
border-radius: 3px;
outline-width: 1px;
outline-color: var(--psp-inactive--color);
padding-left: 4px;
&.editable {
outline-style: dashed;
&:hover {
outline-style: solid;
cursor: text;
}
}
&.editable:focus {
outline-style: solid;
background: var(--psp--background-color);
}
/* &.edited {
outline-style: dashed;
} */
&.invalid {
outline-color: var(--psp-error--color);
}
.sidebar_header_title {
line-height: normal;
margin: 0;
flex: 1;
padding-left: 5px;
background: none;
outline: none;
color: unset;
&:disabled {
background: none;
outline: none;
color: unset;
}
}
}
}
label#color-label:before {
content: var(--psp-label--color--content, "Color");
}
label#format-label:before {
content: var(--psp-label--format--content, "Format");
}
label#timezone-label:before {
content: var(--psp-label--timezone--content, "Timezone");
}
label#date-style-label:before {
content: var(--psp-label--date-style--content, "Date Style");
}
label#time-style-label:before {
content: var(--psp-label--time-style--content, "Time Style");
}
label#number_fg_mode-label:before {
content: var(--psp-label--number-fg-mode--content, "Foreground");
}
label#pos_fg_color-label:before {
content: var(--psp-label--number-fg-mode--content, "Foreground");
}
label#number_bg_mode-label:before {
content: var(--psp-label--number-fg-mode--content, "Background");
}
label#pos_bg_color-label:before {
content: var(--psp-label--pos-bg-color--content, "Background");
}
label#datetime_color_mode-label:before {
content: var(--psp-label--datetime-color-mode--content, "Color");
}
label#string_color_mode-label:before {
content: var(--psp-label--string-color-mode--content, "Color");
}
label#chart-type-label:before,
label#chart_type-label:before {
content: var(--psp-label--chart-type--content, "Chart Type");
}
label#stack-label:before {
content: var(--psp-label--stack--content, "Stack");
}
label#interpolate-label:before {
content: var(--psp-label--interpolate--content, "Interpolate null");
}
label#series-label:before {
content: var(--psp-label--series--content, "Series");
}
label#color-range-label:before {
content: var(--psp-label--color-range--content, "Color Range");
}
label#style-label:before {
content: var(--psp-label--style--content, "Style");
}
label#minimum-integer-digits-label:before {
content: var(
--psp-label--minimum-integer-digits--content,
"Minimum Integer Digits"
);
}
label#rounding-increment-label:before {
content: var(
--psp-label--rounding-increment--content,
"Rounding Increment"
);
}
label#notation-label:before {
content: var(--psp-label--notation--content, "Notation");
}
label#use-grouping-label:before {
content: var(--psp-label--use-grouping--content, "Use Grouping");
}
label#sign-display-label:before {
content: var(--psp-label--sign-display--content, "Sign Display");
}
label#aggregate-depth-label:before {
content: var(--psp-label--aggregate-depth--content, "Aggregate Depth");
}
label#fg_gradient-label:before {
content: var(--psp-label--fg-gradient--content, "Max Value");
}
label#bg_gradient-label:before {
content: var(--psp-label--bg-gradient--content, "Max Value");
}
label#rounding-priority-label:before {
content: var(
--psp-label--rounding-priority--content,
"Rounding Priority"
);
}
label#rounding-mode-label:before {
content: var(--psp-label--rounding-mode--content, "Rounding Mode");
}
label#trailing-zero-display-label:before {
content: var(
--psp-label--trailing-zero-display--content,
"Trailing Zero Display"
);
}
label#significant-digits-label:before {
content: var(
--psp-label--significant-digits--content,
"Significant Digits"
);
}
label#fractional-digits-label:before {
content: var(
--psp-label--fractional-digits--content,
"Fractional Digits"
);
}
label#year-label:before {
content: var(--psp-label--year--content, "Year");
}
label#month-label:before {
content: var(--psp-label--month--content, "Month");
}
label#day-label:before {
content: var(--psp-label--day--content, "Day");
}
label#weekday-label:before {
content: var(--psp-label--weekday--content, "Weekday");
}
label#hour-label:before {
content: var(--psp-label--hour--content, "Hour");
}
label#minute-label:before {
content: var(--psp-label--minute--content, "Minute");
}
label#second-label:before {
content: var(--psp-label--second--content, "Second");
}
label#fractional-seconds-label:before {
content: var(
--psp-label--fractional-seconds--content,
"Fractional Seconds"
);
}
label#hours-label:before {
content: var(--psp-label--hours--content, "12/24 Hours");
}
div.tab-title#Style:before {
content: var(--psp-label--style-tab--content, "Style");
}
div.tab-title#Attributes:before {
content: var(--psp-label--attributes-tab--content, "Attributes");
}
}
@@ -0,0 +1,338 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#column-style-container,
#plugin-config-container {
outline: none;
user-select: none;
.bool-field-desc {
font-size: 10px;
flex: 1 1 auto;
height: 22px;
display: flex;
align-items: center;
padding-left: 4px;
margin: 0px;
}
.is-default-value .bool-field-container {
background-color: var(--psp--background-color);
border: 1px solid var(--psp-inactive--color);
}
.bool-field-container {
display: flex;
border: 1px dashed var(--psp-inactive--color);
border-radius: 3px;
align-items: center;
padding: 0 6px;
width: 100%;
cursor: pointer;
label {
cursor: pointer;
}
}
&.no-style {
font-style: italic;
background-color: var(--psp-placeholder--background);
}
.is-default-value .dropdown-width-container {
background-color: var(--psp--background-color);
}
.dropdown-width-container {
cursor: pointer;
height: 24px;
border: 1px solid transparent;
border-radius: 2px;
padding: 0 8px;
border-radius: 2px;
/* border: 1px solid transparent; */
border-color: var(--psp-inactive--color, #666);
&:hover {
background-color: var(--psp--background-color);
}
}
select {
cursor: pointer;
font-size: 10px;
height: 22px;
/* padding-bottom: 2px; */
/* border-bottom: 1px solid var(--input--border-color, #ccc); */
padding: 0 8px;
}
label {
display: block;
font-size: var(--label--font-size, 0.75em);
margin: 4px 0 2px 0;
width: 100%;
}
span.reset-default-style-disabled {
flex: 0 0 14px;
margin-left: 4px;
margin-right: 4px;
width: 14px;
height: 14px;
border: 1px solid var(--psp-inactive--border-color);
border-radius: 2px;
}
span.reset-default-style {
flex: 0 0 22px;
cursor: pointer;
width: 22px;
height: 22px;
/* margin-right: -4px; */
display: inline-block;
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(--psp-icon--close--mask-image);
mask-image: var(--psp-icon--close--mask-image);
&:before {
content: var(--psp-icon--close--mask-image);
visibility: hidden;
}
}
.is-default-value input.parameter {
background-color: var(--psp--background-color);
}
input.parameter {
background: none;
color: inherit;
border: 0px solid transparent;
outline: none;
}
input.parameter[type="number"] {
flex: 1 1 auto;
text-align: left;
font-family: inherit;
width: 0;
border-radius: 2px;
font-size: 10px;
height: 24px;
padding-left: 8px;
border-width: 1px;
border-color: var(
--input--border-color,
var(--psp-inactive--color, inherit)
);
}
input.parameter.parameter-min[type="number"] {
border-radius: 2px 0 0 2px;
}
input.parameter.parameter-max[type="number"] {
border-radius: 0 2px 2px 0;
border-left-width: 0px;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
opacity: 1;
background: transparent;
background-color: transparent;
}
.column-style-label {
display: flex;
padding: 4px 0px;
}
.indent {
margin-left: 24px;
}
input[type="radio"] {
appearance: none;
display: inline-block;
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
mask-image: var(--psp-icon--radio-off--mask-image);
&:before {
content: var(--psp-icon--radio-off--mask-image);
visibility: hidden;
}
&:checked {
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
mask-image: var(--psp-icon--radio-on--mask-image);
}
&:hover {
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
mask-image: var(--psp-icon--radio-hover--mask-image);
}
}
div.section {
display: flex;
align-items: center;
flex: 1 1 100%;
}
div.row {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
fieldset {
margin: 0;
}
fieldset.style-control {
border: none;
padding: 0px 0;
}
.color-gradient-container {
display: flex;
align-items: center;
flex-wrap: nowrap;
flex: 1 1 100%;
.color-thermometer {
flex: 1 1 auto;
height: 24px;
}
}
.color-selector {
display: grid;
input {
grid-column: 1;
grid-row: 1;
}
.color-label {
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
grid-column: 1;
grid-row: 1;
margin: 0;
font-family: var(--psp-button--font-family, inherit);
color: var(--sign--color, white);
width: 36px;
height: 24px;
text-align: center;
z-index: 1;
pointer-events: none;
}
}
input[type="color"] {
width: 100%;
height: 24px;
cursor: pointer;
padding: 0;
font-family: inherit;
overflow: hidden;
border-radius: 12px;
&:hover {
opacity: 0.8;
}
&.pos-color-param {
width: 36px;
border-radius: 12px 0 0 12px;
margin: 0 1px 0 0;
}
&.neg-color-param {
width: 36px;
border-radius: 0 12px 12px 0;
margin: 0 0 0 1px;
}
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-color-swatch {
border: 0;
border-radius: 0;
}
::-moz-color-swatch,
::-moz-focus-inner {
border: 0;
}
::-moz-focus-inner {
padding: 0;
}
.operator {
font-family: inherit;
white-space: pre;
}
input[disabled]:before {
opacity: 0.5;
}
input.parameter[disabled] {
opacity: 0.5;
}
button#datetime_format {
appearance: none;
background: none;
color: inherit;
border: 1px solid var(--psp-inactive--color);
border-radius: 3px;
font-family: inherit;
padding: 6px;
font-size: 10px;
width: 100%;
cursor: pointer;
&:before {
content: attr(data-title);
}
&:hover {
border-color: var(--psp--color);
background-color: var(--psp--color);
color: var(--psp--background-color);
}
&:hover:before {
content: attr(data-title-hover);
}
}
}
}
@@ -0,0 +1,74 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#attributes-symbols {
position: relative;
svg {
width: 15px;
height: auto;
color: var(--psp--color);
fill: var(--psp--color);
background: none;
}
.toggle-mode.is_column_active {
margin: 0;
padding: 0;
}
.row-selector {
flex: 1;
min-width: 0px;
.column-selector-column-border {
min-height: 24px;
}
input.column-empty-input {
padding: 0 4px;
margin: 0;
width: 100%;
}
.row-selector-errored input {
border-color: var(--psp-error--color);
}
}
.pairs-list-item .dropdown-width-container {
flex: 0 1 75px;
}
&:before {
font-size: var(--label--font-size, 0.75em);
left: 0px;
padding: var(--column_type--padding, 0px 0px 0px 0px);
position: absolute;
margin-top: -15px;
content: attr(data-label);
}
&.pairs-list[data-label] {
margin-top: 24px;
}
.column_name {
padding: 0;
margin: 0 8px;
}
.symbol-selector-wrapper {
width: 75px;
}
}
}
@@ -0,0 +1,441 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#top_panel.dragdrop-highlight {
.psp-text-field {
border-bottom-color: var(--psp--color) !important;
}
.pivot-column-border {
border-color: var(--psp--color) !important;
}
}
#top_panel.group-rollup-mode-total {
#group_by {
width: 100%;
/* height: 26px; */
.pivot-column {
.pivot-column-total {
min-height: 24px;
margin-bottom: 4px;
&:before {
background-color: var(--psp--background-color);
}
}
.column_name {
color: var(--psp-inactive--color);
}
.type-icon {
background-color: var(--psp-inactive--color);
}
&:hover .pivot-column-border {
border-color: var(--psp-inactive--color, #ababab);
}
/* input { */
/* background-color: var(--psp--background-color); */
/* pointer-events: none; */
/* border: 1px solid var(--psp-inactive--color); */
/* color: var(--psp-inactive--color) !important; */
/* // &:placeholder { */
/* // } */
/* } */
}
}
}
#top_panel {
display: flex;
flex-direction: column;
justify-content: stretch;
padding: 0px 0px 12px 0px;
max-width: 1000px;
z-index: 1;
.row_close {
margin: 0 6.5px 4px 6.5px;
cursor: pointer;
height: 14px;
display: inline-block;
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
mask-image: var(--psp-icon--checkbox-on--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--checkbox-on--mask-image);
visibility: hidden;
}
&:hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
.pivot-column {
display: inline-flex;
align-items: center;
min-height: 28px;
position: relative;
.pivot-column-border {
flex: 1 1 auto;
overflow: hidden;
background-color: var(--psp--background-color);
border: 1px solid var(--psp-inactive--color, #ababab);
border-radius: 2px;
display: flex;
align-items: center;
position: relative;
padding-right: 8px;
.column_name {
display: block;
}
.input-sizer {
flex: 1 100000 auto;
}
}
&:hover .pivot-column-border {
border-color: var(--psp--color);
}
.pivot-column-draggable {
position: relative;
width: calc(100% - 27px);
min-height: 28px;
padding-bottom: 4px;
display: flex;
align-items: stretch;
cursor: move;
}
&:not(.column-empty) .pivot-column-border > .drag-handle {
flex: 0 0 auto;
margin: 0px 0 0 6px;
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
mask-image: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
&:before {
content: var(
--psp-icon--psp-icon--column-drag-handle--mask-image
);
visibility: hidden;
}
}
span {
white-space: nowrap;
}
span:first-child {
text-overflow: ellipsis;
/* max-width: 250px; */
overflow: hidden;
}
/* Column is being dragged-over, e.g. this is the drop indicator. */
& .config-drop {
background-color: var(--psp-placeholder--background);
margin-right: 7px;
margin-bottom: 4px;
border: 1px solid var(--psp--color);
border-radius: 2px;
overflow: visible;
flex: 1 1 auto;
align-self: stretch;
position: relative;
&:before {
content: " ";
position: absolute;
z-index: -1;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
border-radius: 2px;
background-color: var(--psp--color);
opacity: 0.3;
}
& > * {
color: transparent;
}
}
}
label.pivot-selector-label {
pointer-events: none;
}
#group_by label.pivot-selector-label:before {
content: var(--psp-label--group-by--content, "Group By");
}
#split_by label.pivot-selector-label:before {
content: var(--psp-label--split-by--content, "Split By");
}
#sort label.pivot-selector-label:before {
content: var(--psp-label--sort--content, "Order By");
}
#filter label.pivot-selector-label:before {
content: var(--psp-label--filter--content, "Where");
}
.rrow {
display: flex;
min-height: 24px;
align-items: center;
list-style: none;
padding: 24px 0px 12px 0px;
span[draggable] {
cursor: move;
}
}
.rrow > div {
display: flex;
min-height: 24px;
flex-direction: column;
justify-content: flex-end;
}
.rrow > * {
width: 100%;
padding: 0;
margin: 0px 0px -12px 0px;
}
.psp-text-field ul {
min-height: 20px;
}
.psp-text-field {
position: relative;
border-width: 0;
border-style: solid;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 24px;
background-size: 100% 200%;
}
.psp-text-field > .psp-text-field__input {
flex-direction: column;
justify-content: stretch;
border: 0px;
line-height: 20px;
vertical-align: bottom;
padding-bottom: 0px;
display: none;
margin: 0;
padding: 0 0 0 0;
min-height: 26px;
}
.psp-text-field__input + label {
width: 0px;
height: 20px;
line-height: 17px;
box-sizing: border-box;
white-space: nowrap;
padding: 0px;
font-size: var(--label--font-size, 0.75em);
display: inline-block;
}
.psp-text-field__input {
display: flex !important;
}
.psp-text-field__input + label {
position: absolute;
top: 0px;
margin: var(--column-drop-label--margin, -16px 0px 0px 0px);
font-size: var(--label--font-size, 0.75em);
display: inline-block;
}
.pivot_controls {
display: flex;
justify-content: flex-end;
height: 15px;
margin-bottom: -24px;
margin-right: 28px;
margin-top: 9px;
select:hover {
color: var(--psp--color, inherit);
}
}
.group_rollup_wrapper {
margin-bottom: -30px;
flex: 0 1 auto;
color: var(--psp-inactive--color);
padding-top: 15px;
font-size: var(--label--font-size, 0.75em);
select {
font-size: var(--label--font-size);
}
}
#transpose_button {
cursor: pointer;
flex-grow: 0;
font-family: inherit;
font-size: var(--label--font-size, 0.75em);
user-select: none;
padding: 0;
align-self: center;
/* margin-bottom: -23px; */
/* margin-top: 11.5px; */
/* align-self: flex-end; */
z-index: 1;
min-height: 0px;
/* margin-right: 30px; */
&:hover:before {
color: var(--psp--color, inherit);
}
&:before {
font-family: var(--psp-button--font-family, inherit);
color: var(--psp-inactive--color, #666);
content: var(--psp-label--transpose-button--content, "Swap");
}
}
}
.sort-icon {
display: inline-flex;
margin-left: auto;
/* margin-right: 8px; */
cursor: pointer;
font-family: var(--psp-button--font-family, inherit);
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
&:hover {
opacity: 1;
color: var(--psp-active--color, inherit);
}
&.desc {
-webkit-mask-image: var(--psp-icon--sort-desc--mask-image);
mask-image: var(--psp-icon--sort-desc--mask-image);
&:before {
content: var(--psp-icon--sort-desc--mask-image);
visibility: hidden;
}
}
&.asc {
-webkit-mask-image: var(--psp-icon--sort-asc--mask-image);
mask-image: var(--psp-icon--sort-asc--mask-image);
&:before {
content: var(--psp-icon--sort-asc--mask-image);
visibility: hidden;
}
}
&.none {
-webkit-mask-image: var(--psp-icon--sort-none--mask-image);
mask-image: var(--psp-icon--sort-none--mask-image);
&:before {
content: var(--psp-icon--sort-none--mask-image);
visibility: hidden;
}
}
&.asc.abs {
-webkit-mask-image: var(--psp-icon--sort-abs-asc--mask-image);
mask-image: var(--psp-icon--sort-abs-asc--mask-image);
&:before {
content: var(--psp-icon--sort-abs-asc--mask-image);
visibility: hidden;
}
}
&.desc.abs {
-webkit-mask-image: var(--psp-icon--sort-abs-desc--mask-image);
mask-image: var(--psp-icon--sort-abs-desc--mask-image);
&:before {
content: var(--psp-icon--sort-abs-desc--mask-image);
visibility: hidden;
}
}
&.col.asc {
-webkit-mask-image: var(--psp-icon--sort-col-asc--mask-image);
mask-image: var(--psp-icon--sort-col-asc--mask-image);
&:before {
content: var(--psp-icon--sort-col-asc--mask-image);
visibility: hidden;
}
}
&.col.desc {
-webkit-mask-image: var(--psp-icon--sort-col-desc--mask-image);
mask-image: var(--psp-icon--sort-col-desc--mask-image);
&:before {
content: var(--psp-icon--sort-col-desc--mask-image);
visibility: hidden;
}
}
&.col.asc.abs {
-webkit-mask-image: var(--psp-icon--sort-abs-col-asc--mask-image);
mask-image: var(--psp-icon--sort-abs-col-asc--mask-image);
&:before {
content: var(--psp-icon--sort-abs-col-asc--mask-image);
visibility: hidden;
}
}
&.col.desc.abs {
-webkit-mask-image: var(--psp-icon--sort-abs-col-desc--mask-image);
mask-image: var(--psp-icon--sort-abs-col-desc--mask-image);
&:before {
content: var(--psp-icon--sort-abs-col-desc--mask-image);
visibility: hidden;
}
}
}
}
@@ -0,0 +1,95 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
position: fixed;
z-index: 10000;
outline: none;
font-size: 0.75em;
border: inherit;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
user-select: none;
background-color: white;
padding: 8px;
display: flex;
flex-direction: column;
min-width: 80px;
code {
font-family: var(--psp-interface-monospace--font-family), monospace;
}
input {
margin: 0;
padding: 0;
border: none;
border-bottom: 1px solid var(--psp-inactive--color, #ccc);
background: transparent;
font-family: var(--psp-interface-monospace--font-family), monospace;
color: inherit;
outline: none;
}
.invalid {
color: var(--psp-error--color, #ff0000);
border-color: var(--psp-error--color, #ff0000);
}
.selected {
background-color: rgba(0, 0, 0, 0.05);
}
.dropdown-group-label {
color: var(--psp-inactive--color);
&:not(:first-of-type) {
margin-top: 16px;
}
}
.dropdown-group-container {
display: flex;
flex-direction: column;
margin: 0;
}
span {
min-height: 22px;
display: inline-flex;
align-items: center;
margin: 0 -8px;
padding: 0 8px;
}
.dropdown-group-container span {
cursor: pointer;
}
.no-results {
font-style: italics;
padding: 6px 24px;
color: #ccc;
}
}
:host(:hover) {
.selected {
background-color: transparent;
}
.dropdown-group-container span:hover {
background-color: var(--psp--color);
color: var(--psp--background-color);
}
}
@@ -0,0 +1,47 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
.pairs-list {
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.pairs-list-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: solid 1px var(--psp-inactive--border-color);
margin-bottom: 12px;
}
.pairs-list-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 4px;
width: 100%;
margin-bottom: 4px;
&:last-child .is_column_active {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
}
}
@@ -0,0 +1,28 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
.scroll-panel-container {
width: 100%;
}
.scroll-panel-auto-width {
height: 1px;
/* margin-left: 6px; */
/* margin-top: -1px; */
}
.scroll-panel-content {
position: relative;
}
}
@@ -0,0 +1,81 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#modal_panel.split-panel.orient-reverse
> .split-panel-child:not(:last-child):not(.is-width-override) {
max-width: 350px;
}
.split-panel.orient-reverse
> .split-panel-child:not(:last-child):not(.is-width-override) {
max-width: 300px;
}
.split-panel {
display: flex;
height: 100%;
width: 100%;
&.orient-vertical {
flex-direction: column;
& > .split-panel-child {
flex: 0 1 auto;
}
& > .split-panel-divider:hover {
cursor: row-resize;
}
}
&.orient-reverse {
flex-direction: row-reverse;
}
&.orient-vertical.orient-reverse {
flex-direction: column-reverse;
}
& > * {
flex: 0 1 auto;
}
&:not(.orient-vertical) > *:first-child {
flex: 0 0 auto;
}
/* The thing you click to drag the panel size. */
.split-panel-divider {
flex: 0 0 6px;
transition: background-color 0.2s ease-out;
z-index: var(--settings-panel-z-index);
&:hover {
background-color: rgba(0, 0, 0, 0.05);
cursor: col-resize;
}
}
/* Make the elements embedded in each child stretch to fill the */
/* container */
.split-panel-child {
position: relative;
display: flex;
& > * {
flex: 1 1 auto;
}
}
}
}
@@ -0,0 +1,44 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#format-tab {
overflow: scroll;
}
#plugin-tab {
overflow-x: hidden;
overflow-y: scroll;
padding: 12px 0 0 0;
}
.tab-content {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
.tab-section:last-child {
padding: 12px 0px 12px 8px;
}
.tab-section {
padding: 12px 0px 0px 8px;
flex: 0 0 auto;
overflow: hidden;
}
.text {
margin-left: 1em;
}
}
}
@@ -0,0 +1,98 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
input[type="checkbox"].alternate {
appearance: none;
height: 14px;
width: 14px;
padding: 0px;
cursor: pointer;
outline: none;
margin: 0;
display: inline-block;
background-repeat: no-repeat;
background-color: var(--psp--color, #ccc);
-webkit-mask-size: cover;
mask-size: cover;
font-family: var(--psp-button--font-family, inherit);
-webkit-mask-image: var(
--psp-label--inactive-column-selector--content,
none
);
mask-image: var(--psp-label--inactive-column-selector--content, none);
&:before {
content: var(--psp-label--inactive-column-selector--content, none);
visibility: hidden;
}
&:hover {
border-radius: 2px;
outline: 1px solid var(--psp--color);
}
&:checked:hover {
background-color: var(--psp--color, #ccc);
}
&:checked {
-webkit-mask-image: var(
--psp-label--active-column-selector--content,
none
);
mask-image: var(--psp-label--active-column-selector--content, none);
}
}
input[type="checkbox"] {
float: left;
appearance: none;
height: 14px;
width: 14px;
padding: 0px;
cursor: pointer;
outline: none;
margin: 0 5px 0 0;
display: inline-block;
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
mask-image: var(--psp-icon--checkbox-off--mask-image);
&:before {
content: var(--psp-icon--checkbox-off--mask-image);
visibility: hidden;
}
&:checked {
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
mask-image: var(--psp-icon--checkbox-on--mask-image);
}
&[disabled] {
opacity: 0.2s;
}
&:not(.alternate):hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
input[type="checkbox"]:not(:disabled) {
cursor: pointer;
}
}
@@ -0,0 +1,35 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
.scrollable {
&:hover {
--fix: ;
}
&::-webkit-scrollbar-thumb {
border-radius: 2px;
border: 2px solid transparent;
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
}
&:hover::-webkit-scrollbar-thumb {
border: 1px solid transparent;
box-shadow: inset 0px 0px 0 4px var(--psp-inactive--color);
}
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
background-color: transparent;
width: 6px;
height: 6px;
}
}
}
@@ -0,0 +1,62 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
.dropdown-width-container {
position: relative;
flex: 1 1 auto;
height: 100%;
&:after {
content: attr(data-value) " ";
visibility: hidden;
white-space: nowrap;
padding-right: 17px;
}
select {
position: absolute;
left: 0px;
right: 4px;
top: 0px;
bottom: 0px;
cursor: pointer;
}
}
select {
border-radius: 0;
border-width: 0px;
outline: none;
-webkit-appearance: none;
appearance: none;
color: inherit;
padding: 0px 12px 0px 0px;
/* font-size: 10px; */
font-family: inherit;
background-color: transparent;
background-image: var(--psp-icon--select-arrow--mask-image);
background-position: right 2px center;
background-repeat: no-repeat;
&.invert:focus-within,
&.invert:hover {
background-image: var(--psp-icon--select-arrow-hover--mask-image);
}
option {
color: var(--psp--color, inherit);
background: var(--psp--background-color, transparent);
}
}
}
@@ -0,0 +1,73 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
--invalid-column-pattern: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' preserveAspectRatio='none' viewBox='0 0 100 24'><path d='M100 0 L0 24 ' stroke='black' stroke-width='1'/><path d='M0 0 L100 24 ' stroke='black' stroke-width='1'/></svg>");
.column-empty {
width: 100%;
}
#top_panel .column-invalid.pivot-column,
.column-invalid.pivot-column {
.column-invalid-input {
mask-image: var(--invalid-column-pattern);
-webkit-mask-image: var(--invalid-column-pattern);
background-color: var(--psp--color);
mask-size: cover;
-webkit-mask-size: cover;
width: 100%;
height: 22px;
background-repeat: no-repeat;
background-position: center center;
background-size:
100% 100%,
auto;
}
position: relative;
box-sizing: border-box;
width: calc(100% - 7px);
background-color: var(--psp-placeholder--background);
border: 1px solid var(--psp--color);
border-radius: 2px;
margin-right: 6px;
margin-bottom: 4px;
min-height: 22px;
width: calc(100% - 7px);
outline: none;
}
.column-empty-input {
position: relative;
display: flex;
align-items: stretch;
cursor: auto;
background-color: var(--psp-placeholder--background);
border: 1px solid transparent;
border-radius: 2px;
padding-bottom: 0px;
margin-bottom: 4px;
min-height: 24px;
width: calc(100% - 7px);
outline: none;
padding-left: 10px;
font-family: inherit;
}
.column-empty-input:focus {
color: var(--psp--background-color);
background-color: var(--psp--color);
border: 1px solid var(--psp--color);
}
}
@@ -0,0 +1,153 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#editor-container {
display: flex;
flex-direction: column;
min-width: 0px;
border: 1px solid var(--psp-inactive--color);
background-color: var(--psp--background-color);
border-radius: 3px;
overflow: hidden;
.error {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 1.5em;
color: var(--psp-error--color);
top: 0.5em;
left: 0.5em;
position: absolute;
font-size: 10px;
width: calc(100% - 24px);
}
&.disabled {
#textarea_editable,
#line_numbers,
#editor {
background-color: var(--psp-inactive--border-color);
}
}
}
#attributes-expr {
flex: 1 1 auto;
margin-top: 1px;
display: flex;
flex-direction: column;
}
#attribtues-tab .split-panel-child {
max-height: 400px;
flex-direction: column;
}
#expression-editor {
display: flex;
flex-direction: column;
}
#editor-alias-container {
flex: 0 0 auto;
margin-bottom: 2px;
}
.item_title {
margin-bottom: 3px;
margin-top: 4px;
}
input#expression-name {
padding-left: 8px;
color: inherit;
&:placeholder-shown {
background-color: transparent;
}
}
.split-panel.orient-vertical > .split-panel-child #editor-container {
height: calc(100% - 15px);
}
#horizontal-resize {
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 8px;
&:hover {
cursor: ew-resize;
background: rgba(0, 0, 0, 0.05);
}
}
#psp-expression-editor-actions {
display: flex;
gap: 0.333333em;
justify-content: flex-end;
align-items: center;
}
#psp-expression-editor-meta {
display: flex;
flex: 0 0 24px;
flex-direction: column;
padding: 0.5em;
gap: 0.25em;
height: 24px;
position: relative;
}
#save-settings,
#danger-zone {
display: flex;
gap: 0.333333em;
}
#save-settings-wrapper {
display: flex;
flex-direction: column;
gap: 0.333333em;
}
.psp-expression-editor__button {
flex: 1;
justify-content: center;
font-family: inherit;
font-size: 0.8333em;
text-transform: uppercase;
border: 1px solid var(--psp--color);
border-radius: 2px;
height: 24px;
padding: 2px 12px;
display: flex;
align-items: center;
background: none;
color: inherit;
&:not([disabled]):hover {
cursor: pointer;
background-color: var(--psp--color);
color: var(--psp--background-color);
}
&[disabled] {
cursor: not-allowed;
opacity: 0.2;
}
}
}
@@ -0,0 +1,54 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
position: fixed;
z-index: 10000;
outline: none;
font-size: 0.75em;
border: inherit;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
user-select: none;
background-color: white;
padding: 6px;
max-width: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
.selected {
background-color: rgba(0, 0, 0, 0.05);
}
span {
cursor: pointer;
}
.no-results {
font-style: italics;
padding: 6px 24px;
color: #ccc;
}
}
:host(:hover) {
.selected {
background-color: transparent;
}
span:hover,
span.selected:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
@@ -0,0 +1,127 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
:-webkit-calendar-picker-indicator {
filter: var(--psp-calendar--filter, none);
}
#top_panel {
.rrow {
.input-sizer {
display: inline-grid;
align-items: center;
position: relative;
margin-top: 1px;
min-width: 45px;
overflow: hidden;
height: 16px;
&:after,
input,
textarea {
width: auto;
grid-area: 1 / 1;
font-family: inherit;
}
input,
textarea {
grid-area: 1 / 1;
padding: 0;
border: none;
border-bottom: 1px solid var(--psp-inactive--color, #ccc);
background: transparent;
color: inherit;
}
&:after {
content: attr(data-value) " ";
color: var(--psp-inactive--color, inherit);
visibility: hidden;
white-space: nowrap;
padding-right: 18px;
}
&.num-filter {
input,
textarea {
width: 100%;
position: absolute;
}
}
&.num-filter:after {
padding-right: 25px;
}
input.date-filter {
width: 120px;
}
input.datetime-filter {
width: 230px;
}
input.time-filter {
grid-area: 1/2;
}
&:focus-within {
textarea:focus,
input:focus {
outline: none;
}
}
}
}
/* The filter comparator */
#filter {
.pivot-column {
input {
font-size: 1em;
}
input[type="search"] {
text-overflow: ellipsis;
position: absolute;
left: 0;
right: 0;
&::-webkit-search-cancel-button {
cursor: pointer;
opacity: 1;
margin-left: 6px;
}
&:not(:empty)::-webkit-search-cancel-button {
opacity: 1;
}
}
.dropdown-width-container {
height: 100%;
flex: 0 0 auto;
select {
padding-right: 12px;
padding-left: 4px;
max-width: 100%;
position: absolute;
height: 100%;
}
}
}
}
}
}
@@ -0,0 +1,134 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#editor {
display: flex;
flex: 1 1 auto;
position: relative;
overflow-x: visible;
overflow-y: auto;
#editor-height-sizer {
height: 100px;
}
#editor-inner {
overflow: hidden;
position: relative;
display: flex;
flex: 1 1 auto;
}
#line_numbers {
color: var(--psp-inactive--color);
background-color: var(--psp--background-color, white);
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 0 0 auto;
font-family: var(--psp-interface-monospace--font-family, monospace);
font-weight: 400;
overflow: hidden;
padding: 6px 0 6px 6px;
pointer-events: none;
span:after {
color: var(--code-editor-error--color, red);
white-space: pre;
content: var(--status-icon--content, " X ");
opacity: 0;
}
span.error_highlight:after {
opacity: 1;
}
}
pre#content {
margin: 0 6px 6px 0;
padding: 6px 0;
/* margin-left: 36px; */
box-sizing: border-box;
pointer-events: none;
font-weight: 400;
font-family: var(--psp-interface-monospace--font-family, monospace);
color: var(--code-editor-error--color, red);
white-space: pre;
overflow: hidden;
span {
/* display: contents; */
}
.comment {
color: var(--psp-code-editor--comment--color, orange);
}
.operator {
color: var(--psp-code-editor--operator--color, green);
}
.unknown {
color: var(--code-editor-unknown--color, red);
}
.symbol {
color: var(--psp-code-editor--symbol--color, #242526);
}
.column {
color: var(--psp-code-editor--column--color, purple);
}
.literal {
color: var(--psp-code-editor--literal--color, blue);
}
.error_highlight {
/* background-color: */
color: var(--code-editor-error--color, red);
background-color: rgba(255, 0, 0, 0.1);
border: 0px solid var(--code-editor-error--color, red);
border-bottom-width: 1px;
margin-bottom: -1px;
}
}
#textarea_editable {
position: absolute;
overscroll-behavior: none;
width: 100%;
height: 100%;
font-family: var(--psp-interface-monospace--font-family, monospace);
font-size: 1em;
resize: none;
padding: 6px 0;
margin: 0;
border: none;
outline: none;
color: transparent;
caret-color: var(--psp-code-editor--symbol--color, #242526);
white-space: pre;
box-sizing: border-box;
background-color: transparent;
}
&.wordwrap {
#textarea_editable,
#content {
white-space: pre-wrap;
word-wrap: break-word;
}
}
}
}
@@ -0,0 +1,82 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
div.tab-title#Debug:before {
content: var(--psp-label--debug-tab--content, "Debug JSON");
}
#debug-panel-overflow {
overflow: hidden;
display: flex;
}
#debug-panel {
display: flex;
min-width: 150px;
label {
display: block;
margin-bottom: 2px;
font-size: var(--label--font-size, 0.75em);
}
#debug-panel-controls {
display: flex;
flex: 0 0 auto;
gap: 0.333333em;
padding: 12px 8px 0px 0px;
align-items: center;
button {
appearance: none;
background-color: transparent;
border-radius: 3px;
border: 1px solid var(--psp--color);
color: inherit;
cursor: pointer;
flex: 0 1 100px;
font-family: inherit;
font-size: 0.8333em;
text-transform: uppercase;
height: 24px;
text-overflow: ellipsis;
overflow: hidden;
width: 0px;
&:not([disabled]):hover {
background-color: var(--psp--color);
color: var(--psp--background-color);
}
&[disabled] {
cursor: not-allowed;
opacity: 0.2;
}
}
}
#debug-panel-editor {
display: flex;
flex-direction: column;
flex: 0 1 auto;
overflow: hidden;
padding: 8px 8px 8px 0;
}
#editor {
background-color: var(--psp--background-color);
border: 1px solid var(--psp-inactive--color);
border-radius: 2px;
font-size: 0.8em;
/* height: 100%; */
}
}
}
@@ -0,0 +1,46 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
position: fixed;
z-index: 10000;
outline: none;
font-size: 0.75em;
border: inherit;
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 10%);
user-select: none;
background-color: white;
padding: 6px;
display: flex;
flex-direction: column;
max-width: 300px;
overflow: hidden;
.selected {
background-color: rgba(0, 0, 0, 0.05);
}
span {
cursor: pointer;
}
}
:host(:hover) {
.selected {
background-color: transparent;
}
.selected:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
@@ -0,0 +1,294 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
#settings_panel {
#plugin_selector_container.open ~ * {
opacity: 0;
}
#plugin_selector_container {
min-height: var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
);
display: flex;
white-space: nowrap;
flex-direction: column;
flex: 0 0 auto;
/* Overflow the padding bounds of the container */
/* TODO these bounds exist to protext the resizer and scrollbar (left & */
/* right resp) but we really should find a way to remove it and delegate */
/* to sub-containers. */
margin: 0 0px 0 -9px;
#plugin_selector_border {
height: 2px;
min-height: 2px;
width: 100%;
background-color: var(--psp-inactive--color, #6e6e6e);
margin-top: 2px;
}
.plugin-selector-options {
overflow-y: auto;
}
&.open {
position: absolute;
margin: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* This element is first in the container but visually "overlaps" */
/* when expanded */
z-index: 3;
color: var(--psp--color, #042121);
/* background: var(--psp--background-color); */
.plugin-select-item {
padding-left: 8px;
}
}
.plugin-select-item-name {
padding-left: 10px;
font-size: 1.33333em;
}
.plugin-select-item-name:before {
/* This value is set in a `style` tag from Yew! */
content: var(--default-column-title);
}
.plugin-select-item {
/* width: 48px; */
padding-left: 9px;
height: var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
);
min-height: var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
);
display: flex;
align-items: center;
border-bottom: 1px solid transparent;
box-shadow: 0px 1px var(--psp-inactive--color, #6e6e6e);
cursor: pointer;
}
.plugin-select-item > .icon {
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
background-repeat: no-repeat;
background-color: var(--psp--color);
}
.plugin-select-item:hover {
background-color: var(--psp--color, #042121);
color: var(--psp--background-color, #fdfffd);
}
.plugin-select-item:hover > .icon {
background-color: var(--psp--background-color, #fdfffd);
}
.plugin-select-item[data-plugin="Candlestick"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--candlestick--content
);
mask-image: var(--psp-plugin-selector--candlestick--content);
&:before {
content: var(--psp-plugin-selector--candlestick--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Heatmap"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--heatmap--content
);
mask-image: var(--psp-plugin-selector--heatmap--content);
&:before {
content: var(--psp-plugin-selector--heatmap--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Density"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--density--content
);
mask-image: var(--psp-plugin-selector--density--content);
&:before {
content: var(--psp-plugin-selector--density--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Map Scatter"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--map-scatter--content
);
mask-image: var(--psp-plugin-selector--map-scatter--content);
&:before {
content: var(--psp-plugin-selector--map-scatter--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Map Line"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--map-line--content
);
mask-image: var(--psp-plugin-selector--map-line--content);
&:before {
content: var(--psp-plugin-selector--map-line--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Map Density"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--map-density--content
);
mask-image: var(--psp-plugin-selector--map-density--content);
&:before {
content: var(--psp-plugin-selector--map-density--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="OHLC"] > .icon {
-webkit-mask-image: var(--psp-plugin-selector--ohlc--content);
mask-image: var(--psp-plugin-selector--ohlc--content);
&:before {
content: var(--psp-plugin-selector--ohlc--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Sunburst"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--sunburst--content
);
mask-image: var(--psp-plugin-selector--sunburst--content);
&:before {
content: var(--psp-plugin-selector--sunburst--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Treemap"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--treemap--content
);
mask-image: var(--psp-plugin-selector--treemap--content);
&:before {
content: var(--psp-plugin-selector--treemap--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="X Bar"] > .icon {
-webkit-mask-image: var(--psp-plugin-selector--x-bar--content);
mask-image: var(--psp-plugin-selector--x-bar--content);
&:before {
content: var(--psp-plugin-selector--x-bar--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="X/Y Line"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--x-y-line--content
);
mask-image: var(--psp-plugin-selector--x-y-line--content);
&:before {
content: var(--psp-plugin-selector--x-y-line--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="X/Y Scatter"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--x-y-scatter--content
);
mask-image: var(--psp-plugin-selector--x-y-scatter--content);
&:before {
content: var(--psp-plugin-selector--x-y-scatter--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Y Area"] > .icon {
-webkit-mask-image: var(--psp-plugin-selector--y-area--content);
mask-image: var(--psp-plugin-selector--y-area--content);
&:before {
content: var(--psp-plugin-selector--y-area--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Y Bar"] > .icon {
-webkit-mask-image: var(--psp-plugin-selector--y-bar--content);
mask-image: var(--psp-plugin-selector--y-bar--content);
&:before {
content: var(--psp-plugin-selector--y-bar--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Y Line"] > .icon {
-webkit-mask-image: var(--psp-plugin-selector--y-line--content);
mask-image: var(--psp-plugin-selector--y-line--content);
&:before {
content: var(--psp-plugin-selector--y-line--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Y Scatter"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--y-scatter--content
);
mask-image: var(--psp-plugin-selector--y-scatter--content);
&:before {
content: var(--psp-plugin-selector--y-scatter--content);
visibility: hidden;
}
}
.plugin-select-item[data-plugin="Datagrid"] > .icon {
-webkit-mask-image: var(
--psp-plugin-selector--datagrid--content
);
mask-image: var(--psp-plugin-selector--datagrid--content);
&:before {
content: var(--psp-plugin-selector--datagrid--content);
visibility: hidden;
}
}
}
#plugin_selector {
flex-grow: 1;
}
}
}
@@ -0,0 +1,99 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
label#edit_mode-label:before {
content: var(--psp-label--edit-mode--content);
}
label#scroll_lock-label:before {
content: var(--psp-label--scroll-lock--content);
}
label#chart_type-label:before {
content: var(--psp-label--chart-type--content);
}
/* viewer-charts plugin_config controls */
label#auto_alt_y_axis-label:before {
content: var(--psp-label--auto-alt-y-axis--content);
}
label#facet_mode-label:before {
content: var(--psp-label--facet-mode--content);
}
label#facet_zoom_mode-label:before {
content: var(--psp-label--facet-zoom-mode--content);
}
label#series_zoom_mode-label:before {
content: var(--psp-label--series-zoom-mode--content);
}
label#line_width_px-label:before {
content: var(--psp-label--line-width-px--content);
}
label#point_size_px-label:before {
content: var(--psp-label--point-size-px--content);
}
label#band_inner_frac-label:before {
content: var(--psp-label--band-inner-frac--content);
}
label#bar_inner_pad-label:before {
content: var(--psp-label--bar-inner-pad--content);
}
label#wick_width_px-label:before {
content: var(--psp-label--wick-width-px--content);
}
label#ohlc_line_width_px-label:before {
content: var(--psp-label--ohlc-line-width-px--content);
}
label#include_zero-label:before {
content: var(--psp-label--include-zero--content);
}
label#domain_mode-label:before {
content: var(--psp-label--domain-mode--content);
}
label#gradient_color_mode-label:before {
content: var(--psp-label--gradient-color-mode--content);
}
label#gradient_radius_px-label:before {
content: var(--psp-label--gradient-radius-px--content);
}
label#gradient_intensity-label:before {
content: var(--psp-label--gradient-intensity--content);
}
label#gradient_heat_max-label:before {
content: var(--psp-label--gradient-heat-max--content);
}
label#map_tile_provider-label:before {
content: var(--psp-label--map-tile-provider--content);
}
label#map_tile_alpha-label:before {
content: var(--psp-label--map-tile-alpha--content);
}
}
@@ -0,0 +1,82 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
/* @amber400: #ffca28; */
:host {
.plugin_information {
color: var(--psp-warning--color, inherit);
box-sizing: border-box;
display: flex;
position: absolute;
flex-direction: row;
justify-content: flex-start;
padding: 0.5rem;
z-index: 10000;
user-select: none;
left: 48px;
padding-left: 24px;
padding-right: 24px;
border-radius: 24px;
align-items: center;
&.plugin_information--warning {
background: var(--psp-warning--background, #ffca28);
}
.plugin_information__text {
margin-right: 0.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.plugin_information__icon {
height: 20px;
}
.plugin_information__icon:before {
content: var(--psp-icon-overflow-hint--content);
height: 20px;
font-family: var(
--overflow_hint--font-family,
var(--psp-button--font-family, inherit)
);
color: var(--psp-icon-overflow-hint--color);
padding: var(--overflow-hint-icon--padding, 0 12px 0 6px);
font-size: 16px;
}
.plugin_information__action {
text-decoration: underline;
cursor: pointer;
margin-right: 0.25rem;
}
.plugin_information__actions {
margin-left: auto;
display: flex;
align-items: center;
flex-direction: row;
justify-content: flex-start;
}
.plugin_information--overflow-hint-percent {
font-weight: 700;
}
.plugin_information--overflow-hint {
white-space: nowrap;
}
}
}
@@ -0,0 +1,611 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host(:hover) {
#status_bar.settings-closed.floating {
opacity: 1;
transition: none;
}
}
:host {
/* When settings open ... */
#main_column #status_bar,
#main_column #status_bar.titled {
input::placeholder {
color: var(--psp-inactive--color);
}
}
#status_bar.settings-closed {
padding-right: 10px;
}
#status_bar.updating {
box-shadow: none;
}
#status_bar.settings-closed.floating {
transition: opacity 0.2s;
opacity: 0;
position: absolute;
padding: 0px;
height: auto;
min-height: auto;
max-height: none;
gap: 6px;
top: calc((var(--psp-status-bar--height, 48px) - 20px) / 2);
right: calc(
min(48px, (var(--psp-status-bar--height, 48px) - 20px) / 2)
);
}
#status_bar {
box-shadow: 0 13px 0 -12px var(--psp-inactive--border-color);
z-index: 3;
display: var(--psp-status-bar--display, flex);
gap: 6px;
align-items: center;
background: var(--psp--background-color);
padding: var(--psp-status-bar--padding, 0 31px 0 8px);
position: relative;
height: var(--psp-status-bar--height, 48px);
min-height: var(--psp-status-bar--height, 48px);
max-height: var(--psp-status-bar--height, 48px);
border-radius: var(--psp-status-bar--border-radius);
#status-bar-placeholder {
margin: 0px;
margin-right: -5px;
pointer-events: none;
}
input {
font-size: 1em;
}
input:placeholder-shown + #status-bar-placeholder:before {
content: var(--psp-label--untitled--content, "untitled");
color: var(--psp-inactive--color);
}
.input-sizer {
display: inline-block;
align-items: center;
position: relative;
min-width: 65px;
overflow: hidden;
padding-top: 0.5px;
&:after,
input,
textarea {
width: auto;
grid-area: 1 / 1;
font-family: inherit;
}
input,
textarea {
position: absolute;
left: 0;
right: 0;
text-overflow: ellipsis;
padding: 0;
border: none;
background: transparent;
color: inherit;
height: 100%;
}
&:after {
content: attr(data-value) " ";
color: var(--psp-inactive--color, inherit);
visibility: hidden;
white-space: nowrap;
padding-right: 12px;
}
&:focus-within {
textarea:focus,
input:focus {
outline: none;
}
}
}
.section {
display: flex;
align-items: center;
pointer-events: none;
}
.section > * {
pointer-events: all;
}
#spacer {
flex: 1 1000 auto;
}
span#rows {
display: flex;
align-items: flex-end;
gap: 6px;
flex: 0 1000 auto;
overflow: hidden;
margin: 0px 10px;
pointer-events: none;
span {
margin: 0 !important;
}
span.x,
span.total {
color: var(--psp-inactive--color);
}
}
/* Exceptions when the menu bar is visible and the settings panel is closed. */
#menu-bar {
flex: 0 1000000 auto;
height: 100%;
}
#plugin-settings {
margin-left: auto;
align-self: stretch;
}
span {
white-space: nowrap;
margin: 0px 14px;
user-select: none;
&:hover {
color: inherit;
}
}
span.icon {
height: 100%;
/* line-height: 36px; */
margin: 0;
}
span#status {
background-repeat: no-repeat;
background-color: var(--psp--color);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
pointer-events: none;
image-rendering: pixelated;
margin: 0 5px;
}
/* span#status_updating */
span#status.loading {
image-rendering: pixelated;
mask-image: var(--psp-icon--downloading--mask-image);
-webkit-mask-image: var(--psp-icon--downloading--mask-image);
&:before {
visibility: hidden;
content: var(--psp-icon--downloading--mask-image);
line-height: 0;
}
}
span#status.uninitialized {
background-color: transparent;
}
span#status.connected,
span#status.updating {
mask-image: var(--psp-icon--status-ok--mask-image);
-webkit-mask-image: var(--psp-icon--status-ok--mask-image);
&:before {
visibility: hidden;
content: var(--psp-icon--status-ok--mask-image);
line-height: 0;
}
}
.error-dialog {
position: absolute;
z-index: 1;
width: 100%;
flex-direction: column;
max-height: 300px;
top: 0;
left: 0;
padding: 0px 0px 0px 50px;
display: none;
white-space: pre-wrap;
color: var(--psp--background-color);
background: var(--psp--color);
border-radius: var(--psp-status-bar--border-radius);
}
.error-dialog-message {
white-space: nowrap;
overflow: hidden;
align-items: center;
display: flex;
padding-right: 17px;
height: var(--psp-status-bar--height, 48px);
min-height: var(--psp-status-bar--height, 48px);
}
.error-dialog-stack {
font-size: 10px;
padding-right: 17px;
color: var(--psp-inactive--color);
overflow-y: auto;
padding-bottom: 24px;
}
.section:hover .error-dialog {
display: flex;
}
div#status_reconnect {
z-index: 2;
display: var(--status-indicator--display, flex);
align-items: center;
border-radius: 3px;
margin: 0;
height: auto;
border: 1px solid
var(--psp-status-icon--ok--border-color, transparent);
cursor: var(--psp-status-icon--ok--cursor);
pointer-events: var(--psp-status-icon--pointer-events, none);
&:hover {
background-color: var(
--psp-status-icon--ok-hover--background-color,
transparent
);
border-color: var(
--psp-status-icon--ok-hover--border-color,
transparent
);
span#status {
background-color: var(
--psp-status-icon--ok-hover--color,
var(--psp--color)
);
}
}
&.errored {
pointer-events: all;
display: flex;
cursor: pointer;
}
&.errored.disabled {
cursor: auto;
pointer-events: none;
}
span#status.errored {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
width: 20px;
border-radius: 10px;
color: var(--psp--background-color);
&:before {
content: "!";
}
}
&.errored:hover {
background-color: var(--psp--color);
span#status.errored {
color: var(--psp--color);
background-color: var(--psp--background-color);
}
}
}
/* Status bar status icon animations */
span#status_updating {
position: absolute;
display: inline-flex;
margin: 0 5px;
opacity: 0;
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
pointer-events: none;
image-rendering: pixelated;
mask-image: var(--psp-icon--updating--mask-image);
-webkit-mask-image: var(--psp-icon--updating--mask-image);
&:before {
visibility: hidden;
content: var(--psp-icon--updating--mask-image);
line-height: 0;
}
}
span#status.updating {
animation-name: status-bar-updating-inverse;
animation-fill-mode: forwards;
animation-iteration-count: 1;
animation-duration: 0.3s;
}
span#status_updating.updating {
animation-name: status-bar-updating;
animation-fill-mode: forwards;
animation-iteration-count: 1;
animation-duration: 0.3s;
}
.button > .icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
-webkit-mask-size: cover;
mask-size: cover;
pointer-events: none;
display: inline-block;
}
span#export {
& > .icon {
-webkit-mask-image: var(--psp-icon--export--mask-image);
mask-image: var(--psp-icon--export--mask-image);
display: inline-flex;
&:before {
line-height: 0;
content: var(--psp-icon--export--mask-image);
visibility: hidden;
}
}
span:not(.icon):before {
content: var(--psp-label--export-button--content, "Export");
}
}
span#lock {
& > .icon {
-webkit-mask-image: var(--psp-icon--free-scroll--mask-image);
mask-image: var(--psp-icon--free-scroll--mask-image);
&:before {
line-height: 0;
content: var(--psp-icon--free-scroll--mask-image);
visibility: hidden;
}
}
}
span#reset {
& > .icon {
-webkit-mask-image: var(--psp-icon--reset--mask-image);
mask-image: var(--psp-icon--reset--mask-image);
display: inline-flex;
&:before {
line-height: 0;
content: var(--psp-icon--reset--mask-image);
visibility: hidden;
}
}
span:not(.icon):before {
content: var(--psp-label--reset-button--content);
}
}
span#copy {
& > .icon {
-webkit-mask-image: var(--psp-icon--copy--mask-image);
mask-image: var(--psp-icon--copy--mask-image);
display: inline-flex;
&:before {
line-height: 0;
content: var(--psp-icon--copy--mask-image);
visibility: hidden;
}
}
span:not(.icon):before {
content: var(--psp-label--copy-button--content);
}
}
#theme_icon {
margin-right: -32px;
height: 100%;
width: 28px;
z-index: 1;
}
#theme_icon.modified {
cursor: pointer;
}
#theme {
align-self: stretch;
border-radius: 0px;
select {
height: 100%;
}
& > .icon {
margin: 0 6px 0 4px;
height: auto;
-webkit-mask-image: var(--psp-icon--theme--mask-image);
mask-image: var(--psp-icon--theme--mask-image);
display: inline-flex;
&:before {
line-height: 0;
content: var(--psp-icon--theme--mask-image);
visibility: hidden;
}
}
}
#theme_icon.modified:hover + #theme > .icon {
-webkit-mask-image: var(--psp-icon--close--mask-image);
mask-image: var(--psp-icon--close--mask-image);
}
#theme_selector {
max-width: 100px;
text-overflow: ellipsis;
font-size: 0.8em;
}
.button {
display: inline-flex;
justify-content: center;
align-items: center;
margin: 0px;
padding: 0 5px;
color: var(--psp-inactive--color, #ccc);
border: 1px solid transparent;
border-radius: 3px;
font-size: var(--label--font-size, 0.75em);
& > span,
& > .dropdown-width-container {
pointer-events: none;
overflow: hidden;
display: none;
}
& > .icon {
z-index: 1;
}
}
.hover-target {
margin: 0;
display: inline-flex;
height: 100%;
align-items: center;
&:focus-within,
&:hover,
&.modal-target {
box-shadow:
-4px 0 0 var(--psp--color),
4px 0 0 var(--psp--color);
background-color: var(--psp--color);
}
&:focus-within .button select,
&:hover .button select {
background-color: transparent;
}
.modal-target .button > span {
color: var(--psp--color) !important;
}
&:focus-within .button,
&:hover .button,
&.modal-target .button {
position: relative;
min-width: var(--psp-button--min-width, 33px);
color: inherit;
cursor: pointer;
transition: none;
background-color: var(--psp--color);
color: var(--psp--background-color);
& > .icon {
background-color: var(--psp--background-color);
}
& > span:not(.icon) {
/* The label. */
display: block;
text-align: center;
position: absolute;
top: calc(100% + 3px);
left: 50%;
translate: -50%;
margin: 0;
padding: 5px;
height: auto;
white-space: nowrap;
line-height: 1;
width: 41px;
background-color: var(--psp--color);
border-radius: 0 0 3px 3px;
}
& > .dropdown-width-container {
display: block;
margin: 0;
white-space: nowrap;
line-height: 1;
background-color: var(--psp--color);
pointer-events: all;
& > select {
position: static;
cursor: pointer;
}
&::before {
content: "";
display: block;
position: absolute;
inset-block-start: -3px;
inset-inline-start: 0;
inline-size: 100%;
block-size: 3px;
}
&::after {
content: none;
}
}
}
}
}
}
@keyframes status-bar-updating {
0% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes status-bar-updating-inverse {
0% {
opacity: 1;
}
99% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@@ -0,0 +1,116 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host #selected-columns .type-icon,
:host #sub-columns .type-icon {
/* position: absolute; */
/* left: 15px; */
margin-left: 4px;
}
:host .icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
flex-shrink: 0;
line-height: 0;
&.integer {
-webkit-mask-image: var(--icon-image);
mask-image: var(--icon-image);
&:before {
content: var(--icon-image);
visibility: hidden;
}
}
}
:host .type-icon {
background-repeat: no-repeat;
background-color: var(--psp--color);
display: inline-block;
-webkit-mask-size: cover;
mask-size: cover;
flex-shrink: 0;
line-height: 0;
&.none {
content: none;
}
&.integer {
-webkit-mask-image: var(--psp-column-type--integer--mask-image);
mask-image: var(--psp-column-type--integer--mask-image);
&:before {
content: var(--psp-column-type--integer--mask-image);
visibility: hidden;
}
}
&.float {
-webkit-mask-image: var(--psp-column-type--float--mask-image);
mask-image: var(--psp-column-type--float--mask-image);
&:before {
content: var(--psp-column-type--float--mask-image);
visibility: hidden;
}
}
&.string {
-webkit-mask-image: var(--psp-column-type--string--mask-image);
mask-image: var(--psp-column-type--string--mask-image);
&:before {
content: var(--psp-column-type--string--mask-image);
visibility: hidden;
}
}
&.boolean {
-webkit-mask-image: var(--psp-column-type--boolean--mask-image);
mask-image: var(--psp-column-type--boolean--mask-image);
&:before {
content: var(--psp-column-type--boolean--mask-image);
visibility: hidden;
}
}
&.date {
-webkit-mask-image: var(--psp-column-type--date--mask-image);
mask-image: var(--psp-column-type--date--mask-image);
&:before {
content: var(--psp-column-type--date--mask-image);
visibility: hidden;
}
}
&.datetime {
-webkit-mask-image: var(--psp-column-type--datetime--mask-image);
mask-image: var(--psp-column-type--datetime--mask-image);
&:before {
content: var(--psp-column-type--datetime--mask-image);
visibility: hidden;
}
}
&.expression {
-webkit-mask-image: var(--psp-icon--add-expression--mask-image);
mask-image: var(--psp-icon--add-expression--mask-image);
&:before {
content: var(--psp-icon--add-expression--mask-image);
visibility: hidden;
}
}
}
+556
View File
@@ -0,0 +1,556 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/
:host {
--settings-panel-z-index: 10;
--psp--color: #ff0000;
}
/* Shift-key affordance: while the user holds Shift, any mask-image icon
* tagged `.shift-alt-icon` recolors to advertise that a Shift-modified
* action is available on the underlying control. `!important` is required
* to beat per-icon ID-scoped `background-color` rules. */
:host(.shift-active) .shift-alt-icon {
background-color: var(
--shift-active--color,
var(--psp-datagrid--pos-cell--color, #1078d1)
) !important;
}
:host(.shift-active) {
#sub-columns {
.is_column_active.toggle-mode {
-webkit-mask-image: var(--psp-icon--radio-off--mask-image);
mask-image: var(--psp-icon--radio-off--mask-image);
&:before {
content: var(--psp-icon--radio-off--mask-image);
}
&:hover {
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
mask-image: var(--psp-icon--radio-hover--mask-image);
}
}
.is_column_active.select-mode {
-webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
mask-image: var(--psp-icon--checkbox-off--mask-image);
&:before {
content: var(--psp-icon--checkbox-off--mask-image);
}
&:hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
}
#active-columns {
.is_column_active.toggle-mode {
-webkit-mask-image: var(--psp-icon--radio-on--mask-image);
mask-image: var(--psp-icon--radio-on--mask-image);
&:before {
content: var(--psp-icon--radio-on--mask-image);
}
&:not(.required):hover {
-webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
mask-image: var(--psp-icon--radio-hover--mask-image);
}
}
.is_column_active.select-mode {
-webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
mask-image: var(--psp-icon--checkbox-on--mask-image);
&:before {
content: var(--psp-icon--checkbox-on--mask-image);
}
&:not(.required):hover {
-webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
mask-image: var(--psp-icon--checkbox-hover--mask-image);
}
}
}
}
::slotted(*) {
pointer-events: var(--override-content-pointer-events);
}
:host input[type="number"]::-webkit-inner-spin-button {
transform: scale(1.5) translateX(-5px); /* Makes the arrows 50% larger */
filter: var(--psp-input--filter);
}
:host .sidebar_close_button {
position: absolute;
top: 0;
padding-top: calc(
(
var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
) -
20px
) /
2
);
padding-bottom: calc(
(
var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
) -
20px
) /
2
);
z-index: 10000;
height: var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
);
&:hover {
cursor: pointer;
}
.sidebar_close_button_inner {
border-radius: 5px 0 0 5px;
height: 20px;
background-color: var(--psp--background-color);
}
.sidebar_close_button_inner .icon {
display: inline-block;
mask-size: cover;
-webkit-mask-size: cover;
background-repeat: no-repeat;
background-color: var(--psp--color);
mask-image: var(--psp-icon--drawer-tab--mask-image);
-webkit-mask-image: var(--psp-icon--drawer-tab--mask-image);
&:before {
content: var(--psp-icon--drawer-tab--mask-image);
visibility: hidden;
}
}
&:hover .sidebar_close_button_inner .icon {
mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
-webkit-mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
}
}
:host #settings_close_button,
:host #column_settings_close_button {
left: -21px;
}
:host #debug_close_button.sidebar_close_button .sidebar_close_button_inner {
background-attachment: fixed;
background: var(--psp-sidebar--background);
.icon {
mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
-webkit-mask-image: var(--psp-icon--drawer-tab-hover--mask-image);
}
}
:host
#debug_close_button.sidebar_close_button:hover
.sidebar_close_button_inner
.icon {
mask-image: var(--psp-icon--drawer-tab--mask-image);
-webkit-mask-image: var(--psp-icon--drawer-tab--mask-image);
}
:host #debug_open_button.sidebar_close_button {
right: 0px;
cursor: pointer;
.sidebar_close_button_inner {
background-color: transparent;
}
}
:host #debug_open_button.sidebar_close_button .sidebar_close_button_inner {
.icon {
mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
-webkit-mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
}
&:hover .icon {
mask-image: var(--psp-icon--drawer-tab-inverted-hover--mask-image);
-webkit-mask-image: var(
--psp-icon--drawer-tab-inverted-hover--mask-image
);
}
}
:host #debug_close_button.sidebar_close_button {
right: 0px;
}
:host(:hover) {
#menu-bar {
opacity: 1 !important;
}
#component_container #settings_button,
#component_container #settings_button.titled {
opacity: 1;
}
}
:host {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
font-size: 0.75em;
* {
box-sizing: border-box;
line-height: 1.25;
}
#component_container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
}
#app_panel {
position: absolute;
bottom: 0px;
left: 0;
right: 0;
top: 0;
height: auto;
flex-direction: row-reverse;
& > .split-panel-divider {
border-left: 1px solid var(--psp-inactive--color, #6e6e6e);
margin-right: -5px;
}
}
#modal_panel {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
& > * {
pointer-events: all;
}
}
#modal_panel > .split-panel-divider {
border-left: 1px solid var(--psp-inactive--color, #6e6e6e);
margin-right: -5px;
}
#status_bar {
height: var(--psp-status-bar--height, 48px);
}
#main_column_container {
position: relative;
display: flex;
flex: 1 1 auto;
overflow: hidden;
}
#main_column {
display: flex;
flex: 1 1 auto;
overflow: hidden;
flex-direction: column;
position: relative;
margin: var(--psp-main-column--margin);
border: var(--psp-main-column--border);
border-width: var(--psp-main-column--border-width);
border-radius: var(--psp-main-column--border-radius);
}
#main_panel_container {
position: relative;
height: 100%;
box-shadow: var(--plugin--box-shadow, none);
background: var(
--psp-main-column--background,
var(--psp--background-color, none)
);
overflow: hidden;
border: var(--psp-plugin--border, none);
}
#settings_panel {
position: relative;
flex: 0 0 auto;
padding: 0pc;
padding-left: 8px;
box-sizing: border-box;
width: 100%;
z-index: var(--settings-panel-z-index);
& > .split-panel-child {
overflow: hidden;
}
& > .split-panel-divider {
border-top: 1px solid var(--psp-inactive--color, #6e6e6e);
border-bottom: 2px solid var(--psp-inactive--color, #6e6e6e);
margin-left: -9px;
margin-right: 0px;
flex: 0 0 4px;
}
}
#column_settings_sidebar .split-panel-divider {
border-top: 1px solid var(--psp-inactive--color, #6e6e6e);
border-bottom: 2px solid var(--psp-inactive--color, #6e6e6e);
margin-left: -9px;
margin-right: -4px;
flex: 0 0 4px;
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.sidebar_column {
background: var(--psp-sidebar--background);
background-attachment: fixed;
display: flex;
list-style: none;
flex-direction: column;
flex: 1;
/* Needs 2 because it must take precedence over teh chart legend. */
z-index: 2;
}
#plugin_selector {
margin-top: 3px;
border-bottom-width: 1px;
border-style: solid;
border-color: var(--psp-inactive--color, #c5c9d0);
border-top-width: 0px;
border-left-width: 0px;
border-right-width: 0px;
}
#component_container > #settings_button {
opacity: 0;
position: absolute;
top: 0;
right: 0;
margin: var(--settings-button--margin, 10px 36px 0 0);
padding: 0;
}
#close_button {
background-color: var(--psp--background-color);
padding: 0px;
overflow: hidden;
display: var(--psp-close-button--display, none);
align-items: center;
justify-content: center;
z-index: 10000;
border: 1px solid var(--psp-inactive--color);
border-radius: 5px;
font-size: 10px;
font-weight: normal;
&:hover {
color: var(--psp--background-color, inherit);
background-color: var(--psp--color);
cursor: pointer;
}
&:hover .icon {
background-color: var(--psp--background-color);
}
.icon {
display: inline-block;
mask-size: cover;
-webkit-mask-size: cover;
background-repeat: no-repeat;
background-color: var(--psp--color);
mask-image: var(--psp-icon--close--mask-image);
-webkit-mask-image: var(--psp-icon--close--mask-image);
&:before {
content: var(--psp-icon--close--mask-image);
visibility: hidden;
}
}
}
#settings_button {
background-color: var(--psp--background-color);
padding: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
border: 1px solid var(--psp-inactive--color);
border-radius: 5px;
font-size: 10px;
font-weight: normal;
&:hover {
color: var(--psp--background-color, inherit);
background-color: var(--psp--color);
cursor: pointer;
}
&:hover .icon {
background-color: var(--psp--background-color);
}
.icon {
display: inline-flex;
mask-size: cover;
-webkit-mask-size: cover;
background-repeat: no-repeat;
background-color: var(--psp--color);
mask-image: var(--psp-icon--drawer-tab-inverted--mask-image);
-webkit-mask-image: var(
--psp-icon--drawer-tab-inverted--mask-image
);
&:before {
line-height: 0;
display: inline-flex;
content: var(--psp-icon--drawer-tab-inverted--mask-image);
visibility: hidden;
}
}
}
.settings_tab_bar_scroll_offset {
margin-left: -8px;
}
#settings_tab_bar {
display: flex;
&:after {
content: " ";
border-bottom: 1px solid var(--psp-inactive--color);
background-color: #00000020;
flex: 1 0 auto;
}
.settings_tab {
border-right: 1px solid var(--psp-inactive--color);
cursor: pointer;
padding: 6px 8px;
font-size: var(--label--font-size, 0.75em);
text-transform: uppercase;
flex: 0 1 100px;
background-color: #00000020;
border-bottom: 1px solid var(--psp-inactive--color);
color: var(--psp-inactive--color);
margin-left: -1px;
border-left: 1px solid var(--psp-inactive--color);
&:hover {
color: inherit;
}
}
.selected_tab {
color: inherit;
background-color: transparent;
border-bottom: 1px solid transparent;
box-shadow: 0px 9px 0px -8px var(--psp-inactive--color);
height: calc(100% - 1px);
}
}
.split-panel.orient-reverse
> .split-panel-child:not(:last-child):not(.is-width-override) {
max-width: 300px;
}
.sidebar_header {
min-height: calc(
var(
--psp-plugin-selector--height,
var(--psp-status-bar--height, 48px)
) +
1px
);
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
border-bottom: 1px solid var(--psp-inactive--color, #6e6e6e);
}
.sidebar_header_title {
padding-left: 9px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 30px;
}
.sidebar_border {
height: 2px;
width: 100%;
background-color: var(--psp-inactive--color, #6e6e6e);
margin-top: 1px;
flex-shrink: 0;
flex-grow: 0;
}
#query_tabbar_tab:before {
content: var(--psp-label--query-tab--content);
}
#plugin_tabbar_tab:before {
content: var(--psp-label--plugin-tab--content);
}
#debug_tabbar_tab:before {
content: var(--psp-label--debug-tab--content);
}
label#stack-label:before {
content: var(--psp-label--stack--content, "Stack");
}
label#alt_axis-label:before {
content: var(--psp-label--alt-axis--content, "Alt Axis");
}
}
@@ -0,0 +1,274 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::cell::RefCell;
use std::collections::HashSet;
use std::rc::Rc;
use perspective_client::clone;
use perspective_client::config::Expression;
use web_sys::*;
use yew::html::ImplicitClone;
use yew::prelude::*;
use super::column_selector::InPlaceColumn;
use super::portal::PortalModal;
use super::style::StyleSurface;
use crate::session::Session;
use crate::utils::*;
use crate::*;
/// Shared state for the column dropdown, updated imperatively.
#[derive(Default)]
pub struct ColumnDropDownState {
pub values: Vec<InPlaceColumn>,
pub selected: usize,
pub width: f64,
pub on_select: Option<Callback<InPlaceColumn>>,
pub target: Option<HtmlElement>,
pub no_results: bool,
}
/// A clonable handle for the column dropdown shared state.
#[derive(Clone)]
pub struct ColumnDropDownElement {
state: Rc<RefCell<ColumnDropDownState>>,
session: Session,
notify: Rc<PubSub<()>>,
}
impl PartialEq for ColumnDropDownElement {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.state, &other.state)
}
}
impl ImplicitClone for ColumnDropDownElement {}
impl ColumnDropDownElement {
pub fn new(session: Session) -> Self {
Self {
state: Default::default(),
session,
notify: Rc::default(),
}
}
pub fn autocomplete(
&self,
target: HtmlInputElement,
exclude: HashSet<String>,
callback: Callback<InPlaceColumn>,
) -> Option<()> {
let input = target.value();
let metadata = self.session.metadata();
let mut values: Vec<InPlaceColumn> = vec![];
let small_input = input.to_lowercase();
for col in metadata.get_table_columns()? {
if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
values.push(InPlaceColumn::Column(col.to_owned()));
}
}
for col in self.session.metadata().get_expression_columns() {
if !exclude.contains(col) && col.to_lowercase().contains(&small_input) {
values.push(InPlaceColumn::Column(col.to_owned()));
}
}
clone!(self.state, self.session, self.notify);
let target_elem: HtmlElement = target.clone().into();
let width = target.get_bounding_client_rect().width();
ApiFuture::spawn(async move {
if !exclude.contains(&input) {
let is_expr = crate::queries::validate_expr(&session, &input)
.await?
.is_none();
if is_expr {
values.push(InPlaceColumn::Expression(Expression::new(
None,
input.into(),
)));
}
}
let no_results = values.is_empty();
{
let mut s = state.borrow_mut();
s.values = values;
s.selected = 0;
s.width = width;
s.on_select = Some(callback);
s.target = Some(target_elem);
s.no_results = no_results;
}
notify.emit(());
Ok(())
});
Some(())
}
pub fn item_select(&self) {
let state = self.state.borrow();
if let Some(value) = state.values.get(state.selected)
&& let Some(ref cb) = state.on_select
{
cb.emit(value.clone());
}
}
pub fn item_down(&self) {
let mut state = self.state.borrow_mut();
state.selected += 1;
if state.selected >= state.values.len() {
state.selected = 0;
}
drop(state);
self.notify.emit(());
}
pub fn item_up(&self) {
let mut state = self.state.borrow_mut();
if state.selected < 1 {
state.selected = state.values.len();
}
state.selected -= 1;
drop(state);
self.notify.emit(());
}
pub fn hide(&self) -> ApiResult<()> {
self.state.borrow_mut().target = None;
self.notify.emit(());
Ok(())
}
}
/// A portal component that renders the column dropdown. Should be placed in
/// the view of the component that creates the `ColumnDropDownElement`.
#[derive(Properties, PartialEq)]
pub struct ColumnDropDownPortalProps {
pub element: ColumnDropDownElement,
pub theme: String,
}
pub struct ColumnDropDownPortal {
_sub: Subscription,
}
impl Component for ColumnDropDownPortal {
type Message = ();
type Properties = ColumnDropDownPortalProps;
fn create(ctx: &Context<Self>) -> Self {
let link = ctx.link().clone();
let sub = ctx
.props()
.element
.notify
.add_listener(move |()| link.send_message(()));
Self { _sub: sub }
}
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let state = ctx.props().element.state.borrow();
let target = state.target.clone();
let on_close = {
let element = ctx.props().element.clone();
Callback::from(move |()| {
let _ = element.hide();
})
};
if target.is_some() {
let values = state.values.clone();
let selected = state.selected;
let width = state.width;
let on_select = state.on_select.clone();
drop(state);
html! {
<PortalModal
tag_name="perspective-dropdown"
surface={StyleSurface::ColumnDropdown}
{target}
own_focus=false
{on_close}
theme={ctx.props().theme.clone()}
>
<ColumnDropDownView {values} {selected} {width} {on_select} />
</PortalModal>
}
} else {
html! {}
}
}
}
/// Pure view component for the column dropdown content.
#[derive(Properties, PartialEq)]
struct ColumnDropDownViewProps {
values: Vec<InPlaceColumn>,
selected: usize,
width: f64,
on_select: Option<Callback<InPlaceColumn>>,
}
#[function_component]
fn ColumnDropDownView(props: &ColumnDropDownViewProps) -> Html {
let body = html! {
if !props.values.is_empty() {
{ for props.values
.iter()
.enumerate()
.map(|(idx, value)| {
let click = props.on_select.as_ref().unwrap().reform({
let value = value.clone();
move |_: MouseEvent| value.clone()
});
let row = match value {
InPlaceColumn::Column(col) => html! {
<span>{ col }</span>
},
InPlaceColumn::Expression(col) => html! {
<span id="add-expression"><span class="icon" />{ col.name.clone() }</span>
},
};
html! {
if idx == props.selected {
<span onmousedown={click} class="selected">{ row }</span>
} else {
<span onmousedown={click}>{ row }</span>
}
}
}) }
} else {
<span class="no-results" />
}
};
let position = format!(
":host{{min-width:{}px;max-width:{}px}}",
props.width, props.width
);
html! { <><style>{ position }</style>{ body }</> }
}
@@ -0,0 +1,535 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod active_column;
mod add_expression_button;
mod aggregate_selector;
mod config_selector;
mod empty_column;
mod expr_edit_button;
mod filter_column;
mod inactive_column;
mod invalid_column;
mod pivot_column;
mod sort_column;
use std::iter::*;
use std::rc::Rc;
pub use empty_column::*;
pub use invalid_column::*;
use perspective_client::config::ViewConfig;
use perspective_js::utils::ApiFuture;
pub use pivot_column::*;
use web_sys::*;
use yew::prelude::*;
use self::active_column::*;
use self::add_expression_button::AddExpressionButton;
use self::config_selector::ConfigSelector;
use self::inactive_column::*;
use super::containers::scroll_panel::*;
use super::containers::split_panel::{Orientation, SplitPanel};
use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
use crate::components::containers::scroll_panel_item::ScrollPanelItem;
use crate::presentation::{ColumnLocator, DragDropContainer, Presentation};
use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet};
use crate::renderer::*;
use crate::session::drag_drop_update::*;
use crate::session::*;
use crate::utils::*;
#[derive(Properties)]
pub struct ColumnSelectorProps {
/// Fires when the expression/config column is open.
pub on_open_expr_panel: Callback<ColumnLocator>,
/// This is passed to the add_expression_button for styling.
pub selected_column: Option<ColumnLocator>,
/// Value props threaded from root's `SessionProps` / `RendererProps`.
pub has_table: Option<TableLoadState>,
pub named_column_count: usize,
pub view_config: PtrEqRc<ViewConfig>,
pub drag_column: Option<String>,
/// Cloned session metadata snapshot — threaded from `SessionProps`
/// so that metadata changes trigger re-renders via prop diffing.
pub metadata: SessionMetadataRc,
/// Selected theme name, threaded for PortalModal consumers.
pub selected_theme: Option<String>,
// State
pub session: Session,
pub renderer: Renderer,
pub presentation: Presentation,
/// Fires when this component is resized via the UI.
#[prop_or_default]
pub on_resize: Option<Rc<PubSub<()>>>,
/// Trap-door width pinned by the parent `SettingsPanel` so switching
/// tabs doesn't shrink the panel. Threaded into the inner
/// `ScrollPanel` as `initial_width`.
#[prop_or_default]
pub initial_width: f64,
/// Fires when the inner `ScrollPanel` measures its natural width.
/// Routed up to `SettingsPanel` which keeps the running max.
#[prop_or_default]
pub on_auto_width: Callback<f64>,
/// External "release the trap-door" signal from the outer settings
/// split-panel divider reset. Forwarded into `self.on_reset` so both
/// inner `ScrollPanel`s drop their cached `viewport_width`.
#[prop_or_default]
pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
}
impl PartialEq for ColumnSelectorProps {
fn eq(&self, rhs: &Self) -> bool {
self.selected_column == rhs.selected_column
&& self.has_table == rhs.has_table
&& self.named_column_count == rhs.named_column_count
&& self.view_config == rhs.view_config
&& self.drag_column == rhs.drag_column
&& self.metadata == rhs.metadata
&& self.selected_theme == rhs.selected_theme
&& self.initial_width == rhs.initial_width
}
}
#[derive(Debug)]
pub enum ColumnSelectorMsg {
/// Triggers a plain re-render; used as `onselect`/`ondragenter` callbacks
/// from `ConfigSelector` after it mutates the view config.
Redraw,
HoverActiveIndex(Option<usize>),
Drop((String, DragTarget, DragEffect, usize)),
}
use ColumnSelectorMsg::*;
/// A `ColumnSelector` controls the `columns` field of the `ViewConfig`,
/// deriving its options from the table columns and `ViewConfig` expressions.
pub struct ColumnSelector {
_subscriptions: Vec<Subscription>,
drag_container: DragDropContainer,
column_dropdown: ColumnDropDownElement,
on_reset: Rc<PubSub<()>>,
}
impl Component for ColumnSelector {
type Message = ColumnSelectorMsg;
type Properties = ColumnSelectorProps;
fn create(ctx: &Context<Self>) -> Self {
let ColumnSelectorProps {
presentation,
session,
..
} = ctx.props();
let drop_sub = {
let cb = ctx.link().callback(ColumnSelectorMsg::Drop);
presentation.drop_received.add_listener(cb)
};
let drag_container = DragDropContainer::new(|| {}, {
let link = ctx.link().clone();
move || link.send_message(ColumnSelectorMsg::HoverActiveIndex(None))
});
let column_dropdown = ColumnDropDownElement::new(session.clone());
let on_reset: Rc<PubSub<()>> = Default::default();
let mut subscriptions = vec![drop_sub];
if let Some(outer_reset) = ctx.props().on_dimensions_reset.as_ref() {
let on_reset = on_reset.clone();
subscriptions.push(outer_reset.add_listener(move |()| on_reset.emit(())));
}
Self {
_subscriptions: subscriptions,
drag_container,
column_dropdown,
on_reset,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Redraw => true,
HoverActiveIndex(Some(to_index)) => ctx
.props()
.presentation
.notify_drag_enter(DragTarget::Active, to_index),
HoverActiveIndex(_) => {
ctx.props()
.presentation
.notify_drag_leave(DragTarget::Active);
true
},
Drop((column, DragTarget::Active, DragEffect::Move(DragTarget::Active), index)) => {
let is_invalid = {
let config = &ctx.props().view_config;
let from_index = config
.columns
.iter()
.position(|x| x.as_ref() == Some(&column));
let min_cols = ctx.props().renderer.metadata().min_config_columns;
let is_to_empty = !config
.columns
.get(index)
.map(|x| x.is_some())
.unwrap_or_default();
min_cols
.and_then(|x| from_index.map(|fi| fi < x))
.unwrap_or_default()
&& is_to_empty
};
if !is_invalid {
let col_type = ctx
.props()
.metadata
.get_column_table_type(column.as_str())
.unwrap();
let update = ctx.props().view_config.create_drag_drop_update(
column,
col_type,
index,
DragTarget::Active,
DragEffect::Move(DragTarget::Active),
&ctx.props().renderer.metadata(),
ctx.props().metadata.get_features().unwrap(),
);
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
true
},
Drop((column, DragTarget::Active, effect, index)) => {
let col_type = ctx
.props()
.metadata
.get_column_table_type(column.as_str())
.unwrap();
let update = ctx.props().view_config.create_drag_drop_update(
column,
col_type,
index,
DragTarget::Active,
effect,
&ctx.props().renderer.metadata(),
ctx.props().metadata.get_features().unwrap(),
);
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
true
},
Drop((_, _, DragEffect::Move(DragTarget::Active), _)) => true,
Drop((..)) => true,
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let ColumnSelectorProps {
session,
renderer,
presentation,
..
} = ctx.props();
let metadata = &ctx.props().metadata;
// When `config.columns` is empty but the table has columns (transient
// state during `load()` after `reset()` clears the config), fill in
// all table columns as active — matching `validate_view_config()`.
let prop_config = &ctx.props().view_config;
let config = if prop_config.columns.is_empty() {
if let Some(table_cols) = metadata.get_table_columns() {
ViewConfig {
columns: table_cols.iter().map(|c| Some(c.clone())).collect(),
..(**prop_config).clone()
}
.into()
} else {
prop_config.clone()
}
} else {
prop_config.clone()
};
let is_aggregated = config.is_aggregated();
let columns_iter = ColumnsIteratorSet::new(&config, metadata, renderer, presentation);
let onselect = ctx.link().callback(|()| Redraw);
let ondragenter = ctx.link().callback(HoverActiveIndex);
let ondragover = Callback::from(|_event: DragEvent| _event.prevent_default());
let ondrop = Callback::from({
clone!(presentation);
move |event| presentation.notify_drop(&event)
});
let ondragend = Callback::from({
clone!(presentation);
move |_| presentation.notify_drag_end()
});
let mut active_classes = classes!("scrollable");
if ctx.props().drag_column.is_some() {
active_classes.push("dragdrop-highlight");
};
if is_aggregated {
active_classes.push("is-aggregated");
}
let size_hint = 28.0f64.mul_add(
(config.group_by.len()
+ config.split_by.len()
+ config.filter.len()
+ config.sort.len()) as f64,
metadata
.get_features()
.map(|x| {
let mut y = 0.0;
if !x.filter_ops.is_empty() {
y += 1.0;
}
if x.group_by {
y += 1.0;
}
if x.split_by {
y += 1.0;
}
if x.sort {
y += 1.0;
}
y * 55.0
})
.unwrap_or_default(),
);
let config_selector = html_nested! {
<ScrollPanelItem key="config_selector" {size_hint}>
<ConfigSelector
onselect={onselect.clone()}
ondragenter={ctx.link().callback(|()| Redraw)}
view_config={ctx.props().view_config.clone()}
drag_column={ctx.props().drag_column.clone()}
metadata={metadata.clone()}
selected_theme={ctx.props().selected_theme.clone()}
{presentation}
{renderer}
{session}
/>
</ScrollPanelItem>
};
let mut named_count = ctx.props().named_column_count;
let mut active_columns: Vec<_> = columns_iter
.active()
.enumerate()
.map(|(idx, name): (usize, ActiveColumnState)| {
let ondragenter = ondragenter.reform(move |_| Some(idx));
let size_hint = if named_count > 0 { 50.0 } else { 28.0 };
named_count = named_count.saturating_sub(1);
let key = name
.get_name()
.map(|x| x.to_owned())
.unwrap_or_else(|| format!("__auto_{idx}__"));
let column_dropdown = self.column_dropdown.clone();
let is_editing = matches!(
&ctx.props().selected_column,
Some(ColumnLocator::Table(x)) | Some(ColumnLocator::Expression(x))
if x == &key );
// Compute metadata-derived props here so that changes to
// session metadata propagate via prop diffing.
// For DragOver placeholders, resolve the type from the
// dragged column (since `get_name()` returns `None`).
let col_type = name
.get_name()
.and_then(|n| metadata.get_column_table_type(n))
.or_else(|| {
if matches!(name.state, ActiveColumnStateData::DragOver) {
presentation
.get_drag_column()
.and_then(|c| metadata.get_column_table_type(&c))
} else {
None
}
});
let is_expression = name
.get_name()
.map(|n| metadata.is_column_expression(n))
.unwrap_or(false);
let can_render_styles =
name.get_name().is_some() && renderer.can_render_column_styles();
let show_edit_btn = is_expression || can_render_styles;
let on_open_expr_panel = &ctx.props().on_open_expr_panel;
html_nested! {
<ScrollPanelItem {key} {size_hint}>
<ActiveColumn
{column_dropdown}
{idx}
{is_aggregated}
{is_editing}
{is_expression}
{show_edit_btn}
{col_type}
view_config={config.clone()}
metadata={metadata.clone()}
{name}
{on_open_expr_panel}
{ondragenter}
ondragend={&ondragend}
onselect={&onselect}
{presentation}
{renderer}
{session}
/>
</ScrollPanelItem>
}
})
.collect();
let mut inactive_children: Vec<_> = columns_iter
.expression()
.chain(columns_iter.inactive())
.enumerate()
.map(|(idx, vc)| {
let selected_column = ctx.props().selected_column.as_ref();
let is_editing = matches!(selected_column, Some(ColumnLocator::Expression(x)) if x.as_str() == vc.name);
let is_expression = metadata.is_column_expression(vc.name);
html_nested! {
<ScrollPanelItem key={vc.name} size_hint=28.0>
<InactiveColumn
{idx}
visible={vc.is_visible}
name={vc.name.to_owned()}
{is_editing}
{is_expression}
view_config={config.clone()}
metadata={metadata.clone()}
onselect={&onselect}
ondragend={&ondragend}
on_open_expr_panel={&ctx.props().on_open_expr_panel}
{presentation}
{renderer}
{session}
/>
</ScrollPanelItem>
}
})
.collect();
let size = 28.0;
let add_column = if metadata.get_features().unwrap().expressions {
html_nested! {
<ScrollPanelItem key="__add_expression__" size_hint={size}>
<AddExpressionButton
on_open_expr_panel={&ctx.props().on_open_expr_panel}
selected_column={ctx.props().selected_column.clone()}
/>
</ScrollPanelItem>
}
} else {
html_nested! {
<ScrollPanelItem key="__add_expression__" size_hint=0_f64><span /></ScrollPanelItem>
}
};
if inactive_children.is_empty() {
active_columns.push(add_column)
} else {
inactive_children.insert(0, add_column);
}
let mut selected_columns = vec![html! {
<div id="selected-columns" key="__active_columns__">
<ScrollPanel
id="active-columns"
omit_autosize_div={true}
class={active_classes}
dragover={ondragover}
dragenter={&self.drag_container.dragenter}
dragleave={&self.drag_container.dragleave}
viewport_ref={&self.drag_container.noderef}
initial_width={ctx.props().initial_width}
on_auto_width={ctx.props().on_auto_width.clone()}
drop={ondrop}
on_resize={&ctx.props().on_resize}
on_dimensions_reset={&self.on_reset}
children={std::iter::once(config_selector).chain(active_columns).collect::<Vec<_>>()}
/>
</div>
}];
if !inactive_children.is_empty() {
selected_columns.push(html! {
<ScrollPanel
id="sub-columns"
key="__sub_columns__"
class={classes!("scrollable")}
on_resize={&ctx.props().on_resize}
on_dimensions_reset={&self.on_reset}
children={inactive_children}
/>
})
}
html! {
<>
<SplitPanel
no_wrap=true
on_reset={self.on_reset.callback()}
skip_empty=true
orientation={Orientation::Vertical}
>
{ for selected_columns }
</SplitPanel>
<ColumnDropDownPortal
element={self.column_dropdown.clone()}
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
/>
</>
}
}
}
@@ -0,0 +1,489 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use perspective_client::config::*;
use perspective_js::utils::ApiFuture;
use web_sys::*;
use yew::prelude::*;
use super::InPlaceColumn;
use super::aggregate_selector::*;
use super::expr_edit_button::*;
use crate::components::column_dropdown::ColumnDropDownElement;
use crate::components::column_selector::{EmptyColumn, InvalidColumn};
use crate::components::type_icon::TypeIcon;
use crate::config::ColumnSelectMode;
use crate::presentation::{ColumnLocator, Presentation};
use crate::queries::*;
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;
#[derive(Clone, Properties)]
pub struct ActiveColumnProps {
/// The column's index in the list.
pub idx: usize,
/// The column definition (including name and type).
pub name: ActiveColumnState,
/// The column select dropdown menu element.
pub column_dropdown: ColumnDropDownElement,
/// `dragenter` event.
pub ondragenter: Callback<()>,
/// `dragend` event.
pub ondragend: Callback<()>,
/// Fires when this component's select button is clicked.
pub onselect: Callback<()>,
/// Fires when this component's expression/config button is clicked.
pub on_open_expr_panel: Callback<ColumnLocator>,
/// Is this column in a grouped context (does the aggregate selector
/// need to be visible)?
#[prop_or_default]
pub is_aggregated: bool,
/// Is this column's expression/config side panel open?
pub is_editing: bool,
/// Whether this column is an expression column. Computed by the parent
/// so that changes to session metadata trigger a re-render via prop diff.
#[prop_or_default]
pub is_expression: bool,
/// Whether the expression/config edit button should be shown. Computed
/// by the parent (`is_expression || can_render_column_styles`).
#[prop_or_default]
pub show_edit_btn: bool,
/// The resolved table column type, if available. Computed by the parent
/// from session metadata so that metadata updates trigger re-renders.
#[prop_or_default]
pub col_type: Option<ColumnType>,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// View config snapshot — threaded from parent as a value prop.
pub view_config: PtrEqRc<ViewConfig>,
/// State
pub session: Session,
pub presentation: Presentation,
pub renderer: Renderer,
}
impl PartialEq for ActiveColumnProps {
fn eq(&self, rhs: &Self) -> bool {
self.idx == rhs.idx
&& self.name == rhs.name
&& self.is_aggregated == rhs.is_aggregated
&& self.is_editing == rhs.is_editing
&& self.is_expression == rhs.is_expression
&& self.show_edit_btn == rhs.show_edit_btn
&& self.col_type == rhs.col_type
&& self.metadata == rhs.metadata
&& self.view_config == rhs.view_config
}
}
pub enum ActiveColumnMsg {
DeactivateColumn(String, bool),
MouseEnter(bool),
MouseLeave(bool),
New(InPlaceColumn),
}
use ActiveColumnMsg::*;
/// An [`ActiveColumn`] indicates a column which is part of the `columns` field
/// of a [`ViewConfig`]. It shows additional column details in context (like
/// selected aggregate), and supports drag/drop and missing entries.
/// TODO Break this into "Active", "Hover" and "Empty"?
pub struct ActiveColumn {
add_expression_ref: NodeRef,
mouseover: bool,
}
impl Component for ActiveColumn {
type Message = ActiveColumnMsg;
type Properties = ActiveColumnProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
add_expression_ref: NodeRef::default(),
mouseover: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: ActiveColumnMsg) -> bool {
match msg {
DeactivateColumn(column, shift_key) => {
ctx.props().deactivate_column(column, shift_key);
ctx.props().onselect.emit(());
false
},
MouseEnter(is_render) => {
self.mouseover = is_render;
is_render
},
MouseLeave(is_render) => {
self.mouseover = false;
is_render
},
New(InPlaceColumn::Column(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
if ctx.props().idx >= view_config.columns.len() {
view_config.columns.push(Some(col));
} else {
view_config.columns[ctx.props().idx] = Some(col);
}
let update = ViewConfigUpdate {
columns: Some(view_config.columns),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
true
},
New(InPlaceColumn::Expression(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
if ctx.props().idx >= view_config.columns.len() {
view_config.columns.push(Some(col.name.as_ref().to_owned()));
} else {
view_config.columns[ctx.props().idx] = Some(col.name.as_ref().to_owned());
}
view_config.expressions.insert(&col);
let update = ViewConfigUpdate {
columns: Some(view_config.columns),
expressions: Some(view_config.expressions),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
true
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
enum ColumnState {
Empty,
Invalid,
Named(String),
}
let mut classes = classes!["column-selector-draggable"];
if ctx.props().is_aggregated {
classes.push("show-aggregate");
};
let mut outer_classes = classes!["column-selector-column"];
if self.mouseover {
outer_classes.push("dragdrop-hover");
}
let name = match &ctx.props().name {
ActiveColumnState {
label,
state: ActiveColumnStateData::DragOver,
} => {
classes.push("dragover");
outer_classes.push("dragover-container");
classes.push("empty-named");
(
label.clone(),
ColumnState::Named(ctx.props().presentation.get_drag_column().unwrap()),
)
},
ActiveColumnState {
label,
state: ActiveColumnStateData::Column(name),
} => (label.clone(), ColumnState::Named(name.to_owned())),
ActiveColumnState {
label,
state: ActiveColumnStateData::Required,
} => (label.clone(), ColumnState::Empty),
ActiveColumnState {
label,
state: ActiveColumnStateData::Invalid,
} => (label.clone(), ColumnState::Invalid),
};
let ondragenter = ctx.props().ondragenter.reform(move |event: DragEvent| {
// Safari does not set `relatedTarget` so this event must be allowed to
// bubble so we can count entry/exit stacks to determine true
// `"dragleave"`.
if event.related_target().is_some() {
event.stop_propagation();
event.prevent_default();
}
});
let path: String = name
.0
.clone()
.unwrap_or_default()
.chars()
.map(|x| {
if x.is_alphanumeric() {
x.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let col_type = ctx.props().col_type;
match (name, col_type) {
((label, ColumnState::Empty), _) => {
classes.push("empty-named");
let column_dropdown = ctx.props().column_dropdown.clone();
let on_select = ctx.link().callback(ActiveColumnMsg::New);
let exclude = ctx
.props()
.view_config
.columns
.iter()
.flatten()
.cloned()
.collect::<HashSet<_>>();
html! {
<div
class={outer_classes}
data-label={label}
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
data-index={ctx.props().idx.to_string()}
ondragenter={ondragenter.clone()}
>
<EmptyColumn {column_dropdown} {exclude} {on_select} />
</div>
}
},
((label, ColumnState::Invalid), _) => {
classes.push("empty-named");
html! {
<div
class={outer_classes}
data-label={label}
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
data-index={ctx.props().idx.to_string()}
ondragenter={ondragenter.clone()}
>
<InvalidColumn />
</div>
}
},
((label, ColumnState::Named(name)), Some(col_type)) => {
let is_required = ctx.props().get_is_required(ctx.props().idx);
let remove_column = if is_required {
None
} else {
Some(ctx.link().callback({
let event_name = name.to_owned();
move |event: MouseEvent| {
ActiveColumnMsg::DeactivateColumn(
event_name.to_owned(),
event.shift_key(),
)
}
}))
};
let ondragend = &ctx.props().ondragend.reform(|_| ());
let ondragstart = ctx.link().callback({
let event_name = name.to_owned();
let presentation = ctx.props().presentation.clone();
move |event: DragEvent| {
presentation.set_drag_image(&event).unwrap();
presentation.notify_drag_start(
event_name.to_string(),
DragEffect::Move(DragTarget::Active),
);
MouseLeave(false)
}
});
let onmouseout = ctx.link().callback(|_| MouseLeave(true));
let onmouseover = ctx
.link()
.callback(|event: MouseEvent| MouseEnter(event.which() == 0));
let is_expression = ctx.props().is_expression;
let show_edit_btn = ctx.props().show_edit_btn;
let mut class = ctx.props().renderer.metadata().select_mode.css();
if is_required {
class.push("required");
};
if !is_required {
class.push("shift-alt-icon");
}
html! {
<div
class={outer_classes}
data-label={label}
style={format!("--default-column-title:var(--column-selector-column-{path}--content)")}
data-index={ctx.props().idx.to_string()}
{onmouseover}
{onmouseout}
ondragenter={ondragenter.clone()}
>
<span {class} onmousedown={remove_column} />
<div
class={classes}
ref={&self.add_expression_ref}
draggable="true"
{ondragstart}
{ondragend}
>
<div class="column-selector-column-border">
<span class="drag-handle icon" />
<TypeIcon ty={col_type} />
if ctx.props().is_aggregated {
<AggregateSelector
column={name.clone()}
aggregate={ctx.props().get_aggregate(&name)}
view_config={ctx.props().view_config.clone()}
metadata={ctx.props().metadata.clone()}
renderer={&ctx.props().renderer}
session={&ctx.props().session}
/>
}
<span
class="column_name"
>
{ name.clone() }
</span>
if !ctx.props().is_aggregated {
<span class="column-selector--spacer" />
}
<ExprEditButton
name={name.clone()}
on_open_expr_panel={&ctx.props().on_open_expr_panel}
{is_expression}
is_disabled={!show_edit_btn}
is_editing={ctx.props().is_editing}
/>
</div>
</div>
</div>
}
},
_ => {
// Expression columns are the only UI element which requires the
// `View` (for its expression type), we may need to stub these
// columns out until the new View forces a re-render (and the
// `change()` method on this component checks for this).
html! {
<div class="column-selector-column">
<span class="is_column_active inactive" />
<div class={classes} />
</div>
}
},
}
}
}
impl ActiveColumnProps {
/// Remove an active column from `columns`, or alternatively make this
/// column the only column in `columns` if the shift key is set (via the
/// `shift` flag).
///
/// # Arguments
/// - `name` The name of the column to de-activate, which is a unique ID
/// with respect to `columns`.
/// - `shift` whether to toggle or select this column.
fn deactivate_column(&self, name: String, shift: bool) {
let mut columns = self.view_config.columns.clone();
let max_cols = self.renderer.metadata().config_column_names.len();
match self.renderer.metadata().select_mode {
ColumnSelectMode::Toggle => {
let index = columns
.iter()
.position(|x| x.as_ref() == Some(&name))
.unwrap();
if max_cols > 0 && index < max_cols - 1 {
columns[index] = None;
} else if !shift && columns.len() > 1 {
columns.retain(|x| x.as_ref() != Some(&name));
} else if shift {
columns.clear();
columns.push(Some(name));
}
},
ColumnSelectMode::Select => {
columns.retain(|x| x.as_ref() != Some(&name));
},
}
self.apply_columns(columns);
}
fn get_is_required(&self, idx: usize) -> bool {
let min_cols = self.renderer.metadata().min_config_columns.unwrap_or(0);
idx < min_cols
}
fn get_aggregate(&self, name: &str) -> Option<Aggregate> {
self.view_config.aggregates.get(name).cloned()
}
fn apply_columns(&self, columns: Vec<Option<String>>) {
let config = ViewConfigUpdate {
columns: Some(columns),
..ViewConfigUpdate::default()
};
if self.session.update_view_config(config).is_ok() {
let session = self.session.clone();
let renderer = self.renderer.clone();
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
}
@@ -0,0 +1,65 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::prelude::*;
use super::ColumnLocator;
#[derive(Clone, PartialEq, Properties)]
pub struct AddExpressionButtonProps {
// Fires when this button is clicked.
pub on_open_expr_panel: Callback<ColumnLocator>,
/// The column this butotn will open configuration for.
pub selected_column: Option<ColumnLocator>,
}
/// `onmouseover` is triggered incorrectly on the `DragTarget` of a
/// drag/drop action after `DropEvent` has fired on the `DropTarget`,
/// causing brief hover effects where the mouse _was_ before the action
/// initiated. Various methods of correcting this were attempted, settling
/// on a manual `dragdrop-hover` class toggle, using the `.button()` property
/// to determine the mis-fired event. This was determined experimentally -
/// according to my read of the spec, this is a bug in Chrome.
///
/// As a result there are 3 hover states `MouseEnter(true)`,
/// `MouseEnter(false)`, and `MouseLeave`; `MouseEnter(false)` can be
/// replaced, but it causes an extra render of the DOM un-necessarily.
#[function_component]
pub fn AddExpressionButton(p: &AddExpressionButtonProps) -> Html {
let is_mouseover = yew::use_state_eq(|| false);
let onmouseover = yew::use_callback(is_mouseover.setter(), |event: MouseEvent, mo| {
mo.set(event.button() == 0);
});
let onmouseout = yew::use_callback(is_mouseover.setter(), |_event, mo| {
mo.set(false);
});
let onmousedown = p
.on_open_expr_panel
.reform(|_| ColumnLocator::NewExpression);
let class = if *is_mouseover || matches!(p.selected_column, Some(ColumnLocator::NewExpression))
{
classes!("dragdrop-hover")
} else {
classes!()
};
html! {
<div id="add-expression" data-index="-1" {class} {onmouseover} {onmouseout} {onmousedown}>
<span class="icon" />
<span id="add-expression-button" />
</div>
}
}
@@ -0,0 +1,197 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::rc::Rc;
use perspective_client::config::*;
use perspective_js::utils::ApiFuture;
use yew::prelude::*;
use crate::components::containers::select::*;
use crate::renderer::*;
use crate::session::*;
use crate::utils::PtrEqRc;
#[derive(Properties)]
pub struct AggregateSelectorProps {
/// The name of this aggregate.
pub column: String,
/// Which aggregate is currently selected.
pub aggregate: Option<Aggregate>,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// View config snapshot — threaded from parent as a value prop.
pub view_config: PtrEqRc<ViewConfig>,
// State
pub renderer: Renderer,
pub session: Session,
}
impl PartialEq for AggregateSelectorProps {
fn eq(&self, rhs: &Self) -> bool {
self.column == rhs.column
&& self.aggregate == rhs.aggregate
&& self.metadata == rhs.metadata
&& self.view_config == rhs.view_config
}
}
pub enum AggregateSelectorMsg {
SetAggregate(Aggregate),
}
pub struct AggregateSelector {
aggregates: Rc<Vec<SelectItem<Aggregate>>>,
aggregate: Option<Aggregate>,
}
impl Component for AggregateSelector {
type Message = AggregateSelectorMsg;
type Properties = AggregateSelectorProps;
fn create(ctx: &Context<Self>) -> Self {
let mut selector = Self {
aggregates: Rc::new(vec![]),
aggregate: ctx.props().aggregate.clone(),
};
selector.aggregates = Rc::new(selector.get_dropdown_aggregates(ctx));
selector
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
AggregateSelectorMsg::SetAggregate(aggregate) => {
self.set_aggregate(ctx, aggregate);
false
},
}
}
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
self.aggregates = Rc::new(self.get_dropdown_aggregates(ctx));
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let callback = ctx.link().callback(AggregateSelectorMsg::SetAggregate);
let selected_agg = ctx
.props()
.aggregate
.clone()
.or_else(|| {
ctx.props()
.metadata
.get_column_table_type(&ctx.props().column)
.and_then(|x| {
ctx.props().metadata.get_features().and_then(|y| {
y.aggregates.get(&(x as u32)).and_then(|z| {
z.aggregates
.first()
.map(|q| Aggregate::SingleAggregate(q.name.clone()))
})
})
})
})
.unwrap_or_else(|| Aggregate::SingleAggregate("".to_string()));
let values = self.aggregates.clone();
let label = ctx.props().aggregate.as_ref().map(|x| match x {
Aggregate::SingleAggregate(_) => "".to_string(),
Aggregate::MultiAggregate(x, _) => x.to_string(),
});
html! {
<>
<div class="aggregate-selector-wrapper">
<Select<Aggregate>
wrapper_class="aggregate-selector"
{values}
label={label.map(|x| x.into())}
selected={selected_agg}
on_select={callback}
/>
</div>
</>
}
}
}
impl AggregateSelector {
pub fn set_aggregate(&mut self, ctx: &Context<Self>, aggregate: Aggregate) {
self.aggregate = Some(aggregate.clone());
let mut aggregates = ctx.props().view_config.aggregates.clone();
aggregates.insert(ctx.props().column.clone(), aggregate);
let config = ViewConfigUpdate {
aggregates: Some(aggregates),
..ViewConfigUpdate::default()
};
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
pub fn get_dropdown_aggregates(&self, ctx: &Context<Self>) -> Vec<SelectItem<Aggregate>> {
let aggregates = ctx
.props()
.metadata
.get_column_aggregates(&ctx.props().column)
.map(|x| x.collect::<Vec<_>>())
.unwrap_or_default();
let multi_aggregates2 = aggregates
.clone()
.into_iter()
.flat_map(|x| match x {
Aggregate::MultiAggregate(x, _) => Some(x),
_ => None,
})
.collect::<HashSet<_>>()
.into_iter()
.map(|x| {
SelectItem::OptGroup(
x.clone().into(),
aggregates
.iter()
.filter(|y| {
matches!(
y,
Aggregate::MultiAggregate(z, _) if &x == z
)
})
.cloned()
.collect(),
)
})
.collect::<Vec<_>>();
let s = aggregates
.iter()
.filter(|x| matches!(x, Aggregate::SingleAggregate(_)))
.cloned()
.map(SelectItem::Option)
.chain(multi_aggregates2);
s.collect::<Vec<_>>()
}
}
@@ -0,0 +1,869 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::rc::Rc;
use perspective_client::config::{ViewConfig, *};
use perspective_js::utils::ApiFuture;
use yew::prelude::*;
use super::InPlaceColumn;
use super::filter_column::*;
use super::pivot_column::*;
use super::sort_column::*;
use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
use crate::components::containers::dragdrop_list::*;
use crate::components::containers::select::{Select, SelectItem};
use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal};
use crate::presentation::Presentation;
use crate::renderer::*;
use crate::session::drag_drop_update::*;
use crate::session::*;
use crate::utils::*;
#[derive(Clone, Properties)]
pub struct ConfigSelectorProps {
pub onselect: Callback<()>,
#[prop_or_default]
pub ondragenter: Callback<()>,
/// Current view config threaded as a value prop so that config changes
/// (group_by, sort, filter, etc.) trigger re-renders via normal prop
/// diffing rather than a PubSub `view_created` subscription.
pub view_config: PtrEqRc<ViewConfig>,
/// Column currently being dragged — threaded to show `dragdrop-highlight`
/// without subscribing to `dragstart_received`/`dragend_received`.
pub drag_column: Option<String>,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// Selected theme name, threaded for PortalModal consumers.
pub selected_theme: Option<String>,
// State
pub session: Session,
pub renderer: Renderer,
pub presentation: Presentation,
}
impl PartialEq for ConfigSelectorProps {
fn eq(&self, other: &Self) -> bool {
self.view_config == other.view_config
&& self.drag_column == other.drag_column
&& self.metadata == other.metadata
&& self.selected_theme == other.selected_theme
}
}
#[derive(Debug)]
pub enum ConfigSelectorMsg {
DragOver(usize, DragTarget),
DragLeave(DragTarget),
Drop(String, DragTarget, DragEffect, usize),
Close(usize, DragTarget),
SetFilterValue(usize, String),
TransposePivots,
New(DragTarget, InPlaceColumn),
UpdateGroupRollupMode(GroupRollupMode),
}
#[derive(Clone)]
pub struct ConfigSelector {
filter_dropdown: FilterDropDownElement,
column_dropdown: ColumnDropDownElement,
_subscriptions: [Rc<Subscription>; 1],
}
impl Component for ConfigSelector {
type Message = ConfigSelectorMsg;
type Properties = ConfigSelectorProps;
fn create(ctx: &Context<Self>) -> Self {
let cb = ctx
.link()
.callback(|x: (String, DragTarget, DragEffect, usize)| {
ConfigSelectorMsg::Drop(x.0, x.1, x.2, x.3)
});
let drop_sub = Rc::new(ctx.props().presentation.drop_received.add_listener(cb));
let filter_dropdown = FilterDropDownElement::new(ctx.props().session.clone());
let column_dropdown = ColumnDropDownElement::new(ctx.props().session.clone());
let _subscriptions = [drop_sub];
Self {
filter_dropdown,
column_dropdown,
_subscriptions,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ConfigSelectorMsg::DragOver(index, action) => {
let should_render = ctx.props().presentation.notify_drag_enter(action, index);
if should_render {
ctx.props().ondragenter.emit(());
}
should_render
},
ConfigSelectorMsg::DragLeave(action) => {
ctx.props().presentation.notify_drag_leave(action);
true
},
ConfigSelectorMsg::Close(index, DragTarget::Sort) => {
let mut sort = ctx.props().view_config.sort.clone();
sort.remove(index);
let sort = Some(sort);
let config = ViewConfigUpdate {
sort,
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::UpdateGroupRollupMode(mode) => {
let config = ViewConfigUpdate {
group_rollup_mode: Some(mode),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
false
},
ConfigSelectorMsg::Close(index, DragTarget::GroupBy) => {
if ctx.props().view_config.group_rollup_mode == GroupRollupMode::Total {
let requirements = ctx.props().renderer.metadata();
let rollup_features = ctx
.props()
.metadata
.get_features()
.map(|x| x.get_group_rollup_modes())
.unwrap();
let group_rollups = requirements.get_group_rollups(&rollup_features);
ctx.link()
.send_message(ConfigSelectorMsg::UpdateGroupRollupMode(
group_rollups.first().cloned().unwrap(),
));
false
} else {
let mut group_by = ctx.props().view_config.group_by.clone();
group_by.remove(index);
let config = ViewConfigUpdate {
group_by: Some(group_by),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
}
},
ConfigSelectorMsg::Close(index, DragTarget::SplitBy) => {
let mut split_by = ctx.props().view_config.split_by.clone();
split_by.remove(index);
let config = ViewConfigUpdate {
split_by: Some(split_by),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::Close(index, DragTarget::Filter) => {
self.filter_dropdown.hide().unwrap();
let mut filter = ctx.props().view_config.filter.clone();
filter.remove(index);
let config = ViewConfigUpdate {
filter: Some(filter),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(config).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::Close(..) => false,
ConfigSelectorMsg::Drop(column, action, effect, index)
if action != DragTarget::Active =>
{
let col_type = ctx
.props()
.metadata
.get_column_table_type(column.as_str())
.unwrap();
let update = ctx.props().view_config.create_drag_drop_update(
column,
col_type,
index,
action,
effect,
&ctx.props().renderer.metadata(),
ctx.props().metadata.get_features().unwrap(),
);
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::Drop(_, _, DragEffect::Move(action), _)
if action != DragTarget::Active =>
{
true
},
ConfigSelectorMsg::Drop(..) => false,
ConfigSelectorMsg::TransposePivots => {
let mut view_config = (*ctx.props().view_config).clone();
std::mem::swap(&mut view_config.group_by, &mut view_config.split_by);
let update = ViewConfigUpdate {
group_by: Some(view_config.group_by),
split_by: Some(view_config.split_by),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::SetFilterValue(index, input) => {
let mut filter = ctx.props().view_config.filter.clone();
// TODO Can't special case these - need to make this part of the
// Features API.
let update = if filter[index].op() == "in" || filter[index].op() == "not in" {
let current = filter[index].term().to_string();
let mut tokens = current.split(',').collect::<Vec<_>>();
tokens.pop();
tokens.push(&input);
*filter[index].term_mut() = FilterTerm::Array(
tokens
.iter()
.map(|x| Scalar::String(x.trim().to_owned()))
.collect(),
);
let filter = Some(filter);
ViewConfigUpdate {
filter,
..ViewConfigUpdate::default()
}
} else {
*filter[index].term_mut() = FilterTerm::Scalar(Scalar::String(input));
let filter = Some(filter);
ViewConfigUpdate {
filter,
..ViewConfigUpdate::default()
}
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
false
},
ConfigSelectorMsg::New(DragTarget::GroupBy, InPlaceColumn::Column(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config.group_by.push(col);
let update = ViewConfigUpdate {
group_by: Some(view_config.group_by),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::SplitBy, InPlaceColumn::Column(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config.split_by.push(col);
let update = ViewConfigUpdate {
split_by: Some(view_config.split_by),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::Filter, InPlaceColumn::Column(column)) => {
let mut view_config = (*ctx.props().view_config).clone();
let op = ctx.props().default_op(column.as_str()).unwrap_or_default();
view_config.filter.push(Filter::new(
&column,
&op,
FilterTerm::Scalar(Scalar::Null),
));
let update = ViewConfigUpdate {
filter: Some(view_config.filter),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::Sort, InPlaceColumn::Column(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config.sort.push(Sort(col, SortDir::Asc));
let update = ViewConfigUpdate {
sort: Some(view_config.sort),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::GroupBy, InPlaceColumn::Expression(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config.group_by.push(col.name.as_ref().to_owned());
view_config.expressions.insert(&col);
let update = ViewConfigUpdate {
group_by: Some(view_config.group_by),
expressions: Some(view_config.expressions),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::SplitBy, InPlaceColumn::Expression(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config.split_by.push(col.name.as_ref().to_owned());
view_config.expressions.insert(&col);
let update = ViewConfigUpdate {
split_by: Some(view_config.split_by),
expressions: Some(view_config.expressions),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::Filter, InPlaceColumn::Expression(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
let column = col.name.as_ref();
view_config.filter.push(Filter::new(
column,
&ctx.props()
.default_op(col.name.as_ref())
.unwrap_or_default(),
FilterTerm::Scalar(Scalar::Null),
));
view_config.expressions.insert(&col);
let update = ViewConfigUpdate {
filter: Some(view_config.filter),
expressions: Some(view_config.expressions),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::Sort, InPlaceColumn::Expression(col)) => {
let mut view_config = (*ctx.props().view_config).clone();
view_config
.sort
.push(Sort(col.name.as_ref().to_owned(), SortDir::Asc));
view_config.expressions.insert(&col);
let update = ViewConfigUpdate {
sort: Some(view_config.sort),
expressions: Some(view_config.expressions),
..ViewConfigUpdate::default()
};
{
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
ctx.props().onselect.emit(());
false
},
ConfigSelectorMsg::New(DragTarget::Active, _) => false,
}
}
// /// Should not render on change, as this component only depends on service
// /// state.
// fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool
// { false
// }
fn view(&self, ctx: &Context<Self>) -> Html {
let ConfigSelectorProps {
presentation,
renderer,
session,
..
} = ctx.props();
let config = &ctx.props().view_config;
let transpose = ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots);
let column_dropdown = self.column_dropdown.clone();
let mut class = classes!();
if ctx.props().drag_column.is_some() {
class.push("dragdrop-highlight");
}
if config.group_rollup_mode == GroupRollupMode::Total {
class.push("group-rollup-mode-total");
}
let dragend = Callback::from({
let presentation = presentation.clone();
move |_event| presentation.notify_drag_end()
});
let metadata = &ctx.props().metadata;
let features = metadata.get_features().unwrap();
let requirements = renderer.metadata();
let on_group_rollup_mode = ctx
.link()
.callback(ConfigSelectorMsg::UpdateGroupRollupMode);
let rollup_features = metadata
.get_features()
.map(|x| x.get_group_rollup_modes())
.unwrap();
let group_rollups = requirements.get_group_rollups(&rollup_features);
html! {
<>
<div slot="top_panel" id="top_panel" {class} ondragend={dragend}>
<div class="pivot_controls">
if group_rollups.len() > 1 {
<Select<GroupRollupMode>
id="group_rollup_mode_selector"
wrapper_class="group_rollup_wrapper"
is_autosize=true
values={Rc::new(
group_rollups
.iter()
.map(|x| SelectItem::Option(*x))
.collect(),
)}
selected={config.group_rollup_mode}
on_select={on_group_rollup_mode}
/>
}
if !config.group_by.is_empty() && config.split_by.is_empty() {
<span
id="transpose_button"
class="rrow centered"
title="Transpose Pivots"
onmousedown={transpose.clone()}
/>
}
</div>
if features.group_by {
<GroupBySelector
name="group_by"
disabled={config.group_rollup_mode == GroupRollupMode::Total}
parent={ctx.link().clone()}
column_dropdown={column_dropdown.clone()}
exclude={config.group_by.iter().cloned().collect::<HashSet<_>>()}
is_dragover={ctx.props().presentation.is_dragover(DragTarget::GroupBy)}
{presentation}
>
{ for config.group_by.iter().map(|group_by| {
html_nested! {
<PivotColumn
action={DragTarget::GroupBy}
column={group_by.clone()}
metadata={metadata.clone()}
{presentation}
opt_session={session}
>
</PivotColumn>
}
}) }
</GroupBySelector>
}
if features.split_by {
if !config.split_by.is_empty() {
<div class="pivot_controls">
<span
id="transpose_button"
class="rrow centered"
title="Transpose Pivots"
onmousedown={transpose}
/>
</div>
}
<SplitBySelector
name="split_by"
parent={ctx.link().clone()}
column_dropdown={column_dropdown.clone()}
exclude={config.split_by.iter().cloned().collect::<HashSet<_>>()}
is_dragover={presentation.is_dragover(DragTarget::SplitBy)}
{presentation}
>
{ for config.split_by.iter().map(|split_by| {
html_nested! {
<PivotColumn
action={ DragTarget::SplitBy }
column={ split_by.clone() }
metadata={metadata.clone()}
{presentation}
opt_session={session}>
</PivotColumn>
}
}) }
</SplitBySelector>
}
if features.sort {
<SortSelector
name="sort"
allow_duplicates=true
parent={ctx.link().clone()}
column_dropdown={column_dropdown.clone()}
exclude={config.sort.iter().map(|x| x.0.clone()).collect::<HashSet<_>>()}
is_dragover={presentation.is_dragover(DragTarget::Sort).map(|(index, name)| {
(index, Sort(name, SortDir::Asc))
})}
{presentation}
>
{ for config.sort.iter().enumerate().map(|(idx, sort)| {
html_nested! {
<SortColumn
idx={ idx }
sort={ sort.clone() }
view_config={config.clone()}
metadata={metadata.clone()}
{presentation}
{renderer}
{session}>
</SortColumn>
}
}) }
</SortSelector>
}
if !features.filter_ops.is_empty() {
<FilterSelector
name="filter"
allow_duplicates=true
parent={ctx.link().clone()}
{column_dropdown}
exclude={config.filter.iter().map(|x| x.column().to_string()).collect::<HashSet<_>>()}
is_dragover={presentation.is_dragover(DragTarget::Filter).map(|(index, name)| {
(index, Filter::new(&name, "", FilterTerm::Scalar(Scalar::Null)))
})}
{presentation}
>
{ for config.filter.iter().enumerate().map(|(idx, filter)| {
let filter_keydown = ctx.link()
.callback(move |txt| ConfigSelectorMsg::SetFilterValue(idx, txt));
html_nested! {
<FilterColumn
idx={ idx }
filter_dropdown={ &self.filter_dropdown }
filter={ filter.clone() }
on_keydown={ filter_keydown }
view_config={config.clone()}
metadata={metadata.clone()}
{presentation}
{renderer}
{session}>
</FilterColumn>
}
}) }
</FilterSelector>
}
</div>
<ColumnDropDownPortal
element={self.column_dropdown.clone()}
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
/>
<FilterDropDownPortal
element={self.filter_dropdown.clone()}
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
/>
</>
}
}
}
impl ConfigSelectorProps {
fn default_op(&self, column: &str) -> Option<String> {
let features = self.metadata.get_features()?;
let col_type = self.metadata.get_column_table_type(column)?;
let first = features.default_op(col_type)?;
Some(first.to_string())
}
}
struct GroupByContext {}
struct SplitByContext {}
struct SortDragContext {}
struct FilterDragContext {}
impl DragContext<ConfigSelectorMsg> for GroupByContext {
fn dragenter(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::DragOver(index, DragTarget::GroupBy)
}
fn close(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::Close(index, DragTarget::GroupBy)
}
fn dragleave() -> ConfigSelectorMsg {
ConfigSelectorMsg::DragLeave(DragTarget::GroupBy)
}
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
ConfigSelectorMsg::New(DragTarget::GroupBy, col)
}
fn is_self_move(target: DragTarget) -> bool {
target == DragTarget::GroupBy
}
}
impl DragContext<ConfigSelectorMsg> for SplitByContext {
fn dragenter(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::DragOver(index, DragTarget::SplitBy)
}
fn close(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::Close(index, DragTarget::SplitBy)
}
fn dragleave() -> ConfigSelectorMsg {
ConfigSelectorMsg::DragLeave(DragTarget::SplitBy)
}
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
ConfigSelectorMsg::New(DragTarget::SplitBy, col)
}
fn is_self_move(target: DragTarget) -> bool {
target == DragTarget::SplitBy
}
}
impl DragContext<ConfigSelectorMsg> for SortDragContext {
fn dragenter(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::DragOver(index, DragTarget::Sort)
}
fn close(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::Close(index, DragTarget::Sort)
}
fn dragleave() -> ConfigSelectorMsg {
ConfigSelectorMsg::DragLeave(DragTarget::Sort)
}
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
ConfigSelectorMsg::New(DragTarget::Sort, col)
}
fn is_self_move(target: DragTarget) -> bool {
target == DragTarget::Sort
}
}
impl DragContext<ConfigSelectorMsg> for FilterDragContext {
fn dragenter(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::DragOver(index, DragTarget::Filter)
}
fn close(index: usize) -> ConfigSelectorMsg {
ConfigSelectorMsg::Close(index, DragTarget::Filter)
}
fn dragleave() -> ConfigSelectorMsg {
ConfigSelectorMsg::DragLeave(DragTarget::Filter)
}
fn create(col: InPlaceColumn) -> ConfigSelectorMsg {
ConfigSelectorMsg::New(DragTarget::Filter, col)
}
fn is_self_move(target: DragTarget) -> bool {
target == DragTarget::Filter
}
}
type GroupBySelector = DragDropList<ConfigSelector, PivotColumn, GroupByContext>;
type SplitBySelector = DragDropList<ConfigSelector, PivotColumn, SplitByContext>;
type SortSelector = DragDropList<ConfigSelector, SortColumn, SortDragContext>;
type FilterSelector = DragDropList<ConfigSelector, FilterColumn, FilterDragContext>;
@@ -0,0 +1,136 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use perspective_client::config::Expression;
use web_sys::*;
use yew::prelude::*;
use crate::components::column_dropdown::ColumnDropDownElement;
#[derive(Properties)]
pub struct EmptyColumnProps {
/// The autocomplete dropdown element.
pub column_dropdown: ColumnDropDownElement,
/// Values to exclude from autocomplete
pub exclude: HashSet<String>,
/// Fires when a value is selected.
pub on_select: Callback<InPlaceColumn>,
}
impl PartialEq for EmptyColumnProps {
fn eq(&self, _other: &Self) -> bool {
false
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum InPlaceColumn {
Column(String),
Expression(Expression<'static>),
}
pub enum EmptyColumnMsg {
KeyDown(u32),
Blur,
Input,
}
use EmptyColumnMsg::*;
#[derive(Default)]
pub struct EmptyColumn {
input_ref: NodeRef,
}
impl Component for EmptyColumn {
type Message = EmptyColumnMsg;
type Properties = EmptyColumnProps;
fn view(&self, ctx: &Context<Self>) -> Html {
let onblur = ctx.link().callback(|_| Blur);
let oninput = ctx.link().callback(|_| Input);
let onkeydown = ctx
.link()
.callback(|event: KeyboardEvent| KeyDown(event.key_code()));
html! {
<div class="pivot-column column-empty">
<input
spellcheck="false"
ref={self.input_ref.clone()}
{onblur}
{onkeydown}
{oninput}
class="column-empty-input"
/>
</div>
}
}
fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
elem.blur().unwrap();
}
false
}
fn destroy(&mut self, ctx: &Context<Self>) {
ctx.props().column_dropdown.hide().unwrap();
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Blur => {
ctx.props().column_dropdown.hide().unwrap();
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
elem.set_value("");
}
false
},
KeyDown(40) => {
ctx.props().column_dropdown.item_down();
false
},
KeyDown(38) => {
ctx.props().column_dropdown.item_up();
false
},
KeyDown(13) => {
ctx.props().column_dropdown.item_select();
ctx.props().column_dropdown.hide().unwrap();
false
},
KeyDown(_) => false,
Input => {
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
ctx.props().column_dropdown.autocomplete(
elem,
ctx.props().exclude.clone(),
ctx.props().on_select.clone(),
);
}
false
},
}
}
fn create(_ctx: &Context<Self>) -> Self {
Self::default()
}
}
@@ -0,0 +1,58 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::prelude::*;
use super::ColumnLocator;
#[derive(PartialEq, Clone, Properties)]
pub struct ExprEditButtonProps {
/// Column name.
pub name: String,
/// Is this an expression column?
pub is_expression: bool,
/// Fires when the config/expresison button is clicked.
pub on_open_expr_panel: Callback<ColumnLocator>,
/// Is the expression/config panel open?
pub is_editing: bool,
/// Is the expression/config panel enabled? If not, show an invisible
/// square in the same dimensions (so the layout does not jump around).
pub is_disabled: bool,
}
/// A button that goes into a column-list for a custom expression
/// when pressed, it opens up the expression editor side-panel.
#[function_component]
pub fn ExprEditButton(p: &ExprEditButtonProps) -> Html {
let onmousedown = yew::use_callback(p.clone(), |_, p| {
let name = if p.is_expression {
ColumnLocator::Expression(p.name.clone())
} else {
ColumnLocator::Table(p.name.clone())
};
p.on_open_expr_panel.emit(name)
});
let class = if p.is_disabled {
"expression-edit-button disabled"
} else if p.is_editing {
"expression-edit-button is-editing"
} else {
"expression-edit-button"
};
html! { <span {onmousedown} {class}><span class="icon" /></span> }
}
@@ -0,0 +1,539 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::rc::Rc;
use chrono::{Datelike, NaiveDate, TimeZone, Utc};
use perspective_client::config::*;
use perspective_js::utils::ApiFuture;
use wasm_bindgen::JsCast;
use web_sys::*;
use yew::prelude::*;
use crate::components::containers::dragdrop_list::*;
use crate::components::containers::select::*;
use crate::components::filter_dropdown::FilterDropDownElement;
use crate::components::type_icon::TypeIcon;
use crate::presentation::Presentation;
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;
#[derive(Clone, Properties)]
pub struct FilterColumnProps {
pub filter: Filter,
pub idx: usize,
pub filter_dropdown: FilterDropDownElement,
pub on_keydown: Callback<String>,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// Current view config threaded as a value prop.
pub view_config: PtrEqRc<ViewConfig>,
// State
pub session: Session,
pub renderer: Renderer,
pub presentation: Presentation,
}
impl PartialEq for FilterColumnProps {
fn eq(&self, rhs: &Self) -> bool {
self.idx == rhs.idx
&& self.filter == rhs.filter
&& self.on_keydown == rhs.on_keydown
&& self.metadata == rhs.metadata
&& self.view_config == rhs.view_config
}
}
impl DragDropListItemProps for FilterColumnProps {
type Item = Filter;
fn get_item(&self) -> Filter {
self.filter.clone()
}
}
#[derive(Debug)]
pub enum FilterColumnMsg {
FilterInput((usize, String), String),
Close,
FilterOpSelect(String),
FilterKeyDown(u32),
}
/// A control for a single filter condition.
pub struct FilterColumn {
input: String,
input_ref: NodeRef,
filter_ops: Rc<Vec<SelectItem<String>>>,
}
impl Component for FilterColumn {
type Message = FilterColumnMsg;
type Properties = FilterColumnProps;
fn create(ctx: &Context<Self>) -> Self {
let input_ref = NodeRef::default();
let mut this = Self {
input: "".to_string(),
input_ref,
filter_ops: Rc::default(),
};
let col_type = ctx.props().get_current_filter_type();
this.input = ctx
.props()
.get_filter_input()
.unwrap_or_else(|| "".to_owned());
this.filter_ops = Rc::new(
try {
get_filter_ops(&ctx.props().metadata, col_type?)?
.into_iter()
.map(SelectItem::Option)
.collect::<Vec<_>>()
}
.unwrap_or_default(),
);
if col_type == Some(ColumnType::Boolean) {
ctx.props().update_filter_input(this.input.clone());
}
this
}
fn update(&mut self, ctx: &Context<Self>, msg: FilterColumnMsg) -> bool {
match msg {
FilterColumnMsg::FilterInput(column, input) => {
let target = self.input_ref.cast::<HtmlInputElement>().unwrap();
let input = if ctx.props().get_current_filter_type() == Some(ColumnType::Boolean) {
if target.checked() {
"true".to_owned()
} else {
"false".to_owned()
}
} else {
input
};
// TODO This belongs in the Features API.
if ctx.props().is_suggestable() {
ctx.props().filter_dropdown.autocomplete(
column,
if ctx.props().filter.op() == "in" || ctx.props().filter.op() == "not in" {
input.split(',').next_back().unwrap().to_owned()
} else {
input.clone()
},
HashSet::new(),
target.unchecked_into(),
ctx.props().on_keydown.clone(),
);
}
ctx.props().update_filter_input(input);
false
},
FilterColumnMsg::FilterKeyDown(40) => {
if ctx.props().is_suggestable() {
ctx.props().filter_dropdown.item_down();
ctx.props().filter_dropdown.item_select();
}
false
},
FilterColumnMsg::FilterKeyDown(38) => {
if ctx.props().is_suggestable() {
ctx.props().filter_dropdown.item_up();
ctx.props().filter_dropdown.item_select();
}
false
},
FilterColumnMsg::Close => {
ctx.props().filter_dropdown.hide().unwrap();
false
},
FilterColumnMsg::FilterKeyDown(13) => {
if ctx.props().is_suggestable() {
ctx.props().filter_dropdown.item_select();
ctx.props().filter_dropdown.hide().unwrap();
}
false
},
FilterColumnMsg::FilterKeyDown(_) => false,
FilterColumnMsg::FilterOpSelect(op) => {
ctx.props().update_filter_op(ctx.props().idx, op);
true
},
}
}
fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
let col_type = ctx.props().get_current_filter_type();
let old_col_type = ctx.props().get_filter_type(&old.filter);
let mut changed = false;
if col_type != old_col_type {
changed = true;
self.filter_ops = Rc::new(
try {
get_filter_ops(&ctx.props().metadata, col_type?)?
.into_iter()
.map(SelectItem::Option)
.collect::<Vec<_>>()
}
.unwrap_or_default(),
);
};
if let Some(input) = ctx.props().get_filter_input() {
self.input = input;
changed = true
}
changed
}
fn view(&self, ctx: &Context<Self>) -> Html {
let idx = ctx.props().idx;
let filter = ctx.props().filter.clone();
let column = filter.column().to_owned();
let col_type = ctx.props().metadata.get_column_table_type(&column);
let select = ctx.link().callback(FilterColumnMsg::FilterOpSelect);
let noderef = &self.input_ref;
let input = ctx.link().callback({
let column = column.clone();
move |input: InputEvent| {
FilterColumnMsg::FilterInput(
(idx, column.clone()),
input
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value(),
)
}
});
let focus = ctx.link().callback({
let input = self.input.clone();
move |_: FocusEvent| FilterColumnMsg::FilterInput((idx, column.clone()), input.clone())
});
let blur = ctx.link().callback(|_| FilterColumnMsg::Close);
let keydown = ctx
.link()
.callback(move |event: KeyboardEvent| FilterColumnMsg::FilterKeyDown(event.key_code()));
let dragstart = Callback::from({
let event_name = ctx.props().filter.column().to_owned();
let presentation = ctx.props().presentation.clone();
move |event: DragEvent| {
presentation.set_drag_image(&event).unwrap();
presentation
.notify_drag_start(event_name.to_string(), DragEffect::Move(DragTarget::Filter))
}
});
let dragend = Callback::from({
let presentation = ctx.props().presentation.clone();
move |_event| presentation.notify_drag_end()
});
let type_class = match col_type {
Some(ColumnType::Float) | Some(ColumnType::Integer) => "num-filter",
Some(ColumnType::String) => "string-filter",
_ => "",
};
let input_elem = match col_type {
Some(ColumnType::Integer) => html! {
<input
type="number"
placeholder="Value"
class="num-filter"
step="1"
ref={noderef.clone()}
onkeydown={keydown}
value={self.input.clone()}
oninput={input}
/>
},
Some(ColumnType::Float) => html! {
<input
type="number"
placeholder="Value"
class="num-filter"
ref={noderef.clone()}
onkeydown={keydown}
value={self.input.clone()}
oninput={input}
/>
},
Some(ColumnType::String) => html! {
<input
type="search"
size="4"
placeholder="Value"
class="string-filter"
spellcheck="false"
// TODO This is dirty and it may not work in the future.
onInput="this.parentNode.dataset.value=this.value"
ref={noderef.clone()}
onkeydown={keydown}
onfocus={focus}
onblur={blur}
value={self.input.clone()}
oninput={input}
/>
},
Some(ColumnType::Date) => html! {
<input
type="date"
placeholder="Value"
class="date-filter"
ref={noderef.clone()}
onkeydown={keydown}
value={self.input.clone()}
oninput={input}
/>
},
Some(ColumnType::Datetime) => html! {
<input
type="datetime-local"
placeholder="Value"
class="datetime-filter"
step="0.001"
ref={noderef.clone()}
onkeydown={keydown}
value={self.input.clone()}
oninput={input}
/>
},
Some(ColumnType::Boolean) => {
html! {
<input
type="checkbox"
class="alternate"
ref={noderef.clone()}
checked={self.input == "true"}
oninput={input}
/>
}
},
None => {
html! {}
},
};
let final_col_type = col_type.unwrap_or(ColumnType::Integer);
html! {
<div
class="pivot-column-draggable"
draggable="true"
ondragstart={dragstart}
ondragend={dragend}
>
<div class="pivot-column-border">
<span class="drag-handle icon" />
// <TypeIcon ty={ColumnType::String} />
<TypeIcon ty={final_col_type} />
<span class="column_name">{ filter.column().to_owned() }</span>
<Select<String>
class="filterop-selector"
is_autosize=true
values={self.filter_ops.clone()}
selected={filter.op().to_string()}
on_select={select}
/>
// TODO: Move this to the Features API.
if filter.op() != "is not null" && filter.op() != "is null" {
if col_type == Some(ColumnType::Boolean) {
{ input_elem }
} else {
<label
class={format!("input-sizer {}", type_class)}
data-value={format!("{}", filter.term())}
>
{ input_elem }
</label>
}
}
</div>
</div>
}
}
}
/// Get the allowed `FilterOp`s for this filter.
fn get_filter_ops(metadata: &SessionMetadata, col_type: ColumnType) -> Option<Vec<String>> {
let features = metadata.get_features()?;
features
.filter_ops
.get(&(col_type as u32))
.map(|x| x.options.clone())
}
impl FilterColumnProps {
/// Does this filter item get a "suggestions" auto-complete modal?
fn is_suggestable(&self) -> bool {
// TODO This needs to be moved to Features API. Or ... we just do this
// all string column type filters, or otherwise "fix" this in the UI?
(self.filter.op() == "=="
|| self.filter.op() == "!="
|| self.filter.op() == "in"
|| self.filter.op() == "not in")
&& self.get_filter_type(&self.filter) == Some(ColumnType::String)
}
fn get_current_filter_type(&self) -> Option<ColumnType> {
self.get_filter_type(&self.filter)
}
/// Get this filter's type, e.g. the type of the column.
fn get_filter_type(&self, filter: &Filter) -> Option<ColumnType> {
self.metadata.get_column_table_type(filter.column())
}
// Get the string value, suitable for the `value` field of a `FilterColumns`'s
// `<input>`.
fn get_filter_input(&self) -> Option<String> {
let filter_type = self.get_current_filter_type()?;
match (&filter_type, &self.filter.term()) {
(ColumnType::Date, FilterTerm::Scalar(Scalar::Float(x))) => {
if *x > 0_f64 {
Some(
Utc.timestamp_opt(*x as i64 / 1000, (*x as u32 % 1000) * 1000)
.earliest()?
.format("%Y-%m-%d")
.to_string(),
)
} else {
None
}
},
(ColumnType::Datetime, FilterTerm::Scalar(Scalar::Float(x))) => {
posix_to_utc_str(*x).ok()
},
(ColumnType::Boolean, FilterTerm::Scalar(Scalar::Bool(x))) => {
Some((if *x { "true" } else { "false" }).to_owned())
},
(ColumnType::Boolean, _) => Some("true".to_owned()),
(_, x) => Some(format!("{x}")),
}
}
/// Update the filter comparison operator.
///
/// # Arguments
/// - `op` The new `FilterOp`.
fn update_filter_op(&self, idx: usize, op: String) {
let mut filter = self.view_config.filter.clone();
let filter_column = &mut filter.get_mut(idx).expect("Filter on no column");
*filter_column.op_mut() = op;
let update = ViewConfigUpdate {
filter: Some(filter),
..ViewConfigUpdate::default()
};
if self.session.update_view_config(update).is_ok() {
let session = self.session.clone();
let renderer = self.renderer.clone();
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
/// Update the filter value from the string input read from the DOM.
///
/// # Arguments
/// - `val` The new filter value.
fn update_filter_input(&self, val: String) {
let mut filters = self.view_config.filter.clone();
let filter_column = &mut filters.get_mut(self.idx).expect("Filter on no column");
// TODO This belongs in the Features API.
let filter_input = if filter_column.op() == "in" || filter_column.op() == "not in" {
Some(FilterTerm::Array(
val.split(',')
.map(|x| Scalar::String(x.trim().to_owned()))
.collect(),
))
} else {
match self.get_current_filter_type() {
Some(ColumnType::String) => Some(FilterTerm::Scalar(Scalar::String(val))),
Some(ColumnType::Integer) => {
if val.is_empty() {
None
} else if let Ok(num) = val.parse::<f64>() {
Some(FilterTerm::Scalar(Scalar::Float(num.floor())))
} else {
None
}
},
Some(ColumnType::Float) => {
if val.is_empty() {
None
} else if let Ok(num) = val.parse::<f64>() {
Some(FilterTerm::Scalar(Scalar::Float(num)))
} else {
None
}
},
Some(ColumnType::Date) => match NaiveDate::parse_from_str(&val, "%Y-%m-%d") {
Ok(ref posix) => Some(FilterTerm::Scalar(Scalar::String(format!(
"{:0>4}-{:0>2}-{:0>2}",
posix.year(),
posix.month(),
posix.day(),
)))),
_ => None,
},
Some(ColumnType::Datetime) => match str_to_utc_posix(&val) {
Ok(x) => Some(FilterTerm::Scalar(Scalar::Float(x))),
_ => None,
},
Some(ColumnType::Boolean) => Some(FilterTerm::Scalar(match val.as_str() {
"true" => Scalar::Bool(true),
_ => Scalar::Bool(false),
})),
// shouldn't be reachable ..
_ => None,
}
};
if let Some(input) = filter_input
&& &input != filter_column.term()
{
*filter_column.term_mut() = input;
let update = ViewConfigUpdate {
filter: Some(filters),
..ViewConfigUpdate::default()
};
if self.session.update_view_config(update).is_ok() {
let session = self.session.clone();
let renderer = self.renderer.clone();
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
}
}
@@ -0,0 +1,243 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use itertools::Itertools;
use perspective_client::config::*;
use perspective_js::utils::ApiFuture;
use web_sys::*;
use yew::prelude::*;
use super::expr_edit_button::*;
use crate::components::type_icon::TypeIcon;
use crate::config::ColumnSelectMode;
use crate::presentation::{ColumnLocator, Presentation};
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;
#[derive(Clone, Properties)]
pub struct InactiveColumnProps {
/// This column's index in its list.
pub idx: usize,
/// Is this column visible?
pub visible: bool,
/// Column name
pub name: String,
/// Is the expression/config panel open for this column?
pub is_editing: bool,
/// Whether this column is an expression column. Computed by the parent
/// so that changes to session metadata trigger a re-render via prop diff.
#[prop_or_default]
pub is_expression: bool,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// View config snapshot — threaded from parent so we avoid
/// `session.get_view_config()` calls.
pub view_config: PtrEqRc<ViewConfig>,
/// `dragend` event`.
pub ondragend: Callback<()>,
/// Fires when this column's select button is sclicked.
pub onselect: Callback<()>,
/// Fires when this column's expression/config button is clicked.
pub on_open_expr_panel: Callback<ColumnLocator>,
// State
pub presentation: Presentation,
pub session: Session,
pub renderer: Renderer,
}
impl PartialEq for InactiveColumnProps {
fn eq(&self, rhs: &Self) -> bool {
self.idx == rhs.idx
&& self.visible == rhs.visible
&& self.name == rhs.name
&& self.is_editing == rhs.is_editing
&& self.is_expression == rhs.is_expression
&& self.metadata == rhs.metadata
&& self.view_config == rhs.view_config
}
}
pub enum InactiveColumnMsg {
ActivateColumn(bool),
MouseEnter(bool),
MouseLeave(bool),
}
use InactiveColumnMsg::*;
pub struct InactiveColumn {
add_expression_ref: NodeRef,
mouseover: bool,
}
impl Component for InactiveColumn {
type Message = InactiveColumnMsg;
type Properties = InactiveColumnProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
add_expression_ref: NodeRef::default(),
mouseover: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: InactiveColumnMsg) -> bool {
match msg {
ActivateColumn(shift_key) => {
ctx.props()
.activate_column(ctx.props().name.to_owned(), shift_key);
ctx.props().onselect.emit(());
false
},
MouseEnter(is_render) => {
self.mouseover = is_render;
is_render
},
MouseLeave(is_render) => {
self.mouseover = false;
is_render
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let col_type = ctx
.props()
.metadata
.get_column_table_type(&ctx.props().name)
.unwrap_or(ColumnType::String);
let add_column = ctx
.link()
.callback(|event: MouseEvent| InactiveColumnMsg::ActivateColumn(event.shift_key()));
let ondragend = ctx.props().ondragend.reform(|_| {});
let ondragstart = ctx.link().callback({
let event_name = ctx.props().name.to_owned();
let presentation = ctx.props().presentation.clone();
move |event: DragEvent| {
presentation.set_drag_image(&event).unwrap();
presentation.notify_drag_start(event_name.to_string(), DragEffect::Copy);
MouseLeave(true)
}
});
let onmouseout = ctx.link().callback(|_| MouseLeave(true));
let onmouseover = ctx
.link()
.callback(|event: MouseEvent| MouseEnter(event.which() == 0));
let is_expression = ctx.props().is_expression;
let mut is_active_class = ctx.props().renderer.metadata().select_mode.css();
is_active_class.push("shift-alt-icon");
let mut class = classes!("column-selector-column");
if !ctx.props().visible {
class.push("column-selector-column-hidden");
}
if self.mouseover {
class.push("dragdrop-hover");
}
html! {
<div {class} {onmouseover} {onmouseout} data-index={ctx.props().idx.to_string()}>
<span class={is_active_class} onmousedown={add_column} />
<div
ref={&self.add_expression_ref}
class="column-selector-draggable column-selector-column-title"
draggable="true"
{ondragstart}
{ondragend}
>
<div class="column-selector-column-border">
<span class="drag-handle icon" />
<TypeIcon ty={col_type} />
<span class="column_name">{ ctx.props().name.clone() }</span>
<span class="column-selector--spacer" />
<ExprEditButton
name={ctx.props().name.clone()}
on_open_expr_panel={&ctx.props().on_open_expr_panel}
{is_expression}
is_disabled={!is_expression}
is_editing={ctx.props().is_editing}
/>
</div>
</div>
</div>
}
}
}
impl InactiveColumnProps {
/// Add a column to the active columns, which corresponds to the `columns`
/// field of the `JsPerspectiveViewConfig`.
///
/// # Arguments
/// - `name` The name of the column to de-activate, which is a unique ID
/// with respect to `columns`.
/// - `shift` whether to toggle or select this column.
pub fn activate_column(&self, name: String, shift: bool) {
let mut columns = self.view_config.columns.clone();
let max_cols = self.renderer.metadata().config_column_names.len();
// Don't treat `None` at the end of the column list as columns, we'll refill
// these later
if let Some(last_filled) = columns.iter().rposition(|x| !x.is_none()) {
columns.truncate(last_filled + 1);
let mode = self.renderer.metadata().select_mode;
if (mode == ColumnSelectMode::Select) ^ shift {
columns.clear();
} else {
columns.retain(|x| x.as_ref() != Some(&name));
}
columns.push(Some(name));
}
// Do this outside the loop so errors dont just become noops
self.apply_columns(
columns
.into_iter()
.pad_using(max_cols, |_| None)
.collect::<Vec<_>>(),
);
}
fn apply_columns(&self, columns: Vec<Option<String>>) {
let config = ViewConfigUpdate {
columns: Some(columns),
..ViewConfigUpdate::default()
};
if self.session.update_view_config(config).is_ok() {
let session = self.session.clone();
let renderer = self.renderer.clone();
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
}
}
@@ -0,0 +1,22 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::prelude::*;
#[function_component(InvalidColumn)]
pub fn invalid_column() -> Html {
html! {
<div class="pivot-column column-empty column-invalid">
<div class="column-invalid-input" />
</div>
}
}
@@ -0,0 +1,109 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use perspective_client::config::ColumnType;
use web_sys::*;
use yew::prelude::*;
use crate::components::containers::dragdrop_list::*;
use crate::components::type_icon::TypeIcon;
use crate::presentation::Presentation;
use crate::session::*;
use crate::utils::*;
#[derive(Properties)]
pub struct PivotColumnProps {
/// Column name.
pub column: String,
#[prop_or_default]
pub column_type: Option<ColumnType>,
/// The drag starte of this column, if applicable.
pub action: DragTarget,
/// Session metadata snapshot — threaded from `SessionProps`.
#[prop_or_default]
pub metadata: Option<SessionMetadataRc>,
// State
#[prop_or_default]
pub opt_session: Option<Session>,
pub presentation: Presentation,
}
impl PartialEq for PivotColumnProps {
fn eq(&self, other: &Self) -> bool {
self.column == other.column
&& self.action == other.action
&& self.metadata == other.metadata
}
}
impl DragDropListItemProps for PivotColumnProps {
type Item = String;
fn get_item(&self) -> String {
self.column.clone()
}
}
pub struct PivotColumn;
impl Component for PivotColumn {
type Message = ();
type Properties = PivotColumnProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
let dragstart = Callback::from({
let event_name = ctx.props().column.to_owned();
let presentation = ctx.props().presentation.clone();
let action = ctx.props().action;
move |event: DragEvent| {
presentation.set_drag_image(&event).unwrap();
presentation.notify_drag_start(event_name.to_string(), DragEffect::Move(action))
}
});
let dragend = Callback::from({
let presentation = ctx.props().presentation.clone();
move |_event| presentation.notify_drag_end()
});
let col_type = ctx.props().column_type.unwrap_or_else(|| {
ctx.props()
.metadata
.as_ref()
.and_then(|x| x.get_column_table_type(&ctx.props().column))
.unwrap_or(ColumnType::Integer)
});
html! {
<div
class="pivot-column-draggable"
draggable="true"
ondragstart={dragstart}
ondragend={dragend}
>
<div class="pivot-column-border">
<span class="drag-handle icon" />
<TypeIcon ty={col_type} />
<span class="column_name">{ ctx.props().column.clone() }</span>
</div>
</div>
}
}
}
@@ -0,0 +1,147 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use perspective_client::config::*;
use perspective_js::utils::ApiFuture;
use web_sys::*;
use yew::prelude::*;
use crate::components::containers::dragdrop_list::*;
use crate::components::type_icon::TypeIcon;
use crate::presentation::Presentation;
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;
#[derive(Properties)]
pub struct SortColumnProps {
pub sort: Sort,
pub idx: usize,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// Current view config — threaded as a value prop.
pub view_config: PtrEqRc<ViewConfig>,
// State
pub session: Session,
pub renderer: Renderer,
pub presentation: Presentation,
}
impl PartialEq for SortColumnProps {
fn eq(&self, other: &Self) -> bool {
self.sort == other.sort
&& self.idx == other.idx
&& self.metadata == other.metadata
&& self.view_config == other.view_config
}
}
impl DragDropListItemProps for SortColumnProps {
type Item = Sort;
fn get_item(&self) -> Sort {
self.sort.clone()
}
}
pub enum SortColumnMsg {
SortDirClick(bool),
}
/// A `SortColumn` includes the column name and `SortDir` arrow, a clickable
/// button which cycles through the available `SortDir` states.
pub struct SortColumn {}
impl Component for SortColumn {
type Message = SortColumnMsg;
type Properties = SortColumnProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, ctx: &Context<Self>, msg: SortColumnMsg) -> bool {
match msg {
SortColumnMsg::SortDirClick(shift_key) => {
let is_split = ctx.props().view_config.split_by.is_empty();
let mut sort = ctx.props().view_config.sort.clone();
let sort_column = &mut sort.get_mut(ctx.props().idx).expect("Sort on no column");
sort_column.1 = sort_column.1.cycle(!is_split, shift_key);
let update = ViewConfigUpdate {
sort: Some(sort),
..ViewConfigUpdate::default()
};
let session = ctx.props().session.clone();
let renderer = ctx.props().renderer.clone();
if session.update_view_config(update).is_ok() {
ApiFuture::spawn(async move {
renderer.apply_pending_plugin()?;
renderer.draw(session.validate().await?.create_view()).await
});
}
false
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let onclick = ctx
.link()
.callback(|event: MouseEvent| SortColumnMsg::SortDirClick(event.shift_key()));
let dragstart = Callback::from({
let event_name = ctx.props().sort.0.to_owned();
let presentation = ctx.props().presentation.clone();
move |event: DragEvent| {
presentation.set_drag_image(&event).unwrap();
presentation
.notify_drag_start(event_name.to_string(), DragEffect::Move(DragTarget::Sort))
}
});
let dragend = Callback::from({
let presentation = ctx.props().presentation.clone();
move |_event| presentation.notify_drag_end()
});
let col_type = ctx
.props()
.metadata
.get_column_table_type(&ctx.props().sort.0.to_owned())
.unwrap_or(ColumnType::Integer);
html! {
<div
class="pivot-column-draggable"
draggable="true"
ondragstart={dragstart}
ondragend={dragend}
>
<div class="pivot-column-border">
<span class="drag-handle icon" />
<TypeIcon ty={col_type} />
// <TypeIcon ty={ColumnType::String} />
<span class="column_name">{ ctx.props().sort.0.to_owned() }</span>
<span
class={format!("sort-icon {}", ctx.props().sort.1)}
onmousedown={onclick}
/>
</div>
</div>
}
}
}
@@ -0,0 +1,451 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod attributes_tab;
mod save_settings;
pub(crate) mod style_tab;
use std::rc::Rc;
use derivative::Derivative;
use itertools::Itertools;
use perspective_client::config::{ColumnType, Expression, ViewConfig};
use perspective_client::utils::PerspectiveResultExt;
use yew::{Callback, Component, Html, Properties, html, props};
use self::attributes_tab::AttributesTabProps;
use self::style_tab::StyleTabProps;
use crate::components::column_settings_sidebar::attributes_tab::AttributesTab;
use crate::components::column_settings_sidebar::save_settings::SaveSettingsProps;
use crate::components::column_settings_sidebar::style_tab::StyleTab;
use crate::components::containers::sidebar::Sidebar;
use crate::components::containers::tab_list::TabList;
use crate::components::editable_header::EditableHeaderProps;
use crate::components::expression_editor::ExpressionEditorProps;
use crate::components::type_icon::TypeIconType;
use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation};
use crate::renderer::Renderer;
use crate::session::{Session, SessionMetadataRc};
use crate::tasks::{delete_expr, save_expr, update_expr};
use crate::utils::PtrEqRc;
#[derive(Clone, Derivative, Properties)]
#[derivative(Debug)]
pub struct ColumnSettingsPanelProps {
pub selected_column: ColumnLocator,
pub selected_tab: Option<ColumnSettingsTab>,
pub on_close: Callback<()>,
pub width_override: Option<i32>,
pub on_select_tab: Callback<ColumnSettingsTab>,
/// Active plugin name threaded as a value prop so that plugin changes
/// trigger re-initialization via `changed()` rather than a PubSub
/// `render_limits_changed` subscription.
pub plugin_name: Option<String>,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// View config snapshot — threaded from `SessionProps`.
pub view_config: PtrEqRc<ViewConfig>,
/// Per-column stats snapshot — threaded from `SessionProps`.
pub column_stats: PtrEqRc<std::collections::HashMap<String, crate::session::ColumnStats>>,
/// Selected theme name, threaded for PortalModal consumers.
pub selected_theme: Option<String>,
// State
#[derivative(Debug = "ignore")]
pub presentation: Presentation,
#[derivative(Debug = "ignore")]
pub renderer: Renderer,
#[derivative(Debug = "ignore")]
pub session: Session,
}
impl PartialEq for ColumnSettingsPanelProps {
fn eq(&self, other: &Self) -> bool {
self.selected_column == other.selected_column
&& self.selected_tab == other.selected_tab
&& self.plugin_name == other.plugin_name
&& self.metadata == other.metadata
&& self.view_config == other.view_config
&& self.column_stats == other.column_stats
&& self.selected_theme == other.selected_theme
}
}
#[derive(Debug)]
pub enum ColumnSettingsPanelMsg {
SetExprValue(Rc<String>),
SetExprValid(bool),
SetHeaderValue(Option<String>),
SetHeaderValid(bool),
SetSelectedTab((usize, ColumnSettingsTab)),
OnSaveAttributes(()),
OnResetAttributes(()),
OnDelete(()),
}
#[derive(Derivative)]
#[derivative(Debug)]
pub struct ColumnSettingsPanel {
column_name: String,
expr_valid: bool,
expr_value: Rc<String>,
header_valid: bool,
header_value: Option<String>,
initial_expr_value: Rc<String>,
initial_header_value: Option<String>,
maybe_ty: Option<ColumnType>,
on_input: Callback<Rc<String>>,
on_save: Callback<()>,
on_validate: Callback<bool>,
reset_count: u8,
reset_enabled: bool,
save_count: u8,
save_enabled: bool,
tabs: Vec<ColumnSettingsTab>,
}
impl Component for ColumnSettingsPanel {
type Message = ColumnSettingsPanelMsg;
type Properties = ColumnSettingsPanelProps;
fn create(ctx: &yew::prelude::Context<Self>) -> Self {
let mut this = Self {
initial_expr_value: Rc::default(),
expr_value: Rc::default(),
expr_valid: false,
initial_header_value: None,
header_value: None,
header_valid: false,
save_enabled: false,
save_count: 0,
reset_enabled: false,
reset_count: 0,
column_name: "".to_owned(),
maybe_ty: None,
tabs: vec![],
on_input: Callback::default(),
on_save: Callback::default(),
on_validate: Callback::default(),
};
this.initialize(ctx);
this
}
fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
if ctx.props() != old_props {
self.initialize(ctx);
true
} else {
false
}
}
fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
match msg {
ColumnSettingsPanelMsg::SetExprValue(val) => {
if self.expr_value != val {
self.expr_value = val;
self.reset_enabled = true;
true
} else {
false
}
},
ColumnSettingsPanelMsg::SetExprValid(val) => {
self.expr_valid = val;
self.save_enabled_effect();
true
},
ColumnSettingsPanelMsg::SetHeaderValue(val) => {
if self.header_value != val {
self.header_value = val;
self.reset_enabled = true;
true
} else {
false
}
},
ColumnSettingsPanelMsg::SetHeaderValid(val) => {
self.header_valid = val;
self.save_enabled_effect();
true
},
ColumnSettingsPanelMsg::SetSelectedTab((_, val)) => {
let rerender = ctx.props().selected_tab != Some(val);
ctx.props().on_select_tab.emit(val);
rerender
},
ColumnSettingsPanelMsg::OnResetAttributes(()) => {
self.header_value.clone_from(&self.initial_header_value);
self.expr_value.clone_from(&self.initial_expr_value);
self.save_enabled = false;
self.reset_enabled = false;
self.reset_count += 1;
true
},
ColumnSettingsPanelMsg::OnSaveAttributes(()) => {
let new_expr = Expression::new(
self.header_value.clone().map(|s| s.into()),
(*(self.expr_value)).clone().into(),
);
match &ctx.props().selected_column {
ColumnLocator::Table(_) => {
tracing::error!("Tried to save non-expression column!")
},
ColumnLocator::Expression(name) => update_expr(
&ctx.props().session,
&ctx.props().renderer,
&ctx.props().presentation,
name.clone(),
new_expr,
),
ColumnLocator::NewExpression => {
if let Err(err) = save_expr(
&ctx.props().session,
&ctx.props().renderer,
&ctx.props().presentation,
new_expr,
) {
tracing::warn!("{}", err);
}
},
}
self.initial_expr_value.clone_from(&self.expr_value);
self.initial_header_value.clone_from(&self.header_value);
self.save_enabled = false;
self.reset_enabled = false;
self.save_count += 1;
true
},
ColumnSettingsPanelMsg::OnDelete(()) => {
if ctx.props().selected_column.is_saved_expr() {
delete_expr(
&ctx.props().session,
&ctx.props().renderer,
&self.column_name,
)
.unwrap_or_log();
}
ctx.props().on_close.emit(());
true
},
}
}
fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
let header_props = props!(EditableHeaderProps {
initial_value: self.initial_header_value.clone(),
placeholder: self.expr_value.clone(),
reset_count: self.reset_count,
editable: ctx.props().selected_column.is_expr()
&& matches!(
ctx.props().selected_tab,
Some(ColumnSettingsTab::Attributes)
),
update_on_input: true,
icon_type: self
.maybe_ty
.map(|ty| ty.into())
.or(Some(TypeIconType::Expr)),
on_change: ctx.link().batch_callback(|(value, valid)| {
vec![
ColumnSettingsPanelMsg::SetHeaderValue(value),
ColumnSettingsPanelMsg::SetHeaderValid(valid),
]
}),
metadata: ctx.props().metadata.clone(),
session: &ctx.props().session
});
let expr_editor = props!(ExpressionEditorProps {
on_input: self.on_input.clone(),
on_save: self.on_save.clone(),
on_validate: self.on_validate.clone(),
alias: ctx.props().selected_column.name().cloned(),
disabled: !ctx.props().selected_column.is_expr(),
reset_count: self.reset_count,
metadata: ctx.props().metadata.clone(),
selected_theme: ctx.props().selected_theme.clone(),
session: &ctx.props().session
});
let disable_delete = ctx
.props()
.selected_column
.name()
.map(|name| {
let config = &ctx.props().view_config;
config.columns.iter().any(|maybe_col| {
maybe_col
.as_ref()
.map(|col| col == name)
.unwrap_or_default()
}) || config.group_by.iter().any(|col| col == name)
|| config.split_by.iter().any(|col| col == name)
|| config.filter.iter().any(|col| col.column() == name)
|| config.sort.iter().any(|col| &col.0 == name)
})
.unwrap_or_default();
let save_section = SaveSettingsProps {
save_enabled: self.save_enabled,
reset_enabled: self.reset_enabled,
is_save: ctx.props().selected_column.name().is_some(),
on_reset: ctx
.link()
.callback(ColumnSettingsPanelMsg::OnResetAttributes),
on_save: ctx
.link()
.callback(ColumnSettingsPanelMsg::OnSaveAttributes),
on_delete: ctx.link().callback(ColumnSettingsPanelMsg::OnDelete),
show_danger_zone: ctx.props().selected_column.is_saved_expr(),
disable_delete,
};
let attrs_tab = AttributesTabProps {
expr_editor,
save_section,
};
let style_tab = StyleTabProps {
ty: self.maybe_ty,
column_name: self.column_name.clone(),
group_by_depth: ctx.props().view_config.group_by.len() as u32,
view_config: ctx.props().view_config.clone(),
metadata: ctx.props().metadata.clone(),
column_stats: ctx.props().column_stats.clone(),
selected_theme: ctx.props().selected_theme.clone(),
presentation: ctx.props().presentation.clone(),
renderer: ctx.props().renderer.clone(),
session: ctx.props().session.clone(),
};
let tab_children = self.tabs.iter().map(|tab| match tab {
ColumnSettingsTab::Attributes => html! { <AttributesTab ..attrs_tab.clone() /> },
ColumnSettingsTab::Style => html! { <StyleTab ..style_tab.clone() /> },
});
let selected_tab_idx = self
.tabs
.iter()
.find_position(|tab| Some(**tab) == ctx.props().selected_tab)
.map(|(idx, _val)| idx)
.unwrap_or_default();
html! {
<>
<Sidebar
on_close={ctx.props().on_close.clone()}
id_prefix="column_settings"
width_override={ctx.props().width_override}
selected_tab={selected_tab_idx}
{header_props}
>
<TabList<ColumnSettingsTab>
tabs={self.tabs.clone()}
on_tab_change={ctx.link().callback(ColumnSettingsPanelMsg::SetSelectedTab)}
selected_tab={selected_tab_idx}
>
{ for tab_children }
</TabList<ColumnSettingsTab>>
</Sidebar>
</>
}
}
}
impl ColumnSettingsPanel {
fn save_enabled_effect(&mut self) {
let changed = self.expr_value != self.initial_expr_value
|| self.header_value != self.initial_header_value;
let valid = self.expr_valid && self.header_valid;
self.save_enabled = changed && valid;
}
fn initialize(&mut self, ctx: &yew::prelude::Context<Self>) {
let column_name = ctx
.props()
.metadata
.locator_name_or_default(&ctx.props().selected_column);
let initial_expr_value = ctx
.props()
.metadata
.get_expression_by_alias(&column_name)
.unwrap_or_default();
let initial_expr_value = Rc::new(initial_expr_value);
let initial_header_value =
(*initial_expr_value != column_name).then_some(column_name.clone());
let maybe_ty = ctx
.props()
.metadata
.locator_view_type(&ctx.props().selected_column);
let tabs = {
let mut tabs = vec![];
let is_new_expr = ctx.props().selected_column.is_new_expr();
let show_styles = !is_new_expr
&& ctx.props().renderer.can_render_column_styles()
&& ctx.props().view_config.columns.contains(&Some(
ctx.props()
.selected_column
.name()
.map(|x| x.to_string())
.unwrap_or_default(),
));
if !is_new_expr && show_styles {
tabs.push(ColumnSettingsTab::Style);
}
if ctx.props().selected_column.is_expr() {
tabs.push(ColumnSettingsTab::Attributes);
}
tabs
};
let on_input = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValue);
let on_save = ctx
.link()
.callback(ColumnSettingsPanelMsg::OnSaveAttributes);
let on_validate = ctx.link().callback(ColumnSettingsPanelMsg::SetExprValid);
*self = Self {
column_name,
expr_value: initial_expr_value.clone(),
initial_expr_value,
header_value: initial_header_value.clone(),
initial_header_value,
maybe_ty,
tabs,
header_valid: true,
on_input,
on_save,
on_validate,
..*self
}
}
}
@@ -0,0 +1,34 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::{Html, Properties, function_component, html};
use super::save_settings::{SaveSettings, SaveSettingsProps};
use crate::components::expression_editor::{ExpressionEditor, ExpressionEditorProps};
#[derive(PartialEq, Properties, Clone)]
pub struct AttributesTabProps {
pub expr_editor: ExpressionEditorProps,
pub save_section: SaveSettingsProps,
}
#[function_component]
pub fn AttributesTab(p: &AttributesTabProps) -> Html {
html! {
<div id="attributes-tab">
<div class="tab-section" id="attributes-expr">
<ExpressionEditor ..p.expr_editor.clone() />
</div>
<div class="tab-section"><SaveSettings ..p.save_section.clone() /></div>
</div>
}
}
@@ -0,0 +1,70 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::{Callback, Html, Properties, function_component, html};
#[derive(Properties, PartialEq, Clone)]
pub struct SaveSettingsProps {
pub save_enabled: bool,
pub reset_enabled: bool,
pub on_reset: Callback<()>,
pub on_save: Callback<()>,
pub on_delete: Callback<()>,
pub show_danger_zone: bool,
pub disable_delete: bool,
pub is_save: bool,
}
#[function_component(SaveSettings)]
pub fn save_settings(props: &SaveSettingsProps) -> Html {
let reset = props.on_reset.reform(|_| ());
let save = props.on_save.reform(|_| ());
let delete = props.on_delete.reform(|_| ());
html! {
<div id="save-settings-wrapper">
if props.show_danger_zone {
<div id="danger-zone">
<button
id="psp-expression-editor-button-delete"
class="psp-expression-editor__button"
onmousedown={delete}
disabled={props.disable_delete}
>
{ "Delete Column" }
</button>
</div>
}
<div
id="save-settings"
>
if props.is_save {
<button
id="psp-expression-editor-button-reset"
class="psp-expression-editor__button"
onmousedown={reset}
disabled={!props.reset_enabled}
>
{ "Reset" }
</button>
}
<button
id="psp-expression-editor-button-save"
class="psp-expression-editor__button"
onmousedown={save}
disabled={!props.save_enabled}
>
{ if props.is_save { "Save" } else { "Create" } }
</button>
</div>
</div>
}
}
@@ -0,0 +1,343 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod agg_depth_selector;
pub(crate) mod primitive_field;
mod symbol;
use std::collections::HashMap;
use itertools::Itertools;
use perspective_client::config::ColumnType;
use yew::{Html, Properties, function_component, html};
use self::agg_depth_selector::*;
use self::primitive_field::{
BoolField, ColorField, ColorRangeField, EnumField, NumberFieldPrimitive,
};
use crate::components::column_settings_sidebar::style_tab::symbol::SymbolStyle;
use crate::components::datetime_column_style::DatetimeColumnStyle;
use crate::components::number_series_style::NumberSeriesStyle;
use crate::components::string_column_style::StringColumnStyle;
use crate::components::style_controls::CustomNumberFormat;
use crate::config::{
ControlSpec, CustomNumberFormatConfig, DatetimeColumnStyleConfig, NumberSeriesStyleConfig,
StringColumnStyleConfig,
};
use crate::presentation::Presentation;
use crate::queries::{fetch_column_abs_max, get_column_config_schema};
use crate::renderer::Renderer;
use crate::session::Session;
use crate::tasks::send_column_config;
use crate::utils::PtrEqRc;
#[derive(Clone, PartialEq, Properties)]
pub struct StyleTabProps {
pub ty: Option<ColumnType>,
pub column_name: String,
pub group_by_depth: u32,
/// View config snapshot — threaded from parent.
pub view_config: PtrEqRc<perspective_client::config::ViewConfig>,
/// Session metadata snapshot — threaded from parent.
pub metadata: PtrEqRc<crate::session::SessionMetadata>,
/// Per-column stats snapshot — threaded from `SessionProps`.
pub column_stats: PtrEqRc<HashMap<String, crate::session::ColumnStats>>,
/// Selected theme name, threaded for PortalModal consumers.
pub selected_theme: Option<String>,
// State
pub presentation: Presentation,
pub renderer: Renderer,
pub session: Session,
}
#[function_component]
pub fn StyleTab(props: &StyleTabProps) -> Html {
// Bumped on every primitive field change so Yew re-renders the tab
// and re-queries `column_config_schema` with the new value. Without
// this, dynamic field gating (e.g. show `Color` only when
// `string_color_mode != none`) wouldn't surface until the user
// closed and reopened the sidebar.
let revision = yew::use_state(|| 0u32);
// `abs_max` lives in `Session`'s shared cache, propagated as a
// value-semantic snapshot through `props.column_stats`. The cache
// is cleared on `view_config_changed`; on cache miss we spawn the
// fetch and let `column_stats_changed` drive the re-render via
// `SessionProps`.
let abs_max = props
.column_stats
.get(&props.column_name)
.and_then(|s| s.abs_max);
yew::use_effect_with((props.column_name.clone(), props.view_config.clone()), {
let session = props.session.clone();
let column_name = props.column_name.clone();
move |_| {
fetch_column_abs_max(&session, column_name);
|| ()
}
});
let raw_config = props.renderer.get_columns_config(&props.column_name);
let on_change = {
let state = props.clone();
let column_name = props.column_name.clone();
let revision = revision.clone();
yew::Callback::from(move |config: crate::config::ColumnConfigFieldUpdate| {
send_column_config(&state.session, &state.renderer, &column_name, config);
revision.set(*revision + 1);
})
};
fn deser_sub<T: serde::de::DeserializeOwned>(
raw: &Option<serde_json::Map<String, serde_json::Value>>,
) -> Option<T> {
raw.as_ref()
.and_then(|m| serde_json::from_value::<T>(serde_json::Value::Object(m.clone())).ok())
}
let components = get_column_config_schema(
&props.renderer,
&props.view_config,
&props.metadata,
&props.column_name,
raw_config.as_ref(),
abs_max,
)
.map(|schema| {
schema
.fields
.into_iter()
.filter_map(|spec| {
let keys: Vec<String> = spec
.serialized_keys()
.into_iter()
.map(|s| s.to_string())
.collect();
let component = match spec {
ControlSpec::AggregateDepth => {
let aggregate_depth = raw_config
.as_ref()
.and_then(|m| m.get("aggregate_depth"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
html! {
<AggregateDepthSelector
group_by_depth={props.group_by_depth}
on_change={on_change.clone()}
column_name={props.column_name.to_owned()}
value={aggregate_depth}
keys={keys.clone()}
/>
}
},
ControlSpec::NumberSeriesStyle {
default: default_config,
} => {
let config: Option<NumberSeriesStyleConfig> = deser_sub(&raw_config);
html! {
<NumberSeriesStyle
{config}
{default_config}
on_change={on_change.clone()}
keys={keys.clone()}
/>
}
},
ControlSpec::DatetimeFormat => {
let config: Option<DatetimeColumnStyleConfig> = deser_sub(&raw_config);
let enable_time_config = props.ty.unwrap() == ColumnType::Datetime;
html! {
<DatetimeColumnStyle
{enable_time_config}
{config}
on_change={on_change.clone()}
keys={keys.clone()}
/>
}
},
ControlSpec::StringFormat => {
let config: Option<StringColumnStyleConfig> = deser_sub(&raw_config);
html! {
<StringColumnStyle
{config}
on_change={on_change.clone()}
keys={keys.clone()}
/>
}
},
ControlSpec::Symbols {
default: default_config,
} => {
let restored_config: HashMap<String, String> = raw_config
.as_ref()
.and_then(|m| m.get("symbols"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
html! {
<SymbolStyle
{default_config}
restored_config={Some(restored_config)}
on_change={on_change.clone()}
column_name={props.column_name.clone()}
selected_theme={props.selected_theme.clone()}
session={props.session.clone()}
keys={keys.clone()}
/>
}
},
ControlSpec::NumberFormat => {
let restored_config: CustomNumberFormatConfig = raw_config
.as_ref()
.and_then(|m| m.get("number_format"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
html! {
<CustomNumberFormat
{restored_config}
on_change={on_change.clone()}
view_type={props.ty.unwrap()}
column_name={props.column_name.clone()}
keys={keys.clone()}
/>
}
},
ControlSpec::Enum {
key,
variants,
default,
} => {
let current = raw_config
.as_ref()
.and_then(|m| m.get(&key))
.and_then(|v| v.as_str().map(|s| s.to_string()));
html! {
<EnumField
field_key={key}
{variants}
{default}
{current}
on_change={on_change.clone()}
/>
}
},
ControlSpec::Bool { key, default } => {
let current = raw_config
.as_ref()
.and_then(|m| m.get(&key))
.and_then(|v| v.as_bool());
html! {
<BoolField
field_key={key}
{default}
{current}
on_change={on_change.clone()}
/>
}
},
ControlSpec::Color { key, default } => {
let current = raw_config
.as_ref()
.and_then(|m| m.get(&key))
.and_then(|v| v.as_str().map(|s| s.to_string()));
html! {
<ColorField
field_key={key}
{default}
{current}
on_change={on_change.clone()}
/>
}
},
ControlSpec::ColorRange {
key_pos,
key_neg,
default_pos,
default_neg,
is_gradient,
} => {
let current_pos = raw_config
.as_ref()
.and_then(|m| m.get(&key_pos))
.and_then(|v| v.as_str().map(|s| s.to_string()));
let current_neg = raw_config
.as_ref()
.and_then(|m| m.get(&key_neg))
.and_then(|v| v.as_str().map(|s| s.to_string()));
html! {
<ColorRangeField
field_key_pos={key_pos}
field_key_neg={key_neg}
{default_pos}
{default_neg}
{current_pos}
{current_neg}
{is_gradient}
on_change={on_change.clone()}
/>
}
},
ControlSpec::Number {
key,
default,
min,
max,
step,
include,
} => {
let current = raw_config
.as_ref()
.and_then(|m| m.get(&key))
.and_then(|v| v.as_f64());
html! {
<NumberFieldPrimitive
field_key={key}
{default}
{current}
{min}
{max}
{step}
{include}
on_change={on_change.clone()}
/>
}
},
// String primitive has no caller yet — wire when a
// plugin emits one.
ControlSpec::String { .. } => {
return None;
},
};
Some(html! { <fieldset class="style-control">{ component }</fieldset> })
})
.collect_vec()
})
.unwrap_or_else(|error| {
tracing::error!("{}", error);
vec![]
});
html! {
<div id="style-tab">
<div id="column-style-container" class="tab-section">{ components }</div>
</div>
}
}
@@ -0,0 +1,66 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use perspective_client::clone;
use yew::{Callback, Html, Properties, function_component, html};
use crate::components::form::number_field::NumberField;
use crate::config::ColumnConfigFieldUpdate;
// ░░░█▀█░█▀▄░█▀█░█▀█░█▀▀░█▀▄░▀█▀░▀█▀░█▀▀░█▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
// ░░░█▀▀░█▀▄░█░█░█▀▀░█▀▀░█▀▄░░█░░░█░░█▀▀░▀▀█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
// ░░░▀░░░▀░▀░▀▀▀░▀░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░▀▀▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
#[derive(Properties, PartialEq)]
pub struct AggregateDepthSelectorProps {
pub on_change: Callback<ColumnConfigFieldUpdate>,
pub value: u32,
pub group_by_depth: u32,
pub column_name: String,
#[prop_or_default]
pub keys: Vec<String>,
}
#[function_component]
pub fn AggregateDepthSelector(props: &AggregateDepthSelectorProps) -> Html {
let state = yew::use_state_eq(|| 0);
yew::use_effect_with((props.column_name.to_owned(), props.group_by_depth), {
clone!(state, props.value);
move |deps| state.set(std::cmp::min(deps.1, value))
});
let on_change = yew::use_callback(
(state.setter(), props.on_change.clone(), props.keys.clone()),
|x: Option<f64>, deps| {
let depth = x.unwrap_or_default() as u32;
deps.0.set(depth);
let mut value = serde_json::Map::new();
if depth > 0 {
value.insert("aggregate_depth".to_owned(), serde_json::json!(depth));
}
deps.1.emit(ColumnConfigFieldUpdate {
keys: deps.2.clone(),
value,
})
},
);
html! {
<NumberField
label="aggregate-depth"
{on_change}
min=0.0
max={props.group_by_depth as f64}
default=0.0
current_value={*state as f64}
/>
}
}
@@ -0,0 +1,395 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! Schema-driven generic widgets for the Style tab. Each widget renders a
//! single primitive [`crate::config::ControlSpec`] variant and emits a
//! [`crate::config::ColumnConfigFieldUpdate`] on change. Built on top of
//! the existing form components ([`Select`], [`OptionalField`],
//! [`ColorSelector`]) so that they visually match the rich Yew widgets in
//! the same sidebar.
use std::rc::Rc;
use itertools::Itertools;
use serde_json::Value;
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, MouseEvent};
use yew::{Callback, Html, Properties, classes, function_component, html, use_callback};
use crate::components::containers::select::{Select, SelectItem};
use crate::components::form::color_range_selector::ColorRangeSelector;
use crate::components::form::color_selector::ColorSelector;
use crate::components::form::number_field::NumberField;
use crate::components::form::optional_field::OptionalField;
use crate::config::{ColumnConfigFieldUpdate, EnumVariant};
fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<Value>) {
let mut map = serde_json::Map::new();
if let Some(v) = value {
map.insert(key.to_owned(), v);
}
on_change.emit(ColumnConfigFieldUpdate {
keys: vec![key.to_owned()],
value: map,
});
}
fn emit_color_range(
on_change: &Callback<ColumnConfigFieldUpdate>,
key_pos: &str,
key_neg: &str,
default_pos: &str,
default_neg: &str,
new_pos: &str,
new_neg: &str,
) {
let mut value = serde_json::Map::new();
if new_pos != default_pos {
value.insert(key_pos.to_owned(), Value::String(new_pos.to_owned()));
}
if new_neg != default_neg {
value.insert(key_neg.to_owned(), Value::String(new_neg.to_owned()));
}
on_change.emit(ColumnConfigFieldUpdate {
keys: vec![key_pos.to_owned(), key_neg.to_owned()],
value,
});
}
#[derive(Properties, PartialEq)]
pub struct EnumFieldProps {
pub field_key: String,
pub variants: Vec<EnumVariant>,
pub default: String,
pub current: Option<String>,
pub on_change: Callback<ColumnConfigFieldUpdate>,
}
#[function_component]
pub fn EnumField(props: &EnumFieldProps) -> Html {
let selected = props
.current
.clone()
.unwrap_or_else(|| props.default.clone());
let checked = selected != props.default;
let values: Rc<Vec<SelectItem<String>>> = Rc::new(
props
.variants
.iter()
.map(|v| SelectItem::Option(v.value.clone()))
.collect_vec(),
);
let on_select = use_callback(
(
props.field_key.clone(),
props.default.clone(),
props.on_change.clone(),
),
|value: String, (key, default, on_change)| {
if value == *default {
emit(on_change, key, None);
} else {
emit(on_change, key, Some(Value::String(value)));
}
},
);
let on_reset = use_callback(
(props.field_key.clone(), props.on_change.clone()),
|_: MouseEvent, (key, on_change)| emit(on_change, key, None),
);
html! {
<div class="row">
<OptionalField label={props.field_key.clone()} on_check={on_reset} {checked}>
<Select<String> {values} {selected} {on_select} />
</OptionalField>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct BoolFieldProps {
pub field_key: String,
pub default: bool,
pub current: Option<bool>,
pub on_change: Callback<ColumnConfigFieldUpdate>,
}
#[function_component]
pub fn BoolField(props: &BoolFieldProps) -> Html {
let current = props.current.unwrap_or(props.default);
let oninput = use_callback(
(
props.field_key.clone(),
props.default,
props.on_change.clone(),
),
|e: yew::events::InputEvent, (key, default, on_change)| {
let target: HtmlInputElement = e.target().unwrap().unchecked_into();
let next = target.checked();
if next == *default {
emit(on_change, key, None);
} else {
emit(on_change, key, Some(Value::Bool(next)));
}
},
);
let checked = current != props.default;
let on_reset = use_callback(
(props.field_key.clone(), props.on_change.clone()),
|_: MouseEvent, (key, on_change)| emit(on_change, key, None),
);
html! {
<div class="row">
<OptionalField label={props.field_key.clone()} on_check={on_reset} {checked}>
<div class="bool-field-container">
<input
type="checkbox"
class="alternate"
id={format!("{}-checkbox", props.field_key)}
checked={current}
{oninput}
/>
<label for={format!("{}-checkbox", props.field_key)} class="bool-field-desc">
{ if current { "Enabled" } else { "Disabled" } }
</label>
</div>
</OptionalField>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct NumberFieldPrimitiveProps {
pub field_key: String,
pub default: f64,
pub current: Option<f64>,
pub on_change: Callback<ColumnConfigFieldUpdate>,
#[prop_or_default]
pub include: Option<bool>,
#[prop_or_default]
pub min: Option<f64>,
#[prop_or_default]
pub max: Option<f64>,
#[prop_or_default]
pub step: Option<f64>,
}
#[function_component]
pub fn NumberFieldPrimitive(props: &NumberFieldPrimitiveProps) -> Html {
let on_change_inner = use_callback(
(
props.field_key.clone(),
props.default,
props.on_change.clone(),
props.include,
),
|value: Option<f64>, (key, default, on_change, include)| match value {
Some(v) if include.unwrap_or_default() || v != *default => emit(
on_change,
key,
Some(
serde_json::Number::from_f64(v)
.map(Value::Number)
.unwrap_or(Value::Null),
),
),
None if include.unwrap_or_default() => emit(
on_change,
key,
Some(
serde_json::Number::from_f64(*default)
.map(Value::Number)
.unwrap_or(Value::Null),
),
),
_ => emit(on_change, key, None),
},
);
html! {
<NumberField
label={props.field_key.clone()}
current_value={props.current}
default={props.default}
min={props.min}
max={props.max}
step={props.step}
on_change={on_change_inner}
/>
}
}
#[derive(Properties, PartialEq)]
pub struct ColorRangeFieldProps {
pub field_key_pos: String,
pub field_key_neg: String,
pub default_pos: String,
pub default_neg: String,
pub current_pos: Option<String>,
pub current_neg: Option<String>,
pub is_gradient: bool,
pub on_change: Callback<ColumnConfigFieldUpdate>,
}
#[function_component]
pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html {
let pos = props
.current_pos
.clone()
.unwrap_or_else(|| props.default_pos.clone());
let neg = props
.current_neg
.clone()
.unwrap_or_else(|| props.default_neg.clone());
let is_modified = (props.current_pos.is_some()
&& props.current_pos.as_deref() != Some(props.default_pos.as_str()))
|| (props.current_neg.is_some()
&& props.current_neg.as_deref() != Some(props.default_neg.as_str()));
// Multi-key emit: write whichever side(s) differ from default,
// clear the others. Mirrors the apply semantics of
// `ColumnConfigFieldUpdate { keys, value }` with both keys owned.
let on_pos_color = use_callback(
(
props.field_key_pos.clone(),
props.field_key_neg.clone(),
props.default_pos.clone(),
props.default_neg.clone(),
props.on_change.clone(),
neg.clone(),
),
|new_pos: String, (key_pos, key_neg, default_pos, default_neg, on_change, neg)| {
emit_color_range(
on_change,
key_pos,
key_neg,
default_pos,
default_neg,
&new_pos,
neg,
);
},
);
let on_neg_color = use_callback(
(
props.field_key_pos.clone(),
props.field_key_neg.clone(),
props.default_pos.clone(),
props.default_neg.clone(),
props.on_change.clone(),
pos.clone(),
),
|new_neg: String, (key_pos, key_neg, default_pos, default_neg, on_change, pos)| {
emit_color_range(
on_change,
key_pos,
key_neg,
default_pos,
default_neg,
pos,
&new_neg,
);
},
);
let on_reset = use_callback(
(
props.field_key_pos.clone(),
props.field_key_neg.clone(),
props.on_change.clone(),
),
|_: (), (key_pos, key_neg, on_change)| {
on_change.emit(ColumnConfigFieldUpdate {
keys: vec![key_pos.clone(), key_neg.clone()],
value: serde_json::Map::new(),
});
},
);
html! {
<div class="row">
<ColorRangeSelector
pos_class={classes!(props.field_key_pos.clone())}
neg_class={classes!(props.field_key_neg.clone())}
pos_color={pos}
neg_color={neg}
is_gradient={props.is_gradient}
{on_pos_color}
{on_neg_color}
{on_reset}
{is_modified}
/>
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct ColorFieldProps {
pub field_key: String,
pub default: String,
pub current: Option<String>,
pub on_change: Callback<ColumnConfigFieldUpdate>,
}
#[function_component]
pub fn ColorField(props: &ColorFieldProps) -> Html {
let color = props
.current
.clone()
.unwrap_or_else(|| props.default.clone());
let is_modified =
props.current.as_deref() != Some(props.default.as_str()) && props.current.is_some();
let on_color = use_callback(
(
props.field_key.clone(),
props.default.clone(),
props.on_change.clone(),
),
|value: String, (key, default, on_change)| {
if value == *default {
emit(on_change, key, None);
} else {
emit(on_change, key, Some(Value::String(value)));
}
},
);
let on_reset = use_callback(
(props.field_key.clone(), props.on_change.clone()),
|_: (), (key, on_change)| emit(on_change, key, None),
);
html! {
<div class="row">
<ColorSelector
{color}
{on_color}
{on_reset}
{is_modified}
title={Some(format!("{}-label", props.field_key))}
/>
</div>
}
}
@@ -0,0 +1,143 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod row_selector;
mod symbol_pairs;
mod symbol_pairs_item;
mod symbol_selector;
use std::collections::HashMap;
use std::rc::Rc;
use itertools::Itertools;
use yew::{Callback, Html, Properties, html};
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_pairs::PairsList;
use crate::components::filter_dropdown::{FilterDropDownElement, FilterDropDownPortal};
use crate::config::{ColumnConfigFieldUpdate, KeyValueOpts, SymbolKVPair};
use crate::session::Session;
#[derive(Properties, PartialEq, Clone)]
pub struct SymbolAttrProps {
pub session: Session,
pub column_name: String,
pub restored_config: Option<HashMap<String, String>>,
pub on_change: Callback<ColumnConfigFieldUpdate>,
pub default_config: KeyValueOpts,
/// Selected theme name, threaded for PortalModal consumers.
pub selected_theme: Option<String>,
#[prop_or_default]
pub keys: Vec<String>,
}
impl SymbolAttrProps {
pub fn next_default_symbol(&self, pairs_len: usize) -> String {
let values = &self.default_config.values;
values.get(pairs_len % values.len()).cloned().unwrap()
}
}
pub enum SymbolAttrMsg {
UpdatePairs(Vec<SymbolKVPair>),
}
pub struct SymbolStyle {
pairs: Vec<SymbolKVPair>,
row_dropdown: Rc<FilterDropDownElement>,
}
impl yew::Component for SymbolStyle {
type Message = SymbolAttrMsg;
type Properties = SymbolAttrProps;
fn create(ctx: &yew::Context<Self>) -> Self {
let pairs = ctx
.props()
.restored_config
.as_ref()
.map(|restored_config| {
let mut pairs = restored_config
.iter()
.map(|(key, value)| SymbolKVPair::new(Some(key.to_owned()), value.to_owned()))
.collect_vec();
pairs.push(SymbolKVPair::new(
None,
ctx.props().next_default_symbol(pairs.len()),
));
pairs
})
.unwrap_or_default();
let row_dropdown = Rc::new(FilterDropDownElement::new(ctx.props().session.clone()));
Self {
pairs,
row_dropdown,
}
}
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg {
SymbolAttrMsg::UpdatePairs(mut new_pairs) => {
let symbols = new_pairs
.clone()
.into_iter()
.filter_map(|pair| Some((pair.key?, pair.value)))
.collect::<HashMap<_, _>>();
let mut value = serde_json::Map::new();
if !symbols.is_empty() {
value.insert(
"symbols".to_owned(),
serde_json::to_value(&symbols).unwrap_or(serde_json::Value::Null),
);
}
ctx.props().on_change.emit(ColumnConfigFieldUpdate {
keys: ctx.props().keys.clone(),
value,
});
let has_last_key = new_pairs
.last()
.map(|pair| pair.key.is_some())
.unwrap_or_default();
if has_last_key {
let val = ctx.props().next_default_symbol(new_pairs.len());
new_pairs.push(SymbolKVPair::new(None, val))
}
self.pairs = new_pairs;
true
},
}
}
fn view(&self, ctx: &yew::Context<Self>) -> Html {
let update_pairs = ctx.link().callback(SymbolAttrMsg::UpdatePairs);
html! {
<>
<PairsList
title="Symbols"
id="attributes-symbols"
pairs={self.pairs.clone()}
row_dropdown={self.row_dropdown.clone()}
column_name={ctx.props().column_name.clone()}
values={Rc::new(ctx.props().default_config.values.clone())}
{update_pairs}
/>
<FilterDropDownPortal
element={(*self.row_dropdown).clone()}
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
/>
</>
}
}
}
@@ -0,0 +1,101 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::rc::Rc;
use itertools::Itertools;
use perspective_client::clone;
use yew::{Html, Properties, function_component, html};
use crate::components::empty_row::EmptyRow;
use crate::components::filter_dropdown::FilterDropDownElement;
use crate::config::SymbolKVPair;
#[derive(Properties, PartialEq)]
pub struct RowSelectorProps {
pub selected_row: Option<String>,
pub on_select: yew::Callback<String>,
pub dropdown: Rc<FilterDropDownElement>,
pub pairs: Vec<SymbolKVPair>,
pub index: usize,
pub focused: bool,
pub set_focused_index: yew::Callback<Option<usize>>,
pub column_name: String,
}
#[function_component(RowSelector)]
pub fn row_selector(props: &RowSelectorProps) -> Html {
let on_select = {
clone!(props.dropdown, props.index, props.set_focused_index);
props.on_select.reform(move |key| {
dropdown.hide().unwrap();
set_focused_index.emit(Some(index + 1));
key
})
};
let ondblclick = {
clone!(props.set_focused_index, props.index);
yew::Callback::from(move |_| {
set_focused_index.emit(Some(index));
})
};
let err_class = (props.index != props.pairs.len() - 1).then_some("row-selector-errored");
let inner = if props.selected_row.is_none() || props.focused {
let mut pairs = props.pairs.clone();
if let Some(ref rowval) = props.selected_row
&& let Some((i, _)) = pairs.iter().find_position(|pair| {
pair.key
.as_ref()
.map(|keyval| keyval == rowval)
.unwrap_or_default()
})
{
pairs.remove(i);
}
let exclude: HashSet<_> = pairs
.into_iter()
.filter_map(|SymbolKVPair { key, .. }| key)
.collect();
html! {
<div class={err_class}>
<EmptyRow
dropdown={props.dropdown.clone()}
{exclude}
{on_select}
focused={props.focused}
index={props.index}
set_focused_index={props.set_focused_index.clone()}
value={props.selected_row.clone().unwrap_or_default()}
column_name={props.column_name.clone()}
/>
</div>
}
} else {
html! {
<div class="row-selector column-selector-column">
<div class="column-selector-column-border">
<span class="column_name none" {ondblclick}>
{ props.selected_row.clone().unwrap() }
</span>
</div>
</div>
}
};
html! { <div class="row-selector">{ inner }</div> }
}
@@ -0,0 +1,91 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use itertools::Itertools;
use yew::{Callback, Html, Properties, html};
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_pairs_item::PairsListItem;
use crate::components::filter_dropdown::FilterDropDownElement;
use crate::config::SymbolKVPair;
use crate::utils::PtrEqRc;
#[derive(Properties, PartialEq)]
pub struct PairsListProps {
pub title: String,
pub pairs: Vec<SymbolKVPair>,
pub update_pairs: Callback<Vec<SymbolKVPair>>,
pub id: Option<String>,
pub row_dropdown: Rc<FilterDropDownElement>,
pub values: PtrEqRc<Vec<String>>,
pub column_name: String,
}
pub enum PairsListMsg {
SetNextFocus(Option<usize>),
}
pub struct PairsList {
next_focus: Option<usize>,
}
impl yew::Component for PairsList {
type Message = PairsListMsg;
type Properties = PairsListProps;
fn create(_ctx: &yew::Context<Self>) -> Self {
Self { next_focus: None }
}
fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg {
PairsListMsg::SetNextFocus(i) => self.next_focus = i,
}
true
}
fn view(&self, ctx: &yew::Context<Self>) -> Html {
let props = ctx.props();
let set_focused = ctx.link().callback(PairsListMsg::SetNextFocus);
let main_pairs = props
.pairs
.iter()
.enumerate()
.map(|(index, pair)| {
let focused = self.next_focus.map(|s| s == index).unwrap_or_default();
html! {
<PairsListItem
pair={pair.clone()}
{index}
pairs={props.pairs.clone()}
row_dropdown={props.row_dropdown.clone()}
values={props.values.clone()}
update_pairs={props.update_pairs.clone()}
set_focused_index={set_focused.clone()}
column_name={props.column_name.clone()}
{focused}
/>
}
})
.collect_vec();
html! {
<>
<div class="pairs-list" id={props.id.clone()} data-label={props.title.clone()}>
<ul>{ for main_pairs }</ul>
</div>
</>
}
}
}
@@ -0,0 +1,112 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use yew::{Callback, Html, Properties, html};
use crate::components::column_settings_sidebar::style_tab::symbol::row_selector::RowSelector;
use crate::components::column_settings_sidebar::style_tab::symbol::symbol_selector::SymbolSelector;
use crate::components::filter_dropdown::FilterDropDownElement;
use crate::config::SymbolKVPair;
use crate::utils::PtrEqRc;
#[derive(Properties, PartialEq)]
pub struct PairsListItemProps {
pub pair: SymbolKVPair,
pub index: usize,
pub pairs: Vec<SymbolKVPair>,
pub update_pairs: Callback<Vec<SymbolKVPair>>,
pub row_dropdown: Rc<FilterDropDownElement>,
pub values: PtrEqRc<Vec<String>>,
pub focused: bool,
pub set_focused_index: Callback<Option<usize>>,
pub column_name: String,
}
pub enum PairListItemMsg {
Remove,
UpdateKey(Option<String>),
UpdateValue(String),
}
pub struct PairsListItem {}
impl yew::Component for PairsListItem {
type Message = PairListItemMsg;
type Properties = PairsListItemProps;
fn create(_ctx: &yew::Context<Self>) -> Self {
Self {}
}
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
let p = ctx.props();
match msg {
PairListItemMsg::Remove => {
let mut new_pairs = p.pairs.clone();
new_pairs.remove(p.index);
p.update_pairs.emit(new_pairs);
true
},
PairListItemMsg::UpdateKey(key) => {
let next = p.pair.update_key(key);
let mut new_pairs = p.pairs.clone();
new_pairs[p.index] = next;
p.update_pairs.emit(new_pairs);
true
},
PairListItemMsg::UpdateValue(val) => {
let next = p.pair.update_value(val);
let mut new_pairs = p.pairs.clone();
new_pairs[p.index] = next;
p.update_pairs.emit(new_pairs);
true
},
}
}
fn view(&self, ctx: &yew::Context<Self>) -> Html {
let props = ctx.props();
let on_remove = ctx.link().callback(|_| PairListItemMsg::Remove);
let on_key_update = ctx.link().callback(|s| PairListItemMsg::UpdateKey(Some(s)));
let on_value_update = ctx.link().callback(PairListItemMsg::UpdateValue);
let remove_style =
(ctx.props().index == ctx.props().pairs.len() - 1).then_some("visibility: hidden");
html! {
<li class="pairs-list-item">
<RowSelector
selected_row={props.pair.key.clone()}
on_select={on_key_update.clone()}
dropdown={props.row_dropdown.clone()}
pairs={props.pairs.clone()}
index={props.index}
focused={props.focused}
set_focused_index={props.set_focused_index.clone()}
column_name={props.column_name.clone()}
/>
<SymbolSelector
index={props.index}
callback={on_value_update}
values={props.values.clone()}
selected_value={props.pair.value.clone()}
/>
<span
class="toggle-mode is_column_active"
style={remove_style}
onclick={on_remove.clone()}
/>
</li>
}
}
}
@@ -0,0 +1,54 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use itertools::Itertools;
use yew::{Callback, Html, Properties, function_component, html};
use crate::components::containers::select::{Select, SelectItem};
use crate::utils::PtrEqRc;
#[derive(Properties, PartialEq)]
pub struct SymbolSelectorProps {
pub index: usize,
pub selected_value: Option<String>,
pub values: PtrEqRc<Vec<String>>,
pub callback: Callback<String>,
}
#[function_component(SymbolSelector)]
pub fn symbol_selector(p: &SymbolSelectorProps) -> Html {
let values = yew::use_memo(p.values.clone(), |values| {
values.iter().cloned().map(SelectItem::Option).collect_vec()
});
let selected = p
.values
.iter()
.find(|sym| {
p.selected_value
.as_ref()
.map(|selected| *sym == selected)
.unwrap_or_default()
})
.cloned()
.unwrap_or_else(|| p.values.get(p.index % values.len()).cloned().unwrap());
html! {
<Select<String>
wrapper_class="symbol-selector-wrapper"
class="symbol-selector"
on_select={p.callback.clone()}
{values}
{selected}
/>
}
}
@@ -0,0 +1,375 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::marker::PhantomData;
use derivative::Derivative;
use perspective_client::proto::ColumnType;
use web_sys::*;
use yew::html::Scope;
use yew::prelude::*;
use crate::components::column_dropdown::ColumnDropDownElement;
use crate::components::column_selector::{EmptyColumn, InPlaceColumn, InvalidColumn};
use crate::components::type_icon::TypeIcon;
use crate::presentation::{DragDropContainer, Presentation};
use crate::utils::DragTarget;
#[derive(Properties, Derivative)]
#[derivative(Clone(bound = ""))]
pub struct DragDropListProps<T, U>
where
T: Component,
U: Component,
<U as Component>::Properties: DragDropListItemProps,
{
pub parent: Scope<T>,
pub presentation: Presentation,
pub name: &'static str,
pub column_dropdown: ColumnDropDownElement,
pub exclude: HashSet<String>,
pub children: ChildrenWithProps<U>,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub is_dragover: Option<(
usize,
<<U as Component>::Properties as DragDropListItemProps>::Item,
)>,
#[prop_or_default]
pub allow_duplicates: bool,
}
impl<T, U> PartialEq for DragDropListProps<T, U>
where
T: Component,
U: Component,
<U as Component>::Properties: DragDropListItemProps,
{
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.children == other.children
&& self.allow_duplicates == other.allow_duplicates
&& self.is_dragover == other.is_dragover
&& self.disabled == other.disabled
}
}
pub enum DragDropListMsg {
Freeze(bool),
}
/// A sub-selector for a list-like component of a `JsViewConfig`, such as
/// `filters` and `sort`.
///
/// `DragDropList` is parameterized by two `Component`
/// types, the parent component `T` and the inner item compnent `U`, which must
/// additionally implement `DragDropListItemProps` trait on its own `Properties`
/// associated type.
///
/// Before you ask: yes, `frozen_size` needs to be a float64 since `flex`
/// containers can have fractional dimensions.
pub struct DragDropList<T, U, V>
where
T: Component,
U: Component,
<U as Component>::Properties: DragDropListItemProps,
V: DragContext<T::Message> + 'static,
{
parent_type: PhantomData<T>,
item_type: PhantomData<U>,
draggable_type: PhantomData<V>,
elem: NodeRef,
frozen_size: Option<f64>,
}
impl<T, U, V> Component for DragDropList<T, U, V>
where
T: Component,
U: Component,
<U as Component>::Properties: DragDropListItemProps,
V: DragContext<T::Message> + 'static,
{
type Message = DragDropListMsg;
type Properties = DragDropListProps<T, U>;
fn create(_ctx: &Context<Self>) -> Self {
Self {
parent_type: PhantomData,
item_type: PhantomData,
draggable_type: PhantomData,
elem: NodeRef::default(),
frozen_size: None,
}
}
fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
true
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
// When a dragover occurs and a new Column is inserted into the selector,
// the geometry of the selector may expand and cause a parent reflow,
// which annoyingly changes the drag status and glitchiness occurs.
// By using `Freeze` when a dragenter occurs, the element's width will be
// frozen until `drop` or `dragleave`.
DragDropListMsg::Freeze(freeze) => {
if freeze && self.frozen_size.is_none() {
let elem = self.elem.cast::<HtmlElement>().unwrap();
self.frozen_size = Some({
// `offset_width` and family are `i32`, but Chrome _really_
// uses fractional pixel widths for these which can only be
// recovered by parsing the generated stylesheet ...
let txt = window()
.unwrap()
.get_computed_style(&elem)
.unwrap()
.unwrap()
.get_property_value("width")
.unwrap();
// Strip "px" suffix, e.g. "24.876px".
let px = &txt[..txt.len() - 2];
px.parse::<f64>().unwrap()
});
true
} else if !freeze {
// Don't render because the invoker will do so through `dragdrop`.
self.frozen_size = None;
false
} else {
false
}
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let dragover = Callback::from(|_event: DragEvent| _event.prevent_default());
// On dragleave, signal the parent but no need to redraw as parent will call
// `change()` when resetting props.
let drag_container = DragDropContainer::new(
{
let total = ctx.props().children.len();
let parent = ctx.props().parent.clone();
let link = ctx.link().clone();
move || {
link.send_message(DragDropListMsg::Freeze(true));
parent.send_message(V::dragenter(total))
}
},
{
let parent = ctx.props().parent.clone();
let link = ctx.link().clone();
move || {
link.send_message(DragDropListMsg::Freeze(false));
parent.send_message(V::dragleave())
}
},
);
let drop = Callback::from({
let presentation = ctx.props().presentation.clone();
let link = ctx.link().clone();
move |event| {
link.send_message(DragDropListMsg::Freeze(false));
presentation.notify_drop(&event);
}
});
// Held by per-row `ondragenter` closures below so they can re-arm
// the `safaridragleave` flag on the container element when the
// row stops dragenter from bubbling. See the comment inside the
// closure for why this matters.
let container_noderef = drag_container.noderef.clone();
let invalid_drag: bool;
let mut valid_duplicate_drag = false;
let columns_html = {
let mut columns = ctx
.props()
.children
.iter()
.map(|x| (true, Some(x)))
.enumerate()
.collect::<Vec<_>>();
invalid_drag = if let Some((x, column)) = &ctx.props().is_dragover {
let index = *x;
let is_append = index == columns.len();
let is_self_move = ctx
.props()
.presentation
.get_drag_target()
.map(|x| V::is_self_move(x))
.unwrap_or_default();
let is_duplicate = columns
.iter()
.position(|x| x.1.1.as_ref().unwrap().props.get_item() == *column);
valid_duplicate_drag = is_duplicate.is_some() && !ctx.props().allow_duplicates;
if let Some(duplicate) = is_duplicate
&& !is_append
&& (!ctx.props().allow_duplicates || is_self_move)
{
columns.remove(duplicate);
}
// If inserting into the middle of the list, use
// the length of the existing element to prevent
// jitter as the underlying dragover zone moves.
if index < columns.len() {
columns.insert(index, (usize::MAX, (false, None)));
false
} else if (!is_append && !ctx.props().allow_duplicates)
|| ((!is_append || !is_self_move)
&& (is_duplicate.is_none() || ctx.props().allow_duplicates))
{
columns.push((usize::MAX, (false, None)));
false
} else {
true
}
} else {
false
};
columns
.into_iter()
.enumerate()
.map(|(idx, column)| {
let close = ctx.props().parent.callback(move |_| V::close(idx));
let dragenter = ctx.props().parent.callback({
let link = ctx.link().clone();
let container_noderef = container_noderef.clone();
move |event: DragEvent| {
event.stop_propagation();
event.prevent_default();
// Safari: `relatedTarget` is always null on
// dragleave, so `dragleave_helper` uses a
// `data-safaridragleave` flag set by the
// container's own dragenter to distinguish
// child-crossing leaves (consume the flag)
// from real leaves (no flag → fire callback).
// The `stop_propagation` above blocks the
// container's dragenter, so the flag would
// never be re-armed after the first consume —
// any further internal boundary crossing
// would demote the state out of
// `DragOverInProgress` and the next drop
// would be silently rejected. Set the flag
// here so each row-targeted dragenter
// refills the pool.
if event.related_target().is_none()
&& let Some(elem) = container_noderef.cast::<HtmlElement>()
{
let _ = elem.dataset().set("safaridragleave", "true");
}
link.send_message(DragDropListMsg::Freeze(true));
V::dragenter(idx)
}
});
if let (key, (true, Some(column))) = column {
html! {
<div {key} class="pivot-column" ondragenter={dragenter}>
{ Html::from(column) }
<span class="row_close" onmousedown={close} />
</div>
}
} else if let (key, (_, Some(column))) = column {
html! {
<div {key} class="pivot-column" ondragenter={dragenter}>
{ Html::from(column) }
<span class="row_close" style="opacity: 0.3" />
</div>
}
} else {
let (key, _) = column;
html! {
<div {key} class="pivot-column" ondragenter={dragenter}>
<div class="config-drop" />
</div>
}
}
})
.collect::<Html>()
};
let column_dropdown = ctx.props().column_dropdown.clone();
let exclude = ctx.props().exclude.clone();
let on_select = ctx.props().parent.callback(V::create);
let class = classes!("rrow");
let is_enabled = true;
html! {
<div ref={&self.elem} {class}>
<div
id={ctx.props().name}
ondragover={is_enabled.then_some(dragover)}
ondragenter={is_enabled.then_some(drag_container.dragenter)}
ondragleave={is_enabled.then_some(drag_container.dragleave)}
ref={drag_container.noderef}
ondrop={is_enabled.then_some(drop)}
>
<div class="psp-text-field">
<ul class="psp-text-field__input" for={ctx.props().name}>
{ columns_html }
if ctx.props().disabled && ctx.props().is_dragover.is_none() {
<div class="pivot-column">
<div class="pivot-column-border pivot-column-total">
<span class="drag-handle icon" />
<TypeIcon ty={ColumnType::Integer} />
<span class="column_name">{ "TOTAL" }</span>
</div>
<span
class="toggle-mode is_column_active"
onmousedown={ctx.props().parent.callback(move |_| V::close(0))}
/>
</div>
} else if ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag) {
<EmptyColumn {column_dropdown} {exclude} {on_select} />
} else if invalid_drag {
<InvalidColumn />
}
</ul>
<label class="pivot-selector-label" for={ctx.props().name} />
</div>
</div>
</div>
}
}
}
/// Must be implemented by `Properties` of children of `DragDropList`, returning
/// the value a DragDropItem represents.
pub trait DragDropListItemProps: Properties {
type Item: Clone + PartialEq;
fn get_item(&self) -> Self::Item;
}
pub trait DragContext<T> {
fn close(index: usize) -> T;
fn dragleave() -> T;
fn dragenter(index: usize) -> T;
fn create(col: InPlaceColumn) -> T;
fn is_self_move(effect: DragTarget) -> bool;
}
@@ -0,0 +1,100 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::marker::PhantomData;
use std::rc::Rc;
use web_sys::*;
use yew::prelude::*;
use super::select::SelectItem;
#[derive(Properties, PartialEq)]
pub struct DropDownMenuProps<T>
where
T: Into<Html> + Clone + PartialEq + 'static,
{
pub values: Rc<Vec<DropDownMenuItem<T>>>,
pub callback: Callback<T>,
}
pub struct DropDownMenu<T>
where
T: Into<Html> + Clone + PartialEq + 'static,
{
_props: PhantomData<T>,
}
impl<T> Component for DropDownMenu<T>
where
T: Into<Html> + Clone + PartialEq + 'static,
{
type Message = ();
type Properties = DropDownMenuProps<T>;
fn create(_ctx: &Context<Self>) -> Self {
Self {
_props: Default::default(),
}
}
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
false
}
fn view(&self, ctx: &Context<Self>) -> Html {
let values = &ctx.props().values;
let body = if !values.is_empty() {
values
.iter()
.map(|value| match value {
DropDownMenuItem::Option(x) => {
let click = ctx.props().callback.reform({
let value = x.clone();
move |_: MouseEvent| value.clone()
});
html! {
<span onmousedown={click} class="selected">{ x.clone().into() }</span>
}
},
DropDownMenuItem::OptGroup(name, xs) => {
html! {
<>
<span class="dropdown-group-label">{ name.as_ref() }</span>
<div class="dropdown-group-container">
{ xs.iter().map(|x| {
let click = ctx.props().callback.reform({
let value = x.clone();
move |_: MouseEvent| value.clone()
});
html! {
<span onmousedown={ click }>
{ x.clone().into() }
</span>
}
}).collect::<Html>() }
</div>
</>
}
},
})
.collect::<Html>()
} else {
html! { <span class="no-results">{ "No Completions" }</span> }
};
html! { <>{ body }</> }
}
}
pub type DropDownMenuItem<T> = SelectItem<T>;
@@ -0,0 +1,24 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! `containers` are generic container-like components for collections of other
//! `Component` types.
pub mod dragdrop_list;
pub mod dropdown_menu;
pub mod scroll_panel;
pub mod scroll_panel_item;
pub mod select;
pub mod sidebar;
pub mod sidebar_close_button;
pub mod split_panel;
pub mod tab_list;
@@ -0,0 +1,330 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
// Forked from https://github.com/AircastDev/yew-virtual-scroller (Apache 2.0)
// Adds support for Yew 0.19, auto-width and a simplified message structure.
use std::ops::Range;
use std::rc::Rc;
use itertools::Itertools;
use web_sys::Element;
use yew::prelude::*;
use yew::virtual_dom::VChild;
use super::scroll_panel_item::ScrollPanelItem;
use crate::utils::*;
#[derive(Properties)]
pub struct ScrollPanelProps {
#[prop_or_default]
pub children: Vec<VChild<ScrollPanelItem>>,
#[prop_or_default]
pub viewport_ref: Option<NodeRef>,
#[prop_or_default]
pub initial_width: Option<f64>,
#[prop_or_default]
pub class: Classes,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub dragenter: Callback<DragEvent>,
#[prop_or_default]
pub dragover: Callback<DragEvent>,
#[prop_or_default]
pub dragleave: Callback<DragEvent>,
#[prop_or_default]
pub on_resize: Option<Rc<PubSub<()>>>,
#[prop_or_default]
pub on_auto_width: Callback<f64>,
#[prop_or_default]
pub on_dimensions_reset: Option<Rc<PubSub<()>>>,
#[prop_or_default]
pub drop: Callback<DragEvent>,
#[prop_or_default]
pub omit_autosize_div: bool,
}
impl ScrollPanelProps {
/// Calculate the total virtual height of this scroll panel from the `size`
/// prop of its children.
fn total_height(&self) -> f64 {
self.children
.iter()
.map(|x| x.props.get_size())
.reduce(|x, y| x + y)
.unwrap_or_default()
}
}
impl PartialEq for ScrollPanelProps {
fn eq(&self, _rhs: &Self) -> bool {
false
}
}
#[doc(hidden)]
pub enum ScrollPanelMsg {
CalculateWindowContent,
UpdateViewportDimensions,
ResetAutoWidth,
ChildrenChanged,
}
pub struct ScrollPanel {
viewport_ref: NodeRef,
viewport_height: f64,
viewport_width: f64,
content_window: Option<ContentWindow>,
needs_rerender: bool,
total_height: f64,
_dimensions_reset_sub: Option<Subscription>,
_resize_sub: Option<Subscription>,
}
impl Component for ScrollPanel {
type Message = ScrollPanelMsg;
type Properties = ScrollPanelProps;
fn create(ctx: &Context<Self>) -> Self {
let _dimensions_reset_sub = ctx.props().on_dimensions_reset.as_ref().map(|pubsub| {
let link = ctx.link().clone();
pubsub.add_listener(move |_| {
link.send_message_batch(vec![
ScrollPanelMsg::ResetAutoWidth,
ScrollPanelMsg::CalculateWindowContent,
])
})
});
let _resize_sub = ctx.props().on_resize.as_ref().map(|pubsub| {
let link = ctx.link().clone();
pubsub.add_listener(move |_| {
link.send_message_batch(vec![
ScrollPanelMsg::UpdateViewportDimensions,
ScrollPanelMsg::CalculateWindowContent,
])
})
});
let total_height = ctx.props().total_height();
Self {
viewport_ref: Default::default(),
viewport_height: 0f64,
viewport_width: ctx.props().initial_width.unwrap_or_default(),
content_window: None,
needs_rerender: true,
total_height,
_dimensions_reset_sub,
_resize_sub,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ScrollPanelMsg::ResetAutoWidth => {
self.viewport_width = 0.0;
self.calculate_window_content(ctx)
},
// TODO don't hardcode pixel offsets - fix the CSS container to not
// require pixel offsets to work.
ScrollPanelMsg::UpdateViewportDimensions => {
let viewport = self.viewport_elem(ctx);
let rect = viewport.get_bounding_client_rect();
let viewport_height = rect.height() - 8.0;
let scroll_offset = if ctx.props().omit_autosize_div {
0.0
} else {
6.0
};
let viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
let re_render = self.viewport_height != viewport_height
|| self.viewport_width != viewport_width;
self.viewport_height = rect.height() - 8.0;
self.viewport_width = self.viewport_width.max(rect.width() - scroll_offset);
ctx.props().on_auto_width.emit(self.viewport_width);
re_render
},
ScrollPanelMsg::CalculateWindowContent => self.calculate_window_content(ctx),
ScrollPanelMsg::ChildrenChanged => true,
}
}
/// If the new total row height is different than last time this component
/// was rendered, we need to double-render to read the container's
/// potentially updated height.
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
let total_height = ctx.props().total_height();
self.needs_rerender =
self.needs_rerender || (self.total_height - total_height).abs() > 0.1f64;
self.total_height = total_height;
ctx.link().send_message_batch(vec![
ScrollPanelMsg::UpdateViewportDimensions,
ScrollPanelMsg::CalculateWindowContent,
ScrollPanelMsg::ChildrenChanged,
]);
false
}
fn view(&self, ctx: &Context<Self>) -> Html {
let content_style = format!("height:{}px", self.total_height);
let (window_style, windowed_items) = match &self.content_window {
None => ("".to_string(), &[][..]),
Some(cw) => (
format!(
"position:sticky;top:0;transform:translateY({}px);",
cw.start_y - cw.scroll_top
),
(&ctx.props().children[cw.visible_range.clone()]),
),
};
let width_style = format!("width:{}px", self.viewport_width.max(0.0));
let items = if !windowed_items.is_empty() {
let onscroll = ctx.link().batch_callback(|_| {
vec![
ScrollPanelMsg::UpdateViewportDimensions,
ScrollPanelMsg::CalculateWindowContent,
]
});
// TODO This glitches - we should use the `sticky` positioning strategy that
// `regular-table` uses.
html! {
<div
ref={self.viewport(ctx)}
id={ctx.props().id}
{onscroll}
ondragover={&ctx.props().dragover}
ondragenter={&ctx.props().dragenter}
ondragleave={&ctx.props().dragleave}
ondrop={&ctx.props().drop}
class={ctx.props().class.clone()}
>
<div class="scroll-panel-container" style={window_style}>
{ for windowed_items.iter().cloned().map(Html::from) }
if !ctx.props().omit_autosize_div {
<div
key="__scroll-panel-auto-width__"
class="scroll-panel-auto-width"
style={width_style}
/>
}
</div>
<div class="scroll-panel-content" style={content_style} />
</div>
}
} else {
html! {
<div
ref={self.viewport(ctx)}
id={ctx.props().id}
class={ctx.props().class.clone()}
>
<div style={content_style} />
</div>
}
};
html! { <>{ items }</> }
}
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
ctx.link().send_message_batch(vec![
ScrollPanelMsg::UpdateViewportDimensions,
ScrollPanelMsg::CalculateWindowContent,
]);
}
}
impl ScrollPanel {
fn viewport<'a, 'b: 'a, 'c: 'a>(&'b self, ctx: &'c Context<Self>) -> &'a NodeRef {
ctx.props()
.viewport_ref
.as_ref()
.unwrap_or(&self.viewport_ref)
}
fn viewport_elem(&self, ctx: &Context<Self>) -> Element {
self.viewport(ctx).cast::<Element>().unwrap()
}
}
#[derive(PartialEq)]
struct ContentWindow {
scroll_top: f64,
start_y: f64,
visible_range: Range<usize>,
}
impl ScrollPanel {
fn calculate_window_content(&mut self, ctx: &Context<Self>) -> bool {
let viewport = self.viewport_elem(ctx);
let scroll_top = viewport.scroll_top() as f64;
let mut start_node = 0;
let mut start_y = 0_f64;
let mut offset = 0_f64;
let end_node = ctx
.props()
.children
.iter()
.enumerate()
.find_or_last(|(i, x)| {
if offset + x.props.get_size() < scroll_top {
start_node = *i + 1;
start_y = offset + x.props.get_size();
}
offset += x.props.get_size();
offset > scroll_top + self.viewport_height
})
.map(|x| x.0)
.unwrap_or_default();
// Why is this `end_node + 2`, I can see you asking yourself? `end_node` is the
// index of the last visible child, but [`Range`] is an open interval so we must
// increment by 1. The next rendered element is always occluded by the parent
// container, it may seem unnecessary to render it, however not doing so causing
// scroll glitching in Chrome:
// * When the first pixel of the `end_node + 1` child is scrolled into view, the
// container element it is embedded in will expand past the end of the scroll
// container.
// * Chrome detects this and helpfully scrolls this new element into view,
// re-triggering the on scroll callback.
let visible_range = start_node..ctx.props().children.len().min(end_node + 2);
let content_window = Some(ContentWindow {
scroll_top,
start_y,
visible_range,
});
let re_render = self.content_window != content_window;
self.content_window = content_window;
re_render
}
}
@@ -0,0 +1,111 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
// Forked from https://github.com/AircastDev/yew-virtual-scroller (Apache 2.0)
// Adds support for Yew 0.19, auto-width and a simplified message structure.
use std::cell::Cell;
use std::rc::Rc;
use yew::prelude::*;
#[derive(PartialEq, Properties)]
pub struct ScrollPanelItemProps {
pub children: Children,
/// The expected size of this component in pixels. Calculating this value
/// ahead of time makes it easier to implement a high-performance virtual
/// renderer without resorting to weird tricks, _but_ checking that this
/// hint is correct is nearly as expensive. So, we only generate the
/// validation code in debug builds.
pub size_hint: f64,
/// Should we measure the child sizes or just rely on the hint? This
/// option may be enabled during debugging to validation render optimization
/// via [`Self::size_hint`], but will faile tests due to the extra DOM
/// structure it emits.
#[prop_or_default]
pub is_debug_mode: bool,
/// Communicate the panel size to virtual parent
#[prop_or_default]
measured_size: Rc<Cell<Option<f64>>>,
}
impl ScrollPanelItemProps {
pub(crate) fn get_size(&self) -> f64 {
if self.is_debug_mode {
self.measured_size.get().unwrap_or(self.size_hint)
} else {
self.size_hint
}
}
}
pub struct ScrollPanelItem {
node: NodeRef,
}
impl Component for ScrollPanelItem {
type Message = ();
type Properties = ScrollPanelItemProps;
fn create(ctx: &Context<Self>) -> Self {
if ctx.props().is_debug_mode {
ctx.props().measured_size.set(Some(ctx.props().size_hint));
}
Self {
node: NodeRef::default(),
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
if ctx.props().is_debug_mode {
<div class="debug-size-wrapper" style="display: flow-root" ref={self.node.clone()}>
for child in ctx.props().children.iter() {
{ child }
}
</div>
} else {
for child in ctx.props().children.iter() {
{ child }
}
}
}
}
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
if ctx.props().is_debug_mode {
ctx.props().measured_size.set(Some(ctx.props().size_hint));
}
true
}
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
if first_render && ctx.props().is_debug_mode {
let elem = self.node.cast::<web_sys::HtmlElement>().unwrap();
let new_height = elem.get_bounding_client_rect().height();
if ctx.props().measured_size.get() != Some(new_height) {
tracing::warn!(
"ScrollPanel size_hint does not match element size: {} != {}",
ctx.props().size_hint,
new_height
);
web_sys::console::warn_1(&elem);
ctx.props().measured_size.set(Some(new_height));
}
}
}
}
@@ -0,0 +1,271 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::borrow::{Borrow, Cow};
use std::fmt::Display;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use yew::prelude::*;
#[derive(Properties)]
pub struct SelectProps<T>
where
T: Clone + Display + PartialEq + 'static,
{
pub values: Rc<Vec<SelectItem<T>>>,
pub selected: T,
pub on_select: Callback<T>,
#[prop_or_default]
pub is_autosize: bool,
#[prop_or_default]
pub label: Option<Cow<'static, str>>,
#[prop_or_default]
pub id: Option<&'static str>,
#[prop_or_default]
pub class: Option<String>,
#[prop_or_default]
pub wrapper_class: Option<String>,
}
impl<T> PartialEq for SelectProps<T>
where
T: Clone + Display + PartialEq + 'static,
{
fn eq(&self, rhs: &Self) -> bool {
self.selected == rhs.selected && self.values == rhs.values
}
}
pub enum SelectMsg {
SelectedChanged(i32),
KeyboardInput(bool, i32, String),
}
/// A `<select>` HTML elements, lifted to support parameterization over a set of
/// values of a type `T`.
pub struct Select<T>
where
T: Clone + Display + PartialEq + 'static,
{
select_ref: NodeRef,
selected: T,
}
impl<T> Component for Select<T>
where
T: Clone + Display + PartialEq + 'static,
{
type Message = SelectMsg;
type Properties = SelectProps<T>;
fn create(_ctx: &Context<Self>) -> Self {
Self {
select_ref: NodeRef::default(),
selected: _ctx.props().selected.clone(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
SelectMsg::SelectedChanged(x) => {
self.selected = find_nth(x, &ctx.props().values).unwrap().clone();
ctx.props().on_select.emit(self.selected.clone());
true
},
// All the up and down arrow keys to instantly select, which is
// useful for keyboard warrios. This is not ada without the shift
// key. I'm not sure if it is _with_ the shift key either, but its
// cool.
SelectMsg::KeyboardInput(is_shift, idx, code) => {
if is_shift {
if code.as_str() == "ArrowUp" {
if let Some(x) = find_nth(idx - 1, &ctx.props().values) {
self.selected = x.clone();
ctx.props().on_select.emit(self.selected.clone());
return true;
}
} else if code.as_str() == "ArrowDown"
&& let Some(x) = find_nth(idx + 1, &ctx.props().values)
{
self.selected = x.clone();
ctx.props().on_select.emit(self.selected.clone());
return true;
}
}
false
},
}
}
// The `<select>` has its own state not refelcted by `SelectProps`.
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
self.selected = ctx.props().selected.clone();
true
}
// Annoyingly, `<select>` cannot be updated from its HTML alone.
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
if let Some(elem) = self.select_ref.cast::<web_sys::HtmlSelectElement>() {
elem.set_value(&format!("{}", self.selected))
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let callback = ctx.link().callback(|event: InputEvent| {
let value = event
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlSelectElement>()
.selected_index();
SelectMsg::SelectedChanged(value)
});
let key_callback = ctx.link().callback(|event: KeyboardEvent| {
event.prevent_default();
let value = event
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlSelectElement>()
.selected_index();
let is_shift = event.shift_key();
SelectMsg::KeyboardInput(is_shift, value, event.code())
});
let class = if let Some(class) = &ctx.props().class {
format!("noselect {class}")
} else {
"noselect".to_owned()
};
let is_group_selected = !ctx
.props()
.values
.iter()
.any(|x| matches!(x, SelectItem::Option(y) if *y == ctx.props().selected));
let select = html! {
<select
id={ctx.props().id}
{class}
ref={&self.select_ref}
oninput={callback}
onkeydown={key_callback}
>
{ for ctx.props().values.iter().map(|value| match value {
SelectItem::Option(value) => {
let selected = *value == ctx.props().selected;
html! {
<option
key={ format!("{}", value) }
selected={ selected }
value={ format!("{value}") }>
{ format!("{}", value) }
</option>
}
},
SelectItem::OptGroup(name, group) => html! {
<optgroup
key={ name.to_string() }
label={ name.to_string() }>
{
for group.iter().map(|value| {
let selected =
*value == ctx.props().selected;
let label = format!("{value}");
let category: &str = name.borrow();
let label = label
.strip_prefix(category)
.unwrap_or(&label)
.trim()
.to_owned();
html! {
<option
key={ format!("{}", value) }
selected={ selected }
value={ format!("{value}") }>
{ label }
</option>
}
})
}
</optgroup>
}
}) }
</select>
};
let wrapper_class = match &ctx.props().wrapper_class {
Some(x) => classes!("dropdown-width-container", x),
None => classes!("dropdown-width-container"),
};
let value = if ctx.props().is_autosize {
Some(self.selected.to_string())
} else {
None
};
html! {
if is_group_selected && ctx.props().label.is_some() {
<label>
{ ctx.props().label.as_ref().map(|x| x.to_string()).unwrap_or_default() }
</label>
<div class={wrapper_class} data-value={value}>{ select }</div>
} else {
<div class={wrapper_class} data-value={value}>{ select }</div>
}
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub enum SelectItem<T> {
Option(T),
OptGroup(Cow<'static, str>, Vec<T>),
}
impl<T: Display> SelectItem<T> {
pub fn name<'a>(&self) -> Cow<'a, str> {
match self {
Self::Option(x) => format!("{x}").into(),
Self::OptGroup(x, _) => x.clone(),
}
}
}
fn find_nth<T>(mut count: i32, items: &[SelectItem<T>]) -> Option<&T> {
for ref item in items.iter() {
match item {
SelectItem::Option(_) if count > 0 => {
count -= 1;
},
SelectItem::OptGroup(_, items) if count >= items.len() as i32 => {
count -= items.len() as i32;
},
SelectItem::OptGroup(_, items) => return items.get(count as usize),
SelectItem::Option(x) => return Some(x),
}
}
None
}
@@ -0,0 +1,75 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use perspective_client::clone;
use web_sys::Element;
use yew::{
Callback, Children, Html, Properties, function_component, html, use_effect_with, use_node_ref,
use_state_eq,
};
use crate::components::containers::sidebar_close_button::SidebarCloseButton;
use crate::components::editable_header::{EditableHeader, EditableHeaderProps};
#[derive(PartialEq, Clone, Properties)]
pub struct SidebarProps {
/// The component's children.
pub children: Children,
/// When this callback is called, the sidebar will close
pub on_close: Callback<()>,
pub id_prefix: String,
pub width_override: Option<i32>,
pub selected_tab: Option<usize>,
pub header_props: EditableHeaderProps,
}
/// Sidebars are designed to live in a
/// [`super::split_panel::SplitPanel`]
#[function_component]
pub fn Sidebar(p: &SidebarProps) -> Html {
let id = &p.id_prefix;
let noderef = use_node_ref();
let auto_width = use_state_eq(|| 0f64);
// this gets the last calculated width and ensures that
// the auto-width element is at least that big.
// this ensures the panel grows but does not shrink.
use_effect_with(p.clone(), {
clone!(noderef, auto_width);
move |p| {
if p.width_override.is_none() {
let updated_width = noderef
.cast::<Element>()
.map(|el| el.get_bounding_client_rect().width())
.unwrap_or_default();
let new_auto_width = (*auto_width).max(updated_width);
auto_width.set(new_auto_width);
} else {
auto_width.set(0f64);
}
}
});
let width_style = format!("min-width: 200px; width: {}px", *auto_width);
html! {
<>
<SidebarCloseButton id={format!("{id}_close_button")} on_close_sidebar={&p.on_close} />
<div class="sidebar_column" id={format!("{id}_sidebar")} ref={noderef}>
<div class="sidebar_header"><EditableHeader ..p.header_props.clone() /></div>
<div class="sidebar_border" id={format!("{id}_border")} />
{ p.children.iter().collect::<Html>() }
<div class="sidebar-auto-width" style={width_style} />
</div>
</>
}
}
@@ -0,0 +1,30 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::{AttrValue, Callback, Html, Properties, function_component, html};
#[derive(PartialEq, Clone, Properties)]
pub struct SidebarCloseButtonProps {
pub on_close_sidebar: Callback<()>,
pub id: AttrValue,
}
#[function_component]
pub fn SidebarCloseButton(p: &SidebarCloseButtonProps) -> Html {
let onclick = yew::use_callback(p.on_close_sidebar.clone(), |_, cb| cb.emit(()));
let id = &p.id;
html! {
<div {onclick} {id} class="sidebar_close_button">
<div class="sidebar_close_button_inner"><span class="icon" /></div>
</div>
}
}
@@ -0,0 +1,492 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::cmp::max;
use perspective_js::utils::{ApiResult, global};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::HtmlElement;
use yew::html::Scope;
use yew::prelude::*;
#[derive(Properties, Default)]
pub struct SplitPanelProps {
pub children: Children,
#[prop_or_default]
pub id: Option<String>,
#[prop_or_default]
pub orientation: Orientation,
/// Whether to render `<></>` empty templates as empty child panels, or
/// omit them entirely.
#[prop_or_default]
pub skip_empty: bool,
/// Should the child panels by wrapped in `<div>` elements?
#[prop_or_default]
pub no_wrap: bool,
/// Should the panels be rendered/sized in _reverse_ order?
#[prop_or_default]
pub reverse: bool,
#[prop_or_default]
pub on_reset: Option<Callback<()>>,
#[prop_or_default]
pub on_resize: Option<Callback<(i32, i32)>>,
#[prop_or_default]
pub on_resize_finished: Option<Callback<()>>,
#[prop_or_default]
pub initial_size: Option<i32>,
}
impl SplitPanelProps {
fn validate(&self) -> bool {
!self.children.is_empty()
}
}
impl PartialEq for SplitPanelProps {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.children == other.children
&& self.orientation == other.orientation
&& self.reverse == other.reverse
}
}
pub enum SplitPanelMsg {
StartResizing(usize, i32, i32, HtmlElement),
MoveResizing(i32),
StopResizing,
Reset(usize),
}
/// A panel with 2 sub panels and a mouse-draggable divider which allows
/// apportioning the panel's width.
///
/// # Examples
///
/// ```
/// html! {
/// <SplitPanel id="app_panel">
/// <div id="A">
/// <div id="B">
/// <a href=".."></a>
/// </div>
/// </SplitPanel>
/// }
/// ```
pub struct SplitPanel {
resize_state: Option<ResizingState>,
refs: Vec<NodeRef>,
styles: Vec<Option<String>>,
on_reset: Option<Callback<()>>,
}
impl Component for SplitPanel {
type Message = SplitPanelMsg;
type Properties = SplitPanelProps;
fn create(ctx: &Context<Self>) -> Self {
assert!(ctx.props().validate());
let len = ctx.props().children.len();
// cant just use vec![Default::default(); len] as it would
// use the same underlying NodeRef for each element.
let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
let mut styles = vec![Default::default(); len];
if let Some(x) = &ctx.props().initial_size {
styles[0] = Some(match ctx.props().orientation {
Orientation::Horizontal => {
format!("max-width:{x}px;min-width:{x}px;width:{x}px")
},
Orientation::Vertical => {
format!("max-height:{x}px;min-height:{x}px;height:{x}px")
},
});
}
Self {
resize_state: None,
refs,
styles,
on_reset: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
SplitPanelMsg::Reset(index) => {
self.styles[index] = None;
self.on_reset.clone_from(&ctx.props().on_reset);
},
SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
let elem = self.refs[index].cast::<HtmlElement>().unwrap();
let state =
ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
self.resize_state = state.ok();
},
SplitPanelMsg::StopResizing => {
self.resize_state = None;
if let Some(cb) = &ctx.props().on_resize_finished {
cb.emit(());
}
},
SplitPanelMsg::MoveResizing(client_offset) => {
if let Some(state) = self.resize_state.as_ref() {
if let Some(ref cb) = ctx.props().on_resize {
cb.emit(state.get_dimensions(client_offset));
}
self.styles[state.index] = state.get_style(client_offset);
}
},
};
true
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
if let Some(on_reset) = self.on_reset.take() {
on_reset.emit(());
}
}
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
assert!(ctx.props().validate());
let new_len = ctx.props().children.len();
self.refs.resize_with(new_len, Default::default);
self.styles.resize(new_len, Default::default());
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let iter = ctx
.props()
.children
.iter()
.filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
.enumerate()
.collect::<Vec<_>>();
let orientation = ctx.props().orientation;
let mut classes = classes!("split-panel");
if orientation == Orientation::Vertical {
classes.push("orient-vertical");
}
if ctx.props().reverse {
classes.push("orient-reverse");
}
let count = iter.len();
let contents = html! {
<>
for (i, x) in iter {
if i == 0 {
if count == 1 {
<key=0>
{x}
</>
} else {
<SplitPanelChild
key=0
style={self.styles[i].clone()}
ref_={self.refs[i].clone()}
>
{ x }
</SplitPanelChild>
}
} else if i == count - 1 {
<key={i}>
<SplitPanelDivider
i={i - 1}
orientation={ctx.props().orientation}
link={ctx.link().clone()}
/>
{ x }
</>
} else {
<key={i}>
<SplitPanelDivider
i={i - 1}
orientation={ctx.props().orientation}
link={ctx.link().clone()}
/>
<SplitPanelChild
style={self.styles[i].clone()}
ref_={self.refs[i].clone()}
>
{ x }
</SplitPanelChild>
</>
}
}
</>
};
// TODO consider removing this
if ctx.props().no_wrap {
html! { { contents } }
} else {
html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
}
}
}
#[derive(Clone, Copy, Default, Eq, PartialEq)]
pub enum Orientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Properties)]
struct SplitPanelDividerProps {
i: usize,
orientation: Orientation,
link: Scope<SplitPanel>,
}
impl PartialEq for SplitPanelDividerProps {
fn eq(&self, rhs: &Self) -> bool {
self.i == rhs.i && self.orientation == rhs.orientation
}
}
/// The resize handle for a `SplitPanel`.
#[function_component(SplitPanelDivider)]
fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
let orientation = props.orientation;
let i = props.i;
let link = props.link.clone();
let onmousedown = link.callback(move |event: PointerEvent| {
let target = event.target().unwrap().unchecked_into::<HtmlElement>();
let pointer_id = event.pointer_id();
let size = match orientation {
Orientation::Horizontal => event.client_x(),
Orientation::Vertical => event.client_y(),
};
SplitPanelMsg::StartResizing(i, size, pointer_id, target)
});
let ondblclick = props.link.callback(move |event: MouseEvent| {
event.prevent_default();
event.stop_propagation();
SplitPanelMsg::Reset(i)
});
// TODO Not sure why, but under some circumstances this can trigger a
// `dragstart`, leading to further drag events which cause perspective
// havoc. `event.prevent_default()` in `onmousedown` alternatively fixes
// this, but also prevents this event from trigger focus-stealing e.g. from
// open dialogs.
let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
html! {
<>
<div
class="split-panel-divider"
{ondragstart}
onpointerdown={onmousedown}
{ondblclick}
/>
</>
}
}
#[derive(Properties, PartialEq)]
struct SplitPanelChildProps {
style: Option<String>,
ref_: NodeRef,
children: Children,
}
#[function_component(SplitPanelChild)]
fn split_panel_child(props: &SplitPanelChildProps) -> Html {
let class = if props.style.is_some() {
classes!("split-panel-child", "is-width-override")
} else {
classes!("split-panel-child")
};
html! {
<div {class} ref={props.ref_.clone()} style={props.style.clone()}>
{ props.children.iter().next().unwrap() }
</div>
}
}
/// The state for the `Resizing` action, including the `MouseEvent` callbacks
/// and panel starting dimensions.
struct ResizingState {
mousemove: Closure<dyn Fn(MouseEvent)>,
mouseup: Closure<dyn Fn(MouseEvent)>,
cursor: String,
index: usize,
start: i32,
total: i32,
alt: i32,
orientation: Orientation,
reverse: bool,
body_style: web_sys::CssStyleDeclaration,
pointer_id: i32,
pointer_elem: HtmlElement,
}
impl Drop for ResizingState {
/// On `drop`, we must remove these event listeners from the document
/// `body`. Without this, the `Closure` objects would not leak, but the
/// document will continue to call them, causing runtime exceptions.
fn drop(&mut self) {
let result: ApiResult<()> = (|| {
let mousemove = self.mousemove.as_ref().unchecked_ref();
global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
let mouseup = self.mouseup.as_ref().unchecked_ref();
global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
self.release_cursor()?;
Ok(())
})();
result.expect("Drop failed")
}
}
/// The minimum size a split panel child can be, including when overridden via
/// user drag/drop.
const MINIMUM_SIZE: i32 = 8;
/// When the instantiated, capture the initial dimensions and create the
/// MouseEvent callbacks.
impl ResizingState {
pub fn new(
index: usize,
client_offset: i32,
ctx: &Context<SplitPanel>,
first_elem: &HtmlElement,
pointer_id: i32,
pointer_elem: HtmlElement,
) -> ApiResult<Self> {
let orientation = ctx.props().orientation;
let reverse = ctx.props().reverse;
let split_panel = ctx.link();
let total = match orientation {
Orientation::Horizontal => first_elem.offset_width(),
Orientation::Vertical => first_elem.offset_height(),
};
let alt = match orientation {
Orientation::Horizontal => first_elem.offset_height(),
Orientation::Vertical => first_elem.offset_width(),
};
let mouseup = Closure::new({
let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
move |x| cb.emit(x)
});
let mousemove = Closure::new({
let cb = split_panel.callback(move |event: MouseEvent| {
SplitPanelMsg::MoveResizing(match orientation {
Orientation::Horizontal => event.client_x(),
Orientation::Vertical => event.client_y(),
})
});
move |x| cb.emit(x)
});
let mut state = Self {
index,
cursor: "".to_owned(),
start: client_offset,
orientation,
reverse,
total,
alt,
body_style: global::body().style(),
mouseup,
mousemove,
pointer_id,
pointer_elem,
};
state.capture_cursor()?;
state.register_listeners()?;
Ok(state)
}
fn get_offset(&self, client_offset: i32) -> i32 {
let delta = if self.reverse {
self.start - client_offset
} else {
client_offset - self.start
};
max(MINIMUM_SIZE, self.total + delta)
}
pub fn get_style(&self, client_offset: i32) -> Option<String> {
let offset = self.get_offset(client_offset);
Some(match self.orientation {
Orientation::Horizontal => {
format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
},
Orientation::Vertical => {
format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
},
})
}
pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
let offset = self.get_offset(client_offset);
match self.orientation {
Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
}
}
/// Adds the event listeners, the corollary of `Drop`.
fn register_listeners(&self) -> ApiResult<()> {
let mousemove = self.mousemove.as_ref().unchecked_ref();
global::body().add_event_listener_with_callback("mousemove", mousemove)?;
let mouseup = self.mouseup.as_ref().unchecked_ref();
Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
}
/// Helper functions capture and release the global cursor while dragging is
/// occurring.
fn capture_cursor(&mut self) -> ApiResult<()> {
self.pointer_elem.set_pointer_capture(self.pointer_id)?;
self.cursor = self.body_style.get_property_value("cursor")?;
self.body_style
.set_property("cursor", match self.orientation {
Orientation::Horizontal => "col-resize",
Orientation::Vertical => "row-resize",
})?;
Ok(())
}
/// " but for release
fn release_cursor(&self) -> ApiResult<()> {
self.pointer_elem.release_pointer_capture(self.pointer_id)?;
Ok(self.body_style.set_property("cursor", &self.cursor)?)
}
}
@@ -0,0 +1,95 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::{Callback, Children, Component, Html, Properties, classes, html};
use crate::presentation::ColumnTab;
#[derive(Properties, Debug, PartialEq)]
pub struct TabListProps<T: ColumnTab> {
/// All tabs
pub tabs: Vec<T>,
/// Fires when the selected tab changes in the UI.
pub on_tab_change: Callback<(usize, T)>,
// Which tab is selected.
pub selected_tab: Option<usize>,
// The currently instantiated tabs.
pub children: Children,
}
pub enum TabListMsg {
SetSelected(usize),
}
pub struct TabList<T: ColumnTab> {
t: std::marker::PhantomData<T>,
selected_idx: usize,
}
impl<T: ColumnTab> Component for TabList<T> {
type Message = TabListMsg;
type Properties = TabListProps<T>;
fn create(_ctx: &yew::Context<Self>) -> Self {
Self {
t: std::marker::PhantomData,
selected_idx: 0,
}
}
fn update(&mut self, ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
match msg {
TabListMsg::SetSelected(idx) => {
ctx.props()
.on_tab_change
.emit((idx, ctx.props().tabs[idx].clone()));
self.selected_idx = idx;
true
},
}
}
fn changed(&mut self, ctx: &yew::Context<Self>, _old_props: &Self::Properties) -> bool {
self.selected_idx = ctx.props().selected_tab.unwrap_or_default();
true
}
fn view(&self, ctx: &yew::Context<Self>) -> Html {
let p = ctx.props();
let gutter_tabs = p.tabs.iter().enumerate().map(|(idx, tab)| {
let mut class = classes!("settings_tab");
if idx == self.selected_idx {
class.push("selected_tab");
}
let onclick = ctx.link().callback(move |_| TabListMsg::SetSelected(idx));
html! {
<span {class} {onclick}>
<div class="tab-title" id={tab.to_string()} />
<div class="tab-border" />
</span>
}
});
html! {
<>
<div id="settings_tab_bar">{ for gutter_tabs }</div>
<div id="format-tab" class="tab-content scrollable">
{ ctx.props().children.iter().nth(self.selected_idx) }
</div>
</>
}
}
}
@@ -0,0 +1,111 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use yew::prelude::*;
use super::containers::dropdown_menu::*;
use crate::config::*;
use crate::renderer::*;
type CopyDropDownMenuItem = DropDownMenuItem<ExportFile>;
#[derive(Properties, PartialEq)]
pub struct CopyDropDownMenuProps {
pub callback: Callback<ExportFile>,
pub renderer: Renderer,
}
pub struct CopyDropDownMenu {}
impl Component for CopyDropDownMenu {
type Message = ();
type Properties = CopyDropDownMenuProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn update(&mut self, _ctx: &Context<Self>, _msg: Self::Message) -> bool {
true
}
fn view(&self, ctx: &Context<Self>) -> yew::virtual_dom::VNode {
let is_chart = ctx.props().renderer.is_chart();
let has_selection = ctx.props().renderer.get_selection().is_some();
html! {
<>
<div id="test" />
<DropDownMenu<ExportFile>
values={Rc::new(get_menu_items(is_chart, has_selection))}
callback={&ctx.props().callback}
/>
</>
}
}
}
fn get_menu_items(is_chart: bool, has_selection: bool) -> Vec<CopyDropDownMenuItem> {
let mut items = vec![
CopyDropDownMenuItem::OptGroup(
"Current View".into(),
if is_chart {
vec![
ExportMethod::Csv.new_file("clipboard", is_chart),
ExportMethod::Json.new_file("clipboard", is_chart),
ExportMethod::Ndjson.new_file("clipboard", is_chart),
ExportMethod::Plugin.new_file("clipboard", is_chart),
]
} else {
vec![
ExportMethod::Csv.new_file("clipboard", is_chart),
ExportMethod::Json.new_file("clipboard", is_chart),
ExportMethod::Ndjson.new_file("clipboard", is_chart),
]
},
),
CopyDropDownMenuItem::OptGroup("All".into(), vec![
ExportMethod::CsvAll.new_file("clipboard", is_chart),
ExportMethod::JsonAll.new_file("clipboard", is_chart),
ExportMethod::NdjsonAll.new_file("clipboard", is_chart),
]),
CopyDropDownMenuItem::OptGroup("Config".into(), vec![
ExportMethod::JsonConfig.new_file("clipboard", is_chart),
]),
];
if has_selection {
items.insert(
0,
CopyDropDownMenuItem::OptGroup(
"Current Selection".into(),
if is_chart {
vec![
ExportMethod::CsvSelected.new_file("clipboard", is_chart),
ExportMethod::JsonSelected.new_file("clipboard", is_chart),
ExportMethod::NdjsonSelected.new_file("clipboard", is_chart),
]
} else {
vec![
ExportMethod::CsvSelected.new_file("clipboard", is_chart),
ExportMethod::JsonSelected.new_file("clipboard", is_chart),
ExportMethod::NdjsonSelected.new_file("clipboard", is_chart),
ExportMethod::Plugin.new_file("clipboard", is_chart),
]
},
),
)
}
items
}
@@ -0,0 +1,220 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod custom;
mod simple;
use std::rc::Rc;
use std::sync::LazyLock;
use derivative::Derivative;
use perspective_js::json;
use perspective_js::utils::global::navigator;
use wasm_bindgen::prelude::*;
use yew::prelude::*;
use super::modal::{ModalLink, SetModalLink};
use crate::components::datetime_column_style::custom::DatetimeStyleCustom;
use crate::components::datetime_column_style::simple::DatetimeStyleSimple;
use crate::components::form::select_value_field::SelectValueField;
use crate::config::*;
use crate::utils::WeakScope;
/// Format-only widget for `datetime` columns. Renders the `date_format`
/// hierarchy (Simple|Custom + timezone); color/color-mode UI is provided
/// externally as primitive `Enum` + `Color` schema fields.
#[derive(Properties, Derivative)]
#[derivative(Debug)]
pub struct DatetimeColumnStyleProps {
pub enable_time_config: bool,
pub config: Option<DatetimeColumnStyleConfig>,
#[prop_or_default]
pub on_change: Callback<ColumnConfigFieldUpdate>,
#[prop_or_default]
pub keys: Vec<String>,
#[prop_or_default]
#[derivative(Debug = "ignore")]
weak_link: WeakScope<DatetimeColumnStyle>,
}
impl ModalLink<DatetimeColumnStyle> for DatetimeColumnStyleProps {
fn weak_link(&self) -> &'_ WeakScope<DatetimeColumnStyle> {
&self.weak_link
}
}
impl PartialEq for DatetimeColumnStyleProps {
fn eq(&self, other: &Self) -> bool {
self.enable_time_config == other.enable_time_config && self.config == other.config
}
}
pub enum DatetimeColumnStyleMsg {
SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig),
CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig),
TimezoneChanged(Option<String>),
}
#[derive(Debug)]
pub struct DatetimeColumnStyle {
config: DatetimeColumnStyleConfig,
}
impl Component for DatetimeColumnStyle {
type Message = DatetimeColumnStyleMsg;
type Properties = DatetimeColumnStyleProps;
fn create(ctx: &Context<Self>) -> Self {
ctx.set_modal_link();
Self {
config: ctx.props().config.clone().unwrap_or_default(),
}
}
fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
let mut rerender = false;
let mut new_config = ctx.props().config.clone().unwrap_or_default();
if self.config != new_config {
std::mem::swap(&mut self.config, &mut new_config);
rerender = true;
}
if old.enable_time_config != ctx.props().enable_time_config {
rerender = true;
}
rerender
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
DatetimeColumnStyleMsg::TimezoneChanged(val) => {
if Some(&*USER_TIMEZONE) != val.as_ref() {
*self.config.date_format.time_zone_mut() = val;
} else {
*self.config.date_format.time_zone_mut() = None;
}
self.dispatch_config(ctx);
true
},
DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(simple) => {
self.config.date_format = DatetimeFormatType::Simple(simple);
self.dispatch_config(ctx);
true
},
DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged(custom) => {
self.config.date_format = DatetimeFormatType::Custom(custom);
self.dispatch_config(ctx);
true
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<div id="column-style-container" class="datetime-column-style-container">
if ctx.props().enable_time_config {
<SelectValueField<String>
label="timezone"
values={ALL_TIMEZONES.with(|x| (*x).clone())}
default_value={(*USER_TIMEZONE).clone()}
on_change={ctx.link().callback(DatetimeColumnStyleMsg::TimezoneChanged)}
current_value={self.config.date_format.time_zone().as_ref().unwrap_or(&*USER_TIMEZONE).clone()}
/>
}
if let DatetimeFormatType::Simple(config) = &self.config.date_format {
if ctx.props().enable_time_config {
<div class="row">
<button
id="datetime_format"
data-title="Simple"
data-title-hover="Switch to Custom"
onclick={ctx.link().callback(|_| DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged(CustomDatetimeStyleConfig::default()))}
/>
</div>
}
<DatetimeStyleSimple
enable_time_config={ctx.props().enable_time_config}
on_change={ctx.link().callback(DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged)}
config={config.clone()}
/>
} else if let DatetimeFormatType::Custom(config) = &self.config.date_format {
if ctx.props().enable_time_config {
<div class="row">
<button
id="datetime_format"
data-title="Custom"
data-title-hover="Switch to Simple"
onclick={ctx.link().callback(|_| DatetimeColumnStyleMsg::SimpleDatetimeStyleConfigChanged(SimpleDatetimeStyleConfig::default()))}
/>
</div>
}
<DatetimeStyleCustom
enable_time_config={ctx.props().enable_time_config}
on_change={ctx.link().callback(DatetimeColumnStyleMsg::CustomDatetimeStyleConfigChanged)}
config={config.clone()}
/>
}
</div>
</>
}
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = supportedValuesOf, js_namespace = Intl)]
pub fn supported_values_of(s: &JsValue) -> js_sys::Array;
}
thread_local! {
static ALL_TIMEZONES: LazyLock<Rc<Vec<String>>> = LazyLock::new(|| {
Rc::new(
supported_values_of(&JsValue::from("timeZone"))
.iter()
.map(|x| x.as_string().unwrap())
.collect(),
)
});
}
static USER_TIMEZONE: LazyLock<String> = LazyLock::new(|| {
js_sys::Reflect::get(
&js_sys::Intl::DateTimeFormat::new(&navigator().languages(), &json!({})).resolved_options(),
&JsValue::from("timeZone"),
)
.unwrap()
.as_string()
.unwrap()
});
impl DatetimeColumnStyle {
/// When this config has changed, we must signal the wrapper element.
fn dispatch_config(&self, ctx: &Context<Self>) {
let value = if self.config == DatetimeColumnStyleConfig::default() {
serde_json::Map::new()
} else {
match serde_json::to_value(&self.config) {
Ok(serde_json::Value::Object(m)) => m,
_ => serde_json::Map::new(),
}
};
ctx.props().on_change.emit(ColumnConfigFieldUpdate {
keys: ctx.props().keys.clone(),
value,
});
}
}
@@ -0,0 +1,222 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use yew::prelude::*;
use crate::components::form::number_field::NumberField;
use crate::components::form::select_value_field::SelectValueField;
use crate::components::modal::{ModalLink, SetModalLink};
use crate::config::*;
use crate::utils::WeakScope;
#[derive(Properties)]
pub struct DatetimeStyleCustomProps {
pub enable_time_config: bool,
pub config: CustomDatetimeStyleConfig,
#[prop_or_default]
pub on_change: Callback<CustomDatetimeStyleConfig>,
#[prop_or_default]
weak_link: WeakScope<DatetimeStyleCustom>,
}
impl ModalLink<DatetimeStyleCustom> for DatetimeStyleCustomProps {
fn weak_link(&self) -> &'_ WeakScope<DatetimeStyleCustom> {
&self.weak_link
}
}
impl PartialEq for DatetimeStyleCustomProps {
fn eq(&self, other: &Self) -> bool {
self.enable_time_config == other.enable_time_config && self.config == other.config
}
}
pub enum DatetimeStyleCustomMsg {
FractionalSeconds(Option<f64>),
Year(CustomDatetimeFormat),
Month(CustomDatetimeFormat),
Day(CustomDatetimeFormat),
Weekday(CustomDatetimeFormat),
Hour(CustomDatetimeFormat),
Minute(CustomDatetimeFormat),
Second(CustomDatetimeFormat),
Hour12(bool),
}
/// The custom variation of the options parameter for `Intl.DatetimeFormat`.
/// Complement to `DatetimeStyleSimple`.
pub struct DatetimeStyleCustom {
config: CustomDatetimeStyleConfig,
}
impl Component for DatetimeStyleCustom {
type Message = DatetimeStyleCustomMsg;
type Properties = DatetimeStyleCustomProps;
fn create(ctx: &Context<Self>) -> Self {
ctx.set_modal_link();
Self {
config: ctx.props().config.clone(),
}
}
// Always re-render when config changes.
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
let mut new_config = ctx.props().config.clone();
if self.config != new_config {
std::mem::swap(&mut self.config, &mut new_config);
true
} else {
false
}
}
// TODO could be more conservative here with re-rendering
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
DatetimeStyleCustomMsg::Year(year) => {
self.config.year = year;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Month(month) => {
self.config.month = month;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Day(day) => {
self.config.day = day;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Weekday(weekday) => {
self.config.weekday = weekday;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Hour(hour) => {
self.config.hour = hour;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Minute(minute) => {
self.config.minute = minute;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Second(second) => {
self.config.second = second;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::FractionalSeconds(fractional) => {
self.config.fractional_seconds = fractional.unwrap_or_default().floor() as u32;
self.dispatch_config(ctx);
true
},
DatetimeStyleCustomMsg::Hour12(hour12) => {
self.config.hour12 = hour12;
self.dispatch_config(ctx);
true
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let all_values = Rc::new(Vec::from(CustomDatetimeFormat::values()));
let number_values = Rc::new(Vec::from(CustomDatetimeFormat::values_1()));
let text_values = Rc::new(Vec::from(CustomDatetimeFormat::values_2()));
type SelectStrField = SelectValueField<&'static str>;
html! {
<>
if ctx.props().enable_time_config {
<SelectValueField<CustomDatetimeFormat>
label="year"
values={number_values.clone()}
default_value={CustomDatetimeFormat::TwoDigit}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Year(x.unwrap_or(CustomDatetimeFormat::TwoDigit)))}
current_value={self.config.year}
/>
<SelectValueField<CustomDatetimeFormat>
label="month"
values={all_values.clone()}
default_value={CustomDatetimeFormat::Numeric}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Month(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
current_value={self.config.month}
/>
<SelectValueField<CustomDatetimeFormat>
label="day"
values={number_values.clone()}
default_value={CustomDatetimeFormat::Numeric}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Day(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
current_value={self.config.day}
/>
<SelectValueField<CustomDatetimeFormat>
label="weekday"
values={text_values.clone()}
default_value={CustomDatetimeFormat::Disabled}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Weekday(x.unwrap_or(CustomDatetimeFormat::Disabled)))}
current_value={self.config.weekday}
/>
<SelectValueField<CustomDatetimeFormat>
label="hour"
values={number_values.clone()}
default_value={CustomDatetimeFormat::Numeric}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Hour(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
current_value={self.config.hour}
/>
<SelectValueField<CustomDatetimeFormat>
label="minute"
values={number_values.clone()}
default_value={CustomDatetimeFormat::Numeric}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Minute(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
current_value={self.config.minute}
/>
<SelectValueField<CustomDatetimeFormat>
label="second"
values={number_values.clone()}
default_value={CustomDatetimeFormat::Numeric}
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Second(x.unwrap_or(CustomDatetimeFormat::Numeric)))}
current_value={self.config.second}
/>
<NumberField
label="fractional-seconds"
min=0.
max=3.
step=1.
default=0.
current_value={self.config.fractional_seconds as f64}
on_change={ctx.link().callback(DatetimeStyleCustomMsg::FractionalSeconds)}
/>
<SelectStrField
label="hours"
values={Rc::new(vec!["12 Hour", "24 Hour"])}
default_value="12 Hour"
on_change={ctx.link().callback(|x: Option<_>| DatetimeStyleCustomMsg::Hour12(x != Some("24 Hour") ))}
current_value={if self.config.hour12 { "12 Hour" } else { "24 Hour" }}
/>
}
</>
}
}
}
impl DatetimeStyleCustom {
fn dispatch_config(&self, ctx: &Context<Self>) {
ctx.props().on_change.emit(self.config.clone());
}
}
@@ -0,0 +1,121 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::prelude::*;
use crate::components::form::select_enum_field::SelectEnumField;
use crate::components::modal::{ModalLink, SetModalLink};
use crate::config::*;
use crate::utils::WeakScope;
#[derive(Properties)]
pub struct DatetimeStyleSimpleProps {
pub enable_time_config: bool,
pub config: SimpleDatetimeStyleConfig,
#[prop_or_default]
pub on_change: Callback<SimpleDatetimeStyleConfig>,
#[prop_or_default]
weak_link: WeakScope<DatetimeStyleSimple>,
}
impl ModalLink<DatetimeStyleSimple> for DatetimeStyleSimpleProps {
fn weak_link(&self) -> &'_ WeakScope<DatetimeStyleSimple> {
&self.weak_link
}
}
impl PartialEq for DatetimeStyleSimpleProps {
fn eq(&self, other: &Self) -> bool {
self.enable_time_config == other.enable_time_config && self.config == other.config
}
}
pub enum DatetimeStyleSimpleMsg {
DateStyleChanged(Option<SimpleDatetimeFormat>),
TimeStyleChanged(Option<SimpleDatetimeFormat>),
}
/// `DatetimeStyleSimple` represents the variation of the options parameter to
/// `Intl.DatetimeFormat()` which supports `timeStyle` and `dateStyle`. These
/// options are mutually exclusive with those of `DatetimeStyleCustom`, hence
/// the two-struct model for this options parameter.
pub struct DatetimeStyleSimple {
config: SimpleDatetimeStyleConfig,
}
impl Component for DatetimeStyleSimple {
type Message = DatetimeStyleSimpleMsg;
type Properties = DatetimeStyleSimpleProps;
fn create(ctx: &Context<Self>) -> Self {
ctx.set_modal_link();
Self {
config: ctx.props().config.clone(),
}
}
// TODO could be more conservative here with re-rendering
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
DatetimeStyleSimpleMsg::DateStyleChanged(format) => {
self.config.date_style = format.unwrap_or_default();
self.dispatch_config(ctx);
true
},
DatetimeStyleSimpleMsg::TimeStyleChanged(format) => {
self.config.time_style = format.unwrap_or_default();
self.dispatch_config(ctx);
true
},
}
}
// Always re-render when config changes.
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
let mut new_config = ctx.props().config.clone();
if self.config != new_config {
std::mem::swap(&mut self.config, &mut new_config);
true
} else {
false
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<SelectEnumField<SimpleDatetimeFormat>
label="date-style"
on_change={ctx.link().callback(DatetimeStyleSimpleMsg::DateStyleChanged)}
current_value={self.config.date_style}
/>
if ctx.props().enable_time_config {
<SelectEnumField<SimpleDatetimeFormat>
label="time-style"
on_change={ctx.link().callback(DatetimeStyleSimpleMsg::TimeStyleChanged)}
current_value={self.config.time_style}
default_value={SimpleDatetimeFormat::Medium}
/>
}
</>
}
}
}
impl DatetimeStyleSimple {
/// When this config has changed, we must signal the wrapper element.
fn dispatch_config(&self, ctx: &Context<Self>) {
ctx.props().on_change.emit(self.config.clone());
}
}
@@ -0,0 +1,204 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use itertools::Itertools;
use web_sys::{FocusEvent, HtmlInputElement, KeyboardEvent};
use yew::{Callback, Component, Html, NodeRef, Properties, TargetCast, classes, html};
use super::type_icon::TypeIconType;
use crate::components::type_icon::TypeIcon;
use crate::session::{Session, SessionMetadataRc};
#[derive(Clone, PartialEq, Properties)]
pub struct EditableHeaderProps {
pub icon_type: Option<TypeIconType>,
pub on_change: Callback<(Option<String>, bool)>,
pub editable: bool,
pub initial_value: Option<String>,
pub placeholder: Rc<String>,
// TODO remove this pattern
#[prop_or_default]
pub reset_count: u8,
#[prop_or_default]
pub update_on_input: bool,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
// State
pub session: Session,
}
impl EditableHeaderProps {
fn split_placeholder(&self) -> String {
let split = self
.placeholder
.split_once('\n')
.map(|(a, _)| a)
.unwrap_or(&*self.placeholder);
match split.char_indices().nth(25) {
None => split.to_string(),
Some((idx, _)) => split[..idx].to_owned(),
}
}
}
pub enum EditableHeaderMsg {
SetNewValue(String),
OnClick(()),
}
#[derive(Debug)]
pub struct EditableHeader {
noderef: NodeRef,
edited: bool,
valid: bool,
value: Option<String>,
placeholder: String,
}
impl Component for EditableHeader {
type Message = EditableHeaderMsg;
type Properties = EditableHeaderProps;
fn create(ctx: &yew::prelude::Context<Self>) -> Self {
Self {
value: ctx.props().initial_value.clone(),
placeholder: ctx.props().split_placeholder(),
valid: true,
noderef: NodeRef::default(),
edited: false,
}
}
fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
if ctx.props().reset_count != old_props.reset_count {
self.value.clone_from(&ctx.props().initial_value);
}
if ctx.props().initial_value != old_props.initial_value {
self.edited = false;
self.value.clone_from(&ctx.props().initial_value);
}
if !ctx.props().editable {
self.edited = false;
}
self.placeholder = ctx.props().split_placeholder();
ctx.props() != old_props
}
fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
match msg {
EditableHeaderMsg::SetNewValue(new_value) => {
let maybe_value = (!new_value.is_empty()).then_some(new_value.clone());
self.edited = ctx.props().initial_value != maybe_value;
self.valid = (|| -> Option<bool> {
if maybe_value
.as_ref()
.map(|v| v == &self.placeholder)
.unwrap_or(true)
{
return Some(true);
}
if !self.edited {
return Some(true);
}
let metadata = &ctx.props().metadata;
let expressions = metadata.get_expression_columns();
let found = metadata
.get_table_columns()?
.iter()
.chain(expressions)
.contains(&new_value);
Some(!found)
})()
.unwrap_or(true);
self.value.clone_from(&maybe_value);
ctx.props().on_change.emit((maybe_value, self.valid));
true
},
EditableHeaderMsg::OnClick(()) => {
self.noderef
.cast::<HtmlInputElement>()
.unwrap()
.focus()
.unwrap();
false
},
}
}
fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
let mut classes = classes!("sidebar_header_contents");
if ctx.props().editable {
classes.push("editable");
}
if !self.valid {
classes.push("invalid");
}
if self.edited {
classes.push("edited");
}
let onkeyup = ctx.link().callback(|e: KeyboardEvent| {
let value = e.target_unchecked_into::<HtmlInputElement>().value();
EditableHeaderMsg::SetNewValue(value)
});
let onblur = ctx.link().callback(|e: FocusEvent| {
let value = e.target_unchecked_into::<HtmlInputElement>().value();
EditableHeaderMsg::SetNewValue(value)
});
let update_on_input = ctx.props().update_on_input;
let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| {
if update_on_input {
let value = e.target_unchecked_into::<HtmlInputElement>().value();
vec![EditableHeaderMsg::SetNewValue(value)]
} else {
vec![]
}
});
html! {
<div class={classes} onclick={ctx.link().callback(|_| EditableHeaderMsg::OnClick(()))}>
if let Some(icon) = ctx.props().icon_type { <TypeIcon ty={icon} /> }
<input
ref={self.noderef.clone()}
type="search"
class="sidebar_header_title"
disabled={!ctx.props().editable}
{onblur}
{onkeyup}
{oninput}
value={self.value.clone()}
placeholder={self.placeholder.clone()}
/>
</div>
}
}
}
#[derive(Default, Debug, PartialEq, Copy, Clone)]
pub enum ValueState {
#[default]
Unedited,
Edited,
}
@@ -0,0 +1,166 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashSet;
use std::rc::Rc;
use derivative::Derivative;
use perspective_client::clone;
use wasm_bindgen::JsCast;
use web_sys::*;
use yew::prelude::*;
use crate::components::filter_dropdown::FilterDropDownElement;
#[derive(Properties, Derivative)]
#[derivative(Debug)]
pub struct EmptyRowProps {
#[derivative(Debug = "ignore")]
pub dropdown: Rc<FilterDropDownElement>,
pub exclude: HashSet<String>,
pub on_select: Callback<String>,
pub focused: bool,
pub index: usize,
pub set_focused_index: Callback<Option<usize>>,
pub value: String,
pub column_name: String,
}
impl PartialEq for EmptyRowProps {
fn eq(&self, _other: &Self) -> bool {
false
}
}
pub enum EmptyRowMsg {
KeyDown(u32),
Blur,
Input(String),
Focus,
}
use EmptyRowMsg::*;
#[derive(Default)]
pub struct EmptyRow {
input_ref: NodeRef,
}
impl Component for EmptyRow {
type Message = EmptyRowMsg;
type Properties = EmptyRowProps;
fn view(&self, ctx: &Context<Self>) -> Html {
let onblur = ctx.link().callback(|_| Blur);
let oninput = ctx.link().callback(|e: InputEvent| {
let value = e
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value();
Input(value)
});
let onkeydown = ctx
.link()
.callback(|event: KeyboardEvent| KeyDown(event.key_code()));
if ctx.props().focused {
// do this on the next render cycle so we know the ref is there
ctx.link()
.send_message_batch(vec![Focus, Input(ctx.props().value.clone())]);
}
let onfocus = {
clone!(ctx.props().value);
ctx.link().callback(move |_| Input(value.clone()))
};
html! {
<div class="pivot-column column-empty">
<input
spellcheck="false"
ref={self.input_ref.clone()}
{onblur}
{onkeydown}
{oninput}
{onfocus}
class="column-empty-input"
/>
</div>
}
}
fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
elem.blur().unwrap();
}
false
}
fn destroy(&mut self, ctx: &Context<Self>) {
ctx.props().dropdown.hide().unwrap();
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
let p = ctx.props();
match msg {
Focus => {
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
elem.set_value(&ctx.props().value);
elem.focus().unwrap();
}
false
},
Blur => {
p.dropdown.hide().unwrap();
if let Some(elem) = self.input_ref.cast::<HtmlInputElement>() {
elem.set_value("");
}
p.set_focused_index.emit(Some(p.index + 1));
false
},
KeyDown(40) => {
p.dropdown.item_down();
false
},
KeyDown(38) => {
p.dropdown.item_up();
false
},
KeyDown(13) => {
p.dropdown.item_select();
p.dropdown.hide().unwrap();
p.set_focused_index.emit(Some(p.index + 1));
true
},
KeyDown(_) => false,
Input(value) => {
if let Some(elem) = self.input_ref.cast::<HtmlElement>() {
ctx.props().dropdown.autocomplete(
(ctx.props().index, ctx.props().column_name.clone()),
value,
ctx.props().exclude.clone(),
elem,
ctx.props().on_select.clone(),
);
}
false
},
}
}
fn create(_ctx: &Context<Self>) -> Self {
Self::default()
}
}
@@ -0,0 +1,126 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use yew::prelude::*;
use super::containers::dropdown_menu::*;
use crate::config::*;
use crate::renderer::*;
use crate::session::Session;
pub type ExportDropDownMenuItem = DropDownMenuItem<ExportFile>;
#[derive(Properties, PartialEq)]
pub struct ExportDropDownMenuProps {
pub renderer: Renderer,
pub session: Session,
pub callback: Callback<ExportFile>,
}
pub enum ExportDropDownMenuMsg {
TitleChange,
}
#[derive(Default)]
pub struct ExportDropDownMenu {
title: String,
input_ref: NodeRef,
invalid: bool,
}
impl Component for ExportDropDownMenu {
type Message = ExportDropDownMenuMsg;
type Properties = ExportDropDownMenuProps;
fn view(&self, ctx: &Context<Self>) -> yew::virtual_dom::VNode {
let callback = ctx.link().callback(|_| ExportDropDownMenuMsg::TitleChange);
let is_chart = ctx.props().renderer.is_chart();
html! {
<>
<span class="dropdown-group-label">{ "Save as" }</span>
<input
class={if self.invalid { "invalid" } else { "" }}
oninput={callback}
ref={&self.input_ref}
value={self.title.to_owned()}
/>
<DropDownMenu<ExportFile>
values={Rc::new(get_menu_items(&self.title, is_chart))}
callback={&ctx.props().callback}
/>
</>
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ExportDropDownMenuMsg::TitleChange => {
self.title = self
.input_ref
.cast::<web_sys::HtmlInputElement>()
.unwrap()
.value();
self.invalid = self.title.is_empty();
true
},
}
}
fn create(ctx: &Context<Self>) -> Self {
Self {
title: ctx
.props()
.session
.get_title()
.unwrap_or_else(|| "untitled".to_owned()),
..Default::default()
}
}
}
fn get_menu_items(name: &str, is_chart: bool) -> Vec<ExportDropDownMenuItem> {
vec![
ExportDropDownMenuItem::OptGroup(
"Current View".into(),
if is_chart {
vec![
ExportMethod::Csv.new_file(name, is_chart),
ExportMethod::Json.new_file(name, is_chart),
ExportMethod::Ndjson.new_file(name, is_chart),
ExportMethod::Arrow.new_file(name, is_chart),
ExportMethod::Html.new_file(name, is_chart),
ExportMethod::Plugin.new_file(name, is_chart),
]
} else {
vec![
ExportMethod::Csv.new_file(name, is_chart),
ExportMethod::Json.new_file(name, is_chart),
ExportMethod::Ndjson.new_file(name, is_chart),
ExportMethod::Arrow.new_file(name, is_chart),
ExportMethod::Html.new_file(name, is_chart),
]
},
),
ExportDropDownMenuItem::OptGroup("All".into(), vec![
ExportMethod::CsvAll.new_file(name, is_chart),
ExportMethod::JsonAll.new_file(name, is_chart),
ExportMethod::NdjsonAll.new_file(name, is_chart),
ExportMethod::ArrowAll.new_file(name, is_chart),
]),
ExportDropDownMenuItem::OptGroup("Config".into(), vec![
ExportMethod::JsonConfig.new_file(name, is_chart),
]),
]
}
@@ -0,0 +1,179 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use perspective_client::{ExprValidationError, clone};
use yew::prelude::*;
use super::form::code_editor::*;
use crate::session::{Session, SessionMetadata, SessionMetadataRc};
use crate::tasks::{ExprValidation, validate_expression};
#[derive(Properties, PartialEq, Clone)]
pub struct ExpressionEditorProps {
pub on_save: Callback<()>,
pub on_validate: Callback<bool>,
pub on_input: Callback<Rc<String>>,
pub alias: Option<String>,
pub disabled: bool,
#[prop_or_default]
pub reset_count: u8,
/// Session metadata snapshot — threaded from `SessionProps`.
pub metadata: SessionMetadataRc,
/// Selected theme name, threaded for PortalModal consumers.
#[prop_or_default]
pub selected_theme: Option<String>,
// State
pub session: Session,
}
#[derive(Debug)]
pub enum ExpressionEditorMsg {
SetExpr(Rc<String>),
ValidateComplete(ExprValidation),
}
/// Expression editor component `CodeEditor` and a button toolbar.
pub struct ExpressionEditor {
expr: Rc<String>,
error: Option<ExprValidationError>,
oninput: Callback<Rc<String>>,
/// Monotonically increasing request id used to drop stale
/// validation results when the user types faster than the engine
/// can validate.
validation_req_id: u64,
/// The id of the most recently dispatched validation; the result
/// is only applied when its echoed id matches.
last_dispatched_req_id: u64,
}
impl Component for ExpressionEditor {
type Message = ExpressionEditorMsg;
type Properties = ExpressionEditorProps;
fn create(ctx: &Context<Self>) -> Self {
let oninput = ctx.link().callback(ExpressionEditorMsg::SetExpr);
let expr = initial_expr(&ctx.props().metadata, &ctx.props().alias);
ctx.link()
.send_message(Self::Message::SetExpr(expr.clone()));
Self {
error: None,
expr,
oninput,
validation_req_id: 0,
last_dispatched_req_id: 0,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ExpressionEditorMsg::SetExpr(val) => {
ctx.props().on_input.emit(val.clone());
self.expr = val.clone();
self.validation_req_id += 1;
self.last_dispatched_req_id = self.validation_req_id;
let cb = ctx.link().callback(ExpressionEditorMsg::ValidateComplete);
validate_expression(
&ctx.props().session,
cb,
self.validation_req_id,
(*val).clone(),
);
true
},
ExpressionEditorMsg::ValidateComplete(result) => {
if result.req_id != self.last_dispatched_req_id {
// Stale result from a superseded request — ignore.
return false;
}
self.error = result.error;
if self.error.is_none() {
let _: Option<bool> = try {
let alias = ctx.props().alias.as_ref()?;
let session = &ctx.props().session;
let old = ctx.props().metadata.get_expression_by_alias(alias)?;
let is_edited = *self.expr != old;
session
.metadata_mut()
.set_edit_by_alias(alias, self.expr.to_string());
is_edited
};
ctx.props().on_validate.emit(true);
} else {
ctx.props().on_validate.emit(false);
}
true
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let disabled_class = ctx.props().disabled.then_some("disabled");
clone!(ctx.props().disabled);
html! {
<>
<label class="item_title">{ "Expression" }</label>
<div id="editor-container" class={disabled_class}>
<CodeEditor
autofocus=true
expr={&self.expr}
autosuggest=true
error={self.error.clone().map(|x| x.into())}
{disabled}
oninput={self.oninput.clone()}
onsave={ctx.props().on_save.clone()}
theme={ctx.props().selected_theme.clone().unwrap_or_default()}
/>
<div id="psp-expression-editor-meta">
<div class="error">
{ &self.error.clone().map(|e| e.error_message).unwrap_or_default() }
</div>
</div>
</div>
</>
}
}
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
if ctx.props().alias != old_props.alias
|| ctx.props().reset_count != old_props.reset_count
|| (ctx.props().alias.is_some() && ctx.props().metadata != old_props.metadata)
{
ctx.link()
.send_message(ExpressionEditorMsg::SetExpr(initial_expr(
&ctx.props().metadata,
&ctx.props().alias,
)));
false
} else {
true
}
}
}
fn initial_expr(metadata: &SessionMetadata, alias: &Option<String>) -> Rc<String> {
alias
.as_ref()
.and_then(|alias| metadata.get_expression_by_alias(alias))
.unwrap_or_default()
.into()
}
@@ -0,0 +1,289 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::cell::RefCell;
use std::collections::HashSet;
use std::rc::Rc;
use perspective_client::clone;
use web_sys::*;
use yew::html::ImplicitClone;
use yew::prelude::*;
use super::portal::PortalModal;
use super::style::StyleSurface;
use crate::session::Session;
use crate::utils::*;
use crate::*;
#[derive(Default)]
struct FilterDropDownState {
values: Vec<String>,
selected: usize,
on_select: Option<Callback<String>>,
target: Option<HtmlElement>,
}
#[derive(Clone)]
pub struct FilterDropDownElement {
state: Rc<RefCell<FilterDropDownState>>,
session: Session,
column: Rc<RefCell<Option<(usize, String)>>>,
all_values: Rc<RefCell<Option<Vec<String>>>>,
notify: Rc<PubSub<()>>,
}
impl PartialEq for FilterDropDownElement {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.state, &other.state)
}
}
impl ImplicitClone for FilterDropDownElement {}
impl FilterDropDownElement {
pub fn new(session: Session) -> Self {
Self {
state: Default::default(),
session,
column: Default::default(),
all_values: Default::default(),
notify: Rc::default(),
}
}
pub fn reautocomplete(&self) {
// Re-open portal with current target
self.notify.emit(());
}
pub fn autocomplete(
&self,
column: (usize, String),
input: String,
exclude: HashSet<String>,
target: HtmlElement,
callback: Callback<String>,
) {
let current_column = self.column.borrow().clone();
match current_column {
Some(filter_col) if filter_col == column => {
let values = filter_values(&input, &self.all_values, &exclude);
if values.len() == 1 && values[0] == input {
let _ = self.hide();
} else {
let mut s = self.state.borrow_mut();
s.values = values;
s.selected = 0;
s.on_select = Some(callback);
if s.target.is_none() {
s.target = Some(target);
}
drop(s);
self.notify.emit(());
}
},
_ => {
clone!(
self.state,
self.session,
self.all_values,
self.notify,
old_column = self.column
);
ApiFuture::spawn(async move {
let fetched =
crate::queries::get_column_values(&session, column.1.clone()).await?;
*all_values.borrow_mut() = Some(fetched);
let values = filter_values(&input, &all_values, &exclude);
let should_hide = values.len() == 1 && values[0] == input;
*old_column.borrow_mut() = Some(column);
{
let mut s = state.borrow_mut();
s.on_select = Some(callback);
if should_hide {
let fv = self::filter_values("", &all_values, &exclude);
s.values = fv;
s.target = Some(target);
} else {
s.values = values;
s.target = Some(target);
}
s.selected = 0;
}
if should_hide {
state.borrow_mut().target = None;
}
notify.emit(());
Ok(())
});
},
}
}
pub fn item_select(&self) {
let state = self.state.borrow();
if let Some(value) = state.values.get(state.selected)
&& let Some(ref cb) = state.on_select
{
cb.emit(value.clone());
}
}
pub fn item_down(&self) {
let mut state = self.state.borrow_mut();
state.selected += 1;
if state.selected >= state.values.len() {
state.selected = 0;
}
drop(state);
self.notify.emit(());
}
pub fn item_up(&self) {
let mut state = self.state.borrow_mut();
if state.selected < 1 {
state.selected = state.values.len();
}
state.selected -= 1;
drop(state);
self.notify.emit(());
}
pub fn hide(&self) -> ApiResult<()> {
self.state.borrow_mut().target = None;
self.column.borrow_mut().take();
self.notify.emit(());
Ok(())
}
}
#[derive(Properties, PartialEq)]
pub struct FilterDropDownPortalProps {
pub element: FilterDropDownElement,
pub theme: String,
}
pub struct FilterDropDownPortal {
_sub: Subscription,
}
impl Component for FilterDropDownPortal {
type Message = ();
type Properties = FilterDropDownPortalProps;
fn create(ctx: &Context<Self>) -> Self {
let link = ctx.link().clone();
let sub = ctx
.props()
.element
.notify
.add_listener(move |()| link.send_message(()));
Self { _sub: sub }
}
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let state = ctx.props().element.state.borrow();
let target = state.target.clone();
let on_close = {
let element = ctx.props().element.clone();
Callback::from(move |()| {
let _ = element.hide();
})
};
if target.is_some() {
let values = state.values.clone();
let selected = state.selected;
let on_select = state.on_select.clone();
drop(state);
html! {
<PortalModal
tag_name="perspective-dropdown"
surface={StyleSurface::FilterDropdown}
{target}
own_focus=false
{on_close}
theme={ctx.props().theme.clone()}
>
<FilterDropDownView {values} {selected} {on_select} />
</PortalModal>
}
} else {
html! {}
}
}
}
#[derive(Properties, PartialEq)]
struct FilterDropDownViewProps {
values: Vec<String>,
selected: usize,
on_select: Option<Callback<String>>,
}
#[function_component]
fn FilterDropDownView(props: &FilterDropDownViewProps) -> Html {
let body = html! {
if !props.values.is_empty() {
{ for props.values
.iter()
.enumerate()
.map(|(idx, value)| {
let click = props.on_select.as_ref().unwrap().reform({
let value = value.clone();
move |_: MouseEvent| value.clone()
});
html! {
if idx == props.selected {
<span onmousedown={click} class="selected">{ value }</span>
} else {
<span onmousedown={click}>{ value }</span>
}
}
}) }
} else {
<span class="no-results">{ "No Completions" }</span>
}
};
html! { <>{ body }</> }
}
fn filter_values(
input: &str,
values: &Rc<RefCell<Option<Vec<String>>>>,
exclude: &HashSet<String>,
) -> Vec<String> {
let input = input.to_lowercase();
if let Some(values) = &*values.borrow() {
values
.iter()
.filter(|x| x.to_lowercase().contains(&input) && !exclude.contains(x.as_str()))
.take(10)
.cloned()
.collect::<Vec<String>>()
} else {
vec![]
}
}
@@ -0,0 +1,239 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::cell::{Cell, Ref, RefCell};
use std::future::Future;
use std::iter::{Iterator, repeat_with};
use std::rc::Rc;
use futures::future::{join_all, select_all};
use perspective_js::utils::{global, *};
use wasm_bindgen::prelude::*;
use wasm_bindgen::{JsCast, intern};
use wasm_bindgen_futures::JsFuture;
use yew::prelude::*;
use crate::utils::*;
/// This test string is injected into the DOM with the target `font-family`
/// applied. It is important for this string to contain the correct unicode
/// range, as otherwise the browser may download the latin-only variant of the
/// font which will later be invalidated.
const FONT_TEST_SAMPLE: &str = "ABCDΔ";
/// How long to wait for the test string to receive the font before proceeding
/// anyway (with a consoel warning).
const FONT_DOWNLOAD_TIMEOUT_MS: i32 = 1000;
/// `state` is private to force construction of props with the `::new()` static
/// method, which initializes the async `load_fonts_task()` method.
#[derive(Clone, Properties)]
pub struct FontLoaderProps {
state: Rc<FontLoaderState>,
}
impl PartialEq for FontLoaderProps {
fn eq(&self, _rhs: &Self) -> bool {
false
}
}
#[function_component(FontLoader)]
pub fn font_loader(props: &FontLoaderProps) -> Html {
if matches!(props.get_status(), FontLoaderStatus::Finished) {
html! {}
} else {
let inner = props
.get_fonts()
.iter()
.map(font_test_html)
.collect::<Html>();
html! { <><style>{ ":host{opacity:0!important;}" }</style>{ inner }</> }
}
}
/// The possible font loading state, which proceeds from top to bottom once per
/// `<perspective-viewer>` element.
#[derive(Clone, Copy)]
pub enum FontLoaderStatus {
Uninitialized,
Loading,
Finished,
}
struct FontLoaderState {
status: Cell<FontLoaderStatus>,
elem: web_sys::HtmlElement,
on_update: Callback<()>,
fonts: RefCell<Vec<(String, String)>>,
}
type PromiseSet = Vec<ApiFuture<JsValue>>;
impl FontLoaderProps {
pub fn new(elem: &web_sys::HtmlElement, on_update: Callback<()>) -> Self {
let inner = FontLoaderState {
status: Cell::new(FontLoaderStatus::Uninitialized),
elem: elem.clone(),
on_update,
fonts: RefCell::new(vec![]),
};
let state = yew::props!(Self {
state: Rc::new(inner)
});
ApiFuture::spawn(state.clone().load_fonts_task_safe());
state
}
pub fn get_status(&self) -> FontLoaderStatus {
self.state.status.get()
}
fn get_fonts(&'_ self) -> Ref<'_, Vec<(String, String)>> {
self.state.fonts.borrow()
}
/// We only want errors in this task to warn, since they are not necessarily
/// error conditions and mainly of interest to developers.
async fn load_fonts_task_safe(self) -> ApiResult<JsValue> {
if let Err(msg) = self.load_fonts_task().await {
web_sys::console::warn_1(&msg.into());
};
Ok(JsValue::UNDEFINED)
}
/// Awaits loading of a required set of font/weight pairs, given an element
/// with a CSS variable of the format:
/// ```css
/// perspective-viewer {
/// --preload-fonts: "Roboto Mono:200;Open Sans:300,400";
/// }
/// ```
async fn load_fonts_task(self) -> ApiResult<JsValue> {
await_dom_loaded().await?;
let txt = global::window()
.get_computed_style(&self.state.elem)?
.unwrap()
.get_property_value("--preload-fonts")?;
let mut block_promises: PromiseSet = vec![];
let preload_fonts = parse_fonts(&txt);
self.state.fonts.borrow_mut().clone_from(&preload_fonts);
self.state.status.set(FontLoaderStatus::Loading);
self.state.on_update.emit(());
for (family, weight) in preload_fonts.iter() {
let task = timeout_font_task(family, weight);
let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
for entry in font_iter(global::document().fonts().values()) {
let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
.dyn_into::<web_sys::FontFace>()?;
// Safari always has to be "different".
if family == &font_face.family().replace('"', "")
&& (weight == &font_face.weight()
|| (font_face.weight() == "normal" && weight == "400"))
{
block_fonts.push(ApiFuture::new(async move {
Ok(JsFuture::from(font_face.loaded()?).await?)
}));
}
}
let fut = async { select_all(block_fonts).await.0 };
block_promises.push(ApiFuture::new(fut))
}
if block_promises.len() != preload_fonts.len() {
web_sys::console::warn_1(&format!("Missing preload fonts {:?}", preload_fonts).into());
}
let res = join_all(block_promises)
.await
.into_iter()
.collect::<ApiResult<Vec<JsValue>>>()
.map(|_| JsValue::UNDEFINED);
self.state.status.set(FontLoaderStatus::Finished);
self.state.on_update.emit(());
res
}
}
// An async task which times out. Can be used to timeout an optional async task
// by combinging with `Promise::any`.
fn timeout_font_task(
family: &str,
weight: &str,
) -> impl Future<Output = ApiResult<JsValue>> + use<> {
let timeout_msg = format!("Timeout awaiting font \"{family}:{weight}\"");
async {
set_timeout(FONT_DOWNLOAD_TIMEOUT_MS).await?;
Err(timeout_msg.into())
}
}
/// Generates a `<span>` for a specific font family and weight with `opacity:0`,
/// since not all of the fonts may be shown and when e.g. the settings panel is
/// closed, and this will defer font loading.
fn font_test_html((family, weight): &(String, String)) -> Html {
let style = format!("opacity:0;font-family:\"{family}\";font-weight:{weight}");
html! { <span {style}>{ FONT_TEST_SAMPLE }</span> }
}
fn parse_font(txt: &str) -> Option<Vec<(String, String)>> {
match *txt.trim().split(':').collect::<Vec<_>>().as_slice() {
[family, weights] => Some(
weights
.split(',')
.map(|weight| (family.to_owned(), weight.to_owned()))
.collect::<Vec<_>>(),
),
_ => None,
}
}
/// Parse a `--preload-fonts` value into (Family, Weight) tuples.
fn parse_fonts(txt: &str) -> Vec<(String, String)> {
let trim = txt.trim();
let trim = if trim.len() > 2 {
&trim[1..trim.len() - 1]
} else {
trim
};
trim.split(';')
.filter_map(parse_font)
.flatten()
.collect::<Vec<_>>()
}
/// wasm_bindgen doesn't fully implement `FontFaceIterator`, but this is
/// basically how it would be implemented if it was.
fn font_iter(
iter: web_sys::FontFaceSetIterator,
) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
repeat_with(move || iter.next())
.filter_map(|x| x.ok())
.take_while(|entry| {
!js_sys::Reflect::get(entry, &intern("done").into())
.unwrap()
.as_bool()
.unwrap()
})
}
@@ -0,0 +1,299 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use perspective_client::ExprValidationError;
use wasm_bindgen::JsCast;
use web_sys::*;
use yew::prelude::*;
use crate::components::form::highlight::highlight;
use crate::components::function_dropdown::{FunctionDropDownElement, FunctionDropDownPortal};
use crate::exprtk::{Cursor, tokenize};
use crate::utils::*;
#[derive(Properties, PartialEq)]
pub struct CodeEditorProps {
pub expr: Rc<String>,
pub oninput: Callback<Rc<String>>,
pub onsave: Callback<()>,
pub disabled: bool,
#[prop_or_default]
pub autofocus: bool,
#[prop_or_default]
pub wordwrap: bool,
#[prop_or_default]
pub autosuggest: bool,
#[prop_or_default]
pub select_all: Subscriber<()>,
#[prop_or_default]
pub error: Option<ExprValidationError>,
/// Selected theme name, threaded for PortalModal consumers.
#[prop_or_default]
pub theme: String,
}
/// A syntax-highlighted text editor component.
#[function_component(CodeEditor)]
pub fn code_editor(props: &CodeEditorProps) -> Html {
let select_all = use_state_eq(|| false);
let caret_position = use_state_eq(|| 0_u32);
let scroll_offset = use_state_eq(|| (0, 0));
let textarea_ref = use_node_ref();
let content_ref = use_node_ref();
let lineno_ref = use_node_ref();
let filter_dropdown = use_memo((), |_| FunctionDropDownElement::default());
let mut cursor = Cursor::new(&props.error);
let terms = tokenize(&props.expr)
.into_iter()
.map(|token| highlight(&mut cursor, token, *caret_position))
.collect::<Html>();
let onkeydown = use_callback((caret_position.setter(), props.onsave.clone()), on_keydown);
let oninput = use_callback(props.oninput.clone(), |event, deps| {
on_input_callback(event, deps)
});
let onscroll = use_callback((scroll_offset.setter(), textarea_ref.clone()), |_, deps| {
on_scroll(&deps.0, &deps.1)
});
let autofocus = props.autofocus;
use_effect_with((props.expr.clone(), textarea_ref.clone()), {
move |(expr, textarea_ref)| {
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
if autofocus {
elem.focus().unwrap();
}
if **expr != elem.value() {
elem.set_value(&format!("{expr}"));
elem.set_scroll_top(0);
elem.set_scroll_left(0);
elem.set_caret_position(0).unwrap();
}
}
});
// select_all.set(props.select_all);
use_effect_with((select_all.setter(), props.select_all.clone()), {
move |(select_all, props_select_all)| {
clone!(select_all);
let sub = props_select_all.add_listener(move |()| select_all.set(true));
move || drop(sub)
}
});
// select_all.set(props.select_all);
use_effect_with((select_all, textarea_ref.clone()), {
move |(select_all, textarea_ref)| {
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
if **select_all {
elem.focus().unwrap();
elem.select_all().unwrap();
}
select_all.set(false);
}
});
// ????
let autofocus = props.autofocus;
use_effect_with((props.error.clone(), textarea_ref.clone()), {
move |(_, textarea_ref)| {
if autofocus {
let elem = textarea_ref.cast::<web_sys::HtmlTextAreaElement>().unwrap();
elem.focus().unwrap();
}
}
});
// Sync scrolling between textarea and formatted HTML
use_effect_with(
(scroll_offset, content_ref.clone(), lineno_ref.clone()),
|deps| scroll_sync(&deps.0, &deps.1, &deps.2),
);
let portal_dropdown = filter_dropdown.clone();
// Blur if this element is not in the tree
use_effect_with(filter_dropdown.clone(), |filter_dropdown| {
clone!(filter_dropdown);
move || filter_dropdown.hide().unwrap()
});
// Show autocomplete
use_effect_with(
(
props.autosuggest,
filter_dropdown,
cursor.auto.clone(),
cursor.noderef.clone(),
),
|deps| {
if deps.0 {
autocomplete(&deps.1, &deps.2, &deps.3)
}
},
);
let error_line = props.error.as_ref().map(|x| x.line);
let line_numbers = cursor
.map_rows(|x| html!(
<span
class={if Some(x) == error_line {"line_number error_highlight"} else {"line_number"}}
>
{ x + 1 }
</span>
))
.collect::<Html>();
let class = if props.wordwrap {
"wordwrap scrollable"
} else {
"scrollable"
};
clone!(props.disabled);
html! {
<>
<div id="editor" {class}>
<div id="line_numbers" ref={lineno_ref}>{ line_numbers }</div>
<div id="editor-inner" {class}>
<textarea
{disabled}
id="textarea_editable"
class="scrollable"
ref={textarea_ref}
spellcheck="false"
{oninput}
{onscroll}
{onkeydown}
/>
<div id="editor-height-sizer" />
<pre id="content" ref={content_ref}>
{ terms }
{ {
// A linebreak which pushs a textarea into scroll overflow
// may not necessarily do so in the `<pre>`, because there is
// no cursor when the last line has no content, so add
// some space here to make sure overlfow is in sync
// with the text area.
" "
} }
</pre>
</div>
</div>
<FunctionDropDownPortal
element={(*portal_dropdown).clone()}
theme={props.theme.clone()}
/>
</>
}
}
/// Capture the input (for re-parse) and caret position whenever the input
/// text changes.
fn on_input_callback(
event: InputEvent,
// position: &UseStateSetter<u32>,
oninput: &Callback<Rc<String>>,
) {
let elem = event
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlTextAreaElement>();
oninput.emit(elem.value().into());
// position.set(elem.get_caret_position().unwrap_or_default());
}
/// Overide for special non-character commands e.g. shift+enter
fn on_keydown(event: KeyboardEvent, deps: &(UseStateSetter<u32>, Callback<()>)) {
let elem = event
.target()
.unwrap()
.unchecked_into::<web_sys::HtmlTextAreaElement>();
deps.0.set(elem.get_caret_position().unwrap_or_default());
if event.shift_key() && event.key_code() == 13 {
event.prevent_default();
deps.1.emit(())
}
// handle the tab key press
if event.key() == "Tab" {
event.prevent_default();
let caret_pos = elem.selection_start().unwrap().unwrap_or_default() as usize;
let mut initial_text = elem.value();
initial_text.insert(caret_pos, '\t');
elem.set_value(&initial_text);
let input_event = web_sys::InputEvent::new("input").unwrap();
let _ = elem.dispatch_event(&input_event).unwrap();
// place caret after inserted tab
let new_caret_pos = (caret_pos + 1) as u32;
let _ = elem.set_selection_range(new_caret_pos, new_caret_pos);
elem.focus().unwrap();
}
}
/// Scrolling callback
fn on_scroll(scroll: &UseStateSetter<(i32, i32)>, editable: &NodeRef) {
let div = editable.cast::<HtmlElement>().unwrap();
scroll.set((div.scroll_top(), div.scroll_left()));
}
/// Scrolling sync
fn scroll_sync(scroll: &UseStateHandle<(i32, i32)>, div: &NodeRef, lineno: &NodeRef) {
if let Some(div) = div.cast::<HtmlElement>() {
div.set_scroll_top(scroll.0);
div.set_scroll_left(scroll.1);
}
if let Some(div) = lineno.cast::<HtmlElement>() {
div.set_scroll_top(scroll.0);
}
}
/// Autocomplete
/// TODO this should use a portal
fn autocomplete(
filter_dropdown: &Rc<FunctionDropDownElement>,
token: &Option<String>,
target: &NodeRef,
) {
if let Some(x) = token {
let elem = target.cast::<HtmlElement>().unwrap();
if elem.is_connected() {
filter_dropdown
.autocomplete(x.clone(), elem, Callback::from(|_| ()))
.unwrap();
} else {
filter_dropdown.hide().unwrap();
}
} else {
filter_dropdown.hide().unwrap();
}
}
@@ -0,0 +1,137 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use wasm_bindgen::JsCast;
use web_sys::*;
use yew::prelude::*;
#[derive(Properties, PartialEq, Clone)]
pub struct ColorRangeProps {
/// Extra class appended to the positive input's `class` attribute,
/// so callers can target the pos / neg sides independently from CSS.
/// Paired with `neg_class`; both default to empty.
#[prop_or_default]
pub pos_class: Classes,
#[prop_or_default]
pub neg_class: Classes,
pub pos_color: String,
pub neg_color: String,
pub is_gradient: bool,
pub on_pos_color: Callback<String>,
pub on_neg_color: Callback<String>,
pub on_reset: Callback<()>,
pub is_modified: bool,
}
#[function_component(ColorRangeSelector)]
pub fn color_chooser_component(props: &ColorRangeProps) -> Html {
let on_pos_color = use_callback(
props.on_pos_color.clone(),
|event: InputEvent, on_pos_color| {
let color = event
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value();
on_pos_color.emit(color);
},
);
let on_neg_color = use_callback(
props.on_neg_color.clone(),
|event: InputEvent, on_neg_color| {
let color = event
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value();
on_neg_color.emit(color);
},
);
let fg_pos = infer_fg(&props.pos_color);
let fg_neg = infer_fg(&props.neg_color);
let style = if props.is_gradient {
format!(
"background:linear-gradient(to right, {} 0%, transparent 50%, {} 100%)",
props.pos_color, props.neg_color
)
} else {
format!(
"background:linear-gradient(to right, {} 0%, {} 50%, {} 50%, {} 100%)",
props.pos_color, props.pos_color, props.neg_color, props.neg_color
)
};
let on_reset = use_callback(props.clone(), |_: MouseEvent, deps| deps.on_reset.emit(()));
let pos_class = classes!("parameter", "pos-color-param", props.pos_class.clone());
let neg_class = classes!("parameter", "neg-color-param", props.neg_class.clone());
html! {
<>
<label id="color-range-label" />
<div class="color-gradient-container">
<div style={fg_pos} class="color-selector">
<input
class={pos_class}
type="color"
value={props.pos_color.to_owned()}
data-value={props.pos_color.to_owned()}
oninput={on_pos_color}
/>
<label class="color-label">{ "+" }</label>
</div>
<div class="color-thermometer" {style} />
<div style={fg_neg} class="color-selector">
<input
class={neg_class}
type="color"
value={props.neg_color.to_owned()}
data-value={props.neg_color.to_owned()}
oninput={on_neg_color}
/>
<label class="color-label">{ "-" }</label>
</div>
if props.is_modified {
<span class="reset-default-style" onclick={on_reset} />
} else {
<span class="reset-default-style-disabled" />
}
</div>
</>
}
}
fn infer_fg(color: &str) -> &'static str {
if color.len() >= 7 {
let r = i32::from_str_radix(&color[1..3], 16).unwrap_or(255) as f64;
let g = i32::from_str_radix(&color[3..5], 16).unwrap_or(0) as f64;
let b = i32::from_str_radix(&color[5..7], 16).unwrap_or(0) as f64;
if (r * r * 0.299 + g * g * 0.587 + b * b * 0.114).sqrt() > 130.0 {
"--sign--color:var(--psp--color)"
} else {
"--sign--color:var(--psp--background-color)"
}
} else if color.len() == 4 {
let r = i32::from_str_radix(&color[1..2], 16).unwrap_or(15) as f64;
let g = i32::from_str_radix(&color[2..3], 16).unwrap_or(0) as f64;
let b = i32::from_str_radix(&color[3..4], 16).unwrap_or(0) as f64;
if (r * r * 0.299 + g * g * 0.587 + b * b * 0.114).sqrt() > 8.0 {
"--sign--color:var(--psp--color)"
} else {
"--sign--color:var(--psp--background-color)"
}
} else {
"--sign--color:var(--psp--color)"
}
}
@@ -0,0 +1,56 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use wasm_bindgen::JsCast;
use web_sys::*;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct ColorProps {
pub color: String,
pub on_color: Callback<String>,
pub on_reset: Callback<()>,
pub is_modified: bool,
#[prop_or_default]
pub title: Option<String>,
}
#[function_component(ColorSelector)]
pub fn color_component(props: &ColorProps) -> Html {
let oninput = use_callback(props.on_color.clone(), |event: InputEvent, deps| {
let color = event
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value();
deps.emit(color);
});
let on_reset = use_callback(props.on_reset.clone(), |_: MouseEvent, on_reset| {
on_reset.emit(())
});
html! {
<>
<label id={props.title.as_deref().unwrap_or("color-label").to_owned()} />
<div class="color-gradient-container">
<input class="parameter" type="color" value={props.color.to_owned()} {oninput} />
if props.is_modified {
<span class="reset-default-style" onclick={on_reset} />
} else {
<span class="reset-default-style-disabled" />
}
</div>
</>
}
}
@@ -0,0 +1,277 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::rc::Rc;
use perspective_client::ExprValidationError;
use perspective_js::utils::{ApiFuture, JsValueSerdeExt};
use wasm_bindgen::prelude::*;
use yew::prelude::*;
use crate::components::form::code_editor::CodeEditor;
use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard};
use crate::presentation::*;
use crate::renderer::*;
use crate::session::*;
use crate::utils::*;
#[derive(Clone, PartialEq, Properties)]
pub struct DebugPanelProps {
pub presentation: Presentation,
pub renderer: Renderer,
pub session: Session,
/// Trap-door width pinned by the parent `SettingsPanel` so switching
/// tabs doesn't shrink the panel. Threaded into the hidden sizer
/// `<div class="scroll-panel-auto-width">`.
#[prop_or_default]
pub initial_width: f64,
/// Fires once on mount with this panel's measured natural width.
/// Routed up to `SettingsPanel` which keeps the running max.
#[prop_or_default]
pub on_auto_width: Callback<f64>,
}
#[function_component(DebugPanel)]
pub fn debug_panel(props: &DebugPanelProps) -> Html {
let expr = use_state_eq(|| Rc::new("".to_string()));
let error = use_state_eq(|| Option::<ExprValidationError>::None);
let select_all = use_memo((), |()| PubSub::default());
let modified = use_state_eq(|| false);
// Measure natural width on mount and route up to `SettingsPanel`.
let sizer = use_node_ref();
use_effect_with(expr.clone(), {
let sizer = sizer.clone();
let on_auto_width = props.on_auto_width.clone();
move |_| {
if let Some(elem) = sizer.cast::<web_sys::HtmlElement>() {
on_auto_width.emit(elem.get_bounding_client_rect().width());
}
}
});
use_effect_with((expr.setter(), props.clone()), {
clone!(error, modified);
move |(text, state)| {
state.set_text(text.clone());
error.set(None);
let sub1 = state
.renderer
.style_changed
.add_listener(state.reset_callback(
text.clone(),
error.setter(),
modified.setter(),
));
let sub2 = state
.renderer
.reset_changed
.add_listener(state.reset_callback(
text.clone(),
error.setter(),
modified.setter(),
));
let sub3 = state
.session
.view_config_changed
.add_listener(state.reset_callback(
text.clone(),
error.setter(),
modified.setter(),
));
|| {
drop(sub1);
drop(sub2);
drop(sub3);
}
}
});
let oninput = use_callback(expr.setter(), {
clone!(modified);
move |x, expr| {
modified.set(true);
expr.set(x)
}
});
let onsave = use_callback((expr.clone(), error.clone(), props.clone()), {
clone!(modified);
move |_, (text, error, props)| props.on_save(text, error, &modified)
});
let oncopy = use_callback(
(expr.clone(), select_all.callback()),
move |_, (text, select_all)| {
select_all.emit(());
let options = web_sys::BlobPropertyBag::new();
options.set_type("text/plain");
let blob_txt = (JsValue::from((***text).clone())).clone();
let blob_parts = js_sys::Array::from_iter([blob_txt].iter());
let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &options);
ApiFuture::spawn(copy_to_clipboard(
async move { Ok(blob?) },
MimeType::TextPlain,
));
},
);
let onapply = use_callback((expr.clone(), error.clone(), props.clone()), {
clone!(modified);
move |_, (text, error, props)| props.on_save(text, error, &modified)
});
let onreset = use_callback((expr.setter(), error.clone(), props.clone()), {
clone!(modified);
move |_, (text, error, props)| {
props.set_text(text.clone());
error.set(None);
modified.set(false);
}
});
let onpaste = use_callback((expr.clone(), error.clone(), props.clone()), {
clone!(modified);
move |_, (text, error, props)| {
clone!(text, error, props, modified);
ApiFuture::spawn(async move {
if let Some(x) = paste_from_clipboard().await {
let x = Rc::new(x);
modified.set(true);
error.set(None);
text.set(x.clone());
props.on_save(&x, &error, &modified);
}
Ok(())
});
}
});
html! {
<>
<div id="debug-panel-overflow">
<div id="debug-panel" class="sidebar_column" ref={sizer}>
<div id="debug-panel-controls">
<button disabled={!*modified} onclick={onapply}>{ "Apply" }</button>
<button disabled={!*modified} onclick={onreset}>{ "Reset" }</button>
<button onclick={oncopy}>{ "Copy" }</button>
<button onclick={onpaste}>{ "Paste" }</button>
</div>
<div id="debug-panel-editor">
<CodeEditor
expr={&*expr}
disabled=false
{oninput}
{onsave}
select_all={select_all.subscriber()}
error={(*error).clone()}
/>
</div>
<div
class="scroll-panel-auto-width"
style={format!("width:{}px", props.initial_width)}
/>
</div>
</div>
</>
}
}
impl DebugPanelProps {
fn set_text(&self, setter: UseStateSetter<Rc<String>>) {
let props = self.clone();
ApiFuture::spawn(async move {
let config = crate::queries::get_viewer_config(
&props.session,
&props.renderer,
&props.presentation,
)
.await?;
let json = JsValue::from_serde_ext(&config)?;
let js_string =
js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?;
setter.set(Rc::new(js_string.as_string().unwrap()));
Ok(())
});
}
fn reset_callback(
&self,
text: UseStateSetter<Rc<String>>,
error: UseStateSetter<Option<ExprValidationError>>,
modified: UseStateSetter<bool>,
) -> impl Fn(()) + use<> {
let props = self.clone();
move |_| {
error.set(None);
props.set_text(text.clone());
modified.set(false);
}
}
fn on_save(
&self,
text: &Rc<String>,
error: &UseStateHandle<Option<ExprValidationError>>,
modified: &UseStateHandle<bool>,
) {
let props = self.clone();
clone!(text, error, modified);
ApiFuture::spawn(async move {
match serde_json::from_str(&text) {
Ok(config) => {
match crate::tasks::restore_and_render(
&props.session,
&props.renderer,
&props.presentation,
config,
async { Ok(()) },
)
.await
{
Ok(_) => {
modified.set(false);
},
Err(e) => {
modified.set(true);
error.set(Some(ExprValidationError {
error_message: JsValue::from(e).as_string().unwrap_or_else(|| {
"Failed to validate viewer config".to_owned()
}),
line: 0_u32,
column: 0,
}));
},
}
Ok(())
},
Err(err) => {
modified.set(true);
error.set(Some(ExprValidationError {
error_message: err.to_string(),
line: err.line() as u32 - 1,
column: err.column() as u32 - 1,
}));
Ok(())
},
}
});
}
}
@@ -0,0 +1,52 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::prelude::*;
use crate::exprtk::{Cursor, Token};
/// Highlight a token if the cursor overlaps an error. This is not a
/// `Component` because of the the lifetimes associated with `Cursor<'a>` etc.
/// This makes the performance assumption that naively re-rendering is faster
/// than allocating this data on the heap (plus a few copies), since virtually
/// any update to the `CodeEditor` component will be an update to the parsed
/// text.
///
/// TODO Validate this assumption with actual data.
pub fn highlight<'a>(cursor: &mut Cursor<'a>, token: Token<'a>, position: u32) -> Html {
cursor.txt = token.content();
let is_auto = cursor.is_autocomplete(position);
let is_break = matches!(token, Token::Break(_));
let is_overlap = cursor.is_error();
let result = match (is_auto, is_overlap, is_break) {
(true, true, false) => html! {
<span ref={cursor.noderef.clone()} class="error_highlight">{ token.to_html() }</span>
},
(false, true, false) => html! { <span class="error_highlight">{ token.to_html() }</span> },
(true, false, false) => {
html! { <span ref={cursor.noderef.clone()}>{ token.to_html() }</span> }
},
_ => token.to_html(),
};
if is_auto && matches!(token, Token::Symbol(_)) {
cursor.auto = Some(token.content().to_owned());
}
if matches!(token, Token::Break(_)) {
cursor.increment_line();
} else {
cursor.increment_column(token.content().len() as u32);
}
result
}
@@ -0,0 +1,30 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! A module for form controls (though form controls like `<Select>` which are
//! also container elements should go in the `containers` module).
//!
//! Components in this module should not be imported from the `components`
//! parent module directly.
pub mod code_editor;
pub mod color_range_selector;
pub mod color_selector;
pub mod debug;
pub mod number_field;
pub mod number_input;
pub mod number_range_field;
pub mod optional_field;
pub mod select_enum_field;
pub mod select_value_field;
mod highlight;
@@ -0,0 +1,72 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use web_sys::{HtmlInputElement, InputEvent};
use yew::{Callback, Properties, TargetCast, function_component, html};
use crate::components::form::optional_field::OptionalField;
#[derive(Properties, Debug, PartialEq, Clone)]
pub struct NumberFieldProps {
pub label: String,
pub current_value: Option<f64>,
pub default: f64,
pub on_change: Callback<Option<f64>>,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub min: Option<f64>,
#[prop_or_default]
pub max: Option<f64>,
#[prop_or_default]
pub step: Option<f64>,
}
#[function_component(NumberField)]
pub fn number_field(props: &NumberFieldProps) -> yew::Html {
let parse_number_input = |event: InputEvent| {
Some(
event
.target_unchecked_into::<HtmlInputElement>()
.value_as_number(),
)
};
let number_input = html! {
<input
type="number"
class="parameter"
min={props.min.map(|val| val.to_string())}
max={props.max.map(|val| val.to_string())}
step={props.step.map(|val| val.to_string())}
value={props.current_value.unwrap_or(props.default).to_string()}
oninput={props.on_change.reform(parse_number_input)}
/>
};
html! {
<div class="row">
<OptionalField
label={props.label.clone()}
on_check={props.on_change.reform(|_| None)}
checked={props.current_value.map(|val| val != props.default).unwrap_or_default()}
disabled={props.disabled}
>
{ number_input }
</OptionalField>
</div>
}
}
@@ -0,0 +1,45 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use wasm_bindgen::*;
use web_sys::*;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct NumberInputProps {
pub max_value: f64,
pub on_max_value: Callback<String>,
}
#[function_component(NumberInput)]
pub fn number_input(props: &NumberInputProps) -> Html {
let oninput = props.on_max_value.reform(|event: InputEvent| {
event
.target()
.unwrap()
.unchecked_into::<HtmlInputElement>()
.value()
});
html! {
<>
<label>{ "Max" }</label>
<input
value={format!("{}", props.max_value)}
class="parameter"
type="number"
min="0"
{oninput}
/>
</>
}
}
@@ -0,0 +1,77 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use web_sys::HtmlInputElement;
use yew::{Callback, Properties, function_component, html, use_callback, use_node_ref};
use crate::components::form::optional_field::OptionalField;
#[derive(Properties, Debug, PartialEq, Clone)]
pub struct NumberRangeFieldProps {
pub label: String,
pub current_value: Option<(f64, f64)>,
pub default: (f64, f64),
pub on_change: Callback<Option<(f64, f64)>>,
#[prop_or_default]
pub min: Option<f64>,
#[prop_or_default]
pub max: Option<f64>,
#[prop_or_default]
pub step: Option<f64>,
}
#[function_component(NumberRangeField)]
pub fn number_range_field(props: &NumberRangeFieldProps) -> yew::Html {
let ref1 = use_node_ref();
let ref2 = use_node_ref();
let parse_number_input = use_callback(
(ref1.clone(), ref2.clone(), props.on_change.clone()),
|_, deps| {
deps.2.emit(Some((
deps.0.cast::<HtmlInputElement>().unwrap().value_as_number(),
deps.1.cast::<HtmlInputElement>().unwrap().value_as_number(),
)));
},
);
html! {
<OptionalField
label={props.label.clone()}
on_check={props.on_change.reform(|_| None)}
checked={props.current_value.map(|val| val != props.default).unwrap_or_default()}
>
<input
type="number"
class="parameter parameter-min"
ref={ref1}
min={props.min.map(|val| val.to_string())}
max={props.max.map(|val| val.to_string())}
step={props.step.map(|val| val.to_string())}
value={props.current_value.unwrap_or(props.default).0.to_string()}
oninput={parse_number_input.clone()}
/>
<input
type="number"
class="parameter parameter-max"
ref={ref2}
min={props.min.map(|val| val.to_string())}
max={props.max.map(|val| val.to_string())}
step={props.step.map(|val| val.to_string())}
value={props.current_value.unwrap_or(props.default).1.to_string()}
oninput={parse_number_input}
/>
</OptionalField>
}
}
@@ -0,0 +1,53 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use yew::{Callback, Children, Html, MouseEvent, Properties, classes, function_component, html};
#[derive(Properties, PartialEq)]
pub struct OptionalFieldProps {
pub checked: bool,
pub children: Children,
pub label: String,
pub on_check: Callback<MouseEvent>,
#[prop_or(String::from("section"))]
pub class: String,
#[prop_or_default]
pub disabled: bool,
}
#[function_component(OptionalField)]
pub fn optional_field(props: &OptionalFieldProps) -> Html {
html! {
<>
<label id={format!("{}-label", props.label)} />
<div
class={classes!(props.class.clone(), props.checked.then_some("is-default-value"))}
>
{ props.children.clone() }
if props.checked {
<span
class="reset-default-style"
onclick={props.on_check.clone()}
id={format!("{}-checkbox", props.label)}
/>
} else {
<span
class="reset-default-style-disabled"
id={format!("{}-checkbox", props.label)}
/>
}
</div>
</>
}
}
@@ -0,0 +1,59 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::{Debug, Display};
use itertools::Itertools;
use strum::IntoEnumIterator;
use yew::{Callback, Properties, function_component, html};
use crate::components::containers::select::{Select, SelectItem};
use crate::components::form::optional_field::OptionalField;
#[derive(Properties, Debug, PartialEq, Clone)]
pub struct SelectEnumFieldProps<T>
where
T: IntoEnumIterator + Display + Default + PartialEq + Clone + 'static,
{
pub current_value: Option<T>,
pub label: String,
pub on_change: Callback<Option<T>>,
#[prop_or_default]
pub default_value: Option<T>,
#[prop_or_default]
pub disabled: bool,
}
#[function_component(SelectEnumField)]
pub fn select_enum_field<T>(props: &SelectEnumFieldProps<T>) -> yew::Html
where
T: IntoEnumIterator + Debug + Display + Default + PartialEq + Clone + 'static,
{
let values = yew::use_memo((), |_| T::iter().map(SelectItem::Option).collect_vec());
let selected = props.current_value.clone().unwrap_or_default();
let checked = selected != props.default_value.clone().unwrap_or_default();
let reset_value = props.default_value.clone();
html! {
<div class="row">
<OptionalField
label={props.label.clone()}
on_check={props.on_change.reform(move |_| reset_value.clone())}
{checked}
disabled={props.disabled}
>
<Select<T> {values} {selected} on_select={props.on_change.reform(Option::Some)} />
</OptionalField>
</div>
}
}
@@ -0,0 +1,64 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::{Debug, Display};
use std::rc::Rc;
use itertools::Itertools;
use yew::{Callback, Properties, function_component, html};
use crate::components::containers::select::{Select, SelectItem};
use crate::components::form::optional_field::OptionalField;
#[derive(Properties, Debug, PartialEq, Clone)]
pub struct SelectValueFieldProps<T>
where
T: Display + PartialEq + Clone + 'static,
{
pub current_value: Option<T>,
pub default_value: T,
pub label: String,
pub on_change: Callback<Option<T>>,
pub values: Rc<Vec<T>>,
#[prop_or_default]
pub disabled: bool,
}
#[function_component(SelectValueField)]
pub fn select_value_field<T>(props: &SelectValueFieldProps<T>) -> yew::Html
where
T: Display + PartialEq + Clone + 'static,
{
let values = yew::use_memo(props.values.clone(), |values| {
values.iter().cloned().map(SelectItem::Option).collect_vec()
});
let selected = props
.current_value
.clone()
.unwrap_or_else(|| props.default_value.clone());
let checked = selected != props.default_value;
html! {
<div class="row">
<OptionalField
label={props.label.clone()}
on_check={props.on_change.reform(|_| None)}
{checked}
disabled={props.disabled}
>
<Select<T> {values} {selected} on_select={props.on_change.reform(Option::Some)} />
</OptionalField>
</div>
}
}
@@ -0,0 +1,224 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::cell::RefCell;
use std::rc::Rc;
use perspective_client::config::{COMPLETIONS, CompletionItemSuggestion};
use perspective_js::utils::ApiResult;
use web_sys::*;
use yew::html::ImplicitClone;
use yew::prelude::*;
use super::portal::PortalModal;
use super::style::StyleSurface;
use crate::utils::*;
#[derive(Default)]
struct FunctionDropDownState {
values: Vec<CompletionItemSuggestion>,
selected: usize,
on_select: Option<Callback<CompletionItemSuggestion>>,
target: Option<HtmlElement>,
}
#[derive(Clone, Default)]
pub struct FunctionDropDownElement {
state: Rc<RefCell<FunctionDropDownState>>,
notify: Rc<PubSub<()>>,
}
impl PartialEq for FunctionDropDownElement {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.state, &other.state)
}
}
impl ImplicitClone for FunctionDropDownElement {}
impl FunctionDropDownElement {
pub fn reautocomplete(&self) {
self.notify.emit(());
}
pub fn autocomplete(
&self,
input: String,
target: HtmlElement,
callback: Callback<CompletionItemSuggestion>,
) -> ApiResult<()> {
let values = filter_values(&input);
if values.is_empty() {
self.hide()?;
} else {
let mut s = self.state.borrow_mut();
s.values = values;
s.selected = 0;
s.on_select = Some(callback);
s.target = Some(target);
drop(s);
self.notify.emit(());
}
Ok(())
}
pub fn item_select(&self) {
let state = self.state.borrow();
if let Some(value) = state.values.get(state.selected)
&& let Some(ref cb) = state.on_select
{
cb.emit(*value);
}
}
pub fn item_down(&self) {
let mut state = self.state.borrow_mut();
state.selected += 1;
if state.selected >= state.values.len() {
state.selected = 0;
}
drop(state);
self.notify.emit(());
}
pub fn item_up(&self) {
let mut state = self.state.borrow_mut();
if state.selected < 1 {
state.selected = state.values.len();
}
state.selected -= 1;
drop(state);
self.notify.emit(());
}
pub fn hide(&self) -> ApiResult<()> {
self.state.borrow_mut().target = None;
self.notify.emit(());
Ok(())
}
}
#[derive(Properties, PartialEq)]
pub struct FunctionDropDownPortalProps {
pub element: FunctionDropDownElement,
pub theme: String,
}
pub struct FunctionDropDownPortal {
_sub: Subscription,
}
impl Component for FunctionDropDownPortal {
type Message = ();
type Properties = FunctionDropDownPortalProps;
fn create(ctx: &Context<Self>) -> Self {
let link = ctx.link().clone();
let sub = ctx
.props()
.element
.notify
.add_listener(move |()| link.send_message(()));
Self { _sub: sub }
}
fn update(&mut self, _ctx: &Context<Self>, _msg: ()) -> bool {
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let state = ctx.props().element.state.borrow();
let target = state.target.clone();
let on_close = {
let element = ctx.props().element.clone();
Callback::from(move |()| {
let _ = element.hide();
})
};
if target.is_some() {
let values = state.values.clone();
let selected = state.selected;
let on_select = state.on_select.clone();
drop(state);
html! {
<PortalModal
tag_name="perspective-dropdown"
surface={StyleSurface::FunctionDropdown}
{target}
own_focus=false
{on_close}
theme={ctx.props().theme.clone()}
>
<FunctionDropDownView {values} {selected} {on_select} />
</PortalModal>
}
} else {
html! {}
}
}
}
#[derive(Properties, PartialEq)]
struct FunctionDropDownViewProps {
values: Vec<CompletionItemSuggestion>,
selected: usize,
on_select: Option<Callback<CompletionItemSuggestion>>,
}
#[function_component]
fn FunctionDropDownView(props: &FunctionDropDownViewProps) -> Html {
let body = html! {
if !props.values.is_empty() {
{ for props.values
.iter()
.enumerate()
.map(|(idx, value)| {
let click = props.on_select.as_ref().unwrap().reform({
let value = *value;
move |_: MouseEvent| value
});
html! {
if idx == props.selected {
<div onmousedown={click} class="selected">
<span style="font-weight:500">{ value.label }</span>
<br/>
<span style="padding-left:12px">{ value.documentation }</span>
</div>
} else {
<div onmousedown={click}>
<span style="font-weight:500">{ value.label }</span>
<br/>
<span style="padding-left:12px">{ value.documentation }</span>
</div>
}
}
}) }
}
};
html! { <>{ body }</> }
}
fn filter_values(input: &str) -> Vec<CompletionItemSuggestion> {
let input = input.to_lowercase();
COMPLETIONS
.iter()
.filter(|x| x.label.to_lowercase().starts_with(&input))
.cloned()
.collect::<Vec<_>>()
}
@@ -0,0 +1,160 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use wasm_bindgen::prelude::*;
use yew::prelude::*;
use super::render_warning::RenderWarning;
use super::status_bar::StatusBar;
use crate::presentation::{Presentation, PresentationProps};
use crate::renderer::*;
use crate::session::{Session, SessionProps};
use crate::tasks::dismiss_render_warning_callback;
#[derive(Clone, Properties)]
pub struct MainPanelProps {
pub on_settings: Callback<()>,
/// Reset callback forwarded from the root component. Fired when the user
/// clicks the reset button; `bool` is `true` for a full reset (expressions
/// + column configs), `false` for config-only.
pub on_reset: Callback<bool>,
/// Snapshots threaded from root. Read for `has_table`, `title` here in
/// the panel itself; threaded wholesale to `StatusBar`/`StatusIndicator`.
pub session_props: SessionProps,
pub renderer_props: RendererProps,
pub presentation_props: PresentationProps,
/// Derived from root: `settings_open && has_table_loaded`.
pub is_settings_open: bool,
/// Root-managed in-flight render counter (not engine state).
pub update_count: u32,
/// State
pub session: Session,
pub renderer: Renderer,
pub presentation: Presentation,
}
impl PartialEq for MainPanelProps {
fn eq(&self, rhs: &Self) -> bool {
self.session_props == rhs.session_props
&& self.renderer_props == rhs.renderer_props
&& self.presentation_props == rhs.presentation_props
&& self.is_settings_open == rhs.is_settings_open
&& self.update_count == rhs.update_count
}
}
impl MainPanelProps {
fn is_title(&self) -> bool {
self.session_props.title.is_some()
}
}
#[derive(Debug)]
pub enum MainPanelMsg {
PointerEvent(web_sys::PointerEvent),
}
pub struct MainPanel {
main_panel_ref: NodeRef,
}
impl Component for MainPanel {
type Message = MainPanelMsg;
type Properties = MainPanelProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
main_panel_ref: NodeRef::default(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
MainPanelMsg::PointerEvent(event) => {
if event.target().map(JsValue::from)
== self
.main_panel_ref
.cast::<web_sys::HtmlElement>()
.map(JsValue::from)
{
ctx.props().presentation.statusbar_pointer_event.emit(event);
}
false
},
}
}
fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let Self::Properties {
presentation,
renderer,
session,
..
} = ctx.props();
let is_settings_open = ctx.props().is_settings_open;
let on_settings = (!is_settings_open).then(|| ctx.props().on_settings.clone());
let mut class = classes!();
if !is_settings_open {
class.push("settings-closed");
}
if ctx.props().is_title() {
class.push("titled");
}
let pointerdown = ctx.link().callback(MainPanelMsg::PointerEvent);
let on_dismiss_warning = dismiss_render_warning_callback(session, renderer);
html! {
<div id="main_column">
<StatusBar
id="status_bar"
{on_settings}
on_reset={ctx.props().on_reset.clone()}
session_props={ctx.props().session_props.clone()}
presentation_props={ctx.props().presentation_props.clone()}
is_settings_open={ctx.props().is_settings_open}
update_count={ctx.props().update_count}
{presentation}
{renderer}
{session}
/>
<div
id="main_panel_container"
ref={self.main_panel_ref.clone()}
{class}
onpointerdown={pointerdown}
>
<RenderWarning
on_dismiss={on_dismiss_warning}
dimensions={ctx.props().renderer_props.render_limits}
/>
<slot />
</div>
</div>
}
}
fn destroy(&mut self, _ctx: &Context<Self>) {}
}

Some files were not shown because too many files have changed in this diff Show More