Change renderer from screen clones to new `RenderState` (#9662)

This adds a new API called `RenderState` that produces the proper state
required for a renderer to draw a viewport and updates our renderer to
use it instead of the prior screen clone method.

The newsworthy change is here is that we've shortened the critical area
where the renderer holds the terminal lock and blocks IO by anywhere
from **2x to 5x faster**, and in about half the frames we're now in the
critical area for **zero microseconds** because `RenderState` has much
better dirty/damage tracking.

**For libghostty Zig users**, this API is available to the Zig module
and can be used to create your own renderers (to any artifact, it
doesn't have to be graphical! It is even useful for text like a tmux
clone or something).

## Differences vs Old Method

The renderer previously called `Screen.clone`. This produces a copy of
all the screen data (even stuff the renderer may not need) and attempts
to create a standalone, fully functional `Screen` allocation. I didn't
microbenchmark this to understand exactly where the slowdown was, but I
think it was based primarily in two places:

- Screens have a minimum allocation of two Ghostty "pages" which are
quite large. I think this allocation was the primary expensive part.
Without refactoring our entire Screen/PageList work, this was
unavoidable.
- The clone was unconditional and would produce a fully new Screen on
every frame.

The new structure `RenderState` is stateful and each frame calls
`update` on the prior value and it modifies itself in place. This
addresses the above two points in a few ways:

- Allocation is minimized to only what we need, no full pages.
- Since it is stateful (updating in-place), we only change dirty data
and don't need to copy everything. **Note the benchmarks below only test
_full updates_, so you aren't even seeing the benefits of this!**
- Also since it is stateful, we cache expensive calculations (such as
for selections) so future screen updates can reuse those cached values
rather than recompute them.
- We retain memory from prior updates even when the screen is dirty, so
even in dirty states, it's unlikely we allocate.

More details in the "Design" section.

## Benchmarks

Here are some microbenchmarks on the previous render critical area
versus the new one.

For each of the benchmarks below, ignore the time units (milliseconds)
and instead **focus on the relative speedup.** The benchmarks are all
doing a full render frame setup around 1000 times, because the actual
cost of a frame update is in dozens or hundreds of microseconds.

> [!NOTE]
>
> I'm still working on some more benchmarks before merging. I'll update
this space.

### Full screen, single style

<img width="1542" height="480" alt="CleanShot 2025-11-21 at 07 35 13@2x"
src="https://github.com/user-attachments/assets/c28c9f33-d1aa-4723-8a8e-3c6d70fe3667"
/>

### Full screen, plaintext

<img width="1642" height="612" alt="CleanShot 2025-11-21 at 07 36 06@2x"
src="https://github.com/user-attachments/assets/b51f57cf-7c48-46c8-a347-8ecc0bdd3d47"
/>

### Full screen, different style per cell (pathological case)

<img width="1704" height="456" alt="CleanShot 2025-11-21 at 07 37 55@2x"
src="https://github.com/user-attachments/assets/71a98250-d8d1-47ab-ae69-5e6b3b60bf2d"
/>

### Critical Area: Typing at Shell Prompt

| This PR | Main Branch |
---------|--------
| <img width="1064" height="764" alt="CleanShot 2025-11-21 at 14 44
31@2x"
src="https://github.com/user-attachments/assets/8a0ab3a1-3d68-41f0-9469-bc08a4569286"
/> | <img width="1040" height="684" alt="CleanShot 2025-11-21 at 14 47
10@2x"
src="https://github.com/user-attachments/assets/04ffa607-8841-436b-b6e9-eeeb6ee9482d"
/> |

### Critical Area: Neovim Scrolling

| This PR | Main Branch |
---------|--------
| <img width="1054" height="748" alt="CleanShot 2025-11-21 at 14 45
06@2x"
src="https://github.com/user-attachments/assets/ccafaee8-720f-41be-820d-fd705835607a"
/> | <img width="1068" height="796" alt="CleanShot 2025-11-21 at 14 47
48@2x"
src="https://github.com/user-attachments/assets/68087496-d371-4c7c-8b4c-b967dbaeaa7c"
/> |

### Critical Area: `btop -u 100`

This is closer to a pathological case, about as close as you get with a
real tool in the wild. `btop` uses hundreds of unique styles and updates
many cells across many rows very frequently (every 100ms in this case).
You can see that some of our frame times in this case are similar but
there are _so many more near-idle frames_ thanks to our dirty tracking.

| This PR | Main Branch |
---------|--------
| <img width="1088" height="900" alt="CleanShot 2025-11-21 at 14 45
44@2x"
src="https://github.com/user-attachments/assets/ea63f0eb-f06e-4d00-95a3-c55a3755cc67"
/> | <img width="1078" height="906" alt="CleanShot 2025-11-21 at 14 48
18@2x"
src="https://github.com/user-attachments/assets/cef360de-2b12-440f-8c4c-6a69b5ce4058"
/> |

### "DOOM Fire" 

Fullscreen on my macOS when from 770 FPS to ~808 FPS consistently, a
solid 5% increase repeatedly.

<img width="3520" height="2392" alt="CleanShot 2025-11-21 at 07 45
29@2x"
src="https://github.com/user-attachments/assets/033effca-0abb-4ff8-a21b-83214d118d12"
/>


### IO 

While this was rendering focused, the smaller critical area does help IO
performance a bit.

We've already tracked down the remaining issues to the IO thread going
to sleep and overhead with context switching. We're investigating
switching to a spin lock for the IO thread only in another track of
work.

> [!NOTE]
>
> **This is comparing with `main`, which already has a 20-30%
performance improvement over v1.2.3.**

<img width="982" height="698" alt="image"
src="https://github.com/user-attachments/assets/52a86f6c-6f09-45fe-9ac7-ca62c7ac6ee4"
/>

## Design

The design of the API is a _stateful_ `RenderState` struct that you call
`update` on each frame with the target terminal, and it only updates
what is needed. RenderState keeps track of rows, cells, hyperlinks,
selections, etc.

```zig
// Start empty
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);

// Each frame update it with a terminal. 
// THIS IS THE ONLY PART THAT ACCESS `t`
try state.update(alloc, &t);

// Access render data (can be outside any locking for `t`)
...
```

The ergonomics of the `RenderState` structure a wee bit clunky because
we make use of struct-of-arrays (SoA, Zig's MultiArrayList) to better
optimize cache locality for renderer data vs. update data (what we need
to update the render state is different from what we need to draw).

Once you get used to the API though, it's pretty beautiful. I mean, look
at this:

```zig
        for (
            0..,
            row_data.items(.raw),
            row_data.items(.cells),
        ) |y, row, cells| {
            const cells_slice = cells.slice();
            for (
                0..,
                cells_slice.items(.raw),
                cells_slice.items(.grapheme),
            ) |x, cell, graphemes| {
```

## Improvements

This PR makes various improvements across the board:

- It bears repeating in case it was missed previously that the critical
area time of a render has gone down 2x to 5x when there is work and is
now free when there is no work (the previous implementation always did
work).
- Font shaping is much more efficient now and only requires access to a
render state.
- Selection handling is now cached and works with dirty tracking.
Previously, if you had an active selection, we'd search the entire
screen multiple times (like... once per row). Yikes.
- Hyperlink handling is _much_ more efficient. Instead of iterating
through the entire screen contents _per configured link_ we now cache
the screen contents as a string and search one whole string multiple
times. Obvious, but we didn't do this before.
- The `contrainedWidth` and `rowNeverExtendBg` helper methods are now
both much more efficient and live within the renderer package rather
than being awkwardly in the terminal package.

## Future Notes

- Our `terminal.Selection` API is very bad. It conceptually makes sense
and I understand why I designed it this way (easy) but it makes it hard
to render or manipulate performantly.

**AI Disclosure:** AI was used only to assist with writing some tests
and converting some tests. The primary logic is all organic, meatbag
produced.
pull/9630/merge
Mitchell Hashimoto 2025-11-22 06:36:05 -08:00 committed by GitHub
commit 6529baea46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2623 additions and 1382 deletions

View File

@ -45,6 +45,9 @@ pub const Mode = enum {
/// Full clone /// Full clone
clone, clone,
/// RenderState rather than a screen clone.
render,
}; };
pub fn create( pub fn create(
@ -75,6 +78,7 @@ pub fn benchmark(self: *ScreenClone) Benchmark {
.stepFn = switch (self.opts.mode) { .stepFn = switch (self.opts.mode) {
.noop => stepNoop, .noop => stepNoop,
.clone => stepClone, .clone => stepClone,
.render => stepRender,
}, },
.setupFn = setup, .setupFn = setup,
.teardownFn = teardown, .teardownFn = teardown,
@ -87,6 +91,13 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void {
// Always reset our terminal state // Always reset our terminal state
self.terminal.fullReset(); self.terminal.fullReset();
// Force a style on every single row, which
var s = self.terminal.vtStream();
defer s.deinit();
s.nextSlice("\x1b[48;2;20;40;60m") catch unreachable;
for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n") catch unreachable;
s.nextSlice("hello") catch unreachable;
// Setup our terminal state // Setup our terminal state
const data_f: std.fs.File = (options.dataFile( const data_f: std.fs.File = (options.dataFile(
self.opts.data, self.opts.data,
@ -153,3 +164,33 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void {
// to benchmark that. We'll free when the benchmark exits. // to benchmark that. We'll free when the benchmark exits.
} }
} }
fn stepRender(ptr: *anyopaque) Benchmark.Error!void {
const self: *ScreenClone = @ptrCast(@alignCast(ptr));
// We do this once out of the loop because a significant slowdown
// on the first run is allocation. After that first run, even with
// a full rebuild, it is much faster. Let's ignore that first run
// slowdown.
const alloc = self.terminal.screens.active.alloc;
var state: terminalpkg.RenderState = .empty;
state.update(alloc, &self.terminal) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
// We loop because its so fast that a single benchmark run doesn't
// properly capture our speeds.
for (0..1000) |_| {
// Forces a full rebuild because it thinks our screen changed
state.screen = .alternate;
state.update(alloc, &self.terminal) catch |err| {
log.warn("error cloning screen err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(state);
// Note: we purposely do not free memory because we don't want
// to benchmark that. We'll free when the benchmark exits.
}
}

View File

@ -1,3 +1,4 @@
const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const options = @import("main.zig").options; const options = @import("main.zig").options;
const run = @import("shaper/run.zig"); const run = @import("shaper/run.zig");
@ -72,17 +73,11 @@ pub const RunOptions = struct {
/// cached values may be updated during shaping. /// cached values may be updated during shaping.
grid: *SharedGrid, grid: *SharedGrid,
/// The terminal screen to shape. /// The cells for the row to shape.
screen: *const terminal.Screen, cells: std.MultiArrayList(terminal.RenderState.Cell).Slice = .empty,
/// The row within the screen to shape. This row must exist within /// The x boundaries of the selection in this row.
/// screen; it is not validated. selection: ?[2]u16 = null,
row: terminal.Pin,
/// The selection boundaries. This is used to break shaping on
/// selection boundaries. This can be disabled by setting this to
/// null.
selection: ?terminal.Selection = null,
/// The cursor position within this row. This is used to break shaping /// The cursor position within this row. This is used to break shaping
/// on cursor boundaries. This can be disabled by setting this to /// on cursor boundaries. This can be disabled by setting this to

File diff suppressed because it is too large Load Diff

View File

@ -207,16 +207,22 @@ test "run iterator" {
{ {
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("ABCD");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("ABCD");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |_| count += 1; while (try it.next(alloc)) |_| count += 1;
@ -225,15 +231,21 @@ test "run iterator" {
// Spaces should be part of a run // Spaces should be part of a run
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("ABCD EFG");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("ABCD EFG");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |_| count += 1; while (try it.next(alloc)) |_| count += 1;
@ -242,16 +254,22 @@ test "run iterator" {
{ {
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("A😃D");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("A😃D");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |_| { while (try it.next(alloc)) |_| {
@ -273,14 +291,17 @@ test "run iterator: empty cells with background set" {
{ {
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
try screen.testWriteString("A"); var s = t.vtStream();
defer s.deinit();
// Set red background and write A
try s.nextSlice("\x1b[48;2;255;0;0mA");
// Get our first row // Get our first row
{ {
const list_cell = screen.pages.getCell(.{ .active = .{ .x = 1 } }).?; const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1 } }).?;
const cell = list_cell.cell; const cell = list_cell.cell;
cell.* = .{ cell.* = .{
.content_tag = .bg_color_rgb, .content_tag = .bg_color_rgb,
@ -288,7 +309,7 @@ test "run iterator: empty cells with background set" {
}; };
} }
{ {
const list_cell = screen.pages.getCell(.{ .active = .{ .x = 2 } }).?; const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 2 } }).?;
const cell = list_cell.cell; const cell = list_cell.cell;
cell.* = .{ cell.* = .{
.content_tag = .bg_color_rgb, .content_tag = .bg_color_rgb,
@ -296,12 +317,15 @@ test "run iterator: empty cells with background set" {
}; };
} }
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
{ {
const run = (try it.next(alloc)).?; const run = (try it.next(alloc)).?;
@ -327,16 +351,22 @@ test "shape" {
buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(buf[0..buf_idx]);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -355,15 +385,21 @@ test "shape inconsolata ligs" {
defer testdata.deinit(); defer testdata.deinit();
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(">=");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -378,15 +414,21 @@ test "shape inconsolata ligs" {
} }
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("===");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("===");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -409,15 +451,21 @@ test "shape monaspace ligs" {
defer testdata.deinit(); defer testdata.deinit();
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("===");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("===");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -443,15 +491,21 @@ test "shape arabic forced LTR" {
var testdata = try testShaperWithFont(alloc, .arabic); var testdata = try testShaperWithFont(alloc, .arabic);
defer testdata.deinit(); defer testdata.deinit();
var screen = try terminal.Screen.init(alloc, .{ .cols = 120, .rows = 30, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 120, .rows = 30 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(@embedFile("testdata/arabic.txt"));
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(@embedFile("testdata/arabic.txt"));
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -478,15 +532,21 @@ test "shape emoji width" {
defer testdata.deinit(); defer testdata.deinit();
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("👍");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("👍");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -509,13 +569,13 @@ test "shape emoji width long" {
defer testdata.deinit(); defer testdata.deinit();
// Make a screen and add a long emoji sequence to it. // Make a screen and add a long emoji sequence to it.
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(
alloc, alloc,
.{ .cols = 30, .rows = 3 }, .{ .cols = 30, .rows = 3 },
); );
defer screen.deinit(); defer t.deinit(alloc);
var page = screen.pages.pages.first.?.data; var page = t.screens.active.pages.pages.first.?.data;
var row = page.getRow(1); var row = page.getRow(1);
const cell = &row.cells.ptr(page.memory)[0]; const cell = &row.cells.ptr(page.memory)[0];
cell.* = .{ cell.* = .{
@ -534,12 +594,15 @@ test "shape emoji width long" {
graphemes[0..], graphemes[0..],
); );
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(1).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -566,16 +629,22 @@ test "shape variation selector VS15" {
buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(buf[0..buf_idx]);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -601,16 +670,22 @@ test "shape variation selector VS16" {
buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(buf[0..buf_idx]);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -631,21 +706,27 @@ test "shape with empty cells in between" {
defer testdata.deinit(); defer testdata.deinit();
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(
alloc, alloc,
.{ .cols = 30, .rows = 3 }, .{ .cols = 30, .rows = 3 },
); );
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("A");
screen.cursorRight(5); var s = t.vtStream();
try screen.testWriteString("B"); defer s.deinit();
try s.nextSlice("A");
try s.nextSlice("\x1b[5C");
try s.nextSlice("B");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -672,19 +753,25 @@ test "shape Chinese characters" {
buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]); buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]);
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(
alloc, alloc,
.{ .cols = 30, .rows = 3 }, .{ .cols = 30, .rows = 3 },
); );
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(buf[0..buf_idx]);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -713,16 +800,22 @@ test "shape box glyphs" {
buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); // buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); //
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(buf[0..buf_idx]);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(buf[0..buf_idx]);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator // Get our run iterator
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -746,9 +839,16 @@ test "shape selection boundary" {
defer testdata.deinit(); defer testdata.deinit();
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("a1b2c3d4e5");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("a1b2c3d4e5");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Full line selection // Full line selection
{ {
@ -756,13 +856,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, .selection = .{ 0, 9 },
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
false,
),
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -778,13 +873,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, .selection = .{ 2, 9 },
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
false,
),
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -800,13 +890,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, .selection = .{ 0, 3 },
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
false,
),
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -822,13 +907,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, .selection = .{ 1, 3 },
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
false,
),
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -844,13 +924,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, .selection = .{ 1, 1 },
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
false,
),
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -869,9 +944,16 @@ test "shape cursor boundary" {
defer testdata.deinit(); defer testdata.deinit();
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("a1b2c3d4e5");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("a1b2c3d4e5");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// No cursor is full line // No cursor is full line
{ {
@ -879,8 +961,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -897,8 +978,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cursor_x = 0, .cursor_x = 0,
}); });
var count: usize = 0; var count: usize = 0;
@ -914,8 +994,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -933,8 +1012,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cursor_x = 1, .cursor_x = 1,
}); });
var count: usize = 0; var count: usize = 0;
@ -950,8 +1028,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -968,8 +1045,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cursor_x = 9, .cursor_x = 9,
}); });
var count: usize = 0; var count: usize = 0;
@ -985,8 +1061,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1006,12 +1081,19 @@ test "shape cursor boundary and colored emoji" {
defer testdata.deinit(); defer testdata.deinit();
// Make a screen with some data // Make a screen with some data
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(
alloc, alloc,
.{ .cols = 3, .rows = 10 }, .{ .cols = 3, .rows = 10 },
); );
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString("👍🏼");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("👍🏼");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// No cursor is full line // No cursor is full line
{ {
@ -1019,8 +1101,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1036,8 +1117,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cursor_x = 0, .cursor_x = 0,
}); });
var count: usize = 0; var count: usize = 0;
@ -1052,8 +1132,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1067,8 +1146,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cursor_x = 1, .cursor_x = 1,
}); });
var count: usize = 0; var count: usize = 0;
@ -1083,8 +1161,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1104,15 +1181,21 @@ test "shape cell attribute change" {
// Plain >= should shape into 1 run // Plain >= should shape into 1 run
{ {
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer screen.deinit(); defer t.deinit(alloc);
try screen.testWriteString(">=");
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1124,20 +1207,23 @@ test "shape cell attribute change" {
// Bold vs regular should split // Bold vs regular should split
{ {
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
alloc, defer t.deinit(alloc);
.{ .cols = 3, .rows = 10 },
); var s = t.vtStream();
defer screen.deinit(); defer s.deinit();
try screen.testWriteString(">"); try s.nextSlice(">");
try screen.setAttribute(.{ .bold = {} }); try s.nextSlice("\x1b[1m");
try screen.testWriteString("="); try s.nextSlice("=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1149,21 +1235,26 @@ test "shape cell attribute change" {
// Changing fg color should split // Changing fg color should split
{ {
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
alloc, defer t.deinit(alloc);
.{ .cols = 3, .rows = 10 },
); var s = t.vtStream();
defer screen.deinit(); defer s.deinit();
try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); // RGB 1, 2, 3
try screen.testWriteString(">"); try s.nextSlice("\x1b[38;2;1;2;3m");
try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } }); try s.nextSlice(">");
try screen.testWriteString("="); // RGB 3, 2, 1
try s.nextSlice("\x1b[38;2;3;2;1m");
try s.nextSlice("=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1175,21 +1266,26 @@ test "shape cell attribute change" {
// Changing bg color should not split // Changing bg color should not split
{ {
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
alloc, defer t.deinit(alloc);
.{ .cols = 3, .rows = 10 },
); var s = t.vtStream();
defer screen.deinit(); defer s.deinit();
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); // RGB 1, 2, 3 bg
try screen.testWriteString(">"); try s.nextSlice("\x1b[48;2;1;2;3m");
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } }); try s.nextSlice(">");
try screen.testWriteString("="); // RGB 3, 2, 1 bg
try s.nextSlice("\x1b[48;2;3;2;1m");
try s.nextSlice("=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {
@ -1201,20 +1297,24 @@ test "shape cell attribute change" {
// Same bg color should not split // Same bg color should not split
{ {
var screen = try terminal.Screen.init( var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
alloc, defer t.deinit(alloc);
.{ .cols = 3, .rows = 10 },
); var s = t.vtStream();
defer screen.deinit(); defer s.deinit();
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); // RGB 1, 2, 3 bg
try screen.testWriteString(">"); try s.nextSlice("\x1b[48;2;1;2;3m");
try screen.testWriteString("="); try s.nextSlice(">");
try s.nextSlice("=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper; var shaper = &testdata.shaper;
var it = shaper.runIterator(.{ var it = shaper.runIterator(.{
.grid = testdata.grid, .grid = testdata.grid,
.screen = &screen, .cells = state.row_data.get(0).cells.slice(),
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
}); });
var count: usize = 0; var count: usize = 0;
while (try it.next(alloc)) |run| { while (try it.next(alloc)) |run| {

View File

@ -45,7 +45,10 @@ pub const RunIterator = struct {
i: usize = 0, i: usize = 0,
pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun {
const cells = self.opts.row.cells(.all); const slice = &self.opts.cells;
const cells: []const terminal.page.Cell = slice.items(.raw);
const graphemes: []const []const u21 = slice.items(.grapheme);
const styles: []const terminal.Style = slice.items(.style);
// Trim the right side of a row that might be empty // Trim the right side of a row that might be empty
const max: usize = max: { const max: usize = max: {
@ -60,10 +63,8 @@ pub const RunIterator = struct {
// Invisible cells don't have any glyphs rendered, // Invisible cells don't have any glyphs rendered,
// so we explicitly skip them in the shaping process. // so we explicitly skip them in the shaping process.
while (self.i < max and while (self.i < max and
self.opts.row.style(&cells[self.i]).flags.invisible) (cells[self.i].hasStyling() and
{ styles[self.i].flags.invisible)) self.i += 1;
self.i += 1;
}
// We're over at the max // We're over at the max
if (self.i >= max) return null; if (self.i >= max) return null;
@ -78,7 +79,7 @@ pub const RunIterator = struct {
var hasher = Hasher.init(0); var hasher = Hasher.init(0);
// Let's get our style that we'll expect for the run. // Let's get our style that we'll expect for the run.
const style = self.opts.row.style(&cells[self.i]); const style: terminal.Style = if (cells[self.i].hasStyling()) styles[self.i] else .{};
// Go through cell by cell and accumulate while we build our run. // Go through cell by cell and accumulate while we build our run.
var j: usize = self.i; var j: usize = self.i;
@ -88,21 +89,14 @@ pub const RunIterator = struct {
// with identical content but different starting positions in the // with identical content but different starting positions in the
// row produce the same hash, enabling cache reuse. // row produce the same hash, enabling cache reuse.
const cluster = j - self.i; const cluster = j - self.i;
const cell = &cells[j]; const cell: *const terminal.page.Cell = &cells[j];
// If we have a selection and we're at a boundary point, then // If we have a selection and we're at a boundary point, then
// we break the run here. // we break the run here.
if (self.opts.selection) |unordered_sel| { if (self.opts.selection) |bounds| {
if (j > self.i) { if (j > self.i) {
const sel = unordered_sel.ordered(self.opts.screen, .forward); if (bounds[0] > 0 and j == bounds[0]) break;
const start_x = sel.start().x; if (bounds[1] > 0 and j == bounds[1] + 1) break;
const end_x = sel.end().x;
if (start_x > 0 and
j == start_x) break;
if (end_x > 0 and
j == end_x + 1) break;
} }
} }
@ -148,7 +142,7 @@ pub const RunIterator = struct {
// The style is different. We allow differing background // The style is different. We allow differing background
// styles but any other change results in a new run. // styles but any other change results in a new run.
const c1 = comparableStyle(style); const c1 = comparableStyle(style);
const c2 = comparableStyle(self.opts.row.style(&cells[j])); const c2 = comparableStyle(if (cell.hasStyling()) styles[j] else .{});
if (!c1.eql(c2)) break; if (!c1.eql(c2)) break;
} }
@ -168,7 +162,7 @@ pub const RunIterator = struct {
const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: {
// We only check the FIRST codepoint because I believe the // We only check the FIRST codepoint because I believe the
// presentation format must be directly adjacent to the codepoint. // presentation format must be directly adjacent to the codepoint.
const cps = self.opts.row.grapheme(cell) orelse break :p null; const cps = graphemes[j];
assert(cps.len > 0); assert(cps.len > 0);
if (cps[0] == 0xFE0E) break :p .text; if (cps[0] == 0xFE0E) break :p .text;
if (cps[0] == 0xFE0F) break :p .emoji; if (cps[0] == 0xFE0F) break :p .emoji;
@ -227,6 +221,7 @@ pub const RunIterator = struct {
if (try self.indexForCell( if (try self.indexForCell(
alloc, alloc,
cell, cell,
graphemes[j],
font_style, font_style,
presentation, presentation,
)) |idx| break :font_info .{ .idx = idx }; )) |idx| break :font_info .{ .idx = idx };
@ -279,8 +274,7 @@ pub const RunIterator = struct {
@intCast(cluster), @intCast(cluster),
); );
if (cell.hasGrapheme()) { if (cell.hasGrapheme()) {
const cps = self.opts.row.grapheme(cell).?; for (graphemes[j]) |cp| {
for (cps) |cp| {
// Do not send presentation modifiers // Do not send presentation modifiers
if (cp == 0xFE0E or cp == 0xFE0F) continue; if (cp == 0xFE0E or cp == 0xFE0F) continue;
try self.addCodepoint(&hasher, cp, @intCast(cluster)); try self.addCodepoint(&hasher, cp, @intCast(cluster));
@ -300,7 +294,7 @@ pub const RunIterator = struct {
// Move our cursor. Must defer since we use self.i below. // Move our cursor. Must defer since we use self.i below.
defer self.i = j; defer self.i = j;
return TextRun{ return .{
.hash = hasher.final(), .hash = hasher.final(),
.offset = @intCast(self.i), .offset = @intCast(self.i),
.cells = @intCast(j - self.i), .cells = @intCast(j - self.i),
@ -324,7 +318,8 @@ pub const RunIterator = struct {
fn indexForCell( fn indexForCell(
self: *RunIterator, self: *RunIterator,
alloc: Allocator, alloc: Allocator,
cell: *terminal.Cell, cell: *const terminal.Cell,
graphemes: []const u21,
style: font.Style, style: font.Style,
presentation: ?font.Presentation, presentation: ?font.Presentation,
) !?font.Collection.Index { ) !?font.Collection.Index {
@ -355,12 +350,14 @@ pub const RunIterator = struct {
// If this is a grapheme, we need to find a font that supports // If this is a grapheme, we need to find a font that supports
// all of the codepoints in the grapheme. // all of the codepoints in the grapheme.
const cps = self.opts.row.grapheme(cell) orelse return primary; var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(
var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(alloc, cps.len + 1); alloc,
graphemes.len + 1,
);
defer candidates.deinit(alloc); defer candidates.deinit(alloc);
candidates.appendAssumeCapacity(primary); candidates.appendAssumeCapacity(primary);
for (cps) |cp| { for (graphemes) |cp| {
// Ignore Emoji ZWJs // Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue;
@ -383,7 +380,7 @@ pub const RunIterator = struct {
// We need to find a candidate that has ALL of our codepoints // We need to find a candidate that has ALL of our codepoints
for (candidates.items) |idx| { for (candidates.items) |idx| {
if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue;
for (cps) |cp| { for (graphemes) |cp| {
// Ignore Emoji ZWJs // Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue;
if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; if (!self.opts.grid.hasCodepoint(idx, cp, null)) break;

View File

@ -47,6 +47,7 @@ pub const PageList = terminal.PageList;
pub const Parser = terminal.Parser; pub const Parser = terminal.Parser;
pub const Pin = PageList.Pin; pub const Pin = PageList.Pin;
pub const Point = point.Point; pub const Point = point.Point;
pub const RenderState = terminal.RenderState;
pub const Screen = terminal.Screen; pub const Screen = terminal.Screen;
pub const ScreenSet = terminal.ScreenSet; pub const ScreenSet = terminal.ScreenSet;
pub const Selection = terminal.Selection; pub const Selection = terminal.Selection;

View File

@ -255,8 +255,12 @@ pub fn isSymbol(cp: u21) bool {
/// Returns the appropriate `constraint_width` for /// Returns the appropriate `constraint_width` for
/// the provided cell when rendering its glyph(s). /// the provided cell when rendering its glyph(s).
pub fn constraintWidth(cell_pin: terminal.Pin) u2 { pub fn constraintWidth(
const cell = cell_pin.rowAndCell().cell; raw_slice: []const terminal.page.Cell,
x: usize,
cols: usize,
) u2 {
const cell = raw_slice[x];
const cp = cell.codepoint(); const cp = cell.codepoint();
const grid_width = cell.gridWidth(); const grid_width = cell.gridWidth();
@ -271,20 +275,14 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
if (!isSymbol(cp)) return grid_width; if (!isSymbol(cp)) return grid_width;
// If we are at the end of the screen it must be constrained to one cell. // 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 // 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. // to also constrain. This is so that multiple PUA glyphs align.
// This does not apply if the previous symbol is a graphics // This does not apply if the previous symbol is a graphics
// element such as a block element or Powerline glyph. // element such as a block element or Powerline glyph.
if (cell_pin.x > 0) { if (x > 0) {
const prev_cp = prev_cp: { const prev_cp = raw_slice[x - 1].codepoint();
var copy = cell_pin;
copy.x -= 1;
const prev_cell = copy.rowAndCell().cell;
break :prev_cp prev_cell.codepoint();
};
if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) { if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) {
return 1; return 1;
} }
@ -292,15 +290,8 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
// If the next cell is whitespace, then we // If the next cell is whitespace, then we
// allow the glyph to be up to two cells wide. // allow the glyph to be up to two cells wide.
const next_cp = next_cp: { const next_cp = raw_slice[x + 1].codepoint();
var copy = cell_pin; if (next_cp == 0 or isSpace(next_cp)) return 2;
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;
}
// Otherwise, this has to be 1 cell wide. // Otherwise, this has to be 1 cell wide.
return 1; return 1;
@ -524,108 +515,171 @@ test "Cell constraint widths" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; 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(); defer s.deinit();
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
// for each case, the numbers in the comment denote expected // for each case, the numbers in the comment denote expected
// constraint widths for the symbol-containing cells // constraint widths for the symbol-containing cells
// symbol->nothing: 2 // symbol->nothing: 2
{ {
try s.testWriteString(""); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("");
try testing.expectEqual(2, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(2, constraintWidth(
state.row_data.get(0).cells.items(.raw),
0,
state.cols,
));
} }
// symbol->character: 1 // symbol->character: 1
{ {
try s.testWriteString("z"); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("z");
try testing.expectEqual(1, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(1, constraintWidth(
state.row_data.get(0).cells.items(.raw),
0,
state.cols,
));
} }
// symbol->space: 2 // symbol->space: 2
{ {
try s.testWriteString(" z"); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice(" z");
try testing.expectEqual(2, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(2, constraintWidth(
state.row_data.get(0).cells.items(.raw),
0,
state.cols,
));
} }
// symbol->no-break space: 1 // symbol->no-break space: 1
{ {
try s.testWriteString("\u{00a0}z"); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("\u{00a0}z");
try testing.expectEqual(1, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(1, constraintWidth(
state.row_data.get(0).cells.items(.raw),
0,
state.cols,
));
} }
// symbol->end of row: 1 // symbol->end of row: 1
{ {
try s.testWriteString(""); t.fullReset();
const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; try s.nextSlice("");
try testing.expectEqual(1, constraintWidth(p3)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(1, constraintWidth(
state.row_data.get(0).cells.items(.raw),
3,
state.cols,
));
} }
// character->symbol: 2 // character->symbol: 2
{ {
try s.testWriteString("z"); t.fullReset();
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; try s.nextSlice("z");
try testing.expectEqual(2, constraintWidth(p1)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(2, constraintWidth(
state.row_data.get(0).cells.items(.raw),
1,
state.cols,
));
} }
// symbol->symbol: 1,1 // symbol->symbol: 1,1
{ {
try s.testWriteString(""); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("");
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; try state.update(alloc, &t);
try testing.expectEqual(1, constraintWidth(p0)); try testing.expectEqual(1, constraintWidth(
try testing.expectEqual(1, constraintWidth(p1)); state.row_data.get(0).cells.items(.raw),
s.reset(); 0,
state.cols,
));
try testing.expectEqual(1, constraintWidth(
state.row_data.get(0).cells.items(.raw),
1,
state.cols,
));
} }
// symbol->space->symbol: 2,2 // symbol->space->symbol: 2,2
{ {
try s.testWriteString(" "); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice(" ");
const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; try state.update(alloc, &t);
try testing.expectEqual(2, constraintWidth(p0)); try testing.expectEqual(2, constraintWidth(
try testing.expectEqual(2, constraintWidth(p2)); state.row_data.get(0).cells.items(.raw),
s.reset(); 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) // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg)
{ {
try s.testWriteString(""); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("");
try testing.expectEqual(1, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); 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) // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg)
{ {
try s.testWriteString(""); t.fullReset();
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; try s.nextSlice("");
try testing.expectEqual(2, constraintWidth(p1)); try state.update(alloc, &t);
s.reset(); 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) // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg)
{ {
try s.testWriteString(""); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice("");
try testing.expectEqual(2, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); 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) // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg)
{ {
try s.testWriteString(" z"); t.fullReset();
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; try s.nextSlice(" z");
try testing.expectEqual(2, constraintWidth(p0)); try state.update(alloc, &t);
s.reset(); try testing.expectEqual(2, constraintWidth(
state.row_data.get(0).cells.items(.raw),
0,
state.cols,
));
} }
} }

View File

@ -5,6 +5,7 @@ const wuffs = @import("wuffs");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
const font = @import("../font/main.zig"); const font = @import("../font/main.zig");
const inputpkg = @import("../input.zig");
const os = @import("../os/main.zig"); const os = @import("../os/main.zig");
const terminal = @import("../terminal/main.zig"); const terminal = @import("../terminal/main.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
@ -15,6 +16,7 @@ const cellpkg = @import("cell.zig");
const noMinContrast = cellpkg.noMinContrast; const noMinContrast = cellpkg.noMinContrast;
const constraintWidth = cellpkg.constraintWidth; const constraintWidth = cellpkg.constraintWidth;
const isCovering = cellpkg.isCovering; const isCovering = cellpkg.isCovering;
const rowNeverExtendBg = @import("row.zig").neverExtendBg;
const imagepkg = @import("image.zig"); const imagepkg = @import("image.zig");
const Image = imagepkg.Image; const Image = imagepkg.Image;
const ImageMap = imagepkg.ImageMap; const ImageMap = imagepkg.ImageMap;
@ -125,12 +127,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// cells goes into a separate shader. /// cells goes into a separate shader.
cells: cellpkg.Contents, 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 /// Set to true after rebuildCells is called. This can be used
/// to determine if any possible changes have been made to the /// to determine if any possible changes have been made to the
/// cells for the draw call. /// cells for the draw call.
@ -207,6 +203,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Our shader pipelines. /// Our shader pipelines.
shaders: Shaders, shaders: Shaders,
/// The render state we update per loop.
terminal_state: terminal.RenderState = .empty,
/// The number of frames since the last terminal state reset.
/// We reset the terminal state after ~100,000 frames (about 10 to
/// 15 minutes at 120Hz) to prevent wasted memory buildup from
/// a large screen.
terminal_state_frame_count: usize = 0,
/// Swap chain which maintains multiple copies of the state needed to /// Swap chain which maintains multiple copies of the state needed to
/// render a frame, so that we can start building the next frame while /// render a frame, so that we can start building the next frame while
/// the previous frame is still being processed on the GPU. /// the previous frame is still being processed on the GPU.
@ -738,6 +743,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.terminal_state.deinit(self.alloc);
self.swap_chain.deinit(); self.swap_chain.deinit();
if (DisplayLink != void) { if (DisplayLink != void) {
@ -935,8 +942,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
} }
/// Mark the full screen as dirty so that we redraw everything. /// Mark the full screen as dirty so that we redraw everything.
pub fn markDirty(self: *Self) void { pub inline fn markDirty(self: *Self) void {
self.cells_viewport = null; self.terminal_state.dirty = .full;
} }
/// Called when we get an updated display ID for our display link. /// Called when we get an updated display ID for our display link.
@ -1042,7 +1049,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Force a full rebuild, because cached rows may still reference // Force a full rebuild, because cached rows may still reference
// an outdated atlas from the old grid and this can cause garbage // an outdated atlas from the old grid and this can cause garbage
// to be rendered. // to be rendered.
self.cells_viewport = null; self.markDirty();
} }
/// Update uniforms that are based on the font grid. /// Update uniforms that are based on the font grid.
@ -1061,21 +1068,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state: *renderer.State, state: *renderer.State,
cursor_blink_visible: bool, cursor_blink_visible: bool,
) !void { ) !void {
// We fully deinit and reset the terminal state every so often
// so that a particularly large terminal state doesn't cause
// the renderer to hold on to retained memory.
//
// Frame count is ~12 minutes at 120Hz.
const max_terminal_state_frame_count = 100_000;
if (self.terminal_state_frame_count >= max_terminal_state_frame_count) {
self.terminal_state.deinit(self.alloc);
self.terminal_state = .empty;
}
self.terminal_state_frame_count += 1;
// Create an arena for all our temporary allocations while rebuilding
var arena = ArenaAllocator.init(self.alloc);
defer arena.deinit();
const arena_alloc = arena.allocator();
// Data we extract out of the critical area. // Data we extract out of the critical area.
const Critical = struct { const Critical = struct {
bg: terminal.color.RGB, links: terminal.RenderState.CellSet,
fg: terminal.color.RGB,
screen: terminal.Screen,
screen_type: terminal.ScreenSet.Key,
mouse: renderer.State.Mouse, mouse: renderer.State.Mouse,
preedit: ?renderer.State.Preedit, preedit: ?renderer.State.Preedit,
cursor_color: ?terminal.color.RGB,
cursor_style: ?renderer.CursorStyle, cursor_style: ?renderer.CursorStyle,
color_palette: terminal.color.Palette,
scrollbar: terminal.Scrollbar, scrollbar: terminal.Scrollbar,
/// If true, rebuild the full screen.
full_rebuild: bool,
}; };
// Update all our data as tightly as possible within the mutex. // Update all our data as tightly as possible within the mutex.
@ -1096,6 +1112,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return; return;
} }
// Update our terminal state
try self.terminal_state.update(self.alloc, state.terminal);
// Get our scrollbar out of the terminal. We synchronize // Get our scrollbar out of the terminal. We synchronize
// the scrollbar read with frame data updates because this // the scrollbar read with frame data updates because this
// naturally limits the number of calls to this method (it // naturally limits the number of calls to this method (it
@ -1103,30 +1122,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// cross-thread mailbox message within the IO path. // cross-thread mailbox message within the IO path.
const scrollbar = state.terminal.screens.active.pages.scrollbar(); const scrollbar = state.terminal.screens.active.pages.scrollbar();
// Get our bg/fg, swap them if reversed.
const RGB = terminal.color.RGB;
const bg: RGB, const fg: RGB = colors: {
const bg = state.terminal.colors.background.get().?;
const fg = state.terminal.colors.foreground.get().?;
break :colors if (state.terminal.modes.get(.reverse_colors))
.{ fg, bg }
else
.{ 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. // Whether to draw our cursor or not.
const cursor_style = if (state.terminal.flags.password_input) const cursor_style = if (state.terminal.flags.password_input)
.lock .lock
@ -1141,9 +1136,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const preedit: ?renderer.State.Preedit = preedit: { const preedit: ?renderer.State.Preedit = preedit: {
if (cursor_style == null) break :preedit null; if (cursor_style == null) break :preedit null;
const p = state.preedit orelse break :preedit null; const p = state.preedit orelse break :preedit null;
break :preedit try p.clone(self.alloc); break :preedit try p.clone(arena_alloc);
}; };
errdefer if (preedit) |p| p.deinit(self.alloc);
// If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
// We only do this if the Kitty image state is dirty meaning only if // We only do this if the Kitty image state is dirty meaning only if
@ -1158,83 +1152,51 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
try self.prepKittyGraphics(state.terminal); try self.prepKittyGraphics(state.terminal);
} }
// If we have any terminal dirty flags set then we need to rebuild // Get our OSC8 links we're hovering if we have a mouse.
// the entire screen. This can be optimized in the future. // This requires terminal state because of URLs.
const full_rebuild: bool = rebuild: { const links: terminal.RenderState.CellSet = osc8: {
{ // If our mouse isn't hovering, we have no links.
const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?; const vp = state.mouse.point orelse break :osc8 .empty;
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 // If the right mods aren't pressed, then we can't match.
// screen because it means we scrolled. If we have no previous if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{})))
// viewport then we must rebuild. break :osc8 .empty;
const prev_viewport = self.cells_viewport orelse break :rebuild true;
if (!prev_viewport.eql(viewport_pin)) break :rebuild true;
break :rebuild false; break :osc8 self.terminal_state.linkCells(
arena_alloc,
vp,
) catch |err| {
log.warn("error searching for OSC8 links err={}", .{err});
break :osc8 .empty;
};
}; };
// 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 .{ break :critical .{
.bg = bg, .links = links,
.fg = fg,
.screen = screen_copy,
.screen_type = state.terminal.screens.active_key,
.mouse = state.mouse, .mouse = state.mouse,
.preedit = preedit, .preedit = preedit,
.cursor_color = state.terminal.colors.cursor.get(),
.cursor_style = cursor_style, .cursor_style = cursor_style,
.color_palette = state.terminal.colors.palette.current,
.scrollbar = scrollbar, .scrollbar = scrollbar,
.full_rebuild = full_rebuild,
}; };
}; };
defer {
critical.screen.deinit(); // Outside the critical area we can update our links to contain
if (critical.preedit) |p| p.deinit(self.alloc); // our regex results.
} self.config.links.renderCellMap(
arena_alloc,
&critical.links,
&self.terminal_state,
state.mouse.point,
state.mouse.mods,
) catch |err| {
log.warn("error searching for regex links err={}", .{err});
};
// Build our GPU cells // Build our GPU cells
try self.rebuildCells( try self.rebuildCells(
critical.full_rebuild,
&critical.screen,
critical.screen_type,
critical.mouse,
critical.preedit, critical.preedit,
critical.cursor_style, critical.cursor_style,
&critical.color_palette, &critical.links,
critical.bg,
critical.fg,
critical.cursor_color,
); );
// Notify our shaper we're done for the frame. For some shapers, // Notify our shaper we're done for the frame. For some shapers,
@ -1256,9 +1218,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Update our background color // Update our background color
self.uniforms.bg_color = .{ self.uniforms.bg_color = .{
critical.bg.r, self.terminal_state.colors.background.r,
critical.bg.g, self.terminal_state.colors.background.g,
critical.bg.b, self.terminal_state.colors.background.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)), @intFromFloat(@round(self.config.background_opacity * 255.0)),
}; };
} }
@ -2090,7 +2052,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (bg_image_config_changed) self.updateBgImageBuffer(); if (bg_image_config_changed) self.updateBgImageBuffer();
// Reset our viewport to force a rebuild, in case of a font change. // 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; const blending_changed = old_blending != config.blending;
@ -2316,17 +2278,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// memory and doesn't touch the GPU. /// memory and doesn't touch the GPU.
fn rebuildCells( fn rebuildCells(
self: *Self, self: *Self,
wants_rebuild: bool,
screen: *terminal.Screen,
screen_type: terminal.ScreenSet.Key,
mouse: renderer.State.Mouse,
preedit: ?renderer.State.Preedit, preedit: ?renderer.State.Preedit,
cursor_style_: ?renderer.CursorStyle, cursor_style_: ?renderer.CursorStyle,
color_palette: *const terminal.color.Palette, links: *const terminal.RenderState.CellSet,
background: terminal.color.RGB,
foreground: terminal.color.RGB,
terminal_cursor_color: ?terminal.color.RGB,
) !void { ) !void {
const state: *terminal.RenderState = &self.terminal_state;
defer state.dirty = .false;
self.draw_mutex.lock(); self.draw_mutex.lock();
defer self.draw_mutex.unlock(); defer self.draw_mutex.unlock();
@ -2338,21 +2296,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // 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();
// 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 .{};
// Determine our x/y range for preedit. We don't want to render anything // Determine our x/y range for preedit. We don't want to render anything
// here because we will render the preedit separately. // here because we will render the preedit separately.
const preedit_range: ?struct { const preedit_range: ?struct {
@ -2360,22 +2303,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
x: [2]terminal.size.CellCountInt, x: [2]terminal.size.CellCountInt,
cp_offset: usize, cp_offset: usize,
} = if (preedit) |preedit_v| preedit: { } = 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 .{ break :preedit .{
.y = screen.cursor.y, .y = @intCast(cursor_vp.y),
.x = .{ range.start, range.end }, .x = .{ range.start, range.end },
.cp_offset = range.cp_offset, .cp_offset = range.cp_offset,
}; };
} else null; } else null;
const grid_size_diff = const grid_size_diff =
self.cells.size.rows != screen.pages.rows or self.cells.size.rows != state.rows or
self.cells.size.columns != screen.pages.cols; self.cells.size.columns != state.cols;
if (grid_size_diff) { if (grid_size_diff) {
var new_size = self.cells.size; var new_size = self.cells.size;
new_size.rows = screen.pages.rows; new_size.rows = state.rows;
new_size.columns = screen.pages.cols; new_size.columns = state.cols;
try self.cells.resize(self.alloc, new_size); try self.cells.resize(self.alloc, new_size);
// Update our uniforms accordingly, otherwise // Update our uniforms accordingly, otherwise
@ -2383,8 +2335,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; self.uniforms.grid_size = .{ new_size.columns, new_size.rows };
} }
const rebuild = wants_rebuild or grid_size_diff; const rebuild = state.dirty == .full or grid_size_diff;
if (rebuild) { if (rebuild) {
// If we are doing a full rebuild, then we clear the entire cell buffer. // If we are doing a full rebuild, then we clear the entire cell buffer.
self.cells.reset(); self.cells.reset();
@ -2406,45 +2357,47 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
} }
} }
// We rebuild the cells row-by-row because we // Get our row data from our state
// do font shaping and dirty tracking by row. const row_data = state.row_data.slice();
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); const row_raws = row_data.items(.raw);
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, // If our cell contents buffer is shorter than the screen viewport,
// we render the rows that fit, starting from the bottom. If instead // we render the rows that fit, starting from the bottom. If instead
// the viewport is shorter than the cell contents buffer, we align // the viewport is shorter than the cell contents buffer, we align
// the top of the viewport with the top of the contents buffer. // the top of the viewport with the top of the contents buffer.
var y: terminal.size.CellCountInt = @min( const row_len: usize = @min(
screen.pages.rows, state.rows,
self.cells.size.rows, self.cells.size.rows,
); );
while (row_it.next()) |row| { for (
// The viewport may have more rows than our cell contents, 0..,
// so we need to break from the loop early if we hit y = 0. row_raws[0..row_len],
if (y == 0) break; row_cells[0..row_len],
row_dirty[0..row_len],
y -= 1; row_selection[0..row_len],
) |y_usize, row, *cells, *dirty, selection| {
const y: terminal.size.CellCountInt = @intCast(y_usize);
if (!rebuild) { if (!rebuild) {
// Only rebuild if we are doing a full rebuild or this row is dirty. // 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 // Clear the cells if the row is dirty
self.cells.clear(y); self.cells.clear(y);
} }
// True if we want to do font shaping around the cursor. // Unmark the dirty state in our render state.
// We want to do font shaping as long as the cursor is enabled. dirty.* = false;
const shape_cursor = screen.viewportIsBottom() and
y == screen.cursor.y;
// We need to get this row's selection, if // If our viewport is wider than our cell contents buffer,
// there is one, for proper run splitting. // we still only process cells up to the width of the buffer.
const row_selection = sel: { const cells_slice = cells.slice();
const sel = screen.selection orelse break :sel null; const cells_len = @min(cells_slice.len, self.cells.size.columns);
const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse const cells_raw = cells_slice.items(.raw);
break :sel null; const cells_style = cells_slice.items(.style);
break :sel sel.containedRow(screen, pin) orelse null;
};
// On primary screen, we still apply vertical padding // On primary screen, we still apply vertical padding
// extension under certain conditions we feel are safe. // extension under certain conditions we feel are safe.
@ -2457,14 +2410,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Apply heuristics for padding extension. // Apply heuristics for padding extension.
.extend => if (y == 0) { .extend => if (y == 0) {
self.uniforms.padding_extend.up = !row.neverExtendBg( self.uniforms.padding_extend.up = !rowNeverExtendBg(
color_palette, row,
background, cells_raw,
cells_style,
&state.colors.palette,
state.colors.background,
); );
} else if (y == self.cells.size.rows - 1) { } else if (y == self.cells.size.rows - 1) {
self.uniforms.padding_extend.down = !row.neverExtendBg( self.uniforms.padding_extend.down = !rowNeverExtendBg(
color_palette, row,
background, cells_raw,
cells_style,
&state.colors.palette,
state.colors.background,
); );
}, },
} }
@ -2472,10 +2431,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Iterator of runs for shaping. // Iterator of runs for shaping.
var run_iter_opts: font.shape.RunOptions = .{ var run_iter_opts: font.shape.RunOptions = .{
.grid = self.font_grid, .grid = self.font_grid,
.screen = screen, .cells = cells_slice,
.row = row, .selection = if (selection) |s| s else null,
.selection = row_selection,
.cursor_x = if (shape_cursor) screen.cursor.x 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;
},
}; };
run_iter_opts.applyBreakConfig(self.config.font_shaping_break); run_iter_opts.applyBreakConfig(self.config.font_shaping_break);
var run_iter = self.font_shaper.runIterator(run_iter_opts); var run_iter = self.font_shaper.runIterator(run_iter_opts);
@ -2483,13 +2448,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells: ?[]const font.shape.Cell = null;
var shaper_cells_i: usize = 0; var shaper_cells_i: usize = 0;
const row_cells_all = row.cells(.all); for (
0..,
// If our viewport is wider than our cell contents buffer, cells_raw[0..cells_len],
// we still only process cells up to the width of the buffer. cells_style[0..cells_len],
const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; ) |x, *cell, *managed_style| {
for (row_cells, 0..) |*cell, x| {
// If this cell falls within our preedit range then we // If this cell falls within our preedit range then we
// skip this because preedits are setup separately. // skip this because preedits are setup separately.
if (preedit_range) |range| preedit: { if (preedit_range) |range| preedit: {
@ -2522,7 +2485,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.get(run) orelse self.font_shaper_cache.get(run) orelse
cache: { cache: {
// Otherwise we have to shape them. // 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 // Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization, // continue because it is just a performance optimization,
@ -2530,7 +2493,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.put( self.font_shaper_cache.put(
self.alloc, self.alloc,
run, run,
cells, new_cells,
) catch |err| { ) catch |err| {
log.warn( log.warn(
"error caching font shaping results err={}", "error caching font shaping results err={}",
@ -2541,52 +2504,45 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// The cells we get from direct shaping are always owned // The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so // by the shaper and valid until the next shaping call so
// we can safely use them. // we can safely use them.
break :cache cells; break :cache new_cells;
}; };
const cells = shaper_cells.?;
// Advance our index until we reach or pass // Advance our index until we reach or pass
// our current x position in the shaper cells. // 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; shaper_cells_i += 1;
} }
} }
const wide = cell.wide; const wide = cell.wide;
const style: terminal.Style = if (cell.hasStyling())
const style = row.style(cell); managed_style.*
else
const cell_pin: terminal.Pin = cell: { .{};
var copy = row;
copy.x = @intCast(x);
break :cell copy;
};
// True if this cell is selected // True if this cell is selected
const selected: bool = if (screen.selection) |sel| const selected: bool = selected: {
sel.contains(screen, .{ const sel = selection orelse break :selected false;
.node = row.node, const x_compare = if (wide == .spacer_tail)
.y = row.y, x -| 1
.x = @intCast( else
// Spacer tails should show the selection x;
// state of the wide cell they belong to.
if (wide == .spacer_tail) break :selected x_compare >= sel[0] and
x -| 1 x_compare <= sel[1];
else };
x,
),
})
else
false;
// The `_style` suffixed values are the colors based on // The `_style` suffixed values are the colors based on
// the cell style (SGR), before applying any additional // the cell style (SGR), before applying any additional
// configuration, inversions, selections, etc. // configuration, inversions, selections, etc.
const bg_style = style.bg(cell, color_palette); const bg_style = style.bg(
cell,
&state.colors.palette,
);
const fg_style = style.fg(.{ const fg_style = style.fg(.{
.default = foreground, .default = state.colors.foreground,
.palette = color_palette, .palette = &state.colors.palette,
.bold = self.config.bold_color, .bold = self.config.bold_color,
}); });
@ -2605,7 +2561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If no configuration, then our selection background // If no configuration, then our selection background
// is our foreground color. // is our foreground color.
break :bg foreground; break :bg state.colors.foreground;
} }
// Not selected // Not selected
@ -2627,7 +2583,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const fg = fg: { const fg = fg: {
// Our happy-path non-selection background color // Our happy-path non-selection background color
// is our style or our configured defaults. // is our style or our configured defaults.
const final_bg = bg_style orelse background; const final_bg = bg_style orelse state.colors.background;
// Whether we need to use the bg color as our fg color: // Whether we need to use the bg color as our fg color:
// - Cell is selected, inverted, and set to cell-foreground // - Cell is selected, inverted, and set to cell-foreground
@ -2643,7 +2599,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}; };
} }
break :fg background; break :fg state.colors.background;
} }
break :fg if (style.flags.inverse) break :fg if (style.flags.inverse)
@ -2657,7 +2613,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Set the cell's background color. // Set the cell's background color.
{ {
const rgb = bg orelse background; const rgb = bg orelse state.colors.background;
// Determine our background alpha. If we have transparency configured // Determine our background alpha. If we have transparency configured
// then this is dynamic depending on some situations. This is all // then this is dynamic depending on some situations. This is all
@ -2708,13 +2664,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Give links a single underline, unless they already have // Give links a single underline, unless they already have
// an underline, in which case use a double underline to // an underline, in which case use a double underline to
// distinguish them. // distinguish them.
const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin)) const underline: terminal.Attribute.Underline = underline: {
if (style.flags.underline == .single) if (links.contains(.{
.double .x = @intCast(x),
else .y = @intCast(y),
.single })) {
else break :underline if (style.flags.underline == .single)
style.flags.underline; .double
else
.single;
}
break :underline style.flags.underline;
};
// We draw underlines first so that they layer underneath text. // We draw underlines first so that they layer underneath text.
// This improves readability when a colored underline is used // This improves readability when a colored underline is used
@ -2723,7 +2684,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
@intCast(x), @intCast(x),
@intCast(y), @intCast(y),
underline, underline,
style.underlineColor(color_palette) orelse fg, style.underlineColor(&state.colors.palette) orelse fg,
alpha, alpha,
) catch |err| { ) catch |err| {
log.warn( log.warn(
@ -2754,7 +2715,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.get(run) orelse self.font_shaper_cache.get(run) orelse
cache: { cache: {
// Otherwise we have to shape them. // 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 // Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization, // continue because it is just a performance optimization,
@ -2762,7 +2723,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.put( self.font_shaper_cache.put(
self.alloc, self.alloc,
run, run,
cells, new_cells,
) catch |err| { ) catch |err| {
log.warn( log.warn(
"error caching font shaping results err={}", "error caching font shaping results err={}",
@ -2773,32 +2734,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// The cells we get from direct shaping are always owned // The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so // by the shaper and valid until the next shaping call so
// we can safely use them. // 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. // If there are no shaper cells for this run, ignore it.
// This can occur for runs of empty cells, and is fine. // 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 // If we encounter a shaper cell to the left of the current
// cell then we have some problems. This logic relies on x // cell then we have some problems. This logic relies on x
// position monotonically increasing. // 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 // NOTE: An assumption is made here that a single cell will never
// be present in more than one shaper run. If that assumption is // be present in more than one shaper run. If that assumption is
// violated, this logic breaks. // 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; shaper_cells_i += 1;
}) { }) {
self.addGlyph( self.addGlyph(
@intCast(x), @intCast(x),
@intCast(y), @intCast(y),
cell_pin, state.cols,
cells[shaper_cells_i], cells_raw,
shaped_cells[shaper_cells_i],
shaper_run.?, shaper_run.?,
fg, fg,
alpha, alpha,
@ -2842,7 +2805,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const style = cursor_style_ orelse break :cursor; const style = cursor_style_ orelse break :cursor;
const cursor_color = cursor_color: { const cursor_color = cursor_color: {
// If an explicit cursor color was set by OSC 12, use that. // If an explicit cursor color was set by OSC 12, use that.
if (terminal_cursor_color) |v| break :cursor_color v; if (state.colors.cursor) |v| break :cursor_color v;
// Use our configured color if specified // Use our configured color if specified
if (self.config.cursor_color) |v| switch (v) { if (self.config.cursor_color) |v| switch (v) {
@ -2850,16 +2813,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
inline .@"cell-foreground", inline .@"cell-foreground",
.@"cell-background", .@"cell-background",
=> |_, tag| { => |_, tag| {
const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); const sty: terminal.Style = state.cursor.style;
const fg_style = sty.fg(.{ const fg_style = sty.fg(.{
.default = foreground, .default = state.colors.foreground,
.palette = color_palette, .palette = &state.colors.palette,
.bold = self.config.bold_color, .bold = self.config.bold_color,
}); });
const bg_style = sty.bg( const bg_style = sty.bg(
screen.cursor.page_cell, &state.cursor.cell,
color_palette, &state.colors.palette,
) orelse background; ) orelse state.colors.background;
break :cursor_color switch (tag) { break :cursor_color switch (tag) {
.color => unreachable, .color => unreachable,
@ -2869,24 +2832,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}, },
}; };
break :cursor_color foreground; break :cursor_color state.colors.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 the cursor is visible then we set our uniforms.
if (style == .block and screen.viewportIsBottom()) { if (style == .block) cursor_uniforms: {
const wide = screen.cursor.page_cell.wide; const cursor_vp = state.cursor.viewport orelse
break :cursor_uniforms;
const wide = state.cursor.cell.wide;
self.uniforms.cursor_pos = .{ self.uniforms.cursor_pos = .{
// If we are a spacer tail of a wide cell, our cursor needs // 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 // to move back one cell. The saturate is to ensure we don't
// overflow but this shouldn't happen with well-formed input. // overflow but this shouldn't happen with well-formed input.
switch (wide) { switch (wide) {
.narrow, .spacer_head, .wide => screen.cursor.x, .narrow, .spacer_head, .wide => cursor_vp.x,
.spacer_tail => screen.cursor.x -| 1, .spacer_tail => cursor_vp.x -| 1,
}, },
screen.cursor.y, @intCast(cursor_vp.y),
}; };
self.uniforms.bools.cursor_wide = switch (wide) { self.uniforms.bools.cursor_wide = switch (wide) {
@ -2902,16 +2871,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
break :blk txt.color.toTerminalRGB(); 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(.{ const fg_style = sty.fg(.{
.default = foreground, .default = state.colors.foreground,
.palette = color_palette, .palette = &state.colors.palette,
.bold = self.config.bold_color, .bold = self.config.bold_color,
}); });
const bg_style = sty.bg( const bg_style = sty.bg(
screen.cursor.page_cell, &state.cursor.cell,
color_palette, &state.colors.palette,
) orelse background; ) orelse state.colors.background;
break :blk switch (txt) { break :blk switch (txt) {
// If the cell is reversed, use the opposite cell color instead. // If the cell is reversed, use the opposite cell color instead.
@ -2919,7 +2888,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style,
else => unreachable, else => unreachable,
}; };
} else background; } else state.colors.background;
self.uniforms.cursor_color = .{ self.uniforms.cursor_color = .{
uniform_color.r, uniform_color.r,
@ -2938,8 +2907,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.addPreeditCell( self.addPreeditCell(
cp, cp,
.{ .x = x, .y = range.y }, .{ .x = x, .y = range.y },
background, state.colors.background,
foreground, state.colors.foreground,
) catch |err| { ) catch |err| {
log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
x, x,
@ -3069,15 +3038,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self, self: *Self,
x: terminal.size.CellCountInt, x: terminal.size.CellCountInt,
y: 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_cell: font.shape.Cell,
shaper_run: font.shape.TextRun, shaper_run: font.shape.TextRun,
color: terminal.color.RGB, color: terminal.color.RGB,
alpha: u8, alpha: u8,
) !void { ) !void {
const rac = cell_pin.rowAndCell(); const cell = cell_raws[x];
const cell = rac.cell;
const cp = cell.codepoint(); const cp = cell.codepoint();
// Render // Render
@ -3097,7 +3065,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (cellpkg.isSymbol(cp)) .{ if (cellpkg.isSymbol(cp)) .{
.size = .fit, .size = .fit,
} else .none, } else .none,
.constraint_width = constraintWidth(cell_pin), .constraint_width = constraintWidth(
cell_raws,
x,
cols,
),
}, },
); );
@ -3126,22 +3098,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
fn addCursor( fn addCursor(
self: *Self, self: *Self,
screen: *terminal.Screen, cursor_state: *const terminal.RenderState.Cursor,
cursor_style: renderer.CursorStyle, cursor_style: renderer.CursorStyle,
cursor_color: terminal.color.RGB, cursor_color: terminal.color.RGB,
) void { ) void {
const cursor_vp = cursor_state.viewport orelse return;
// Add the cursor. We render the cursor over the wide character if // Add the cursor. We render the cursor over the wide character if
// we're on the wide character tail. // we're on the wide character tail.
const wide, const x = cell: { const wide, const x = cell: {
// The cursor goes over the screen cursor position. // The cursor goes over the screen cursor position.
const cell = screen.cursor.page_cell; if (!cursor_vp.wide_tail) break :cell .{
if (cell.wide != .spacer_tail or screen.cursor.x == 0) cursor_state.cell.wide == .wide,
break :cell .{ cell.wide == .wide, screen.cursor.x }; cursor_vp.x,
};
// If we're part of a wide character, we move the cursor back to // If we're part of a wide character, we move the cursor back
// the actual character. // to the actual character.
const prev_cell = screen.cursorCellLeft(1); break :cell .{ true, cursor_vp.x - 1 };
break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 };
}; };
const alpha: u8 = if (!self.focused) 255 else alpha: { const alpha: u8 = if (!self.focused) 255 else alpha: {
@ -3200,7 +3174,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.cells.setCursor(.{ self.cells.setCursor(.{
.atlas = .grayscale, .atlas = .grayscale,
.bools = .{ .is_cursor_glyph = true }, .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 }, .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height }, .glyph_size = .{ render.glyph.width, render.glyph.height },

View File

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const oni = @import("oniguruma"); const oni = @import("oniguruma");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -54,354 +55,105 @@ pub const Set = struct {
alloc.free(self.links); alloc.free(self.links);
} }
/// Returns the matchset for the viewport state. The matchset is the
/// full set of matching links for the visible viewport. A link
/// only matches if it is also in the correct state (i.e. hovered
/// if necessary).
///
/// This is not a particularly efficient operation. This should be
/// called sparingly.
pub fn matchSet(
self: *const Set,
alloc: Allocator,
screen: *Screen,
mouse_vp_pt: point.Coordinate,
mouse_mods: inputpkg.Mods,
) !MatchSet {
// Convert the viewport point to a screen point.
const mouse_pin = screen.pages.pin(.{
.viewport = mouse_vp_pt,
}) orelse return .{};
// This contains our list of matches. The matches are stored
// as selections which contain the start and end points of
// the match. There is no way to map these back to the link
// configuration right now because we don't need to.
var matches: std.ArrayList(terminal.Selection) = .empty;
defer matches.deinit(alloc);
// If our mouse is over an OSC8 link, then we can skip the regex
// matches below since OSC8 takes priority.
try self.matchSetFromOSC8(
alloc,
&matches,
screen,
mouse_pin,
mouse_mods,
);
// If we have no matches then we can try the regex matches.
if (matches.items.len == 0) {
try self.matchSetFromLinks(
alloc,
&matches,
screen,
mouse_pin,
mouse_mods,
);
}
return .{ .matches = try matches.toOwnedSlice(alloc) };
}
fn matchSetFromOSC8(
self: *const Set,
alloc: Allocator,
matches: *std.ArrayList(terminal.Selection),
screen: *Screen,
mouse_pin: terminal.Pin,
mouse_mods: inputpkg.Mods,
) !void {
// If the right mods aren't pressed, then we can't match.
if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return;
// Check if the cell the mouse is over is an OSC8 hyperlink
const mouse_cell = mouse_pin.rowAndCell().cell;
if (!mouse_cell.hyperlink) return;
// Get our hyperlink entry
const page: *terminal.Page = &mouse_pin.node.data;
const link_id = page.lookupHyperlink(mouse_cell) orelse {
log.warn("failed to find hyperlink for cell", .{});
return;
};
const link = page.hyperlink_set.get(page.memory, link_id);
// If our link has an implicit ID (no ID set explicitly via OSC8)
// then we use an alternate matching technique that iterates forward
// and backward until it finds boundaries.
if (link.id == .implicit) {
const uri = link.uri.slice(page.memory);
return try self.matchSetFromOSC8Implicit(
alloc,
matches,
mouse_pin,
uri,
);
}
// Go through every row and find matching hyperlinks for the given ID.
// Note the link ID is not the same as the OSC8 ID parameter. But
// we hash hyperlinks by their contents which should achieve the same
// thing so we can use the ID as a key.
var current: ?terminal.Selection = null;
var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null);
while (row_it.next()) |row_pin| {
const row = row_pin.rowAndCell().row;
// If the row doesn't have any hyperlinks then we're done
// building our matching selection.
if (!row.hyperlink) {
if (current) |sel| {
try matches.append(alloc, sel);
current = null;
}
continue;
}
// We have hyperlinks, look for our own matching hyperlink.
for (row_pin.cells(.right), 0..) |*cell, x| {
const match = match: {
if (cell.hyperlink) {
if (row_pin.node.data.lookupHyperlink(cell)) |cell_link_id| {
break :match cell_link_id == link_id;
}
}
break :match false;
};
// If we have a match, extend our selection or start a new
// selection.
if (match) {
const cell_pin = row_pin.right(x);
if (current) |*sel| {
sel.endPtr().* = cell_pin;
} else {
current = .init(
cell_pin,
cell_pin,
false,
);
}
continue;
}
// No match, if we have a current selection then complete it.
if (current) |sel| {
try matches.append(alloc, sel);
current = null;
}
}
}
}
/// Match OSC8 links around the mouse pin for an OSC8 link with an
/// implicit ID. This only matches cells with the same URI directly
/// around the mouse pin.
fn matchSetFromOSC8Implicit(
self: *const Set,
alloc: Allocator,
matches: *std.ArrayList(terminal.Selection),
mouse_pin: terminal.Pin,
uri: []const u8,
) !void {
_ = self;
// Our selection starts with just our pin.
var sel = terminal.Selection.init(mouse_pin, mouse_pin, false);
// Expand it to the left.
var it = mouse_pin.cellIterator(.left_up, null);
while (it.next()) |cell_pin| {
const page: *terminal.Page = &cell_pin.node.data;
const rac = cell_pin.rowAndCell();
const cell = rac.cell;
// If this cell isn't a hyperlink then we've found a boundary
if (!cell.hyperlink) break;
const link_id = page.lookupHyperlink(cell) orelse {
log.warn("failed to find hyperlink for cell", .{});
break;
};
const link = page.hyperlink_set.get(page.memory, link_id);
// If this link has an explicit ID then we found a boundary
if (link.id != .implicit) break;
// If this link has a different URI then we found a boundary
const cell_uri = link.uri.slice(page.memory);
if (!std.mem.eql(u8, uri, cell_uri)) break;
sel.startPtr().* = cell_pin;
}
// Expand it to the right
it = mouse_pin.cellIterator(.right_down, null);
while (it.next()) |cell_pin| {
const page: *terminal.Page = &cell_pin.node.data;
const rac = cell_pin.rowAndCell();
const cell = rac.cell;
// If this cell isn't a hyperlink then we've found a boundary
if (!cell.hyperlink) break;
const link_id = page.lookupHyperlink(cell) orelse {
log.warn("failed to find hyperlink for cell", .{});
break;
};
const link = page.hyperlink_set.get(page.memory, link_id);
// If this link has an explicit ID then we found a boundary
if (link.id != .implicit) break;
// If this link has a different URI then we found a boundary
const cell_uri = link.uri.slice(page.memory);
if (!std.mem.eql(u8, uri, cell_uri)) break;
sel.endPtr().* = cell_pin;
}
try matches.append(alloc, sel);
}
/// Fills matches with the matches from regex link matches. /// Fills matches with the matches from regex link matches.
fn matchSetFromLinks( pub fn renderCellMap(
self: *const Set, self: *const Set,
alloc: Allocator, alloc: Allocator,
matches: *std.ArrayList(terminal.Selection), result: *terminal.RenderState.CellSet,
screen: *Screen, render_state: *const terminal.RenderState,
mouse_pin: terminal.Pin, mouse_viewport: ?point.Coordinate,
mouse_mods: inputpkg.Mods, mouse_mods: inputpkg.Mods,
) !void { ) !void {
// Iterate over all the visible lines. // Fast path, not very likely since we have default links.
var lineIter = screen.lineIterator(screen.pages.pin(.{ if (self.links.len == 0) return;
.viewport = .{},
}) orelse return); // Convert our render state to a string + byte map.
while (lineIter.next()) |line_sel| { var builder: std.Io.Writer.Allocating = .init(alloc);
const strmap: terminal.StringMap = strmap: { defer builder.deinit();
var strmap: terminal.StringMap = undefined; var map: terminal.RenderState.StringMap = .empty;
const str = screen.selectionString(alloc, .{ defer map.deinit(alloc);
.sel = line_sel, try render_state.string(&builder.writer, .{
.trim = false, .alloc = alloc,
.map = &strmap, .map = &map,
}) catch |err| { });
log.warn(
"failed to build string map for link checking err={}", const str = builder.writer.buffered();
.{err},
); // Go through each link and see if we have any matches.
continue; for (self.links) |*link| {
}; // Determine if our highlight conditions are met. We use a
alloc.free(str); // switch here instead of an if so that we can get a compile
break :strmap strmap; // error if any other conditions are added.
}; switch (link.highlight) {
defer strmap.deinit(alloc); .always => {},
.always_mods => |v| if (!mouse_mods.equal(v)) continue,
// We check the hover points later.
.hover => if (mouse_viewport == null) continue,
.hover_mods => |v| {
if (mouse_viewport == null) continue;
if (!mouse_mods.equal(v)) continue;
},
}
var offset: usize = 0;
while (offset < str.len) {
var region = link.regex.search(
str[offset..],
.{},
) catch |err| switch (err) {
error.Mismatch => break,
else => return err,
};
defer region.deinit();
// We have a match!
const offset_start: usize = @intCast(region.starts()[0]);
const offset_end: usize = @intCast(region.ends()[0]);
const start = offset + offset_start;
const end = offset + offset_end;
// Increment our offset by the number of bytes in the match.
// We defer this so that we can return the match before
// modifying the offset.
defer offset = end;
// Go through each link and see if we have any matches.
for (self.links) |link| {
// Determine if our highlight conditions are met. We use a
// switch here instead of an if so that we can get a compile
// error if any other conditions are added.
switch (link.highlight) { switch (link.highlight) {
.always => {}, .always, .always_mods => {},
.always_mods => |v| if (!mouse_mods.equal(v)) continue, .hover, .hover_mods => if (mouse_viewport) |vp| {
inline .hover, .hover_mods => |v, tag| { for (map.items[start..end]) |pt| {
if (!line_sel.contains(screen, mouse_pin)) continue; if (pt.eql(vp)) break;
if (comptime tag == .hover_mods) { } else continue;
if (!mouse_mods.equal(v)) continue; } else continue,
}
},
} }
var it = strmap.searchIterator(link.regex); // Record the match
while (true) { for (map.items[start..end]) |pt| {
const match_ = it.next() catch |err| { try result.put(alloc, pt, {});
log.warn("failed to search for link err={}", .{err});
break;
};
var match = match_ orelse break;
defer match.deinit();
const sel = match.selection();
// If this is a highlight link then we only want to
// include matches that include our hover point.
switch (link.highlight) {
.always, .always_mods => {},
.hover,
.hover_mods,
=> if (!sel.contains(screen, mouse_pin)) continue,
}
try matches.append(alloc, sel);
} }
} }
} }
} }
}; };
/// MatchSet is the result of matching links against a screen. This contains test "renderCellMap" {
/// all the matching links and operations on them such as whether a specific
/// cell is part of a matched link.
pub const MatchSet = struct {
/// The matches.
///
/// Important: this must be in left-to-right top-to-bottom order.
matches: []const terminal.Selection = &.{},
i: usize = 0,
pub fn deinit(self: *MatchSet, alloc: Allocator) void {
alloc.free(self.matches);
}
/// Checks if the matchset contains the given pin. This is slower than
/// orderedContains but is stateless and more flexible since it doesn't
/// require the points to be in order.
pub fn contains(
self: *MatchSet,
screen: *const Screen,
pin: terminal.Pin,
) bool {
for (self.matches) |sel| {
if (sel.contains(screen, pin)) return true;
}
return false;
}
/// Checks if the matchset contains the given pt. The points must be
/// given in left-to-right top-to-bottom order. This is a stateful
/// operation and giving a point out of order can cause invalid
/// results.
pub fn orderedContains(
self: *MatchSet,
screen: *const Screen,
pin: terminal.Pin,
) bool {
// If we're beyond the end of our possible matches, we're done.
if (self.i >= self.matches.len) return false;
// If our selection ends before the point, then no point will ever
// again match this selection so we move on to the next one.
while (self.matches[self.i].end().before(pin)) {
self.i += 1;
if (self.i >= self.matches.len) return false;
}
return self.matches[self.i].contains(screen, pin);
}
};
test "matchset" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
// Initialize our screen var t: terminal.Terminal = try .init(alloc, .{
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); .cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit(); defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL"; const str = "1ABCD2EFGH\r\n3IJKL";
try s.testWriteString(str); try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set // Get a set
var set = try Set.fromConfig(alloc, &.{ var set = try Set.fromConfig(alloc, &.{
@ -420,46 +172,41 @@ test "matchset" {
defer set.deinit(alloc); defer set.deinit(alloc);
// Get our matches // Get our matches
var match = try set.matchSet(alloc, &s, .{}, .{}); var result: terminal.RenderState.CellSet = .empty;
defer match.deinit(alloc); defer result.deinit(alloc);
try testing.expectEqual(@as(usize, 2), match.matches.len); try set.renderCellMap(
alloc,
// Test our matches &result,
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ &state,
.x = 0, null,
.y = 0, .{},
} }).?)); );
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
.x = 1, try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
.y = 0, try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
} }).?)); try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
.x = 2, try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 3,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 1,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 2,
} }).?));
} }
test "matchset hover links" { test "renderCellMap hover links" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
// Initialize our screen var t: terminal.Terminal = try .init(alloc, .{
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); .cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit(); defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL"; const str = "1ABCD2EFGH\r\n3IJKL";
try s.testWriteString(str); try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set // Get a set
var set = try Set.fromConfig(alloc, &.{ var set = try Set.fromConfig(alloc, &.{
@ -479,80 +226,65 @@ test "matchset hover links" {
// Not hovering over the first link // Not hovering over the first link
{ {
var match = try set.matchSet(alloc, &s, .{}, .{}); var result: terminal.RenderState.CellSet = .empty;
defer match.deinit(alloc); defer result.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len); try set.renderCellMap(
alloc,
&result,
&state,
null,
.{},
);
// Test our matches // Test our matches
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
.x = 0, try testing.expect(!result.contains(.{ .x = 1, .y = 0 }));
.y = 0, try testing.expect(!result.contains(.{ .x = 2, .y = 0 }));
} }).?)); try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
.x = 1, try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 2,
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 3,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 1,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 2,
} }).?));
} }
// Hovering over the first link // Hovering over the first link
{ {
var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); var result: terminal.RenderState.CellSet = .empty;
defer match.deinit(alloc); defer result.deinit(alloc);
try testing.expectEqual(@as(usize, 2), match.matches.len); try set.renderCellMap(
alloc,
&result,
&state,
.{ .x = 1, .y = 0 },
.{},
);
// Test our matches // Test our matches
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
.x = 0, try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
.y = 0, try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
} }).?)); try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
.x = 1, try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 2,
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 3,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 1,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 2,
} }).?));
} }
} }
test "matchset mods no match" { test "renderCellMap mods no match" {
const testing = std.testing; const testing = std.testing;
const alloc = testing.allocator; const alloc = testing.allocator;
// Initialize our screen var t: terminal.Terminal = try .init(alloc, .{
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); .cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit(); defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL"; const str = "1ABCD2EFGH\r\n3IJKL";
try s.testWriteString(str); try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set // Get a set
var set = try Set.fromConfig(alloc, &.{ var set = try Set.fromConfig(alloc, &.{
@ -571,96 +303,21 @@ test "matchset mods no match" {
defer set.deinit(alloc); defer set.deinit(alloc);
// Get our matches // Get our matches
var match = try set.matchSet(alloc, &s, .{}, .{}); var result: terminal.RenderState.CellSet = .empty;
defer match.deinit(alloc); defer result.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len); try set.renderCellMap(
// Test our matches
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 0,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 2,
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 3,
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 1,
} }).?));
try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{
.x = 1,
.y = 2,
} }).?));
}
test "matchset osc8" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our terminal
var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
const s: *terminal.Screen = t.screens.active;
try t.printString("ABC");
try t.screens.active.startHyperlink("http://example.com", null);
try t.printString("123");
t.screens.active.endHyperlink();
// Get a set
var set = try Set.fromConfig(alloc, &.{});
defer set.deinit(alloc);
// No matches over the non-link
{
var match = try set.matchSet(
alloc,
t.screens.active,
.{ .x = 2, .y = 0 },
inputpkg.ctrlOrSuper(.{}),
);
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 0), match.matches.len);
}
// Match over link
var match = try set.matchSet(
alloc, alloc,
t.screens.active, &result,
.{ .x = 3, .y = 0 }, &state,
inputpkg.ctrlOrSuper(.{}), null,
.{},
); );
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len);
// Test our matches // Test our matches
try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
.x = 2, try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
.y = 0, try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
} }).?)); try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ try testing.expect(!result.contains(.{ .x = 1, .y = 1 }));
.x = 3, try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
.y = 0,
} }).?));
try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{
.x = 4,
.y = 0,
} }).?));
try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{
.x = 5,
.y = 0,
} }).?));
try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{
.x = 6,
.y = 0,
} }).?));
} }

