diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 8a5767518..de4293ba1 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -83,9 +83,13 @@ pub const WebCanvas = struct { pub fn deinit(self: *DeferredFace) void { switch (options.backend) { .fontconfig_freetype => if (self.fc) |*fc| fc.deinit(), - .coretext, .coretext_freetype, .coretext_harfbuzz => if (self.ct) |*ct| ct.deinit(), .freetype => {}, .web_canvas => if (self.wc) |*wc| wc.deinit(), + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => if (self.ct) |*ct| ct.deinit(), } self.* = undefined; } @@ -98,7 +102,11 @@ pub fn familyName(self: DeferredFace, buf: []u8) ![]const u8 { .fontconfig_freetype => if (self.fc) |fc| return (try fc.pattern.get(.family, 0)).string, - .coretext, .coretext_freetype, .coretext_harfbuzz => if (self.ct) |ct| { + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => if (self.ct) |ct| { const family_name = ct.font.copyAttribute(.family_name); return family_name.cstringPtr(.utf8) orelse unsupported: { break :unsupported family_name.cstring(buf, .utf8) orelse @@ -121,7 +129,11 @@ pub fn name(self: DeferredFace, buf: []u8) ![]const u8 { .fontconfig_freetype => if (self.fc) |fc| return (try fc.pattern.get(.fullname, 0)).string, - .coretext, .coretext_freetype, .coretext_harfbuzz => if (self.ct) |ct| { + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => if (self.ct) |ct| { const display_name = ct.font.copyDisplayName(); return display_name.cstringPtr(.utf8) orelse unsupported: { // "NULL if the internal storage of theString does not allow @@ -147,7 +159,7 @@ pub fn load( ) !Face { return switch (options.backend) { .fontconfig_freetype => try self.loadFontconfig(lib, opts), - .coretext, .coretext_harfbuzz => try self.loadCoreText(lib, opts), + .coretext, .coretext_harfbuzz, .coretext_noshape => try self.loadCoreText(lib, opts), .coretext_freetype => try self.loadCoreTextFreetype(lib, opts), .web_canvas => try self.loadWebCanvas(opts), @@ -262,7 +274,11 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool { } }, - .coretext, .coretext_freetype, .coretext_harfbuzz => { + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => { // If we are using coretext, we check the loaded CT font. if (self.ct) |ct| { if (p) |desired_p| { diff --git a/src/font/discovery.zig b/src/font/discovery.zig index b21d374f5..0259200c9 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -14,8 +14,12 @@ const log = std.log.scoped(.discovery); pub const Discover = switch (options.backend) { .freetype => void, // no discovery .fontconfig_freetype => Fontconfig, - .coretext, .coretext_freetype, .coretext_harfbuzz => CoreText, .web_canvas => void, // no discovery + .coretext, + .coretext_freetype, + .coretext_harfbuzz, + .coretext_noshape, + => CoreText, }; /// Descriptor is used to search for fonts. The only required field diff --git a/src/font/face.zig b/src/font/face.zig index 6d5f80169..815629b44 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -13,7 +13,11 @@ pub const Face = switch (options.backend) { .coretext_freetype, => freetype.Face, - .coretext, .coretext_harfbuzz => coretext.Face, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => coretext.Face, + .web_canvas => web_canvas.Face, }; diff --git a/src/font/library.zig b/src/font/library.zig index 8f72c4fb8..57e11e64a 100644 --- a/src/font/library.zig +++ b/src/font/library.zig @@ -16,6 +16,7 @@ pub const Library = switch (options.backend) { // Some backends such as CT and Canvas don't have a "library" .coretext, .coretext_harfbuzz, + .coretext_noshape, .web_canvas, => NoopLibrary, }; diff --git a/src/font/main.zig b/src/font/main.zig index 72c3aa9cf..a287d9a06 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -61,6 +61,9 @@ pub const Backend = enum { /// CoreText for font discovery and rendering, HarfBuzz for shaping coretext_harfbuzz, + /// CoreText for font discovery and rendering, no shaping. + coretext_noshape, + /// Use the browser font system and the Canvas API (wasm). This limits /// the available fonts to browser fonts (anything Canvas natively /// supports). @@ -97,6 +100,7 @@ pub const Backend = enum { .coretext, .coretext_harfbuzz, + .coretext_noshape, .web_canvas, => false, }; @@ -107,6 +111,7 @@ pub const Backend = enum { .coretext, .coretext_freetype, .coretext_harfbuzz, + .coretext_noshape, => true, .freetype, @@ -124,6 +129,7 @@ pub const Backend = enum { .coretext, .coretext_freetype, .coretext_harfbuzz, + .coretext_noshape, .web_canvas, => false, }; @@ -138,6 +144,7 @@ pub const Backend = enum { => true, .coretext, + .coretext_noshape, .web_canvas, => false, }; diff --git a/src/font/shape.zig b/src/font/shape.zig index 1e5dd1a9f..e633c15c9 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,5 +1,6 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; +pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); pub const web_canvas = @import("shaper/web_canvas.zig"); @@ -19,6 +20,8 @@ pub const Shaper = switch (options.backend) { // font faces. .coretext => coretext.Shaper, + .coretext_noshape => noop.Shaper, + .web_canvas => web_canvas.Shaper, }; @@ -61,4 +64,7 @@ pub const Options = struct { test { _ = Cache; _ = Shaper; + + // Always test noop + _ = noop; } diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig new file mode 100644 index 000000000..310b5cf40 --- /dev/null +++ b/src/font/shaper/noop.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const trace = @import("tracy").trace; +const font = @import("../main.zig"); +const Face = font.Face; +const Collection = font.Collection; +const DeferredFace = font.DeferredFace; +const Group = font.Group; +const GroupCache = font.GroupCache; +const Library = font.Library; +const SharedGrid = font.SharedGrid; +const Style = font.Style; +const Presentation = font.Presentation; +const terminal = @import("../../terminal/main.zig"); + +const log = std.log.scoped(.font_shaper); + +/// Shaper that doesn't do any shaping. Each individual codepoint is mapped +/// directly to the detected text run font's glyph index. +pub const Shaper = struct { + /// The allocated used for the feature list and cell buf. + alloc: Allocator, + + /// The string used for shaping the current run. + run_state: RunState, + + /// The shared memory used for shaping results. + cell_buf: CellBuf, + + const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); + const CodepointList = std.ArrayListUnmanaged(Codepoint); + const Codepoint = struct { + codepoint: u32, + cluster: u32, + }; + + const RunState = struct { + codepoints: CodepointList = .{}, + + fn deinit(self: *RunState, alloc: Allocator) void { + self.codepoints.deinit(alloc); + } + + fn reset(self: *RunState) !void { + self.codepoints.clearRetainingCapacity(); + } + }; + + /// The cell_buf argument is the buffer to use for storing shaped results. + /// This should be at least the number of columns in the terminal. + pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { + _ = opts; + + return Shaper{ + .alloc = alloc, + .cell_buf = .{}, + .run_state = .{}, + }; + } + + pub fn deinit(self: *Shaper) void { + self.cell_buf.deinit(self.alloc); + self.run_state.deinit(self.alloc); + } + + pub fn runIterator( + self: *Shaper, + grid: *SharedGrid, + screen: *const terminal.Screen, + row: terminal.Pin, + selection: ?terminal.Selection, + cursor_x: ?usize, + ) font.shape.RunIterator { + return .{ + .hooks = .{ .shaper = self }, + .grid = grid, + .screen = screen, + .row = row, + .selection = selection, + .cursor_x = cursor_x, + }; + } + + pub fn shape(self: *Shaper, run: font.shape.TextRun) ![]const font.shape.Cell { + const state = &self.run_state; + + // Special fonts aren't shaped and their codepoint == glyph so we + // can just return the codepoints as-is. + if (run.font_index.special() != null) { + self.cell_buf.clearRetainingCapacity(); + try self.cell_buf.ensureTotalCapacity(self.alloc, state.codepoints.items.len); + for (state.codepoints.items) |entry| { + self.cell_buf.appendAssumeCapacity(.{ + .x = @intCast(entry.cluster), + .glyph_index = @intCast(entry.codepoint), + }); + } + + return self.cell_buf.items; + } + + // Go through the run and map each codepoint to a glyph index. + self.cell_buf.clearRetainingCapacity(); + + // Note: this is digging into some internal details, we should maybe + // expose a public API for this. + const face = try run.grid.resolver.collection.getFace(run.font_index); + for (state.codepoints.items) |entry| { + const glyph_index = face.glyphIndex(entry.codepoint); + try self.cell_buf.append(self.alloc, .{ + .x = @intCast(entry.cluster), + .glyph_index = glyph_index, + }); + } + + return self.cell_buf.items; + } + + /// The hooks for RunIterator. + pub const RunIteratorHook = struct { + shaper: *Shaper, + + pub fn prepare(self: *RunIteratorHook) !void { + try self.shaper.run_state.reset(); + } + + pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void { + try self.shaper.run_state.codepoints.append(self.shaper.alloc, .{ + .codepoint = cp, + .cluster = cluster, + }); + } + + pub fn finalize(self: RunIteratorHook) !void { + _ = self; + } + }; +}; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index e4528eeca..0d05b7983 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -92,6 +92,9 @@ current_background_color: terminal.color.RGB, /// cells goes into a separate shader. cells: mtl_cell.Contents, +/// If this is true, we do a full cell rebuild on the next frame. +cells_rebuild: bool = true, + /// The current GPU uniform values. uniforms: mtl_shaders.Uniforms, @@ -129,8 +132,7 @@ pub const GPUState = struct { // is comptime because there isn't a good reason to change this at // runtime and there is a lot of complexity to support it. For comptime, // this is useful for debugging. - // TODO(mitchellh): enable triple-buffering when we improve our frame times - const BufferCount = 1; + const BufferCount = 3; /// The frame data, the current frame index, and the semaphore protecting /// the frame data. This is used to implement double/triple/etc. buffering. @@ -786,6 +788,36 @@ pub fn updateFrame( try self.prepKittyGraphics(state.terminal); } + // If we have any terminal dirty flags set then we need to rebuild + // the entire screen. This can be optimized in the future. + { + const Int = @typeInfo(terminal.Terminal.Dirty).Struct.backing_integer.?; + const v: Int = @bitCast(state.terminal.flags.dirty); + if (v > 0) self.cells_rebuild = true; + } + { + const Int = @typeInfo(terminal.Screen.Dirty).Struct.backing_integer.?; + const v: Int = @bitCast(state.terminal.screen.dirty); + if (v > 0) self.cells_rebuild = true; + } + + // Reset the dirty flags in the terminal and screen. We assume + // that our rebuild will be successful since so we optimize for + // success and reset while we hold the lock. This is much easier + // than coordinating row by row or as changes are persisted. + state.terminal.flags.dirty = .{}; + { + var it = state.terminal.screen.pages.pageIterator( + .right_down, + .{ .screen = .{} }, + null, + ); + while (it.next()) |chunk| { + var dirty_set = chunk.page.data.dirtyBitSet(); + dirty_set.unsetAll(); + } + } + break :critical .{ .bg = self.background_color, .screen = screen_copy, @@ -1711,6 +1743,10 @@ fn rebuildCells( while (row_it.next()) |row| { y = y - 1; + // Only rebuild if we are doing a full rebuild or this row is dirty. + // if (row.isDirty()) std.log.warn("dirty y={}", .{y}); + if (!self.cells_rebuild and !row.isDirty()) continue; + // If we're rebuilding a row, then we always clear the cells self.cells.clear(y); @@ -1856,6 +1892,9 @@ fn rebuildCells( } } + // We always mark our rebuild flag as false since we're done. + self.cells_rebuild = false; + // Log some things // log.debug("rebuildCells complete cached_runs={}", .{ // self.font_shaper_cache.count(), diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig index b11861817..20590afc2 100644 --- a/src/renderer/metal/buffer.zig +++ b/src/renderer/metal/buffer.zig @@ -64,7 +64,12 @@ pub fn Buffer(comptime T: type) type { return ptr[0..len]; } - /// Sync new contents to the buffer. + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amont of data is larger + /// than the buffer length, the buffer will be reallocated. + /// + /// If the amount of data is smaller than the buffer length, the + /// remaining data in the buffer is left untouched. pub fn sync(self: *Self, device: objc.Object, data: []const T) !void { // If we need more bytes than our buffer has, we need to reallocate. const req_bytes = data.len * @sizeOf(T); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 95df3e911..e1da55ad4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1917,6 +1917,7 @@ fn createPage( self: *PageList, cap: Capacity, ) !*List.Node { + // log.debug("create page cap={}", .{cap}); return try createPageExt(&self.pool, cap, &self.page_size); } @@ -2101,6 +2102,10 @@ pub fn eraseRowBounded( page.data.clearCells(&rows[pn.y], 0, page.data.size.cols); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); + // Set all the rows as dirty + var dirty = page.data.dirtyBitSet(); + dirty.setRangeValue(.{ .start = pn.y, .end = pn.y + limit }, true); + // Update pins in the shifted region. var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { @@ -2122,6 +2127,12 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[pn.y..page.data.size.rows]); + // All the rows in the page are dirty below the erased row. + { + var dirty = page.data.dirtyBitSet(); + dirty.setRangeValue(.{ .start = pn.y, .end = page.data.size.rows }, true); + } + // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent // pages. @@ -2164,6 +2175,10 @@ pub fn eraseRowBounded( page.data.clearCells(&rows[0], 0, page.data.size.cols); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); + // Set all the rows as dirty + var dirty = page.data.dirtyBitSet(); + dirty.setRangeValue(.{ .start = 0, .end = shifted_limit }, true); + // Update pins in the shifted region. var pin_it = self.tracked_pins.keyIterator(); while (pin_it.next()) |p_ptr| { @@ -2182,6 +2197,10 @@ pub fn eraseRowBounded( fastmem.rotateOnce(Row, rows[0..page.data.size.rows]); + // Set all the rows as dirty + var dirty = page.data.dirtyBitSet(); + dirty.setRangeValue(.{ .start = 0, .end = page.data.size.rows }, true); + // Account for the rows shifted in this page. shifted += page.data.size.rows; @@ -2927,6 +2946,27 @@ fn growRows(self: *PageList, n: usize) !void { } } +/// Clear all dirty bits on all pages. This is not efficient since it +/// traverses the entire list of pages. This is used for testing/debugging. +pub fn clearDirty(self: *PageList) void { + var page = self.pages.first; + while (page) |p| { + var set = p.data.dirtyBitSet(); + set.unsetAll(); + page = p.next; + } +} + +/// Returns true if the point is dirty, used for testing. +pub fn isDirty(self: *const PageList, pt: point.Point) bool { + return self.getCell(pt).?.isDirty(); +} + +/// Mark a point as dirty, used for testing. +fn markDirty(self: *PageList, pt: point.Point) void { + self.pin(pt).?.markDirty(); +} + /// Represents an exact x/y coordinate within the screen. This is called /// a "pin" because it is a fixed point within the pagelist direct to /// a specific page pointer and memory offset. The benefit is that this @@ -2985,6 +3025,17 @@ pub const Pin = struct { ).?.*; } + /// Check if this pin is dirty. + pub fn isDirty(self: Pin) bool { + return self.page.data.isRowDirty(self.y); + } + + /// Mark this pin location as dirty. + pub fn markDirty(self: Pin) void { + var set = self.page.data.dirtyBitSet(); + set.set(self.y); + } + /// Iterators. These are the same as PageList iterator funcs but operate /// on pins rather than points. This is MUCH more efficient than calling /// pointFromPin and building up the iterator from points. @@ -3218,6 +3269,14 @@ const Cell = struct { row_idx: size.CellCountInt, col_idx: size.CellCountInt, + /// Returns true if this cell is marked as dirty. + /// + /// This is not very performant this is primarily used for assertions + /// and testing. + pub fn isDirty(self: Cell) bool { + return self.page.data.isRowDirty(self.row_idx); + } + /// Get the cell style. /// /// Not meant for non-test usage since this is inefficient. @@ -4486,6 +4545,13 @@ test "PageList eraseRowBounded less than full row" { try s.eraseRowBounded(.{ .active = .{ .y = 5 } }, 3); try testing.expectEqual(s.rows, s.totalRows()); + // The erased rows should be dirty + try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 5 } })); + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 6 } })); + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 7 } })); + try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 8 } })); + try testing.expectEqual(s.pages.first.?, p_top.page); try testing.expectEqual(@as(usize, 4), p_top.y); try testing.expectEqual(@as(usize, 0), p_top.x); @@ -4514,6 +4580,12 @@ test "PageList eraseRowBounded with pin at top" { try s.eraseRowBounded(.{ .active = .{ .y = 0 } }, 3); try testing.expectEqual(s.rows, s.totalRows()); + // The erased rows should be dirty + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expectEqual(s.pages.first.?, p_top.page); try testing.expectEqual(@as(usize, 0), p_top.y); try testing.expectEqual(@as(usize, 0), p_top.x); @@ -4536,6 +4608,13 @@ test "PageList eraseRowBounded full rows single page" { try s.eraseRowBounded(.{ .active = .{ .y = 5 } }, 10); try testing.expectEqual(s.rows, s.totalRows()); + // The erased rows should be dirty + try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + for (5..10) |y| try testing.expect(s.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p_in.page); try testing.expectEqual(@as(usize, 6), p_in.y); @@ -4593,6 +4672,13 @@ test "PageList eraseRowBounded full rows two pages" { // Erase only a few rows in our active try s.eraseRowBounded(.{ .active = .{ .y = 4 } }, 4); + // The erased rows should be dirty + try testing.expect(!s.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + for (4..8) |y| try testing.expect(s.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + // In page in first page is shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first.page); try testing.expectEqual(@as(usize, p_first.page.data.size.rows - 2), p_first.y); @@ -4753,6 +4839,34 @@ test "PageList clone remap tracked pin not in cloned area" { try testing.expect(pin_remap.get(p) == null); } +test "PageList clone full dirty" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Mark a row as dirty + s.markDirty(.{ .active = .{ .x = 0, .y = 0 } }); + s.markDirty(.{ .active = .{ .x = 0, .y = 12 } }); + s.markDirty(.{ .active = .{ .x = 0, .y = 23 } }); + + var s2 = try s.clone(.{ + .top = .{ .screen = .{} }, + .memory = .{ .alloc = alloc }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); + + // Should still be dirty + try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!s2.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 12 } })); + try testing.expect(!s2.isDirty(.{ .active = .{ .x = 0, .y = 14 } })); + try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 23 } })); +} + test "PageList resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 58e4e681b..304bc7cce 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -62,6 +62,16 @@ kitty_keyboard: kitty.KeyFlagStack = .{}, /// Kitty graphics protocol state. kitty_images: kitty.graphics.ImageStorage = .{}, +/// Dirty flags for the renderer. +dirty: Dirty = .{}, + +/// See Terminal.Dirty. This behaves the same way. +pub const Dirty = packed struct { + /// Set when the selection is set or unset, regardless of if the + /// selection is changed or not. + selection: bool = false, +}; + /// The cursor position. pub const Cursor = struct { // The x/y position within the viewport. @@ -362,6 +372,7 @@ pub fn clonePool( .no_scrollback = self.no_scrollback, .cursor = cursor, .selection = sel, + .dirty = self.dirty, }; result.assertIntegrity(); return result; @@ -719,6 +730,12 @@ fn cursorChangePin(self: *Screen, new: Pin) void { }; } +/// Mark the cursor position as dirty. +/// TODO: test +pub fn cursorMarkDirty(self: *Screen) void { + self.cursor.page_pin.markDirty(); +} + /// Options for scrolling the viewport of the terminal grid. The reason /// we have this in addition to PageList.Scroll is because we have additional /// scroll behaviors that are not part of the PageList.Scroll enum. @@ -803,6 +820,10 @@ pub fn clearRows( var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { + // Mark everything in this chunk as dirty + var dirty = chunk.page.data.dirtyBitSet(); + dirty.setRangeValue(.{ .start = chunk.start, .end = chunk.end }, true); + for (chunk.rows()) |*row| { const cells_offset = row.cells; const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); @@ -1318,12 +1339,14 @@ pub fn select(self: *Screen, sel_: ?Selection) !void { // Untrack prior selection if (self.selection) |*old| old.deinit(self); self.selection = tracked_sel; + self.dirty.selection = true; } /// Same as select(null) but can't fail. pub fn clearSelection(self: *Screen) void { if (self.selection) |*sel| sel.deinit(self); self.selection = null; + self.dirty.selection = true; } pub const SelectionString = struct { @@ -2502,6 +2525,7 @@ test "Screen clearRows active one line" { try s.testWriteString("hello, world"); s.clearRows(.{ .active = .{} }, null, false); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); @@ -2516,6 +2540,8 @@ test "Screen clearRows active multi line" { try s.testWriteString("hello\nworld"); s.clearRows(.{ .active = .{} }, null, false); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 9ddec218e..cb13e5edf 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -15,6 +15,7 @@ const modes = @import("modes.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const kitty = @import("kitty.zig"); +const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); @@ -113,8 +114,29 @@ flags: packed struct { /// then we want to capture the shift key for the mouse protocol /// if the configuration allows it. mouse_shift_capture: enum(u2) { null, false, true } = .null, + + /// Dirty flags for the renderer. + dirty: Dirty = .{}, } = .{}, +/// This is a set of dirty flags the renderer can use to determine +/// what parts of the screen need to be redrawn. It is up to the renderer +/// to clear these flags. +/// +/// This only contains dirty flags for terminal state, not for the screen +/// state. The screen state has its own dirty flags. +pub const Dirty = packed struct { + /// Set when the color palette is modified in any way. + palette: bool = false, + + /// Set when the reverse colors mode is modified. + reverse_colors: bool = false, + + /// Screen clear of some kind. This can be due to a screen change, + /// erase display, etc. + clear: bool = false, +}; + /// The event types that can be reported for mouse-related activities. /// These are all mutually exclusive (hence in a single enum). pub const MouseEvents = enum(u3) { @@ -354,6 +376,7 @@ pub fn print(self: *Terminal, c: u21) !void { } log.debug("c={x} grapheme attach to left={}", .{ c, prev.left }); + self.screen.cursorMarkDirty(); try self.screen.appendGrapheme(prev.cell, c); return; } @@ -429,7 +452,10 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell - 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), + 1 => { + self.screen.cursorMarkDirty(); + @call(.always_inline, printCell, .{ self, c, .narrow }); + }, // Wide character requires a spacer. We print this by // using two cells: the first is flagged "wide" and has the @@ -452,12 +478,14 @@ pub fn print(self: *Terminal, c: u21) !void { try self.printWrap(); } + self.screen.cursorMarkDirty(); self.printCell(c, .wide); self.screen.cursorRight(1); self.printCell(' ', .spacer_tail); } else { // This is pretty broken, terminals should never be only 1-wide. // We sould prevent this downstream. + self.screen.cursorMarkDirty(); self.printCell(' ', .narrow); }, @@ -1438,6 +1466,10 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.rowWillBeShifted(&p.page.data, src); self.rowWillBeShifted(&dst_p.page.data, dst); + // Mark both our src/dst as dirty + p.markDirty(); + dst_p.markDirty(); + // If our scrolling region is full width, then we unset wrap. if (!left_right) { dst.wrap = false; @@ -1502,6 +1534,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { while (it.next()) |p| { const row: *Row = p.rowAndCell().row; + // This row is now dirty + p.markDirty(); + // Clear the src row. const page = &p.page.data; const cells = page.getCells(row); @@ -1580,6 +1615,10 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { const src: *Row = src_rac.row; const dst: *Row = dst_rac.row; + // Mark both our src/dst as dirty + p.markDirty(); + src_p.markDirty(); + self.rowWillBeShifted(&src_p.page.data, src); self.rowWillBeShifted(&p.page.data, dst); @@ -1645,6 +1684,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void { while (it.next()) |p| { const row: *Row = p.rowAndCell().row; + // This row is now dirty + p.markDirty(); + // Clear the src row. const page = &p.page.data; const cells = page.getCells(row); @@ -1742,6 +1784,9 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // Insert blanks. The blanks preserve the background color. self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); + + // Our row is always dirty + self.screen.cursorMarkDirty(); } /// Removes amount characters from the current cursor position to the right. @@ -1832,6 +1877,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // Insert blanks. The blanks preserve the background color. self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); + + // Our row is always dirty + self.screen.cursorMarkDirty(); } pub fn eraseChars(self: *Terminal, count_req: usize) void { @@ -1859,6 +1907,9 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { break :end end; }; + // Mark our cursor row as dirty + self.screen.cursorMarkDirty(); + // Clear the cells const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); @@ -1929,6 +1980,9 @@ pub fn eraseLine( // a valid mode at this point. self.screen.cursor.pending_wrap = false; + // We always mark our row as dirty + self.screen.cursorMarkDirty(); + // Start of our cells const cells: [*]Cell = cells: { const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); @@ -2045,6 +2099,9 @@ pub fn eraseDisplay( self, .{ .all = true }, ); + + // Cleared screen dirty bit + self.flags.dirty.clear = true; }, .below => { @@ -2113,7 +2170,7 @@ pub fn decaln(self: *Terminal) !void { // Move our cursor to the top-left self.setCursorPos(1, 1); - // Erase the display which will deallocate graphames, styles, etc. + // Erase the display which will deallocate graphemes, styles, etc. self.eraseDisplay(.complete, false); // Fill with Es by moving the cursor but reset it after. @@ -2135,6 +2192,7 @@ pub fn decaln(self: *Terminal) !void { row.styled = true; } + self.screen.cursorMarkDirty(); if (self.screen.cursor.y == self.rows - 1) break; self.screen.cursorDown(1); } @@ -2325,6 +2383,9 @@ pub fn resize( } } + // Whenever we resize we just mark it as a screen clear + self.flags.dirty.clear = true; + // Set our size self.cols = cols; self.rows = rows; @@ -2402,6 +2463,9 @@ pub fn alternateScreen( // Mark kitty images as dirty so they redraw self.screen.kitty_images.dirty = true; + // Mark our terminal as dirty + self.flags.dirty.clear = true; + // Bring our pen with us self.screen.cursorCopy(old.cursor) catch |err| { log.warn("cursor copy failed entering alt screen err={}", .{err}); @@ -2437,6 +2501,9 @@ pub fn primaryScreen( // Mark kitty images as dirty so they redraw self.screen.kitty_images.dirty = true; + // Mark our terminal as dirty + self.flags.dirty.clear = true; + // Restore the cursor from the primary screen. This should not // fail because we should not have to allocate memory since swapping // screens does not create new cursors. @@ -2494,6 +2561,16 @@ pub fn fullReset(self: *Terminal) void { self.status_display = .main; } +/// Returns true if the point is dirty, used for testing. +fn isDirty(t: *const Terminal, pt: point.Point) bool { + return t.screen.pages.getCell(pt).?.isDirty(); +} + +/// Clear all dirty bits. Testing only. +fn clearDirty(t: *Terminal) void { + t.screen.pages.clearDirty(); +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 40, .rows = 40 }); @@ -2508,6 +2585,10 @@ test "Terminal: input with no control characters" { defer alloc.free(str); try testing.expectEqualStrings("hello", str); } + + // The first row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 5, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 5, .y = 1 } })); } test "Terminal: input with basic wraparound" { @@ -2527,6 +2608,20 @@ test "Terminal: input with basic wraparound" { } } +test "Terminal: input with basic wraparound dirty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + t.clearDirty(); + try t.print('w'); + + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); +} + test "Terminal: input that forces scroll" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 1, .rows = 5 }); @@ -2582,6 +2677,9 @@ test "Terminal: zero-width character at start" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + + // Should not be dirty since we changed nothing. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } // https://github.com/mitchellh/ghostty/issues/1400 @@ -2613,6 +2711,8 @@ test "Terminal: print wide char" { const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print wide char at edge creates spacer head" { @@ -2640,6 +2740,12 @@ test "Terminal: print wide char at edge creates spacer head" { const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } + + // Our first row just had a spacer head added which does not affect + // rendering so only the place where the wide char was printed + // should be marked. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); } test "Terminal: print wide char with 1-column width" { @@ -2648,6 +2754,9 @@ test "Terminal: print wide char with 1-column width" { defer t.deinit(alloc); try t.print('😀'); // 0x1F600 + + // This prints a space so we should be dirty. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print wide char in single-width terminal" { @@ -2665,6 +2774,8 @@ test "Terminal: print wide char in single-width terminal" { try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print over wide char at 0,0" { @@ -2673,7 +2784,7 @@ test "Terminal: print over wide char at 0,0" { try t.print(0x1F600); // Smiley face t.setCursorPos(0, 0); - try t.print('A'); // Smiley face + try t.print('A'); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); @@ -2690,6 +2801,9 @@ test "Terminal: print over wide char at 0,0" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); } test "Terminal: print over wide spacer tail" { @@ -2718,6 +2832,8 @@ test "Terminal: print over wide spacer tail" { defer testing.allocator.free(str); try testing.expectEqualStrings(" X", str); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print over wide char with bold" { @@ -2742,6 +2858,8 @@ test "Terminal: print over wide char with bold" { const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print over wide char with bg color" { @@ -2770,6 +2888,8 @@ test "Terminal: print over wide char with bg color" { const page = t.screen.cursor.page_pin.page.data; try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print multicodepoint grapheme, disabled mode 2027" { @@ -2840,6 +2960,8 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: VS16 doesn't make character with 2027 disabled" { @@ -2869,6 +2991,21 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } } +test "Terminal: ignored VS16 doesn't mark dirty" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print(0x2764); // Heart + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + test "Terminal: print invalid VS16 non-grapheme" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); @@ -2897,6 +3034,21 @@ test "Terminal: print invalid VS16 non-grapheme" { } } +test "Terminal: invalid VS16 doesn't mark dirty" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print('x'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + test "Terminal: print multicodepoint grapheme, mode 2027" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); @@ -2916,6 +3068,9 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Assert various properties about our screen to verify // we have all expected cells. { @@ -2936,6 +3091,35 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x200D); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x1F469); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x200D); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x1F467); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); +} + test "Terminal: VS15 to make narrow character" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); @@ -2944,7 +3128,11 @@ test "Terminal: VS15 to make narrow character" { t.modes.set(.grapheme_cluster, true); try t.print(0x26C8); // Thunder cloud and rain + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); { const str = try t.plainString(testing.allocator); @@ -2971,7 +3159,11 @@ test "Terminal: VS16 to make wide character with mode 2027" { t.modes.set(.grapheme_cluster, true); try t.print(0x2764); // Heart + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); try t.print(0xFE0F); // VS16 to make wide + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); { const str = try t.plainString(testing.allocator); @@ -3002,6 +3194,8 @@ test "Terminal: VS16 repeated with mode 2027" { try t.print(0x2764); // Heart try t.print(0xFE0F); // VS16 to make wide + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3104,8 +3298,12 @@ test "Terminal: overwrite grapheme should clear grapheme data" { try t.print(0x26C8); // Thunder cloud and rain try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + t.setCursorPos(1, 1); try t.print('A'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -3147,7 +3345,9 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { // Move back and overwrite wide t.setCursorPos(1, 1); + t.clearDirty(); try t.print('X'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); @@ -3233,6 +3433,11 @@ test "Terminal: print writes to bottom if scrolled" { defer testing.allocator.free(str); try testing.expectEqualStrings("\nA", str); } + + try testing.expect(t.isDirty(.{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } })); } test "Terminal: print charset" { @@ -3244,6 +3449,9 @@ test "Terminal: print charset" { t.configureCharset(.G2, .dec_special); t.configureCharset(.G3, .dec_special); + // No dirty to configure charset + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Basic grid writing try t.print('`'); t.configureCharset(.G0, .utf8); @@ -3257,6 +3465,8 @@ test "Terminal: print charset" { defer testing.allocator.free(str); try testing.expectEqualStrings("```◆", str); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print charset outside of ASCII" { @@ -3268,6 +3478,9 @@ test "Terminal: print charset outside of ASCII" { t.configureCharset(.G2, .dec_special); t.configureCharset(.G3, .dec_special); + // No dirty to configure charset + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Basic grid writing t.configureCharset(.G0, .dec_special); try t.print('`'); @@ -3277,6 +3490,8 @@ test "Terminal: print charset outside of ASCII" { defer testing.allocator.free(str); try testing.expectEqualStrings("◆ ", str); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print invoke charset" { @@ -3285,10 +3500,14 @@ test "Terminal: print invoke charset" { t.configureCharset(.G1, .dec_special); - // Basic grid writing try t.print('`'); + + // Invokecharset but should not mark dirty on its own + t.clearDirty(); t.invokeCharset(.GL, .G1, false); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); try t.print('`'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); try t.print('`'); t.invokeCharset(.GL, .G0, false); try t.print('`'); @@ -3336,7 +3555,10 @@ test "Terminal: soft wrap with semantic prompt" { var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); + // Mark our prompt. Should not make anything dirty on its own. t.markSemanticPrompt(.prompt); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + for ("hello") |c| try t.print(c); { @@ -3358,6 +3580,7 @@ test "Terminal: disabled wraparound with wide char and one space" { // This puts our cursor at the end and there is NO SPACE for a // wide character. try t.printString("AAAA"); + t.clearDirty(); try t.print(0x1F6A8); // Police car light try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); @@ -3375,6 +3598,9 @@ test "Terminal: disabled wraparound with wide char and one space" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: disabled wraparound with wide char and no space" { @@ -3386,6 +3612,7 @@ test "Terminal: disabled wraparound with wide char and no space" { // This puts our cursor at the end and there is NO SPACE for a // wide character. try t.printString("AAAAA"); + t.clearDirty(); try t.print(0x1F6A8); // Police car light try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); @@ -3402,6 +3629,9 @@ test "Terminal: disabled wraparound with wide char and no space" { try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: disabled wraparound with wide grapheme and half space" { @@ -3415,6 +3645,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { // wide character. try t.printString("AAAA"); try t.print(0x2764); // Heart + t.clearDirty(); try t.print(0xFE0F); // VS16 to make wide try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); @@ -3431,6 +3662,9 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } test "Terminal: print right margin wrap" { @@ -3456,6 +3690,34 @@ test "Terminal: print right margin wrap" { } } +test "Terminal: print right margin wrap dirty tracking" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + + // Writing our X on the first line should mark only that line dirty. + t.clearDirty(); + try t.print('X'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 2, .y = 1 } })); + + // Writing our Y should wrap and only mark the second line dirty. + t.clearDirty(); + try t.print('Y'); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1234X6789\n Y", str); + } +} + test "Terminal: print right margin outside" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); @@ -3464,6 +3726,7 @@ test "Terminal: print right margin outside" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); t.setCursorPos(1, 6); + t.clearDirty(); try t.printString("XY"); { @@ -3471,6 +3734,8 @@ test "Terminal: print right margin outside" { defer testing.allocator.free(str); try testing.expectEqualStrings("12345XY89", str); } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 5, .y = 0 } })); } test "Terminal: print right margin outside wrap" { @@ -3501,6 +3766,10 @@ test "Terminal: print wide char at right margin does not create spacer head" { try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + // Only wrapped row should be dirty + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 1 } })); + { const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; @@ -3527,10 +3796,20 @@ test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - // Basic grid writing + // Print and CR. for ("hello") |c| try t.print(c); + t.clearDirty(); t.carriageReturn(); + + // CR should not mark row dirty because it doesn't change rendering. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try t.linefeed(); + + // LF should not mark row dirty + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + for ("world") |c| try t.print(c); try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); @@ -3548,7 +3827,10 @@ test "Terminal: linefeed unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap == true); + t.clearDirty(); try t.linefeed(); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); try testing.expect(t.screen.cursor.pending_wrap == false); } @@ -3923,8 +4205,16 @@ test "Terminal: setTopAndBottomMargin simple" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(0, 0); + + t.clearDirty(); t.scrollDown(1); + // Mark the rows we moved as dirty. + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3945,8 +4235,15 @@ test "Terminal: setTopAndBottomMargin top only" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 0); + + t.clearDirty(); t.scrollDown(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3967,8 +4264,14 @@ test "Terminal: setTopAndBottomMargin top and bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(1, 2); + + t.clearDirty(); t.scrollDown(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3989,8 +4292,15 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 2); + + t.clearDirty(); t.scrollDown(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4012,8 +4322,13 @@ test "Terminal: setLeftAndRightMargin simple" { try t.printString("GHI"); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(0, 0); + + t.clearDirty(); t.eraseChars(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4038,8 +4353,15 @@ test "Terminal: setLeftAndRightMargin left only" { try testing.expectEqual(@as(usize, 1), t.scrolling_region.left); try testing.expectEqual(@as(usize, t.cols - 1), t.scrolling_region.right); t.setCursorPos(1, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4062,8 +4384,15 @@ test "Terminal: setLeftAndRightMargin left and right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4086,8 +4415,15 @@ test "Terminal: setLeftAndRightMargin left equal right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 2); t.setCursorPos(1, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4110,8 +4446,15 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { t.modes.set(.enable_left_and_right_margin, false); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4132,8 +4475,15 @@ test "Terminal: insertLines simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4230,8 +4580,14 @@ test "Terminal: insertLines outside of scroll region" { try t.printString("GHI"); t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4256,8 +4612,15 @@ test "Terminal: insertLines top/bottom scroll region" { try t.printString("123"); t.setTopAndBottomMargin(1, 3); t.setCursorPos(2, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4330,8 +4693,14 @@ test "Terminal: insertLines with scroll region" { t.setTopAndBottomMargin(1, 2); t.setCursorPos(1, 1); + + t.clearDirty(); t.insertLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try t.print('X'); { @@ -4365,8 +4734,13 @@ test "Terminal: insertLines more than remaining" { t.setCursorPos(2, 1); // Insert a bunch of lines + t.clearDirty(); t.insertLines(20); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4465,8 +4839,15 @@ test "Terminal: insertLines left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); + + t.clearDirty(); t.insertLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4487,11 +4868,17 @@ test "Terminal: scrollUp simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollUp(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4513,8 +4900,14 @@ test "Terminal: scrollUp top/bottom scroll region" { try t.printString("GHI"); t.setTopAndBottomMargin(2, 3); t.setCursorPos(1, 1); + + t.clearDirty(); t.scrollUp(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4537,11 +4930,17 @@ test "Terminal: scrollUp left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollUp(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4579,8 +4978,13 @@ test "Terminal: scrollUp full top/bottom region" { t.setCursorPos(5, 1); try t.printString("ABCDE"); t.setTopAndBottomMargin(2, 5); + + t.clearDirty(); t.scrollUp(4); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4599,8 +5003,16 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.modes.set(.enable_left_and_right_margin, true); t.setTopAndBottomMargin(2, 5); t.setLeftAndRightMargin(2, 4); + + t.clearDirty(); t.scrollUp(4); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..5) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4621,11 +5033,18 @@ test "Terminal: scrollDown simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollDown(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + for (0..5) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4647,11 +5066,18 @@ test "Terminal: scrollDown outside of scroll region" { try t.printString("GHI"); t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollDown(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4674,11 +5100,18 @@ test "Terminal: scrollDown left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollDown(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4701,11 +5134,18 @@ test "Terminal: scrollDown outside of left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(1, 1); + const cursor = t.screen.cursor; + t.clearDirty(); t.scrollDown(1); try testing.expectEqual(cursor.x, t.screen.cursor.x); try testing.expectEqual(cursor.y, t.screen.cursor.y); + for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4741,9 +5181,13 @@ test "Terminal: eraseChars simple operation" { for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + t.clearDirty(); t.eraseChars(2); try t.print('X'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4758,8 +5202,10 @@ test "Terminal: eraseChars minimum one" { for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + t.clearDirty(); t.eraseChars(0); try t.print('X'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -5193,6 +5639,11 @@ test "Terminal: index" { try t.index(); try t.print('A'); + // Only the row we write to is dirty. Moving the cursor itself + // does not make a row dirty. + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5208,10 +5659,16 @@ test "Terminal: index from the bottom" { t.setCursorPos(5, 1); try t.print('A'); t.cursorLeft(1); // undo moving right from 'A' - try t.index(); + t.clearDirty(); + try t.index(); try t.print('B'); + // Only the row we write to is dirty. Moving the cursor itself + // does not make a row dirty. + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5238,8 +5695,10 @@ test "Terminal: index from the bottom outside of scroll region" { t.setTopAndBottomMargin(1, 2); t.setCursorPos(5, 1); try t.print('A'); + t.clearDirty(); try t.index(); try t.print('B'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); { const str = try t.plainString(testing.allocator); @@ -5254,9 +5713,13 @@ test "Terminal: index no scroll region, top of screen" { defer t.deinit(alloc); try t.print('A'); + t.clearDirty(); try t.index(); try t.print('X'); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5271,9 +5734,13 @@ test "Terminal: index bottom of primary screen" { t.setCursorPos(5, 1); try t.print('A'); + t.clearDirty(); try t.index(); try t.print('X'); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5321,9 +5788,13 @@ test "Terminal: index inside scroll region" { t.setTopAndBottomMargin(1, 3); try t.print('A'); + t.clearDirty(); try t.index(); try t.print('X'); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5377,11 +5848,18 @@ test "Terminal: index bottom of primary screen with scroll region" { t.setCursorPos(3, 1); try t.print('A'); t.setCursorPos(5, 1); + t.clearDirty(); try t.index(); try t.index(); try t.index(); try t.print('X'); + for (0..4) |y| try testing.expect(!t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5400,9 +5878,12 @@ test "Terminal: index outside left/right margin" { t.setCursorPos(3, 3); try t.print('A'); t.setCursorPos(3, 1); + t.clearDirty(); try t.index(); try t.print('X'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5426,8 +5907,14 @@ test "Terminal: index inside left/right margin" { t.setTopAndBottomMargin(1, 3); t.setLeftAndRightMargin(1, 3); t.setCursorPos(3, 1); + + t.clearDirty(); try t.index(); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); @@ -5448,9 +5935,15 @@ test "Terminal: index bottom of scroll region" { try t.print('B'); t.setCursorPos(3, 1); try t.print('A'); + t.clearDirty(); try t.index(); try t.print('X'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5933,8 +6426,15 @@ test "Terminal: deleteLines simple" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); + + t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6037,8 +6537,15 @@ test "Terminal: deleteLines with scroll region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); + + t.clearDirty(); t.deleteLines(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try t.print('E'); t.carriageReturn(); try t.linefeed(); @@ -6054,7 +6561,6 @@ test "Terminal: deleteLines with scroll region" { } } -// X test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 80, .rows = 80 }); @@ -6074,8 +6580,15 @@ test "Terminal: deleteLines with scroll region, large count" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); + + t.clearDirty(); t.deleteLines(5); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try t.print('E'); t.carriageReturn(); try t.linefeed(); @@ -6091,7 +6604,6 @@ test "Terminal: deleteLines with scroll region, large count" { } } -// X test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 80, .rows = 80 }); @@ -6111,8 +6623,15 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(4, 1); + + t.clearDirty(); t.deleteLines(1); + for (0..4) |y| try testing.expect(!t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6184,8 +6703,16 @@ test "Terminal: deleteLines left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); + + t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6208,8 +6735,15 @@ test "Terminal: deleteLines left/right scroll region from top" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(1, 2); + + t.clearDirty(); t.deleteLines(1); + for (0..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6232,8 +6766,16 @@ test "Terminal: deleteLines left/right scroll region high count" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); + + t.clearDirty(); t.deleteLines(100); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6596,6 +7138,11 @@ test "Terminal: DECALN" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6661,7 +7208,11 @@ test "Terminal: insertBlanks" { try t.print('B'); try t.print('C'); t.setCursorPos(1, 1); + + t.clearDirty(); t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); { const str = try t.plainString(testing.allocator); @@ -6681,7 +7232,10 @@ test "Terminal: insertBlanks pushes off end" { try t.print('B'); try t.print('C'); t.setCursorPos(1, 1); + + t.clearDirty(); t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -6701,7 +7255,10 @@ test "Terminal: insertBlanks more than size" { try t.print('B'); try t.print('C'); t.setCursorPos(1, 1); + + t.clearDirty(); t.insertBlanks(5); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -6717,7 +7274,10 @@ test "Terminal: insertBlanks no scroll region, fits" { for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + + t.clearDirty(); t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -6763,7 +7323,9 @@ test "Terminal: insertBlanks shift off screen" { for (" ABC") |c| try t.print(c); t.setCursorPos(1, 3); + t.clearDirty(); t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try t.print('X'); { @@ -6781,7 +7343,9 @@ test "Terminal: insertBlanks split multi-cell character" { for ("123") |c| try t.print(c); try t.print('橋'); t.setCursorPos(1, 1); + t.clearDirty(); t.insertBlanks(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -6800,7 +7364,10 @@ test "Terminal: insertBlanks inside left/right scroll region" { t.setCursorPos(1, 3); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 3); + + t.clearDirty(); t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try t.print('X'); { @@ -6820,7 +7387,9 @@ test "Terminal: insertBlanks outside left/right scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; try testing.expect(t.screen.cursor.pending_wrap); + t.clearDirty(); t.insertBlanks(2); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('X'); @@ -6840,7 +7409,9 @@ test "Terminal: insertBlanks left/right scroll region large count" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); t.setCursorPos(1, 1); + t.clearDirty(); t.insertBlanks(140); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try t.print('X'); { @@ -6872,7 +7443,9 @@ test "Terminal: insertBlanks deleting graphemes" { try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); + t.clearDirty(); t.insertBlanks(4); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -6906,7 +7479,9 @@ test "Terminal: insertBlanks shift graphemes" { try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); + t.clearDirty(); t.insertBlanks(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7043,7 +7618,10 @@ test "Terminal: deleteChars" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.deleteChars(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -7059,7 +7637,10 @@ test "Terminal: deleteChars zero count" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.deleteChars(0); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -7075,7 +7656,10 @@ test "Terminal: deleteChars more than half" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.deleteChars(3); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -7091,7 +7675,10 @@ test "Terminal: deleteChars more than line width" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.deleteChars(10); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -7107,7 +7694,10 @@ test "Terminal: deleteChars should shift left" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.deleteChars(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -7169,7 +7759,10 @@ test "Terminal: deleteChars simple operation" { try t.printString("ABC123"); t.setCursorPos(1, 3); + + t.clearDirty(); t.deleteChars(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7220,7 +7813,9 @@ test "Terminal: deleteChars outside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; try testing.expect(t.screen.cursor.pending_wrap); + t.clearDirty(); t.deleteChars(2); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.screen.cursor.pending_wrap); { @@ -7239,7 +7834,10 @@ test "Terminal: deleteChars inside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; t.setCursorPos(1, 4); + + t.clearDirty(); t.deleteChars(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7518,7 +8116,9 @@ test "Terminal: eraseLine simple erase right" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 3); + t.clearDirty(); t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7614,7 +8214,9 @@ test "Terminal: eraseLine right wide character" { try t.print('橋'); for ("DE") |c| try t.print(c); t.setCursorPos(1, 4); + t.clearDirty(); t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7631,7 +8233,9 @@ test "Terminal: eraseLine right protected attributes respected with iso" { t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + t.clearDirty(); t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7650,7 +8254,9 @@ test "Terminal: eraseLine right protected attributes ignored with dec most recen t.setProtectedMode(.dec); t.setProtectedMode(.off); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7667,7 +8273,9 @@ test "Terminal: eraseLine right protected attributes ignored with dec set" { t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7686,7 +8294,9 @@ test "Terminal: eraseLine right protected requested" { t.setProtectedMode(.dec); try t.print('X'); t.setCursorPos(t.screen.cursor.y + 1, 4); + t.clearDirty(); t.eraseLine(.right, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7702,7 +8312,9 @@ test "Terminal: eraseLine simple erase left" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 3); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7718,7 +8330,9 @@ test "Terminal: eraseLine left resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('B'); @@ -7771,7 +8385,9 @@ test "Terminal: eraseLine left wide character" { try t.print('橋'); for ("DE") |c| try t.print(c); t.setCursorPos(1, 3); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7788,7 +8404,9 @@ test "Terminal: eraseLine left protected attributes respected with iso" { t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7807,7 +8425,9 @@ test "Terminal: eraseLine left protected attributes ignored with dec most recent t.setProtectedMode(.dec); t.setProtectedMode(.off); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7824,7 +8444,9 @@ test "Terminal: eraseLine left protected attributes ignored with dec set" { t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7843,7 +8465,9 @@ test "Terminal: eraseLine left protected requested" { t.setProtectedMode(.dec); try t.print('X'); t.setCursorPos(t.screen.cursor.y + 1, 8); + t.clearDirty(); t.eraseLine(.left, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7893,7 +8517,9 @@ test "Terminal: eraseLine complete protected attributes respected with iso" { t.setProtectedMode(.iso); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); + t.clearDirty(); t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7912,7 +8538,9 @@ test "Terminal: eraseLine complete protected attributes ignored with dec most re t.setProtectedMode(.dec); t.setProtectedMode(.off); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7929,7 +8557,9 @@ test "Terminal: eraseLine complete protected attributes ignored with dec set" { t.setProtectedMode(.dec); for ("ABC") |c| try t.print(c); t.setCursorPos(1, 2); + t.clearDirty(); t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7948,7 +8578,9 @@ test "Terminal: eraseLine complete protected requested" { t.setProtectedMode(.dec); try t.print('X'); t.setCursorPos(t.screen.cursor.y + 1, 8); + t.clearDirty(); t.eraseLine(.complete, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -7964,6 +8596,7 @@ test "Terminal: tabClear single" { try t.horizontalTab(); t.tabClear(.current); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); @@ -7975,6 +8608,7 @@ test "Terminal: tabClear all" { defer t.deinit(alloc); t.tabClear(.all); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); @@ -7987,6 +8621,7 @@ test "Terminal: printRepeat simple" { try t.printString("A"); try t.printRepeat(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -8002,6 +8637,7 @@ test "Terminal: printRepeat wrap" { try t.printString(" A"); try t.printRepeat(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -8016,6 +8652,7 @@ test "Terminal: printRepeat no previous character" { defer t.deinit(alloc); try t.printRepeat(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); { const str = try t.plainString(testing.allocator); @@ -8088,8 +8725,14 @@ test "Terminal: eraseDisplay simple erase below" { try t.linefeed(); for ("GHI") |c| try t.print(c); t.setCursorPos(2, 2); + + t.clearDirty(); t.eraseDisplay(.below, false); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -8266,7 +8909,12 @@ test "Terminal: eraseDisplay simple erase above" { try t.linefeed(); for ("GHI") |c| try t.print(c); t.setCursorPos(2, 2); + + t.clearDirty(); t.eraseDisplay(.above, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); { const str = try t.plainString(testing.allocator); @@ -8444,7 +9092,13 @@ test "Terminal: eraseDisplay protected complete" { t.setProtectedMode(.dec); try t.print('X'); t.setCursorPos(t.screen.cursor.y + 1, 4); + + t.clearDirty(); t.eraseDisplay(.complete, true); + for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); { const str = try t.plainString(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 02bd45776..7137431cf 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -91,6 +91,44 @@ pub const Page = struct { /// The available set of styles in use on this page. styles: style.Set, + /// The offset to the first mask of dirty bits in the page. + /// + /// The dirty bits is a contiguous array of usize where each bit represents + /// a row in the page, in order. If the bit is set, then the row is dirty + /// and requires a redraw. Dirty status is only ever meant to convey that + /// a cell has changed visually. A cell which changes in a way that doesn't + /// affect the visual representation may not be marked as dirty. + /// + /// Dirty tracking may have false positives but should never have false + /// negatives. A false negative would result in a visual artifact on the + /// screen. + /// + /// Dirty bits are only ever unset by consumers of a page. The page + /// structure itself does not unset dirty bits since the page does not + /// know when a cell has been redrawn. + /// + /// As implementation background: it may seem that dirty bits should be + /// stored elsewhere and not on the page itself, because the only data + /// that could possibly change is in the active area of a terminal + /// historically and that area is small compared to the typical scrollback. + /// My original thinking was to put the dirty bits on Screen instead and + /// have them only track the active area. However, I decided to put them + /// into the page directly for a few reasons: + /// + /// 1. It's simpler. The page is a self-contained unit and it's nice + /// to have all the data for a page in one place. + /// + /// 2. It's cheap. Even a very large page might have 1000 rows and + /// that's only ~128 bytes of 64-bit integers to track all the dirty + /// bits. Compared to the hundreds of kilobytes a typical page + /// consumes, this is nothing. + /// + /// 3. It's more flexible. If we ever want to implement new terminal + /// features that allow non-active area to be dirty, we can do that + /// with minimal dirty-tracking work. + /// + dirty: Offset(usize), + /// The current dimensions of the page. The capacity may be larger /// than this. This allows us to allocate a larger page than necessary /// and also to resize a page smaller witout reallocating. @@ -155,6 +193,7 @@ pub const Page = struct { .memory = @alignCast(buf.start()[0..l.total_size]), .rows = rows, .cells = cells, + .dirty = buf.member(usize, l.dirty_start), .styles = style.Set.init( buf.add(l.styles_start), l.styles_layout, @@ -461,11 +500,12 @@ pub const Page = struct { const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; - for (rows, other_rows) |*dst_row, *src_row| try self.cloneRowFrom( - other, - dst_row, - src_row, - ); + const other_dirty_set = other.dirtyBitSet(); + var dirty_set = self.dirtyBitSet(); + for (rows, 0.., other_rows, y_start..) |*dst_row, dst_y, *src_row, src_y| { + try self.cloneRowFrom(other, dst_row, src_row); + if (other_dirty_set.isSet(src_y)) dirty_set.set(dst_y); + } // We should remain consistent self.assertIntegrity(); @@ -866,12 +906,40 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).count(); } + /// Returns the bitset for the dirty bits on this page. + /// + /// The returned value is a DynamicBitSetUnmanaged but it is NOT + /// actually dynamic; do NOT call resize on this. It is safe to + /// read and write but do not resize it. + pub fn dirtyBitSet(self: *const Page) std.DynamicBitSetUnmanaged { + return .{ + .bit_length = self.capacity.rows, + .masks = self.dirty.ptr(self.memory), + }; + } + + /// Returns true if the given row is dirty. This is NOT very + /// efficient if you're checking many rows and you should use + /// dirtyBitSet directly instead. + pub fn isRowDirty(self: *const Page, y: usize) bool { + return self.dirtyBitSet().isSet(y); + } + + /// Returns true if this page is dirty at all. If you plan on + /// checking any additional rows, you should use dirtyBitSet and + /// check this on your own so you have the set available. + pub fn isDirty(self: *const Page) bool { + return self.dirtyBitSet().findFirstSet() != null; + } + pub const Layout = struct { total_size: usize, rows_start: usize, rows_size: usize, cells_start: usize, cells_size: usize, + dirty_start: usize, + dirty_size: usize, styles_start: usize, styles_layout: style.Set.Layout, grapheme_alloc_start: usize, @@ -892,8 +960,19 @@ pub const Page = struct { const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell)); + // The division below cannot fail because our row count cannot + // exceed the maximum value of usize. + const dirty_bit_length: usize = rows_count; + const dirty_usize_length: usize = std.math.divCeil( + usize, + dirty_bit_length, + @bitSizeOf(usize), + ) catch unreachable; + const dirty_start = alignForward(usize, cells_end, @alignOf(usize)); + const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize)); + const styles_layout = style.Set.layout(cap.styles); - const styles_start = alignForward(usize, cells_end, style.Set.base_align); + const styles_start = alignForward(usize, dirty_end, style.Set.base_align); const styles_end = styles_start + styles_layout.total_size; const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); @@ -913,6 +992,8 @@ pub const Page = struct { .rows_size = rows_end - rows_start, .cells_start = cells_start, .cells_size = cells_end - cells_start, + .dirty_start = dirty_start, + .dirty_size = dirty_end - dirty_start, .styles_start = styles_start, .styles_layout = styles_layout, .grapheme_alloc_start = grapheme_alloc_start, @@ -981,9 +1062,18 @@ pub const Capacity = struct { const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); - const available_size = styles_start; - const size_per_row = @sizeOf(Row) + (@sizeOf(Cell) * @as(usize, @intCast(cols))); - const new_rows = @divFloor(available_size, size_per_row); + // The size per row is: + // - The row metadata itself + // - The cells per row (n=cols) + // - 1 bit for dirty tracking + const bits_per_row: usize = size: { + var bits: usize = @bitSizeOf(Row); // Row metadata + bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols) + bits += 1; // The dirty bit + break :size bits; + }; + const available_bits: usize = styles_start * 8; + const new_rows: usize = @divFloor(available_bits, bits_per_row); // If our rows go to zero then we can't fit any row metadata // for the desired number of columns. @@ -1315,6 +1405,10 @@ test "Page init" { .styles = 32, }); defer page.deinit(); + + // Dirty set should be empty + const dirty = page.dirtyBitSet(); + try std.testing.expectEqual(@as(usize, 0), dirty.count()); } test "Page read and write cells" { diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1a044c25b..9d106f79d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -397,6 +397,7 @@ pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void { for (0..config.palette.len) |i| { if (!self.terminal.color_palette.mask.isSet(i)) { self.terminal.color_palette.colors[i] = config.palette[i]; + self.terminal.flags.dirty.palette = true; } } @@ -2166,7 +2167,10 @@ const StreamHandler = struct { .autorepeat => {}, // Schedule a render since we changed colors - .reverse_colors => try self.queueRender(), + .reverse_colors => { + self.terminal.flags.dirty.reverse_colors = true; + try self.queueRender(); + }, // Origin resets cursor pos. This is called whether or not // we're enabling or disabling origin mode and whether or @@ -2792,6 +2796,7 @@ const StreamHandler = struct { switch (kind) { .palette => |i| { + self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = color; self.terminal.color_palette.mask.set(i); }, @@ -2829,6 +2834,7 @@ const StreamHandler = struct { // reset those indices to the default palette var it = mask.iterator(.{}); while (it.next()) |i| { + self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); } @@ -2838,6 +2844,7 @@ const StreamHandler = struct { // Skip invalid parameters const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; if (mask.isSet(i)) { + self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); }