Skip to main content

ftui_layout/
pane.rs

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