mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge pull request #1898 from ghostty-org/pressure-click
Mouse Pressure Support and QuickLook on macOS
This commit is contained in:
@ -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);
|
||||
|
@ -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<CTFont>.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
|
||||
|
102
src/Surface.zig
102
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,
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user