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
clone,
/// RenderState rather than a screen clone.
render,
};
pub fn create(
@ -75,6 +78,7 @@ pub fn benchmark(self: *ScreenClone) Benchmark {
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.clone => stepClone,
.render => stepRender,
},
.setupFn = setup,
.teardownFn = teardown,
@ -87,6 +91,13 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void {
// Always reset our terminal state
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
const data_f: std.fs.File = (options.dataFile(
self.opts.data,
@ -153,3 +164,33 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void {
// 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 options = @import("main.zig").options;
const run = @import("shaper/run.zig");
@ -72,17 +73,11 @@ pub const RunOptions = struct {
/// cached values may be updated during shaping.
grid: *SharedGrid,
/// The terminal screen to shape.
screen: *const terminal.Screen,
/// The cells for the row to shape.
cells: std.MultiArrayList(terminal.RenderState.Cell).Slice = .empty,
/// The row within the screen to shape. This row must exist within
/// screen; it is not validated.
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 x boundaries of the selection in this row.
selection: ?[2]u16 = null,
/// The cursor position within this row. This is used to break shaping
/// 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
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("ABCD");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |_| count += 1;
@ -225,15 +231,21 @@ test "run iterator" {
// Spaces should be part of a run
{
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("ABCD EFG");
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |_| count += 1;
@ -242,16 +254,22 @@ test "run iterator" {
{
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("A😃D");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |_| {
@ -273,14 +291,17 @@ test "run iterator: empty cells with background set" {
{
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
try screen.testWriteString("A");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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
{
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;
cell.* = .{
.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;
cell.* = .{
.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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
{
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
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -355,15 +385,21 @@ test "shape inconsolata ligs" {
defer testdata.deinit();
{
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(">=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
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 });
defer screen.deinit();
try screen.testWriteString("===");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -409,15 +451,21 @@ test "shape monaspace ligs" {
defer testdata.deinit();
{
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("===");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -443,15 +491,21 @@ test "shape arabic forced LTR" {
var testdata = try testShaperWithFont(alloc, .arabic);
defer testdata.deinit();
var screen = try terminal.Screen.init(alloc, .{ .cols = 120, .rows = 30, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(@embedFile("testdata/arabic.txt"));
var t = try terminal.Terminal.init(alloc, .{ .cols = 120, .rows = 30 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -478,15 +532,21 @@ test "shape emoji width" {
defer testdata.deinit();
{
var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("👍");
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -509,13 +569,13 @@ test "shape emoji width long" {
defer testdata.deinit();
// Make a screen and add a long emoji sequence to it.
var screen = try terminal.Screen.init(
var t = try terminal.Terminal.init(
alloc,
.{ .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);
const cell = &row.cells.ptr(page.memory)[0];
cell.* = .{
@ -534,12 +594,15 @@ test "shape emoji width long" {
graphemes[0..],
);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?,
.cells = state.row_data.get(1).cells.slice(),
});
var count: usize = 0;
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
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
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
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -631,21 +706,27 @@ test "shape with empty cells in between" {
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(
var t = try terminal.Terminal.init(
alloc,
.{ .cols = 30, .rows = 3 },
);
defer screen.deinit();
try screen.testWriteString("A");
screen.cursorRight(5);
try screen.testWriteString("B");
defer t.deinit(alloc);
var s = t.vtStream();
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -672,19 +753,25 @@ test "shape Chinese characters" {
buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]);
// Make a screen with some data
var screen = try terminal.Screen.init(
var t = try terminal.Terminal.init(
alloc,
.{ .cols = 30, .rows = 3 },
);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -713,16 +800,22 @@ test "shape box glyphs" {
buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); //
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -746,9 +839,16 @@ test "shape selection boundary" {
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("a1b2c3d4e5");
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
{
@ -756,13 +856,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
false,
),
.cells = state.row_data.get(0).cells.slice(),
.selection = .{ 0, 9 },
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -778,13 +873,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?,
false,
),
.cells = state.row_data.get(0).cells.slice(),
.selection = .{ 2, 9 },
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -800,13 +890,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
false,
),
.cells = state.row_data.get(0).cells.slice(),
.selection = .{ 0, 3 },
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -822,13 +907,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
false,
),
.cells = state.row_data.get(0).cells.slice(),
.selection = .{ 1, 3 },
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -844,13 +924,8 @@ test "shape selection boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.selection = terminal.Selection.init(
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
false,
),
.cells = state.row_data.get(0).cells.slice(),
.selection = .{ 1, 1 },
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -869,9 +944,16 @@ test "shape cursor boundary" {
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString("a1b2c3d4e5");
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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
{
@ -879,8 +961,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -897,8 +978,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
.cursor_x = 0,
});
var count: usize = 0;
@ -914,8 +994,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -933,8 +1012,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
.cursor_x = 1,
});
var count: usize = 0;
@ -950,8 +1028,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -968,8 +1045,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
.cursor_x = 9,
});
var count: usize = 0;
@ -985,8 +1061,7 @@ test "shape cursor boundary" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1006,12 +1081,19 @@ test "shape cursor boundary and colored emoji" {
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(
var t = try terminal.Terminal.init(
alloc,
.{ .cols = 3, .rows = 10 },
);
defer screen.deinit();
try screen.testWriteString("👍🏼");
defer t.deinit(alloc);
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
{
@ -1019,8 +1101,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1036,8 +1117,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
.cursor_x = 0,
});
var count: usize = 0;
@ -1052,8 +1132,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1067,8 +1146,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
.cursor_x = 1,
});
var count: usize = 0;
@ -1083,8 +1161,7 @@ test "shape cursor boundary and colored emoji" {
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1104,15 +1181,21 @@ test "shape cell attribute change" {
// Plain >= should shape into 1 run
{
var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer screen.deinit();
try screen.testWriteString(">=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1124,20 +1207,23 @@ test "shape cell attribute change" {
// Bold vs regular should split
{
var screen = try terminal.Screen.init(
alloc,
.{ .cols = 3, .rows = 10 },
);
defer screen.deinit();
try screen.testWriteString(">");
try screen.setAttribute(.{ .bold = {} });
try screen.testWriteString("=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice(">");
try s.nextSlice("\x1b[1m");
try s.nextSlice("=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1149,21 +1235,26 @@ test "shape cell attribute change" {
// Changing fg color should split
{
var screen = try terminal.Screen.init(
alloc,
.{ .cols = 3, .rows = 10 },
);
defer screen.deinit();
try screen.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } });
try screen.testWriteString(">");
try screen.setAttribute(.{ .direct_color_fg = .{ .r = 3, .g = 2, .b = 1 } });
try screen.testWriteString("=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// RGB 1, 2, 3
try s.nextSlice("\x1b[38;2;1;2;3m");
try s.nextSlice(">");
// 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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1175,21 +1266,26 @@ test "shape cell attribute change" {
// Changing bg color should not split
{
var screen = try terminal.Screen.init(
alloc,
.{ .cols = 3, .rows = 10 },
);
defer screen.deinit();
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } });
try screen.testWriteString(">");
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 3, .g = 2, .b = 1 } });
try screen.testWriteString("=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// RGB 1, 2, 3 bg
try s.nextSlice("\x1b[48;2;1;2;3m");
try s.nextSlice(">");
// 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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {
@ -1201,20 +1297,24 @@ test "shape cell attribute change" {
// Same bg color should not split
{
var screen = try terminal.Screen.init(
alloc,
.{ .cols = 3, .rows = 10 },
);
defer screen.deinit();
try screen.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } });
try screen.testWriteString(">");
try screen.testWriteString("=");
var t = try terminal.Terminal.init(alloc, .{ .cols = 3, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// RGB 1, 2, 3 bg
try s.nextSlice("\x1b[48;2;1;2;3m");
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 it = shaper.runIterator(.{
.grid = testdata.grid,
.screen = &screen,
.row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?,
.cells = state.row_data.get(0).cells.slice(),
});
var count: usize = 0;
while (try it.next(alloc)) |run| {

View File

@ -45,7 +45,10 @@ pub const RunIterator = struct {
i: usize = 0,
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
const max: usize = max: {
@ -60,10 +63,8 @@ pub const RunIterator = struct {
// Invisible cells don't have any glyphs rendered,
// so we explicitly skip them in the shaping process.
while (self.i < max and
self.opts.row.style(&cells[self.i]).flags.invisible)
{
self.i += 1;
}
(cells[self.i].hasStyling() and
styles[self.i].flags.invisible)) self.i += 1;
// We're over at the max
if (self.i >= max) return null;
@ -78,7 +79,7 @@ pub const RunIterator = struct {
var hasher = Hasher.init(0);
// 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.
var j: usize = self.i;
@ -88,21 +89,14 @@ pub const RunIterator = struct {
// with identical content but different starting positions in the
// row produce the same hash, enabling cache reuse.
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
// we break the run here.
if (self.opts.selection) |unordered_sel| {
if (self.opts.selection) |bounds| {
if (j > self.i) {
const sel = unordered_sel.ordered(self.opts.screen, .forward);
const start_x = sel.start().x;
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;
if (bounds[0] > 0 and j == bounds[0]) break;
if (bounds[1] > 0 and j == bounds[1] + 1) break;
}
}
@ -148,7 +142,7 @@ pub const RunIterator = struct {
// The style is different. We allow differing background
// styles but any other change results in a new run.
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;
}
@ -168,7 +162,7 @@ pub const RunIterator = struct {
const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: {
// We only check the FIRST codepoint because I believe the
// 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);
if (cps[0] == 0xFE0E) break :p .text;
if (cps[0] == 0xFE0F) break :p .emoji;
@ -227,6 +221,7 @@ pub const RunIterator = struct {
if (try self.indexForCell(
alloc,
cell,
graphemes[j],
font_style,
presentation,
)) |idx| break :font_info .{ .idx = idx };
@ -279,8 +274,7 @@ pub const RunIterator = struct {
@intCast(cluster),
);
if (cell.hasGrapheme()) {
const cps = self.opts.row.grapheme(cell).?;
for (cps) |cp| {
for (graphemes[j]) |cp| {
// Do not send presentation modifiers
if (cp == 0xFE0E or cp == 0xFE0F) continue;
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.
defer self.i = j;
return TextRun{
return .{
.hash = hasher.final(),
.offset = @intCast(self.i),
.cells = @intCast(j - self.i),
@ -324,7 +318,8 @@ pub const RunIterator = struct {
fn indexForCell(
self: *RunIterator,
alloc: Allocator,
cell: *terminal.Cell,
cell: *const terminal.Cell,
graphemes: []const u21,
style: font.Style,
presentation: ?font.Presentation,
) !?font.Collection.Index {
@ -355,12 +350,14 @@ pub const RunIterator = struct {
// If this is a grapheme, we need to find a font that supports
// 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(alloc, cps.len + 1);
var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(
alloc,
graphemes.len + 1,
);
defer candidates.deinit(alloc);
candidates.appendAssumeCapacity(primary);
for (cps) |cp| {
for (graphemes) |cp| {
// Ignore Emoji ZWJs
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
for (candidates.items) |idx| {
if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue;
for (cps) |cp| {
for (graphemes) |cp| {
// Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue;
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 Pin = PageList.Pin;
pub const Point = point.Point;
pub const RenderState = terminal.RenderState;
pub const Screen = terminal.Screen;
pub const ScreenSet = terminal.ScreenSet;
pub const Selection = terminal.Selection;

View File

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

View File

@ -5,6 +5,7 @@ const wuffs = @import("wuffs");
const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
const font = @import("../font/main.zig");
const inputpkg = @import("../input.zig");
const os = @import("../os/main.zig");
const terminal = @import("../terminal/main.zig");
const renderer = @import("../renderer.zig");
@ -15,6 +16,7 @@ const cellpkg = @import("cell.zig");
const noMinContrast = cellpkg.noMinContrast;
const constraintWidth = cellpkg.constraintWidth;
const isCovering = cellpkg.isCovering;
const rowNeverExtendBg = @import("row.zig").neverExtendBg;
const imagepkg = @import("image.zig");
const Image = imagepkg.Image;
const ImageMap = imagepkg.ImageMap;
@ -125,12 +127,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// cells goes into a separate shader.
cells: cellpkg.Contents,
/// The last viewport that we based our rebuild off of. If this changes,
/// then we do a full rebuild of the cells. The pointer values in this pin
/// are NOT SAFE to read because they may be modified, freed, etc from the
/// termio thread. We treat the pointers as integers for comparison only.
cells_viewport: ?terminal.Pin = null,
/// Set to true after rebuildCells is called. This can be used
/// to determine if any possible changes have been made to the
/// cells for the draw call.
@ -207,6 +203,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Our shader pipelines.
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
/// render a frame, so that we can start building the next frame while
/// 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 {
self.terminal_state.deinit(self.alloc);
self.swap_chain.deinit();
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.
pub fn markDirty(self: *Self) void {
self.cells_viewport = null;
pub inline fn markDirty(self: *Self) void {
self.terminal_state.dirty = .full;
}
/// 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
// an outdated atlas from the old grid and this can cause garbage
// to be rendered.
self.cells_viewport = null;
self.markDirty();
}
/// Update uniforms that are based on the font grid.
@ -1061,21 +1068,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state: *renderer.State,
cursor_blink_visible: bool,
) !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.
const Critical = struct {
bg: terminal.color.RGB,
fg: terminal.color.RGB,
screen: terminal.Screen,
screen_type: terminal.ScreenSet.Key,
links: terminal.RenderState.CellSet,
mouse: renderer.State.Mouse,
preedit: ?renderer.State.Preedit,
cursor_color: ?terminal.color.RGB,
cursor_style: ?renderer.CursorStyle,
color_palette: terminal.color.Palette,
scrollbar: terminal.Scrollbar,
/// If true, rebuild the full screen.
full_rebuild: bool,
};
// Update all our data as tightly as possible within the mutex.
@ -1096,6 +1112,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return;
}
// Update our terminal state
try self.terminal_state.update(self.alloc, state.terminal);
// Get our scrollbar out of the terminal. We synchronize
// the scrollbar read with frame data updates because this
// 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.
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.
const cursor_style = if (state.terminal.flags.password_input)
.lock
@ -1141,9 +1136,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const preedit: ?renderer.State.Preedit = preedit: {
if (cursor_style == null) 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.
// 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);
}
// If we have any terminal dirty flags set then we need to rebuild
// the entire screen. This can be optimized in the future.
const full_rebuild: bool = rebuild: {
{
const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?;
const v: Int = @bitCast(state.terminal.flags.dirty);
if (v > 0) break :rebuild true;
}
{
const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?;
const v: Int = @bitCast(state.terminal.screens.active.dirty);
if (v > 0) break :rebuild true;
}
// Get our OSC8 links we're hovering if we have a mouse.
// This requires terminal state because of URLs.
const links: terminal.RenderState.CellSet = osc8: {
// If our mouse isn't hovering, we have no links.
const vp = state.mouse.point orelse break :osc8 .empty;
// If our viewport changed then we need to rebuild the entire
// screen because it means we scrolled. If we have no previous
// viewport then we must rebuild.
const prev_viewport = self.cells_viewport orelse break :rebuild true;
if (!prev_viewport.eql(viewport_pin)) break :rebuild true;
// If the right mods aren't pressed, then we can't match.
if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{})))
break :osc8 .empty;
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 .{
.bg = bg,
.fg = fg,
.screen = screen_copy,
.screen_type = state.terminal.screens.active_key,
.links = links,
.mouse = state.mouse,
.preedit = preedit,
.cursor_color = state.terminal.colors.cursor.get(),
.cursor_style = cursor_style,
.color_palette = state.terminal.colors.palette.current,
.scrollbar = scrollbar,
.full_rebuild = full_rebuild,
};
};
defer {
critical.screen.deinit();
if (critical.preedit) |p| p.deinit(self.alloc);
}
// Outside the critical area we can update our links to contain
// 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
try self.rebuildCells(
critical.full_rebuild,
&critical.screen,
critical.screen_type,
critical.mouse,
critical.preedit,
critical.cursor_style,
&critical.color_palette,
critical.bg,
critical.fg,
critical.cursor_color,
&critical.links,
);
// 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
self.uniforms.bg_color = .{
critical.bg.r,
critical.bg.g,
critical.bg.b,
self.terminal_state.colors.background.r,
self.terminal_state.colors.background.g,
self.terminal_state.colors.background.b,
@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();
// 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;
@ -2316,17 +2278,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// memory and doesn't touch the GPU.
fn rebuildCells(
self: *Self,
wants_rebuild: bool,
screen: *terminal.Screen,
screen_type: terminal.ScreenSet.Key,
mouse: renderer.State.Mouse,
preedit: ?renderer.State.Preedit,
cursor_style_: ?renderer.CursorStyle,
color_palette: *const terminal.color.Palette,
background: terminal.color.RGB,
foreground: terminal.color.RGB,
terminal_cursor_color: ?terminal.color.RGB,
links: *const terminal.RenderState.CellSet,
) !void {
const state: *terminal.RenderState = &self.terminal_state;
defer state.dirty = .false;
self.draw_mutex.lock();
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});
// }
_ = 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
// here because we will render the preedit separately.
const preedit_range: ?struct {
@ -2360,22 +2303,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
x: [2]terminal.size.CellCountInt,
cp_offset: usize,
} = if (preedit) |preedit_v| preedit: {
const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1);
// We base the preedit on the position of the cursor in the
// viewport. If the cursor isn't visible in the viewport we
// don't show it.
const cursor_vp = state.cursor.viewport orelse
break :preedit null;
const range = preedit_v.range(
cursor_vp.x,
state.cols - 1,
);
break :preedit .{
.y = screen.cursor.y,
.y = @intCast(cursor_vp.y),
.x = .{ range.start, range.end },
.cp_offset = range.cp_offset,
};
} else null;
const grid_size_diff =
self.cells.size.rows != screen.pages.rows or
self.cells.size.columns != screen.pages.cols;
self.cells.size.rows != state.rows or
self.cells.size.columns != state.cols;
if (grid_size_diff) {
var new_size = self.cells.size;
new_size.rows = screen.pages.rows;
new_size.columns = screen.pages.cols;
new_size.rows = state.rows;
new_size.columns = state.cols;
try self.cells.resize(self.alloc, new_size);
// Update our uniforms accordingly, otherwise
@ -2383,8 +2335,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.uniforms.grid_size = .{ new_size.columns, new_size.rows };
}
const rebuild = wants_rebuild or grid_size_diff;
const rebuild = state.dirty == .full or grid_size_diff;
if (rebuild) {
// If we are doing a full rebuild, then we clear the entire cell buffer.
self.cells.reset();
@ -2406,45 +2357,47 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
}
// We rebuild the cells row-by-row because we
// do font shaping and dirty tracking by row.
var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
// Get our row data from our state
const row_data = state.row_data.slice();
const row_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,
// we render the rows that fit, starting from the bottom. If instead
// the viewport is shorter than the cell contents buffer, we align
// the top of the viewport with the top of the contents buffer.
var y: terminal.size.CellCountInt = @min(
screen.pages.rows,
const row_len: usize = @min(
state.rows,
self.cells.size.rows,
);
while (row_it.next()) |row| {
// The viewport may have more rows than our cell contents,
// so we need to break from the loop early if we hit y = 0.
if (y == 0) break;
y -= 1;
for (
0..,
row_raws[0..row_len],
row_cells[0..row_len],
row_dirty[0..row_len],
row_selection[0..row_len],
) |y_usize, row, *cells, *dirty, selection| {
const y: terminal.size.CellCountInt = @intCast(y_usize);
if (!rebuild) {
// Only rebuild if we are doing a full rebuild or this row is dirty.
if (!row.isDirty()) continue;
if (!dirty.*) continue;
// Clear the cells if the row is dirty
self.cells.clear(y);
}
// True if we want to do font shaping around the cursor.
// We want to do font shaping as long as the cursor is enabled.
const shape_cursor = screen.viewportIsBottom() and
y == screen.cursor.y;
// Unmark the dirty state in our render state.
dirty.* = false;
// We need to get this row's selection, if
// there is one, for proper run splitting.
const row_selection = sel: {
const sel = screen.selection orelse break :sel null;
const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse
break :sel null;
break :sel sel.containedRow(screen, pin) orelse null;
};
// If our viewport is wider than our cell contents buffer,
// we still only process cells up to the width of the buffer.
const cells_slice = cells.slice();
const cells_len = @min(cells_slice.len, self.cells.size.columns);
const cells_raw = cells_slice.items(.raw);
const cells_style = cells_slice.items(.style);
// On primary screen, we still apply vertical padding
// extension under certain conditions we feel are safe.
@ -2457,14 +2410,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Apply heuristics for padding extension.
.extend => if (y == 0) {
self.uniforms.padding_extend.up = !row.neverExtendBg(
color_palette,
background,
self.uniforms.padding_extend.up = !rowNeverExtendBg(
row,
cells_raw,
cells_style,
&state.colors.palette,
state.colors.background,
);
} else if (y == self.cells.size.rows - 1) {
self.uniforms.padding_extend.down = !row.neverExtendBg(
color_palette,
background,
self.uniforms.padding_extend.down = !rowNeverExtendBg(
row,
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.
var run_iter_opts: font.shape.RunOptions = .{
.grid = self.font_grid,
.screen = screen,
.row = row,
.selection = row_selection,
.cursor_x = if (shape_cursor) screen.cursor.x else null,
.cells = cells_slice,
.selection = if (selection) |s| s else null,
// We want to do font shaping as long as the cursor is
// visible on this viewport.
.cursor_x = cursor_x: {
const vp = state.cursor.viewport orelse break :cursor_x null;
if (vp.y != y) break :cursor_x null;
break :cursor_x vp.x;
},
};
run_iter_opts.applyBreakConfig(self.config.font_shaping_break);
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_i: usize = 0;
const row_cells_all = row.cells(.all);
// If our viewport is wider than our cell contents buffer,
// we still only process cells up to the width of the buffer.
const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)];
for (row_cells, 0..) |*cell, x| {
for (
0..,
cells_raw[0..cells_len],
cells_style[0..cells_len],
) |x, *cell, *managed_style| {
// If this cell falls within our preedit range then we
// skip this because preedits are setup separately.
if (preedit_range) |range| preedit: {
@ -2522,7 +2485,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.get(run) orelse
cache: {
// Otherwise we have to shape them.
const cells = try self.font_shaper.shape(run);
const new_cells = try self.font_shaper.shape(run);
// Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization,
@ -2530,7 +2493,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.put(
self.alloc,
run,
cells,
new_cells,
) catch |err| {
log.warn(
"error caching font shaping results err={}",
@ -2541,52 +2504,45 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
break :cache new_cells;
};
const cells = shaper_cells.?;
// Advance our index until we reach or pass
// our current x position in the shaper cells.
while (run.offset + cells[shaper_cells_i].x < x) {
const shaper_cells_unwrapped = shaper_cells.?;
while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) {
shaper_cells_i += 1;
}
}
const wide = cell.wide;
const style = row.style(cell);
const cell_pin: terminal.Pin = cell: {
var copy = row;
copy.x = @intCast(x);
break :cell copy;
};
const style: terminal.Style = if (cell.hasStyling())
managed_style.*
else
.{};
// True if this cell is selected
const selected: bool = if (screen.selection) |sel|
sel.contains(screen, .{
.node = row.node,
.y = row.y,
.x = @intCast(
// Spacer tails should show the selection
// state of the wide cell they belong to.
if (wide == .spacer_tail)
const selected: bool = selected: {
const sel = selection orelse break :selected false;
const x_compare = if (wide == .spacer_tail)
x -| 1
else
x,
),
})
else
false;
x;
break :selected x_compare >= sel[0] and
x_compare <= sel[1];
};
// The `_style` suffixed values are the colors based on
// the cell style (SGR), before applying any additional
// configuration, inversions, selections, etc.
const bg_style = style.bg(cell, color_palette);
const bg_style = style.bg(
cell,
&state.colors.palette,
);
const fg_style = style.fg(.{
.default = foreground,
.palette = color_palette,
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
@ -2605,7 +2561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If no configuration, then our selection background
// is our foreground color.
break :bg foreground;
break :bg state.colors.foreground;
}
// Not selected
@ -2627,7 +2583,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const fg = fg: {
// Our happy-path non-selection background color
// 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:
// - 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)
@ -2657,7 +2613,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// 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
// 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
// an underline, in which case use a double underline to
// distinguish them.
const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin))
if (style.flags.underline == .single)
const underline: terminal.Attribute.Underline = underline: {
if (links.contains(.{
.x = @intCast(x),
.y = @intCast(y),
})) {
break :underline if (style.flags.underline == .single)
.double
else
.single
else
style.flags.underline;
.single;
}
break :underline style.flags.underline;
};
// We draw underlines first so that they layer underneath text.
// This improves readability when a colored underline is used
@ -2723,7 +2684,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
@intCast(x),
@intCast(y),
underline,
style.underlineColor(color_palette) orelse fg,
style.underlineColor(&state.colors.palette) orelse fg,
alpha,
) catch |err| {
log.warn(
@ -2754,7 +2715,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.get(run) orelse
cache: {
// Otherwise we have to shape them.
const cells = try self.font_shaper.shape(run);
const new_cells = try self.font_shaper.shape(run);
// Try to cache them. If caching fails for any reason we
// continue because it is just a performance optimization,
@ -2762,7 +2723,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.font_shaper_cache.put(
self.alloc,
run,
cells,
new_cells,
) catch |err| {
log.warn(
"error caching font shaping results err={}",
@ -2773,32 +2734,34 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// The cells we get from direct shaping are always owned
// by the shaper and valid until the next shaping call so
// we can safely use them.
break :cache cells;
break :cache new_cells;
};
const cells = shaper_cells orelse break :glyphs;
const shaped_cells = shaper_cells orelse break :glyphs;
// If there are no shaper cells for this run, ignore it.
// This can occur for runs of empty cells, and is fine.
if (cells.len == 0) break :glyphs;
if (shaped_cells.len == 0) break :glyphs;
// If we encounter a shaper cell to the left of the current
// cell then we have some problems. This logic relies on x
// position monotonically increasing.
assert(run.offset + cells[shaper_cells_i].x >= x);
assert(run.offset + shaped_cells[shaper_cells_i].x >= x);
// NOTE: An assumption is made here that a single cell will never
// be present in more than one shaper run. If that assumption is
// violated, this logic breaks.
while (shaper_cells_i < cells.len and run.offset + cells[shaper_cells_i].x == x) : ({
while (shaper_cells_i < shaped_cells.len and
run.offset + shaped_cells[shaper_cells_i].x == x) : ({
shaper_cells_i += 1;
}) {
self.addGlyph(
@intCast(x),
@intCast(y),
cell_pin,
cells[shaper_cells_i],
state.cols,
cells_raw,
shaped_cells[shaper_cells_i],
shaper_run.?,
fg,
alpha,
@ -2842,7 +2805,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const style = cursor_style_ orelse break :cursor;
const cursor_color = cursor_color: {
// 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
if (self.config.cursor_color) |v| switch (v) {
@ -2850,16 +2813,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
inline .@"cell-foreground",
.@"cell-background",
=> |_, tag| {
const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
const sty: terminal.Style = state.cursor.style;
const fg_style = sty.fg(.{
.default = foreground,
.palette = color_palette,
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
const bg_style = sty.bg(
screen.cursor.page_cell,
color_palette,
) orelse background;
&state.cursor.cell,
&state.colors.palette,
) orelse state.colors.background;
break :cursor_color switch (tag) {
.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 (style == .block and screen.viewportIsBottom()) {
const wide = screen.cursor.page_cell.wide;
if (style == .block) cursor_uniforms: {
const cursor_vp = state.cursor.viewport orelse
break :cursor_uniforms;
const wide = state.cursor.cell.wide;
self.uniforms.cursor_pos = .{
// If we are a spacer tail of a wide cell, our cursor needs
// to move back one cell. The saturate is to ensure we don't
// overflow but this shouldn't happen with well-formed input.
switch (wide) {
.narrow, .spacer_head, .wide => screen.cursor.x,
.spacer_tail => screen.cursor.x -| 1,
.narrow, .spacer_head, .wide => cursor_vp.x,
.spacer_tail => cursor_vp.x -| 1,
},
screen.cursor.y,
@intCast(cursor_vp.y),
};
self.uniforms.bools.cursor_wide = switch (wide) {
@ -2902,16 +2871,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
break :blk txt.color.toTerminalRGB();
}
const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
const sty = state.cursor.style;
const fg_style = sty.fg(.{
.default = foreground,
.palette = color_palette,
.default = state.colors.foreground,
.palette = &state.colors.palette,
.bold = self.config.bold_color,
});
const bg_style = sty.bg(
screen.cursor.page_cell,
color_palette,
) orelse background;
&state.cursor.cell,
&state.colors.palette,
) orelse state.colors.background;
break :blk switch (txt) {
// 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,
else => unreachable,
};
} else background;
} else state.colors.background;
self.uniforms.cursor_color = .{
uniform_color.r,
@ -2938,8 +2907,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.addPreeditCell(
cp,
.{ .x = x, .y = range.y },
background,
foreground,
state.colors.background,
state.colors.foreground,
) catch |err| {
log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
x,
@ -3069,15 +3038,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
x: terminal.size.CellCountInt,
y: terminal.size.CellCountInt,
cell_pin: terminal.Pin,
cols: usize,
cell_raws: []const terminal.page.Cell,
shaper_cell: font.shape.Cell,
shaper_run: font.shape.TextRun,
color: terminal.color.RGB,
alpha: u8,
) !void {
const rac = cell_pin.rowAndCell();
const cell = rac.cell;
const cell = cell_raws[x];
const cp = cell.codepoint();
// Render
@ -3097,7 +3065,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (cellpkg.isSymbol(cp)) .{
.size = .fit,
} else .none,
.constraint_width = constraintWidth(cell_pin),
.constraint_width = constraintWidth(
cell_raws,
x,
cols,
),
},
);
@ -3126,22 +3098,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
fn addCursor(
self: *Self,
screen: *terminal.Screen,
cursor_state: *const terminal.RenderState.Cursor,
cursor_style: renderer.CursorStyle,
cursor_color: terminal.color.RGB,
) void {
const cursor_vp = cursor_state.viewport orelse return;
// Add the cursor. We render the cursor over the wide character if
// we're on the wide character tail.
const wide, const x = cell: {
// The cursor goes over the screen cursor position.
const cell = screen.cursor.page_cell;
if (cell.wide != .spacer_tail or screen.cursor.x == 0)
break :cell .{ cell.wide == .wide, screen.cursor.x };
if (!cursor_vp.wide_tail) break :cell .{
cursor_state.cell.wide == .wide,
cursor_vp.x,
};
// If we're part of a wide character, we move the cursor back to
// the actual character.
const prev_cell = screen.cursorCellLeft(1);
break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 };
// If we're part of a wide character, we move the cursor back
// to the actual character.
break :cell .{ true, cursor_vp.x - 1 };
};
const alpha: u8 = if (!self.focused) 255 else alpha: {
@ -3200,7 +3174,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.cells.setCursor(.{
.atlas = .grayscale,
.bools = .{ .is_cursor_glyph = true },
.grid_pos = .{ x, screen.cursor.y },
.grid_pos = .{ x, cursor_vp.y },
.color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },

View File

@ -1,4 +1,5 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const oni = @import("oniguruma");
const configpkg = @import("../config.zig");
@ -54,354 +55,105 @@ pub const Set = struct {
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.
fn matchSetFromLinks(
pub fn renderCellMap(
self: *const Set,
alloc: Allocator,
matches: *std.ArrayList(terminal.Selection),
screen: *Screen,
mouse_pin: terminal.Pin,
result: *terminal.RenderState.CellSet,
render_state: *const terminal.RenderState,
mouse_viewport: ?point.Coordinate,
mouse_mods: inputpkg.Mods,
) !void {
// Iterate over all the visible lines.
var lineIter = screen.lineIterator(screen.pages.pin(.{
.viewport = .{},
}) orelse return);
while (lineIter.next()) |line_sel| {
const strmap: terminal.StringMap = strmap: {
var strmap: terminal.StringMap = undefined;
const str = screen.selectionString(alloc, .{
.sel = line_sel,
.trim = false,
.map = &strmap,
}) catch |err| {
log.warn(
"failed to build string map for link checking err={}",
.{err},
);
continue;
};
alloc.free(str);
break :strmap strmap;
};
defer strmap.deinit(alloc);
// Fast path, not very likely since we have default links.
if (self.links.len == 0) return;
// Convert our render state to a string + byte map.
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var map: terminal.RenderState.StringMap = .empty;
defer map.deinit(alloc);
try render_state.string(&builder.writer, .{
.alloc = alloc,
.map = &map,
});
const str = builder.writer.buffered();
// Go through each link and see if we have any matches.
for (self.links) |link| {
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) {
.always => {},
.always_mods => |v| if (!mouse_mods.equal(v)) continue,
inline .hover, .hover_mods => |v, tag| {
if (!line_sel.contains(screen, mouse_pin)) continue;
if (comptime tag == .hover_mods) {
// 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 it = strmap.searchIterator(link.regex);
while (true) {
const match_ = it.next() catch |err| {
log.warn("failed to search for link err={}", .{err});
break;
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,
};
var match = match_ orelse break;
defer match.deinit();
const sel = match.selection();
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;
// 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,
.hover, .hover_mods => if (mouse_viewport) |vp| {
for (map.items[start..end]) |pt| {
if (pt.eql(vp)) break;
} else continue;
} else continue,
}
try matches.append(alloc, sel);
// Record the match
for (map.items[start..end]) |pt| {
try result.put(alloc, pt, {});
}
}
}
}
};
/// MatchSet is the result of matching links against a screen. This contains
/// 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" {
test "renderCellMap" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
var t: terminal.Terminal = try .init(alloc, .{
.cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
const str = "1ABCD2EFGH\r\n3IJKL";
try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set
var set = try Set.fromConfig(alloc, &.{
@ -420,46 +172,41 @@ test "matchset" {
defer set.deinit(alloc);
// Get our matches
var match = try set.matchSet(alloc, &s, .{}, .{});
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 2), match.matches.len);
// 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,
} }).?));
var result: terminal.RenderState.CellSet = .empty;
defer result.deinit(alloc);
try set.renderCellMap(
alloc,
&result,
&state,
null,
.{},
);
try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
}
test "matchset hover links" {
test "renderCellMap hover links" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
var t: terminal.Terminal = try .init(alloc, .{
.cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
const str = "1ABCD2EFGH\r\n3IJKL";
try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set
var set = try Set.fromConfig(alloc, &.{
@ -479,80 +226,65 @@ test "matchset hover links" {
// Not hovering over the first link
{
var match = try set.matchSet(alloc, &s, .{}, .{});
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len);
var result: terminal.RenderState.CellSet = .empty;
defer result.deinit(alloc);
try set.renderCellMap(
alloc,
&result,
&state,
null,
.{},
);
// 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,
} }).?));
try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 2, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
}
// Hovering over the first link
{
var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{});
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 2), match.matches.len);
var result: terminal.RenderState.CellSet = .empty;
defer result.deinit(alloc);
try set.renderCellMap(
alloc,
&result,
&state,
.{ .x = 1, .y = 0 },
.{},
);
// 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,
} }).?));
try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 1 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
}
}
test "matchset mods no match" {
test "renderCellMap mods no match" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
var t: terminal.Terminal = try .init(alloc, .{
.cols = 5,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
const str = "1ABCD2EFGH\r\n3IJKL";
try s.nextSlice(str);
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Get a set
var set = try Set.fromConfig(alloc, &.{
@ -571,96 +303,21 @@ test "matchset mods no match" {
defer set.deinit(alloc);
// Get our matches
var match = try set.matchSet(alloc, &s, .{}, .{});
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len);
var result: terminal.RenderState.CellSet = .empty;
defer result.deinit(alloc);
try set.renderCellMap(
alloc,
&result,
&state,
null,
.{},
);
// 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,
t.screens.active,
.{ .x = 3, .y = 0 },
inputpkg.ctrlOrSuper(.{}),
);
defer match.deinit(alloc);
try testing.expectEqual(@as(usize, 1), match.matches.len);
// Test our matches
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 = 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,
} }).?));
try testing.expect(!result.contains(.{ .x = 0, .y = 0 }));
try testing.expect(result.contains(.{ .x = 1, .y = 0 }));
try testing.expect(result.contains(.{ .x = 2, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 3, .y = 0 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 1 }));
try testing.expect(!result.contains(.{ .x = 1, .y = 2 }));
}

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;
}
/// 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
/// on pins rather than points. This is MUCH more efficient than calling
/// pointFromPin and building up the iterator from points.

View File

@ -88,7 +88,7 @@ pub const Dirty = packed struct {
/// The cursor position and style.
pub const Cursor = struct {
// The x/y position within the viewport.
// The x/y position within the active area.
x: 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
/// 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 {
const tl_pin = self.topLeft(s);
const br_pin = self.bottomRight(s);
// 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 br = s.pages.pointFromPin(.screen, br_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;
// 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.
if (self.rectangle) return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = tl.x } }).?,
s.pages.pin(.{ .screen = .{ .y = p.y, .x = br.x } }).?,
start: {
var copy: Pin = pin;
copy.x = tl.x;
break :start copy;
},
end: {
var copy: Pin = pin;
copy.x = br.x;
break :end copy;
},
true,
);
@ -309,7 +346,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
// Selection top-left line matches only.
return init(
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,
);
}
@ -320,7 +361,11 @@ pub fn containedRow(self: Selection, s: *const Screen, pin: Pin) ?Selection {
if (p.y == br.y) {
assert(p.y != tl.y);
return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?,
start: {
var copy: Pin = pin;
copy.x = 0;
break :start copy;
},
br_pin,
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.
return init(
s.pages.pin(.{ .screen = .{ .y = p.y, .x = 0 } }).?,
s.pages.pin(.{ .screen = .{ .y = p.y, .x = s.pages.cols - 1 } }).?,
start: {
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,
);
}

View File

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

View File

@ -5,6 +5,7 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig");
const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig");
const render = @import("render.zig");
const stream_readonly = @import("stream_readonly.zig");
const style = @import("style.zig");
pub const apc = @import("apc.zig");
@ -40,6 +41,7 @@ pub const Pin = PageList.Pin;
pub const Point = point.Point;
pub const ReadonlyHandler = stream_readonly.Handler;
pub const ReadonlyStream = stream_readonly.Stream;
pub const RenderState = render.RenderState;
pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig");
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
/// row structure (graphemes, styles, etc.)
inline fn managedMemory(self: Row) bool {
return self.grapheme or self.styled or self.hyperlink;
pub inline fn managedMemory(self: Row) bool {
// 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.
pub const options = @import("terminal_options");
pub const Active = @import("search/active.zig").ActiveSearch;
pub const PageList = @import("search/pagelist.zig").PageListSearch;
pub const Screen = @import("search/screen.zig").ScreenSearch;
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 {
@import("std").testing.refAllDecls(@This());