feat: Add form widgets, status bar, toast system, and documentation

Form widgets:
- Checkbox and CheckboxGroup for boolean inputs
- RadioGroup for single-selection options
- Select dropdown with keyboard navigation
- Slider and RangeSlider for numeric inputs
- TextArea for multi-line text input

UI utilities:
- StatusBar for bottom-of-screen information
- Toast and ToastManager for notifications

Examples:
- form_demo.zig: Interactive form widgets showcase
- panel_demo.zig: Docking panel system demo

Documentation:
- Complete README.md with Quick Start, widget examples, and API reference

🤖 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 18:02:06 +01:00
parent d3e42a241d
commit 79c0bb1a58
11 changed files with 3651 additions and 49 deletions

324
README.md Normal file
View file

@ -0,0 +1,324 @@
# zcatui
A Terminal User Interface (TUI) library for Zig, inspired by [ratatui](https://github.com/ratatui/ratatui).
> **zcatui** = "zcat" + "ui" (a nod to ratatui and Zig's mascot)
## Features
### Core
- **Immediate mode rendering** with double buffering and diff-based updates
- **Flexible layout system** with constraints (Length, Min, Max, Percentage, Ratio)
- **Rich styling** with 16/256/RGB colors and modifiers (bold, italic, underline, etc.)
- **Full event handling** for keyboard and mouse input
- **Cross-terminal compatibility** via ANSI escape sequences
### Widgets (30+)
| Category | Widgets |
|----------|---------|
| **Basic** | Block, Paragraph, List, Table, Tabs |
| **Data** | Gauge, LineGauge, Sparkline, BarChart, Chart, Canvas |
| **Input** | Input (text field), TextArea, Checkbox, RadioGroup, Select, Slider |
| **Navigation** | Menu, MenuBar, ContextMenu, Tree, FilePicker |
| **Overlays** | Popup, Modal, Tooltip, Toast |
| **Layout** | Panel, PanelSplit, TabbedPanel, DockingPanel, ScrollView, VirtualList |
| **Utilities** | Scrollbar, Calendar, StatusBar, Clear |
### Terminal Extensions
- **Clipboard** (OSC 52) - Read/write system clipboard
- **Hyperlinks** (OSC 8) - Clickable links in terminal
- **Notifications** (OSC 9/777) - Desktop notifications
- **Images** (Kitty/iTerm2) - Display images in terminal
- **Cursor control** - Style, visibility, position
### Advanced Features
- **Animation system** with easing functions
- **Lazy rendering** with caching and throttling
- **Virtual scrolling** for large datasets
- **LEGO panel system** for complex layouts
## Quick Start
```zig
const std = @import("std");
const zcatui = @import("zcatui");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Initialize terminal
var term = try zcatui.Terminal.init(allocator);
defer term.deinit();
// Main loop
while (true) {
try term.draw(render);
if (try term.pollEvent(100)) |event| {
if (event == .key) {
if (event.key.code == .char and event.key.code.char == 'q') {
break;
}
}
}
}
}
fn render(area: zcatui.Rect, buf: *zcatui.Buffer) void {
const block = zcatui.widgets.Block.init()
.title(" Hello zcatui! ")
.setBorders(zcatui.widgets.Borders.all)
.style(zcatui.Style.default.fg(zcatui.Color.cyan));
block.render(area, buf);
}
```
## Installation
Add zcatui to your `build.zig.zon`:
```zig
.dependencies = .{
.zcatui = .{
.url = "https://git.reugenio.com/reugenio/zcatui/archive/main.tar.gz",
// Add hash after first build attempt
},
},
```
Then in `build.zig`:
```zig
const zcatui = b.dependency("zcatui", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zcatui", zcatui.module("zcatui"));
```
## Examples
Run examples with:
```bash
zig build hello # Basic hello world
zig build events-demo # Keyboard/mouse events
zig build list-demo # List widget
zig build table-demo # Table widget
zig build dashboard # Dashboard with multiple widgets
zig build input-demo # Text input
zig build animation-demo # Animations
zig build menu-demo # Menus and modals
zig build form-demo # Form widgets
zig build panel-demo # Panel system
```
## Widget Examples
### Layout
```zig
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
// Split area vertically
const chunks = Layout.vertical(&.{
Constraint.length(3), // Fixed 3 rows
Constraint.percentage(50), // 50% of remaining
Constraint.min(5), // At least 5 rows
}).split(area);
// Split horizontally
const cols = Layout.horizontal(&.{
Constraint.ratio(1, 3), // 1/3 of width
Constraint.ratio(2, 3), // 2/3 of width
}).split(area);
```
### Block with Borders
```zig
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const block = Block.init()
.title(" My Panel ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan));
block.render(area, buf);
const inner = block.inner(area); // Get content area
```
### List
```zig
const List = zcatui.widgets.List;
const ListItem = zcatui.widgets.ListItem;
const items = &[_]ListItem{
ListItem.init("Item 1"),
ListItem.init("Item 2").style(Style.default.fg(Color.green)),
ListItem.init("Item 3"),
};
var list = List.init(items)
.block(Block.init().title("List").setBorders(Borders.all))
.highlightStyle(Style.default.bg(Color.blue));
list.renderStateful(area, buf, &list_state);
```
### Form Widgets
```zig
// Checkbox
const checkbox = Checkbox.init("Enable feature")
.setChecked(true)
.setFocused(is_focused);
checkbox.render(area, buf);
// Radio buttons
var radio = RadioGroup.init(&.{"Option A", "Option B", "Option C"})
.setSelected(1);
radio.render(area, buf);
// Dropdown select
var select = Select.init(&.{"Small", "Medium", "Large"})
.setPlaceholder("Choose size...");
select.render(area, buf);
// Slider
const slider = Slider.init(0, 100)
.setValue(50)
.setLabel("Volume");
slider.render(area, buf);
```
### Charts
```zig
// Bar chart
const BarChart = zcatui.widgets.BarChart;
const chart = BarChart.init()
.data(&.{
.{ .label = "A", .value = 10 },
.{ .label = "B", .value = 20 },
.{ .label = "C", .value = 15 },
})
.barWidth(5);
chart.render(area, buf);
// Sparkline
const Sparkline = zcatui.widgets.Sparkline;
const sparkline = Sparkline.init(&.{1, 4, 2, 8, 5, 3, 9, 2});
sparkline.render(area, buf);
```
### Popups and Modals
```zig
const Modal = zcatui.widgets.Modal;
const confirmDialog = zcatui.widgets.confirmDialog;
// Quick confirm dialog
const modal = confirmDialog("Confirm", &.{"Are you sure?"});
modal.render(area, buf);
// Handle button press
if (modal.getFocusedButton() == 0) {
// OK pressed
}
```
### Toast Notifications
```zig
const ToastManager = zcatui.widgets.ToastManager;
var toasts = ToastManager.init();
// Show notifications
toasts.info("Information message");
toasts.success("Operation completed!");
toasts.warning("Warning!");
toasts.showError("Error occurred");
// In render loop
toasts.update();
toasts.render(area, buf);
```
## Terminal Extensions
### Clipboard
```zig
const Clipboard = zcatui.Clipboard;
// Write to clipboard
try Clipboard.write(writer, "Hello clipboard!");
// Read (async - response comes via terminal)
try Clipboard.requestRead(writer);
```
### Hyperlinks
```zig
const Hyperlink = zcatui.Hyperlink;
const link = Hyperlink.init("https://example.com", "Click here");
try link.write(writer);
```
### Notifications
```zig
const notification = zcatui.notification;
try notification.notify(writer, "Build complete!");
try notification.notifyWithTitle(writer, "zcatui", "Task finished");
```
### Images
```zig
const image = zcatui.image;
// Display image (Kitty protocol)
try image.Kitty.displayFile(writer, "/path/to/image.png", .{
.width = 40,
.height = 20,
});
```
## Architecture
```
┌─────────────┐ ┌────────┐ ┌──────────┐
│ Application │───▶│ Buffer │───▶│ Terminal │
│ (widgets) │ │ (diff) │ │ (output) │
└─────────────┘ └────────┘ └──────────┘
```
1. Application renders widgets to a Buffer
2. Buffer is compared (diff) with previous frame
3. Only changes are sent to terminal (efficient)
## Requirements
- Zig 0.15.x
- POSIX terminal (Linux, macOS) or Windows Terminal
- Terminal with ANSI escape sequence support
## License
MIT
## Credits
- Inspired by [ratatui](https://github.com/ratatui/ratatui) (Rust)
- Built with Zig

View file

@ -194,4 +194,42 @@ pub fn build(b: *std.Build) void {
run_menu_demo.step.dependOn(b.getInstallStep()); run_menu_demo.step.dependOn(b.getInstallStep());
const menu_demo_step = b.step("menu-demo", "Run menu demo"); const menu_demo_step = b.step("menu-demo", "Run menu demo");
menu_demo_step.dependOn(&run_menu_demo.step); menu_demo_step.dependOn(&run_menu_demo.step);
// Ejemplo: form_demo
const form_demo_exe = b.addExecutable(.{
.name = "form-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/form_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(form_demo_exe);
const run_form_demo = b.addRunArtifact(form_demo_exe);
run_form_demo.step.dependOn(b.getInstallStep());
const form_demo_step = b.step("form-demo", "Run form demo");
form_demo_step.dependOn(&run_form_demo.step);
// Ejemplo: panel_demo
const panel_demo_exe = b.addExecutable(.{
.name = "panel-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/panel_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(panel_demo_exe);
const run_panel_demo = b.addRunArtifact(panel_demo_exe);
run_panel_demo.step.dependOn(b.getInstallStep());
const panel_demo_step = b.step("panel-demo", "Run panel demo");
panel_demo_step.dependOn(&run_panel_demo.step);
} }

295
examples/form_demo.zig Normal file
View file

@ -0,0 +1,295 @@
//! Form widgets demo for zcatui.
//!
//! Demonstrates:
//! - Checkbox, RadioGroup
//! - Select dropdown
//! - Slider
//! - TextArea
//! - StatusBar with toast notifications
//!
//! Run with: zig build form-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 Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Checkbox = zcatui.widgets.Checkbox;
const RadioGroup = zcatui.widgets.RadioGroup;
const Select = zcatui.widgets.Select;
const Slider = zcatui.widgets.Slider;
const StatusBar = zcatui.widgets.StatusBar;
const Toast = zcatui.widgets.Toast;
const ToastManager = zcatui.widgets.ToastManager;
const FormField = enum {
checkbox1,
checkbox2,
radio,
select,
slider,
};
const AppState = struct {
running: bool = true,
current_field: FormField = .checkbox1,
// Form values
checkbox1: bool = false,
checkbox2: bool = true,
radio_selected: usize = 0,
select: Select,
slider_value: f64 = 50,
// UI state
toast_manager: ToastManager = ToastManager.init(),
status: []const u8 = "Use Tab to navigate, Space/Enter to interact",
fn init() AppState {
return .{
.select = Select.init(&.{
"Option 1 - Basic",
"Option 2 - Standard",
"Option 3 - Premium",
"Option 4 - Enterprise",
}),
};
}
fn getCurrentFieldName(self: AppState) []const u8 {
return switch (self.current_field) {
.checkbox1 => "Dark Mode",
.checkbox2 => "Notifications",
.radio => "Theme",
.select => "Plan",
.slider => "Volume",
};
}
fn nextField(self: *AppState) void {
self.current_field = switch (self.current_field) {
.checkbox1 => .checkbox2,
.checkbox2 => .radio,
.radio => .select,
.select => .slider,
.slider => .checkbox1,
};
self.status = self.getCurrentFieldName();
}
fn prevField(self: *AppState) void {
self.current_field = switch (self.current_field) {
.checkbox1 => .slider,
.checkbox2 => .checkbox1,
.radio => .checkbox2,
.select => .radio,
.slider => .select,
};
self.status = self.getCurrentFieldName();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState.init();
while (state.running) {
// Update toasts
state.toast_manager.update();
try term.drawWithContext(&state, render);
if (try term.pollEvent(50)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.tab => state.nextField(),
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
' ' => handleSpace(state),
else => {},
}
},
.enter => handleEnter(state),
.up => handleUp(state),
.down => handleDown(state),
.left => handleLeft(state),
.right => handleRight(state),
else => {},
}
},
else => {},
}
}
fn handleSpace(state: *AppState) void {
switch (state.current_field) {
.checkbox1 => {
state.checkbox1 = !state.checkbox1;
if (state.checkbox1) {
state.toast_manager.info("Dark mode enabled");
} else {
state.toast_manager.info("Dark mode disabled");
}
},
.checkbox2 => {
state.checkbox2 = !state.checkbox2;
},
.select => {
state.select.toggle();
},
else => {},
}
}
fn handleEnter(state: *AppState) void {
switch (state.current_field) {
.select => {
if (state.select.open) {
state.select.confirm();
state.toast_manager.success("Plan selected!");
} else {
state.select.toggle();
}
},
.radio => {
state.toast_manager.info("Theme applied");
},
else => handleSpace(state),
}
}
fn handleUp(state: *AppState) void {
switch (state.current_field) {
.radio => {
if (state.radio_selected > 0) {
state.radio_selected -= 1;
}
},
.select => state.select.highlightPrev(),
.slider => state.slider_value = @min(state.slider_value + 5, 100),
else => state.prevField(),
}
}
fn handleDown(state: *AppState) void {
switch (state.current_field) {
.radio => {
if (state.radio_selected < 2) {
state.radio_selected += 1;
}
},
.select => state.select.highlightNext(),
.slider => state.slider_value = @max(state.slider_value - 5, 0),
else => state.nextField(),
}
}
fn handleLeft(state: *AppState) void {
if (state.current_field == .slider) {
state.slider_value = @max(state.slider_value - 1, 0);
}
}
fn handleRight(state: *AppState) void {
if (state.current_field == .slider) {
state.slider_value = @min(state.slider_value + 1, 100);
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Main layout
const chunks = Layout.vertical(&.{
Constraint.min(0), // Content
Constraint.length(1), // Status bar
}).split(area);
// Content area
const content = chunks.get(0);
const block = Block.init()
.title(" Form Demo - Tab to navigate, Space/Enter to interact ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan));
block.render(content, buf);
const inner = block.inner(content);
// Form fields layout
const form_chunks = Layout.vertical(&.{
Constraint.length(2), // Checkbox 1
Constraint.length(2), // Checkbox 2
Constraint.length(5), // Radio
Constraint.length(4), // Select
Constraint.length(2), // Slider
}).split(inner);
// Checkbox 1 - Dark Mode
const cb1 = Checkbox.init("Enable Dark Mode")
.setChecked(state.checkbox1)
.setFocused(state.current_field == .checkbox1);
cb1.render(form_chunks.get(0), buf);
// Checkbox 2 - Notifications
const cb2 = Checkbox.init("Enable Notifications")
.setChecked(state.checkbox2)
.setFocused(state.current_field == .checkbox2);
cb2.render(form_chunks.get(1), buf);
// Radio - Theme
_ = buf.setString(form_chunks.get(2).x, form_chunks.get(2).y, "Theme:", Style.default);
var radio = RadioGroup.init(&.{ "Light", "Dark", "System" })
.setFocused(state.current_field == .radio);
radio.selected = state.radio_selected;
radio.focused = state.radio_selected;
radio.render(
Rect.init(form_chunks.get(2).x, form_chunks.get(2).y + 1, form_chunks.get(2).width, 3),
buf,
);
// Select - Plan
_ = buf.setString(form_chunks.get(3).x, form_chunks.get(3).y, "Plan:", Style.default);
state.select.focused = (state.current_field == .select);
state.select.render(
Rect.init(form_chunks.get(3).x, form_chunks.get(3).y + 1, 30, form_chunks.get(3).height - 1),
buf,
);
// Slider - Volume
const slider = Slider.init(0, 100)
.setValue(state.slider_value)
.setLabel("Volume")
.setFocused(state.current_field == .slider);
slider.render(form_chunks.get(4), buf);
// Status bar
const status = StatusBar.init()
.setLeft(state.status)
.setRight("Esc to exit");
status.render(chunks.get(1), buf);
// Toasts
state.toast_manager.render(area, buf);
}

299
examples/panel_demo.zig Normal file
View file

@ -0,0 +1,299 @@
//! Panel system demo for zcatui.
//!
//! Demonstrates:
//! - Panel with borders and focus
//! - TabbedPanel
//! - DockingPanel (dockable/floating)
//! - PanelManager
//!
//! Run with: zig build panel-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 Paragraph = zcatui.widgets.Paragraph;
const Panel = zcatui.widgets.Panel;
const TabbedPanel = zcatui.widgets.TabbedPanel;
const DockingPanel = zcatui.widgets.DockingPanel;
const DockPosition = zcatui.widgets.DockPosition;
const PanelManager = zcatui.widgets.PanelManager;
const StatusBar = zcatui.widgets.StatusBar;
const AppState = struct {
running: bool = true,
manager: PanelManager,
tabbed: TabbedPanel,
show_floating: bool = false,
floating_x: u16 = 20,
floating_y: u16 = 5,
fn init(allocator: std.mem.Allocator) !AppState {
var manager = PanelManager.init(allocator);
// Add sidebar (left docked)
_ = try manager.add(
DockingPanel.init("Sidebar")
.setPosition(.left)
.setDockSize(20),
);
// Add bottom panel
_ = try manager.add(
DockingPanel.init("Console")
.setPosition(.bottom)
.setDockSize(25),
);
// Focus first panel
manager.focus(0);
return .{
.manager = manager,
.tabbed = TabbedPanel.init(&.{ "Overview", "Details", "Settings" }),
};
}
fn deinit(self: *AppState) void {
self.manager.deinit();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = try AppState.init(allocator);
defer state.deinit();
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.tab => state.manager.focusNext(),
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
'f', 'F' => state.show_floating = !state.show_floating,
'1' => state.tabbed.select(0),
'2' => state.tabbed.select(1),
'3' => state.tabbed.select(2),
'h', 'H' => {
if (state.manager.get(0)) |p| {
p.visible = !p.visible;
}
},
'j', 'J' => {
if (state.manager.get(1)) |p| {
p.visible = !p.visible;
}
},
else => {},
}
},
.left => {
if (state.show_floating) {
state.floating_x -|= 1;
} else {
state.tabbed.selectPrev();
}
},
.right => {
if (state.show_floating) {
state.floating_x += 1;
} else {
state.tabbed.selectNext();
}
},
.up => {
if (state.show_floating) {
state.floating_y -|= 1;
}
},
.down => {
if (state.show_floating) {
state.floating_y += 1;
}
},
else => {},
}
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Layout: main area and status bar
if (area.height < 3) return;
const content_area = Rect.init(area.x, area.y, area.width, area.height - 1);
const status_area = Rect.init(area.x, area.y + area.height - 1, area.width, 1);
// Render docked panels (modifies remaining area)
state.manager.render(content_area, buf);
// Calculate center area (after docked panels)
var center_area = content_area;
// Adjust for sidebar
if (state.manager.get(0)) |sidebar| {
if (sidebar.visible) {
const sidebar_width = content_area.width * sidebar.dock_size / 100;
center_area.x += sidebar_width;
center_area.width -|= sidebar_width;
// Render sidebar content
const sb_area = Rect.init(content_area.x, content_area.y, sidebar_width, content_area.height);
renderSidebar(sb_area, buf);
}
}
// Adjust for bottom panel
if (state.manager.get(1)) |bottom| {
if (bottom.visible) {
const bottom_height = content_area.height * bottom.dock_size / 100;
center_area.height -|= bottom_height;
// Render console content
const console_area = Rect.init(
center_area.x,
center_area.y + center_area.height,
center_area.width,
bottom_height,
);
renderConsole(console_area, buf);
}
}
// Render tabbed panel in center
state.tabbed.render(center_area, buf);
// Render tab content
const tab_content = Rect.init(
center_area.x + 1,
center_area.y + 2,
center_area.width -| 2,
center_area.height -| 4,
);
renderTabContent(state.tabbed.selected, tab_content, buf);
// Floating panel
if (state.show_floating) {
renderFloatingPanel(state, area, buf);
}
// Status bar
const status = StatusBar.init()
.setLeft("Tab: focus | H: sidebar | J: console | F: floating | 1-3: tabs")
.setRight("Esc: exit");
status.render(status_area, buf);
}
fn renderSidebar(area: Rect, buf: *Buffer) void {
const items = [_][]const u8{
" Files",
" Search",
" Git",
" Debug",
" Extensions",
};
var y = area.y + 2;
for (items) |item| {
if (y < area.y + area.height - 1) {
_ = buf.setString(area.x + 1, y, item, Style.default);
y += 1;
}
}
}
fn renderConsole(area: Rect, buf: *Buffer) void {
const lines = [_][]const u8{
"> Build started...",
"> Compiling src/main.zig",
"> Linking...",
"> Build completed successfully!",
};
var y = area.y + 1;
for (lines) |line| {
if (y < area.y + area.height - 1) {
_ = buf.setString(area.x + 1, y, line, Style.default.fg(Color.green));
y += 1;
}
}
}
fn renderTabContent(tab: usize, area: Rect, buf: *Buffer) void {
const content = switch (tab) {
0 => "Welcome to the Panel Demo!\n\nThis demonstrates the LEGO panel system.",
1 => "Details tab content.\n\nPanels can be:\n- Docked (left/right/top/bottom)\n- Floating\n- Tabbed",
2 => "Settings tab content.\n\nConfigure your preferences here.",
else => "",
};
var y = area.y;
var iter = std.mem.splitScalar(u8, content, '\n');
while (iter.next()) |line| {
if (y < area.y + area.height) {
_ = buf.setString(area.x, y, line, Style.default);
y += 1;
}
}
}
fn renderFloatingPanel(state: *AppState, bounds: Rect, buf: *Buffer) void {
const width: u16 = 30;
const height: u16 = 10;
const x = @min(state.floating_x, bounds.width -| width);
const y = @min(state.floating_y, bounds.height -| height);
const float_area = Rect.init(x, y, width, height);
// Clear background
var cy = float_area.y;
while (cy < float_area.y + float_area.height) : (cy += 1) {
var cx = float_area.x;
while (cx < float_area.x + float_area.width) : (cx += 1) {
if (buf.getCell(cx, cy)) |cell| {
cell.setChar(' ');
cell.setStyle(Style.default.bg(Color.indexed(236)));
}
}
}
// Border
const block = Block.init()
.title(" Floating Panel ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.yellow).bg(Color.indexed(236)));
block.render(float_area, buf);
// Content
const inner = block.inner(float_area);
_ = buf.setString(inner.x, inner.y, "This panel floats!", Style.default.bg(Color.indexed(236)));
_ = buf.setString(inner.x, inner.y + 2, "Arrow keys to move", Style.default.fg(Color.indexed(8)).bg(Color.indexed(236)));
_ = buf.setString(inner.x, inner.y + 3, "F to toggle", Style.default.fg(Color.indexed(8)).bg(Color.indexed(236)));
}

View file

@ -181,6 +181,32 @@ pub const widgets = struct {
pub const DockPosition = panel_mod.DockPosition; pub const DockPosition = panel_mod.DockPosition;
pub const PanelManager = panel_mod.PanelManager; pub const PanelManager = panel_mod.PanelManager;
pub const SplitDirection = panel_mod.SplitDirection; pub const SplitDirection = panel_mod.SplitDirection;
pub const checkbox_mod = @import("widgets/checkbox.zig");
pub const Checkbox = checkbox_mod.Checkbox;
pub const CheckboxSymbols = checkbox_mod.CheckboxSymbols;
pub const RadioGroup = checkbox_mod.RadioGroup;
pub const RadioSymbols = checkbox_mod.RadioSymbols;
pub const CheckboxGroup = checkbox_mod.CheckboxGroup;
pub const select_mod = @import("widgets/select.zig");
pub const Select = select_mod.Select;
pub const MultiSelect = select_mod.MultiSelect;
pub const slider_mod = @import("widgets/slider.zig");
pub const Slider = slider_mod.Slider;
pub const RangeSlider = slider_mod.RangeSlider;
pub const SliderSymbols = slider_mod.SliderSymbols;
pub const textarea_mod = @import("widgets/textarea.zig");
pub const TextArea = textarea_mod.TextArea;
pub const statusbar_mod = @import("widgets/statusbar.zig");
pub const StatusBar = statusbar_mod.StatusBar;
pub const StatusBarBuilder = statusbar_mod.StatusBarBuilder;
pub const Toast = statusbar_mod.Toast;
pub const ToastType = statusbar_mod.ToastType;
pub const ToastManager = statusbar_mod.ToastManager;
}; };
// Backend // Backend

558
src/widgets/checkbox.zig Normal file
View file

@ -0,0 +1,558 @@
//! Checkbox and RadioButton widgets for zcatui.
//!
//! Provides form controls for boolean and exclusive selection:
//! - Checkbox: Toggle on/off
//! - RadioGroup: Exclusive selection from options
//!
//! ## Example
//!
//! ```zig
//! // Single checkbox
//! var checkbox = Checkbox.init("Enable notifications")
//! .setChecked(true);
//! checkbox.render(area, buf);
//!
//! // Radio group
//! var radio = RadioGroup.init(&.{"Option A", "Option B", "Option C"})
//! .setSelected(0);
//! radio.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
// ============================================================================
// Checkbox Symbols
// ============================================================================
pub const CheckboxSymbols = struct {
checked: []const u8 = "[x]",
unchecked: []const u8 = "[ ]",
pub const unicode = CheckboxSymbols{
.checked = "",
.unchecked = "",
};
pub const filled = CheckboxSymbols{
.checked = "",
.unchecked = "",
};
pub const fancy = CheckboxSymbols{
.checked = "",
.unchecked = "",
};
};
// ============================================================================
// Checkbox
// ============================================================================
/// A checkbox widget for boolean input.
pub const Checkbox = struct {
/// Label text.
label: []const u8,
/// Whether checked.
checked: bool = false,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Base style.
style: Style = Style.default,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Checked style (for the checkmark).
checked_style: Style = Style.default.fg(Color.green),
/// Disabled style.
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Symbols to use.
symbols: CheckboxSymbols = .{},
/// Creates a new checkbox.
pub fn init(label: []const u8) Checkbox {
return .{
.label = label,
};
}
/// Sets checked state.
pub fn setChecked(self: Checkbox, checked: bool) Checkbox {
var cb = self;
cb.checked = checked;
return cb;
}
/// Sets focused state.
pub fn setFocused(self: Checkbox, focused: bool) Checkbox {
var cb = self;
cb.focused = focused;
return cb;
}
/// Sets disabled state.
pub fn setDisabled(self: Checkbox, disabled: bool) Checkbox {
var cb = self;
cb.disabled = disabled;
return cb;
}
/// Sets symbols.
pub fn setSymbols(self: Checkbox, symbols: CheckboxSymbols) Checkbox {
var cb = self;
cb.symbols = symbols;
return cb;
}
/// Toggles the checkbox.
pub fn toggle(self: *Checkbox) void {
if (!self.disabled) {
self.checked = !self.checked;
}
}
/// Renders the checkbox.
pub fn render(self: Checkbox, area: Rect, buf: *Buffer) void {
if (area.width < 4 or area.height == 0) return;
// Determine style
var box_style = self.style;
var label_style = self.style;
if (self.disabled) {
box_style = self.disabled_style;
label_style = self.disabled_style;
} else if (self.focused) {
box_style = self.focused_style;
label_style = self.focused_style;
}
if (self.checked and !self.disabled) {
box_style = self.checked_style;
}
// Render checkbox symbol
const symbol = if (self.checked) self.symbols.checked else self.symbols.unchecked;
var x = buf.setString(area.x, area.y, symbol, box_style);
// Space
x = buf.setString(x, area.y, " ", label_style);
// Render label
const max_label = area.width -| (x - area.x);
if (max_label > 0) {
const label_len = @min(self.label.len, max_label);
_ = buf.setString(x, area.y, self.label[0..label_len], label_style);
}
}
};
// ============================================================================
// RadioButton Symbols
// ============================================================================
pub const RadioSymbols = struct {
selected: []const u8 = "(o)",
unselected: []const u8 = "( )",
pub const unicode = RadioSymbols{
.selected = "",
.unselected = "",
};
pub const filled = RadioSymbols{
.selected = "",
.unselected = "",
};
};
// ============================================================================
// RadioGroup
// ============================================================================
/// A group of radio buttons for exclusive selection.
pub const RadioGroup = struct {
/// Option labels.
options: []const []const u8,
/// Currently selected index.
selected: usize = 0,
/// Focused option index.
focused: usize = 0,
/// Whether the group is focused.
is_focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Layout direction.
horizontal: bool = false,
/// Base style.
style: Style = Style.default,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Selected style.
selected_style: Style = Style.default.fg(Color.green),
/// Disabled style.
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Symbols.
symbols: RadioSymbols = .{},
/// Creates a new radio group.
pub fn init(options: []const []const u8) RadioGroup {
return .{
.options = options,
};
}
/// Sets selected index.
pub fn setSelected(self: RadioGroup, index: usize) RadioGroup {
var rg = self;
if (index < self.options.len) {
rg.selected = index;
}
return rg;
}
/// Sets focused state.
pub fn setFocused(self: RadioGroup, focused: bool) RadioGroup {
var rg = self;
rg.is_focused = focused;
return rg;
}
/// Sets horizontal layout.
pub fn setHorizontal(self: RadioGroup, horizontal: bool) RadioGroup {
var rg = self;
rg.horizontal = horizontal;
return rg;
}
/// Sets symbols.
pub fn setSymbols(self: RadioGroup, symbols: RadioSymbols) RadioGroup {
var rg = self;
rg.symbols = symbols;
return rg;
}
/// Selects next option.
pub fn selectNext(self: *RadioGroup) void {
if (!self.disabled and self.options.len > 0) {
self.focused = (self.focused + 1) % self.options.len;
}
}
/// Selects previous option.
pub fn selectPrev(self: *RadioGroup) void {
if (!self.disabled and self.options.len > 0) {
if (self.focused == 0) {
self.focused = self.options.len - 1;
} else {
self.focused -= 1;
}
}
}
/// Confirms current focus as selection.
pub fn confirm(self: *RadioGroup) void {
if (!self.disabled) {
self.selected = self.focused;
}
}
/// Gets selected option label.
pub fn getSelectedLabel(self: RadioGroup) ?[]const u8 {
if (self.selected < self.options.len) {
return self.options[self.selected];
}
return null;
}
/// Renders the radio group.
pub fn render(self: RadioGroup, area: Rect, buf: *Buffer) void {
if (area.width < 4 or area.height == 0) return;
if (self.horizontal) {
self.renderHorizontal(area, buf);
} else {
self.renderVertical(area, buf);
}
}
fn renderVertical(self: RadioGroup, area: Rect, buf: *Buffer) void {
var y = area.y;
for (self.options, 0..) |option, i| {
if (y >= area.y + area.height) break;
_ = self.renderOption(area.x, y, area.width, option, i, buf);
y += 1;
}
}
fn renderHorizontal(self: RadioGroup, area: Rect, buf: *Buffer) void {
var x = area.x;
for (self.options, 0..) |option, i| {
if (x >= area.x + area.width) break;
const option_width = self.renderOption(x, area.y, area.width -| (x - area.x), option, i, buf);
x += option_width + 2; // Gap between options
}
}
fn renderOption(self: RadioGroup, x: u16, y: u16, max_width: u16, label: []const u8, index: usize, buf: *Buffer) u16 {
if (max_width < 4) return 0;
const is_selected = index == self.selected;
const is_current = index == self.focused and self.is_focused;
// Determine style
var radio_style = self.style;
var label_style = self.style;
if (self.disabled) {
radio_style = self.disabled_style;
label_style = self.disabled_style;
} else {
if (is_selected) {
radio_style = self.selected_style;
}
if (is_current) {
label_style = self.focused_style;
if (!is_selected) {
radio_style = self.focused_style;
}
}
}
// Render radio symbol
const symbol = if (is_selected) self.symbols.selected else self.symbols.unselected;
var curr_x = buf.setString(x, y, symbol, radio_style);
// Space
curr_x = buf.setString(curr_x, y, " ", label_style);
// Label
const label_max = max_width -| (curr_x - x);
const label_len = @min(label.len, label_max);
if (label_len > 0) {
curr_x = buf.setString(curr_x, y, label[0..label_len], label_style);
}
return curr_x - x;
}
};
// ============================================================================
// CheckboxGroup
// ============================================================================
/// A group of checkboxes (multiple selection).
pub const CheckboxGroup = struct {
/// Option labels.
options: []const []const u8,
/// Selected states (bitmask for up to 64 options).
selected: u64 = 0,
/// Focused option index.
focused: usize = 0,
/// Whether the group is focused.
is_focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Layout direction.
horizontal: bool = false,
/// Styles.
style: Style = Style.default,
focused_style: Style = Style.default.fg(Color.cyan),
checked_style: Style = Style.default.fg(Color.green),
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Symbols.
symbols: CheckboxSymbols = .{},
/// Creates a new checkbox group.
pub fn init(options: []const []const u8) CheckboxGroup {
return .{
.options = options,
};
}
/// Checks if an option is selected.
pub fn isSelected(self: CheckboxGroup, index: usize) bool {
if (index >= 64) return false;
return (self.selected & (@as(u64, 1) << @intCast(index))) != 0;
}
/// Sets an option's selected state.
pub fn setOption(self: *CheckboxGroup, index: usize, selected: bool) void {
if (index >= 64) return;
const mask = @as(u64, 1) << @intCast(index);
if (selected) {
self.selected |= mask;
} else {
self.selected &= ~mask;
}
}
/// Toggles the focused option.
pub fn toggleFocused(self: *CheckboxGroup) void {
if (!self.disabled and self.focused < self.options.len) {
self.setOption(self.focused, !self.isSelected(self.focused));
}
}
/// Moves focus to next option.
pub fn focusNext(self: *CheckboxGroup) void {
if (self.options.len > 0) {
self.focused = (self.focused + 1) % self.options.len;
}
}
/// Moves focus to previous option.
pub fn focusPrev(self: *CheckboxGroup) void {
if (self.options.len > 0) {
if (self.focused == 0) {
self.focused = self.options.len - 1;
} else {
self.focused -= 1;
}
}
}
/// Gets all selected indices.
pub fn getSelected(self: CheckboxGroup, out: []usize) usize {
var count: usize = 0;
for (self.options, 0..) |_, i| {
if (self.isSelected(i) and count < out.len) {
out[count] = i;
count += 1;
}
}
return count;
}
/// Renders the checkbox group.
pub fn render(self: CheckboxGroup, area: Rect, buf: *Buffer) void {
if (area.width < 4 or area.height == 0) return;
var y = area.y;
for (self.options, 0..) |option, i| {
if (y >= area.y + area.height) break;
const is_checked = self.isSelected(i);
const is_current = i == self.focused and self.is_focused;
// Determine style
var box_style = self.style;
var label_style = self.style;
if (self.disabled) {
box_style = self.disabled_style;
label_style = self.disabled_style;
} else {
if (is_checked) {
box_style = self.checked_style;
}
if (is_current) {
label_style = self.focused_style;
}
}
// Symbol
const symbol = if (is_checked) self.symbols.checked else self.symbols.unchecked;
var x = buf.setString(area.x, y, symbol, box_style);
x = buf.setString(x, y, " ", label_style);
// Label
const max_label = area.width -| (x - area.x);
if (max_label > 0) {
const label_len = @min(option.len, max_label);
_ = buf.setString(x, y, option[0..label_len], label_style);
}
y += 1;
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "Checkbox toggle" {
var cb = Checkbox.init("Test");
try std.testing.expect(!cb.checked);
cb.toggle();
try std.testing.expect(cb.checked);
cb.toggle();
try std.testing.expect(!cb.checked);
}
test "Checkbox disabled" {
var cb = Checkbox.init("Test").setDisabled(true);
cb.toggle();
try std.testing.expect(!cb.checked); // Should not change
}
test "RadioGroup selection" {
var rg = RadioGroup.init(&.{ "A", "B", "C" });
try std.testing.expectEqual(@as(usize, 0), rg.selected);
rg.selectNext();
rg.confirm();
try std.testing.expectEqual(@as(usize, 1), rg.selected);
rg.selectNext();
rg.selectNext();
rg.confirm();
try std.testing.expectEqual(@as(usize, 0), rg.selected); // Wrapped
}
test "CheckboxGroup multiple selection" {
var cg = CheckboxGroup.init(&.{ "A", "B", "C", "D" });
cg.setOption(0, true);
cg.setOption(2, true);
try std.testing.expect(cg.isSelected(0));
try std.testing.expect(!cg.isSelected(1));
try std.testing.expect(cg.isSelected(2));
try std.testing.expect(!cg.isSelected(3));
var selected: [4]usize = undefined;
const count = cg.getSelected(&selected);
try std.testing.expectEqual(@as(usize, 2), count);
}

View file

@ -576,38 +576,44 @@ pub const DockingPanel = struct {
/// Manages a collection of panels. /// Manages a collection of panels.
pub const PanelManager = struct { pub const PanelManager = struct {
/// All panels. /// All panels (static array for simplicity).
panels: std.ArrayList(DockingPanel), panels: [16]?DockingPanel = [_]?DockingPanel{null} ** 16,
/// Count of panels.
count: usize = 0,
/// Currently focused panel index. /// Currently focused panel index.
focused: ?usize = null, focused: ?usize = null,
/// Allocator. /// Allocator (kept for compatibility).
allocator: Allocator, allocator: Allocator,
/// Creates a new panel manager. /// Creates a new panel manager.
pub fn init(allocator: Allocator) PanelManager { pub fn init(allocator: Allocator) PanelManager {
return .{ return .{
.panels = std.ArrayList(DockingPanel).init(allocator),
.allocator = allocator, .allocator = allocator,
}; };
} }
/// Frees resources. /// Frees resources.
pub fn deinit(self: *PanelManager) void { pub fn deinit(self: *PanelManager) void {
self.panels.deinit(); _ = self;
} }
/// Adds a panel. /// Adds a panel.
pub fn add(self: *PanelManager, panel: DockingPanel) !usize { pub fn add(self: *PanelManager, panel: DockingPanel) !usize {
try self.panels.append(panel); if (self.count >= 16) return error.TooManyPanels;
return self.panels.items.len - 1; self.panels[self.count] = panel;
self.count += 1;
return self.count - 1;
} }
/// Gets a panel by index. /// Gets a panel by index.
pub fn get(self: *PanelManager, index: usize) ?*DockingPanel { pub fn get(self: *PanelManager, index: usize) ?*DockingPanel {
if (index < self.panels.items.len) { if (index < self.count) {
return &self.panels.items[index]; if (self.panels[index]) |*p| {
return p;
}
} }
return null; return null;
} }
@ -616,24 +622,24 @@ pub const PanelManager = struct {
pub fn focus(self: *PanelManager, index: usize) void { pub fn focus(self: *PanelManager, index: usize) void {
// Unfocus previous // Unfocus previous
if (self.focused) |f| { if (self.focused) |f| {
if (f < self.panels.items.len) { if (self.get(f)) |p| {
self.panels.items[f].panel.focused = false; p.panel.focused = false;
} }
} }
// Focus new // Focus new
if (index < self.panels.items.len) { if (self.get(index)) |p| {
self.focused = index; self.focused = index;
self.panels.items[index].panel.focused = true; p.panel.focused = true;
} }
} }
/// Focuses next panel. /// Focuses next panel.
pub fn focusNext(self: *PanelManager) void { pub fn focusNext(self: *PanelManager) void {
if (self.panels.items.len == 0) return; if (self.count == 0) return;
const next = if (self.focused) |f| const next = if (self.focused) |f|
(f + 1) % self.panels.items.len (f + 1) % self.count
else else
0; 0;
@ -645,7 +651,8 @@ pub const PanelManager = struct {
// Render docked panels first (left, right, top, bottom) // Render docked panels first (left, right, top, bottom)
var remaining = bounds; var remaining = bounds;
for (self.panels.items) |*panel| { for (&self.panels) |*slot| {
if (slot.*) |*panel| {
if (!panel.visible) continue; if (!panel.visible) continue;
switch (panel.position) { switch (panel.position) {
@ -674,23 +681,28 @@ pub const PanelManager = struct {
else => {}, else => {},
} }
} }
}
// Render center panels // Render center panels
for (self.panels.items) |*panel| { for (&self.panels) |*slot| {
if (slot.*) |*panel| {
if (!panel.visible) continue; if (!panel.visible) continue;
if (panel.position == .center) { if (panel.position == .center) {
panel.panel.render(remaining, buf); panel.panel.render(remaining, buf);
} }
} }
}
// Render floating panels last (on top) // Render floating panels last (on top)
for (self.panels.items) |*panel| { for (&self.panels) |*slot| {
if (slot.*) |*panel| {
if (!panel.visible) continue; if (!panel.visible) continue;
if (panel.position == .floating) { if (panel.position == .floating) {
panel.render(bounds, buf); panel.render(bounds, buf);
} }
} }
} }
}
}; };
// ============================================================================ // ============================================================================

527
src/widgets/select.zig Normal file
View file

@ -0,0 +1,527 @@
//! Select/Dropdown widget for zcatui.
//!
//! Provides dropdown selection from a list of options.
//!
//! ## Example
//!
//! ```zig
//! var select = Select.init(&.{"Option 1", "Option 2", "Option 3"})
//! .setPlaceholder("Choose an option...")
//! .setSelected(0);
//!
//! // Toggle dropdown
//! if (event.key.code == .enter) {
//! select.toggle();
//! }
//!
//! select.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
// ============================================================================
// Select
// ============================================================================
/// A dropdown select widget.
pub const Select = struct {
/// Available options.
options: []const []const u8,
/// Currently selected index (null = none).
selected: ?usize = null,
/// Highlighted index when open.
highlighted: usize = 0,
/// Whether dropdown is open.
open: bool = false,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Placeholder text when nothing selected.
placeholder: []const u8 = "Select...",
/// Maximum visible items when open.
max_visible: u16 = 8,
/// Scroll offset when list is long.
scroll_offset: usize = 0,
/// Base style.
style: Style = Style.default,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Selected option style.
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Disabled style.
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Dropdown arrow.
arrow_down: []const u8 = "",
arrow_up: []const u8 = "",
/// Creates a new select.
pub fn init(options: []const []const u8) Select {
return .{
.options = options,
};
}
/// Sets placeholder text.
pub fn setPlaceholder(self: Select, placeholder: []const u8) Select {
var s = self;
s.placeholder = placeholder;
return s;
}
/// Sets selected index.
pub fn setSelected(self: Select, index: ?usize) Select {
var s = self;
if (index) |i| {
if (i < self.options.len) {
s.selected = i;
}
} else {
s.selected = null;
}
return s;
}
/// Sets focused state.
pub fn setFocused(self: Select, focused: bool) Select {
var s = self;
s.focused = focused;
return s;
}
/// Sets max visible items.
pub fn setMaxVisible(self: Select, max: u16) Select {
var s = self;
s.max_visible = max;
return s;
}
/// Opens the dropdown.
pub fn openDropdown(self: *Select) void {
if (!self.disabled and self.options.len > 0) {
self.open = true;
self.highlighted = self.selected orelse 0;
self.adjustScroll();
}
}
/// Closes the dropdown.
pub fn close(self: *Select) void {
self.open = false;
}
/// Toggles the dropdown.
pub fn toggle(self: *Select) void {
if (self.open) {
self.close();
} else {
self.openDropdown();
}
}
/// Moves highlight up.
pub fn highlightPrev(self: *Select) void {
if (!self.open or self.options.len == 0) return;
if (self.highlighted > 0) {
self.highlighted -= 1;
} else {
self.highlighted = self.options.len - 1;
}
self.adjustScroll();
}
/// Moves highlight down.
pub fn highlightNext(self: *Select) void {
if (!self.open or self.options.len == 0) return;
self.highlighted = (self.highlighted + 1) % self.options.len;
self.adjustScroll();
}
/// Confirms current highlight as selection.
pub fn confirm(self: *Select) void {
if (self.open and self.highlighted < self.options.len) {
self.selected = self.highlighted;
self.close();
}
}
/// Gets selected option text.
pub fn getSelectedText(self: Select) ?[]const u8 {
if (self.selected) |i| {
if (i < self.options.len) {
return self.options[i];
}
}
return null;
}
/// Adjusts scroll to keep highlighted visible.
fn adjustScroll(self: *Select) void {
if (self.highlighted < self.scroll_offset) {
self.scroll_offset = self.highlighted;
} else if (self.highlighted >= self.scroll_offset + self.max_visible) {
self.scroll_offset = self.highlighted - self.max_visible + 1;
}
}
/// Gets the dropdown area.
pub fn getDropdownArea(self: Select, trigger_area: Rect) Rect {
const visible_count: u16 = @intCast(@min(self.options.len, self.max_visible));
return Rect.init(
trigger_area.x,
trigger_area.y + 1,
trigger_area.width,
visible_count + 2, // +2 for border
);
}
/// Renders the select widget.
pub fn render(self: *Select, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
// Render trigger
self.renderTrigger(area, buf);
// Render dropdown if open
if (self.open) {
self.renderDropdown(area, buf);
}
}
fn renderTrigger(self: *Select, area: Rect, buf: *Buffer) void {
// Determine style
var trigger_style = self.style;
if (self.disabled) {
trigger_style = self.disabled_style;
} else if (self.focused) {
trigger_style = self.focused_style;
}
// Border
const block = Block.init()
.setBorders(Borders.all)
.style(trigger_style);
block.render(Rect.init(area.x, area.y, area.width, 3), buf);
// Text
const inner = block.inner(Rect.init(area.x, area.y, area.width, 3));
const text = self.getSelectedText() orelse self.placeholder;
const text_style = if (self.selected == null and !self.disabled)
Style.default.fg(Color.indexed(8))
else
trigger_style;
const max_text = inner.width -| 2; // Space for arrow
const text_len = @min(text.len, max_text);
_ = buf.setString(inner.x, inner.y, text[0..text_len], text_style);
// Arrow
const arrow = if (self.open) self.arrow_up else self.arrow_down;
_ = buf.setString(inner.x + inner.width - 1, inner.y, arrow, trigger_style);
}
fn renderDropdown(self: *Select, trigger_area: Rect, buf: *Buffer) void {
const dropdown_area = self.getDropdownArea(trigger_area);
// Background
const block = Block.init()
.setBorders(Borders.all)
.style(self.style);
block.render(dropdown_area, buf);
// Fill background
const inner = block.inner(dropdown_area);
var y = inner.y;
while (y < inner.y + inner.height) : (y += 1) {
var x = inner.x;
while (x < inner.x + inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setChar(' ');
cell.setStyle(self.style);
}
}
}
// Render visible options
const visible_count = @min(self.options.len - self.scroll_offset, self.max_visible);
y = inner.y;
for (0..visible_count) |i| {
const option_idx = self.scroll_offset + i;
if (option_idx >= self.options.len) break;
const option = self.options[option_idx];
const is_highlighted = option_idx == self.highlighted;
const is_selected = self.selected != null and option_idx == self.selected.?;
var option_style = self.style;
if (is_highlighted) {
option_style = self.selected_style;
// Fill line background
var x = inner.x;
while (x < inner.x + inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setStyle(option_style);
}
}
}
// Selected indicator
const prefix: []const u8 = if (is_selected) "" else " ";
const x = buf.setString(inner.x, y, prefix, option_style);
// Option text
const max_text = inner.width -| 2;
const text_len = @min(option.len, max_text);
_ = buf.setString(x, y, option[0..text_len], option_style);
y += 1;
}
// Scroll indicators
if (self.scroll_offset > 0) {
_ = buf.setString(inner.x + inner.width - 1, inner.y, "", self.style);
}
if (self.scroll_offset + visible_count < self.options.len) {
_ = buf.setString(inner.x + inner.width - 1, inner.y + inner.height - 1, "", self.style);
}
}
};
// ============================================================================
// MultiSelect
// ============================================================================
/// A multi-select dropdown widget.
pub const MultiSelect = struct {
/// Available options.
options: []const []const u8,
/// Selected indices (bitmask for up to 64 options).
selected: u64 = 0,
/// Highlighted index when open.
highlighted: usize = 0,
/// Whether dropdown is open.
open: bool = false,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Placeholder text.
placeholder: []const u8 = "Select...",
/// Max visible items.
max_visible: u16 = 8,
/// Scroll offset.
scroll_offset: usize = 0,
/// Styles.
style: Style = Style.default,
focused_style: Style = Style.default.fg(Color.cyan),
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Creates a new multi-select.
pub fn init(options: []const []const u8) MultiSelect {
return .{
.options = options,
};
}
/// Checks if option is selected.
pub fn isSelected(self: MultiSelect, index: usize) bool {
if (index >= 64) return false;
return (self.selected & (@as(u64, 1) << @intCast(index))) != 0;
}
/// Toggles option selection.
pub fn toggleOption(self: *MultiSelect, index: usize) void {
if (index >= 64 or self.disabled) return;
const mask = @as(u64, 1) << @intCast(index);
self.selected ^= mask;
}
/// Toggles highlighted option.
pub fn toggleHighlighted(self: *MultiSelect) void {
self.toggleOption(self.highlighted);
}
/// Opens dropdown.
pub fn openDropdown(self: *MultiSelect) void {
if (!self.disabled and self.options.len > 0) {
self.open = true;
}
}
/// Closes dropdown.
pub fn close(self: *MultiSelect) void {
self.open = false;
}
/// Toggles dropdown.
pub fn toggle(self: *MultiSelect) void {
if (self.open) {
self.close();
} else {
self.openDropdown();
}
}
/// Moves highlight up.
pub fn highlightPrev(self: *MultiSelect) void {
if (!self.open or self.options.len == 0) return;
if (self.highlighted > 0) {
self.highlighted -= 1;
} else {
self.highlighted = self.options.len - 1;
}
}
/// Moves highlight down.
pub fn highlightNext(self: *MultiSelect) void {
if (!self.open or self.options.len == 0) return;
self.highlighted = (self.highlighted + 1) % self.options.len;
}
/// Gets count of selected options.
pub fn getSelectedCount(self: MultiSelect) usize {
return @popCount(self.selected);
}
/// Renders the multi-select.
pub fn render(self: *MultiSelect, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
// Render trigger
var trigger_style = self.style;
if (self.focused) trigger_style = self.focused_style;
const block = Block.init()
.setBorders(Borders.all)
.style(trigger_style);
block.render(Rect.init(area.x, area.y, area.width, 3), buf);
const inner = block.inner(Rect.init(area.x, area.y, area.width, 3));
// Display text
const count = self.getSelectedCount();
var text_buf: [64]u8 = undefined;
const text = if (count == 0)
self.placeholder
else blk: {
const result = std.fmt.bufPrint(&text_buf, "{d} selected", .{count}) catch self.placeholder;
break :blk result;
};
_ = buf.setString(inner.x, inner.y, text, trigger_style);
_ = buf.setString(inner.x + inner.width - 1, inner.y, if (self.open) "" else "", trigger_style);
// Dropdown
if (self.open) {
const dropdown_y = area.y + 3;
const visible: u16 = @intCast(@min(self.options.len, self.max_visible));
const dropdown_area = Rect.init(area.x, dropdown_y, area.width, visible + 2);
const dd_block = Block.init().setBorders(Borders.all).style(self.style);
dd_block.render(dropdown_area, buf);
const dd_inner = dd_block.inner(dropdown_area);
var y = dd_inner.y;
for (self.options, 0..) |option, i| {
if (y >= dd_inner.y + dd_inner.height) break;
const is_sel = self.isSelected(i);
const is_hl = i == self.highlighted;
var opt_style = self.style;
if (is_hl) {
opt_style = self.selected_style;
var x = dd_inner.x;
while (x < dd_inner.x + dd_inner.width) : (x += 1) {
if (buf.getCell(x, y)) |cell| {
cell.setStyle(opt_style);
}
}
}
const check: []const u8 = if (is_sel) "[x] " else "[ ] ";
const x = buf.setString(dd_inner.x, y, check, opt_style);
const max_text = dd_inner.width -| 4;
const text_len = @min(option.len, max_text);
_ = buf.setString(x, y, option[0..text_len], opt_style);
y += 1;
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "Select basic" {
var sel = Select.init(&.{ "A", "B", "C" });
try std.testing.expectEqual(@as(?usize, null), sel.selected);
try std.testing.expect(!sel.open);
sel.openDropdown();
try std.testing.expect(sel.open);
sel.highlightNext();
sel.confirm();
try std.testing.expectEqual(@as(?usize, 1), sel.selected);
try std.testing.expect(!sel.open);
}
test "Select getSelectedText" {
var sel = Select.init(&.{ "Apple", "Banana", "Cherry" }).setSelected(1);
try std.testing.expectEqualStrings("Banana", sel.getSelectedText().?);
}
test "MultiSelect" {
var ms = MultiSelect.init(&.{ "A", "B", "C", "D" });
try std.testing.expectEqual(@as(usize, 0), ms.getSelectedCount());
ms.toggleOption(0);
ms.toggleOption(2);
try std.testing.expect(ms.isSelected(0));
try std.testing.expect(!ms.isSelected(1));
try std.testing.expect(ms.isSelected(2));
try std.testing.expectEqual(@as(usize, 2), ms.getSelectedCount());
}

470
src/widgets/slider.zig Normal file
View file

@ -0,0 +1,470 @@
//! Slider widget for zcatui.
//!
//! Provides numeric input through a draggable slider.
//!
//! ## Example
//!
//! ```zig
//! var slider = Slider.init(0, 100)
//! .setValue(50)
//! .setStep(5);
//!
//! // Handle input
//! switch (event.key.code) {
//! .left => slider.decrement(),
//! .right => slider.increment(),
//! }
//!
//! slider.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
// ============================================================================
// SliderSymbols
// ============================================================================
pub const SliderSymbols = struct {
track_left: []const u8 = "",
track_right: []const u8 = "",
track_filled: []const u8 = "",
thumb: []const u8 = "",
left_cap: []const u8 = "",
right_cap: []const u8 = "",
pub const block = SliderSymbols{
.track_left = "",
.track_right = "",
.track_filled = "",
.thumb = "",
.left_cap = "",
.right_cap = "",
};
pub const ascii = SliderSymbols{
.track_left = "-",
.track_right = "-",
.track_filled = "=",
.thumb = "O",
.left_cap = "[",
.right_cap = "]",
};
};
// ============================================================================
// Slider
// ============================================================================
/// A slider for numeric input.
pub const Slider = struct {
/// Minimum value.
min: f64,
/// Maximum value.
max: f64,
/// Current value.
value: f64,
/// Step size for increment/decrement.
step: f64 = 1.0,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Label (optional).
label: ?[]const u8 = null,
/// Show value text.
show_value: bool = true,
/// Value format (decimal places).
decimals: u8 = 0,
/// Base style.
style: Style = Style.default,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Filled track style.
filled_style: Style = Style.default.fg(Color.blue),
/// Thumb style.
thumb_style: Style = Style.default.fg(Color.white).bold(),
/// Disabled style.
disabled_style: Style = Style.default.fg(Color.indexed(8)),
/// Symbols.
symbols: SliderSymbols = .{},
/// Creates a new slider.
pub fn init(min: f64, max: f64) Slider {
return .{
.min = min,
.max = max,
.value = min,
};
}
/// Creates an integer slider.
pub fn initInt(min: i32, max: i32) Slider {
return .{
.min = @floatFromInt(min),
.max = @floatFromInt(max),
.value = @floatFromInt(min),
.step = 1.0,
.decimals = 0,
};
}
/// Sets the value.
pub fn setValue(self: Slider, value: f64) Slider {
var s = self;
s.value = std.math.clamp(value, self.min, self.max);
return s;
}
/// Sets the step size.
pub fn setStep(self: Slider, step: f64) Slider {
var s = self;
s.step = step;
return s;
}
/// Sets the label.
pub fn setLabel(self: Slider, label: []const u8) Slider {
var s = self;
s.label = label;
return s;
}
/// Sets decimal places.
pub fn setDecimals(self: Slider, decimals: u8) Slider {
var s = self;
s.decimals = decimals;
return s;
}
/// Sets symbols.
pub fn setSymbols(self: Slider, symbols: SliderSymbols) Slider {
var s = self;
s.symbols = symbols;
return s;
}
/// Sets focused state.
pub fn setFocused(self: Slider, focused: bool) Slider {
var s = self;
s.focused = focused;
return s;
}
/// Increments value by step.
pub fn increment(self: *Slider) void {
if (!self.disabled) {
self.value = @min(self.value + self.step, self.max);
}
}
/// Decrements value by step.
pub fn decrement(self: *Slider) void {
if (!self.disabled) {
self.value = @max(self.value - self.step, self.min);
}
}
/// Sets value from percentage (0.0 - 1.0).
pub fn setPercent(self: *Slider, percent: f64) void {
if (!self.disabled) {
const clamped = std.math.clamp(percent, 0.0, 1.0);
self.value = self.min + clamped * (self.max - self.min);
}
}
/// Gets current percentage (0.0 - 1.0).
pub fn getPercent(self: Slider) f64 {
if (self.max == self.min) return 0.0;
return (self.value - self.min) / (self.max - self.min);
}
/// Gets value as integer.
pub fn getInt(self: Slider) i64 {
return @intFromFloat(@round(self.value));
}
/// Renders the slider.
pub fn render(self: Slider, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
var x = area.x;
// Determine style
var base_style = self.style;
if (self.disabled) {
base_style = self.disabled_style;
} else if (self.focused) {
base_style = self.focused_style;
}
// Render label
if (self.label) |label| {
const label_len: u16 = @intCast(@min(label.len, area.width / 3));
x = buf.setString(x, area.y, label[0..label_len], base_style);
x = buf.setString(x, area.y, " ", base_style);
}
// Calculate track area
var value_width: u16 = 0;
if (self.show_value) {
// Reserve space for value text
value_width = 8; // " 100.00" max
}
const track_width = area.x + area.width -| x -| value_width;
if (track_width < 3) return;
// Calculate thumb position
const percent = self.getPercent();
const track_inner = track_width -| 2; // Exclude caps
const thumb_pos: u16 = @intFromFloat(@as(f64, @floatFromInt(track_inner)) * percent);
// Render left cap
if (self.symbols.left_cap.len > 0) {
x = buf.setString(x, area.y, self.symbols.left_cap, base_style);
}
// Render track
const filled_style = if (self.disabled) self.disabled_style else self.filled_style;
var i: u16 = 0;
while (i < track_inner) : (i += 1) {
const is_thumb = i == thumb_pos;
const is_filled = i < thumb_pos;
if (is_thumb) {
const ts = if (self.disabled) self.disabled_style else self.thumb_style;
x = buf.setString(x, area.y, self.symbols.thumb, ts);
} else if (is_filled) {
x = buf.setString(x, area.y, self.symbols.track_filled, filled_style);
} else {
x = buf.setString(x, area.y, self.symbols.track_right, base_style);
}
}
// Render right cap
if (self.symbols.right_cap.len > 0) {
x = buf.setString(x, area.y, self.symbols.right_cap, base_style);
}
// Render value
if (self.show_value) {
var value_buf: [32]u8 = undefined;
const value_str = switch (self.decimals) {
0 => std.fmt.bufPrint(&value_buf, " {d:.0}", .{self.value}) catch "",
1 => std.fmt.bufPrint(&value_buf, " {d:.1}", .{self.value}) catch "",
2 => std.fmt.bufPrint(&value_buf, " {d:.2}", .{self.value}) catch "",
else => std.fmt.bufPrint(&value_buf, " {d:.3}", .{self.value}) catch "",
};
_ = buf.setString(x, area.y, value_str, base_style);
}
}
};
// ============================================================================
// RangeSlider
// ============================================================================
/// A slider with two thumbs for range selection.
pub const RangeSlider = struct {
/// Minimum value.
min: f64,
/// Maximum value.
max: f64,
/// Low value.
low: f64,
/// High value.
high: f64,
/// Step size.
step: f64 = 1.0,
/// Which thumb is active (0 = low, 1 = high).
active_thumb: u1 = 0,
/// Whether focused.
focused: bool = false,
/// Whether disabled.
disabled: bool = false,
/// Styles.
style: Style = Style.default,
focused_style: Style = Style.default.fg(Color.cyan),
filled_style: Style = Style.default.fg(Color.blue),
thumb_style: Style = Style.default.fg(Color.white).bold(),
/// Symbols.
symbols: SliderSymbols = .{},
/// Creates a new range slider.
pub fn init(min: f64, max: f64) RangeSlider {
return .{
.min = min,
.max = max,
.low = min,
.high = max,
};
}
/// Sets the range.
pub fn setRange(self: RangeSlider, low: f64, high: f64) RangeSlider {
var rs = self;
rs.low = std.math.clamp(low, self.min, self.max);
rs.high = std.math.clamp(high, self.min, self.max);
if (rs.low > rs.high) {
const tmp = rs.low;
rs.low = rs.high;
rs.high = tmp;
}
return rs;
}
/// Switches active thumb.
pub fn switchThumb(self: *RangeSlider) void {
self.active_thumb = if (self.active_thumb == 0) 1 else 0;
}
/// Increments active thumb.
pub fn increment(self: *RangeSlider) void {
if (self.disabled) return;
if (self.active_thumb == 0) {
self.low = @min(self.low + self.step, self.high);
} else {
self.high = @min(self.high + self.step, self.max);
}
}
/// Decrements active thumb.
pub fn decrement(self: *RangeSlider) void {
if (self.disabled) return;
if (self.active_thumb == 0) {
self.low = @max(self.low - self.step, self.min);
} else {
self.high = @max(self.high - self.step, self.low);
}
}
/// Renders the range slider.
pub fn render(self: RangeSlider, area: Rect, buf: *Buffer) void {
if (area.width < 5 or area.height == 0) return;
const range = self.max - self.min;
if (range == 0) return;
const track_width = area.width -| 2;
const low_pos: u16 = @intFromFloat(@as(f64, @floatFromInt(track_width)) * (self.low - self.min) / range);
const high_pos: u16 = @intFromFloat(@as(f64, @floatFromInt(track_width)) * (self.high - self.min) / range);
var base_style = self.style;
if (self.focused) base_style = self.focused_style;
// Left cap
var x = buf.setString(area.x, area.y, self.symbols.left_cap, base_style);
// Track
var i: u16 = 0;
while (i < track_width) : (i += 1) {
const is_low_thumb = i == low_pos;
const is_high_thumb = i == high_pos;
const in_range = i >= low_pos and i <= high_pos;
if (is_low_thumb or is_high_thumb) {
const is_active = (is_low_thumb and self.active_thumb == 0) or
(is_high_thumb and self.active_thumb == 1);
var ts = self.thumb_style;
if (is_active and self.focused) {
ts = ts.bg(Color.cyan);
}
x = buf.setString(x, area.y, self.symbols.thumb, ts);
} else if (in_range) {
x = buf.setString(x, area.y, self.symbols.track_filled, self.filled_style);
} else {
x = buf.setString(x, area.y, self.symbols.track_right, base_style);
}
}
// Right cap
_ = buf.setString(x, area.y, self.symbols.right_cap, base_style);
}
};
// ============================================================================
// Tests
// ============================================================================
test "Slider basic" {
var slider = Slider.init(0, 100).setValue(50);
try std.testing.expectEqual(@as(f64, 50), slider.value);
try std.testing.expectEqual(@as(f64, 0.5), slider.getPercent());
slider.increment();
try std.testing.expectEqual(@as(f64, 51), slider.value);
slider.decrement();
slider.decrement();
try std.testing.expectEqual(@as(f64, 49), slider.value);
}
test "Slider clamp" {
var slider = Slider.init(0, 10).setValue(5);
slider.value = 15;
slider = slider.setValue(slider.value);
try std.testing.expectEqual(@as(f64, 10), slider.value);
slider.value = -5;
slider = slider.setValue(slider.value);
try std.testing.expectEqual(@as(f64, 0), slider.value);
}
test "Slider step" {
var slider = Slider.init(0, 100).setStep(10).setValue(50);
slider.increment();
try std.testing.expectEqual(@as(f64, 60), slider.value);
slider.increment();
slider.increment();
slider.increment();
slider.increment();
slider.increment();
try std.testing.expectEqual(@as(f64, 100), slider.value); // Clamped
}
test "RangeSlider" {
var range = RangeSlider.init(0, 100).setRange(25, 75);
try std.testing.expectEqual(@as(f64, 25), range.low);
try std.testing.expectEqual(@as(f64, 75), range.high);
range.increment();
try std.testing.expectEqual(@as(f64, 26), range.low);
range.switchThumb();
range.decrement();
try std.testing.expectEqual(@as(f64, 74), range.high);
}

535
src/widgets/statusbar.zig Normal file
View file

@ -0,0 +1,535 @@
//! StatusBar and Toast widgets for zcatui.
//!
//! Provides status feedback UI components:
//! - StatusBar: Bottom bar with status info
//! - Toast: Temporary notification messages
//!
//! ## Example
//!
//! ```zig
//! var statusbar = StatusBar.init()
//! .setLeft("Ready")
//! .setCenter("myfile.txt")
//! .setRight("Ln 1, Col 1");
//!
//! statusbar.render(area, buf);
//!
//! // Toast notification
//! var toast = Toast.init("File saved successfully!")
//! .setDuration(3000);
//! toast.show();
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
// ============================================================================
// StatusBar
// ============================================================================
/// A status bar widget (typically at bottom of screen).
pub const StatusBar = struct {
/// Left-aligned text.
left: []const u8 = "",
/// Center-aligned text.
center: []const u8 = "",
/// Right-aligned text.
right: []const u8 = "",
/// Style.
style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Separator between sections.
separator: []const u8 = "",
/// Mode indicator (e.g., "INSERT", "NORMAL").
mode: ?[]const u8 = null,
/// Mode style.
mode_style: Style = Style.default.bg(Color.green).fg(Color.black).bold(),
/// Progress indicator (0-100, null = hidden).
progress: ?u8 = null,
/// Progress style.
progress_style: Style = Style.default.bg(Color.cyan).fg(Color.black),
/// Creates a new status bar.
pub fn init() StatusBar {
return .{};
}
/// Sets left text.
pub fn setLeft(self: StatusBar, text: []const u8) StatusBar {
var sb = self;
sb.left = text;
return sb;
}
/// Sets center text.
pub fn setCenter(self: StatusBar, text: []const u8) StatusBar {
var sb = self;
sb.center = text;
return sb;
}
/// Sets right text.
pub fn setRight(self: StatusBar, text: []const u8) StatusBar {
var sb = self;
sb.right = text;
return sb;
}
/// Sets mode indicator.
pub fn setMode(self: StatusBar, mode: ?[]const u8) StatusBar {
var sb = self;
sb.mode = mode;
return sb;
}
/// Sets style.
pub fn setStyle(self: StatusBar, style: Style) StatusBar {
var sb = self;
sb.style = style;
return sb;
}
/// Sets progress (0-100 or null).
pub fn setProgress(self: StatusBar, progress: ?u8) StatusBar {
var sb = self;
sb.progress = if (progress) |p| @min(p, 100) else null;
return sb;
}
/// Renders the status bar.
pub fn render(self: StatusBar, area: Rect, buf: *Buffer) void {
if (area.width == 0 or area.height == 0) return;
// Fill background
var x = area.x;
while (x < area.x + area.width) : (x += 1) {
if (buf.getCell(x, area.y)) |cell| {
cell.setChar(' ');
cell.setStyle(self.style);
}
}
x = area.x;
// Mode indicator
if (self.mode) |mode| {
x = buf.setString(x, area.y, " ", self.mode_style);
x = buf.setString(x, area.y, mode, self.mode_style);
x = buf.setString(x, area.y, " ", self.mode_style);
x = buf.setString(x, area.y, " ", self.style);
}
// Left text
if (self.left.len > 0) {
const max_left = (area.width / 3) -| (x - area.x);
const left_len = @min(self.left.len, max_left);
x = buf.setString(x, area.y, self.left[0..left_len], self.style);
}
// Right text (render from right edge)
if (self.right.len > 0) {
const right_start = area.x + area.width -| @as(u16, @intCast(self.right.len)) -| 1;
if (right_start > x) {
_ = buf.setString(right_start, area.y, self.right, self.style);
}
}
// Center text
if (self.center.len > 0) {
const center_start = area.x + (area.width -| @as(u16, @intCast(self.center.len))) / 2;
if (center_start > x) {
_ = buf.setString(center_start, area.y, self.center, self.style);
}
}
// Progress bar (if set)
if (self.progress) |progress| {
const progress_width: u16 = 10;
const progress_x = area.x + area.width -| progress_width -| @as(u16, @intCast(self.right.len)) -| 2;
if (progress_x > x) {
const filled: u16 = @intCast((@as(u32, progress_width) * progress) / 100);
var px = progress_x;
var i: u16 = 0;
while (i < progress_width) : (i += 1) {
if (buf.getCell(px, area.y)) |cell| {
if (i < filled) {
cell.setChar('█');
cell.setStyle(self.progress_style);
} else {
cell.setChar('░');
cell.setStyle(self.style);
}
}
px += 1;
}
}
}
}
};
// ============================================================================
// StatusBarBuilder
// ============================================================================
/// Segment of status bar.
pub const StatusSegment = struct {
text: []const u8,
style: ?Style = null,
min_width: u16 = 0,
priority: u8 = 0, // Higher priority segments shown first when space limited
};
/// Builder for complex status bars.
pub const StatusBarBuilder = struct {
segments_left: [8]?StatusSegment = [_]?StatusSegment{null} ** 8,
segments_right: [8]?StatusSegment = [_]?StatusSegment{null} ** 8,
left_count: usize = 0,
right_count: usize = 0,
style: Style = Style.default.bg(Color.blue).fg(Color.white),
separator: []const u8 = "",
pub fn init() StatusBarBuilder {
return .{};
}
pub fn addLeft(self: *StatusBarBuilder, segment: StatusSegment) *StatusBarBuilder {
if (self.left_count < 8) {
self.segments_left[self.left_count] = segment;
self.left_count += 1;
}
return self;
}
pub fn addRight(self: *StatusBarBuilder, segment: StatusSegment) *StatusBarBuilder {
if (self.right_count < 8) {
self.segments_right[self.right_count] = segment;
self.right_count += 1;
}
return self;
}
pub fn render(self: StatusBarBuilder, area: Rect, buf: *Buffer) void {
if (area.width == 0 or area.height == 0) return;
// Fill background
var bx = area.x;
while (bx < area.x + area.width) : (bx += 1) {
if (buf.getCell(bx, area.y)) |cell| {
cell.setChar(' ');
cell.setStyle(self.style);
}
}
// Left segments
var x = area.x;
for (self.segments_left[0..self.left_count]) |maybe_seg| {
if (maybe_seg) |seg| {
const seg_style = seg.style orelse self.style;
x = buf.setString(x, area.y, seg.text, seg_style);
x = buf.setString(x, area.y, self.separator, self.style);
}
}
// Right segments (render from right)
var right_x = area.x + area.width;
var i: usize = self.right_count;
while (i > 0) {
i -= 1;
if (self.segments_right[i]) |seg| {
const seg_style = seg.style orelse self.style;
right_x -|= @intCast(seg.text.len);
_ = buf.setString(right_x, area.y, seg.text, seg_style);
if (i > 0) {
right_x -|= @intCast(self.separator.len);
_ = buf.setString(right_x, area.y, self.separator, self.style);
}
}
}
}
};
// ============================================================================
// Toast
// ============================================================================
/// Toast type.
pub const ToastType = enum {
info,
success,
warning,
error_toast,
};
/// A toast notification.
pub const Toast = struct {
/// Message text.
message: []const u8,
/// Toast type.
toast_type: ToastType = .info,
/// Duration in milliseconds (0 = manual dismiss).
duration_ms: u32 = 3000,
/// When shown (timestamp).
shown_at: i64 = 0,
/// Whether visible.
visible: bool = false,
/// Position (from bottom).
bottom_offset: u16 = 2,
/// Style (auto-determined by type if null).
custom_style: ?Style = null,
/// Creates a new toast.
pub fn init(message: []const u8) Toast {
return .{
.message = message,
};
}
/// Sets the type.
pub fn setType(self: Toast, toast_type: ToastType) Toast {
var t = self;
t.toast_type = toast_type;
return t;
}
/// Sets duration.
pub fn setDuration(self: Toast, duration_ms: u32) Toast {
var t = self;
t.duration_ms = duration_ms;
return t;
}
/// Shows the toast.
pub fn show(self: *Toast) void {
self.visible = true;
self.shown_at = std.time.milliTimestamp();
}
/// Hides the toast.
pub fn hide(self: *Toast) void {
self.visible = false;
}
/// Updates toast (call each frame).
pub fn update(self: *Toast) void {
if (!self.visible) return;
if (self.duration_ms == 0) return;
const elapsed = std.time.milliTimestamp() - self.shown_at;
if (elapsed >= self.duration_ms) {
self.visible = false;
}
}
/// Gets style based on type.
pub fn getStyle(self: Toast) Style {
if (self.custom_style) |s| return s;
return switch (self.toast_type) {
.info => Style.default.bg(Color.blue).fg(Color.white),
.success => Style.default.bg(Color.green).fg(Color.black),
.warning => Style.default.bg(Color.yellow).fg(Color.black),
.error_toast => Style.default.bg(Color.red).fg(Color.white),
};
}
/// Gets icon based on type.
pub fn getIcon(self: Toast) []const u8 {
return switch (self.toast_type) {
.info => " ",
.success => "",
.warning => "",
.error_toast => "",
};
}
/// Renders the toast.
pub fn render(self: Toast, bounds: Rect, buf: *Buffer) void {
if (!self.visible) return;
const toast_style = self.getStyle();
const icon = self.getIcon();
// Calculate size and position
const content_width: u16 = @intCast(@min(icon.len + self.message.len + 4, bounds.width - 4));
const toast_width = content_width + 2;
const toast_height: u16 = 3;
const x = bounds.x + (bounds.width - toast_width) / 2;
const y = bounds.y + bounds.height -| toast_height -| self.bottom_offset;
// Draw background
var ty = y;
while (ty < y + toast_height) : (ty += 1) {
var tx = x;
while (tx < x + toast_width) : (tx += 1) {
if (buf.getCell(tx, ty)) |cell| {
cell.setChar(' ');
cell.setStyle(toast_style);
}
}
}
// Draw border (simple)
if (buf.getCell(x, y)) |cell| cell.setChar('╭');
if (buf.getCell(x + toast_width - 1, y)) |cell| cell.setChar('╮');
if (buf.getCell(x, y + toast_height - 1)) |cell| cell.setChar('╰');
if (buf.getCell(x + toast_width - 1, y + toast_height - 1)) |cell| cell.setChar('╯');
var bx = x + 1;
while (bx < x + toast_width - 1) : (bx += 1) {
if (buf.getCell(bx, y)) |cell| cell.setChar('─');
if (buf.getCell(bx, y + toast_height - 1)) |cell| cell.setChar('─');
}
if (buf.getCell(x, y + 1)) |cell| cell.setChar('│');
if (buf.getCell(x + toast_width - 1, y + 1)) |cell| cell.setChar('│');
// Draw content
const cx = buf.setString(x + 2, y + 1, icon, toast_style);
_ = buf.setString(cx, y + 1, self.message, toast_style);
}
};
// ============================================================================
// ToastManager
// ============================================================================
/// Manages multiple toasts.
pub const ToastManager = struct {
/// Active toasts.
toasts: [8]?Toast = [_]?Toast{null} ** 8,
/// Count of active toasts.
count: usize = 0,
/// Creates a new toast manager.
pub fn init() ToastManager {
return .{};
}
/// Shows a toast.
pub fn show(self: *ToastManager, toast: Toast) void {
// Find empty slot
for (&self.toasts) |*slot| {
if (slot.* == null) {
slot.* = toast;
slot.*.?.show();
self.count += 1;
return;
}
}
}
/// Shows a simple info toast.
pub fn info(self: *ToastManager, message: []const u8) void {
self.show(Toast.init(message).setType(.info));
}
/// Shows a success toast.
pub fn success(self: *ToastManager, message: []const u8) void {
self.show(Toast.init(message).setType(.success));
}
/// Shows a warning toast.
pub fn warning(self: *ToastManager, message: []const u8) void {
self.show(Toast.init(message).setType(.warning));
}
/// Shows an error toast.
pub fn showError(self: *ToastManager, message: []const u8) void {
self.show(Toast.init(message).setType(.error_toast));
}
/// Updates all toasts.
pub fn update(self: *ToastManager) void {
for (&self.toasts) |*slot| {
if (slot.*) |*toast| {
toast.update();
if (!toast.visible) {
slot.* = null;
self.count -|= 1;
}
}
}
}
/// Renders all visible toasts.
pub fn render(self: *ToastManager, bounds: Rect, buf: *Buffer) void {
var offset: u16 = 2;
for (&self.toasts) |*slot| {
if (slot.*) |*toast| {
if (toast.visible) {
toast.bottom_offset = offset;
toast.render(bounds, buf);
offset += 4;
}
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "StatusBar basic" {
const sb = StatusBar.init()
.setLeft("Ready")
.setCenter("file.txt")
.setRight("Ln 1");
try std.testing.expectEqualStrings("Ready", sb.left);
try std.testing.expectEqualStrings("file.txt", sb.center);
try std.testing.expectEqualStrings("Ln 1", sb.right);
}
test "StatusBar with mode" {
const sb = StatusBar.init()
.setMode("INSERT")
.setLeft("-- INSERT --");
try std.testing.expectEqualStrings("INSERT", sb.mode.?);
}
test "Toast visibility" {
var toast = Toast.init("Test message").setDuration(100);
try std.testing.expect(!toast.visible);
toast.show();
try std.testing.expect(toast.visible);
// Wait for expiry
std.time.sleep(150 * std.time.ns_per_ms);
toast.update();
try std.testing.expect(!toast.visible);
}
test "ToastManager" {
var tm = ToastManager.init();
tm.info("Info message");
try std.testing.expectEqual(@as(usize, 1), tm.count);
tm.success("Success!");
try std.testing.expectEqual(@as(usize, 2), tm.count);
}

518
src/widgets/textarea.zig Normal file
View file

@ -0,0 +1,518 @@
//! TextArea widget for zcatui.
//!
//! Multi-line text input with scrolling support.
//!
//! ## Example
//!
//! ```zig
//! var textarea = try TextArea.init(allocator)
//! .setPlaceholder("Enter text...");
//!
//! // Handle input
//! switch (event.key.code) {
//! .char => |c| textarea.insertChar(c),
//! .enter => textarea.insertNewline(),
//! .backspace => textarea.deleteBack(),
//! }
//!
//! textarea.render(area, buf);
//! ```
const std = @import("std");
const Allocator = std.mem.Allocator;
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
// ============================================================================
// TextArea
// ============================================================================
/// A multi-line text input widget.
pub const TextArea = struct {
/// Lines of text.
lines: std.ArrayList(std.ArrayList(u8)),
/// Cursor position (line, column).
cursor_line: usize = 0,
cursor_col: usize = 0,
/// Scroll offset.
scroll_y: usize = 0,
scroll_x: usize = 0,
/// Whether focused.
focused: bool = false,
/// Whether read-only.
read_only: bool = false,
/// Placeholder text.
placeholder: []const u8 = "",
/// Maximum lines (0 = unlimited).
max_lines: usize = 0,
/// Maximum characters per line (0 = unlimited).
max_line_length: usize = 0,
/// Show line numbers.
show_line_numbers: bool = false,
/// Word wrap.
word_wrap: bool = false,
/// Tab width.
tab_width: u8 = 4,
/// Styles.
style: Style = Style.default,
focused_style: Style = Style.default.fg(Color.cyan),
cursor_style: Style = Style.default.bg(Color.white).fg(Color.black),
line_number_style: Style = Style.default.fg(Color.indexed(8)),
placeholder_style: Style = Style.default.fg(Color.indexed(8)),
selection_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Selection (if any).
selection_start: ?struct { line: usize, col: usize } = null,
selection_end: ?struct { line: usize, col: usize } = null,
/// Allocator.
allocator: Allocator,
/// Creates a new text area.
pub fn init(allocator: Allocator) !TextArea {
var ta = TextArea{
.lines = std.ArrayList(std.ArrayList(u8)).init(allocator),
.allocator = allocator,
};
// Start with one empty line
try ta.lines.append(std.ArrayList(u8).init(allocator));
return ta;
}
/// Frees resources.
pub fn deinit(self: *TextArea) void {
for (self.lines.items) |*line| {
line.deinit();
}
self.lines.deinit();
}
/// Sets placeholder text.
pub fn setPlaceholder(self: *TextArea, placeholder: []const u8) void {
self.placeholder = placeholder;
}
/// Sets focused state.
pub fn setFocused(self: *TextArea, focused: bool) void {
self.focused = focused;
}
/// Sets read-only state.
pub fn setReadOnly(self: *TextArea, read_only: bool) void {
self.read_only = read_only;
}
/// Gets total line count.
pub fn getLineCount(self: TextArea) usize {
return self.lines.items.len;
}
/// Gets text of a line.
pub fn getLine(self: TextArea, line_idx: usize) ?[]const u8 {
if (line_idx < self.lines.items.len) {
return self.lines.items[line_idx].items;
}
return null;
}
/// Gets all text as a single string.
pub fn getText(self: TextArea, allocator: Allocator) ![]u8 {
var total_len: usize = 0;
for (self.lines.items) |line| {
total_len += line.items.len + 1; // +1 for newline
}
if (total_len > 0) total_len -= 1; // No trailing newline
var result = try allocator.alloc(u8, total_len);
var pos: usize = 0;
for (self.lines.items, 0..) |line, i| {
@memcpy(result[pos..][0..line.items.len], line.items);
pos += line.items.len;
if (i < self.lines.items.len - 1) {
result[pos] = '\n';
pos += 1;
}
}
return result;
}
/// Sets text content.
pub fn setText(self: *TextArea, text: []const u8) !void {
// Clear existing
for (self.lines.items) |*line| {
line.deinit();
}
self.lines.clearRetainingCapacity();
// Parse lines
var iter = std.mem.splitScalar(u8, text, '\n');
while (iter.next()) |line_text| {
var line = std.ArrayList(u8).init(self.allocator);
try line.appendSlice(line_text);
try self.lines.append(line);
}
// Ensure at least one line
if (self.lines.items.len == 0) {
try self.lines.append(std.ArrayList(u8).init(self.allocator));
}
// Reset cursor
self.cursor_line = 0;
self.cursor_col = 0;
self.scroll_y = 0;
self.scroll_x = 0;
}
/// Clears all text.
pub fn clear(self: *TextArea) !void {
for (self.lines.items) |*line| {
line.deinit();
}
self.lines.clearRetainingCapacity();
try self.lines.append(std.ArrayList(u8).init(self.allocator));
self.cursor_line = 0;
self.cursor_col = 0;
}
/// Inserts a character at cursor.
pub fn insertChar(self: *TextArea, c: u8) !void {
if (self.read_only) return;
var line = &self.lines.items[self.cursor_line];
// Check max line length
if (self.max_line_length > 0 and line.items.len >= self.max_line_length) {
return;
}
// Handle tab
if (c == '\t') {
const spaces = self.tab_width - @as(u8, @intCast(self.cursor_col % self.tab_width));
var i: u8 = 0;
while (i < spaces) : (i += 1) {
try line.insert(self.cursor_col, ' ');
self.cursor_col += 1;
}
return;
}
try line.insert(self.cursor_col, c);
self.cursor_col += 1;
}
/// Inserts a newline at cursor.
pub fn insertNewline(self: *TextArea) !void {
if (self.read_only) return;
// Check max lines
if (self.max_lines > 0 and self.lines.items.len >= self.max_lines) {
return;
}
var current_line = &self.lines.items[self.cursor_line];
// Create new line with text after cursor
var new_line = std.ArrayList(u8).init(self.allocator);
if (self.cursor_col < current_line.items.len) {
try new_line.appendSlice(current_line.items[self.cursor_col..]);
current_line.shrinkRetainingCapacity(self.cursor_col);
}
// Insert new line
try self.lines.insert(self.cursor_line + 1, new_line);
// Move cursor
self.cursor_line += 1;
self.cursor_col = 0;
}
/// Deletes character before cursor (backspace).
pub fn deleteBack(self: *TextArea) void {
if (self.read_only) return;
if (self.cursor_col > 0) {
var line = &self.lines.items[self.cursor_line];
_ = line.orderedRemove(self.cursor_col - 1);
self.cursor_col -= 1;
} else if (self.cursor_line > 0) {
// Merge with previous line
const current_line = self.lines.orderedRemove(self.cursor_line);
self.cursor_line -= 1;
self.cursor_col = self.lines.items[self.cursor_line].items.len;
self.lines.items[self.cursor_line].appendSlice(current_line.items) catch {};
@constCast(&current_line).deinit();
}
}
/// Deletes character at cursor (delete).
pub fn deleteForward(self: *TextArea) void {
if (self.read_only) return;
var line = &self.lines.items[self.cursor_line];
if (self.cursor_col < line.items.len) {
_ = line.orderedRemove(self.cursor_col);
} else if (self.cursor_line < self.lines.items.len - 1) {
// Merge with next line
const next_line = self.lines.orderedRemove(self.cursor_line + 1);
line.appendSlice(next_line.items) catch {};
@constCast(&next_line).deinit();
}
}
/// Moves cursor up.
pub fn cursorUp(self: *TextArea) void {
if (self.cursor_line > 0) {
self.cursor_line -= 1;
self.clampCursorCol();
}
}
/// Moves cursor down.
pub fn cursorDown(self: *TextArea) void {
if (self.cursor_line < self.lines.items.len - 1) {
self.cursor_line += 1;
self.clampCursorCol();
}
}
/// Moves cursor left.
pub fn cursorLeft(self: *TextArea) void {
if (self.cursor_col > 0) {
self.cursor_col -= 1;
} else if (self.cursor_line > 0) {
self.cursor_line -= 1;
self.cursor_col = self.lines.items[self.cursor_line].items.len;
}
}
/// Moves cursor right.
pub fn cursorRight(self: *TextArea) void {
const line_len = self.lines.items[self.cursor_line].items.len;
if (self.cursor_col < line_len) {
self.cursor_col += 1;
} else if (self.cursor_line < self.lines.items.len - 1) {
self.cursor_line += 1;
self.cursor_col = 0;
}
}
/// Moves cursor to start of line.
pub fn cursorHome(self: *TextArea) void {
self.cursor_col = 0;
}
/// Moves cursor to end of line.
pub fn cursorEnd(self: *TextArea) void {
self.cursor_col = self.lines.items[self.cursor_line].items.len;
}
fn clampCursorCol(self: *TextArea) void {
const line_len = self.lines.items[self.cursor_line].items.len;
if (self.cursor_col > line_len) {
self.cursor_col = line_len;
}
}
/// Adjusts scroll to keep cursor visible.
fn adjustScroll(self: *TextArea, viewport_height: u16, viewport_width: u16) void {
// Vertical scroll
if (self.cursor_line < self.scroll_y) {
self.scroll_y = self.cursor_line;
} else if (self.cursor_line >= self.scroll_y + viewport_height) {
self.scroll_y = self.cursor_line - viewport_height + 1;
}
// Horizontal scroll
if (self.cursor_col < self.scroll_x) {
self.scroll_x = self.cursor_col;
} else if (self.cursor_col >= self.scroll_x + viewport_width) {
self.scroll_x = self.cursor_col - viewport_width + 1;
}
}
/// Renders the text area.
pub fn render(self: *TextArea, area: Rect, buf: *Buffer) void {
if (area.width < 3 or area.height < 3) return;
// Border
var border_style = self.style;
if (self.focused) border_style = self.focused_style;
const block = Block.init()
.setBorders(Borders.all)
.style(border_style);
block.render(area, buf);
var inner = block.inner(area);
// Line numbers
var line_num_width: u16 = 0;
if (self.show_line_numbers) {
line_num_width = 4;
inner.x += line_num_width;
inner.width -|= line_num_width;
}
// Adjust scroll
self.adjustScroll(inner.height, inner.width);
// Check if empty
const is_empty = self.lines.items.len == 1 and self.lines.items[0].items.len == 0;
// Render placeholder if empty
if (is_empty and !self.focused and self.placeholder.len > 0) {
const max_len = @min(self.placeholder.len, inner.width);
_ = buf.setString(inner.x, inner.y, self.placeholder[0..max_len], self.placeholder_style);
return;
}
// Render lines
var screen_y = inner.y;
var line_idx = self.scroll_y;
while (screen_y < inner.y + inner.height and line_idx < self.lines.items.len) {
// Line number
if (self.show_line_numbers) {
var num_buf: [8]u8 = undefined;
const num_str = std.fmt.bufPrint(&num_buf, "{d:>3} ", .{line_idx + 1}) catch " ";
_ = buf.setString(area.x + 1, screen_y, num_str, self.line_number_style);
}
// Line content
const line = self.lines.items[line_idx].items;
const visible_start = @min(self.scroll_x, line.len);
const visible_len = @min(line.len - visible_start, inner.width);
if (visible_len > 0) {
_ = buf.setString(inner.x, screen_y, line[visible_start..][0..visible_len], self.style);
}
// Cursor
if (self.focused and line_idx == self.cursor_line) {
const cursor_screen_x = inner.x + @as(u16, @intCast(self.cursor_col -| self.scroll_x));
if (cursor_screen_x < inner.x + inner.width) {
if (buf.getCell(cursor_screen_x, screen_y)) |cell| {
cell.setStyle(self.cursor_style);
}
}
}
screen_y += 1;
line_idx += 1;
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "TextArea basic" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try std.testing.expectEqual(@as(usize, 1), ta.getLineCount());
try std.testing.expectEqualStrings("", ta.getLine(0).?);
}
test "TextArea insert" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try ta.insertChar('H');
try ta.insertChar('i');
try std.testing.expectEqualStrings("Hi", ta.getLine(0).?);
try std.testing.expectEqual(@as(usize, 2), ta.cursor_col);
}
test "TextArea newline" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try ta.insertChar('A');
try ta.insertNewline();
try ta.insertChar('B');
try std.testing.expectEqual(@as(usize, 2), ta.getLineCount());
try std.testing.expectEqualStrings("A", ta.getLine(0).?);
try std.testing.expectEqualStrings("B", ta.getLine(1).?);
}
test "TextArea setText" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try ta.setText("Hello\nWorld\nTest");
try std.testing.expectEqual(@as(usize, 3), ta.getLineCount());
try std.testing.expectEqualStrings("Hello", ta.getLine(0).?);
try std.testing.expectEqualStrings("World", ta.getLine(1).?);
try std.testing.expectEqualStrings("Test", ta.getLine(2).?);
}
test "TextArea backspace" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try ta.insertChar('A');
try ta.insertChar('B');
try ta.insertChar('C');
ta.deleteBack();
try std.testing.expectEqualStrings("AB", ta.getLine(0).?);
ta.deleteBack();
ta.deleteBack();
try std.testing.expectEqualStrings("", ta.getLine(0).?);
}
test "TextArea cursor movement" {
const allocator = std.testing.allocator;
var ta = try TextArea.init(allocator);
defer ta.deinit();
try ta.setText("Line1\nLine2");
ta.cursorDown();
try std.testing.expectEqual(@as(usize, 1), ta.cursor_line);
ta.cursorEnd();
try std.testing.expectEqual(@as(usize, 5), ta.cursor_col);
ta.cursorHome();
try std.testing.expectEqual(@as(usize, 0), ta.cursor_col);
}