Skip to main content

fret_runtime/
interaction_diagnostics.rs

1use std::collections::HashMap;
2
3use fret_core::geometry::{Point, Rect};
4use fret_core::{AppWindowId, Axis, DockNodeId, DropZone, PointerId, RenderTargetId};
5
6use crate::DragKindId;
7use crate::FrameId;
8use crate::WindowUnderCursorSource;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct DockDragDiagnostics {
12    pub pointer_id: PointerId,
13    pub source_window: AppWindowId,
14    pub current_window: AppWindowId,
15    /// Window-local logical cursor position at the time the snapshot was published.
16    pub position: Point,
17    /// Window-local logical cursor position when the drag session started.
18    pub start_position: Point,
19    /// Cursor grab offset in window-local logical coordinates (ImGui-style multi-viewport anchor).
20    pub cursor_grab_offset: Option<Point>,
21    /// The OS window requested to follow the cursor for this drag session (if any).
22    pub follow_window: Option<AppWindowId>,
23    /// Best-effort diagnostics hint: raw cursor position in screen-space physical pixels, as
24    /// observed by the runner.
25    pub cursor_screen_pos_raw_physical_px: Option<Point>,
26    /// Best-effort diagnostics hint: cursor position in screen-space physical pixels used for
27    /// local position conversion (may be clamped during scripted injection).
28    pub cursor_screen_pos_used_physical_px: Option<Point>,
29    pub cursor_screen_pos_was_clamped: bool,
30    pub cursor_override_active: bool,
31    /// Best-effort diagnostics hint: outer position of `current_window` in screen-space physical
32    /// pixels when routing was computed.
33    pub current_window_outer_pos_physical_px: Option<Point>,
34    /// Best-effort diagnostics hint: decoration offset (client origin relative to outer origin)
35    /// in physical pixels for `current_window`.
36    pub current_window_decoration_offset_physical_px: Option<Point>,
37    /// Best-effort diagnostics hint: computed client origin (screen-space physical px) for
38    /// `current_window`.
39    pub current_window_client_origin_screen_physical_px: Option<Point>,
40    pub current_window_client_origin_source_platform: bool,
41    /// Best-effort diagnostics hint: scale factor used by the runner when converting screen
42    /// physical pixels into window-local logical pixels.
43    pub current_window_scale_factor_x1000_from_runner: Option<u32>,
44    /// Best-effort diagnostics hint: local position derived from the screen-space cursor position
45    /// + client origin + scale factor.
46    pub current_window_local_pos_from_screen_logical_px: Option<Point>,
47    /// Best-effort diagnostics hint: scale factor (DPI) of `current_window` at the time the
48    /// snapshot was published.
49    pub current_window_scale_factor_x1000: Option<u32>,
50    /// The drag kind for the active dock drag session.
51    pub kind: DragKindId,
52    pub dragging: bool,
53    pub cross_window_hover: bool,
54    /// True when the shell-local dock payload ghost should currently paint in this window.
55    ///
56    /// This reflects shell choreography only; it does not imply a native or external drag preview.
57    pub payload_ghost_visible: bool,
58    /// True when the runner has applied an ImGui-style "transparent payload" treatment to the
59    /// moving dock window (e.g. click-through/NoInputs while following the cursor).
60    pub transparent_payload_applied: bool,
61    /// Best-effort diagnostics hint: true when the runner successfully applied click-through
62    /// hit-test passthrough to the moving dock window while transparent payload is enabled.
63    pub transparent_payload_hit_test_passthrough_applied: bool,
64    /// Best-effort diagnostics hint: which mechanism was used to select the hovered window during
65    /// cross-window drag routing (OS-backed vs heuristic).
66    pub window_under_cursor_source: WindowUnderCursorSource,
67    /// Best-effort diagnostics hint: OS window currently being moved by the runner for this drag
68    /// session (ImGui-style "follow window" multi-viewport behavior).
69    pub moving_window: Option<AppWindowId>,
70    /// Best-effort diagnostics hint: scale factor (DPI) of [`Self::moving_window`] at the time
71    /// the snapshot was published.
72    pub moving_window_scale_factor_x1000: Option<u32>,
73    /// Best-effort diagnostics hint: when [`Self::moving_window`] is set, the window considered
74    /// "under" the moving window at the current cursor position.
75    pub window_under_moving_window: Option<AppWindowId>,
76    /// Best-effort diagnostics hint: which mechanism was used to select
77    /// [`Self::window_under_moving_window`].
78    pub window_under_moving_window_source: WindowUnderCursorSource,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct DockFloatingDragDiagnostics {
83    pub pointer_id: PointerId,
84    pub floating: DockNodeId,
85    pub activated: bool,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct ViewportCaptureDiagnostics {
90    pub pointer_id: PointerId,
91    pub target: RenderTargetId,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum DockTabStripActiveVisibilityStatusDiagnostics {
96    Ok,
97    MissingWindowRoot,
98    NoTabsFound,
99    MissingLayoutRect,
100    MissingTabsNode,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct DockTabStripActiveVisibilityDiagnostics {
105    pub status: DockTabStripActiveVisibilityStatusDiagnostics,
106    pub tabs_node: Option<DockNodeId>,
107    /// True when the tab strip reports overflow (i.e. `max_scroll > 0`).
108    pub overflow: bool,
109    pub tab_count: usize,
110    pub active: usize,
111    pub scroll: fret_core::geometry::Px,
112    pub max_scroll: fret_core::geometry::Px,
113    /// True when `active` is visible at the current `scroll` (best-effort).
114    pub active_visible: bool,
115}
116
117#[derive(Debug, Clone, PartialEq, Default)]
118pub struct DockingInteractionDiagnostics {
119    pub dock_drag: Option<DockDragDiagnostics>,
120    pub floating_drag: Option<DockFloatingDragDiagnostics>,
121    pub dock_drop_resolve: Option<DockDropResolveDiagnostics>,
122    pub viewport_capture: Option<ViewportCaptureDiagnostics>,
123    /// Best-effort diagnostics for ensuring the active tab remains visible after selection.
124    pub tab_strip_active_visibility: Option<DockTabStripActiveVisibilityDiagnostics>,
125    /// Best-effort dock graph stats snapshot for the current window.
126    pub dock_graph_stats: Option<DockGraphStatsDiagnostics>,
127    /// Best-effort stable signature for the current window's dock graph.
128    ///
129    /// This is intended for scripted regression gates that want to assert an exact layout shape
130    /// (dockview-style) without relying on pixels.
131    pub dock_graph_signature: Option<DockGraphSignatureDiagnostics>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq)]
135pub enum WorkspaceTabStripActiveVisibilityStatusDiagnostics {
136    Ok,
137    NoActiveTab,
138    MissingScrollViewportRect,
139    MissingActiveTabRect,
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct WorkspaceTabStripActiveVisibilityDiagnostics {
144    pub status: WorkspaceTabStripActiveVisibilityStatusDiagnostics,
145    pub pane_id: Option<std::sync::Arc<str>>,
146    pub active_tab_id: Option<std::sync::Arc<str>>,
147    pub tab_count: usize,
148    pub overflow: bool,
149    pub scroll_x: fret_core::geometry::Px,
150    pub max_scroll_x: fret_core::geometry::Px,
151    pub scroll_viewport_rect: Option<Rect>,
152    pub active_tab_rect: Option<Rect>,
153    pub active_visible: bool,
154}
155
156#[derive(Debug, Clone, PartialEq)]
157pub struct WorkspaceTabStripDragDiagnostics {
158    pub pane_id: Option<std::sync::Arc<str>>,
159    pub pointer_id: Option<PointerId>,
160    pub dragging: bool,
161    pub dragged_tab_id: Option<std::sync::Arc<str>>,
162}
163
164#[derive(Debug, Clone, PartialEq, Default)]
165pub struct WorkspaceInteractionDiagnostics {
166    /// Best-effort tab strip visibility diagnostics published by workspace shells.
167    ///
168    /// Multiple strips may exist per window (multi-pane); publishers should include `pane_id`
169    /// so scripted gates can select deterministically.
170    pub tab_strip_active_visibility: Vec<WorkspaceTabStripActiveVisibilityDiagnostics>,
171    /// Best-effort drag state published by workspace shells.
172    ///
173    /// This is intended for scripted regression gates that want to assert "close buttons do not
174    /// start drags" without relying on pixels.
175    pub tab_strip_drag: Vec<WorkspaceTabStripDragDiagnostics>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct DockGraphSignatureDiagnostics {
180    /// Stable, canonical-ish shape signature for the dock graph in a specific window.
181    ///
182    /// Notes:
183    /// - Does not include floating window rects (platform-dependent).
184    /// - Does not include split fractions (pointer-driven and DPI-sensitive).
185    pub signature: String,
186    /// FNV-1a 64-bit hash of `signature` (for compact assertions).
187    pub fingerprint64: u64,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct DockGraphStatsDiagnostics {
192    pub node_count: u32,
193    pub tabs_count: u32,
194    pub split_count: u32,
195    pub floating_count: u32,
196    pub max_depth: u32,
197    pub max_split_depth: u32,
198    /// True when the graph satisfies the key canonical-form invariants used by docking.
199    pub canonical_ok: bool,
200    /// True when a split contains a same-axis split child (an indicator of unflattened nesting).
201    pub has_nested_same_axis_splits: bool,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum DockDropPreviewKindDiagnostics {
206    WrapBinary,
207    InsertIntoSplit {
208        axis: Axis,
209        split: DockNodeId,
210        insert_index: usize,
211    },
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct DockDropPreviewDiagnostics {
216    pub kind: DockDropPreviewKindDiagnostics,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum DockDropResolveSource {
221    /// Docking previews are disabled for this drag session (inversion policy / modifier gating).
222    InvertDocking,
223    /// The cursor is outside the window bounds.
224    OutsideWindow,
225    /// The cursor is inside `float_zone(...)`, forcing in-window floating.
226    FloatZone,
227    /// The window has no dock root and the cursor is inside the dock bounds.
228    ///
229    /// Dropping in this state will create the initial root tab stack for the window.
230    EmptyDockSpace,
231    /// The position is inside the window, but outside the computed docking layout bounds.
232    LayoutBoundsMiss,
233    /// The previous hover target was reused (anti-flicker latch).
234    LatchedPreviousHover,
235    /// The cursor hit the explicit tab-bar target (center docking + insert index).
236    TabBar,
237    /// The cursor is hovering an in-window floating container title bar (explicit target band).
238    FloatingTitleBar,
239    /// The cursor hit the outer direction-pad (window-root edge docking).
240    OuterHintRect,
241    /// The cursor hit the inner direction-pad (leaf docking).
242    InnerHintRect,
243    /// No docking drop target matched (gated by explicit-target rules).
244    None,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub enum DockDropCandidateRectKind {
249    WindowBounds,
250    DockBounds,
251    FloatZone,
252    LayoutBounds,
253    RootRect,
254    LeafTabsRect,
255    TabBarRect,
256    InnerHintRect,
257    OuterHintRect,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq)]
261pub struct DockDropCandidateRectDiagnostics {
262    pub kind: DockDropCandidateRectKind,
263    pub zone: Option<DropZone>,
264    pub rect: Rect,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub struct DockDropTargetDiagnostics {
269    pub layout_root: DockNodeId,
270    pub tabs: DockNodeId,
271    pub zone: DropZone,
272    pub insert_index: Option<usize>,
273    pub outer: bool,
274}
275
276#[derive(Debug, Clone, PartialEq)]
277pub struct DockDropResolveDiagnostics {
278    pub pointer_id: PointerId,
279    pub position: Point,
280    pub window_bounds: Rect,
281    pub dock_bounds: Rect,
282    pub source: DockDropResolveSource,
283    pub resolved: Option<DockDropTargetDiagnostics>,
284    pub preview: Option<DockDropPreviewDiagnostics>,
285    pub candidates: Vec<DockDropCandidateRectDiagnostics>,
286}
287
288#[derive(Default)]
289pub struct WindowInteractionDiagnosticsStore {
290    per_window: HashMap<AppWindowId, WindowInteractionDiagnosticsFrame>,
291}
292
293#[derive(Default)]
294struct WindowInteractionDiagnosticsFrame {
295    frame_id: FrameId,
296    docking: DockingInteractionDiagnostics,
297    latest_docking: DockingInteractionDiagnostics,
298    workspace: WorkspaceInteractionDiagnostics,
299    latest_workspace: WorkspaceInteractionDiagnostics,
300}
301
302impl WindowInteractionDiagnosticsStore {
303    pub fn begin_frame(&mut self, window: AppWindowId, frame_id: FrameId) {
304        let w = self.per_window.entry(window).or_default();
305        if w.frame_id != frame_id {
306            w.frame_id = frame_id;
307            w.docking = DockingInteractionDiagnostics::default();
308            w.workspace = WorkspaceInteractionDiagnostics::default();
309        }
310    }
311
312    pub fn record_docking(
313        &mut self,
314        window: AppWindowId,
315        frame_id: FrameId,
316        diagnostics: DockingInteractionDiagnostics,
317    ) {
318        self.begin_frame(window, frame_id);
319        let w = self.per_window.entry(window).or_default();
320        w.docking = diagnostics.clone();
321        w.latest_docking = diagnostics;
322    }
323
324    pub fn record_workspace_tab_strip_active_visibility(
325        &mut self,
326        window: AppWindowId,
327        frame_id: FrameId,
328        diagnostics: WorkspaceTabStripActiveVisibilityDiagnostics,
329    ) {
330        self.begin_frame(window, frame_id);
331        let w = self.per_window.entry(window).or_default();
332        w.workspace.tab_strip_active_visibility.push(diagnostics);
333        w.latest_workspace = w.workspace.clone();
334    }
335
336    pub fn record_workspace_tab_strip_drag(
337        &mut self,
338        window: AppWindowId,
339        frame_id: FrameId,
340        diagnostics: WorkspaceTabStripDragDiagnostics,
341    ) {
342        self.begin_frame(window, frame_id);
343        let w = self.per_window.entry(window).or_default();
344        w.workspace.tab_strip_drag.push(diagnostics);
345        w.latest_workspace = w.workspace.clone();
346    }
347
348    pub fn docking_for_window(
349        &self,
350        window: AppWindowId,
351        frame_id: FrameId,
352    ) -> Option<&DockingInteractionDiagnostics> {
353        let w = self.per_window.get(&window)?;
354        (w.frame_id == frame_id).then_some(&w.docking)
355    }
356
357    pub fn workspace_for_window(
358        &self,
359        window: AppWindowId,
360        frame_id: FrameId,
361    ) -> Option<&WorkspaceInteractionDiagnostics> {
362        let w = self.per_window.get(&window)?;
363        (w.frame_id == frame_id).then_some(&w.workspace)
364    }
365
366    pub fn docking_latest_for_window(
367        &self,
368        window: AppWindowId,
369    ) -> Option<&DockingInteractionDiagnostics> {
370        self.per_window.get(&window).map(|w| &w.latest_docking)
371    }
372
373    pub fn workspace_latest_for_window(
374        &self,
375        window: AppWindowId,
376    ) -> Option<&WorkspaceInteractionDiagnostics> {
377        self.per_window.get(&window).map(|w| &w.latest_workspace)
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn docking_latest_is_stable_across_begin_frame_resets() {
387        let mut store = WindowInteractionDiagnosticsStore::default();
388        let window = AppWindowId::default();
389
390        let snapshot = DockingInteractionDiagnostics {
391            dock_graph_stats: Some(DockGraphStatsDiagnostics {
392                node_count: 3,
393                tabs_count: 1,
394                split_count: 1,
395                floating_count: 0,
396                max_depth: 2,
397                max_split_depth: 1,
398                canonical_ok: true,
399                has_nested_same_axis_splits: false,
400            }),
401            ..Default::default()
402        };
403
404        store.record_docking(window, FrameId(1), snapshot);
405        store.begin_frame(window, FrameId(2));
406
407        assert!(
408            store
409                .docking_latest_for_window(window)
410                .and_then(|d| d.dock_graph_stats)
411                .is_some_and(|s| s.canonical_ok),
412            "latest snapshot should persist even when the current frame snapshot is reset"
413        );
414
415        assert!(
416            store
417                .docking_for_window(window, FrameId(2))
418                .is_some_and(|d| d.dock_graph_stats.is_none()),
419            "frame-scoped snapshot should be cleared by begin_frame when not recorded"
420        );
421    }
422
423    #[test]
424    fn workspace_latest_is_stable_across_begin_frame_resets() {
425        let mut store = WindowInteractionDiagnosticsStore::default();
426        let window = AppWindowId::default();
427
428        let snapshot = WorkspaceTabStripActiveVisibilityDiagnostics {
429            status: WorkspaceTabStripActiveVisibilityStatusDiagnostics::Ok,
430            pane_id: Some(std::sync::Arc::<str>::from("pane-a")),
431            active_tab_id: Some(std::sync::Arc::<str>::from("doc-a-2")),
432            tab_count: 3,
433            overflow: true,
434            scroll_x: fret_core::geometry::Px(12.0),
435            max_scroll_x: fret_core::geometry::Px(120.0),
436            scroll_viewport_rect: None,
437            active_tab_rect: None,
438            active_visible: true,
439        };
440
441        store.record_workspace_tab_strip_active_visibility(window, FrameId(1), snapshot);
442        store.begin_frame(window, FrameId(2));
443
444        assert!(
445            store
446                .workspace_latest_for_window(window)
447                .is_some_and(|w| !w.tab_strip_active_visibility.is_empty()),
448            "latest snapshot should persist even when the current frame snapshot is reset"
449        );
450
451        assert!(
452            store
453                .workspace_for_window(window, FrameId(2))
454                .is_some_and(|w| w.tab_strip_active_visibility.is_empty()),
455            "frame-scoped snapshot should be cleared by begin_frame when not recorded"
456        );
457    }
458}