Skip to main content

stynx_code_tui/widgets/
footer.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Paragraph, Widget},
7};
8
9use crate::theme;
10use crate::widgets::spinner::FRAMES;
11
12pub struct Footer<'a> {
13    pub cwd: &'a str,
14    pub model: &'a str,
15    pub mode: &'a str,
16    pub cost: f64,
17    pub git_branch: Option<&'a str>,
18    pub is_streaming: bool,
19    pub is_paused: bool,
20    pub spinner_frame: usize,
21}
22
23fn shrink_path(cwd: &str, max: usize) -> String {
24    let with_tilde = if let Some(home) = std::env::var_os("HOME") {
25        if let Some(home) = home.to_str() {
26            if cwd.starts_with(home) {
27                format!("~{}", &cwd[home.len()..])
28            } else {
29                cwd.to_string()
30            }
31        } else {
32            cwd.to_string()
33        }
34    } else {
35        cwd.to_string()
36    };
37
38    if with_tilde.len() <= max {
39        return with_tilde;
40    }
41    let take = max.saturating_sub(1);
42    let start = with_tilde.len().saturating_sub(take);
43    format!("…{}", &with_tilde[start..])
44}
45
46fn pretty_model(model: &str) -> String {
47    let s = model.trim_start_matches("claude-");
48    s.split('-').collect::<Vec<_>>().join("·")
49}
50
51impl<'a> Widget for Footer<'a> {
52    fn render(self, area: Rect, buf: &mut Buffer) {
53        let bg = theme::SURFACE();
54        let fg_dim = theme::SUBTLE();
55        let dim_attr = Modifier::DIM;
56
57        for x in area.x..area.x + area.width {
58            buf[(x, area.y)].set_style(Style::default().bg(bg));
59        }
60
61        let max_path = (area.width as usize).saturating_sub(60).max(10);
62        let path = shrink_path(self.cwd, max_path);
63
64        let mut left_spans: Vec<Span<'static>> = vec![
65            Span::styled(" ", Style::default().bg(bg)),
66            Span::styled(
67                path,
68                Style::default().fg(theme::TEXT_MUTED()).bg(bg),
69            ),
70        ];
71
72        if let Some(branch) = self.git_branch {
73            left_spans.push(Span::styled(
74                "  ",
75                Style::default().fg(fg_dim).bg(bg).add_modifier(dim_attr),
76            ));
77            left_spans.push(Span::styled(
78                "\u{E0A0} ",
79                Style::default().fg(theme::SUCCESS()).bg(bg),
80            ));
81            left_spans.push(Span::styled(
82                branch.to_string(),
83                Style::default().fg(theme::SUCCESS()).bg(bg),
84            ));
85        }
86
87        let mut right_spans: Vec<Span<'static>> = Vec::new();
88        let sep = Span::styled(
89            "  ",
90            Style::default().fg(fg_dim).bg(bg).add_modifier(dim_attr),
91        );
92
93        if self.is_paused {
94            right_spans.push(Span::styled(
95                "⏸ ",
96                Style::default().fg(theme::WARNING()).bg(bg).add_modifier(Modifier::BOLD),
97            ));
98            right_spans.push(Span::styled(
99                "paused",
100                Style::default().fg(theme::WARNING()).bg(bg).add_modifier(Modifier::ITALIC),
101            ));
102            right_spans.push(sep.clone());
103        } else if self.is_streaming {
104            let ch = FRAMES[self.spinner_frame % FRAMES.len()];
105            right_spans.push(Span::styled(
106                format!("{ch} "),
107                Style::default().fg(theme::PRIMARY()).bg(bg).add_modifier(Modifier::BOLD),
108            ));
109            right_spans.push(Span::styled(
110                "generating",
111                Style::default().fg(theme::PRIMARY()).bg(bg).add_modifier(Modifier::ITALIC),
112            ));
113            right_spans.push(sep.clone());
114        }
115
116        let (mode_icon, mode_color) = match self.mode {
117            "Auto-accept" => ("⚡", theme::WARNING()),
118            "Plan" => ("◆", theme::PRIMARY()),
119            "Bypass" => ("⚠", theme::ERROR()),
120            _ => ("●", theme::ACCENT()),
121        };
122        right_spans.push(Span::styled(
123            format!("{mode_icon} "),
124            Style::default().fg(mode_color).bg(bg).add_modifier(Modifier::BOLD),
125        ));
126        right_spans.push(Span::styled(
127            self.mode.to_string(),
128            Style::default().fg(mode_color).bg(bg),
129        ));
130        right_spans.push(sep.clone());
131
132        right_spans.push(Span::styled(
133            "◇ ",
134            Style::default().fg(theme::IRIS()).bg(bg),
135        ));
136        right_spans.push(Span::styled(
137            pretty_model(self.model),
138            Style::default().fg(theme::IRIS()).bg(bg).add_modifier(Modifier::BOLD),
139        ));
140        right_spans.push(sep.clone());
141
142        right_spans.push(Span::styled(
143            format!("${:.4} ", self.cost),
144            Style::default().fg(theme::GOLD()).bg(bg),
145        ));
146
147        let left = Line::from(left_spans);
148        let right = Line::from(right_spans);
149        let right_width: u16 = right.width() as u16;
150        let left_width: u16 = left.width() as u16;
151
152        Paragraph::new(left)
153            .style(Style::default().bg(bg))
154            .render(Rect { width: left_width.min(area.width), ..area }, buf);
155
156        if area.width > right_width {
157            let right_area = Rect {
158                x: area.x + area.width - right_width,
159                y: area.y,
160                width: right_width,
161                height: 1,
162            };
163            Paragraph::new(right)
164                .style(Style::default().bg(bg))
165                .render(right_area, buf);
166        }
167    }
168}