Merge pull request #454 from mitchellh/pointer-cursor

OSC 22: Set Mouse Shape
This commit is contained in:
Mitchell Hashimoto
2023-09-14 11:55:40 -07:00
committed by GitHub
13 changed files with 524 additions and 138 deletions

View File

@ -70,6 +70,43 @@ typedef enum {
GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
} ghostty_input_mouse_momentum_e; } ghostty_input_mouse_momentum_e;
typedef enum {
GHOSTTY_MOUSE_SHAPE_DEFAULT,
GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
GHOSTTY_MOUSE_SHAPE_HELP,
GHOSTTY_MOUSE_SHAPE_POINTER,
GHOSTTY_MOUSE_SHAPE_PROGRESS,
GHOSTTY_MOUSE_SHAPE_WAIT,
GHOSTTY_MOUSE_SHAPE_CELL,
GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
GHOSTTY_MOUSE_SHAPE_TEXT,
GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
GHOSTTY_MOUSE_SHAPE_ALIAS,
GHOSTTY_MOUSE_SHAPE_COPY,
GHOSTTY_MOUSE_SHAPE_MOVE,
GHOSTTY_MOUSE_SHAPE_NO_DROP,
GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
GHOSTTY_MOUSE_SHAPE_GRAB,
GHOSTTY_MOUSE_SHAPE_GRABBING,
GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
GHOSTTY_MOUSE_SHAPE_N_RESIZE,
GHOSTTY_MOUSE_SHAPE_E_RESIZE,
GHOSTTY_MOUSE_SHAPE_S_RESIZE,
GHOSTTY_MOUSE_SHAPE_W_RESIZE,
GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
} ghostty_mouse_shape_e;
typedef enum { typedef enum {
GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE, GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE,
GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE, GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE,
@ -264,6 +301,7 @@ typedef struct {
typedef void (*ghostty_runtime_wakeup_cb)(void *); typedef void (*ghostty_runtime_wakeup_cb)(void *);
typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void *); typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void *);
typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef void (*ghostty_runtime_set_title_cb)(void *, const char *);
typedef void (*ghostty_runtime_set_mouse_shape_cb)(void *, ghostty_mouse_shape_e);
typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e);
typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e);
typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s);
@ -281,6 +319,7 @@ typedef struct {
ghostty_runtime_wakeup_cb wakeup_cb; ghostty_runtime_wakeup_cb wakeup_cb;
ghostty_runtime_reload_config_cb reload_config_cb; ghostty_runtime_reload_config_cb reload_config_cb;
ghostty_runtime_set_title_cb set_title_cb; ghostty_runtime_set_title_cb set_title_cb;
ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb;
ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb;
ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb;
ghostty_runtime_new_split_cb new_split_cb; ghostty_runtime_new_split_cb new_split_cb;

View File

@ -72,6 +72,7 @@ extension Ghostty {
wakeup_cb: { userdata in AppState.wakeup(userdata) }, wakeup_cb: { userdata in AppState.wakeup(userdata) },
reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, reload_config_cb: { userdata in AppState.reloadConfig(userdata) },
set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) },
set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) },
read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) }, read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) },
write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) }, write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) },
new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) },
@ -332,6 +333,11 @@ extension Ghostty {
surfaceView.title = titleStr surfaceView.title = titleStr
} }
} }
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
surfaceView.setCursorShape(shape)
}
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
guard let surface = self.surfaceUserdata(from: userdata) else { return } guard let surface = self.surfaceUserdata(from: userdata) else { return }

View File

