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