Skip to main content

stynx_code_tui/widgets/
footer.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Paragraph, Widget},
7};
8
9use crate::theme;
10use crate::widgets::spinner::FRAMES;
11
12/// Powerline separators (Nerd Font) — matches the user's tmux Rosé Pine bar.
13const SEP_RIGHT: &str = "\u{E0B0}"; //
14const SEP_LEFT: &str = "\u{E0B2}"; //
15
16pub struct Footer<'a> {
17    pub cwd: &'a str,
18    pub model: &'a str,
19    pub mode: &'a str,
20    pub cost: f64,
21    pub git_branch: Option<&'a str>,
22    pub is_streaming: bool,
23    pub is_pending: bool,
24    pub is_paused: bool,
25    pub spinner_frame: usize,
26    pub elapsed_secs: u64,
27}
28
29fn shrink_path(cwd: &str, max: usize) -> String {
30    let with_tilde = if let Some(home) = std::env::var_os("HOME") {
31        if let Some(home) = home.to_str() {
32            if cwd.starts_with(home) {
33                format!("~{}", &cwd[home.len()..])
34            } else {
35                cwd.to_string()
36            }
37        } else {
38            cwd.to_string()
39        }
40    } else {
41        cwd.to_string()
42    };
43
44    if with_tilde.len() <= max {
45        return with_tilde;
46    }
47    let take = max.saturating_sub(1);
48    let start = with_tilde.len().saturating_sub(take);
49    format!("…{}", &with_tilde[start..])
50}
51
52fn pretty_model(model: &str) -> String {
53    let s = model.trim_start_matches("claude-");
54    s.split('-').collect::<Vec<_>>().join("·")
55}
56
57fn fmt_elapsed_short(secs: u64) -> String {
58    if secs >= 60 {
59        format!("{}m {}s", secs / 60, secs % 60)
60    } else {
61        format!("{secs}s")
62    }
63}
64
65/// A single lualine/tmux-style segment: padded text on a solid color block.
66struct Seg {
67    text: String,
68    fg: Color,
69    bg: Color,
70    bold: bool,
71    italic: bool,
72}
73
74impl Seg {
75    fn new(text: impl Into<String>, fg: Color, bg: Color) -> Self {
76        Self { text: text.into(), fg, bg, bold: false, italic: false }
77    }
78    fn bold(mut self) -> Self {
79        self.bold = true;
80        self
81    }
82    fn italic(mut self) -> Self {
83        self.italic = true;
84        self
85    }
86    fn style(&self) -> Style {
87        let mut s = Style::default().fg(self.fg).bg(self.bg);
88        if self.bold {
89            s = s.add_modifier(Modifier::BOLD);
90        }
91        if self.italic {
92            s = s.add_modifier(Modifier::ITALIC);
93        }
94        s
95    }
96}
97
98/// Left-aligned bar: blocks grow rightward, joined with `` separators
99/// that fade each block's bg into the next.
100fn build_left(segs: &[Seg], bar_bg: Color) -> Line<'static> {
101    let mut spans: Vec<Span<'static>> = Vec::new();
102    for (i, seg) in segs.iter().enumerate() {
103        spans.push(Span::styled(format!(" {} ", seg.text), seg.style()));
104        let next_bg = segs.get(i + 1).map(|s| s.bg).unwrap_or(bar_bg);
105        spans.push(Span::styled(
106            SEP_RIGHT,
107            Style::default().fg(seg.bg).bg(next_bg),
108        ));
109    }
110    Line::from(spans)
111}
112
113/// Right-aligned bar: blocks grow leftward, each prefixed with a ``
114/// separator that fades the previous bg into this block's bg.
115fn build_right(segs: &[Seg], bar_bg: Color) -> Line<'static> {
116    let mut spans: Vec<Span<'static>> = Vec::new();
117    for (i, seg) in segs.iter().enumerate() {
118        let prev_bg = if i == 0 { bar_bg } else { segs[i - 1].bg };
119        spans.push(Span::styled(
120            SEP_LEFT,
121            Style::default().fg(seg.bg).bg(prev_bg),
122        ));
123        spans.push(Span::styled(format!(" {} ", seg.text), seg.style()));
124    }
125    Line::from(spans)
126}
127
128impl<'a> Widget for Footer<'a> {
129    fn render(self, area: Rect, buf: &mut Buffer) {
130        // Match tmux `status-style 'bg=#191724 fg=#e0def4'` (base / text).
131        let bar_bg = theme::BASE();
132        let block_bg = theme::OVERLAY(); // #26233a — inactive / section blocks
133        let accent = theme::IRIS(); // #c4a7e7 — active tab + section_z
134        let base = theme::BASE();
135
136        for x in area.x..area.x + area.width {
137            buf[(x, area.y)].set_style(Style::default().bg(bar_bg));
138        }
139
140        // ---- left: [ mode? ]  [ path ]  [ branch ] ----
141        let mut left_segs: Vec<Seg> = Vec::new();
142
143        // Mode block — only for non-default modes (hidden in Normal so the bar
144        // stays clean), colored like its shift+tab cycle.
145        if self.mode != "Normal" {
146            let (icon, color) = match self.mode {
147                "Auto-accept" => ("\u{26A1}", theme::GOLD()), // ⚡
148                "Plan" => ("\u{25C6}", theme::IRIS()),        // ◆
149                "Bypass" => ("\u{26A0}", theme::LOVE()),      // ⚠
150                _ => ("\u{25CF}", theme::FOAM()),             // ●
151            };
152            left_segs.push(Seg::new(format!("{icon} {}", self.mode), base, color).bold());
153        }
154
155        // Path is the "active window" block (iris bg, base fg, bold).
156        let max_path = (area.width as usize).saturating_sub(46).max(10);
157        left_segs.push(
158            Seg::new(
159                format!("\u{F07C} {}", shrink_path(self.cwd, max_path)),
160                base,
161                accent,
162            )
163            .bold(),
164        );
165
166        if let Some(branch) = self.git_branch {
167            left_segs.push(Seg::new(
168                format!("\u{E0A0} {branch}"),
169                theme::FOAM(),
170                block_bg,
171            ));
172        }
173
174        // ---- right: [ status ]  [ model ]  [ cost ] ----
175        let mut right_segs: Vec<Seg> = Vec::new();
176
177        if self.is_paused {
178            right_segs.push(
179                Seg::new("\u{23F8} paused", theme::GOLD(), block_bg)
180                    .bold()
181                    .italic(),
182            );
183        } else if self.is_pending {
184            let ch = FRAMES[self.spinner_frame % FRAMES.len()];
185            right_segs.push(
186                Seg::new(format!("{ch} connecting…"), theme::SUBTLE(), block_bg).italic(),
187            );
188        } else if self.is_streaming {
189            let ch = FRAMES[self.spinner_frame % FRAMES.len()];
190            let elapsed = fmt_elapsed_short(self.elapsed_secs);
191            right_segs.push(
192                Seg::new(
193                    format!("{ch} generating  {elapsed}"),
194                    theme::FOAM(),
195                    block_bg,
196                )
197                .italic(),
198            );
199        }
200
201        right_segs.push(
202            Seg::new(
203                format!("\u{25C7} {}", pretty_model(self.model)),
204                theme::TEXT(),
205                block_bg,
206            )
207            .bold(),
208        );
209
210        // Cost is the section_z block — iris, mirroring the path block.
211        right_segs.push(Seg::new(format!("${:.4}", self.cost), base, accent).bold());
212
213        let left = build_left(&left_segs, bar_bg);
214        let right = build_right(&right_segs, bar_bg);
215        let left_width = left.width() as u16;
216        let right_width = right.width() as u16;
217
218        Paragraph::new(left)
219            .style(Style::default().bg(bar_bg))
220            .render(Rect { width: left_width.min(area.width), ..area }, buf);
221
222        if area.width > right_width {
223            let right_area = Rect {
224                x: area.x + area.width - right_width,
225                y: area.y,
226                width: right_width,
227                height: 1,
228            };
229            Paragraph::new(right)
230                .style(Style::default().bg(bar_bg))
231                .render(right_area, buf);
232        }
233    }
234}