1use std::io::{self, IsTerminal};
2use std::time::Duration;
3
4use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
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(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum TuiExit {
26 Quit,
27 Authenticate,
28}
29
30struct TerminalGuard;
31
32impl TerminalGuard {
33 fn enter() -> Result<Self, AppError> {
34 enable_raw_mode()?;
35 if let Err(error) = execute!(io::stdout(), EnterAlternateScreen) {
36 let _ = disable_raw_mode();
37 return Err(error.into());
38 }
39 Ok(Self)
40 }
41}
42
43impl Drop for TerminalGuard {
44 fn drop(&mut self) {
45 let _ = disable_raw_mode();
46 let _ = execute!(io::stdout(), LeaveAlternateScreen);
47 }
48}
49
50#[derive(Debug, Clone)]
51pub struct TuiData {
52 pub account: String,
53 pub conversations: Vec<Conversation>,
54}
55
56#[derive(Debug, Clone)]
57pub struct Conversation {
58 pub name: String,
59 pub unread: u16,
60 pub messages: Vec<Message>,
61}
62
63#[derive(Debug, Clone)]
64pub struct Message {
65 pub sender: String,
66 pub time: String,
67 pub body: String,
68}
69
70pub fn demo_data() -> TuiData {
71 TuiData {
72 account: "Alex Morgan · Northstar Studio".into(),
73 conversations: vec![
74 Conversation {
75 name: "Launch room".into(),
76 unread: 3,
77 messages: vec![
78 Message {
79 sender: "Mina".into(),
80 time: "09:42".into(),
81 body: "The release candidate is green. I pinned the final checklist."
82 .into(),
83 },
84 Message {
85 sender: "Jon".into(),
86 time: "09:46".into(),
87 body: "Docs are ready. One broken link remains in the migration guide."
88 .into(),
89 },
90 Message {
91 sender: "You".into(),
92 time: "09:48".into(),
93 body: "I’ll take it. Hold the announcement until I clear the link check."
94 .into(),
95 },
96 ],
97 },
98 Conversation {
99 name: "Design crit".into(),
100 unread: 0,
101 messages: vec![],
102 },
103 Conversation {
104 name: "Ari Chen".into(),
105 unread: 1,
106 messages: vec![],
107 },
108 Conversation {
109 name: "Platform on-call".into(),
110 unread: 0,
111 messages: vec![],
112 },
113 ],
114 }
115}
116
117struct App {
118 data: TuiData,
119 selected: usize,
120}
121
122pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
123 let backend = TestBackend::new(width.max(48), height.max(18));
124 let mut terminal = Terminal::new(backend).expect("test backend is infallible");
125 let app = App {
126 data: demo_data(),
127 selected: 0,
128 };
129 terminal
130 .draw(|frame| draw(frame, &app))
131 .expect("test backend is infallible");
132 let buffer = terminal.backend().buffer();
133 let mut output = String::new();
134 for y in 0..buffer.area.height {
135 let mut line = String::new();
136 for x in 0..buffer.area.width {
137 line.push_str(buffer[(x, y)].symbol());
138 }
139 output.push_str(line.trim_end());
140 output.push('\n');
141 }
142 while output.ends_with("\n\n") {
143 output.pop();
144 }
145 Ok(output)
146}
147
148pub fn connection_snapshot(
149 width: u16,
150 height: u16,
151 profile: &str,
152 configured: bool,
153 reason: &str,
154) -> Result<String, AppError> {
155 let backend = TestBackend::new(width.max(48), height.max(18));
156 let mut terminal = Terminal::new(backend).expect("test backend is infallible");
157 terminal
158 .draw(|frame| draw_connection(frame, profile, configured, reason))
159 .expect("test backend is infallible");
160 Ok(buffer_text(terminal.backend().buffer()))
161}
162
163fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
164 let mut output = String::new();
165 for y in 0..buffer.area.height {
166 let mut line = String::new();
167 for x in 0..buffer.area.width {
168 line.push_str(buffer[(x, y)].symbol());
169 }
170 output.push_str(line.trim_end());
171 output.push('\n');
172 }
173 while output.ends_with("\n\n") {
174 output.pop();
175 }
176 output
177}
178
179pub fn run(data: TuiData) -> Result<(), AppError> {
180 if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
181 return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
182 }
183 let _terminal_guard = TerminalGuard::enter()?;
184 let backend = CrosstermBackend::new(io::stdout());
185 let mut terminal = Terminal::new(backend)?;
186 let mut app = App { data, selected: 0 };
187 (|| -> Result<(), AppError> {
188 loop {
189 terminal.draw(|frame| draw(frame, &app))?;
190 if event::poll(Duration::from_millis(150))?
191 && let Event::Key(key) = event::read()?
192 {
193 if key.kind != KeyEventKind::Press {
194 continue;
195 }
196 match key.code {
197 KeyCode::Char('q') | KeyCode::Esc => break,
198 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
199 KeyCode::Down | KeyCode::Char('j') => {
200 app.selected =
201 (app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
202 }
203 KeyCode::Up | KeyCode::Char('k') => {
204 app.selected = app.selected.saturating_sub(1)
205 }
206 _ => {}
207 }
208 }
209 }
210 Ok(())
211 })()
212}
213
214pub fn request_authentication(
218 profile: &str,
219 configured: bool,
220 reason: &str,
221) -> Result<TuiExit, AppError> {
222 if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
223 return Err(AppError::NonInteractive(
224 "`teams tui` needs a terminal; run `teams auth login --device-code` for headless sign-in"
225 .into(),
226 ));
227 }
228 let _terminal_guard = TerminalGuard::enter()?;
229 let backend = CrosstermBackend::new(io::stdout());
230 let mut terminal = Terminal::new(backend)?;
231 loop {
232 terminal.draw(|frame| draw_connection(frame, profile, configured, reason))?;
233 if event::poll(Duration::from_millis(150))?
234 && let Event::Key(key) = event::read()?
235 {
236 if key.kind != KeyEventKind::Press {
237 continue;
238 }
239 match key.code {
240 KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
241 KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
242 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
243 return Ok(TuiExit::Quit);
244 }
245 _ => {}
246 }
247 }
248 }
249}
250
251fn draw_connection(frame: &mut ratatui::Frame, profile: &str, configured: bool, reason: &str) {
252 let area = frame.area();
253 frame.render_widget(Block::default().style(Style::default().bg(SURFACE)), area);
254 let panel_width = area.width.saturating_sub(2).clamp(1, 74);
255 let panel_height = area.height.saturating_sub(2).clamp(1, 12);
256 let panel = Rect {
257 x: area.x + area.width.saturating_sub(panel_width) / 2,
258 y: area.y + area.height.saturating_sub(panel_height) / 2,
259 width: panel_width,
260 height: panel_height,
261 };
262 let title = if configured {
263 " SIGN-IN REQUIRED "
264 } else {
265 " CONNECT MICROSOFT TEAMS "
266 };
267 let action = if configured {
268 "Continue with Microsoft sign-in. Your password is never shared with teams-cli."
269 } else {
270 "Set up this profile and continue with secure Microsoft sign-in."
271 };
272 let content = vec![
273 Line::styled(
274 if configured {
275 "Your Teams session needs attention"
276 } else {
277 "Bring your Teams workspace into focus"
278 },
279 Style::default().fg(INK).add_modifier(Modifier::BOLD),
280 ),
281 Line::styled(format!("Profile: {profile}"), Style::default().fg(MUTED)),
282 Line::raw(""),
283 Line::styled(reason, Style::default().fg(INK)),
284 Line::styled(action, Style::default().fg(MUTED)),
285 Line::raw(""),
286 Line::from(vec![
287 Span::styled(
288 " enter / a ",
289 Style::default()
290 .fg(Color::Black)
291 .bg(FOCUS)
292 .add_modifier(Modifier::BOLD),
293 ),
294 Span::styled(
295 if configured {
296 " sign in "
297 } else {
298 " set up "
299 },
300 Style::default().fg(MUTED),
301 ),
302 Span::styled("q", Style::default().fg(INK)),
303 Span::styled(" quit", Style::default().fg(MUTED)),
304 ]),
305 ];
306 frame.render_widget(
307 Paragraph::new(content).wrap(Wrap { trim: true }).block(
308 Block::default()
309 .title(title)
310 .borders(Borders::ALL)
311 .border_style(Style::default().fg(FOCUS))
312 .padding(ratatui::widgets::Padding::horizontal(2)),
313 ),
314 panel,
315 );
316}
317
318fn draw(frame: &mut ratatui::Frame, app: &App) {
319 let area = frame.area();
320 let vertical = Layout::default()
321 .direction(Direction::Vertical)
322 .constraints([
323 Constraint::Length(3),
324 Constraint::Min(10),
325 Constraint::Length(2),
326 ])
327 .split(area);
328 header(frame, vertical[0], app);
329 if area.width < 76 {
330 conversation(frame, vertical[1], app);
331 } else {
332 let body = Layout::default()
333 .direction(Direction::Horizontal)
334 .constraints([Constraint::Length(28), Constraint::Min(44)])
335 .split(vertical[1]);
336 rail(frame, body[0], app);
337 conversation(frame, body[1], app);
338 }
339 let footer = Paragraph::new(Line::from(vec![
340 Span::styled(
341 " j/k ",
342 Style::default()
343 .fg(Color::Black)
344 .bg(FOCUS)
345 .add_modifier(Modifier::BOLD),
346 ),
347 Span::styled(" move ", Style::default().fg(MUTED)),
348 Span::styled("q", Style::default().fg(INK)),
349 Span::styled(" quit", Style::default().fg(MUTED)),
350 ]))
351 .style(Style::default().bg(SURFACE));
352 frame.render_widget(footer, vertical[2]);
353}
354
355fn header(frame: &mut ratatui::Frame, area: Rect, app: &App) {
356 let title = Paragraph::new(Line::from(vec![
357 Span::styled(
358 " teams ",
359 Style::default()
360 .fg(Color::Black)
361 .bg(LIVE)
362 .add_modifier(Modifier::BOLD),
363 ),
364 Span::styled(
365 " INBOX",
366 Style::default().fg(INK).add_modifier(Modifier::BOLD),
367 ),
368 Span::styled(
369 format!(" {}", app.data.account),
370 Style::default().fg(MUTED),
371 ),
372 ]))
373 .block(
374 Block::default()
375 .borders(Borders::BOTTOM)
376 .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
377 );
378 frame.render_widget(title, area);
379}
380
381fn rail(frame: &mut ratatui::Frame, area: Rect, app: &App) {
382 let items = app
383 .data
384 .conversations
385 .iter()
386 .enumerate()
387 .map(|(index, conversation)| {
388 let marker = if index == app.selected { "›" } else { " " };
389 let unread = if conversation.unread > 0 {
390 format!(" {}", conversation.unread)
391 } else {
392 String::new()
393 };
394 let style = if index == app.selected {
395 Style::default().fg(FOCUS).add_modifier(Modifier::BOLD)
396 } else {
397 Style::default().fg(INK)
398 };
399 ListItem::new(Line::from(vec![
400 Span::styled(format!(" {marker} {}", conversation.name), style),
401 Span::styled(unread, Style::default().fg(LIVE)),
402 ]))
403 });
404 frame.render_widget(
405 List::new(items).block(
406 Block::default()
407 .title(" Conversations ")
408 .borders(Borders::RIGHT)
409 .border_style(Style::default().fg(Color::Rgb(55, 61, 72))),
410 ),
411 area,
412 );
413}
414
415fn conversation(frame: &mut ratatui::Frame, area: Rect, app: &App) {
416 let conversation = &app.data.conversations[app.selected];
417 let mut lines = vec![
418 Line::styled(
419 conversation.name.clone(),
420 Style::default().fg(INK).add_modifier(Modifier::BOLD),
421 ),
422 Line::styled("Channel · 12 members", Style::default().fg(MUTED)),
423 Line::raw(""),
424 ];
425 if conversation.messages.is_empty() {
426 lines.push(Line::styled(
427 "No recent messages",
428 Style::default().fg(MUTED),
429 ));
430 lines.push(Line::styled(
431 "Press c to start the conversation.",
432 Style::default().fg(MUTED),
433 ));
434 } else {
435 for message in &conversation.messages {
436 lines.push(Line::from(vec![
437 Span::styled(
438 &message.sender,
439 Style::default().fg(FOCUS).add_modifier(Modifier::BOLD),
440 ),
441 Span::styled(format!(" {}", message.time), Style::default().fg(MUTED)),
442 ]));
443 lines.push(Line::styled(&message.body, Style::default().fg(INK)));
444 lines.push(Line::raw(""));
445 }
446 }
447 let block = Block::default()
448 .title(" Now ")
449 .borders(Borders::NONE)
450 .padding(ratatui::widgets::Padding::horizontal(2));
451 frame.render_widget(
452 Paragraph::new(lines)
453 .wrap(Wrap { trim: false })
454 .block(block),
455 area,
456 );
457}
458
459#[cfg(test)]
460mod tests {
461 #[test]
462 fn snapshot_contains_real_states() {
463 let frame = super::snapshot(90, 26).unwrap();
464 assert!(frame.contains("Launch room"));
465 assert!(frame.contains("release candidate is green"));
466 assert!(frame.contains("q quit"));
467 }
468
469 #[test]
470 fn connection_snapshot_has_a_clear_recovery_action() {
471 let frame =
472 super::connection_snapshot(80, 22, "work", true, "Your saved session has expired.")
473 .unwrap();
474 assert!(frame.contains("SIGN-IN REQUIRED"));
475 assert!(frame.contains("Profile: work"));
476 assert!(frame.contains("enter / a"));
477 assert!(frame.contains("sign in"));
478 assert!(frame.contains("q quit"));
479 }
480}