From b1d6aeebf2bf2573f2781013393b47c334af8b5c Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 07 Mar 2025 14:33:22 +0000 Subject: [PATCH] ui: add text field to networks --- src/app.zig | 16 ++++++++++------ src/irc.zig | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ src/ui.zig | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 145 insertion(s)(+), 12 deletion(s)(-) diff --git a/src/app.zig b/src/app.zig --- a/src/app.zig +++ b/src/app.zig @@ -230,6 +230,7 @@ } if (self.fg != null and self.bg != null) { for (self.clients.items) |client| { + client.text_field.style.bg = self.blendBg(10); for (client.channels.items) |channel| { channel.text_field.style.bg = self.blendBg(10); } @@ -304,6 +305,9 @@ try ctx.queryColor(.fg); try ctx.queryColor(.bg); try ctx.queryColor(.{ .index = 3 }); + if (self.clients.items.len > 0) { + try ctx.requestFocus(self.clients.items[0].text_field.widget()); + } }, .tick => { for (self.clients.items) |client| { @@ -374,7 +378,7 @@ pub fn connect(self: *App, cfg: irc.Client.Config) !void { const client = try self.alloc.create(irc.Client); - client.* = try irc.Client.init(self.alloc, self, &self.write_queue, cfg); + try client.init(self.alloc, self, &self.write_queue, cfg); try self.clients.append(client); } @@ -383,8 +387,8 @@ self.buffer_list.nextItem(ctx); if (self.selectedBuffer()) |buffer| { switch (buffer) { - .client => { - ctx.requestFocus(self.widget()) catch {}; + .client => |client| { + ctx.requestFocus(client.text_field.widget()) catch {}; }, .channel => |channel| { ctx.requestFocus(channel.text_field.widget()) catch {}; @@ -399,8 +403,8 @@ self.buffer_list.prevItem(ctx); if (self.selectedBuffer()) |buffer| { switch (buffer) { - .client => { - ctx.requestFocus(self.widget()) catch {}; + .client => |client| { + ctx.requestFocus(client.text_field.widget()) catch {}; }, .channel => |channel| { ctx.requestFocus(channel.text_field.widget()) catch {}; @@ -659,7 +663,7 @@ for (self.clients.items) |client| { if (client == target) { if (self.ctx) |ctx| { - ctx.requestFocus(self.widget()) catch {}; + ctx.requestFocus(client.text_field.widget()) catch {}; } self.buffer_list.cursor = i; self.buffer_list.ensureScroll(); diff --git a/src/irc.zig b/src/irc.zig --- a/src/irc.zig +++ b/src/irc.zig @@ -1656,13 +1656,17 @@ has_mouse: bool, retry_delay_s: u8, + text_field: vxfw.TextField, + completer_shown: bool, + pub fn init( + self: *Client, alloc: std.mem.Allocator, app: *comlink.App, wq: *comlink.WriteQueue, cfg: Config, - ) !Client { - return .{ + ) !void { + self.* = .{ .alloc = alloc, .app = app, .client = undefined, @@ -1678,7 +1682,29 @@ .read_buf = std.ArrayList(u8).init(alloc), .has_mouse = false, .retry_delay_s = 0, + .text_field = .init(alloc, app.unicode), + .completer_shown = false, }; + self.text_field.style = .{ .bg = self.app.blendBg(10) }; + self.text_field.userdata = self; + self.text_field.onSubmit = Client.onSubmit; + } + + fn onSubmit(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void { + const self: *Client = @ptrCast(@alignCast(ptr orelse unreachable)); + + // Copy the input into a temporary buffer + var buf: [1024]u8 = undefined; + @memcpy(buf[0..input.len], input); + const local = buf[0..input.len]; + // Free the text field. We do this here because the command may destroy our channel + self.text_field.clearAndFree(); + self.completer_shown = false; + + if (std.mem.startsWith(u8, local, "/")) { + try self.app.handleCommand(.{ .client = self }, local); + } + ctx.redraw = true; } /// Closes the connection @@ -1779,10 +1805,65 @@ fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { const self: *Client = @ptrCast(@alignCast(ptr)); - const text: vxfw.Text = .{ .text = "content" }; - var surface = try text.draw(ctx); - surface.widget = self.view(); - return surface; + const max = ctx.max.size(); + + var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); + { + // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n" + const max_limit = 510; + const limit = try std.fmt.allocPrint( + ctx.arena, + " {d}/{d}", + .{ self.text_field.buf.realLength(), max_limit }, + ); + const style: vaxis.Style = if (self.text_field.buf.realLength() > max_limit) + .{ .fg = .{ .index = 1 }, .reverse = true } + else + .{ .bg = self.app.blendBg(30) }; + const limit_text: vxfw.Text = .{ .text = limit, .style = style }; + const limit_ctx = ctx.withConstraints(.{ .width = @intCast(limit.len) }, ctx.max); + const limit_s = try limit_text.draw(limit_ctx); + + try children.append(.{ + .origin = .{ .col = max.width -| limit_s.size.width, .row = max.height - 1 }, + .surface = limit_s, + }); + + const text_field_ctx = ctx.withConstraints( + ctx.min, + .{ .height = 1, .width = max.width -| limit_s.size.width }, + ); + + // Draw the text field + try children.append(.{ + .origin = .{ .col = 0, .row = max.height - 1 }, + .surface = try self.text_field.draw(text_field_ctx), + }); + // Write some placeholder text if we don't have anything in the text field + if (self.text_field.buf.realLength() == 0) { + const text = try std.fmt.allocPrint(ctx.arena, "Message {s}", .{self.serverName()}); + var text_style = self.text_field.style; + text_style.italic = true; + text_style.dim = true; + var ghost_text_ctx = text_field_ctx; + ghost_text_ctx.max.width = text_field_ctx.max.width.? -| 2; + const ghost_text: vxfw.Text = .{ .text = text, .style = text_style }; + try children.append(.{ + .origin = .{ .col = 2, .row = max.height - 1 }, + .surface = try ghost_text.draw(ghost_text_ctx), + }); + } + } + return .{ + .widget = self.view(), + .size = max, + .buffer = &.{}, + .children = children.items, + }; + } + + pub fn serverName(self: *Client) []const u8 { + return self.config.name orelse self.config.server; } pub fn nameWidget(self: *Client, selected: bool) vxfw.Widget { diff --git a/src/ui.zig b/src/ui.zig new file mode 100644 --- /dev/null +++ b/src/ui.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); + +const vxfw = vaxis.vxfw; + +const Allocator = std.mem.Allocator; +const App = @import("app.zig").App; +const ChildList = std.ArrayListUnmanaged(SubSurface); +const Surface = vxfw.Surface; +const SubSurface = vxfw.SubSurface; + +const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" }; + +pub fn drawMain(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!Surface { + const self: *App = @ptrCast(@alignCast(ptr)); + const max = ctx.max.size(); + self.last_height = max.height; + if (self.selectedBuffer()) |buffer| { + switch (buffer) { + .client => |client| self.view.rhs = client.view(), + .channel => |channel| self.view.rhs = channel.view.widget(), + } + } else self.view.rhs = default_rhs.widget(); + + var children: ChildList = .empty; + + // UI is a tree of splits + // │ │ │ │ + // │ │ │ │ + // │ buffers │ buffer content │ members │ + // │ │ │ │ + // │ │ │ │ + // │ │ │ │ + // │ │ │ │ + + const sub: vxfw.SubSurface = .{ + .origin = .{ .col = 0, .row = 0 }, + .surface = try self.view.widget().draw(ctx), + }; + try children.append(ctx.arena, sub); + + return .{ + .size = ctx.max.size(), + .widget = self.widget(), + .buffer = &.{}, + .children = children.items, + }; +} -- tangled.sh