feat(scrollable): make Container scrollable through Element Scrollable
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m35s
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m35s
This commit is contained in:
25
README.md
25
README.md
@@ -93,22 +93,25 @@ the primary use-case for myself to create this library in the first place.
|
||||
- [x] separators
|
||||
- [x] Rectangle
|
||||
- [ ] User control
|
||||
- [x] event handling
|
||||
- [x] event loop handling
|
||||
- [x] mouse support
|
||||
- [x] user content
|
||||
- [ ] Default `Element` implementations
|
||||
- [ ] Scrollable
|
||||
- [ ] user input handling
|
||||
- [ ] vertical
|
||||
- [ ] horizontal
|
||||
- [x] user input handling
|
||||
- [x] vertical
|
||||
- [x] horizontal
|
||||
- [x] mouse input
|
||||
- [ ] scroll bar(s) rendering
|
||||
- [ ] vertical
|
||||
- [ ] horizontal
|
||||
- [ ] Content alignment (i.e. standard calculations done with the provided `Size`)
|
||||
- [ ] Text display
|
||||
- [ ] User input
|
||||
- [ ] single line
|
||||
- [ ] multi line
|
||||
- [ ] min size? (I don't have access to the `.resize` `Event`..)
|
||||
- [x] User input
|
||||
- [x] single line
|
||||
- [x] multi line
|
||||
- [x] min size (provide size to use which would be a minimal size - as if the actual size is smaller then the `Container` will scroll and otherwise the contents expand to the available space instead?)
|
||||
- [ ] image support through kitty protocol
|
||||
|
||||
Decorations should respect the layout and the viewport accordingly. This means
|
||||
that scrollbars are always visible (except there is no need to have a scrollbar)
|
||||
@@ -117,6 +120,12 @@ cells of the content (and may be overwritten by child elements contents).
|
||||
The border of an element should be around independent of the scrolling of the
|
||||
contents, just like padding.
|
||||
|
||||
For most of the `Element`s a standalone implementation would not make a lot
|
||||
of sense due to the complexity of the user application states. Therefore the
|
||||
library should instead provide small examples to show how you can implement
|
||||
such user fields yourself and connect them using your own event system loops to
|
||||
communicate with other `Container`s and/or `Element`s.
|
||||
|
||||
### Scrollable contents
|
||||
|
||||
Contents that is scrollable should be done *virtually* through the contents of
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
const std = @import("std");
|
||||
const zterm = @import("zterm");
|
||||
|
||||
const App = zterm.App(union(enum) {});
|
||||
const App = zterm.App(union(enum) {
|
||||
send: []u21,
|
||||
});
|
||||
|
||||
const log = std.log.scoped(.example);
|
||||
|
||||
pub const ExampleElement = packed struct {
|
||||
pub const InputField = struct {
|
||||
// TODO: I would need the following features:
|
||||
// - current position of the input (i.e. a cursor)
|
||||
// - gnu readline keybindings
|
||||
// - truncate inputs?
|
||||
input: std.ArrayList(u21),
|
||||
/// The thread safe `App.Event` queue used to send events to the application's event loop.
|
||||
queue: *App.Queue,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, queue: *App.Queue) @This() {
|
||||
return .{
|
||||
.input = .init(allocator),
|
||||
.queue = queue,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(this: *@This()) void {
|
||||
this.input.deinit();
|
||||
}
|
||||
|
||||
pub fn element(this: *@This()) App.Element {
|
||||
return .{
|
||||
.ptr = this,
|
||||
@@ -16,27 +37,43 @@ pub const ExampleElement = packed struct {
|
||||
};
|
||||
}
|
||||
|
||||
// example function to render contents for a `Container`
|
||||
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
|
||||
_ = ctx;
|
||||
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
|
||||
// example function to handle events for a `Container`
|
||||
fn handle(ctx: *anyopaque, event: App.Event) !void {
|
||||
const this: *@This() = @ptrCast(@alignCast(ctx));
|
||||
switch (event) {
|
||||
.key => |key| {
|
||||
if (key.isAscii()) try this.input.append(key.cp);
|
||||
|
||||
// NOTE: error should only be returned here in case an in-recoverable exception has occurred
|
||||
const row = size.rows / 2;
|
||||
const col = size.cols / 2 -| 3;
|
||||
if (key.eql(.{ .cp = zterm.input.Enter }) or key.eql(.{ .cp = zterm.input.KpEnter }))
|
||||
this.queue.push(.{ .send = try this.input.toOwnedSlice() });
|
||||
|
||||
for (0..5) |c| {
|
||||
cells[(row * size.cols) + col + c].style.fg = .black;
|
||||
cells[(row * size.cols) + col + c].cp = '-';
|
||||
if (key.eql(.{ .cp = zterm.input.Backspace }) or key.eql(.{ .cp = zterm.input.Delete }) or key.eql(.{ .cp = zterm.input.KpDelete }))
|
||||
_ = this.input.popOrNull();
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
// example function to handle events for a `Container`
|
||||
fn handle(ctx: *anyopaque, event: App.Event) !void {
|
||||
_ = ctx;
|
||||
switch (event) {
|
||||
.init => log.debug(".init event", .{}),
|
||||
else => {},
|
||||
// example function to render contents for a `Container`
|
||||
fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Size) !void {
|
||||
const this: *@This() = @ptrCast(@alignCast(ctx));
|
||||
std.debug.assert(cells.len == @as(usize, size.cols) * @as(usize, size.rows));
|
||||
|
||||
if (this.input.items.len == 0) return;
|
||||
|
||||
const row = size.rows / 2;
|
||||
const col: u16 = 5;
|
||||
|
||||
for (this.input.items, 0..) |cp, idx| {
|
||||
cells[(row * size.cols) + col + idx].style.fg = .black;
|
||||
cells[(row * size.cols) + col + idx].cp = cp;
|
||||
// NOTE: line wrapping happens automatically due to the way the container cells are constructed
|
||||
// - it would also be possible to limit the line to cols you want to display (i.e. then only for a single row)
|
||||
// - for areas you would rather go ahead and create another
|
||||
// container and fit the text inside of that container (so it is dynamic to the screen size, etc.)
|
||||
|
||||
// NOTE: do not write over the contents of this `Container`'s `Size`
|
||||
if ((row * size.cols) + col + idx == cells.len - 1) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -58,7 +95,8 @@ pub fn main() !void {
|
||||
var renderer = zterm.Renderer.Buffered.init(allocator);
|
||||
defer renderer.deinit();
|
||||
|
||||
var element_wrapper = ExampleElement{};
|
||||
var element_wrapper: InputField = .init(allocator, &app.queue);
|
||||
defer element_wrapper.deinit();
|
||||
const element = element_wrapper.element();
|
||||
|
||||
var container = try App.Container.init(allocator, .{
|
||||
@@ -73,7 +111,7 @@ pub fn main() !void {
|
||||
.padding = .all(5),
|
||||
.direction = .vertical,
|
||||
},
|
||||
}, element);
|
||||
}, .{});
|
||||
var box = try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .blue },
|
||||
.layout = .{
|
||||
@@ -82,15 +120,15 @@ pub fn main() !void {
|
||||
.padding = .vertical(1),
|
||||
},
|
||||
}, .{});
|
||||
try box.append(try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .light_green },
|
||||
}, element));
|
||||
try box.append(try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .light_green },
|
||||
}, .{}));
|
||||
try box.append(try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .light_green },
|
||||
}, element));
|
||||
try box.append(try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .light_green },
|
||||
}, .{}));
|
||||
try container.append(box);
|
||||
try container.append(try App.Container.init(allocator, .{
|
||||
.border = .{
|
||||
@@ -124,6 +162,7 @@ pub fn main() !void {
|
||||
if (key.eql(.{ .cp = 'n', .mod = .{ .ctrl = true } })) {
|
||||
try app.interrupt();
|
||||
defer app.start() catch @panic("could not start app event loop");
|
||||
// FIX: the rendering is afterwards incorrect (wrong state of the double buffered renderer?)
|
||||
var child = std.process.Child.init(&.{"hx"}, allocator);
|
||||
_ = child.spawnAndWait() catch |err| app.postEvent(.{
|
||||
.err = .{
|
||||
@@ -133,6 +172,12 @@ pub fn main() !void {
|
||||
});
|
||||
}
|
||||
},
|
||||
.send => |input| {
|
||||
// NOTE: as the input in owned by the caller, this means that it still needs to be freed
|
||||
defer allocator.free(input);
|
||||
|
||||
log.info("accepted user input: {any}", .{input});
|
||||
},
|
||||
// NOTE: errors could be displayed in another container in case one was received, etc. to provide the user with feedback
|
||||
.err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }),
|
||||
else => {},
|
||||
|
||||
@@ -4,6 +4,7 @@ const code_point = @import("code_point");
|
||||
const event = @import("event.zig");
|
||||
const input = @import("input.zig");
|
||||
const terminal = @import("terminal.zig");
|
||||
const queue = @import("queue.zig");
|
||||
|
||||
const mergeTaggedUnions = event.mergeTaggedUnions;
|
||||
const isTaggedUnion = event.isTaggedUnion;
|
||||
@@ -11,7 +12,6 @@ const isTaggedUnion = event.isTaggedUnion;
|
||||
const Mouse = input.Mouse;
|
||||
const Key = input.Key;
|
||||
const Size = @import("size.zig").Size;
|
||||
const Queue = @import("queue.zig").Queue;
|
||||
|
||||
const log = std.log.scoped(.app);
|
||||
|
||||
@@ -44,8 +44,9 @@ pub fn App(comptime E: type) type {
|
||||
const element = @import("element.zig");
|
||||
pub const Element = element.Element(Event);
|
||||
pub const Scrollable = element.Scrollable(Event);
|
||||
pub const Queue = queue.Queue(Event, 256);
|
||||
|
||||
queue: Queue(Event, 256),
|
||||
queue: Queue,
|
||||
thread: ?std.Thread,
|
||||
quit_event: std.Thread.ResetEvent,
|
||||
termios: ?std.posix.termios = null,
|
||||
@@ -175,6 +176,7 @@ pub fn App(comptime E: type) type {
|
||||
while (true) {
|
||||
// FIX: I still think that there is a race condition (I'm just waiting 'long' enough)
|
||||
this.quit_event.timedWait(20 * std.time.ns_per_ms) catch {
|
||||
// FIX: in case the queue is full -> the next user input should panic and quit the application? because something seems to clock up the event queue
|
||||
const read_bytes = try terminal.read(buf[0..]);
|
||||
// TODO: `break` should not terminate the reading of the user inputs, but instead only the received faulty input!
|
||||
// escape key presses
|
||||
|
||||
@@ -225,9 +225,8 @@ pub const Layout = packed struct {
|
||||
};
|
||||
|
||||
pub fn Container(comptime Event: type) type {
|
||||
if (!isTaggedUnion(Event)) {
|
||||
@compileError("Provided user event `Event` for `Container(comptime Event: type)`");
|
||||
}
|
||||
if (!isTaggedUnion(Event)) @compileError("Provided user event `Event` for `Container(comptime Event: type)`");
|
||||
|
||||
const Element = @import("element.zig").Element(Event);
|
||||
return struct {
|
||||
allocator: std.mem.Allocator,
|
||||
@@ -243,6 +242,7 @@ pub fn Container(comptime Event: type) type {
|
||||
border: Border = .{},
|
||||
rectangle: Rectangle = .{},
|
||||
layout: Layout = .{},
|
||||
min_size: Size = .{},
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
@@ -270,6 +270,24 @@ pub fn Container(comptime Event: type) type {
|
||||
try this.elements.append(element);
|
||||
}
|
||||
|
||||
pub fn minSize(this: @This()) Size {
|
||||
var size: Size = .{};
|
||||
const len: u16 = @truncate(this.elements.items.len);
|
||||
if (len > 0) {
|
||||
for (this.elements.items) |element| {
|
||||
size = size.merge(element.minSize());
|
||||
}
|
||||
switch (this.properties.layout.direction) {
|
||||
.horizontal => size.cols += this.properties.layout.gap * (len - 1),
|
||||
.vertical => size.rows += this.properties.layout.gap * (len - 1),
|
||||
}
|
||||
}
|
||||
return .{
|
||||
.cols = @max(size.cols, this.properties.min_size.cols),
|
||||
.rows = @max(size.rows, this.properties.min_size.rows),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn handle(this: *@This(), event: Event) !void {
|
||||
switch (event) {
|
||||
.resize => |size| resize: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Interface for Element's which describe the contents of a `Container`.
|
||||
const std = @import("std");
|
||||
const s = @import("size.zig");
|
||||
const input = @import("input.zig");
|
||||
|
||||
const Container = @import("container.zig").Container;
|
||||
const Cell = @import("cell.zig");
|
||||
const Mouse = input.Mouse;
|
||||
const Position = s.Position;
|
||||
const Size = s.Size;
|
||||
|
||||
@@ -82,9 +84,12 @@ pub fn Scrollable(Event: type) type {
|
||||
/// `Size` of the actual contents where the anchor and the size is
|
||||
/// representing the size and location on screen.
|
||||
size: Size = .{},
|
||||
/// Anchor
|
||||
/// `Size` of the `Container` content that is scrollable and mapped to
|
||||
/// the *size* of the `Scrollable` `Element`.
|
||||
container_size: Size = .{},
|
||||
/// Anchor of the viewport of the scrollable `Container`.
|
||||
anchor: Position = .{},
|
||||
/// The actual container, that is *scrollable*
|
||||
/// The actual container, that is scrollable.
|
||||
container: Container(Event),
|
||||
/// Enable horizontal scrolling. This also renders a scrollbar (along the bottom of the viewport).
|
||||
horizontal: bool = false,
|
||||
@@ -104,24 +109,42 @@ pub fn Scrollable(Event: type) type {
|
||||
fn handle(ctx: *anyopaque, event: Event) !void {
|
||||
const this: *@This() = @ptrCast(@alignCast(ctx));
|
||||
switch (event) {
|
||||
.init => try this.container.handle(event),
|
||||
// TODO: emit `.resize` event for the container to set the size for the scrollable `Container`
|
||||
// - how would I determine the required or necessary `Size`?
|
||||
.resize => |size| {
|
||||
this.size = size;
|
||||
// TODO: not just pass through the given size, but rather the size that is necessary for scrollable content
|
||||
try this.container.handle(.{ .resize = size });
|
||||
const min_size = this.container.minSize();
|
||||
this.container_size = .{
|
||||
.anchor = size.anchor,
|
||||
.cols = @max(min_size.cols, size.cols),
|
||||
.rows = @max(min_size.rows, size.rows),
|
||||
};
|
||||
try this.container.handle(.{ .resize = this.container_size });
|
||||
},
|
||||
.mouse => |mouse| {
|
||||
std.log.debug("mouse event detected in scrollable element {any}", .{mouse.in(this.size)});
|
||||
try this.container.handle(.{
|
||||
.mouse => |mouse| switch (mouse.button) {
|
||||
Mouse.Button.wheel_up => if (this.vertical) {
|
||||
this.anchor.row -|= 1;
|
||||
},
|
||||
Mouse.Button.wheel_down => if (this.vertical) {
|
||||
const max_anchor_row = this.container_size.rows -| this.size.rows;
|
||||
this.anchor.row = @min(this.anchor.row + 1, max_anchor_row);
|
||||
},
|
||||
Mouse.Button.wheel_left => if (this.horizontal) {
|
||||
this.anchor.col -|= 1;
|
||||
},
|
||||
Mouse.Button.wheel_right => if (this.horizontal) {
|
||||
const max_anchor_col = this.container_size.cols -| this.size.cols;
|
||||
this.anchor.col = @min(this.anchor.col + 1, max_anchor_col);
|
||||
},
|
||||
else => try this.container.handle(.{
|
||||
.mouse = .{
|
||||
.col = mouse.col + this.anchor.col,
|
||||
.row = mouse.row + this.anchor.row,
|
||||
.button = mouse.button,
|
||||
.kind = mouse.kind,
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
else => try this.container.handle(event),
|
||||
}
|
||||
@@ -131,39 +154,41 @@ pub fn Scrollable(Event: type) type {
|
||||
const this: *@This() = @ptrCast(@alignCast(ctx));
|
||||
std.debug.assert(cells.len == @as(usize, this.size.cols) * @as(usize, this.size.rows));
|
||||
|
||||
const container_cells = try this.container.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows));
|
||||
@memset(cells, .{});
|
||||
const container_size = this.container.size;
|
||||
const container_cells = try this.container.allocator.alloc(Cell, @as(usize, container_size.cols) * @as(usize, container_size.rows));
|
||||
{
|
||||
const container_cells_const = try this.container.contents();
|
||||
defer this.container.allocator.free(container_cells_const);
|
||||
const container_size = this.container.size;
|
||||
std.debug.assert(container_cells_const.len == @as(usize, container_size.cols) * @as(usize, container_size.rows));
|
||||
@memcpy(container_cells, container_cells_const);
|
||||
}
|
||||
|
||||
// TODO: render correct view port into the container_cells
|
||||
for (this.container.elements.items) |*e| {
|
||||
const e_size = e.size;
|
||||
const element_cells = try e.contents();
|
||||
defer e.allocator.free(element_cells);
|
||||
|
||||
const anchor = (@as(usize, e_size.anchor.row -| size.anchor.row) * @as(usize, size.cols)) + @as(usize, e_size.anchor.col -| size.anchor.col);
|
||||
const anchor = (@as(usize, e_size.anchor.row -| container_size.anchor.row) * @as(usize, container_size.cols)) + @as(usize, e_size.anchor.col -| container_size.anchor.col);
|
||||
var idx: usize = 0;
|
||||
blk: for (0..e_size.rows) |row| {
|
||||
for (0..e_size.cols) |col| {
|
||||
blk: for (0..container_size.rows) |row| {
|
||||
for (0..container_size.cols) |col| {
|
||||
const cell = element_cells[idx];
|
||||
idx += 1;
|
||||
|
||||
container_cells[anchor + (row * size.cols) + col].style = cell.style;
|
||||
container_cells[anchor + (row * size.cols) + col].cp = cell.cp;
|
||||
container_cells[anchor + (row * container_size.cols) + col].style = cell.style;
|
||||
container_cells[anchor + (row * container_size.cols) + col].cp = cell.cp;
|
||||
|
||||
if (element_cells.len == idx) break :blk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const anchor = (@as(usize, this.anchor.row) * @as(usize, size.cols)) + @as(usize, this.anchor.col);
|
||||
for (container_cells, 0..) |cell, idx| {
|
||||
cells[anchor + idx] = cell;
|
||||
const anchor = (@as(usize, this.anchor.row) * @as(usize, container_size.cols)) + @as(usize, this.anchor.col);
|
||||
for (0..size.rows) |row| {
|
||||
for (0..size.cols) |col| {
|
||||
cells[(row * size.cols) + col] = container_cells[anchor + (row * container_size.cols) + col];
|
||||
}
|
||||
}
|
||||
this.container.allocator.free(container_cells);
|
||||
}
|
||||
|
||||
@@ -60,17 +60,34 @@ pub const Key = packed struct {
|
||||
/// ```zig
|
||||
/// switch (event) {
|
||||
/// .quit => break,
|
||||
/// .key => |key| {
|
||||
/// // ctrl+c to quit
|
||||
/// if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } }))
|
||||
/// app.quit.set();
|
||||
/// },
|
||||
/// .key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit.set(),
|
||||
/// else => {},
|
||||
/// }
|
||||
/// ```
|
||||
pub fn eql(this: @This(), other: @This()) bool {
|
||||
return std.meta.eql(this, other);
|
||||
}
|
||||
|
||||
/// Determine if the `Key` is an ascii character that can be printed to
|
||||
/// the screen. This means that the code point of the `Key` is an ascii
|
||||
/// character between 32 - 255 (with the exception of 127 = Delete) and no
|
||||
/// modifiers (alt and/or ctrl) are used.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Get user input's from the .key event from the application event loop:
|
||||
///
|
||||
/// ```zig
|
||||
/// switch (event) {
|
||||
/// .key => |key| if (key.isAscii()) try this.input.append(key.cp),
|
||||
/// else => {},
|
||||
/// }
|
||||
/// ```
|
||||
pub fn isAscii(this: @This()) bool {
|
||||
return this.mod.alt == false and this.mod.ctrl == false and // no modifier keys
|
||||
(this.cp >= 32 and this.cp <= 126 or // ascii printable characters (except for input.Delete)
|
||||
this.cp >= 128 and this.cp <= 255); // extended ascii codes
|
||||
}
|
||||
};
|
||||
|
||||
// codepoints for keys
|
||||
|
||||
17
src/main.zig
17
src/main.zig
@@ -42,15 +42,13 @@ pub const HelloWorldText = packed struct {
|
||||
const this: *@This() = @ptrCast(@alignCast(ctx));
|
||||
switch (event) {
|
||||
.init => log.debug(".init event", .{}),
|
||||
.key => |key| {
|
||||
if (key.eql(.{ .cp = input.Space })) {
|
||||
var next_color_idx = @intFromEnum(this.text_color);
|
||||
next_color_idx += 1;
|
||||
next_color_idx %= 17; // iterate over the first 16 colors (but exclude `.default` == 0)
|
||||
if (next_color_idx == 0) next_color_idx += 1;
|
||||
this.text_color = @enumFromInt(next_color_idx);
|
||||
log.debug("Next color: {s}", .{@tagName(this.text_color)});
|
||||
}
|
||||
.key => |key| if (key.eql(.{ .cp = input.Space })) {
|
||||
var next_color_idx = @intFromEnum(this.text_color);
|
||||
next_color_idx += 1;
|
||||
next_color_idx %= 17;
|
||||
if (next_color_idx == @intFromEnum(zterm.Color.default)) next_color_idx += 1;
|
||||
this.text_color = @enumFromInt(next_color_idx);
|
||||
log.debug("Next color: {s}", .{@tagName(this.text_color)});
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
@@ -84,6 +82,7 @@ pub fn main() !void {
|
||||
.direction = .vertical,
|
||||
.padding = .vertical(1),
|
||||
},
|
||||
.min_size = .{ .rows = 100 },
|
||||
}, .{});
|
||||
try box.append(try App.Container.init(allocator, .{
|
||||
.rectangle = .{ .fill = .light_green },
|
||||
|
||||
@@ -2,6 +2,13 @@ pub const Size = packed struct {
|
||||
anchor: Position = .{},
|
||||
cols: u16 = 0,
|
||||
rows: u16 = 0,
|
||||
|
||||
pub fn merge(this: @This(), other: @This()) Size {
|
||||
return .{
|
||||
.cols = this.cols + other.cols,
|
||||
.rows = this.rows + other.rows,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Position = packed struct {
|
||||
|
||||
Reference in New Issue
Block a user