Skip to main content

schwab_cli/ui/
mod.rs

1pub mod chart_markers;
2pub mod agent_health;
3pub mod context;
4pub mod dashboard;
5pub mod discover;
6pub mod market_status;
7pub mod menu;
8pub mod positions_panel;
9pub mod rules_view;
10pub mod spread_feed;
11pub mod spread_live;
12pub mod spread_payoff;
13pub mod theme;
14pub mod tui_render;
15pub mod watch;
16
17use console::Style;
18use unicode_width::UnicodeWidthStr;
19
20/// Visible terminal width (fallback 100).
21pub fn terminal_width() -> usize {
22    console::Term::stdout().size().1.max(80) as usize
23}
24
25/// Horizontal rule with centered title (furoshiki-style).
26pub fn rule(title: &str) -> String {
27    let width = terminal_width().min(120);
28    let plain_len = UnicodeWidthStr::width(title);
29    let pad = width.saturating_sub(plain_len + 4);
30    let left = pad / 2;
31    let right = pad - left;
32    format!(
33        "{}{}{}",
34        "─".repeat(left),
35        Style::new().bold().apply_to(format!(" {title} ")),
36        "─".repeat(right)
37    )
38}
39
40/// Unicode progress bar (█░).
41pub fn bar(ratio: f64, width: usize) -> String {
42    let ratio = ratio.clamp(0.0, 1.0);
43    let filled = (ratio * width as f64).round() as usize;
44    let empty = width.saturating_sub(filled);
45    format!(
46        "{}{}",
47        Style::new().green().apply_to("█".repeat(filled)),
48        Style::new().dim().apply_to("░".repeat(empty))
49    )
50}
51
52pub fn status_dot(running: bool) -> String {
53    if running {
54        Style::new().green().apply_to("●").to_string()
55    } else {
56        Style::new().red().apply_to("○").to_string()
57    }
58}
59
60pub fn clock_dot() -> String {
61    Style::new().dim().apply_to("◷").to_string()
62}
63
64/// Outer width of a panel that fits its content (capped at `max_width`).
65pub fn panel_width_for(title: &str, lines: &[String], max_width: usize) -> usize {
66    let title_plain = format!(" {title} ");
67    let title_need = UnicodeWidthStr::width(title_plain.as_str()) + 2;
68    let content_need = lines
69        .iter()
70        .map(|l| strip_ansi_width(l) + 4)
71        .max()
72        .unwrap_or(0);
73    title_need.max(content_need).clamp(28, max_width)
74}
75
76/// Rounded panel with title; `width` is total outer width including corners.
77pub fn panel(title: &str, lines: &[String], width: usize) -> String {
78    let width = width.max(28);
79    let border_inner = width.saturating_sub(2);
80    let title_plain = format!(" {title} ");
81    let title_w = UnicodeWidthStr::width(title_plain.as_str());
82    let dash_total = border_inner.saturating_sub(title_w);
83    let dash_left = dash_total / 2;
84    let dash_right = dash_total - dash_left;
85
86    let mut out = String::new();
87    out.push('╭');
88    out.push_str(&"─".repeat(dash_left));
89    out.push_str(&Style::new().bold().apply_to(&title_plain).to_string());
90    out.push_str(&"─".repeat(dash_right));
91    out.push_str("╮\n");
92
93    for line in lines {
94        let visible = strip_ansi_width(line);
95        let pad = width.saturating_sub(visible + 4);
96        out.push('│');
97        out.push(' ');
98        out.push_str(line);
99        out.push_str(&" ".repeat(pad));
100        out.push(' ');
101        out.push_str("│\n");
102    }
103
104    out.push('╰');
105    out.push_str(&"─".repeat(border_inner));
106    out.push('╯');
107    out
108}
109
110/// Panel sized to content, not full terminal width.
111pub fn panel_fit(title: &str, lines: &[String], max_width: usize) -> String {
112    let width = panel_width_for(title, lines, max_width);
113    panel(title, lines, width)
114}
115
116/// Key / value line with aligned columns inside a panel.
117pub fn kv_line(key: &str, value: &str, key_width: usize) -> String {
118    format!("  {key:key_width$}  {value}", key_width = key_width)
119}
120
121/// Two panels side by side (each column is one panel's line sequence).
122pub fn two_column(left: String, right: String, total_width: usize) -> String {
123    let gap = 2;
124    let col_w = (total_width.saturating_sub(gap)) / 2;
125    let left_lines: Vec<&str> = left.lines().collect();
126    let right_lines: Vec<&str> = right.lines().collect();
127    let rows = left_lines.len().max(right_lines.len());
128
129    let mut out = String::new();
130    for i in 0..rows {
131        let l = left_lines.get(i).copied().unwrap_or("");
132        let r = right_lines.get(i).copied().unwrap_or("");
133        let l_vis = strip_ansi_width(l);
134        let pad = col_w.saturating_sub(l_vis);
135        out.push_str(l);
136        out.push_str(&" ".repeat(pad));
137        out.push_str(&" ".repeat(gap));
138        out.push_str(r);
139        out.push('\n');
140    }
141    out.trim_end().to_string()
142}
143
144pub fn ago_secs(secs: i64) -> String {
145    if secs < 0 {
146        return "just now".into();
147    }
148    if secs < 60 {
149        return format!("{secs}s ago");
150    }
151    if secs < 3600 {
152        return format!("{}m ago", secs / 60);
153    }
154    if secs < 86_400 {
155        return format!("{}h ago", secs / 3600);
156    }
157    format!("{}d ago", secs / 86_400)
158}
159
160pub fn format_duration_secs(secs: u64) -> String {
161    if secs < 60 {
162        format!("{secs}s")
163    } else if secs < 3600 {
164        format!("{}m", secs / 60)
165    } else {
166        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
167    }
168}
169
170fn strip_ansi_width(s: &str) -> usize {
171    let stripped = console::strip_ansi_codes(s);
172    UnicodeWidthStr::width(stripped.as_ref())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn bar_clamps() {
181        assert!(bar(1.5, 8).contains('█'));
182        assert!(bar(0.0, 8).contains('░'));
183    }
184
185    #[test]
186    fn panel_has_corners() {
187        let p = panel("Test", &["line".into()], 40);
188        assert!(p.starts_with('╭'));
189        assert!(p.contains('╯'));
190    }
191
192    #[test]
193    fn panel_lines_match_border_width() {
194        let lines = vec!["  tick interval     2m (120)".into()];
195        let p = panel_fit("Schedule", &lines, 120);
196        let border_len = p.lines().next().unwrap().chars().count();
197        for line in p.lines().skip(1) {
198            if line.starts_with('│') {
199                assert_eq!(
200                    line.chars().count(),
201                    border_len,
202                    "mismatched line width: {line}"
203                );
204            }
205        }
206    }
207
208    #[test]
209    fn panel_fit_shrinks_to_content() {
210        let lines = vec!["  short".into()];
211        let w = panel_width_for("Accounts", &lines, 120);
212        assert!(w < 80, "expected compact panel, got width {w}");
213    }
214}