Skip to main content

telex/
canvas.rs

1//! Canvas widget for pixel-level drawing using the Kitty graphics protocol.
2//!
3//! This module provides:
4//! - `PixelBuffer` - RGBA pixel storage
5//! - `DrawContext` - Drawing API for the `on_draw` callback
6//! - `animated_canvas` - Helper for frame-based animation
7//! - Kitty protocol encoding for terminal output
8//!
9//! # Animated Canvas Example
10//!
11//! ```rust,ignore
12//! use telex::prelude::*;
13//! use telex::canvas::animated_canvas;
14//!
15//! fn App(cx: Scope) -> View {
16//!     animated_canvas(cx)
17//!         .width(200)
18//!         .height(100)
19//!         .fps(30)
20//!         .on_frame(|ctx, frame| {
21//!             ctx.clear(Color::Black);
22//!             let x = (frame % 200) as u16;
23//!             ctx.fill_circle(x, 50, 10, Color::Red);
24//!         })
25//!         .build()
26//! }
27//! ```
28
29use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
30use crossterm::style::Color;
31use std::rc::Rc;
32use std::time::Duration;
33
34use crate::Scope;
35use crate::View;
36
37/// Type alias for the frame drawing callback.
38type FrameCallback = Option<Rc<dyn Fn(&mut DrawContext, u64)>>;
39
40/// RGBA pixel buffer for canvas rendering.
41#[derive(Clone)]
42pub struct PixelBuffer {
43    width: u16,
44    height: u16,
45    /// RGBA data, row-major, 4 bytes per pixel
46    data: Vec<u8>,
47}
48
49impl PixelBuffer {
50    /// Create a new pixel buffer filled with transparent black.
51    pub fn new(width: u16, height: u16) -> Self {
52        let size = (width as usize) * (height as usize) * 4;
53        Self {
54            width,
55            height,
56            data: vec![0; size],
57        }
58    }
59
60    /// Get buffer dimensions.
61    pub fn dimensions(&self) -> (u16, u16) {
62        (self.width, self.height)
63    }
64
65    /// Get pixel at (x, y) as (r, g, b, a).
66    pub fn get(&self, x: u16, y: u16) -> (u8, u8, u8, u8) {
67        if x >= self.width || y >= self.height {
68            return (0, 0, 0, 0);
69        }
70        let idx = ((y as usize) * (self.width as usize) + (x as usize)) * 4;
71        (
72            self.data[idx],
73            self.data[idx + 1],
74            self.data[idx + 2],
75            self.data[idx + 3],
76        )
77    }
78
79    /// Set pixel at (x, y) with RGBA values.
80    pub fn set(&mut self, x: u16, y: u16, r: u8, g: u8, b: u8, a: u8) {
81        if x >= self.width || y >= self.height {
82            return;
83        }
84        let idx = ((y as usize) * (self.width as usize) + (x as usize)) * 4;
85        self.data[idx] = r;
86        self.data[idx + 1] = g;
87        self.data[idx + 2] = b;
88        self.data[idx + 3] = a;
89    }
90
91    /// Clear the buffer to a solid color.
92    pub fn clear(&mut self, r: u8, g: u8, b: u8, a: u8) {
93        for i in (0..self.data.len()).step_by(4) {
94            self.data[i] = r;
95            self.data[i + 1] = g;
96            self.data[i + 2] = b;
97            self.data[i + 3] = a;
98        }
99    }
100
101    /// Get raw RGBA bytes.
102    pub fn as_bytes(&self) -> &[u8] {
103        &self.data
104    }
105}
106
107/// Drawing context passed to the canvas `on_draw` callback.
108///
109/// Provides primitives for drawing pixels, lines, rectangles, and circles.
110pub struct DrawContext<'a> {
111    buffer: &'a mut PixelBuffer,
112}
113
114impl<'a> DrawContext<'a> {
115    /// Create a new draw context wrapping a pixel buffer.
116    pub fn new(buffer: &'a mut PixelBuffer) -> Self {
117        Self { buffer }
118    }
119
120    /// Get canvas dimensions (width, height) in pixels.
121    pub fn dimensions(&self) -> (u16, u16) {
122        self.buffer.dimensions()
123    }
124
125    /// Clear the canvas to a solid color.
126    pub fn clear(&mut self, color: Color) {
127        let (r, g, b) = color_to_rgb(color);
128        self.buffer.clear(r, g, b, 255);
129    }
130
131    /// Set a single pixel.
132    pub fn pixel(&mut self, x: u16, y: u16, color: Color) {
133        let (r, g, b) = color_to_rgb(color);
134        self.buffer.set(x, y, r, g, b, 255);
135    }
136
137    /// Set a pixel with alpha.
138    pub fn pixel_alpha(&mut self, x: u16, y: u16, color: Color, alpha: u8) {
139        let (r, g, b) = color_to_rgb(color);
140        self.buffer.set(x, y, r, g, b, alpha);
141    }
142
143    /// Draw a line using Bresenham's algorithm.
144    pub fn line(&mut self, x1: i32, y1: i32, x2: i32, y2: i32, color: Color) {
145        let (r, g, b) = color_to_rgb(color);
146
147        let dx = (x2 - x1).abs();
148        let dy = -(y2 - y1).abs();
149        let sx = if x1 < x2 { 1 } else { -1 };
150        let sy = if y1 < y2 { 1 } else { -1 };
151        let mut err = dx + dy;
152
153        let mut x = x1;
154        let mut y = y1;
155
156        loop {
157            if x >= 0 && y >= 0 {
158                self.buffer.set(x as u16, y as u16, r, g, b, 255);
159            }
160
161            if x == x2 && y == y2 {
162                break;
163            }
164
165            let e2 = 2 * err;
166            if e2 >= dy {
167                err += dy;
168                x += sx;
169            }
170            if e2 <= dx {
171                err += dx;
172                y += sy;
173            }
174        }
175    }
176
177    /// Draw a stroked rectangle (outline only).
178    pub fn stroke_rect(&mut self, x: u16, y: u16, w: u16, h: u16, color: Color) {
179        if w == 0 || h == 0 {
180            return;
181        }
182        let x2 = x.saturating_add(w).saturating_sub(1);
183        let y2 = y.saturating_add(h).saturating_sub(1);
184
185        // Top and bottom
186        self.line(x as i32, y as i32, x2 as i32, y as i32, color);
187        self.line(x as i32, y2 as i32, x2 as i32, y2 as i32, color);
188        // Left and right
189        self.line(x as i32, y as i32, x as i32, y2 as i32, color);
190        self.line(x2 as i32, y as i32, x2 as i32, y2 as i32, color);
191    }
192
193    /// Draw a filled rectangle.
194    pub fn fill_rect(&mut self, x: u16, y: u16, w: u16, h: u16, color: Color) {
195        let (r, g, b) = color_to_rgb(color);
196        for dy in 0..h {
197            for dx in 0..w {
198                self.buffer
199                    .set(x.saturating_add(dx), y.saturating_add(dy), r, g, b, 255);
200            }
201        }
202    }
203
204    /// Draw a stroked circle (outline only) using midpoint algorithm.
205    pub fn circle(&mut self, cx: u16, cy: u16, radius: u16, color: Color) {
206        if radius == 0 {
207            self.pixel(cx, cy, color);
208            return;
209        }
210
211        let (r, g, b) = color_to_rgb(color);
212        let cx = cx as i32;
213        let cy = cy as i32;
214        let mut x = radius as i32;
215        let mut y = 0i32;
216        let mut err = 1 - x;
217
218        while x >= y {
219            // Draw 8 octants
220            self.set_pixel_safe(cx + x, cy + y, r, g, b);
221            self.set_pixel_safe(cx + y, cy + x, r, g, b);
222            self.set_pixel_safe(cx - y, cy + x, r, g, b);
223            self.set_pixel_safe(cx - x, cy + y, r, g, b);
224            self.set_pixel_safe(cx - x, cy - y, r, g, b);
225            self.set_pixel_safe(cx - y, cy - x, r, g, b);
226            self.set_pixel_safe(cx + y, cy - x, r, g, b);
227            self.set_pixel_safe(cx + x, cy - y, r, g, b);
228
229            y += 1;
230            if err < 0 {
231                err += 2 * y + 1;
232            } else {
233                x -= 1;
234                err += 2 * (y - x + 1);
235            }
236        }
237    }
238
239    /// Draw a filled circle.
240    pub fn fill_circle(&mut self, cx: u16, cy: u16, radius: u16, color: Color) {
241        let (r, g, b) = color_to_rgb(color);
242        let cx = cx as i32;
243        let cy = cy as i32;
244        let radius = radius as i32;
245
246        for dy in -radius..=radius {
247            for dx in -radius..=radius {
248                if dx * dx + dy * dy <= radius * radius {
249                    self.set_pixel_safe(cx + dx, cy + dy, r, g, b);
250                }
251            }
252        }
253    }
254
255    /// Helper to set pixel with bounds checking for signed coords.
256    fn set_pixel_safe(&mut self, x: i32, y: i32, r: u8, g: u8, b: u8) {
257        if x >= 0 && y >= 0 {
258            self.buffer.set(x as u16, y as u16, r, g, b, 255);
259        }
260    }
261}
262
263/// Convert crossterm Color to RGB tuple.
264fn color_to_rgb(color: Color) -> (u8, u8, u8) {
265    match color {
266        Color::Rgb { r, g, b } => (r, g, b),
267        Color::Black => (0, 0, 0),
268        Color::DarkGrey => (128, 128, 128),
269        Color::Red => (255, 0, 0),
270        Color::DarkRed => (139, 0, 0),
271        Color::Green => (0, 255, 0),
272        Color::DarkGreen => (0, 100, 0),
273        Color::Yellow => (255, 255, 0),
274        Color::DarkYellow => (128, 128, 0),
275        Color::Blue => (0, 0, 255),
276        Color::DarkBlue => (0, 0, 139),
277        Color::Magenta => (255, 0, 255),
278        Color::DarkMagenta => (139, 0, 139),
279        Color::Cyan => (0, 255, 255),
280        Color::DarkCyan => (0, 139, 139),
281        Color::White => (255, 255, 255),
282        Color::Grey => (192, 192, 192),
283        Color::Reset => (0, 0, 0), // Default to black
284        Color::AnsiValue(v) => ansi_to_rgb(v),
285    }
286}
287
288/// Convert ANSI 256-color to RGB.
289fn ansi_to_rgb(code: u8) -> (u8, u8, u8) {
290    match code {
291        0..=15 => {
292            // Standard colors
293            let colors = [
294                (0, 0, 0),
295                (128, 0, 0),
296                (0, 128, 0),
297                (128, 128, 0),
298                (0, 0, 128),
299                (128, 0, 128),
300                (0, 128, 128),
301                (192, 192, 192),
302                (128, 128, 128),
303                (255, 0, 0),
304                (0, 255, 0),
305                (255, 255, 0),
306                (0, 0, 255),
307                (255, 0, 255),
308                (0, 255, 255),
309                (255, 255, 255),
310            ];
311            colors[code as usize]
312        }
313        16..=231 => {
314            // 216-color cube
315            let n = code - 16;
316            let r = (n / 36) % 6;
317            let g = (n / 6) % 6;
318            let b = n % 6;
319            let to_rgb = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
320            (to_rgb(r), to_rgb(g), to_rgb(b))
321        }
322        232..=255 => {
323            // Grayscale
324            let gray = 8 + (code - 232) * 10;
325            (gray, gray, gray)
326        }
327    }
328}
329
330// =============================================================================
331// Kitty Graphics Protocol Encoding
332// =============================================================================
333
334/// A pending canvas graphic to be rendered after the character buffer.
335#[derive(Clone)]
336pub struct PendingCanvas {
337    /// Cell column position
338    pub cell_x: u16,
339    /// Cell row position
340    pub cell_y: u16,
341    /// Pixel buffer to render
342    pub pixels: PixelBuffer,
343    /// Unique ID for this canvas (for caching/replacement)
344    pub id: u32,
345}
346
347/// Check if the terminal supports Kitty graphics protocol.
348pub fn supports_kitty_graphics() -> bool {
349    if let Ok(term) = std::env::var("TERM") {
350        let term_lower = term.to_lowercase();
351        if term_lower.contains("kitty") || term_lower.contains("ghostty") {
352            return true;
353        }
354    }
355
356    // Also check TERM_PROGRAM for terminals that don't set TERM correctly
357    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
358        let program_lower = term_program.to_lowercase();
359        if program_lower.contains("kitty")
360            || program_lower.contains("ghostty")
361            || program_lower.contains("wezterm")
362        {
363            return true;
364        }
365    }
366
367    false
368}
369
370/// Encode a pixel buffer as Kitty graphics protocol escape sequences.
371///
372/// Returns the complete escape sequence string to write to the terminal.
373pub fn encode_kitty_graphics(
374    pixels: &PixelBuffer,
375    cell_x: u16,
376    cell_y: u16,
377    image_id: u32,
378) -> String {
379    let (width, height) = pixels.dimensions();
380    if width == 0 || height == 0 {
381        return String::new();
382    }
383
384    // Encode RGBA data as base64
385    let b64_data = BASE64.encode(pixels.as_bytes());
386
387    // Kitty protocol uses chunked transmission for large images
388    // Max chunk size is 4096 bytes of base64 data
389    const CHUNK_SIZE: usize = 4096;
390
391    let mut result = String::new();
392    let chunks: Vec<&str> = b64_data
393        .as_bytes()
394        .chunks(CHUNK_SIZE)
395        .map(|c| std::str::from_utf8(c).unwrap_or(""))
396        .collect();
397
398    let total_chunks = chunks.len();
399
400    for (i, chunk) in chunks.iter().enumerate() {
401        let is_first = i == 0;
402        let is_last = i == total_chunks - 1;
403        let more = if is_last { 0 } else { 1 };
404
405        if is_first {
406            // First chunk includes full header
407            // a=T means transmit and display
408            // f=32 means RGBA format
409            // t=d means direct (data follows)
410            // i=ID for image identification
411            // p=1 means placement (display at cursor)
412            // q=2 suppresses responses
413            result.push_str(&format!(
414                "\x1b_Ga=T,f=32,s={},v={},i={},t=d,m={},q=2;{}\x1b\\",
415                width, height, image_id, more, chunk
416            ));
417        } else {
418            // Continuation chunks
419            result.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
420        }
421    }
422
423    // Position the image at the specified cell
424    // We use the cursor position before sending the image
425    result.insert_str(0, &format!("\x1b[{};{}H", cell_y + 1, cell_x + 1));
426
427    result
428}
429
430/// Delete a previously displayed image by ID.
431pub fn delete_kitty_image(image_id: u32) -> String {
432    // d=I means delete by image ID
433    // q=2 suppresses responses
434    format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", image_id)
435}
436
437/// Delete all Kitty graphics images.
438pub fn delete_all_kitty_images() -> String {
439    // d=a means delete all
440    // q=2 suppresses responses
441    "\x1b_Ga=d,d=a,q=2\x1b\\".to_string()
442}
443
444// =============================================================================
445// Animated Canvas
446// =============================================================================
447
448/// Create an animated canvas with automatic frame management.
449///
450/// This is a convenience helper that manages frame timing internally using
451/// a stream. The `on_frame` callback receives both the draw context and the
452/// current frame number.
453///
454/// # Example
455///
456/// ```rust,ignore
457/// use telex::canvas::animated_canvas;
458///
459/// animated_canvas(cx)
460///     .width(200)
461///     .height(100)
462///     .fps(30)  // 30 frames per second
463///     .on_frame(|ctx, frame| {
464///         ctx.clear(Color::Black);
465///         // Animate based on frame number
466///         let x = (frame % 200) as u16;
467///         ctx.fill_circle(x, 50, 10, Color::Red);
468///     })
469///     .build()
470/// ```
471///
472/// # Note
473///
474/// The FPS value is set on first render and cannot be changed dynamically.
475/// If you need dynamic FPS control, use `View::canvas()` with `cx.use_stream()`
476/// directly.
477pub fn animated_canvas(cx: Scope) -> AnimatedCanvasBuilder {
478    AnimatedCanvasBuilder::new(cx)
479}
480
481/// Builder for animated canvas with automatic frame management.
482pub struct AnimatedCanvasBuilder {
483    cx: Scope,
484    width: u16,
485    height: u16,
486    fps: u32,
487    on_frame: FrameCallback,
488}
489
490impl AnimatedCanvasBuilder {
491    /// Create a new animated canvas builder.
492    pub fn new(cx: Scope) -> Self {
493        Self {
494            cx,
495            width: 100,
496            height: 50,
497            fps: 30,
498            on_frame: None,
499        }
500    }
501
502    /// Set the canvas width in pixels.
503    pub fn width(mut self, width: u16) -> Self {
504        self.width = width;
505        self
506    }
507
508    /// Set the canvas height in pixels.
509    pub fn height(mut self, height: u16) -> Self {
510        self.height = height;
511        self
512    }
513
514    /// Set the target frames per second (default: 30).
515    ///
516    /// Note: This is set on first render. Changing it after the first
517    /// render has no effect.
518    pub fn fps(mut self, fps: u32) -> Self {
519        self.fps = fps.max(1); // Ensure at least 1 FPS
520        self
521    }
522
523    /// Set the frame drawing callback.
524    ///
525    /// The callback receives a `DrawContext` for drawing and the current
526    /// frame number (starting from 0).
527    pub fn on_frame<F>(mut self, f: F) -> Self
528    where
529        F: Fn(&mut DrawContext, u64) + 'static,
530    {
531        self.on_frame = Some(Rc::new(f));
532        self
533    }
534
535    /// Build the animated canvas View.
536    ///
537    /// This creates an internal stream that ticks at the specified FPS
538    /// and returns a Canvas view that calls your `on_frame` callback
539    /// with the current frame number.
540    pub fn build(self) -> View {
541        let delay_ms = 1000 / self.fps as u64;
542
543        // Create a frame counter stream that ticks at the specified FPS
544        struct AnimatedCanvasStreamKey;
545        let frame_stream = self.cx.use_stream_keyed::<AnimatedCanvasStreamKey, _, _, _>(move || {
546            (0u64..).inspect(move |&i| {
547                if i > 0 {
548                    std::thread::sleep(Duration::from_millis(delay_ms));
549                }
550            })
551        });
552
553        let current_frame = frame_stream.get();
554        let on_frame = self.on_frame;
555        let width = self.width;
556        let height = self.height;
557
558        // Return a regular Canvas that calls on_frame with the current frame number
559        View::canvas()
560            .width(width)
561            .height(height)
562            .on_draw(move |ctx| {
563                if let Some(ref callback) = on_frame {
564                    callback(ctx, current_frame);
565                }
566            })
567            .build()
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_pixel_buffer_new() {
577        let buf = PixelBuffer::new(10, 10);
578        assert_eq!(buf.dimensions(), (10, 10));
579        assert_eq!(buf.get(0, 0), (0, 0, 0, 0));
580    }
581
582    #[test]
583    fn test_pixel_buffer_set_get() {
584        let mut buf = PixelBuffer::new(10, 10);
585        buf.set(5, 5, 255, 128, 64, 255);
586        assert_eq!(buf.get(5, 5), (255, 128, 64, 255));
587    }
588
589    #[test]
590    fn test_pixel_buffer_clear() {
591        let mut buf = PixelBuffer::new(10, 10);
592        buf.clear(100, 150, 200, 255);
593        assert_eq!(buf.get(0, 0), (100, 150, 200, 255));
594        assert_eq!(buf.get(9, 9), (100, 150, 200, 255));
595    }
596
597    #[test]
598    fn test_draw_context_line() {
599        let mut buf = PixelBuffer::new(10, 10);
600        {
601            let mut ctx = DrawContext::new(&mut buf);
602            ctx.line(0, 0, 9, 0, Color::White);
603        }
604        // Check horizontal line was drawn
605        assert_eq!(buf.get(0, 0), (255, 255, 255, 255));
606        assert_eq!(buf.get(5, 0), (255, 255, 255, 255));
607        assert_eq!(buf.get(9, 0), (255, 255, 255, 255));
608    }
609
610    #[test]
611    fn test_draw_context_fill_rect() {
612        let mut buf = PixelBuffer::new(10, 10);
613        {
614            let mut ctx = DrawContext::new(&mut buf);
615            ctx.fill_rect(2, 2, 3, 3, Color::Red);
616        }
617        assert_eq!(buf.get(2, 2), (255, 0, 0, 255));
618        assert_eq!(buf.get(4, 4), (255, 0, 0, 255));
619        assert_eq!(buf.get(1, 1), (0, 0, 0, 0)); // Outside rect
620    }
621
622    #[test]
623    fn test_color_to_rgb() {
624        assert_eq!(color_to_rgb(Color::Red), (255, 0, 0));
625        assert_eq!(color_to_rgb(Color::Green), (0, 255, 0));
626        assert_eq!(color_to_rgb(Color::Blue), (0, 0, 255));
627        assert_eq!(
628            color_to_rgb(Color::Rgb {
629                r: 100,
630                g: 150,
631                b: 200
632            }),
633            (100, 150, 200)
634        );
635    }
636}