add(widget/Input): widget for user inputs
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m22s

Accepts optional label and placeholders which are rendered accordingly.
The widget only is interactable if it is active. Hence it needs further
control from the outside scope where it is used (likely a corresponding
layout). This also applies to the inputed value, which needs to be
received from the within the owning layout to be used for further
processing (i.e. in custom user events, etc.).
This commit is contained in:
2024-12-27 11:34:51 +01:00
parent 3a989321fc
commit 2ef59ca9ea
3 changed files with 244 additions and 1 deletions

View File

@@ -296,7 +296,6 @@ pub fn App(comptime E: type, comptime R: fn (comptime bool) type, comptime fulls
}; };
this.postEvent(.{ .key = key }); this.postEvent(.{ .key = key });
}, },
'I' => this.postEvent(.{ .focus = true }), 'I' => this.postEvent(.{ .focus = true }),
'O' => this.postEvent(.{ .focus = false }), 'O' => this.postEvent(.{ .focus = false }),
// 'M', 'm' => return parseMouse(sequence), // TODO: parse mouse inputs // 'M', 'm' => return parseMouse(sequence), // TODO: parse mouse inputs

View File

@@ -91,6 +91,7 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type {
// TODO: implement a minimal size requirement for Widgets to render correctly? // TODO: implement a minimal size requirement for Widgets to render correctly?
// import and export of `Widget` implementations // import and export of `Widget` implementations
pub const Input = @import("widget/Input.zig").Widget(Event, Renderer);
pub const Text = @import("widget/Text.zig").Widget(Event, Renderer); pub const Text = @import("widget/Text.zig").Widget(Event, Renderer);
pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer); pub const RawText = @import("widget/RawText.zig").Widget(Event, Renderer);
pub const Spacer = @import("widget/Spacer.zig").Widget(Event, Renderer); pub const Spacer = @import("widget/Spacer.zig").Widget(Event, Renderer);
@@ -98,6 +99,7 @@ pub fn Widget(comptime Event: type, comptime Renderer: type) type {
}; };
// test widget implementation satisfies the interface // test widget implementation satisfies the interface
comptime Type.Interface.satisfiedBy(Type); comptime Type.Interface.satisfiedBy(Type);
comptime Type.Interface.satisfiedBy(Type.Input);
comptime Type.Interface.satisfiedBy(Type.Text); comptime Type.Interface.satisfiedBy(Type.Text);
comptime Type.Interface.satisfiedBy(Type.RawText); comptime Type.Interface.satisfiedBy(Type.RawText);
comptime Type.Interface.satisfiedBy(Type.Spacer); comptime Type.Interface.satisfiedBy(Type.Spacer);

242
src/widget/Input.zig Normal file
View File

@@ -0,0 +1,242 @@
const std = @import("std");
const terminal = @import("../terminal.zig");
const isTaggedUnion = @import("../event.zig").isTaggedUnion;
const Cell = terminal.Cell;
const Key = terminal.Key;
const Size = terminal.Size;
const log = std.log.scoped(.widget_input);
pub fn Widget(comptime Event: type, comptime Renderer: type) type {
if (!isTaggedUnion(Event)) {
@compileError("Provided user event `Event` for `Layout(comptime Event: type)` is not of type `union(enum)`.");
}
return struct {
active: bool = false,
allocator: std.mem.Allocator = undefined,
label: ?[]const u8 = null,
placeholder: ?[]const u8 = null,
size: Size = undefined,
require_render: bool = false,
value: std.ArrayList(u8) = undefined,
value_len: usize = 0, // value content length
cursor_idx: usize = 0, // current cursor position
pub fn init(allocator: std.mem.Allocator, label: ?[]const u8, placeholder: ?[]const u8) @This() {
var value = std.ArrayList(u8).init(allocator);
value.resize(32) catch @panic("Input.zig: out of memory");
return .{
.allocator = allocator,
.value = value,
.label = label,
.placeholder = placeholder,
};
}
pub fn deinit(this: *@This()) void {
this.value.deinit();
this.* = undefined;
}
pub fn getValue(this: *const @This()) []const u8 {
return this.value.items[0..this.value_len];
}
pub fn handle(this: *@This(), event: Event) ?Event {
switch (event) {
.resize => |size| {
this.size = size;
this.require_render = true;
var required_cols: u16 = 4; // '...c'
if (this.label) |label| {
required_cols += @as(u16, @truncate(label.len)); // <label>
required_cols += 2; // ': '
}
if (this.size.cols < required_cols) {
return .{ .err = .{
.err = error.InsufficientSize,
.msg = "Received Size is too small to render App.Widget.Input correctly",
} };
}
},
.key => |key| {
if (!this.active) {
return null;
}
if (key.matches(.{ .cp = Key.tab }) or key.matches(.{ .cp = Key.enter })) {
// ignored keys
} else if (key.mod.alt or key.mod.ctrl or key.matches(.{ .cp = Key.escape })) {
// TODO: what about ctrl-v, ctrl-w, alt-b, alt-f?
// ignored keys
} else if (key.matches(.{ .cp = Key.backspace })) {
// remove one character
_ = this.value.orderedRemove(this.cursor_idx);
this.cursor_idx -|= 1;
this.value_len -|= 1;
this.require_render = true;
} else if (key.matches(.{ .cp = Key.left }) or key.matches(.{ .cp = 'b', .mod = .{ .ctrl = true } })) {
// left
this.cursor_idx -|= 1;
this.require_render = true;
} else if (key.matches(.{ .cp = Key.right }) or key.matches(.{ .cp = 'f', .mod = .{ .ctrl = true } })) {
// right
if (this.cursor_idx < this.value_len) {
this.cursor_idx += 1;
this.require_render = true;
}
} else {
if (this.value.items.len <= this.value_len) {
// double capacity in case we need more space
this.value.resize(this.value.capacity * 2) catch |err| {
return .{
.err = .{
.err = err,
.msg = "Could not resize input value buffer",
},
};
};
}
this.value.insert(this.cursor_idx, @as(u8, @truncate(key.cp))) catch @panic("Input.zig: out of memory");
this.cursor_idx += 1;
this.value_len += 1;
this.require_render = true;
}
// TODO: handle key input that should be used for the input field value
// - move cursor using arrow keys
// - allow word-wise navigation?
// - add / remove characters
// - allow removal of words?
// - do not support pasting, as that can be done by the terminal emulator (not sure if this would even work correctly over ssh)
},
else => {},
}
return null;
}
// Overview of the rendered contents:
//
// With both label and placeholder:
// <label>: <placeholder>
// Without any label:
// <placeholder>
// Without any placeholder, but a label:
// <label>: ____________
// With neither label nor placeholder:
// ____________
// When value is not an empty string, the corresponding placeholder
// (if any) will be replaced with the current value. The current
// cursor position is show when this input field is `active`.
pub fn render(this: *@This(), renderer: *Renderer) !void {
if (!this.require_render) {
return;
}
try renderer.clear(this.size);
var size = this.size;
this.require_render = false;
if (this.label) |label| {
const label_style: Cell.Style = .{
.fg = .default,
.italic = true,
};
try renderer.render(size, &[_]Cell{
.{
.content = label,
.style = label_style,
},
});
size.anchor.col += @as(u16, @truncate(label.len));
size.cols -= @as(u16, @truncate(label.len));
try renderer.render(size, &[_]Cell{
.{
.content = ":",
},
});
size.anchor.col += 2;
size.cols -= 2;
}
if (this.value_len > 0) {
var start: usize = 0;
// TODO: moving the cursor position will change position of the '..' placement (i.e. at the beginning, at the end or both)
// truncate representation according to the available space
if (this.value_len >= size.cols - 1) {
start = this.value_len -| (size.cols - 3);
try renderer.render(size, &[_]Cell{
.{
.content = "..",
.style = .{ .dim = true },
},
});
size.anchor.col += 2;
size.cols -|= 2;
}
// print current value representation (and cursor position if active)
if (this.cursor_idx == 0 and this.value_len > 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[0..1],
.style = .{ .reverse = true },
},
.{
.content = this.value.items[1..this.value_len],
},
});
} else if (this.cursor_idx == 0 and this.value_len == 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[0..1],
.style = .{ .reverse = true },
},
});
} else if (this.cursor_idx == this.value_len) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[start..this.cursor_idx],
},
.{
.content = " ",
.style = .{ .reverse = true },
},
});
} else {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[start..this.cursor_idx],
},
.{
.content = this.value.items[this.cursor_idx .. this.cursor_idx + 1],
.style = .{ .reverse = true, .blink = true },
},
});
size.anchor.col += @as(u16, @truncate(this.cursor_idx)) + 1;
size.cols -= @as(u16, @truncate(this.cursor_idx)) + 1;
if (this.value_len > this.cursor_idx + 1) {
try renderer.render(size, &[_]Cell{
.{
.content = this.value.items[this.cursor_idx + 1 .. this.value_len],
},
});
}
}
} else {
if (this.placeholder) |placeholder| {
var placeholder_style: Cell.Style = .{
.fg = .default,
.dim = true,
};
if (this.active) {
placeholder_style.blink = true;
}
try renderer.render(size, &[_]Cell{
.{
.content = placeholder,
.style = placeholder_style,
},
});
}
}
}
};
}