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