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