Codex Handbook
tui/src/bottom_pane/status_line_style.rs 300 lines
//! Theme-derived styling for the configurable footer statusline.use ratatui::prelude::Stylize;use ratatui::style::Color;use ratatui::style::Style;use ratatui::text::Line;use ratatui::text::Span;use super::status_line_setup::StatusLineItem;use crate::render::highlight::foreground_style_for_scopes;const STATUS_LINE_SEPARATOR: &str = " · ";const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85;const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100;#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum StatusLineAccent {    Model,    Path,    Branch,    State,    Usage,    Limit,    Metadata,    Mode,    Thread,    Progress,}impl StatusLineAccent {    fn for_item(item: StatusLineItem) -> Self {        match item {            StatusLineItem::ModelName            | StatusLineItem::ModelWithReasoning            | StatusLineItem::Reasoning => Self::Model,            StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path,            StatusLineItem::GitBranch            | StatusLineItem::PullRequestNumber            | StatusLineItem::BranchChanges => Self::Branch,            StatusLineItem::Status => Self::State,            StatusLineItem::ContextRemaining            | StatusLineItem::ContextUsed            | StatusLineItem::ContextWindowSize            | StatusLineItem::UsedTokens            | StatusLineItem::TotalInputTokens            | StatusLineItem::TotalOutputTokens => Self::Usage,            StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit,            StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata,            StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode,            StatusLineItem::Permissions => Self::Mode,            StatusLineItem::ApprovalMode => Self::Mode,            StatusLineItem::ThreadTitle => Self::Thread,            StatusLineItem::TaskProgress => Self::Progress,        }    }    fn scopes(self) -> &'static [&'static str] {        match self {            Self::Model => &["entity.name.type", "support.type", "variable"],            Self::Path => &["string", "markup.underline.link"],            Self::Branch => &["entity.name.function", "entity.name.tag"],            Self::State => &["keyword.control", "keyword"],            Self::Usage => &["constant.numeric", "constant"],            Self::Limit => &["constant.language", "storage.type"],            Self::Metadata => &["comment", "constant.other"],            Self::Mode => &["storage.modifier", "keyword.operator"],            Self::Thread => &["markup.heading", "entity.name.section"],            Self::Progress => &["markup.inserted", "constant.numeric"],        }    }    fn fallback_style(self) -> Style {        match self {            Self::Model | Self::State | Self::Metadata | Self::Mode => Style::default().cyan(),            Self::Path | Self::Usage | Self::Progress => Style::default().green(),            Self::Branch | Self::Limit | Self::Thread => Style::default().magenta(),        }    }}pub(crate) fn status_line_from_segments<I>(    segments: I,    use_theme_colors: bool,) -> Option<Line<'static>>where    I: IntoIterator<Item = (StatusLineItem, String)>,{    status_line_from_segments_with_resolver(segments, use_theme_colors, |accent| {        foreground_style_for_scopes(accent.scopes())    })}fn status_line_from_segments_with_resolver<I, F>(    segments: I,    use_theme_colors: bool,    theme_style_for_accent: F,) -> Option<Line<'static>>where    I: IntoIterator<Item = (StatusLineItem, String)>,    F: Fn(StatusLineAccent) -> Option<Style>,{    let mut spans = Vec::new();    for (item, text) in segments {        if !spans.is_empty() {            spans.push(STATUS_LINE_SEPARATOR.dim());        }        let style = if use_theme_colors {            let accent = StatusLineAccent::for_item(item);            soften_status_line_style(                theme_style_for_accent(accent).unwrap_or_else(|| accent.fallback_style()),            )        } else {            Style::default().dim()        };        let style = if item == StatusLineItem::PullRequestNumber {            style.underlined()        } else {            style        };        spans.push(Span::styled(text, style));    }    (!spans.is_empty()).then(|| Line::from(spans))}fn soften_status_line_style(mut style: Style) -> Style {    if let Some(fg) = style.fg {        style.fg = Some(soften_status_line_color(fg));    }    style}#[allow(clippy::disallowed_methods)]fn soften_status_line_color(color: Color) -> Color {    match color {        Color::Rgb(r, g, b) => {            let luma = weighted_luma(r, g, b);            Color::Rgb(                soften_rgb_channel(r, luma),                soften_rgb_channel(g, luma),                soften_rgb_channel(b, luma),            )        }        Color::LightRed => Color::Red,        Color::LightGreen => Color::Green,        Color::LightYellow => Color::Yellow,        Color::LightBlue => Color::Blue,        Color::LightMagenta => Color::Magenta,        Color::LightCyan => Color::Cyan,        Color::White => Color::Gray,        Color::Reset        | Color::Black        | Color::Red        | Color::Green        | Color::Yellow        | Color::Blue        | Color::Magenta        | Color::Cyan        | Color::Gray        | Color::DarkGray        | Color::Indexed(_) => color,    }}fn weighted_luma(r: u8, g: u8, b: u8) -> u16 {    (77 * u16::from(r) + 150 * u16::from(g) + 29 * u16::from(b)) / 256}fn soften_rgb_channel(channel: u8, luma: u16) -> u8 {    let channel = u16::from(channel);    let softened = (channel * STATUS_LINE_COLOR_SATURATION_PERCENT        + luma * (100 - STATUS_LINE_COLOR_SATURATION_PERCENT)        + 50)        / 100;    ((softened * STATUS_LINE_COLOR_BRIGHTNESS_PERCENT + 50) / 100) as u8}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    use ratatui::style::Modifier;    fn line_text(line: &Line<'static>) -> String {        line.spans            .iter()            .map(|span| span.content.as_ref())            .collect::<String>()    }    #[test]    fn status_line_segments_preserve_order_and_plain_text() {        let line = status_line_from_segments_with_resolver(            [                (StatusLineItem::ModelName, "gpt-5".to_string()),                (StatusLineItem::CurrentDir, "/repo".to_string()),                (StatusLineItem::GitBranch, "main".to_string()),            ],            /*use_theme_colors*/ true,            |_| None,        )        .expect("status line");        assert_eq!(line_text(&line), "gpt-5 · /repo · main");        assert_eq!(line.spans[0].style.fg, Some(Color::Cyan));        assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));        assert_eq!(line.spans[2].style.fg, Some(Color::Green));        assert!(!line.spans[2].style.add_modifier.contains(Modifier::DIM));        assert_eq!(line.spans[4].style.fg, Some(Color::Magenta));        assert!(!line.spans[4].style.add_modifier.contains(Modifier::DIM));    }    #[test]    fn status_line_segments_dim_separators_and_use_theme_styles_first() {        let line = status_line_from_segments_with_resolver(            [                (StatusLineItem::ModelName, "gpt-5".to_string()),                (StatusLineItem::ContextUsed, "Context 12% used".to_string()),            ],            /*use_theme_colors*/ true,            |accent| match accent {                StatusLineAccent::Model => Some(Style::default().red()),                _ => None,            },        )        .expect("status line");        assert_eq!(line.spans[0].style.fg, Some(Color::Red));        assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));        assert!(line.spans[1].style.add_modifier.contains(Modifier::DIM));        assert_eq!(line.spans[2].style.fg, Some(Color::Green));        assert!(!line.spans[2].style.add_modifier.contains(Modifier::DIM));    }    #[test]    #[allow(clippy::disallowed_methods)]    fn status_line_segments_soften_rgb_theme_styles_without_dimming_text() {        let line = status_line_from_segments_with_resolver(            [(StatusLineItem::ModelName, "gpt-5".to_string())],            /*use_theme_colors*/ true,            |_| Some(Style::default().fg(Color::Rgb(255, 0, 0))),        )        .expect("status line");        assert_eq!(line.spans[0].style.fg, Some(Color::Rgb(228, 11, 11)));        assert!(!line.spans[0].style.add_modifier.contains(Modifier::DIM));    }    #[test]    fn status_line_segments_can_disable_theme_colors() {        let line = status_line_from_segments_with_resolver(            [                (StatusLineItem::ModelName, "gpt-5".to_string()),                (StatusLineItem::ContextUsed, "Context 12% used".to_string()),            ],            /*use_theme_colors*/ false,            |_| Some(Style::default().red()),        )        .expect("status line");        assert_eq!(line_text(&line), "gpt-5 · Context 12% used");        assert_eq!(line.spans[0].style.fg, None);        assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM));        assert!(line.spans[1].style.add_modifier.contains(Modifier::DIM));        assert_eq!(line.spans[2].style.fg, None);        assert!(line.spans[2].style.add_modifier.contains(Modifier::DIM));    }    #[test]    fn pull_request_number_uses_link_style() {        let line = status_line_from_segments_with_resolver(            [(StatusLineItem::PullRequestNumber, "PR #20252".to_string())],            /*use_theme_colors*/ false,            |_| None,        )        .expect("status line");        assert_eq!(line.spans[0].style.fg, None);        assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM));        assert!(            line.spans[0]                .style                .add_modifier                .contains(Modifier::UNDERLINED)        );    }    #[test]    fn status_line_segments_return_none_when_empty() {        assert_eq!(            status_line_from_segments_with_resolver(                Vec::<(StatusLineItem, String)>::new(),                /*use_theme_colors*/ true,                |_| None,            ),            None        );    }}