ailloli_ui_devtools_ui/
lib.rs

1//! Ailloli UI widget frontend for DevTools snapshots.
2//!
3//! The builder is pure retained-view composition: it does not mutate the
4//! snapshot or state. Overlay/right modes use a 560-logical-pixel panel;
5//! bottom mode uses a 280-logical-pixel panel. All interactions emit typed
6//! [`DevToolsAction`] values for the host to reduce.
7
8use ailloli_ui_core::style::Length;
9use ailloli_ui_core::{Color, FontId, TextStyle};
10#[cfg(feature = "terminal")]
11use ailloli_ui_devtools_core::TerminalDebugSnapshot;
12use ailloli_ui_devtools_core::{DebugNode, DebugSnapshot, DebugWarning, DevToolsMode};
13use ailloli_ui_runtime::component::{IntoView, IntoViewKeyExt, View};
14use ailloli_ui_widgets::controls::Button;
15use ailloli_ui_widgets::layout::{Column, Container, FlexItemExt, Row, ScrollView};
16use ailloli_ui_widgets::text::Text;
17
18/// Stable retained key for the panel container.
19///
20/// # Examples
21///
22/// ```
23/// use ailloli_ui_devtools_ui::DEVTOOLS_PANEL_KEY;
24/// assert_eq!(DEVTOOLS_PANEL_KEY, "ailloli_ui-devtools-panel");
25/// ```
26pub const DEVTOOLS_PANEL_KEY: &str = "ailloli_ui-devtools-panel";
27/// Stable retained key for the overlay-mode button.
28///
29/// # Examples
30///
31/// ```
32/// use ailloli_ui_devtools_ui::DEVTOOLS_MODE_OVERLAY_KEY;
33/// assert!(DEVTOOLS_MODE_OVERLAY_KEY.ends_with("overlay"));
34/// ```
35pub const DEVTOOLS_MODE_OVERLAY_KEY: &str = "ailloli_ui-devtools-mode-overlay";
36/// Stable retained key for the right-dock button.
37///
38/// # Examples
39///
40/// ```
41/// use ailloli_ui_devtools_ui::DEVTOOLS_MODE_RIGHT_KEY;
42/// assert!(DEVTOOLS_MODE_RIGHT_KEY.ends_with("right"));
43/// ```
44pub const DEVTOOLS_MODE_RIGHT_KEY: &str = "ailloli_ui-devtools-mode-right";
45/// Stable retained key for the bottom-dock button.
46///
47/// # Examples
48///
49/// ```
50/// use ailloli_ui_devtools_ui::DEVTOOLS_MODE_BOTTOM_KEY;
51/// assert!(DEVTOOLS_MODE_BOTTOM_KEY.ends_with("bottom"));
52/// ```
53pub const DEVTOOLS_MODE_BOTTOM_KEY: &str = "ailloli_ui-devtools-mode-bottom";
54/// Stable retained key for the picker toggle button.
55///
56/// # Examples
57///
58/// ```
59/// use ailloli_ui_devtools_ui::DEVTOOLS_PICK_KEY;
60/// assert_eq!(DEVTOOLS_PICK_KEY, "ailloli_ui-devtools-pick");
61/// ```
62pub const DEVTOOLS_PICK_KEY: &str = "ailloli_ui-devtools-pick";
63
64/// User intent emitted by the retained DevTools view.
65///
66/// The UI never applies these actions itself; the host owns state reduction and
67/// snapshot refresh.
68///
69/// # Examples
70///
71/// ```
72/// use ailloli_ui_devtools_ui::DevToolsAction;
73/// let action = DevToolsAction::Select(Some(42));
74/// assert_eq!(action, DevToolsAction::Select(Some(42)));
75/// ```
76#[derive(Debug, Clone, PartialEq)]
77pub enum DevToolsAction {
78    /// Selects an element ID, or clears selection with `None`.
79    Select(Option<u64>),
80    /// Sets an element hover ID, or clears hover with `None`.
81    Hover(Option<u64>),
82    /// Requests one core presentation mode.
83    SetMode(DevToolsMode),
84    /// Toggles host-controlled element-picking mode.
85    TogglePicker,
86    /// Replaces the case-insensitive tree filter; empty/whitespace shows all nodes.
87    SetFilter(String),
88}
89
90/// Host-owned presentation and interaction state consumed by the view builder.
91///
92/// # Examples
93///
94/// ```
95/// use ailloli_ui_devtools_core::DevToolsMode;
96/// use ailloli_ui_devtools_ui::DevToolsState;
97/// let state = DevToolsState::default();
98/// assert!(!state.enabled);
99/// assert_eq!(state.mode, DevToolsMode::Overlay);
100/// assert!(state.filter.is_empty());
101/// ```
102#[derive(Debug, Clone)]
103pub struct DevToolsState {
104    /// Master visibility switch; `false` produces an empty view.
105    pub enabled: bool,
106    /// Overlay/docking mode; `Hidden` also produces an empty view.
107    pub mode: DevToolsMode,
108    /// Whether the header reports active host picking.
109    pub picker_active: bool,
110    /// Selected element ID used by the tree marker and details panel.
111    pub selected: Option<u64>,
112    /// Hovered element ID used by the tree marker when not selected.
113    pub hovered: Option<u64>,
114    /// Case-insensitive widget-name/key/decimal-ID filter.
115    pub filter: String,
116}
117
118/// Starts disabled in overlay mode with no interaction state or filter.
119impl Default for DevToolsState {
120    /// Returns the conservative host-controlled defaults.
121    fn default() -> Self {
122        Self {
123            enabled: false,
124            mode: DevToolsMode::Overlay,
125            picker_active: false,
126            selected: None,
127            hovered: None,
128            filter: String::new(),
129        }
130    }
131}
132
133/// Builds an overlay or docked panel from immutable snapshot and UI state.
134///
135/// Disabled/hidden state returns [`View::empty`]. Overlay mode right-aligns a
136/// 560-pixel panel with 12 pixels of top spacing; right docking reserves 560
137/// pixels; bottom docking reserves 280 pixels. The tree filter does not change
138/// the details fallback, which uses the selected node or first snapshot node.
139///
140/// # Examples
141///
142/// ```no_run
143/// use ailloli_ui_devtools_core::DebugSnapshot;
144/// use ailloli_ui_devtools_ui::{build_devtools_overlay, DevToolsAction, DevToolsState};
145/// use ailloli_ui_runtime::component::View;
146/// fn build(snapshot: &DebugSnapshot) {
147///     let view: View<DevToolsAction> = build_devtools_overlay(snapshot, &DevToolsState::default());
148///     let _ = view;
149/// }
150/// ```
151pub fn build_devtools_overlay(
152    snapshot: &DebugSnapshot,
153    state: &DevToolsState,
154) -> View<DevToolsAction> {
155    if !state.enabled || matches!(state.mode, DevToolsMode::Hidden) {
156        return View::empty();
157    }
158
159    let panel = Container::new()
160        .width(panel_width(state.mode))
161        .height(panel_height(state.mode))
162        .background(Color::rgba(20, 22, 28, 0.94))
163        .radius(8.0)
164        .child(
165            Column::new().fill().child(header(snapshot, state)).child(
166                Row::new()
167                    .fill()
168                    .child(tree_panel(snapshot, state).flex_grow_by(1.2))
169                    .child(details_panel(snapshot, state).flex_grow()),
170            ),
171        )
172        .key(DEVTOOLS_PANEL_KEY)
173        .into_view();
174
175    match state.mode {
176        DevToolsMode::Overlay => Container::new()
177            .fill()
178            .child(
179                Row::new()
180                    .fill()
181                    .child(Container::new().flex_grow().into_view())
182                    .child(
183                        Column::new()
184                            .fill_height()
185                            .child(Container::new().height(Length::px(12.0)).into_view())
186                            .child(panel)
187                            .child(Container::new().flex_grow().into_view()),
188                    ),
189            )
190            .into_view(),
191        DevToolsMode::DockRight => Row::new()
192            .fill()
193            .child(Container::new().flex_grow().into_view())
194            .child(panel)
195            .into_view(),
196        DevToolsMode::DockBottom => Column::new()
197            .fill()
198            .child(Container::new().flex_grow().into_view())
199            .child(panel)
200            .into_view(),
201        DevToolsMode::Hidden => View::empty(),
202    }
203}
204
205/// Builds counts, picker status, three mode buttons, and picker toggle.
206fn header(snapshot: &DebugSnapshot, state: &DevToolsState) -> View<DevToolsAction> {
207    Row::new()
208        .fill_width()
209        .height(Length::px(34.0))
210        .child(label(format!(
211            "Ailloli UI DevTools - nodes {} - warnings {}{}",
212            snapshot.nodes.len(),
213            snapshot.warnings.len(),
214            if state.picker_active { " - picker" } else { "" }
215        )))
216        .child(mode_button("Overlay", DevToolsMode::Overlay))
217        .child(mode_button("Right", DevToolsMode::DockRight))
218        .child(mode_button("Bottom", DevToolsMode::DockBottom))
219        .child(
220            Button::new()
221                .child(label("Pick"))
222                .on_click(DevToolsAction::TogglePicker)
223                .key(DEVTOOLS_PICK_KEY),
224        )
225        .into_view()
226}
227
228/// Builds filtered pre-order node buttons inside a scroll view.
229///
230/// Selected nodes use `>`; otherwise hovered nodes use `~`. Indentation repeats
231/// two spaces per depth and `!` marks any per-node warning.
232fn tree_panel(snapshot: &DebugSnapshot, state: &DevToolsState) -> View<DevToolsAction> {
233    let mut list = Column::new().fill_width();
234    for node in snapshot
235        .nodes
236        .iter()
237        .filter(|node| node_matches(node, state))
238    {
239        let marker = if Some(node.id) == state.selected {
240            ">"
241        } else if Some(node.id) == state.hovered {
242            "~"
243        } else {
244            " "
245        };
246        let warning = if node.warnings.is_empty() { "" } else { " !" };
247        let key = node
248            .key
249            .as_ref()
250            .map(|key| format!(" key={key}"))
251            .unwrap_or_default();
252        let text = format!(
253            "{marker} {}{}#{} {}{}",
254            "  ".repeat(node.depth),
255            node.widget_name,
256            node.id,
257            key,
258            warning
259        );
260        list = list.child(
261            Button::new()
262                .fill_width()
263                .child(label(text))
264                .on_click(DevToolsAction::Select(Some(node.id))),
265        );
266    }
267
268    section("Tree", ScrollView::new().child(list).into_view())
269}
270
271/// Builds property/layout, optional terminal, and warning sections.
272///
273/// Details prefer the selected ID when present in the snapshot, otherwise the
274/// first node. The warning section is always present.
275fn details_panel(snapshot: &DebugSnapshot, state: &DevToolsState) -> View<DevToolsAction> {
276    let selected = state
277        .selected
278        .and_then(|id| snapshot.node(id))
279        .or_else(|| snapshot.nodes.first());
280
281    let mut content = Column::new().fill_width();
282    if let Some(node) = selected {
283        content = content
284            .child(section("Properties", node_properties(node)))
285            .child(section("Layout / Flex / Paint", node_layout(node)));
286    }
287    #[cfg(feature = "terminal")]
288    {
289        if !snapshot.terminal_inspections.is_empty() {
290            content = content.child(section("Terminal inspector", terminal_inspector(snapshot)));
291        }
292    }
293    content = content.child(section("Warnings", warnings_view(snapshot)));
294
295    ScrollView::new().child(content).into_view()
296}
297
298/// Formats stable identity, key, parent, and child values for one node.
299fn node_properties(node: &DebugNode) -> View<DevToolsAction> {
300    Column::new()
301        .fill_width()
302        .child(label(format!("id: {}", node.id)))
303        .child(label(format!("widget: {}", node.widget_name)))
304        .child(label(format!(
305            "key: {}",
306            node.key.as_deref().unwrap_or("-")
307        )))
308        .child(label(format!("parent: {:?}", node.parent)))
309        .child(label(format!("children: {:?}", node.children)))
310        .into_view()
311}
312
313/// Formats node geometry, hints, flex inputs, and optional constraints.
314///
315/// Floating-point display uses one decimal place; absent values use `-`.
316fn node_layout(node: &DebugNode) -> View<DevToolsAction> {
317    Column::new()
318        .fill_width()
319        .child(label(format!(
320            "layout size: {:.1} x {:.1}",
321            node.layout_size.w, node.layout_size.h
322        )))
323        .child(label(format_rect("assigned slot", node.assigned_slot)))
324        .child(label(format_rect("absolute", Some(node.absolute_bounds))))
325        .child(label(format_rect("paint", node.paint_bounds)))
326        .child(label(format_rect("clip", node.clip_bounds)))
327        .child(label(format!(
328            "size hint: width={:?} height={:?}",
329            node.size_hint.width, node.size_hint.height
330        )))
331        .child(label(format!(
332            "flex: grow={:.1} shrink={:.1} basis={:?} align={:?}",
333            node.flex_item.flex_grow,
334            node.flex_item.flex_shrink,
335            node.flex_item.flex_basis,
336            node.flex_item.align_self
337        )))
338        .child(label(format!(
339            "constraints in: {}",
340            node.layout_debug
341                .as_ref()
342                .and_then(|debug| debug.constraints_in)
343                .map(format_constraints)
344                .unwrap_or_else(|| "-".to_string())
345        )))
346        .child(label(format!(
347            "constraints final: {}",
348            node.layout_debug
349                .as_ref()
350                .and_then(|debug| debug.constraints_final)
351                .map(format_constraints)
352                .unwrap_or_else(|| "-".to_string())
353        )))
354        .into_view()
355}
356
357/// Builds warning rows or the explicit `No warnings` empty state.
358fn warnings_view(snapshot: &DebugSnapshot) -> View<DevToolsAction> {
359    let mut list = Column::new().fill_width();
360    if snapshot.warnings.is_empty() {
361        list = list.child(label("No warnings"));
362    } else {
363        for warning in &snapshot.warnings {
364            list = list.child(warning_row(warning));
365        }
366    }
367    list.into_view()
368}
369
370/// Builds a warning button that selects its optional affected node.
371fn warning_row(warning: &DebugWarning) -> View<DevToolsAction> {
372    Button::new()
373        .fill_width()
374        .child(label(format!(
375            "{:?} node={:?}: {}",
376            warning.kind, warning.node, warning.message
377        )))
378        .on_click(DevToolsAction::Select(warning.node))
379        .into_view()
380}
381
382#[cfg(feature = "terminal")]
383/// Builds one terminal block per inspection in snapshot order.
384fn terminal_inspector(snapshot: &DebugSnapshot) -> View<DevToolsAction> {
385    let mut list = Column::new().fill_width();
386    for terminal in &snapshot.terminal_inspections {
387        list = list.child(terminal_debug_block(terminal));
388    }
389    list.into_view()
390}
391
392#[cfg(feature = "terminal")]
393/// Formats bounded terminal state without exposing hyperlink targets.
394///
395/// It shows the newest three commands in reverse order, the oldest three
396/// retained warnings, and the first four captured latest-output lines.
397fn terminal_debug_block(terminal: &TerminalDebugSnapshot) -> View<DevToolsAction> {
398    let snapshot = &terminal.snapshot;
399    let mut list = Column::new()
400        .fill_width()
401        .child(label(format!(
402            "session: {} ({})",
403            terminal.title, terminal.id
404        )))
405        .child(label(format!(
406            "screen: {:?} {}x{} scrollback={} pushed={}",
407            snapshot.active_screen,
408            snapshot.size.cols,
409            snapshot.size.rows,
410            snapshot.scrollback_len,
411            snapshot.scrollback_total_pushed
412        )))
413        .child(label(format!(
414            "cursor: row={} col={} visible={} shape={:?}",
415            snapshot.cursor.row,
416            snapshot.cursor.col,
417            snapshot.cursor.visible,
418            snapshot.cursor.shape
419        )))
420        .child(label(format!(
421            "modes: alt={} wrap={} bracketed={} mouse={:?}/sgr={}",
422            snapshot.modes.alternate_screen,
423            snapshot.modes.wraparound,
424            snapshot.modes.bracketed_paste,
425            snapshot.modes.mouse_tracking,
426            snapshot.modes.sgr_mouse
427        )))
428        .child(label(format!(
429            "damage: full={} dirty={:?}",
430            snapshot.damage_full, snapshot.dirty_lines
431        )))
432        .child(label(format!(
433            "diagnostics={} warnings={} commands={} events={} stable_ids={}",
434            snapshot.diagnostics.len(),
435            snapshot.warnings.len(),
436            snapshot.commands.len(),
437            snapshot.event_log.len(),
438            terminal.stable_ids.len()
439        )));
440    for command in snapshot.commands.iter().rev().take(3) {
441        list = list.child(label(format!(
442            "cmd #{} {:?}: {}",
443            command.id.0, command.status, command.command_line
444        )));
445    }
446    for warning in snapshot.warnings.iter().take(3) {
447        list = list.child(label(format!(
448            "warning: {:?} {}",
449            warning.kind, warning.reason
450        )));
451    }
452    for line in snapshot.latest_output_lines.iter().take(4) {
453        list = list.child(label(format!("out: {}", line.trim_end())));
454    }
455    list.into_view()
456}
457
458/// Wraps titled content with eight logical pixels of padding and panel styling.
459fn section(title: impl Into<String>, child: impl IntoView<DevToolsAction>) -> View<DevToolsAction> {
460    Container::new()
461        .fill_width()
462        .padding(8.0)
463        .background(Color::rgba(31, 34, 42, 0.96))
464        .child(
465            Column::new()
466                .fill_width()
467                .child(label(title.into()))
468                .child(child),
469        )
470        .into_view()
471}
472
473/// Builds a keyed mode button; hidden mode uses an internal stable fallback key.
474fn mode_button(label_text: &str, mode: DevToolsMode) -> View<DevToolsAction> {
475    let key = match mode {
476        DevToolsMode::Overlay => DEVTOOLS_MODE_OVERLAY_KEY,
477        DevToolsMode::DockRight => DEVTOOLS_MODE_RIGHT_KEY,
478        DevToolsMode::DockBottom => DEVTOOLS_MODE_BOTTOM_KEY,
479        DevToolsMode::Hidden => "ailloli_ui-devtools-mode-hidden",
480    };
481    Button::new()
482        .child(label(label_text))
483        .on_click(DevToolsAction::SetMode(mode))
484        .key(key)
485        .into_view()
486}
487
488/// Builds 12-logical-pixel white UI text.
489fn label(text: impl Into<String>) -> Text {
490    Text::new(text.into()).style(TextStyle::new(FontId::Ui, 12, Color::WHITE))
491}
492
493/// Matches trimmed lowercase filter text against widget name, key, or decimal ID.
494///
495/// Unicode lowercase conversion allocates for each inspected node when the
496/// filter is nonempty. Whitespace-only filters match every node.
497fn node_matches(node: &DebugNode, state: &DevToolsState) -> bool {
498    if state.filter.trim().is_empty() {
499        return true;
500    }
501    let needle = state.filter.to_lowercase();
502    node.widget_name.to_lowercase().contains(&needle)
503        || node
504            .key
505            .as_ref()
506            .is_some_and(|key| key.to_lowercase().contains(&needle))
507        || node.id.to_string().contains(&needle)
508}
509
510/// Returns 560 logical pixels for overlay/right, fill for bottom, and zero when hidden.
511fn panel_width(mode: DevToolsMode) -> Length {
512    match mode {
513        DevToolsMode::Overlay | DevToolsMode::DockRight => Length::px(560.0),
514        DevToolsMode::DockBottom => Length::Fill,
515        DevToolsMode::Hidden => Length::px(0.0),
516    }
517}
518
519/// Returns fill for overlay/right, 280 logical pixels for bottom, and zero when hidden.
520fn panel_height(mode: DevToolsMode) -> Length {
521    match mode {
522        DevToolsMode::Overlay | DevToolsMode::DockRight => Length::Fill,
523        DevToolsMode::DockBottom => Length::px(280.0),
524        DevToolsMode::Hidden => Length::px(0.0),
525    }
526}
527
528/// Formats optional logical geometry with one decimal and derived bottom edge.
529fn format_rect(label: &str, rect: Option<ailloli_ui_devtools_core::DebugRect>) -> String {
530    match rect {
531        Some(rect) => format!(
532            "{label}: x={:.1} y={:.1} w={:.1} h={:.1} bottom={:.1}",
533            rect.x,
534            rect.y,
535            rect.w,
536            rect.h,
537            rect.bottom()
538        ),
539        None => format!("{label}: -"),
540    }
541}
542
543/// Formats `(min_w,min_h)` and `(max_w,max_h)` with one decimal place.
544fn format_constraints(c: ailloli_ui_devtools_core::DebugConstraints) -> String {
545    format!(
546        "min=({:.1},{:.1}) max=({:.1},{:.1})",
547        c.min_w, c.min_h, c.max_w, c.max_h
548    )
549}