ansi: Add support blinking text

Add support for the blinking text ansi character. Both "slow" (code 5)
and "fast" (code 6) blink at the same interval as the cursor, as it
seems implementations often do this.

Blinking of text stops when the screen loses focus, same as the cursor.
To keep blinking of text and cursor in sync, typing a character no
longer resets the cursor blinking timer. Instead of reseting the timer,
the cursor is immediately set to visible and will stay visible for at
least the length of one interval. This has the side-effect that at
startup, it takes two intervals until the first cursor blink happens.
This commit is contained in:
Charlie Jenkins
2025-01-11 20:51:38 -08:00
parent 72d085525b
commit 3929ee7a99
7 changed files with 116 additions and 54 deletions

View File

@ -92,6 +92,7 @@ pub const Shaper = struct {
grid: *SharedGrid,
screen: *const terminal.Screen,
row: terminal.Pin,
text_blink_visible: bool,
selection: ?terminal.Selection,
cursor_x: ?usize,
) font.shape.RunIterator {
@ -100,6 +101,7 @@ pub const Shaper = struct {
.grid = grid,
.screen = screen,
.row = row,
.text_blink_visible = text_blink_visible,
.selection = selection,
.cursor_x = cursor_x,
};
@ -229,6 +231,7 @@ test "run iterator" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -248,6 +251,7 @@ test "run iterator" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -268,6 +272,7 @@ test "run iterator" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -320,6 +325,7 @@ test "run iterator: empty cells with background set" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -357,6 +363,7 @@ test "shape" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -386,6 +393,7 @@ test "shape inconsolata ligs" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -411,6 +419,7 @@ test "shape inconsolata ligs" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -444,6 +453,7 @@ test "shape monaspace ligs" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -480,6 +490,7 @@ test "shape arabic forced LTR" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -517,6 +528,7 @@ test "shape emoji width" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -559,6 +571,7 @@ test "shape emoji width long" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -597,6 +610,7 @@ test "shape variation selector VS15" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -634,6 +648,7 @@ test "shape variation selector VS16" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -668,6 +683,7 @@ test "shape with empty cells in between" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -706,6 +722,7 @@ test "shape Chinese characters" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -746,6 +763,7 @@ test "shape box glyphs" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -783,6 +801,7 @@ test "shape selection boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
@ -806,6 +825,7 @@ test "shape selection boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
@ -829,6 +849,7 @@ test "shape selection boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
@ -852,6 +873,7 @@ test "shape selection boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
@ -875,6 +897,7 @@ test "shape selection boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
@ -911,6 +934,7 @@ test "shape cursor boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -930,6 +954,7 @@ test "shape cursor boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
0,
);
@ -949,6 +974,7 @@ test "shape cursor boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
1,
);
@ -968,6 +994,7 @@ test "shape cursor boundary" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
9,
);
@ -1000,6 +1027,7 @@ test "shape cursor boundary and colored emoji" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -1019,6 +1047,7 @@ test "shape cursor boundary and colored emoji" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
0,
);
@ -1036,6 +1065,7 @@ test "shape cursor boundary and colored emoji" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
1,
);
@ -1066,6 +1096,7 @@ test "shape cell attribute change" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -1090,6 +1121,7 @@ test "shape cell attribute change" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -1115,6 +1147,7 @@ test "shape cell attribute change" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -1140,6 +1173,7 @@ test "shape cell attribute change" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);
@ -1164,6 +1198,7 @@ test "shape cell attribute change" {
testdata.grid,
&screen,
screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
true,
null,
null,
);

View File

