From ebc8bff8f1f74abaef47fa86d3f5348c7d15fe91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 05:07:07 -1000 Subject: [PATCH] renderer: switch to using render state --- src/renderer/cell.zig | 200 ++++++++++------ src/renderer/generic.zig | 476 ++++++++++++++------------------------- src/terminal/render.zig | 43 +++- 3 files changed, 334 insertions(+), 385 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 855abdf76..9e5802ea5 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -255,8 +255,12 @@ pub fn isSymbol(cp: u21) bool { /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). -pub fn constraintWidth(cell_pin: terminal.Pin) u2 { - const cell = cell_pin.rowAndCell().cell; +pub fn constraintWidth( + raw_slice: []const terminal.page.Cell, + x: usize, + cols: usize, +) u2 { + const cell = raw_slice[x]; const cp = cell.codepoint(); const grid_width = cell.gridWidth(); @@ -271,20 +275,14 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { if (!isSymbol(cp)) return grid_width; // If we are at the end of the screen it must be constrained to one cell. - if (cell_pin.x == cell_pin.node.data.size.cols - 1) return 1; + if (x == cols - 1) return 1; // If we have a previous cell and it was a symbol then we need // to also constrain. This is so that multiple PUA glyphs align. // This does not apply if the previous symbol is a graphics // element such as a block element or Powerline glyph. - if (cell_pin.x > 0) { - const prev_cp = prev_cp: { - var copy = cell_pin; - copy.x -= 1; - const prev_cell = copy.rowAndCell().cell; - break :prev_cp prev_cell.codepoint(); - }; - + if (x > 0) { + const prev_cp = raw_slice[x - 1].codepoint(); if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) { return 1; } @@ -292,15 +290,8 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // If the next cell is whitespace, then we // allow the glyph to be up to two cells wide. - const next_cp = next_cp: { - var copy = cell_pin; - copy.x += 1; - const next_cell = copy.rowAndCell().cell; - break :next_cp next_cell.codepoint(); - }; - if (next_cp == 0 or isSpace(next_cp)) { - return 2; - } + const next_cp = raw_slice[x + 1].codepoint(); + if (next_cp == 0 or isSpace(next_cp)) return 2; // Otherwise, this has to be 1 cell wide. return 1; @@ -524,108 +515,171 @@ test "Cell constraint widths" { const testing = std.testing; const alloc = testing.allocator; - var s = try terminal.Screen.init(alloc, .{ .cols = 4, .rows = 1, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 4, + .rows = 1, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + // for each case, the numbers in the comment denote expected // constraint widths for the symbol-containing cells // symbol->nothing: 2 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->character: 1 { - try s.testWriteString("z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->space: 2 { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->no-break space: 1 { - try s.testWriteString("\u{00a0}z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice("\u{00a0}z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // symbol->end of row: 1 { - try s.testWriteString(" "); - const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p3)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 3, + state.cols, + )); } // character->symbol: 2 { - try s.testWriteString("z"); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->symbol: 1,1 { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - try testing.expectEqual(1, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // symbol->space->symbol: 2,2 { - try s.testWriteString(" "); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - try testing.expectEqual(2, constraintWidth(p2)); - s.reset(); + t.fullReset(); + try s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 2, + state.cols, + )); } // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p1)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); } // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, constraintWidth(p0)); - s.reset(); + t.fullReset(); + try s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 48c6da54f..77d826ed2 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -125,12 +125,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// cells goes into a separate shader. cells: cellpkg.Contents, - /// The last viewport that we based our rebuild off of. If this changes, - /// then we do a full rebuild of the cells. The pointer values in this pin - /// are NOT SAFE to read because they may be modified, freed, etc from the - /// termio thread. We treat the pointers as integers for comparison only. - cells_viewport: ?terminal.Pin = null, - /// Set to true after rebuildCells is called. This can be used /// to determine if any possible changes have been made to the /// cells for the draw call. @@ -940,8 +934,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// Mark the full screen as dirty so that we redraw everything. - pub fn markDirty(self: *Self) void { - self.cells_viewport = null; + pub inline fn markDirty(self: *Self) void { + self.terminal_state.redraw = true; } /// Called when we get an updated display ID for our display link. @@ -1047,7 +1041,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Force a full rebuild, because cached rows may still reference // an outdated atlas from the old grid and this can cause garbage // to be rendered. - self.cells_viewport = null; + self.markDirty(); } /// Update uniforms that are based on the font grid. @@ -1070,17 +1064,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const Critical = struct { bg: terminal.color.RGB, fg: terminal.color.RGB, - screen: terminal.Screen, - screen_type: terminal.ScreenSet.Key, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_color: ?terminal.color.RGB, cursor_style: ?renderer.CursorStyle, color_palette: terminal.color.Palette, scrollbar: terminal.Scrollbar, - - /// If true, rebuild the full screen. - full_rebuild: bool, }; // Update all our data as tightly as possible within the mutex. @@ -1122,19 +1111,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .{ bg, fg }; }; - // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screens.active.pages.pin(.{ .viewport = .{} }).?; - - // We used to share terminal state, but we've since learned through - // analysis that it is faster to copy the terminal state than to - // hold the lock while rebuilding GPU cells. - var screen_copy = try state.terminal.screens.active.clone( - self.alloc, - .{ .viewport = .{} }, - null, - ); - errdefer screen_copy.deinit(); - // Whether to draw our cursor or not. const cursor_style = if (state.terminal.flags.password_input) .lock @@ -1166,77 +1142,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 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 full_rebuild: bool = rebuild: { - { - const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) break :rebuild true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screens.active.dirty); - if (v > 0) break :rebuild true; - } - - // If our viewport changed then we need to rebuild the entire - // screen because it means we scrolled. If we have no previous - // viewport then we must rebuild. - const prev_viewport = self.cells_viewport orelse break :rebuild true; - if (!prev_viewport.eql(viewport_pin)) break :rebuild true; - - break :rebuild false; - }; - - // 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 = .{}; - state.terminal.screens.active.dirty = .{}; - { - var it = state.terminal.screens.active.pages.pageIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - while (it.next()) |chunk| { - chunk.node.data.dirty = false; - for (chunk.rows()) |*row| { - row.dirty = false; - } - } - } - - // Update our viewport pin - self.cells_viewport = viewport_pin; - break :critical .{ .bg = bg, .fg = fg, - .screen = screen_copy, - .screen_type = state.terminal.screens.active_key, .mouse = state.mouse, .preedit = preedit, .cursor_color = state.terminal.colors.cursor.get(), .cursor_style = cursor_style, .color_palette = state.terminal.colors.palette.current, .scrollbar = scrollbar, - .full_rebuild = full_rebuild, }; }; defer { - critical.screen.deinit(); if (critical.preedit) |p| p.deinit(self.alloc); } // Build our GPU cells try self.rebuildCells( - critical.full_rebuild, - &critical.screen, - critical.screen_type, - critical.mouse, critical.preedit, critical.cursor_style, &critical.color_palette, @@ -2098,7 +2020,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (bg_image_config_changed) self.updateBgImageBuffer(); // Reset our viewport to force a rebuild, in case of a font change. - self.cells_viewport = null; + self.markDirty(); const blending_changed = old_blending != config.blending; @@ -2319,95 +2241,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - fn rebuildCells2( - self: *Self, - ) !void { - const state: *terminal.RenderState = &self.terminal_state; - - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); - - // Handle the case that our grid size doesn't match the terminal - // state grid size. It's possible our backing views for renderers - // have a mismatch temporarily since view resize is handled async - // to terminal state resize and is mostly dependent on GUI - // frameworks. - const grid_size_diff = - self.cells.size.rows != state.rows or - self.cells.size.columns != state.cols; - if (grid_size_diff) { - var new_size = self.cells.size; - new_size.rows = state.rows; - new_size.columns = state.cols; - try self.cells.resize(self.alloc, new_size); - - // Update our uniforms accordingly, otherwise - // our background cells will be out of place. - self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; - } - - // Redraw means we are redrawing the full grid, regardless of - // individual row dirtiness. - const redraw = state.redraw or grid_size_diff; - - if (redraw) { - // If we are doing a full rebuild, then we clear the entire - // cell buffer. - self.cells.reset(); - - // We also reset our padding extension depending on the - // screen type - switch (self.config.padding_color) { - .background => {}, - - // For extension, assume we are extending in all directions. - // For "extend" this may be disabled due to heuristics below. - .extend, .@"extend-always" => { - self.uniforms.padding_extend = .{ - .up = true, - .down = true, - .left = true, - .right = true, - }; - }, - } - } - - // Go through all the rows and rebuild as necessary. If we have - // a size mismatch on the state and our grid we just fill what - // we can from the BOTTOM of the viewport. - const start_idx = state.rows - @min( - state.rows, - self.cells.size.rows, - ); - const row_data = state.row_data.slice(); - for ( - 0.., - row_data.items(.cells)[start_idx..], - row_data.items(.dirty)[start_idx..], - ) |y, *cell, dirty| { - if (!redraw) { - // Only rebuild if we are doing a full rebuild or - // this row is dirty. - if (!dirty) continue; - - // Clear the cells if the row is dirty - self.cells.clear(y); - } - - _ = cell; - } - } - /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. fn rebuildCells( self: *Self, - wants_rebuild: bool, - screen: *terminal.Screen, - screen_type: terminal.ScreenSet.Key, - mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, color_palette: *const terminal.color.Palette, @@ -2415,6 +2253,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { foreground: terminal.color.RGB, terminal_cursor_color: ?terminal.color.RGB, ) !void { + const state: *terminal.RenderState = &self.terminal_state; + defer state.redraw = false; + self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -2426,20 +2267,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // } - _ = screen_type; // we might use this again later so not deleting it yet - - // Create an arena for all our temporary allocations while rebuilding - var arena = ArenaAllocator.init(self.alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - + // TODO: renderstate // Create our match set for the links. - var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - arena_alloc, - screen, - mouse_pt, - mouse.mods, - ) else .{}; + // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( + // arena_alloc, + // screen, + // mouse_pt, + // mouse.mods, + // ) else .{}; // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -2448,22 +2283,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type { x: [2]terminal.size.CellCountInt, cp_offset: usize, } = if (preedit) |preedit_v| preedit: { - const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1); + // We base the preedit on the position of the cursor in the + // viewport. If the cursor isn't visible in the viewport we + // don't show it. + const cursor_vp = state.cursor.viewport orelse + break :preedit null; + + const range = preedit_v.range( + cursor_vp.x, + state.cols - 1, + ); break :preedit .{ - .y = screen.cursor.y, + .y = @intCast(cursor_vp.y), .x = .{ range.start, range.end }, .cp_offset = range.cp_offset, }; } else null; const grid_size_diff = - self.cells.size.rows != screen.pages.rows or - self.cells.size.columns != screen.pages.cols; + self.cells.size.rows != state.rows or + self.cells.size.columns != state.cols; if (grid_size_diff) { var new_size = self.cells.size; - new_size.rows = screen.pages.rows; - new_size.columns = screen.pages.cols; + new_size.rows = state.rows; + new_size.columns = state.cols; try self.cells.resize(self.alloc, new_size); // Update our uniforms accordingly, otherwise @@ -2471,8 +2315,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; } - const rebuild = wants_rebuild or grid_size_diff; - + const rebuild = state.redraw or grid_size_diff; if (rebuild) { // If we are doing a full rebuild, then we clear the entire cell buffer. self.cells.reset(); @@ -2494,76 +2337,82 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - // We rebuild the cells row-by-row because we - // do font shaping and dirty tracking by row. - var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); + // Get our row data from our state + const row_data = state.row_data.slice(); + const row_cells = row_data.items(.cells); + const row_dirty = row_data.items(.dirty); + const row_selection = row_data.items(.selection); + // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead // the viewport is shorter than the cell contents buffer, we align // the top of the viewport with the top of the contents buffer. - var y: terminal.size.CellCountInt = @min( - screen.pages.rows, + const row_len: usize = @min( + state.rows, self.cells.size.rows, ); - while (row_it.next()) |row| { - // The viewport may have more rows than our cell contents, - // so we need to break from the loop early if we hit y = 0. - if (y == 0) break; - - y -= 1; + for ( + 0.., + row_cells[0..row_len], + row_dirty[0..row_len], + row_selection[0..row_len], + ) |y_usize, *cells, *dirty, selection| { + const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { // Only rebuild if we are doing a full rebuild or this row is dirty. - if (!row.isDirty()) continue; + if (!dirty.*) continue; // Clear the cells if the row is dirty self.cells.clear(y); } - // True if we want to do font shaping around the cursor. - // We want to do font shaping as long as the cursor is enabled. - const shape_cursor = screen.viewportIsBottom() and - y == screen.cursor.y; - - // We need to get this row's selection, if - // there is one, for proper run splitting. - const row_selection = sel: { - const sel = screen.selection orelse break :sel null; - const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse - break :sel null; - break :sel sel.containedRow(screen, pin) orelse null; - }; + // Unmark the dirty state in our render state. + dirty.* = false; + // TODO: renderstate // On primary screen, we still apply vertical padding // extension under certain conditions we feel are safe. // // This helps make some scenarios look better while // avoiding scenarios we know do NOT look good. - switch (self.config.padding_color) { - // These already have the correct values set above. - .background, .@"extend-always" => {}, - - // Apply heuristics for padding extension. - .extend => if (y == 0) { - self.uniforms.padding_extend.up = !row.neverExtendBg( - color_palette, - background, - ); - } else if (y == self.cells.size.rows - 1) { - self.uniforms.padding_extend.down = !row.neverExtendBg( - color_palette, - background, - ); - }, - } + // switch (self.config.padding_color) { + // // These already have the correct values set above. + // .background, .@"extend-always" => {}, + // + // // Apply heuristics for padding extension. + // .extend => if (y == 0) { + // self.uniforms.padding_extend.up = !row.neverExtendBg( + // color_palette, + // background, + // ); + // } else if (y == self.cells.size.rows - 1) { + // self.uniforms.padding_extend.down = !row.neverExtendBg( + // color_palette, + // background, + // ); + // }, + // } // Iterator of runs for shaping. + const cells_slice = cells.slice(); var run_iter_opts: font.shape.RunOptions = .{ .grid = self.font_grid, - .screen = screen, - .row = row, - .selection = row_selection, - .cursor_x = if (shape_cursor) screen.cursor.x else null, + .cells = cells_slice, + .selection2 = if (selection) |s| s else null, + + // We want to do font shaping as long as the cursor is + // visible on this viewport. + .cursor_x = cursor_x: { + const vp = state.cursor.viewport orelse break :cursor_x null; + if (vp.y != y) break :cursor_x null; + break :cursor_x vp.x; + }, + + // Old stuff + .screen = undefined, + .row = undefined, + .selection = null, }; run_iter_opts.applyBreakConfig(self.config.font_shaping_break); var run_iter = self.font_shaper.runIterator(run_iter_opts); @@ -2571,13 +2420,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells_all = row.cells(.all); - // If our viewport is wider than our cell contents buffer, // we still only process cells up to the width of the buffer. - const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; - - for (row_cells, 0..) |*cell, x| { + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); + for ( + 0.., + cells_raw[0..cells_len], + cells_style[0..cells_len], + ) |x, *cell, *managed_style| { // If this cell falls within our preedit range then we // skip this because preedits are setup separately. if (preedit_range) |range| preedit: { @@ -2610,7 +2462,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2618,7 +2470,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2629,49 +2481,42 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells.?; - // Advance our index until we reach or pass // our current x position in the shaper cells. - while (run.offset + cells[shaper_cells_i].x < x) { + const shaper_cells_unwrapped = shaper_cells.?; + while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { shaper_cells_i += 1; } } const wide = cell.wide; - - const style = row.style(cell); - - const cell_pin: terminal.Pin = cell: { - var copy = row; - copy.x = @intCast(x); - break :cell copy; - }; + const style: terminal.Style = if (cell.hasStyling()) + managed_style.* + else + .{}; // True if this cell is selected - const selected: bool = if (screen.selection) |sel| - sel.contains(screen, .{ - .node = row.node, - .y = row.y, - .x = @intCast( - // Spacer tails should show the selection - // state of the wide cell they belong to. - if (wide == .spacer_tail) - x -| 1 - else - x, - ), - }) - else - false; + const selected: bool = selected: { + const sel = selection orelse break :selected false; + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + + break :selected x_compare >= sel[0] and + x_compare <= sel[1]; + }; // The `_style` suffixed values are the colors based on // the cell style (SGR), before applying any additional // configuration, inversions, selections, etc. - const bg_style = style.bg(cell, color_palette); + const bg_style = style.bg( + cell, + color_palette, + ); const fg_style = style.fg(.{ .default = foreground, .palette = color_palette, @@ -2793,16 +2638,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { continue; } + // TODO: renderstate // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. - const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) - if (style.flags.underline == .single) - .double - else - .single - else - style.flags.underline; + // const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) + // if (style.flags.underline == .single) + // .double + // else + // .single + // else + // style.flags.underline; + const underline = style.flags.underline; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used @@ -2842,7 +2689,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.get(run) orelse cache: { // Otherwise we have to shape them. - const cells = try self.font_shaper.shape(run); + const new_cells = try self.font_shaper.shape(run); // Try to cache them. If caching fails for any reason we // continue because it is just a performance optimization, @@ -2850,7 +2697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.font_shaper_cache.put( self.alloc, run, - cells, + new_cells, ) catch |err| { log.warn( "error caching font shaping results err={}", @@ -2861,32 +2708,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The cells we get from direct shaping are always owned // by the shaper and valid until the next shaping call so // we can safely use them. - break :cache cells; + break :cache new_cells; }; - const cells = shaper_cells orelse break :glyphs; + const shaped_cells = shaper_cells orelse break :glyphs; // If there are no shaper cells for this run, ignore it. // This can occur for runs of empty cells, and is fine. - if (cells.len == 0) break :glyphs; + if (shaped_cells.len == 0) break :glyphs; // If we encounter a shaper cell to the left of the current // cell then we have some problems. This logic relies on x // position monotonically increasing. - assert(run.offset + cells[shaper_cells_i].x >= x); + assert(run.offset + shaped_cells[shaper_cells_i].x >= x); // NOTE: An assumption is made here that a single cell will never // be present in more than one shaper run. If that assumption is // violated, this logic breaks. - while (shaper_cells_i < cells.len and run.offset + cells[shaper_cells_i].x == x) : ({ + while (shaper_cells_i < shaped_cells.len and + run.offset + shaped_cells[shaper_cells_i].x == x) : ({ shaper_cells_i += 1; }) { self.addGlyph( @intCast(x), @intCast(y), - cell_pin, - cells[shaper_cells_i], + state.cols, + cells_raw, + shaped_cells[shaper_cells_i], shaper_run.?, fg, alpha, @@ -2938,14 +2787,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { inline .@"cell-foreground", .@"cell-background", => |_, tag| { - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + const sty: terminal.Style = state.cursor.style; const fg_style = sty.fg(.{ .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( - screen.cursor.page_cell, + &state.cursor.cell, color_palette, ) orelse background; @@ -2960,21 +2809,27 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :cursor_color foreground; }; - self.addCursor(screen, style, cursor_color); + self.addCursor( + &state.cursor, + style, + cursor_color, + ); // If the cursor is visible then we set our uniforms. - if (style == .block and screen.viewportIsBottom()) { - const wide = screen.cursor.page_cell.wide; + if (style == .block) cursor_uniforms: { + const cursor_vp = state.cursor.viewport orelse + break :cursor_uniforms; + const wide = state.cursor.cell.wide; self.uniforms.cursor_pos = .{ // If we are a spacer tail of a wide cell, our cursor needs // to move back one cell. The saturate is to ensure we don't // overflow but this shouldn't happen with well-formed input. switch (wide) { - .narrow, .spacer_head, .wide => screen.cursor.x, - .spacer_tail => screen.cursor.x -| 1, + .narrow, .spacer_head, .wide => cursor_vp.x, + .spacer_tail => cursor_vp.x -| 1, }, - screen.cursor.y, + @intCast(cursor_vp.y), }; self.uniforms.bools.cursor_wide = switch (wide) { @@ -2990,14 +2845,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :blk txt.color.toTerminalRGB(); } - const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); + const sty = state.cursor.style; const fg_style = sty.fg(.{ .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( - screen.cursor.page_cell, + &state.cursor.cell, color_palette, ) orelse background; @@ -3157,15 +3012,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, x: terminal.size.CellCountInt, y: terminal.size.CellCountInt, - cell_pin: terminal.Pin, + cols: usize, + cell_raws: []const terminal.page.Cell, shaper_cell: font.shape.Cell, shaper_run: font.shape.TextRun, color: terminal.color.RGB, alpha: u8, ) !void { - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - + const cell = cell_raws[x]; const cp = cell.codepoint(); // Render @@ -3185,7 +3039,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (cellpkg.isSymbol(cp)) .{ .size = .fit, } else .none, - .constraint_width = constraintWidth(cell_pin), + .constraint_width = constraintWidth( + cell_raws, + x, + cols, + ), }, ); @@ -3214,22 +3072,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fn addCursor( self: *Self, - screen: *terminal.Screen, + cursor_state: *const terminal.RenderState.Cursor, cursor_style: renderer.CursorStyle, cursor_color: terminal.color.RGB, ) void { + const cursor_vp = cursor_state.viewport orelse return; + // Add the cursor. We render the cursor over the wide character if // we're on the wide character tail. const wide, const x = cell: { // The cursor goes over the screen cursor position. - const cell = screen.cursor.page_cell; - if (cell.wide != .spacer_tail or screen.cursor.x == 0) - break :cell .{ cell.wide == .wide, screen.cursor.x }; + if (!cursor_vp.wide_tail) break :cell .{ + cursor_state.cell.wide == .wide, + cursor_vp.x, + }; - // If we're part of a wide character, we move the cursor back to - // the actual character. - const prev_cell = screen.cursorCellLeft(1); - break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 }; + // If we're part of a wide character, we move the cursor back + // to the actual character. + break :cell .{ true, cursor_vp.x - 1 }; }; const alpha: u8 = if (!self.focused) 255 else alpha: { @@ -3288,7 +3148,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells.setCursor(.{ .atlas = .grayscale, .bools = .{ .is_cursor_glyph = true }, - .grid_pos = .{ x, screen.cursor.y }, + .grid_pos = .{ x, cursor_vp.y }, .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, .glyph_size = .{ render.glyph.width, render.glyph.height }, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index d105f21af..0033ef16f 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -6,6 +6,7 @@ const fastmem = @import("../fastmem.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); +const Pin = @import("PageList.zig").Pin; const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Style = @import("style.zig").Style; @@ -68,6 +69,11 @@ pub const RenderState = struct { /// to detect changes. screen: ScreenSet.Key, + /// The last viewport pin used to generate this state. This is NOT + /// a tracked pin and is generally NOT safe to read other than the direct + /// values for comparison. + viewport_pin: ?Pin = null, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -90,7 +96,7 @@ pub const RenderState = struct { /// The x/y position of the cursor within the viewport. This /// may be null if the cursor is not visible within the viewport. - viewport: ?point.Coordinate, + viewport: ?Viewport, /// The cell data for the cursor position. Managed memory is not /// safe to access from this. @@ -98,6 +104,17 @@ pub const RenderState = struct { /// The style, always valid even if the cell is default style. style: Style, + + pub const Viewport = struct { + /// The x/y position of the cursor within the viewport. + x: size.CellCountInt, + y: size.CellCountInt, + + /// Whether the cursor is part of a wide character and + /// on the tail of it. If so, some renderers may use this + /// to move the cursor back one. + wide_tail: bool, + }; }; /// A row within the viewport. @@ -118,7 +135,7 @@ pub const RenderState = struct { dirty: bool, /// The x range of the selection within this row. - selection: [2]size.CellCountInt, + selection: ?[2]size.CellCountInt, }; pub const Cell = struct { @@ -159,6 +176,7 @@ pub const RenderState = struct { t: *Terminal, ) Allocator.Error!void { const s: *Screen = t.screens.active; + const viewport_pin = s.pages.getTopLeft(.viewport); const redraw = redraw: { // If our screen key changed, we need to do a full rebuild // because our render state is viewport-specific. @@ -187,6 +205,11 @@ pub const RenderState = struct { break :redraw true; } + // If our viewport pin changed, we do a full rebuild. + if (self.viewport_pin) |old| { + if (!old.eql(viewport_pin)) break :redraw true; + } + break :redraw false; }; @@ -203,6 +226,7 @@ pub const RenderState = struct { self.rows = s.pages.rows; self.cols = s.pages.cols; self.viewport_is_bottom = s.viewportIsBottom(); + self.viewport_pin = viewport_pin; self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y }; self.cursor.cell = s.cursor.page_cell.*; self.cursor.style = s.cursor.page_pin.style(s.cursor.page_cell); @@ -232,7 +256,7 @@ pub const RenderState = struct { .arena = .{}, .cells = .empty, .dirty = true, - .selection = .{ 0, 0 }, + .selection = null, }); } } else { @@ -272,11 +296,22 @@ pub const RenderState = struct { self.cursor.viewport = .{ .y = y, .x = s.cursor.x, + + // Future: we should use our own state here to look this + // up rather than calling this. + .wide_tail = if (s.cursor.x > 0) + s.cursorCellLeft(1).wide == .wide + else + false, }; } // If the row isn't dirty then we assume it is unchanged. - if (!redraw and !row_pin.isDirty()) continue; + var dirty_set = row_pin.node.data.dirtyBitSet(); + if (!redraw and !dirty_set.isSet(row_pin.y)) continue; + + // Clear the dirty flag on the row + dirty_set.unset(row_pin.y); // Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory.