Merge branch 'ghostty-org:main' into main

This commit is contained in:
plyght
2024-10-29 14:42:18 -04:00
committed by GitHub
25 changed files with 551 additions and 130 deletions

View File

@ -21,17 +21,20 @@ const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig");
const Version = @import("src/build/Version.zig"); const Version = @import("src/build/Version.zig");
const Command = @import("src/Command.zig"); const Command = @import("src/Command.zig");
// Do a comptime Zig version requirement. This is the minimum required
// Zig version. We don't check a maximum so that devs can try newer
// versions but this is the only version we guarantee to work.
comptime { comptime {
// This is the required Zig version for building this project. We allow
// any patch version but the major and minor must match exactly.
const required_zig = "0.13.0"; const required_zig = "0.13.0";
const current_zig = builtin.zig_version;
const min_zig = std.SemanticVersion.parse(required_zig) catch unreachable; // Fail compilation if the current Zig version doesn't meet requirements.
if (current_zig.order(min_zig) == .lt) { const current_vsn = builtin.zig_version;
const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable;
if (current_vsn.major != required_vsn.major or
current_vsn.minor != required_vsn.minor)
{
@compileError(std.fmt.comptimePrint( @compileError(std.fmt.comptimePrint(
"Your Zig version v{} does not meet the minimum build requirement of v{}", "Your Zig version v{} does not meet the required build version of v{}",
.{ current_zig, min_zig }, .{ current_vsn, required_vsn },
)); ));
} }
} }

View File

@ -15,6 +15,7 @@ extern "C" {
#include <stdbool.h> #include <stdbool.h>
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include <sys/types.h>
//------------------------------------------------------------------- //-------------------------------------------------------------------
// Macros // Macros
@ -379,6 +380,11 @@ typedef struct {
ghostty_action_resize_split_direction_e direction; ghostty_action_resize_split_direction_e direction;
} ghostty_action_resize_split_s; } ghostty_action_resize_split_s;
// apprt.action.MoveTab
typedef struct {
ssize_t amount;
} ghostty_action_move_tab_s;
// apprt.action.GotoTab // apprt.action.GotoTab
typedef enum { typedef enum {
GHOSTTY_GOTO_TAB_PREVIOUS = -1, GHOSTTY_GOTO_TAB_PREVIOUS = -1,
@ -517,6 +523,7 @@ typedef enum {
GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
GHOSTTY_ACTION_TOGGLE_VISIBILITY, GHOSTTY_ACTION_TOGGLE_VISIBILITY,
GHOSTTY_ACTION_MOVE_TAB,
GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_TAB,
GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_GOTO_SPLIT,
GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT,
@ -543,6 +550,7 @@ typedef enum {
typedef union { typedef union {
ghostty_action_split_direction_e new_split; ghostty_action_split_direction_e new_split;
ghostty_action_fullscreen_e toggle_fullscreen; ghostty_action_fullscreen_e toggle_fullscreen;
ghostty_action_move_tab_s move_tab;
ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_tab_e goto_tab;
ghostty_action_goto_split_e goto_split; ghostty_action_goto_split_e goto_split;
ghostty_action_resize_split_s resize_split; ghostty_action_resize_split_s resize_split;

View File

@ -32,6 +32,7 @@
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; };
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; }; A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; };
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C932B53B43700305CE6 /* iOSApp.swift */; }; A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C932B53B43700305CE6 /* iOSApp.swift */; };
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
@ -115,6 +116,7 @@
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = "<group>"; }; A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = "<group>"; };
A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
@ -317,6 +319,7 @@
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
@ -601,6 +604,7 @@
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,

View File

