From 3da908a028da4ef9ee47f5587ef927f4796a0dfe Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Wed, 25 Dec 2024 17:30:46 +0100 Subject: [PATCH] feat: customize quick terminal size This commit introduce `quick-terminal-size` option which allows to define the size of the quick terminal. It also fixes an issue where the quick terminal position was not properly updated when reloading the configuration. Resolves #2384 --- include/ghostty.h | 16 ++ macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../QuickTerminalController.swift | 16 +- .../QuickTerminal/QuickTerminalPosition.swift | 37 +-- .../QuickTerminal/QuickTerminalScreen.swift | 4 +- .../QuickTerminal/QuickTerminalSize.swift | 81 ++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 8 + src/config/Config.zig | 259 ++++++++++++++++++ 8 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift diff --git a/include/ghostty.h b/include/ghostty.h index 61c3aad324..b62ec2d33a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -348,6 +348,22 @@ typedef struct { size_t len; } ghostty_config_color_list_s; +// config.QuickTerminalSize +typedef enum { + GHOSTTY_QUICK_TERMINAL_PIXEL_UNIT, + GHOSTTY_QUICK_TERMINAL_PERCENTAGE_UNIT, +} ghostty_config_quick_terminal_unit_e; + +typedef struct { + uint16_t value; + ghostty_config_quick_terminal_unit_e unit; +} ghostty_config_quick_terminal_dimension_s; + +typedef struct { + ghostty_config_quick_terminal_dimension_s* dimensions; + size_t len; +} ghostty_config_quick_terminal_size_s; + // apprt.Target.Key typedef enum { GHOSTTY_TARGET_APP, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 68322756b6..b65d0d02d5 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; + AB315D402D1DCC6B0012D326 /* QuickTerminalSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB315D3F2D1DCC630012D326 /* QuickTerminalSize.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -183,6 +184,7 @@ A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + AB315D3F2D1DCC630012D326 /* QuickTerminalSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSize.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -434,6 +436,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { isa = PBXGroup; children = ( + AB315D3F2D1DCC630012D326 /* QuickTerminalSize.swift */, A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, @@ -633,6 +636,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, + AB315D402D1DCC6B0012D326 /* QuickTerminalSize.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index e4606f7293..220201d979 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -8,7 +8,7 @@ class QuickTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "QuickTerminal" } /// The position for the quick terminal. - let position: QuickTerminalPosition + private var position: QuickTerminalPosition /// The current state of the quick terminal private(set) var visible: Bool = false @@ -72,7 +72,7 @@ class QuickTerminalController: BaseTerminalController { syncAppearance(ghostty.config) // Setup our initial size based on our configured position - position.setLoaded(window) + derivedConfig.quickTerminalSize.apply(window, position) // Setup our content window.contentView = NSHostingView(rootView: TerminalView( @@ -306,6 +306,9 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance(_ config: Ghostty.Config) { guard let window else { return } + + // Update the quick terminal size right away + config.quickTerminalSize.apply(window, config.quickTerminalPosition) // If our window is not visible, then delay this. This is possible specifically // during state restoration but probably in other scenarios as well. To delay, @@ -390,7 +393,8 @@ class QuickTerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - + self.position = self.derivedConfig.quickTerminalPosition + syncAppearance(config) } @@ -398,17 +402,23 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool + let quickTerminalPosition: QuickTerminalPosition + let quickTerminalSize: QuickTerminalSize init() { self.quickTerminalScreen = .main self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true + self.quickTerminalPosition = .top + self.quickTerminalSize = .init() } init(_ config: Ghostty.Config) { self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide + self.quickTerminalPosition = config.quickTerminalPosition + self.quickTerminalSize = config.quickTerminalSize } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 3d2a2a0459..57860acac0 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -7,36 +7,6 @@ enum QuickTerminalPosition : String { case right case center - /// Set the loaded state for a window. - func setLoaded(_ window: NSWindow) { - guard let screen = window.screen ?? NSScreen.main else { return } - switch (self) { - case .top, .bottom: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width, - height: screen.frame.height / 4) - ), display: false) - - case .left, .right: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width / 4, - height: screen.frame.height) - ), display: false) - - case .center: - window.setFrame(.init( - origin: window.frame.origin, - size: .init( - width: screen.frame.width / 2, - height: screen.frame.height / 3) - ), display: false) - } - } - /// Set the initial state for a window for animating out of this position. func setInitial(in window: NSWindow, on screen: NSScreen) { // We always start invisible @@ -67,13 +37,12 @@ enum QuickTerminalPosition : String { switch (self) { case .top, .bottom: finalSize.width = screen.frame.width - + case .left, .right: finalSize.height = screen.frame.height - + case .center: - finalSize.width = screen.frame.width / 2 - finalSize.height = screen.frame.height / 3 + break } return finalSize diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift index cd07a6f120..eee819a812 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift @@ -12,10 +12,10 @@ enum QuickTerminalScreen { case "mouse": self = .mouse - + case "macos-menu-bar": self = .menuBar - + default: return nil } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift new file mode 100644 index 0000000000..74c89be488 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -0,0 +1,81 @@ +import Cocoa +import GhosttyKit + +class QuickTerminalSize { + enum Size { + case percent(value: Double) + case pixel(value: UInt) + + init?(c_dimension: ghostty_config_quick_terminal_dimension_s) { + switch(c_dimension.unit) { + case GHOSTTY_QUICK_TERMINAL_PIXEL_UNIT: + self = .pixel(value: UInt(c_dimension.value)) + case GHOSTTY_QUICK_TERMINAL_PERCENTAGE_UNIT: + self = .percent(value: Double(c_dimension.value) / 100.0) + default: + return nil + } + } + + func apply(value: CGFloat) -> CGFloat { + switch(self) { + case .pixel(let fixed_size): + return CGFloat(fixed_size); + case .percent(let pct): + return value * pct; + } + } + } + + var mainDimension: Size; + var secondDimension: Size; + + init() { + self.mainDimension = Size.percent(value: 0.25) + self.secondDimension = Size.percent(value: 0.25) + } + + init(config: ghostty_config_quick_terminal_size_s) { + switch (config.len) { + case 1: + self.mainDimension = Size(c_dimension: config.dimensions[0]) ?? Size.percent(value: 0.25) + self.secondDimension = Size.percent(value: 0.25) + case 2: + self.mainDimension = Size(c_dimension: config.dimensions[0]) ?? Size.percent(value: 0.25) + self.secondDimension = Size(c_dimension: config.dimensions[1]) ?? Size.percent(value: 0.25) + default: + self.mainDimension = Size.percent(value: 0.25) + self.secondDimension = Size.percent(value: 0.25) + } + } + + /// Set the window size. + func apply(_ window: NSWindow, _ position: QuickTerminalPosition) { + guard let screen = window.screen ?? NSScreen.main else { return } + switch (position) { + case .top, .bottom: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: screen.frame.width, + height: self.mainDimension.apply(value: screen.frame.height)) + ), display: false) + + case .left, .right: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: self.mainDimension.apply(value: screen.frame.width), + height: screen.frame.height) + ), display: false) + + case .center: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: self.mainDimension.apply(value: screen.frame.width), + height: self.secondDimension.apply(value: screen.frame.height)) + ), display: false) + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1e733c5e1f..cee23bca6e 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -406,6 +406,14 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var quickTerminalSize: QuickTerminalSize { + guard let config = self.config else { return .init() } + var v: ghostty_config_quick_terminal_size_s = .init() + let key = "quick-terminal-size" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return .init(config: v); + } #endif var resizeOverlay: ResizeOverlay { diff --git a/src/config/Config.zig b/src/config/Config.zig index b89aa566d2..a0b379bd0f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1411,6 +1411,31 @@ keybind: Keybinds = .{}, /// Set it to false for the quick terminal to remain open even when it loses focus. @"quick-terminal-autohide": bool = true, +/// Control the size of the quick terminal. +/// +/// The size can expressed in two units: +/// * A value ending in `%` specifies a percentage of the screen size. +/// * A value ending in `px` specifies a fixed size in pixel, which is clamped by the +/// screen's maximum width or height. +/// +/// The configuration accept one or two dimensions: +/// * A single value specifies the dimension that is growable based on the quick terminal +/// position: +/// * For `top` and `bottom` positions, the value applies to the height. +/// * For `right` and `left` positions, the value applies to the width. +/// * For `center`, the size applied to both the width and height. +/// * Two comma separated value specifies the width and height. +/// +/// Examples: +/// +/// ``` +/// quick-terminal-size = 25% // 25% of the maximum size for the growable dimension +/// quick-terminal-size = 42px // 42 pixels for the growable dimension +/// quick-terminal-size = 25%,75% // 25% for the primary dimension, 75% for the secondary +/// quick-terminal-size = 300px,80% // 300px for the primary dimension, 80% for the secondary +/// ``` +@"quick-terminal-size": ?QuickTerminalSize = null, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -5294,6 +5319,240 @@ pub const QuickTerminalScreen = enum { @"macos-menu-bar", }; +/// See quick-terminal-size +pub const QuickTerminalSize = struct { + const Self = @This(); + + pub const Size = union(UnitKey) { + // Absolute value in pixel. + pixel: u16, + + // Percentage value relative to the screen size. Allowed value [0-100]. + percent: u8, + + // Sync with `ghostty_config_quick_terminal_unit_e`. + pub const UnitKey = enum(c_int) { + pixel, + percent, + }; + + pub const C = extern struct { + value: u16, + tag: UnitKey, + }; + + pub fn cval(self: Size) Size.C { + return .{ + .tag = @as(UnitKey, self), + .value = res: { + switch (self) { + .percent => |v| { + break :res v; + }, + .pixel => |v| { + break :res @intCast(v); + }, + } + }, + }; + } + + fn parseValue(input: []const u8) !Size { + if (input[input.len - 1] == '%') { + const v = std.fmt.parseInt( + u8, + input[0 .. input.len - 1], + 10, + ) catch return error.InvalidFormat; + + // Percentage value must be between 0-100. + if (v > 100) return error.InvalidFormat; + + return .{ .percent = v }; + } else if (input[input.len - 2] == 'p' and input[input.len - 1] == 'x') { + const v = std.fmt.parseInt( + u16, + input[0 .. input.len - 2], + 10, + ) catch return error.InvalidFormat; + + return .{ .pixel = v }; + } + + return error.InvalidFormat; + } + + pub fn formatBuf(self: Size, buf: []u8) Allocator.Error![]const u8 { + return res: { + switch (self) { + .percent => |v| { + break :res std.fmt.bufPrint(buf, "{d}%", .{v}); + }, + .pixel => |v| { + break :res std.fmt.bufPrint(buf, "{d}px", .{v}); + }, + } + } catch error.OutOfMemory; + } + + pub fn equal(self: Size, other: Size) bool { + return std.meta.eql(self, other); + } + }; + + dimensions: std.ArrayListUnmanaged(Size) = .{}, + dimensions_c: std.ArrayListUnmanaged(Size.C) = .{}, + + /// Sync with `ghostty_config_quick_terminal_size_s` + pub const C = extern struct { + dimensions: [*]Size.C, + len: usize, + }; + + /// Required by Config, for C bindings. + pub fn cval(self: *const Self) C { + return .{ + .dimensions = self.dimensions_c.items.ptr, + .len = self.dimensions_c.items.len, + }; + } + + /// Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ + .dimensions = try self.dimensions.clone(alloc), + .dimensions_c = try self.dimensions_c.clone(alloc), + }; + } + + /// Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.dimensions.items; + const itemsB = other.dimensions.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |a, b| { + if (!a.equal(b)) return false; + } else return true; + } + + /// Required by Config. + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + if (value.len == 0) return error.InvalidFormat; + + self.* = .{}; + + var i: usize = 0; + var it = std.mem.tokenizeScalar(u8, value, ','); + while (it.next()) |part| { + i += 1; + if (i > 2) return error.InvalidValue; + + const parsed_value = try Size.parseValue(part); + try self.dimensions.append(alloc, parsed_value); + try self.dimensions_c.append(alloc, parsed_value.cval()); + } + + if (self.dimensions.items.len == 0) return error.InvalidValue; + assert(self.dimensions.items.len == self.dimensions_c.items.len); + } + + /// Required by Config, use for config formatted. + pub fn formatEntry(self: Self, formatter: anytype) !void { + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + var writer = fbs.writer(); + + for (self.dimensions.items, 0..) |dim, i| { + var dim_buf: [128]u8 = undefined; + const dim_str = try dim.formatBuf(&dim_buf); + if (i != 0) writer.writeByte(',') catch return error.OutOfMemory; + writer.writeAll(dim_str) catch return error.OutOfMemory; + } + + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const alloc = arena.allocator(); + + var p: Self = .{}; + + { + var expected_dimensions: [1]Size = .{.{ .pixel = 42 }}; + + try p.parseCLI(alloc, "42px"); + const parsed_dimensions = try p.dimensions.toOwnedSlice(alloc); + try testing.expectEqualSlices(Size, &expected_dimensions, parsed_dimensions); + } + + { + var expected_dimensions = [1]Size{.{ .percent = 15 }}; + + try p.parseCLI(alloc, "15%"); + const parsed_dimensions = try p.dimensions.toOwnedSlice(alloc); + try testing.expectEqualSlices(Size, &expected_dimensions, parsed_dimensions); + } + + { + var expected_dimensions = [_]Size{ .{ .pixel = 4096 }, .{ .percent = 23 } }; + + try p.parseCLI(alloc, "4096px,23%"); + const parsed_dimensions = try p.dimensions.toOwnedSlice(alloc); + try testing.expectEqualSlices(Size, &expected_dimensions, parsed_dimensions); + } + + { + var expected_dimensions = [2]Size{ .{ .percent = 78 }, .{ .pixel = 75 } }; + + try p.parseCLI(alloc, "78%,75px"); + const parsed_dimensions = try p.dimensions.toOwnedSlice(alloc); + try testing.expectEqualSlices(Size, &expected_dimensions, parsed_dimensions); + } + + try testing.expectError(error.InvalidFormat, p.parseCLI(alloc, "")); + try testing.expectError(error.InvalidFormat, p.parseCLI(alloc, "29")); + try testing.expectError(error.InvalidFormat, p.parseCLI(alloc, "120%")); + try testing.expectError(error.InvalidFormat, p.parseCLI(alloc, "65px,12")); + } + + test "formatEntry on one-dimension size" { + const testing = std.testing; + + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{}; + try p.parseCLI(alloc, "1024px"); + try p.formatEntry(formatterpkg.entryFormatter("v", buf.writer())); + try testing.expectEqualSlices(u8, "v = 1024px\n", buf.items); + } + + test "formatEntry on two-dimension size" { + const testing = std.testing; + + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var p: Self = .{}; + try p.parseCLI(alloc, "1024px,80%"); + try p.formatEntry(formatterpkg.entryFormatter("v", buf.writer())); + try testing.expectEqualSlices(u8, "v = 1024px,80%\n", buf.items); + } +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy,