//! Blinking text cursor for text-input style widgets. Renders a single //! character that toggles between styled and reverse-video styled. //! //! Typical use: embed a Cursor in a parent struct, set `char` to whatever's //! under the cursor, route relevant updates from the parent's `update`, and //! call `cursor.view(w)` from the parent's `view`. const std = @import("std"); const tea = @import("blacktea"); const matte = @import("matte"); const util = @import("util.zig"); const t = std.testing; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cursor = @This(); const Mode = enum { blink, static, hide }; /// Marker address used to identify our tick messages in `Msg.custom`. var tick_marker: u8 = 0; fn tickTypeId() usize { return @intFromPtr(&tick_marker); } /// Style applied to the cursor cell when it is visible. style: matte.Style = .none, /// Style applied to the cursor cell when blinked off (i.e. plain text). text_style: matte.Style = .none, /// How long each blink half-cycle lasts. blink_speed_ns: u64 = 530_000_000, /// When true, the cursor is currently in its hidden half-cycle. is_blinked: bool = false, mode: Mode = .blink, focused: bool = false, /// The character under the cursor. Must be a single grapheme; rendered as-is. char: []const u8 = " ", /// Set true while a chain of tick Cmds is in flight. running: bool = false, model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }, }, fn vtableInit(model: *tea.Model) ?tea.Cmd { const self: *Cursor = @fieldParentPtr("model", model); return self.focus(model.gpa); } fn vtableUpdate(model: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *Cursor = @fieldParentPtr("model", model); return self.update(model.gpa, msg); } fn vtableView(model: *tea.Model, writer: *Writer) Writer.Error!tea.View { const self: *Cursor = @fieldParentPtr("model", model); try self.view(writer); return .{}; } pub fn setMode(self: *Cursor, allocator: Allocator, mode: Mode) ?tea.Cmd { self.mode = mode; self.is_blinked = mode == .hide or !self.focused; if (mode == .blink and self.focused and !self.running) return self.startBlink(allocator); if (mode != .blink) self.running = false; return null; } pub fn focus(self: *Cursor, allocator: Allocator) ?tea.Cmd { self.focused = true; self.is_blinked = self.mode == .hide; if (self.mode == .blink and !self.running) return self.startBlink(allocator); return null; } pub fn blur(self: *Cursor) void { self.focused = false; self.is_blinked = true; self.running = false; } pub fn setChar(self: *Cursor, char: []const u8) void { self.char = char; } pub fn update(self: *Cursor, allocator: Allocator, msg: tea.Msg) ?tea.Cmd { switch (msg) { .focus => return self.focus(allocator), .blur => { self.blur(); return null; }, .custom => |c| { if (c.type_id != tickTypeId()) return null; if (!self.running or self.mode != .blink or !self.focused) { self.running = false; return null; } self.is_blinked = !self.is_blinked; return self.scheduleTick(allocator); }, else => return null, } } pub fn view(self: *const Cursor, writer: *Writer) Writer.Error!void { if (self.is_blinked) { try util.writeInline(writer, self.text_style, self.char); return; } var s: matte.Style = self.style; s.reverse = true; try util.writeInline(writer, s, self.char); } fn startBlink(self: *Cursor, allocator: Allocator) ?tea.Cmd { self.running = true; return self.scheduleTick(allocator); } fn scheduleTick(self: *Cursor, allocator: Allocator) ?tea.Cmd { return tea.Cmd.tick(allocator, self.blink_speed_ns, .{ .custom = .{ .type_id = tickTypeId() }, }) catch { self.running = false; return null; }; } test "blur hides cursor" { var c: Cursor = .{}; c.blur(); try t.expect(c.is_blinked); try t.expect(!c.focused); } test "view renders char when visible" { var buf: [16]u8 = undefined; var w: Writer = .fixed(&buf); const c: Cursor = .{ .char = "x", .is_blinked = false }; try c.view(&w); try t.expectEqualStrings("\x1b[7mx\x1b[0m", w.buffered()); } test "view renders char when blinked" { var buf: [16]u8 = undefined; var w: Writer = .fixed(&buf); const c: Cursor = .{ .char = "x", .is_blinked = true }; try c.view(&w); try t.expectEqualStrings("x", w.buffered()); }