From 6d9953e2245cedc88ed6e7a9a3cef491ade4e41e Mon Sep 17 00:00:00 2001 From: akarpovskii Date: Sat, 22 Jun 2024 20:27:59 +0400 Subject: [PATCH 1/3] Draft implementation of List widget --- examples/build.zig | 1 + examples/src/list.zig | 39 ++++++++ src/widgets.zig | 2 + src/widgets/List.zig | 209 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 examples/src/list.zig create mode 100644 src/widgets/List.zig diff --git a/examples/build.zig b/examples/build.zig index 9304735..9db1d79 100644 --- a/examples/build.zig +++ b/examples/build.zig @@ -22,6 +22,7 @@ pub fn build(b: *std.Build) void { "event_handler", "palette", "unicode", + "list", }; inline for (executables) |name| { const exe = b.addExecutable(.{ diff --git a/examples/src/list.zig b/examples/src/list.zig new file mode 100644 index 0000000..834c0c0 --- /dev/null +++ b/examples/src/list.zig @@ -0,0 +1,39 @@ +const std = @import("std"); +const tuile = @import("tuile"); + +pub fn main() !void { + var tui = try tuile.Tuile.init(.{}); + defer tui.deinit(); + + const layout = tuile.block( + .{ .layout = .{ .max_height = 4 }, .border_type = .solid, .border = tuile.Border.all() }, + tuile.list( + .{}, + &.{ + .{ + .label = try tuile.label(.{ .text = "Item 1\nNew line" }), + .value = null, + }, + .{ + .label = try tuile.label(.{ .text = "Item 2" }), + .value = null, + }, + .{ + .label = try tuile.label(.{ .text = "Item 3" }), + .value = null, + }, + .{ + .label = try tuile.label(.{ .text = "Item 4" }), + .value = null, + }, + .{ + .label = try tuile.label(.{ .text = "Item 5" }), + .value = null, + }, + }, + ), + ); + + try tui.add(layout); + try tui.run(); +} diff --git a/src/widgets.zig b/src/widgets.zig index 8a65cb3..6209265 100644 --- a/src/widgets.zig +++ b/src/widgets.zig @@ -11,6 +11,7 @@ pub const FocusHandler = @import("widgets/FocusHandler.zig"); pub const Input = @import("widgets/Input.zig"); pub const Label = @import("widgets/Label.zig"); pub const LayoutProperties = @import("widgets/LayoutProperties.zig"); +pub const List = @import("widgets/List.zig"); pub const Align = LayoutProperties.Align; pub const HAlign = LayoutProperties.HAlign; pub const VAlign = LayoutProperties.VAlign; @@ -26,6 +27,7 @@ pub const checkbox = Checkbox.create; pub const checkbox_group = CheckboxGroup.create; pub const input = Input.create; pub const label = Label.create; +pub const list = List.create; pub const spacer = Spacer.create; pub const stack_layout = StackLayout.create; pub fn horizontal(config: StackLayout.Config, children: anytype) !*StackLayout { diff --git a/src/widgets/List.zig b/src/widgets/List.zig new file mode 100644 index 0000000..d6287dc --- /dev/null +++ b/src/widgets/List.zig @@ -0,0 +1,209 @@ +const std = @import("std"); +const Widget = @import("Widget.zig"); +const Label = @import("Label.zig"); +const LayoutProperties = @import("LayoutProperties.zig"); +const Rect = @import("../Rect.zig"); +const Vec2 = @import("../Vec2.zig"); +const Constraints = @import("Constraints.zig"); +const Frame = @import("../render/Frame.zig"); +const FocusHandler = @import("FocusHandler.zig"); +const callbacks = @import("callbacks.zig"); +const display = @import("../display.zig"); +const events = @import("../events.zig"); +const internal = @import("../internal.zig"); + +pub const Config = struct { + /// A unique identifier of the widget to be used in `Tuile.findById` and `Widget.findById`. + id: ?[]const u8 = null, + + /// Layout properties of the widget, see `LayoutProperties`. + layout: LayoutProperties = .{}, + + /// List will call this when pressed passing the selected value. + on_press: ?callbacks.Callback(?*anyopaque) = null, +}; + +const List = @This(); + +pub usingnamespace Widget.Leaf.Mixin(List); +pub usingnamespace Widget.Base.Mixin(List, .widget_base); + +widget_base: Widget.Base, + +layout_properties: LayoutProperties, + +items: std.ArrayListUnmanaged(Item), + +top_index: usize = 0, + +top_overflow: usize = 0, + +selected_index: usize = 0, + +focus_handler: FocusHandler = .{}, + +on_press: ?callbacks.Callback(?*anyopaque), + +item_sizes: std.ArrayListUnmanaged(Vec2), + +pub const Item = struct { + label: *Label, + + value: ?*anyopaque, +}; + +pub fn create(config: Config, items: []const Item) !*List { + const self = try internal.allocator.create(List); + self.* = .{ + .widget_base = try Widget.Base.init(config.id), + .layout_properties = config.layout, + .items = std.ArrayListUnmanaged(Item){}, + .item_sizes = std.ArrayListUnmanaged(Vec2){}, + .on_press = config.on_press, + }; + try self.items.appendSlice(internal.allocator, items); + return self; +} + +pub fn destroy(self: *List) void { + for (self.items.items) |item| { + item.label.destroy(); + } + self.items.deinit(internal.allocator); + self.widget_base.deinit(); + internal.allocator.destroy(self); +} + +pub fn widget(self: *List) Widget { + return Widget.init(self); +} + +pub fn render(self: *List, area: Rect, frame: Frame, theme: display.Theme) !void { + var cursor = area.min; + cursor.y -= @intCast(self.top_overflow); + + for (self.top_index..self.top_index + self.item_sizes.items.len) |index| { + const item = self.items.items[index]; + const size = self.item_sizes.items[index - self.top_index]; + const props = item.label.layoutProps(); + const alignment = props.alignment; + + var item_area = Rect{ + .min = cursor, + .max = cursor.add(size), + }; + item_area = area.alignH(alignment.h, item_area); + const item_frame = frame.withArea(item_area); + + if (index == self.selected_index) { + self.focus_handler.render(item_area, item_frame, theme); + } + try item.label.render(item_area, item_frame, theme); + cursor.y += size.y; + } +} + +pub fn layout(self: *List, constraints: Constraints) !Vec2 { + const self_constraints = Constraints.fromProps(self.layout_properties); + const item_constraints = Constraints{ + .min_height = 0, + .min_width = 0, + .max_height = @min(self_constraints.max_height, constraints.max_height), + .max_width = @min(self_constraints.max_width, constraints.max_width), + }; + + if (self.selected_index < self.top_index) { + self.top_index = self.selected_index; + } + + var total_size = Vec2.zero(); + const max_height = @min(self_constraints.max_height, constraints.max_height); + self.item_sizes.clearRetainingCapacity(); + var index = self.top_index; + // When selection is below the current window, we need all the sizes until the selection + // in order to backtrack below and layout everything from bottom to top. + while ((total_size.y < max_height and index < self.items.items.len) or index <= self.selected_index) { + const item = self.items.items[index]; + const size = try item.label.layout(item_constraints); + + total_size.x = @max(total_size.x, size.x); + total_size.y += size.y; + + try self.item_sizes.append(internal.allocator, size); + index += 1; + } + // std.debug.print("{any} {any} {d} {d}\n", .{ constraints, total_size, index, self.selected_index }); + + // Selection moved down and outside the current window. + // Update top_index and top_overflow. + self.top_overflow = 0; + if (index == self.selected_index + 1 and total_size.y > max_height) { + // std.debug.print("Overflow\n", .{}); + var fits: usize = 0; + total_size = Vec2.zero(); + var reverse_iter = std.mem.reverseIterator(self.item_sizes.items); + while (reverse_iter.next()) |size| { + total_size.x = @max(total_size.x, size.x); + total_size.y += size.y; + fits += 1; + if (total_size.y >= max_height) { + break; + } + } + self.top_overflow = total_size.y - max_height; + const offset = self.item_sizes.items.len - fits; + self.top_index += offset; + self.item_sizes.replaceRangeAssumeCapacity(0, self.item_sizes.items.len - fits, &.{}); + } + + total_size = self_constraints.apply(total_size); + total_size = constraints.apply(total_size); + return total_size; +} + +pub fn handleEvent(self: *List, event: events.Event) !events.EventResult { + if (self.focus_handler.handleEvent(event) == .consumed) { + return .consumed; + } + + switch (event) { + .key => |key| switch (key) { + .Up => { + if (self.selected_index > 0) { + self.selected_index -= 1; + } + return .consumed; + }, + .Down => { + if (self.selected_index + 1 < self.items.items.len) { + self.selected_index += 1; + } + return .consumed; + }, + else => {}, + }, + .char => |char| switch (char) { + ' ' => { + if (self.on_press) |on_press| { + on_press.call(self.items.items[self.selected_index].value); + } + return .consumed; + }, + else => {}, + }, + else => {}, + } + return .ignored; +} + +pub fn layoutProps(self: *List) LayoutProperties { + return self.layout_properties; +} + +pub fn scrollUp(self: *List) void { + self.selected_index = 0; +} + +pub fn scrollDown(self: *List) void { + self.selected_index = self.items.items.len; +} From 4389649bb1c89b097dccec6a4fbd3db31472cd87 Mon Sep 17 00:00:00 2001 From: akarpovskii Date: Sat, 22 Jun 2024 22:44:40 +0400 Subject: [PATCH 2/3] Breaking: introduce signed Vec2 and Rect, make coordinates signed Signed coordinates allow clipping widgets when you need to render them partially. For example, when a list item is taking multiple lines doesn't fit into the current list window, list will clip the item. --- examples/src/fps_counter.zig | 6 +-- examples/src/list.zig | 2 +- src/Rect.zig | 71 --------------------------------- src/Vec2.zig | 57 -------------------------- src/backends/Backend.zig | 14 +++---- src/backends/Crossterm.zig | 6 +-- src/backends/Ncurses.zig | 6 +-- src/backends/Testing.zig | 25 +++++++----- src/rect.zig | 75 +++++++++++++++++++++++++++++++++++ src/render/Frame.zig | 56 ++++++++++++++++---------- src/tests.zig | 4 +- src/tuile.zig | 23 ++++++----- src/vec2.zig | 68 +++++++++++++++++++++++++++++++ src/widgets/Block.zig | 32 ++++++++------- src/widgets/Button.zig | 8 ++-- src/widgets/Checkbox.zig | 8 ++-- src/widgets/CheckboxGroup.zig | 8 ++-- src/widgets/Constraints.zig | 4 +- src/widgets/FocusHandler.zig | 4 +- src/widgets/Input.zig | 14 +++---- src/widgets/Label.zig | 16 ++++---- src/widgets/List.zig | 28 ++++++------- src/widgets/Spacer.zig | 10 ++--- src/widgets/StackLayout.zig | 24 +++++------ src/widgets/Themed.zig | 8 ++-- src/widgets/Widget.zig | 16 ++++---- 26 files changed, 316 insertions(+), 277 deletions(-) delete mode 100644 src/Rect.zig delete mode 100644 src/Vec2.zig create mode 100644 src/rect.zig create mode 100644 src/vec2.zig diff --git a/examples/src/fps_counter.zig b/examples/src/fps_counter.zig index 2dece7c..6811f37 100644 --- a/examples/src/fps_counter.zig +++ b/examples/src/fps_counter.zig @@ -44,7 +44,7 @@ const FPSCounter = struct { return tuile.Widget.init(self); } - pub fn render(self: *FPSCounter, area: tuile.Rect, frame: tuile.render.Frame, _: tuile.Theme) !void { + pub fn render(self: *FPSCounter, area: tuile.Rect(i32), frame: tuile.render.Frame, _: tuile.Theme) !void { self.frames += 1; if (self.frames >= window_size) { const now = try std.time.Instant.now(); @@ -63,10 +63,10 @@ const FPSCounter = struct { self.frames = 0; } - _ = try frame.writeSymbols(area.min, &self.buffer, area.width()); + _ = try frame.writeSymbols(area.min, &self.buffer, @intCast(area.width())); } - pub fn layout(self: *FPSCounter, _: tuile.Constraints) !tuile.Vec2 { + pub fn layout(self: *FPSCounter, _: tuile.Constraints) !tuile.Vec2u { return .{ .x = @intCast(self.buffer.len), .y = 1 }; } diff --git a/examples/src/list.zig b/examples/src/list.zig index 834c0c0..c230d8d 100644 --- a/examples/src/list.zig +++ b/examples/src/list.zig @@ -11,7 +11,7 @@ pub fn main() !void { .{}, &.{ .{ - .label = try tuile.label(.{ .text = "Item 1\nNew line" }), + .label = try tuile.label(.{ .text = "Item 1\nNew line 1\nNew line 2" }), .value = null, }, .{ diff --git a/src/Rect.zig b/src/Rect.zig deleted file mode 100644 index ec20565..0000000 --- a/src/Rect.zig +++ /dev/null @@ -1,71 +0,0 @@ -const Vec2 = @import("Vec2.zig"); -const LayoutProperties = @import("widgets/LayoutProperties.zig"); -const Align = LayoutProperties.Align; -const HAlign = LayoutProperties.HAlign; -const VAlign = LayoutProperties.VAlign; - -const Rect = @This(); - -min: Vec2, - -max: Vec2, - -pub fn intersect(self: Rect, other: Rect) Rect { - return Rect{ - .min = .{ - .x = @max(self.min.x, other.min.x), - .y = @max(self.min.y, other.min.y), - }, - .max = .{ - .x = @min(self.max.x, other.max.x), - .y = @min(self.max.y, other.max.y), - }, - }; -} - -pub fn width(self: Rect) u32 { - return self.max.x - self.min.x; -} - -pub fn height(self: Rect) u32 { - return self.max.y - self.min.y; -} - -pub fn diag(self: Rect) Vec2 { - return self.max.sub(self.min); -} - -/// Other area must fit inside this area, otherwise the result will be clamped -pub fn alignH(self: Rect, alignment: HAlign, other: Rect) Rect { - var min = Vec2{ - .x = self.min.x, - .y = other.min.y, - }; - switch (alignment) { - .left => {}, - .center => min.x += (self.width() -| other.width()) / 2, - .right => min.x = self.max.x -| other.width(), - } - return Rect{ .min = min, .max = min.add(other.diag()) }; -} - -/// Other area must fit inside this area, otherwise the result will be clamped -pub fn alignV(self: Rect, alignment: VAlign, other: Rect) Rect { - var min = Vec2{ - .x = other.min.x, - .y = self.min.y, - }; - switch (alignment) { - .top => {}, - .center => min.y += (self.height() -| other.height()) / 2, - .bottom => min.y = self.max.y -| other.height(), - } - return Rect{ .min = min, .max = min.add(other.diag()) }; -} - -/// Other area must fit inside this area, otherwise the result will be clamped -pub fn alignInside(self: Rect, alignment: Align, other: Rect) Rect { - const h = self.alignH(alignment.h, other); - const v = self.alignV(alignment.v, h); - return v; -} diff --git a/src/Vec2.zig b/src/Vec2.zig deleted file mode 100644 index 2241f8c..0000000 --- a/src/Vec2.zig +++ /dev/null @@ -1,57 +0,0 @@ -const std = @import("std"); - -const Vec2 = @This(); - -x: u32, -y: u32, - -pub fn zero() Vec2 { - return .{ .x = 0, .y = 0 }; -} - -pub fn add(a: Vec2, b: Vec2) Vec2 { - return .{ - .x = a.x + b.x, - .y = a.y + b.y, - }; -} - -pub fn addEq(self: *Vec2, b: Vec2) void { - self.*.x += b.x; - self.*.y += b.y; -} - -pub fn sub(a: Vec2, b: Vec2) Vec2 { - return .{ - .x = a.x - b.x, - .y = a.y - b.y, - }; -} - -pub fn subEq(self: *Vec2, b: Vec2) void { - self.*.x -= b.x; - self.*.y -= b.y; -} - -pub fn mul(a: Vec2, k: u32) Vec2 { - return .{ - .x = a.x * k, - .y = a.y * k, - }; -} - -pub fn mulEq(self: *Vec2, k: u32) void { - self.*.x *= k; - self.*.y *= k; -} - -pub fn divFloor(self: Vec2, denominator: u32) Vec2 { - return .{ - .x = @divFloor(self.x, denominator), - .y = @divFloor(self.y, denominator), - }; -} - -pub fn transpose(self: Vec2) Vec2 { - return .{ .x = self.y, .y = self.x }; -} diff --git a/src/backends/Backend.zig b/src/backends/Backend.zig index d50326f..4e0b0ff 100644 --- a/src/backends/Backend.zig +++ b/src/backends/Backend.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Vec2 = @import("../Vec2.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; const events = @import("../events.zig"); const display = @import("../display.zig"); const internal = @import("../internal.zig"); @@ -14,8 +14,8 @@ pub const VTable = struct { destroy: *const fn (context: *anyopaque) void, poll_event: *const fn (context: *anyopaque) anyerror!?events.Event, refresh: *const fn (context: *anyopaque) anyerror!void, - print_at: *const fn (context: *anyopaque, pos: Vec2, text: []const u8) anyerror!void, - window_size: *const fn (context: *anyopaque) anyerror!Vec2, + print_at: *const fn (context: *anyopaque, pos: Vec2u, text: []const u8) anyerror!void, + window_size: *const fn (context: *anyopaque) anyerror!Vec2u, enable_effect: *const fn (context: *anyopaque, effect: display.Style.Effect) anyerror!void, disable_effect: *const fn (context: *anyopaque, effect: display.Style.Effect) anyerror!void, use_color: *const fn (context: *anyopaque, color: display.ColorPair) anyerror!void, @@ -42,12 +42,12 @@ pub fn init(context: anytype) Backend { return ptr_info.Pointer.child.refresh(self); } - pub fn printAt(pointer: *anyopaque, pos: Vec2, text: []const u8) anyerror!void { + pub fn printAt(pointer: *anyopaque, pos: Vec2u, text: []const u8) anyerror!void { const self: T = @ptrCast(@alignCast(pointer)); return ptr_info.Pointer.child.printAt(self, pos, text); } - pub fn windowSize(pointer: *anyopaque) anyerror!Vec2 { + pub fn windowSize(pointer: *anyopaque) anyerror!Vec2u { const self: T = @ptrCast(@alignCast(pointer)); return ptr_info.Pointer.child.windowSize(self); } @@ -101,11 +101,11 @@ pub fn refresh(self: Backend) anyerror!void { return self.vtable.refresh(self.context); } -pub fn printAt(self: Backend, pos: Vec2, text: []const u8) anyerror!void { +pub fn printAt(self: Backend, pos: Vec2u, text: []const u8) anyerror!void { return self.vtable.print_at(self.context, pos, text); } -pub fn windowSize(self: Backend) anyerror!Vec2 { +pub fn windowSize(self: Backend) anyerror!Vec2u { return self.vtable.window_size(self.context); } diff --git a/src/backends/Crossterm.zig b/src/backends/Crossterm.zig index 81a7dad..c731cbe 100644 --- a/src/backends/Crossterm.zig +++ b/src/backends/Crossterm.zig @@ -1,7 +1,7 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Backend = @import("Backend.zig"); -const Vec2 = @import("../Vec2.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; const events = @import("../events.zig"); const display = @import("../display.zig"); const render = @import("../render.zig"); @@ -126,11 +126,11 @@ pub fn refresh(_: *Crossterm) !void { crossterm_refresh(); } -pub fn printAt(_: *Crossterm, pos: Vec2, text: []const u8) !void { +pub fn printAt(_: *Crossterm, pos: Vec2u, text: []const u8) !void { crossterm_print_at(.{ .x = @intCast(pos.x), .y = @intCast(pos.y) }, text.ptr, @intCast(text.len)); } -pub fn windowSize(_: *Crossterm) !Vec2 { +pub fn windowSize(_: *Crossterm) !Vec2u { const size = crossterm_window_size(); return .{ .x = @intCast(size.x), .y = @intCast(size.y) }; } diff --git a/src/backends/Ncurses.zig b/src/backends/Ncurses.zig index 5cf791e..e6dadd8 100644 --- a/src/backends/Ncurses.zig +++ b/src/backends/Ncurses.zig @@ -1,7 +1,7 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Backend = @import("Backend.zig"); -const Vec2 = @import("../Vec2.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; const events = @import("../events.zig"); const display = @import("../display.zig"); @@ -136,11 +136,11 @@ pub fn refresh(_: *Ncurses) !void { if (c.refresh() == c.ERR) return error.NcursesError; } -pub fn printAt(_: *Ncurses, pos: Vec2, text: []const u8) !void { +pub fn printAt(_: *Ncurses, pos: Vec2u, text: []const u8) !void { _ = c.mvaddnstr(@intCast(pos.y), @intCast(pos.x), text.ptr, @intCast(text.len)); } -pub fn windowSize(self: *Ncurses) !Vec2 { +pub fn windowSize(self: *Ncurses) !Vec2u { _ = self; const x = c.getmaxx(c.stdscr); const y = c.getmaxy(c.stdscr); diff --git a/src/backends/Testing.zig b/src/backends/Testing.zig index 7a53e55..2e54a7c 100644 --- a/src/backends/Testing.zig +++ b/src/backends/Testing.zig @@ -1,7 +1,9 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Backend = @import("Backend.zig"); -const Vec2 = @import("../Vec2.zig"); +const vec2 = @import("../vec2.zig"); +const Vec2u = vec2.Vec2u; +const Vec2i = vec2.Vec2i; const events = @import("../events.zig"); const display = @import("../display.zig"); const render = @import("../render.zig"); @@ -10,21 +12,22 @@ const Testing = @This(); frame_buffer: std.ArrayListUnmanaged(render.Cell), -window_size: Vec2, +window_size: Vec2u, frame: render.Frame, -pub fn create(window_size: Vec2) !*Testing { +pub fn create(window_size: Vec2u) !*Testing { const self = try internal.allocator.create(Testing); var buffer = std.ArrayListUnmanaged(render.Cell){}; try buffer.resize(internal.allocator, window_size.x * window_size.y); + const window_area = .{ + .min = Vec2i.zero(), + .max = window_size.as(i32), + }; const frame = render.Frame{ - .size = window_size, .buffer = buffer.items, - .area = .{ - .min = Vec2.zero(), - .max = window_size, - }, + .total_area = window_area, + .area = window_area, }; frame.clear(display.color("black"), display.color("white")); @@ -51,11 +54,11 @@ pub fn pollEvent(_: *Testing) !?events.Event { pub fn refresh(_: *Testing) !void {} -pub fn printAt(self: *Testing, pos: Vec2, text: []const u8) !void { - self.frame.setSymbol(pos, text); +pub fn printAt(self: *Testing, pos: Vec2u, text: []const u8) !void { + self.frame.setSymbol(pos.as(i32), text); } -pub fn windowSize(self: *Testing) !Vec2 { +pub fn windowSize(self: *Testing) !Vec2u { return self.window_size; } diff --git a/src/rect.zig b/src/rect.zig new file mode 100644 index 0000000..446c07c --- /dev/null +++ b/src/rect.zig @@ -0,0 +1,75 @@ +const Vec2 = @import("vec2.zig").Vec2; +const LayoutProperties = @import("widgets/LayoutProperties.zig"); +const Align = LayoutProperties.Align; +const HAlign = LayoutProperties.HAlign; +const VAlign = LayoutProperties.VAlign; + +pub fn Rect(T: type) type { + return struct { + const Self = @This(); + + min: Vec2(T), + + max: Vec2(T), + + pub fn intersect(self: Self, other: Self) Self { + return Self{ + .min = .{ + .x = @max(self.min.x, other.min.x), + .y = @max(self.min.y, other.min.y), + }, + .max = .{ + .x = @min(self.max.x, other.max.x), + .y = @min(self.max.y, other.max.y), + }, + }; + } + + pub fn width(self: Self) i32 { + return self.max.x - self.min.x; + } + + pub fn height(self: Self) i32 { + return self.max.y - self.min.y; + } + + pub fn diag(self: Self) Vec2(T) { + return self.max.sub(self.min); + } + + /// Centers other inside self horizontally + pub fn alignH(self: Self, alignment: HAlign, other: Self) Self { + var min = Vec2(T){ + .x = self.min.x, + .y = other.min.y, + }; + switch (alignment) { + .left => {}, + .center => min.x += @divTrunc(self.width() - other.width(), 2), + .right => min.x = self.max.x - other.width(), + } + return Self{ .min = min, .max = min.add(other.diag()) }; + } + + /// Centers other inside self vertically + pub fn alignV(self: Self, alignment: VAlign, other: Self) Self { + var min = Vec2(T){ + .x = other.min.x, + .y = self.min.y, + }; + switch (alignment) { + .top => {}, + .center => min.y += @divTrunc(self.height() - other.height(), 2), + .bottom => min.y = self.max.y - other.height(), + } + return Self{ .min = min, .max = min.add(other.diag()) }; + } + + /// Centers other inside self both horizontally and vertically + pub fn alignInside(self: Self, alignment: Align, other: Self) Self { + const h = self.alignH(alignment.h, other); + const v = self.alignV(alignment.v, h); + return v; + } + }; +} diff --git a/src/render/Frame.zig b/src/render/Frame.zig index 78b58b8..5ed202e 100644 --- a/src/render/Frame.zig +++ b/src/render/Frame.zig @@ -1,7 +1,9 @@ const std = @import("std"); const Cell = @import("Cell.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const vec2 = @import("../vec2.zig"); +const Vec2u = vec2.Vec2u; +const Vec2i = vec2.Vec2i; +const Rect = @import("../rect.zig").Rect; const Backend = @import("../backends/Backend.zig"); const display = @import("../display.zig"); const internal = @import("../internal.zig"); @@ -13,17 +15,20 @@ const Frame = @This(); buffer: []Cell, /// The size of the buffer. -size: Vec2, +total_area: Rect(i32), /// The area on which Frame is allowed to operate. /// Any writes outside of the area are ignored. -area: Rect, +area: Rect(i32), -pub fn at(self: Frame, pos: Vec2) *Cell { - return &self.buffer[pos.y * self.size.x + pos.x]; +pub fn at(self: Frame, pos: Vec2i) *Cell { + return &self.buffer[ + @intCast((pos.y - self.total_area.min.y) * self.total_area.width() + + (pos.x - self.total_area.min.x)) + ]; } -fn inside(self: Frame, pos: Vec2) bool { +fn inside(self: Frame, pos: Vec2i) bool { return self.area.min.x <= pos.x and pos.x < self.area.max.x and self.area.min.y <= pos.y and pos.y < self.area.max.y; } @@ -37,18 +42,21 @@ pub fn clear(self: Frame, fg: display.Color, bg: display.Color) void { } } -pub fn withArea(self: Frame, area: Rect) Frame { +pub fn withArea(self: Frame, area: Rect(i32)) Frame { return Frame{ .buffer = self.buffer, - .size = self.size, + .total_area = self.total_area, .area = self.area.intersect(area), }; } -pub fn setStyle(self: Frame, area: Rect, style: display.Style) void { - for (area.min.y..area.max.y) |y| { - for (area.min.x..area.max.x) |x| { - const pos = Vec2{ .x = @intCast(x), .y = @intCast(y) }; +pub fn setStyle(self: Frame, area: Rect(i32), style: display.Style) void { + for (0..@intCast(area.height())) |dy| { + for (0..@intCast(area.width())) |dx| { + const pos = Vec2i{ + .x = area.min.x + @as(i32, @intCast(dx)), + .y = area.min.y + @as(i32, @intCast(dy)), + }; if (self.inside(pos)) { self.at(pos).setStyle(style); } @@ -56,14 +64,14 @@ pub fn setStyle(self: Frame, area: Rect, style: display.Style) void { } } -pub fn setSymbol(self: Frame, pos: Vec2, symbol: []const u8) void { +pub fn setSymbol(self: Frame, pos: Vec2i, symbol: []const u8) void { if (self.inside(pos)) { self.at(pos).symbol = symbol; } } // Decodes text as UTF-8, writes all code points separately and returns the number of 'characters' written -pub fn writeSymbols(self: Frame, start: Vec2, bytes: []const u8, max: ?usize) !usize { +pub fn writeSymbols(self: Frame, start: Vec2i, bytes: []const u8, max: ?usize) !usize { var iter = try text_clustering.ClusterIterator.init(internal.text_clustering_type, bytes); var limit = max orelse std.math.maxInt(usize); var written: usize = 0; @@ -87,11 +95,19 @@ pub fn render(self: Frame, backend: Backend) !void { try backend.disableEffect(display.Style.Effect.all()); - for (0..self.size.y) |y| { + for (0..@intCast(self.area.height())) |dy| { + // for (0..self.size.y) |y| { // For the characters taking more than 1 column like の var overflow: usize = 0; - for (0..self.size.x) |x| { - const pos = Vec2{ .x = @intCast(x), .y = @intCast(y) }; + // for (0..self.size.x) |x| { + for (0..@intCast(self.area.width())) |dx| { + const pos = Vec2i{ + .x = self.area.min.x + @as(i32, @intCast(dx)), + .y = self.area.min.y + @as(i32, @intCast(dy)), + }; + if (pos.x < 0 or pos.y < 0) { + continue; + } const cell = self.at(pos); const none = display.Style.Effect{}; @@ -118,7 +134,7 @@ pub fn render(self: Frame, backend: Backend) !void { last_color = .{ .fg = cell.fg, .bg = cell.bg }; if (cell.symbol) |symbol| { - try backend.printAt(pos, symbol); + try backend.printAt(pos.as(u32), symbol); const width = try text_clustering.stringDisplayWidth(symbol, internal.text_clustering_type); overflow = width -| 1; } else { @@ -127,7 +143,7 @@ pub fn render(self: Frame, backend: Backend) !void { overflow -= 1; } else { // Print whitespace to properly display the background - try backend.printAt(pos, " "); + try backend.printAt(pos.as(u32), " "); } } } diff --git a/src/tests.zig b/src/tests.zig index a867b72..9dbec79 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -13,7 +13,7 @@ test { std.testing.refAllDecls(@This()); } -fn renderWidget(window_size: tuile.Vec2, layout: anytype) !std.ArrayList(u8) { +fn renderWidget(window_size: tuile.Vec2u, layout: anytype) !std.ArrayList(u8) { const backend = try tuile.backends.Testing.create(window_size); var tui = try tuile.Tuile.init(.{ .backend = backend.backend() }); @@ -27,7 +27,7 @@ fn renderWidget(window_size: tuile.Vec2, layout: anytype) !std.ArrayList(u8) { return content; } -fn renderAndCompare(window_size: tuile.Vec2, layout: anytype, expected: []const u8) !void { +fn renderAndCompare(window_size: tuile.Vec2u, layout: anytype, expected: []const u8) !void { const content = try renderWidget(window_size, layout); defer content.deinit(); const result = std.testing.expect(std.mem.eql(u8, content.items, expected)); diff --git a/src/tuile.zig b/src/tuile.zig index 870811f..8381e11 100644 --- a/src/tuile.zig +++ b/src/tuile.zig @@ -1,7 +1,11 @@ const std = @import("std"); const internal = @import("internal.zig"); -pub const Vec2 = @import("Vec2.zig"); -pub const Rect = @import("Rect.zig"); +pub const vec2 = @import("vec2.zig"); +pub const Vec2 = vec2.Vec2; +pub const Vec2u = vec2.Vec2u; +pub const Vec2i = vec2.Vec2i; +pub const rect = @import("rect.zig"); +pub const Rect = rect.Rect; pub const backends = @import("backends.zig"); pub const render = @import("render.zig"); pub const events = @import("events.zig"); @@ -48,7 +52,7 @@ pub const Tuile = struct { event_handlers: std.ArrayListUnmanaged(EventHandler), frame_buffer: std.ArrayListUnmanaged(render.Cell), - window_size: Vec2, + window_size: Vec2u, task_queue: std.fifo.LinearFifo(Task, .Dynamic), task_queue_mutex: std.Thread.Mutex, @@ -76,7 +80,7 @@ pub const Tuile = struct { .last_sleep_error = 0, .event_handlers = .{}, .frame_buffer = .{}, - .window_size = Vec2.zero(), + .window_size = Vec2u.zero(), .task_queue = std.fifo.LinearFifo(Task, .Dynamic).init(internal.allocator), .task_queue_mutex = .{}, }; @@ -186,13 +190,14 @@ pub const Tuile = struct { }; _ = try self.root.layout(constraints); + const window_area = .{ + .min = Vec2i.zero(), + .max = self.window_size.as(i32), + }; var frame = render.Frame{ .buffer = self.frame_buffer.items, - .size = self.window_size, - .area = .{ - .min = Vec2.zero(), - .max = self.window_size, - }, + .total_area = window_area, + .area = window_area, }; frame.clear(self.theme.text_primary, self.theme.background_primary); diff --git a/src/vec2.zig b/src/vec2.zig new file mode 100644 index 0000000..e14e699 --- /dev/null +++ b/src/vec2.zig @@ -0,0 +1,68 @@ +const std = @import("std"); + +pub fn Vec2(T: type) type { + return struct { + const Self = @This(); + + x: T, + y: T, + + pub fn zero() Self { + return .{ .x = 0, .y = 0 }; + } + + pub fn add(a: Self, b: Self) Self { + return .{ + .x = a.x + b.x, + .y = a.y + b.y, + }; + } + + pub fn addEq(self: *Self, b: Self) void { + self.*.x += b.x; + self.*.y += b.y; + } + + pub fn sub(a: Self, b: Self) Self { + return .{ + .x = a.x - b.x, + .y = a.y - b.y, + }; + } + + pub fn subEq(self: *Self, b: Self) void { + self.*.x -= b.x; + self.*.y -= b.y; + } + + pub fn mul(a: Self, k: T) Self { + return .{ + .x = a.x * k, + .y = a.y * k, + }; + } + + pub fn mulEq(self: *Self, k: T) void { + self.*.x *= k; + self.*.y *= k; + } + + pub fn divFloor(self: Self, denominator: T) Self { + return .{ + .x = @divFloor(self.x, denominator), + .y = @divFloor(self.y, denominator), + }; + } + + pub fn transpose(self: Self) Self { + return .{ .x = self.y, .y = self.x }; + } + + pub fn as(self: Self, U: type) Vec2(U) { + return .{ .x = @intCast(self.x), .y = @intCast(self.y) }; + } + }; +} + +pub const Vec2u = Vec2(u32); +pub const Vec2i = Vec2(i32); diff --git a/src/widgets/Block.zig b/src/widgets/Block.zig index 4f73eb7..329a969 100644 --- a/src/widgets/Block.zig +++ b/src/widgets/Block.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const border = @import("border.zig"); @@ -41,7 +41,7 @@ widget_base: Widget.Base, inner: Widget, -inner_size: Vec2 = Vec2.zero(), +inner_size: Vec2u = Vec2u.zero(), border: border.Border, @@ -82,26 +82,28 @@ pub fn setInner(self: *Block, new_widget: Widget) void { self.inner = new_widget; } -pub fn render(self: *Block, area: Rect, frame: Frame, theme: display.Theme) !void { - var content_area = Rect{ +pub fn render(self: *Block, area: Rect(i32), frame: Frame, theme: display.Theme) !void { + var content_area = Rect(i32){ .min = .{ - .x = area.min.x + @intFromBool(self.border.left) + self.padding.left, - .y = area.min.y + @intFromBool(self.border.top) + self.padding.top, + .x = area.min.x + @intFromBool(self.border.left) + @as(i32, @intCast(self.padding.left)), + .y = area.min.y + @intFromBool(self.border.top) + @as(i32, @intCast(self.padding.top)), }, .max = .{ - .x = area.max.x -| (@intFromBool(self.border.right) + self.padding.right), - .y = area.max.y -| (@intFromBool(self.border.bottom) + self.padding.bottom), + .x = area.max.x - (@intFromBool(self.border.right) + @as(i32, @intCast(self.padding.right))), + .y = area.max.y - (@intFromBool(self.border.bottom) + @as(i32, @intCast(self.padding.bottom))), }, }; if (content_area.min.x > content_area.max.x or content_area.min.y > content_area.max.y) { self.renderBorder(area, frame, theme); } else { - var inner_area = Rect{ + var inner_area = Rect(i32){ .min = content_area.min, .max = .{ - .x = content_area.min.x + @min(content_area.width(), self.inner_size.x), - .y = content_area.min.y + @min(content_area.height(), self.inner_size.y), + // inner_size may contain std.math.maxInt(u32), but content_area is always finite. + // Cast content_area to u32 first to get a finite value in @min, and then cast it back to i32 + .x = content_area.min.x + @as(i32, @intCast(@min(@as(u32, @intCast(content_area.width())), self.inner_size.x))), + .y = content_area.min.y + @as(i32, @intCast(@min(@as(u32, @intCast(content_area.height())), self.inner_size.y))), }, }; @@ -113,7 +115,7 @@ pub fn render(self: *Block, area: Rect, frame: Frame, theme: display.Theme) !voi } } -pub fn layout(self: *Block, constraints: Constraints) !Vec2 { +pub fn layout(self: *Block, constraints: Constraints) !Vec2u { const props = self.layout_properties; const self_constraints = Constraints{ .min_width = @max(props.min_width, constraints.min_width), @@ -121,7 +123,7 @@ pub fn layout(self: *Block, constraints: Constraints) !Vec2 { .max_width = @min(props.max_width, constraints.max_width), .max_height = @min(props.max_height, constraints.max_height), }; - const border_size = Vec2{ + const border_size = Vec2u{ .x = @intFromBool(self.border.left) + self.padding.left + @intFromBool(self.border.right) + self.padding.right, .y = @intFromBool(self.border.top) + self.padding.top + @intFromBool(self.border.bottom) + self.padding.bottom, }; @@ -164,7 +166,7 @@ pub fn layoutProps(self: *Block) LayoutProperties { return self.layout_properties; } -fn renderBorder(self: *Block, area: Rect, frame: Frame, theme: display.Theme) void { +fn renderBorder(self: *Block, area: Rect(i32), frame: Frame, theme: display.Theme) void { const min = area.min; const max = area.max; const chars = border.BorderCharacters.fromType(self.border_type); diff --git a/src/widgets/Button.zig b/src/widgets/Button.zig index 761ffd4..ee1fede 100644 --- a/src/widgets/Button.zig +++ b/src/widgets/Button.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const Label = @import("Label.zig"); @@ -90,13 +90,13 @@ pub fn setLabelSpan(self: *Button, span: display.SpanView) !void { try self.view.setSpan(label.view()); } -pub fn render(self: *Button, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *Button, area: Rect(i32), frame: Frame, theme: display.Theme) !void { frame.setStyle(area, .{ .bg = theme.interactive }); self.focus_handler.render(area, frame, theme); try self.view.render(area, frame, theme); } -pub fn layout(self: *Button, constraints: Constraints) !Vec2 { +pub fn layout(self: *Button, constraints: Constraints) !Vec2u { return self.view.layout(constraints); } diff --git a/src/widgets/Checkbox.zig b/src/widgets/Checkbox.zig index 7012b13..58e71fb 100644 --- a/src/widgets/Checkbox.zig +++ b/src/widgets/Checkbox.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const Label = @import("Label.zig"); @@ -105,7 +105,7 @@ pub fn setSpan(self: *Checkbox, span: display.SpanView) !void { self.view.setSpan(label.view()); } -pub fn render(self: *Checkbox, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *Checkbox, area: Rect(i32), frame: Frame, theme: display.Theme) !void { frame.setStyle(area, .{ .bg = theme.interactive }); self.focus_handler.render(area, frame, theme); @@ -130,7 +130,7 @@ pub fn render(self: *Checkbox, area: Rect, frame: Frame, theme: display.Theme) ! try self.view.render(area, frame, theme); } -pub fn layout(self: *Checkbox, constraints: Constraints) !Vec2 { +pub fn layout(self: *Checkbox, constraints: Constraints) !Vec2u { return try self.view.layout(constraints); } diff --git a/src/widgets/CheckboxGroup.zig b/src/widgets/CheckboxGroup.zig index 87954f0..2b27cc7 100644 --- a/src/widgets/CheckboxGroup.zig +++ b/src/widgets/CheckboxGroup.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const StackLayout = @import("StackLayout.zig"); @@ -111,11 +111,11 @@ pub fn widget(self: *CheckboxGroup) Widget { return Widget.init(self); } -pub fn render(self: *CheckboxGroup, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *CheckboxGroup, area: Rect(i32), frame: Frame, theme: display.Theme) !void { try self.view.render(area, frame, theme); } -pub fn layout(self: *CheckboxGroup, constraints: Constraints) !Vec2 { +pub fn layout(self: *CheckboxGroup, constraints: Constraints) !Vec2u { return try self.view.layout(constraints); } diff --git a/src/widgets/Constraints.zig b/src/widgets/Constraints.zig index 4a58475..09e99d7 100644 --- a/src/widgets/Constraints.zig +++ b/src/widgets/Constraints.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Vec2 = @import("../Vec2.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; const LayoutProperties = @import("LayoutProperties.zig"); const Constraints = @This(); @@ -12,7 +12,7 @@ max_width: u32 = std.math.maxInt(u32), max_height: u32 = std.math.maxInt(u32), -pub fn apply(self: Constraints, size: Vec2) Vec2 { +pub fn apply(self: Constraints, size: Vec2u) Vec2u { return .{ .x = self.clampWidth(size.x), .y = self.clampHeight(size.y), diff --git a/src/widgets/FocusHandler.zig b/src/widgets/FocusHandler.zig index ac7b5d8..41d518c 100644 --- a/src/widgets/FocusHandler.zig +++ b/src/widgets/FocusHandler.zig @@ -1,4 +1,4 @@ -const Rect = @import("../Rect.zig"); +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const display = @import("../display.zig"); @@ -23,7 +23,7 @@ pub fn handleEvent(self: *FocusHandler, event: events.Event) events.EventResult } } -pub fn render(self: *FocusHandler, area: Rect, frame: Frame, theme: display.Theme) void { +pub fn render(self: *FocusHandler, area: Rect(i32), frame: Frame, theme: display.Theme) void { if (self.focused) { frame.setStyle(area, .{ .bg = theme.focused }); } diff --git a/src/widgets/Input.zig b/src/widgets/Input.zig index 50a4e77..881fccd 100644 --- a/src/widgets/Input.zig +++ b/src/widgets/Input.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const FocusHandler = @import("FocusHandler.zig"); @@ -96,7 +96,7 @@ fn cursor(self: Input) usize { return self.graphemes.items[self.grapheme_cursor].offset; } -pub fn render(self: *Input, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *Input, area: Rect(i32), frame: Frame, theme: display.Theme) !void { if (area.height() < 1) { return; } @@ -106,7 +106,7 @@ pub fn render(self: *Input, area: Rect, frame: Frame, theme: display.Theme) !voi const render_placeholder = self.value.items.len == 0; if (render_placeholder) frame.setStyle(area, .{ .fg = theme.text_secondary }); - _ = try frame.writeSymbols(area.min, self.visibleText(), area.width()); + _ = try frame.writeSymbols(area.min, self.visibleText(), @intCast(area.width())); if (self.focus_handler.focused) { var cursor_pos = area.min; @@ -118,7 +118,7 @@ pub fn render(self: *Input, area: Rect, frame: Frame, theme: display.Theme) !voi if (cursor_pos.x >= area.max.x) { cursor_pos.x = area.max.x - 1; } - const end_area = Rect{ + const end_area = Rect(i32){ .min = cursor_pos, .max = cursor_pos.add(.{ .x = 1, .y = 1 }), }; @@ -128,7 +128,7 @@ pub fn render(self: *Input, area: Rect, frame: Frame, theme: display.Theme) !voi } } -pub fn layout(self: *Input, constraints: Constraints) !Vec2 { +pub fn layout(self: *Input, constraints: Constraints) !Vec2u { if (self.cursor() < self.view_start) { self.view_start = self.cursor(); } else { @@ -152,7 +152,7 @@ pub fn layout(self: *Input, constraints: Constraints) !Vec2 { // +1 for the cursor const len = try stringDisplayWidth(visible, internal.text_clustering_type) + 1; - var size = Vec2{ + var size = Vec2u{ .x = @intCast(len), .y = 1, }; diff --git a/src/widgets/Label.zig b/src/widgets/Label.zig index 6fe5490..a90c527 100644 --- a/src/widgets/Label.zig +++ b/src/widgets/Label.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const LayoutProperties = @import("LayoutProperties.zig"); @@ -98,24 +98,24 @@ pub fn setSpan(self: *Label, span: display.SpanView) !void { self.content = try display.SpanUnmanaged.fromView(internal.allocator, span); } -pub fn render(self: *Label, area: Rect, frame: Frame, _: display.Theme) !void { +pub fn render(self: *Label, area: Rect(i32), frame: Frame, _: display.Theme) !void { const rows = self.rows.items; - for (0..area.height()) |y| { + for (0..@intCast(area.height())) |y| { if (y >= rows.len) break; const row = rows[y]; var pos = area.min.add(.{ .x = 0, .y = @intCast(y) }); for (row.chunks.items) |chunk| { const text = self.content.getTextForChunk(chunk.orig)[chunk.start..chunk.end]; - const written: u32 = @intCast(try frame.writeSymbols(pos, text, area.width())); - const chunk_area = Rect{ .min = pos, .max = pos.add(.{ .x = written, .y = 1 }) }; + const written: i32 = @intCast(try frame.writeSymbols(pos, text, @intCast(area.width()))); + const chunk_area = Rect(i32){ .min = pos, .max = pos.add(.{ .x = written, .y = 1 }) }; frame.setStyle(chunk_area, self.content.getStyleForChunk(chunk.orig)); pos.x += written; } } } -pub fn layout(self: *Label, constraints: Constraints) !Vec2 { +pub fn layout(self: *Label, constraints: Constraints) !Vec2u { try self.wrapText(constraints); var max_len: usize = 0; @@ -128,7 +128,7 @@ pub fn layout(self: *Label, constraints: Constraints) !Vec2 { max_len = @max(max_len, len); } - var size = Vec2{ + var size = Vec2u{ .x = @intCast(max_len), .y = @intCast(self.rows.items.len), }; diff --git a/src/widgets/List.zig b/src/widgets/List.zig index d6287dc..f510dfb 100644 --- a/src/widgets/List.zig +++ b/src/widgets/List.zig @@ -2,8 +2,8 @@ const std = @import("std"); const Widget = @import("Widget.zig"); const Label = @import("Label.zig"); const LayoutProperties = @import("LayoutProperties.zig"); -const Rect = @import("../Rect.zig"); -const Vec2 = @import("../Vec2.zig"); +const Rect = @import("../rect.zig").Rect; +const Vec2u = @import("../vec2.zig").Vec2u; const Constraints = @import("Constraints.zig"); const Frame = @import("../render/Frame.zig"); const FocusHandler = @import("FocusHandler.zig"); @@ -44,7 +44,7 @@ focus_handler: FocusHandler = .{}, on_press: ?callbacks.Callback(?*anyopaque), -item_sizes: std.ArrayListUnmanaged(Vec2), +item_sizes: std.ArrayListUnmanaged(Vec2u), pub const Item = struct { label: *Label, @@ -58,7 +58,7 @@ pub fn create(config: Config, items: []const Item) !*List { .widget_base = try Widget.Base.init(config.id), .layout_properties = config.layout, .items = std.ArrayListUnmanaged(Item){}, - .item_sizes = std.ArrayListUnmanaged(Vec2){}, + .item_sizes = std.ArrayListUnmanaged(Vec2u){}, .on_press = config.on_press, }; try self.items.appendSlice(internal.allocator, items); @@ -78,7 +78,7 @@ pub fn widget(self: *List) Widget { return Widget.init(self); } -pub fn render(self: *List, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *List, area: Rect(i32), frame: Frame, theme: display.Theme) !void { var cursor = area.min; cursor.y -= @intCast(self.top_overflow); @@ -88,9 +88,9 @@ pub fn render(self: *List, area: Rect, frame: Frame, theme: display.Theme) !void const props = item.label.layoutProps(); const alignment = props.alignment; - var item_area = Rect{ + var item_area = Rect(i32){ .min = cursor, - .max = cursor.add(size), + .max = cursor.add(size.as(i32)), }; item_area = area.alignH(alignment.h, item_area); const item_frame = frame.withArea(item_area); @@ -99,16 +99,16 @@ pub fn render(self: *List, area: Rect, frame: Frame, theme: display.Theme) !void self.focus_handler.render(item_area, item_frame, theme); } try item.label.render(item_area, item_frame, theme); - cursor.y += size.y; + cursor.y += @intCast(size.y); } } -pub fn layout(self: *List, constraints: Constraints) !Vec2 { +pub fn layout(self: *List, constraints: Constraints) !Vec2u { const self_constraints = Constraints.fromProps(self.layout_properties); const item_constraints = Constraints{ .min_height = 0, .min_width = 0, - .max_height = @min(self_constraints.max_height, constraints.max_height), + .max_height = std.math.maxInt(u32), .max_width = @min(self_constraints.max_width, constraints.max_width), }; @@ -116,7 +116,7 @@ pub fn layout(self: *List, constraints: Constraints) !Vec2 { self.top_index = self.selected_index; } - var total_size = Vec2.zero(); + var total_size = Vec2u.zero(); const max_height = @min(self_constraints.max_height, constraints.max_height); self.item_sizes.clearRetainingCapacity(); var index = self.top_index; @@ -132,15 +132,13 @@ pub fn layout(self: *List, constraints: Constraints) !Vec2 { try self.item_sizes.append(internal.allocator, size); index += 1; } - // std.debug.print("{any} {any} {d} {d}\n", .{ constraints, total_size, index, self.selected_index }); // Selection moved down and outside the current window. // Update top_index and top_overflow. self.top_overflow = 0; - if (index == self.selected_index + 1 and total_size.y > max_height) { - // std.debug.print("Overflow\n", .{}); + if (index == self.selected_index + 1 and self.selected_index != self.top_index and total_size.y > max_height) { var fits: usize = 0; - total_size = Vec2.zero(); + total_size = Vec2u.zero(); var reverse_iter = std.mem.reverseIterator(self.item_sizes.items); while (reverse_iter.next()) |size| { total_size.x = @max(total_size.x, size.x); diff --git a/src/widgets/Spacer.zig b/src/widgets/Spacer.zig index 5a7ac62..2a52b02 100644 --- a/src/widgets/Spacer.zig +++ b/src/widgets/Spacer.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const LayoutProperties = @import("LayoutProperties.zig"); @@ -53,11 +53,11 @@ pub fn widget(self: *Spacer) Widget { return Widget.init(self); } -pub fn render(_: *Spacer, _: Rect, _: Frame, _: Theme) !void {} +pub fn render(_: *Spacer, _: Rect(i32), _: Frame, _: Theme) !void {} -pub fn layout(self: *Spacer, constraints: Constraints) !Vec2 { +pub fn layout(self: *Spacer, constraints: Constraints) !Vec2u { const props = self.layout_properties; - const size = Vec2{ + const size = Vec2u{ .x = @min(props.max_width, constraints.max_width), .y = @min(props.max_height, constraints.max_height), }; diff --git a/src/widgets/StackLayout.zig b/src/widgets/StackLayout.zig index b5f0875..ef246f2 100644 --- a/src/widgets/StackLayout.zig +++ b/src/widgets/StackLayout.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const LayoutProperties = @import("LayoutProperties.zig"); @@ -34,7 +34,7 @@ widget_base: Widget.Base, widgets: std.ArrayListUnmanaged(Widget), -widget_sizes: std.ArrayListUnmanaged(Vec2), +widget_sizes: std.ArrayListUnmanaged(Vec2u), orientation: Orientation, @@ -66,7 +66,7 @@ pub fn create(config: Config, children: anytype) !*StackLayout { self.* = StackLayout{ .widget_base = try Widget.Base.init(config.id), .widgets = widgets, - .widget_sizes = std.ArrayListUnmanaged(Vec2){}, + .widget_sizes = std.ArrayListUnmanaged(Vec2u){}, .orientation = config.orientation, .layout_properties = config.layout, }; @@ -103,16 +103,16 @@ pub fn widget(self: *StackLayout) Widget { return Widget.init(self); } -pub fn render(self: *StackLayout, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *StackLayout, area: Rect(i32), frame: Frame, theme: display.Theme) !void { var cursor = area.min; for (self.widgets.items, self.widget_sizes.items) |w, s| { const props = w.layoutProps(); const alignment = props.alignment; - var widget_area = Rect{ + var widget_area = Rect(i32){ .min = cursor, - .max = cursor.add(s), + .max = cursor.add(s.as(i32)), }; switch (self.orientation) { @@ -123,23 +123,23 @@ pub fn render(self: *StackLayout, area: Rect, frame: Frame, theme: display.Theme try w.render(widget_area, frame.withArea(widget_area), theme); switch (self.orientation) { .horizontal => { - cursor.x += s.x; + cursor.x += @intCast(s.x); }, .vertical => { - cursor.y += s.y; + cursor.y += @intCast(s.y); }, } } } -pub fn layout(self: *StackLayout, constraints: Constraints) !Vec2 { +pub fn layout(self: *StackLayout, constraints: Constraints) !Vec2u { switch (self.orientation) { .vertical => return try self.layoutImpl(constraints, .vertical), .horizontal => return try self.layoutImpl(constraints, .horizontal), } } -pub fn layoutImpl(self: *StackLayout, constraints: Constraints, comptime orientation: Orientation) !Vec2 { +pub fn layoutImpl(self: *StackLayout, constraints: Constraints, comptime orientation: Orientation) !Vec2u { if (self.widgets.items.len == 0) { return .{ .x = constraints.min_width, .y = constraints.min_height }; } @@ -189,7 +189,7 @@ pub fn layoutImpl(self: *StackLayout, constraints: Constraints, comptime orienta } } - var self_size = Vec2.zero(); + var self_size = Vec2u.zero(); var fixed_size: u32 = 0; for (fixed_indices.items) |idx| { const w = &self.widgets.items[idx]; diff --git a/src/widgets/Themed.zig b/src/widgets/Themed.zig index daa91ce..3d14302 100644 --- a/src/widgets/Themed.zig +++ b/src/widgets/Themed.zig @@ -1,8 +1,8 @@ const std = @import("std"); const internal = @import("../internal.zig"); const Widget = @import("Widget.zig"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const events = @import("../events.zig"); const Frame = @import("../render/Frame.zig"); const LayoutProperties = @import("LayoutProperties.zig"); @@ -94,7 +94,7 @@ pub fn updateTheme(self: *Themed, update: PartialTheme) void { } } -pub fn render(self: *Themed, area: Rect, frame: Frame, theme: display.Theme) !void { +pub fn render(self: *Themed, area: Rect(i32), frame: Frame, theme: display.Theme) !void { var new_theme = theme; inline for (@typeInfo(display.Theme).Struct.fields) |field| { const part = @field(self.theme, field.name); @@ -108,7 +108,7 @@ pub fn render(self: *Themed, area: Rect, frame: Frame, theme: display.Theme) !vo return try self.inner.render(area, frame, new_theme); } -pub fn layout(self: *Themed, constraints: Constraints) !Vec2 { +pub fn layout(self: *Themed, constraints: Constraints) !Vec2u { return try self.inner.layout(constraints); } diff --git a/src/widgets/Widget.zig b/src/widgets/Widget.zig index c614d39..4c940c7 100644 --- a/src/widgets/Widget.zig +++ b/src/widgets/Widget.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const Vec2 = @import("../Vec2.zig"); -const Rect = @import("../Rect.zig"); +const Vec2u = @import("../vec2.zig").Vec2u; +const Rect = @import("../rect.zig").Rect; const Frame = @import("../render/Frame.zig"); const LayoutProperties = @import("LayoutProperties.zig"); const Constraints = @import("Constraints.zig"); @@ -44,12 +44,12 @@ const VTable = struct { /// * A parent might set `max_width` or `max_height` to std.math.maxInt(u32) /// if it doesn't have anough information to align the child. In which case /// the child should tell the parent a finite desired size. - layout: *const fn (context: *anyopaque, constraints: Constraints) anyerror!Vec2, + layout: *const fn (context: *anyopaque, constraints: Constraints) anyerror!Vec2u, /// Widgets must draw themselves inside of `area`. Writes outside of `area` are ignored. /// `theme` is the currently used theme which can be overridden by the `Themed` widget. /// `render` is guaranteed to be called after `layout`. - render: *const fn (context: *anyopaque, area: Rect, frame: Frame, theme: display.Theme) anyerror!void, + render: *const fn (context: *anyopaque, area: Rect(i32), frame: Frame, theme: display.Theme) anyerror!void, /// If a widget returns .consumed, the event is considered fulfilled and is not propagated further. /// If a widget returns .ignored, the event is passed to the next widget in the tree. @@ -142,14 +142,14 @@ pub fn constructVTable(comptime T: type) VTable { return T.destroy(self); } - pub fn render(pointer: *anyopaque, area: Rect, frame: Frame, theme: display.Theme) anyerror!void { + pub fn render(pointer: *anyopaque, area: Rect(i32), frame: Frame, theme: display.Theme) anyerror!void { std.debug.assert(area.max.x != std.math.maxInt(u32)); std.debug.assert(area.max.y != std.math.maxInt(u32)); const self: *T = @ptrCast(@alignCast(pointer)); return T.render(self, area, frame, theme); } - pub fn layout(pointer: *anyopaque, constraints: Constraints) anyerror!Vec2 { + pub fn layout(pointer: *anyopaque, constraints: Constraints) anyerror!Vec2u { std.debug.assert(constraints.min_width <= constraints.max_width); std.debug.assert(constraints.min_height <= constraints.max_height); // std.debug.print("{any} - {any}\n", .{ *T, constraints }); @@ -219,11 +219,11 @@ pub inline fn destroy(self: Widget) void { return self.vtable.destroy(self.context); } -pub inline fn render(self: Widget, area: Rect, frame: Frame, theme: display.Theme) !void { +pub inline fn render(self: Widget, area: Rect(i32), frame: Frame, theme: display.Theme) !void { return self.vtable.render(self.context, area, frame, theme); } -pub inline fn layout(self: Widget, constraints: Constraints) anyerror!Vec2 { +pub inline fn layout(self: Widget, constraints: Constraints) anyerror!Vec2u { return try self.vtable.layout(self.context, constraints); } From 251403466d0c3c709a62e0a3f9ce108a059ebbbe Mon Sep 17 00:00:00 2001 From: akarpovskii Date: Tue, 9 Jul 2024 20:10:56 +0400 Subject: [PATCH 3/3] List: show scrollbar --- examples/src/list.zig | 37 +++++++++++++++++++++++++++------- src/widgets/List.zig | 47 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/examples/src/list.zig b/examples/src/list.zig index c230d8d..ca4c417 100644 --- a/examples/src/list.zig +++ b/examples/src/list.zig @@ -6,28 +6,51 @@ pub fn main() !void { defer tui.deinit(); const layout = tuile.block( - .{ .layout = .{ .max_height = 4 }, .border_type = .solid, .border = tuile.Border.all() }, + .{ .layout = .{ .max_height = 12 }, .border_type = .solid, .border = tuile.Border.all() }, tuile.list( - .{}, + .{ .layout = .{ .alignment = tuile.Align.topCenter(), .min_height = 10 } }, &.{ .{ - .label = try tuile.label(.{ .text = "Item 1\nNew line 1\nNew line 2" }), + .label = try tuile.label(.{ + // .text = "Item 1\nNew line 1\nNew line 2", + .text = "Item 1", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), .value = null, }, .{ - .label = try tuile.label(.{ .text = "Item 2" }), + .label = try tuile.label(.{ + .text = "Item 2 - long line long line long line", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), .value = null, }, .{ - .label = try tuile.label(.{ .text = "Item 3" }), + .label = try tuile.label(.{ + .text = "Item 3", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), .value = null, }, .{ - .label = try tuile.label(.{ .text = "Item 4" }), + .label = try tuile.label(.{ + .text = "Item 4", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), .value = null, }, .{ - .label = try tuile.label(.{ .text = "Item 5" }), + .label = try tuile.label(.{ + .text = "Item 5", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), + .value = null, + }, + .{ + .label = try tuile.label(.{ + .text = "Item 6", + .layout = .{ .alignment = tuile.Align.topLeft() }, + }), .value = null, }, }, diff --git a/src/widgets/List.zig b/src/widgets/List.zig index f510dfb..68f2c43 100644 --- a/src/widgets/List.zig +++ b/src/widgets/List.zig @@ -21,6 +21,8 @@ pub const Config = struct { /// List will call this when pressed passing the selected value. on_press: ?callbacks.Callback(?*anyopaque) = null, + + show_scrollbar: bool = true, }; const List = @This(); @@ -46,6 +48,8 @@ on_press: ?callbacks.Callback(?*anyopaque), item_sizes: std.ArrayListUnmanaged(Vec2u), +show_scrollbar: bool, + pub const Item = struct { label: *Label, @@ -60,6 +64,7 @@ pub fn create(config: Config, items: []const Item) !*List { .items = std.ArrayListUnmanaged(Item){}, .item_sizes = std.ArrayListUnmanaged(Vec2u){}, .on_press = config.on_press, + .show_scrollbar = config.show_scrollbar, }; try self.items.appendSlice(internal.allocator, items); return self; @@ -85,14 +90,12 @@ pub fn render(self: *List, area: Rect(i32), frame: Frame, theme: display.Theme) for (self.top_index..self.top_index + self.item_sizes.items.len) |index| { const item = self.items.items[index]; const size = self.item_sizes.items[index - self.top_index]; - const props = item.label.layoutProps(); - const alignment = props.alignment; var item_area = Rect(i32){ .min = cursor, .max = cursor.add(size.as(i32)), }; - item_area = area.alignH(alignment.h, item_area); + item_area = area.alignH(LayoutProperties.HAlign.left, item_area); const item_frame = frame.withArea(item_area); if (index == self.selected_index) { @@ -101,22 +104,51 @@ pub fn render(self: *List, area: Rect(i32), frame: Frame, theme: display.Theme) try item.label.render(item_area, item_frame, theme); cursor.y += @intCast(size.y); } + + const total_items = self.items.items.len; + if (self.show_scrollbar and total_items > 0) { + const viewport_height = @as(usize, @intCast(area.height())); + const scroll_height = @max(1, (viewport_height + total_items - 1) / total_items); + const before_scroll = @min(viewport_height * self.selected_index / total_items, viewport_height -| scroll_height); + + var y = area.min.y; + var index: usize = 0; + while (y < area.max.y) { + const symbol = if (before_scroll <= index and index < before_scroll + scroll_height) + "█" + else + "│"; + _ = try frame.writeSymbols(.{ .x = area.max.x - 1, .y = y }, symbol, null); + + y += 1; + index += 1; + } + } } pub fn layout(self: *List, constraints: Constraints) !Vec2u { const self_constraints = Constraints.fromProps(self.layout_properties); - const item_constraints = Constraints{ + var item_constraints = Constraints{ .min_height = 0, .min_width = 0, .max_height = std.math.maxInt(u32), .max_width = @min(self_constraints.max_width, constraints.max_width), }; + if (self.show_scrollbar and item_constraints.max_width != std.math.maxInt(u32)) { + item_constraints.max_width -= 1; + } + if (self.selected_index < self.top_index) { self.top_index = self.selected_index; } - var total_size = Vec2u.zero(); + // If set to 0 (first version did this), list will adapt its width depending on the + // currently visible elements. It doesn't look natural, but keeping the logic + // here for possible future improvements. + const total_size_zero_x = item_constraints.max_width; + var total_size = Vec2u{ .x = total_size_zero_x, .y = 0 }; + const max_height = @min(self_constraints.max_height, constraints.max_height); self.item_sizes.clearRetainingCapacity(); var index = self.top_index; @@ -138,7 +170,7 @@ pub fn layout(self: *List, constraints: Constraints) !Vec2u { self.top_overflow = 0; if (index == self.selected_index + 1 and self.selected_index != self.top_index and total_size.y > max_height) { var fits: usize = 0; - total_size = Vec2u.zero(); + total_size = Vec2u{ .x = total_size_zero_x, .y = 0 }; var reverse_iter = std.mem.reverseIterator(self.item_sizes.items); while (reverse_iter.next()) |size| { total_size.x = @max(total_size.x, size.x); @@ -154,6 +186,9 @@ pub fn layout(self: *List, constraints: Constraints) !Vec2u { self.item_sizes.replaceRangeAssumeCapacity(0, self.item_sizes.items.len - fits, &.{}); } + if (self.show_scrollbar and total_size.x != std.math.maxInt(u32)) { + total_size.x += 1; + } total_size = self_constraints.apply(total_size); total_size = constraints.apply(total_size); return total_size;