tempo_cli/ui/
interactive.rs1use anyhow::Result;
2use crossterm::event::{self, Event, KeyCode};
3use ratatui::{
4 backend::Backend,
5 layout::{Constraint, Direction, Layout, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
9 Frame, Terminal,
10};
11use std::time::Duration;
12
13use crate::{
14 models::{Project, Session},
15 ui::formatter::Formatter,
16};
17
18pub struct InteractiveViewer {
19 projects: Vec<Project>,
20 sessions: Vec<Session>,
21 selected_project: Option<usize>,
22 project_list_state: ListState,
23}
24
25impl InteractiveViewer {
26 pub fn new() -> Result<Self> {
27 let mut viewer = Self {
28 projects: Vec::new(),
29 sessions: Vec::new(),
30 selected_project: None,
31 project_list_state: ListState::default(),
32 };
33
34 viewer.load_data()?;
35 Ok(viewer)
36 }
37
38 pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
39 loop {
40 terminal.draw(|f| {
41 self.render(f);
42 })?;
43
44 if event::poll(Duration::from_millis(100))? {
46 if let Event::Key(key) = event::read()? {
47 match key.code {
48 KeyCode::Char('q') | KeyCode::Esc => break,
49 KeyCode::Up => self.previous_project(),
50 KeyCode::Down => self.next_project(),
51 KeyCode::Enter => self.select_project(),
52 KeyCode::Char('r') => self.load_data()?,
53 _ => {}
54 }
55 }
56 }
57 }
58
59 Ok(())
60 }
61
62 fn render(&mut self, f: &mut Frame) {
63 let chunks = Layout::default()
64 .direction(Direction::Horizontal)
65 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
66 .split(f.size());
67
68 self.render_project_list(f, chunks[0]);
69 self.render_session_details(f, chunks[1]);
70 }
71
72 fn render_project_list(&mut self, f: &mut Frame, area: Rect) {
73 let items: Vec<ListItem> = self
74 .projects
75 .iter()
76 .enumerate()
77 .map(|(i, project)| {
78 let style = if Some(i) == self.selected_project {
79 Style::default()
80 .fg(Color::Yellow)
81 .add_modifier(Modifier::BOLD)
82 } else {
83 Style::default().fg(Color::White)
84 };
85
86 ListItem::new(Line::from(Span::styled(project.name.clone(), style)))
87 })
88 .collect();
89
90 let list = List::new(items)
91 .block(
92 Block::default()
93 .title("Projects")
94 .borders(Borders::ALL)
95 .style(Style::default().fg(Color::Cyan)),
96 )
97 .highlight_style(Style::default().bg(Color::DarkGray));
98
99 f.render_stateful_widget(list, area, &mut self.project_list_state);
100 }
101
102 fn render_session_details(&self, f: &mut Frame, area: Rect) {
103 let chunks = Layout::default()
104 .direction(Direction::Vertical)
105 .constraints([Constraint::Length(8), Constraint::Min(0)])
106 .split(area);
107
108 if let Some(selected_idx) = self.selected_project {
110 if let Some(project) = self.projects.get(selected_idx) {
111 let project_info = Formatter::format_project_info(project);
112 let paragraph = Paragraph::new(project_info)
113 .block(Formatter::create_header_block("Project Details"));
114 f.render_widget(paragraph, chunks[0]);
115
116 let project_sessions: Vec<&Session> = self
118 .sessions
119 .iter()
120 .filter(|s| s.project_id == project.id.unwrap_or(-1))
121 .collect();
122
123 if !project_sessions.is_empty() {
124 let sessions_summary = Formatter::format_sessions_summary(
125 &project_sessions.into_iter().cloned().collect::<Vec<_>>(),
126 );
127 let sessions_widget =
128 Paragraph::new(sessions_summary).block(Formatter::create_info_block());
129 f.render_widget(sessions_widget, chunks[1]);
130 } else {
131 let no_sessions = Paragraph::new("No sessions found for this project")
132 .style(Style::default().fg(Color::Gray))
133 .block(Formatter::create_info_block());
134 f.render_widget(no_sessions, chunks[1]);
135 }
136 }
137 } else {
138 let help_text = vec![
139 Line::from("Select a project to view details"),
140 Line::from(""),
141 Line::from("Controls:"),
142 Line::from(" Up/Down Navigate projects"),
143 Line::from(" Enter Select project"),
144 Line::from(" r Refresh data"),
145 Line::from(" q/Esc Quit"),
146 ];
147
148 let paragraph = Paragraph::new(help_text).block(
149 Block::default()
150 .title("Help")
151 .borders(Borders::ALL)
152 .style(Style::default().fg(Color::Cyan)),
153 );
154 f.render_widget(paragraph, area);
155 }
156 }
157
158 fn previous_project(&mut self) {
159 if self.projects.is_empty() {
160 return;
161 }
162
163 let i = match self.project_list_state.selected() {
164 Some(i) => {
165 if i == 0 {
166 self.projects.len() - 1
167 } else {
168 i - 1
169 }
170 }
171 None => 0,
172 };
173 self.project_list_state.select(Some(i));
174 self.selected_project = Some(i);
175 }
176
177 fn next_project(&mut self) {
178 if self.projects.is_empty() {
179 return;
180 }
181
182 let i = match self.project_list_state.selected() {
183 Some(i) => {
184 if i >= self.projects.len() - 1 {
185 0
186 } else {
187 i + 1
188 }
189 }
190 None => 0,
191 };
192 self.project_list_state.select(Some(i));
193 self.selected_project = Some(i);
194 }
195
196 fn select_project(&mut self) {
197 if let Some(i) = self.project_list_state.selected() {
198 self.selected_project = Some(i);
199 }
200 }
201
202 fn load_data(&mut self) -> Result<()> {
203 use chrono::Utc;
206 use std::path::PathBuf;
207
208 self.projects = vec![Project {
209 id: Some(1),
210 name: "Sample Project".to_string(),
211 path: PathBuf::from("/Users/example/sample"),
212 git_hash: Some("abc123".to_string()),
213 created_at: chrono::Local::now().with_timezone(&Utc),
214 updated_at: chrono::Local::now().with_timezone(&Utc),
215 is_archived: false,
216 description: Some("A sample project for demo".to_string()),
217 }];
218
219 use crate::models::session::SessionContext;
220
221 self.sessions = vec![Session {
222 id: Some(1),
223 project_id: 1,
224 start_time: (chrono::Local::now() - chrono::Duration::hours(2)).with_timezone(&Utc),
225 end_time: Some((chrono::Local::now() - chrono::Duration::hours(1)).with_timezone(&Utc)),
226 context: SessionContext::Terminal,
227 paused_duration: chrono::Duration::minutes(5),
228 notes: Some("Working on initial setup".to_string()),
229 created_at: chrono::Local::now().with_timezone(&Utc),
230 }];
231
232 Ok(())
233 }
234}