@ -6,7 +6,7 @@ extension Ghostty {
struct Terminal: View { struct Terminal: View {
@Environment(\.ghosttyApp) private var app @Environment(\.ghosttyApp) private var app
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
var body: some View { var body: some View {
if let app = self.app { if let app = self.app {
SurfaceForApp(app) { surfaceView in SurfaceForApp(app) { surfaceView in
@ -16,44 +16,44 @@ extension Ghostty {
} }
} }
} }
/// Yields a SurfaceView for a ghostty app that can then be used however you want. /// Yields a SurfaceView for a ghostty app that can then be used however you want.
struct SurfaceForApp<Content: View>: View { struct SurfaceForApp<Content: View>: View {
let content: ((SurfaceView) -> Content) let content: ((SurfaceView) -> Content)
@StateObject private var surfaceView: SurfaceView @StateObject private var surfaceView: SurfaceView
init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) {
_surfaceView = StateObject(wrappedValue: SurfaceView(app, nil)) _surfaceView = StateObject(wrappedValue: SurfaceView(app, nil))
self.content = content self.content = content
} }
var body: some View { var body: some View {
content(surfaceView) content(surfaceView)
} }
} }
struct SurfaceWrapper: View { struct SurfaceWrapper: View {
// The surface to create a view for. This must be created upstream. As long as this // The surface to create a view for. This must be created upstream. As long as this
// remains the same, the surface that is being rendered remains the same. // remains the same, the surface that is being rendered remains the same.
@ObservedObject var surfaceView: SurfaceView @ObservedObject var surfaceView: SurfaceView
// True if this surface is part of a split view. This is important to know so // True if this surface is part of a split view. This is important to know so
// we know whether to dim the surface out of focus. // we know whether to dim the surface out of focus.
var isSplit: Bool = false var isSplit: Bool = false
// Maintain whether our view has focus or not // Maintain whether our view has focus or not
@FocusState private var surfaceFocus: Bool @FocusState private var surfaceFocus: Bool
// Maintain whether our window has focus (is key) or not // Maintain whether our window has focus (is key) or not
@State private var windowFocus: Bool = true @State private var windowFocus: Bool = true
@Environment(\.ghosttyConfig) private var ghostty_config @Environment(\.ghosttyConfig) private var ghostty_config
// This is true if the terminal is considered "focused". The terminal is focused if // This is true if the terminal is considered "focused". The terminal is focused if
// it is both individually focused and the containing window is key. // it is both individually focused and the containing window is key.
private var hasFocus: Bool { surfaceFocus && windowFocus } private var hasFocus: Bool { surfaceFocus && windowFocus }
// The opacity of the rectangle when unfocused. // The opacity of the rectangle when unfocused.
private var unfocusedOpacity: Double { private var unfocusedOpacity: Double {
var opacity: Double = 0.85 var opacity: Double = 0.85
@ -61,7 +61,7 @@ extension Ghostty {
_ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count)) _ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count))
return 1 - opacity return 1 - opacity
} }
var body: some View { var body: some View {
ZStack { ZStack {
// We use a GeometryReader to get the frame bounds so that our metal surface // We use a GeometryReader to get the frame bounds so that our metal surface
@ -73,7 +73,7 @@ extension Ghostty {
let pubBecomeFocused = NotificationCenter.default.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView) let pubBecomeFocused = NotificationCenter.default.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView)
let pubBecomeKey = NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification) let pubBecomeKey = NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)
let pubResign = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification) let pubResign = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)
Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size)
.focused($surfaceFocus) .focused($surfaceFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
@ -95,7 +95,7 @@ extension Ghostty {
// method doesn't work properly. See the dispatch of this notification // method doesn't work properly. See the dispatch of this notification
// for more information. // for more information.
if #available(macOS 13, *) { return } if #available(macOS 13, *) { return }
DispatchQueue.main.async { DispatchQueue.main.async {
surfaceFocus = true surfaceFocus = true
} }
@ -125,13 +125,13 @@ extension Ghostty {
surfaceFocus = true surfaceFocus = true
} }
} }
// I don't know how older macOS versions behave but Ghostty only // I don't know how older macOS versions behave but Ghostty only
// supports back to macOS 12 so its moot. // supports back to macOS 12 so its moot.
} }
} }
.ghosttySurfaceView(surfaceView) .ghosttySurfaceView(surfaceView)
// If we're part of a split view and don't have focus, we put a semi-transparent // If we're part of a split view and don't have focus, we put a semi-transparent
// rectangle above our view to make it look unfocused. We use "surfaceFocus" // rectangle above our view to make it look unfocused. We use "surfaceFocus"
// because we want to keep our focused surface dark even if we don't have window // because we want to keep our focused surface dark even if we don't have window
@ -145,7 +145,7 @@ extension Ghostty {
} }
} }
} }
/// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn
/// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// and interacted with. The word "surface" is used because a surface may represent a window, a tab,
/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it.
@ -156,12 +156,12 @@ extension Ghostty {
struct Surface: NSViewRepresentable { struct Surface: NSViewRepresentable {
/// The view to render for the terminal surface. /// The view to render for the terminal surface.
let view: SurfaceView let view: SurfaceView
/// This should be set to true when the surface has focus. This is up to the parent because /// This should be set to true when the surface has focus. This is up to the parent because
/// focus is also defined by window focus. It is important this is set correctly since if it is /// focus is also defined by window focus. It is important this is set correctly since if it is
/// false then the surface will idle at almost 0% CPU. /// false then the surface will idle at almost 0% CPU.
let hasFocus: Bool let hasFocus: Bool
/// The size of the frame containing this view. We use this to update the the underlying /// The size of the frame containing this view. We use this to update the the underlying
/// surface. This does not actually SET the size of our frame, this only sets the size /// surface. This does not actually SET the size of our frame, this only sets the size
/// of our Metal surface for drawing. /// of our Metal surface for drawing.
@ -171,74 +171,76 @@ extension Ghostty {
/// ///
/// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
let size: CGSize let size: CGSize
func makeNSView(context: Context) -> SurfaceView { func makeNSView(context: Context) -> SurfaceView {
// We need the view as part of the state to be created previously because // We need the view as part of the state to be created previously because
// the view is sent to the Ghostty API so that it can manipulate it // the view is sent to the Ghostty API so that it can manipulate it
// directly since we draw on a render thread. // directly since we draw on a render thread.
return view; return view;
} }
func updateNSView(_ view: SurfaceView, context: Context) { func updateNSView(_ view: SurfaceView, context: Context) {
view.focusDidChange(hasFocus) view.focusDidChange(hasFocus)
view.sizeDidChange(size) view.sizeDidChange(size)
} }
} }
/// The NSView implementation for a terminal surface. /// The NSView implementation for a terminal surface.
class SurfaceView: NSView, NSTextInputClient, ObservableObject { class SurfaceView: NSView, NSTextInputClient, ObservableObject {
// The current title of the surface as defined by the pty. This can be // The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go // changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there. // to the app level and it is set from there.
@Published var title: String = "👻" @Published var title: String = "👻"
private(set) var surface: ghostty_surface_t? private(set) var surface: ghostty_surface_t?
var error: Error? = nil var error: Error? = nil
private var markedText: NSMutableAttributedString; private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false
private var cursor: NSCursor = .arrow
// We need to support being a first responder so that we can get input events // We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true } override var acceptsFirstResponder: Bool { return true }
// I don't think we need this but this lets us know we should redraw our layer // I don't think we need this but this lets us know we should redraw our layer
// so we'll use that to tell ghostty to refresh. // so we'll use that to tell ghostty to refresh.
override var wantsUpdateLayer: Bool { return true } override var wantsUpdateLayer: Bool { return true }
init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) {
self.markedText = NSMutableAttributedString() self.markedText = NSMutableAttributedString()
// Initialize with some default frame size. The important thing is that this // Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer // is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING. // can do SOMETHING.
super.init(frame: NSMakeRect(0, 0, 800, 600)) super.init(frame: NSMakeRect(0, 0, 800, 600))
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
var surface_cfg = baseConfig ?? ghostty_surface_config_new() var surface_cfg = baseConfig ?? ghostty_surface_config_new()
surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque() surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque()
surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque() surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque()
surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor
guard let surface = ghostty_surface_new(app, &surface_cfg) else { guard let surface = ghostty_surface_new(app, &surface_cfg) else {
self.error = AppError.surfaceCreateError self.error = AppError.surfaceCreateError
return return
} }
self.surface = surface; self.surface = surface;
// Setup our tracking area so we get mouse moved events // Setup our tracking area so we get mouse moved events
updateTrackingAreas() updateTrackingAreas()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view") fatalError("init(coder:) is not supported for this view")
} }
deinit { deinit {
trackingAreas.forEach { removeTrackingArea($0) } trackingAreas.forEach { removeTrackingArea($0) }
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
ghostty_surface_free(surface) ghostty_surface_free(surface)
} }
/// Close the surface early. This will free the associated Ghostty surface and the view will /// Close the surface early. This will free the associated Ghostty surface and the view will
/// no longer render. The view can never be used again. This is a way for us to free the /// no longer render. The view can never be used again. This is a way for us to free the
/// Ghostty resources while references may still be held to this view. I've found that SwiftUI /// Ghostty resources while references may still be held to this view. I've found that SwiftUI
@ -248,15 +250,15 @@ extension Ghostty {
ghostty_surface_free(surface) ghostty_surface_free(surface)
self.surface = nil self.surface = nil
} }
func focusDidChange(_ focused: Bool) { func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
ghostty_surface_set_focus(surface, focused) ghostty_surface_set_focus(surface, focused)
} }
func sizeDidChange(_ size: CGSize) { func sizeDidChange(_ size: CGSize) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
// Ghostty wants to know the actual framebuffer size... It is very important // Ghostty wants to know the actual framebuffer size... It is very important
// here that we use "size" and NOT the view frame. If we're in the middle of // here that we use "size" and NOT the view frame. If we're in the middle of
// an animation (i.e. a fullscreen animation), the frame will not yet be updated. // an animation (i.e. a fullscreen animation), the frame will not yet be updated.
@ -264,35 +266,93 @@ extension Ghostty {
let scaledSize = self.convertToBacking(size) let scaledSize = self.convertToBacking(size)
ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height))
} }
func setCursorShape(_ shape: ghostty_mouse_shape_e) {
switch (shape) {
case GHOSTTY_MOUSE_SHAPE_DEFAULT:
cursor = .arrow
case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU:
cursor = .contextualMenu
case GHOSTTY_MOUSE_SHAPE_TEXT:
cursor = .iBeam
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
cursor = .crosshair
case GHOSTTY_MOUSE_SHAPE_GRAB:
cursor = .openHand
case GHOSTTY_MOUSE_SHAPE_GRABBING:
cursor = .closedHand
case GHOSTTY_MOUSE_SHAPE_POINTER:
cursor = .pointingHand
case GHOSTTY_MOUSE_SHAPE_W_RESIZE:
cursor = .resizeLeft
case GHOSTTY_MOUSE_SHAPE_E_RESIZE:
cursor = .resizeRight
case GHOSTTY_MOUSE_SHAPE_N_RESIZE:
cursor = .resizeUp
case GHOSTTY_MOUSE_SHAPE_S_RESIZE:
cursor = .resizeDown
case GHOSTTY_MOUSE_SHAPE_NS_RESIZE:
cursor = .resizeUpDown
case GHOSTTY_MOUSE_SHAPE_EW_RESIZE:
cursor = .resizeLeftRight
case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT:
cursor = .iBeamCursorForVerticalLayout
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
cursor = .operationNotAllowed
default:
// We ignore unknown shapes.
return
}
// Set our cursor immediately if our mouse is over our window
if (mouseEntered) {
cursor.set()
}
}
override func viewDidMoveToWindow() { override func viewDidMoveToWindow() {
guard let window = self.window else { return } guard let window = self.window else { return }
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
guard ghostty_surface_transparent(surface) else { return } guard ghostty_surface_transparent(surface) else { return }
// Set the window transparency settings // Set the window transparency settings
window.isOpaque = false window.isOpaque = false
window.hasShadow = false window.hasShadow = false
window.backgroundColor = .clear window.backgroundColor = .clear
// If we have a blur, set the blur // If we have a blur, set the blur
ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque())
} }
override func resignFirstResponder() -> Bool { override func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder() let result = super.resignFirstResponder()
// We sometimes call this manually (see SplitView) as a way to force us to // We sometimes call this manually (see SplitView) as a way to force us to
// yield our focus state. // yield our focus state.
if (result) { focusDidChange(false) } if (result) { focusDidChange(false) }
return result return result
} }
override func updateTrackingAreas() { override func updateTrackingAreas() {
// To update our tracking area we just recreate it all. // To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) } trackingAreas.forEach { removeTrackingArea($0) }
// This tracking area is across the entire frame to notify us of mouse movements. // This tracking area is across the entire frame to notify us of mouse movements.
addTrackingArea(NSTrackingArea( addTrackingArea(NSTrackingArea(
rect: frame, rect: frame,
@ -300,7 +360,7 @@ extension Ghostty {
.mouseEnteredAndExited, .mouseEnteredAndExited,
.mouseMoved, .mouseMoved,
.inVisibleRect, .inVisibleRect,
// It is possible this is incorrect when we have splits. This will make // It is possible this is incorrect when we have splits. This will make
// mouse events only happen while the terminal is focused. Is that what // mouse events only happen while the terminal is focused. Is that what
// we want? // we want?
@ -309,12 +369,12 @@ extension Ghostty {
owner: self, owner: self,
userInfo: nil)) userInfo: nil))
} }
override func resetCursorRects() { override func resetCursorRects() {
discardCursorRects() discardCursorRects()
addCursorRect(frame, cursor: .iBeam) addCursorRect(frame, cursor: .iBeam)
} }
override func viewDidChangeBackingProperties() { override func viewDidChangeBackingProperties() {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
@ -323,71 +383,79 @@ extension Ghostty {
let xScale = fbFrame.size.width / self.frame.size.width let xScale = fbFrame.size.width / self.frame.size.width
let yScale = fbFrame.size.height / self.frame.size.height let yScale = fbFrame.size.height / self.frame.size.height
ghostty_surface_set_content_scale(surface, xScale, yScale) ghostty_surface_set_content_scale(surface, xScale, yScale)
// When our scale factor changes, so does our fb size so we send that too // When our scale factor changes, so does our fb size so we send that too
ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
} }
override func updateLayer() { override func updateLayer() {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
ghostty_surface_refresh(surface); ghostty_surface_refresh(surface);
} }
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
} }
override func mouseUp(with event: NSEvent) { override func mouseUp(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(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)
} }
override func rightMouseDown(with event: NSEvent) { override func rightMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
} }
override func rightMouseUp(with event: NSEvent) { override func rightMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
} }
override func mouseMoved(with event: NSEvent) { override func mouseMoved(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
// Convert window position to view position. Note (0, 0) is bottom left. // Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil) let pos = self.convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
} }
override func mouseDragged(with event: NSEvent) { override func mouseDragged(with event: NSEvent) {
self.mouseMoved(with: event) self.mouseMoved(with: event)
} }
override func mouseEntered(with event: NSEvent) {
mouseEntered = true
}
override func mouseExited(with event: NSEvent) {
mouseEntered = false
}
override func scrollWheel(with event: NSEvent) { override func scrollWheel(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
// Builds up the "input.ScrollMods" bitmask // Builds up the "input.ScrollMods" bitmask
var mods: Int32 = 0 var mods: Int32 = 0
var x = event.scrollingDeltaX var x = event.scrollingDeltaX
var y = event.scrollingDeltaY var y = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas { if event.hasPreciseScrollingDeltas {
mods = 1 mods = 1
// We do a 2x speed multiplier. This is subjective, it "feels" better to me. // We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2; x *= 2;
y *= 2; y *= 2;
// TODO(mitchellh): do we have to scale the x/y here by window scale factor? // TODO(mitchellh): do we have to scale the x/y here by window scale factor?
} }
// Determine our momentum value // Determine our momentum value
var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE
switch (event.momentumPhase) { switch (event.momentumPhase) {
@ -406,17 +474,21 @@ extension Ghostty {
default: default:
break break
} }
// Pack our momentum value into the mods bitmask // Pack our momentum value into the mods bitmask
mods |= Int32(momentum.rawValue) << 1 mods |= Int32(momentum.rawValue) << 1
ghostty_surface_mouse_scroll(surface, x, y, mods) ghostty_surface_mouse_scroll(surface, x, y, mods)
} }
override func cursorUpdate(with event: NSEvent) {
cursor.set()
}
override func keyDown(with event: NSEvent) { override func keyDown(with event: NSEvent) {
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
keyAction(action, event: event) keyAction(action, event: event)
// We specifically DO NOT call interpretKeyEvents because ghostty_surface_key // We specifically DO NOT call interpretKeyEvents because ghostty_surface_key
// automatically handles all key translation, and we don't handle any commands // automatically handles all key translation, and we don't handle any commands
// currently. // currently.
@ -427,11 +499,11 @@ extension Ghostty {
// //
// self.interpretKeyEvents([event]) // self.interpretKeyEvents([event])
} }
override func keyUp(with event: NSEvent) { override func keyUp(with event: NSEvent) {
keyAction(GHOSTTY_ACTION_RELEASE, event: event) keyAction(GHOSTTY_ACTION_RELEASE, event: event)
} }
override func flagsChanged(with event: NSEvent) { override func flagsChanged(with event: NSEvent) {
let mod: UInt32; let mod: UInt32;
switch (event.keyCode) { switch (event.keyCode) {
@ -442,26 +514,26 @@ extension Ghostty {
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
default: return default: return
} }
// The keyAction function will do this AGAIN below which sucks to repeat // The keyAction function will do this AGAIN below which sucks to repeat
// but this is super cheap and flagsChanged isn't that common. // but this is super cheap and flagsChanged isn't that common.
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(event.modifierFlags)
// If the key that pressed this is active, its a press, else release // If the key that pressed this is active, its a press, else release
var action = GHOSTTY_ACTION_RELEASE var action = GHOSTTY_ACTION_RELEASE
if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS } if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS }
keyAction(action, event: event) keyAction(action, event: event)
} }
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let mods = Self.translateFlags(event.modifierFlags) let mods = Self.translateFlags(event.modifierFlags)
ghostty_surface_key(surface, action, UInt32(event.keyCode), mods) ghostty_surface_key(surface, action, UInt32(event.keyCode), mods)
} }
// MARK: Menu Handlers // MARK: Menu Handlers
@IBAction func copy(_ sender: Any?) { @IBAction func copy(_ sender: Any?) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let action = "copy_to_clipboard" let action = "copy_to_clipboard"
@ -469,7 +541,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)") AppDelegate.logger.warning("action failed action=\(action)")
} }
} }
@IBAction func paste(_ sender: Any?) { @IBAction func paste(_ sender: Any?) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let action = "paste_from_clipboard" let action = "paste_from_clipboard"
@ -477,7 +549,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)") AppDelegate.logger.warning("action failed action=\(action)")
} }
} }
@IBAction func pasteAsPlainText(_ sender: Any?) { @IBAction func pasteAsPlainText(_ sender: Any?) {
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
let action = "paste_from_clipboard" let action = "paste_from_clipboard"
@ -485,75 +557,75 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)") AppDelegate.logger.warning("action failed action=\(action)")
} }
} }
// MARK: NSTextInputClient // MARK: NSTextInputClient
func hasMarkedText() -> Bool { func hasMarkedText() -> Bool {
return markedText.length > 0 return markedText.length > 0
} }
func markedRange() -> NSRange { func markedRange() -> NSRange {
guard markedText.length > 0 else { return NSRange() } guard markedText.length > 0 else { return NSRange() }
return NSRange(0...(markedText.length-1)) return NSRange(0...(markedText.length-1))
} }
func selectedRange() -> NSRange { func selectedRange() -> NSRange {
return NSRange() return NSRange()
} }
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
switch string { switch string {
case let v as NSAttributedString: case let v as NSAttributedString:
self.markedText = NSMutableAttributedString(attributedString: v) self.markedText = NSMutableAttributedString(attributedString: v)
case let v as String: case let v as String:
self.markedText = NSMutableAttributedString(string: v) self.markedText = NSMutableAttributedString(string: v)
default: default:
print("unknown marked text: \(string)") print("unknown marked text: \(string)")
} }
} }
func unmarkText() { func unmarkText() {
self.markedText.mutableString.setString("") self.markedText.mutableString.setString("")
} }
func validAttributesForMarkedText() -> [NSAttributedString.Key] { func validAttributesForMarkedText() -> [NSAttributedString.Key] {
return [] return []
} }
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil return nil
} }
func characterIndex(for point: NSPoint) -> Int { func characterIndex(for point: NSPoint) -> Int {
return 0 return 0
} }
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
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) 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
let rect = NSMakeRect(x, frame.size.height - y, 0, 0) let rect = NSMakeRect(x, frame.size.height - y, 0, 0)
// Convert from view to screen coordinates // Convert from view to screen coordinates
guard let window = self.window else { return rect } guard let window = self.window else { return rect }
return window.convertToScreen(rect) return window.convertToScreen(rect)
} }
func insertText(_ string: Any, replacementRange: NSRange) { func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event // We must have an associated event
guard NSApp.currentEvent != nil else { return } guard NSApp.currentEvent != nil else { return }
guard let surface = self.surface else { return } guard let surface = self.surface else { return }
// We want the string view of the any value // We want the string view of the any value
var chars = "" var chars = ""
switch (string) { switch (string) {
@ -564,39 +636,39 @@ extension Ghostty {
default: default:
return return
} }
for codepoint in chars.unicodeScalars { for codepoint in chars.unicodeScalars {
ghostty_surface_char(surface, codepoint.value) ghostty_surface_char(surface, codepoint.value)
} }
} }
override func doCommand(by selector: Selector) { override func doCommand(by selector: Selector) {
// This currently just prevents NSBeep from interpretKeyEvents but in the future // This currently just prevents NSBeep from interpretKeyEvents but in the future
// we may want to make some of this work. // we may want to make some of this work.
print("SEL: \(selector)") print("SEL: \(selector)")
} }
private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }
if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
// Handle sided input. We can't tell that both are pressed in the // Handle sided input. We can't tell that both are pressed in the
// Ghostty structure but thats okay -- we don't use that information. // Ghostty structure but thats okay -- we don't use that information.
let rawFlags = flags.rawValue let rawFlags = flags.rawValue
if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }
if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }
return ghostty_input_mods_e(mods) return ghostty_input_mods_e(mods)
} }
// Mapping of event keyCode to ghostty input key values. This is cribbed from // Mapping of event keyCode to ghostty input key values. This is cribbed from
// glfw mostly since we started as a glfw-based app way back in the day! // glfw mostly since we started as a glfw-based app way back in the day!
static let keycodes: [UInt16 : ghostty_input_key_e] = [ static let keycodes: [UInt16 : ghostty_input_key_e] = [
@ -742,7 +814,7 @@ extension FocusedValues {
get { self[FocusedGhosttySurface.self] } get { self[FocusedGhosttySurface.self] }
set { self[FocusedGhosttySurface.self] = newValue } set { self[FocusedGhosttySurface.self] = newValue }
} }
struct FocusedGhosttySurface: FocusedValueKey { struct FocusedGhosttySurface: FocusedValueKey {
typealias Value = Ghostty.SurfaceView typealias Value = Ghostty.SurfaceView
} }
@ -753,7 +825,7 @@ extension FocusedValues {
get { self[FocusedGhosttySurfaceTitle.self] } get { self[FocusedGhosttySurfaceTitle.self] }
set { self[FocusedGhosttySurfaceTitle.self] = newValue } set { self[FocusedGhosttySurfaceTitle.self] = newValue }
} }
struct FocusedGhosttySurfaceTitle: FocusedValueKey { struct FocusedGhosttySurfaceTitle: FocusedValueKey {
typealias Value = String typealias Value = String
} }
@ -764,7 +836,7 @@ extension FocusedValues {
get { self[FocusedGhosttySurfaceZoomed.self] } get { self[FocusedGhosttySurfaceZoomed.self] }
set { self[FocusedGhosttySurfaceZoomed.self] = newValue } set { self[FocusedGhosttySurfaceZoomed.self] = newValue }
} }
struct FocusedGhosttySurfaceZoomed: FocusedValueKey { struct FocusedGhosttySurfaceZoomed: FocusedValueKey {
typealias Value = Bool typealias Value = Bool
} }

