a modern tui library written in zig
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}