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
This commit is contained in:
Damien Mehala
2024-12-25 17:30:46 +01:00
parent 8de95f6e8d
commit 3da908a028
8 changed files with 386 additions and 39 deletions

View File

@ -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,

View File

@ -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 = "<group>"; };
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AB315D3F2D1DCC630012D326 /* QuickTerminalSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSize.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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
}
}
}

View File

@ -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

View File

@ -12,10 +12,10 @@ enum QuickTerminalScreen {
case "mouse":
self = .mouse
case "macos-menu-bar":
self = .menuBar
default:
return nil
}

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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,