use crate::state::MachineState;
use crate::ui::gui::theme::{colors, rounding, spacing};
use crate::ui::gui::typography::{self, FontSize, FontWeight};
use crate::ui::shared::format_state_label;
pub use crate::ui::shared::{
format_duration, format_duration_secs, format_relative_time, format_relative_time_secs,
format_run_duration, RunProgress, Status,
};
use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Vec2};
pub const STATUS_DOT_RADIUS: f32 = 4.0;
#[derive(Debug, Clone, Copy)]
pub struct StatusDot {
color: Color32,
radius: f32,
}
impl StatusDot {
pub fn from_status(status: Status) -> Self {
Self {
color: status.color(),
radius: STATUS_DOT_RADIUS,
}
}
pub fn from_machine_state(state: MachineState) -> Self {
Self::from_status(Status::from_machine_state(state))
}
pub fn with_color(color: Color32) -> Self {
Self {
color,
radius: STATUS_DOT_RADIUS,
}
}
pub fn with_radius(mut self, radius: f32) -> Self {
self.radius = radius;
self
}
pub fn radius(&self) -> f32 {
self.radius
}
pub fn color(&self) -> Color32 {
self.color
}
pub fn paint(&self, painter: &egui::Painter, center: Pos2) {
painter.circle_filled(center, self.radius, self.color);
}
pub fn paint_with_border(&self, painter: &egui::Painter, center: Pos2, border_color: Color32) {
painter.circle_filled(center, self.radius, self.color);
painter.circle_stroke(center, self.radius, egui::Stroke::new(1.0, border_color));
}
}
impl Default for StatusDot {
fn default() -> Self {
Self {
color: colors::STATUS_IDLE,
radius: STATUS_DOT_RADIUS,
}
}
}
pub trait StatusColors {
fn color(self) -> Color32;
fn background_color(self) -> Color32;
}
impl StatusColors for Status {
fn color(self) -> Color32 {
match self {
Status::Setup => colors::STATUS_IDLE,
Status::Running => colors::STATUS_RUNNING,
Status::Reviewing => colors::STATUS_WARNING,
Status::Correcting => colors::STATUS_CORRECTING,
Status::Success => colors::STATUS_SUCCESS,
Status::Warning => colors::STATUS_WARNING,
Status::Error => colors::STATUS_ERROR,
Status::Idle => colors::STATUS_IDLE,
}
}
fn background_color(self) -> Color32 {
match self {
Status::Setup => colors::STATUS_IDLE_BG,
Status::Running => colors::STATUS_RUNNING_BG,
Status::Reviewing => colors::STATUS_WARNING_BG,
Status::Correcting => colors::STATUS_CORRECTING_BG,
Status::Success => colors::STATUS_SUCCESS_BG,
Status::Warning => colors::STATUS_WARNING_BG,
Status::Error => colors::STATUS_ERROR_BG,
Status::Idle => colors::STATUS_IDLE_BG,
}
}
}
pub fn state_to_color(state: MachineState) -> Color32 {
Status::from_machine_state(state).color()
}
pub fn state_to_background_color(state: MachineState) -> Color32 {
Status::from_machine_state(state).background_color()
}
pub fn badge_background_color(status_color: Color32) -> Color32 {
let bg = colors::BACKGROUND;
let alpha = 0.15;
let r = (status_color.r() as f32 * alpha + bg.r() as f32 * (1.0 - alpha)) as u8;
let g = (status_color.g() as f32 * alpha + bg.g() as f32 * (1.0 - alpha)) as u8;
let b = (status_color.b() as f32 * alpha + bg.b() as f32 * (1.0 - alpha)) as u8;
Color32::from_rgb(r, g, b)
}
pub fn is_terminal_state(state: MachineState) -> bool {
matches!(
state,
MachineState::Completed | MachineState::Failed | MachineState::Idle
)
}
#[derive(Debug, Clone)]
pub struct ProgressBar {
progress: f32,
height: f32,
background_color: Color32,
fill_color: Color32,
rounding: f32,
}
impl ProgressBar {
pub fn new(progress: f32) -> Self {
Self {
progress: progress.clamp(0.0, 1.0),
height: 6.0,
background_color: colors::SURFACE_HOVER,
fill_color: colors::ACCENT,
rounding: 3.0,
}
}
pub fn from_progress(progress: &RunProgress) -> Self {
Self::new(progress.fraction())
}
pub fn with_height(mut self, height: f32) -> Self {
self.height = height;
self
}
pub fn with_status_color(mut self, status: Status) -> Self {
self.fill_color = status.color();
self
}
pub fn with_colors(mut self, background: Color32, fill: Color32) -> Self {
self.background_color = background;
self.fill_color = fill;
self
}
pub fn with_rounding(mut self, rounding: f32) -> Self {
self.rounding = rounding;
self
}
pub fn progress(&self) -> f32 {
self.progress
}
pub fn paint(&self, painter: &egui::Painter, rect: Rect) {
painter.rect_filled(rect, Rounding::same(self.rounding), self.background_color);
if self.progress > 0.0 {
let fill_width = rect.width() * self.progress;
let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height()));
painter.rect_filled(fill_rect, Rounding::same(self.rounding), self.fill_color);
}
}
pub fn show(&self, ui: &mut egui::Ui, width: f32) -> egui::Response {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, self.height), egui::Sense::hover());
self.paint(ui.painter(), rect);
response
}
}
impl Default for ProgressBar {
fn default() -> Self {
Self::new(0.0)
}
}
pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
s.to_string()
} else if max_len <= 3 {
s.chars().take(max_len).collect()
} else {
let target_len = max_len - 3; let truncated: String = s.chars().take(target_len).collect();
let truncate_at = truncated.rfind(' ').unwrap_or(target_len);
if truncate_at == 0 {
format!("{}...", truncated.trim_end())
} else {
format!("{}...", truncated[..truncate_at].trim_end())
}
}
}
pub fn strip_worktree_prefix(branch_name: &str, project_name: &str) -> String {
let wt_prefix = format!("{}-wt-", project_name);
if let Some(stripped) = branch_name.strip_prefix(&wt_prefix) {
return stripped.to_string();
}
let wt_prefix_lower = format!("{}-wt-", project_name.to_lowercase());
if branch_name.to_lowercase().starts_with(&wt_prefix_lower) {
return branch_name[wt_prefix_lower.len()..].to_string();
}
branch_name.to_string()
}
pub const MAX_TEXT_LENGTH: usize = 40;
pub const MAX_BRANCH_LENGTH: usize = 25;
pub fn format_state(state: MachineState) -> &'static str {
format_state_label(state)
}
#[derive(Debug, Clone)]
pub struct StatusLabel {
status: Status,
label: String,
dot_radius: f32,
spacing: f32,
}
impl StatusLabel {
pub fn new(status: Status, label: impl Into<String>) -> Self {
Self {
status,
label: label.into(),
dot_radius: STATUS_DOT_RADIUS,
spacing: 8.0,
}
}
pub fn from_machine_state(state: MachineState) -> Self {
Self::new(Status::from_machine_state(state), format_state(state))
}
pub fn with_dot_radius(mut self, radius: f32) -> Self {
self.dot_radius = radius;
self
}
pub fn with_spacing(mut self, spacing: f32) -> Self {
self.spacing = spacing;
self
}
pub fn status(&self) -> Status {
self.status
}
pub fn label(&self) -> &str {
&self.label
}
pub fn paint(
&self,
_ui: &egui::Ui,
painter: &egui::Painter,
pos: Pos2,
font: egui::FontId,
text_color: Color32,
) -> f32 {
let dot = StatusDot::from_status(self.status).with_radius(self.dot_radius);
let dot_center = Pos2::new(pos.x + self.dot_radius, pos.y + self.dot_radius);
dot.paint(painter, dot_center);
let label_x = pos.x + self.dot_radius * 2.0 + self.spacing;
let galley = painter.layout_no_wrap(self.label.clone(), font, text_color);
painter.galley(
Pos2::new(label_x, pos.y),
galley.clone(),
Color32::TRANSPARENT,
);
self.dot_radius * 2.0 + self.spacing + galley.rect.width()
}
pub fn show(&self, ui: &mut egui::Ui) -> egui::Response {
let font = typography::font(FontSize::Caption, FontWeight::Medium);
let text_color = colors::TEXT_PRIMARY;
let text_galley =
ui.fonts(|f| f.layout_no_wrap(self.label.clone(), font.clone(), text_color));
let width = self.dot_radius * 2.0 + self.spacing + text_galley.rect.width();
let height = text_galley.rect.height().max(self.dot_radius * 2.0);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover());
if ui.is_rect_visible(rect) {
self.paint(ui, ui.painter(), rect.min, font, text_color);
}
response
}
}
pub struct CollapsibleSection<'a> {
id: &'a str,
title: &'a str,
default_expanded: bool,
}
impl<'a> CollapsibleSection<'a> {
pub fn new(id: &'a str, title: &'a str) -> Self {
Self {
id,
title,
default_expanded: true,
}
}
pub fn default_expanded(mut self, expanded: bool) -> Self {
self.default_expanded = expanded;
self
}
pub fn show<R>(
self,
ui: &mut egui::Ui,
collapsed_state: &mut std::collections::HashMap<String, bool>,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::Response {
let is_collapsed = *collapsed_state
.entry(self.id.to_string())
.or_insert(!self.default_expanded);
let header_response = self.render_header(ui, is_collapsed);
if header_response.clicked() {
collapsed_state.insert(self.id.to_string(), !is_collapsed);
}
if !is_collapsed {
ui.add_space(spacing::SM);
add_contents(ui);
}
header_response
}
fn render_header(&self, ui: &mut egui::Ui, is_collapsed: bool) -> egui::Response {
let available_width = ui.available_width();
let header_height = typography::line_height(FontSize::Body) + spacing::XS * 2.0;
let (rect, response) = ui.allocate_exact_size(
Vec2::new(available_width, header_height),
egui::Sense::click(),
);
if ui.is_rect_visible(rect) {
let painter = ui.painter();
if response.hovered() {
painter.rect_filled(rect, Rounding::same(rounding::SMALL), colors::SURFACE_HOVER);
}
let chevron_size = 8.0;
let chevron_x = rect.min.x + spacing::XS;
let chevron_y = rect.center().y;
let chevron_color = if response.hovered() {
colors::TEXT_PRIMARY
} else {
colors::TEXT_SECONDARY
};
if is_collapsed {
let points = [
Pos2::new(chevron_x, chevron_y - chevron_size / 2.0),
Pos2::new(chevron_x + chevron_size / 2.0, chevron_y),
Pos2::new(chevron_x, chevron_y + chevron_size / 2.0),
];
painter.line_segment(
[points[0], points[1]],
egui::Stroke::new(1.5, chevron_color),
);
painter.line_segment(
[points[1], points[2]],
egui::Stroke::new(1.5, chevron_color),
);
} else {
let points = [
Pos2::new(chevron_x, chevron_y - chevron_size / 4.0),
Pos2::new(
chevron_x + chevron_size / 2.0,
chevron_y + chevron_size / 4.0,
),
Pos2::new(chevron_x + chevron_size, chevron_y - chevron_size / 4.0),
];
painter.line_segment(
[points[0], points[1]],
egui::Stroke::new(1.5, chevron_color),
);
painter.line_segment(
[points[1], points[2]],
egui::Stroke::new(1.5, chevron_color),
);
}
let title_x = chevron_x + chevron_size + spacing::SM;
let title_y = rect.center().y - typography::line_height(FontSize::Body) / 2.0;
let title_color = if response.hovered() {
colors::TEXT_PRIMARY
} else {
colors::TEXT_SECONDARY
};
let galley = painter.layout_no_wrap(
self.title.to_string(),
typography::font(FontSize::Body, FontWeight::Medium),
title_color,
);
painter.galley(Pos2::new(title_x, title_y), galley, Color32::TRANSPARENT);
}
if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_colors() {
assert_eq!(Status::Setup.color(), colors::STATUS_IDLE);
assert_eq!(Status::Running.color(), colors::STATUS_RUNNING);
assert_eq!(Status::Reviewing.color(), colors::STATUS_WARNING);
assert_eq!(Status::Correcting.color(), colors::STATUS_CORRECTING);
assert_eq!(Status::Success.color(), colors::STATUS_SUCCESS);
assert_eq!(Status::Warning.color(), colors::STATUS_WARNING);
assert_eq!(Status::Error.color(), colors::STATUS_ERROR);
assert_eq!(Status::Idle.color(), colors::STATUS_IDLE);
}
#[test]
fn test_status_background_colors() {
assert_eq!(Status::Setup.background_color(), colors::STATUS_IDLE_BG);
assert_eq!(
Status::Running.background_color(),
colors::STATUS_RUNNING_BG
);
assert_eq!(
Status::Reviewing.background_color(),
colors::STATUS_WARNING_BG
);
assert_eq!(
Status::Correcting.background_color(),
colors::STATUS_CORRECTING_BG
);
assert_eq!(
Status::Success.background_color(),
colors::STATUS_SUCCESS_BG
);
assert_eq!(
Status::Warning.background_color(),
colors::STATUS_WARNING_BG
);
assert_eq!(Status::Error.background_color(), colors::STATUS_ERROR_BG);
assert_eq!(Status::Idle.background_color(), colors::STATUS_IDLE_BG);
}
#[test]
fn test_status_from_machine_state_setup_phase() {
assert_eq!(
Status::from_machine_state(MachineState::Initializing),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::PickingStory),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::LoadingSpec),
Status::Setup
);
assert_eq!(
Status::from_machine_state(MachineState::GeneratingSpec),
Status::Setup
);
}
#[test]
fn test_status_from_machine_state_running() {
assert_eq!(
Status::from_machine_state(MachineState::RunningClaude),
Status::Running
);
}
#[test]
fn test_status_from_machine_state_reviewing() {
assert_eq!(
Status::from_machine_state(MachineState::Reviewing),
Status::Reviewing
);
}
#[test]
fn test_status_from_machine_state_correcting() {
assert_eq!(
Status::from_machine_state(MachineState::Correcting),
Status::Correcting
);
}
#[test]
fn test_status_from_machine_state_success_path() {
assert_eq!(
Status::from_machine_state(MachineState::Committing),
Status::Success
);
assert_eq!(
Status::from_machine_state(MachineState::CreatingPR),
Status::Success
);
assert_eq!(
Status::from_machine_state(MachineState::Completed),
Status::Success
);
}
#[test]
fn test_status_from_machine_state_terminal() {
assert_eq!(
Status::from_machine_state(MachineState::Failed),
Status::Error
);
assert_eq!(Status::from_machine_state(MachineState::Idle), Status::Idle);
}
#[test]
fn test_state_to_color_semantic_mapping() {
assert_eq!(
state_to_color(MachineState::Initializing),
colors::STATUS_IDLE
);
assert_eq!(
state_to_color(MachineState::PickingStory),
colors::STATUS_IDLE
);
assert_eq!(
state_to_color(MachineState::RunningClaude),
colors::STATUS_RUNNING
);
assert_eq!(
state_to_color(MachineState::Reviewing),
colors::STATUS_WARNING
);
assert_eq!(
state_to_color(MachineState::Correcting),
colors::STATUS_CORRECTING
);
assert_eq!(
state_to_color(MachineState::Committing),
colors::STATUS_SUCCESS
);
assert_eq!(
state_to_color(MachineState::CreatingPR),
colors::STATUS_SUCCESS
);
assert_eq!(
state_to_color(MachineState::Completed),
colors::STATUS_SUCCESS
);
assert_eq!(state_to_color(MachineState::Failed), colors::STATUS_ERROR);
assert_eq!(state_to_color(MachineState::Idle), colors::STATUS_IDLE);
}
#[test]
fn test_state_to_background_color() {
assert_eq!(
state_to_background_color(MachineState::RunningClaude),
colors::STATUS_RUNNING_BG
);
assert_eq!(
state_to_background_color(MachineState::Completed),
colors::STATUS_SUCCESS_BG
);
assert_eq!(
state_to_background_color(MachineState::Failed),
colors::STATUS_ERROR_BG
);
assert_eq!(
state_to_background_color(MachineState::Correcting),
colors::STATUS_CORRECTING_BG
);
}
#[test]
fn test_status_dot_default() {
let dot = StatusDot::default();
assert_eq!(dot.radius(), STATUS_DOT_RADIUS);
assert_eq!(dot.color(), colors::STATUS_IDLE);
}
#[test]
fn test_status_dot_from_status() {
let dot = StatusDot::from_status(Status::Running);
assert_eq!(dot.color(), colors::STATUS_RUNNING);
}
#[test]
fn test_status_dot_from_machine_state() {
let dot = StatusDot::from_machine_state(MachineState::Completed);
assert_eq!(dot.color(), colors::STATUS_SUCCESS);
}
#[test]
fn test_status_dot_with_radius() {
let dot = StatusDot::default().with_radius(8.0);
assert_eq!(dot.radius(), 8.0);
}
#[test]
fn test_status_dot_with_color() {
let custom_color = Color32::from_rgb(255, 0, 128);
let dot = StatusDot::with_color(custom_color);
assert_eq!(dot.color(), custom_color);
}
#[test]
fn test_progress_bar() {
assert!((ProgressBar::new(0.5).progress() - 0.5).abs() < 0.001);
assert_eq!(ProgressBar::new(-0.5).progress(), 0.0); assert_eq!(ProgressBar::new(1.5).progress(), 1.0); assert!(
(ProgressBar::from_progress(&RunProgress::new(3, 10)).progress() - 0.3).abs() < 0.001
);
}
#[test]
fn test_truncate_with_ellipsis_short_string() {
let result = truncate_with_ellipsis("short", 10);
assert_eq!(result, "short");
}
#[test]
fn test_truncate_with_ellipsis_exact_length() {
let result = truncate_with_ellipsis("exactly10!", 10);
assert_eq!(result, "exactly10!");
}
#[test]
fn test_truncate_with_ellipsis_long_string_word_boundary() {
let result = truncate_with_ellipsis("this is a very long string", 15);
assert_eq!(result, "this is a...");
assert!(result.len() <= 15);
}
#[test]
fn test_truncate_with_ellipsis_very_short_max() {
let result = truncate_with_ellipsis("hello", 3);
assert_eq!(result, "hel");
}
#[test]
fn test_truncate_with_ellipsis_empty_string() {
let result = truncate_with_ellipsis("", 10);
assert_eq!(result, "");
}
#[test]
fn test_truncate_with_ellipsis_no_space_fallback() {
let result = truncate_with_ellipsis("superlongword", 10);
assert_eq!(result, "superlo...");
assert_eq!(result.len(), 10);
}
#[test]
fn test_truncate_with_ellipsis_word_boundary_exact() {
let result = truncate_with_ellipsis("hello world", 11);
assert_eq!(result, "hello world");
}
#[test]
fn test_truncate_with_ellipsis_word_boundary_just_over() {
let result = truncate_with_ellipsis("hello world test", 14);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_with_ellipsis_single_word_too_long() {
let result = truncate_with_ellipsis("internationalization", 15);
assert_eq!(result, "internationa...");
assert!(result.len() <= 15);
}
#[test]
fn test_truncate_with_ellipsis_preserves_short_content() {
let result = truncate_with_ellipsis("ok", 10);
assert_eq!(result, "ok");
}
#[test]
fn test_truncate_with_ellipsis_multiple_spaces() {
let result = truncate_with_ellipsis("one two three four five", 16);
assert_eq!(result, "one two...");
}
#[test]
fn test_truncate_with_ellipsis_trailing_space_trimmed() {
let result = truncate_with_ellipsis("hello world", 10);
assert_eq!(result, "hello...");
}
#[test]
fn test_strip_worktree_prefix_no_prefix() {
assert_eq!(
strip_worktree_prefix("feature/login", "myproject"),
"feature/login"
);
assert_eq!(strip_worktree_prefix("main", "myproject"), "main");
assert_eq!(
strip_worktree_prefix("develop/new-feature", "myproject"),
"develop/new-feature"
);
}
#[test]
fn test_strip_worktree_prefix_standard_wt_prefix() {
assert_eq!(
strip_worktree_prefix("myproject-wt-feature/login", "myproject"),
"feature/login"
);
assert_eq!(
strip_worktree_prefix("autom8-wt-feature/gui-tabs", "autom8"),
"feature/gui-tabs"
);
assert_eq!(
strip_worktree_prefix("MyProject-wt-feature/test", "myproject"),
"feature/test"
);
}
#[test]
fn test_format_state_all_states() {
assert_eq!(format_state(MachineState::Idle), "Idle");
assert_eq!(format_state(MachineState::LoadingSpec), "Loading Spec");
assert_eq!(
format_state(MachineState::GeneratingSpec),
"Generating Spec"
);
assert_eq!(format_state(MachineState::Initializing), "Initializing");
assert_eq!(format_state(MachineState::PickingStory), "Picking Story");
assert_eq!(format_state(MachineState::RunningClaude), "Running Claude");
assert_eq!(format_state(MachineState::Reviewing), "Reviewing");
assert_eq!(format_state(MachineState::Correcting), "Correcting");
assert_eq!(format_state(MachineState::Committing), "Committing");
assert_eq!(format_state(MachineState::CreatingPR), "Creating PR");
assert_eq!(format_state(MachineState::Completed), "Completed");
assert_eq!(format_state(MachineState::Failed), "Failed");
}
#[test]
fn test_status_label_new() {
let label = StatusLabel::new(Status::Running, "Test Label");
assert_eq!(label.status(), Status::Running);
assert_eq!(label.label(), "Test Label");
}
#[test]
fn test_status_label_from_machine_state() {
let label = StatusLabel::from_machine_state(MachineState::RunningClaude);
assert_eq!(label.status(), Status::Running);
assert_eq!(label.label(), "Running Claude");
}
#[test]
fn test_status_label_with_dot_radius() {
let label = StatusLabel::new(Status::Success, "Done").with_dot_radius(8.0);
assert_eq!(label.status(), Status::Success);
}
#[test]
fn test_status_label_with_spacing() {
let label = StatusLabel::new(Status::Error, "Failed").with_spacing(12.0);
assert_eq!(label.status(), Status::Error);
}
#[test]
fn test_badge_background_color() {
let running_bg = badge_background_color(colors::STATUS_RUNNING);
let success_bg = badge_background_color(colors::STATUS_SUCCESS);
let error_bg = badge_background_color(colors::STATUS_ERROR);
for (name, bg) in [
("running", running_bg),
("success", success_bg),
("error", error_bg),
] {
let lum = bg.r() as u32 + bg.g() as u32 + bg.b() as u32;
assert!(
lum > 600,
"{} badge bg should be light, got luminance {}",
name,
lum
);
}
assert_ne!(running_bg, success_bg);
assert_ne!(success_bg, error_bg);
assert_ne!(running_bg, error_bg);
}
}