use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use crate::core::component::{Component, Context, KeyUpdate, Update};
use crate::core::element::{Element, IntoElement};
use crate::core::event::KeyCode;
use crate::style::{Align, BorderStyle, Justify, Length, Paint, ScrollbarConfig, Style};
use crate::utils::gradient::ColorGradient;
use crate::widgets::{
BorderMergeMode, Button, ButtonVariant, Frame, FrameLabel, HStack, Input, InputEvent,
LogFilterMode, LogView, LogViewEvent, Overflow, ScrollKeymap, ScrollView, Spacer, Sparkline,
SparklineBarsPreset, SparklineVariant, SparklineZeroPolicy, TabsEvent, Text, VStack,
};
use super::state::DevToolsState;
pub(crate) const DEVTOOLS_KEY: &str = "devtools-panel";
const DEVTOOLS_FILTER_KEY: &str = "devtools-filter";
const DEVTOOLS_TAB_LOGS: usize = 1;
const DEVTOOLS_TAB_APP: usize = 2;
const DEVTOOLS_APP_METRICS_KEY: &str = "devtools-app-metrics";
pub(crate) struct DevToolsPanel;
#[derive(Clone, PartialEq)]
pub(crate) struct DevToolsProps {
pub(crate) state: Rc<RefCell<DevToolsState>>,
}
pub(crate) fn panel_element(state: Rc<RefCell<DevToolsState>>) -> Element {
crate::child(|| DevToolsPanel, DevToolsProps { state })
}
#[derive(Clone, Debug)]
pub(crate) enum DevToolsMsg {
TabChanged(TabsEvent),
FilterChanged(InputEvent),
LogSelected(LogViewEvent),
ToggleAutoFollow,
TogglePaused,
ToggleFrameworkLogs,
ClearLogs,
CopySelected,
CopyEntry(String),
Hide,
}
impl Component for DevToolsPanel {
type Message = DevToolsMsg;
type Properties = DevToolsProps;
type State = ();
fn create_state(&self, _props: &Self::Properties) -> Self::State {}
fn on_key(&mut self, key: crate::core::event::KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
if matches!(key.code, KeyCode::Esc) {
ctx.link().send(DevToolsMsg::Hide);
return KeyUpdate::handled(Update::none());
}
if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& key.mods.ctrl
&& ctx.props.state.borrow().is_logs_tab_active()
{
ctx.link().send(DevToolsMsg::CopySelected);
return KeyUpdate::handled(Update::none());
}
KeyUpdate::unhandled(Update::none())
}
fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
match msg {
DevToolsMsg::CopySelected => {
let text = ctx.props.state.borrow().selected_log_text();
self.copy_to_clipboard(ctx, text);
return Update::none();
}
DevToolsMsg::CopyEntry(text) => {
self.copy_to_clipboard(ctx, Some(text));
return Update::none();
}
_ => {}
}
let mut state = ctx.props.state.borrow_mut();
match msg {
DevToolsMsg::TabChanged(event) => {
state.set_active_tab(event.index);
}
DevToolsMsg::FilterChanged(event) => {
state.apply_log_filter(&event);
}
DevToolsMsg::LogSelected(event) => {
state.set_log_auto_follow(false);
state.set_log_selected(event.visible_index);
}
DevToolsMsg::ToggleAutoFollow => {
state.toggle_log_auto_follow();
}
DevToolsMsg::TogglePaused => {
state.toggle_log_paused();
}
DevToolsMsg::ToggleFrameworkLogs => {
state.toggle_hide_framework_logs();
}
DevToolsMsg::ClearLogs => {
state.clear_logs();
}
DevToolsMsg::Hide => {
state.set_visible(false);
}
DevToolsMsg::CopySelected | DevToolsMsg::CopyEntry(_) => unreachable!(),
}
Update::full()
}
fn view(&self, ctx: &Context<Self>) -> Element {
let state = ctx.props.state.borrow();
if !state.visible {
return Spacer::new().height(Length::Px(0)).into();
}
let theme = ctx.theme();
let frame_style = fg_style(theme.primary.fg).bg(theme.surface.menu);
let secondary_style = fg_style(theme.muted.fg.or(theme.primary.fg));
let viewport = ctx.viewport();
let (panel_width, panel_height) = state.resolved_panel_size(viewport.w, viewport.h);
let body = match state.active_tab {
DEVTOOLS_TAB_LOGS => logs_body(ctx, &state),
DEVTOOLS_TAB_APP => app_body(ctx, &state, state.app_label_width(viewport.w)),
_ => stats_body(ctx, &state),
};
VStack::new()
.width(Length::Flex(1))
.height(Length::Flex(1))
.justify(Justify::End)
.child(
Frame::new()
.border(true)
.border_style(BorderStyle::Rounded)
.border_merge_mode(BorderMergeMode::Replace)
.tab_titles(["Stats", "Logs", "App"])
.active_tab(state.active_tab.min(DEVTOOLS_TAB_APP))
.on_tab_change(ctx.link().callback(DevToolsMsg::TabChanged))
.style(frame_style)
.header_right(FrameLabel::new("DevTools").style(secondary_style))
.header_style(secondary_style)
.width(panel_width)
.height(panel_height)
.child(body)
.key(DEVTOOLS_KEY),
)
.into()
}
}
impl DevToolsPanel {
fn copy_to_clipboard(&self, ctx: &mut Context<Self>, text: Option<String>) {
let message = match text {
None => "No log selected to copy",
Some(text) => match ctx.clipboard().copy(&text) {
Ok(()) => "Copied log line",
Err(_) => "Clipboard write failed",
},
};
ctx.toast().push(crate::widgets::Toast::new(message));
}
}
fn fg_style(color: Option<Paint>) -> Style {
match color {
Some(color) => Style::new().fg(color),
None => Style::new(),
}
}
fn fmt_ms(duration: std::time::Duration) -> String {
format!("{:.2}ms", duration.as_secs_f64() * 1000.0)
}
fn dotted(parts: &[String], empty: &str) -> String {
if parts.is_empty() {
empty.to_string()
} else {
parts.join(" \u{b7} ")
}
}
fn stats_body(ctx: &Context<DevToolsPanel>, state: &DevToolsState) -> Element {
let theme = ctx.theme();
let primary_style = fg_style(theme.primary.fg);
let secondary_style = fg_style(theme.muted.fg.or(theme.primary.fg));
let dim_style = fg_style(
theme
.muted
.fg
.map(|paint| Paint::solid(paint.color().dim())),
);
let window = state.stats_window();
let (node_count, overlay_count) = state
.latest_frame()
.map(|frame| (frame.node_count, frame.overlay_count))
.unwrap_or((0, 0));
let mut rows: Vec<Element> = Vec::new();
let label_style = primary_style.bold();
let labeled = |label: &'static str, value: String, value_style: Style| -> Element {
HStack::new()
.height(Length::Auto)
.child(Text::new(label).width(Length::Px(8)).style(label_style))
.child(
Text::new(value)
.overflow(Overflow::Ellipsis)
.width(Length::Flex(1))
.style(value_style),
)
.into()
};
rows.push(
Text::new(format!(
"FPS {:.0} \u{b7} Nodes {} \u{b7} Overlays {}",
state.fps(),
node_count,
overlay_count,
))
.overflow(Overflow::Ellipsis)
.width(Length::Flex(1))
.style(primary_style.bold())
.into(),
);
rows.push(labeled(
"Frame",
format!(
"avg {} \u{b7} max {}",
fmt_ms(window.avg_total),
fmt_ms(window.max_total),
),
secondary_style,
));
rows.push(labeled(
"Recon",
format!(
"avg {} \u{b7} Draw avg {}",
fmt_ms(window.avg_reconcile),
fmt_ms(window.avg_draw),
),
secondary_style,
));
let cols = state.sparkline_columns(ctx.viewport().w);
let history = state.duration_history_us(cols);
let scale_us = history
.iter()
.copied()
.max()
.unwrap_or(0)
.max(crate::devtools::state::FRAME_BUDGET_US);
rows.push(labeled(
"Chart",
format!("scale {:.1}ms", scale_us as f64 / 1000.0),
dim_style,
));
let sqrt_max = (scale_us as f64).sqrt().ceil() as u64;
let sqrt_history: Vec<u64> = history
.iter()
.map(|&us| (us as f64).sqrt().round() as u64)
.collect();
rows.push(
Sparkline::new(sqrt_history)
.variant(SparklineVariant::Bars)
.min(0)
.max(sqrt_max)
.zero_policy(SparklineZeroPolicy::MinGlyph)
.chart_height(2)
.bars_preset(SparklineBarsPreset::Blocks)
.height_gradient(ColorGradient::new(
theme
.accent
.fg
.map(Paint::color)
.unwrap_or(theme.border_active),
theme
.muted
.fg
.or(theme.primary.fg)
.map(Paint::color)
.unwrap_or(theme.border_active),
))
.overflow(Overflow::ClipStart)
.width(Length::Flex(1))
.height(Length::Px(2))
.into(),
);
rows.push(labeled(
"Updates",
format!(
"full {} \u{b7} layout {} \u{b7} paint {}",
window.full, window.layout, window.paint,
),
secondary_style,
));
let source_parts: Vec<String> = window
.top_sources
.iter()
.map(|(label, count)| format!("{label} x{count}"))
.collect();
let why_style = if source_parts.is_empty() {
dim_style
} else {
secondary_style
};
rows.push(labeled("Why", dotted(&source_parts, "idle"), why_style));
let memo_total = window.memo_hits + window.memo_misses;
let (memo_text, memo_style) = if memo_total == 0 {
("no data".to_string(), dim_style)
} else {
let hit_rate = (window.memo_hits as f64 / memo_total as f64) * 100.0;
(
format!("{hit_rate:.0}% hit ({}/{memo_total})", window.memo_hits),
secondary_style,
)
};
rows.push(labeled("Memo", memo_text, memo_style));
let miss_parts: Vec<String> = window
.top_miss_reasons
.iter()
.map(|(reason, count)| {
let label = crate::core::nested::memo_miss_reason_label(*reason);
format!("{label} x{count}")
})
.collect();
let miss_style = if miss_parts.is_empty() {
dim_style
} else {
secondary_style
};
rows.push(labeled("Miss", dotted(&miss_parts, "none"), miss_style));
let slow_parts: Vec<String> = window
.top_slow_views
.iter()
.map(|(name, duration)| format!("{name} {}", fmt_ms(*duration)))
.collect();
let slow_style = if slow_parts.is_empty() {
dim_style
} else {
secondary_style
};
rows.push(labeled("Slow", dotted(&slow_parts, "none"), slow_style));
let focus_target = match (&state.focus.tag, &state.focus.key) {
(Some(tag), Some(key)) => format!("{tag:?} \"{}\"", key.as_ref() as &str),
(Some(tag), None) => format!("{tag:?}"),
(None, Some(key)) => format!("\"{}\"", key.as_ref() as &str),
(None, None) => "none".to_string(),
};
rows.push(labeled(
"Focus",
format!(
"{focus_target} \u{b7} {:?} \u{b7} r{}",
state.focus.policy, state.focus.ring_len,
),
secondary_style,
));
let pressure = state.input_pressure();
if pressure.should_warn() {
rows.push(labeled(
"Input",
format!(
"{}/{} full frames over budget",
pressure.offending, pressure.window,
),
Style::default().fg(crate::style::Color::Yellow),
));
} else {
rows.push(labeled("Input", "ok".to_string(), dim_style));
}
let mut stack = VStack::new().height(Length::Flex(1)).gap(0);
for row in rows {
stack = stack.child(row);
}
stack.into()
}
fn logs_body(ctx: &Context<DevToolsPanel>, state: &DevToolsState) -> Element {
let theme = ctx.theme();
let secondary_style = fg_style(theme.muted.fg.or(theme.primary.fg));
let dim_style = fg_style(
theme
.muted
.fg
.map(|paint| Paint::solid(paint.color().dim())),
);
let primary_style = fg_style(theme.primary.fg);
let accent_style = fg_style(theme.accent.fg.or(theme.primary.fg));
let filter_input = Input::bound(&state.log_filter)
.placeholder("Filter logs (fuzzy)...")
.border(true)
.border_style(BorderStyle::Rounded)
.on_change(ctx.link().callback(DevToolsMsg::FilterChanged))
.key(DEVTOOLS_FILTER_KEY);
let chip_bg = theme.surface.element;
let toggle = |label: &'static str, on: bool, msg: DevToolsMsg| -> Button {
let (icon, style) = if on {
("\u{25cf}", accent_style.bold())
} else {
("\u{25cb}", dim_style)
};
Button::new(label)
.variant(ButtonVariant::Filled)
.icon(icon)
.style(style.bg(chip_bg))
.hover_style(primary_style.bold().bg(chip_bg))
.focus_style(accent_style.bold().bg(chip_bg))
.on_click(ctx.link().callback(move |_| msg.clone()))
};
let controls = HStack::new()
.height(Length::Auto)
.align(Align::Center)
.gap(1)
.child(toggle(
"Follow",
state.log_auto_follow,
DevToolsMsg::ToggleAutoFollow,
))
.child(toggle("Pause", state.log_paused, DevToolsMsg::TogglePaused))
.child(toggle(
"Framework",
!state.hide_framework_logs,
DevToolsMsg::ToggleFrameworkLogs,
))
.child(
Button::new("Clear")
.variant(ButtonVariant::Filled)
.style(secondary_style.bg(chip_bg))
.hover_style(Style::default().fg(theme.status.error).bold().bg(chip_bg))
.focus_style(accent_style.bold().bg(chip_bg))
.on_click(ctx.link().callback(|_| DevToolsMsg::ClearLogs)),
)
.child(Spacer::new().width(Length::Flex(1)))
.child(
Text::new(format!(
" {} / {} lines ",
state.displayed_log_count(),
state.log_entries.len()
))
.overflow(Overflow::Clip)
.style(dim_style),
);
let log_view = LogView::new()
.entries_arc(state.log_entries())
.filter(state.log_filter.text())
.filter_mode(LogFilterMode::Fuzzy)
.case_sensitive(false)
.show_level(true)
.trace_style(dim_style)
.debug_style(dim_style)
.info_style(secondary_style)
.warn_style(Style::default().fg(theme.status.warning).bold())
.error_style(Style::default().fg(theme.status.error).bold())
.auto_follow(state.log_auto_follow)
.paused(state.log_paused)
.selected(state.log_selected)
.scrollbar(true)
.scrollbar_config(ScrollbarConfig::new())
.empty_text("No logs")
.width(Length::Flex(1))
.height(Length::Flex(1))
.activate_on_click(false)
.on_select(ctx.link().callback(DevToolsMsg::LogSelected))
.on_activate(ctx.link().callback(|event: LogViewEvent| {
DevToolsMsg::CopyEntry(event.entry.message.to_string())
}));
VStack::new()
.height(Length::Flex(1))
.gap(1)
.child(
VStack::new()
.height(Length::Auto)
.child(filter_input)
.child(controls),
)
.child(log_view)
.into()
}
fn app_body(ctx: &Context<DevToolsPanel>, state: &DevToolsState, label_width: u16) -> Element {
let theme = ctx.theme();
let label_style = fg_style(theme.primary.fg).bold();
let value_style = fg_style(theme.muted.fg.or(theme.primary.fg));
let dim_style = fg_style(
theme
.muted
.fg
.map(|paint| Paint::solid(paint.color().dim())),
);
let metrics = state.app_metrics.rows.borrow();
let mut rows = Vec::with_capacity(metrics.len().max(1));
if metrics.is_empty() {
rows.push(
HStack::new()
.height(Length::Auto)
.child(
Text::new("Metrics")
.overflow(Overflow::Ellipsis)
.width(Length::Px(label_width))
.style(label_style),
)
.child(
Text::new("none")
.overflow(Overflow::Ellipsis)
.width(Length::Flex(1))
.style(dim_style),
)
.into(),
);
} else {
rows.extend(metrics.iter().map(|metric| {
HStack::new()
.height(Length::Auto)
.child(
Text::new(Arc::clone(&metric.label))
.overflow(Overflow::Ellipsis)
.width(Length::Px(label_width))
.style(label_style),
)
.child(
Text::new(Arc::clone(&metric.value))
.overflow(Overflow::Ellipsis)
.width(Length::Flex(1))
.style(value_style),
)
.into()
}));
}
ScrollView::new()
.height(Length::Flex(1))
.scrollbar(true)
.scrollbar_config(ScrollbarConfig::new())
.focusable(true)
.scroll_keys(ScrollKeymap::DEFAULT)
.estimated_child_height(1)
.children(rows)
.key(DEVTOOLS_APP_METRICS_KEY)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{Color, Rect, Theme};
use crate::test_backend::TestBackend;
#[cfg(feature = "devtools")]
fn busy_state() -> DevToolsState {
use crate::app::interaction_state::DirtyLevel;
use crate::callback::ScopeId;
use crate::core::nested::MemoMissReason;
use crate::devtools::state::{
ComponentTiming, FrameMetrics, UpdateAttribution, UpdateSource,
};
use std::time::Duration;
use web_time::Instant;
let mut state = DevToolsState::default();
state.set_visible(true);
state.focus.policy = crate::app::FocusPolicy::Auto;
state.focus.tag = Some(crate::layout::tag::Tag::TextArea);
state.focus.key = Some("search-input".into());
state.focus.ring_len = 4;
state.focus.stack_depth = 1;
for i in 0..70u64 {
let spike = i == 30 || i == 52;
let full = i % 6 == 0;
let mut attributions = vec![UpdateAttribution {
source: UpdateSource::Input("input:scroll"),
level: DirtyLevel::LayoutOnly,
count: 2,
}];
let mut memo_miss_reasons = Vec::new();
let mut component_timings = Vec::new();
if full {
attributions.push(UpdateAttribution {
source: UpdateSource::Component {
scope: ScopeId(7),
name: "Sidebar".into(),
},
level: DirtyLevel::Full,
count: 1,
});
memo_miss_reasons.push((MemoMissReason::SelfDirty, 3));
memo_miss_reasons.push((
MemoMissReason::DependencyChanged(
crate::core::nested::MemoDependencyKind::Focus,
),
1,
));
component_timings.push(ComponentTiming {
name: "Sidebar".into(),
scope: ScopeId(7),
duration: Duration::from_micros(if spike { 14_200 } else { 1_180 }),
calls: 1,
});
component_timings.push(ComponentTiming {
name: "DiffTable".into(),
scope: ScopeId(9),
duration: Duration::from_micros(640),
calls: 1,
});
}
state.push_frame_metrics(FrameMetrics {
timestamp: Instant::now(),
dirty_level: if full { "full" } else { "layout" }.into(),
total_duration: Duration::from_micros(if spike {
21_400
} else {
380 + (i % 7) * 130
}),
reconcile_duration: Duration::from_micros(210),
draw_duration: Duration::from_micros(160),
node_count: 47,
overlay_count: 1,
memo_hits: 9,
memo_misses: if full { 4 } else { 0 },
memo_miss_reasons,
attributions,
component_timings,
input_sourced_full: spike,
});
}
state
}
#[test]
fn stats_panel_renders_stable_rows() {
let props = DevToolsProps {
state: Rc::new(RefCell::new(busy_state())),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 52,
h: 17,
});
backend.render();
let snapshot =
backend.capture_ui_snapshot_with_margin(4, 2, &crate::UiSnapshotOptions::default());
let markdown = snapshot.to_markdown();
if let Ok(dir) = std::env::var("DEVTOOLS_SNAPSHOT_DIR") {
let _ = std::fs::write(format!("{dir}/devtools-stats.md"), &markdown);
#[cfg(feature = "ui-snapshot-png")]
let _ = std::fs::write(
format!("{dir}/devtools-stats.png"),
snapshot.to_png_default().unwrap_or_default(),
);
}
for label in [
"FPS", "Frame", "Recon", "Chart", "Updates", "Why", "Memo", "Miss", "Slow", "Focus",
"Input",
] {
assert!(
markdown.contains(label),
"stats panel should always render the `{label}` row; got:\n{markdown}"
);
}
}
#[test]
fn logs_panel_renders_controls_and_entries() {
use crate::debug::LogSource;
use crate::devtools::state::DevLogEntry;
use std::time::SystemTime;
let mut state = DevToolsState::default();
state.set_visible(true);
state.set_active_tab(1);
for (i, message) in [
"Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.",
"error: failed to reach update server (retrying in 30s)",
"opencode server listening on http://127.0.0.1:40155",
"session restored: 3 tabs, 14 panes",
"watcher: 412 files under /src",
]
.iter()
.enumerate()
{
state.push_log_entry(DevLogEntry {
timestamp: SystemTime::now(),
message: (*message).to_string(),
source: if i == 3 {
LogSource::Framework
} else {
LogSource::App
},
});
}
state.set_log_auto_follow(false);
state.set_log_selected(1);
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 100,
h: 28,
});
backend.render();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
let _ = backend.pump();
backend.render();
if backend
.capture_ui_snapshot()
.to_markdown()
.contains("opencode server listening")
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let snapshot =
backend.capture_ui_snapshot_with_margin(4, 2, &crate::UiSnapshotOptions::default());
let markdown = snapshot.to_markdown();
if let Ok(dir) = std::env::var("DEVTOOLS_SNAPSHOT_DIR") {
let _ = std::fs::write(format!("{dir}/devtools-logs.md"), &markdown);
#[cfg(feature = "ui-snapshot-png")]
let _ = std::fs::write(
format!("{dir}/devtools-logs.png"),
snapshot.to_png_default().unwrap_or_default(),
);
}
for label in ["Follow", "Pause", "Framework", "Clear", "lines"] {
assert!(
markdown.contains(label),
"logs panel should render the `{label}` control; got:\n{markdown}"
);
}
assert!(markdown.contains("opencode server listening"));
}
#[test]
fn stats_panel_renders_same_rows_with_no_frames() {
let mut state = DevToolsState::default();
state.set_visible(true);
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 52,
h: 17,
});
backend.render();
let snapshot = backend.capture_ui_snapshot();
let markdown = snapshot.to_markdown();
#[cfg(feature = "ui-snapshot-png")]
if let Ok(dir) = std::env::var("DEVTOOLS_SNAPSHOT_DIR") {
let _ = std::fs::write(
format!("{dir}/devtools-stats-empty.png"),
snapshot.to_png_default().unwrap_or_default(),
);
}
for label in [
"FPS", "Frame", "Recon", "Chart", "Updates", "Why", "Memo", "Miss", "Slow", "Focus",
"Input",
] {
assert!(
markdown.contains(label),
"empty stats panel should still render the `{label}` row"
);
}
assert!(
markdown.contains("idle"),
"Why row should show idle placeholder"
);
assert!(
markdown.contains("none"),
"Miss/Slow rows should show none placeholder"
);
}
#[test]
fn app_panel_renders_metrics_in_host_order() {
let mut state = DevToolsState::default();
state.set_visible(true);
state.set_active_tab(DEVTOOLS_TAB_APP);
state.app_metrics.rows.borrow_mut().extend([
crate::DevToolsMetric::new("Panes", "4"),
crate::DevToolsMetric::new("Clients", "2"),
crate::DevToolsMetric::new("Queue", "idle"),
]);
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 52,
h: 12,
});
backend.render();
let text = backend.capture_frame().plain_text();
let panes = text.find("Panes").expect("Panes row");
let clients = text.find("Clients").expect("Clients row");
let queue = text.find("Queue").expect("Queue row");
assert!(panes < clients && clients < queue);
assert!(text.contains("4"));
assert!(text.contains("idle"));
}
#[test]
fn app_panel_uses_wide_viewport_to_render_full_values() {
let value = "x".repeat(60);
let mut state = DevToolsState::default();
state.set_visible(true);
state.set_active_tab(DEVTOOLS_TAB_APP);
state
.app_metrics
.rows
.borrow_mut()
.push(crate::DevToolsMetric::new("Heap", value.clone()));
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 100,
h: 8,
});
backend.render();
assert!(backend.capture_frame().plain_text().contains(&value));
let panel = backend
.core
.tree
.iter()
.find(|node| {
node.key
.as_ref()
.is_some_and(|key| key.as_ref() == DEVTOOLS_KEY)
})
.expect("devtools panel frame");
assert_eq!(panel.rect.w, 67);
}
#[test]
fn app_panel_caps_to_viewport_and_scrolls_overflow_rows() {
let mut state = DevToolsState::default();
state.set_visible(true);
state.set_active_tab(DEVTOOLS_TAB_APP);
state.app_metrics.rows.borrow_mut().extend((0..24).map(|i| {
crate::DevToolsMetric::new(
format!("Metric{i}"),
format!("value-{i}-{}", "x".repeat(40)),
)
}));
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 32,
h: 8,
});
backend.render();
let panel = backend
.core
.tree
.iter()
.find(|node| {
node.key
.as_ref()
.is_some_and(|key| key.as_ref() == DEVTOOLS_KEY)
})
.expect("devtools panel frame");
assert_eq!(panel.rect.w, 32);
let scroll = backend
.core
.tree
.iter()
.find_map(|node| match &node.kind {
crate::core::node::NodeKind::ScrollView(scroll)
if node
.key
.as_ref()
.is_some_and(|key| key.as_ref() == DEVTOOLS_APP_METRICS_KEY) =>
{
Some(scroll)
}
_ => None,
})
.expect("app metrics ScrollView");
assert!(scroll.scrollbar);
assert!(scroll.content_height > scroll.viewport_height);
assert!(scroll.max_offset > 0);
}
#[test]
fn panel_paints_an_opaque_menu_surface_background() {
let mut state = DevToolsState::default();
state.set_visible(true);
let props = DevToolsProps {
state: Rc::new(RefCell::new(state)),
};
let mut backend = TestBackend::new_with_props(DevToolsPanel, props);
backend.set_viewport(Rect {
x: 0,
y: 0,
w: 60,
h: 20,
});
backend.render();
let menu = Theme::default().surface.menu;
assert_ne!(menu, Color::Reset);
let frame = backend.capture_frame();
assert!(
frame.cells.iter().any(|cell| cell.bg == menu),
"devtools panel should paint the opaque menu surface background"
);
}
}