From 2a4e79f4edba1fcdd0fd337483e35b726fb91b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Wed, 4 Mar 2026 13:29:09 +0100 Subject: [PATCH 1/2] feat: add focus management with FocusGroup, Tab cycling, and FocusStyle --- README.md | 75 ++++++ build.zig | 2 + examples/focus_form.zig | 226 ++++++++++++++++ src/components/confirm.zig | 14 +- src/components/file_picker.zig | 15 ++ src/components/focus.zig | 456 +++++++++++++++++++++++++++++++++ src/components/list.zig | 15 ++ src/root.zig | 6 + tests/focus_tests.zig | 314 +++++++++++++++++++++++ 9 files changed, 1122 insertions(+), 1 deletion(-) create mode 100644 examples/focus_form.zig create mode 100644 src/components/focus.zig create mode 100644 tests/focus_tests.zig diff --git a/README.md b/README.md index 33d3605..298fa3e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A delightful TUI framework for Zig, inspired by [Bubble Tea](https://github.com/ - **Elm Architecture** - Model-Update-View pattern for predictable state management - **Rich Styling** - Comprehensive styling system with colors, borders, padding, margin backgrounds, per-side border colors, tab width control, style ranges, full style inheritance, text transforms, whitespace formatting controls, and unset methods - **16 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Notification/Toast, Confirm dialog, Help, Paginator, Timer, FilePicker +- **Focus Management** - `FocusGroup` with Tab/Shift+Tab cycling, comptime focusable protocol, `FocusStyle` for visual focus ring indicators - **Keybinding Management** - Structured `KeyBinding`/`KeyMap` with matching, display formatting, and Help component integration - **Color System** - ANSI 16, 256, and TrueColor with adaptive colors, color profile detection, and dark background detection - **Command System** - Quit, tick, repeating tick (`every`), batch, sequence, suspend/resume, runtime terminal control (mouse, cursor, alt screen, title), print above program, comprehensive image rendering @@ -453,6 +454,79 @@ defer help.deinit(); const help_view = try help.view(allocator); ``` +### Focus Management + +Manage Tab/Shift+Tab cycling between interactive components with `FocusGroup`: + +```zig +const Model = struct { + name: zz.TextInput, + email: zz.TextInput, + focus: zz.FocusGroup(2), + focus_style: zz.FocusStyle, + + pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) { + self.name = zz.TextInput.init(ctx.persistent_allocator); + self.email = zz.TextInput.init(ctx.persistent_allocator); + + self.focus = .{}; + self.focus.add(&self.name); // index 0 + self.focus.add(&self.email); // index 1 + self.focus.initFocus(); // focus first, blur rest + + self.focus_style = .{}; // cyan/gray borders by default + return .none; + } + + pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { + switch (msg) { + .key => |k| { + // Tab/Shift+Tab cycles focus (returns true if consumed) + if (self.focus.handleKey(k)) return .none; + // Forward to all — unfocused components auto-ignore + self.name.handleKey(k); + self.email.handleKey(k); + }, + } + return .none; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + // Apply focus ring (border color changes based on focus) + var style = zz.Style{}; + style = style.paddingAll(1); + const name_style = self.focus_style.apply(style, self.focus.isFocused(0)); + const email_style = self.focus_style.apply(style, self.focus.isFocused(1)); + // ... render with styled boxes ... + } +}; +``` + +Any component with `focused: bool`, `focus()`, and `blur()` methods works with `FocusGroup`. +Built-in focusable components: TextInput, TextArea, Table, List, Confirm, FilePicker. + +Additional API: + +```zig +fg.focusAt(2); // Focus specific index +fg.focusNext(); // Manual next (same as Tab) +fg.focusPrev(); // Manual prev (same as Shift+Tab) +fg.blurAll(); // Remove focus from all +fg.focused(); // Get current index +fg.isFocused(1); // Check if index is focused +fg.len(); // Number of registered items + +// Disable wrapping (stop at ends instead of cycling) +var fg: zz.FocusGroup(3) = .{ .wrap = false }; + +// Custom focus ring colors +const fs = zz.FocusStyle{ + .focused_border_fg = zz.Color.green(), + .blurred_border_fg = zz.Color.gray(8), + .border_chars = zz.Border.double, +}; +``` + ## Options Configure the program with custom options: @@ -753,6 +827,7 @@ zig build run-text_editor zig build run-file_browser zig build run-dashboard zig build run-showcase # Multi-tab demo of all features +zig build run-focus_form # Focus management with Tab cycling ``` ## Building diff --git a/build.zig b/build.zig index 464a6cd..892720c 100644 --- a/build.zig +++ b/build.zig @@ -20,6 +20,7 @@ pub fn build(b: *std.Build) void { "file_browser", "dashboard", "showcase", + "focus_form", }; for (examples) |example_name| { @@ -56,6 +57,7 @@ pub fn build(b: *std.Build) void { "tests/layout_tests.zig", "tests/unicode_tests.zig", "tests/program_tests.zig", + "tests/focus_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/examples/focus_form.zig b/examples/focus_form.zig new file mode 100644 index 0000000..abed974 --- /dev/null +++ b/examples/focus_form.zig @@ -0,0 +1,226 @@ +//! ZigZag Focus Form Example +//! Demonstrates focus management with Tab/Shift+Tab cycling between +//! multiple text inputs, with visual focus indicators (border colors). +//! +//! Keys: +//! Tab — move to next field +//! Shift+Tab — move to previous field +//! Enter — submit form +//! Escape — quit + +const std = @import("std"); +const zz = @import("zigzag"); + +const Model = struct { + name_input: zz.TextInput, + email_input: zz.TextInput, + message_input: zz.TextInput, + focus_group: zz.FocusGroup(3), + focus_style: zz.FocusStyle, + submitted: bool, + + pub const Msg = union(enum) { + key: zz.KeyEvent, + }; + + pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) { + // Initialize text inputs + self.name_input = zz.TextInput.init(ctx.persistent_allocator); + self.name_input.setPlaceholder("Your name..."); + self.name_input.setPrompt(" "); + + self.email_input = zz.TextInput.init(ctx.persistent_allocator); + self.email_input.setPlaceholder("you@example.com"); + self.email_input.setPrompt(" "); + + self.message_input = zz.TextInput.init(ctx.persistent_allocator); + self.message_input.setPlaceholder("Type your message..."); + self.message_input.setPrompt(" "); + + // Set up focus group + self.focus_group = .{}; + self.focus_group.add(&self.name_input); + self.focus_group.add(&self.email_input); + self.focus_group.add(&self.message_input); + self.focus_group.initFocus(); + + // Focus style with cyan/gray borders + self.focus_style = .{}; + + self.submitted = false; + + return .none; + } + + pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { + switch (msg) { + .key => |k| { + if (self.submitted) { + // Any key after submit quits + return .quit; + } + + switch (k.key) { + .escape => return .quit, + .enter => { + self.submitted = true; + return .none; + }, + else => {}, + } + + // Try focus cycling first (consumes Tab/Shift+Tab) + if (self.focus_group.handleKey(k)) return .none; + + // Forward key to all inputs (unfocused ones auto-ignore) + self.name_input.handleKey(k); + self.email_input.handleKey(k); + self.message_input.handleKey(k); + }, + } + return .none; + } + + pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { + // Title + var title_style = zz.Style{}; + title_style = title_style.bold(true); + title_style = title_style.fg(zz.Color.hex("#FF6B6B")); + title_style = title_style.inline_style(true); + const title = title_style.render(ctx.allocator, "Contact Form") catch "Contact Form"; + + // Subtitle + var sub_style = zz.Style{}; + sub_style = sub_style.fg(zz.Color.gray(15)); + sub_style = sub_style.inline_style(true); + const subtitle = sub_style.render(ctx.allocator, "Tab/Shift+Tab to navigate • Enter to submit • Esc to quit") catch ""; + + // Field labels + var label_style = zz.Style{}; + label_style = label_style.bold(true); + label_style = label_style.inline_style(true); + + var focused_label = zz.Style{}; + focused_label = focused_label.bold(true); + focused_label = focused_label.fg(zz.Color.cyan()); + focused_label = focused_label.inline_style(true); + + // Render each field with focus indicator + const name_label = if (self.focus_group.isFocused(0)) + focused_label.render(ctx.allocator, "▸ Name") catch "Name" + else + label_style.render(ctx.allocator, " Name") catch "Name"; + + const email_label = if (self.focus_group.isFocused(1)) + focused_label.render(ctx.allocator, "▸ Email") catch "Email" + else + label_style.render(ctx.allocator, " Email") catch "Email"; + + const message_label = if (self.focus_group.isFocused(2)) + focused_label.render(ctx.allocator, "▸ Message") catch "Message" + else + label_style.render(ctx.allocator, " Message") catch "Message"; + + // Render inputs + const name_view = self.name_input.view(ctx.allocator) catch ""; + const email_view = self.email_input.view(ctx.allocator) catch ""; + const message_view = self.message_input.view(ctx.allocator) catch ""; + + // Wrap each input in a focus-styled box + const name_box = self.renderField(ctx, name_label, name_view, 0); + const email_box = self.renderField(ctx, email_label, email_view, 1); + const message_box = self.renderField(ctx, message_label, message_view, 2); + + // Status line + const status = if (self.submitted) blk: { + var success_style = zz.Style{}; + success_style = success_style.bold(true); + success_style = success_style.fg(zz.Color.green()); + success_style = success_style.inline_style(true); + + const name_val = self.name_input.getValue(); + const email_val = self.email_input.getValue(); + const msg_val = self.message_input.getValue(); + + const text = std.fmt.allocPrint( + ctx.allocator, + "✓ Submitted! Name: {s}, Email: {s}, Message: {s}", + .{ + if (name_val.len > 0) name_val else "(empty)", + if (email_val.len > 0) email_val else "(empty)", + if (msg_val.len > 0) msg_val else "(empty)", + }, + ) catch "✓ Submitted!"; + break :blk success_style.render(ctx.allocator, text) catch text; + } else blk: { + var hint_style = zz.Style{}; + hint_style = hint_style.fg(zz.Color.gray(12)); + hint_style = hint_style.inline_style(true); + const field_name = switch (self.focus_group.focused()) { + 0 => "Name", + 1 => "Email", + 2 => "Message", + else => "?", + }; + const text = std.fmt.allocPrint( + ctx.allocator, + "Editing: {s} (field {d}/{d})", + .{ field_name, self.focus_group.focused() + 1, self.focus_group.len() }, + ) catch ""; + break :blk hint_style.render(ctx.allocator, text) catch text; + }; + + // Get max width + const box_width = @max( + zz.measure.maxLineWidth(name_box), + @max(zz.measure.maxLineWidth(email_box), zz.measure.maxLineWidth(message_box)), + ); + const max_width = @max(box_width, @max(zz.measure.width(title), zz.measure.width(subtitle))); + + // Center elements + const centered_title = zz.place.place(ctx.allocator, max_width, 1, .center, .top, title) catch title; + const centered_sub = zz.place.place(ctx.allocator, max_width, 1, .center, .top, subtitle) catch subtitle; + const centered_status = zz.place.place(ctx.allocator, max_width, 1, .center, .top, status) catch status; + + const content = std.fmt.allocPrint( + ctx.allocator, + "{s}\n{s}\n\n{s}\n{s}\n{s}\n\n{s}", + .{ centered_title, centered_sub, name_box, email_box, message_box, centered_status }, + ) catch "Error rendering view"; + + return zz.place.place( + ctx.allocator, + ctx.width, + ctx.height, + .center, + .middle, + content, + ) catch content; + } + + fn renderField(self: *const Model, ctx: *const zz.Context, label: []const u8, input_view: []const u8, index: usize) []const u8 { + const content = std.fmt.allocPrint(ctx.allocator, "{s}\n{s}", .{ label, input_view }) catch input_view; + var box = zz.Style{}; + box = box.paddingLeft(1); + box = box.paddingRight(1); + box = box.width(40); + box = self.focus_style.apply(box, self.focus_group.isFocused(index)); + return box.render(ctx.allocator, content) catch content; + } + + pub fn deinit(self: *Model) void { + self.name_input.deinit(); + self.email_input.deinit(); + self.message_input.deinit(); + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + + var prog = try zz.Program(Model).init(gpa.allocator()); + defer prog.deinit(); + + try prog.run(); +} diff --git a/src/components/confirm.zig b/src/components/confirm.zig index ccc8b70..aef9aba 100644 --- a/src/components/confirm.zig +++ b/src/components/confirm.zig @@ -11,6 +11,7 @@ pub const Confirm = struct { selected: Selection, confirmed: ?bool, active: bool, + focused: bool, // Styling prompt_style: style_mod.Style, @@ -28,6 +29,7 @@ pub const Confirm = struct { .selected = .yes, .confirmed = null, .active = false, + .focused = true, .prompt_style = blk: { var s = style_mod.Style{}; s = s.bold(true); @@ -67,9 +69,19 @@ pub const Confirm = struct { return self.confirmed; } + /// Set focused state (for use with FocusGroup). + pub fn focus(self: *Confirm) void { + self.focused = true; + } + + /// Clear focused state (for use with FocusGroup). + pub fn blur(self: *Confirm) void { + self.focused = false; + } + /// Handle a key event pub fn handleKey(self: *Confirm, key: keys.KeyEvent) void { - if (!self.active) return; + if (!self.active or !self.focused) return; switch (key.key) { .left, .right, .tab => { diff --git a/src/components/file_picker.zig b/src/components/file_picker.zig index f0125b0..f868d8b 100644 --- a/src/components/file_picker.zig +++ b/src/components/file_picker.zig @@ -37,6 +37,9 @@ pub const FilePicker = struct { size_style: style_mod.Style, path_style: style_mod.Style, + // Focus + focused: bool, + // Symbols dir_icon: []const u8, file_icon: []const u8, @@ -104,6 +107,7 @@ pub const FilePicker = struct { s = s.inline_style(true); break :blk s; }, + .focused = true, .dir_icon = "📁 ", .file_icon = "📄 ", .link_icon = "🔗 ", @@ -278,8 +282,19 @@ pub const FilePicker = struct { } } + /// Set focused state (for use with FocusGroup). + pub fn focus(self: *FilePicker) void { + self.focused = true; + } + + /// Clear focused state (for use with FocusGroup). + pub fn blur(self: *FilePicker) void { + self.focused = false; + } + /// Handle key event pub fn handleKey(self: *FilePicker, key: keys.KeyEvent) !bool { + if (!self.focused) return false; switch (key.key) { .up => self.cursorUp(), .down => self.cursorDown(), diff --git a/src/components/focus.zig b/src/components/focus.zig new file mode 100644 index 0000000..e92fa36 --- /dev/null +++ b/src/components/focus.zig @@ -0,0 +1,456 @@ +//! Focus management for ZigZag TUI applications. +//! Provides Tab/Shift+Tab cycling between focusable components and +//! focus-aware style helpers for rendering focus indicators. +//! +//! A component is "focusable" if it has: +//! - a `focused: bool` field +//! - a `pub fn focus(*Self) void` method +//! - a `pub fn blur(*Self) void` method +//! +//! TextInput, TextArea, Table, List, Confirm, and FilePicker all satisfy +//! this protocol out of the box. + +const std = @import("std"); +const keys = @import("../input/keys.zig"); +const style_mod = @import("../style/style.zig"); +const border_mod = @import("../style/border.zig"); +const Color = @import("../style/color.zig").Color; + +/// Comptime check: returns true if `T` satisfies the focusable protocol. +pub fn isFocusable(comptime T: type) bool { + return @hasField(T, "focused") and + @hasDecl(T, "focus") and + @hasDecl(T, "blur"); +} + +/// A group of focusable components with Tab/Shift+Tab cycling. +/// +/// `max_items` is the maximum number of components that can be registered. +/// The group uses a fixed-size array so it requires no allocation and can +/// be embedded directly in a Model struct. +/// +/// Usage: +/// ``` +/// var fg: FocusGroup(3) = .{}; +/// fg.add(&self.input_a); +/// fg.add(&self.input_b); +/// fg.add(&self.table); +/// fg.initFocus(); // focus first, blur the rest +/// ``` +pub fn FocusGroup(comptime max_items: usize) type { + return struct { + const Self = @This(); + + /// Type-erased handle to a focusable component. + pub const FocusItem = struct { + focus_fn: *const fn (*anyopaque) void, + blur_fn: *const fn (*anyopaque) void, + is_focused_fn: *const fn (*const anyopaque) bool, + ptr: *anyopaque, + }; + + items: [max_items]?FocusItem = [_]?FocusItem{null} ** max_items, + count: usize = 0, + active: usize = 0, + /// Whether cycling wraps from last to first (and vice versa). + wrap: bool = true, + + /// Register a focusable component. + /// + /// The component must satisfy the focusable protocol + /// (`focused: bool`, `focus()`, `blur()`). A compile error is + /// emitted if the protocol is not satisfied. + /// + /// The pointer must remain valid for the lifetime of the FocusGroup + /// (which is naturally the case for fields in the same Model struct). + pub fn add(self: *Self, item_ptr: anytype) void { + const Ptr = @TypeOf(item_ptr); + const T = @typeInfo(Ptr).pointer.child; + + comptime { + if (!@hasField(T, "focused")) + @compileError("FocusGroup item must have a 'focused: bool' field. " ++ + "Type '" ++ @typeName(T) ++ "' does not satisfy the focusable protocol."); + if (!@hasDecl(T, "focus")) + @compileError("FocusGroup item must have a 'pub fn focus(*Self) void' method. " ++ + "Type '" ++ @typeName(T) ++ "' does not satisfy the focusable protocol."); + if (!@hasDecl(T, "blur")) + @compileError("FocusGroup item must have a 'pub fn blur(*Self) void' method. " ++ + "Type '" ++ @typeName(T) ++ "' does not satisfy the focusable protocol."); + } + + if (self.count >= max_items) return; + + self.items[self.count] = .{ + .focus_fn = @ptrCast(&struct { + fn call(raw_ptr: *anyopaque) void { + const ptr: *T = @ptrCast(@alignCast(raw_ptr)); + ptr.focus(); + } + }.call), + .blur_fn = @ptrCast(&struct { + fn call(raw_ptr: *anyopaque) void { + const ptr: *T = @ptrCast(@alignCast(raw_ptr)); + ptr.blur(); + } + }.call), + .is_focused_fn = @ptrCast(&struct { + fn call(raw_ptr: *const anyopaque) bool { + const ptr: *const T = @ptrCast(@alignCast(raw_ptr)); + return ptr.focused; + } + }.call), + .ptr = @ptrCast(item_ptr), + }; + self.count += 1; + } + + /// Focus the item at `index`, blurring all others. + /// Does nothing if `index` is out of range. + pub fn focusAt(self: *Self, index: usize) void { + if (index >= self.count) return; + for (0..self.count) |i| { + if (self.items[i]) |item| { + if (i == index) { + item.focus_fn(item.ptr); + } else { + item.blur_fn(item.ptr); + } + } + } + self.active = index; + } + + /// Move focus to the next item (Tab behavior). + pub fn focusNext(self: *Self) void { + if (self.count == 0) return; + if (self.active + 1 < self.count) { + self.focusAt(self.active + 1); + } else if (self.wrap) { + self.focusAt(0); + } + } + + /// Move focus to the previous item (Shift+Tab behavior). + pub fn focusPrev(self: *Self) void { + if (self.count == 0) return; + if (self.active > 0) { + self.focusAt(self.active - 1); + } else if (self.wrap) { + self.focusAt(self.count - 1); + } + } + + /// Handle a key event for focus cycling. + /// + /// Returns `true` if the key was consumed (Tab or Shift+Tab), + /// `false` if it should be forwarded to the active component. + pub fn handleKey(self: *Self, key: keys.KeyEvent) bool { + if (key.key == .tab) { + if (key.modifiers.shift) { + self.focusPrev(); + } else { + self.focusNext(); + } + return true; + } + return false; + } + + /// Get the index of the currently focused item. + pub fn focused(self: *const Self) usize { + return self.active; + } + + /// Check if the item at `index` is the currently focused one. + pub fn isFocused(self: *const Self, index: usize) bool { + return self.active == index; + } + + /// Initialize focus: focuses the first item and blurs all others. + /// Call this after adding all components. + pub fn initFocus(self: *Self) void { + if (self.count > 0) { + self.focusAt(0); + } + } + + /// Blur all items (none focused). + pub fn blurAll(self: *Self) void { + for (0..self.count) |i| { + if (self.items[i]) |item| { + item.blur_fn(item.ptr); + } + } + } + + /// Returns the total number of registered items. + pub fn len(self: *const Self) usize { + return self.count; + } + }; +} + +/// Style helper for rendering focus-aware borders. +/// +/// Apply to a base `Style` in your `view()` function to get a border +/// that changes color depending on focus state. +/// +/// ``` +/// const fs = FocusStyle{}; +/// var style = zz.Style{}; +/// style = style.paddingAll(1); +/// style = fs.apply(style, is_focused); +/// const rendered = style.render(allocator, content); +/// ``` +pub const FocusStyle = struct { + /// Border color when focused (default: cyan). + focused_border_fg: Color = Color.cyan(), + /// Border color when not focused (default: dark gray). + blurred_border_fg: Color = Color.gray(12), + /// Border character set (default: rounded). + border_chars: border_mod.BorderChars = border_mod.Border.rounded, + + /// Apply focus-aware border styling to `base`. + /// Returns a new Style with the appropriate border and color. + pub fn apply(self: FocusStyle, base: style_mod.Style, is_focused: bool) style_mod.Style { + var s = base; + s = s.borderAll(self.border_chars); + if (is_focused) { + s = s.borderForeground(self.focused_border_fg); + } else { + s = s.borderForeground(self.blurred_border_fg); + } + return s; + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "isFocusable positive" { + const Focusable = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + try std.testing.expect(isFocusable(Focusable)); +} + +test "isFocusable negative — missing field" { + const NotFocusable = struct { + pub fn focus(_: *@This()) void {} + pub fn blur(_: *@This()) void {} + }; + try std.testing.expect(!isFocusable(NotFocusable)); +} + +test "isFocusable negative — missing method" { + const NotFocusable = struct { + focused: bool = false, + pub fn focus(_: *@This()) void {} + }; + try std.testing.expect(!isFocusable(NotFocusable)); +} + +test "FocusGroup — basic cycling" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + var c = Item{}; + + var fg: FocusGroup(3) = .{}; + fg.add(&a); + fg.add(&b); + fg.add(&c); + fg.initFocus(); + + // After init: first item focused + try std.testing.expect(a.focused); + try std.testing.expect(!b.focused); + try std.testing.expect(!c.focused); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + + // focusNext + fg.focusNext(); + try std.testing.expect(!a.focused); + try std.testing.expect(b.focused); + try std.testing.expect(!c.focused); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); + + // focusNext again + fg.focusNext(); + try std.testing.expectEqual(@as(usize, 2), fg.focused()); + try std.testing.expect(c.focused); + + // focusNext wraps + fg.focusNext(); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + try std.testing.expect(a.focused); + try std.testing.expect(!c.focused); +} + +test "FocusGroup — prev cycling" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + + var fg: FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // focusPrev wraps from 0 to last + fg.focusPrev(); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); + try std.testing.expect(b.focused); + try std.testing.expect(!a.focused); + + // focusPrev goes back + fg.focusPrev(); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + try std.testing.expect(a.focused); +} + +test "FocusGroup — no wrap" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + + var fg: FocusGroup(2) = .{ .wrap = false }; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // At first item, prev should NOT wrap + fg.focusPrev(); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + + // At last item, next should NOT wrap + fg.focusAt(1); + fg.focusNext(); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — handleKey Tab" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + + var fg: FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // Tab moves forward + const tab_event = keys.KeyEvent{ .key = .tab, .modifiers = .{} }; + const consumed = fg.handleKey(tab_event); + try std.testing.expect(consumed); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); + + // Shift+Tab moves backward + const shift_tab = keys.KeyEvent{ .key = .tab, .modifiers = .{ .shift = true } }; + const consumed2 = fg.handleKey(shift_tab); + try std.testing.expect(consumed2); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + + // Other keys not consumed + const other = keys.KeyEvent{ .key = .{ .char = 'a' }, .modifiers = .{} }; + const consumed3 = fg.handleKey(other); + try std.testing.expect(!consumed3); +} + +test "FocusGroup — focusAt and isFocused" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + var c = Item{}; + + var fg: FocusGroup(3) = .{}; + fg.add(&a); + fg.add(&b); + fg.add(&c); + + fg.focusAt(2); + try std.testing.expect(!fg.isFocused(0)); + try std.testing.expect(!fg.isFocused(1)); + try std.testing.expect(fg.isFocused(2)); + try std.testing.expect(!a.focused); + try std.testing.expect(!b.focused); + try std.testing.expect(c.focused); +} + +test "FocusGroup — blurAll" { + const Item = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var a = Item{}; + var b = Item{}; + + var fg: FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + try std.testing.expect(a.focused); + + fg.blurAll(); + try std.testing.expect(!a.focused); + try std.testing.expect(!b.focused); +} diff --git a/src/components/list.zig b/src/components/list.zig index 618faeb..0b5be48 100644 --- a/src/components/list.zig +++ b/src/components/list.zig @@ -37,6 +37,9 @@ pub fn List(comptime T: type) type { selected_symbol: []const u8, unselected_symbol: []const u8, + // Focus + focused: bool, + // Behavior multi_select: bool, wrap_around: bool, @@ -110,6 +113,7 @@ pub fn List(comptime T: type) type { .cursor_symbol = "> ", .selected_symbol = "[x] ", .unselected_symbol = "[ ] ", + .focused = true, .multi_select = false, .wrap_around = true, .status_message = null, @@ -298,8 +302,19 @@ pub fn List(comptime T: type) type { try self.updateFilter(); } + /// Set focused state (for use with FocusGroup). + pub fn focus(self: *Self) void { + self.focused = true; + } + + /// Clear focused state (for use with FocusGroup). + pub fn blur(self: *Self) void { + self.focused = false; + } + /// Handle key event pub fn handleKey(self: *Self, key: keys.KeyEvent) void { + if (!self.focused) return; if (self.filter_enabled and key.key == .char and !key.modifiers.ctrl) { // Add to filter const c = key.key.char; diff --git a/src/root.zig b/src/root.zig index 73891ef..ee93eeb 100644 --- a/src/root.zig +++ b/src/root.zig @@ -114,6 +114,7 @@ pub const components = struct { pub const notification = @import("components/notification.zig"); pub const Notification = notification.Notification; pub const Confirm = @import("components/confirm.zig").Confirm; + pub const focus = @import("components/focus.zig"); }; // Re-export commonly used components at top level @@ -130,6 +131,11 @@ pub const Sparkline = components.Sparkline; pub const Notification = components.Notification; pub const Confirm = components.Confirm; +// Focus management +pub const FocusGroup = components.focus.FocusGroup; +pub const FocusStyle = components.focus.FocusStyle; +pub const isFocusable = components.focus.isFocusable; + // Keybinding management pub const keybinding = @import("components/keybinding.zig"); pub const KeyBinding = keybinding.KeyBinding; diff --git a/tests/focus_tests.zig b/tests/focus_tests.zig new file mode 100644 index 0000000..21906c4 --- /dev/null +++ b/tests/focus_tests.zig @@ -0,0 +1,314 @@ +//! Tests for the focus management system. + +const std = @import("std"); +const zz = @import("zigzag"); + +// --------------------------------------------------------------------------- +// isFocusable comptime checks +// --------------------------------------------------------------------------- + +test "isFocusable — positive for conforming type" { + const Good = struct { + focused: bool = false, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + try std.testing.expect(zz.isFocusable(Good)); +} + +test "isFocusable — negative when field missing" { + const Bad = struct { + pub fn focus(_: *@This()) void {} + pub fn blur(_: *@This()) void {} + }; + try std.testing.expect(!zz.isFocusable(Bad)); +} + +test "isFocusable — negative when blur missing" { + const Bad = struct { + focused: bool = false, + pub fn focus(_: *@This()) void {} + }; + try std.testing.expect(!zz.isFocusable(Bad)); +} + +test "isFocusable — negative when focus missing" { + const Bad = struct { + focused: bool = false, + pub fn blur(_: *@This()) void {} + }; + try std.testing.expect(!zz.isFocusable(Bad)); +} + +// --------------------------------------------------------------------------- +// Built-in components satisfy the protocol +// --------------------------------------------------------------------------- + +test "isFocusable — TextInput" { + try std.testing.expect(zz.isFocusable(zz.TextInput)); +} + +test "isFocusable — TextArea" { + try std.testing.expect(zz.isFocusable(zz.TextArea)); +} + +test "isFocusable — Confirm" { + try std.testing.expect(zz.isFocusable(zz.Confirm)); +} + +// --------------------------------------------------------------------------- +// FocusGroup cycling +// --------------------------------------------------------------------------- + +const TestItem = struct { + focused: bool = false, + pub fn focus(self: *TestItem) void { + self.focused = true; + } + pub fn blur(self: *TestItem) void { + self.focused = false; + } +}; + +test "FocusGroup — initFocus focuses first, blurs rest" { + var a = TestItem{}; + var b = TestItem{}; + var c = TestItem{}; + + var fg: zz.FocusGroup(3) = .{}; + fg.add(&a); + fg.add(&b); + fg.add(&c); + fg.initFocus(); + + try std.testing.expect(a.focused); + try std.testing.expect(!b.focused); + try std.testing.expect(!c.focused); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); +} + +test "FocusGroup — focusNext cycles forward" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + fg.focusNext(); + try std.testing.expect(!a.focused); + try std.testing.expect(b.focused); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — focusNext wraps around" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + fg.focusNext(); // -> 1 + fg.focusNext(); // -> 0 (wrap) + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + try std.testing.expect(a.focused); + try std.testing.expect(!b.focused); +} + +test "FocusGroup — focusPrev wraps around" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + fg.focusPrev(); // wrap from 0 -> 1 + try std.testing.expectEqual(@as(usize, 1), fg.focused()); + try std.testing.expect(!a.focused); + try std.testing.expect(b.focused); +} + +test "FocusGroup — no wrap mode" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{ .wrap = false }; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // Prev at 0 should stay at 0 + fg.focusPrev(); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + + // Next to 1, then next should stay at 1 + fg.focusAt(1); + fg.focusNext(); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — focusAt" { + var a = TestItem{}; + var b = TestItem{}; + var c = TestItem{}; + + var fg: zz.FocusGroup(3) = .{}; + fg.add(&a); + fg.add(&b); + fg.add(&c); + + fg.focusAt(2); + try std.testing.expect(!a.focused); + try std.testing.expect(!b.focused); + try std.testing.expect(c.focused); + try std.testing.expect(fg.isFocused(2)); + try std.testing.expect(!fg.isFocused(0)); +} + +test "FocusGroup — focusAt out of range does nothing" { + var a = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.initFocus(); + + fg.focusAt(99); // should not crash + try std.testing.expectEqual(@as(usize, 0), fg.focused()); +} + +test "FocusGroup — blurAll" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + try std.testing.expect(a.focused); + + fg.blurAll(); + try std.testing.expect(!a.focused); + try std.testing.expect(!b.focused); +} + +test "FocusGroup — len" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(4) = .{}; + try std.testing.expectEqual(@as(usize, 0), fg.len()); + + fg.add(&a); + try std.testing.expectEqual(@as(usize, 1), fg.len()); + + fg.add(&b); + try std.testing.expectEqual(@as(usize, 2), fg.len()); +} + +// --------------------------------------------------------------------------- +// handleKey +// --------------------------------------------------------------------------- + +test "FocusGroup — handleKey Tab moves forward" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + const tab = zz.KeyEvent{ .key = .tab, .modifiers = .{} }; + const consumed = fg.handleKey(tab); + try std.testing.expect(consumed); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — handleKey Shift+Tab moves backward" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + fg.focusAt(1); + + const shift_tab = zz.KeyEvent{ .key = .tab, .modifiers = .{ .shift = true } }; + const consumed = fg.handleKey(shift_tab); + try std.testing.expect(consumed); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); +} + +test "FocusGroup — handleKey non-Tab returns false" { + var a = TestItem{}; + + var fg: zz.FocusGroup(1) = .{}; + fg.add(&a); + fg.initFocus(); + + const letter = zz.KeyEvent{ .key = .{ .char = 'x' }, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(letter)); + + const enter = zz.KeyEvent{ .key = .enter, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(enter)); + + const escape = zz.KeyEvent{ .key = .escape, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(escape)); +} + +// --------------------------------------------------------------------------- +// Mixed concrete types in one FocusGroup +// --------------------------------------------------------------------------- + +test "FocusGroup — heterogeneous items" { + const Alpha = struct { + focused: bool = false, + value: u32 = 42, + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + const Beta = struct { + focused: bool = false, + name: []const u8 = "beta", + pub fn focus(self: *@This()) void { + self.focused = true; + } + pub fn blur(self: *@This()) void { + self.focused = false; + } + }; + + var alpha = Alpha{}; + var beta = Beta{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&alpha); + fg.add(&beta); + fg.initFocus(); + + try std.testing.expect(alpha.focused); + try std.testing.expect(!beta.focused); + + fg.focusNext(); + try std.testing.expect(!alpha.focused); + try std.testing.expect(beta.focused); + + // Original data preserved + try std.testing.expectEqual(@as(u32, 42), alpha.value); + try std.testing.expectEqualStrings("beta", beta.name); +} From 400f809b109397260872cf8da596c04a37c7916b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Wed, 4 Mar 2026 13:36:55 +0100 Subject: [PATCH 2/2] feat: add customizable key bindings for FocusGroup navigation --- README.md | 31 ++++++- src/components/focus.zig | 113 ++++++++++++++++++++++++-- src/root.zig | 1 + tests/focus_tests.zig | 171 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 298fa3e..8fd10d3 100644 --- a/README.md +++ b/README.md @@ -505,12 +505,37 @@ const Model = struct { Any component with `focused: bool`, `focus()`, and `blur()` methods works with `FocusGroup`. Built-in focusable components: TextInput, TextArea, Table, List, Confirm, FilePicker. -Additional API: +#### Custom navigation keys + +By default Tab moves forward and Shift+Tab moves backward. Add or replace bindings freely: + +```zig +// Add arrow keys and vim j/k alongside the default Tab +fg.addNextKey(.{ .key = .down }); // Down arrow +fg.addNextKey(.{ .key = .{ .char = 'j' } }); // vim j +fg.addPrevKey(.{ .key = .up }); // Up arrow +fg.addPrevKey(.{ .key = .{ .char = 'k' } }); // vim k + +// Or replace defaults entirely +fg.setNextKey(.{ .key = .down }); // Down only, Tab no longer works +fg.setPrevKey(.{ .key = .up }); // Up only + +// Clear all bindings (manual-only via focusNext/focusPrev) +fg.clearNextKeys(); +fg.clearPrevKeys(); + +// Modifier keys work too +fg.addNextKey(.{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true } }); // Ctrl+N +``` + +Up to 4 bindings per direction. Modifier matching is exact (Ctrl+Tab won't match a plain Tab binding). + +#### Additional API ```zig fg.focusAt(2); // Focus specific index -fg.focusNext(); // Manual next (same as Tab) -fg.focusPrev(); // Manual prev (same as Shift+Tab) +fg.focusNext(); // Manual next +fg.focusPrev(); // Manual prev fg.blurAll(); // Remove focus from all fg.focused(); // Get current index fg.isFocused(1); // Check if index is focused diff --git a/src/components/focus.zig b/src/components/focus.zig index e92fa36..bc8fb49 100644 --- a/src/components/focus.zig +++ b/src/components/focus.zig @@ -23,12 +23,46 @@ pub fn isFocusable(comptime T: type) bool { @hasDecl(T, "blur"); } -/// A group of focusable components with Tab/Shift+Tab cycling. +/// Maximum number of key bindings per action (next / prev). +const max_binds = 4; + +/// A key binding slot: a key plus optional modifiers. +pub const KeyBind = struct { + key: keys.Key, + modifiers: keys.Modifiers = .{}, + + /// Check if a KeyEvent matches this binding. + pub fn matches(self: KeyBind, event: keys.KeyEvent) bool { + return self.key.eql(event.key) and self.modifiers.eql(event.modifiers); + } +}; + +/// Default "next" bindings: Tab (no modifiers), Down arrow, 'j'. +pub const default_next_keys = [max_binds]?KeyBind{ + .{ .key = .tab }, + null, + null, + null, +}; + +/// Default "prev" bindings: Shift+Tab, Up arrow, 'k'. +pub const default_prev_keys = [max_binds]?KeyBind{ + .{ .key = .tab, .modifiers = .{ .shift = true } }, + null, + null, + null, +}; + +/// A group of focusable components with customizable key-driven cycling. /// /// `max_items` is the maximum number of components that can be registered. /// The group uses a fixed-size array so it requires no allocation and can /// be embedded directly in a Model struct. /// +/// By default, Tab moves forward and Shift+Tab moves backward. +/// Override `next_keys` / `prev_keys` or call `addNextKey()` / `addPrevKey()` +/// to use any keys you want (arrows, vim j/k, etc.). +/// /// Usage: /// ``` /// var fg: FocusGroup(3) = .{}; @@ -36,6 +70,12 @@ pub fn isFocusable(comptime T: type) bool { /// fg.add(&self.input_b); /// fg.add(&self.table); /// fg.initFocus(); // focus first, blur the rest +/// +/// // Optional: add extra navigation keys +/// fg.addNextKey(.{ .key = .down }); // Down arrow +/// fg.addNextKey(.{ .key = .{ .char = 'j' } }); // vim j +/// fg.addPrevKey(.{ .key = .up }); // Up arrow +/// fg.addPrevKey(.{ .key = .{ .char = 'k' } }); // vim k /// ``` pub fn FocusGroup(comptime max_items: usize) type { return struct { @@ -54,6 +94,10 @@ pub fn FocusGroup(comptime max_items: usize) type { active: usize = 0, /// Whether cycling wraps from last to first (and vice versa). wrap: bool = true, + /// Key bindings that trigger focusNext(). Up to 4 bindings. + next_keys: [max_binds]?KeyBind = default_next_keys, + /// Key bindings that trigger focusPrev(). Up to 4 bindings. + prev_keys: [max_binds]?KeyBind = default_prev_keys, /// Register a focusable component. /// @@ -143,20 +187,73 @@ pub fn FocusGroup(comptime max_items: usize) type { /// Handle a key event for focus cycling. /// - /// Returns `true` if the key was consumed (Tab or Shift+Tab), + /// Checks the event against `next_keys` and `prev_keys`. + /// Returns `true` if the key was consumed (matched a binding), /// `false` if it should be forwarded to the active component. pub fn handleKey(self: *Self, key: keys.KeyEvent) bool { - if (key.key == .tab) { - if (key.modifiers.shift) { - self.focusPrev(); - } else { - self.focusNext(); + for (self.next_keys) |maybe_bind| { + if (maybe_bind) |bind| { + if (bind.matches(key)) { + self.focusNext(); + return true; + } + } + } + for (self.prev_keys) |maybe_bind| { + if (maybe_bind) |bind| { + if (bind.matches(key)) { + self.focusPrev(); + return true; + } } - return true; } return false; } + /// Add an extra key binding for "focus next". + /// Returns false if all 4 slots are full. + pub fn addNextKey(self: *Self, bind: KeyBind) bool { + for (&self.next_keys) |*slot| { + if (slot.* == null) { + slot.* = bind; + return true; + } + } + return false; + } + + /// Add an extra key binding for "focus prev". + /// Returns false if all 4 slots are full. + pub fn addPrevKey(self: *Self, bind: KeyBind) bool { + for (&self.prev_keys) |*slot| { + if (slot.* == null) { + slot.* = bind; + return true; + } + } + return false; + } + + /// Replace all "next" bindings with a single key. + pub fn setNextKey(self: *Self, bind: KeyBind) void { + self.next_keys = .{ bind, null, null, null }; + } + + /// Replace all "prev" bindings with a single key. + pub fn setPrevKey(self: *Self, bind: KeyBind) void { + self.prev_keys = .{ bind, null, null, null }; + } + + /// Clear all "next" key bindings. + pub fn clearNextKeys(self: *Self) void { + self.next_keys = .{ null, null, null, null }; + } + + /// Clear all "prev" key bindings. + pub fn clearPrevKeys(self: *Self) void { + self.prev_keys = .{ null, null, null, null }; + } + /// Get the index of the currently focused item. pub fn focused(self: *const Self) usize { return self.active; diff --git a/src/root.zig b/src/root.zig index ee93eeb..4261d23 100644 --- a/src/root.zig +++ b/src/root.zig @@ -134,6 +134,7 @@ pub const Confirm = components.Confirm; // Focus management pub const FocusGroup = components.focus.FocusGroup; pub const FocusStyle = components.focus.FocusStyle; +pub const KeyBind = components.focus.KeyBind; pub const isFocusable = components.focus.isFocusable; // Keybinding management diff --git a/tests/focus_tests.zig b/tests/focus_tests.zig index 21906c4..a8fd950 100644 --- a/tests/focus_tests.zig +++ b/tests/focus_tests.zig @@ -266,6 +266,177 @@ test "FocusGroup — handleKey non-Tab returns false" { try std.testing.expect(!fg.handleKey(escape)); } +// --------------------------------------------------------------------------- +// Custom key bindings +// --------------------------------------------------------------------------- + +test "FocusGroup — addNextKey with arrow keys" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // Down arrow should not work by default + const down = zz.KeyEvent{ .key = .down, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(down)); + + // Add Down as next key + try std.testing.expect(fg.addNextKey(.{ .key = .down })); + + // Now it should work + try std.testing.expect(fg.handleKey(down)); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — addPrevKey with arrow keys" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + fg.focusAt(1); + + // Up arrow should not work by default + const up = zz.KeyEvent{ .key = .up, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(up)); + + // Add Up as prev key + try std.testing.expect(fg.addPrevKey(.{ .key = .up })); + + // Now it should work + try std.testing.expect(fg.handleKey(up)); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); +} + +test "FocusGroup — vim j/k keys" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + _ = fg.addNextKey(.{ .key = .{ .char = 'j' } }); + _ = fg.addPrevKey(.{ .key = .{ .char = 'k' } }); + + // j moves forward + const j = zz.KeyEvent{ .key = .{ .char = 'j' }, .modifiers = .{} }; + try std.testing.expect(fg.handleKey(j)); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); + + // k moves backward + const k_key = zz.KeyEvent{ .key = .{ .char = 'k' }, .modifiers = .{} }; + try std.testing.expect(fg.handleKey(k_key)); + try std.testing.expectEqual(@as(usize, 0), fg.focused()); + + // Tab still works (default binding preserved) + const tab = zz.KeyEvent{ .key = .tab, .modifiers = .{} }; + try std.testing.expect(fg.handleKey(tab)); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — setNextKey replaces defaults" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // Replace Tab with Down arrow only + fg.setNextKey(.{ .key = .down }); + + // Tab should no longer work + const tab = zz.KeyEvent{ .key = .tab, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(tab)); + + // Down should work + const down = zz.KeyEvent{ .key = .down, .modifiers = .{} }; + try std.testing.expect(fg.handleKey(down)); + try std.testing.expectEqual(@as(usize, 1), fg.focused()); +} + +test "FocusGroup — clearNextKeys removes all bindings" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + fg.clearNextKeys(); + + // Tab no longer works + const tab = zz.KeyEvent{ .key = .tab, .modifiers = .{} }; + try std.testing.expect(!fg.handleKey(tab)); +} + +test "FocusGroup — modifier matching is exact" { + var a = TestItem{}; + var b = TestItem{}; + + var fg: zz.FocusGroup(2) = .{}; + fg.add(&a); + fg.add(&b); + fg.initFocus(); + + // Ctrl+Tab should NOT match default Tab binding + const ctrl_tab = zz.KeyEvent{ .key = .tab, .modifiers = .{ .ctrl = true } }; + try std.testing.expect(!fg.handleKey(ctrl_tab)); + + // Alt+Tab should NOT match + const alt_tab = zz.KeyEvent{ .key = .tab, .modifiers = .{ .alt = true } }; + try std.testing.expect(!fg.handleKey(alt_tab)); + + // Plain Tab still works + const tab = zz.KeyEvent{ .key = .tab, .modifiers = .{} }; + try std.testing.expect(fg.handleKey(tab)); +} + +test "FocusGroup — addNextKey returns false when full" { + var a = TestItem{}; + + var fg: zz.FocusGroup(1) = .{}; + fg.add(&a); + fg.initFocus(); + + // Slot 0: Tab (default), fill remaining 3 slots + try std.testing.expect(fg.addNextKey(.{ .key = .down })); + try std.testing.expect(fg.addNextKey(.{ .key = .right })); + try std.testing.expect(fg.addNextKey(.{ .key = .{ .char = 'j' } })); + + // 5th should fail (4 slots max) + try std.testing.expect(!fg.addNextKey(.{ .key = .{ .char = 'n' } })); +} + +test "KeyBind — matches with modifiers" { + const bind = zz.KeyBind{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true } }; + + // Exact match + const match = zz.KeyEvent{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true } }; + try std.testing.expect(bind.matches(match)); + + // Missing modifier + const no_mod = zz.KeyEvent{ .key = .{ .char = 'n' }, .modifiers = .{} }; + try std.testing.expect(!bind.matches(no_mod)); + + // Extra modifier + const extra = zz.KeyEvent{ .key = .{ .char = 'n' }, .modifiers = .{ .ctrl = true, .shift = true } }; + try std.testing.expect(!bind.matches(extra)); + + // Wrong key + const wrong = zz.KeyEvent{ .key = .{ .char = 'x' }, .modifiers = .{ .ctrl = true } }; + try std.testing.expect(!bind.matches(wrong)); +} + // --------------------------------------------------------------------------- // Mixed concrete types in one FocusGroup // ---------------------------------------------------------------------------