zcatui v1.2 - Sistema de eventos integrado (crossterm-style)

Eventos de teclado:
- Event, KeyEvent, KeyCode, KeyModifiers, KeyEventKind
- Soporte para todas las teclas: caracteres, F1-F12, navegación, control
- Modificadores: Ctrl, Alt, Shift, Super

Eventos de ratón:
- MouseEvent, MouseEventKind, MouseButton
- Click, release, drag, scroll (up/down/left/right)
- Posición (column, row) con modificadores
- Protocolos: SGR extended y X10 legacy

Parser de escape sequences:
- CSI sequences (arrows, F-keys, navigation)
- SS3 sequences (F1-F4 alternativo)
- SGR mouse protocol (mejores coordenadas)
- X10 mouse protocol (compatibilidad)
- Focus events, bracketed paste

Cursor control:
- Visibility: show/hide
- Blinking: enable/disable
- Styles: block, underline, bar (blinking/steady)
- Position: moveTo, moveUp/Down/Left/Right
- Save/restore position

Terminal integrado:
- pollEvent(timeout_ms) - polling con timeout
- readEvent() - blocking read
- enableMouseCapture/disableMouseCapture
- enableFocusChange/disableFocusChange
- enableBracketedPaste/disableBracketedPaste

Ejemplo interactivo:
- examples/events_demo.zig

Tests: 47 (29 nuevos para eventos y cursor)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 12:55:54 +01:00
parent 7acf583763
commit 5556ee1370
10 changed files with 2111 additions and 26 deletions

View file

@ -2,22 +2,24 @@
> **Última actualización**: 2025-12-08 > **Última actualización**: 2025-12-08
> **Lenguaje**: Zig 0.15.2 > **Lenguaje**: Zig 0.15.2
> **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) (Rust TUI library) > **Inspiración**: [ratatui](https://github.com/ratatui/ratatui) + [crossterm](https://github.com/crossterm-rs/crossterm) (Rust)
> **Estado**: v1.1 - Implementación completa + optimizaciones de performance > **Estado**: v1.2 - Renderizado + Eventos integrados
## Descripción del Proyecto ## 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) **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 ## Estado Actual del Proyecto
### Implementación Completa (v1.1) - 2025-12-08 ### Implementación Completa (v1.2) - 2025-12-08
| Componente | Estado | Archivo | | Componente | Estado | Archivo |
|------------|--------|---------| |------------|--------|---------|
@ -28,6 +30,11 @@
| Layout + Constraint | ✅ | `src/layout.zig` | | Layout + Constraint | ✅ | `src/layout.zig` |
| Terminal | ✅ | `src/terminal.zig` | | Terminal | ✅ | `src/terminal.zig` |
| Backend ANSI | ✅ | `src/backend/` | | 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/` | | **Symbols** | ✅ Completo | `src/symbols/` |
| Line drawing | ✅ | `line.zig` | | Line drawing | ✅ | `line.zig` |
| Border sets | ✅ | `border.zig` | | Border sets | ✅ | `border.zig` |
@ -96,14 +103,18 @@ Como ratatui, usamos **renderizado inmediato con buffers intermedios**:
zcatui/ zcatui/
├── src/ ├── src/
│ ├── root.zig # Entry point, re-exports públicos │ ├── root.zig # Entry point, re-exports públicos
│ ├── terminal.zig # Terminal abstraction │ ├── terminal.zig # Terminal + eventos integrados
│ ├── buffer.zig # Buffer + Cell + Rect │ ├── buffer.zig # Buffer + Cell + Rect
│ ├── layout.zig # Layout, Constraint, Direction │ ├── layout.zig # Layout, Constraint, Direction
│ ├── style.zig # Color, Style, Modifier │ ├── style.zig # Color, Style, Modifier
│ ├── text.zig # Text, Line, Span, Alignment │ ├── 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/
│ │ ├── backend.zig # Backend interface │ │ └── backend.zig # ANSI escape sequences backend
│ │ └── ansi.zig # ANSI escape sequences
│ ├── symbols/ │ ├── symbols/
│ │ ├── symbols.zig # Re-exports │ │ ├── symbols.zig # Re-exports
│ │ ├── line.zig # Line drawing characters │ │ ├── line.zig # Line drawing characters
@ -128,6 +139,9 @@ zcatui/
│ ├── chart.zig # Line/scatter/bar graphs │ ├── chart.zig # Line/scatter/bar graphs
│ ├── calendar.zig # Monthly calendar │ ├── calendar.zig # Monthly calendar
│ └── clear.zig # Clear/reset area │ └── clear.zig # Clear/reset area
├── examples/
│ ├── hello.zig # Minimal example
│ └── events_demo.zig # Interactive keyboard/mouse demo
├── docs/ ├── docs/
│ ├── ARCHITECTURE.md # Arquitectura detallada │ ├── ARCHITECTURE.md # Arquitectura detallada
│ ├── WIDGETS.md # Documentación de widgets │ ├── WIDGETS.md # Documentación de widgets
@ -449,6 +463,16 @@ zig build test --summary all # Tests con detalles
## Historial de Desarrollo ## 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) ### 2025-12-08 - v1.1 (Performance)
- Symbol: tipo compacto UTF-8 (4 bytes max) - Symbol: tipo compacto UTF-8 (4 bytes max)
- Buffer.diff(): renderizado diferencial eficiente - Buffer.diff(): renderizado diferencial eficiente

View file

@ -42,4 +42,23 @@ pub fn build(b: *std.Build) void {
run_hello.step.dependOn(b.getInstallStep()); run_hello.step.dependOn(b.getInstallStep());
const hello_step = b.step("hello", "Ejecutar ejemplo hello"); const hello_step = b.step("hello", "Ejecutar ejemplo hello");
hello_step.dependOn(&run_hello.step); 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);
} }

View file

@ -27,6 +27,19 @@ const Direction = zcatui.Direction;
// Terminal // Terminal
const Terminal = zcatui.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 // Widgets
const widgets = zcatui.widgets; const widgets = zcatui.widgets;
const Block = widgets.Block; const Block = widgets.Block;
@ -552,23 +565,36 @@ pub const Terminal = struct {
pub fn size(self: Terminal) struct { width: u16, height: u16 }; pub fn size(self: Terminal) struct { width: u16, height: u16 };
pub fn area(self: Terminal) Rect; pub fn area(self: Terminal) Rect;
// Rendering
pub fn draw(self: *Terminal, render_fn: fn(area: Rect, buf: *Buffer) void) !void; 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 clear(self: *Terminal) !void;
pub fn flush(self: *Terminal) !void; pub fn flush(self: *Terminal) !void;
// Cursor
pub fn hideCursor(self: *Terminal) !void; pub fn hideCursor(self: *Terminal) !void;
pub fn showCursor(self: *Terminal) !void; pub fn showCursor(self: *Terminal) !void;
pub fn setCursorPosition(self: *Terminal, x: u16, y: u16) !void; pub fn setCursorPosition(self: *Terminal, x: u16, y: u16) !void;
pub fn enterAlternateScreen(self: *Terminal) !void; // Events (crossterm-style)
pub fn leaveAlternateScreen(self: *Terminal) !void; pub fn pollEvent(self: *Terminal, timeout_ms: ?u32) !?Event;
pub fn readEvent(self: *Terminal) !Event;
pub fn enableRawMode(self: *Terminal) !void; // Mouse
pub fn disableRawMode(self: *Terminal) !void; 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 ```zig
pub fn main() !void { pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@ -578,25 +604,225 @@ pub fn main() !void {
var term = try Terminal.init(allocator); var term = try Terminal.init(allocator);
defer term.deinit(); defer term.deinit();
try term.enterAlternateScreen(); // Enable mouse (optional)
defer term.leaveAlternateScreen() catch {}; try term.enableMouseCapture();
try term.hideCursor(); var running = true;
defer term.showCursor() catch {}; while (running) {
try term.draw(render);
try term.draw(struct { // Poll for events with 100ms timeout
pub fn render(area: Rect, buf: *Buffer) void { if (try term.pollEvent(100)) |event| {
const block = Block.bordered().title("Hello zcatui!"); switch (event) {
block.render(area, buf); .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 ## Widgets Quick Reference
| Widget | Constructor | Stateful | Key Methods | | Widget | Constructor | Stateful | Key Methods |

200
examples/events_demo.zig Normal file
View file

@ -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",
};
}

268
src/cursor.zig Normal file
View file

@ -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);
}

393
src/event.zig Normal file
View file

@ -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,
}
}

616
src/event/parse.zig Normal file
View file

@ -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 [ <params> <intermediate> <final>
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);
}

133
src/event/reader.zig Normal file
View file

@ -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());
}

