Skip to main content

ftui_layout/
pane.rs

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