64
src/renderer/row.zig Normal file
View File

@ -0,0 +1,64 @@
const std = @import("std");
const terminal = @import("../terminal/main.zig");
// TODO: Test neverExtendBg function
/// Returns true if the row of this pin should never have its background
/// color extended for filling padding space in the renderer. This is
/// a set of heuristics that help making our padding look better.
pub fn neverExtendBg(
row: terminal.page.Row,
cells: []const terminal.page.Cell,
styles: []const terminal.Style,
palette: *const terminal.color.Palette,
default_background: terminal.color.RGB,
) bool {
// Any semantic prompts should not have their background extended
// because prompts often contain special formatting (such as
// powerline) that looks bad when extended.
switch (row.semantic_prompt) {
.prompt, .prompt_continuation, .input => return true,
.unknown, .command => {},
}
for (0.., cells) |x, *cell| {
// If any cell has a default background color then we don't
// extend because the default background color probably looks
// good enough as an extension.
switch (cell.content_tag) {
// If it is a background color cell, we check the color.
.bg_color_palette, .bg_color_rgb => {
const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{};
const bg = s.bg(cell, palette) orelse return true;
if (bg.eql(default_background)) return true;
},
// If its a codepoint cell we can check the style.
.codepoint, .codepoint_grapheme => {
// For codepoint containing, we also never extend bg
// if any cell has a powerline glyph because these are
// perfect-fit.
switch (cell.codepoint()) {
// Powerline
0xE0B0...0xE0C8,
0xE0CA,
0xE0CC...0xE0D2,
0xE0D4,
=> return true,
else => {},
}
// Never extend a cell that has a default background.
// A default background is applied if there is no background
// on the style or the explicitly set background
// matches our default background.
const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{};
const bg = s.bg(cell, palette) orelse return true;
if (bg.eql(default_background)) return true;
},
}
}
return false;
}

