@perspective-dev/viewer 5.2.0 → 5.3.0

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 (133) hide show
  1. package/dist/cdn/perspective-viewer.js +2 -2
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/docs/perspective-docs.json +1 -1
  17. package/dist/esm/perspective-viewer.d.ts +1 -0
  18. package/dist/esm/perspective-viewer.inline.js +9 -9
  19. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  20. package/dist/esm/perspective-viewer.js +4 -4
  21. package/dist/esm/perspective-viewer.js.map +2 -2
  22. package/dist/esm/plugin.d.ts +10 -0
  23. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  24. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  25. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  26. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  27. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  28. package/dist/wasm/perspective-viewer.d.ts +43 -36
  29. package/dist/wasm/perspective-viewer.js +194 -136
  30. package/dist/wasm/perspective-viewer.wasm +0 -0
  31. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  32. package/package.json +1 -1
  33. package/src/css/column-settings-panel.css +9 -5
  34. package/src/css/column-style.css +209 -31
  35. package/src/css/dom/select.css +2 -1
  36. package/src/css/form/code-editor.css +4 -4
  37. package/src/rust/agent/tools.rs +48 -17
  38. package/src/rust/components/column_selector/active_column.rs +2 -2
  39. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  40. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  41. package/src/rust/components/column_selector/config_selector.rs +8 -0
  42. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  43. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  44. package/src/rust/components/column_selector.rs +48 -4
  45. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  46. package/src/rust/components/column_settings_sidebar/style_tab.rs +262 -225
  47. package/src/rust/components/column_settings_sidebar.rs +88 -69
  48. package/src/rust/components/editable_header.rs +85 -144
  49. package/src/rust/components/expression_editor.rs +9 -16
  50. package/src/rust/components/form/code_editor.rs +16 -6
  51. package/src/rust/components/form/debug.rs +1 -0
  52. package/src/rust/components/form/mod.rs +3 -1
  53. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  54. package/src/rust/components/form/named_value_picker.rs +86 -0
  55. package/src/rust/components/form/palette_selector.rs +440 -0
  56. package/src/rust/components/main_panel/msg.rs +2 -2
  57. package/src/rust/components/main_panel/reconcile.rs +4 -4
  58. package/src/rust/components/plugin_tab.rs +4 -28
  59. package/src/rust/components/settings_panel.rs +25 -20
  60. package/src/rust/components/viewer/msg.rs +46 -7
  61. package/src/rust/components/viewer/panels.rs +11 -9
  62. package/src/rust/components/viewer/render.rs +8 -3
  63. package/src/rust/components/viewer/settings.rs +163 -23
  64. package/src/rust/components/viewer/snapshots.rs +46 -21
  65. package/src/rust/components/viewer/wiring.rs +14 -25
  66. package/src/rust/components/viewer.rs +12 -8
  67. package/src/rust/config/column_config_schema.rs +100 -41
  68. package/src/rust/config/css_scale.rs +882 -0
  69. package/src/rust/config/mod.rs +2 -0
  70. package/src/rust/config/options.rs +10 -0
  71. package/src/rust/config/viewer_config.rs +49 -55
  72. package/src/rust/config/workspace_config.rs +132 -5
  73. package/src/rust/custom_elements/viewer.rs +214 -155
  74. package/src/rust/custom_events.rs +14 -1
  75. package/src/rust/js/regular_layout.rs +3 -3
  76. package/src/rust/lib.rs +2 -0
  77. package/src/rust/presentation/column_locator.rs +19 -5
  78. package/src/rust/presentation.rs +192 -36
  79. package/src/rust/queries/column_locator.rs +74 -44
  80. package/src/rust/queries/get_viewer_config.rs +0 -3
  81. package/src/rust/queries/mod.rs +2 -0
  82. package/src/rust/queries/palette_set.rs +82 -0
  83. package/src/rust/queries/plugin_column_styles.rs +6 -2
  84. package/src/rust/renderer/dispatch.rs +16 -4
  85. package/src/rust/renderer/plugin_config.rs +309 -47
  86. package/src/rust/renderer.rs +15 -42
  87. package/src/rust/session/column_defaults_update.rs +15 -8
  88. package/src/rust/session/metadata.rs +5 -1
  89. package/src/rust/session/props.rs +1 -0
  90. package/src/rust/session.rs +9 -6
  91. package/src/rust/tasks/create_panel.rs +44 -57
  92. package/src/rust/tasks/edit_expression.rs +19 -23
  93. package/src/rust/tasks/edit_window.rs +8 -10
  94. package/src/rust/tasks/eject.rs +4 -1
  95. package/src/rust/tasks/pipeline.rs +2 -2
  96. package/src/rust/tasks/presize_panels.rs +44 -5
  97. package/src/rust/tasks/reset_all.rs +4 -4
  98. package/src/rust/tasks/restore_and_render.rs +17 -44
  99. package/src/rust/tasks/restore_panel.rs +11 -13
  100. package/src/rust/tasks/set_edit_mode.rs +3 -1
  101. package/src/rust/tasks/sync_update_panels.rs +11 -1
  102. package/src/rust/tasks/update_theme.rs +23 -18
  103. package/src/rust/utils/css_vars.rs +26 -0
  104. package/src/rust/utils/mod.rs +2 -0
  105. package/src/rust/utils/spawn.rs +1 -1
  106. package/src/rust/workspace.rs +435 -272
  107. package/src/svg/lock.svg +7 -0
  108. package/src/themes/botanical.css +10 -0
  109. package/src/themes/defaults.css +12 -0
  110. package/src/themes/dracula.css +17 -0
  111. package/src/themes/gruvbox-dark.css +20 -0
  112. package/src/themes/gruvbox.css +10 -0
  113. package/src/themes/icons.css +1 -0
  114. package/src/themes/monokai.css +22 -0
  115. package/src/themes/phosphor.css +5 -0
  116. package/src/themes/pro-dark.css +10 -0
  117. package/src/themes/solarized-dark.css +7 -0
  118. package/src/themes/solarized.css +7 -0
  119. package/src/themes/vaporwave.css +20 -0
  120. package/src/ts/perspective-viewer.ts +1 -0
  121. package/src/ts/plugin.ts +14 -0
  122. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  123. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  124. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  125. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  126. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  127. package/src/rust/components/form/color_range_selector.rs +0 -137
  128. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline0.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline1.js +0 -0
  130. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline2.js +0 -0
  131. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline3.js +0 -0
  132. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline4.js +0 -0
  133. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline5.js +0 -0
@@ -29,7 +29,7 @@ use std::rc::Rc;
29
29
  pub use column_selector_column_row::*;
30
30
  pub use empty_column::*;
31
31
  pub use invalid_column::*;
32
- use perspective_client::config::ViewConfig;
32
+ use perspective_client::config::{ViewConfig, ViewConfigUpdate};
33
33
  pub use pivot_column::*;
34
34
  use web_sys::*;
35
35
  use yew::prelude::*;
@@ -43,8 +43,10 @@ use super::containers::split_panel::{Orientation, SplitPanel};
43
43
  use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
44
44
  use crate::components::containers::scroll_panel_item::ScrollPanelItem;
45
45
  use crate::config::PluginStaticConfig;
46
- use crate::presentation::{ColumnLocator, DragDropContainer, Presentation};
47
- use crate::queries::{ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet};
46
+ use crate::presentation::{ColumnLocator, ColumnSettingsTarget, DragDropContainer, Presentation};
47
+ use crate::queries::{
48
+ ActiveColumnState, ActiveColumnStateData, ColumnsIteratorSet, get_current_column_locator,
49
+ };
48
50
  use crate::renderer::*;
49
51
  use crate::session::drag_drop_update::*;
50
52
  use crate::session::*;
@@ -54,7 +56,7 @@ use crate::utils::*;
54
56
  #[derive(Properties)]
55
57
  pub struct ColumnSelectorProps {
56
58
  /// Fires when the expression/config column is open.
57
- pub on_open_expr_panel: Callback<ColumnLocator>,
59
+ pub on_open_expr_panel: Callback<ColumnSettingsTarget>,
58
60
 
59
61
  /// This is passed to the add_expression_button for styling.
60
62
  pub selected_column: Option<ColumnLocator>,
@@ -137,6 +139,32 @@ pub struct ColumnSelector {
137
139
  on_reset: Rc<PubSub<()>>,
138
140
  }
139
141
 
142
+ fn close_column_settings_if_displaced(
143
+ presentation: &Presentation,
144
+ renderer: &Renderer,
145
+ metadata: &SessionMetadata,
146
+ view_config: &ViewConfig,
147
+ update: &ViewConfigUpdate,
148
+ ) {
149
+ let Some(columns) = &update.columns else {
150
+ return;
151
+ };
152
+
153
+ let ocs = presentation.get_open_column_settings();
154
+ if get_current_column_locator(&ocs, renderer, view_config, metadata).is_none() {
155
+ return;
156
+ }
157
+
158
+ let next_config = ViewConfig {
159
+ columns: columns.clone(),
160
+ ..view_config.clone()
161
+ };
162
+
163
+ if get_current_column_locator(&ocs, renderer, &next_config, metadata).is_none() {
164
+ presentation.set_open_column_settings(None);
165
+ }
166
+ }
167
+
140
168
  impl Component for ColumnSelector {
141
169
  type Message = ColumnSelectorMsg;
142
170
  type Properties = ColumnSelectorProps;
@@ -224,6 +252,14 @@ impl Component for ColumnSelector {
224
252
  ctx.props().metadata.get_features().unwrap(),
225
253
  );
226
254
 
255
+ close_column_settings_if_displaced(
256
+ &ctx.props().presentation,
257
+ &ctx.props().renderer,
258
+ &ctx.props().metadata,
259
+ &ctx.props().view_config,
260
+ &update,
261
+ );
262
+
227
263
  let session = ctx.props().session.clone();
228
264
  let renderer = ctx.props().renderer.clone();
229
265
  if let Ok(task) = apply_and_render(&session, &renderer, update) {
@@ -249,6 +285,14 @@ impl Component for ColumnSelector {
249
285
  ctx.props().metadata.get_features().unwrap(),
250
286
  );
251
287
 
288
+ close_column_settings_if_displaced(
289
+ &ctx.props().presentation,
290
+ &ctx.props().renderer,
291
+ &ctx.props().metadata,
292
+ &ctx.props().view_config,
293
+ &update,
294
+ );
295
+
252
296
  let session = ctx.props().session.clone();
253
297
  let renderer = ctx.props().renderer.clone();
254
298
  if let Ok(task) = apply_and_render(&session, &renderer, update) {
@@ -17,20 +17,27 @@
17
17
  //! [`ColorSelector`]) so that they visually match the rich Yew widgets in
18
18
  //! the same sidebar.
19
19
 
20
+ use std::collections::BTreeMap;
20
21
  use std::rc::Rc;
21
22
 
22
23
  use itertools::Itertools;
23
24
  use serde_json::Value;
24
25
  use wasm_bindgen::JsCast;
25
26
  use web_sys::{HtmlInputElement, MouseEvent};
26
- use yew::{Callback, Html, Properties, classes, function_component, html, use_callback};
27
+ use yew::{Callback, Html, Properties, function_component, html, use_callback};
27
28
 
28
29
  use crate::components::containers::select::{Select, SelectItem};
29
- use crate::components::form::color_range_selector::ColorRangeSelector;
30
30
  use crate::components::form::color_selector::ColorSelector;
31
+ use crate::components::form::multi_stop_gradient_selector::MultiStopGradientSelector;
32
+ use crate::components::form::named_value_picker::NamedValuePicker;
31
33
  use crate::components::form::number_field::NumberField;
32
34
  use crate::components::form::optional_field::OptionalField;
33
- use crate::config::{ColumnConfigFieldUpdate, EnumVariant};
35
+ use crate::components::form::palette_selector::PaletteSelector;
36
+ use crate::config::{
37
+ ColumnConfigFieldUpdate, CssColor, CssGradient, CssKind, CssPalette, EnumVariant,
38
+ GradientStopSpec, NamedValue, canonicalize_css_color, discrete_pair, gradient_to_css,
39
+ palette_name_for,
40
+ };
34
41
 
35
42
  fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<Value>) {
36
43
  let mut map = serde_json::Map::new();
@@ -44,28 +51,6 @@ fn emit(on_change: &Callback<ColumnConfigFieldUpdate>, key: &str, value: Option<
44
51
  });
45
52
  }
46
53
 
47
- fn emit_color_range(
48
- on_change: &Callback<ColumnConfigFieldUpdate>,
49
- key_pos: &str,
50
- key_neg: &str,
51
- default_pos: &str,
52
- default_neg: &str,
53
- new_pos: &str,
54
- new_neg: &str,
55
- ) {
56
- let mut value = serde_json::Map::new();
57
- if new_pos != default_pos {
58
- value.insert(key_pos.to_owned(), Value::String(new_pos.to_owned()));
59
- }
60
- if new_neg != default_neg {
61
- value.insert(key_neg.to_owned(), Value::String(new_neg.to_owned()));
62
- }
63
- on_change.emit(ColumnConfigFieldUpdate {
64
- keys: vec![key_pos.to_owned(), key_neg.to_owned()],
65
- value,
66
- });
67
- }
68
-
69
54
  #[derive(Properties, PartialEq)]
70
55
  pub struct EnumFieldProps {
71
56
  pub field_key: String,
@@ -239,106 +224,242 @@ pub fn NumberFieldPrimitive(props: &NumberFieldPrimitiveProps) -> Html {
239
224
  }
240
225
  }
241
226
 
227
+ #[allow(clippy::too_many_arguments)]
228
+ fn css_field_controls(
229
+ kind: CssKind,
230
+ named: &Rc<Vec<NamedValue>>,
231
+ restored: &BTreeMap<String, String>,
232
+ current: &str,
233
+ is_modified: bool,
234
+ on_select: Callback<String>,
235
+ on_pin: Callback<()>,
236
+ ) -> Html {
237
+ let can_pin = is_modified && palette_name_for(restored, kind, current).is_none();
238
+ if named.is_empty() && !can_pin {
239
+ return html! {};
240
+ }
241
+
242
+ html! { <NamedValuePicker {kind} entries={named.clone()} {on_select} {can_pin} {on_pin} /> }
243
+ }
244
+
245
+ fn named_literal(named: &[NamedValue], name: &str) -> Option<String> {
246
+ named
247
+ .iter()
248
+ .find(|entry| entry.name == name)
249
+ .map(|entry| entry.value.clone())
250
+ }
251
+
242
252
  #[derive(Properties, PartialEq)]
243
- pub struct ColorRangeFieldProps {
244
- pub field_key_pos: String,
245
- pub field_key_neg: String,
246
- pub default_pos: String,
247
- pub default_neg: String,
248
- pub current_pos: Option<String>,
249
- pub current_neg: Option<String>,
250
- pub is_gradient: bool,
253
+ pub struct PaletteFieldProps {
254
+ pub field_key: String,
255
+ /// Canonical palette string.
256
+ pub default: String,
257
+ pub max: Option<usize>,
258
+ /// Canonical palette string.
259
+ pub current: Option<String>,
251
260
  pub on_change: Callback<ColumnConfigFieldUpdate>,
261
+
262
+ /// Named palettes (workspace set ∪ theme), for the loader.
263
+ #[prop_or_default]
264
+ pub named: Rc<Vec<NamedValue>>,
265
+
266
+ /// The restored palette — the set Pin adds to.
267
+ #[prop_or_default]
268
+ pub restored: Rc<BTreeMap<String, String>>,
269
+
270
+ /// Pin the field's `(kind, literal)` into the restored palette.
271
+ #[prop_or_default]
272
+ pub on_pin: Callback<(CssKind, String)>,
273
+ }
274
+
275
+ fn palette_colors(src: &str, default: &str) -> Vec<String> {
276
+ let literal = |css: &str| match CssPalette::parse(css) {
277
+ Ok(CssPalette::Literal(colors)) => Some(colors),
278
+ _ => None,
279
+ };
280
+
281
+ literal(src)
282
+ .or_else(|| literal(default))
283
+ .unwrap_or_default()
252
284
  }
253
285
 
254
286
  #[function_component]
255
- pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html {
256
- let pos = props
257
- .current_pos
258
- .clone()
259
- .unwrap_or_else(|| props.default_pos.clone());
260
- let neg = props
261
- .current_neg
262
- .clone()
263
- .unwrap_or_else(|| props.default_neg.clone());
264
- let is_modified = (props.current_pos.is_some()
265
- && props.current_pos.as_deref() != Some(props.default_pos.as_str()))
266
- || (props.current_neg.is_some()
267
- && props.current_neg.as_deref() != Some(props.default_neg.as_str()));
268
-
269
- // Multi-key emit: write whichever side(s) differ from default,
270
- // clear the others. Mirrors the apply semantics of
271
- // `ColumnConfigFieldUpdate { keys, value }` with both keys owned.
272
- let on_pos_color = use_callback(
273
- (
274
- props.field_key_pos.clone(),
275
- props.field_key_neg.clone(),
276
- props.default_pos.clone(),
277
- props.default_neg.clone(),
278
- props.on_change.clone(),
279
- neg.clone(),
280
- ),
281
- |new_pos: String, (key_pos, key_neg, default_pos, default_neg, on_change, neg)| {
282
- emit_color_range(
283
- on_change,
284
- key_pos,
285
- key_neg,
286
- default_pos,
287
- default_neg,
288
- &new_pos,
289
- neg,
290
- );
291
- },
292
- );
287
+ pub fn PaletteField(props: &PaletteFieldProps) -> Html {
288
+ let current = props.current.as_deref().unwrap_or(&props.default);
289
+ let values = palette_colors(current, &props.default);
290
+ let is_modified = props.current.is_some() && props.current.as_ref() != Some(&props.default);
291
+ let emit_css = {
292
+ let key = props.field_key.clone();
293
+ let default = props.default.clone();
294
+ let on_change = props.on_change.clone();
295
+ move |css: String| {
296
+ if css == default {
297
+ emit(&on_change, &key, None);
298
+ } else {
299
+ emit(&on_change, &key, Some(Value::String(css)));
300
+ }
301
+ }
302
+ };
303
+
304
+ let on_change_palette = {
305
+ let emit_css = emit_css.clone();
306
+ Callback::from(move |values: Vec<String>| {
307
+ emit_css(CssPalette::Literal(values).to_css());
308
+ })
309
+ };
310
+
311
+ let on_select = {
312
+ let emit_css = emit_css.clone();
313
+ let named = props.named.clone();
314
+ Callback::from(move |name: String| {
315
+ if let Some(literal) = named_literal(&named, &name) {
316
+ emit_css(literal);
317
+ }
318
+ })
319
+ };
293
320
 
294
- let on_neg_color = use_callback(
295
- (
296
- props.field_key_pos.clone(),
297
- props.field_key_neg.clone(),
298
- props.default_pos.clone(),
299
- props.default_neg.clone(),
300
- props.on_change.clone(),
301
- pos.clone(),
302
- ),
303
- |new_neg: String, (key_pos, key_neg, default_pos, default_neg, on_change, pos)| {
304
- emit_color_range(
305
- on_change,
306
- key_pos,
307
- key_neg,
308
- default_pos,
309
- default_neg,
310
- pos,
311
- &new_neg,
312
- );
313
- },
321
+ let on_reset = use_callback(
322
+ (props.field_key.clone(), props.on_change.clone()),
323
+ |_: (), (key, on_change)| emit(on_change, key, None),
314
324
  );
315
325
 
326
+ let on_pin = {
327
+ let on_pin = props.on_pin.clone();
328
+ let literal = current.to_owned();
329
+ Callback::from(move |()| on_pin.emit((CssKind::Palette, literal.clone())))
330
+ };
331
+
332
+ html! {
333
+ <div class="row">
334
+ { css_field_controls(
335
+ CssKind::Palette,
336
+ &props.named,
337
+ &props.restored,
338
+ current,
339
+ is_modified,
340
+ on_select,
341
+ on_pin,
342
+ ) }
343
+ <PaletteSelector
344
+ {values}
345
+ max={props.max}
346
+ on_change={on_change_palette}
347
+ {on_reset}
348
+ {is_modified}
349
+ title={Some(format!("{}-label", props.field_key))}
350
+ />
351
+ </div>
352
+ }
353
+ }
354
+
355
+ #[derive(Properties, PartialEq)]
356
+ pub struct GradientStopsFieldProps {
357
+ pub field_key: String,
358
+ /// Canonical gradient string.
359
+ pub default: String,
360
+ pub discrete: bool,
361
+ /// Canonical gradient string.
362
+ pub current: Option<String>,
363
+ pub on_change: Callback<ColumnConfigFieldUpdate>,
364
+
365
+ /// Named gradients (workspace set ∪ theme), for the loader.
366
+ #[prop_or_default]
367
+ pub named: Rc<Vec<NamedValue>>,
368
+
369
+ /// The restored palette — the set Pin adds to.
370
+ #[prop_or_default]
371
+ pub restored: Rc<BTreeMap<String, String>>,
372
+
373
+ /// Pin the field's `(kind, literal)` into the restored palette.
374
+ #[prop_or_default]
375
+ pub on_pin: Callback<(CssKind, String)>,
376
+ }
377
+
378
+ fn gradient_stops(src: &str, default: &str) -> Vec<GradientStopSpec> {
379
+ let literal = |css: &str| match CssGradient::parse(css) {
380
+ Ok(CssGradient::Literal(stops)) => Some(stops),
381
+ _ => None,
382
+ };
383
+
384
+ literal(src)
385
+ .or_else(|| literal(default))
386
+ .unwrap_or_default()
387
+ }
388
+
389
+ #[function_component]
390
+ pub fn GradientStopsField(props: &GradientStopsFieldProps) -> Html {
391
+ let current = props.current.as_deref().unwrap_or(&props.default);
392
+ let stops = gradient_stops(current, &props.default);
393
+ let is_modified = props.current.is_some() && props.current.as_ref() != Some(&props.default);
394
+ let emit_css = {
395
+ let key = props.field_key.clone();
396
+ let default = props.default.clone();
397
+ let on_change = props.on_change.clone();
398
+ move |css: String| {
399
+ if css == default {
400
+ emit(&on_change, &key, None);
401
+ } else {
402
+ emit(&on_change, &key, Some(Value::String(css)));
403
+ }
404
+ }
405
+ };
406
+
407
+ let on_change_stops = {
408
+ let emit_css = emit_css.clone();
409
+ Callback::from(move |stops: Vec<GradientStopSpec>| {
410
+ emit_css(gradient_to_css(&stops));
411
+ })
412
+ };
413
+
414
+ let on_select = {
415
+ let emit_css = emit_css.clone();
416
+ let named = props.named.clone();
417
+ let discrete = props.discrete;
418
+ Callback::from(move |name: String| {
419
+ let Some(literal) = named_literal(&named, &name) else {
420
+ return;
421
+ };
422
+
423
+ let literal = match CssGradient::parse(&literal) {
424
+ Ok(CssGradient::Literal(stops)) if discrete && stops.len() > 2 => {
425
+ gradient_to_css(&discrete_pair(stops))
426
+ },
427
+ _ => literal,
428
+ };
429
+
430
+ emit_css(literal);
431
+ })
432
+ };
433
+
316
434
  let on_reset = use_callback(
317
- (
318
- props.field_key_pos.clone(),
319
- props.field_key_neg.clone(),
320
- props.on_change.clone(),
321
- ),
322
- |_: (), (key_pos, key_neg, on_change)| {
323
- on_change.emit(ColumnConfigFieldUpdate {
324
- keys: vec![key_pos.clone(), key_neg.clone()],
325
- value: serde_json::Map::new(),
326
- });
327
- },
435
+ (props.field_key.clone(), props.on_change.clone()),
436
+ |_: (), (key, on_change)| emit(on_change, key, None),
328
437
  );
329
438
 
439
+ let on_pin = {
440
+ let on_pin = props.on_pin.clone();
441
+ let literal = current.to_owned();
442
+ Callback::from(move |()| on_pin.emit((CssKind::Gradient, literal.clone())))
443
+ };
444
+
330
445
  html! {
331
446
  <div class="row">
332
- <ColorRangeSelector
333
- pos_class={classes!(props.field_key_pos.clone())}
334
- neg_class={classes!(props.field_key_neg.clone())}
335
- pos_color={pos}
336
- neg_color={neg}
337
- is_gradient={props.is_gradient}
338
- {on_pos_color}
339
- {on_neg_color}
447
+ { css_field_controls(
448
+ CssKind::Gradient,
449
+ &props.named,
450
+ &props.restored,
451
+ current,
452
+ is_modified,
453
+ on_select,
454
+ on_pin,
455
+ ) }
456
+ <MultiStopGradientSelector
457
+ {stops}
458
+ discrete={props.discrete}
459
+ on_change={on_change_stops}
340
460
  {on_reset}
341
461
  {is_modified}
462
+ title={Some(format!("{}-label", props.field_key))}
342
463
  />
343
464
  </div>
344
465
  }
@@ -347,42 +468,88 @@ pub fn ColorRangeField(props: &ColorRangeFieldProps) -> Html {
347
468
  #[derive(Properties, PartialEq)]
348
469
  pub struct ColorFieldProps {
349
470
  pub field_key: String,
471
+ /// Canonical `#rrggbb`.
350
472
  pub default: String,
473
+ /// Canonical `#rrggbb`.
351
474
  pub current: Option<String>,
352
475
  pub on_change: Callback<ColumnConfigFieldUpdate>,
476
+
477
+ /// Named colors (workspace set ∪ theme), for the loader.
478
+ #[prop_or_default]
479
+ pub named: Rc<Vec<NamedValue>>,
480
+
481
+ /// The restored palette — the set Pin adds to.
482
+ #[prop_or_default]
483
+ pub restored: Rc<BTreeMap<String, String>>,
484
+
485
+ /// Pin the field's `(kind, literal)` into the restored palette.
486
+ #[prop_or_default]
487
+ pub on_pin: Callback<(CssKind, String)>,
353
488
  }
354
489
 
355
490
  #[function_component]
356
491
  pub fn ColorField(props: &ColorFieldProps) -> Html {
357
- let color = props
358
- .current
359
- .clone()
360
- .unwrap_or_else(|| props.default.clone());
492
+ let current = props.current.as_deref().unwrap_or(&props.default);
493
+ let color = match CssColor::parse(current) {
494
+ Ok(CssColor::Literal(color)) => color,
495
+ _ => props.default.clone(),
496
+ };
497
+
361
498
  let is_modified =
362
499
  props.current.as_deref() != Some(props.default.as_str()) && props.current.is_some();
363
500
 
364
- let on_color = use_callback(
365
- (
366
- props.field_key.clone(),
367
- props.default.clone(),
368
- props.on_change.clone(),
369
- ),
370
- |value: String, (key, default, on_change)| {
371
- if value == *default {
372
- emit(on_change, key, None);
501
+ let emit_css = {
502
+ let key = props.field_key.clone();
503
+ let default = props.default.clone();
504
+ let on_change = props.on_change.clone();
505
+ move |css: String| {
506
+ if css == default {
507
+ emit(&on_change, &key, None);
373
508
  } else {
374
- emit(on_change, key, Some(Value::String(value)));
509
+ emit(&on_change, &key, Some(Value::String(css)));
375
510
  }
376
- },
377
- );
511
+ }
512
+ };
513
+
514
+ let on_color = {
515
+ let emit_css = emit_css.clone();
516
+ Callback::from(move |value: String| {
517
+ emit_css(canonicalize_css_color(&value).unwrap_or(value));
518
+ })
519
+ };
520
+
521
+ let on_select = {
522
+ let emit_css = emit_css.clone();
523
+ let named = props.named.clone();
524
+ Callback::from(move |name: String| {
525
+ if let Some(literal) = named_literal(&named, &name) {
526
+ emit_css(literal);
527
+ }
528
+ })
529
+ };
378
530
 
379
531
  let on_reset = use_callback(
380
532
  (props.field_key.clone(), props.on_change.clone()),
381
533
  |_: (), (key, on_change)| emit(on_change, key, None),
382
534
  );
383
535
 
536
+ let on_pin = {
537
+ let on_pin = props.on_pin.clone();
538
+ let literal = current.to_owned();
539
+ Callback::from(move |()| on_pin.emit((CssKind::Color, literal.clone())))
540
+ };
541
+
384
542
  html! {
385
543
  <div class="row">
544
+ { css_field_controls(
545
+ CssKind::Color,
546
+ &props.named,
547
+ &props.restored,
548
+ current,
549
+ is_modified,
550
+ on_select,
551
+ on_pin,
552
+ ) }
386
553
  <ColorSelector
387
554
  {color}
388
555
  {on_color}