From 8b5d3757fcce0c40500f252f66b1b465ed84bbb6 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 27 Dec 2025 17:06:07 +0100 Subject: [PATCH] mod(element/Input): split into `TextField` without associated event to automatically trigger --- src/element.zig | 386 +++++++++++++++++++++++++++++------------------- src/queue.zig | 2 +- 2 files changed, 231 insertions(+), 157 deletions(-) diff --git a/src/element.zig b/src/element.zig index 00112ee..e02ddee 100644 --- a/src/element.zig +++ b/src/element.zig @@ -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); } }; } diff --git a/src/queue.zig b/src/queue.zig index aae7ddf..5b713be 100644 --- a/src/queue.zig +++ b/src/queue.zig @@ -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,