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