Skip to main content

telex/
terminal.rs

1use std::io::{self, Stdout, Write};
2
3use crossterm::{
4    cursor::{Hide, MoveTo, Show},
5    event::{self, poll, Event},
6    execute, queue,
7    style::{Attribute, Color, Print, SetAttribute, SetBackgroundColor, SetForegroundColor},
8    terminal::{
9        self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
10        LeaveAlternateScreen,
11    },
12};
13
14use crate::buffer::Buffer;
15use crate::canvas::{encode_kitty_graphics, supports_kitty_graphics, PendingCanvas};
16use crate::image::{encode_kitty_image, PendingImage};
17use crate::render::{render_view, RenderContext};
18use crate::theme::current_theme;
19use crate::View;
20
21/// Terminal wrapper that handles setup, rendering, and cleanup.
22pub struct Terminal {
23    stdout: Stdout,
24    buffer: Buffer,
25    prev_buffer: Buffer,
26    headless: bool,
27}
28
29impl Terminal {
30    /// Create a new terminal instance.
31    /// Enters raw mode and the alternate screen.
32    pub fn new() -> io::Result<Self> {
33        enable_raw_mode()?;
34        let mut stdout = io::stdout();
35        execute!(stdout, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
36
37        let (width, height) = terminal::size()?;
38        let buffer = Buffer::new(width, height);
39        let prev_buffer = Buffer::new(width, height);
40
41        Ok(Self {
42            stdout,
43            buffer,
44            prev_buffer,
45            headless: false,
46        })
47    }
48
49    /// Create a headless terminal for testing.
50    /// Does not enter raw mode or the alternate screen.
51    pub fn new_headless(width: u16, height: u16) -> Self {
52        let stdout = io::stdout();
53        let buffer = Buffer::new(width, height);
54        let prev_buffer = Buffer::new(width, height);
55
56        Self {
57            stdout,
58            buffer,
59            prev_buffer,
60            headless: true,
61        }
62    }
63
64    /// Draw a view to the terminal. Returns clamped scroll offsets to apply back to FocusManager.
65    pub fn draw(
66        &mut self,
67        view: &View,
68        focus_index: usize,
69        focus_visible: bool,
70        scroll_offsets: Vec<(u16, u16)>,
71        cursor_offsets: Vec<usize>,
72        modal_visible: bool,
73    ) -> io::Result<Vec<(u16, u16)>> {
74        if !self.headless {
75            // Check for resize
76            let (width, height) = terminal::size()?;
77            if width != self.buffer.width || height != self.buffer.height {
78                self.buffer = Buffer::new(width, height);
79                self.prev_buffer = Buffer::new(width, height);
80                // Force full redraw after resize
81                execute!(self.stdout, Clear(ClearType::All))?;
82            }
83        }
84
85        // Fill the buffer with the current theme's background color
86        let theme = current_theme();
87        self.buffer.fill(theme.foreground, theme.background);
88
89        // Render the view into the buffer
90        let area = self.buffer.rect();
91        let mut ctx = RenderContext::new(focus_index, focus_visible, scroll_offsets, cursor_offsets, area);
92        ctx.set_modal_visible(modal_visible);
93        render_view(&mut self.buffer, view, area, &mut ctx);
94
95        // Render overlays (menu dropdowns) after main content
96        ctx.render_pending_dropdowns(&mut self.buffer);
97
98        if !self.headless {
99            // Get pending canvases and images before finishing with ctx
100            let pending_canvases = ctx.take_pending_canvases();
101            let pending_images = ctx.take_pending_images();
102
103            // Compute diff and write changes (Pass 1: character buffer)
104            self.flush_diff()?;
105
106            // Pass 2: Render canvas graphics via Kitty protocol
107            if !pending_canvases.is_empty() {
108                self.flush_canvases(&pending_canvases)?;
109            }
110
111            // Pass 3: Render images via Kitty protocol
112            if !pending_images.is_empty() {
113                self.flush_images(&pending_images)?;
114            }
115        }
116
117        // Swap buffers
118        std::mem::swap(&mut self.buffer, &mut self.prev_buffer);
119
120        // Return potentially clamped scroll offsets
121        Ok(ctx.scroll_offsets().to_vec())
122    }
123
124    /// Get the terminal height (useful for page up/down calculations).
125    pub fn height(&self) -> u16 {
126        self.buffer.height
127    }
128
129    /// Get the terminal width.
130    pub fn width(&self) -> u16 {
131        self.buffer.width
132    }
133
134    /// Get the last rendered frame as a string (headless mode).
135    /// The prev_buffer holds the last drawn frame (after swap in draw()).
136    pub fn buffer_string(&self) -> String {
137        self.prev_buffer.to_string()
138    }
139
140    /// Flush only the changed cells to the terminal.
141    fn flush_diff(&mut self) -> io::Result<()> {
142        let changes = self.buffer.diff(&self.prev_buffer);
143
144        for (x, y, cell) in changes {
145            // Skip wide character continuation cells - the wide char already occupies this space.
146            // Printing here would overwrite the second half of the emoji/CJK character.
147            if cell.wide_continuation {
148                continue;
149            }
150
151            queue!(self.stdout, MoveTo(x, y))?;
152
153            // Reset attributes first to avoid state leakage
154            queue!(self.stdout, SetAttribute(Attribute::Reset))?;
155
156            // Apply text styles
157            if cell.bold {
158                queue!(self.stdout, SetAttribute(Attribute::Bold))?;
159            }
160            if cell.italic {
161                queue!(self.stdout, SetAttribute(Attribute::Italic))?;
162            }
163            if cell.underline {
164                queue!(self.stdout, SetAttribute(Attribute::Underlined))?;
165            }
166            if cell.dim {
167                queue!(self.stdout, SetAttribute(Attribute::Dim))?;
168            }
169
170            // Set colors
171            queue!(self.stdout, SetForegroundColor(cell.fg))?;
172            queue!(self.stdout, SetBackgroundColor(cell.bg))?;
173
174            queue!(self.stdout, Print(cell.ch))?;
175        }
176
177        // Reset attributes at end
178        queue!(self.stdout, SetAttribute(Attribute::Reset))?;
179        self.stdout.flush()?;
180        Ok(())
181    }
182
183    /// Flush canvas graphics to terminal via Kitty protocol.
184    fn flush_canvases(&mut self, canvases: &[PendingCanvas]) -> io::Result<()> {
185        if !supports_kitty_graphics() {
186            return Ok(());
187        }
188
189        for canvas in canvases {
190            let escape_seq =
191                encode_kitty_graphics(&canvas.pixels, canvas.cell_x, canvas.cell_y, canvas.id);
192            self.stdout.write_all(escape_seq.as_bytes())?;
193        }
194
195        self.stdout.flush()?;
196        Ok(())
197    }
198
199    /// Flush images to terminal via Kitty protocol.
200    fn flush_images(&mut self, images: &[PendingImage]) -> io::Result<()> {
201        if !supports_kitty_graphics() {
202            return Ok(());
203        }
204
205        for image in images {
206            let escape_seq = encode_kitty_image(&image.data, image.cell_x, image.cell_y, image.id);
207            self.stdout.write_all(escape_seq.as_bytes())?;
208        }
209
210        self.stdout.flush()?;
211        Ok(())
212    }
213
214    /// Poll for an input event with the given timeout.
215    pub fn poll_event(&self, timeout: std::time::Duration) -> io::Result<Option<Event>> {
216        if poll(timeout)? {
217            Ok(Some(event::read()?))
218        } else {
219            Ok(None)
220        }
221    }
222
223    /// Draw debug information overlay.
224    pub fn draw_debug(
225        &mut self,
226        frame: u64,
227        render_us: u64,
228        focus_idx: usize,
229        focusable_count: usize,
230    ) -> io::Result<()> {
231        let _ = (frame, render_us); // Suppress unused warnings
232        let debug_text = format!(" Focus: {}/{} ", focus_idx, focusable_count);
233
234        // Draw at bottom-right corner
235        let x = self
236            .buffer
237            .width
238            .saturating_sub(debug_text.len() as u16 + 1);
239        let y = 0; // Top of screen
240
241        queue!(
242            self.stdout,
243            MoveTo(x, y),
244            SetForegroundColor(Color::Black),
245            SetBackgroundColor(Color::Yellow),
246            Print(&debug_text),
247            SetForegroundColor(Color::Reset),
248            SetBackgroundColor(Color::Reset)
249        )?;
250        self.stdout.flush()?;
251        Ok(())
252    }
253
254    /// Clean up the terminal state.
255    pub fn cleanup(&mut self) -> io::Result<()> {
256        if self.headless {
257            return Ok(());
258        }
259
260        // Delete any Kitty graphics images
261        if supports_kitty_graphics() {
262            let delete_cmd = crate::canvas::delete_all_kitty_images();
263            let _ = self.stdout.write_all(delete_cmd.as_bytes());
264        }
265
266        execute!(
267            self.stdout,
268            Clear(ClearType::All),
269            SetForegroundColor(Color::Reset),
270            SetBackgroundColor(Color::Reset),
271            Show,
272            LeaveAlternateScreen
273        )?;
274        disable_raw_mode()?;
275        Ok(())
276    }
277}
278
279impl Drop for Terminal {
280    fn drop(&mut self) {
281        if self.headless {
282            return;
283        }
284
285        // Best-effort cleanup on drop
286        // Delete any Kitty graphics images
287        if supports_kitty_graphics() {
288            let delete_cmd = crate::canvas::delete_all_kitty_images();
289            let _ = self.stdout.write_all(delete_cmd.as_bytes());
290        }
291
292        let _ = execute!(
293            self.stdout,
294            Clear(ClearType::All),
295            SetForegroundColor(Color::Reset),
296            SetBackgroundColor(Color::Reset),
297            Show,
298            LeaveAlternateScreen
299        );
300        let _ = disable_raw_mode();
301    }
302}