mod(element/Input): split into TextField without associated event to automatically trigger
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m9s

This commit is contained in:
2025-12-27 17:06:07 +01:00
parent 06ab32bdde
commit 8b5d3757fc
2 changed files with 231 additions and 157 deletions

View File

@@ -502,6 +502,215 @@ pub fn Scrollable(Model: type, Event: type) type {
};
} // Scrollable(Model: type, Event: type)
pub fn TextField(Model: type, Event: type) type {
return struct {
allocator: Allocator,
/// Offset from the end describing the current position of the cursor.
cursor_offset: usize = 0,
/// Configuration for the InputField.
configuration: Configuration,
/// Array holding the value of the input.
input: std.ArrayList(u21),
/// Configuration for InputField's.
pub const Configuration = packed struct {
color: Color,
cursor: Color,
pub fn init(color: Color, cursor: Color) @This() {
return .{
.color = color,
.cursor = cursor,
};
}
};
pub fn init(allocator: Allocator, configuration: Configuration) @This() {
return .{
.allocator = allocator,
.configuration = configuration,
.input = std.ArrayList(u21).initCapacity(allocator, 8) catch unreachable,
};
}
pub fn deinit(this: *@This()) void {
this.input.deinit(this.allocator);
}
pub fn clear(this: *@This()) void {
this.input.clearRetainingCapacity();
this.cursor_offset = 0;
}
fn element_deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.deinit = element_deinit,
.handle = element_handle,
.content = element_content,
},
};
}
pub fn handle(this: *@This(), model: *Model, event: Event) !void {
_ = model;
switch (event) {
.key => |key| {
assert(this.cursor_offset >= 0 and this.cursor_offset <= this.input.items.len);
// readline commands
if (key.eql(.{ .cp = input.Left }) or key.eql(.{ .cp = input.KpLeft }) or key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } })) {
if (this.cursor_offset < this.input.items.len) this.cursor_offset += 1;
}
if (key.eql(.{ .cp = input.Right }) or key.eql(.{ .cp = input.KpRight }) or key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }))
this.cursor_offset -|= 1;
if (key.eql(.{ .cp = 'e', .mod = .{ .ctrl = true } })) this.cursor_offset = 0;
if (key.eql(.{ .cp = 'a', .mod = .{ .ctrl = true } })) this.cursor_offset = this.input.items.len;
if (key.eql(.{ .cp = 'k', .mod = .{ .ctrl = true } })) {
while (this.cursor_offset > 0) {
_ = this.input.pop();
this.cursor_offset -= 1;
}
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
const len = this.input.items.len - this.cursor_offset;
for (0..len) |_| _ = this.input.orderedRemove(0);
this.cursor_offset = this.input.items.len;
}
if (key.eql(.{ .cp = 'w', .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
const removed = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
if (removed != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'b', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Left, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpLeft, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
this.cursor_offset += 1;
if (this.cursor_offset == this.input.items.len) break;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'f', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Right, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpRight, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset > 0) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') {
this.cursor_offset += 1; // correct cursor position back again to make sure the cursor is not on the whitespace, but at the end of the jumped word
break;
}
// see backspace
this.cursor_offset -= 1;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
// TODO should this also accept `input.Enter` and insert `\n` into the value of the field?
// usual input keys
if (key.isAscii()) try this.input.insert(this.allocator, this.input.items.len - this.cursor_offset, key.cp);
if (key.eql(.{ .cp = input.Backspace })) {
if (this.cursor_offset < this.input.items.len) {
_ = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
}
}
if (key.eql(.{ .cp = input.Delete }) or key.eql(.{ .cp = input.KpDelete })) {
if (this.cursor_offset > 0) {
_ = this.input.orderedRemove(this.input.items.len - this.cursor_offset);
this.cursor_offset -= 1;
}
}
// TODO enter to accept?
// - shift+enter is not recognized by the input reader of `zterm`, so currently it is not possible to add newlines into the text box?
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) {}
},
else => {},
}
}
fn element_handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
try this.handle(model, event);
}
pub fn toOwnedSlice(this: *@This(), comptime t: enum { bytes, code_points }) !switch (t) {
.bytes => []u8,
.code_points => []u21,
} {
const value = switch (t) {
.bytes => blk: {
// NOTE convert unicode characters to ascii characters; if non ascii characters are found this is will fail!
var slice = try this.allocator.alloc(u8, this.input.items.len);
for (0.., this.input.items) |i, c| slice[i] = @intCast(c);
this.input.clearAndFree(this.allocator);
break :blk slice;
},
.code_points => try this.input.toOwnedSlice(this.allocator),
};
this.cursor_offset = 0;
return value;
}
pub fn content(this: *@This(), model: *const Model, cells: []Cell, size: Point) !void {
_ = model;
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const offset = if (this.input.items.len - this.cursor_offset + 1 >= cells.len) this.input.items.len + 1 - cells.len else 0;
for (this.input.items[offset..], 0..) |cp, idx| {
cells[idx].style.fg = this.configuration.color;
cells[idx].cp = cp;
// display ellipse at the beginning
if (offset > 0 and idx == 0) cells[idx].cp = '…';
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len - 1) {
// display ellipse at the end
if (this.input.items.len >= cells.len and this.cursor_offset > 0) cells[idx].cp = '…';
break;
}
}
if (this.input.items.len < cells.len) {
cells[this.input.items.len - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - this.cursor_offset].style.cursor_shape = .bar_blinking;
} else {
cells[this.input.items.len - offset - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_shape = .bar_blinking;
}
}
fn element_content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
try this.content(model, cells, size);
}
};
} // TextField(Model: type, Event: type)
// TODO features
// - clear input (with and without retaining of capacity) through an public api
// - make handle / content functions public
@@ -509,7 +718,7 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const input_struct = struct {
pub fn input_fn(accept_event: meta.FieldEnum(Event)) type {
const event_type: enum { ascii, utf8 } = blk: { // check for type correctness and the associated type to use for the passed `accept_event`
const event_type: enum { bytes, code_points } = blk: { // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `[]u8` or `[]u21` are allowed.";
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.pointer => |pointer| {
@@ -518,8 +727,8 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
.int => |num| {
if (num.signedness != .unsigned) @compileError(err_msg);
switch (num.bits) {
8 => break :blk .ascii,
21 => break :blk .utf8,
8 => break :blk .bytes,
21 => break :blk .code_points,
else => @compileError(err_msg),
}
},
@@ -530,41 +739,21 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
}
};
return struct {
allocator: Allocator,
/// Offset from the end describing the current position of the cursor.
cursor_offset: usize = 0,
/// Configuration for the InputField.
configuration: Configuration,
/// Array holding the value of the input.
input: std.ArrayList(u21),
/// Inner `TextField` for capturing, displaying and handling user inputs.
text_field: TextField(Model, Event),
/// Reference to the app's queue to issue the associated event to trigger when completing the input.
queue: *Queue,
/// Configuration for InputField's.
pub const Configuration = packed struct {
color: Color,
cursor: Color,
pub fn init(color: Color, cursor: Color) @This() {
return .{
.color = color,
.cursor = cursor,
};
}
};
pub fn init(allocator: Allocator, queue: *Queue, configuration: Configuration) @This() {
pub fn init(allocator: Allocator, queue: *Queue, configuration: TextField(Model, Event).Configuration) @This() {
return .{
.allocator = allocator,
.configuration = configuration,
.input = std.ArrayList(u21).initCapacity(allocator, 8) catch unreachable,
.text_field = .init(allocator, configuration),
.queue = queue,
};
}
fn deinit(ctx: *anyopaque) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.input.deinit(this.allocator);
this.text_field.deinit();
}
pub fn element(this: *@This()) Element(Model, Event) {
@@ -578,147 +767,32 @@ pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) t
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => |key| {
assert(this.cursor_offset >= 0 and this.cursor_offset <= this.input.items.len);
// readline commands
if (key.eql(.{ .cp = input.Left }) or key.eql(.{ .cp = input.KpLeft }) or key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } })) {
if (this.cursor_offset < this.input.items.len) this.cursor_offset += 1;
}
if (key.eql(.{ .cp = input.Right }) or key.eql(.{ .cp = input.KpRight }) or key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }))
this.cursor_offset -|= 1;
if (key.eql(.{ .cp = 'e', .mod = .{ .ctrl = true } })) this.cursor_offset = 0;
if (key.eql(.{ .cp = 'a', .mod = .{ .ctrl = true } })) this.cursor_offset = this.input.items.len;
if (key.eql(.{ .cp = 'k', .mod = .{ .ctrl = true } })) {
while (this.cursor_offset > 0) {
_ = this.input.pop();
this.cursor_offset -= 1;
}
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
const len = this.input.items.len - this.cursor_offset;
for (0..len) |_| _ = this.input.orderedRemove(0);
this.cursor_offset = this.input.items.len;
}
if (key.eql(.{ .cp = 'w', .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
const removed = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
if (removed != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'b', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Left, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpLeft, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
this.cursor_offset += 1;
if (this.cursor_offset == this.input.items.len) break;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'f', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Right, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpRight, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset > 0) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') {
this.cursor_offset += 1; // correct cursor position back again to make sure the cursor is not on the whitespace, but at the end of the jumped word
break;
}
// see backspace
this.cursor_offset -= 1;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
// usual input keys
if (key.isAscii()) try this.input.insert(this.allocator, this.input.items.len - this.cursor_offset, key.cp);
if (key.eql(.{ .cp = input.Backspace })) {
if (this.cursor_offset < this.input.items.len) {
_ = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
}
}
if (key.eql(.{ .cp = input.Delete }) or key.eql(.{ .cp = input.KpDelete })) {
if (this.cursor_offset > 0) {
_ = this.input.orderedRemove(this.input.items.len - this.cursor_offset);
this.cursor_offset -= 1;
}
}
// NOTE handle order of keys such that the `input.Enter` might not be processed twice! (leading to unexpected values)
try this.text_field.handle(model, event);
// TODO enter to accept?
// - shift+enter is not recognized by the input reader of `zterm`, so currently it is not possible to add newlines into the text box?
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) {
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) this.queue.push(@unionInit(
Event,
@tagName(accept_event),
switch (event_type) {
.ascii => {
// NOTE convert unicode characters to ascii characters; if non ascii characters are found this is will fail!
var slice = try this.allocator.alloc(u8, this.input.items.len);
for (0.., this.input.items) |i, c| slice[i] = @intCast(c);
this.input.clearAndFree(this.allocator);
this.queue.push(@unionInit(
Event,
@tagName(accept_event),
slice,
));
},
.utf8 => this.queue.push(@unionInit(
Event,
@tagName(accept_event),
try this.input.toOwnedSlice(this.allocator),
)),
}
this.cursor_offset = 0;
}
.bytes => try this.text_field.toOwnedSlice(.bytes),
.code_points => try this.text_field.toOwnedSlice(.code_points),
},
));
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
fn content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const offset = if (this.input.items.len - this.cursor_offset + 1 >= cells.len) this.input.items.len + 1 - cells.len else 0;
for (this.input.items[offset..], 0..) |cp, idx| {
cells[idx].style.fg = this.configuration.color;
cells[idx].cp = cp;
// display ellipse at the beginning
if (offset > 0 and idx == 0) cells[idx].cp = '…';
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len - 1) {
// display ellipse at the end
if (this.input.items.len >= cells.len and this.cursor_offset > 0) cells[idx].cp = '…';
break;
}
}
if (this.input.items.len < cells.len) {
cells[this.input.items.len - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - this.cursor_offset].style.cursor_shape = .bar_blinking;
} else {
cells[this.input.items.len - offset - this.cursor_offset].style.cursor = true;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_color = this.configuration.cursor;
cells[this.input.items.len - offset - this.cursor_offset].style.cursor_shape = .bar_blinking;
}
// NOTE size assertion against cells happens in the `TextField.content` method
try this.text_field.content(model, cells, size);
}
};
}

View File

@@ -1,7 +1,7 @@
// taken from https://github.com/rockorager/libvaxis/blob/main/src/queue.zig (MIT-License)
// with slight modifications
/// Queue implementation. Thread safe. Fixed size. Blocking push and pop. Polling through tryPop and tryPush.
/// Queue implementation. Thread safe. Fixed size. _Blocking_ `push` and `pop`. _Polling_ through `tryPop` and `tryPush`.
pub fn Queue(comptime T: type, comptime size: usize) type {
return struct {
buf: [size]T = undefined,