1use std::{collections::BTreeMap, fmt};
10
11use rustc_hash::FxHashSet;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::{
15 engine::{ExpandDepth, SortDirection},
16 model::{ColumnId, OwnedValue},
17 state::ColumnZone,
18};
19
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184#[non_exhaustive]
185pub struct GridLayout {
186 version: u32,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 view_id: Option<String>,
189 columns: Vec<LayoutColumn>,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 sort: Vec<LayoutSort>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 group_by: Vec<LayoutGroup>,
194 #[serde(default, skip_serializing_if = "LayoutExpansion::is_unwritten")]
195 expansion: LayoutExpansion,
196}
197
198impl GridLayout {
199 pub const VERSION: u32 = 1;
201
202 pub fn new() -> Self {
204 Self {
205 version: Self::VERSION,
206 view_id: None,
207 columns: Vec::new(),
208 sort: Vec::new(),
209 group_by: Vec::new(),
210 expansion: LayoutExpansion::default(),
211 }
212 }
213
214 pub fn with_view_id(mut self, view_id: impl Into<String>) -> Self {
217 self.view_id = Some(view_id.into());
218 self
219 }
220
221 pub fn with_column(mut self, column: LayoutColumn) -> Self {
223 self.columns.push(column);
224 self
225 }
226
227 pub fn with_sort(mut self, sort: impl IntoIterator<Item = LayoutSort>) -> Self {
229 self.sort = sort.into_iter().collect();
230 self
231 }
232
233 pub fn with_group_by(mut self, levels: impl IntoIterator<Item = LayoutGroup>) -> Self {
236 self.group_by = levels.into_iter().collect();
237 self
238 }
239
240 pub fn with_expansion(mut self, expansion: LayoutExpansion) -> Self {
243 self.expansion = expansion;
244 self
245 }
246
247 pub fn version(&self) -> u32 {
249 self.version
250 }
251
252 pub fn view_id(&self) -> Option<&str> {
254 self.view_id.as_deref()
255 }
256
257 pub fn columns(&self) -> &[LayoutColumn] {
260 &self.columns
261 }
262
263 pub fn column(&self, id: &str) -> Option<&LayoutColumn> {
265 self.columns.iter().find(|column| column.id.as_str() == id)
266 }
267
268 pub fn sort(&self) -> &[LayoutSort] {
270 &self.sort
271 }
272
273 pub fn group_by(&self) -> &[LayoutGroup] {
276 &self.group_by
277 }
278
279 pub fn expansion(&self) -> Option<&LayoutExpansion> {
282 (!self.expansion.is_default()).then_some(&self.expansion)
283 }
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
288#[non_exhaustive]
289pub struct LayoutColumn {
290 id: ColumnId,
291 #[serde(deserialize_with = "deserialize_width")]
292 width: f32,
293 #[serde(default = "default_visible", skip_serializing_if = "is_visible")]
294 visible: bool,
295 #[serde(
296 rename = "frozen",
297 default,
298 skip_serializing_if = "is_scrolling",
299 with = "frozen"
300 )]
301 zone: ColumnZone,
302}
303
304impl LayoutColumn {
305 pub fn new(id: impl Into<ColumnId>, width: impl Into<f32>) -> Self {
309 Self {
310 id: id.into(),
311 width: width.into(),
312 visible: true,
313 zone: ColumnZone::Scrolling,
314 }
315 }
316
317 pub fn with_width(mut self, width: impl Into<f32>) -> Self {
320 self.width = width.into();
321 self
322 }
323
324 pub fn with_visible(mut self, visible: bool) -> Self {
326 self.visible = visible;
327 self
328 }
329
330 pub fn with_zone(mut self, zone: ColumnZone) -> Self {
332 self.zone = zone;
333 self
334 }
335
336 pub fn id(&self) -> &ColumnId {
338 &self.id
339 }
340
341 pub fn width(&self) -> f32 {
343 self.width
344 }
345
346 pub fn is_visible(&self) -> bool {
348 self.visible
349 }
350
351 pub fn zone(&self) -> ColumnZone {
353 self.zone
354 }
355}
356
357fn default_visible() -> bool {
358 true
359}
360
361fn is_visible(visible: &bool) -> bool {
362 *visible
363}
364
365fn is_scrolling(zone: &ColumnZone) -> bool {
366 *zone == ColumnZone::Scrolling
367}
368
369fn deserialize_width<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f32, D::Error> {
373 Ok(Option::<f32>::deserialize(deserializer)?.unwrap_or(f32::NAN))
374}
375
376mod frozen {
380 use serde::{Deserialize, Deserializer, Serialize, Serializer};
381
382 use crate::state::ColumnZone;
383
384 #[derive(Serialize, Deserialize)]
385 #[serde(rename_all = "lowercase")]
386 enum FrozenSide {
387 Left,
388 Right,
389 }
390
391 pub(super) fn serialize<S: Serializer>(
392 zone: &ColumnZone,
393 serializer: S,
394 ) -> Result<S::Ok, S::Error> {
395 match zone {
396 ColumnZone::FrozenLeft => Some(FrozenSide::Left),
397 ColumnZone::Scrolling => None,
398 ColumnZone::FrozenRight => Some(FrozenSide::Right),
399 }
400 .serialize(serializer)
401 }
402
403 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
404 deserializer: D,
405 ) -> Result<ColumnZone, D::Error> {
406 Ok(match Option::<FrozenSide>::deserialize(deserializer)? {
407 Some(FrozenSide::Left) => ColumnZone::FrozenLeft,
408 None => ColumnZone::Scrolling,
409 Some(FrozenSide::Right) => ColumnZone::FrozenRight,
410 })
411 }
412}
413
414#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416pub struct LayoutSort {
417 column: ColumnId,
418 direction: SortDirection,
419}
420
421impl LayoutSort {
422 pub fn new(column: impl Into<ColumnId>, direction: SortDirection) -> Self {
424 Self {
425 column: column.into(),
426 direction,
427 }
428 }
429
430 pub fn column(&self) -> &ColumnId {
432 &self.column
433 }
434
435 pub fn direction(&self) -> SortDirection {
437 self.direction
438 }
439}
440
441#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
448#[non_exhaustive]
449pub struct LayoutGroup {
450 column: ColumnId,
451 direction: SortDirection,
452}
453
454impl LayoutGroup {
455 pub fn new(column: impl Into<ColumnId>, direction: SortDirection) -> Self {
457 Self {
458 column: column.into(),
459 direction,
460 }
461 }
462
463 pub fn column(&self) -> &ColumnId {
465 &self.column
466 }
467
468 pub fn direction(&self) -> SortDirection {
470 self.direction
471 }
472}
473
474#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
492#[non_exhaustive]
493pub struct LayoutExpansion {
494 #[serde(default)]
495 depth: ExpandDepth,
496 #[serde(default, skip_serializing_if = "TogglePaths::is_unwritten")]
497 toggled: TogglePaths,
498}
499
500impl LayoutExpansion {
501 pub fn new(depth: ExpandDepth) -> Self {
503 Self {
504 depth,
505 toggled: TogglePaths::default(),
506 }
507 }
508
509 pub fn with_toggled(mut self, path: impl IntoIterator<Item = OwnedValue>) -> Self {
512 self.toggled.paths.push(path.into_iter().collect());
513 self
514 }
515
516 pub fn depth(&self) -> ExpandDepth {
518 self.depth
519 }
520
521 pub fn toggled(&self) -> &[Vec<OwnedValue>] {
524 &self.toggled.paths
525 }
526
527 pub fn is_default(&self) -> bool {
529 self.depth == ExpandDepth::All && self.toggled.paths.is_empty()
530 }
531
532 fn is_unwritten(&self) -> bool {
535 self.depth == ExpandDepth::All && self.toggled.is_unwritten()
536 }
537}
538
539#[derive(Clone, Debug, Default, PartialEq, Eq)]
542struct TogglePaths {
543 paths: Vec<Vec<OwnedValue>>,
544 unreadable: usize,
547}
548
549impl TogglePaths {
550 fn is_unwritten(&self) -> bool {
551 !self.paths.iter().any(|path| is_writable(path))
552 }
553}
554
555fn is_writable(path: &[OwnedValue]) -> bool {
557 path.iter()
558 .all(|value| !matches!(value, OwnedValue::Float(float) if !float.is_finite()))
559}
560
561impl Serialize for TogglePaths {
562 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
563 serializer.collect_seq(self.paths.iter().filter(|path| is_writable(path)))
564 }
565}
566
567impl<'de> Deserialize<'de> for TogglePaths {
568 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
571 let saved = Vec::<Vec<serde_json::Value>>::deserialize(deserializer)?;
572 let mut toggled = Self::default();
573 for path in saved {
574 match path
575 .into_iter()
576 .map(OwnedValue::deserialize)
577 .collect::<Result<Vec<_>, _>>()
578 {
579 Ok(path) => toggled.paths.push(path),
580 Err(_) => toggled.unreadable += 1,
581 }
582 }
583 Ok(toggled)
584 }
585}
586
587#[derive(Clone, Debug, PartialEq, Eq)]
590#[non_exhaustive]
591pub enum LayoutError {
592 UnsupportedVersion {
595 found: u32,
597 },
598 ViewMismatch {
600 expected: String,
602 found: String,
604 },
605 MissingViewId,
607 Decode(String),
609 Encode(String),
611}
612
613impl fmt::Display for LayoutError {
614 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
615 match self {
616 Self::UnsupportedVersion { found } => write!(
617 f,
618 "layout version {found} is not supported; this build reads 1 to {}",
619 GridLayout::VERSION
620 ),
621 Self::ViewMismatch { expected, found } => {
622 write!(f, "the layout is for view \"{found}\", not \"{expected}\"")
623 }
624 Self::MissingViewId => f.write_str("the layout has no view id"),
625 Self::Decode(message) => write!(f, "the layout could not be read: {message}"),
626 Self::Encode(message) => write!(f, "the layout could not be written: {message}"),
627 }
628 }
629}
630
631impl std::error::Error for LayoutError {}
632
633pub(crate) fn check_version(found: u32) -> Result<(), LayoutError> {
635 if (1..=GridLayout::VERSION).contains(&found) {
636 Ok(())
637 } else {
638 Err(LayoutError::UnsupportedVersion { found })
639 }
640}
641
642#[derive(Clone, Debug, Default, PartialEq, Eq)]
647pub struct LayoutReport {
648 ignored_columns: Vec<ColumnId>,
649 added_columns: Vec<ColumnId>,
650 dropped_sort: Vec<ColumnId>,
651 dropped_group_by: Vec<ColumnId>,
652 dropped_expansion: usize,
653}
654
655impl LayoutReport {
656 pub(crate) fn resolve(context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Self {
662 let mut ignored_columns = Vec::new();
663 for column in saved.columns() {
664 if !context.contains(column.id().as_str()) && !ignored_columns.contains(column.id()) {
665 ignored_columns.push(column.id().clone());
666 }
667 }
668 let added_columns = context
669 .columns()
670 .iter()
671 .filter(|column| saved.column(column.id().as_str()).is_none())
672 .map(|column| column.id().clone())
673 .collect();
674 let mut sorted = FxHashSet::default();
675 let mut dropped_sort = Vec::new();
676 for key in saved.sort() {
677 let id = key.column();
678 let kept = context.contains(id.as_str()) && sorted.insert(id.as_str());
679 if !kept && !dropped_sort.contains(id) {
680 dropped_sort.push(id.clone());
681 }
682 }
683 let grouping = ResolvedGrouping::new(context, saved);
684 Self {
685 ignored_columns,
686 added_columns,
687 dropped_sort,
688 dropped_group_by: grouping.dropped_group_by,
689 dropped_expansion: grouping.dropped_expansion,
690 }
691 }
692
693 pub fn ignored_columns(&self) -> &[ColumnId] {
695 &self.ignored_columns
696 }
697
698 pub fn added_columns(&self) -> &[ColumnId] {
700 &self.added_columns
701 }
702
703 pub fn dropped_sort(&self) -> &[ColumnId] {
705 &self.dropped_sort
706 }
707
708 pub fn dropped_group_by(&self) -> &[ColumnId] {
712 &self.dropped_group_by
713 }
714
715 pub fn dropped_expansion(&self) -> usize {
719 self.dropped_expansion
720 }
721
722 pub fn is_exact(&self) -> bool {
726 self.ignored_columns.is_empty()
727 && self.added_columns.is_empty()
728 && self.dropped_sort.is_empty()
729 && self.dropped_group_by.is_empty()
730 && self.dropped_expansion == 0
731 }
732}
733
734pub(crate) struct ResolvedGrouping<'a> {
739 levels: Vec<&'a LayoutGroup>,
740 toggled: Vec<&'a [OwnedValue]>,
741 depth: ExpandDepth,
742 dropped_group_by: Vec<ColumnId>,
743 dropped_expansion: usize,
744}
745
746impl<'a> ResolvedGrouping<'a> {
747 pub(crate) fn new(context: &LayoutMergeContext<'_>, saved: &'a GridLayout) -> Self {
756 let mut grouped = FxHashSet::default();
757 let mut levels = Vec::with_capacity(saved.group_by.len());
758 let mut dropped_group_by = Vec::new();
759 for level in &saved.group_by {
760 let id = level.column();
761 if context.is_groupable(id.as_str()) && grouped.insert(id.as_str()) {
762 levels.push(level);
763 } else if !dropped_group_by.contains(id) {
764 dropped_group_by.push(id.clone());
765 }
766 }
767 let expansion = &saved.expansion;
768 let toggled: Vec<&[OwnedValue]> = expansion
769 .toggled()
770 .iter()
771 .map(Vec::as_slice)
772 .filter(|path| (1..=levels.len()).contains(&path.len()))
773 .collect();
774 let dropped_expansion =
775 expansion.toggled.unreadable + (expansion.toggled().len() - toggled.len());
776 Self {
777 levels,
778 toggled,
779 depth: expansion.depth(),
780 dropped_group_by,
781 dropped_expansion,
782 }
783 }
784
785 pub(crate) fn levels(&self) -> &[&'a LayoutGroup] {
787 &self.levels
788 }
789
790 pub(crate) fn toggled(&self) -> &[&'a [OwnedValue]] {
793 &self.toggled
794 }
795
796 pub(crate) fn depth(&self) -> ExpandDepth {
798 self.depth
799 }
800}
801
802pub trait LayoutCodec: 'static {
807 fn encode(&self, layout: &GridLayout) -> Result<String, LayoutError>;
809
810 fn decode(&self, text: &str) -> Result<GridLayout, LayoutError>;
814}
815
816#[derive(Clone, Copy, Debug, Default)]
818pub struct JsonLayoutCodec {
819 pretty: bool,
820}
821
822impl JsonLayoutCodec {
823 pub fn pretty(mut self, pretty: bool) -> Self {
825 self.pretty = pretty;
826 self
827 }
828
829 pub fn is_pretty(&self) -> bool {
831 self.pretty
832 }
833}
834
835impl LayoutCodec for JsonLayoutCodec {
836 fn encode(&self, layout: &GridLayout) -> Result<String, LayoutError> {
837 if self.pretty {
838 serde_json::to_string_pretty(layout)
839 } else {
840 serde_json::to_string(layout)
841 }
842 .map_err(|error| LayoutError::Encode(error.to_string()))
843 }
844
845 fn decode(&self, text: &str) -> Result<GridLayout, LayoutError> {
846 let decode_error = |error: serde_json::Error| LayoutError::Decode(error.to_string());
847 let value: serde_json::Value = serde_json::from_str(text).map_err(decode_error)?;
848 let Some(object) = value.as_object() else {
849 return Err(LayoutError::Decode("expected a JSON object".into()));
850 };
851 let found = match object.get("version") {
852 None => return Err(LayoutError::Decode("missing field `version`".into())),
853 Some(version) => version.as_u64().ok_or_else(|| {
854 LayoutError::Decode(format!("`version` is not a whole number: {version}"))
855 })?,
856 };
857 check_version(u32::try_from(found).unwrap_or(u32::MAX))?;
858 serde_json::from_value(value).map_err(decode_error)
861 }
862}
863
864#[derive(Clone, Copy, Debug)]
867pub struct LayoutMergeContext<'a> {
868 columns: &'a [LayoutColumn],
869 ungroupable: &'a [ColumnId],
871}
872
873impl<'a> LayoutMergeContext<'a> {
874 pub(crate) fn new(columns: &'a [LayoutColumn]) -> Self {
877 Self {
878 columns,
879 ungroupable: &[],
880 }
881 }
882
883 pub(crate) fn with_ungroupable(mut self, ids: &'a [ColumnId]) -> Self {
885 self.ungroupable = ids;
886 self
887 }
888
889 pub fn columns(&self) -> &'a [LayoutColumn] {
892 self.columns
893 }
894
895 pub fn column(&self, id: &str) -> Option<&'a LayoutColumn> {
897 self.columns
898 .iter()
899 .find(|column| column.id().as_str() == id)
900 }
901
902 pub fn contains(&self, id: &str) -> bool {
904 self.column(id).is_some()
905 }
906
907 pub fn is_groupable(&self, id: &str) -> bool {
910 self.contains(id) && !self.ungroupable.iter().any(|column| column.as_str() == id)
911 }
912}
913
914pub trait LayoutMerge: 'static {
921 fn merge(&self, context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Vec<LayoutColumn>;
926}
927
928#[derive(Clone, Copy, Debug, Default)]
933pub struct AppendNewColumns;
934
935impl LayoutMerge for AppendNewColumns {
936 fn merge(&self, context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Vec<LayoutColumn> {
937 fn zone_ix(zone: ColumnZone) -> usize {
938 match zone {
939 ColumnZone::FrozenLeft => 0,
940 ColumnZone::Scrolling => 1,
941 ColumnZone::FrozenRight => 2,
942 }
943 }
944
945 let mut merged = FxHashSet::default();
946 let mut zones: [Vec<LayoutColumn>; 3] = Default::default();
947 for column in saved.columns() {
948 let id = column.id().as_str();
949 if context.contains(id) && merged.insert(id) {
950 zones[zone_ix(column.zone())].push(column.clone());
951 }
952 }
953 for column in context.columns() {
954 if !merged.contains(column.id().as_str()) {
955 zones[zone_ix(column.zone())].push(column.clone());
956 }
957 }
958 zones.into_iter().flatten().collect()
959 }
960}
961
962#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
970pub struct LayoutLibrary {
971 #[serde(default)]
972 views: BTreeMap<String, ViewLayouts>,
973}
974
975#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
976struct ViewLayouts {
977 #[serde(default, skip_serializing_if = "Option::is_none")]
978 default: Option<String>,
979 #[serde(default)]
980 layouts: BTreeMap<String, GridLayout>,
981}
982
983impl LayoutLibrary {
984 pub fn new() -> Self {
986 Self::default()
987 }
988
989 pub fn insert(
993 &mut self,
994 name: impl Into<String>,
995 layout: GridLayout,
996 ) -> Result<(), LayoutError> {
997 let view_id = layout.view_id().ok_or(LayoutError::MissingViewId)?;
998 self.views
999 .entry(view_id.to_owned())
1000 .or_default()
1001 .layouts
1002 .insert(name.into(), layout);
1003 Ok(())
1004 }
1005
1006 pub fn get(&self, view_id: &str, name: &str) -> Option<&GridLayout> {
1008 self.views.get(view_id)?.layouts.get(name)
1009 }
1010
1011 pub fn names<'a>(&'a self, view_id: &str) -> impl Iterator<Item = &'a str> + use<'a> {
1013 self.views
1014 .get(view_id)
1015 .into_iter()
1016 .flat_map(|view| view.layouts.keys().map(String::as_str))
1017 }
1018
1019 pub fn remove(&mut self, view_id: &str, name: &str) -> Option<GridLayout> {
1022 let view = self.views.get_mut(view_id)?;
1023 let layout = view.layouts.remove(name)?;
1024 if view.default.as_deref() == Some(name) {
1025 view.default = None;
1026 }
1027 if view.layouts.is_empty() {
1028 self.views.remove(view_id);
1029 }
1030 Some(layout)
1031 }
1032
1033 pub fn rename(&mut self, view_id: &str, from: &str, to: impl Into<String>) -> bool {
1037 let to = to.into();
1038 let Some(view) = self.views.get_mut(view_id) else {
1039 return false;
1040 };
1041 if to != from && view.layouts.contains_key(&to) {
1042 return false;
1043 }
1044 let Some(layout) = view.layouts.remove(from) else {
1045 return false;
1046 };
1047 if view.default.as_deref() == Some(from) {
1048 view.default = Some(to.clone());
1049 }
1050 view.layouts.insert(to, layout);
1051 true
1052 }
1053
1054 pub fn set_default(&mut self, view_id: &str, name: Option<&str>) -> bool {
1058 let view = self.views.get_mut(view_id);
1059 match (view, name) {
1060 (Some(view), Some(name)) if view.layouts.contains_key(name) => {
1061 view.default = Some(name.to_owned());
1062 true
1063 }
1064 (_, Some(_)) => false,
1065 (Some(view), None) => {
1066 view.default = None;
1067 true
1068 }
1069 (None, None) => true,
1070 }
1071 }
1072
1073 pub fn default_layout(&self, view_id: &str) -> Option<(&str, &GridLayout)> {
1075 let view = self.views.get(view_id)?;
1076 let (name, layout) = view.layouts.get_key_value(view.default.as_deref()?)?;
1077 Some((name.as_str(), layout))
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 fn reference_layout() -> GridLayout {
1089 GridLayout::new()
1090 .with_view_id("trades")
1091 .with_column(LayoutColumn::new("id", 80.).with_zone(ColumnZone::FrozenLeft))
1092 .with_column(LayoutColumn::new("instrument", 140.))
1093 .with_column(LayoutColumn::new("time", 92.5))
1094 .with_column(LayoutColumn::new("fee", 90.).with_visible(false))
1095 .with_column(LayoutColumn::new("price", 100.))
1096 .with_column(LayoutColumn::new("side", 60.).with_zone(ColumnZone::FrozenRight))
1097 .with_column(
1098 LayoutColumn::new("venue", 72.)
1099 .with_visible(false)
1100 .with_zone(ColumnZone::FrozenRight),
1101 )
1102 .with_sort([
1103 LayoutSort::new("time", SortDirection::Descending),
1104 LayoutSort::new("instrument", SortDirection::Ascending),
1105 ])
1106 }
1107
1108 #[test]
1109 fn test_layout_builder() {
1110 let empty = GridLayout::new();
1111 assert_eq!(empty.version(), GridLayout::VERSION);
1112 assert_eq!(empty.view_id(), None);
1113 assert!(empty.columns().is_empty() && empty.sort().is_empty());
1114
1115 let column = LayoutColumn::new("price", 100.);
1116 assert_eq!(column.id().as_str(), "price");
1117 assert_eq!(column.width(), 100.);
1118 assert!(column.is_visible());
1119 assert_eq!(column.zone(), ColumnZone::Scrolling);
1120
1121 let layout = GridLayout::new()
1122 .with_view_id("orders")
1123 .with_column(
1124 LayoutColumn::new("price", 100.)
1125 .with_width(gpui_kit::px(120.))
1126 .with_visible(false)
1127 .with_zone(ColumnZone::FrozenRight),
1128 )
1129 .with_column(LayoutColumn::new(String::from("qty"), 60.))
1130 .with_column(LayoutColumn::new("price", 30.))
1131 .with_sort([LayoutSort::new("qty", SortDirection::Ascending)])
1132 .with_sort([LayoutSort::new("price", SortDirection::Descending)]);
1133 assert_eq!(layout.view_id(), Some("orders"));
1134 let ids: Vec<_> = layout.columns().iter().map(|c| c.id().as_str()).collect();
1135 assert_eq!(ids, ["price", "qty", "price"]);
1136 let price = layout.column("price").unwrap();
1138 assert_eq!(price.width(), 120.);
1139 assert!(!price.is_visible());
1140 assert_eq!(price.zone(), ColumnZone::FrozenRight);
1141 assert_eq!(layout.column("fee"), None);
1142 assert_eq!(layout.sort().len(), 1);
1144 assert_eq!(layout.sort()[0].column().as_str(), "price");
1145 assert_eq!(layout.sort()[0].direction(), SortDirection::Descending);
1146
1147 assert!(!JsonLayoutCodec::default().is_pretty());
1148 assert!(JsonLayoutCodec::default().pretty(true).is_pretty());
1149 assert_eq!(
1150 LayoutError::UnsupportedVersion { found: 2 }.to_string(),
1151 "layout version 2 is not supported; this build reads 1 to 1"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_layout_json_matches_fixture() {
1157 let fixture = include_str!("../tests/fixtures/layout-v1.json").replace("\r\n", "\n");
1159 let layout = reference_layout();
1160
1161 let pretty = JsonLayoutCodec::default().pretty(true);
1162 assert_eq!(format!("{}\n", pretty.encode(&layout).unwrap()), fixture);
1163 assert_eq!(pretty.decode(&fixture).unwrap(), layout);
1164
1165 let ungrouped = layout
1168 .clone()
1169 .with_group_by(Vec::new())
1170 .with_expansion(LayoutExpansion::default());
1171 assert_eq!(ungrouped, layout);
1172 assert_eq!(format!("{}\n", pretty.encode(&ungrouped).unwrap()), fixture);
1173 let read = pretty.decode(&fixture).unwrap();
1174 assert!(read.group_by().is_empty());
1175 assert_eq!(read.expansion(), None);
1176
1177 let codec = JsonLayoutCodec::default();
1180 let compact = codec.encode(&layout).unwrap();
1181 assert!(!compact.contains('\n'));
1182 assert_eq!(codec.decode(&compact).unwrap(), layout);
1183 assert_eq!(
1184 serde_json::from_str::<GridLayout>(&fixture).unwrap(),
1185 layout
1186 );
1187 }
1188
1189 #[test]
1190 fn test_layout_decode_defaults_and_unknown_fields() {
1191 let codec = JsonLayoutCodec::default();
1192 let layout = codec
1193 .decode(
1194 r#"{
1195 "version": 1,
1196 "view_id": null,
1197 "columns": [
1198 { "id": "a", "width": 50 },
1199 { "id": "b", "width": 60.5, "visible": true, "frozen": null, "tint": "red" },
1200 { "id": "c", "width": null, "visible": false, "frozen": "right" }
1201 ],
1202 "filters": ["a"],
1203 "density": { "rows": "compact" }
1204 }"#,
1205 )
1206 .unwrap();
1207 assert_eq!(layout.version(), 1);
1208 assert_eq!(layout.view_id(), None);
1209 assert!(layout.sort().is_empty());
1210 let a = layout.column("a").unwrap();
1211 assert_eq!(a.width(), 50.);
1212 assert!(a.is_visible());
1213 assert_eq!(a.zone(), ColumnZone::Scrolling);
1214 let b = layout.column("b").unwrap();
1215 assert_eq!(
1216 (b.width(), b.is_visible(), b.zone()),
1217 (60.5, true, ColumnZone::Scrolling)
1218 );
1219 let c = layout.column("c").unwrap();
1222 assert!(c.width().is_nan());
1223 assert_eq!((c.is_visible(), c.zone()), (false, ColumnZone::FrozenRight));
1224
1225 assert_eq!(
1227 codec.encode(&layout).unwrap(),
1228 concat!(
1229 r#"{"version":1,"columns":[{"id":"a","width":50.0},{"id":"b","width":60.5},"#,
1230 r#"{"id":"c","width":null,"visible":false,"frozen":"right"}]}"#,
1231 )
1232 );
1233
1234 for text in [
1236 r#"{"version":1,"columns":[{"id":"a","width":50,"frozen":"top"}]}"#,
1237 r#"{"version":1,"columns":[{"id":"a","width":"wide"}]}"#,
1238 r#"{"version":1,"columns":[{"width":50}]}"#,
1239 r#"{"version":1,"columns":[],"sort":[{"column":"a","direction":"up"}]}"#,
1240 r#"{"version":1,"columns":[],"group_by":[{"column":"a","direction":"up"}]}"#,
1241 r#"{"version":1,"columns":[],"group_by":[{"direction":"ascending"}]}"#,
1242 r#"{"version":1,"columns":[],"group_by":["a"]}"#,
1243 r#"{"version":1,"columns":[],"expansion":{"depth":"deep"}}"#,
1244 r#"{"version":1,"columns":[],"expansion":"all"}"#,
1245 r#"{"version":1,"columns":[],"expansion":{"toggled":"AAPL"}}"#,
1246 r#"{"version":1,"columns":[],"expansion":{"toggled":["AAPL"]}}"#,
1247 ] {
1248 assert!(
1249 matches!(codec.decode(text), Err(LayoutError::Decode(_))),
1250 "{text}"
1251 );
1252 }
1253 }
1254
1255 fn text(value: &str) -> OwnedValue {
1256 OwnedValue::Text(value.into())
1257 }
1258
1259 fn grouped_layout() -> GridLayout {
1264 let expansion = LayoutExpansion::new(ExpandDepth::All)
1265 .with_toggled([text("AAPL")])
1266 .with_toggled([text("MSFT"), text("Sell")])
1267 .with_toggled([OwnedValue::Empty])
1268 .with_toggled([OwnedValue::Bool(true)])
1269 .with_toggled([OwnedValue::Int(12)])
1270 .with_toggled([OwnedValue::Float(12.0)]);
1271 #[cfg(feature = "decimal")]
1272 let expansion =
1273 expansion.with_toggled([OwnedValue::Decimal(rust_decimal::Decimal::new(1020, 2))]);
1274 GridLayout::new()
1275 .with_view_id("trades")
1276 .with_column(LayoutColumn::new("instrument", 140.))
1277 .with_column(LayoutColumn::new("side", 60.))
1278 .with_column(LayoutColumn::new("price", 100.))
1279 .with_sort([LayoutSort::new("price", SortDirection::Descending)])
1280 .with_group_by([
1281 LayoutGroup::new("instrument", SortDirection::Ascending),
1282 LayoutGroup::new("side", SortDirection::Descending),
1283 ])
1284 .with_expansion(expansion)
1285 }
1286
1287 fn ids(ids: &[ColumnId]) -> Vec<&str> {
1288 ids.iter().map(ColumnId::as_str).collect()
1289 }
1290
1291 #[test]
1292 fn test_layout_groups_builder() {
1293 let level = LayoutGroup::new("side", SortDirection::Descending);
1294 assert_eq!(level.column().as_str(), "side");
1295 assert_eq!(level.direction(), SortDirection::Descending);
1296
1297 let expansion = LayoutExpansion::new(ExpandDepth::Levels(1))
1298 .with_toggled([text("AAPL")])
1299 .with_toggled(Vec::new());
1300 assert_eq!(expansion.depth(), ExpandDepth::Levels(1));
1301 assert_eq!(expansion.toggled(), [vec![text("AAPL")], Vec::new()]);
1302 assert!(!expansion.is_default());
1303 assert!(LayoutExpansion::default().is_default());
1304 assert!(!LayoutExpansion::new(ExpandDepth::Captions).is_default());
1305 assert!(
1306 !LayoutExpansion::default()
1307 .with_toggled([text("a")])
1308 .is_default()
1309 );
1310
1311 let layout = GridLayout::new()
1312 .with_group_by([level.clone()])
1313 .with_group_by([level.clone(), level.clone()])
1314 .with_expansion(LayoutExpansion::new(ExpandDepth::Captions))
1315 .with_expansion(expansion.clone());
1316 assert_eq!(layout.group_by(), [level.clone(), level]);
1318 assert_eq!(layout.expansion(), Some(&expansion));
1319 assert!(GridLayout::new().group_by().is_empty());
1320 assert_eq!(GridLayout::new().expansion(), None);
1321 assert_eq!(
1322 layout
1323 .with_expansion(LayoutExpansion::default())
1324 .expansion(),
1325 None
1326 );
1327 }
1328
1329 #[test]
1330 fn test_layout_groups_fixture() {
1331 use std::mem::discriminant;
1332
1333 let fixture = include_str!("../tests/fixtures/layout-v1-groups.json").replace("\r\n", "\n");
1334 let layout = grouped_layout();
1335 let pretty = JsonLayoutCodec::default().pretty(true);
1336 let read = pretty.decode(&fixture).unwrap();
1337 assert_eq!(serde_json::from_str::<GridLayout>(&fixture).unwrap(), read);
1339
1340 let toggled = read.expansion().unwrap().toggled();
1342 assert_eq!(
1343 discriminant(&toggled[4][0]),
1344 discriminant(&OwnedValue::Int(12))
1345 );
1346 assert_eq!(
1347 discriminant(&toggled[5][0]),
1348 discriminant(&OwnedValue::Float(12.0))
1349 );
1350
1351 let schema = [
1352 LayoutColumn::new("instrument", 100.),
1353 LayoutColumn::new("side", 100.),
1354 LayoutColumn::new("price", 100.),
1355 ];
1356 let context = LayoutMergeContext::new(&schema);
1357 let report = LayoutReport::resolve(&context, &read);
1358 assert!(report.dropped_group_by().is_empty());
1359
1360 #[cfg(feature = "decimal")]
1361 {
1362 assert_eq!(format!("{}\n", pretty.encode(&layout).unwrap()), fixture);
1363 assert_eq!(read, layout);
1364 assert!(
1365 matches!(&toggled[6][0], OwnedValue::Decimal(value) if value.to_string() == "10.20")
1366 );
1367 assert!(report.is_exact());
1368 }
1369 #[cfg(not(feature = "decimal"))]
1370 {
1371 assert_eq!(read.group_by(), layout.group_by());
1374 assert_eq!(toggled, layout.expansion().unwrap().toggled());
1375 assert_eq!(report.dropped_expansion(), 1);
1376 assert!(!report.is_exact());
1377 assert_eq!(
1379 pretty.encode(&read).unwrap(),
1380 pretty.encode(&layout).unwrap()
1381 );
1382 }
1383 let compact = JsonLayoutCodec::default();
1385 let text = compact.encode(&read).unwrap();
1386 assert_eq!(
1387 compact.encode(&compact.decode(&text).unwrap()).unwrap(),
1388 text
1389 );
1390 }
1391
1392 #[test]
1393 fn test_expansion_default_omitted() {
1394 let codec = JsonLayoutCodec::default();
1395 let empty = r#"{"version":1,"columns":[]}"#;
1396 for layout in [
1399 GridLayout::new().with_expansion(LayoutExpansion::new(ExpandDepth::All)),
1400 GridLayout::new()
1401 .with_group_by(Vec::new())
1402 .with_expansion(LayoutExpansion::default()),
1403 GridLayout::new().with_expansion(
1404 LayoutExpansion::default()
1405 .with_toggled([text("a"), OwnedValue::Float(f64::NAN)])
1406 .with_toggled([OwnedValue::Float(f64::NEG_INFINITY)]),
1407 ),
1408 ] {
1409 assert_eq!(codec.encode(&layout).unwrap(), empty);
1410 }
1411 let read = codec.decode(empty).unwrap();
1412 assert_eq!(read, GridLayout::new());
1413 assert_eq!(read.expansion(), None);
1414
1415 for (depth, json) in [
1417 (ExpandDepth::Captions, r#""captions""#),
1418 (ExpandDepth::Levels(0), "0"),
1419 (ExpandDepth::Levels(3), "3"),
1420 ] {
1421 let layout = GridLayout::new().with_expansion(LayoutExpansion::new(depth));
1422 let text = codec.encode(&layout).unwrap();
1423 assert_eq!(
1424 text,
1425 format!(r#"{{"version":1,"columns":[],"expansion":{{"depth":{json}}}}}"#)
1426 );
1427 assert_eq!(codec.decode(&text).unwrap(), layout);
1428 assert_eq!(layout.expansion().map(LayoutExpansion::depth), Some(depth));
1429 }
1430
1431 let mixed = GridLayout::new().with_expansion(
1433 LayoutExpansion::new(ExpandDepth::Levels(1))
1434 .with_toggled([text("a"), OwnedValue::Float(f64::INFINITY)])
1435 .with_toggled([text("b")]),
1436 );
1437 assert_eq!(
1438 codec.encode(&mixed).unwrap(),
1439 r#"{"version":1,"columns":[],"expansion":{"depth":1,"toggled":[["b"]]}}"#
1440 );
1441
1442 for (json, depth) in [
1445 (r#"{"depth":"all"}"#, ExpandDepth::All),
1446 (r#"{"depth":"captions"}"#, ExpandDepth::Captions),
1447 (r#"{"depth":0}"#, ExpandDepth::Levels(0)),
1448 (r#"{"depth":2,"rows":[[1]]}"#, ExpandDepth::Levels(2)),
1449 (r#"{}"#, ExpandDepth::All),
1450 (r#"{"toggled":[]}"#, ExpandDepth::All),
1451 ] {
1452 let text = format!(r#"{{"version":1,"columns":[],"expansion":{json}}}"#);
1453 let layout = codec.decode(&text).unwrap();
1454 assert_eq!(
1455 layout
1456 .expansion()
1457 .map_or(ExpandDepth::All, LayoutExpansion::depth),
1458 depth,
1459 "{json}"
1460 );
1461 }
1462 for json in [r#""deep""#, r#""All""#, "-1", "1.5", "65536", "null", "[1]"] {
1463 let text = format!(r#"{{"version":1,"columns":[],"expansion":{{"depth":{json}}}}}"#);
1464 assert!(
1465 matches!(codec.decode(&text), Err(LayoutError::Decode(_))),
1466 "{json}"
1467 );
1468 }
1469 }
1470
1471 #[test]
1472 fn test_layout_report_groups() {
1473 let schema = ["a", "b", "c", "d", "e"].map(|id| LayoutColumn::new(id, 50.));
1475 let ungroupable = [ColumnId::from("e")];
1476 let context = LayoutMergeContext::new(&schema).with_ungroupable(&ungroupable);
1477 assert!(context.is_groupable("a") && context.contains("e"));
1478 assert!(!context.is_groupable("e") && !context.is_groupable("gone"));
1479 assert!(
1480 LayoutMergeContext::new(&schema).is_groupable("e"),
1481 "every column can be grouped by default"
1482 );
1483
1484 let saved = JsonLayoutCodec::default()
1485 .decode(
1486 r#"{
1487 "version": 1,
1488 "columns": [
1489 { "id": "a", "width": 50 }, { "id": "b", "width": 50 },
1490 { "id": "c", "width": 50 }, { "id": "d", "width": 50 },
1491 { "id": "e", "width": 50 }
1492 ],
1493 "sort": [
1494 { "column": "gone", "direction": "ascending" },
1495 { "column": "e", "direction": "ascending" }
1496 ],
1497 "group_by": [
1498 { "column": "a", "direction": "ascending" },
1499 { "column": "gone", "direction": "ascending" },
1500 { "column": "b", "direction": "descending" },
1501 { "column": "a", "direction": "descending" },
1502 { "column": "e", "direction": "ascending" },
1503 { "column": "gone", "direction": "descending" },
1504 { "column": "c", "direction": "ascending" }
1505 ],
1506 "expansion": {
1507 "depth": 9,
1508 "toggled": [
1509 ["x"], [], [1, 2, 3], [1, 2, 3, 4], ["x", "y"],
1510 [{ "date": "2026-11-04" }], ["x", [1]], [18446744073709551615]
1511 ]
1512 }
1513 }"#,
1514 )
1515 .unwrap();
1516 assert_eq!(saved.expansion().unwrap().toggled().len(), 5);
1518
1519 let report = LayoutReport::resolve(&context, &saved);
1520 assert_eq!(ids(report.dropped_group_by()), ["gone", "a", "e"]);
1522 assert_eq!(report.dropped_expansion(), 5);
1524 assert_eq!(ids(report.dropped_sort()), ["gone"]);
1526 assert!(report.ignored_columns().is_empty() && report.added_columns().is_empty());
1527 assert!(!report.is_exact());
1528
1529 let resolved = ResolvedGrouping::new(&context, &saved);
1531 let levels: Vec<_> = resolved
1532 .levels()
1533 .iter()
1534 .map(|level| (level.column().as_str(), level.direction()))
1535 .collect();
1536 assert_eq!(
1537 levels,
1538 [
1539 ("a", SortDirection::Ascending),
1540 ("b", SortDirection::Descending),
1541 ("c", SortDirection::Ascending),
1542 ]
1543 );
1544 let int = OwnedValue::Int;
1545 assert_eq!(
1546 resolved.toggled(),
1547 [
1548 &[text("x")][..],
1549 &[int(1), int(2), int(3)],
1550 &[text("x"), text("y")],
1551 ]
1552 );
1553 assert_eq!(resolved.depth(), ExpandDepth::Levels(9));
1555
1556 let gone = GridLayout::new()
1558 .with_group_by([LayoutGroup::new("gone", SortDirection::Ascending)])
1559 .with_expansion(LayoutExpansion::new(ExpandDepth::Captions).with_toggled([text("x")]));
1560 let report = LayoutReport::resolve(&context, &gone);
1561 assert_eq!(ids(report.dropped_group_by()), ["gone"]);
1562 assert_eq!(report.dropped_expansion(), 1);
1563 let resolved = ResolvedGrouping::new(&context, &gone);
1564 assert!(resolved.levels().is_empty() && resolved.toggled().is_empty());
1565 assert_eq!(resolved.depth(), ExpandDepth::Captions);
1566
1567 let depth_only = |expansion: LayoutExpansion| {
1569 schema
1570 .iter()
1571 .cloned()
1572 .fold(GridLayout::new(), GridLayout::with_column)
1573 .with_expansion(expansion)
1574 };
1575 let report = LayoutReport::resolve(
1576 &context,
1577 &depth_only(LayoutExpansion::new(ExpandDepth::Levels(0))),
1578 );
1579 assert!(report.is_exact());
1580 let report = LayoutReport::resolve(
1581 &context,
1582 &depth_only(LayoutExpansion::new(ExpandDepth::Levels(0)).with_toggled([text("x")])),
1583 );
1584 assert_eq!(report.dropped_expansion(), 1);
1585
1586 let fits = depth_only(
1588 LayoutExpansion::new(ExpandDepth::All)
1589 .with_toggled([text("x")])
1590 .with_toggled([text("x"), OwnedValue::Empty]),
1591 )
1592 .with_group_by([
1593 LayoutGroup::new("d", SortDirection::Descending),
1594 LayoutGroup::new("a", SortDirection::Ascending),
1595 ]);
1596 assert!(LayoutReport::resolve(&context, &fits).is_exact());
1597 assert_eq!(ResolvedGrouping::new(&context, &fits).toggled().len(), 2);
1598 }
1599
1600 #[test]
1601 fn test_old_build_reads_new_layout() {
1602 #[derive(Debug, PartialEq, Serialize, Deserialize)]
1604 struct OldLayout {
1605 version: u32,
1606 #[serde(default, skip_serializing_if = "Option::is_none")]
1607 view_id: Option<String>,
1608 columns: Vec<LayoutColumn>,
1609 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1610 sort: Vec<LayoutSort>,
1611 }
1612
1613 let fixture = include_str!("../tests/fixtures/layout-v1-groups.json");
1615 let old: OldLayout = serde_json::from_str(fixture).unwrap();
1616 let layout = grouped_layout();
1617 assert_eq!(old.version, GridLayout::VERSION);
1618 assert_eq!(old.view_id.as_deref(), layout.view_id());
1619 assert_eq!(old.columns, layout.columns());
1620 assert_eq!(old.sort, layout.sort());
1621
1622 let saved_again = serde_json::to_string(&old).unwrap();
1625 let read = JsonLayoutCodec::default().decode(&saved_again).unwrap();
1626 assert_eq!(
1627 read,
1628 layout
1629 .with_group_by(Vec::new())
1630 .with_expansion(LayoutExpansion::default())
1631 );
1632
1633 let reference = reference_layout();
1635 let old = OldLayout {
1636 version: reference.version(),
1637 view_id: reference.view_id().map(str::to_owned),
1638 columns: reference.columns().to_vec(),
1639 sort: reference.sort().to_vec(),
1640 };
1641 assert_eq!(
1642 serde_json::to_string(&old).unwrap(),
1643 JsonLayoutCodec::default().encode(&reference).unwrap()
1644 );
1645 }
1646
1647 #[test]
1648 fn test_layout_round_trip_property() {
1649 const IDS: [&str; 10] = ["id", "a", "b", "c", "d", "e", "f", "g", "Цена", "量"];
1650 const PIECES: [&str; 10] = ["a", "Я", "✓", "😀", "\"", "\\", "\n", " ", "é", "中"];
1651
1652 fn value(rng: &mut fastrand::Rng) -> OwnedValue {
1653 match rng.u8(..7) {
1654 0 => OwnedValue::Empty,
1655 1 => OwnedValue::Bool(rng.bool()),
1656 2 => OwnedValue::Int(rng.i64(..)),
1657 3 => loop {
1659 let float = f64::from_bits(rng.u64(..));
1660 if float.is_finite() {
1661 break OwnedValue::Float(float);
1662 }
1663 },
1664 4 => OwnedValue::Float(match rng.u8(..4) {
1666 0 => (rng.f64() - 0.5) * 1e6,
1667 1 => rng.i32(..) as f64,
1668 2 => 0.,
1669 _ => -0.,
1670 }),
1671 _ => OwnedValue::Text(
1672 (0..rng.usize(..6))
1673 .map(|_| PIECES[rng.usize(..PIECES.len())])
1674 .collect::<String>()
1675 .into(),
1676 ),
1677 }
1678 }
1679
1680 fn direction(rng: &mut fastrand::Rng) -> SortDirection {
1681 if rng.bool() {
1682 SortDirection::Ascending
1683 } else {
1684 SortDirection::Descending
1685 }
1686 }
1687
1688 let zones = [
1689 ColumnZone::FrozenLeft,
1690 ColumnZone::Scrolling,
1691 ColumnZone::FrozenRight,
1692 ];
1693 for seed in 0..300 {
1694 let mut rng = fastrand::Rng::with_seed(seed);
1695 let mut layout = GridLayout::new();
1696 if rng.bool() {
1697 layout = layout.with_view_id(PIECES[rng.usize(..PIECES.len())]);
1698 }
1699 for _ in 0..rng.usize(..=8) {
1700 layout = layout.with_column(
1701 LayoutColumn::new(IDS[rng.usize(..IDS.len())], rng.u16(1..800) as f32 / 2.)
1702 .with_visible(rng.bool())
1703 .with_zone(zones[rng.usize(..3)]),
1704 );
1705 }
1706 let id = |rng: &mut fastrand::Rng| IDS[rng.usize(..IDS.len())];
1707 let sort: Vec<_> = (0..rng.usize(..=3))
1708 .map(|_| LayoutSort::new(id(&mut rng), direction(&mut rng)))
1709 .collect();
1710 let levels: Vec<_> = (0..rng.usize(..=3))
1711 .map(|_| LayoutGroup::new(id(&mut rng), direction(&mut rng)))
1712 .collect();
1713 let depth = match rng.u8(..4) {
1714 0 => ExpandDepth::All,
1715 1 => ExpandDepth::Captions,
1716 _ => ExpandDepth::Levels(rng.u16(..5)),
1717 };
1718 let mut expansion = LayoutExpansion::new(depth);
1719 for _ in 0..rng.usize(..=10) {
1720 let path: Vec<_> = (0..rng.usize(1..=3)).map(|_| value(&mut rng)).collect();
1721 expansion = expansion.with_toggled(path);
1722 }
1723 let layout = layout
1724 .with_sort(sort)
1725 .with_group_by(levels)
1726 .with_expansion(expansion);
1727
1728 for codec in [
1729 JsonLayoutCodec::default(),
1730 JsonLayoutCodec::default().pretty(true),
1731 ] {
1732 let text = codec.encode(&layout).unwrap();
1733 let read = codec.decode(&text).unwrap();
1734 assert_eq!(read, layout, "seed {seed}: {text}");
1735 assert_eq!(codec.encode(&read).unwrap(), text, "seed {seed}");
1737 }
1738 }
1739 }
1740
1741 #[test]
1746 #[ignore = "timing; run in release with --ignored --nocapture"]
1747 #[cfg(not(target_family = "wasm"))]
1748 fn test_layout_codec_timing() {
1749 use std::{hint::black_box, time::Instant};
1750
1751 let names: Vec<String> = (0..128).map(|ix| format!("column_{ix}")).collect();
1752 let mut expansion = LayoutExpansion::new(ExpandDepth::Levels(1));
1753 for ix in 0..10 {
1754 expansion = expansion.with_toggled([
1755 text(&format!("instrument {ix}")),
1756 text("Buy"),
1757 OwnedValue::Float(100.25 + f64::from(ix)),
1758 ]);
1759 }
1760 let layout = names
1761 .iter()
1762 .map(|name| LayoutColumn::new(name.clone(), 96.5))
1763 .fold(
1764 GridLayout::new().with_view_id("trades"),
1765 GridLayout::with_column,
1766 )
1767 .with_sort([LayoutSort::new("column_3", SortDirection::Descending)])
1768 .with_group_by(
1769 names[..3]
1770 .iter()
1771 .map(|name| LayoutGroup::new(name.clone(), SortDirection::Ascending)),
1772 )
1773 .with_expansion(expansion);
1774
1775 let codec = JsonLayoutCodec::default();
1776 let text = codec.encode(&layout).unwrap();
1777 let micros = |run: &dyn Fn()| {
1778 (0..5)
1779 .map(|_| {
1780 let started = Instant::now();
1781 for _ in 0..1_000 {
1782 run();
1783 }
1784 started.elapsed().as_secs_f64() * 1e6 / 1_000.
1785 })
1786 .fold(f64::MAX, f64::min)
1787 };
1788 let encode = micros(&|| {
1789 black_box(codec.encode(black_box(&layout)).unwrap());
1790 });
1791 let decode = micros(&|| {
1792 black_box(codec.decode(black_box(&text)).unwrap());
1793 });
1794 println!(
1795 "{} bytes: encode {encode:.1} µs, decode {decode:.1} µs",
1796 text.len()
1797 );
1798 }
1799
1800 #[test]
1801 fn test_layout_version_rules() {
1802 let codec = JsonLayoutCodec::default();
1803 for (text, found) in [
1804 (r#"{"version":2,"columns":[]}"#, 2),
1805 (r#"{"version":0,"columns":[]}"#, 0),
1806 (r#"{"version":99,"columns":"by id"}"#, 99),
1809 (r#"{"version":4294967296,"columns":[]}"#, u32::MAX),
1810 ] {
1811 assert_eq!(
1812 codec.decode(text),
1813 Err(LayoutError::UnsupportedVersion { found }),
1814 "{text}"
1815 );
1816 }
1817 for text in [
1818 r#"{"columns":[]}"#,
1819 r#"{"version":"1","columns":[]}"#,
1820 r#"{"version":-1,"columns":[]}"#,
1821 r#"{"version":1.5,"columns":[]}"#,
1822 r#"{"version":1}"#,
1823 r#"[{"version":1,"columns":[]}]"#,
1824 "not json",
1825 "",
1826 ] {
1827 assert!(
1828 matches!(codec.decode(text), Err(LayoutError::Decode(_))),
1829 "{text}"
1830 );
1831 }
1832
1833 let newer: GridLayout = serde_json::from_str(r#"{"version":2,"columns":[]}"#).unwrap();
1836 assert_eq!(newer.version(), 2);
1837 assert_eq!(
1838 check_version(newer.version()),
1839 Err(LayoutError::UnsupportedVersion { found: 2 })
1840 );
1841 assert_eq!(check_version(GridLayout::VERSION), Ok(()));
1842 }
1843
1844 #[test]
1845 fn test_append_new_columns() {
1846 let schema = [
1848 LayoutColumn::new("id", 80.).with_zone(ColumnZone::FrozenLeft),
1849 LayoutColumn::new("name", 120.),
1850 LayoutColumn::new("fee", 60.),
1851 LayoutColumn::new("price", 90.),
1852 LayoutColumn::new("flag", 40.).with_zone(ColumnZone::FrozenLeft),
1853 LayoutColumn::new("qty", 70.).with_visible(false),
1854 LayoutColumn::new("note", 200.).with_visible(false),
1855 LayoutColumn::new("side", 50.).with_zone(ColumnZone::FrozenRight),
1856 ];
1857 let context = LayoutMergeContext::new(&schema);
1858 assert!(context.contains("fee") && !context.contains("gone"));
1859 assert_eq!(context.column("qty"), Some(&schema[5]));
1860
1861 let saved = GridLayout::new()
1862 .with_column(LayoutColumn::new("price", 101.))
1863 .with_column(LayoutColumn::new("gone", 55.))
1864 .with_column(LayoutColumn::new("name", 133.).with_visible(false))
1865 .with_column(LayoutColumn::new("price", 999.).with_zone(ColumnZone::FrozenLeft))
1866 .with_column(LayoutColumn::new("qty", 71.).with_zone(ColumnZone::FrozenRight))
1867 .with_column(LayoutColumn::new("id", 81.).with_zone(ColumnZone::FrozenLeft))
1868 .with_column(LayoutColumn::new("side", 51.).with_zone(ColumnZone::FrozenRight))
1869 .with_column(LayoutColumn::new("gone", 56.))
1870 .with_sort([
1871 LayoutSort::new("gone", SortDirection::Descending),
1872 LayoutSort::new("price", SortDirection::Ascending),
1873 LayoutSort::new("price", SortDirection::Descending),
1874 LayoutSort::new("side", SortDirection::Ascending),
1875 LayoutSort::new("zz", SortDirection::Ascending),
1876 ]);
1877
1878 let merged = AppendNewColumns.merge(&context, &saved);
1882 let expected = [
1883 LayoutColumn::new("id", 81.).with_zone(ColumnZone::FrozenLeft),
1884 LayoutColumn::new("flag", 40.).with_zone(ColumnZone::FrozenLeft),
1885 LayoutColumn::new("price", 101.),
1886 LayoutColumn::new("name", 133.).with_visible(false),
1887 LayoutColumn::new("fee", 60.),
1888 LayoutColumn::new("note", 200.).with_visible(false),
1889 LayoutColumn::new("qty", 71.).with_zone(ColumnZone::FrozenRight),
1890 LayoutColumn::new("side", 51.).with_zone(ColumnZone::FrozenRight),
1891 ];
1892 assert_eq!(merged, expected);
1893
1894 let report = LayoutReport::resolve(&context, &saved);
1897 assert_eq!(ids(report.ignored_columns()), ["gone"]);
1898 assert_eq!(ids(report.added_columns()), ["fee", "flag", "note"]);
1899 assert_eq!(ids(report.dropped_sort()), ["gone", "price", "zz"]);
1900 assert!(!report.is_exact());
1901
1902 let schema_only = AppendNewColumns.merge(&context, &GridLayout::new());
1904 let order: Vec<_> = schema_only.iter().map(|c| c.id().as_str()).collect();
1905 assert_eq!(
1906 order,
1907 ["id", "flag", "name", "fee", "price", "qty", "note", "side"]
1908 );
1909
1910 let round_trip = merged
1912 .iter()
1913 .cloned()
1914 .fold(GridLayout::new(), GridLayout::with_column)
1915 .with_sort([LayoutSort::new("price", SortDirection::Ascending)]);
1916 assert!(LayoutReport::resolve(&context, &round_trip).is_exact());
1917 assert_eq!(AppendNewColumns.merge(&context, &round_trip), merged);
1918 }
1919
1920 #[test]
1921 fn test_layout_library() {
1922 let trades = |width: f32| {
1923 GridLayout::new()
1924 .with_view_id("trades")
1925 .with_column(LayoutColumn::new("price", width))
1926 };
1927 let names = |library: &LayoutLibrary, view_id: &str| -> Vec<String> {
1928 library.names(view_id).map(str::to_owned).collect()
1929 };
1930
1931 let mut library = LayoutLibrary::new();
1932 assert_eq!(
1933 library.insert("Wide", GridLayout::new()),
1934 Err(LayoutError::MissingViewId)
1935 );
1936 library.insert("Wide", trades(200.)).unwrap();
1937 library.insert("Compact", trades(60.)).unwrap();
1938 library.insert("Wide", trades(240.)).unwrap();
1939 library
1940 .insert("Default", GridLayout::new().with_view_id("orders"))
1941 .unwrap();
1942 assert_eq!(names(&library, "trades"), ["Compact", "Wide"]);
1943 assert_eq!(names(&library, "orders"), ["Default"]);
1944 assert_eq!(names(&library, "positions"), Vec::<String>::new());
1945 assert_eq!(library.get("trades", "Wide"), Some(&trades(240.)));
1946 assert_eq!(library.get("orders", "Wide"), None);
1947
1948 assert_eq!(library.default_layout("trades"), None);
1949 assert!(!library.set_default("trades", Some("Missing")));
1950 assert!(!library.set_default("positions", Some("Wide")));
1951 assert!(library.set_default("trades", Some("Compact")));
1952 assert_eq!(
1953 library.default_layout("trades"),
1954 Some(("Compact", &trades(60.)))
1955 );
1956
1957 assert!(!library.rename("trades", "Compact", "Wide"));
1959 assert!(!library.rename("trades", "Missing", "Other"));
1960 assert!(!library.rename("positions", "Compact", "Other"));
1961 assert!(library.rename("trades", "Compact", "Compact"));
1962 assert!(library.rename("trades", "Compact", "Narrow"));
1963 assert_eq!(names(&library, "trades"), ["Narrow", "Wide"]);
1964 assert_eq!(
1965 library.default_layout("trades"),
1966 Some(("Narrow", &trades(60.)))
1967 );
1968
1969 let text = serde_json::to_string(&library).unwrap();
1971 assert_eq!(
1972 serde_json::from_str::<LayoutLibrary>(&text).unwrap(),
1973 library
1974 );
1975 let mut orders_only = LayoutLibrary::new();
1976 orders_only
1977 .insert("Default", GridLayout::new().with_view_id("orders"))
1978 .unwrap();
1979 assert_eq!(
1980 serde_json::to_string(&orders_only).unwrap(),
1981 r#"{"views":{"orders":{"layouts":{"Default":{"version":1,"view_id":"orders","columns":[]}}}}}"#
1982 );
1983
1984 assert_eq!(library.remove("trades", "Narrow"), Some(trades(60.)));
1986 assert_eq!(library.remove("trades", "Narrow"), None);
1987 assert_eq!(library.default_layout("trades"), None);
1988 assert!(library.set_default("trades", Some("Wide")));
1989 assert!(library.set_default("trades", None));
1990 assert_eq!(library.default_layout("trades"), None);
1991 assert_eq!(library.remove("trades", "Wide"), Some(trades(240.)));
1992 assert_eq!(names(&library, "trades"), Vec::<String>::new());
1993 assert!(library.set_default("trades", None));
1994 assert_eq!(
1995 library.get("orders", "Default").map(GridLayout::version),
1996 Some(1)
1997 );
1998 }
1999}