View File

@ -3977,65 +3977,6 @@ pub const Pin = struct {
self.rowAndCell().row.dirty = true; self.rowAndCell().row.dirty = true;
} }
/// Returns true if the row of this pin should never have its background
/// color extended for filling padding space in the renderer. This is
/// a set of heuristics that help making our padding look better.
pub fn neverExtendBg(
self: Pin,
palette: *const color.Palette,
default_background: color.RGB,
) bool {
// Any semantic prompts should not have their background extended
// because prompts often contain special formatting (such as
// powerline) that looks bad when extended.
const rac = self.rowAndCell();
switch (rac.row.semantic_prompt) {
.prompt, .prompt_continuation, .input => return true,
.unknown, .command => {},
}
for (self.cells(.all)) |*cell| {
// If any cell has a default background color then we don't
// extend because the default background color probably looks
// good enough as an extension.
switch (cell.content_tag) {
// If it is a background color cell, we check the color.
.bg_color_palette, .bg_color_rgb => {
const s = self.style(cell);
const bg = s.bg(cell, palette) orelse return true;
if (bg.eql(default_background)) return true;
},
// If its a codepoint cell we can check the style.
.codepoint, .codepoint_grapheme => {
// For codepoint containing, we also never extend bg
// if any cell has a powerline glyph because these are
// perfect-fit.
switch (cell.codepoint()) {
// Powerline
0xE0B0...0xE0C8,
0xE0CA,
0xE0CC...0xE0D2,
0xE0D4,
=> return true,
else => {},
}
// Never extend a cell that has a default background.
// A default background is applied if there is no background
// on the style or the explicitly set background
// matches our default background.
const s = self.style(cell);
const bg = s.bg(cell, palette) orelse return true;
if (bg.eql(default_background)) return true;
},
}
}
return false;
}
/// Iterators. These are the same as PageList iterator funcs but operate /// Iterators. These are the same as PageList iterator funcs but operate
/// on pins rather than points. This is MUCH more efficient than calling /// on pins rather than points. This is MUCH more efficient than calling
/// pointFromPin and building up the iterator from points. /// pointFromPin and building up the iterator from points.

