From 9cf247bb3e90cacc833b6389e22c9ba9005d91d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Aug 2024 20:14:21 -0700 Subject: [PATCH 1/5] macos: implement resize overlay Implements the resize overlay configurations completely. --- include/ghostty.h | 10 +++ macos/Sources/Ghostty/Ghostty.Config.swift | 77 +++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 51 ++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 7 ++ src/apprt/embedded.zig | 21 +++++ src/config/Config.zig | 26 +++---- src/config/c_get.zig | 6 ++ 7 files changed, 185 insertions(+), 13 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 2feb35ad99..b56b8827ed 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -404,6 +404,15 @@ typedef struct { const char* command; } ghostty_surface_config_s; +typedef struct { + uint16_t columns; + uint16_t rows; + uint32_t width_px; + uint32_t height_px; + uint32_t cell_width_px; + uint32_t cell_height_px; +} ghostty_surface_size_s; + typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void*); typedef void (*ghostty_runtime_open_config_cb)(void*); @@ -530,6 +539,7 @@ void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_occlusion(ghostty_surface_t, bool); void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); +ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index ab583e9562..63997e6210 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -331,5 +331,82 @@ extension Ghostty { let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) return Color(newColor) } + + var resizeOverlay: ResizeOverlay { + guard let config = self.config else { return .after_first } + var v: UnsafePointer? = nil + let key = "resize-overlay" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .after_first } + guard let ptr = v else { return .after_first } + let str = String(cString: ptr) + return ResizeOverlay(rawValue: str) ?? .after_first + } + + var resizeOverlayPosition: ResizeOverlayPosition { + let defaultValue = ResizeOverlayPosition.center + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "resize-overlay-position" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return ResizeOverlayPosition(rawValue: str) ?? defaultValue + } + + var resizeOverlayDuration: UInt { + guard let config = self.config else { return 1000 } + var v: UInt = 0 + let key = "resize-overlay-duration" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v; + } + } +} + +// MARK: Configuration Enums + +extension Ghostty.Config { + enum ResizeOverlay : String { + case always + case never + case after_first = "after-first" + } + + enum ResizeOverlayPosition : String { + case center + case top_left = "top-left" + case top_center = "top-center" + case top_right = "top-right" + case bottom_left = "bottom-left" + case bottom_center = "bottom-center" + case bottom_right = "bottom-right" + + func top() -> Bool { + switch (self) { + case .top_left, .top_center, .top_right: return true; + default: return false; + } + } + + func bottom() -> Bool { + switch (self) { + case .bottom_left, .bottom_center, .bottom_right: return true; + default: return false; + } + } + + func left() -> Bool { + switch (self) { + case .top_left, .bottom_left: return true; + default: return false; + } + } + + func right() -> Bool { + switch (self) { + case .top_right, .bottom_right: return true; + default: return false; + } + } } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 92d8993004..e1c0a684b0 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -52,6 +52,9 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false + // The last size so we can detect resizes and show our resize overlay + @State private var lastSize: CGSize? = nil + @EnvironmentObject private var ghostty: Ghostty.App var body: some View { @@ -145,6 +148,54 @@ extension Ghostty { // I don't know how older macOS versions behave but Ghostty only // supports back to macOS 12 so its moot. } + + // If our geo size changed then we show the resize overlay as configured. + if (lastSize != geo.size) { + let resizeOverlay = ghostty.config.resizeOverlay + if (resizeOverlay != .never) { + if let surfaceSize = surfaceView.surfaceSize { + let padding: CGFloat = 5 + let resizeDuration = ghostty.config.resizeOverlayDuration + let resizePosition = ghostty.config.resizeOverlayPosition + + VStack { + if (!resizePosition.top()) { + Spacer() + } + + HStack { + if (!resizePosition.left()) { + Spacer() + } + + Text(verbatim: "\(surfaceSize.columns)c ⨯ \(surfaceSize.rows)r") + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.background) + .shadow(radius: 3) + ).lineLimit(1) + .truncationMode(.middle) + + if (!resizePosition.right()) { + Spacer() + } + } + + if (!resizePosition.bottom()) { + Spacer() + } + } + .allowsHitTesting(false) + .opacity(resizeOverlay == .after_first && lastSize == nil ? 0 : 1) + .onAppear() { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(resizeDuration))) { + self.lastSize = geo.size + } + } + } + } + } } .ghosttySurfaceView(surfaceView) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 26e5c7d7ab..3d6c177503 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -52,6 +52,13 @@ extension Ghostty { return v } + // Returns sizing information for the surface. This is the raw C + // structure because I'm lazy. + var surfaceSize: ghostty_surface_size_s? { + guard let surface = self.surface else { return nil } + return ghostty_surface_size(surface) + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b7c8b2bed8..9b7fcccb75 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1418,6 +1418,15 @@ pub const CAPI = struct { offset_len: u32, }; + const SurfaceSize = extern struct { + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, + cell_width_px: u32, + cell_height_px: u32, + }; + /// Create a new app. export fn ghostty_app_new( opts: *const apprt.runtime.App.Options, @@ -1593,6 +1602,18 @@ pub const CAPI = struct { surface.updateSize(w, h); } + /// Return the size information a surface has. + export fn ghostty_surface_size(surface: *Surface) SurfaceSize { + return .{ + .columns = surface.core_surface.grid_size.columns, + .rows = surface.core_surface.grid_size.rows, + .width_px = surface.core_surface.screen_size.width, + .height_px = surface.core_surface.screen_size.height, + .cell_width_px = surface.core_surface.cell_size.width, + .cell_height_px = surface.core_surface.cell_size.height, + }; + } + /// Update the color scheme of the surface. export fn ghostty_surface_set_color_scheme(surface: *Surface, scheme_raw: c_int) void { const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch { diff --git a/src/config/Config.zig b/src/config/Config.zig index a346ad9837..78081dc862 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -871,10 +871,9 @@ keybind: Keybinds = .{}, /// /// The default is `after-first`. /// -/// Changing this value at runtime and reloading the configuration will only -/// affect new windows, tabs, and splits. -/// -/// Linux/GTK only. +/// Changing this value at runtime and reloading the configuration will take +/// effect immediately on macOS, but will only affect new terminals on +/// Linux. @"resize-overlay": ResizeOverlay = .@"after-first", /// If resize overlays are enabled, this controls the position of the overlay. @@ -889,8 +888,6 @@ keybind: Keybinds = .{}, /// * `bottom-right` /// /// The default is `center`. -/// -/// Linux/GTK only. @"resize-overlay-position": ResizeOverlayPosition = .center, /// If resize overlays are enabled, this controls how long the overlay is @@ -923,8 +920,6 @@ keybind: Keybinds = .{}, /// /// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any /// value larger than this will be clamped to the maximum value. -/// -/// Linux/GTK only. @"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms }, // If true, when there are multiple split panes, the mouse selects the pane @@ -4027,11 +4022,11 @@ pub const Duration = struct { .{ .name = "ns", .factor = 1 }, }; - pub fn clone(self: *const @This(), _: Allocator) !@This() { + pub fn clone(self: *const Duration, _: Allocator) !Duration { return .{ .duration = self.duration }; } - pub fn equal(self: @This(), other: @This()) bool { + pub fn equal(self: Duration, other: Duration) bool { return self.duration == other.duration; } @@ -4099,7 +4094,7 @@ pub const Duration = struct { return if (value) |v| .{ .duration = v } else error.ValueRequired; } - pub fn formatEntry(self: @This(), formatter: anytype) !void { + pub fn formatEntry(self: Duration, formatter: anytype) !void { var buf: [64]u8 = undefined; var fbs = std.io.fixedBufferStream(&buf); const writer = fbs.writer(); @@ -4107,7 +4102,7 @@ pub const Duration = struct { try formatter.formatEntry([]const u8, fbs.getWritten()); } - pub fn format(self: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + pub fn format(self: Duration, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { var value = self.duration; var i: usize = 0; for (units) |unit| { @@ -4122,9 +4117,14 @@ pub const Duration = struct { } } + pub fn c_get(self: Duration, ptr_raw: *anyopaque) void { + const ptr: *usize = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @intCast(self.asMilliseconds()); + } + /// Convenience function to convert to milliseconds since many OS and /// library timing functions operate on that timescale. - pub fn asMilliseconds(self: @This()) c_uint { + pub fn asMilliseconds(self: Duration) c_uint { const ms: u64 = std.math.divTrunc( u64, self.duration, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index ff3523c29a..32a19df1cd 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -60,6 +60,12 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { }, .Struct => |info| { + // If the struct implements c_get then we call that + if (@hasDecl(@TypeOf(value), "c_get")) { + value.c_get(ptr_raw); + return true; + } + // Packed structs that are less than or equal to the // size of a C int can be passed directly as their // bit representation. From 1c982278f359807ac18e05d3264c1d4eafc39fbc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Aug 2024 20:56:21 -0700 Subject: [PATCH 2/5] macos: use dedicated overlay view for resize overlay --- macos/Sources/Ghostty/SurfaceView.swift | 126 +++++++++++++++--------- 1 file changed, 78 insertions(+), 48 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index e1c0a684b0..9c0c0514ea 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -52,9 +52,6 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false - // The last size so we can detect resizes and show our resize overlay - @State private var lastSize: CGSize? = nil - @EnvironmentObject private var ghostty: Ghostty.App var body: some View { @@ -150,51 +147,14 @@ extension Ghostty { } // If our geo size changed then we show the resize overlay as configured. - if (lastSize != geo.size) { - let resizeOverlay = ghostty.config.resizeOverlay - if (resizeOverlay != .never) { - if let surfaceSize = surfaceView.surfaceSize { - let padding: CGFloat = 5 - let resizeDuration = ghostty.config.resizeOverlayDuration - let resizePosition = ghostty.config.resizeOverlayPosition - - VStack { - if (!resizePosition.top()) { - Spacer() - } - - HStack { - if (!resizePosition.left()) { - Spacer() - } - - Text(verbatim: "\(surfaceSize.columns)c ⨯ \(surfaceSize.rows)r") - .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(.background) - .shadow(radius: 3) - ).lineLimit(1) - .truncationMode(.middle) - - if (!resizePosition.right()) { - Spacer() - } - } - - if (!resizePosition.bottom()) { - Spacer() - } - } - .allowsHitTesting(false) - .opacity(resizeOverlay == .after_first && lastSize == nil ? 0 : 1) - .onAppear() { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(resizeDuration))) { - self.lastSize = geo.size - } - } - } - } + if let surfaceSize = surfaceView.surfaceSize { + SurfaceResizeOverlay( + geoSize: geo.size, + size: surfaceSize, + overlay: ghostty.config.resizeOverlay, + position: ghostty.config.resizeOverlayPosition, + duration: ghostty.config.resizeOverlayDuration) + } } .ghosttySurfaceView(surfaceView) @@ -306,6 +266,76 @@ extension Ghostty { } } + // This is the resize overlay that shows on top of a surface to show the current + // size during a resize operation. + struct SurfaceResizeOverlay: View { + let geoSize: CGSize + let size: ghostty_surface_size_s + let overlay: Ghostty.Config.ResizeOverlay + let position: Ghostty.Config.ResizeOverlayPosition + let duration: UInt + + // This is the last size that we processed. This is how we handle our + // timer state. + @State var lastSize: CGSize? = nil + + // Fixed value set based on personal taste. + private let padding: CGFloat = 5 + + // This computed boolean is set to true when the overlay should be hidden. + private var hidden: Bool { + // Hidden if we already processed this size. + if (lastSize == geoSize) { return true; } + + // Hidden depending on overlay config + switch (overlay) { + case .never: return true; + case .always: return false; + case .after_first: return lastSize == nil; + } + } + + var body: some View { + VStack { + if (!position.top()) { + Spacer() + } + + HStack { + if (!position.left()) { + Spacer() + } + + Text(verbatim: "\(size.columns)c ⨯ \(size.rows)r") + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.background) + .shadow(radius: 3) + ).lineLimit(1) + .truncationMode(.middle) + + if (!position.right()) { + Spacer() + } + } + + if (!position.bottom()) { + Spacer() + } + } + .allowsHitTesting(false) + .opacity(hidden ? 0 : 1) + .task(id: geoSize) { + // By ID-ing the task on the geoSize, we get the task to restart if our + // geoSize changes. This also ensures that future resize overlays are shown + // properly. + try? await Task.sleep(nanoseconds: UInt64(duration) * 1_000_000) + lastSize = geoSize + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. From bac258e6d26581f81ff78db57eb3183370029b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Aug 2024 21:00:04 -0700 Subject: [PATCH 3/5] renderer: fix underflow possibility in padding calculation --- src/renderer/size.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 1c2cfe97a4..add3134ba7 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -43,8 +43,14 @@ pub const ScreenSize = struct { const grid_height = grid.rows * cell.height; const padded_width = grid_width + (padding.left + padding.right); const padded_height = grid_height + (padding.top + padding.bottom); - const leftover_width = self.width - padded_width; - const leftover_height = self.height - padded_height; + + // Note these have to use a saturating subtraction to avoid underflow + // because our padding can cause the padded sizes to be larger than + // our real screen if the screen is shrunk to a minimal size such + // as 1x1. + const leftover_width = self.width -| padded_width; + const leftover_height = self.height -| padded_height; + return .{ .top = 0, .bottom = leftover_height, From 0e2012617efe9c790e506638a96aa61e3f9a83f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Aug 2024 21:01:29 -0700 Subject: [PATCH 4/5] macos: truncate tail of resize view --- macos/Sources/Ghostty/SurfaceView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 9c0c0514ea..dba3ca6320 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -312,8 +312,9 @@ extension Ghostty { RoundedRectangle(cornerRadius: 4) .fill(.background) .shadow(radius: 3) - ).lineLimit(1) - .truncationMode(.middle) + ) + .lineLimit(1) + .truncationMode(.tail) if (!position.right()) { Spacer() From 448382b49d452a3178f00aef2e7bbd17a3af60bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Aug 2024 21:08:59 -0700 Subject: [PATCH 5/5] macos: add iOS field necessary --- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 87a9afa53f..a9739d7d4c 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -28,6 +28,13 @@ extension Ghostty { // The hovered URL @Published var hoverUrl: String? = nil + // Returns sizing information for the surface. This is the raw C + // structure because I'm lazy. + var surfaceSize: ghostty_surface_size_s? { + guard let surface = self.surface else { return nil } + return ghostty_surface_size(surface) + } + private(set) var surface: ghostty_surface_t? init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {