@perspective-dev/viewer 5.4.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/cdn/perspective-viewer.js +4 -4
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/blueprint.css +1 -1
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -1
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/ar.css +1 -1
  11. package/dist/css/intl/de.css +1 -1
  12. package/dist/css/intl/es.css +1 -1
  13. package/dist/css/intl/fr.css +1 -1
  14. package/dist/css/intl/ja.css +1 -1
  15. package/dist/css/intl/pt.css +1 -1
  16. package/dist/css/intl/zh.css +1 -1
  17. package/dist/css/intl.css +1 -1
  18. package/dist/css/ledger.css +1 -1
  19. package/dist/css/monokai.css +1 -1
  20. package/dist/css/nord.css +1 -1
  21. package/dist/css/phosphor.css +1 -1
  22. package/dist/css/pro-dark.css +1 -1
  23. package/dist/css/pro.css +1 -1
  24. package/dist/css/solarized-dark.css +1 -1
  25. package/dist/css/solarized.css +1 -1
  26. package/dist/css/themes.css +1 -1
  27. package/dist/css/vaporwave.css +1 -1
  28. package/dist/css/velvet.css +1 -1
  29. package/dist/docs/perspective-docs.json +1 -1
  30. package/dist/esm/perspective-viewer.d.ts +2 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +4 -4
  34. package/dist/esm/perspective-viewer.js.map +2 -2
  35. package/dist/esm/plugin.d.ts +9 -0
  36. package/dist/esm/ts-rs/AddPanelOptions.d.ts +9 -0
  37. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  38. package/dist/esm/ts-rs/GetViewMode.d.ts +4 -0
  39. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  40. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  41. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  42. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  43. package/dist/wasm/perspective-viewer.d.ts +102 -47
  44. package/dist/wasm/perspective-viewer.js +233 -142
  45. package/dist/wasm/perspective-viewer.wasm +0 -0
  46. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -18
  47. package/package.json +7 -7
  48. package/src/css/column-selector.css +11 -3
  49. package/src/css/column-style.css +162 -0
  50. package/src/css/config-selector.css +1 -1
  51. package/src/css/form/debug.css +9 -0
  52. package/src/css/status-bar.css +11 -0
  53. package/src/css/ui/containers/tabs.css +2 -1
  54. package/src/rust/agent/tools.rs +80 -7
  55. package/src/rust/components/column_selector/active_column.rs +9 -0
  56. package/src/rust/components/column_selector.rs +11 -0
  57. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  58. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  59. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -1
  60. package/src/rust/components/column_settings_sidebar/style_tab.rs +68 -9
  61. package/src/rust/components/debug_panel.rs +107 -50
  62. package/src/rust/components/export_dropdown.rs +6 -0
  63. package/src/rust/components/main_panel/msg.rs +4 -0
  64. package/src/rust/components/main_panel.rs +14 -0
  65. package/src/rust/components/mod.rs +0 -1
  66. package/src/rust/components/plugin_tab.rs +57 -3
  67. package/src/rust/components/settings_panel.rs +6 -0
  68. package/src/rust/components/status_bar.rs +12 -3
  69. package/src/rust/components/status_indicator.rs +4 -0
  70. package/src/rust/components/viewer/filters.rs +2 -2
  71. package/src/rust/components/viewer/msg.rs +4 -0
  72. package/src/rust/components/viewer/panels.rs +6 -3
  73. package/src/rust/components/viewer/render.rs +1 -0
  74. package/src/rust/components/viewer/wiring.rs +14 -2
  75. package/src/rust/components/viewer.rs +2 -2
  76. package/src/rust/config/column_config_schema.rs +312 -8
  77. package/src/rust/config/datetime_column_style.rs +0 -11
  78. package/src/rust/config/export_method.rs +24 -0
  79. package/src/rust/config/mod.rs +0 -2
  80. package/src/rust/config/options.rs +50 -0
  81. package/src/rust/config/workspace_config.rs +69 -24
  82. package/src/rust/custom_elements/viewer.rs +214 -113
  83. package/src/rust/js/plugin.rs +3 -1
  84. package/src/rust/lib.rs +7 -3
  85. package/src/rust/queries/plugin_column_styles.rs +6 -5
  86. package/src/rust/renderer/plugin_config.rs +200 -41
  87. package/src/rust/renderer/plugin_store.rs +2 -1
  88. package/src/rust/renderer/props.rs +5 -0
  89. package/src/rust/renderer.rs +9 -0
  90. package/src/rust/session/metadata.rs +5 -10
  91. package/src/rust/session/props.rs +4 -0
  92. package/src/rust/session.rs +273 -195
  93. package/src/rust/tasks/copy_export.rs +67 -0
  94. package/src/rust/tasks/create_panel.rs +16 -13
  95. package/src/rust/tasks/pipeline.rs +26 -8
  96. package/src/rust/tasks/restore_panel.rs +129 -30
  97. package/src/rust/tasks/send_column_config.rs +3 -0
  98. package/src/rust/tasks/sync_update_panels.rs +148 -68
  99. package/src/rust/tasks/table_lifecycle.rs +7 -1
  100. package/src/rust/ui/containers/tab_list.rs +1 -1
  101. package/src/rust/ui/form/intl_label.rs +4 -23
  102. package/src/rust/ui/portal.rs +1 -0
  103. package/src/rust/utils/css_vars.rs +13 -0
  104. package/src/rust/{config/datetime_column_style/color_mode.rs → utils/in_flight.rs} +47 -35
  105. package/src/rust/utils/local_fonts.rs +395 -0
  106. package/src/rust/utils/mod.rs +4 -0
  107. package/src/rust/utils/pending_effects.rs +6 -30
  108. package/src/rust/workspace.rs +12 -0
  109. package/src/svg/status_pending.svg +3 -0
  110. package/src/themes/icons.css +1 -0
  111. package/src/themes/intl/ar.css +20 -7
  112. package/src/themes/intl/de.css +20 -7
  113. package/src/themes/intl/es.css +20 -7
  114. package/src/themes/intl/fr.css +20 -7
  115. package/src/themes/intl/ja.css +20 -7
  116. package/src/themes/intl/pt.css +20 -7
  117. package/src/themes/intl/zh.css +20 -7
  118. package/src/themes/intl.css +20 -7
  119. package/src/themes/monokai.css +1 -1
  120. package/src/themes/vaporwave.css +1 -1
  121. package/src/ts/perspective-viewer.ts +2 -0
  122. package/src/ts/plugin.ts +9 -0
  123. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  124. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  125. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  126. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  127. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  128. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  129. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  130. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  131. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  132. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  133. package/src/rust/components/string_column_style.rs +0 -120
  134. package/src/rust/config/string_column_style.rs +0 -135
  135. package/src/ts/ts-rs/FormatMode.ts +0 -3
  136. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  137. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline0.js +0 -0
  138. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline1.js +0 -0
  139. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline2.js +0 -0
  140. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline3.js +0 -0
  141. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline4.js +0 -0
  142. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline5.js +0 -0
@@ -0,0 +1,267 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ use std::rc::Rc;
14
+
15
+ use serde_json::Value;
16
+ use web_sys::{HtmlInputElement, InputEvent, MouseEvent};
17
+ use yew::prelude::*;
18
+
19
+ use super::primitive_field::emit;
20
+ use crate::config::{ColumnConfigFieldUpdate, FontSize, FontToggle};
21
+ use crate::ui::{OptionalField, Select, SelectItem};
22
+ use crate::utils::{
23
+ LocalFontsAccess, font_family_options, local_fonts_access, on_local_fonts_changed,
24
+ probe_local_fonts_access, read_intl_string, request_local_fonts_access,
25
+ };
26
+
27
+ #[derive(Properties, PartialEq)]
28
+ pub struct FontFieldProps {
29
+ pub field_key: String,
30
+ pub default: String,
31
+ pub current: Option<String>,
32
+
33
+ /// The size input's key, default and bounds, when the spec declares
34
+ /// one, and the stored value for that key.
35
+ #[prop_or_default]
36
+ pub size: Option<FontSize>,
37
+
38
+ #[prop_or_default]
39
+ pub size_current: Option<f64>,
40
+
41
+ /// The bold toggle's key and default, when the spec declares one, and
42
+ /// the stored value for that key.
43
+ #[prop_or_default]
44
+ pub bold: Option<FontToggle>,
45
+
46
+ #[prop_or_default]
47
+ pub bold_current: Option<bool>,
48
+
49
+ #[prop_or_default]
50
+ pub italic: Option<FontToggle>,
51
+
52
+ #[prop_or_default]
53
+ pub italic_current: Option<bool>,
54
+ pub on_change: Callback<ColumnConfigFieldUpdate>,
55
+ }
56
+
57
+ /// The `ControlSpec::Font` widget: a family `<select>`, the optional size
58
+ /// input and bold / italic toggles, and a "Local Fonts" action while the
59
+ /// `local-fonts` permission is promptable.
60
+ #[function_component]
61
+ pub fn FontField(props: &FontFieldProps) -> Html {
62
+ let force_update = use_force_update();
63
+ use_effect_with((), move |_| {
64
+ let sub = on_local_fonts_changed(Callback::from(move |()| force_update.force_update()));
65
+ probe_local_fonts_access();
66
+ move || drop(sub)
67
+ });
68
+
69
+ let selected = props
70
+ .current
71
+ .clone()
72
+ .unwrap_or_else(|| props.default.clone());
73
+
74
+ let toggle_state = |toggle: &Option<FontToggle>, current: Option<bool>| {
75
+ toggle
76
+ .as_ref()
77
+ .map(|t| (t.clone(), current.unwrap_or(t.default)))
78
+ };
79
+
80
+ let bold = toggle_state(&props.bold, props.bold_current);
81
+ let italic = toggle_state(&props.italic, props.italic_current);
82
+ let family_checked = selected != props.default;
83
+ let style_checked = props
84
+ .size
85
+ .as_ref()
86
+ .is_some_and(|s| props.size_current.is_some_and(|v| v != s.default))
87
+ || bold.as_ref().is_some_and(|(t, on)| *on != t.default)
88
+ || italic.as_ref().is_some_and(|(t, on)| *on != t.default);
89
+
90
+ let values: Rc<Vec<SelectItem<String>>> = Rc::new(
91
+ font_family_options(&selected)
92
+ .into_iter()
93
+ .map(SelectItem::Option)
94
+ .collect(),
95
+ );
96
+
97
+ let on_select = use_callback(
98
+ (
99
+ props.field_key.clone(),
100
+ props.default.clone(),
101
+ props.on_change.clone(),
102
+ ),
103
+ |value: String, (key, default, on_change)| {
104
+ if value == *default {
105
+ emit(on_change, key, None);
106
+ } else {
107
+ emit(on_change, key, Some(Value::String(value)));
108
+ }
109
+ },
110
+ );
111
+
112
+ let on_reset_style = use_callback(
113
+ (
114
+ props.size.clone(),
115
+ props.bold.clone(),
116
+ props.italic.clone(),
117
+ props.field_key.clone(),
118
+ props.on_change.clone(),
119
+ ),
120
+ |_: MouseEvent, (size, bold, italic, key, on_change)| {
121
+ let keys = [
122
+ size.as_ref().map(|s| s.key.clone()),
123
+ bold.as_ref().map(|t| t.key.clone()),
124
+ italic.as_ref().map(|t| t.key.clone()),
125
+ Some(key.clone()),
126
+ ]
127
+ .into_iter()
128
+ .flatten()
129
+ .collect();
130
+
131
+ on_change.emit(ColumnConfigFieldUpdate {
132
+ keys,
133
+ value: serde_json::Map::new(),
134
+ });
135
+ },
136
+ );
137
+
138
+ let size_input = props.size.as_ref().map(|size| {
139
+ let on_input = {
140
+ let on_change = props.on_change.clone();
141
+ let size = size.clone();
142
+ Callback::from(move |event: InputEvent| {
143
+ let value = event
144
+ .target_unchecked_into::<HtmlInputElement>()
145
+ .value_as_number();
146
+ let value = (value.is_finite() && value != size.default)
147
+ .then(|| serde_json::Number::from_f64(value).map(Value::Number))
148
+ .flatten();
149
+
150
+ emit(&on_change, &size.key, value);
151
+ })
152
+ };
153
+
154
+ html! {
155
+ <input
156
+ type="number"
157
+ class="parameter font-size"
158
+ id={format!("{}-input", size.key)}
159
+ min={size.min.map(|v| v.to_string())}
160
+ max={size.max.map(|v| v.to_string())}
161
+ step={size.step.map(|v| v.to_string())}
162
+ value={props.size_current.unwrap_or(size.default).to_string()}
163
+ oninput={on_input}
164
+ />
165
+ }
166
+ });
167
+
168
+ let toggle_button = |class: &'static str, state: &Option<(FontToggle, bool)>| {
169
+ let Some((toggle, on)) = state else {
170
+ return html! {};
171
+ };
172
+
173
+ let next = !*on;
174
+ let on_toggle = {
175
+ let on_change = props.on_change.clone();
176
+ let toggle = toggle.clone();
177
+ Callback::from(move |_: MouseEvent| {
178
+ let value = (next != toggle.default).then_some(Value::Bool(next));
179
+ emit(&on_change, &toggle.key, value);
180
+ })
181
+ };
182
+
183
+ html! {
184
+ <span
185
+ class={classes!("font-style-toggle", class)}
186
+ id={format!("{}-toggle", toggle.key)}
187
+ role="button"
188
+ aria-pressed={on.to_string()}
189
+ onclick={on_toggle}
190
+ />
191
+ }
192
+ };
193
+
194
+ let access = local_fonts_access();
195
+ let action_ref = use_node_ref();
196
+ use_effect_with((action_ref.clone(), access), |(action_ref, access)| {
197
+ let Some(elem) = action_ref.cast::<web_sys::Element>() else {
198
+ return;
199
+ };
200
+
201
+ let (slug, fallback) = match access {
202
+ Some(LocalFontsAccess::Denied) => (
203
+ "local-fonts-blocked-title",
204
+ "Access to local fonts is blocked for this site. Allow it in the browser's site \
205
+ settings to list them here.",
206
+ ),
207
+ _ => (
208
+ "request-local-fonts-title",
209
+ "Allow access to the fonts installed on this device",
210
+ ),
211
+ };
212
+
213
+ let title = read_intl_string(&elem, slug).unwrap_or_else(|| fallback.to_owned());
214
+ let _ = elem.set_attribute("title", &title);
215
+ });
216
+
217
+ let local_fonts = match access {
218
+ Some(LocalFontsAccess::Prompt) => {
219
+ let on_request = Callback::from(|_: MouseEvent| request_local_fonts_access());
220
+ html! {
221
+ <div class="local-fonts-controls">
222
+ <span
223
+ ref={action_ref}
224
+ class="local-fonts-request"
225
+ id="request-local-fonts"
226
+ onclick={on_request}
227
+ />
228
+ </div>
229
+ }
230
+ },
231
+ Some(LocalFontsAccess::Denied) => html! {
232
+ <div class="local-fonts-controls">
233
+ <span
234
+ ref={action_ref}
235
+ class="local-fonts-request blocked"
236
+ id="request-local-fonts"
237
+ />
238
+ </div>
239
+ },
240
+ _ => html! {},
241
+ };
242
+
243
+ html! {
244
+ <>
245
+ <div class="row">
246
+ { local_fonts }
247
+ <OptionalField
248
+ label={props.field_key.clone()}
249
+ on_check={on_reset_style}
250
+ checked={family_checked || style_checked}
251
+ >
252
+ <div class="font-group">
253
+ <Select<String>
254
+ wrapper_class="font-group-family"
255
+ {values}
256
+ {selected}
257
+ {on_select}
258
+ />
259
+ { size_input }
260
+ { toggle_button("bold", &bold) }
261
+ { toggle_button("italic", &italic) }
262
+ </div>
263
+ </OptionalField>
264
+ </div>
265
+ </>
266
+ }
267
+ }
@@ -36,7 +36,7 @@ use crate::utils::{
36
36
  canonicalize_css_color, gradient_to_css, palette_name_for,
37
37
  };
38
38
 
39
- fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<Value>) {
39
+ pub(super) fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<Value>) {
40
40
  let mut map = serde_json::Map::new();
41
41
  if let Some(v) = value {
42
42
  map.insert(key.to_owned(), v);
@@ -11,6 +11,8 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  mod agg_depth_selector;
14
+ pub(crate) mod alignment_field;
15
+ pub(crate) mod font_field;
14
16
  pub(crate) mod primitive_field;
15
17
  mod symbol;
16
18
 
@@ -22,17 +24,18 @@ use perspective_client::config::ColumnType;
22
24
  use yew::{Callback, Html, Properties, function_component, html};
23
25
 
24
26
  use self::agg_depth_selector::*;
27
+ use self::alignment_field::AlignmentField;
28
+ use self::font_field::FontField;
25
29
  use self::primitive_field::{
26
30
  BoolField, ColorField, EnumField, GradientStopsField, NumberFieldPrimitive, PaletteField,
27
31
  };
28
32
  use crate::components::column_settings_sidebar::style_tab::symbol::SymbolStyle;
29
33
  use crate::components::datetime_column_style::DatetimeColumnStyle;
30
34
  use crate::components::number_series_style::NumberSeriesStyle;
31
- use crate::components::string_column_style::StringColumnStyle;
32
35
  use crate::components::style_controls::CustomNumberFormat;
33
36
  use crate::config::{
34
- ColumnConfigFieldUpdate, ControlSpec, CustomNumberFormatConfig, DatetimeColumnStyleConfig,
35
- NumberFormatDefaults, NumberSeriesStyleConfig, StringColumnStyleConfig,
37
+ Alignment, ColumnConfigFieldUpdate, ControlSpec, CustomNumberFormatConfig,
38
+ DatetimeColumnStyleConfig, FontToggle, NumberFormatDefaults, NumberSeriesStyleConfig,
36
39
  };
37
40
  use crate::presentation::Presentation;
38
41
  use crate::queries::{fetch_column_abs_max, get_column_config_schema, named_values};
@@ -270,12 +273,6 @@ fn render_leaf(spec: ControlSpec, keys: &[String], ctx: &FieldRenderCtx) -> Opti
270
273
  />
271
274
  }
272
275
  },
273
- ControlSpec::StringFormat => {
274
- let config: Option<StringColumnStyleConfig> = deser_sub(raw_config);
275
- html! {
276
- <StringColumnStyle {config} on_change={on_change.clone()} keys={keys.to_vec()} />
277
- }
278
- },
279
276
  ControlSpec::Symbols {
280
277
  default: default_config,
281
278
  } => {
@@ -341,6 +338,68 @@ fn render_leaf(spec: ControlSpec, keys: &[String], ctx: &FieldRenderCtx) -> Opti
341
338
  />
342
339
  }
343
340
  },
341
+ ControlSpec::Font {
342
+ key,
343
+ default,
344
+ size,
345
+ bold,
346
+ italic,
347
+ } => {
348
+ let current = raw_config
349
+ .as_ref()
350
+ .and_then(|m| m.get(&key))
351
+ .and_then(|v| v.as_str().map(|s| s.to_string()));
352
+
353
+ let toggle = |t: &FontToggle| {
354
+ raw_config
355
+ .as_ref()
356
+ .and_then(|m| m.get(&t.key))
357
+ .and_then(|v| v.as_bool())
358
+ };
359
+
360
+ let size_current = size
361
+ .as_ref()
362
+ .and_then(|s| raw_config.as_ref().and_then(|m| m.get(&s.key)))
363
+ .and_then(|v| v.as_f64());
364
+
365
+ let bold_current = bold.as_ref().and_then(toggle);
366
+ let italic_current = italic.as_ref().and_then(toggle);
367
+ html! {
368
+ <FontField
369
+ field_key={key}
370
+ {default}
371
+ {current}
372
+ {size}
373
+ {size_current}
374
+ {bold}
375
+ {bold_current}
376
+ {italic}
377
+ {italic_current}
378
+ on_change={on_change.clone()}
379
+ />
380
+ }
381
+ },
382
+ ControlSpec::Alignment {
383
+ key,
384
+ default,
385
+ corners,
386
+ } => {
387
+ let current = raw_config
388
+ .as_ref()
389
+ .and_then(|m| m.get(&key))
390
+ .and_then(|v| v.as_str())
391
+ .and_then(Alignment::parse);
392
+
393
+ html! {
394
+ <AlignmentField
395
+ field_key={key}
396
+ {default}
397
+ {corners}
398
+ {current}
399
+ on_change={on_change.clone()}
400
+ />
401
+ }
402
+ },
344
403
  ControlSpec::Bool { key, default } => {
345
404
  let current = raw_config
346
405
  .as_ref()
@@ -10,6 +10,7 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ use std::cell::Cell;
13
14
  use std::rc::Rc;
14
15
 
15
16
  use perspective_client::ExprValidationError;
@@ -18,8 +19,10 @@ use wasm_bindgen::prelude::*;
18
19
  use yew::prelude::*;
19
20
 
20
21
  use crate::components::code_editor::CodeEditor;
22
+ use crate::config::*;
21
23
  use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard};
22
24
  use crate::presentation::*;
25
+ use crate::queries::fetch_hosted_tables;
23
26
  use crate::renderer::*;
24
27
  use crate::session::*;
25
28
  use crate::utils::*;
@@ -44,12 +47,18 @@ pub struct DebugPanelProps {
44
47
  pub on_auto_width: Callback<f64>,
45
48
  }
46
49
 
50
+ /// Whether the editor holds text the panel has not successfully applied, read
51
+ /// synchronously by the config-change listeners so a failed apply's own
52
+ /// `view_config_changed` never discards it.
53
+ type Dirty = Rc<Cell<bool>>;
54
+
47
55
  #[function_component(DebugPanel)]
48
56
  pub fn debug_panel(props: &DebugPanelProps) -> Html {
49
57
  let expr = use_state_eq(|| Rc::new("".to_string()));
50
58
  let error = use_state_eq(|| Option::<ExprValidationError>::None);
51
59
  let select_all = use_memo((), |()| PubSub::default());
52
60
  let modified = use_state_eq(|| false);
61
+ let dirty: Dirty = use_memo((), |()| Cell::new(false));
53
62
 
54
63
  // Measure natural width on mount and route up to `SettingsPanel`.
55
64
  let sizer = use_node_ref();
@@ -64,7 +73,7 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
64
73
  });
65
74
 
66
75
  use_effect_with((expr.setter(), props.clone()), {
67
- clone!(error, modified);
76
+ clone!(error, modified, dirty);
68
77
  move |(text, state)| {
69
78
  state.set_text(text.clone());
70
79
  error.set(None);
@@ -75,6 +84,7 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
75
84
  text.clone(),
76
85
  error.setter(),
77
86
  modified.setter(),
87
+ dirty.clone(),
78
88
  ));
79
89
 
80
90
  let sub2 = state
@@ -84,6 +94,7 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
84
94
  text.clone(),
85
95
  error.setter(),
86
96
  modified.setter(),
97
+ dirty.clone(),
87
98
  ));
88
99
 
89
100
  let sub3 = state
@@ -93,6 +104,7 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
93
104
  text.clone(),
94
105
  error.setter(),
95
106
  modified.setter(),
107
+ dirty.clone(),
96
108
  ));
97
109
 
98
110
  || {
@@ -104,16 +116,17 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
104
116
  });
105
117
 
106
118
  let oninput = use_callback(expr.setter(), {
107
- clone!(modified);
119
+ clone!(modified, dirty);
108
120
  move |x, expr| {
121
+ dirty.set(true);
109
122
  modified.set(true);
110
123
  expr.set(x)
111
124
  }
112
125
  });
113
126
 
114
127
  let onsave = use_callback((expr.clone(), error.clone(), props.clone()), {
115
- clone!(modified);
116
- move |_, (text, error, props)| props.on_save(text, error, &modified)
128
+ clone!(modified, dirty);
129
+ move |_, (text, error, props)| props.on_save(text, text.setter(), error, &modified, &dirty)
117
130
  });
118
131
 
119
132
  let oncopy = use_callback(
@@ -133,13 +146,14 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
133
146
  );
134
147
 
135
148
  let onapply = use_callback((expr.clone(), error.clone(), props.clone()), {
136
- clone!(modified);
137
- move |_, (text, error, props)| props.on_save(text, error, &modified)
149
+ clone!(modified, dirty);
150
+ move |_, (text, error, props)| props.on_save(text, text.setter(), error, &modified, &dirty)
138
151
  });
139
152
 
140
153
  let onreset = use_callback((expr.setter(), error.clone(), props.clone()), {
141
- clone!(modified);
154
+ clone!(modified, dirty);
142
155
  move |_, (text, error, props)| {
156
+ dirty.set(false);
143
157
  props.set_text(text.clone());
144
158
  error.set(None);
145
159
  modified.set(false);
@@ -147,16 +161,17 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
147
161
  });
148
162
 
149
163
  let onpaste = use_callback((expr.clone(), error.clone(), props.clone()), {
150
- clone!(modified);
164
+ clone!(modified, dirty);
151
165
  move |_, (text, error, props)| {
152
- clone!(text, error, props, modified);
166
+ clone!(text, error, props, modified, dirty);
153
167
  ApiFuture::spawn(async move {
154
168
  if let Some(x) = paste_from_clipboard().await {
155
169
  let x = Rc::new(x);
170
+ dirty.set(true);
156
171
  modified.set(true);
157
172
  error.set(None);
158
173
  text.set(x.clone());
159
- props.on_save(&x, &error, &modified);
174
+ props.on_save(&x, text.setter(), &error, &modified, &dirty);
160
175
  }
161
176
 
162
177
  Ok(())
@@ -184,6 +199,9 @@ pub fn debug_panel(props: &DebugPanelProps) -> Html {
184
199
  select_all={select_all.subscriber()}
185
200
  error={(*error).clone()}
186
201
  />
202
+ if let Some(err) = &*error {
203
+ <div id="debug-panel-error" class="error">{ &err.error_message }</div>
204
+ }
187
205
  </div>
188
206
  <div
189
207
  class="scroll-panel-auto-width"
@@ -219,66 +237,105 @@ impl DebugPanelProps {
219
237
  text: UseStateSetter<Rc<String>>,
220
238
  error: UseStateSetter<Option<ExprValidationError>>,
221
239
  modified: UseStateSetter<bool>,
240
+ dirty: Dirty,
222
241
  ) -> impl Fn(()) + use<> {
223
242
  let props = self.clone();
224
243
  move |_| {
244
+ if dirty.get() {
245
+ return;
246
+ }
247
+
225
248
  error.set(None);
226
249
  props.set_text(text.clone());
227
250
  modified.set(false);
228
251
  }
229
252
  }
230
253
 
254
+ /// Validate and apply the editor's text as this panel's config, treating
255
+ /// an un-hosted `table` as a validation error rather than a pending bind.
231
256
  fn on_save(
232
257
  &self,
233
- text: &Rc<String>,
258
+ source: &Rc<String>,
259
+ text: UseStateSetter<Rc<String>>,
234
260
  error: &UseStateHandle<Option<ExprValidationError>>,
235
261
  modified: &UseStateHandle<bool>,
262
+ dirty: &Dirty,
236
263
  ) {
237
264
  let props = self.clone();
238
- clone!(text, error, modified);
265
+ clone!(source, error, modified, dirty);
239
266
  ApiFuture::spawn(async move {
240
- match serde_json::from_str(&text) {
241
- Ok(config) => {
242
- let active =
243
- props.workspace.active_renderer().as_ref() == Some(&props.renderer);
244
- match crate::tasks::restore_panel(
245
- &props.session,
246
- &props.renderer,
247
- &props.presentation,
248
- &props.workspace,
249
- crate::tasks::RestoreMode::Existing { active },
250
- config,
251
- crate::tasks::RestoreErrors::Suppress,
252
- )
253
- .await
254
- {
255
- Ok(_) => {
256
- modified.set(false);
257
- },
258
- Err(e) => {
259
- modified.set(true);
260
- error.set(Some(ExprValidationError {
261
- error_message: JsValue::from(e).as_string().unwrap_or_else(|| {
262
- "Failed to validate viewer config".to_owned()
263
- }),
264
- line: 0_u32,
265
- column: 0,
266
- }));
267
- },
268
- }
269
- Ok(())
270
- },
267
+ let fail = |message: String, (line, column): (u32, u32)| {
268
+ dirty.set(true);
269
+ modified.set(true);
270
+ error.set(Some(ExprValidationError {
271
+ error_message: message,
272
+ line,
273
+ column,
274
+ }));
275
+ };
276
+
277
+ let config: ViewerConfigUpdate = match serde_json::from_str(&source) {
278
+ Ok(config) => config,
271
279
  Err(err) => {
272
- modified.set(true);
273
- error.set(Some(ExprValidationError {
274
- error_message: err.to_string(),
275
- line: err.line() as u32 - 1,
276
- column: err.column() as u32 - 1,
277
- }));
280
+ let position = (err.line() as u32 - 1, err.column() as u32 - 1);
281
+ fail(err.to_string(), position);
282
+ return Ok(());
283
+ },
284
+ };
285
+
286
+ if let OptionalUpdate::Update(name) = &config.table {
287
+ let hosted = fetch_hosted_tables(&props.workspace).await;
288
+ if !hosted
289
+ .iter()
290
+ .any(|(_, names)| names.iter().any(|n| n == name))
291
+ {
292
+ fail(
293
+ format!("Unknown table \"{name}\""),
294
+ locate_key(&source, "table"),
295
+ );
296
+
297
+ return Ok(());
298
+ }
299
+ }
300
+
301
+ let active = props.workspace.active_renderer().as_ref() == Some(&props.renderer);
302
+ let result = crate::tasks::restore_panel(
303
+ &props.session,
304
+ &props.renderer,
305
+ &props.presentation,
306
+ &props.workspace,
307
+ crate::tasks::RestoreMode::Existing { active },
308
+ config,
309
+ crate::tasks::RestoreErrors::Suppress,
310
+ MissingTable::Error,
311
+ )
312
+ .await;
278
313
 
279
- Ok(())
314
+ match result {
315
+ Ok(_) => {
316
+ dirty.set(false);
317
+ error.set(None);
318
+ modified.set(false);
319
+ props.set_text(text);
280
320
  },
321
+ Err(e) => fail(format!("{e}"), (0, 0)),
281
322
  }
323
+
324
+ Ok(())
282
325
  });
283
326
  }
284
327
  }
328
+
329
+ /// The 0-based `(line, column)` of the first `"key"` in `text`, or `(0, 0)`
330
+ /// when absent.
331
+ fn locate_key(text: &str, key: &str) -> (u32, u32) {
332
+ let needle = format!("\"{key}\"");
333
+ text.lines()
334
+ .enumerate()
335
+ .find_map(|(line, content)| {
336
+ content
337
+ .find(&needle)
338
+ .map(|column| (line as u32, column as u32))
339
+ })
340
+ .unwrap_or((0, 0))
341
+ }