diff --git a/include/ghostty.h b/include/ghostty.h index f30275b2c..c4ef11930 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -254,8 +254,10 @@ typedef enum { typedef struct { ghostty_input_action_e action; ghostty_input_mods_e mods; + ghostty_input_mods_e consumed_mods; uint32_t keycode; const char* text; + uint32_t unshifted_codepoint; bool composing; } ghostty_input_key_s; @@ -725,6 +727,7 @@ ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); +void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift index 4118cd94d..754bb7a3a 100644 --- a/macos/Sources/Ghostty/NSEvent+Extension.swift +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -3,13 +3,66 @@ import GhosttyKit extension NSEvent { /// Create a Ghostty key event for a given keyboard action. - func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s { - var key_ev = ghostty_input_key_s() + /// + /// This will not set the "text" or "composing" fields since these can't safely be set + /// with the information or lifetimes given. + /// + /// The translationMods should be set to the modifiers used for actual character + /// translation if available. + func ghosttyKeyEvent( + _ action: ghostty_input_action_e, + translationMods: NSEvent.ModifierFlags? = nil + ) -> ghostty_input_key_s { + var key_ev: ghostty_input_key_s = .init() key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(modifierFlags) key_ev.keycode = UInt32(keyCode) + + // We can't infer or set these safely from this method. Since text is + // a cString, we can't use self.characters because of garbage collection. + // We have to let the caller handle this. key_ev.text = nil key_ev.composing = false + + // macOS provides no easy way to determine the consumed modifiers for + // producing text. We apply a simple heuristic here that has worked for years + // so far: control and command never contribute to the translation of text, + // assume everything else did. + key_ev.mods = Ghostty.ghosttyMods(modifierFlags) + key_ev.consumed_mods = Ghostty.ghosttyMods( + (translationMods ?? modifierFlags) + .subtracting([.control, .command])) + + // Our unshifted codepoint is the codepoint with no modifiers. We + // ignore multi-codepoint values. + key_ev.unshifted_codepoint = 0 + if type == .keyDown || type == .keyUp { + if let charactersIgnoringModifiers, + let codepoint = charactersIgnoringModifiers.unicodeScalars.first + { + key_ev.unshifted_codepoint = codepoint.value + } + } + return key_ev } + + /// Returns the text to set for a key event for Ghostty. + /// + /// This namely contains logic to avoid control characters, since we handle control character + /// mapping manually within Ghostty. + var ghosttyCharacters: String? { + // If we have no characters associated with this event we do nothing. + guard let characters else { return nil } + + // If we have a single control character, then we return the characters + // without control pressed. We do this because we handle control character + // encoding directly within Ghostty's KeyEncoder. + if characters.count == 1, + let scalar = characters.unicodeScalars.first, + scalar.value < 0x20 { + return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + } + + return nil + } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 1269f2314..574c88044 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -951,29 +951,32 @@ extension Ghostty { return } - // If we have text, then we've composed a character, send that down. We do this - // first because if we completed a preedit, the text will be available here - // AND we'll have a preedit. - var handled: Bool = false + // If we have marked text, we're in a preedit state. The order we + // do this and the key event callbacks below doesn't matter since + // we control the preedit state only through the preedit API. + syncPreedit(clearIfNeeded: markedTextBefore) + if let list = keyTextAccumulator, list.count > 0 { - handled = true + // If we have text, then we've composed a character, send that down. + // These never have "composing" set to true because these are the + // result of a composition. for text in list { - _ = keyAction(action, event: event, text: text) + _ = keyAction( + action, + event: event, + translationEvent: translationEvent, + text: text + ) } - } - - // If we have marked text, we're in a preedit state. Send that down. - // If we don't have marked text but we had marked text before, then the preedit - // was cleared so we want to send down an empty string to ensure we've cleared - // the preedit. - if (markedText.length > 0 || markedTextBefore) { - handled = true - _ = keyAction(action, event: event, preedit: markedText.string) - } - - if (!handled) { - // No text or anything, we want to handle this manually. - _ = keyAction(action, event: event) + } else { + // We have no accumulated text so this is a normal key event. + _ = keyAction( + action, + event: event, + translationEvent: translationEvent, + text: translationEvent.ghosttyCharacters, + composing: markedText.length > 0 + ) } } @@ -1043,16 +1046,6 @@ extension Ghostty { let equivalent: String switch (event.charactersIgnoringModifiers) { - case "/": - // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep - // sound and we don't like the beep sound. - if (!event.modifierFlags.contains(.control) || - !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { - return false - } - - equivalent = "_" - case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) @@ -1165,34 +1158,23 @@ extension Ghostty { _ = keyAction(action, event: event) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool { - guard let surface = self.surface else { return false } - return ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) - } - private func keyAction( _ action: ghostty_input_action_e, - event: NSEvent, preedit: String + event: NSEvent, + translationEvent: NSEvent? = nil, + text: String? = nil, + composing: Bool = false ) -> Bool { guard let surface = self.surface else { return false } - return preedit.withCString { ptr in - var key_ev = event.ghosttyKeyEvent(action) - key_ev.text = ptr - key_ev.composing = true - return ghostty_surface_key(surface, key_ev) - } - } - - private func keyAction( - _ action: ghostty_input_action_e, - event: NSEvent, text: String - ) -> Bool { - guard let surface = self.surface else { return false } - - return text.withCString { ptr in - var key_ev = event.ghosttyKeyEvent(action) - key_ev.text = ptr + var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) + key_ev.composing = composing + if let text { + return text.withCString { ptr in + key_ev.text = ptr + return ghostty_surface_key(surface, key_ev) + } + } else { return ghostty_surface_key(surface, key_ev) } } @@ -1468,10 +1450,21 @@ extension Ghostty.SurfaceView: NSTextInputClient { default: print("unknown marked text: \(string)") } + + // If we're not in a keyDown event, then we want to update our preedit + // text immediately. This can happen due to external events, for example + // changing keyboard layouts while composing: (1) set US intl (2) type ' + // to enter dead key state (3) + if keyTextAccumulator == nil { + syncPreedit() + } } func unmarkText() { - self.markedText.mutableString.setString("") + if self.markedText.length > 0 { + self.markedText.mutableString.setString("") + syncPreedit() + } } func validAttributesForMarkedText() -> [NSAttributedString.Key] { @@ -1610,6 +1603,26 @@ extension Ghostty.SurfaceView: NSTextInputClient { print("SEL: \(selector)") } + + /// Sync the preedit state based on the markedText value to libghostty + private func syncPreedit(clearIfNeeded: Bool = true) { + guard let surface else { return } + + if markedText.length > 0 { + let str = markedText.string + let len = str.utf8CString.count + if len > 0 { + markedText.string.withCString { ptr in + // Subtract 1 for the null terminator + ghostty_surface_preedit(surface, ptr, UInt(len - 1)) + } + } + } else if clearIfNeeded { + // If we had marked text before but don't now, we're no longer + // in a preedit state so we can clear it. + ghostty_surface_preedit(surface, nil, 0) + } + } } // MARK: Services diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 795a93585..cdb4c3873 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -36,14 +36,14 @@ msgstr "确认" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 msgid "Configuration Errors" -msgstr "设置错误" +msgstr "配置错误" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -"加载设置时发现了以下错误。请仔细阅读错误信息,并选择忽略或重新加载设置文件。" +"加载配置时发现了以下错误。请仔细阅读错误信息,并选择忽略或重新加载配置文件。" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 msgid "Ignore" @@ -53,7 +53,7 @@ msgstr "忽略" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Reload Configuration" -msgstr "重新加载设置" +msgstr "重新加载配置" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -69,7 +69,7 @@ msgstr "粘贴" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 msgid "Clear" -msgstr "清除界面" +msgstr "清除屏幕" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 @@ -137,16 +137,16 @@ msgstr "关闭窗口" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 msgid "Config" -msgstr "设置" +msgstr "配置" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Open Configuration" -msgstr "打开设置文件" +msgstr "打开配置文件" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Terminal Inspector" -msgstr "终端检视器" +msgstr "终端调试器" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:102 #: src/apprt/gtk/Window.zig:960 @@ -160,13 +160,13 @@ msgstr "退出" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 msgid "Authorize Clipboard Access" -msgstr "剪切板访问授权" +msgstr "剪贴板访问授权" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." -msgstr "一个应用正在试图从剪切板读取内容。剪切板目前的内容如下:" +msgstr "一个应用正在试图从剪贴板读取内容。剪贴板目前的内容如下:" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 @@ -182,7 +182,7 @@ msgstr "允许" msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." -msgstr "一个应用正在试图向剪切板写入内容。剪切板目前的内容如下:" +msgstr "一个应用正在试图向剪贴板写入内容。剪贴板目前的内容如下:" #: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" @@ -196,11 +196,11 @@ msgstr "将以下内容粘贴至终端内将可能执行有害命令。" #: src/apprt/gtk/inspector.zig:144 msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty 终端检视器" +msgstr "Ghostty 终端调试器" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" -msgstr "已复制至剪切板" +msgstr "已复制至剪贴板" #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" @@ -253,7 +253,7 @@ msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" #: src/apprt/gtk/Window.zig:725 msgid "Reloaded the configuration" -msgstr "已重新加载设置" +msgstr "已重新加载配置" #: src/apprt/gtk/Window.zig:941 msgid "Ghostty Developers" diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 50b54435d..e8da8612c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -73,16 +73,68 @@ pub const App = struct { /// This is the key event sent for ghostty_surface_key and /// ghostty_app_key. pub const KeyEvent = struct { - /// The three below are absolutely required. action: input.Action, mods: input.Mods, + consumed_mods: input.Mods, keycode: u32, - - /// Optionally, the embedder can handle text translation and send - /// the text value here. If text is non-nil, it is assumed that the - /// embedder also handles dead key states and sets composing as necessary. text: ?[:0]const u8, + unshifted_codepoint: u32, composing: bool, + + /// Convert a libghostty key event into a core key event. + fn core(self: KeyEvent) ?input.KeyEvent { + const text: []const u8 = if (self.text) |v| v else ""; + const unshifted_codepoint: u21 = std.math.cast( + u21, + self.unshifted_codepoint, + ) orelse 0; + + // We want to get the physical unmapped key to process keybinds. + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == self.keycode) break :keycode entry.key; + } else .invalid; + + // If the resulting text has length 1 then we can take its key + // and attempt to translate it to a key enum and call the key callback. + // If the length is greater than 1 then we're going to call the + // charCallback. + // + // We also only do key translation if this is not a dead key. + const key = if (!self.composing) key: { + // If our physical key is a keypad key, we use that. + if (physical_key.keypad()) break :key physical_key; + + // A completed key. If the length of the key is one then we can + // attempt to translate it to a key enum and call the key + // callback. First try plain ASCII. + if (text.len > 0) { + if (input.Key.fromASCII(text[0])) |key| { + break :key key; + } + } + + // If the above doesn't work, we use the unmodified value. + if (std.math.cast(u8, unshifted_codepoint)) |ascii| { + if (input.Key.fromASCII(ascii)) |key| { + break :key key; + } + } + + break :key physical_key; + } else .invalid; + + // Build our final key event + return .{ + .action = self.action, + .key = key, + .physical_key = physical_key, + .mods = self.mods, + .consumed_mods = self.consumed_mods, + .composing = self.composing, + .utf8 = text, + .unshifted_codepoint = unshifted_codepoint, + }; + } }; core_app: *CoreApp, @@ -92,10 +144,6 @@ pub const App = struct { /// The configuration for the app. This is owned by this structure. config: Config, - /// The keymap state is used for global keybinds only. Each surface - /// also has its own keymap state for focused keybinds. - keymap_state: input.Keymap.State, - pub fn init( core_app: *CoreApp, config: *const Config, @@ -114,7 +162,6 @@ pub const App = struct { .config = config_clone, .opts = opts, .keymap = keymap, - .keymap_state = .{}, }; } @@ -148,219 +195,6 @@ pub const App = struct { self.core_app.focusEvent(focused); } - /// Convert a C key event into a Zig key event. - /// - /// The buffer is needed for possibly storing translated UTF-8 text. - /// This buffer may (or may not) be referenced by the resulting KeyEvent - /// so it should be valid for the lifetime of the KeyEvent. - /// - /// The size of the buffer doesn't need to be large, we always - /// used to hardcode 128 bytes and never ran into issues. If it isn't - /// large enough an error will be returned. - fn coreKeyEvent( - self: *App, - buf: []u8, - target: KeyTarget, - event: KeyEvent, - ) !?input.KeyEvent { - const action = event.action; - const keycode = event.keycode; - const mods = event.mods; - - // True if this is a key down event - const is_down = action == .press or action == .repeat; - - // If we're on macOS and we have macos-option-as-alt enabled, - // then we strip the alt modifier from the mods for translation. - const translate_mods = translate_mods: { - var translate_mods = mods; - if ((comptime builtin.target.os.tag.isDarwin()) and translate_mods.alt) { - // Note: the keyboardLayout() function is not super cheap - // so we only want to run it if alt is already pressed hence - // the above condition. - const option_as_alt: configpkg.OptionAsAlt = - self.config.@"macos-option-as-alt" orelse - self.keyboardLayout().detectOptionAsAlt(); - - const strip = switch (option_as_alt) { - .false => false, - .true => mods.alt, - .left => mods.sides.alt == .left, - .right => mods.sides.alt == .right, - }; - if (strip) translate_mods.alt = false; - } - - // We strip super on macOS because its not used for translation - // it results in a bad translation. - if (comptime builtin.target.os.tag.isDarwin()) { - translate_mods.super = false; - } - - break :translate_mods translate_mods; - }; - - const event_text: ?[]const u8 = event_text: { - // This logic only applies to macOS. - if (comptime builtin.os.tag != .macos) break :event_text event.text; - - // If we're in a preedit state then we allow it through. This - // allows ctrl sequences that affect IME to work. For example, - // Ctrl+H deletes a character with Japanese input. - if (event.composing) break :event_text event.text; - - // If the modifiers are ONLY "control" then we never process - // the event text because we want to do our own translation so - // we can handle ctrl+c, ctrl+z, etc. - // - // This is specifically because on macOS using the - // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as - // "/" (the physical key that is z on a qwerty keyboard). But on - // other layouts, ctrl+ is not translated by AppKit. So, - // we just avoid this by never allowing AppKit to translate - // ctrl+ and instead do it ourselves. - const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); - break :event_text if (mods.binding().int() == ctrl_only) null else event.text; - }; - - // Translate our key using the keymap for our localized keyboard layout. - // We only translate for keydown events. Otherwise, we only care about - // the raw keycode. - const result: input.Keymap.Translation = if (is_down) translate: { - // If the event provided us with text, then we use this as a result - // and do not do manual translation. - const result: input.Keymap.Translation = if (event_text) |text| .{ - .text = text, - .composing = event.composing, - .mods = translate_mods, - } else try self.keymap.translate( - buf, - switch (target) { - .app => &self.keymap_state, - .surface => |surface| &surface.keymap_state, - }, - @intCast(keycode), - translate_mods, - ); - - // TODO(mitchellh): I think we can get rid of the above keymap - // translation code completely and defer to AppKit/Swift - // (for macOS) for handling all translations. The translation - // within libghostty is an artifact of an earlier design and - // it is buggy (see #5558). We should move closer to a GTK-style - // model of tracking composing states and preedit in the apprt - // and not in libghostty. - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state if we're targeting a - // surface. - if (result.composing) { - switch (target) { - .app => {}, - .surface => |surface| surface.core_surface.preeditCallback( - result.text, - ) catch |err| { - log.err("error in preedit callback err={}", .{err}); - return null; - }, - } - } else { - switch (target) { - .app => {}, - .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { - log.err("error in preedit callback err={}", .{err}); - return null; - }, - } - - // If the text is just a single non-printable ASCII character - // then we clear the text. We handle non-printables in the - // key encoder manual (such as tab, ctrl+c, etc.) - if (result.text.len == 1 and result.text[0] < 0x20) { - break :translate .{}; - } - } - - break :translate result; - } else .{}; - - // We need to always do a translation with no modifiers at all in - // order to get the "unshifted_codepoint" for the key event. - const unshifted_codepoint: u21 = unshifted: { - var nomod_buf: [128]u8 = undefined; - var nomod_state: input.Keymap.State = .{}; - const nomod = try self.keymap.translate( - &nomod_buf, - &nomod_state, - @intCast(keycode), - .{}, - ); - - const view = std.unicode.Utf8View.init(nomod.text) catch |err| { - log.warn("cannot build utf8 view over text: {}", .{err}); - break :unshifted 0; - }; - var it = view.iterator(); - break :unshifted it.nextCodepoint() orelse 0; - }; - - // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ - // action, - // keycode, - // result.composing, - // result.text.len, - // result.text, - // result.text, - // mods, - // }); - - // We want to get the physical unmapped key to process keybinds. - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!result.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (result.text.len > 0) { - if (input.Key.fromASCII(result.text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; - - // Build our final key event - return .{ - .action = action, - .key = key, - .physical_key = physical_key, - .mods = mods, - .consumed_mods = result.mods, - .composing = result.composing, - .utf8 = result.text, - .unshifted_codepoint = unshifted_codepoint, - }; - } - /// See CoreApp.keyEvent. pub fn keyEvent( self: *App, @@ -368,12 +202,8 @@ pub const App = struct { event: KeyEvent, ) !bool { // Convert our C key event into a Zig one. - var buf: [128]u8 = undefined; - const input_event: input.KeyEvent = (try self.coreKeyEvent( - &buf, - target, - event, - )) orelse return false; + const input_event: input.KeyEvent = event.core() orelse + return false; // Invoke the core Ghostty logic to handle this input. const effect: CoreSurface.InputEffect = switch (target) { @@ -390,23 +220,7 @@ pub const App = struct { return switch (effect) { .closed => true, .ignored => false, - .consumed => consumed: { - const is_down = input_event.action == .press or - input_event.action == .repeat; - - if (is_down) { - // If we consume the key then we want to reset the dead - // key state. - self.keymap_state = .{}; - - switch (target) { - .app => {}, - .surface => |surface| surface.core_surface.preeditCallback(null) catch {}, - } - } - - break :consumed true; - }, + .consumed => true, }; } @@ -414,13 +228,6 @@ pub const App = struct { pub fn reloadKeymap(self: *App) !void { // Reload the keymap try self.keymap.reload(); - - // Clear the dead key state since we changed the keymap, any - // dead key state is just forgotten. i.e. if you type ' on us-intl - // and then switch to us and type a, you'll get a rather than á. - for (self.core_app.surfaces.items) |surface| { - surface.keymap_state = .{}; - } } /// Loads the keyboard layout. @@ -607,7 +414,6 @@ pub const Surface = struct { content_scale: apprt.ContentScale, size: apprt.SurfaceSize, cursor_pos: apprt.CursorPos, - keymap_state: input.Keymap.State, inspector: ?*Inspector = null, /// The current title of the surface. The embedded apprt saves this so @@ -656,7 +462,6 @@ pub const Surface = struct { }, .size = .{ .width = 800, .height = 600 }, .cursor_pos = .{ .x = -1, .y = -1 }, - .keymap_state = .{}, }; // Add ourselves to the list of surfaces on the app. @@ -992,6 +797,13 @@ pub const Surface = struct { }; } + pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) void { + _ = self.core_surface.preeditCallback(preedit_) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return; + }; + } + pub fn textCallback(self: *Surface, text: []const u8) void { _ = self.core_surface.textCallback(text) catch |err| { log.err("error in key callback err={}", .{err}); @@ -1082,7 +894,6 @@ pub const Inspector = struct { surface: *Surface, ig_ctx: *cimgui.c.ImGuiContext, backend: ?Backend = null, - keymap_state: input.Keymap.State = .{}, content_scale: f64 = 1, /// Our previous instant used to calculate delta time for animations. @@ -1328,11 +1139,13 @@ pub const CAPI = struct { const KeyEvent = extern struct { action: input.Action, mods: c_int, + consumed_mods: c_int, keycode: u32, text: ?[*:0]const u8, + unshifted_codepoint: u32, composing: bool, - /// Convert to surface key event. + /// Convert to Zig key event. fn keyEvent(self: KeyEvent) App.KeyEvent { return .{ .action = self.action, @@ -1340,8 +1153,13 @@ pub const CAPI = struct { input.Mods.Backing, @truncate(@as(c_uint, @bitCast(self.mods))), )), + .consumed_mods = @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(self.consumed_mods))), + )), .keycode = self.keycode, .text = if (self.text) |ptr| std.mem.sliceTo(ptr, 0) else null, + .unshifted_codepoint = self.unshifted_codepoint, .composing = self.composing, }; } @@ -1447,15 +1265,7 @@ pub const CAPI = struct { app: *App, event: KeyEvent, ) bool { - var buf: [128]u8 = undefined; - const core_event = app.coreKeyEvent( - &buf, - .app, - event.keyEvent(), - ) catch |err| { - log.warn("error processing key event err={}", .{err}); - return false; - } orelse { + const core_event = event.keyEvent().core() orelse { log.warn("error processing key event", .{}); return false; }; @@ -1701,20 +1511,7 @@ pub const CAPI = struct { surface: *Surface, event: KeyEvent, ) bool { - var buf: [128]u8 = undefined; - const core_event = surface.app.coreKeyEvent( - &buf, - // Note: this "app" target here looks like a bug, but it is - // intentional. coreKeyEvent uses the target only as a way to - // trigger preedit callbacks for keymap translation and we don't - // want to trigger that here. See the todo item in coreKeyEvent - // for a long term solution to this and removing target altogether. - .app, - event.keyEvent(), - ) catch |err| { - log.warn("error processing key event err={}", .{err}); - return false; - } orelse { + const core_event = event.keyEvent().core() orelse { log.warn("error processing key event", .{}); return false; }; @@ -1733,6 +1530,16 @@ pub const CAPI = struct { surface.textCallback(ptr[0..len]); } + /// Set the preedit text for the surface. This is used for IME + /// composition. If the length is 0, then the preedit text is cleared. + export fn ghostty_surface_preedit( + surface: *Surface, + ptr: [*]const u8, + len: usize, + ) void { + surface.preeditCallback(if (len == 0) null else ptr[0..len]); + } + /// Returns true if the surface currently has mouse capturing /// enabled. export fn ghostty_surface_mouse_captured(surface: *Surface) bool { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 5fcb0d42b..129c149e7 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -811,16 +811,19 @@ fn gtkWindowUpdateScaleFactor( }; } -// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab -// sends an undefined value. -fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void { +/// Perform a binding action on the window's action surface. +fn performBindingAction(self: *Window, action: input.Binding.Action) void { const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_tab = {} }) catch |err| { + _ = surface.performBindingAction(action) catch |err| { log.warn("error performing binding action error={}", .{err}); return; }; } +fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void { + self.performBindingAction(.{ .new_tab = {} }); +} + /// Create a new tab from the AdwTabOverview. We can't copy gtkTabNewClick /// because we need to return an AdwTabPage from this function. fn gtkNewTabFromOverview(_: *adw.TabOverview, self: *Window) callconv(.c) *adw.TabPage { @@ -1007,11 +1010,7 @@ fn gtkActionNewWindow( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_window = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .new_window = {} }); } fn gtkActionNewTab( @@ -1019,8 +1018,7 @@ fn gtkActionNewTab( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - // We can use undefined because the button is not used. - gtkTabNewClick(undefined, self); + self.performBindingAction(.{ .new_tab = {} }); } fn gtkActionCloseTab( @@ -1028,11 +1026,7 @@ fn gtkActionCloseTab( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .close_tab = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .close_tab = {} }); } fn gtkActionSplitRight( @@ -1040,11 +1034,7 @@ fn gtkActionSplitRight( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_split = .right }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .new_split = .right }); } fn gtkActionSplitDown( @@ -1052,11 +1042,7 @@ fn gtkActionSplitDown( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_split = .down }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .new_split = .down }); } fn gtkActionSplitLeft( @@ -1064,11 +1050,7 @@ fn gtkActionSplitLeft( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_split = .left }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .new_split = .left }); } fn gtkActionSplitUp( @@ -1076,11 +1058,7 @@ fn gtkActionSplitUp( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .new_split = .up }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .new_split = .right }); } fn gtkActionToggleInspector( @@ -1088,11 +1066,7 @@ fn gtkActionToggleInspector( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .inspector = .toggle }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .inspector = .toggle }); } fn gtkActionCopy( @@ -1100,11 +1074,7 @@ fn gtkActionCopy( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .copy_to_clipboard = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .copy_to_clipboard = {} }); } fn gtkActionPaste( @@ -1112,11 +1082,7 @@ fn gtkActionPaste( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .paste_from_clipboard = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .paste_from_clipboard = {} }); } fn gtkActionReset( @@ -1124,11 +1090,7 @@ fn gtkActionReset( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .reset = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .reset = {} }); } fn gtkActionClear( @@ -1136,11 +1098,7 @@ fn gtkActionClear( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .clear_screen = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .clear_screen = {} }); } fn gtkActionPromptTitle( @@ -1148,11 +1106,7 @@ fn gtkActionPromptTitle( _: ?*glib.Variant, self: *Window, ) callconv(.C) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(.{ .prompt_surface_title = {} }) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; + self.performBindingAction(.{ .prompt_surface_title = {} }); } /// Returns the surface to use for an action. diff --git a/src/input/key.zig b/src/input/key.zig index f9db4a04a..ec65170f2 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -150,21 +150,25 @@ pub const Mods = packed struct(Mods.Backing) { /// like macos-option-as-alt. The translation mods should be used for /// translation but never sent back in for the key callback. pub fn translation(self: Mods, option_as_alt: config.OptionAsAlt) Mods { - // We currently only process macos-option-as-alt so other - // platforms don't need to do anything. - if (comptime !builtin.target.os.tag.isDarwin()) return self; + var result = self; - // Alt has to be set only on the correct side - switch (option_as_alt) { - .false => return self, - .true => {}, - .left => if (self.sides.alt == .right) return self, - .right => if (self.sides.alt == .left) return self, + // Control is never used for translation. + result.ctrl = false; + + // macos-option-as-alt for darwin + if (comptime builtin.target.os.tag.isDarwin()) alt: { + // Alt has to be set only on the correct side + switch (option_as_alt) { + .false => break :alt, + .true => {}, + .left => if (self.sides.alt == .right) break :alt, + .right => if (self.sides.alt == .left) break :alt, + } + + // Unset alt + result.alt = false; } - // Unset alt - var result = self; - result.alt = false; return result; } @@ -186,6 +190,14 @@ pub const Mods = packed struct(Mods.Backing) { ); } + test "translation removes control" { + const testing = std.testing; + + const mods: Mods = .{ .ctrl = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{}, result); + } + test "translation macos-option-as-alt" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest;