Merge pull request #1898 from ghostty-org/pressure-click

Mouse Pressure Support and QuickLook on macOS
This commit is contained in:
Mitchell Hashimoto
2024-06-30 12:35:37 -04:00
committed by GitHub
6 changed files with 336 additions and 5 deletions

View File

@ -373,6 +373,13 @@ typedef struct {
const char* message; const char* message;
} ghostty_error_s; } 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 { typedef struct {
void* nsview; void* nsview;
} ghostty_platform_macos_s; } ghostty_platform_macos_s;
@ -535,6 +542,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t,
double, double,
double, double,
ghostty_input_scroll_mods_t); 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_ime_point(ghostty_surface_t, double*, double*);
void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_request_close(ghostty_surface_t);
void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); 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__ #ifdef __APPLE__
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); 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 #endif
ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import CoreText
import UserNotifications import UserNotifications
import GhosttyKit import GhosttyKit
@ -72,6 +73,7 @@ extension Ghostty {
private var markedText: NSMutableAttributedString private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false private var mouseEntered: Bool = false
private(set) var focused: Bool = true private(set) var focused: Bool = true
private var prevPressureStage: Int = 0
private var cursor: NSCursor = .iBeam private var cursor: NSCursor = .iBeam
private var cursorVisible: CursorVisibility = .visible private var cursorVisible: CursorVisibility = .visible
private var appearanceObserver: NSKeyValueObservation? = nil private var appearanceObserver: NSKeyValueObservation? = nil
@ -441,9 +443,16 @@ extension Ghostty {
} }
override func mouseUp(with event: NSEvent) { 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 } guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) 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) { override func otherMouseDown(with event: NSEvent) {
@ -570,6 +579,26 @@ extension Ghostty {
ghostty_surface_mouse_scroll(surface, x, y, mods) 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) { override func cursorUpdate(with event: NSEvent) {
switch (cursorVisible) { switch (cursorVisible) {
@ -800,7 +829,7 @@ extension Ghostty {
ghostty_surface_key(surface, key_ev) ghostty_surface_key(surface, key_ev)
} }
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
@ -901,7 +930,14 @@ extension Ghostty.SurfaceView: NSTextInputClient {
} }
func selectedRange() -> NSRange { 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) { func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@ -926,7 +962,39 @@ extension Ghostty.SurfaceView: NSTextInputClient {
} }
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { 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<CTFont>.fromOpaque(fontRaw)
attributes[.font] = font.takeUnretainedValue()
font.release()
}
return .init(string: v, attributes: attributes)
} }
func characterIndex(for point: NSPoint) -> Int { func characterIndex(for point: NSPoint) -> Int {
@ -937,11 +1005,29 @@ extension Ghostty.SurfaceView: NSTextInputClient {
guard let surface = self.surface else { guard let surface = self.surface else {
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
} }
// Ghostty will tell us where it thinks an IME keyboard should render. // Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0; var x: Double = 0;
var y: 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 // Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects // bottom-left since that is what UIKit expects

View File

@ -173,6 +173,10 @@ const Mouse = struct {
/// The last x/y sent for mouse reports. /// The last x/y sent for mouse reports.
event_point: ?terminal.point.Coordinate = null, 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 amounts for high-precision scrolls
pending_scroll_x: f64 = 0, pending_scroll_x: f64 = 0,
pending_scroll_y: 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 /// 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 /// the pwd can change at any point from termio. If we are calling from the IO
/// thread you should just check the terminal directly. /// thread you should just check the terminal directly.
@ -2492,6 +2559,41 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
return true; 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( pub fn cursorPosCallback(
self: *Surface, self: *Surface,
pos: apprt.CursorPos, pos: apprt.CursorPos,

View File

@ -10,6 +10,7 @@ const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const objc = @import("objc"); const objc = @import("objc");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const font = @import("../font/main.zig");
const input = @import("../input.zig"); const input = @import("../input.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.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( pub fn scrollCallback(
self: *Surface, self: *Surface,
xoff: f64, 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. /// Create a new app.
export fn ghostty_app_new( export fn ghostty_app_new(
opts: *const apprt.runtime.App.Options, 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 { export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void {
const pos = surface.core_surface.imePoint(); const pos = surface.core_surface.imePoint();
x.* = pos.x; x.* = pos.x;
@ -1741,6 +1779,70 @@ pub const CAPI = struct {
surface.renderer_thread.wakeup.notify() catch {}; 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 { export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {
return ptr.initMetal(objc.Object.fromId(device)); return ptr.initMetal(objc.Object.fromId(device));
} }

View File

@ -74,3 +74,18 @@ pub const ColorScheme = enum(u2) {
light = 0, light = 0,
dark = 1, 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,
};

View File

@ -63,6 +63,22 @@ pub const MouseMomentum = enum(u3) {
may_begin = 6, 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. /// The bitmask for mods for scroll events.
pub const ScrollMods = packed struct(u8) { pub const ScrollMods = packed struct(u8) {
/// True if this is a high-precision scroll event. For example, Apple /// True if this is a high-precision scroll event. For example, Apple