@ -197,7 +197,7 @@ class QuickTerminalController: BaseTerminalController {
// Run the animation that moves our window into the proper place and makes // Run the animation that moves our window into the proper place and makes
// it visible. // it visible.
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2 context.duration = ghostty.config.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn) context.timingFunction = .init(name: .easeIn)
position.setFinal(in: window.animator(), on: screen) position.setFinal(in: window.animator(), on: screen)
}, completionHandler: { }, completionHandler: {
@ -287,7 +287,7 @@ class QuickTerminalController: BaseTerminalController {
} }
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2 context.duration = ghostty.config.quickTerminalAnimationDuration
context.timingFunction = .init(name: .easeIn) context.timingFunction = .init(name: .easeIn)
position.setInitial(in: window.animator(), on: screen) position.setInitial(in: window.animator(), on: screen)
}, completionHandler: { }, completionHandler: {

View File

@ -12,6 +12,14 @@ class QuickTerminalWindow: NSWindow {
// Note: almost all of this stuff can be done in the nib/xib directly // Note: almost all of this stuff can be done in the nib/xib directly
// but I prefer to do it programmatically because the properties we // but I prefer to do it programmatically because the properties we
// care about are less hidden. // care about are less hidden.
// Add a custom identifier so third party apps can use the Accessibility
// API to apply special rules to the quick terminal.
self.identifier = .init(rawValue: "com.mitchellh.ghostty.quickTerminal")
// Set the correct AXSubrole of kAXFloatingWindowSubrole (allows
// AeroSpace to treat the Quick Terminal as a floating window)
self.setAccessibilitySubrole(.floatingWindow)
// Remove the title completely. This will make the window square. One // Remove the title completely. This will make the window square. One
// downside is it also hides the cursor indications of resize but the // downside is it also hides the cursor indications of resize but the

View File

@ -40,6 +40,11 @@ class TerminalController: BaseTerminalController {
selector: #selector(onToggleFullscreen), selector: #selector(onToggleFullscreen),
name: Ghostty.Notification.ghosttyToggleFullscreen, name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil) object: nil)
center.addObserver(
self,
selector: #selector(onMoveTab),
name: .ghosttyMoveTab,
object: nil)
center.addObserver( center.addObserver(
self, self,
selector: #selector(onGotoTab), selector: #selector(onGotoTab),
@ -482,6 +487,44 @@ class TerminalController: BaseTerminalController {
//MARK: - Notifications //MARK: - Notifications
@objc private func onMoveTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let window = self.window else { return }
// Get the move action
guard let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey] as? Ghostty.Action.MoveTab else { return }
guard action.amount != 0 else { return }
// Determine our current selected index
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
guard let selectedWindow = tabGroup.selectedWindow else { return }
let tabbedWindows = tabGroup.windows
guard tabbedWindows.count > 0 else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
// Determine the final index we want to insert our tab
let finalIndex: Int
if action.amount < 0 {
finalIndex = selectedIndex - min(selectedIndex, -action.amount)
} else {
let remaining: Int = tabbedWindows.count - 1 - selectedIndex
finalIndex = selectedIndex + min(remaining, action.amount)
}
// If our index is the same we do nothing
guard finalIndex != selectedIndex else { return }
// Get our parent
let parent = tabbedWindows[finalIndex]
// Move our current selected window to the proper index
tabGroup.removeWindow(selectedWindow)
parent.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above)
selectedWindow.makeKeyAndOrderFront(nil)
}
@objc private func onGotoTab(notification: SwiftUI.Notification) { @objc private func onGotoTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return } guard target == self.focusedSurface else { return }

View File

@ -0,0 +1,15 @@
import GhosttyKit
extension Ghostty {
struct Action {}
}
extension Ghostty.Action {
struct MoveTab {
let amount: Int
init(c: ghostty_action_move_tab_s) {
self.amount = c.amount
}
}
}

View File

@ -458,6 +458,9 @@ extension Ghostty {
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen) toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen)
case GHOSTTY_ACTION_MOVE_TAB:
moveTab(app, target: target, move: action.action.move_tab)
case GHOSTTY_ACTION_GOTO_TAB: case GHOSTTY_ACTION_GOTO_TAB:
gotoTab(app, target: target, tab: action.action.goto_tab) gotoTab(app, target: target, tab: action.action.goto_tab)
@ -666,6 +669,31 @@ extension Ghostty {
appDelegate.toggleVisibility(self) appDelegate.toggleVisibility(self)
} }
private static func moveTab(
_ app: ghostty_app_t,
target: ghostty_target_s,
move: ghostty_action_move_tab_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("move tab does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyMoveTab,
object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.GhosttyMoveTabKey: Action.MoveTab(c: move),
]
)
default:
assertionFailure()
}
}
private static func gotoTab( private static func gotoTab(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -354,6 +354,14 @@ extension Ghostty {
let str = String(cString: ptr) let str = String(cString: ptr)
return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main
} }
var quickTerminalAnimationDuration: Double {
guard let config = self.config else { return 0.2 }
var v: Double = 0.2
let key = "quick-terminal-animation-duration"
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
#endif #endif
var resizeOverlay: ResizeOverlay { var resizeOverlay: ResizeOverlay {

View File

@ -203,8 +203,16 @@ extension Ghostty {
} }
// MARK: Surface Notifications // MARK: Surface Notification
extension Notification.Name {
/// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue
}
// NOTE: I am moving all of these to Notification.Name extensions over time. This
// namespace was the old namespace.
extension Ghostty.Notification { extension Ghostty.Notification {
/// Used to pass a configuration along when creating a new tab/window/split. /// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig" static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"

View File

@ -160,6 +160,10 @@ pub const Font = opaque {
return @ptrFromInt(@intFromPtr(c.CTFontCopyDisplayName(@ptrCast(self)))); return @ptrFromInt(@intFromPtr(c.CTFontCopyDisplayName(@ptrCast(self))));
} }
pub fn copyPostScriptName(self: *Font) *foundation.String {
return @ptrFromInt(@intFromPtr(c.CTFontCopyPostScriptName(@ptrCast(self))));
}
pub fn getSymbolicTraits(self: *Font) text.FontSymbolicTraits { pub fn getSymbolicTraits(self: *Font) text.FontSymbolicTraits {
return @bitCast(c.CTFontGetSymbolicTraits(@ptrCast(self))); return @bitCast(c.CTFontGetSymbolicTraits(@ptrCast(self)));
} }

View File

@ -3913,6 +3913,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}, },
), ),
.move_tab => |position| try self.rt_app.performAction(
.{ .surface = self },
.move_tab,
.{ .amount = position },
),
.new_split => |direction| try self.rt_app.performAction( .new_split => |direction| try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.new_split, .new_split,
@ -4112,9 +4118,13 @@ fn writeScreenFile(
var tmp_dir = try internal_os.TempDir.init(); var tmp_dir = try internal_os.TempDir.init();
errdefer tmp_dir.deinit(); errdefer tmp_dir.deinit();
var filename_buf: [std.fs.max_path_bytes]u8 = undefined;
const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)});
// Open our scrollback file // Open our scrollback file
var file = try tmp_dir.dir.createFile(@tagName(loc), .{}); var file = try tmp_dir.dir.createFile(filename, .{});
defer file.close(); defer file.close();
// Screen.dumpString writes byte-by-byte, so buffer it // Screen.dumpString writes byte-by-byte, so buffer it
var buf_writer = std.io.bufferedWriter(file.writer()); var buf_writer = std.io.bufferedWriter(file.writer());
@ -4173,7 +4183,7 @@ fn writeScreenFile(
// Get the final path // Get the final path
var path_buf: [std.fs.max_path_bytes]u8 = undefined; var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try tmp_dir.dir.realpath(@tagName(loc), &path_buf); const path = try tmp_dir.dir.realpath(filename, &path_buf);
switch (write_action) { switch (write_action) {
.open => try internal_os.open(self.alloc, path), .open => try internal_os.open(self.alloc, path),

View File

@ -100,6 +100,13 @@ pub const Action = union(Key) {
/// Toggle the visibility of all Ghostty terminal windows. /// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility, toggle_visibility,
/// Moves a tab by a relative offset.
///
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1
/// for right). If the new position is out of bounds, it wraps around
/// cyclically within the tab range.
move_tab: MoveTab,
/// Jump to a specific tab. Must handle the scenario that the tab /// Jump to a specific tab. Must handle the scenario that the tab
/// value is invalid. /// value is invalid.
goto_tab: GotoTab, goto_tab: GotoTab,
@ -190,6 +197,7 @@ pub const Action = union(Key) {
toggle_window_decorations, toggle_window_decorations,
toggle_quick_terminal, toggle_quick_terminal,
toggle_visibility, toggle_visibility,
move_tab,
goto_tab, goto_tab,
goto_split, goto_split,
resize_split, resize_split,
@ -308,6 +316,10 @@ pub const ResizeSplit = extern struct {
}; };
}; };
pub const MoveTab = extern struct {
amount: isize,
};
/// The tab to jump to. This is non-exhaustive so that integer values represent /// The tab to jump to. This is non-exhaustive so that integer values represent
/// the index (zero-based) of the tab to jump to. Negative values are special /// the index (zero-based) of the tab to jump to. Negative values are special
/// values. /// values.

View File

@ -213,6 +213,7 @@ pub const App = struct {
.toggle_quick_terminal, .toggle_quick_terminal,
.toggle_visibility, .toggle_visibility,
.goto_tab, .goto_tab,
.move_tab,
.inspector, .inspector,
.render_inspector, .render_inspector,
.quit_timer, .quit_timer,

View File

@ -456,6 +456,7 @@ pub fn performAction(
.new_tab => try self.newTab(target), .new_tab => try self.newTab(target),
.goto_tab => self.gotoTab(target, value), .goto_tab => self.gotoTab(target, value),
.move_tab => self.moveTab(target, value),
.new_split => try self.newSplit(target, value), .new_split => try self.newSplit(target, value),
.resize_split => self.resizeSplit(target, value), .resize_split => self.resizeSplit(target, value),
.equalize_splits => self.equalizeSplits(target), .equalize_splits => self.equalizeSplits(target),
@ -527,6 +528,23 @@ fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void {
} }
} }
fn moveTab(_: *App, target: apprt.Target, move_tab: apprt.action.MoveTab) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"moveTab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.moveTab(v.rt_surface, @intCast(move_tab.amount));
},
}
}
fn newSplit( fn newSplit(
self: *App, self: *App,
target: apprt.Target, target: apprt.Target,

View File

@ -456,6 +456,15 @@ pub fn gotoNextTab(self: *Window, surface: *Surface) void {
self.focusCurrentTab(); self.focusCurrentTab();
} }
/// Move the current tab for a surface.
pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void {
const tab = surface.container.tab() orelse {
log.info("surface is not attached to a tab bar, cannot navigate", .{});
return;
};
self.notebook.moveTab(tab, position);
}
/// Go to the next tab for a surface. /// Go to the next tab for a surface.
pub fn gotoLastTab(self: *Window) void { pub fn gotoLastTab(self: *Window) void {
const max = self.notebook.nPages() -| 1; const max = self.notebook.nPages() -| 1;

View File

@ -183,6 +183,35 @@ pub const Notebook = union(enum) {
self.gotoNthTab(next_idx); self.gotoNthTab(next_idx);
} }
pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void {
const page_idx = self.getTabPosition(tab) orelse return;
const max = self.nPages() -| 1;
var new_position: c_int = page_idx + position;
if (new_position < 0) {
new_position = max + new_position + 1;
} else if (new_position > max) {
new_position = new_position - max - 1;
}
if (new_position == page_idx) return;
self.reorderPage(tab, new_position);
}
pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void {
switch (self) {
.gtk_notebook => |notebook| {
c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position);
},
.adw_tab_view => |tab_view| {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box));
_ = c.adw_tab_view_reorder_page(tab_view, page, position);
},
}
}
pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self) { switch (self) {
.adw_tab_view => |tab_view| { .adw_tab_view => |tab_view| {

View File

@ -515,7 +515,10 @@ const Preview = struct {
} }
if (theme_list.hasMouse(mouse)) |_| { if (theme_list.hasMouse(mouse)) |_| {
if (mouse.button == .left and mouse.type == .release) { if (mouse.button == .left and mouse.type == .release) {
self.current = self.window + mouse.row; const selection = self.window + mouse.row;
if (selection < self.filtered.items.len) {
self.current = selection;
}
} }
highlight = mouse.row; highlight = mouse.row;
} }

View File

@ -1270,6 +1270,11 @@ keybind: Keybinds = .{},
/// by the operating system. /// by the operating system.
@"quick-terminal-screen": QuickTerminalScreen = .main, @"quick-terminal-screen": QuickTerminalScreen = .main,
/// Duration (in seconds) of the quick terminal enter and exit animation.
/// Set it to 0 to disable animation completely. This can be changed at
/// runtime.
@"quick-terminal-animation-duration": f64 = 0.2,
/// Whether to enable shell integration auto-injection or not. Shell integration /// Whether to enable shell integration auto-injection or not. Shell integration
/// greatly enhances the terminal experience by enabling a number of features: /// greatly enhances the terminal experience by enabling a number of features:
/// ///

View File

@ -424,7 +424,30 @@ pub const CoreText = struct {
}; };
} }
return try self.discover(alloc, desc); const it = try self.discover(alloc, desc);
// If our normal discovery doesn't find anything and we have a specific
// codepoint, then fallback to using CTFontCreateForString to find a
// matching font CoreText wants to use. See:
// https://github.com/ghostty-org/ghostty/issues/2499
if (it.list.len == 0 and desc.codepoint > 0) codepoint: {
const ct_desc = try self.discoverCodepoint(
collection,
desc,
) orelse break :codepoint;
const list = try alloc.alloc(*macos.text.FontDescriptor, 1);
errdefer alloc.free(list);
list[0] = ct_desc;
return DiscoverIterator{
.alloc = alloc,
.list = list,
.i = 0,
};
}
return it;
} }
/// Discover a font for a specific codepoint using the CoreText /// Discover a font for a specific codepoint using the CoreText
@ -491,16 +514,45 @@ pub const CoreText = struct {
); );
defer str.release(); defer str.release();
// Get our range length for CTFontCreateForString. It looks like
// the range uses UTF-16 codepoints and not UTF-32 codepoints.
const range_len: usize = range_len: {
var unichars: [2]u16 = undefined;
const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
desc.codepoint,
&unichars,
);
break :range_len if (pair) 2 else 1;
};
// Get our font // Get our font
const font = original.font.createForString( const font = original.font.createForString(
str, str,
macos.foundation.Range.init(0, 1), macos.foundation.Range.init(0, range_len),
) orelse return null; ) orelse return null;
defer font.release(); defer font.release();
// Do not allow the last resort font to go through. This is the
// last font used by CoreText if it can't find anything else and
// only contains replacement characters.
last_resort: {
const name_str = font.copyPostScriptName();
defer name_str.release();
// If the name doesn't fit in our buffer, then it can't
// be the last resort font so we break out.
var name_buf: [64]u8 = undefined;
const name: []const u8 = name_str.cstring(&name_buf, .utf8) orelse
break :last_resort;
// If the name is "LastResort" then we don't want to use it.
if (std.mem.eql(u8, "LastResort", name)) return null;
}
// Get the descriptor // Get the descriptor
return font.copyDescriptor(); return font.copyDescriptor();
} }
fn copyMatchingDescriptors( fn copyMatchingDescriptors(
alloc: Allocator, alloc: Allocator,
list: *macos.foundation.Array, list: *macos.foundation.Array,

View File

@ -301,6 +301,11 @@ pub const Action = union(enum) {
/// is higher than the number of tabs, this will go to the last tab. /// is higher than the number of tabs, this will go to the last tab.
goto_tab: usize, goto_tab: usize,
/// Moves a tab by a relative offset.
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 for right).
/// If the new position is out of bounds, it wraps around cyclically within the tab range.
move_tab: isize,
/// Toggle the tab overview. /// Toggle the tab overview.
/// This only works with libadwaita enabled currently. /// This only works with libadwaita enabled currently.
toggle_tab_overview: void, toggle_tab_overview: void,
@ -312,7 +317,8 @@ pub const Action = union(enum) {
/// Focus on a split in a given direction. /// Focus on a split in a given direction.
goto_split: SplitFocusDirection, goto_split: SplitFocusDirection,
/// zoom/unzoom the current split. /// zoom/unzoom the current split. This is currently only supported
/// on macOS. Contributions welcome for other platforms.
toggle_split_zoom: void, toggle_split_zoom: void,
/// Resize the current split by moving the split divider in the given /// Resize the current split by moving the split divider in the given
@ -647,6 +653,7 @@ pub const Action = union(enum) {
.next_tab, .next_tab,
.last_tab, .last_tab,
.goto_tab, .goto_tab,
.move_tab,
.toggle_tab_overview, .toggle_tab_overview,
.new_split, .new_split,
.goto_split, .goto_split,

View File

@ -896,7 +896,7 @@ const ReflowCursor = struct {
// If our page can't support an additional cell with // If our page can't support an additional cell with
// graphemes then we create a new page for this row. // graphemes then we create a new page for this row.
if (self.page.graphemeCount() >= self.page.graphemeCapacity() - 1) { if (self.page.graphemeCount() >= self.page.graphemeCapacity()) {
try self.moveLastRowToNewPage(list, cap); try self.moveLastRowToNewPage(list, cap);
} else { } else {
// Attempt to allocate the space that would be required for // Attempt to allocate the space that would be required for
@ -924,7 +924,7 @@ const ReflowCursor = struct {
// If our page can't support an additional cell with // If our page can't support an additional cell with
// a hyperlink ID then we create a new page for this row. // a hyperlink ID then we create a new page for this row.
if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity() - 1) { if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity()) {
try self.moveLastRowToNewPage(list, cap); try self.moveLastRowToNewPage(list, cap);
} }

View File

@ -125,7 +125,7 @@ pub const Cursor = struct {
/// the cursor page pin changes. We can't get it from the old screen /// the cursor page pin changes. We can't get it from the old screen
/// state because the page may be cleared. This is heap allocated /// state because the page may be cleared. This is heap allocated
/// because its most likely null. /// because its most likely null.
hyperlink: ?*Hyperlink = null, hyperlink: ?*hyperlink.Hyperlink = null,
/// The pointers into the page list where the cursor is currently /// The pointers into the page list where the cursor is currently
/// located. This makes it faster to move the cursor. /// located. This makes it faster to move the cursor.
@ -134,7 +134,10 @@ pub const Cursor = struct {
page_cell: *pagepkg.Cell, page_cell: *pagepkg.Cell,
pub fn deinit(self: *Cursor, alloc: Allocator) void { pub fn deinit(self: *Cursor, alloc: Allocator) void {
if (self.hyperlink) |link| link.destroy(alloc); if (self.hyperlink) |link| {
link.deinit(alloc);
alloc.destroy(link);
}
} }
}; };
@ -182,31 +185,6 @@ pub const CharsetState = struct {
const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset);
}; };
pub const Hyperlink = struct {
id: ?[]const u8,
uri: []const u8,
pub fn create(
alloc: Allocator,
uri: []const u8,
id: ?[]const u8,
) !*Hyperlink {
const self = try alloc.create(Hyperlink);
errdefer alloc.destroy(self);
self.id = if (id) |v| try alloc.dupe(u8, v) else null;
errdefer if (self.id) |v| alloc.free(v);
self.uri = try alloc.dupe(u8, uri);
errdefer alloc.free(self.uri);
return self;
}
pub fn destroy(self: *Hyperlink, alloc: Allocator) void {
if (self.id) |id| alloc.free(id);
alloc.free(self.uri);
alloc.destroy(self);
}
};
/// Initialize a new screen. /// Initialize a new screen.
/// ///
/// max_scrollback is the amount of scrollback to keep in bytes. This /// max_scrollback is the amount of scrollback to keep in bytes. This
@ -471,10 +449,11 @@ pub fn adjustCapacity(
self.cursor.hyperlink = null; self.cursor.hyperlink = null;
// Re-add // Re-add
self.startHyperlinkOnce(link.uri, link.id) catch unreachable; self.startHyperlinkOnce(link.*) catch unreachable;
// Remove our old link // Remove our old link
link.destroy(self.alloc); link.deinit(self.alloc);
self.alloc.destroy(link);
} }
// Reload the cursor information because the pin changed. // Reload the cursor information because the pin changed.
@ -1023,7 +1002,10 @@ fn cursorChangePin(self: *Screen, new: Pin) void {
self.cursor.hyperlink = null; self.cursor.hyperlink = null;
// Re-add // Re-add
self.startHyperlink(link.uri, link.id) catch |err| { self.startHyperlink(link.uri, switch (link.id) {
.explicit => |v| v,
.implicit => null,
}) catch |err| {
// This shouldn't happen because startHyperlink should handle // This shouldn't happen because startHyperlink should handle
// resizing. This only happens if we're truly out of RAM. Degrade // resizing. This only happens if we're truly out of RAM. Degrade
// to forgetting the hyperlink. // to forgetting the hyperlink.
@ -1031,7 +1013,8 @@ fn cursorChangePin(self: *Screen, new: Pin) void {
}; };
// Remove our old link // Remove our old link
link.destroy(self.alloc); link.deinit(self.alloc);
self.alloc.destroy(link);
} }
} }
@ -1550,7 +1533,10 @@ fn resizeInternal(
// Fix up our hyperlink if we had one. // Fix up our hyperlink if we had one.
if (hyperlink_) |link| { if (hyperlink_) |link| {
self.startHyperlink(link.uri, link.id) catch |err| { self.startHyperlink(link.uri, switch (link.id) {
.explicit => |v| v,
.implicit => null,
}) catch |err| {
// This shouldn't happen because startHyperlink should handle // This shouldn't happen because startHyperlink should handle
// resizing. This only happens if we're truly out of RAM. Degrade // resizing. This only happens if we're truly out of RAM. Degrade
// to forgetting the hyperlink. // to forgetting the hyperlink.
@ -1558,7 +1544,8 @@ fn resizeInternal(
}; };
// Remove our old link // Remove our old link
link.destroy(self.alloc); link.deinit(self.alloc);
self.alloc.destroy(link);
} }
} }
@ -1805,6 +1792,8 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void {
}; };
} }
pub const StartHyperlinkError = Allocator.Error || PageList.AdjustCapacityError;
/// Start the hyperlink state. Future cells will be marked as hyperlinks with /// Start the hyperlink state. Future cells will be marked as hyperlinks with
/// this state. Note that various terminal operations may clear the hyperlink /// this state. Note that various terminal operations may clear the hyperlink
/// state, such as switching screens (alt screen). /// state, such as switching screens (alt screen).
@ -1812,14 +1801,29 @@ pub fn startHyperlink(
self: *Screen, self: *Screen,
uri: []const u8, uri: []const u8,
id_: ?[]const u8, id_: ?[]const u8,
) !void { ) StartHyperlinkError!void {
// Create our pending entry.
const link: hyperlink.Hyperlink = .{
.uri = uri,
.id = if (id_) |id| .{
.explicit = id,
} else implicit: {
defer self.cursor.hyperlink_implicit_id += 1;
break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id };
},
};
errdefer switch (link.id) {
.explicit => {},
.implicit => self.cursor.hyperlink_implicit_id -= 1,
};
// Loop until we have enough page memory to add the hyperlink // Loop until we have enough page memory to add the hyperlink
while (true) { while (true) {
if (self.startHyperlinkOnce(uri, id_)) { if (self.startHyperlinkOnce(link)) {
return; return;
} else |err| switch (err) { } else |err| switch (err) {
// An actual self.alloc OOM is a fatal error. // An actual self.alloc OOM is a fatal error.
error.RealOutOfMemory => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory,
// strings table is out of memory, adjust it up // strings table is out of memory, adjust it up
error.StringsOutOfMemory => _ = try self.adjustCapacity( error.StringsOutOfMemory => _ = try self.adjustCapacity(
@ -1849,74 +1853,21 @@ pub fn startHyperlink(
/// all the previous state and try again. /// all the previous state and try again.
fn startHyperlinkOnce( fn startHyperlinkOnce(
self: *Screen, self: *Screen,
uri: []const u8, source: hyperlink.Hyperlink,
id_: ?[]const u8, ) (Allocator.Error || Page.InsertHyperlinkError)!void {
) !void {
// End any prior hyperlink // End any prior hyperlink
self.endHyperlink(); self.endHyperlink();
// Create our hyperlink state. // Allocate our new Hyperlink entry in non-page memory. This
const link = Hyperlink.create(self.alloc, uri, id_) catch |err| switch (err) { // lets us quickly get access to URI, ID.
error.OutOfMemory => return error.RealOutOfMemory, const link = try self.alloc.create(hyperlink.Hyperlink);
}; errdefer self.alloc.destroy(link);
errdefer link.destroy(self.alloc); link.* = try source.dupe(self.alloc);
errdefer link.deinit(self.alloc);
// Copy our URI into the page memory. // Insert the hyperlink into page memory
var page = &self.cursor.page_pin.page.data; var page = &self.cursor.page_pin.page.data;
const string_alloc = &page.string_alloc; const id: hyperlink.Id = try page.insertHyperlink(link.*);
const page_uri: Offset(u8).Slice = uri: {
const buf = string_alloc.alloc(u8, page.memory, uri.len) catch |err| switch (err) {
error.OutOfMemory => return error.StringsOutOfMemory,
};
errdefer string_alloc.free(page.memory, buf);
@memcpy(buf, uri);
break :uri .{
.offset = size.getOffset(u8, page.memory, &buf[0]),
.len = uri.len,
};
};
errdefer string_alloc.free(
page.memory,
page_uri.offset.ptr(page.memory)[0..page_uri.len],
);
// Copy our ID into page memory or create an implicit ID via the counter
const page_id: hyperlink.Hyperlink.Id = if (id_) |id| explicit: {
const buf = string_alloc.alloc(u8, page.memory, id.len) catch |err| switch (err) {
error.OutOfMemory => return error.StringsOutOfMemory,
};
errdefer string_alloc.free(page.memory, buf);
@memcpy(buf, id);
break :explicit .{
.explicit = .{
.offset = size.getOffset(u8, page.memory, &buf[0]),
.len = id.len,
},
};
} else implicit: {
defer self.cursor.hyperlink_implicit_id += 1;
break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id };
};
errdefer switch (page_id) {
.implicit => self.cursor.hyperlink_implicit_id -= 1,
.explicit => |slice| string_alloc.free(
page.memory,
slice.offset.ptr(page.memory)[0..slice.len],
),
};
// Put our hyperlink into the hyperlink set to get an ID
const id = page.hyperlink_set.addContext(
page.memory,
.{ .id = page_id, .uri = page_uri },
.{ .page = page },
) catch |err| switch (err) {
error.OutOfMemory => return error.SetOutOfMemory,
error.NeedsRehash => return error.SetNeedsRehash,
};
errdefer page.hyperlink_set.release(page.memory, id);
// Save it all // Save it all
self.cursor.hyperlink = link; self.cursor.hyperlink = link;
@ -1944,7 +1895,8 @@ pub fn endHyperlink(self: *Screen) void {
// will be called. // will be called.
var page = &self.cursor.page_pin.page.data; var page = &self.cursor.page_pin.page.data;
page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id);
self.cursor.hyperlink.?.destroy(self.alloc); self.cursor.hyperlink.?.deinit(self.alloc);
self.alloc.destroy(self.cursor.hyperlink.?);
self.cursor.hyperlink_id = 0; self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null; self.cursor.hyperlink = null;
} }

View File

@ -1,4 +1,5 @@
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert; const assert = std.debug.assert;
const hash_map = @import("hash_map.zig"); const hash_map = @import("hash_map.zig");
const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; const AutoOffsetHashMap = hash_map.AutoOffsetHashMap;
@ -21,9 +22,63 @@ pub const Id = size.CellCountInt;
// the hyperlink ID in the cell itself. // the hyperlink ID in the cell itself.
pub const Map = AutoOffsetHashMap(Offset(Cell), Id); pub const Map = AutoOffsetHashMap(Offset(Cell), Id);
/// The main entry for hyperlinks. /// A fully decoded hyperlink that may or may not have its
/// memory within a page. The memory location of this is dependent
/// on the context so users should check with the source of the
/// hyperlink.
pub const Hyperlink = struct { pub const Hyperlink = struct {
id: Hyperlink.Id, id: Hyperlink.Id,
uri: []const u8,
/// See PageEntry.Id
pub const Id = union(enum) {
explicit: []const u8,
implicit: size.OffsetInt,
};
/// Deinit and deallocate all the pointers using the given
/// allocator.
///
/// WARNING: This should only be called if the hyperlink was
/// heap-allocated. This DOES NOT need to be unconditionally
/// called.
pub fn deinit(self: *const Hyperlink, alloc: Allocator) void {
alloc.free(self.uri);
switch (self.id) {
.implicit => {},
.explicit => |v| alloc.free(v),
}
}
/// Duplicate a hyperlink by allocating all values with the
/// given allocator. The returned hyperlink should have deinit
/// called.
pub fn dupe(
self: *const Hyperlink,
alloc: Allocator,
) Allocator.Error!Hyperlink {
const uri = try alloc.dupe(u8, self.uri);
errdefer alloc.free(uri);
const id: Hyperlink.Id = switch (self.id) {
.implicit => self.id,
.explicit => |v| .{ .explicit = try alloc.dupe(u8, v) },
};
errdefer switch (id) {
.implicit => {},
.explicit => |v| alloc.free(v),
};
return .{ .id = id, .uri = uri };
}
};
/// A hyperlink that has been committed to page memory. This
/// is a "page entry" because while it represents a hyperlink,
/// some decoding (pointer chasing) is still necessary to get the
/// fully realized ID, URI, etc.
pub const PageEntry = struct {
id: PageEntry.Id,
uri: Offset(u8).Slice, uri: Offset(u8).Slice,
pub const Id = union(enum) { pub const Id = union(enum) {
@ -37,10 +92,10 @@ pub const Hyperlink = struct {
/// Duplicate this hyperlink from one page to another. /// Duplicate this hyperlink from one page to another.
pub fn dupe( pub fn dupe(
self: *const Hyperlink, self: *const PageEntry,
self_page: *const Page, self_page: *const Page,
dst_page: *Page, dst_page: *Page,
) error{OutOfMemory}!Hyperlink { ) error{OutOfMemory}!PageEntry {
var copy = self.*; var copy = self.*;
// If the pages are the same then we can return a shallow copy. // If the pages are the same then we can return a shallow copy.
@ -85,7 +140,7 @@ pub const Hyperlink = struct {
return copy; return copy;
} }
pub fn hash(self: *const Hyperlink, base: anytype) u64 { pub fn hash(self: *const PageEntry, base: anytype) u64 {
var hasher = Wyhash.init(0); var hasher = Wyhash.init(0);
autoHash(&hasher, std.meta.activeTag(self.id)); autoHash(&hasher, std.meta.activeTag(self.id));
switch (self.id) { switch (self.id) {
@ -105,9 +160,9 @@ pub const Hyperlink = struct {
} }
pub fn eql( pub fn eql(
self: *const Hyperlink, self: *const PageEntry,
self_base: anytype, self_base: anytype,
other: *const Hyperlink, other: *const PageEntry,
other_base: anytype, other_base: anytype,
) bool { ) bool {
if (std.meta.activeTag(self.id) != std.meta.activeTag(other.id)) return false; if (std.meta.activeTag(self.id) != std.meta.activeTag(other.id)) return false;
@ -135,21 +190,21 @@ pub const Hyperlink = struct {
/// The set of hyperlinks. This is ref-counted so that a set of cells /// The set of hyperlinks. This is ref-counted so that a set of cells
/// can share the same hyperlink without duplicating the data. /// can share the same hyperlink without duplicating the data.
pub const Set = RefCountedSet( pub const Set = RefCountedSet(
Hyperlink, PageEntry,
Id, Id,
size.CellCountInt, size.CellCountInt,
struct { struct {
page: ?*Page = null, page: ?*Page = null,
pub fn hash(self: *const @This(), link: Hyperlink) u64 { pub fn hash(self: *const @This(), link: PageEntry) u64 {
return link.hash(self.page.?.memory); return link.hash(self.page.?.memory);
} }
pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool { pub fn eql(self: *const @This(), a: PageEntry, b: PageEntry) bool {
return a.eql(self.page.?.memory, &b, self.page.?.memory); return a.eql(self.page.?.memory, &b, self.page.?.memory);
} }
pub fn deleted(self: *const @This(), link: Hyperlink) void { pub fn deleted(self: *const @This(), link: PageEntry) void {
const page = self.page.?; const page = self.page.?;
const alloc = &page.string_alloc; const alloc = &page.string_alloc;
switch (link.id) { switch (link.id) {

View File

@ -808,7 +808,7 @@ pub const Page = struct {
// If our page can't support an additional cell with // If our page can't support an additional cell with
// a hyperlink then we have to return an error. // a hyperlink then we have to return an error.
if (self.hyperlinkCount() >= self.hyperlinkCapacity() - 1) { if (self.hyperlinkCount() >= self.hyperlinkCapacity()) {
// The hyperlink map capacity needs to be increased. // The hyperlink map capacity needs to be increased.
return error.HyperlinkMapOutOfMemory; return error.HyperlinkMapOutOfMemory;
} }
@ -1142,6 +1142,101 @@ pub const Page = struct {
row.hyperlink = false; row.hyperlink = false;
} }
pub const InsertHyperlinkError = error{
/// string_alloc errors
StringsOutOfMemory,
/// hyperlink_set errors
SetOutOfMemory,
SetNeedsRehash,
};
/// Convert a hyperlink into a page entry, returning the ID.
///
/// This does not de-dupe any strings, so if the URI, explicit ID,
/// etc. is already in the strings table this will duplicate it.
///
/// To release the memory associated with the given hyperlink,
/// release the ID from the `hyperlink_set`. If the refcount reaches
/// zero and the slot is needed then the context will reap the
/// memory.
pub fn insertHyperlink(
self: *Page,
link: hyperlink.Hyperlink,
) InsertHyperlinkError!hyperlink.Id {
// Insert our URI into the page strings table.
const page_uri: Offset(u8).Slice = uri: {
const buf = self.string_alloc.alloc(
u8,
self.memory,
link.uri.len,
) catch |err| switch (err) {
error.OutOfMemory => return error.StringsOutOfMemory,
};
errdefer self.string_alloc.free(self.memory, buf);
@memcpy(buf, link.uri);
break :uri .{
.offset = size.getOffset(u8, self.memory, &buf[0]),
.len = link.uri.len,
};
};
errdefer self.string_alloc.free(
self.memory,
page_uri.offset.ptr(self.memory)[0..page_uri.len],
);
// Allocate an ID for our page memory if we have to.
const page_id: hyperlink.PageEntry.Id = switch (link.id) {
.explicit => |id| explicit: {
const buf = self.string_alloc.alloc(
u8,
self.memory,
id.len,
) catch |err| switch (err) {
error.OutOfMemory => return error.StringsOutOfMemory,
};
errdefer self.string_alloc.free(self.memory, buf);
@memcpy(buf, id);
break :explicit .{
.explicit = .{
.offset = size.getOffset(u8, self.memory, &buf[0]),
.len = id.len,
},
};
},
.implicit => |id| .{ .implicit = id },
};
errdefer switch (page_id) {
.implicit => {},
.explicit => |slice| self.string_alloc.free(
self.memory,
slice.offset.ptr(self.memory)[0..slice.len],
),
};
// Build our entry
const entry: hyperlink.PageEntry = .{
.id = page_id,
.uri = page_uri,
};
// Put our hyperlink into the hyperlink set to get an ID
const id = self.hyperlink_set.addContext(
self.memory,
entry,
.{ .page = self },
) catch |err| switch (err) {
error.OutOfMemory => return error.SetOutOfMemory,
error.NeedsRehash => return error.SetNeedsRehash,
};
errdefer self.hyperlink_set.release(self.memory, id);
return id;
}
/// Set the hyperlink for the given cell. If the cell already has a /// Set the hyperlink for the given cell. If the cell already has a
/// hyperlink, then this will handle memory management and refcount /// hyperlink, then this will handle memory management and refcount
/// update for the prior hyperlink. /// update for the prior hyperlink.
@ -2237,6 +2332,50 @@ test "Page cloneFrom partial" {
} }
} }
test "Page cloneFrom hyperlinks exact capacity" {
var page = try Page.init(.{
.cols = 50,
.rows = 50,
});
defer page.deinit();
// Ensure our page can accommodate the capacity.
const hyperlink_cap = page.hyperlinkCapacity();
try testing.expect(hyperlink_cap <= page.size.cols * page.size.rows);
// Create a hyperlink.
const hyperlink_id = try page.insertHyperlink(.{
.id = .{ .implicit = 0 },
.uri = "https://example.com",
});
// Fill the exact cap with cells.
fill: for (0..page.size.cols) |x| {
for (0..page.size.rows) |y| {
const rac = page.getRowAndCell(x, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 42 },
};
try page.setHyperlink(rac.row, rac.cell, hyperlink_id);
page.hyperlink_set.use(page.memory, hyperlink_id);
if (page.hyperlinkCount() == hyperlink_cap) {
break :fill;
}
}
}
try testing.expectEqual(page.hyperlinkCount(), page.hyperlinkCapacity());
// Clone the full page
var page2 = try Page.init(page.capacity);
defer page2.deinit();
try page2.cloneFrom(&page, 0, page.size.rows);
// We should have the same number of hyperlinks
try testing.expectEqual(page2.hyperlinkCount(), page.hyperlinkCount());
}
test "Page cloneFrom graphemes" { test "Page cloneFrom graphemes" {
var page = try Page.init(.{ var page = try Page.init(.{
.cols = 10, .cols = 10,