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