qt: parity tier 2 batch 1 — input + child-exited (B15, B23, B24, B27, B28)

Five fixes:

  B23 — Sided modifiers (left vs right) now reported. New
  XkbState::sideBitsForKeycode looks up the keysym for a keycode and
  returns GHOSTTY_MODS_*_RIGHT when it's a right-side modifier
  (Shift_R / Control_R / Alt_R or ISO_Level3_Shift / Super_R / etc.).
  sendKey ORs the result alongside the unsided bits so binds like
  `right_shift+x` can fire. macOS + GTK both populate sided bits.

  B24 — Mouse enter/leave now notify libghostty.
   * enterEvent forwards the actual cursor position so hover state
     and OSC-8 link arming reset from any stale (-1, -1) sentinel.
   * leaveEvent (new override) sends (-1, -1) so any hover-armed
     state clears once the pointer leaves the pane.
  Mirrors macOS mouseEntered/mouseExited and GTK ecMouseLeave.

  B27 — Right-click is now sent to libghostty BEFORE deciding
  whether to open the context menu. The mouse-press fires
  unconditionally; the contextMenuEvent handler still gates on
  mouse_captured, so capturing programs (e.g. mc) still get raw
  clicks. Matches macOS rightMouseDown + menu(for:) and GTK
  mouseButtonCallback's "fire menu only if not consumed."

  B28 — Click-to-focus suppresses the matching mouse-up. New
  m_suppressNextLeftRelease flag set on a press that grabs focus
  from elsewhere, cleared on the matching release. Without this,
  vim/less/etc. would see a stray button-up event right after the
  user clicked the pane to focus it. macOS does the same with
  suppressNextLeftMouseUp; GTK uses suppress_left_mouse_release.

  B15 — SHOW_CHILD_EXITED now respects the abnormal-command-exit-
  runtime config (default 250ms). Banner is suppressed for
  short-lived children (e.g. quick `ls`); macOS does the same gate.

  B40 — window-decoration full enum: documenting that Qt has no
  portable way to force CSD vs SSD on Wayland, so `auto`/`server`/
  `client` all map to "decorated" (the platform decides which).
  No code change needed; the existing comment already says this.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-20 20:23:36 -05:00
parent 4d0e34975a
commit 8e87252745
3 changed files with 88 additions and 5 deletions

View File

