From ab8569b4bd2391fe696acd9f8daffbd656efce6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 09:36:23 -0700 Subject: [PATCH 1/8] terminal: parse OSC 22 --- src/terminal/osc.zig | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 990962261..c4af80cf1 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -83,6 +83,14 @@ pub const Command = union(enum) { /// be a file URL but it is up to the caller to utilize this value. value: []const u8, }, + + /// OSC 22. Set the cursor 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. + pointer_cursor: struct { + value: []const u8, + }, }; pub const Parser = struct { @@ -130,6 +138,7 @@ pub const Parser = struct { @"13", @"133", @"2", + @"22", @"5", @"52", @"7", @@ -226,6 +235,7 @@ pub const Parser = struct { }, .@"2" => switch (c) { + '2' => self.state = .@"22", ';' => { self.command = .{ .change_window_title = undefined }; @@ -236,6 +246,17 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"22" => switch (c) { + ';' => { + self.command = .{ .pointer_cursor = undefined }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.pointer_cursor.value }; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + .@"5" => switch (c) { '2' => self.state = .@"52", 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)); } +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 == .pointer_cursor); + try testing.expect(std.mem.eql(u8, "pointer", cmd.pointer_cursor.value)); +} + test "OSC: report pwd empty" { const testing = std.testing; From 7734bab8c4ddf046fc84c4a0f076281ed893011c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 10:12:38 -0700 Subject: [PATCH 2/8] terminal: cursor shape parsing, hook up to apprt callback --- src/Surface.zig | 5 ++ src/apprt/glfw.zig | 7 +++ src/apprt/surface.zig | 4 ++ src/terminal/cursor_shape.zig | 113 ++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + src/terminal/stream.zig | 12 ++++ src/termio/Exec.zig | 9 +++ 7 files changed, 151 insertions(+) create mode 100644 src/terminal/cursor_shape.zig diff --git a/src/Surface.zig b/src/Surface.zig index ba657181f..3e957844b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -525,6 +525,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { try self.rt_surface.setTitle(slice); }, + .set_cursor_shape => |shape| { + log.debug("changing cursor shape: {}", .{shape}); + try self.rt_surface.setCursorShape(shape); + }, + .cell_size => |size| try self.setCellSize(size), .clipboard_read => |kind| try self.clipboardRead(kind), diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 47b37ec9a..36bafb54b 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -15,6 +15,7 @@ const objc = @import("objc"); const input = @import("../input.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); const Renderer = renderer.Renderer; const apprt = @import("../apprt.zig"); const CoreApp = @import("../App.zig"); @@ -508,6 +509,12 @@ pub const Surface = struct { self.window.setTitle(slice.ptr); } + /// Set the shape of the cursor. + pub fn setCursorShape(self: *Surface, shape: terminal.CursorShape) !void { + _ = self; + _ = shape; + } + /// Read the clipboard. The windowing system is responsible for allocating /// a buffer as necessary. This should be a stable pointer until the next /// time getClipboardString is called. diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 67b4247e8..a0ba1a54d 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -2,6 +2,7 @@ const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); const termio = @import("../termio.zig"); +const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; /// The message types that can be sent to a single surface. @@ -16,6 +17,9 @@ pub const Message = union(enum) { /// of any length set_title: [256]u8, + /// Set the cursor shape. + set_cursor_shape: terminal.CursorShape, + /// Change the cell size. cell_size: renderer.CellSize, diff --git a/src/terminal/cursor_shape.zig b/src/terminal/cursor_shape.zig new file mode 100644 index 000000000..021ee74d4 --- /dev/null +++ b/src/terminal/cursor_shape.zig @@ -0,0 +1,113 @@ +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. +pub const CursorShape = 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) ?CursorShape { + return string_map.get(v); + } +}; + +const string_map = std.ComptimeStringMap(CursorShape, .{ + // 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(CursorShape.default, CursorShape.fromString("default").?); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index e1a6ee439..32b7d6898 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,6 +15,7 @@ pub const parse_table = @import("parse_table.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const CursorShape = @import("cursor_shape.zig").CursorShape; pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 04d2b9aef..a7c4834da 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -9,6 +9,7 @@ const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); const trace = @import("tracy").trace; +const CursorShape = @import("cursor_shape.zig").CursorShape; const log = std.log.scoped(.stream); @@ -849,6 +850,17 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .pointer_cursor => |v| { + if (@hasDecl(T, "setCursorShape")) { + const shape = CursorShape.fromString(v.value) orelse { + log.warn("unknown cursor shape: {s}", .{v.value}); + return; + }; + + try self.handler.setCursorShape(shape); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aa4627f35..4365c3a6a 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1682,6 +1682,15 @@ const StreamHandler = struct { }, .{ .forever = {} }); } + pub fn setCursorShape( + self: *StreamHandler, + shape: terminal.CursorShape, + ) !void { + _ = self.ev.surface_mailbox.push(.{ + .set_cursor_shape = shape, + }, .{ .forever = {} }); + } + pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { // 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 From 31a61613e9c40c5e37850f1d4fdff295fcb64707 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 10:23:11 -0700 Subject: [PATCH 3/8] apprt/glfw: support setting cursor shape --- src/apprt/glfw.zig | 57 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 36bafb54b..a56868924 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -276,7 +276,7 @@ pub const Surface = struct { window: glfw.Window, /// The glfw mouse cursor handle. - cursor: glfw.Cursor, + cursor: ?glfw.Cursor, /// The app we're part of app: *App, @@ -336,16 +336,6 @@ pub const Surface = struct { 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 win.setUserPointer(&self.core_surface); win.setSizeCallback(sizeCallback); @@ -361,11 +351,14 @@ pub const Surface = struct { self.* = .{ .app = app, .window = win, - .cursor = cursor, + .cursor = null, .core_surface = undefined, }; errdefer self.* = undefined; + // Initialize our cursor + try self.setCursorShape(.text); + // Add ourselves to the list of surfaces on the app. try app.app.addSurface(self); errdefer app.app.deleteSurface(self); @@ -426,7 +419,10 @@ pub const Surface = struct { // We can now safely destroy our windows. We have to do this BEFORE // setting up the new focused window below. 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. // We should NOT have to do this usually, see the comments above. @@ -511,8 +507,39 @@ pub const Surface = struct { /// Set the shape of the cursor. pub fn setCursorShape(self: *Surface, shape: terminal.CursorShape) !void { - _ = self; - _ = shape; + 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 From 3356146bb444bf716d8496d7dd88f1ffd42dbba0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 10:40:40 -0700 Subject: [PATCH 4/8] macos: support cursor style --- include/ghostty.h | 39 ++++ macos/Sources/Ghostty/AppState.swift | 6 + macos/Sources/Ghostty/SurfaceView.swift | 286 +++++++++++++++--------- src/apprt/embedded.zig | 11 + src/terminal/cursor_shape.zig | 2 + 5 files changed, 237 insertions(+), 107 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index eab70bac2..5c5ba7d59 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -70,6 +70,43 @@ typedef enum { GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, } ghostty_input_mouse_momentum_e; +typedef enum { + GHOSTTY_CURSOR_SHAPE_DEFAULT, + GHOSTTY_CURSOR_SHAPE_CONTEXT_MENU, + GHOSTTY_CURSOR_SHAPE_HELP, + GHOSTTY_CURSOR_SHAPE_POINTER, + GHOSTTY_CURSOR_SHAPE_PROGRESS, + GHOSTTY_CURSOR_SHAPE_WAIT, + GHOSTTY_CURSOR_SHAPE_CELL, + GHOSTTY_CURSOR_SHAPE_CROSSHAIR, + GHOSTTY_CURSOR_SHAPE_TEXT, + GHOSTTY_CURSOR_SHAPE_VERTICAL_TEXT, + GHOSTTY_CURSOR_SHAPE_ALIAS, + GHOSTTY_CURSOR_SHAPE_COPY, + GHOSTTY_CURSOR_SHAPE_MOVE, + GHOSTTY_CURSOR_SHAPE_NO_DROP, + GHOSTTY_CURSOR_SHAPE_NOT_ALLOWED, + GHOSTTY_CURSOR_SHAPE_GRAB, + GHOSTTY_CURSOR_SHAPE_GRABBING, + GHOSTTY_CURSOR_SHAPE_ALL_SCROLL, + GHOSTTY_CURSOR_SHAPE_COL_RESIZE, + GHOSTTY_CURSOR_SHAPE_ROW_RESIZE, + GHOSTTY_CURSOR_SHAPE_N_RESIZE, + GHOSTTY_CURSOR_SHAPE_E_RESIZE, + GHOSTTY_CURSOR_SHAPE_S_RESIZE, + GHOSTTY_CURSOR_SHAPE_W_RESIZE, + GHOSTTY_CURSOR_SHAPE_NE_RESIZE, + GHOSTTY_CURSOR_SHAPE_NW_RESIZE, + GHOSTTY_CURSOR_SHAPE_SE_RESIZE, + GHOSTTY_CURSOR_SHAPE_SW_RESIZE, + GHOSTTY_CURSOR_SHAPE_EW_RESIZE, + GHOSTTY_CURSOR_SHAPE_NS_RESIZE, + GHOSTTY_CURSOR_SHAPE_NESW_RESIZE, + GHOSTTY_CURSOR_SHAPE_NWSE_RESIZE, + GHOSTTY_CURSOR_SHAPE_ZOOM_IN, + GHOSTTY_CURSOR_SHAPE_ZOOM_OUT, +} ghostty_cursor_shape_e; + typedef enum { GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE, GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE, @@ -264,6 +301,7 @@ typedef struct { typedef void (*ghostty_runtime_wakeup_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_cursor_shape_cb)(void *, ghostty_cursor_shape_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_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_reload_config_cb reload_config_cb; ghostty_runtime_set_title_cb set_title_cb; + ghostty_runtime_set_cursor_shape_cb set_cursor_shape_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_new_split_cb new_split_cb; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index b6dec9dec..fb043991f 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -72,6 +72,7 @@ extension Ghostty { wakeup_cb: { userdata in AppState.wakeup(userdata) }, reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, + set_cursor_shape_cb: { userdata, shape in AppState.setCursorShape(userdata, shape: shape) }, 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) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, @@ -332,6 +333,11 @@ extension Ghostty { surfaceView.title = titleStr } } + + static func setCursorShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_cursor_shape_e) { + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + surfaceView.setCursorShape(shape) + } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { guard let surface = self.surfaceUserdata(from: userdata) else { return } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ae7f73652..48d932b06 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -6,7 +6,7 @@ extension Ghostty { struct Terminal: View { @Environment(\.ghosttyApp) private var app @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - + var body: some View { if let app = self.app { 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. struct SurfaceForApp: View { let content: ((SurfaceView) -> Content) - + @StateObject private var surfaceView: SurfaceView - + init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { _surfaceView = StateObject(wrappedValue: SurfaceView(app, nil)) self.content = content } - + var body: some View { content(surfaceView) } } - + struct SurfaceWrapper: View { // 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. @ObservedObject var surfaceView: SurfaceView - + // 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. var isSplit: Bool = false - + // Maintain whether our view has focus or not @FocusState private var surfaceFocus: Bool - + // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true - + @Environment(\.ghosttyConfig) private var ghostty_config - + // 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. private var hasFocus: Bool { surfaceFocus && windowFocus } - + // The opacity of the rectangle when unfocused. private var unfocusedOpacity: Double { var opacity: Double = 0.85 @@ -61,7 +61,7 @@ extension Ghostty { _ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count)) return 1 - opacity } - + var body: some View { ZStack { // 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 pubBecomeKey = NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification) let pubResign = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification) - + Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) @@ -95,7 +95,7 @@ extension Ghostty { // method doesn't work properly. See the dispatch of this notification // for more information. if #available(macOS 13, *) { return } - + DispatchQueue.main.async { surfaceFocus = true } @@ -125,13 +125,13 @@ extension Ghostty { surfaceFocus = true } } - + // I don't know how older macOS versions behave but Ghostty only // supports back to macOS 12 so its moot. } } .ghosttySurfaceView(surfaceView) - + // 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" // 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 /// 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. @@ -156,12 +156,12 @@ extension Ghostty { struct Surface: NSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView - + /// 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 /// false then the surface will idle at almost 0% CPU. let hasFocus: Bool - + /// 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 /// 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. let size: CGSize - + func makeNSView(context: Context) -> SurfaceView { // 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 // directly since we draw on a render thread. return view; } - + func updateNSView(_ view: SurfaceView, context: Context) { view.focusDidChange(hasFocus) view.sizeDidChange(size) } } - + /// The NSView implementation for a terminal surface. class SurfaceView: NSView, NSTextInputClient, ObservableObject { // 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 // to the app level and it is set from there. @Published var title: String = "👻" - + private(set) var surface: ghostty_surface_t? 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 override var acceptsFirstResponder: Bool { return true } - + // 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. override var wantsUpdateLayer: Bool { return true } - + init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { self.markedText = NSMutableAttributedString() - + // 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 // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) - + // Setup our surface. This will also initialize all the terminal IO. var surface_cfg = baseConfig ?? ghostty_surface_config_new() surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque() surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque() surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor - + guard let surface = ghostty_surface_new(app, &surface_cfg) else { self.error = AppError.surfaceCreateError return } self.surface = surface; - + // Setup our tracking area so we get mouse moved events updateTrackingAreas() } - + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } - + deinit { trackingAreas.forEach { removeTrackingArea($0) } - + guard let surface = self.surface else { return } ghostty_surface_free(surface) } - + /// 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 /// 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) self.surface = nil } - + func focusDidChange(_ focused: Bool) { guard let surface = self.surface else { return } ghostty_surface_set_focus(surface, focused) } - + func sizeDidChange(_ size: CGSize) { guard let surface = self.surface else { return } - + // 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 // 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) ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) } - + + func setCursorShape(_ shape: ghostty_cursor_shape_e) { + switch (shape) { + case GHOSTTY_CURSOR_SHAPE_DEFAULT: + cursor = .arrow + + case GHOSTTY_CURSOR_SHAPE_CONTEXT_MENU: + cursor = .contextualMenu + + case GHOSTTY_CURSOR_SHAPE_TEXT: + cursor = .iBeam + + case GHOSTTY_CURSOR_SHAPE_CROSSHAIR: + cursor = .crosshair + + case GHOSTTY_CURSOR_SHAPE_GRAB: + cursor = .openHand + + case GHOSTTY_CURSOR_SHAPE_GRABBING: + cursor = .closedHand + + case GHOSTTY_CURSOR_SHAPE_POINTER: + cursor = .pointingHand + + case GHOSTTY_CURSOR_SHAPE_W_RESIZE: + cursor = .resizeLeft + + case GHOSTTY_CURSOR_SHAPE_E_RESIZE: + cursor = .resizeRight + + case GHOSTTY_CURSOR_SHAPE_N_RESIZE: + cursor = .resizeUp + + case GHOSTTY_CURSOR_SHAPE_S_RESIZE: + cursor = .resizeDown + + case GHOSTTY_CURSOR_SHAPE_NS_RESIZE: + cursor = .resizeUpDown + + case GHOSTTY_CURSOR_SHAPE_EW_RESIZE: + cursor = .resizeLeftRight + + case GHOSTTY_CURSOR_SHAPE_VERTICAL_TEXT: + cursor = .iBeamCursorForVerticalLayout + + case GHOSTTY_CURSOR_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() { guard let window = self.window else { return } guard let surface = self.surface else { return } guard ghostty_surface_transparent(surface) else { return } - + // Set the window transparency settings window.isOpaque = false window.hasShadow = false window.backgroundColor = .clear - + // If we have a blur, set the blur ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) } - + override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - + // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. if (result) { focusDidChange(false) } - + return result } - + override func updateTrackingAreas() { // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } - + // This tracking area is across the entire frame to notify us of mouse movements. addTrackingArea(NSTrackingArea( rect: frame, @@ -300,7 +360,7 @@ extension Ghostty { .mouseEnteredAndExited, .mouseMoved, .inVisibleRect, - + // 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 // we want? @@ -309,12 +369,12 @@ extension Ghostty { owner: self, userInfo: nil)) } - + override func resetCursorRects() { discardCursorRects() addCursorRect(frame, cursor: .iBeam) } - + override func viewDidChangeBackingProperties() { guard let surface = self.surface else { return } @@ -323,71 +383,79 @@ extension Ghostty { let xScale = fbFrame.size.width / self.frame.size.width let yScale = fbFrame.size.height / self.frame.size.height ghostty_surface_set_content_scale(surface, xScale, yScale) - + // 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)) } - + override func updateLayer() { guard let surface = self.surface else { return } ghostty_surface_refresh(surface); } - + override func mouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) } - + override func mouseUp(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) } - + override func rightMouseDown(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) } - + override func rightMouseUp(with event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) } - + override func mouseMoved(with event: NSEvent) { guard let surface = self.surface else { return } // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) - + } - + override func mouseDragged(with event: NSEvent) { 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) { guard let surface = self.surface else { return } - + // Builds up the "input.ScrollMods" bitmask var mods: Int32 = 0 - + var x = event.scrollingDeltaX var y = event.scrollingDeltaY if event.hasPreciseScrollingDeltas { mods = 1 - + // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; - + // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } - + // Determine our momentum value var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE switch (event.momentumPhase) { @@ -406,17 +474,21 @@ extension Ghostty { default: break } - + // Pack our momentum value into the mods bitmask mods |= Int32(momentum.rawValue) << 1 - + ghostty_surface_mouse_scroll(surface, x, y, mods) } + override func cursorUpdate(with event: NSEvent) { + cursor.set() + } + override func keyDown(with event: NSEvent) { let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS keyAction(action, event: event) - + // We specifically DO NOT call interpretKeyEvents because ghostty_surface_key // automatically handles all key translation, and we don't handle any commands // currently. @@ -427,11 +499,11 @@ extension Ghostty { // // self.interpretKeyEvents([event]) } - + override func keyUp(with event: NSEvent) { keyAction(GHOSTTY_ACTION_RELEASE, event: event) } - + override func flagsChanged(with event: NSEvent) { let mod: UInt32; switch (event.keyCode) { @@ -442,26 +514,26 @@ extension Ghostty { case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue default: return } - + // The keyAction function will do this AGAIN below which sucks to repeat // but this is super cheap and flagsChanged isn't that common. let mods = Self.translateFlags(event.modifierFlags) - + // If the key that pressed this is active, its a press, else release var action = GHOSTTY_ACTION_RELEASE if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS } - + keyAction(action, event: event) } - + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let surface = self.surface else { return } let mods = Self.translateFlags(event.modifierFlags) ghostty_surface_key(surface, action, UInt32(event.keyCode), mods) } - + // MARK: Menu Handlers - + @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" @@ -469,7 +541,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" @@ -477,7 +549,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" @@ -485,75 +557,75 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + // MARK: NSTextInputClient - + func hasMarkedText() -> Bool { return markedText.length > 0 } - + func markedRange() -> NSRange { guard markedText.length > 0 else { return NSRange() } return NSRange(0...(markedText.length-1)) } - + func selectedRange() -> NSRange { return NSRange() } - + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { switch string { case let v as NSAttributedString: self.markedText = NSMutableAttributedString(attributedString: v) - + case let v as String: self.markedText = NSMutableAttributedString(string: v) - + default: print("unknown marked text: \(string)") } } - + func unmarkText() { self.markedText.mutableString.setString("") } - + func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [] } - + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { return nil } - + func characterIndex(for point: NSPoint) -> Int { return 0 } - + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { 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) - + // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects let rect = NSMakeRect(x, frame.size.height - y, 0, 0) - + // Convert from view to screen coordinates guard let window = self.window else { return rect } return window.convertToScreen(rect) } - + func insertText(_ string: Any, replacementRange: NSRange) { // We must have an associated event guard NSApp.currentEvent != nil else { return } guard let surface = self.surface else { return } - + // We want the string view of the any value var chars = "" switch (string) { @@ -564,39 +636,39 @@ extension Ghostty { default: return } - + for codepoint in chars.unicodeScalars { ghostty_surface_char(surface, codepoint.value) } } - + override func doCommand(by selector: Selector) { // This currently just prevents NSBeep from interpretKeyEvents but in the future // we may want to make some of this work. - + print("SEL: \(selector)") } - + private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue - + if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } - + // Handle sided input. We can't tell that both are pressed in the // Ghostty structure but thats okay -- we don't use that information. let rawFlags = flags.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_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) } - + // 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! static let keycodes: [UInt16 : ghostty_input_key_e] = [ @@ -742,7 +814,7 @@ extension FocusedValues { get { self[FocusedGhosttySurface.self] } set { self[FocusedGhosttySurface.self] = newValue } } - + struct FocusedGhosttySurface: FocusedValueKey { typealias Value = Ghostty.SurfaceView } @@ -753,7 +825,7 @@ extension FocusedValues { get { self[FocusedGhosttySurfaceTitle.self] } set { self[FocusedGhosttySurfaceTitle.self] = newValue } } - + struct FocusedGhosttySurfaceTitle: FocusedValueKey { typealias Value = String } @@ -764,7 +836,7 @@ extension FocusedValues { get { self[FocusedGhosttySurfaceZoomed.self] } set { self[FocusedGhosttySurfaceZoomed.self] = newValue } } - + struct FocusedGhosttySurfaceZoomed: FocusedValueKey { typealias Value = Bool } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3420fdd05..80d296c9b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); const input = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); const CoreApp = @import("../App.zig"); const CoreSurface = @import("../Surface.zig"); const configpkg = @import("../config.zig"); @@ -48,6 +49,9 @@ pub const App = struct { /// Called to set the title of the window. set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, + /// Called to set the cursor shape. + set_cursor_shape: *const fn (SurfaceUD, terminal.CursorShape) callconv(.C) void, + /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard /// value then this should return null. @@ -310,6 +314,13 @@ pub const Surface = struct { ); } + pub fn setCursorShape(self: *Surface, shape: terminal.CursorShape) !void { + self.app.opts.set_cursor_shape( + self.opts.userdata, + shape, + ); + } + pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, diff --git a/src/terminal/cursor_shape.zig b/src/terminal/cursor_shape.zig index 021ee74d4..99dfed461 100644 --- a/src/terminal/cursor_shape.zig +++ b/src/terminal/cursor_shape.zig @@ -3,6 +3,8 @@ 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 CursorShape = enum(c_int) { default, context_menu, From 674575e59846bad34b9e754a66bc6adb9b595ff5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 10:55:05 -0700 Subject: [PATCH 5/8] apprt/gtk: support set cursor shape --- src/apprt/gtk.zig | 72 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 580970711..813d8c9bf 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -8,6 +8,7 @@ const glfw = @import("glfw"); const apprt = @import("../apprt.zig"); const font = @import("../font/main.zig"); const input = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); const CoreApp = @import("../App.zig"); const CoreSurface = @import("../Surface.zig"); const configpkg = @import("../config.zig"); @@ -44,8 +45,8 @@ pub const App = struct { app: *c.GtkApplication, ctx: *c.GMainContext, - cursor_default: *c.GdkCursor, - cursor_ibeam: *c.GdkCursor, + /// Any active cursor we may have + cursor: ?*c.GdkCursor = null, /// This is set to false when the main loop should exit. running: bool = true, @@ -125,19 +126,11 @@ pub const App = struct { // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 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 .{ .core_app = core_app, .app = app, .config = config, .ctx = ctx, - .cursor_default = cursor_default, - .cursor_ibeam = cursor_ibeam, // 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 @@ -154,8 +147,7 @@ pub const App = struct { c.g_main_context_release(self.ctx); c.g_object_unref(self.app); - c.g_object_unref(self.cursor_ibeam); - c.g_object_unref(self.cursor_default); + if (self.cursor) |cursor| c.g_object_unref(cursor); self.config.deinit(); @@ -998,6 +990,62 @@ pub const Surface = struct { // )); } + pub fn setCursorShape( + self: *Surface, + shape: terminal.CursorShape, + ) !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", + else => return, + }; + + 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( self: *Surface, clipboard_type: apprt.Clipboard, From cb2931cb276dd15fc078e87334a6ad3a37248118 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 10:59:22 -0700 Subject: [PATCH 6/8] rename cursor shape to mouse shape for OSC 22 --- include/ghostty.h | 74 +++++++++---------- macos/Sources/Ghostty/AppState.swift | 4 +- macos/Sources/Ghostty/SurfaceView.swift | 36 ++++----- src/Surface.zig | 6 +- src/apprt/embedded.zig | 6 +- src/apprt/glfw.zig | 4 +- src/apprt/gtk.zig | 4 +- src/apprt/surface.zig | 4 +- src/terminal/main.zig | 2 +- .../{cursor_shape.zig => mouse_shape.zig} | 8 +- src/terminal/osc.zig | 12 +-- src/terminal/stream.zig | 10 +-- src/termio/Exec.zig | 6 +- 13 files changed, 88 insertions(+), 88 deletions(-) rename src/terminal/{cursor_shape.zig => mouse_shape.zig} (92%) diff --git a/include/ghostty.h b/include/ghostty.h index 5c5ba7d59..225265633 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -71,41 +71,41 @@ typedef enum { } ghostty_input_mouse_momentum_e; typedef enum { - GHOSTTY_CURSOR_SHAPE_DEFAULT, - GHOSTTY_CURSOR_SHAPE_CONTEXT_MENU, - GHOSTTY_CURSOR_SHAPE_HELP, - GHOSTTY_CURSOR_SHAPE_POINTER, - GHOSTTY_CURSOR_SHAPE_PROGRESS, - GHOSTTY_CURSOR_SHAPE_WAIT, - GHOSTTY_CURSOR_SHAPE_CELL, - GHOSTTY_CURSOR_SHAPE_CROSSHAIR, - GHOSTTY_CURSOR_SHAPE_TEXT, - GHOSTTY_CURSOR_SHAPE_VERTICAL_TEXT, - GHOSTTY_CURSOR_SHAPE_ALIAS, - GHOSTTY_CURSOR_SHAPE_COPY, - GHOSTTY_CURSOR_SHAPE_MOVE, - GHOSTTY_CURSOR_SHAPE_NO_DROP, - GHOSTTY_CURSOR_SHAPE_NOT_ALLOWED, - GHOSTTY_CURSOR_SHAPE_GRAB, - GHOSTTY_CURSOR_SHAPE_GRABBING, - GHOSTTY_CURSOR_SHAPE_ALL_SCROLL, - GHOSTTY_CURSOR_SHAPE_COL_RESIZE, - GHOSTTY_CURSOR_SHAPE_ROW_RESIZE, - GHOSTTY_CURSOR_SHAPE_N_RESIZE, - GHOSTTY_CURSOR_SHAPE_E_RESIZE, - GHOSTTY_CURSOR_SHAPE_S_RESIZE, - GHOSTTY_CURSOR_SHAPE_W_RESIZE, - GHOSTTY_CURSOR_SHAPE_NE_RESIZE, - GHOSTTY_CURSOR_SHAPE_NW_RESIZE, - GHOSTTY_CURSOR_SHAPE_SE_RESIZE, - GHOSTTY_CURSOR_SHAPE_SW_RESIZE, - GHOSTTY_CURSOR_SHAPE_EW_RESIZE, - GHOSTTY_CURSOR_SHAPE_NS_RESIZE, - GHOSTTY_CURSOR_SHAPE_NESW_RESIZE, - GHOSTTY_CURSOR_SHAPE_NWSE_RESIZE, - GHOSTTY_CURSOR_SHAPE_ZOOM_IN, - GHOSTTY_CURSOR_SHAPE_ZOOM_OUT, -} ghostty_cursor_shape_e; + 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 { GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE, @@ -301,7 +301,7 @@ typedef struct { typedef void (*ghostty_runtime_wakeup_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_cursor_shape_cb)(void *, ghostty_cursor_shape_e); +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 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); @@ -319,7 +319,7 @@ typedef struct { ghostty_runtime_wakeup_cb wakeup_cb; ghostty_runtime_reload_config_cb reload_config_cb; ghostty_runtime_set_title_cb set_title_cb; - ghostty_runtime_set_cursor_shape_cb set_cursor_shape_cb; + ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_new_split_cb new_split_cb; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index fb043991f..2743ef01f 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -72,7 +72,7 @@ extension Ghostty { wakeup_cb: { userdata in AppState.wakeup(userdata) }, reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, - set_cursor_shape_cb: { userdata, shape in AppState.setCursorShape(userdata, shape: shape) }, + set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) }, 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) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, @@ -334,7 +334,7 @@ extension Ghostty { } } - static func setCursorShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_cursor_shape_e) { + static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() surfaceView.setCursorShape(shape) } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 48d932b06..94c4700f4 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -267,51 +267,51 @@ extension Ghostty { ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) } - func setCursorShape(_ shape: ghostty_cursor_shape_e) { + func setCursorShape(_ shape: ghostty_mouse_shape_e) { switch (shape) { - case GHOSTTY_CURSOR_SHAPE_DEFAULT: + case GHOSTTY_MOUSE_SHAPE_DEFAULT: cursor = .arrow - case GHOSTTY_CURSOR_SHAPE_CONTEXT_MENU: + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: cursor = .contextualMenu - case GHOSTTY_CURSOR_SHAPE_TEXT: + case GHOSTTY_MOUSE_SHAPE_TEXT: cursor = .iBeam - case GHOSTTY_CURSOR_SHAPE_CROSSHAIR: + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: cursor = .crosshair - case GHOSTTY_CURSOR_SHAPE_GRAB: + case GHOSTTY_MOUSE_SHAPE_GRAB: cursor = .openHand - case GHOSTTY_CURSOR_SHAPE_GRABBING: + case GHOSTTY_MOUSE_SHAPE_GRABBING: cursor = .closedHand - case GHOSTTY_CURSOR_SHAPE_POINTER: + case GHOSTTY_MOUSE_SHAPE_POINTER: cursor = .pointingHand - case GHOSTTY_CURSOR_SHAPE_W_RESIZE: + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: cursor = .resizeLeft - case GHOSTTY_CURSOR_SHAPE_E_RESIZE: + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: cursor = .resizeRight - case GHOSTTY_CURSOR_SHAPE_N_RESIZE: + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: cursor = .resizeUp - case GHOSTTY_CURSOR_SHAPE_S_RESIZE: + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: cursor = .resizeDown - case GHOSTTY_CURSOR_SHAPE_NS_RESIZE: + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: cursor = .resizeUpDown - case GHOSTTY_CURSOR_SHAPE_EW_RESIZE: + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: cursor = .resizeLeftRight - case GHOSTTY_CURSOR_SHAPE_VERTICAL_TEXT: + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: cursor = .iBeamCursorForVerticalLayout - case GHOSTTY_CURSOR_SHAPE_NOT_ALLOWED: + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: cursor = .operationNotAllowed default: @@ -433,11 +433,11 @@ extension Ghostty { override func mouseEntered(with event: NSEvent) { mouseEntered = true } - + override func mouseExited(with event: NSEvent) { mouseEntered = false } - + override func scrollWheel(with event: NSEvent) { guard let surface = self.surface else { return } diff --git a/src/Surface.zig b/src/Surface.zig index 3e957844b..eda00483f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -525,9 +525,9 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { try self.rt_surface.setTitle(slice); }, - .set_cursor_shape => |shape| { - log.debug("changing cursor shape: {}", .{shape}); - try self.rt_surface.setCursorShape(shape); + .set_mouse_shape => |shape| { + log.debug("changing mouse shape: {}", .{shape}); + try self.rt_surface.setMouseShape(shape); }, .cell_size => |size| try self.setCellSize(size), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 80d296c9b..54746ec50 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -50,7 +50,7 @@ pub const App = struct { set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, /// Called to set the cursor shape. - set_cursor_shape: *const fn (SurfaceUD, terminal.CursorShape) callconv(.C) void, + set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard @@ -314,8 +314,8 @@ pub const Surface = struct { ); } - pub fn setCursorShape(self: *Surface, shape: terminal.CursorShape) !void { - self.app.opts.set_cursor_shape( + pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { + self.app.opts.set_mouse_shape( self.opts.userdata, shape, ); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a56868924..2efab9539 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -357,7 +357,7 @@ pub const Surface = struct { errdefer self.* = undefined; // Initialize our cursor - try self.setCursorShape(.text); + try self.setMouseShape(.text); // Add ourselves to the list of surfaces on the app. try app.app.addSurface(self); @@ -506,7 +506,7 @@ pub const Surface = struct { } /// Set the shape of the cursor. - pub fn setCursorShape(self: *Surface, shape: terminal.CursorShape) !void { + pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { if ((comptime builtin.target.isDarwin()) and !internal_os.macosVersionAtLeast(13, 0, 0)) { diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 813d8c9bf..2919c8fe6 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -990,9 +990,9 @@ pub const Surface = struct { // )); } - pub fn setCursorShape( + pub fn setMouseShape( self: *Surface, - shape: terminal.CursorShape, + shape: terminal.MouseShape, ) !void { const name: [:0]const u8 = switch (shape) { .default => "default", diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index a0ba1a54d..1cf303888 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -17,8 +17,8 @@ pub const Message = union(enum) { /// of any length set_title: [256]u8, - /// Set the cursor shape. - set_cursor_shape: terminal.CursorShape, + /// Set the mouse shape. + set_mouse_shape: terminal.MouseShape, /// Change the cell size. cell_size: renderer.CellSize, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 32b7d6898..3de1835d9 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,7 +15,7 @@ pub const parse_table = @import("parse_table.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; -pub const CursorShape = @import("cursor_shape.zig").CursorShape; +pub const MouseShape = @import("mouse_shape.zig").MouseShape; pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); diff --git a/src/terminal/cursor_shape.zig b/src/terminal/mouse_shape.zig similarity index 92% rename from src/terminal/cursor_shape.zig rename to src/terminal/mouse_shape.zig index 99dfed461..cf8f42c4b 100644 --- a/src/terminal/cursor_shape.zig +++ b/src/terminal/mouse_shape.zig @@ -5,7 +5,7 @@ const std = @import("std"); /// can have a cross platform list. // // Must be kept in sync with ghostty_cursor_shape_e -pub const CursorShape = enum(c_int) { +pub const MouseShape = enum(c_int) { default, context_menu, help, @@ -42,12 +42,12 @@ pub const CursorShape = enum(c_int) { zoom_out, /// Build cursor shape from string or null if its unknown. - pub fn fromString(v: []const u8) ?CursorShape { + pub fn fromString(v: []const u8) ?MouseShape { return string_map.get(v); } }; -const string_map = std.ComptimeStringMap(CursorShape, .{ +const string_map = std.ComptimeStringMap(MouseShape, .{ // W3C .{ "default", .default }, .{ "context-menu", .context_menu }, @@ -111,5 +111,5 @@ const string_map = std.ComptimeStringMap(CursorShape, .{ test "cursor shape from string" { const testing = std.testing; - try testing.expectEqual(CursorShape.default, CursorShape.fromString("default").?); + try testing.expectEqual(MouseShape.default, MouseShape.fromString("default").?); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index c4af80cf1..f61504daf 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -84,11 +84,11 @@ pub const Command = union(enum) { value: []const u8, }, - /// OSC 22. Set the cursor shape. There doesn't seem to be a standard + /// 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. - pointer_cursor: struct { + mouse_shape: struct { value: []const u8, }, }; @@ -248,10 +248,10 @@ pub const Parser = struct { .@"22" => switch (c) { ';' => { - self.command = .{ .pointer_cursor = undefined }; + self.command = .{ .mouse_shape = undefined }; self.state = .string; - self.temp_state = .{ .str = &self.command.pointer_cursor.value }; + self.temp_state = .{ .str = &self.command.mouse_shape.value }; self.buf_start = self.buf_idx; }, else => self.state = .invalid, @@ -672,8 +672,8 @@ test "OSC: pointer cursor" { for (input) |ch| p.next(ch); const cmd = p.end().?; - try testing.expect(cmd == .pointer_cursor); - try testing.expect(std.mem.eql(u8, "pointer", cmd.pointer_cursor.value)); + try testing.expect(cmd == .mouse_shape); + try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); } test "OSC: report pwd empty" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a7c4834da..c12bc4063 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -9,7 +9,7 @@ const modes = @import("modes.zig"); const osc = @import("osc.zig"); const sgr = @import("sgr.zig"); const trace = @import("tracy").trace; -const CursorShape = @import("cursor_shape.zig").CursorShape; +const MouseShape = @import("mouse_shape.zig").MouseShape; const log = std.log.scoped(.stream); @@ -850,14 +850,14 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .pointer_cursor => |v| { - if (@hasDecl(T, "setCursorShape")) { - const shape = CursorShape.fromString(v.value) orelse { + .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.setCursorShape(shape); + try self.handler.setMouseShape(shape); } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 4365c3a6a..36deb1235 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1682,12 +1682,12 @@ const StreamHandler = struct { }, .{ .forever = {} }); } - pub fn setCursorShape( + pub fn setMouseShape( self: *StreamHandler, - shape: terminal.CursorShape, + shape: terminal.MouseShape, ) !void { _ = self.ev.surface_mailbox.push(.{ - .set_cursor_shape = shape, + .set_mouse_shape = shape, }, .{ .forever = {} }); } From d96d60445ab71745f7d0637e229d353cb8a60fa5 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Thu, 14 Sep 2023 20:19:53 +0200 Subject: [PATCH 7/8] Fix compilation issues with GTK cursor support --- src/apprt/gtk.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 2919c8fe6..2453ec033 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -791,7 +791,7 @@ pub const Surface = struct { 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); + c.gtk_widget_set_cursor(widget, app.cursor); // Build our result self.* = .{ @@ -1029,7 +1029,6 @@ pub const Surface = struct { .nwse_resize => "nwse-resize", .zoom_in => "zoom-in", .zoom_out => "zoom-out", - else => return, }; const cursor = c.gdk_cursor_new_from_name(name.ptr, null) orelse { @@ -1042,8 +1041,8 @@ pub const Surface = struct { 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; + if (self.app.cursor) |old| c.g_object_unref(old); + self.app.cursor = cursor; } pub fn getClipboardString( From 01cb1ad90e41bfc822dfa1b727bd130f2206ee31 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Sep 2023 11:22:54 -0700 Subject: [PATCH 8/8] apprt/gtk: cursor state should be on surface --- src/apprt/gtk.zig | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 2453ec033..560c24b34 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -45,9 +45,6 @@ pub const App = struct { app: *c.GtkApplication, ctx: *c.GMainContext, - /// Any active cursor we may have - cursor: ?*c.GdkCursor = null, - /// This is set to false when the main loop should exit. running: bool = true, @@ -147,8 +144,6 @@ pub const App = struct { c.g_main_context_release(self.ctx); c.g_object_unref(self.app); - if (self.cursor) |cursor| c.g_object_unref(cursor); - self.config.deinit(); glfw.terminate(); @@ -714,6 +709,9 @@ pub const Surface = struct { /// Our GTK area gl_area: *c.GtkGLArea, + /// Any active cursor we may have + cursor: ?*c.GdkCursor = null, + /// Our title label (if there is one). title: Title, @@ -790,9 +788,6 @@ pub const Surface = struct { c.gtk_widget_set_focusable(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); - // Build our result self.* = .{ .app = app, @@ -872,6 +867,8 @@ pub const Surface = struct { // Free all our GTK stuff c.g_object_unref(self.im_context); c.g_value_unset(&self.clipboard); + + if (self.cursor) |cursor| c.g_object_unref(cursor); } fn render(self: *Surface) !void { @@ -1041,8 +1038,8 @@ pub const Surface = struct { c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); // Free our existing cursor - if (self.app.cursor) |old| c.g_object_unref(old); - self.app.cursor = cursor; + if (self.cursor) |old| c.g_object_unref(old); + self.cursor = cursor; } pub fn getClipboardString(