diff --git a/src/root.zig b/src/root.zig index bdcfb4f..241abde 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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 diff --git a/src/widgets/panel.zig b/src/widgets/panel.zig new file mode 100644 index 0000000..08e62e2 --- /dev/null +++ b/src/widgets/panel.zig @@ -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); +}