@ -73,6 +73,7 @@ pub const Shaper = struct {
grid: *SharedGrid,
screen: *const terminal.Screen,
row: terminal.Pin,
text_blink_visible: bool,
selection: ?terminal.Selection,
cursor_x: ?usize,
) font.shape.RunIterator {
@ -81,6 +82,7 @@ pub const Shaper = struct {
.grid = grid,
.screen = screen,
.row = row,
.text_blink_visible = text_blink_visible,
.selection = selection,
.cursor_x = cursor_x,
};

View File

@ -38,6 +38,7 @@ pub const RunIterator = struct {
grid: *font.SharedGrid,
screen: *const terminal.Screen,
row: terminal.Pin,
text_blink_visible: bool,
selection: ?terminal.Selection = null,
cursor_x: ?usize = null,
i: usize = 0,
@ -58,7 +59,8 @@ pub const RunIterator = struct {
// Invisible cells don't have any glyphs rendered,
// so we explicitly skip them in the shaping process.
while (self.i < max and
self.row.style(&cells[self.i]).flags.invisible)
(self.row.style(&cells[self.i]).flags.invisible or
(self.row.style(&cells[self.i]).flags.blink and !self.text_blink_visible)))
{
self.i += 1;
}

View File

@ -63,6 +63,7 @@ pub const Shaper = struct {
self: *Shaper,
group: *font.GroupCache,
row: terminal.Screen.Row,
text_blink_visible: bool,
selection: ?terminal.Selection,
cursor_x: ?usize,
) font.shape.RunIterator {
@ -70,6 +71,7 @@ pub const Shaper = struct {
.hooks = .{ .shaper = self },
.group = group,
.row = row,
.text_blink_visible = text_blink_visible,
.selection = selection,
.cursor_x = cursor_x,
};
@ -295,7 +297,7 @@ pub const Wasm = struct {
while (rowIter.next()) |row| {
defer y += 1;
var iter = self.runIterator(group, row, null, null);
var iter = self.runIterator(group, row, true, null, null);
while (try iter.next(alloc)) |run| {
const cells = try self.shape(run);
log.info("y={} run={d} shape={any} idx={}", .{

View File

@ -2377,6 +2377,7 @@ fn rebuildCells(
preedit: ?renderer.State.Preedit,
cursor_style_: ?renderer.CursorStyle,
color_palette: *const terminal.color.Palette,
text_blink_visible: bool,
) !void {
// const start = try std.time.Instant.now();
// const start_micro = std.time.microTimestamp();
@ -2495,6 +2496,7 @@ fn rebuildCells(
screen,
row,
row_selection,
text_blink_visible,
if (shape_cursor) screen.cursor.x else null,
);
var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc);
@ -2694,7 +2696,7 @@ fn rebuildCells(
// emulators, e.g. Alacritty, still render text decorations
// and only make the text itself invisible. The decision
// has been made here to match xterm's behavior for this.
if (style.flags.invisible) {
if (style.flags.invisible or style.flags.blink) {
continue;
}

View File

@ -689,6 +689,7 @@ pub fn updateFrame(
surface: *apprt.Surface,
state: *renderer.State,
cursor_blink_visible: bool,
text_blink_visible: bool,
) !void {
_ = surface;
@ -702,6 +703,7 @@ pub fn updateFrame(
preedit: ?renderer.State.Preedit,
cursor_style: ?renderer.CursorStyle,
color_palette: terminal.color.Palette,
text_blink_visible: bool,
};
// Update all our data as tightly as possible within the mutex.
@ -863,6 +865,7 @@ pub fn updateFrame(
.preedit = preedit,
.cursor_style = cursor_style,
.color_palette = state.terminal.color_palette.colors,
.text_blink_visible = text_blink_visible,
};
};
defer {
@ -887,6 +890,7 @@ pub fn updateFrame(
critical.preedit,
critical.cursor_style,
&critical.color_palette,
critical.text_blink_visible,
);
// Notify our shaper we're done for the frame. For some shapers like
@ -1217,6 +1221,7 @@ pub fn rebuildCells(
preedit: ?renderer.State.Preedit,
cursor_style_: ?renderer.CursorStyle,
color_palette: *const terminal.color.Palette,
text_blink_visible: bool,
) !void {
_ = screen_type;
@ -1349,6 +1354,7 @@ pub fn rebuildCells(
self.font_grid,
screen,
row,
text_blink_visible,
row_selection,
if (shape_cursor) screen.cursor.x else null,
);
@ -1576,7 +1582,7 @@ pub fn rebuildCells(
// emulators, e.g. Alacritty, still render text decorations
// and only make the text itself invisible. The decision
// has been made here to match xterm's behavior for this.
if (style.flags.invisible) {
if (style.flags.invisible or (style.flags.blink and !text_blink_visible)) {
continue;
}

View File

@ -18,7 +18,7 @@ const Allocator = std.mem.Allocator;
const log = std.log.scoped(.renderer_thread);
const DRAW_INTERVAL = 8; // 120 FPS
const CURSOR_BLINK_INTERVAL = 600;
const BLINK_INTERVAL = 600;
/// The type used for sending messages to the IO thread. For now this is
/// hardcoded with a capacity. We can make this a comptime parameter in
@ -58,10 +58,15 @@ draw_active: bool = false,
draw_now: xev.Async,
draw_now_c: xev.Completion = .{},
/// The timer used for cursor blinking
cursor_h: xev.Timer,
cursor_c: xev.Completion = .{},
cursor_c_cancel: xev.Completion = .{},
/// Timer that cursor and text blinking is built on
blink_h: xev.Timer,
blink_c: xev.Completion = .{},
blink_c_cancel: xev.Completion = .{},
blink: bool = true,
cursor_blink_active: bool = true,
cursor_blink_buffer: bool = true,
text_blink_active: bool = true,
/// The surface we're rendering to.
surface: *apprt.Surface,
@ -88,6 +93,11 @@ flags: packed struct {
/// thread automatically.
cursor_blink_visible: bool = false,
/// This is true when blinking text should be visible and false
/// when it should not be visible. This is toggled on a timer by the
/// thread automatically.
text_blink_visible: bool = true,
/// This is true when the inspector is active.
has_inspector: bool = false,
@ -145,9 +155,9 @@ pub fn init(
var draw_now = try xev.Async.init();
errdefer draw_now.deinit();
// Setup a timer for blinking the cursor
var cursor_timer = try xev.Timer.init();
errdefer cursor_timer.deinit();
// Setup a timer for blinking the cursor and text
var blink_timer = try xev.Timer.init();
errdefer blink_timer.deinit();
// The mailbox for messaging this thread
var mailbox = try Mailbox.create(alloc);
@ -162,7 +172,7 @@ pub fn init(
.render_h = render_h,
.draw_h = draw_h,
.draw_now = draw_now,
.cursor_h = cursor_timer,
.blink_h = blink_timer,
.surface = surface,
.renderer = renderer_impl,
.state = state,
@ -179,7 +189,7 @@ pub fn deinit(self: *Thread) void {
self.render_h.deinit();
self.draw_h.deinit();
self.draw_now.deinit();
self.cursor_h.deinit();
self.blink_h.deinit();
self.loop.deinit();
// Nothing can possibly access the mailbox anymore, destroy it.
@ -227,14 +237,14 @@ fn threadMain_(self: *Thread) !void {
// Send an initial wakeup message so that we render right away.
try self.wakeup.notify();
// Start blinking the cursor.
self.cursor_h.run(
// Start the blinking timer.
self.blink_h.run(
&self.loop,
&self.cursor_c,
CURSOR_BLINK_INTERVAL,
&self.blink_c,
BLINK_INTERVAL,
Thread,
self,
cursorTimerCallback,
blinkTimerCallback,
);
// Start the draw timer
@ -357,52 +367,39 @@ fn drainMailbox(self: *Thread) !void {
self.stopDrawTimer();
}
// If we're not focused, then we stop the cursor blink
if (self.cursor_c.state() == .active and
self.cursor_c_cancel.state() == .dead)
// If we're not focused, then we stop the blink
if (self.blink_c.state() == .active and
self.blink_c_cancel.state() == .dead)
{
self.cursor_h.cancel(
self.blink_h.cancel(
&self.loop,
&self.cursor_c,
&self.cursor_c_cancel,
&self.blink_c,
&self.blink_c_cancel,
void,
null,
cursorCancelCallback,
blinkCancelCallback,
);
}
} else {
// Start the draw timer
self.startDrawTimer();
// If we're focused, we immediately show the cursor again
// and then restart the timer.
if (self.cursor_c.state() != .active) {
self.flags.cursor_blink_visible = true;
self.cursor_h.run(
// If we're focused, we immediately start blinking again
self.blink_h.run(
&self.loop,
&self.cursor_c,
CURSOR_BLINK_INTERVAL,
&self.blink_c,
BLINK_INTERVAL,
Thread,
self,
cursorTimerCallback,
blinkTimerCallback,
);
}
}
},
.reset_cursor_blink => {
self.flags.cursor_blink_visible = true;
if (self.cursor_c.state() == .active) {
self.cursor_h.reset(
&self.loop,
&self.cursor_c,
&self.cursor_c_cancel,
CURSOR_BLINK_INTERVAL,
Thread,
self,
cursorTimerCallback,
);
}
self.cursor_blink_active = true;
self.cursor_blink_buffer = false;
},
.font_grid => |grid| {
@ -585,6 +582,7 @@ fn renderCallback(
t.surface,
t.state,
t.flags.cursor_blink_visible,
t.flags.text_blink_visible,
) catch |err|
log.warn("error rendering err={}", .{err});
@ -594,7 +592,7 @@ fn renderCallback(
return .disarm;
}
fn cursorTimerCallback(
fn blinkTimerCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
@ -616,14 +614,29 @@ fn cursorTimerCallback(
return .disarm;
};
t.flags.cursor_blink_visible = !t.flags.cursor_blink_visible;
// Keep blinking sychronized
t.blink = !t.blink;
// After cursor blink is reset, don't change state for one interval
if (t.cursor_blink_buffer) {
if (t.cursor_blink_active) {
t.flags.cursor_blink_visible = t.blink;
}
} else {
t.cursor_blink_buffer = true;
}
if (t.text_blink_active) {
t.flags.text_blink_visible = t.blink;
}
t.wakeup.notify() catch {};
t.cursor_h.run(&t.loop, &t.cursor_c, CURSOR_BLINK_INTERVAL, Thread, t, cursorTimerCallback);
t.blink_h.run(&t.loop, &t.blink_c, BLINK_INTERVAL, Thread, t, blinkTimerCallback);
return .disarm;
}
fn cursorCancelCallback(
fn blinkCancelCallback(
_: ?*void,
_: *xev.Loop,
_: *xev.Completion,