Skip to main content

autom8/ui/gui/
components.rs

1//! Reusable UI components for the GUI.
2//!
3//! This module contains reusable widgets and helper functions for consistent
4//! status visualization across the application, including status dots, progress
5//! indicators, and time formatting utilities.
6
7use crate::state::MachineState;
8use crate::ui::gui::theme::{colors, rounding, spacing};
9use crate::ui::gui::typography::{self, FontSize, FontWeight};
10// Import and re-export shared types and functions for backward compatibility
11use crate::ui::shared::format_state_label;
12pub use crate::ui::shared::{
13    format_duration, format_duration_secs, format_relative_time, format_relative_time_secs,
14    format_run_duration, RunProgress, Status,
15};
16use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Vec2};
17
18// ============================================================================
19// Status Dot Component
20// ============================================================================
21
22/// Default radius for status indicator dots.
23pub const STATUS_DOT_RADIUS: f32 = 4.0;
24
25/// A reusable status dot component that renders a small filled circle
26/// with a color representing the current status.
27///
28/// # Example
29///
30/// ```ignore
31/// let dot = StatusDot::new(Status::Running);
32/// dot.paint(painter, egui::pos2(10.0, 10.0));
33/// ```
34#[derive(Debug, Clone, Copy)]
35pub struct StatusDot {
36    /// The color of the status dot.
37    color: Color32,
38    /// The radius of the dot.
39    radius: f32,
40}
41
42impl StatusDot {
43    /// Create a new status dot from a Status enum value.
44    pub fn from_status(status: Status) -> Self {
45        Self {
46            color: status.color(),
47            radius: STATUS_DOT_RADIUS,
48        }
49    }
50
51    /// Create a new status dot from a MachineState.
52    pub fn from_machine_state(state: MachineState) -> Self {
53        Self::from_status(Status::from_machine_state(state))
54    }
55
56    /// Create a new status dot with a custom color.
57    pub fn with_color(color: Color32) -> Self {
58        Self {
59            color,
60            radius: STATUS_DOT_RADIUS,
61        }
62    }
63
64    /// Set a custom radius for the dot.
65    pub fn with_radius(mut self, radius: f32) -> Self {
66        self.radius = radius;
67        self
68    }
69
70    /// Returns the radius of this status dot.
71    pub fn radius(&self) -> f32 {
72        self.radius
73    }
74
75    /// Returns the color of this status dot.
76    pub fn color(&self) -> Color32 {
77        self.color
78    }
79
80    /// Paint the status dot at the given center position.
81    pub fn paint(&self, painter: &egui::Painter, center: Pos2) {
82        painter.circle_filled(center, self.radius, self.color);
83    }
84
85    /// Paint the status dot with an optional border.
86    pub fn paint_with_border(&self, painter: &egui::Painter, center: Pos2, border_color: Color32) {
87        painter.circle_filled(center, self.radius, self.color);
88        painter.circle_stroke(center, self.radius, egui::Stroke::new(1.0, border_color));
89    }
90}
91
92impl Default for StatusDot {
93    fn default() -> Self {
94        Self {
95            color: colors::STATUS_IDLE,
96            radius: STATUS_DOT_RADIUS,
97        }
98    }
99}
100
101// ============================================================================
102// Status Color Mapping (GUI-specific extension)
103// ============================================================================
104
105// The Status enum is imported from crate::ui::shared and re-exported above.
106// This module provides GUI-specific color mapping via the StatusColors trait.
107
108/// GUI-specific color mapping for the shared Status enum.
109///
110/// This trait extends the shared Status enum with GUI-specific colors
111/// using the theme's color palette. The shared Status enum defines the
112/// semantic categorization (Setup, Running, etc.), and this trait provides
113/// the visual representation for the GUI.
114pub trait StatusColors {
115    /// Returns the primary color for this status.
116    fn color(self) -> Color32;
117
118    /// Returns the background color for this status (for badges/highlights).
119    fn background_color(self) -> Color32;
120}
121
122impl StatusColors for Status {
123    fn color(self) -> Color32 {
124        match self {
125            Status::Setup => colors::STATUS_IDLE,
126            Status::Running => colors::STATUS_RUNNING,
127            Status::Reviewing => colors::STATUS_WARNING,
128            Status::Correcting => colors::STATUS_CORRECTING,
129            Status::Success => colors::STATUS_SUCCESS,
130            Status::Warning => colors::STATUS_WARNING,
131            Status::Error => colors::STATUS_ERROR,
132            Status::Idle => colors::STATUS_IDLE,
133        }
134    }
135
136    fn background_color(self) -> Color32 {
137        match self {
138            Status::Setup => colors::STATUS_IDLE_BG,
139            Status::Running => colors::STATUS_RUNNING_BG,
140            Status::Reviewing => colors::STATUS_WARNING_BG,
141            Status::Correcting => colors::STATUS_CORRECTING_BG,
142            Status::Success => colors::STATUS_SUCCESS_BG,
143            Status::Warning => colors::STATUS_WARNING_BG,
144            Status::Error => colors::STATUS_ERROR_BG,
145            Status::Idle => colors::STATUS_IDLE_BG,
146        }
147    }
148}
149
150/// Map a MachineState directly to its display color.
151///
152/// This is a convenience function that wraps `Status::from_machine_state().color()`.
153pub fn state_to_color(state: MachineState) -> Color32 {
154    Status::from_machine_state(state).color()
155}
156
157/// Map a MachineState to its background color (for badges).
158pub fn state_to_background_color(state: MachineState) -> Color32 {
159    Status::from_machine_state(state).background_color()
160}
161
162/// Create a badge background color from any status color.
163///
164/// This blends the status color with the warm background color to create
165/// a soft, theme-consistent badge background. Use this instead of
166/// `color.gamma_multiply()` for status badges.
167pub fn badge_background_color(status_color: Color32) -> Color32 {
168    // Blend the status color with warm cream at ~15% opacity
169    // This creates a soft tinted background that complements the warm theme
170    let bg = colors::BACKGROUND;
171    let alpha = 0.15;
172
173    let r = (status_color.r() as f32 * alpha + bg.r() as f32 * (1.0 - alpha)) as u8;
174    let g = (status_color.g() as f32 * alpha + bg.g() as f32 * (1.0 - alpha)) as u8;
175    let b = (status_color.b() as f32 * alpha + bg.b() as f32 * (1.0 - alpha)) as u8;
176
177    Color32::from_rgb(r, g, b)
178}
179
180/// Check if a MachineState represents a finished/terminal state.
181///
182/// US-002: Used to determine when the close button should be visible on session tabs.
183/// Terminal states are those where the run has ended (success, failure, or idle):
184/// - `Completed`: Run finished successfully
185/// - `Failed`: Run ended with an error
186/// - `Idle`: No active run (session has never started or was interrupted)
187///
188/// All other states are considered "in progress" and the close button should be hidden.
189pub fn is_terminal_state(state: MachineState) -> bool {
190    matches!(
191        state,
192        MachineState::Completed | MachineState::Failed | MachineState::Idle
193    )
194}
195
196// ============================================================================
197// Progress Components
198// ============================================================================
199
200// NOTE: Progress information is now provided by the shared `RunProgress` struct
201// from `crate::ui::shared`. It is re-exported above for backward compatibility.
202// The `ProgressBar` component below uses `RunProgress` for its data source.
203
204/// A visual progress bar component.
205#[derive(Debug, Clone)]
206pub struct ProgressBar {
207    /// The progress value (0.0 to 1.0).
208    progress: f32,
209    /// The height of the progress bar.
210    height: f32,
211    /// The background color.
212    background_color: Color32,
213    /// The fill color.
214    fill_color: Color32,
215    /// Corner rounding for the bar.
216    rounding: f32,
217}
218
219impl ProgressBar {
220    /// Create a new progress bar with the given progress value.
221    pub fn new(progress: f32) -> Self {
222        Self {
223            progress: progress.clamp(0.0, 1.0),
224            height: 6.0,
225            background_color: colors::SURFACE_HOVER,
226            fill_color: colors::ACCENT,
227            rounding: 3.0,
228        }
229    }
230
231    /// Create a progress bar from a RunProgress struct.
232    pub fn from_progress(progress: &RunProgress) -> Self {
233        Self::new(progress.fraction())
234    }
235
236    /// Set the height of the progress bar.
237    pub fn with_height(mut self, height: f32) -> Self {
238        self.height = height;
239        self
240    }
241
242    /// Set the fill color based on a status.
243    pub fn with_status_color(mut self, status: Status) -> Self {
244        self.fill_color = status.color();
245        self
246    }
247
248    /// Set custom colors for the progress bar.
249    pub fn with_colors(mut self, background: Color32, fill: Color32) -> Self {
250        self.background_color = background;
251        self.fill_color = fill;
252        self
253    }
254
255    /// Set the corner rounding.
256    pub fn with_rounding(mut self, rounding: f32) -> Self {
257        self.rounding = rounding;
258        self
259    }
260
261    /// Returns the current progress value.
262    pub fn progress(&self) -> f32 {
263        self.progress
264    }
265
266    /// Paint the progress bar at the given rectangle.
267    pub fn paint(&self, painter: &egui::Painter, rect: Rect) {
268        // Draw background
269        painter.rect_filled(rect, Rounding::same(self.rounding), self.background_color);
270
271        // Draw fill based on progress
272        if self.progress > 0.0 {
273            let fill_width = rect.width() * self.progress;
274            let fill_rect = Rect::from_min_size(rect.min, Vec2::new(fill_width, rect.height()));
275            painter.rect_filled(fill_rect, Rounding::same(self.rounding), self.fill_color);
276        }
277    }
278
279    /// Allocate space and paint the progress bar in a UI.
280    pub fn show(&self, ui: &mut egui::Ui, width: f32) -> egui::Response {
281        let (rect, response) =
282            ui.allocate_exact_size(Vec2::new(width, self.height), egui::Sense::hover());
283        self.paint(ui.painter(), rect);
284        response
285    }
286}
287
288impl Default for ProgressBar {
289    fn default() -> Self {
290        Self::new(0.0)
291    }
292}
293
294// Time formatting utilities are re-exported from crate::ui::shared above.
295// See format_duration, format_duration_secs, format_relative_time, format_relative_time_secs.
296
297// ============================================================================
298// Text Utilities
299// ============================================================================
300
301/// Truncate a string with ellipsis if it exceeds the max length.
302///
303/// If the string fits within `max_len` characters, it is returned unchanged.
304/// Otherwise, it is truncated at the last word boundary (space) before the
305/// character limit, with "..." appended.
306///
307/// Special cases:
308/// - If `max_len <= 3`, the string is simply truncated without ellipsis
309/// - Empty strings are returned unchanged
310/// - If no space exists before the limit, falls back to character-based truncation
311///
312/// This function is Unicode-safe and operates on characters, not bytes.
313pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
314    let char_count = s.chars().count();
315    if char_count <= max_len {
316        s.to_string()
317    } else if max_len <= 3 {
318        s.chars().take(max_len).collect()
319    } else {
320        let target_len = max_len - 3; // Reserve space for "..."
321        let truncated: String = s.chars().take(target_len).collect();
322
323        // Try to find the last space to break at a word boundary
324        let truncate_at = truncated.rfind(' ').unwrap_or(target_len);
325
326        if truncate_at == 0 {
327            // No space found or only leading space - fall back to character truncation
328            format!("{}...", truncated.trim_end())
329        } else {
330            format!("{}...", truncated[..truncate_at].trim_end())
331        }
332    }
333}
334
335/// Strip worktree-related prefixes from branch names for cleaner display.
336///
337/// This removes common prefixes that follow the worktree naming pattern:
338/// - `{project}-wt-` prefix (e.g., "autom8-wt-feature/foo" → "feature/foo")
339/// - `{project}-` prefix if followed by common branch prefixes
340///
341/// The project_name is used to identify project-specific prefixes.
342///
343/// # Examples
344/// ```
345/// use autom8::ui::gui::components::strip_worktree_prefix;
346///
347/// assert_eq!(strip_worktree_prefix("feature/login", "myproject"), "feature/login");
348/// assert_eq!(strip_worktree_prefix("myproject-wt-feature/login", "myproject"), "feature/login");
349/// ```
350pub fn strip_worktree_prefix(branch_name: &str, project_name: &str) -> String {
351    // Try to strip "{project}-wt-" prefix
352    let wt_prefix = format!("{}-wt-", project_name);
353    if let Some(stripped) = branch_name.strip_prefix(&wt_prefix) {
354        return stripped.to_string();
355    }
356
357    // Try lowercase version as well (case-insensitive matching)
358    let wt_prefix_lower = format!("{}-wt-", project_name.to_lowercase());
359    if branch_name.to_lowercase().starts_with(&wt_prefix_lower) {
360        // Return the original case for the rest of the branch name
361        return branch_name[wt_prefix_lower.len()..].to_string();
362    }
363
364    // Return unchanged if no prefix matched
365    branch_name.to_string()
366}
367
368/// Maximum characters for general text truncation.
369pub const MAX_TEXT_LENGTH: usize = 40;
370
371/// Maximum characters for branch name truncation.
372pub const MAX_BRANCH_LENGTH: usize = 25;
373
374// ============================================================================
375// State Label Formatting
376// ============================================================================
377
378/// Format a machine state as a human-readable string.
379///
380/// This is a re-export of the shared `format_state_label` function for
381/// backward compatibility. Both GUI and TUI use the same underlying
382/// function from `ui::shared` to ensure consistent state labels.
383pub fn format_state(state: MachineState) -> &'static str {
384    format_state_label(state)
385}
386
387// ============================================================================
388// Status Label Component
389// ============================================================================
390
391/// A component that renders a status dot followed by a text label.
392#[derive(Debug, Clone)]
393pub struct StatusLabel {
394    /// The status for coloring.
395    status: Status,
396    /// The label text.
397    label: String,
398    /// The dot radius.
399    dot_radius: f32,
400    /// Spacing between dot and label.
401    spacing: f32,
402}
403
404impl StatusLabel {
405    /// Create a new status label.
406    pub fn new(status: Status, label: impl Into<String>) -> Self {
407        Self {
408            status,
409            label: label.into(),
410            dot_radius: STATUS_DOT_RADIUS,
411            spacing: 8.0,
412        }
413    }
414
415    /// Create a status label from a machine state.
416    pub fn from_machine_state(state: MachineState) -> Self {
417        Self::new(Status::from_machine_state(state), format_state(state))
418    }
419
420    /// Set custom dot radius.
421    pub fn with_dot_radius(mut self, radius: f32) -> Self {
422        self.dot_radius = radius;
423        self
424    }
425
426    /// Set custom spacing between dot and label.
427    pub fn with_spacing(mut self, spacing: f32) -> Self {
428        self.spacing = spacing;
429        self
430    }
431
432    /// Returns the status.
433    pub fn status(&self) -> Status {
434        self.status
435    }
436
437    /// Returns the label text.
438    pub fn label(&self) -> &str {
439        &self.label
440    }
441
442    /// Paint the status label at the given position.
443    ///
444    /// Returns the total width used.
445    pub fn paint(
446        &self,
447        _ui: &egui::Ui,
448        painter: &egui::Painter,
449        pos: Pos2,
450        font: egui::FontId,
451        text_color: Color32,
452    ) -> f32 {
453        // Draw the dot
454        let dot = StatusDot::from_status(self.status).with_radius(self.dot_radius);
455        let dot_center = Pos2::new(pos.x + self.dot_radius, pos.y + self.dot_radius);
456        dot.paint(painter, dot_center);
457
458        // Draw the label
459        let label_x = pos.x + self.dot_radius * 2.0 + self.spacing;
460        let galley = painter.layout_no_wrap(self.label.clone(), font, text_color);
461        painter.galley(
462            Pos2::new(label_x, pos.y),
463            galley.clone(),
464            Color32::TRANSPARENT,
465        );
466
467        // Return total width
468        self.dot_radius * 2.0 + self.spacing + galley.rect.width()
469    }
470
471    /// Show the status label in a UI, allocating space automatically.
472    pub fn show(&self, ui: &mut egui::Ui) -> egui::Response {
473        let font = typography::font(FontSize::Caption, FontWeight::Medium);
474        let text_color = colors::TEXT_PRIMARY;
475
476        // Calculate the approximate width needed
477        let text_galley =
478            ui.fonts(|f| f.layout_no_wrap(self.label.clone(), font.clone(), text_color));
479        let width = self.dot_radius * 2.0 + self.spacing + text_galley.rect.width();
480        let height = text_galley.rect.height().max(self.dot_radius * 2.0);
481
482        let (rect, response) =
483            ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::hover());
484
485        if ui.is_rect_visible(rect) {
486            self.paint(ui, ui.painter(), rect.min, font, text_color);
487        }
488
489        response
490    }
491}
492
493// ============================================================================
494// Collapsible Section Component
495// ============================================================================
496
497/// A reusable collapsible section component for detail panels.
498///
499/// The section has a clickable header that toggles between expanded and collapsed
500/// states. When expanded, the content area is visible. When collapsed, only the
501/// header is shown with an indicator showing the collapsed state.
502///
503/// # Example
504///
505/// ```ignore
506/// let mut collapsed_sections = HashMap::new();
507///
508/// CollapsibleSection::new("work_summaries", "Work Summaries")
509///     .default_expanded(false)
510///     .show(ui, &mut collapsed_sections, |ui| {
511///         // Section content here
512///         ui.label("Content goes here");
513///     });
514/// ```
515pub struct CollapsibleSection<'a> {
516    /// Unique identifier for this section (used for state tracking).
517    id: &'a str,
518    /// Title displayed in the section header.
519    title: &'a str,
520    /// Whether the section should be expanded by default.
521    default_expanded: bool,
522}
523
524impl<'a> CollapsibleSection<'a> {
525    /// Create a new collapsible section with the given ID and title.
526    ///
527    /// The ID should be unique within the context where the section is used,
528    /// as it's used to track the collapsed state in the state map.
529    pub fn new(id: &'a str, title: &'a str) -> Self {
530        Self {
531            id,
532            title,
533            default_expanded: true,
534        }
535    }
536
537    /// Set whether this section should be expanded by default.
538    ///
539    /// When the section is first rendered (or when its state is not in the map),
540    /// this determines whether it starts expanded or collapsed.
541    pub fn default_expanded(mut self, expanded: bool) -> Self {
542        self.default_expanded = expanded;
543        self
544    }
545
546    /// Render the collapsible section and execute the content callback if expanded.
547    ///
548    /// # Arguments
549    ///
550    /// * `ui` - The egui UI context
551    /// * `collapsed_state` - Map of section IDs to their collapsed state (true = collapsed)
552    /// * `add_contents` - Callback to render the section content when expanded
553    ///
554    /// # Returns
555    ///
556    /// The response from the header click interaction.
557    pub fn show<R>(
558        self,
559        ui: &mut egui::Ui,
560        collapsed_state: &mut std::collections::HashMap<String, bool>,
561        add_contents: impl FnOnce(&mut egui::Ui) -> R,
562    ) -> egui::Response {
563        // Get or initialize the collapsed state for this section
564        let is_collapsed = *collapsed_state
565            .entry(self.id.to_string())
566            .or_insert(!self.default_expanded);
567
568        // Render the header (clickable to toggle)
569        let header_response = self.render_header(ui, is_collapsed);
570
571        // Toggle state on click
572        if header_response.clicked() {
573            collapsed_state.insert(self.id.to_string(), !is_collapsed);
574        }
575
576        // Render content if expanded
577        if !is_collapsed {
578            ui.add_space(spacing::SM);
579            add_contents(ui);
580        }
581
582        header_response
583    }
584
585    /// Render the section header with title and expand/collapse indicator.
586    fn render_header(&self, ui: &mut egui::Ui, is_collapsed: bool) -> egui::Response {
587        let available_width = ui.available_width();
588
589        // Create a clickable header area
590        let header_height = typography::line_height(FontSize::Body) + spacing::XS * 2.0;
591
592        let (rect, response) = ui.allocate_exact_size(
593            Vec2::new(available_width, header_height),
594            egui::Sense::click(),
595        );
596
597        if ui.is_rect_visible(rect) {
598            let painter = ui.painter();
599
600            // Draw hover highlight if applicable
601            if response.hovered() {
602                painter.rect_filled(rect, Rounding::same(rounding::SMALL), colors::SURFACE_HOVER);
603            }
604
605            // Draw the chevron indicator
606            let chevron_size = 8.0;
607            let chevron_x = rect.min.x + spacing::XS;
608            let chevron_y = rect.center().y;
609
610            let chevron_color = if response.hovered() {
611                colors::TEXT_PRIMARY
612            } else {
613                colors::TEXT_SECONDARY
614            };
615
616            if is_collapsed {
617                // Right-pointing chevron (collapsed)
618                // Draw > shape
619                let points = [
620                    Pos2::new(chevron_x, chevron_y - chevron_size / 2.0),
621                    Pos2::new(chevron_x + chevron_size / 2.0, chevron_y),
622                    Pos2::new(chevron_x, chevron_y + chevron_size / 2.0),
623                ];
624                painter.line_segment(
625                    [points[0], points[1]],
626                    egui::Stroke::new(1.5, chevron_color),
627                );
628                painter.line_segment(
629                    [points[1], points[2]],
630                    egui::Stroke::new(1.5, chevron_color),
631                );
632            } else {
633                // Down-pointing chevron (expanded)
634                // Draw v shape
635                let points = [
636                    Pos2::new(chevron_x, chevron_y - chevron_size / 4.0),
637                    Pos2::new(
638                        chevron_x + chevron_size / 2.0,
639                        chevron_y + chevron_size / 4.0,
640                    ),
641                    Pos2::new(chevron_x + chevron_size, chevron_y - chevron_size / 4.0),
642                ];
643                painter.line_segment(
644                    [points[0], points[1]],
645                    egui::Stroke::new(1.5, chevron_color),
646                );
647                painter.line_segment(
648                    [points[1], points[2]],
649                    egui::Stroke::new(1.5, chevron_color),
650                );
651            }
652
653            // Draw the title
654            let title_x = chevron_x + chevron_size + spacing::SM;
655            let title_y = rect.center().y - typography::line_height(FontSize::Body) / 2.0;
656
657            let title_color = if response.hovered() {
658                colors::TEXT_PRIMARY
659            } else {
660                colors::TEXT_SECONDARY
661            };
662
663            let galley = painter.layout_no_wrap(
664                self.title.to_string(),
665                typography::font(FontSize::Body, FontWeight::Medium),
666                title_color,
667            );
668
669            painter.galley(Pos2::new(title_x, title_y), galley, Color32::TRANSPARENT);
670        }
671
672        // Show cursor change on hover
673        if response.hovered() {
674            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
675        }
676
677        response
678    }
679}
680
681// ============================================================================
682// Tests
683// ============================================================================
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    // ------------------------------------------------------------------------
690    // Status Tests
691    // ------------------------------------------------------------------------
692
693    #[test]
694    fn test_status_colors() {
695        // All status variants should return their designated colors
696        assert_eq!(Status::Setup.color(), colors::STATUS_IDLE);
697        assert_eq!(Status::Running.color(), colors::STATUS_RUNNING);
698        assert_eq!(Status::Reviewing.color(), colors::STATUS_WARNING);
699        assert_eq!(Status::Correcting.color(), colors::STATUS_CORRECTING);
700        assert_eq!(Status::Success.color(), colors::STATUS_SUCCESS);
701        assert_eq!(Status::Warning.color(), colors::STATUS_WARNING);
702        assert_eq!(Status::Error.color(), colors::STATUS_ERROR);
703        assert_eq!(Status::Idle.color(), colors::STATUS_IDLE);
704    }
705
706    #[test]
707    fn test_status_background_colors() {
708        assert_eq!(Status::Setup.background_color(), colors::STATUS_IDLE_BG);
709        assert_eq!(
710            Status::Running.background_color(),
711            colors::STATUS_RUNNING_BG
712        );
713        assert_eq!(
714            Status::Reviewing.background_color(),
715            colors::STATUS_WARNING_BG
716        );
717        assert_eq!(
718            Status::Correcting.background_color(),
719            colors::STATUS_CORRECTING_BG
720        );
721        assert_eq!(
722            Status::Success.background_color(),
723            colors::STATUS_SUCCESS_BG
724        );
725        assert_eq!(
726            Status::Warning.background_color(),
727            colors::STATUS_WARNING_BG
728        );
729        assert_eq!(Status::Error.background_color(), colors::STATUS_ERROR_BG);
730        assert_eq!(Status::Idle.background_color(), colors::STATUS_IDLE_BG);
731    }
732
733    #[test]
734    fn test_status_from_machine_state_setup_phase() {
735        // Setup phases should map to Status::Setup (gray)
736        assert_eq!(
737            Status::from_machine_state(MachineState::Initializing),
738            Status::Setup
739        );
740        assert_eq!(
741            Status::from_machine_state(MachineState::PickingStory),
742            Status::Setup
743        );
744        assert_eq!(
745            Status::from_machine_state(MachineState::LoadingSpec),
746            Status::Setup
747        );
748        assert_eq!(
749            Status::from_machine_state(MachineState::GeneratingSpec),
750            Status::Setup
751        );
752    }
753
754    #[test]
755    fn test_status_from_machine_state_running() {
756        // Active implementation should map to Status::Running (blue)
757        assert_eq!(
758            Status::from_machine_state(MachineState::RunningClaude),
759            Status::Running
760        );
761    }
762
763    #[test]
764    fn test_status_from_machine_state_reviewing() {
765        // Evaluation phase should map to Status::Reviewing (amber)
766        assert_eq!(
767            Status::from_machine_state(MachineState::Reviewing),
768            Status::Reviewing
769        );
770    }
771
772    #[test]
773    fn test_status_from_machine_state_correcting() {
774        // Attention needed should map to Status::Correcting (orange)
775        assert_eq!(
776            Status::from_machine_state(MachineState::Correcting),
777            Status::Correcting
778        );
779    }
780
781    #[test]
782    fn test_status_from_machine_state_success_path() {
783        // Success path states should map to Status::Success (green)
784        assert_eq!(
785            Status::from_machine_state(MachineState::Committing),
786            Status::Success
787        );
788        assert_eq!(
789            Status::from_machine_state(MachineState::CreatingPR),
790            Status::Success
791        );
792        assert_eq!(
793            Status::from_machine_state(MachineState::Completed),
794            Status::Success
795        );
796    }
797
798    #[test]
799    fn test_status_from_machine_state_terminal() {
800        // Terminal states
801        assert_eq!(
802            Status::from_machine_state(MachineState::Failed),
803            Status::Error
804        );
805        assert_eq!(Status::from_machine_state(MachineState::Idle), Status::Idle);
806    }
807
808    #[test]
809    fn test_state_to_color_semantic_mapping() {
810        // Setup phases -> gray (STATUS_IDLE)
811        assert_eq!(
812            state_to_color(MachineState::Initializing),
813            colors::STATUS_IDLE
814        );
815        assert_eq!(
816            state_to_color(MachineState::PickingStory),
817            colors::STATUS_IDLE
818        );
819
820        // Active implementation -> blue
821        assert_eq!(
822            state_to_color(MachineState::RunningClaude),
823            colors::STATUS_RUNNING
824        );
825
826        // Evaluation -> amber (warning)
827        assert_eq!(
828            state_to_color(MachineState::Reviewing),
829            colors::STATUS_WARNING
830        );
831
832        // Attention needed -> orange
833        assert_eq!(
834            state_to_color(MachineState::Correcting),
835            colors::STATUS_CORRECTING
836        );
837
838        // Success path -> green
839        assert_eq!(
840            state_to_color(MachineState::Committing),
841            colors::STATUS_SUCCESS
842        );
843        assert_eq!(
844            state_to_color(MachineState::CreatingPR),
845            colors::STATUS_SUCCESS
846        );
847        assert_eq!(
848            state_to_color(MachineState::Completed),
849            colors::STATUS_SUCCESS
850        );
851
852        // Failure -> red
853        assert_eq!(state_to_color(MachineState::Failed), colors::STATUS_ERROR);
854
855        // Idle -> gray
856        assert_eq!(state_to_color(MachineState::Idle), colors::STATUS_IDLE);
857    }
858
859    #[test]
860    fn test_state_to_background_color() {
861        assert_eq!(
862            state_to_background_color(MachineState::RunningClaude),
863            colors::STATUS_RUNNING_BG
864        );
865        assert_eq!(
866            state_to_background_color(MachineState::Completed),
867            colors::STATUS_SUCCESS_BG
868        );
869        assert_eq!(
870            state_to_background_color(MachineState::Failed),
871            colors::STATUS_ERROR_BG
872        );
873        assert_eq!(
874            state_to_background_color(MachineState::Correcting),
875            colors::STATUS_CORRECTING_BG
876        );
877    }
878
879    // ------------------------------------------------------------------------
880    // StatusDot Tests
881    // ------------------------------------------------------------------------
882
883    #[test]
884    fn test_status_dot_default() {
885        let dot = StatusDot::default();
886        assert_eq!(dot.radius(), STATUS_DOT_RADIUS);
887        assert_eq!(dot.color(), colors::STATUS_IDLE);
888    }
889
890    #[test]
891    fn test_status_dot_from_status() {
892        let dot = StatusDot::from_status(Status::Running);
893        assert_eq!(dot.color(), colors::STATUS_RUNNING);
894    }
895
896    #[test]
897    fn test_status_dot_from_machine_state() {
898        let dot = StatusDot::from_machine_state(MachineState::Completed);
899        assert_eq!(dot.color(), colors::STATUS_SUCCESS);
900    }
901
902    #[test]
903    fn test_status_dot_with_radius() {
904        let dot = StatusDot::default().with_radius(8.0);
905        assert_eq!(dot.radius(), 8.0);
906    }
907
908    #[test]
909    fn test_status_dot_with_color() {
910        let custom_color = Color32::from_rgb(255, 0, 128);
911        let dot = StatusDot::with_color(custom_color);
912        assert_eq!(dot.color(), custom_color);
913    }
914
915    // ------------------------------------------------------------------------
916    // ProgressBar Tests (RunProgress tests are in ui::shared::tests)
917    // ------------------------------------------------------------------------
918
919    #[test]
920    fn test_progress_bar() {
921        assert!((ProgressBar::new(0.5).progress() - 0.5).abs() < 0.001);
922        assert_eq!(ProgressBar::new(-0.5).progress(), 0.0); // Clamps low
923        assert_eq!(ProgressBar::new(1.5).progress(), 1.0); // Clamps high
924        assert!(
925            (ProgressBar::from_progress(&RunProgress::new(3, 10)).progress() - 0.3).abs() < 0.001
926        );
927    }
928
929    // Duration and relative time formatting tests are in ui::shared::tests.
930    // The functions are re-exported from shared for backward compatibility.
931
932    // ------------------------------------------------------------------------
933    // Text Utility Tests
934    // ------------------------------------------------------------------------
935
936    #[test]
937    fn test_truncate_with_ellipsis_short_string() {
938        let result = truncate_with_ellipsis("short", 10);
939        assert_eq!(result, "short");
940    }
941
942    #[test]
943    fn test_truncate_with_ellipsis_exact_length() {
944        let result = truncate_with_ellipsis("exactly10!", 10);
945        assert_eq!(result, "exactly10!");
946    }
947
948    #[test]
949    fn test_truncate_with_ellipsis_long_string_word_boundary() {
950        // Should break at word boundary "is a" instead of mid-word "ve"
951        let result = truncate_with_ellipsis("this is a very long string", 15);
952        assert_eq!(result, "this is a...");
953        assert!(result.len() <= 15);
954    }
955
956    #[test]
957    fn test_truncate_with_ellipsis_very_short_max() {
958        let result = truncate_with_ellipsis("hello", 3);
959        assert_eq!(result, "hel");
960    }
961
962    #[test]
963    fn test_truncate_with_ellipsis_empty_string() {
964        let result = truncate_with_ellipsis("", 10);
965        assert_eq!(result, "");
966    }
967
968    #[test]
969    fn test_truncate_with_ellipsis_no_space_fallback() {
970        // When there's no space, fall back to character-based truncation
971        let result = truncate_with_ellipsis("superlongword", 10);
972        assert_eq!(result, "superlo...");
973        assert_eq!(result.len(), 10);
974    }
975
976    #[test]
977    fn test_truncate_with_ellipsis_word_boundary_exact() {
978        // "hello world" with max 11 fits exactly
979        let result = truncate_with_ellipsis("hello world", 11);
980        assert_eq!(result, "hello world");
981    }
982
983    #[test]
984    fn test_truncate_with_ellipsis_word_boundary_just_over() {
985        // "hello world test" with max 14 -> target_len = 11 -> "hello world" -> last space at 5
986        // Should truncate at "hello" since "world" would exceed
987        let result = truncate_with_ellipsis("hello world test", 14);
988        assert_eq!(result, "hello...");
989    }
990
991    #[test]
992    fn test_truncate_with_ellipsis_single_word_too_long() {
993        // Single word that's too long should use character truncation
994        // max_len 15, target_len = 12 -> "internationa" -> no space -> fall back to char truncation
995        let result = truncate_with_ellipsis("internationalization", 15);
996        assert_eq!(result, "internationa...");
997        assert!(result.len() <= 15);
998    }
999
1000    #[test]
1001    fn test_truncate_with_ellipsis_preserves_short_content() {
1002        // Short content should be unchanged
1003        let result = truncate_with_ellipsis("ok", 10);
1004        assert_eq!(result, "ok");
1005    }
1006
1007    #[test]
1008    fn test_truncate_with_ellipsis_multiple_spaces() {
1009        // max_len 16, target_len = 13 -> "one two three" -> last space at 7 -> "one two"
1010        let result = truncate_with_ellipsis("one two three four five", 16);
1011        assert_eq!(result, "one two...");
1012    }
1013
1014    #[test]
1015    fn test_truncate_with_ellipsis_trailing_space_trimmed() {
1016        // Trailing spaces before truncation point should be trimmed
1017        let result = truncate_with_ellipsis("hello   world", 10);
1018        assert_eq!(result, "hello...");
1019    }
1020
1021    // ------------------------------------------------------------------------
1022    // strip_worktree_prefix Tests
1023    // ------------------------------------------------------------------------
1024
1025    #[test]
1026    fn test_strip_worktree_prefix_no_prefix() {
1027        // Branch without worktree prefix should be unchanged
1028        assert_eq!(
1029            strip_worktree_prefix("feature/login", "myproject"),
1030            "feature/login"
1031        );
1032        assert_eq!(strip_worktree_prefix("main", "myproject"), "main");
1033        assert_eq!(
1034            strip_worktree_prefix("develop/new-feature", "myproject"),
1035            "develop/new-feature"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_strip_worktree_prefix_standard_wt_prefix() {
1041        // Standard "{project}-wt-" prefix should be stripped
1042        assert_eq!(
1043            strip_worktree_prefix("myproject-wt-feature/login", "myproject"),
1044            "feature/login"
1045        );
1046        assert_eq!(
1047            strip_worktree_prefix("autom8-wt-feature/gui-tabs", "autom8"),
1048            "feature/gui-tabs"
1049        );
1050        // Case insensitive matching
1051        assert_eq!(
1052            strip_worktree_prefix("MyProject-wt-feature/test", "myproject"),
1053            "feature/test"
1054        );
1055    }
1056
1057    // ------------------------------------------------------------------------
1058    // State Label Formatting Tests
1059    // ------------------------------------------------------------------------
1060
1061    #[test]
1062    fn test_format_state_all_states() {
1063        assert_eq!(format_state(MachineState::Idle), "Idle");
1064        assert_eq!(format_state(MachineState::LoadingSpec), "Loading Spec");
1065        assert_eq!(
1066            format_state(MachineState::GeneratingSpec),
1067            "Generating Spec"
1068        );
1069        assert_eq!(format_state(MachineState::Initializing), "Initializing");
1070        assert_eq!(format_state(MachineState::PickingStory), "Picking Story");
1071        assert_eq!(format_state(MachineState::RunningClaude), "Running Claude");
1072        assert_eq!(format_state(MachineState::Reviewing), "Reviewing");
1073        assert_eq!(format_state(MachineState::Correcting), "Correcting");
1074        assert_eq!(format_state(MachineState::Committing), "Committing");
1075        assert_eq!(format_state(MachineState::CreatingPR), "Creating PR");
1076        assert_eq!(format_state(MachineState::Completed), "Completed");
1077        assert_eq!(format_state(MachineState::Failed), "Failed");
1078    }
1079
1080    // ------------------------------------------------------------------------
1081    // StatusLabel Tests
1082    // ------------------------------------------------------------------------
1083
1084    #[test]
1085    fn test_status_label_new() {
1086        let label = StatusLabel::new(Status::Running, "Test Label");
1087        assert_eq!(label.status(), Status::Running);
1088        assert_eq!(label.label(), "Test Label");
1089    }
1090
1091    #[test]
1092    fn test_status_label_from_machine_state() {
1093        let label = StatusLabel::from_machine_state(MachineState::RunningClaude);
1094        assert_eq!(label.status(), Status::Running);
1095        assert_eq!(label.label(), "Running Claude");
1096    }
1097
1098    #[test]
1099    fn test_status_label_with_dot_radius() {
1100        let label = StatusLabel::new(Status::Success, "Done").with_dot_radius(8.0);
1101        // Just verify it compiles and stores the value
1102        assert_eq!(label.status(), Status::Success);
1103    }
1104
1105    #[test]
1106    fn test_status_label_with_spacing() {
1107        let label = StatusLabel::new(Status::Error, "Failed").with_spacing(12.0);
1108        assert_eq!(label.status(), Status::Error);
1109    }
1110
1111    // ------------------------------------------------------------------------
1112    // Badge Background Tests
1113    // ------------------------------------------------------------------------
1114
1115    #[test]
1116    fn test_badge_background_color() {
1117        let running_bg = badge_background_color(colors::STATUS_RUNNING);
1118        let success_bg = badge_background_color(colors::STATUS_SUCCESS);
1119        let error_bg = badge_background_color(colors::STATUS_ERROR);
1120
1121        // Badge backgrounds should be light (high luminance > 600 out of 765 max)
1122        for (name, bg) in [
1123            ("running", running_bg),
1124            ("success", success_bg),
1125            ("error", error_bg),
1126        ] {
1127            let lum = bg.r() as u32 + bg.g() as u32 + bg.b() as u32;
1128            assert!(
1129                lum > 600,
1130                "{} badge bg should be light, got luminance {}",
1131                name,
1132                lum
1133            );
1134        }
1135
1136        // All three should produce different colors
1137        assert_ne!(running_bg, success_bg);
1138        assert_ne!(success_bg, error_bg);
1139        assert_ne!(running_bg, error_bg);
1140    }
1141}