1use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use ftui_core::geometry::Rect;
15use serde::{Deserialize, Serialize};
16
17pub const PANE_TREE_SCHEMA_VERSION: u16 = 1;
19
20pub const PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION: u16 = 1;
26
27pub const PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION: u16 = 1;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct PaneId(u64);
36
37impl PaneId {
38 pub const MIN: Self = Self(1);
40
41 pub fn new(raw: u64) -> Result<Self, PaneModelError> {
43 if raw == 0 {
44 return Err(PaneModelError::ZeroPaneId);
45 }
46 Ok(Self(raw))
47 }
48
49 #[must_use]
51 pub const fn get(self) -> u64 {
52 self.0
53 }
54
55 pub fn checked_next(self) -> Result<Self, PaneModelError> {
57 let Some(next) = self.0.checked_add(1) else {
58 return Err(PaneModelError::PaneIdOverflow { current: self });
59 };
60 Self::new(next)
61 }
62}
63
64impl Default for PaneId {
65 fn default() -> Self {
66 Self::MIN
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum SplitAxis {
74 Horizontal,
75 Vertical,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PaneSplitRatio {
84 numerator: u32,
85 denominator: u32,
86}
87
88impl PaneSplitRatio {
89 pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneModelError> {
91 if numerator == 0 || denominator == 0 {
92 return Err(PaneModelError::InvalidSplitRatio {
93 numerator,
94 denominator,
95 });
96 }
97 let gcd = gcd_u32(numerator, denominator);
98 Ok(Self {
99 numerator: numerator / gcd,
100 denominator: denominator / gcd,
101 })
102 }
103
104 #[must_use]
106 pub const fn numerator(self) -> u32 {
107 self.numerator
108 }
109
110 #[must_use]
112 pub const fn denominator(self) -> u32 {
113 self.denominator
114 }
115}
116
117impl Default for PaneSplitRatio {
118 fn default() -> Self {
119 Self {
120 numerator: 1,
121 denominator: 1,
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub struct PaneConstraints {
129 pub min_width: u16,
130 pub min_height: u16,
131 pub max_width: Option<u16>,
132 pub max_height: Option<u16>,
133 pub collapsible: bool,
134}
135
136impl PaneConstraints {
137 pub fn validate(self, node_id: PaneId) -> Result<(), PaneModelError> {
139 if let Some(max_width) = self.max_width
140 && max_width < self.min_width
141 {
142 return Err(PaneModelError::InvalidConstraint {
143 node_id,
144 axis: "width",
145 min: self.min_width,
146 max: max_width,
147 });
148 }
149 if let Some(max_height) = self.max_height
150 && max_height < self.min_height
151 {
152 return Err(PaneModelError::InvalidConstraint {
153 node_id,
154 axis: "height",
155 min: self.min_height,
156 max: max_height,
157 });
158 }
159 Ok(())
160 }
161}
162
163impl Default for PaneConstraints {
164 fn default() -> Self {
165 Self {
166 min_width: 1,
167 min_height: 1,
168 max_width: None,
169 max_height: None,
170 collapsible: false,
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PaneLeaf {
178 pub surface_key: String,
180 #[serde(default)]
182 pub extensions: BTreeMap<String, String>,
183}
184
185impl PaneLeaf {
186 #[must_use]
188 pub fn new(surface_key: impl Into<String>) -> Self {
189 Self {
190 surface_key: surface_key.into(),
191 extensions: BTreeMap::new(),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct PaneSplit {
199 pub axis: SplitAxis,
200 pub ratio: PaneSplitRatio,
201 pub first: PaneId,
202 pub second: PaneId,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "kind", rename_all = "snake_case")]
208pub enum PaneNodeKind {
209 Leaf(PaneLeaf),
210 Split(PaneSplit),
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct PaneNodeRecord {
216 pub id: PaneId,
217 #[serde(default)]
218 pub parent: Option<PaneId>,
219 #[serde(default)]
220 pub constraints: PaneConstraints,
221 #[serde(flatten)]
222 pub kind: PaneNodeKind,
223 #[serde(default)]
225 pub extensions: BTreeMap<String, String>,
226}
227
228impl PaneNodeRecord {
229 #[must_use]
231 pub fn leaf(id: PaneId, parent: Option<PaneId>, leaf: PaneLeaf) -> Self {
232 Self {
233 id,
234 parent,
235 constraints: PaneConstraints::default(),
236 kind: PaneNodeKind::Leaf(leaf),
237 extensions: BTreeMap::new(),
238 }
239 }
240
241 #[must_use]
243 pub fn split(id: PaneId, parent: Option<PaneId>, split: PaneSplit) -> Self {
244 Self {
245 id,
246 parent,
247 constraints: PaneConstraints::default(),
248 kind: PaneNodeKind::Split(split),
249 extensions: BTreeMap::new(),
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct PaneTreeSnapshot {
259 #[serde(default = "default_schema_version")]
260 pub schema_version: u16,
261 pub root: PaneId,
262 pub next_id: PaneId,
263 pub nodes: Vec<PaneNodeRecord>,
264 #[serde(default)]
265 pub extensions: BTreeMap<String, String>,
266}
267
268fn default_schema_version() -> u16 {
269 PANE_TREE_SCHEMA_VERSION
270}
271
272impl PaneTreeSnapshot {
273 pub fn canonicalize(&mut self) {
275 self.nodes.sort_by_key(|node| node.id);
276 }
277
278 #[must_use]
280 pub fn state_hash(&self) -> u64 {
281 snapshot_state_hash(self)
282 }
283
284 #[must_use]
286 pub fn invariant_report(&self) -> PaneInvariantReport {
287 build_invariant_report(self)
288 }
289
290 pub fn repair_safe(self) -> Result<PaneRepairOutcome, PaneRepairError> {
295 repair_snapshot_safe(self)
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum PaneInvariantSeverity {
303 Error,
304 Warning,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
309#[serde(rename_all = "snake_case")]
310pub enum PaneInvariantCode {
311 UnsupportedSchemaVersion,
312 DuplicateNodeId,
313 MissingRoot,
314 RootHasParent,
315 MissingParent,
316 MissingChild,
317 MultipleParents,
318 ParentMismatch,
319 SelfReferentialSplit,
320 DuplicateSplitChildren,
321 InvalidSplitRatio,
322 InvalidConstraint,
323 CycleDetected,
324 UnreachableNode,
325 NextIdNotGreaterThanExisting,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct PaneInvariantIssue {
331 pub code: PaneInvariantCode,
332 pub severity: PaneInvariantSeverity,
333 pub repairable: bool,
334 pub node_id: Option<PaneId>,
335 pub related_node: Option<PaneId>,
336 pub message: String,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct PaneInvariantReport {
342 pub snapshot_hash: u64,
343 pub issues: Vec<PaneInvariantIssue>,
344}
345
346impl PaneInvariantReport {
347 #[must_use]
349 pub fn has_errors(&self) -> bool {
350 self.issues
351 .iter()
352 .any(|issue| issue.severity == PaneInvariantSeverity::Error)
353 }
354
355 #[must_use]
357 pub fn has_unrepairable_errors(&self) -> bool {
358 self.issues
359 .iter()
360 .any(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(tag = "action", rename_all = "snake_case")]
367pub enum PaneRepairAction {
368 ReparentNode {
369 node_id: PaneId,
370 before_parent: Option<PaneId>,
371 after_parent: Option<PaneId>,
372 },
373 NormalizeRatio {
374 node_id: PaneId,
375 before_numerator: u32,
376 before_denominator: u32,
377 after_numerator: u32,
378 after_denominator: u32,
379 },
380 RemoveOrphanNode {
381 node_id: PaneId,
382 },
383 BumpNextId {
384 before: PaneId,
385 after: PaneId,
386 },
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct PaneRepairOutcome {
392 pub before_hash: u64,
393 pub after_hash: u64,
394 pub report_before: PaneInvariantReport,
395 pub report_after: PaneInvariantReport,
396 pub actions: Vec<PaneRepairAction>,
397 pub tree: PaneTree,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum PaneRepairFailure {
403 UnsafeIssuesPresent { codes: Vec<PaneInvariantCode> },
404 ValidationFailed { error: PaneModelError },
405}
406
407impl fmt::Display for PaneRepairFailure {
408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409 match self {
410 Self::UnsafeIssuesPresent { codes } => {
411 write!(f, "snapshot contains unsafe invariant issues: {codes:?}")
412 }
413 Self::ValidationFailed { error } => {
414 write!(f, "repaired snapshot failed validation: {error}")
415 }
416 }
417 }
418}
419
420impl std::error::Error for PaneRepairFailure {
421 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
422 if let Self::ValidationFailed { error } = self {
423 return Some(error);
424 }
425 None
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct PaneRepairError {
432 pub before_hash: u64,
433 pub report: PaneInvariantReport,
434 pub reason: PaneRepairFailure,
435}
436
437impl fmt::Display for PaneRepairError {
438 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439 write!(
440 f,
441 "pane repair failed: {} (before_hash={:#x}, issues={})",
442 self.reason,
443 self.before_hash,
444 self.report.issues.len()
445 )
446 }
447}
448
449impl std::error::Error for PaneRepairError {
450 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
451 Some(&self.reason)
452 }
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct PaneLayout {
458 pub area: Rect,
459 rects: BTreeMap<PaneId, Rect>,
460}
461
462impl PaneLayout {
463 #[must_use]
465 pub fn rect(&self, node_id: PaneId) -> Option<Rect> {
466 self.rects.get(&node_id).copied()
467 }
468
469 pub fn iter(&self) -> impl Iterator<Item = (PaneId, Rect)> + '_ {
471 self.rects.iter().map(|(node_id, rect)| (*node_id, *rect))
472 }
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "snake_case")]
478pub enum PanePlacement {
479 ExistingFirst,
480 IncomingFirst,
481}
482
483impl PanePlacement {
484 fn ordered(self, existing: PaneId, incoming: PaneId) -> (PaneId, PaneId) {
485 match self {
486 Self::ExistingFirst => (existing, incoming),
487 Self::IncomingFirst => (incoming, existing),
488 }
489 }
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum PanePointerButton {
496 Primary,
497 Secondary,
498 Middle,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503pub struct PanePointerPosition {
504 pub x: i32,
505 pub y: i32,
506}
507
508impl PanePointerPosition {
509 #[must_use]
510 pub const fn new(x: i32, y: i32) -> Self {
511 Self { x, y }
512 }
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
517pub struct PaneModifierSnapshot {
518 pub shift: bool,
519 pub alt: bool,
520 pub ctrl: bool,
521 pub meta: bool,
522}
523
524impl PaneModifierSnapshot {
525 #[must_use]
526 pub const fn none() -> Self {
527 Self {
528 shift: false,
529 alt: false,
530 ctrl: false,
531 meta: false,
532 }
533 }
534}
535
536impl Default for PaneModifierSnapshot {
537 fn default() -> Self {
538 Self::none()
539 }
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub struct PaneResizeTarget {
545 pub split_id: PaneId,
546 pub axis: SplitAxis,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "snake_case")]
552pub enum PaneResizeDirection {
553 Increase,
554 Decrease,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum PaneCancelReason {
561 EscapeKey,
562 PointerCancel,
563 FocusLost,
564 Blur,
565 Programmatic,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(tag = "event", rename_all = "snake_case")]
571pub enum PaneSemanticInputEventKind {
572 PointerDown {
573 target: PaneResizeTarget,
574 pointer_id: u32,
575 button: PanePointerButton,
576 position: PanePointerPosition,
577 },
578 PointerMove {
579 target: PaneResizeTarget,
580 pointer_id: u32,
581 position: PanePointerPosition,
582 delta_x: i32,
583 delta_y: i32,
584 },
585 PointerUp {
586 target: PaneResizeTarget,
587 pointer_id: u32,
588 button: PanePointerButton,
589 position: PanePointerPosition,
590 },
591 WheelNudge {
592 target: PaneResizeTarget,
593 lines: i16,
594 },
595 KeyboardResize {
596 target: PaneResizeTarget,
597 direction: PaneResizeDirection,
598 units: u16,
599 },
600 Cancel {
601 target: Option<PaneResizeTarget>,
602 reason: PaneCancelReason,
603 },
604 Blur {
605 target: Option<PaneResizeTarget>,
606 },
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct PaneSemanticInputEvent {
613 #[serde(default = "default_pane_semantic_input_event_schema_version")]
614 pub schema_version: u16,
615 pub sequence: u64,
616 #[serde(default)]
617 pub modifiers: PaneModifierSnapshot,
618 #[serde(flatten)]
619 pub kind: PaneSemanticInputEventKind,
620 #[serde(default)]
621 pub extensions: BTreeMap<String, String>,
622}
623
624fn default_pane_semantic_input_event_schema_version() -> u16 {
625 PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
626}
627
628impl PaneSemanticInputEvent {
629 #[must_use]
631 pub fn new(sequence: u64, kind: PaneSemanticInputEventKind) -> Self {
632 Self {
633 schema_version: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
634 sequence,
635 modifiers: PaneModifierSnapshot::default(),
636 kind,
637 extensions: BTreeMap::new(),
638 }
639 }
640
641 pub fn validate(&self) -> Result<(), PaneSemanticInputEventError> {
643 if self.schema_version != PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION {
644 return Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
645 version: self.schema_version,
646 expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
647 });
648 }
649 if self.sequence == 0 {
650 return Err(PaneSemanticInputEventError::ZeroSequence);
651 }
652
653 match self.kind {
654 PaneSemanticInputEventKind::PointerDown { pointer_id, .. }
655 | PaneSemanticInputEventKind::PointerMove { pointer_id, .. }
656 | PaneSemanticInputEventKind::PointerUp { pointer_id, .. } => {
657 if pointer_id == 0 {
658 return Err(PaneSemanticInputEventError::ZeroPointerId);
659 }
660 }
661 PaneSemanticInputEventKind::WheelNudge { lines, .. } => {
662 if lines == 0 {
663 return Err(PaneSemanticInputEventError::ZeroWheelLines);
664 }
665 }
666 PaneSemanticInputEventKind::KeyboardResize { units, .. } => {
667 if units == 0 {
668 return Err(PaneSemanticInputEventError::ZeroResizeUnits);
669 }
670 }
671 PaneSemanticInputEventKind::Cancel { .. } | PaneSemanticInputEventKind::Blur { .. } => {
672 }
673 }
674
675 Ok(())
676 }
677}
678
679#[derive(Debug, Clone, PartialEq, Eq)]
681pub enum PaneSemanticInputEventError {
682 UnsupportedSchemaVersion { version: u16, expected: u16 },
683 ZeroSequence,
684 ZeroPointerId,
685 ZeroWheelLines,
686 ZeroResizeUnits,
687}
688
689impl fmt::Display for PaneSemanticInputEventError {
690 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 match self {
692 Self::UnsupportedSchemaVersion { version, expected } => write!(
693 f,
694 "unsupported pane semantic input schema version {version} (expected {expected})"
695 ),
696 Self::ZeroSequence => write!(f, "semantic pane input event sequence must be non-zero"),
697 Self::ZeroPointerId => {
698 write!(
699 f,
700 "semantic pane pointer events require non-zero pointer_id"
701 )
702 }
703 Self::ZeroWheelLines => write!(f, "semantic pane wheel nudge must be non-zero"),
704 Self::ZeroResizeUnits => {
705 write!(f, "semantic pane keyboard resize units must be non-zero")
706 }
707 }
708 }
709}
710
711impl std::error::Error for PaneSemanticInputEventError {}
712
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
715pub struct PaneSemanticInputTraceMetadata {
716 #[serde(default = "default_pane_semantic_input_trace_schema_version")]
717 pub schema_version: u16,
718 pub seed: u64,
719 pub start_unix_ms: u64,
720 #[serde(default)]
721 pub host: String,
722 pub checksum: u64,
723}
724
725fn default_pane_semantic_input_trace_schema_version() -> u16 {
726 PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION
727}
728
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731pub struct PaneSemanticInputTrace {
732 pub metadata: PaneSemanticInputTraceMetadata,
733 #[serde(default)]
734 pub events: Vec<PaneSemanticInputEvent>,
735}
736
737impl PaneSemanticInputTrace {
738 pub fn new(
740 seed: u64,
741 start_unix_ms: u64,
742 host: impl Into<String>,
743 events: Vec<PaneSemanticInputEvent>,
744 ) -> Result<Self, PaneSemanticInputTraceError> {
745 let mut trace = Self {
746 metadata: PaneSemanticInputTraceMetadata {
747 schema_version: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
748 seed,
749 start_unix_ms,
750 host: host.into(),
751 checksum: 0,
752 },
753 events,
754 };
755 trace.metadata.checksum = trace.recompute_checksum();
756 trace.validate()?;
757 Ok(trace)
758 }
759
760 #[must_use]
762 pub fn recompute_checksum(&self) -> u64 {
763 pane_semantic_input_trace_checksum_payload(&self.metadata, &self.events)
764 }
765
766 pub fn validate(&self) -> Result<(), PaneSemanticInputTraceError> {
768 if self.metadata.schema_version != PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION {
769 return Err(PaneSemanticInputTraceError::UnsupportedSchemaVersion {
770 version: self.metadata.schema_version,
771 expected: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
772 });
773 }
774 if self.events.is_empty() {
775 return Err(PaneSemanticInputTraceError::EmptyEvents);
776 }
777
778 let mut previous_sequence = 0_u64;
779 for (index, event) in self.events.iter().enumerate() {
780 event
781 .validate()
782 .map_err(|source| PaneSemanticInputTraceError::InvalidEvent { index, source })?;
783
784 if index > 0 && event.sequence <= previous_sequence {
785 return Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
786 index,
787 previous: previous_sequence,
788 current: event.sequence,
789 });
790 }
791 previous_sequence = event.sequence;
792 }
793
794 let computed = self.recompute_checksum();
795 if self.metadata.checksum != computed {
796 return Err(PaneSemanticInputTraceError::ChecksumMismatch {
797 recorded: self.metadata.checksum,
798 computed,
799 });
800 }
801
802 Ok(())
803 }
804
805 pub fn replay(
807 &self,
808 machine: &mut PaneDragResizeMachine,
809 ) -> Result<PaneSemanticReplayOutcome, PaneSemanticReplayError> {
810 self.validate()
811 .map_err(PaneSemanticReplayError::InvalidTrace)?;
812
813 let mut transitions = Vec::with_capacity(self.events.len());
814 for event in &self.events {
815 let transition = machine
816 .apply_event(event)
817 .map_err(PaneSemanticReplayError::Machine)?;
818 transitions.push(transition);
819 }
820
821 Ok(PaneSemanticReplayOutcome {
822 trace_checksum: self.metadata.checksum,
823 transitions,
824 final_state: machine.state(),
825 })
826 }
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub enum PaneSemanticInputTraceError {
832 UnsupportedSchemaVersion {
833 version: u16,
834 expected: u16,
835 },
836 EmptyEvents,
837 SequenceOutOfOrder {
838 index: usize,
839 previous: u64,
840 current: u64,
841 },
842 InvalidEvent {
843 index: usize,
844 source: PaneSemanticInputEventError,
845 },
846 ChecksumMismatch {
847 recorded: u64,
848 computed: u64,
849 },
850}
851
852impl fmt::Display for PaneSemanticInputTraceError {
853 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
854 match self {
855 Self::UnsupportedSchemaVersion { version, expected } => write!(
856 f,
857 "unsupported pane semantic input trace schema version {version} (expected {expected})"
858 ),
859 Self::EmptyEvents => write!(
860 f,
861 "semantic pane input trace must contain at least one event"
862 ),
863 Self::SequenceOutOfOrder {
864 index,
865 previous,
866 current,
867 } => write!(
868 f,
869 "semantic pane input trace sequence out of order at index {index} ({current} <= {previous})"
870 ),
871 Self::InvalidEvent { index, source } => {
872 write!(
873 f,
874 "semantic pane input trace contains invalid event at index {index}: {source}"
875 )
876 }
877 Self::ChecksumMismatch { recorded, computed } => write!(
878 f,
879 "semantic pane input trace checksum mismatch (recorded={recorded:#x}, computed={computed:#x})"
880 ),
881 }
882 }
883}
884
885impl std::error::Error for PaneSemanticInputTraceError {}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct PaneSemanticReplayOutcome {
890 pub trace_checksum: u64,
891 pub transitions: Vec<PaneDragResizeTransition>,
892 pub final_state: PaneDragResizeState,
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
897#[serde(rename_all = "snake_case")]
898pub enum PaneSemanticReplayDiffKind {
899 TransitionMismatch,
900 MissingExpectedTransition,
901 UnexpectedTransition,
902 FinalStateMismatch,
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct PaneSemanticReplayDiffArtifact {
908 pub kind: PaneSemanticReplayDiffKind,
909 pub index: Option<usize>,
910 pub expected_transition: Option<PaneDragResizeTransition>,
911 pub actual_transition: Option<PaneDragResizeTransition>,
912 pub expected_final_state: Option<PaneDragResizeState>,
913 pub actual_final_state: Option<PaneDragResizeState>,
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
918pub struct PaneSemanticReplayConformanceArtifact {
919 pub trace_checksum: u64,
920 pub passed: bool,
921 pub diffs: Vec<PaneSemanticReplayDiffArtifact>,
922}
923
924impl PaneSemanticReplayConformanceArtifact {
925 #[must_use]
927 pub fn compare(
928 outcome: &PaneSemanticReplayOutcome,
929 expected_transitions: &[PaneDragResizeTransition],
930 expected_final_state: PaneDragResizeState,
931 ) -> Self {
932 let mut diffs = Vec::new();
933 let max_len = expected_transitions.len().max(outcome.transitions.len());
934
935 for index in 0..max_len {
936 let expected = expected_transitions.get(index);
937 let actual = outcome.transitions.get(index);
938
939 match (expected, actual) {
940 (Some(expected_transition), Some(actual_transition))
941 if expected_transition != actual_transition =>
942 {
943 diffs.push(PaneSemanticReplayDiffArtifact {
944 kind: PaneSemanticReplayDiffKind::TransitionMismatch,
945 index: Some(index),
946 expected_transition: Some(expected_transition.clone()),
947 actual_transition: Some(actual_transition.clone()),
948 expected_final_state: None,
949 actual_final_state: None,
950 });
951 }
952 (Some(expected_transition), None) => {
953 diffs.push(PaneSemanticReplayDiffArtifact {
954 kind: PaneSemanticReplayDiffKind::MissingExpectedTransition,
955 index: Some(index),
956 expected_transition: Some(expected_transition.clone()),
957 actual_transition: None,
958 expected_final_state: None,
959 actual_final_state: None,
960 });
961 }
962 (None, Some(actual_transition)) => {
963 diffs.push(PaneSemanticReplayDiffArtifact {
964 kind: PaneSemanticReplayDiffKind::UnexpectedTransition,
965 index: Some(index),
966 expected_transition: None,
967 actual_transition: Some(actual_transition.clone()),
968 expected_final_state: None,
969 actual_final_state: None,
970 });
971 }
972 (Some(_), Some(_)) | (None, None) => {}
973 }
974 }
975
976 if outcome.final_state != expected_final_state {
977 diffs.push(PaneSemanticReplayDiffArtifact {
978 kind: PaneSemanticReplayDiffKind::FinalStateMismatch,
979 index: None,
980 expected_transition: None,
981 actual_transition: None,
982 expected_final_state: Some(expected_final_state),
983 actual_final_state: Some(outcome.final_state),
984 });
985 }
986
987 Self {
988 trace_checksum: outcome.trace_checksum,
989 passed: diffs.is_empty(),
990 diffs,
991 }
992 }
993}
994
995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997pub struct PaneSemanticReplayFixture {
998 pub trace: PaneSemanticInputTrace,
999 #[serde(default)]
1000 pub expected_transitions: Vec<PaneDragResizeTransition>,
1001 pub expected_final_state: PaneDragResizeState,
1002}
1003
1004impl PaneSemanticReplayFixture {
1005 pub fn run(
1007 &self,
1008 machine: &mut PaneDragResizeMachine,
1009 ) -> Result<PaneSemanticReplayConformanceArtifact, PaneSemanticReplayError> {
1010 let outcome = self.trace.replay(machine)?;
1011 Ok(PaneSemanticReplayConformanceArtifact::compare(
1012 &outcome,
1013 &self.expected_transitions,
1014 self.expected_final_state,
1015 ))
1016 }
1017}
1018
1019#[derive(Debug, Clone, PartialEq, Eq)]
1021pub enum PaneSemanticReplayError {
1022 InvalidTrace(PaneSemanticInputTraceError),
1023 Machine(PaneDragResizeMachineError),
1024}
1025
1026impl fmt::Display for PaneSemanticReplayError {
1027 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1028 match self {
1029 Self::InvalidTrace(source) => write!(f, "invalid semantic replay trace: {source}"),
1030 Self::Machine(source) => write!(f, "pane drag/resize machine replay failed: {source}"),
1031 }
1032 }
1033}
1034
1035impl std::error::Error for PaneSemanticReplayError {}
1036
1037fn pane_semantic_input_trace_checksum_payload(
1038 metadata: &PaneSemanticInputTraceMetadata,
1039 events: &[PaneSemanticInputEvent],
1040) -> u64 {
1041 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1042 const PRIME: u64 = 0x0000_0001_0000_01b3;
1043
1044 fn mix(hash: &mut u64, byte: u8) {
1045 *hash ^= u64::from(byte);
1046 *hash = hash.wrapping_mul(PRIME);
1047 }
1048
1049 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
1050 for byte in bytes {
1051 mix(hash, *byte);
1052 }
1053 }
1054
1055 fn mix_u16(hash: &mut u64, value: u16) {
1056 mix_bytes(hash, &value.to_le_bytes());
1057 }
1058
1059 fn mix_u32(hash: &mut u64, value: u32) {
1060 mix_bytes(hash, &value.to_le_bytes());
1061 }
1062
1063 fn mix_i32(hash: &mut u64, value: i32) {
1064 mix_bytes(hash, &value.to_le_bytes());
1065 }
1066
1067 fn mix_u64(hash: &mut u64, value: u64) {
1068 mix_bytes(hash, &value.to_le_bytes());
1069 }
1070
1071 fn mix_i16(hash: &mut u64, value: i16) {
1072 mix_bytes(hash, &value.to_le_bytes());
1073 }
1074
1075 fn mix_bool(hash: &mut u64, value: bool) {
1076 mix(hash, u8::from(value));
1077 }
1078
1079 fn mix_str(hash: &mut u64, value: &str) {
1080 mix_u64(hash, value.len() as u64);
1081 mix_bytes(hash, value.as_bytes());
1082 }
1083
1084 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
1085 mix_u64(hash, extensions.len() as u64);
1086 for (key, value) in extensions {
1087 mix_str(hash, key);
1088 mix_str(hash, value);
1089 }
1090 }
1091
1092 fn mix_target(hash: &mut u64, target: PaneResizeTarget) {
1093 mix_u64(hash, target.split_id.get());
1094 let axis = match target.axis {
1095 SplitAxis::Horizontal => 1,
1096 SplitAxis::Vertical => 2,
1097 };
1098 mix(hash, axis);
1099 }
1100
1101 fn mix_position(hash: &mut u64, position: PanePointerPosition) {
1102 mix_i32(hash, position.x);
1103 mix_i32(hash, position.y);
1104 }
1105
1106 fn mix_optional_target(hash: &mut u64, target: Option<PaneResizeTarget>) {
1107 match target {
1108 Some(target) => {
1109 mix(hash, 1);
1110 mix_target(hash, target);
1111 }
1112 None => mix(hash, 0),
1113 }
1114 }
1115
1116 fn mix_pointer_button(hash: &mut u64, button: PanePointerButton) {
1117 let value = match button {
1118 PanePointerButton::Primary => 1,
1119 PanePointerButton::Secondary => 2,
1120 PanePointerButton::Middle => 3,
1121 };
1122 mix(hash, value);
1123 }
1124
1125 fn mix_resize_direction(hash: &mut u64, direction: PaneResizeDirection) {
1126 let value = match direction {
1127 PaneResizeDirection::Increase => 1,
1128 PaneResizeDirection::Decrease => 2,
1129 };
1130 mix(hash, value);
1131 }
1132
1133 fn mix_cancel_reason(hash: &mut u64, reason: PaneCancelReason) {
1134 let value = match reason {
1135 PaneCancelReason::EscapeKey => 1,
1136 PaneCancelReason::PointerCancel => 2,
1137 PaneCancelReason::FocusLost => 3,
1138 PaneCancelReason::Blur => 4,
1139 PaneCancelReason::Programmatic => 5,
1140 };
1141 mix(hash, value);
1142 }
1143
1144 let mut hash = OFFSET_BASIS;
1145 mix_u16(&mut hash, metadata.schema_version);
1146 mix_u64(&mut hash, metadata.seed);
1147 mix_u64(&mut hash, metadata.start_unix_ms);
1148 mix_str(&mut hash, &metadata.host);
1149 mix_u64(&mut hash, events.len() as u64);
1150
1151 for event in events {
1152 mix_u16(&mut hash, event.schema_version);
1153 mix_u64(&mut hash, event.sequence);
1154 mix_bool(&mut hash, event.modifiers.shift);
1155 mix_bool(&mut hash, event.modifiers.alt);
1156 mix_bool(&mut hash, event.modifiers.ctrl);
1157 mix_bool(&mut hash, event.modifiers.meta);
1158 mix_extensions(&mut hash, &event.extensions);
1159
1160 match event.kind {
1161 PaneSemanticInputEventKind::PointerDown {
1162 target,
1163 pointer_id,
1164 button,
1165 position,
1166 } => {
1167 mix(&mut hash, 1);
1168 mix_target(&mut hash, target);
1169 mix_u32(&mut hash, pointer_id);
1170 mix_pointer_button(&mut hash, button);
1171 mix_position(&mut hash, position);
1172 }
1173 PaneSemanticInputEventKind::PointerMove {
1174 target,
1175 pointer_id,
1176 position,
1177 delta_x,
1178 delta_y,
1179 } => {
1180 mix(&mut hash, 2);
1181 mix_target(&mut hash, target);
1182 mix_u32(&mut hash, pointer_id);
1183 mix_position(&mut hash, position);
1184 mix_i32(&mut hash, delta_x);
1185 mix_i32(&mut hash, delta_y);
1186 }
1187 PaneSemanticInputEventKind::PointerUp {
1188 target,
1189 pointer_id,
1190 button,
1191 position,
1192 } => {
1193 mix(&mut hash, 3);
1194 mix_target(&mut hash, target);
1195 mix_u32(&mut hash, pointer_id);
1196 mix_pointer_button(&mut hash, button);
1197 mix_position(&mut hash, position);
1198 }
1199 PaneSemanticInputEventKind::WheelNudge { target, lines } => {
1200 mix(&mut hash, 4);
1201 mix_target(&mut hash, target);
1202 mix_i16(&mut hash, lines);
1203 }
1204 PaneSemanticInputEventKind::KeyboardResize {
1205 target,
1206 direction,
1207 units,
1208 } => {
1209 mix(&mut hash, 5);
1210 mix_target(&mut hash, target);
1211 mix_resize_direction(&mut hash, direction);
1212 mix_u16(&mut hash, units);
1213 }
1214 PaneSemanticInputEventKind::Cancel { target, reason } => {
1215 mix(&mut hash, 6);
1216 mix_optional_target(&mut hash, target);
1217 mix_cancel_reason(&mut hash, reason);
1218 }
1219 PaneSemanticInputEventKind::Blur { target } => {
1220 mix(&mut hash, 7);
1221 mix_optional_target(&mut hash, target);
1222 }
1223 }
1224 }
1225
1226 hash
1227}
1228
1229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1231pub struct PaneScaleFactor {
1232 numerator: u32,
1233 denominator: u32,
1234}
1235
1236impl PaneScaleFactor {
1237 pub const ONE: Self = Self {
1239 numerator: 1,
1240 denominator: 1,
1241 };
1242
1243 pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneCoordinateNormalizationError> {
1245 if numerator == 0 || denominator == 0 {
1246 return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1247 field: "scale_factor",
1248 numerator,
1249 denominator,
1250 });
1251 }
1252 let gcd = gcd_u32(numerator, denominator);
1253 Ok(Self {
1254 numerator: numerator / gcd,
1255 denominator: denominator / gcd,
1256 })
1257 }
1258
1259 fn validate(self, field: &'static str) -> Result<(), PaneCoordinateNormalizationError> {
1260 if self.numerator == 0 || self.denominator == 0 {
1261 return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1262 field,
1263 numerator: self.numerator,
1264 denominator: self.denominator,
1265 });
1266 }
1267 Ok(())
1268 }
1269
1270 #[must_use]
1271 pub const fn numerator(self) -> u32 {
1272 self.numerator
1273 }
1274
1275 #[must_use]
1276 pub const fn denominator(self) -> u32 {
1277 self.denominator
1278 }
1279}
1280
1281impl Default for PaneScaleFactor {
1282 fn default() -> Self {
1283 Self::ONE
1284 }
1285}
1286
1287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1289#[serde(rename_all = "snake_case")]
1290pub enum PaneCoordinateRoundingPolicy {
1291 #[default]
1293 TowardNegativeInfinity,
1294 NearestHalfTowardNegativeInfinity,
1296}
1297
1298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(tag = "source", rename_all = "snake_case")]
1301pub enum PaneInputCoordinate {
1302 CssPixels { position: PanePointerPosition },
1304 DevicePixels { position: PanePointerPosition },
1306 Cell { position: PanePointerPosition },
1308}
1309
1310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1312pub struct PaneNormalizedCoordinate {
1313 pub global_cell: PanePointerPosition,
1315 pub local_cell: PanePointerPosition,
1317 pub local_css: PanePointerPosition,
1319}
1320
1321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1323pub struct PaneCoordinateNormalizer {
1324 pub viewport_origin_css: PanePointerPosition,
1325 pub viewport_origin_cells: PanePointerPosition,
1326 pub cell_width_css: u16,
1327 pub cell_height_css: u16,
1328 pub dpr: PaneScaleFactor,
1329 pub zoom: PaneScaleFactor,
1330 #[serde(default)]
1331 pub rounding: PaneCoordinateRoundingPolicy,
1332}
1333
1334impl PaneCoordinateNormalizer {
1335 pub fn new(
1337 viewport_origin_css: PanePointerPosition,
1338 viewport_origin_cells: PanePointerPosition,
1339 cell_width_css: u16,
1340 cell_height_css: u16,
1341 dpr: PaneScaleFactor,
1342 zoom: PaneScaleFactor,
1343 rounding: PaneCoordinateRoundingPolicy,
1344 ) -> Result<Self, PaneCoordinateNormalizationError> {
1345 if cell_width_css == 0 || cell_height_css == 0 {
1346 return Err(PaneCoordinateNormalizationError::InvalidCellSize {
1347 width: cell_width_css,
1348 height: cell_height_css,
1349 });
1350 }
1351 dpr.validate("dpr")?;
1352 zoom.validate("zoom")?;
1353
1354 Ok(Self {
1355 viewport_origin_css,
1356 viewport_origin_cells,
1357 cell_width_css,
1358 cell_height_css,
1359 dpr,
1360 zoom,
1361 rounding,
1362 })
1363 }
1364
1365 pub fn normalize(
1367 &self,
1368 input: PaneInputCoordinate,
1369 ) -> Result<PaneNormalizedCoordinate, PaneCoordinateNormalizationError> {
1370 let (local_css_x, local_css_y) = match input {
1371 PaneInputCoordinate::CssPixels { position } => (
1372 i64::from(position.x) - i64::from(self.viewport_origin_css.x),
1373 i64::from(position.y) - i64::from(self.viewport_origin_css.y),
1374 ),
1375 PaneInputCoordinate::DevicePixels { position } => {
1376 let css_x = scale_div_round(
1377 i64::from(position.x),
1378 i64::from(self.dpr.denominator()),
1379 i64::from(self.dpr.numerator()),
1380 self.rounding,
1381 )?;
1382 let css_y = scale_div_round(
1383 i64::from(position.y),
1384 i64::from(self.dpr.denominator()),
1385 i64::from(self.dpr.numerator()),
1386 self.rounding,
1387 )?;
1388 (
1389 css_x - i64::from(self.viewport_origin_css.x),
1390 css_y - i64::from(self.viewport_origin_css.y),
1391 )
1392 }
1393 PaneInputCoordinate::Cell { position } => {
1394 let local_css_x = i64::from(position.x)
1395 .checked_mul(i64::from(self.cell_width_css))
1396 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1397 let local_css_y = i64::from(position.y)
1398 .checked_mul(i64::from(self.cell_height_css))
1399 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1400 let global_cell_x = i64::from(position.x) + i64::from(self.viewport_origin_cells.x);
1401 let global_cell_y = i64::from(position.y) + i64::from(self.viewport_origin_cells.y);
1402
1403 return Ok(PaneNormalizedCoordinate {
1404 global_cell: PanePointerPosition::new(
1405 to_i32(global_cell_x)?,
1406 to_i32(global_cell_y)?,
1407 ),
1408 local_cell: position,
1409 local_css: PanePointerPosition::new(to_i32(local_css_x)?, to_i32(local_css_y)?),
1410 });
1411 }
1412 };
1413
1414 let unzoomed_css_x = scale_div_round(
1415 local_css_x,
1416 i64::from(self.zoom.denominator()),
1417 i64::from(self.zoom.numerator()),
1418 self.rounding,
1419 )?;
1420 let unzoomed_css_y = scale_div_round(
1421 local_css_y,
1422 i64::from(self.zoom.denominator()),
1423 i64::from(self.zoom.numerator()),
1424 self.rounding,
1425 )?;
1426
1427 let local_cell_x = div_round(
1428 unzoomed_css_x,
1429 i64::from(self.cell_width_css),
1430 self.rounding,
1431 )?;
1432 let local_cell_y = div_round(
1433 unzoomed_css_y,
1434 i64::from(self.cell_height_css),
1435 self.rounding,
1436 )?;
1437
1438 let global_cell_x = local_cell_x + i64::from(self.viewport_origin_cells.x);
1439 let global_cell_y = local_cell_y + i64::from(self.viewport_origin_cells.y);
1440
1441 Ok(PaneNormalizedCoordinate {
1442 global_cell: PanePointerPosition::new(to_i32(global_cell_x)?, to_i32(global_cell_y)?),
1443 local_cell: PanePointerPosition::new(to_i32(local_cell_x)?, to_i32(local_cell_y)?),
1444 local_css: PanePointerPosition::new(to_i32(unzoomed_css_x)?, to_i32(unzoomed_css_y)?),
1445 })
1446 }
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Eq)]
1451pub enum PaneCoordinateNormalizationError {
1452 InvalidCellSize {
1453 width: u16,
1454 height: u16,
1455 },
1456 InvalidScaleFactor {
1457 field: &'static str,
1458 numerator: u32,
1459 denominator: u32,
1460 },
1461 CoordinateOverflow,
1462}
1463
1464impl fmt::Display for PaneCoordinateNormalizationError {
1465 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1466 match self {
1467 Self::InvalidCellSize { width, height } => {
1468 write!(
1469 f,
1470 "invalid pane cell dimensions width={width} height={height} (must be > 0)"
1471 )
1472 }
1473 Self::InvalidScaleFactor {
1474 field,
1475 numerator,
1476 denominator,
1477 } => {
1478 write!(
1479 f,
1480 "invalid pane scale factor for {field}: {numerator}/{denominator} (must be > 0)"
1481 )
1482 }
1483 Self::CoordinateOverflow => {
1484 write!(f, "coordinate conversion overflowed representable range")
1485 }
1486 }
1487 }
1488}
1489
1490impl std::error::Error for PaneCoordinateNormalizationError {}
1491
1492fn scale_div_round(
1493 value: i64,
1494 numerator: i64,
1495 denominator: i64,
1496 rounding: PaneCoordinateRoundingPolicy,
1497) -> Result<i64, PaneCoordinateNormalizationError> {
1498 let scaled = value
1499 .checked_mul(numerator)
1500 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1501 div_round(scaled, denominator, rounding)
1502}
1503
1504fn div_round(
1505 value: i64,
1506 denominator: i64,
1507 rounding: PaneCoordinateRoundingPolicy,
1508) -> Result<i64, PaneCoordinateNormalizationError> {
1509 if denominator <= 0 {
1510 return Err(PaneCoordinateNormalizationError::CoordinateOverflow);
1511 }
1512
1513 let floor = value.div_euclid(denominator);
1514 let remainder = value.rem_euclid(denominator);
1515 if remainder == 0 || rounding == PaneCoordinateRoundingPolicy::TowardNegativeInfinity {
1516 return Ok(floor);
1517 }
1518
1519 let twice_remainder = remainder
1520 .checked_mul(2)
1521 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1522 if twice_remainder > denominator {
1523 if value >= 0 {
1524 return floor
1525 .checked_add(1)
1526 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow);
1527 }
1528 return Ok(floor);
1529 }
1530 Ok(floor)
1531}
1532
1533fn to_i32(value: i64) -> Result<i32, PaneCoordinateNormalizationError> {
1534 i32::try_from(value).map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
1535}
1536
1537pub const PANE_DRAG_RESIZE_DEFAULT_THRESHOLD: u16 = 2;
1540
1541pub const PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS: u16 = 2;
1544
1545pub const PANE_SNAP_DEFAULT_STEP_BPS: u16 = 500;
1547
1548pub const PANE_SNAP_DEFAULT_HYSTERESIS_BPS: u16 = 125;
1550
1551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1553#[serde(rename_all = "snake_case")]
1554pub enum PanePrecisionMode {
1555 Normal,
1556 Fine,
1557 Coarse,
1558}
1559
1560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1562pub struct PanePrecisionPolicy {
1563 pub mode: PanePrecisionMode,
1564 pub axis_lock: Option<SplitAxis>,
1565 pub scale: PaneScaleFactor,
1566}
1567
1568impl PanePrecisionPolicy {
1569 #[must_use]
1571 pub fn from_modifiers(modifiers: PaneModifierSnapshot, target_axis: SplitAxis) -> Self {
1572 let mode = if modifiers.alt {
1573 PanePrecisionMode::Fine
1574 } else if modifiers.ctrl {
1575 PanePrecisionMode::Coarse
1576 } else {
1577 PanePrecisionMode::Normal
1578 };
1579 let axis_lock = modifiers.shift.then_some(target_axis);
1580 let scale = match mode {
1581 PanePrecisionMode::Normal => PaneScaleFactor::ONE,
1582 PanePrecisionMode::Fine => PaneScaleFactor {
1583 numerator: 1,
1584 denominator: 2,
1585 },
1586 PanePrecisionMode::Coarse => PaneScaleFactor {
1587 numerator: 2,
1588 denominator: 1,
1589 },
1590 };
1591 Self {
1592 mode,
1593 axis_lock,
1594 scale,
1595 }
1596 }
1597
1598 pub fn apply_delta(
1600 &self,
1601 raw_delta_x: i32,
1602 raw_delta_y: i32,
1603 ) -> Result<(i32, i32), PaneInteractionPolicyError> {
1604 let (locked_x, locked_y) = match self.axis_lock {
1605 Some(SplitAxis::Horizontal) => (raw_delta_x, 0),
1606 Some(SplitAxis::Vertical) => (0, raw_delta_y),
1607 None => (raw_delta_x, raw_delta_y),
1608 };
1609
1610 let scaled_x = scale_delta_by_factor(locked_x, self.scale)?;
1611 let scaled_y = scale_delta_by_factor(locked_y, self.scale)?;
1612 Ok((scaled_x, scaled_y))
1613 }
1614}
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1618pub struct PaneSnapTuning {
1619 pub step_bps: u16,
1620 pub hysteresis_bps: u16,
1621}
1622
1623impl PaneSnapTuning {
1624 pub fn new(step_bps: u16, hysteresis_bps: u16) -> Result<Self, PaneInteractionPolicyError> {
1625 let tuning = Self {
1626 step_bps,
1627 hysteresis_bps,
1628 };
1629 tuning.validate()?;
1630 Ok(tuning)
1631 }
1632
1633 pub fn validate(self) -> Result<(), PaneInteractionPolicyError> {
1634 if self.step_bps == 0 || self.step_bps > 10_000 {
1635 return Err(PaneInteractionPolicyError::InvalidSnapTuning {
1636 step_bps: self.step_bps,
1637 hysteresis_bps: self.hysteresis_bps,
1638 });
1639 }
1640 Ok(())
1641 }
1642
1643 #[must_use]
1645 pub fn decide(self, ratio_bps: u16, previous_snap: Option<u16>) -> PaneSnapDecision {
1646 let step = u32::from(self.step_bps);
1647 let ratio = u32::from(ratio_bps).min(10_000);
1648 let low = ((ratio / step) * step).min(10_000);
1649 let high = (low + step).min(10_000);
1650
1651 let distance_low = ratio.abs_diff(low);
1652 let distance_high = ratio.abs_diff(high);
1653
1654 let (nearest, nearest_distance) = if distance_low <= distance_high {
1655 (low as u16, distance_low as u16)
1656 } else {
1657 (high as u16, distance_high as u16)
1658 };
1659
1660 if let Some(previous) = previous_snap {
1661 let distance_previous = ratio.abs_diff(u32::from(previous));
1662 if distance_previous <= u32::from(self.hysteresis_bps) {
1663 return PaneSnapDecision {
1664 input_ratio_bps: ratio_bps,
1665 snapped_ratio_bps: Some(previous),
1666 nearest_ratio_bps: nearest,
1667 nearest_distance_bps: nearest_distance,
1668 reason: PaneSnapReason::RetainedPrevious,
1669 };
1670 }
1671 }
1672
1673 if nearest_distance <= self.hysteresis_bps {
1674 PaneSnapDecision {
1675 input_ratio_bps: ratio_bps,
1676 snapped_ratio_bps: Some(nearest),
1677 nearest_ratio_bps: nearest,
1678 nearest_distance_bps: nearest_distance,
1679 reason: PaneSnapReason::SnappedNearest,
1680 }
1681 } else {
1682 PaneSnapDecision {
1683 input_ratio_bps: ratio_bps,
1684 snapped_ratio_bps: None,
1685 nearest_ratio_bps: nearest,
1686 nearest_distance_bps: nearest_distance,
1687 reason: PaneSnapReason::UnsnapOutsideWindow,
1688 }
1689 }
1690 }
1691}
1692
1693impl Default for PaneSnapTuning {
1694 fn default() -> Self {
1695 Self {
1696 step_bps: PANE_SNAP_DEFAULT_STEP_BPS,
1697 hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
1698 }
1699 }
1700}
1701
1702#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1704pub struct PaneDragBehaviorTuning {
1705 pub activation_threshold: u16,
1706 pub update_hysteresis: u16,
1707 pub snap: PaneSnapTuning,
1708}
1709
1710impl PaneDragBehaviorTuning {
1711 pub fn new(
1712 activation_threshold: u16,
1713 update_hysteresis: u16,
1714 snap: PaneSnapTuning,
1715 ) -> Result<Self, PaneInteractionPolicyError> {
1716 if activation_threshold == 0 {
1717 return Err(PaneInteractionPolicyError::InvalidThreshold {
1718 field: "activation_threshold",
1719 value: activation_threshold,
1720 });
1721 }
1722 if update_hysteresis == 0 {
1723 return Err(PaneInteractionPolicyError::InvalidThreshold {
1724 field: "update_hysteresis",
1725 value: update_hysteresis,
1726 });
1727 }
1728 snap.validate()?;
1729 Ok(Self {
1730 activation_threshold,
1731 update_hysteresis,
1732 snap,
1733 })
1734 }
1735
1736 #[must_use]
1737 pub fn should_start_drag(
1738 self,
1739 origin: PanePointerPosition,
1740 current: PanePointerPosition,
1741 ) -> bool {
1742 crossed_drag_threshold(origin, current, self.activation_threshold)
1743 }
1744
1745 #[must_use]
1746 pub fn should_emit_drag_update(
1747 self,
1748 previous: PanePointerPosition,
1749 current: PanePointerPosition,
1750 ) -> bool {
1751 crossed_drag_threshold(previous, current, self.update_hysteresis)
1752 }
1753}
1754
1755impl Default for PaneDragBehaviorTuning {
1756 fn default() -> Self {
1757 Self {
1758 activation_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1759 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1760 snap: PaneSnapTuning::default(),
1761 }
1762 }
1763}
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1767#[serde(rename_all = "snake_case")]
1768pub enum PaneSnapReason {
1769 RetainedPrevious,
1770 SnappedNearest,
1771 UnsnapOutsideWindow,
1772}
1773
1774#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1776pub struct PaneSnapDecision {
1777 pub input_ratio_bps: u16,
1778 pub snapped_ratio_bps: Option<u16>,
1779 pub nearest_ratio_bps: u16,
1780 pub nearest_distance_bps: u16,
1781 pub reason: PaneSnapReason,
1782}
1783
1784#[derive(Debug, Clone, PartialEq, Eq)]
1786pub enum PaneInteractionPolicyError {
1787 InvalidThreshold { field: &'static str, value: u16 },
1788 InvalidSnapTuning { step_bps: u16, hysteresis_bps: u16 },
1789 DeltaOverflow,
1790}
1791
1792impl fmt::Display for PaneInteractionPolicyError {
1793 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1794 match self {
1795 Self::InvalidThreshold { field, value } => {
1796 write!(f, "invalid {field} value {value} (must be > 0)")
1797 }
1798 Self::InvalidSnapTuning {
1799 step_bps,
1800 hysteresis_bps,
1801 } => {
1802 write!(
1803 f,
1804 "invalid snap tuning step_bps={step_bps} hysteresis_bps={hysteresis_bps}"
1805 )
1806 }
1807 Self::DeltaOverflow => write!(f, "delta scaling overflow"),
1808 }
1809 }
1810}
1811
1812impl std::error::Error for PaneInteractionPolicyError {}
1813
1814fn scale_delta_by_factor(
1815 delta: i32,
1816 factor: PaneScaleFactor,
1817) -> Result<i32, PaneInteractionPolicyError> {
1818 let scaled = i64::from(delta)
1819 .checked_mul(i64::from(factor.numerator()))
1820 .ok_or(PaneInteractionPolicyError::DeltaOverflow)?;
1821 let normalized = scaled / i64::from(factor.denominator());
1822 i32::try_from(normalized).map_err(|_| PaneInteractionPolicyError::DeltaOverflow)
1823}
1824
1825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1832#[serde(tag = "state", rename_all = "snake_case")]
1833pub enum PaneDragResizeState {
1834 Idle,
1835 Armed {
1836 target: PaneResizeTarget,
1837 pointer_id: u32,
1838 origin: PanePointerPosition,
1839 current: PanePointerPosition,
1840 started_sequence: u64,
1841 },
1842 Dragging {
1843 target: PaneResizeTarget,
1844 pointer_id: u32,
1845 origin: PanePointerPosition,
1846 current: PanePointerPosition,
1847 started_sequence: u64,
1848 drag_started_sequence: u64,
1849 },
1850}
1851
1852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1854#[serde(rename_all = "snake_case")]
1855pub enum PaneDragResizeNoopReason {
1856 IdleWithoutActiveDrag,
1857 ActiveDragAlreadyInProgress,
1858 PointerMismatch,
1859 TargetMismatch,
1860 ActiveStateDisallowsDiscreteInput,
1861 ThresholdNotReached,
1862 BelowHysteresis,
1863}
1864
1865#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1867#[serde(tag = "effect", rename_all = "snake_case")]
1868pub enum PaneDragResizeEffect {
1869 Armed {
1870 target: PaneResizeTarget,
1871 pointer_id: u32,
1872 origin: PanePointerPosition,
1873 },
1874 DragStarted {
1875 target: PaneResizeTarget,
1876 pointer_id: u32,
1877 origin: PanePointerPosition,
1878 current: PanePointerPosition,
1879 total_delta_x: i32,
1880 total_delta_y: i32,
1881 },
1882 DragUpdated {
1883 target: PaneResizeTarget,
1884 pointer_id: u32,
1885 previous: PanePointerPosition,
1886 current: PanePointerPosition,
1887 delta_x: i32,
1888 delta_y: i32,
1889 total_delta_x: i32,
1890 total_delta_y: i32,
1891 },
1892 Committed {
1893 target: PaneResizeTarget,
1894 pointer_id: u32,
1895 origin: PanePointerPosition,
1896 end: PanePointerPosition,
1897 total_delta_x: i32,
1898 total_delta_y: i32,
1899 },
1900 Canceled {
1901 target: Option<PaneResizeTarget>,
1902 pointer_id: Option<u32>,
1903 reason: PaneCancelReason,
1904 },
1905 KeyboardApplied {
1906 target: PaneResizeTarget,
1907 direction: PaneResizeDirection,
1908 units: u16,
1909 },
1910 WheelApplied {
1911 target: PaneResizeTarget,
1912 lines: i16,
1913 },
1914 Noop {
1915 reason: PaneDragResizeNoopReason,
1916 },
1917}
1918
1919#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1921pub struct PaneDragResizeTransition {
1922 pub transition_id: u64,
1923 pub sequence: u64,
1924 pub from: PaneDragResizeState,
1925 pub to: PaneDragResizeState,
1926 pub effect: PaneDragResizeEffect,
1927}
1928
1929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1931pub struct PaneDragResizeMachine {
1932 state: PaneDragResizeState,
1933 drag_threshold: u16,
1934 update_hysteresis: u16,
1935 transition_counter: u64,
1936}
1937
1938impl Default for PaneDragResizeMachine {
1939 fn default() -> Self {
1940 Self {
1941 state: PaneDragResizeState::Idle,
1942 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1943 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1944 transition_counter: 0,
1945 }
1946 }
1947}
1948
1949impl PaneDragResizeMachine {
1950 pub fn new(drag_threshold: u16) -> Result<Self, PaneDragResizeMachineError> {
1952 Self::new_with_hysteresis(drag_threshold, PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS)
1953 }
1954
1955 pub fn new_with_hysteresis(
1958 drag_threshold: u16,
1959 update_hysteresis: u16,
1960 ) -> Result<Self, PaneDragResizeMachineError> {
1961 if drag_threshold == 0 {
1962 return Err(PaneDragResizeMachineError::InvalidDragThreshold {
1963 threshold: drag_threshold,
1964 });
1965 }
1966 if update_hysteresis == 0 {
1967 return Err(PaneDragResizeMachineError::InvalidUpdateHysteresis {
1968 hysteresis: update_hysteresis,
1969 });
1970 }
1971 Ok(Self {
1972 state: PaneDragResizeState::Idle,
1973 drag_threshold,
1974 update_hysteresis,
1975 transition_counter: 0,
1976 })
1977 }
1978
1979 #[must_use]
1981 pub const fn state(&self) -> PaneDragResizeState {
1982 self.state
1983 }
1984
1985 #[must_use]
1987 pub const fn drag_threshold(&self) -> u16 {
1988 self.drag_threshold
1989 }
1990
1991 #[must_use]
1993 pub const fn update_hysteresis(&self) -> u16 {
1994 self.update_hysteresis
1995 }
1996
1997 #[must_use]
1999 pub const fn is_active(&self) -> bool {
2000 !matches!(self.state, PaneDragResizeState::Idle)
2001 }
2002
2003 pub fn force_cancel(&mut self) -> Option<PaneDragResizeTransition> {
2013 let from = self.state;
2014 match from {
2015 PaneDragResizeState::Idle => None,
2016 PaneDragResizeState::Armed {
2017 target, pointer_id, ..
2018 }
2019 | PaneDragResizeState::Dragging {
2020 target, pointer_id, ..
2021 } => {
2022 self.state = PaneDragResizeState::Idle;
2023 self.transition_counter = self.transition_counter.saturating_add(1);
2024 Some(PaneDragResizeTransition {
2025 transition_id: self.transition_counter,
2026 sequence: 0,
2027 from,
2028 to: PaneDragResizeState::Idle,
2029 effect: PaneDragResizeEffect::Canceled {
2030 target: Some(target),
2031 pointer_id: Some(pointer_id),
2032 reason: PaneCancelReason::Programmatic,
2033 },
2034 })
2035 }
2036 }
2037 }
2038
2039 pub fn apply_event(
2042 &mut self,
2043 event: &PaneSemanticInputEvent,
2044 ) -> Result<PaneDragResizeTransition, PaneDragResizeMachineError> {
2045 event
2046 .validate()
2047 .map_err(PaneDragResizeMachineError::InvalidEvent)?;
2048
2049 let from = self.state;
2050 let effect = match (self.state, &event.kind) {
2051 (
2052 PaneDragResizeState::Idle,
2053 PaneSemanticInputEventKind::PointerDown {
2054 target,
2055 pointer_id,
2056 position,
2057 ..
2058 },
2059 ) => {
2060 self.state = PaneDragResizeState::Armed {
2061 target: *target,
2062 pointer_id: *pointer_id,
2063 origin: *position,
2064 current: *position,
2065 started_sequence: event.sequence,
2066 };
2067 PaneDragResizeEffect::Armed {
2068 target: *target,
2069 pointer_id: *pointer_id,
2070 origin: *position,
2071 }
2072 }
2073 (
2074 PaneDragResizeState::Idle,
2075 PaneSemanticInputEventKind::KeyboardResize {
2076 target,
2077 direction,
2078 units,
2079 },
2080 ) => PaneDragResizeEffect::KeyboardApplied {
2081 target: *target,
2082 direction: *direction,
2083 units: *units,
2084 },
2085 (
2086 PaneDragResizeState::Idle,
2087 PaneSemanticInputEventKind::WheelNudge { target, lines },
2088 ) => PaneDragResizeEffect::WheelApplied {
2089 target: *target,
2090 lines: *lines,
2091 },
2092 (PaneDragResizeState::Idle, _) => PaneDragResizeEffect::Noop {
2093 reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag,
2094 },
2095 (
2096 PaneDragResizeState::Armed {
2097 target,
2098 pointer_id,
2099 origin,
2100 current: _,
2101 started_sequence,
2102 },
2103 PaneSemanticInputEventKind::PointerMove {
2104 target: incoming_target,
2105 pointer_id: incoming_pointer_id,
2106 position,
2107 ..
2108 },
2109 ) => {
2110 if *incoming_pointer_id != pointer_id {
2111 PaneDragResizeEffect::Noop {
2112 reason: PaneDragResizeNoopReason::PointerMismatch,
2113 }
2114 } else if *incoming_target != target {
2115 PaneDragResizeEffect::Noop {
2116 reason: PaneDragResizeNoopReason::TargetMismatch,
2117 }
2118 } else {
2119 self.state = PaneDragResizeState::Armed {
2120 target,
2121 pointer_id,
2122 origin,
2123 current: *position,
2124 started_sequence,
2125 };
2126 if crossed_drag_threshold(origin, *position, self.drag_threshold) {
2127 self.state = PaneDragResizeState::Dragging {
2128 target,
2129 pointer_id,
2130 origin,
2131 current: *position,
2132 started_sequence,
2133 drag_started_sequence: event.sequence,
2134 };
2135 let (total_delta_x, total_delta_y) = delta(origin, *position);
2136 PaneDragResizeEffect::DragStarted {
2137 target,
2138 pointer_id,
2139 origin,
2140 current: *position,
2141 total_delta_x,
2142 total_delta_y,
2143 }
2144 } else {
2145 PaneDragResizeEffect::Noop {
2146 reason: PaneDragResizeNoopReason::ThresholdNotReached,
2147 }
2148 }
2149 }
2150 }
2151 (
2152 PaneDragResizeState::Armed {
2153 target,
2154 pointer_id,
2155 origin,
2156 ..
2157 },
2158 PaneSemanticInputEventKind::PointerUp {
2159 target: incoming_target,
2160 pointer_id: incoming_pointer_id,
2161 position,
2162 ..
2163 },
2164 ) => {
2165 if *incoming_pointer_id != pointer_id {
2166 PaneDragResizeEffect::Noop {
2167 reason: PaneDragResizeNoopReason::PointerMismatch,
2168 }
2169 } else if *incoming_target != target {
2170 PaneDragResizeEffect::Noop {
2171 reason: PaneDragResizeNoopReason::TargetMismatch,
2172 }
2173 } else {
2174 self.state = PaneDragResizeState::Idle;
2175 let (total_delta_x, total_delta_y) = delta(origin, *position);
2176 PaneDragResizeEffect::Committed {
2177 target,
2178 pointer_id,
2179 origin,
2180 end: *position,
2181 total_delta_x,
2182 total_delta_y,
2183 }
2184 }
2185 }
2186 (
2187 PaneDragResizeState::Armed {
2188 target, pointer_id, ..
2189 },
2190 PaneSemanticInputEventKind::Cancel {
2191 target: incoming_target,
2192 reason,
2193 },
2194 ) => {
2195 if !cancel_target_matches(target, *incoming_target) {
2196 PaneDragResizeEffect::Noop {
2197 reason: PaneDragResizeNoopReason::TargetMismatch,
2198 }
2199 } else {
2200 self.state = PaneDragResizeState::Idle;
2201 PaneDragResizeEffect::Canceled {
2202 target: Some(target),
2203 pointer_id: Some(pointer_id),
2204 reason: *reason,
2205 }
2206 }
2207 }
2208 (
2209 PaneDragResizeState::Armed {
2210 target, pointer_id, ..
2211 },
2212 PaneSemanticInputEventKind::Blur {
2213 target: incoming_target,
2214 },
2215 ) => {
2216 if !cancel_target_matches(target, *incoming_target) {
2217 PaneDragResizeEffect::Noop {
2218 reason: PaneDragResizeNoopReason::TargetMismatch,
2219 }
2220 } else {
2221 self.state = PaneDragResizeState::Idle;
2222 PaneDragResizeEffect::Canceled {
2223 target: Some(target),
2224 pointer_id: Some(pointer_id),
2225 reason: PaneCancelReason::Blur,
2226 }
2227 }
2228 }
2229 (PaneDragResizeState::Armed { .. }, PaneSemanticInputEventKind::PointerDown { .. }) => {
2230 PaneDragResizeEffect::Noop {
2231 reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2232 }
2233 }
2234 (
2235 PaneDragResizeState::Armed { .. },
2236 PaneSemanticInputEventKind::KeyboardResize { .. }
2237 | PaneSemanticInputEventKind::WheelNudge { .. },
2238 ) => PaneDragResizeEffect::Noop {
2239 reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2240 },
2241 (
2242 PaneDragResizeState::Dragging {
2243 target,
2244 pointer_id,
2245 origin,
2246 current,
2247 started_sequence,
2248 drag_started_sequence,
2249 },
2250 PaneSemanticInputEventKind::PointerMove {
2251 target: incoming_target,
2252 pointer_id: incoming_pointer_id,
2253 position,
2254 ..
2255 },
2256 ) => {
2257 if *incoming_pointer_id != pointer_id {
2258 PaneDragResizeEffect::Noop {
2259 reason: PaneDragResizeNoopReason::PointerMismatch,
2260 }
2261 } else if *incoming_target != target {
2262 PaneDragResizeEffect::Noop {
2263 reason: PaneDragResizeNoopReason::TargetMismatch,
2264 }
2265 } else {
2266 let previous = current;
2267 if !crossed_drag_threshold(previous, *position, self.update_hysteresis) {
2268 PaneDragResizeEffect::Noop {
2269 reason: PaneDragResizeNoopReason::BelowHysteresis,
2270 }
2271 } else {
2272 let (delta_x, delta_y) = delta(previous, *position);
2273 let (total_delta_x, total_delta_y) = delta(origin, *position);
2274 self.state = PaneDragResizeState::Dragging {
2275 target,
2276 pointer_id,
2277 origin,
2278 current: *position,
2279 started_sequence,
2280 drag_started_sequence,
2281 };
2282 PaneDragResizeEffect::DragUpdated {
2283 target,
2284 pointer_id,
2285 previous,
2286 current: *position,
2287 delta_x,
2288 delta_y,
2289 total_delta_x,
2290 total_delta_y,
2291 }
2292 }
2293 }
2294 }
2295 (
2296 PaneDragResizeState::Dragging {
2297 target,
2298 pointer_id,
2299 origin,
2300 ..
2301 },
2302 PaneSemanticInputEventKind::PointerUp {
2303 target: incoming_target,
2304 pointer_id: incoming_pointer_id,
2305 position,
2306 ..
2307 },
2308 ) => {
2309 if *incoming_pointer_id != pointer_id {
2310 PaneDragResizeEffect::Noop {
2311 reason: PaneDragResizeNoopReason::PointerMismatch,
2312 }
2313 } else if *incoming_target != target {
2314 PaneDragResizeEffect::Noop {
2315 reason: PaneDragResizeNoopReason::TargetMismatch,
2316 }
2317 } else {
2318 self.state = PaneDragResizeState::Idle;
2319 let (total_delta_x, total_delta_y) = delta(origin, *position);
2320 PaneDragResizeEffect::Committed {
2321 target,
2322 pointer_id,
2323 origin,
2324 end: *position,
2325 total_delta_x,
2326 total_delta_y,
2327 }
2328 }
2329 }
2330 (
2331 PaneDragResizeState::Dragging {
2332 target, pointer_id, ..
2333 },
2334 PaneSemanticInputEventKind::Cancel {
2335 target: incoming_target,
2336 reason,
2337 },
2338 ) => {
2339 if !cancel_target_matches(target, *incoming_target) {
2340 PaneDragResizeEffect::Noop {
2341 reason: PaneDragResizeNoopReason::TargetMismatch,
2342 }
2343 } else {
2344 self.state = PaneDragResizeState::Idle;
2345 PaneDragResizeEffect::Canceled {
2346 target: Some(target),
2347 pointer_id: Some(pointer_id),
2348 reason: *reason,
2349 }
2350 }
2351 }
2352 (
2353 PaneDragResizeState::Dragging {
2354 target, pointer_id, ..
2355 },
2356 PaneSemanticInputEventKind::Blur {
2357 target: incoming_target,
2358 },
2359 ) => {
2360 if !cancel_target_matches(target, *incoming_target) {
2361 PaneDragResizeEffect::Noop {
2362 reason: PaneDragResizeNoopReason::TargetMismatch,
2363 }
2364 } else {
2365 self.state = PaneDragResizeState::Idle;
2366 PaneDragResizeEffect::Canceled {
2367 target: Some(target),
2368 pointer_id: Some(pointer_id),
2369 reason: PaneCancelReason::Blur,
2370 }
2371 }
2372 }
2373 (
2374 PaneDragResizeState::Dragging { .. },
2375 PaneSemanticInputEventKind::PointerDown { .. },
2376 ) => PaneDragResizeEffect::Noop {
2377 reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2378 },
2379 (
2380 PaneDragResizeState::Dragging { .. },
2381 PaneSemanticInputEventKind::KeyboardResize { .. }
2382 | PaneSemanticInputEventKind::WheelNudge { .. },
2383 ) => PaneDragResizeEffect::Noop {
2384 reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2385 },
2386 };
2387
2388 self.transition_counter = self.transition_counter.saturating_add(1);
2389 Ok(PaneDragResizeTransition {
2390 transition_id: self.transition_counter,
2391 sequence: event.sequence,
2392 from,
2393 to: self.state,
2394 effect,
2395 })
2396 }
2397}
2398
2399#[derive(Debug, Clone, PartialEq, Eq)]
2401pub enum PaneDragResizeMachineError {
2402 InvalidDragThreshold { threshold: u16 },
2403 InvalidUpdateHysteresis { hysteresis: u16 },
2404 InvalidEvent(PaneSemanticInputEventError),
2405}
2406
2407impl fmt::Display for PaneDragResizeMachineError {
2408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2409 match self {
2410 Self::InvalidDragThreshold { threshold } => {
2411 write!(f, "drag threshold must be > 0 (got {threshold})")
2412 }
2413 Self::InvalidUpdateHysteresis { hysteresis } => {
2414 write!(f, "update hysteresis must be > 0 (got {hysteresis})")
2415 }
2416 Self::InvalidEvent(error) => write!(f, "invalid semantic pane input event: {error}"),
2417 }
2418 }
2419}
2420
2421impl std::error::Error for PaneDragResizeMachineError {
2422 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2423 if let Self::InvalidEvent(error) = self {
2424 return Some(error);
2425 }
2426 None
2427 }
2428}
2429
2430fn delta(origin: PanePointerPosition, current: PanePointerPosition) -> (i32, i32) {
2431 (current.x - origin.x, current.y - origin.y)
2432}
2433
2434fn crossed_drag_threshold(
2435 origin: PanePointerPosition,
2436 current: PanePointerPosition,
2437 threshold: u16,
2438) -> bool {
2439 let (dx, dy) = delta(origin, current);
2440 let threshold = i64::from(threshold);
2441 let squared_distance = i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy);
2442 squared_distance >= threshold * threshold
2443}
2444
2445fn cancel_target_matches(active: PaneResizeTarget, incoming: Option<PaneResizeTarget>) -> bool {
2446 incoming.is_none() || incoming == Some(active)
2447}
2448
2449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2451#[serde(tag = "op", rename_all = "snake_case")]
2452pub enum PaneOperation {
2453 SplitLeaf {
2456 target: PaneId,
2457 axis: SplitAxis,
2458 ratio: PaneSplitRatio,
2459 placement: PanePlacement,
2460 new_leaf: PaneLeaf,
2461 },
2462 CloseNode { target: PaneId },
2464 MoveSubtree {
2467 source: PaneId,
2468 target: PaneId,
2469 axis: SplitAxis,
2470 ratio: PaneSplitRatio,
2471 placement: PanePlacement,
2472 },
2473 SwapNodes { first: PaneId, second: PaneId },
2475 NormalizeRatios,
2477}
2478
2479impl PaneOperation {
2480 #[must_use]
2482 pub const fn kind(&self) -> PaneOperationKind {
2483 match self {
2484 Self::SplitLeaf { .. } => PaneOperationKind::SplitLeaf,
2485 Self::CloseNode { .. } => PaneOperationKind::CloseNode,
2486 Self::MoveSubtree { .. } => PaneOperationKind::MoveSubtree,
2487 Self::SwapNodes { .. } => PaneOperationKind::SwapNodes,
2488 Self::NormalizeRatios => PaneOperationKind::NormalizeRatios,
2489 }
2490 }
2491
2492 #[must_use]
2493 fn referenced_nodes(&self) -> Vec<PaneId> {
2494 match self {
2495 Self::SplitLeaf { target, .. } | Self::CloseNode { target } => vec![*target],
2496 Self::MoveSubtree { source, target, .. }
2497 | Self::SwapNodes {
2498 first: source,
2499 second: target,
2500 } => {
2501 vec![*source, *target]
2502 }
2503 Self::NormalizeRatios => Vec::new(),
2504 }
2505 }
2506}
2507
2508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2510#[serde(rename_all = "snake_case")]
2511pub enum PaneOperationKind {
2512 SplitLeaf,
2513 CloseNode,
2514 MoveSubtree,
2515 SwapNodes,
2516 NormalizeRatios,
2517}
2518
2519#[derive(Debug, Clone, PartialEq, Eq)]
2521pub struct PaneOperationOutcome {
2522 pub operation_id: u64,
2523 pub kind: PaneOperationKind,
2524 pub touched_nodes: Vec<PaneId>,
2525 pub before_hash: u64,
2526 pub after_hash: u64,
2527}
2528
2529#[derive(Debug, Clone, PartialEq, Eq)]
2531pub struct PaneOperationError {
2532 pub operation_id: u64,
2533 pub kind: PaneOperationKind,
2534 pub touched_nodes: Vec<PaneId>,
2535 pub before_hash: u64,
2536 pub after_hash: u64,
2537 pub reason: PaneOperationFailure,
2538}
2539
2540#[derive(Debug, Clone, PartialEq, Eq)]
2542pub enum PaneOperationFailure {
2543 MissingNode {
2544 node_id: PaneId,
2545 },
2546 NodeNotLeaf {
2547 node_id: PaneId,
2548 },
2549 ParentNotSplit {
2550 node_id: PaneId,
2551 },
2552 ParentChildMismatch {
2553 parent: PaneId,
2554 child: PaneId,
2555 },
2556 CannotCloseRoot {
2557 node_id: PaneId,
2558 },
2559 CannotMoveRoot {
2560 node_id: PaneId,
2561 },
2562 SameNode {
2563 first: PaneId,
2564 second: PaneId,
2565 },
2566 AncestorConflict {
2567 ancestor: PaneId,
2568 descendant: PaneId,
2569 },
2570 TargetRemovedByDetach {
2571 target: PaneId,
2572 detached_parent: PaneId,
2573 },
2574 PaneIdOverflow {
2575 current: PaneId,
2576 },
2577 InvalidRatio {
2578 node_id: PaneId,
2579 numerator: u32,
2580 denominator: u32,
2581 },
2582 Validation(PaneModelError),
2583}
2584
2585impl fmt::Display for PaneOperationFailure {
2586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2587 match self {
2588 Self::MissingNode { node_id } => write!(f, "node {} not found", node_id.0),
2589 Self::NodeNotLeaf { node_id } => write!(f, "node {} is not a leaf", node_id.0),
2590 Self::ParentNotSplit { node_id } => {
2591 write!(f, "node {} is not a split parent", node_id.0)
2592 }
2593 Self::ParentChildMismatch { parent, child } => write!(
2594 f,
2595 "split parent {} does not reference child {}",
2596 parent.0, child.0
2597 ),
2598 Self::CannotCloseRoot { node_id } => {
2599 write!(f, "cannot close root node {}", node_id.0)
2600 }
2601 Self::CannotMoveRoot { node_id } => {
2602 write!(f, "cannot move root node {}", node_id.0)
2603 }
2604 Self::SameNode { first, second } => write!(
2605 f,
2606 "operation requires distinct nodes, got {} and {}",
2607 first.0, second.0
2608 ),
2609 Self::AncestorConflict {
2610 ancestor,
2611 descendant,
2612 } => write!(
2613 f,
2614 "operation would create cycle: node {} is an ancestor of {}",
2615 ancestor.0, descendant.0
2616 ),
2617 Self::TargetRemovedByDetach {
2618 target,
2619 detached_parent,
2620 } => write!(
2621 f,
2622 "target {} would be removed while detaching parent {}",
2623 target.0, detached_parent.0
2624 ),
2625 Self::PaneIdOverflow { current } => {
2626 write!(f, "pane id overflow after {}", current.0)
2627 }
2628 Self::InvalidRatio {
2629 node_id,
2630 numerator,
2631 denominator,
2632 } => write!(
2633 f,
2634 "split node {} has invalid ratio {numerator}/{denominator}",
2635 node_id.0
2636 ),
2637 Self::Validation(err) => write!(f, "{err}"),
2638 }
2639 }
2640}
2641
2642impl std::error::Error for PaneOperationFailure {
2643 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2644 if let Self::Validation(err) = self {
2645 return Some(err);
2646 }
2647 None
2648 }
2649}
2650
2651impl fmt::Display for PaneOperationError {
2652 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2653 write!(
2654 f,
2655 "pane op {} ({:?}) failed: {} [nodes={:?}, before_hash={:#x}, after_hash={:#x}]",
2656 self.operation_id,
2657 self.kind,
2658 self.reason,
2659 self.touched_nodes
2660 .iter()
2661 .map(|node_id| node_id.0)
2662 .collect::<Vec<_>>(),
2663 self.before_hash,
2664 self.after_hash
2665 )
2666 }
2667}
2668
2669impl std::error::Error for PaneOperationError {
2670 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2671 Some(&self.reason)
2672 }
2673}
2674
2675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2677pub struct PaneOperationJournalEntry {
2678 pub transaction_id: u64,
2679 pub sequence: u64,
2680 pub operation_id: u64,
2681 pub operation: PaneOperation,
2682 pub kind: PaneOperationKind,
2683 pub touched_nodes: Vec<PaneId>,
2684 pub before_hash: u64,
2685 pub after_hash: u64,
2686 pub result: PaneOperationJournalResult,
2687}
2688
2689#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2691#[serde(tag = "status", rename_all = "snake_case")]
2692pub enum PaneOperationJournalResult {
2693 Applied,
2694 Rejected { reason: String },
2695}
2696
2697#[derive(Debug, Clone, PartialEq, Eq)]
2699pub struct PaneTransactionOutcome {
2700 pub transaction_id: u64,
2701 pub committed: bool,
2702 pub tree: PaneTree,
2703 pub journal: Vec<PaneOperationJournalEntry>,
2704}
2705
2706#[derive(Debug, Clone, PartialEq, Eq)]
2708pub struct PaneTransaction {
2709 transaction_id: u64,
2710 sequence: u64,
2711 base_tree: PaneTree,
2712 working_tree: PaneTree,
2713 journal: Vec<PaneOperationJournalEntry>,
2714}
2715
2716impl PaneTransaction {
2717 fn new(transaction_id: u64, base_tree: PaneTree) -> Self {
2718 Self {
2719 transaction_id,
2720 sequence: 1,
2721 base_tree: base_tree.clone(),
2722 working_tree: base_tree,
2723 journal: Vec::new(),
2724 }
2725 }
2726
2727 #[must_use]
2729 pub const fn transaction_id(&self) -> u64 {
2730 self.transaction_id
2731 }
2732
2733 #[must_use]
2735 pub fn tree(&self) -> &PaneTree {
2736 &self.working_tree
2737 }
2738
2739 #[must_use]
2741 pub fn journal(&self) -> &[PaneOperationJournalEntry] {
2742 &self.journal
2743 }
2744
2745 pub fn apply_operation(
2749 &mut self,
2750 operation_id: u64,
2751 operation: PaneOperation,
2752 ) -> Result<PaneOperationOutcome, PaneOperationError> {
2753 let operation_for_journal = operation.clone();
2754 let kind = operation_for_journal.kind();
2755 let sequence = self.next_sequence();
2756
2757 match self.working_tree.apply_operation(operation_id, operation) {
2758 Ok(outcome) => {
2759 self.journal.push(PaneOperationJournalEntry {
2760 transaction_id: self.transaction_id,
2761 sequence,
2762 operation_id,
2763 operation: operation_for_journal,
2764 kind,
2765 touched_nodes: outcome.touched_nodes.clone(),
2766 before_hash: outcome.before_hash,
2767 after_hash: outcome.after_hash,
2768 result: PaneOperationJournalResult::Applied,
2769 });
2770 Ok(outcome)
2771 }
2772 Err(err) => {
2773 self.journal.push(PaneOperationJournalEntry {
2774 transaction_id: self.transaction_id,
2775 sequence,
2776 operation_id,
2777 operation: operation_for_journal,
2778 kind,
2779 touched_nodes: err.touched_nodes.clone(),
2780 before_hash: err.before_hash,
2781 after_hash: err.after_hash,
2782 result: PaneOperationJournalResult::Rejected {
2783 reason: err.reason.to_string(),
2784 },
2785 });
2786 Err(err)
2787 }
2788 }
2789 }
2790
2791 #[must_use]
2793 pub fn commit(self) -> PaneTransactionOutcome {
2794 PaneTransactionOutcome {
2795 transaction_id: self.transaction_id,
2796 committed: true,
2797 tree: self.working_tree,
2798 journal: self.journal,
2799 }
2800 }
2801
2802 #[must_use]
2804 pub fn rollback(self) -> PaneTransactionOutcome {
2805 PaneTransactionOutcome {
2806 transaction_id: self.transaction_id,
2807 committed: false,
2808 tree: self.base_tree,
2809 journal: self.journal,
2810 }
2811 }
2812
2813 fn next_sequence(&mut self) -> u64 {
2814 let sequence = self.sequence;
2815 self.sequence = self.sequence.saturating_add(1);
2816 sequence
2817 }
2818}
2819
2820#[derive(Debug, Clone, PartialEq, Eq)]
2822pub struct PaneTree {
2823 schema_version: u16,
2824 root: PaneId,
2825 next_id: PaneId,
2826 nodes: BTreeMap<PaneId, PaneNodeRecord>,
2827 extensions: BTreeMap<String, String>,
2828}
2829
2830impl PaneTree {
2831 #[must_use]
2833 pub fn singleton(surface_key: impl Into<String>) -> Self {
2834 let root = PaneId::MIN;
2835 let mut nodes = BTreeMap::new();
2836 let _ = nodes.insert(
2837 root,
2838 PaneNodeRecord::leaf(root, None, PaneLeaf::new(surface_key)),
2839 );
2840 Self {
2841 schema_version: PANE_TREE_SCHEMA_VERSION,
2842 root,
2843 next_id: root.checked_next().unwrap_or(root),
2844 nodes,
2845 extensions: BTreeMap::new(),
2846 }
2847 }
2848
2849 pub fn from_snapshot(mut snapshot: PaneTreeSnapshot) -> Result<Self, PaneModelError> {
2851 if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
2852 return Err(PaneModelError::UnsupportedSchemaVersion {
2853 version: snapshot.schema_version,
2854 });
2855 }
2856 snapshot.canonicalize();
2857 let mut nodes = BTreeMap::new();
2858 for node in snapshot.nodes {
2859 let node_id = node.id;
2860 if nodes.insert(node_id, node).is_some() {
2861 return Err(PaneModelError::DuplicateNodeId { node_id });
2862 }
2863 }
2864 validate_tree(snapshot.root, snapshot.next_id, &nodes)?;
2865 Ok(Self {
2866 schema_version: snapshot.schema_version,
2867 root: snapshot.root,
2868 next_id: snapshot.next_id,
2869 nodes,
2870 extensions: snapshot.extensions,
2871 })
2872 }
2873
2874 #[must_use]
2876 pub fn to_snapshot(&self) -> PaneTreeSnapshot {
2877 let mut snapshot = PaneTreeSnapshot {
2878 schema_version: self.schema_version,
2879 root: self.root,
2880 next_id: self.next_id,
2881 nodes: self.nodes.values().cloned().collect(),
2882 extensions: self.extensions.clone(),
2883 };
2884 snapshot.canonicalize();
2885 snapshot
2886 }
2887
2888 #[must_use]
2890 pub const fn root(&self) -> PaneId {
2891 self.root
2892 }
2893
2894 #[must_use]
2896 pub const fn next_id(&self) -> PaneId {
2897 self.next_id
2898 }
2899
2900 #[must_use]
2902 pub const fn schema_version(&self) -> u16 {
2903 self.schema_version
2904 }
2905
2906 #[must_use]
2908 pub fn node(&self, id: PaneId) -> Option<&PaneNodeRecord> {
2909 self.nodes.get(&id)
2910 }
2911
2912 pub fn nodes(&self) -> impl Iterator<Item = &PaneNodeRecord> {
2914 self.nodes.values()
2915 }
2916
2917 pub fn validate(&self) -> Result<(), PaneModelError> {
2919 validate_tree(self.root, self.next_id, &self.nodes)
2920 }
2921
2922 #[must_use]
2924 pub fn invariant_report(&self) -> PaneInvariantReport {
2925 self.to_snapshot().invariant_report()
2926 }
2927
2928 #[must_use]
2932 pub fn state_hash(&self) -> u64 {
2933 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
2934 const PRIME: u64 = 0x0000_0001_0000_01b3;
2935
2936 fn mix(hash: &mut u64, byte: u8) {
2937 *hash ^= u64::from(byte);
2938 *hash = hash.wrapping_mul(PRIME);
2939 }
2940
2941 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
2942 for byte in bytes {
2943 mix(hash, *byte);
2944 }
2945 }
2946
2947 fn mix_u16(hash: &mut u64, value: u16) {
2948 mix_bytes(hash, &value.to_le_bytes());
2949 }
2950
2951 fn mix_u32(hash: &mut u64, value: u32) {
2952 mix_bytes(hash, &value.to_le_bytes());
2953 }
2954
2955 fn mix_u64(hash: &mut u64, value: u64) {
2956 mix_bytes(hash, &value.to_le_bytes());
2957 }
2958
2959 fn mix_bool(hash: &mut u64, value: bool) {
2960 mix(hash, u8::from(value));
2961 }
2962
2963 fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
2964 match value {
2965 Some(value) => {
2966 mix(hash, 1);
2967 mix_u16(hash, value);
2968 }
2969 None => mix(hash, 0),
2970 }
2971 }
2972
2973 fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
2974 match value {
2975 Some(value) => {
2976 mix(hash, 1);
2977 mix_u64(hash, value.get());
2978 }
2979 None => mix(hash, 0),
2980 }
2981 }
2982
2983 fn mix_str(hash: &mut u64, value: &str) {
2984 mix_u64(hash, value.len() as u64);
2985 mix_bytes(hash, value.as_bytes());
2986 }
2987
2988 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
2989 mix_u64(hash, extensions.len() as u64);
2990 for (key, value) in extensions {
2991 mix_str(hash, key);
2992 mix_str(hash, value);
2993 }
2994 }
2995
2996 fn mix_constraints(hash: &mut u64, constraints: PaneConstraints) {
2997 mix_u16(hash, constraints.min_width);
2998 mix_u16(hash, constraints.min_height);
2999 mix_opt_u16(hash, constraints.max_width);
3000 mix_opt_u16(hash, constraints.max_height);
3001 mix_bool(hash, constraints.collapsible);
3002 }
3003
3004 let mut hash = OFFSET_BASIS;
3005 mix_u16(&mut hash, self.schema_version);
3006 mix_u64(&mut hash, self.root.get());
3007 mix_u64(&mut hash, self.next_id.get());
3008 mix_extensions(&mut hash, &self.extensions);
3009 mix_u64(&mut hash, self.nodes.len() as u64);
3010
3011 for node in self.nodes.values() {
3012 mix_u64(&mut hash, node.id.get());
3013 mix_opt_pane_id(&mut hash, node.parent);
3014 mix_constraints(&mut hash, node.constraints);
3015 mix_extensions(&mut hash, &node.extensions);
3016
3017 match &node.kind {
3018 PaneNodeKind::Leaf(leaf) => {
3019 mix(&mut hash, 1);
3020 mix_str(&mut hash, &leaf.surface_key);
3021 mix_extensions(&mut hash, &leaf.extensions);
3022 }
3023 PaneNodeKind::Split(split) => {
3024 mix(&mut hash, 2);
3025 let axis_byte = match split.axis {
3026 SplitAxis::Horizontal => 1,
3027 SplitAxis::Vertical => 2,
3028 };
3029 mix(&mut hash, axis_byte);
3030 mix_u32(&mut hash, split.ratio.numerator());
3031 mix_u32(&mut hash, split.ratio.denominator());
3032 mix_u64(&mut hash, split.first.get());
3033 mix_u64(&mut hash, split.second.get());
3034 }
3035 }
3036 }
3037
3038 hash
3039 }
3040
3041 #[must_use]
3046 pub fn begin_transaction(&self, transaction_id: u64) -> PaneTransaction {
3047 PaneTransaction::new(transaction_id, self.clone())
3048 }
3049
3050 pub fn apply_operation(
3055 &mut self,
3056 operation_id: u64,
3057 operation: PaneOperation,
3058 ) -> Result<PaneOperationOutcome, PaneOperationError> {
3059 let kind = operation.kind();
3060 let before_hash = self.state_hash();
3061 let mut working = self.clone();
3062 let mut touched = operation
3063 .referenced_nodes()
3064 .into_iter()
3065 .collect::<BTreeSet<_>>();
3066
3067 if let Err(reason) = working.apply_operation_inner(operation, &mut touched) {
3068 return Err(PaneOperationError {
3069 operation_id,
3070 kind,
3071 touched_nodes: touched.into_iter().collect(),
3072 before_hash,
3073 after_hash: working.state_hash(),
3074 reason,
3075 });
3076 }
3077
3078 if let Err(err) = working.validate() {
3079 return Err(PaneOperationError {
3080 operation_id,
3081 kind,
3082 touched_nodes: touched.into_iter().collect(),
3083 before_hash,
3084 after_hash: working.state_hash(),
3085 reason: PaneOperationFailure::Validation(err),
3086 });
3087 }
3088
3089 let after_hash = working.state_hash();
3090 *self = working;
3091
3092 Ok(PaneOperationOutcome {
3093 operation_id,
3094 kind,
3095 touched_nodes: touched.into_iter().collect(),
3096 before_hash,
3097 after_hash,
3098 })
3099 }
3100
3101 fn apply_operation_inner(
3102 &mut self,
3103 operation: PaneOperation,
3104 touched: &mut BTreeSet<PaneId>,
3105 ) -> Result<(), PaneOperationFailure> {
3106 match operation {
3107 PaneOperation::SplitLeaf {
3108 target,
3109 axis,
3110 ratio,
3111 placement,
3112 new_leaf,
3113 } => self.apply_split_leaf(target, axis, ratio, placement, new_leaf, touched),
3114 PaneOperation::CloseNode { target } => self.apply_close_node(target, touched),
3115 PaneOperation::MoveSubtree {
3116 source,
3117 target,
3118 axis,
3119 ratio,
3120 placement,
3121 } => self.apply_move_subtree(source, target, axis, ratio, placement, touched),
3122 PaneOperation::SwapNodes { first, second } => {
3123 self.apply_swap_nodes(first, second, touched)
3124 }
3125 PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
3126 }
3127 }
3128
3129 fn apply_split_leaf(
3130 &mut self,
3131 target: PaneId,
3132 axis: SplitAxis,
3133 ratio: PaneSplitRatio,
3134 placement: PanePlacement,
3135 new_leaf: PaneLeaf,
3136 touched: &mut BTreeSet<PaneId>,
3137 ) -> Result<(), PaneOperationFailure> {
3138 let target_parent = match self.nodes.get(&target) {
3139 Some(PaneNodeRecord {
3140 parent,
3141 kind: PaneNodeKind::Leaf(_),
3142 ..
3143 }) => *parent,
3144 Some(_) => {
3145 return Err(PaneOperationFailure::NodeNotLeaf { node_id: target });
3146 }
3147 None => {
3148 return Err(PaneOperationFailure::MissingNode { node_id: target });
3149 }
3150 };
3151
3152 let split_id = self.allocate_node_id()?;
3153 let new_leaf_id = self.allocate_node_id()?;
3154 touched.extend([target, split_id, new_leaf_id]);
3155 if let Some(parent_id) = target_parent {
3156 let _ = touched.insert(parent_id);
3157 }
3158
3159 let (first, second) = placement.ordered(target, new_leaf_id);
3160 let split_record = PaneNodeRecord::split(
3161 split_id,
3162 target_parent,
3163 PaneSplit {
3164 axis,
3165 ratio,
3166 first,
3167 second,
3168 },
3169 );
3170
3171 if let Some(target_node) = self.nodes.get_mut(&target) {
3172 target_node.parent = Some(split_id);
3173 }
3174 let _ = self.nodes.insert(
3175 new_leaf_id,
3176 PaneNodeRecord::leaf(new_leaf_id, Some(split_id), new_leaf),
3177 );
3178 let _ = self.nodes.insert(split_id, split_record);
3179
3180 if let Some(parent_id) = target_parent {
3181 self.replace_child(parent_id, target, split_id)?;
3182 } else {
3183 self.root = split_id;
3184 }
3185
3186 Ok(())
3187 }
3188
3189 fn apply_close_node(
3190 &mut self,
3191 target: PaneId,
3192 touched: &mut BTreeSet<PaneId>,
3193 ) -> Result<(), PaneOperationFailure> {
3194 if !self.nodes.contains_key(&target) {
3195 return Err(PaneOperationFailure::MissingNode { node_id: target });
3196 }
3197 if target == self.root {
3198 return Err(PaneOperationFailure::CannotCloseRoot { node_id: target });
3199 }
3200
3201 let subtree_ids = self.collect_subtree_ids(target)?;
3202 for node_id in &subtree_ids {
3203 let _ = touched.insert(*node_id);
3204 }
3205
3206 let (parent_id, sibling_id, grandparent_id) =
3207 self.promote_sibling_after_detach(target, touched)?;
3208 let _ = touched.insert(parent_id);
3209 let _ = touched.insert(sibling_id);
3210 if let Some(grandparent_id) = grandparent_id {
3211 let _ = touched.insert(grandparent_id);
3212 }
3213
3214 for node_id in subtree_ids {
3215 let _ = self.nodes.remove(&node_id);
3216 }
3217
3218 Ok(())
3219 }
3220
3221 fn apply_move_subtree(
3222 &mut self,
3223 source: PaneId,
3224 target: PaneId,
3225 axis: SplitAxis,
3226 ratio: PaneSplitRatio,
3227 placement: PanePlacement,
3228 touched: &mut BTreeSet<PaneId>,
3229 ) -> Result<(), PaneOperationFailure> {
3230 if source == target {
3231 return Err(PaneOperationFailure::SameNode {
3232 first: source,
3233 second: target,
3234 });
3235 }
3236
3237 if !self.nodes.contains_key(&source) {
3238 return Err(PaneOperationFailure::MissingNode { node_id: source });
3239 }
3240 if !self.nodes.contains_key(&target) {
3241 return Err(PaneOperationFailure::MissingNode { node_id: target });
3242 }
3243
3244 if source == self.root {
3245 return Err(PaneOperationFailure::CannotMoveRoot { node_id: source });
3246 }
3247 if self.is_ancestor(source, target)? {
3248 return Err(PaneOperationFailure::AncestorConflict {
3249 ancestor: source,
3250 descendant: target,
3251 });
3252 }
3253
3254 let source_parent = self
3255 .nodes
3256 .get(&source)
3257 .and_then(|node| node.parent)
3258 .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: source })?;
3259 if source_parent == target {
3260 return Err(PaneOperationFailure::TargetRemovedByDetach {
3261 target,
3262 detached_parent: source_parent,
3263 });
3264 }
3265
3266 let _ = touched.insert(source);
3267 let _ = touched.insert(target);
3268 let _ = touched.insert(source_parent);
3269
3270 let (removed_parent, sibling_id, grandparent_id) =
3271 self.promote_sibling_after_detach(source, touched)?;
3272 let _ = touched.insert(removed_parent);
3273 let _ = touched.insert(sibling_id);
3274 if let Some(grandparent_id) = grandparent_id {
3275 let _ = touched.insert(grandparent_id);
3276 }
3277
3278 if let Some(source_node) = self.nodes.get_mut(&source) {
3279 source_node.parent = None;
3280 }
3281
3282 if !self.nodes.contains_key(&target) {
3283 return Err(PaneOperationFailure::MissingNode { node_id: target });
3284 }
3285 let target_parent = self.nodes.get(&target).and_then(|node| node.parent);
3286 if let Some(parent_id) = target_parent {
3287 let _ = touched.insert(parent_id);
3288 }
3289
3290 let split_id = self.allocate_node_id()?;
3291 let _ = touched.insert(split_id);
3292 let (first, second) = placement.ordered(target, source);
3293
3294 if let Some(target_node) = self.nodes.get_mut(&target) {
3295 target_node.parent = Some(split_id);
3296 }
3297 if let Some(source_node) = self.nodes.get_mut(&source) {
3298 source_node.parent = Some(split_id);
3299 }
3300
3301 let _ = self.nodes.insert(
3302 split_id,
3303 PaneNodeRecord::split(
3304 split_id,
3305 target_parent,
3306 PaneSplit {
3307 axis,
3308 ratio,
3309 first,
3310 second,
3311 },
3312 ),
3313 );
3314
3315 if let Some(parent_id) = target_parent {
3316 self.replace_child(parent_id, target, split_id)?;
3317 } else {
3318 self.root = split_id;
3319 }
3320
3321 Ok(())
3322 }
3323
3324 fn apply_swap_nodes(
3325 &mut self,
3326 first: PaneId,
3327 second: PaneId,
3328 touched: &mut BTreeSet<PaneId>,
3329 ) -> Result<(), PaneOperationFailure> {
3330 if first == second {
3331 return Ok(());
3332 }
3333
3334 if !self.nodes.contains_key(&first) {
3335 return Err(PaneOperationFailure::MissingNode { node_id: first });
3336 }
3337 if !self.nodes.contains_key(&second) {
3338 return Err(PaneOperationFailure::MissingNode { node_id: second });
3339 }
3340 if self.is_ancestor(first, second)? {
3341 return Err(PaneOperationFailure::AncestorConflict {
3342 ancestor: first,
3343 descendant: second,
3344 });
3345 }
3346 if self.is_ancestor(second, first)? {
3347 return Err(PaneOperationFailure::AncestorConflict {
3348 ancestor: second,
3349 descendant: first,
3350 });
3351 }
3352
3353 let _ = touched.insert(first);
3354 let _ = touched.insert(second);
3355
3356 let first_parent = self.nodes.get(&first).and_then(|node| node.parent);
3357 let second_parent = self.nodes.get(&second).and_then(|node| node.parent);
3358
3359 if first_parent == second_parent {
3360 if let Some(parent_id) = first_parent {
3361 let _ = touched.insert(parent_id);
3362 self.swap_children(parent_id, first, second)?;
3363 }
3364 return Ok(());
3365 }
3366
3367 match (first_parent, second_parent) {
3368 (Some(left_parent), Some(right_parent)) => {
3369 let _ = touched.insert(left_parent);
3370 let _ = touched.insert(right_parent);
3371 self.replace_child(left_parent, first, second)?;
3372 self.replace_child(right_parent, second, first)?;
3373 if let Some(left) = self.nodes.get_mut(&first) {
3374 left.parent = Some(right_parent);
3375 }
3376 if let Some(right) = self.nodes.get_mut(&second) {
3377 right.parent = Some(left_parent);
3378 }
3379 }
3380 (None, Some(parent_id)) => {
3381 let _ = touched.insert(parent_id);
3382 self.replace_child(parent_id, second, first)?;
3383 if let Some(first_node) = self.nodes.get_mut(&first) {
3384 first_node.parent = Some(parent_id);
3385 }
3386 if let Some(second_node) = self.nodes.get_mut(&second) {
3387 second_node.parent = None;
3388 }
3389 self.root = second;
3390 }
3391 (Some(parent_id), None) => {
3392 let _ = touched.insert(parent_id);
3393 self.replace_child(parent_id, first, second)?;
3394 if let Some(first_node) = self.nodes.get_mut(&first) {
3395 first_node.parent = None;
3396 }
3397 if let Some(second_node) = self.nodes.get_mut(&second) {
3398 second_node.parent = Some(parent_id);
3399 }
3400 self.root = first;
3401 }
3402 (None, None) => {}
3403 }
3404
3405 Ok(())
3406 }
3407
3408 fn apply_normalize_ratios(
3409 &mut self,
3410 touched: &mut BTreeSet<PaneId>,
3411 ) -> Result<(), PaneOperationFailure> {
3412 for node in self.nodes.values_mut() {
3413 if let PaneNodeKind::Split(split) = &mut node.kind {
3414 let normalized =
3415 PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator())
3416 .map_err(|_| PaneOperationFailure::InvalidRatio {
3417 node_id: node.id,
3418 numerator: split.ratio.numerator(),
3419 denominator: split.ratio.denominator(),
3420 })?;
3421 split.ratio = normalized;
3422 let _ = touched.insert(node.id);
3423 }
3424 }
3425 Ok(())
3426 }
3427
3428 fn replace_child(
3429 &mut self,
3430 parent_id: PaneId,
3431 old_child: PaneId,
3432 new_child: PaneId,
3433 ) -> Result<(), PaneOperationFailure> {
3434 let parent = self
3435 .nodes
3436 .get_mut(&parent_id)
3437 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3438 let PaneNodeKind::Split(split) = &mut parent.kind else {
3439 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3440 };
3441
3442 if split.first == old_child {
3443 split.first = new_child;
3444 return Ok(());
3445 }
3446 if split.second == old_child {
3447 split.second = new_child;
3448 return Ok(());
3449 }
3450
3451 Err(PaneOperationFailure::ParentChildMismatch {
3452 parent: parent_id,
3453 child: old_child,
3454 })
3455 }
3456
3457 fn swap_children(
3458 &mut self,
3459 parent_id: PaneId,
3460 left: PaneId,
3461 right: PaneId,
3462 ) -> Result<(), PaneOperationFailure> {
3463 let parent = self
3464 .nodes
3465 .get_mut(&parent_id)
3466 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3467 let PaneNodeKind::Split(split) = &mut parent.kind else {
3468 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3469 };
3470
3471 let has_pair = (split.first == left && split.second == right)
3472 || (split.first == right && split.second == left);
3473 if !has_pair {
3474 return Err(PaneOperationFailure::ParentChildMismatch {
3475 parent: parent_id,
3476 child: left,
3477 });
3478 }
3479
3480 std::mem::swap(&mut split.first, &mut split.second);
3481 Ok(())
3482 }
3483
3484 fn promote_sibling_after_detach(
3485 &mut self,
3486 detached: PaneId,
3487 touched: &mut BTreeSet<PaneId>,
3488 ) -> Result<(PaneId, PaneId, Option<PaneId>), PaneOperationFailure> {
3489 let parent_id = self
3490 .nodes
3491 .get(&detached)
3492 .ok_or(PaneOperationFailure::MissingNode { node_id: detached })?
3493 .parent
3494 .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: detached })?;
3495 let parent_node = self
3496 .nodes
3497 .get(&parent_id)
3498 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3499 let PaneNodeKind::Split(parent_split) = &parent_node.kind else {
3500 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3501 };
3502
3503 let sibling_id = if parent_split.first == detached {
3504 parent_split.second
3505 } else if parent_split.second == detached {
3506 parent_split.first
3507 } else {
3508 return Err(PaneOperationFailure::ParentChildMismatch {
3509 parent: parent_id,
3510 child: detached,
3511 });
3512 };
3513
3514 let grandparent_id = parent_node.parent;
3515 let _ = touched.insert(parent_id);
3516 let _ = touched.insert(sibling_id);
3517 if let Some(grandparent_id) = grandparent_id {
3518 let _ = touched.insert(grandparent_id);
3519 self.replace_child(grandparent_id, parent_id, sibling_id)?;
3520 } else {
3521 self.root = sibling_id;
3522 }
3523
3524 let sibling_node =
3525 self.nodes
3526 .get_mut(&sibling_id)
3527 .ok_or(PaneOperationFailure::MissingNode {
3528 node_id: sibling_id,
3529 })?;
3530 sibling_node.parent = grandparent_id;
3531 let _ = self.nodes.remove(&parent_id);
3532
3533 Ok((parent_id, sibling_id, grandparent_id))
3534 }
3535
3536 fn is_ancestor(
3537 &self,
3538 ancestor: PaneId,
3539 mut node_id: PaneId,
3540 ) -> Result<bool, PaneOperationFailure> {
3541 loop {
3542 let node = self
3543 .nodes
3544 .get(&node_id)
3545 .ok_or(PaneOperationFailure::MissingNode { node_id })?;
3546 let Some(parent_id) = node.parent else {
3547 return Ok(false);
3548 };
3549 if parent_id == ancestor {
3550 return Ok(true);
3551 }
3552 node_id = parent_id;
3553 }
3554 }
3555
3556 fn collect_subtree_ids(&self, root_id: PaneId) -> Result<Vec<PaneId>, PaneOperationFailure> {
3557 if !self.nodes.contains_key(&root_id) {
3558 return Err(PaneOperationFailure::MissingNode { node_id: root_id });
3559 }
3560
3561 let mut out = Vec::new();
3562 let mut stack = vec![root_id];
3563 while let Some(node_id) = stack.pop() {
3564 let node = self
3565 .nodes
3566 .get(&node_id)
3567 .ok_or(PaneOperationFailure::MissingNode { node_id })?;
3568 out.push(node_id);
3569 if let PaneNodeKind::Split(split) = &node.kind {
3570 stack.push(split.first);
3571 stack.push(split.second);
3572 }
3573 }
3574 Ok(out)
3575 }
3576
3577 fn allocate_node_id(&mut self) -> Result<PaneId, PaneOperationFailure> {
3578 let current = self.next_id;
3579 self.next_id = self
3580 .next_id
3581 .checked_next()
3582 .map_err(|_| PaneOperationFailure::PaneIdOverflow { current })?;
3583 Ok(current)
3584 }
3585
3586 pub fn solve_layout(&self, area: Rect) -> Result<PaneLayout, PaneModelError> {
3597 let mut rects = BTreeMap::new();
3598 self.solve_node(self.root, area, &mut rects)?;
3599 Ok(PaneLayout { area, rects })
3600 }
3601
3602 fn solve_node(
3603 &self,
3604 node_id: PaneId,
3605 area: Rect,
3606 rects: &mut BTreeMap<PaneId, Rect>,
3607 ) -> Result<(), PaneModelError> {
3608 let Some(node) = self.nodes.get(&node_id) else {
3609 return Err(PaneModelError::MissingRoot { root: node_id });
3610 };
3611
3612 validate_area_against_constraints(node_id, area, node.constraints)?;
3613 let _ = rects.insert(node_id, area);
3614
3615 let PaneNodeKind::Split(split) = &node.kind else {
3616 return Ok(());
3617 };
3618
3619 let first_node = self
3620 .nodes
3621 .get(&split.first)
3622 .ok_or(PaneModelError::MissingChild {
3623 parent: node_id,
3624 child: split.first,
3625 })?;
3626 let second_node = self
3627 .nodes
3628 .get(&split.second)
3629 .ok_or(PaneModelError::MissingChild {
3630 parent: node_id,
3631 child: split.second,
3632 })?;
3633
3634 let (first_bounds, second_bounds, available) = match split.axis {
3635 SplitAxis::Horizontal => (
3636 axis_bounds(first_node.constraints, split.axis),
3637 axis_bounds(second_node.constraints, split.axis),
3638 area.width,
3639 ),
3640 SplitAxis::Vertical => (
3641 axis_bounds(first_node.constraints, split.axis),
3642 axis_bounds(second_node.constraints, split.axis),
3643 area.height,
3644 ),
3645 };
3646
3647 let (first_size, second_size) = solve_split_sizes(
3648 node_id,
3649 split.axis,
3650 available,
3651 split.ratio,
3652 first_bounds,
3653 second_bounds,
3654 )?;
3655
3656 let (first_rect, second_rect) = match split.axis {
3657 SplitAxis::Horizontal => (
3658 Rect::new(area.x, area.y, first_size, area.height),
3659 Rect::new(
3660 area.x.saturating_add(first_size),
3661 area.y,
3662 second_size,
3663 area.height,
3664 ),
3665 ),
3666 SplitAxis::Vertical => (
3667 Rect::new(area.x, area.y, area.width, first_size),
3668 Rect::new(
3669 area.x,
3670 area.y.saturating_add(first_size),
3671 area.width,
3672 second_size,
3673 ),
3674 ),
3675 };
3676
3677 self.solve_node(split.first, first_rect, rects)?;
3678 self.solve_node(split.second, second_rect, rects)?;
3679 Ok(())
3680 }
3681}
3682
3683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3685pub struct PaneIdAllocator {
3686 next: PaneId,
3687}
3688
3689impl PaneIdAllocator {
3690 #[must_use]
3692 pub const fn with_next(next: PaneId) -> Self {
3693 Self { next }
3694 }
3695
3696 #[must_use]
3698 pub fn from_tree(tree: &PaneTree) -> Self {
3699 Self { next: tree.next_id }
3700 }
3701
3702 #[must_use]
3704 pub const fn peek(&self) -> PaneId {
3705 self.next
3706 }
3707
3708 pub fn allocate(&mut self) -> Result<PaneId, PaneModelError> {
3710 let current = self.next;
3711 self.next = self.next.checked_next()?;
3712 Ok(current)
3713 }
3714}
3715
3716impl Default for PaneIdAllocator {
3717 fn default() -> Self {
3718 Self { next: PaneId::MIN }
3719 }
3720}
3721
3722#[derive(Debug, Clone, PartialEq, Eq)]
3724pub enum PaneModelError {
3725 ZeroPaneId,
3726 UnsupportedSchemaVersion {
3727 version: u16,
3728 },
3729 DuplicateNodeId {
3730 node_id: PaneId,
3731 },
3732 MissingRoot {
3733 root: PaneId,
3734 },
3735 RootHasParent {
3736 root: PaneId,
3737 parent: PaneId,
3738 },
3739 MissingParent {
3740 node_id: PaneId,
3741 parent: PaneId,
3742 },
3743 MissingChild {
3744 parent: PaneId,
3745 child: PaneId,
3746 },
3747 MultipleParents {
3748 child: PaneId,
3749 first_parent: PaneId,
3750 second_parent: PaneId,
3751 },
3752 ParentMismatch {
3753 node_id: PaneId,
3754 expected: Option<PaneId>,
3755 actual: Option<PaneId>,
3756 },
3757 SelfReferentialSplit {
3758 node_id: PaneId,
3759 },
3760 DuplicateSplitChildren {
3761 node_id: PaneId,
3762 child: PaneId,
3763 },
3764 InvalidSplitRatio {
3765 numerator: u32,
3766 denominator: u32,
3767 },
3768 InvalidConstraint {
3769 node_id: PaneId,
3770 axis: &'static str,
3771 min: u16,
3772 max: u16,
3773 },
3774 NodeConstraintUnsatisfied {
3775 node_id: PaneId,
3776 axis: &'static str,
3777 actual: u16,
3778 min: u16,
3779 max: Option<u16>,
3780 },
3781 OverconstrainedSplit {
3782 node_id: PaneId,
3783 axis: SplitAxis,
3784 available: u16,
3785 first_min: u16,
3786 first_max: u16,
3787 second_min: u16,
3788 second_max: u16,
3789 },
3790 CycleDetected {
3791 node_id: PaneId,
3792 },
3793 UnreachableNode {
3794 node_id: PaneId,
3795 },
3796 NextIdNotGreaterThanExisting {
3797 next_id: PaneId,
3798 max_existing: PaneId,
3799 },
3800 PaneIdOverflow {
3801 current: PaneId,
3802 },
3803}
3804
3805impl fmt::Display for PaneModelError {
3806 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3807 match self {
3808 Self::ZeroPaneId => write!(f, "pane id 0 is invalid"),
3809 Self::UnsupportedSchemaVersion { version } => {
3810 write!(
3811 f,
3812 "unsupported pane schema version {version} (expected {PANE_TREE_SCHEMA_VERSION})"
3813 )
3814 }
3815 Self::DuplicateNodeId { node_id } => write!(f, "duplicate pane node id {}", node_id.0),
3816 Self::MissingRoot { root } => write!(f, "root pane node {} not found", root.0),
3817 Self::RootHasParent { root, parent } => write!(
3818 f,
3819 "root pane node {} must not have parent {}",
3820 root.0, parent.0
3821 ),
3822 Self::MissingParent { node_id, parent } => write!(
3823 f,
3824 "node {} references missing parent {}",
3825 node_id.0, parent.0
3826 ),
3827 Self::MissingChild { parent, child } => write!(
3828 f,
3829 "split node {} references missing child {}",
3830 parent.0, child.0
3831 ),
3832 Self::MultipleParents {
3833 child,
3834 first_parent,
3835 second_parent,
3836 } => write!(
3837 f,
3838 "node {} has multiple parents: {} and {}",
3839 child.0, first_parent.0, second_parent.0
3840 ),
3841 Self::ParentMismatch {
3842 node_id,
3843 expected,
3844 actual,
3845 } => write!(
3846 f,
3847 "node {} parent mismatch: expected {:?}, got {:?}",
3848 node_id.0,
3849 expected.map(PaneId::get),
3850 actual.map(PaneId::get)
3851 ),
3852 Self::SelfReferentialSplit { node_id } => {
3853 write!(f, "split node {} cannot reference itself", node_id.0)
3854 }
3855 Self::DuplicateSplitChildren { node_id, child } => write!(
3856 f,
3857 "split node {} references child {} twice",
3858 node_id.0, child.0
3859 ),
3860 Self::InvalidSplitRatio {
3861 numerator,
3862 denominator,
3863 } => write!(
3864 f,
3865 "invalid split ratio {numerator}/{denominator}: both values must be > 0"
3866 ),
3867 Self::InvalidConstraint {
3868 node_id,
3869 axis,
3870 min,
3871 max,
3872 } => write!(
3873 f,
3874 "invalid {axis} constraints for node {}: max {max} < min {min}",
3875 node_id.0
3876 ),
3877 Self::NodeConstraintUnsatisfied {
3878 node_id,
3879 axis,
3880 actual,
3881 min,
3882 max,
3883 } => write!(
3884 f,
3885 "node {} {axis}={} violates constraints [min={}, max={:?}]",
3886 node_id.0, actual, min, max
3887 ),
3888 Self::OverconstrainedSplit {
3889 node_id,
3890 axis,
3891 available,
3892 first_min,
3893 first_max,
3894 second_min,
3895 second_max,
3896 } => write!(
3897 f,
3898 "overconstrained {:?} split at node {} (available={}): first[min={}, max={}], second[min={}, max={}]",
3899 axis, node_id.0, available, first_min, first_max, second_min, second_max
3900 ),
3901 Self::CycleDetected { node_id } => {
3902 write!(f, "cycle detected at node {}", node_id.0)
3903 }
3904 Self::UnreachableNode { node_id } => {
3905 write!(f, "node {} is unreachable from root", node_id.0)
3906 }
3907 Self::NextIdNotGreaterThanExisting {
3908 next_id,
3909 max_existing,
3910 } => write!(
3911 f,
3912 "next_id {} must be greater than max existing id {}",
3913 next_id.0, max_existing.0
3914 ),
3915 Self::PaneIdOverflow { current } => {
3916 write!(f, "pane id overflow after {}", current.0)
3917 }
3918 }
3919 }
3920}
3921
3922impl std::error::Error for PaneModelError {}
3923
3924fn snapshot_state_hash(snapshot: &PaneTreeSnapshot) -> u64 {
3925 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
3926 const PRIME: u64 = 0x0000_0001_0000_01b3;
3927
3928 fn mix(hash: &mut u64, byte: u8) {
3929 *hash ^= u64::from(byte);
3930 *hash = hash.wrapping_mul(PRIME);
3931 }
3932
3933 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
3934 for byte in bytes {
3935 mix(hash, *byte);
3936 }
3937 }
3938
3939 fn mix_u16(hash: &mut u64, value: u16) {
3940 mix_bytes(hash, &value.to_le_bytes());
3941 }
3942
3943 fn mix_u32(hash: &mut u64, value: u32) {
3944 mix_bytes(hash, &value.to_le_bytes());
3945 }
3946
3947 fn mix_u64(hash: &mut u64, value: u64) {
3948 mix_bytes(hash, &value.to_le_bytes());
3949 }
3950
3951 fn mix_bool(hash: &mut u64, value: bool) {
3952 mix(hash, u8::from(value));
3953 }
3954
3955 fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
3956 match value {
3957 Some(value) => {
3958 mix(hash, 1);
3959 mix_u16(hash, value);
3960 }
3961 None => mix(hash, 0),
3962 }
3963 }
3964
3965 fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
3966 match value {
3967 Some(value) => {
3968 mix(hash, 1);
3969 mix_u64(hash, value.get());
3970 }
3971 None => mix(hash, 0),
3972 }
3973 }
3974
3975 fn mix_str(hash: &mut u64, value: &str) {
3976 mix_u64(hash, value.len() as u64);
3977 mix_bytes(hash, value.as_bytes());
3978 }
3979
3980 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
3981 mix_u64(hash, extensions.len() as u64);
3982 for (key, value) in extensions {
3983 mix_str(hash, key);
3984 mix_str(hash, value);
3985 }
3986 }
3987
3988 let mut canonical = snapshot.clone();
3989 canonical.canonicalize();
3990
3991 let mut hash = OFFSET_BASIS;
3992 mix_u16(&mut hash, canonical.schema_version);
3993 mix_u64(&mut hash, canonical.root.get());
3994 mix_u64(&mut hash, canonical.next_id.get());
3995 mix_extensions(&mut hash, &canonical.extensions);
3996 mix_u64(&mut hash, canonical.nodes.len() as u64);
3997
3998 for node in &canonical.nodes {
3999 mix_u64(&mut hash, node.id.get());
4000 mix_opt_pane_id(&mut hash, node.parent);
4001 mix_u16(&mut hash, node.constraints.min_width);
4002 mix_u16(&mut hash, node.constraints.min_height);
4003 mix_opt_u16(&mut hash, node.constraints.max_width);
4004 mix_opt_u16(&mut hash, node.constraints.max_height);
4005 mix_bool(&mut hash, node.constraints.collapsible);
4006 mix_extensions(&mut hash, &node.extensions);
4007
4008 match &node.kind {
4009 PaneNodeKind::Leaf(leaf) => {
4010 mix(&mut hash, 1);
4011 mix_str(&mut hash, &leaf.surface_key);
4012 mix_extensions(&mut hash, &leaf.extensions);
4013 }
4014 PaneNodeKind::Split(split) => {
4015 mix(&mut hash, 2);
4016 let axis_byte = match split.axis {
4017 SplitAxis::Horizontal => 1,
4018 SplitAxis::Vertical => 2,
4019 };
4020 mix(&mut hash, axis_byte);
4021 mix_u32(&mut hash, split.ratio.numerator());
4022 mix_u32(&mut hash, split.ratio.denominator());
4023 mix_u64(&mut hash, split.first.get());
4024 mix_u64(&mut hash, split.second.get());
4025 }
4026 }
4027 }
4028
4029 hash
4030}
4031
4032fn push_invariant_issue(
4033 issues: &mut Vec<PaneInvariantIssue>,
4034 code: PaneInvariantCode,
4035 repairable: bool,
4036 node_id: Option<PaneId>,
4037 related_node: Option<PaneId>,
4038 message: impl Into<String>,
4039) {
4040 issues.push(PaneInvariantIssue {
4041 code,
4042 severity: PaneInvariantSeverity::Error,
4043 repairable,
4044 node_id,
4045 related_node,
4046 message: message.into(),
4047 });
4048}
4049
4050fn dfs_collect_cycles_and_reachable(
4051 node_id: PaneId,
4052 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4053 visiting: &mut BTreeSet<PaneId>,
4054 visited: &mut BTreeSet<PaneId>,
4055 cycle_nodes: &mut BTreeSet<PaneId>,
4056) {
4057 if visiting.contains(&node_id) {
4058 let _ = cycle_nodes.insert(node_id);
4059 return;
4060 }
4061 if !visited.insert(node_id) {
4062 return;
4063 }
4064
4065 let _ = visiting.insert(node_id);
4066 if let Some(node) = nodes.get(&node_id)
4067 && let PaneNodeKind::Split(split) = &node.kind
4068 {
4069 for child in [split.first, split.second] {
4070 if nodes.contains_key(&child) {
4071 dfs_collect_cycles_and_reachable(child, nodes, visiting, visited, cycle_nodes);
4072 }
4073 }
4074 }
4075 let _ = visiting.remove(&node_id);
4076}
4077
4078fn build_invariant_report(snapshot: &PaneTreeSnapshot) -> PaneInvariantReport {
4079 let mut issues = Vec::new();
4080
4081 if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
4082 push_invariant_issue(
4083 &mut issues,
4084 PaneInvariantCode::UnsupportedSchemaVersion,
4085 false,
4086 None,
4087 None,
4088 format!(
4089 "unsupported schema version {} (expected {})",
4090 snapshot.schema_version, PANE_TREE_SCHEMA_VERSION
4091 ),
4092 );
4093 }
4094
4095 let mut nodes = BTreeMap::new();
4096 for node in &snapshot.nodes {
4097 if nodes.insert(node.id, node.clone()).is_some() {
4098 push_invariant_issue(
4099 &mut issues,
4100 PaneInvariantCode::DuplicateNodeId,
4101 false,
4102 Some(node.id),
4103 None,
4104 format!("duplicate node id {}", node.id.get()),
4105 );
4106 }
4107 }
4108
4109 if let Some(max_existing) = nodes.keys().next_back().copied()
4110 && snapshot.next_id <= max_existing
4111 {
4112 push_invariant_issue(
4113 &mut issues,
4114 PaneInvariantCode::NextIdNotGreaterThanExisting,
4115 true,
4116 Some(snapshot.next_id),
4117 Some(max_existing),
4118 format!(
4119 "next_id {} must be greater than max node id {}",
4120 snapshot.next_id.get(),
4121 max_existing.get()
4122 ),
4123 );
4124 }
4125
4126 if !nodes.contains_key(&snapshot.root) {
4127 push_invariant_issue(
4128 &mut issues,
4129 PaneInvariantCode::MissingRoot,
4130 false,
4131 Some(snapshot.root),
4132 None,
4133 format!("root node {} is missing", snapshot.root.get()),
4134 );
4135 }
4136
4137 let mut expected_parents = BTreeMap::new();
4138 for node in nodes.values() {
4139 if let Err(err) = node.constraints.validate(node.id) {
4140 push_invariant_issue(
4141 &mut issues,
4142 PaneInvariantCode::InvalidConstraint,
4143 false,
4144 Some(node.id),
4145 None,
4146 err.to_string(),
4147 );
4148 }
4149
4150 if let Some(parent) = node.parent
4151 && !nodes.contains_key(&parent)
4152 {
4153 push_invariant_issue(
4154 &mut issues,
4155 PaneInvariantCode::MissingParent,
4156 true,
4157 Some(node.id),
4158 Some(parent),
4159 format!(
4160 "node {} references missing parent {}",
4161 node.id.get(),
4162 parent.get()
4163 ),
4164 );
4165 }
4166
4167 if let PaneNodeKind::Split(split) = &node.kind {
4168 if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
4169 push_invariant_issue(
4170 &mut issues,
4171 PaneInvariantCode::InvalidSplitRatio,
4172 false,
4173 Some(node.id),
4174 None,
4175 format!(
4176 "split node {} has invalid ratio {}/{}",
4177 node.id.get(),
4178 split.ratio.numerator(),
4179 split.ratio.denominator()
4180 ),
4181 );
4182 }
4183
4184 if split.first == node.id || split.second == node.id {
4185 push_invariant_issue(
4186 &mut issues,
4187 PaneInvariantCode::SelfReferentialSplit,
4188 false,
4189 Some(node.id),
4190 None,
4191 format!("split node {} references itself", node.id.get()),
4192 );
4193 }
4194
4195 if split.first == split.second {
4196 push_invariant_issue(
4197 &mut issues,
4198 PaneInvariantCode::DuplicateSplitChildren,
4199 false,
4200 Some(node.id),
4201 Some(split.first),
4202 format!(
4203 "split node {} references child {} twice",
4204 node.id.get(),
4205 split.first.get()
4206 ),
4207 );
4208 }
4209
4210 for child in [split.first, split.second] {
4211 if !nodes.contains_key(&child) {
4212 push_invariant_issue(
4213 &mut issues,
4214 PaneInvariantCode::MissingChild,
4215 false,
4216 Some(node.id),
4217 Some(child),
4218 format!(
4219 "split node {} references missing child {}",
4220 node.id.get(),
4221 child.get()
4222 ),
4223 );
4224 continue;
4225 }
4226
4227 if let Some(first_parent) = expected_parents.insert(child, node.id)
4228 && first_parent != node.id
4229 {
4230 push_invariant_issue(
4231 &mut issues,
4232 PaneInvariantCode::MultipleParents,
4233 false,
4234 Some(child),
4235 Some(node.id),
4236 format!(
4237 "node {} has multiple split parents {} and {}",
4238 child.get(),
4239 first_parent.get(),
4240 node.id.get()
4241 ),
4242 );
4243 }
4244 }
4245 }
4246 }
4247
4248 if let Some(root_node) = nodes.get(&snapshot.root)
4249 && let Some(parent) = root_node.parent
4250 {
4251 push_invariant_issue(
4252 &mut issues,
4253 PaneInvariantCode::RootHasParent,
4254 true,
4255 Some(snapshot.root),
4256 Some(parent),
4257 format!(
4258 "root node {} must not have parent {}",
4259 snapshot.root.get(),
4260 parent.get()
4261 ),
4262 );
4263 }
4264
4265 for node in nodes.values() {
4266 let expected_parent = if node.id == snapshot.root {
4267 None
4268 } else {
4269 expected_parents.get(&node.id).copied()
4270 };
4271
4272 if node.parent != expected_parent {
4273 push_invariant_issue(
4274 &mut issues,
4275 PaneInvariantCode::ParentMismatch,
4276 true,
4277 Some(node.id),
4278 expected_parent,
4279 format!(
4280 "node {} parent mismatch: expected {:?}, got {:?}",
4281 node.id.get(),
4282 expected_parent.map(PaneId::get),
4283 node.parent.map(PaneId::get)
4284 ),
4285 );
4286 }
4287 }
4288
4289 if nodes.contains_key(&snapshot.root) {
4290 let mut visiting = BTreeSet::new();
4291 let mut visited = BTreeSet::new();
4292 let mut cycle_nodes = BTreeSet::new();
4293 dfs_collect_cycles_and_reachable(
4294 snapshot.root,
4295 &nodes,
4296 &mut visiting,
4297 &mut visited,
4298 &mut cycle_nodes,
4299 );
4300
4301 for node_id in cycle_nodes {
4302 push_invariant_issue(
4303 &mut issues,
4304 PaneInvariantCode::CycleDetected,
4305 false,
4306 Some(node_id),
4307 None,
4308 format!("cycle detected at node {}", node_id.get()),
4309 );
4310 }
4311
4312 for node_id in nodes.keys() {
4313 if !visited.contains(node_id) {
4314 push_invariant_issue(
4315 &mut issues,
4316 PaneInvariantCode::UnreachableNode,
4317 true,
4318 Some(*node_id),
4319 None,
4320 format!("node {} is unreachable from root", node_id.get()),
4321 );
4322 }
4323 }
4324 }
4325
4326 issues.sort_by(|left, right| {
4327 (
4328 left.code,
4329 left.node_id.is_none(),
4330 left.node_id,
4331 left.related_node.is_none(),
4332 left.related_node,
4333 &left.message,
4334 )
4335 .cmp(&(
4336 right.code,
4337 right.node_id.is_none(),
4338 right.node_id,
4339 right.related_node.is_none(),
4340 right.related_node,
4341 &right.message,
4342 ))
4343 });
4344
4345 PaneInvariantReport {
4346 snapshot_hash: snapshot_state_hash(snapshot),
4347 issues,
4348 }
4349}
4350
4351fn repair_snapshot_safe(
4352 mut snapshot: PaneTreeSnapshot,
4353) -> Result<PaneRepairOutcome, PaneRepairError> {
4354 snapshot.canonicalize();
4355
4356 let before_hash = snapshot_state_hash(&snapshot);
4357 let report_before = build_invariant_report(&snapshot);
4358 let mut unsafe_codes = report_before
4359 .issues
4360 .iter()
4361 .filter(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
4362 .map(|issue| issue.code)
4363 .collect::<Vec<_>>();
4364 unsafe_codes.sort();
4365 unsafe_codes.dedup();
4366
4367 if !unsafe_codes.is_empty() {
4368 return Err(PaneRepairError {
4369 before_hash,
4370 report: report_before,
4371 reason: PaneRepairFailure::UnsafeIssuesPresent {
4372 codes: unsafe_codes,
4373 },
4374 });
4375 }
4376
4377 let mut nodes = BTreeMap::new();
4378 for node in snapshot.nodes {
4379 let _ = nodes.entry(node.id).or_insert(node);
4380 }
4381
4382 let mut actions = Vec::new();
4383 let mut expected_parents = BTreeMap::new();
4384 for node in nodes.values() {
4385 if let PaneNodeKind::Split(split) = &node.kind {
4386 for child in [split.first, split.second] {
4387 let _ = expected_parents.entry(child).or_insert(node.id);
4388 }
4389 }
4390 }
4391
4392 for node in nodes.values_mut() {
4393 let expected_parent = if node.id == snapshot.root {
4394 None
4395 } else {
4396 expected_parents.get(&node.id).copied()
4397 };
4398 if node.parent != expected_parent {
4399 actions.push(PaneRepairAction::ReparentNode {
4400 node_id: node.id,
4401 before_parent: node.parent,
4402 after_parent: expected_parent,
4403 });
4404 node.parent = expected_parent;
4405 }
4406
4407 if let PaneNodeKind::Split(split) = &mut node.kind {
4408 let normalized =
4409 PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator()).map_err(
4410 |error| PaneRepairError {
4411 before_hash,
4412 report: report_before.clone(),
4413 reason: PaneRepairFailure::ValidationFailed { error },
4414 },
4415 )?;
4416 if split.ratio != normalized {
4417 actions.push(PaneRepairAction::NormalizeRatio {
4418 node_id: node.id,
4419 before_numerator: split.ratio.numerator(),
4420 before_denominator: split.ratio.denominator(),
4421 after_numerator: normalized.numerator(),
4422 after_denominator: normalized.denominator(),
4423 });
4424 split.ratio = normalized;
4425 }
4426 }
4427 }
4428
4429 let mut visiting = BTreeSet::new();
4430 let mut visited = BTreeSet::new();
4431 let mut cycle_nodes = BTreeSet::new();
4432 if nodes.contains_key(&snapshot.root) {
4433 dfs_collect_cycles_and_reachable(
4434 snapshot.root,
4435 &nodes,
4436 &mut visiting,
4437 &mut visited,
4438 &mut cycle_nodes,
4439 );
4440 }
4441 if !cycle_nodes.is_empty() {
4442 let mut codes = vec![PaneInvariantCode::CycleDetected];
4443 codes.sort();
4444 codes.dedup();
4445 return Err(PaneRepairError {
4446 before_hash,
4447 report: report_before,
4448 reason: PaneRepairFailure::UnsafeIssuesPresent { codes },
4449 });
4450 }
4451
4452 let all_node_ids = nodes.keys().copied().collect::<Vec<_>>();
4453 for node_id in all_node_ids {
4454 if !visited.contains(&node_id) {
4455 let _ = nodes.remove(&node_id);
4456 actions.push(PaneRepairAction::RemoveOrphanNode { node_id });
4457 }
4458 }
4459
4460 if let Some(max_existing) = nodes.keys().next_back().copied()
4461 && snapshot.next_id <= max_existing
4462 {
4463 let after = max_existing
4464 .checked_next()
4465 .map_err(|error| PaneRepairError {
4466 before_hash,
4467 report: report_before.clone(),
4468 reason: PaneRepairFailure::ValidationFailed { error },
4469 })?;
4470 actions.push(PaneRepairAction::BumpNextId {
4471 before: snapshot.next_id,
4472 after,
4473 });
4474 snapshot.next_id = after;
4475 }
4476
4477 snapshot.nodes = nodes.into_values().collect();
4478 snapshot.canonicalize();
4479
4480 let tree = PaneTree::from_snapshot(snapshot).map_err(|error| PaneRepairError {
4481 before_hash,
4482 report: report_before.clone(),
4483 reason: PaneRepairFailure::ValidationFailed { error },
4484 })?;
4485 let report_after = tree.invariant_report();
4486 let after_hash = tree.state_hash();
4487
4488 Ok(PaneRepairOutcome {
4489 before_hash,
4490 after_hash,
4491 report_before,
4492 report_after,
4493 actions,
4494 tree,
4495 })
4496}
4497
4498fn validate_tree(
4499 root: PaneId,
4500 next_id: PaneId,
4501 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4502) -> Result<(), PaneModelError> {
4503 if !nodes.contains_key(&root) {
4504 return Err(PaneModelError::MissingRoot { root });
4505 }
4506
4507 let max_existing = nodes.keys().next_back().copied().unwrap_or(root);
4508 if next_id <= max_existing {
4509 return Err(PaneModelError::NextIdNotGreaterThanExisting {
4510 next_id,
4511 max_existing,
4512 });
4513 }
4514
4515 let mut expected_parents = BTreeMap::new();
4516
4517 for node in nodes.values() {
4518 node.constraints.validate(node.id)?;
4519
4520 if let Some(parent) = node.parent
4521 && !nodes.contains_key(&parent)
4522 {
4523 return Err(PaneModelError::MissingParent {
4524 node_id: node.id,
4525 parent,
4526 });
4527 }
4528
4529 if let PaneNodeKind::Split(split) = &node.kind {
4530 if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
4531 return Err(PaneModelError::InvalidSplitRatio {
4532 numerator: split.ratio.numerator(),
4533 denominator: split.ratio.denominator(),
4534 });
4535 }
4536
4537 if split.first == node.id || split.second == node.id {
4538 return Err(PaneModelError::SelfReferentialSplit { node_id: node.id });
4539 }
4540 if split.first == split.second {
4541 return Err(PaneModelError::DuplicateSplitChildren {
4542 node_id: node.id,
4543 child: split.first,
4544 });
4545 }
4546
4547 for child in [split.first, split.second] {
4548 if !nodes.contains_key(&child) {
4549 return Err(PaneModelError::MissingChild {
4550 parent: node.id,
4551 child,
4552 });
4553 }
4554 if let Some(first_parent) = expected_parents.insert(child, node.id)
4555 && first_parent != node.id
4556 {
4557 return Err(PaneModelError::MultipleParents {
4558 child,
4559 first_parent,
4560 second_parent: node.id,
4561 });
4562 }
4563 }
4564 }
4565 }
4566
4567 if let Some(parent) = nodes.get(&root).and_then(|node| node.parent) {
4568 return Err(PaneModelError::RootHasParent { root, parent });
4569 }
4570
4571 for node in nodes.values() {
4572 let expected = if node.id == root {
4573 None
4574 } else {
4575 expected_parents.get(&node.id).copied()
4576 };
4577 if node.parent != expected {
4578 return Err(PaneModelError::ParentMismatch {
4579 node_id: node.id,
4580 expected,
4581 actual: node.parent,
4582 });
4583 }
4584 }
4585
4586 let mut visiting = BTreeSet::new();
4587 let mut visited = BTreeSet::new();
4588 dfs_validate(root, nodes, &mut visiting, &mut visited)?;
4589
4590 if visited.len() != nodes.len()
4591 && let Some(node_id) = nodes.keys().find(|node_id| !visited.contains(node_id))
4592 {
4593 return Err(PaneModelError::UnreachableNode { node_id: *node_id });
4594 }
4595
4596 Ok(())
4597}
4598
4599#[derive(Debug, Clone, Copy)]
4600struct AxisBounds {
4601 min: u16,
4602 max: Option<u16>,
4603}
4604
4605fn axis_bounds(constraints: PaneConstraints, axis: SplitAxis) -> AxisBounds {
4606 match axis {
4607 SplitAxis::Horizontal => AxisBounds {
4608 min: constraints.min_width,
4609 max: constraints.max_width,
4610 },
4611 SplitAxis::Vertical => AxisBounds {
4612 min: constraints.min_height,
4613 max: constraints.max_height,
4614 },
4615 }
4616}
4617
4618fn validate_area_against_constraints(
4619 node_id: PaneId,
4620 area: Rect,
4621 constraints: PaneConstraints,
4622) -> Result<(), PaneModelError> {
4623 if area.width < constraints.min_width {
4624 return Err(PaneModelError::NodeConstraintUnsatisfied {
4625 node_id,
4626 axis: "width",
4627 actual: area.width,
4628 min: constraints.min_width,
4629 max: constraints.max_width,
4630 });
4631 }
4632 if area.height < constraints.min_height {
4633 return Err(PaneModelError::NodeConstraintUnsatisfied {
4634 node_id,
4635 axis: "height",
4636 actual: area.height,
4637 min: constraints.min_height,
4638 max: constraints.max_height,
4639 });
4640 }
4641 if let Some(max_width) = constraints.max_width
4642 && area.width > max_width
4643 {
4644 return Err(PaneModelError::NodeConstraintUnsatisfied {
4645 node_id,
4646 axis: "width",
4647 actual: area.width,
4648 min: constraints.min_width,
4649 max: constraints.max_width,
4650 });
4651 }
4652 if let Some(max_height) = constraints.max_height
4653 && area.height > max_height
4654 {
4655 return Err(PaneModelError::NodeConstraintUnsatisfied {
4656 node_id,
4657 axis: "height",
4658 actual: area.height,
4659 min: constraints.min_height,
4660 max: constraints.max_height,
4661 });
4662 }
4663 Ok(())
4664}
4665
4666fn solve_split_sizes(
4667 node_id: PaneId,
4668 axis: SplitAxis,
4669 available: u16,
4670 ratio: PaneSplitRatio,
4671 first: AxisBounds,
4672 second: AxisBounds,
4673) -> Result<(u16, u16), PaneModelError> {
4674 let first_max = first.max.unwrap_or(available).min(available);
4675 let second_max = second.max.unwrap_or(available).min(available);
4676
4677 let feasible_first_min = first.min.max(available.saturating_sub(second_max));
4678 let feasible_first_max = first_max.min(available.saturating_sub(second.min));
4679
4680 if feasible_first_min > feasible_first_max {
4681 return Err(PaneModelError::OverconstrainedSplit {
4682 node_id,
4683 axis,
4684 available,
4685 first_min: first.min,
4686 first_max,
4687 second_min: second.min,
4688 second_max,
4689 });
4690 }
4691
4692 let total_weight = u64::from(ratio.numerator()) + u64::from(ratio.denominator());
4693 let desired_first_u64 = (u64::from(available) * u64::from(ratio.numerator())) / total_weight;
4694 let desired_first = desired_first_u64 as u16;
4695
4696 let first_size = desired_first.clamp(feasible_first_min, feasible_first_max);
4697 let second_size = available.saturating_sub(first_size);
4698 Ok((first_size, second_size))
4699}
4700
4701fn dfs_validate(
4702 node_id: PaneId,
4703 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4704 visiting: &mut BTreeSet<PaneId>,
4705 visited: &mut BTreeSet<PaneId>,
4706) -> Result<(), PaneModelError> {
4707 if visiting.contains(&node_id) {
4708 return Err(PaneModelError::CycleDetected { node_id });
4709 }
4710 if !visited.insert(node_id) {
4711 return Ok(());
4712 }
4713
4714 let _ = visiting.insert(node_id);
4715 if let Some(node) = nodes.get(&node_id)
4716 && let PaneNodeKind::Split(split) = &node.kind
4717 {
4718 dfs_validate(split.first, nodes, visiting, visited)?;
4719 dfs_validate(split.second, nodes, visiting, visited)?;
4720 }
4721 let _ = visiting.remove(&node_id);
4722 Ok(())
4723}
4724
4725fn gcd_u32(mut left: u32, mut right: u32) -> u32 {
4726 while right != 0 {
4727 let rem = left % right;
4728 left = right;
4729 right = rem;
4730 }
4731 left.max(1)
4732}
4733
4734#[cfg(test)]
4735mod tests {
4736 use super::*;
4737 use proptest::prelude::*;
4738
4739 fn id(raw: u64) -> PaneId {
4740 PaneId::new(raw).expect("test ID must be non-zero")
4741 }
4742
4743 fn make_valid_snapshot() -> PaneTreeSnapshot {
4744 let root = id(1);
4745 let left = id(2);
4746 let right = id(3);
4747
4748 PaneTreeSnapshot {
4749 schema_version: PANE_TREE_SCHEMA_VERSION,
4750 root,
4751 next_id: id(4),
4752 nodes: vec![
4753 PaneNodeRecord::leaf(
4754 right,
4755 Some(root),
4756 PaneLeaf {
4757 surface_key: "right".to_string(),
4758 extensions: BTreeMap::new(),
4759 },
4760 ),
4761 PaneNodeRecord::split(
4762 root,
4763 None,
4764 PaneSplit {
4765 axis: SplitAxis::Horizontal,
4766 ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
4767 first: left,
4768 second: right,
4769 },
4770 ),
4771 PaneNodeRecord::leaf(
4772 left,
4773 Some(root),
4774 PaneLeaf {
4775 surface_key: "left".to_string(),
4776 extensions: BTreeMap::new(),
4777 },
4778 ),
4779 ],
4780 extensions: BTreeMap::new(),
4781 }
4782 }
4783
4784 fn make_nested_snapshot() -> PaneTreeSnapshot {
4785 let root = id(1);
4786 let left = id(2);
4787 let right_split = id(3);
4788 let right_top = id(4);
4789 let right_bottom = id(5);
4790
4791 PaneTreeSnapshot {
4792 schema_version: PANE_TREE_SCHEMA_VERSION,
4793 root,
4794 next_id: id(6),
4795 nodes: vec![
4796 PaneNodeRecord::split(
4797 root,
4798 None,
4799 PaneSplit {
4800 axis: SplitAxis::Horizontal,
4801 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
4802 first: left,
4803 second: right_split,
4804 },
4805 ),
4806 PaneNodeRecord::leaf(left, Some(root), PaneLeaf::new("left")),
4807 PaneNodeRecord::split(
4808 right_split,
4809 Some(root),
4810 PaneSplit {
4811 axis: SplitAxis::Vertical,
4812 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
4813 first: right_top,
4814 second: right_bottom,
4815 },
4816 ),
4817 PaneNodeRecord::leaf(right_top, Some(right_split), PaneLeaf::new("right_top")),
4818 PaneNodeRecord::leaf(
4819 right_bottom,
4820 Some(right_split),
4821 PaneLeaf::new("right_bottom"),
4822 ),
4823 ],
4824 extensions: BTreeMap::new(),
4825 }
4826 }
4827
4828 #[test]
4829 fn ratio_is_normalized() {
4830 let ratio = PaneSplitRatio::new(12, 8).expect("ratio should normalize");
4831 assert_eq!(ratio.numerator(), 3);
4832 assert_eq!(ratio.denominator(), 2);
4833 }
4834
4835 #[test]
4836 fn snapshot_round_trip_preserves_canonical_order() {
4837 let tree =
4838 PaneTree::from_snapshot(make_valid_snapshot()).expect("snapshot should validate");
4839 let snapshot = tree.to_snapshot();
4840 let ids = snapshot
4841 .nodes
4842 .iter()
4843 .map(|node| node.id.get())
4844 .collect::<Vec<_>>();
4845 assert_eq!(ids, vec![1, 2, 3]);
4846 }
4847
4848 #[test]
4849 fn duplicate_node_id_is_rejected() {
4850 let mut snapshot = make_valid_snapshot();
4851 snapshot.nodes.push(PaneNodeRecord::leaf(
4852 id(2),
4853 Some(id(1)),
4854 PaneLeaf::new("dup"),
4855 ));
4856 let err = PaneTree::from_snapshot(snapshot).expect_err("duplicate ID should fail");
4857 assert_eq!(err, PaneModelError::DuplicateNodeId { node_id: id(2) });
4858 }
4859
4860 #[test]
4861 fn missing_child_is_rejected() {
4862 let mut snapshot = make_valid_snapshot();
4863 snapshot.nodes.retain(|node| node.id != id(3));
4864 let err = PaneTree::from_snapshot(snapshot).expect_err("missing child should fail");
4865 assert_eq!(
4866 err,
4867 PaneModelError::MissingChild {
4868 parent: id(1),
4869 child: id(3),
4870 }
4871 );
4872 }
4873
4874 #[test]
4875 fn unreachable_node_is_rejected() {
4876 let mut snapshot = make_valid_snapshot();
4877 snapshot
4878 .nodes
4879 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
4880 snapshot.next_id = id(11);
4881 let err = PaneTree::from_snapshot(snapshot).expect_err("orphan should fail");
4882 assert_eq!(err, PaneModelError::UnreachableNode { node_id: id(10) });
4883 }
4884
4885 #[test]
4886 fn next_id_must_be_greater_than_existing_ids() {
4887 let mut snapshot = make_valid_snapshot();
4888 snapshot.next_id = id(3);
4889 let err = PaneTree::from_snapshot(snapshot).expect_err("next_id should be > max ID");
4890 assert_eq!(
4891 err,
4892 PaneModelError::NextIdNotGreaterThanExisting {
4893 next_id: id(3),
4894 max_existing: id(3),
4895 }
4896 );
4897 }
4898
4899 #[test]
4900 fn constraints_validate_bounds() {
4901 let constraints = PaneConstraints {
4902 min_width: 8,
4903 min_height: 1,
4904 max_width: Some(4),
4905 max_height: None,
4906 collapsible: false,
4907 };
4908 let err = constraints
4909 .validate(id(5))
4910 .expect_err("max width below min width must fail");
4911 assert_eq!(
4912 err,
4913 PaneModelError::InvalidConstraint {
4914 node_id: id(5),
4915 axis: "width",
4916 min: 8,
4917 max: 4,
4918 }
4919 );
4920 }
4921
4922 #[test]
4923 fn allocator_is_deterministic() {
4924 let mut allocator = PaneIdAllocator::default();
4925 assert_eq!(allocator.allocate().expect("id 1"), id(1));
4926 assert_eq!(allocator.allocate().expect("id 2"), id(2));
4927 assert_eq!(allocator.peek(), id(3));
4928 }
4929
4930 #[test]
4931 fn snapshot_json_shape_contains_forward_compat_fields() {
4932 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
4933 let json = serde_json::to_value(tree.to_snapshot()).expect("snapshot should serialize");
4934 assert_eq!(json["schema_version"], serde_json::json!(1));
4935 assert!(json.get("extensions").is_some());
4936 let nodes = json["nodes"]
4937 .as_array()
4938 .expect("nodes should serialize as array");
4939 assert_eq!(nodes.len(), 3);
4940 assert!(nodes[0].get("kind").is_some());
4941 }
4942
4943 #[test]
4944 fn solver_horizontal_ratio_split() {
4945 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
4946 let layout = tree
4947 .solve_layout(Rect::new(0, 0, 50, 10))
4948 .expect("layout solve should succeed");
4949
4950 assert_eq!(layout.rect(id(1)), Some(Rect::new(0, 0, 50, 10)));
4951 assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 30, 10)));
4952 assert_eq!(layout.rect(id(3)), Some(Rect::new(30, 0, 20, 10)));
4953 }
4954
4955 #[test]
4956 fn solver_clamps_to_child_minimum_constraints() {
4957 let mut snapshot = make_valid_snapshot();
4958 for node in &mut snapshot.nodes {
4959 if node.id == id(2) {
4960 node.constraints.min_width = 35;
4961 }
4962 }
4963
4964 let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
4965 let layout = tree
4966 .solve_layout(Rect::new(0, 0, 50, 10))
4967 .expect("layout solve should succeed");
4968
4969 assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 35, 10)));
4970 assert_eq!(layout.rect(id(3)), Some(Rect::new(35, 0, 15, 10)));
4971 }
4972
4973 #[test]
4974 fn solver_rejects_overconstrained_split() {
4975 let mut snapshot = make_valid_snapshot();
4976 for node in &mut snapshot.nodes {
4977 if node.id == id(2) {
4978 node.constraints.min_width = 30;
4979 }
4980 if node.id == id(3) {
4981 node.constraints.min_width = 30;
4982 }
4983 }
4984
4985 let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
4986 let err = tree
4987 .solve_layout(Rect::new(0, 0, 50, 10))
4988 .expect_err("infeasible constraints should fail");
4989
4990 assert_eq!(
4991 err,
4992 PaneModelError::OverconstrainedSplit {
4993 node_id: id(1),
4994 axis: SplitAxis::Horizontal,
4995 available: 50,
4996 first_min: 30,
4997 first_max: 50,
4998 second_min: 30,
4999 second_max: 50,
5000 }
5001 );
5002 }
5003
5004 #[test]
5005 fn solver_is_deterministic() {
5006 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5007 let first = tree
5008 .solve_layout(Rect::new(0, 0, 79, 17))
5009 .expect("first solve should succeed");
5010 let second = tree
5011 .solve_layout(Rect::new(0, 0, 79, 17))
5012 .expect("second solve should succeed");
5013 assert_eq!(first, second);
5014 }
5015
5016 #[test]
5017 fn split_leaf_wraps_existing_leaf_with_new_split() {
5018 let mut tree = PaneTree::singleton("root");
5019 let outcome = tree
5020 .apply_operation(
5021 7,
5022 PaneOperation::SplitLeaf {
5023 target: id(1),
5024 axis: SplitAxis::Horizontal,
5025 ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
5026 placement: PanePlacement::ExistingFirst,
5027 new_leaf: PaneLeaf::new("new"),
5028 },
5029 )
5030 .expect("split should succeed");
5031
5032 assert_eq!(outcome.operation_id, 7);
5033 assert_eq!(outcome.kind, PaneOperationKind::SplitLeaf);
5034 assert_ne!(outcome.before_hash, outcome.after_hash);
5035 assert_eq!(tree.root(), id(2));
5036
5037 let root = tree.node(id(2)).expect("split node exists");
5038 let PaneNodeKind::Split(split) = &root.kind else {
5039 unreachable!("root should be split");
5040 };
5041 assert_eq!(split.first, id(1));
5042 assert_eq!(split.second, id(3));
5043
5044 let original = tree.node(id(1)).expect("original leaf exists");
5045 assert_eq!(original.parent, Some(id(2)));
5046 assert!(matches!(original.kind, PaneNodeKind::Leaf(_)));
5047
5048 let new_leaf = tree.node(id(3)).expect("new leaf exists");
5049 assert_eq!(new_leaf.parent, Some(id(2)));
5050 let PaneNodeKind::Leaf(leaf) = &new_leaf.kind else {
5051 unreachable!("new node must be leaf");
5052 };
5053 assert_eq!(leaf.surface_key, "new");
5054 assert!(tree.validate().is_ok());
5055 }
5056
5057 #[test]
5058 fn close_node_promotes_sibling_and_removes_split_parent() {
5059 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5060 let outcome = tree
5061 .apply_operation(8, PaneOperation::CloseNode { target: id(2) })
5062 .expect("close should succeed");
5063 assert_eq!(outcome.kind, PaneOperationKind::CloseNode);
5064
5065 assert_eq!(tree.root(), id(3));
5066 assert!(tree.node(id(1)).is_none());
5067 assert!(tree.node(id(2)).is_none());
5068 assert_eq!(tree.node(id(3)).and_then(|node| node.parent), None);
5069 assert!(tree.validate().is_ok());
5070 }
5071
5072 #[test]
5073 fn close_root_is_rejected_with_stable_hashes() {
5074 let mut tree = PaneTree::singleton("root");
5075 let err = tree
5076 .apply_operation(9, PaneOperation::CloseNode { target: id(1) })
5077 .expect_err("closing root must fail");
5078
5079 assert_eq!(err.operation_id, 9);
5080 assert_eq!(err.kind, PaneOperationKind::CloseNode);
5081 assert_eq!(
5082 err.reason,
5083 PaneOperationFailure::CannotCloseRoot { node_id: id(1) }
5084 );
5085 assert_eq!(err.before_hash, err.after_hash);
5086 assert_eq!(tree.root(), id(1));
5087 assert!(tree.validate().is_ok());
5088 }
5089
5090 #[test]
5091 fn move_subtree_wraps_target_and_detaches_old_parent() {
5092 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5093 let outcome = tree
5094 .apply_operation(
5095 10,
5096 PaneOperation::MoveSubtree {
5097 source: id(4),
5098 target: id(2),
5099 axis: SplitAxis::Vertical,
5100 ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
5101 placement: PanePlacement::ExistingFirst,
5102 },
5103 )
5104 .expect("move should succeed");
5105 assert_eq!(outcome.kind, PaneOperationKind::MoveSubtree);
5106
5107 assert!(
5108 tree.node(id(3)).is_none(),
5109 "old split parent should be removed"
5110 );
5111 assert_eq!(tree.node(id(5)).and_then(|node| node.parent), Some(id(1)));
5112
5113 let inserted_split = tree
5114 .nodes()
5115 .find(|node| matches!(node.kind, PaneNodeKind::Split(_)) && node.id.get() >= 6)
5116 .expect("new split should exist");
5117 let PaneNodeKind::Split(split) = &inserted_split.kind else {
5118 unreachable!();
5119 };
5120 assert_eq!(split.first, id(2));
5121 assert_eq!(split.second, id(4));
5122 assert_eq!(
5123 tree.node(id(2)).and_then(|node| node.parent),
5124 Some(inserted_split.id)
5125 );
5126 assert_eq!(
5127 tree.node(id(4)).and_then(|node| node.parent),
5128 Some(inserted_split.id)
5129 );
5130 assert!(tree.validate().is_ok());
5131 }
5132
5133 #[test]
5134 fn move_subtree_rejects_ancestor_target() {
5135 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5136 let err = tree
5137 .apply_operation(
5138 11,
5139 PaneOperation::MoveSubtree {
5140 source: id(3),
5141 target: id(4),
5142 axis: SplitAxis::Horizontal,
5143 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
5144 placement: PanePlacement::ExistingFirst,
5145 },
5146 )
5147 .expect_err("ancestor move must fail");
5148
5149 assert_eq!(err.kind, PaneOperationKind::MoveSubtree);
5150 assert_eq!(
5151 err.reason,
5152 PaneOperationFailure::AncestorConflict {
5153 ancestor: id(3),
5154 descendant: id(4),
5155 }
5156 );
5157 assert!(tree.validate().is_ok());
5158 }
5159
5160 #[test]
5161 fn swap_nodes_exchanges_sibling_positions() {
5162 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5163 let outcome = tree
5164 .apply_operation(
5165 12,
5166 PaneOperation::SwapNodes {
5167 first: id(2),
5168 second: id(3),
5169 },
5170 )
5171 .expect("swap should succeed");
5172 assert_eq!(outcome.kind, PaneOperationKind::SwapNodes);
5173
5174 let root = tree.node(id(1)).expect("root exists");
5175 let PaneNodeKind::Split(split) = &root.kind else {
5176 unreachable!("root should remain split");
5177 };
5178 assert_eq!(split.first, id(3));
5179 assert_eq!(split.second, id(2));
5180 assert_eq!(tree.node(id(2)).and_then(|node| node.parent), Some(id(1)));
5181 assert_eq!(tree.node(id(3)).and_then(|node| node.parent), Some(id(1)));
5182 assert!(tree.validate().is_ok());
5183 }
5184
5185 #[test]
5186 fn swap_nodes_rejects_ancestor_relation() {
5187 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5188 let err = tree
5189 .apply_operation(
5190 13,
5191 PaneOperation::SwapNodes {
5192 first: id(3),
5193 second: id(4),
5194 },
5195 )
5196 .expect_err("ancestor swap must fail");
5197
5198 assert_eq!(err.kind, PaneOperationKind::SwapNodes);
5199 assert_eq!(
5200 err.reason,
5201 PaneOperationFailure::AncestorConflict {
5202 ancestor: id(3),
5203 descendant: id(4),
5204 }
5205 );
5206 assert!(tree.validate().is_ok());
5207 }
5208
5209 #[test]
5210 fn normalize_ratios_canonicalizes_non_reduced_values() {
5211 let mut snapshot = make_valid_snapshot();
5212 for node in &mut snapshot.nodes {
5213 if let PaneNodeKind::Split(split) = &mut node.kind {
5214 split.ratio = PaneSplitRatio {
5215 numerator: 12,
5216 denominator: 8,
5217 };
5218 }
5219 }
5220
5221 let mut tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
5222 let outcome = tree
5223 .apply_operation(14, PaneOperation::NormalizeRatios)
5224 .expect("normalize should succeed");
5225 assert_eq!(outcome.kind, PaneOperationKind::NormalizeRatios);
5226
5227 let root = tree.node(id(1)).expect("root exists");
5228 let PaneNodeKind::Split(split) = &root.kind else {
5229 unreachable!("root should be split");
5230 };
5231 assert_eq!(split.ratio.numerator(), 3);
5232 assert_eq!(split.ratio.denominator(), 2);
5233 }
5234
5235 #[test]
5236 fn transaction_commit_persists_mutations_and_journal_order() {
5237 let tree = PaneTree::singleton("root");
5238 let mut tx = tree.begin_transaction(77);
5239
5240 let split = tx
5241 .apply_operation(
5242 100,
5243 PaneOperation::SplitLeaf {
5244 target: id(1),
5245 axis: SplitAxis::Horizontal,
5246 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
5247 placement: PanePlacement::ExistingFirst,
5248 new_leaf: PaneLeaf::new("secondary"),
5249 },
5250 )
5251 .expect("split should succeed");
5252 assert_eq!(split.kind, PaneOperationKind::SplitLeaf);
5253
5254 let normalize = tx
5255 .apply_operation(101, PaneOperation::NormalizeRatios)
5256 .expect("normalize should succeed");
5257 assert_eq!(normalize.kind, PaneOperationKind::NormalizeRatios);
5258
5259 let outcome = tx.commit();
5260 assert!(outcome.committed);
5261 assert_eq!(outcome.transaction_id, 77);
5262 assert_eq!(outcome.tree.root(), id(2));
5263 assert_eq!(outcome.journal.len(), 2);
5264 assert_eq!(outcome.journal[0].sequence, 1);
5265 assert_eq!(outcome.journal[1].sequence, 2);
5266 assert_eq!(outcome.journal[0].operation_id, 100);
5267 assert_eq!(outcome.journal[1].operation_id, 101);
5268 assert_eq!(
5269 outcome.journal[0].result,
5270 PaneOperationJournalResult::Applied
5271 );
5272 assert_eq!(
5273 outcome.journal[1].result,
5274 PaneOperationJournalResult::Applied
5275 );
5276 }
5277
5278 #[test]
5279 fn transaction_rollback_discards_mutations() {
5280 let tree = PaneTree::singleton("root");
5281 let before_hash = tree.state_hash();
5282 let mut tx = tree.begin_transaction(78);
5283
5284 tx.apply_operation(
5285 200,
5286 PaneOperation::SplitLeaf {
5287 target: id(1),
5288 axis: SplitAxis::Vertical,
5289 ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
5290 placement: PanePlacement::ExistingFirst,
5291 new_leaf: PaneLeaf::new("extra"),
5292 },
5293 )
5294 .expect("split should succeed");
5295
5296 let outcome = tx.rollback();
5297 assert!(!outcome.committed);
5298 assert_eq!(outcome.tree.state_hash(), before_hash);
5299 assert_eq!(outcome.tree.root(), id(1));
5300 assert_eq!(outcome.journal.len(), 1);
5301 assert_eq!(outcome.journal[0].operation_id, 200);
5302 }
5303
5304 #[test]
5305 fn transaction_journals_rejected_operation_without_mutation() {
5306 let tree = PaneTree::singleton("root");
5307 let mut tx = tree.begin_transaction(79);
5308 let before_hash = tx.tree().state_hash();
5309
5310 let err = tx
5311 .apply_operation(300, PaneOperation::CloseNode { target: id(1) })
5312 .expect_err("close root should fail");
5313 assert_eq!(err.before_hash, err.after_hash);
5314 assert_eq!(tx.tree().state_hash(), before_hash);
5315
5316 let journal = tx.journal();
5317 assert_eq!(journal.len(), 1);
5318 assert_eq!(journal[0].operation_id, 300);
5319 let PaneOperationJournalResult::Rejected { reason } = &journal[0].result else {
5320 unreachable!("journal entry should be rejected");
5321 };
5322 assert!(reason.contains("cannot close root"));
5323 }
5324
5325 #[test]
5326 fn transaction_journal_is_deterministic_for_equivalent_runs() {
5327 let base = PaneTree::singleton("root");
5328
5329 let mut first_tx = base.begin_transaction(80);
5330 first_tx
5331 .apply_operation(
5332 1,
5333 PaneOperation::SplitLeaf {
5334 target: id(1),
5335 axis: SplitAxis::Horizontal,
5336 ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
5337 placement: PanePlacement::IncomingFirst,
5338 new_leaf: PaneLeaf::new("new"),
5339 },
5340 )
5341 .expect("split should succeed");
5342 first_tx
5343 .apply_operation(2, PaneOperation::NormalizeRatios)
5344 .expect("normalize should succeed");
5345 let first = first_tx.commit();
5346
5347 let mut second_tx = base.begin_transaction(80);
5348 second_tx
5349 .apply_operation(
5350 1,
5351 PaneOperation::SplitLeaf {
5352 target: id(1),
5353 axis: SplitAxis::Horizontal,
5354 ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
5355 placement: PanePlacement::IncomingFirst,
5356 new_leaf: PaneLeaf::new("new"),
5357 },
5358 )
5359 .expect("split should succeed");
5360 second_tx
5361 .apply_operation(2, PaneOperation::NormalizeRatios)
5362 .expect("normalize should succeed");
5363 let second = second_tx.commit();
5364
5365 assert_eq!(first.tree.state_hash(), second.tree.state_hash());
5366 assert_eq!(first.journal, second.journal);
5367 }
5368
5369 #[test]
5370 fn invariant_report_detects_parent_mismatch_and_orphan() {
5371 let mut snapshot = make_valid_snapshot();
5372 for node in &mut snapshot.nodes {
5373 if node.id == id(2) {
5374 node.parent = Some(id(3));
5375 }
5376 }
5377 snapshot
5378 .nodes
5379 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5380 snapshot.next_id = id(11);
5381
5382 let report = snapshot.invariant_report();
5383 assert!(report.has_errors());
5384 assert!(
5385 report
5386 .issues
5387 .iter()
5388 .any(|issue| issue.code == PaneInvariantCode::ParentMismatch)
5389 );
5390 assert!(
5391 report
5392 .issues
5393 .iter()
5394 .any(|issue| issue.code == PaneInvariantCode::UnreachableNode)
5395 );
5396 }
5397
5398 #[test]
5399 fn repair_safe_normalizes_ratio_repairs_parents_and_removes_orphans() {
5400 let mut snapshot = make_valid_snapshot();
5401 for node in &mut snapshot.nodes {
5402 if node.id == id(1) {
5403 node.parent = Some(id(3));
5404 let PaneNodeKind::Split(split) = &mut node.kind else {
5405 unreachable!("root should be split");
5406 };
5407 split.ratio = PaneSplitRatio {
5408 numerator: 12,
5409 denominator: 8,
5410 };
5411 }
5412 if node.id == id(2) {
5413 node.parent = Some(id(3));
5414 }
5415 }
5416 snapshot
5417 .nodes
5418 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5419 snapshot.next_id = id(11);
5420
5421 let repaired = snapshot.repair_safe().expect("repair should succeed");
5422 assert_ne!(repaired.before_hash, repaired.after_hash);
5423 assert!(repaired.tree.validate().is_ok());
5424 assert!(!repaired.report_after.has_errors());
5425 assert!(
5426 repaired
5427 .actions
5428 .iter()
5429 .any(|action| matches!(action, PaneRepairAction::NormalizeRatio { node_id, .. } if *node_id == id(1)))
5430 );
5431 assert!(
5432 repaired
5433 .actions
5434 .iter()
5435 .any(|action| matches!(action, PaneRepairAction::ReparentNode { node_id, .. } if *node_id == id(1)))
5436 );
5437 assert!(
5438 repaired
5439 .actions
5440 .iter()
5441 .any(|action| matches!(action, PaneRepairAction::RemoveOrphanNode { node_id } if *node_id == id(10)))
5442 );
5443 }
5444
5445 #[test]
5446 fn repair_safe_rejects_unsafe_topology() {
5447 let mut snapshot = make_valid_snapshot();
5448 snapshot.nodes.retain(|node| node.id != id(3));
5449
5450 let err = snapshot
5451 .repair_safe()
5452 .expect_err("missing-child topology must be rejected");
5453 assert!(matches!(
5454 err.reason,
5455 PaneRepairFailure::UnsafeIssuesPresent { .. }
5456 ));
5457 let PaneRepairFailure::UnsafeIssuesPresent { codes } = err.reason else {
5458 unreachable!("expected unsafe issue failure");
5459 };
5460 assert!(codes.contains(&PaneInvariantCode::MissingChild));
5461 }
5462
5463 #[test]
5464 fn repair_safe_is_deterministic_for_equivalent_snapshot() {
5465 let mut snapshot = make_valid_snapshot();
5466 for node in &mut snapshot.nodes {
5467 if node.id == id(1) {
5468 let PaneNodeKind::Split(split) = &mut node.kind else {
5469 unreachable!("root should be split");
5470 };
5471 split.ratio = PaneSplitRatio {
5472 numerator: 12,
5473 denominator: 8,
5474 };
5475 }
5476 }
5477 snapshot
5478 .nodes
5479 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5480 snapshot.next_id = id(11);
5481
5482 let first = snapshot.clone().repair_safe().expect("first repair");
5483 let second = snapshot.repair_safe().expect("second repair");
5484
5485 assert_eq!(first.tree.state_hash(), second.tree.state_hash());
5486 assert_eq!(first.actions, second.actions);
5487 assert_eq!(first.report_after, second.report_after);
5488 }
5489
5490 fn default_target() -> PaneResizeTarget {
5491 PaneResizeTarget {
5492 split_id: id(7),
5493 axis: SplitAxis::Horizontal,
5494 }
5495 }
5496
5497 #[test]
5498 fn semantic_input_event_fixture_round_trip_covers_all_variants() {
5499 let mut pointer_down = PaneSemanticInputEvent::new(
5500 1,
5501 PaneSemanticInputEventKind::PointerDown {
5502 target: default_target(),
5503 pointer_id: 11,
5504 button: PanePointerButton::Primary,
5505 position: PanePointerPosition::new(42, 9),
5506 },
5507 );
5508 pointer_down.modifiers = PaneModifierSnapshot {
5509 shift: true,
5510 alt: false,
5511 ctrl: true,
5512 meta: false,
5513 };
5514 let pointer_down_fixture = r#"{"schema_version":1,"sequence":1,"modifiers":{"shift":true,"alt":false,"ctrl":true,"meta":false},"event":"pointer_down","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":42,"y":9},"extensions":{}}"#;
5515
5516 let pointer_move = PaneSemanticInputEvent::new(
5517 2,
5518 PaneSemanticInputEventKind::PointerMove {
5519 target: default_target(),
5520 pointer_id: 11,
5521 position: PanePointerPosition::new(45, 8),
5522 delta_x: 3,
5523 delta_y: -1,
5524 },
5525 );
5526 let pointer_move_fixture = r#"{"schema_version":1,"sequence":2,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":45,"y":8},"delta_x":3,"delta_y":-1,"extensions":{}}"#;
5527
5528 let pointer_up = PaneSemanticInputEvent::new(
5529 3,
5530 PaneSemanticInputEventKind::PointerUp {
5531 target: default_target(),
5532 pointer_id: 11,
5533 button: PanePointerButton::Primary,
5534 position: PanePointerPosition::new(45, 8),
5535 },
5536 );
5537 let pointer_up_fixture = r#"{"schema_version":1,"sequence":3,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_up","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":45,"y":8},"extensions":{}}"#;
5538
5539 let wheel_nudge = PaneSemanticInputEvent::new(
5540 4,
5541 PaneSemanticInputEventKind::WheelNudge {
5542 target: default_target(),
5543 lines: -2,
5544 },
5545 );
5546 let wheel_nudge_fixture = r#"{"schema_version":1,"sequence":4,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"wheel_nudge","target":{"split_id":7,"axis":"horizontal"},"lines":-2,"extensions":{}}"#;
5547
5548 let keyboard_resize = PaneSemanticInputEvent::new(
5549 5,
5550 PaneSemanticInputEventKind::KeyboardResize {
5551 target: default_target(),
5552 direction: PaneResizeDirection::Increase,
5553 units: 3,
5554 },
5555 );
5556 let keyboard_resize_fixture = r#"{"schema_version":1,"sequence":5,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"keyboard_resize","target":{"split_id":7,"axis":"horizontal"},"direction":"increase","units":3,"extensions":{}}"#;
5557
5558 let cancel = PaneSemanticInputEvent::new(
5559 6,
5560 PaneSemanticInputEventKind::Cancel {
5561 target: Some(default_target()),
5562 reason: PaneCancelReason::PointerCancel,
5563 },
5564 );
5565 let cancel_fixture = r#"{"schema_version":1,"sequence":6,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"cancel","target":{"split_id":7,"axis":"horizontal"},"reason":"pointer_cancel","extensions":{}}"#;
5566
5567 let blur =
5568 PaneSemanticInputEvent::new(7, PaneSemanticInputEventKind::Blur { target: None });
5569 let blur_fixture = r#"{"schema_version":1,"sequence":7,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
5570
5571 let fixtures = [
5572 ("pointer_down", pointer_down_fixture, pointer_down),
5573 ("pointer_move", pointer_move_fixture, pointer_move),
5574 ("pointer_up", pointer_up_fixture, pointer_up),
5575 ("wheel_nudge", wheel_nudge_fixture, wheel_nudge),
5576 ("keyboard_resize", keyboard_resize_fixture, keyboard_resize),
5577 ("cancel", cancel_fixture, cancel),
5578 ("blur", blur_fixture, blur),
5579 ];
5580
5581 for (name, fixture, expected) in fixtures {
5582 let parsed: PaneSemanticInputEvent =
5583 serde_json::from_str(fixture).expect("fixture should parse");
5584 assert_eq!(
5585 parsed, expected,
5586 "{name} fixture should match expected shape"
5587 );
5588 parsed.validate().expect("fixture should validate");
5589 let encoded = serde_json::to_string(&parsed).expect("event should encode");
5590 assert_eq!(encoded, fixture, "{name} fixture should be canonical");
5591 }
5592 }
5593
5594 #[test]
5595 fn semantic_input_event_defaults_schema_version_to_current() {
5596 let fixture = r#"{"sequence":9,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
5597 let parsed: PaneSemanticInputEvent =
5598 serde_json::from_str(fixture).expect("fixture should parse");
5599 assert_eq!(
5600 parsed.schema_version,
5601 PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
5602 );
5603 parsed.validate().expect("defaulted event should validate");
5604 }
5605
5606 #[test]
5607 fn semantic_input_event_rejects_invalid_invariants() {
5608 let target = default_target();
5609
5610 let mut schema_version = PaneSemanticInputEvent::new(
5611 1,
5612 PaneSemanticInputEventKind::Blur {
5613 target: Some(target),
5614 },
5615 );
5616 schema_version.schema_version = 99;
5617 assert_eq!(
5618 schema_version.validate(),
5619 Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
5620 version: 99,
5621 expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
5622 })
5623 );
5624
5625 let sequence = PaneSemanticInputEvent::new(
5626 0,
5627 PaneSemanticInputEventKind::Blur {
5628 target: Some(target),
5629 },
5630 );
5631 assert_eq!(
5632 sequence.validate(),
5633 Err(PaneSemanticInputEventError::ZeroSequence)
5634 );
5635
5636 let pointer = PaneSemanticInputEvent::new(
5637 2,
5638 PaneSemanticInputEventKind::PointerDown {
5639 target,
5640 pointer_id: 0,
5641 button: PanePointerButton::Primary,
5642 position: PanePointerPosition::new(0, 0),
5643 },
5644 );
5645 assert_eq!(
5646 pointer.validate(),
5647 Err(PaneSemanticInputEventError::ZeroPointerId)
5648 );
5649
5650 let wheel = PaneSemanticInputEvent::new(
5651 3,
5652 PaneSemanticInputEventKind::WheelNudge { target, lines: 0 },
5653 );
5654 assert_eq!(
5655 wheel.validate(),
5656 Err(PaneSemanticInputEventError::ZeroWheelLines)
5657 );
5658
5659 let keyboard = PaneSemanticInputEvent::new(
5660 4,
5661 PaneSemanticInputEventKind::KeyboardResize {
5662 target,
5663 direction: PaneResizeDirection::Decrease,
5664 units: 0,
5665 },
5666 );
5667 assert_eq!(
5668 keyboard.validate(),
5669 Err(PaneSemanticInputEventError::ZeroResizeUnits)
5670 );
5671 }
5672
5673 #[test]
5674 fn semantic_input_trace_fixture_round_trip_and_checksum_validation() {
5675 let fixture = r#"{"metadata":{"schema_version":1,"seed":7,"start_unix_ms":1700000000000,"host":"terminal","checksum":0},"events":[{"schema_version":1,"sequence":1,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_down","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":10,"y":4},"extensions":{}},{"schema_version":1,"sequence":2,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":13,"y":4},"delta_x":0,"delta_y":0,"extensions":{}},{"schema_version":1,"sequence":3,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_move","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"position":{"x":15,"y":6},"delta_x":0,"delta_y":0,"extensions":{}},{"schema_version":1,"sequence":4,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"pointer_up","target":{"split_id":7,"axis":"horizontal"},"pointer_id":11,"button":"primary","position":{"x":16,"y":6},"extensions":{}}]}"#;
5676
5677 let parsed: PaneSemanticInputTrace =
5678 serde_json::from_str(fixture).expect("trace fixture should parse");
5679 let checksum_mismatch = parsed
5680 .validate()
5681 .expect_err("fixture checksum=0 should fail validation");
5682 assert!(matches!(
5683 checksum_mismatch,
5684 PaneSemanticInputTraceError::ChecksumMismatch { recorded: 0, .. }
5685 ));
5686
5687 let mut canonical = parsed;
5688 canonical.metadata.checksum = canonical.recompute_checksum();
5689 canonical
5690 .validate()
5691 .expect("canonicalized fixture should validate");
5692 let encoded = serde_json::to_string(&canonical).expect("trace should encode");
5693 let reparsed: PaneSemanticInputTrace =
5694 serde_json::from_str(&encoded).expect("encoded fixture should parse");
5695 assert_eq!(reparsed, canonical);
5696 assert_eq!(reparsed.metadata.checksum, reparsed.recompute_checksum());
5697 }
5698
5699 #[test]
5700 fn semantic_input_trace_rejects_out_of_order_sequence() {
5701 let target = default_target();
5702 let mut trace = PaneSemanticInputTrace::new(
5703 42,
5704 1_700_000_000_111,
5705 "web",
5706 vec![
5707 PaneSemanticInputEvent::new(
5708 1,
5709 PaneSemanticInputEventKind::PointerDown {
5710 target,
5711 pointer_id: 9,
5712 button: PanePointerButton::Primary,
5713 position: PanePointerPosition::new(0, 0),
5714 },
5715 ),
5716 PaneSemanticInputEvent::new(
5717 2,
5718 PaneSemanticInputEventKind::PointerMove {
5719 target,
5720 pointer_id: 9,
5721 position: PanePointerPosition::new(2, 0),
5722 delta_x: 0,
5723 delta_y: 0,
5724 },
5725 ),
5726 PaneSemanticInputEvent::new(
5727 3,
5728 PaneSemanticInputEventKind::PointerUp {
5729 target,
5730 pointer_id: 9,
5731 button: PanePointerButton::Primary,
5732 position: PanePointerPosition::new(2, 0),
5733 },
5734 ),
5735 ],
5736 )
5737 .expect("trace should construct");
5738
5739 trace.events[2].sequence = 2;
5740 trace.metadata.checksum = trace.recompute_checksum();
5741 assert_eq!(
5742 trace.validate(),
5743 Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
5744 index: 2,
5745 previous: 2,
5746 current: 2
5747 })
5748 );
5749 }
5750
5751 #[test]
5752 fn semantic_replay_fixture_runner_produces_diff_artifacts() {
5753 let target = default_target();
5754 let trace = PaneSemanticInputTrace::new(
5755 99,
5756 1_700_000_000_222,
5757 "terminal",
5758 vec![
5759 PaneSemanticInputEvent::new(
5760 1,
5761 PaneSemanticInputEventKind::PointerDown {
5762 target,
5763 pointer_id: 11,
5764 button: PanePointerButton::Primary,
5765 position: PanePointerPosition::new(10, 4),
5766 },
5767 ),
5768 PaneSemanticInputEvent::new(
5769 2,
5770 PaneSemanticInputEventKind::PointerMove {
5771 target,
5772 pointer_id: 11,
5773 position: PanePointerPosition::new(13, 4),
5774 delta_x: 0,
5775 delta_y: 0,
5776 },
5777 ),
5778 PaneSemanticInputEvent::new(
5779 3,
5780 PaneSemanticInputEventKind::PointerMove {
5781 target,
5782 pointer_id: 11,
5783 position: PanePointerPosition::new(15, 6),
5784 delta_x: 0,
5785 delta_y: 0,
5786 },
5787 ),
5788 PaneSemanticInputEvent::new(
5789 4,
5790 PaneSemanticInputEventKind::PointerUp {
5791 target,
5792 pointer_id: 11,
5793 button: PanePointerButton::Primary,
5794 position: PanePointerPosition::new(16, 6),
5795 },
5796 ),
5797 ],
5798 )
5799 .expect("trace should construct");
5800
5801 let mut baseline_machine = PaneDragResizeMachine::default();
5802 let baseline = trace
5803 .replay(&mut baseline_machine)
5804 .expect("baseline replay should pass");
5805 let fixture = PaneSemanticReplayFixture {
5806 trace: trace.clone(),
5807 expected_transitions: baseline.transitions.clone(),
5808 expected_final_state: baseline.final_state,
5809 };
5810
5811 let mut pass_machine = PaneDragResizeMachine::default();
5812 let pass_report = fixture
5813 .run(&mut pass_machine)
5814 .expect("fixture replay should succeed");
5815 assert!(pass_report.passed);
5816 assert!(pass_report.diffs.is_empty());
5817
5818 let mut mismatch_fixture = fixture.clone();
5819 mismatch_fixture.expected_transitions[1].transition_id += 77;
5820 mismatch_fixture.expected_final_state = PaneDragResizeState::Armed {
5821 target,
5822 pointer_id: 11,
5823 origin: PanePointerPosition::new(10, 4),
5824 current: PanePointerPosition::new(10, 4),
5825 started_sequence: 1,
5826 };
5827
5828 let mut mismatch_machine = PaneDragResizeMachine::default();
5829 let mismatch_report = mismatch_fixture
5830 .run(&mut mismatch_machine)
5831 .expect("mismatch replay should still execute");
5832 assert!(!mismatch_report.passed);
5833 assert!(
5834 mismatch_report
5835 .diffs
5836 .iter()
5837 .any(|diff| diff.kind == PaneSemanticReplayDiffKind::TransitionMismatch)
5838 );
5839 assert!(
5840 mismatch_report
5841 .diffs
5842 .iter()
5843 .any(|diff| diff.kind == PaneSemanticReplayDiffKind::FinalStateMismatch)
5844 );
5845 }
5846
5847 fn default_coordinate_normalizer() -> PaneCoordinateNormalizer {
5848 PaneCoordinateNormalizer::new(
5849 PanePointerPosition::new(100, 50),
5850 PanePointerPosition::new(20, 10),
5851 8,
5852 16,
5853 PaneScaleFactor::new(2, 1).expect("valid dpr"),
5854 PaneScaleFactor::ONE,
5855 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5856 )
5857 .expect("normalizer should be valid")
5858 }
5859
5860 #[test]
5861 fn coordinate_normalizer_css_device_and_cell_pipeline() {
5862 let normalizer = default_coordinate_normalizer();
5863
5864 let css = normalizer
5865 .normalize(PaneInputCoordinate::CssPixels {
5866 position: PanePointerPosition::new(116, 82),
5867 })
5868 .expect("css normalization should succeed");
5869 assert_eq!(
5870 css,
5871 PaneNormalizedCoordinate {
5872 global_cell: PanePointerPosition::new(22, 12),
5873 local_cell: PanePointerPosition::new(2, 2),
5874 local_css: PanePointerPosition::new(16, 32),
5875 }
5876 );
5877
5878 let device = normalizer
5879 .normalize(PaneInputCoordinate::DevicePixels {
5880 position: PanePointerPosition::new(232, 164),
5881 })
5882 .expect("device normalization should match css");
5883 assert_eq!(device, css);
5884
5885 let cell = normalizer
5886 .normalize(PaneInputCoordinate::Cell {
5887 position: PanePointerPosition::new(3, 1),
5888 })
5889 .expect("cell normalization should succeed");
5890 assert_eq!(
5891 cell,
5892 PaneNormalizedCoordinate {
5893 global_cell: PanePointerPosition::new(23, 11),
5894 local_cell: PanePointerPosition::new(3, 1),
5895 local_css: PanePointerPosition::new(24, 16),
5896 }
5897 );
5898 }
5899
5900 #[test]
5901 fn coordinate_normalizer_zoom_and_rounding_tie_breaks_are_deterministic() {
5902 let zoomed = PaneCoordinateNormalizer::new(
5903 PanePointerPosition::new(100, 50),
5904 PanePointerPosition::new(0, 0),
5905 8,
5906 8,
5907 PaneScaleFactor::ONE,
5908 PaneScaleFactor::new(5, 4).expect("valid zoom"),
5909 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5910 )
5911 .expect("zoomed normalizer should be valid");
5912
5913 let zoomed_point = zoomed
5914 .normalize(PaneInputCoordinate::CssPixels {
5915 position: PanePointerPosition::new(120, 70),
5916 })
5917 .expect("zoomed normalization should succeed");
5918 assert_eq!(zoomed_point.local_css, PanePointerPosition::new(16, 16));
5919 assert_eq!(zoomed_point.local_cell, PanePointerPosition::new(2, 2));
5920
5921 let nearest = PaneCoordinateNormalizer::new(
5922 PanePointerPosition::new(0, 0),
5923 PanePointerPosition::new(0, 0),
5924 10,
5925 10,
5926 PaneScaleFactor::ONE,
5927 PaneScaleFactor::ONE,
5928 PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity,
5929 )
5930 .expect("nearest normalizer should be valid");
5931
5932 let positive_tie = nearest
5933 .normalize(PaneInputCoordinate::CssPixels {
5934 position: PanePointerPosition::new(15, 0),
5935 })
5936 .expect("positive tie should normalize");
5937 let negative_tie = nearest
5938 .normalize(PaneInputCoordinate::CssPixels {
5939 position: PanePointerPosition::new(-15, 0),
5940 })
5941 .expect("negative tie should normalize");
5942
5943 assert_eq!(positive_tie.local_cell.x, 1);
5944 assert_eq!(negative_tie.local_cell.x, -2);
5945 }
5946
5947 #[test]
5948 fn coordinate_normalizer_rejects_invalid_configuration() {
5949 assert_eq!(
5950 PaneScaleFactor::new(0, 1).expect_err("zero numerator must fail"),
5951 PaneCoordinateNormalizationError::InvalidScaleFactor {
5952 field: "scale_factor",
5953 numerator: 0,
5954 denominator: 1,
5955 }
5956 );
5957
5958 let err = PaneCoordinateNormalizer::new(
5959 PanePointerPosition::new(0, 0),
5960 PanePointerPosition::new(0, 0),
5961 0,
5962 10,
5963 PaneScaleFactor::ONE,
5964 PaneScaleFactor::ONE,
5965 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5966 )
5967 .expect_err("zero width must fail");
5968 assert_eq!(
5969 err,
5970 PaneCoordinateNormalizationError::InvalidCellSize {
5971 width: 0,
5972 height: 10,
5973 }
5974 );
5975 }
5976
5977 #[test]
5978 fn coordinate_normalizer_repeated_device_updates_do_not_drift() {
5979 let normalizer = PaneCoordinateNormalizer::new(
5980 PanePointerPosition::new(0, 0),
5981 PanePointerPosition::new(0, 0),
5982 7,
5983 11,
5984 PaneScaleFactor::new(3, 2).expect("valid dpr"),
5985 PaneScaleFactor::new(5, 4).expect("valid zoom"),
5986 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5987 )
5988 .expect("normalizer should be valid");
5989
5990 let mut prev = i32::MIN;
5991 for x in 150..190 {
5992 let first = normalizer
5993 .normalize(PaneInputCoordinate::DevicePixels {
5994 position: PanePointerPosition::new(x, 0),
5995 })
5996 .expect("first normalization should succeed");
5997 let second = normalizer
5998 .normalize(PaneInputCoordinate::DevicePixels {
5999 position: PanePointerPosition::new(x, 0),
6000 })
6001 .expect("second normalization should succeed");
6002
6003 assert_eq!(
6004 first, second,
6005 "normalization should be stable for same input"
6006 );
6007 assert!(
6008 first.global_cell.x >= prev,
6009 "cell coordinate should be monotonic"
6010 );
6011 if prev != i32::MIN {
6012 assert!(
6013 first.global_cell.x - prev <= 1,
6014 "cell coordinate should not jump by more than one per pixel step"
6015 );
6016 }
6017 prev = first.global_cell.x;
6018 }
6019 }
6020
6021 #[test]
6022 fn snap_tuning_is_deterministic_with_tie_breaks_and_hysteresis() {
6023 let tuning = PaneSnapTuning::default();
6024
6025 let tie = tuning.decide(3_250, None);
6026 assert_eq!(tie.nearest_ratio_bps, 3_000);
6027 assert_eq!(tie.snapped_ratio_bps, None);
6028 assert_eq!(tie.reason, PaneSnapReason::UnsnapOutsideWindow);
6029
6030 let snap = tuning.decide(3_499, None);
6031 assert_eq!(snap.nearest_ratio_bps, 3_500);
6032 assert_eq!(snap.snapped_ratio_bps, Some(3_500));
6033 assert_eq!(snap.reason, PaneSnapReason::SnappedNearest);
6034
6035 let retain = tuning.decide(3_390, Some(3_500));
6036 assert_eq!(retain.snapped_ratio_bps, Some(3_500));
6037 assert_eq!(retain.reason, PaneSnapReason::RetainedPrevious);
6038
6039 assert_eq!(
6040 PaneSnapTuning::new(0, 125).expect_err("step=0 must fail"),
6041 PaneInteractionPolicyError::InvalidSnapTuning {
6042 step_bps: 0,
6043 hysteresis_bps: 125
6044 }
6045 );
6046 }
6047
6048 #[test]
6049 fn precision_policy_applies_axis_lock_and_mode_scaling() {
6050 let fine = PanePrecisionPolicy::from_modifiers(
6051 PaneModifierSnapshot {
6052 shift: true,
6053 alt: true,
6054 ctrl: false,
6055 meta: false,
6056 },
6057 SplitAxis::Horizontal,
6058 );
6059 assert_eq!(fine.mode, PanePrecisionMode::Fine);
6060 assert_eq!(fine.axis_lock, Some(SplitAxis::Horizontal));
6061 assert_eq!(fine.apply_delta(5, 3).expect("fine delta"), (2, 0));
6062
6063 let coarse = PanePrecisionPolicy::from_modifiers(
6064 PaneModifierSnapshot {
6065 shift: false,
6066 alt: false,
6067 ctrl: true,
6068 meta: false,
6069 },
6070 SplitAxis::Vertical,
6071 );
6072 assert_eq!(coarse.mode, PanePrecisionMode::Coarse);
6073 assert_eq!(coarse.axis_lock, None);
6074 assert_eq!(coarse.apply_delta(2, -3).expect("coarse delta"), (4, -6));
6075 }
6076
6077 #[test]
6078 fn drag_behavior_tuning_validates_and_threshold_helpers_are_stable() {
6079 let tuning = PaneDragBehaviorTuning::new(3, 2, PaneSnapTuning::default())
6080 .expect("valid tuning should construct");
6081 assert!(tuning.should_start_drag(
6082 PanePointerPosition::new(0, 0),
6083 PanePointerPosition::new(3, 0)
6084 ));
6085 assert!(!tuning.should_start_drag(
6086 PanePointerPosition::new(0, 0),
6087 PanePointerPosition::new(2, 0)
6088 ));
6089 assert!(tuning.should_emit_drag_update(
6090 PanePointerPosition::new(10, 10),
6091 PanePointerPosition::new(12, 10)
6092 ));
6093 assert!(!tuning.should_emit_drag_update(
6094 PanePointerPosition::new(10, 10),
6095 PanePointerPosition::new(11, 10)
6096 ));
6097
6098 assert_eq!(
6099 PaneDragBehaviorTuning::new(0, 2, PaneSnapTuning::default())
6100 .expect_err("activation threshold=0 must fail"),
6101 PaneInteractionPolicyError::InvalidThreshold {
6102 field: "activation_threshold",
6103 value: 0
6104 }
6105 );
6106 assert_eq!(
6107 PaneDragBehaviorTuning::new(2, 0, PaneSnapTuning::default())
6108 .expect_err("hysteresis=0 must fail"),
6109 PaneInteractionPolicyError::InvalidThreshold {
6110 field: "update_hysteresis",
6111 value: 0
6112 }
6113 );
6114 }
6115
6116 fn pointer_down_event(
6117 sequence: u64,
6118 target: PaneResizeTarget,
6119 pointer_id: u32,
6120 x: i32,
6121 y: i32,
6122 ) -> PaneSemanticInputEvent {
6123 PaneSemanticInputEvent::new(
6124 sequence,
6125 PaneSemanticInputEventKind::PointerDown {
6126 target,
6127 pointer_id,
6128 button: PanePointerButton::Primary,
6129 position: PanePointerPosition::new(x, y),
6130 },
6131 )
6132 }
6133
6134 fn pointer_move_event(
6135 sequence: u64,
6136 target: PaneResizeTarget,
6137 pointer_id: u32,
6138 x: i32,
6139 y: i32,
6140 ) -> PaneSemanticInputEvent {
6141 PaneSemanticInputEvent::new(
6142 sequence,
6143 PaneSemanticInputEventKind::PointerMove {
6144 target,
6145 pointer_id,
6146 position: PanePointerPosition::new(x, y),
6147 delta_x: 0,
6148 delta_y: 0,
6149 },
6150 )
6151 }
6152
6153 fn pointer_up_event(
6154 sequence: u64,
6155 target: PaneResizeTarget,
6156 pointer_id: u32,
6157 x: i32,
6158 y: i32,
6159 ) -> PaneSemanticInputEvent {
6160 PaneSemanticInputEvent::new(
6161 sequence,
6162 PaneSemanticInputEventKind::PointerUp {
6163 target,
6164 pointer_id,
6165 button: PanePointerButton::Primary,
6166 position: PanePointerPosition::new(x, y),
6167 },
6168 )
6169 }
6170
6171 #[test]
6172 fn drag_resize_machine_full_lifecycle_commit() {
6173 let mut machine = PaneDragResizeMachine::default();
6174 let target = default_target();
6175
6176 let down = machine
6177 .apply_event(&pointer_down_event(1, target, 10, 10, 4))
6178 .expect("down should arm");
6179 assert_eq!(down.transition_id, 1);
6180 assert_eq!(down.sequence, 1);
6181 assert_eq!(machine.state(), down.to);
6182 assert!(matches!(
6183 down.effect,
6184 PaneDragResizeEffect::Armed {
6185 target: t,
6186 pointer_id: 10,
6187 origin: PanePointerPosition { x: 10, y: 4 }
6188 } if t == target
6189 ));
6190
6191 let below_threshold = machine
6192 .apply_event(&pointer_move_event(2, target, 10, 11, 4))
6193 .expect("small move should not start drag");
6194 assert_eq!(
6195 below_threshold.effect,
6196 PaneDragResizeEffect::Noop {
6197 reason: PaneDragResizeNoopReason::ThresholdNotReached
6198 }
6199 );
6200 assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
6201
6202 let drag_start = machine
6203 .apply_event(&pointer_move_event(3, target, 10, 13, 4))
6204 .expect("large move should start drag");
6205 assert!(matches!(
6206 drag_start.effect,
6207 PaneDragResizeEffect::DragStarted {
6208 target: t,
6209 pointer_id: 10,
6210 total_delta_x: 3,
6211 total_delta_y: 0,
6212 ..
6213 } if t == target
6214 ));
6215 assert!(matches!(
6216 machine.state(),
6217 PaneDragResizeState::Dragging { .. }
6218 ));
6219
6220 let drag_update = machine
6221 .apply_event(&pointer_move_event(4, target, 10, 15, 6))
6222 .expect("drag move should update");
6223 assert!(matches!(
6224 drag_update.effect,
6225 PaneDragResizeEffect::DragUpdated {
6226 target: t,
6227 pointer_id: 10,
6228 delta_x: 2,
6229 delta_y: 2,
6230 total_delta_x: 5,
6231 total_delta_y: 2,
6232 ..
6233 } if t == target
6234 ));
6235
6236 let commit = machine
6237 .apply_event(&pointer_up_event(5, target, 10, 16, 6))
6238 .expect("up should commit drag");
6239 assert!(matches!(
6240 commit.effect,
6241 PaneDragResizeEffect::Committed {
6242 target: t,
6243 pointer_id: 10,
6244 total_delta_x: 6,
6245 total_delta_y: 2,
6246 ..
6247 } if t == target
6248 ));
6249 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6250 }
6251
6252 #[test]
6253 fn drag_resize_machine_cancel_and_blur_paths_are_reason_coded() {
6254 let target = default_target();
6255
6256 let mut cancel_machine = PaneDragResizeMachine::default();
6257 cancel_machine
6258 .apply_event(&pointer_down_event(1, target, 1, 2, 2))
6259 .expect("down should arm");
6260 let cancel = cancel_machine
6261 .apply_event(&PaneSemanticInputEvent::new(
6262 2,
6263 PaneSemanticInputEventKind::Cancel {
6264 target: Some(target),
6265 reason: PaneCancelReason::FocusLost,
6266 },
6267 ))
6268 .expect("cancel should reset to idle");
6269 assert_eq!(cancel_machine.state(), PaneDragResizeState::Idle);
6270 assert_eq!(
6271 cancel.effect,
6272 PaneDragResizeEffect::Canceled {
6273 target: Some(target),
6274 pointer_id: Some(1),
6275 reason: PaneCancelReason::FocusLost
6276 }
6277 );
6278
6279 let mut blur_machine = PaneDragResizeMachine::default();
6280 blur_machine
6281 .apply_event(&pointer_down_event(3, target, 2, 5, 5))
6282 .expect("down should arm");
6283 blur_machine
6284 .apply_event(&pointer_move_event(4, target, 2, 8, 5))
6285 .expect("move should start dragging");
6286 let blur = blur_machine
6287 .apply_event(&PaneSemanticInputEvent::new(
6288 5,
6289 PaneSemanticInputEventKind::Blur {
6290 target: Some(target),
6291 },
6292 ))
6293 .expect("blur should cancel active drag");
6294 assert_eq!(blur_machine.state(), PaneDragResizeState::Idle);
6295 assert_eq!(
6296 blur.effect,
6297 PaneDragResizeEffect::Canceled {
6298 target: Some(target),
6299 pointer_id: Some(2),
6300 reason: PaneCancelReason::Blur
6301 }
6302 );
6303 }
6304
6305 #[test]
6306 fn drag_resize_machine_duplicate_end_and_pointer_mismatch_are_safe_noops() {
6307 let mut machine = PaneDragResizeMachine::default();
6308 let target = default_target();
6309
6310 machine
6311 .apply_event(&pointer_down_event(1, target, 9, 0, 0))
6312 .expect("down should arm");
6313
6314 let mismatch = machine
6315 .apply_event(&pointer_move_event(2, target, 99, 3, 0))
6316 .expect("mismatch should be ignored");
6317 assert_eq!(
6318 mismatch.effect,
6319 PaneDragResizeEffect::Noop {
6320 reason: PaneDragResizeNoopReason::PointerMismatch
6321 }
6322 );
6323 assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
6324
6325 machine
6326 .apply_event(&pointer_move_event(3, target, 9, 3, 0))
6327 .expect("drag should start");
6328 machine
6329 .apply_event(&pointer_up_event(4, target, 9, 3, 0))
6330 .expect("up should commit");
6331 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6332
6333 let duplicate_end = machine
6334 .apply_event(&pointer_up_event(5, target, 9, 3, 0))
6335 .expect("duplicate end should noop");
6336 assert_eq!(
6337 duplicate_end.effect,
6338 PaneDragResizeEffect::Noop {
6339 reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag
6340 }
6341 );
6342 }
6343
6344 #[test]
6345 fn drag_resize_machine_discrete_inputs_in_idle_and_validation_errors() {
6346 let mut machine = PaneDragResizeMachine::default();
6347 let target = default_target();
6348
6349 let keyboard = machine
6350 .apply_event(&PaneSemanticInputEvent::new(
6351 1,
6352 PaneSemanticInputEventKind::KeyboardResize {
6353 target,
6354 direction: PaneResizeDirection::Increase,
6355 units: 2,
6356 },
6357 ))
6358 .expect("keyboard resize should apply in idle");
6359 assert_eq!(
6360 keyboard.effect,
6361 PaneDragResizeEffect::KeyboardApplied {
6362 target,
6363 direction: PaneResizeDirection::Increase,
6364 units: 2
6365 }
6366 );
6367 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6368
6369 let wheel = machine
6370 .apply_event(&PaneSemanticInputEvent::new(
6371 2,
6372 PaneSemanticInputEventKind::WheelNudge { target, lines: -1 },
6373 ))
6374 .expect("wheel nudge should apply in idle");
6375 assert_eq!(
6376 wheel.effect,
6377 PaneDragResizeEffect::WheelApplied { target, lines: -1 }
6378 );
6379
6380 let invalid_pointer = PaneSemanticInputEvent::new(
6381 3,
6382 PaneSemanticInputEventKind::PointerDown {
6383 target,
6384 pointer_id: 0,
6385 button: PanePointerButton::Primary,
6386 position: PanePointerPosition::new(0, 0),
6387 },
6388 );
6389 let err = machine
6390 .apply_event(&invalid_pointer)
6391 .expect_err("invalid input should be rejected");
6392 assert_eq!(
6393 err,
6394 PaneDragResizeMachineError::InvalidEvent(PaneSemanticInputEventError::ZeroPointerId)
6395 );
6396
6397 assert_eq!(
6398 PaneDragResizeMachine::new(0).expect_err("zero threshold should fail"),
6399 PaneDragResizeMachineError::InvalidDragThreshold { threshold: 0 }
6400 );
6401 }
6402
6403 #[test]
6404 fn drag_resize_machine_hysteresis_suppresses_micro_jitter() {
6405 let target = default_target();
6406 let mut machine = PaneDragResizeMachine::new_with_hysteresis(2, 2)
6407 .expect("explicit machine tuning should construct");
6408 machine
6409 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6410 .expect("down should arm");
6411 machine
6412 .apply_event(&pointer_move_event(2, target, 22, 2, 0))
6413 .expect("move should start dragging");
6414
6415 let jitter = machine
6416 .apply_event(&pointer_move_event(3, target, 22, 3, 0))
6417 .expect("small move should be ignored");
6418 assert_eq!(
6419 jitter.effect,
6420 PaneDragResizeEffect::Noop {
6421 reason: PaneDragResizeNoopReason::BelowHysteresis
6422 }
6423 );
6424
6425 let update = machine
6426 .apply_event(&pointer_move_event(4, target, 22, 4, 0))
6427 .expect("larger move should update drag");
6428 assert!(matches!(
6429 update.effect,
6430 PaneDragResizeEffect::DragUpdated { .. }
6431 ));
6432 assert_eq!(
6433 PaneDragResizeMachine::new_with_hysteresis(2, 0)
6434 .expect_err("zero hysteresis must fail"),
6435 PaneDragResizeMachineError::InvalidUpdateHysteresis { hysteresis: 0 }
6436 );
6437 }
6438
6439 #[test]
6444 fn force_cancel_idle_is_noop() {
6445 let mut machine = PaneDragResizeMachine::default();
6446 assert!(!machine.is_active());
6447 assert!(machine.force_cancel().is_none());
6448 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6449 }
6450
6451 #[test]
6452 fn force_cancel_from_armed_resets_to_idle() {
6453 let target = default_target();
6454 let mut machine = PaneDragResizeMachine::default();
6455 machine
6456 .apply_event(&pointer_down_event(1, target, 22, 5, 5))
6457 .expect("down should arm");
6458 assert!(machine.is_active());
6459
6460 let transition = machine
6461 .force_cancel()
6462 .expect("armed machine should produce transition");
6463 assert_eq!(transition.to, PaneDragResizeState::Idle);
6464 assert!(matches!(
6465 transition.effect,
6466 PaneDragResizeEffect::Canceled {
6467 reason: PaneCancelReason::Programmatic,
6468 ..
6469 }
6470 ));
6471 assert!(!machine.is_active());
6472 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6473 }
6474
6475 #[test]
6476 fn force_cancel_from_dragging_resets_to_idle() {
6477 let target = default_target();
6478 let mut machine = PaneDragResizeMachine::default();
6479 machine
6480 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6481 .expect("down");
6482 machine
6483 .apply_event(&pointer_move_event(2, target, 22, 5, 0))
6484 .expect("move past threshold to start drag");
6485 assert!(matches!(
6486 machine.state(),
6487 PaneDragResizeState::Dragging { .. }
6488 ));
6489 assert!(machine.is_active());
6490
6491 let transition = machine
6492 .force_cancel()
6493 .expect("dragging machine should produce transition");
6494 assert_eq!(transition.to, PaneDragResizeState::Idle);
6495 assert!(matches!(
6496 transition.effect,
6497 PaneDragResizeEffect::Canceled {
6498 target: Some(_),
6499 pointer_id: Some(22),
6500 reason: PaneCancelReason::Programmatic,
6501 }
6502 ));
6503 assert!(!machine.is_active());
6504 }
6505
6506 #[test]
6507 fn force_cancel_is_idempotent() {
6508 let target = default_target();
6509 let mut machine = PaneDragResizeMachine::default();
6510 machine
6511 .apply_event(&pointer_down_event(1, target, 22, 5, 5))
6512 .expect("down should arm");
6513
6514 let first = machine.force_cancel();
6515 assert!(first.is_some());
6516 let second = machine.force_cancel();
6517 assert!(second.is_none());
6518 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6519 }
6520
6521 #[test]
6522 fn force_cancel_preserves_transition_counter_monotonicity() {
6523 let target = default_target();
6524 let mut machine = PaneDragResizeMachine::default();
6525
6526 let t1 = machine
6527 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6528 .expect("arm");
6529 let t2 = machine.force_cancel().expect("force cancel from armed");
6530 assert!(t2.transition_id > t1.transition_id);
6531
6532 let t3 = machine
6534 .apply_event(&pointer_down_event(2, target, 22, 10, 10))
6535 .expect("re-arm");
6536 let t4 = machine.force_cancel().expect("second force cancel");
6537 assert!(t3.transition_id > t2.transition_id);
6538 assert!(t4.transition_id > t3.transition_id);
6539 }
6540
6541 #[test]
6542 fn force_cancel_records_prior_state_in_from_field() {
6543 let target = default_target();
6544 let mut machine = PaneDragResizeMachine::default();
6545 machine
6546 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6547 .expect("arm");
6548
6549 let armed_state = machine.state();
6550 let transition = machine.force_cancel().expect("force cancel");
6551 assert_eq!(transition.from, armed_state);
6552 }
6553
6554 #[test]
6555 fn machine_usable_after_force_cancel() {
6556 let target = default_target();
6557 let mut machine = PaneDragResizeMachine::default();
6558
6559 machine
6561 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6562 .expect("arm");
6563 machine.force_cancel();
6564
6565 machine
6566 .apply_event(&pointer_down_event(2, target, 22, 10, 10))
6567 .expect("re-arm after force cancel");
6568 machine
6569 .apply_event(&pointer_move_event(3, target, 22, 15, 10))
6570 .expect("move to drag");
6571 let commit = machine
6572 .apply_event(&pointer_up_event(4, target, 22, 15, 10))
6573 .expect("commit");
6574 assert!(matches!(
6575 commit.effect,
6576 PaneDragResizeEffect::Committed { .. }
6577 ));
6578 assert_eq!(machine.state(), PaneDragResizeState::Idle);
6579 }
6580
6581 proptest! {
6582 #[test]
6583 fn ratio_is_always_reduced(numerator in 1u32..100_000, denominator in 1u32..100_000) {
6584 let ratio = PaneSplitRatio::new(numerator, denominator).expect("positive ratio must be valid");
6585 let gcd = gcd_u32(ratio.numerator(), ratio.denominator());
6586 prop_assert_eq!(gcd, 1);
6587 }
6588
6589 #[test]
6590 fn allocator_produces_monotonic_ids(
6591 start in 1u64..1_000_000,
6592 count in 1usize..64,
6593 ) {
6594 let mut allocator = PaneIdAllocator::with_next(PaneId::new(start).expect("start must be valid"));
6595 let mut prev = 0u64;
6596 for _ in 0..count {
6597 let current = allocator.allocate().expect("allocation must succeed").get();
6598 prop_assert!(current > prev);
6599 prev = current;
6600 }
6601 }
6602
6603 #[test]
6604 fn split_solver_preserves_available_space(
6605 numerator in 1u32..64,
6606 denominator in 1u32..64,
6607 first_min in 0u16..40,
6608 second_min in 0u16..40,
6609 available in 0u16..80,
6610 ) {
6611 let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
6612 prop_assume!(first_min.saturating_add(second_min) <= available);
6613
6614 let (first_size, second_size) = solve_split_sizes(
6615 id(1),
6616 SplitAxis::Horizontal,
6617 available,
6618 ratio,
6619 AxisBounds { min: first_min, max: None },
6620 AxisBounds { min: second_min, max: None },
6621 ).expect("feasible split should solve");
6622
6623 prop_assert_eq!(first_size.saturating_add(second_size), available);
6624 prop_assert!(first_size >= first_min);
6625 prop_assert!(second_size >= second_min);
6626 }
6627
6628 #[test]
6629 fn split_then_close_round_trip_preserves_validity(
6630 numerator in 1u32..32,
6631 denominator in 1u32..32,
6632 incoming_first in any::<bool>(),
6633 ) {
6634 let mut tree = PaneTree::singleton("root");
6635 let placement = if incoming_first {
6636 PanePlacement::IncomingFirst
6637 } else {
6638 PanePlacement::ExistingFirst
6639 };
6640 let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
6641
6642 tree.apply_operation(
6643 1,
6644 PaneOperation::SplitLeaf {
6645 target: id(1),
6646 axis: SplitAxis::Horizontal,
6647 ratio,
6648 placement,
6649 new_leaf: PaneLeaf::new("extra"),
6650 },
6651 ).expect("split should succeed");
6652
6653 let split_root_id = tree.root();
6654 let split_root = tree.node(split_root_id).expect("split root exists");
6655 let PaneNodeKind::Split(split) = &split_root.kind else {
6656 unreachable!("root should be split");
6657 };
6658 let extra_leaf_id = if split.first == id(1) {
6659 split.second
6660 } else {
6661 split.first
6662 };
6663
6664 tree.apply_operation(2, PaneOperation::CloseNode { target: extra_leaf_id })
6665 .expect("close should succeed");
6666
6667 prop_assert_eq!(tree.root(), id(1));
6668 prop_assert!(matches!(
6669 tree.node(id(1)).map(|node| &node.kind),
6670 Some(PaneNodeKind::Leaf(_))
6671 ));
6672 prop_assert!(tree.validate().is_ok());
6673 }
6674
6675 #[test]
6676 fn transaction_rollback_restores_initial_state_hash(
6677 numerator in 1u32..64,
6678 denominator in 1u32..64,
6679 incoming_first in any::<bool>(),
6680 ) {
6681 let base = PaneTree::singleton("root");
6682 let initial_hash = base.state_hash();
6683 let mut tx = base.begin_transaction(90);
6684 let placement = if incoming_first {
6685 PanePlacement::IncomingFirst
6686 } else {
6687 PanePlacement::ExistingFirst
6688 };
6689
6690 tx.apply_operation(
6691 1,
6692 PaneOperation::SplitLeaf {
6693 target: id(1),
6694 axis: SplitAxis::Horizontal,
6695 ratio: PaneSplitRatio::new(numerator, denominator).expect("valid ratio"),
6696 placement,
6697 new_leaf: PaneLeaf::new("new"),
6698 },
6699 ).expect("split should succeed");
6700
6701 let rolled_back = tx.rollback();
6702 prop_assert_eq!(rolled_back.tree.state_hash(), initial_hash);
6703 prop_assert_eq!(rolled_back.tree.root(), id(1));
6704 prop_assert!(rolled_back.tree.validate().is_ok());
6705 }
6706
6707 #[test]
6708 fn repair_safe_is_deterministic_under_recoverable_damage(
6709 numerator in 1u32..32,
6710 denominator in 1u32..32,
6711 add_orphan in any::<bool>(),
6712 mismatch_parent in any::<bool>(),
6713 ) {
6714 let mut snapshot = make_valid_snapshot();
6715 for node in &mut snapshot.nodes {
6716 if node.id == id(1) {
6717 let PaneNodeKind::Split(split) = &mut node.kind else {
6718 unreachable!("root should be split");
6719 };
6720 split.ratio = PaneSplitRatio {
6721 numerator: numerator.saturating_mul(2),
6722 denominator: denominator.saturating_mul(2),
6723 };
6724 }
6725 if mismatch_parent && node.id == id(2) {
6726 node.parent = Some(id(3));
6727 }
6728 }
6729 if add_orphan {
6730 snapshot
6731 .nodes
6732 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
6733 snapshot.next_id = id(11);
6734 }
6735
6736 let first = snapshot.clone().repair_safe().expect("first repair should succeed");
6737 let second = snapshot.repair_safe().expect("second repair should succeed");
6738
6739 prop_assert_eq!(first.tree.state_hash(), second.tree.state_hash());
6740 prop_assert_eq!(first.actions, second.actions);
6741 prop_assert_eq!(first.report_after, second.report_after);
6742 }
6743 }
6744}