View File

@ -88,7 +88,7 @@ pub const Dirty = packed struct {
/// The cursor position and style. /// The cursor position and style.
pub const Cursor = struct { pub const Cursor = struct {
// The x/y position within the viewport. // The x/y position within the active area.
x: size.CellCountInt = 0, x: size.CellCountInt = 0,
y: size.CellCountInt = 0, y: size.CellCountInt = 0,

View File

@ -280,23 +280,60 @@ pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool {
/// Get a selection for a single row in the screen. This will return null /// Get a selection for a single row in the screen. This will return null
/// if the row is not included in the selection. /// if the row is not included in the selection.
///
/// This is a very expensive operation. It has to traverse the linked list
/// of pages for the top-left, bottom-right, and the given pin to find
/// the coordinates. If you are calling this repeatedly, prefer
/// `containedRowCached`.
pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection { pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
const tl_pin = self.topLeft(s); const tl_pin = self.topLeft(s);
const br_pin = self.bottomRight(s); const br_pin = self.bottomRight(s);
// This is definitely not very efficient. Low-hanging fruit to // This is definitely not very efficient. Low-hanging fruit to
// improve this. // improve this. Callers should prefer containedRowCached if they
// can swing it.
const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen;
const br = s.pages.pointFromPin(.screen, br_pin).?.screen; const br = s.pages.pointFromPin(.screen, br_pin).?.screen;
const p = s.pages.pointFromPin(.screen, pin).?.screen; const p = s.pages.pointFromPin(.screen, pin).?.screen;
return self.containedRowCached(
s,
tl_pin,
br_pin,
pin,
tl,
br,
p,
);
}
/// Same as containedRow but useful if you're calling it repeatedly
/// so that the pins can be cached across calls. Advanced.
pub fn containedRowCached(
self: Selection,
s: *const Screen,
tl_pin: Pin,
br_pin: Pin,
pin: Pin,
tl: point.Coordinate,
br: point.Coordinate,
p: point.Coordinate,
) ?Selection {
if (p.y < tl.y or p.y > br.y) return null; if (p.y < tl.y or p.y > br.y) return null;
// Rectangle case: we can return early as the x range will always be the // Rectangle case: we can return early as the x range will always be the
// same. We've already validated that the row is in the selection. // same. We've already validated that the row is in the selection.
if (self.rectangle) return init( if (self.rectangle) return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?, start: {
s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?, var copy: Pin = pin;
copy.x = tl.x;
break :start copy;
},
end: {
var copy: Pin = pin;
copy.x = br.x;
break :end copy;
},
true, true,
); );
@ -309,7 +346,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
// Selection top-left line matches only. // Selection top-left line matches only.
return init( return init(
tl_pin, tl_pin,
s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, end: {
var copy: Pin = pin;
copy.x = s.pages.cols - 1;
break :end copy;
},
false, false,
); );
} }
@ -320,7 +361,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
if (p.y == br.y) { if (p.y == br.y) {
assert(p.y != tl.y); assert(p.y != tl.y);
return init( return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, start: {
var copy: Pin = pin;
copy.x = 0;
break :start copy;
},
br_pin, br_pin,
false, false,
); );
@ -328,8 +373,16 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
// Row is somewhere between our selection lines so we return the full line. // Row is somewhere between our selection lines so we return the full line.
return init( return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?, start: {
s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?, var copy: Pin = pin;
copy.x = 0;
break :start copy;
},
end: {
var copy: Pin = pin;
copy.x = s.pages.cols - 1;
break :end copy;
},
false, false,
); );
} }

