use ratatui::layout::Rect;
use super::action_logger::ActionLog;
use super::cell::CellPreview;
#[derive(Debug, Clone)]
pub enum DebugTableRow {
Section(String),
Entry { key: String, value: String },
}
#[derive(Debug, Clone)]
pub struct DebugTableOverlay {
pub title: String,
pub rows: Vec<DebugTableRow>,
pub cell_preview: Option<CellPreview>,
}
impl DebugTableOverlay {
pub fn new(title: impl Into<String>, rows: Vec<DebugTableRow>) -> Self {
Self {
title: title.into(),
rows,
cell_preview: None,
}
}
pub fn with_cell_preview(
title: impl Into<String>,
rows: Vec<DebugTableRow>,
preview: CellPreview,
) -> Self {
Self {
title: title.into(),
rows,
cell_preview: Some(preview),
}
}
}
#[derive(Debug, Clone)]
pub enum DebugOverlay {
Inspect(DebugTableOverlay),
State(DebugTableOverlay),
ActionLog(ActionLogOverlay),
ActionDetail(ActionDetailOverlay),
Components(ComponentsOverlay),
StateDetail(StateEntryDetail),
ComponentDetail(ComponentDetailOverlay),
}
#[derive(Debug, Clone)]
pub struct ActionDetailOverlay {
pub sequence: u64,
pub name: String,
pub params: String,
pub elapsed: String,
}
#[derive(Debug, Clone)]
pub struct StateEntryDetail {
pub section: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct ComponentDetailOverlay {
pub index: usize,
pub type_name: String,
pub type_name_full: String,
pub bound_id: Option<String>,
pub last_area: Option<Rect>,
pub debug_entries: Vec<(String, String)>,
}
impl ComponentDetailOverlay {
pub fn from_snapshot(snap: &ComponentSnapshot, index: usize) -> Self {
Self {
index,
type_name: snap.type_name.clone(),
type_name_full: snap.type_name_full.clone(),
bound_id: snap.bound_id.clone(),
last_area: snap.last_area,
debug_entries: snap.debug_entries.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct ComponentSnapshot {
pub raw_id: u32,
pub type_name: String,
pub type_name_full: String,
pub bound_id: Option<String>,
pub last_area: Option<Rect>,
pub debug_entries: Vec<(String, String)>,
}
impl ComponentSnapshot {
pub fn from_mounted_info<Id: tui_dispatch_core::ComponentId>(
info: &tui_dispatch_components::MountedComponentInfo<Id>,
) -> Self {
let type_name_full = info.type_name.to_string();
let type_name = type_name_full
.rsplit("::")
.next()
.unwrap_or(&type_name_full)
.to_string();
Self {
raw_id: info.raw,
type_name,
type_name_full,
bound_id: info.bound_id.map(|id| id.name().to_string()),
last_area: info.last_area,
debug_entries: info
.debug_state
.iter()
.map(|e| (e.key.clone(), e.value.clone()))
.collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct ComponentsOverlay {
pub title: String,
pub components: Vec<ComponentSnapshot>,
pub selected: usize,
pub expanded: std::collections::HashSet<usize>,
}
impl ComponentsOverlay {
pub fn new(title: impl Into<String>, components: Vec<ComponentSnapshot>) -> Self {
Self {
title: title.into(),
components,
selected: 0,
expanded: std::collections::HashSet::new(),
}
}
pub fn scroll_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
if !self.components.is_empty() {
self.selected = (self.selected + 1).min(self.components.len() - 1);
}
}
pub fn scroll_to_top(&mut self) {
self.selected = 0;
}
pub fn scroll_to_bottom(&mut self) {
if !self.components.is_empty() {
self.selected = self.components.len() - 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.selected = self.selected.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
if !self.components.is_empty() {
self.selected = (self.selected + page_size).min(self.components.len() - 1);
}
}
pub fn toggle_expanded(&mut self) {
if !self.expanded.remove(&self.selected) {
self.expanded.insert(self.selected);
}
}
pub fn is_expanded(&self, index: usize) -> bool {
self.expanded.contains(&index)
}
}
impl DebugOverlay {
pub fn table(&self) -> Option<&DebugTableOverlay> {
match self {
DebugOverlay::Inspect(table) | DebugOverlay::State(table) => Some(table),
DebugOverlay::ActionLog(_)
| DebugOverlay::ActionDetail(_)
| DebugOverlay::Components(_)
| DebugOverlay::StateDetail(_)
| DebugOverlay::ComponentDetail(_) => None,
}
}
pub fn action_log(&self) -> Option<&ActionLogOverlay> {
match self {
DebugOverlay::ActionLog(log) => Some(log),
_ => None,
}
}
pub fn action_log_mut(&mut self) -> Option<&mut ActionLogOverlay> {
match self {
DebugOverlay::ActionLog(log) => Some(log),
_ => None,
}
}
pub fn components(&self) -> Option<&ComponentsOverlay> {
match self {
DebugOverlay::Components(c) => Some(c),
_ => None,
}
}
pub fn components_mut(&mut self) -> Option<&mut ComponentsOverlay> {
match self {
DebugOverlay::Components(c) => Some(c),
_ => None,
}
}
pub fn kind(&self) -> &'static str {
match self {
DebugOverlay::Inspect(_) => "inspect",
DebugOverlay::State(_) => "state",
DebugOverlay::ActionLog(_) => "action_log",
DebugOverlay::ActionDetail(_) => "action_detail",
DebugOverlay::Components(_) => "components",
DebugOverlay::StateDetail(_) => "state_detail",
DebugOverlay::ComponentDetail(_) => "component_detail",
}
}
}
#[derive(Debug, Clone)]
pub struct ActionLogDisplayEntry {
pub sequence: u64,
pub name: String,
pub params: String,
pub params_detail: String,
pub elapsed: String,
}
#[derive(Debug, Clone)]
pub struct ActionLogOverlay {
pub title: String,
pub entries: Vec<ActionLogDisplayEntry>,
pub selected: usize,
pub scroll_offset: usize,
pub search_query: String,
pub search_matches: Vec<usize>,
pub search_match_index: usize,
pub search_input_active: bool,
}
impl ActionLogOverlay {
pub fn from_log(log: &ActionLog, title: impl Into<String>) -> Self {
let entries: Vec<_> = log
.entries_rev()
.map(|e| ActionLogDisplayEntry {
sequence: e.sequence,
name: e.name.to_string(),
params: e.params.clone(),
params_detail: e.params_pretty.clone(),
elapsed: e.elapsed.clone(),
})
.collect();
Self {
title: title.into(),
entries,
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: Vec::new(),
search_match_index: 0,
search_input_active: false,
}
}
pub fn scroll_up(&mut self) {
if self.navigate_filtered(|current, _| current.saturating_sub(1)) {
return;
}
if self.selected > 0 {
self.selected -= 1;
self.sync_search_index_from_selection();
}
}
pub fn scroll_down(&mut self) {
if self.navigate_filtered(|current, max| current.saturating_add(1).min(max)) {
return;
}
if self.selected + 1 < self.entries.len() {
self.selected += 1;
self.sync_search_index_from_selection();
}
}
pub fn scroll_to_top(&mut self) {
if self.navigate_filtered(|_, _| 0) {
return;
}
self.selected = 0;
self.sync_search_index_from_selection();
}
pub fn scroll_to_bottom(&mut self) {
if self.navigate_filtered(|_, max| max) {
return;
}
if !self.entries.is_empty() {
self.selected = self.entries.len() - 1;
self.sync_search_index_from_selection();
}
}
pub fn page_up(&mut self, page_size: usize) {
if self.navigate_filtered(|current, _| current.saturating_sub(page_size)) {
return;
}
self.selected = self.selected.saturating_sub(page_size);
self.sync_search_index_from_selection();
}
pub fn page_down(&mut self, page_size: usize) {
if self.navigate_filtered(|current, max| current.saturating_add(page_size).min(max)) {
return;
}
self.selected = (self.selected + page_size).min(self.entries.len().saturating_sub(1));
self.sync_search_index_from_selection();
}
pub fn scroll_offset_for(&self, visible_rows: usize) -> usize {
if visible_rows == 0 {
return 0;
}
if self.selected >= visible_rows {
self.selected - visible_rows + 1
} else {
0
}
}
pub fn get_selected(&self) -> Option<&ActionLogDisplayEntry> {
self.entries.get(self.selected)
}
pub fn selected_detail(&self) -> Option<ActionDetailOverlay> {
self.get_selected().map(|entry| ActionDetailOverlay {
sequence: entry.sequence,
name: entry.name.clone(),
params: entry.params_detail.clone(),
elapsed: entry.elapsed.clone(),
})
}
pub fn set_search_query(&mut self, query: impl Into<String>) {
self.search_query = query.into();
self.rebuild_search_matches();
}
pub fn push_search_char(&mut self, ch: char) {
self.search_query.push(ch);
self.rebuild_search_matches();
}
pub fn pop_search_char(&mut self) -> bool {
let popped = self.search_query.pop().is_some();
if popped {
self.rebuild_search_matches();
}
popped
}
pub fn clear_search_query(&mut self) {
self.search_query.clear();
self.search_matches.clear();
self.search_match_index = 0;
}
pub fn search_next(&mut self) -> bool {
if self.search_matches.is_empty() {
return false;
}
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
self.selected = self.search_matches[self.search_match_index];
true
}
pub fn search_prev(&mut self) -> bool {
if self.search_matches.is_empty() {
return false;
}
self.search_match_index = if self.search_match_index == 0 {
self.search_matches.len() - 1
} else {
self.search_match_index - 1
};
self.selected = self.search_matches[self.search_match_index];
true
}
pub fn has_search_query(&self) -> bool {
!self.search_query.is_empty()
}
fn navigate_filtered<F>(&mut self, advance: F) -> bool
where
F: FnOnce(usize, usize) -> usize,
{
if !self.has_search_query() {
return false;
}
if self.search_matches.is_empty() {
return true;
}
let max_match_index = self.search_matches.len() - 1;
self.search_match_index =
advance(self.search_match_index, max_match_index).min(max_match_index);
self.selected = self.search_matches[self.search_match_index];
true
}
pub fn search_match_count(&self) -> usize {
self.search_matches.len()
}
pub fn search_match_position(&self) -> Option<(usize, usize)> {
if self.search_matches.is_empty() {
None
} else {
Some((self.search_match_index + 1, self.search_matches.len()))
}
}
pub fn is_search_match(&self, row_index: usize) -> bool {
self.search_matches.binary_search(&row_index).is_ok()
}
fn rebuild_search_matches(&mut self) {
self.search_matches.clear();
self.search_match_index = 0;
let query = self.search_query.trim().to_ascii_lowercase();
if query.is_empty() {
return;
}
for (idx, entry) in self.entries.iter().enumerate() {
let name = entry.name.to_ascii_lowercase();
let params = entry.params.to_ascii_lowercase();
let params_detail = entry.params_detail.to_ascii_lowercase();
if name.contains(&query) || params.contains(&query) || params_detail.contains(&query) {
self.search_matches.push(idx);
}
}
if self.search_matches.is_empty() {
return;
}
if let Some(position) = self
.search_matches
.iter()
.position(|&idx| idx == self.selected)
{
self.search_match_index = position;
} else {
self.search_match_index = 0;
self.selected = self.search_matches[0];
}
}
fn sync_search_index_from_selection(&mut self) {
if self.search_matches.is_empty() {
return;
}
if let Some(position) = self
.search_matches
.iter()
.position(|&idx| idx == self.selected)
{
self.search_match_index = position;
} else {
self.search_match_index = self.search_match_index.min(self.search_matches.len() - 1);
self.selected = self.search_matches[self.search_match_index];
}
}
}
#[derive(Debug, Default)]
pub struct DebugTableBuilder {
rows: Vec<DebugTableRow>,
cell_preview: Option<CellPreview>,
}
impl DebugTableBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn section(mut self, title: impl Into<String>) -> Self {
self.rows.push(DebugTableRow::Section(title.into()));
self
}
pub fn entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.rows.push(DebugTableRow::Entry {
key: key.into(),
value: value.into(),
});
self
}
pub fn push_section(&mut self, title: impl Into<String>) {
self.rows.push(DebugTableRow::Section(title.into()));
}
pub fn push_entry(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.rows.push(DebugTableRow::Entry {
key: key.into(),
value: value.into(),
});
}
pub fn cell_preview(mut self, preview: CellPreview) -> Self {
self.cell_preview = Some(preview);
self
}
pub fn set_cell_preview(&mut self, preview: CellPreview) {
self.cell_preview = Some(preview);
}
pub fn finish(self, title: impl Into<String>) -> DebugTableOverlay {
DebugTableOverlay {
title: title.into(),
rows: self.rows,
cell_preview: self.cell_preview,
}
}
pub fn finish_inspect(self, title: impl Into<String>) -> DebugOverlay {
DebugOverlay::Inspect(self.finish(title))
}
pub fn finish_state(self, title: impl Into<String>) -> DebugOverlay {
DebugOverlay::State(self.finish(title))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_basic() {
let table = DebugTableBuilder::new()
.section("Test")
.entry("key1", "value1")
.entry("key2", "value2")
.finish("Test Table");
assert_eq!(table.title, "Test Table");
assert_eq!(table.rows.len(), 3);
assert!(table.cell_preview.is_none());
}
#[test]
fn test_builder_multiple_sections() {
let table = DebugTableBuilder::new()
.section("Section 1")
.entry("a", "1")
.section("Section 2")
.entry("b", "2")
.finish("Multi-Section");
assert_eq!(table.rows.len(), 4);
match &table.rows[0] {
DebugTableRow::Section(s) => assert_eq!(s, "Section 1"),
_ => panic!("Expected section"),
}
match &table.rows[2] {
DebugTableRow::Section(s) => assert_eq!(s, "Section 2"),
_ => panic!("Expected section"),
}
}
#[test]
fn test_overlay_kinds() {
let table = DebugTableBuilder::new().finish("Test");
let inspect = DebugOverlay::Inspect(table.clone());
assert_eq!(inspect.kind(), "inspect");
assert!(inspect.table().is_some());
assert!(inspect.action_log().is_none());
let state = DebugOverlay::State(table);
assert_eq!(state.kind(), "state");
let action_log = ActionLogOverlay {
title: "Test".to_string(),
entries: vec![],
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: vec![],
search_match_index: 0,
search_input_active: false,
};
let log_overlay = DebugOverlay::ActionLog(action_log);
assert_eq!(log_overlay.kind(), "action_log");
assert!(log_overlay.table().is_none());
assert!(log_overlay.action_log().is_some());
}
#[test]
fn test_action_log_overlay_scrolling() {
let mut overlay = ActionLogOverlay {
title: "Test".to_string(),
entries: vec![
ActionLogDisplayEntry {
sequence: 0,
name: "A".to_string(),
params: "".to_string(),
params_detail: "".to_string(),
elapsed: "0ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 1,
name: "B".to_string(),
params: "x: 1".to_string(),
params_detail: "x: 1".to_string(),
elapsed: "1ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 2,
name: "C".to_string(),
params: "y: 2".to_string(),
params_detail: "y: 2".to_string(),
elapsed: "2ms".to_string(),
},
],
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: vec![],
search_match_index: 0,
search_input_active: false,
};
assert_eq!(overlay.selected, 0);
overlay.scroll_down();
assert_eq!(overlay.selected, 1);
overlay.scroll_down();
assert_eq!(overlay.selected, 2);
overlay.scroll_down(); assert_eq!(overlay.selected, 2);
overlay.scroll_up();
assert_eq!(overlay.selected, 1);
overlay.scroll_to_top();
assert_eq!(overlay.selected, 0);
overlay.scroll_to_bottom();
assert_eq!(overlay.selected, 2);
}
#[test]
fn test_action_log_overlay_search_query_and_navigation() {
let mut overlay = ActionLogOverlay {
title: "Test".to_string(),
entries: vec![
ActionLogDisplayEntry {
sequence: 10,
name: "SearchStart".to_string(),
params: "query: \"foo\"".to_string(),
params_detail: "query: \"foo\"".to_string(),
elapsed: "0ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 11,
name: "SearchSubmit".to_string(),
params: "query: \"foo\"".to_string(),
params_detail: "query: \"foo\"".to_string(),
elapsed: "1ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 12,
name: "Connect".to_string(),
params: "host: \"localhost\"".to_string(),
params_detail: "host: \"localhost\"".to_string(),
elapsed: "2ms".to_string(),
},
],
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: vec![],
search_match_index: 0,
search_input_active: false,
};
overlay.set_search_query("search");
assert!(overlay.has_search_query());
assert_eq!(overlay.search_match_count(), 2);
assert_eq!(overlay.selected, 0);
assert_eq!(overlay.search_match_position(), Some((1, 2)));
assert!(overlay.search_next());
assert_eq!(overlay.selected, 1);
assert_eq!(overlay.search_match_position(), Some((2, 2)));
assert!(overlay.search_next());
assert_eq!(overlay.selected, 0);
assert_eq!(overlay.search_match_position(), Some((1, 2)));
assert!(overlay.search_prev());
assert_eq!(overlay.selected, 1);
assert_eq!(overlay.search_match_position(), Some((2, 2)));
assert_eq!(overlay.search_matches, vec![0, 1]);
}
#[test]
fn test_action_log_overlay_search_edge_cases() {
let mut overlay = ActionLogOverlay {
title: "Test".to_string(),
entries: vec![ActionLogDisplayEntry {
sequence: 0,
name: "Connect".to_string(),
params: "host: \"example\"".to_string(),
params_detail: "host: \"example\"".to_string(),
elapsed: "0ms".to_string(),
}],
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: vec![],
search_match_index: 0,
search_input_active: false,
};
overlay.set_search_query("missing");
assert_eq!(overlay.search_match_count(), 0);
assert!(!overlay.search_next());
assert!(!overlay.search_prev());
assert_eq!(overlay.search_match_position(), None);
overlay.set_search_query("connect");
assert_eq!(overlay.search_match_count(), 1);
assert_eq!(overlay.search_match_position(), Some((1, 1)));
assert!(overlay.search_next());
assert_eq!(overlay.search_match_position(), Some((1, 1)));
assert!(overlay.pop_search_char());
assert!(overlay.has_search_query());
overlay.clear_search_query();
assert!(!overlay.has_search_query());
assert_eq!(overlay.search_match_count(), 0);
}
#[test]
fn test_action_log_overlay_scroll_respects_filter() {
let mut overlay = ActionLogOverlay {
title: "Test".to_string(),
entries: vec![
ActionLogDisplayEntry {
sequence: 0,
name: "SearchStart".to_string(),
params: "".to_string(),
params_detail: "".to_string(),
elapsed: "0ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 1,
name: "Connect".to_string(),
params: "".to_string(),
params_detail: "".to_string(),
elapsed: "1ms".to_string(),
},
ActionLogDisplayEntry {
sequence: 2,
name: "SearchSubmit".to_string(),
params: "".to_string(),
params_detail: "".to_string(),
elapsed: "2ms".to_string(),
},
],
selected: 0,
scroll_offset: 0,
search_query: String::new(),
search_matches: vec![],
search_match_index: 0,
search_input_active: false,
};
overlay.set_search_query("search");
assert_eq!(overlay.search_matches, vec![0, 2]);
assert_eq!(overlay.selected, 0);
overlay.scroll_down();
assert_eq!(overlay.selected, 2);
overlay.scroll_up();
assert_eq!(overlay.selected, 0);
}
}