a modern tui library written in zig
at main 17 kB view raw
1const std = @import("std"); 2const assert = std.debug.assert; 3const vaxis = @import("../../main.zig"); 4 5const ansi = @import("ansi.zig"); 6 7const log = std.log.scoped(.vaxis_terminal); 8 9const Screen = @This(); 10 11pub const Cell = struct { 12 char: std.ArrayList(u8) = .empty, 13 style: vaxis.Style = .{}, 14 uri: std.ArrayList(u8) = .empty, 15 uri_id: std.ArrayList(u8) = .empty, 16 width: u8 = 1, 17 18 wrapped: bool = false, 19 dirty: bool = true, 20 21 pub fn erase(self: *Cell, allocator: std.mem.Allocator, bg: vaxis.Color) void { 22 self.char.clearRetainingCapacity(); 23 self.char.append(allocator, ' ') catch unreachable; // we never completely free this list 24 self.style = .{}; 25 self.style.bg = bg; 26 self.uri.clearRetainingCapacity(); 27 self.uri_id.clearRetainingCapacity(); 28 self.width = 1; 29 self.wrapped = false; 30 self.dirty = true; 31 } 32 33 pub fn copyFrom(self: *Cell, allocator: std.mem.Allocator, src: Cell) !void { 34 self.char.clearRetainingCapacity(); 35 try self.char.appendSlice(allocator, src.char.items); 36 self.style = src.style; 37 self.uri.clearRetainingCapacity(); 38 try self.uri.appendSlice(allocator, src.uri.items); 39 self.uri_id.clearRetainingCapacity(); 40 try self.uri_id.appendSlice(allocator, src.uri_id.items); 41 self.width = src.width; 42 self.wrapped = src.wrapped; 43 44 self.dirty = true; 45 } 46}; 47 48pub const Cursor = struct { 49 style: vaxis.Style = .{}, 50 uri: std.ArrayList(u8) = undefined, 51 uri_id: std.ArrayList(u8) = undefined, 52 col: u16 = 0, 53 row: u16 = 0, 54 pending_wrap: bool = false, 55 shape: vaxis.Cell.CursorShape = .default, 56 visible: bool = true, 57 58 pub fn isOutsideScrollingRegion(self: Cursor, sr: ScrollingRegion) bool { 59 return self.row < sr.top or 60 self.row > sr.bottom or 61 self.col < sr.left or 62 self.col > sr.right; 63 } 64 65 pub fn isInsideScrollingRegion(self: Cursor, sr: ScrollingRegion) bool { 66 return !self.isOutsideScrollingRegion(sr); 67 } 68}; 69 70pub const ScrollingRegion = struct { 71 top: u16, 72 bottom: u16, 73 left: u16, 74 right: u16, 75 76 pub fn contains(self: ScrollingRegion, col: usize, row: usize) bool { 77 return col >= self.left and 78 col <= self.right and 79 row >= self.top and 80 row <= self.bottom; 81 } 82}; 83 84allocator: std.mem.Allocator, 85 86width: u16 = 0, 87height: u16 = 0, 88 89scrolling_region: ScrollingRegion, 90 91buf: []Cell = undefined, 92 93cursor: Cursor = .{}, 94 95csi_u_flags: vaxis.Key.KittyFlags = @bitCast(@as(u5, 0)), 96 97/// sets each cell to the default cell 98pub fn init(alloc: std.mem.Allocator, w: u16, h: u16) !Screen { 99 var screen = Screen{ 100 .allocator = alloc, 101 .buf = try alloc.alloc(Cell, @as(usize, @intCast(w)) * h), 102 .scrolling_region = .{ 103 .top = 0, 104 .bottom = h - 1, 105 .left = 0, 106 .right = w - 1, 107 }, 108 .width = w, 109 .height = h, 110 }; 111 for (screen.buf, 0..) |_, i| { 112 screen.buf[i] = .{ 113 .char = try .initCapacity(alloc, 1), 114 }; 115 try screen.buf[i].char.append(alloc, ' '); 116 } 117 return screen; 118} 119 120pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void { 121 for (self.buf, 0..) |_, i| { 122 self.buf[i].char.deinit(alloc); 123 self.buf[i].uri.deinit(alloc); 124 self.buf[i].uri_id.deinit(alloc); 125 } 126 127 alloc.free(self.buf); 128} 129 130/// copies the visible area to the destination screen 131pub fn copyTo(self: *Screen, allocator: std.mem.Allocator, dst: *Screen) !void { 132 dst.cursor = self.cursor; 133 for (self.buf, 0..) |cell, i| { 134 if (!cell.dirty) continue; 135 self.buf[i].dirty = false; 136 const grapheme = cell.char.items; 137 dst.buf[i].char.clearRetainingCapacity(); 138 try dst.buf[i].char.appendSlice(allocator, grapheme); 139 dst.buf[i].width = cell.width; 140 dst.buf[i].style = cell.style; 141 } 142} 143 144pub fn readCell(self: *Screen, col: usize, row: usize) ?vaxis.Cell { 145 if (self.width < col) { 146 // column out of bounds 147 return null; 148 } 149 if (self.height < row) { 150 // height out of bounds 151 return null; 152 } 153 const i = (row * self.width) + col; 154 assert(i < self.buf.len); 155 const cell = self.buf[i]; 156 return .{ 157 .char = .{ .grapheme = cell.char.items, .width = cell.width }, 158 .style = cell.style, 159 }; 160} 161 162/// returns true if the current cursor position is within the scrolling region 163pub fn withinScrollingRegion(self: Screen) bool { 164 return self.scrolling_region.contains(self.cursor.col, self.cursor.row); 165} 166 167/// writes a cell to a location. 0 indexed 168pub fn print( 169 self: *Screen, 170 grapheme: []const u8, 171 width: u8, 172 wrap: bool, 173) !void { 174 if (self.cursor.pending_wrap) { 175 try self.index(); 176 self.cursor.col = self.scrolling_region.left; 177 } 178 if (self.cursor.col >= self.width) return; 179 if (self.cursor.row >= self.height) return; 180 const col = self.cursor.col; 181 const row = self.cursor.row; 182 183 const i = (row * self.width) + col; 184 assert(i < self.buf.len); 185 self.buf[i].char.clearRetainingCapacity(); 186 self.buf[i].char.appendSlice(self.allocator, grapheme) catch { 187 log.warn("couldn't write grapheme", .{}); 188 }; 189 self.buf[i].uri.clearRetainingCapacity(); 190 self.buf[i].uri.appendSlice(self.allocator, self.cursor.uri.items) catch { 191 log.warn("couldn't write uri", .{}); 192 }; 193 self.buf[i].uri_id.clearRetainingCapacity(); 194 self.buf[i].uri_id.appendSlice(self.allocator, self.cursor.uri_id.items) catch { 195 log.warn("couldn't write uri_id", .{}); 196 }; 197 self.buf[i].style = self.cursor.style; 198 self.buf[i].width = width; 199 self.buf[i].dirty = true; 200 201 if (wrap and self.cursor.col >= self.width - 1) self.cursor.pending_wrap = true; 202 self.cursor.col += width; 203} 204 205/// IND 206pub fn index(self: *Screen) !void { 207 self.cursor.pending_wrap = false; 208 209 if (self.cursor.isOutsideScrollingRegion(self.scrolling_region)) { 210 // Outside, we just move cursor down one 211 self.cursor.row = @min(self.height - 1, self.cursor.row + 1); 212 return; 213 } 214 // We are inside the scrolling region 215 if (self.cursor.row == self.scrolling_region.bottom) { 216 // Inside scrolling region *and* at bottom of screen, we scroll contents up and insert a 217 // blank line 218 // TODO: scrollback if scrolling region is entire visible screen 219 try self.deleteLine(1); 220 return; 221 } 222 self.cursor.row += 1; 223} 224 225pub fn sgr(self: *Screen, seq: ansi.CSI) void { 226 if (seq.params.len == 0) { 227 self.cursor.style = .{}; 228 return; 229 } 230 231 var iter = seq.iterator(u8); 232 while (iter.next()) |ps| { 233 switch (ps) { 234 0 => self.cursor.style = .{}, 235 1 => self.cursor.style.bold = true, 236 2 => self.cursor.style.dim = true, 237 3 => self.cursor.style.italic = true, 238 4 => { 239 const kind: vaxis.Style.Underline = if (iter.next_is_sub) 240 @enumFromInt(iter.next() orelse 1) 241 else 242 .single; 243 self.cursor.style.ul_style = kind; 244 }, 245 5 => self.cursor.style.blink = true, 246 7 => self.cursor.style.reverse = true, 247 8 => self.cursor.style.invisible = true, 248 9 => self.cursor.style.strikethrough = true, 249 21 => self.cursor.style.ul_style = .double, 250 22 => { 251 self.cursor.style.bold = false; 252 self.cursor.style.dim = false; 253 }, 254 23 => self.cursor.style.italic = false, 255 24 => self.cursor.style.ul_style = .off, 256 25 => self.cursor.style.blink = false, 257 27 => self.cursor.style.reverse = false, 258 28 => self.cursor.style.invisible = false, 259 29 => self.cursor.style.strikethrough = false, 260 30...37 => self.cursor.style.fg = .{ .index = ps - 30 }, 261 38 => { 262 // must have another parameter 263 const kind = iter.next() orelse return; 264 switch (kind) { 265 2 => { // rgb 266 const r = r: { 267 // First param can be empty 268 var ps_r = iter.next() orelse return; 269 if (iter.is_empty) 270 ps_r = iter.next() orelse return; 271 break :r ps_r; 272 }; 273 const g = iter.next() orelse return; 274 const b = iter.next() orelse return; 275 self.cursor.style.fg = .{ .rgb = .{ r, g, b } }; 276 }, 277 5 => { 278 const idx = iter.next() orelse return; 279 self.cursor.style.fg = .{ .index = idx }; 280 }, // index 281 else => return, 282 } 283 }, 284 39 => self.cursor.style.fg = .default, 285 40...47 => self.cursor.style.bg = .{ .index = ps - 40 }, 286 48 => { 287 // must have another parameter 288 const kind = iter.next() orelse return; 289 switch (kind) { 290 2 => { // rgb 291 const r = r: { 292 // First param can be empty 293 var ps_r = iter.next() orelse return; 294 if (iter.is_empty) 295 ps_r = iter.next() orelse return; 296 break :r ps_r; 297 }; 298 const g = iter.next() orelse return; 299 const b = iter.next() orelse return; 300 self.cursor.style.bg = .{ .rgb = .{ r, g, b } }; 301 }, 302 5 => { 303 const idx = iter.next() orelse return; 304 self.cursor.style.bg = .{ .index = idx }; 305 }, // index 306 else => return, 307 } 308 }, 309 49 => self.cursor.style.bg = .default, 310 90...97 => self.cursor.style.fg = .{ .index = ps - 90 + 8 }, 311 100...107 => self.cursor.style.bg = .{ .index = ps - 100 + 8 }, 312 else => continue, 313 } 314 } 315} 316 317pub fn cursorUp(self: *Screen, n: u16) void { 318 self.cursor.pending_wrap = false; 319 if (self.withinScrollingRegion()) 320 self.cursor.row = @max( 321 self.cursor.row -| n, 322 self.scrolling_region.top, 323 ) 324 else 325 self.cursor.row -|= n; 326} 327 328pub fn cursorLeft(self: *Screen, n: u16) void { 329 self.cursor.pending_wrap = false; 330 if (self.withinScrollingRegion()) 331 self.cursor.col = @max( 332 self.cursor.col -| n, 333 self.scrolling_region.left, 334 ) 335 else 336 self.cursor.col = self.cursor.col -| n; 337} 338 339pub fn cursorRight(self: *Screen, n: u16) void { 340 self.cursor.pending_wrap = false; 341 if (self.withinScrollingRegion()) 342 self.cursor.col = @min( 343 self.cursor.col + n, 344 self.scrolling_region.right, 345 ) 346 else 347 self.cursor.col = @min( 348 self.cursor.col + n, 349 self.width - 1, 350 ); 351} 352 353pub fn cursorDown(self: *Screen, n: usize) void { 354 self.cursor.pending_wrap = false; 355 if (self.withinScrollingRegion()) 356 self.cursor.row = @min( 357 self.scrolling_region.bottom, 358 self.cursor.row + n, 359 ) 360 else 361 self.cursor.row = @min( 362 self.height -| 1, 363 self.cursor.row + n, 364 ); 365} 366 367pub fn eraseRight(self: *Screen) void { 368 self.cursor.pending_wrap = false; 369 const end = (self.cursor.row * self.width) + (self.width); 370 var i = (self.cursor.row * self.width) + self.cursor.col; 371 while (i < end) : (i += 1) { 372 self.buf[i].erase(self.allocator, self.cursor.style.bg); 373 } 374} 375 376pub fn eraseLeft(self: *Screen) void { 377 self.cursor.pending_wrap = false; 378 const start = self.cursor.row * self.width; 379 const end = start + self.cursor.col + 1; 380 var i = start; 381 while (i < end) : (i += 1) { 382 self.buf[i].erase(self.allocator, self.cursor.style.bg); 383 } 384} 385 386pub fn eraseLine(self: *Screen) void { 387 self.cursor.pending_wrap = false; 388 const start = self.cursor.row * self.width; 389 const end = start + self.width; 390 var i = start; 391 while (i < end) : (i += 1) { 392 self.buf[i].erase(self.allocator, self.cursor.style.bg); 393 } 394} 395 396/// delete n lines from the bottom of the scrolling region 397pub fn deleteLine(self: *Screen, n: usize) !void { 398 if (n == 0) return; 399 400 // Don't delete if outside scroll region 401 if (!self.withinScrollingRegion()) return; 402 403 self.cursor.pending_wrap = false; 404 405 // Number of rows from here to bottom of scroll region or n 406 const cnt = @min(self.scrolling_region.bottom - self.cursor.row + 1, n); 407 const stride = (self.width) * cnt; 408 409 var row: usize = self.scrolling_region.top; 410 while (row <= self.scrolling_region.bottom) : (row += 1) { 411 var col: usize = self.scrolling_region.left; 412 while (col <= self.scrolling_region.right) : (col += 1) { 413 const i = (row * self.width) + col; 414 if (row + cnt > self.scrolling_region.bottom) 415 self.buf[i].erase(self.allocator, self.cursor.style.bg) 416 else 417 try self.buf[i].copyFrom(self.allocator, self.buf[i + stride]); 418 } 419 } 420} 421 422/// insert n lines at the top of the scrolling region 423pub fn insertLine(self: *Screen, n: usize) !void { 424 if (n == 0) return; 425 426 self.cursor.pending_wrap = false; 427 // Don't insert if outside scroll region 428 if (!self.withinScrollingRegion()) return; 429 430 const adjusted_n = @min(self.scrolling_region.bottom - self.cursor.row, n); 431 const stride = (self.width) * adjusted_n; 432 433 var row: usize = self.scrolling_region.bottom; 434 while (row >= self.scrolling_region.top + adjusted_n) : (row -|= 1) { 435 var col: usize = self.scrolling_region.left; 436 while (col <= self.scrolling_region.right) : (col += 1) { 437 const i = (row * self.width) + col; 438 try self.buf[i].copyFrom(self.allocator, self.buf[i - stride]); 439 } 440 } 441 442 row = self.scrolling_region.top; 443 while (row < self.scrolling_region.top + adjusted_n) : (row += 1) { 444 var col: usize = self.scrolling_region.left; 445 while (col <= self.scrolling_region.right) : (col += 1) { 446 const i = (row * self.width) + col; 447 self.buf[i].erase(self.allocator, self.cursor.style.bg); 448 } 449 } 450} 451 452pub fn eraseBelow(self: *Screen) void { 453 self.eraseRight(); 454 // start is the first column of the row below us 455 const start = (self.cursor.row * self.width) + (self.width); 456 var i = start; 457 while (i < self.buf.len) : (i += 1) { 458 self.buf[i].erase(self.allocator, self.cursor.style.bg); 459 } 460} 461 462pub fn eraseAbove(self: *Screen) void { 463 self.eraseLeft(); 464 // start is the first column of the row below us 465 const start: usize = 0; 466 const end = self.cursor.row * self.width; 467 var i = start; 468 while (i < end) : (i += 1) { 469 self.buf[i].erase(self.allocator, self.cursor.style.bg); 470 } 471} 472 473pub fn eraseAll(self: *Screen) void { 474 var i: usize = 0; 475 while (i < self.buf.len) : (i += 1) { 476 self.buf[i].erase(self.allocator, self.cursor.style.bg); 477 } 478} 479 480pub fn deleteCharacters(self: *Screen, n: usize) !void { 481 if (!self.withinScrollingRegion()) return; 482 483 self.cursor.pending_wrap = false; 484 var col = self.cursor.col; 485 while (col <= self.scrolling_region.right) : (col += 1) { 486 if (col + n <= self.scrolling_region.right) 487 try self.buf[col].copyFrom(self.allocator, self.buf[col + n]) 488 else 489 self.buf[col].erase(self.allocator, self.cursor.style.bg); 490 } 491} 492 493pub fn reverseIndex(self: *Screen) !void { 494 if (self.cursor.row != self.scrolling_region.top or 495 self.cursor.col < self.scrolling_region.left or 496 self.cursor.col > self.scrolling_region.right) 497 self.cursorUp(1) 498 else 499 try self.scrollDown(1); 500} 501 502pub fn scrollDown(self: *Screen, n: usize) !void { 503 const cur_row = self.cursor.row; 504 const cur_col = self.cursor.col; 505 const wrap = self.cursor.pending_wrap; 506 defer { 507 self.cursor.row = cur_row; 508 self.cursor.col = cur_col; 509 self.cursor.pending_wrap = wrap; 510 } 511 self.cursor.col = self.scrolling_region.left; 512 self.cursor.row = self.scrolling_region.top; 513 try self.insertLine(n); 514}