Skip to main content

max / makenotwork

18.9 KB · 417 lines History Blame Raw
1 //! TUI event loop. `launch` sets up the terminal, spawns the render/input
2 //! task, and drives the per-screen handlers. Split out of tui/mod.rs.
3
4 use super::{
5 App, AppEvent, AppHandle, CrosstermBackend, DataPayload, KeyCode, KeyModifiers, MnwApiClient,
6 PathBuf, Screen, Terminal, TerminalHandle, UserInfo, analytics, blog, collections,
7 handle_analytics_input, handle_blog_input, handle_collections_input, handle_home_input,
8 handle_item_input, handle_keys_input, handle_project_input, handle_promo_input,
9 handle_settings_input, handle_tiers_input, handle_upload_input, home, item, keys,
10 load_blog_posts, load_home_data, load_project_items, load_staged_files, mpsc, parse_key,
11 project, promo, settings, tiers, upload,
12 };
13
14 /// Fill the whole viewport with the theme's page surface and body text colour.
15 ///
16 /// Painted under every screen rather than left to the terminal's own default.
17 /// Left transparent, a light theme lands its parchment borders and muted labels
18 /// on whatever background the creator's terminal happens to have, which on a
19 /// dark one is the pairing the theme was authored to avoid. It also gives every
20 /// unstyled `Span` in the screens below a colour that came from the theme, so a
21 /// span with no `Style` is correct rather than merely inherited.
22 pub(super) fn paint_page(frame: &mut ratatui::Frame, app: &App) {
23 use ratatui::style::Style;
24 use ratatui::widgets::Block;
25
26 frame.render_widget(
27 Block::default().style(
28 Style::default()
29 .bg(app.theme.surface_page)
30 .fg(app.theme.content_primary),
31 ),
32 frame.area(),
33 );
34 }
35
36 /// Launch the TUI event loop in a background task.
37 #[allow(clippy::too_many_arguments)]
38 pub(crate) fn launch(
39 writer: TerminalHandle,
40 user: UserInfo,
41 theme: makeover_tui::Theme,
42 cols: u16,
43 rows: u16,
44 session_handle: russh::server::Handle,
45 channel_id: russh::ChannelId,
46 api: MnwApiClient,
47 staging_dir: PathBuf,
48 ) -> anyhow::Result<AppHandle> {
49 let mut writer = writer;
50 // Enter alternate screen, hide cursor, enable raw-like mode
51 use std::io::Write;
52 let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
53 let _ = writer.flush();
54 let backend = CrosstermBackend::new(writer);
55 let options = ratatui::TerminalOptions {
56 viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)),
57 };
58 let mut terminal = Terminal::with_options(backend, options)?;
59
60 let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
61 let handle = AppHandle { tx: tx.clone() };
62
63 // Kick off initial data load
64 let user_id = user.user_id.clone();
65 let api_clone = api.clone();
66 let tx_clone = tx.clone();
67 tokio::spawn(async move {
68 load_home_data(&api_clone, &user_id, &tx_clone).await;
69 });
70
71 tokio::spawn(async move {
72 let mut app = App::new(user, theme);
73 let mut screen = Screen::Home;
74 let staging_dir = staging_dir;
75
76 /// Write escape codes to leave alternate screen and restore cursor.
77 fn cleanup(terminal: &mut Terminal<CrosstermBackend<TerminalHandle>>) {
78 use std::io::Write;
79 let be = terminal.backend_mut();
80 let _ = be.write_all(b"\x1b[?25h\x1b[?1049l");
81 let _ = be.flush();
82 }
83
84 // Initial render (loading state)
85 if let Err(e) = terminal.draw(|frame| {
86 paint_page(frame, &app);
87 home::render(frame, &app);
88 }) {
89 tracing::error!(error = ?e, "TUI: initial render failed");
90 cleanup(&mut terminal);
91 return;
92 }
93
94 while let Some(event) = rx.recv().await {
95 match event {
96 AppEvent::Input(data) => {
97 if let Some(key) = parse_key(&data) {
98 // Global quit
99 if (key.modifiers.contains(KeyModifiers::CONTROL)
100 && key.code == KeyCode::Char('c'))
101 || matches!(
102 (&screen, key.code),
103 (Screen::Home, KeyCode::Char('q' | 'Q'))
104 )
105 {
106 tracing::info!(user = %app.user.username, "user quit");
107 cleanup(&mut terminal);
108 let _ = session_handle.close(channel_id).await;
109 return;
110 }
111
112 match screen {
113 Screen::Home => {
114 handle_home_input(
115 key,
116 &mut app,
117 &mut screen,
118 &api,
119 &tx,
120 &staging_dir,
121 )
122 .await;
123 }
124 Screen::Project(_) => {
125 handle_project_input(key, &mut app, &mut screen, &api, &tx).await;
126 }
127 Screen::Upload => {
128 handle_upload_input(
129 key,
130 &mut app,
131 &mut screen,
132 &api,
133 &tx,
134 &staging_dir,
135 )
136 .await;
137 }
138 Screen::Item(..) => {
139 handle_item_input(key, &mut app, &mut screen, &api, &tx).await;
140 }
141 Screen::Blog(..) => {
142 handle_blog_input(key, &mut app, &mut screen, &api, &tx).await;
143 }
144 Screen::Promo => {
145 handle_promo_input(key, &mut app, &mut screen, &api, &tx).await;
146 }
147 Screen::Keys(..) => {
148 handle_keys_input(key, &mut app, &mut screen, &api, &tx).await;
149 }
150 Screen::Analytics => {
151 handle_analytics_input(key, &mut app, &mut screen, &api, &tx).await;
152 }
153 Screen::Settings => {
154 handle_settings_input(key, &mut app, &mut screen, &api, &tx).await;
155 }
156 Screen::Collections => {
157 handle_collections_input(key, &mut app, &mut screen, &api, &tx)
158 .await;
159 }
160 Screen::Tiers(..) => {
161 handle_tiers_input(key, &mut app, &mut screen).await;
162 }
163 }
164 }
165 }
166 AppEvent::Resize(cols, rows) => {
167 let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
168 let _ = terminal.resize(rect);
169 }
170 AppEvent::DataLoaded(payload) => match payload {
171 DataPayload::Home { projects, stats } => {
172 app.projects = projects;
173 app.stats = Some(stats);
174 app.loading = false;
175 app.selected_index = 0;
176 }
177 DataPayload::ProjectItems { items } => {
178 app.items = items;
179 app.loading = false;
180 app.selected_index = 0;
181 app.selected_items.clear();
182 }
183 DataPayload::StagedFiles { files, storage } => {
184 app.staged_files = files;
185 if let Some(s) = storage {
186 app.storage_info = Some(s);
187 }
188 app.sync_metadata();
189 app.loading = false;
190 if app.selected_index >= app.staged_files.len()
191 && !app.staged_files.is_empty()
192 {
193 app.selected_index = app.staged_files.len() - 1;
194 }
195 }
196 DataPayload::ItemDetail { detail, versions } => {
197 app.item_detail = Some(detail);
198 app.item_versions = versions;
199 app.loading = false;
200 app.selected_index = 0;
201 }
202 DataPayload::ItemUpdated { detail } => {
203 app.item_detail = Some(detail);
204 app.item_status = Some("Updated".to_string());
205 app.item_editing = None;
206 app.edit_buffer.clear();
207 }
208 DataPayload::ItemDeleted => {
209 app.item_status = Some("Deleted".to_string());
210 // Navigate back to project view
211 if let Screen::Item(project_idx, _) = &screen {
212 let pidx = *project_idx;
213 screen = Screen::Project(pidx);
214 app.item_detail = None;
215 app.item_versions.clear();
216 app.item_status = None;
217 app.selected_index = 0;
218 app.loading = true;
219
220 if let Some(p) = app.projects.get(pidx) {
221 let api = api.clone();
222 let project_id = p.id.clone();
223 let user_id = app.user.user_id.clone();
224 let tx = tx.clone();
225 tokio::spawn(async move {
226 load_project_items(&api, &project_id, &user_id, &tx).await;
227 });
228 }
229 }
230 }
231 DataPayload::ItemActionError { error } => {
232 app.item_status = Some(format!("Error: {error}"));
233 }
234 DataPayload::BlogPosts { posts } => {
235 app.blog_posts = posts;
236 app.loading = false;
237 app.selected_index = 0;
238 }
239 DataPayload::BlogCreated => {
240 app.blog_creating = false;
241 app.blog_create_step = None;
242 app.blog_create_title.clear();
243 app.blog_create_body.clear();
244 app.edit_buffer.clear();
245 app.blog_status = Some("Post created".to_string());
246 // Reload blog posts
247 if let Screen::Blog(_, ref project_id) = screen {
248 let api = api.clone();
249 let user_id = app.user.user_id.clone();
250 let project_id = project_id.clone();
251 let tx = tx.clone();
252 tokio::spawn(async move {
253 load_blog_posts(&api, &user_id, &project_id, &tx).await;
254 });
255 }
256 }
257 DataPayload::PromoCodes { codes } => {
258 app.promo_codes = codes;
259 app.loading = false;
260 app.selected_index = 0;
261 }
262 DataPayload::LicenseKeys { keys: k } => {
263 app.license_keys = k;
264 app.loading = false;
265 app.selected_index = 0;
266 }
267 DataPayload::GenericSuccess { message } => {
268 // Use as status on whatever screen is active
269 match screen {
270 Screen::Blog(..) => app.blog_status = Some(message),
271 Screen::Promo => app.promo_status = Some(message),
272 Screen::Keys(..) => app.keys_status = Some(message),
273 _ => {}
274 }
275 }
276 DataPayload::GenericError { error } => {
277 let msg = format!("Error: {error}");
278 match screen {
279 Screen::Blog(..) => app.blog_status = Some(msg),
280 Screen::Promo => {
281 app.promo_status = Some(msg);
282 app.promo_editing_step = None;
283 }
284 Screen::Keys(..) => app.keys_status = Some(msg),
285 Screen::Analytics => app.analytics_status = Some(msg),
286 Screen::Settings => app.settings_status = Some(msg),
287 _ => app.item_status = Some(msg),
288 }
289 }
290 DataPayload::Analytics { data } => {
291 app.analytics_data = Some(data);
292 app.loading = false;
293 }
294 DataPayload::Transactions { txs } => {
295 app.transactions = txs;
296 app.loading = false;
297 app.selected_index = 0;
298 }
299 DataPayload::ExportCsv { csv, row_count } => {
300 app.analytics_status =
301 Some(format!("Exported {} rows ({} bytes)", row_count, csv.len()));
302 }
303 DataPayload::Settings { keys, storage } => {
304 app.ssh_keys = keys;
305 if let Some(s) = storage {
306 app.storage_info = Some(s);
307 }
308 app.loading = false;
309 app.selected_index = 0;
310 }
311 DataPayload::ItemTags { tags } => {
312 app.item_tags = tags;
313 }
314 DataPayload::TagSearchResults { results } => {
315 app.tag_search_results = results;
316 app.tag_searching = false;
317 }
318 DataPayload::CollectionsList { collections: c } => {
319 app.collections = c;
320 app.loading = false;
321 app.selected_index = 0;
322 }
323 DataPayload::TiersList { tiers: t } => {
324 app.tiers = t;
325 app.loading = false;
326 app.selected_index = 0;
327 }
328 DataPayload::BulkActionComplete { message } => {
329 app.item_status = Some(message);
330 app.selected_items.clear();
331 // Reload project items
332 if let Screen::Project(pidx) = &screen
333 && let Some(p) = app.projects.get(*pidx)
334 {
335 app.loading = true;
336 let api = api.clone();
337 let project_id = p.id.clone();
338 let user_id = app.user.user_id.clone();
339 let tx = tx.clone();
340 tokio::spawn(async move {
341 load_project_items(&api, &project_id, &user_id, &tx).await;
342 });
343 }
344 }
345 DataPayload::ProjectReload { project_idx } => {
346 if let Some(p) = app.projects.get(project_idx) {
347 app.loading = true;
348 let api = api.clone();
349 let project_id = p.id.clone();
350 let user_id = app.user.user_id.clone();
351 let tx = tx.clone();
352 tokio::spawn(async move {
353 load_project_items(&api, &project_id, &user_id, &tx).await;
354 });
355 }
356 }
357 DataPayload::PublishResult {
358 filename,
359 success,
360 error,
361 } => {
362 app.publishing = false;
363 if success {
364 app.upload_status = Some(format!("Published {filename}"));
365 // Reload staged files
366 let staging_dir = staging_dir.clone();
367 let api = api.clone();
368 let user_id = app.user.user_id.clone();
369 let tx = tx.clone();
370 tokio::spawn(async move {
371 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
372 });
373 } else {
374 app.upload_status = Some(format!(
375 "Error: {}",
376 error.unwrap_or_else(|| "unknown error".to_string())
377 ));
378 }
379 }
380 },
381 }
382
383 // Re-render after every event
384 if let Err(e) = terminal.draw(|frame| {
385 paint_page(frame, &app);
386 match &screen {
387 Screen::Home => home::render(frame, &app),
388 Screen::Project(idx) => {
389 if let Some(p) = app.projects.get(*idx) {
390 project::render(frame, &app, p);
391 } else {
392 home::render(frame, &app);
393 }
394 }
395 Screen::Upload => upload::render(frame, &app),
396 Screen::Item(..) => item::render(frame, &app),
397 Screen::Blog(..) => blog::render(frame, &app),
398 Screen::Promo => promo::render(frame, &app),
399 Screen::Keys(..) => keys::render(frame, &app),
400 Screen::Analytics => analytics::render(frame, &app),
401 Screen::Settings => settings::render(frame, &app),
402 Screen::Collections => collections::render(frame, &app),
403 Screen::Tiers(..) => tiers::render(frame, &app),
404 }
405 }) {
406 tracing::error!(error = ?e, "render failed");
407 cleanup(&mut terminal);
408 return;
409 }
410 }
411 // Event loop ended (channel dropped)
412 cleanup(&mut terminal);
413 });
414
415 Ok(handle)
416 }
417