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