main
1mod app;
2mod protocol;
3
4use app::App;
5use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind};
6use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
7use crossterm::ExecutableCommand;
8use protocol::{Event, Line};
9use ratatui::layout::{Constraint, Layout};
10use ratatui::widgets::{Block, Borders, Paragraph};
11use ratatui::Terminal;
12use std::fs::{File, OpenOptions};
13use std::io::{self, BufRead, Write};
14use std::sync::mpsc;
15use std::sync::{Arc, Mutex};
16use std::thread;
17use std::time::Duration;
18
19fn write_reply(stdout: &Arc<Mutex<io::Stdout>>, line: Option<String>) {
20 let payload = serde_json::to_string(&Line { line }).unwrap();
21 let mut out = stdout.lock().unwrap();
22 let _ = writeln!(out, "{payload}");
23 let _ = out.flush();
24}
25
26fn open_tty() -> io::Result<File> {
27 OpenOptions::new().read(true).write(true).open("/dev/tty")
28}
29
30fn main() -> io::Result<()> {
31 let stdout_handle = Arc::new(Mutex::new(io::stdout()));
32
33 let (tx, rx) = mpsc::channel::<Event>();
34 thread::spawn(move || {
35 let stdin = io::stdin();
36 for line in stdin.lock().lines() {
37 let Ok(line) = line else { break };
38 if line.trim().is_empty() {
39 continue;
40 }
41 match serde_json::from_str::<Event>(&line) {
42 Ok(event) => {
43 if tx.send(event).is_err() {
44 break;
45 }
46 }
47 Err(err) => eprintln!("elelem-ratatui: bad event: {err}"),
48 }
49 }
50 });
51
52 let mut tty = open_tty()?;
53 enable_raw_mode()?;
54 tty.execute(EnterAlternateScreen)?;
55
56 let backend_tty = tty.try_clone()?;
57 let terminal = Terminal::new(ratatui::backend::CrosstermBackend::new(backend_tty));
58 let result = match terminal {
59 Ok(mut terminal) => {
60 let mut app = App::new();
61 run(&mut terminal, &mut app, &rx, &stdout_handle)
62 }
63 Err(err) => Err(err),
64 };
65
66 disable_raw_mode()?;
67 tty.execute(LeaveAlternateScreen)?;
68 result
69}
70
71fn run(
72 terminal: &mut Terminal<ratatui::backend::CrosstermBackend<File>>,
73 app: &mut App,
74 rx: &mpsc::Receiver<Event>,
75 stdout_handle: &Arc<Mutex<io::Stdout>>,
76) -> io::Result<()> {
77 loop {
78 while let Ok(event) = rx.try_recv() {
79 app.apply(event);
80 }
81
82 if app.should_quit {
83 return Ok(());
84 }
85
86 terminal.draw(|f| draw(f, app))?;
87
88 if event::poll(Duration::from_millis(50))? {
89 if let TermEvent::Key(key) = event::read()? {
90 if key.kind != KeyEventKind::Press {
91 continue;
92 }
93
94 if app.pending_prompt.is_some() {
95 match key.code {
96 KeyCode::Enter => {
97 let line = std::mem::take(&mut app.input);
98 app.pending_prompt = None;
99 write_reply(stdout_handle, Some(line));
100 }
101 KeyCode::Char('d')
102 if key
103 .modifiers
104 .contains(crossterm::event::KeyModifiers::CONTROL) =>
105 {
106 app.pending_prompt = None;
107 app.should_quit = true;
108 write_reply(stdout_handle, None);
109 }
110 KeyCode::Char(c) => app.input.push(c),
111 KeyCode::Backspace => {
112 app.input.pop();
113 }
114 _ => {}
115 }
116 } else if key.code == KeyCode::Char('c')
117 && key
118 .modifiers
119 .contains(crossterm::event::KeyModifiers::CONTROL)
120 {
121 app.should_quit = true;
122 }
123 }
124 }
125 }
126}
127
128fn draw(frame: &mut ratatui::Frame, app: &App) {
129 let area = frame.area();
130 let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
131
132 let title = if app.waiting { "elelem (waiting…)" } else { "elelem" };
133 let log = Paragraph::new(app.render_rows())
134 .block(Block::default().borders(Borders::ALL).title(title))
135 .wrap(ratatui::widgets::Wrap { trim: false });
136 frame.render_widget(log, chunks[0]);
137
138 let prompt_title = app.pending_prompt.as_deref().unwrap_or("(no prompt pending)");
139 let input = Paragraph::new(app.input.as_str())
140 .block(Block::default().borders(Borders::ALL).title(prompt_title));
141 frame.render_widget(input, chunks[1]);
142}