From 3929ee7a99bcb5443d24a131039b9e5a78920346 Mon Sep 17 00:00:00 2001 From: Charlie Jenkins Date: Sat, 11 Jan 2025 20:51:38 -0800 Subject: [PATCH] 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. --- src/font/shaper/harfbuzz.zig | 35 ++++++++++ src/font/shaper/noop.zig | 2 + src/font/shaper/run.zig | 4 +- src/font/shaper/web_canvas.zig | 4 +- src/renderer/Metal.zig | 4 +- src/renderer/OpenGL.zig | 8 ++- src/renderer/Thread.zig | 113 ++++++++++++++++++--------------- 7 files changed, 116 insertions(+), 54 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 97292b9b0..efd69f288 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -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, ); diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index f8988f4ee..7cbcb5a93 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -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, }; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 22d19979e..1321fd80b 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -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; } diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index f38ab885a..6245090eb 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -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={}", .{ diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 45d8f84c2..73b08d0ff 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -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; } diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e5dec6b2b..5545a12cc 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -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; } diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index cc63889fa..87bdeba93 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -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( - &self.loop, - &self.cursor_c, - CURSOR_BLINK_INTERVAL, - Thread, - self, - cursorTimerCallback, - ); - } + // If we're focused, we immediately start blinking again + self.blink_h.run( + &self.loop, + &self.blink_c, + BLINK_INTERVAL, + Thread, + self, + 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,