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;
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}
135
136impl PaneConstraints {
137    /// Validate constraints for a given node.
138    pub fn validate(self, node_id: PaneId) -> Result<(), PaneModelError> {
139        if let Some(max_width) = self.max_width
140            && max_width < self.min_width
141        {
142            return Err(PaneModelError::InvalidConstraint {
143                node_id,
144                axis: "width",
145                min: self.min_width,
146                max: max_width,
147            });
148        }
149        if let Some(max_height) = self.max_height
150            && max_height < self.min_height
151        {
152            return Err(PaneModelError::InvalidConstraint {
153                node_id,
154                axis: "height",
155                min: self.min_height,
156                max: max_height,
157            });
158        }
159        Ok(())
160    }
161}
162
163impl Default for PaneConstraints {
164    fn default() -> Self {
165        Self {
166            min_width: 1,
167            min_height: 1,
168            max_width: None,
169            max_height: None,
170            collapsible: false,
171        }
172    }
173}
174
175/// Leaf payload for pane content identity.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PaneLeaf {
178    /// Host-provided stable surface key (for replay/diff mapping).
179    pub surface_key: String,
180    /// Forward-compatible extension bag.
181    #[serde(default)]
182    pub extensions: BTreeMap<String, String>,
183}
184
185impl PaneLeaf {
186    /// Build a leaf with a stable surface key.
187    #[must_use]
188    pub fn new(surface_key: impl Into<String>) -> Self {
189        Self {
190            surface_key: surface_key.into(),
191            extensions: BTreeMap::new(),
192        }
193    }
194}
195
196/// Split payload with child references.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct PaneSplit {
199    pub axis: SplitAxis,
200    pub ratio: PaneSplitRatio,
201    pub first: PaneId,
202    pub second: PaneId,
203}
204
205/// Node payload variant.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "kind", rename_all = "snake_case")]
208pub enum PaneNodeKind {
209    Leaf(PaneLeaf),
210    Split(PaneSplit),
211}
212
213/// Serializable node record in the canonical schema.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct PaneNodeRecord {
216    pub id: PaneId,
217    #[serde(default)]
218    pub parent: Option<PaneId>,
219    #[serde(default)]
220    pub constraints: PaneConstraints,
221    #[serde(flatten)]
222    pub kind: PaneNodeKind,
223    /// Forward-compatible extension bag.
224    #[serde(default)]
225    pub extensions: BTreeMap<String, String>,
226}
227
228impl PaneNodeRecord {
229    /// Construct a leaf node record.
230    #[must_use]
231    pub fn leaf(id: PaneId, parent: Option<PaneId>, leaf: PaneLeaf) -> Self {
232        Self {
233            id,
234            parent,
235            constraints: PaneConstraints::default(),
236            kind: PaneNodeKind::Leaf(leaf),
237            extensions: BTreeMap::new(),
238        }
239    }
240
241    /// Construct a split node record.
242    #[must_use]
243    pub fn split(id: PaneId, parent: Option<PaneId>, split: PaneSplit) -> Self {
244        Self {
245            id,
246            parent,
247            constraints: PaneConstraints::default(),
248            kind: PaneNodeKind::Split(split),
249            extensions: BTreeMap::new(),
250        }
251    }
252}
253
254/// Canonical serialized pane tree shape.
255///
256/// The extension maps are reserved for forward-compatible fields.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258pub struct PaneTreeSnapshot {
259    #[serde(default = "default_schema_version")]
260    pub schema_version: u16,
261    pub root: PaneId,
262    pub next_id: PaneId,
263    pub nodes: Vec<PaneNodeRecord>,
264    #[serde(default)]
265    pub extensions: BTreeMap<String, String>,
266}
267
268fn default_schema_version() -> u16 {
269    PANE_TREE_SCHEMA_VERSION
270}
271
272impl PaneTreeSnapshot {
273    /// Canonicalize node ordering by ID for deterministic serialization.
274    pub fn canonicalize(&mut self) {
275        self.nodes.sort_by_key(|node| node.id);
276    }
277
278    /// Deterministic hash for diagnostics over serialized tree state.
279    #[must_use]
280    pub fn state_hash(&self) -> u64 {
281        snapshot_state_hash(self)
282    }
283
284    /// Inspect invariants and emit a structured diagnostics report.
285    #[must_use]
286    pub fn invariant_report(&self) -> PaneInvariantReport {
287        build_invariant_report(self)
288    }
289
290    /// Attempt deterministic safe repairs for recoverable invariant issues.
291    ///
292    /// Safety guardrail: any unrepairable error in the pre-repair report causes
293    /// this method to fail without modifying topology.
294    pub fn repair_safe(self) -> Result<PaneRepairOutcome, PaneRepairError> {
295        repair_snapshot_safe(self)
296    }
297}
298
299/// Severity for one invariant finding.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum PaneInvariantSeverity {
303    Error,
304    Warning,
305}
306
307/// Stable code for invariant findings.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
309#[serde(rename_all = "snake_case")]
310pub enum PaneInvariantCode {
311    UnsupportedSchemaVersion,
312    DuplicateNodeId,
313    MissingRoot,
314    RootHasParent,
315    MissingParent,
316    MissingChild,
317    MultipleParents,
318    ParentMismatch,
319    SelfReferentialSplit,
320    DuplicateSplitChildren,
321    InvalidSplitRatio,
322    InvalidConstraint,
323    CycleDetected,
324    UnreachableNode,
325    NextIdNotGreaterThanExisting,
326}
327
328/// One actionable invariant finding.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct PaneInvariantIssue {
331    pub code: PaneInvariantCode,
332    pub severity: PaneInvariantSeverity,
333    pub repairable: bool,
334    pub node_id: Option<PaneId>,
335    pub related_node: Option<PaneId>,
336    pub message: String,
337}
338
339/// Structured invariant report over a pane tree snapshot.
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct PaneInvariantReport {
342    pub snapshot_hash: u64,
343    pub issues: Vec<PaneInvariantIssue>,
344}
345
346impl PaneInvariantReport {
347    /// Return true if any error-level finding exists.
348    #[must_use]
349    pub fn has_errors(&self) -> bool {
350        self.issues
351            .iter()
352            .any(|issue| issue.severity == PaneInvariantSeverity::Error)
353    }
354
355    /// Return true if any unrepairable error-level finding exists.
356    #[must_use]
357    pub fn has_unrepairable_errors(&self) -> bool {
358        self.issues
359            .iter()
360            .any(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
361    }
362}
363
364/// One deterministic repair action.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(tag = "action", rename_all = "snake_case")]
367pub enum PaneRepairAction {
368    ReparentNode {
369        node_id: PaneId,
370        before_parent: Option<PaneId>,
371        after_parent: Option<PaneId>,
372    },
373    NormalizeRatio {
374        node_id: PaneId,
375        before_numerator: u32,
376        before_denominator: u32,
377        after_numerator: u32,
378        after_denominator: u32,
379    },
380    RemoveOrphanNode {
381        node_id: PaneId,
382    },
383    BumpNextId {
384        before: PaneId,
385        after: PaneId,
386    },
387}
388
389/// Outcome from successful safe repair pass.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct PaneRepairOutcome {
392    pub before_hash: u64,
393    pub after_hash: u64,
394    pub report_before: PaneInvariantReport,
395    pub report_after: PaneInvariantReport,
396    pub actions: Vec<PaneRepairAction>,
397    pub tree: PaneTree,
398}
399
400/// Failure reason for safe repair.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub enum PaneRepairFailure {
403    UnsafeIssuesPresent { codes: Vec<PaneInvariantCode> },
404    ValidationFailed { error: PaneModelError },
405}
406
407impl fmt::Display for PaneRepairFailure {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            Self::UnsafeIssuesPresent { codes } => {
411                write!(f, "snapshot contains unsafe invariant issues: {codes:?}")
412            }
413            Self::ValidationFailed { error } => {
414                write!(f, "repaired snapshot failed validation: {error}")
415            }
416        }
417    }
418}
419
420impl std::error::Error for PaneRepairFailure {
421    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
422        if let Self::ValidationFailed { error } = self {
423            return Some(error);
424        }
425        None
426    }
427}
428
429/// Error payload for repair attempts.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct PaneRepairError {
432    pub before_hash: u64,
433    pub report: PaneInvariantReport,
434    pub reason: PaneRepairFailure,
435}
436
437impl fmt::Display for PaneRepairError {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        write!(
440            f,
441            "pane repair failed: {} (before_hash={:#x}, issues={})",
442            self.reason,
443            self.before_hash,
444            self.report.issues.len()
445        )
446    }
447}
448
449impl std::error::Error for PaneRepairError {
450    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
451        Some(&self.reason)
452    }
453}
454
455/// Concrete layout result for a solved pane tree.
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct PaneLayout {
458    pub area: Rect,
459    rects: BTreeMap<PaneId, Rect>,
460}
461
462impl PaneLayout {
463    /// Lookup rectangle for a specific pane node.
464    #[must_use]
465    pub fn rect(&self, node_id: PaneId) -> Option<Rect> {
466        self.rects.get(&node_id).copied()
467    }
468
469    /// Iterate all solved rectangles in deterministic ID order.
470    pub fn iter(&self) -> impl Iterator<Item = (PaneId, Rect)> + '_ {
471        self.rects.iter().map(|(node_id, rect)| (*node_id, *rect))
472    }
473}
474
475/// Placement of an incoming node relative to an existing node inside a split.
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "snake_case")]
478pub enum PanePlacement {
479    ExistingFirst,
480    IncomingFirst,
481}
482
483impl PanePlacement {
484    fn ordered(self, existing: PaneId, incoming: PaneId) -> (PaneId, PaneId) {
485        match self {
486            Self::ExistingFirst => (existing, incoming),
487            Self::IncomingFirst => (incoming, existing),
488        }
489    }
490}
491
492/// Pointer button for pane interaction events.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum PanePointerButton {
496    Primary,
497    Secondary,
498    Middle,
499}
500
501/// Normalized interaction position in pane-local coordinates.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503pub struct PanePointerPosition {
504    pub x: i32,
505    pub y: i32,
506}
507
508impl PanePointerPosition {
509    #[must_use]
510    pub const fn new(x: i32, y: i32) -> Self {
511        Self { x, y }
512    }
513}
514
515/// Snapshot of active modifiers captured with one semantic event.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
517pub struct PaneModifierSnapshot {
518    pub shift: bool,
519    pub alt: bool,
520    pub ctrl: bool,
521    pub meta: bool,
522}
523
524impl PaneModifierSnapshot {
525    #[must_use]
526    pub const fn none() -> Self {
527        Self {
528            shift: false,
529            alt: false,
530            ctrl: false,
531            meta: false,
532        }
533    }
534}
535
536impl Default for PaneModifierSnapshot {
537    fn default() -> Self {
538        Self::none()
539    }
540}
541
542/// Canonical resize target for semantic pane input events.
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub struct PaneResizeTarget {
545    pub split_id: PaneId,
546    pub axis: SplitAxis,
547}
548
549/// Direction for semantic resize commands.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "snake_case")]
552pub enum PaneResizeDirection {
553    Increase,
554    Decrease,
555}
556
557/// Canonical cancel reasons for pane interaction state machines.
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum PaneCancelReason {
561    EscapeKey,
562    PointerCancel,
563    FocusLost,
564    Blur,
565    Programmatic,
566}
567
568/// Versioned semantic pane interaction event kind.
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(tag = "event", rename_all = "snake_case")]
571pub enum PaneSemanticInputEventKind {
572    PointerDown {
573        target: PaneResizeTarget,
574        pointer_id: u32,
575        button: PanePointerButton,
576        position: PanePointerPosition,
577    },
578    PointerMove {
579        target: PaneResizeTarget,
580        pointer_id: u32,
581        position: PanePointerPosition,
582        delta_x: i32,
583        delta_y: i32,
584    },
585    PointerUp {
586        target: PaneResizeTarget,
587        pointer_id: u32,
588        button: PanePointerButton,
589        position: PanePointerPosition,
590    },
591    WheelNudge {
592        target: PaneResizeTarget,
593        lines: i16,
594    },
595    KeyboardResize {
596        target: PaneResizeTarget,
597        direction: PaneResizeDirection,
598        units: u16,
599    },
600    Cancel {
601        target: Option<PaneResizeTarget>,
602        reason: PaneCancelReason,
603    },
604    Blur {
605        target: Option<PaneResizeTarget>,
606    },
607}
608
609/// Versioned semantic pane interaction event consumed by pane-core and emitted
610/// by host adapters.
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct PaneSemanticInputEvent {
613    #[serde(default = "default_pane_semantic_input_event_schema_version")]
614    pub schema_version: u16,
615    pub sequence: u64,
616    #[serde(default)]
617    pub modifiers: PaneModifierSnapshot,
618    #[serde(flatten)]
619    pub kind: PaneSemanticInputEventKind,
620    #[serde(default)]
621    pub extensions: BTreeMap<String, String>,
622}
623
624fn default_pane_semantic_input_event_schema_version() -> u16 {
625    PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
626}
627
628impl PaneSemanticInputEvent {
629    /// Build a schema-versioned semantic pane input event.
630    #[must_use]
631    pub fn new(sequence: u64, kind: PaneSemanticInputEventKind) -> Self {
632        Self {
633            schema_version: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
634            sequence,
635            modifiers: PaneModifierSnapshot::default(),
636            kind,
637            extensions: BTreeMap::new(),
638        }
639    }
640
641    /// Validate event invariants required for deterministic replay.
642    pub fn validate(&self) -> Result<(), PaneSemanticInputEventError> {
643        if self.schema_version != PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION {
644            return Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
645                version: self.schema_version,
646                expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION,
647            });
648        }
649        if self.sequence == 0 {
650            return Err(PaneSemanticInputEventError::ZeroSequence);
651        }
652
653        match self.kind {
654            PaneSemanticInputEventKind::PointerDown { pointer_id, .. }
655            | PaneSemanticInputEventKind::PointerMove { pointer_id, .. }
656            | PaneSemanticInputEventKind::PointerUp { pointer_id, .. } => {
657                if pointer_id == 0 {
658                    return Err(PaneSemanticInputEventError::ZeroPointerId);
659                }
660            }
661            PaneSemanticInputEventKind::WheelNudge { lines, .. } => {
662                if lines == 0 {
663                    return Err(PaneSemanticInputEventError::ZeroWheelLines);
664                }
665            }
666            PaneSemanticInputEventKind::KeyboardResize { units, .. } => {
667                if units == 0 {
668                    return Err(PaneSemanticInputEventError::ZeroResizeUnits);
669                }
670            }
671            PaneSemanticInputEventKind::Cancel { .. } | PaneSemanticInputEventKind::Blur { .. } => {
672            }
673        }
674
675        Ok(())
676    }
677}
678
679/// Validation failures for semantic pane input events.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub enum PaneSemanticInputEventError {
682    UnsupportedSchemaVersion { version: u16, expected: u16 },
683    ZeroSequence,
684    ZeroPointerId,
685    ZeroWheelLines,
686    ZeroResizeUnits,
687}
688
689impl fmt::Display for PaneSemanticInputEventError {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        match self {
692            Self::UnsupportedSchemaVersion { version, expected } => write!(
693                f,
694                "unsupported pane semantic input schema version {version} (expected {expected})"
695            ),
696            Self::ZeroSequence => write!(f, "semantic pane input event sequence must be non-zero"),
697            Self::ZeroPointerId => {
698                write!(
699                    f,
700                    "semantic pane pointer events require non-zero pointer_id"
701                )
702            }
703            Self::ZeroWheelLines => write!(f, "semantic pane wheel nudge must be non-zero"),
704            Self::ZeroResizeUnits => {
705                write!(f, "semantic pane keyboard resize units must be non-zero")
706            }
707        }
708    }
709}
710
711impl std::error::Error for PaneSemanticInputEventError {}
712
713/// Metadata carried alongside semantic replay traces.
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
715pub struct PaneSemanticInputTraceMetadata {
716    #[serde(default = "default_pane_semantic_input_trace_schema_version")]
717    pub schema_version: u16,
718    pub seed: u64,
719    pub start_unix_ms: u64,
720    #[serde(default)]
721    pub host: String,
722    pub checksum: u64,
723}
724
725fn default_pane_semantic_input_trace_schema_version() -> u16 {
726    PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION
727}
728
729/// Canonical replay trace for semantic pane input streams.
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731pub struct PaneSemanticInputTrace {
732    pub metadata: PaneSemanticInputTraceMetadata,
733    #[serde(default)]
734    pub events: Vec<PaneSemanticInputEvent>,
735}
736
737impl PaneSemanticInputTrace {
738    /// Build a canonical semantic input trace and compute its checksum.
739    pub fn new(
740        seed: u64,
741        start_unix_ms: u64,
742        host: impl Into<String>,
743        events: Vec<PaneSemanticInputEvent>,
744    ) -> Result<Self, PaneSemanticInputTraceError> {
745        let mut trace = Self {
746            metadata: PaneSemanticInputTraceMetadata {
747                schema_version: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
748                seed,
749                start_unix_ms,
750                host: host.into(),
751                checksum: 0,
752            },
753            events,
754        };
755        trace.metadata.checksum = trace.recompute_checksum();
756        trace.validate()?;
757        Ok(trace)
758    }
759
760    /// Deterministically recompute the checksum over trace payload fields.
761    #[must_use]
762    pub fn recompute_checksum(&self) -> u64 {
763        pane_semantic_input_trace_checksum_payload(&self.metadata, &self.events)
764    }
765
766    /// Validate schema/version, event ordering, and checksum invariants.
767    pub fn validate(&self) -> Result<(), PaneSemanticInputTraceError> {
768        if self.metadata.schema_version != PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION {
769            return Err(PaneSemanticInputTraceError::UnsupportedSchemaVersion {
770                version: self.metadata.schema_version,
771                expected: PANE_SEMANTIC_INPUT_TRACE_SCHEMA_VERSION,
772            });
773        }
774        if self.events.is_empty() {
775            return Err(PaneSemanticInputTraceError::EmptyEvents);
776        }
777
778        let mut previous_sequence = 0_u64;
779        for (index, event) in self.events.iter().enumerate() {
780            event
781                .validate()
782                .map_err(|source| PaneSemanticInputTraceError::InvalidEvent { index, source })?;
783
784            if index > 0 && event.sequence <= previous_sequence {
785                return Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
786                    index,
787                    previous: previous_sequence,
788                    current: event.sequence,
789                });
790            }
791            previous_sequence = event.sequence;
792        }
793
794        let computed = self.recompute_checksum();
795        if self.metadata.checksum != computed {
796            return Err(PaneSemanticInputTraceError::ChecksumMismatch {
797                recorded: self.metadata.checksum,
798                computed,
799            });
800        }
801
802        Ok(())
803    }
804
805    /// Replay a semantic trace through a drag/resize machine.
806    pub fn replay(
807        &self,
808        machine: &mut PaneDragResizeMachine,
809    ) -> Result<PaneSemanticReplayOutcome, PaneSemanticReplayError> {
810        self.validate()
811            .map_err(PaneSemanticReplayError::InvalidTrace)?;
812
813        let mut transitions = Vec::with_capacity(self.events.len());
814        for event in &self.events {
815            let transition = machine
816                .apply_event(event)
817                .map_err(PaneSemanticReplayError::Machine)?;
818            transitions.push(transition);
819        }
820
821        Ok(PaneSemanticReplayOutcome {
822            trace_checksum: self.metadata.checksum,
823            transitions,
824            final_state: machine.state(),
825        })
826    }
827}
828
829/// Validation failures for semantic replay trace payloads.
830#[derive(Debug, Clone, PartialEq, Eq)]
831pub enum PaneSemanticInputTraceError {
832    UnsupportedSchemaVersion {
833        version: u16,
834        expected: u16,
835    },
836    EmptyEvents,
837    SequenceOutOfOrder {
838        index: usize,
839        previous: u64,
840        current: u64,
841    },
842    InvalidEvent {
843        index: usize,
844        source: PaneSemanticInputEventError,
845    },
846    ChecksumMismatch {
847        recorded: u64,
848        computed: u64,
849    },
850}
851
852impl fmt::Display for PaneSemanticInputTraceError {
853    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
854        match self {
855            Self::UnsupportedSchemaVersion { version, expected } => write!(
856                f,
857                "unsupported pane semantic input trace schema version {version} (expected {expected})"
858            ),
859            Self::EmptyEvents => write!(
860                f,
861                "semantic pane input trace must contain at least one event"
862            ),
863            Self::SequenceOutOfOrder {
864                index,
865                previous,
866                current,
867            } => write!(
868                f,
869                "semantic pane input trace sequence out of order at index {index} ({current} <= {previous})"
870            ),
871            Self::InvalidEvent { index, source } => {
872                write!(
873                    f,
874                    "semantic pane input trace contains invalid event at index {index}: {source}"
875                )
876            }
877            Self::ChecksumMismatch { recorded, computed } => write!(
878                f,
879                "semantic pane input trace checksum mismatch (recorded={recorded:#x}, computed={computed:#x})"
880            ),
881        }
882    }
883}
884
885impl std::error::Error for PaneSemanticInputTraceError {}
886
887/// Replay output from running one trace through a pane interaction machine.
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct PaneSemanticReplayOutcome {
890    pub trace_checksum: u64,
891    pub transitions: Vec<PaneDragResizeTransition>,
892    pub final_state: PaneDragResizeState,
893}
894
895/// Classification for replay conformance differences.
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
897#[serde(rename_all = "snake_case")]
898pub enum PaneSemanticReplayDiffKind {
899    TransitionMismatch,
900    MissingExpectedTransition,
901    UnexpectedTransition,
902    FinalStateMismatch,
903}
904
905/// One structured replay conformance difference artifact.
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct PaneSemanticReplayDiffArtifact {
908    pub kind: PaneSemanticReplayDiffKind,
909    pub index: Option<usize>,
910    pub expected_transition: Option<PaneDragResizeTransition>,
911    pub actual_transition: Option<PaneDragResizeTransition>,
912    pub expected_final_state: Option<PaneDragResizeState>,
913    pub actual_final_state: Option<PaneDragResizeState>,
914}
915
916/// Conformance comparison output for replay fixtures.
917#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
918pub struct PaneSemanticReplayConformanceArtifact {
919    pub trace_checksum: u64,
920    pub passed: bool,
921    pub diffs: Vec<PaneSemanticReplayDiffArtifact>,
922}
923
924impl PaneSemanticReplayConformanceArtifact {
925    /// Compare replay output against expected transitions/final state.
926    #[must_use]
927    pub fn compare(
928        outcome: &PaneSemanticReplayOutcome,
929        expected_transitions: &[PaneDragResizeTransition],
930        expected_final_state: PaneDragResizeState,
931    ) -> Self {
932        let mut diffs = Vec::new();
933        let max_len = expected_transitions.len().max(outcome.transitions.len());
934
935        for index in 0..max_len {
936            let expected = expected_transitions.get(index);
937            let actual = outcome.transitions.get(index);
938
939            match (expected, actual) {
940                (Some(expected_transition), Some(actual_transition))
941                    if expected_transition != actual_transition =>
942                {
943                    diffs.push(PaneSemanticReplayDiffArtifact {
944                        kind: PaneSemanticReplayDiffKind::TransitionMismatch,
945                        index: Some(index),
946                        expected_transition: Some(expected_transition.clone()),
947                        actual_transition: Some(actual_transition.clone()),
948                        expected_final_state: None,
949                        actual_final_state: None,
950                    });
951                }
952                (Some(expected_transition), None) => {
953                    diffs.push(PaneSemanticReplayDiffArtifact {
954                        kind: PaneSemanticReplayDiffKind::MissingExpectedTransition,
955                        index: Some(index),
956                        expected_transition: Some(expected_transition.clone()),
957                        actual_transition: None,
958                        expected_final_state: None,
959                        actual_final_state: None,
960                    });
961                }
962                (None, Some(actual_transition)) => {
963                    diffs.push(PaneSemanticReplayDiffArtifact {
964                        kind: PaneSemanticReplayDiffKind::UnexpectedTransition,
965                        index: Some(index),
966                        expected_transition: None,
967                        actual_transition: Some(actual_transition.clone()),
968                        expected_final_state: None,
969                        actual_final_state: None,
970                    });
971                }
972                (Some(_), Some(_)) | (None, None) => {}
973            }
974        }
975
976        if outcome.final_state != expected_final_state {
977            diffs.push(PaneSemanticReplayDiffArtifact {
978                kind: PaneSemanticReplayDiffKind::FinalStateMismatch,
979                index: None,
980                expected_transition: None,
981                actual_transition: None,
982                expected_final_state: Some(expected_final_state),
983                actual_final_state: Some(outcome.final_state),
984            });
985        }
986
987        Self {
988            trace_checksum: outcome.trace_checksum,
989            passed: diffs.is_empty(),
990            diffs,
991        }
992    }
993}
994
995/// Golden fixture shape for replay conformance runs.
996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997pub struct PaneSemanticReplayFixture {
998    pub trace: PaneSemanticInputTrace,
999    #[serde(default)]
1000    pub expected_transitions: Vec<PaneDragResizeTransition>,
1001    pub expected_final_state: PaneDragResizeState,
1002}
1003
1004impl PaneSemanticReplayFixture {
1005    /// Run one replay fixture and emit structured conformance artifacts.
1006    pub fn run(
1007        &self,
1008        machine: &mut PaneDragResizeMachine,
1009    ) -> Result<PaneSemanticReplayConformanceArtifact, PaneSemanticReplayError> {
1010        let outcome = self.trace.replay(machine)?;
1011        Ok(PaneSemanticReplayConformanceArtifact::compare(
1012            &outcome,
1013            &self.expected_transitions,
1014            self.expected_final_state,
1015        ))
1016    }
1017}
1018
1019/// Replay runner failures.
1020#[derive(Debug, Clone, PartialEq, Eq)]
1021pub enum PaneSemanticReplayError {
1022    InvalidTrace(PaneSemanticInputTraceError),
1023    Machine(PaneDragResizeMachineError),
1024}
1025
1026impl fmt::Display for PaneSemanticReplayError {
1027    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1028        match self {
1029            Self::InvalidTrace(source) => write!(f, "invalid semantic replay trace: {source}"),
1030            Self::Machine(source) => write!(f, "pane drag/resize machine replay failed: {source}"),
1031        }
1032    }
1033}
1034
1035impl std::error::Error for PaneSemanticReplayError {}
1036
1037fn pane_semantic_input_trace_checksum_payload(
1038    metadata: &PaneSemanticInputTraceMetadata,
1039    events: &[PaneSemanticInputEvent],
1040) -> u64 {
1041    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1042    const PRIME: u64 = 0x0000_0001_0000_01b3;
1043
1044    fn mix(hash: &mut u64, byte: u8) {
1045        *hash ^= u64::from(byte);
1046        *hash = hash.wrapping_mul(PRIME);
1047    }
1048
1049    fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
1050        for byte in bytes {
1051            mix(hash, *byte);
1052        }
1053    }
1054
1055    fn mix_u16(hash: &mut u64, value: u16) {
1056        mix_bytes(hash, &value.to_le_bytes());
1057    }
1058
1059    fn mix_u32(hash: &mut u64, value: u32) {
1060        mix_bytes(hash, &value.to_le_bytes());
1061    }
1062
1063    fn mix_i32(hash: &mut u64, value: i32) {
1064        mix_bytes(hash, &value.to_le_bytes());
1065    }
1066
1067    fn mix_u64(hash: &mut u64, value: u64) {
1068        mix_bytes(hash, &value.to_le_bytes());
1069    }
1070
1071    fn mix_i16(hash: &mut u64, value: i16) {
1072        mix_bytes(hash, &value.to_le_bytes());
1073    }
1074
1075    fn mix_bool(hash: &mut u64, value: bool) {
1076        mix(hash, u8::from(value));
1077    }
1078
1079    fn mix_str(hash: &mut u64, value: &str) {
1080        mix_u64(hash, value.len() as u64);
1081        mix_bytes(hash, value.as_bytes());
1082    }
1083
1084    fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
1085        mix_u64(hash, extensions.len() as u64);
1086        for (key, value) in extensions {
1087            mix_str(hash, key);
1088            mix_str(hash, value);
1089        }
1090    }
1091
1092    fn mix_target(hash: &mut u64, target: PaneResizeTarget) {
1093        mix_u64(hash, target.split_id.get());
1094        let axis = match target.axis {
1095            SplitAxis::Horizontal => 1,
1096            SplitAxis::Vertical => 2,
1097        };
1098        mix(hash, axis);
1099    }
1100
1101    fn mix_position(hash: &mut u64, position: PanePointerPosition) {
1102        mix_i32(hash, position.x);
1103        mix_i32(hash, position.y);
1104    }
1105
1106    fn mix_optional_target(hash: &mut u64, target: Option<PaneResizeTarget>) {
1107        match target {
1108            Some(target) => {
1109                mix(hash, 1);
1110                mix_target(hash, target);
1111            }
1112            None => mix(hash, 0),
1113        }
1114    }
1115
1116    fn mix_pointer_button(hash: &mut u64, button: PanePointerButton) {
1117        let value = match button {
1118            PanePointerButton::Primary => 1,
1119            PanePointerButton::Secondary => 2,
1120            PanePointerButton::Middle => 3,
1121        };
1122        mix(hash, value);
1123    }
1124
1125    fn mix_resize_direction(hash: &mut u64, direction: PaneResizeDirection) {
1126        let value = match direction {
1127            PaneResizeDirection::Increase => 1,
1128            PaneResizeDirection::Decrease => 2,
1129        };
1130        mix(hash, value);
1131    }
1132
1133    fn mix_cancel_reason(hash: &mut u64, reason: PaneCancelReason) {
1134        let value = match reason {
1135            PaneCancelReason::EscapeKey => 1,
1136            PaneCancelReason::PointerCancel => 2,
1137            PaneCancelReason::FocusLost => 3,
1138            PaneCancelReason::Blur => 4,
1139            PaneCancelReason::Programmatic => 5,
1140        };
1141        mix(hash, value);
1142    }
1143
1144    let mut hash = OFFSET_BASIS;
1145    mix_u16(&mut hash, metadata.schema_version);
1146    mix_u64(&mut hash, metadata.seed);
1147    mix_u64(&mut hash, metadata.start_unix_ms);
1148    mix_str(&mut hash, &metadata.host);
1149    mix_u64(&mut hash, events.len() as u64);
1150
1151    for event in events {
1152        mix_u16(&mut hash, event.schema_version);
1153        mix_u64(&mut hash, event.sequence);
1154        mix_bool(&mut hash, event.modifiers.shift);
1155        mix_bool(&mut hash, event.modifiers.alt);
1156        mix_bool(&mut hash, event.modifiers.ctrl);
1157        mix_bool(&mut hash, event.modifiers.meta);
1158        mix_extensions(&mut hash, &event.extensions);
1159
1160        match event.kind {
1161            PaneSemanticInputEventKind::PointerDown {
1162                target,
1163                pointer_id,
1164                button,
1165                position,
1166            } => {
1167                mix(&mut hash, 1);
1168                mix_target(&mut hash, target);
1169                mix_u32(&mut hash, pointer_id);
1170                mix_pointer_button(&mut hash, button);
1171                mix_position(&mut hash, position);
1172            }
1173            PaneSemanticInputEventKind::PointerMove {
1174                target,
1175                pointer_id,
1176                position,
1177                delta_x,
1178                delta_y,
1179            } => {
1180                mix(&mut hash, 2);
1181                mix_target(&mut hash, target);
1182                mix_u32(&mut hash, pointer_id);
1183                mix_position(&mut hash, position);
1184                mix_i32(&mut hash, delta_x);
1185                mix_i32(&mut hash, delta_y);
1186            }
1187            PaneSemanticInputEventKind::PointerUp {
1188                target,
1189                pointer_id,
1190                button,
1191                position,
1192            } => {
1193                mix(&mut hash, 3);
1194                mix_target(&mut hash, target);
1195                mix_u32(&mut hash, pointer_id);
1196                mix_pointer_button(&mut hash, button);
1197                mix_position(&mut hash, position);
1198            }
1199            PaneSemanticInputEventKind::WheelNudge { target, lines } => {
1200                mix(&mut hash, 4);
1201                mix_target(&mut hash, target);
1202                mix_i16(&mut hash, lines);
1203            }
1204            PaneSemanticInputEventKind::KeyboardResize {
1205                target,
1206                direction,
1207                units,
1208            } => {
1209                mix(&mut hash, 5);
1210                mix_target(&mut hash, target);
1211                mix_resize_direction(&mut hash, direction);
1212                mix_u16(&mut hash, units);
1213            }
1214            PaneSemanticInputEventKind::Cancel { target, reason } => {
1215                mix(&mut hash, 6);
1216                mix_optional_target(&mut hash, target);
1217                mix_cancel_reason(&mut hash, reason);
1218            }
1219            PaneSemanticInputEventKind::Blur { target } => {
1220                mix(&mut hash, 7);
1221                mix_optional_target(&mut hash, target);
1222            }
1223        }
1224    }
1225
1226    hash
1227}
1228
1229/// Rational scale factor used for deterministic coordinate transforms.
1230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1231pub struct PaneScaleFactor {
1232    numerator: u32,
1233    denominator: u32,
1234}
1235
1236impl PaneScaleFactor {
1237    /// Identity scale (`1/1`).
1238    pub const ONE: Self = Self {
1239        numerator: 1,
1240        denominator: 1,
1241    };
1242
1243    /// Build and normalize a rational scale factor.
1244    pub fn new(numerator: u32, denominator: u32) -> Result<Self, PaneCoordinateNormalizationError> {
1245        if numerator == 0 || denominator == 0 {
1246            return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1247                field: "scale_factor",
1248                numerator,
1249                denominator,
1250            });
1251        }
1252        let gcd = gcd_u32(numerator, denominator);
1253        Ok(Self {
1254            numerator: numerator / gcd,
1255            denominator: denominator / gcd,
1256        })
1257    }
1258
1259    fn validate(self, field: &'static str) -> Result<(), PaneCoordinateNormalizationError> {
1260        if self.numerator == 0 || self.denominator == 0 {
1261            return Err(PaneCoordinateNormalizationError::InvalidScaleFactor {
1262                field,
1263                numerator: self.numerator,
1264                denominator: self.denominator,
1265            });
1266        }
1267        Ok(())
1268    }
1269
1270    #[must_use]
1271    pub const fn numerator(self) -> u32 {
1272        self.numerator
1273    }
1274
1275    #[must_use]
1276    pub const fn denominator(self) -> u32 {
1277        self.denominator
1278    }
1279}
1280
1281impl Default for PaneScaleFactor {
1282    fn default() -> Self {
1283        Self::ONE
1284    }
1285}
1286
1287/// Deterministic rounding policy for coordinate normalization.
1288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1289#[serde(rename_all = "snake_case")]
1290pub enum PaneCoordinateRoundingPolicy {
1291    /// Round toward negative infinity (`floor`).
1292    #[default]
1293    TowardNegativeInfinity,
1294    /// Round to nearest value; exact half-way ties resolve toward negative infinity.
1295    NearestHalfTowardNegativeInfinity,
1296}
1297
1298/// Input coordinate source variants accepted by pane normalization.
1299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(tag = "source", rename_all = "snake_case")]
1301pub enum PaneInputCoordinate {
1302    /// Absolute CSS pixel coordinates.
1303    CssPixels { position: PanePointerPosition },
1304    /// Absolute device pixel coordinates.
1305    DevicePixels { position: PanePointerPosition },
1306    /// Viewport-local cell coordinates.
1307    Cell { position: PanePointerPosition },
1308}
1309
1310/// Deterministic normalized coordinate payload used by pane interaction layers.
1311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1312pub struct PaneNormalizedCoordinate {
1313    /// Canonical global cell coordinate (viewport offset applied).
1314    pub global_cell: PanePointerPosition,
1315    /// Viewport-local cell coordinate.
1316    pub local_cell: PanePointerPosition,
1317    /// Normalized viewport-local CSS coordinate after DPR/zoom conversion.
1318    pub local_css: PanePointerPosition,
1319}
1320
1321/// Coordinate normalization configuration and transform pipeline.
1322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1323pub struct PaneCoordinateNormalizer {
1324    pub viewport_origin_css: PanePointerPosition,
1325    pub viewport_origin_cells: PanePointerPosition,
1326    pub cell_width_css: u16,
1327    pub cell_height_css: u16,
1328    pub dpr: PaneScaleFactor,
1329    pub zoom: PaneScaleFactor,
1330    #[serde(default)]
1331    pub rounding: PaneCoordinateRoundingPolicy,
1332}
1333
1334impl PaneCoordinateNormalizer {
1335    /// Construct a validated coordinate normalizer.
1336    pub fn new(
1337        viewport_origin_css: PanePointerPosition,
1338        viewport_origin_cells: PanePointerPosition,
1339        cell_width_css: u16,
1340        cell_height_css: u16,
1341        dpr: PaneScaleFactor,
1342        zoom: PaneScaleFactor,
1343        rounding: PaneCoordinateRoundingPolicy,
1344    ) -> Result<Self, PaneCoordinateNormalizationError> {
1345        if cell_width_css == 0 || cell_height_css == 0 {
1346            return Err(PaneCoordinateNormalizationError::InvalidCellSize {
1347                width: cell_width_css,
1348                height: cell_height_css,
1349            });
1350        }
1351        dpr.validate("dpr")?;
1352        zoom.validate("zoom")?;
1353
1354        Ok(Self {
1355            viewport_origin_css,
1356            viewport_origin_cells,
1357            cell_width_css,
1358            cell_height_css,
1359            dpr,
1360            zoom,
1361            rounding,
1362        })
1363    }
1364
1365    /// Convert one raw coordinate into canonical pane cell space.
1366    pub fn normalize(
1367        &self,
1368        input: PaneInputCoordinate,
1369    ) -> Result<PaneNormalizedCoordinate, PaneCoordinateNormalizationError> {
1370        let (local_css_x, local_css_y) = match input {
1371            PaneInputCoordinate::CssPixels { position } => (
1372                i64::from(position.x) - i64::from(self.viewport_origin_css.x),
1373                i64::from(position.y) - i64::from(self.viewport_origin_css.y),
1374            ),
1375            PaneInputCoordinate::DevicePixels { position } => {
1376                let css_x = scale_div_round(
1377                    i64::from(position.x),
1378                    i64::from(self.dpr.denominator()),
1379                    i64::from(self.dpr.numerator()),
1380                    self.rounding,
1381                )?;
1382                let css_y = scale_div_round(
1383                    i64::from(position.y),
1384                    i64::from(self.dpr.denominator()),
1385                    i64::from(self.dpr.numerator()),
1386                    self.rounding,
1387                )?;
1388                (
1389                    css_x - i64::from(self.viewport_origin_css.x),
1390                    css_y - i64::from(self.viewport_origin_css.y),
1391                )
1392            }
1393            PaneInputCoordinate::Cell { position } => {
1394                let local_css_x = i64::from(position.x)
1395                    .checked_mul(i64::from(self.cell_width_css))
1396                    .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1397                let local_css_y = i64::from(position.y)
1398                    .checked_mul(i64::from(self.cell_height_css))
1399                    .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1400                let global_cell_x = i64::from(position.x) + i64::from(self.viewport_origin_cells.x);
1401                let global_cell_y = i64::from(position.y) + i64::from(self.viewport_origin_cells.y);
1402
1403                return Ok(PaneNormalizedCoordinate {
1404                    global_cell: PanePointerPosition::new(
1405                        to_i32(global_cell_x)?,
1406                        to_i32(global_cell_y)?,
1407                    ),
1408                    local_cell: position,
1409                    local_css: PanePointerPosition::new(to_i32(local_css_x)?, to_i32(local_css_y)?),
1410                });
1411            }
1412        };
1413
1414        let unzoomed_css_x = scale_div_round(
1415            local_css_x,
1416            i64::from(self.zoom.denominator()),
1417            i64::from(self.zoom.numerator()),
1418            self.rounding,
1419        )?;
1420        let unzoomed_css_y = scale_div_round(
1421            local_css_y,
1422            i64::from(self.zoom.denominator()),
1423            i64::from(self.zoom.numerator()),
1424            self.rounding,
1425        )?;
1426
1427        let local_cell_x = div_round(
1428            unzoomed_css_x,
1429            i64::from(self.cell_width_css),
1430            self.rounding,
1431        )?;
1432        let local_cell_y = div_round(
1433            unzoomed_css_y,
1434            i64::from(self.cell_height_css),
1435            self.rounding,
1436        )?;
1437
1438        let global_cell_x = local_cell_x + i64::from(self.viewport_origin_cells.x);
1439        let global_cell_y = local_cell_y + i64::from(self.viewport_origin_cells.y);
1440
1441        Ok(PaneNormalizedCoordinate {
1442            global_cell: PanePointerPosition::new(to_i32(global_cell_x)?, to_i32(global_cell_y)?),
1443            local_cell: PanePointerPosition::new(to_i32(local_cell_x)?, to_i32(local_cell_y)?),
1444            local_css: PanePointerPosition::new(to_i32(unzoomed_css_x)?, to_i32(unzoomed_css_y)?),
1445        })
1446    }
1447}
1448
1449/// Coordinate normalization failures.
1450#[derive(Debug, Clone, PartialEq, Eq)]
1451pub enum PaneCoordinateNormalizationError {
1452    InvalidCellSize {
1453        width: u16,
1454        height: u16,
1455    },
1456    InvalidScaleFactor {
1457        field: &'static str,
1458        numerator: u32,
1459        denominator: u32,
1460    },
1461    CoordinateOverflow,
1462}
1463
1464impl fmt::Display for PaneCoordinateNormalizationError {
1465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1466        match self {
1467            Self::InvalidCellSize { width, height } => {
1468                write!(
1469                    f,
1470                    "invalid pane cell dimensions width={width} height={height} (must be > 0)"
1471                )
1472            }
1473            Self::InvalidScaleFactor {
1474                field,
1475                numerator,
1476                denominator,
1477            } => {
1478                write!(
1479                    f,
1480                    "invalid pane scale factor for {field}: {numerator}/{denominator} (must be > 0)"
1481                )
1482            }
1483            Self::CoordinateOverflow => {
1484                write!(f, "coordinate conversion overflowed representable range")
1485            }
1486        }
1487    }
1488}
1489
1490impl std::error::Error for PaneCoordinateNormalizationError {}
1491
1492fn scale_div_round(
1493    value: i64,
1494    numerator: i64,
1495    denominator: i64,
1496    rounding: PaneCoordinateRoundingPolicy,
1497) -> Result<i64, PaneCoordinateNormalizationError> {
1498    let scaled = value
1499        .checked_mul(numerator)
1500        .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1501    div_round(scaled, denominator, rounding)
1502}
1503
1504fn div_round(
1505    value: i64,
1506    denominator: i64,
1507    rounding: PaneCoordinateRoundingPolicy,
1508) -> Result<i64, PaneCoordinateNormalizationError> {
1509    if denominator <= 0 {
1510        return Err(PaneCoordinateNormalizationError::CoordinateOverflow);
1511    }
1512
1513    let floor = value.div_euclid(denominator);
1514    let remainder = value.rem_euclid(denominator);
1515    if remainder == 0 || rounding == PaneCoordinateRoundingPolicy::TowardNegativeInfinity {
1516        return Ok(floor);
1517    }
1518
1519    let twice_remainder = remainder
1520        .checked_mul(2)
1521        .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow)?;
1522    if twice_remainder > denominator {
1523        if value >= 0 {
1524            return floor
1525                .checked_add(1)
1526                .ok_or(PaneCoordinateNormalizationError::CoordinateOverflow);
1527        }
1528        return Ok(floor);
1529    }
1530    Ok(floor)
1531}
1532
1533fn to_i32(value: i64) -> Result<i32, PaneCoordinateNormalizationError> {
1534    i32::try_from(value).map_err(|_| PaneCoordinateNormalizationError::CoordinateOverflow)
1535}
1536
1537/// Default move threshold (in coordinate units) for transitioning from
1538/// `Armed` to `Dragging`.
1539pub const PANE_DRAG_RESIZE_DEFAULT_THRESHOLD: u16 = 2;
1540
1541/// Default minimum move distance (in coordinate units) required to emit a
1542/// `DragUpdated` transition while dragging.
1543pub const PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS: u16 = 2;
1544
1545/// Default snapping interval expressed in basis points (0..=10_000).
1546pub const PANE_SNAP_DEFAULT_STEP_BPS: u16 = 500;
1547
1548/// Default snap stickiness window in basis points.
1549pub const PANE_SNAP_DEFAULT_HYSTERESIS_BPS: u16 = 125;
1550
1551/// Precision mode derived from modifier snapshots.
1552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1553#[serde(rename_all = "snake_case")]
1554pub enum PanePrecisionMode {
1555    Normal,
1556    Fine,
1557    Coarse,
1558}
1559
1560/// Modifier-derived precision/axis-lock policy for drag updates.
1561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1562pub struct PanePrecisionPolicy {
1563    pub mode: PanePrecisionMode,
1564    pub axis_lock: Option<SplitAxis>,
1565    pub scale: PaneScaleFactor,
1566}
1567
1568impl PanePrecisionPolicy {
1569    /// Build precision policy from modifiers for a target split axis.
1570    #[must_use]
1571    pub fn from_modifiers(modifiers: PaneModifierSnapshot, target_axis: SplitAxis) -> Self {
1572        let mode = if modifiers.alt {
1573            PanePrecisionMode::Fine
1574        } else if modifiers.ctrl {
1575            PanePrecisionMode::Coarse
1576        } else {
1577            PanePrecisionMode::Normal
1578        };
1579        let axis_lock = modifiers.shift.then_some(target_axis);
1580        let scale = match mode {
1581            PanePrecisionMode::Normal => PaneScaleFactor::ONE,
1582            PanePrecisionMode::Fine => PaneScaleFactor {
1583                numerator: 1,
1584                denominator: 2,
1585            },
1586            PanePrecisionMode::Coarse => PaneScaleFactor {
1587                numerator: 2,
1588                denominator: 1,
1589            },
1590        };
1591        Self {
1592            mode,
1593            axis_lock,
1594            scale,
1595        }
1596    }
1597
1598    /// Apply precision mode and optional axis-lock to an interaction delta.
1599    pub fn apply_delta(
1600        &self,
1601        raw_delta_x: i32,
1602        raw_delta_y: i32,
1603    ) -> Result<(i32, i32), PaneInteractionPolicyError> {
1604        let (locked_x, locked_y) = match self.axis_lock {
1605            Some(SplitAxis::Horizontal) => (raw_delta_x, 0),
1606            Some(SplitAxis::Vertical) => (0, raw_delta_y),
1607            None => (raw_delta_x, raw_delta_y),
1608        };
1609
1610        let scaled_x = scale_delta_by_factor(locked_x, self.scale)?;
1611        let scaled_y = scale_delta_by_factor(locked_y, self.scale)?;
1612        Ok((scaled_x, scaled_y))
1613    }
1614}
1615
1616/// Deterministic snapping policy for pane split ratios.
1617#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1618pub struct PaneSnapTuning {
1619    pub step_bps: u16,
1620    pub hysteresis_bps: u16,
1621}
1622
1623impl PaneSnapTuning {
1624    pub fn new(step_bps: u16, hysteresis_bps: u16) -> Result<Self, PaneInteractionPolicyError> {
1625        let tuning = Self {
1626            step_bps,
1627            hysteresis_bps,
1628        };
1629        tuning.validate()?;
1630        Ok(tuning)
1631    }
1632
1633    pub fn validate(self) -> Result<(), PaneInteractionPolicyError> {
1634        if self.step_bps == 0 || self.step_bps > 10_000 {
1635            return Err(PaneInteractionPolicyError::InvalidSnapTuning {
1636                step_bps: self.step_bps,
1637                hysteresis_bps: self.hysteresis_bps,
1638            });
1639        }
1640        Ok(())
1641    }
1642
1643    /// Decide whether to snap an input ratio using deterministic tie-breaking.
1644    #[must_use]
1645    pub fn decide(self, ratio_bps: u16, previous_snap: Option<u16>) -> PaneSnapDecision {
1646        let step = u32::from(self.step_bps);
1647        let ratio = u32::from(ratio_bps).min(10_000);
1648        let low = ((ratio / step) * step).min(10_000);
1649        let high = (low + step).min(10_000);
1650
1651        let distance_low = ratio.abs_diff(low);
1652        let distance_high = ratio.abs_diff(high);
1653
1654        let (nearest, nearest_distance) = if distance_low <= distance_high {
1655            (low as u16, distance_low as u16)
1656        } else {
1657            (high as u16, distance_high as u16)
1658        };
1659
1660        if let Some(previous) = previous_snap {
1661            let distance_previous = ratio.abs_diff(u32::from(previous));
1662            if distance_previous <= u32::from(self.hysteresis_bps) {
1663                return PaneSnapDecision {
1664                    input_ratio_bps: ratio_bps,
1665                    snapped_ratio_bps: Some(previous),
1666                    nearest_ratio_bps: nearest,
1667                    nearest_distance_bps: nearest_distance,
1668                    reason: PaneSnapReason::RetainedPrevious,
1669                };
1670            }
1671        }
1672
1673        if nearest_distance <= self.hysteresis_bps {
1674            PaneSnapDecision {
1675                input_ratio_bps: ratio_bps,
1676                snapped_ratio_bps: Some(nearest),
1677                nearest_ratio_bps: nearest,
1678                nearest_distance_bps: nearest_distance,
1679                reason: PaneSnapReason::SnappedNearest,
1680            }
1681        } else {
1682            PaneSnapDecision {
1683                input_ratio_bps: ratio_bps,
1684                snapped_ratio_bps: None,
1685                nearest_ratio_bps: nearest,
1686                nearest_distance_bps: nearest_distance,
1687                reason: PaneSnapReason::UnsnapOutsideWindow,
1688            }
1689        }
1690    }
1691}
1692
1693impl Default for PaneSnapTuning {
1694    fn default() -> Self {
1695        Self {
1696            step_bps: PANE_SNAP_DEFAULT_STEP_BPS,
1697            hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
1698        }
1699    }
1700}
1701
1702/// Combined drag behavior tuning constants.
1703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1704pub struct PaneDragBehaviorTuning {
1705    pub activation_threshold: u16,
1706    pub update_hysteresis: u16,
1707    pub snap: PaneSnapTuning,
1708}
1709
1710impl PaneDragBehaviorTuning {
1711    pub fn new(
1712        activation_threshold: u16,
1713        update_hysteresis: u16,
1714        snap: PaneSnapTuning,
1715    ) -> Result<Self, PaneInteractionPolicyError> {
1716        if activation_threshold == 0 {
1717            return Err(PaneInteractionPolicyError::InvalidThreshold {
1718                field: "activation_threshold",
1719                value: activation_threshold,
1720            });
1721        }
1722        if update_hysteresis == 0 {
1723            return Err(PaneInteractionPolicyError::InvalidThreshold {
1724                field: "update_hysteresis",
1725                value: update_hysteresis,
1726            });
1727        }
1728        snap.validate()?;
1729        Ok(Self {
1730            activation_threshold,
1731            update_hysteresis,
1732            snap,
1733        })
1734    }
1735
1736    #[must_use]
1737    pub fn should_start_drag(
1738        self,
1739        origin: PanePointerPosition,
1740        current: PanePointerPosition,
1741    ) -> bool {
1742        crossed_drag_threshold(origin, current, self.activation_threshold)
1743    }
1744
1745    #[must_use]
1746    pub fn should_emit_drag_update(
1747        self,
1748        previous: PanePointerPosition,
1749        current: PanePointerPosition,
1750    ) -> bool {
1751        crossed_drag_threshold(previous, current, self.update_hysteresis)
1752    }
1753}
1754
1755impl Default for PaneDragBehaviorTuning {
1756    fn default() -> Self {
1757        Self {
1758            activation_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1759            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1760            snap: PaneSnapTuning::default(),
1761        }
1762    }
1763}
1764
1765/// Deterministic snap decision categories.
1766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1767#[serde(rename_all = "snake_case")]
1768pub enum PaneSnapReason {
1769    RetainedPrevious,
1770    SnappedNearest,
1771    UnsnapOutsideWindow,
1772}
1773
1774/// Output of snap-decision evaluation.
1775#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1776pub struct PaneSnapDecision {
1777    pub input_ratio_bps: u16,
1778    pub snapped_ratio_bps: Option<u16>,
1779    pub nearest_ratio_bps: u16,
1780    pub nearest_distance_bps: u16,
1781    pub reason: PaneSnapReason,
1782}
1783
1784/// Tuning/policy validation errors for pane interaction behavior controls.
1785#[derive(Debug, Clone, PartialEq, Eq)]
1786pub enum PaneInteractionPolicyError {
1787    InvalidThreshold { field: &'static str, value: u16 },
1788    InvalidSnapTuning { step_bps: u16, hysteresis_bps: u16 },
1789    DeltaOverflow,
1790}
1791
1792impl fmt::Display for PaneInteractionPolicyError {
1793    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1794        match self {
1795            Self::InvalidThreshold { field, value } => {
1796                write!(f, "invalid {field} value {value} (must be > 0)")
1797            }
1798            Self::InvalidSnapTuning {
1799                step_bps,
1800                hysteresis_bps,
1801            } => {
1802                write!(
1803                    f,
1804                    "invalid snap tuning step_bps={step_bps} hysteresis_bps={hysteresis_bps}"
1805                )
1806            }
1807            Self::DeltaOverflow => write!(f, "delta scaling overflow"),
1808        }
1809    }
1810}
1811
1812impl std::error::Error for PaneInteractionPolicyError {}
1813
1814fn scale_delta_by_factor(
1815    delta: i32,
1816    factor: PaneScaleFactor,
1817) -> Result<i32, PaneInteractionPolicyError> {
1818    let scaled = i64::from(delta)
1819        .checked_mul(i64::from(factor.numerator()))
1820        .ok_or(PaneInteractionPolicyError::DeltaOverflow)?;
1821    let normalized = scaled / i64::from(factor.denominator());
1822    i32::try_from(normalized).map_err(|_| PaneInteractionPolicyError::DeltaOverflow)
1823}
1824
1825/// Deterministic pane drag/resize lifecycle state.
1826///
1827/// ```text
1828/// Idle -> Armed -> Dragging -> Idle
1829///    \------> Idle (commit/cancel from Armed)
1830/// ```
1831#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1832#[serde(tag = "state", rename_all = "snake_case")]
1833pub enum PaneDragResizeState {
1834    Idle,
1835    Armed {
1836        target: PaneResizeTarget,
1837        pointer_id: u32,
1838        origin: PanePointerPosition,
1839        current: PanePointerPosition,
1840        started_sequence: u64,
1841    },
1842    Dragging {
1843        target: PaneResizeTarget,
1844        pointer_id: u32,
1845        origin: PanePointerPosition,
1846        current: PanePointerPosition,
1847        started_sequence: u64,
1848        drag_started_sequence: u64,
1849    },
1850}
1851
1852/// Explicit no-op diagnostics for lifecycle events that are safely ignored.
1853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1854#[serde(rename_all = "snake_case")]
1855pub enum PaneDragResizeNoopReason {
1856    IdleWithoutActiveDrag,
1857    ActiveDragAlreadyInProgress,
1858    PointerMismatch,
1859    TargetMismatch,
1860    ActiveStateDisallowsDiscreteInput,
1861    ThresholdNotReached,
1862    BelowHysteresis,
1863}
1864
1865/// Transition effect emitted by one lifecycle step.
1866#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1867#[serde(tag = "effect", rename_all = "snake_case")]
1868pub enum PaneDragResizeEffect {
1869    Armed {
1870        target: PaneResizeTarget,
1871        pointer_id: u32,
1872        origin: PanePointerPosition,
1873    },
1874    DragStarted {
1875        target: PaneResizeTarget,
1876        pointer_id: u32,
1877        origin: PanePointerPosition,
1878        current: PanePointerPosition,
1879        total_delta_x: i32,
1880        total_delta_y: i32,
1881    },
1882    DragUpdated {
1883        target: PaneResizeTarget,
1884        pointer_id: u32,
1885        previous: PanePointerPosition,
1886        current: PanePointerPosition,
1887        delta_x: i32,
1888        delta_y: i32,
1889        total_delta_x: i32,
1890        total_delta_y: i32,
1891    },
1892    Committed {
1893        target: PaneResizeTarget,
1894        pointer_id: u32,
1895        origin: PanePointerPosition,
1896        end: PanePointerPosition,
1897        total_delta_x: i32,
1898        total_delta_y: i32,
1899    },
1900    Canceled {
1901        target: Option<PaneResizeTarget>,
1902        pointer_id: Option<u32>,
1903        reason: PaneCancelReason,
1904    },
1905    KeyboardApplied {
1906        target: PaneResizeTarget,
1907        direction: PaneResizeDirection,
1908        units: u16,
1909    },
1910    WheelApplied {
1911        target: PaneResizeTarget,
1912        lines: i16,
1913    },
1914    Noop {
1915        reason: PaneDragResizeNoopReason,
1916    },
1917}
1918
1919/// One state-machine transition with deterministic telemetry fields.
1920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1921pub struct PaneDragResizeTransition {
1922    pub transition_id: u64,
1923    pub sequence: u64,
1924    pub from: PaneDragResizeState,
1925    pub to: PaneDragResizeState,
1926    pub effect: PaneDragResizeEffect,
1927}
1928
1929/// Runtime lifecycle machine for pane drag/resize interactions.
1930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1931pub struct PaneDragResizeMachine {
1932    state: PaneDragResizeState,
1933    drag_threshold: u16,
1934    update_hysteresis: u16,
1935    transition_counter: u64,
1936}
1937
1938impl Default for PaneDragResizeMachine {
1939    fn default() -> Self {
1940        Self {
1941            state: PaneDragResizeState::Idle,
1942            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1943            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1944            transition_counter: 0,
1945        }
1946    }
1947}
1948
1949impl PaneDragResizeMachine {
1950    /// Construct a drag/resize lifecycle machine with explicit threshold.
1951    pub fn new(drag_threshold: u16) -> Result<Self, PaneDragResizeMachineError> {
1952        Self::new_with_hysteresis(drag_threshold, PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS)
1953    }
1954
1955    /// Construct a drag/resize lifecycle machine with explicit threshold and
1956    /// drag-update hysteresis.
1957    pub fn new_with_hysteresis(
1958        drag_threshold: u16,
1959        update_hysteresis: u16,
1960    ) -> Result<Self, PaneDragResizeMachineError> {
1961        if drag_threshold == 0 {
1962            return Err(PaneDragResizeMachineError::InvalidDragThreshold {
1963                threshold: drag_threshold,
1964            });
1965        }
1966        if update_hysteresis == 0 {
1967            return Err(PaneDragResizeMachineError::InvalidUpdateHysteresis {
1968                hysteresis: update_hysteresis,
1969            });
1970        }
1971        Ok(Self {
1972            state: PaneDragResizeState::Idle,
1973            drag_threshold,
1974            update_hysteresis,
1975            transition_counter: 0,
1976        })
1977    }
1978
1979    /// Current lifecycle state.
1980    #[must_use]
1981    pub const fn state(&self) -> PaneDragResizeState {
1982        self.state
1983    }
1984
1985    /// Configured drag-start threshold.
1986    #[must_use]
1987    pub const fn drag_threshold(&self) -> u16 {
1988        self.drag_threshold
1989    }
1990
1991    /// Configured drag-update hysteresis threshold.
1992    #[must_use]
1993    pub const fn update_hysteresis(&self) -> u16 {
1994        self.update_hysteresis
1995    }
1996
1997    /// Whether the machine is in a non-idle state (Armed or Dragging).
1998    #[must_use]
1999    pub const fn is_active(&self) -> bool {
2000        !matches!(self.state, PaneDragResizeState::Idle)
2001    }
2002
2003    /// Unconditionally reset the machine to Idle, returning a diagnostic
2004    /// transition if the machine was in an active state.
2005    ///
2006    /// This is a safety valve for RAII cleanup paths (panic, signal, guard
2007    /// drop) where constructing a valid `PaneSemanticInputEvent` is not
2008    /// possible. The returned transition carries `PaneCancelReason::Programmatic`
2009    /// and a `Canceled` effect.
2010    ///
2011    /// If the machine is already Idle, returns `None` (no-op).
2012    pub fn force_cancel(&mut self) -> Option<PaneDragResizeTransition> {
2013        let from = self.state;
2014        match from {
2015            PaneDragResizeState::Idle => None,
2016            PaneDragResizeState::Armed {
2017                target, pointer_id, ..
2018            }
2019            | PaneDragResizeState::Dragging {
2020                target, pointer_id, ..
2021            } => {
2022                self.state = PaneDragResizeState::Idle;
2023                self.transition_counter = self.transition_counter.saturating_add(1);
2024                Some(PaneDragResizeTransition {
2025                    transition_id: self.transition_counter,
2026                    sequence: 0,
2027                    from,
2028                    to: PaneDragResizeState::Idle,
2029                    effect: PaneDragResizeEffect::Canceled {
2030                        target: Some(target),
2031                        pointer_id: Some(pointer_id),
2032                        reason: PaneCancelReason::Programmatic,
2033                    },
2034                })
2035            }
2036        }
2037    }
2038
2039    /// Apply one semantic pane input event and emit deterministic transition
2040    /// diagnostics.
2041    pub fn apply_event(
2042        &mut self,
2043        event: &PaneSemanticInputEvent,
2044    ) -> Result<PaneDragResizeTransition, PaneDragResizeMachineError> {
2045        event
2046            .validate()
2047            .map_err(PaneDragResizeMachineError::InvalidEvent)?;
2048
2049        let from = self.state;
2050        let effect = match (self.state, &event.kind) {
2051            (
2052                PaneDragResizeState::Idle,
2053                PaneSemanticInputEventKind::PointerDown {
2054                    target,
2055                    pointer_id,
2056                    position,
2057                    ..
2058                },
2059            ) => {
2060                self.state = PaneDragResizeState::Armed {
2061                    target: *target,
2062                    pointer_id: *pointer_id,
2063                    origin: *position,
2064                    current: *position,
2065                    started_sequence: event.sequence,
2066                };
2067                PaneDragResizeEffect::Armed {
2068                    target: *target,
2069                    pointer_id: *pointer_id,
2070                    origin: *position,
2071                }
2072            }
2073            (
2074                PaneDragResizeState::Idle,
2075                PaneSemanticInputEventKind::KeyboardResize {
2076                    target,
2077                    direction,
2078                    units,
2079                },
2080            ) => PaneDragResizeEffect::KeyboardApplied {
2081                target: *target,
2082                direction: *direction,
2083                units: *units,
2084            },
2085            (
2086                PaneDragResizeState::Idle,
2087                PaneSemanticInputEventKind::WheelNudge { target, lines },
2088            ) => PaneDragResizeEffect::WheelApplied {
2089                target: *target,
2090                lines: *lines,
2091            },
2092            (PaneDragResizeState::Idle, _) => PaneDragResizeEffect::Noop {
2093                reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag,
2094            },
2095            (
2096                PaneDragResizeState::Armed {
2097                    target,
2098                    pointer_id,
2099                    origin,
2100                    current: _,
2101                    started_sequence,
2102                },
2103                PaneSemanticInputEventKind::PointerMove {
2104                    target: incoming_target,
2105                    pointer_id: incoming_pointer_id,
2106                    position,
2107                    ..
2108                },
2109            ) => {
2110                if *incoming_pointer_id != pointer_id {
2111                    PaneDragResizeEffect::Noop {
2112                        reason: PaneDragResizeNoopReason::PointerMismatch,
2113                    }
2114                } else if *incoming_target != target {
2115                    PaneDragResizeEffect::Noop {
2116                        reason: PaneDragResizeNoopReason::TargetMismatch,
2117                    }
2118                } else {
2119                    self.state = PaneDragResizeState::Armed {
2120                        target,
2121                        pointer_id,
2122                        origin,
2123                        current: *position,
2124                        started_sequence,
2125                    };
2126                    if crossed_drag_threshold(origin, *position, self.drag_threshold) {
2127                        self.state = PaneDragResizeState::Dragging {
2128                            target,
2129                            pointer_id,
2130                            origin,
2131                            current: *position,
2132                            started_sequence,
2133                            drag_started_sequence: event.sequence,
2134                        };
2135                        let (total_delta_x, total_delta_y) = delta(origin, *position);
2136                        PaneDragResizeEffect::DragStarted {
2137                            target,
2138                            pointer_id,
2139                            origin,
2140                            current: *position,
2141                            total_delta_x,
2142                            total_delta_y,
2143                        }
2144                    } else {
2145                        PaneDragResizeEffect::Noop {
2146                            reason: PaneDragResizeNoopReason::ThresholdNotReached,
2147                        }
2148                    }
2149                }
2150            }
2151            (
2152                PaneDragResizeState::Armed {
2153                    target,
2154                    pointer_id,
2155                    origin,
2156                    ..
2157                },
2158                PaneSemanticInputEventKind::PointerUp {
2159                    target: incoming_target,
2160                    pointer_id: incoming_pointer_id,
2161                    position,
2162                    ..
2163                },
2164            ) => {
2165                if *incoming_pointer_id != pointer_id {
2166                    PaneDragResizeEffect::Noop {
2167                        reason: PaneDragResizeNoopReason::PointerMismatch,
2168                    }
2169                } else if *incoming_target != target {
2170                    PaneDragResizeEffect::Noop {
2171                        reason: PaneDragResizeNoopReason::TargetMismatch,
2172                    }
2173                } else {
2174                    self.state = PaneDragResizeState::Idle;
2175                    let (total_delta_x, total_delta_y) = delta(origin, *position);
2176                    PaneDragResizeEffect::Committed {
2177                        target,
2178                        pointer_id,
2179                        origin,
2180                        end: *position,
2181                        total_delta_x,
2182                        total_delta_y,
2183                    }
2184                }
2185            }
2186            (
2187                PaneDragResizeState::Armed {
2188                    target, pointer_id, ..
2189                },
2190                PaneSemanticInputEventKind::Cancel {
2191                    target: incoming_target,
2192                    reason,
2193                },
2194            ) => {
2195                if !cancel_target_matches(target, *incoming_target) {
2196                    PaneDragResizeEffect::Noop {
2197                        reason: PaneDragResizeNoopReason::TargetMismatch,
2198                    }
2199                } else {
2200                    self.state = PaneDragResizeState::Idle;
2201                    PaneDragResizeEffect::Canceled {
2202                        target: Some(target),
2203                        pointer_id: Some(pointer_id),
2204                        reason: *reason,
2205                    }
2206                }
2207            }
2208            (
2209                PaneDragResizeState::Armed {
2210                    target, pointer_id, ..
2211                },
2212                PaneSemanticInputEventKind::Blur {
2213                    target: incoming_target,
2214                },
2215            ) => {
2216                if !cancel_target_matches(target, *incoming_target) {
2217                    PaneDragResizeEffect::Noop {
2218                        reason: PaneDragResizeNoopReason::TargetMismatch,
2219                    }
2220                } else {
2221                    self.state = PaneDragResizeState::Idle;
2222                    PaneDragResizeEffect::Canceled {
2223                        target: Some(target),
2224                        pointer_id: Some(pointer_id),
2225                        reason: PaneCancelReason::Blur,
2226                    }
2227                }
2228            }
2229            (PaneDragResizeState::Armed { .. }, PaneSemanticInputEventKind::PointerDown { .. }) => {
2230                PaneDragResizeEffect::Noop {
2231                    reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2232                }
2233            }
2234            (
2235                PaneDragResizeState::Armed { .. },
2236                PaneSemanticInputEventKind::KeyboardResize { .. }
2237                | PaneSemanticInputEventKind::WheelNudge { .. },
2238            ) => PaneDragResizeEffect::Noop {
2239                reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2240            },
2241            (
2242                PaneDragResizeState::Dragging {
2243                    target,
2244                    pointer_id,
2245                    origin,
2246                    current,
2247                    started_sequence,
2248                    drag_started_sequence,
2249                },
2250                PaneSemanticInputEventKind::PointerMove {
2251                    target: incoming_target,
2252                    pointer_id: incoming_pointer_id,
2253                    position,
2254                    ..
2255                },
2256            ) => {
2257                if *incoming_pointer_id != pointer_id {
2258                    PaneDragResizeEffect::Noop {
2259                        reason: PaneDragResizeNoopReason::PointerMismatch,
2260                    }
2261                } else if *incoming_target != target {
2262                    PaneDragResizeEffect::Noop {
2263                        reason: PaneDragResizeNoopReason::TargetMismatch,
2264                    }
2265                } else {
2266                    let previous = current;
2267                    if !crossed_drag_threshold(previous, *position, self.update_hysteresis) {
2268                        PaneDragResizeEffect::Noop {
2269                            reason: PaneDragResizeNoopReason::BelowHysteresis,
2270                        }
2271                    } else {
2272                        let (delta_x, delta_y) = delta(previous, *position);
2273                        let (total_delta_x, total_delta_y) = delta(origin, *position);
2274                        self.state = PaneDragResizeState::Dragging {
2275                            target,
2276                            pointer_id,
2277                            origin,
2278                            current: *position,
2279                            started_sequence,
2280                            drag_started_sequence,
2281                        };
2282                        PaneDragResizeEffect::DragUpdated {
2283                            target,
2284                            pointer_id,
2285                            previous,
2286                            current: *position,
2287                            delta_x,
2288                            delta_y,
2289                            total_delta_x,
2290                            total_delta_y,
2291                        }
2292                    }
2293                }
2294            }
2295            (
2296                PaneDragResizeState::Dragging {
2297                    target,
2298                    pointer_id,
2299                    origin,
2300                    ..
2301                },
2302                PaneSemanticInputEventKind::PointerUp {
2303                    target: incoming_target,
2304                    pointer_id: incoming_pointer_id,
2305                    position,
2306                    ..
2307                },
2308            ) => {
2309                if *incoming_pointer_id != pointer_id {
2310                    PaneDragResizeEffect::Noop {
2311                        reason: PaneDragResizeNoopReason::PointerMismatch,
2312                    }
2313                } else if *incoming_target != target {
2314                    PaneDragResizeEffect::Noop {
2315                        reason: PaneDragResizeNoopReason::TargetMismatch,
2316                    }
2317                } else {
2318                    self.state = PaneDragResizeState::Idle;
2319                    let (total_delta_x, total_delta_y) = delta(origin, *position);
2320                    PaneDragResizeEffect::Committed {
2321                        target,
2322                        pointer_id,
2323                        origin,
2324                        end: *position,
2325                        total_delta_x,
2326                        total_delta_y,
2327                    }
2328                }
2329            }
2330            (
2331                PaneDragResizeState::Dragging {
2332                    target, pointer_id, ..
2333                },
2334                PaneSemanticInputEventKind::Cancel {
2335                    target: incoming_target,
2336                    reason,
2337                },
2338            ) => {
2339                if !cancel_target_matches(target, *incoming_target) {
2340                    PaneDragResizeEffect::Noop {
2341                        reason: PaneDragResizeNoopReason::TargetMismatch,
2342                    }
2343                } else {
2344                    self.state = PaneDragResizeState::Idle;
2345                    PaneDragResizeEffect::Canceled {
2346                        target: Some(target),
2347                        pointer_id: Some(pointer_id),
2348                        reason: *reason,
2349                    }
2350                }
2351            }
2352            (
2353                PaneDragResizeState::Dragging {
2354                    target, pointer_id, ..
2355                },
2356                PaneSemanticInputEventKind::Blur {
2357                    target: incoming_target,
2358                },
2359            ) => {
2360                if !cancel_target_matches(target, *incoming_target) {
2361                    PaneDragResizeEffect::Noop {
2362                        reason: PaneDragResizeNoopReason::TargetMismatch,
2363                    }
2364                } else {
2365                    self.state = PaneDragResizeState::Idle;
2366                    PaneDragResizeEffect::Canceled {
2367                        target: Some(target),
2368                        pointer_id: Some(pointer_id),
2369                        reason: PaneCancelReason::Blur,
2370                    }
2371                }
2372            }
2373            (
2374                PaneDragResizeState::Dragging { .. },
2375                PaneSemanticInputEventKind::PointerDown { .. },
2376            ) => PaneDragResizeEffect::Noop {
2377                reason: PaneDragResizeNoopReason::ActiveDragAlreadyInProgress,
2378            },
2379            (
2380                PaneDragResizeState::Dragging { .. },
2381                PaneSemanticInputEventKind::KeyboardResize { .. }
2382                | PaneSemanticInputEventKind::WheelNudge { .. },
2383            ) => PaneDragResizeEffect::Noop {
2384                reason: PaneDragResizeNoopReason::ActiveStateDisallowsDiscreteInput,
2385            },
2386        };
2387
2388        self.transition_counter = self.transition_counter.saturating_add(1);
2389        Ok(PaneDragResizeTransition {
2390            transition_id: self.transition_counter,
2391            sequence: event.sequence,
2392            from,
2393            to: self.state,
2394            effect,
2395        })
2396    }
2397}
2398
2399/// Lifecycle machine configuration/runtime errors.
2400#[derive(Debug, Clone, PartialEq, Eq)]
2401pub enum PaneDragResizeMachineError {
2402    InvalidDragThreshold { threshold: u16 },
2403    InvalidUpdateHysteresis { hysteresis: u16 },
2404    InvalidEvent(PaneSemanticInputEventError),
2405}
2406
2407impl fmt::Display for PaneDragResizeMachineError {
2408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2409        match self {
2410            Self::InvalidDragThreshold { threshold } => {
2411                write!(f, "drag threshold must be > 0 (got {threshold})")
2412            }
2413            Self::InvalidUpdateHysteresis { hysteresis } => {
2414                write!(f, "update hysteresis must be > 0 (got {hysteresis})")
2415            }
2416            Self::InvalidEvent(error) => write!(f, "invalid semantic pane input event: {error}"),
2417        }
2418    }
2419}
2420
2421impl std::error::Error for PaneDragResizeMachineError {
2422    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2423        if let Self::InvalidEvent(error) = self {
2424            return Some(error);
2425        }
2426        None
2427    }
2428}
2429
2430fn delta(origin: PanePointerPosition, current: PanePointerPosition) -> (i32, i32) {
2431    (current.x - origin.x, current.y - origin.y)
2432}
2433
2434fn crossed_drag_threshold(
2435    origin: PanePointerPosition,
2436    current: PanePointerPosition,
2437    threshold: u16,
2438) -> bool {
2439    let (dx, dy) = delta(origin, current);
2440    let threshold = i64::from(threshold);
2441    let squared_distance = i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy);
2442    squared_distance >= threshold * threshold
2443}
2444
2445fn cancel_target_matches(active: PaneResizeTarget, incoming: Option<PaneResizeTarget>) -> bool {
2446    incoming.is_none() || incoming == Some(active)
2447}
2448
2449/// Supported structural pane operations.
2450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2451#[serde(tag = "op", rename_all = "snake_case")]
2452pub enum PaneOperation {
2453    /// Split an existing leaf by wrapping it with a new split parent and adding
2454    /// one new sibling leaf.
2455    SplitLeaf {
2456        target: PaneId,
2457        axis: SplitAxis,
2458        ratio: PaneSplitRatio,
2459        placement: PanePlacement,
2460        new_leaf: PaneLeaf,
2461    },
2462    /// Close a non-root pane (leaf or subtree) and promote its sibling.
2463    CloseNode { target: PaneId },
2464    /// Move an existing subtree next to a target node by wrapping the target in
2465    /// a new split with the source subtree.
2466    MoveSubtree {
2467        source: PaneId,
2468        target: PaneId,
2469        axis: SplitAxis,
2470        ratio: PaneSplitRatio,
2471        placement: PanePlacement,
2472    },
2473    /// Swap two non-ancestor subtrees.
2474    SwapNodes { first: PaneId, second: PaneId },
2475    /// Canonicalize all split ratios to reduced form and validate positivity.
2476    NormalizeRatios,
2477}
2478
2479impl PaneOperation {
2480    /// Operation family.
2481    #[must_use]
2482    pub const fn kind(&self) -> PaneOperationKind {
2483        match self {
2484            Self::SplitLeaf { .. } => PaneOperationKind::SplitLeaf,
2485            Self::CloseNode { .. } => PaneOperationKind::CloseNode,
2486            Self::MoveSubtree { .. } => PaneOperationKind::MoveSubtree,
2487            Self::SwapNodes { .. } => PaneOperationKind::SwapNodes,
2488            Self::NormalizeRatios => PaneOperationKind::NormalizeRatios,
2489        }
2490    }
2491
2492    #[must_use]
2493    fn referenced_nodes(&self) -> Vec<PaneId> {
2494        match self {
2495            Self::SplitLeaf { target, .. } | Self::CloseNode { target } => vec![*target],
2496            Self::MoveSubtree { source, target, .. }
2497            | Self::SwapNodes {
2498                first: source,
2499                second: target,
2500            } => {
2501                vec![*source, *target]
2502            }
2503            Self::NormalizeRatios => Vec::new(),
2504        }
2505    }
2506}
2507
2508/// Stable operation discriminator used in logs and telemetry.
2509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2510#[serde(rename_all = "snake_case")]
2511pub enum PaneOperationKind {
2512    SplitLeaf,
2513    CloseNode,
2514    MoveSubtree,
2515    SwapNodes,
2516    NormalizeRatios,
2517}
2518
2519/// Successful transactional operation result.
2520#[derive(Debug, Clone, PartialEq, Eq)]
2521pub struct PaneOperationOutcome {
2522    pub operation_id: u64,
2523    pub kind: PaneOperationKind,
2524    pub touched_nodes: Vec<PaneId>,
2525    pub before_hash: u64,
2526    pub after_hash: u64,
2527}
2528
2529/// Failure payload for transactional operation APIs.
2530#[derive(Debug, Clone, PartialEq, Eq)]
2531pub struct PaneOperationError {
2532    pub operation_id: u64,
2533    pub kind: PaneOperationKind,
2534    pub touched_nodes: Vec<PaneId>,
2535    pub before_hash: u64,
2536    pub after_hash: u64,
2537    pub reason: PaneOperationFailure,
2538}
2539
2540/// Structured reasons for pane operation failure.
2541#[derive(Debug, Clone, PartialEq, Eq)]
2542pub enum PaneOperationFailure {
2543    MissingNode {
2544        node_id: PaneId,
2545    },
2546    NodeNotLeaf {
2547        node_id: PaneId,
2548    },
2549    ParentNotSplit {
2550        node_id: PaneId,
2551    },
2552    ParentChildMismatch {
2553        parent: PaneId,
2554        child: PaneId,
2555    },
2556    CannotCloseRoot {
2557        node_id: PaneId,
2558    },
2559    CannotMoveRoot {
2560        node_id: PaneId,
2561    },
2562    SameNode {
2563        first: PaneId,
2564        second: PaneId,
2565    },
2566    AncestorConflict {
2567        ancestor: PaneId,
2568        descendant: PaneId,
2569    },
2570    TargetRemovedByDetach {
2571        target: PaneId,
2572        detached_parent: PaneId,
2573    },
2574    PaneIdOverflow {
2575        current: PaneId,
2576    },
2577    InvalidRatio {
2578        node_id: PaneId,
2579        numerator: u32,
2580        denominator: u32,
2581    },
2582    Validation(PaneModelError),
2583}
2584
2585impl fmt::Display for PaneOperationFailure {
2586    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2587        match self {
2588            Self::MissingNode { node_id } => write!(f, "node {} not found", node_id.0),
2589            Self::NodeNotLeaf { node_id } => write!(f, "node {} is not a leaf", node_id.0),
2590            Self::ParentNotSplit { node_id } => {
2591                write!(f, "node {} is not a split parent", node_id.0)
2592            }
2593            Self::ParentChildMismatch { parent, child } => write!(
2594                f,
2595                "split parent {} does not reference child {}",
2596                parent.0, child.0
2597            ),
2598            Self::CannotCloseRoot { node_id } => {
2599                write!(f, "cannot close root node {}", node_id.0)
2600            }
2601            Self::CannotMoveRoot { node_id } => {
2602                write!(f, "cannot move root node {}", node_id.0)
2603            }
2604            Self::SameNode { first, second } => write!(
2605                f,
2606                "operation requires distinct nodes, got {} and {}",
2607                first.0, second.0
2608            ),
2609            Self::AncestorConflict {
2610                ancestor,
2611                descendant,
2612            } => write!(
2613                f,
2614                "operation would create cycle: node {} is an ancestor of {}",
2615                ancestor.0, descendant.0
2616            ),
2617            Self::TargetRemovedByDetach {
2618                target,
2619                detached_parent,
2620            } => write!(
2621                f,
2622                "target {} would be removed while detaching parent {}",
2623                target.0, detached_parent.0
2624            ),
2625            Self::PaneIdOverflow { current } => {
2626                write!(f, "pane id overflow after {}", current.0)
2627            }
2628            Self::InvalidRatio {
2629                node_id,
2630                numerator,
2631                denominator,
2632            } => write!(
2633                f,
2634                "split node {} has invalid ratio {numerator}/{denominator}",
2635                node_id.0
2636            ),
2637            Self::Validation(err) => write!(f, "{err}"),
2638        }
2639    }
2640}
2641
2642impl std::error::Error for PaneOperationFailure {
2643    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2644        if let Self::Validation(err) = self {
2645            return Some(err);
2646        }
2647        None
2648    }
2649}
2650
2651impl fmt::Display for PaneOperationError {
2652    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2653        write!(
2654            f,
2655            "pane op {} ({:?}) failed: {} [nodes={:?}, before_hash={:#x}, after_hash={:#x}]",
2656            self.operation_id,
2657            self.kind,
2658            self.reason,
2659            self.touched_nodes
2660                .iter()
2661                .map(|node_id| node_id.0)
2662                .collect::<Vec<_>>(),
2663            self.before_hash,
2664            self.after_hash
2665        )
2666    }
2667}
2668
2669impl std::error::Error for PaneOperationError {
2670    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2671        Some(&self.reason)
2672    }
2673}
2674
2675/// One deterministic operation journal row emitted by a transaction.
2676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2677pub struct PaneOperationJournalEntry {
2678    pub transaction_id: u64,
2679    pub sequence: u64,
2680    pub operation_id: u64,
2681    pub operation: PaneOperation,
2682    pub kind: PaneOperationKind,
2683    pub touched_nodes: Vec<PaneId>,
2684    pub before_hash: u64,
2685    pub after_hash: u64,
2686    pub result: PaneOperationJournalResult,
2687}
2688
2689/// Journal result state for one attempted operation.
2690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2691#[serde(tag = "status", rename_all = "snake_case")]
2692pub enum PaneOperationJournalResult {
2693    Applied,
2694    Rejected { reason: String },
2695}
2696
2697/// Finalized transaction payload emitted by commit/rollback.
2698#[derive(Debug, Clone, PartialEq, Eq)]
2699pub struct PaneTransactionOutcome {
2700    pub transaction_id: u64,
2701    pub committed: bool,
2702    pub tree: PaneTree,
2703    pub journal: Vec<PaneOperationJournalEntry>,
2704}
2705
2706/// Transaction boundary wrapper for pane mutations.
2707#[derive(Debug, Clone, PartialEq, Eq)]
2708pub struct PaneTransaction {
2709    transaction_id: u64,
2710    sequence: u64,
2711    base_tree: PaneTree,
2712    working_tree: PaneTree,
2713    journal: Vec<PaneOperationJournalEntry>,
2714}
2715
2716impl PaneTransaction {
2717    fn new(transaction_id: u64, base_tree: PaneTree) -> Self {
2718        Self {
2719            transaction_id,
2720            sequence: 1,
2721            base_tree: base_tree.clone(),
2722            working_tree: base_tree,
2723            journal: Vec::new(),
2724        }
2725    }
2726
2727    /// Transaction identifier supplied by the caller.
2728    #[must_use]
2729    pub const fn transaction_id(&self) -> u64 {
2730        self.transaction_id
2731    }
2732
2733    /// Current mutable working tree for read-only inspection.
2734    #[must_use]
2735    pub fn tree(&self) -> &PaneTree {
2736        &self.working_tree
2737    }
2738
2739    /// Journal entries in deterministic insertion order.
2740    #[must_use]
2741    pub fn journal(&self) -> &[PaneOperationJournalEntry] {
2742        &self.journal
2743    }
2744
2745    /// Attempt one operation against the transaction working tree.
2746    ///
2747    /// Every attempt is journaled, including rejected operations.
2748    pub fn apply_operation(
2749        &mut self,
2750        operation_id: u64,
2751        operation: PaneOperation,
2752    ) -> Result<PaneOperationOutcome, PaneOperationError> {
2753        let operation_for_journal = operation.clone();
2754        let kind = operation_for_journal.kind();
2755        let sequence = self.next_sequence();
2756
2757        match self.working_tree.apply_operation(operation_id, operation) {
2758            Ok(outcome) => {
2759                self.journal.push(PaneOperationJournalEntry {
2760                    transaction_id: self.transaction_id,
2761                    sequence,
2762                    operation_id,
2763                    operation: operation_for_journal,
2764                    kind,
2765                    touched_nodes: outcome.touched_nodes.clone(),
2766                    before_hash: outcome.before_hash,
2767                    after_hash: outcome.after_hash,
2768                    result: PaneOperationJournalResult::Applied,
2769                });
2770                Ok(outcome)
2771            }
2772            Err(err) => {
2773                self.journal.push(PaneOperationJournalEntry {
2774                    transaction_id: self.transaction_id,
2775                    sequence,
2776                    operation_id,
2777                    operation: operation_for_journal,
2778                    kind,
2779                    touched_nodes: err.touched_nodes.clone(),
2780                    before_hash: err.before_hash,
2781                    after_hash: err.after_hash,
2782                    result: PaneOperationJournalResult::Rejected {
2783                        reason: err.reason.to_string(),
2784                    },
2785                });
2786                Err(err)
2787            }
2788        }
2789    }
2790
2791    /// Finalize and keep all successful mutations.
2792    #[must_use]
2793    pub fn commit(self) -> PaneTransactionOutcome {
2794        PaneTransactionOutcome {
2795            transaction_id: self.transaction_id,
2796            committed: true,
2797            tree: self.working_tree,
2798            journal: self.journal,
2799        }
2800    }
2801
2802    /// Finalize and discard all mutations.
2803    #[must_use]
2804    pub fn rollback(self) -> PaneTransactionOutcome {
2805        PaneTransactionOutcome {
2806            transaction_id: self.transaction_id,
2807            committed: false,
2808            tree: self.base_tree,
2809            journal: self.journal,
2810        }
2811    }
2812
2813    fn next_sequence(&mut self) -> u64 {
2814        let sequence = self.sequence;
2815        self.sequence = self.sequence.saturating_add(1);
2816        sequence
2817    }
2818}
2819
2820/// Validated pane tree model for runtime usage.
2821#[derive(Debug, Clone, PartialEq, Eq)]
2822pub struct PaneTree {
2823    schema_version: u16,
2824    root: PaneId,
2825    next_id: PaneId,
2826    nodes: BTreeMap<PaneId, PaneNodeRecord>,
2827    extensions: BTreeMap<String, String>,
2828}
2829
2830impl PaneTree {
2831    /// Build a singleton tree with one root leaf.
2832    #[must_use]
2833    pub fn singleton(surface_key: impl Into<String>) -> Self {
2834        let root = PaneId::MIN;
2835        let mut nodes = BTreeMap::new();
2836        let _ = nodes.insert(
2837            root,
2838            PaneNodeRecord::leaf(root, None, PaneLeaf::new(surface_key)),
2839        );
2840        Self {
2841            schema_version: PANE_TREE_SCHEMA_VERSION,
2842            root,
2843            next_id: root.checked_next().unwrap_or(root),
2844            nodes,
2845            extensions: BTreeMap::new(),
2846        }
2847    }
2848
2849    /// Construct and validate from a serial snapshot.
2850    pub fn from_snapshot(mut snapshot: PaneTreeSnapshot) -> Result<Self, PaneModelError> {
2851        if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
2852            return Err(PaneModelError::UnsupportedSchemaVersion {
2853                version: snapshot.schema_version,
2854            });
2855        }
2856        snapshot.canonicalize();
2857        let mut nodes = BTreeMap::new();
2858        for node in snapshot.nodes {
2859            let node_id = node.id;
2860            if nodes.insert(node_id, node).is_some() {
2861                return Err(PaneModelError::DuplicateNodeId { node_id });
2862            }
2863        }
2864        validate_tree(snapshot.root, snapshot.next_id, &nodes)?;
2865        Ok(Self {
2866            schema_version: snapshot.schema_version,
2867            root: snapshot.root,
2868            next_id: snapshot.next_id,
2869            nodes,
2870            extensions: snapshot.extensions,
2871        })
2872    }
2873
2874    /// Export to canonical snapshot form.
2875    #[must_use]
2876    pub fn to_snapshot(&self) -> PaneTreeSnapshot {
2877        let mut snapshot = PaneTreeSnapshot {
2878            schema_version: self.schema_version,
2879            root: self.root,
2880            next_id: self.next_id,
2881            nodes: self.nodes.values().cloned().collect(),
2882            extensions: self.extensions.clone(),
2883        };
2884        snapshot.canonicalize();
2885        snapshot
2886    }
2887
2888    /// Root node ID.
2889    #[must_use]
2890    pub const fn root(&self) -> PaneId {
2891        self.root
2892    }
2893
2894    /// Next deterministic ID value.
2895    #[must_use]
2896    pub const fn next_id(&self) -> PaneId {
2897        self.next_id
2898    }
2899
2900    /// Current schema version.
2901    #[must_use]
2902    pub const fn schema_version(&self) -> u16 {
2903        self.schema_version
2904    }
2905
2906    /// Lookup a node by ID.
2907    #[must_use]
2908    pub fn node(&self, id: PaneId) -> Option<&PaneNodeRecord> {
2909        self.nodes.get(&id)
2910    }
2911
2912    /// Iterate nodes in canonical ID order.
2913    pub fn nodes(&self) -> impl Iterator<Item = &PaneNodeRecord> {
2914        self.nodes.values()
2915    }
2916
2917    /// Validate internal invariants.
2918    pub fn validate(&self) -> Result<(), PaneModelError> {
2919        validate_tree(self.root, self.next_id, &self.nodes)
2920    }
2921
2922    /// Structured invariant diagnostics for the current tree snapshot.
2923    #[must_use]
2924    pub fn invariant_report(&self) -> PaneInvariantReport {
2925        self.to_snapshot().invariant_report()
2926    }
2927
2928    /// Deterministic structural hash of the current tree state.
2929    ///
2930    /// This is intended for operation logs and replay diagnostics.
2931    #[must_use]
2932    pub fn state_hash(&self) -> u64 {
2933        const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
2934        const PRIME: u64 = 0x0000_0001_0000_01b3;
2935
2936        fn mix(hash: &mut u64, byte: u8) {
2937            *hash ^= u64::from(byte);
2938            *hash = hash.wrapping_mul(PRIME);
2939        }
2940
2941        fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
2942            for byte in bytes {
2943                mix(hash, *byte);
2944            }
2945        }
2946
2947        fn mix_u16(hash: &mut u64, value: u16) {
2948            mix_bytes(hash, &value.to_le_bytes());
2949        }
2950
2951        fn mix_u32(hash: &mut u64, value: u32) {
2952            mix_bytes(hash, &value.to_le_bytes());
2953        }
2954
2955        fn mix_u64(hash: &mut u64, value: u64) {
2956            mix_bytes(hash, &value.to_le_bytes());
2957        }
2958
2959        fn mix_bool(hash: &mut u64, value: bool) {
2960            mix(hash, u8::from(value));
2961        }
2962
2963        fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
2964            match value {
2965                Some(value) => {
2966                    mix(hash, 1);
2967                    mix_u16(hash, value);
2968                }
2969                None => mix(hash, 0),
2970            }
2971        }
2972
2973        fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
2974            match value {
2975                Some(value) => {
2976                    mix(hash, 1);
2977                    mix_u64(hash, value.get());
2978                }
2979                None => mix(hash, 0),
2980            }
2981        }
2982
2983        fn mix_str(hash: &mut u64, value: &str) {
2984            mix_u64(hash, value.len() as u64);
2985            mix_bytes(hash, value.as_bytes());
2986        }
2987
2988        fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
2989            mix_u64(hash, extensions.len() as u64);
2990            for (key, value) in extensions {
2991                mix_str(hash, key);
2992                mix_str(hash, value);
2993            }
2994        }
2995
2996        fn mix_constraints(hash: &mut u64, constraints: PaneConstraints) {
2997            mix_u16(hash, constraints.min_width);
2998            mix_u16(hash, constraints.min_height);
2999            mix_opt_u16(hash, constraints.max_width);
3000            mix_opt_u16(hash, constraints.max_height);
3001            mix_bool(hash, constraints.collapsible);
3002        }
3003
3004        let mut hash = OFFSET_BASIS;
3005        mix_u16(&mut hash, self.schema_version);
3006        mix_u64(&mut hash, self.root.get());
3007        mix_u64(&mut hash, self.next_id.get());
3008        mix_extensions(&mut hash, &self.extensions);
3009        mix_u64(&mut hash, self.nodes.len() as u64);
3010
3011        for node in self.nodes.values() {
3012            mix_u64(&mut hash, node.id.get());
3013            mix_opt_pane_id(&mut hash, node.parent);
3014            mix_constraints(&mut hash, node.constraints);
3015            mix_extensions(&mut hash, &node.extensions);
3016
3017            match &node.kind {
3018                PaneNodeKind::Leaf(leaf) => {
3019                    mix(&mut hash, 1);
3020                    mix_str(&mut hash, &leaf.surface_key);
3021                    mix_extensions(&mut hash, &leaf.extensions);
3022                }
3023                PaneNodeKind::Split(split) => {
3024                    mix(&mut hash, 2);
3025                    let axis_byte = match split.axis {
3026                        SplitAxis::Horizontal => 1,
3027                        SplitAxis::Vertical => 2,
3028                    };
3029                    mix(&mut hash, axis_byte);
3030                    mix_u32(&mut hash, split.ratio.numerator());
3031                    mix_u32(&mut hash, split.ratio.denominator());
3032                    mix_u64(&mut hash, split.first.get());
3033                    mix_u64(&mut hash, split.second.get());
3034                }
3035            }
3036        }
3037
3038        hash
3039    }
3040
3041    /// Start a transaction boundary for one or more structural operations.
3042    ///
3043    /// Transactions stage mutations on a cloned working tree and provide a
3044    /// deterministic operation journal for replay, undo/redo, and auditing.
3045    #[must_use]
3046    pub fn begin_transaction(&self, transaction_id: u64) -> PaneTransaction {
3047        PaneTransaction::new(transaction_id, self.clone())
3048    }
3049
3050    /// Apply one structural operation atomically.
3051    ///
3052    /// The operation is executed on a cloned working tree. On success, the
3053    /// mutated clone replaces `self`; on failure, `self` is unchanged.
3054    pub fn apply_operation(
3055        &mut self,
3056        operation_id: u64,
3057        operation: PaneOperation,
3058    ) -> Result<PaneOperationOutcome, PaneOperationError> {
3059        let kind = operation.kind();
3060        let before_hash = self.state_hash();
3061        let mut working = self.clone();
3062        let mut touched = operation
3063            .referenced_nodes()
3064            .into_iter()
3065            .collect::<BTreeSet<_>>();
3066
3067        if let Err(reason) = working.apply_operation_inner(operation, &mut touched) {
3068            return Err(PaneOperationError {
3069                operation_id,
3070                kind,
3071                touched_nodes: touched.into_iter().collect(),
3072                before_hash,
3073                after_hash: working.state_hash(),
3074                reason,
3075            });
3076        }
3077
3078        if let Err(err) = working.validate() {
3079            return Err(PaneOperationError {
3080                operation_id,
3081                kind,
3082                touched_nodes: touched.into_iter().collect(),
3083                before_hash,
3084                after_hash: working.state_hash(),
3085                reason: PaneOperationFailure::Validation(err),
3086            });
3087        }
3088
3089        let after_hash = working.state_hash();
3090        *self = working;
3091
3092        Ok(PaneOperationOutcome {
3093            operation_id,
3094            kind,
3095            touched_nodes: touched.into_iter().collect(),
3096            before_hash,
3097            after_hash,
3098        })
3099    }
3100
3101    fn apply_operation_inner(
3102        &mut self,
3103        operation: PaneOperation,
3104        touched: &mut BTreeSet<PaneId>,
3105    ) -> Result<(), PaneOperationFailure> {
3106        match operation {
3107            PaneOperation::SplitLeaf {
3108                target,
3109                axis,
3110                ratio,
3111                placement,
3112                new_leaf,
3113            } => self.apply_split_leaf(target, axis, ratio, placement, new_leaf, touched),
3114            PaneOperation::CloseNode { target } => self.apply_close_node(target, touched),
3115            PaneOperation::MoveSubtree {
3116                source,
3117                target,
3118                axis,
3119                ratio,
3120                placement,
3121            } => self.apply_move_subtree(source, target, axis, ratio, placement, touched),
3122            PaneOperation::SwapNodes { first, second } => {
3123                self.apply_swap_nodes(first, second, touched)
3124            }
3125            PaneOperation::NormalizeRatios => self.apply_normalize_ratios(touched),
3126        }
3127    }
3128
3129    fn apply_split_leaf(
3130        &mut self,
3131        target: PaneId,
3132        axis: SplitAxis,
3133        ratio: PaneSplitRatio,
3134        placement: PanePlacement,
3135        new_leaf: PaneLeaf,
3136        touched: &mut BTreeSet<PaneId>,
3137    ) -> Result<(), PaneOperationFailure> {
3138        let target_parent = match self.nodes.get(&target) {
3139            Some(PaneNodeRecord {
3140                parent,
3141                kind: PaneNodeKind::Leaf(_),
3142                ..
3143            }) => *parent,
3144            Some(_) => {
3145                return Err(PaneOperationFailure::NodeNotLeaf { node_id: target });
3146            }
3147            None => {
3148                return Err(PaneOperationFailure::MissingNode { node_id: target });
3149            }
3150        };
3151
3152        let split_id = self.allocate_node_id()?;
3153        let new_leaf_id = self.allocate_node_id()?;
3154        touched.extend([target, split_id, new_leaf_id]);
3155        if let Some(parent_id) = target_parent {
3156            let _ = touched.insert(parent_id);
3157        }
3158
3159        let (first, second) = placement.ordered(target, new_leaf_id);
3160        let split_record = PaneNodeRecord::split(
3161            split_id,
3162            target_parent,
3163            PaneSplit {
3164                axis,
3165                ratio,
3166                first,
3167                second,
3168            },
3169        );
3170
3171        if let Some(target_node) = self.nodes.get_mut(&target) {
3172            target_node.parent = Some(split_id);
3173        }
3174        let _ = self.nodes.insert(
3175            new_leaf_id,
3176            PaneNodeRecord::leaf(new_leaf_id, Some(split_id), new_leaf),
3177        );
3178        let _ = self.nodes.insert(split_id, split_record);
3179
3180        if let Some(parent_id) = target_parent {
3181            self.replace_child(parent_id, target, split_id)?;
3182        } else {
3183            self.root = split_id;
3184        }
3185
3186        Ok(())
3187    }
3188
3189    fn apply_close_node(
3190        &mut self,
3191        target: PaneId,
3192        touched: &mut BTreeSet<PaneId>,
3193    ) -> Result<(), PaneOperationFailure> {
3194        if !self.nodes.contains_key(&target) {
3195            return Err(PaneOperationFailure::MissingNode { node_id: target });
3196        }
3197        if target == self.root {
3198            return Err(PaneOperationFailure::CannotCloseRoot { node_id: target });
3199        }
3200
3201        let subtree_ids = self.collect_subtree_ids(target)?;
3202        for node_id in &subtree_ids {
3203            let _ = touched.insert(*node_id);
3204        }
3205
3206        let (parent_id, sibling_id, grandparent_id) =
3207            self.promote_sibling_after_detach(target, touched)?;
3208        let _ = touched.insert(parent_id);
3209        let _ = touched.insert(sibling_id);
3210        if let Some(grandparent_id) = grandparent_id {
3211            let _ = touched.insert(grandparent_id);
3212        }
3213
3214        for node_id in subtree_ids {
3215            let _ = self.nodes.remove(&node_id);
3216        }
3217
3218        Ok(())
3219    }
3220
3221    fn apply_move_subtree(
3222        &mut self,
3223        source: PaneId,
3224        target: PaneId,
3225        axis: SplitAxis,
3226        ratio: PaneSplitRatio,
3227        placement: PanePlacement,
3228        touched: &mut BTreeSet<PaneId>,
3229    ) -> Result<(), PaneOperationFailure> {
3230        if source == target {
3231            return Err(PaneOperationFailure::SameNode {
3232                first: source,
3233                second: target,
3234            });
3235        }
3236
3237        if !self.nodes.contains_key(&source) {
3238            return Err(PaneOperationFailure::MissingNode { node_id: source });
3239        }
3240        if !self.nodes.contains_key(&target) {
3241            return Err(PaneOperationFailure::MissingNode { node_id: target });
3242        }
3243
3244        if source == self.root {
3245            return Err(PaneOperationFailure::CannotMoveRoot { node_id: source });
3246        }
3247        if self.is_ancestor(source, target)? {
3248            return Err(PaneOperationFailure::AncestorConflict {
3249                ancestor: source,
3250                descendant: target,
3251            });
3252        }
3253
3254        let source_parent = self
3255            .nodes
3256            .get(&source)
3257            .and_then(|node| node.parent)
3258            .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: source })?;
3259        if source_parent == target {
3260            return Err(PaneOperationFailure::TargetRemovedByDetach {
3261                target,
3262                detached_parent: source_parent,
3263            });
3264        }
3265
3266        let _ = touched.insert(source);
3267        let _ = touched.insert(target);
3268        let _ = touched.insert(source_parent);
3269
3270        let (removed_parent, sibling_id, grandparent_id) =
3271            self.promote_sibling_after_detach(source, touched)?;
3272        let _ = touched.insert(removed_parent);
3273        let _ = touched.insert(sibling_id);
3274        if let Some(grandparent_id) = grandparent_id {
3275            let _ = touched.insert(grandparent_id);
3276        }
3277
3278        if let Some(source_node) = self.nodes.get_mut(&source) {
3279            source_node.parent = None;
3280        }
3281
3282        if !self.nodes.contains_key(&target) {
3283            return Err(PaneOperationFailure::MissingNode { node_id: target });
3284        }
3285        let target_parent = self.nodes.get(&target).and_then(|node| node.parent);
3286        if let Some(parent_id) = target_parent {
3287            let _ = touched.insert(parent_id);
3288        }
3289
3290        let split_id = self.allocate_node_id()?;
3291        let _ = touched.insert(split_id);
3292        let (first, second) = placement.ordered(target, source);
3293
3294        if let Some(target_node) = self.nodes.get_mut(&target) {
3295            target_node.parent = Some(split_id);
3296        }
3297        if let Some(source_node) = self.nodes.get_mut(&source) {
3298            source_node.parent = Some(split_id);
3299        }
3300
3301        let _ = self.nodes.insert(
3302            split_id,
3303            PaneNodeRecord::split(
3304                split_id,
3305                target_parent,
3306                PaneSplit {
3307                    axis,
3308                    ratio,
3309                    first,
3310                    second,
3311                },
3312            ),
3313        );
3314
3315        if let Some(parent_id) = target_parent {
3316            self.replace_child(parent_id, target, split_id)?;
3317        } else {
3318            self.root = split_id;
3319        }
3320
3321        Ok(())
3322    }
3323
3324    fn apply_swap_nodes(
3325        &mut self,
3326        first: PaneId,
3327        second: PaneId,
3328        touched: &mut BTreeSet<PaneId>,
3329    ) -> Result<(), PaneOperationFailure> {
3330        if first == second {
3331            return Ok(());
3332        }
3333
3334        if !self.nodes.contains_key(&first) {
3335            return Err(PaneOperationFailure::MissingNode { node_id: first });
3336        }
3337        if !self.nodes.contains_key(&second) {
3338            return Err(PaneOperationFailure::MissingNode { node_id: second });
3339        }
3340        if self.is_ancestor(first, second)? {
3341            return Err(PaneOperationFailure::AncestorConflict {
3342                ancestor: first,
3343                descendant: second,
3344            });
3345        }
3346        if self.is_ancestor(second, first)? {
3347            return Err(PaneOperationFailure::AncestorConflict {
3348                ancestor: second,
3349                descendant: first,
3350            });
3351        }
3352
3353        let _ = touched.insert(first);
3354        let _ = touched.insert(second);
3355
3356        let first_parent = self.nodes.get(&first).and_then(|node| node.parent);
3357        let second_parent = self.nodes.get(&second).and_then(|node| node.parent);
3358
3359        if first_parent == second_parent {
3360            if let Some(parent_id) = first_parent {
3361                let _ = touched.insert(parent_id);
3362                self.swap_children(parent_id, first, second)?;
3363            }
3364            return Ok(());
3365        }
3366
3367        match (first_parent, second_parent) {
3368            (Some(left_parent), Some(right_parent)) => {
3369                let _ = touched.insert(left_parent);
3370                let _ = touched.insert(right_parent);
3371                self.replace_child(left_parent, first, second)?;
3372                self.replace_child(right_parent, second, first)?;
3373                if let Some(left) = self.nodes.get_mut(&first) {
3374                    left.parent = Some(right_parent);
3375                }
3376                if let Some(right) = self.nodes.get_mut(&second) {
3377                    right.parent = Some(left_parent);
3378                }
3379            }
3380            (None, Some(parent_id)) => {
3381                let _ = touched.insert(parent_id);
3382                self.replace_child(parent_id, second, first)?;
3383                if let Some(first_node) = self.nodes.get_mut(&first) {
3384                    first_node.parent = Some(parent_id);
3385                }
3386                if let Some(second_node) = self.nodes.get_mut(&second) {
3387                    second_node.parent = None;
3388                }
3389                self.root = second;
3390            }
3391            (Some(parent_id), None) => {
3392                let _ = touched.insert(parent_id);
3393                self.replace_child(parent_id, first, second)?;
3394                if let Some(first_node) = self.nodes.get_mut(&first) {
3395                    first_node.parent = None;
3396                }
3397                if let Some(second_node) = self.nodes.get_mut(&second) {
3398                    second_node.parent = Some(parent_id);
3399                }
3400                self.root = first;
3401            }
3402            (None, None) => {}
3403        }
3404
3405        Ok(())
3406    }
3407
3408    fn apply_normalize_ratios(
3409        &mut self,
3410        touched: &mut BTreeSet<PaneId>,
3411    ) -> Result<(), PaneOperationFailure> {
3412        for node in self.nodes.values_mut() {
3413            if let PaneNodeKind::Split(split) = &mut node.kind {
3414                let normalized =
3415                    PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator())
3416                        .map_err(|_| PaneOperationFailure::InvalidRatio {
3417                            node_id: node.id,
3418                            numerator: split.ratio.numerator(),
3419                            denominator: split.ratio.denominator(),
3420                        })?;
3421                split.ratio = normalized;
3422                let _ = touched.insert(node.id);
3423            }
3424        }
3425        Ok(())
3426    }
3427
3428    fn replace_child(
3429        &mut self,
3430        parent_id: PaneId,
3431        old_child: PaneId,
3432        new_child: PaneId,
3433    ) -> Result<(), PaneOperationFailure> {
3434        let parent = self
3435            .nodes
3436            .get_mut(&parent_id)
3437            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3438        let PaneNodeKind::Split(split) = &mut parent.kind else {
3439            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3440        };
3441
3442        if split.first == old_child {
3443            split.first = new_child;
3444            return Ok(());
3445        }
3446        if split.second == old_child {
3447            split.second = new_child;
3448            return Ok(());
3449        }
3450
3451        Err(PaneOperationFailure::ParentChildMismatch {
3452            parent: parent_id,
3453            child: old_child,
3454        })
3455    }
3456
3457    fn swap_children(
3458        &mut self,
3459        parent_id: PaneId,
3460        left: PaneId,
3461        right: PaneId,
3462    ) -> Result<(), PaneOperationFailure> {
3463        let parent = self
3464            .nodes
3465            .get_mut(&parent_id)
3466            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3467        let PaneNodeKind::Split(split) = &mut parent.kind else {
3468            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3469        };
3470
3471        let has_pair = (split.first == left && split.second == right)
3472            || (split.first == right && split.second == left);
3473        if !has_pair {
3474            return Err(PaneOperationFailure::ParentChildMismatch {
3475                parent: parent_id,
3476                child: left,
3477            });
3478        }
3479
3480        std::mem::swap(&mut split.first, &mut split.second);
3481        Ok(())
3482    }
3483
3484    fn promote_sibling_after_detach(
3485        &mut self,
3486        detached: PaneId,
3487        touched: &mut BTreeSet<PaneId>,
3488    ) -> Result<(PaneId, PaneId, Option<PaneId>), PaneOperationFailure> {
3489        let parent_id = self
3490            .nodes
3491            .get(&detached)
3492            .ok_or(PaneOperationFailure::MissingNode { node_id: detached })?
3493            .parent
3494            .ok_or(PaneOperationFailure::CannotMoveRoot { node_id: detached })?;
3495        let parent_node = self
3496            .nodes
3497            .get(&parent_id)
3498            .ok_or(PaneOperationFailure::MissingNode { node_id: parent_id })?;
3499        let PaneNodeKind::Split(parent_split) = &parent_node.kind else {
3500            return Err(PaneOperationFailure::ParentNotSplit { node_id: parent_id });
3501        };
3502
3503        let sibling_id = if parent_split.first == detached {
3504            parent_split.second
3505        } else if parent_split.second == detached {
3506            parent_split.first
3507        } else {
3508            return Err(PaneOperationFailure::ParentChildMismatch {
3509                parent: parent_id,
3510                child: detached,
3511            });
3512        };
3513
3514        let grandparent_id = parent_node.parent;
3515        let _ = touched.insert(parent_id);
3516        let _ = touched.insert(sibling_id);
3517        if let Some(grandparent_id) = grandparent_id {
3518            let _ = touched.insert(grandparent_id);
3519            self.replace_child(grandparent_id, parent_id, sibling_id)?;
3520        } else {
3521            self.root = sibling_id;
3522        }
3523
3524        let sibling_node =
3525            self.nodes
3526                .get_mut(&sibling_id)
3527                .ok_or(PaneOperationFailure::MissingNode {
3528                    node_id: sibling_id,
3529                })?;
3530        sibling_node.parent = grandparent_id;
3531        let _ = self.nodes.remove(&parent_id);
3532
3533        Ok((parent_id, sibling_id, grandparent_id))
3534    }
3535
3536    fn is_ancestor(
3537        &self,
3538        ancestor: PaneId,
3539        mut node_id: PaneId,
3540    ) -> Result<bool, PaneOperationFailure> {
3541        loop {
3542            let node = self
3543                .nodes
3544                .get(&node_id)
3545                .ok_or(PaneOperationFailure::MissingNode { node_id })?;
3546            let Some(parent_id) = node.parent else {
3547                return Ok(false);
3548            };
3549            if parent_id == ancestor {
3550                return Ok(true);
3551            }
3552            node_id = parent_id;
3553        }
3554    }
3555
3556    fn collect_subtree_ids(&self, root_id: PaneId) -> Result<Vec<PaneId>, PaneOperationFailure> {
3557        if !self.nodes.contains_key(&root_id) {
3558            return Err(PaneOperationFailure::MissingNode { node_id: root_id });
3559        }
3560
3561        let mut out = Vec::new();
3562        let mut stack = vec![root_id];
3563        while let Some(node_id) = stack.pop() {
3564            let node = self
3565                .nodes
3566                .get(&node_id)
3567                .ok_or(PaneOperationFailure::MissingNode { node_id })?;
3568            out.push(node_id);
3569            if let PaneNodeKind::Split(split) = &node.kind {
3570                stack.push(split.first);
3571                stack.push(split.second);
3572            }
3573        }
3574        Ok(out)
3575    }
3576
3577    fn allocate_node_id(&mut self) -> Result<PaneId, PaneOperationFailure> {
3578        let current = self.next_id;
3579        self.next_id = self
3580            .next_id
3581            .checked_next()
3582            .map_err(|_| PaneOperationFailure::PaneIdOverflow { current })?;
3583        Ok(current)
3584    }
3585
3586    /// Solve the split-tree into concrete rectangles for the provided viewport.
3587    ///
3588    /// Deterministic tie-break rule:
3589    /// - Desired split size is `floor(available * ratio)`.
3590    /// - If clamping is required by constraints, we clamp into the feasible
3591    ///   interval for the first child; remainder goes to the second child.
3592    ///
3593    /// Complexity:
3594    /// - Time: `O(node_count)` (single DFS over split tree)
3595    /// - Space: `O(node_count)` (output rectangle map)
3596    pub fn solve_layout(&self, area: Rect) -> Result<PaneLayout, PaneModelError> {
3597        let mut rects = BTreeMap::new();
3598        self.solve_node(self.root, area, &mut rects)?;
3599        Ok(PaneLayout { area, rects })
3600    }
3601
3602    fn solve_node(
3603        &self,
3604        node_id: PaneId,
3605        area: Rect,
3606        rects: &mut BTreeMap<PaneId, Rect>,
3607    ) -> Result<(), PaneModelError> {
3608        let Some(node) = self.nodes.get(&node_id) else {
3609            return Err(PaneModelError::MissingRoot { root: node_id });
3610        };
3611
3612        validate_area_against_constraints(node_id, area, node.constraints)?;
3613        let _ = rects.insert(node_id, area);
3614
3615        let PaneNodeKind::Split(split) = &node.kind else {
3616            return Ok(());
3617        };
3618
3619        let first_node = self
3620            .nodes
3621            .get(&split.first)
3622            .ok_or(PaneModelError::MissingChild {
3623                parent: node_id,
3624                child: split.first,
3625            })?;
3626        let second_node = self
3627            .nodes
3628            .get(&split.second)
3629            .ok_or(PaneModelError::MissingChild {
3630                parent: node_id,
3631                child: split.second,
3632            })?;
3633
3634        let (first_bounds, second_bounds, available) = match split.axis {
3635            SplitAxis::Horizontal => (
3636                axis_bounds(first_node.constraints, split.axis),
3637                axis_bounds(second_node.constraints, split.axis),
3638                area.width,
3639            ),
3640            SplitAxis::Vertical => (
3641                axis_bounds(first_node.constraints, split.axis),
3642                axis_bounds(second_node.constraints, split.axis),
3643                area.height,
3644            ),
3645        };
3646
3647        let (first_size, second_size) = solve_split_sizes(
3648            node_id,
3649            split.axis,
3650            available,
3651            split.ratio,
3652            first_bounds,
3653            second_bounds,
3654        )?;
3655
3656        let (first_rect, second_rect) = match split.axis {
3657            SplitAxis::Horizontal => (
3658                Rect::new(area.x, area.y, first_size, area.height),
3659                Rect::new(
3660                    area.x.saturating_add(first_size),
3661                    area.y,
3662                    second_size,
3663                    area.height,
3664                ),
3665            ),
3666            SplitAxis::Vertical => (
3667                Rect::new(area.x, area.y, area.width, first_size),
3668                Rect::new(
3669                    area.x,
3670                    area.y.saturating_add(first_size),
3671                    area.width,
3672                    second_size,
3673                ),
3674            ),
3675        };
3676
3677        self.solve_node(split.first, first_rect, rects)?;
3678        self.solve_node(split.second, second_rect, rects)?;
3679        Ok(())
3680    }
3681}
3682
3683/// Deterministic allocator for pane IDs.
3684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3685pub struct PaneIdAllocator {
3686    next: PaneId,
3687}
3688
3689impl PaneIdAllocator {
3690    /// Start allocating from a known ID.
3691    #[must_use]
3692    pub const fn with_next(next: PaneId) -> Self {
3693        Self { next }
3694    }
3695
3696    /// Create allocator from the next ID in a validated tree.
3697    #[must_use]
3698    pub fn from_tree(tree: &PaneTree) -> Self {
3699        Self { next: tree.next_id }
3700    }
3701
3702    /// Peek at the next ID without consuming.
3703    #[must_use]
3704    pub const fn peek(&self) -> PaneId {
3705        self.next
3706    }
3707
3708    /// Allocate the next ID and advance.
3709    pub fn allocate(&mut self) -> Result<PaneId, PaneModelError> {
3710        let current = self.next;
3711        self.next = self.next.checked_next()?;
3712        Ok(current)
3713    }
3714}
3715
3716impl Default for PaneIdAllocator {
3717    fn default() -> Self {
3718        Self { next: PaneId::MIN }
3719    }
3720}
3721
3722/// Validation errors for pane schema construction.
3723#[derive(Debug, Clone, PartialEq, Eq)]
3724pub enum PaneModelError {
3725    ZeroPaneId,
3726    UnsupportedSchemaVersion {
3727        version: u16,
3728    },
3729    DuplicateNodeId {
3730        node_id: PaneId,
3731    },
3732    MissingRoot {
3733        root: PaneId,
3734    },
3735    RootHasParent {
3736        root: PaneId,
3737        parent: PaneId,
3738    },
3739    MissingParent {
3740        node_id: PaneId,
3741        parent: PaneId,
3742    },
3743    MissingChild {
3744        parent: PaneId,
3745        child: PaneId,
3746    },
3747    MultipleParents {
3748        child: PaneId,
3749        first_parent: PaneId,
3750        second_parent: PaneId,
3751    },
3752    ParentMismatch {
3753        node_id: PaneId,
3754        expected: Option<PaneId>,
3755        actual: Option<PaneId>,
3756    },
3757    SelfReferentialSplit {
3758        node_id: PaneId,
3759    },
3760    DuplicateSplitChildren {
3761        node_id: PaneId,
3762        child: PaneId,
3763    },
3764    InvalidSplitRatio {
3765        numerator: u32,
3766        denominator: u32,
3767    },
3768    InvalidConstraint {
3769        node_id: PaneId,
3770        axis: &'static str,
3771        min: u16,
3772        max: u16,
3773    },
3774    NodeConstraintUnsatisfied {
3775        node_id: PaneId,
3776        axis: &'static str,
3777        actual: u16,
3778        min: u16,
3779        max: Option<u16>,
3780    },
3781    OverconstrainedSplit {
3782        node_id: PaneId,
3783        axis: SplitAxis,
3784        available: u16,
3785        first_min: u16,
3786        first_max: u16,
3787        second_min: u16,
3788        second_max: u16,
3789    },
3790    CycleDetected {
3791        node_id: PaneId,
3792    },
3793    UnreachableNode {
3794        node_id: PaneId,
3795    },
3796    NextIdNotGreaterThanExisting {
3797        next_id: PaneId,
3798        max_existing: PaneId,
3799    },
3800    PaneIdOverflow {
3801        current: PaneId,
3802    },
3803}
3804
3805impl fmt::Display for PaneModelError {
3806    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3807        match self {
3808            Self::ZeroPaneId => write!(f, "pane id 0 is invalid"),
3809            Self::UnsupportedSchemaVersion { version } => {
3810                write!(
3811                    f,
3812                    "unsupported pane schema version {version} (expected {PANE_TREE_SCHEMA_VERSION})"
3813                )
3814            }
3815            Self::DuplicateNodeId { node_id } => write!(f, "duplicate pane node id {}", node_id.0),
3816            Self::MissingRoot { root } => write!(f, "root pane node {} not found", root.0),
3817            Self::RootHasParent { root, parent } => write!(
3818                f,
3819                "root pane node {} must not have parent {}",
3820                root.0, parent.0
3821            ),
3822            Self::MissingParent { node_id, parent } => write!(
3823                f,
3824                "node {} references missing parent {}",
3825                node_id.0, parent.0
3826            ),
3827            Self::MissingChild { parent, child } => write!(
3828                f,
3829                "split node {} references missing child {}",
3830                parent.0, child.0
3831            ),
3832            Self::MultipleParents {
3833                child,
3834                first_parent,
3835                second_parent,
3836            } => write!(
3837                f,
3838                "node {} has multiple parents: {} and {}",
3839                child.0, first_parent.0, second_parent.0
3840            ),
3841            Self::ParentMismatch {
3842                node_id,
3843                expected,
3844                actual,
3845            } => write!(
3846                f,
3847                "node {} parent mismatch: expected {:?}, got {:?}",
3848                node_id.0,
3849                expected.map(PaneId::get),
3850                actual.map(PaneId::get)
3851            ),
3852            Self::SelfReferentialSplit { node_id } => {
3853                write!(f, "split node {} cannot reference itself", node_id.0)
3854            }
3855            Self::DuplicateSplitChildren { node_id, child } => write!(
3856                f,
3857                "split node {} references child {} twice",
3858                node_id.0, child.0
3859            ),
3860            Self::InvalidSplitRatio {
3861                numerator,
3862                denominator,
3863            } => write!(
3864                f,
3865                "invalid split ratio {numerator}/{denominator}: both values must be > 0"
3866            ),
3867            Self::InvalidConstraint {
3868                node_id,
3869                axis,
3870                min,
3871                max,
3872            } => write!(
3873                f,
3874                "invalid {axis} constraints for node {}: max {max} < min {min}",
3875                node_id.0
3876            ),
3877            Self::NodeConstraintUnsatisfied {
3878                node_id,
3879                axis,
3880                actual,
3881                min,
3882                max,
3883            } => write!(
3884                f,
3885                "node {} {axis}={} violates constraints [min={}, max={:?}]",
3886                node_id.0, actual, min, max
3887            ),
3888            Self::OverconstrainedSplit {
3889                node_id,
3890                axis,
3891                available,
3892                first_min,
3893                first_max,
3894                second_min,
3895                second_max,
3896            } => write!(
3897                f,
3898                "overconstrained {:?} split at node {} (available={}): first[min={}, max={}], second[min={}, max={}]",
3899                axis, node_id.0, available, first_min, first_max, second_min, second_max
3900            ),
3901            Self::CycleDetected { node_id } => {
3902                write!(f, "cycle detected at node {}", node_id.0)
3903            }
3904            Self::UnreachableNode { node_id } => {
3905                write!(f, "node {} is unreachable from root", node_id.0)
3906            }
3907            Self::NextIdNotGreaterThanExisting {
3908                next_id,
3909                max_existing,
3910            } => write!(
3911                f,
3912                "next_id {} must be greater than max existing id {}",
3913                next_id.0, max_existing.0
3914            ),
3915            Self::PaneIdOverflow { current } => {
3916                write!(f, "pane id overflow after {}", current.0)
3917            }
3918        }
3919    }
3920}
3921
3922impl std::error::Error for PaneModelError {}
3923
3924fn snapshot_state_hash(snapshot: &PaneTreeSnapshot) -> u64 {
3925    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
3926    const PRIME: u64 = 0x0000_0001_0000_01b3;
3927
3928    fn mix(hash: &mut u64, byte: u8) {
3929        *hash ^= u64::from(byte);
3930        *hash = hash.wrapping_mul(PRIME);
3931    }
3932
3933    fn mix_bytes(hash: &mut u64, bytes: &[u8]) {
3934        for byte in bytes {
3935            mix(hash, *byte);
3936        }
3937    }
3938
3939    fn mix_u16(hash: &mut u64, value: u16) {
3940        mix_bytes(hash, &value.to_le_bytes());
3941    }
3942
3943    fn mix_u32(hash: &mut u64, value: u32) {
3944        mix_bytes(hash, &value.to_le_bytes());
3945    }
3946
3947    fn mix_u64(hash: &mut u64, value: u64) {
3948        mix_bytes(hash, &value.to_le_bytes());
3949    }
3950
3951    fn mix_bool(hash: &mut u64, value: bool) {
3952        mix(hash, u8::from(value));
3953    }
3954
3955    fn mix_opt_u16(hash: &mut u64, value: Option<u16>) {
3956        match value {
3957            Some(value) => {
3958                mix(hash, 1);
3959                mix_u16(hash, value);
3960            }
3961            None => mix(hash, 0),
3962        }
3963    }
3964
3965    fn mix_opt_pane_id(hash: &mut u64, value: Option<PaneId>) {
3966        match value {
3967            Some(value) => {
3968                mix(hash, 1);
3969                mix_u64(hash, value.get());
3970            }
3971            None => mix(hash, 0),
3972        }
3973    }
3974
3975    fn mix_str(hash: &mut u64, value: &str) {
3976        mix_u64(hash, value.len() as u64);
3977        mix_bytes(hash, value.as_bytes());
3978    }
3979
3980    fn mix_extensions(hash: &mut u64, extensions: &BTreeMap<String, String>) {
3981        mix_u64(hash, extensions.len() as u64);
3982        for (key, value) in extensions {
3983            mix_str(hash, key);
3984            mix_str(hash, value);
3985        }
3986    }
3987
3988    let mut canonical = snapshot.clone();
3989    canonical.canonicalize();
3990
3991    let mut hash = OFFSET_BASIS;
3992    mix_u16(&mut hash, canonical.schema_version);
3993    mix_u64(&mut hash, canonical.root.get());
3994    mix_u64(&mut hash, canonical.next_id.get());
3995    mix_extensions(&mut hash, &canonical.extensions);
3996    mix_u64(&mut hash, canonical.nodes.len() as u64);
3997
3998    for node in &canonical.nodes {
3999        mix_u64(&mut hash, node.id.get());
4000        mix_opt_pane_id(&mut hash, node.parent);
4001        mix_u16(&mut hash, node.constraints.min_width);
4002        mix_u16(&mut hash, node.constraints.min_height);
4003        mix_opt_u16(&mut hash, node.constraints.max_width);
4004        mix_opt_u16(&mut hash, node.constraints.max_height);
4005        mix_bool(&mut hash, node.constraints.collapsible);
4006        mix_extensions(&mut hash, &node.extensions);
4007
4008        match &node.kind {
4009            PaneNodeKind::Leaf(leaf) => {
4010                mix(&mut hash, 1);
4011                mix_str(&mut hash, &leaf.surface_key);
4012                mix_extensions(&mut hash, &leaf.extensions);
4013            }
4014            PaneNodeKind::Split(split) => {
4015                mix(&mut hash, 2);
4016                let axis_byte = match split.axis {
4017                    SplitAxis::Horizontal => 1,
4018                    SplitAxis::Vertical => 2,
4019                };
4020                mix(&mut hash, axis_byte);
4021                mix_u32(&mut hash, split.ratio.numerator());
4022                mix_u32(&mut hash, split.ratio.denominator());
4023                mix_u64(&mut hash, split.first.get());
4024                mix_u64(&mut hash, split.second.get());
4025            }
4026        }
4027    }
4028
4029    hash
4030}
4031
4032fn push_invariant_issue(
4033    issues: &mut Vec<PaneInvariantIssue>,
4034    code: PaneInvariantCode,
4035    repairable: bool,
4036    node_id: Option<PaneId>,
4037    related_node: Option<PaneId>,
4038    message: impl Into<String>,
4039) {
4040    issues.push(PaneInvariantIssue {
4041        code,
4042        severity: PaneInvariantSeverity::Error,
4043        repairable,
4044        node_id,
4045        related_node,
4046        message: message.into(),
4047    });
4048}
4049
4050fn dfs_collect_cycles_and_reachable(
4051    node_id: PaneId,
4052    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4053    visiting: &mut BTreeSet<PaneId>,
4054    visited: &mut BTreeSet<PaneId>,
4055    cycle_nodes: &mut BTreeSet<PaneId>,
4056) {
4057    if visiting.contains(&node_id) {
4058        let _ = cycle_nodes.insert(node_id);
4059        return;
4060    }
4061    if !visited.insert(node_id) {
4062        return;
4063    }
4064
4065    let _ = visiting.insert(node_id);
4066    if let Some(node) = nodes.get(&node_id)
4067        && let PaneNodeKind::Split(split) = &node.kind
4068    {
4069        for child in [split.first, split.second] {
4070            if nodes.contains_key(&child) {
4071                dfs_collect_cycles_and_reachable(child, nodes, visiting, visited, cycle_nodes);
4072            }
4073        }
4074    }
4075    let _ = visiting.remove(&node_id);
4076}
4077
4078fn build_invariant_report(snapshot: &PaneTreeSnapshot) -> PaneInvariantReport {
4079    let mut issues = Vec::new();
4080
4081    if snapshot.schema_version != PANE_TREE_SCHEMA_VERSION {
4082        push_invariant_issue(
4083            &mut issues,
4084            PaneInvariantCode::UnsupportedSchemaVersion,
4085            false,
4086            None,
4087            None,
4088            format!(
4089                "unsupported schema version {} (expected {})",
4090                snapshot.schema_version, PANE_TREE_SCHEMA_VERSION
4091            ),
4092        );
4093    }
4094
4095    let mut nodes = BTreeMap::new();
4096    for node in &snapshot.nodes {
4097        if nodes.insert(node.id, node.clone()).is_some() {
4098            push_invariant_issue(
4099                &mut issues,
4100                PaneInvariantCode::DuplicateNodeId,
4101                false,
4102                Some(node.id),
4103                None,
4104                format!("duplicate node id {}", node.id.get()),
4105            );
4106        }
4107    }
4108
4109    if let Some(max_existing) = nodes.keys().next_back().copied()
4110        && snapshot.next_id <= max_existing
4111    {
4112        push_invariant_issue(
4113            &mut issues,
4114            PaneInvariantCode::NextIdNotGreaterThanExisting,
4115            true,
4116            Some(snapshot.next_id),
4117            Some(max_existing),
4118            format!(
4119                "next_id {} must be greater than max node id {}",
4120                snapshot.next_id.get(),
4121                max_existing.get()
4122            ),
4123        );
4124    }
4125
4126    if !nodes.contains_key(&snapshot.root) {
4127        push_invariant_issue(
4128            &mut issues,
4129            PaneInvariantCode::MissingRoot,
4130            false,
4131            Some(snapshot.root),
4132            None,
4133            format!("root node {} is missing", snapshot.root.get()),
4134        );
4135    }
4136
4137    let mut expected_parents = BTreeMap::new();
4138    for node in nodes.values() {
4139        if let Err(err) = node.constraints.validate(node.id) {
4140            push_invariant_issue(
4141                &mut issues,
4142                PaneInvariantCode::InvalidConstraint,
4143                false,
4144                Some(node.id),
4145                None,
4146                err.to_string(),
4147            );
4148        }
4149
4150        if let Some(parent) = node.parent
4151            && !nodes.contains_key(&parent)
4152        {
4153            push_invariant_issue(
4154                &mut issues,
4155                PaneInvariantCode::MissingParent,
4156                true,
4157                Some(node.id),
4158                Some(parent),
4159                format!(
4160                    "node {} references missing parent {}",
4161                    node.id.get(),
4162                    parent.get()
4163                ),
4164            );
4165        }
4166
4167        if let PaneNodeKind::Split(split) = &node.kind {
4168            if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
4169                push_invariant_issue(
4170                    &mut issues,
4171                    PaneInvariantCode::InvalidSplitRatio,
4172                    false,
4173                    Some(node.id),
4174                    None,
4175                    format!(
4176                        "split node {} has invalid ratio {}/{}",
4177                        node.id.get(),
4178                        split.ratio.numerator(),
4179                        split.ratio.denominator()
4180                    ),
4181                );
4182            }
4183
4184            if split.first == node.id || split.second == node.id {
4185                push_invariant_issue(
4186                    &mut issues,
4187                    PaneInvariantCode::SelfReferentialSplit,
4188                    false,
4189                    Some(node.id),
4190                    None,
4191                    format!("split node {} references itself", node.id.get()),
4192                );
4193            }
4194
4195            if split.first == split.second {
4196                push_invariant_issue(
4197                    &mut issues,
4198                    PaneInvariantCode::DuplicateSplitChildren,
4199                    false,
4200                    Some(node.id),
4201                    Some(split.first),
4202                    format!(
4203                        "split node {} references child {} twice",
4204                        node.id.get(),
4205                        split.first.get()
4206                    ),
4207                );
4208            }
4209
4210            for child in [split.first, split.second] {
4211                if !nodes.contains_key(&child) {
4212                    push_invariant_issue(
4213                        &mut issues,
4214                        PaneInvariantCode::MissingChild,
4215                        false,
4216                        Some(node.id),
4217                        Some(child),
4218                        format!(
4219                            "split node {} references missing child {}",
4220                            node.id.get(),
4221                            child.get()
4222                        ),
4223                    );
4224                    continue;
4225                }
4226
4227                if let Some(first_parent) = expected_parents.insert(child, node.id)
4228                    && first_parent != node.id
4229                {
4230                    push_invariant_issue(
4231                        &mut issues,
4232                        PaneInvariantCode::MultipleParents,
4233                        false,
4234                        Some(child),
4235                        Some(node.id),
4236                        format!(
4237                            "node {} has multiple split parents {} and {}",
4238                            child.get(),
4239                            first_parent.get(),
4240                            node.id.get()
4241                        ),
4242                    );
4243                }
4244            }
4245        }
4246    }
4247
4248    if let Some(root_node) = nodes.get(&snapshot.root)
4249        && let Some(parent) = root_node.parent
4250    {
4251        push_invariant_issue(
4252            &mut issues,
4253            PaneInvariantCode::RootHasParent,
4254            true,
4255            Some(snapshot.root),
4256            Some(parent),
4257            format!(
4258                "root node {} must not have parent {}",
4259                snapshot.root.get(),
4260                parent.get()
4261            ),
4262        );
4263    }
4264
4265    for node in nodes.values() {
4266        let expected_parent = if node.id == snapshot.root {
4267            None
4268        } else {
4269            expected_parents.get(&node.id).copied()
4270        };
4271
4272        if node.parent != expected_parent {
4273            push_invariant_issue(
4274                &mut issues,
4275                PaneInvariantCode::ParentMismatch,
4276                true,
4277                Some(node.id),
4278                expected_parent,
4279                format!(
4280                    "node {} parent mismatch: expected {:?}, got {:?}",
4281                    node.id.get(),
4282                    expected_parent.map(PaneId::get),
4283                    node.parent.map(PaneId::get)
4284                ),
4285            );
4286        }
4287    }
4288
4289    if nodes.contains_key(&snapshot.root) {
4290        let mut visiting = BTreeSet::new();
4291        let mut visited = BTreeSet::new();
4292        let mut cycle_nodes = BTreeSet::new();
4293        dfs_collect_cycles_and_reachable(
4294            snapshot.root,
4295            &nodes,
4296            &mut visiting,
4297            &mut visited,
4298            &mut cycle_nodes,
4299        );
4300
4301        for node_id in cycle_nodes {
4302            push_invariant_issue(
4303                &mut issues,
4304                PaneInvariantCode::CycleDetected,
4305                false,
4306                Some(node_id),
4307                None,
4308                format!("cycle detected at node {}", node_id.get()),
4309            );
4310        }
4311
4312        for node_id in nodes.keys() {
4313            if !visited.contains(node_id) {
4314                push_invariant_issue(
4315                    &mut issues,
4316                    PaneInvariantCode::UnreachableNode,
4317                    true,
4318                    Some(*node_id),
4319                    None,
4320                    format!("node {} is unreachable from root", node_id.get()),
4321                );
4322            }
4323        }
4324    }
4325
4326    issues.sort_by(|left, right| {
4327        (
4328            left.code,
4329            left.node_id.is_none(),
4330            left.node_id,
4331            left.related_node.is_none(),
4332            left.related_node,
4333            &left.message,
4334        )
4335            .cmp(&(
4336                right.code,
4337                right.node_id.is_none(),
4338                right.node_id,
4339                right.related_node.is_none(),
4340                right.related_node,
4341                &right.message,
4342            ))
4343    });
4344
4345    PaneInvariantReport {
4346        snapshot_hash: snapshot_state_hash(snapshot),
4347        issues,
4348    }
4349}
4350
4351fn repair_snapshot_safe(
4352    mut snapshot: PaneTreeSnapshot,
4353) -> Result<PaneRepairOutcome, PaneRepairError> {
4354    snapshot.canonicalize();
4355
4356    let before_hash = snapshot_state_hash(&snapshot);
4357    let report_before = build_invariant_report(&snapshot);
4358    let mut unsafe_codes = report_before
4359        .issues
4360        .iter()
4361        .filter(|issue| issue.severity == PaneInvariantSeverity::Error && !issue.repairable)
4362        .map(|issue| issue.code)
4363        .collect::<Vec<_>>();
4364    unsafe_codes.sort();
4365    unsafe_codes.dedup();
4366
4367    if !unsafe_codes.is_empty() {
4368        return Err(PaneRepairError {
4369            before_hash,
4370            report: report_before,
4371            reason: PaneRepairFailure::UnsafeIssuesPresent {
4372                codes: unsafe_codes,
4373            },
4374        });
4375    }
4376
4377    let mut nodes = BTreeMap::new();
4378    for node in snapshot.nodes {
4379        let _ = nodes.entry(node.id).or_insert(node);
4380    }
4381
4382    let mut actions = Vec::new();
4383    let mut expected_parents = BTreeMap::new();
4384    for node in nodes.values() {
4385        if let PaneNodeKind::Split(split) = &node.kind {
4386            for child in [split.first, split.second] {
4387                let _ = expected_parents.entry(child).or_insert(node.id);
4388            }
4389        }
4390    }
4391
4392    for node in nodes.values_mut() {
4393        let expected_parent = if node.id == snapshot.root {
4394            None
4395        } else {
4396            expected_parents.get(&node.id).copied()
4397        };
4398        if node.parent != expected_parent {
4399            actions.push(PaneRepairAction::ReparentNode {
4400                node_id: node.id,
4401                before_parent: node.parent,
4402                after_parent: expected_parent,
4403            });
4404            node.parent = expected_parent;
4405        }
4406
4407        if let PaneNodeKind::Split(split) = &mut node.kind {
4408            let normalized =
4409                PaneSplitRatio::new(split.ratio.numerator(), split.ratio.denominator()).map_err(
4410                    |error| PaneRepairError {
4411                        before_hash,
4412                        report: report_before.clone(),
4413                        reason: PaneRepairFailure::ValidationFailed { error },
4414                    },
4415                )?;
4416            if split.ratio != normalized {
4417                actions.push(PaneRepairAction::NormalizeRatio {
4418                    node_id: node.id,
4419                    before_numerator: split.ratio.numerator(),
4420                    before_denominator: split.ratio.denominator(),
4421                    after_numerator: normalized.numerator(),
4422                    after_denominator: normalized.denominator(),
4423                });
4424                split.ratio = normalized;
4425            }
4426        }
4427    }
4428
4429    let mut visiting = BTreeSet::new();
4430    let mut visited = BTreeSet::new();
4431    let mut cycle_nodes = BTreeSet::new();
4432    if nodes.contains_key(&snapshot.root) {
4433        dfs_collect_cycles_and_reachable(
4434            snapshot.root,
4435            &nodes,
4436            &mut visiting,
4437            &mut visited,
4438            &mut cycle_nodes,
4439        );
4440    }
4441    if !cycle_nodes.is_empty() {
4442        let mut codes = vec![PaneInvariantCode::CycleDetected];
4443        codes.sort();
4444        codes.dedup();
4445        return Err(PaneRepairError {
4446            before_hash,
4447            report: report_before,
4448            reason: PaneRepairFailure::UnsafeIssuesPresent { codes },
4449        });
4450    }
4451
4452    let all_node_ids = nodes.keys().copied().collect::<Vec<_>>();
4453    for node_id in all_node_ids {
4454        if !visited.contains(&node_id) {
4455            let _ = nodes.remove(&node_id);
4456            actions.push(PaneRepairAction::RemoveOrphanNode { node_id });
4457        }
4458    }
4459
4460    if let Some(max_existing) = nodes.keys().next_back().copied()
4461        && snapshot.next_id <= max_existing
4462    {
4463        let after = max_existing
4464            .checked_next()
4465            .map_err(|error| PaneRepairError {
4466                before_hash,
4467                report: report_before.clone(),
4468                reason: PaneRepairFailure::ValidationFailed { error },
4469            })?;
4470        actions.push(PaneRepairAction::BumpNextId {
4471            before: snapshot.next_id,
4472            after,
4473        });
4474        snapshot.next_id = after;
4475    }
4476
4477    snapshot.nodes = nodes.into_values().collect();
4478    snapshot.canonicalize();
4479
4480    let tree = PaneTree::from_snapshot(snapshot).map_err(|error| PaneRepairError {
4481        before_hash,
4482        report: report_before.clone(),
4483        reason: PaneRepairFailure::ValidationFailed { error },
4484    })?;
4485    let report_after = tree.invariant_report();
4486    let after_hash = tree.state_hash();
4487
4488    Ok(PaneRepairOutcome {
4489        before_hash,
4490        after_hash,
4491        report_before,
4492        report_after,
4493        actions,
4494        tree,
4495    })
4496}
4497
4498fn validate_tree(
4499    root: PaneId,
4500    next_id: PaneId,
4501    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4502) -> Result<(), PaneModelError> {
4503    if !nodes.contains_key(&root) {
4504        return Err(PaneModelError::MissingRoot { root });
4505    }
4506
4507    let max_existing = nodes.keys().next_back().copied().unwrap_or(root);
4508    if next_id <= max_existing {
4509        return Err(PaneModelError::NextIdNotGreaterThanExisting {
4510            next_id,
4511            max_existing,
4512        });
4513    }
4514
4515    let mut expected_parents = BTreeMap::new();
4516
4517    for node in nodes.values() {
4518        node.constraints.validate(node.id)?;
4519
4520        if let Some(parent) = node.parent
4521            && !nodes.contains_key(&parent)
4522        {
4523            return Err(PaneModelError::MissingParent {
4524                node_id: node.id,
4525                parent,
4526            });
4527        }
4528
4529        if let PaneNodeKind::Split(split) = &node.kind {
4530            if split.ratio.numerator() == 0 || split.ratio.denominator() == 0 {
4531                return Err(PaneModelError::InvalidSplitRatio {
4532                    numerator: split.ratio.numerator(),
4533                    denominator: split.ratio.denominator(),
4534                });
4535            }
4536
4537            if split.first == node.id || split.second == node.id {
4538                return Err(PaneModelError::SelfReferentialSplit { node_id: node.id });
4539            }
4540            if split.first == split.second {
4541                return Err(PaneModelError::DuplicateSplitChildren {
4542                    node_id: node.id,
4543                    child: split.first,
4544                });
4545            }
4546
4547            for child in [split.first, split.second] {
4548                if !nodes.contains_key(&child) {
4549                    return Err(PaneModelError::MissingChild {
4550                        parent: node.id,
4551                        child,
4552                    });
4553                }
4554                if let Some(first_parent) = expected_parents.insert(child, node.id)
4555                    && first_parent != node.id
4556                {
4557                    return Err(PaneModelError::MultipleParents {
4558                        child,
4559                        first_parent,
4560                        second_parent: node.id,
4561                    });
4562                }
4563            }
4564        }
4565    }
4566
4567    if let Some(parent) = nodes.get(&root).and_then(|node| node.parent) {
4568        return Err(PaneModelError::RootHasParent { root, parent });
4569    }
4570
4571    for node in nodes.values() {
4572        let expected = if node.id == root {
4573            None
4574        } else {
4575            expected_parents.get(&node.id).copied()
4576        };
4577        if node.parent != expected {
4578            return Err(PaneModelError::ParentMismatch {
4579                node_id: node.id,
4580                expected,
4581                actual: node.parent,
4582            });
4583        }
4584    }
4585
4586    let mut visiting = BTreeSet::new();
4587    let mut visited = BTreeSet::new();
4588    dfs_validate(root, nodes, &mut visiting, &mut visited)?;
4589
4590    if visited.len() != nodes.len()
4591        && let Some(node_id) = nodes.keys().find(|node_id| !visited.contains(node_id))
4592    {
4593        return Err(PaneModelError::UnreachableNode { node_id: *node_id });
4594    }
4595
4596    Ok(())
4597}
4598
4599#[derive(Debug, Clone, Copy)]
4600struct AxisBounds {
4601    min: u16,
4602    max: Option<u16>,
4603}
4604
4605fn axis_bounds(constraints: PaneConstraints, axis: SplitAxis) -> AxisBounds {
4606    match axis {
4607        SplitAxis::Horizontal => AxisBounds {
4608            min: constraints.min_width,
4609            max: constraints.max_width,
4610        },
4611        SplitAxis::Vertical => AxisBounds {
4612            min: constraints.min_height,
4613            max: constraints.max_height,
4614        },
4615    }
4616}
4617
4618fn validate_area_against_constraints(
4619    node_id: PaneId,
4620    area: Rect,
4621    constraints: PaneConstraints,
4622) -> Result<(), PaneModelError> {
4623    if area.width < constraints.min_width {
4624        return Err(PaneModelError::NodeConstraintUnsatisfied {
4625            node_id,
4626            axis: "width",
4627            actual: area.width,
4628            min: constraints.min_width,
4629            max: constraints.max_width,
4630        });
4631    }
4632    if area.height < constraints.min_height {
4633        return Err(PaneModelError::NodeConstraintUnsatisfied {
4634            node_id,
4635            axis: "height",
4636            actual: area.height,
4637            min: constraints.min_height,
4638            max: constraints.max_height,
4639        });
4640    }
4641    if let Some(max_width) = constraints.max_width
4642        && area.width > max_width
4643    {
4644        return Err(PaneModelError::NodeConstraintUnsatisfied {
4645            node_id,
4646            axis: "width",
4647            actual: area.width,
4648            min: constraints.min_width,
4649            max: constraints.max_width,
4650        });
4651    }
4652    if let Some(max_height) = constraints.max_height
4653        && area.height > max_height
4654    {
4655        return Err(PaneModelError::NodeConstraintUnsatisfied {
4656            node_id,
4657            axis: "height",
4658            actual: area.height,
4659            min: constraints.min_height,
4660            max: constraints.max_height,
4661        });
4662    }
4663    Ok(())
4664}
4665
4666fn solve_split_sizes(
4667    node_id: PaneId,
4668    axis: SplitAxis,
4669    available: u16,
4670    ratio: PaneSplitRatio,
4671    first: AxisBounds,
4672    second: AxisBounds,
4673) -> Result<(u16, u16), PaneModelError> {
4674    let first_max = first.max.unwrap_or(available).min(available);
4675    let second_max = second.max.unwrap_or(available).min(available);
4676
4677    let feasible_first_min = first.min.max(available.saturating_sub(second_max));
4678    let feasible_first_max = first_max.min(available.saturating_sub(second.min));
4679
4680    if feasible_first_min > feasible_first_max {
4681        return Err(PaneModelError::OverconstrainedSplit {
4682            node_id,
4683            axis,
4684            available,
4685            first_min: first.min,
4686            first_max,
4687            second_min: second.min,
4688            second_max,
4689        });
4690    }
4691
4692    let total_weight = u64::from(ratio.numerator()) + u64::from(ratio.denominator());
4693    let desired_first_u64 = (u64::from(available) * u64::from(ratio.numerator())) / total_weight;
4694    let desired_first = desired_first_u64 as u16;
4695
4696    let first_size = desired_first.clamp(feasible_first_min, feasible_first_max);
4697    let second_size = available.saturating_sub(first_size);
4698    Ok((first_size, second_size))
4699}
4700
4701fn dfs_validate(
4702    node_id: PaneId,
4703    nodes: &BTreeMap<PaneId, PaneNodeRecord>,
4704    visiting: &mut BTreeSet<PaneId>,
4705    visited: &mut BTreeSet<PaneId>,
4706) -> Result<(), PaneModelError> {
4707    if visiting.contains(&node_id) {
4708        return Err(PaneModelError::CycleDetected { node_id });
4709    }
4710    if !visited.insert(node_id) {
4711        return Ok(());
4712    }
4713
4714    let _ = visiting.insert(node_id);
4715    if let Some(node) = nodes.get(&node_id)
4716        && let PaneNodeKind::Split(split) = &node.kind
4717    {
4718        dfs_validate(split.first, nodes, visiting, visited)?;
4719        dfs_validate(split.second, nodes, visiting, visited)?;
4720    }
4721    let _ = visiting.remove(&node_id);
4722    Ok(())
4723}
4724
4725fn gcd_u32(mut left: u32, mut right: u32) -> u32 {
4726    while right != 0 {
4727        let rem = left % right;
4728        left = right;
4729        right = rem;
4730    }
4731    left.max(1)
4732}
4733
4734#[cfg(test)]
4735mod tests {
4736    use super::*;
4737    use proptest::prelude::*;
4738
4739    fn id(raw: u64) -> PaneId {
4740        PaneId::new(raw).expect("test ID must be non-zero")
4741    }
4742
4743    fn make_valid_snapshot() -> PaneTreeSnapshot {
4744        let root = id(1);
4745        let left = id(2);
4746        let right = id(3);
4747
4748        PaneTreeSnapshot {
4749            schema_version: PANE_TREE_SCHEMA_VERSION,
4750            root,
4751            next_id: id(4),
4752            nodes: vec![
4753                PaneNodeRecord::leaf(
4754                    right,
4755                    Some(root),
4756                    PaneLeaf {
4757                        surface_key: "right".to_string(),
4758                        extensions: BTreeMap::new(),
4759                    },
4760                ),
4761                PaneNodeRecord::split(
4762                    root,
4763                    None,
4764                    PaneSplit {
4765                        axis: SplitAxis::Horizontal,
4766                        ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
4767                        first: left,
4768                        second: right,
4769                    },
4770                ),
4771                PaneNodeRecord::leaf(
4772                    left,
4773                    Some(root),
4774                    PaneLeaf {
4775                        surface_key: "left".to_string(),
4776                        extensions: BTreeMap::new(),
4777                    },
4778                ),
4779            ],
4780            extensions: BTreeMap::new(),
4781        }
4782    }
4783
4784    fn make_nested_snapshot() -> PaneTreeSnapshot {
4785        let root = id(1);
4786        let left = id(2);
4787        let right_split = id(3);
4788        let right_top = id(4);
4789        let right_bottom = id(5);
4790
4791        PaneTreeSnapshot {
4792            schema_version: PANE_TREE_SCHEMA_VERSION,
4793            root,
4794            next_id: id(6),
4795            nodes: vec![
4796                PaneNodeRecord::split(
4797                    root,
4798                    None,
4799                    PaneSplit {
4800                        axis: SplitAxis::Horizontal,
4801                        ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
4802                        first: left,
4803                        second: right_split,
4804                    },
4805                ),
4806                PaneNodeRecord::leaf(left, Some(root), PaneLeaf::new("left")),
4807                PaneNodeRecord::split(
4808                    right_split,
4809                    Some(root),
4810                    PaneSplit {
4811                        axis: SplitAxis::Vertical,
4812                        ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
4813                        first: right_top,
4814                        second: right_bottom,
4815                    },
4816                ),
4817                PaneNodeRecord::leaf(right_top, Some(right_split), PaneLeaf::new("right_top")),
4818                PaneNodeRecord::leaf(
4819                    right_bottom,
4820                    Some(right_split),
4821                    PaneLeaf::new("right_bottom"),
4822                ),
4823            ],
4824            extensions: BTreeMap::new(),
4825        }
4826    }
4827
4828    #[test]
4829    fn ratio_is_normalized() {
4830        let ratio = PaneSplitRatio::new(12, 8).expect("ratio should normalize");
4831        assert_eq!(ratio.numerator(), 3);
4832        assert_eq!(ratio.denominator(), 2);
4833    }
4834
4835    #[test]
4836    fn snapshot_round_trip_preserves_canonical_order() {
4837        let tree =
4838            PaneTree::from_snapshot(make_valid_snapshot()).expect("snapshot should validate");
4839        let snapshot = tree.to_snapshot();
4840        let ids = snapshot
4841            .nodes
4842            .iter()
4843            .map(|node| node.id.get())
4844            .collect::<Vec<_>>();
4845        assert_eq!(ids, vec![1, 2, 3]);
4846    }
4847
4848    #[test]
4849    fn duplicate_node_id_is_rejected() {
4850        let mut snapshot = make_valid_snapshot();
4851        snapshot.nodes.push(PaneNodeRecord::leaf(
4852            id(2),
4853            Some(id(1)),
4854            PaneLeaf::new("dup"),
4855        ));
4856        let err = PaneTree::from_snapshot(snapshot).expect_err("duplicate ID should fail");
4857        assert_eq!(err, PaneModelError::DuplicateNodeId { node_id: id(2) });
4858    }
4859
4860    #[test]
4861    fn missing_child_is_rejected() {
4862        let mut snapshot = make_valid_snapshot();
4863        snapshot.nodes.retain(|node| node.id != id(3));
4864        let err = PaneTree::from_snapshot(snapshot).expect_err("missing child should fail");
4865        assert_eq!(
4866            err,
4867            PaneModelError::MissingChild {
4868                parent: id(1),
4869                child: id(3),
4870            }
4871        );
4872    }
4873
4874    #[test]
4875    fn unreachable_node_is_rejected() {
4876        let mut snapshot = make_valid_snapshot();
4877        snapshot
4878            .nodes
4879            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
4880        snapshot.next_id = id(11);
4881        let err = PaneTree::from_snapshot(snapshot).expect_err("orphan should fail");
4882        assert_eq!(err, PaneModelError::UnreachableNode { node_id: id(10) });
4883    }
4884
4885    #[test]
4886    fn next_id_must_be_greater_than_existing_ids() {
4887        let mut snapshot = make_valid_snapshot();
4888        snapshot.next_id = id(3);
4889        let err = PaneTree::from_snapshot(snapshot).expect_err("next_id should be > max ID");
4890        assert_eq!(
4891            err,
4892            PaneModelError::NextIdNotGreaterThanExisting {
4893                next_id: id(3),
4894                max_existing: id(3),
4895            }
4896        );
4897    }
4898
4899    #[test]
4900    fn constraints_validate_bounds() {
4901        let constraints = PaneConstraints {
4902            min_width: 8,
4903            min_height: 1,
4904            max_width: Some(4),
4905            max_height: None,
4906            collapsible: false,
4907        };
4908        let err = constraints
4909            .validate(id(5))
4910            .expect_err("max width below min width must fail");
4911        assert_eq!(
4912            err,
4913            PaneModelError::InvalidConstraint {
4914                node_id: id(5),
4915                axis: "width",
4916                min: 8,
4917                max: 4,
4918            }
4919        );
4920    }
4921
4922    #[test]
4923    fn allocator_is_deterministic() {
4924        let mut allocator = PaneIdAllocator::default();
4925        assert_eq!(allocator.allocate().expect("id 1"), id(1));
4926        assert_eq!(allocator.allocate().expect("id 2"), id(2));
4927        assert_eq!(allocator.peek(), id(3));
4928    }
4929
4930    #[test]
4931    fn snapshot_json_shape_contains_forward_compat_fields() {
4932        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
4933        let json = serde_json::to_value(tree.to_snapshot()).expect("snapshot should serialize");
4934        assert_eq!(json["schema_version"], serde_json::json!(1));
4935        assert!(json.get("extensions").is_some());
4936        let nodes = json["nodes"]
4937            .as_array()
4938            .expect("nodes should serialize as array");
4939        assert_eq!(nodes.len(), 3);
4940        assert!(nodes[0].get("kind").is_some());
4941    }
4942
4943    #[test]
4944    fn solver_horizontal_ratio_split() {
4945        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
4946        let layout = tree
4947            .solve_layout(Rect::new(0, 0, 50, 10))
4948            .expect("layout solve should succeed");
4949
4950        assert_eq!(layout.rect(id(1)), Some(Rect::new(0, 0, 50, 10)));
4951        assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 30, 10)));
4952        assert_eq!(layout.rect(id(3)), Some(Rect::new(30, 0, 20, 10)));
4953    }
4954
4955    #[test]
4956    fn solver_clamps_to_child_minimum_constraints() {
4957        let mut snapshot = make_valid_snapshot();
4958        for node in &mut snapshot.nodes {
4959            if node.id == id(2) {
4960                node.constraints.min_width = 35;
4961            }
4962        }
4963
4964        let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
4965        let layout = tree
4966            .solve_layout(Rect::new(0, 0, 50, 10))
4967            .expect("layout solve should succeed");
4968
4969        assert_eq!(layout.rect(id(2)), Some(Rect::new(0, 0, 35, 10)));
4970        assert_eq!(layout.rect(id(3)), Some(Rect::new(35, 0, 15, 10)));
4971    }
4972
4973    #[test]
4974    fn solver_rejects_overconstrained_split() {
4975        let mut snapshot = make_valid_snapshot();
4976        for node in &mut snapshot.nodes {
4977            if node.id == id(2) {
4978                node.constraints.min_width = 30;
4979            }
4980            if node.id == id(3) {
4981                node.constraints.min_width = 30;
4982            }
4983        }
4984
4985        let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
4986        let err = tree
4987            .solve_layout(Rect::new(0, 0, 50, 10))
4988            .expect_err("infeasible constraints should fail");
4989
4990        assert_eq!(
4991            err,
4992            PaneModelError::OverconstrainedSplit {
4993                node_id: id(1),
4994                axis: SplitAxis::Horizontal,
4995                available: 50,
4996                first_min: 30,
4997                first_max: 50,
4998                second_min: 30,
4999                second_max: 50,
5000            }
5001        );
5002    }
5003
5004    #[test]
5005    fn solver_is_deterministic() {
5006        let tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5007        let first = tree
5008            .solve_layout(Rect::new(0, 0, 79, 17))
5009            .expect("first solve should succeed");
5010        let second = tree
5011            .solve_layout(Rect::new(0, 0, 79, 17))
5012            .expect("second solve should succeed");
5013        assert_eq!(first, second);
5014    }
5015
5016    #[test]
5017    fn split_leaf_wraps_existing_leaf_with_new_split() {
5018        let mut tree = PaneTree::singleton("root");
5019        let outcome = tree
5020            .apply_operation(
5021                7,
5022                PaneOperation::SplitLeaf {
5023                    target: id(1),
5024                    axis: SplitAxis::Horizontal,
5025                    ratio: PaneSplitRatio::new(3, 2).expect("valid ratio"),
5026                    placement: PanePlacement::ExistingFirst,
5027                    new_leaf: PaneLeaf::new("new"),
5028                },
5029            )
5030            .expect("split should succeed");
5031
5032        assert_eq!(outcome.operation_id, 7);
5033        assert_eq!(outcome.kind, PaneOperationKind::SplitLeaf);
5034        assert_ne!(outcome.before_hash, outcome.after_hash);
5035        assert_eq!(tree.root(), id(2));
5036
5037        let root = tree.node(id(2)).expect("split node exists");
5038        let PaneNodeKind::Split(split) = &root.kind else {
5039            unreachable!("root should be split");
5040        };
5041        assert_eq!(split.first, id(1));
5042        assert_eq!(split.second, id(3));
5043
5044        let original = tree.node(id(1)).expect("original leaf exists");
5045        assert_eq!(original.parent, Some(id(2)));
5046        assert!(matches!(original.kind, PaneNodeKind::Leaf(_)));
5047
5048        let new_leaf = tree.node(id(3)).expect("new leaf exists");
5049        assert_eq!(new_leaf.parent, Some(id(2)));
5050        let PaneNodeKind::Leaf(leaf) = &new_leaf.kind else {
5051            unreachable!("new node must be leaf");
5052        };
5053        assert_eq!(leaf.surface_key, "new");
5054        assert!(tree.validate().is_ok());
5055    }
5056
5057    #[test]
5058    fn close_node_promotes_sibling_and_removes_split_parent() {
5059        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5060        let outcome = tree
5061            .apply_operation(8, PaneOperation::CloseNode { target: id(2) })
5062            .expect("close should succeed");
5063        assert_eq!(outcome.kind, PaneOperationKind::CloseNode);
5064
5065        assert_eq!(tree.root(), id(3));
5066        assert!(tree.node(id(1)).is_none());
5067        assert!(tree.node(id(2)).is_none());
5068        assert_eq!(tree.node(id(3)).and_then(|node| node.parent), None);
5069        assert!(tree.validate().is_ok());
5070    }
5071
5072    #[test]
5073    fn close_root_is_rejected_with_stable_hashes() {
5074        let mut tree = PaneTree::singleton("root");
5075        let err = tree
5076            .apply_operation(9, PaneOperation::CloseNode { target: id(1) })
5077            .expect_err("closing root must fail");
5078
5079        assert_eq!(err.operation_id, 9);
5080        assert_eq!(err.kind, PaneOperationKind::CloseNode);
5081        assert_eq!(
5082            err.reason,
5083            PaneOperationFailure::CannotCloseRoot { node_id: id(1) }
5084        );
5085        assert_eq!(err.before_hash, err.after_hash);
5086        assert_eq!(tree.root(), id(1));
5087        assert!(tree.validate().is_ok());
5088    }
5089
5090    #[test]
5091    fn move_subtree_wraps_target_and_detaches_old_parent() {
5092        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5093        let outcome = tree
5094            .apply_operation(
5095                10,
5096                PaneOperation::MoveSubtree {
5097                    source: id(4),
5098                    target: id(2),
5099                    axis: SplitAxis::Vertical,
5100                    ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
5101                    placement: PanePlacement::ExistingFirst,
5102                },
5103            )
5104            .expect("move should succeed");
5105        assert_eq!(outcome.kind, PaneOperationKind::MoveSubtree);
5106
5107        assert!(
5108            tree.node(id(3)).is_none(),
5109            "old split parent should be removed"
5110        );
5111        assert_eq!(tree.node(id(5)).and_then(|node| node.parent), Some(id(1)));
5112
5113        let inserted_split = tree
5114            .nodes()
5115            .find(|node| matches!(node.kind, PaneNodeKind::Split(_)) && node.id.get() >= 6)
5116            .expect("new split should exist");
5117        let PaneNodeKind::Split(split) = &inserted_split.kind else {
5118            unreachable!();
5119        };
5120        assert_eq!(split.first, id(2));
5121        assert_eq!(split.second, id(4));
5122        assert_eq!(
5123            tree.node(id(2)).and_then(|node| node.parent),
5124            Some(inserted_split.id)
5125        );
5126        assert_eq!(
5127            tree.node(id(4)).and_then(|node| node.parent),
5128            Some(inserted_split.id)
5129        );
5130        assert!(tree.validate().is_ok());
5131    }
5132
5133    #[test]
5134    fn move_subtree_rejects_ancestor_target() {
5135        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5136        let err = tree
5137            .apply_operation(
5138                11,
5139                PaneOperation::MoveSubtree {
5140                    source: id(3),
5141                    target: id(4),
5142                    axis: SplitAxis::Horizontal,
5143                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
5144                    placement: PanePlacement::ExistingFirst,
5145                },
5146            )
5147            .expect_err("ancestor move must fail");
5148
5149        assert_eq!(err.kind, PaneOperationKind::MoveSubtree);
5150        assert_eq!(
5151            err.reason,
5152            PaneOperationFailure::AncestorConflict {
5153                ancestor: id(3),
5154                descendant: id(4),
5155            }
5156        );
5157        assert!(tree.validate().is_ok());
5158    }
5159
5160    #[test]
5161    fn swap_nodes_exchanges_sibling_positions() {
5162        let mut tree = PaneTree::from_snapshot(make_valid_snapshot()).expect("valid tree");
5163        let outcome = tree
5164            .apply_operation(
5165                12,
5166                PaneOperation::SwapNodes {
5167                    first: id(2),
5168                    second: id(3),
5169                },
5170            )
5171            .expect("swap should succeed");
5172        assert_eq!(outcome.kind, PaneOperationKind::SwapNodes);
5173
5174        let root = tree.node(id(1)).expect("root exists");
5175        let PaneNodeKind::Split(split) = &root.kind else {
5176            unreachable!("root should remain split");
5177        };
5178        assert_eq!(split.first, id(3));
5179        assert_eq!(split.second, id(2));
5180        assert_eq!(tree.node(id(2)).and_then(|node| node.parent), Some(id(1)));
5181        assert_eq!(tree.node(id(3)).and_then(|node| node.parent), Some(id(1)));
5182        assert!(tree.validate().is_ok());
5183    }
5184
5185    #[test]
5186    fn swap_nodes_rejects_ancestor_relation() {
5187        let mut tree = PaneTree::from_snapshot(make_nested_snapshot()).expect("valid tree");
5188        let err = tree
5189            .apply_operation(
5190                13,
5191                PaneOperation::SwapNodes {
5192                    first: id(3),
5193                    second: id(4),
5194                },
5195            )
5196            .expect_err("ancestor swap must fail");
5197
5198        assert_eq!(err.kind, PaneOperationKind::SwapNodes);
5199        assert_eq!(
5200            err.reason,
5201            PaneOperationFailure::AncestorConflict {
5202                ancestor: id(3),
5203                descendant: id(4),
5204            }
5205        );
5206        assert!(tree.validate().is_ok());
5207    }
5208
5209    #[test]
5210    fn normalize_ratios_canonicalizes_non_reduced_values() {
5211        let mut snapshot = make_valid_snapshot();
5212        for node in &mut snapshot.nodes {
5213            if let PaneNodeKind::Split(split) = &mut node.kind {
5214                split.ratio = PaneSplitRatio {
5215                    numerator: 12,
5216                    denominator: 8,
5217                };
5218            }
5219        }
5220
5221        let mut tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
5222        let outcome = tree
5223            .apply_operation(14, PaneOperation::NormalizeRatios)
5224            .expect("normalize should succeed");
5225        assert_eq!(outcome.kind, PaneOperationKind::NormalizeRatios);
5226
5227        let root = tree.node(id(1)).expect("root exists");
5228        let PaneNodeKind::Split(split) = &root.kind else {
5229            unreachable!("root should be split");
5230        };
5231        assert_eq!(split.ratio.numerator(), 3);
5232        assert_eq!(split.ratio.denominator(), 2);
5233    }
5234
5235    #[test]
5236    fn transaction_commit_persists_mutations_and_journal_order() {
5237        let tree = PaneTree::singleton("root");
5238        let mut tx = tree.begin_transaction(77);
5239
5240        let split = tx
5241            .apply_operation(
5242                100,
5243                PaneOperation::SplitLeaf {
5244                    target: id(1),
5245                    axis: SplitAxis::Horizontal,
5246                    ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
5247                    placement: PanePlacement::ExistingFirst,
5248                    new_leaf: PaneLeaf::new("secondary"),
5249                },
5250            )
5251            .expect("split should succeed");
5252        assert_eq!(split.kind, PaneOperationKind::SplitLeaf);
5253
5254        let normalize = tx
5255            .apply_operation(101, PaneOperation::NormalizeRatios)
5256            .expect("normalize should succeed");
5257        assert_eq!(normalize.kind, PaneOperationKind::NormalizeRatios);
5258
5259        let outcome = tx.commit();
5260        assert!(outcome.committed);
5261        assert_eq!(outcome.transaction_id, 77);
5262        assert_eq!(outcome.tree.root(), id(2));
5263        assert_eq!(outcome.journal.len(), 2);
5264        assert_eq!(outcome.journal[0].sequence, 1);
5265        assert_eq!(outcome.journal[1].sequence, 2);
5266        assert_eq!(outcome.journal[0].operation_id, 100);
5267        assert_eq!(outcome.journal[1].operation_id, 101);
5268        assert_eq!(
5269            outcome.journal[0].result,
5270            PaneOperationJournalResult::Applied
5271        );
5272        assert_eq!(
5273            outcome.journal[1].result,
5274            PaneOperationJournalResult::Applied
5275        );
5276    }
5277
5278    #[test]
5279    fn transaction_rollback_discards_mutations() {
5280        let tree = PaneTree::singleton("root");
5281        let before_hash = tree.state_hash();
5282        let mut tx = tree.begin_transaction(78);
5283
5284        tx.apply_operation(
5285            200,
5286            PaneOperation::SplitLeaf {
5287                target: id(1),
5288                axis: SplitAxis::Vertical,
5289                ratio: PaneSplitRatio::new(2, 1).expect("valid ratio"),
5290                placement: PanePlacement::ExistingFirst,
5291                new_leaf: PaneLeaf::new("extra"),
5292            },
5293        )
5294        .expect("split should succeed");
5295
5296        let outcome = tx.rollback();
5297        assert!(!outcome.committed);
5298        assert_eq!(outcome.tree.state_hash(), before_hash);
5299        assert_eq!(outcome.tree.root(), id(1));
5300        assert_eq!(outcome.journal.len(), 1);
5301        assert_eq!(outcome.journal[0].operation_id, 200);
5302    }
5303
5304    #[test]
5305    fn transaction_journals_rejected_operation_without_mutation() {
5306        let tree = PaneTree::singleton("root");
5307        let mut tx = tree.begin_transaction(79);
5308        let before_hash = tx.tree().state_hash();
5309
5310        let err = tx
5311            .apply_operation(300, PaneOperation::CloseNode { target: id(1) })
5312            .expect_err("close root should fail");
5313        assert_eq!(err.before_hash, err.after_hash);
5314        assert_eq!(tx.tree().state_hash(), before_hash);
5315
5316        let journal = tx.journal();
5317        assert_eq!(journal.len(), 1);
5318        assert_eq!(journal[0].operation_id, 300);
5319        let PaneOperationJournalResult::Rejected { reason } = &journal[0].result else {
5320            unreachable!("journal entry should be rejected");
5321        };
5322        assert!(reason.contains("cannot close root"));
5323    }
5324
5325    #[test]
5326    fn transaction_journal_is_deterministic_for_equivalent_runs() {
5327        let base = PaneTree::singleton("root");
5328
5329        let mut first_tx = base.begin_transaction(80);
5330        first_tx
5331            .apply_operation(
5332                1,
5333                PaneOperation::SplitLeaf {
5334                    target: id(1),
5335                    axis: SplitAxis::Horizontal,
5336                    ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
5337                    placement: PanePlacement::IncomingFirst,
5338                    new_leaf: PaneLeaf::new("new"),
5339                },
5340            )
5341            .expect("split should succeed");
5342        first_tx
5343            .apply_operation(2, PaneOperation::NormalizeRatios)
5344            .expect("normalize should succeed");
5345        let first = first_tx.commit();
5346
5347        let mut second_tx = base.begin_transaction(80);
5348        second_tx
5349            .apply_operation(
5350                1,
5351                PaneOperation::SplitLeaf {
5352                    target: id(1),
5353                    axis: SplitAxis::Horizontal,
5354                    ratio: PaneSplitRatio::new(3, 1).expect("valid ratio"),
5355                    placement: PanePlacement::IncomingFirst,
5356                    new_leaf: PaneLeaf::new("new"),
5357                },
5358            )
5359            .expect("split should succeed");
5360        second_tx
5361            .apply_operation(2, PaneOperation::NormalizeRatios)
5362            .expect("normalize should succeed");
5363        let second = second_tx.commit();
5364
5365        assert_eq!(first.tree.state_hash(), second.tree.state_hash());
5366        assert_eq!(first.journal, second.journal);
5367    }
5368
5369    #[test]
5370    fn invariant_report_detects_parent_mismatch_and_orphan() {
5371        let mut snapshot = make_valid_snapshot();
5372        for node in &mut snapshot.nodes {
5373            if node.id == id(2) {
5374                node.parent = Some(id(3));
5375            }
5376        }
5377        snapshot
5378            .nodes
5379            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5380        snapshot.next_id = id(11);
5381
5382        let report = snapshot.invariant_report();
5383        assert!(report.has_errors());
5384        assert!(
5385            report
5386                .issues
5387                .iter()
5388                .any(|issue| issue.code == PaneInvariantCode::ParentMismatch)
5389        );
5390        assert!(
5391            report
5392                .issues
5393                .iter()
5394                .any(|issue| issue.code == PaneInvariantCode::UnreachableNode)
5395        );
5396    }
5397
5398    #[test]
5399    fn repair_safe_normalizes_ratio_repairs_parents_and_removes_orphans() {
5400        let mut snapshot = make_valid_snapshot();
5401        for node in &mut snapshot.nodes {
5402            if node.id == id(1) {
5403                node.parent = Some(id(3));
5404                let PaneNodeKind::Split(split) = &mut node.kind else {
5405                    unreachable!("root should be split");
5406                };
5407                split.ratio = PaneSplitRatio {
5408                    numerator: 12,
5409                    denominator: 8,
5410                };
5411            }
5412            if node.id == id(2) {
5413                node.parent = Some(id(3));
5414            }
5415        }
5416        snapshot
5417            .nodes
5418            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5419        snapshot.next_id = id(11);
5420
5421        let repaired = snapshot.repair_safe().expect("repair should succeed");
5422        assert_ne!(repaired.before_hash, repaired.after_hash);
5423        assert!(repaired.tree.validate().is_ok());
5424        assert!(!repaired.report_after.has_errors());
5425        assert!(
5426            repaired
5427                .actions
5428                .iter()
5429                .any(|action| matches!(action, PaneRepairAction::NormalizeRatio { node_id, .. } if *node_id == id(1)))
5430        );
5431        assert!(
5432            repaired
5433                .actions
5434                .iter()
5435                .any(|action| matches!(action, PaneRepairAction::ReparentNode { node_id, .. } if *node_id == id(1)))
5436        );
5437        assert!(
5438            repaired
5439                .actions
5440                .iter()
5441                .any(|action| matches!(action, PaneRepairAction::RemoveOrphanNode { node_id } if *node_id == id(10)))
5442        );
5443    }
5444
5445    #[test]
5446    fn repair_safe_rejects_unsafe_topology() {
5447        let mut snapshot = make_valid_snapshot();
5448        snapshot.nodes.retain(|node| node.id != id(3));
5449
5450        let err = snapshot
5451            .repair_safe()
5452            .expect_err("missing-child topology must be rejected");
5453        assert!(matches!(
5454            err.reason,
5455            PaneRepairFailure::UnsafeIssuesPresent { .. }
5456        ));
5457        let PaneRepairFailure::UnsafeIssuesPresent { codes } = err.reason else {
5458            unreachable!("expected unsafe issue failure");
5459        };
5460        assert!(codes.contains(&PaneInvariantCode::MissingChild));
5461    }
5462
5463    #[test]
5464    fn repair_safe_is_deterministic_for_equivalent_snapshot() {
5465        let mut snapshot = make_valid_snapshot();
5466        for node in &mut snapshot.nodes {
5467            if node.id == id(1) {
5468                let PaneNodeKind::Split(split) = &mut node.kind else {
5469                    unreachable!("root should be split");
5470                };
5471                split.ratio = PaneSplitRatio {
5472                    numerator: 12,
5473                    denominator: 8,
5474                };
5475            }
5476        }
5477        snapshot
5478            .nodes
5479            .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
5480        snapshot.next_id = id(11);
5481
5482        let first = snapshot.clone().repair_safe().expect("first repair");
5483        let second = snapshot.repair_safe().expect("second repair");
5484
5485        assert_eq!(first.tree.state_hash(), second.tree.state_hash());
5486        assert_eq!(first.actions, second.actions);
5487        assert_eq!(first.report_after, second.report_after);
5488    }
5489
5490    fn default_target() -> PaneResizeTarget {
5491        PaneResizeTarget {
5492            split_id: id(7),
5493            axis: SplitAxis::Horizontal,
5494        }
5495    }
5496
5497    #[test]
5498    fn semantic_input_event_fixture_round_trip_covers_all_variants() {
5499        let mut pointer_down = PaneSemanticInputEvent::new(
5500            1,
5501            PaneSemanticInputEventKind::PointerDown {
5502                target: default_target(),
5503                pointer_id: 11,
5504                button: PanePointerButton::Primary,
5505                position: PanePointerPosition::new(42, 9),
5506            },
5507        );
5508        pointer_down.modifiers = PaneModifierSnapshot {
5509            shift: true,
5510            alt: false,
5511            ctrl: true,
5512            meta: false,
5513        };
5514        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":{}}"#;
5515
5516        let pointer_move = PaneSemanticInputEvent::new(
5517            2,
5518            PaneSemanticInputEventKind::PointerMove {
5519                target: default_target(),
5520                pointer_id: 11,
5521                position: PanePointerPosition::new(45, 8),
5522                delta_x: 3,
5523                delta_y: -1,
5524            },
5525        );
5526        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":{}}"#;
5527
5528        let pointer_up = PaneSemanticInputEvent::new(
5529            3,
5530            PaneSemanticInputEventKind::PointerUp {
5531                target: default_target(),
5532                pointer_id: 11,
5533                button: PanePointerButton::Primary,
5534                position: PanePointerPosition::new(45, 8),
5535            },
5536        );
5537        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":{}}"#;
5538
5539        let wheel_nudge = PaneSemanticInputEvent::new(
5540            4,
5541            PaneSemanticInputEventKind::WheelNudge {
5542                target: default_target(),
5543                lines: -2,
5544            },
5545        );
5546        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":{}}"#;
5547
5548        let keyboard_resize = PaneSemanticInputEvent::new(
5549            5,
5550            PaneSemanticInputEventKind::KeyboardResize {
5551                target: default_target(),
5552                direction: PaneResizeDirection::Increase,
5553                units: 3,
5554            },
5555        );
5556        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":{}}"#;
5557
5558        let cancel = PaneSemanticInputEvent::new(
5559            6,
5560            PaneSemanticInputEventKind::Cancel {
5561                target: Some(default_target()),
5562                reason: PaneCancelReason::PointerCancel,
5563            },
5564        );
5565        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":{}}"#;
5566
5567        let blur =
5568            PaneSemanticInputEvent::new(7, PaneSemanticInputEventKind::Blur { target: None });
5569        let blur_fixture = r#"{"schema_version":1,"sequence":7,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
5570
5571        let fixtures = [
5572            ("pointer_down", pointer_down_fixture, pointer_down),
5573            ("pointer_move", pointer_move_fixture, pointer_move),
5574            ("pointer_up", pointer_up_fixture, pointer_up),
5575            ("wheel_nudge", wheel_nudge_fixture, wheel_nudge),
5576            ("keyboard_resize", keyboard_resize_fixture, keyboard_resize),
5577            ("cancel", cancel_fixture, cancel),
5578            ("blur", blur_fixture, blur),
5579        ];
5580
5581        for (name, fixture, expected) in fixtures {
5582            let parsed: PaneSemanticInputEvent =
5583                serde_json::from_str(fixture).expect("fixture should parse");
5584            assert_eq!(
5585                parsed, expected,
5586                "{name} fixture should match expected shape"
5587            );
5588            parsed.validate().expect("fixture should validate");
5589            let encoded = serde_json::to_string(&parsed).expect("event should encode");
5590            assert_eq!(encoded, fixture, "{name} fixture should be canonical");
5591        }
5592    }
5593
5594    #[test]
5595    fn semantic_input_event_defaults_schema_version_to_current() {
5596        let fixture = r#"{"sequence":9,"modifiers":{"shift":false,"alt":false,"ctrl":false,"meta":false},"event":"blur","target":null,"extensions":{}}"#;
5597        let parsed: PaneSemanticInputEvent =
5598            serde_json::from_str(fixture).expect("fixture should parse");
5599        assert_eq!(
5600            parsed.schema_version,
5601            PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
5602        );
5603        parsed.validate().expect("defaulted event should validate");
5604    }
5605
5606    #[test]
5607    fn semantic_input_event_rejects_invalid_invariants() {
5608        let target = default_target();
5609
5610        let mut schema_version = PaneSemanticInputEvent::new(
5611            1,
5612            PaneSemanticInputEventKind::Blur {
5613                target: Some(target),
5614            },
5615        );
5616        schema_version.schema_version = 99;
5617        assert_eq!(
5618            schema_version.validate(),
5619            Err(PaneSemanticInputEventError::UnsupportedSchemaVersion {
5620                version: 99,
5621                expected: PANE_SEMANTIC_INPUT_EVENT_SCHEMA_VERSION
5622            })
5623        );
5624
5625        let sequence = PaneSemanticInputEvent::new(
5626            0,
5627            PaneSemanticInputEventKind::Blur {
5628                target: Some(target),
5629            },
5630        );
5631        assert_eq!(
5632            sequence.validate(),
5633            Err(PaneSemanticInputEventError::ZeroSequence)
5634        );
5635
5636        let pointer = PaneSemanticInputEvent::new(
5637            2,
5638            PaneSemanticInputEventKind::PointerDown {
5639                target,
5640                pointer_id: 0,
5641                button: PanePointerButton::Primary,
5642                position: PanePointerPosition::new(0, 0),
5643            },
5644        );
5645        assert_eq!(
5646            pointer.validate(),
5647            Err(PaneSemanticInputEventError::ZeroPointerId)
5648        );
5649
5650        let wheel = PaneSemanticInputEvent::new(
5651            3,
5652            PaneSemanticInputEventKind::WheelNudge { target, lines: 0 },
5653        );
5654        assert_eq!(
5655            wheel.validate(),
5656            Err(PaneSemanticInputEventError::ZeroWheelLines)
5657        );
5658
5659        let keyboard = PaneSemanticInputEvent::new(
5660            4,
5661            PaneSemanticInputEventKind::KeyboardResize {
5662                target,
5663                direction: PaneResizeDirection::Decrease,
5664                units: 0,
5665            },
5666        );
5667        assert_eq!(
5668            keyboard.validate(),
5669            Err(PaneSemanticInputEventError::ZeroResizeUnits)
5670        );
5671    }
5672
5673    #[test]
5674    fn semantic_input_trace_fixture_round_trip_and_checksum_validation() {
5675        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":{}}]}"#;
5676
5677        let parsed: PaneSemanticInputTrace =
5678            serde_json::from_str(fixture).expect("trace fixture should parse");
5679        let checksum_mismatch = parsed
5680            .validate()
5681            .expect_err("fixture checksum=0 should fail validation");
5682        assert!(matches!(
5683            checksum_mismatch,
5684            PaneSemanticInputTraceError::ChecksumMismatch { recorded: 0, .. }
5685        ));
5686
5687        let mut canonical = parsed;
5688        canonical.metadata.checksum = canonical.recompute_checksum();
5689        canonical
5690            .validate()
5691            .expect("canonicalized fixture should validate");
5692        let encoded = serde_json::to_string(&canonical).expect("trace should encode");
5693        let reparsed: PaneSemanticInputTrace =
5694            serde_json::from_str(&encoded).expect("encoded fixture should parse");
5695        assert_eq!(reparsed, canonical);
5696        assert_eq!(reparsed.metadata.checksum, reparsed.recompute_checksum());
5697    }
5698
5699    #[test]
5700    fn semantic_input_trace_rejects_out_of_order_sequence() {
5701        let target = default_target();
5702        let mut trace = PaneSemanticInputTrace::new(
5703            42,
5704            1_700_000_000_111,
5705            "web",
5706            vec![
5707                PaneSemanticInputEvent::new(
5708                    1,
5709                    PaneSemanticInputEventKind::PointerDown {
5710                        target,
5711                        pointer_id: 9,
5712                        button: PanePointerButton::Primary,
5713                        position: PanePointerPosition::new(0, 0),
5714                    },
5715                ),
5716                PaneSemanticInputEvent::new(
5717                    2,
5718                    PaneSemanticInputEventKind::PointerMove {
5719                        target,
5720                        pointer_id: 9,
5721                        position: PanePointerPosition::new(2, 0),
5722                        delta_x: 0,
5723                        delta_y: 0,
5724                    },
5725                ),
5726                PaneSemanticInputEvent::new(
5727                    3,
5728                    PaneSemanticInputEventKind::PointerUp {
5729                        target,
5730                        pointer_id: 9,
5731                        button: PanePointerButton::Primary,
5732                        position: PanePointerPosition::new(2, 0),
5733                    },
5734                ),
5735            ],
5736        )
5737        .expect("trace should construct");
5738
5739        trace.events[2].sequence = 2;
5740        trace.metadata.checksum = trace.recompute_checksum();
5741        assert_eq!(
5742            trace.validate(),
5743            Err(PaneSemanticInputTraceError::SequenceOutOfOrder {
5744                index: 2,
5745                previous: 2,
5746                current: 2
5747            })
5748        );
5749    }
5750
5751    #[test]
5752    fn semantic_replay_fixture_runner_produces_diff_artifacts() {
5753        let target = default_target();
5754        let trace = PaneSemanticInputTrace::new(
5755            99,
5756            1_700_000_000_222,
5757            "terminal",
5758            vec![
5759                PaneSemanticInputEvent::new(
5760                    1,
5761                    PaneSemanticInputEventKind::PointerDown {
5762                        target,
5763                        pointer_id: 11,
5764                        button: PanePointerButton::Primary,
5765                        position: PanePointerPosition::new(10, 4),
5766                    },
5767                ),
5768                PaneSemanticInputEvent::new(
5769                    2,
5770                    PaneSemanticInputEventKind::PointerMove {
5771                        target,
5772                        pointer_id: 11,
5773                        position: PanePointerPosition::new(13, 4),
5774                        delta_x: 0,
5775                        delta_y: 0,
5776                    },
5777                ),
5778                PaneSemanticInputEvent::new(
5779                    3,
5780                    PaneSemanticInputEventKind::PointerMove {
5781                        target,
5782                        pointer_id: 11,
5783                        position: PanePointerPosition::new(15, 6),
5784                        delta_x: 0,
5785                        delta_y: 0,
5786                    },
5787                ),
5788                PaneSemanticInputEvent::new(
5789                    4,
5790                    PaneSemanticInputEventKind::PointerUp {
5791                        target,
5792                        pointer_id: 11,
5793                        button: PanePointerButton::Primary,
5794                        position: PanePointerPosition::new(16, 6),
5795                    },
5796                ),
5797            ],
5798        )
5799        .expect("trace should construct");
5800
5801        let mut baseline_machine = PaneDragResizeMachine::default();
5802        let baseline = trace
5803            .replay(&mut baseline_machine)
5804            .expect("baseline replay should pass");
5805        let fixture = PaneSemanticReplayFixture {
5806            trace: trace.clone(),
5807            expected_transitions: baseline.transitions.clone(),
5808            expected_final_state: baseline.final_state,
5809        };
5810
5811        let mut pass_machine = PaneDragResizeMachine::default();
5812        let pass_report = fixture
5813            .run(&mut pass_machine)
5814            .expect("fixture replay should succeed");
5815        assert!(pass_report.passed);
5816        assert!(pass_report.diffs.is_empty());
5817
5818        let mut mismatch_fixture = fixture.clone();
5819        mismatch_fixture.expected_transitions[1].transition_id += 77;
5820        mismatch_fixture.expected_final_state = PaneDragResizeState::Armed {
5821            target,
5822            pointer_id: 11,
5823            origin: PanePointerPosition::new(10, 4),
5824            current: PanePointerPosition::new(10, 4),
5825            started_sequence: 1,
5826        };
5827
5828        let mut mismatch_machine = PaneDragResizeMachine::default();
5829        let mismatch_report = mismatch_fixture
5830            .run(&mut mismatch_machine)
5831            .expect("mismatch replay should still execute");
5832        assert!(!mismatch_report.passed);
5833        assert!(
5834            mismatch_report
5835                .diffs
5836                .iter()
5837                .any(|diff| diff.kind == PaneSemanticReplayDiffKind::TransitionMismatch)
5838        );
5839        assert!(
5840            mismatch_report
5841                .diffs
5842                .iter()
5843                .any(|diff| diff.kind == PaneSemanticReplayDiffKind::FinalStateMismatch)
5844        );
5845    }
5846
5847    fn default_coordinate_normalizer() -> PaneCoordinateNormalizer {
5848        PaneCoordinateNormalizer::new(
5849            PanePointerPosition::new(100, 50),
5850            PanePointerPosition::new(20, 10),
5851            8,
5852            16,
5853            PaneScaleFactor::new(2, 1).expect("valid dpr"),
5854            PaneScaleFactor::ONE,
5855            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5856        )
5857        .expect("normalizer should be valid")
5858    }
5859
5860    #[test]
5861    fn coordinate_normalizer_css_device_and_cell_pipeline() {
5862        let normalizer = default_coordinate_normalizer();
5863
5864        let css = normalizer
5865            .normalize(PaneInputCoordinate::CssPixels {
5866                position: PanePointerPosition::new(116, 82),
5867            })
5868            .expect("css normalization should succeed");
5869        assert_eq!(
5870            css,
5871            PaneNormalizedCoordinate {
5872                global_cell: PanePointerPosition::new(22, 12),
5873                local_cell: PanePointerPosition::new(2, 2),
5874                local_css: PanePointerPosition::new(16, 32),
5875            }
5876        );
5877
5878        let device = normalizer
5879            .normalize(PaneInputCoordinate::DevicePixels {
5880                position: PanePointerPosition::new(232, 164),
5881            })
5882            .expect("device normalization should match css");
5883        assert_eq!(device, css);
5884
5885        let cell = normalizer
5886            .normalize(PaneInputCoordinate::Cell {
5887                position: PanePointerPosition::new(3, 1),
5888            })
5889            .expect("cell normalization should succeed");
5890        assert_eq!(
5891            cell,
5892            PaneNormalizedCoordinate {
5893                global_cell: PanePointerPosition::new(23, 11),
5894                local_cell: PanePointerPosition::new(3, 1),
5895                local_css: PanePointerPosition::new(24, 16),
5896            }
5897        );
5898    }
5899
5900    #[test]
5901    fn coordinate_normalizer_zoom_and_rounding_tie_breaks_are_deterministic() {
5902        let zoomed = PaneCoordinateNormalizer::new(
5903            PanePointerPosition::new(100, 50),
5904            PanePointerPosition::new(0, 0),
5905            8,
5906            8,
5907            PaneScaleFactor::ONE,
5908            PaneScaleFactor::new(5, 4).expect("valid zoom"),
5909            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5910        )
5911        .expect("zoomed normalizer should be valid");
5912
5913        let zoomed_point = zoomed
5914            .normalize(PaneInputCoordinate::CssPixels {
5915                position: PanePointerPosition::new(120, 70),
5916            })
5917            .expect("zoomed normalization should succeed");
5918        assert_eq!(zoomed_point.local_css, PanePointerPosition::new(16, 16));
5919        assert_eq!(zoomed_point.local_cell, PanePointerPosition::new(2, 2));
5920
5921        let nearest = PaneCoordinateNormalizer::new(
5922            PanePointerPosition::new(0, 0),
5923            PanePointerPosition::new(0, 0),
5924            10,
5925            10,
5926            PaneScaleFactor::ONE,
5927            PaneScaleFactor::ONE,
5928            PaneCoordinateRoundingPolicy::NearestHalfTowardNegativeInfinity,
5929        )
5930        .expect("nearest normalizer should be valid");
5931
5932        let positive_tie = nearest
5933            .normalize(PaneInputCoordinate::CssPixels {
5934                position: PanePointerPosition::new(15, 0),
5935            })
5936            .expect("positive tie should normalize");
5937        let negative_tie = nearest
5938            .normalize(PaneInputCoordinate::CssPixels {
5939                position: PanePointerPosition::new(-15, 0),
5940            })
5941            .expect("negative tie should normalize");
5942
5943        assert_eq!(positive_tie.local_cell.x, 1);
5944        assert_eq!(negative_tie.local_cell.x, -2);
5945    }
5946
5947    #[test]
5948    fn coordinate_normalizer_rejects_invalid_configuration() {
5949        assert_eq!(
5950            PaneScaleFactor::new(0, 1).expect_err("zero numerator must fail"),
5951            PaneCoordinateNormalizationError::InvalidScaleFactor {
5952                field: "scale_factor",
5953                numerator: 0,
5954                denominator: 1,
5955            }
5956        );
5957
5958        let err = PaneCoordinateNormalizer::new(
5959            PanePointerPosition::new(0, 0),
5960            PanePointerPosition::new(0, 0),
5961            0,
5962            10,
5963            PaneScaleFactor::ONE,
5964            PaneScaleFactor::ONE,
5965            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5966        )
5967        .expect_err("zero width must fail");
5968        assert_eq!(
5969            err,
5970            PaneCoordinateNormalizationError::InvalidCellSize {
5971                width: 0,
5972                height: 10,
5973            }
5974        );
5975    }
5976
5977    #[test]
5978    fn coordinate_normalizer_repeated_device_updates_do_not_drift() {
5979        let normalizer = PaneCoordinateNormalizer::new(
5980            PanePointerPosition::new(0, 0),
5981            PanePointerPosition::new(0, 0),
5982            7,
5983            11,
5984            PaneScaleFactor::new(3, 2).expect("valid dpr"),
5985            PaneScaleFactor::new(5, 4).expect("valid zoom"),
5986            PaneCoordinateRoundingPolicy::TowardNegativeInfinity,
5987        )
5988        .expect("normalizer should be valid");
5989
5990        let mut prev = i32::MIN;
5991        for x in 150..190 {
5992            let first = normalizer
5993                .normalize(PaneInputCoordinate::DevicePixels {
5994                    position: PanePointerPosition::new(x, 0),
5995                })
5996                .expect("first normalization should succeed");
5997            let second = normalizer
5998                .normalize(PaneInputCoordinate::DevicePixels {
5999                    position: PanePointerPosition::new(x, 0),
6000                })
6001                .expect("second normalization should succeed");
6002
6003            assert_eq!(
6004                first, second,
6005                "normalization should be stable for same input"
6006            );
6007            assert!(
6008                first.global_cell.x >= prev,
6009                "cell coordinate should be monotonic"
6010            );
6011            if prev != i32::MIN {
6012                assert!(
6013                    first.global_cell.x - prev <= 1,
6014                    "cell coordinate should not jump by more than one per pixel step"
6015                );
6016            }
6017            prev = first.global_cell.x;
6018        }
6019    }
6020
6021    #[test]
6022    fn snap_tuning_is_deterministic_with_tie_breaks_and_hysteresis() {
6023        let tuning = PaneSnapTuning::default();
6024
6025        let tie = tuning.decide(3_250, None);
6026        assert_eq!(tie.nearest_ratio_bps, 3_000);
6027        assert_eq!(tie.snapped_ratio_bps, None);
6028        assert_eq!(tie.reason, PaneSnapReason::UnsnapOutsideWindow);
6029
6030        let snap = tuning.decide(3_499, None);
6031        assert_eq!(snap.nearest_ratio_bps, 3_500);
6032        assert_eq!(snap.snapped_ratio_bps, Some(3_500));
6033        assert_eq!(snap.reason, PaneSnapReason::SnappedNearest);
6034
6035        let retain = tuning.decide(3_390, Some(3_500));
6036        assert_eq!(retain.snapped_ratio_bps, Some(3_500));
6037        assert_eq!(retain.reason, PaneSnapReason::RetainedPrevious);
6038
6039        assert_eq!(
6040            PaneSnapTuning::new(0, 125).expect_err("step=0 must fail"),
6041            PaneInteractionPolicyError::InvalidSnapTuning {
6042                step_bps: 0,
6043                hysteresis_bps: 125
6044            }
6045        );
6046    }
6047
6048    #[test]
6049    fn precision_policy_applies_axis_lock_and_mode_scaling() {
6050        let fine = PanePrecisionPolicy::from_modifiers(
6051            PaneModifierSnapshot {
6052                shift: true,
6053                alt: true,
6054                ctrl: false,
6055                meta: false,
6056            },
6057            SplitAxis::Horizontal,
6058        );
6059        assert_eq!(fine.mode, PanePrecisionMode::Fine);
6060        assert_eq!(fine.axis_lock, Some(SplitAxis::Horizontal));
6061        assert_eq!(fine.apply_delta(5, 3).expect("fine delta"), (2, 0));
6062
6063        let coarse = PanePrecisionPolicy::from_modifiers(
6064            PaneModifierSnapshot {
6065                shift: false,
6066                alt: false,
6067                ctrl: true,
6068                meta: false,
6069            },
6070            SplitAxis::Vertical,
6071        );
6072        assert_eq!(coarse.mode, PanePrecisionMode::Coarse);
6073        assert_eq!(coarse.axis_lock, None);
6074        assert_eq!(coarse.apply_delta(2, -3).expect("coarse delta"), (4, -6));
6075    }
6076
6077    #[test]
6078    fn drag_behavior_tuning_validates_and_threshold_helpers_are_stable() {
6079        let tuning = PaneDragBehaviorTuning::new(3, 2, PaneSnapTuning::default())
6080            .expect("valid tuning should construct");
6081        assert!(tuning.should_start_drag(
6082            PanePointerPosition::new(0, 0),
6083            PanePointerPosition::new(3, 0)
6084        ));
6085        assert!(!tuning.should_start_drag(
6086            PanePointerPosition::new(0, 0),
6087            PanePointerPosition::new(2, 0)
6088        ));
6089        assert!(tuning.should_emit_drag_update(
6090            PanePointerPosition::new(10, 10),
6091            PanePointerPosition::new(12, 10)
6092        ));
6093        assert!(!tuning.should_emit_drag_update(
6094            PanePointerPosition::new(10, 10),
6095            PanePointerPosition::new(11, 10)
6096        ));
6097
6098        assert_eq!(
6099            PaneDragBehaviorTuning::new(0, 2, PaneSnapTuning::default())
6100                .expect_err("activation threshold=0 must fail"),
6101            PaneInteractionPolicyError::InvalidThreshold {
6102                field: "activation_threshold",
6103                value: 0
6104            }
6105        );
6106        assert_eq!(
6107            PaneDragBehaviorTuning::new(2, 0, PaneSnapTuning::default())
6108                .expect_err("hysteresis=0 must fail"),
6109            PaneInteractionPolicyError::InvalidThreshold {
6110                field: "update_hysteresis",
6111                value: 0
6112            }
6113        );
6114    }
6115
6116    fn pointer_down_event(
6117        sequence: u64,
6118        target: PaneResizeTarget,
6119        pointer_id: u32,
6120        x: i32,
6121        y: i32,
6122    ) -> PaneSemanticInputEvent {
6123        PaneSemanticInputEvent::new(
6124            sequence,
6125            PaneSemanticInputEventKind::PointerDown {
6126                target,
6127                pointer_id,
6128                button: PanePointerButton::Primary,
6129                position: PanePointerPosition::new(x, y),
6130            },
6131        )
6132    }
6133
6134    fn pointer_move_event(
6135        sequence: u64,
6136        target: PaneResizeTarget,
6137        pointer_id: u32,
6138        x: i32,
6139        y: i32,
6140    ) -> PaneSemanticInputEvent {
6141        PaneSemanticInputEvent::new(
6142            sequence,
6143            PaneSemanticInputEventKind::PointerMove {
6144                target,
6145                pointer_id,
6146                position: PanePointerPosition::new(x, y),
6147                delta_x: 0,
6148                delta_y: 0,
6149            },
6150        )
6151    }
6152
6153    fn pointer_up_event(
6154        sequence: u64,
6155        target: PaneResizeTarget,
6156        pointer_id: u32,
6157        x: i32,
6158        y: i32,
6159    ) -> PaneSemanticInputEvent {
6160        PaneSemanticInputEvent::new(
6161            sequence,
6162            PaneSemanticInputEventKind::PointerUp {
6163                target,
6164                pointer_id,
6165                button: PanePointerButton::Primary,
6166                position: PanePointerPosition::new(x, y),
6167            },
6168        )
6169    }
6170
6171    #[test]
6172    fn drag_resize_machine_full_lifecycle_commit() {
6173        let mut machine = PaneDragResizeMachine::default();
6174        let target = default_target();
6175
6176        let down = machine
6177            .apply_event(&pointer_down_event(1, target, 10, 10, 4))
6178            .expect("down should arm");
6179        assert_eq!(down.transition_id, 1);
6180        assert_eq!(down.sequence, 1);
6181        assert_eq!(machine.state(), down.to);
6182        assert!(matches!(
6183            down.effect,
6184            PaneDragResizeEffect::Armed {
6185                target: t,
6186                pointer_id: 10,
6187                origin: PanePointerPosition { x: 10, y: 4 }
6188            } if t == target
6189        ));
6190
6191        let below_threshold = machine
6192            .apply_event(&pointer_move_event(2, target, 10, 11, 4))
6193            .expect("small move should not start drag");
6194        assert_eq!(
6195            below_threshold.effect,
6196            PaneDragResizeEffect::Noop {
6197                reason: PaneDragResizeNoopReason::ThresholdNotReached
6198            }
6199        );
6200        assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
6201
6202        let drag_start = machine
6203            .apply_event(&pointer_move_event(3, target, 10, 13, 4))
6204            .expect("large move should start drag");
6205        assert!(matches!(
6206            drag_start.effect,
6207            PaneDragResizeEffect::DragStarted {
6208                target: t,
6209                pointer_id: 10,
6210                total_delta_x: 3,
6211                total_delta_y: 0,
6212                ..
6213            } if t == target
6214        ));
6215        assert!(matches!(
6216            machine.state(),
6217            PaneDragResizeState::Dragging { .. }
6218        ));
6219
6220        let drag_update = machine
6221            .apply_event(&pointer_move_event(4, target, 10, 15, 6))
6222            .expect("drag move should update");
6223        assert!(matches!(
6224            drag_update.effect,
6225            PaneDragResizeEffect::DragUpdated {
6226                target: t,
6227                pointer_id: 10,
6228                delta_x: 2,
6229                delta_y: 2,
6230                total_delta_x: 5,
6231                total_delta_y: 2,
6232                ..
6233            } if t == target
6234        ));
6235
6236        let commit = machine
6237            .apply_event(&pointer_up_event(5, target, 10, 16, 6))
6238            .expect("up should commit drag");
6239        assert!(matches!(
6240            commit.effect,
6241            PaneDragResizeEffect::Committed {
6242                target: t,
6243                pointer_id: 10,
6244                total_delta_x: 6,
6245                total_delta_y: 2,
6246                ..
6247            } if t == target
6248        ));
6249        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6250    }
6251
6252    #[test]
6253    fn drag_resize_machine_cancel_and_blur_paths_are_reason_coded() {
6254        let target = default_target();
6255
6256        let mut cancel_machine = PaneDragResizeMachine::default();
6257        cancel_machine
6258            .apply_event(&pointer_down_event(1, target, 1, 2, 2))
6259            .expect("down should arm");
6260        let cancel = cancel_machine
6261            .apply_event(&PaneSemanticInputEvent::new(
6262                2,
6263                PaneSemanticInputEventKind::Cancel {
6264                    target: Some(target),
6265                    reason: PaneCancelReason::FocusLost,
6266                },
6267            ))
6268            .expect("cancel should reset to idle");
6269        assert_eq!(cancel_machine.state(), PaneDragResizeState::Idle);
6270        assert_eq!(
6271            cancel.effect,
6272            PaneDragResizeEffect::Canceled {
6273                target: Some(target),
6274                pointer_id: Some(1),
6275                reason: PaneCancelReason::FocusLost
6276            }
6277        );
6278
6279        let mut blur_machine = PaneDragResizeMachine::default();
6280        blur_machine
6281            .apply_event(&pointer_down_event(3, target, 2, 5, 5))
6282            .expect("down should arm");
6283        blur_machine
6284            .apply_event(&pointer_move_event(4, target, 2, 8, 5))
6285            .expect("move should start dragging");
6286        let blur = blur_machine
6287            .apply_event(&PaneSemanticInputEvent::new(
6288                5,
6289                PaneSemanticInputEventKind::Blur {
6290                    target: Some(target),
6291                },
6292            ))
6293            .expect("blur should cancel active drag");
6294        assert_eq!(blur_machine.state(), PaneDragResizeState::Idle);
6295        assert_eq!(
6296            blur.effect,
6297            PaneDragResizeEffect::Canceled {
6298                target: Some(target),
6299                pointer_id: Some(2),
6300                reason: PaneCancelReason::Blur
6301            }
6302        );
6303    }
6304
6305    #[test]
6306    fn drag_resize_machine_duplicate_end_and_pointer_mismatch_are_safe_noops() {
6307        let mut machine = PaneDragResizeMachine::default();
6308        let target = default_target();
6309
6310        machine
6311            .apply_event(&pointer_down_event(1, target, 9, 0, 0))
6312            .expect("down should arm");
6313
6314        let mismatch = machine
6315            .apply_event(&pointer_move_event(2, target, 99, 3, 0))
6316            .expect("mismatch should be ignored");
6317        assert_eq!(
6318            mismatch.effect,
6319            PaneDragResizeEffect::Noop {
6320                reason: PaneDragResizeNoopReason::PointerMismatch
6321            }
6322        );
6323        assert!(matches!(machine.state(), PaneDragResizeState::Armed { .. }));
6324
6325        machine
6326            .apply_event(&pointer_move_event(3, target, 9, 3, 0))
6327            .expect("drag should start");
6328        machine
6329            .apply_event(&pointer_up_event(4, target, 9, 3, 0))
6330            .expect("up should commit");
6331        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6332
6333        let duplicate_end = machine
6334            .apply_event(&pointer_up_event(5, target, 9, 3, 0))
6335            .expect("duplicate end should noop");
6336        assert_eq!(
6337            duplicate_end.effect,
6338            PaneDragResizeEffect::Noop {
6339                reason: PaneDragResizeNoopReason::IdleWithoutActiveDrag
6340            }
6341        );
6342    }
6343
6344    #[test]
6345    fn drag_resize_machine_discrete_inputs_in_idle_and_validation_errors() {
6346        let mut machine = PaneDragResizeMachine::default();
6347        let target = default_target();
6348
6349        let keyboard = machine
6350            .apply_event(&PaneSemanticInputEvent::new(
6351                1,
6352                PaneSemanticInputEventKind::KeyboardResize {
6353                    target,
6354                    direction: PaneResizeDirection::Increase,
6355                    units: 2,
6356                },
6357            ))
6358            .expect("keyboard resize should apply in idle");
6359        assert_eq!(
6360            keyboard.effect,
6361            PaneDragResizeEffect::KeyboardApplied {
6362                target,
6363                direction: PaneResizeDirection::Increase,
6364                units: 2
6365            }
6366        );
6367        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6368
6369        let wheel = machine
6370            .apply_event(&PaneSemanticInputEvent::new(
6371                2,
6372                PaneSemanticInputEventKind::WheelNudge { target, lines: -1 },
6373            ))
6374            .expect("wheel nudge should apply in idle");
6375        assert_eq!(
6376            wheel.effect,
6377            PaneDragResizeEffect::WheelApplied { target, lines: -1 }
6378        );
6379
6380        let invalid_pointer = PaneSemanticInputEvent::new(
6381            3,
6382            PaneSemanticInputEventKind::PointerDown {
6383                target,
6384                pointer_id: 0,
6385                button: PanePointerButton::Primary,
6386                position: PanePointerPosition::new(0, 0),
6387            },
6388        );
6389        let err = machine
6390            .apply_event(&invalid_pointer)
6391            .expect_err("invalid input should be rejected");
6392        assert_eq!(
6393            err,
6394            PaneDragResizeMachineError::InvalidEvent(PaneSemanticInputEventError::ZeroPointerId)
6395        );
6396
6397        assert_eq!(
6398            PaneDragResizeMachine::new(0).expect_err("zero threshold should fail"),
6399            PaneDragResizeMachineError::InvalidDragThreshold { threshold: 0 }
6400        );
6401    }
6402
6403    #[test]
6404    fn drag_resize_machine_hysteresis_suppresses_micro_jitter() {
6405        let target = default_target();
6406        let mut machine = PaneDragResizeMachine::new_with_hysteresis(2, 2)
6407            .expect("explicit machine tuning should construct");
6408        machine
6409            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6410            .expect("down should arm");
6411        machine
6412            .apply_event(&pointer_move_event(2, target, 22, 2, 0))
6413            .expect("move should start dragging");
6414
6415        let jitter = machine
6416            .apply_event(&pointer_move_event(3, target, 22, 3, 0))
6417            .expect("small move should be ignored");
6418        assert_eq!(
6419            jitter.effect,
6420            PaneDragResizeEffect::Noop {
6421                reason: PaneDragResizeNoopReason::BelowHysteresis
6422            }
6423        );
6424
6425        let update = machine
6426            .apply_event(&pointer_move_event(4, target, 22, 4, 0))
6427            .expect("larger move should update drag");
6428        assert!(matches!(
6429            update.effect,
6430            PaneDragResizeEffect::DragUpdated { .. }
6431        ));
6432        assert_eq!(
6433            PaneDragResizeMachine::new_with_hysteresis(2, 0)
6434                .expect_err("zero hysteresis must fail"),
6435            PaneDragResizeMachineError::InvalidUpdateHysteresis { hysteresis: 0 }
6436        );
6437    }
6438
6439    // -----------------------------------------------------------------------
6440    // force_cancel lifecycle robustness (bd-24v9m)
6441    // -----------------------------------------------------------------------
6442
6443    #[test]
6444    fn force_cancel_idle_is_noop() {
6445        let mut machine = PaneDragResizeMachine::default();
6446        assert!(!machine.is_active());
6447        assert!(machine.force_cancel().is_none());
6448        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6449    }
6450
6451    #[test]
6452    fn force_cancel_from_armed_resets_to_idle() {
6453        let target = default_target();
6454        let mut machine = PaneDragResizeMachine::default();
6455        machine
6456            .apply_event(&pointer_down_event(1, target, 22, 5, 5))
6457            .expect("down should arm");
6458        assert!(machine.is_active());
6459
6460        let transition = machine
6461            .force_cancel()
6462            .expect("armed machine should produce transition");
6463        assert_eq!(transition.to, PaneDragResizeState::Idle);
6464        assert!(matches!(
6465            transition.effect,
6466            PaneDragResizeEffect::Canceled {
6467                reason: PaneCancelReason::Programmatic,
6468                ..
6469            }
6470        ));
6471        assert!(!machine.is_active());
6472        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6473    }
6474
6475    #[test]
6476    fn force_cancel_from_dragging_resets_to_idle() {
6477        let target = default_target();
6478        let mut machine = PaneDragResizeMachine::default();
6479        machine
6480            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6481            .expect("down");
6482        machine
6483            .apply_event(&pointer_move_event(2, target, 22, 5, 0))
6484            .expect("move past threshold to start drag");
6485        assert!(matches!(
6486            machine.state(),
6487            PaneDragResizeState::Dragging { .. }
6488        ));
6489        assert!(machine.is_active());
6490
6491        let transition = machine
6492            .force_cancel()
6493            .expect("dragging machine should produce transition");
6494        assert_eq!(transition.to, PaneDragResizeState::Idle);
6495        assert!(matches!(
6496            transition.effect,
6497            PaneDragResizeEffect::Canceled {
6498                target: Some(_),
6499                pointer_id: Some(22),
6500                reason: PaneCancelReason::Programmatic,
6501            }
6502        ));
6503        assert!(!machine.is_active());
6504    }
6505
6506    #[test]
6507    fn force_cancel_is_idempotent() {
6508        let target = default_target();
6509        let mut machine = PaneDragResizeMachine::default();
6510        machine
6511            .apply_event(&pointer_down_event(1, target, 22, 5, 5))
6512            .expect("down should arm");
6513
6514        let first = machine.force_cancel();
6515        assert!(first.is_some());
6516        let second = machine.force_cancel();
6517        assert!(second.is_none());
6518        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6519    }
6520
6521    #[test]
6522    fn force_cancel_preserves_transition_counter_monotonicity() {
6523        let target = default_target();
6524        let mut machine = PaneDragResizeMachine::default();
6525
6526        let t1 = machine
6527            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6528            .expect("arm");
6529        let t2 = machine.force_cancel().expect("force cancel from armed");
6530        assert!(t2.transition_id > t1.transition_id);
6531
6532        // Re-arm and force cancel again to confirm counter keeps incrementing
6533        let t3 = machine
6534            .apply_event(&pointer_down_event(2, target, 22, 10, 10))
6535            .expect("re-arm");
6536        let t4 = machine.force_cancel().expect("second force cancel");
6537        assert!(t3.transition_id > t2.transition_id);
6538        assert!(t4.transition_id > t3.transition_id);
6539    }
6540
6541    #[test]
6542    fn force_cancel_records_prior_state_in_from_field() {
6543        let target = default_target();
6544        let mut machine = PaneDragResizeMachine::default();
6545        machine
6546            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6547            .expect("arm");
6548
6549        let armed_state = machine.state();
6550        let transition = machine.force_cancel().expect("force cancel");
6551        assert_eq!(transition.from, armed_state);
6552    }
6553
6554    #[test]
6555    fn machine_usable_after_force_cancel() {
6556        let target = default_target();
6557        let mut machine = PaneDragResizeMachine::default();
6558
6559        // Full lifecycle: arm → force cancel → arm again → normal commit
6560        machine
6561            .apply_event(&pointer_down_event(1, target, 22, 0, 0))
6562            .expect("arm");
6563        machine.force_cancel();
6564
6565        machine
6566            .apply_event(&pointer_down_event(2, target, 22, 10, 10))
6567            .expect("re-arm after force cancel");
6568        machine
6569            .apply_event(&pointer_move_event(3, target, 22, 15, 10))
6570            .expect("move to drag");
6571        let commit = machine
6572            .apply_event(&pointer_up_event(4, target, 22, 15, 10))
6573            .expect("commit");
6574        assert!(matches!(
6575            commit.effect,
6576            PaneDragResizeEffect::Committed { .. }
6577        ));
6578        assert_eq!(machine.state(), PaneDragResizeState::Idle);
6579    }
6580
6581    proptest! {
6582        #[test]
6583        fn ratio_is_always_reduced(numerator in 1u32..100_000, denominator in 1u32..100_000) {
6584            let ratio = PaneSplitRatio::new(numerator, denominator).expect("positive ratio must be valid");
6585            let gcd = gcd_u32(ratio.numerator(), ratio.denominator());
6586            prop_assert_eq!(gcd, 1);
6587        }
6588
6589        #[test]
6590        fn allocator_produces_monotonic_ids(
6591            start in 1u64..1_000_000,
6592            count in 1usize..64,
6593        ) {
6594            let mut allocator = PaneIdAllocator::with_next(PaneId::new(start).expect("start must be valid"));
6595            let mut prev = 0u64;
6596            for _ in 0..count {
6597                let current = allocator.allocate().expect("allocation must succeed").get();
6598                prop_assert!(current > prev);
6599                prev = current;
6600            }
6601        }
6602
6603        #[test]
6604        fn split_solver_preserves_available_space(
6605            numerator in 1u32..64,
6606            denominator in 1u32..64,
6607            first_min in 0u16..40,
6608            second_min in 0u16..40,
6609            available in 0u16..80,
6610        ) {
6611            let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
6612            prop_assume!(first_min.saturating_add(second_min) <= available);
6613
6614            let (first_size, second_size) = solve_split_sizes(
6615                id(1),
6616                SplitAxis::Horizontal,
6617                available,
6618                ratio,
6619                AxisBounds { min: first_min, max: None },
6620                AxisBounds { min: second_min, max: None },
6621            ).expect("feasible split should solve");
6622
6623            prop_assert_eq!(first_size.saturating_add(second_size), available);
6624            prop_assert!(first_size >= first_min);
6625            prop_assert!(second_size >= second_min);
6626        }
6627
6628        #[test]
6629        fn split_then_close_round_trip_preserves_validity(
6630            numerator in 1u32..32,
6631            denominator in 1u32..32,
6632            incoming_first in any::<bool>(),
6633        ) {
6634            let mut tree = PaneTree::singleton("root");
6635            let placement = if incoming_first {
6636                PanePlacement::IncomingFirst
6637            } else {
6638                PanePlacement::ExistingFirst
6639            };
6640            let ratio = PaneSplitRatio::new(numerator, denominator).expect("ratio must be valid");
6641
6642            tree.apply_operation(
6643                1,
6644                PaneOperation::SplitLeaf {
6645                    target: id(1),
6646                    axis: SplitAxis::Horizontal,
6647                    ratio,
6648                    placement,
6649                    new_leaf: PaneLeaf::new("extra"),
6650                },
6651            ).expect("split should succeed");
6652
6653            let split_root_id = tree.root();
6654            let split_root = tree.node(split_root_id).expect("split root exists");
6655            let PaneNodeKind::Split(split) = &split_root.kind else {
6656                unreachable!("root should be split");
6657            };
6658            let extra_leaf_id = if split.first == id(1) {
6659                split.second
6660            } else {
6661                split.first
6662            };
6663
6664            tree.apply_operation(2, PaneOperation::CloseNode { target: extra_leaf_id })
6665                .expect("close should succeed");
6666
6667            prop_assert_eq!(tree.root(), id(1));
6668            prop_assert!(matches!(
6669                tree.node(id(1)).map(|node| &node.kind),
6670                Some(PaneNodeKind::Leaf(_))
6671            ));
6672            prop_assert!(tree.validate().is_ok());
6673        }
6674
6675        #[test]
6676        fn transaction_rollback_restores_initial_state_hash(
6677            numerator in 1u32..64,
6678            denominator in 1u32..64,
6679            incoming_first in any::<bool>(),
6680        ) {
6681            let base = PaneTree::singleton("root");
6682            let initial_hash = base.state_hash();
6683            let mut tx = base.begin_transaction(90);
6684            let placement = if incoming_first {
6685                PanePlacement::IncomingFirst
6686            } else {
6687                PanePlacement::ExistingFirst
6688            };
6689
6690            tx.apply_operation(
6691                1,
6692                PaneOperation::SplitLeaf {
6693                    target: id(1),
6694                    axis: SplitAxis::Horizontal,
6695                    ratio: PaneSplitRatio::new(numerator, denominator).expect("valid ratio"),
6696                    placement,
6697                    new_leaf: PaneLeaf::new("new"),
6698                },
6699            ).expect("split should succeed");
6700
6701            let rolled_back = tx.rollback();
6702            prop_assert_eq!(rolled_back.tree.state_hash(), initial_hash);
6703            prop_assert_eq!(rolled_back.tree.root(), id(1));
6704            prop_assert!(rolled_back.tree.validate().is_ok());
6705        }
6706
6707        #[test]
6708        fn repair_safe_is_deterministic_under_recoverable_damage(
6709            numerator in 1u32..32,
6710            denominator in 1u32..32,
6711            add_orphan in any::<bool>(),
6712            mismatch_parent in any::<bool>(),
6713        ) {
6714            let mut snapshot = make_valid_snapshot();
6715            for node in &mut snapshot.nodes {
6716                if node.id == id(1) {
6717                    let PaneNodeKind::Split(split) = &mut node.kind else {
6718                        unreachable!("root should be split");
6719                    };
6720                    split.ratio = PaneSplitRatio {
6721                        numerator: numerator.saturating_mul(2),
6722                        denominator: denominator.saturating_mul(2),
6723                    };
6724                }
6725                if mismatch_parent && node.id == id(2) {
6726                    node.parent = Some(id(3));
6727                }
6728            }
6729            if add_orphan {
6730                snapshot
6731                    .nodes
6732                    .push(PaneNodeRecord::leaf(id(10), None, PaneLeaf::new("orphan")));
6733                snapshot.next_id = id(11);
6734            }
6735
6736            let first = snapshot.clone().repair_safe().expect("first repair should succeed");
6737            let second = snapshot.repair_safe().expect("second repair should succeed");
6738
6739            prop_assert_eq!(first.tree.state_hash(), second.tree.state_hash());
6740            prop_assert_eq!(first.actions, second.actions);
6741            prop_assert_eq!(first.report_after, second.report_after);
6742        }
6743    }
6744}