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}