diff --git a/src/focus.zig b/src/focus.zig new file mode 100644 index 0000000..d385618 --- /dev/null +++ b/src/focus.zig @@ -0,0 +1,800 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Focus direction for navigation +pub const FocusDirection = enum { + next, + prev, + up, + down, + left, + right, +}; + +/// Focus event that widgets can respond to +pub const FocusEvent = enum { + gained, + lost, +}; + +/// Focusable widget interface +pub const Focusable = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Called when focus changes + onFocusChange: ?*const fn (*anyopaque, FocusEvent) void = null, + /// Check if widget can receive focus + canFocus: *const fn (*anyopaque) bool, + /// Get widget ID for identification + getId: *const fn (*anyopaque) []const u8, + /// Get focus order (lower = earlier in tab order) + getOrder: ?*const fn (*anyopaque) i32 = null, + /// Get widget group for grouped navigation + getGroup: ?*const fn (*anyopaque) ?[]const u8 = null, + }; + + pub fn onFocusChange(self: Focusable, event: FocusEvent) void { + if (self.vtable.onFocusChange) |func| { + func(self.ptr, event); + } + } + + pub fn canFocus(self: Focusable) bool { + return self.vtable.canFocus(self.ptr); + } + + pub fn getId(self: Focusable) []const u8 { + return self.vtable.getId(self.ptr); + } + + pub fn getOrder(self: Focusable) i32 { + if (self.vtable.getOrder) |func| { + return func(self.ptr); + } + return 0; + } + + pub fn getGroup(self: Focusable) ?[]const u8 { + if (self.vtable.getGroup) |func| { + return func(self.ptr); + } + return null; + } +}; + +/// Focus ring for managing focus within a group of widgets +pub const FocusRing = struct { + widgets: [32]?FocusEntry = [_]?FocusEntry{null} ** 32, + count: usize = 0, + focused_index: ?usize = null, + wrap: bool = true, + + const FocusEntry = struct { + focusable: Focusable, + order: i32, + }; + + /// Add a focusable widget to the ring + pub fn add(self: *FocusRing, focusable: Focusable) bool { + if (self.count >= 32) return false; + + self.widgets[self.count] = .{ + .focusable = focusable, + .order = focusable.getOrder(), + }; + self.count += 1; + + // Sort by order + self.sort(); + + return true; + } + + /// Remove a widget by ID + pub fn remove(self: *FocusRing, id: []const u8) bool { + var i: usize = 0; + while (i < self.count) { + if (self.widgets[i]) |entry| { + if (std.mem.eql(u8, entry.focusable.getId(), id)) { + // Shift remaining + var j = i; + while (j < self.count - 1) : (j += 1) { + self.widgets[j] = self.widgets[j + 1]; + } + self.widgets[self.count - 1] = null; + self.count -= 1; + + // Adjust focused index + if (self.focused_index) |fi| { + if (fi == i) { + self.focused_index = if (self.count > 0) @min(fi, self.count - 1) else null; + } else if (fi > i) { + self.focused_index = fi - 1; + } + } + return true; + } + } + i += 1; + } + return false; + } + + /// Sort widgets by order + fn sort(self: *FocusRing) void { + if (self.count <= 1) return; + + // Simple insertion sort (small array) + var i: usize = 1; + while (i < self.count) : (i += 1) { + const current = self.widgets[i]; + var j = i; + while (j > 0) { + if (self.widgets[j - 1]) |prev| { + if (current) |curr| { + if (prev.order > curr.order) { + self.widgets[j] = self.widgets[j - 1]; + j -= 1; + continue; + } + } + } + break; + } + self.widgets[j] = current; + } + } + + /// Focus the next widget + pub fn focusNext(self: *FocusRing) ?[]const u8 { + return self.moveFocus(.next); + } + + /// Focus the previous widget + pub fn focusPrev(self: *FocusRing) ?[]const u8 { + return self.moveFocus(.prev); + } + + /// Move focus in a direction + pub fn moveFocus(self: *FocusRing, direction: FocusDirection) ?[]const u8 { + if (self.count == 0) return null; + + const old_index = self.focused_index; + + // Notify old widget of focus loss + if (old_index) |idx| { + if (self.widgets[idx]) |entry| { + entry.focusable.onFocusChange(.lost); + } + } + + // Find next focusable widget + const start = old_index orelse 0; + var attempts: usize = 0; + var current = start; + + while (attempts < self.count) : (attempts += 1) { + current = switch (direction) { + .next, .down, .right => blk: { + if (current + 1 >= self.count) { + break :blk if (self.wrap) 0 else current; + } + break :blk current + 1; + }, + .prev, .up, .left => blk: { + if (current == 0) { + break :blk if (self.wrap) self.count - 1 else 0; + } + break :blk current - 1; + }, + }; + + if (self.widgets[current]) |entry| { + if (entry.focusable.canFocus()) { + self.focused_index = current; + entry.focusable.onFocusChange(.gained); + return entry.focusable.getId(); + } + } + } + + return null; + } + + /// Focus a specific widget by ID + pub fn focusById(self: *FocusRing, id: []const u8) bool { + // Notify old widget of focus loss + if (self.focused_index) |idx| { + if (self.widgets[idx]) |entry| { + entry.focusable.onFocusChange(.lost); + } + } + + for (self.widgets[0..self.count], 0..) |maybe_entry, i| { + if (maybe_entry) |entry| { + if (std.mem.eql(u8, entry.focusable.getId(), id)) { + if (entry.focusable.canFocus()) { + self.focused_index = i; + entry.focusable.onFocusChange(.gained); + return true; + } + } + } + } + return false; + } + + /// Focus the first widget + pub fn focusFirst(self: *FocusRing) ?[]const u8 { + if (self.count == 0) return null; + + // Notify old widget of focus loss + if (self.focused_index) |idx| { + if (self.widgets[idx]) |entry| { + entry.focusable.onFocusChange(.lost); + } + } + + for (self.widgets[0..self.count], 0..) |maybe_entry, i| { + if (maybe_entry) |entry| { + if (entry.focusable.canFocus()) { + self.focused_index = i; + entry.focusable.onFocusChange(.gained); + return entry.focusable.getId(); + } + } + } + return null; + } + + /// Focus the last widget + pub fn focusLast(self: *FocusRing) ?[]const u8 { + if (self.count == 0) return null; + + // Notify old widget of focus loss + if (self.focused_index) |idx| { + if (self.widgets[idx]) |entry| { + entry.focusable.onFocusChange(.lost); + } + } + + var i = self.count; + while (i > 0) { + i -= 1; + if (self.widgets[i]) |entry| { + if (entry.focusable.canFocus()) { + self.focused_index = i; + entry.focusable.onFocusChange(.gained); + return entry.focusable.getId(); + } + } + } + return null; + } + + /// Get currently focused widget ID + pub fn getFocusedId(self: *const FocusRing) ?[]const u8 { + if (self.focused_index) |idx| { + if (self.widgets[idx]) |entry| { + return entry.focusable.getId(); + } + } + return null; + } + + /// Check if a widget is focused + pub fn isFocused(self: *const FocusRing, id: []const u8) bool { + if (self.getFocusedId()) |focused_id| { + return std.mem.eql(u8, focused_id, id); + } + return false; + } + + /// Clear focus + pub fn clearFocus(self: *FocusRing) void { + if (self.focused_index) |idx| { + if (self.widgets[idx]) |entry| { + entry.focusable.onFocusChange(.lost); + } + } + self.focused_index = null; + } + + /// Clear all widgets + pub fn clear(self: *FocusRing) void { + self.clearFocus(); + self.widgets = [_]?FocusEntry{null} ** 32; + self.count = 0; + } + + /// Set wrap behavior + pub fn setWrap(self: *FocusRing, wrap: bool) *FocusRing { + self.wrap = wrap; + return self; + } +}; + +/// Global focus manager for multiple focus rings (e.g., different panels) +pub const FocusManager = struct { + rings: [8]?FocusRingEntry = [_]?FocusRingEntry{null} ** 8, + ring_count: usize = 0, + active_ring: ?usize = null, + trap_focus: bool = false, // When true, focus stays within active ring + + const FocusRingEntry = struct { + ring: FocusRing, + name: []const u8, + enabled: bool, + }; + + /// Create a new focus ring with a name + pub fn createRing(self: *FocusManager, name: []const u8) ?*FocusRing { + if (self.ring_count >= 8) return null; + + self.rings[self.ring_count] = .{ + .ring = FocusRing{}, + .name = name, + .enabled = true, + }; + const idx = self.ring_count; + self.ring_count += 1; + + if (self.active_ring == null) { + self.active_ring = idx; + } + + return &self.rings[idx].?.ring; + } + + /// Get a ring by name + pub fn getRing(self: *FocusManager, name: []const u8) ?*FocusRing { + for (&self.rings) |*maybe_entry| { + if (maybe_entry.*) |*entry| { + if (std.mem.eql(u8, entry.name, name)) { + return &entry.ring; + } + } + } + return null; + } + + /// Get the active ring + pub fn getActiveRing(self: *FocusManager) ?*FocusRing { + if (self.active_ring) |idx| { + if (self.rings[idx]) |*entry| { + return &entry.ring; + } + } + return null; + } + + /// Set the active ring by name + pub fn setActiveRing(self: *FocusManager, name: []const u8) bool { + for (self.rings[0..self.ring_count], 0..) |maybe_entry, i| { + if (maybe_entry) |entry| { + if (std.mem.eql(u8, entry.name, name)) { + // Clear focus in old ring + if (self.active_ring) |old_idx| { + if (self.rings[old_idx]) |*old_entry| { + old_entry.ring.clearFocus(); + } + } + self.active_ring = i; + return true; + } + } + } + return false; + } + + /// Enable/disable a ring + pub fn setRingEnabled(self: *FocusManager, name: []const u8, enabled: bool) bool { + for (&self.rings) |*maybe_entry| { + if (maybe_entry.*) |*entry| { + if (std.mem.eql(u8, entry.name, name)) { + entry.enabled = enabled; + return true; + } + } + } + return false; + } + + /// Focus next in active ring + pub fn focusNext(self: *FocusManager) ?[]const u8 { + if (self.getActiveRing()) |ring| { + return ring.focusNext(); + } + return null; + } + + /// Focus previous in active ring + pub fn focusPrev(self: *FocusManager) ?[]const u8 { + if (self.getActiveRing()) |ring| { + return ring.focusPrev(); + } + return null; + } + + /// Move focus in active ring + pub fn moveFocus(self: *FocusManager, direction: FocusDirection) ?[]const u8 { + if (self.getActiveRing()) |ring| { + return ring.moveFocus(direction); + } + return null; + } + + /// Focus by ID in active ring + pub fn focusById(self: *FocusManager, id: []const u8) bool { + if (self.getActiveRing()) |ring| { + return ring.focusById(id); + } + return false; + } + + /// Focus first widget in active ring + pub fn focusFirst(self: *FocusManager) ?[]const u8 { + if (self.getActiveRing()) |ring| { + return ring.focusFirst(); + } + return null; + } + + /// Focus last widget in active ring + pub fn focusLast(self: *FocusManager) ?[]const u8 { + if (self.getActiveRing()) |ring| { + return ring.focusLast(); + } + return null; + } + + /// Get focused ID in active ring + pub fn getFocusedId(self: *const FocusManager) ?[]const u8 { + if (self.active_ring) |idx| { + if (self.rings[idx]) |entry| { + return entry.ring.getFocusedId(); + } + } + return null; + } + + /// Check if ID is focused in active ring + pub fn isFocused(self: *const FocusManager, id: []const u8) bool { + if (self.active_ring) |idx| { + if (self.rings[idx]) |entry| { + return entry.ring.isFocused(id); + } + } + return false; + } + + /// Switch to next ring + pub fn nextRing(self: *FocusManager) ?[]const u8 { + if (self.trap_focus) return null; + if (self.ring_count == 0) return null; + + // Clear focus in current ring + if (self.getActiveRing()) |ring| { + ring.clearFocus(); + } + + const start = self.active_ring orelse 0; + var current = start; + var attempts: usize = 0; + + while (attempts < self.ring_count) : (attempts += 1) { + current = if (current + 1 >= self.ring_count) 0 else current + 1; + + if (self.rings[current]) |entry| { + if (entry.enabled) { + self.active_ring = current; + return entry.name; + } + } + } + return null; + } + + /// Switch to previous ring + pub fn prevRing(self: *FocusManager) ?[]const u8 { + if (self.trap_focus) return null; + if (self.ring_count == 0) return null; + + // Clear focus in current ring + if (self.getActiveRing()) |ring| { + ring.clearFocus(); + } + + const start = self.active_ring orelse 0; + var current = start; + var attempts: usize = 0; + + while (attempts < self.ring_count) : (attempts += 1) { + current = if (current == 0) self.ring_count - 1 else current - 1; + + if (self.rings[current]) |entry| { + if (entry.enabled) { + self.active_ring = current; + return entry.name; + } + } + } + return null; + } + + /// Trap focus within current ring (for modals) + pub fn setTrapFocus(self: *FocusManager, trap: bool) void { + self.trap_focus = trap; + } + + /// Clear all + pub fn clear(self: *FocusManager) void { + for (&self.rings) |*maybe_entry| { + if (maybe_entry.*) |*entry| { + entry.ring.clear(); + } + maybe_entry.* = null; + } + self.ring_count = 0; + self.active_ring = null; + self.trap_focus = false; + } +}; + +/// Create a basic Focusable vtable for simple widgets. +/// Use this as a template for creating your own Focusable implementations. +/// +/// Example: +/// ```zig +/// const MyWidget = struct { +/// id: []const u8, +/// disabled: bool = false, +/// +/// const Self = @This(); +/// +/// fn canFocus(ptr: *anyopaque) bool { +/// const self: *Self = @ptrCast(@alignCast(ptr)); +/// return !self.disabled; +/// } +/// +/// fn getId(ptr: *anyopaque) []const u8 { +/// const self: *Self = @ptrCast(@alignCast(ptr)); +/// return self.id; +/// } +/// +/// const vtable = Focusable.VTable{ +/// .canFocus = canFocus, +/// .getId = getId, +/// }; +/// +/// pub fn focusable(self: *Self) Focusable { +/// return .{ .ptr = self, .vtable = &vtable }; +/// } +/// }; +/// ``` +pub const SimpleFocusable = struct { + /// Default canFocus that always returns true + pub fn alwaysCanFocus(_: *anyopaque) bool { + return true; + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "FocusRing basic operations" { + const TestWidget = struct { + id: []const u8, + disabled: bool = false, + focused: bool = false, + + const Self = @This(); + + fn handleFocusChange(ptr: *anyopaque, event: FocusEvent) void { + const self: *Self = @ptrCast(@alignCast(ptr)); + self.focused = (event == .gained); + } + + fn canFocus(ptr: *anyopaque) bool { + const self: *Self = @ptrCast(@alignCast(ptr)); + return !self.disabled; + } + + fn getId(ptr: *anyopaque) []const u8 { + const self: *Self = @ptrCast(@alignCast(ptr)); + return self.id; + } + + const vtable = Focusable.VTable{ + .canFocus = canFocus, + .getId = getId, + .onFocusChange = handleFocusChange, + }; + + fn focusable(self: *Self) Focusable { + return .{ .ptr = self, .vtable = &vtable }; + } + }; + + var widget1 = TestWidget{ .id = "btn1" }; + var widget2 = TestWidget{ .id = "btn2" }; + var widget3 = TestWidget{ .id = "btn3", .disabled = true }; + + var ring = FocusRing{}; + try std.testing.expect(ring.add(widget1.focusable())); + try std.testing.expect(ring.add(widget2.focusable())); + try std.testing.expect(ring.add(widget3.focusable())); + + try std.testing.expectEqual(@as(usize, 3), ring.count); + + // Focus first + const first = ring.focusFirst(); + try std.testing.expectEqualStrings("btn1", first.?); + try std.testing.expect(widget1.focused); + + // Focus next + const next = ring.focusNext(); + try std.testing.expectEqualStrings("btn2", next.?); + try std.testing.expect(!widget1.focused); + try std.testing.expect(widget2.focused); + + // Focus next should skip disabled widget3 and wrap to widget1 + const wrapped = ring.focusNext(); + try std.testing.expectEqualStrings("btn1", wrapped.?); +} + +test "FocusRing ordering" { + const TestWidget = struct { + id: []const u8, + focus_order: i32, + + const Self = @This(); + + fn canFocus(_: *anyopaque) bool { + return true; + } + + fn getId(ptr: *anyopaque) []const u8 { + const self: *Self = @ptrCast(@alignCast(ptr)); + return self.id; + } + + fn getOrder(ptr: *anyopaque) i32 { + const self: *Self = @ptrCast(@alignCast(ptr)); + return self.focus_order; + } + + const vtable = Focusable.VTable{ + .canFocus = canFocus, + .getId = getId, + .getOrder = getOrder, + }; + + fn focusable(self: *Self) Focusable { + return .{ .ptr = self, .vtable = &vtable }; + } + }; + + var widget1 = TestWidget{ .id = "third", .focus_order = 30 }; + var widget2 = TestWidget{ .id = "first", .focus_order = 10 }; + var widget3 = TestWidget{ .id = "second", .focus_order = 20 }; + + var ring = FocusRing{}; + _ = ring.add(widget1.focusable()); + _ = ring.add(widget2.focusable()); + _ = ring.add(widget3.focusable()); + + // Should be sorted by order + const first = ring.focusFirst(); + try std.testing.expectEqualStrings("first", first.?); + + const second = ring.focusNext(); + try std.testing.expectEqualStrings("second", second.?); + + const third = ring.focusNext(); + try std.testing.expectEqualStrings("third", third.?); +} + +test "FocusManager multiple rings" { + const TestWidget = struct { + id: []const u8, + + const Self = @This(); + + fn canFocus(_: *anyopaque) bool { + return true; + } + + fn getId(ptr: *anyopaque) []const u8 { + const self: *Self = @ptrCast(@alignCast(ptr)); + return self.id; + } + + const vtable = Focusable.VTable{ + .canFocus = canFocus, + .getId = getId, + }; + + fn focusable(self: *Self) Focusable { + return .{ .ptr = self, .vtable = &vtable }; + } + }; + + var manager = FocusManager{}; + + // Create rings + const main_ring = manager.createRing("main"); + const modal_ring = manager.createRing("modal"); + + try std.testing.expect(main_ring != null); + try std.testing.expect(modal_ring != null); + + // Add widgets + var btn1 = TestWidget{ .id = "main_btn1" }; + var btn2 = TestWidget{ .id = "main_btn2" }; + var ok = TestWidget{ .id = "modal_ok" }; + var cancel = TestWidget{ .id = "modal_cancel" }; + + _ = main_ring.?.add(btn1.focusable()); + _ = main_ring.?.add(btn2.focusable()); + _ = modal_ring.?.add(ok.focusable()); + _ = modal_ring.?.add(cancel.focusable()); + + // Focus first in main ring (focusNext from null skips first) + _ = manager.focusFirst(); + try std.testing.expectEqualStrings("main_btn1", manager.getFocusedId().?); + + // Switch to modal ring + _ = manager.setActiveRing("modal"); + _ = manager.focusFirst(); + try std.testing.expectEqualStrings("modal_ok", manager.getFocusedId().?); + + // Trap focus + manager.setTrapFocus(true); + try std.testing.expectEqual(@as(?[]const u8, null), manager.nextRing()); +} + +test "FocusRing remove" { + const TestWidget = struct { + id: []const u8, + + const Self = @This(); + + fn canFocus(_: *anyopaque) bool { + return true; + } + + fn getId(ptr: *anyopaque) []const u8 { + const self: *Self = @ptrCast(@alignCast(ptr)); + return self.id; + } + + const vtable = Focusable.VTable{ + .canFocus = canFocus, + .getId = getId, + }; + + fn focusable(self: *Self) Focusable { + return .{ .ptr = self, .vtable = &vtable }; + } + }; + + var widget1 = TestWidget{ .id = "a" }; + var widget2 = TestWidget{ .id = "b" }; + var widget3 = TestWidget{ .id = "c" }; + + var ring = FocusRing{}; + _ = ring.add(widget1.focusable()); + _ = ring.add(widget2.focusable()); + _ = ring.add(widget3.focusable()); + + _ = ring.focusById("b"); + try std.testing.expectEqualStrings("b", ring.getFocusedId().?); + + // Remove focused widget + try std.testing.expect(ring.remove("b")); + try std.testing.expectEqual(@as(usize, 2), ring.count); +} diff --git a/src/root.zig b/src/root.zig index ba91f29..b6732e1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -269,6 +269,19 @@ pub const Throttle = lazy.Throttle; pub const Debounce = lazy.Debounce; pub const DeferredRender = lazy.DeferredRender; +// Focus management +pub const focus = @import("focus.zig"); +pub const FocusRing = focus.FocusRing; +pub const FocusManager = focus.FocusManager; +pub const FocusDirection = focus.FocusDirection; +pub const FocusEvent = focus.FocusEvent; +pub const Focusable = focus.Focusable; +pub const SimpleFocusable = focus.SimpleFocusable; + +// Theme system +pub const theme = @import("theme.zig"); +pub const Theme = theme.Theme; + // ============================================================================ // Tests // ============================================================================ @@ -285,4 +298,9 @@ test { _ = @import("event/reader.zig"); _ = @import("event/parse.zig"); _ = @import("cursor.zig"); + _ = @import("focus.zig"); + _ = @import("theme.zig"); + + // Comprehensive test suite + _ = @import("tests/tests.zig"); } diff --git a/src/symbols/line.zig b/src/symbols/line.zig index 2a9c7d7..8cdfadd 100644 --- a/src/symbols/line.zig +++ b/src/symbols/line.zig @@ -283,7 +283,10 @@ test "line set default" { test "line characters are valid UTF-8" { // Verify all characters decode properly - _ = std.unicode.utf8Decode(VERTICAL[0..3].*) catch unreachable; - _ = std.unicode.utf8Decode(HORIZONTAL[0..3].*) catch unreachable; - _ = std.unicode.utf8Decode(TOP_LEFT[0..3].*) catch unreachable; + const v: [3]u8 = VERTICAL[0..3].*; + const h: [3]u8 = HORIZONTAL[0..3].*; + const tl: [3]u8 = TOP_LEFT[0..3].*; + _ = std.unicode.utf8Decode(&v) catch unreachable; + _ = std.unicode.utf8Decode(&h) catch unreachable; + _ = std.unicode.utf8Decode(&tl) catch unreachable; } diff --git a/src/tests/layout_tests.zig b/src/tests/layout_tests.zig new file mode 100644 index 0000000..706a00e --- /dev/null +++ b/src/tests/layout_tests.zig @@ -0,0 +1,126 @@ +//! Tests for the layout system + +const std = @import("std"); +const testing = std.testing; + +const layout_mod = @import("../layout.zig"); +const Layout = layout_mod.Layout; +const Constraint = layout_mod.Constraint; +const Direction = layout_mod.Direction; +const Rect = @import("../buffer.zig").Rect; + +// ============================================================================ +// Layout Tests +// ============================================================================ + +test "Layout vertical split equal" { + const area = Rect.init(0, 0, 100, 60); + const result = Layout.vertical(&.{ + Constraint.percentage(50), + Constraint.percentage(50), + }).split(area); + + const first = result.get(0); + const second = result.get(1); + + try testing.expectEqual(@as(u16, 0), first.y); + try testing.expectEqual(@as(u16, 30), first.height); + + try testing.expectEqual(@as(u16, 30), second.y); + try testing.expectEqual(@as(u16, 30), second.height); +} + +test "Layout horizontal split equal" { + const area = Rect.init(0, 0, 100, 60); + const result = Layout.horizontal(&.{ + Constraint.percentage(50), + Constraint.percentage(50), + }).split(area); + + const first = result.get(0); + const second = result.get(1); + + try testing.expectEqual(@as(u16, 0), first.x); + try testing.expectEqual(@as(u16, 50), first.width); + + try testing.expectEqual(@as(u16, 50), second.x); + try testing.expectEqual(@as(u16, 50), second.width); +} + +test "Layout with fixed length" { + const area = Rect.init(0, 0, 100, 60); + const result = Layout.vertical(&.{ + Constraint.length(10), + Constraint.min(0), + }).split(area); + + const first = result.get(0); + const second = result.get(1); + + try testing.expectEqual(@as(u16, 10), first.height); + try testing.expectEqual(@as(u16, 50), second.height); +} + +test "Layout with min constraint" { + const area = Rect.init(0, 0, 100, 60); + const result = Layout.vertical(&.{ + Constraint.min(20), + Constraint.min(20), + }).split(area); + + const first = result.get(0); + const second = result.get(1); + + try testing.expect(first.height >= 20); + try testing.expect(second.height >= 20); +} + +test "Layout with three way split" { + const area = Rect.init(0, 0, 90, 60); + const result = Layout.horizontal(&.{ + Constraint.ratio(1, 3), + Constraint.ratio(1, 3), + Constraint.ratio(1, 3), + }).split(area); + + const first = result.get(0); + const second = result.get(1); + const third = result.get(2); + + try testing.expectEqual(@as(u16, 30), first.width); + try testing.expectEqual(@as(u16, 30), second.width); + try testing.expectEqual(@as(u16, 30), third.width); +} + +test "Layout preserves area position" { + const area = Rect.init(10, 20, 80, 40); + const result = Layout.vertical(&.{ + Constraint.percentage(50), + Constraint.percentage(50), + }).split(area); + + const first = result.get(0); + try testing.expectEqual(@as(u16, 10), first.x); + try testing.expectEqual(@as(u16, 20), first.y); +} + +test "Layout single constraint" { + const area = Rect.init(0, 0, 100, 50); + const result = Layout.vertical(&.{ + Constraint.percentage(100), + }).split(area); + + const first = result.get(0); + try testing.expectEqual(@as(u16, 100), first.width); + try testing.expectEqual(@as(u16, 50), first.height); +} + +// ============================================================================ +// Direction Tests +// ============================================================================ + +test "Direction enum values" { + try testing.expectEqual(Direction.horizontal, Direction.horizontal); + try testing.expectEqual(Direction.vertical, Direction.vertical); + try testing.expect(Direction.horizontal != Direction.vertical); +} diff --git a/src/tests/tests.zig b/src/tests/tests.zig new file mode 100644 index 0000000..ea0994e --- /dev/null +++ b/src/tests/tests.zig @@ -0,0 +1,18 @@ +//! Main test module for zcatui +//! +//! Run all tests with: +//! zig build test + +const std = @import("std"); + +// Test modules +pub const widget_tests = @import("widget_tests.zig"); +pub const theme_tests = @import("theme_tests.zig"); +pub const layout_tests = @import("layout_tests.zig"); + +test { + // Test modules + _ = widget_tests; + _ = theme_tests; + _ = layout_tests; +} diff --git a/src/tests/theme_tests.zig b/src/tests/theme_tests.zig new file mode 100644 index 0000000..e16774b --- /dev/null +++ b/src/tests/theme_tests.zig @@ -0,0 +1,104 @@ +//! Tests for the theme system + +const std = @import("std"); +const testing = std.testing; + +const theme_mod = @import("../theme.zig"); +const Theme = theme_mod.Theme; + +// ============================================================================ +// Theme Tests +// ============================================================================ + +test "Theme default has colors" { + const t = theme_mod.dark; + const style = t.default(); + // Style should be created successfully + _ = style; +} + +test "Theme style builders" { + const t = theme_mod.dark; + + // All style builders should work without error + _ = t.primaryStyle(); + _ = t.secondaryStyle(); + _ = t.successStyle(); + _ = t.warningStyle(); + _ = t.errorStyle(); + _ = t.infoStyle(); + _ = t.selectionStyle(); +} + +test "Theme border styles" { + const t = theme_mod.nord; + + _ = t.borderStyle(); + _ = t.borderFocusedStyle(); + _ = t.borderDisabledStyle(); +} + +test "Theme status bar styles" { + const t = theme_mod.dracula; + + _ = t.statusBarStyle(); + _ = t.statusBarModeStyle(); +} + +test "Theme button styles" { + const t = theme_mod.gruvbox; + + _ = t.buttonStyle(); + _ = t.buttonFocusedStyle(); + _ = t.buttonActiveStyle(); +} + +test "All predefined themes are valid" { + const themes = [_]Theme{ + theme_mod.dark, + theme_mod.light, + theme_mod.dracula, + theme_mod.nord, + theme_mod.gruvbox, + theme_mod.solarized_dark, + theme_mod.monokai, + theme_mod.one_dark, + theme_mod.tokyo_night, + theme_mod.catppuccin, + }; + + for (themes) |t| { + _ = t.default(); + _ = t.primaryStyle(); + _ = t.secondaryStyle(); + _ = t.successStyle(); + _ = t.warningStyle(); + _ = t.errorStyle(); + _ = t.infoStyle(); + _ = t.disabledStyle(); + _ = t.secondaryTextStyle(); + _ = t.borderStyle(); + _ = t.borderFocusedStyle(); + _ = t.borderDisabledStyle(); + _ = t.selectionStyle(); + _ = t.highlightStyle(); + _ = t.surfaceStyle(); + _ = t.surfaceVariantStyle(); + _ = t.statusBarStyle(); + _ = t.statusBarModeStyle(); + _ = t.inputStyle(); + _ = t.inputFocusedStyle(); + _ = t.placeholderStyle(); + _ = t.titleStyle(); + _ = t.buttonStyle(); + _ = t.buttonFocusedStyle(); + _ = t.buttonActiveStyle(); + _ = t.linkStyle(); + _ = t.codeStyle(); + } +} + +test "Theme count" { + // We should have 10 predefined themes + try testing.expectEqual(@as(usize, 10), 10); +} diff --git a/src/tests/widget_tests.zig b/src/tests/widget_tests.zig new file mode 100644 index 0000000..79896b4 --- /dev/null +++ b/src/tests/widget_tests.zig @@ -0,0 +1,490 @@ +//! Comprehensive tests for zcatui widgets +//! +//! Tests core functionality of all widgets. + +const std = @import("std"); +const testing = std.testing; + +// Core types +const Buffer = @import("../buffer.zig").Buffer; +const Rect = @import("../buffer.zig").Rect; +const Style = @import("../style.zig").Style; +const Color = @import("../style.zig").Color; + +// Widgets +const Block = @import("../widgets/block.zig").Block; +const Borders = @import("../widgets/block.zig").Borders; +const Gauge = @import("../widgets/gauge.zig").Gauge; +const LineGauge = @import("../widgets/gauge.zig").LineGauge; +const Checkbox = @import("../widgets/checkbox.zig").Checkbox; +const RadioGroup = @import("../widgets/checkbox.zig").RadioGroup; +const CheckboxGroup = @import("../widgets/checkbox.zig").CheckboxGroup; +const Select = @import("../widgets/select.zig").Select; +const Slider = @import("../widgets/slider.zig").Slider; +const RangeSlider = @import("../widgets/slider.zig").RangeSlider; +const StatusBar = @import("../widgets/statusbar.zig").StatusBar; +const Toast = @import("../widgets/statusbar.zig").Toast; +const ToastType = @import("../widgets/statusbar.zig").ToastType; +const Panel = @import("../widgets/panel.zig").Panel; +const TabbedPanel = @import("../widgets/panel.zig").TabbedPanel; + +// ============================================================================ +// Block Tests +// ============================================================================ + +test "Block initialization" { + const block = Block.init(); + try testing.expect(block.borders.top == false); + try testing.expect(block.borders.bottom == false); +} + +test "Block with all borders" { + const block = Block.init().setBorders(Borders.all); + try testing.expect(block.borders.top); + try testing.expect(block.borders.bottom); + try testing.expect(block.borders.left); + try testing.expect(block.borders.right); +} + +test "Block inner area calculation" { + const block = Block.init().setBorders(Borders.all); + const outer = Rect.init(0, 0, 10, 5); + const inner = block.inner(outer); + + try testing.expectEqual(@as(u16, 1), inner.x); + try testing.expectEqual(@as(u16, 1), inner.y); + try testing.expectEqual(@as(u16, 8), inner.width); + try testing.expectEqual(@as(u16, 3), inner.height); +} + +test "Block inner with no borders" { + const block = Block.init(); + const outer = Rect.init(5, 5, 10, 10); + const inner = block.inner(outer); + + try testing.expectEqual(@as(u16, 5), inner.x); + try testing.expectEqual(@as(u16, 5), inner.y); + try testing.expectEqual(@as(u16, 10), inner.width); + try testing.expectEqual(@as(u16, 10), inner.height); +} + +// ============================================================================ +// Gauge Tests +// ============================================================================ + +test "Gauge initialization" { + const gauge = Gauge.init(); + try testing.expectEqual(@as(f64, 0.0), gauge.ratio); +} + +test "LineGauge initialization" { + const gauge = LineGauge.init(); + try testing.expectEqual(@as(f64, 0.0), gauge.ratio); +} + +// ============================================================================ +// Checkbox Tests +// ============================================================================ + +test "Checkbox initialization" { + const cb = Checkbox.init("Accept terms"); + try testing.expectEqualStrings("Accept terms", cb.label); + try testing.expect(!cb.checked); +} + +test "Checkbox toggle" { + var cb = Checkbox.init("Toggle me"); + try testing.expect(!cb.checked); + + cb.toggle(); + try testing.expect(cb.checked); + + cb.toggle(); + try testing.expect(!cb.checked); +} + +test "Checkbox disabled no toggle" { + var cb = Checkbox.init("Disabled").setDisabled(true); + cb.toggle(); + try testing.expect(!cb.checked); +} + +// ============================================================================ +// RadioGroup Tests +// ============================================================================ + +test "RadioGroup initialization" { + const radio = RadioGroup.init(&.{ "A", "B", "C" }); + try testing.expectEqual(@as(usize, 3), radio.options.len); + try testing.expectEqual(@as(usize, 0), radio.selected); +} + +test "RadioGroup navigation" { + var radio = RadioGroup.init(&.{ "X", "Y", "Z" }); + + // selectNext/Prev change focused, not selected + radio.selectNext(); + try testing.expectEqual(@as(usize, 1), radio.focused); + + radio.selectNext(); + try testing.expectEqual(@as(usize, 2), radio.focused); + + radio.selectPrev(); + try testing.expectEqual(@as(usize, 1), radio.focused); +} + +test "RadioGroup confirm" { + var radio = RadioGroup.init(&.{ "A", "B", "C" }); + radio.focused = 2; + radio.confirm(); + try testing.expectEqual(@as(usize, 2), radio.selected); +} + +// ============================================================================ +// CheckboxGroup Tests +// ============================================================================ + +test "CheckboxGroup initialization" { + const group = CheckboxGroup.init(&.{ "A", "B", "C" }); + try testing.expectEqual(@as(u64, 0), group.selected); +} + +test "CheckboxGroup selection" { + var group = CheckboxGroup.init(&.{ "A", "B", "C" }); + + group.setOption(0, true); + try testing.expect(group.isSelected(0)); + try testing.expect(!group.isSelected(1)); + + group.setOption(1, true); + try testing.expect(group.isSelected(0)); + try testing.expect(group.isSelected(1)); + + group.setOption(0, false); + try testing.expect(!group.isSelected(0)); + try testing.expect(group.isSelected(1)); +} + +test "CheckboxGroup toggle focused" { + var group = CheckboxGroup.init(&.{ "A", "B" }); + group.focused = 1; + + group.toggleFocused(); + try testing.expect(group.isSelected(1)); + + group.toggleFocused(); + try testing.expect(!group.isSelected(1)); +} + +// ============================================================================ +// Select Tests +// ============================================================================ + +test "Select initialization" { + const sel = Select.init(&.{ "Small", "Medium", "Large" }); + try testing.expectEqual(@as(usize, 3), sel.options.len); + try testing.expect(!sel.open); +} + +test "Select toggle" { + var sel = Select.init(&.{ "A", "B" }); + try testing.expect(!sel.open); + + sel.toggle(); + try testing.expect(sel.open); + + sel.toggle(); + try testing.expect(!sel.open); +} + +test "Select navigation" { + var sel = Select.init(&.{ "X", "Y", "Z" }); + sel.open = true; + + sel.highlightNext(); + try testing.expectEqual(@as(usize, 1), sel.highlighted); + + sel.highlightNext(); + try testing.expectEqual(@as(usize, 2), sel.highlighted); + + sel.highlightPrev(); + try testing.expectEqual(@as(usize, 1), sel.highlighted); +} + +test "Select confirm" { + var sel = Select.init(&.{ "A", "B", "C" }); + sel.open = true; + sel.highlighted = 2; + + sel.confirm(); + try testing.expectEqual(@as(?usize, 2), sel.selected); + try testing.expect(!sel.open); +} + +// ============================================================================ +// Slider Tests +// ============================================================================ + +test "Slider initialization" { + const slider = Slider.init(0, 100); + try testing.expectEqual(@as(f64, 0), slider.min); + try testing.expectEqual(@as(f64, 100), slider.max); + try testing.expectEqual(@as(f64, 0), slider.value); +} + +test "Slider setValue" { + const slider = Slider.init(0, 100).setValue(50); + try testing.expectEqual(@as(f64, 50), slider.value); +} + +test "Slider setValue clamping" { + const over = Slider.init(0, 100).setValue(150); + try testing.expectEqual(@as(f64, 100), over.value); + + const under = Slider.init(0, 100).setValue(-50); + try testing.expectEqual(@as(f64, 0), under.value); +} + +test "Slider increment/decrement" { + var slider = Slider.init(0, 100).setValue(50); + + slider.increment(); + try testing.expectEqual(@as(f64, 51), slider.value); + + slider.decrement(); + try testing.expectEqual(@as(f64, 50), slider.value); +} + +// ============================================================================ +// RangeSlider Tests +// ============================================================================ + +test "RangeSlider initialization" { + const slider = RangeSlider.init(0, 100); + try testing.expectEqual(@as(f64, 0), slider.low); + try testing.expectEqual(@as(f64, 100), slider.high); +} + +// ============================================================================ +// StatusBar Tests +// ============================================================================ + +test "StatusBar initialization" { + const status = StatusBar.init(); + try testing.expectEqualStrings("", status.left); + try testing.expectEqualStrings("", status.center); + try testing.expectEqualStrings("", status.right); +} + +test "StatusBar setters" { + const status = StatusBar.init() + .setLeft("Left text") + .setCenter("Center") + .setRight("Right"); + + try testing.expectEqualStrings("Left text", status.left); + try testing.expectEqualStrings("Center", status.center); + try testing.expectEqualStrings("Right", status.right); +} + +// ============================================================================ +// Toast Tests +// ============================================================================ + +test "Toast initialization" { + const toast = Toast.init("Hello!"); + try testing.expectEqualStrings("Hello!", toast.message); + try testing.expectEqual(ToastType.info, toast.toast_type); + try testing.expect(!toast.visible); +} + +test "Toast show/hide" { + var toast = Toast.init("Test"); + try testing.expect(!toast.visible); + + toast.show(); + try testing.expect(toast.visible); + + toast.hide(); + try testing.expect(!toast.visible); +} + +// ============================================================================ +// Panel Tests +// ============================================================================ + +test "Panel initialization" { + const panel = Panel.init("My Panel"); + try testing.expect(!panel.focused); +} + +test "Panel focus" { + const panel = Panel.init("Test").setFocused(true); + try testing.expect(panel.focused); +} + +// ============================================================================ +// TabbedPanel Tests +// ============================================================================ + +test "TabbedPanel initialization" { + const panel = TabbedPanel.init(&.{ "Tab1", "Tab2" }); + try testing.expectEqual(@as(usize, 2), panel.tabs.len); + try testing.expectEqual(@as(usize, 0), panel.selected); +} + +test "TabbedPanel select" { + var panel = TabbedPanel.init(&.{ "A", "B", "C" }); + panel.select(2); + try testing.expectEqual(@as(usize, 2), panel.selected); +} + +test "TabbedPanel navigation" { + var panel = TabbedPanel.init(&.{ "X", "Y", "Z" }); + + panel.selectNext(); + try testing.expectEqual(@as(usize, 1), panel.selected); + + panel.selectNext(); + try testing.expectEqual(@as(usize, 2), panel.selected); + + // selectNext does not wrap - stays at 2 + panel.selectNext(); + try testing.expectEqual(@as(usize, 2), panel.selected); + + panel.selectPrev(); + try testing.expectEqual(@as(usize, 1), panel.selected); +} + +// ============================================================================ +// Rect Tests +// ============================================================================ + +test "Rect initialization" { + const rect = Rect.init(10, 20, 100, 50); + try testing.expectEqual(@as(u16, 10), rect.x); + try testing.expectEqual(@as(u16, 20), rect.y); + try testing.expectEqual(@as(u16, 100), rect.width); + try testing.expectEqual(@as(u16, 50), rect.height); +} + +test "Rect area" { + const rect = Rect.init(0, 0, 10, 5); + try testing.expectEqual(@as(u32, 50), rect.area()); +} + +test "Rect right/bottom" { + const rect = Rect.init(10, 20, 30, 40); + try testing.expectEqual(@as(u16, 40), rect.right()); + try testing.expectEqual(@as(u16, 60), rect.bottom()); +} + +test "Rect isEmpty" { + const empty1 = Rect.init(0, 0, 0, 10); + try testing.expect(empty1.isEmpty()); + + const empty2 = Rect.init(0, 0, 10, 0); + try testing.expect(empty2.isEmpty()); + + const notEmpty = Rect.init(0, 0, 10, 10); + try testing.expect(!notEmpty.isEmpty()); +} + +test "Rect contains" { + const rect = Rect.init(10, 10, 20, 20); + + try testing.expect(rect.contains(15, 15)); + try testing.expect(rect.contains(10, 10)); + try testing.expect(!rect.contains(30, 30)); + try testing.expect(!rect.contains(5, 15)); +} + +test "Rect intersection" { + const a = Rect.init(0, 0, 20, 20); + const b = Rect.init(10, 10, 20, 20); + const inter = a.intersection(b); + + try testing.expectEqual(@as(u16, 10), inter.x); + try testing.expectEqual(@as(u16, 10), inter.y); + try testing.expectEqual(@as(u16, 10), inter.width); + try testing.expectEqual(@as(u16, 10), inter.height); +} + +test "Rect no intersection" { + const a = Rect.init(0, 0, 10, 10); + const b = Rect.init(20, 20, 10, 10); + const inter = a.intersection(b); + + try testing.expect(inter.isEmpty()); +} + +// ============================================================================ +// Buffer Tests +// ============================================================================ + +test "Buffer initialization" { + var buf = try Buffer.init(testing.allocator, Rect.init(0, 0, 10, 5)); + defer buf.deinit(); + + try testing.expectEqual(@as(u16, 10), buf.area.width); + try testing.expectEqual(@as(u16, 5), buf.area.height); +} + +test "Buffer getCell" { + var buf = try Buffer.init(testing.allocator, Rect.init(0, 0, 10, 5)); + defer buf.deinit(); + + const cell = buf.getCell(0, 0); + try testing.expect(cell != null); + + const out_of_bounds = buf.getCell(100, 100); + try testing.expect(out_of_bounds == null); +} + +test "Buffer setString" { + var buf = try Buffer.init(testing.allocator, Rect.init(0, 0, 20, 5)); + defer buf.deinit(); + + _ = buf.setString(0, 0, "Hello", Style.default); + + // Just verify the call succeeded + const cell = buf.getCell(0, 0); + try testing.expect(cell != null); +} + +test "Buffer clear" { + var buf = try Buffer.init(testing.allocator, Rect.init(0, 0, 10, 5)); + defer buf.deinit(); + + _ = buf.setString(0, 0, "Test", Style.default); + buf.clear(); + + // Just verify clear succeeded + const cell = buf.getCell(0, 0); + try testing.expect(cell != null); +} + +// ============================================================================ +// Style Tests +// ============================================================================ + +test "Style default" { + const style = Style.default; + try testing.expect(style.foreground == null); + try testing.expect(style.background == null); +} + +test "Style fg/bg" { + const style = Style.default.fg(Color.red).bg(Color.blue); + try testing.expect(style.foreground != null); + try testing.expect(style.background != null); +} + +test "Style patch" { + const base = Style.default.fg(Color.red); + const overlay = Style.default.bg(Color.blue); + const patched = base.patch(overlay); + + try testing.expect(patched.foreground != null); + try testing.expect(patched.background != null); +} diff --git a/src/theme.zig b/src/theme.zig new file mode 100644 index 0000000..7cfb7f1 --- /dev/null +++ b/src/theme.zig @@ -0,0 +1,657 @@ +const std = @import("std"); +const style_mod = @import("style.zig"); +const Color = style_mod.Color; +const Style = style_mod.Style; +const Modifier = style_mod.Modifier; + +/// Theme defines colors and styles for consistent UI appearance +pub const Theme = struct { + // Base colors + background: Color = Color.reset, + foreground: Color = Color.reset, + + // Primary accent colors + primary: Color = Color.blue, + primary_variant: Color = Color.cyan, + secondary: Color = Color.magenta, + secondary_variant: Color = Color.rgb(180, 100, 180), + + // Semantic colors + success: Color = Color.green, + warning: Color = Color.yellow, + error_color: Color = Color.red, + info: Color = Color.cyan, + + // Surface colors (for panels, cards, etc.) + surface: Color = Color.reset, + surface_variant: Color = Color.indexed(236), + + // Border colors + border: Color = Color.indexed(240), + border_focused: Color = Color.blue, + border_disabled: Color = Color.indexed(238), + + // Text colors + text: Color = Color.reset, + text_secondary: Color = Color.indexed(245), + text_disabled: Color = Color.indexed(240), + text_inverse: Color = Color.indexed(232), + + // Selection colors + selection_bg: Color = Color.blue, + selection_fg: Color = Color.white, + highlight_bg: Color = Color.indexed(236), + + // Input colors + input_bg: Color = Color.reset, + input_border: Color = Color.indexed(240), + input_focused_border: Color = Color.blue, + input_placeholder: Color = Color.indexed(240), + + // Status bar + statusbar_bg: Color = Color.indexed(236), + statusbar_fg: Color = Color.indexed(252), + statusbar_mode_bg: Color = Color.blue, + statusbar_mode_fg: Color = Color.white, + + // ======================================================================== + // Style builders - return complete styles based on theme colors + // ======================================================================== + + /// Default style (foreground on background) + pub fn default(self: Theme) Style { + return Style.default.fg(self.foreground).bg(self.background); + } + + /// Primary styled text + pub fn primaryStyle(self: Theme) Style { + return Style.default.fg(self.primary); + } + + /// Secondary styled text + pub fn secondaryStyle(self: Theme) Style { + return Style.default.fg(self.secondary); + } + + /// Success styled text + pub fn successStyle(self: Theme) Style { + return Style.default.fg(self.success); + } + + /// Warning styled text + pub fn warningStyle(self: Theme) Style { + return Style.default.fg(self.warning); + } + + /// Error styled text + pub fn errorStyle(self: Theme) Style { + return Style.default.fg(self.error_color); + } + + /// Info styled text + pub fn infoStyle(self: Theme) Style { + return Style.default.fg(self.info); + } + + /// Disabled text style + pub fn disabledStyle(self: Theme) Style { + return Style.default.fg(self.text_disabled); + } + + /// Secondary text style + pub fn secondaryTextStyle(self: Theme) Style { + return Style.default.fg(self.text_secondary); + } + + /// Border style (normal) + pub fn borderStyle(self: Theme) Style { + return Style.default.fg(self.border); + } + + /// Border style (focused) + pub fn borderFocusedStyle(self: Theme) Style { + return Style.default.fg(self.border_focused); + } + + /// Border style (disabled) + pub fn borderDisabledStyle(self: Theme) Style { + return Style.default.fg(self.border_disabled); + } + + /// Selection style (highlighted item) + pub fn selectionStyle(self: Theme) Style { + return Style.default.fg(self.selection_fg).bg(self.selection_bg); + } + + /// Highlight style (subtle highlight) + pub fn highlightStyle(self: Theme) Style { + return Style.default.bg(self.highlight_bg); + } + + /// Surface style (panels, cards) + pub fn surfaceStyle(self: Theme) Style { + return Style.default.bg(self.surface); + } + + /// Surface variant style + pub fn surfaceVariantStyle(self: Theme) Style { + return Style.default.bg(self.surface_variant); + } + + /// Status bar style + pub fn statusBarStyle(self: Theme) Style { + return Style.default.fg(self.statusbar_fg).bg(self.statusbar_bg); + } + + /// Status bar mode style + pub fn statusBarModeStyle(self: Theme) Style { + return Style.default.fg(self.statusbar_mode_fg).bg(self.statusbar_mode_bg); + } + + /// Input field style + pub fn inputStyle(self: Theme) Style { + return Style.default.bg(self.input_bg); + } + + /// Input field focused style + pub fn inputFocusedStyle(self: Theme) Style { + return Style.default.fg(self.input_focused_border); + } + + /// Placeholder text style + pub fn placeholderStyle(self: Theme) Style { + return Style.default.fg(self.input_placeholder); + } + + /// Title style (bold primary) + pub fn titleStyle(self: Theme) Style { + return Style.default.fg(self.primary).add_modifier(.{ .bold = true }); + } + + /// Button style (normal) + pub fn buttonStyle(self: Theme) Style { + return Style.default.fg(self.text).bg(self.surface_variant); + } + + /// Button style (focused) + pub fn buttonFocusedStyle(self: Theme) Style { + return Style.default.fg(self.selection_fg).bg(self.primary); + } + + /// Button style (pressed/active) + pub fn buttonActiveStyle(self: Theme) Style { + return Style.default.fg(self.selection_fg).bg(self.primary_variant); + } + + /// Link style + pub fn linkStyle(self: Theme) Style { + return Style.default.fg(self.info).add_modifier(.{ .underlined = true }); + } + + /// Code/monospace style + pub fn codeStyle(self: Theme) Style { + return Style.default.fg(self.secondary).bg(self.surface_variant); + } +}; + +// ============================================================================ +// Predefined Themes +// ============================================================================ + +/// Default dark theme +pub const dark = Theme{ + .background = Color.indexed(232), // Near black + .foreground = Color.indexed(252), // Light gray + + .primary = Color.rgb(97, 175, 239), // Soft blue + .primary_variant = Color.rgb(86, 156, 214), + .secondary = Color.rgb(198, 120, 221), // Purple + .secondary_variant = Color.rgb(180, 100, 200), + + .success = Color.rgb(152, 195, 121), // Soft green + .warning = Color.rgb(229, 192, 123), // Soft yellow + .error_color = Color.rgb(224, 108, 117), // Soft red + .info = Color.rgb(86, 182, 194), // Cyan + + .surface = Color.indexed(234), + .surface_variant = Color.indexed(236), + + .border = Color.indexed(240), + .border_focused = Color.rgb(97, 175, 239), + .border_disabled = Color.indexed(238), + + .text = Color.indexed(252), + .text_secondary = Color.indexed(245), + .text_disabled = Color.indexed(240), + .text_inverse = Color.indexed(232), + + .selection_bg = Color.rgb(97, 175, 239), + .selection_fg = Color.indexed(232), + .highlight_bg = Color.indexed(238), + + .input_bg = Color.indexed(235), + .input_border = Color.indexed(240), + .input_focused_border = Color.rgb(97, 175, 239), + .input_placeholder = Color.indexed(242), + + .statusbar_bg = Color.indexed(236), + .statusbar_fg = Color.indexed(252), + .statusbar_mode_bg = Color.rgb(97, 175, 239), + .statusbar_mode_fg = Color.indexed(232), +}; + +/// Light theme +pub const light = Theme{ + .background = Color.indexed(231), // White + .foreground = Color.indexed(235), // Dark gray + + .primary = Color.rgb(0, 122, 204), // Blue + .primary_variant = Color.rgb(0, 102, 184), + .secondary = Color.rgb(136, 57, 239), // Purple + .secondary_variant = Color.rgb(116, 37, 219), + + .success = Color.rgb(40, 167, 69), // Green + .warning = Color.rgb(255, 193, 7), // Yellow + .error_color = Color.rgb(220, 53, 69), // Red + .info = Color.rgb(23, 162, 184), // Cyan + + .surface = Color.indexed(255), + .surface_variant = Color.indexed(254), + + .border = Color.indexed(250), + .border_focused = Color.rgb(0, 122, 204), + .border_disabled = Color.indexed(252), + + .text = Color.indexed(235), + .text_secondary = Color.indexed(242), + .text_disabled = Color.indexed(248), + .text_inverse = Color.indexed(255), + + .selection_bg = Color.rgb(0, 122, 204), + .selection_fg = Color.indexed(255), + .highlight_bg = Color.indexed(254), + + .input_bg = Color.indexed(255), + .input_border = Color.indexed(250), + .input_focused_border = Color.rgb(0, 122, 204), + .input_placeholder = Color.indexed(248), + + .statusbar_bg = Color.indexed(254), + .statusbar_fg = Color.indexed(236), + .statusbar_mode_bg = Color.rgb(0, 122, 204), + .statusbar_mode_fg = Color.indexed(255), +}; + +/// Dracula theme +pub const dracula = Theme{ + .background = Color.rgb(40, 42, 54), // #282a36 + .foreground = Color.rgb(248, 248, 242), // #f8f8f2 + + .primary = Color.rgb(189, 147, 249), // Purple #bd93f9 + .primary_variant = Color.rgb(139, 233, 253), // Cyan #8be9fd + .secondary = Color.rgb(255, 121, 198), // Pink #ff79c6 + .secondary_variant = Color.rgb(255, 85, 85), // Red #ff5555 + + .success = Color.rgb(80, 250, 123), // Green #50fa7b + .warning = Color.rgb(241, 250, 140), // Yellow #f1fa8c + .error_color = Color.rgb(255, 85, 85), // Red #ff5555 + .info = Color.rgb(139, 233, 253), // Cyan #8be9fd + + .surface = Color.rgb(68, 71, 90), // #44475a + .surface_variant = Color.rgb(98, 114, 164), // #6272a4 + + .border = Color.rgb(98, 114, 164), // #6272a4 + .border_focused = Color.rgb(189, 147, 249), // Purple + .border_disabled = Color.rgb(68, 71, 90), + + .text = Color.rgb(248, 248, 242), // #f8f8f2 + .text_secondary = Color.rgb(98, 114, 164), // #6272a4 + .text_disabled = Color.rgb(68, 71, 90), + .text_inverse = Color.rgb(40, 42, 54), + + .selection_bg = Color.rgb(68, 71, 90), + .selection_fg = Color.rgb(248, 248, 242), + .highlight_bg = Color.rgb(68, 71, 90), + + .input_bg = Color.rgb(40, 42, 54), + .input_border = Color.rgb(98, 114, 164), + .input_focused_border = Color.rgb(189, 147, 249), + .input_placeholder = Color.rgb(98, 114, 164), + + .statusbar_bg = Color.rgb(68, 71, 90), + .statusbar_fg = Color.rgb(248, 248, 242), + .statusbar_mode_bg = Color.rgb(189, 147, 249), + .statusbar_mode_fg = Color.rgb(40, 42, 54), +}; + +/// Nord theme +pub const nord = Theme{ + .background = Color.rgb(46, 52, 64), // #2e3440 + .foreground = Color.rgb(236, 239, 244), // #eceff4 + + .primary = Color.rgb(136, 192, 208), // #88c0d0 + .primary_variant = Color.rgb(129, 161, 193), // #81a1c1 + .secondary = Color.rgb(180, 142, 173), // #b48ead + .secondary_variant = Color.rgb(163, 190, 140), // #a3be8c + + .success = Color.rgb(163, 190, 140), // #a3be8c + .warning = Color.rgb(235, 203, 139), // #ebcb8b + .error_color = Color.rgb(191, 97, 106), // #bf616a + .info = Color.rgb(136, 192, 208), // #88c0d0 + + .surface = Color.rgb(59, 66, 82), // #3b4252 + .surface_variant = Color.rgb(67, 76, 94), // #434c5e + + .border = Color.rgb(67, 76, 94), // #434c5e + .border_focused = Color.rgb(136, 192, 208), + .border_disabled = Color.rgb(59, 66, 82), + + .text = Color.rgb(236, 239, 244), // #eceff4 + .text_secondary = Color.rgb(216, 222, 233), // #d8dee9 + .text_disabled = Color.rgb(76, 86, 106), // #4c566a + .text_inverse = Color.rgb(46, 52, 64), + + .selection_bg = Color.rgb(136, 192, 208), + .selection_fg = Color.rgb(46, 52, 64), + .highlight_bg = Color.rgb(67, 76, 94), + + .input_bg = Color.rgb(46, 52, 64), + .input_border = Color.rgb(67, 76, 94), + .input_focused_border = Color.rgb(136, 192, 208), + .input_placeholder = Color.rgb(76, 86, 106), + + .statusbar_bg = Color.rgb(59, 66, 82), + .statusbar_fg = Color.rgb(236, 239, 244), + .statusbar_mode_bg = Color.rgb(136, 192, 208), + .statusbar_mode_fg = Color.rgb(46, 52, 64), +}; + +/// Gruvbox dark theme +pub const gruvbox = Theme{ + .background = Color.rgb(40, 40, 40), // #282828 + .foreground = Color.rgb(235, 219, 178), // #ebdbb2 + + .primary = Color.rgb(131, 165, 152), // #83a598 aqua + .primary_variant = Color.rgb(142, 192, 124), // #8ec07c + .secondary = Color.rgb(211, 134, 155), // #d3869b + .secondary_variant = Color.rgb(177, 98, 134), // #b16286 + + .success = Color.rgb(184, 187, 38), // #b8bb26 + .warning = Color.rgb(250, 189, 47), // #fabd2f + .error_color = Color.rgb(251, 73, 52), // #fb4934 + .info = Color.rgb(131, 165, 152), // #83a598 + + .surface = Color.rgb(50, 48, 47), // #32302f + .surface_variant = Color.rgb(60, 56, 54), // #3c3836 + + .border = Color.rgb(80, 73, 69), // #504945 + .border_focused = Color.rgb(131, 165, 152), + .border_disabled = Color.rgb(60, 56, 54), + + .text = Color.rgb(235, 219, 178), // #ebdbb2 + .text_secondary = Color.rgb(189, 174, 147), // #bdae93 + .text_disabled = Color.rgb(102, 92, 84), // #665c54 + .text_inverse = Color.rgb(40, 40, 40), + + .selection_bg = Color.rgb(131, 165, 152), + .selection_fg = Color.rgb(40, 40, 40), + .highlight_bg = Color.rgb(60, 56, 54), + + .input_bg = Color.rgb(40, 40, 40), + .input_border = Color.rgb(80, 73, 69), + .input_focused_border = Color.rgb(131, 165, 152), + .input_placeholder = Color.rgb(102, 92, 84), + + .statusbar_bg = Color.rgb(60, 56, 54), + .statusbar_fg = Color.rgb(235, 219, 178), + .statusbar_mode_bg = Color.rgb(250, 189, 47), + .statusbar_mode_fg = Color.rgb(40, 40, 40), +}; + +/// Solarized dark theme +pub const solarized_dark = Theme{ + .background = Color.rgb(0, 43, 54), // #002b36 + .foreground = Color.rgb(131, 148, 150), // #839496 + + .primary = Color.rgb(38, 139, 210), // #268bd2 blue + .primary_variant = Color.rgb(42, 161, 152), // #2aa198 cyan + .secondary = Color.rgb(211, 54, 130), // #d33682 magenta + .secondary_variant = Color.rgb(108, 113, 196), // #6c71c4 violet + + .success = Color.rgb(133, 153, 0), // #859900 + .warning = Color.rgb(181, 137, 0), // #b58900 + .error_color = Color.rgb(220, 50, 47), // #dc322f + .info = Color.rgb(42, 161, 152), // #2aa198 + + .surface = Color.rgb(7, 54, 66), // #073642 + .surface_variant = Color.rgb(0, 43, 54), // #002b36 + + .border = Color.rgb(88, 110, 117), // #586e75 + .border_focused = Color.rgb(38, 139, 210), + .border_disabled = Color.rgb(7, 54, 66), + + .text = Color.rgb(131, 148, 150), // #839496 + .text_secondary = Color.rgb(88, 110, 117), // #586e75 + .text_disabled = Color.rgb(7, 54, 66), + .text_inverse = Color.rgb(253, 246, 227), // #fdf6e3 + + .selection_bg = Color.rgb(38, 139, 210), + .selection_fg = Color.rgb(253, 246, 227), + .highlight_bg = Color.rgb(7, 54, 66), + + .input_bg = Color.rgb(0, 43, 54), + .input_border = Color.rgb(88, 110, 117), + .input_focused_border = Color.rgb(38, 139, 210), + .input_placeholder = Color.rgb(88, 110, 117), + + .statusbar_bg = Color.rgb(7, 54, 66), + .statusbar_fg = Color.rgb(131, 148, 150), + .statusbar_mode_bg = Color.rgb(38, 139, 210), + .statusbar_mode_fg = Color.rgb(253, 246, 227), +}; + +/// Monokai theme +pub const monokai = Theme{ + .background = Color.rgb(39, 40, 34), // #272822 + .foreground = Color.rgb(248, 248, 242), // #f8f8f2 + + .primary = Color.rgb(102, 217, 239), // #66d9ef cyan + .primary_variant = Color.rgb(174, 129, 255), // #ae81ff purple + .secondary = Color.rgb(249, 38, 114), // #f92672 pink + .secondary_variant = Color.rgb(253, 151, 31), // #fd971f orange + + .success = Color.rgb(166, 226, 46), // #a6e22e green + .warning = Color.rgb(253, 151, 31), // #fd971f orange + .error_color = Color.rgb(249, 38, 114), // #f92672 pink + .info = Color.rgb(102, 217, 239), // #66d9ef cyan + + .surface = Color.rgb(49, 50, 44), // #31322c + .surface_variant = Color.rgb(59, 60, 54), // #3b3c36 + + .border = Color.rgb(117, 113, 94), // #75715e + .border_focused = Color.rgb(102, 217, 239), + .border_disabled = Color.rgb(59, 60, 54), + + .text = Color.rgb(248, 248, 242), // #f8f8f2 + .text_secondary = Color.rgb(117, 113, 94), // #75715e + .text_disabled = Color.rgb(59, 60, 54), + .text_inverse = Color.rgb(39, 40, 34), + + .selection_bg = Color.rgb(73, 72, 62), // #49483e + .selection_fg = Color.rgb(248, 248, 242), + .highlight_bg = Color.rgb(59, 60, 54), + + .input_bg = Color.rgb(39, 40, 34), + .input_border = Color.rgb(117, 113, 94), + .input_focused_border = Color.rgb(102, 217, 239), + .input_placeholder = Color.rgb(117, 113, 94), + + .statusbar_bg = Color.rgb(49, 50, 44), + .statusbar_fg = Color.rgb(248, 248, 242), + .statusbar_mode_bg = Color.rgb(166, 226, 46), + .statusbar_mode_fg = Color.rgb(39, 40, 34), +}; + +/// One Dark theme (Atom/VS Code) +pub const one_dark = Theme{ + .background = Color.rgb(40, 44, 52), // #282c34 + .foreground = Color.rgb(171, 178, 191), // #abb2bf + + .primary = Color.rgb(97, 175, 239), // #61afef blue + .primary_variant = Color.rgb(86, 182, 194), // #56b6c2 cyan + .secondary = Color.rgb(198, 120, 221), // #c678dd purple + .secondary_variant = Color.rgb(224, 108, 117), // #e06c75 red + + .success = Color.rgb(152, 195, 121), // #98c379 green + .warning = Color.rgb(229, 192, 123), // #e5c07b yellow + .error_color = Color.rgb(224, 108, 117), // #e06c75 red + .info = Color.rgb(86, 182, 194), // #56b6c2 cyan + + .surface = Color.rgb(33, 37, 43), // #21252b + .surface_variant = Color.rgb(44, 49, 58), // #2c313a + + .border = Color.rgb(62, 68, 81), // #3e4451 + .border_focused = Color.rgb(97, 175, 239), + .border_disabled = Color.rgb(44, 49, 58), + + .text = Color.rgb(171, 178, 191), // #abb2bf + .text_secondary = Color.rgb(92, 99, 112), // #5c6370 + .text_disabled = Color.rgb(62, 68, 81), + .text_inverse = Color.rgb(40, 44, 52), + + .selection_bg = Color.rgb(62, 68, 81), + .selection_fg = Color.rgb(171, 178, 191), + .highlight_bg = Color.rgb(44, 49, 58), + + .input_bg = Color.rgb(40, 44, 52), + .input_border = Color.rgb(62, 68, 81), + .input_focused_border = Color.rgb(97, 175, 239), + .input_placeholder = Color.rgb(92, 99, 112), + + .statusbar_bg = Color.rgb(33, 37, 43), + .statusbar_fg = Color.rgb(171, 178, 191), + .statusbar_mode_bg = Color.rgb(97, 175, 239), + .statusbar_mode_fg = Color.rgb(40, 44, 52), +}; + +/// Tokyo Night theme +pub const tokyo_night = Theme{ + .background = Color.rgb(26, 27, 38), // #1a1b26 + .foreground = Color.rgb(169, 177, 214), // #a9b1d6 + + .primary = Color.rgb(122, 162, 247), // #7aa2f7 blue + .primary_variant = Color.rgb(125, 207, 255), // #7dcfff cyan + .secondary = Color.rgb(187, 154, 247), // #bb9af7 purple + .secondary_variant = Color.rgb(247, 118, 142), // #f7768e red + + .success = Color.rgb(158, 206, 106), // #9ece6a green + .warning = Color.rgb(224, 175, 104), // #e0af68 yellow + .error_color = Color.rgb(247, 118, 142), // #f7768e red + .info = Color.rgb(125, 207, 255), // #7dcfff cyan + + .surface = Color.rgb(36, 40, 59), // #24283b + .surface_variant = Color.rgb(52, 59, 88), // #343b58 + + .border = Color.rgb(52, 59, 88), // #343b58 + .border_focused = Color.rgb(122, 162, 247), + .border_disabled = Color.rgb(36, 40, 59), + + .text = Color.rgb(169, 177, 214), // #a9b1d6 + .text_secondary = Color.rgb(86, 95, 137), // #565f89 + .text_disabled = Color.rgb(52, 59, 88), + .text_inverse = Color.rgb(26, 27, 38), + + .selection_bg = Color.rgb(52, 59, 88), + .selection_fg = Color.rgb(169, 177, 214), + .highlight_bg = Color.rgb(36, 40, 59), + + .input_bg = Color.rgb(26, 27, 38), + .input_border = Color.rgb(52, 59, 88), + .input_focused_border = Color.rgb(122, 162, 247), + .input_placeholder = Color.rgb(86, 95, 137), + + .statusbar_bg = Color.rgb(36, 40, 59), + .statusbar_fg = Color.rgb(169, 177, 214), + .statusbar_mode_bg = Color.rgb(122, 162, 247), + .statusbar_mode_fg = Color.rgb(26, 27, 38), +}; + +/// Catppuccin Mocha theme +pub const catppuccin = Theme{ + .background = Color.rgb(30, 30, 46), // #1e1e2e base + .foreground = Color.rgb(205, 214, 244), // #cdd6f4 text + + .primary = Color.rgb(137, 180, 250), // #89b4fa blue + .primary_variant = Color.rgb(148, 226, 213), // #94e2d5 teal + .secondary = Color.rgb(203, 166, 247), // #cba6f7 mauve + .secondary_variant = Color.rgb(245, 194, 231), // #f5c2e7 pink + + .success = Color.rgb(166, 227, 161), // #a6e3a1 green + .warning = Color.rgb(249, 226, 175), // #f9e2af yellow + .error_color = Color.rgb(243, 139, 168), // #f38ba8 red + .info = Color.rgb(137, 220, 235), // #89dceb sky + + .surface = Color.rgb(49, 50, 68), // #313244 surface0 + .surface_variant = Color.rgb(69, 71, 90), // #45475a surface1 + + .border = Color.rgb(88, 91, 112), // #585b70 surface2 + .border_focused = Color.rgb(137, 180, 250), + .border_disabled = Color.rgb(49, 50, 68), + + .text = Color.rgb(205, 214, 244), // #cdd6f4 text + .text_secondary = Color.rgb(166, 173, 200), // #a6adc8 subtext0 + .text_disabled = Color.rgb(88, 91, 112), // #585b70 surface2 + .text_inverse = Color.rgb(30, 30, 46), + + .selection_bg = Color.rgb(69, 71, 90), + .selection_fg = Color.rgb(205, 214, 244), + .highlight_bg = Color.rgb(49, 50, 68), + + .input_bg = Color.rgb(30, 30, 46), + .input_border = Color.rgb(88, 91, 112), + .input_focused_border = Color.rgb(137, 180, 250), + .input_placeholder = Color.rgb(108, 112, 134), // #6c7086 overlay0 + + .statusbar_bg = Color.rgb(24, 24, 37), // #181825 mantle + .statusbar_fg = Color.rgb(205, 214, 244), + .statusbar_mode_bg = Color.rgb(137, 180, 250), + .statusbar_mode_fg = Color.rgb(30, 30, 46), +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "Theme default" { + const theme = dark; + const style = theme.default(); + try std.testing.expect(style.foreground != null); + try std.testing.expect(style.background != null); +} + +test "Theme style builders" { + const theme = nord; + + const primary = theme.primaryStyle(); + try std.testing.expect(primary.foreground != null); + + const success = theme.successStyle(); + try std.testing.expect(success.foreground != null); + + const selection = theme.selectionStyle(); + try std.testing.expect(selection.foreground != null); + try std.testing.expect(selection.background != null); +} + +test "All predefined themes exist" { + _ = dark; + _ = light; + _ = dracula; + _ = nord; + _ = gruvbox; + _ = solarized_dark; + _ = monokai; + _ = one_dark; + _ = tokyo_night; + _ = catppuccin; +} diff --git a/src/widgets/block.zig b/src/widgets/block.zig index b3d408a..ca6ddbc 100644 --- a/src/widgets/block.zig +++ b/src/widgets/block.zig @@ -293,7 +293,9 @@ test "Block render compiles" { block.render(Rect.init(0, 0, 20, 10), &buf); - // Check corners - try std.testing.expectEqual(BorderSet.single.top_left, buf.get(0, 0).?.char); - try std.testing.expectEqual(BorderSet.single.top_right, buf.get(19, 0).?.char); + // Check corners - compare using Symbol.eql + const expected_tl = buffer.Symbol.fromCodepoint(BorderSet.single.top_left); + const expected_tr = buffer.Symbol.fromCodepoint(BorderSet.single.top_right); + try std.testing.expect(buf.get(0, 0).?.symbol.eql(expected_tl)); + try std.testing.expect(buf.get(19, 0).?.symbol.eql(expected_tr)); } diff --git a/src/widgets/statusbar.zig b/src/widgets/statusbar.zig index b3fb68e..f053871 100644 --- a/src/widgets/statusbar.zig +++ b/src/widgets/statusbar.zig @@ -519,7 +519,7 @@ test "Toast visibility" { try std.testing.expect(toast.visible); // Wait for expiry - std.time.sleep(150 * std.time.ns_per_ms); + std.Thread.sleep(150 * std.time.ns_per_ms); toast.update(); try std.testing.expect(!toast.visible); }