//! 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 matcha = @import("matcha"); const blush = @import("blush"); const util = @import("util.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; const Cursor = @This(); pub const Mode = enum { blink, static, hide }; pub const default_blink_speed_ns: u64 = 530_000_000; /// Marker address used to identify our tick messages in `Msg.custom`. var tick_marker: u8 = 0; fn tickTypeId() usize { return @intFromPtr(&tick_marker); } model: matcha.Model = .{ .vtable = &vtable }, /// Style applied to the cursor cell when it is visible. style: ?blush.Style = null, /// Style applied to the cursor cell when blinked off (i.e. plain text). text_style: ?blush.Style = null, /// How long each blink half-cycle lasts. blink_speed_ns: u64 = default_blink_speed_ns, /// 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, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(m: *matcha.Model) ?Cmd { const self: *Cursor = @fieldParentPtr("model", m); return self.focus(m.gpa); } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *Cursor = @fieldParentPtr("model", m); return self.update(m.gpa, msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *Cursor = @fieldParentPtr("model", m); try self.view(w); return .{}; } pub fn setMode(self: *Cursor, allocator: Allocator, mode: Mode) ?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) ?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: Msg) ?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, w: *Writer) Writer.Error!void { if (self.is_blinked) { try util.writeInline(w, self.text_style, self.char); return; } var s: blush.Style = self.style orelse .{}; s.reverse = true; try util.writeInline(w, s, self.char); } fn startBlink(self: *Cursor, allocator: Allocator) ?Cmd { self.running = true; return self.scheduleTick(allocator); } fn scheduleTick(self: *Cursor, allocator: Allocator) ?Cmd { return 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 std.testing.expect(c.is_blinked); try std.testing.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 std.testing.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 std.testing.expectEqualStrings("x", w.buffered()); }