mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-07 22:55:16 +08:00
The terminal.Stream next/nextSlice functions can now no longer fail. All prior failure modes were fully isolated in the handler `vt` callbacks. As such, vt callbacks are now required to not return an error and handle their own errors somehow. Allowing streams to be fallible before was an incorrect design. It caused problematic scenarios like in `nextSlice` early terminating processing due to handler errors. This should not be possible. There is no safe way to bubble up vt errors through the stream because if nextSlice is called and multiple errors are returned, we can't coalesce them. We could modify that to return a partial result but its just more work for stream that is unnecessary. The handler can do all of this. This work was discovered due to cleanups to prepare for more C APIs. Less errors make C APIs easier to implement! And, it helps clean up our Zig, too.
323 lines
9.5 KiB
Zig
323 lines
9.5 KiB
Zig
const std = @import("std");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const oni = @import("oniguruma");
|
|
const inputpkg = @import("../input.zig");
|
|
const terminal = @import("../terminal/main.zig");
|
|
const point = terminal.point;
|
|
const Screen = terminal.Screen;
|
|
const Terminal = terminal.Terminal;
|
|
|
|
const log = std.log.scoped(.renderer_link);
|
|
|
|
/// The link configuration needed for renderers.
|
|
pub const Link = struct {
|
|
/// The regular expression to match the link against.
|
|
regex: oni.Regex,
|
|
|
|
/// The situations in which the link should be highlighted.
|
|
highlight: inputpkg.Link.Highlight,
|
|
|
|
pub fn deinit(self: *Link) void {
|
|
self.regex.deinit();
|
|
}
|
|
};
|
|
|
|
/// A set of links. This provides a higher level API for renderers
|
|
/// to match against a viewport and determine if cells are part of
|
|
/// a link.
|
|
pub const Set = struct {
|
|
links: []Link,
|
|
|
|
/// Returns the slice of links from the configuration.
|
|
pub fn fromConfig(
|
|
alloc: Allocator,
|
|
config: []const inputpkg.Link,
|
|
) !Set {
|
|
var links: std.ArrayList(Link) = .empty;
|
|
defer links.deinit(alloc);
|
|
|
|
for (config) |link| {
|
|
var regex = try link.oniRegex();
|
|
errdefer regex.deinit();
|
|
try links.append(alloc, .{
|
|
.regex = regex,
|
|
.highlight = link.highlight,
|
|
});
|
|
}
|
|
|
|
return .{ .links = try links.toOwnedSlice(alloc) };
|
|
}
|
|
|
|
pub fn deinit(self: *Set, alloc: Allocator) void {
|
|
for (self.links) |*link| link.deinit();
|
|
alloc.free(self.links);
|
|
}
|
|
|
|
/// Fills matches with the matches from regex link matches.
|
|
pub fn renderCellMap(
|
|
self: *const Set,
|
|
alloc: Allocator,
|
|
result: *terminal.RenderState.CellSet,
|
|
render_state: *const terminal.RenderState,
|
|
mouse_viewport: ?point.Coordinate,
|
|
mouse_mods: inputpkg.Mods,
|
|
) !void {
|
|
// 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| {
|
|
// 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,
|
|
|
|
// We check the hover points later.
|
|
.hover => if (mouse_viewport == null) continue,
|
|
.hover_mods => |v| {
|
|
if (mouse_viewport == null) continue;
|
|
if (!mouse_mods.equal(v)) continue;
|
|
},
|
|
}
|
|
|
|
var offset: usize = 0;
|
|
while (offset < str.len) {
|
|
var region = link.regex.search(
|
|
str[offset..],
|
|
.{},
|
|
) catch |err| switch (err) {
|
|
error.Mismatch => break,
|
|
else => return err,
|
|
};
|
|
defer region.deinit();
|
|
|
|
// We have a match!
|
|
const offset_start: usize = @intCast(region.starts()[0]);
|
|
const offset_end: usize = @intCast(region.ends()[0]);
|
|
const start = offset + offset_start;
|
|
const end = offset + offset_end;
|
|
|
|
// Increment our offset by the number of bytes in the match.
|
|
// We defer this so that we can return the match before
|
|
// modifying the offset.
|
|
defer offset = end;
|
|
|
|
switch (link.highlight) {
|
|
.always, .always_mods => {},
|
|
.hover, .hover_mods => if (mouse_viewport) |vp| {
|
|
for (map.items[start..end]) |pt| {
|
|
if (pt.eql(vp)) break;
|
|
} else continue;
|
|
} else continue,
|
|
}
|
|
|
|
// Record the match
|
|
for (map.items[start..end]) |pt| {
|
|
try result.put(alloc, pt, {});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
test "renderCellMap" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
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\r\n3IJKL";
|
|
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, &.{
|
|
.{
|
|
.regex = "AB",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .always = {} },
|
|
},
|
|
|
|
.{
|
|
.regex = "EF",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .always = {} },
|
|
},
|
|
});
|
|
defer set.deinit(alloc);
|
|
|
|
// Get our matches
|
|
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 "renderCellMap hover links" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
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\r\n3IJKL";
|
|
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, &.{
|
|
.{
|
|
.regex = "AB",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .hover = {} },
|
|
},
|
|
|
|
.{
|
|
.regex = "EF",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .always = {} },
|
|
},
|
|
});
|
|
defer set.deinit(alloc);
|
|
|
|
// Not hovering over the first link
|
|
{
|
|
var result: terminal.RenderState.CellSet = .empty;
|
|
defer result.deinit(alloc);
|
|
try set.renderCellMap(
|
|
alloc,
|
|
&result,
|
|
&state,
|
|
null,
|
|
.{},
|
|
);
|
|
|
|
// Test our matches
|
|
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 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(!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 "renderCellMap mods no match" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
|
|
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\r\n3IJKL";
|
|
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, &.{
|
|
.{
|
|
.regex = "AB",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .always = {} },
|
|
},
|
|
|
|
.{
|
|
.regex = "EF",
|
|
.action = .{ .open = {} },
|
|
.highlight = .{ .always_mods = .{ .ctrl = true } },
|
|
},
|
|
});
|
|
defer set.deinit(alloc);
|
|
|
|
// Get our matches
|
|
var result: terminal.RenderState.CellSet = .empty;
|
|
defer result.deinit(alloc);
|
|
try set.renderCellMap(
|
|
alloc,
|
|
&result,
|
|
&state,
|
|
null,
|
|
.{},
|
|
);
|
|
|
|
// Test our matches
|
|
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 }));
|
|
}
|