@ -603,6 +603,32 @@ public:
return xkb_keysym_to_utf32(sym);
}
// Side bits for the libghostty mods bitfield, derived from a
// keycode — used so that pressing Right-Shift sets BOTH the
// unsided GHOSTTY_MODS_SHIFT and the GHOSTTY_MODS_SHIFT_RIGHT bit
// (a left-side keycode sets only the unsided bit). macOS and GTK
// populate sided bits this way; Qt was leaving them empty so
// bindings that distinguish left-vs-right modifier keys couldn't
// fire.
ghostty_input_mods_e sideBitsForKeycode(uint32_t keycode) const {
if (!m_unshifted) return GHOSTTY_MODS_NONE;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
int r = GHOSTTY_MODS_NONE;
switch (sym) {
case XKB_KEY_Shift_R: r |= GHOSTTY_MODS_SHIFT_RIGHT; break;
case XKB_KEY_Control_R: r |= GHOSTTY_MODS_CTRL_RIGHT; break;
// Both Alt_R and ISO_Level3_Shift (AltGr) are right-Alt physically.
case XKB_KEY_Alt_R:
case XKB_KEY_ISO_Level3_Shift: r |= GHOSTTY_MODS_ALT_RIGHT; break;
case XKB_KEY_Super_R:
case XKB_KEY_Hyper_R:
case XKB_KEY_Meta_R: r |= GHOSTTY_MODS_SUPER_RIGHT; break;
default: break;
}
return static_cast<ghostty_input_mods_e>(r);
}
// Modifiers consumed by the layout to produce `keycode`'s text given
// `mods` are depressed. Returns the consumed subset, expressed as
// ghostty mod bits. Mutates m_query (mutable) — see thread-safety
@ -698,6 +724,12 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
ghostty_input_key_s k = {};
k.action = action;
k.mods = translateMods(ev->modifiers());
// OR in any right-side bit for this keycode (e.g. Right-Shift sets
// SHIFT_RIGHT alongside SHIFT). macOS + GTK populate these; without
// them, keybinds like `right_shift+x` can't distinguish from
// `left_shift+x`.
k.mods = static_cast<ghostty_input_mods_e>(
k.mods | XkbState::instance().sideBitsForKeycode(keycode));
k.keycode = keycode;
k.text = printable ? text.constData() : nullptr;
// XKB lookups: unshifted codepoint (what this physical key would
@ -772,13 +804,30 @@ void GhosttySurface::mousePressEvent(QMouseEvent *ev) {
m_owner->removeSurface(this);
return;
}
// Click-to-focus: if the surface didn't have focus, this click is
// grabbing focus rather than a real interaction with the running
// program. macOS + GTK suppress the matching mouse-up so vim, less,
// etc. don't see a stray button-up event. We mirror that by setting
// a one-shot flag the matching release consults.
const bool wasFocused = hasFocus();
setFocus();
if (rightClickOpensMenu(ev)) return;
if (!wasFocused && ev->button() == Qt::LeftButton)
m_suppressNextLeftRelease = true;
// Right-click: send the press to libghostty BEFORE deciding to
// open the context menu. macOS + GTK both do this so the core can
// word-select on right-press and then we open the menu over the
// selection. If the running program is mouse-captured, the press
// is forwarded as a real button event.
sendMouseButton(ev, GHOSTTY_MOUSE_PRESS);
}
void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) {
if (rightClickOpensMenu(ev)) return;
// Suppress the release of a focus-grabbing click — see press above.
if (ev->button() == Qt::LeftButton && m_suppressNextLeftRelease) {
m_suppressNextLeftRelease = false;
return;
}
sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE);
}
@ -947,9 +996,28 @@ void GhosttySurface::wheelEvent(QWheelEvent *ev) {
flashScrollbar(); // mouse-wheel scrolling reveals the overlay scrollbar
}
void GhosttySurface::enterEvent(QEnterEvent *) {
void GhosttySurface::enterEvent(QEnterEvent *ev) {
// focus-follows-mouse: take focus when the pointer enters this pane.
if (m_owner && m_owner->focusFollowsMouse() && !hasFocus()) setFocus();
// Tell libghostty about the actual cursor position so hover state
// and OSC-8 link arming reset from any stale (-1, -1) sentinel.
// macOS does this in mouseEntered (SurfaceView_AppKit.swift:920);
// GTK does it in ecMouseEnter (apprt/gtk/class/surface.zig).
if (m_surface)
ghostty_surface_mouse_pos(m_surface, ev->position().x(),
ev->position().y(),
translateMods(QGuiApplication::keyboardModifiers()));
}
void GhosttySurface::leaveEvent(QEvent *) {
// libghostty's "no cursor here" sentinel: pass (-1, -1) so any
// hover-armed state (URL underline, mouse-report sequences for an
// OSC-8 link) clears once the pointer leaves the pane. macOS and
// GTK both do this; without it the arm state would survive until
// the next move event.
if (m_surface)
ghostty_surface_mouse_pos(m_surface, -1, -1,
translateMods(QGuiApplication::keyboardModifiers()));
}
void GhosttySurface::focusInEvent(QFocusEvent *) {

View File

@ -131,6 +131,7 @@ protected:
void dropEvent(QDropEvent *) override;
void wheelEvent(QWheelEvent *) override;
void enterEvent(QEnterEvent *) override; // focus-follows-mouse
void leaveEvent(QEvent *) override; // libghostty hover reset
void focusInEvent(QFocusEvent *) override;
void focusOutEvent(QFocusEvent *) override;
@ -206,6 +207,11 @@ private:
bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish
bool m_bellFlash = false; // bell `border` flash in progress
bool m_bellTitle = false; // unacknowledged bell `title` mark
// Set when a left-click grabbed focus from elsewhere; cleared on
// the matching mouse-up so the click that grabbed focus isn't
// also reported to the running program. macOS + GTK do the same
// (suppressNextLeftMouseUp / suppress_left_mouse_release).
bool m_suppressNextLeftRelease = false;
// Last requested cursor shape (from MOUSE_SHAPE) and visibility
// (from MOUSE_VISIBILITY). Tracked separately so toggling
// visibility doesn't reset the shape.

View File

@ -1457,8 +1457,17 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target,
case GHOSTTY_ACTION_SHOW_CHILD_EXITED: {
if (!src) return false;
const int code =
static_cast<int>(action.action.child_exited.exit_code);
const ghostty_surface_message_childexited_s ce =
action.action.child_exited;
// Suppress the banner for fast-exiting children (e.g. an
// intentional `exit 0` after a quick command). Match the macOS
// gate: only show when runtime_ms is at least the configured
// abnormal threshold (default 250ms). Banner = "the process
// died unexpectedly," not "the process exited."
uint32_t threshold = 250;
configGet(s_config, &threshold, "abnormal-command-exit-runtime");
if (ce.runtime_ms < threshold) return true;
const int code = static_cast<int>(ce.exit_code);
post(src, [srcp, code]() {
if (srcp) srcp->showChildExited(code);
});