mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
Merge remote-tracking branch 'upstream/main' into titlebar-unzoom-button
This commit is contained in:
20
README.md
20
README.md
@ -434,7 +434,8 @@ To build Ghostty, you need [Zig](https://ziglang.org/) installed.
|
||||
|
||||
On Linux, you may need to install additional dependencies. See
|
||||
[Linux Installation Tips](#linux-installation-tips). On macOS, you
|
||||
need Xcode installed with the macOS and iOS SDKs enabled.
|
||||
need Xcode installed with the macOS and iOS SDKs enabled. See
|
||||
[Mac `.app`](#mac-app).
|
||||
|
||||
The official development environment is defined by Nix. You do not need
|
||||
to use Nix to develop Ghostty, but the Nix environment is the environment
|
||||
@ -565,7 +566,22 @@ all features of Ghostty work.
|
||||
### Mac `.app`
|
||||
|
||||
To build the official, fully featured macOS application, you must
|
||||
build on a macOS machine with XCode installed:
|
||||
build on a macOS machine with Xcode installed, and the active developer
|
||||
directory pointing to it. If you're not sure that's the case, check the
|
||||
output of `xcode-select --print-path`:
|
||||
|
||||
```shell-session
|
||||
$ xcode-select --print-path
|
||||
/Library/Developer/CommandLineTools # <-- BAD
|
||||
$ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
|
||||
$ xcode-select --print-path
|
||||
/Applications/Xcode.app/Contents/Developer # <-- GOOD
|
||||
```
|
||||
|
||||
The above can happen if you install the Xcode Command Line Tools _after_ Xcode
|
||||
is installed. With that out of the way, make sure you have both the macOS and
|
||||
iOS SDKs installed (from inside Xcode → Settings → Platforms), and let's move
|
||||
on to building Ghostty:
|
||||
|
||||
```shell-session
|
||||
$ zig build -Doptimize=ReleaseFast
|
||||
|
61
build.zig
61
build.zig
@ -3,6 +3,7 @@ const builtin = @import("builtin");
|
||||
const fs = std.fs;
|
||||
const CompileStep = std.Build.Step.Compile;
|
||||
const RunStep = std.Build.Step.Run;
|
||||
const ResolvedTarget = std.Build.ResolvedTarget;
|
||||
|
||||
const apprt = @import("src/apprt.zig");
|
||||
const font = @import("src/font/main.zig");
|
||||
@ -706,10 +707,15 @@ pub fn build(b: *std.Build) !void {
|
||||
const test_step = b.step("test", "Run all tests");
|
||||
const test_filter = b.option([]const u8, "test-filter", "Filter for test");
|
||||
|
||||
// Force all Mac builds to use a `generic` CPU. This avoids
|
||||
// potential issues with `highway` compile errors due to missing
|
||||
// `arm_neon` features (see for example https://github.com/mitchellh/ghostty/issues/1640).
|
||||
const test_target = if (target.result.os.tag == .macos and builtin.target.isDarwin()) genericMacOSTarget(b) else target;
|
||||
|
||||
const main_test = b.addTest(.{
|
||||
.name = "ghostty-test",
|
||||
.root_source_file = .{ .path = "src/main.zig" },
|
||||
.target = target,
|
||||
.target = test_target,
|
||||
.filter = test_filter,
|
||||
});
|
||||
|
||||
@ -754,6 +760,16 @@ fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion {
|
||||
};
|
||||
}
|
||||
|
||||
// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`.
|
||||
// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`.
|
||||
fn genericMacOSTarget(b: *std.Build) ResolvedTarget {
|
||||
return b.resolveTargetQuery(.{
|
||||
.cpu_arch = .aarch64,
|
||||
.os_tag = .macos,
|
||||
.os_version_min = osVersionMin(.macos),
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a universal macOS libghostty library and returns the path
|
||||
/// to the final library.
|
||||
///
|
||||
@ -781,11 +797,7 @@ fn createMacOSLib(
|
||||
const lib = b.addStaticLibrary(.{
|
||||
.name = "ghostty",
|
||||
.root_source_file = .{ .path = "src/main_c.zig" },
|
||||
.target = b.resolveTargetQuery(.{
|
||||
.cpu_arch = .aarch64,
|
||||
.os_tag = .macos,
|
||||
.os_version_min = osVersionMin(.macos),
|
||||
}),
|
||||
.target = genericMacOSTarget(b),
|
||||
.optimize = optimize,
|
||||
});
|
||||
lib.bundle_compiler_rt = true;
|
||||
@ -1165,6 +1177,43 @@ fn addDeps(
|
||||
.gtk => {
|
||||
step.linkSystemLibrary2("gtk4", dynamic_link_opts);
|
||||
if (config.libadwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts);
|
||||
|
||||
{
|
||||
const gresource = @import("src/apprt/gtk/gresource.zig");
|
||||
|
||||
const wf = b.addWriteFiles();
|
||||
const gresource_xml = wf.add(
|
||||
"gresource.xml",
|
||||
if (config.libadwaita)
|
||||
gresource.gresource_xml_libadwaita
|
||||
else
|
||||
gresource.gresource_xml_gtk,
|
||||
);
|
||||
|
||||
const generate_resources_c = b.addSystemCommand(&.{
|
||||
"glib-compile-resources",
|
||||
"--c-name",
|
||||
"ghostty",
|
||||
"--generate-source",
|
||||
"--target",
|
||||
});
|
||||
const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c");
|
||||
generate_resources_c.addFileArg(gresource_xml);
|
||||
generate_resources_c.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk;
|
||||
step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} });
|
||||
|
||||
const generate_resources_h = b.addSystemCommand(&.{
|
||||
"glib-compile-resources",
|
||||
"--c-name",
|
||||
"ghostty",
|
||||
"--generate-header",
|
||||
"--target",
|
||||
});
|
||||
const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h");
|
||||
generate_resources_h.addFileArg(gresource_xml);
|
||||
generate_resources_h.extra_file_dependencies = if (config.libadwaita) &gresource.dependencies_libadwaita else &gresource.dependencies_gtk;
|
||||
step.addIncludePath(ghostty_resources_h.dirname());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
6
flake.lock
generated
6
flake.lock
generated
@ -147,11 +147,11 @@
|
||||
},
|
||||
"nixpkgs-zig-0-12": {
|
||||
"locked": {
|
||||
"lastModified": 1711143939,
|
||||
"narHash": "sha256-oT6a81U4NHjJH1hjaMVXKsdTZJwl2dT+MhMESKoevvA=",
|
||||
"lastModified": 1711789504,
|
||||
"narHash": "sha256-1XRwW0MD9LxtHMMlPmF3rDw/Zbv4jLnpGnJEtibO+MQ=",
|
||||
"owner": "vancluever",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c4749393c06e52da4adf42877fdf9bac7141f0de",
|
||||
"rev": "c9e24149cca8215b84fc3ce5bc2bdc1ca823a588",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
24
flake.nix
24
flake.nix
@ -52,23 +52,19 @@
|
||||
wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
|
||||
};
|
||||
|
||||
packages.${system} = rec {
|
||||
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix {
|
||||
packages.${system} = let
|
||||
mkArgs = optimize: {
|
||||
inherit (pkgs-zig-0-12) zig_0_12;
|
||||
inherit optimize;
|
||||
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
optimize = "Debug";
|
||||
};
|
||||
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix {
|
||||
inherit (pkgs-zig-0-12) zig_0_12;
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
optimize = "ReleaseSafe";
|
||||
};
|
||||
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix {
|
||||
inherit (pkgs-zig-0-12) zig_0_12;
|
||||
revision = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
optimize = "ReleaseFast";
|
||||
};
|
||||
ghostty = ghostty-releasesafe;
|
||||
in rec {
|
||||
ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug");
|
||||
ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
|
||||
ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
|
||||
|
||||
ghostty = ghostty-releasefast;
|
||||
default = ghostty;
|
||||
};
|
||||
|
||||
|
@ -175,6 +175,13 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
|
||||
window.appearance = appearance
|
||||
}
|
||||
|
||||
// Set the font for the window and tab titles.
|
||||
if let titleFontName = ghostty.config.windowTitleFontFamily {
|
||||
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
|
||||
} else {
|
||||
window.titlebarFont = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
|
||||
@ -258,7 +265,6 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
if (ghostty.config.macosTitlebarTabs) {
|
||||
window.tabbingMode = .preferred
|
||||
window.titlebarTabs = true
|
||||
syncAppearance()
|
||||
DispatchQueue.main.async {
|
||||
window.tabbingMode = .automatic
|
||||
}
|
||||
@ -290,6 +296,9 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
window.tabGroup?.removeWindow(window)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply any additional appearance-related properties to the new window.
|
||||
syncAppearance()
|
||||
}
|
||||
|
||||
// Shows the "+" button in the tab bar, responds to that click.
|
||||
|
@ -15,6 +15,16 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
var titleFont: NSFont? {
|
||||
get {
|
||||
titleTextField.font
|
||||
}
|
||||
|
||||
set {
|
||||
titleTextField.font = newValue
|
||||
}
|
||||
}
|
||||
|
||||
override init(identifier: NSToolbar.Identifier) {
|
||||
super.init(identifier: identifier)
|
||||
|
||||
|
@ -96,6 +96,12 @@ class TerminalWindow: NSWindow {
|
||||
}
|
||||
|
||||
// MARK: - NSWindow
|
||||
|
||||
override var title: String {
|
||||
didSet {
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
}
|
||||
|
||||
override func becomeKey() {
|
||||
// This is required because the removeTitlebarAccessoryViewController hook does not
|
||||
@ -109,6 +115,7 @@ class TerminalWindow: NSWindow {
|
||||
updateNewTabButtonOpacity()
|
||||
resetZoomTabButton.contentTintColor = .controlAccentColor
|
||||
resetZoomToolbarButton.contentTintColor = .controlAccentColor
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
|
||||
override func resignKey() {
|
||||
@ -117,6 +124,7 @@ class TerminalWindow: NSWindow {
|
||||
updateNewTabButtonOpacity()
|
||||
resetZoomTabButton.contentTintColor = .secondaryLabelColor
|
||||
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
|
||||
tab.attributedTitle = attributedTitle
|
||||
}
|
||||
|
||||
override func layoutIfNeeded() {
|
||||
@ -171,6 +179,38 @@ class TerminalWindow: NSWindow {
|
||||
updateNewTabButtonImage()
|
||||
updateResetZoomTitlebarButtonVisibility()
|
||||
}
|
||||
|
||||
// Used to set the titlebar font.
|
||||
var titlebarFont: NSFont? {
|
||||
didSet {
|
||||
titlebarTextField?.font = titlebarFont
|
||||
tab.attributedTitle = attributedTitle
|
||||
|
||||
if let toolbar = toolbar as? TerminalToolbar {
|
||||
toolbar.titleFont = titlebarFont
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the NSTextField responsible for displaying the titlebar's title.
|
||||
private var titlebarTextField: NSTextField? {
|
||||
guard let titlebarContainer = contentView?.superview?.subviews
|
||||
.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil }
|
||||
guard let titlebarView = titlebarContainer.subviews
|
||||
.first(where: { $0.className == "NSTitlebarView" }) else { return nil }
|
||||
return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField
|
||||
}
|
||||
|
||||
// Return a styled representation of our title property.
|
||||
private var attributedTitle: NSAttributedString? {
|
||||
guard let titlebarFont else { return nil }
|
||||
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: titlebarFont,
|
||||
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
||||
]
|
||||
return NSAttributedString(string: title, attributes: attributes)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
|
@ -197,7 +197,16 @@ extension Ghostty {
|
||||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
var windowTitleFontFamily: String? {
|
||||
guard let config = self.config else { return nil }
|
||||
var v: UnsafePointer<Int8>? = nil
|
||||
let key = "window-title-font-family"
|
||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil }
|
||||
guard let ptr = v else { return nil }
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
var macosTitlebarTabs: Bool {
|
||||
guard let config = self.config else { return false }
|
||||
var v = false;
|
||||
|
@ -78,7 +78,7 @@ in
|
||||
mkShell {
|
||||
name = "ghostty";
|
||||
|
||||
nativeBuildInputs =
|
||||
packages =
|
||||
[
|
||||
# For builds
|
||||
llvmPackages_latest.llvm
|
||||
@ -120,13 +120,7 @@ in
|
||||
gdb
|
||||
valgrind
|
||||
wraptest
|
||||
];
|
||||
|
||||
buildInputs =
|
||||
[
|
||||
# TODO: non-linux
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
bzip2
|
||||
expat
|
||||
fontconfig
|
||||
|
@ -151,8 +151,16 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
|
||||
break :app @ptrCast(adw_app);
|
||||
};
|
||||
|
||||
errdefer c.g_object_unref(app);
|
||||
const gapp = @as(*c.GApplication, @ptrCast(app));
|
||||
|
||||
// force the resource path to a known value so that it doesn't depend on
|
||||
// the app id and load in compiled resources
|
||||
c.g_application_set_resource_base_path(gapp, "/com/mitchellh/ghostty");
|
||||
c.g_resources_register(c.ghostty_get_resource());
|
||||
|
||||
// The `activate` signal is used when Ghostty is first launched and when a
|
||||
// secondary Ghostty is launched and requests a new window.
|
||||
_ = c.g_signal_connect_data(
|
||||
app,
|
||||
"activate",
|
||||
@ -169,7 +177,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
|
||||
if (c.g_main_context_acquire(ctx) == 0) return error.GtkContextAcquireFailed;
|
||||
errdefer c.g_main_context_release(ctx);
|
||||
|
||||
const gapp = @as(*c.GApplication, @ptrCast(app));
|
||||
var err_: ?*c.GError = null;
|
||||
if (c.g_application_register(
|
||||
gapp,
|
||||
|
@ -53,6 +53,7 @@ fn init(self: *ConfigErrors, app: *App) !void {
|
||||
c.gtk_window_set_title(gtk_window, "Configuration Errors");
|
||||
c.gtk_window_set_default_size(gtk_window, 600, 275);
|
||||
c.gtk_window_set_resizable(gtk_window, 0);
|
||||
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
|
||||
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT);
|
||||
|
||||
// Set some state
|
||||
|
@ -943,32 +943,14 @@ pub fn showDesktopNotification(
|
||||
0 => "Ghostty",
|
||||
else => title,
|
||||
};
|
||||
|
||||
const notif = c.g_notification_new(t.ptr);
|
||||
defer c.g_object_unref(notif);
|
||||
c.g_notification_set_body(notif, body.ptr);
|
||||
|
||||
// Find our icon in the current icon theme. Not pretty, but the builtin GIO
|
||||
// method "g_themed_icon_new" doesn't search XDG_DATA_DIRS, so any install
|
||||
// not in /usr/share will be unable to find an icon
|
||||
const display = c.gdk_display_get_default();
|
||||
const theme = c.gtk_icon_theme_get_for_display(display);
|
||||
const icon = c.gtk_icon_theme_lookup_icon(
|
||||
theme,
|
||||
"com.mitchellh.ghostty",
|
||||
null,
|
||||
48,
|
||||
1, // Window scale
|
||||
c.GTK_TEXT_DIR_LTR,
|
||||
0,
|
||||
);
|
||||
const icon = c.g_themed_icon_new("com.mitchellh.ghostty");
|
||||
defer c.g_object_unref(icon);
|
||||
// Get the filepath of the icon we found
|
||||
const file = c.gtk_icon_paintable_get_file(icon);
|
||||
defer c.g_object_unref(file);
|
||||
// Create a GIO icon
|
||||
const gicon = c.g_file_icon_new(file);
|
||||
defer c.g_object_unref(gicon);
|
||||
c.g_notification_set_icon(notif, gicon);
|
||||
c.g_notification_set_icon(notif, icon);
|
||||
|
||||
const g_app: *c.GApplication = @ptrCast(self.app.app);
|
||||
|
||||
@ -984,6 +966,8 @@ fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
|
||||
c.gtk_gl_area_make_current(area);
|
||||
if (c.gtk_gl_area_get_error(area)) |err| {
|
||||
log.err("surface failed to realize: {s}", .{err.*.message});
|
||||
log.warn("this error is usually due to a driver or gtk bug", .{});
|
||||
log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1624,6 +1608,7 @@ fn gtkInputCommit(
|
||||
|
||||
fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
|
||||
const self = userdataSelf(ud.?);
|
||||
if (!self.realized) return;
|
||||
|
||||
// Notify our IM context
|
||||
c.gtk_im_context_focus_in(self.im_context);
|
||||
@ -1637,6 +1622,7 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo
|
||||
|
||||
fn gtkFocusLeave(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) void {
|
||||
const self = userdataSelf(ud.?);
|
||||
if (!self.realized) return;
|
||||
|
||||
// Notify our IM context
|
||||
c.gtk_im_context_focus_out(self.im_context);
|
||||
|
@ -18,7 +18,6 @@ const CoreSurface = @import("../../Surface.zig");
|
||||
const App = @import("App.zig");
|
||||
const Surface = @import("Surface.zig");
|
||||
const Tab = @import("Tab.zig");
|
||||
const icon = @import("icon.zig");
|
||||
const c = @import("c.zig");
|
||||
|
||||
const log = std.log.scoped(.gtk);
|
||||
@ -31,10 +30,6 @@ window: *c.GtkWindow,
|
||||
/// The notebook (tab grouping) for this window.
|
||||
notebook: *c.GtkNotebook,
|
||||
|
||||
/// The resources directory for the icon (if any). We need to retain a
|
||||
/// pointer to this because GTK can use it at any time.
|
||||
icon: icon.Icon,
|
||||
|
||||
pub fn create(alloc: Allocator, app: *App) !*Window {
|
||||
// Allocate a fixed pointer for our window. We try to minimize
|
||||
// allocations but windows and other GUI requirements are so minimal
|
||||
@ -53,7 +48,6 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
// Set up our own state
|
||||
self.* = .{
|
||||
.app = app,
|
||||
.icon = undefined,
|
||||
.window = undefined,
|
||||
.notebook = undefined,
|
||||
};
|
||||
@ -70,10 +64,7 @@ pub fn init(self: *Window, app: *App) !void {
|
||||
// to disable this so that terminal programs can capture F10 (such as htop)
|
||||
c.gtk_window_set_handle_menubar_accel(gtk_window, 0);
|
||||
|
||||
// If we don't have the icon then we'll try to add our resources dir
|
||||
// to the search path and see if we can find it there.
|
||||
self.icon = try icon.appIcon(self.app, window);
|
||||
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
|
||||
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
|
||||
|
||||
// Apply background opacity if we have it
|
||||
if (app.config.@"background-opacity" < 1) {
|
||||
@ -189,9 +180,7 @@ fn initActions(self: *Window) void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Window) void {
|
||||
self.icon.deinit(self.app);
|
||||
}
|
||||
pub fn deinit(_: *Window) void {}
|
||||
|
||||
/// Add a new tab to this window.
|
||||
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
|
||||
|
@ -7,6 +7,9 @@ const c = @cImport({
|
||||
@cInclude("gdk/x11/gdkx.h");
|
||||
// Xkb for X11 state handling
|
||||
@cInclude("X11/XKBlib.h");
|
||||
|
||||
// generated header files
|
||||
@cInclude("ghostty_resources.h");
|
||||
});
|
||||
|
||||
pub usingnamespace c;
|
||||
|
124
src/apprt/gtk/gresource.zig
Normal file
124
src/apprt/gtk/gresource.zig
Normal file
@ -0,0 +1,124 @@
|
||||
const std = @import("std");
|
||||
|
||||
const css_files = [_][]const u8{
|
||||
"style.css",
|
||||
"style-dark.css",
|
||||
"style-hc.css",
|
||||
"style-hc-dark.css",
|
||||
};
|
||||
|
||||
const icons = [_]struct {
|
||||
alias: []const u8,
|
||||
source: []const u8,
|
||||
}{
|
||||
.{
|
||||
.alias = "16x16",
|
||||
.source = "16x16",
|
||||
},
|
||||
.{
|
||||
.alias = "16x16@2",
|
||||
.source = "16x16@2x@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "32x32",
|
||||
.source = "32x32",
|
||||
},
|
||||
.{
|
||||
.alias = "32x32@2",
|
||||
.source = "32x32@2x@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "128x128",
|
||||
.source = "128x128",
|
||||
},
|
||||
.{
|
||||
.alias = "128x128@2",
|
||||
.source = "128x128@2x@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "256x256",
|
||||
.source = "256x256",
|
||||
},
|
||||
.{
|
||||
.alias = "256x256@2",
|
||||
.source = "256x256@2x@2x",
|
||||
},
|
||||
.{
|
||||
.alias = "512x512",
|
||||
.source = "512x512",
|
||||
},
|
||||
};
|
||||
|
||||
pub const gresource_xml_gtk = comptimeGenerateGResourceXML(false);
|
||||
pub const gresource_xml_libadwaita = comptimeGenerateGResourceXML(true);
|
||||
|
||||
fn comptimeGenerateGResourceXML(comptime libadwaita: bool) []const u8 {
|
||||
comptime {
|
||||
@setEvalBranchQuota(13000);
|
||||
var counter = std.io.countingWriter(std.io.null_writer);
|
||||
try writeGResourceXML(libadwaita, &counter.writer());
|
||||
|
||||
var buf: [counter.bytes_written]u8 = undefined;
|
||||
var stream = std.io.fixedBufferStream(&buf);
|
||||
try writeGResourceXML(libadwaita, stream.writer());
|
||||
return stream.getWritten();
|
||||
}
|
||||
}
|
||||
|
||||
fn writeGResourceXML(libadwaita: bool, writer: anytype) !void {
|
||||
try writer.writeAll(
|
||||
\\<?xml version="1.0" encoding="UTF-8"?>
|
||||
\\<gresources>
|
||||
\\
|
||||
);
|
||||
if (libadwaita) {
|
||||
try writer.writeAll(
|
||||
\\ <gresource prefix="/com/mitchellh/ghostty">
|
||||
\\
|
||||
);
|
||||
for (css_files) |css_file| {
|
||||
try writer.print(
|
||||
" <file compressed=\"true\" alias=\"{s}\">src/apprt/gtk/{s}</file>\n",
|
||||
.{ css_file, css_file },
|
||||
);
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\ </gresource>
|
||||
\\
|
||||
);
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\ <gresource prefix="/com/mitchellh/ghostty/icons">
|
||||
\\
|
||||
);
|
||||
for (icons) |icon| {
|
||||
try writer.print(
|
||||
" <file alias=\"{s}/apps/com.mitchellh.ghostty.png\">images/icons/icon_{s}.png</file>\n",
|
||||
.{ icon.alias, icon.source },
|
||||
);
|
||||
}
|
||||
try writer.writeAll(
|
||||
\\ </gresource>
|
||||
\\</gresources>
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
pub const dependencies_gtk = deps: {
|
||||
var deps: [icons.len][]const u8 = undefined;
|
||||
for (icons, 0..) |icon, i| {
|
||||
deps[i] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
|
||||
}
|
||||
break :deps deps;
|
||||
};
|
||||
|
||||
pub const dependencies_libadwaita = deps: {
|
||||
var deps: [css_files.len + icons.len][]const u8 = undefined;
|
||||
for (css_files, 0..) |css_file, i| {
|
||||
deps[i] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file});
|
||||
}
|
||||
for (icons, css_files.len..) |icon, i| {
|
||||
deps[i] = std.fmt.comptimePrint("images/icons/icon_{s}.png", .{icon.source});
|
||||
}
|
||||
break :deps deps;
|
||||
};
|
@ -1,63 +0,0 @@
|
||||
const std = @import("std");
|
||||
|
||||
const App = @import("App.zig");
|
||||
const c = @import("c.zig");
|
||||
const global_state = &@import("../../main.zig").state;
|
||||
|
||||
const log = std.log.scoped(.gtk_icon);
|
||||
|
||||
/// An icon. The icon may be associated with some allocated state so when
|
||||
/// the icon is no longer in use it should be deinitialized.
|
||||
pub const Icon = struct {
|
||||
name: [:0]const u8,
|
||||
state: ?[:0]const u8 = null,
|
||||
|
||||
pub fn deinit(self: *const Icon, app: *App) void {
|
||||
if (self.state) |v| app.core_app.alloc.free(v);
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns the application icon that can be used anywhere. This attempts to
|
||||
/// find the icon in the theme and if it can't be found, it is loaded from
|
||||
/// the resources dir. If the resources dir can't be found, we'll log a warning
|
||||
/// and let GTK choose a fallback.
|
||||
pub fn appIcon(app: *App, widget: *c.GtkWidget) !Icon {
|
||||
const icon_name = "com.mitchellh.ghostty";
|
||||
var result: Icon = .{ .name = icon_name };
|
||||
|
||||
// If we don't have the icon then we'll try to add our resources dir
|
||||
// to the search path and see if we can find it there.
|
||||
const icon_theme = c.gtk_icon_theme_get_for_display(c.gtk_widget_get_display(widget));
|
||||
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) icon: {
|
||||
const resources_dir = global_state.resources_dir orelse {
|
||||
log.info("gtk app missing Ghostty icon and no resources dir detected", .{});
|
||||
log.info("gtk app will not have Ghostty icon", .{});
|
||||
break :icon;
|
||||
};
|
||||
|
||||
// The resources dir usually is `/usr/share/ghostty` but GTK icons
|
||||
// go into `/usr/share/icons`.
|
||||
const base = std.fs.path.dirname(resources_dir) orelse {
|
||||
log.warn(
|
||||
"unexpected error getting dirname of resources dir dir={s}",
|
||||
.{resources_dir},
|
||||
);
|
||||
break :icon;
|
||||
};
|
||||
|
||||
// Note that this method for adding the icon search path is
|
||||
// a fallback mechanism. The recommended mechanism is the
|
||||
// Freedesktop Icon Theme Specification. We distribute a ".desktop"
|
||||
// file in zig-out/share that should be installed to the proper
|
||||
// place.
|
||||
const dir = try std.fmt.allocPrintZ(app.core_app.alloc, "{s}/icons", .{base});
|
||||
errdefer app.core_app.alloc.free(dir);
|
||||
result.state = dir;
|
||||
c.gtk_icon_theme_add_search_path(icon_theme, dir.ptr);
|
||||
if (c.gtk_icon_theme_has_icon(icon_theme, icon_name) == 0) {
|
||||
log.warn("Ghostty icon for gtk app not found", .{});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@ -7,7 +7,6 @@ const Surface = @import("Surface.zig");
|
||||
const TerminalWindow = @import("Window.zig");
|
||||
const ImguiWidget = @import("ImguiWidget.zig");
|
||||
const c = @import("c.zig");
|
||||
const icon = @import("icon.zig");
|
||||
const CoreInspector = @import("../../inspector/main.zig").Inspector;
|
||||
|
||||
const log = std.log.scoped(.inspector);
|
||||
@ -125,14 +124,12 @@ pub const Inspector = struct {
|
||||
const Window = struct {
|
||||
inspector: *Inspector,
|
||||
window: *c.GtkWindow,
|
||||
icon: icon.Icon,
|
||||
imgui_widget: ImguiWidget,
|
||||
|
||||
pub fn init(self: *Window, inspector: *Inspector) !void {
|
||||
// Initialize to undefined
|
||||
self.* = .{
|
||||
.inspector = inspector,
|
||||
.icon = undefined,
|
||||
.window = undefined,
|
||||
.imgui_widget = undefined,
|
||||
};
|
||||
@ -144,8 +141,7 @@ const Window = struct {
|
||||
self.window = gtk_window;
|
||||
c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector");
|
||||
c.gtk_window_set_default_size(gtk_window, 1000, 600);
|
||||
self.icon = try icon.appIcon(self.inspector.surface.app, window);
|
||||
c.gtk_window_set_icon_name(gtk_window, self.icon.name);
|
||||
c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty");
|
||||
|
||||
// Initialize our imgui widget
|
||||
try self.imgui_widget.init();
|
||||
@ -163,7 +159,6 @@ const Window = struct {
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Window) void {
|
||||
self.icon.deinit(self.inspector.surface.app);
|
||||
self.inspector.locationDidClose();
|
||||
}
|
||||
|
||||
|
0
src/apprt/gtk/style-dark.css
Normal file
0
src/apprt/gtk/style-dark.css
Normal file
0
src/apprt/gtk/style-hc-dark.css
Normal file
0
src/apprt/gtk/style-hc-dark.css
Normal file
0
src/apprt/gtk/style-hc.css
Normal file
0
src/apprt/gtk/style-hc.css
Normal file
0
src/apprt/gtk/style.css
Normal file
0
src/apprt/gtk/style.css
Normal file
@ -624,6 +624,11 @@ keybind: Keybinds = .{},
|
||||
/// borders.
|
||||
@"window-decoration": bool = true,
|
||||
|
||||
/// The font that will be used for the application's window and tab titles.
|
||||
///
|
||||
/// This is currently only supported on macOS.
|
||||
@"window-title-font-family": ?[:0]const u8 = null,
|
||||
|
||||
/// The theme to use for the windows. Valid values:
|
||||
///
|
||||
/// * `auto` - Determine the theme based on the configured terminal
|
||||
|
@ -26,7 +26,7 @@ pub inline fn copy(comptime T: type, dest: []T, source: []const T) void {
|
||||
/// and a tmp var for the single rotated item instead of 3 calls to reverse.
|
||||
pub inline fn rotateOnce(comptime T: type, items: []T) void {
|
||||
const tmp = items[0];
|
||||
move(T, items[0..items.len - 1], items[1..items.len]);
|
||||
move(T, items[0 .. items.len - 1], items[1..items.len]);
|
||||
items[items.len - 1] = tmp;
|
||||
}
|
||||
|
||||
|
@ -2431,139 +2431,147 @@ fn draw_dash_horizontal(
|
||||
) void {
|
||||
assert(count >= 2 and count <= 4);
|
||||
|
||||
// The number of gaps we have is one less than the number of dashes.
|
||||
// "- - -" => 2 gaps
|
||||
const gap_count = count - 1;
|
||||
// +------------+
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | -- -- -- |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// +------------+
|
||||
// Our dashed line should be made such that when tiled horizontally
|
||||
// it creates one consistent line with no uneven gap or segment sizes.
|
||||
// In order to make sure this is the case, we should have half-sized
|
||||
// gaps on the left and right so that it is centered properly.
|
||||
|
||||
// Determine the width of each dash and the gap between them. We try
|
||||
// to have gap match desired_gap but if our cell is too small then we
|
||||
// have to bring it down.
|
||||
const adjusted: struct {
|
||||
dash_width: u32,
|
||||
gap: u32,
|
||||
} = adjusted: {
|
||||
for (0..desired_gap) |i| {
|
||||
const gap_width: u32 = desired_gap - @as(u32, @intCast(i));
|
||||
const total_gap_width: u32 = gap_count * gap_width;
|
||||
// For N dashes, there are N - 1 gaps between them, but we also have
|
||||
// half-sized gaps on either side, adding up to N total gaps.
|
||||
const gap_count = count;
|
||||
|
||||
// This would make a negative and overflow our u32. A negative
|
||||
// dash width is not allowed so we keep trying to fit it.
|
||||
if (total_gap_width >= self.width) continue;
|
||||
|
||||
break :adjusted .{
|
||||
.dash_width = (self.width - total_gap_width) / count,
|
||||
.gap = gap_width,
|
||||
};
|
||||
}
|
||||
|
||||
// In this case, there is no combination of gap width and dash
|
||||
// width that would fit our desired number of dashes, so we just
|
||||
// draw a horizontal line.
|
||||
// We need at least 1 pixel for each gap and each dash, if we don't
|
||||
// have that then we can't draw our dashed line correctly so we just
|
||||
// draw a solid line and return.
|
||||
if (self.width < count + gap_count) {
|
||||
self.hline_middle(canvas, .light);
|
||||
return;
|
||||
};
|
||||
const dash_width = adjusted.dash_width;
|
||||
const gap = adjusted.gap;
|
||||
|
||||
// Our total width should be less than our real width
|
||||
assert(count * dash_width + gap_count * gap <= self.width);
|
||||
const remaining = self.width - count * dash_width - gap_count * gap;
|
||||
|
||||
var x: [4]u32 = .{0} ** 4;
|
||||
var w: [4]u32 = .{dash_width} ** 4;
|
||||
x[1] = x[0] + w[0] + gap;
|
||||
if (count == 2)
|
||||
w[1] = self.width - x[1]
|
||||
else if (count == 3)
|
||||
w[1] += remaining
|
||||
else
|
||||
w[1] += remaining / 2;
|
||||
|
||||
if (count >= 3) {
|
||||
x[2] = x[1] + w[1] + gap;
|
||||
if (count == 3)
|
||||
w[2] = self.width - x[2]
|
||||
else
|
||||
w[2] += remaining - remaining / 2;
|
||||
}
|
||||
|
||||
if (count >= 4) {
|
||||
x[3] = x[2] + w[2] + gap;
|
||||
w[3] = self.width - x[3];
|
||||
}
|
||||
// We never want the gaps to take up more than 50% of the space,
|
||||
// because if they do the dashes are too small and look wrong.
|
||||
const gap_width = @min(desired_gap, self.width / (2 * count));
|
||||
const total_gap_width = gap_count * gap_width;
|
||||
const total_dash_width = self.width - total_gap_width;
|
||||
const dash_width = total_dash_width / count;
|
||||
const remaining = total_dash_width % count;
|
||||
|
||||
self.hline(canvas, x[0], x[0] + w[0], (self.height -| thick_px) / 2, thick_px);
|
||||
self.hline(canvas, x[1], x[1] + w[1], (self.height -| thick_px) / 2, thick_px);
|
||||
if (count >= 3)
|
||||
self.hline(canvas, x[2], x[2] + w[2], (self.height -| thick_px) / 2, thick_px);
|
||||
if (count >= 4)
|
||||
self.hline(canvas, x[3], x[3] + w[3], (self.height -| thick_px) / 2, thick_px);
|
||||
assert(dash_width * count + gap_width * gap_count + remaining == self.width);
|
||||
|
||||
// Our dashes should be centered vertically.
|
||||
const y: u32 = (self.height -| thick_px) / 2;
|
||||
|
||||
// We start at half a gap from the left edge, in order to center
|
||||
// our dashes properly.
|
||||
var x: u32 = gap_width / 2;
|
||||
|
||||
// We'll distribute the extra space in to dash widths, 1px at a
|
||||
// time. We prefer this to making gaps larger since that is much
|
||||
// more visually obvious.
|
||||
var extra: u32 = remaining;
|
||||
|
||||
for (0..count) |_| {
|
||||
var x1 = x + dash_width;
|
||||
// We distribute left-over size in to dash widths,
|
||||
// since it's less obvious there than in the gaps.
|
||||
if (extra > 0) {
|
||||
extra -= 1;
|
||||
x1 += 1;
|
||||
}
|
||||
self.hline(canvas, x, x1, y, thick_px);
|
||||
// Advance by the width of the dash we drew and the width
|
||||
// of a gap to get the the start of the next dash.
|
||||
x = x1 + gap_width;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_dash_vertical(
|
||||
self: Box,
|
||||
canvas: *font.sprite.Canvas,
|
||||
count: u8,
|
||||
comptime count: u8,
|
||||
thick_px: u32,
|
||||
gap: u32,
|
||||
desired_gap: u32,
|
||||
) void {
|
||||
assert(count >= 2 and count <= 4);
|
||||
|
||||
// The number of gaps we have is one less than the number of dashes.
|
||||
// "- - -" => 2 gaps
|
||||
const gap_count = count - 1;
|
||||
// +-----------+
|
||||
// | | |
|
||||
// | | |
|
||||
// | |
|
||||
// | | |
|
||||
// | | |
|
||||
// | |
|
||||
// | | |
|
||||
// | | |
|
||||
// | |
|
||||
// +-----------+
|
||||
// Our dashed line should be made such that when tiled verically it
|
||||
// it creates one consistent line with no uneven gap or segment sizes.
|
||||
// In order to make sure this is the case, we should have an extra gap
|
||||
// gap at the bottom.
|
||||
//
|
||||
// A single full-sized extra gap is preferred to two half-sized ones for
|
||||
// vertical to allow better joining to solid characters without creating
|
||||
// visible half-sized gaps. Unlike horizontal, centering is a lot less
|
||||
// important, visually.
|
||||
|
||||
// Determine the height of our dashes
|
||||
const dash_height = dash_height: {
|
||||
var gap_i = gap;
|
||||
var dash_height = (self.height - (gap_count * gap_i)) / count;
|
||||
while (dash_height <= 0 and gap_i > 1) {
|
||||
gap_i -= 1;
|
||||
dash_height = (self.height - (gap_count * gap_i)) / count;
|
||||
}
|
||||
// Because of the extra gap at the bottom, there are as many gaps as
|
||||
// there are dashes.
|
||||
const gap_count = count;
|
||||
|
||||
// If we can't fit any dashes then we just render a horizontal line.
|
||||
if (dash_height <= 0) {
|
||||
self.vline_middle(canvas, .light);
|
||||
return;
|
||||
}
|
||||
|
||||
break :dash_height dash_height;
|
||||
};
|
||||
|
||||
// Our total height should be less than our real height
|
||||
assert(count * dash_height + gap_count * gap <= self.height);
|
||||
const remaining = self.height - count * dash_height - gap_count * gap;
|
||||
|
||||
var y: [4]u32 = .{0} ** 4;
|
||||
var h: [4]u32 = .{dash_height} ** 4;
|
||||
y[1] = y[0] + h[0] + gap;
|
||||
if (count == 2)
|
||||
h[1] = self.height - y[1]
|
||||
else if (count == 3)
|
||||
h[1] += remaining
|
||||
else
|
||||
h[1] += remaining / 2;
|
||||
|
||||
if (count >= 3) {
|
||||
y[2] = y[1] + h[1] + gap;
|
||||
if (count == 3)
|
||||
h[2] = self.height - y[2]
|
||||
else
|
||||
h[2] += remaining - remaining / 2;
|
||||
// We need at least 1 pixel for each gap and each dash, if we don't
|
||||
// have that then we can't draw our dashed line correctly so we just
|
||||
// draw a solid line and return.
|
||||
if (self.height < count + gap_count) {
|
||||
self.vline_middle(canvas, .light);
|
||||
return;
|
||||
}
|
||||
|
||||
if (count >= 4) {
|
||||
y[3] = y[2] + h[2] + gap;
|
||||
h[3] = self.height - y[3];
|
||||
}
|
||||
// We never want the gaps to take up more than 50% of the space,
|
||||
// because if they do the dashes are too small and look wrong.
|
||||
const gap_height = @min(desired_gap, self.height / (2 * count));
|
||||
const total_gap_height = gap_count * gap_height;
|
||||
const total_dash_height = self.height - total_gap_height;
|
||||
const dash_height = total_dash_height / count;
|
||||
const remaining = total_dash_height % count;
|
||||
|
||||
self.vline(canvas, y[0], y[0] + h[0], (self.width -| thick_px) / 2, thick_px);
|
||||
self.vline(canvas, y[1], y[1] + h[1], (self.width -| thick_px) / 2, thick_px);
|
||||
if (count >= 3)
|
||||
self.vline(canvas, y[2], y[2] + h[2], (self.width -| thick_px) / 2, thick_px);
|
||||
if (count >= 4)
|
||||
self.vline(canvas, y[3], y[3] + h[3], (self.width -| thick_px) / 2, thick_px);
|
||||
assert(dash_height * count + gap_height * gap_count + remaining == self.height);
|
||||
|
||||
// Our dashes should be centered horizontally.
|
||||
const x: u32 = (self.width -| thick_px) / 2;
|
||||
|
||||
// We start at the top of the cell.
|
||||
var y: u32 = 0;
|
||||
|
||||
// We'll distribute the extra space in to dash heights, 1px at a
|
||||
// time. We prefer this to making gaps larger since that is much
|
||||
// more visually obvious.
|
||||
var extra: u32 = remaining;
|
||||
|
||||
inline for (0..count) |_| {
|
||||
var y1 = y + dash_height;
|
||||
// We distribute left-over size in to dash widths,
|
||||
// since it's less obvious there than in the gaps.
|
||||
if (extra > 0) {
|
||||
extra -= 1;
|
||||
y1 += 1;
|
||||
}
|
||||
self.vline(canvas, y, y1, x, thick_px);
|
||||
// Advance by the height of the dash we drew and the height
|
||||
// of a gap to get the the start of the next dash.
|
||||
y = y1 + gap_height;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void {
|
||||
|
@ -91,7 +91,7 @@ pub fn main() !MainReturn {
|
||||
\\
|
||||
\\We don't have proper help output yet, sorry! Please refer to the
|
||||
\\source code or Discord community for help for now. We'll fix this in time.
|
||||
\\
|
||||
\\
|
||||
,
|
||||
.{},
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const passwd = @import("passwd.zig");
|
||||
const posix = std.posix;
|
||||
const objc = @import("objc");
|
||||
|
||||
const Error = error{
|
||||
/// The buffer used for output is not large enough to store the value.
|
||||
@ -31,33 +32,26 @@ fn homeUnix(buf: []u8) !?[]u8 {
|
||||
return buf[0..result.len];
|
||||
}
|
||||
|
||||
// On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path
|
||||
if (builtin.os.tag == .macos) {
|
||||
const NSFileManager = objc.getClass("NSFileManager").?;
|
||||
const manager = NSFileManager.msgSend(objc.Object, objc.sel("defaultManager"), .{});
|
||||
const homeURL = manager.getProperty(objc.Object, "homeDirectoryForCurrentUser");
|
||||
const homePath = homeURL.getProperty(objc.Object, "path");
|
||||
|
||||
const c_str = homePath.getProperty([*:0]const u8, "UTF8String");
|
||||
const result = std.mem.sliceTo(c_str, 0);
|
||||
|
||||
if (buf.len < result.len) return Error.BufferTooSmall;
|
||||
@memcpy(buf[0..result.len], result);
|
||||
return buf[0..result.len];
|
||||
}
|
||||
|
||||
// Everything below here will require some allocation
|
||||
var tempBuf: [1024]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&tempBuf);
|
||||
|
||||
// If we're on darwin, we try the directory service. I'm not sure if there
|
||||
// is a Mac API to do this but if so we can link to that...
|
||||
if (builtin.os.tag == .macos) {
|
||||
const run = try std.ChildProcess.run(.{
|
||||
.allocator = fba.allocator(),
|
||||
.argv = &[_][]const u8{
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"dscl -q . -read /Users/\"$(whoami)\" NFSHomeDirectory | sed 's/^[^ ]*: //'",
|
||||
},
|
||||
.max_output_bytes = fba.buffer.len / 2,
|
||||
});
|
||||
|
||||
if (run.term == .Exited and run.term.Exited == 0) {
|
||||
const result = trimSpace(run.stdout);
|
||||
if (buf.len < result.len) return Error.BufferTooSmall;
|
||||
@memcpy(buf[0..result.len], result);
|
||||
return buf[0..result.len];
|
||||
}
|
||||
}
|
||||
|
||||
// We try passwd. This doesn't work on multi-user mac but we try it anyways.
|
||||
fba.reset();
|
||||
const pw = try passwd.get(fba.allocator());
|
||||
if (pw.home) |result| {
|
||||
if (buf.len < result.len) return Error.BufferTooSmall;
|
||||
|
@ -540,6 +540,9 @@ pub fn cursorDownScroll(self: *Screen) !void {
|
||||
assert(self.cursor.y == self.pages.rows - 1);
|
||||
defer self.assertIntegrity();
|
||||
|
||||
// Scrolling dirties the images because it updates their placements pins.
|
||||
self.kitty_images.dirty = true;
|
||||
|
||||
// If we have no scrollback, then we shift all our rows instead.
|
||||
if (self.no_scrollback) {
|
||||
// If we have a single-row screen, we have no rows to shift
|
||||
@ -891,10 +894,20 @@ pub fn clearUnprotectedCells(
|
||||
row: *Row,
|
||||
cells: []Cell,
|
||||
) void {
|
||||
for (cells) |*cell| {
|
||||
if (cell.protected) continue;
|
||||
const cell_multi: [*]Cell = @ptrCast(cell);
|
||||
self.clearCells(page, row, cell_multi[0..1]);
|
||||
var x0: usize = 0;
|
||||
var x1: usize = 0;
|
||||
|
||||
while (x0 < cells.len) clear: {
|
||||
while (cells[x0].protected) {
|
||||
x0 += 1;
|
||||
if (x0 >= cells.len) break :clear;
|
||||
}
|
||||
x1 = x0 + 1;
|
||||
while (x1 < cells.len and !cells[x1].protected) {
|
||||
x1 += 1;
|
||||
}
|
||||
self.clearCells(page, row, cells[x0..x1]);
|
||||
x0 = x1;
|
||||
}
|
||||
|
||||
page.assertIntegrity();
|
||||
@ -2137,6 +2150,25 @@ pub fn dumpStringAlloc(
|
||||
return try builder.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// You should use dumpString, this is a restricted version mostly for
|
||||
/// legacy and convenience reasons for unit tests.
|
||||
pub fn dumpStringAllocUnwrapped(
|
||||
self: *const Screen,
|
||||
alloc: Allocator,
|
||||
tl: point.Point,
|
||||
) ![]const u8 {
|
||||
var builder = std.ArrayList(u8).init(alloc);
|
||||
defer builder.deinit();
|
||||
|
||||
try self.dumpString(builder.writer(), .{
|
||||
.tl = self.pages.getTopLeft(tl),
|
||||
.br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint,
|
||||
.unwrap = true,
|
||||
});
|
||||
|
||||
return try builder.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// This is basically a really jank version of Terminal.printString. We
|
||||
/// have to reimplement it here because we want a way to print to the screen
|
||||
/// to test it but don't want all the features of Terminal.
|
||||
@ -2190,6 +2222,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = c },
|
||||
.style_id = self.cursor.style_id,
|
||||
.protected = self.cursor.protected,
|
||||
};
|
||||
|
||||
// If we have a ref-counted style, increase.
|
||||
@ -2206,6 +2239,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = 0 },
|
||||
.wide = .spacer_head,
|
||||
.protected = self.cursor.protected,
|
||||
};
|
||||
|
||||
self.cursor.page_row.wrap = true;
|
||||
@ -2220,6 +2254,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
|
||||
.content = .{ .codepoint = c },
|
||||
.style_id = self.cursor.style_id,
|
||||
.wide = .wide,
|
||||
.protected = self.cursor.protected,
|
||||
};
|
||||
|
||||
// Write our tail
|
||||
@ -2228,6 +2263,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = 0 },
|
||||
.wide = .spacer_tail,
|
||||
.protected = self.cursor.protected,
|
||||
};
|
||||
},
|
||||
|
||||
@ -2508,6 +2544,34 @@ test "Screen clearRows active styled line" {
|
||||
try testing.expectEqualStrings("", str);
|
||||
}
|
||||
|
||||
test "Screen clearRows protected" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try Screen.init(alloc, 80, 24, 1000);
|
||||
defer s.deinit();
|
||||
|
||||
try s.testWriteString("UNPROTECTED");
|
||||
s.cursor.protected = true;
|
||||
try s.testWriteString("PROTECTED");
|
||||
s.cursor.protected = false;
|
||||
try s.testWriteString("UNPROTECTED");
|
||||
try s.testWriteString("\n");
|
||||
s.cursor.protected = true;
|
||||
try s.testWriteString("PROTECTED");
|
||||
s.cursor.protected = false;
|
||||
try s.testWriteString("UNPROTECTED");
|
||||
s.cursor.protected = true;
|
||||
try s.testWriteString("PROTECTED");
|
||||
s.cursor.protected = false;
|
||||
|
||||
s.clearRows(.{ .active = .{} }, null, true);
|
||||
|
||||
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(" PROTECTED\nPROTECTED PROTECTED", str);
|
||||
}
|
||||
|
||||
test "Screen eraseRows history" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
@ -1075,6 +1075,9 @@ pub fn index(self: *Terminal) !void {
|
||||
self.screen.cursor.x >= self.scrolling_region.left and
|
||||
self.screen.cursor.x <= self.scrolling_region.right)
|
||||
{
|
||||
// Scrolling dirties the images because it updates their placements pins.
|
||||
self.screen.kitty_images.dirty = true;
|
||||
|
||||
// If our scrolling region is the full screen, we create scrollback.
|
||||
// Otherwise, we simply scroll the region.
|
||||
if (self.scrolling_region.top == 0 and
|
||||
@ -1306,6 +1309,63 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void {
|
||||
});
|
||||
}
|
||||
|
||||
/// To be called before shifting a row (as in insertLines and deleteLines)
|
||||
///
|
||||
/// Takes care of boundary conditions such as potentially split wide chars
|
||||
/// across scrolling region boundaries and orphaned spacer heads at line
|
||||
/// ends.
|
||||
fn rowWillBeShifted(
|
||||
self: *Terminal,
|
||||
page: *Page,
|
||||
row: *Row,
|
||||
) void {
|
||||
const cells = row.cells.ptr(page.memory.ptr);
|
||||
|
||||
// If our scrolling region includes the rightmost column then we
|
||||
// need to turn any spacer heads in to normal empty cells, since
|
||||
// once we move them they no longer correspond with soft-wrapped
|
||||
// wide characters.
|
||||
//
|
||||
// If it contains either of the 2 leftmost columns, then the wide
|
||||
// characters in the first column which may be associated with a
|
||||
// spacer head will be either moved or cleared, so we also need
|
||||
// to turn the spacer heads in to empty cells in that case.
|
||||
if (self.scrolling_region.right == self.cols - 1 or
|
||||
self.scrolling_region.left < 2)
|
||||
{
|
||||
const end_cell: *Cell = &cells[page.size.cols - 1];
|
||||
if (end_cell.wide == .spacer_head) {
|
||||
end_cell.wide = .narrow;
|
||||
}
|
||||
}
|
||||
|
||||
// If the leftmost or rightmost cells of our scrolling region
|
||||
// are parts of wide chars, we need to clear the cells' contents
|
||||
// since they'd be split by the move.
|
||||
const left_cell: *Cell = &cells[self.scrolling_region.left];
|
||||
const right_cell: *Cell = &cells[self.scrolling_region.right];
|
||||
|
||||
if (left_cell.wide == .spacer_tail) {
|
||||
const wide_cell: *Cell = &cells[self.scrolling_region.left - 1];
|
||||
if (wide_cell.hasGrapheme()) {
|
||||
page.clearGrapheme(row, wide_cell);
|
||||
}
|
||||
wide_cell.content.codepoint = 0;
|
||||
wide_cell.wide = .narrow;
|
||||
left_cell.wide = .narrow;
|
||||
}
|
||||
|
||||
if (right_cell.wide == .wide) {
|
||||
const tail_cell: *Cell = &cells[self.scrolling_region.right + 1];
|
||||
if (right_cell.hasGrapheme()) {
|
||||
page.clearGrapheme(row, right_cell);
|
||||
}
|
||||
right_cell.content.codepoint = 0;
|
||||
right_cell.wide = .narrow;
|
||||
tail_cell.wide = .narrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert amount lines at the current cursor row. The contents of the line
|
||||
/// at the current cursor row and below (to the bottom-most line in the
|
||||
/// scrolling region) are shifted down by amount lines. The contents of the
|
||||
@ -1334,6 +1394,9 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
self.screen.cursor.x < self.scrolling_region.left or
|
||||
self.screen.cursor.x > self.scrolling_region.right) return;
|
||||
|
||||
// Scrolling dirties the images because it updates their placements pins.
|
||||
self.screen.kitty_images.dirty = true;
|
||||
|
||||
// Remaining rows from our cursor to the bottom of the scroll region.
|
||||
const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1;
|
||||
|
||||
@ -1359,8 +1422,21 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
var it = bot.rowIterator(.left_up, top);
|
||||
while (it.next()) |p| {
|
||||
const dst_p = p.down(adjusted_count).?;
|
||||
const src: *Row = p.rowAndCell().row;
|
||||
const dst: *Row = dst_p.rowAndCell().row;
|
||||
const src_rac = p.rowAndCell();
|
||||
const dst_rac = dst_p.rowAndCell();
|
||||
const src: *Row = src_rac.row;
|
||||
const dst: *Row = dst_rac.row;
|
||||
|
||||
self.rowWillBeShifted(&p.page.data, src);
|
||||
self.rowWillBeShifted(&dst_p.page.data, dst);
|
||||
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (!left_right) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
dst.wrap_continuation = false;
|
||||
src.wrap_continuation = false;
|
||||
}
|
||||
|
||||
// If our page doesn't match, then we need to do a copy from
|
||||
// one page to another. This is the slow path.
|
||||
@ -1376,9 +1452,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
@panic("TODO");
|
||||
};
|
||||
|
||||
// Row never is wrapped if we're full width.
|
||||
if (!left_right) dst.wrap = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1389,10 +1462,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
dst.* = src.*;
|
||||
src.* = dst_row;
|
||||
|
||||
// Row never is wrapped
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
|
||||
// Ensure what we did didn't corrupt the page
|
||||
p.page.data.assertIntegrity();
|
||||
continue;
|
||||
@ -1407,9 +1476,6 @@ pub fn insertLines(self: *Terminal, count: usize) void {
|
||||
self.scrolling_region.left,
|
||||
(self.scrolling_region.right - self.scrolling_region.left) + 1,
|
||||
);
|
||||
|
||||
// Row never is wrapped
|
||||
dst.wrap = false;
|
||||
}
|
||||
|
||||
// The operations above can prune our cursor style so we need to
|
||||
@ -1474,6 +1540,9 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
self.screen.cursor.x < self.scrolling_region.left or
|
||||
self.screen.cursor.x > self.scrolling_region.right) return;
|
||||
|
||||
// Scrolling dirties the images because it updates their placements pins.
|
||||
self.screen.kitty_images.dirty = true;
|
||||
|
||||
// top is just the cursor position. insertLines starts at the cursor
|
||||
// so this is our top. We want to shift lines down, down to the bottom
|
||||
// of the scroll region.
|
||||
@ -1498,8 +1567,21 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
var it = top.rowIterator(.right_down, bot);
|
||||
while (it.next()) |p| {
|
||||
const src_p = p.down(count).?;
|
||||
const src: *Row = src_p.rowAndCell().row;
|
||||
const dst: *Row = p.rowAndCell().row;
|
||||
const src_rac = src_p.rowAndCell();
|
||||
const dst_rac = p.rowAndCell();
|
||||
const src: *Row = src_rac.row;
|
||||
const dst: *Row = dst_rac.row;
|
||||
|
||||
self.rowWillBeShifted(&src_p.page.data, src);
|
||||
self.rowWillBeShifted(&p.page.data, dst);
|
||||
|
||||
// If our scrolling region is full width, then we unset wrap.
|
||||
if (!left_right) {
|
||||
dst.wrap = false;
|
||||
src.wrap = false;
|
||||
dst.wrap_continuation = false;
|
||||
src.wrap_continuation = false;
|
||||
}
|
||||
|
||||
if (src_p.page != p.page) {
|
||||
p.page.data.clonePartialRowFrom(
|
||||
@ -1513,9 +1595,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
@panic("TODO");
|
||||
};
|
||||
|
||||
// Row never is wrapped if we're full width.
|
||||
if (!left_right) dst.wrap = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1526,9 +1605,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
dst.* = src.*;
|
||||
src.* = dst_row;
|
||||
|
||||
// Row never is wrapped
|
||||
dst.wrap = false;
|
||||
|
||||
// Ensure what we did didn't corrupt the page
|
||||
p.page.data.assertIntegrity();
|
||||
continue;
|
||||
@ -1543,9 +1619,6 @@ pub fn deleteLines(self: *Terminal, count_req: usize) void {
|
||||
self.scrolling_region.left,
|
||||
(self.scrolling_region.right - self.scrolling_region.left) + 1,
|
||||
);
|
||||
|
||||
// Row never is wrapped
|
||||
dst.wrap = false;
|
||||
}
|
||||
|
||||
// The operations above can prune our cursor style so we need to
|
||||
@ -1793,19 +1866,11 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
|
||||
return;
|
||||
}
|
||||
|
||||
// SLOW PATH
|
||||
// We had a protection mode at some point. We must go through each
|
||||
// cell and check its protection attribute.
|
||||
for (0..end) |x| {
|
||||
const cell_multi: [*]Cell = @ptrCast(cells + x);
|
||||
const cell: *Cell = @ptrCast(&cell_multi[0]);
|
||||
if (cell.protected) continue;
|
||||
self.screen.clearCells(
|
||||
&self.screen.cursor.page_pin.page.data,
|
||||
self.screen.cursor.page_row,
|
||||
cell_multi[0..1],
|
||||
);
|
||||
}
|
||||
self.screen.clearUnprotectedCells(
|
||||
&self.screen.cursor.page_pin.page.data,
|
||||
self.screen.cursor.page_row,
|
||||
cells[0..end],
|
||||
);
|
||||
}
|
||||
|
||||
/// Erase the line.
|
||||
@ -1878,16 +1943,11 @@ pub fn eraseLine(
|
||||
return;
|
||||
}
|
||||
|
||||
for (start..end) |x| {
|
||||
const cell_multi: [*]Cell = @ptrCast(cells + x);
|
||||
const cell: *Cell = @ptrCast(&cell_multi[0]);
|
||||
if (cell.protected) continue;
|
||||
self.screen.clearCells(
|
||||
&self.screen.cursor.page_pin.page.data,
|
||||
self.screen.cursor.page_row,
|
||||
cell_multi[0..1],
|
||||
);
|
||||
}
|
||||
self.screen.clearUnprotectedCells(
|
||||
&self.screen.cursor.page_pin.page.data,
|
||||
self.screen.cursor.page_row,
|
||||
cells[start..end],
|
||||
);
|
||||
}
|
||||
|
||||
/// Erase the display.
|
||||
@ -2385,6 +2445,11 @@ pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 {
|
||||
return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} });
|
||||
}
|
||||
|
||||
/// Same as plainString, but respects row wrap state when building the string.
|
||||
pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 {
|
||||
return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} });
|
||||
}
|
||||
|
||||
/// Full reset
|
||||
pub fn fullReset(self: *Terminal) void {
|
||||
// Switch back to primary screen and clear it. We do not restore cursor
|
||||
@ -6119,6 +6184,243 @@ test "Terminal: deleteLines left/right scroll region high count" {
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
// Delete the top line
|
||||
// +-----+
|
||||
// |BBBB | < Non-wrapped
|
||||
// |WWCCC| < Non-wrapped
|
||||
// | | < Non-wrapped
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, and un-set wrap.
|
||||
t.setCursorPos(1, 1);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", str);
|
||||
try testing.expectEqualStrings("BBBB \n\u{1F600}CCC", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.left = 2;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |AABB | < Wrapped
|
||||
// |BBCCC| < Wrapped (continued)
|
||||
// |WW | < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, but due to the
|
||||
// left scrolling margin, wrap state should
|
||||
// remain.
|
||||
t.setCursorPos(1, 3);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("AABB \nBBCCC\n\u{1F600}", str);
|
||||
try testing.expectEqualStrings("AABB BBCCC\u{1F600}", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
|
||||
// Delete the top line
|
||||
// #### <- scrolling region
|
||||
// +-----+
|
||||
// |BBBBA| < Wrapped
|
||||
// |WWCC | < Wrapped (continued)
|
||||
// | C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// This should convert the spacer head to
|
||||
// a regular empty cell, but due to the
|
||||
// right scrolling margin, wrap state should
|
||||
// remain.
|
||||
t.setCursorPos(1, 1);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("BBBBA\n\u{1F600}CC \n C", str);
|
||||
try testing.expectEqualStrings("BBBBA\u{1F600}CC C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left and right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 2;
|
||||
|
||||
// Delete the top line
|
||||
// ## <- scrolling region
|
||||
// +-----+
|
||||
// |AABBA| < Wrapped
|
||||
// |BBCC*| < Wrapped (continued)
|
||||
// |WW C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// Because there is both a left scrolling
|
||||
// margin > 1 and a right scrolling margin
|
||||
// the spacer head should remain, and the
|
||||
// wrap state should be untouched.
|
||||
t.setCursorPos(1, 3);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("AABBA\nBBCC\n\u{1F600} C", str);
|
||||
try testing.expectEqualStrings("AABBABBCC\u{1F600} C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide character spacer head left (< 2) and right scroll margin" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 3 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA| < Wrapped
|
||||
// |BBBB*| < Wrapped (continued)
|
||||
// |WWCCC| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// where * represents a spacer head cell
|
||||
// and WW is the wide character.
|
||||
try t.printString("AAAAABBBB\u{1F600}CCC");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 1;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |ABBBA| < Wrapped
|
||||
// |B CC | < Wrapped (continued)
|
||||
// | C| < Non-wrapped (continued)
|
||||
// +-----+
|
||||
// Because the left margin is 1, the wide
|
||||
// char is split, and therefore removed,
|
||||
// along with the spacer head - however,
|
||||
// wrap state should be untouched.
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
const unwrapped_str = try t.plainStringUnwrapped(testing.allocator);
|
||||
defer testing.allocator.free(unwrapped_str);
|
||||
try testing.expectEqualStrings("ABBBA\nB CC \n C", str);
|
||||
try testing.expectEqualStrings("ABBBAB CC C", unwrapped_str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines wide characters split by left/right scroll region boundaries" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 5, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Initial value
|
||||
// +-----+
|
||||
// |AAAAA|
|
||||
// |WWBWW|
|
||||
// +-----+
|
||||
// where WW represents a wide character
|
||||
try t.printString("AAAAA\n\u{1F600}B\u{1F600}");
|
||||
|
||||
t.scrolling_region.right = 3;
|
||||
t.scrolling_region.left = 1;
|
||||
|
||||
// Delete the top line
|
||||
// ### <- scrolling region
|
||||
// +-----+
|
||||
// |A B A|
|
||||
// | |
|
||||
// +-----+
|
||||
// The two wide chars, because they're
|
||||
// split by the edge of the scrolling
|
||||
// region, get removed.
|
||||
t.setCursorPos(1, 2);
|
||||
t.deleteLines(1);
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("A B A\n ", str);
|
||||
}
|
||||
}
|
||||
|
||||
test "Terminal: deleteLines zero" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 2, .rows = 5 });
|
||||
|
@ -3,6 +3,8 @@ const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const log = std.log.scoped(.kitty_gfx);
|
||||
|
||||
/// The key-value pairs for the control information for a command. The
|
||||
/// keys are always single characters and the values are either single
|
||||
/// characters or 32-bit unsigned integers.
|
||||
@ -27,8 +29,11 @@ pub const CommandParser = struct {
|
||||
kv_temp_len: u4 = 0,
|
||||
kv_current: u8 = 0, // Current kv key
|
||||
|
||||
/// This is the list of bytes that contains both KV data and final
|
||||
/// data. You shouldn't access this directly.
|
||||
/// This is the list we use to collect the bytes from the data payload.
|
||||
/// The Kitty Graphics protocol specification seems to imply that the
|
||||
/// payload content of a single command should never exceed 4096 bytes,
|
||||
/// but Kitty itself supports larger payloads, so we use an ArrayList
|
||||
/// here instead of a fixed buffer so that we can too.
|
||||
data: std.ArrayList(u8),
|
||||
|
||||
/// Internal state for parsing.
|
||||
@ -42,7 +47,7 @@ pub const CommandParser = struct {
|
||||
control_value,
|
||||
control_value_ignore,
|
||||
|
||||
/// We're parsing the data blob.
|
||||
/// Collecting the data payload blob.
|
||||
data,
|
||||
};
|
||||
|
||||
@ -106,9 +111,6 @@ pub const CommandParser = struct {
|
||||
|
||||
.data => try self.data.append(c),
|
||||
}
|
||||
|
||||
// We always add to our data list because this is our stable
|
||||
// array of bytes that we'll reference everywhere else.
|
||||
}
|
||||
|
||||
/// Complete the parsing. This must be called after all the
|
||||
@ -165,12 +167,45 @@ pub const CommandParser = struct {
|
||||
return .{
|
||||
.control = control,
|
||||
.quiet = quiet,
|
||||
.data = if (self.data.items.len == 0) "" else data: {
|
||||
break :data try self.data.toOwnedSlice();
|
||||
},
|
||||
.data = try self.decodeData(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Decodes the payload data from base64 and returns it as a slice.
|
||||
/// This function will destroy the contents of self.data, it should
|
||||
/// only be used once we are done collecting payload bytes.
|
||||
fn decodeData(self: *CommandParser) ![]const u8 {
|
||||
if (self.data.items.len == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const Base64Decoder = std.base64.standard_no_pad.Decoder;
|
||||
|
||||
// We remove any padding, since it's optional, and decode without it.
|
||||
while (self.data.items[self.data.items.len - 1] == '=') {
|
||||
self.data.items.len -= 1;
|
||||
}
|
||||
|
||||
const size = Base64Decoder.calcSizeForSlice(self.data.items) catch |err| {
|
||||
log.warn("failed to calculate base64 size for payload: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// This is kinda cursed, but we can decode the base64 on top of
|
||||
// itself, since it's guaranteed that the encoded size is larger,
|
||||
// and any bytes in areas that are written to will have already
|
||||
// been used (assuming scalar decoding).
|
||||
Base64Decoder.decode(self.data.items[0..size], self.data.items) catch |err| {
|
||||
log.warn("failed to decode base64 payload data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// Remove the extra bytes.
|
||||
self.data.items.len = size;
|
||||
|
||||
return try self.data.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void {
|
||||
const idx = self.kv_temp_len;
|
||||
self.kv_temp_len += 1;
|
||||
@ -855,7 +890,7 @@ test "query command" {
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA";
|
||||
const input = "i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const posix = std.posix;
|
||||
|
||||
const fastmem = @import("../../fastmem.zig");
|
||||
const command = @import("graphics_command.zig");
|
||||
const point = @import("../point.zig");
|
||||
const PageList = @import("../PageList.zig");
|
||||
@ -56,30 +57,16 @@ pub const LoadingImage = struct {
|
||||
.display = cmd.display(),
|
||||
};
|
||||
|
||||
// Special case for the direct medium, we just add it directly
|
||||
// which will handle copying the data, base64 decoding, etc.
|
||||
// Special case for the direct medium, we just add the chunk directly.
|
||||
if (t.medium == .direct) {
|
||||
try result.addData(alloc, cmd.data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// For every other medium, we'll need to at least base64 decode
|
||||
// the data to make it useful so let's do that. Also, all the data
|
||||
// has to be path data so we can put it in a stack-allocated buffer.
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const Base64Decoder = std.base64.standard.Decoder;
|
||||
const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| {
|
||||
log.warn("failed to calculate base64 size for file path: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
if (size > buf.len) return error.FilePathTooLong;
|
||||
Base64Decoder.decode(&buf, cmd.data) catch |err| {
|
||||
log.warn("failed to decode base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
// Otherwise, the payload data is guaranteed to be a path.
|
||||
|
||||
if (comptime builtin.os.tag != .windows) {
|
||||
if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) {
|
||||
if (std.mem.indexOfScalar(u8, cmd.data, 0) != null) {
|
||||
// posix.realpath *asserts* that the path does not have
|
||||
// internal nulls instead of erroring.
|
||||
log.warn("failed to get absolute path: BadPathName", .{});
|
||||
@ -88,7 +75,7 @@ pub const LoadingImage = struct {
|
||||
}
|
||||
|
||||
var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const path = posix.realpath(buf[0..size], &abs_buf) catch |err| {
|
||||
const path = posix.realpath(cmd.data, &abs_buf) catch |err| {
|
||||
log.warn("failed to get absolute path: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
@ -229,42 +216,25 @@ pub const LoadingImage = struct {
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
/// Adds a chunk of base64-encoded data to the image. Use this if the
|
||||
/// image is coming in chunks (the "m" parameter in the protocol).
|
||||
/// Adds a chunk of data to the image. Use this if the image
|
||||
/// is coming in chunks (the "m" parameter in the protocol).
|
||||
pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void {
|
||||
// If no data, skip
|
||||
if (data.len == 0) return;
|
||||
|
||||
// Grow our array list by size capacity if it needs it
|
||||
const Base64Decoder = std.base64.standard.Decoder;
|
||||
const size = Base64Decoder.calcSizeForSlice(data) catch |err| {
|
||||
log.warn("failed to calculate size for base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// If our data would get too big, return an error
|
||||
if (self.data.items.len + size > max_size) {
|
||||
if (self.data.items.len + data.len > max_size) {
|
||||
log.warn("image data too large max_size={}", .{max_size});
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
try self.data.ensureUnusedCapacity(alloc, size);
|
||||
// Ensure we have enough room to add the data
|
||||
// to the end of the ArrayList before doing so.
|
||||
try self.data.ensureUnusedCapacity(alloc, data.len);
|
||||
|
||||
// We decode directly into the arraylist
|
||||
const start_i = self.data.items.len;
|
||||
self.data.items.len = start_i + size;
|
||||
const buf = self.data.items[start_i..];
|
||||
Base64Decoder.decode(buf, data) catch |err| switch (err) {
|
||||
// We have to ignore invalid padding because lots of encoders
|
||||
// add the wrong padding. Since we validate image data later
|
||||
// (PNG decode or simple dimensions check), we can ignore this.
|
||||
error.InvalidPadding => {},
|
||||
|
||||
else => {
|
||||
log.warn("failed to decode base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
},
|
||||
};
|
||||
self.data.items.len = start_i + data.len;
|
||||
fastmem.copy(u8, self.data.items[start_i..], data);
|
||||
}
|
||||
|
||||
/// Complete the chunked image, returning a completed image.
|
||||
@ -457,23 +427,6 @@ pub const Rect = struct {
|
||||
bottom_right: PageList.Pin,
|
||||
};
|
||||
|
||||
/// Easy base64 encoding function.
|
||||
fn testB64(alloc: Allocator, data: []const u8) ![]const u8 {
|
||||
const B64Encoder = std.base64.standard.Encoder;
|
||||
const b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len));
|
||||
errdefer alloc.free(b64);
|
||||
return B64Encoder.encode(b64, data);
|
||||
}
|
||||
|
||||
/// Easy base64 decoding function.
|
||||
fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 {
|
||||
const B64Decoder = std.base64.standard.Decoder;
|
||||
const result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data));
|
||||
errdefer alloc.free(result);
|
||||
try B64Decoder.decode(result, data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// This specifically tests we ALLOW invalid RGB data because Kitty
|
||||
// documents that this should work.
|
||||
test "image load with invalid RGB data" {
|
||||
@ -548,7 +501,7 @@ test "image load: rgb, zlib compressed, direct" {
|
||||
} },
|
||||
.data = try alloc.dupe(
|
||||
u8,
|
||||
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"),
|
||||
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data"),
|
||||
),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
@ -576,7 +529,7 @@ test "image load: rgb, not compressed, direct" {
|
||||
} },
|
||||
.data = try alloc.dupe(
|
||||
u8,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"),
|
||||
),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
@ -593,7 +546,7 @@ test "image load: rgb, zlib compressed, direct, chunked" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data");
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
|
||||
|
||||
// Setup our initial chunk
|
||||
var cmd: command.Command = .{
|
||||
@ -630,7 +583,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk"
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data");
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data");
|
||||
|
||||
// Setup our initial chunk
|
||||
var cmd: command.Command = .{
|
||||
@ -668,11 +621,7 @@ test "image load: rgb, not compressed, temporary file" {
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = try testB64Decode(
|
||||
alloc,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
);
|
||||
defer alloc.free(data);
|
||||
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
||||
try tmp_dir.dir.writeFile("image.data", data);
|
||||
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
@ -687,7 +636,7 @@ test "image load: rgb, not compressed, temporary file" {
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
.data = try alloc.dupe(u8, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
@ -706,11 +655,7 @@ test "image load: rgb, not compressed, regular file" {
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = try testB64Decode(
|
||||
alloc,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
);
|
||||
defer alloc.free(data);
|
||||
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
||||
try tmp_dir.dir.writeFile("image.data", data);
|
||||
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
@ -725,7 +670,7 @@ test "image load: rgb, not compressed, regular file" {
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
.data = try alloc.dupe(u8, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
@ -757,7 +702,7 @@ test "image load: png, not compressed, regular file" {
|
||||
.height = 0,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
.data = try alloc.dupe(u8, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
|
BIN
src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data
vendored
Normal file
BIN
src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647-raw.data
vendored
Normal file
Binary file not shown.
@ -1 +0,0 @@
|
||||
DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w
|
BIN
src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data
vendored
Normal file
BIN
src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647-raw.data
vendored
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -184,7 +184,7 @@ pub const Page = struct {
|
||||
pub fn reinit(self: *Page) void {
|
||||
// We zero the page memory as u64 instead of u8 because
|
||||
// we can and it's empirically quite a bit faster.
|
||||
@memset(@as([*]u64, @ptrCast(self.memory))[0..self.memory.len / 8], 0);
|
||||
@memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0);
|
||||
self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity));
|
||||
}
|
||||
|
||||
|
@ -1465,6 +1465,16 @@ pub fn Stream(comptime Handler: type) type {
|
||||
},
|
||||
} else log.warn("unimplemented invokeCharset: {}", .{action}),
|
||||
|
||||
// SPA - Start of Guarded Area
|
||||
'V' => if (@hasDecl(T, "setProtectedMode")) {
|
||||
try self.handler.setProtectedMode(ansi.ProtectedMode.iso);
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
// EPA - End of Guarded Area
|
||||
'W' => if (@hasDecl(T, "setProtectedMode")) {
|
||||
try self.handler.setProtectedMode(ansi.ProtectedMode.off);
|
||||
} else log.warn("unimplemented ESC callback: {}", .{action}),
|
||||
|
||||
// DECID
|
||||
'Z' => if (@hasDecl(T, "deviceAttributes")) {
|
||||
try self.handler.deviceAttributes(.primary, &.{});
|
||||
|
@ -1993,7 +1993,7 @@ const StreamHandler = struct {
|
||||
pub fn setCursorColRelative(self: *StreamHandler, offset: u16) !void {
|
||||
self.terminal.setCursorPos(
|
||||
self.terminal.screen.cursor.y + 1,
|
||||
self.terminal.screen.cursor.x + 1 + offset,
|
||||
self.terminal.screen.cursor.x + 1 +| offset,
|
||||
);
|
||||
}
|
||||
|
||||
@ -2003,7 +2003,7 @@ const StreamHandler = struct {
|
||||
|
||||
pub fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void {
|
||||
self.terminal.setCursorPos(
|
||||
self.terminal.screen.cursor.y + 1 + offset,
|
||||
self.terminal.screen.cursor.y + 1 +| offset,
|
||||
self.terminal.screen.cursor.x + 1,
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user