Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -453,6 +454,104 @@ 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.

#### 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
fg.focusPrev(); // Manual prev
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:
Expand Down Expand Up @@ -753,6 +852,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
Expand Down
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub fn build(b: *std.Build) void {
"file_browser",
"dashboard",
"showcase",
"focus_form",
};

for (examples) |example_name| {
Expand Down Expand Up @@ -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");
Expand Down
226 changes: 226 additions & 0 deletions examples/focus_form.zig
Original file line number Diff line number Diff line change
@@ -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();
}
Loading