View File

@ -525,6 +525,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
try self.rt_surface.setTitle(slice); try self.rt_surface.setTitle(slice);
}, },
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
try self.rt_surface.setMouseShape(shape);
},
.cell_size => |size| try self.setCellSize(size), .cell_size => |size| try self.setCellSize(size),
.clipboard_read => |kind| try self.clipboardRead(kind), .clipboard_read => |kind| try self.clipboardRead(kind),

View File

@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator;
const objc = @import("objc"); const objc = @import("objc");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const input = @import("../input.zig"); const input = @import("../input.zig");
const terminal = @import("../terminal/main.zig");
const CoreApp = @import("../App.zig"); const CoreApp = @import("../App.zig");
const CoreSurface = @import("../Surface.zig"); const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -48,6 +49,9 @@ pub const App = struct {
/// Called to set the title of the window. /// Called to set the title of the window.
set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void,
/// Called to set the cursor shape.
set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void,
/// Read the clipboard value. The return value must be preserved /// Read the clipboard value. The return value must be preserved
/// by the host until the next call. If there is no valid clipboard /// by the host until the next call. If there is no valid clipboard
/// value then this should return null. /// value then this should return null.
@ -310,6 +314,13 @@ pub const Surface = struct {
); );
} }
pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
self.app.opts.set_mouse_shape(
self.opts.userdata,
shape,
);
}
pub fn supportsClipboard( pub fn supportsClipboard(
self: *const Surface, self: *const Surface,
clipboard_type: apprt.Clipboard, clipboard_type: apprt.Clipboard,

View File

@ -15,6 +15,7 @@ const objc = @import("objc");
const input = @import("../input.zig"); const input = @import("../input.zig");
const internal_os = @import("../os/main.zig"); const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const Renderer = renderer.Renderer; const Renderer = renderer.Renderer;
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const CoreApp = @import("../App.zig"); const CoreApp = @import("../App.zig");
@ -275,7 +276,7 @@ pub const Surface = struct {
window: glfw.Window, window: glfw.Window,
/// The glfw mouse cursor handle. /// The glfw mouse cursor handle.
cursor: glfw.Cursor, cursor: ?glfw.Cursor,
/// The app we're part of /// The app we're part of
app: *App, app: *App,
@ -335,16 +336,6 @@ pub const Surface = struct {
nswindow.setProperty("tabbingIdentifier", app.darwin.tabbing_id); nswindow.setProperty("tabbingIdentifier", app.darwin.tabbing_id);
} }
// Create the cursor
const cursor = glfw.Cursor.createStandard(.ibeam) orelse return glfw.mustGetErrorCode();
errdefer cursor.destroy();
if ((comptime !builtin.target.isDarwin()) or internal_os.macosVersionAtLeast(13, 0, 0)) {
// We only set our cursor if we're NOT on Mac, or if we are then the
// macOS version is >= 13 (Ventura). On prior versions, glfw crashes
// since we use a tab group.
win.setCursor(cursor);
}
// Set our callbacks // Set our callbacks
win.setUserPointer(&self.core_surface); win.setUserPointer(&self.core_surface);
win.setSizeCallback(sizeCallback); win.setSizeCallback(sizeCallback);
@ -360,11 +351,14 @@ pub const Surface = struct {
self.* = .{ self.* = .{
.app = app, .app = app,
.window = win, .window = win,
.cursor = cursor, .cursor = null,
.core_surface = undefined, .core_surface = undefined,
}; };
errdefer self.* = undefined; errdefer self.* = undefined;
// Initialize our cursor
try self.setMouseShape(.text);
// Add ourselves to the list of surfaces on the app. // Add ourselves to the list of surfaces on the app.
try app.app.addSurface(self); try app.app.addSurface(self);
errdefer app.app.deleteSurface(self); errdefer app.app.deleteSurface(self);
@ -425,7 +419,10 @@ pub const Surface = struct {
// We can now safely destroy our windows. We have to do this BEFORE // We can now safely destroy our windows. We have to do this BEFORE
// setting up the new focused window below. // setting up the new focused window below.
self.window.destroy(); self.window.destroy();
self.cursor.destroy(); if (self.cursor) |c| {
c.destroy();
self.cursor = null;
}
// If we have a tabgroup set, we want to manually focus the next window. // If we have a tabgroup set, we want to manually focus the next window.
// We should NOT have to do this usually, see the comments above. // We should NOT have to do this usually, see the comments above.
@ -508,6 +505,43 @@ pub const Surface = struct {
self.window.setTitle(slice.ptr); self.window.setTitle(slice.ptr);
} }
/// Set the shape of the cursor.
pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
if ((comptime builtin.target.isDarwin()) and
!internal_os.macosVersionAtLeast(13, 0, 0))
{
// We only set our cursor if we're NOT on Mac, or if we are then the
// macOS version is >= 13 (Ventura). On prior versions, glfw crashes
// since we use a tab group.
return;
}
const new = glfw.Cursor.createStandard(switch (shape) {
.default => .arrow,
.text => .ibeam,
.crosshair => .crosshair,
.pointer => .pointing_hand,
.ew_resize => .resize_ew,
.ns_resize => .resize_ns,
.nwse_resize => .resize_nwse,
.nesw_resize => .resize_nesw,
.all_scroll => .resize_all,
.not_allowed => .not_allowed,
else => return, // unsupported, ignore
}) orelse {
const err = glfw.mustGetErrorCode();
log.warn("error creating cursor: {}", .{err});
return;
};
errdefer new.destroy();
// Set our cursor before we destroy the old one
self.window.setCursor(new);
if (self.cursor) |c| c.destroy();
self.cursor = new;
}
/// Read the clipboard. The windowing system is responsible for allocating /// Read the clipboard. The windowing system is responsible for allocating
/// a buffer as necessary. This should be a stable pointer until the next /// a buffer as necessary. This should be a stable pointer until the next
/// time getClipboardString is called. /// time getClipboardString is called.

View File

@ -8,6 +8,7 @@ const glfw = @import("glfw");
const apprt = @import("../apprt.zig"); const apprt = @import("../apprt.zig");
const font = @import("../font/main.zig"); const font = @import("../font/main.zig");
const input = @import("../input.zig"); const input = @import("../input.zig");
const terminal = @import("../terminal/main.zig");
const CoreApp = @import("../App.zig"); const CoreApp = @import("../App.zig");
const CoreSurface = @import("../Surface.zig"); const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig"); const configpkg = @import("../config.zig");
@ -44,9 +45,6 @@ pub const App = struct {
app: *c.GtkApplication, app: *c.GtkApplication,
ctx: *c.GMainContext, ctx: *c.GMainContext,
cursor_default: *c.GdkCursor,
cursor_ibeam: *c.GdkCursor,
/// This is set to false when the main loop should exit. /// This is set to false when the main loop should exit.
running: bool = true, running: bool = true,
@ -125,19 +123,11 @@ pub const App = struct {
// https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302
c.g_application_activate(gapp); c.g_application_activate(gapp);
// Get our cursors
const cursor_default = c.gdk_cursor_new_from_name("default", null).?;
errdefer c.g_object_unref(cursor_default);
const cursor_ibeam = c.gdk_cursor_new_from_name("text", cursor_default).?;
errdefer c.g_object_unref(cursor_ibeam);
return .{ return .{
.core_app = core_app, .core_app = core_app,
.app = app, .app = app,
.config = config, .config = config,
.ctx = ctx, .ctx = ctx,
.cursor_default = cursor_default,
.cursor_ibeam = cursor_ibeam,
// If we are NOT the primary instance, then we never want to run. // If we are NOT the primary instance, then we never want to run.
// This means that another instance of the GTK app is running and // This means that another instance of the GTK app is running and
@ -154,9 +144,6 @@ pub const App = struct {
c.g_main_context_release(self.ctx); c.g_main_context_release(self.ctx);
c.g_object_unref(self.app); c.g_object_unref(self.app);
c.g_object_unref(self.cursor_ibeam);
c.g_object_unref(self.cursor_default);
self.config.deinit(); self.config.deinit();
glfw.terminate(); glfw.terminate();
@ -722,6 +709,9 @@ pub const Surface = struct {
/// Our GTK area /// Our GTK area
gl_area: *c.GtkGLArea, gl_area: *c.GtkGLArea,
/// Any active cursor we may have
cursor: ?*c.GdkCursor = null,
/// Our title label (if there is one). /// Our title label (if there is one).
title: Title, title: Title,
@ -798,9 +788,6 @@ pub const Surface = struct {
c.gtk_widget_set_focusable(widget, 1); c.gtk_widget_set_focusable(widget, 1);
c.gtk_widget_set_focus_on_click(widget, 1); c.gtk_widget_set_focus_on_click(widget, 1);
// When we're over the widget, set the cursor to the ibeam
c.gtk_widget_set_cursor(widget, app.cursor_ibeam);
// Build our result // Build our result
self.* = .{ self.* = .{
.app = app, .app = app,
@ -880,6 +867,8 @@ pub const Surface = struct {
// Free all our GTK stuff // Free all our GTK stuff
c.g_object_unref(self.im_context); c.g_object_unref(self.im_context);
c.g_value_unset(&self.clipboard); c.g_value_unset(&self.clipboard);
if (self.cursor) |cursor| c.g_object_unref(cursor);
} }
fn render(self: *Surface) !void { fn render(self: *Surface) !void {
@ -998,6 +987,61 @@ pub const Surface = struct {
// )); // ));
} }
pub fn setMouseShape(
self: *Surface,
shape: terminal.MouseShape,
) !void {
const name: [:0]const u8 = switch (shape) {
.default => "default",
.help => "help",
.pointer => "pointer",
.context_menu => "context-menu",
.progress => "progress",
.wait => "wait",
.cell => "cell",
.crosshair => "crosshair",
.text => "text",
.vertical_text => "vertical-text",
.alias => "alias",
.copy => "copy",
.no_drop => "no-drop",
.move => "move",
.not_allowed => "not-allowed",
.grab => "grab",
.grabbing => "grabbing",
.all_scroll => "all-scroll",
.col_resize => "col-resize",
.row_resize => "row-resize",
.n_resize => "n-resize",
.e_resize => "e-resize",
.s_resize => "s-resize",
.w_resize => "w-resize",
.ne_resize => "ne-resize",
.nw_resize => "nw-resize",
.se_resize => "se-resize",
.sw_resize => "sw-resize",
.ew_resize => "ew-resize",
.ns_resize => "ns-resize",
.nesw_resize => "nesw-resize",
.nwse_resize => "nwse-resize",
.zoom_in => "zoom-in",
.zoom_out => "zoom-out",
};
const cursor = c.gdk_cursor_new_from_name(name.ptr, null) orelse {
log.warn("unsupported cursor name={s}", .{name});
return;
};
errdefer c.g_object_unref(cursor);
// Set our new cursor
c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor);
// Free our existing cursor
if (self.cursor) |old| c.g_object_unref(old);
self.cursor = cursor;
}
pub fn getClipboardString( pub fn getClipboardString(
self: *Surface, self: *Surface,
clipboard_type: apprt.Clipboard, clipboard_type: apprt.Clipboard,

View File

@ -2,6 +2,7 @@ const App = @import("../App.zig");
const Surface = @import("../Surface.zig"); const Surface = @import("../Surface.zig");
const renderer = @import("../renderer.zig"); const renderer = @import("../renderer.zig");
const termio = @import("../termio.zig"); const termio = @import("../termio.zig");
const terminal = @import("../terminal/main.zig");
const Config = @import("../config.zig").Config; const Config = @import("../config.zig").Config;
/// The message types that can be sent to a single surface. /// The message types that can be sent to a single surface.
@ -16,6 +17,9 @@ pub const Message = union(enum) {
/// of any length /// of any length
set_title: [256]u8, set_title: [256]u8,
/// Set the mouse shape.
set_mouse_shape: terminal.MouseShape,
/// Change the cell size. /// Change the cell size.
cell_size: renderer.CellSize, cell_size: renderer.CellSize,

View File

@ -15,6 +15,7 @@ pub const parse_table = @import("parse_table.zig");
pub const Charset = charsets.Charset; pub const Charset = charsets.Charset;
pub const CharsetSlot = charsets.Slots; pub const CharsetSlot = charsets.Slots;
pub const CharsetActiveSlot = charsets.ActiveSlot; pub const CharsetActiveSlot = charsets.ActiveSlot;
pub const MouseShape = @import("mouse_shape.zig").MouseShape;
pub const Terminal = @import("Terminal.zig"); pub const Terminal = @import("Terminal.zig");
pub const Parser = @import("Parser.zig"); pub const Parser = @import("Parser.zig");
pub const Selection = @import("Selection.zig"); pub const Selection = @import("Selection.zig");

View File

@ -0,0 +1,115 @@
const std = @import("std");
/// The possible cursor shapes. Not all app runtimes support these shapes.
/// The shapes are always based on the W3C supported cursor styles so we
/// can have a cross platform list.
//
// Must be kept in sync with ghostty_cursor_shape_e
pub const MouseShape = enum(c_int) {
default,
context_menu,
help,
pointer,
progress,
wait,
cell,
crosshair,
text,
vertical_text,
alias,
copy,
move,
no_drop,
not_allowed,
grab,
grabbing,
all_scroll,
col_resize,
row_resize,
n_resize,
e_resize,
s_resize,
w_resize,
ne_resize,
nw_resize,
se_resize,
sw_resize,
ew_resize,
ns_resize,
nesw_resize,
nwse_resize,
zoom_in,
zoom_out,
/// Build cursor shape from string or null if its unknown.
pub fn fromString(v: []const u8) ?MouseShape {
return string_map.get(v);
}
};
const string_map = std.ComptimeStringMap(MouseShape, .{
// W3C
.{ "default", .default },
.{ "context-menu", .context_menu },
.{ "help", .help },
.{ "pointer", .pointer },
.{ "progress", .progress },
.{ "wait", .wait },
.{ "cell", .cell },
.{ "crosshair", .crosshair },
.{ "text", .text },
.{ "vertical-text", .vertical_text },
.{ "alias", .alias },
.{ "copy", .copy },
.{ "move", .move },
.{ "no-drop", .no_drop },
.{ "not-allowed", .not_allowed },
.{ "grab", .grab },
.{ "grabbing", .grabbing },
.{ "all-scroll", .all_scroll },
.{ "col-resize", .col_resize },
.{ "row-resize", .row_resize },
.{ "n-resize", .n_resize },
.{ "e-resize", .e_resize },
.{ "s-resize", .s_resize },
.{ "w-resize", .w_resize },
.{ "ne-resize", .ne_resize },
.{ "nw-resize", .nw_resize },
.{ "se-resize", .se_resize },
.{ "sw-resize", .sw_resize },
.{ "ew-resize", .ew_resize },
.{ "ns-resize", .ns_resize },
.{ "nesw-resize", .nesw_resize },
.{ "nwse-resize", .nwse_resize },
.{ "zoom-in", .zoom_in },
.{ "zoom-out", .zoom_out },
// xterm/foot
.{ "left_ptr", .default },
.{ "question_arrow", .help },
.{ "hand", .pointer },
.{ "left_ptr_watch", .progress },
.{ "watch", .wait },
.{ "cross", .crosshair },
.{ "xterm", .text },
.{ "dnd-link", .alias },
.{ "dnd-copy", .copy },
.{ "dnd-move", .move },
.{ "dnd-no-drop", .no_drop },
.{ "crossed_circle", .not_allowed },
.{ "hand1", .grab },
.{ "right_side", .e_resize },
.{ "top_side", .n_resize },
.{ "top_right_corner", .ne_resize },
.{ "top_left_corner", .nw_resize },
.{ "bottom_side", .s_resize },
.{ "bottom_right_corner", .se_resize },
.{ "bottom_left_corner", .sw_resize },
.{ "left_side", .w_resize },
.{ "fleur", .all_scroll },
});
test "cursor shape from string" {
const testing = std.testing;
try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?);
}

View File

@ -83,6 +83,14 @@ pub const Command = union(enum) {
/// be a file URL but it is up to the caller to utilize this value. /// be a file URL but it is up to the caller to utilize this value.
value: []const u8, value: []const u8,
}, },
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
/// naming scheme for cursors but it looks like terminals such as Foot
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
/// we just parse whatever string is given.
mouse_shape: struct {
value: []const u8,
},
}; };
pub const Parser = struct { pub const Parser = struct {
@ -130,6 +138,7 @@ pub const Parser = struct {
@"13", @"13",
@"133", @"133",
@"2", @"2",
@"22",
@"5", @"5",
@"52", @"52",
@"7", @"7",
@ -226,6 +235,7 @@ pub const Parser = struct {
}, },
.@"2" => switch (c) { .@"2" => switch (c) {
'2' => self.state = .@"22",
';' => { ';' => {
self.command = .{ .change_window_title = undefined }; self.command = .{ .change_window_title = undefined };
@ -236,6 +246,17 @@ pub const Parser = struct {
else => self.state = .invalid, else => self.state = .invalid,
}, },
.@"22" => switch (c) {
';' => {
self.command = .{ .mouse_shape = undefined };
self.state = .string;
self.temp_state = .{ .str = &self.command.mouse_shape.value };
self.buf_start = self.buf_idx;
},
else => self.state = .invalid,
},
.@"5" => switch (c) { .@"5" => switch (c) {
'2' => self.state = .@"52", '2' => self.state = .@"52",
else => self.state = .invalid, else => self.state = .invalid,
@ -642,6 +663,19 @@ test "OSC: report pwd" {
try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value));
} }
test "OSC: pointer cursor" {
const testing = std.testing;
var p: Parser = .{};
const input = "22;pointer";
for (input) |ch| p.next(ch);
const cmd = p.end().?;
try testing.expect(cmd == .mouse_shape);
try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value));
}
test "OSC: report pwd empty" { test "OSC: report pwd empty" {
const testing = std.testing; const testing = std.testing;

View File

@ -9,6 +9,7 @@ const modes = @import("modes.zig");
const osc = @import("osc.zig"); const osc = @import("osc.zig");
const sgr = @import("sgr.zig"); const sgr = @import("sgr.zig");
const trace = @import("tracy").trace; const trace = @import("tracy").trace;
const MouseShape = @import("mouse_shape.zig").MouseShape;
const log = std.log.scoped(.stream); const log = std.log.scoped(.stream);
@ -849,6 +850,17 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd}); } else log.warn("unimplemented OSC callback: {}", .{cmd});
}, },
.mouse_shape => |v| {
if (@hasDecl(T, "setMouseShape")) {
const shape = MouseShape.fromString(v.value) orelse {
log.warn("unknown cursor shape: {s}", .{v.value});
return;
};
try self.handler.setMouseShape(shape);
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
else => if (@hasDecl(T, "oscUnimplemented")) else => if (@hasDecl(T, "oscUnimplemented"))
try self.handler.oscUnimplemented(cmd) try self.handler.oscUnimplemented(cmd)
else else

View File

@ -1682,6 +1682,15 @@ const StreamHandler = struct {
}, .{ .forever = {} }); }, .{ .forever = {} });
} }
pub fn setMouseShape(
self: *StreamHandler,
shape: terminal.MouseShape,
) !void {
_ = self.ev.surface_mailbox.push(.{
.set_mouse_shape = shape,
}, .{ .forever = {} });
}
pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void {
// Note: we ignore the "kind" field and always use the standard clipboard. // Note: we ignore the "kind" field and always use the standard clipboard.
// iTerm also appears to do this but other terminals seem to only allow // iTerm also appears to do this but other terminals seem to only allow