Skip to main content

iced_cube/data/
property_grid.rs

1//! Named properties in a compact two-column inspector, such as the
2//! properties panel of a design tool: names on the left, editors on the
3//! right, grouped into categories that collapse.
4//!
5//! [`State`] holds the properties by id with their values, which categories
6//! are collapsed, the highlight, the filter and the [`Sort`]. Its
7//! [`update`](State::update) returns [`Output::Changed`] whenever a value
8//! changes and [`Output::Reset`] when one goes back to its default.
9//!
10//! Text, numbers and colours are edited as text. A valid edit changes the
11//! value as it is typed; an invalid one keeps its draft and shows the error
12//! until it is fixed or cancelled, without changing the value. Cancelling
13//! puts back the value from before the edit.
14//!
15//! The grid resolves its keys through a [`Keymap`] of [`Action`]s while it
16//! has focus, which it takes when pressed; see [`default_keymap`].
17
18use std::collections::{HashMap, HashSet};
19use std::fmt;
20use std::hash::Hash;
21use std::ops::Range;
22use std::rc::Rc;
23
24use iced::advanced::layout::{self, Layout};
25use iced::advanced::widget::operation;
26use iced::advanced::widget::{Operation, Tree, Widget, tree};
27use iced::advanced::{Clipboard, Shell, overlay, renderer};
28use iced::keyboard::key::Named;
29use iced::widget::{self, Space, column, container, mouse_area, responsive, row};
30use iced::{
31    Alignment, Background, Border, Color, Element, Length, Padding, Rectangle, Renderer, Size,
32    Task, Theme, Vector, mouse,
33};
34
35use crate::data::ellipsis::ellipsis;
36use crate::data::interaction::{
37    Look, RowMenu, match_count_label, pressable, ringed, row_menu, scope, search_count, search_keys,
38};
39use crate::data::rows::rows;
40use crate::forms::select::select;
41use crate::icon::{opacity, themed};
42use crate::keys::{self, Chord, Keymap};
43use crate::overlay::context_menu;
44use crate::overlay::dropdown_menu::item;
45use crate::overlay::tooltip::tooltip;
46use crate::primitives::button;
47use crate::primitives::checkbox::checkbox;
48use crate::primitives::icon_button::icon_button;
49use crate::primitives::input::{self, input};
50use crate::primitives::switch::switch;
51use crate::theme::{self, Tokens, fade, mix, radius, space, text_size};
52
53/// Height of every row, which fits a select.
54pub const ROW_HEIGHT: f32 = 36.0;
55/// The widest the name column starts out, before the user drags it.
56pub const NAME_WIDTH: f32 = 160.0;
57/// The narrowest the name column gets.
58pub const MIN_NAME_WIDTH: f32 = 64.0;
59/// The room the value column keeps when the name column is dragged wide.
60pub const MIN_VALUE_WIDTH: f32 = 88.0;
61/// The room the value column keeps before the name column, at its default
62/// width, starts to shrink: enough for a colour swatch and its hex.
63pub const VALUE_WIDTH: f32 = 112.0;
64/// Width of the draggable line between the columns.
65pub const HANDLE_WIDTH: f32 = 9.0;
66/// Width of the slot at the end of a row for the reset button.
67pub const RESET_WIDTH: f32 = 24.0;
68/// The least height of the description area under the rows, which grows
69/// to fit a longer description.
70pub const DESCRIPTION_HEIGHT: f32 = 64.0;
71/// The widget id of the filter box, which [`focus_search`] focuses.
72pub const SEARCH: widget::Id = widget::Id::new("iced-cube-property-grid-filter");
73/// The widget id of the field being edited, to find it in tests.
74pub const EDITOR_ID: widget::Id = widget::Id::new("iced-cube-property-grid-editor");
75
76/// Shown in the description area while nothing is highlighted.
77pub const NOTHING_SELECTED: &str = "Select a property to see its description.";
78
79/// Width of the slot before a name that marks a modified property.
80const MARK_WIDTH: f32 = 14.0;
81const MARK_SIZE: f32 = 6.0;
82const SWATCH_SIZE: f32 = 16.0;
83const ICON_SIZE: f32 = 16.0;
84const RESET_ICON_SIZE: f32 = 14.0;
85
86/// A property's value.
87#[derive(Debug, Clone, PartialEq)]
88pub enum Value {
89    /// Text, a read-only value or the chosen option of a choice.
90    Text(String),
91    Number(f64),
92    Bool(bool),
93    Colour(Color),
94}
95
96impl Value {
97    pub fn as_text(&self) -> Option<&str> {
98        match self {
99            Value::Text(value) => Some(value),
100            _ => None,
101        }
102    }
103
104    pub fn as_number(&self) -> Option<f64> {
105        match self {
106            Value::Number(value) => Some(*value),
107            _ => None,
108        }
109    }
110
111    pub fn as_bool(&self) -> Option<bool> {
112        match self {
113            Value::Bool(value) => Some(*value),
114            _ => None,
115        }
116    }
117
118    pub fn as_colour(&self) -> Option<Color> {
119        match self {
120            Value::Colour(value) => Some(*value),
121            _ => None,
122        }
123    }
124}
125
126impl From<&str> for Value {
127    fn from(value: &str) -> Self {
128        Value::Text(value.to_owned())
129    }
130}
131
132impl From<String> for Value {
133    fn from(value: String) -> Self {
134        Value::Text(value)
135    }
136}
137
138impl From<f64> for Value {
139    fn from(value: f64) -> Self {
140        Value::Number(value)
141    }
142}
143
144impl From<f32> for Value {
145    fn from(value: f32) -> Self {
146        Value::Number(f64::from(value))
147    }
148}
149
150impl From<i32> for Value {
151    fn from(value: i32) -> Self {
152        Value::Number(f64::from(value))
153    }
154}
155
156impl From<bool> for Value {
157    fn from(value: bool) -> Self {
158        Value::Bool(value)
159    }
160}
161
162impl From<Color> for Value {
163    fn from(value: Color) -> Self {
164        Value::Colour(value)
165    }
166}
167
168/// The limits and look of a number property.
169#[derive(Debug, Clone, PartialEq, Default)]
170pub struct Number {
171    pub min: Option<f64>,
172    pub max: Option<f64>,
173    /// How far Up and Down move the value while it is edited. A whole
174    /// step also makes values between steps invalid.
175    pub step: Option<f64>,
176    /// Decimal places shown. Defaults to those of the step, or as many as
177    /// the value needs.
178    pub decimals: Option<usize>,
179    /// Shown after the value, such as `px` or `%`, and accepted when typed.
180    pub unit: Option<String>,
181}
182
183impl Number {
184    /// The decimal places values are shown with, if fixed.
185    pub fn precision(&self) -> Option<usize> {
186        self.decimals.or_else(|| self.step.map(decimals_of))
187    }
188
189    /// The value as text, without the unit.
190    pub fn format(&self, value: f64) -> String {
191        let value = if value == 0.0 { 0.0 } else { value };
192        match self.precision() {
193            Some(decimals) => format!("{value:.decimals$}"),
194            None => format!("{value}"),
195        }
196    }
197
198    /// The value as text, with the unit.
199    pub fn display(&self, value: f64) -> String {
200        match &self.unit {
201            Some(unit) => format!("{}{unit}", self.format(value)),
202            None => self.format(value),
203        }
204    }
205
206    /// Reads typed text, with or without the unit, and checks it.
207    pub fn parse(&self, input: &str) -> Result<f64, String> {
208        let mut input = input.trim();
209        if let Some(unit) = self.unit.as_deref().filter(|unit| !unit.is_empty()) {
210            input = input.strip_suffix(unit).unwrap_or(input).trim_end();
211        }
212        let value: f64 = input.parse().map_err(|_| "Enter a number".to_owned())?;
213        if !value.is_finite() {
214            return Err("Enter a number".to_owned());
215        }
216        self.check(value)
217    }
218
219    /// Checks a value against the limits and a whole step.
220    pub fn check(&self, value: f64) -> Result<f64, String> {
221        if let Some(min) = self.min
222            && value < min
223        {
224            return Err(format!("Must be at least {}", self.display(min)));
225        }
226        if let Some(max) = self.max
227            && value > max
228        {
229            return Err(format!("Must be at most {}", self.display(max)));
230        }
231        let Some(step) = self.step.filter(|step| *step > 0.0 && step.fract() == 0.0) else {
232            return Ok(value);
233        };
234        let steps = (value - self.min.unwrap_or(0.0)) / step;
235        if (steps - steps.round()).abs() > 1e-9 {
236            if step == 1.0 {
237                return Err("Must be a whole number".to_owned());
238            }
239            return Err(format!("Must be a multiple of {}", self.format(step)));
240        }
241        Ok(value)
242    }
243
244    /// The value one step up or down, kept within the limits.
245    pub fn stepped(&self, value: f64, up: bool) -> f64 {
246        let step = self.step.filter(|step| *step > 0.0).unwrap_or(1.0);
247        let next = if up { value + step } else { value - step };
248        let next = match self.precision() {
249            Some(decimals) => {
250                let scale = 10f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX).min(15));
251                (next * scale).round() / scale
252            }
253            None => next,
254        };
255        let next = self.min.map_or(next, |min| next.max(min));
256        self.max.map_or(next, |max| next.min(max))
257    }
258}
259
260/// Decimal places in a step such as 0.25.
261fn decimals_of(step: f64) -> usize {
262    let text = format!("{step}");
263    text.split_once('.')
264        .map_or(0, |(_, fraction)| fraction.len())
265}
266
267/// A colour as `#RRGGBB`, or `#RRGGBBAA` when it is not opaque.
268pub fn hex(colour: Color) -> String {
269    let [r, g, b, a] = colour.into_rgba8();
270    if a == u8::MAX {
271        format!("#{r:02X}{g:02X}{b:02X}")
272    } else {
273        format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
274    }
275}
276
277/// Reads a hex colour: `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA`, with
278/// or without the `#`.
279pub fn parse_hex(input: &str) -> Result<Color, String> {
280    let error = || "Enter a hex colour, such as #3B82F6".to_owned();
281    let input = input.trim();
282    let digits = input.strip_prefix('#').unwrap_or(input);
283    if !digits.chars().all(|digit| digit.is_ascii_hexdigit()) {
284        return Err(error());
285    }
286    let digits: String = match digits.len() {
287        3 | 4 => digits.chars().flat_map(|digit| [digit, digit]).collect(),
288        6 | 8 => digits.to_owned(),
289        _ => return Err(error()),
290    };
291    let bytes = (0..digits.len())
292        .step_by(2)
293        .map(|start| {
294            digits
295                .get(start..start + 2)
296                .and_then(|pair| u8::from_str_radix(pair, 16).ok())
297        })
298        .collect::<Option<Vec<u8>>>()
299        .ok_or_else(error)?;
300    match bytes.as_slice() {
301        [r, g, b] => Ok(Color::from_rgb8(*r, *g, *b)),
302        [r, g, b, a] => Ok(Color::from_rgba8(*r, *g, *b, f32::from(*a) / 255.0)),
303        _ => Err(error()),
304    }
305}
306
307/// Checks text typed into a text property, returning the error to show.
308pub type Validate = fn(&str) -> Result<(), String>;
309
310/// How a true or false property is shown.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
312pub enum Toggle {
313    #[default]
314    Checkbox,
315    Switch,
316}
317
318impl Toggle {
319    pub const ALL: [Toggle; 2] = [Toggle::Checkbox, Toggle::Switch];
320}
321
322/// What a property holds and how it is edited.
323#[derive(Debug, Clone)]
324pub enum Kind {
325    /// Free text, with an optional check.
326    Text(Option<Validate>),
327    Number(Number),
328    Toggle(Toggle),
329    /// One of a list of options, chosen with a select.
330    Choice(Vec<String>),
331    /// A colour, shown as a swatch and edited as hex.
332    Colour,
333    /// Text that cannot be edited.
334    ReadOnly,
335    /// Drawn by the app with [`PropertyGrid::custom`].
336    Custom,
337}
338
339/// One named property.
340#[derive(Debug, Clone)]
341pub struct Property<Id> {
342    pub id: Id,
343    pub name: String,
344    /// The group it is listed under while categorised.
345    pub category: Option<String>,
346    /// Shown under the rows while the property is highlighted.
347    pub description: Option<String>,
348    pub kind: Kind,
349    pub value: Value,
350    /// The value a reset goes back to. A property without one has no reset.
351    pub default: Option<Value>,
352    pub disabled: bool,
353}
354
355fn property<Id>(id: Id, name: impl Into<String>, kind: Kind, value: Value) -> Property<Id> {
356    Property {
357        id,
358        name: name.into(),
359        category: None,
360        description: None,
361        kind,
362        value,
363        default: None,
364        disabled: false,
365    }
366}
367
368/// A text property.
369pub fn text<Id>(id: Id, name: impl Into<String>, value: impl Into<String>) -> Property<Id> {
370    property(id, name, Kind::Text(None), Value::Text(value.into()))
371}
372
373/// A number property. Set its limits with [`min`](Property::min),
374/// [`max`](Property::max), [`step`](Property::step) and its look with
375/// [`decimals`](Property::decimals) and [`unit`](Property::unit).
376pub fn number<Id>(id: Id, name: impl Into<String>, value: f64) -> Property<Id> {
377    property(
378        id,
379        name,
380        Kind::Number(Number::default()),
381        Value::Number(value),
382    )
383}
384
385/// A true or false property, shown as a checkbox, or as a switch with
386/// [`switch`](Property::switch).
387pub fn toggle<Id>(id: Id, name: impl Into<String>, value: bool) -> Property<Id> {
388    property(id, name, Kind::Toggle(Toggle::Checkbox), Value::Bool(value))
389}
390
391/// A property chosen from `options` with a select.
392pub fn choice<Id>(
393    id: Id,
394    name: impl Into<String>,
395    options: impl IntoIterator<Item = impl Into<String>>,
396    selected: impl Into<String>,
397) -> Property<Id> {
398    property(
399        id,
400        name,
401        Kind::Choice(options.into_iter().map(Into::into).collect()),
402        Value::Text(selected.into()),
403    )
404}
405
406/// A colour property, shown as a swatch and edited as hex.
407pub fn colour<Id>(id: Id, name: impl Into<String>, value: Color) -> Property<Id> {
408    property(id, name, Kind::Colour, Value::Colour(value))
409}
410
411/// Text that cannot be edited, such as an id or a computed size.
412pub fn read_only<Id>(id: Id, name: impl Into<String>, value: impl Into<String>) -> Property<Id> {
413    property(id, name, Kind::ReadOnly, Value::Text(value.into()))
414}
415
416/// A property the app draws itself with [`PropertyGrid::custom`]. Without
417/// a renderer its value is shown as text.
418pub fn custom<Id>(id: Id, name: impl Into<String>, value: impl Into<Value>) -> Property<Id> {
419    property(id, name, Kind::Custom, value.into())
420}
421
422impl<Id> Property<Id> {
423    pub fn category(mut self, category: impl Into<String>) -> Self {
424        self.category = Some(category.into());
425        self
426    }
427
428    pub fn description(mut self, description: impl Into<String>) -> Self {
429        self.description = Some(description.into());
430        self
431    }
432
433    /// The value a reset goes back to. The name shows as modified while
434    /// the value differs from it.
435    pub fn default(mut self, value: impl Into<Value>) -> Self {
436        self.default = Some(value.into());
437        self
438    }
439
440    /// A disabled property shows its value but cannot be edited or reset.
441    pub fn disabled(mut self, disabled: bool) -> Self {
442        self.disabled = disabled;
443        self
444    }
445
446    /// The smallest value of a number property.
447    pub fn min(mut self, min: f64) -> Self {
448        if let Kind::Number(number) = &mut self.kind {
449            number.min = Some(min);
450        }
451        self
452    }
453
454    /// The largest value of a number property.
455    pub fn max(mut self, max: f64) -> Self {
456        if let Kind::Number(number) = &mut self.kind {
457            number.max = Some(max);
458        }
459        self
460    }
461
462    pub fn range(self, min: f64, max: f64) -> Self {
463        self.min(min).max(max)
464    }
465
466    /// How far Up and Down move a number while it is edited. A whole step
467    /// also makes values between steps invalid.
468    pub fn step(mut self, step: f64) -> Self {
469        if let Kind::Number(number) = &mut self.kind {
470            number.step = Some(step);
471        }
472        self
473    }
474
475    /// Decimal places a number is shown with.
476    pub fn decimals(mut self, decimals: usize) -> Self {
477        if let Kind::Number(number) = &mut self.kind {
478            number.decimals = Some(decimals);
479        }
480        self
481    }
482
483    /// Shown after a number, such as `px`, and accepted when typed.
484    pub fn unit(mut self, unit: impl Into<String>) -> Self {
485        if let Kind::Number(number) = &mut self.kind {
486            number.unit = Some(unit.into());
487        }
488        self
489    }
490
491    /// Shows a true or false property as a switch.
492    pub fn switch(mut self) -> Self {
493        if let Kind::Toggle(toggle) = &mut self.kind {
494            *toggle = Toggle::Switch;
495        }
496        self
497    }
498
499    /// Checks what is typed into a text property.
500    pub fn validate(mut self, validate: Validate) -> Self {
501        if let Kind::Text(check) = &mut self.kind {
502            *check = Some(validate);
503        }
504        self
505    }
506
507    /// Whether the value differs from the default.
508    pub fn is_modified(&self) -> bool {
509        self.default
510            .as_ref()
511            .is_some_and(|default| *default != self.value)
512    }
513
514    /// Whether the value is edited by typing: text, numbers and colours.
515    pub fn is_typed(&self) -> bool {
516        matches!(self.kind, Kind::Text(_) | Kind::Number(_) | Kind::Colour)
517    }
518
519    /// Whether the user can change the value from the grid.
520    pub fn is_editable(&self) -> bool {
521        !self.disabled && !matches!(self.kind, Kind::ReadOnly | Kind::Custom)
522    }
523
524    /// The value as shown in its row.
525    pub fn display(&self) -> String {
526        match (&self.kind, &self.value) {
527            (Kind::Number(number), Value::Number(value)) => number.display(*value),
528            _ => display(&self.value),
529        }
530    }
531
532    /// The value as text to start an edit with.
533    pub fn edit_text(&self) -> String {
534        match (&self.kind, &self.value) {
535            (Kind::Number(number), Value::Number(value)) => number.format(*value),
536            _ => display(&self.value),
537        }
538    }
539
540    /// Reads typed text into a value of this property's kind.
541    pub fn parse(&self, input: &str) -> Result<Value, String> {
542        match &self.kind {
543            Kind::Number(number) => number.parse(input).map(Value::Number),
544            Kind::Colour => parse_hex(input).map(Value::Colour),
545            Kind::Text(Some(validate)) => validate(input).map(|()| Value::Text(input.to_owned())),
546            Kind::Choice(options) if !options.iter().any(|option| option == input) => {
547                Err("Choose one of the options".to_owned())
548            }
549            _ => Ok(Value::Text(input.to_owned())),
550        }
551    }
552
553    /// Whether `value` suits this property, such as an option of a choice.
554    pub fn accepts(&self, value: &Value) -> bool {
555        match (&self.kind, value) {
556            (Kind::Text(validate), Value::Text(text)) => {
557                validate.is_none_or(|validate| validate(text).is_ok())
558            }
559            (Kind::Number(number), Value::Number(value)) => number.check(*value).is_ok(),
560            (Kind::Toggle(_), Value::Bool(_)) | (Kind::Colour, Value::Colour(_)) => true,
561            (Kind::Choice(options), Value::Text(text)) => options.contains(text),
562            (Kind::ReadOnly, Value::Text(_)) | (Kind::Custom, _) => true,
563            _ => false,
564        }
565    }
566}
567
568fn display(value: &Value) -> String {
569    match value {
570        Value::Text(text) => text.clone(),
571        Value::Number(number) => Number::default().format(*number),
572        Value::Bool(true) => "On".to_owned(),
573        Value::Bool(false) => "Off".to_owned(),
574        Value::Colour(colour) => hex(*colour),
575    }
576}
577
578/// How the properties are listed.
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
580pub enum Sort {
581    /// Under their categories, in the order they were given. Properties
582    /// without a category come first.
583    #[default]
584    Categorised,
585    /// One list, by name.
586    Alphabetical,
587}
588
589impl Sort {
590    pub const ALL: [Sort; 2] = [Sort::Categorised, Sort::Alphabetical];
591}
592
593/// One row of the grid.
594#[derive(Debug, Clone, PartialEq, Eq, Hash)]
595pub enum Entry<Id> {
596    /// A category heading, by name.
597    Category(String),
598    Property(Id),
599}
600
601/// Text being typed into a property, and why it is not valid yet.
602#[derive(Debug, Clone, PartialEq, Eq, Default)]
603pub struct Draft {
604    pub text: String,
605    pub error: Option<String>,
606}
607
608/// Which field the grid wants focused next.
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
610enum Target {
611    Filter,
612    Editor,
613}
614
615#[derive(Debug, Clone, PartialEq)]
616struct Editing<Id> {
617    id: Id,
618    /// The value before the edit, which a cancel puts back.
619    original: Value,
620}
621
622/// Everything that changes a property grid.
623#[derive(Debug, Clone, PartialEq)]
624pub enum Event<Id> {
625    /// The pointer pressed a row. A category heading also opens or closes.
626    Press(Entry<Id>),
627    ToggleCategory(String),
628    Expand(String),
629    Collapse(String),
630    Next,
631    Previous,
632    First,
633    Last,
634    /// Opens the highlighted category, or moves into it when it is open.
635    ExpandOrEnter,
636    /// Closes the highlighted category, or moves from a property to its
637    /// category.
638    CollapseOrLeave,
639    /// Edits a property: starts typing into text, numbers and colours,
640    /// flips a toggle and moves a choice to its next option.
641    Edit(Id),
642    /// Edits the highlighted property, or opens or closes the highlighted
643    /// category.
644    EditHighlighted,
645    /// Text typed into a property's field.
646    Input(Id, String),
647    /// Enter in the field being edited. Ends the edit unless it is invalid.
648    Submit,
649    /// Ends the edit and puts back the value from before it. Without an
650    /// edit, clears the filter.
651    Cancel,
652    /// Steps the number being edited up.
653    Increment,
654    /// Steps the number being edited down.
655    Decrement,
656    /// A new value from a checkbox, switch or select.
657    Set(Id, Value),
658    /// Puts a property back to its default.
659    Reset(Id),
660    ResetHighlighted,
661    /// The search text, which filters properties by name, changed.
662    Search(String),
663    /// Highlights the next property the filter shows, wrapping at the end.
664    NextMatch,
665    /// Highlights the previous property the filter shows, wrapping at the
666    /// start.
667    PreviousMatch,
668    /// Moves keyboard focus to the filter box.
669    FocusSearch,
670    /// Asks the app to copy a property's value, as it is shown.
671    CopyValue(Id),
672    Sort(Sort),
673    /// The pointer pressed the line between the columns.
674    ResizeStart,
675    /// The pointer dragged the line: the name column's new width.
676    Resize(f32),
677    ResizeEnd,
678    /// The grid gained or lost focus. The widget sends this itself.
679    Focus(bool),
680}
681
682/// What the app may need to act on after an [`Event`].
683#[derive(Debug, Clone, PartialEq)]
684pub enum Output<Id> {
685    /// A property has a new value.
686    Changed { id: Id, value: Value },
687    /// A property went back to its default, which is `value`.
688    Reset { id: Id, value: Value },
689    /// A value as it is shown, such as `120px` or `#3B82F6`, to write to
690    /// the clipboard.
691    CopyValue(String),
692}
693
694/// The properties, their values, and what is open, highlighted, filtered
695/// and being edited.
696#[derive(Debug, Clone)]
697pub struct State<Id> {
698    properties: Vec<Property<Id>>,
699    index: HashMap<Id, usize>,
700    categories: Vec<String>,
701    collapsed: HashSet<String>,
702    sort: Sort,
703    query: String,
704    highlighted: Option<Entry<Id>>,
705    editing: Option<Editing<Id>>,
706    drafts: HashMap<Id, Draft>,
707    name_width: Option<f32>,
708    resizing: bool,
709    focused: bool,
710    /// Counts focus requests, so the widget acts on each one once.
711    focus: (u64, Option<Target>),
712}
713
714impl<Id: Copy + Eq + Hash> State<Id> {
715    /// Holds `properties`, categorised with every category open. Ids must
716    /// be unique; a later property with the same id replaces the earlier.
717    pub fn new(properties: impl IntoIterator<Item = Property<Id>>) -> Self {
718        let mut list: Vec<Property<Id>> = Vec::new();
719        let mut index = HashMap::new();
720        for property in properties {
721            match index.get(&property.id) {
722                Some(&position) => {
723                    if let Some(slot) = list.get_mut(position) {
724                        *slot = property;
725                    }
726                }
727                None => {
728                    let _ = index.insert(property.id, list.len());
729                    list.push(property);
730                }
731            }
732        }
733        let mut categories: Vec<String> = Vec::new();
734        for category in list
735            .iter()
736            .filter_map(|property| property.category.as_ref())
737        {
738            if !categories.contains(category) {
739                categories.push(category.clone());
740            }
741        }
742        Self {
743            properties: list,
744            index,
745            categories,
746            collapsed: HashSet::new(),
747            sort: Sort::default(),
748            query: String::new(),
749            highlighted: None,
750            editing: None,
751            drafts: HashMap::new(),
752            name_width: None,
753            resizing: false,
754            focused: false,
755            focus: (0, None),
756        }
757    }
758
759    pub fn with_sort(mut self, sort: Sort) -> Self {
760        self.sort = sort;
761        self
762    }
763
764    /// Starts with these categories closed.
765    pub fn with_collapsed(
766        mut self,
767        categories: impl IntoIterator<Item = impl Into<String>>,
768    ) -> Self {
769        self.collapsed
770            .extend(categories.into_iter().map(Into::into));
771        self
772    }
773
774    /// Starts the name column at `width` pixels rather than
775    /// [`NAME_WIDTH`], which a narrow grid shrinks.
776    pub fn with_name_width(mut self, width: f32) -> Self {
777        self.name_width = Some(width.max(MIN_NAME_WIDTH));
778        self
779    }
780
781    /// Every property, in the order given.
782    pub fn properties(&self) -> &[Property<Id>] {
783        &self.properties
784    }
785
786    pub fn property(&self, id: Id) -> Option<&Property<Id>> {
787        self.index
788            .get(&id)
789            .and_then(|&position| self.properties.get(position))
790    }
791
792    fn property_mut(&mut self, id: Id) -> Option<&mut Property<Id>> {
793        self.index
794            .get(&id)
795            .and_then(|&position| self.properties.get_mut(position))
796    }
797
798    pub fn value(&self, id: Id) -> Option<&Value> {
799        self.property(id).map(|property| &property.value)
800    }
801
802    /// Sets a value from the app, such as after an undo or from a custom
803    /// editor, dropping any draft or edit of it. Returns whether it changed.
804    pub fn set(&mut self, id: Id, value: impl Into<Value>) -> bool {
805        let value = value.into();
806        let _ = self.drafts.remove(&id);
807        if self.editing_id() == Some(id) {
808            self.editing = None;
809        }
810        let Some(property) = self.property_mut(id) else {
811            return false;
812        };
813        if property.value == value {
814            return false;
815        }
816        property.value = value;
817        true
818    }
819
820    /// Category names, in the order they first appear.
821    pub fn categories(&self) -> &[String] {
822        &self.categories
823    }
824
825    pub fn is_expanded(&self, category: &str) -> bool {
826        !self.collapsed.contains(category)
827    }
828
829    pub fn sort(&self) -> Sort {
830        self.sort
831    }
832
833    /// The search text, which filters properties by name.
834    pub fn query(&self) -> &str {
835        &self.query
836    }
837
838    /// The row the keyboard acts on, which the description area describes.
839    pub fn highlighted(&self) -> Option<&Entry<Id>> {
840        self.highlighted.as_ref()
841    }
842
843    /// The highlighted property, if a property rather than a category is
844    /// highlighted.
845    pub fn highlighted_property(&self) -> Option<&Property<Id>> {
846        match self.highlighted {
847            Some(Entry::Property(id)) => self.property(id),
848            _ => None,
849        }
850    }
851
852    /// The property being typed into.
853    pub fn editing(&self) -> Option<Id> {
854        self.editing_id()
855    }
856
857    fn editing_id(&self) -> Option<Id> {
858        self.editing.as_ref().map(|editing| editing.id)
859    }
860
861    /// Text typed into a property that is being edited, or that was left
862    /// while invalid.
863    pub fn draft(&self, id: Id) -> Option<&Draft> {
864        self.drafts.get(&id)
865    }
866
867    /// Why a property's draft is not valid.
868    pub fn error(&self, id: Id) -> Option<&str> {
869        self.drafts
870            .get(&id)
871            .and_then(|draft| draft.error.as_deref())
872    }
873
874    /// Whether resetting the property would change anything: it has a
875    /// default, is enabled, and differs from the default or holds a draft.
876    pub fn can_reset(&self, id: Id) -> bool {
877        self.property(id).is_some_and(|property| {
878            !property.disabled
879                && property.default.is_some()
880                && (property.is_modified() || self.draft(id).is_some())
881        })
882    }
883
884    pub fn is_focused(&self) -> bool {
885        self.focused
886    }
887
888    /// Whether the line between the columns is being dragged.
889    pub fn is_resizing(&self) -> bool {
890        self.resizing
891    }
892
893    /// The name column's width once set or dragged.
894    pub fn name_width(&self) -> Option<f32> {
895        self.name_width
896    }
897
898    /// Whether a property passes the filter.
899    pub fn matches(&self, id: Id) -> bool {
900        self.property(id)
901            .is_some_and(|property| self.passes(property))
902    }
903
904    fn passes(&self, property: &Property<Id>) -> bool {
905        let query = self.query.trim();
906        query.is_empty() || property.name.to_lowercase().contains(&query.to_lowercase())
907    }
908
909    /// How many properties in a category pass the filter.
910    pub fn count(&self, category: &str) -> usize {
911        self.properties
912            .iter()
913            .filter(|property| property.category.as_deref() == Some(category))
914            .filter(|property| self.passes(property))
915            .count()
916    }
917
918    /// The rows on screen, in order: properties that pass the filter, under
919    /// their categories while categorised.
920    pub fn entries(&self) -> Vec<Entry<Id>> {
921        let passing = self
922            .properties
923            .iter()
924            .filter(|property| self.passes(property));
925        if self.sort == Sort::Alphabetical {
926            let mut sorted: Vec<&Property<Id>> = passing.collect();
927            sorted.sort_by_cached_key(|property| property.name.to_lowercase());
928            return sorted
929                .into_iter()
930                .map(|property| Entry::Property(property.id))
931                .collect();
932        }
933        let mut entries: Vec<Entry<Id>> = passing
934            .clone()
935            .filter(|property| property.category.is_none())
936            .map(|property| Entry::Property(property.id))
937            .collect();
938        for category in &self.categories {
939            let members: Vec<Id> = passing
940                .clone()
941                .filter(|property| property.category.as_ref() == Some(category))
942                .map(|property| property.id)
943                .collect();
944            if members.is_empty() {
945                continue;
946            }
947            entries.push(Entry::Category(category.clone()));
948            if self.is_expanded(category) {
949                entries.extend(members.into_iter().map(Entry::Property));
950            }
951        }
952        entries
953    }
954
955    /// Applies an event and returns what the app may need to act on.
956    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
957        match event {
958            Event::Press(entry) => {
959                let same = matches!(&entry, Entry::Property(id) if self.editing_id() == Some(*id));
960                if !same {
961                    self.finish();
962                }
963                if let Entry::Category(name) = &entry {
964                    self.toggle(name);
965                }
966                self.highlighted = Some(entry);
967            }
968            Event::ToggleCategory(name) => self.toggle(&name),
969            Event::Expand(name) => {
970                let _ = self.collapsed.remove(&name);
971            }
972            Event::Collapse(name) => self.collapse(&name),
973            Event::Next => self.step(Step::Next),
974            Event::Previous => self.step(Step::Previous),
975            Event::First => self.step(Step::First),
976            Event::Last => self.step(Step::Last),
977            Event::ExpandOrEnter => self.expand_or_enter(),
978            Event::CollapseOrLeave => self.collapse_or_leave(),
979            Event::Edit(id) => return self.edit(id),
980            Event::EditHighlighted => match self.highlighted.clone() {
981                Some(Entry::Property(id)) => return self.edit(id),
982                Some(Entry::Category(name)) => self.toggle(&name),
983                None => {}
984            },
985            Event::Input(id, input) => return self.input(id, input),
986            Event::Submit => self.submit(),
987            Event::Cancel => return self.cancel(),
988            Event::Increment => return self.nudge(true),
989            Event::Decrement => return self.nudge(false),
990            Event::Set(id, value) => {
991                if self.editing_id() != Some(id) {
992                    self.finish();
993                }
994                return self.choose(id, value);
995            }
996            Event::Reset(id) => return self.reset(id),
997            Event::ResetHighlighted => {
998                if let Some(Entry::Property(id)) = self.highlighted {
999                    return self.reset(id);
1000                }
1001            }
1002            Event::Search(query) => {
1003                self.finish();
1004                self.query = query;
1005                self.highlight_nearest_shown();
1006            }
1007            Event::NextMatch => self.step_match(true),
1008            Event::PreviousMatch => self.step_match(false),
1009            Event::FocusSearch => {
1010                self.finish();
1011                self.request(Target::Filter);
1012            }
1013            Event::CopyValue(id) => {
1014                let value = self.property(id)?.display();
1015                if self.editing_id() != Some(id) {
1016                    self.finish();
1017                }
1018                self.highlighted = Some(Entry::Property(id));
1019                return Some(Output::CopyValue(value));
1020            }
1021            Event::Sort(sort) => {
1022                self.finish();
1023                self.sort = sort;
1024                self.keep_highlight_visible();
1025            }
1026            Event::ResizeStart => self.resizing = true,
1027            Event::Resize(width) => {
1028                if self.resizing {
1029                    self.name_width = Some(width.max(MIN_NAME_WIDTH));
1030                }
1031            }
1032            Event::ResizeEnd => self.resizing = false,
1033            Event::Focus(focused) => {
1034                self.focused = focused;
1035                if !focused {
1036                    self.finish();
1037                    self.resizing = false;
1038                    self.focus.1 = None;
1039                } else if self.highlighted.is_none() {
1040                    self.highlighted = self.first_property();
1041                }
1042            }
1043        }
1044        None
1045    }
1046
1047    /// Turns a key press into an event through `keymap`.
1048    pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
1049        keymap
1050            .resolve_event(key)
1051            .and_then(|action| action.event(self))
1052    }
1053
1054    /// The focus request the widget has not acted on yet, by count.
1055    fn focus_request(&self) -> Option<(u64, Target)> {
1056        let (count, target) = self.focus;
1057        target.map(|target| (count, target))
1058    }
1059
1060    fn request(&mut self, target: Target) {
1061        self.focus = (self.focus.0.wrapping_add(1), Some(target));
1062    }
1063
1064    fn first_property(&self) -> Option<Entry<Id>> {
1065        let entries = self.entries();
1066        entries
1067            .iter()
1068            .find(|entry| matches!(entry, Entry::Property(_)))
1069            .or(entries.first())
1070            .cloned()
1071    }
1072
1073    /// Every property in the order the grid lists them, shown or not.
1074    fn display_order(&self) -> Vec<Id> {
1075        if self.sort == Sort::Alphabetical {
1076            let mut sorted: Vec<&Property<Id>> = self.properties.iter().collect();
1077            sorted.sort_by_cached_key(|property| property.name.to_lowercase());
1078            return sorted.into_iter().map(|property| property.id).collect();
1079        }
1080        let mut order: Vec<Id> = Vec::with_capacity(self.properties.len());
1081        for category in std::iter::once(None).chain(self.categories.iter().map(Some)) {
1082            order.extend(
1083                self.properties
1084                    .iter()
1085                    .filter(|property| property.category.as_ref() == category)
1086                    .map(|property| property.id),
1087            );
1088        }
1089        order
1090    }
1091
1092    /// After the search changed, keeps the highlight on a shown row: the
1093    /// highlighted property if it still passes, or else the nearest one
1094    /// shown at or after it, or else the last shown before it.
1095    fn highlight_nearest_shown(&mut self) {
1096        let shown = self.entries();
1097        if self.highlighted.is_none() && !self.query.trim().is_empty() {
1098            self.highlighted = self.first_property();
1099            return;
1100        }
1101        let Some(highlighted) = self.highlighted.clone() else {
1102            return;
1103        };
1104        if shown.contains(&highlighted) {
1105            return;
1106        }
1107        let order = self.display_order();
1108        let start = match &highlighted {
1109            Entry::Property(id) => order.iter().position(|other| other == id),
1110            Entry::Category(name) => order.iter().position(|&id| {
1111                self.property(id)
1112                    .is_some_and(|property| property.category.as_ref() == Some(name))
1113            }),
1114        }
1115        .unwrap_or(0);
1116        let (before, after) = order.split_at(start.min(order.len()));
1117        let is_shown = |id: &&Id| shown.contains(&Entry::Property(**id));
1118        let nearest = after
1119            .iter()
1120            .find(is_shown)
1121            .or_else(|| before.iter().rev().find(is_shown));
1122        self.highlighted = match nearest {
1123            Some(&id) => Some(Entry::Property(id)),
1124            None => self.first_property(),
1125        };
1126    }
1127
1128    fn keep_highlight_visible(&mut self) {
1129        let Some(highlighted) = &self.highlighted else {
1130            return;
1131        };
1132        if !self.entries().contains(highlighted) {
1133            self.highlighted = self.first_property();
1134        }
1135    }
1136
1137    fn toggle(&mut self, name: &str) {
1138        if self.collapsed.contains(name) {
1139            let _ = self.collapsed.remove(name);
1140        } else {
1141            self.collapse(name);
1142        }
1143    }
1144
1145    fn collapse(&mut self, name: &str) {
1146        if !self.categories.iter().any(|category| category == name) {
1147            return;
1148        }
1149        let _ = self.collapsed.insert(name.to_owned());
1150        if self.sort == Sort::Alphabetical {
1151            return;
1152        }
1153        let inside = |id: Option<Id>| {
1154            id.and_then(|id| self.property(id))
1155                .is_some_and(|property| property.category.as_deref() == Some(name))
1156        };
1157        let highlighted = match self.highlighted {
1158            Some(Entry::Property(id)) => Some(id),
1159            _ => None,
1160        };
1161        let hides_highlight = inside(highlighted);
1162        let hides_edit = inside(self.editing_id());
1163        if hides_highlight {
1164            self.highlighted = Some(Entry::Category(name.to_owned()));
1165        }
1166        if hides_edit {
1167            self.finish();
1168        }
1169    }
1170
1171    fn step(&mut self, step: Step) {
1172        self.finish();
1173        let entries = self.entries();
1174        let current = self
1175            .highlighted
1176            .as_ref()
1177            .and_then(|highlighted| entries.iter().position(|entry| entry == highlighted));
1178        let target = match (step, current) {
1179            (Step::First, _) | (Step::Next, None) => entries.first(),
1180            (Step::Last, _) | (Step::Previous, None) => entries.last(),
1181            (Step::Next, Some(index)) => entries.get(index + 1).or(entries.get(index)),
1182            (Step::Previous, Some(index)) => entries.get(index.saturating_sub(1)),
1183        };
1184        if let Some(target) = target {
1185            self.highlighted = Some(target.clone());
1186        }
1187    }
1188
1189    /// The properties on screen in order, without category headings.
1190    fn shown_properties(&self) -> Vec<Entry<Id>> {
1191        self.entries()
1192            .into_iter()
1193            .filter(|entry| matches!(entry, Entry::Property(_)))
1194            .collect()
1195    }
1196
1197    /// Moves the highlight through the properties shown, skipping category
1198    /// headings and wrapping at either end.
1199    fn step_match(&mut self, forward: bool) {
1200        self.finish();
1201        let properties = self.shown_properties();
1202        let count = properties.len();
1203        if count == 0 {
1204            return;
1205        }
1206        let current = self
1207            .highlighted
1208            .as_ref()
1209            .and_then(|highlighted| properties.iter().position(|entry| entry == highlighted));
1210        let index = match (current, forward) {
1211            (None, true) => 0,
1212            (None, false) => count - 1,
1213            (Some(index), true) => (index + 1) % count,
1214            (Some(index), false) => (index + count - 1) % count,
1215        };
1216        self.highlighted = properties.get(index).cloned();
1217    }
1218
1219    fn expand_or_enter(&mut self) {
1220        let Some(Entry::Category(name)) = self.highlighted.clone() else {
1221            return;
1222        };
1223        if !self.is_expanded(&name) {
1224            let _ = self.collapsed.remove(&name);
1225            return;
1226        }
1227        let entries = self.entries();
1228        let first = entries
1229            .iter()
1230            .skip_while(|entry| **entry != Entry::Category(name.clone()))
1231            .nth(1)
1232            .filter(|entry| matches!(entry, Entry::Property(_)));
1233        if let Some(first) = first {
1234            self.highlighted = Some(first.clone());
1235        }
1236    }
1237
1238    fn collapse_or_leave(&mut self) {
1239        match self.highlighted.clone() {
1240            Some(Entry::Category(name)) => self.collapse(&name),
1241            Some(Entry::Property(id)) if self.sort == Sort::Categorised => {
1242                let category = self
1243                    .property(id)
1244                    .and_then(|property| property.category.clone());
1245                if let Some(category) = category {
1246                    self.finish();
1247                    self.highlighted = Some(Entry::Category(category));
1248                }
1249            }
1250            _ => {}
1251        }
1252    }
1253
1254    /// Ends the edit. A valid draft has already been applied and goes; an
1255    /// invalid one stays so the row keeps showing it and its error.
1256    fn finish(&mut self) {
1257        let Some(editing) = self.editing.take() else {
1258            return;
1259        };
1260        if self.error(editing.id).is_none() {
1261            let _ = self.drafts.remove(&editing.id);
1262        }
1263    }
1264
1265    /// Sets a value, reporting it when it changed.
1266    fn assign(&mut self, id: Id, value: Value) -> Option<Output<Id>> {
1267        let property = self.property_mut(id)?;
1268        if property.value == value {
1269            return None;
1270        }
1271        property.value = value.clone();
1272        Some(Output::Changed { id, value })
1273    }
1274
1275    fn edit(&mut self, id: Id) -> Option<Output<Id>> {
1276        let property = self.property(id)?;
1277        if property.disabled {
1278            self.highlighted = Some(Entry::Property(id));
1279            return None;
1280        }
1281        let kind = property.kind.clone();
1282        let value = property.value.clone();
1283        let typed = property.is_typed();
1284        let text = property.edit_text();
1285        if self.editing_id() != Some(id) {
1286            self.finish();
1287        }
1288        self.highlighted = Some(Entry::Property(id));
1289        match (kind, value) {
1290            (Kind::Toggle(_), Value::Bool(on)) => self.assign(id, Value::Bool(!on)),
1291            (Kind::Choice(options), Value::Text(current)) => {
1292                let next = options
1293                    .iter()
1294                    .position(|option| *option == current)
1295                    .map_or(0, |index| (index + 1) % options.len().max(1));
1296                let option = options.get(next)?.clone();
1297                self.assign(id, Value::Text(option))
1298            }
1299            (_, value) if typed => {
1300                if self.editing_id() != Some(id) {
1301                    let _ = self.drafts.entry(id).or_insert(Draft { text, error: None });
1302                    self.editing = Some(Editing {
1303                        id,
1304                        original: value,
1305                    });
1306                }
1307                self.request(Target::Editor);
1308                None
1309            }
1310            _ => None,
1311        }
1312    }
1313
1314    fn input(&mut self, id: Id, input: String) -> Option<Output<Id>> {
1315        let property = self.property(id).filter(|property| property.is_typed())?;
1316        if property.disabled {
1317            return None;
1318        }
1319        let parsed = property.parse(&input);
1320        let value = property.value.clone();
1321        if self.editing_id() != Some(id) {
1322            self.finish();
1323            self.editing = Some(Editing {
1324                id,
1325                original: value,
1326            });
1327            self.highlighted = Some(Entry::Property(id));
1328        }
1329        match parsed {
1330            Ok(value) => {
1331                let _ = self.drafts.insert(
1332                    id,
1333                    Draft {
1334                        text: input,
1335                        error: None,
1336                    },
1337                );
1338                self.assign(id, value)
1339            }
1340            Err(error) => {
1341                let _ = self.drafts.insert(
1342                    id,
1343                    Draft {
1344                        text: input,
1345                        error: Some(error),
1346                    },
1347                );
1348                None
1349            }
1350        }
1351    }
1352
1353    fn submit(&mut self) {
1354        let Some(id) = self.editing_id() else {
1355            return;
1356        };
1357        if self.error(id).is_some() {
1358            return;
1359        }
1360        let _ = self.drafts.remove(&id);
1361        self.editing = None;
1362    }
1363
1364    fn cancel(&mut self) -> Option<Output<Id>> {
1365        let Some(editing) = self.editing.take() else {
1366            self.query.clear();
1367            return None;
1368        };
1369        let _ = self.drafts.remove(&editing.id);
1370        self.assign(editing.id, editing.original)
1371    }
1372
1373    fn nudge(&mut self, up: bool) -> Option<Output<Id>> {
1374        let id = self.editing_id()?;
1375        let property = self.property(id)?;
1376        let (Kind::Number(number), Value::Number(current)) = (&property.kind, &property.value)
1377        else {
1378            return None;
1379        };
1380        let base = self
1381            .drafts
1382            .get(&id)
1383            .and_then(|draft| number.parse(&draft.text).ok())
1384            .unwrap_or(*current);
1385        let next = number.stepped(base, up);
1386        let text = number.format(next);
1387        let _ = self.drafts.insert(id, Draft { text, error: None });
1388        self.assign(id, Value::Number(next))
1389    }
1390
1391    fn choose(&mut self, id: Id, value: Value) -> Option<Output<Id>> {
1392        let property = self.property(id)?;
1393        if !property.is_editable() || !property.accepts(&value) {
1394            return None;
1395        }
1396        self.highlighted = Some(Entry::Property(id));
1397        self.assign(id, value)
1398    }
1399
1400    fn reset(&mut self, id: Id) -> Option<Output<Id>> {
1401        let property = self.property(id)?;
1402        if property.disabled {
1403            return None;
1404        }
1405        let default = property.default.clone()?;
1406        let _ = self.drafts.remove(&id);
1407        if self.editing_id() == Some(id) {
1408            self.editing = None;
1409        }
1410        let property = self.property_mut(id)?;
1411        if property.value == default {
1412            return None;
1413        }
1414        property.value = default.clone();
1415        Some(Output::Reset { id, value: default })
1416    }
1417}
1418
1419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1420enum Step {
1421    Next,
1422    Previous,
1423    First,
1424    Last,
1425}
1426
1427/// What a property grid keyboard shortcut does.
1428#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1429pub enum Action {
1430    Next,
1431    Previous,
1432    First,
1433    Last,
1434    Expand,
1435    Collapse,
1436    Edit,
1437    Cancel,
1438    Reset,
1439    CopyValue,
1440    FocusSearch,
1441}
1442
1443impl Action {
1444    /// The [`Event`] this action sends to `state`, or `None` when it does
1445    /// nothing, so the key is left for others.
1446    pub fn event<Id: Copy + Eq + Hash>(self, state: &State<Id>) -> Option<Event<Id>> {
1447        let number = state
1448            .editing()
1449            .and_then(|id| state.property(id))
1450            .is_some_and(|property| matches!(property.kind, Kind::Number(_)));
1451        let highlighted = state.highlighted();
1452        let property = state.highlighted_property();
1453        match self {
1454            Action::Next if number => Some(Event::Decrement),
1455            Action::Next => Some(Event::Next),
1456            Action::Previous if number => Some(Event::Increment),
1457            Action::Previous => Some(Event::Previous),
1458            Action::First => Some(Event::First),
1459            Action::Last => Some(Event::Last),
1460            Action::Expand => {
1461                matches!(highlighted, Some(Entry::Category(_))).then_some(Event::ExpandOrEnter)
1462            }
1463            Action::Collapse => {
1464                let leaves = property.is_some_and(|property| property.category.is_some())
1465                    && state.sort() == Sort::Categorised;
1466                (matches!(highlighted, Some(Entry::Category(_))) || leaves)
1467                    .then_some(Event::CollapseOrLeave)
1468            }
1469            Action::Edit => {
1470                let editable = property.is_some_and(Property::is_editable);
1471                (matches!(highlighted, Some(Entry::Category(_))) || editable)
1472                    .then_some(Event::EditHighlighted)
1473            }
1474            Action::Cancel => {
1475                (state.editing().is_some() || !state.query().is_empty()).then_some(Event::Cancel)
1476            }
1477            Action::Reset => property
1478                .filter(|property| state.can_reset(property.id))
1479                .map(|_| Event::ResetHighlighted),
1480            Action::CopyValue => property.map(|property| Event::CopyValue(property.id)),
1481            Action::FocusSearch => Some(Event::FocusSearch),
1482        }
1483    }
1484}
1485
1486impl keys::Action for Action {
1487    const ALL: &'static [Self] = &[
1488        Action::Next,
1489        Action::Previous,
1490        Action::First,
1491        Action::Last,
1492        Action::Expand,
1493        Action::Collapse,
1494        Action::Edit,
1495        Action::Cancel,
1496        Action::Reset,
1497        Action::CopyValue,
1498        Action::FocusSearch,
1499    ];
1500
1501    fn defaults() -> Keymap<Self> {
1502        default_keymap()
1503    }
1504
1505    fn name(self) -> &'static str {
1506        match self {
1507            Action::Next => "Next",
1508            Action::Previous => "Previous",
1509            Action::First => "First",
1510            Action::Last => "Last",
1511            Action::Expand => "Expand",
1512            Action::Collapse => "Collapse",
1513            Action::Edit => "Edit",
1514            Action::Cancel => "Cancel",
1515            Action::Reset => "Reset",
1516            Action::CopyValue => "CopyValue",
1517            Action::FocusSearch => "FocusSearch",
1518        }
1519    }
1520
1521    fn description(self) -> &'static str {
1522        match self {
1523            Action::Next => "Highlights the next row, or steps a number being edited down.",
1524            Action::Previous => "Highlights the previous row, or steps a number being edited up.",
1525            Action::First => "Highlights the first row.",
1526            Action::Last => "Highlights the last row.",
1527            Action::Expand => {
1528                "Opens the highlighted category, or moves to its first property when it is open."
1529            }
1530            Action::Collapse => {
1531                "Closes the highlighted category, or moves from a property to its category."
1532            }
1533            Action::Edit => {
1534                "Edits the highlighted property: types into text, flips a toggle or picks the next option."
1535            }
1536            Action::Cancel => "Cancels the edit and puts back the old value, or clears the filter.",
1537            Action::Reset => "Puts the highlighted property back to its default.",
1538            Action::CopyValue => "Copies the highlighted value as it is shown.",
1539            Action::FocusSearch => "Moves the keyboard to the filter box.",
1540        }
1541    }
1542}
1543
1544/// The default property grid shortcuts:
1545///
1546/// | Keys | Action |
1547/// | --- | --- |
1548/// | `ArrowDown` | [`Action::Next`] |
1549/// | `ArrowUp` | [`Action::Previous`] |
1550/// | `Home` | [`Action::First`] |
1551/// | `End` | [`Action::Last`] |
1552/// | `ArrowRight` | [`Action::Expand`] |
1553/// | `ArrowLeft` | [`Action::Collapse`] |
1554/// | `Enter`, `F2` | [`Action::Edit`] |
1555/// | `Escape` | [`Action::Cancel`] |
1556/// | `Delete` | [`Action::Reset`] |
1557/// | `Mod+C` (Ctrl, or Cmd on macOS) | [`Action::CopyValue`] |
1558/// | `Mod+F` | [`Action::FocusSearch`] |
1559///
1560/// While a field is being typed into it keeps the keys it uses, such as
1561/// the arrows that move its cursor; Enter ends the edit and Escape cancels
1562/// it. Up and Down step a number being edited. Escape in the filter box
1563/// clears it, and a second Escape leaves it. The grid resolves these
1564/// itself, only while it has focus, so they never clash with other
1565/// components. Pass a changed keymap with [`PropertyGrid::keymap`].
1566pub fn default_keymap() -> Keymap<Action> {
1567    Keymap::new()
1568        .bind(Chord::named(Named::ArrowDown), Action::Next)
1569        .bind(Chord::named(Named::ArrowUp), Action::Previous)
1570        .bind(Chord::named(Named::Home), Action::First)
1571        .bind(Chord::named(Named::End), Action::Last)
1572        .bind(Chord::named(Named::ArrowRight), Action::Expand)
1573        .bind(Chord::named(Named::ArrowLeft), Action::Collapse)
1574        .bind(Chord::named(Named::Enter), Action::Edit)
1575        .bind(Chord::named(Named::F2), Action::Edit)
1576        .bind(Chord::named(Named::Escape), Action::Cancel)
1577        .bind(Chord::named(Named::Delete), Action::Reset)
1578        .bind(Chord::character('c').command(), Action::CopyValue)
1579        .bind(Chord::character('f').command(), Action::FocusSearch)
1580}
1581
1582/// Focuses the filter box of a grid, for an app that moves the keyboard
1583/// there itself. [`Action::FocusSearch`] does the same from inside the
1584/// grid.
1585pub fn focus_search<Id, Message>(_state: &State<Id>) -> Task<Message> {
1586    widget::operation::focus(SEARCH)
1587}
1588
1589/// The text beside the search box, as in the log and JSON viewers: "2 of 5"
1590/// for the highlighted property of those shown, "5 found" while none of
1591/// them is highlighted, "None" when nothing passes, and empty without a
1592/// search.
1593pub fn match_label<Id: Copy + Eq + Hash>(state: &State<Id>) -> String {
1594    let shown = state.shown_properties();
1595    let current = state
1596        .highlighted
1597        .as_ref()
1598        .and_then(|highlighted| shown.iter().position(|entry| entry == highlighted))
1599        .map(|index| index + 1);
1600    match_count_label(state.query(), shown.len(), current)
1601}
1602
1603/// An entry of the context menu from [`menu`].
1604#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1605pub enum MenuItem {
1606    CopyValue,
1607    Reset,
1608}
1609
1610impl MenuItem {
1611    pub const ALL: [MenuItem; 2] = [MenuItem::CopyValue, MenuItem::Reset];
1612
1613    /// The event the entry sends for the property the menu opened on.
1614    pub fn event<Id>(self, id: Id) -> Event<Id> {
1615        match self {
1616            MenuItem::CopyValue => Event::CopyValue(id),
1617            MenuItem::Reset => Event::Reset(id),
1618        }
1619    }
1620
1621    /// Whether the entry would do anything for property `id`.
1622    pub fn is_enabled<Id: Copy + Eq + Hash>(self, state: &State<Id>, id: Id) -> bool {
1623        match self {
1624            MenuItem::CopyValue => state.property(id).is_some(),
1625            MenuItem::Reset => state.can_reset(id),
1626        }
1627    }
1628}
1629
1630/// A context menu to pass to [`PropertyGrid::context_menu`]: copy the
1631/// value, or reset it to its default. Turn the chosen entry into an event
1632/// with [`MenuItem::event`], and call [`sync_menu`] after each menu event
1633/// so entries that would do nothing are disabled.
1634pub fn menu<Id: Clone + PartialEq>() -> context_menu::State<MenuItem, Id> {
1635    context_menu::State::new([
1636        item(MenuItem::CopyValue, "Copy value").icon(crate::lucide!(Copy)),
1637        item(MenuItem::Reset, "Reset to default").icon(crate::lucide!(RotateCcw)),
1638    ])
1639}
1640
1641/// Disables the entries of a [`menu`] that would do nothing for the
1642/// property it is open on, such as "Reset to default" on a property that
1643/// already holds its default.
1644pub fn sync_menu<Id: Copy + Eq + Hash>(
1645    menu: &mut context_menu::State<MenuItem, Id>,
1646    state: &State<Id>,
1647) {
1648    let Some(&id) = menu.target() else {
1649        return;
1650    };
1651    for item in MenuItem::ALL {
1652        menu.set_disabled(item, !item.is_enabled(state, id));
1653    }
1654}
1655
1656/// How a row is shown.
1657#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1658pub struct Status {
1659    /// The row the keyboard acts on.
1660    pub highlighted: bool,
1661    /// The grid has keyboard focus, so the highlight carries a ring.
1662    pub focused: bool,
1663    pub hovered: bool,
1664    pub disabled: bool,
1665    /// A category heading rather than a property.
1666    pub heading: bool,
1667}
1668
1669/// The colours of a row.
1670#[derive(Debug, Clone, Copy, PartialEq)]
1671pub struct RowStyle {
1672    pub background: Option<Color>,
1673    pub text: Color,
1674    pub icon: Color,
1675    /// The ring around the highlighted row while the grid has focus.
1676    pub ring: Option<Color>,
1677}
1678
1679/// Resolves a row's colours. Headings sit on a muted band. The grid selects
1680/// nothing, so the highlighted row only carries a ring, and only while the
1681/// grid has focus.
1682pub fn row_style(tokens: &Tokens, status: Status) -> RowStyle {
1683    let hovered = status.hovered && !status.disabled;
1684    let background = match (hovered, status.heading) {
1685        (true, true) => Some(tokens.muted),
1686        (true, false) => Some(fade(tokens.accent, 0.6)),
1687        (false, true) => Some(fade(tokens.muted, 0.6)),
1688        (false, false) => None,
1689    };
1690    let text = if status.disabled {
1691        tokens.muted_foreground
1692    } else {
1693        tokens.foreground
1694    };
1695    RowStyle {
1696        background,
1697        text,
1698        icon: tokens.muted_foreground,
1699        ring: (status.highlighted && status.focused).then_some(tokens.ring),
1700    }
1701}
1702
1703/// The colour of a value: muted when it cannot be edited.
1704pub fn value_colour(tokens: &Tokens, editable: bool) -> Color {
1705    if editable {
1706        tokens.foreground
1707    } else {
1708        tokens.muted_foreground
1709    }
1710}
1711
1712/// The dot before the name of a property that differs from its default.
1713pub fn mark_colour(tokens: &Tokens) -> Color {
1714    tokens.primary
1715}
1716
1717/// The line between the name and value columns, brighter while dragged or
1718/// hovered.
1719pub fn divider_colour(tokens: &Tokens, active: bool) -> Color {
1720    if active {
1721        tokens.ring
1722    } else {
1723        fade(tokens.border, 0.7)
1724    }
1725}
1726
1727/// The border around the grid.
1728pub fn frame_style(tokens: &Tokens) -> container::Style {
1729    container::Style {
1730        border: Border {
1731            color: tokens.border,
1732            width: 1.0,
1733            radius: radius::MD.into(),
1734        },
1735        ..container::Style::default()
1736    }
1737}
1738
1739/// The ring around a colour swatch. Dark themes lift it towards the text
1740/// colour, so a near-black swatch still shows its edge.
1741pub fn swatch_ring(tokens: &Tokens) -> Color {
1742    if tokens.is_dark {
1743        mix(tokens.border, tokens.foreground, 0.4)
1744    } else {
1745        tokens.border
1746    }
1747}
1748
1749/// A colour swatch: the colour inside a thin ring.
1750pub fn swatch_style(tokens: &Tokens, colour: Color) -> container::Style {
1751    container::Style {
1752        background: Some(Background::Color(colour)),
1753        border: Border {
1754            color: swatch_ring(tokens),
1755            width: 1.0,
1756            radius: radius::SM.into(),
1757        },
1758        ..container::Style::default()
1759    }
1760}
1761
1762/// The name column's width in a row `width` pixels wide. A dragged or set
1763/// width is kept between [`MIN_NAME_WIDTH`] and what leaves the value
1764/// column [`MIN_VALUE_WIDTH`]. Otherwise names take [`NAME_WIDTH`], and in
1765/// a narrow grid the value column gives up its room first, down to
1766/// [`VALUE_WIDTH`], so names stay readable.
1767pub fn name_width(requested: Option<f32>, width: f32) -> f32 {
1768    let room = width - HANDLE_WIDTH - RESET_WIDTH;
1769    let max = (room - MIN_VALUE_WIDTH).max(MIN_NAME_WIDTH);
1770    let wanted = requested.unwrap_or_else(|| NAME_WIDTH.min(room - VALUE_WIDTH));
1771    wanted.clamp(MIN_NAME_WIDTH, max)
1772}
1773
1774type OnEvent<'a, Id, Message> = dyn Fn(Event<Id>) -> Message + 'a;
1775type Render<'a, Id, Message> = dyn Fn(&'a Property<Id>) -> Element<'a, Message> + 'a;
1776type BuildMenu<'a, Id, Message> =
1777    dyn FnOnce(Keymap<context_menu::Action>) -> RowMenu<'a, Id, Message> + 'a;
1778
1779/// A property grid builder. Convert it into an [`Element`] to render.
1780pub struct PropertyGrid<'a, Id, Message> {
1781    state: &'a State<Id>,
1782    customs: Vec<(Id, Box<Render<'a, Id, Message>>)>,
1783    toolbar: bool,
1784    description: bool,
1785    placeholder: String,
1786    width: Length,
1787    height: Length,
1788    keymap: Keymap<Action>,
1789    menu: Option<Box<BuildMenu<'a, Id, Message>>>,
1790    menu_keymap: Keymap<context_menu::Action>,
1791    on_event: Option<Box<OnEvent<'a, Id, Message>>>,
1792}
1793
1794/// Renders `state`. Without [`on_event`](PropertyGrid::on_event) every
1795/// editor renders disabled.
1796pub fn property_grid<Id, Message>(state: &State<Id>) -> PropertyGrid<'_, Id, Message> {
1797    PropertyGrid {
1798        state,
1799        customs: Vec::new(),
1800        toolbar: true,
1801        description: true,
1802        placeholder: "Filter".to_owned(),
1803        width: Length::Fill,
1804        height: Length::Shrink,
1805        keymap: default_keymap(),
1806        menu: None,
1807        menu_keymap: context_menu::default_keymap(),
1808        on_event: None,
1809    }
1810}
1811
1812impl<Id: fmt::Debug, Message> fmt::Debug for PropertyGrid<'_, Id, Message> {
1813    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1814        f.debug_struct("PropertyGrid")
1815            .field("state", self.state)
1816            .field("toolbar", &self.toolbar)
1817            .field("description", &self.description)
1818            .field("placeholder", &self.placeholder)
1819            .field("width", &self.width)
1820            .field("height", &self.height)
1821            .field("keymap", &self.keymap)
1822            .finish_non_exhaustive()
1823    }
1824}
1825
1826impl<'a, Id, Message> PropertyGrid<'a, Id, Message> {
1827    /// Shows the filter box and the sort buttons above the rows. On by
1828    /// default.
1829    pub fn toolbar(mut self, toolbar: bool) -> Self {
1830        self.toolbar = toolbar;
1831        self
1832    }
1833
1834    /// Shows the highlighted property's name and description under the
1835    /// rows. On by default. Errors show there even when it is off.
1836    pub fn description(mut self, description: bool) -> Self {
1837        self.description = description;
1838        self
1839    }
1840
1841    /// The filter box placeholder.
1842    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
1843        self.placeholder = placeholder.into();
1844        self
1845    }
1846
1847    pub fn width(mut self, width: impl Into<Length>) -> Self {
1848        self.width = width.into();
1849        self
1850    }
1851
1852    /// A fixed or filling height scrolls the rows. Defaults to shrinking to
1853    /// fit them.
1854    pub fn height(mut self, height: impl Into<Length>) -> Self {
1855        self.height = height.into();
1856        self
1857    }
1858
1859    /// Replaces the [`default_keymap`] the focused grid resolves keys with.
1860    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
1861        self.keymap = keymap;
1862        self
1863    }
1864
1865    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
1866        self.on_event = Some(Box::new(on_event));
1867        self
1868    }
1869
1870    /// Draws the value of property `id` with `render`, such as a slider.
1871    /// Keep it within a row's height. The app handles its messages and
1872    /// stores the result with [`State::set`].
1873    pub fn custom(
1874        mut self,
1875        id: Id,
1876        render: impl Fn(&'a Property<Id>) -> Element<'a, Message> + 'a,
1877    ) -> Self
1878    where
1879        Id: PartialEq,
1880    {
1881        self.customs.retain(|(custom, _)| *custom != id);
1882        self.customs.push((id, Box::new(render)));
1883        self
1884    }
1885
1886    /// Gives every property row the context menu of `menu`, keyed by
1887    /// property, such as the one from [`menu()`]. It opens on a right-click
1888    /// or a long press, and on the highlighted property with its opening
1889    /// chord while the grid has focus.
1890    pub fn context_menu<MenuId>(
1891        mut self,
1892        menu: &'a context_menu::State<MenuId, Id>,
1893        on_event: impl Fn(context_menu::Event<MenuId, Id>) -> Message + 'a,
1894    ) -> Self
1895    where
1896        MenuId: Copy + PartialEq + 'a,
1897        Id: Clone + PartialEq + 'a,
1898        Message: Clone + 'a,
1899    {
1900        let on_event: Rc<dyn Fn(context_menu::Event<MenuId, Id>) -> Message + 'a> =
1901            Rc::new(on_event);
1902        self.menu = Some(Box::new(move |keymap| row_menu(menu, keymap, on_event)));
1903        self
1904    }
1905
1906    /// Replaces the context menu's default keymap.
1907    pub fn context_menu_keymap(mut self, keymap: Keymap<context_menu::Action>) -> Self {
1908        self.menu_keymap = keymap;
1909        self
1910    }
1911
1912    pub fn is_enabled(&self) -> bool {
1913        self.on_event.is_some()
1914    }
1915}
1916
1917impl<'a, Id, Message> From<PropertyGrid<'a, Id, Message>> for Element<'a, Message>
1918where
1919    Id: Copy + Eq + Hash + 'a,
1920    Message: Clone + 'a,
1921{
1922    fn from(grid: PropertyGrid<'a, Id, Message>) -> Self {
1923        let PropertyGrid {
1924            state,
1925            customs,
1926            toolbar,
1927            description,
1928            placeholder,
1929            width,
1930            height,
1931            keymap,
1932            menu,
1933            menu_keymap,
1934            on_event,
1935        } = grid;
1936        let menu = menu
1937            .filter(|_| on_event.is_some())
1938            .map(|build| build(menu_keymap));
1939        let view = Rc::new(View {
1940            state,
1941            customs,
1942            toolbar,
1943            description,
1944            placeholder,
1945            height,
1946            keymap,
1947            menu,
1948            entries: state.entries(),
1949            on_event: on_event.map(Rc::from),
1950        });
1951        responsive(move |size| view.body(size.width))
1952            .width(width)
1953            .height(height)
1954            .into()
1955    }
1956}
1957
1958/// Everything the grid's parts are built from.
1959struct View<'a, Id, Message> {
1960    state: &'a State<Id>,
1961    customs: Vec<(Id, Box<Render<'a, Id, Message>>)>,
1962    toolbar: bool,
1963    description: bool,
1964    placeholder: String,
1965    height: Length,
1966    keymap: Keymap<Action>,
1967    menu: Option<RowMenu<'a, Id, Message>>,
1968    entries: Vec<Entry<Id>>,
1969    on_event: Option<Rc<OnEvent<'a, Id, Message>>>,
1970}
1971
1972fn muted(theme: &Theme) -> Color {
1973    Tokens::of(theme).muted_foreground
1974}
1975
1976impl<'a, Id, Message> View<'a, Id, Message>
1977where
1978    Id: Copy + Eq + Hash + 'a,
1979    Message: Clone + 'a,
1980{
1981    fn send(&self, event: Event<Id>) -> Option<Message> {
1982        self.on_event.as_ref().map(|on_event| on_event(event))
1983    }
1984
1985    fn body(self: &Rc<Self>, width: f32) -> Element<'a, Message> {
1986        let state = self.state;
1987        // The frame's border and the rows' padding on both sides.
1988        let inner = width - 2.0 - space::SM * 2.0;
1989        let names = name_width(state.name_width(), inner);
1990
1991        let highlight = state
1992            .highlighted()
1993            .and_then(|highlighted| self.entries.iter().position(|entry| entry == highlighted));
1994        let list = {
1995            let view = Rc::clone(self);
1996            rows(self.entries.len(), ROW_HEIGHT, move |range| {
1997                view.lines(range, names)
1998            })
1999            .highlight(highlight)
2000            .height(if self.height == Length::Shrink {
2001                Length::Shrink
2002            } else {
2003                Length::Fill
2004            })
2005        };
2006
2007        let mut inside = column![list];
2008        if let Some(footer) = self.footer() {
2009            inside = inside.push(container(Space::new()).width(Length::Fill).height(1).style(
2010                |theme| container::Style {
2011                    background: Some(Background::Color(Tokens::of(theme).border)),
2012                    ..container::Style::default()
2013                },
2014            ));
2015            inside = inside.push(footer);
2016        }
2017        let mut inside: Element<'a, Message> = inside.into();
2018        if state.is_resizing()
2019            && let Some(on_event) = self.on_event.clone()
2020        {
2021            let end = on_event(Event::ResizeEnd);
2022            inside = mouse_area(inside)
2023                .on_move(move |point| {
2024                    on_event(Event::Resize(point.x - space::SM - HANDLE_WIDTH / 2.0))
2025                })
2026                .on_release(end.clone())
2027                .on_exit(end)
2028                .interaction(mouse::Interaction::ResizingHorizontally)
2029                .into();
2030        }
2031        let frame = container(inside)
2032            .padding(1)
2033            .width(Length::Fill)
2034            .height(if self.height == Length::Shrink {
2035                Length::Shrink
2036            } else {
2037                Length::Fill
2038            })
2039            .clip(true)
2040            .style(|theme| frame_style(&Tokens::of(theme)));
2041
2042        let mut parts = column![].spacing(space::SM);
2043        if self.toolbar {
2044            parts = parts.push(self.toolbar());
2045        }
2046        parts = parts.push(frame);
2047        let parts: Element<'a, Message> = parts.into();
2048
2049        let Some(on_event) = self.on_event.clone() else {
2050            return parts;
2051        };
2052        let request = state.focus_request().map(|(count, target)| match target {
2053            Target::Filter => (count, SEARCH, false),
2054            Target::Editor => (count, EDITOR_ID, true),
2055        });
2056        let focuser = Focuser {
2057            content: parts,
2058            request,
2059        };
2060        let cancel = state.editing().map(|_| on_event(Event::Cancel));
2061        let on_focus = on_event.clone();
2062        let keymap = self.keymap.clone();
2063        let view = Rc::clone(self);
2064        scope(focuser)
2065            .escape(EDITOR_ID, cancel)
2066            .on_focus(move |focused| on_focus(Event::Focus(focused)))
2067            .on_key(move |key| {
2068                let opened = view
2069                    .menu
2070                    .as_ref()
2071                    .zip(state.highlighted_property())
2072                    .and_then(|(menu, property)| menu.key(key, property.id));
2073                opened.or_else(|| state.key_event(&keymap, key).map(|event| on_event(event)))
2074            })
2075            .into()
2076    }
2077
2078    fn toolbar(&self) -> Element<'a, Message> {
2079        let state = self.state;
2080        let on_event = self.on_event.clone();
2081        let field = input(&self.placeholder, state.query())
2082            .id(SEARCH)
2083            .icon(crate::lucide!(Search))
2084            .size(input::Size::Sm)
2085            .width(Length::Fill)
2086            .on_input_maybe(on_event.map(|on_event| move |query| on_event(Event::Search(query))));
2087        let searching = !state.query().is_empty();
2088        let filter = search_keys(field, SEARCH)
2089            .on_clear(
2090                self.send(Event::Search(String::new()))
2091                    .filter(|_| searching),
2092            )
2093            .on_next(self.send(Event::NextMatch).filter(|_| searching))
2094            .on_previous(self.send(Event::PreviousMatch).filter(|_| searching));
2095        let sort = |sort: Sort, glyph, label| {
2096            icon_button(glyph)
2097                .label(label)
2098                .size(button::Size::Sm)
2099                .pressed(state.sort() == sort)
2100                .on_press_maybe(self.send(Event::Sort(sort)))
2101        };
2102        row![
2103            filter,
2104            search_count(match_label(state)),
2105            sort(Sort::Categorised, crate::lucide!(ListTree), "Categorised"),
2106            sort(
2107                Sort::Alphabetical,
2108                crate::lucide!(ArrowDownAZ),
2109                "Alphabetical"
2110            ),
2111        ]
2112        .spacing(space::XS)
2113        .align_y(Alignment::Center)
2114        .into()
2115    }
2116
2117    /// The description area, or an error when the description is off.
2118    fn footer(&self) -> Option<Element<'a, Message>> {
2119        let state = self.state;
2120        let property = state.highlighted_property();
2121        let error = state
2122            .editing()
2123            .and_then(|id| state.error(id))
2124            .or_else(|| property.and_then(|property| state.error(property.id)));
2125        if !self.description && error.is_none() {
2126            return None;
2127        }
2128        let (title, body) = match state.highlighted() {
2129            Some(Entry::Property(_)) => (
2130                property
2131                    .map(|property| property.name.as_str())
2132                    .unwrap_or_default(),
2133                property.and_then(|property| property.description.as_deref()),
2134            ),
2135            Some(Entry::Category(name)) => (name.as_str(), None),
2136            None => ("", None),
2137        };
2138        let mut content = column![].spacing(2).width(Length::Fill);
2139        if self.description && title.is_empty() {
2140            content = content.push(widget::text(NOTHING_SELECTED).size(text_size::SM).style(
2141                |theme: &Theme| widget::text::Style {
2142                    color: Some(muted(theme)),
2143                },
2144            ));
2145        } else if self.description {
2146            content = content.push(ellipsis(title).font(theme::semibold()));
2147        }
2148        match (error, body) {
2149            (Some(error), _) => {
2150                content = content.push(widget::text(error.to_owned()).size(text_size::XS).style(
2151                    |theme: &Theme| widget::text::Style {
2152                        color: Some(Tokens::of(theme).destructive),
2153                    },
2154                ));
2155            }
2156            (None, Some(body)) if self.description => {
2157                content = content.push(widget::text(body).size(text_size::XS).style(
2158                    |theme: &Theme| widget::text::Style {
2159                        color: Some(muted(theme)),
2160                    },
2161                ));
2162            }
2163            _ => {}
2164        }
2165        let least = if self.description {
2166            DESCRIPTION_HEIGHT
2167        } else {
2168            DESCRIPTION_HEIGHT / 2.0
2169        };
2170        // The strut keeps the least height; longer text grows the area.
2171        let strut = Space::new().height(least - space::SM * 2.0);
2172        Some(
2173            container(row![content, strut])
2174                .padding([space::SM, space::MD])
2175                .width(Length::Fill)
2176                .into(),
2177        )
2178    }
2179
2180    fn lines(&self, range: Range<usize>, names: f32) -> Element<'a, Message> {
2181        let entries = self.entries.get(range).unwrap_or_default();
2182        column(entries.iter().map(|entry| match entry {
2183            Entry::Category(name) => self.heading(name),
2184            Entry::Property(id) => {
2185                let row = self.property(*id, names);
2186                match &self.menu {
2187                    Some(menu) => menu.wrap(*id, row),
2188                    None => row,
2189                }
2190            }
2191        }))
2192        .into()
2193    }
2194
2195    fn status(&self, entry: &Entry<Id>, disabled: bool) -> Status {
2196        let state = self.state;
2197        let target = self
2198            .menu
2199            .as_ref()
2200            .and_then(RowMenu::target)
2201            .map(Entry::Property);
2202        let menu_open_here = target.as_ref() == Some(entry);
2203        Status {
2204            highlighted: ringed(target.as_ref(), entry, state.highlighted() == Some(entry)),
2205            focused: state.is_focused() || menu_open_here,
2206            hovered: false,
2207            disabled,
2208            heading: matches!(entry, Entry::Category(_)),
2209        }
2210    }
2211
2212    fn heading(&self, name: &str) -> Element<'a, Message> {
2213        let state = self.state;
2214        let entry = Entry::Category(name.to_owned());
2215        let status = self.status(&entry, self.on_event.is_none());
2216        let enabled = self.on_event.is_some();
2217        let glyph = if state.is_expanded(name) {
2218            crate::lucide!(ChevronDown)
2219        } else {
2220            crate::lucide!(ChevronRight)
2221        };
2222        let colours = move |theme: &Theme| row_style(&Tokens::of(theme), status);
2223        let count = state.count(name);
2224        let body = row![
2225            themed(glyph, ICON_SIZE, opacity(enabled), move |theme| colours(
2226                theme
2227            )
2228            .icon),
2229            ellipsis(name.to_owned())
2230                .font(theme::semibold())
2231                .colour(move |theme| colours(theme).text),
2232            widget::text(count.to_string())
2233                .size(text_size::XS)
2234                .wrapping(widget::text::Wrapping::None)
2235                .style(|theme: &Theme| widget::text::Style {
2236                    color: Some(muted(theme)),
2237                }),
2238        ]
2239        .spacing(space::SM)
2240        .align_y(Alignment::Center)
2241        .padding(Padding::ZERO.left(space::SM).right(space::SM))
2242        .height(ROW_HEIGHT);
2243        let row = pressable(body, move |theme, hovered| {
2244            look(row_style(&Tokens::of(theme), Status { hovered, ..status }))
2245        });
2246        match self.send(Event::Press(entry)) {
2247            Some(message) => row.on_press(move |_| message.clone()).into(),
2248            None => row.into(),
2249        }
2250    }
2251
2252    fn property(&self, id: Id, names: f32) -> Element<'a, Message> {
2253        let state = self.state;
2254        let Some(property) = state.property(id) else {
2255            return Space::new().height(ROW_HEIGHT).into();
2256        };
2257        let entry = Entry::Property(id);
2258        let on_event = self.on_event.clone().filter(|_| !property.disabled);
2259        let enabled = on_event.is_some();
2260        let status = self.status(&entry, !enabled);
2261        let colours = move |theme: &Theme| row_style(&Tokens::of(theme), status);
2262        let modified = property.is_modified();
2263
2264        let mark: Element<'a, Message> = if modified {
2265            container(
2266                container(Space::new())
2267                    .width(MARK_SIZE)
2268                    .height(MARK_SIZE)
2269                    .style(|theme| container::Style {
2270                        background: Some(Background::Color(mark_colour(&Tokens::of(theme)))),
2271                        border: Border {
2272                            radius: radius::FULL.into(),
2273                            ..Border::default()
2274                        },
2275                        ..container::Style::default()
2276                    }),
2277            )
2278            .width(MARK_WIDTH)
2279            .into()
2280        } else {
2281            Space::new().width(MARK_WIDTH).into()
2282        };
2283        let name = ellipsis(property.name.as_str())
2284            .font(if modified {
2285                theme::semibold()
2286            } else {
2287                theme::font()
2288            })
2289            .colour(move |theme| colours(theme).text);
2290        let name = container(row![mark, name].align_y(Alignment::Center))
2291            .width(names)
2292            .height(Length::Fill)
2293            .align_y(Alignment::Center);
2294
2295        let handle = self.handle();
2296        let value = container(self.value(property))
2297            .width(Length::Fill)
2298            .height(Length::Fill)
2299            .align_y(Alignment::Center);
2300
2301        let reset: Element<'a, Message> = match &on_event {
2302            Some(on_event) if state.can_reset(id) => {
2303                let icon = themed(crate::lucide!(RotateCcw), RESET_ICON_SIZE, 1.0, muted);
2304                let button = mouse_area(container(icon).center(RESET_WIDTH))
2305                    .on_press(on_event(Event::Reset(id)))
2306                    .interaction(mouse::Interaction::Pointer);
2307                tooltip(button, "Reset to default").into()
2308            }
2309            _ => Space::new().width(RESET_WIDTH).into(),
2310        };
2311
2312        let body = row![name, handle, value, reset]
2313            .align_y(Alignment::Center)
2314            .padding(Padding::ZERO.left(space::SM).right(space::SM))
2315            .height(ROW_HEIGHT);
2316        let row = pressable(body, move |theme, hovered| {
2317            look(row_style(&Tokens::of(theme), Status { hovered, ..status }))
2318        });
2319        let Some(on_event) = self.on_event.clone() else {
2320            return row.into();
2321        };
2322        let press = on_event(Event::Press(entry));
2323        let edit = property.is_editable().then(|| on_event(Event::Edit(id)));
2324        row.on_press(move |_| press.clone())
2325            .on_double_click(edit)
2326            .into()
2327    }
2328
2329    /// The line between the columns, which drags to resize them.
2330    fn handle(&self) -> Element<'a, Message> {
2331        let resizing = self.state.is_resizing();
2332        let line = container(container(Space::new()).width(1).height(Length::Fill).style(
2333            move |theme| container::Style {
2334                background: Some(Background::Color(divider_colour(
2335                    &Tokens::of(theme),
2336                    resizing,
2337                ))),
2338                ..container::Style::default()
2339            },
2340        ))
2341        .center_x(HANDLE_WIDTH)
2342        .height(Length::Fill);
2343        match self.send(Event::ResizeStart) {
2344            Some(start) => mouse_area(line)
2345                .on_press(start)
2346                .interaction(mouse::Interaction::ResizingHorizontally)
2347                .into(),
2348            None => line.into(),
2349        }
2350    }
2351
2352    fn value(&self, property: &'a Property<Id>) -> Element<'a, Message> {
2353        let state = self.state;
2354        let id = property.id;
2355        if let Some((_, render)) = self.customs.iter().find(|(custom, _)| *custom == id) {
2356            return render(property);
2357        }
2358        let on_event = self.on_event.clone().filter(|_| !property.disabled);
2359        let editable = on_event.is_some() && property.is_editable();
2360
2361        match (&property.kind, &property.value) {
2362            (Kind::Toggle(toggle), Value::Bool(on)) => {
2363                let set =
2364                    on_event.map(|on_event| move |on| on_event(Event::Set(id, Value::Bool(on))));
2365                let control: Element<'a, Message> = match toggle {
2366                    Toggle::Checkbox => checkbox(*on).on_toggle_maybe(set).into(),
2367                    Toggle::Switch => switch(*on).on_toggle_maybe(set).into(),
2368                };
2369                container(control)
2370                    .padding(Padding::ZERO.left(space::SM))
2371                    .into()
2372            }
2373            (Kind::Choice(options), Value::Text(current)) => {
2374                select(options.as_slice(), Some(current.clone()))
2375                    .width(Length::Fill)
2376                    .on_select_maybe(on_event.map(|on_event| {
2377                        move |option: String| on_event(Event::Set(id, Value::Text(option)))
2378                    }))
2379                    .into()
2380            }
2381            _ if property.is_typed() && editable => match state.draft(id) {
2382                Some(draft) => self.field(property, draft),
2383                None => self.shown(property, true),
2384            },
2385            _ => self.shown(property, editable),
2386        }
2387    }
2388
2389    /// A value shown as text, which starts an edit when pressed.
2390    fn shown(&self, property: &'a Property<Id>, editable: bool) -> Element<'a, Message> {
2391        let label = ellipsis(property.display())
2392            .colour(move |theme| value_colour(&Tokens::of(theme), editable));
2393        let content: Element<'a, Message> = match property.value {
2394            Value::Colour(colour) => row![swatch(colour), label]
2395                .spacing(space::SM)
2396                .align_y(Alignment::Center)
2397                .into(),
2398            _ => label.into(),
2399        };
2400        let content = container(content)
2401            .padding(Padding::ZERO.left(space::SM))
2402            .width(Length::Fill)
2403            .height(Length::Fill)
2404            .align_y(Alignment::Center);
2405        match self
2406            .send(Event::Edit(property.id))
2407            .filter(|_| editable && property.is_typed())
2408        {
2409            Some(edit) => mouse_area(content)
2410                .on_press(edit)
2411                .interaction(mouse::Interaction::Text)
2412                .into(),
2413            None => content.into(),
2414        }
2415    }
2416
2417    /// The text field of a property being edited, or left while invalid.
2418    fn field(&self, property: &'a Property<Id>, draft: &'a Draft) -> Element<'a, Message> {
2419        let id = property.id;
2420        let mut field = input("", &draft.text)
2421            .size(input::Size::Sm)
2422            .invalid(draft.error.is_some())
2423            .on_input_maybe(
2424                self.on_event
2425                    .clone()
2426                    .map(|on_event| move |text| on_event(Event::Input(id, text))),
2427            )
2428            .on_submit_maybe(self.send(Event::Submit));
2429        if self.state.editing() == Some(id) {
2430            field = field.id(EDITOR_ID);
2431        }
2432        let Value::Colour(current) = property.value else {
2433            return field.into();
2434        };
2435        let colour = parse_hex(&draft.text).unwrap_or(current);
2436        row![swatch(colour), field]
2437            .spacing(space::SM)
2438            .align_y(Alignment::Center)
2439            .padding(Padding::ZERO.left(space::SM))
2440            .into()
2441    }
2442}
2443
2444fn swatch<'a, Message: 'a>(colour: Color) -> Element<'a, Message> {
2445    container(Space::new())
2446        .width(SWATCH_SIZE)
2447        .height(SWATCH_SIZE)
2448        .style(move |theme| swatch_style(&Tokens::of(theme), colour))
2449        .into()
2450}
2451
2452fn look(style: RowStyle) -> Look {
2453    Look {
2454        background: style.background,
2455        border: Border {
2456            color: style.ring.unwrap_or(Color::TRANSPARENT),
2457            width: if style.ring.is_some() { 1.0 } else { 0.0 },
2458            radius: radius::SM.into(),
2459        },
2460        divider: None,
2461    }
2462}
2463
2464/// Focuses a field inside the grid once for each request the state makes,
2465/// selecting its text when asked.
2466struct Focuser<'a, Message> {
2467    content: Element<'a, Message>,
2468    request: Option<(u64, widget::Id, bool)>,
2469}
2470
2471/// The last focus request acted on.
2472#[derive(Debug, Default)]
2473struct Handled(u64);
2474
2475impl<Message: Clone> Widget<Message, Theme, Renderer> for Focuser<'_, Message> {
2476    fn tag(&self) -> tree::Tag {
2477        tree::Tag::of::<Handled>()
2478    }
2479
2480    fn state(&self) -> tree::State {
2481        tree::State::new(Handled::default())
2482    }
2483
2484    fn children(&self) -> Vec<Tree> {
2485        vec![Tree::new(&self.content)]
2486    }
2487
2488    fn diff(&self, tree: &mut Tree) {
2489        tree.diff_children(std::slice::from_ref(&self.content));
2490    }
2491
2492    fn size(&self) -> Size<Length> {
2493        self.content.as_widget().size()
2494    }
2495
2496    fn size_hint(&self) -> Size<Length> {
2497        self.content.as_widget().size_hint()
2498    }
2499
2500    fn layout(
2501        &mut self,
2502        tree: &mut Tree,
2503        renderer: &Renderer,
2504        limits: &layout::Limits,
2505    ) -> layout::Node {
2506        let Tree {
2507            state, children, ..
2508        } = tree;
2509        let Some(child) = children.first_mut() else {
2510            return layout::Node::new(Size::ZERO);
2511        };
2512        let node = self.content.as_widget_mut().layout(child, renderer, limits);
2513        let handled = state.downcast_mut::<Handled>();
2514        // The field may only exist from this layout on, so focus it here,
2515        // once the new rows have been built.
2516        if let Some((count, id, select_all)) = &self.request
2517            && handled.0 != *count
2518        {
2519            handled.0 = *count;
2520            let layout = Layout::new(&node);
2521            let mut focus = operation::focusable::focus(id.clone());
2522            self.content
2523                .as_widget_mut()
2524                .operate(child, layout, renderer, &mut focus);
2525            if *select_all {
2526                let mut select = operation::text_input::select_all(id.clone());
2527                self.content
2528                    .as_widget_mut()
2529                    .operate(child, layout, renderer, &mut select);
2530            }
2531        }
2532        node
2533    }
2534
2535    fn draw(
2536        &self,
2537        tree: &Tree,
2538        renderer: &mut Renderer,
2539        theme: &Theme,
2540        style: &renderer::Style,
2541        layout: Layout<'_>,
2542        cursor: mouse::Cursor,
2543        viewport: &Rectangle,
2544    ) {
2545        if let Some(child) = tree.children.first() {
2546            self.content
2547                .as_widget()
2548                .draw(child, renderer, theme, style, layout, cursor, viewport);
2549        }
2550    }
2551
2552    fn operate(
2553        &mut self,
2554        tree: &mut Tree,
2555        layout: Layout<'_>,
2556        renderer: &Renderer,
2557        operation: &mut dyn Operation,
2558    ) {
2559        if let Some(child) = tree.children.first_mut() {
2560            self.content
2561                .as_widget_mut()
2562                .operate(child, layout, renderer, operation);
2563        }
2564    }
2565
2566    fn update(
2567        &mut self,
2568        tree: &mut Tree,
2569        event: &iced::Event,
2570        layout: Layout<'_>,
2571        cursor: mouse::Cursor,
2572        renderer: &Renderer,
2573        clipboard: &mut dyn Clipboard,
2574        shell: &mut Shell<'_, Message>,
2575        viewport: &Rectangle,
2576    ) {
2577        let Some(child) = tree.children.first_mut() else {
2578            return;
2579        };
2580        self.content.as_widget_mut().update(
2581            child, event, layout, cursor, renderer, clipboard, shell, viewport,
2582        );
2583    }
2584
2585    fn mouse_interaction(
2586        &self,
2587        tree: &Tree,
2588        layout: Layout<'_>,
2589        cursor: mouse::Cursor,
2590        viewport: &Rectangle,
2591        renderer: &Renderer,
2592    ) -> mouse::Interaction {
2593        tree.children
2594            .first()
2595            .map_or(mouse::Interaction::None, |child| {
2596                self.content
2597                    .as_widget()
2598                    .mouse_interaction(child, layout, cursor, viewport, renderer)
2599            })
2600    }
2601
2602    fn overlay<'b>(
2603        &'b mut self,
2604        tree: &'b mut Tree,
2605        layout: Layout<'b>,
2606        renderer: &Renderer,
2607        viewport: &Rectangle,
2608        translation: Vector,
2609    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
2610        let child = tree.children.first_mut()?;
2611        self.content
2612            .as_widget_mut()
2613            .overlay(child, layout, renderer, viewport, translation)
2614    }
2615}
2616
2617impl<'a, Message: Clone + 'a> From<Focuser<'a, Message>> for Element<'a, Message> {
2618    fn from(focuser: Focuser<'a, Message>) -> Self {
2619        Element::new(focuser)
2620    }
2621}
2622
2623#[cfg(test)]
2624mod tests {
2625    use iced::keyboard::{Key, Modifiers};
2626
2627    use super::*;
2628    use crate::theme::{dark, light};
2629
2630    fn even(text: &str) -> Result<(), String> {
2631        if text.len().is_multiple_of(2) {
2632            Ok(())
2633        } else {
2634            Err("Needs an even length".to_owned())
2635        }
2636    }
2637
2638    /// ```text
2639    /// 1 Id (read-only, no category)
2640    /// Layout: 2 Width, 3 Height, 4 Visible
2641    /// Appearance: 5 Fill, 6 Blend, 7 Label, 8 Locked (disabled)
2642    /// ```
2643    fn sample() -> State<u32> {
2644        State::new([
2645            read_only(1, "Id", "rect-1"),
2646            number(2, "Width", 120.0)
2647                .category("Layout")
2648                .range(0.0, 400.0)
2649                .step(1.0)
2650                .unit("px")
2651                .default(100.0),
2652            number(3, "Height", 0.5)
2653                .category("Layout")
2654                .range(0.0, 1.0)
2655                .step(0.25),
2656            toggle(4, "Visible", true).category("Layout").default(true),
2657            colour(5, "Fill", Color::from_rgb8(0x3b, 0x82, 0xf6))
2658                .category("Appearance")
2659                .default(Color::WHITE),
2660            choice(6, "Blend", ["Normal", "Multiply", "Screen"], "Normal")
2661                .category("Appearance")
2662                .description("How the layer mixes with what is under it."),
2663            text(7, "Label", "Box")
2664                .category("Appearance")
2665                .validate(even),
2666            toggle(8, "Locked", false)
2667                .category("Appearance")
2668                .disabled(true)
2669                .default(true),
2670        ])
2671    }
2672
2673    fn property_entries(ids: &[u32]) -> Vec<Entry<u32>> {
2674        ids.iter().map(|&id| Entry::Property(id)).collect()
2675    }
2676
2677    fn category(name: &str) -> Entry<u32> {
2678        Entry::Category(name.to_owned())
2679    }
2680
2681    fn key(key: Key, modifiers: Modifiers) -> keys::Event {
2682        keys::Event { key, modifiers }
2683    }
2684
2685    fn named(named: Named) -> keys::Event {
2686        key(Key::Named(named), Modifiers::empty())
2687    }
2688
2689    fn changed(id: u32, value: impl Into<Value>) -> Option<Output<u32>> {
2690        Some(Output::Changed {
2691            id,
2692            value: value.into(),
2693        })
2694    }
2695
2696    #[test]
2697    fn lists_uncategorised_properties_first_then_each_category() {
2698        let state = sample();
2699        assert_eq!(state.categories(), ["Layout", "Appearance"]);
2700        let mut expected = vec![Entry::Property(1), category("Layout")];
2701        expected.extend(property_entries(&[2, 3, 4]));
2702        expected.push(category("Appearance"));
2703        expected.extend(property_entries(&[5, 6, 7, 8]));
2704        assert_eq!(state.entries(), expected);
2705        assert_eq!(state.count("Layout"), 3);
2706    }
2707
2708    #[test]
2709    fn alphabetical_lists_every_property_by_name() {
2710        let state = sample().with_sort(Sort::Alphabetical);
2711        assert_eq!(state.entries(), property_entries(&[6, 5, 3, 1, 7, 8, 4, 2]));
2712        assert_eq!(Sort::ALL.len(), 2);
2713        assert_eq!(Sort::default(), Sort::Categorised);
2714    }
2715
2716    #[test]
2717    fn a_repeated_id_replaces_the_earlier_property() {
2718        let state = State::new([text(1, "Old", "a"), text(1, "New", "b")]);
2719        assert_eq!(state.properties().len(), 1);
2720        assert_eq!(state.property(1).map(|p| p.name.as_str()), Some("New"));
2721        assert!(State::<u8>::new([]).entries().is_empty());
2722    }
2723
2724    #[test]
2725    fn the_filter_matches_names_and_drops_empty_categories() {
2726        let mut state = sample();
2727        let _ = state.update(Event::Press(Entry::Property(2)));
2728        let _ = state.update(Event::Search("FI".into()));
2729        assert_eq!(
2730            state.entries(),
2731            vec![category("Appearance"), Entry::Property(5)]
2732        );
2733        assert!(state.matches(5) && !state.matches(2));
2734        assert_eq!(state.count("Layout"), 0);
2735        assert_eq!(
2736            state.highlighted(),
2737            Some(&Entry::Property(5)),
2738            "a hidden highlight moves to the nearest match after it"
2739        );
2740        let _ = state.update(Event::Search("nothing".into()));
2741        assert!(state.entries().is_empty());
2742        assert_eq!(state.highlighted(), None);
2743    }
2744
2745    #[test]
2746    fn searching_keeps_the_highlight_on_the_nearest_shown_property() {
2747        let mut state = sample();
2748        let _ = state.update(Event::Press(Entry::Property(6)));
2749        let _ = state.update(Event::Search("ab".into()));
2750        assert_eq!(
2751            state.highlighted(),
2752            Some(&Entry::Property(7)),
2753            "Label follows Blend"
2754        );
2755        let _ = state.update(Event::Search("la".into()));
2756        assert_eq!(state.highlighted(), Some(&Entry::Property(7)), "stays put");
2757        let _ = state.update(Event::Search("wid".into()));
2758        assert_eq!(
2759            state.highlighted(),
2760            Some(&Entry::Property(2)),
2761            "the last before it when nothing follows"
2762        );
2763
2764        let mut fresh = sample();
2765        let _ = fresh.update(Event::Search("e".into()));
2766        assert_eq!(
2767            fresh.highlighted(),
2768            Some(&Entry::Property(3)),
2769            "typing highlights the first shown property"
2770        );
2771    }
2772
2773    #[test]
2774    fn stepping_through_matches_skips_headings_and_wraps() {
2775        let mut state = sample();
2776        let _ = state.update(Event::Search("e".into()));
2777        let mut visited = Vec::new();
2778        for _ in 0..5 {
2779            let _ = state.update(Event::NextMatch);
2780            visited.push(state.highlighted().cloned());
2781        }
2782        assert_eq!(
2783            visited,
2784            [4, 6, 7, 8, 3].map(|id| Some(Entry::Property(id))),
2785            "Height, Visible, Blend, Label and Locked, then back to Height"
2786        );
2787        let _ = state.update(Event::PreviousMatch);
2788        assert_eq!(state.highlighted(), Some(&Entry::Property(8)), "wraps back");
2789
2790        let mut none = sample();
2791        let _ = none.update(Event::Search("nothing".into()));
2792        let _ = none.update(Event::NextMatch);
2793        assert_eq!(none.highlighted(), None);
2794    }
2795
2796    #[test]
2797    fn pressing_a_heading_toggles_it_and_collapsing_lifts_the_highlight() {
2798        let mut state = sample();
2799        let _ = state.update(Event::Press(Entry::Property(3)));
2800        let _ = state.update(Event::Collapse("Layout".into()));
2801        assert!(!state.is_expanded("Layout"));
2802        assert_eq!(state.highlighted(), Some(&category("Layout")));
2803        assert!(!state.entries().contains(&Entry::Property(3)));
2804        let _ = state.update(Event::Press(category("Layout")));
2805        assert!(state.is_expanded("Layout"));
2806        let _ = state.update(Event::ToggleCategory("Layout".into()));
2807        assert!(!state.is_expanded("Layout"));
2808        let _ = state.update(Event::Expand("Layout".into()));
2809        assert!(state.is_expanded("Layout"));
2810        let _ = state.update(Event::Collapse("Missing".into()));
2811        assert!(
2812            state.is_expanded("Missing"),
2813            "unknown categories are ignored"
2814        );
2815    }
2816
2817    #[test]
2818    fn with_collapsed_starts_closed() {
2819        let state = sample().with_collapsed(["Appearance"]);
2820        assert!(!state.is_expanded("Appearance"));
2821        assert_eq!(state.entries().last(), Some(&category("Appearance")));
2822    }
2823
2824    #[test]
2825    fn arrows_walk_the_rows_without_wrapping() {
2826        let mut state = sample();
2827        let _ = state.update(Event::Next);
2828        assert_eq!(state.highlighted(), Some(&Entry::Property(1)));
2829        let _ = state.update(Event::Next);
2830        assert_eq!(state.highlighted(), Some(&category("Layout")));
2831        let _ = state.update(Event::Last);
2832        assert_eq!(state.highlighted(), Some(&Entry::Property(8)));
2833        let _ = state.update(Event::Next);
2834        assert_eq!(state.highlighted(), Some(&Entry::Property(8)));
2835        let _ = state.update(Event::First);
2836        let _ = state.update(Event::Previous);
2837        assert_eq!(state.highlighted(), Some(&Entry::Property(1)));
2838    }
2839
2840    #[test]
2841    fn right_opens_then_enters_and_left_closes_then_leaves() {
2842        let mut state = sample().with_collapsed(["Layout"]);
2843        let _ = state.update(Event::Press(category("Layout")));
2844        assert!(state.is_expanded("Layout"));
2845        let _ = state.update(Event::CollapseOrLeave);
2846        assert!(!state.is_expanded("Layout"));
2847        let _ = state.update(Event::ExpandOrEnter);
2848        assert!(state.is_expanded("Layout"));
2849        let _ = state.update(Event::ExpandOrEnter);
2850        assert_eq!(state.highlighted(), Some(&Entry::Property(2)));
2851        let _ = state.update(Event::CollapseOrLeave);
2852        assert_eq!(state.highlighted(), Some(&category("Layout")));
2853        assert!(state.is_expanded("Layout"), "leaving does not close");
2854    }
2855
2856    #[test]
2857    fn editing_text_starts_a_draft_and_asks_for_focus() {
2858        let mut state = sample();
2859        assert!(state.update(Event::Edit(2)).is_none());
2860        assert_eq!(state.editing(), Some(2));
2861        assert_eq!(state.draft(2).map(|d| d.text.as_str()), Some("120"));
2862        assert_eq!(state.highlighted(), Some(&Entry::Property(2)));
2863        assert_eq!(state.focus_request(), Some((1, Target::Editor)));
2864        let _ = state.update(Event::Edit(2));
2865        assert_eq!(state.focus_request(), Some((2, Target::Editor)));
2866    }
2867
2868    #[test]
2869    fn a_valid_edit_changes_the_value_as_it_is_typed() {
2870        let mut state = sample();
2871        let _ = state.update(Event::Edit(2));
2872        assert_eq!(state.update(Event::Input(2, "12".into())), changed(2, 12.0));
2873        assert_eq!(state.update(Event::Input(2, "12px".into())), None);
2874        assert_eq!(state.value(2), Some(&Value::Number(12.0)));
2875        let _ = state.update(Event::Submit);
2876        assert_eq!(state.editing(), None);
2877        assert!(state.draft(2).is_none());
2878    }
2879
2880    #[test]
2881    fn an_invalid_edit_keeps_its_draft_and_error() {
2882        let mut state = sample();
2883        let _ = state.update(Event::Edit(2));
2884        assert_eq!(state.update(Event::Input(2, "9000".into())), None);
2885        assert_eq!(state.error(2), Some("Must be at most 400px"));
2886        assert_eq!(state.value(2), Some(&Value::Number(120.0)));
2887        let _ = state.update(Event::Submit);
2888        assert_eq!(
2889            state.editing(),
2890            Some(2),
2891            "Enter does not leave an invalid edit"
2892        );
2893
2894        let _ = state.update(Event::Press(Entry::Property(3)));
2895        assert_eq!(state.editing(), None);
2896        assert_eq!(
2897            state.draft(2).map(|d| d.text.as_str()),
2898            Some("9000"),
2899            "the invalid draft stays after leaving"
2900        );
2901        assert_eq!(state.update(Event::Input(2, "40".into())), changed(2, 40.0));
2902        assert_eq!(state.editing(), Some(2), "typing again resumes the edit");
2903        assert_eq!(state.error(2), None);
2904    }
2905
2906    #[test]
2907    fn leaving_a_valid_edit_drops_its_draft() {
2908        let mut state = sample();
2909        let _ = state.update(Event::Edit(7));
2910        let _ = state.update(Event::Input(7, "Card".into()));
2911        let _ = state.update(Event::Focus(false));
2912        assert_eq!(state.editing(), None);
2913        assert!(state.draft(7).is_none());
2914        assert_eq!(state.value(7), Some(&Value::from("Card")));
2915    }
2916
2917    #[test]
2918    fn cancel_puts_back_the_value_from_before_the_edit() {
2919        let mut state = sample();
2920        let _ = state.update(Event::Edit(7));
2921        let _ = state.update(Event::Input(7, "Card".into()));
2922        let _ = state.update(Event::Input(7, "Car".into()));
2923        assert_eq!(state.error(7), Some("Needs an even length"));
2924        assert_eq!(state.update(Event::Cancel), changed(7, "Box"));
2925        assert_eq!(state.editing(), None);
2926        assert!(state.draft(7).is_none());
2927
2928        let _ = state.update(Event::Edit(7));
2929        assert_eq!(state.update(Event::Cancel), None, "nothing changed");
2930    }
2931
2932    #[test]
2933    fn cancel_without_an_edit_clears_the_search() {
2934        let mut state = sample();
2935        let _ = state.update(Event::Search("wid".into()));
2936        let _ = state.update(Event::Cancel);
2937        assert_eq!(state.query(), "");
2938    }
2939
2940    #[test]
2941    fn edit_flips_toggles_and_steps_choices() {
2942        let mut state = sample();
2943        assert_eq!(state.update(Event::Edit(4)), changed(4, false));
2944        assert_eq!(state.editing(), None);
2945        assert_eq!(state.update(Event::Edit(6)), changed(6, "Multiply"));
2946        let _ = state.update(Event::Edit(6));
2947        assert_eq!(
2948            state.update(Event::Edit(6)),
2949            changed(6, "Normal"),
2950            "wraps round"
2951        );
2952    }
2953
2954    #[test]
2955    fn read_only_and_disabled_properties_do_not_edit() {
2956        let mut state = sample();
2957        assert_eq!(state.update(Event::Edit(1)), None);
2958        assert_eq!(state.editing(), None);
2959        assert_eq!(state.update(Event::Edit(8)), None);
2960        assert_eq!(state.update(Event::Set(8, Value::Bool(true))), None);
2961        assert_eq!(state.update(Event::Input(8, "x".into())), None);
2962        assert_eq!(state.update(Event::Reset(8)), None);
2963        assert_eq!(state.highlighted(), Some(&Entry::Property(8)));
2964    }
2965
2966    #[test]
2967    fn set_accepts_only_values_that_suit_the_property() {
2968        let mut state = sample();
2969        assert_eq!(
2970            state.update(Event::Set(6, "Screen".into())),
2971            changed(6, "Screen")
2972        );
2973        assert_eq!(state.update(Event::Set(6, "Overlay".into())), None);
2974        assert_eq!(state.update(Event::Set(6, Value::Bool(true))), None);
2975        assert_eq!(
2976            state.update(Event::Set(4, Value::Bool(true))),
2977            None,
2978            "unchanged"
2979        );
2980        assert_eq!(state.update(Event::Set(99, Value::Bool(true))), None);
2981    }
2982
2983    #[test]
2984    fn the_menu_disables_reset_on_a_property_at_its_default() {
2985        let disabled = |menu: &context_menu::State<MenuItem, u32>, item| {
2986            menu.menu().item(item).is_some_and(|item| item.disabled)
2987        };
2988        let mut state = sample();
2989        let mut menu = menu();
2990        let _ = menu.update(context_menu::Event::OpenFromKeyboard(2));
2991        sync_menu(&mut menu, &state);
2992        assert!(state.can_reset(2));
2993        assert!(!disabled(&menu, MenuItem::Reset));
2994        assert!(!disabled(&menu, MenuItem::CopyValue));
2995
2996        let _ = state.update(Event::Reset(2));
2997        sync_menu(&mut menu, &state);
2998        assert!(!state.can_reset(2));
2999        assert!(disabled(&menu, MenuItem::Reset));
3000        assert!(!disabled(&menu, MenuItem::CopyValue));
3001        assert!(!state.can_reset(8), "disabled");
3002    }
3003
3004    #[test]
3005    fn reset_puts_back_the_default() {
3006        let mut state = sample();
3007        assert!(state.property(2).is_some_and(Property::is_modified));
3008        assert_eq!(
3009            state.update(Event::Reset(2)),
3010            Some(Output::Reset {
3011                id: 2,
3012                value: Value::Number(100.0)
3013            })
3014        );
3015        assert!(!state.property(2).is_some_and(Property::is_modified));
3016        assert_eq!(state.update(Event::Reset(2)), None, "already the default");
3017        assert_eq!(state.update(Event::Reset(3)), None, "no default");
3018    }
3019
3020    #[test]
3021    fn reset_drops_an_invalid_draft() {
3022        let mut state = sample();
3023        let _ = state.update(Event::Edit(2));
3024        let _ = state.update(Event::Input(2, "abc".into()));
3025        let _ = state.update(Event::Press(Entry::Property(2)));
3026        let _ = state.update(Event::ResetHighlighted);
3027        assert!(state.draft(2).is_none());
3028        assert_eq!(state.editing(), None);
3029        assert_eq!(state.value(2), Some(&Value::Number(100.0)));
3030    }
3031
3032    #[test]
3033    fn up_and_down_step_the_number_being_edited() {
3034        let mut state = sample();
3035        let _ = state.update(Event::Edit(3));
3036        assert_eq!(state.update(Event::Increment), changed(3, 0.75));
3037        assert_eq!(state.update(Event::Increment), changed(3, 1.0));
3038        assert_eq!(state.update(Event::Increment), None, "clamped to the max");
3039        assert_eq!(state.draft(3).map(|d| d.text.as_str()), Some("1.00"));
3040        assert_eq!(state.update(Event::Decrement), changed(3, 0.75));
3041        let _ = state.update(Event::Submit);
3042        assert_eq!(state.update(Event::Increment), None, "only while editing");
3043    }
3044
3045    #[test]
3046    fn numbers_parse_with_their_unit_and_limits() {
3047        let number = Number {
3048            min: Some(0.0),
3049            max: Some(100.0),
3050            step: Some(5.0),
3051            decimals: None,
3052            unit: Some("%".into()),
3053        };
3054        assert_eq!(number.parse(" 25% "), Ok(25.0));
3055        assert_eq!(number.parse("25"), Ok(25.0));
3056        assert_eq!(number.parse("-5"), Err("Must be at least 0%".into()));
3057        assert_eq!(number.parse("105"), Err("Must be at most 100%".into()));
3058        assert_eq!(number.parse("12"), Err("Must be a multiple of 5".into()));
3059        assert_eq!(number.parse("abc"), Err("Enter a number".into()));
3060        assert_eq!(number.parse("inf"), Err("Enter a number".into()));
3061        assert_eq!(number.display(25.0), "25%");
3062        assert_eq!(number.stepped(98.0, true), 100.0);
3063
3064        let whole = Number {
3065            step: Some(1.0),
3066            ..Number::default()
3067        };
3068        assert_eq!(whole.parse("1.5"), Err("Must be a whole number".into()));
3069        let fine = Number {
3070            step: Some(0.1),
3071            ..Number::default()
3072        };
3073        assert_eq!(
3074            fine.parse("0.15"),
3075            Ok(0.15),
3076            "fractional steps do not validate"
3077        );
3078        assert_eq!(fine.format(0.15), "0.1");
3079        assert_eq!(fine.stepped(0.2, true), 0.3);
3080        assert_eq!(Number::default().format(-0.0), "0");
3081        assert_eq!(Number::default().format(2.5), "2.5");
3082        let fixed = Number {
3083            decimals: Some(2),
3084            ..Number::default()
3085        };
3086        assert_eq!(fixed.format(1.0), "1.00");
3087    }
3088
3089    #[test]
3090    fn hex_colours_round_trip() {
3091        let blue = Color::from_rgb8(0x3b, 0x82, 0xf6);
3092        assert_eq!(hex(blue), "#3B82F6");
3093        assert_eq!(parse_hex("#3b82f6"), Ok(blue));
3094        assert_eq!(parse_hex("3B82F6"), Ok(blue));
3095        assert_eq!(parse_hex("#fff"), Ok(Color::WHITE));
3096        assert_eq!(parse_hex(" #000 "), Ok(Color::BLACK));
3097        let faded = parse_hex("#00000080").map(hex);
3098        assert_eq!(faded, Ok("#00000080".to_owned()));
3099        assert_eq!(parse_hex("#0008").map(hex), Ok("#00000088".to_owned()));
3100        for bad in ["", "#", "#12", "#12345", "#ggg", "blue", "#3B82F6F6F6"] {
3101            assert!(parse_hex(bad).is_err(), "{bad}");
3102        }
3103    }
3104
3105    #[test]
3106    fn colour_edits_parse_hex() {
3107        let mut state = sample();
3108        let _ = state.update(Event::Edit(5));
3109        assert_eq!(state.draft(5).map(|d| d.text.as_str()), Some("#3B82F6"));
3110        assert_eq!(state.update(Event::Input(5, "#zz".into())), None);
3111        assert!(state.error(5).is_some());
3112        assert_eq!(
3113            state.update(Event::Input(5, "#fff".into())),
3114            changed(5, Color::WHITE)
3115        );
3116        assert!(!state.property(5).is_some_and(Property::is_modified));
3117    }
3118
3119    #[test]
3120    fn property_text_and_parsing_follow_the_kind() {
3121        let state = sample();
3122        let show = |id| {
3123            state
3124                .property(id)
3125                .map(Property::display)
3126                .unwrap_or_default()
3127        };
3128        assert_eq!(show(2), "120px");
3129        assert_eq!(show(3), "0.50");
3130        assert_eq!(show(4), "On");
3131        assert_eq!(show(5), "#3B82F6");
3132        assert_eq!(
3133            state.property(2).map(Property::edit_text),
3134            Some("120".into())
3135        );
3136        let blend = state.property(6).map(|p| p.parse("Screen"));
3137        assert_eq!(blend, Some(Ok(Value::from("Screen"))));
3138        assert!(state.property(6).is_some_and(|p| p.parse("Nope").is_err()));
3139        assert!(state.property(1).is_some_and(|p| !p.is_editable()));
3140        assert!(state.property(8).is_some_and(|p| !p.is_editable()));
3141        assert!(state.property(4).is_some_and(|p| !p.is_typed()));
3142        let shown = custom::<u8>(1, "Opacity", 0.5);
3143        assert_eq!(shown.display(), "0.5");
3144        assert!(shown.accepts(&Value::Bool(true)));
3145        assert_eq!(Toggle::ALL.len(), 2);
3146    }
3147
3148    #[test]
3149    fn number_settings_only_apply_to_numbers() {
3150        let label = text::<u8>(1, "Label", "a").min(1.0).unit("px").switch();
3151        assert!(matches!(label.kind, Kind::Text(None)));
3152        let flag = toggle::<u8>(2, "Flag", true).switch();
3153        assert!(matches!(flag.kind, Kind::Toggle(Toggle::Switch)));
3154        let size = number::<u8>(3, "Size", 1.0).decimals(1).validate(even);
3155        assert!(matches!(
3156            size.kind,
3157            Kind::Number(Number {
3158                decimals: Some(1),
3159                ..
3160            })
3161        ));
3162    }
3163
3164    #[test]
3165    fn values_convert_and_read_back() {
3166        assert_eq!(Value::from(2).as_number(), Some(2.0));
3167        assert_eq!(Value::from(0.5f32).as_number(), Some(0.5));
3168        assert_eq!(Value::from("a").as_text(), Some("a"));
3169        assert_eq!(Value::from(String::from("b")).as_text(), Some("b"));
3170        assert_eq!(Value::from(true).as_bool(), Some(true));
3171        assert_eq!(Value::from(Color::BLACK).as_colour(), Some(Color::BLACK));
3172        assert_eq!(Value::from(true).as_text(), None);
3173        assert_eq!(Value::from("a").as_number(), None);
3174        assert_eq!(Value::from("a").as_bool(), None);
3175        assert_eq!(Value::from("a").as_colour(), None);
3176    }
3177
3178    #[test]
3179    fn set_from_the_app_drops_the_edit() {
3180        let mut state = sample();
3181        let _ = state.update(Event::Edit(7));
3182        assert!(state.set(7, "Card"));
3183        assert_eq!(state.editing(), None);
3184        assert!(state.draft(7).is_none());
3185        assert!(!state.set(7, "Card"));
3186        assert!(!state.set(99, "Card"));
3187    }
3188
3189    #[test]
3190    fn the_name_column_drags_only_while_resizing() {
3191        let mut state = sample().with_name_width(10.0);
3192        assert_eq!(state.name_width(), Some(MIN_NAME_WIDTH));
3193        let _ = state.update(Event::Resize(150.0));
3194        assert_eq!(state.name_width(), Some(MIN_NAME_WIDTH));
3195        let _ = state.update(Event::ResizeStart);
3196        assert!(state.is_resizing());
3197        let _ = state.update(Event::Resize(150.0));
3198        assert_eq!(state.name_width(), Some(150.0));
3199        let _ = state.update(Event::Resize(2.0));
3200        assert_eq!(state.name_width(), Some(MIN_NAME_WIDTH));
3201        let _ = state.update(Event::ResizeEnd);
3202        assert!(!state.is_resizing());
3203    }
3204
3205    #[test]
3206    fn the_name_column_keeps_room_for_values() {
3207        assert_eq!(name_width(None, 600.0), NAME_WIDTH);
3208        let narrow = name_width(None, 260.0);
3209        let room = 260.0 - HANDLE_WIDTH - RESET_WIDTH;
3210        assert_eq!(narrow, room - VALUE_WIDTH, "the values give way first");
3211        assert!(narrow > room * 0.45, "names keep a fair share");
3212        assert_eq!(
3213            name_width(None, 300.0 + NAME_WIDTH),
3214            NAME_WIDTH,
3215            "values only give way when room runs short"
3216        );
3217        assert_eq!(
3218            name_width(Some(500.0), 300.0),
3219            300.0 - HANDLE_WIDTH - RESET_WIDTH - MIN_VALUE_WIDTH
3220        );
3221        assert_eq!(name_width(Some(10.0), 300.0), MIN_NAME_WIDTH);
3222        assert_eq!(
3223            name_width(None, 100.0),
3224            MIN_NAME_WIDTH,
3225            "never below the minimum"
3226        );
3227    }
3228
3229    #[test]
3230    fn focus_highlights_the_first_property_and_losing_it_ends_edits() {
3231        let mut state = sample();
3232        let _ = state.update(Event::Focus(true));
3233        assert!(state.is_focused());
3234        assert_eq!(state.highlighted(), Some(&Entry::Property(1)));
3235        let _ = state.update(Event::Edit(2));
3236        let _ = state.update(Event::ResizeStart);
3237        let _ = state.update(Event::Focus(false));
3238        assert_eq!(state.editing(), None);
3239        assert!(!state.is_resizing());
3240    }
3241
3242    #[test]
3243    fn focus_search_asks_for_the_filter_box() {
3244        let mut state = sample();
3245        let _ = state.update(Event::Edit(7));
3246        let _ = state.update(Event::FocusSearch);
3247        assert_eq!(state.editing(), None);
3248        assert_eq!(state.focus_request(), Some((2, Target::Filter)));
3249        let _ = state.update(Event::Sort(Sort::Alphabetical));
3250        assert_eq!(state.sort(), Sort::Alphabetical);
3251    }
3252
3253    #[test]
3254    fn copying_gives_the_value_as_shown_and_highlights_it() {
3255        let mut state = sample();
3256        assert_eq!(
3257            state.update(Event::CopyValue(2)),
3258            Some(Output::CopyValue("120px".into()))
3259        );
3260        assert_eq!(state.highlighted(), Some(&Entry::Property(2)));
3261        assert_eq!(
3262            state.update(Event::CopyValue(5)),
3263            Some(Output::CopyValue("#3B82F6".into()))
3264        );
3265        assert_eq!(state.update(Event::CopyValue(99)), None);
3266    }
3267
3268    #[test]
3269    fn menu_items_map_to_events() {
3270        assert_eq!(MenuItem::ALL.len(), 2);
3271        assert_eq!(MenuItem::CopyValue.event(4), Event::CopyValue(4));
3272        assert_eq!(MenuItem::Reset.event(4), Event::Reset(4));
3273        assert!(!menu::<u32>().is_open());
3274    }
3275
3276    #[test]
3277    fn the_match_label_counts_what_passes_the_search() {
3278        let mut state = sample();
3279        assert_eq!(match_label(&state), "");
3280        let _ = state.update(Event::Search("i".into()));
3281        assert_eq!(
3282            match_label(&state),
3283            "1 of 5",
3284            "Id, Width, Height, Visible, Fill, with Id highlighted"
3285        );
3286        let _ = state.update(Event::NextMatch);
3287        assert_eq!(match_label(&state), "2 of 5");
3288        let _ = state.update(Event::PreviousMatch);
3289        let _ = state.update(Event::PreviousMatch);
3290        assert_eq!(match_label(&state), "5 of 5", "wraps to Fill");
3291        let _ = state.update(Event::ToggleCategory("Layout".into()));
3292        assert_eq!(
3293            match_label(&state),
3294            "2 of 2",
3295            "a closed category's properties are not shown"
3296        );
3297        let _ = state.update(Event::Search("zebra".into()));
3298        assert_eq!(match_label(&state), "None");
3299    }
3300
3301    #[test]
3302    fn default_keymap_resolves_each_action() {
3303        let keymap = default_keymap();
3304        let cases = [
3305            (named(Named::ArrowDown), Action::Next),
3306            (named(Named::ArrowUp), Action::Previous),
3307            (named(Named::Home), Action::First),
3308            (named(Named::End), Action::Last),
3309            (named(Named::ArrowRight), Action::Expand),
3310            (named(Named::ArrowLeft), Action::Collapse),
3311            (named(Named::Enter), Action::Edit),
3312            (named(Named::F2), Action::Edit),
3313            (named(Named::Escape), Action::Cancel),
3314            (named(Named::Delete), Action::Reset),
3315            (
3316                key(Key::Character("c".into()), Modifiers::COMMAND),
3317                Action::CopyValue,
3318            ),
3319            (
3320                key(Key::Character("f".into()), Modifiers::COMMAND),
3321                Action::FocusSearch,
3322            ),
3323        ];
3324        for (press, action) in cases {
3325            assert_eq!(keymap.resolve_event(&press), Some(action), "{press:?}");
3326        }
3327        for action in <Action as keys::Action>::ALL {
3328            assert!(!keymap.chords(action).is_empty(), "{action:?} unbound");
3329            assert!(keys::Action::description(*action).ends_with('.'));
3330            assert!(!keys::Action::name(*action).is_empty());
3331        }
3332    }
3333
3334    #[test]
3335    fn keymaps_can_be_changed() {
3336        let keymap = default_keymap()
3337            .bind(Chord::character('j'), Action::Next)
3338            .unbind(&Chord::named(Named::Delete));
3339        let mut state = sample();
3340        let _ = state.update(Event::Focus(true));
3341        let j = key(Key::Character("j".into()), Modifiers::empty());
3342        assert_eq!(state.key_event(&keymap, &j), Some(Event::Next));
3343        let _ = state.update(Event::Press(Entry::Property(2)));
3344        assert_eq!(state.key_event(&keymap, &named(Named::Delete)), None);
3345        assert_eq!(
3346            state.key_event(&default_keymap(), &named(Named::Delete)),
3347            Some(Event::ResetHighlighted)
3348        );
3349    }
3350
3351    #[test]
3352    fn actions_leave_keys_they_cannot_use() {
3353        let mut state = sample();
3354        assert_eq!(Action::Edit.event(&state), None, "nothing highlighted");
3355        assert_eq!(Action::Cancel.event(&state), None);
3356        assert_eq!(Action::FocusSearch.event(&state), Some(Event::FocusSearch));
3357        assert_eq!(Action::CopyValue.event(&state), None);
3358
3359        let _ = state.update(Event::Press(Entry::Property(1)));
3360        assert_eq!(Action::CopyValue.event(&state), Some(Event::CopyValue(1)));
3361        assert_eq!(Action::Edit.event(&state), None, "read-only");
3362        assert_eq!(Action::Expand.event(&state), None);
3363        assert_eq!(Action::Collapse.event(&state), None, "no category");
3364        assert_eq!(Action::Reset.event(&state), None);
3365
3366        let _ = state.update(Event::Press(Entry::Property(3)));
3367        assert_eq!(Action::Collapse.event(&state), Some(Event::CollapseOrLeave));
3368        assert_eq!(Action::Reset.event(&state), None, "no default");
3369        assert_eq!(Action::Edit.event(&state), Some(Event::EditHighlighted));
3370        let _ = state.update(Event::EditHighlighted);
3371        assert_eq!(Action::Next.event(&state), Some(Event::Decrement));
3372        assert_eq!(Action::Previous.event(&state), Some(Event::Increment));
3373        assert_eq!(Action::Cancel.event(&state), Some(Event::Cancel));
3374
3375        let _ = state.update(Event::Press(Entry::Property(2)));
3376        assert_eq!(Action::Next.event(&state), Some(Event::Next));
3377        assert_eq!(Action::Reset.event(&state), Some(Event::ResetHighlighted));
3378
3379        let _ = state.update(Event::Press(category("Layout")));
3380        assert_eq!(Action::Expand.event(&state), Some(Event::ExpandOrEnter));
3381        assert_eq!(Action::Edit.event(&state), Some(Event::EditHighlighted));
3382        let _ = state.update(Event::EditHighlighted);
3383        assert!(state.is_expanded("Layout"), "Enter toggles a heading");
3384
3385        let mut alphabetical = sample().with_sort(Sort::Alphabetical);
3386        let _ = alphabetical.update(Event::Press(Entry::Property(3)));
3387        assert_eq!(Action::Collapse.event(&alphabetical), None);
3388    }
3389
3390    #[test]
3391    fn rows_resolve_their_colours_in_both_themes() {
3392        for theme in [light(), dark()] {
3393            let tokens = Tokens::of(&theme);
3394            let idle = row_style(&tokens, Status::default());
3395            assert_eq!(idle.background, None);
3396            assert_eq!(idle.text, tokens.foreground);
3397            assert_eq!(idle.ring, None);
3398
3399            let highlighted = Status {
3400                highlighted: true,
3401                ..Status::default()
3402            };
3403            assert_eq!(
3404                row_style(&tokens, highlighted).background,
3405                None,
3406                "nothing is selected, so the highlight has no fill"
3407            );
3408            assert_eq!(
3409                row_style(&tokens, highlighted).ring,
3410                None,
3411                "no ring unfocused"
3412            );
3413            let focused = Status {
3414                focused: true,
3415                ..highlighted
3416            };
3417            assert_eq!(row_style(&tokens, focused).ring, Some(tokens.ring));
3418            let focused_heading = Status {
3419                heading: true,
3420                ..focused
3421            };
3422            assert_eq!(
3423                row_style(&tokens, focused_heading).background,
3424                Some(fade(tokens.muted, 0.6)),
3425                "a highlighted heading keeps its band and adds the ring"
3426            );
3427            assert_eq!(row_style(&tokens, focused_heading).ring, Some(tokens.ring));
3428
3429            let hovered = Status {
3430                hovered: true,
3431                ..Status::default()
3432            };
3433            assert_eq!(
3434                row_style(&tokens, hovered).background,
3435                Some(fade(tokens.accent, 0.6))
3436            );
3437            let disabled = Status {
3438                disabled: true,
3439                ..hovered
3440            };
3441            assert_eq!(row_style(&tokens, disabled).background, None);
3442            assert_eq!(row_style(&tokens, disabled).text, tokens.muted_foreground);
3443
3444            let heading = Status {
3445                heading: true,
3446                ..Status::default()
3447            };
3448            assert_eq!(
3449                row_style(&tokens, heading).background,
3450                Some(fade(tokens.muted, 0.6))
3451            );
3452            assert_eq!(
3453                row_style(
3454                    &tokens,
3455                    Status {
3456                        hovered: true,
3457                        ..heading
3458                    }
3459                )
3460                .background,
3461                Some(tokens.muted)
3462            );
3463
3464            assert_eq!(value_colour(&tokens, true), tokens.foreground);
3465            assert_eq!(value_colour(&tokens, false), tokens.muted_foreground);
3466            assert_eq!(mark_colour(&tokens), tokens.primary);
3467            assert_eq!(divider_colour(&tokens, true), tokens.ring);
3468            assert_ne!(divider_colour(&tokens, false), tokens.ring);
3469            assert_eq!(frame_style(&tokens).border.color, tokens.border);
3470            let swatch = swatch_style(&tokens, Color::WHITE);
3471            assert_eq!(swatch.background, Some(Background::Color(Color::WHITE)));
3472            assert_eq!(swatch.border.color, swatch_ring(&tokens));
3473        }
3474    }
3475
3476    #[test]
3477    fn swatches_keep_a_visible_ring_on_a_dark_background() {
3478        let tokens = Tokens::of(&dark());
3479        let ring = swatch_ring(&tokens);
3480        let near_black = Color::from_rgb8(0x18, 0x18, 0x1b);
3481        let lift = |colour: Color| colour.r + colour.g + colour.b;
3482        assert!(
3483            lift(ring) - lift(near_black) > 0.6,
3484            "{ring:?} stands out from {near_black:?}"
3485        );
3486        assert!(lift(ring) > lift(tokens.border), "stronger than the border");
3487    }
3488
3489    #[test]
3490    fn the_builder_starts_with_the_toolbar_and_description() {
3491        let state = sample();
3492        let grid: PropertyGrid<'_, u32, ()> = property_grid(&state);
3493        assert!(grid.toolbar && grid.description);
3494        assert!(!grid.is_enabled());
3495        assert_eq!(grid.height, Length::Shrink);
3496        let grid = grid
3497            .toolbar(false)
3498            .description(false)
3499            .placeholder("Search")
3500            .height(200)
3501            .keymap(Keymap::new())
3502            .custom(1, |_| Space::new().into())
3503            .custom(1, |_| Space::new().into())
3504            .on_event(|_| ());
3505        assert!(grid.is_enabled());
3506        assert_eq!(
3507            grid.customs.len(),
3508            1,
3509            "a second renderer replaces the first"
3510        );
3511        assert!(format!("{grid:?}").contains("Search"));
3512    }
3513}