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());
|
run_menu_demo.step.dependOn(b.getInstallStep());
|
||||||
const menu_demo_step = b.step("menu-demo", "Run menu demo");
|
const menu_demo_step = b.step("menu-demo", "Run menu demo");
|
||||||
menu_demo_step.dependOn(&run_menu_demo.step);
|
menu_demo_step.dependOn(&run_menu_demo.step);
|
||||||
|
|
||||||
|
// Ejemplo: form_demo
|
||||||
|
const form_demo_exe = b.addExecutable(.{
|
||||||
|
.name = "form-demo",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("examples/form_demo.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "zcatui", .module = zcatui_mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
b.installArtifact(form_demo_exe);
|
||||||
|
|
||||||
|
const run_form_demo = b.addRunArtifact(form_demo_exe);
|
||||||
|
run_form_demo.step.dependOn(b.getInstallStep());
|
||||||
|
const form_demo_step = b.step("form-demo", "Run form demo");
|
||||||
|
form_demo_step.dependOn(&run_form_demo.step);
|
||||||
|
|
||||||
|
// Ejemplo: panel_demo
|
||||||
|
const panel_demo_exe = b.addExecutable(.{
|
||||||
|
.name = "panel-demo",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("examples/panel_demo.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "zcatui", .module = zcatui_mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
b.installArtifact(panel_demo_exe);
|
||||||
|
|
||||||
|
const run_panel_demo = b.addRunArtifact(panel_demo_exe);
|
||||||
|
run_panel_demo.step.dependOn(b.getInstallStep());
|
||||||
|
const panel_demo_step = b.step("panel-demo", "Run panel demo");
|
||||||
|
panel_demo_step.dependOn(&run_panel_demo.step);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
295
examples/form_demo.zig
Normal file
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 DockPosition = panel_mod.DockPosition;
|
||||||
pub const PanelManager = panel_mod.PanelManager;
|
pub const PanelManager = panel_mod.PanelManager;
|
||||||
pub const SplitDirection = panel_mod.SplitDirection;
|
pub const SplitDirection = panel_mod.SplitDirection;
|
||||||
|
|
||||||
|
pub const checkbox_mod = @import("widgets/checkbox.zig");
|
||||||
|
pub const Checkbox = checkbox_mod.Checkbox;
|
||||||
|
pub const CheckboxSymbols = checkbox_mod.CheckboxSymbols;
|
||||||
|
pub const RadioGroup = checkbox_mod.RadioGroup;
|
||||||
|
pub const RadioSymbols = checkbox_mod.RadioSymbols;
|
||||||
|
pub const CheckboxGroup = checkbox_mod.CheckboxGroup;
|
||||||
|
|
||||||
|
pub const select_mod = @import("widgets/select.zig");
|
||||||
|
pub const Select = select_mod.Select;
|
||||||
|
pub const MultiSelect = select_mod.MultiSelect;
|
||||||
|
|
||||||
|
pub const slider_mod = @import("widgets/slider.zig");
|
||||||
|
pub const Slider = slider_mod.Slider;
|
||||||
|
pub const RangeSlider = slider_mod.RangeSlider;
|
||||||
|
pub const SliderSymbols = slider_mod.SliderSymbols;
|
||||||
|
|
||||||
|
pub const textarea_mod = @import("widgets/textarea.zig");
|
||||||
|
pub const TextArea = textarea_mod.TextArea;
|
||||||
|
|
||||||
|
pub const statusbar_mod = @import("widgets/statusbar.zig");
|
||||||
|
pub const StatusBar = statusbar_mod.StatusBar;
|
||||||
|
pub const StatusBarBuilder = statusbar_mod.StatusBarBuilder;
|
||||||
|
pub const Toast = statusbar_mod.Toast;
|
||||||
|
pub const ToastType = statusbar_mod.ToastType;
|
||||||
|
pub const ToastManager = statusbar_mod.ToastManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Backend
|
// Backend
|
||||||
|
|
|
||||||
558
src/widgets/checkbox.zig
Normal file
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.
|
/// Manages a collection of panels.
|
||||||
pub const PanelManager = struct {
|
pub const PanelManager = struct {
|
||||||
/// All panels.
|
/// All panels (static array for simplicity).
|
||||||
panels: std.ArrayList(DockingPanel),
|
panels: [16]?DockingPanel = [_]?DockingPanel{null} ** 16,
|
||||||
|
|
||||||
|
/// Count of panels.
|
||||||
|
count: usize = 0,
|
||||||
|
|
||||||
/// Currently focused panel index.
|
/// Currently focused panel index.
|
||||||
focused: ?usize = null,
|
focused: ?usize = null,
|
||||||
|
|
||||||
/// Allocator.
|
/// Allocator (kept for compatibility).
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
|
|
||||||
/// Creates a new panel manager.
|
/// Creates a new panel manager.
|
||||||
pub fn init(allocator: Allocator) PanelManager {
|
pub fn init(allocator: Allocator) PanelManager {
|
||||||
return .{
|
return .{
|
||||||
.panels = std.ArrayList(DockingPanel).init(allocator),
|
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frees resources.
|
/// Frees resources.
|
||||||
pub fn deinit(self: *PanelManager) void {
|
pub fn deinit(self: *PanelManager) void {
|
||||||
self.panels.deinit();
|
_ = self;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a panel.
|
/// Adds a panel.
|
||||||
pub fn add(self: *PanelManager, panel: DockingPanel) !usize {
|
pub fn add(self: *PanelManager, panel: DockingPanel) !usize {
|
||||||
try self.panels.append(panel);
|
if (self.count >= 16) return error.TooManyPanels;
|
||||||
return self.panels.items.len - 1;
|
self.panels[self.count] = panel;
|
||||||
|
self.count += 1;
|
||||||
|
return self.count - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets a panel by index.
|
/// Gets a panel by index.
|
||||||
pub fn get(self: *PanelManager, index: usize) ?*DockingPanel {
|
pub fn get(self: *PanelManager, index: usize) ?*DockingPanel {
|
||||||
if (index < self.panels.items.len) {
|
if (index < self.count) {
|
||||||
return &self.panels.items[index];
|
if (self.panels[index]) |*p| {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -616,24 +622,24 @@ pub const PanelManager = struct {
|
||||||
pub fn focus(self: *PanelManager, index: usize) void {
|
pub fn focus(self: *PanelManager, index: usize) void {
|
||||||
// Unfocus previous
|
// Unfocus previous
|
||||||
if (self.focused) |f| {
|
if (self.focused) |f| {
|
||||||
if (f < self.panels.items.len) {
|
if (self.get(f)) |p| {
|
||||||
self.panels.items[f].panel.focused = false;
|
p.panel.focused = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus new
|
// Focus new
|
||||||
if (index < self.panels.items.len) {
|
if (self.get(index)) |p| {
|
||||||
self.focused = index;
|
self.focused = index;
|
||||||
self.panels.items[index].panel.focused = true;
|
p.panel.focused = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Focuses next panel.
|
/// Focuses next panel.
|
||||||
pub fn focusNext(self: *PanelManager) void {
|
pub fn focusNext(self: *PanelManager) void {
|
||||||
if (self.panels.items.len == 0) return;
|
if (self.count == 0) return;
|
||||||
|
|
||||||
const next = if (self.focused) |f|
|
const next = if (self.focused) |f|
|
||||||
(f + 1) % self.panels.items.len
|
(f + 1) % self.count
|
||||||
else
|
else
|
||||||
0;
|
0;
|
||||||
|
|
||||||
|
|
@ -645,49 +651,55 @@ pub const PanelManager = struct {
|
||||||
// Render docked panels first (left, right, top, bottom)
|
// Render docked panels first (left, right, top, bottom)
|
||||||
var remaining = bounds;
|
var remaining = bounds;
|
||||||
|
|
||||||
for (self.panels.items) |*panel| {
|
for (&self.panels) |*slot| {
|
||||||
if (!panel.visible) continue;
|
if (slot.*) |*panel| {
|
||||||
|
if (!panel.visible) continue;
|
||||||
|
|
||||||
switch (panel.position) {
|
switch (panel.position) {
|
||||||
.left => {
|
.left => {
|
||||||
const area = panel.calculateArea(remaining);
|
const area = panel.calculateArea(remaining);
|
||||||
panel.panel.render(area, buf);
|
panel.panel.render(area, buf);
|
||||||
remaining.x += area.width;
|
remaining.x += area.width;
|
||||||
remaining.width -|= area.width;
|
remaining.width -|= area.width;
|
||||||
},
|
},
|
||||||
.right => {
|
.right => {
|
||||||
const area = panel.calculateArea(remaining);
|
const area = panel.calculateArea(remaining);
|
||||||
panel.panel.render(area, buf);
|
panel.panel.render(area, buf);
|
||||||
remaining.width -|= area.width;
|
remaining.width -|= area.width;
|
||||||
},
|
},
|
||||||
.top => {
|
.top => {
|
||||||
const area = panel.calculateArea(remaining);
|
const area = panel.calculateArea(remaining);
|
||||||
panel.panel.render(area, buf);
|
panel.panel.render(area, buf);
|
||||||
remaining.y += area.height;
|
remaining.y += area.height;
|
||||||
remaining.height -|= area.height;
|
remaining.height -|= area.height;
|
||||||
},
|
},
|
||||||
.bottom => {
|
.bottom => {
|
||||||
const area = panel.calculateArea(remaining);
|
const area = panel.calculateArea(remaining);
|
||||||
panel.panel.render(area, buf);
|
panel.panel.render(area, buf);
|
||||||
remaining.height -|= area.height;
|
remaining.height -|= area.height;
|
||||||
},
|
},
|
||||||
else => {},
|
else => {},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render center panels
|
// Render center panels
|
||||||
for (self.panels.items) |*panel| {
|
for (&self.panels) |*slot| {
|
||||||
if (!panel.visible) continue;
|
if (slot.*) |*panel| {
|
||||||
if (panel.position == .center) {
|
if (!panel.visible) continue;
|
||||||
panel.panel.render(remaining, buf);
|
if (panel.position == .center) {
|
||||||
|
panel.panel.render(remaining, buf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render floating panels last (on top)
|
// Render floating panels last (on top)
|
||||||
for (self.panels.items) |*panel| {
|
for (&self.panels) |*slot| {
|
||||||
if (!panel.visible) continue;
|
if (slot.*) |*panel| {
|
||||||
if (panel.position == .floating) {
|
if (!panel.visible) continue;
|
||||||
panel.render(bounds, buf);
|
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