feat: Add LEGO Panel System

A modular panel system for building complex layouts:

Panel:
- Basic container with borders, title, focus state
- Min/max width/height constraints
- Content render callback

PanelSplit:
- Split container (horizontal/vertical)
- Configurable ratios per child
- Gap between panels

TabbedPanel:
- Multiple panels with tab navigation
- Tab bar rendering
- Per-tab content renderers

DockingPanel:
- Dockable positions (left/right/top/bottom/center)
- Floating mode with custom position/size
- Show/hide/close functionality

PanelManager:
- Manages collection of panels
- Focus navigation
- Layered rendering (docked → center → floating)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 17:40:38 +01:00
parent 04e18ff0c1
commit d3e42a241d
2 changed files with 775 additions and 0 deletions

View file

@ -172,6 +172,15 @@ pub const widgets = struct {
pub const ScrollState = scroll_mod.ScrollState;
pub const VirtualList = scroll_mod.VirtualList;
pub const InfiniteScroll = scroll_mod.InfiniteScroll;
pub const panel_mod = @import("widgets/panel.zig");
pub const Panel = panel_mod.Panel;
pub const PanelSplit = panel_mod.PanelSplit;
pub const TabbedPanel = panel_mod.TabbedPanel;
pub const DockingPanel = panel_mod.DockingPanel;
pub const DockPosition = panel_mod.DockPosition;
pub const PanelManager = panel_mod.PanelManager;
pub const SplitDirection = panel_mod.SplitDirection;
};
// Backend

766
src/widgets/panel.zig Normal file
View file