View File

@ -1417,10 +1417,10 @@ pub fn scrollUp(self: *Terminal, count: usize) void {
/// Options for scrolling the viewport of the terminal grid. /// Options for scrolling the viewport of the terminal grid.
pub const ScrollViewport = union(enum) { pub const ScrollViewport = union(enum) {
/// Scroll to the top of the scrollback /// Scroll to the top of the scrollback
top: void, top,
/// Scroll to the bottom, i.e. the top of the active area /// Scroll to the bottom, i.e. the top of the active area
bottom: void, bottom,
/// Scroll by some delta amount, up is negative. /// Scroll by some delta amount, up is negative.
delta: isize, delta: isize,

View File

@ -5,6 +5,7 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig"); const ansi = @import("ansi.zig");
const csi = @import("csi.zig"); const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig"); const hyperlink = @import("hyperlink.zig");
const render = @import("render.zig");
const stream_readonly = @import("stream_readonly.zig"); const stream_readonly = @import("stream_readonly.zig");
const style = @import("style.zig"); const style = @import("style.zig");
pub const apc = @import("apc.zig"); pub const apc = @import("apc.zig");
@ -40,6 +41,7 @@ pub const Pin = PageList.Pin;
pub const Point = point.Point; pub const Point = point.Point;
pub const ReadonlyHandler = stream_readonly.Handler; pub const ReadonlyHandler = stream_readonly.Handler;
pub const ReadonlyStream = stream_readonly.Stream; pub const ReadonlyStream = stream_readonly.Stream;
pub const RenderState = render.RenderState;
pub const Screen = @import("Screen.zig"); pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig"); pub const ScreenSet = @import("ScreenSet.zig");
pub const Scrollbar = PageList.Scrollbar; pub const Scrollbar = PageList.Scrollbar;

View File

@ -1800,8 +1800,9 @@ pub const Row = packed struct(u64) {
/// Returns true if this row has any managed memory outside of the /// Returns true if this row has any managed memory outside of the
/// row structure (graphemes, styles, etc.) /// row structure (graphemes, styles, etc.)
inline fn managedMemory(self: Row) bool { pub inline fn managedMemory(self: Row) bool {
return self.grapheme or self.styled or self.hyperlink; // Ordered on purpose for likelihood.
return self.styled or self.hyperlink or self.grapheme;
} }
}; };

1203
src/terminal/render.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,18 @@
//! Search functionality for the terminal. //! Search functionality for the terminal.
pub const options = @import("terminal_options");
pub const Active = @import("search/active.zig").ActiveSearch; pub const Active = @import("search/active.zig").ActiveSearch;
pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch;
pub const Screen = @import("search/screen.zig").ScreenSearch; pub const Screen = @import("search/screen.zig").ScreenSearch;
pub const Viewport = @import("search/viewport.zig").ViewportSearch; pub const Viewport = @import("search/viewport.zig").ViewportSearch;
pub const Thread = @import("search/Thread.zig");
// The search thread is not available in libghostty due to the xev dep
// for now.
pub const Thread = switch (options.artifact) {
.ghostty => @import("search/Thread.zig"),
.lib => void,
};
test { test {
@import("std").testing.refAllDecls(@This()); @import("std").testing.refAllDecls(@This());