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:
parent
d3e42a241d
commit
79c0bb1a58
11 changed files with 3651 additions and 49 deletions
324
README.md
Normal file
324
README.md
Normal 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
|
||||
38
build.zig
38
build.zig
|
|
@ -194,4 +194,42 @@ pub fn build(b: *std.Build) void {
|
|||
run_menu_demo.step.dependOn(b.getInstallStep());
|
||||
const menu_demo_step = b.step("menu-demo", "Run menu demo");
|
||||
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
295
examples/form_demo.zig
Normal 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
299
examples/panel_demo.zig
Normal 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)));
|
||||
}
|
||||
26
src/root.zig
26
src/root.zig
|
|
@ -181,6 +181,32 @@ pub const widgets = struct {
|
|||
pub const DockPosition = panel_mod.DockPosition;
|
||||
pub const PanelManager = panel_mod.PanelManager;
|
||||
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
|
||||
|
|
|
|||
558
src/widgets/checkbox.zig
Normal file
558
src/widgets/checkbox.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -576,38 +576,44 @@ pub const DockingPanel = struct {
|
|||
|
||||
/// Manages a collection of panels.
|
||||
pub const PanelManager = struct {
|
||||
/// All panels.
|
||||
panels: std.ArrayList(DockingPanel),
|
||||
/// All panels (static array for simplicity).
|
||||
panels: [16]?DockingPanel = [_]?DockingPanel{null} ** 16,
|
||||
|
||||
/// Count of panels.
|
||||
count: usize = 0,
|
||||
|
||||
/// Currently focused panel index.
|
||||
focused: ?usize = null,
|
||||
|
||||
/// Allocator.
|
||||
/// Allocator (kept for compatibility).
|
||||
allocator: Allocator,
|
||||
|
||||
/// Creates a new panel manager.
|
||||
pub fn init(allocator: Allocator) PanelManager {
|
||||
return .{
|
||||
.panels = std.ArrayList(DockingPanel).init(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Frees resources.
|
||||
pub fn deinit(self: *PanelManager) void {
|
||||
self.panels.deinit();
|
||||
_ = self;
|
||||
}
|
||||
|
||||
/// Adds a panel.
|
||||
pub fn add(self: *PanelManager, panel: DockingPanel) !usize {
|
||||
try self.panels.append(panel);
|
||||
return self.panels.items.len - 1;
|
||||
if (self.count >= 16) return error.TooManyPanels;
|
||||
self.panels[self.count] = panel;
|
||||
self.count += 1;
|
||||
return self.count - 1;
|
||||
}
|
||||
|
||||
/// Gets a panel by index.
|
||||
pub fn get(self: *PanelManager, index: usize) ?*DockingPanel {
|
||||
if (index < self.panels.items.len) {
|
||||
return &self.panels.items[index];
|
||||
if (index < self.count) {
|
||||
if (self.panels[index]) |*p| {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -616,24 +622,24 @@ pub const PanelManager = struct {
|
|||
pub fn focus(self: *PanelManager, index: usize) void {
|
||||
// Unfocus previous
|
||||
if (self.focused) |f| {
|
||||
if (f < self.panels.items.len) {
|
||||
self.panels.items[f].panel.focused = false;
|
||||
if (self.get(f)) |p| {
|
||||
p.panel.focused = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus new
|
||||
if (index < self.panels.items.len) {
|
||||
if (self.get(index)) |p| {
|
||||
self.focused = index;
|
||||
self.panels.items[index].panel.focused = true;
|
||||
p.panel.focused = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Focuses next panel.
|
||||
pub fn focusNext(self: *PanelManager) void {
|
||||
if (self.panels.items.len == 0) return;
|
||||
if (self.count == 0) return;
|
||||
|
||||
const next = if (self.focused) |f|
|
||||
(f + 1) % self.panels.items.len
|
||||
(f + 1) % self.count
|
||||
else
|
||||
0;
|
||||
|
||||
|
|
@ -645,49 +651,55 @@ pub const PanelManager = struct {
|
|||
// Render docked panels first (left, right, top, bottom)
|
||||
var remaining = bounds;
|
||||
|
||||
for (self.panels.items) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
for (&self.panels) |*slot| {
|
||||
if (slot.*) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
|
||||
switch (panel.position) {
|
||||
.left => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.x += area.width;
|
||||
remaining.width -|= area.width;
|
||||
},
|
||||
.right => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.width -|= area.width;
|
||||
},
|
||||
.top => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.y += area.height;
|
||||
remaining.height -|= area.height;
|
||||
},
|
||||
.bottom => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.height -|= area.height;
|
||||
},
|
||||
else => {},
|
||||
switch (panel.position) {
|
||||
.left => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.x += area.width;
|
||||
remaining.width -|= area.width;
|
||||
},
|
||||
.right => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.width -|= area.width;
|
||||
},
|
||||
.top => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.y += area.height;
|
||||
remaining.height -|= area.height;
|
||||
},
|
||||
.bottom => {
|
||||
const area = panel.calculateArea(remaining);
|
||||
panel.panel.render(area, buf);
|
||||
remaining.height -|= area.height;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render center panels
|
||||
for (self.panels.items) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
if (panel.position == .center) {
|
||||
panel.panel.render(remaining, buf);
|
||||
for (&self.panels) |*slot| {
|
||||
if (slot.*) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
if (panel.position == .center) {
|
||||
panel.panel.render(remaining, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render floating panels last (on top)
|
||||
for (self.panels.items) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
if (panel.position == .floating) {
|
||||
panel.render(bounds, buf);
|
||||
for (&self.panels) |*slot| {
|
||||
if (slot.*) |*panel| {
|
||||
if (!panel.visible) continue;
|
||||
if (panel.position == .floating) {
|
||||
panel.render(bounds, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
527
src/widgets/select.zig
Normal file
527
src/widgets/select.zig
Normal 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
470
src/widgets/slider.zig
Normal 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
535
src/widgets/statusbar.zig
Normal 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
518
src/widgets/textarea.zig
Normal 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(¤t_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);
|
||||
}
|
||||
Loading…
Reference in a new issue