@ -0,0 +1,766 @@
//! LEGO Panel System for zcatui.
//!
//! A modular panel system that allows building complex layouts by
//! combining and nesting panels, similar to LEGO blocks.
//!
//! ## Features
//!
//! - **Panel**: Basic container with borders and title
//! - **PanelGroup**: Split container (horizontal/vertical)
//! - **TabbedPanel**: Multiple panels with tab navigation
//! - **ResizablePanel**: Draggable dividers for resizing
//! - **DockingPanel**: Dockable/floating panels
//!
//! ## Example
//!
//! ```zig
//! // Create a layout with sidebar and main area
//! var layout = PanelGroup.horizontal(&.{
//! Panel.init("Sidebar").setMinWidth(20),
//! PanelGroup.vertical(&.{
//! Panel.init("Main Content"),
//! Panel.init("Console").setMaxHeight(10),
//! }),
//! });
//!
//! layout.render(area, buf);
//! ```
const std = @import("std");
const Allocator = std.mem.Allocator;
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
const layout_mod = @import("../layout.zig");
const Layout = layout_mod.Layout;
const Constraint = layout_mod.Constraint;
// ============================================================================
// Panel
// ============================================================================
/// A basic panel widget.
pub const Panel = struct {
/// Panel title.
title: ?[]const u8 = null,
/// Panel ID (for identification).
id: ?[]const u8 = null,
/// Base style.
style: Style = Style.default,
/// Border style.
border_style: Style = Style.default,
/// Whether panel is focused.
focused: bool = false,
/// Focused style.
focused_style: Style = Style.default.fg(Color.cyan),
/// Whether to show borders.
show_border: bool = true,
/// Minimum width.
min_width: u16 = 1,
/// Minimum height.
min_height: u16 = 1,
/// Maximum width (0 = unlimited).
max_width: u16 = 0,
/// Maximum height (0 = unlimited).
max_height: u16 = 0,
/// Content render function.
render_content: ?*const fn (*Panel, Rect, *Buffer) void = null,
/// User data.
user_data: ?*anyopaque = null,
/// Creates a new panel.
pub fn init(title: ?[]const u8) Panel {
return .{
.title = title,
};
}
/// Sets the title.
pub fn setTitle(self: Panel, title: []const u8) Panel {
var p = self;
p.title = title;
return p;
}
/// Sets the ID.
pub fn setId(self: Panel, id: []const u8) Panel {
var p = self;
p.id = id;
return p;
}
/// Sets the style.
pub fn setStyle(self: Panel, style: Style) Panel {
var p = self;
p.style = style;
return p;
}
/// Sets whether focused.
pub fn setFocused(self: Panel, focused: bool) Panel {
var p = self;
p.focused = focused;
return p;
}
/// Sets minimum width.
pub fn setMinWidth(self: Panel, width: u16) Panel {
var p = self;
p.min_width = width;
return p;
}
/// Sets minimum height.
pub fn setMinHeight(self: Panel, height: u16) Panel {
var p = self;
p.min_height = height;
return p;
}
/// Sets maximum width.
pub fn setMaxWidth(self: Panel, width: u16) Panel {
var p = self;
p.max_width = width;
return p;
}
/// Sets maximum height.
pub fn setMaxHeight(self: Panel, height: u16) Panel {
var p = self;
p.max_height = height;
return p;
}
/// Sets content renderer.
pub fn setRenderer(self: Panel, render_fn: *const fn (*Panel, Rect, *Buffer) void) Panel {
var p = self;
p.render_content = render_fn;
return p;
}
/// Gets the inner area (content area after borders).
pub fn innerArea(self: Panel, area: Rect) Rect {
if (self.show_border) {
return Rect.init(
area.x + 1,
area.y + 1,
area.width -| 2,
area.height -| 2,
);
}
return area;
}
/// Renders the panel.
pub fn render(self: *Panel, area: Rect, buf: *Buffer) void {
// Clear area
buf.setStyle(area, self.style);
// Render border
if (self.show_border) {
const bs = if (self.focused) self.focused_style else self.border_style;
var block = Block.init().setBorders(Borders.all).style(bs);
if (self.title) |t| {
block = block.title(t);
}
block.render(area, buf);
}
// Render content
if (self.render_content) |render_fn| {
render_fn(self, self.innerArea(area), buf);
}
}
};
// ============================================================================
// PanelSplit
// ============================================================================
/// Split direction.
pub const SplitDirection = enum {
horizontal,
vertical,
};
/// A panel split into multiple sections.
pub const PanelSplit = struct {
/// Split direction.
direction: SplitDirection,
/// Child panels.
children: []Panel,
/// Ratios for each child (1.0 = equal).
ratios: []f32,
/// Allocator.
allocator: Allocator,
/// Gap between panels.
gap: u16 = 0,
/// Creates a horizontal split.
pub fn horizontal(allocator: Allocator, panels: []const Panel) !PanelSplit {
return try create(allocator, .horizontal, panels);
}
/// Creates a vertical split.
pub fn vertical(allocator: Allocator, panels: []const Panel) !PanelSplit {
return try create(allocator, .vertical, panels);
}
fn create(allocator: Allocator, direction: SplitDirection, panels: []const Panel) !PanelSplit {
const children = try allocator.alloc(Panel, panels.len);
@memcpy(children, panels);
const ratios = try allocator.alloc(f32, panels.len);
for (ratios) |*r| {
r.* = 1.0;
}
return .{
.direction = direction,
.children = children,
.ratios = ratios,
.allocator = allocator,
};
}
/// Frees resources.
pub fn deinit(self: *PanelSplit) void {
self.allocator.free(self.children);
self.allocator.free(self.ratios);
}
/// Sets ratio for a child.
pub fn setRatio(self: *PanelSplit, index: usize, ratio: f32) void {
if (index < self.ratios.len) {
self.ratios[index] = ratio;
}
}
/// Calculates areas for children.
pub fn calculateAreas(self: PanelSplit, area: Rect) []Rect {
// Static buffer for simplicity
var areas: [16]Rect = undefined;
if (self.children.len == 0) return areas[0..0];
if (self.children.len > 16) return areas[0..16];
// Calculate total ratio
var total_ratio: f32 = 0;
for (self.ratios[0..self.children.len]) |r| {
total_ratio += r;
}
// Calculate sizes
const total_gap = self.gap * @as(u16, @intCast(self.children.len - 1));
const available = switch (self.direction) {
.horizontal => area.width -| total_gap,
.vertical => area.height -| total_gap,
};
var pos: u16 = switch (self.direction) {
.horizontal => area.x,
.vertical => area.y,
};
var remaining = available;
for (self.children, 0..) |_, i| {
const ratio = self.ratios[i] / total_ratio;
const size: u16 = if (i == self.children.len - 1)
remaining
else
@intFromFloat(@as(f32, @floatFromInt(available)) * ratio);
areas[i] = switch (self.direction) {
.horizontal => Rect.init(pos, area.y, size, area.height),
.vertical => Rect.init(area.x, pos, area.width, size),
};
pos += size + self.gap;
remaining -|= size;
}
return areas[0..self.children.len];
}
/// Renders the split.
pub fn render(self: *PanelSplit, area: Rect, buf: *Buffer) void {
const child_areas = self.calculateAreas(area);
for (self.children, 0..) |*child, i| {
if (i < child_areas.len) {
child.render(child_areas[i], buf);
}
}
}
};
// ============================================================================
// TabbedPanel
// ============================================================================
/// A panel with tabs.
pub const TabbedPanel = struct {
/// Tab titles.
tabs: []const []const u8,
/// Currently selected tab.
selected: usize = 0,
/// Tab bar height.
tab_height: u16 = 1,
/// Style.
style: Style = Style.default,
/// Selected tab style.
selected_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Content render functions (one per tab).
renderers: []const ?*const fn (usize, Rect, *Buffer) void = &.{},
/// Creates a tabbed panel.
pub fn init(tabs: []const []const u8) TabbedPanel {
return .{
.tabs = tabs,
};
}
/// Sets renderers for each tab.
pub fn setRenderers(self: TabbedPanel, renderers: []const ?*const fn (usize, Rect, *Buffer) void) TabbedPanel {
var tp = self;
tp.renderers = renderers;
return tp;
}
/// Selects a tab.
pub fn select(self: *TabbedPanel, index: usize) void {
if (index < self.tabs.len) {
self.selected = index;
}
}
/// Selects next tab.
pub fn selectNext(self: *TabbedPanel) void {
if (self.selected + 1 < self.tabs.len) {
self.selected += 1;
}
}
/// Selects previous tab.
pub fn selectPrev(self: *TabbedPanel) void {
if (self.selected > 0) {
self.selected -= 1;
}
}
/// Renders the tabbed panel.
pub fn render(self: *TabbedPanel, area: Rect, buf: *Buffer) void {
if (area.height < self.tab_height + 2) return;
// Render tab bar
const tab_area = Rect.init(area.x, area.y, area.width, self.tab_height);
self.renderTabs(tab_area, buf);
// Render content
const content_area = Rect.init(
area.x,
area.y + self.tab_height,
area.width,
area.height - self.tab_height,
);
// Border around content
const block = Block.init().setBorders(Borders.all);
block.render(content_area, buf);
// Content
if (self.selected < self.renderers.len) {
if (self.renderers[self.selected]) |render_fn| {
render_fn(self.selected, block.inner(content_area), buf);
}
}
}
fn renderTabs(self: *TabbedPanel, area: Rect, buf: *Buffer) void {
var x = area.x;
for (self.tabs, 0..) |tab, i| {
const tab_width: u16 = @intCast(@min(tab.len + 2, area.width -| x));
if (tab_width == 0) break;
const tab_style = if (i == self.selected) self.selected_style else self.style;
// Fill tab background
var tx = x;
while (tx < x + tab_width) : (tx += 1) {
if (buf.getCell(tx, area.y)) |cell| {
cell.setChar(' ');
cell.setStyle(tab_style);
}
}
// Tab text
_ = buf.setString(x + 1, area.y, tab, tab_style);
x += tab_width + 1;
}
}
};
// ============================================================================
// DockPosition
// ============================================================================
/// Docking position.
pub const DockPosition = enum {
left,
right,
top,
bottom,
center,
floating,
};
// ============================================================================
// DockingPanel
// ============================================================================
/// A dockable/floatable panel.
pub const DockingPanel = struct {
/// Inner panel.
panel: Panel,
/// Current dock position.
position: DockPosition = .center,
/// Floating position (when floating).
float_x: u16 = 10,
float_y: u16 = 5,
/// Floating size.
float_width: u16 = 40,
float_height: u16 = 15,
/// Whether panel is visible.
visible: bool = true,
/// Whether panel can be closed.
closable: bool = true,
/// Whether panel can float.
floatable: bool = true,
/// Size when docked (percentage 1-100).
dock_size: u8 = 25,
/// Creates a docking panel.
pub fn init(title: []const u8) DockingPanel {
return .{
.panel = Panel.init(title),
};
}
/// Sets dock position.
pub fn setPosition(self: DockingPanel, pos: DockPosition) DockingPanel {
var dp = self;
dp.position = pos;
return dp;
}
/// Sets dock size percentage.
pub fn setDockSize(self: DockingPanel, size: u8) DockingPanel {
var dp = self;
dp.dock_size = @min(size, 100);
return dp;
}
/// Toggles floating.
pub fn toggleFloat(self: *DockingPanel) void {
if (self.floatable) {
if (self.position == .floating) {
self.position = .center;
} else {
self.position = .floating;
}
}
}
/// Shows the panel.
pub fn show(self: *DockingPanel) void {
self.visible = true;
}
/// Hides the panel.
pub fn hide(self: *DockingPanel) void {
self.visible = false;
}
/// Closes the panel.
pub fn close(self: *DockingPanel) void {
if (self.closable) {
self.visible = false;
}
}
/// Calculates panel area based on dock position.
pub fn calculateArea(self: DockingPanel, bounds: Rect) Rect {
if (!self.visible) return Rect.init(0, 0, 0, 0);
switch (self.position) {
.floating => {
return Rect.init(
self.float_x,
self.float_y,
@min(self.float_width, bounds.width -| self.float_x),
@min(self.float_height, bounds.height -| self.float_y),
);
},
.left => {
const width = bounds.width * self.dock_size / 100;
return Rect.init(bounds.x, bounds.y, width, bounds.height);
},
.right => {
const width = bounds.width * self.dock_size / 100;
return Rect.init(bounds.x + bounds.width - width, bounds.y, width, bounds.height);
},
.top => {
const height = bounds.height * self.dock_size / 100;
return Rect.init(bounds.x, bounds.y, bounds.width, height);
},
.bottom => {
const height = bounds.height * self.dock_size / 100;
return Rect.init(bounds.x, bounds.y + bounds.height - height, bounds.width, height);
},
.center => return bounds,
}
}
/// Renders the panel.
pub fn render(self: *DockingPanel, bounds: Rect, buf: *Buffer) void {
if (!self.visible) return;
const area = self.calculateArea(bounds);
if (area.width == 0 or area.height == 0) return;
self.panel.render(area, buf);
}
};
// ============================================================================
// PanelManager
// ============================================================================
/// Manages a collection of panels.
pub const PanelManager = struct {
/// All panels.
panels: std.ArrayList(DockingPanel),
/// Currently focused panel index.
focused: ?usize = null,
/// Allocator.
allocator: Allocator,
/// Creates a new panel manager.
pub fn init(allocator: Allocator) PanelManager {
return .{
.panels = std.ArrayList(DockingPanel).init(allocator),
.allocator = allocator,
};
}
/// Frees resources.
pub fn deinit(self: *PanelManager) void {
self.panels.deinit();
}
/// Adds a panel.
pub fn add(self: *PanelManager, panel: DockingPanel) !usize {
try self.panels.append(panel);
return self.panels.items.len - 1;
}
/// Gets a panel by index.
pub fn get(self: *PanelManager, index: usize) ?*DockingPanel {
if (index < self.panels.items.len) {
return &self.panels.items[index];
}
return null;
}
/// Focuses a panel.
pub fn focus(self: *PanelManager, index: usize) void {
// Unfocus previous
if (self.focused) |f| {
if (f < self.panels.items.len) {
self.panels.items[f].panel.focused = false;
}
}
// Focus new
if (index < self.panels.items.len) {
self.focused = index;
self.panels.items[index].panel.focused = true;
}
}
/// Focuses next panel.
pub fn focusNext(self: *PanelManager) void {
if (self.panels.items.len == 0) return;
const next = if (self.focused) |f|
(f + 1) % self.panels.items.len
else
0;
self.focus(next);
}
/// Renders all panels.
pub fn render(self: *PanelManager, bounds: Rect, buf: *Buffer) void {
// Render docked panels first (left, right, top, bottom)
var remaining = bounds;
for (self.panels.items) |*panel| {
if (!panel.visible) continue;
switch (panel.position) {
.left => {
const area = panel.calculateArea(remaining);
panel.panel.render(area, buf);
remaining.x += area.width;
remaining.width -|= area.width;
},
.right => {
const area = panel.calculateArea(remaining);
panel.panel.render(area, buf);
remaining.width -|= area.width;
},
.top => {
const area = panel.calculateArea(remaining);
panel.panel.render(area, buf);
remaining.y += area.height;
remaining.height -|= area.height;
},
.bottom => {
const area = panel.calculateArea(remaining);
panel.panel.render(area, buf);
remaining.height -|= area.height;
},
else => {},
}
}
// Render center panels
for (self.panels.items) |*panel| {
if (!panel.visible) continue;
if (panel.position == .center) {
panel.panel.render(remaining, buf);
}
}
// Render floating panels last (on top)
for (self.panels.items) |*panel| {
if (!panel.visible) continue;
if (panel.position == .floating) {
panel.render(bounds, buf);
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "Panel basic" {
var panel = Panel.init("Test");
try std.testing.expectEqualStrings("Test", panel.title.?);
panel = panel.setMinWidth(10).setMaxHeight(20);
try std.testing.expectEqual(@as(u16, 10), panel.min_width);
try std.testing.expectEqual(@as(u16, 20), panel.max_height);
}
test "Panel innerArea" {
const panel = Panel.init("Test");
const area = Rect.init(0, 0, 40, 20);
const inner = panel.innerArea(area);
try std.testing.expectEqual(@as(u16, 1), inner.x);
try std.testing.expectEqual(@as(u16, 1), inner.y);
try std.testing.expectEqual(@as(u16, 38), inner.width);
try std.testing.expectEqual(@as(u16, 18), inner.height);
}
test "TabbedPanel selection" {
var tabs = TabbedPanel.init(&.{ "Tab 1", "Tab 2", "Tab 3" });
try std.testing.expectEqual(@as(usize, 0), tabs.selected);
tabs.selectNext();
try std.testing.expectEqual(@as(usize, 1), tabs.selected);
tabs.selectNext();
try std.testing.expectEqual(@as(usize, 2), tabs.selected);
tabs.selectNext(); // Should stay at 2
try std.testing.expectEqual(@as(usize, 2), tabs.selected);
tabs.selectPrev();
try std.testing.expectEqual(@as(usize, 1), tabs.selected);
}
test "DockingPanel area calculation" {
const bounds = Rect.init(0, 0, 100, 50);
var panel = DockingPanel.init("Test").setPosition(.left).setDockSize(20);
const area = panel.calculateArea(bounds);
try std.testing.expectEqual(@as(u16, 0), area.x);
try std.testing.expectEqual(@as(u16, 20), area.width);
try std.testing.expectEqual(@as(u16, 50), area.height);
}
test "PanelManager" {
const allocator = std.testing.allocator;
var manager = PanelManager.init(allocator);
defer manager.deinit();
const idx1 = try manager.add(DockingPanel.init("Panel 1"));
const idx2 = try manager.add(DockingPanel.init("Panel 2"));
try std.testing.expectEqual(@as(usize, 0), idx1);
try std.testing.expectEqual(@as(usize, 1), idx2);
manager.focus(0);
try std.testing.expectEqual(@as(?usize, 0), manager.focused);
manager.focusNext();
try std.testing.expectEqual(@as(?usize, 1), manager.focused);
}