Compare commits
3 commits
79c0bb1a58
...
73667a752e
| Author | SHA1 | Date | |
|---|---|---|---|
| 73667a752e | |||
| a928fc55fd | |||
| 96810d80ea |
13 changed files with 3435 additions and 453 deletions
800
src/focus.zig
Normal file
800
src/focus.zig
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
32
src/root.zig
32
src/root.zig
|
|
@ -269,6 +269,31 @@ pub const Throttle = lazy.Throttle;
|
||||||
pub const Debounce = lazy.Debounce;
|
pub const Debounce = lazy.Debounce;
|
||||||
pub const DeferredRender = lazy.DeferredRender;
|
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;
|
||||||
|
|
||||||
|
// Unicode width calculation (wcwidth)
|
||||||
|
pub const unicode = @import("unicode.zig");
|
||||||
|
pub const charWidth = unicode.charWidth;
|
||||||
|
pub const stringWidth = unicode.stringWidth;
|
||||||
|
pub const truncateToWidth = unicode.truncateToWidth;
|
||||||
|
|
||||||
|
// Terminal capability detection
|
||||||
|
pub const termcap = @import("termcap.zig");
|
||||||
|
pub const Capabilities = termcap.Capabilities;
|
||||||
|
pub const ColorSupport = termcap.ColorSupport;
|
||||||
|
pub const detectCapabilities = termcap.detect;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -285,4 +310,11 @@ test {
|
||||||
_ = @import("event/reader.zig");
|
_ = @import("event/reader.zig");
|
||||||
_ = @import("event/parse.zig");
|
_ = @import("event/parse.zig");
|
||||||
_ = @import("cursor.zig");
|
_ = @import("cursor.zig");
|
||||||
|
_ = @import("focus.zig");
|
||||||
|
_ = @import("theme.zig");
|
||||||
|
_ = @import("unicode.zig");
|
||||||
|
_ = @import("termcap.zig");
|
||||||
|
|
||||||
|
// Comprehensive test suite
|
||||||
|
_ = @import("tests/tests.zig");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,10 @@ test "line set default" {
|
||||||
|
|
||||||
test "line characters are valid UTF-8" {
|
test "line characters are valid UTF-8" {
|
||||||
// Verify all characters decode properly
|
// Verify all characters decode properly
|
||||||
_ = std.unicode.utf8Decode(VERTICAL[0..3].*) catch unreachable;
|
const v: [3]u8 = VERTICAL[0..3].*;
|
||||||
_ = std.unicode.utf8Decode(HORIZONTAL[0..3].*) catch unreachable;
|
const h: [3]u8 = HORIZONTAL[0..3].*;
|
||||||
_ = std.unicode.utf8Decode(TOP_LEFT[0..3].*) catch unreachable;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
405
src/termcap.zig
Normal file
405
src/termcap.zig
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
//! Terminal capability detection.
|
||||||
|
//!
|
||||||
|
//! This module detects the color and feature support of the current terminal
|
||||||
|
//! by examining environment variables and terminal responses.
|
||||||
|
//!
|
||||||
|
//! ## Color Support Levels
|
||||||
|
//!
|
||||||
|
//! - **No color**: Monochrome terminal
|
||||||
|
//! - **Basic (8)**: 8 standard colors (black, red, green, yellow, blue, magenta, cyan, white)
|
||||||
|
//! - **Extended (16)**: 8 colors + 8 bright variants
|
||||||
|
//! - **256**: 256 color palette (6x6x6 cube + 24 grayscale)
|
||||||
|
//! - **TrueColor (24-bit)**: Full RGB support (16 million colors)
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```zig
|
||||||
|
//! const termcap = @import("termcap.zig");
|
||||||
|
//!
|
||||||
|
//! const caps = termcap.detect();
|
||||||
|
//!
|
||||||
|
//! if (caps.color_support.hasTrueColor()) {
|
||||||
|
//! // Use RGB colors
|
||||||
|
//! } else if (caps.color_support.has256()) {
|
||||||
|
//! // Fall back to 256 colors
|
||||||
|
//! } else {
|
||||||
|
//! // Use basic colors
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
|
/// Level of color support.
|
||||||
|
pub const ColorSupport = enum(u8) {
|
||||||
|
/// No color support (monochrome).
|
||||||
|
none = 0,
|
||||||
|
/// Basic 8 colors.
|
||||||
|
basic = 8,
|
||||||
|
/// 16 colors (8 + bright variants).
|
||||||
|
extended = 16,
|
||||||
|
/// 256 color palette.
|
||||||
|
palette_256 = 255,
|
||||||
|
/// 24-bit true color (16M colors).
|
||||||
|
true_color = 254,
|
||||||
|
|
||||||
|
/// Returns true if the terminal supports at least 256 colors.
|
||||||
|
pub fn has256(self: ColorSupport) bool {
|
||||||
|
return self == .palette_256 or self == .true_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the terminal supports true color (24-bit RGB).
|
||||||
|
pub fn hasTrueColor(self: ColorSupport) bool {
|
||||||
|
return self == .true_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the terminal supports any colors.
|
||||||
|
pub fn hasColor(self: ColorSupport) bool {
|
||||||
|
return self != .none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum number of colors supported.
|
||||||
|
pub fn maxColors(self: ColorSupport) u32 {
|
||||||
|
return switch (self) {
|
||||||
|
.none => 1,
|
||||||
|
.basic => 8,
|
||||||
|
.extended => 16,
|
||||||
|
.palette_256 => 256,
|
||||||
|
.true_color => 16777216,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Terminal capabilities.
|
||||||
|
pub const Capabilities = struct {
|
||||||
|
/// Color support level.
|
||||||
|
color_support: ColorSupport = .basic,
|
||||||
|
/// Terminal name (from TERM).
|
||||||
|
term_name: ?[]const u8 = null,
|
||||||
|
/// Terminal program (from TERM_PROGRAM).
|
||||||
|
term_program: ?[]const u8 = null,
|
||||||
|
/// Whether the terminal supports Unicode.
|
||||||
|
unicode: bool = true,
|
||||||
|
/// Whether the terminal supports hyperlinks (OSC 8).
|
||||||
|
hyperlinks: bool = false,
|
||||||
|
/// Whether the terminal supports images (Kitty/Sixel/iTerm2).
|
||||||
|
images: bool = false,
|
||||||
|
/// Whether the terminal supports OSC 52 clipboard.
|
||||||
|
clipboard: bool = false,
|
||||||
|
/// Whether the terminal supports bracketed paste.
|
||||||
|
bracketed_paste: bool = true,
|
||||||
|
/// Whether the terminal supports mouse reporting.
|
||||||
|
mouse: bool = true,
|
||||||
|
/// Whether the terminal supports alternate screen buffer.
|
||||||
|
alternate_screen: bool = true,
|
||||||
|
/// Whether the terminal supports styled underlines.
|
||||||
|
styled_underline: bool = false,
|
||||||
|
|
||||||
|
/// Returns true if this is a known modern terminal with good support.
|
||||||
|
pub fn isModern(self: Capabilities) bool {
|
||||||
|
return self.color_support.hasTrueColor() and
|
||||||
|
self.hyperlinks and
|
||||||
|
self.styled_underline;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Known terminal programs and their capabilities.
|
||||||
|
const KnownTerminal = struct {
|
||||||
|
name: []const u8,
|
||||||
|
color: ColorSupport,
|
||||||
|
hyperlinks: bool = false,
|
||||||
|
images: bool = false,
|
||||||
|
clipboard: bool = false,
|
||||||
|
styled_underline: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const known_terminals = [_]KnownTerminal{
|
||||||
|
// Modern terminals with full support
|
||||||
|
.{ .name = "kitty", .color = .true_color, .hyperlinks = true, .images = true, .clipboard = true, .styled_underline = true },
|
||||||
|
.{ .name = "WezTerm", .color = .true_color, .hyperlinks = true, .images = true, .clipboard = true, .styled_underline = true },
|
||||||
|
.{ .name = "iTerm.app", .color = .true_color, .hyperlinks = true, .images = true, .clipboard = true, .styled_underline = true },
|
||||||
|
.{ .name = "vscode", .color = .true_color, .hyperlinks = true, .clipboard = true, .styled_underline = true },
|
||||||
|
.{ .name = "Hyper", .color = .true_color, .hyperlinks = true, .clipboard = true },
|
||||||
|
.{ .name = "Alacritty", .color = .true_color, .hyperlinks = true, .clipboard = true },
|
||||||
|
.{ .name = "foot", .color = .true_color, .hyperlinks = true, .clipboard = true, .styled_underline = true },
|
||||||
|
.{ .name = "contour", .color = .true_color, .hyperlinks = true, .images = true, .clipboard = true, .styled_underline = true },
|
||||||
|
|
||||||
|
// Good terminals
|
||||||
|
.{ .name = "gnome-terminal", .color = .true_color, .hyperlinks = true },
|
||||||
|
.{ .name = "konsole", .color = .true_color, .hyperlinks = true },
|
||||||
|
.{ .name = "xfce4-terminal", .color = .true_color, .hyperlinks = true },
|
||||||
|
.{ .name = "terminator", .color = .true_color, .hyperlinks = true },
|
||||||
|
.{ .name = "tilix", .color = .true_color, .hyperlinks = true },
|
||||||
|
.{ .name = "rio", .color = .true_color, .hyperlinks = true, .images = true },
|
||||||
|
|
||||||
|
// Apple Terminal
|
||||||
|
.{ .name = "Apple_Terminal", .color = .palette_256 },
|
||||||
|
|
||||||
|
// tmux/screen (pass-through)
|
||||||
|
.{ .name = "tmux", .color = .true_color, .clipboard = true },
|
||||||
|
.{ .name = "screen", .color = .palette_256 },
|
||||||
|
|
||||||
|
// Basic terminals
|
||||||
|
.{ .name = "linux", .color = .basic }, // Linux console
|
||||||
|
.{ .name = "xterm", .color = .palette_256 },
|
||||||
|
.{ .name = "rxvt", .color = .palette_256 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Detects terminal capabilities from environment variables.
|
||||||
|
pub fn detect() Capabilities {
|
||||||
|
var caps = Capabilities{};
|
||||||
|
|
||||||
|
// Get TERM
|
||||||
|
caps.term_name = std.posix.getenv("TERM");
|
||||||
|
|
||||||
|
// Get TERM_PROGRAM
|
||||||
|
caps.term_program = std.posix.getenv("TERM_PROGRAM");
|
||||||
|
|
||||||
|
// Check for known terminal programs first
|
||||||
|
if (caps.term_program) |prog| {
|
||||||
|
for (known_terminals) |kt| {
|
||||||
|
if (std.mem.eql(u8, prog, kt.name)) {
|
||||||
|
caps.color_support = kt.color;
|
||||||
|
caps.hyperlinks = kt.hyperlinks;
|
||||||
|
caps.images = kt.images;
|
||||||
|
caps.clipboard = kt.clipboard;
|
||||||
|
caps.styled_underline = kt.styled_underline;
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check COLORTERM for true color
|
||||||
|
if (std.posix.getenv("COLORTERM")) |colorterm| {
|
||||||
|
if (std.mem.eql(u8, colorterm, "truecolor") or std.mem.eql(u8, colorterm, "24bit")) {
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TERM for color hints
|
||||||
|
if (caps.term_name) |term| {
|
||||||
|
// True color indicators
|
||||||
|
if (std.mem.indexOf(u8, term, "truecolor") != null or
|
||||||
|
std.mem.indexOf(u8, term, "24bit") != null or
|
||||||
|
std.mem.indexOf(u8, term, "direct") != null)
|
||||||
|
{
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
}
|
||||||
|
// 256 color indicators
|
||||||
|
else if (std.mem.indexOf(u8, term, "256color") != null or
|
||||||
|
std.mem.indexOf(u8, term, "256") != null)
|
||||||
|
{
|
||||||
|
if (caps.color_support != .true_color) {
|
||||||
|
caps.color_support = .palette_256;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Known terminal types
|
||||||
|
else {
|
||||||
|
for (known_terminals) |kt| {
|
||||||
|
if (std.mem.startsWith(u8, term, kt.name)) {
|
||||||
|
if (@intFromEnum(kt.color) > @intFromEnum(caps.color_support)) {
|
||||||
|
caps.color_support = kt.color;
|
||||||
|
}
|
||||||
|
caps.hyperlinks = caps.hyperlinks or kt.hyperlinks;
|
||||||
|
caps.images = caps.images or kt.images;
|
||||||
|
caps.clipboard = caps.clipboard or kt.clipboard;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific feature environment variables
|
||||||
|
if (std.posix.getenv("KITTY_WINDOW_ID") != null) {
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
caps.hyperlinks = true;
|
||||||
|
caps.images = true;
|
||||||
|
caps.clipboard = true;
|
||||||
|
caps.styled_underline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.posix.getenv("WEZTERM_PANE") != null) {
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
caps.hyperlinks = true;
|
||||||
|
caps.images = true;
|
||||||
|
caps.clipboard = true;
|
||||||
|
caps.styled_underline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.posix.getenv("ITERM_SESSION_ID") != null) {
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
caps.hyperlinks = true;
|
||||||
|
caps.images = true;
|
||||||
|
caps.clipboard = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (std.posix.getenv("VSCODE_INJECTION") != null or
|
||||||
|
std.posix.getenv("TERM_PROGRAM_VERSION") != null and caps.term_program != null and
|
||||||
|
std.mem.eql(u8, caps.term_program.?, "vscode"))
|
||||||
|
{
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
caps.hyperlinks = true;
|
||||||
|
caps.clipboard = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for NO_COLOR environment variable (https://no-color.org/)
|
||||||
|
if (std.posix.getenv("NO_COLOR") != null) {
|
||||||
|
caps.color_support = .none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for FORCE_COLOR environment variable
|
||||||
|
if (std.posix.getenv("FORCE_COLOR")) |force| {
|
||||||
|
if (force.len == 0 or std.mem.eql(u8, force, "1") or std.mem.eql(u8, force, "true")) {
|
||||||
|
if (caps.color_support == .none) {
|
||||||
|
caps.color_support = .basic;
|
||||||
|
}
|
||||||
|
} else if (std.mem.eql(u8, force, "2")) {
|
||||||
|
caps.color_support = .palette_256;
|
||||||
|
} else if (std.mem.eql(u8, force, "3")) {
|
||||||
|
caps.color_support = .true_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Unicode support via LANG/LC_ALL
|
||||||
|
const lang = std.posix.getenv("LC_ALL") orelse std.posix.getenv("LC_CTYPE") orelse std.posix.getenv("LANG");
|
||||||
|
if (lang) |l| {
|
||||||
|
caps.unicode = std.mem.indexOf(u8, l, "UTF-8") != null or
|
||||||
|
std.mem.indexOf(u8, l, "utf-8") != null or
|
||||||
|
std.mem.indexOf(u8, l, "UTF8") != null or
|
||||||
|
std.mem.indexOf(u8, l, "utf8") != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a color value appropriate for the terminal's color support level.
|
||||||
|
/// If the terminal doesn't support the given color depth, it will be
|
||||||
|
/// downgraded to a supported format.
|
||||||
|
pub fn adaptColor(caps: Capabilities, r: u8, g: u8, b: u8) union(enum) {
|
||||||
|
rgb: struct { r: u8, g: u8, b: u8 },
|
||||||
|
palette: u8,
|
||||||
|
basic: u8,
|
||||||
|
none: void,
|
||||||
|
} {
|
||||||
|
return switch (caps.color_support) {
|
||||||
|
.true_color => .{ .rgb = .{ .r = r, .g = g, .b = b } },
|
||||||
|
.palette_256 => .{ .palette = rgbTo256(r, g, b) },
|
||||||
|
.extended => .{ .basic = rgbToBasic(r, g, b, true) },
|
||||||
|
.basic => .{ .basic = rgbToBasic(r, g, b, false) },
|
||||||
|
.none => .{ .none = {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts RGB to the closest 256-color palette index.
|
||||||
|
pub fn rgbTo256(r: u8, g: u8, b: u8) u8 {
|
||||||
|
// Check if it's a grayscale
|
||||||
|
if (r == g and g == b) {
|
||||||
|
if (r < 8) return 16; // black
|
||||||
|
if (r > 248) return 231; // white
|
||||||
|
return @intCast((((@as(u16, r) - 8) * 24) / 240) + 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to 6x6x6 cube
|
||||||
|
const r6: u8 = @intCast(((@as(u16, r) * 6) / 256));
|
||||||
|
const g6: u8 = @intCast(((@as(u16, g) * 6) / 256));
|
||||||
|
const b6: u8 = @intCast(((@as(u16, b) * 6) / 256));
|
||||||
|
|
||||||
|
return 16 + 36 * r6 + 6 * g6 + b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts RGB to basic 8/16 color index.
|
||||||
|
pub fn rgbToBasic(r: u8, g: u8, b: u8, bright_support: bool) u8 {
|
||||||
|
// Determine "brightness" based on max channel value
|
||||||
|
const max_channel = @max(r, @max(g, b));
|
||||||
|
const is_bright = bright_support and max_channel > 170;
|
||||||
|
|
||||||
|
// Threshold for color detection
|
||||||
|
const threshold: u8 = 85;
|
||||||
|
|
||||||
|
var color: u8 = 0;
|
||||||
|
if (r >= threshold) color |= 1; // red
|
||||||
|
if (g >= threshold) color |= 2; // green
|
||||||
|
if (b >= threshold) color |= 4; // blue
|
||||||
|
|
||||||
|
// Calculate luminance for black/white decision
|
||||||
|
const lum: u32 = (@as(u32, r) * 299 + @as(u32, g) * 587 + @as(u32, b) * 114) / 1000;
|
||||||
|
|
||||||
|
// Map to ANSI colors
|
||||||
|
const base: u8 = switch (color) {
|
||||||
|
0 => if (lum > 64) 7 else 0, // black/white based on luminance
|
||||||
|
1 => 1, // red
|
||||||
|
2 => 2, // green
|
||||||
|
3 => 3, // yellow
|
||||||
|
4 => 4, // blue
|
||||||
|
5 => 5, // magenta
|
||||||
|
6 => 6, // cyan
|
||||||
|
7 => 7, // white
|
||||||
|
else => 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
return if (is_bright) base + 8 else base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
test "ColorSupport methods" {
|
||||||
|
try std.testing.expect(ColorSupport.true_color.hasTrueColor());
|
||||||
|
try std.testing.expect(ColorSupport.true_color.has256());
|
||||||
|
try std.testing.expect(ColorSupport.true_color.hasColor());
|
||||||
|
|
||||||
|
try std.testing.expect(!ColorSupport.palette_256.hasTrueColor());
|
||||||
|
try std.testing.expect(ColorSupport.palette_256.has256());
|
||||||
|
try std.testing.expect(ColorSupport.palette_256.hasColor());
|
||||||
|
|
||||||
|
try std.testing.expect(!ColorSupport.basic.hasTrueColor());
|
||||||
|
try std.testing.expect(!ColorSupport.basic.has256());
|
||||||
|
try std.testing.expect(ColorSupport.basic.hasColor());
|
||||||
|
|
||||||
|
try std.testing.expect(!ColorSupport.none.hasColor());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "maxColors" {
|
||||||
|
try std.testing.expectEqual(@as(u32, 16777216), ColorSupport.true_color.maxColors());
|
||||||
|
try std.testing.expectEqual(@as(u32, 256), ColorSupport.palette_256.maxColors());
|
||||||
|
try std.testing.expectEqual(@as(u32, 16), ColorSupport.extended.maxColors());
|
||||||
|
try std.testing.expectEqual(@as(u32, 8), ColorSupport.basic.maxColors());
|
||||||
|
try std.testing.expectEqual(@as(u32, 1), ColorSupport.none.maxColors());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "rgbTo256 grayscale" {
|
||||||
|
try std.testing.expectEqual(@as(u8, 16), rgbTo256(0, 0, 0));
|
||||||
|
try std.testing.expectEqual(@as(u8, 231), rgbTo256(255, 255, 255));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "rgbTo256 colors" {
|
||||||
|
// Pure red should be in the cube
|
||||||
|
const red = rgbTo256(255, 0, 0);
|
||||||
|
try std.testing.expect(red >= 16 and red <= 231);
|
||||||
|
|
||||||
|
// Pure green
|
||||||
|
const green = rgbTo256(0, 255, 0);
|
||||||
|
try std.testing.expect(green >= 16 and green <= 231);
|
||||||
|
|
||||||
|
// Pure blue
|
||||||
|
const blue = rgbTo256(0, 0, 255);
|
||||||
|
try std.testing.expect(blue >= 16 and blue <= 231);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "rgbToBasic" {
|
||||||
|
// Black
|
||||||
|
try std.testing.expectEqual(@as(u8, 0), rgbToBasic(0, 0, 0, false));
|
||||||
|
// White
|
||||||
|
try std.testing.expectEqual(@as(u8, 7), rgbToBasic(255, 255, 255, false));
|
||||||
|
// Red
|
||||||
|
try std.testing.expectEqual(@as(u8, 1), rgbToBasic(255, 0, 0, false));
|
||||||
|
// Bright red
|
||||||
|
try std.testing.expectEqual(@as(u8, 9), rgbToBasic(255, 0, 0, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "detect returns valid capabilities" {
|
||||||
|
const caps = detect();
|
||||||
|
// Just verify it doesn't crash and returns something valid
|
||||||
|
try std.testing.expect(@intFromEnum(caps.color_support) <= 255);
|
||||||
|
}
|
||||||
126
src/tests/layout_tests.zig
Normal file
126
src/tests/layout_tests.zig
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
18
src/tests/tests.zig
Normal file
18
src/tests/tests.zig
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
104
src/tests/theme_tests.zig
Normal file
104
src/tests/theme_tests.zig
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
490
src/tests/widget_tests.zig
Normal file
490
src/tests/widget_tests.zig
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
657
src/theme.zig
Normal file
657
src/theme.zig
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
330
src/unicode.zig
Normal file
330
src/unicode.zig
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
//! Unicode width calculation for TUI rendering.
|
||||||
|
//!
|
||||||
|
//! This module provides functions to calculate the display width of Unicode
|
||||||
|
//! characters and strings, essential for proper text alignment in terminal UIs.
|
||||||
|
//!
|
||||||
|
//! Most characters are single-width (1 cell), but:
|
||||||
|
//! - CJK characters are double-width (2 cells)
|
||||||
|
//! - Combining characters are zero-width (0 cells)
|
||||||
|
//! - Control characters are zero-width (0 cells)
|
||||||
|
//! - Some emojis are double-width (2 cells)
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```zig
|
||||||
|
//! const unicode = @import("unicode.zig");
|
||||||
|
//!
|
||||||
|
//! // Single-width ASCII
|
||||||
|
//! try testing.expectEqual(@as(usize, 5), unicode.stringWidth("Hello"));
|
||||||
|
//!
|
||||||
|
//! // Double-width CJK
|
||||||
|
//! try testing.expectEqual(@as(usize, 4), unicode.stringWidth("日本"));
|
||||||
|
//!
|
||||||
|
//! // Mixed content
|
||||||
|
//! try testing.expectEqual(@as(usize, 9), unicode.stringWidth("Hello日本"));
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Returns the display width of a Unicode codepoint.
|
||||||
|
///
|
||||||
|
/// - Returns 0 for control characters and combining marks
|
||||||
|
/// - Returns 1 for most characters (ASCII, Latin, etc.)
|
||||||
|
/// - Returns 2 for wide characters (CJK, some emojis)
|
||||||
|
/// - Returns -1 for non-printable characters (use 0 in most cases)
|
||||||
|
pub fn charWidth(codepoint: u21) i8 {
|
||||||
|
// Control characters (C0 and DEL)
|
||||||
|
if (codepoint < 0x20 or codepoint == 0x7F) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1 control characters
|
||||||
|
if (codepoint >= 0x80 and codepoint < 0xA0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combining characters (zero-width)
|
||||||
|
if (isCombining(codepoint)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-width characters
|
||||||
|
if (isZeroWidth(codepoint)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wide characters (CJK, etc.)
|
||||||
|
if (isWide(codepoint)) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: single width
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the display width of a Unicode codepoint as usize.
|
||||||
|
/// Non-printable characters return 0.
|
||||||
|
pub fn charWidthUnsigned(codepoint: u21) usize {
|
||||||
|
const w = charWidth(codepoint);
|
||||||
|
return if (w < 0) 0 else @intCast(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the display width of a UTF-8 encoded string.
|
||||||
|
pub fn stringWidth(str: []const u8) usize {
|
||||||
|
var width: usize = 0;
|
||||||
|
var iter = std.unicode.Utf8Iterator{ .bytes = str, .i = 0 };
|
||||||
|
|
||||||
|
while (iter.nextCodepoint()) |cp| {
|
||||||
|
width += charWidthUnsigned(cp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the display width of a UTF-8 string, stopping at max_width.
|
||||||
|
/// Returns the number of bytes consumed and the display width.
|
||||||
|
pub fn stringWidthBounded(str: []const u8, max_width: usize) struct { bytes: usize, width: usize } {
|
||||||
|
var width: usize = 0;
|
||||||
|
var byte_pos: usize = 0;
|
||||||
|
var iter = std.unicode.Utf8Iterator{ .bytes = str, .i = 0 };
|
||||||
|
|
||||||
|
while (iter.nextCodepoint()) |cp| {
|
||||||
|
const cw = charWidthUnsigned(cp);
|
||||||
|
if (width + cw > max_width) break;
|
||||||
|
width += cw;
|
||||||
|
byte_pos = iter.i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ .bytes = byte_pos, .width = width };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncates a string to fit within max_width display columns.
|
||||||
|
/// Returns a slice of the original string.
|
||||||
|
pub fn truncateToWidth(str: []const u8, max_width: usize) []const u8 {
|
||||||
|
const result = stringWidthBounded(str, max_width);
|
||||||
|
return str[0..result.bytes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pads a string to exactly the specified width.
|
||||||
|
/// If the string is wider, it is truncated.
|
||||||
|
/// Returns a new slice (or the original if no padding needed).
|
||||||
|
pub fn padToWidth(allocator: std.mem.Allocator, str: []const u8, target_width: usize) ![]u8 {
|
||||||
|
const current_width = stringWidth(str);
|
||||||
|
|
||||||
|
if (current_width >= target_width) {
|
||||||
|
// Truncate if needed
|
||||||
|
const truncated = truncateToWidth(str, target_width);
|
||||||
|
const result = try allocator.alloc(u8, truncated.len);
|
||||||
|
@memcpy(result, truncated);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad with spaces
|
||||||
|
const padding = target_width - current_width;
|
||||||
|
const result = try allocator.alloc(u8, str.len + padding);
|
||||||
|
@memcpy(result[0..str.len], str);
|
||||||
|
@memset(result[str.len..], ' ');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a codepoint is a combining character (zero-width).
|
||||||
|
fn isCombining(cp: u21) bool {
|
||||||
|
// Combining Diacritical Marks
|
||||||
|
if (cp >= 0x0300 and cp <= 0x036F) return true;
|
||||||
|
// Combining Diacritical Marks Extended
|
||||||
|
if (cp >= 0x1AB0 and cp <= 0x1AFF) return true;
|
||||||
|
// Combining Diacritical Marks Supplement
|
||||||
|
if (cp >= 0x1DC0 and cp <= 0x1DFF) return true;
|
||||||
|
// Combining Diacritical Marks for Symbols
|
||||||
|
if (cp >= 0x20D0 and cp <= 0x20FF) return true;
|
||||||
|
// Combining Half Marks
|
||||||
|
if (cp >= 0xFE20 and cp <= 0xFE2F) return true;
|
||||||
|
|
||||||
|
// Thai combining marks
|
||||||
|
if (cp >= 0x0E31 and cp <= 0x0E3A) return true;
|
||||||
|
if (cp >= 0x0E47 and cp <= 0x0E4E) return true;
|
||||||
|
|
||||||
|
// Hebrew combining marks
|
||||||
|
if (cp >= 0x0591 and cp <= 0x05BD) return true;
|
||||||
|
if (cp == 0x05BF or cp == 0x05C1 or cp == 0x05C2 or cp == 0x05C4 or cp == 0x05C5 or cp == 0x05C7) return true;
|
||||||
|
|
||||||
|
// Arabic combining marks
|
||||||
|
if (cp >= 0x0610 and cp <= 0x061A) return true;
|
||||||
|
if (cp >= 0x064B and cp <= 0x065F) return true;
|
||||||
|
if (cp == 0x0670) return true;
|
||||||
|
if (cp >= 0x06D6 and cp <= 0x06DC) return true;
|
||||||
|
if (cp >= 0x06DF and cp <= 0x06E4) return true;
|
||||||
|
if (cp >= 0x06E7 and cp <= 0x06E8) return true;
|
||||||
|
if (cp >= 0x06EA and cp <= 0x06ED) return true;
|
||||||
|
|
||||||
|
// Variation selectors
|
||||||
|
if (cp >= 0xFE00 and cp <= 0xFE0F) return true;
|
||||||
|
if (cp >= 0xE0100 and cp <= 0xE01EF) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a codepoint is zero-width (but not combining).
|
||||||
|
fn isZeroWidth(cp: u21) bool {
|
||||||
|
// Soft hyphen
|
||||||
|
if (cp == 0x00AD) return true;
|
||||||
|
// Zero-width space
|
||||||
|
if (cp == 0x200B) return true;
|
||||||
|
// Zero-width non-joiner
|
||||||
|
if (cp == 0x200C) return true;
|
||||||
|
// Zero-width joiner
|
||||||
|
if (cp == 0x200D) return true;
|
||||||
|
// Word joiner
|
||||||
|
if (cp == 0x2060) return true;
|
||||||
|
// Zero-width no-break space (BOM when not at start)
|
||||||
|
if (cp == 0xFEFF) return true;
|
||||||
|
|
||||||
|
// Default ignorables
|
||||||
|
if (cp >= 0x2060 and cp <= 0x206F) return true;
|
||||||
|
|
||||||
|
// Hangul fillers
|
||||||
|
if (cp == 0x115F or cp == 0x1160) return true;
|
||||||
|
if (cp >= 0x3164 and cp <= 0x3164) return true;
|
||||||
|
if (cp == 0xFFA0) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a codepoint is a wide character (2 cells).
|
||||||
|
fn isWide(cp: u21) bool {
|
||||||
|
// CJK Radicals Supplement
|
||||||
|
if (cp >= 0x2E80 and cp <= 0x2EFF) return true;
|
||||||
|
// Kangxi Radicals
|
||||||
|
if (cp >= 0x2F00 and cp <= 0x2FDF) return true;
|
||||||
|
// CJK Symbols and Punctuation
|
||||||
|
if (cp >= 0x3000 and cp <= 0x303F) return true;
|
||||||
|
// Hiragana
|
||||||
|
if (cp >= 0x3040 and cp <= 0x309F) return true;
|
||||||
|
// Katakana
|
||||||
|
if (cp >= 0x30A0 and cp <= 0x30FF) return true;
|
||||||
|
// Bopomofo
|
||||||
|
if (cp >= 0x3100 and cp <= 0x312F) return true;
|
||||||
|
// Hangul Compatibility Jamo
|
||||||
|
if (cp >= 0x3130 and cp <= 0x318F) return true;
|
||||||
|
// Kanbun
|
||||||
|
if (cp >= 0x3190 and cp <= 0x319F) return true;
|
||||||
|
// Bopomofo Extended
|
||||||
|
if (cp >= 0x31A0 and cp <= 0x31BF) return true;
|
||||||
|
// CJK Strokes
|
||||||
|
if (cp >= 0x31C0 and cp <= 0x31EF) return true;
|
||||||
|
// Katakana Phonetic Extensions
|
||||||
|
if (cp >= 0x31F0 and cp <= 0x31FF) return true;
|
||||||
|
// Enclosed CJK Letters and Months
|
||||||
|
if (cp >= 0x3200 and cp <= 0x32FF) return true;
|
||||||
|
// CJK Compatibility
|
||||||
|
if (cp >= 0x3300 and cp <= 0x33FF) return true;
|
||||||
|
// CJK Unified Ideographs Extension A
|
||||||
|
if (cp >= 0x3400 and cp <= 0x4DBF) return true;
|
||||||
|
// CJK Unified Ideographs
|
||||||
|
if (cp >= 0x4E00 and cp <= 0x9FFF) return true;
|
||||||
|
// Yi Syllables
|
||||||
|
if (cp >= 0xA000 and cp <= 0xA48F) return true;
|
||||||
|
// Yi Radicals
|
||||||
|
if (cp >= 0xA490 and cp <= 0xA4CF) return true;
|
||||||
|
// Hangul Syllables
|
||||||
|
if (cp >= 0xAC00 and cp <= 0xD7AF) return true;
|
||||||
|
// CJK Compatibility Ideographs
|
||||||
|
if (cp >= 0xF900 and cp <= 0xFAFF) return true;
|
||||||
|
// Halfwidth and Fullwidth Forms (fullwidth only)
|
||||||
|
if (cp >= 0xFF00 and cp <= 0xFF60) return true;
|
||||||
|
if (cp >= 0xFFE0 and cp <= 0xFFE6) return true;
|
||||||
|
// CJK Unified Ideographs Extension B-F
|
||||||
|
if (cp >= 0x20000 and cp <= 0x2A6DF) return true;
|
||||||
|
if (cp >= 0x2A700 and cp <= 0x2B73F) return true;
|
||||||
|
if (cp >= 0x2B740 and cp <= 0x2B81F) return true;
|
||||||
|
if (cp >= 0x2B820 and cp <= 0x2CEAF) return true;
|
||||||
|
if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return true;
|
||||||
|
if (cp >= 0x30000 and cp <= 0x3134F) return true;
|
||||||
|
|
||||||
|
// Some emoji are wide
|
||||||
|
// Emoji modifiers and ZWJ sequences handled separately
|
||||||
|
// Basic wide emoji ranges
|
||||||
|
if (cp >= 0x1F300 and cp <= 0x1F64F) return true; // Misc Symbols and Pictographs + Emoticons
|
||||||
|
if (cp >= 0x1F680 and cp <= 0x1F6FF) return true; // Transport and Map Symbols
|
||||||
|
if (cp >= 0x1F900 and cp <= 0x1F9FF) return true; // Supplemental Symbols and Pictographs
|
||||||
|
if (cp >= 0x1FA00 and cp <= 0x1FA6F) return true; // Chess Symbols
|
||||||
|
if (cp >= 0x1FA70 and cp <= 0x1FAFF) return true; // Symbols and Pictographs Extended-A
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
test "ASCII characters are single-width" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 1), charWidth('a'));
|
||||||
|
try std.testing.expectEqual(@as(i8, 1), charWidth('Z'));
|
||||||
|
try std.testing.expectEqual(@as(i8, 1), charWidth('0'));
|
||||||
|
try std.testing.expectEqual(@as(i8, 1), charWidth('!'));
|
||||||
|
try std.testing.expectEqual(@as(i8, 1), charWidth(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Control characters are zero-width" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x00)); // NUL
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x0A)); // LF
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x0D)); // CR
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x1B)); // ESC
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x7F)); // DEL
|
||||||
|
}
|
||||||
|
|
||||||
|
test "CJK characters are double-width" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x4E2D)); // 中
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x6587)); // 文
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x65E5)); // 日
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x672C)); // 本
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Hiragana/Katakana are double-width" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x3042)); // あ
|
||||||
|
try std.testing.expectEqual(@as(i8, 2), charWidth(0x30A2)); // ア
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Combining characters are zero-width" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x0301)); // combining acute
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x0308)); // combining diaeresis
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Zero-width characters" {
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x200B)); // ZWSP
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0x200D)); // ZWJ
|
||||||
|
try std.testing.expectEqual(@as(i8, 0), charWidth(0xFEFF)); // BOM
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stringWidth for ASCII" {
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), stringWidth("Hello"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stringWidth(""));
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stringWidth("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stringWidth for CJK" {
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), stringWidth("日本"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), stringWidth("中文字"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stringWidth for mixed content" {
|
||||||
|
try std.testing.expectEqual(@as(usize, 9), stringWidth("Hello日本"));
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), stringWidth("a日b本c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "truncateToWidth" {
|
||||||
|
const result = truncateToWidth("Hello World", 5);
|
||||||
|
try std.testing.expectEqualStrings("Hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "truncateToWidth with CJK" {
|
||||||
|
// "日本" = 4 width, truncate to 3 should give "日" (2 width)
|
||||||
|
const result = truncateToWidth("日本語", 3);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), result.len); // 日 is 3 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
test "stringWidthBounded" {
|
||||||
|
const result = stringWidthBounded("Hello World", 5);
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), result.bytes);
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), result.width);
|
||||||
|
}
|
||||||
|
|
@ -293,7 +293,9 @@ test "Block render compiles" {
|
||||||
|
|
||||||
block.render(Rect.init(0, 0, 20, 10), &buf);
|
block.render(Rect.init(0, 0, 20, 10), &buf);
|
||||||
|
|
||||||
// Check corners
|
// Check corners - compare using Symbol.eql
|
||||||
try std.testing.expectEqual(BorderSet.single.top_left, buf.get(0, 0).?.char);
|
const expected_tl = buffer.Symbol.fromCodepoint(BorderSet.single.top_left);
|
||||||
try std.testing.expectEqual(BorderSet.single.top_right, buf.get(19, 0).?.char);
|
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@ test "Toast visibility" {
|
||||||
try std.testing.expect(toast.visible);
|
try std.testing.expect(toast.visible);
|
||||||
|
|
||||||
// Wait for expiry
|
// Wait for expiry
|
||||||
std.time.sleep(150 * std.time.ns_per_ms);
|
std.Thread.sleep(150 * std.time.ns_per_ms);
|
||||||
toast.update();
|
toast.update();
|
||||||
try std.testing.expect(!toast.visible);
|
try std.testing.expect(!toast.visible);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue