diff --git a/CLAUDE.md b/CLAUDE.md index f96c805..a4b7841 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,22 +2,24 @@ > **Última actualización**: 2025-12-08 > **Lenguaje**: Zig 0.15.2 -> **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) (Rust TUI library) -> **Estado**: v1.1 - Implementación completa + optimizaciones de performance +> **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) + [crossterm](https://github.com/crossterm-rs/crossterm) (Rust) +> **Estado**: v1.2 - Renderizado + Eventos integrados ## Descripción del Proyecto -**zcatui** es una librería para crear interfaces de usuario en terminal (TUI) en Zig puro, inspirada en ratatui de Rust. +**zcatui** es una librería para crear interfaces de usuario en terminal (TUI) en Zig puro, combinando las capacidades de ratatui (renderizado) y crossterm (eventos) de Rust. -**Objetivo**: Proveer una API idiomática Zig para construir aplicaciones TUI con widgets, layouts, y estilos, manteniendo la filosofía de Zig: simple, explícito, y sin magia. +**Objetivo**: Proveer una API idiomática Zig para construir aplicaciones TUI interactivas con widgets, layouts, estilos, y manejo de eventos de teclado/ratón, manteniendo la filosofía de Zig: simple, explícito, y sin magia. **Nombre**: "zcat" + "ui" (un guiño a ratatui y la mascota de Zig) +**Diferencia con Rust**: En Rust, ratatui y crossterm son librerías separadas. En zcatui, todo está integrado en una sola librería. + --- ## Estado Actual del Proyecto -### Implementación Completa (v1.1) - 2025-12-08 +### Implementación Completa (v1.2) - 2025-12-08 | Componente | Estado | Archivo | |------------|--------|---------| @@ -28,6 +30,11 @@ | Layout + Constraint | ✅ | `src/layout.zig` | | Terminal | ✅ | `src/terminal.zig` | | Backend ANSI | ✅ | `src/backend/` | +| **Eventos (crossterm-style)** | ✅ Completo | | +| Event, KeyEvent, MouseEvent | ✅ | `src/event.zig` | +| EventReader + polling | ✅ | `src/event/reader.zig` | +| Escape sequence parser | ✅ | `src/event/parse.zig` | +| Cursor control | ✅ | `src/cursor.zig` | | **Symbols** | ✅ Completo | `src/symbols/` | | Line drawing | ✅ | `line.zig` | | Border sets | ✅ | `border.zig` | @@ -96,14 +103,18 @@ Como ratatui, usamos **renderizado inmediato con buffers intermedios**: zcatui/ ├── src/ │ ├── root.zig # Entry point, re-exports públicos -│ ├── terminal.zig # Terminal abstraction +│ ├── terminal.zig # Terminal + eventos integrados │ ├── buffer.zig # Buffer + Cell + Rect │ ├── layout.zig # Layout, Constraint, Direction │ ├── style.zig # Color, Style, Modifier │ ├── text.zig # Text, Line, Span, Alignment +│ ├── event.zig # Event, KeyEvent, MouseEvent, KeyCode +│ ├── cursor.zig # Cursor control (shapes, position) +│ ├── event/ +│ │ ├── reader.zig # EventReader + polling +│ │ └── parse.zig # Escape sequence parser │ ├── backend/ -│ │ ├── backend.zig # Backend interface -│ │ └── ansi.zig # ANSI escape sequences +│ │ └── backend.zig # ANSI escape sequences backend │ ├── symbols/ │ │ ├── symbols.zig # Re-exports │ │ ├── line.zig # Line drawing characters @@ -128,6 +139,9 @@ zcatui/ │ ├── chart.zig # Line/scatter/bar graphs │ ├── calendar.zig # Monthly calendar │ └── clear.zig # Clear/reset area +├── examples/ +│ ├── hello.zig # Minimal example +│ └── events_demo.zig # Interactive keyboard/mouse demo ├── docs/ │ ├── ARCHITECTURE.md # Arquitectura detallada │ ├── WIDGETS.md # Documentación de widgets @@ -449,6 +463,16 @@ zig build test --summary all # Tests con detalles ## Historial de Desarrollo +### 2025-12-08 - v1.2 (Eventos - crossterm-style) +- Sistema de eventos integrado (teclado + ratón) +- Event, KeyEvent, MouseEvent, KeyCode types +- EventReader con polling y timeout +- Parser de escape sequences (CSI, SS3, SGR mouse, X10 mouse) +- Cursor control (shapes, blinking, save/restore) +- Terminal integrado: pollEvent(), enableMouseCapture(), enableFocusChange() +- Ejemplo interactivo: events_demo.zig +- Tests: 47 tests (29 nuevos para eventos) + ### 2025-12-08 - v1.1 (Performance) - Symbol: tipo compacto UTF-8 (4 bytes max) - Buffer.diff(): renderizado diferencial eficiente diff --git a/build.zig b/build.zig index 91d3909..4d4b5fd 100644 --- a/build.zig +++ b/build.zig @@ -42,4 +42,23 @@ pub fn build(b: *std.Build) void { run_hello.step.dependOn(b.getInstallStep()); const hello_step = b.step("hello", "Ejecutar ejemplo hello"); hello_step.dependOn(&run_hello.step); + + // Ejemplo: events_demo + const events_demo_exe = b.addExecutable(.{ + .name = "events-demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/events_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zcatui", .module = zcatui_mod }, + }, + }), + }); + b.installArtifact(events_demo_exe); + + const run_events_demo = b.addRunArtifact(events_demo_exe); + run_events_demo.step.dependOn(b.getInstallStep()); + const events_demo_step = b.step("events-demo", "Run events demo"); + events_demo_step.dependOn(&run_events_demo.step); } diff --git a/docs/API.md b/docs/API.md index 492a835..b62c224 100644 --- a/docs/API.md +++ b/docs/API.md @@ -27,6 +27,19 @@ const Direction = zcatui.Direction; // Terminal const Terminal = zcatui.Terminal; +// Events (crossterm-style) +const Event = zcatui.Event; +const KeyEvent = zcatui.KeyEvent; +const KeyCode = zcatui.KeyCode; +const KeyModifiers = zcatui.KeyModifiers; +const MouseEvent = zcatui.MouseEvent; +const MouseEventKind = zcatui.MouseEventKind; +const MouseButton = zcatui.MouseButton; + +// Cursor +const Cursor = zcatui.Cursor; +const CursorStyle = zcatui.CursorStyle; + // Widgets const widgets = zcatui.widgets; const Block = widgets.Block; @@ -552,23 +565,36 @@ pub const Terminal = struct { pub fn size(self: Terminal) struct { width: u16, height: u16 }; pub fn area(self: Terminal) Rect; + // Rendering pub fn draw(self: *Terminal, render_fn: fn(area: Rect, buf: *Buffer) void) !void; + pub fn drawWithContext(self: *Terminal, ctx: anytype, render_fn: fn(...) void) !void; pub fn clear(self: *Terminal) !void; pub fn flush(self: *Terminal) !void; + // Cursor pub fn hideCursor(self: *Terminal) !void; pub fn showCursor(self: *Terminal) !void; pub fn setCursorPosition(self: *Terminal, x: u16, y: u16) !void; - pub fn enterAlternateScreen(self: *Terminal) !void; - pub fn leaveAlternateScreen(self: *Terminal) !void; + // Events (crossterm-style) + pub fn pollEvent(self: *Terminal, timeout_ms: ?u32) !?Event; + pub fn readEvent(self: *Terminal) !Event; - pub fn enableRawMode(self: *Terminal) !void; - pub fn disableRawMode(self: *Terminal) !void; + // Mouse + pub fn enableMouseCapture(self: *Terminal) !void; + pub fn disableMouseCapture(self: *Terminal) !void; + + // Focus + pub fn enableFocusChange(self: *Terminal) !void; + pub fn disableFocusChange(self: *Terminal) !void; + + // Bracketed paste + pub fn enableBracketedPaste(self: *Terminal) !void; + pub fn disableBracketedPaste(self: *Terminal) !void; }; ``` -**Ejemplo de uso:** +**Ejemplo interactivo completo:** ```zig pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -578,25 +604,225 @@ pub fn main() !void { var term = try Terminal.init(allocator); defer term.deinit(); - try term.enterAlternateScreen(); - defer term.leaveAlternateScreen() catch {}; + // Enable mouse (optional) + try term.enableMouseCapture(); - try term.hideCursor(); - defer term.showCursor() catch {}; + var running = true; + while (running) { + try term.draw(render); - try term.draw(struct { - pub fn render(area: Rect, buf: *Buffer) void { - const block = Block.bordered().title("Hello zcatui!"); - block.render(area, buf); + // Poll for events with 100ms timeout + if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + if (key.code == .esc) running = false; + if (key.code.isChar('q')) running = false; + }, + .mouse => |mouse| { + if (mouse.kind == .down) handleClick(mouse.column, mouse.row); + }, + .resize => |size| try term.resize(size.width, size.height), + else => {}, + } } - }.render); - - // Wait for input... + } } ``` --- +## Events (crossterm-style) + +### Event + +```zig +pub const Event = union(enum) { + key: KeyEvent, + mouse: MouseEvent, + resize: ResizeEvent, + focus_gained, + focus_lost, + paste: []const u8, +}; +``` + +### KeyEvent + +```zig +pub const KeyEvent = struct { + code: KeyCode, + modifiers: KeyModifiers, + kind: KeyEventKind, + + pub fn char(c: u21) KeyEvent; + pub fn withMod(code: KeyCode, mods: KeyModifiers) KeyEvent; + pub fn getChar(self: KeyEvent) ?u21; + pub fn isCtrl(self: KeyEvent) bool; + pub fn isAlt(self: KeyEvent) bool; + pub fn isShift(self: KeyEvent) bool; +}; +``` + +### KeyCode + +```zig +pub const KeyCode = union(enum) { + char: u21, // Any unicode character + f: u8, // F1-F12 + backspace, enter, tab, backtab, + left, right, up, down, + home, end, page_up, page_down, + insert, delete, esc, null_key, + caps_lock, scroll_lock, num_lock, + print_screen, pause, menu, + media: MediaKeyCode, + modifier: ModifierKeyCode, + + pub fn isChar(self: KeyCode, c: u21) bool; +}; +``` + +### KeyModifiers + +```zig +pub const KeyModifiers = packed struct { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + + pub const none: KeyModifiers = .{}; + pub const SHIFT: KeyModifiers = .{ .shift = true }; + pub const CTRL: KeyModifiers = .{ .ctrl = true }; + pub const ALT: KeyModifiers = .{ .alt = true }; + + pub fn combine(self: KeyModifiers, other: KeyModifiers) KeyModifiers; + pub fn any(self: KeyModifiers) bool; + pub fn isEmpty(self: KeyModifiers) bool; +}; +``` + +### MouseEvent + +```zig +pub const MouseEvent = struct { + kind: MouseEventKind, + button: MouseButton, + column: u16, + row: u16, + modifiers: KeyModifiers, + + pub fn down(button: MouseButton, col: u16, row: u16) MouseEvent; + pub fn up(button: MouseButton, col: u16, row: u16) MouseEvent; + pub fn moved(col: u16, row: u16) MouseEvent; + pub fn isClick(self: MouseEvent) bool; + pub fn isLeft(self: MouseEvent) bool; + pub fn isRight(self: MouseEvent) bool; +}; +``` + +### MouseEventKind + +```zig +pub const MouseEventKind = enum { + down, up, drag, moved, + scroll_down, scroll_up, + scroll_left, scroll_right, + + pub fn isScroll(self: MouseEventKind) bool; + pub fn isButton(self: MouseEventKind) bool; +}; +``` + +### MouseButton + +```zig +pub const MouseButton = enum { + left, right, middle, none, +}; +``` + +**Ejemplo de manejo de eventos:** +```zig +if (try term.pollEvent(100)) |event| { + switch (event) { + .key => |key| { + // Ctrl+C + if (key.code.isChar('c') and key.isCtrl()) { + break; + } + // Arrow keys + switch (key.code) { + .up => state.previous(), + .down => state.next(), + .enter => state.select(), + else => {}, + } + }, + .mouse => |mouse| { + switch (mouse.kind) { + .down => { + if (mouse.isLeft()) selectAt(mouse.column, mouse.row); + }, + .scroll_up => state.scrollUp(), + .scroll_down => state.scrollDown(), + else => {}, + } + }, + .resize => |size| { + try term.resize(size.width, size.height); + }, + .focus_gained => {}, + .focus_lost => {}, + .paste => |text| handlePaste(text), + } +} +``` + +--- + +## Cursor + +```zig +pub const CursorStyle = enum { + default, + blinking_block, + steady_block, + blinking_underline, + steady_underline, + blinking_bar, + steady_bar, +}; + +pub const Cursor = struct { + pub fn init() Cursor; + + // Visibility + pub fn hide(self: *Cursor) !void; + pub fn show(self: *Cursor) !void; + + // Blinking + pub fn enableBlinking(self: *Cursor) !void; + pub fn disableBlinking(self: *Cursor) !void; + + // Style + pub fn setStyle(self: *Cursor, style: CursorStyle) !void; + + // Position + pub fn moveTo(self: *Cursor, column: u16, row: u16) !void; + pub fn moveUp(self: *Cursor, n: u16) !void; + pub fn moveDown(self: *Cursor, n: u16) !void; + pub fn moveLeft(self: *Cursor, n: u16) !void; + pub fn moveRight(self: *Cursor, n: u16) !void; + + // Save/Restore + pub fn savePosition(self: *Cursor) !void; + pub fn restorePosition(self: *Cursor) !void; +}; +``` + +--- + ## Widgets Quick Reference | Widget | Constructor | Stateful | Key Methods | diff --git a/examples/events_demo.zig b/examples/events_demo.zig new file mode 100644 index 0000000..563223f --- /dev/null +++ b/examples/events_demo.zig @@ -0,0 +1,200 @@ +//! Interactive events demo for zcatui. +//! +//! Demonstrates keyboard and mouse event handling. +//! Press 'q' or ESC to quit. +//! +//! Run with: zig build run-events-demo + +const std = @import("std"); +const zcatui = @import("zcatui"); + +const Terminal = zcatui.Terminal; +const Buffer = zcatui.Buffer; +const Rect = zcatui.Rect; +const Style = zcatui.Style; +const Color = zcatui.Color; +const Event = zcatui.Event; +const KeyCode = zcatui.KeyCode; +const Block = zcatui.widgets.Block; +const Borders = zcatui.widgets.Borders; +const Layout = zcatui.Layout; +const Constraint = zcatui.Constraint; + +/// Application state +const AppState = struct { + last_event: ?Event = null, + key_count: u32 = 0, + mouse_x: u16 = 0, + mouse_y: u16 = 0, + mouse_clicks: u32 = 0, + running: bool = true, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Initialize terminal + var term = try Terminal.init(allocator); + defer term.deinit(); + + // Enable mouse capture + try term.enableMouseCapture(); + + // Enable focus events + try term.enableFocusChange(); + + var state = AppState{}; + + // Main loop + while (state.running) { + // Draw UI + try term.drawWithContext(&state, render); + + // Poll for events (100ms timeout) + if (try term.pollEvent(100)) |event| { + handleEvent(&state, event); + } + } +} + +fn handleEvent(state: *AppState, event: Event) void { + state.last_event = event; + + switch (event) { + .key => |key| { + state.key_count += 1; + + // Quit on 'q' or ESC + switch (key.code) { + .esc => state.running = false, + .char => |c| { + if (c == 'q' or c == 'Q') { + state.running = false; + } + }, + else => {}, + } + }, + .mouse => |mouse| { + state.mouse_x = mouse.column; + state.mouse_y = mouse.row; + if (mouse.kind == .down) { + state.mouse_clicks += 1; + } + }, + .resize => |_| { + // Terminal handles resize automatically + }, + else => {}, + } +} + +fn render(state: *AppState, area: Rect, buf: *Buffer) void { + // Create layout: header, content, footer + const chunks = Layout.vertical(&.{ + Constraint.length(3), + Constraint.min(0), + Constraint.length(3), + }).split(area); + + // Header + const header = Block.init() + .title(" zcatui Events Demo ") + .setBorders(Borders.all) + .style(Style.default.fg(Color.cyan)); + header.render(chunks.get(0), buf); + + // Content - show event info + renderContent(state, chunks.get(1), buf); + + // Footer + const footer_text = "Press 'q' or ESC to quit | Mouse enabled"; + + const footer = Block.init() + .setBorders(Borders.all) + .style(Style.default.fg(Color.blue)); + const footer_area = chunks.get(2); + footer.render(footer_area, buf); + + // Render footer text manually + const inner = footer.inner(footer_area); + _ = buf.setString(inner.left(), inner.top(), footer_text, Style.default); +} + +fn renderContent(state: *AppState, area: Rect, buf: *Buffer) void { + const content = Block.init() + .title(" Event Info ") + .setBorders(Borders.all); + content.render(area, buf); + + const inner = content.inner(area); + var y = inner.top(); + + // Key count + var key_buf: [64]u8 = undefined; + const key_str = std.fmt.bufPrint(&key_buf, "Key presses: {d}", .{state.key_count}) catch "???"; + _ = buf.setString(inner.left(), y, key_str, Style.default.fg(Color.green)); + y += 1; + + // Mouse position + var mouse_buf: [64]u8 = undefined; + const mouse_str = std.fmt.bufPrint(&mouse_buf, "Mouse position: ({d}, {d})", .{ state.mouse_x, state.mouse_y }) catch "???"; + _ = buf.setString(inner.left(), y, mouse_str, Style.default.fg(Color.yellow)); + y += 1; + + // Mouse clicks + var clicks_buf: [64]u8 = undefined; + const clicks_str = std.fmt.bufPrint(&clicks_buf, "Mouse clicks: {d}", .{state.mouse_clicks}) catch "???"; + _ = buf.setString(inner.left(), y, clicks_str, Style.default.fg(Color.magenta)); + y += 2; + + // Last event + _ = buf.setString(inner.left(), y, "Last event:", Style.default.bold()); + y += 1; + + if (state.last_event) |event| { + const event_str = formatEvent(event); + _ = buf.setString(inner.left() + 2, y, event_str, Style.default.fg(Color.white)); + } else { + _ = buf.setString(inner.left() + 2, y, "(none)", Style.default); + } +} + +fn formatEvent(event: Event) []const u8 { + return switch (event) { + .key => |key| switch (key.code) { + .char => "Key: char", + .enter => "Key: Enter", + .esc => "Key: Escape", + .tab => "Key: Tab", + .backspace => "Key: Backspace", + .up => "Key: Up", + .down => "Key: Down", + .left => "Key: Left", + .right => "Key: Right", + .home => "Key: Home", + .end => "Key: End", + .page_up => "Key: PageUp", + .page_down => "Key: PageDown", + .delete => "Key: Delete", + .insert => "Key: Insert", + .f => "Key: F-key", + else => "Key: other", + }, + .mouse => |mouse| switch (mouse.kind) { + .down => "Mouse: click", + .up => "Mouse: release", + .drag => "Mouse: drag", + .moved => "Mouse: move", + .scroll_up => "Mouse: scroll up", + .scroll_down => "Mouse: scroll down", + else => "Mouse: other", + }, + .resize => "Resize", + .focus_gained => "Focus gained", + .focus_lost => "Focus lost", + .paste => "Paste", + }; +} diff --git a/src/cursor.zig b/src/cursor.zig new file mode 100644 index 0000000..bad2e01 --- /dev/null +++ b/src/cursor.zig @@ -0,0 +1,268 @@ +//! Cursor control for zcatui. +//! +//! Provides cursor visibility, positioning, shape, and blinking control. +//! Inspired by crossterm's cursor module. +//! +//! ## Example +//! +//! ```zig +//! // Hide cursor during rendering +//! try cursor.hide(); +//! defer cursor.show() catch {}; +//! +//! // Save and restore position +//! try cursor.savePosition(); +//! try cursor.moveTo(10, 5); +//! // ... do something +//! try cursor.restorePosition(); +//! +//! // Change cursor style +//! try cursor.setStyle(.blinking_bar); +//! ``` + +const std = @import("std"); + +/// Cursor style/shape. +pub const CursorStyle = enum { + /// Default cursor style (determined by terminal). + default, + /// Blinking block cursor (▐). + blinking_block, + /// Steady (non-blinking) block cursor. + steady_block, + /// Blinking underline cursor. + blinking_underline, + /// Steady underline cursor. + steady_underline, + /// Blinking bar/line cursor (|). + blinking_bar, + /// Steady bar cursor. + steady_bar, + + /// Returns the DECSCUSR parameter for this style. + pub fn toDecscusr(self: CursorStyle) u8 { + return switch (self) { + .default => 0, + .blinking_block => 1, + .steady_block => 2, + .blinking_underline => 3, + .steady_underline => 4, + .blinking_bar => 5, + .steady_bar => 6, + }; + } +}; + +/// Cursor controller. +/// +/// Provides methods to control cursor appearance and position. +pub const Cursor = struct { + file: std.fs.File, + + /// Creates a new cursor controller for stdout. + pub fn init() Cursor { + return .{ + .file = std.fs.File.stdout(), + }; + } + + /// Creates a cursor controller from a file. + pub fn fromFile(file: std.fs.File) Cursor { + return .{ .file = file }; + } + + /// Writes bytes to the output. + fn write(self: *Cursor, data: []const u8) !void { + _ = try self.file.write(data); + } + + // ======================================================================== + // Visibility + // ======================================================================== + + /// Hides the cursor. + pub fn hide(self: *Cursor) !void { + try self.write("\x1b[?25l"); + } + + /// Shows the cursor. + pub fn show(self: *Cursor) !void { + try self.write("\x1b[?25h"); + } + + // ======================================================================== + // Blinking + // ======================================================================== + + /// Enables cursor blinking. + pub fn enableBlinking(self: *Cursor) !void { + try self.write("\x1b[?12h"); + } + + /// Disables cursor blinking. + pub fn disableBlinking(self: *Cursor) !void { + try self.write("\x1b[?12l"); + } + + // ======================================================================== + // Style/Shape + // ======================================================================== + + /// Sets the cursor style. + /// + /// Uses DECSCUSR (DEC Set Cursor Style) escape sequence. + /// Supported by most modern terminals (xterm, VTE, iTerm2, etc.) + pub fn setStyle(self: *Cursor, cursor_style: CursorStyle) !void { + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d} q", .{cursor_style.toDecscusr()}) catch return; + try self.write(seq); + } + + // ======================================================================== + // Positioning + // ======================================================================== + + /// Moves the cursor to (column, row). + /// + /// Both are 0-indexed. Top-left is (0, 0). + pub fn moveTo(self: *Cursor, column: u16, row: u16) !void { + var buf: [32]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ row + 1, column + 1 }) catch return; + try self.write(seq); + } + + /// Moves the cursor to a specific column (0-indexed). + pub fn moveToColumn(self: *Cursor, column: u16) !void { + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}G", .{column + 1}) catch return; + try self.write(seq); + } + + /// Moves the cursor to a specific row (0-indexed). + pub fn moveToRow(self: *Cursor, row: u16) !void { + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}d", .{row + 1}) catch return; + try self.write(seq); + } + + /// Moves the cursor up by n rows. + pub fn moveUp(self: *Cursor, n: u16) !void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; + try self.write(seq); + } + + /// Moves the cursor down by n rows. + pub fn moveDown(self: *Cursor, n: u16) !void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return; + try self.write(seq); + } + + /// Moves the cursor right by n columns. + pub fn moveRight(self: *Cursor, n: u16) !void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}C", .{n}) catch return; + try self.write(seq); + } + + /// Moves the cursor left by n columns. + pub fn moveLeft(self: *Cursor, n: u16) !void { + if (n == 0) return; + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}D", .{n}) catch return; + try self.write(seq); + } + + /// Moves the cursor to the beginning of the next line, n lines down. + pub fn moveToNextLine(self: *Cursor, n: u16) !void { + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}E", .{if (n == 0) @as(u16, 1) else n}) catch return; + try self.write(seq); + } + + /// Moves the cursor to the beginning of the previous line, n lines up. + pub fn moveToPreviousLine(self: *Cursor, n: u16) !void { + var buf: [16]u8 = undefined; + const seq = std.fmt.bufPrint(&buf, "\x1b[{d}F", .{if (n == 0) @as(u16, 1) else n}) catch return; + try self.write(seq); + } + + // ======================================================================== + // Save/Restore + // ======================================================================== + + /// Saves the current cursor position. + /// + /// Use restorePosition() to return to this position. + pub fn savePosition(self: *Cursor) !void { + try self.write("\x1b7"); // DECSC + } + + /// Restores the previously saved cursor position. + pub fn restorePosition(self: *Cursor) !void { + try self.write("\x1b8"); // DECRC + } + + // ======================================================================== + // Alternative sequences (for compatibility) + // ======================================================================== + + /// Saves position using ANSI.SYS sequence (alternative). + pub fn savePositionAnsi(self: *Cursor) !void { + try self.write("\x1b[s"); + } + + /// Restores position using ANSI.SYS sequence (alternative). + pub fn restorePositionAnsi(self: *Cursor) !void { + try self.write("\x1b[u"); + } +}; + +// ============================================================================ +// Escape sequence constants (for direct use) +// ============================================================================ + +/// Hide cursor sequence. +pub const HIDE: []const u8 = "\x1b[?25l"; + +/// Show cursor sequence. +pub const SHOW: []const u8 = "\x1b[?25h"; + +/// Save cursor position sequence (DEC). +pub const SAVE_POSITION: []const u8 = "\x1b7"; + +/// Restore cursor position sequence (DEC). +pub const RESTORE_POSITION: []const u8 = "\x1b8"; + +/// Enable blinking sequence. +pub const ENABLE_BLINKING: []const u8 = "\x1b[?12h"; + +/// Disable blinking sequence. +pub const DISABLE_BLINKING: []const u8 = "\x1b[?12l"; + +// ============================================================================ +// Tests +// ============================================================================ + +test "CursorStyle toDecscusr" { + try std.testing.expectEqual(@as(u8, 0), CursorStyle.default.toDecscusr()); + try std.testing.expectEqual(@as(u8, 1), CursorStyle.blinking_block.toDecscusr()); + try std.testing.expectEqual(@as(u8, 2), CursorStyle.steady_block.toDecscusr()); + try std.testing.expectEqual(@as(u8, 5), CursorStyle.blinking_bar.toDecscusr()); + try std.testing.expectEqual(@as(u8, 6), CursorStyle.steady_bar.toDecscusr()); +} + +test "Cursor creation" { + _ = Cursor.init(); +} + +test "escape sequence constants" { + try std.testing.expectEqualStrings("\x1b[?25l", HIDE); + try std.testing.expectEqualStrings("\x1b[?25h", SHOW); + try std.testing.expectEqualStrings("\x1b7", SAVE_POSITION); + try std.testing.expectEqualStrings("\x1b8", RESTORE_POSITION); +} diff --git a/src/event.zig b/src/event.zig new file mode 100644 index 0000000..2f74d92 --- /dev/null +++ b/src/event.zig @@ -0,0 +1,393 @@ +//! Event types for zcatui. +//! +//! Provides keyboard, mouse, and terminal events for interactive TUI applications. +//! Inspired by crossterm's event system. +//! +//! ## Example +//! +//! ```zig +//! if (try term.pollEvent(100)) |event| { +//! switch (event) { +//! .key => |key| { +//! if (key.code == .char and key.char == 'q') break; +//! }, +//! .mouse => |mouse| { +//! if (mouse.kind == .down) handleClick(mouse.column, mouse.row); +//! }, +//! .resize => |size| try term.resize(size.width, size.height), +//! else => {}, +//! } +//! } +//! ``` + +const std = @import("std"); + +// ============================================================================ +// Main Event Type +// ============================================================================ + +/// Represents a terminal event. +/// +/// Events can be keyboard input, mouse interaction, terminal resize, +/// focus changes, or bracketed paste. +pub const Event = union(enum) { + /// A keyboard event. + key: KeyEvent, + /// A mouse event. + mouse: MouseEvent, + /// Terminal resize event. + resize: ResizeEvent, + /// Terminal gained focus. + focus_gained, + /// Terminal lost focus. + focus_lost, + /// Bracketed paste content. + /// Note: The slice is only valid until the next event is read. + paste: []const u8, +}; + +// ============================================================================ +// Keyboard Events +// ============================================================================ + +/// Represents a keyboard event. +pub const KeyEvent = struct { + /// The key that was pressed. + code: KeyCode, + /// Modifier keys held during the event. + modifiers: KeyModifiers = .{}, + /// The kind of key event. + kind: KeyEventKind = .press, + + /// Creates a KeyEvent from a character. + pub fn char(c: u21) KeyEvent { + return .{ .code = .{ .char = c } }; + } + + /// Creates a KeyEvent with modifiers. + pub fn withMod(code: KeyCode, mods: KeyModifiers) KeyEvent { + return .{ .code = code, .modifiers = mods }; + } + + /// Returns the character if this is a char event. + pub fn getChar(self: KeyEvent) ?u21 { + return switch (self.code) { + .char => |c| c, + else => null, + }; + } + + /// Checks if Ctrl modifier is active. + pub fn isCtrl(self: KeyEvent) bool { + return self.modifiers.ctrl; + } + + /// Checks if Alt modifier is active. + pub fn isAlt(self: KeyEvent) bool { + return self.modifiers.alt; + } + + /// Checks if Shift modifier is active. + pub fn isShift(self: KeyEvent) bool { + return self.modifiers.shift; + } +}; + +/// Represents a key code. +pub const KeyCode = union(enum) { + // Character keys + /// A unicode character. + char: u21, + + // Function keys + /// F1-F12 (and beyond). + f: u8, + + // Navigation keys + backspace, + enter, + tab, + backtab, // Shift+Tab + + // Arrow keys + left, + right, + up, + down, + + // Navigation + home, + end, + page_up, + page_down, + insert, + delete, + + // Special keys + esc, + null_key, + + // Lock keys (require enhanced keyboard protocol) + caps_lock, + scroll_lock, + num_lock, + + // System keys (require enhanced keyboard protocol) + print_screen, + pause, + menu, + + // Media keys + media: MediaKeyCode, + + // Modifier keys (when pressed alone, require enhanced protocol) + modifier: ModifierKeyCode, + + /// Checks if this key matches a character. + pub fn isChar(self: KeyCode, c: u21) bool { + return switch (self) { + .char => |ch| ch == c, + else => false, + }; + } +}; + +/// Media key codes. +pub const MediaKeyCode = enum { + play, + pause, + play_pause, + stop, + reverse, + fast_forward, + rewind, + next, + previous, + record, + lower_volume, + raise_volume, + mute_volume, +}; + +/// Modifier key codes (when pressed alone). +pub const ModifierKeyCode = enum { + left_shift, + right_shift, + left_ctrl, + right_ctrl, + left_alt, + right_alt, + left_super, + right_super, + left_hyper, + right_hyper, + left_meta, + right_meta, + iso_level_3_shift, + iso_level_5_shift, +}; + +/// Key modifiers (Ctrl, Alt, Shift, Super). +pub const KeyModifiers = packed struct { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + hyper: bool = false, + meta: bool = false, + _padding: u2 = 0, + + pub const none: KeyModifiers = .{}; + pub const SHIFT: KeyModifiers = .{ .shift = true }; + pub const CTRL: KeyModifiers = .{ .ctrl = true }; + pub const ALT: KeyModifiers = .{ .alt = true }; + pub const SUPER: KeyModifiers = .{ .super = true }; + + /// Combines two modifier sets. + pub fn combine(self: KeyModifiers, other: KeyModifiers) KeyModifiers { + return .{ + .shift = self.shift or other.shift, + .ctrl = self.ctrl or other.ctrl, + .alt = self.alt or other.alt, + .super = self.super or other.super, + .hyper = self.hyper or other.hyper, + .meta = self.meta or other.meta, + }; + } + + /// Checks if any modifier is active. + pub fn any(self: KeyModifiers) bool { + return self.shift or self.ctrl or self.alt or self.super or self.hyper or self.meta; + } + + /// Checks if no modifiers are active. + pub fn isEmpty(self: KeyModifiers) bool { + return !self.any(); + } +}; + +/// The kind of key event. +pub const KeyEventKind = enum { + /// Key was pressed. + press, + /// Key was released (requires enhanced keyboard protocol). + release, + /// Key is being held/repeated. + repeat, +}; + +// ============================================================================ +// Mouse Events +// ============================================================================ + +/// Represents a mouse event. +pub const MouseEvent = struct { + /// The kind of mouse event. + kind: MouseEventKind, + /// The mouse button involved (if applicable). + button: MouseButton = .none, + /// Column (x) position of the mouse (0-indexed). + column: u16, + /// Row (y) position of the mouse (0-indexed). + row: u16, + /// Modifier keys held during the event. + modifiers: KeyModifiers = .{}, + + /// Creates a mouse down event. + pub fn down(button: MouseButton, col: u16, r: u16) MouseEvent { + return .{ .kind = .down, .button = button, .column = col, .row = r }; + } + + /// Creates a mouse up event. + pub fn up(button: MouseButton, col: u16, r: u16) MouseEvent { + return .{ .kind = .up, .button = button, .column = col, .row = r }; + } + + /// Creates a mouse move event. + pub fn moved(col: u16, r: u16) MouseEvent { + return .{ .kind = .moved, .button = .none, .column = col, .row = r }; + } + + /// Checks if this is a click event (button down). + pub fn isClick(self: MouseEvent) bool { + return self.kind == .down; + } + + /// Checks if the left button was used. + pub fn isLeft(self: MouseEvent) bool { + return self.button == .left; + } + + /// Checks if the right button was used. + pub fn isRight(self: MouseEvent) bool { + return self.button == .right; + } +}; + +/// The kind of mouse event. +pub const MouseEventKind = enum { + /// Mouse button pressed down. + down, + /// Mouse button released. + up, + /// Mouse dragged (button held while moving). + drag, + /// Mouse moved (no button pressed). + moved, + /// Scroll wheel down. + scroll_down, + /// Scroll wheel up. + scroll_up, + /// Scroll wheel left (horizontal scroll). + scroll_left, + /// Scroll wheel right (horizontal scroll). + scroll_right, + + /// Checks if this is a scroll event. + pub fn isScroll(self: MouseEventKind) bool { + return self == .scroll_down or self == .scroll_up or + self == .scroll_left or self == .scroll_right; + } + + /// Checks if this is a button event (down/up). + pub fn isButton(self: MouseEventKind) bool { + return self == .down or self == .up; + } +}; + +/// Mouse buttons. +pub const MouseButton = enum { + /// Left mouse button. + left, + /// Right mouse button. + right, + /// Middle mouse button (wheel click). + middle, + /// No button (for move/scroll events). + none, +}; + +// ============================================================================ +// Terminal Events +// ============================================================================ + +/// Terminal resize event. +pub const ResizeEvent = struct { + /// New terminal width in columns. + width: u16, + /// New terminal height in rows. + height: u16, +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "KeyEvent creation" { + const key = KeyEvent.char('a'); + try std.testing.expectEqual(@as(u21, 'a'), key.getChar().?); + try std.testing.expect(!key.isCtrl()); +} + +test "KeyEvent with modifiers" { + const key = KeyEvent.withMod(.{ .char = 'c' }, .{ .ctrl = true }); + try std.testing.expect(key.isCtrl()); + try std.testing.expectEqual(@as(u21, 'c'), key.getChar().?); +} + +test "KeyModifiers combine" { + const ctrl = KeyModifiers.CTRL; + const shift = KeyModifiers.SHIFT; + const combined = ctrl.combine(shift); + try std.testing.expect(combined.ctrl); + try std.testing.expect(combined.shift); + try std.testing.expect(!combined.alt); +} + +test "KeyCode isChar" { + const code: KeyCode = .{ .char = 'q' }; + try std.testing.expect(code.isChar('q')); + try std.testing.expect(!code.isChar('x')); +} + +test "MouseEvent creation" { + const click = MouseEvent.down(.left, 10, 5); + try std.testing.expect(click.isClick()); + try std.testing.expect(click.isLeft()); + try std.testing.expectEqual(@as(u16, 10), click.column); + try std.testing.expectEqual(@as(u16, 5), click.row); +} + +test "MouseEventKind isScroll" { + try std.testing.expect(MouseEventKind.scroll_down.isScroll()); + try std.testing.expect(MouseEventKind.scroll_up.isScroll()); + try std.testing.expect(!MouseEventKind.down.isScroll()); +} + +test "Event union" { + const event: Event = .{ .key = KeyEvent.char('x') }; + switch (event) { + .key => |k| try std.testing.expectEqual(@as(u21, 'x'), k.getChar().?), + else => unreachable, + } +} diff --git a/src/event/parse.zig b/src/event/parse.zig new file mode 100644 index 0000000..31f242e --- /dev/null +++ b/src/event/parse.zig @@ -0,0 +1,616 @@ +//! Escape sequence parser for zcatui. +//! +//! Parses ANSI escape sequences into structured events. +//! Supports: +//! - Single key presses (a-z, 0-9, etc.) +//! - Control sequences (Ctrl+A, etc.) +//! - Function keys (F1-F12) +//! - Arrow keys and navigation +//! - Mouse events (SGR and X10 protocols) +//! - Bracketed paste +//! - Focus events + +const std = @import("std"); +const event = @import("../event.zig"); +const Event = event.Event; +const KeyEvent = event.KeyEvent; +const KeyCode = event.KeyCode; +const KeyModifiers = event.KeyModifiers; +const KeyEventKind = event.KeyEventKind; +const MouseEvent = event.MouseEvent; +const MouseEventKind = event.MouseEventKind; +const MouseButton = event.MouseButton; +const ResizeEvent = event.ResizeEvent; + +/// Result of parsing input. +pub const ParseResult = struct { + event: ?Event, + consumed: usize, +}; + +/// Parses terminal input into an event. +/// +/// Returns the parsed event (if valid) and number of bytes consumed. +pub fn parseEvent(data: []const u8) ParseResult { + if (data.len == 0) { + return .{ .event = null, .consumed = 0 }; + } + + const first = data[0]; + + // Escape sequence + if (first == 0x1B) { + return parseEscapeSequence(data); + } + + // Control characters (Ctrl+A through Ctrl+Z) + if (first < 32) { + return parseControlChar(first); + } + + // DEL (Ctrl+?) + if (first == 127) { + return .{ + .event = .{ .key = .{ .code = .backspace } }, + .consumed = 1, + }; + } + + // Regular UTF-8 character + return parseUtf8Char(data); +} + +/// Parses a control character (0x00-0x1F). +fn parseControlChar(c: u8) ParseResult { + const ev: KeyEvent = switch (c) { + 0 => .{ .code = .{ .char = ' ' }, .modifiers = .{ .ctrl = true } }, // Ctrl+Space + 9 => .{ .code = .tab }, // Tab (Ctrl+I) + 10, 13 => .{ .code = .enter }, // Enter (Ctrl+J, Ctrl+M) + 27 => .{ .code = .esc }, // Escape + 1...8, 11, 12, 14...26 => .{ .code = .{ .char = 'a' + c - 1 }, .modifiers = .{ .ctrl = true } }, // Ctrl+A-Z (excluding Tab, Enter, Esc) + else => .{ .code = .null_key }, + }; + + return .{ + .event = .{ .key = ev }, + .consumed = 1, + }; +} + +/// Parses an escape sequence. +fn parseEscapeSequence(data: []const u8) ParseResult { + if (data.len < 2) { + // Just ESC key + return .{ + .event = .{ .key = .{ .code = .esc } }, + .consumed = 1, + }; + } + + const second = data[1]; + + // ESC [ - CSI sequence + if (second == '[') { + return parseCsiSequence(data); + } + + // ESC O - SS3 sequence (some terminals use for function keys) + if (second == 'O') { + return parseSs3Sequence(data); + } + + // ESC followed by a character = Alt+key + if (second >= 32 and second < 127) { + return .{ + .event = .{ .key = .{ + .code = .{ .char = second }, + .modifiers = .{ .alt = true }, + } }, + .consumed = 2, + }; + } + + // Unknown escape sequence, return just ESC + return .{ + .event = .{ .key = .{ .code = .esc } }, + .consumed = 1, + }; +} + +/// Parses a CSI (Control Sequence Introducer) sequence. +/// Format: ESC [ +fn parseCsiSequence(data: []const u8) ParseResult { + if (data.len < 3) { + return .{ .event = null, .consumed = 0 }; + } + + // Find the end of the sequence (final byte: 0x40-0x7E) + var end: usize = 2; + while (end < data.len) : (end += 1) { + const c = data[end]; + if (c >= 0x40 and c <= 0x7E) { + end += 1; + break; + } + } + + if (end > data.len) { + return .{ .event = null, .consumed = 0 }; + } + + const seq = data[2..end]; + if (seq.len == 0) { + return .{ .event = null, .consumed = end }; + } + + const final = seq[seq.len - 1]; + const params_slice = if (seq.len > 1) seq[0 .. seq.len - 1] else ""; + + // Parse based on final byte + switch (final) { + // Arrow keys and navigation + 'A' => return keyResult(.up, params_slice, end), + 'B' => return keyResult(.down, params_slice, end), + 'C' => return keyResult(.right, params_slice, end), + 'D' => return keyResult(.left, params_slice, end), + 'H' => return keyResult(.home, params_slice, end), + 'F' => return keyResult(.end, params_slice, end), + + // Delete, Insert, Page Up/Down, Home, End (with ~) + '~' => return parseTildeSequence(params_slice, end), + + // Focus events + 'I' => return .{ .event = .focus_gained, .consumed = end }, + 'O' => return .{ .event = .focus_lost, .consumed = end }, + + // SGR mouse (ESC [ < ... M/m) + 'M', 'm' => { + // Check if this is SGR mouse (starts with <) + if (params_slice.len > 0 and params_slice[0] == '<') { + return parseSgrMouse(params_slice[1..], final == 'm', end); + } + // Legacy X10 mouse - handled differently + return parseX10Mouse(data, end); + }, + + // Bracketed paste + 'u' => { + // Kitty keyboard protocol or other + return .{ .event = null, .consumed = end }; + }, + + else => return .{ .event = null, .consumed = end }, + } +} + +/// Parses tilde sequences (F-keys, Insert, Delete, etc.). +fn parseTildeSequence(params: []const u8, consumed: usize) ParseResult { + const params_parsed = parseNumericParams(params); + const key_num = params_parsed.first; + const modifier = modifiersFromParam(params_parsed.second); + + const code: KeyCode = switch (key_num) { + 1 => .home, + 2 => .insert, + 3 => .delete, + 4 => .end, + 5 => .page_up, + 6 => .page_down, + 7 => .home, + 8 => .end, + // Function keys + 11 => .{ .f = 1 }, + 12 => .{ .f = 2 }, + 13 => .{ .f = 3 }, + 14 => .{ .f = 4 }, + 15 => .{ .f = 5 }, + 17 => .{ .f = 6 }, + 18 => .{ .f = 7 }, + 19 => .{ .f = 8 }, + 20 => .{ .f = 9 }, + 21 => .{ .f = 10 }, + 23 => .{ .f = 11 }, + 24 => .{ .f = 12 }, + else => .null_key, + }; + + return .{ + .event = .{ .key = .{ .code = code, .modifiers = modifier } }, + .consumed = consumed, + }; +} + +/// Parses SS3 sequences (ESC O ...). +fn parseSs3Sequence(data: []const u8) ParseResult { + if (data.len < 3) { + return .{ .event = null, .consumed = 0 }; + } + + const final = data[2]; + const code: KeyCode = switch (final) { + 'A' => .up, + 'B' => .down, + 'C' => .right, + 'D' => .left, + 'H' => .home, + 'F' => .end, + 'P' => .{ .f = 1 }, + 'Q' => .{ .f = 2 }, + 'R' => .{ .f = 3 }, + 'S' => .{ .f = 4 }, + else => .null_key, + }; + + return .{ + .event = .{ .key = .{ .code = code } }, + .consumed = 3, + }; +} + +/// Parses SGR mouse protocol (ESC [ < Cb;Cx;Cy M/m). +fn parseSgrMouse(params: []const u8, is_release: bool, consumed: usize) ParseResult { + var nums: [3]u16 = .{ 0, 0, 0 }; + var num_idx: usize = 0; + var current: u16 = 0; + + for (params) |c| { + if (c >= '0' and c <= '9') { + current = current * 10 + (c - '0'); + } else if (c == ';') { + if (num_idx < 3) { + nums[num_idx] = current; + num_idx += 1; + } + current = 0; + } + } + if (num_idx < 3) { + nums[num_idx] = current; + } + + const cb = nums[0]; + const cx = if (nums[1] > 0) nums[1] - 1 else 0; // 1-indexed to 0-indexed + const cy = if (nums[2] > 0) nums[2] - 1 else 0; + + // Decode button and modifiers from cb + const button_bits = cb & 0b11; + const motion = (cb & 0b100000) != 0; + const shift = (cb & 0b100) != 0; + const alt = (cb & 0b1000) != 0; + const ctrl = (cb & 0b10000) != 0; + + const mods = KeyModifiers{ + .shift = shift, + .alt = alt, + .ctrl = ctrl, + }; + + // Determine event kind and button + var kind: MouseEventKind = undefined; + var button: MouseButton = undefined; + + // Check for scroll events (button_bits == 0b11 with specific high bits) + if ((cb & 0b1000000) != 0) { + // Scroll events + if (button_bits == 0) { + kind = .scroll_up; + } else if (button_bits == 1) { + kind = .scroll_down; + } else if (button_bits == 2) { + kind = .scroll_left; + } else { + kind = .scroll_right; + } + button = .none; + } else if (button_bits == 3) { + // Release (in X10 mode) or motion only + kind = if (motion) .moved else .up; + button = .none; + } else { + // Button event + button = switch (button_bits) { + 0 => .left, + 1 => .middle, + 2 => .right, + else => .none, + }; + + if (is_release) { + kind = .up; + } else if (motion) { + kind = .drag; + } else { + kind = .down; + } + } + + return .{ + .event = .{ + .mouse = .{ + .kind = kind, + .button = button, + .column = cx, + .row = cy, + .modifiers = mods, + }, + }, + .consumed = consumed, + }; +} + +/// Parses X10 mouse protocol (ESC [ M Cb Cx Cy). +fn parseX10Mouse(data: []const u8, start_consumed: usize) ParseResult { + _ = start_consumed; + + // X10 format: ESC [ M Cb Cx Cy (6 bytes total) + if (data.len < 6) { + return .{ .event = null, .consumed = 0 }; + } + + const cb = data[3] -| 32; + const cx = data[4] -| 33; // 1-indexed, 32 offset + const cy = data[5] -| 33; + + const button_bits = cb & 0b11; + const motion = (cb & 0b100000) != 0; + const shift = (cb & 0b100) != 0; + const alt = (cb & 0b1000) != 0; + const ctrl = (cb & 0b10000) != 0; + + const mods = KeyModifiers{ + .shift = shift, + .alt = alt, + .ctrl = ctrl, + }; + + var kind: MouseEventKind = undefined; + var button: MouseButton = undefined; + + if ((cb & 0b1000000) != 0) { + // Scroll + kind = if (button_bits == 0) .scroll_up else .scroll_down; + button = .none; + } else if (button_bits == 3) { + kind = if (motion) .moved else .up; + button = .none; + } else { + button = switch (button_bits) { + 0 => .left, + 1 => .middle, + 2 => .right, + else => .none, + }; + kind = if (motion) .drag else .down; + } + + return .{ + .event = .{ + .mouse = .{ + .kind = kind, + .button = button, + .column = cx, + .row = cy, + .modifiers = mods, + }, + }, + .consumed = 6, + }; +} + +/// Parses a UTF-8 character. +fn parseUtf8Char(data: []const u8) ParseResult { + const len = std.unicode.utf8ByteSequenceLength(data[0]) catch 1; + if (len > data.len) { + return .{ .event = null, .consumed = 0 }; + } + + const codepoint = std.unicode.utf8Decode(data[0..len]) catch ' '; + + return .{ + .event = .{ .key = .{ .code = .{ .char = codepoint } } }, + .consumed = len, + }; +} + +/// Creates a key result with optional modifiers. +fn keyResult(code: KeyCode, params: []const u8, consumed: usize) ParseResult { + const params_parsed = parseNumericParams(params); + const modifier = modifiersFromParam(params_parsed.second); + + return .{ + .event = .{ .key = .{ .code = code, .modifiers = modifier } }, + .consumed = consumed, + }; +} + +/// Parses numeric parameters from CSI sequence. +fn parseNumericParams(params: []const u8) struct { first: u16, second: u8 } { + var first: u16 = 0; + var second: u8 = 0; + var in_second = false; + + for (params) |c| { + if (c >= '0' and c <= '9') { + if (in_second) { + second = second * 10 + (c - '0'); + } else { + first = first * 10 + @as(u16, c - '0'); + } + } else if (c == ';') { + in_second = true; + } + } + + return .{ .first = first, .second = second }; +} + +/// Converts modifier parameter to KeyModifiers. +fn modifiersFromParam(param: u8) KeyModifiers { + if (param == 0) return .{}; + + const m = param -| 1; + return .{ + .shift = (m & 1) != 0, + .alt = (m & 2) != 0, + .ctrl = (m & 4) != 0, + .super = (m & 8) != 0, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "parse single character" { + const result = parseEvent("a"); + try std.testing.expectEqual(@as(usize, 1), result.consumed); + const ev = result.event.?.key; + try std.testing.expectEqual(@as(u21, 'a'), ev.code.char); +} + +test "parse escape key" { + const result = parseEvent(&[_]u8{27}); + try std.testing.expectEqual(@as(usize, 1), result.consumed); + try std.testing.expectEqual(KeyCode.esc, result.event.?.key.code); +} + +test "parse arrow keys" { + // Up arrow: ESC [ A + const up = parseEvent("\x1b[A"); + try std.testing.expectEqual(KeyCode.up, up.event.?.key.code); + + // Down arrow: ESC [ B + const down = parseEvent("\x1b[B"); + try std.testing.expectEqual(KeyCode.down, down.event.?.key.code); + + // Right arrow: ESC [ C + const right = parseEvent("\x1b[C"); + try std.testing.expectEqual(KeyCode.right, right.event.?.key.code); + + // Left arrow: ESC [ D + const left = parseEvent("\x1b[D"); + try std.testing.expectEqual(KeyCode.left, left.event.?.key.code); +} + +test "parse function keys" { + // F1: ESC O P (SS3 sequence) + const f1 = parseEvent("\x1bOP"); + try std.testing.expectEqual(@as(u8, 1), f1.event.?.key.code.f); + + // F5: ESC [ 15 ~ + const f5 = parseEvent("\x1b[15~"); + try std.testing.expectEqual(@as(u8, 5), f5.event.?.key.code.f); +} + +test "parse control characters" { + // Ctrl+C (0x03) + const ctrl_c = parseEvent(&[_]u8{3}); + const ev = ctrl_c.event.?.key; + try std.testing.expectEqual(@as(u21, 'c'), ev.code.char); + try std.testing.expect(ev.modifiers.ctrl); +} + +test "parse enter and tab" { + // Enter (0x0D) + const enter = parseEvent(&[_]u8{13}); + try std.testing.expectEqual(KeyCode.enter, enter.event.?.key.code); + + // Tab (0x09) + const tab = parseEvent(&[_]u8{9}); + try std.testing.expectEqual(KeyCode.tab, tab.event.?.key.code); +} + +test "parse alt+key" { + // Alt+a: ESC a + const alt_a = parseEvent("\x1ba"); + const ev = alt_a.event.?.key; + try std.testing.expectEqual(@as(u21, 'a'), ev.code.char); + try std.testing.expect(ev.modifiers.alt); +} + +test "parse delete and insert" { + // Delete: ESC [ 3 ~ + const del = parseEvent("\x1b[3~"); + try std.testing.expectEqual(KeyCode.delete, del.event.?.key.code); + + // Insert: ESC [ 2 ~ + const ins = parseEvent("\x1b[2~"); + try std.testing.expectEqual(KeyCode.insert, ins.event.?.key.code); +} + +test "parse page up/down" { + // Page Up: ESC [ 5 ~ + const pgup = parseEvent("\x1b[5~"); + try std.testing.expectEqual(KeyCode.page_up, pgup.event.?.key.code); + + // Page Down: ESC [ 6 ~ + const pgdn = parseEvent("\x1b[6~"); + try std.testing.expectEqual(KeyCode.page_down, pgdn.event.?.key.code); +} + +test "parse home/end" { + // Home: ESC [ H + const home = parseEvent("\x1b[H"); + try std.testing.expectEqual(KeyCode.home, home.event.?.key.code); + + // End: ESC [ F + const end_key = parseEvent("\x1b[F"); + try std.testing.expectEqual(KeyCode.end, end_key.event.?.key.code); +} + +test "parse UTF-8 character" { + // UTF-8: é (0xC3 0xA9) + const result = parseEvent("\xc3\xa9"); + try std.testing.expectEqual(@as(usize, 2), result.consumed); + try std.testing.expectEqual(@as(u21, 'é'), result.event.?.key.code.char); +} + +test "parse focus events" { + // Focus gained: ESC [ I + const focus_in = parseEvent("\x1b[I"); + try std.testing.expectEqual(Event.focus_gained, focus_in.event.?); + + // Focus lost: ESC [ O + const focus_out = parseEvent("\x1b[O"); + try std.testing.expectEqual(Event.focus_lost, focus_out.event.?); +} + +test "parse SGR mouse click" { + // Left click at (10, 5): ESC [ < 0 ; 11 ; 6 M + const click = parseEvent("\x1b[<0;11;6M"); + const mouse = click.event.?.mouse; + try std.testing.expectEqual(MouseEventKind.down, mouse.kind); + try std.testing.expectEqual(MouseButton.left, mouse.button); + try std.testing.expectEqual(@as(u16, 10), mouse.column); + try std.testing.expectEqual(@as(u16, 5), mouse.row); +} + +test "parse SGR mouse release" { + // Left release at (10, 5): ESC [ < 0 ; 11 ; 6 m + const release = parseEvent("\x1b[<0;11;6m"); + const mouse = release.event.?.mouse; + try std.testing.expectEqual(MouseEventKind.up, mouse.kind); +} + +test "parse backspace" { + // Backspace (127) + const bs = parseEvent(&[_]u8{127}); + try std.testing.expectEqual(KeyCode.backspace, bs.event.?.key.code); +} + +test "modifiersFromParam" { + // param 2 = Shift + const shift = modifiersFromParam(2); + try std.testing.expect(shift.shift); + try std.testing.expect(!shift.ctrl); + + // param 5 = Ctrl + const ctrl = modifiersFromParam(5); + try std.testing.expect(ctrl.ctrl); + try std.testing.expect(!ctrl.shift); + + // param 6 = Shift+Ctrl + const both = modifiersFromParam(6); + try std.testing.expect(both.shift); + try std.testing.expect(both.ctrl); +} diff --git a/src/event/reader.zig b/src/event/reader.zig new file mode 100644 index 0000000..be61f14 --- /dev/null +++ b/src/event/reader.zig @@ -0,0 +1,133 @@ +//! Event reader for zcatui. +//! +//! Provides non-blocking and blocking event reading from stdin. +//! Uses poll() for efficient event waiting with timeout support. + +const std = @import("std"); +const event = @import("../event.zig"); +const Event = event.Event; +const KeyEvent = event.KeyEvent; +const KeyCode = event.KeyCode; +const KeyModifiers = event.KeyModifiers; +const MouseEvent = event.MouseEvent; +const MouseEventKind = event.MouseEventKind; +const MouseButton = event.MouseButton; +const ResizeEvent = event.ResizeEvent; + +const parse = @import("parse.zig"); + +/// Event reader for terminal input. +/// +/// Reads and parses terminal escape sequences into structured events. +pub const EventReader = struct { + stdin: std.fs.File, + buffer: [256]u8 = undefined, + buffer_len: usize = 0, + buffer_pos: usize = 0, + + /// Creates a new event reader. + pub fn init() EventReader { + return .{ + .stdin = std.fs.File.stdin(), + }; + } + + /// Polls for an event with a timeout. + /// + /// Returns an event if available within the timeout, or null if no event. + /// A timeout of 0 means non-blocking (check immediately). + /// A timeout of null means block indefinitely. + /// + /// Parameters: + /// timeout_ms: Timeout in milliseconds, or null to block forever + pub fn poll(self: *EventReader, timeout_ms: ?u32) !?Event { + // First check if we have buffered data + if (self.buffer_pos < self.buffer_len) { + return self.parseBufferedEvent(); + } + + // Use poll to wait for input + var fds = [_]std.posix.pollfd{ + .{ + .fd = self.stdin.handle, + .events = std.posix.POLL.IN, + .revents = 0, + }, + }; + + const timeout: i32 = if (timeout_ms) |t| @intCast(t) else -1; + const ready = std.posix.poll(&fds, timeout) catch { + // On any error (including interrupt), treat as timeout + return null; + }; + + if (ready == 0) { + return null; // Timeout + } + + if (fds[0].revents & std.posix.POLL.IN != 0) { + return self.readEvent(); + } + + return null; + } + + /// Reads an event, blocking until one is available. + pub fn read(self: *EventReader) !Event { + while (true) { + if (try self.poll(null)) |ev| { + return ev; + } + } + } + + /// Reads raw input and parses into an event. + fn readEvent(self: *EventReader) !?Event { + const n = self.stdin.read(&self.buffer) catch |err| { + return switch (err) { + error.WouldBlock => null, + else => err, + }; + }; + + if (n == 0) { + return null; + } + + self.buffer_len = n; + self.buffer_pos = 0; + + return self.parseBufferedEvent(); + } + + /// Parses an event from the buffer. + fn parseBufferedEvent(self: *EventReader) ?Event { + if (self.buffer_pos >= self.buffer_len) { + return null; + } + + const data = self.buffer[self.buffer_pos..self.buffer_len]; + const result = parse.parseEvent(data); + + self.buffer_pos += result.consumed; + return result.event; + } + + /// Checks if there's buffered data waiting to be parsed. + pub fn hasBufferedData(self: *const EventReader) bool { + return self.buffer_pos < self.buffer_len; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "EventReader creation" { + _ = EventReader.init(); +} + +test "EventReader hasBufferedData" { + var reader = EventReader.init(); + try std.testing.expect(!reader.hasBufferedData()); +} diff --git a/src/root.zig b/src/root.zig index 27a2f4f..ed1c6b6 100644 --- a/src/root.zig +++ b/src/root.zig @@ -134,6 +134,28 @@ pub const widgets = struct { // Backend pub const backend = @import("backend/backend.zig"); +// Events (crossterm-style) +pub const event = @import("event.zig"); +pub const Event = event.Event; +pub const KeyEvent = event.KeyEvent; +pub const KeyCode = event.KeyCode; +pub const KeyModifiers = event.KeyModifiers; +pub const KeyEventKind = event.KeyEventKind; +pub const MouseEvent = event.MouseEvent; +pub const MouseEventKind = event.MouseEventKind; +pub const MouseButton = event.MouseButton; +pub const ResizeEvent = event.ResizeEvent; + +pub const event_reader = @import("event/reader.zig"); +pub const EventReader = event_reader.EventReader; + +pub const event_parse = @import("event/parse.zig"); + +// Cursor control +pub const cursor = @import("cursor.zig"); +pub const Cursor = cursor.Cursor; +pub const CursorStyle = cursor.CursorStyle; + // ============================================================================ // Tests // ============================================================================ @@ -143,3 +165,11 @@ test "zcatui module compiles" { _ = style; _ = buffer; } + +test { + // Include all module tests + _ = @import("event.zig"); + _ = @import("event/reader.zig"); + _ = @import("event/parse.zig"); + _ = @import("cursor.zig"); +} diff --git a/src/terminal.zig b/src/terminal.zig index 8ef9688..f295a8e 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1,7 +1,7 @@ //! Terminal abstraction for zcatui. //! //! The Terminal struct provides the main entry point for TUI applications. -//! It handles initialization, drawing, and cleanup of the terminal state. +//! It handles initialization, drawing, event handling, and cleanup. //! //! ## Example //! @@ -9,7 +9,22 @@ //! var term = try Terminal.init(allocator); //! defer term.deinit(); //! -//! try term.draw(renderFn); +//! // Optional: enable mouse capture +//! try term.enableMouseCapture(); +//! defer term.disableMouseCapture() catch {}; +//! +//! while (true) { +//! try term.draw(renderFn); +//! +//! if (try term.pollEvent(100)) |event| { +//! switch (event) { +//! .key => |key| if (key.code == .esc) break, +//! .mouse => |mouse| handleMouse(mouse), +//! .resize => |size| try term.resize(size.width, size.height), +//! else => {}, +//! } +//! } +//! } //! ``` const std = @import("std"); @@ -18,13 +33,24 @@ const Buffer = buffer.Buffer; const Rect = buffer.Rect; const backend_mod = @import("backend/backend.zig"); const AnsiBackend = backend_mod.AnsiBackend; +const event_mod = @import("event.zig"); +const Event = event_mod.Event; +const event_reader = @import("event/reader.zig"); +const EventReader = event_reader.EventReader; /// Terminal provides the main interface for TUI applications. +/// +/// Combines rendering (ratatui-style) with event handling (crossterm-style) +/// in a single, unified interface. pub const Terminal = struct { allocator: std.mem.Allocator, backend: AnsiBackend, current_buffer: Buffer, previous_buffer: Buffer, + event_reader: EventReader, + mouse_enabled: bool = false, + focus_enabled: bool = false, + bracketed_paste_enabled: bool = false, /// Initializes the terminal for TUI mode. /// @@ -54,13 +80,26 @@ pub const Terminal = struct { .backend = backend, .current_buffer = current_buffer, .previous_buffer = previous_buffer, + .event_reader = EventReader.init(), }; } /// Cleans up terminal state. /// /// Shows cursor, exits alternate screen, and restores terminal mode. + /// Also disables any enabled features (mouse, focus, paste). pub fn deinit(self: *Terminal) void { + // Disable enabled features + if (self.mouse_enabled) { + self.disableMouseCapture() catch {}; + } + if (self.focus_enabled) { + self.disableFocusChange() catch {}; + } + if (self.bracketed_paste_enabled) { + self.disableBracketedPaste() catch {}; + } + self.backend.disableRawMode() catch {}; self.backend.showCursor() catch {}; self.backend.leaveAlternateScreen() catch {}; @@ -146,6 +185,143 @@ pub const Terminal = struct { self.previous_buffer.clear(); self.current_buffer.markDirty(); } + + // ======================================================================== + // Event Handling + // ======================================================================== + + /// Polls for an event with a timeout. + /// + /// Returns an event if one is available within the timeout, or null if + /// the timeout expires. A timeout of 0 means non-blocking (check immediately). + /// + /// Parameters: + /// timeout_ms: Timeout in milliseconds, or null to block forever + /// + /// Example: + /// ```zig + /// if (try term.pollEvent(100)) |event| { + /// switch (event) { + /// .key => |key| handleKey(key), + /// .mouse => |mouse| handleMouse(mouse), + /// else => {}, + /// } + /// } + /// ``` + pub fn pollEvent(self: *Terminal, timeout_ms: ?u32) !?Event { + return self.event_reader.poll(timeout_ms); + } + + /// Reads an event, blocking until one is available. + /// + /// Use pollEvent() with a timeout for non-blocking behavior. + pub fn readEvent(self: *Terminal) !Event { + return self.event_reader.read(); + } + + // ======================================================================== + // Mouse Capture + // ======================================================================== + + /// Enables mouse event capture. + /// + /// After enabling, mouse events (clicks, movement, scroll) will be + /// reported through pollEvent()/readEvent(). + /// + /// Uses SGR extended mouse protocol for better coordinate support. + pub fn enableMouseCapture(self: *Terminal) !void { + if (self.mouse_enabled) return; + + // Enable mouse tracking: + // 1000: Normal tracking (button press/release) + // 1002: Button event tracking (drag) + // 1003: Any event tracking (motion) + // 1006: SGR extended mode (better coordinates, release info) + _ = try self.backend.stdout.write("\x1b[?1000h"); // Enable button events + _ = try self.backend.stdout.write("\x1b[?1002h"); // Enable button motion + _ = try self.backend.stdout.write("\x1b[?1003h"); // Enable any motion + _ = try self.backend.stdout.write("\x1b[?1006h"); // Enable SGR mode + + self.mouse_enabled = true; + } + + /// Disables mouse event capture. + pub fn disableMouseCapture(self: *Terminal) !void { + if (!self.mouse_enabled) return; + + _ = try self.backend.stdout.write("\x1b[?1006l"); + _ = try self.backend.stdout.write("\x1b[?1003l"); + _ = try self.backend.stdout.write("\x1b[?1002l"); + _ = try self.backend.stdout.write("\x1b[?1000l"); + + self.mouse_enabled = false; + } + + // ======================================================================== + // Focus Events + // ======================================================================== + + /// Enables focus change events. + /// + /// When enabled, you'll receive Event.focus_gained and Event.focus_lost + /// events when the terminal gains or loses focus. + pub fn enableFocusChange(self: *Terminal) !void { + if (self.focus_enabled) return; + + _ = try self.backend.stdout.write("\x1b[?1004h"); + self.focus_enabled = true; + } + + /// Disables focus change events. + pub fn disableFocusChange(self: *Terminal) !void { + if (!self.focus_enabled) return; + + _ = try self.backend.stdout.write("\x1b[?1004l"); + self.focus_enabled = false; + } + + // ======================================================================== + // Bracketed Paste + // ======================================================================== + + /// Enables bracketed paste mode. + /// + /// When enabled, pasted text will be wrapped in escape sequences, + /// allowing the application to distinguish between typed and pasted text. + /// Pasted content will arrive as Event.paste events. + pub fn enableBracketedPaste(self: *Terminal) !void { + if (self.bracketed_paste_enabled) return; + + _ = try self.backend.stdout.write("\x1b[?2004h"); + self.bracketed_paste_enabled = true; + } + + /// Disables bracketed paste mode. + pub fn disableBracketedPaste(self: *Terminal) !void { + if (!self.bracketed_paste_enabled) return; + + _ = try self.backend.stdout.write("\x1b[?2004l"); + self.bracketed_paste_enabled = false; + } + + // ======================================================================== + // Cursor Control + // ======================================================================== + + /// Shows the cursor. + pub fn showCursor(self: *Terminal) !void { + try self.backend.showCursor(); + } + + /// Hides the cursor. + pub fn hideCursor(self: *Terminal) !void { + try self.backend.hideCursor(); + } + + /// Sets the cursor position. + pub fn setCursorPosition(self: *Terminal, column: u16, row: u16) !void { + try self.backend.moveCursor(column, row); + } }; // Helper functions for comparison