Skip to main content

zsh/
curses.rs

1//! Curses module - port of Modules/curses.c
2//!
3//! Provides a curses windowing interface for terminal UI.
4//! Uses ANSI escape sequences for portability.
5
6use std::collections::HashMap;
7use std::io::{self, Write};
8
9/// Window attributes
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Attribute {
12    Normal,
13    Bold,
14    Dim,
15    Underline,
16    Blink,
17    Reverse,
18    Standout,
19}
20
21impl Attribute {
22    pub fn to_ansi(&self) -> &'static str {
23        match self {
24            Attribute::Normal => "\x1b[0m",
25            Attribute::Bold => "\x1b[1m",
26            Attribute::Dim => "\x1b[2m",
27            Attribute::Underline => "\x1b[4m",
28            Attribute::Blink => "\x1b[5m",
29            Attribute::Reverse => "\x1b[7m",
30            Attribute::Standout => "\x1b[7m",
31        }
32    }
33
34    pub fn from_name(name: &str) -> Option<Self> {
35        match name {
36            "normal" => Some(Attribute::Normal),
37            "bold" => Some(Attribute::Bold),
38            "dim" => Some(Attribute::Dim),
39            "underline" => Some(Attribute::Underline),
40            "blink" => Some(Attribute::Blink),
41            "reverse" => Some(Attribute::Reverse),
42            "standout" => Some(Attribute::Standout),
43            _ => None,
44        }
45    }
46}
47
48/// Basic colors
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Color {
51    Black,
52    Red,
53    Green,
54    Yellow,
55    Blue,
56    Magenta,
57    Cyan,
58    White,
59    Default,
60}
61
62impl Color {
63    pub fn fg_code(&self) -> u8 {
64        match self {
65            Color::Black => 30,
66            Color::Red => 31,
67            Color::Green => 32,
68            Color::Yellow => 33,
69            Color::Blue => 34,
70            Color::Magenta => 35,
71            Color::Cyan => 36,
72            Color::White => 37,
73            Color::Default => 39,
74        }
75    }
76
77    pub fn bg_code(&self) -> u8 {
78        match self {
79            Color::Black => 40,
80            Color::Red => 41,
81            Color::Green => 42,
82            Color::Yellow => 43,
83            Color::Blue => 44,
84            Color::Magenta => 45,
85            Color::Cyan => 46,
86            Color::White => 47,
87            Color::Default => 49,
88        }
89    }
90
91    pub fn from_name(name: &str) -> Option<Self> {
92        match name {
93            "black" => Some(Color::Black),
94            "red" => Some(Color::Red),
95            "green" => Some(Color::Green),
96            "yellow" => Some(Color::Yellow),
97            "blue" => Some(Color::Blue),
98            "magenta" => Some(Color::Magenta),
99            "cyan" => Some(Color::Cyan),
100            "white" => Some(Color::White),
101            "default" => Some(Color::Default),
102            _ => None,
103        }
104    }
105}
106
107/// A curses window
108#[derive(Debug)]
109pub struct Window {
110    pub name: String,
111    pub rows: usize,
112    pub cols: usize,
113    pub y: usize,
114    pub x: usize,
115    pub cursor_y: usize,
116    pub cursor_x: usize,
117    pub scroll: bool,
118    pub keypad: bool,
119    pub fg: Color,
120    pub bg: Color,
121    pub attrs: Vec<Attribute>,
122    buffer: Vec<Vec<char>>,
123}
124
125impl Window {
126    pub fn new(name: &str, rows: usize, cols: usize, y: usize, x: usize) -> Self {
127        Self {
128            name: name.to_string(),
129            rows,
130            cols,
131            y,
132            x,
133            cursor_y: 0,
134            cursor_x: 0,
135            scroll: false,
136            keypad: false,
137            fg: Color::Default,
138            bg: Color::Default,
139            attrs: Vec::new(),
140            buffer: vec![vec![' '; cols]; rows],
141        }
142    }
143
144    pub fn stdscr() -> Self {
145        let (rows, cols) = terminal_size().unwrap_or((24, 80));
146        Self::new("stdscr", rows, cols, 0, 0)
147    }
148
149    pub fn move_cursor(&mut self, y: usize, x: usize) {
150        if y < self.rows && x < self.cols {
151            self.cursor_y = y;
152            self.cursor_x = x;
153        }
154    }
155
156    pub fn addch(&mut self, ch: char) {
157        if self.cursor_y < self.rows && self.cursor_x < self.cols {
158            self.buffer[self.cursor_y][self.cursor_x] = ch;
159            self.cursor_x += 1;
160            if self.cursor_x >= self.cols {
161                self.cursor_x = 0;
162                self.cursor_y += 1;
163                if self.cursor_y >= self.rows {
164                    if self.scroll {
165                        self.scroll_up();
166                        self.cursor_y = self.rows - 1;
167                    } else {
168                        self.cursor_y = self.rows - 1;
169                    }
170                }
171            }
172        }
173    }
174
175    pub fn addstr(&mut self, s: &str) {
176        for ch in s.chars() {
177            self.addch(ch);
178        }
179    }
180
181    pub fn clear(&mut self) {
182        for row in &mut self.buffer {
183            for cell in row {
184                *cell = ' ';
185            }
186        }
187        self.cursor_y = 0;
188        self.cursor_x = 0;
189    }
190
191    pub fn erase(&mut self) {
192        self.clear();
193    }
194
195    pub fn clrtoeol(&mut self) {
196        if self.cursor_y < self.rows {
197            for x in self.cursor_x..self.cols {
198                self.buffer[self.cursor_y][x] = ' ';
199            }
200        }
201    }
202
203    pub fn clrtobot(&mut self) {
204        self.clrtoeol();
205        for y in (self.cursor_y + 1)..self.rows {
206            for x in 0..self.cols {
207                self.buffer[y][x] = ' ';
208            }
209        }
210    }
211
212    fn scroll_up(&mut self) {
213        self.buffer.remove(0);
214        self.buffer.push(vec![' '; self.cols]);
215    }
216
217    pub fn set_scroll(&mut self, enable: bool) {
218        self.scroll = enable;
219    }
220
221    pub fn set_keypad(&mut self, enable: bool) {
222        self.keypad = enable;
223    }
224
225    pub fn attron(&mut self, attr: Attribute) {
226        if !self.attrs.contains(&attr) {
227            self.attrs.push(attr);
228        }
229    }
230
231    pub fn attroff(&mut self, attr: Attribute) {
232        self.attrs.retain(|a| *a != attr);
233    }
234
235    pub fn set_color(&mut self, fg: Color, bg: Color) {
236        self.fg = fg;
237        self.bg = bg;
238    }
239
240    pub fn refresh(&self) -> io::Result<()> {
241        let mut stdout = io::stdout();
242
243        write!(stdout, "\x1b[{};{}H", self.y + 1, self.x + 1)?;
244
245        for attr in &self.attrs {
246            write!(stdout, "{}", attr.to_ansi())?;
247        }
248        write!(stdout, "\x1b[{};{}m", self.fg.fg_code(), self.bg.bg_code())?;
249
250        for (row_idx, row) in self.buffer.iter().enumerate() {
251            write!(stdout, "\x1b[{};{}H", self.y + row_idx + 1, self.x + 1)?;
252            let line: String = row.iter().collect();
253            write!(stdout, "{}", line)?;
254        }
255
256        write!(
257            stdout,
258            "\x1b[{};{}H",
259            self.y + self.cursor_y + 1,
260            self.x + self.cursor_x + 1
261        )?;
262
263        stdout.flush()
264    }
265
266    pub fn getyx(&self) -> (usize, usize) {
267        (self.cursor_y, self.cursor_x)
268    }
269
270    pub fn getmaxyx(&self) -> (usize, usize) {
271        (self.rows, self.cols)
272    }
273}
274
275/// Curses state manager
276#[derive(Debug, Default)]
277pub struct Curses {
278    windows: HashMap<String, Window>,
279    initialized: bool,
280    color_pairs: HashMap<i32, (Color, Color)>,
281    next_pair: i32,
282}
283
284impl Curses {
285    pub fn new() -> Self {
286        Self::default()
287    }
288
289    pub fn initscr(&mut self) -> io::Result<()> {
290        if self.initialized {
291            return Ok(());
292        }
293
294        let mut stdout = io::stdout();
295        write!(stdout, "\x1b[?1049h")?;
296        write!(stdout, "\x1b[2J")?;
297        write!(stdout, "\x1b[H")?;
298        stdout.flush()?;
299
300        let stdscr = Window::stdscr();
301        self.windows.insert("stdscr".to_string(), stdscr);
302        self.initialized = true;
303        self.next_pair = 1;
304
305        Ok(())
306    }
307
308    pub fn endwin(&mut self) -> io::Result<()> {
309        if !self.initialized {
310            return Ok(());
311        }
312
313        let mut stdout = io::stdout();
314        write!(stdout, "\x1b[?1049l")?;
315        write!(stdout, "\x1b[0m")?;
316        stdout.flush()?;
317
318        self.windows.clear();
319        self.color_pairs.clear();
320        self.initialized = false;
321
322        Ok(())
323    }
324
325    pub fn newwin(&mut self, name: &str, rows: usize, cols: usize, y: usize, x: usize) -> bool {
326        if self.windows.contains_key(name) {
327            return false;
328        }
329
330        let win = Window::new(name, rows, cols, y, x);
331        self.windows.insert(name.to_string(), win);
332        true
333    }
334
335    pub fn delwin(&mut self, name: &str) -> bool {
336        if name == "stdscr" {
337            return false;
338        }
339        self.windows.remove(name).is_some()
340    }
341
342    pub fn get_window(&self, name: &str) -> Option<&Window> {
343        self.windows.get(name)
344    }
345
346    pub fn get_window_mut(&mut self, name: &str) -> Option<&mut Window> {
347        self.windows.get_mut(name)
348    }
349
350    pub fn refresh(&self, name: &str) -> io::Result<()> {
351        if let Some(win) = self.windows.get(name) {
352            win.refresh()
353        } else {
354            Ok(())
355        }
356    }
357
358    pub fn refresh_all(&self) -> io::Result<()> {
359        for win in self.windows.values() {
360            win.refresh()?;
361        }
362        Ok(())
363    }
364
365    pub fn init_pair(&mut self, pair: i32, fg: Color, bg: Color) {
366        self.color_pairs.insert(pair, (fg, bg));
367    }
368
369    pub fn get_pair(&self, pair: i32) -> Option<(Color, Color)> {
370        self.color_pairs.get(&pair).copied()
371    }
372
373    pub fn is_initialized(&self) -> bool {
374        self.initialized
375    }
376
377    pub fn window_names(&self) -> Vec<&str> {
378        self.windows.keys().map(|s| s.as_str()).collect()
379    }
380}
381
382/// Get terminal size
383pub fn terminal_size() -> Option<(usize, usize)> {
384    #[cfg(unix)]
385    {
386        let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
387        let result = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
388        if result == 0 && ws.ws_row > 0 && ws.ws_col > 0 {
389            return Some((ws.ws_row as usize, ws.ws_col as usize));
390        }
391    }
392
393    std::env::var("LINES")
394        .ok()
395        .and_then(|l| l.parse().ok())
396        .zip(std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()))
397}
398
399/// Raw mode for input
400#[cfg(unix)]
401pub fn cbreak() -> io::Result<()> {
402    let mut termios: libc::termios = unsafe { std::mem::zeroed() };
403    unsafe {
404        if libc::tcgetattr(libc::STDIN_FILENO, &mut termios) < 0 {
405            return Err(io::Error::last_os_error());
406        }
407        termios.c_lflag &= !(libc::ICANON | libc::ECHO);
408        termios.c_cc[libc::VMIN] = 1;
409        termios.c_cc[libc::VTIME] = 0;
410        if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios) < 0 {
411            return Err(io::Error::last_os_error());
412        }
413    }
414    Ok(())
415}
416
417#[cfg(not(unix))]
418pub fn cbreak() -> io::Result<()> {
419    Ok(())
420}
421
422/// Disable echo
423#[cfg(unix)]
424pub fn noecho() -> io::Result<()> {
425    let mut termios: libc::termios = unsafe { std::mem::zeroed() };
426    unsafe {
427        if libc::tcgetattr(libc::STDIN_FILENO, &mut termios) < 0 {
428            return Err(io::Error::last_os_error());
429        }
430        termios.c_lflag &= !libc::ECHO;
431        if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios) < 0 {
432            return Err(io::Error::last_os_error());
433        }
434    }
435    Ok(())
436}
437
438#[cfg(not(unix))]
439pub fn noecho() -> io::Result<()> {
440    Ok(())
441}
442
443/// Hide cursor
444pub fn curs_set(visible: bool) -> io::Result<()> {
445    let mut stdout = io::stdout();
446    if visible {
447        write!(stdout, "\x1b[?25h")?;
448    } else {
449        write!(stdout, "\x1b[?25l")?;
450    }
451    stdout.flush()
452}
453
454/// Execute zcurses builtin
455pub fn builtin_zcurses(args: &[&str], curses: &mut Curses) -> (i32, String) {
456    if args.is_empty() {
457        return (1, "zcurses: subcommand required\n".to_string());
458    }
459
460    match args[0] {
461        "init" => {
462            if curses.initscr().is_err() {
463                return (1, "zcurses: failed to initialize\n".to_string());
464            }
465            (0, String::new())
466        }
467        "end" => {
468            if curses.endwin().is_err() {
469                return (1, "zcurses: failed to end\n".to_string());
470            }
471            (0, String::new())
472        }
473        "addwin" => {
474            if args.len() < 6 {
475                return (
476                    1,
477                    "zcurses addwin: name rows cols y x required\n".to_string(),
478                );
479            }
480            let name = args[1];
481            let rows: usize = args[2].parse().unwrap_or(1);
482            let cols: usize = args[3].parse().unwrap_or(1);
483            let y: usize = args[4].parse().unwrap_or(0);
484            let x: usize = args[5].parse().unwrap_or(0);
485
486            if curses.newwin(name, rows, cols, y, x) {
487                (0, String::new())
488            } else {
489                (1, format!("zcurses: window {} already exists\n", name))
490            }
491        }
492        "delwin" => {
493            if args.len() < 2 {
494                return (1, "zcurses delwin: window name required\n".to_string());
495            }
496            if curses.delwin(args[1]) {
497                (0, String::new())
498            } else {
499                (1, format!("zcurses: cannot delete window {}\n", args[1]))
500            }
501        }
502        "refresh" => {
503            let name = if args.len() > 1 { args[1] } else { "stdscr" };
504            if curses.refresh(name).is_err() {
505                return (1, format!("zcurses: failed to refresh {}\n", name));
506            }
507            (0, String::new())
508        }
509        "move" => {
510            if args.len() < 4 {
511                return (1, "zcurses move: window y x required\n".to_string());
512            }
513            let name = args[1];
514            let y: usize = args[2].parse().unwrap_or(0);
515            let x: usize = args[3].parse().unwrap_or(0);
516
517            if let Some(win) = curses.get_window_mut(name) {
518                win.move_cursor(y, x);
519                (0, String::new())
520            } else {
521                (1, format!("zcurses: window {} not found\n", name))
522            }
523        }
524        "string" => {
525            if args.len() < 3 {
526                return (1, "zcurses string: window text required\n".to_string());
527            }
528            let name = args[1];
529            let text = args[2..].join(" ");
530
531            if let Some(win) = curses.get_window_mut(name) {
532                win.addstr(&text);
533                (0, String::new())
534            } else {
535                (1, format!("zcurses: window {} not found\n", name))
536            }
537        }
538        "clear" => {
539            let name = if args.len() > 1 { args[1] } else { "stdscr" };
540            if let Some(win) = curses.get_window_mut(name) {
541                win.clear();
542                (0, String::new())
543            } else {
544                (1, format!("zcurses: window {} not found\n", name))
545            }
546        }
547        "attr" => {
548            if args.len() < 3 {
549                return (1, "zcurses attr: window attribute required\n".to_string());
550            }
551            let name = args[1];
552            let attr_name = args[2];
553
554            if let Some(win) = curses.get_window_mut(name) {
555                if let Some(attr) = Attribute::from_name(attr_name) {
556                    win.attron(attr);
557                    (0, String::new())
558                } else {
559                    (1, format!("zcurses: unknown attribute {}\n", attr_name))
560                }
561            } else {
562                (1, format!("zcurses: window {} not found\n", name))
563            }
564        }
565        _ => (1, format!("zcurses: unknown subcommand {}\n", args[0])),
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_attribute_to_ansi() {
575        assert_eq!(Attribute::Bold.to_ansi(), "\x1b[1m");
576        assert_eq!(Attribute::Normal.to_ansi(), "\x1b[0m");
577    }
578
579    #[test]
580    fn test_attribute_from_name() {
581        assert_eq!(Attribute::from_name("bold"), Some(Attribute::Bold));
582        assert_eq!(Attribute::from_name("invalid"), None);
583    }
584
585    #[test]
586    fn test_color_codes() {
587        assert_eq!(Color::Red.fg_code(), 31);
588        assert_eq!(Color::Red.bg_code(), 41);
589    }
590
591    #[test]
592    fn test_color_from_name() {
593        assert_eq!(Color::from_name("red"), Some(Color::Red));
594        assert_eq!(Color::from_name("invalid"), None);
595    }
596
597    #[test]
598    fn test_window_new() {
599        let win = Window::new("test", 10, 20, 0, 0);
600        assert_eq!(win.name, "test");
601        assert_eq!(win.rows, 10);
602        assert_eq!(win.cols, 20);
603    }
604
605    #[test]
606    fn test_window_move_cursor() {
607        let mut win = Window::new("test", 10, 20, 0, 0);
608        win.move_cursor(5, 10);
609        assert_eq!(win.getyx(), (5, 10));
610    }
611
612    #[test]
613    fn test_window_addch() {
614        let mut win = Window::new("test", 10, 20, 0, 0);
615        win.addch('X');
616        assert_eq!(win.buffer[0][0], 'X');
617        assert_eq!(win.getyx(), (0, 1));
618    }
619
620    #[test]
621    fn test_window_addstr() {
622        let mut win = Window::new("test", 10, 20, 0, 0);
623        win.addstr("Hello");
624        assert_eq!(win.getyx(), (0, 5));
625    }
626
627    #[test]
628    fn test_window_clear() {
629        let mut win = Window::new("test", 10, 20, 0, 0);
630        win.addstr("Hello");
631        win.clear();
632        assert_eq!(win.buffer[0][0], ' ');
633        assert_eq!(win.getyx(), (0, 0));
634    }
635
636    #[test]
637    fn test_curses_new() {
638        let curses = Curses::new();
639        assert!(!curses.is_initialized());
640    }
641
642    #[test]
643    fn test_curses_newwin() {
644        let mut curses = Curses::new();
645        assert!(curses.newwin("test", 10, 20, 0, 0));
646        assert!(!curses.newwin("test", 10, 20, 0, 0));
647    }
648
649    #[test]
650    fn test_curses_delwin() {
651        let mut curses = Curses::new();
652        curses.newwin("test", 10, 20, 0, 0);
653        assert!(curses.delwin("test"));
654        assert!(!curses.delwin("test"));
655    }
656
657    #[test]
658    fn test_builtin_zcurses_no_args() {
659        let mut curses = Curses::new();
660        let (status, _) = builtin_zcurses(&[], &mut curses);
661        assert_eq!(status, 1);
662    }
663
664    #[test]
665    fn test_builtin_zcurses_unknown() {
666        let mut curses = Curses::new();
667        let (status, _) = builtin_zcurses(&["unknown"], &mut curses);
668        assert_eq!(status, 1);
669    }
670}