use iced::Background;
use iced::Color;
use iced::border;
use iced::widget::{container, scrollable};
use crate::board::TicketPhase;
use iced_fonts::lucide;
use std::sync::atomic::AtomicBool;
pub const BG_BASE: Color = Color::from_rgb(0.063, 0.059, 0.059); pub const BG_SURFACE: Color = Color::from_rgb(0.110, 0.106, 0.102); pub const BG_ELEVATED: Color = Color::from_rgb(0.157, 0.153, 0.149);
pub const BORDER: Color = Color::from_rgba(0.808, 0.804, 0.765, 0.08);
pub const BORDER_STRONG: Color = Color::from_rgba(0.808, 0.804, 0.765, 0.12);
pub const TEXT_PRIMARY: Color = Color::from_rgb(0.808, 0.804, 0.765); pub const TEXT_SECONDARY: Color = Color::from_rgb(0.529, 0.522, 0.502); pub const TEXT_MUTED: Color = Color::from_rgb(0.341, 0.337, 0.325); pub const TEXT_FAINT: Color = Color::from_rgb(0.204, 0.200, 0.192);
pub const ACCENT: Color = Color::from_rgb(0.227, 0.663, 0.624); pub const ACCENT_LIGHT: Color = Color::from_rgb(0.357, 0.749, 0.710); pub const ACCENT_DIM: Color = Color::from_rgba(0.227, 0.663, 0.624, 0.3);
pub const FIND_MATCH_DIM: Color = Color::from_rgba(0.816, 0.635, 0.082, 0.25);
pub const FIND_MATCH_CURRENT: Color = Color::from_rgba(0.816, 0.635, 0.082, 0.45);
pub const BRACKET_MATCH: Color = Color::from_rgba(0.357, 0.749, 0.710, 0.35);
pub const HOVER: Color = Color::from_rgba(0.808, 0.804, 0.765, 0.05);
pub const HOVER_STRONG: Color = Color::from_rgba(0.808, 0.804, 0.765, 0.08);
pub const STATUS_SUCCESS: Color = Color::from_rgb(0.0, 0.902, 0.541); pub const STATUS_WARNING: Color = Color::from_rgb(1.0, 0.667, 0.0); pub const STATUS_ERROR: Color = Color::from_rgb(1.0, 0.267, 0.4);
pub fn log_level_color(level: &str) -> (Color, Color) {
match level.to_uppercase().as_str() {
"ERROR" => (STATUS_ERROR, Color::from_rgba(0.937, 0.267, 0.267, 0.08)),
"WARN" => (STATUS_WARNING, Color::from_rgba(1.0, 0.667, 0.0, 0.08)),
"INFO" => (
Color::from_rgb(0.219, 0.741, 0.973),
Color::from_rgba(0.219, 0.741, 0.973, 0.08),
),
"DEBUG" => (
Color::from_rgb(0.655, 0.545, 0.980),
Color::from_rgba(0.655, 0.545, 0.980, 0.08),
),
"TRACE" => (TEXT_MUTED, Color::from_rgba(0.5, 0.5, 0.5, 0.08)),
_ => (TEXT_MUTED, HOVER),
}
}
#[must_use]
pub const fn role_badge_color_for(role: &crate::Role) -> (Color, Color) {
let info = crate::role::role_info(role);
let (r, g, b) = info.badge_fg;
(Color::from_rgb(r, g, b), Color::from_rgba(r, g, b, 0.1))
}
#[must_use]
pub fn role_badge_color(role: &str) -> (Color, Color) {
if let Ok(r) = role.parse::<crate::Role>() {
return role_badge_color_for(&r);
}
if let Some(idx) = role.rfind('_')
&& idx + 1 < role.len()
&& role.as_bytes()[idx + 1..].iter().all(u8::is_ascii_digit)
{
let stripped = &role[..idx];
if let Ok(r) = stripped.parse::<crate::Role>() {
return role_badge_color_for(&r);
}
}
(TEXT_MUTED, HOVER)
}
#[must_use]
pub fn role_icon(role: &crate::Role) -> iced::widget::Text<'static, iced::Theme, iced::Renderer> {
match role {
crate::Role::Manager => lucide::bot(),
crate::Role::Engineer => lucide::wrench(),
crate::Role::Analyst => lucide::scan_search(),
crate::Role::Coder => lucide::code(),
crate::Role::Qa => lucide::gavel(),
crate::Role::Maintainer => lucide::cog(),
crate::Role::Discovery => lucide::search(),
crate::Role::Artist => lucide::palette(),
crate::Role::Reviewer => lucide::file_check(),
}
}
pub const FONT_BOLD: iced::Font = iced::Font {
family: iced::font::Family::Name("JetBrains Mono"),
weight: iced::font::Weight::Bold,
..iced::Font::DEFAULT
};
pub const FONT_REGULAR: iced::Font = iced::Font {
family: iced::font::Family::Name("JetBrains Mono"),
..iced::Font::DEFAULT
};
#[must_use]
pub fn markdown_settings() -> iced::widget::markdown::Settings {
let style = iced::widget::markdown::Style {
font: FONT_REGULAR,
inline_code_highlight: iced::widget::markdown::Highlight {
background: iced::Color::from_rgba(0.0, 0.0, 0.0, 0.15).into(),
border: iced::border::rounded(4),
},
inline_code_padding: iced::padding::left(1).right(1),
inline_code_color: TEXT_PRIMARY,
inline_code_font: FONT_REGULAR,
code_block_font: FONT_REGULAR,
link_color: ACCENT,
};
iced::widget::markdown::Settings::with_text_size(13, style)
}
pub const fn ticket_status_color(phase: TicketPhase) -> (Color, Color) {
use TicketPhase::{
Analysis, Backlog, Cancelled, DiagnosticsDone, Done, Failed, InDevelopment, InDiagnostics,
InQa, InReview, Paused, Planning, QaPassed, ReadyForDevelopment, Reviewed,
};
match phase {
Backlog => (
Color::from_rgb(0.353, 0.353, 0.353),
Color::from_rgb(0.808, 0.804, 0.765),
),
Planning => (
Color::from_rgb(0.478, 0.447, 0.227),
Color::from_rgb(0.902, 0.863, 0.784),
),
Analysis => (
Color::from_rgb(0.227, 0.431, 0.620),
Color::from_rgb(0.784, 0.863, 0.949),
),
ReadyForDevelopment => (
Color::from_rgb(0.478, 0.431, 0.243),
Color::from_rgb(0.902, 0.863, 0.784),
),
InDevelopment => (
Color::from_rgb(0.659, 0.431, 0.204),
Color::from_rgb(0.941, 0.878, 0.784),
),
InDiagnostics => (
Color::from_rgb(0.541, 0.447, 0.227),
Color::from_rgb(0.902, 0.863, 0.784),
),
DiagnosticsDone => (
Color::from_rgb(0.322, 0.471, 0.447),
Color::from_rgb(0.784, 0.902, 0.871),
),
InReview => (
Color::from_rgb(0.369, 0.431, 0.659),
Color::from_rgb(0.816, 0.816, 0.933),
),
Reviewed => (
Color::from_rgb(0.447, 0.416, 0.541),
Color::from_rgb(0.878, 0.816, 0.933),
),
InQa => (
Color::from_rgb(0.431, 0.369, 0.659),
Color::from_rgb(0.816, 0.816, 0.933),
),
QaPassed => (
Color::from_rgb(0.353, 0.541, 0.416),
Color::from_rgb(0.784, 0.902, 0.816),
),
Done => (
Color::from_rgb(0.227, 0.353, 0.227),
Color::from_rgb(0.753, 0.816, 0.753),
),
Cancelled => (
Color::from_rgb(0.290, 0.290, 0.290),
Color::from_rgb(0.690, 0.690, 0.690),
),
Failed => (
Color::from_rgb(0.541, 0.227, 0.227),
Color::from_rgb(0.878, 0.753, 0.753),
),
Paused => (
Color::from_rgb(0.478, 0.416, 0.227),
Color::from_rgb(0.878, 0.847, 0.753),
),
}
}
static TIMESTAMP_PARSE_WARNED: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn format_timestamp(ts: &str) -> String {
if let Ok(dt) = crate::turso::parse_utc_timestamp(ts) {
dt.format("%b %-d, %H:%M").to_string()
} else {
if !TIMESTAMP_PARSE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(timestamp = %ts, "Failed to parse timestamp, falling back to truncated string");
}
if ts.len() > 16 {
ts[..ts.floor_char_boundary(16)].to_string()
} else {
ts.to_string()
}
}
}
pub fn workspace_status_color(status: &str) -> (Color, Color) {
match status {
"ready" => (
Color::from_rgb(0.133, 0.773, 0.369),
Color::from_rgba(0.133, 0.773, 0.369, 0.1),
),
"analyzing" => (
Color::from_rgb(0.851, 0.557, 0.0),
Color::from_rgba(0.851, 0.557, 0.0, 0.1),
),
"pending" => (
Color::from_rgb(0.631, 0.631, 0.631),
Color::from_rgba(0.631, 0.631, 0.631, 0.1),
),
"failed" => (
Color::from_rgb(0.957, 0.247, 0.369),
Color::from_rgba(0.957, 0.247, 0.369, 0.1),
),
_ => (
Color::from_rgb(0.631, 0.631, 0.631),
Color::from_rgba(0.631, 0.631, 0.631, 0.1),
),
}
}
pub const ANIM_LOG_FADE_MS: u64 = 100;
pub const ANIM_SORT_MS: u64 = 200;
pub const ANIM_SELECTED_MS: u64 = 150;
#[must_use]
pub fn thin_scrollbar() -> scrollable::Scrollbar {
scrollable::Scrollbar::new().width(6).scroller_width(6)
}
#[must_use]
pub fn scrollbar_style(theme: &iced::Theme, status: scrollable::Status) -> scrollable::Style {
let base = scrollable::default(theme, status);
let opacity = match status {
scrollable::Status::Active { .. } => 0.4,
scrollable::Status::Hovered { .. } => 0.6,
scrollable::Status::Dragged { .. } => 0.8,
};
let rail = scrollable::Rail {
background: None,
border: border::rounded(2),
scroller: scrollable::Scroller {
background: Background::Color(TEXT_PRIMARY.scale_alpha(opacity)),
border: border::rounded(2),
},
};
scrollable::Style {
vertical_rail: rail,
horizontal_rail: rail,
..base
}
}
pub fn button_primary(
_: &iced::Theme,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let base = match status {
iced::widget::button::Status::Active => ACCENT,
iced::widget::button::Status::Hovered => ACCENT_LIGHT,
iced::widget::button::Status::Pressed => Color::from_rgb(0.165, 0.482, 0.455),
iced::widget::button::Status::Disabled => TEXT_FAINT,
};
let text = match status {
iced::widget::button::Status::Disabled => TEXT_MUTED,
_ => BG_BASE,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(base)),
text_color: text,
border: iced::Border {
radius: 4.0.into(),
width: 0.0,
color: Color::TRANSPARENT,
},
..iced::widget::button::Style::default()
}
}
pub fn button_danger(
_: &iced::Theme,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let base = match status {
iced::widget::button::Status::Active => Color::from_rgba(1.0, 0.267, 0.4, 0.15),
iced::widget::button::Status::Hovered => Color::from_rgba(1.0, 0.267, 0.4, 0.25),
iced::widget::button::Status::Pressed => Color::from_rgba(1.0, 0.267, 0.4, 0.35),
iced::widget::button::Status::Disabled => Color::TRANSPARENT,
};
let text = match status {
iced::widget::button::Status::Disabled => TEXT_MUTED,
_ => STATUS_ERROR,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(base)),
text_color: text,
border: iced::Border {
radius: 4.0.into(),
width: 1.0,
color: Color::from_rgba(1.0, 0.267, 0.4, 0.2),
},
..iced::widget::button::Style::default()
}
}
pub fn button_secondary(
_: &iced::Theme,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let bg = match status {
iced::widget::button::Status::Active => HOVER,
iced::widget::button::Status::Hovered => HOVER_STRONG,
iced::widget::button::Status::Pressed => Color::from_rgba(0.808, 0.804, 0.765, 0.12),
iced::widget::button::Status::Disabled => Color::TRANSPARENT,
};
let text = match status {
iced::widget::button::Status::Disabled => TEXT_MUTED,
_ => TEXT_PRIMARY,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(bg)),
text_color: text,
border: iced::Border {
radius: 4.0.into(),
width: 1.0,
color: BORDER,
},
..iced::widget::button::Style::default()
}
}
pub fn button_text_danger(
_: &iced::Theme,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let bg = match status {
iced::widget::button::Status::Hovered => HOVER,
iced::widget::button::Status::Pressed => HOVER_STRONG,
_ => Color::TRANSPARENT,
};
let text = match status {
iced::widget::button::Status::Disabled => TEXT_MUTED,
_ => STATUS_ERROR,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(bg)),
text_color: text,
border: iced::Border {
radius: 4.0.into(),
width: 0.0,
color: Color::TRANSPARENT,
},
..iced::widget::button::Style::default()
}
}
pub fn button_text(
_: &iced::Theme,
status: iced::widget::button::Status,
) -> iced::widget::button::Style {
let bg = match status {
iced::widget::button::Status::Hovered => HOVER,
iced::widget::button::Status::Pressed => HOVER_STRONG,
_ => Color::TRANSPARENT,
};
let text = match status {
iced::widget::button::Status::Disabled => TEXT_MUTED,
_ => TEXT_PRIMARY,
};
iced::widget::button::Style {
background: Some(iced::Background::Color(bg)),
text_color: text,
border: iced::Border {
radius: 4.0.into(),
width: 0.0,
color: Color::TRANSPARENT,
},
..iced::widget::button::Style::default()
}
}
#[must_use]
pub fn tooltip_style(_theme: &iced::Theme) -> container::Style {
container::Style {
background: Some(iced::Background::Color(BG_ELEVATED)),
border: iced::Border {
radius: 6.0.into(),
width: 1.0,
color: BORDER_STRONG,
},
..container::Style::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_names_match() {
for role in <crate::Role as strum::IntoEnumIterator>::iter() {
let name = role.as_str();
assert_eq!(
role_badge_color_for(&role),
role_badge_color(name),
"role_badge_color_for and role_badge_color must agree for {name}"
);
}
}
#[test]
fn derivative_analyst_names_get_correct_color() {
let analyst_color = role_badge_color_for(&crate::Role::Analyst);
assert_eq!(role_badge_color("analyst_1"), analyst_color);
assert_eq!(role_badge_color("analyst_2"), analyst_color);
assert_eq!(role_badge_color("analyst_3"), analyst_color);
}
#[test]
fn derivative_other_role_names_get_correct_color() {
for role in <crate::Role as strum::IntoEnumIterator>::iter() {
let name = role.as_str();
let expected = role_badge_color_for(&role);
assert_eq!(
role_badge_color(&format!("{name}_1")),
expected,
"derivative {name}_1 should match role_badge_color_for"
);
assert_eq!(
role_badge_color(&format!("{name}_42")),
expected,
"derivative {name}_42 should match role_badge_color_for"
);
}
}
#[test]
fn non_numeric_suffix_is_unknown() {
assert_eq!(role_badge_color("analyst_final"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("coder_abc"), (TEXT_MUTED, HOVER));
}
#[test]
fn llm_api_roles_are_unknown() {
assert_eq!(role_badge_color("user"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("assistant"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("system"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("tool"), (TEXT_MUTED, HOVER));
}
#[test]
fn empty_and_garbage_are_unknown() {
assert_eq!(role_badge_color(""), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("garbage"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("unknown_role"), (TEXT_MUTED, HOVER));
assert_eq!(role_badge_color("_1"), (TEXT_MUTED, HOVER));
}
#[test]
fn case_insensitive_parse() {
let analyst_color = role_badge_color_for(&crate::Role::Analyst);
assert_eq!(role_badge_color("ANALYST"), analyst_color);
assert_eq!(role_badge_color("Analyst"), analyst_color);
assert_eq!(role_badge_color("ANALYST_1"), analyst_color);
}
}