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