use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::interaction::{
HitRegion, InteractionLayer, SelectableSpan, SelectionGroup, TextRange, WidgetAction, WidgetId,
WidgetRole, WidgetState, WidgetValue,
};
use crate::sanitize;
use crate::theme::ThemeTokens;
use crate::widgets::Widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusDeckSectionKind {
Toggles,
Runtime,
Keys,
Validation,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationState {
Idle,
Running,
Passed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusDeckItemKind {
Toggle {
active: bool,
},
Runtime,
Key,
Validation {
state: ValidationState,
exit_code: Option<i32>,
},
Text,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusDeckItem {
pub id: String,
pub label: String,
pub value: Option<String>,
pub hint: Option<String>,
pub kind: StatusDeckItemKind,
}
impl StatusDeckItem {
pub fn toggle(id: &str, label: &str, active: bool) -> Self {
Self {
id: id.to_string(),
label: label.to_string(),
value: None,
hint: None,
kind: StatusDeckItemKind::Toggle { active },
}
}
pub fn runtime(id: &str, label: &str, value: &str) -> Self {
Self {
id: id.to_string(),
label: label.to_string(),
value: Some(value.to_string()),
hint: None,
kind: StatusDeckItemKind::Runtime,
}
}
pub fn key(id: &str, label: &str, value: &str) -> Self {
Self {
id: id.to_string(),
label: label.to_string(),
value: Some(value.to_string()),
hint: None,
kind: StatusDeckItemKind::Key,
}
}
pub fn validation(
id: &str,
command: &str,
state: ValidationState,
exit_code: Option<i32>,
hint: &str,
) -> Self {
Self {
id: id.to_string(),
label: command.to_string(),
value: Some(validation_label(state, exit_code)),
hint: if hint.is_empty() {
None
} else {
Some(hint.to_string())
},
kind: StatusDeckItemKind::Validation { state, exit_code },
}
}
pub fn text(id: &str, label: &str, value: Option<&str>) -> Self {
Self {
id: id.to_string(),
label: label.to_string(),
value: value.map(ToString::to_string),
hint: None,
kind: StatusDeckItemKind::Text,
}
}
pub fn with_hint(mut self, hint: &str) -> Self {
self.hint = Some(hint.to_string());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusDeckSection {
pub id: String,
pub title: String,
pub kind: StatusDeckSectionKind,
pub items: Vec<StatusDeckItem>,
}
impl StatusDeckSection {
pub fn new(id: &str, title: &str, kind: StatusDeckSectionKind) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
kind,
items: Vec::new(),
}
}
pub fn with_item(mut self, item: StatusDeckItem) -> Self {
self.items.push(item);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RightPaneVisibility {
pub effect: bool,
pub runtime: bool,
pub thinking: bool,
}
#[derive(Debug, Clone)]
pub struct StatusDeck {
pub sections: Vec<StatusDeckSection>,
pub tokens: ThemeTokens,
pub row_limit: Option<usize>,
pub visibility: RightPaneVisibility,
pub region_id: Option<WidgetId>,
}
impl StatusDeck {
pub fn new() -> Self {
Self {
sections: Vec::new(),
tokens: ThemeTokens::SCRIN,
row_limit: None,
visibility: RightPaneVisibility::default(),
region_id: None,
}
}
pub fn with_tokens(mut self, tokens: ThemeTokens) -> Self {
self.tokens = tokens;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
self
}
pub fn with_row_limit(mut self, limit: usize) -> Self {
self.row_limit = Some(limit);
self
}
pub fn with_visibility(mut self, visibility: RightPaneVisibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_section(mut self, section: StatusDeckSection) -> Self {
self.sections.push(section);
self
}
pub fn add_section(&mut self, section: StatusDeckSection) {
self.sections.push(section);
}
pub fn set_pane_visible(&mut self, pane: &str, visible: bool) {
match pane {
"effect" => self.visibility.effect = visible,
"runtime" => self.visibility.runtime = visible,
"thinking" | "activity" => self.visibility.thinking = visible,
_ => {}
}
}
pub fn pane_visible(&self, pane: &str) -> bool {
match pane {
"effect" => self.visibility.effect,
"runtime" => self.visibility.runtime,
"thinking" | "activity" => self.visibility.thinking,
_ => false,
}
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
self.render(buffer, area);
if area.is_empty() {
return;
}
let region_id = self
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("status-deck"));
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Panel)
.with_label("status deck"),
);
let group = SelectionGroup::new(format!("{}:rows", region_id.as_ref()));
let mut y = area.y;
let mut rendered = 0usize;
for section in &self.sections {
if y >= area.bottom() || limit_reached(self.row_limit, rendered) {
break;
}
y = y.saturating_add(1);
for item in §ion.items {
if y >= area.bottom() || limit_reached(self.row_limit, rendered) {
break;
}
let row_area = Rect::new(area.x, y, area.width, 1);
let row_id =
WidgetId::new(format!("{}:{}:{}", region_id.as_ref(), section.id, item.id));
let text = item_text(item);
let role = match item.kind {
StatusDeckItemKind::Toggle { .. } => WidgetRole::Toggle,
StatusDeckItemKind::Validation { .. } => WidgetRole::StatusIndicator,
_ => WidgetRole::StatusIndicator,
};
let action = match item.kind {
StatusDeckItemKind::Toggle { .. } => WidgetAction::Toggle,
_ => WidgetAction::Focus,
};
let active = matches!(item.kind, StatusDeckItemKind::Toggle { active: true });
layer.push_region(
HitRegion::new(row_id.clone(), row_area)
.with_role(role)
.with_label(text.clone())
.with_action(action)
.with_row(rendered)
.with_selection_group(group.clone())
.with_state(WidgetState::default().active(active).checked(active))
.with_value(WidgetValue::Status(section.title.clone()))
.with_z_index(1),
);
let display = sanitize::sanitize_str(&text, area.width as usize);
layer.push_selectable_span(
SelectableSpan::new(
format!("{}:span", row_id.as_ref()),
display.clone(),
0..display.len(),
Rect::new(area.x, y, sanitize::str_display_width(&display) as u16, 1),
)
.with_source_id(region_id.clone())
.with_group(group.clone())
.with_logical_range(TextRange::new(
rendered,
0,
sanitize::str_display_width(&display),
)),
);
y = y.saturating_add(1);
rendered += 1;
}
}
}
}
impl Default for StatusDeck {
fn default() -> Self {
Self::new()
}
}
impl Widget for StatusDeck {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if area.is_empty() {
return;
}
buffer.fill(area, ' ', self.tokens.text, Some(self.tokens.panel));
let mut y = area.y;
let mut rendered = 0usize;
for section in &self.sections {
if y >= area.bottom() || limit_reached(self.row_limit, rendered) {
break;
}
write_row(buffer, area, y, §ion.title, self.tokens.accent, true);
y = y.saturating_add(1);
for item in §ion.items {
if y >= area.bottom() || limit_reached(self.row_limit, rendered) {
break;
}
let color = item_color(item, self.tokens);
write_row(buffer, area, y, &item_text(item), color, false);
y = y.saturating_add(1);
rendered += 1;
}
}
}
}
fn item_color(item: &StatusDeckItem, tokens: ThemeTokens) -> Color {
match item.kind {
StatusDeckItemKind::Toggle { active: true } => tokens.success,
StatusDeckItemKind::Toggle { active: false } => tokens.dim,
StatusDeckItemKind::Validation {
state: ValidationState::Running,
..
} => tokens.warning,
StatusDeckItemKind::Validation {
state: ValidationState::Passed,
..
} => tokens.success,
StatusDeckItemKind::Validation {
state: ValidationState::Failed,
..
} => tokens.error,
StatusDeckItemKind::Validation { .. } => tokens.dim,
StatusDeckItemKind::Key => tokens.accent,
_ => tokens.text,
}
}
fn item_text(item: &StatusDeckItem) -> String {
let prefix = match item.kind {
StatusDeckItemKind::Toggle { active: true } => "[on] ",
StatusDeckItemKind::Toggle { active: false } => "[off] ",
StatusDeckItemKind::Validation { .. } => "[run] ",
StatusDeckItemKind::Key => "[key] ",
_ => "",
};
match (&item.value, &item.hint) {
(Some(value), Some(hint)) => format!("{}{}: {} - {}", prefix, item.label, value, hint),
(Some(value), None) => format!("{}{}: {}", prefix, item.label, value),
(None, Some(hint)) => format!("{}{} - {}", prefix, item.label, hint),
(None, None) => format!("{}{}", prefix, item.label),
}
}
fn validation_label(state: ValidationState, exit_code: Option<i32>) -> String {
match (state, exit_code) {
(ValidationState::Idle, _) => "idle".to_string(),
(ValidationState::Running, _) => "running".to_string(),
(ValidationState::Passed, Some(code)) => format!("passed ({code})"),
(ValidationState::Passed, None) => "passed".to_string(),
(ValidationState::Failed, Some(code)) => format!("failed ({code})"),
(ValidationState::Failed, None) => "failed".to_string(),
}
}
fn write_row(buffer: &mut Buffer, area: Rect, y: u16, text: &str, fg: Color, bold: bool) {
let text = sanitize::truncate_str(text, area.width as usize);
for (i, ch) in text.chars().enumerate() {
let x = area.x as usize + i;
if x >= area.right() as usize {
break;
}
buffer.set(x, y as usize, Cell::new(ch, fg, None).with_bold(bold));
}
}
fn limit_reached(limit: Option<usize>, rendered: usize) -> bool {
limit.is_some_and(|limit| rendered >= limit)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_deck_renders_and_registers_toggle_rows() {
let deck = StatusDeck::new().with_region_id("deck").with_section(
StatusDeckSection::new("toggles", "toggles", StatusDeckSectionKind::Toggles)
.with_item(StatusDeckItem::toggle("runtime", "Runtime", true)),
);
let mut buffer = Buffer::new(32, 3);
let mut layer = InteractionLayer::new();
deck.render_with_interaction(&mut buffer, Rect::new(0, 0, 32, 3), &mut layer);
let hit = layer.hit_test(1, 1).unwrap();
assert_eq!(hit.id.as_ref(), "deck:toggles:runtime");
assert_eq!(hit.role, WidgetRole::Toggle);
assert!(hit.state.checked);
}
}