syncable_cli/agent/ui/
layout.rs1use crossterm::{cursor::MoveTo, execute, terminal};
11use std::io::{self, Write};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
14
15use super::colors::ansi;
16
17const RESERVED_LINES: u16 = 4;
19
20pub mod escape {
22 pub fn set_scroll_region(top: u16, bottom: u16) -> String {
24 format!("\x1b[{};{}r", top, bottom)
25 }
26
27 pub const RESET_SCROLL_REGION: &str = "\x1b[r";
29
30 pub const SAVE_CURSOR: &str = "\x1b[s";
32
33 pub const RESTORE_CURSOR: &str = "\x1b[u";
35
36 pub fn move_to_line(line: u16) -> String {
38 format!("\x1b[{};1H", line)
39 }
40}
41
42#[derive(Debug)]
44pub struct LayoutState {
45 pub active: AtomicBool,
47 pub term_height: AtomicU16,
49 pub term_width: AtomicU16,
51}
52
53impl Default for LayoutState {
54 fn default() -> Self {
55 let (width, height) = terminal::size().unwrap_or((80, 24));
56 Self {
57 active: AtomicBool::new(false),
58 term_height: AtomicU16::new(height),
59 term_width: AtomicU16::new(width),
60 }
61 }
62}
63
64impl LayoutState {
65 pub fn new() -> Arc<Self> {
66 Arc::new(Self::default())
67 }
68
69 pub fn is_active(&self) -> bool {
70 self.active.load(Ordering::SeqCst)
71 }
72
73 pub fn height(&self) -> u16 {
74 self.term_height.load(Ordering::SeqCst)
75 }
76
77 pub fn width(&self) -> u16 {
78 self.term_width.load(Ordering::SeqCst)
79 }
80
81 pub fn status_line(&self) -> u16 {
83 self.height().saturating_sub(3)
84 }
85
86 pub fn focus_line(&self) -> u16 {
88 self.height().saturating_sub(2)
89 }
90
91 pub fn input_line(&self) -> u16 {
93 self.height().saturating_sub(1)
94 }
95
96 pub fn mode_line(&self) -> u16 {
98 self.height()
99 }
100}
101
102pub struct TerminalLayout {
104 state: Arc<LayoutState>,
105}
106
107impl TerminalLayout {
108 pub fn new() -> Self {
110 Self {
111 state: LayoutState::new(),
112 }
113 }
114
115 pub fn state(&self) -> Arc<LayoutState> {
117 self.state.clone()
118 }
119
120 pub fn init(&self) -> io::Result<()> {
122 let mut stdout = io::stdout();
123
124 let (width, height) = terminal::size()?;
126 self.state.term_width.store(width, Ordering::SeqCst);
127 self.state.term_height.store(height, Ordering::SeqCst);
128
129 let scroll_bottom = height.saturating_sub(RESERVED_LINES);
131
132 execute!(stdout, MoveTo(0, height - 1))?;
134 for _ in 0..RESERVED_LINES {
135 println!();
136 }
137
138 print!("{}", escape::set_scroll_region(1, scroll_bottom));
140
141 execute!(stdout, MoveTo(0, 0))?;
143
144 self.draw_status_line("")?;
146 self.draw_focus_line(None)?;
147 self.draw_input_line(false)?;
148 self.draw_mode_line(false)?;
149
150 execute!(stdout, MoveTo(0, 0))?;
152
153 self.state.active.store(true, Ordering::SeqCst);
154 stdout.flush()?;
155
156 Ok(())
157 }
158
159 pub fn update_status(&self, content: &str) -> io::Result<()> {
161 if !self.state.is_active() {
162 return Ok(());
163 }
164
165 let mut stdout = io::stdout();
166 let status_line = self.state.status_line();
167
168 print!("{}", escape::SAVE_CURSOR);
170 print!("{}", escape::move_to_line(status_line));
171 print!("{}", ansi::CLEAR_LINE);
172 print!("{}", content);
173 print!("{}", escape::RESTORE_CURSOR);
174 stdout.flush()?;
175
176 Ok(())
177 }
178
179 fn draw_status_line(&self, content: &str) -> io::Result<()> {
181 let mut stdout = io::stdout();
182 let status_line = self.state.status_line();
183
184 print!("{}", escape::move_to_line(status_line));
185 print!("{}", ansi::CLEAR_LINE);
186 if !content.is_empty() {
187 print!("{}", content);
188 }
189 stdout.flush()?;
190
191 Ok(())
192 }
193
194 fn draw_focus_line(&self, content: Option<&str>) -> io::Result<()> {
196 let mut stdout = io::stdout();
197 let focus_line = self.state.focus_line();
198
199 print!("{}", escape::move_to_line(focus_line));
200 print!("{}", ansi::CLEAR_LINE);
201 if let Some(text) = content {
202 print!(
203 "{}└{} {}{}{}",
204 ansi::DIM,
205 ansi::RESET,
206 ansi::GRAY,
207 text,
208 ansi::RESET
209 );
210 }
211 stdout.flush()?;
212
213 Ok(())
214 }
215
216 fn draw_input_line(&self, _has_text: bool) -> io::Result<()> {
218 let mut stdout = io::stdout();
219 let input_line = self.state.input_line();
220
221 print!("{}", escape::move_to_line(input_line));
222 print!("{}", ansi::CLEAR_LINE);
223 stdout.flush()?;
225
226 Ok(())
227 }
228
229 fn draw_mode_line(&self, plan_mode: bool) -> io::Result<()> {
231 let mut stdout = io::stdout();
232 let mode_line = self.state.mode_line();
233
234 print!("{}", escape::move_to_line(mode_line));
235 print!("{}", ansi::CLEAR_LINE);
236
237 if plan_mode {
238 print!(
239 "{}⏸ plan mode on (shift+tab to switch){}",
240 ansi::DIM,
241 ansi::RESET
242 );
243 } else {
244 print!(
245 "{}▶ standard mode (shift+tab to switch){}",
246 ansi::DIM,
247 ansi::RESET
248 );
249 }
250 stdout.flush()?;
251
252 Ok(())
253 }
254
255 pub fn update_mode(&self, plan_mode: bool) -> io::Result<()> {
257 if !self.state.is_active() {
258 return Ok(());
259 }
260
261 let mut stdout = io::stdout();
262
263 print!("{}", escape::SAVE_CURSOR);
264 self.draw_mode_line(plan_mode)?;
265 print!("{}", escape::RESTORE_CURSOR);
266 stdout.flush()?;
267
268 Ok(())
269 }
270
271 pub fn position_for_input(&self) -> io::Result<()> {
273 if !self.state.is_active() {
274 return Ok(());
275 }
276
277 let mut stdout = io::stdout();
278 let input_line = self.state.input_line();
279
280 print!("{}", escape::move_to_line(input_line));
281 print!("{}", ansi::CLEAR_LINE);
282 stdout.flush()?;
283
284 Ok(())
285 }
286
287 pub fn position_for_output(&self) -> io::Result<()> {
289 if !self.state.is_active() {
290 return Ok(());
291 }
292
293 print!("{}", escape::RESTORE_CURSOR);
295 io::stdout().flush()?;
296
297 Ok(())
298 }
299
300 pub fn cleanup(&self) -> io::Result<()> {
302 if !self.state.is_active() {
303 return Ok(());
304 }
305
306 let mut stdout = io::stdout();
307
308 print!("{}", escape::RESET_SCROLL_REGION);
310
311 let height = self.state.height();
313 for line in (height - RESERVED_LINES + 1)..=height {
314 print!("{}", escape::move_to_line(line));
315 print!("{}", ansi::CLEAR_LINE);
316 }
317
318 execute!(stdout, MoveTo(0, height - 1))?;
320 print!("{}", ansi::SHOW_CURSOR);
321
322 self.state.active.store(false, Ordering::SeqCst);
323 stdout.flush()?;
324
325 Ok(())
326 }
327
328 pub fn handle_resize(&self) -> io::Result<()> {
330 if !self.state.is_active() {
331 return Ok(());
332 }
333
334 let (width, height) = terminal::size()?;
336 self.state.term_width.store(width, Ordering::SeqCst);
337 self.state.term_height.store(height, Ordering::SeqCst);
338
339 let scroll_bottom = height.saturating_sub(RESERVED_LINES);
341 print!("{}", escape::set_scroll_region(1, scroll_bottom));
342
343 self.draw_status_line("")?;
345 self.draw_focus_line(None)?;
346 self.draw_input_line(false)?;
347 self.draw_mode_line(false)?;
348
349 io::stdout().flush()?;
350 Ok(())
351 }
352}
353
354impl Default for TerminalLayout {
355 fn default() -> Self {
356 Self::new()
357 }
358}
359
360impl Drop for TerminalLayout {
361 fn drop(&mut self) {
362 let _ = self.cleanup();
363 }
364}
365
366pub fn print_to_scroll_region(content: &str) {
369 print!("{}", content);
371 let _ = io::stdout().flush();
372}
373
374pub fn println_to_scroll_region(content: &str) {
376 println!("{}", content);
377 let _ = io::stdout().flush();
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_layout_state_defaults() {
386 let state = LayoutState::default();
387 assert!(!state.is_active());
388 assert!(state.height() > 0);
389 assert!(state.width() > 0);
390 }
391
392 #[test]
393 fn test_scroll_region_escape() {
394 assert_eq!(escape::set_scroll_region(1, 20), "\x1b[1;20r");
395 assert_eq!(escape::move_to_line(5), "\x1b[5;1H");
396 }
397}