View file

@ -134,6 +134,28 @@ pub const widgets = struct {
// Backend // Backend
pub const backend = @import("backend/backend.zig"); 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 // Tests
// ============================================================================ // ============================================================================
@ -143,3 +165,11 @@ test "zcatui module compiles" {
_ = style; _ = style;
_ = buffer; _ = buffer;
} }
test {
// Include all module tests
_ = @import("event.zig");
_ = @import("event/reader.zig");
_ = @import("event/parse.zig");
_ = @import("cursor.zig");
}

View file

@ -1,7 +1,7 @@
//! Terminal abstraction for zcatui. //! Terminal abstraction for zcatui.
//! //!
//! The Terminal struct provides the main entry point for TUI applications. //! 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 //! ## Example
//! //!
@ -9,7 +9,22 @@
//! var term = try Terminal.init(allocator); //! var term = try Terminal.init(allocator);
//! defer term.deinit(); //! defer term.deinit();
//! //!
//! // Optional: enable mouse capture
//! try term.enableMouseCapture();
//! defer term.disableMouseCapture() catch {};
//!
//! while (true) {
//! try term.draw(renderFn); //! 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"); const std = @import("std");
@ -18,13 +33,24 @@ const Buffer = buffer.Buffer;
const Rect = buffer.Rect; const Rect = buffer.Rect;
const backend_mod = @import("backend/backend.zig"); const backend_mod = @import("backend/backend.zig");
const AnsiBackend = backend_mod.AnsiBackend; 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. /// 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 { pub const Terminal = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
backend: AnsiBackend, backend: AnsiBackend,
current_buffer: Buffer, current_buffer: Buffer,
previous_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. /// Initializes the terminal for TUI mode.
/// ///
@ -54,13 +80,26 @@ pub const Terminal = struct {
.backend = backend, .backend = backend,
.current_buffer = current_buffer, .current_buffer = current_buffer,
.previous_buffer = previous_buffer, .previous_buffer = previous_buffer,
.event_reader = EventReader.init(),
}; };
} }
/// Cleans up terminal state. /// Cleans up terminal state.
/// ///
/// Shows cursor, exits alternate screen, and restores terminal mode. /// Shows cursor, exits alternate screen, and restores terminal mode.
/// Also disables any enabled features (mouse, focus, paste).
pub fn deinit(self: *Terminal) void { 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.disableRawMode() catch {};
self.backend.showCursor() catch {}; self.backend.showCursor() catch {};
self.backend.leaveAlternateScreen() catch {}; self.backend.leaveAlternateScreen() catch {};
@ -146,6 +185,143 @@ pub const Terminal = struct {
self.previous_buffer.clear(); self.previous_buffer.clear();
self.current_buffer.markDirty(); 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 // Helper functions for comparison