ghostty/src/renderer/link.zig
2024-03-22 20:28:05 -07:00

394 lines
12 KiB
Zig

const std = @import("std");
const Allocator = std.mem.Allocator;
const oni = @import("oniguruma");
const configpkg = @import("../config.zig");
const inputpkg = @import("../input.zig");
const terminal = @import("../terminal/main.zig");
const point = terminal.point;
const Screen = terminal.Screen;
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).init(alloc);
defer links.deinit();
for (config) |link| {
var regex = try link.oniRegex();
errdefer regex.deinit();
try links.append(.{
.regex = regex,
.highlight = link.highlight,
});
}
return .{ .links = try links.toOwnedSlice() };
}
pub fn deinit(self: *Set, alloc: Allocator) void {
for (self.links) |*link| link.deinit();
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).init(alloc);
defer matches.deinit();
// 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);
// 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,
inline .hover, .hover_mods => |v, tag| {
if (!line_sel.contains(screen, mouse_pin)) continue;
if (comptime tag == .hover_mods) {
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 match = match_ orelse break;
defer match.deinit();
const sel = match.selection();
// If this is a highlight link then we only want to
// include matches that include our hover point.
switch (link.highlight) {
.always, .always_mods => {},
.hover,
.hover_mods,
=> if (!sel.contains(screen, mouse_pin)) continue,
}
try matches.append(sel);
}
}
}
return .{ .matches = try matches.toOwnedSlice() };
}
};
/// 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 pt. The points must be
/// given in left-to-right top-to-bottom order. This is a stateful
/// operation and giving a point out of order can cause invalid
/// results.
pub fn orderedContains(
self: *MatchSet,
screen: *const Screen,
pin: terminal.Pin,
) bool {
// If we're beyond the end of our possible matches, we're done.
if (self.i >= self.matches.len) return false;
// If our selection ends before the point, then no point will ever
// again match this selection so we move on to the next one.
while (self.matches[self.i].end().before(pin)) {
self.i += 1;
if (self.i >= self.matches.len) return false;
}
return self.matches[self.i].contains(screen, pin);
}
};
test "matchset" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// 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 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,
} }).?));
}
test "matchset hover links" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// 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 match = try set.matchSet(alloc, &s, .{}, .{});
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 = 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,
} }).?));
}
// 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);
// 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 mods no match" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize our screen
var s = try Screen.init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// 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 match = try set.matchSet(alloc, &s, .{}, .{});
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 = 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,
} }).?));
}