Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466const std = @import("std");const assert = @import("../quirks.zig").inlineAssert;const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;const fastmem = @import("../fastmem.zig");const lib = @import("lib.zig");const color = @import("color.zig");const cursor = @import("cursor.zig");const highlight = @import("highlight.zig");const point = @import("point.zig");const size = @import("size.zig");const page = @import("page.zig");const PageList = @import("PageList.zig");const Selection = @import("Selection.zig");const Screen = @import("Screen.zig");const ScreenSet = @import("ScreenSet.zig");const Style = @import("style.zig").Style;const Terminal = @import("Terminal.zig");
// Developer note: this is in src/terminal and not src/renderer because// the goal is that this remains generic to multiple renderers. This can// aid specifically with libghostty-vt with converting terminal state to// a renderable form.
/// Contains the state required to render the screen, including optimizing/// for repeated render calls and only rendering dirty regions.////// Previously, our renderer would use `clone` to clone the screen within/// the viewport to perform rendering. This worked well enough that we kept/// it all the way up through the Ghostty 1.2.x series, but the clone time/// was repeatedly a bottleneck blocking IO.////// Rather than a generic clone that tries to clone all screen state per call/// (within a region), a stateful approach that optimizes for only what a/// renderer needs to do makes more sense.////// To use this, initialize the render state to empty, then call `update`/// on each frame to update the state to the latest terminal state.////// var state: RenderState = .empty;/// defer state.deinit(alloc);/// state.update(alloc, &terminal);////// Note: the render state retains as much memory as possible between updates/// to prevent future allocations. If a very large frame is rendered once,/// the render state will retain that much memory until deinit. To avoid/// waste, it is recommended that the caller `deinit` and start with an/// empty render state every so often.pub const RenderState = struct { /// The current screen dimensions. It is possible that these don't match /// the renderer's current dimensions in grid cells because resizing /// can happen asynchronously. For example, for Metal, our NSView resizes /// at a different time than when our internal terminal state resizes. /// This can lead to a one or two frame mismatch a renderer needs to /// handle. /// /// The viewport is always exactly equal to the active area size so this /// is also the viewport size. rows: size.CellCountInt, cols: size.CellCountInt,
/// The color state for the terminal. colors: Colors,
/// Cursor state within the viewport. cursor: Cursor,
/// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length. /// /// This is a MultiArrayList because only the update cares about /// the allocators. Callers care about all the other properties, and /// this better optimizes cache locality for read access for those /// use cases. row_data: std.MultiArrayList(Row),
/// The dirty state of the render state. This is set by the update method. /// The renderer/caller should set this to false when it has handled /// the dirty state. dirty: Dirty,
/// The screen type that this state represents. This is used primarily /// 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: ?PageList.Pin = null,
/// The cached selection so we can avoid expensive selection calculations /// if possible. selection_cache: ?SelectionCache = null,
/// Initial state. pub const empty: RenderState = .{ .rows = 0, .cols = 0, .colors = .{ .background = .{ .r = 0, .g = 0, .b = 0 }, .foreground = .{ .r = 0xff, .g = 0xff, .b = 0xff }, .cursor = null, .palette = color.default, }, .cursor = .{ .active = .{ .x = 0, .y = 0 }, .viewport = null, .cell = .{}, .style = undefined, .visual_style = .block, .password_input = false, .visible = true, .blinking = false, }, .row_data = .empty, .dirty = .false, .screen = .primary, };
/// The color state for the terminal. /// /// The background/foreground will be reversed if the terminal reverse /// color mode is on! You do not need to handle that manually! pub const Colors = struct { background: color.RGB, foreground: color.RGB, cursor: ?color.RGB, palette: color.Palette, };
pub const Cursor = struct { /// The x/y position of the cursor within the active area. active: point.Coordinate,
/// The x/y position of the cursor within the viewport. This /// may be null if the cursor is not visible within the viewport. viewport: ?Viewport,
/// The cell data for the cursor position. Managed memory is not /// safe to access from this. cell: page.Cell,
/// The style, always valid even if the cell is default style. style: Style,
/// The visual style of the cursor itself, such as a block or /// bar. visual_style: cursor.Style,
/// True if the cursor is detected to be at a password input field. password_input: bool,
/// Cursor visibility state determined by the terminal mode. visible: bool,
/// Cursor blink state determined by the terminal mode. blinking: bool,
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. pub const Row = struct { /// Arena used for any heap allocations for cell contents /// in this row. Importantly, this is NOT used for the MultiArrayList /// itself. We do this on purpose so that we can easily clear rows, /// but retain cached MultiArrayList capacities since grid sizes don't /// change often. arena: ArenaAllocator.State,
/// The page pin. This is not safe to read unless you can guarantee /// the terminal state hasn't changed since the last `update` call. pin: PageList.Pin,
/// Raw row data. raw: page.Row,
/// The cells in this row. Guaranteed to be `cols` length. cells: std.MultiArrayList(Cell),
/// A dirty flag that can be used by the renderer to track /// its own draw state. `update` will mark this true whenever /// this row is changed, too. dirty: bool,
/// The x range of the selection within this row. selection: ?[2]size.CellCountInt,
/// The highlights within this row. highlights: std.ArrayList(Highlight), };
pub const Highlight = struct { /// A special tag that can be used by the caller to differentiate /// different highlight types. The value is opaque to the RenderState. tag: u8,
/// The x ranges of highlights within this row. range: [2]size.CellCountInt, };
pub const Cell = struct { /// Always set, this is the raw copied cell data from page.Cell. /// The managed memory (hyperlinks, graphames, etc.) is NOT safe /// to access from here. It is duplicated into the other fields if /// it exists. raw: page.Cell,
/// Grapheme data for the cell. This is undefined unless the /// raw cell's content_tag is `codepoint_grapheme`. grapheme: []const u21,
/// The style data for the cell. This is undefined unless /// the style_id is non-default on raw. style: Style, };
// Dirty state. pub const Dirty = lib.Enum(lib.target, &.{ // Not dirty at all. Can skip rendering if prior state was // already rendered. "false",
// Some rows changed but not all. None of the global state // changed such as colors. "partial",
// Global state changed or dimensions changed. All rows should // be redrawn. "full", });
const SelectionCache = struct { selection: Selection, tl_pin: PageList.Pin, br_pin: PageList.Pin, };
pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), self.row_data.items(.cells), ) |state, *cells| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); cells.deinit(alloc); } self.row_data.deinit(alloc); }
/// Update the render state to the latest terminal state. /// /// This will reset the terminal dirty state since it is consumed /// by this render state update. pub fn update( self: *RenderState, alloc: Allocator, 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. if (t.screens.active_key != self.screen) break :redraw true;
// If our terminal is dirty at all, we do a full rebuild. These // dirty values are full-terminal dirty values. { const Int = @typeInfo(Terminal.Dirty).@"struct".backing_integer.?; const v: Int = @bitCast(t.flags.dirty); if (v > 0) break :redraw true; }
// If our screen is dirty at all, we do a full rebuild. This is // a full screen dirty tracker. { const Int = @typeInfo(Screen.Dirty).@"struct".backing_integer.?; const v: Int = @bitCast(t.screens.active.dirty); if (v > 0) break :redraw true; }
// If our dimensions changed, we do a full rebuild. if (self.rows != s.pages.rows or self.cols != s.pages.cols) { 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; };
// Always set our cheap fields, its more expensive to compare self.rows = s.pages.rows; self.cols = s.pages.cols; 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.style; self.cursor.visual_style = s.cursor.cursor_style; self.cursor.password_input = t.flags.password_input; self.cursor.visible = t.modes.get(.cursor_visible); self.cursor.blinking = t.modes.get(.cursor_blinking);
// Always reset the cursor viewport position. In the future we can // probably cache this by comparing the cursor pin and viewport pin // but may not be worth it. self.cursor.viewport = null;
// Colors. self.colors.cursor = t.colors.cursor.get(); self.colors.palette = t.colors.palette.current; bg_fg: { // Background/foreground can be unset initially which would // depend on "default" background/foreground. The expected use // case of Terminal is that the caller set their own configured // defaults on load so this doesn't happen. const bg = t.colors.background.get() orelse break :bg_fg; const fg = t.colors.foreground.get() orelse break :bg_fg; if (t.modes.get(.reverse_colors)) { self.colors.background = fg; self.colors.foreground = bg; } else { self.colors.background = bg; self.colors.foreground = fg; } }
// Ensure our row length is exactly our height, freeing or allocating // data as necessary. In most cases we'll have a perfectly matching // size. if (self.row_data.len != self.rows) { @branchHint(.unlikely);
if (self.row_data.len < self.rows) { // Resize our rows to the desired length, marking any added // values undefined. const old_len = self.row_data.len; try self.row_data.resize(alloc, self.rows);
// Initialize all our values. Its faster to use slice() + set() // because appendAssumeCapacity does this multiple times. var row_data = self.row_data.slice(); for (old_len..self.rows) |y| { row_data.set(y, .{ .arena = .{}, .pin = undefined, .raw = undefined, .cells = .empty, .dirty = true, .selection = null, .highlights = .empty, }); } } else { const row_data = self.row_data.slice(); for ( row_data.items(.arena)[self.rows..], row_data.items(.cells)[self.rows..], ) |state, *cell| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); cell.deinit(alloc); } self.row_data.shrinkRetainingCapacity(self.rows); } }
// Break down our row data const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty);
// Track the last page that we know was dirty. This lets us // more quickly do the full-page dirty check. var last_dirty_page: ?*page.Page = null;
// Go through and setup our rows. var row_it = s.pages.rowIterator( .right_down, .{ .viewport = .{} }, null, ); var y: size.CellCountInt = 0; var any_dirty: bool = false; while (row_it.next()) |row_pin| : (y = y + 1) { // Find our cursor if we haven't found it yet. We do this even // if the row is not dirty because the cursor is unrelated. if (self.cursor.viewport == null and row_pin.node == s.cursor.page_pin.node and row_pin.y == s.cursor.page_pin.y) { 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, }; }
// Store our pin. We have to store these even if we're not dirty // because dirty is only a renderer optimization. It doesn't // apply to memory movement. This will let us remap any cell // pins back to an exact entry in our RenderState. row_pins[y] = row_pin;
// Get all our cells in the page. const p: *page.Page = &row_pin.node.data; const page_rac = row_pin.rowAndCell();
dirty: { // If we're redrawing then we're definitely dirty. if (redraw) break :dirty;
// If our page is the same as last time then its dirty. if (p == last_dirty_page) break :dirty; if (p.dirty) { // If this page is dirty then clear the dirty flag // of the last page and then store this one. This benchmarks // faster than iterating pages again later. if (last_dirty_page) |last_p| last_p.dirty = false; last_dirty_page = p; break :dirty; }
// If our row is dirty then we're dirty. if (page_rac.row.dirty) break :dirty;
// Not dirty! continue; }
// Set that at least one row was dirty. any_dirty = true;
// Clear our row dirty, we'll clear our page dirty later. // We can't clear it now because we have more rows to go through. page_rac.row.dirty = false;
// Promote our arena. State is copied by value so we need to // restore it on all exit paths so we don't leak memory. var arena = row_arenas[y].promote(alloc); defer row_arenas[y] = arena.state;
// Reset our cells if we're rebuilding this row. if (row_cells[y].len > 0) { _ = arena.reset(.retain_capacity); row_cells[y].clearRetainingCapacity(); row_sels[y] = null; row_highlights[y] = .empty; } row_dirties[y] = true;
// Get all our cells in the page. const page_cells: []const page.Cell = p.getCells(page_rac.row); assert(page_cells.len == self.cols);
// Copy our raw row data row_rows[y] = page_rac.row.*;
// Note: our cells MultiArrayList uses our general allocator. // We do this on purpose because as rows become dirty, we do // not want to reallocate space for cells (which are large). This // was a source of huge slowdown. // // Our per-row arena is only used for temporary allocations // pertaining to cells directly (e.g. graphemes, hyperlinks). const cells: *std.MultiArrayList(Cell) = &row_cells[y]; try cells.resize(alloc, self.cols);
// We always copy our raw cell data. In the case we have no // managed memory, we can skip setting any other fields. // // This is an important optimization. For plain-text screens // this ends up being something around 300% faster based on // the `screen-clone` benchmark. const cells_slice = cells.slice(); fastmem.copy( page.Cell, cells_slice.items(.raw), page_cells, ); if (!page_rac.row.managedMemory()) continue;
const arena_alloc = arena.allocator(); const cells_grapheme = cells_slice.items(.grapheme); const cells_style = cells_slice.items(.style); for (page_cells, 0..) |*page_cell, x| { // Append assuming its a single-codepoint, styled cell // (most common by far). if (page_cell.style_id > 0) cells_style[x] = p.styles.get( p.memory, page_cell.style_id, ).*;
// Switch on our content tag to handle less likely cases. switch (page_cell.content_tag) { .codepoint => { @branchHint(.likely); // Primary codepoint goes into `raw` field. },
// If we have a multi-codepoint grapheme, look it up and // set our content type. .codepoint_grapheme => { @branchHint(.unlikely); cells_grapheme[x] = try arena_alloc.dupe( u21, p.lookupGrapheme(page_cell) orelse &.{}, ); },
.bg_color_rgb => { @branchHint(.unlikely); cells_style[x] = .{ .bg_color = .{ .rgb = .{ .r = page_cell.content.color_rgb.r, .g = page_cell.content.color_rgb.g, .b = page_cell.content.color_rgb.b, } } }; },
.bg_color_palette => { @branchHint(.unlikely); cells_style[x] = .{ .bg_color = .{ .palette = page_cell.content.color_palette, } }; }, } } } assert(y == self.rows);
// If our screen has a selection, then mark the rows with the // selection. We do this outside of the loop above because its unlikely // a selection exists and because the way our selections are structured // today is very inefficient. // // NOTE: To improve the performance of the block below, we'll need // to rethink how we model selections in general. // // There are performance improvements that can be made here, though. // For example, `containedRow` recalculates a bunch of information // we can cache. if (s.selection) |*sel| selection: { @branchHint(.unlikely);
// Populate our selection cache to avoid some expensive // recalculation. const cache: *const SelectionCache = cache: { if (self.selection_cache) |*c| cache_check: { // If we're redrawing, we recalculate the cache just to // be safe. if (redraw) break :cache_check;
// If our selection isn't equal, we aren't cached! if (!c.selection.eql(sel.*)) break :cache_check;
// If we have no dirty rows, we can not recalculate. if (!any_dirty) break :selection;
// We have dirty rows, we can utilize the cache. break :cache c; }
// Create a new cache const tl_pin = sel.topLeft(s); const br_pin = sel.bottomRight(s); self.selection_cache = .{ .selection = .init(tl_pin, br_pin, sel.rectangle), .tl_pin = tl_pin, .br_pin = br_pin, }; break :cache &self.selection_cache.?; };
// Grab the inefficient data we need from the selection. At // least we can cache it. const tl = s.pages.pointFromPin(.screen, cache.tl_pin).?.screen; const br = s.pages.pointFromPin(.screen, cache.br_pin).?.screen;
// We need to determine if our selection is within the viewport. // The viewport is generally very small so the efficient way to // do this is to traverse the viewport pages and check for the // matching selection pages. for ( row_pins, row_sels, ) |pin, *sel_bounds| { const p = s.pages.pointFromPin(.screen, pin).?.screen; const row_sel = sel.containedRowCached( s, cache.tl_pin, cache.br_pin, pin, tl, br, p, ) orelse continue; const start = row_sel.start(); const end = row_sel.end(); assert(start.node == end.node); assert(start.x <= end.x); assert(start.y == end.y); sel_bounds.* = .{ start.x, end.x }; } }
// Handle dirty state. if (redraw) { // Fully redraw resets some other state. self.screen = t.screens.active_key; self.dirty = .full;
// Note: we don't clear any row_data here because our rebuild // above did this. } else if (any_dirty and self.dirty == .false) { self.dirty = .partial; }
// Finalize our final dirty page if (last_dirty_page) |last_p| last_p.dirty = false;
// Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; }
/// Update the highlights in the render state from the given flattened /// highlights. Because this uses flattened highlights, it does not require /// reading from the terminal state so it should be done outside of /// any critical sections. /// /// This will not clear any previous highlights, so the caller must /// manually clear them if desired. pub fn updateHighlightsFlattened( self: *RenderState, alloc: Allocator, tag: u8, hls: []const highlight.Flattened, ) Allocator.Error!void { // Fast path, we have no highlights! if (hls.len == 0) return;
// This is, admittedly, horrendous. This is some low hanging fruit // to optimize. In my defense, screens are usually small, the number // of highlights is usually small, and this only happens on the // viewport outside of a locked area. Still, I'd love to see this // improved someday.
// We need to track whether any row had a match so we can mark // the dirty state. var any_dirty: bool = false;
const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, row_highlights_slice, row_dirties, ) |*row_arena, row_pin, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); const starts = chunks_slice.items(.start); const ends = chunks_slice.items(.end); for (0.., nodes) |i, node| { // If this node doesn't match or we're not within // the row range, skip it. if (node != row_pin.node or row_pin.y < starts[i] or row_pin.y >= ends[i]) continue;
// We're a match! var arena = row_arena.promote(alloc); defer row_arena.* = arena.state; const arena_alloc = arena.allocator(); try row_highlights.append( arena_alloc, .{ .tag = tag, .range = .{ if (i == 0 and row_pin.y == starts[0]) hl.top_x else 0, if (i == nodes.len - 1 and row_pin.y == ends[nodes.len - 1] - 1) hl.bot_x else self.cols - 1, }, }, );
dirty.* = true; any_dirty = true; } } }
// Mark our dirty state. if (any_dirty and self.dirty == .false) self.dirty = .partial; }
pub const StringMap = std.ArrayListUnmanaged(point.Coordinate);
/// Convert the current render state contents to a UTF-8 encoded /// string written to the given writer. This will unwrap all the wrapped /// rows. This is useful for a minimal viewport search. /// /// This currently writes empty cell contents as \x00 and writes all /// blank lines. This is fine for our current usage (link search) but /// we can adjust this later. /// /// NOTE: There is a limitation in that wrapped lines before/after /// the top/bottom line of the viewport are not included, since /// the render state cuts them off. pub fn string( self: *const RenderState, writer: *std.Io.Writer, map: ?struct { alloc: Allocator, map: *StringMap, }, ) (Allocator.Error || std.Io.Writer.Error)!void { const row_slice = self.row_data.slice(); const row_rows = row_slice.items(.raw); const row_cells = row_slice.items(.cells);
for ( 0.., row_rows, row_cells, ) |y, row, cells| { const cells_slice = cells.slice(); for ( 0.., cells_slice.items(.raw), cells_slice.items(.grapheme), ) |x, cell, graphemes| { var len: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch return error.WriteFailed; try writer.print("{u}", .{cell.codepoint()}); if (cell.hasGrapheme()) { for (graphemes) |cp| { len += std.unicode.utf8CodepointSequenceLength(cp) catch return error.WriteFailed; try writer.print("{u}", .{cp}); } }
if (map) |m| try m.map.appendNTimes(m.alloc, .{ .x = @intCast(x), .y = @intCast(y), }, len); }
if (!row.wrap) { try writer.writeAll("\n"); if (map) |m| try m.map.append(m.alloc, .{ .x = @intCast(cells_slice.len), .y = @intCast(y), }); } } }
/// A set of coordinates representing cells. pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void);
/// Returns a map of the cells that match to an OSC8 hyperlink over the /// given point in the render state. /// /// IMPORTANT: The terminal must not have updated since the last call to /// `update`. If there is any chance the terminal has updated, the caller /// must first call `update` again to refresh the render state. /// /// For example, you may want to hold a lock for the duration of the /// update and hyperlink lookup to ensure no updates happen in between. pub fn linkCells( self: *const RenderState, alloc: Allocator, viewport_point: point.Coordinate, ) Allocator.Error!CellSet { var result: CellSet = .empty; errdefer result.deinit(alloc);
const row_slice = self.row_data.slice(); const row_pins = row_slice.items(.pin); const row_cells = row_slice.items(.cells);
// Our viewport point is sent in by the caller and can't be trusted. // If it is outside the valid area then just return empty because // we can't possibly have a link there. if (viewport_point.x >= self.cols or viewport_point.y >= row_pins.len) return result;
// Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; const link_page: *page.Page = &link_pin.node.data; const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, link_pin.y, );
// The likely scenario is that our mouse isn't even over a link. if (!rac.cell.hyperlink) { @branchHint(.likely); return result; }
const link_id = link_page.lookupHyperlink(rac.cell) orelse return result; break :link link_page.hyperlink_set.get( link_page.memory, link_id, ); };
for ( 0.., row_pins, row_cells, ) |y, pin, cells| { for (0.., cells.items(.raw)) |x, cell| { if (!cell.hyperlink) continue;
const other_page: *page.Page = &pin.node.data; const other = link: { const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; break :link other_page.hyperlink_set.get( other_page.memory, link_id, ); };
if (link.eql( link_page.memory, other, other_page.memory, )) try result.put(alloc, .{ .y = @intCast(y), .x = @intCast(x), }, {}); } }
return result; }};
test "styled" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 80, .rows = 24, }); defer t.deinit(alloc);
// This fills the screen up try t.decaln();
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);}
test "basic text" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("ABCD");
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
// Verify we have the right number of rows const row_data = state.row_data.slice(); try testing.expectEqual(3, row_data.len);
// All rows should have cols cells const cells = row_data.items(.cells); try testing.expectEqual(10, cells[0].len); try testing.expectEqual(10, cells[1].len); try testing.expectEqual(10, cells[2].len);
// Row zero should contain our text try testing.expectEqual('A', cells[0].get(0).raw.codepoint()); try testing.expectEqual('B', cells[0].get(1).raw.codepoint()); try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); try testing.expectEqual('D', cells[0].get(3).raw.codepoint()); try testing.expectEqual(0, cells[0].get(4).raw.codepoint());}
test "styled text" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("\x1b[1mA"); // Bold s.nextSlice("\x1b[0;3mB"); // Italic s.nextSlice("\x1b[0;4mC"); // Underline
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
// Verify we have the right number of rows const row_data = state.row_data.slice(); try testing.expectEqual(3, row_data.len);
// All rows should have cols cells const cells = row_data.items(.cells); try testing.expectEqual(10, cells[0].len); try testing.expectEqual(10, cells[1].len); try testing.expectEqual(10, cells[2].len);
// Row zero should contain our text { const cell = cells[0].get(0); try testing.expectEqual('A', cell.raw.codepoint()); try testing.expect(cell.style.flags.bold); } { const cell = cells[0].get(1); try testing.expectEqual('B', cell.raw.codepoint()); try testing.expect(!cell.style.flags.bold); try testing.expect(cell.style.flags.italic); } try testing.expectEqual('C', cells[0].get(2).raw.codepoint()); try testing.expectEqual(0, cells[0].get(3).raw.codepoint());}
test "grapheme" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("A"); s.nextSlice("👨"); // this has a ZWJ
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
// Verify we have the right number of rows const row_data = state.row_data.slice(); try testing.expectEqual(3, row_data.len);
// All rows should have cols cells const cells = row_data.items(.cells); try testing.expectEqual(10, cells[0].len); try testing.expectEqual(10, cells[1].len); try testing.expectEqual(10, cells[2].len);
// Row zero should contain our text { const cell = cells[0].get(0); try testing.expectEqual('A', cell.raw.codepoint()); } { const cell = cells[0].get(1); try testing.expectEqual(0x1F468, cell.raw.codepoint()); try testing.expectEqual(.wide, cell.raw.wide); try testing.expectEqualSlices(u21, &.{0x200D}, cell.grapheme); } { const cell = cells[0].get(2); try testing.expectEqual(0, cell.raw.codepoint()); try testing.expectEqual(.spacer_tail, cell.raw.wide); }}
test "cursor state in viewport" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 5, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("A\x1b[H");
var state: RenderState = .empty; defer state.deinit(alloc);
// Initial update try state.update(alloc, &t); try testing.expectEqual(0, state.cursor.active.x); try testing.expectEqual(0, state.cursor.active.y); try testing.expectEqual(0, state.cursor.viewport.?.x); try testing.expectEqual(0, state.cursor.viewport.?.y); try testing.expectEqual('A', state.cursor.cell.codepoint()); try testing.expect(state.cursor.style.default());
// Set a style on the cursor s.nextSlice("\x1b[1m"); // Bold try state.update(alloc, &t); try testing.expect(!state.cursor.style.default()); try testing.expect(state.cursor.style.flags.bold); s.nextSlice("\x1b[0m"); // Reset style
// Move cursor to 2,1 s.nextSlice("\x1b[2;3H"); try state.update(alloc, &t); try testing.expectEqual(2, state.cursor.active.x); try testing.expectEqual(1, state.cursor.active.y); try testing.expectEqual(2, state.cursor.viewport.?.x); try testing.expectEqual(1, state.cursor.viewport.?.y);}
test "cursor state out of viewport" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 2, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("A\r\nB\r\nC\r\nD\r\n");
var state: RenderState = .empty; defer state.deinit(alloc);
// Initial update try state.update(alloc, &t); try testing.expectEqual(0, state.cursor.active.x); try testing.expectEqual(1, state.cursor.active.y); try testing.expectEqual(0, state.cursor.viewport.?.x); try testing.expectEqual(1, state.cursor.viewport.?.y);
// Scroll the viewport t.scrollViewport(.top); try state.update(alloc, &t);
// Set a style on the cursor try testing.expectEqual(0, state.cursor.active.x); try testing.expectEqual(1, state.cursor.active.y); try testing.expect(state.cursor.viewport == null);}
test "dirty state" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 5, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit();
var state: RenderState = .empty; defer state.deinit(alloc);
// First update should trigger redraw due to resize try state.update(alloc, &t); try testing.expectEqual(.full, state.dirty);
// Reset dirty flag and dirty rows state.dirty = .false; { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); @memset(dirty, false); }
// Second update with no changes - no dirty rows try state.update(alloc, &t); try testing.expectEqual(.false, state.dirty); { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); for (dirty) |d| try testing.expect(!d); }
// Write to first line s.nextSlice("A"); try state.update(alloc, &t); try testing.expectEqual(.partial, state.dirty); { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); try testing.expect(dirty[0]); // First row dirty try testing.expect(!dirty[1]); // Second row clean }}
test "colors" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 5, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit();
var state: RenderState = .empty; defer state.deinit(alloc);
// Default colors try state.update(alloc, &t);
// Change cursor color s.nextSlice("\x1b]12;#FF0000\x07"); try state.update(alloc, &t);
const c = state.colors.cursor.?; try testing.expectEqual(0xFF, c.r); try testing.expectEqual(0, c.g); try testing.expectEqual(0, c.b);
// Change palette color 0 to White s.nextSlice("\x1b]4;0;#FFFFFF\x07"); try state.update(alloc, &t); const p0 = state.colors.palette[0]; try testing.expectEqual(0xFF, p0.r); try testing.expectEqual(0xFF, p0.g); try testing.expectEqual(0xFF, p0.b);}
test "selection single line" { const testing = std.testing; const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
const screen: *Screen = t.screens.active; try screen.select(.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, false, ));
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
const row_data = state.row_data.slice(); const sels = row_data.items(.selection); try testing.expectEqual(null, sels[0]); try testing.expectEqualSlices(size.CellCountInt, &.{ 0, 2 }, &sels[1].?); try testing.expectEqual(null, sels[2]);
// Clear the selection try screen.select(null); try state.update(alloc, &t); try testing.expectEqual(null, sels[0]); try testing.expectEqual(null, sels[1]); try testing.expectEqual(null, sels[2]);}
test "selection multiple lines" { const testing = std.testing; const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
const screen: *Screen = t.screens.active; try screen.select(.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, screen.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, false, ));
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
const row_data = state.row_data.slice(); const sels = row_data.items(.selection); try testing.expectEqual(null, sels[0]); try testing.expectEqualSlices( size.CellCountInt, &.{ 0, screen.pages.cols - 1 }, &sels[1].?, ); try testing.expectEqualSlices( size.CellCountInt, &.{ 0, 2 }, &sels[2].?, );}
test "linkCells" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 5, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit();
var state: RenderState = .empty; defer state.deinit(alloc);
// Create a hyperlink s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); try state.update(alloc, &t);
// Query link at 0,0 var cells = try state.linkCells(alloc, .{ .x = 0, .y = 0 }); defer cells.deinit(alloc);
try testing.expectEqual(4, cells.count()); try testing.expect(cells.contains(.{ .x = 0, .y = 0 })); try testing.expect(cells.contains(.{ .x = 1, .y = 0 })); try testing.expect(cells.contains(.{ .x = 2, .y = 0 })); try testing.expect(cells.contains(.{ .x = 3, .y = 0 }));
// Query no link var cells2 = try state.linkCells(alloc, .{ .x = 4, .y = 0 }); defer cells2.deinit(alloc); try testing.expectEqual(0, cells2.count());}
test "string" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 5, .rows = 2, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("AB");
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
var w = std.Io.Writer.Allocating.init(alloc); defer w.deinit();
try state.string(&w.writer, null);
const result = try w.toOwnedSlice(); defer alloc.free(result);
const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n"; try testing.expectEqualStrings(expected, result);}
test "linkCells with scrollback spanning pages" { const testing = std.testing; const alloc = testing.allocator;
const viewport_rows: size.CellCountInt = 10; const tail_rows: size.CellCountInt = 5;
var t = try Terminal.init(alloc, .{ .cols = page.std_capacity.cols, .rows = viewport_rows, .max_scrollback = 10_000, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit();
const pages = &t.screens.active.pages; const first_page_cap = pages.pages.first.?.data.capacity.rows;
// Fill first page for (0..first_page_cap - 1) |_| s.nextSlice("\r\n");
// Create second page with hyperlink s.nextSlice("\r\n"); s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); for (0..(tail_rows - 1)) |_| s.nextSlice("\r\n");
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
const expected_viewport_y: usize = viewport_rows - tail_rows; // BUG: This crashes without the fix var cells = try state.linkCells(alloc, .{ .x = 0, .y = expected_viewport_y, }); defer cells.deinit(alloc); try testing.expectEqual(@as(usize, 4), cells.count());}
test "linkCells with invalid viewport point" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 5, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit();
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
// Row out of bound { var cells = try state.linkCells( alloc, .{ .x = 0, .y = t.rows + 10 }, ); defer cells.deinit(alloc); try testing.expectEqual(0, cells.count()); }
// Col out of bound { var cells = try state.linkCells( alloc, .{ .x = t.cols + 10, .y = 0 }, ); defer cells.deinit(alloc); try testing.expectEqual(0, cells.count()); }}
test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 3, }); defer t.deinit(alloc);
var s = t.vtStream(); defer s.deinit(); s.nextSlice("ABC");
var state: RenderState = .empty; defer state.deinit(alloc); try state.update(alloc, &t);
// Reset dirty state state.dirty = .false; { const row_data = state.row_data.slice(); const dirty = row_data.items(.dirty); @memset(dirty, false); }
// Manually add a highlight to row 0 { const row_data = state.row_data.slice(); const row_arenas = row_data.items(.arena); const row_highlights = row_data.items(.highlights); var arena = row_arenas[0].promote(alloc); defer row_arenas[0] = arena.state; try row_highlights[0].append(arena.allocator(), .{ .tag = 1, .range = .{ 0, 2 }, }); }
// Verify we have a highlight { const row_data = state.row_data.slice(); const row_highlights = row_data.items(.highlights); try testing.expectEqual(1, row_highlights[0].items.len); }
// Write to row 0 to make it dirty s.nextSlice("\x1b[H"); // Move to home s.nextSlice("X"); try state.update(alloc, &t);
// Verify the highlight was reset on the dirty row { const row_data = state.row_data.slice(); const row_highlights = row_data.items(.highlights); try testing.expectEqual(0, row_highlights[0].items.len); }}