Skip to main content

teams_cli/
tui.rs

1use std::io::{self, IsTerminal};
2use std::time::Duration;
3
4use crossterm::event::{self, Event, KeyCode, KeyEventKind};
5use crossterm::execute;
6use crossterm::terminal::{
7    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
8};
9use ratatui::Terminal;
10use ratatui::backend::{CrosstermBackend, TestBackend};
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
15
16use crate::error::AppError;
17
18const INK: Color = Color::Rgb(230, 233, 239);
19const MUTED: Color = Color::Rgb(151, 158, 171);
20const FOCUS: Color = Color::Rgb(102, 194, 255);
21const LIVE: Color = Color::Rgb(255, 119, 107);
22const SURFACE: Color = Color::Rgb(20, 23, 30);
23
24#[derive(Debug, Clone)]
25pub struct TuiData {
26    pub account: String,
27    pub conversations: Vec<Conversation>,
28}
29
30#[derive(Debug, Clone)]
31pub struct Conversation {
32    pub name: String,
33    pub unread: u16,
34    pub messages: Vec<Message>,
35}
36
37#[derive(Debug, Clone)]
38pub struct Message {
39    pub sender: String,
40    pub time: String,
41    pub body: String,
42}
43
44pub fn demo_data() -> TuiData {
45    TuiData {
46        account: "Alex Morgan · Northstar Studio".into(),
47        conversations: vec![
48            Conversation {
49                name: "Launch room".into(),
50                unread: 3,
51                messages: vec![
52                    Message {
53                        sender: "Mina".into(),
54                        time: "09:42".into(),
55                        body: "The release candidate is green. I pinned the final checklist."
56                            .into(),
57                    },
58                    Message {
59                        sender: "Jon".into(),
60                        time: "09:46".into(),
61                        body: "Docs are ready. One broken link remains in the migration guide."
62                            .into(),
63                    },
64                    Message {
65                        sender: "You".into(),
66                        time: "09:48".into(),
67                        body: "I’ll take it. Hold the announcement until I clear the link check."
68                            .into(),
69                    },
70                ],
71            },
72            Conversation {
73                name: "Design crit".into(),
74                unread: 0,
75                messages: vec![],
76            },
77            Conversation {
78                name: "Ari Chen".into(),
79                unread: 1,
80                messages: vec![],
81            },
82            Conversation {
83                name: "Platform on-call".into(),
84                unread: 0,
85                messages: vec![],
86            },
87        ],
88    }
89}
90
91struct App {
92    data: TuiData,
93    selected: usize,
94}
95
96pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
97    let backend = TestBackend::new(width.max(48), height.max(18));
98    let mut terminal = Terminal::new(backend).expect("test backend is infallible");
99    let app = App {
100        data: demo_data(),
101        selected: 0,
102    };
103    terminal
104        .draw(|frame| draw(frame, &app))
105        .expect("test backend is infallible");
106    let buffer = terminal.backend().buffer();
107    let mut output = String::new();
108    for y in 0..buffer.area.height {
109        let mut line = String::new();
110        for x in 0..buffer.area.width {
111            line.push_str(buffer[(x, y)].symbol());
112        }
113        output.push_str(line.trim_end());
114        output.push('\n');
115    }
116    while output.ends_with("\n\n") {
117        output.pop();
118    }
119    Ok(output)
120}
121
122pub fn run(data: TuiData) -> Result<(), AppError> {
123    if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
124        return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
125    }
126    enable_raw_mode()?;
127    let mut stdout = io::stdout();
128    execute!(stdout, EnterAlternateScreen)?;
129    let backend = CrosstermBackend::new(stdout);
130    let mut terminal = Terminal::new(backend)?;
131    let mut app = App { data, selected: 0 };
132    let result = (|| -> Result<(), AppError> {
133        loop {
134            terminal.draw(|frame| draw(frame, &app))?;
135            if event::poll(Duration::from_millis(150))?
136                && let Event::Key(key) = event::read()?
137            {
138                if key.kind != KeyEventKind::Press {
139                    continue;
140                }
141                match key.code {
142                    KeyCode::Char('q') | KeyCode::Esc => break,
143                    KeyCode::Down | KeyCode::Char('j') => {
144                        app.selected =
145                            (app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
146                    }
147                    KeyCode::Up | KeyCode::Char('k') => {
148                        app.selected = app.selected.saturating_sub(1)
149                    }
150                    _ => {}
151                }
152            }
153        }
154        Ok(())
155    })();
156    let cleanup = (|| -> Result<(), AppError> {
157        disable_raw_mode()?;
158        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
159        terminal.show_cursor()?;
160        Ok(())
161    })();
162    result.and(cleanup)
163}
164
165fn draw(frame: &mut ratatui::Frame, app: &App) {
166    let area = frame.area();
167    let vertical = Layout::default()
168        .direction(Direction::Vertical)
169        .constraints([
170            Constraint::Length(3),
171            Constraint::Min(10),
172            Constraint::Length(2),
173        ])
174        .split(area);
175    header(frame, vertical[0], app);
176    if area.width < 76 {
177        conversation(frame, vertical[1], app);
178    } else {
179        let body = Layout::default()
180            .direction(Direction::Horizontal)
181            .constraints([Constraint::Length(28), Constraint::Min(44)])
182            .split(vertical[1]);
183        rail(frame, body[0], app);
184        conversation(frame, body[1], app);
185    }
186    let footer = Paragraph::new(Line::from(vec![
187        Span::styled(
188            " j/k ",
189            Style::default()
190                .fg(Color::Black)
191                .bg(FOCUS)
192                .add_modifier(Modifier::BOLD),
193        ),
194        Span::styled(" move   ", Style::default().fg(MUTED)),
195        Span::styled("q", Style::default().fg(INK)),
196        Span::styled(" quit", Style::default().fg(MUTED)),
197    ]))
198    .style(Style::default().bg(SURFACE));
199    frame.render_widget(footer, vertical[2]);
200}
201
202fn header(frame: &mut ratatui::Frame, area: Rect, app: &App) {
203    let title = Paragraph::new(Line::from(vec![
204        Span::styled(
205            " teams ",
206            Style::default()
207                .fg(Color::Black)
208                .bg(LIVE)
209                .add_modifier(Modifier::BOLD),
210        ),
211        Span::styled(
212            "  INBOX",
213            Style::default().fg(INK).add_modifier(Modifier::BOLD),
214        ),
215        Span::styled(
216            format!("   {}", app.data.account),
217            Style::default().fg(MUTED),
218        ),
219    ]))
220    .block(
221        Block::default()
222            .borders(Borders::BOTTOM)
223            .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
224    );
225    frame.render_widget(title, area);
226}
227
228fn rail(frame: &mut ratatui::Frame, area: Rect, app: &App) {
229    let items = app
230        .data
231        .conversations
232        .iter()
233        .enumerate()
234        .map(|(index, conversation)| {
235            let marker = if index == app.selected { "›" } else { " " };
236            let unread = if conversation.unread > 0 {
237                format!("  {}", conversation.unread)
238            } else {
239                String::new()
240            };
241            let style = if index == app.selected {
242                Style::default().fg(FOCUS).add_modifier(Modifier::BOLD)
243            } else {
244                Style::default().fg(INK)
245            };
246            ListItem::new(Line::from(vec![
247                Span::styled(format!(" {marker} {}", conversation.name), style),
248                Span::styled(unread, Style::default().fg(LIVE)),
249            ]))
250        });
251    frame.render_widget(
252        List::new(items).block(
253            Block::default()
254                .title(" Conversations ")
255                .borders(Borders::RIGHT)
256                .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
257        ),
258        area,
259    );
260}
261
262fn conversation(frame: &mut ratatui::Frame, area: Rect, app: &App) {
263    let conversation = &app.data.conversations[app.selected];
264    let mut lines = vec![
265        Line::styled(
266            conversation.name.clone(),
267            Style::default().fg(INK).add_modifier(Modifier::BOLD),
268        ),
269        Line::styled("Channel · 12 members", Style::default().fg(MUTED)),
270        Line::raw(""),
271    ];
272    if conversation.messages.is_empty() {
273        lines.push(Line::styled(
274            "No recent messages",
275            Style::default().fg(MUTED),
276        ));
277        lines.push(Line::styled(
278            "Press c to start the conversation.",
279            Style::default().fg(MUTED),
280        ));
281    } else {
282        for message in &conversation.messages {
283            lines.push(Line::from(vec![
284                Span::styled(
285                    &message.sender,
286                    Style::default().fg(FOCUS).add_modifier(Modifier::BOLD),
287                ),
288                Span::styled(format!("  {}", message.time), Style::default().fg(MUTED)),
289            ]));
290            lines.push(Line::styled(&message.body, Style::default().fg(INK)));
291            lines.push(Line::raw(""));
292        }
293    }
294    let block = Block::default()
295        .title(" Now ")
296        .borders(Borders::NONE)
297        .padding(ratatui::widgets::Padding::horizontal(2));
298    frame.render_widget(
299        Paragraph::new(lines)
300            .wrap(Wrap { trim: false })
301            .block(block),
302        area,
303    );
304}
305
306#[cfg(test)]
307mod tests {
308    #[test]
309    fn snapshot_contains_real_states() {
310        let frame = super::snapshot(90, 26).unwrap();
311        assert!(frame.contains("Launch room"));
312        assert!(frame.contains("release candidate is green"));
313        assert!(frame.contains("q quit"));
314    }
315}