1use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use ftui_core::geometry::{Rect, Sides};
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 if self.numerator == 0 {
108 1
109 } else {
110 self.numerator
111 }
112 }
113
114 #[must_use]
116 pub const fn denominator(self) -> u32 {
117 if self.denominator == 0 {
118 1
119 } else {
120 self.denominator
121 }
122 }
123}
124
125impl Default for PaneSplitRatio {
126 fn default() -> Self {
127 Self {
128 numerator: 1,
129 denominator: 1,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub struct PaneConstraints {
137 pub min_width: u16,
138 pub min_height: u16,
139 pub max_width: Option<u16>,
140 pub max_height: Option<u16>,
141 pub collapsible: bool,
142 #[serde(default)]
143 pub margin: Option<u16>,
144 #[serde(default)]
145 pub padding: Option<u16>,
146}
147
148impl PaneConstraints {
149 pub fn validate(self, node_id: PaneId) -> Result<(), PaneModelError> {
151 if let Some(max_width) = self.max_width
152 && max_width < self.min_width
153 {
154 return Err(PaneModelError::InvalidConstraint {
155 node_id,
156 axis: "width",
157 min: self.min_width,
158 max: max_width,
159 });
160 }
161 if let Some(max_height) = self.max_height
162 && max_height < self.min_height
163 {
164 return Err(PaneModelError::InvalidConstraint {
165 node_id,
166 axis: "height",
167 min: self.min_height,
168 max: max_height,
169 });
170 }
171 Ok(())
172 }
173}
174
175impl Default for PaneConstraints {
176 fn default() -> Self {
177 Self {
178 min_width: 1,
179 min_height: 1,
180 max_width: None,
181 max_height: None,
182 collapsible: false,
183 margin: Some(PANE_DEFAULT_MARGIN_CELLS),
184 padding: Some(PANE_DEFAULT_PADDING_CELLS),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct PaneLeaf {
192 pub surface_key: String,
194 #[serde(
196 default,
197 rename = "leaf_extensions",
198 skip_serializing_if = "BTreeMap::is_empty"
199 )]
200 pub extensions: BTreeMap<String, String>,
201}
202
203impl PaneLeaf {
204 #[must_use]
206 pub fn new(surface_key: impl Into<String>) -> Self {
207 Self {
208 surface_key: surface_key.into(),
209 extensions: BTreeMap::new(),
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct PaneSplit {
217 pub axis: SplitAxis,
218 pub ratio: PaneSplitRatio,
219 pub first: PaneId,
220 pub second: PaneId,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(tag = "kind", rename_all = "snake_case")]
226pub enum PaneNodeKind {
227 Leaf(PaneLeaf),
228 Split(PaneSplit),
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct PaneNodeRecord {
234 pub id: PaneId,
235 #[serde(default)]
236 pub parent: Option<PaneId>,
237 #[serde(default)]
238 pub constraints: PaneConstraints,
239 #[serde(flatten)]
240 pub kind: PaneNodeKind,
241 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
243 pub extensions: BTreeMap<String, String>,
244}
245
246impl PaneNodeRecord {
247 #[must_use]
249 pub fn leaf(id: PaneId, parent: Option<PaneId>, leaf: PaneLeaf) -> Self {
250 Self {
251 id,
252 parent,
253 constraints: PaneConstraints::default(),
254 kind: PaneNodeKind::Leaf(leaf),
255 extensions: BTreeMap::new(),
256 }
257 }
258
259 #[must_use]
261 pub fn split(id: PaneId, parent: Option<PaneId>, split: PaneSplit) -> Self {
262 Self {
263 id,
264 parent,
265 constraints: PaneConstraints::default(),
266 kind: PaneNodeKind::Split(split),
267 extensions: BTreeMap::new(),
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct PaneTreeSnapshot {
277 #[serde(default = "default_schema_version")]
278 pub schema_version: u16,
279 pub root: PaneId,
280 pub next_id: PaneId,
281 pub nodes: Vec<PaneNodeRecord>,
282 #[serde(default)]
283 pub extensions: BTreeMap<String, String>,
284}
285
286fn default_schema_version() -> u16 {
287 PANE_TREE_SCHEMA_VERSION
288}
289
290impl PaneTreeSnapshot {
291 pub fn canonicalize(&mut self) {
293 self.nodes.sort_by_key(|node| node.id);
294 }
295
296 #[must_use]
298 pub fn state_hash(&self) -> u64 {
299 snapshot_state_hash(self)
300 }
301
302 #[must_use]
304 pub fn invariant_report(&self) -> PaneInvariantReport {
305 build_invariant_report(self)
306 }
307
308 pub fn repair_safe(self) -> Result<PaneRepairOutcome, PaneRepairError> {
313 repair_snapshot_safe(self)
314 }
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum PaneInvariantSeverity {
321 Error,
322 Warning,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
327#[serde(rename_all = "snake_case")]
328pub enum PaneInvariantCode {
329 UnsupportedSchemaVersion,
330 DuplicateNodeId,
331 MissingRoot,
332 RootHasParent,
333 MissingParent,
334 MissingChild,
335 MultipleParents,
336 ParentMismatch,
337 SelfReferentialSplit,
338 DuplicateSplitChildren,
339 InvalidSplitRatio,
340 InvalidConstraint,
341 CycleDetected,
342 UnreachableNode,
343 NextIdNotGreaterThanExisting,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct PaneInvariantIssue {
349 pub code: PaneInvariantCode,
350 pub severity: PaneInvariantSeverity,
351 pub repairable: bool,
352 pub node_id: Option<PaneId>,
353 pub related_node: Option<PaneId>,
354 pub message: String,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct PaneInvariantReport {
360 pub snapshot_hash: u64,
361 pub issues: Vec<PaneInvariantIssue>,
362}
363
364impl PaneInvariantReport {
365 #[must_use]
367 pub fn has_errors(&self) -> bool {
368 self.issues
369 .iter()
370 .any(|issue| issue.severity == PaneInvariantSeverity::Error)
371 }
372
373 #[must_use]
375 pub fn has_unrepairable_errors(&self) -> bool {
376 self.issues
377 .iter()
378 .any(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(tag = "action", rename_all = "snake_case")]
385pub enum PaneRepairAction {
386 ReparentNode {
387 node_id: PaneId,
388 before_parent: Option<PaneId>,
389 after_parent: Option<PaneId>,
390 },
391 NormalizeRatio {
392 node_id: PaneId,
393 before_numerator: u32,
394 before_denominator: u32,
395 after_numerator: u32,
396 after_denominator: u32,
397 },
398 RemoveOrphanNode {
399 node_id: PaneId,
400 },
401 BumpNextId {
402 before: PaneId,
403 after: PaneId,
404 },
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct PaneRepairOutcome {
410 pub before_hash: u64,
411 pub after_hash: u64,
412 pub report_before: PaneInvariantReport,
413 pub report_after: PaneInvariantReport,
414 pub actions: Vec<PaneRepairAction>,
415 pub tree: PaneTree,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
420pub enum PaneRepairFailure {
421 UnsafeIssuesPresent { codes: Vec<PaneInvariantCode> },
422 ValidationFailed { error: PaneModelError },
423}
424
425impl fmt::Display for PaneRepairFailure {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 match self {
428 Self::UnsafeIssuesPresent { codes } => {
429 write!(f, "snapshot contains unsafe invariant issues: {codes:?}")
430 }
431 Self::ValidationFailed { error } => {
432 write!(f, "repaired snapshot failed validation: {error}")
433 }
434 }
435 }
436}
437
438impl std::error::Error for PaneRepairFailure {
439 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
440 if let Self::ValidationFailed { error } = self {
441 return Some(error);
442 }
443 None
444 }
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct PaneRepairError {
450 pub before_hash: u64,
451 pub report: PaneInvariantReport,
452 pub reason: PaneRepairFailure,
453}
454
455impl fmt::Display for PaneRepairError {
456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457 write!(
458 f,
459 "pane repair failed: {} (before_hash={:#x}, issues={})",
460 self.reason,
461 self.before_hash,
462 self.report.issues.len()
463 )
464 }
465}
466
467impl std::error::Error for PaneRepairError {
468 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
469 Some(&self.reason)
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct PaneLayout {
476 pub area: Rect,
477 rects: BTreeMap<PaneId, Rect>,
478}
479
480impl PaneLayout {
481 #[must_use]
483 pub fn rect(&self, node_id: PaneId) -> Option<Rect> {
484 self.rects.get(&node_id).copied()
485 }
486
487 pub fn iter(&self) -> impl Iterator<Item = (PaneId, Rect)> + '_ {
489 self.rects.iter().map(|(node_id, rect)| (*node_id, *rect))
490 }
491
492 #[must_use]
494 pub fn classify_resize_grip(
495 &self,
496 node_id: PaneId,
497 pointer: PanePointerPosition,
498 inset_cells: f64,
499 ) -> Option<PaneResizeGrip> {
500 let rect = self.rect(node_id)?;
501 classify_resize_grip(rect, pointer, inset_cells)
502 }
503
504 #[must_use]
509 pub fn visual_rect(&self, node_id: PaneId) -> Option<Rect> {
510 let rect = self.rect(node_id)?;
511 let with_margin = rect.inner(Sides::all(PANE_DEFAULT_MARGIN_CELLS));
512 let with_padding = with_margin.inner(Sides::all(PANE_DEFAULT_PADDING_CELLS));
513 if with_padding.width == 0 || with_padding.height == 0 {
514 Some(with_margin)
515 } else {
516 Some(with_padding)
517 }
518 }
519
520 #[must_use]
522 pub fn visual_rect_with_constraints(
523 &self,
524 node_id: PaneId,
525 constraints: &PaneConstraints,
526 ) -> Option<Rect> {
527 let rect = self.rect(node_id)?;
528 let margin = constraints.margin.unwrap_or(PANE_DEFAULT_MARGIN_CELLS);
529 let padding = constraints.padding.unwrap_or(PANE_DEFAULT_PADDING_CELLS);
530 let with_margin = rect.inner(Sides::all(margin));
531 let with_padding = with_margin.inner(Sides::all(padding));
532 if with_padding.width == 0 || with_padding.height == 0 {
533 Some(with_margin)
534 } else {
535 Some(with_padding)
536 }
537 }
538
539 #[must_use]
541 pub fn cluster_bounds(&self, nodes: &BTreeSet<PaneId>) -> Option<Rect> {
542 if nodes.is_empty() {
543 return None;
544 }
545 let mut min_x: Option<u16> = None;
546 let mut min_y: Option<u16> = None;
547 let mut max_x: Option<u16> = None;
548 let mut max_y: Option<u16> = None;
549
550 for node_id in nodes {
551 let rect = self.rect(*node_id)?;
552 min_x = Some(min_x.map_or(rect.x, |v| v.min(rect.x)));
553 min_y = Some(min_y.map_or(rect.y, |v| v.min(rect.y)));
554 let right = rect.x.saturating_add(rect.width);
555 let bottom = rect.y.saturating_add(rect.height);
556 max_x = Some(max_x.map_or(right, |v| v.max(right)));
557 max_y = Some(max_y.map_or(bottom, |v| v.max(bottom)));
558 }
559
560 let left = min_x?;
561 let top = min_y?;
562 let right = max_x?;
563 let bottom = max_y?;
564 Some(Rect::new(
565 left,
566 top,
567 right.saturating_sub(left).max(1),
568 bottom.saturating_sub(top).max(1),
569 ))
570 }
571}
572
573pub const PANE_MAGNETIC_FIELD_CELLS: f64 = 6.0;
575
576pub const PANE_EDGE_GRIP_INSET_CELLS: f64 = 1.5;
578
579pub const PANE_DEFAULT_MARGIN_CELLS: u16 = 1;
581
582pub const PANE_DEFAULT_PADDING_CELLS: u16 = 1;
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum PaneDockZone {
589 Left,
590 Right,
591 Top,
592 Bottom,
593 Center,
594}
595
596#[derive(Debug, Clone, Copy, PartialEq)]
598pub struct PaneDockPreview {
599 pub target: PaneId,
600 pub zone: PaneDockZone,
601 pub score: f64,
603 pub ghost_rect: Rect,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "snake_case")]
610pub enum PaneResizeGrip {
611 Left,
612 Right,
613 Top,
614 Bottom,
615 TopLeft,
616 TopRight,
617 BottomLeft,
618 BottomRight,
619}
620
621impl PaneResizeGrip {
622 #[must_use]
623 const fn horizontal_edge(self) -> Option<bool> {
624 match self {
625 Self::Left | Self::TopLeft | Self::BottomLeft => Some(false),
626 Self::Right | Self::TopRight | Self::BottomRight => Some(true),
627 Self::Top | Self::Bottom => None,
628 }
629 }
630
631 #[must_use]
632 const fn vertical_edge(self) -> Option<bool> {
633 match self {
634 Self::Top | Self::TopLeft | Self::TopRight => Some(false),
635 Self::Bottom | Self::BottomLeft | Self::BottomRight => Some(true),
636 Self::Left | Self::Right => None,
637 }
638 }
639}
640
641#[derive(Debug, Clone, Copy, PartialEq)]
643pub struct PaneMotionVector {
644 pub delta_x: i32,
645 pub delta_y: i32,
646 pub speed: f64,
648 pub direction_changes: u16,
650}
651
652impl PaneMotionVector {
653 #[must_use]
654 pub fn from_delta(delta_x: i32, delta_y: i32, elapsed_ms: u32, direction_changes: u16) -> Self {
655 let elapsed = f64::from(elapsed_ms.max(1)) / 1_000.0;
656 let dx = f64::from(delta_x);
657 let dy = f64::from(delta_y);
658 let distance = (dx * dx + dy * dy).sqrt();
659 Self {
660 delta_x,
661 delta_y,
662 speed: distance / elapsed,
663 direction_changes,
664 }
665 }
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
670pub struct PaneInertialThrow {
671 pub velocity_x: f64,
672 pub velocity_y: f64,
673 pub damping: f64,
675 pub horizon_ms: u16,
677}
678
679impl PaneInertialThrow {
680 #[must_use]
681 pub fn from_motion(motion: PaneMotionVector) -> Self {
682 let dx = f64::from(motion.delta_x);
683 let dy = f64::from(motion.delta_y);
684 let magnitude = (dx * dx + dy * dy).sqrt();
685 let direction_x = if magnitude <= f64::EPSILON {
686 0.0
687 } else {
688 dx / magnitude
689 };
690 let direction_y = if magnitude <= f64::EPSILON {
691 0.0
692 } else {
693 dy / magnitude
694 };
695 let speed = motion.speed.clamp(0.0, 220.0);
696 let speed_curve = (speed / 220.0).clamp(0.0, 1.0).powf(0.72);
697 let noise_penalty = (f64::from(motion.direction_changes) / 10.0).clamp(0.0, 1.0);
698 let coherence = (1.0 - 0.55 * noise_penalty).clamp(0.35, 1.0);
699 let projected_velocity = (10.0 + speed * 0.55) * coherence;
700 Self {
701 velocity_x: direction_x * projected_velocity,
702 velocity_y: direction_y * projected_velocity,
703 damping: (9.2 - speed_curve * 4.0 + noise_penalty * 2.4).clamp(4.8, 10.5),
704 horizon_ms: (140.0 + speed_curve * 220.0).round().clamp(120.0, 380.0) as u16,
705 }
706 }
707
708 #[must_use]
709 pub fn projected_pointer(self, start: PanePointerPosition) -> PanePointerPosition {
710 let dt = f64::from(self.horizon_ms) / 1_000.0;
711 let attenuation = (-self.damping * dt).exp();
712 let gain = if self.damping <= f64::EPSILON {
713 dt
714 } else {
715 (1.0 - attenuation) / self.damping
716 };
717 let projected_x = f64::from(start.x) + self.velocity_x * gain;
718 let projected_y = f64::from(start.y) + self.velocity_y * gain;
719 PanePointerPosition::new(round_f64_to_i32(projected_x), round_f64_to_i32(projected_y))
720 }
721}
722
723#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
725pub struct PanePressureSnapProfile {
726 pub strength_bps: u16,
728 pub hysteresis_bps: u16,
730}
731
732impl PanePressureSnapProfile {
733 #[must_use]
738 pub fn from_motion(motion: PaneMotionVector) -> Self {
739 let abs_dx = f64::from(motion.delta_x.unsigned_abs());
740 let abs_dy = f64::from(motion.delta_y.unsigned_abs());
741 let axis_dominance = (abs_dx.max(abs_dy) / (abs_dx + abs_dy).max(1.0)).clamp(0.5, 1.0);
742 let speed_factor = (motion.speed / 70.0).clamp(0.0, 1.0).powf(0.78);
743 let noise_penalty = (f64::from(motion.direction_changes) / 7.0).clamp(0.0, 1.0);
744 let confidence =
745 (speed_factor * (0.65 + axis_dominance * 0.35) * (1.0 - noise_penalty * 0.72))
746 .clamp(0.0, 1.0);
747 let strength = (1_500.0 + confidence.powf(0.85) * 8_500.0).round() as u16;
748 let hysteresis = (60.0 + confidence * 500.0).round() as u16;
749 Self {
750 strength_bps: strength.min(10_000),
751 hysteresis_bps: hysteresis.min(2_000),
752 }
753 }
754
755 #[must_use]
756 pub fn apply_to_tuning(self, base: PaneSnapTuning) -> PaneSnapTuning {
757 let scaled_step = ((u32::from(base.step_bps) * (11_000 - u32::from(self.strength_bps)))
758 / 10_000)
759 .clamp(100, 10_000);
760 PaneSnapTuning {
761 step_bps: scaled_step as u16,
762 hysteresis_bps: self.hysteresis_bps.max(base.hysteresis_bps),
763 }
764 }
765}
766
767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct PaneEdgeResizePlan {
770 pub leaf: PaneId,
771 pub grip: PaneResizeGrip,
772 pub operations: Vec<PaneOperation>,
773}
774
775#[derive(Debug, Clone, PartialEq)]
777pub struct PaneReflowMovePlan {
778 pub source: PaneId,
779 pub pointer: PanePointerPosition,
780 pub projected_pointer: PanePointerPosition,
781 pub preview: PaneDockPreview,
782 pub snap_profile: PanePressureSnapProfile,
783 pub operations: Vec<PaneOperation>,
784}
785
786#[derive(Debug, Clone, PartialEq, Eq)]
788pub enum PaneEdgeResizePlanError {
789 MissingLeaf { leaf: PaneId },
790 NodeNotLeaf { node: PaneId },
791 MissingLayoutRect { node: PaneId },
792 NoAxisSplit { leaf: PaneId, axis: SplitAxis },
793 InvalidRatio { numerator: u32, denominator: u32 },
794}
795
796impl fmt::Display for PaneEdgeResizePlanError {
797 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
798 match self {
799 Self::MissingLeaf { leaf } => write!(f, "pane leaf {} not found", leaf.get()),
800 Self::NodeNotLeaf { node } => write!(f, "node {} is not a leaf", node.get()),
801 Self::MissingLayoutRect { node } => {
802 write!(f, "layout missing rectangle for node {}", node.get())
803 }
804 Self::NoAxisSplit { leaf, axis } => {
805 write!(
806 f,
807 "no ancestor split on {axis:?} axis for leaf {}",
808 leaf.get()
809 )
810 }
811 Self::InvalidRatio {
812 numerator,
813 denominator,
814 } => write!(
815 f,
816 "invalid planned ratio {numerator}/{denominator} for edge resize"
817 ),
818 }
819 }
820}
821
822impl std::error::Error for PaneEdgeResizePlanError {}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
826pub enum PaneReflowPlanError {
827 MissingSource { source: PaneId },
828 NoDockTarget,
829 SourceCannotMoveRoot { source: PaneId },
830 InvalidRatio { numerator: u32, denominator: u32 },
831}
832
833impl fmt::Display for PaneReflowPlanError {
834 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
835 match self {
836 Self::MissingSource { source } => write!(f, "source node {} not found", source.get()),
837 Self::NoDockTarget => write!(f, "no magnetic docking target available"),
838 Self::SourceCannotMoveRoot { source } => {
839 write!(
840 f,
841 "source node {} is root and cannot be reflow-moved",
842 source.get()
843 )
844 }
845 Self::InvalidRatio {
846 numerator,
847 denominator,
848 } => write!(f, "invalid reflow ratio {numerator}/{denominator}"),
849 }
850 }
851}
852
853impl std::error::Error for PaneReflowPlanError {}
854
855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
857pub struct PaneSelectionState {
858 pub anchor: Option<PaneId>,
859 pub selected: BTreeSet<PaneId>,
860}
861
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864pub struct PaneGroupTransformPlan {
865 pub members: Vec<PaneId>,
866 pub operations: Vec<PaneOperation>,
867}
868
869impl PaneSelectionState {
870 pub fn shift_toggle(&mut self, pane_id: PaneId) {
872 if self.selected.contains(&pane_id) {
873 let _ = self.selected.remove(&pane_id);
874 if self.anchor == Some(pane_id) {
875 self.anchor = self.selected.iter().next().copied();
876 }
877 } else {
878 let _ = self.selected.insert(pane_id);
879 if self.anchor.is_none() {
880 self.anchor = Some(pane_id);
881 }
882 }
883 }
884
885 #[must_use]
886 pub fn as_sorted_vec(&self) -> Vec<PaneId> {
887 self.selected.iter().copied().collect()
888 }
889
890 #[must_use]
891 pub fn is_empty(&self) -> bool {
892 self.selected.is_empty()
893 }
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
898#[serde(rename_all = "snake_case")]
899pub enum PaneLayoutIntelligenceMode {
900 Focus,
901 Compare,
902 Monitor,
903 Compact,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908pub struct PaneInteractionTimelineEntry {
909 pub sequence: u64,
910 pub operation_id: u64,
911 pub operation: PaneOperation,
912 pub before_hash: u64,
913 pub after_hash: u64,
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
918pub struct PaneInteractionTimelineCheckpoint {
919 pub applied_len: usize,
920 pub snapshot: PaneTreeSnapshot,
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
925pub struct PaneInteractionTimelineReplayDiagnostics {
926 pub entry_count: usize,
927 pub cursor: usize,
928 pub checkpoint_count: usize,
929 pub checkpoint_interval: usize,
930 pub checkpoint_hit: bool,
931 pub replay_start_idx: usize,
932 pub replay_depth: usize,
933}
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
937pub struct PaneInteractionTimelineCheckpointDecision {
938 pub checkpoint_interval: usize,
939 pub estimated_snapshot_cost_ns: u128,
940 pub estimated_replay_step_cost_ns: u128,
941 pub estimated_replay_depth_ns: u128,
942}
943
944#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946pub struct PaneInteractionTimeline {
947 pub baseline: Option<PaneTreeSnapshot>,
949 pub entries: Vec<PaneInteractionTimelineEntry>,
951 pub cursor: usize,
953 pub checkpoints: Vec<PaneInteractionTimelineCheckpoint>,
955 pub checkpoint_interval: usize,
957}
958
959const DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL: usize = 16;
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962enum PaneValidationStrategy {
963 FullTree,
964 LocalClosure,
965}
966
967impl Default for PaneInteractionTimeline {
968 fn default() -> Self {
969 Self {
970 baseline: None,
971 entries: Vec::new(),
972 cursor: 0,
973 checkpoints: Vec::new(),
974 checkpoint_interval: DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL,
975 }
976 }
977}
978
979#[derive(Debug, Clone, PartialEq, Eq)]
981pub enum PaneInteractionTimelineError {
982 MissingBaseline,
983 BaselineInvalid { source: PaneModelError },
984 ApplyFailed { source: PaneOperationError },
985}
986
987impl fmt::Display for PaneInteractionTimelineError {
988 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
989 match self {
990 Self::MissingBaseline => write!(f, "timeline baseline is not set"),
991 Self::BaselineInvalid { source } => {
992 write!(f, "failed to restore timeline baseline: {source}")
993 }
994 Self::ApplyFailed { source } => write!(f, "timeline replay operation failed: {source}"),
995 }
996 }
997}
998
999impl std::error::Error for PaneInteractionTimelineError {
1000 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1001 match self {
1002 Self::BaselineInvalid { source } => Some(source),
1003 Self::ApplyFailed { source } => Some(source),
1004 Self::MissingBaseline => None,
1005 }
1006 }
1007}
1008
1009#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1011#[serde(rename_all = "snake_case")]
1012pub enum PanePlacement {
1013 ExistingFirst,
1014 IncomingFirst,
1015}
1016
1017impl PanePlacement {
1018 fn ordered(self, existing: PaneId, incoming: PaneId) -> (PaneId, PaneId) {
1019 match self {
1020 Self::ExistingFirst => (existing, incoming),
1021 Self::IncomingFirst => (incoming, existing),
1022 }
1023 }
1024}
1025
1026#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1028#[serde(rename_all = "snake_case")]
1029pub enum PanePointerButton {
1030 Primary,
1031 Secondary,
1032 Middle,
1033}
1034
1035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1037pub struct PanePointerPosition {
1038 pub x: i32,
1039 pub y: i32,
1040}
1041
1042impl PanePointerPosition {
1043 #[must_use]
1044 pub const fn new(x: i32, y: i32) -> Self {
1045 Self { x, y }
1046 }
1047}
1048
1049#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1051pub struct PaneModifierSnapshot {
1052 pub shift: bool,
1053 pub alt: bool,
1054 pub ctrl: bool,
1055 pub meta: bool,
1056}
1057
1058impl PaneModifierSnapshot {
1059 #[must_use]
1060 pub const fn none() -> Self {
1061 Self {
1062 shift: false,
1063 alt: false,
1064 ctrl: false,
1065 meta: false,
1066 }
1067 }
1068}
1069
1070impl Default for PaneModifierSnapshot {
1071 fn default() -> Self {
1072 Self::none()
1073 }
1074}
1075
1076#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1078pub struct PaneResizeTarget {
1079 pub split_id: PaneId,
1080 pub axis: SplitAxis,
1081}
1082
1083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1085#[serde(rename_all = "snake_case")]
1086pub enum PaneResizeDirection {
1087 Increase,
1088 Decrease,
1089}
1090
1091#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1093#[serde(rename_all = "snake_case")]
1094pub enum PaneCancelReason {
1095 EscapeKey,
1096 PointerCancel,
1097 FocusLost,
1098 Blur,
1099 Programmatic,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104#[serde(tag = "event", rename_all = "snake_case")]
1105pub enum PaneSemanticInputEventKind {
1106 PointerDown {
1107 target: PaneResizeTarget,
1108 pointer_id: u32,
1109 button: PanePointerButton,
1110 position: PanePointerPosition,
1111 },
1112 PointerMove {
1113 target: PaneResizeTarget,
1114 pointer_id: u32,
1115 position: PanePointerPosition,
1116 delta_x: i32,
1117 delta_y: i32,
1118 },
1119 PointerUp {
1120 target: PaneResizeTarget,
1121 pointer_id: u32,
1122 button: PanePointerButton,
1123 position: PanePointerPosition,
1124 },
1125 WheelNudge {
1126 target: PaneResizeTarget,
1127 lines: i16,
1128 },
1129 KeyboardResize {
1130 target: PaneResizeTarget,
1131 direction: PaneResizeDirection,
1132 units: u16,
1133 },
1134 Cancel {
1135 target: Option<PaneResizeTarget>,
1136 reason: PaneCancelReason,
1137 },
1138 Blur {
1139 target: Option<PaneResizeTarget>,
1140 },
1141}
1142
1143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146pub struct PaneSemanticInputEvent {
1147 #[serde(default = "default_pane_semantic_input_event_schema_version")]
1148 pub schema_version: u16,
1149 pub sequence: u64,
1150 #[serde(default)]
1151 pub modifiers: PaneModifierSnapshot,
1152 #[serde(flatten)]
1153 pub kind: PaneSemanticInputEventKind,
1154 #[serde(default)]
1155 pub extensions: BTreeMap<String, String>,
1156}
1157
1158fn default_pane_semantic_input_event_schema_version() -> u16 {
1159 PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
1160}
1161
1162impl PaneSemanticInputEvent {
1163 #[must_use]
1165 pub fn new(sequence: u64, kind: PaneSemanticInputEventKind) -> Self {
1166 Self {
1167 schema_version: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
1168 sequence,
1169 modifiers: PaneModifierSnapshot::default(),
1170 kind,
1171 extensions: BTreeMap::new(),
1172 }
1173 }
1174
1175 pub fn validate(&self) -> Result<(), PaneSemanticInputEventError> {
1177 if self.schema_version != PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION {
1178 return Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
1179 version: self.schema_version,
1180 expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
1181 });
1182 }
1183 if self.sequence == 0 {
1184 return Err(PaneSemanticInputEventError::ZeroSequence);
1185 }
1186
1187 match self.kind {
1188 PaneSemanticInputEventKind::PointerDown { pointer_id, .. }
1189 | PaneSemanticInputEventKind::PointerMove { pointer_id, .. }
1190 | PaneSemanticInputEventKind::PointerUp { pointer_id, .. } => {
1191 if pointer_id == 0 {
1192 return Err(PaneSemanticInputEventError::ZeroPointerId);
1193 }
1194 }
1195 PaneSemanticInputEventKind::WheelNudge { lines, .. } => {
1196 if lines == 0 {
1197 return Err(PaneSemanticInputEventError::ZeroWheelLines);
1198 }
1199 }
1200 PaneSemanticInputEventKind::KeyboardResize { units, .. } => {
1201 if units == 0 {
1202 return Err(PaneSemanticInputEventError::ZeroResizeUnits);
1203 }
1204 }
1205 PaneSemanticInputEventKind::Cancel { .. } | PaneSemanticInputEventKind::Blur { .. } => {
1206 }
1207 }
1208
1209 Ok(())
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1215pub enum PaneSemanticInputEventError {
1216 UnsupportedSchemaVersion { version: u16, expected: u16 },
1217 ZeroSequence,
1218 ZeroPointerId,
1219 ZeroWheelLines,
1220 ZeroResizeUnits,
1221}
1222
1223impl fmt::Display for PaneSemanticInputEventError {
1224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1225 match self {
1226 Self::UnsupportedSchemaVersion { version, expected } => write!(
1227 f,
1228 "unsupported pane semantic input schema version {version} (expected {expected})"
1229 ),
1230 Self::ZeroSequence => write!(f, "semantic pane input event sequence must be non-zero"),
1231 Self::ZeroPointerId => {
1232 write!(
1233 f,
1234 "semantic pane pointer events require non-zero pointer_id"
1235 )
1236 }
1237 Self::ZeroWheelLines => write!(f, "semantic pane wheel nudge must be non-zero"),
1238 Self::ZeroResizeUnits => {
1239 write!(f, "semantic pane keyboard resize units must be non-zero")
1240 }
1241 }
1242 }
1243}
1244
1245impl std::error::Error for PaneSemanticInputEventError {}
1246
1247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249pub struct PaneSemanticInputTraceMetadata {
1250 #[serde(default = "default_pane_semantic_input_trace_schema_version")]
1251 pub schema_version: u16,
1252 pub seed: u64,
1253 pub start_unix_ms: u64,
1254 #[serde(default)]
1255 pub host: String,
1256 pub checksum: u64,
1257}
1258
1259fn default_pane_semantic_input_trace_schema_version() -> u16 {
1260 PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1265pub struct PaneSemanticInputTrace {
1266 pub metadata: PaneSemanticInputTraceMetadata,
1267 #[serde(default)]
1268 pub events: Vec<PaneSemanticInputEvent>,
1269}
1270
1271impl PaneSemanticInputTrace {
1272 pub fn new(
1274 seed: u64,
1275 start_unix_ms: u64,
1276 host: impl Into<String>,
1277 events: Vec<PaneSemanticInputEvent>,
1278 ) -> Result<Self, PaneSemanticInputTraceError> {
1279 let mut trace = Self {
1280 metadata: PaneSemanticInputTraceMetadata {
1281 schema_version: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
1282 seed,
1283 start_unix_ms,
1284 host: host.into(),
1285 checksum: 0,
1286 },
1287 events,
1288 };
1289 trace.metadata.checksum = trace.recompute_checksum();
1290 trace.validate()?;
1291 Ok(trace)
1292 }
1293
1294 #[must_use]
1296 pub fn recompute_checksum(&self) -> u64 {
1297 pane_semantic_input_trace_checksum_payload(&self.metadata, &self.events)
1298 }
1299
1300 pub fn validate(&self) -> Result<(), PaneSemanticInputTraceError> {
1302 if self.metadata.schema_version != PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION {
1303 return Err(PaneSemanticInputTraceError::UnsupportedSchemaVersion {
1304 version: self.metadata.schema_version,
1305 expected: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
1306 });
1307 }
1308 if self.events.is_empty() {
1309 return Err(PaneSemanticInputTraceError::EmptyEvents);
1310 }
1311
1312 let mut previous_sequence = 0_u64;
1313 for (index, event) in self.events.iter().enumerate() {
1314 event
1315 .validate()
1316 .map_err(|source| PaneSemanticInputTraceError::InvalidEvent { index, source })?;
1317
1318 if index > 0 && event.sequence <= previous_sequence {
1319 return Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
1320 index,
1321 previous: previous_sequence,
1322 current: event.sequence,
1323 });
1324 }
1325 previous_sequence = event.sequence;
1326 }
1327
1328 let computed = self.recompute_checksum();
1329 if self.metadata.checksum != computed {
1330 return Err(PaneSemanticInputTraceError::ChecksumMismatch {
1331 recorded: self.metadata.checksum,
1332 computed,
1333 });
1334 }
1335
1336 Ok(())
1337 }
1338
1339 pub fn replay(
1341 &self,
1342 machine: &mut PaneDragResizeMachine,
1343 ) -> Result<PaneSemanticReplayOutcome, PaneSemanticReplayError> {
1344 self.validate()
1345 .map_err(PaneSemanticReplayError::InvalidTrace)?;
1346
1347 let mut transitions = Vec::with_capacity(self.events.len());
1348 for event in &self.events {
1349 let transition = machine
1350 .apply_event(event)
1351 .map_err(PaneSemanticReplayError::Machine)?;
1352 transitions.push(transition);
1353 }
1354
1355 Ok(PaneSemanticReplayOutcome {
1356 trace_checksum: self.metadata.checksum,
1357 transitions,
1358 final_state: machine.state(),
1359 })
1360 }
1361}
1362
1363#[derive(Debug, Clone, PartialEq, Eq)]
1365pub enum PaneSemanticInputTraceError {
1366 UnsupportedSchemaVersion {
1367 version: u16,
1368 expected: u16,
1369 },
1370 EmptyEvents,
1371 SequenceOutOfOrder {
1372 index: usize,
1373 previous: u64,
1374 current: u64,
1375 },
1376 InvalidEvent {
1377 index: usize,
1378 source: PaneSemanticInputEventError,
1379 },
1380 ChecksumMismatch {
1381 recorded: u64,
1382 computed: u64,
1383 },
1384}
1385
1386impl fmt::Display for PaneSemanticInputTraceError {
1387 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1388 match self {
1389 Self::UnsupportedSchemaVersion { version, expected } => write!(
1390 f,
1391 "unsupported pane semantic input trace schema version {version} (expected {expected})"
1392 ),
1393 Self::EmptyEvents => write!(
1394 f,
1395 "semantic pane input trace must contain at least one event"
1396 ),
1397 Self::SequenceOutOfOrder {
1398 index,
1399 previous,
1400 current,
1401 } => write!(
1402 f,
1403 "semantic pane input trace sequence out of order at index {index} ({current} <= {previous})"
1404 ),
1405 Self::InvalidEvent { index, source } => {
1406 write!(
1407 f,
1408 "semantic pane input trace contains invalid event at index {index}: {source}"
1409 )
1410 }
1411 Self::ChecksumMismatch { recorded, computed } => write!(
1412 f,
1413 "semantic pane input trace checksum mismatch (recorded={recorded:#x}, computed={computed:#x})"
1414 ),
1415 }
1416 }
1417}
1418
1419impl std::error::Error for PaneSemanticInputTraceError {}
1420
1421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1423pub struct PaneSemanticReplayOutcome {
1424 pub trace_checksum: u64,
1425 pub transitions: Vec<PaneDragResizeTransition>,
1426 pub final_state: PaneDragResizeState,
1427}
1428
1429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1431#[serde(rename_all = "snake_case")]
1432pub enum PaneSemanticReplayDiffKind {
1433 TransitionMismatch,
1434 MissingExpectedTransition,
1435 UnexpectedTransition,
1436 FinalStateMismatch,
1437}
1438
1439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1441pub struct PaneSemanticReplayDiffArtifact {
1442 pub kind: PaneSemanticReplayDiffKind,
1443 pub index: Option<usize>,
1444 pub expected_transition: Option<PaneDragResizeTransition>,
1445 pub actual_transition: Option<PaneDragResizeTransition>,
1446 pub expected_final_state: Option<PaneDragResizeState>,
1447 pub actual_final_state: Option<PaneDragResizeState>,
1448}
1449
1450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1452pub struct PaneSemanticReplayConformanceArtifact {
1453 pub trace_checksum: u64,
1454 pub passed: bool,
1455 pub diffs: Vec<PaneSemanticReplayDiffArtifact>,
1456}
1457
1458impl PaneSemanticReplayConformanceArtifact {
1459 #[must_use]
1461 pub fn compare(
1462 outcome: &PaneSemanticReplayOutcome,
1463 expected_transitions: &[PaneDragResizeTransition],
1464 expected_final_state: PaneDragResizeState,
1465 ) -> Self {
1466 let mut diffs = Vec::new();
1467 let max_len = expected_transitions.len().max(outcome.transitions.len());
1468
1469 for index in 0..max_len {
1470 let expected = expected_transitions.get(index);
1471 let actual = outcome.transitions.get(index);
1472
1473 match (expected, actual) {
1474 (Some(expected_transition), Some(actual_transition))
1475 if expected_transition != actual_transition =>
1476 {
1477 diffs.push(PaneSemanticReplayDiffArtifact {
1478 kind: PaneSemanticReplayDiffKind::TransitionMismatch,
1479 index: Some(index),
1480 expected_transition: Some(expected_transition.clone()),
1481 actual_transition: Some(actual_transition.clone()),
1482 expected_final_state: None,
1483 actual_final_state: None,
1484 });
1485 }
1486 (Some(expected_transition), None) => {
1487 diffs.push(PaneSemanticReplayDiffArtifact {
1488 kind: PaneSemanticReplayDiffKind::MissingExpectedTransition,
1489 index: Some(index),
1490 expected_transition: Some(expected_transition.clone()),
1491 actual_transition: None,
1492 expected_final_state: None,
1493 actual_final_state: None,
1494 });
1495 }
1496 (None, Some(actual_transition)) => {
1497 diffs.push(PaneSemanticReplayDiffArtifact {
1498 kind: PaneSemanticReplayDiffKind::UnexpectedTransition,
1499 index: Some(index),
1500 expected_transition: None,
1501 actual_transition: Some(actual_transition.clone()),
1502 expected_final_state: None,
1503 actual_final_state: None,
1504 });
1505 }
1506 (Some(_), Some(_)) | (None, None) => {}
1507 }
1508 }
1509
1510 if outcome.final_state != expected_final_state {
1511 diffs.push(PaneSemanticReplayDiffArtifact {
1512 kind: PaneSemanticReplayDiffKind::FinalStateMismatch,
1513 index: None,
1514 expected_transition: None,
1515 actual_transition: None,
1516 expected_final_state: Some(expected_final_state),
1517 actual_final_state: Some(outcome.final_state),
1518 });
1519 }
1520
1521 Self {
1522 trace_checksum: outcome.trace_checksum,
1523 passed: diffs.is_empty(),
1524 diffs,
1525 }
1526 }
1527}
1528
1529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1531pub struct PaneSemanticReplayFixture {
1532 pub trace: PaneSemanticInputTrace,
1533 #[serde(default)]
1534 pub expected_transitions: Vec<PaneDragResizeTransition>,
1535 pub expected_final_state: PaneDragResizeState,
1536}
1537
1538impl PaneSemanticReplayFixture {
1539 pub fn run(
1541 &self,
1542 machine: &mut PaneDragResizeMachine,
1543 ) -> Result<PaneSemanticReplayConformanceArtifact, PaneSemanticReplayError> {
1544 let outcome = self.trace.replay(machine)?;
1545 Ok(PaneSemanticReplayConformanceArtifact::compare(
1546 &outcome,
1547 &self.expected_transitions,
1548 self.expected_final_state,
1549 ))
1550 }
1551}
1552
1553#[derive(Debug, Clone, PartialEq, Eq)]
1555pub enum PaneSemanticReplayError {
1556 InvalidTrace(PaneSemanticInputTraceError),
1557 Machine(PaneDragResizeMachineError),
1558}
1559
1560impl fmt::Display for PaneSemanticReplayError {
1561 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1562 match self {
1563 Self::InvalidTrace(source) => write!(f, "invalid semantic replay trace: {source}"),
1564 Self::Machine(source) => write!(f, "pane drag/resize machine replay failed: {source}"),
1565 }
1566 }
1567}
1568
1569impl std::error::Error for PaneSemanticReplayError {}
1570
1571fn pane_semantic_input_trace_checksum_payload(
1572 metadata: &PaneSemanticInputTraceMetadata,
1573 events: &[PaneSemanticInputEvent],
1574) -> u64 {
1575 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1576 const PRIME: u64 = 0x0000_0001_0000_01b3;
1577
1578 fn mix(hash: &mut u64, byte: u8) {
1579 *hash ^= u64::from(byte);
1580 *hash = hash.wrapping_mul(PRIME);
1581 }
1582
1583 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
1584 for byte in bytes {
1585 mix(hash, *byte);
1586 }
1587 }
1588
1589 fn mix_u16(hash: &mut u64, value: u16) {
1590 mix_bytes(hash, &value.to_le_bytes());
1591 }
1592
1593 fn mix_u32(hash: &mut u64, value: u32) {
1594 mix_bytes(hash, &value.to_le_bytes());
1595 }
1596
1597 fn mix_i32(hash: &mut u64, value: i32) {
1598 mix_bytes(hash, &value.to_le_bytes());
1599 }
1600
1601 fn mix_u64(hash: &mut u64, value: u64) {
1602 mix_bytes(hash, &value.to_le_bytes());
1603 }
1604
1605 fn mix_i16(hash: &mut u64, value: i16) {
1606 mix_bytes(hash, &value.to_le_bytes());
1607 }
1608
1609 fn mix_bool(hash: &mut u64, value: bool) {
1610 mix(hash, u8::from(value));
1611 }
1612
1613 fn mix_str(hash: &mut u64, value: &str) {
1614 mix_u64(hash, value.len() as u64);
1615 mix_bytes(hash, value.as_bytes());
1616 }
1617
1618 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
1619 mix_u64(hash, extensions.len() as u64);
1620 for (key, value) in extensions {
1621 mix_str(hash, key);
1622 mix_str(hash, value);
1623 }
1624 }
1625
1626 fn mix_target(hash: &mut u64, target: PaneResizeTarget) {
1627 mix_u64(hash, target.split_id.get());
1628 let axis = match target.axis {
1629 SplitAxis::Horizontal => 1,
1630 SplitAxis::Vertical => 2,
1631 };
1632 mix(hash, axis);
1633 }
1634
1635 fn mix_position(hash: &mut u64, position: PanePointerPosition) {
1636 mix_i32(hash, position.x);
1637 mix_i32(hash, position.y);
1638 }
1639
1640 fn mix_optional_target(hash: &mut u64, target: Option<PaneResizeTarget>) {
1641 match target {
1642 Some(target) => {
1643 mix(hash, 1);
1644 mix_target(hash, target);
1645 }
1646 None => mix(hash, 0),
1647 }
1648 }
1649
1650 fn mix_pointer_button(hash: &mut u64, button: PanePointerButton) {
1651 let value = match button {
1652 PanePointerButton::Primary => 1,
1653 PanePointerButton::Secondary => 2,
1654 PanePointerButton::Middle => 3,
1655 };
1656 mix(hash, value);
1657 }
1658
1659 fn mix_resize_direction(hash: &mut u64, direction: PaneResizeDirection) {
1660 let value = match direction {
1661 PaneResizeDirection::Increase => 1,
1662 PaneResizeDirection::Decrease => 2,
1663 };
1664 mix(hash, value);
1665 }
1666
1667 fn mix_cancel_reason(hash: &mut u64, reason: PaneCancelReason) {
1668 let value = match reason {
1669 PaneCancelReason::EscapeKey => 1,
1670 PaneCancelReason::PointerCancel => 2,
1671 PaneCancelReason::FocusLost => 3,
1672 PaneCancelReason::Blur => 4,
1673 PaneCancelReason::Programmatic => 5,
1674 };
1675 mix(hash, value);
1676 }
1677
1678 let mut hash = OFFSET_BASIS;
1679 mix_u16(&mut hash, metadata.schema_version);
1680 mix_u64(&mut hash, metadata.seed);
1681 mix_u64(&mut hash, metadata.start_unix_ms);
1682 mix_str(&mut hash, &metadata.host);
1683 mix_u64(&mut hash, events.len() as u64);
1684
1685 for event in events {
1686 mix_u16(&mut hash, event.schema_version);
1687 mix_u64(&mut hash, event.sequence);
1688 mix_bool(&mut hash, event.modifiers.shift);
1689 mix_bool(&mut hash, event.modifiers.alt);
1690 mix_bool(&mut hash, event.modifiers.ctrl);
1691 mix_bool(&mut hash, event.modifiers.meta);
1692 mix_extensions(&mut hash, &event.extensions);
1693
1694 match event.kind {
1695 PaneSemanticInputEventKind::PointerDown {
1696 target,
1697 pointer_id,
1698 button,
1699 position,
1700 } => {
1701 mix(&mut hash, 1);
1702 mix_target(&mut hash, target);
1703 mix_u32(&mut hash, pointer_id);
1704 mix_pointer_button(&mut hash, button);
1705 mix_position(&mut hash, position);
1706 }
1707 PaneSemanticInputEventKind::PointerMove {
1708 target,
1709 pointer_id,
1710 position,
1711 delta_x,
1712 delta_y,
1713 } => {
1714 mix(&mut hash, 2);
1715 mix_target(&mut hash, target);
1716 mix_u32(&mut hash, pointer_id);
1717 mix_position(&mut hash, position);
1718 mix_i32(&mut hash, delta_x);
1719 mix_i32(&mut hash, delta_y);
1720 }
1721 PaneSemanticInputEventKind::PointerUp {
1722 target,
1723 pointer_id,
1724 button,
1725 position,
1726 } => {
1727 mix(&mut hash, 3);
1728 mix_target(&mut hash, target);
1729 mix_u32(&mut hash, pointer_id);
1730 mix_pointer_button(&mut hash, button);
1731 mix_position(&mut hash, position);
1732 }
1733 PaneSemanticInputEventKind::WheelNudge { target, lines } => {
1734 mix(&mut hash, 4);
1735 mix_target(&mut hash, target);
1736 mix_i16(&mut hash, lines);
1737 }
1738 PaneSemanticInputEventKind::KeyboardResize {
1739 target,
1740 direction,
1741 units,
1742 } => {
1743 mix(&mut hash, 5);
1744 mix_target(&mut hash, target);
1745 mix_resize_direction(&mut hash, direction);
1746 mix_u16(&mut hash, units);
1747 }
1748 PaneSemanticInputEventKind::Cancel { target, reason } => {
1749 mix(&mut hash, 6);
1750 mix_optional_target(&mut hash, target);
1751 mix_cancel_reason(&mut hash, reason);
1752 }
1753 PaneSemanticInputEventKind::Blur { target } => {
1754 mix(&mut hash, 7);
1755 mix_optional_target(&mut hash, target);
1756 }
1757 }
1758 }
1759
1760 hash
1761}
1762
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1765pub struct PaneScaleFactor {
1766 numerator: u32,
1767 denominator: u32,
1768}
1769
1770impl PaneScaleFactor {
1771 pub const ONE: Self = Self {
1773 numerator: 1,
1774 denominator: 1,
1775 };
1776
1777 pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneCoordinateNormalizationError> {
1779 if numerator == 0 || denominator == 0 {
1780 return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1781 field: "scale_factor",
1782 numerator,
1783 denominator,
1784 });
1785 }
1786 let gcd = gcd_u32(numerator, denominator);
1787 Ok(Self {
1788 numerator: numerator / gcd,
1789 denominator: denominator / gcd,
1790 })
1791 }
1792
1793 fn validate(self, field: &'static str) -> Result<(), PaneCoordinateNormalizationError> {
1794 if self.numerator == 0 || self.denominator == 0 {
1795 return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1796 field,
1797 numerator: self.numerator,
1798 denominator: self.denominator,
1799 });
1800 }
1801 Ok(())
1802 }
1803
1804 #[must_use]
1805 pub const fn numerator(self) -> u32 {
1806 if self.numerator == 0 {
1807 1
1808 } else {
1809 self.numerator
1810 }
1811 }
1812
1813 #[must_use]
1814 pub const fn denominator(self) -> u32 {
1815 if self.denominator == 0 {
1816 1
1817 } else {
1818 self.denominator
1819 }
1820 }
1821}
1822
1823impl Default for PaneScaleFactor {
1824 fn default() -> Self {
1825 Self::ONE
1826 }
1827}
1828
1829#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1831#[serde(rename_all = "snake_case")]
1832pub enum PaneCoordinateRoundingPolicy {
1833 #[default]
1835 TowardNegativeInfinity,
1836 NearestHalfTowardNegativeInfinity,
1838}
1839
1840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1842#[serde(tag = "source", rename_all = "snake_case")]
1843pub enum PaneInputCoordinate {
1844 CssPixels { position: PanePointerPosition },
1846 DevicePixels { position: PanePointerPosition },
1848 Cell { position: PanePointerPosition },
1850}
1851
1852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1854pub struct PaneNormalizedCoordinate {
1855 pub global_cell: PanePointerPosition,
1857 pub local_cell: PanePointerPosition,
1859 pub local_css: PanePointerPosition,
1861}
1862
1863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1865pub struct PaneCoordinateNormalizer {
1866 pub viewport_origin_css: PanePointerPosition,
1867 pub viewport_origin_cells: PanePointerPosition,
1868 pub cell_width_css: u16,
1869 pub cell_height_css: u16,
1870 pub dpr: PaneScaleFactor,
1871 pub zoom: PaneScaleFactor,
1872 #[serde(default)]
1873 pub rounding: PaneCoordinateRoundingPolicy,
1874}
1875
1876impl PaneCoordinateNormalizer {
1877 pub fn new(
1879 viewport_origin_css: PanePointerPosition,
1880 viewport_origin_cells: PanePointerPosition,
1881 cell_width_css: u16,
1882 cell_height_css: u16,
1883 dpr: PaneScaleFactor,
1884 zoom: PaneScaleFactor,
1885 rounding: PaneCoordinateRoundingPolicy,
1886 ) -> Result<Self, PaneCoordinateNormalizationError> {
1887 if cell_width_css == 0 || cell_height_css == 0 {
1888 return Err(PaneCoordinateNormalizationError::InvalidCellSize {
1889 width: cell_width_css,
1890 height: cell_height_css,
1891 });
1892 }
1893 dpr.validate("dpr")?;
1894 zoom.validate("zoom")?;
1895
1896 Ok(Self {
1897 viewport_origin_css,
1898 viewport_origin_cells,
1899 cell_width_css,
1900 cell_height_css,
1901 dpr,
1902 zoom,
1903 rounding,
1904 })
1905 }
1906
1907 pub fn normalize(
1909 &self,
1910 input: PaneInputCoordinate,
1911 ) -> Result<PaneNormalizedCoordinate, PaneCoordinateNormalizationError> {
1912 let (local_css_x, local_css_y) = match input {
1913 PaneInputCoordinate::CssPixels { position } => (
1914 i64::from(position.x) - i64::from(self.viewport_origin_css.x),
1915 i64::from(position.y) - i64::from(self.viewport_origin_css.y),
1916 ),
1917 PaneInputCoordinate::DevicePixels { position } => {
1918 let css_x = scale_div_round(
1919 i64::from(position.x),
1920 i64::from(self.dpr.denominator()),
1921 i64::from(self.dpr.numerator()),
1922 self.rounding,
1923 )?;
1924 let css_y = scale_div_round(
1925 i64::from(position.y),
1926 i64::from(self.dpr.denominator()),
1927 i64::from(self.dpr.numerator()),
1928 self.rounding,
1929 )?;
1930 (
1931 css_x - i64::from(self.viewport_origin_css.x),
1932 css_y - i64::from(self.viewport_origin_css.y),
1933 )
1934 }
1935 PaneInputCoordinate::Cell { position } => {
1936 let local_css_x = i64::from(position.x)
1937 .checked_mul(i64::from(self.cell_width_css))
1938 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1939 let local_css_y = i64::from(position.y)
1940 .checked_mul(i64::from(self.cell_height_css))
1941 .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1942 let global_cell_x = i64::from(position.x) + i64::from(self.viewport_origin_cells.x);
1943 let global_cell_y = i64::from(position.y) + i64::from(self.viewport_origin_cells.y);
1944
1945 return Ok(PaneNormalizedCoordinate {
1946 global_cell: PanePointerPosition::new(
1947 to_i32(global_cell_x)?,
1948 to_i32(global_cell_y)?,
1949 ),
1950 local_cell: position,
1951 local_css: PanePointerPosition::new(to_i32(local_css_x)?, to_i32(local_css_y)?),
1952 });
1953 }
1954 };
1955
1956 let unzoomed_css_x = scale_div_round(
1957 local_css_x,
1958 i64::from(self.zoom.denominator()),
1959 i64::from(self.zoom.numerator()),
1960 self.rounding,
1961 )?;
1962 let unzoomed_css_y = scale_div_round(
1963 local_css_y,
1964 i64::from(self.zoom.denominator()),
1965 i64::from(self.zoom.numerator()),
1966 self.rounding,
1967 )?;
1968
1969 let local_cell_x = div_round(
1970 unzoomed_css_x,
1971 i64::from(self.cell_width_css),
1972 self.rounding,
1973 )?;
1974 let local_cell_y = div_round(
1975 unzoomed_css_y,
1976 i64::from(self.cell_height_css),
1977 self.rounding,
1978 )?;
1979
1980 let global_cell_x = local_cell_x + i64::from(self.viewport_origin_cells.x);
1981 let global_cell_y = local_cell_y + i64::from(self.viewport_origin_cells.y);
1982
1983 Ok(PaneNormalizedCoordinate {
1984 global_cell: PanePointerPosition::new(to_i32(global_cell_x)?, to_i32(global_cell_y)?),
1985 local_cell: PanePointerPosition::new(to_i32(local_cell_x)?, to_i32(local_cell_y)?),
1986 local_css: PanePointerPosition::new(to_i32(unzoomed_css_x)?, to_i32(unzoomed_css_y)?),
1987 })
1988 }
1989}
1990
1991#[derive(Debug, Clone, PartialEq, Eq)]
1993pub enum PaneCoordinateNormalizationError {
1994 InvalidCellSize {
1995 width: u16,
1996 height: u16,
1997 },
1998 InvalidScaleFactor {
1999 field: &'static str,
2000 numerator: u32,
2001 denominator: u32,
2002 },
2003 CoordinateOverflow,
2004}
2005
2006impl fmt::Display for PaneCoordinateNormalizationError {
2007 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2008 match self {
2009 Self::InvalidCellSize { width, height } => {
2010 write!(
2011 f,
2012 "invalid pane cell dimensions width={width} height={height} (must be > 0)"
2013 )
2014 }
2015 Self::InvalidScaleFactor {
2016 field,
2017 numerator,
2018 denominator,
2019 } => {
2020 write!(
2021 f,
2022 "invalid pane scale factor for {field}: {numerator}/{denominator} (must be > 0)"
2023 )
2024 }
2025 Self::CoordinateOverflow => {
2026 write!(f, "coordinate conversion overflowed representable range")
2027 }
2028 }
2029 }
2030}
2031
2032impl std::error::Error for PaneCoordinateNormalizationError {}
2033
2034fn scale_div_round(
2035 value: i64,
2036 numerator: i64,
2037 denominator: i64,
2038 rounding: PaneCoordinateRoundingPolicy,
2039) -> Result<i64, PaneCoordinateNormalizationError> {
2040 if denominator <= 0 {
2041 return Err(PaneCoordinateNormalizationError::CoordinateOverflow);
2042 }
2043
2044 let scaled = (value as i128) * (numerator as i128);
2045 let den = denominator as i128;
2046
2047 let floor = scaled.div_euclid(den);
2048 let remainder = scaled.rem_euclid(den);
2049
2050 let mut result = floor;
2051
2052 if remainder != 0 {
2053 match rounding {
2054 PaneCoordinateRoundingPolicy::TowardNegativeInfinity => {}
2055 PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity => {
2056 let twice_remainder = remainder * 2;
2057 if twice_remainder > den {
2058 result += 1;
2059 }
2060 }
2063 }
2064 }
2065
2066 result
2067 .try_into()
2068 .map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
2069}
2070
2071fn div_round(
2072 value: i64,
2073 denominator: i64,
2074 rounding: PaneCoordinateRoundingPolicy,
2075) -> Result<i64, PaneCoordinateNormalizationError> {
2076 scale_div_round(value, 1, denominator, rounding)
2077}
2078
2079fn to_i32(value: i64) -> Result<i32, PaneCoordinateNormalizationError> {
2080 i32::try_from(value).map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
2081}
2082
2083pub const PANE_DRAG_RESIZE_DEFAULT_THRESHOLD: u16 = 2;
2086
2087pub const PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS: u16 = 2;
2090
2091pub const PANE_SNAP_DEFAULT_STEP_BPS: u16 = 500;
2093
2094pub const PANE_SNAP_DEFAULT_HYSTERESIS_BPS: u16 = 125;
2096
2097#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2099#[serde(rename_all = "snake_case")]
2100pub enum PanePrecisionMode {
2101 Normal,
2102 Fine,
2103 Coarse,
2104}
2105
2106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2108pub struct PanePrecisionPolicy {
2109 pub mode: PanePrecisionMode,
2110 pub axis_lock: Option<SplitAxis>,
2111 pub scale: PaneScaleFactor,
2112}
2113
2114impl PanePrecisionPolicy {
2115 #[must_use]
2117 pub fn from_modifiers(modifiers: PaneModifierSnapshot, target_axis: SplitAxis) -> Self {
2118 let mode = if modifiers.alt {
2119 PanePrecisionMode::Fine
2120 } else if modifiers.ctrl {
2121 PanePrecisionMode::Coarse
2122 } else {
2123 PanePrecisionMode::Normal
2124 };
2125 let axis_lock = modifiers.shift.then_some(target_axis);
2126 let scale = match mode {
2127 PanePrecisionMode::Normal => PaneScaleFactor::ONE,
2128 PanePrecisionMode::Fine => PaneScaleFactor {
2129 numerator: 1,
2130 denominator: 2,
2131 },
2132 PanePrecisionMode::Coarse => PaneScaleFactor {
2133 numerator: 2,
2134 denominator: 1,
2135 },
2136 };
2137 Self {
2138 mode,
2139 axis_lock,
2140 scale,
2141 }
2142 }
2143
2144 pub fn apply_delta(
2146 &self,
2147 raw_delta_x: i32,
2148 raw_delta_y: i32,
2149 ) -> Result<(i32, i32), PaneInteractionPolicyError> {
2150 let (locked_x, locked_y) = match self.axis_lock {
2151 Some(SplitAxis::Horizontal) => (raw_delta_x, 0),
2152 Some(SplitAxis::Vertical) => (0, raw_delta_y),
2153 None => (raw_delta_x, raw_delta_y),
2154 };
2155
2156 let scaled_x = scale_delta_by_factor(locked_x, self.scale)?;
2157 let scaled_y = scale_delta_by_factor(locked_y, self.scale)?;
2158 Ok((scaled_x, scaled_y))
2159 }
2160}
2161
2162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2164pub struct PaneSnapTuning {
2165 pub step_bps: u16,
2166 pub hysteresis_bps: u16,
2167}
2168
2169impl PaneSnapTuning {
2170 pub fn new(step_bps: u16, hysteresis_bps: u16) -> Result<Self, PaneInteractionPolicyError> {
2171 let tuning = Self {
2172 step_bps,
2173 hysteresis_bps,
2174 };
2175 tuning.validate()?;
2176 Ok(tuning)
2177 }
2178
2179 pub fn validate(self) -> Result<(), PaneInteractionPolicyError> {
2180 if self.step_bps == 0 || self.step_bps > 10_000 {
2181 return Err(PaneInteractionPolicyError::InvalidSnapTuning {
2182 step_bps: self.step_bps,
2183 hysteresis_bps: self.hysteresis_bps,
2184 });
2185 }
2186 Ok(())
2187 }
2188
2189 #[must_use]
2191 pub fn decide(self, ratio_bps: u16, previous_snap: Option<u16>) -> PaneSnapDecision {
2192 let step = u32::from(self.step_bps).max(1);
2193 let ratio = u32::from(ratio_bps).min(10_000);
2194 let low = ((ratio / step) * step).min(10_000);
2195 let high = (low + step).min(10_000);
2196
2197 let distance_low = ratio.abs_diff(low);
2198 let distance_high = ratio.abs_diff(high);
2199
2200 let (nearest, nearest_distance) = if distance_low <= distance_high {
2201 (low as u16, distance_low as u16)
2202 } else {
2203 (high as u16, distance_high as u16)
2204 };
2205
2206 if let Some(previous) = previous_snap {
2207 let distance_previous = ratio.abs_diff(u32::from(previous));
2208 if distance_previous <= u32::from(self.hysteresis_bps) {
2209 return PaneSnapDecision {
2210 input_ratio_bps: ratio_bps,
2211 snapped_ratio_bps: Some(previous),
2212 nearest_ratio_bps: nearest,
2213 nearest_distance_bps: nearest_distance,
2214 reason: PaneSnapReason::RetainedPrevious,
2215 };
2216 }
2217 }
2218
2219 if nearest_distance <= self.hysteresis_bps {
2220 PaneSnapDecision {
2221 input_ratio_bps: ratio_bps,
2222 snapped_ratio_bps: Some(nearest),
2223 nearest_ratio_bps: nearest,
2224 nearest_distance_bps: nearest_distance,
2225 reason: PaneSnapReason::SnappedNearest,
2226 }
2227 } else {
2228 PaneSnapDecision {
2229 input_ratio_bps: ratio_bps,
2230 snapped_ratio_bps: None,
2231 nearest_ratio_bps: nearest,
2232 nearest_distance_bps: nearest_distance,
2233 reason: PaneSnapReason::UnsnapOutsideWindow,
2234 }
2235 }
2236 }
2237}
2238
2239impl Default for PaneSnapTuning {
2240 fn default() -> Self {
2241 Self {
2242 step_bps: PANE_SNAP_DEFAULT_STEP_BPS,
2243 hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
2244 }
2245 }
2246}
2247
2248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2250pub struct PaneDragBehaviorTuning {
2251 pub activation_threshold: u16,
2252 pub update_hysteresis: u16,
2253 pub snap: PaneSnapTuning,
2254}
2255
2256impl PaneDragBehaviorTuning {
2257 pub fn new(
2258 activation_threshold: u16,
2259 update_hysteresis: u16,
2260 snap: PaneSnapTuning,
2261 ) -> Result<Self, PaneInteractionPolicyError> {
2262 if activation_threshold == 0 {
2263 return Err(PaneInteractionPolicyError::InvalidThreshold {
2264 field: "activation_threshold",
2265 value: activation_threshold,
2266 });
2267 }
2268 if update_hysteresis == 0 {
2269 return Err(PaneInteractionPolicyError::InvalidThreshold {
2270 field: "update_hysteresis",
2271 value: update_hysteresis,
2272 });
2273 }
2274 snap.validate()?;
2275 Ok(Self {
2276 activation_threshold,
2277 update_hysteresis,
2278 snap,
2279 })
2280 }
2281
2282 #[must_use]
2283 pub fn should_start_drag(
2284 self,
2285 origin: PanePointerPosition,
2286 current: PanePointerPosition,
2287 ) -> bool {
2288 crossed_drag_threshold(origin, current, self.activation_threshold)
2289 }
2290
2291 #[must_use]
2292 pub fn should_emit_drag_update(
2293 self,
2294 previous: PanePointerPosition,
2295 current: PanePointerPosition,
2296 ) -> bool {
2297 crossed_drag_threshold(previous, current, self.update_hysteresis)
2298 }
2299}
2300
2301impl Default for PaneDragBehaviorTuning {
2302 fn default() -> Self {
2303 Self {
2304 activation_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
2305 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
2306 snap: PaneSnapTuning::default(),
2307 }
2308 }
2309}
2310
2311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2313#[serde(rename_all = "snake_case")]
2314pub enum PaneSnapReason {
2315 RetainedPrevious,
2316 SnappedNearest,
2317 UnsnapOutsideWindow,
2318}
2319
2320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2322pub struct PaneSnapDecision {
2323 pub input_ratio_bps: u16,
2324 pub snapped_ratio_bps: Option<u16>,
2325 pub nearest_ratio_bps: u16,
2326 pub nearest_distance_bps: u16,
2327 pub reason: PaneSnapReason,
2328}
2329
2330#[derive(Debug, Clone, PartialEq, Eq)]
2332pub enum PaneInteractionPolicyError {
2333 InvalidThreshold { field: &'static str, value: u16 },
2334 InvalidSnapTuning { step_bps: u16, hysteresis_bps: u16 },
2335 DeltaOverflow,
2336}
2337
2338impl fmt::Display for PaneInteractionPolicyError {
2339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2340 match self {
2341 Self::InvalidThreshold { field, value } => {
2342 write!(f, "invalid {field} value {value} (must be > 0)")
2343 }
2344 Self::InvalidSnapTuning {
2345 step_bps,
2346 hysteresis_bps,
2347 } => {
2348 write!(
2349 f,
2350 "invalid snap tuning step_bps={step_bps} hysteresis_bps={hysteresis_bps}"
2351 )
2352 }
2353 Self::DeltaOverflow => write!(f, "delta scaling overflow"),
2354 }
2355 }
2356}
2357
2358impl std::error::Error for PaneInteractionPolicyError {}
2359
2360fn scale_delta_by_factor(
2361 delta: i32,
2362 factor: PaneScaleFactor,
2363) -> Result<i32, PaneInteractionPolicyError> {
2364 let scaled = i64::from(delta)
2365 .checked_mul(i64::from(factor.numerator()))
2366 .ok_or(PaneInteractionPolicyError::DeltaOverflow)?;
2367 let normalized = scaled / i64::from(factor.denominator());
2368 i32::try_from(normalized).map_err(|_| PaneInteractionPolicyError::DeltaOverflow)
2369}
2370
2371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2378#[serde(tag = "state", rename_all = "snake_case")]
2379pub enum PaneDragResizeState {
2380 Idle,
2381 Armed {
2382 target: PaneResizeTarget,
2383 pointer_id: u32,
2384 origin: PanePointerPosition,
2385 current: PanePointerPosition,
2386 started_sequence: u64,
2387 },
2388 Dragging {
2389 target: PaneResizeTarget,
2390 pointer_id: u32,
2391 origin: PanePointerPosition,
2392 current: PanePointerPosition,
2393 started_sequence: u64,
2394 drag_started_sequence: u64,
2395 },
2396}
2397
2398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2400#[serde(rename_all = "snake_case")]
2401pub enum PaneDragResizeNoopReason {
2402 IdleWithoutActiveDrag,
2403 ActiveDragAlreadyInProgress,
2404 PointerMismatch,
2405 TargetMismatch,
2406 ActiveStateDisallowsDiscreteInput,
2407 ThresholdNotReached,
2408 BelowHysteresis,
2409}
2410
2411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2413#[serde(tag = "effect", rename_all = "snake_case")]
2414pub enum PaneDragResizeEffect {
2415 Armed {
2416 target: PaneResizeTarget,
2417 pointer_id: u32,
2418 origin: PanePointerPosition,
2419 },
2420 DragStarted {
2421 target: PaneResizeTarget,
2422 pointer_id: u32,
2423 origin: PanePointerPosition,
2424 current: PanePointerPosition,
2425 total_delta_x: i32,
2426 total_delta_y: i32,
2427 },
2428 DragUpdated {
2429 target: PaneResizeTarget,
2430 pointer_id: u32,
2431 previous: PanePointerPosition,
2432 current: PanePointerPosition,
2433 delta_x: i32,
2434 delta_y: i32,
2435 total_delta_x: i32,
2436 total_delta_y: i32,
2437 },
2438 Committed {
2439 target: PaneResizeTarget,
2440 pointer_id: u32,
2441 origin: PanePointerPosition,
2442 end: PanePointerPosition,
2443 total_delta_x: i32,
2444 total_delta_y: i32,
2445 },
2446 Canceled {
2447 target: Option<PaneResizeTarget>,
2448 pointer_id: Option<u32>,
2449 reason: PaneCancelReason,
2450 },
2451 KeyboardApplied {
2452 target: PaneResizeTarget,
2453 direction: PaneResizeDirection,
2454 units: u16,
2455 },
2456 WheelApplied {
2457 target: PaneResizeTarget,
2458 lines: i16,
2459 },
2460 Noop {
2461 reason: PaneDragResizeNoopReason,
2462 },
2463}
2464
2465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2467pub struct PaneDragResizeTransition {
2468 pub transition_id: u64,
2469 pub sequence: u64,
2470 pub from: PaneDragResizeState,
2471 pub to: PaneDragResizeState,
2472 pub effect: PaneDragResizeEffect,
2473}
2474
2475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2477pub struct PaneDragResizeMachine {
2478 state: PaneDragResizeState,
2479 drag_threshold: u16,
2480 update_hysteresis: u16,
2481 transition_counter: u64,
2482}
2483
2484impl Default for PaneDragResizeMachine {
2485 fn default() -> Self {
2486 Self {
2487 state: PaneDragResizeState::Idle,
2488 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
2489 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
2490 transition_counter: 0,
2491 }
2492 }
2493}
2494
2495impl PaneDragResizeMachine {
2496 pub fn new(drag_threshold: u16) -> Result<Self, PaneDragResizeMachineError> {
2498 Self::new_with_hysteresis(drag_threshold, PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS)
2499 }
2500
2501 pub fn new_with_hysteresis(
2504 drag_threshold: u16,
2505 update_hysteresis: u16,
2506 ) -> Result<Self, PaneDragResizeMachineError> {
2507 if drag_threshold == 0 {
2508 return Err(PaneDragResizeMachineError::InvalidDragThreshold {
2509 threshold: drag_threshold,
2510 });
2511 }
2512 if update_hysteresis == 0 {
2513 return Err(PaneDragResizeMachineError::InvalidUpdateHysteresis {
2514 hysteresis: update_hysteresis,
2515 });
2516 }
2517 Ok(Self {
2518 state: PaneDragResizeState::Idle,
2519 drag_threshold,
2520 update_hysteresis,
2521 transition_counter: 0,
2522 })
2523 }
2524
2525 #[must_use]
2527 pub const fn state(&self) -> PaneDragResizeState {
2528 self.state
2529 }
2530
2531 #[must_use]
2533 pub const fn drag_threshold(&self) -> u16 {
2534 self.drag_threshold
2535 }
2536
2537 #[must_use]
2539 pub const fn update_hysteresis(&self) -> u16 {
2540 self.update_hysteresis
2541 }
2542
2543 #[must_use]
2545 pub const fn is_active(&self) -> bool {
2546 !matches!(self.state, PaneDragResizeState::Idle)
2547 }
2548
2549 pub fn force_cancel(&mut self) -> Option<PaneDragResizeTransition> {
2559 let from = self.state;
2560 match from {
2561 PaneDragResizeState::Idle => None,
2562 PaneDragResizeState::Armed {
2563 target, pointer_id, ..
2564 }
2565 | PaneDragResizeState::Dragging {
2566 target, pointer_id, ..
2567 } => {
2568 self.state = PaneDragResizeState::Idle;
2569 self.transition_counter = self.transition_counter.saturating_add(1);
2570 Some(PaneDragResizeTransition {
2571 transition_id: self.transition_counter,
2572 sequence: 0,
2573 from,
2574 to: PaneDragResizeState::Idle,
2575 effect: PaneDragResizeEffect::Canceled {
2576 target: Some(target),
2577 pointer_id: Some(pointer_id),
2578 reason: PaneCancelReason::Programmatic,
2579 },
2580 })
2581 }
2582 }
2583 }
2584
2585 pub fn apply_event(
2588 &mut self,
2589 event: &PaneSemanticInputEvent,
2590 ) -> Result<PaneDragResizeTransition, PaneDragResizeMachineError> {
2591 event
2592 .validate()
2593 .map_err(PaneDragResizeMachineError::InvalidEvent)?;
2594
2595 let from = self.state;
2596 let effect = match (self.state, &event.kind) {
2597 (
2598 PaneDragResizeState::Idle,
2599 PaneSemanticInputEventKind::PointerDown {
2600 target,
2601 pointer_id,
2602 position,
2603 ..
2604 },
2605 ) => {
2606 self.state = PaneDragResizeState::Armed {
2607 target: *target,
2608 pointer_id: *pointer_id,
2609 origin: *position,
2610 current: *position,
2611 started_sequence: event.sequence,
2612 };
2613 PaneDragResizeEffect::Armed {
2614 target: *target,
2615 pointer_id: *pointer_id,
2616 origin: *position,
2617 }
2618 }
2619 (
2620 PaneDragResizeState::Idle,
2621 PaneSemanticInputEventKind::KeyboardResize {
2622 target,
2623 direction,
2624 units,
2625 },
2626 ) => PaneDragResizeEffect::KeyboardApplied {
2627 target: *target,
2628 direction: *direction,
2629 units: *units,
2630 },
2631 (
2632 PaneDragResizeState::Idle,
2633 PaneSemanticInputEventKind::WheelNudge { target, lines },
2634 ) => PaneDragResizeEffect::WheelApplied {
2635 target: *target,
2636 lines: *lines,
2637 },
2638 (PaneDragResizeState::Idle, _) => PaneDragResizeEffect::Noop {
2639 reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag,
2640 },
2641 (
2642 PaneDragResizeState::Armed {
2643 target,
2644 pointer_id,
2645 origin,
2646 current: _,
2647 started_sequence,
2648 },
2649 PaneSemanticInputEventKind::PointerMove {
2650 target: incoming_target,
2651 pointer_id: incoming_pointer_id,
2652 position,
2653 ..
2654 },
2655 ) => {
2656 if *incoming_pointer_id != pointer_id {
2657 PaneDragResizeEffect::Noop {
2658 reason: PaneDragResizeNoopReason::PointerMismatch,
2659 }
2660 } else if *incoming_target != target {
2661 PaneDragResizeEffect::Noop {
2662 reason: PaneDragResizeNoopReason::TargetMismatch,
2663 }
2664 } else {
2665 self.state = PaneDragResizeState::Armed {
2666 target,
2667 pointer_id,
2668 origin,
2669 current: *position,
2670 started_sequence,
2671 };
2672 if crossed_drag_threshold(origin, *position, self.drag_threshold) {
2673 self.state = PaneDragResizeState::Dragging {
2674 target,
2675 pointer_id,
2676 origin,
2677 current: *position,
2678 started_sequence,
2679 drag_started_sequence: event.sequence,
2680 };
2681 let (total_delta_x, total_delta_y) = delta(origin, *position);
2682 PaneDragResizeEffect::DragStarted {
2683 target,
2684 pointer_id,
2685 origin,
2686 current: *position,
2687 total_delta_x,
2688 total_delta_y,
2689 }
2690 } else {
2691 PaneDragResizeEffect::Noop {
2692 reason: PaneDragResizeNoopReason::ThresholdNotReached,
2693 }
2694 }
2695 }
2696 }
2697 (
2698 PaneDragResizeState::Armed {
2699 target,
2700 pointer_id,
2701 origin,
2702 ..
2703 },
2704 PaneSemanticInputEventKind::PointerUp {
2705 target: incoming_target,
2706 pointer_id: incoming_pointer_id,
2707 position,
2708 ..
2709 },
2710 ) => {
2711 if *incoming_pointer_id != pointer_id {
2712 PaneDragResizeEffect::Noop {
2713 reason: PaneDragResizeNoopReason::PointerMismatch,
2714 }
2715 } else if *incoming_target != target {
2716 PaneDragResizeEffect::Noop {
2717 reason: PaneDragResizeNoopReason::TargetMismatch,
2718 }
2719 } else {
2720 self.state = PaneDragResizeState::Idle;
2721 let (total_delta_x, total_delta_y) = delta(origin, *position);
2722 PaneDragResizeEffect::Committed {
2723 target,
2724 pointer_id,
2725 origin,
2726 end: *position,
2727 total_delta_x,
2728 total_delta_y,
2729 }
2730 }
2731 }
2732 (
2733 PaneDragResizeState::Armed {
2734 target, pointer_id, ..
2735 },
2736 PaneSemanticInputEventKind::Cancel {
2737 target: incoming_target,
2738 reason,
2739 },
2740 ) => {
2741 if !cancel_target_matches(target, *incoming_target) {
2742 PaneDragResizeEffect::Noop {
2743 reason: PaneDragResizeNoopReason::TargetMismatch,
2744 }
2745 } else {
2746 self.state = PaneDragResizeState::Idle;
2747 PaneDragResizeEffect::Canceled {
2748 target: Some(target),
2749 pointer_id: Some(pointer_id),
2750 reason: *reason,
2751 }
2752 }
2753 }
2754 (
2755 PaneDragResizeState::Armed {
2756 target, pointer_id, ..
2757 },
2758 PaneSemanticInputEventKind::Blur {
2759 target: incoming_target,
2760 },
2761 ) => {
2762 if !cancel_target_matches(target, *incoming_target) {
2763 PaneDragResizeEffect::Noop {
2764 reason: PaneDragResizeNoopReason::TargetMismatch,
2765 }
2766 } else {
2767 self.state = PaneDragResizeState::Idle;
2768 PaneDragResizeEffect::Canceled {
2769 target: Some(target),
2770 pointer_id: Some(pointer_id),
2771 reason: PaneCancelReason::Blur,
2772 }
2773 }
2774 }
2775 (PaneDragResizeState::Armed { .. }, PaneSemanticInputEventKind::PointerDown { .. }) => {
2776 PaneDragResizeEffect::Noop {
2777 reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2778 }
2779 }
2780 (
2781 PaneDragResizeState::Armed { .. },
2782 PaneSemanticInputEventKind::KeyboardResize { .. }
2783 | PaneSemanticInputEventKind::WheelNudge { .. },
2784 ) => PaneDragResizeEffect::Noop {
2785 reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2786 },
2787 (
2788 PaneDragResizeState::Dragging {
2789 target,
2790 pointer_id,
2791 origin,
2792 current,
2793 started_sequence,
2794 drag_started_sequence,
2795 },
2796 PaneSemanticInputEventKind::PointerMove {
2797 target: incoming_target,
2798 pointer_id: incoming_pointer_id,
2799 position,
2800 ..
2801 },
2802 ) => {
2803 if *incoming_pointer_id != pointer_id {
2804 PaneDragResizeEffect::Noop {
2805 reason: PaneDragResizeNoopReason::PointerMismatch,
2806 }
2807 } else if *incoming_target != target {
2808 PaneDragResizeEffect::Noop {
2809 reason: PaneDragResizeNoopReason::TargetMismatch,
2810 }
2811 } else {
2812 let previous = current;
2813 if !crossed_drag_threshold(previous, *position, self.update_hysteresis) {
2814 PaneDragResizeEffect::Noop {
2815 reason: PaneDragResizeNoopReason::BelowHysteresis,
2816 }
2817 } else {
2818 let (delta_x, delta_y) = delta(previous, *position);
2819 let (total_delta_x, total_delta_y) = delta(origin, *position);
2820 self.state = PaneDragResizeState::Dragging {
2821 target,
2822 pointer_id,
2823 origin,
2824 current: *position,
2825 started_sequence,
2826 drag_started_sequence,
2827 };
2828 PaneDragResizeEffect::DragUpdated {
2829 target,
2830 pointer_id,
2831 previous,
2832 current: *position,
2833 delta_x,
2834 delta_y,
2835 total_delta_x,
2836 total_delta_y,
2837 }
2838 }
2839 }
2840 }
2841 (
2842 PaneDragResizeState::Dragging {
2843 target,
2844 pointer_id,
2845 origin,
2846 ..
2847 },
2848 PaneSemanticInputEventKind::PointerUp {
2849 target: incoming_target,
2850 pointer_id: incoming_pointer_id,
2851 position,
2852 ..
2853 },
2854 ) => {
2855 if *incoming_pointer_id != pointer_id {
2856 PaneDragResizeEffect::Noop {
2857 reason: PaneDragResizeNoopReason::PointerMismatch,
2858 }
2859 } else if *incoming_target != target {
2860 PaneDragResizeEffect::Noop {
2861 reason: PaneDragResizeNoopReason::TargetMismatch,
2862 }
2863 } else {
2864 self.state = PaneDragResizeState::Idle;
2865 let (total_delta_x, total_delta_y) = delta(origin, *position);
2866 PaneDragResizeEffect::Committed {
2867 target,
2868 pointer_id,
2869 origin,
2870 end: *position,
2871 total_delta_x,
2872 total_delta_y,
2873 }
2874 }
2875 }
2876 (
2877 PaneDragResizeState::Dragging {
2878 target, pointer_id, ..
2879 },
2880 PaneSemanticInputEventKind::Cancel {
2881 target: incoming_target,
2882 reason,
2883 },
2884 ) => {
2885 if !cancel_target_matches(target, *incoming_target) {
2886 PaneDragResizeEffect::Noop {
2887 reason: PaneDragResizeNoopReason::TargetMismatch,
2888 }
2889 } else {
2890 self.state = PaneDragResizeState::Idle;
2891 PaneDragResizeEffect::Canceled {
2892 target: Some(target),
2893 pointer_id: Some(pointer_id),
2894 reason: *reason,
2895 }
2896 }
2897 }
2898 (
2899 PaneDragResizeState::Dragging {
2900 target, pointer_id, ..
2901 },
2902 PaneSemanticInputEventKind::Blur {
2903 target: incoming_target,
2904 },
2905 ) => {
2906 if !cancel_target_matches(target, *incoming_target) {
2907 PaneDragResizeEffect::Noop {
2908 reason: PaneDragResizeNoopReason::TargetMismatch,
2909 }
2910 } else {
2911 self.state = PaneDragResizeState::Idle;
2912 PaneDragResizeEffect::Canceled {
2913 target: Some(target),
2914 pointer_id: Some(pointer_id),
2915 reason: PaneCancelReason::Blur,
2916 }
2917 }
2918 }
2919 (
2920 PaneDragResizeState::Dragging { .. },
2921 PaneSemanticInputEventKind::PointerDown { .. },
2922 ) => PaneDragResizeEffect::Noop {
2923 reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2924 },
2925 (
2926 PaneDragResizeState::Dragging { .. },
2927 PaneSemanticInputEventKind::KeyboardResize { .. }
2928 | PaneSemanticInputEventKind::WheelNudge { .. },
2929 ) => PaneDragResizeEffect::Noop {
2930 reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2931 },
2932 };
2933
2934 self.transition_counter = self.transition_counter.saturating_add(1);
2935 Ok(PaneDragResizeTransition {
2936 transition_id: self.transition_counter,
2937 sequence: event.sequence,
2938 from,
2939 to: self.state,
2940 effect,
2941 })
2942 }
2943}
2944
2945#[derive(Debug, Clone, PartialEq, Eq)]
2947pub enum PaneDragResizeMachineError {
2948 InvalidDragThreshold { threshold: u16 },
2949 InvalidUpdateHysteresis { hysteresis: u16 },
2950 InvalidEvent(PaneSemanticInputEventError),
2951}
2952
2953impl fmt::Display for PaneDragResizeMachineError {
2954 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2955 match self {
2956 Self::InvalidDragThreshold { threshold } => {
2957 write!(f, "drag threshold must be > 0 (got {threshold})")
2958 }
2959 Self::InvalidUpdateHysteresis { hysteresis } => {
2960 write!(f, "update hysteresis must be > 0 (got {hysteresis})")
2961 }
2962 Self::InvalidEvent(error) => write!(f, "invalid semantic pane input event: {error}"),
2963 }
2964 }
2965}
2966
2967impl std::error::Error for PaneDragResizeMachineError {
2968 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2969 if let Self::InvalidEvent(error) = self {
2970 return Some(error);
2971 }
2972 None
2973 }
2974}
2975
2976fn delta(origin: PanePointerPosition, current: PanePointerPosition) -> (i32, i32) {
2977 (current.x - origin.x, current.y - origin.y)
2978}
2979
2980fn crossed_drag_threshold(
2981 origin: PanePointerPosition,
2982 current: PanePointerPosition,
2983 threshold: u16,
2984) -> bool {
2985 let (dx, dy) = delta(origin, current);
2986 let threshold = i64::from(threshold);
2987 let squared_distance = i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy);
2988 squared_distance >= threshold * threshold
2989}
2990
2991fn cancel_target_matches(active: PaneResizeTarget, incoming: Option<PaneResizeTarget>) -> bool {
2992 incoming.is_none() || incoming == Some(active)
2993}
2994
2995fn round_f64_to_i32(value: f64) -> i32 {
2996 if !value.is_finite() {
2997 return 0;
2998 }
2999 if value >= f64::from(i32::MAX) {
3000 return i32::MAX;
3001 }
3002 if value <= f64::from(i32::MIN) {
3003 return i32::MIN;
3004 }
3005 value.round() as i32
3006}
3007
3008fn axis_share_from_pointer(
3009 rect: Rect,
3010 pointer: PanePointerPosition,
3011 axis: SplitAxis,
3012 inset_cells: f64,
3013) -> f64 {
3014 let inset = inset_cells.max(0.0);
3015 let (origin, extent, coordinate) = match axis {
3016 SplitAxis::Horizontal => (
3017 f64::from(rect.x),
3018 f64::from(rect.width),
3019 f64::from(pointer.x),
3020 ),
3021 SplitAxis::Vertical => (
3022 f64::from(rect.y),
3023 f64::from(rect.height),
3024 f64::from(pointer.y),
3025 ),
3026 };
3027 if extent <= 0.0 {
3028 return 0.5;
3029 }
3030 let low = origin + inset.min(extent / 2.0);
3031 let high = (origin + extent) - inset.min(extent / 2.0);
3032 if high <= low {
3033 return 0.5;
3034 }
3035 ((coordinate - low) / (high - low)).clamp(0.0, 1.0)
3036}
3037
3038fn elastic_ratio_bps(raw_bps: u16, pressure: PanePressureSnapProfile) -> u16 {
3039 let raw = f64::from(raw_bps.clamp(1, 9_999)) / 10_000.0;
3040 let confidence = (f64::from(pressure.strength_bps) / 10_000.0).clamp(0.0, 1.0);
3041 let edge_band = (0.16 - confidence * 0.09).clamp(0.05, 0.18);
3042 let resistance = (0.62 - confidence * 0.34).clamp(0.18, 0.68);
3043 let eased = if raw < edge_band {
3044 let ratio = (raw / edge_band).clamp(0.0, 1.0);
3045 edge_band * ratio.powf(1.0 / (1.0 + resistance))
3046 } else if raw > 1.0 - edge_band {
3047 let ratio = ((1.0 - raw) / edge_band).clamp(0.0, 1.0);
3048 1.0 - edge_band * ratio.powf(1.0 / (1.0 + resistance))
3049 } else {
3050 raw
3051 };
3052 (eased * 10_000.0).round().clamp(1.0, 9_999.0) as u16
3053}
3054
3055fn classify_resize_grip(
3056 rect: Rect,
3057 pointer: PanePointerPosition,
3058 inset_cells: f64,
3059) -> Option<PaneResizeGrip> {
3060 let inset = inset_cells.max(0.5);
3061 let left = f64::from(rect.x);
3062 let right = f64::from(rect.x.saturating_add(rect.width.saturating_sub(1)));
3063 let top = f64::from(rect.y);
3064 let bottom = f64::from(rect.y.saturating_add(rect.height.saturating_sub(1)));
3065 let px = f64::from(pointer.x);
3066 let py = f64::from(pointer.y);
3067
3068 if px < left - inset || px > right + inset || py < top - inset || py > bottom + inset {
3069 return None;
3070 }
3071
3072 let mut near_left = (px - left).abs() <= inset;
3073 let mut near_right = (px - right).abs() <= inset;
3074 let mut near_top = (py - top).abs() <= inset;
3075 let mut near_bottom = (py - bottom).abs() <= inset;
3076
3077 if near_left && near_right {
3079 if (px - left).abs() < (px - right).abs() {
3080 near_right = false;
3081 } else {
3082 near_left = false;
3083 }
3084 }
3085 if near_top && near_bottom {
3086 if (py - top).abs() < (py - bottom).abs() {
3087 near_bottom = false;
3088 } else {
3089 near_top = false;
3090 }
3091 }
3092
3093 match (near_left, near_right, near_top, near_bottom) {
3094 (true, false, true, false) => Some(PaneResizeGrip::TopLeft),
3095 (false, true, true, false) => Some(PaneResizeGrip::TopRight),
3096 (true, false, false, true) => Some(PaneResizeGrip::BottomLeft),
3097 (false, true, false, true) => Some(PaneResizeGrip::BottomRight),
3098 (true, false, false, false) => Some(PaneResizeGrip::Left),
3099 (false, true, false, false) => Some(PaneResizeGrip::Right),
3100 (false, false, true, false) => Some(PaneResizeGrip::Top),
3101 (false, false, false, true) => Some(PaneResizeGrip::Bottom),
3102 _ => None,
3103 }
3104}
3105
3106fn euclidean_distance(a: PanePointerPosition, b: PanePointerPosition) -> f64 {
3107 let dx = f64::from(a.x - b.x);
3108 let dy = f64::from(a.y - b.y);
3109 (dx * dx + dy * dy).sqrt()
3110}
3111
3112fn rect_zone_anchor(rect: Rect, zone: PaneDockZone) -> PanePointerPosition {
3113 let left = i32::from(rect.x);
3114 let right = i32::from(rect.x.saturating_add(rect.width.saturating_sub(1)));
3115 let top = i32::from(rect.y);
3116 let bottom = i32::from(rect.y.saturating_add(rect.height.saturating_sub(1)));
3117 let mid_x = (left + right) / 2;
3118 let mid_y = (top + bottom) / 2;
3119 match zone {
3120 PaneDockZone::Left => PanePointerPosition::new(left, mid_y),
3121 PaneDockZone::Right => PanePointerPosition::new(right, mid_y),
3122 PaneDockZone::Top => PanePointerPosition::new(mid_x, top),
3123 PaneDockZone::Bottom => PanePointerPosition::new(mid_x, bottom),
3124 PaneDockZone::Center => PanePointerPosition::new(mid_x, mid_y),
3125 }
3126}
3127
3128fn dock_zone_ghost_rect(rect: Rect, zone: PaneDockZone) -> Rect {
3129 match zone {
3130 PaneDockZone::Left => {
3131 Rect::new(rect.x, rect.y, (rect.width / 2).max(1), rect.height.max(1))
3132 }
3133 PaneDockZone::Right => {
3134 let width = (rect.width / 2).max(1);
3135 Rect::new(
3136 rect.x.saturating_add(rect.width.saturating_sub(width)),
3137 rect.y,
3138 width,
3139 rect.height.max(1),
3140 )
3141 }
3142 PaneDockZone::Top => Rect::new(rect.x, rect.y, rect.width.max(1), (rect.height / 2).max(1)),
3143 PaneDockZone::Bottom => {
3144 let height = (rect.height / 2).max(1);
3145 Rect::new(
3146 rect.x,
3147 rect.y.saturating_add(rect.height.saturating_sub(height)),
3148 rect.width.max(1),
3149 height,
3150 )
3151 }
3152 PaneDockZone::Center => rect,
3153 }
3154}
3155
3156fn dock_zone_score(distance: f64, radius: f64, zone: PaneDockZone) -> f64 {
3157 if radius <= 0.0 || distance > radius {
3158 return 0.0;
3159 }
3160 let base = 1.0 - (distance / radius);
3161 let zone_weight = match zone {
3162 PaneDockZone::Center => 0.85,
3163 PaneDockZone::Left | PaneDockZone::Right | PaneDockZone::Top | PaneDockZone::Bottom => 1.0,
3164 };
3165 base * zone_weight
3166}
3167
3168const fn dock_zone_rank(zone: PaneDockZone) -> u8 {
3169 match zone {
3170 PaneDockZone::Left => 0,
3171 PaneDockZone::Right => 1,
3172 PaneDockZone::Top => 2,
3173 PaneDockZone::Bottom => 3,
3174 PaneDockZone::Center => 4,
3175 }
3176}
3177
3178fn dock_preview_for_rect(
3179 target: PaneId,
3180 rect: Rect,
3181 pointer: PanePointerPosition,
3182 magnetic_field_cells: f64,
3183) -> Option<PaneDockPreview> {
3184 let radius = magnetic_field_cells.max(0.5);
3185 let zones = [
3186 PaneDockZone::Left,
3187 PaneDockZone::Right,
3188 PaneDockZone::Top,
3189 PaneDockZone::Bottom,
3190 PaneDockZone::Center,
3191 ];
3192 let mut best: Option<PaneDockPreview> = None;
3193 for zone in zones {
3194 let anchor = rect_zone_anchor(rect, zone);
3195 let distance = euclidean_distance(anchor, pointer);
3196 let score = dock_zone_score(distance, radius, zone);
3197 if score <= 0.0 {
3198 continue;
3199 }
3200 let candidate = PaneDockPreview {
3201 target,
3202 zone,
3203 score,
3204 ghost_rect: dock_zone_ghost_rect(rect, zone),
3205 };
3206 match best {
3207 Some(current) if candidate.score <= current.score => {}
3208 _ => best = Some(candidate),
3209 }
3210 }
3211 best
3212}
3213
3214fn dock_zone_motion_intent(zone: PaneDockZone, motion: PaneMotionVector) -> f64 {
3215 let dx = f64::from(motion.delta_x);
3216 let dy = f64::from(motion.delta_y);
3217 let abs_dx = dx.abs();
3218 let abs_dy = dy.abs();
3219 let total = (abs_dx + abs_dy).max(1.0);
3220 let horizontal = abs_dx / total;
3221 let vertical = abs_dy / total;
3222 let speed_factor = (motion.speed / 140.0).clamp(0.0, 1.0);
3223 let noise_penalty = (f64::from(motion.direction_changes) / 10.0).clamp(0.0, 1.0);
3224
3225 let directional = match zone {
3226 PaneDockZone::Left => {
3227 if dx < 0.0 {
3228 0.95 + horizontal * 0.55
3229 } else {
3230 1.0 - horizontal * 0.35
3231 }
3232 }
3233 PaneDockZone::Right => {
3234 if dx > 0.0 {
3235 0.95 + horizontal * 0.55
3236 } else {
3237 1.0 - horizontal * 0.35
3238 }
3239 }
3240 PaneDockZone::Top => {
3241 if dy < 0.0 {
3242 0.95 + vertical * 0.55
3243 } else {
3244 1.0 - vertical * 0.35
3245 }
3246 }
3247 PaneDockZone::Bottom => {
3248 if dy > 0.0 {
3249 0.95 + vertical * 0.55
3250 } else {
3251 1.0 - vertical * 0.35
3252 }
3253 }
3254 PaneDockZone::Center => {
3255 let axis_ambiguity = 1.0 - horizontal.max(vertical);
3256 0.9 + axis_ambiguity * 0.25 - speed_factor * 0.12
3257 }
3258 };
3259 (directional - noise_penalty * 0.22).clamp(0.55, 1.45)
3260}
3261
3262fn dock_preview_for_rect_with_motion(
3263 target: PaneId,
3264 rect: Rect,
3265 pointer: PanePointerPosition,
3266 magnetic_field_cells: f64,
3267 motion: PaneMotionVector,
3268) -> Option<PaneDockPreview> {
3269 let radius = magnetic_field_cells.max(0.5);
3270 let zones = [
3271 PaneDockZone::Left,
3272 PaneDockZone::Right,
3273 PaneDockZone::Top,
3274 PaneDockZone::Bottom,
3275 PaneDockZone::Center,
3276 ];
3277 let mut best: Option<PaneDockPreview> = None;
3278 for zone in zones {
3279 let anchor = rect_zone_anchor(rect, zone);
3280 let distance = euclidean_distance(anchor, pointer);
3281 let base = dock_zone_score(distance, radius, zone);
3282 if base <= 0.0 {
3283 continue;
3284 }
3285 let intent = dock_zone_motion_intent(zone, motion);
3286 let score = (base * intent).clamp(0.0, 1.0);
3287 if score <= 0.0 {
3288 continue;
3289 }
3290 let candidate = PaneDockPreview {
3291 target,
3292 zone,
3293 score,
3294 ghost_rect: dock_zone_ghost_rect(rect, zone),
3295 };
3296 match best {
3297 Some(current) if candidate.score <= current.score => {}
3298 _ => best = Some(candidate),
3299 }
3300 }
3301 best
3302}
3303
3304fn zone_to_axis_placement_and_target_share(
3305 zone: PaneDockZone,
3306 incoming_share_bps: u16,
3307) -> (SplitAxis, PanePlacement, u16) {
3308 let incoming = incoming_share_bps.clamp(500, 9_500);
3309 let target_share = 10_000_u16.saturating_sub(incoming);
3310 match zone {
3311 PaneDockZone::Left => (
3312 SplitAxis::Horizontal,
3313 PanePlacement::IncomingFirst,
3314 incoming,
3315 ),
3316 PaneDockZone::Right => (
3317 SplitAxis::Horizontal,
3318 PanePlacement::ExistingFirst,
3319 target_share,
3320 ),
3321 PaneDockZone::Top => (SplitAxis::Vertical, PanePlacement::IncomingFirst, incoming),
3322 PaneDockZone::Bottom => (
3323 SplitAxis::Vertical,
3324 PanePlacement::ExistingFirst,
3325 target_share,
3326 ),
3327 PaneDockZone::Center => (SplitAxis::Horizontal, PanePlacement::ExistingFirst, 5_000),
3328 }
3329}
3330
3331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3333#[serde(tag = "op", rename_all = "snake_case")]
3334pub enum PaneOperation {
3335 SplitLeaf {
3338 target: PaneId,
3339 axis: SplitAxis,
3340 ratio: PaneSplitRatio,
3341 placement: PanePlacement,
3342 new_leaf: PaneLeaf,
3343 },
3344 CloseNode { target: PaneId },
3346 MoveSubtree {
3349 source: PaneId,
3350 target: PaneId,
3351 axis: SplitAxis,
3352 ratio: PaneSplitRatio,
3353 placement: PanePlacement,
3354 },
3355 SwapNodes { first: PaneId, second: PaneId },
3357 SetSplitRatio {
3359 split: PaneId,
3360 ratio: PaneSplitRatio,
3361 },
3362 NormalizeRatios,
3364}
3365
3366impl PaneOperation {
3367 #[must_use]
3369 pub const fn kind(&self) -> PaneOperationKind {
3370 match self {
3371 Self::SplitLeaf { .. } => PaneOperationKind::SplitLeaf,
3372 Self::CloseNode { .. } => PaneOperationKind::CloseNode,
3373 Self::MoveSubtree { .. } => PaneOperationKind::MoveSubtree,
3374 Self::SwapNodes { .. } => PaneOperationKind::SwapNodes,
3375 Self::SetSplitRatio { .. } => PaneOperationKind::SetSplitRatio,
3376 Self::NormalizeRatios => PaneOperationKind::NormalizeRatios,
3377 }
3378 }
3379
3380 #[must_use]
3381 fn referenced_nodes(&self) -> Vec<PaneId> {
3382 match self {
3383 Self::SplitLeaf { target, .. } | Self::CloseNode { target } => vec![*target],
3384 Self::MoveSubtree { source, target, .. }
3385 | Self::SwapNodes {
3386 first: source,
3387 second: target,
3388 } => {
3389 vec![*source, *target]
3390 }
3391 Self::SetSplitRatio { split, .. } => vec![*split],
3392 Self::NormalizeRatios => Vec::new(),
3393 }
3394 }
3395}
3396
3397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3399#[serde(rename_all = "snake_case")]
3400pub enum PaneOperationKind {
3401 SplitLeaf,
3402 CloseNode,
3403 MoveSubtree,
3404 SwapNodes,
3405 SetSplitRatio,
3406 NormalizeRatios,
3407}
3408
3409#[derive(Debug, Clone, PartialEq, Eq)]
3411pub struct PaneOperationOutcome {
3412 pub operation_id: u64,
3413 pub kind: PaneOperationKind,
3414 pub touched_nodes: Vec<PaneId>,
3415 pub before_hash: u64,
3416 pub after_hash: u64,
3417}
3418
3419#[derive(Debug, Clone, PartialEq, Eq)]
3421pub struct PaneOperationError {
3422 pub operation_id: u64,
3423 pub kind: PaneOperationKind,
3424 pub touched_nodes: Vec<PaneId>,
3425 pub before_hash: u64,
3426 pub after_hash: u64,
3427 pub reason: PaneOperationFailure,
3428}
3429
3430#[derive(Debug, Clone, PartialEq, Eq)]
3432pub enum PaneOperationFailure {
3433 MissingNode {
3434 node_id: PaneId,
3435 },
3436 NodeNotLeaf {
3437 node_id: PaneId,
3438 },
3439 ParentNotSplit {
3440 node_id: PaneId,
3441 },
3442 ParentChildMismatch {
3443 parent: PaneId,
3444 child: PaneId,
3445 },
3446 CannotCloseRoot {
3447 node_id: PaneId,
3448 },
3449 CannotMoveRoot {
3450 node_id: PaneId,
3451 },
3452 SameNode {
3453 first: PaneId,
3454 second: PaneId,
3455 },
3456 AncestorConflict {
3457 ancestor: PaneId,
3458 descendant: PaneId,
3459 },
3460 TargetRemovedByDetach {
3461 target: PaneId,
3462 detached_parent: PaneId,
3463 },
3464 PaneIdOverflow {
3465 current: PaneId,
3466 },
3467 InvalidRatio {
3468 node_id: PaneId,
3469 numerator: u32,
3470 denominator: u32,
3471 },
3472 Validation(PaneModelError),
3473}
3474
3475impl fmt::Display for PaneOperationFailure {
3476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3477 match self {
3478 Self::MissingNode { node_id } => write!(f, "node {} not found", node_id.0),
3479 Self::NodeNotLeaf { node_id } => write!(f, "node {} is not a leaf", node_id.0),
3480 Self::ParentNotSplit { node_id } => {
3481 write!(f, "node {} is not a split parent", node_id.0)
3482 }
3483 Self::ParentChildMismatch { parent, child } => write!(
3484 f,
3485 "split parent {} does not reference child {}",
3486 parent.0, child.0
3487 ),
3488 Self::CannotCloseRoot { node_id } => {
3489 write!(f, "cannot close root node {}", node_id.0)
3490 }
3491 Self::CannotMoveRoot { node_id } => {
3492 write!(f, "cannot move root node {}", node_id.0)
3493 }
3494 Self::SameNode { first, second } => write!(
3495 f,
3496 "operation requires distinct nodes, got {} and {}",
3497 first.0, second.0
3498 ),
3499 Self::AncestorConflict {
3500 ancestor,
3501 descendant,
3502 } => write!(
3503 f,
3504 "operation would create cycle: node {} is an ancestor of {}",
3505 ancestor.0, descendant.0
3506 ),
3507 Self::TargetRemovedByDetach {
3508 target,
3509 detached_parent,
3510 } => write!(
3511 f,
3512 "target {} would be removed while detaching parent {}",
3513 target.0, detached_parent.0
3514 ),
3515 Self::PaneIdOverflow { current } => {
3516 write!(f, "pane id overflow after {}", current.0)
3517 }
3518 Self::InvalidRatio {
3519 node_id,
3520 numerator,
3521 denominator,
3522 } => write!(
3523 f,
3524 "split node {} has invalid ratio {numerator}/{denominator}",
3525 node_id.0
3526 ),
3527 Self::Validation(err) => write!(f, "{err}"),
3528 }
3529 }
3530}
3531
3532impl std::error::Error for PaneOperationFailure {
3533 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3534 if let Self::Validation(err) = self {
3535 return Some(err);
3536 }
3537 None
3538 }
3539}
3540
3541impl fmt::Display for PaneOperationError {
3542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3543 write!(
3544 f,
3545 "pane op {} ({:?}) failed: {} [nodes={:?}, before_hash={:#x}, after_hash={:#x}]",
3546 self.operation_id,
3547 self.kind,
3548 self.reason,
3549 self.touched_nodes
3550 .iter()
3551 .map(|node_id| node_id.0)
3552 .collect::<Vec<_>>(),
3553 self.before_hash,
3554 self.after_hash
3555 )
3556 }
3557}
3558
3559impl std::error::Error for PaneOperationError {
3560 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3561 Some(&self.reason)
3562 }
3563}
3564
3565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3567pub struct PaneOperationJournalEntry {
3568 pub transaction_id: u64,
3569 pub sequence: u64,
3570 pub operation_id: u64,
3571 pub operation: PaneOperation,
3572 pub kind: PaneOperationKind,
3573 pub touched_nodes: Vec<PaneId>,
3574 pub before_hash: u64,
3575 pub after_hash: u64,
3576 pub result: PaneOperationJournalResult,
3577}
3578
3579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3581#[serde(tag = "status", rename_all = "snake_case")]
3582pub enum PaneOperationJournalResult {
3583 Applied,
3584 Rejected { reason: String },
3585}
3586
3587#[derive(Debug, Clone, PartialEq, Eq)]
3589pub struct PaneTransactionOutcome {
3590 pub transaction_id: u64,
3591 pub committed: bool,
3592 pub tree: PaneTree,
3593 pub journal: Vec<PaneOperationJournalEntry>,
3594}
3595
3596#[derive(Debug, Clone, PartialEq, Eq)]
3598pub struct PaneTransaction {
3599 transaction_id: u64,
3600 sequence: u64,
3601 base_tree: PaneTree,
3602 working_tree: PaneTree,
3603 journal: Vec<PaneOperationJournalEntry>,
3604}
3605
3606impl PaneTransaction {
3607 fn new(transaction_id: u64, base_tree: PaneTree) -> Self {
3608 Self {
3609 transaction_id,
3610 sequence: 1,
3611 base_tree: base_tree.clone(),
3612 working_tree: base_tree,
3613 journal: Vec::new(),
3614 }
3615 }
3616
3617 #[must_use]
3619 pub const fn transaction_id(&self) -> u64 {
3620 self.transaction_id
3621 }
3622
3623 #[must_use]
3625 pub fn tree(&self) -> &PaneTree {
3626 &self.working_tree
3627 }
3628
3629 #[must_use]
3631 pub fn journal(&self) -> &[PaneOperationJournalEntry] {
3632 &self.journal
3633 }
3634
3635 pub fn apply_operation(
3639 &mut self,
3640 operation_id: u64,
3641 operation: PaneOperation,
3642 ) -> Result<PaneOperationOutcome, PaneOperationError> {
3643 let operation_for_journal = operation.clone();
3644 let kind = operation_for_journal.kind();
3645 let sequence = self.next_sequence();
3646
3647 match self.working_tree.apply_operation(operation_id, operation) {
3648 Ok(outcome) => {
3649 self.journal.push(PaneOperationJournalEntry {
3650 transaction_id: self.transaction_id,
3651 sequence,
3652 operation_id,
3653 operation: operation_for_journal,
3654 kind,
3655 touched_nodes: outcome.touched_nodes.clone(),
3656 before_hash: outcome.before_hash,
3657 after_hash: outcome.after_hash,
3658 result: PaneOperationJournalResult::Applied,
3659 });
3660 Ok(outcome)
3661 }
3662 Err(err) => {
3663 self.journal.push(PaneOperationJournalEntry {
3664 transaction_id: self.transaction_id,
3665 sequence,
3666 operation_id,
3667 operation: operation_for_journal,
3668 kind,
3669 touched_nodes: err.touched_nodes.clone(),
3670 before_hash: err.before_hash,
3671 after_hash: err.after_hash,
3672 result: PaneOperationJournalResult::Rejected {
3673 reason: err.reason.to_string(),
3674 },
3675 });
3676 Err(err)
3677 }
3678 }
3679 }
3680
3681 #[must_use]
3683 pub fn commit(self) -> PaneTransactionOutcome {
3684 PaneTransactionOutcome {
3685 transaction_id: self.transaction_id,
3686 committed: true,
3687 tree: self.working_tree,
3688 journal: self.journal,
3689 }
3690 }
3691
3692 #[must_use]
3694 pub fn rollback(self) -> PaneTransactionOutcome {
3695 PaneTransactionOutcome {
3696 transaction_id: self.transaction_id,
3697 committed: false,
3698 tree: self.base_tree,
3699 journal: self.journal,
3700 }
3701 }
3702
3703 fn next_sequence(&mut self) -> u64 {
3704 let sequence = self.sequence;
3705 self.sequence = self.sequence.saturating_add(1);
3706 sequence
3707 }
3708}
3709
3710#[derive(Debug, Clone, PartialEq, Eq)]
3712pub struct PaneTree {
3713 schema_version: u16,
3714 root: PaneId,
3715 next_id: PaneId,
3716 nodes: BTreeMap<PaneId, PaneNodeRecord>,
3717 extensions: BTreeMap<String, String>,
3718}
3719
3720impl PaneTree {
3721 #[must_use]
3723 pub fn singleton(surface_key: impl Into<String>) -> Self {
3724 let root = PaneId::MIN;
3725 let mut nodes = BTreeMap::new();
3726 let _ = nodes.insert(
3727 root,
3728 PaneNodeRecord::leaf(root, None, PaneLeaf::new(surface_key)),
3729 );
3730 Self {
3731 schema_version: PANE_TREE_SCHEMA_VERSION,
3732 root,
3733 next_id: root.checked_next().unwrap_or(root),
3734 nodes,
3735 extensions: BTreeMap::new(),
3736 }
3737 }
3738
3739 pub fn from_snapshot(mut snapshot: PaneTreeSnapshot) -> Result<Self, PaneModelError> {
3741 if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
3742 return Err(PaneModelError::UnsupportedSchemaVersion {
3743 version: snapshot.schema_version,
3744 });
3745 }
3746 snapshot.canonicalize();
3747 let mut nodes = BTreeMap::new();
3748 for node in snapshot.nodes {
3749 let node_id = node.id;
3750 if nodes.insert(node_id, node).is_some() {
3751 return Err(PaneModelError::DuplicateNodeId { node_id });
3752 }
3753 }
3754 validate_tree(snapshot.root, snapshot.next_id, &nodes)?;
3755 Ok(Self {
3756 schema_version: snapshot.schema_version,
3757 root: snapshot.root,
3758 next_id: snapshot.next_id,
3759 nodes,
3760 extensions: snapshot.extensions,
3761 })
3762 }
3763
3764 #[must_use]
3766 pub fn to_snapshot(&self) -> PaneTreeSnapshot {
3767 let mut snapshot = PaneTreeSnapshot {
3768 schema_version: self.schema_version,
3769 root: self.root,
3770 next_id: self.next_id,
3771 nodes: self.nodes.values().cloned().collect(),
3772 extensions: self.extensions.clone(),
3773 };
3774 snapshot.canonicalize();
3775 snapshot
3776 }
3777
3778 #[must_use]
3780 pub const fn root(&self) -> PaneId {
3781 self.root
3782 }
3783
3784 #[must_use]
3786 pub const fn next_id(&self) -> PaneId {
3787 self.next_id
3788 }
3789
3790 #[must_use]
3792 pub const fn schema_version(&self) -> u16 {
3793 self.schema_version
3794 }
3795
3796 #[must_use]
3798 pub fn node(&self, id: PaneId) -> Option<&PaneNodeRecord> {
3799 self.nodes.get(&id)
3800 }
3801
3802 pub fn nodes(&self) -> impl Iterator<Item = &PaneNodeRecord> {
3804 self.nodes.values()
3805 }
3806
3807 pub fn validate(&self) -> Result<(), PaneModelError> {
3809 validate_tree(self.root, self.next_id, &self.nodes)
3810 }
3811
3812 #[must_use]
3814 pub fn invariant_report(&self) -> PaneInvariantReport {
3815 self.to_snapshot().invariant_report()
3816 }
3817
3818 #[must_use]
3822 pub fn state_hash(&self) -> u64 {
3823 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
3824 const PRIME: u64 = 0x0000_0001_0000_01b3;
3825
3826 fn mix(hash: &mut u64, byte: u8) {
3827 *hash ^= u64::from(byte);
3828 *hash = hash.wrapping_mul(PRIME);
3829 }
3830
3831 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
3832 for byte in bytes {
3833 mix(hash, *byte);
3834 }
3835 }
3836
3837 fn mix_u16(hash: &mut u64, value: u16) {
3838 mix_bytes(hash, &value.to_le_bytes());
3839 }
3840
3841 fn mix_u32(hash: &mut u64, value: u32) {
3842 mix_bytes(hash, &value.to_le_bytes());
3843 }
3844
3845 fn mix_u64(hash: &mut u64, value: u64) {
3846 mix_bytes(hash, &value.to_le_bytes());
3847 }
3848
3849 fn mix_bool(hash: &mut u64, value: bool) {
3850 mix(hash, u8::from(value));
3851 }
3852
3853 fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
3854 match value {
3855 Some(value) => {
3856 mix(hash, 1);
3857 mix_u16(hash, value);
3858 }
3859 None => mix(hash, 0),
3860 }
3861 }
3862
3863 fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
3864 match value {
3865 Some(value) => {
3866 mix(hash, 1);
3867 mix_u64(hash, value.get());
3868 }
3869 None => mix(hash, 0),
3870 }
3871 }
3872
3873 fn mix_str(hash: &mut u64, value: &str) {
3874 mix_u64(hash, value.len() as u64);
3875 mix_bytes(hash, value.as_bytes());
3876 }
3877
3878 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
3879 mix_u64(hash, extensions.len() as u64);
3880 for (key, value) in extensions {
3881 mix_str(hash, key);
3882 mix_str(hash, value);
3883 }
3884 }
3885
3886 fn mix_constraints(hash: &mut u64, constraints: PaneConstraints) {
3887 mix_u16(hash, constraints.min_width);
3888 mix_u16(hash, constraints.min_height);
3889 mix_opt_u16(hash, constraints.max_width);
3890 mix_opt_u16(hash, constraints.max_height);
3891 mix_bool(hash, constraints.collapsible);
3892 }
3893
3894 let mut hash = OFFSET_BASIS;
3895 mix_u16(&mut hash, self.schema_version);
3896 mix_u64(&mut hash, self.root.get());
3897 mix_u64(&mut hash, self.next_id.get());
3898 mix_extensions(&mut hash, &self.extensions);
3899 mix_u64(&mut hash, self.nodes.len() as u64);
3900
3901 for node in self.nodes.values() {
3902 mix_u64(&mut hash, node.id.get());
3903 mix_opt_pane_id(&mut hash, node.parent);
3904 mix_constraints(&mut hash, node.constraints);
3905 mix_extensions(&mut hash, &node.extensions);
3906
3907 match &node.kind {
3908 PaneNodeKind::Leaf(leaf) => {
3909 mix(&mut hash, 1);
3910 mix_str(&mut hash, &leaf.surface_key);
3911 mix_extensions(&mut hash, &leaf.extensions);
3912 }
3913 PaneNodeKind::Split(split) => {
3914 mix(&mut hash, 2);
3915 let axis_byte = match split.axis {
3916 SplitAxis::Horizontal => 1,
3917 SplitAxis::Vertical => 2,
3918 };
3919 mix(&mut hash, axis_byte);
3920 mix_u32(&mut hash, split.ratio.numerator());
3921 mix_u32(&mut hash, split.ratio.denominator());
3922 mix_u64(&mut hash, split.first.get());
3923 mix_u64(&mut hash, split.second.get());
3924 }
3925 }
3926 }
3927
3928 hash
3929 }
3930
3931 #[must_use]
3936 pub fn begin_transaction(&self, transaction_id: u64) -> PaneTransaction {
3937 PaneTransaction::new(transaction_id, self.clone())
3938 }
3939
3940 pub fn apply_operation(
3945 &mut self,
3946 operation_id: u64,
3947 operation: PaneOperation,
3948 ) -> Result<PaneOperationOutcome, PaneOperationError> {
3949 if let PaneOperation::SetSplitRatio { split, ratio } = operation {
3950 return self.apply_set_split_ratio_atomic(operation_id, split, ratio);
3951 }
3952
3953 let kind = operation.kind();
3954 let before_hash = self.state_hash();
3955 let mut working = self.clone();
3956 let mut touched = operation
3957 .referenced_nodes()
3958 .into_iter()
3959 .collect::<BTreeSet<_>>();
3960
3961 if let Err(reason) = working.apply_operation_inner(operation, &mut touched) {
3962 return Err(PaneOperationError {
3963 operation_id,
3964 kind,
3965 touched_nodes: touched.into_iter().collect(),
3966 before_hash,
3967 after_hash: working.state_hash(),
3968 reason,
3969 });
3970 }
3971
3972 if let Err(err) = working.validate_after_operation(kind, &touched) {
3973 return Err(PaneOperationError {
3974 operation_id,
3975 kind,
3976 touched_nodes: touched.into_iter().collect(),
3977 before_hash,
3978 after_hash: working.state_hash(),
3979 reason: PaneOperationFailure::Validation(err),
3980 });
3981 }
3982
3983 let after_hash = working.state_hash();
3984 *self = working;
3985
3986 Ok(PaneOperationOutcome {
3987 operation_id,
3988 kind,
3989 touched_nodes: touched.into_iter().collect(),
3990 before_hash,
3991 after_hash,
3992 })
3993 }
3994
3995 fn apply_set_split_ratio_atomic(
3996 &mut self,
3997 operation_id: u64,
3998 split_id: PaneId,
3999 ratio: PaneSplitRatio,
4000 ) -> Result<PaneOperationOutcome, PaneOperationError> {
4001 let kind = PaneOperationKind::SetSplitRatio;
4002 let before_hash = self.state_hash();
4003 let normalized =
4004 PaneSplitRatio::new(ratio.numerator(), ratio.denominator()).map_err(|_| {
4005 PaneOperationError {
4006 operation_id,
4007 kind,
4008 touched_nodes: vec![split_id],
4009 before_hash,
4010 after_hash: before_hash,
4011 reason: PaneOperationFailure::InvalidRatio {
4012 node_id: split_id,
4013 numerator: ratio.numerator(),
4014 denominator: ratio.denominator(),
4015 },
4016 }
4017 })?;
4018
4019 let previous_ratio = {
4020 let node = self.nodes.get_mut(&split_id).ok_or(PaneOperationError {
4021 operation_id,
4022 kind,
4023 touched_nodes: vec![split_id],
4024 before_hash,
4025 after_hash: before_hash,
4026 reason: PaneOperationFailure::MissingNode { node_id: split_id },
4027 })?;
4028 let PaneNodeKind::Split(split) = &mut node.kind else {
4029 return Err(PaneOperationError {
4030 operation_id,
4031 kind,
4032 touched_nodes: vec![split_id],
4033 before_hash,
4034 after_hash: before_hash,
4035 reason: PaneOperationFailure::ParentNotSplit { node_id: split_id },
4036 });
4037 };
4038 let previous_ratio = split.ratio;
4039 split.ratio = normalized;
4040 previous_ratio
4041 };
4042
4043 if let Err(err) = self.validate_after_operation(kind, &BTreeSet::from([split_id])) {
4044 let node = self.nodes.get_mut(&split_id).ok_or(PaneOperationError {
4045 operation_id,
4046 kind,
4047 touched_nodes: vec![split_id],
4048 before_hash,
4049 after_hash: before_hash,
4050 reason: PaneOperationFailure::Validation(err.clone()),
4051 })?;
4052 let PaneNodeKind::Split(split) = &mut node.kind else {
4053 return Err(PaneOperationError {
4054 operation_id,
4055 kind,
4056 touched_nodes: vec![split_id],
4057 before_hash,
4058 after_hash: before_hash,
4059 reason: PaneOperationFailure::Validation(err),
4060 });
4061 };
4062 split.ratio = previous_ratio;
4063 return Err(PaneOperationError {
4064 operation_id,
4065 kind,
4066 touched_nodes: vec![split_id],
4067 before_hash,
4068 after_hash: before_hash,
4069 reason: PaneOperationFailure::Validation(err),
4070 });
4071 }
4072
4073 let after_hash = self.state_hash();
4074 Ok(PaneOperationOutcome {
4075 operation_id,
4076 kind,
4077 touched_nodes: vec![split_id],
4078 before_hash,
4079 after_hash,
4080 })
4081 }
4082
4083 fn apply_operation_in_place_for_replay(
4084 &mut self,
4085 operation_id: u64,
4086 operation: &PaneOperation,
4087 ) -> Result<(), PaneOperationError> {
4088 let kind = operation.kind();
4089 let before_hash = self.state_hash();
4090 let touched_nodes = operation.referenced_nodes();
4091 let mut touched = touched_nodes.iter().copied().collect::<BTreeSet<_>>();
4092
4093 if let Err(reason) = self.apply_operation_inner_ref(operation, &mut touched) {
4094 return Err(PaneOperationError {
4095 operation_id,
4096 kind,
4097 touched_nodes,
4098 before_hash,
4099 after_hash: self.state_hash(),
4100 reason,
4101 });
4102 }
4103
4104 if let Err(err) = self.validate_after_operation(kind, &touched) {
4105 return Err(PaneOperationError {
4106 operation_id,
4107 kind,
4108 touched_nodes,
4109 before_hash,
4110 after_hash: self.state_hash(),
4111 reason: PaneOperationFailure::Validation(err),
4112 });
4113 }
4114
4115 Ok(())
4116 }
4117
4118 fn validate_after_operation(
4119 &self,
4120 kind: PaneOperationKind,
4121 touched: &BTreeSet<PaneId>,
4122 ) -> Result<(), PaneModelError> {
4123 match self.validation_strategy_for_operation(kind) {
4124 PaneValidationStrategy::FullTree => self.validate(),
4125 PaneValidationStrategy::LocalClosure => self.validate_local_closure(touched),
4126 }
4127 }
4128
4129 const fn validation_strategy_for_operation(
4130 &self,
4131 kind: PaneOperationKind,
4132 ) -> PaneValidationStrategy {
4133 match kind {
4134 PaneOperationKind::SetSplitRatio => PaneValidationStrategy::LocalClosure,
4135 PaneOperationKind::SplitLeaf
4136 | PaneOperationKind::CloseNode
4137 | PaneOperationKind::MoveSubtree
4138 | PaneOperationKind::SwapNodes
4139 | PaneOperationKind::NormalizeRatios => PaneValidationStrategy::FullTree,
4140 }
4141 }
4142
4143 fn validate_local_closure(&self, touched: &BTreeSet<PaneId>) -> Result<(), PaneModelError> {
4144 for node_id in touched {
4145 let node = self
4146 .nodes
4147 .get(node_id)
4148 .ok_or(PaneModelError::MissingRoot { root: *node_id })?;
4149 node.constraints.validate(*node_id)?;
4150
4151 if *node_id == self.root {
4152 if let Some(parent) = node.parent {
4153 return Err(PaneModelError::RootHasParent {
4154 root: self.root,
4155 parent,
4156 });
4157 }
4158 } else if let Some(parent_id) = node.parent {
4159 let parent = self
4160 .nodes
4161 .get(&parent_id)
4162 .ok_or(PaneModelError::MissingParent {
4163 node_id: *node_id,
4164 parent: parent_id,
4165 })?;
4166 let PaneNodeKind::Split(split) = &parent.kind else {
4167 return Err(PaneModelError::ParentMismatch {
4168 node_id: *node_id,
4169 expected: Some(parent_id),
4170 actual: node.parent,
4171 });
4172 };
4173 if split.first != *node_id && split.second != *node_id {
4174 return Err(PaneModelError::ParentMismatch {
4175 node_id: *node_id,
4176 expected: Some(parent_id),
4177 actual: node.parent,
4178 });
4179 }
4180 }
4181
4182 if let PaneNodeKind::Split(split) = &node.kind {
4183 if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
4184 return Err(PaneModelError::InvalidSplitRatio {
4185 numerator: split.ratio.numerator(),
4186 denominator: split.ratio.denominator(),
4187 });
4188 }
4189 if split.first == *node_id || split.second == *node_id {
4190 return Err(PaneModelError::SelfReferentialSplit { node_id: *node_id });
4191 }
4192 if split.first == split.second {
4193 return Err(PaneModelError::DuplicateSplitChildren {
4194 node_id: *node_id,
4195 child: split.first,
4196 });
4197 }
4198 for child_id in [split.first, split.second] {
4199 let child = self
4200 .nodes
4201 .get(&child_id)
4202 .ok_or(PaneModelError::MissingChild {
4203 parent: *node_id,
4204 child: child_id,
4205 })?;
4206 if child.parent != Some(*node_id) {
4207 return Err(PaneModelError::ParentMismatch {
4208 node_id: child_id,
4209 expected: Some(*node_id),
4210 actual: child.parent,
4211 });
4212 }
4213 }
4214 }
4215 }
4216
4217 Ok(())
4218 }
4219
4220 fn apply_operation_inner(
4221 &mut self,
4222 operation: PaneOperation,
4223 touched: &mut BTreeSet<PaneId>,
4224 ) -> Result<(), PaneOperationFailure> {
4225 match operation {
4226 PaneOperation::SplitLeaf {
4227 target,
4228 axis,
4229 ratio,
4230 placement,
4231 new_leaf,
4232 } => self.apply_split_leaf(target, axis, ratio, placement, new_leaf, touched),
4233 PaneOperation::CloseNode { target } => self.apply_close_node(target, touched),
4234 PaneOperation::MoveSubtree {
4235 source,
4236 target,
4237 axis,
4238 ratio,
4239 placement,
4240 } => self.apply_move_subtree(source, target, axis, ratio, placement, touched),
4241 PaneOperation::SwapNodes { first, second } => {
4242 self.apply_swap_nodes(first, second, touched)
4243 }
4244 PaneOperation::SetSplitRatio { split, ratio } => {
4245 self.apply_set_split_ratio(split, ratio, touched)
4246 }
4247 PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
4248 }
4249 }
4250
4251 fn apply_operation_inner_ref(
4252 &mut self,
4253 operation: &PaneOperation,
4254 touched: &mut BTreeSet<PaneId>,
4255 ) -> Result<(), PaneOperationFailure> {
4256 match operation {
4257 PaneOperation::SplitLeaf {
4258 target,
4259 axis,
4260 ratio,
4261 placement,
4262 new_leaf,
4263 } => self.apply_split_leaf(
4264 *target,
4265 *axis,
4266 *ratio,
4267 *placement,
4268 new_leaf.clone(),
4269 touched,
4270 ),
4271 PaneOperation::CloseNode { target } => self.apply_close_node(*target, touched),
4272 PaneOperation::MoveSubtree {
4273 source,
4274 target,
4275 axis,
4276 ratio,
4277 placement,
4278 } => self.apply_move_subtree(*source, *target, *axis, *ratio, *placement, touched),
4279 PaneOperation::SwapNodes { first, second } => {
4280 self.apply_swap_nodes(*first, *second, touched)
4281 }
4282 PaneOperation::SetSplitRatio { split, ratio } => {
4283 self.apply_set_split_ratio(*split, *ratio, touched)
4284 }
4285 PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
4286 }
4287 }
4288
4289 fn apply_split_leaf(
4290 &mut self,
4291 target: PaneId,
4292 axis: SplitAxis,
4293 ratio: PaneSplitRatio,
4294 placement: PanePlacement,
4295 new_leaf: PaneLeaf,
4296 touched: &mut BTreeSet<PaneId>,
4297 ) -> Result<(), PaneOperationFailure> {
4298 let target_parent = match self.nodes.get(&target) {
4299 Some(PaneNodeRecord {
4300 parent,
4301 kind: PaneNodeKind::Leaf(_),
4302 ..
4303 }) => *parent,
4304 Some(_) => {
4305 return Err(PaneOperationFailure::NodeNotLeaf { node_id: target });
4306 }
4307 None => {
4308 return Err(PaneOperationFailure::MissingNode { node_id: target });
4309 }
4310 };
4311
4312 let split_id = self.allocate_node_id()?;
4313 let new_leaf_id = self.allocate_node_id()?;
4314 touched.extend([target, split_id, new_leaf_id]);
4315 if let Some(parent_id) = target_parent {
4316 let _ = touched.insert(parent_id);
4317 }
4318
4319 let (first, second) = placement.ordered(target, new_leaf_id);
4320 let split_record = PaneNodeRecord::split(
4321 split_id,
4322 target_parent,
4323 PaneSplit {
4324 axis,
4325 ratio,
4326 first,
4327 second,
4328 },
4329 );
4330
4331 if let Some(target_node) = self.nodes.get_mut(&target) {
4332 target_node.parent = Some(split_id);
4333 }
4334 let _ = self.nodes.insert(
4335 new_leaf_id,
4336 PaneNodeRecord::leaf(new_leaf_id, Some(split_id), new_leaf),
4337 );
4338 let _ = self.nodes.insert(split_id, split_record);
4339
4340 if let Some(parent_id) = target_parent {
4341 self.replace_child(parent_id, target, split_id)?;
4342 } else {
4343 self.root = split_id;
4344 }
4345
4346 Ok(())
4347 }
4348
4349 fn apply_close_node(
4350 &mut self,
4351 target: PaneId,
4352 touched: &mut BTreeSet<PaneId>,
4353 ) -> Result<(), PaneOperationFailure> {
4354 if !self.nodes.contains_key(&target) {
4355 return Err(PaneOperationFailure::MissingNode { node_id: target });
4356 }
4357 if target == self.root {
4358 return Err(PaneOperationFailure::CannotCloseRoot { node_id: target });
4359 }
4360
4361 let subtree_ids = self.collect_subtree_ids(target)?;
4362 for node_id in &subtree_ids {
4363 let _ = touched.insert(*node_id);
4364 }
4365
4366 let (parent_id, sibling_id, grandparent_id) =
4367 self.promote_sibling_after_detach(target, touched)?;
4368 let _ = touched.insert(parent_id);
4369 let _ = touched.insert(sibling_id);
4370 if let Some(grandparent_id) = grandparent_id {
4371 let _ = touched.insert(grandparent_id);
4372 }
4373
4374 for node_id in subtree_ids {
4375 let _ = self.nodes.remove(&node_id);
4376 }
4377
4378 Ok(())
4379 }
4380
4381 fn apply_move_subtree(
4382 &mut self,
4383 source: PaneId,
4384 target: PaneId,
4385 axis: SplitAxis,
4386 ratio: PaneSplitRatio,
4387 placement: PanePlacement,
4388 touched: &mut BTreeSet<PaneId>,
4389 ) -> Result<(), PaneOperationFailure> {
4390 if source == target {
4391 return Err(PaneOperationFailure::SameNode {
4392 first: source,
4393 second: target,
4394 });
4395 }
4396
4397 if !self.nodes.contains_key(&source) {
4398 return Err(PaneOperationFailure::MissingNode { node_id: source });
4399 }
4400 if !self.nodes.contains_key(&target) {
4401 return Err(PaneOperationFailure::MissingNode { node_id: target });
4402 }
4403
4404 if source == self.root {
4405 return Err(PaneOperationFailure::CannotMoveRoot { node_id: source });
4406 }
4407 if self.is_ancestor(source, target)? {
4408 return Err(PaneOperationFailure::AncestorConflict {
4409 ancestor: source,
4410 descendant: target,
4411 });
4412 }
4413
4414 let source_parent = self
4415 .nodes
4416 .get(&source)
4417 .and_then(|node| node.parent)
4418 .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: source })?;
4419 if source_parent == target {
4420 return Err(PaneOperationFailure::TargetRemovedByDetach {
4421 target,
4422 detached_parent: source_parent,
4423 });
4424 }
4425
4426 let _ = touched.insert(source);
4427 let _ = touched.insert(target);
4428 let _ = touched.insert(source_parent);
4429
4430 let (removed_parent, sibling_id, grandparent_id) =
4431 self.promote_sibling_after_detach(source, touched)?;
4432 let _ = touched.insert(removed_parent);
4433 let _ = touched.insert(sibling_id);
4434 if let Some(grandparent_id) = grandparent_id {
4435 let _ = touched.insert(grandparent_id);
4436 }
4437
4438 if let Some(source_node) = self.nodes.get_mut(&source) {
4439 source_node.parent = None;
4440 }
4441
4442 if !self.nodes.contains_key(&target) {
4443 return Err(PaneOperationFailure::MissingNode { node_id: target });
4444 }
4445 let target_parent = self.nodes.get(&target).and_then(|node| node.parent);
4446 if let Some(parent_id) = target_parent {
4447 let _ = touched.insert(parent_id);
4448 }
4449
4450 let split_id = self.allocate_node_id()?;
4451 let _ = touched.insert(split_id);
4452 let (first, second) = placement.ordered(target, source);
4453
4454 if let Some(target_node) = self.nodes.get_mut(&target) {
4455 target_node.parent = Some(split_id);
4456 }
4457 if let Some(source_node) = self.nodes.get_mut(&source) {
4458 source_node.parent = Some(split_id);
4459 }
4460
4461 let _ = self.nodes.insert(
4462 split_id,
4463 PaneNodeRecord::split(
4464 split_id,
4465 target_parent,
4466 PaneSplit {
4467 axis,
4468 ratio,
4469 first,
4470 second,
4471 },
4472 ),
4473 );
4474
4475 if let Some(parent_id) = target_parent {
4476 self.replace_child(parent_id, target, split_id)?;
4477 } else {
4478 self.root = split_id;
4479 }
4480
4481 Ok(())
4482 }
4483
4484 fn apply_swap_nodes(
4485 &mut self,
4486 first: PaneId,
4487 second: PaneId,
4488 touched: &mut BTreeSet<PaneId>,
4489 ) -> Result<(), PaneOperationFailure> {
4490 if first == second {
4491 return Ok(());
4492 }
4493
4494 if !self.nodes.contains_key(&first) {
4495 return Err(PaneOperationFailure::MissingNode { node_id: first });
4496 }
4497 if !self.nodes.contains_key(&second) {
4498 return Err(PaneOperationFailure::MissingNode { node_id: second });
4499 }
4500 if self.is_ancestor(first, second)? {
4501 return Err(PaneOperationFailure::AncestorConflict {
4502 ancestor: first,
4503 descendant: second,
4504 });
4505 }
4506 if self.is_ancestor(second, first)? {
4507 return Err(PaneOperationFailure::AncestorConflict {
4508 ancestor: second,
4509 descendant: first,
4510 });
4511 }
4512
4513 let _ = touched.insert(first);
4514 let _ = touched.insert(second);
4515
4516 let first_parent = self.nodes.get(&first).and_then(|node| node.parent);
4517 let second_parent = self.nodes.get(&second).and_then(|node| node.parent);
4518
4519 if first_parent == second_parent {
4520 if let Some(parent_id) = first_parent {
4521 let _ = touched.insert(parent_id);
4522 self.swap_children(parent_id, first, second)?;
4523 }
4524 return Ok(());
4525 }
4526
4527 match (first_parent, second_parent) {
4528 (Some(left_parent), Some(right_parent)) => {
4529 let _ = touched.insert(left_parent);
4530 let _ = touched.insert(right_parent);
4531 self.replace_child(left_parent, first, second)?;
4532 self.replace_child(right_parent, second, first)?;
4533 if let Some(left) = self.nodes.get_mut(&first) {
4534 left.parent = Some(right_parent);
4535 }
4536 if let Some(right) = self.nodes.get_mut(&second) {
4537 right.parent = Some(left_parent);
4538 }
4539 }
4540 (None, Some(parent_id)) => {
4541 let _ = touched.insert(parent_id);
4542 self.replace_child(parent_id, second, first)?;
4543 if let Some(first_node) = self.nodes.get_mut(&first) {
4544 first_node.parent = Some(parent_id);
4545 }
4546 if let Some(second_node) = self.nodes.get_mut(&second) {
4547 second_node.parent = None;
4548 }
4549 self.root = second;
4550 }
4551 (Some(parent_id), None) => {
4552 let _ = touched.insert(parent_id);
4553 self.replace_child(parent_id, first, second)?;
4554 if let Some(first_node) = self.nodes.get_mut(&first) {
4555 first_node.parent = None;
4556 }
4557 if let Some(second_node) = self.nodes.get_mut(&second) {
4558 second_node.parent = Some(parent_id);
4559 }
4560 self.root = first;
4561 }
4562 (None, None) => {}
4563 }
4564
4565 Ok(())
4566 }
4567
4568 fn apply_normalize_ratios(
4569 &mut self,
4570 touched: &mut BTreeSet<PaneId>,
4571 ) -> Result<(), PaneOperationFailure> {
4572 for node in self.nodes.values_mut() {
4573 if let PaneNodeKind::Split(split) = &mut node.kind {
4574 let normalized =
4575 PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator())
4576 .map_err(|_| PaneOperationFailure::InvalidRatio {
4577 node_id: node.id,
4578 numerator: split.ratio.numerator(),
4579 denominator: split.ratio.denominator(),
4580 })?;
4581 split.ratio = normalized;
4582 let _ = touched.insert(node.id);
4583 }
4584 }
4585 Ok(())
4586 }
4587
4588 fn apply_set_split_ratio(
4589 &mut self,
4590 split_id: PaneId,
4591 ratio: PaneSplitRatio,
4592 touched: &mut BTreeSet<PaneId>,
4593 ) -> Result<(), PaneOperationFailure> {
4594 let node = self
4595 .nodes
4596 .get_mut(&split_id)
4597 .ok_or(PaneOperationFailure::MissingNode { node_id: split_id })?;
4598 let PaneNodeKind::Split(split) = &mut node.kind else {
4599 return Err(PaneOperationFailure::ParentNotSplit { node_id: split_id });
4600 };
4601 split.ratio =
4602 PaneSplitRatio::new(ratio.numerator(), ratio.denominator()).map_err(|_| {
4603 PaneOperationFailure::InvalidRatio {
4604 node_id: split_id,
4605 numerator: ratio.numerator(),
4606 denominator: ratio.denominator(),
4607 }
4608 })?;
4609 let _ = touched.insert(split_id);
4610 Ok(())
4611 }
4612
4613 fn replace_child(
4614 &mut self,
4615 parent_id: PaneId,
4616 old_child: PaneId,
4617 new_child: PaneId,
4618 ) -> Result<(), PaneOperationFailure> {
4619 let parent = self
4620 .nodes
4621 .get_mut(&parent_id)
4622 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
4623 let PaneNodeKind::Split(split) = &mut parent.kind else {
4624 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
4625 };
4626
4627 if split.first == old_child {
4628 split.first = new_child;
4629 return Ok(());
4630 }
4631 if split.second == old_child {
4632 split.second = new_child;
4633 return Ok(());
4634 }
4635
4636 Err(PaneOperationFailure::ParentChildMismatch {
4637 parent: parent_id,
4638 child: old_child,
4639 })
4640 }
4641
4642 fn swap_children(
4643 &mut self,
4644 parent_id: PaneId,
4645 left: PaneId,
4646 right: PaneId,
4647 ) -> Result<(), PaneOperationFailure> {
4648 let parent = self
4649 .nodes
4650 .get_mut(&parent_id)
4651 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
4652 let PaneNodeKind::Split(split) = &mut parent.kind else {
4653 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
4654 };
4655
4656 let has_pair = (split.first == left && split.second == right)
4657 || (split.first == right && split.second == left);
4658 if !has_pair {
4659 return Err(PaneOperationFailure::ParentChildMismatch {
4660 parent: parent_id,
4661 child: left,
4662 });
4663 }
4664
4665 std::mem::swap(&mut split.first, &mut split.second);
4666 Ok(())
4667 }
4668
4669 fn promote_sibling_after_detach(
4670 &mut self,
4671 detached: PaneId,
4672 touched: &mut BTreeSet<PaneId>,
4673 ) -> Result<(PaneId, PaneId, Option<PaneId>), PaneOperationFailure> {
4674 let parent_id = self
4675 .nodes
4676 .get(&detached)
4677 .ok_or(PaneOperationFailure::MissingNode { node_id: detached })?
4678 .parent
4679 .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: detached })?;
4680 let parent_node = self
4681 .nodes
4682 .get(&parent_id)
4683 .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
4684 let PaneNodeKind::Split(parent_split) = &parent_node.kind else {
4685 return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
4686 };
4687
4688 let sibling_id = if parent_split.first == detached {
4689 parent_split.second
4690 } else if parent_split.second == detached {
4691 parent_split.first
4692 } else {
4693 return Err(PaneOperationFailure::ParentChildMismatch {
4694 parent: parent_id,
4695 child: detached,
4696 });
4697 };
4698
4699 let grandparent_id = parent_node.parent;
4700 let _ = touched.insert(parent_id);
4701 let _ = touched.insert(sibling_id);
4702 if let Some(grandparent_id) = grandparent_id {
4703 let _ = touched.insert(grandparent_id);
4704 self.replace_child(grandparent_id, parent_id, sibling_id)?;
4705 } else {
4706 self.root = sibling_id;
4707 }
4708
4709 let sibling_node =
4710 self.nodes
4711 .get_mut(&sibling_id)
4712 .ok_or(PaneOperationFailure::MissingNode {
4713 node_id: sibling_id,
4714 })?;
4715 sibling_node.parent = grandparent_id;
4716 let _ = self.nodes.remove(&parent_id);
4717
4718 Ok((parent_id, sibling_id, grandparent_id))
4719 }
4720
4721 fn is_ancestor(
4722 &self,
4723 ancestor: PaneId,
4724 mut node_id: PaneId,
4725 ) -> Result<bool, PaneOperationFailure> {
4726 loop {
4727 let node = self
4728 .nodes
4729 .get(&node_id)
4730 .ok_or(PaneOperationFailure::MissingNode { node_id })?;
4731 let Some(parent_id) = node.parent else {
4732 return Ok(false);
4733 };
4734 if parent_id == ancestor {
4735 return Ok(true);
4736 }
4737 node_id = parent_id;
4738 }
4739 }
4740
4741 fn collect_subtree_ids(&self, root_id: PaneId) -> Result<Vec<PaneId>, PaneOperationFailure> {
4742 if !self.nodes.contains_key(&root_id) {
4743 return Err(PaneOperationFailure::MissingNode { node_id: root_id });
4744 }
4745
4746 let mut out = Vec::new();
4747 let mut stack = vec![root_id];
4748 while let Some(node_id) = stack.pop() {
4749 let node = self
4750 .nodes
4751 .get(&node_id)
4752 .ok_or(PaneOperationFailure::MissingNode { node_id })?;
4753 out.push(node_id);
4754 if let PaneNodeKind::Split(split) = &node.kind {
4755 stack.push(split.first);
4756 stack.push(split.second);
4757 }
4758 }
4759 Ok(out)
4760 }
4761
4762 fn allocate_node_id(&mut self) -> Result<PaneId, PaneOperationFailure> {
4763 let current = self.next_id;
4764 self.next_id = self
4765 .next_id
4766 .checked_next()
4767 .map_err(|_| PaneOperationFailure::PaneIdOverflow { current })?;
4768 Ok(current)
4769 }
4770
4771 pub fn solve_layout(&self, area: Rect) -> Result<PaneLayout, PaneModelError> {
4782 let mut rects = BTreeMap::new();
4783 self.solve_node(self.root, area, &mut rects)?;
4784 Ok(PaneLayout { area, rects })
4785 }
4786
4787 fn solve_node(
4788 &self,
4789 node_id: PaneId,
4790 area: Rect,
4791 rects: &mut BTreeMap<PaneId, Rect>,
4792 ) -> Result<(), PaneModelError> {
4793 let Some(node) = self.nodes.get(&node_id) else {
4794 return Err(PaneModelError::MissingRoot { root: node_id });
4795 };
4796
4797 validate_area_against_constraints(node_id, area, node.constraints)?;
4798 let _ = rects.insert(node_id, area);
4799
4800 let PaneNodeKind::Split(split) = &node.kind else {
4801 return Ok(());
4802 };
4803
4804 let first_node = self
4805 .nodes
4806 .get(&split.first)
4807 .ok_or(PaneModelError::MissingChild {
4808 parent: node_id,
4809 child: split.first,
4810 })?;
4811 let second_node = self
4812 .nodes
4813 .get(&split.second)
4814 .ok_or(PaneModelError::MissingChild {
4815 parent: node_id,
4816 child: split.second,
4817 })?;
4818
4819 let (first_bounds, second_bounds, available) = match split.axis {
4820 SplitAxis::Horizontal => (
4821 axis_bounds(first_node.constraints, split.axis),
4822 axis_bounds(second_node.constraints, split.axis),
4823 area.width,
4824 ),
4825 SplitAxis::Vertical => (
4826 axis_bounds(first_node.constraints, split.axis),
4827 axis_bounds(second_node.constraints, split.axis),
4828 area.height,
4829 ),
4830 };
4831
4832 let (first_size, second_size) = solve_split_sizes(
4833 node_id,
4834 split.axis,
4835 available,
4836 split.ratio,
4837 first_bounds,
4838 second_bounds,
4839 )?;
4840
4841 let (first_rect, second_rect) = match split.axis {
4842 SplitAxis::Horizontal => (
4843 Rect::new(area.x, area.y, first_size, area.height),
4844 Rect::new(
4845 area.x.saturating_add(first_size),
4846 area.y,
4847 second_size,
4848 area.height,
4849 ),
4850 ),
4851 SplitAxis::Vertical => (
4852 Rect::new(area.x, area.y, area.width, first_size),
4853 Rect::new(
4854 area.x,
4855 area.y.saturating_add(first_size),
4856 area.width,
4857 second_size,
4858 ),
4859 ),
4860 };
4861
4862 self.solve_node(split.first, first_rect, rects)?;
4863 self.solve_node(split.second, second_rect, rects)?;
4864 Ok(())
4865 }
4866
4867 #[must_use]
4869 pub fn choose_dock_preview(
4870 &self,
4871 layout: &PaneLayout,
4872 pointer: PanePointerPosition,
4873 magnetic_field_cells: f64,
4874 ) -> Option<PaneDockPreview> {
4875 self.choose_dock_preview_excluding(layout, pointer, magnetic_field_cells, None)
4876 }
4877
4878 #[must_use]
4881 pub fn ranked_dock_previews_with_motion(
4882 &self,
4883 layout: &PaneLayout,
4884 pointer: PanePointerPosition,
4885 motion: PaneMotionVector,
4886 magnetic_field_cells: f64,
4887 excluded: Option<PaneId>,
4888 limit: usize,
4889 ) -> Vec<PaneDockPreview> {
4890 self.collect_dock_previews_excluding_with_motion(
4891 layout,
4892 pointer,
4893 magnetic_field_cells,
4894 excluded,
4895 motion,
4896 limit,
4897 )
4898 }
4899
4900 pub fn plan_reflow_move_with_preview(
4903 &self,
4904 source: PaneId,
4905 layout: &PaneLayout,
4906 pointer: PanePointerPosition,
4907 motion: PaneMotionVector,
4908 inertial: Option<PaneInertialThrow>,
4909 magnetic_field_cells: f64,
4910 ) -> Result<PaneReflowMovePlan, PaneReflowPlanError> {
4911 if !self.nodes.contains_key(&source) {
4912 return Err(PaneReflowPlanError::MissingSource { source });
4913 }
4914 if source == self.root {
4915 return Err(PaneReflowPlanError::SourceCannotMoveRoot { source });
4916 }
4917
4918 let projected = inertial
4919 .map(|profile| profile.projected_pointer(pointer))
4920 .unwrap_or(pointer);
4921 let preview = self
4922 .choose_dock_preview_excluding_with_motion(
4923 layout,
4924 projected,
4925 magnetic_field_cells,
4926 Some(source),
4927 motion,
4928 )
4929 .ok_or(PaneReflowPlanError::NoDockTarget)?;
4930
4931 let snap_profile = PanePressureSnapProfile::from_motion(motion);
4932 let magnetic_boost = (preview.score * 1_800.0).round().clamp(0.0, 1_800.0) as u16;
4933 let incoming_share_bps = snap_profile
4934 .strength_bps
4935 .saturating_sub(2_200)
4936 .saturating_add(magnetic_boost / 2)
4937 .clamp(2_400, 7_800);
4938
4939 let operations = if preview.zone == PaneDockZone::Center {
4940 vec![PaneOperation::SwapNodes {
4941 first: source,
4942 second: preview.target,
4943 }]
4944 } else {
4945 let (axis, placement, target_first_share) =
4946 zone_to_axis_placement_and_target_share(preview.zone, incoming_share_bps);
4947 let ratio = PaneSplitRatio::new(
4948 u32::from(target_first_share.max(1)),
4949 u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
4950 )
4951 .map_err(|_| PaneReflowPlanError::InvalidRatio {
4952 numerator: u32::from(target_first_share.max(1)),
4953 denominator: u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
4954 })?;
4955 vec![PaneOperation::MoveSubtree {
4956 source,
4957 target: preview.target,
4958 axis,
4959 ratio,
4960 placement,
4961 }]
4962 };
4963
4964 Ok(PaneReflowMovePlan {
4965 source,
4966 pointer,
4967 projected_pointer: projected,
4968 preview,
4969 snap_profile,
4970 operations,
4971 })
4972 }
4973
4974 pub fn apply_reflow_move_plan(
4976 &mut self,
4977 operation_seed: u64,
4978 plan: &PaneReflowMovePlan,
4979 ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
4980 let mut outcomes = Vec::with_capacity(plan.operations.len());
4981 for (index, operation) in plan.operations.iter().cloned().enumerate() {
4982 let outcome =
4983 self.apply_operation(operation_seed.saturating_add(index as u64), operation)?;
4984 outcomes.push(outcome);
4985 }
4986 Ok(outcomes)
4987 }
4988
4989 pub fn plan_edge_resize(
4991 &self,
4992 leaf: PaneId,
4993 layout: &PaneLayout,
4994 grip: PaneResizeGrip,
4995 pointer: PanePointerPosition,
4996 pressure: PanePressureSnapProfile,
4997 ) -> Result<PaneEdgeResizePlan, PaneEdgeResizePlanError> {
4998 let node = self
4999 .nodes
5000 .get(&leaf)
5001 .ok_or(PaneEdgeResizePlanError::MissingLeaf { leaf })?;
5002 if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
5003 return Err(PaneEdgeResizePlanError::NodeNotLeaf { node: leaf });
5004 }
5005
5006 let tuned_snap = pressure.apply_to_tuning(PaneSnapTuning::default());
5007 let mut operations = Vec::with_capacity(2);
5008
5009 if let Some(_toward_max) = grip.horizontal_edge() {
5010 let split_id = self
5011 .nearest_axis_split_for_node(leaf, SplitAxis::Horizontal)
5012 .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
5013 leaf,
5014 axis: SplitAxis::Horizontal,
5015 })?;
5016 let split_rect = layout
5017 .rect(split_id)
5018 .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
5019 let share = axis_share_from_pointer(
5020 split_rect,
5021 pointer,
5022 SplitAxis::Horizontal,
5023 PANE_EDGE_GRIP_INSET_CELLS,
5024 );
5025 let raw_bps = elastic_ratio_bps(
5026 (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
5027 pressure,
5028 );
5029 let snapped = tuned_snap
5030 .decide(raw_bps, None)
5031 .snapped_ratio_bps
5032 .unwrap_or(raw_bps);
5033 let ratio = PaneSplitRatio::new(
5034 u32::from(snapped.max(1)),
5035 u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5036 )
5037 .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
5038 numerator: u32::from(snapped.max(1)),
5039 denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5040 })?;
5041 operations.push(PaneOperation::SetSplitRatio {
5042 split: split_id,
5043 ratio,
5044 });
5045 }
5046
5047 if let Some(_toward_max) = grip.vertical_edge() {
5048 let split_id = self
5049 .nearest_axis_split_for_node(leaf, SplitAxis::Vertical)
5050 .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
5051 leaf,
5052 axis: SplitAxis::Vertical,
5053 })?;
5054 let split_rect = layout
5055 .rect(split_id)
5056 .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
5057 let share = axis_share_from_pointer(
5058 split_rect,
5059 pointer,
5060 SplitAxis::Vertical,
5061 PANE_EDGE_GRIP_INSET_CELLS,
5062 );
5063 let raw_bps = elastic_ratio_bps(
5064 (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
5065 pressure,
5066 );
5067 let snapped = tuned_snap
5068 .decide(raw_bps, None)
5069 .snapped_ratio_bps
5070 .unwrap_or(raw_bps);
5071 let ratio = PaneSplitRatio::new(
5072 u32::from(snapped.max(1)),
5073 u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5074 )
5075 .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
5076 numerator: u32::from(snapped.max(1)),
5077 denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5078 })?;
5079 operations.push(PaneOperation::SetSplitRatio {
5080 split: split_id,
5081 ratio,
5082 });
5083 }
5084
5085 Ok(PaneEdgeResizePlan {
5086 leaf,
5087 grip,
5088 operations,
5089 })
5090 }
5091
5092 pub fn apply_edge_resize_plan(
5094 &mut self,
5095 operation_seed: u64,
5096 plan: &PaneEdgeResizePlan,
5097 ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
5098 let mut outcomes = Vec::with_capacity(plan.operations.len());
5099 for (index, operation) in plan.operations.iter().cloned().enumerate() {
5100 outcomes.push(
5101 self.apply_operation(operation_seed.saturating_add(index as u64), operation)?,
5102 );
5103 }
5104 Ok(outcomes)
5105 }
5106
5107 pub fn plan_group_move(
5109 &self,
5110 selection: &PaneSelectionState,
5111 layout: &PaneLayout,
5112 pointer: PanePointerPosition,
5113 motion: PaneMotionVector,
5114 inertial: Option<PaneInertialThrow>,
5115 magnetic_field_cells: f64,
5116 ) -> Result<PaneGroupTransformPlan, PaneReflowPlanError> {
5117 if selection.is_empty() {
5118 return Ok(PaneGroupTransformPlan {
5119 members: Vec::new(),
5120 operations: Vec::new(),
5121 });
5122 }
5123 let members = selection.as_sorted_vec();
5124 let anchor = selection.anchor.unwrap_or(members[0]);
5125 let reflow = self.plan_reflow_move_with_preview(
5126 anchor,
5127 layout,
5128 pointer,
5129 motion,
5130 inertial,
5131 magnetic_field_cells,
5132 )?;
5133 let mut operations = reflow.operations.clone();
5134 if members.len() > 1 {
5135 let (axis, placement, target_first_share) =
5136 zone_to_axis_placement_and_target_share(reflow.preview.zone, 5_000);
5137 let ratio = PaneSplitRatio::new(
5138 u32::from(target_first_share.max(1)),
5139 u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
5140 )
5141 .map_err(|_| PaneReflowPlanError::InvalidRatio {
5142 numerator: u32::from(target_first_share.max(1)),
5143 denominator: u32::from(10_000_u16.saturating_sub(target_first_share).max(1)),
5144 })?;
5145 for member in members.iter().copied().filter(|member| *member != anchor) {
5146 operations.push(PaneOperation::MoveSubtree {
5147 source: member,
5148 target: anchor,
5149 axis,
5150 ratio,
5151 placement,
5152 });
5153 }
5154 }
5155 Ok(PaneGroupTransformPlan {
5156 members,
5157 operations,
5158 })
5159 }
5160
5161 pub fn plan_group_resize(
5164 &self,
5165 selection: &PaneSelectionState,
5166 layout: &PaneLayout,
5167 grip: PaneResizeGrip,
5168 pointer: PanePointerPosition,
5169 pressure: PanePressureSnapProfile,
5170 ) -> Result<PaneGroupTransformPlan, PaneEdgeResizePlanError> {
5171 if selection.is_empty() {
5172 return Ok(PaneGroupTransformPlan {
5173 members: Vec::new(),
5174 operations: Vec::new(),
5175 });
5176 }
5177 let members = selection.as_sorted_vec();
5178 let cluster_root = self
5179 .lowest_common_ancestor(&members)
5180 .unwrap_or_else(|| selection.anchor.unwrap_or(members[0]));
5181 let proxy_leaf = selection.anchor.unwrap_or(members[0]);
5182
5183 let tuned_snap = pressure.apply_to_tuning(PaneSnapTuning::default());
5184 let mut operations = Vec::with_capacity(2);
5185
5186 if grip.horizontal_edge().is_some() {
5187 let split_id = self
5188 .nearest_axis_split_for_node(cluster_root, SplitAxis::Horizontal)
5189 .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
5190 leaf: proxy_leaf,
5191 axis: SplitAxis::Horizontal,
5192 })?;
5193 let split_rect = layout
5194 .rect(split_id)
5195 .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
5196 let share = axis_share_from_pointer(
5197 split_rect,
5198 pointer,
5199 SplitAxis::Horizontal,
5200 PANE_EDGE_GRIP_INSET_CELLS,
5201 );
5202 let raw_bps = elastic_ratio_bps(
5203 (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
5204 pressure,
5205 );
5206 let snapped = tuned_snap
5207 .decide(raw_bps, None)
5208 .snapped_ratio_bps
5209 .unwrap_or(raw_bps);
5210 let ratio = PaneSplitRatio::new(
5211 u32::from(snapped.max(1)),
5212 u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5213 )
5214 .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
5215 numerator: u32::from(snapped.max(1)),
5216 denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5217 })?;
5218 operations.push(PaneOperation::SetSplitRatio {
5219 split: split_id,
5220 ratio,
5221 });
5222 }
5223
5224 if grip.vertical_edge().is_some() {
5225 let split_id = self
5226 .nearest_axis_split_for_node(cluster_root, SplitAxis::Vertical)
5227 .ok_or(PaneEdgeResizePlanError::NoAxisSplit {
5228 leaf: proxy_leaf,
5229 axis: SplitAxis::Vertical,
5230 })?;
5231 let split_rect = layout
5232 .rect(split_id)
5233 .ok_or(PaneEdgeResizePlanError::MissingLayoutRect { node: split_id })?;
5234 let share = axis_share_from_pointer(
5235 split_rect,
5236 pointer,
5237 SplitAxis::Vertical,
5238 PANE_EDGE_GRIP_INSET_CELLS,
5239 );
5240 let raw_bps = elastic_ratio_bps(
5241 (share * 10_000.0).round().clamp(1.0, 9_999.0) as u16,
5242 pressure,
5243 );
5244 let snapped = tuned_snap
5245 .decide(raw_bps, None)
5246 .snapped_ratio_bps
5247 .unwrap_or(raw_bps);
5248 let ratio = PaneSplitRatio::new(
5249 u32::from(snapped.max(1)),
5250 u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5251 )
5252 .map_err(|_| PaneEdgeResizePlanError::InvalidRatio {
5253 numerator: u32::from(snapped.max(1)),
5254 denominator: u32::from(10_000_u16.saturating_sub(snapped).max(1)),
5255 })?;
5256 operations.push(PaneOperation::SetSplitRatio {
5257 split: split_id,
5258 ratio,
5259 });
5260 }
5261
5262 Ok(PaneGroupTransformPlan {
5263 members,
5264 operations,
5265 })
5266 }
5267
5268 pub fn apply_group_transform_plan(
5270 &mut self,
5271 operation_seed: u64,
5272 plan: &PaneGroupTransformPlan,
5273 ) -> Result<Vec<PaneOperationOutcome>, PaneOperationError> {
5274 let mut outcomes = Vec::with_capacity(plan.operations.len());
5275 for (index, operation) in plan.operations.iter().cloned().enumerate() {
5276 outcomes.push(
5277 self.apply_operation(operation_seed.saturating_add(index as u64), operation)?,
5278 );
5279 }
5280 Ok(outcomes)
5281 }
5282
5283 pub fn plan_intelligence_mode(
5285 &self,
5286 mode: PaneLayoutIntelligenceMode,
5287 primary: PaneId,
5288 ) -> Result<Vec<PaneOperation>, PaneReflowPlanError> {
5289 if !self.nodes.contains_key(&primary) {
5290 return Err(PaneReflowPlanError::MissingSource { source: primary });
5291 }
5292 let mut leaves = self
5293 .nodes
5294 .values()
5295 .filter_map(|node| matches!(node.kind, PaneNodeKind::Leaf(_)).then_some(node.id))
5296 .collect::<Vec<_>>();
5297 leaves.sort_unstable();
5298 let secondary = leaves.iter().copied().find(|leaf| *leaf != primary);
5299
5300 let focused_ratio =
5301 PaneSplitRatio::new(7, 3).map_err(|_| PaneReflowPlanError::InvalidRatio {
5302 numerator: 7,
5303 denominator: 3,
5304 })?;
5305 let balanced_ratio =
5306 PaneSplitRatio::new(1, 1).map_err(|_| PaneReflowPlanError::InvalidRatio {
5307 numerator: 1,
5308 denominator: 1,
5309 })?;
5310 let monitor_ratio =
5311 PaneSplitRatio::new(2, 1).map_err(|_| PaneReflowPlanError::InvalidRatio {
5312 numerator: 2,
5313 denominator: 1,
5314 })?;
5315
5316 let mut operations = Vec::new();
5317 match mode {
5318 PaneLayoutIntelligenceMode::Focus => {
5319 if primary != self.root {
5320 operations.push(PaneOperation::MoveSubtree {
5321 source: primary,
5322 target: self.root,
5323 axis: SplitAxis::Horizontal,
5324 ratio: focused_ratio,
5325 placement: PanePlacement::IncomingFirst,
5326 });
5327 }
5328 }
5329 PaneLayoutIntelligenceMode::Compare => {
5330 if let Some(other) = secondary
5331 && other != primary
5332 {
5333 operations.push(PaneOperation::MoveSubtree {
5334 source: primary,
5335 target: other,
5336 axis: SplitAxis::Horizontal,
5337 ratio: balanced_ratio,
5338 placement: PanePlacement::IncomingFirst,
5339 });
5340 }
5341 }
5342 PaneLayoutIntelligenceMode::Monitor => {
5343 if primary != self.root {
5344 operations.push(PaneOperation::MoveSubtree {
5345 source: primary,
5346 target: self.root,
5347 axis: SplitAxis::Vertical,
5348 ratio: monitor_ratio,
5349 placement: PanePlacement::IncomingFirst,
5350 });
5351 }
5352 }
5353 PaneLayoutIntelligenceMode::Compact => {
5354 for node in self.nodes.values() {
5355 if matches!(node.kind, PaneNodeKind::Split(_)) {
5356 operations.push(PaneOperation::SetSplitRatio {
5357 split: node.id,
5358 ratio: balanced_ratio,
5359 });
5360 }
5361 }
5362 operations.push(PaneOperation::NormalizeRatios);
5363 }
5364 }
5365 Ok(operations)
5366 }
5367
5368 fn choose_dock_preview_excluding(
5369 &self,
5370 layout: &PaneLayout,
5371 pointer: PanePointerPosition,
5372 magnetic_field_cells: f64,
5373 excluded: Option<PaneId>,
5374 ) -> Option<PaneDockPreview> {
5375 let mut best: Option<PaneDockPreview> = None;
5376 for node in self.nodes.values() {
5377 if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
5378 continue;
5379 }
5380 if excluded == Some(node.id) {
5381 continue;
5382 }
5383 let Some(rect) = layout.rect(node.id) else {
5384 continue;
5385 };
5386 let Some(candidate) =
5387 dock_preview_for_rect(node.id, rect, pointer, magnetic_field_cells)
5388 else {
5389 continue;
5390 };
5391 match best {
5392 Some(current)
5393 if candidate.score < current.score
5394 || (candidate.score == current.score
5395 && candidate.target > current.target) => {}
5396 _ => best = Some(candidate),
5397 }
5398 }
5399 best
5400 }
5401
5402 fn choose_dock_preview_excluding_with_motion(
5403 &self,
5404 layout: &PaneLayout,
5405 pointer: PanePointerPosition,
5406 magnetic_field_cells: f64,
5407 excluded: Option<PaneId>,
5408 motion: PaneMotionVector,
5409 ) -> Option<PaneDockPreview> {
5410 self.collect_dock_previews_excluding_with_motion(
5411 layout,
5412 pointer,
5413 magnetic_field_cells,
5414 excluded,
5415 motion,
5416 1,
5417 )
5418 .into_iter()
5419 .next()
5420 }
5421
5422 fn collect_dock_previews_excluding_with_motion(
5423 &self,
5424 layout: &PaneLayout,
5425 pointer: PanePointerPosition,
5426 magnetic_field_cells: f64,
5427 excluded: Option<PaneId>,
5428 motion: PaneMotionVector,
5429 limit: usize,
5430 ) -> Vec<PaneDockPreview> {
5431 let limit = limit.max(1);
5432 let mut candidates = Vec::new();
5433 for node in self.nodes.values() {
5434 if !matches!(node.kind, PaneNodeKind::Leaf(_)) {
5435 continue;
5436 }
5437 if excluded == Some(node.id) {
5438 continue;
5439 }
5440 let Some(rect) = layout.rect(node.id) else {
5441 continue;
5442 };
5443 let Some(candidate) = dock_preview_for_rect_with_motion(
5444 node.id,
5445 rect,
5446 pointer,
5447 magnetic_field_cells,
5448 motion,
5449 ) else {
5450 continue;
5451 };
5452 candidates.push(candidate);
5453 }
5454 candidates.sort_by(|left, right| {
5455 right
5456 .score
5457 .total_cmp(&left.score)
5458 .then_with(|| left.target.cmp(&right.target))
5459 .then_with(|| dock_zone_rank(left.zone).cmp(&dock_zone_rank(right.zone)))
5460 });
5461 if candidates.len() > limit {
5462 candidates.truncate(limit);
5463 }
5464 candidates
5465 }
5466
5467 fn nearest_axis_split_for_node(&self, node: PaneId, axis: SplitAxis) -> Option<PaneId> {
5468 let mut cursor = Some(node);
5469 while let Some(node_id) = cursor {
5470 let parent = self.nodes.get(&node_id).and_then(|record| record.parent)?;
5471 let parent_record = self.nodes.get(&parent)?;
5472 if let PaneNodeKind::Split(split) = &parent_record.kind
5473 && split.axis == axis
5474 {
5475 return Some(parent);
5476 }
5477 cursor = Some(parent);
5478 }
5479 None
5480 }
5481
5482 fn lowest_common_ancestor(&self, nodes: &[PaneId]) -> Option<PaneId> {
5483 if nodes.is_empty() {
5484 return None;
5485 }
5486 let mut ancestor_paths = nodes
5487 .iter()
5488 .map(|node_id| self.ancestor_chain(*node_id))
5489 .collect::<Option<Vec<_>>>()?;
5490 let first = ancestor_paths.remove(0);
5491 first
5492 .into_iter()
5493 .find(|candidate| ancestor_paths.iter().all(|path| path.contains(candidate)))
5494 }
5495
5496 fn ancestor_chain(&self, node: PaneId) -> Option<Vec<PaneId>> {
5497 let mut out = Vec::new();
5498 let mut cursor = Some(node);
5499 while let Some(node_id) = cursor {
5500 if !self.nodes.contains_key(&node_id) {
5501 return None;
5502 }
5503 out.push(node_id);
5504 cursor = self.nodes.get(&node_id).and_then(|record| record.parent);
5505 }
5506 Some(out)
5507 }
5508}
5509
5510impl PaneInteractionTimeline {
5511 #[must_use]
5513 pub fn with_baseline(tree: &PaneTree) -> Self {
5514 Self {
5515 baseline: Some(tree.to_snapshot()),
5516 entries: Vec::new(),
5517 cursor: 0,
5518 checkpoints: Vec::new(),
5519 checkpoint_interval: DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL,
5520 }
5521 }
5522
5523 #[must_use]
5525 pub const fn applied_len(&self) -> usize {
5526 self.cursor
5527 }
5528
5529 #[must_use]
5531 pub fn replay_diagnostics(&self) -> PaneInteractionTimelineReplayDiagnostics {
5532 let replay_start_idx = self
5533 .checkpoints
5534 .iter()
5535 .rev()
5536 .find(|checkpoint| checkpoint.applied_len <= self.cursor)
5537 .map_or(0, |checkpoint| checkpoint.applied_len);
5538
5539 PaneInteractionTimelineReplayDiagnostics {
5540 entry_count: self.entries.len(),
5541 cursor: self.cursor,
5542 checkpoint_count: self.checkpoints.len(),
5543 checkpoint_interval: self.checkpoint_interval,
5544 checkpoint_hit: replay_start_idx != 0,
5545 replay_start_idx,
5546 replay_depth: self.cursor.saturating_sub(replay_start_idx),
5547 }
5548 }
5549
5550 #[must_use]
5552 pub fn checkpoint_decision(
5553 snapshot_cost_ns: u128,
5554 replay_step_cost_ns: u128,
5555 ) -> PaneInteractionTimelineCheckpointDecision {
5556 let interval =
5557 analytically_tuned_checkpoint_interval(snapshot_cost_ns, replay_step_cost_ns);
5558 let replay_depth_ns = replay_step_cost_ns.saturating_mul(interval as u128) / 2;
5559 PaneInteractionTimelineCheckpointDecision {
5560 checkpoint_interval: interval,
5561 estimated_snapshot_cost_ns: snapshot_cost_ns,
5562 estimated_replay_step_cost_ns: replay_step_cost_ns,
5563 estimated_replay_depth_ns: replay_depth_ns,
5564 }
5565 }
5566
5567 pub fn apply_and_record(
5572 &mut self,
5573 tree: &mut PaneTree,
5574 sequence: u64,
5575 operation_id: u64,
5576 operation: PaneOperation,
5577 ) -> Result<PaneOperationOutcome, PaneOperationError> {
5578 if self.baseline.is_none() {
5579 self.baseline = Some(tree.to_snapshot());
5580 }
5581 if self.cursor < self.entries.len() {
5582 self.entries.truncate(self.cursor);
5583 self.checkpoints
5584 .retain(|checkpoint| checkpoint.applied_len <= self.cursor);
5585 }
5586 let outcome = tree.apply_operation(operation_id, operation.clone())?;
5587 self.entries.push(PaneInteractionTimelineEntry {
5588 sequence,
5589 operation_id,
5590 operation,
5591 before_hash: outcome.before_hash,
5592 after_hash: outcome.after_hash,
5593 });
5594 self.cursor = self.entries.len();
5595 self.maybe_record_checkpoint(tree);
5596 Ok(outcome)
5597 }
5598
5599 pub fn undo(&mut self, tree: &mut PaneTree) -> Result<bool, PaneInteractionTimelineError> {
5601 if self.cursor == 0 {
5602 return Ok(false);
5603 }
5604 self.cursor -= 1;
5605 self.rebuild(tree)?;
5606 Ok(true)
5607 }
5608
5609 pub fn redo(&mut self, tree: &mut PaneTree) -> Result<bool, PaneInteractionTimelineError> {
5611 if self.cursor >= self.entries.len() {
5612 return Ok(false);
5613 }
5614 self.cursor += 1;
5615 self.rebuild(tree)?;
5616 Ok(true)
5617 }
5618
5619 pub fn replay(&self) -> Result<PaneTree, PaneInteractionTimelineError> {
5621 let (mut tree, start_idx) = self.restore_replay_start()?;
5622 for entry in self.entries.iter().take(self.cursor).skip(start_idx) {
5623 tree.apply_operation_in_place_for_replay(entry.operation_id, &entry.operation)
5624 .map_err(|source| PaneInteractionTimelineError::ApplyFailed { source })?;
5625 }
5626 Ok(tree)
5627 }
5628
5629 fn rebuild(&self, tree: &mut PaneTree) -> Result<(), PaneInteractionTimelineError> {
5630 let replayed = self.replay()?;
5631 *tree = replayed;
5632 Ok(())
5633 }
5634
5635 fn restore_replay_start(&self) -> Result<(PaneTree, usize), PaneInteractionTimelineError> {
5636 if let Some(checkpoint) = self
5637 .checkpoints
5638 .iter()
5639 .rev()
5640 .find(|checkpoint| checkpoint.applied_len <= self.cursor)
5641 {
5642 let tree = PaneTree::from_snapshot(checkpoint.snapshot.clone())
5643 .map_err(|source| PaneInteractionTimelineError::BaselineInvalid { source })?;
5644 return Ok((tree, checkpoint.applied_len));
5645 }
5646
5647 let baseline = self
5648 .baseline
5649 .clone()
5650 .ok_or(PaneInteractionTimelineError::MissingBaseline)?;
5651 let tree = PaneTree::from_snapshot(baseline)
5652 .map_err(|source| PaneInteractionTimelineError::BaselineInvalid { source })?;
5653 Ok((tree, 0))
5654 }
5655
5656 fn maybe_record_checkpoint(&mut self, tree: &PaneTree) {
5657 if self.checkpoint_interval == 0 || self.cursor == 0 {
5658 return;
5659 }
5660 if !self.cursor.is_multiple_of(self.checkpoint_interval) {
5661 return;
5662 }
5663 self.checkpoints.push(PaneInteractionTimelineCheckpoint {
5664 applied_len: self.cursor,
5665 snapshot: tree.to_snapshot(),
5666 });
5667 }
5668}
5669
5670fn analytically_tuned_checkpoint_interval(
5671 snapshot_cost_ns: u128,
5672 replay_step_cost_ns: u128,
5673) -> usize {
5674 if snapshot_cost_ns == 0 || replay_step_cost_ns == 0 {
5675 return DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL;
5676 }
5677
5678 let ratio = snapshot_cost_ns.saturating_mul(2) / replay_step_cost_ns;
5679 let root = integer_sqrt(ratio).max(1);
5680 usize::try_from(root).unwrap_or(DEFAULT_PANE_TIMELINE_CHECKPOINT_INTERVAL)
5681}
5682
5683fn integer_sqrt(value: u128) -> u128 {
5684 if value < 2 {
5685 return value;
5686 }
5687
5688 let mut left = 1u128;
5689 let mut right = value;
5690 let mut answer = 1u128;
5691 while left <= right {
5692 let mid = left + (right - left) / 2;
5693 if mid <= value / mid {
5694 answer = mid;
5695 left = mid + 1;
5696 } else {
5697 right = mid - 1;
5698 }
5699 }
5700 answer
5701}
5702
5703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5705pub struct PaneIdAllocator {
5706 next: PaneId,
5707}
5708
5709impl PaneIdAllocator {
5710 #[must_use]
5712 pub const fn with_next(next: PaneId) -> Self {
5713 Self { next }
5714 }
5715
5716 #[must_use]
5718 pub fn from_tree(tree: &PaneTree) -> Self {
5719 Self { next: tree.next_id }
5720 }
5721
5722 #[must_use]
5724 pub const fn peek(&self) -> PaneId {
5725 self.next
5726 }
5727
5728 pub fn allocate(&mut self) -> Result<PaneId, PaneModelError> {
5730 let current = self.next;
5731 self.next = self.next.checked_next()?;
5732 Ok(current)
5733 }
5734}
5735
5736impl Default for PaneIdAllocator {
5737 fn default() -> Self {
5738 Self { next: PaneId::MIN }
5739 }
5740}
5741
5742#[derive(Debug, Clone, PartialEq, Eq)]
5744pub enum PaneModelError {
5745 ZeroPaneId,
5746 UnsupportedSchemaVersion {
5747 version: u16,
5748 },
5749 DuplicateNodeId {
5750 node_id: PaneId,
5751 },
5752 MissingRoot {
5753 root: PaneId,
5754 },
5755 RootHasParent {
5756 root: PaneId,
5757 parent: PaneId,
5758 },
5759 MissingParent {
5760 node_id: PaneId,
5761 parent: PaneId,
5762 },
5763 MissingChild {
5764 parent: PaneId,
5765 child: PaneId,
5766 },
5767 MultipleParents {
5768 child: PaneId,
5769 first_parent: PaneId,
5770 second_parent: PaneId,
5771 },
5772 ParentMismatch {
5773 node_id: PaneId,
5774 expected: Option<PaneId>,
5775 actual: Option<PaneId>,
5776 },
5777 SelfReferentialSplit {
5778 node_id: PaneId,
5779 },
5780 DuplicateSplitChildren {
5781 node_id: PaneId,
5782 child: PaneId,
5783 },
5784 InvalidSplitRatio {
5785 numerator: u32,
5786 denominator: u32,
5787 },
5788 InvalidConstraint {
5789 node_id: PaneId,
5790 axis: &'static str,
5791 min: u16,
5792 max: u16,
5793 },
5794 NodeConstraintUnsatisfied {
5795 node_id: PaneId,
5796 axis: &'static str,
5797 actual: u16,
5798 min: u16,
5799 max: Option<u16>,
5800 },
5801 OverconstrainedSplit {
5802 node_id: PaneId,
5803 axis: SplitAxis,
5804 available: u16,
5805 first_min: u16,
5806 first_max: u16,
5807 second_min: u16,
5808 second_max: u16,
5809 },
5810 CycleDetected {
5811 node_id: PaneId,
5812 },
5813 UnreachableNode {
5814 node_id: PaneId,
5815 },
5816 NextIdNotGreaterThanExisting {
5817 next_id: PaneId,
5818 max_existing: PaneId,
5819 },
5820 PaneIdOverflow {
5821 current: PaneId,
5822 },
5823}
5824
5825impl fmt::Display for PaneModelError {
5826 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5827 match self {
5828 Self::ZeroPaneId => write!(f, "pane id 0 is invalid"),
5829 Self::UnsupportedSchemaVersion { version } => {
5830 write!(
5831 f,
5832 "unsupported pane schema version {version} (expected {PANE_TREE_SCHEMA_VERSION})"
5833 )
5834 }
5835 Self::DuplicateNodeId { node_id } => write!(f, "duplicate pane node id {}", node_id.0),
5836 Self::MissingRoot { root } => write!(f, "root pane node {} not found", root.0),
5837 Self::RootHasParent { root, parent } => write!(
5838 f,
5839 "root pane node {} must not have parent {}",
5840 root.0, parent.0
5841 ),
5842 Self::MissingParent { node_id, parent } => write!(
5843 f,
5844 "node {} references missing parent {}",
5845 node_id.0, parent.0
5846 ),
5847 Self::MissingChild { parent, child } => write!(
5848 f,
5849 "split node {} references missing child {}",
5850 parent.0, child.0
5851 ),
5852 Self::MultipleParents {
5853 child,
5854 first_parent,
5855 second_parent,
5856 } => write!(
5857 f,
5858 "node {} has multiple parents: {} and {}",
5859 child.0, first_parent.0, second_parent.0
5860 ),
5861 Self::ParentMismatch {
5862 node_id,
5863 expected,
5864 actual,
5865 } => write!(
5866 f,
5867 "node {} parent mismatch: expected {:?}, got {:?}",
5868 node_id.0,
5869 expected.map(PaneId::get),
5870 actual.map(PaneId::get)
5871 ),
5872 Self::SelfReferentialSplit { node_id } => {
5873 write!(f, "split node {} cannot reference itself", node_id.0)
5874 }
5875 Self::DuplicateSplitChildren { node_id, child } => write!(
5876 f,
5877 "split node {} references child {} twice",
5878 node_id.0, child.0
5879 ),
5880 Self::InvalidSplitRatio {
5881 numerator,
5882 denominator,
5883 } => write!(
5884 f,
5885 "invalid split ratio {numerator}/{denominator}: both values must be > 0"
5886 ),
5887 Self::InvalidConstraint {
5888 node_id,
5889 axis,
5890 min,
5891 max,
5892 } => write!(
5893 f,
5894 "invalid {axis} constraints for node {}: max {max} < min {min}",
5895 node_id.0
5896 ),
5897 Self::NodeConstraintUnsatisfied {
5898 node_id,
5899 axis,
5900 actual,
5901 min,
5902 max,
5903 } => write!(
5904 f,
5905 "node {} {axis}={} violates constraints [min={}, max={:?}]",
5906 node_id.0, actual, min, max
5907 ),
5908 Self::OverconstrainedSplit {
5909 node_id,
5910 axis,
5911 available,
5912 first_min,
5913 first_max,
5914 second_min,
5915 second_max,
5916 } => write!(
5917 f,
5918 "overconstrained {:?} split at node {} (available={}): first[min={}, max={}], second[min={}, max={}]",
5919 axis, node_id.0, available, first_min, first_max, second_min, second_max
5920 ),
5921 Self::CycleDetected { node_id } => {
5922 write!(f, "cycle detected at node {}", node_id.0)
5923 }
5924 Self::UnreachableNode { node_id } => {
5925 write!(f, "node {} is unreachable from root", node_id.0)
5926 }
5927 Self::NextIdNotGreaterThanExisting {
5928 next_id,
5929 max_existing,
5930 } => write!(
5931 f,
5932 "next_id {} must be greater than max existing id {}",
5933 next_id.0, max_existing.0
5934 ),
5935 Self::PaneIdOverflow { current } => {
5936 write!(f, "pane id overflow after {}", current.0)
5937 }
5938 }
5939 }
5940}
5941
5942impl std::error::Error for PaneModelError {}
5943
5944fn snapshot_state_hash(snapshot: &PaneTreeSnapshot) -> u64 {
5945 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
5946 const PRIME: u64 = 0x0000_0001_0000_01b3;
5947
5948 fn mix(hash: &mut u64, byte: u8) {
5949 *hash ^= u64::from(byte);
5950 *hash = hash.wrapping_mul(PRIME);
5951 }
5952
5953 fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
5954 for byte in bytes {
5955 mix(hash, *byte);
5956 }
5957 }
5958
5959 fn mix_u16(hash: &mut u64, value: u16) {
5960 mix_bytes(hash, &value.to_le_bytes());
5961 }
5962
5963 fn mix_u32(hash: &mut u64, value: u32) {
5964 mix_bytes(hash, &value.to_le_bytes());
5965 }
5966
5967 fn mix_u64(hash: &mut u64, value: u64) {
5968 mix_bytes(hash, &value.to_le_bytes());
5969 }
5970
5971 fn mix_bool(hash: &mut u64, value: bool) {
5972 mix(hash, u8::from(value));
5973 }
5974
5975 fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
5976 match value {
5977 Some(value) => {
5978 mix(hash, 1);
5979 mix_u16(hash, value);
5980 }
5981 None => mix(hash, 0),
5982 }
5983 }
5984
5985 fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
5986 match value {
5987 Some(value) => {
5988 mix(hash, 1);
5989 mix_u64(hash, value.get());
5990 }
5991 None => mix(hash, 0),
5992 }
5993 }
5994
5995 fn mix_str(hash: &mut u64, value: &str) {
5996 mix_u64(hash, value.len() as u64);
5997 mix_bytes(hash, value.as_bytes());
5998 }
5999
6000 fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
6001 mix_u64(hash, extensions.len() as u64);
6002 for (key, value) in extensions {
6003 mix_str(hash, key);
6004 mix_str(hash, value);
6005 }
6006 }
6007
6008 let mut canonical = snapshot.clone();
6009 canonical.canonicalize();
6010
6011 let mut hash = OFFSET_BASIS;
6012 mix_u16(&mut hash, canonical.schema_version);
6013 mix_u64(&mut hash, canonical.root.get());
6014 mix_u64(&mut hash, canonical.next_id.get());
6015 mix_extensions(&mut hash, &canonical.extensions);
6016 mix_u64(&mut hash, canonical.nodes.len() as u64);
6017
6018 for node in &canonical.nodes {
6019 mix_u64(&mut hash, node.id.get());
6020 mix_opt_pane_id(&mut hash, node.parent);
6021 mix_u16(&mut hash, node.constraints.min_width);
6022 mix_u16(&mut hash, node.constraints.min_height);
6023 mix_opt_u16(&mut hash, node.constraints.max_width);
6024 mix_opt_u16(&mut hash, node.constraints.max_height);
6025 mix_bool(&mut hash, node.constraints.collapsible);
6026 mix_extensions(&mut hash, &node.extensions);
6027
6028 match &node.kind {
6029 PaneNodeKind::Leaf(leaf) => {
6030 mix(&mut hash, 1);
6031 mix_str(&mut hash, &leaf.surface_key);
6032 mix_extensions(&mut hash, &leaf.extensions);
6033 }
6034 PaneNodeKind::Split(split) => {
6035 mix(&mut hash, 2);
6036 let axis_byte = match split.axis {
6037 SplitAxis::Horizontal => 1,
6038 SplitAxis::Vertical => 2,
6039 };
6040 mix(&mut hash, axis_byte);
6041 mix_u32(&mut hash, split.ratio.numerator());
6042 mix_u32(&mut hash, split.ratio.denominator());
6043 mix_u64(&mut hash, split.first.get());
6044 mix_u64(&mut hash, split.second.get());
6045 }
6046 }
6047 }
6048
6049 hash
6050}
6051
6052fn push_invariant_issue(
6053 issues: &mut Vec<PaneInvariantIssue>,
6054 code: PaneInvariantCode,
6055 repairable: bool,
6056 node_id: Option<PaneId>,
6057 related_node: Option<PaneId>,
6058 message: impl Into<String>,
6059) {
6060 issues.push(PaneInvariantIssue {
6061 code,
6062 severity: PaneInvariantSeverity::Error,
6063 repairable,
6064 node_id,
6065 related_node,
6066 message: message.into(),
6067 });
6068}
6069
6070fn dfs_collect_cycles_and_reachable(
6071 node_id: PaneId,
6072 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
6073 visiting: &mut BTreeSet<PaneId>,
6074 visited: &mut BTreeSet<PaneId>,
6075 cycle_nodes: &mut BTreeSet<PaneId>,
6076) {
6077 if visiting.contains(&node_id) {
6078 let _ = cycle_nodes.insert(node_id);
6079 return;
6080 }
6081 if !visited.insert(node_id) {
6082 return;
6083 }
6084
6085 let _ = visiting.insert(node_id);
6086 if let Some(node) = nodes.get(&node_id)
6087 && let PaneNodeKind::Split(split) = &node.kind
6088 {
6089 for child in [split.first, split.second] {
6090 if nodes.contains_key(&child) {
6091 dfs_collect_cycles_and_reachable(child, nodes, visiting, visited, cycle_nodes);
6092 }
6093 }
6094 }
6095 let _ = visiting.remove(&node_id);
6096}
6097
6098fn build_invariant_report(snapshot: &PaneTreeSnapshot) -> PaneInvariantReport {
6099 let mut issues = Vec::new();
6100
6101 if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
6102 push_invariant_issue(
6103 &mut issues,
6104 PaneInvariantCode::UnsupportedSchemaVersion,
6105 false,
6106 None,
6107 None,
6108 format!(
6109 "unsupported schema version {} (expected {})",
6110 snapshot.schema_version, PANE_TREE_SCHEMA_VERSION
6111 ),
6112 );
6113 }
6114
6115 let mut nodes = BTreeMap::new();
6116 for node in &snapshot.nodes {
6117 if nodes.insert(node.id, node.clone()).is_some() {
6118 push_invariant_issue(
6119 &mut issues,
6120 PaneInvariantCode::DuplicateNodeId,
6121 false,
6122 Some(node.id),
6123 None,
6124 format!("duplicate node id {}", node.id.get()),
6125 );
6126 }
6127 }
6128
6129 if let Some(max_existing) = nodes.keys().next_back().copied()
6130 && snapshot.next_id <= max_existing
6131 {
6132 push_invariant_issue(
6133 &mut issues,
6134 PaneInvariantCode::NextIdNotGreaterThanExisting,
6135 true,
6136 Some(snapshot.next_id),
6137 Some(max_existing),
6138 format!(
6139 "next_id {} must be greater than max node id {}",
6140 snapshot.next_id.get(),
6141 max_existing.get()
6142 ),
6143 );
6144 }
6145
6146 if !nodes.contains_key(&snapshot.root) {
6147 push_invariant_issue(
6148 &mut issues,
6149 PaneInvariantCode::MissingRoot,
6150 false,
6151 Some(snapshot.root),
6152 None,
6153 format!("root node {} is missing", snapshot.root.get()),
6154 );
6155 }
6156
6157 let mut expected_parents = BTreeMap::new();
6158 for node in nodes.values() {
6159 if let Err(err) = node.constraints.validate(node.id) {
6160 push_invariant_issue(
6161 &mut issues,
6162 PaneInvariantCode::InvalidConstraint,
6163 false,
6164 Some(node.id),
6165 None,
6166 err.to_string(),
6167 );
6168 }
6169
6170 if let Some(parent) = node.parent
6171 && !nodes.contains_key(&parent)
6172 {
6173 push_invariant_issue(
6174 &mut issues,
6175 PaneInvariantCode::MissingParent,
6176 true,
6177 Some(node.id),
6178 Some(parent),
6179 format!(
6180 "node {} references missing parent {}",
6181 node.id.get(),
6182 parent.get()
6183 ),
6184 );
6185 }
6186
6187 if let PaneNodeKind::Split(split) = &node.kind {
6188 if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
6189 push_invariant_issue(
6190 &mut issues,
6191 PaneInvariantCode::InvalidSplitRatio,
6192 false,
6193 Some(node.id),
6194 None,
6195 format!(
6196 "split node {} has invalid ratio {}/{}",
6197 node.id.get(),
6198 split.ratio.numerator(),
6199 split.ratio.denominator()
6200 ),
6201 );
6202 }
6203
6204 if split.first == node.id || split.second == node.id {
6205 push_invariant_issue(
6206 &mut issues,
6207 PaneInvariantCode::SelfReferentialSplit,
6208 false,
6209 Some(node.id),
6210 None,
6211 format!("split node {} references itself", node.id.get()),
6212 );
6213 }
6214
6215 if split.first == split.second {
6216 push_invariant_issue(
6217 &mut issues,
6218 PaneInvariantCode::DuplicateSplitChildren,
6219 false,
6220 Some(node.id),
6221 Some(split.first),
6222 format!(
6223 "split node {} references child {} twice",
6224 node.id.get(),
6225 split.first.get()
6226 ),
6227 );
6228 }
6229
6230 for child in [split.first, split.second] {
6231 if !nodes.contains_key(&child) {
6232 push_invariant_issue(
6233 &mut issues,
6234 PaneInvariantCode::MissingChild,
6235 false,
6236 Some(node.id),
6237 Some(child),
6238 format!(
6239 "split node {} references missing child {}",
6240 node.id.get(),
6241 child.get()
6242 ),
6243 );
6244 continue;
6245 }
6246
6247 if let Some(first_parent) = expected_parents.insert(child, node.id)
6248 && first_parent != node.id
6249 {
6250 push_invariant_issue(
6251 &mut issues,
6252 PaneInvariantCode::MultipleParents,
6253 false,
6254 Some(child),
6255 Some(node.id),
6256 format!(
6257 "node {} has multiple split parents {} and {}",
6258 child.get(),
6259 first_parent.get(),
6260 node.id.get()
6261 ),
6262 );
6263 }
6264 }
6265 }
6266 }
6267
6268 if let Some(root_node) = nodes.get(&snapshot.root)
6269 && let Some(parent) = root_node.parent
6270 {
6271 push_invariant_issue(
6272 &mut issues,
6273 PaneInvariantCode::RootHasParent,
6274 true,
6275 Some(snapshot.root),
6276 Some(parent),
6277 format!(
6278 "root node {} must not have parent {}",
6279 snapshot.root.get(),
6280 parent.get()
6281 ),
6282 );
6283 }
6284
6285 for node in nodes.values() {
6286 let expected_parent = if node.id == snapshot.root {
6287 None
6288 } else {
6289 expected_parents.get(&node.id).copied()
6290 };
6291
6292 if node.parent != expected_parent {
6293 push_invariant_issue(
6294 &mut issues,
6295 PaneInvariantCode::ParentMismatch,
6296 true,
6297 Some(node.id),
6298 expected_parent,
6299 format!(
6300 "node {} parent mismatch: expected {:?}, got {:?}",
6301 node.id.get(),
6302 expected_parent.map(PaneId::get),
6303 node.parent.map(PaneId::get)
6304 ),
6305 );
6306 }
6307 }
6308
6309 if nodes.contains_key(&snapshot.root) {
6310 let mut visiting = BTreeSet::new();
6311 let mut visited = BTreeSet::new();
6312 let mut cycle_nodes = BTreeSet::new();
6313 dfs_collect_cycles_and_reachable(
6314 snapshot.root,
6315 &nodes,
6316 &mut visiting,
6317 &mut visited,
6318 &mut cycle_nodes,
6319 );
6320
6321 for node_id in cycle_nodes {
6322 push_invariant_issue(
6323 &mut issues,
6324 PaneInvariantCode::CycleDetected,
6325 false,
6326 Some(node_id),
6327 None,
6328 format!("cycle detected at node {}", node_id.get()),
6329 );
6330 }
6331
6332 for node_id in nodes.keys() {
6333 if !visited.contains(node_id) {
6334 push_invariant_issue(
6335 &mut issues,
6336 PaneInvariantCode::UnreachableNode,
6337 true,
6338 Some(*node_id),
6339 None,
6340 format!("node {} is unreachable from root", node_id.get()),
6341 );
6342 }
6343 }
6344 }
6345
6346 issues.sort_by(|left, right| {
6347 (
6348 left.code,
6349 left.node_id.is_none(),
6350 left.node_id,
6351 left.related_node.is_none(),
6352 left.related_node,
6353 &left.message,
6354 )
6355 .cmp(&(
6356 right.code,
6357 right.node_id.is_none(),
6358 right.node_id,
6359 right.related_node.is_none(),
6360 right.related_node,
6361 &right.message,
6362 ))
6363 });
6364
6365 PaneInvariantReport {
6366 snapshot_hash: snapshot_state_hash(snapshot),
6367 issues,
6368 }
6369}
6370
6371fn repair_snapshot_safe(
6372 mut snapshot: PaneTreeSnapshot,
6373) -> Result<PaneRepairOutcome, PaneRepairError> {
6374 snapshot.canonicalize();
6375
6376 let before_hash = snapshot_state_hash(&snapshot);
6377 let report_before = build_invariant_report(&snapshot);
6378 let mut unsafe_codes = report_before
6379 .issues
6380 .iter()
6381 .filter(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
6382 .map(|issue| issue.code)
6383 .collect::<Vec<_>>();
6384 unsafe_codes.sort();
6385 unsafe_codes.dedup();
6386
6387 if !unsafe_codes.is_empty() {
6388 return Err(PaneRepairError {
6389 before_hash,
6390 report: report_before,
6391 reason: PaneRepairFailure::UnsafeIssuesPresent {
6392 codes: unsafe_codes,
6393 },
6394 });
6395 }
6396
6397 let mut nodes = BTreeMap::new();
6398 for node in snapshot.nodes {
6399 let _ = nodes.entry(node.id).or_insert(node);
6400 }
6401
6402 let mut actions = Vec::new();
6403 let mut expected_parents = BTreeMap::new();
6404 for node in nodes.values() {
6405 if let PaneNodeKind::Split(split) = &node.kind {
6406 for child in [split.first, split.second] {
6407 let _ = expected_parents.entry(child).or_insert(node.id);
6408 }
6409 }
6410 }
6411
6412 for node in nodes.values_mut() {
6413 let expected_parent = if node.id == snapshot.root {
6414 None
6415 } else {
6416 expected_parents.get(&node.id).copied()
6417 };
6418 if node.parent != expected_parent {
6419 actions.push(PaneRepairAction::ReparentNode {
6420 node_id: node.id,
6421 before_parent: node.parent,
6422 after_parent: expected_parent,
6423 });
6424 node.parent = expected_parent;
6425 }
6426
6427 if let PaneNodeKind::Split(split) = &mut node.kind {
6428 let normalized =
6429 PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator()).map_err(
6430 |error| PaneRepairError {
6431 before_hash,
6432 report: report_before.clone(),
6433 reason: PaneRepairFailure::ValidationFailed { error },
6434 },
6435 )?;
6436 if split.ratio != normalized {
6437 actions.push(PaneRepairAction::NormalizeRatio {
6438 node_id: node.id,
6439 before_numerator: split.ratio.numerator(),
6440 before_denominator: split.ratio.denominator(),
6441 after_numerator: normalized.numerator(),
6442 after_denominator: normalized.denominator(),
6443 });
6444 split.ratio = normalized;
6445 }
6446 }
6447 }
6448
6449 let mut visiting = BTreeSet::new();
6450 let mut visited = BTreeSet::new();
6451 let mut cycle_nodes = BTreeSet::new();
6452 if nodes.contains_key(&snapshot.root) {
6453 dfs_collect_cycles_and_reachable(
6454 snapshot.root,
6455 &nodes,
6456 &mut visiting,
6457 &mut visited,
6458 &mut cycle_nodes,
6459 );
6460 }
6461 if !cycle_nodes.is_empty() {
6462 let mut codes = vec![PaneInvariantCode::CycleDetected];
6463 codes.sort();
6464 codes.dedup();
6465 return Err(PaneRepairError {
6466 before_hash,
6467 report: report_before,
6468 reason: PaneRepairFailure::UnsafeIssuesPresent { codes },
6469 });
6470 }
6471
6472 let all_node_ids = nodes.keys().copied().collect::<Vec<_>>();
6473 for node_id in all_node_ids {
6474 if !visited.contains(&node_id) {
6475 let _ = nodes.remove(&node_id);
6476 actions.push(PaneRepairAction::RemoveOrphanNode { node_id });
6477 }
6478 }
6479
6480 if let Some(max_existing) = nodes.keys().next_back().copied()
6481 && snapshot.next_id <= max_existing
6482 {
6483 let after = max_existing
6484 .checked_next()
6485 .map_err(|error| PaneRepairError {
6486 before_hash,
6487 report: report_before.clone(),
6488 reason: PaneRepairFailure::ValidationFailed { error },
6489 })?;
6490 actions.push(PaneRepairAction::BumpNextId {
6491 before: snapshot.next_id,
6492 after,
6493 });
6494 snapshot.next_id = after;
6495 }
6496
6497 snapshot.nodes = nodes.into_values().collect();
6498 snapshot.canonicalize();
6499
6500 let tree = PaneTree::from_snapshot(snapshot).map_err(|error| PaneRepairError {
6501 before_hash,
6502 report: report_before.clone(),
6503 reason: PaneRepairFailure::ValidationFailed { error },
6504 })?;
6505 let report_after = tree.invariant_report();
6506 let after_hash = tree.state_hash();
6507
6508 Ok(PaneRepairOutcome {
6509 before_hash,
6510 after_hash,
6511 report_before,
6512 report_after,
6513 actions,
6514 tree,
6515 })
6516}
6517
6518fn validate_tree(
6519 root: PaneId,
6520 next_id: PaneId,
6521 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
6522) -> Result<(), PaneModelError> {
6523 if !nodes.contains_key(&root) {
6524 return Err(PaneModelError::MissingRoot { root });
6525 }
6526
6527 let max_existing = nodes.keys().next_back().copied().unwrap_or(root);
6528 if next_id <= max_existing {
6529 return Err(PaneModelError::NextIdNotGreaterThanExisting {
6530 next_id,
6531 max_existing,
6532 });
6533 }
6534
6535 let mut expected_parents = BTreeMap::new();
6536
6537 for node in nodes.values() {
6538 node.constraints.validate(node.id)?;
6539
6540 if let Some(parent) = node.parent
6541 && !nodes.contains_key(&parent)
6542 {
6543 return Err(PaneModelError::MissingParent {
6544 node_id: node.id,
6545 parent,
6546 });
6547 }
6548
6549 if let PaneNodeKind::Split(split) = &node.kind {
6550 if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
6551 return Err(PaneModelError::InvalidSplitRatio {
6552 numerator: split.ratio.numerator(),
6553 denominator: split.ratio.denominator(),
6554 });
6555 }
6556
6557 if split.first == node.id || split.second == node.id {
6558 return Err(PaneModelError::SelfReferentialSplit { node_id: node.id });
6559 }
6560 if split.first == split.second {
6561 return Err(PaneModelError::DuplicateSplitChildren {
6562 node_id: node.id,
6563 child: split.first,
6564 });
6565 }
6566
6567 for child in [split.first, split.second] {
6568 if !nodes.contains_key(&child) {
6569 return Err(PaneModelError::MissingChild {
6570 parent: node.id,
6571 child,
6572 });
6573 }
6574 if let Some(first_parent) = expected_parents.insert(child, node.id)
6575 && first_parent != node.id
6576 {
6577 return Err(PaneModelError::MultipleParents {
6578 child,
6579 first_parent,
6580 second_parent: node.id,
6581 });
6582 }
6583 }
6584 }
6585 }
6586
6587 if let Some(parent) = nodes.get(&root).and_then(|node| node.parent) {
6588 return Err(PaneModelError::RootHasParent { root, parent });
6589 }
6590
6591 for node in nodes.values() {
6592 let expected = if node.id == root {
6593 None
6594 } else {
6595 expected_parents.get(&node.id).copied()
6596 };
6597 if node.parent != expected {
6598 return Err(PaneModelError::ParentMismatch {
6599 node_id: node.id,
6600 expected,
6601 actual: node.parent,
6602 });
6603 }
6604 }
6605
6606 let mut visiting = BTreeSet::new();
6607 let mut visited = BTreeSet::new();
6608 dfs_validate(root, nodes, &mut visiting, &mut visited)?;
6609
6610 if visited.len() != nodes.len()
6611 && let Some(node_id) = nodes.keys().find(|node_id| !visited.contains(node_id))
6612 {
6613 return Err(PaneModelError::UnreachableNode { node_id: *node_id });
6614 }
6615
6616 Ok(())
6617}
6618
6619#[derive(Debug, Clone, Copy)]
6620struct AxisBounds {
6621 min: u16,
6622 max: Option<u16>,
6623}
6624
6625fn axis_bounds(constraints: PaneConstraints, axis: SplitAxis) -> AxisBounds {
6626 match axis {
6627 SplitAxis::Horizontal => AxisBounds {
6628 min: constraints.min_width,
6629 max: constraints.max_width,
6630 },
6631 SplitAxis::Vertical => AxisBounds {
6632 min: constraints.min_height,
6633 max: constraints.max_height,
6634 },
6635 }
6636}
6637
6638fn validate_area_against_constraints(
6639 node_id: PaneId,
6640 area: Rect,
6641 constraints: PaneConstraints,
6642) -> Result<(), PaneModelError> {
6643 if area.width < constraints.min_width {
6644 return Err(PaneModelError::NodeConstraintUnsatisfied {
6645 node_id,
6646 axis: "width",
6647 actual: area.width,
6648 min: constraints.min_width,
6649 max: constraints.max_width,
6650 });
6651 }
6652 if area.height < constraints.min_height {
6653 return Err(PaneModelError::NodeConstraintUnsatisfied {
6654 node_id,
6655 axis: "height",
6656 actual: area.height,
6657 min: constraints.min_height,
6658 max: constraints.max_height,
6659 });
6660 }
6661 if let Some(max_width) = constraints.max_width
6662 && area.width > max_width
6663 {
6664 return Err(PaneModelError::NodeConstraintUnsatisfied {
6665 node_id,
6666 axis: "width",
6667 actual: area.width,
6668 min: constraints.min_width,
6669 max: constraints.max_width,
6670 });
6671 }
6672 if let Some(max_height) = constraints.max_height
6673 && area.height > max_height
6674 {
6675 return Err(PaneModelError::NodeConstraintUnsatisfied {
6676 node_id,
6677 axis: "height",
6678 actual: area.height,
6679 min: constraints.min_height,
6680 max: constraints.max_height,
6681 });
6682 }
6683 Ok(())
6684}
6685
6686fn solve_split_sizes(
6687 node_id: PaneId,
6688 axis: SplitAxis,
6689 available: u16,
6690 ratio: PaneSplitRatio,
6691 first: AxisBounds,
6692 second: AxisBounds,
6693) -> Result<(u16, u16), PaneModelError> {
6694 let first_max = first.max.unwrap_or(available).min(available);
6695 let second_max = second.max.unwrap_or(available).min(available);
6696
6697 let feasible_first_min = first.min.max(available.saturating_sub(second_max));
6698 let feasible_first_max = first_max.min(available.saturating_sub(second.min));
6699
6700 if feasible_first_min > feasible_first_max {
6701 return Err(PaneModelError::OverconstrainedSplit {
6702 node_id,
6703 axis,
6704 available,
6705 first_min: first.min,
6706 first_max,
6707 second_min: second.min,
6708 second_max,
6709 });
6710 }
6711
6712 let total_weight = u64::from(ratio.numerator()) + u64::from(ratio.denominator());
6713 let desired_first_u64 = (u64::from(available) * u64::from(ratio.numerator())) / total_weight;
6714 let desired_first = desired_first_u64 as u16;
6715
6716 let first_size = desired_first.clamp(feasible_first_min, feasible_first_max);
6717 let second_size = available.saturating_sub(first_size);
6718 Ok((first_size, second_size))
6719}
6720
6721fn dfs_validate(
6722 node_id: PaneId,
6723 nodes: &BTreeMap<PaneId, PaneNodeRecord>,
6724 visiting: &mut BTreeSet<PaneId>,
6725 visited: &mut BTreeSet<PaneId>,
6726) -> Result<(), PaneModelError> {
6727 if visiting.contains(&node_id) {
6728 return Err(PaneModelError::CycleDetected { node_id });
6729 }
6730 if !visited.insert(node_id) {
6731 return Ok(());
6732 }
6733
6734 let _ = visiting.insert(node_id);
6735 if let Some(node) = nodes.get(&node_id)
6736 && let PaneNodeKind::Split(split) = &node.kind
6737 {
6738 dfs_validate(split.first, nodes, visiting, visited)?;
6739 dfs_validate(split.second, nodes, visiting, visited)?;
6740 }
6741 let _ = visiting.remove(&node_id);
6742 Ok(())
6743}
6744
6745fn gcd_u32(mut left: u32, mut right: u32) -> u32 {
6746 while right != 0 {
6747 let rem = left % right;
6748 left = right;
6749 right = rem;
6750 }
6751 left.max(1)
6752}
6753
6754#[cfg(test)]
6755mod tests {
6756 use super::*;
6757 use proptest::prelude::*;
6758
6759 fn id(raw: u64) -> PaneId {
6760 PaneId::new(raw).expect("test ID must be non-zero")
6761 }
6762
6763 fn make_valid_snapshot() -> PaneTreeSnapshot {
6764 let root = id(1);
6765 let left = id(2);
6766 let right = id(3);
6767
6768 PaneTreeSnapshot {
6769 schema_version: PANE_TREE_SCHEMA_VERSION,
6770 root,
6771 next_id: id(4),
6772 nodes: vec![
6773 PaneNodeRecord::leaf(
6774 right,
6775 Some(root),
6776 PaneLeaf {
6777 surface_key: "right".to_string(),
6778 extensions: BTreeMap::new(),
6779 },
6780 ),
6781 PaneNodeRecord::split(
6782 root,
6783 None,
6784 PaneSplit {
6785 axis: SplitAxis::Horizontal,
6786 ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
6787 first: left,
6788 second: right,
6789 },
6790 ),
6791 PaneNodeRecord::leaf(
6792 left,
6793 Some(root),
6794 PaneLeaf {
6795 surface_key: "left".to_string(),
6796 extensions: BTreeMap::new(),
6797 },
6798 ),
6799 ],
6800 extensions: BTreeMap::new(),
6801 }
6802 }
6803
6804 fn make_nested_snapshot() -> PaneTreeSnapshot {
6805 let root = id(1);
6806 let left = id(2);
6807 let right_split = id(3);
6808 let right_top = id(4);
6809 let right_bottom = id(5);
6810
6811 PaneTreeSnapshot {
6812 schema_version: PANE_TREE_SCHEMA_VERSION,
6813 root,
6814 next_id: id(6),
6815 nodes: vec![
6816 PaneNodeRecord::split(
6817 root,
6818 None,
6819 PaneSplit {
6820 axis: SplitAxis::Horizontal,
6821 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
6822 first: left,
6823 second: right_split,
6824 },
6825 ),
6826 PaneNodeRecord::leaf(left, Some(root), PaneLeaf::new("left")),
6827 PaneNodeRecord::split(
6828 right_split,
6829 Some(root),
6830 PaneSplit {
6831 axis: SplitAxis::Vertical,
6832 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
6833 first: right_top,
6834 second: right_bottom,
6835 },
6836 ),
6837 PaneNodeRecord::leaf(right_top, Some(right_split), PaneLeaf::new("right_top")),
6838 PaneNodeRecord::leaf(
6839 right_bottom,
6840 Some(right_split),
6841 PaneLeaf::new("right_bottom"),
6842 ),
6843 ],
6844 extensions: BTreeMap::new(),
6845 }
6846 }
6847
6848 #[test]
6849 fn ratio_is_normalized() {
6850 let ratio = PaneSplitRatio::new(12, 8).expect("ratio should normalize");
6851 assert_eq!(ratio.numerator(), 3);
6852 assert_eq!(ratio.denominator(), 2);
6853 }
6854
6855 #[test]
6856 fn snapshot_round_trip_preserves_canonical_order() {
6857 let tree =
6858 PaneTree::from_snapshot(make_valid_snapshot()).expect("snapshot should validate");
6859 let snapshot = tree.to_snapshot();
6860 let ids = snapshot
6861 .nodes
6862 .iter()
6863 .map(|node| node.id.get())
6864 .collect::<Vec<_>>();
6865 assert_eq!(ids, vec![1, 2, 3]);
6866 }
6867
6868 #[test]
6869 fn duplicate_node_id_is_rejected() {
6870 let mut snapshot = make_valid_snapshot();
6871 snapshot.nodes.push(PaneNodeRecord::leaf(
6872 id(2),
6873 Some(id(1)),
6874 PaneLeaf::new("dup"),
6875 ));
6876 let err = PaneTree::from_snapshot(snapshot).expect_err("duplicate ID should fail");
6877 assert_eq!(err, PaneModelError::DuplicateNodeId { node_id: id(2) });
6878 }
6879
6880 #[test]
6881 fn missing_child_is_rejected() {
6882 let mut snapshot = make_valid_snapshot();
6883 snapshot.nodes.retain(|node| node.id != id(3));
6884 let err = PaneTree::from_snapshot(snapshot).expect_err("missing child should fail");
6885 assert_eq!(
6886 err,
6887 PaneModelError::MissingChild {
6888 parent: id(1),
6889 child: id(3),
6890 }
6891 );
6892 }
6893
6894 #[test]
6895 fn unreachable_node_is_rejected() {
6896 let mut snapshot = make_valid_snapshot();
6897 snapshot
6898 .nodes
6899 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
6900 snapshot.next_id = id(11);
6901 let err = PaneTree::from_snapshot(snapshot).expect_err("orphan should fail");
6902 assert_eq!(err, PaneModelError::UnreachableNode { node_id: id(10) });
6903 }
6904
6905 #[test]
6906 fn next_id_must_be_greater_than_existing_ids() {
6907 let mut snapshot = make_valid_snapshot();
6908 snapshot.next_id = id(3);
6909 let err = PaneTree::from_snapshot(snapshot).expect_err("next_id should be > max ID");
6910 assert_eq!(
6911 err,
6912 PaneModelError::NextIdNotGreaterThanExisting {
6913 next_id: id(3),
6914 max_existing: id(3),
6915 }
6916 );
6917 }
6918
6919 #[test]
6920 fn constraints_validate_bounds() {
6921 let constraints = PaneConstraints {
6922 min_width: 8,
6923 min_height: 1,
6924 max_width: Some(4),
6925 max_height: None,
6926 collapsible: false,
6927 margin: None,
6928 padding: None,
6929 };
6930 let err = constraints
6931 .validate(id(5))
6932 .expect_err("max width below min width must fail");
6933 assert_eq!(
6934 err,
6935 PaneModelError::InvalidConstraint {
6936 node_id: id(5),
6937 axis: "width",
6938 min: 8,
6939 max: 4,
6940 }
6941 );
6942 }
6943
6944 #[test]
6945 fn allocator_is_deterministic() {
6946 let mut allocator = PaneIdAllocator::default();
6947 assert_eq!(allocator.allocate().expect("id 1"), id(1));
6948 assert_eq!(allocator.allocate().expect("id 2"), id(2));
6949 assert_eq!(allocator.peek(), id(3));
6950 }
6951
6952 #[test]
6953 fn snapshot_json_shape_contains_forward_compat_fields() {
6954 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
6955 let json = serde_json::to_value(tree.to_snapshot()).expect("snapshot should serialize");
6956 assert_eq!(json["schema_version"], serde_json::json!(1));
6957 assert!(json.get("extensions").is_some());
6958 let nodes = json["nodes"]
6959 .as_array()
6960 .expect("nodes should serialize as array");
6961 assert_eq!(nodes.len(), 3);
6962 assert!(nodes[0].get("kind").is_some());
6963 }
6964
6965 #[test]
6966 fn solver_horizontal_ratio_split() {
6967 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
6968 let layout = tree
6969 .solve_layout(Rect::new(0, 0, 50, 10))
6970 .expect("layout solve should succeed");
6971
6972 assert_eq!(layout.rect(id(1)), Some(Rect::new(0, 0, 50, 10)));
6973 assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 30, 10)));
6974 assert_eq!(layout.rect(id(3)), Some(Rect::new(30, 0, 20, 10)));
6975 }
6976
6977 #[test]
6978 fn solver_clamps_to_child_minimum_constraints() {
6979 let mut snapshot = make_valid_snapshot();
6980 for node in &mut snapshot.nodes {
6981 if node.id == id(2) {
6982 node.constraints.min_width = 35;
6983 }
6984 }
6985
6986 let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
6987 let layout = tree
6988 .solve_layout(Rect::new(0, 0, 50, 10))
6989 .expect("layout solve should succeed");
6990
6991 assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 35, 10)));
6992 assert_eq!(layout.rect(id(3)), Some(Rect::new(35, 0, 15, 10)));
6993 }
6994
6995 #[test]
6996 fn solver_rejects_overconstrained_split() {
6997 let mut snapshot = make_valid_snapshot();
6998 for node in &mut snapshot.nodes {
6999 if node.id == id(2) {
7000 node.constraints.min_width = 30;
7001 }
7002 if node.id == id(3) {
7003 node.constraints.min_width = 30;
7004 }
7005 }
7006
7007 let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
7008 let err = tree
7009 .solve_layout(Rect::new(0, 0, 50, 10))
7010 .expect_err("infeasible constraints should fail");
7011
7012 assert_eq!(
7013 err,
7014 PaneModelError::OverconstrainedSplit {
7015 node_id: id(1),
7016 axis: SplitAxis::Horizontal,
7017 available: 50,
7018 first_min: 30,
7019 first_max: 50,
7020 second_min: 30,
7021 second_max: 50,
7022 }
7023 );
7024 }
7025
7026 #[test]
7027 fn solver_is_deterministic() {
7028 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
7029 let first = tree
7030 .solve_layout(Rect::new(0, 0, 79, 17))
7031 .expect("first solve should succeed");
7032 let second = tree
7033 .solve_layout(Rect::new(0, 0, 79, 17))
7034 .expect("second solve should succeed");
7035 assert_eq!(first, second);
7036 }
7037
7038 #[test]
7039 fn split_leaf_wraps_existing_leaf_with_new_split() {
7040 let mut tree = PaneTree::singleton("root");
7041 let outcome = tree
7042 .apply_operation(
7043 7,
7044 PaneOperation::SplitLeaf {
7045 target: id(1),
7046 axis: SplitAxis::Horizontal,
7047 ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
7048 placement: PanePlacement::ExistingFirst,
7049 new_leaf: PaneLeaf::new("new"),
7050 },
7051 )
7052 .expect("split should succeed");
7053
7054 assert_eq!(outcome.operation_id, 7);
7055 assert_eq!(outcome.kind, PaneOperationKind::SplitLeaf);
7056 assert_ne!(outcome.before_hash, outcome.after_hash);
7057 assert_eq!(tree.root(), id(2));
7058
7059 let root = tree.node(id(2)).expect("split node exists");
7060 let PaneNodeKind::Split(split) = &root.kind else {
7061 unreachable!("root should be split");
7062 };
7063 assert_eq!(split.first, id(1));
7064 assert_eq!(split.second, id(3));
7065
7066 let original = tree.node(id(1)).expect("original leaf exists");
7067 assert_eq!(original.parent, Some(id(2)));
7068 assert!(matches!(original.kind, PaneNodeKind::Leaf(_)));
7069
7070 let new_leaf = tree.node(id(3)).expect("new leaf exists");
7071 assert_eq!(new_leaf.parent, Some(id(2)));
7072 let PaneNodeKind::Leaf(leaf) = &new_leaf.kind else {
7073 unreachable!("new node must be leaf");
7074 };
7075 assert_eq!(leaf.surface_key, "new");
7076 assert!(tree.validate().is_ok());
7077 }
7078
7079 #[test]
7080 fn close_node_promotes_sibling_and_removes_split_parent() {
7081 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
7082 let outcome = tree
7083 .apply_operation(8, PaneOperation::CloseNode { target: id(2) })
7084 .expect("close should succeed");
7085 assert_eq!(outcome.kind, PaneOperationKind::CloseNode);
7086
7087 assert_eq!(tree.root(), id(3));
7088 assert!(tree.node(id(1)).is_none());
7089 assert!(tree.node(id(2)).is_none());
7090 assert_eq!(tree.node(id(3)).and_then(|node| node.parent), None);
7091 assert!(tree.validate().is_ok());
7092 }
7093
7094 #[test]
7095 fn close_root_is_rejected_with_stable_hashes() {
7096 let mut tree = PaneTree::singleton("root");
7097 let err = tree
7098 .apply_operation(9, PaneOperation::CloseNode { target: id(1) })
7099 .expect_err("closing root must fail");
7100
7101 assert_eq!(err.operation_id, 9);
7102 assert_eq!(err.kind, PaneOperationKind::CloseNode);
7103 assert_eq!(
7104 err.reason,
7105 PaneOperationFailure::CannotCloseRoot { node_id: id(1) }
7106 );
7107 assert_eq!(err.before_hash, err.after_hash);
7108 assert_eq!(tree.root(), id(1));
7109 assert!(tree.validate().is_ok());
7110 }
7111
7112 #[test]
7113 fn move_subtree_wraps_target_and_detaches_old_parent() {
7114 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
7115 let outcome = tree
7116 .apply_operation(
7117 10,
7118 PaneOperation::MoveSubtree {
7119 source: id(4),
7120 target: id(2),
7121 axis: SplitAxis::Vertical,
7122 ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
7123 placement: PanePlacement::ExistingFirst,
7124 },
7125 )
7126 .expect("move should succeed");
7127 assert_eq!(outcome.kind, PaneOperationKind::MoveSubtree);
7128
7129 assert!(
7130 tree.node(id(3)).is_none(),
7131 "old split parent should be removed"
7132 );
7133 assert_eq!(tree.node(id(5)).and_then(|node| node.parent), Some(id(1)));
7134
7135 let inserted_split = tree
7136 .nodes()
7137 .find(|node| matches!(node.kind, PaneNodeKind::Split(_)) && node.id.get() >= 6)
7138 .expect("new split should exist");
7139 let PaneNodeKind::Split(split) = &inserted_split.kind else {
7140 unreachable!();
7141 };
7142 assert_eq!(split.first, id(2));
7143 assert_eq!(split.second, id(4));
7144 assert_eq!(
7145 tree.node(id(2)).and_then(|node| node.parent),
7146 Some(inserted_split.id)
7147 );
7148 assert_eq!(
7149 tree.node(id(4)).and_then(|node| node.parent),
7150 Some(inserted_split.id)
7151 );
7152 assert!(tree.validate().is_ok());
7153 }
7154
7155 #[test]
7156 fn move_subtree_rejects_ancestor_target() {
7157 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
7158 let err = tree
7159 .apply_operation(
7160 11,
7161 PaneOperation::MoveSubtree {
7162 source: id(3),
7163 target: id(4),
7164 axis: SplitAxis::Horizontal,
7165 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
7166 placement: PanePlacement::ExistingFirst,
7167 },
7168 )
7169 .expect_err("ancestor move must fail");
7170
7171 assert_eq!(err.kind, PaneOperationKind::MoveSubtree);
7172 assert_eq!(
7173 err.reason,
7174 PaneOperationFailure::AncestorConflict {
7175 ancestor: id(3),
7176 descendant: id(4),
7177 }
7178 );
7179 assert!(tree.validate().is_ok());
7180 }
7181
7182 #[test]
7183 fn swap_nodes_exchanges_sibling_positions() {
7184 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
7185 let outcome = tree
7186 .apply_operation(
7187 12,
7188 PaneOperation::SwapNodes {
7189 first: id(2),
7190 second: id(3),
7191 },
7192 )
7193 .expect("swap should succeed");
7194 assert_eq!(outcome.kind, PaneOperationKind::SwapNodes);
7195
7196 let root = tree.node(id(1)).expect("root exists");
7197 let PaneNodeKind::Split(split) = &root.kind else {
7198 unreachable!("root should remain split");
7199 };
7200 assert_eq!(split.first, id(3));
7201 assert_eq!(split.second, id(2));
7202 assert_eq!(tree.node(id(2)).and_then(|node| node.parent), Some(id(1)));
7203 assert_eq!(tree.node(id(3)).and_then(|node| node.parent), Some(id(1)));
7204 assert!(tree.validate().is_ok());
7205 }
7206
7207 #[test]
7208 fn swap_nodes_rejects_ancestor_relation() {
7209 let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
7210 let err = tree
7211 .apply_operation(
7212 13,
7213 PaneOperation::SwapNodes {
7214 first: id(3),
7215 second: id(4),
7216 },
7217 )
7218 .expect_err("ancestor swap must fail");
7219
7220 assert_eq!(err.kind, PaneOperationKind::SwapNodes);
7221 assert_eq!(
7222 err.reason,
7223 PaneOperationFailure::AncestorConflict {
7224 ancestor: id(3),
7225 descendant: id(4),
7226 }
7227 );
7228 assert!(tree.validate().is_ok());
7229 }
7230
7231 #[test]
7232 fn normalize_ratios_canonicalizes_non_reduced_values() {
7233 let mut snapshot = make_valid_snapshot();
7234 for node in &mut snapshot.nodes {
7235 if let PaneNodeKind::Split(split) = &mut node.kind {
7236 split.ratio = PaneSplitRatio {
7237 numerator: 12,
7238 denominator: 8,
7239 };
7240 }
7241 }
7242
7243 let mut tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
7244 let outcome = tree
7245 .apply_operation(14, PaneOperation::NormalizeRatios)
7246 .expect("normalize should succeed");
7247 assert_eq!(outcome.kind, PaneOperationKind::NormalizeRatios);
7248
7249 let root = tree.node(id(1)).expect("root exists");
7250 let PaneNodeKind::Split(split) = &root.kind else {
7251 unreachable!("root should be split");
7252 };
7253 assert_eq!(split.ratio.numerator(), 3);
7254 assert_eq!(split.ratio.denominator(), 2);
7255 }
7256
7257 #[test]
7258 fn transaction_commit_persists_mutations_and_journal_order() {
7259 let tree = PaneTree::singleton("root");
7260 let mut tx = tree.begin_transaction(77);
7261
7262 let split = tx
7263 .apply_operation(
7264 100,
7265 PaneOperation::SplitLeaf {
7266 target: id(1),
7267 axis: SplitAxis::Horizontal,
7268 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
7269 placement: PanePlacement::ExistingFirst,
7270 new_leaf: PaneLeaf::new("secondary"),
7271 },
7272 )
7273 .expect("split should succeed");
7274 assert_eq!(split.kind, PaneOperationKind::SplitLeaf);
7275
7276 let normalize = tx
7277 .apply_operation(101, PaneOperation::NormalizeRatios)
7278 .expect("normalize should succeed");
7279 assert_eq!(normalize.kind, PaneOperationKind::NormalizeRatios);
7280
7281 let outcome = tx.commit();
7282 assert!(outcome.committed);
7283 assert_eq!(outcome.transaction_id, 77);
7284 assert_eq!(outcome.tree.root(), id(2));
7285 assert_eq!(outcome.journal.len(), 2);
7286 assert_eq!(outcome.journal[0].sequence, 1);
7287 assert_eq!(outcome.journal[1].sequence, 2);
7288 assert_eq!(outcome.journal[0].operation_id, 100);
7289 assert_eq!(outcome.journal[1].operation_id, 101);
7290 assert_eq!(
7291 outcome.journal[0].result,
7292 PaneOperationJournalResult::Applied
7293 );
7294 assert_eq!(
7295 outcome.journal[1].result,
7296 PaneOperationJournalResult::Applied
7297 );
7298 }
7299
7300 #[test]
7301 fn transaction_rollback_discards_mutations() {
7302 let tree = PaneTree::singleton("root");
7303 let before_hash = tree.state_hash();
7304 let mut tx = tree.begin_transaction(78);
7305
7306 tx.apply_operation(
7307 200,
7308 PaneOperation::SplitLeaf {
7309 target: id(1),
7310 axis: SplitAxis::Vertical,
7311 ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
7312 placement: PanePlacement::ExistingFirst,
7313 new_leaf: PaneLeaf::new("extra"),
7314 },
7315 )
7316 .expect("split should succeed");
7317
7318 let outcome = tx.rollback();
7319 assert!(!outcome.committed);
7320 assert_eq!(outcome.tree.state_hash(), before_hash);
7321 assert_eq!(outcome.tree.root(), id(1));
7322 assert_eq!(outcome.journal.len(), 1);
7323 assert_eq!(outcome.journal[0].operation_id, 200);
7324 }
7325
7326 #[test]
7327 fn transaction_journals_rejected_operation_without_mutation() {
7328 let tree = PaneTree::singleton("root");
7329 let mut tx = tree.begin_transaction(79);
7330 let before_hash = tx.tree().state_hash();
7331
7332 let err = tx
7333 .apply_operation(300, PaneOperation::CloseNode { target: id(1) })
7334 .expect_err("close root should fail");
7335 assert_eq!(err.before_hash, err.after_hash);
7336 assert_eq!(tx.tree().state_hash(), before_hash);
7337
7338 let journal = tx.journal();
7339 assert_eq!(journal.len(), 1);
7340 assert_eq!(journal[0].operation_id, 300);
7341 let PaneOperationJournalResult::Rejected { reason } = &journal[0].result else {
7342 unreachable!("journal entry should be rejected");
7343 };
7344 assert!(reason.contains("cannot close root"));
7345 }
7346
7347 #[test]
7348 fn transaction_journal_is_deterministic_for_equivalent_runs() {
7349 let base = PaneTree::singleton("root");
7350
7351 let mut first_tx = base.begin_transaction(80);
7352 first_tx
7353 .apply_operation(
7354 1,
7355 PaneOperation::SplitLeaf {
7356 target: id(1),
7357 axis: SplitAxis::Horizontal,
7358 ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
7359 placement: PanePlacement::IncomingFirst,
7360 new_leaf: PaneLeaf::new("new"),
7361 },
7362 )
7363 .expect("split should succeed");
7364 first_tx
7365 .apply_operation(2, PaneOperation::NormalizeRatios)
7366 .expect("normalize should succeed");
7367 let first = first_tx.commit();
7368
7369 let mut second_tx = base.begin_transaction(80);
7370 second_tx
7371 .apply_operation(
7372 1,
7373 PaneOperation::SplitLeaf {
7374 target: id(1),
7375 axis: SplitAxis::Horizontal,
7376 ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
7377 placement: PanePlacement::IncomingFirst,
7378 new_leaf: PaneLeaf::new("new"),
7379 },
7380 )
7381 .expect("split should succeed");
7382 second_tx
7383 .apply_operation(2, PaneOperation::NormalizeRatios)
7384 .expect("normalize should succeed");
7385 let second = second_tx.commit();
7386
7387 assert_eq!(first.tree.state_hash(), second.tree.state_hash());
7388 assert_eq!(first.journal, second.journal);
7389 }
7390
7391 #[test]
7392 fn invariant_report_detects_parent_mismatch_and_orphan() {
7393 let mut snapshot = make_valid_snapshot();
7394 for node in &mut snapshot.nodes {
7395 if node.id == id(2) {
7396 node.parent = Some(id(3));
7397 }
7398 }
7399 snapshot
7400 .nodes
7401 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
7402 snapshot.next_id = id(11);
7403
7404 let report = snapshot.invariant_report();
7405 assert!(report.has_errors());
7406 assert!(
7407 report
7408 .issues
7409 .iter()
7410 .any(|issue| issue.code == PaneInvariantCode::ParentMismatch)
7411 );
7412 assert!(
7413 report
7414 .issues
7415 .iter()
7416 .any(|issue| issue.code == PaneInvariantCode::UnreachableNode)
7417 );
7418 }
7419
7420 #[test]
7421 fn repair_safe_normalizes_ratio_repairs_parents_and_removes_orphans() {
7422 let mut snapshot = make_valid_snapshot();
7423 for node in &mut snapshot.nodes {
7424 if node.id == id(1) {
7425 node.parent = Some(id(3));
7426 let PaneNodeKind::Split(split) = &mut node.kind else {
7427 unreachable!("root should be split");
7428 };
7429 split.ratio = PaneSplitRatio {
7430 numerator: 12,
7431 denominator: 8,
7432 };
7433 }
7434 if node.id == id(2) {
7435 node.parent = Some(id(3));
7436 }
7437 }
7438 snapshot
7439 .nodes
7440 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
7441 snapshot.next_id = id(11);
7442
7443 let repaired = snapshot.repair_safe().expect("repair should succeed");
7444 assert_ne!(repaired.before_hash, repaired.after_hash);
7445 assert!(repaired.tree.validate().is_ok());
7446 assert!(!repaired.report_after.has_errors());
7447 assert!(
7448 repaired
7449 .actions
7450 .iter()
7451 .any(|action| matches!(action, PaneRepairAction::NormalizeRatio { node_id, .. } if *node_id == id(1)))
7452 );
7453 assert!(
7454 repaired
7455 .actions
7456 .iter()
7457 .any(|action| matches!(action, PaneRepairAction::ReparentNode { node_id, .. } if *node_id == id(1)))
7458 );
7459 assert!(
7460 repaired
7461 .actions
7462 .iter()
7463 .any(|action| matches!(action, PaneRepairAction::RemoveOrphanNode { node_id } if *node_id == id(10)))
7464 );
7465 }
7466
7467 #[test]
7468 fn repair_safe_rejects_unsafe_topology() {
7469 let mut snapshot = make_valid_snapshot();
7470 snapshot.nodes.retain(|node| node.id != id(3));
7471
7472 let err = snapshot
7473 .repair_safe()
7474 .expect_err("missing-child topology must be rejected");
7475 assert!(matches!(
7476 err.reason,
7477 PaneRepairFailure::UnsafeIssuesPresent { .. }
7478 ));
7479 let PaneRepairFailure::UnsafeIssuesPresent { codes } = err.reason else {
7480 unreachable!("expected unsafe issue failure");
7481 };
7482 assert!(codes.contains(&PaneInvariantCode::MissingChild));
7483 }
7484
7485 #[test]
7486 fn repair_safe_is_deterministic_for_equivalent_snapshot() {
7487 let mut snapshot = make_valid_snapshot();
7488 for node in &mut snapshot.nodes {
7489 if node.id == id(1) {
7490 let PaneNodeKind::Split(split) = &mut node.kind else {
7491 unreachable!("root should be split");
7492 };
7493 split.ratio = PaneSplitRatio {
7494 numerator: 12,
7495 denominator: 8,
7496 };
7497 }
7498 }
7499 snapshot
7500 .nodes
7501 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
7502 snapshot.next_id = id(11);
7503
7504 let first = snapshot.clone().repair_safe().expect("first repair");
7505 let second = snapshot.repair_safe().expect("second repair");
7506
7507 assert_eq!(first.tree.state_hash(), second.tree.state_hash());
7508 assert_eq!(first.actions, second.actions);
7509 assert_eq!(first.report_after, second.report_after);
7510 }
7511
7512 fn default_target() -> PaneResizeTarget {
7513 PaneResizeTarget {
7514 split_id: id(7),
7515 axis: SplitAxis::Horizontal,
7516 }
7517 }
7518
7519 #[test]
7520 fn semantic_input_event_fixture_round_trip_covers_all_variants() {
7521 let mut pointer_down = PaneSemanticInputEvent::new(
7522 1,
7523 PaneSemanticInputEventKind::PointerDown {
7524 target: default_target(),
7525 pointer_id: 11,
7526 button: PanePointerButton::Primary,
7527 position: PanePointerPosition::new(42, 9),
7528 },
7529 );
7530 pointer_down.modifiers = PaneModifierSnapshot {
7531 shift: true,
7532 alt: false,
7533 ctrl: true,
7534 meta: false,
7535 };
7536 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":{}}"#;
7537
7538 let pointer_move = PaneSemanticInputEvent::new(
7539 2,
7540 PaneSemanticInputEventKind::PointerMove {
7541 target: default_target(),
7542 pointer_id: 11,
7543 position: PanePointerPosition::new(45, 8),
7544 delta_x: 3,
7545 delta_y: -1,
7546 },
7547 );
7548 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":{}}"#;
7549
7550 let pointer_up = PaneSemanticInputEvent::new(
7551 3,
7552 PaneSemanticInputEventKind::PointerUp {
7553 target: default_target(),
7554 pointer_id: 11,
7555 button: PanePointerButton::Primary,
7556 position: PanePointerPosition::new(45, 8),
7557 },
7558 );
7559 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":{}}"#;
7560
7561 let wheel_nudge = PaneSemanticInputEvent::new(
7562 4,
7563 PaneSemanticInputEventKind::WheelNudge {
7564 target: default_target(),
7565 lines: -2,
7566 },
7567 );
7568 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":{}}"#;
7569
7570 let keyboard_resize = PaneSemanticInputEvent::new(
7571 5,
7572 PaneSemanticInputEventKind::KeyboardResize {
7573 target: default_target(),
7574 direction: PaneResizeDirection::Increase,
7575 units: 3,
7576 },
7577 );
7578 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":{}}"#;
7579
7580 let cancel = PaneSemanticInputEvent::new(
7581 6,
7582 PaneSemanticInputEventKind::Cancel {
7583 target: Some(default_target()),
7584 reason: PaneCancelReason::PointerCancel,
7585 },
7586 );
7587 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":{}}"#;
7588
7589 let blur =
7590 PaneSemanticInputEvent::new(7, PaneSemanticInputEventKind::Blur { target: None });
7591 let blur_fixture = r#"{"schema_version":1,"sequence":7,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
7592
7593 let fixtures = [
7594 ("pointer_down", pointer_down_fixture, pointer_down),
7595 ("pointer_move", pointer_move_fixture, pointer_move),
7596 ("pointer_up", pointer_up_fixture, pointer_up),
7597 ("wheel_nudge", wheel_nudge_fixture, wheel_nudge),
7598 ("keyboard_resize", keyboard_resize_fixture, keyboard_resize),
7599 ("cancel", cancel_fixture, cancel),
7600 ("blur", blur_fixture, blur),
7601 ];
7602
7603 for (name, fixture, expected) in fixtures {
7604 let parsed: PaneSemanticInputEvent =
7605 serde_json::from_str(fixture).expect("fixture should parse");
7606 assert_eq!(
7607 parsed, expected,
7608 "{name} fixture should match expected shape"
7609 );
7610 parsed.validate().expect("fixture should validate");
7611 let encoded = serde_json::to_string(&parsed).expect("event should encode");
7612 assert_eq!(encoded, fixture, "{name} fixture should be canonical");
7613 }
7614 }
7615
7616 #[test]
7617 fn semantic_input_event_defaults_schema_version_to_current() {
7618 let fixture = r#"{"sequence":9,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
7619 let parsed: PaneSemanticInputEvent =
7620 serde_json::from_str(fixture).expect("fixture should parse");
7621 assert_eq!(
7622 parsed.schema_version,
7623 PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
7624 );
7625 parsed.validate().expect("defaulted event should validate");
7626 }
7627
7628 #[test]
7629 fn semantic_input_event_rejects_invalid_invariants() {
7630 let target = default_target();
7631
7632 let mut schema_version = PaneSemanticInputEvent::new(
7633 1,
7634 PaneSemanticInputEventKind::Blur {
7635 target: Some(target),
7636 },
7637 );
7638 schema_version.schema_version = 99;
7639 assert_eq!(
7640 schema_version.validate(),
7641 Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
7642 version: 99,
7643 expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
7644 })
7645 );
7646
7647 let sequence = PaneSemanticInputEvent::new(
7648 0,
7649 PaneSemanticInputEventKind::Blur {
7650 target: Some(target),
7651 },
7652 );
7653 assert_eq!(
7654 sequence.validate(),
7655 Err(PaneSemanticInputEventError::ZeroSequence)
7656 );
7657
7658 let pointer = PaneSemanticInputEvent::new(
7659 2,
7660 PaneSemanticInputEventKind::PointerDown {
7661 target,
7662 pointer_id: 0,
7663 button: PanePointerButton::Primary,
7664 position: PanePointerPosition::new(0, 0),
7665 },
7666 );
7667 assert_eq!(
7668 pointer.validate(),
7669 Err(PaneSemanticInputEventError::ZeroPointerId)
7670 );
7671
7672 let wheel = PaneSemanticInputEvent::new(
7673 3,
7674 PaneSemanticInputEventKind::WheelNudge { target, lines: 0 },
7675 );
7676 assert_eq!(
7677 wheel.validate(),
7678 Err(PaneSemanticInputEventError::ZeroWheelLines)
7679 );
7680
7681 let keyboard = PaneSemanticInputEvent::new(
7682 4,
7683 PaneSemanticInputEventKind::KeyboardResize {
7684 target,
7685 direction: PaneResizeDirection::Decrease,
7686 units: 0,
7687 },
7688 );
7689 assert_eq!(
7690 keyboard.validate(),
7691 Err(PaneSemanticInputEventError::ZeroResizeUnits)
7692 );
7693 }
7694
7695 #[test]
7696 fn semantic_input_trace_fixture_round_trip_and_checksum_validation() {
7697 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":{}}]}"#;
7698
7699 let parsed: PaneSemanticInputTrace =
7700 serde_json::from_str(fixture).expect("trace fixture should parse");
7701 let checksum_mismatch = parsed
7702 .validate()
7703 .expect_err("fixture checksum=0 should fail validation");
7704 assert!(matches!(
7705 checksum_mismatch,
7706 PaneSemanticInputTraceError::ChecksumMismatch { recorded: 0, .. }
7707 ));
7708
7709 let mut canonical = parsed;
7710 canonical.metadata.checksum = canonical.recompute_checksum();
7711 canonical
7712 .validate()
7713 .expect("canonicalized fixture should validate");
7714 let encoded = serde_json::to_string(&canonical).expect("trace should encode");
7715 let reparsed: PaneSemanticInputTrace =
7716 serde_json::from_str(&encoded).expect("encoded fixture should parse");
7717 assert_eq!(reparsed, canonical);
7718 assert_eq!(reparsed.metadata.checksum, reparsed.recompute_checksum());
7719 }
7720
7721 #[test]
7722 fn semantic_input_trace_rejects_out_of_order_sequence() {
7723 let target = default_target();
7724 let mut trace = PaneSemanticInputTrace::new(
7725 42,
7726 1_700_000_000_111,
7727 "web",
7728 vec![
7729 PaneSemanticInputEvent::new(
7730 1,
7731 PaneSemanticInputEventKind::PointerDown {
7732 target,
7733 pointer_id: 9,
7734 button: PanePointerButton::Primary,
7735 position: PanePointerPosition::new(0, 0),
7736 },
7737 ),
7738 PaneSemanticInputEvent::new(
7739 2,
7740 PaneSemanticInputEventKind::PointerMove {
7741 target,
7742 pointer_id: 9,
7743 position: PanePointerPosition::new(2, 0),
7744 delta_x: 0,
7745 delta_y: 0,
7746 },
7747 ),
7748 PaneSemanticInputEvent::new(
7749 3,
7750 PaneSemanticInputEventKind::PointerUp {
7751 target,
7752 pointer_id: 9,
7753 button: PanePointerButton::Primary,
7754 position: PanePointerPosition::new(2, 0),
7755 },
7756 ),
7757 ],
7758 )
7759 .expect("trace should construct");
7760
7761 trace.events[2].sequence = 2;
7762 trace.metadata.checksum = trace.recompute_checksum();
7763 assert_eq!(
7764 trace.validate(),
7765 Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
7766 index: 2,
7767 previous: 2,
7768 current: 2
7769 })
7770 );
7771 }
7772
7773 #[test]
7774 fn semantic_replay_fixture_runner_produces_diff_artifacts() {
7775 let target = default_target();
7776 let trace = PaneSemanticInputTrace::new(
7777 99,
7778 1_700_000_000_222,
7779 "terminal",
7780 vec![
7781 PaneSemanticInputEvent::new(
7782 1,
7783 PaneSemanticInputEventKind::PointerDown {
7784 target,
7785 pointer_id: 11,
7786 button: PanePointerButton::Primary,
7787 position: PanePointerPosition::new(10, 4),
7788 },
7789 ),
7790 PaneSemanticInputEvent::new(
7791 2,
7792 PaneSemanticInputEventKind::PointerMove {
7793 target,
7794 pointer_id: 11,
7795 position: PanePointerPosition::new(13, 4),
7796 delta_x: 0,
7797 delta_y: 0,
7798 },
7799 ),
7800 PaneSemanticInputEvent::new(
7801 3,
7802 PaneSemanticInputEventKind::PointerMove {
7803 target,
7804 pointer_id: 11,
7805 position: PanePointerPosition::new(15, 6),
7806 delta_x: 0,
7807 delta_y: 0,
7808 },
7809 ),
7810 PaneSemanticInputEvent::new(
7811 4,
7812 PaneSemanticInputEventKind::PointerUp {
7813 target,
7814 pointer_id: 11,
7815 button: PanePointerButton::Primary,
7816 position: PanePointerPosition::new(16, 6),
7817 },
7818 ),
7819 ],
7820 )
7821 .expect("trace should construct");
7822
7823 let mut baseline_machine = PaneDragResizeMachine::default();
7824 let baseline = trace
7825 .replay(&mut baseline_machine)
7826 .expect("baseline replay should pass");
7827 let fixture = PaneSemanticReplayFixture {
7828 trace: trace.clone(),
7829 expected_transitions: baseline.transitions.clone(),
7830 expected_final_state: baseline.final_state,
7831 };
7832
7833 let mut pass_machine = PaneDragResizeMachine::default();
7834 let pass_report = fixture
7835 .run(&mut pass_machine)
7836 .expect("fixture replay should succeed");
7837 assert!(pass_report.passed);
7838 assert!(pass_report.diffs.is_empty());
7839
7840 let mut mismatch_fixture = fixture.clone();
7841 mismatch_fixture.expected_transitions[1].transition_id += 77;
7842 mismatch_fixture.expected_final_state = PaneDragResizeState::Armed {
7843 target,
7844 pointer_id: 11,
7845 origin: PanePointerPosition::new(10, 4),
7846 current: PanePointerPosition::new(10, 4),
7847 started_sequence: 1,
7848 };
7849
7850 let mut mismatch_machine = PaneDragResizeMachine::default();
7851 let mismatch_report = mismatch_fixture
7852 .run(&mut mismatch_machine)
7853 .expect("mismatch replay should still execute");
7854 assert!(!mismatch_report.passed);
7855 assert!(
7856 mismatch_report
7857 .diffs
7858 .iter()
7859 .any(|diff| diff.kind == PaneSemanticReplayDiffKind::TransitionMismatch)
7860 );
7861 assert!(
7862 mismatch_report
7863 .diffs
7864 .iter()
7865 .any(|diff| diff.kind == PaneSemanticReplayDiffKind::FinalStateMismatch)
7866 );
7867 }
7868
7869 fn default_coordinate_normalizer() -> PaneCoordinateNormalizer {
7870 PaneCoordinateNormalizer::new(
7871 PanePointerPosition::new(100, 50),
7872 PanePointerPosition::new(20, 10),
7873 8,
7874 16,
7875 PaneScaleFactor::new(2, 1).expect("valid dpr"),
7876 PaneScaleFactor::ONE,
7877 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
7878 )
7879 .expect("normalizer should be valid")
7880 }
7881
7882 #[test]
7883 fn coordinate_normalizer_css_device_and_cell_pipeline() {
7884 let normalizer = default_coordinate_normalizer();
7885
7886 let css = normalizer
7887 .normalize(PaneInputCoordinate::CssPixels {
7888 position: PanePointerPosition::new(116, 82),
7889 })
7890 .expect("css normalization should succeed");
7891 assert_eq!(
7892 css,
7893 PaneNormalizedCoordinate {
7894 global_cell: PanePointerPosition::new(22, 12),
7895 local_cell: PanePointerPosition::new(2, 2),
7896 local_css: PanePointerPosition::new(16, 32),
7897 }
7898 );
7899
7900 let device = normalizer
7901 .normalize(PaneInputCoordinate::DevicePixels {
7902 position: PanePointerPosition::new(232, 164),
7903 })
7904 .expect("device normalization should match css");
7905 assert_eq!(device, css);
7906
7907 let cell = normalizer
7908 .normalize(PaneInputCoordinate::Cell {
7909 position: PanePointerPosition::new(3, 1),
7910 })
7911 .expect("cell normalization should succeed");
7912 assert_eq!(
7913 cell,
7914 PaneNormalizedCoordinate {
7915 global_cell: PanePointerPosition::new(23, 11),
7916 local_cell: PanePointerPosition::new(3, 1),
7917 local_css: PanePointerPosition::new(24, 16),
7918 }
7919 );
7920 }
7921
7922 #[test]
7923 fn coordinate_normalizer_zoom_and_rounding_tie_breaks_are_deterministic() {
7924 let zoomed = PaneCoordinateNormalizer::new(
7925 PanePointerPosition::new(100, 50),
7926 PanePointerPosition::new(0, 0),
7927 8,
7928 8,
7929 PaneScaleFactor::ONE,
7930 PaneScaleFactor::new(5, 4).expect("valid zoom"),
7931 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
7932 )
7933 .expect("zoomed normalizer should be valid");
7934
7935 let zoomed_point = zoomed
7936 .normalize(PaneInputCoordinate::CssPixels {
7937 position: PanePointerPosition::new(120, 70),
7938 })
7939 .expect("zoomed normalization should succeed");
7940 assert_eq!(zoomed_point.local_css, PanePointerPosition::new(16, 16));
7941 assert_eq!(zoomed_point.local_cell, PanePointerPosition::new(2, 2));
7942
7943 let nearest = PaneCoordinateNormalizer::new(
7944 PanePointerPosition::new(0, 0),
7945 PanePointerPosition::new(0, 0),
7946 10,
7947 10,
7948 PaneScaleFactor::ONE,
7949 PaneScaleFactor::ONE,
7950 PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity,
7951 )
7952 .expect("nearest normalizer should be valid");
7953
7954 let positive_tie = nearest
7955 .normalize(PaneInputCoordinate::CssPixels {
7956 position: PanePointerPosition::new(15, 0),
7957 })
7958 .expect("positive tie should normalize");
7959 let positive_above_tie = nearest
7960 .normalize(PaneInputCoordinate::CssPixels {
7961 position: PanePointerPosition::new(16, 0),
7962 })
7963 .expect("positive > half should normalize");
7964 let negative_tie = nearest
7965 .normalize(PaneInputCoordinate::CssPixels {
7966 position: PanePointerPosition::new(-15, 0),
7967 })
7968 .expect("negative tie should normalize");
7969
7970 assert_eq!(positive_tie.local_cell.x, 1);
7971 assert_eq!(positive_above_tie.local_cell.x, 2);
7972 assert_eq!(negative_tie.local_cell.x, -2);
7973 }
7974
7975 #[test]
7976 fn coordinate_normalizer_rejects_invalid_configuration() {
7977 assert_eq!(
7978 PaneScaleFactor::new(0, 1).expect_err("zero numerator must fail"),
7979 PaneCoordinateNormalizationError::InvalidScaleFactor {
7980 field: "scale_factor",
7981 numerator: 0,
7982 denominator: 1,
7983 }
7984 );
7985
7986 let err = PaneCoordinateNormalizer::new(
7987 PanePointerPosition::new(0, 0),
7988 PanePointerPosition::new(0, 0),
7989 0,
7990 10,
7991 PaneScaleFactor::ONE,
7992 PaneScaleFactor::ONE,
7993 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
7994 )
7995 .expect_err("zero width must fail");
7996 assert_eq!(
7997 err,
7998 PaneCoordinateNormalizationError::InvalidCellSize {
7999 width: 0,
8000 height: 10,
8001 }
8002 );
8003 }
8004
8005 #[test]
8006 fn coordinate_normalizer_repeated_device_updates_do_not_drift() {
8007 let normalizer = PaneCoordinateNormalizer::new(
8008 PanePointerPosition::new(0, 0),
8009 PanePointerPosition::new(0, 0),
8010 7,
8011 11,
8012 PaneScaleFactor::new(3, 2).expect("valid dpr"),
8013 PaneScaleFactor::new(5, 4).expect("valid zoom"),
8014 PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
8015 )
8016 .expect("normalizer should be valid");
8017
8018 let mut prev = i32::MIN;
8019 for x in 150..190 {
8020 let first = normalizer
8021 .normalize(PaneInputCoordinate::DevicePixels {
8022 position: PanePointerPosition::new(x, 0),
8023 })
8024 .expect("first normalization should succeed");
8025 let second = normalizer
8026 .normalize(PaneInputCoordinate::DevicePixels {
8027 position: PanePointerPosition::new(x, 0),
8028 })
8029 .expect("second normalization should succeed");
8030
8031 assert_eq!(
8032 first, second,
8033 "normalization should be stable for same input"
8034 );
8035 assert!(
8036 first.global_cell.x >= prev,
8037 "cell coordinate should be monotonic"
8038 );
8039 if prev != i32::MIN {
8040 assert!(
8041 first.global_cell.x - prev <= 1,
8042 "cell coordinate should not jump by more than one per pixel step"
8043 );
8044 }
8045 prev = first.global_cell.x;
8046 }
8047 }
8048
8049 #[test]
8050 fn snap_tuning_is_deterministic_with_tie_breaks_and_hysteresis() {
8051 let tuning = PaneSnapTuning::default();
8052
8053 let tie = tuning.decide(3_250, None);
8054 assert_eq!(tie.nearest_ratio_bps, 3_000);
8055 assert_eq!(tie.snapped_ratio_bps, None);
8056 assert_eq!(tie.reason, PaneSnapReason::UnsnapOutsideWindow);
8057
8058 let snap = tuning.decide(3_499, None);
8059 assert_eq!(snap.nearest_ratio_bps, 3_500);
8060 assert_eq!(snap.snapped_ratio_bps, Some(3_500));
8061 assert_eq!(snap.reason, PaneSnapReason::SnappedNearest);
8062
8063 let retain = tuning.decide(3_390, Some(3_500));
8064 assert_eq!(retain.snapped_ratio_bps, Some(3_500));
8065 assert_eq!(retain.reason, PaneSnapReason::RetainedPrevious);
8066
8067 assert_eq!(
8068 PaneSnapTuning::new(0, 125).expect_err("step=0 must fail"),
8069 PaneInteractionPolicyError::InvalidSnapTuning {
8070 step_bps: 0,
8071 hysteresis_bps: 125
8072 }
8073 );
8074 }
8075
8076 #[test]
8077 fn precision_policy_applies_axis_lock_and_mode_scaling() {
8078 let fine = PanePrecisionPolicy::from_modifiers(
8079 PaneModifierSnapshot {
8080 shift: true,
8081 alt: true,
8082 ctrl: false,
8083 meta: false,
8084 },
8085 SplitAxis::Horizontal,
8086 );
8087 assert_eq!(fine.mode, PanePrecisionMode::Fine);
8088 assert_eq!(fine.axis_lock, Some(SplitAxis::Horizontal));
8089 assert_eq!(fine.apply_delta(5, 3).expect("fine delta"), (2, 0));
8090
8091 let coarse = PanePrecisionPolicy::from_modifiers(
8092 PaneModifierSnapshot {
8093 shift: false,
8094 alt: false,
8095 ctrl: true,
8096 meta: false,
8097 },
8098 SplitAxis::Vertical,
8099 );
8100 assert_eq!(coarse.mode, PanePrecisionMode::Coarse);
8101 assert_eq!(coarse.axis_lock, None);
8102 assert_eq!(coarse.apply_delta(2, -3).expect("coarse delta"), (4, -6));
8103 }
8104
8105 #[test]
8106 fn drag_behavior_tuning_validates_and_threshold_helpers_are_stable() {
8107 let tuning = PaneDragBehaviorTuning::new(3, 2, PaneSnapTuning::default())
8108 .expect("valid tuning should construct");
8109 assert!(tuning.should_start_drag(
8110 PanePointerPosition::new(0, 0),
8111 PanePointerPosition::new(3, 0)
8112 ));
8113 assert!(!tuning.should_start_drag(
8114 PanePointerPosition::new(0, 0),
8115 PanePointerPosition::new(2, 0)
8116 ));
8117 assert!(tuning.should_emit_drag_update(
8118 PanePointerPosition::new(10, 10),
8119 PanePointerPosition::new(12, 10)
8120 ));
8121 assert!(!tuning.should_emit_drag_update(
8122 PanePointerPosition::new(10, 10),
8123 PanePointerPosition::new(11, 10)
8124 ));
8125
8126 assert_eq!(
8127 PaneDragBehaviorTuning::new(0, 2, PaneSnapTuning::default())
8128 .expect_err("activation threshold=0 must fail"),
8129 PaneInteractionPolicyError::InvalidThreshold {
8130 field: "activation_threshold",
8131 value: 0
8132 }
8133 );
8134 assert_eq!(
8135 PaneDragBehaviorTuning::new(2, 0, PaneSnapTuning::default())
8136 .expect_err("hysteresis=0 must fail"),
8137 PaneInteractionPolicyError::InvalidThreshold {
8138 field: "update_hysteresis",
8139 value: 0
8140 }
8141 );
8142 }
8143
8144 fn pointer_down_event(
8145 sequence: u64,
8146 target: PaneResizeTarget,
8147 pointer_id: u32,
8148 x: i32,
8149 y: i32,
8150 ) -> PaneSemanticInputEvent {
8151 PaneSemanticInputEvent::new(
8152 sequence,
8153 PaneSemanticInputEventKind::PointerDown {
8154 target,
8155 pointer_id,
8156 button: PanePointerButton::Primary,
8157 position: PanePointerPosition::new(x, y),
8158 },
8159 )
8160 }
8161
8162 fn pointer_move_event(
8163 sequence: u64,
8164 target: PaneResizeTarget,
8165 pointer_id: u32,
8166 x: i32,
8167 y: i32,
8168 ) -> PaneSemanticInputEvent {
8169 PaneSemanticInputEvent::new(
8170 sequence,
8171 PaneSemanticInputEventKind::PointerMove {
8172 target,
8173 pointer_id,
8174 position: PanePointerPosition::new(x, y),
8175 delta_x: 0,
8176 delta_y: 0,
8177 },
8178 )
8179 }
8180
8181 fn pointer_up_event(
8182 sequence: u64,
8183 target: PaneResizeTarget,
8184 pointer_id: u32,
8185 x: i32,
8186 y: i32,
8187 ) -> PaneSemanticInputEvent {
8188 PaneSemanticInputEvent::new(
8189 sequence,
8190 PaneSemanticInputEventKind::PointerUp {
8191 target,
8192 pointer_id,
8193 button: PanePointerButton::Primary,
8194 position: PanePointerPosition::new(x, y),
8195 },
8196 )
8197 }
8198
8199 #[test]
8200 fn drag_resize_machine_full_lifecycle_commit() {
8201 let mut machine = PaneDragResizeMachine::default();
8202 let target = default_target();
8203
8204 let down = machine
8205 .apply_event(&pointer_down_event(1, target, 10, 10, 4))
8206 .expect("down should arm");
8207 assert_eq!(down.transition_id, 1);
8208 assert_eq!(down.sequence, 1);
8209 assert_eq!(machine.state(), down.to);
8210 assert!(matches!(
8211 down.effect,
8212 PaneDragResizeEffect::Armed {
8213 target: t,
8214 pointer_id: 10,
8215 origin: PanePointerPosition { x: 10, y: 4 }
8216 } if t == target
8217 ));
8218
8219 let below_threshold = machine
8220 .apply_event(&pointer_move_event(2, target, 10, 11, 4))
8221 .expect("small move should not start drag");
8222 assert_eq!(
8223 below_threshold.effect,
8224 PaneDragResizeEffect::Noop {
8225 reason: PaneDragResizeNoopReason::ThresholdNotReached
8226 }
8227 );
8228 assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
8229
8230 let drag_start = machine
8231 .apply_event(&pointer_move_event(3, target, 10, 13, 4))
8232 .expect("large move should start drag");
8233 assert!(matches!(
8234 drag_start.effect,
8235 PaneDragResizeEffect::DragStarted {
8236 target: t,
8237 pointer_id: 10,
8238 total_delta_x: 3,
8239 total_delta_y: 0,
8240 ..
8241 } if t == target
8242 ));
8243 assert!(matches!(
8244 machine.state(),
8245 PaneDragResizeState::Dragging { .. }
8246 ));
8247
8248 let drag_update = machine
8249 .apply_event(&pointer_move_event(4, target, 10, 15, 6))
8250 .expect("drag move should update");
8251 assert!(matches!(
8252 drag_update.effect,
8253 PaneDragResizeEffect::DragUpdated {
8254 target: t,
8255 pointer_id: 10,
8256 delta_x: 2,
8257 delta_y: 2,
8258 total_delta_x: 5,
8259 total_delta_y: 2,
8260 ..
8261 } if t == target
8262 ));
8263
8264 let commit = machine
8265 .apply_event(&pointer_up_event(5, target, 10, 16, 6))
8266 .expect("up should commit drag");
8267 assert!(matches!(
8268 commit.effect,
8269 PaneDragResizeEffect::Committed {
8270 target: t,
8271 pointer_id: 10,
8272 total_delta_x: 6,
8273 total_delta_y: 2,
8274 ..
8275 } if t == target
8276 ));
8277 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8278 }
8279
8280 #[test]
8281 fn drag_resize_machine_cancel_and_blur_paths_are_reason_coded() {
8282 let target = default_target();
8283
8284 let mut cancel_machine = PaneDragResizeMachine::default();
8285 cancel_machine
8286 .apply_event(&pointer_down_event(1, target, 1, 2, 2))
8287 .expect("down should arm");
8288 let cancel = cancel_machine
8289 .apply_event(&PaneSemanticInputEvent::new(
8290 2,
8291 PaneSemanticInputEventKind::Cancel {
8292 target: Some(target),
8293 reason: PaneCancelReason::FocusLost,
8294 },
8295 ))
8296 .expect("cancel should reset to idle");
8297 assert_eq!(cancel_machine.state(), PaneDragResizeState::Idle);
8298 assert_eq!(
8299 cancel.effect,
8300 PaneDragResizeEffect::Canceled {
8301 target: Some(target),
8302 pointer_id: Some(1),
8303 reason: PaneCancelReason::FocusLost
8304 }
8305 );
8306
8307 let mut blur_machine = PaneDragResizeMachine::default();
8308 blur_machine
8309 .apply_event(&pointer_down_event(3, target, 2, 5, 5))
8310 .expect("down should arm");
8311 blur_machine
8312 .apply_event(&pointer_move_event(4, target, 2, 8, 5))
8313 .expect("move should start dragging");
8314 let blur = blur_machine
8315 .apply_event(&PaneSemanticInputEvent::new(
8316 5,
8317 PaneSemanticInputEventKind::Blur {
8318 target: Some(target),
8319 },
8320 ))
8321 .expect("blur should cancel active drag");
8322 assert_eq!(blur_machine.state(), PaneDragResizeState::Idle);
8323 assert_eq!(
8324 blur.effect,
8325 PaneDragResizeEffect::Canceled {
8326 target: Some(target),
8327 pointer_id: Some(2),
8328 reason: PaneCancelReason::Blur
8329 }
8330 );
8331 }
8332
8333 #[test]
8334 fn drag_resize_machine_duplicate_end_and_pointer_mismatch_are_safe_noops() {
8335 let mut machine = PaneDragResizeMachine::default();
8336 let target = default_target();
8337
8338 machine
8339 .apply_event(&pointer_down_event(1, target, 9, 0, 0))
8340 .expect("down should arm");
8341
8342 let mismatch = machine
8343 .apply_event(&pointer_move_event(2, target, 99, 3, 0))
8344 .expect("mismatch should be ignored");
8345 assert_eq!(
8346 mismatch.effect,
8347 PaneDragResizeEffect::Noop {
8348 reason: PaneDragResizeNoopReason::PointerMismatch
8349 }
8350 );
8351 assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
8352
8353 machine
8354 .apply_event(&pointer_move_event(3, target, 9, 3, 0))
8355 .expect("drag should start");
8356 machine
8357 .apply_event(&pointer_up_event(4, target, 9, 3, 0))
8358 .expect("up should commit");
8359 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8360
8361 let duplicate_end = machine
8362 .apply_event(&pointer_up_event(5, target, 9, 3, 0))
8363 .expect("duplicate end should noop");
8364 assert_eq!(
8365 duplicate_end.effect,
8366 PaneDragResizeEffect::Noop {
8367 reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag
8368 }
8369 );
8370 }
8371
8372 #[test]
8373 fn drag_resize_machine_discrete_inputs_in_idle_and_validation_errors() {
8374 let mut machine = PaneDragResizeMachine::default();
8375 let target = default_target();
8376
8377 let keyboard = machine
8378 .apply_event(&PaneSemanticInputEvent::new(
8379 1,
8380 PaneSemanticInputEventKind::KeyboardResize {
8381 target,
8382 direction: PaneResizeDirection::Increase,
8383 units: 2,
8384 },
8385 ))
8386 .expect("keyboard resize should apply in idle");
8387 assert_eq!(
8388 keyboard.effect,
8389 PaneDragResizeEffect::KeyboardApplied {
8390 target,
8391 direction: PaneResizeDirection::Increase,
8392 units: 2
8393 }
8394 );
8395 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8396
8397 let wheel = machine
8398 .apply_event(&PaneSemanticInputEvent::new(
8399 2,
8400 PaneSemanticInputEventKind::WheelNudge { target, lines: -1 },
8401 ))
8402 .expect("wheel nudge should apply in idle");
8403 assert_eq!(
8404 wheel.effect,
8405 PaneDragResizeEffect::WheelApplied { target, lines: -1 }
8406 );
8407
8408 let invalid_pointer = PaneSemanticInputEvent::new(
8409 3,
8410 PaneSemanticInputEventKind::PointerDown {
8411 target,
8412 pointer_id: 0,
8413 button: PanePointerButton::Primary,
8414 position: PanePointerPosition::new(0, 0),
8415 },
8416 );
8417 let err = machine
8418 .apply_event(&invalid_pointer)
8419 .expect_err("invalid input should be rejected");
8420 assert_eq!(
8421 err,
8422 PaneDragResizeMachineError::InvalidEvent(PaneSemanticInputEventError::ZeroPointerId)
8423 );
8424
8425 assert_eq!(
8426 PaneDragResizeMachine::new(0).expect_err("zero threshold should fail"),
8427 PaneDragResizeMachineError::InvalidDragThreshold { threshold: 0 }
8428 );
8429 }
8430
8431 #[test]
8432 fn drag_resize_machine_hysteresis_suppresses_micro_jitter() {
8433 let target = default_target();
8434 let mut machine = PaneDragResizeMachine::new_with_hysteresis(2, 2)
8435 .expect("explicit machine tuning should construct");
8436 machine
8437 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
8438 .expect("down should arm");
8439 machine
8440 .apply_event(&pointer_move_event(2, target, 22, 2, 0))
8441 .expect("move should start dragging");
8442
8443 let jitter = machine
8444 .apply_event(&pointer_move_event(3, target, 22, 3, 0))
8445 .expect("small move should be ignored");
8446 assert_eq!(
8447 jitter.effect,
8448 PaneDragResizeEffect::Noop {
8449 reason: PaneDragResizeNoopReason::BelowHysteresis
8450 }
8451 );
8452
8453 let update = machine
8454 .apply_event(&pointer_move_event(4, target, 22, 4, 0))
8455 .expect("larger move should update drag");
8456 assert!(matches!(
8457 update.effect,
8458 PaneDragResizeEffect::DragUpdated { .. }
8459 ));
8460 assert_eq!(
8461 PaneDragResizeMachine::new_with_hysteresis(2, 0)
8462 .expect_err("zero hysteresis must fail"),
8463 PaneDragResizeMachineError::InvalidUpdateHysteresis { hysteresis: 0 }
8464 );
8465 }
8466
8467 #[test]
8472 fn force_cancel_idle_is_noop() {
8473 let mut machine = PaneDragResizeMachine::default();
8474 assert!(!machine.is_active());
8475 assert!(machine.force_cancel().is_none());
8476 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8477 }
8478
8479 #[test]
8480 fn force_cancel_from_armed_resets_to_idle() {
8481 let target = default_target();
8482 let mut machine = PaneDragResizeMachine::default();
8483 machine
8484 .apply_event(&pointer_down_event(1, target, 22, 5, 5))
8485 .expect("down should arm");
8486 assert!(machine.is_active());
8487
8488 let transition = machine
8489 .force_cancel()
8490 .expect("armed machine should produce transition");
8491 assert_eq!(transition.to, PaneDragResizeState::Idle);
8492 assert!(matches!(
8493 transition.effect,
8494 PaneDragResizeEffect::Canceled {
8495 reason: PaneCancelReason::Programmatic,
8496 ..
8497 }
8498 ));
8499 assert!(!machine.is_active());
8500 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8501 }
8502
8503 #[test]
8504 fn force_cancel_from_dragging_resets_to_idle() {
8505 let target = default_target();
8506 let mut machine = PaneDragResizeMachine::default();
8507 machine
8508 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
8509 .expect("down");
8510 machine
8511 .apply_event(&pointer_move_event(2, target, 22, 5, 0))
8512 .expect("move past threshold to start drag");
8513 assert!(matches!(
8514 machine.state(),
8515 PaneDragResizeState::Dragging { .. }
8516 ));
8517 assert!(machine.is_active());
8518
8519 let transition = machine
8520 .force_cancel()
8521 .expect("dragging machine should produce transition");
8522 assert_eq!(transition.to, PaneDragResizeState::Idle);
8523 assert!(matches!(
8524 transition.effect,
8525 PaneDragResizeEffect::Canceled {
8526 target: Some(_),
8527 pointer_id: Some(22),
8528 reason: PaneCancelReason::Programmatic,
8529 }
8530 ));
8531 assert!(!machine.is_active());
8532 }
8533
8534 #[test]
8535 fn force_cancel_is_idempotent() {
8536 let target = default_target();
8537 let mut machine = PaneDragResizeMachine::default();
8538 machine
8539 .apply_event(&pointer_down_event(1, target, 22, 5, 5))
8540 .expect("down should arm");
8541
8542 let first = machine.force_cancel();
8543 assert!(first.is_some());
8544 let second = machine.force_cancel();
8545 assert!(second.is_none());
8546 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8547 }
8548
8549 #[test]
8550 fn force_cancel_preserves_transition_counter_monotonicity() {
8551 let target = default_target();
8552 let mut machine = PaneDragResizeMachine::default();
8553
8554 let t1 = machine
8555 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
8556 .expect("arm");
8557 let t2 = machine.force_cancel().expect("force cancel from armed");
8558 assert!(t2.transition_id > t1.transition_id);
8559
8560 let t3 = machine
8562 .apply_event(&pointer_down_event(2, target, 22, 10, 10))
8563 .expect("re-arm");
8564 let t4 = machine.force_cancel().expect("second force cancel");
8565 assert!(t3.transition_id > t2.transition_id);
8566 assert!(t4.transition_id > t3.transition_id);
8567 }
8568
8569 #[test]
8570 fn force_cancel_records_prior_state_in_from_field() {
8571 let target = default_target();
8572 let mut machine = PaneDragResizeMachine::default();
8573 machine
8574 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
8575 .expect("arm");
8576
8577 let armed_state = machine.state();
8578 let transition = machine.force_cancel().expect("force cancel");
8579 assert_eq!(transition.from, armed_state);
8580 }
8581
8582 #[test]
8583 fn machine_usable_after_force_cancel() {
8584 let target = default_target();
8585 let mut machine = PaneDragResizeMachine::default();
8586
8587 machine
8589 .apply_event(&pointer_down_event(1, target, 22, 0, 0))
8590 .expect("arm");
8591 machine.force_cancel();
8592
8593 machine
8594 .apply_event(&pointer_down_event(2, target, 22, 10, 10))
8595 .expect("re-arm after force cancel");
8596 machine
8597 .apply_event(&pointer_move_event(3, target, 22, 15, 10))
8598 .expect("move to drag");
8599 let commit = machine
8600 .apply_event(&pointer_up_event(4, target, 22, 15, 10))
8601 .expect("commit");
8602 assert!(matches!(
8603 commit.effect,
8604 PaneDragResizeEffect::Committed { .. }
8605 ));
8606 assert_eq!(machine.state(), PaneDragResizeState::Idle);
8607 }
8608
8609 proptest! {
8610 #[test]
8611 fn ratio_is_always_reduced(numerator in 1u32..100_000, denominator in 1u32..100_000) {
8612 let ratio = PaneSplitRatio::new(numerator, denominator).expect("positive ratio must be valid");
8613 let gcd = gcd_u32(ratio.numerator(), ratio.denominator());
8614 prop_assert_eq!(gcd, 1);
8615 }
8616
8617 #[test]
8618 fn allocator_produces_monotonic_ids(
8619 start in 1u64..1_000_000,
8620 count in 1usize..64,
8621 ) {
8622 let mut allocator = PaneIdAllocator::with_next(PaneId::new(start).expect("start must be valid"));
8623 let mut prev = 0u64;
8624 for _ in 0..count {
8625 let current = allocator.allocate().expect("allocation must succeed").get();
8626 prop_assert!(current > prev);
8627 prev = current;
8628 }
8629 }
8630
8631 #[test]
8632 fn split_solver_preserves_available_space(
8633 numerator in 1u32..64,
8634 denominator in 1u32..64,
8635 first_min in 0u16..40,
8636 second_min in 0u16..40,
8637 available in 0u16..80,
8638 ) {
8639 let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
8640 prop_assume!(first_min.saturating_add(second_min) <= available);
8641
8642 let (first_size, second_size) = solve_split_sizes(
8643 id(1),
8644 SplitAxis::Horizontal,
8645 available,
8646 ratio,
8647 AxisBounds { min: first_min, max: None },
8648 AxisBounds { min: second_min, max: None },
8649 ).expect("feasible split should solve");
8650
8651 prop_assert_eq!(first_size.saturating_add(second_size), available);
8652 prop_assert!(first_size >= first_min);
8653 prop_assert!(second_size >= second_min);
8654 }
8655
8656 #[test]
8657 fn split_then_close_round_trip_preserves_validity(
8658 numerator in 1u32..32,
8659 denominator in 1u32..32,
8660 incoming_first in any::<bool>(),
8661 ) {
8662 let mut tree = PaneTree::singleton("root");
8663 let placement = if incoming_first {
8664 PanePlacement::IncomingFirst
8665 } else {
8666 PanePlacement::ExistingFirst
8667 };
8668 let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
8669
8670 tree.apply_operation(
8671 1,
8672 PaneOperation::SplitLeaf {
8673 target: id(1),
8674 axis: SplitAxis::Horizontal,
8675 ratio,
8676 placement,
8677 new_leaf: PaneLeaf::new("extra"),
8678 },
8679 ).expect("split should succeed");
8680
8681 let split_root_id = tree.root();
8682 let split_root = tree.node(split_root_id).expect("split root exists");
8683 let PaneNodeKind::Split(split) = &split_root.kind else {
8684 unreachable!("root should be split");
8685 };
8686 let extra_leaf_id = if split.first == id(1) {
8687 split.second
8688 } else {
8689 split.first
8690 };
8691
8692 tree.apply_operation(2, PaneOperation::CloseNode { target: extra_leaf_id })
8693 .expect("close should succeed");
8694
8695 prop_assert_eq!(tree.root(), id(1));
8696 prop_assert!(matches!(
8697 tree.node(id(1)).map(|node| &node.kind),
8698 Some(PaneNodeKind::Leaf(_))
8699 ));
8700 prop_assert!(tree.validate().is_ok());
8701 }
8702
8703 #[test]
8704 fn transaction_rollback_restores_initial_state_hash(
8705 numerator in 1u32..64,
8706 denominator in 1u32..64,
8707 incoming_first in any::<bool>(),
8708 ) {
8709 let base = PaneTree::singleton("root");
8710 let initial_hash = base.state_hash();
8711 let mut tx = base.begin_transaction(90);
8712 let placement = if incoming_first {
8713 PanePlacement::IncomingFirst
8714 } else {
8715 PanePlacement::ExistingFirst
8716 };
8717
8718 tx.apply_operation(
8719 1,
8720 PaneOperation::SplitLeaf {
8721 target: id(1),
8722 axis: SplitAxis::Horizontal,
8723 ratio: PaneSplitRatio::new(numerator, denominator).expect("valid ratio"),
8724 placement,
8725 new_leaf: PaneLeaf::new("new"),
8726 },
8727 ).expect("split should succeed");
8728
8729 let rolled_back = tx.rollback();
8730 prop_assert_eq!(rolled_back.tree.state_hash(), initial_hash);
8731 prop_assert_eq!(rolled_back.tree.root(), id(1));
8732 prop_assert!(rolled_back.tree.validate().is_ok());
8733 }
8734
8735 #[test]
8736 fn repair_safe_is_deterministic_under_recoverable_damage(
8737 numerator in 1u32..32,
8738 denominator in 1u32..32,
8739 add_orphan in any::<bool>(),
8740 mismatch_parent in any::<bool>(),
8741 ) {
8742 let mut snapshot = make_valid_snapshot();
8743 for node in &mut snapshot.nodes {
8744 if node.id == id(1) {
8745 let PaneNodeKind::Split(split) = &mut node.kind else {
8746 unreachable!("root should be split");
8747 };
8748 split.ratio = PaneSplitRatio {
8749 numerator: numerator.saturating_mul(2),
8750 denominator: denominator.saturating_mul(2),
8751 };
8752 }
8753 if mismatch_parent && node.id == id(2) {
8754 node.parent = Some(id(3));
8755 }
8756 }
8757 if add_orphan {
8758 snapshot
8759 .nodes
8760 .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
8761 snapshot.next_id = id(11);
8762 }
8763
8764 let first = snapshot.clone().repair_safe().expect("first repair should succeed");
8765 let second = snapshot.repair_safe().expect("second repair should succeed");
8766
8767 prop_assert_eq!(first.tree.state_hash(), second.tree.state_hash());
8768 prop_assert_eq!(first.actions, second.actions);
8769 prop_assert_eq!(first.report_after, second.report_after);
8770 }
8771 }
8772
8773 #[test]
8774 fn set_split_ratio_operation_updates_existing_split() {
8775 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
8776 tree.apply_operation(
8777 900,
8778 PaneOperation::SetSplitRatio {
8779 split: id(1),
8780 ratio: PaneSplitRatio::new(5, 3).expect("valid ratio"),
8781 },
8782 )
8783 .expect("set split ratio should succeed");
8784
8785 let root = tree.node(id(1)).expect("root exists");
8786 let PaneNodeKind::Split(split) = &root.kind else {
8787 unreachable!("root should be split");
8788 };
8789 assert_eq!(split.ratio.numerator(), 5);
8790 assert_eq!(split.ratio.denominator(), 3);
8791 }
8792
8793 #[test]
8794 fn layout_classifies_any_edge_grips_and_edge_resize_plans_apply() {
8795 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
8796 let layout = tree
8797 .solve_layout(Rect::new(0, 0, 120, 48))
8798 .expect("layout should solve");
8799 let left_rect = layout.rect(id(2)).expect("leaf 2 rect");
8800 let pointer = PanePointerPosition::new(
8801 i32::from(
8802 left_rect
8803 .x
8804 .saturating_add(left_rect.width.saturating_sub(1)),
8805 ),
8806 i32::from(left_rect.y.saturating_add(left_rect.height / 2)),
8807 );
8808 let grip = layout
8809 .classify_resize_grip(id(2), pointer, PANE_EDGE_GRIP_INSET_CELLS)
8810 .expect("grip should classify");
8811 assert!(matches!(
8812 grip,
8813 PaneResizeGrip::Right | PaneResizeGrip::TopRight | PaneResizeGrip::BottomRight
8814 ));
8815
8816 let plan = tree
8817 .plan_edge_resize(
8818 id(2),
8819 &layout,
8820 grip,
8821 pointer,
8822 PanePressureSnapProfile {
8823 strength_bps: 8_000,
8824 hysteresis_bps: 250,
8825 },
8826 )
8827 .expect("edge resize plan should build");
8828 assert!(!plan.operations.is_empty());
8829 tree.apply_edge_resize_plan(901, &plan)
8830 .expect("edge resize plan should apply");
8831 assert!(tree.validate().is_ok());
8832 }
8833
8834 #[test]
8835 fn pane_layout_visual_rect_applies_default_margin_and_padding() {
8836 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
8837 let layout = tree
8838 .solve_layout(Rect::new(0, 0, 120, 48))
8839 .expect("layout should solve");
8840 let raw = layout.rect(id(2)).expect("leaf rect exists");
8841 let visual = layout.visual_rect(id(2)).expect("visual rect exists");
8842 assert!(visual.width <= raw.width);
8843 assert!(visual.height <= raw.height);
8844 assert!(visual.width > 0);
8845 assert!(visual.height > 0);
8846 }
8847
8848 #[test]
8849 fn magnetic_docking_preview_and_reflow_plan_are_generated() {
8850 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
8851 let layout = tree
8852 .solve_layout(Rect::new(0, 0, 100, 40))
8853 .expect("layout should solve");
8854 let right_rect = layout.rect(id(3)).expect("leaf 3 rect");
8855 let pointer = PanePointerPosition::new(
8856 i32::from(right_rect.x),
8857 i32::from(right_rect.y.saturating_add(right_rect.height / 2)),
8858 );
8859 let preview = tree
8860 .choose_dock_preview(&layout, pointer, PANE_MAGNETIC_FIELD_CELLS)
8861 .expect("magnetic preview should exist");
8862 assert!(preview.score > 0.0);
8863
8864 let plan = tree
8865 .plan_reflow_move_with_preview(
8866 id(2),
8867 &layout,
8868 pointer,
8869 PaneMotionVector::from_delta(24, 0, 48, 0),
8870 Some(PaneInertialThrow::from_motion(
8871 PaneMotionVector::from_delta(24, 0, 48, 0),
8872 )),
8873 PANE_MAGNETIC_FIELD_CELLS,
8874 )
8875 .expect("reflow plan should build");
8876 assert!(!plan.operations.is_empty());
8877 }
8878
8879 #[test]
8880 fn group_move_and_group_resize_plan_generation() {
8881 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
8882 let layout = tree
8883 .solve_layout(Rect::new(0, 0, 100, 40))
8884 .expect("layout should solve");
8885 let mut selection = PaneSelectionState::default();
8886 selection.shift_toggle(id(2));
8887 assert_eq!(selection.selected.len(), 1);
8888
8889 let move_plan = tree
8890 .plan_group_move(
8891 &selection,
8892 &layout,
8893 PanePointerPosition::new(80, 4),
8894 PaneMotionVector::from_delta(30, 2, 64, 1),
8895 None,
8896 PANE_MAGNETIC_FIELD_CELLS,
8897 )
8898 .expect("group move plan should build");
8899 assert!(!move_plan.operations.is_empty());
8900
8901 let resize_plan = tree
8902 .plan_group_resize(
8903 &selection,
8904 &layout,
8905 PaneResizeGrip::Right,
8906 PanePointerPosition::new(70, 20),
8907 PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(40, 1, 32, 0)),
8908 )
8909 .expect("group resize plan should build");
8910 assert!(!resize_plan.operations.is_empty());
8911 }
8912
8913 #[test]
8914 fn classify_resize_grip_handles_small_panes() {
8915 let rect = Rect::new(10, 10, 1, 1);
8917 let pointer = PanePointerPosition::new(10, 10);
8918 let grip = classify_resize_grip(rect, pointer, 1.5).expect("should classify");
8919 assert_eq!(grip, PaneResizeGrip::BottomRight);
8921
8922 let rect2 = Rect::new(10, 10, 2, 1);
8924 let ptr_left = PanePointerPosition::new(10, 10);
8926 let grip_left = classify_resize_grip(rect2, ptr_left, 1.5).expect("left pixel");
8927 assert_eq!(grip_left, PaneResizeGrip::BottomLeft);
8928
8929 let ptr_right = PanePointerPosition::new(11, 10);
8931 let grip_right = classify_resize_grip(rect2, ptr_right, 1.5).expect("right pixel");
8932 assert_eq!(grip_right, PaneResizeGrip::BottomRight);
8933 }
8934
8935 #[test]
8936 fn pressure_sensitive_snap_prefers_fast_straight_drags() {
8937 let slow = PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(4, 1, 300, 3));
8938 let fast = PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(40, 2, 48, 0));
8939 assert!(fast.strength_bps > slow.strength_bps);
8940 assert!(fast.hysteresis_bps >= slow.hysteresis_bps);
8941 }
8942
8943 #[test]
8944 fn pressure_sensitive_snap_penalizes_direction_noise() {
8945 let stable =
8946 PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(32, 2, 60, 0));
8947 let noisy =
8948 PanePressureSnapProfile::from_motion(PaneMotionVector::from_delta(32, 2, 60, 7));
8949 assert!(stable.strength_bps > noisy.strength_bps);
8950 }
8951
8952 #[test]
8953 fn dock_zone_motion_intent_prefers_directionally_aligned_zones() {
8954 let rightward = PaneMotionVector::from_delta(36, 2, 50, 0);
8955 let left_bias = dock_zone_motion_intent(PaneDockZone::Left, rightward);
8956 let right_bias = dock_zone_motion_intent(PaneDockZone::Right, rightward);
8957 assert!(right_bias > left_bias);
8958
8959 let downward = PaneMotionVector::from_delta(2, 32, 52, 0);
8960 let top_bias = dock_zone_motion_intent(PaneDockZone::Top, downward);
8961 let bottom_bias = dock_zone_motion_intent(PaneDockZone::Bottom, downward);
8962 assert!(bottom_bias > top_bias);
8963 }
8964
8965 #[test]
8966 fn dock_zone_motion_intent_noise_reduces_alignment_confidence() {
8967 let stable = dock_zone_motion_intent(
8968 PaneDockZone::Right,
8969 PaneMotionVector::from_delta(40, 1, 45, 0),
8970 );
8971 let noisy = dock_zone_motion_intent(
8972 PaneDockZone::Right,
8973 PaneMotionVector::from_delta(40, 1, 45, 8),
8974 );
8975 assert!(stable > noisy);
8976 }
8977
8978 #[test]
8979 fn elastic_ratio_bps_resists_extreme_edges_more_at_low_confidence() {
8980 let near_edge = 350;
8981 let low_confidence = elastic_ratio_bps(
8982 near_edge,
8983 PanePressureSnapProfile {
8984 strength_bps: 1_800,
8985 hysteresis_bps: 120,
8986 },
8987 );
8988 let high_confidence = elastic_ratio_bps(
8989 near_edge,
8990 PanePressureSnapProfile {
8991 strength_bps: 8_600,
8992 hysteresis_bps: 520,
8993 },
8994 );
8995 assert!(low_confidence > near_edge);
8996 assert!(high_confidence <= low_confidence);
8997 }
8998
8999 #[test]
9000 fn ranked_dock_previews_with_motion_returns_descending_scores() {
9001 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9002 let layout = tree
9003 .solve_layout(Rect::new(0, 0, 100, 40))
9004 .expect("layout should solve");
9005 let right_rect = layout.rect(id(3)).expect("leaf 3 rect");
9006 let pointer = PanePointerPosition::new(
9007 i32::from(right_rect.x),
9008 i32::from(right_rect.y.saturating_add(right_rect.height / 2)),
9009 );
9010 let ranked = tree.ranked_dock_previews_with_motion(
9011 &layout,
9012 pointer,
9013 PaneMotionVector::from_delta(28, 2, 48, 0),
9014 PANE_MAGNETIC_FIELD_CELLS,
9015 Some(id(2)),
9016 3,
9017 );
9018 assert!(!ranked.is_empty());
9019 for pair in ranked.windows(2) {
9020 assert!(pair[0].score >= pair[1].score);
9021 }
9022 }
9023
9024 #[test]
9025 fn inertial_throw_projects_farther_for_faster_motion() {
9026 let start = PanePointerPosition::new(40, 12);
9027 let slow = PaneInertialThrow::from_motion(PaneMotionVector::from_delta(6, 0, 220, 1))
9028 .projected_pointer(start);
9029 let fast = PaneInertialThrow::from_motion(PaneMotionVector::from_delta(42, 0, 40, 0))
9030 .projected_pointer(start);
9031 assert!(fast.x > slow.x);
9032 }
9033
9034 #[test]
9035 fn intelligence_mode_compact_emits_ratio_normalization_ops() {
9036 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9037 let ops = tree
9038 .plan_intelligence_mode(PaneLayoutIntelligenceMode::Compact, id(2))
9039 .expect("compact mode should plan");
9040 assert!(
9041 ops.iter()
9042 .any(|op| matches!(op, PaneOperation::NormalizeRatios))
9043 );
9044 assert!(
9045 ops.iter()
9046 .any(|op| matches!(op, PaneOperation::SetSplitRatio { .. }))
9047 );
9048 }
9049
9050 #[test]
9051 fn interaction_timeline_supports_undo_redo_and_replay() {
9052 let mut tree = PaneTree::singleton("root");
9053 let mut timeline = PaneInteractionTimeline::default();
9054
9055 timeline
9056 .apply_and_record(
9057 &mut tree,
9058 1,
9059 1000,
9060 PaneOperation::SplitLeaf {
9061 target: id(1),
9062 axis: SplitAxis::Horizontal,
9063 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
9064 placement: PanePlacement::ExistingFirst,
9065 new_leaf: PaneLeaf::new("aux"),
9066 },
9067 )
9068 .expect("split should apply");
9069 let split_hash = tree.state_hash();
9070 assert_eq!(timeline.applied_len(), 1);
9071
9072 let undone = timeline.undo(&mut tree).expect("undo should succeed");
9073 assert!(undone);
9074 assert_eq!(tree.root(), id(1));
9075
9076 let redone = timeline.redo(&mut tree).expect("redo should succeed");
9077 assert!(redone);
9078 assert_eq!(tree.state_hash(), split_hash);
9079
9080 let replayed = timeline.replay().expect("replay should succeed");
9081 assert_eq!(replayed.state_hash(), tree.state_hash());
9082 }
9083
9084 #[test]
9085 fn interaction_timeline_replay_matches_recorded_ratio_updates() {
9086 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9087 let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
9088 let split_ids: Vec<_> = tree
9089 .nodes()
9090 .filter_map(|node| match node.kind {
9091 PaneNodeKind::Split(_) => Some(node.id),
9092 PaneNodeKind::Leaf(_) => None,
9093 })
9094 .collect();
9095 assert!(!split_ids.is_empty());
9096 let ratios = [
9097 PaneSplitRatio::new(3, 2).expect("valid ratio"),
9098 PaneSplitRatio::new(2, 3).expect("valid ratio"),
9099 PaneSplitRatio::new(5, 4).expect("valid ratio"),
9100 PaneSplitRatio::new(4, 5).expect("valid ratio"),
9101 ];
9102
9103 for idx in 0..16u64 {
9104 timeline
9105 .apply_and_record(
9106 &mut tree,
9107 idx,
9108 20_000 + idx,
9109 PaneOperation::SetSplitRatio {
9110 split: split_ids[idx as usize % split_ids.len()],
9111 ratio: ratios[idx as usize % ratios.len()],
9112 },
9113 )
9114 .expect("ratio update should apply");
9115 }
9116
9117 let replayed = timeline.replay().expect("replay should succeed");
9118 assert_eq!(replayed.state_hash(), tree.state_hash());
9119 assert_eq!(replayed.to_snapshot(), tree.to_snapshot());
9120 }
9121
9122 #[test]
9123 fn interaction_timeline_records_checkpoints_at_default_interval() {
9124 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9125 let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
9126 let split_ids: Vec<_> = tree
9127 .nodes()
9128 .filter_map(|node| match node.kind {
9129 PaneNodeKind::Split(_) => Some(node.id),
9130 PaneNodeKind::Leaf(_) => None,
9131 })
9132 .collect();
9133 let ratios = [
9134 PaneSplitRatio::new(3, 2).expect("valid ratio"),
9135 PaneSplitRatio::new(2, 3).expect("valid ratio"),
9136 PaneSplitRatio::new(5, 4).expect("valid ratio"),
9137 PaneSplitRatio::new(4, 5).expect("valid ratio"),
9138 ];
9139
9140 for idx in 0..16u64 {
9141 timeline
9142 .apply_and_record(
9143 &mut tree,
9144 idx,
9145 30_000 + idx,
9146 PaneOperation::SetSplitRatio {
9147 split: split_ids[idx as usize % split_ids.len()],
9148 ratio: ratios[idx as usize % ratios.len()],
9149 },
9150 )
9151 .expect("ratio update should apply");
9152 }
9153
9154 assert_eq!(timeline.checkpoints.len(), 1);
9155 assert_eq!(timeline.checkpoints[0].applied_len, 16);
9156 assert_eq!(timeline.checkpoints[0].snapshot, tree.to_snapshot());
9157 }
9158
9159 #[test]
9160 fn interaction_timeline_discards_stale_checkpoints_after_branching() {
9161 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9162 let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
9163 let split_ids: Vec<_> = tree
9164 .nodes()
9165 .filter_map(|node| match node.kind {
9166 PaneNodeKind::Split(_) => Some(node.id),
9167 PaneNodeKind::Leaf(_) => None,
9168 })
9169 .collect();
9170 let ratios = [
9171 PaneSplitRatio::new(3, 2).expect("valid ratio"),
9172 PaneSplitRatio::new(2, 3).expect("valid ratio"),
9173 PaneSplitRatio::new(5, 4).expect("valid ratio"),
9174 PaneSplitRatio::new(4, 5).expect("valid ratio"),
9175 ];
9176
9177 for idx in 0..32u64 {
9178 timeline
9179 .apply_and_record(
9180 &mut tree,
9181 idx,
9182 40_000 + idx,
9183 PaneOperation::SetSplitRatio {
9184 split: split_ids[idx as usize % split_ids.len()],
9185 ratio: ratios[idx as usize % ratios.len()],
9186 },
9187 )
9188 .expect("ratio update should apply");
9189 }
9190
9191 assert_eq!(timeline.checkpoints.len(), 2);
9192 timeline.undo(&mut tree).expect("undo should succeed");
9193 timeline.undo(&mut tree).expect("undo should succeed");
9194 timeline.undo(&mut tree).expect("undo should succeed");
9195 timeline.undo(&mut tree).expect("undo should succeed");
9196 timeline.undo(&mut tree).expect("undo should succeed");
9197
9198 timeline
9199 .apply_and_record(
9200 &mut tree,
9201 99,
9202 99_999,
9203 PaneOperation::SetSplitRatio {
9204 split: split_ids[0],
9205 ratio: PaneSplitRatio::new(7, 5).expect("valid ratio"),
9206 },
9207 )
9208 .expect("branching update should apply");
9209
9210 assert_eq!(timeline.cursor, 28);
9211 assert_eq!(timeline.entries.len(), 28);
9212 assert_eq!(timeline.checkpoints.len(), 1);
9213 assert_eq!(timeline.checkpoints[0].applied_len, 16);
9214 }
9215
9216 #[test]
9217 fn interaction_timeline_replay_diagnostics_report_checkpoint_hit_and_depth() {
9218 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9219 let mut timeline = PaneInteractionTimeline::with_baseline(&tree);
9220 let split_ids: Vec<_> = tree
9221 .nodes()
9222 .filter_map(|node| match node.kind {
9223 PaneNodeKind::Split(_) => Some(node.id),
9224 PaneNodeKind::Leaf(_) => None,
9225 })
9226 .collect();
9227
9228 for idx in 0..20u64 {
9229 timeline
9230 .apply_and_record(
9231 &mut tree,
9232 idx,
9233 50_000 + idx,
9234 PaneOperation::SetSplitRatio {
9235 split: split_ids[idx as usize % split_ids.len()],
9236 ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
9237 },
9238 )
9239 .expect("ratio update should apply");
9240 }
9241
9242 let diagnostics = timeline.replay_diagnostics();
9243 assert_eq!(diagnostics.entry_count, 20);
9244 assert_eq!(diagnostics.cursor, 20);
9245 assert_eq!(diagnostics.checkpoint_interval, 16);
9246 assert_eq!(diagnostics.checkpoint_count, 1);
9247 assert!(diagnostics.checkpoint_hit);
9248 assert_eq!(diagnostics.replay_start_idx, 16);
9249 assert_eq!(diagnostics.replay_depth, 4);
9250 }
9251
9252 #[test]
9253 fn interaction_timeline_checkpoint_decision_prefers_shorter_interval_for_expensive_replay_steps()
9254 {
9255 let slow_replay =
9256 PaneInteractionTimeline::checkpoint_decision(10_000, 2_500).checkpoint_interval;
9257 let cheap_replay =
9258 PaneInteractionTimeline::checkpoint_decision(10_000, 100).checkpoint_interval;
9259
9260 assert!(slow_replay < cheap_replay);
9261 assert!(slow_replay >= 1);
9262 assert!(cheap_replay >= 1);
9263 }
9264
9265 #[test]
9266 fn set_split_ratio_uses_local_validation_strategy() {
9267 let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9268 assert_eq!(
9269 tree.validation_strategy_for_operation(PaneOperationKind::SetSplitRatio),
9270 PaneValidationStrategy::LocalClosure
9271 );
9272 assert_eq!(
9273 tree.validation_strategy_for_operation(PaneOperationKind::NormalizeRatios),
9274 PaneValidationStrategy::FullTree
9275 );
9276 }
9277
9278 #[test]
9279 fn local_validation_closure_rejects_parent_mismatch_for_touched_split() {
9280 let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
9281 let split_id = id(1);
9282 let child_id = id(2);
9283 tree.nodes.get_mut(&child_id).expect("child present").parent = None;
9284
9285 let err = tree
9286 .validate_local_closure(&BTreeSet::from([split_id]))
9287 .expect_err("local closure should detect broken child parent");
9288 assert!(matches!(
9289 err,
9290 PaneModelError::ParentMismatch {
9291 node_id,
9292 expected: Some(expected),
9293 actual: None,
9294 } if node_id == child_id && expected == split_id
9295 ));
9296 }
9297}