diff --git a/include/ghostty.h b/include/ghostty.h index 6638caaa6..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; @@ -535,6 +542,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); @@ -555,6 +563,8 @@ 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); +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 b3c002d64..f41a01d57 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 @@ -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,9 +443,16 @@ 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) + + // Release pressure + ghostty_surface_mouse_pressure(surface, 0, 0) } override func otherMouseDown(with event: NSEvent) { @@ -570,6 +579,26 @@ extension Ghostty { ghostty_surface_mouse_scroll(surface, x, y, mods) } + + 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 } + 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 +829,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 } @@ -901,7 +930,14 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func selectedRange() -> NSRange { - return NSRange() + guard let surface = self.surface else { return NSRange() } + + // 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 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) { @@ -926,7 +962,39 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil + // 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) { + 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 { @@ -937,11 +1005,29 @@ 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 + var sel: ghostty_selection_s = ghostty_selection_s(); + if ghostty_surface_selection_info(surface, &sel) { + // 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) + } + } 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 2227e1f37..a4557be78 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, @@ -845,6 +849,69 @@ pub fn selectionString(self: *Surface, alloc: Allocator) !?[]const u8 { }); } +/// Return the apprt selection metadata used by apprt's for implementing +/// things like contextual information on right click and so on. +/// +/// 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(); + 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); + 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; + + // 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 /// the pwd can change at any point from termio. If we are calling from the IO /// thread you should just check the terminal directly. @@ -2492,6 +2559,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..0ab955206 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"); @@ -716,6 +717,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, @@ -1364,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, @@ -1648,6 +1667,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; @@ -1741,6 +1779,70 @@ 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; + } + + // 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. + 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; + + // 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; + }; + + const copy = face.font.copyWithAttributes( + size / content_scale.y, + null, + null, + ) catch return null; + + return copy; + } + + /// 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, + info: *Selection, + ) bool { + const sel = ptr.core_surface.selectionInfo() orelse + return false; + + 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 { return ptr.initMetal(objc.Object.fromId(device)); } 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, +}; 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