1use 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
18pub const DEVTOOLS_PANEL_KEY: &str = "ailloli_ui-devtools-panel";
27pub const DEVTOOLS_MODE_OVERLAY_KEY: &str = "ailloli_ui-devtools-mode-overlay";
36pub const DEVTOOLS_MODE_RIGHT_KEY: &str = "ailloli_ui-devtools-mode-right";
45pub const DEVTOOLS_MODE_BOTTOM_KEY: &str = "ailloli_ui-devtools-mode-bottom";
54pub const DEVTOOLS_PICK_KEY: &str = "ailloli_ui-devtools-pick";
63
64#[derive(Debug, Clone, PartialEq)]
77pub enum DevToolsAction {
78 Select(Option<u64>),
80 Hover(Option<u64>),
82 SetMode(DevToolsMode),
84 TogglePicker,
86 SetFilter(String),
88}
89
90#[derive(Debug, Clone)]
103pub struct DevToolsState {
104 pub enabled: bool,
106 pub mode: DevToolsMode,
108 pub picker_active: bool,
110 pub selected: Option<u64>,
112 pub hovered: Option<u64>,
114 pub filter: String,
116}
117
118impl Default for DevToolsState {
120 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
133pub 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
205fn 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
228fn 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
271fn 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
298fn 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
313fn 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
357fn 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
370fn 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")]
383fn 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")]
393fn 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
458fn 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
473fn 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
488fn label(text: impl Into<String>) -> Text {
490 Text::new(text.into()).style(TextStyle::new(FontId::Ui, 12, Color::WHITE))
491}
492
493fn 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
510fn 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
519fn 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
528fn 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
543fn 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}