diff --git a/crates/mergify-queue/src/show.rs b/crates/mergify-queue/src/show.rs index 50cdf262..f1e16d9f 100644 --- a/crates/mergify-queue/src/show.rs +++ b/crates/mergify-queue/src/show.rs @@ -27,12 +27,12 @@ use std::io::Write; use anstyle::AnsiColor; -use anstyle::Style; use chrono::DateTime; use chrono::Utc; use mergify_core::CliError; use mergify_core::CommandContext; use mergify_core::Output; +use mergify_tui::StyledGlyph; use mergify_tui::Theme; use mergify_tui::relative_time; use mergify_tui::tree; @@ -230,11 +230,12 @@ fn print_checks_section( now: DateTime, ) -> std::io::Result<()> { writeln!(w)?; - let (icon, style) = check_state_glyph(theme, &mc.ci_state); + let glyph = check_state_glyph(theme, &mc.ci_state); write!( w, " CI State: {S}{icon} {state}{R}", - S = style, + S = glyph.style, + icon = glyph.icon, state = mc.ci_state, R = theme.reset, )?; @@ -267,7 +268,7 @@ fn print_checks_table(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> std .max() .unwrap_or(0); for check in checks { - let (icon, style) = check_state_glyph(theme, &check.state); + let glyph = check_state_glyph(theme, &check.state); let pad = name_width.saturating_sub(check.name.chars().count()); writeln!( w, @@ -276,7 +277,8 @@ fn print_checks_table(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> std name = check.name, spaces = " ".repeat(pad), R = theme.reset, - S = style, + S = glyph.style, + icon = glyph.icon, state = check.state, )?; } @@ -325,11 +327,12 @@ fn print_checks_summary(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> s check.state.as_str(), "failure" | "error" | "timed_out" | "action_required" ) { - let (icon, style) = check_state_glyph(theme, &check.state); + let glyph = check_state_glyph(theme, &check.state); writeln!( w, " {S}{icon} {state}{R} {D}{name}{R}", - S = style, + S = glyph.style, + icon = glyph.icon, state = check.state, R = theme.reset, D = theme.dim, @@ -340,17 +343,17 @@ fn print_checks_summary(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> s Ok(()) } -/// Map a check state string to (icon, ANSI style). Mirrors Python's +/// Map a check state string to its [`StyledGlyph`]. Mirrors Python's /// `CHECK_STATE_STYLES`; unknown states fall back to a dim `?` so /// the renderer never crashes on a new API code. -fn check_state_glyph(theme: &Theme, state: &str) -> (&'static str, Style) { +fn check_state_glyph(theme: &Theme, state: &str) -> StyledGlyph { match state { - "success" => ("✓", theme.fg(AnsiColor::Green)), - "pending" => ("◌", theme.fg(AnsiColor::Yellow)), - "failure" | "error" | "action_required" => ("✗", theme.fg(AnsiColor::Red)), - "timed_out" => ("⏰", theme.fg(AnsiColor::Red)), - "cancelled" | "neutral" | "skipped" | "stale" => ("○", theme.dim), - _ => ("?", theme.dim), + "success" => StyledGlyph::new("✓", theme.fg(AnsiColor::Green)), + "pending" => StyledGlyph::new("◌", theme.fg(AnsiColor::Yellow)), + "failure" | "error" | "action_required" => StyledGlyph::new("✗", theme.fg(AnsiColor::Red)), + "timed_out" => StyledGlyph::new("⏰", theme.fg(AnsiColor::Red)), + "cancelled" | "neutral" | "skipped" | "stale" => StyledGlyph::new("○", theme.dim), + _ => StyledGlyph::new("?", theme.dim), } } @@ -446,15 +449,16 @@ fn write_condition_tree( let last = nodes.len() - 1; for (i, node) in nodes.iter().enumerate() { let (branch, continuation) = tree::branch_chars(i == last); - let (icon, style) = if node.r#match { - ("✓", theme.fg(AnsiColor::Green)) + let glyph = if node.r#match { + StyledGlyph::new("✓", theme.fg(AnsiColor::Green)) } else { - ("✗", theme.fg(AnsiColor::Red)) + StyledGlyph::new("✗", theme.fg(AnsiColor::Red)) }; writeln!( w, "{prefix}{branch}{S}{icon}{R} {label}", - S = style, + S = glyph.style, + icon = glyph.icon, R = theme.reset, label = node.label, )?; diff --git a/crates/mergify-queue/src/status.rs b/crates/mergify-queue/src/status.rs index 8807b62b..2b9662e4 100644 --- a/crates/mergify-queue/src/status.rs +++ b/crates/mergify-queue/src/status.rs @@ -29,13 +29,13 @@ use std::collections::HashSet; use std::io::Write; use anstyle::AnsiColor; -use anstyle::Style; use chrono::DateTime; use chrono::Utc; use indexmap::IndexMap; use mergify_core::CliError; use mergify_core::CommandContext; use mergify_core::Output; +use mergify_tui::StyledGlyph; use mergify_tui::Theme; use mergify_tui::relative_time; use mergify_tui::tree; @@ -194,28 +194,48 @@ fn emit_human(output: &mut dyn Output, repository: &str, view: &StatusView) -> s }) } -/// Map a queue batch status code to a foreground color, honoring -/// the theme's enabled flag. Mirrors Python's `STATUS_STYLES`; -/// unknown codes render dim. -fn batch_status_style(theme: &Theme, code: &str) -> Style { - if !theme.enabled { - return Style::new(); - } - match code { - "running" => theme.fg(AnsiColor::Green), - // Python rendered `merged` as `"dim green"` — bold off, - // green on, dimmed. anstyle composes the same effect by - // setting `dimmed()` on the green style. - "merged" => theme.fg(AnsiColor::Green).dimmed(), - "failed" => theme.fg(AnsiColor::Red), - "bisecting" - | "preparing" - | "waiting_for_previous_batches" - | "waiting_for_requeue" - | "waiting_schedule" => theme.fg(AnsiColor::Yellow), - "waiting_for_merge" | "frozen" => theme.fg(AnsiColor::Cyan), - _ => theme.dim, - } +/// Map a queue batch status code to its [`StyledGlyph`]. Mirrors +/// Python's `STATUS_STYLES` (icon + color side); unknown codes fall +/// back to a dim `?`. +fn batch_glyph(theme: &Theme, code: &str) -> StyledGlyph { + // `theme.fg` already collapses to `Style::new()` when colors are + // off — except for `merged`, which we compose as `green.dimmed()`. + // When colors are off the `dimmed` attribute would still emit a + // dim escape, so short-circuit here to keep the plain output + // truly plain. + let style = if theme.enabled { + match code { + "running" => theme.fg(AnsiColor::Green), + // Python rendered `merged` as `"dim green"` — bold off, + // green on, dimmed. anstyle composes the same effect by + // setting `dimmed()` on the green style. + "merged" => theme.fg(AnsiColor::Green).dimmed(), + "failed" => theme.fg(AnsiColor::Red), + "bisecting" + | "preparing" + | "waiting_for_previous_batches" + | "waiting_for_requeue" + | "waiting_schedule" => theme.fg(AnsiColor::Yellow), + "waiting_for_merge" | "frozen" => theme.fg(AnsiColor::Cyan), + _ => theme.dim, + } + } else { + anstyle::Style::new() + }; + let icon = match code { + "running" => "●", + "bisecting" => "◑", + "preparing" => "◌", + "failed" => "✗", + "merged" => "✓", + "waiting_for_merge" => "◎", + "waiting_for_previous_batches" | "waiting_for_batch" => "⏳", + "waiting_for_requeue" => "↻", + "waiting_schedule" => "⏰", + "frozen" => "❄", + _ => "?", + }; + StyledGlyph::new(icon, style) } fn print_pause( @@ -281,12 +301,12 @@ fn print_batch_line( batch: &Batch, now: DateTime, ) -> std::io::Result<()> { - let icon = status_icon(&batch.status.code); - let icon_style = batch_status_style(theme, &batch.status.code); + let glyph = batch_glyph(theme, &batch.status.code); write!( w, "{branch}{S}{icon} {code}{R}", - S = icon_style, + S = glyph.style, + icon = glyph.icon, code = batch.status.code, R = theme.reset, )?; @@ -379,24 +399,6 @@ fn print_waiting_prs( Ok(()) } -/// Map a batch-status code to a compact Unicode icon. Same icons as -/// the Python implementation; unknown codes fall back to `?`. -fn status_icon(code: &str) -> &'static str { - match code { - "running" => "●", - "bisecting" => "◑", - "preparing" => "◌", - "failed" => "✗", - "merged" => "✓", - "waiting_for_merge" => "◎", - "waiting_for_previous_batches" | "waiting_for_batch" => "⏳", - "waiting_for_requeue" => "↻", - "waiting_schedule" => "⏰", - "frozen" => "❄", - _ => "?", - } -} - /// Topological sort of batches by `parent_ids`. Roots come first, /// children follow their parents — matches the Python /// `_topological_sort`. Cycles are impossible by API contract, but @@ -543,22 +545,29 @@ mod tests { } #[test] - fn status_icon_known_codes() { - assert_eq!(status_icon("running"), "●"); - assert_eq!(status_icon("merged"), "✓"); - assert_eq!(status_icon("failed"), "✗"); + fn batch_glyph_known_codes() { + // Pin a no-color theme so we exercise the icon mapping + // without coupling the assertions to the ANSI escape format. + let theme = Theme::new(false); + assert_eq!(batch_glyph(&theme, "running").icon, "●"); + assert_eq!(batch_glyph(&theme, "merged").icon, "✓"); + assert_eq!(batch_glyph(&theme, "failed").icon, "✗"); // Two pairs that share an icon vs. a different icon — // mirrors the Python `STATUS_STYLES` table, so a future // table edit can't silently swap glyphs without updating // this test. - assert_eq!(status_icon("waiting_for_previous_batches"), "⏳"); - assert_eq!(status_icon("waiting_for_batch"), "⏳"); - assert_eq!(status_icon("waiting_for_requeue"), "↻"); + assert_eq!( + batch_glyph(&theme, "waiting_for_previous_batches").icon, + "⏳" + ); + assert_eq!(batch_glyph(&theme, "waiting_for_batch").icon, "⏳"); + assert_eq!(batch_glyph(&theme, "waiting_for_requeue").icon, "↻"); } #[test] - fn status_icon_unknown_falls_back() { - assert_eq!(status_icon("brand-new-status"), "?"); + fn batch_glyph_unknown_falls_back() { + let theme = Theme::new(false); + assert_eq!(batch_glyph(&theme, "brand-new-status").icon, "?"); } fn sample_batch(id: &str, parents: &[&str]) -> Batch { diff --git a/crates/mergify-tui/src/glyph.rs b/crates/mergify-tui/src/glyph.rs new file mode 100644 index 00000000..c3919c9f --- /dev/null +++ b/crates/mergify-tui/src/glyph.rs @@ -0,0 +1,27 @@ +//! Pairing of a Unicode icon with its [`anstyle::Style`]. +//! +//! Multiple commands map an enum-ish state code (CI check states, +//! merge-queue batch states, …) to "render this icon in that color". +//! Both halves of that pair travel together at every call site: the +//! icon goes in the formatted output, the style wraps it. Bundling +//! them into one named type beats returning a `(&str, Style)` tuple +//! — the field names document what's what at the call site, and +//! future fields (e.g. a dim suffix) can be added without rewriting +//! every consumer. + +use anstyle::Style; + +#[derive(Clone, Copy)] +pub struct StyledGlyph { + pub icon: &'static str, + pub style: Style, +} + +impl StyledGlyph { + /// Convenience constructor — saves a few `StyledGlyph { … }` + /// braces at the dense pattern-match call sites. + #[must_use] + pub const fn new(icon: &'static str, style: Style) -> Self { + Self { icon, style } + } +} diff --git a/crates/mergify-tui/src/lib.rs b/crates/mergify-tui/src/lib.rs index aaf20275..dbfdefc5 100644 --- a/crates/mergify-tui/src/lib.rs +++ b/crates/mergify-tui/src/lib.rs @@ -15,6 +15,10 @@ //! palette. The same closure-based emit code paths produce //! styled output on a TTY and plain text everywhere else with no //! conditional branching at every write. +//! - [`glyph`]: [`StyledGlyph`] — pairs a Unicode icon with the +//! [`anstyle::Style`] it's drawn in. Used by commands that map +//! state codes (check states, batch states, …) to a small visual +//! token. //! - [`time`]: [`relative_time`](time::relative_time) formats an //! ISO-8601/RFC-3339 timestamp as a coarse delta (`Ns` / `Nm` / //! `Nh` / `Nd`), with `~…` / `… ago` decorators for @@ -27,9 +31,11 @@ //! [`LAST_CONTINUATION`](tree::LAST_CONTINUATION)) and the //! [`branch_chars`](tree::branch_chars) helper. +pub mod glyph; pub mod theme; pub mod time; pub mod tree; +pub use glyph::StyledGlyph; pub use theme::Theme; pub use time::relative_time;