From c165cef0a660e76f05fa941e65584e1add526069 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Jun 2024 06:14:06 -0400 Subject: [PATCH 01/12] macos: initialize some pressure click boilerplate --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index b3c002d64..23f0492d2 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,3 +1,4 @@ +import QuickLookUI import SwiftUI import UserNotifications import GhosttyKit @@ -72,6 +73,7 @@ extension Ghostty { private var markedText: NSMutableAttributedString private var mouseEntered: Bool = false private(set) var focused: Bool = true + private var prevPressureStage: Int = 0 private var cursor: NSCursor = .iBeam private var cursorVisible: CursorVisibility = .visible private var appearanceObserver: NSKeyValueObservation? = nil @@ -441,6 +443,10 @@ extension Ghostty { } override func mouseUp(with event: NSEvent) { + // Always reset our pressure when the mouse goes up + prevPressureStage = 0 + + // If we have an active surface, report the event guard let surface = self.surface else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) @@ -570,6 +576,19 @@ extension Ghostty { ghostty_surface_mouse_scroll(surface, x, y, mods) } + + override func pressureChange(with event: NSEvent) { + // Pressure stage 2 is force click. We only want to execute this on the + // initial transition to stage 2, and not for any repeated events. + guard self.prevPressureStage < 2 else { return } + prevPressureStage = event.stage + guard event.stage == 2 else { return } + + // If the user has force click enabled then we do a quick look. There + // is no public API for this as far as I can tell. + guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } + quickLook(with: event) + } override func cursorUpdate(with event: NSEvent) { switch (cursorVisible) { @@ -800,7 +819,7 @@ extension Ghostty { ghostty_surface_key(surface, key_ev) } } - + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { guard let surface = self.surface else { return } @@ -885,6 +904,31 @@ extension Ghostty { Ghostty.moveFocus(to: self) } } + + // MARK: QuickLook + + private var quickLookURL: NSURL? + + override func quickLook(with event: NSEvent) { + /* TODO + guard let panel = QLPreviewPanel.shared() else { return } + panel.delegate = self + panel.dataSource = self + panel.makeKeyAndOrderFront(self) + */ + } + } +} + +// MARK: QuickLook Delegates + +extension Ghostty.SurfaceView: QLPreviewPanelDelegate, QLPreviewPanelDataSource { + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + return quickLookURL != nil ? 1 : 0 + } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + return quickLookURL ?? nil } } From 6faeb9ba40ad91cb3ab390ca9297b2a814b02303 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Jun 2024 06:39:17 -0400 Subject: [PATCH 02/12] core: mouse pressure state and callbacks --- include/ghostty.h | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 7 ++++ src/Surface.zig | 39 +++++++++++++++++++ src/apprt/embedded.zig | 30 ++++++++++++++ src/input/mouse.zig | 16 ++++++++ 5 files changed, 93 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 6638caaa6..d49d02386 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -535,6 +535,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double, ghostty_input_scroll_mods_t); +void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); void ghostty_surface_ime_point(ghostty_surface_t, double*, double*); void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 23f0492d2..2a32fe6ce 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -578,6 +578,13 @@ extension Ghostty { } override func pressureChange(with event: NSEvent) { + guard let surface = self.surface else { return } + + // Notify Ghostty first. We do this because this will let Ghostty handle + // state setup that we'll need for later pressure handling (such as + // QuickLook) + ghostty_surface_mouse_pressure(surface, UInt32(event.stage), Double(event.pressure)) + // Pressure stage 2 is force click. We only want to execute this on the // initial transition to stage 2, and not for any repeated events. guard self.prevPressureStage < 2 else { return } diff --git a/src/Surface.zig b/src/Surface.zig index 2227e1f37..625e1ddb0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -173,6 +173,10 @@ const Mouse = struct { /// The last x/y sent for mouse reports. event_point: ?terminal.point.Coordinate = null, + /// The pressure stage for the mouse. This should always be none if + /// the mouse is not pressed. + pressure_stage: input.MousePressureStage = .none, + /// Pending scroll amounts for high-precision scrolls pending_scroll_x: f64 = 0, pending_scroll_y: f64 = 0, @@ -2492,6 +2496,41 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { return true; } +pub fn mousePressureCallback( + self: *Surface, + stage: input.MousePressureStage, + pressure: f64, +) !void { + // We don't currently use the pressure value for anything. In the + // future, we could report this to applications using new mouse + // events or utilize it for some custom UI. + _ = pressure; + + // If the pressure stage is the same as what we already have do nothing + if (self.mouse.pressure_stage == stage) return; + + // Update our pressure stage. + self.mouse.pressure_stage = stage; + + // If our left mouse button is pressed and we're entering a deep + // click then we want to start a selection. We treat this as a + // word selection since that is typical macOS behavior. + const left_idx = @intFromEnum(input.MouseButton.left); + if (self.mouse.click_state[left_idx] == .press and + stage == .deep) + select: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // This should always be set in this state but we don't want + // to handle state inconsistency here. + const pin = self.mouse.left_click_pin orelse break :select; + const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; + try self.setSelection(sel); + try self.queueRender(); + } +} + pub fn cursorPosCallback( self: *Surface, pos: apprt.CursorPos, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 57fc26aea..6fbd8ef14 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -716,6 +716,17 @@ pub const Surface = struct { }; } + pub fn mousePressureCallback( + self: *Surface, + stage: input.MousePressureStage, + pressure: f64, + ) void { + self.core_surface.mousePressureCallback(stage, pressure) catch |err| { + log.err("error in mouse pressure callback err={}", .{err}); + return; + }; + } + pub fn scrollCallback( self: *Surface, xoff: f64, @@ -1648,6 +1659,25 @@ pub const CAPI = struct { ); } + export fn ghostty_surface_mouse_pressure( + surface: *Surface, + stage_raw: u32, + pressure: f64, + ) void { + const stage = std.meta.intToEnum( + input.MousePressureStage, + stage_raw, + ) catch { + log.warn( + "invalid mouse pressure stage value={}", + .{stage_raw}, + ); + return; + }; + + surface.mousePressureCallback(stage, pressure); + } + export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void { const pos = surface.core_surface.imePoint(); x.* = pos.x; diff --git a/src/input/mouse.zig b/src/input/mouse.zig index 326e87e81..7fb3cfe89 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -63,6 +63,22 @@ pub const MouseMomentum = enum(u3) { may_begin = 6, }; +/// The pressure stage of a pressure-sensitive input device. +/// +/// This currently only supports the stages that macOS supports. +pub const MousePressureStage = enum(u2) { + /// The input device is unpressed. + none = 0, + + /// The input device is pressed a normal amount. On macOS trackpads, + /// this is after a "click". + normal = 1, + + /// The input device is pressed a deep amount. On macOS trackpads, + /// this is after a "force click". + deep = 2, +}; + /// The bitmask for mods for scroll events. pub const ScrollMods = packed struct(u8) { /// True if this is a high-precision scroll event. For example, Apple From 99faeab649b0a23537ee6eae9de07aecd7bd3914 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Jun 2024 06:40:03 -0400 Subject: [PATCH 03/12] macos: release pressure on mouseUp --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2a32fe6ce..93375ba3c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -450,6 +450,9 @@ extension Ghostty { guard let surface = self.surface else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + + // Release pressure + ghostty_surface_mouse_pressure(surface, 0, 0) } override func otherMouseDown(with event: NSEvent) { From 280b8efacc9f522aa7581d74c423d382997ce529 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Jun 2024 06:58:33 -0400 Subject: [PATCH 04/12] macos: I don't need any custom quicklook stuff --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 93375ba3c..ed8d52c43 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,3 @@ -import QuickLookUI import SwiftUI import UserNotifications import GhosttyKit @@ -914,31 +913,6 @@ extension Ghostty { Ghostty.moveFocus(to: self) } } - - // MARK: QuickLook - - private var quickLookURL: NSURL? - - override func quickLook(with event: NSEvent) { - /* TODO - guard let panel = QLPreviewPanel.shared() else { return } - panel.delegate = self - panel.dataSource = self - panel.makeKeyAndOrderFront(self) - */ - } - } -} - -// MARK: QuickLook Delegates - -extension Ghostty.SurfaceView: QLPreviewPanelDelegate, QLPreviewPanelDataSource { - func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { - return quickLookURL != nil ? 1 : 0 - } - - func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - return quickLookURL ?? nil } } From d5f27245d4784e5c4897e59ef99b47dc34a5b3fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 18 Jun 2024 17:43:39 -0400 Subject: [PATCH 05/12] macos: hacky API to get a CTFont for QuickLook --- include/ghostty.h | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 37 ++++++++++++++++++- src/apprt/embedded.zig | 32 ++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index d49d02386..8aef893c9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -556,6 +556,7 @@ uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); +void* ghostty_surface_quicklook_font(ghostty_surface_t); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index ed8d52c43..2abf20d9c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoreText import UserNotifications import GhosttyKit @@ -929,7 +930,13 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func selectedRange() -> NSRange { - return NSRange() + guard let surface = self.surface else { return NSRange() } + guard ghostty_surface_has_selection(surface) else { return NSRange() } + + // If we have a selection, we just return a non-empty range. The actual + // values are meaningless but the non-emptiness of it tells AppKit we + // have a selection. + return NSRange(location: 0, length: 1) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -954,7 +961,33 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil + // We ignore the proposed range and always return the selection from + // this (if we have one). This enables features like QuickLook. I don't + // know if this breaks anything else... + guard let surface = self.surface else { return nil } + guard ghostty_surface_has_selection(surface) else { return nil } + + // Get our selection. We cap it at 1MB for the purpose of this. This is + // arbitrary. If this is a good reason to increase it I'm happy to. + let v = String(unsafeUninitializedCapacity: 1000000) { + Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count))) + } + + // If we can get a font then we use the font. This should always work + // since we always have a primary font. The only scenario this doesn't + // work is if someone is using a non-CoreText build which would be + // unofficial. + var attributes: [ NSAttributedString.Key : Any ] = [:]; + if let fontRaw = ghostty_surface_quicklook_font(surface) { + // Memory management here is wonky: ghostty_surface_quicklook_font + // will create a copy of a CTFont, Swift will auto-retain the + // unretained value passed into the dict, so we release the original. + let font = Unmanaged.fromOpaque(fontRaw) + attributes[.font] = font.takeUnretainedValue() + font.release() + } + + return .init(string: v, attributes: attributes) } func characterIndex(for point: NSPoint) -> Int { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6fbd8ef14..392c3a90b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -10,6 +10,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); @@ -1771,6 +1772,37 @@ pub const CAPI = struct { surface.renderer_thread.wakeup.notify() catch {}; } + /// This returns a CTFontRef that should be used for quicklook + /// highlighted text. This is always the primary font in use + /// regardless of the selected text. If coretext is not in use + /// then this will return nothing. + export fn ghostty_surface_quicklook_font(ptr: *Surface) ?*anyopaque { + // For non-CoreText we just return null. + if (comptime font.options.backend != .coretext) { + return null; + } + + // Get the shared font grid. We acquire a read lock to + // read the font face. It should not be deffered since + // we're loading the primary face. + const grid = ptr.core_surface.renderer.font_grid; + grid.lock.lockShared(); + defer grid.lock.unlockShared(); + + const collection = &grid.resolver.collection; + const face = collection.getFace(.{}) catch return null; + + // The font is not the right size by default so we need + // to set it to our configured window size. + const copy = face.font.copyWithAttributes( + ptr.app.config.@"font-size", + null, + null, + ) catch return null; + + return copy; + } + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { return ptr.initMetal(objc.Object.fromId(device)); } From 4c3fbffa4beeb480948e4963de9672ba18bbb408 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Jun 2024 10:31:51 -0700 Subject: [PATCH 06/12] macos: return valid selection range --- include/ghostty.h | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++----- src/Surface.zig | 32 +++++++++++++++++++ src/apprt/embedded.zig | 20 ++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 8aef893c9..a0160af43 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -557,6 +557,7 @@ uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); +void ghostty_surface_selection_range(ghostty_surface_t, uint32_t*, uint32_t*); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2abf20d9c..91bc37f35 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -931,12 +931,14 @@ extension Ghostty.SurfaceView: NSTextInputClient { func selectedRange() -> NSRange { guard let surface = self.surface else { return NSRange() } - guard ghostty_surface_has_selection(surface) else { return NSRange() } - // If we have a selection, we just return a non-empty range. The actual - // values are meaningless but the non-emptiness of it tells AppKit we - // have a selection. - return NSRange(location: 0, length: 1) + // Get our range from the Ghostty API. There is a race condition between getting the + // range and actually using it since our selection may change but there isn't a good + // way I can think of to solve this for AppKit. + var start: UInt32 = 0; + var len: UInt32 = 0; + ghostty_surface_selection_range(surface, &start, &len); + return NSRange(location: Int(start), length: Int(len)) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -961,12 +963,18 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - // We ignore the proposed range and always return the selection from - // this (if we have one). This enables features like QuickLook. I don't - // know if this breaks anything else... + Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())") guard let surface = self.surface else { return nil } guard ghostty_surface_has_selection(surface) else { return nil } + // If the range is empty then we don't need to return anything + guard range.length > 0 else { return nil } + + // I used to do a bunch of testing here that the range requested matches the + // selection range or contains it but a lot of macOS system behaviors request + // bogus ranges I truly don't understand so we just always return the + // attributed string containing our selection which is... weird but works? + // Get our selection. We cap it at 1MB for the purpose of this. This is // arbitrary. If this is a good reason to increase it I'm happy to. let v = String(unsafeUninitializedCapacity: 1000000) { diff --git a/src/Surface.zig b/src/Surface.zig index 625e1ddb0..e7ae87fe4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -849,6 +849,38 @@ pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { }); } +/// This returns the selection range offset from the beginning of the +/// viewport. If the selection is not entirely within the viewport then +/// this will return null. +/// +/// This is a oddly specific function that is used with macOS to enable +/// NSTextInputClient to work properly for features such as the IME Emoji +/// keyboard and QuickLook amongst other things. +pub fn selectionRange(self: *Surface) ?struct { + start: u32, + len: u32, +} { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Get the TL/BR pins for the selection + const sel = self.io.terminal.screen.selection orelse return null; + const tl = sel.topLeft(&self.io.terminal.screen); + const br = sel.bottomRight(&self.io.terminal.screen); + + // Convert the pins to coordinates (x,y) + const tl_pt = self.io.terminal.screen.pages.pointFromPin(.viewport, tl) orelse return null; + const br_pt = self.io.terminal.screen.pages.pointFromPin(.viewport, br) orelse return null; + const tl_coord = tl_pt.coord(); + const br_coord = br_pt.coord(); + + // Utilize viewport sizing to convert to offsets + const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x; + const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x; + + return .{ .start = start, .len = end - start }; +} + /// Returns the pwd of the terminal, if any. This is always copied because /// the pwd can change at any point from termio. If we are calling from the IO /// thread you should just check the terminal directly. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 392c3a90b..5726bc1f9 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1803,6 +1803,26 @@ pub const CAPI = struct { return copy; } + /// This returns the start and length of the current selection range + /// in viewport coordinates. If the selection is not visible in the + /// viewport completely then this will return 0,0. This rather odd + /// detail is due to the current usage of this in the macOS app where + /// selections are only meaningful if they're in the viewport. We can + /// change this behavior if we have something useful to do with the + /// selection range outside of the viewport in the future. + export fn ghostty_surface_selection_range( + ptr: *Surface, + start: *u32, + len: *u32, + ) void { + start.* = 0; + len.* = 0; + + const range = ptr.core_surface.selectionRange() orelse return; + start.* = range.start; + len.* = range.len; + } + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { return ptr.initMetal(objc.Object.fromId(device)); } From 80700d524db0d7e1c622379abae2519668c48e06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Jun 2024 14:05:47 -0700 Subject: [PATCH 07/12] macos: add API to get selection top-left for quicklook --- include/ghostty.h | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 14 +++++- src/Surface.zig | 47 +++++++++++++++++++ src/apprt/embedded.zig | 10 ++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index a0160af43..1deea1864 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -558,6 +558,7 @@ uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); void ghostty_surface_selection_range(ghostty_surface_t, uint32_t*, uint32_t*); +void ghostty_surface_selection_point(ghostty_surface_t, double*, double*); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 91bc37f35..a95538729 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1006,11 +1006,21 @@ extension Ghostty.SurfaceView: NSTextInputClient { guard let surface = self.surface else { return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) } - + // Ghostty will tell us where it thinks an IME keyboard should render. var x: Double = 0; var y: Double = 0; - ghostty_surface_ime_point(surface, &x, &y) + + // QuickLook never gives us a matching range to our selection so if we detect + // this then we return the top-left selection point rather than the cursor point. + // This is hacky but I can't think of a better way to get the right IME vs. QuickLook + // point right now. I'm sure I'm missing something fundamental... + if range.length > 0 && range != self.selectedRange() { + // QuickLook + ghostty_surface_selection_point(surface, &x, &y) + } else { + ghostty_surface_ime_point(surface, &x, &y) + } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects diff --git a/src/Surface.zig b/src/Surface.zig index e7ae87fe4..c0c011936 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -933,6 +933,53 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { return .{ .x = x, .y = y }; } +/// Returns the x/y coordinate of where the selection top-left is. This is +/// used currently only by macOS to render the QuickLook highlight in the +/// proper location. +pub fn selectionPoint(self: *const Surface) ?apprt.IMEPos { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // Get the top-left coordinate of the selection in the viewport. + const sel = self.io.terminal.screen.selection orelse return null; + const tl_pt = self.io.terminal.screen.pages.pointFromPin( + .viewport, + sel.topLeft(&self.io.terminal.screen), + ) orelse return null; + const tl_coord = tl_pt.coord(); + + // Our sizes are all scaled so we need to send the unscaled values back. + const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; + + const x: f64 = x: { + // Simple x * cell width gives the top-left corner + var x: f64 = @floatFromInt(tl_coord.x * self.cell_size.width); + + // We want the midpoint + x += @as(f64, @floatFromInt(self.cell_size.width)) / 2; + + // And scale it + x /= content_scale.x; + + break :x x; + }; + + const y: f64 = y: { + // Simple x * cell width gives the top-left corner + var y: f64 = @floatFromInt(tl_coord.y * self.cell_size.height); + + // We want the bottom + y += @floatFromInt(self.cell_size.height); + + // And scale it + y /= content_scale.y; + + break :y y; + }; + + return .{ .x = x, .y = y }; +} + fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { if (self.config.clipboard_write == .deny) { log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 5726bc1f9..98a96345b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1823,6 +1823,16 @@ pub const CAPI = struct { len.* = range.len; } + export fn ghostty_surface_selection_point( + ptr: *Surface, + x: *f64, + y: *f64, + ) void { + const point = ptr.core_surface.selectionPoint() orelse return; + x.* = point.x; + y.* = point.y; + } + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { return ptr.initMetal(objc.Object.fromId(device)); } From 26735f0e052b1ebfbaad9b7b0bda1207103b7ac8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Jun 2024 11:23:14 -0500 Subject: [PATCH 08/12] apprt: convert selection info to a single struct and C API --- include/ghostty.h | 10 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 15 ++- src/Surface.zig | 108 ++++++++---------- src/apprt/embedded.zig | 49 ++++---- src/apprt/structs.zig | 15 +++ 5 files changed, 102 insertions(+), 95 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 1deea1864..e3a9e4223 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -373,6 +373,13 @@ typedef struct { const char* message; } ghostty_error_s; +typedef struct { + double tl_px_x; + double tl_px_y; + uint32_t offset_start; + uint32_t offset_len; +} ghostty_selection_s; + typedef struct { void* nsview; } ghostty_platform_macos_s; @@ -557,8 +564,7 @@ uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); -void ghostty_surface_selection_range(ghostty_surface_t, uint32_t*, uint32_t*); -void ghostty_surface_selection_point(ghostty_surface_t, double*, double*); +bool ghostty_surface_selection_info(ghostty_surface_t, ghostty_selection_s*); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index a95538729..9a28dc5cf 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -935,10 +935,9 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Get our range from the Ghostty API. There is a race condition between getting the // range and actually using it since our selection may change but there isn't a good // way I can think of to solve this for AppKit. - var start: UInt32 = 0; - var len: UInt32 = 0; - ghostty_surface_selection_range(surface, &start, &len); - return NSRange(location: Int(start), length: Int(len)) + var sel: ghostty_selection_s = ghostty_selection_s(); + guard ghostty_surface_selection_info(surface, &sel) else { return NSRange() } + return NSRange(location: Int(sel.offset_start), length: Int(sel.offset_len)) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { @@ -1017,7 +1016,13 @@ extension Ghostty.SurfaceView: NSTextInputClient { // point right now. I'm sure I'm missing something fundamental... if range.length > 0 && range != self.selectedRange() { // QuickLook - ghostty_surface_selection_point(surface, &x, &y) + var sel: ghostty_selection_s = ghostty_selection_s(); + if ghostty_surface_selection_info(surface, &sel) { + x = sel.tl_px_x; + y = sel.tl_px_y; + } else { + ghostty_surface_ime_point(surface, &x, &y) + } } else { ghostty_surface_ime_point(surface, &x, &y) } diff --git a/src/Surface.zig b/src/Surface.zig index c0c011936..a4557be78 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -849,26 +849,23 @@ pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { }); } -/// This returns the selection range offset from the beginning of the -/// viewport. If the selection is not entirely within the viewport then -/// this will return null. +/// Return the apprt selection metadata used by apprt's for implementing +/// things like contextual information on right click and so on. /// -/// This is a oddly specific function that is used with macOS to enable -/// NSTextInputClient to work properly for features such as the IME Emoji -/// keyboard and QuickLook amongst other things. -pub fn selectionRange(self: *Surface) ?struct { - start: u32, - len: u32, -} { +/// This only returns non-null if the selection is fully contained within +/// the viewport. The use case for this function at the time of authoring +/// it is for apprt's to implement right-click contextual menus and +/// those only make sense for selections fully contained within the +/// viewport. We don't handle the case where you right click a word-wrapped +/// word at the end of the viewport yet. +pub fn selectionInfo(self: *const Surface) ?apprt.Selection { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - - // Get the TL/BR pins for the selection const sel = self.io.terminal.screen.selection orelse return null; + + // Get the TL/BR pins for the selection and convert to viewport. const tl = sel.topLeft(&self.io.terminal.screen); const br = sel.bottomRight(&self.io.terminal.screen); - - // Convert the pins to coordinates (x,y) const tl_pt = self.io.terminal.screen.pages.pointFromPin(.viewport, tl) orelse return null; const br_pt = self.io.terminal.screen.pages.pointFromPin(.viewport, br) orelse return null; const tl_coord = tl_pt.coord(); @@ -878,7 +875,41 @@ pub fn selectionRange(self: *Surface) ?struct { const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x; const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x; - return .{ .start = start, .len = end - start }; + // Our sizes are all scaled so we need to send the unscaled values back. + const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; + + const x: f64 = x: { + // Simple x * cell width gives the top-left corner + var x: f64 = @floatFromInt(tl_coord.x * self.cell_size.width); + + // We want the midpoint + x += @as(f64, @floatFromInt(self.cell_size.width)) / 2; + + // And scale it + x /= content_scale.x; + + break :x x; + }; + + const y: f64 = y: { + // Simple x * cell width gives the top-left corner + var y: f64 = @floatFromInt(tl_coord.y * self.cell_size.height); + + // We want the bottom + y += @floatFromInt(self.cell_size.height); + + // And scale it + y /= content_scale.y; + + break :y y; + }; + + return .{ + .tl_x_px = x, + .tl_y_px = y, + .offset_start = start, + .offset_len = end - start, + }; } /// Returns the pwd of the terminal, if any. This is always copied because @@ -933,53 +964,6 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { return .{ .x = x, .y = y }; } -/// Returns the x/y coordinate of where the selection top-left is. This is -/// used currently only by macOS to render the QuickLook highlight in the -/// proper location. -pub fn selectionPoint(self: *const Surface) ?apprt.IMEPos { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // Get the top-left coordinate of the selection in the viewport. - const sel = self.io.terminal.screen.selection orelse return null; - const tl_pt = self.io.terminal.screen.pages.pointFromPin( - .viewport, - sel.topLeft(&self.io.terminal.screen), - ) orelse return null; - const tl_coord = tl_pt.coord(); - - // Our sizes are all scaled so we need to send the unscaled values back. - const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; - - const x: f64 = x: { - // Simple x * cell width gives the top-left corner - var x: f64 = @floatFromInt(tl_coord.x * self.cell_size.width); - - // We want the midpoint - x += @as(f64, @floatFromInt(self.cell_size.width)) / 2; - - // And scale it - x /= content_scale.x; - - break :x x; - }; - - const y: f64 = y: { - // Simple x * cell width gives the top-left corner - var y: f64 = @floatFromInt(tl_coord.y * self.cell_size.height); - - // We want the bottom - y += @floatFromInt(self.cell_size.height); - - // And scale it - y /= content_scale.y; - - break :y y; - }; - - return .{ .x = x, .y = y }; -} - fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { if (self.config.clipboard_write == .deny) { log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 98a96345b..34a82046e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1376,6 +1376,13 @@ pub const CAPI = struct { } }; + const Selection = extern struct { + tl_x_px: f64, + tl_y_px: f64, + offset_start: u32, + offset_len: u32, + }; + /// Create a new app. export fn ghostty_app_new( opts: *const apprt.runtime.App.Options, @@ -1803,34 +1810,24 @@ pub const CAPI = struct { return copy; } - /// This returns the start and length of the current selection range - /// in viewport coordinates. If the selection is not visible in the - /// viewport completely then this will return 0,0. This rather odd - /// detail is due to the current usage of this in the macOS app where - /// selections are only meaningful if they're in the viewport. We can - /// change this behavior if we have something useful to do with the - /// selection range outside of the viewport in the future. - export fn ghostty_surface_selection_range( + /// This returns the selection metadata for the current selection. + /// This will return false if there is no selection or the + /// selection is not fully contained in the viewport (since the + /// metadata is all about that). + export fn ghostty_surface_selection_info( ptr: *Surface, - start: *u32, - len: *u32, - ) void { - start.* = 0; - len.* = 0; + info: *Selection, + ) bool { + const sel = ptr.core_surface.selectionInfo() orelse + return false; - const range = ptr.core_surface.selectionRange() orelse return; - start.* = range.start; - len.* = range.len; - } - - export fn ghostty_surface_selection_point( - ptr: *Surface, - x: *f64, - y: *f64, - ) void { - const point = ptr.core_surface.selectionPoint() orelse return; - x.* = point.x; - y.* = point.y; + info.* = .{ + .tl_x_px = sel.tl_x_px, + .tl_y_px = sel.tl_y_px, + .offset_start = sel.offset_start, + .offset_len = sel.offset_len, + }; + return true; } export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 2a4583103..1982cc497 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -74,3 +74,18 @@ pub const ColorScheme = enum(u2) { light = 0, dark = 1, }; + +/// Selection information +pub const Selection = struct { + /// Top-left point of the selection in the viewport in scaled + /// window pixels. (0,0) is the top-left of the window. + tl_x_px: f64, + tl_y_px: f64, + + /// The offset of the selection start in cells from the top-left + /// of the viewport. + /// + /// This is a strange metric but its used by macOS. + offset_start: u32, + offset_len: u32, +}; From 9a7856342897dbcd943f210963fa185331176228 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Jun 2024 11:29:02 -0500 Subject: [PATCH 09/12] add TODO for branch --- src/apprt/embedded.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 34a82046e..ce1579093 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1799,6 +1799,9 @@ pub const CAPI = struct { const collection = &grid.resolver.collection; const face = collection.getFace(.{}) catch return null; + // TODO(pressure-click): the font size below only does + // the initial font size and not the current font size. + // The font is not the right size by default so we need // to set it to our configured window size. const copy = face.font.copyWithAttributes( From afc172728f56687dae6ce16e6f4ba121bdd23edd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Jun 2024 11:31:12 -0500 Subject: [PATCH 10/12] macos: offset quicklook box a bit to look better (imo) --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 9a28dc5cf..1cf094d87 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1018,8 +1018,10 @@ extension Ghostty.SurfaceView: NSTextInputClient { // QuickLook var sel: ghostty_selection_s = ghostty_selection_s(); if ghostty_surface_selection_info(surface, &sel) { - x = sel.tl_px_x; - y = sel.tl_px_y; + // The -2/+2 here is subjective. QuickLook seems to offset the rectangle + // a bit and I think these small adjustments make it look more natural. + x = sel.tl_px_x - 2; + y = sel.tl_px_y + 2; } else { ghostty_surface_ime_point(surface, &x, &y) } From db29c10bceba233f2f97c57d0dc3fe4f62044600 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 29 Jun 2024 11:43:13 -0500 Subject: [PATCH 11/12] apprt: scale coretext font size for quicklook properly --- src/apprt/embedded.zig | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index ce1579093..0ab955206 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1789,6 +1789,9 @@ pub const CAPI = struct { return null; } + // We'll need content scale so fail early if we can't get it. + const content_scale = ptr.getContentScale() catch return null; + // Get the shared font grid. We acquire a read lock to // read the font face. It should not be deffered since // we're loading the primary face. @@ -1799,13 +1802,20 @@ pub const CAPI = struct { const collection = &grid.resolver.collection; const face = collection.getFace(.{}) catch return null; - // TODO(pressure-click): the font size below only does - // the initial font size and not the current font size. + // We need to unscale the content scale. We apply the + // content scale to our font stack because we are rendering + // at 1x but callers of this should be using scaled or apply + // scale themselves. + const size: f32 = size: { + const num = face.font.copyAttribute(.size); + defer num.release(); + var v: f32 = 12; + _ = num.getValue(.float, &v); + break :size v; + }; - // The font is not the right size by default so we need - // to set it to our configured window size. const copy = face.font.copyWithAttributes( - ptr.app.config.@"font-size", + size / content_scale.y, null, null, ) catch return null; From 46a0cbf9d7b89697180055f9719343cd663a54b3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Jun 2024 09:18:13 -0700 Subject: [PATCH 12/12] macos: remove log statement --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 1cf094d87..f41a01d57 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -962,7 +962,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())") + // Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())") guard let surface = self.surface else { return nil } guard ghostty_surface_has_selection(surface) else { return nil }