Skip to main content

perspective_viewer/components/
window_editor.rs

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
13use std::collections::HashSet;
14use std::rc::Rc;
15
16use perspective_client::config::{ColumnType, WindowFrame, WindowSort, WindowSortDir, WindowSpec};
17use wasm_bindgen::JsCast;
18use web_sys::{DragEvent, HtmlInputElement, MouseEvent};
19use yew::prelude::*;
20
21use crate::components::column_dropdown::{ColumnDropDownElement, ColumnDropDownPortal};
22use crate::components::column_selector::{ColumnSelectorColumnRow, InPlaceColumn, PivotColumn};
23use crate::components::dragdrop_list::{DragContext, DragDropList, DragDropListItemProps};
24use crate::presentation::Presentation;
25use crate::session::{Session, SessionMetadataRc};
26use crate::ui::{IntlLabel, Select, SelectItem};
27use crate::utils::{AddListener, DragEffect, DragTarget, Subscription};
28
29/// The declared capabilities of one window aggregate, for a `source` column
30/// type. Which controls an aggregate needs is the data model's to state - the
31/// editor cannot infer it from a name it has never seen.
32fn op_spec(
33    metadata: &SessionMetadataRc,
34    source: &str,
35    op: &str,
36) -> Option<perspective_client::proto::WindowAggregateArgs> {
37    let ty = metadata.get_column_table_type(source)?;
38    metadata.get_window_aggregate(ty, op)
39}
40
41/// The editor's frame-type labels, as the `frames` a declaration lists.
42fn frame_label(frame: &str) -> &'static str {
43    match frame {
44        "rows" => "Rows",
45        "range" => "Range",
46        _ => "Cumulative",
47    }
48}
49
50fn frame_name(label: &str) -> &'static str {
51    match label {
52        "Rows" => "rows",
53        "Range" => "range",
54        _ => "cumulative",
55    }
56}
57
58fn is_orderable_for_range(ty: ColumnType) -> bool {
59    matches!(
60        ty,
61        ColumnType::Integer | ColumnType::Float | ColumnType::Date | ColumnType::Datetime
62    )
63}
64
65#[derive(Clone, Properties)]
66pub struct WindowEditorProps {
67    pub metadata: SessionMetadataRc,
68
69    /// The saved spec under edit, or `None` for the "new column" drawer.
70    pub initial: Option<WindowSpec>,
71
72    /// Fires on every form mutation with the draft spec when it validates,
73    /// else `None`. The spec's `name` is a placeholder - the editable header
74    /// owns naming.
75    pub on_change: Callback<Option<WindowSpec>>,
76
77    /// Incremented by the parent to discard the draft.
78    pub reset_count: u8,
79
80    /// Drag/drop state - the editor's column slots are STAGED drop targets
81    /// ([`DragTarget::is_staged`]): drops mutate the draft only, and the
82    /// drag origin never self-removes.
83    pub presentation: Presentation,
84
85    /// Threaded for the slots' autocomplete dropdown
86    /// ([`ColumnDropDownElement`]).
87    pub session: Session,
88
89    /// Selected theme name, threaded for the autocomplete dropdown's
90    /// `PortalModal`.
91    #[prop_or_default]
92    pub selected_theme: Option<String>,
93}
94
95impl PartialEq for WindowEditorProps {
96    fn eq(&self, other: &Self) -> bool {
97        self.metadata == other.metadata
98            && self.initial == other.initial
99            && self.reset_count == other.reset_count
100            && self.selected_theme == other.selected_theme
101    }
102}
103
104/// Input mirror of a draft [`WindowSpec`]. Free-text fields hold raw
105/// strings so invalid intermediate input is representable; the NUMBER
106/// fields are typed with default `1` and can never hold an invalid value -
107/// like the Style tab's `NumberField`, a non-numeric or out-of-domain
108/// input resets to the default at the keystroke. `validate` produces the
109/// spec (or the error shown inline), applying the same rules as `View`
110/// construction so a saveable draft cannot be rejected by the engine.
111#[derive(Clone, PartialEq)]
112struct WindowDraft {
113    op: String,
114    source: String,
115    order_by: String,
116    order_desc: bool,
117    partition_by: Vec<String>,
118    frame_type: String,
119    frame_rows: u32,
120    frame_range: f64,
121    offset: u32,
122    alpha: f64,
123}
124
125impl Default for WindowDraft {
126    fn default() -> Self {
127        Self {
128            op: String::default(),
129            source: String::default(),
130            order_by: String::default(),
131            order_desc: false,
132            partition_by: vec![],
133            frame_type: String::default(),
134            frame_rows: 1,
135            frame_range: 1.0,
136            offset: 1,
137            alpha: 1.0,
138        }
139    }
140}
141
142impl WindowDraft {
143    fn from_spec(spec: &WindowSpec) -> Self {
144        let (frame_type, frame_rows, frame_range) = match spec.frame {
145            Some(WindowFrame::Rows(n)) => ("Rows", n, 1.0),
146            Some(WindowFrame::Range(x)) => ("Range", 1, x),
147            Some(WindowFrame::Cumulative) | None => ("Cumulative", 1, 1.0),
148        };
149
150        Self {
151            op: spec.aggregate.clone(),
152            source: spec.column.clone(),
153            order_by: spec
154                .order_by
155                .as_ref()
156                .map(|x| x.0.clone())
157                .unwrap_or_default(),
158            order_desc: spec
159                .order_by
160                .as_ref()
161                .map(|x| x.1 == WindowSortDir::Desc)
162                .unwrap_or_default(),
163            partition_by: spec.partition_by.clone(),
164            frame_type: frame_type.to_string(),
165            frame_rows,
166            frame_range,
167            offset: spec.offset.unwrap_or(1),
168            alpha: spec.alpha.unwrap_or(1.0),
169        }
170    }
171
172    fn new_default() -> Self {
173        Self {
174            op: "sum".to_string(),
175            frame_type: "Cumulative".to_string(),
176            ..Self::default()
177        }
178    }
179
180    fn validate(&self, metadata: &SessionMetadataRc) -> Result<WindowSpec, String> {
181        let op = self.op.clone();
182
183        // Every slot takes true `Table` columns ONLY - expression aliases
184        // and other window columns would create dependency cycles (and
185        // force delete-blocking). The slots reject non-table drops with the
186        // invalid-X overlay, so these are backstops for API-authored specs
187        // opened in the editor.
188        let table_column = |col: &String| {
189            metadata
190                .get_table_columns()
191                .into_iter()
192                .flatten()
193                .any(|x| x == col)
194        };
195
196        if self.source.is_empty() {
197            return Err("Missing Column".to_string());
198        }
199
200        if !table_column(&self.source) {
201            // TODO I should not be
202            return Err(format!(
203                "\"{}\" must be a table column to source a window",
204                self.source
205            ));
206        }
207
208        let source_ty = metadata
209            .get_column_table_type(&self.source)
210            .ok_or_else(|| format!("Unknown source column \"{}\"", self.source))?;
211
212        // Backstop for API-authored specs opened in the editor - the op
213        // menu only offers the feature-declared set, so this is
214        // unreachable from the UI. The declaration also supplies the
215        // controls this op takes, below.
216        let declared = metadata
217            .get_window_aggregate(source_ty, &op)
218            .ok_or_else(|| {
219                format!("\"{op}\" is not a supported window aggregate for this column")
220            })?;
221
222        // An EMPTY order slot is valid when the backend has a natural row
223        // order to fall back on (primary key order in the engine, `rowid`
224        // for SQL virtual servers); UNORDERED stores (`Features`) require
225        // an explicit order column.
226        let order_ty = if self.order_by.is_empty() {
227            if metadata
228                .get_features()
229                .map(|x| x.unordered)
230                .unwrap_or_default()
231            {
232                return Err("Missing Order By".to_string());
233            }
234
235            None
236        } else {
237            if !table_column(&self.order_by) {
238                // TODO I should not be
239                return Err(format!(
240                    "\"{}\" must be a table column to order by",
241                    self.order_by
242                ));
243            }
244
245            Some(
246                metadata
247                    .get_column_table_type(&self.order_by)
248                    .ok_or_else(|| format!("Unknown order by column \"{}\"", self.order_by))?,
249            )
250        };
251
252        for col in self.partition_by.iter() {
253            // TODO I should not be
254            if !table_column(col) {
255                return Err(format!(
256                    "\"{}\" must be a table column to partition by",
257                    col
258                ));
259            }
260        }
261
262        // The numeric fields are typed and input-clamped to their domains,
263        // so no parse or range errors are reachable here.
264        let frame = if declared.frames.is_empty() {
265            None
266        } else {
267            let chosen = frame_name(&self.frame_type);
268            if !declared.frames.iter().any(|x| x == chosen) {
269                return Err(format!("\"{op}\" does not support a {chosen} frame"));
270            }
271
272            match self.frame_type.as_str() {
273                "Rows" => Some(WindowFrame::Rows(self.frame_rows)),
274                "Range" => {
275                    // The natural-order fallback has no units, so `range`
276                    // frames require an explicit order column.
277                    let Some(order_ty) = order_ty else {
278                        return Err("Range frames require an order by column".to_string());
279                    };
280
281                    if !is_orderable_for_range(order_ty) {
282                        return Err("Range frames require a numeric, date or datetime order by \
283                                    column"
284                            .to_string());
285                    }
286
287                    Some(WindowFrame::Range(self.frame_range))
288                },
289                _ => None,
290            }
291        };
292
293        // Emit `None` at the engine default so a spec saved without an
294        // explicit `offset` round-trips unchanged (the name-stripped
295        // change-detection baseline compares specs structurally).
296        let offset = (declared.offset && self.offset != 1).then_some(self.offset);
297        let alpha = declared.alpha.then_some(self.alpha);
298
299        let mut partition_by = self.partition_by.clone();
300        partition_by.retain(|col| !col.is_empty());
301
302        Ok(WindowSpec {
303            column: self.source.clone(),
304            aggregate: op,
305            partition_by,
306            order_by: (!self.order_by.is_empty()).then(|| {
307                WindowSort(
308                    self.order_by.clone(),
309                    if self.order_desc {
310                        WindowSortDir::Desc
311                    } else {
312                        WindowSortDir::Asc
313                    },
314                )
315            }),
316            frame,
317            offset,
318            alpha,
319        })
320    }
321}
322
323#[derive(Clone, Debug)]
324pub enum WindowEditorMsg {
325    SetOp(String),
326    ClearSource,
327    ClearOrderBy,
328    ToggleOrderDir,
329    RemovePartition(usize),
330    SetFrameType(String),
331    /// Number-field messages carry the input's `value_as_number` (`NaN` for
332    /// empty or unparseable text); the handlers clamp to each field's
333    /// domain, resetting to the default `1` on invalid input.
334    SetFrameRows(f64),
335    SetFrameRange(f64),
336    SetOffset(f64),
337    SetAlpha(f64),
338    /// A completed drop concerning this editor: a staged target to fill,
339    /// and/or the staged origin slot (`DragEffect::Move` from a slot pill)
340    /// to clear.
341    Drop(String, DragTarget, Option<DragTarget>),
342    New(DragTarget, InPlaceColumn),
343    DragEnter(DragTarget, usize),
344    DragLeave(DragTarget),
345}
346
347/// [`DragContext`] bindings routing one [`DragDropList`] per staged target
348/// into [`WindowEditorMsg`]s - the same composition the config selector
349/// uses per zone, so the slots inherit its drop previews, autocomplete
350/// (`EmptyColumn`) and pill styling.
351struct WindowSourceContext;
352struct WindowOrderByContext;
353struct WindowPartitionByContext;
354
355impl DragContext<WindowEditorMsg> for WindowSourceContext {
356    fn close(_index: usize) -> WindowEditorMsg {
357        WindowEditorMsg::ClearSource
358    }
359
360    fn dragenter(index: usize) -> WindowEditorMsg {
361        WindowEditorMsg::DragEnter(DragTarget::WindowSource, index)
362    }
363
364    fn dragleave() -> WindowEditorMsg {
365        WindowEditorMsg::DragLeave(DragTarget::WindowSource)
366    }
367
368    fn create(col: InPlaceColumn) -> WindowEditorMsg {
369        WindowEditorMsg::New(DragTarget::WindowSource, col)
370    }
371
372    fn is_self_move(effect: DragTarget) -> bool {
373        effect == DragTarget::WindowSource
374    }
375}
376
377impl DragContext<WindowEditorMsg> for WindowOrderByContext {
378    fn close(_index: usize) -> WindowEditorMsg {
379        WindowEditorMsg::ClearOrderBy
380    }
381
382    fn dragenter(index: usize) -> WindowEditorMsg {
383        WindowEditorMsg::DragEnter(DragTarget::WindowOrderBy, index)
384    }
385
386    fn dragleave() -> WindowEditorMsg {
387        WindowEditorMsg::DragLeave(DragTarget::WindowOrderBy)
388    }
389
390    fn create(col: InPlaceColumn) -> WindowEditorMsg {
391        WindowEditorMsg::New(DragTarget::WindowOrderBy, col)
392    }
393
394    fn is_self_move(effect: DragTarget) -> bool {
395        effect == DragTarget::WindowOrderBy
396    }
397}
398
399impl DragContext<WindowEditorMsg> for WindowPartitionByContext {
400    fn close(index: usize) -> WindowEditorMsg {
401        WindowEditorMsg::RemovePartition(index)
402    }
403
404    fn dragenter(index: usize) -> WindowEditorMsg {
405        WindowEditorMsg::DragEnter(DragTarget::WindowPartitionBy, index)
406    }
407
408    fn dragleave() -> WindowEditorMsg {
409        WindowEditorMsg::DragLeave(DragTarget::WindowPartitionBy)
410    }
411
412    fn create(col: InPlaceColumn) -> WindowEditorMsg {
413        WindowEditorMsg::New(DragTarget::WindowPartitionBy, col)
414    }
415
416    fn is_self_move(effect: DragTarget) -> bool {
417        effect == DragTarget::WindowPartitionBy
418    }
419}
420
421/// A slot's filled state: the shared `ColumnSelectorColumnRow`, with the
422/// window `op` selector bound into its aggregate slot for the SOURCE slot -
423/// the ONLY op control in the UI. The pill is a drag ORIGIN with
424/// `DragEffect::Move(action)` (the same shape as `PivotColumn`) - the open
425/// [`WindowEditor`] consumes the staged origin from `drop_received` to
426/// complete the move by clearing this slot in its draft.
427#[derive(Clone, Properties)]
428pub struct WindowSlotColumnProps {
429    pub column: String,
430    pub column_type: Option<ColumnType>,
431
432    /// The staged slot this pill occupies - its drag origin.
433    pub action: DragTarget,
434
435    pub presentation: Presentation,
436
437    #[prop_or_default]
438    pub aggregate: Option<Html>,
439
440    /// Trailing affordance in the row (the order slot's staged sort-dir
441    /// toggle).
442    #[prop_or_default]
443    pub trailing: Html,
444}
445
446impl PartialEq for WindowSlotColumnProps {
447    fn eq(&self, other: &Self) -> bool {
448        self.column == other.column
449            && self.column_type == other.column_type
450            && self.action == other.action
451            && self.aggregate == other.aggregate
452            && self.trailing == other.trailing
453    }
454}
455
456impl DragDropListItemProps for WindowSlotColumnProps {
457    type Item = String;
458
459    fn get_item(&self) -> String {
460        self.column.clone()
461    }
462}
463
464pub struct WindowSlotColumn;
465
466impl Component for WindowSlotColumn {
467    type Message = ();
468    type Properties = WindowSlotColumnProps;
469
470    fn create(_ctx: &Context<Self>) -> Self {
471        Self
472    }
473
474    fn view(&self, ctx: &Context<Self>) -> Html {
475        let dragstart = Callback::from({
476            let column = ctx.props().column.clone();
477            let presentation = ctx.props().presentation.clone();
478            let action = ctx.props().action;
479            move |event: DragEvent| {
480                if presentation.set_drag_image(&event) {
481                    presentation.notify_drag_start(column.clone(), DragEffect::Move(action))
482                }
483            }
484        });
485
486        let dragend = Callback::from({
487            let presentation = ctx.props().presentation.clone();
488            move |_: DragEvent| presentation.notify_drag_end()
489        });
490
491        html! {
492            <div class="column-selector-column">
493                <ColumnSelectorColumnRow
494                    name={ctx.props().column.clone()}
495                    col_type={ctx.props().column_type}
496                    aggregate={ctx.props().aggregate.clone()}
497                    trailing={ctx.props().trailing.clone()}
498                    wrapper_class={classes!["column-selector-column-title"]}
499                    ondragstart={Some(dragstart)}
500                    ondragend={Some(dragend)}
501                />
502            </div>
503        }
504    }
505}
506
507pub struct WindowEditor {
508    draft: WindowDraft,
509    error: Option<String>,
510    column_dropdown: ColumnDropDownElement,
511    _drop_sub: Subscription,
512}
513
514impl WindowEditor {
515    fn initialize(ctx: &Context<Self>) -> WindowDraft {
516        ctx.props()
517            .initial
518            .as_ref()
519            .map(WindowDraft::from_spec)
520            .unwrap_or_else(WindowDraft::new_default)
521    }
522
523    fn emit(&mut self, ctx: &Context<Self>) {
524        match self.draft.validate(&ctx.props().metadata) {
525            Ok(spec) => {
526                self.error = None;
527                ctx.props().on_change.emit(Some(spec));
528            },
529            Err(msg) => {
530                self.error = Some(msg);
531                ctx.props().on_change.emit(None);
532            },
533        }
534    }
535}
536
537impl Component for WindowEditor {
538    type Message = WindowEditorMsg;
539    type Properties = WindowEditorProps;
540
541    fn create(ctx: &Context<Self>) -> Self {
542        let _drop_sub = {
543            let link = ctx.link().clone();
544            ctx.props().presentation.drop_received.add_listener(
545                move |(column, target, effect, _index): (String, DragTarget, DragEffect, usize)| {
546                    let staged_origin = match effect {
547                        DragEffect::Move(origin) if origin.is_staged() => Some(origin),
548                        _ => None,
549                    };
550
551                    if target.is_staged() || staged_origin.is_some() {
552                        link.send_message(WindowEditorMsg::Drop(column, target, staged_origin));
553                    }
554                },
555            )
556        };
557
558        let mut this = Self {
559            draft: Self::initialize(ctx),
560            error: None,
561            column_dropdown: ColumnDropDownElement::new(ctx.props().session.clone()),
562            _drop_sub,
563        };
564
565        this.emit(ctx);
566        this
567    }
568
569    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
570        if ctx.props().reset_count != old_props.reset_count
571            || ctx.props().initial != old_props.initial
572        {
573            self.draft = Self::initialize(ctx);
574            self.emit(ctx);
575            true
576        } else {
577            false
578        }
579    }
580
581    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
582        match msg {
583            WindowEditorMsg::DragEnter(target, index) => {
584                return ctx.props().presentation.notify_drag_enter(target, index);
585            },
586            WindowEditorMsg::DragLeave(target) => {
587                ctx.props().presentation.notify_drag_leave(target);
588                return true;
589            },
590            WindowEditorMsg::New(target, InPlaceColumn::Column(column)) => {
591                ctx.link()
592                    .send_message(WindowEditorMsg::Drop(column, target, None));
593                return false;
594            },
595            WindowEditorMsg::New(_, InPlaceColumn::Expression(_)) => {
596                // In-place expression creation is a config-selector affordance;
597                // staged window slots take existing columns only.
598                return false;
599            },
600            WindowEditorMsg::Drop(column, target, origin) => {
601                // The origin slot clears FIRST so a self-move (origin ==
602                // target) re-inserts the same column and nets to no change.
603                match origin {
604                    Some(DragTarget::WindowSource) => self.draft.source = String::default(),
605                    Some(DragTarget::WindowOrderBy) => self.draft.order_by = String::default(),
606                    Some(DragTarget::WindowPartitionBy) => {
607                        self.draft.partition_by.retain(|x| x != &column)
608                    },
609                    _ => {},
610                }
611
612                // Slots take true `Table` columns ONLY - a staged drop of
613                // an expression or window column is rejected outright (the
614                // list already showed the invalid-X overlay during hover).
615                let is_table_column = ctx
616                    .props()
617                    .metadata
618                    .get_table_columns()
619                    .into_iter()
620                    .flatten()
621                    .any(|x| *x == column);
622
623                match target {
624                    _ if target.is_staged() && !is_table_column => {},
625                    DragTarget::WindowSource => {
626                        self.draft.source = column;
627
628                        // The op selector only offers the feature-declared
629                        // set for the source's type, so an op orphaned by
630                        // the new source coerces to that set's first entry.
631                        let available = ctx
632                            .props()
633                            .metadata
634                            .get_column_table_type(&self.draft.source)
635                            .and_then(|ty| {
636                                ctx.props()
637                                    .metadata
638                                    .get_features()
639                                    .map(|x| x.get_window_aggregates(ty))
640                            })
641                            .unwrap_or_default();
642
643                        if !available.iter().any(|x| x.name == self.draft.op)
644                            && let Some(first) = available.first()
645                        {
646                            self.draft.op = first.name.clone();
647                        }
648                    },
649                    DragTarget::WindowOrderBy => self.draft.order_by = column,
650                    DragTarget::WindowPartitionBy
651                        if !column.is_empty() && !self.draft.partition_by.contains(&column) =>
652                    {
653                        self.draft.partition_by.push(column);
654                    },
655                    // A committing target: the config zone handles its own
656                    // insert; only the staged origin cleanup applies here.
657                    _ => {},
658                }
659            },
660            WindowEditorMsg::SetOp(op) => {
661                self.draft.op = op;
662
663                // Keep the frame coherent as the op changes - an op that
664                // does not accept the current frame kind coerces to its
665                // first declared one (the dropdown omits the rest).
666                if let Some(declared) =
667                    op_spec(&ctx.props().metadata, &self.draft.source, &self.draft.op)
668                    && !declared.frames.is_empty()
669                    && !declared
670                        .frames
671                        .iter()
672                        .any(|x| x == frame_name(&self.draft.frame_type))
673                    && let Some(first) = declared.frames.first()
674                {
675                    self.draft.frame_type = frame_label(first).to_string();
676                }
677            },
678            WindowEditorMsg::ClearSource => self.draft.source = String::default(),
679            WindowEditorMsg::ClearOrderBy => self.draft.order_by = String::default(),
680            WindowEditorMsg::ToggleOrderDir => self.draft.order_desc = !self.draft.order_desc,
681            WindowEditorMsg::RemovePartition(idx) => {
682                if idx < self.draft.partition_by.len() {
683                    self.draft.partition_by.remove(idx);
684                }
685            },
686            WindowEditorMsg::SetFrameType(x) => self.draft.frame_type = x,
687            WindowEditorMsg::SetFrameRows(x) => {
688                self.draft.frame_rows = if x.is_finite() && x >= 0.0 {
689                    x as u32
690                } else {
691                    1
692                };
693            },
694            WindowEditorMsg::SetFrameRange(x) => {
695                self.draft.frame_range = if x.is_finite() && x > 0.0 { x } else { 1.0 };
696            },
697            WindowEditorMsg::SetOffset(x) => {
698                self.draft.offset = if x.is_finite() && x >= 0.0 {
699                    x as u32
700                } else {
701                    1
702                };
703            },
704            WindowEditorMsg::SetAlpha(x) => {
705                self.draft.alpha = if x.is_finite() && x > 0.0 && x <= 1.0 {
706                    x
707                } else {
708                    1.0
709                };
710            },
711        }
712
713        self.emit(ctx);
714        true
715    }
716
717    fn view(&self, ctx: &Context<Self>) -> Html {
718        let declared = op_spec(&ctx.props().metadata, &self.draft.source, &self.draft.op);
719
720        // The op selector renders the FEATURE-DECLARED window aggregates
721        // for the source column's type, in the server's declared order -
722        // capability and type-validity both come from the data model
723        // (`GetFeaturesResp::window_aggregates`), not a hardcoded list.
724        let ops: Rc<Vec<SelectItem<String>>> = Rc::new(
725            ctx.props()
726                .metadata
727                .get_column_table_type(&self.draft.source)
728                .and_then(|ty| {
729                    ctx.props()
730                        .metadata
731                        .get_features()
732                        .map(|x| x.get_window_aggregates(ty))
733                })
734                .unwrap_or_default()
735                .into_iter()
736                .map(|x| SelectItem::Option(x.name.clone()))
737                .collect(),
738        );
739
740        // Number inputs report `value_as_number` (`NaN` for empty/garbage),
741        // the Style tab `NumberField` idiom - the handlers reset invalid
742        // values to the default.
743        let on_number_input = |f: fn(f64) -> WindowEditorMsg| {
744            ctx.link().callback(move |event: InputEvent| {
745                let value = event
746                    .target()
747                    .and_then(|t| t.dyn_into::<HtmlInputElement>().ok())
748                    .map(|x| x.value_as_number())
749                    .unwrap_or(f64::NAN);
750                f(value)
751            })
752        };
753
754        // Frame type as a dropdown; like the op selector, invalid choices
755        // are omitted rather than disabled - the declared `frames` are the
756        // menu, and `SetOp` already coerces the draft into them.
757        let frame_types: Rc<Vec<SelectItem<String>>> = Rc::new(
758            declared
759                .as_ref()
760                .map(|x| x.frames.clone())
761                .unwrap_or_default()
762                .iter()
763                .map(|x| SelectItem::Option(frame_label(x).to_string()))
764                .collect(),
765        );
766
767        // Slots take true `Table` columns ONLY - expression aliases and
768        // other window columns would create dependency cycles (and force
769        // delete-blocking). They join every slot's `exclude` set (removing
770        // them from the autocomplete dropdowns), the drop handler rejects
771        // them, and an invalid hover shows the X overlay below.
772        let non_table: HashSet<String> = ctx
773            .props()
774            .metadata
775            .get_expression_columns()
776            .chain(ctx.props().metadata.get_window_columns())
777            .cloned()
778            .collect();
779
780        let is_invalid_drag = |target: DragTarget| {
781            ctx.props()
782                .presentation
783                .is_dragover(target)
784                .map(|(_, col)| non_table.contains(&col))
785                .unwrap_or_default()
786        };
787
788        // The op selector renders in the source pill's aggregate-selector
789        // space - the ONLY place a window op control exists in the UI.
790        let source_exclude: HashSet<String> = std::iter::once(self.draft.source.clone())
791            .chain(non_table.iter().cloned())
792            .collect();
793
794        let source_list = html! {
795            <DragDropList<WindowEditor, WindowSlotColumn, WindowSourceContext>
796                name="window-source"
797                parent={ctx.link().clone()}
798                presentation={ctx.props().presentation.clone()}
799                column_dropdown={self.column_dropdown.clone()}
800                exclude={source_exclude}
801                is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowSource)}
802                is_invalid={is_invalid_drag(DragTarget::WindowSource)}
803                single_slot=true
804            >
805                { for (!self.draft.source.is_empty()).then(|| yew::html_nested! {
806                        <WindowSlotColumn
807                            column={self.draft.source.clone()}
808                            column_type={ctx.props().metadata.get_column_table_type(&self.draft.source)}
809                            action={DragTarget::WindowSource}
810                            presentation={ctx.props().presentation.clone()}
811                            aggregate={html! {
812                                <div class="aggregate-selector-wrapper">
813                                    <Select<String>
814                                        wrapper_class="aggregate-selector"
815                                        values={ops.clone()}
816                                        selected={self.draft.op.clone()}
817                                        on_select={ctx.link().callback(WindowEditorMsg::SetOp)}
818                                    />
819                                </div>
820                            }}
821                        />
822                    }) }
823            </DragDropList<WindowEditor, WindowSlotColumn, WindowSourceContext>>
824        };
825
826        let order_exclude: HashSet<String> = std::iter::once(self.draft.order_by.clone())
827            .chain(non_table.iter().cloned())
828            .collect();
829        let order_list = html! {
830            <DragDropList<WindowEditor, WindowSlotColumn, WindowOrderByContext>
831                name="window-order-by"
832                parent={ctx.link().clone()}
833                presentation={ctx.props().presentation.clone()}
834                column_dropdown={self.column_dropdown.clone()}
835                exclude={order_exclude}
836                is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowOrderBy)}
837                is_invalid={is_invalid_drag(DragTarget::WindowOrderBy)}
838                single_slot=true
839            >
840                { for (!self.draft.order_by.is_empty()).then(|| {
841                        // The sort pill's direction affordance, staged: the
842                        // toggle mutates the draft only, committed by Save.
843                        let dir = if self.draft.order_desc {
844                            WindowSortDir::Desc
845                        } else {
846                            WindowSortDir::Asc
847                        };
848
849                        let onmousedown = ctx
850                            .link()
851                            .callback(|_: MouseEvent| WindowEditorMsg::ToggleOrderDir);
852
853                        yew::html_nested! {
854                            <WindowSlotColumn
855                                column={self.draft.order_by.clone()}
856                                column_type={ctx.props().metadata.get_column_table_type(&self.draft.order_by)}
857                                action={DragTarget::WindowOrderBy}
858                                presentation={ctx.props().presentation.clone()}
859                                trailing={html! {
860                                    <span
861                                        class={format!("sort-icon {}", dir)}
862                                        {onmousedown}
863                                    />
864                                }}
865                            />
866                        }
867                    }) }
868            </DragDropList<WindowEditor, WindowSlotColumn, WindowOrderByContext>>
869        };
870
871        let partition_exclude: HashSet<String> = self
872            .draft
873            .partition_by
874            .iter()
875            .cloned()
876            .chain(non_table.iter().cloned())
877            .collect();
878        let partition_list = html! {
879            <DragDropList<WindowEditor, PivotColumn, WindowPartitionByContext>
880                name="window-partition-by"
881                parent={ctx.link().clone()}
882                presentation={ctx.props().presentation.clone()}
883                column_dropdown={self.column_dropdown.clone()}
884                exclude={partition_exclude}
885                is_dragover={ctx.props().presentation.is_dragover(DragTarget::WindowPartitionBy)}
886                is_invalid={is_invalid_drag(DragTarget::WindowPartitionBy)}
887            >
888                { for self.draft.partition_by.iter().map(|col| yew::html_nested! {
889                        <PivotColumn
890                            column={col.clone()}
891                            column_type={ctx.props().metadata.get_column_table_type(col)}
892                            action={DragTarget::WindowPartitionBy}
893                            presentation={ctx.props().presentation.clone()}
894                        />
895                    }) }
896            </DragDropList<WindowEditor, PivotColumn, WindowPartitionByContext>>
897        };
898
899        let show_frame = declared
900            .as_ref()
901            .map(|x| !x.frames.is_empty())
902            .unwrap_or_default();
903        let show_offset = declared.as_ref().map(|x| x.offset).unwrap_or_default();
904        let show_alpha = declared.as_ref().map(|x| x.alpha).unwrap_or_default();
905
906        html! {
907            <>
908                <div id="window-editor-slots">{ source_list }{ order_list }{ partition_list }</div>
909                <div id="window-editor-container">
910                    if show_frame {
911                        <div class="column-style-label"><IntlLabel name="window-frame" /></div>
912                        <div class="row">
913                            <Select<String>
914                                id="window-frame-type"
915                                values={frame_types}
916                                selected={self.draft.frame_type.clone()}
917                                on_select={ctx.link().callback(WindowEditorMsg::SetFrameType)}
918                            />
919                        </div>
920                        if self.draft.frame_type == "Rows" {
921                            <div class="row">
922                                <input
923                                    type="number"
924                                    class="parameter"
925                                    min="0"
926                                    value={self.draft.frame_rows.to_string()}
927                                    oninput={on_number_input(WindowEditorMsg::SetFrameRows)}
928                                />
929                            </div>
930                        }
931                        if self.draft.frame_type == "Range" {
932                            <div class="row">
933                                <input
934                                    type="number"
935                                    class="parameter"
936                                    min="0"
937                                    value={self.draft.frame_range.to_string()}
938                                    oninput={on_number_input(WindowEditorMsg::SetFrameRange)}
939                                />
940                            </div>
941                        }
942                    }
943                    if show_offset {
944                        <div class="column-style-label">
945                            <IntlLabel name="window-offset" class="indent" />
946                        </div>
947                        <div class="row">
948                            <input
949                                type="number"
950                                class="parameter"
951                                min="0"
952                                value={self.draft.offset.to_string()}
953                                oninput={on_number_input(WindowEditorMsg::SetOffset)}
954                            />
955                        </div>
956                    }
957                    if show_alpha {
958                        <div class="column-style-label">
959                            <IntlLabel name="window-alpha" class="indent" />
960                        </div>
961                        <div class="row">
962                            <input
963                                type="number"
964                                class="parameter"
965                                step="0.05"
966                                min="0"
967                                max="1"
968                                value={self.draft.alpha.to_string()}
969                                oninput={on_number_input(WindowEditorMsg::SetAlpha)}
970                            />
971                        </div>
972                    }
973                    if let Some(error) = &self.error {
974                        <div class="row window-editor-error">{ error.clone() }</div>
975                    }
976                </div>
977                <ColumnDropDownPortal
978                    element={self.column_dropdown.clone()}
979                    theme={ctx.props().selected_theme.clone().unwrap_or_default()}
980                />
981            </>
982        }
983    }
984}