ctate 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.rs ADDED
@@ -0,0 +1,1194 @@
1
+ use std::{env, io, panic, time::Duration};
2
+
3
+ use crossterm::{
4
+ event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
5
+ execute,
6
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7
+ };
8
+ use ratatui::{
9
+ backend::CrosstermBackend,
10
+ layout::{Constraint, Direction, Layout, Rect},
11
+ style::{Color, Modifier, Style},
12
+ text::{Line, Span},
13
+ widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
14
+ Frame, Terminal,
15
+ };
16
+
17
+ type AppResult<T> = Result<T, Box<dyn std::error::Error>>;
18
+
19
+ const GITHUB_URL: &str = "https://github.com/ctate";
20
+ const GITHUB_DISPLAY_URL: &str = "github.com/ctate";
21
+ const X_URL: &str = "https://x.com/ctatedev";
22
+ const X_DISPLAY_URL: &str = "x.com/ctatedev";
23
+ const TAGLINE: &str = "Building for the web since 1997";
24
+ const SHIP_LIST_TITLE: &str = "RECENT SHIPS AT \u{25b2} ";
25
+ const HEADER_WIDTH: u16 = 78;
26
+ const LIST_WIDTH: u16 = HEADER_WIDTH + 2;
27
+ const SCROLLBAR_GAP_WIDTH: u16 = 1;
28
+ const SCROLLBAR_WIDTH: u16 = 1;
29
+ const MAX_APP_WIDTH: u16 = LIST_WIDTH + SCROLLBAR_GAP_WIDTH + SCROLLBAR_WIDTH;
30
+ const LOGO_HEIGHT: u16 = 6;
31
+ const LOGO_BOTTOM_SPACER_HEIGHT: u16 = 1;
32
+ const HEADER_HEIGHT: u16 = LOGO_HEIGHT + LOGO_BOTTOM_SPACER_HEIGHT + 1;
33
+ const VISIBLE_PROJECT_ROWS: u16 = 16;
34
+ const PROJECT_TABLE_HEIGHT: u16 = VISIBLE_PROJECT_ROWS + 1;
35
+ const TABLE_TOP_SPACER_HEIGHT: u16 = 1;
36
+ const MAX_APP_HEIGHT: u16 = HEADER_HEIGHT + TABLE_TOP_SPACER_HEIGHT + PROJECT_TABLE_HEIGHT;
37
+ const LOGO_LINES: &[&str] = &[
38
+ " ██ ██",
39
+ " ██████ ██ ██████ ██ ██████",
40
+ "██ ██████████ ██ ██████████ ██ ██",
41
+ "██ ██ ████████ ██ ██████████",
42
+ "██ ██ ██ ██ ██ ██",
43
+ " ██████ ████ ████████ ████ ████████",
44
+ ];
45
+
46
+ #[derive(Clone, Copy)]
47
+ struct Project {
48
+ name: &'static str,
49
+ kind: &'static str,
50
+ url: &'static str,
51
+ }
52
+
53
+ const PROJECTS: &[Project] = &[
54
+ Project {
55
+ name: "zerolang",
56
+ kind: "Language",
57
+ url: "https://zerolang.ai",
58
+ },
59
+ Project {
60
+ name: "Markdown Experience Guidelines",
61
+ kind: "Specification",
62
+ url: "https://mdxg.org",
63
+ },
64
+ Project {
65
+ name: "zero-native",
66
+ kind: "Framework",
67
+ url: "https://zero-native.dev",
68
+ },
69
+ Project {
70
+ name: "wterm",
71
+ kind: "Library",
72
+ url: "https://wterm.dev",
73
+ },
74
+ Project {
75
+ name: "emulate",
76
+ kind: "Library",
77
+ url: "https://emulate.dev",
78
+ },
79
+ Project {
80
+ name: "webreel",
81
+ kind: "Library",
82
+ url: "https://webreel.dev",
83
+ },
84
+ Project {
85
+ name: "visual-json",
86
+ kind: "Library",
87
+ url: "https://visual-json.dev",
88
+ },
89
+ Project {
90
+ name: "portless",
91
+ kind: "Library",
92
+ url: "https://port1355.dev",
93
+ },
94
+ Project {
95
+ name: "json-render",
96
+ kind: "Library",
97
+ url: "https://json-render.dev",
98
+ },
99
+ Project {
100
+ name: "agent-browser",
101
+ kind: "Library",
102
+ url: "https://agent-browser.dev",
103
+ },
104
+ Project {
105
+ name: "opensrc",
106
+ kind: "Library",
107
+ url: "https://github.com/vercel-labs/opensrc",
108
+ },
109
+ Project {
110
+ name: "ralph-loop-agent",
111
+ kind: "Library",
112
+ url: "https://github.com/vercel-labs/ralph-loop-agent",
113
+ },
114
+ Project {
115
+ name: "Workflow Builder",
116
+ kind: "Template",
117
+ url: "https://workflow-builder.dev/",
118
+ },
119
+ Project {
120
+ name: "Coding Agent Platform",
121
+ kind: "Template",
122
+ url: "https://coding-agent-platform.vercel.sh/",
123
+ },
124
+ Project {
125
+ name: "vterm",
126
+ kind: "Web App",
127
+ url: "https://vterm.dev/",
128
+ },
129
+ Project {
130
+ name: "Type-Z",
131
+ kind: "Game",
132
+ url: "https://type-z.ctate.dev/",
133
+ },
134
+ Project {
135
+ name: "v0 Platform API",
136
+ kind: "API",
137
+ url: "https://v0.app/docs/api/platform",
138
+ },
139
+ Project {
140
+ name: "v0 Model API",
141
+ kind: "API",
142
+ url: "https://v0.app/docs/api/model",
143
+ },
144
+ Project {
145
+ name: "v0 SDK",
146
+ kind: "Library",
147
+ url: "https://www.npmjs.com/package/v0-sdk",
148
+ },
149
+ Project {
150
+ name: "v0 SDK Playground",
151
+ kind: "Web App",
152
+ url: "https://playground.v0-sdk.dev/",
153
+ },
154
+ Project {
155
+ name: "v0 Clone",
156
+ kind: "Template",
157
+ url: "https://clone-demo.v0-sdk.dev/",
158
+ },
159
+ Project {
160
+ name: "Liquid Glass",
161
+ kind: "Template",
162
+ url: "https://v0.app/templates/liquid-glass-2Tyr62QLwAT",
163
+ },
164
+ Project {
165
+ name: "3D Maze",
166
+ kind: "Art",
167
+ url: "https://x.com/ctatedev/status/1853791722196615325",
168
+ },
169
+ Project {
170
+ name: "Tetrahedron Physics",
171
+ kind: "Art",
172
+ url: "https://tetrahedron-physics.ctate.dev/",
173
+ },
174
+ Project {
175
+ name: "Classic v0",
176
+ kind: "Template",
177
+ url: "https://x.com/ctatedev/status/1960785629501120779",
178
+ },
179
+ Project {
180
+ name: "Simple v0",
181
+ kind: "Template",
182
+ url: "https://simple-demo.v0-sdk.dev/",
183
+ },
184
+ Project {
185
+ name: "v0 MCP",
186
+ kind: "MCP",
187
+ url: "https://mcp.v0.dev/",
188
+ },
189
+ Project {
190
+ name: "@v0-sdk/ai-tools",
191
+ kind: "Package",
192
+ url: "https://www.npmjs.com/package/@v0-sdk/ai-tools",
193
+ },
194
+ Project {
195
+ name: "OpenUI",
196
+ kind: "Specification",
197
+ url: "https://openuispec.org/",
198
+ },
199
+ Project {
200
+ name: "Next.js Conf 2024",
201
+ kind: "Art",
202
+ url: "https://nextjs-conf-2024.ctate.dev/",
203
+ },
204
+ Project {
205
+ name: "Halftone Waves",
206
+ kind: "Art",
207
+ url: "https://halftone-waves.ctate.dev/",
208
+ },
209
+ Project {
210
+ name: "Neon Maze",
211
+ kind: "Art",
212
+ url: "https://neon-maze.ctate.dev/",
213
+ },
214
+ Project {
215
+ name: "SEV0",
216
+ kind: "Game",
217
+ url: "https://x.com/ctatedev/status/1907498490370068606",
218
+ },
219
+ Project {
220
+ name: "v0 CLI",
221
+ kind: "Concept",
222
+ url: "https://x.com/ctatedev/status/1962322136724373651",
223
+ },
224
+ Project {
225
+ name: "3D Model Generator",
226
+ kind: "Web App",
227
+ url: "https://3d-model-generator.ctate.dev/",
228
+ },
229
+ Project {
230
+ name: "p0",
231
+ kind: "Template",
232
+ url: "https://v0.app/chat/templates/p0-minimalist-linear-task-list-hvjn7tu440z",
233
+ },
234
+ Project {
235
+ name: "VS Code extension for v0",
236
+ kind: "Extension",
237
+ url: "https://x.com/ctatedev/status/1875380513869066695",
238
+ },
239
+ Project {
240
+ name: "Image to SVG",
241
+ kind: "Template",
242
+ url: "https://v0.app/templates/image-to-svg-v0xdBJ3LNVP",
243
+ },
244
+ Project {
245
+ name: "Prompting Is All You Need",
246
+ kind: "Template",
247
+ url: "https://prompting-is-all-you-need.ctate.dev/",
248
+ },
249
+ Project {
250
+ name: "We're Snow Back",
251
+ kind: "Game",
252
+ url: "https://snow-back.ctate.dev/",
253
+ },
254
+ Project {
255
+ name: "Audio Visualizer",
256
+ kind: "Template",
257
+ url: "https://v0.app/templates/audio-visualizer-eGfAJ9Uw70W",
258
+ },
259
+ ];
260
+
261
+ struct App {
262
+ state: TableState,
263
+ theme: TerminalTheme,
264
+ status: String,
265
+ should_quit: bool,
266
+ }
267
+
268
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
269
+ enum TerminalTheme {
270
+ Dark,
271
+ Light,
272
+ }
273
+
274
+ impl App {
275
+ fn new() -> Self {
276
+ let mut state = TableState::default();
277
+ state.select(Some(0));
278
+
279
+ Self {
280
+ state,
281
+ theme: detect_terminal_theme(),
282
+ status: "Select a ship and press Enter to open it.".to_string(),
283
+ should_quit: false,
284
+ }
285
+ }
286
+
287
+ fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> AppResult<()> {
288
+ while !self.should_quit {
289
+ terminal.draw(|frame| self.render(frame))?;
290
+
291
+ if event::poll(Duration::from_millis(200))? {
292
+ if let Event::Key(key) = event::read()? {
293
+ if key.kind == KeyEventKind::Press {
294
+ self.handle_key(key);
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ Ok(())
301
+ }
302
+
303
+ fn render(&mut self, frame: &mut Frame<'_>) {
304
+ let areas = screen_areas(frame.area());
305
+ let area = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
306
+ let app_areas = app_layout_areas(area);
307
+
308
+ let mut header_lines = LOGO_LINES
309
+ .iter()
310
+ .map(|line| Line::from(Span::styled(*line, logo_style(self.theme))))
311
+ .collect::<Vec<_>>();
312
+ header_lines.push(Line::raw(""));
313
+ header_lines.push(metadata_line(app_areas.header.width));
314
+
315
+ let header = Paragraph::new(header_lines).wrap(Wrap { trim: false });
316
+ frame.render_widget(header, app_areas.header);
317
+
318
+ let selected = self.selected();
319
+ let selected_style = selected_row_style(self.theme);
320
+ let rows = PROJECTS.iter().enumerate().map(|(index, project)| {
321
+ let is_selected = index == selected;
322
+ let name_style = if is_selected {
323
+ selected_style
324
+ } else {
325
+ Style::default()
326
+ };
327
+ let kind_style = if is_selected {
328
+ selected_style
329
+ } else {
330
+ project_kind_style()
331
+ };
332
+
333
+ Row::new(vec![
334
+ Cell::from(project.name).style(name_style),
335
+ Cell::from(project.kind).style(kind_style),
336
+ ])
337
+ });
338
+
339
+ let table = Table::new(rows, [Constraint::Min(18), Constraint::Length(15)])
340
+ .block(
341
+ Block::default()
342
+ .borders(Borders::TOP)
343
+ .border_style(Style::default().add_modifier(Modifier::DIM))
344
+ .title(Line::from(Span::styled(
345
+ SHIP_LIST_TITLE,
346
+ Style::default().add_modifier(Modifier::DIM),
347
+ ))),
348
+ )
349
+ .row_highlight_style(selected_style);
350
+ frame.render_stateful_widget(table, app_areas.table, &mut self.state);
351
+ frame.render_widget(
352
+ scroll_indicator(
353
+ PROJECTS.len(),
354
+ app_areas.visible_rows as usize,
355
+ app_areas.scroll.height,
356
+ self.state.offset(),
357
+ ),
358
+ app_areas.scroll,
359
+ );
360
+
361
+ let footer_style = status_bar_style(self.theme);
362
+ let footer_key_style = footer_style.add_modifier(Modifier::BOLD);
363
+ let shortcuts = vec![
364
+ Span::styled("Enter", footer_key_style),
365
+ Span::styled(" open ", footer_style),
366
+ Span::styled("g", footer_key_style),
367
+ Span::styled(" github ", footer_style),
368
+ Span::styled("x", footer_key_style),
369
+ Span::styled(" x.com ", footer_style),
370
+ Span::styled("q", footer_key_style),
371
+ Span::styled(" quit", footer_style),
372
+ ];
373
+ let footer = Paragraph::new(status_line(
374
+ areas.status.width,
375
+ shortcuts,
376
+ &self.status,
377
+ footer_style,
378
+ ))
379
+ .style(footer_style);
380
+ frame.render_widget(footer, areas.status);
381
+ }
382
+
383
+ fn handle_key(&mut self, key: KeyEvent) {
384
+ match key.code {
385
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
386
+ self.should_quit = true;
387
+ }
388
+ KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
389
+ KeyCode::Down | KeyCode::Char('j') => self.next(),
390
+ KeyCode::Up | KeyCode::Char('k') => self.previous(),
391
+ KeyCode::PageDown => self.move_by(8),
392
+ KeyCode::PageUp => self.move_by(-8),
393
+ KeyCode::Home => self.select(0),
394
+ KeyCode::End => self.select(PROJECTS.len().saturating_sub(1)),
395
+ KeyCode::Enter => self.open_selected(),
396
+ KeyCode::Char(character) => {
397
+ if let Some((label, url)) = shortcut_url(character) {
398
+ self.open_url(label, url);
399
+ }
400
+ }
401
+ _ => {}
402
+ }
403
+ }
404
+
405
+ fn selected(&self) -> usize {
406
+ self.state.selected().unwrap_or(0)
407
+ }
408
+
409
+ fn select(&mut self, index: usize) {
410
+ self.state
411
+ .select(Some(index.min(PROJECTS.len().saturating_sub(1))));
412
+ }
413
+
414
+ fn next(&mut self) {
415
+ let next = (self.selected() + 1) % PROJECTS.len();
416
+ self.select(next);
417
+ }
418
+
419
+ fn previous(&mut self) {
420
+ let previous = self
421
+ .selected()
422
+ .checked_sub(1)
423
+ .unwrap_or_else(|| PROJECTS.len().saturating_sub(1));
424
+ self.select(previous);
425
+ }
426
+
427
+ fn move_by(&mut self, delta: isize) {
428
+ let selected = self.selected() as isize;
429
+ let max = PROJECTS.len().saturating_sub(1) as isize;
430
+ let next = (selected + delta).clamp(0, max);
431
+ self.select(next as usize);
432
+ }
433
+
434
+ fn open_selected(&mut self) {
435
+ let project = PROJECTS[self.selected()];
436
+ self.open_url(project.name, project.url);
437
+ }
438
+
439
+ fn open_url(&mut self, label: &str, url: &str) {
440
+ match webbrowser::open(url) {
441
+ Ok(_) => {
442
+ self.status = format!("Opened {label}: {url}");
443
+ }
444
+ Err(error) => {
445
+ self.status = format!("Could not open {label}: {error}");
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ #[derive(Clone, Copy)]
452
+ struct ScreenAreas {
453
+ content: Rect,
454
+ status: Rect,
455
+ }
456
+
457
+ #[derive(Clone, Copy)]
458
+ struct AppLayoutAreas {
459
+ header: Rect,
460
+ table: Rect,
461
+ scroll: Rect,
462
+ visible_rows: u16,
463
+ }
464
+
465
+ fn screen_areas(area: Rect) -> ScreenAreas {
466
+ let chunks = Layout::default()
467
+ .direction(Direction::Vertical)
468
+ .constraints([Constraint::Min(0), Constraint::Length(1)])
469
+ .split(area);
470
+
471
+ ScreenAreas {
472
+ content: chunks[0],
473
+ status: chunks[1],
474
+ }
475
+ }
476
+
477
+ fn app_layout_areas(area: Rect) -> AppLayoutAreas {
478
+ let spacer_height = table_top_spacer_height(area.height);
479
+ let table_height = project_table_height(area.height, spacer_height);
480
+ let chunks = Layout::default()
481
+ .direction(Direction::Vertical)
482
+ .constraints([
483
+ Constraint::Length(HEADER_HEIGHT),
484
+ Constraint::Length(spacer_height),
485
+ Constraint::Length(table_height),
486
+ Constraint::Min(0),
487
+ ])
488
+ .split(area);
489
+ let table_chunks = Layout::default()
490
+ .direction(Direction::Horizontal)
491
+ .constraints([
492
+ Constraint::Min(1),
493
+ Constraint::Length(SCROLLBAR_GAP_WIDTH),
494
+ Constraint::Length(SCROLLBAR_WIDTH),
495
+ ])
496
+ .split(chunks[2]);
497
+ let header_area = Rect {
498
+ y: chunks[0].y,
499
+ height: chunks[0].height,
500
+ ..table_chunks[0]
501
+ };
502
+
503
+ AppLayoutAreas {
504
+ header: left_aligned_width(header_area, HEADER_WIDTH),
505
+ table: table_chunks[0],
506
+ scroll: table_chunks[2],
507
+ visible_rows: table_height.saturating_sub(1).min(VISIBLE_PROJECT_ROWS),
508
+ }
509
+ }
510
+
511
+ fn table_top_spacer_height(area_height: u16) -> u16 {
512
+ let compact_height = HEADER_HEIGHT + PROJECT_TABLE_HEIGHT;
513
+
514
+ if area_height >= compact_height + TABLE_TOP_SPACER_HEIGHT {
515
+ TABLE_TOP_SPACER_HEIGHT
516
+ } else {
517
+ 0
518
+ }
519
+ }
520
+
521
+ fn project_table_height(area_height: u16, spacer_height: u16) -> u16 {
522
+ area_height
523
+ .saturating_sub(HEADER_HEIGHT + spacer_height)
524
+ .min(PROJECT_TABLE_HEIGHT)
525
+ }
526
+
527
+ fn centered_area(area: Rect, max_width: u16, max_height: u16) -> Rect {
528
+ let width = area.width.min(max_width);
529
+ let height = area.height.min(max_height);
530
+
531
+ Rect {
532
+ x: area.x + area.width.saturating_sub(width) / 2,
533
+ y: area.y + area.height.saturating_sub(height) / 2,
534
+ width,
535
+ height,
536
+ }
537
+ }
538
+
539
+ fn left_aligned_width(area: Rect, max_width: u16) -> Rect {
540
+ let width = area.width.min(max_width);
541
+
542
+ Rect { width, ..area }
543
+ }
544
+
545
+ fn scroll_indicator(
546
+ total_rows: usize,
547
+ visible_rows: usize,
548
+ height: u16,
549
+ window_start: usize,
550
+ ) -> Paragraph<'static> {
551
+ let lines = scroll_indicator_lines(total_rows, visible_rows, height, window_start)
552
+ .into_iter()
553
+ .map(Line::from)
554
+ .collect::<Vec<_>>();
555
+
556
+ Paragraph::new(lines)
557
+ }
558
+
559
+ fn scroll_indicator_lines(
560
+ total_rows: usize,
561
+ visible_rows: usize,
562
+ height: u16,
563
+ window_start: usize,
564
+ ) -> Vec<Span<'static>> {
565
+ let height = height as usize;
566
+ if height == 0 {
567
+ return Vec::new();
568
+ }
569
+
570
+ let window_start = window_start.min(total_rows.saturating_sub(visible_rows));
571
+ let hidden_above = window_start > 0;
572
+ let hidden_below = window_start + visible_rows < total_rows;
573
+ let mut cells = vec![Span::raw(" "); height];
574
+ let marker_style = Style::default().add_modifier(Modifier::DIM);
575
+
576
+ if hidden_above {
577
+ cells[0] = Span::styled("↑", marker_style);
578
+ }
579
+
580
+ if hidden_below {
581
+ cells[height.saturating_sub(1)] = Span::styled("↓", marker_style);
582
+ }
583
+
584
+ if total_rows > visible_rows && height > 2 {
585
+ let track_height = height - 2;
586
+ let scrollable = total_rows - visible_rows;
587
+ let thumb_offset = (window_start * track_height) / scrollable;
588
+ let thumb_row = 1 + thumb_offset.min(track_height.saturating_sub(1));
589
+ cells[thumb_row] = Span::styled("|", marker_style);
590
+ }
591
+
592
+ cells
593
+ }
594
+
595
+ fn shortcut_url(character: char) -> Option<(&'static str, &'static str)> {
596
+ match character {
597
+ 'g' => Some(("GitHub", GITHUB_URL)),
598
+ 'x' => Some(("X", X_URL)),
599
+ _ => None,
600
+ }
601
+ }
602
+
603
+ fn logo_style(theme: TerminalTheme) -> Style {
604
+ match theme {
605
+ TerminalTheme::Dark => Style::default()
606
+ .fg(Color::White)
607
+ .add_modifier(Modifier::BOLD),
608
+ TerminalTheme::Light => Style::default()
609
+ .fg(Color::Black)
610
+ .add_modifier(Modifier::BOLD),
611
+ }
612
+ .remove_modifier(Modifier::DIM)
613
+ }
614
+
615
+ fn project_kind_style() -> Style {
616
+ Style::default().add_modifier(Modifier::DIM)
617
+ }
618
+
619
+ fn selected_row_style(theme: TerminalTheme) -> Style {
620
+ selected_row_style_for(theme)
621
+ }
622
+
623
+ fn selected_row_style_for(theme: TerminalTheme) -> Style {
624
+ match theme {
625
+ TerminalTheme::Dark => Style::default().fg(Color::Black).bg(Color::White),
626
+ TerminalTheme::Light => Style::default().fg(Color::White).bg(Color::Black),
627
+ }
628
+ .remove_modifier(Modifier::DIM)
629
+ }
630
+
631
+ fn metadata_line(width: u16) -> Line<'static> {
632
+ let width = width as usize;
633
+ let left_width = text_width(TAGLINE);
634
+
635
+ if width <= left_width + 4 {
636
+ return Line::from(Span::styled(
637
+ TAGLINE,
638
+ Style::default().add_modifier(Modifier::DIM),
639
+ ));
640
+ }
641
+
642
+ let max_right_width = width.saturating_sub(left_width + 1);
643
+ let links = truncate_to_width(
644
+ &format!("{GITHUB_DISPLAY_URL} {X_DISPLAY_URL}"),
645
+ max_right_width,
646
+ );
647
+ let right_width = text_width(&links);
648
+ let gap = width.saturating_sub(left_width + right_width);
649
+
650
+ Line::from(vec![
651
+ Span::styled(TAGLINE, Style::default().add_modifier(Modifier::DIM)),
652
+ Span::raw(" ".repeat(gap)),
653
+ Span::styled(links, Style::default().add_modifier(Modifier::DIM)),
654
+ ])
655
+ }
656
+
657
+ fn status_line(
658
+ width: u16,
659
+ mut shortcuts: Vec<Span<'static>>,
660
+ status: &str,
661
+ style: Style,
662
+ ) -> Line<'static> {
663
+ let width = width as usize;
664
+ let shortcut_width = spans_width(&shortcuts);
665
+ let min_gap = usize::from(width > shortcut_width);
666
+ let available_status_width = width.saturating_sub(shortcut_width + min_gap);
667
+ let status = truncate_to_width(status, available_status_width);
668
+ let status_width = text_width(&status);
669
+ let gap = width.saturating_sub(shortcut_width + status_width);
670
+
671
+ if gap > 0 {
672
+ shortcuts.push(Span::styled(" ".repeat(gap), style));
673
+ }
674
+
675
+ if status_width > 0 {
676
+ shortcuts.push(Span::styled(status, style));
677
+ }
678
+
679
+ Line::from(shortcuts)
680
+ }
681
+
682
+ fn status_bar_style(theme: TerminalTheme) -> Style {
683
+ status_bar_style_for(theme, terminal_colors_disabled())
684
+ }
685
+
686
+ fn status_bar_style_for(theme: TerminalTheme, colors_disabled: bool) -> Style {
687
+ if colors_disabled {
688
+ return Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
689
+ }
690
+
691
+ match theme {
692
+ TerminalTheme::Dark => Style::default().fg(Color::Black).bg(Color::White),
693
+ TerminalTheme::Light => Style::default().fg(Color::White).bg(Color::Black),
694
+ }
695
+ }
696
+
697
+ fn terminal_colors_disabled() -> bool {
698
+ env::var_os("NO_COLOR").is_some() && env::var_os("CLICOLOR_FORCE").is_none()
699
+ }
700
+
701
+ fn detect_terminal_theme() -> TerminalTheme {
702
+ env::var("CTATE_TUI_THEME")
703
+ .ok()
704
+ .and_then(|value| parse_theme_override(&value))
705
+ .or_else(|| {
706
+ env::var("COLORFGBG")
707
+ .ok()
708
+ .and_then(|value| parse_colorfgbg_theme(&value))
709
+ })
710
+ .unwrap_or(TerminalTheme::Dark)
711
+ }
712
+
713
+ fn parse_theme_override(value: &str) -> Option<TerminalTheme> {
714
+ match value.trim().to_ascii_lowercase().as_str() {
715
+ "dark" => Some(TerminalTheme::Dark),
716
+ "light" => Some(TerminalTheme::Light),
717
+ _ => None,
718
+ }
719
+ }
720
+
721
+ fn parse_colorfgbg_theme(value: &str) -> Option<TerminalTheme> {
722
+ let background = value.rsplit(';').next()?.parse::<u8>().ok()?;
723
+
724
+ match background {
725
+ 0..=6 | 8 => Some(TerminalTheme::Dark),
726
+ 7 | 9..=15 => Some(TerminalTheme::Light),
727
+ _ => None,
728
+ }
729
+ }
730
+
731
+ fn truncate_to_width(value: &str, width: usize) -> String {
732
+ if text_width(value) <= width {
733
+ return value.to_string();
734
+ }
735
+
736
+ if width <= 3 {
737
+ return value.chars().take(width).collect();
738
+ }
739
+
740
+ let mut truncated = value.chars().take(width - 3).collect::<String>();
741
+ truncated.push_str("...");
742
+ truncated
743
+ }
744
+
745
+ fn spans_width(spans: &[Span<'_>]) -> usize {
746
+ spans.iter().map(|span| text_width(&span.content)).sum()
747
+ }
748
+
749
+ #[cfg(test)]
750
+ fn line_width(line: &Line<'_>) -> usize {
751
+ spans_width(&line.spans)
752
+ }
753
+
754
+ fn text_width(value: &str) -> usize {
755
+ value.chars().count()
756
+ }
757
+
758
+ fn main() -> AppResult<()> {
759
+ if handle_cli_flags() {
760
+ return Ok(());
761
+ }
762
+
763
+ install_panic_hook();
764
+
765
+ let mut terminal = setup_terminal()?;
766
+ let app_result = App::new().run(&mut terminal);
767
+ let restore_result = restore_terminal(&mut terminal);
768
+
769
+ restore_result?;
770
+ app_result
771
+ }
772
+
773
+ fn handle_cli_flags() -> bool {
774
+ let args: Vec<String> = env::args().skip(1).collect();
775
+
776
+ if args.iter().any(|arg| arg == "--version" || arg == "-V") {
777
+ println!("ctate {}", env!("CARGO_PKG_VERSION"));
778
+ return true;
779
+ }
780
+
781
+ if args.iter().any(|arg| arg == "--help" || arg == "-h") {
782
+ println!("ctate");
783
+ println!();
784
+ println!("A ratatui version of ctate.dev.");
785
+ println!();
786
+ println!("Usage: ctate");
787
+ println!();
788
+ println!("Keys:");
789
+ println!(" Up/Down, j/k move selection");
790
+ println!(" Enter open selected link in a browser");
791
+ println!(" g open GitHub");
792
+ println!(" x open X");
793
+ println!(" q, Esc quit");
794
+ println!(" Ctrl-C quit");
795
+ return true;
796
+ }
797
+
798
+ false
799
+ }
800
+
801
+ fn setup_terminal() -> AppResult<Terminal<CrosstermBackend<io::Stdout>>> {
802
+ enable_raw_mode()?;
803
+ let mut stdout = io::stdout();
804
+ execute!(stdout, EnterAlternateScreen)?;
805
+ let backend = CrosstermBackend::new(stdout);
806
+ let mut terminal = Terminal::new(backend)?;
807
+ terminal.clear()?;
808
+ Ok(terminal)
809
+ }
810
+
811
+ fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> AppResult<()> {
812
+ disable_raw_mode()?;
813
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
814
+ terminal.show_cursor()?;
815
+ Ok(())
816
+ }
817
+
818
+ fn install_panic_hook() {
819
+ let original_hook = panic::take_hook();
820
+
821
+ panic::set_hook(Box::new(move |panic_info| {
822
+ let _ = disable_raw_mode();
823
+ let _ = execute!(io::stdout(), LeaveAlternateScreen);
824
+ original_hook(panic_info);
825
+ }));
826
+ }
827
+
828
+ #[cfg(test)]
829
+ mod tests {
830
+ use super::*;
831
+
832
+ #[test]
833
+ fn project_links_are_populated() {
834
+ assert_eq!(PROJECTS.len(), 41);
835
+
836
+ for project in PROJECTS {
837
+ assert!(!project.name.is_empty());
838
+ assert!(!project.kind.is_empty());
839
+ assert!(project.url.starts_with("https://"));
840
+ }
841
+ }
842
+
843
+ #[test]
844
+ fn app_area_is_centered_when_terminal_is_large() {
845
+ let areas = screen_areas(Rect {
846
+ x: 0,
847
+ y: 0,
848
+ width: 120,
849
+ height: 50,
850
+ });
851
+ let area = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
852
+
853
+ assert_eq!(area.width, MAX_APP_WIDTH);
854
+ assert_eq!(area.height, MAX_APP_HEIGHT);
855
+ assert_eq!(area.x, 19);
856
+ assert_eq!(area.y, 11);
857
+ }
858
+
859
+ #[test]
860
+ fn app_area_uses_available_space_when_terminal_is_small() {
861
+ let areas = screen_areas(Rect {
862
+ x: 0,
863
+ y: 0,
864
+ width: 60,
865
+ height: 20,
866
+ });
867
+ let area = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
868
+
869
+ assert_eq!(area.width, 60);
870
+ assert_eq!(area.height, 19);
871
+ assert_eq!(area.x, 0);
872
+ assert_eq!(area.y, 0);
873
+ }
874
+
875
+ #[test]
876
+ fn status_area_sticks_to_bottom_and_fills_width() {
877
+ let areas = screen_areas(Rect {
878
+ x: 0,
879
+ y: 0,
880
+ width: 120,
881
+ height: 50,
882
+ });
883
+
884
+ assert_eq!(areas.status.x, 0);
885
+ assert_eq!(areas.status.y, 49);
886
+ assert_eq!(areas.status.width, 120);
887
+ assert_eq!(areas.status.height, 1);
888
+ }
889
+
890
+ #[test]
891
+ fn project_table_viewport_is_limited_to_sixteen_rows() {
892
+ let areas = screen_areas(Rect {
893
+ x: 0,
894
+ y: 0,
895
+ width: 120,
896
+ height: 50,
897
+ });
898
+ let centered = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
899
+ let app_areas = app_layout_areas(centered);
900
+
901
+ assert_eq!(app_areas.table.height, PROJECT_TABLE_HEIGHT);
902
+ assert_eq!(app_areas.visible_rows, VISIBLE_PROJECT_ROWS);
903
+ assert_eq!(PROJECT_TABLE_HEIGHT - 1, VISIBLE_PROJECT_ROWS);
904
+ }
905
+
906
+ #[test]
907
+ fn logo_has_lowercase_x_height() {
908
+ assert_eq!(LOGO_LINES.len(), LOGO_HEIGHT as usize);
909
+ assert_eq!(HEADER_HEIGHT, LOGO_HEIGHT + LOGO_BOTTOM_SPACER_HEIGHT + 1);
910
+
911
+ assert!(LOGO_LINES[0]
912
+ .chars()
913
+ .take(8)
914
+ .all(|character| character == ' '));
915
+ assert!(LOGO_LINES[1]
916
+ .chars()
917
+ .take(8)
918
+ .any(|character| character == '█'));
919
+ assert!(LOGO_LINES[2].starts_with("██"));
920
+ }
921
+
922
+ #[test]
923
+ fn logo_uses_primary_style() {
924
+ let dark_style = logo_style(TerminalTheme::Dark);
925
+ let light_style = logo_style(TerminalTheme::Light);
926
+
927
+ assert_eq!(dark_style.fg, Some(Color::White));
928
+ assert_eq!(light_style.fg, Some(Color::Black));
929
+ assert!(dark_style.sub_modifier.contains(Modifier::DIM));
930
+ assert!(light_style.sub_modifier.contains(Modifier::DIM));
931
+ assert!(!dark_style.add_modifier.contains(Modifier::DIM));
932
+ assert!(!light_style.add_modifier.contains(Modifier::DIM));
933
+ assert!(dark_style.add_modifier.contains(Modifier::BOLD));
934
+ assert!(light_style.add_modifier.contains(Modifier::BOLD));
935
+ }
936
+
937
+ #[test]
938
+ fn selected_row_uses_primary_text_over_dim_kind() {
939
+ let kind_style = project_kind_style();
940
+ let dark_style = selected_row_style_for(TerminalTheme::Dark);
941
+ let light_style = selected_row_style_for(TerminalTheme::Light);
942
+
943
+ assert!(kind_style.add_modifier.contains(Modifier::DIM));
944
+ assert_eq!(dark_style.fg, Some(Color::Black));
945
+ assert_eq!(dark_style.bg, Some(Color::White));
946
+ assert_eq!(light_style.fg, Some(Color::White));
947
+ assert_eq!(light_style.bg, Some(Color::Black));
948
+ assert!(dark_style.sub_modifier.contains(Modifier::DIM));
949
+ assert!(light_style.sub_modifier.contains(Modifier::DIM));
950
+ assert!(!dark_style.add_modifier.contains(Modifier::REVERSED));
951
+ assert!(!light_style.add_modifier.contains(Modifier::REVERSED));
952
+ }
953
+
954
+ #[test]
955
+ fn project_table_title_aligns_with_left_content() {
956
+ assert!(SHIP_LIST_TITLE.starts_with("RECENT SHIPS AT"));
957
+ assert!(!SHIP_LIST_TITLE.starts_with(' '));
958
+ }
959
+
960
+ #[test]
961
+ fn project_table_is_two_columns_wider_than_header_when_space_allows() {
962
+ let areas = screen_areas(Rect {
963
+ x: 0,
964
+ y: 0,
965
+ width: 120,
966
+ height: 50,
967
+ });
968
+ let centered = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
969
+ let app_areas = app_layout_areas(centered);
970
+
971
+ assert_eq!(app_areas.header.width, HEADER_WIDTH);
972
+ assert_eq!(app_areas.table.width, LIST_WIDTH);
973
+ assert_eq!(app_areas.table.width, app_areas.header.width + 2);
974
+ assert_eq!(app_areas.table.x, app_areas.header.x);
975
+ assert_eq!(
976
+ app_areas.table.x + app_areas.table.width,
977
+ app_areas.header.x + app_areas.header.width + 2
978
+ );
979
+ assert_eq!(
980
+ app_areas.scroll.x,
981
+ app_areas.table.x + app_areas.table.width + SCROLLBAR_GAP_WIDTH
982
+ );
983
+ }
984
+
985
+ #[test]
986
+ fn project_table_has_a_line_above_it_when_space_allows() {
987
+ let areas = screen_areas(Rect {
988
+ x: 0,
989
+ y: 0,
990
+ width: 120,
991
+ height: 50,
992
+ });
993
+ let centered = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
994
+ let app_areas = app_layout_areas(centered);
995
+
996
+ assert_eq!(
997
+ app_areas.table.y,
998
+ app_areas.header.y + HEADER_HEIGHT + TABLE_TOP_SPACER_HEIGHT
999
+ );
1000
+ }
1001
+
1002
+ #[test]
1003
+ fn project_table_viewport_fits_standard_terminal_height() {
1004
+ let areas = screen_areas(Rect {
1005
+ x: 0,
1006
+ y: 0,
1007
+ width: 80,
1008
+ height: 24,
1009
+ });
1010
+ let centered = centered_area(areas.content, MAX_APP_WIDTH, MAX_APP_HEIGHT);
1011
+ let app_areas = app_layout_areas(centered);
1012
+
1013
+ assert_eq!(app_areas.table.y, app_areas.header.y + HEADER_HEIGHT);
1014
+ assert_eq!(app_areas.table.y + app_areas.table.height, areas.status.y);
1015
+ assert!(app_areas.table.height < PROJECT_TABLE_HEIGHT);
1016
+ assert!(app_areas.visible_rows < VISIBLE_PROJECT_ROWS);
1017
+ }
1018
+
1019
+ #[test]
1020
+ fn scroll_indicator_marks_more_rows_below_at_top() {
1021
+ let cells = scroll_indicator_lines(
1022
+ PROJECTS.len(),
1023
+ VISIBLE_PROJECT_ROWS as usize,
1024
+ PROJECT_TABLE_HEIGHT,
1025
+ 0,
1026
+ )
1027
+ .into_iter()
1028
+ .map(|span| span.content.into_owned())
1029
+ .collect::<Vec<_>>();
1030
+
1031
+ assert_eq!(cells[0], " ");
1032
+ assert_eq!(cells[1], "|");
1033
+ assert_eq!(cells[cells.len() - 1], "↓");
1034
+ }
1035
+
1036
+ #[test]
1037
+ fn scroll_indicator_marks_more_rows_above_and_below_in_middle() {
1038
+ let cells = scroll_indicator_lines(
1039
+ PROJECTS.len(),
1040
+ VISIBLE_PROJECT_ROWS as usize,
1041
+ PROJECT_TABLE_HEIGHT,
1042
+ 10,
1043
+ )
1044
+ .into_iter()
1045
+ .map(|span| span.content.into_owned())
1046
+ .collect::<Vec<_>>();
1047
+
1048
+ assert_eq!(cells[0], "↑");
1049
+ assert_eq!(cells[cells.len() - 1], "↓");
1050
+ assert!(cells.iter().any(|cell| cell == "|"));
1051
+ }
1052
+
1053
+ #[test]
1054
+ fn scroll_indicator_marks_more_rows_above_at_bottom() {
1055
+ let max_start = PROJECTS.len() - VISIBLE_PROJECT_ROWS as usize;
1056
+ let cells = scroll_indicator_lines(
1057
+ PROJECTS.len(),
1058
+ VISIBLE_PROJECT_ROWS as usize,
1059
+ PROJECT_TABLE_HEIGHT,
1060
+ max_start,
1061
+ )
1062
+ .into_iter()
1063
+ .map(|span| span.content.into_owned())
1064
+ .collect::<Vec<_>>();
1065
+
1066
+ assert_eq!(cells[0], "↑");
1067
+ assert_eq!(cells[cells.len() - 1], " ");
1068
+ assert!(cells.iter().any(|cell| cell == "|"));
1069
+ }
1070
+
1071
+ #[test]
1072
+ fn scroll_indicator_uses_dim_markers() {
1073
+ let cells = scroll_indicator_lines(
1074
+ PROJECTS.len(),
1075
+ VISIBLE_PROJECT_ROWS as usize,
1076
+ PROJECT_TABLE_HEIGHT,
1077
+ 10,
1078
+ );
1079
+
1080
+ for marker in ["↑", "|", "↓"] {
1081
+ let span = cells
1082
+ .iter()
1083
+ .find(|span| span.content.as_ref() == marker)
1084
+ .expect("scroll marker should be present");
1085
+
1086
+ assert!(span.style.add_modifier.contains(Modifier::DIM));
1087
+ assert!(!span.style.add_modifier.contains(Modifier::BOLD));
1088
+ }
1089
+ }
1090
+
1091
+ #[test]
1092
+ fn social_shortcuts_map_to_expected_urls() {
1093
+ assert_eq!(shortcut_url('g'), Some(("GitHub", GITHUB_URL)));
1094
+ assert_eq!(shortcut_url('x'), Some(("X", X_URL)));
1095
+ assert_eq!(shortcut_url('q'), None);
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn metadata_line_places_links_on_same_line() {
1100
+ let line = metadata_line(HEADER_WIDTH);
1101
+ let text = line
1102
+ .spans
1103
+ .iter()
1104
+ .map(|span| span.content.as_ref())
1105
+ .collect::<String>();
1106
+
1107
+ assert_eq!(line_width(&line), HEADER_WIDTH as usize);
1108
+ assert!(text.starts_with(TAGLINE));
1109
+ assert!(text.ends_with(X_DISPLAY_URL));
1110
+ assert!(text.contains(GITHUB_DISPLAY_URL));
1111
+ assert!(!text.contains("https://"));
1112
+ assert!(!text.contains("github "));
1113
+ }
1114
+
1115
+ #[test]
1116
+ fn status_line_keeps_shortcuts_left_and_hint_right() {
1117
+ let style = status_bar_style_for(TerminalTheme::Dark, false);
1118
+ let line = status_line(
1119
+ 48,
1120
+ vec![
1121
+ Span::styled("Enter", style.add_modifier(Modifier::BOLD)),
1122
+ Span::styled(" open ", style),
1123
+ Span::styled("x", style.add_modifier(Modifier::BOLD)),
1124
+ Span::styled(" x.com ", style),
1125
+ Span::styled("q", style.add_modifier(Modifier::BOLD)),
1126
+ Span::styled(" quit", style),
1127
+ ],
1128
+ "Select a ship",
1129
+ style,
1130
+ );
1131
+ let text = line
1132
+ .spans
1133
+ .iter()
1134
+ .map(|span| span.content.as_ref())
1135
+ .collect::<String>();
1136
+
1137
+ assert_eq!(line_width(&line), 48);
1138
+ assert!(text.starts_with("Enter open x x.com q quit"));
1139
+ assert!(text.ends_with("Select a ship"));
1140
+ assert!(
1141
+ line.spans
1142
+ .iter()
1143
+ .all(|span| span.style.fg == Some(Color::Black)
1144
+ && span.style.bg == Some(Color::White))
1145
+ );
1146
+ }
1147
+
1148
+ #[test]
1149
+ fn status_bar_uses_explicit_black_and_white_colors() {
1150
+ let dark_style = status_bar_style_for(TerminalTheme::Dark, false);
1151
+ let light_style = status_bar_style_for(TerminalTheme::Light, false);
1152
+
1153
+ assert_eq!(dark_style.fg, Some(Color::Black));
1154
+ assert_eq!(dark_style.bg, Some(Color::White));
1155
+ assert_eq!(light_style.fg, Some(Color::White));
1156
+ assert_eq!(light_style.bg, Some(Color::Black));
1157
+ }
1158
+
1159
+ #[test]
1160
+ fn status_bar_uses_reverse_video_when_colors_are_disabled() {
1161
+ let style = status_bar_style_for(TerminalTheme::Dark, true);
1162
+
1163
+ assert_eq!(style.fg, None);
1164
+ assert_eq!(style.bg, None);
1165
+ assert!(style.add_modifier.contains(Modifier::BOLD));
1166
+ assert!(style.add_modifier.contains(Modifier::REVERSED));
1167
+ }
1168
+
1169
+ #[test]
1170
+ fn theme_overrides_are_parsed() {
1171
+ assert_eq!(parse_theme_override("dark"), Some(TerminalTheme::Dark));
1172
+ assert_eq!(parse_theme_override("LIGHT"), Some(TerminalTheme::Light));
1173
+ assert_eq!(parse_theme_override(""), None);
1174
+ }
1175
+
1176
+ #[test]
1177
+ fn colorfgbg_background_is_parsed_when_available() {
1178
+ assert_eq!(parse_colorfgbg_theme("15;0"), Some(TerminalTheme::Dark));
1179
+ assert_eq!(parse_colorfgbg_theme("0;15"), Some(TerminalTheme::Light));
1180
+ assert_eq!(parse_colorfgbg_theme("bad"), None);
1181
+ }
1182
+
1183
+ #[test]
1184
+ fn navigation_does_not_replace_status_with_selection_name() {
1185
+ let mut app = App::new();
1186
+ let initial_status = app.status.clone();
1187
+
1188
+ app.next();
1189
+ app.previous();
1190
+ app.move_by(4);
1191
+
1192
+ assert_eq!(app.status, initial_status);
1193
+ }
1194
+ }