1use 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
53pub const ROW_HEIGHT: f32 = 36.0;
55pub const NAME_WIDTH: f32 = 160.0;
57pub const MIN_NAME_WIDTH: f32 = 64.0;
59pub const MIN_VALUE_WIDTH: f32 = 88.0;
61pub const VALUE_WIDTH: f32 = 112.0;
64pub const HANDLE_WIDTH: f32 = 9.0;
66pub const RESET_WIDTH: f32 = 24.0;
68pub const DESCRIPTION_HEIGHT: f32 = 64.0;
71pub const SEARCH: widget::Id = widget::Id::new("iced-cube-property-grid-filter");
73pub const EDITOR_ID: widget::Id = widget::Id::new("iced-cube-property-grid-editor");
75
76pub const NOTHING_SELECTED: &str = "Select a property to see its description.";
78
79const 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#[derive(Debug, Clone, PartialEq)]
88pub enum Value {
89 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#[derive(Debug, Clone, PartialEq, Default)]
170pub struct Number {
171 pub min: Option<f64>,
172 pub max: Option<f64>,
173 pub step: Option<f64>,
176 pub decimals: Option<usize>,
179 pub unit: Option<String>,
181}
182
183impl Number {
184 pub fn precision(&self) -> Option<usize> {
186 self.decimals.or_else(|| self.step.map(decimals_of))
187 }
188
189 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 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 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 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 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
260fn decimals_of(step: f64) -> usize {
262 let text = format!("{step}");
263 text.split_once('.')
264 .map_or(0, |(_, fraction)| fraction.len())
265}
266
267pub 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
277pub 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
307pub type Validate = fn(&str) -> Result<(), String>;
309
310#[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#[derive(Debug, Clone)]
324pub enum Kind {
325 Text(Option<Validate>),
327 Number(Number),
328 Toggle(Toggle),
329 Choice(Vec<String>),
331 Colour,
333 ReadOnly,
335 Custom,
337}
338
339#[derive(Debug, Clone)]
341pub struct Property<Id> {
342 pub id: Id,
343 pub name: String,
344 pub category: Option<String>,
346 pub description: Option<String>,
348 pub kind: Kind,
349 pub value: Value,
350 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
368pub 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
373pub 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
385pub 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
391pub 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
406pub fn colour<Id>(id: Id, name: impl Into<String>, value: Color) -> Property<Id> {
408 property(id, name, Kind::Colour, Value::Colour(value))
409}
410
411pub 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
416pub 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 pub fn default(mut self, value: impl Into<Value>) -> Self {
436 self.default = Some(value.into());
437 self
438 }
439
440 pub fn disabled(mut self, disabled: bool) -> Self {
442 self.disabled = disabled;
443 self
444 }
445
446 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 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 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 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 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 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 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 pub fn is_modified(&self) -> bool {
509 self.default
510 .as_ref()
511 .is_some_and(|default| *default != self.value)
512 }
513
514 pub fn is_typed(&self) -> bool {
516 matches!(self.kind, Kind::Text(_) | Kind::Number(_) | Kind::Colour)
517 }
518
519 pub fn is_editable(&self) -> bool {
521 !self.disabled && !matches!(self.kind, Kind::ReadOnly | Kind::Custom)
522 }
523
524 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
580pub enum Sort {
581 #[default]
584 Categorised,
585 Alphabetical,
587}
588
589impl Sort {
590 pub const ALL: [Sort; 2] = [Sort::Categorised, Sort::Alphabetical];
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Hash)]
595pub enum Entry<Id> {
596 Category(String),
598 Property(Id),
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Default)]
603pub struct Draft {
604 pub text: String,
605 pub error: Option<String>,
606}
607
608#[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 original: Value,
620}
621
622#[derive(Debug, Clone, PartialEq)]
624pub enum Event<Id> {
625 Press(Entry<Id>),
627 ToggleCategory(String),
628 Expand(String),
629 Collapse(String),
630 Next,
631 Previous,
632 First,
633 Last,
634 ExpandOrEnter,
636 CollapseOrLeave,
639 Edit(Id),
642 EditHighlighted,
645 Input(Id, String),
647 Submit,
649 Cancel,
652 Increment,
654 Decrement,
656 Set(Id, Value),
658 Reset(Id),
660 ResetHighlighted,
661 Search(String),
663 NextMatch,
665 PreviousMatch,
668 FocusSearch,
670 CopyValue(Id),
672 Sort(Sort),
673 ResizeStart,
675 Resize(f32),
677 ResizeEnd,
678 Focus(bool),
680}
681
682#[derive(Debug, Clone, PartialEq)]
684pub enum Output<Id> {
685 Changed { id: Id, value: Value },
687 Reset { id: Id, value: Value },
689 CopyValue(String),
692}
693
694#[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 focus: (u64, Option<Target>),
712}
713
714impl<Id: Copy + Eq + Hash> State<Id> {
715 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 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 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 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 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 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 pub fn query(&self) -> &str {
835 &self.query
836 }
837
838 pub fn highlighted(&self) -> Option<&Entry<Id>> {
840 self.highlighted.as_ref()
841 }
842
843 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 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 pub fn draft(&self, id: Id) -> Option<&Draft> {
864 self.drafts.get(&id)
865 }
866
867 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 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 pub fn is_resizing(&self) -> bool {
890 self.resizing
891 }
892
893 pub fn name_width(&self) -> Option<f32> {
895 self.name_width
896 }
897
898 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 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 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 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 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 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 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 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 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 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 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 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#[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 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
1544pub 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
1582pub fn focus_search<Id, Message>(_state: &State<Id>) -> Task<Message> {
1586 widget::operation::focus(SEARCH)
1587}
1588
1589pub 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#[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 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 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
1630pub 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
1641pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1658pub struct Status {
1659 pub highlighted: bool,
1661 pub focused: bool,
1663 pub hovered: bool,
1664 pub disabled: bool,
1665 pub heading: bool,
1667}
1668
1669#[derive(Debug, Clone, Copy, PartialEq)]
1671pub struct RowStyle {
1672 pub background: Option<Color>,
1673 pub text: Color,
1674 pub icon: Color,
1675 pub ring: Option<Color>,
1677}
1678
1679pub 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
1703pub fn value_colour(tokens: &Tokens, editable: bool) -> Color {
1705 if editable {
1706 tokens.foreground
1707 } else {
1708 tokens.muted_foreground
1709 }
1710}
1711
1712pub fn mark_colour(tokens: &Tokens) -> Color {
1714 tokens.primary
1715}
1716
1717pub 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
1727pub 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
1739pub 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
1749pub 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
1762pub 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
1779pub 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
1794pub 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 pub fn toolbar(mut self, toolbar: bool) -> Self {
1830 self.toolbar = toolbar;
1831 self
1832 }
1833
1834 pub fn description(mut self, description: bool) -> Self {
1837 self.description = description;
1838 self
1839 }
1840
1841 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 pub fn height(mut self, height: impl Into<Length>) -> Self {
1855 self.height = height.into();
1856 self
1857 }
1858
1859 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 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 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 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
1958struct 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 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 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 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 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 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 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
2464struct Focuser<'a, Message> {
2467 content: Element<'a, Message>,
2468 request: Option<(u64, widget::Id, bool)>,
2469}
2470
2471#[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 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 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}