Skip to content

Fix intermittent frameless title-bar drag on macOS#2576

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-frameless-title-drag
May 12, 2026
Merged

Fix intermittent frameless title-bar drag on macOS#2576
ten9876 merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-frameless-title-drag

Conversation

@rfoust

@rfoust rfoust commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a shared frameless window move helper for custom title bars
  • use a manual top-level widget move path on macOS instead of relying on repeated QWindow::startSystemMove() calls
  • keep the native startSystemMove() path on non-macOS platforms, with a manual fallback when Qt refuses the native move request

Bug

On macOS, frameless windows could intermittently stop responding to title-bar drag attempts. A user could successfully drag a frameless window once, then immediately trying to drag it again would do nothing for roughly 5-10 seconds before it began working again.

The click position within the title bar was not the trigger. The issue was that several custom title bars called QWindow::startSystemMove() on mouse press, consumed the event, and did not check or recover when Qt/macOS did not actually enter a native move operation. When that native move handoff stalled or was refused, the mouse press was swallowed and there was no manual drag fallback, making the title bar feel dead until the platform state recovered.

Fix

This PR centralizes frameless title-bar movement in FramelessMoveHelper:

  • macOS always uses a manual move path: record the initial global mouse position and window position on press, move the top-level widget on mouse move, and release the mouse grab on button release.
  • Linux/Windows still try QWindow::startSystemMove() first so compositor/window-manager managed moves remain unchanged there.
  • If startSystemMove() returns false on non-macOS platforms, the helper falls back to the same manual movement path.

The main TitleBar already had a manual fallback, so this PR disables the macOS native move handoff there and applies the same helper behavior to the reusable frameless title bars and custom frameless dialog/pop-out title bars.

Testing

  • Built successfully on macOS with: cmake --build build
  • Launched the rebuilt app from the CLI and verified repeated frameless title-bar drags no longer enter the temporary non-draggable state.

Copilot AI review requested due to automatic review settings May 11, 2026 08:03
@rfoust rfoust requested review from jensenpat and ten9876 as code owners May 11, 2026 08:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses intermittent “dead” title-bar dragging for frameless windows on macOS by centralizing drag-to-move behavior in a shared helper that uses a manual top-level widget move path on macOS (and as a fallback when Qt refuses a native system move).

Changes:

  • Added FramelessMoveHelper to unify frameless window movement (manual move on macOS; startSystemMove() elsewhere with manual fallback).
  • Updated multiple custom title bars / frameless dialogs / pop-outs to use the helper for consistent drag behavior.
  • Disabled the macOS startSystemMove() path in the main TitleBar to avoid intermittent refusal/stall behavior.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/gui/TitleBar.cpp Disables native startSystemMove() on macOS so the existing manual-move path is always used there.
src/gui/StripFinalOutputPanel.cpp Switches the Quindar editor dialog’s inline title-bar drag handling to FramelessMoveHelper.
src/gui/PanadapterApplet.cpp Routes floating pop-out title-bar drag handling through FramelessMoveHelper.
src/gui/NetworkDiagnosticsDialog.cpp Updates custom title-bar drag handling to use FramelessMoveHelper.
src/gui/FramelessWindowTitleBar.h Adds move/release overrides to support the new shared drag helper.
src/gui/FramelessWindowTitleBar.cpp Replaces direct startSystemMove() usage with FramelessMoveHelper (including maximize toggle helper).
src/gui/FramelessMoveHelper.h Introduces shared logic for native system-move (non-macOS) with manual-move fallback and macOS manual move.
src/gui/EditorFramelessTitleBar.h Adds move/release overrides and updates behavior docs for macOS manual move.
src/gui/EditorFramelessTitleBar.cpp Replaces direct startSystemMove() usage with FramelessMoveHelper (including maximize toggle helper).
src/gui/containers/ContainerTitleBar.h Adds mouseReleaseEvent override to support finishing manual drags.
src/gui/containers/ContainerTitleBar.cpp Uses FramelessMoveHelper for floating-window drags; adds release handling for the helper.
src/gui/AetherialAudioStrip.cpp Updates custom title-bar drag handling to use FramelessMoveHelper.
src/gui/AetherDspDialog.cpp Updates custom title-bar drag handling to use FramelessMoveHelper.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

FramelessMoveHelper::finish(this, ev);
setCursor(Qt::OpenHandCursor);
return;
}
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Hi @rfoust — thanks for putting the work into the shared FramelessMoveHelper refactor, this is a nice cleanup. 🙌

CI status: this failure is on us, not on your code

The only red check is "Cleanup artifacts" → step "Delete artifacts". That's a workflow-internal teardown step that runs gh api ... -X DELETE against the run's own artifacts after the build finishes; failures there are almost always GitHub API hiccups (rate-limit, permission, or transient 5xx) and have nothing to do with the diff. The substantive jobs on this run are healthy:

Job Status
Prepare ✅ success
Agent ✅ success
Upload results ✅ success
check-paths ✅ success
build (Windows) ⏳ still in progress
analyze (cpp) (CodeQL) ⏳ still in progress
Cleanup artifacts ❌ failed (infrastructure)

A reviewer can safely re-run the workflow to clear the red mark, or just ignore it once the build/analyze jobs finish green. No change is needed on your branch for this.

One real thing worth fixing while you're here

Copilot flagged a legitimate issue in src/gui/containers/ContainerTitleBar.cpp that the diff introduced — your new mouseReleaseEvent handles the floating path but the docked (!m_isFloating) path falls straight through to QWidget::mouseReleaseEvent without ever clearing m_pressed or restoring the cursor that mousePressEvent set to Qt::ClosedHandCursor. With the press setting state and the release not clearing it, a click-without-drag in docked mode leaves the title bar stuck in the pressed/closed-hand state until the next press.

Roughly:

void ContainerTitleBar::mouseReleaseEvent(QMouseEvent* ev)
{
    if (m_isFloating) {
        FramelessMoveHelper::finish(this, ev);
        setCursor(Qt::OpenHandCursor);
        return;
    }
    if (ev->button() == Qt::LeftButton) {
        m_pressed = false;
        setCursor(Qt::OpenHandCursor);
        ev->accept();
        return;
    }
    QWidget::mouseReleaseEvent(ev);
}

That mirrors what the floating branch is doing (release mouse-state + restore cursor) and matches the symmetry of the existing mousePressEvent in the docked branch.

Unrelated to the CI red — pushing that fix won't re-trigger or resolve the artifact-cleanup failure, but it would be a good follow-up commit on this PR. Thanks again for cleaning up the duplicated startSystemMove() boilerplate across so many title bars.

@rfoust

rfoust commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

Updated the branch after testing the ContainerTitleBar follow-up locally. The PR now contains a single squashed commit: 1664002.

The review concern was valid: the new docked ContainerTitleBar release path could leave m_pressed true and the cursor in Qt::ClosedHandCursor after a click-without-drag.

I initially tried clearing that state directly from mouseReleaseEvent, but that was too intrusive for the docked drag path. The final version lets QWidget process the release normally first, then queues a zero-delay cleanup that only runs if the title bar is still docked, m_pressed is still set, and the left mouse button is no longer down. That addresses the stale pressed/cursor state without consuming or changing the release event sequence used by docked click-dragging.

Validation:

  • cmake --build build --target container_widget_test container_manager_test container_nesting_test
  • cmake --build build --target AetherSDR
  • manual macOS test confirmed repeated frameless title-bar dragging works

No branch change was made for the Cleanup artifacts CI failure, since that was identified as workflow/infrastructure noise rather than a code issue.

@rfoust rfoust force-pushed the codex/fix-frameless-title-drag branch from 4f4534b to 1664002 Compare May 11, 2026 09:37

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into this — the root-cause analysis (consumed press + no fallback when startSystemMove() is silently refused on macOS) matches what the diff fixes. The centralization into FramelessMoveHelper is a nice cleanup; previously every frameless title bar reimplemented the same windowHandle()->startSystemMove() snippet.

A few observations:

Copilot's ContainerTitleBar concern is addressed but worth a second look. The new mouseReleaseEvent does reset m_pressed / OpenHandCursor for the docked path, but only via QTimer::singleShot(0, ...). Why deferred rather than just resetting synchronously on left-button release? src/gui/containers/ContainerTitleBar.cpp:189-205:

QTimer::singleShot(0, this, [this]() {
    if (!m_isFloating && m_pressed
        && !(QGuiApplication::mouseButtons() & Qt::LeftButton)) {
        m_pressed = false;
        setCursor(Qt::OpenHandCursor);
    }
});

The mouseButtons() & LeftButton guard suggests defending against a reentrancy case, but for a plain click-without-drag in docked mode the synchronous reset is straightforward — happy to leave the async version if there's a specific scenario it's protecting against, but a one-line comment explaining what would help.

FramelessMoveHelper.h storing state in QWidget properties is a slightly unusual pattern but works. Worth noting: the helper grabs the mouse on handle (the title bar widget) on macOS, and properties live on that same widget — so if two frameless title bars share a state somehow, they'd collide, but that doesn't happen in practice since each is its own widget.

One subtle behavior change worth confirming: previously in AetherDspDialog, NetworkDiagnosticsDialog, AetherialAudioStrip, and PanadapterApplet, the press handler only consumed the event if windowHandle() existed; otherwise it fell through to the dialog's base handler. The new FramelessMoveHelper::start() always consumes a left-button press on a valid window via the manual path. That's the intended fix on macOS, but on non-macOS if startSystemMove() returns false, the helper now silently swallows the press into a manual move where the old code would have done nothing. That's arguably an improvement (no more dead title bars on WMs that refuse system move), but it does mean these dialogs are slightly more "draggy" on Linux/Windows than before — probably what you want, just flagging.

The TitleBar.cpp change is minimal and clean#ifndef Q_OS_MAC around the startSystemMove() call inside an already-manual-fallback-capable codepath. m_windowMoveUsesSystem stays false on macOS and the existing manual path runs.

Otherwise nothing blocking. Build is GUI-only and the manual-move math (startPos + globalDelta) is straightforward. Thanks @rfoust!

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude here, reviewing on Jeremy's behalf.

Verdict

Strong merge. Real macOS-only frameless-drag bug, sound architectural diagnosis, clean centralized helper. Built locally without errors. The one stale-code finding I'd flag (ContainerWidget.cpp:154) appears to be unreachable in the current title-bar state machine — worth a follow-up note, but not a blocker.

Architectural diagnosis

The PR description correctly identifies the root cause: QWindow::startSystemMove() is unreliable on macOS for frameless windows. When Qt/macOS refuses or stalls the native handoff, the mouse press is already consumed and there's no recovery path, so the window appears unresponsive for 5–10 seconds while platform state resolves.

The fix architecture:

  • macOS: skip startSystemMove() entirely; always use the manual grabMouse() + window->move() path
  • Linux/Windows: try native move first (compositor/WM-managed behavior matters there); fall back to manual if Qt returns false
  • One centralized helper so the same logic doesn't drift across the 6+ title-bar implementations

This is the right design call. Manual move on macOS gives up the OS animation polish that native move provides, but in exchange you get a drag that actually works every time. That's a clearly correct tradeoff for frameless windows where the native title bar isn't visible anyway.

Verified

  • All 6 frameless title bars migrated to the helper: EditorFramelessTitleBar, FramelessWindowTitleBar, AetherDspDialog, AetherialAudioStrip, NetworkDiagnosticsDialog, PanadapterApplet, StripFinalOutputPanel, ContainerTitleBar — all use FramelessMoveHelper::{start,move,finish} consistently.
  • Main TitleBar keeps its custom logic but adds the Q_OS_MAC guard at line 471-474, matching the helper's behavior. PR description correctly explains this scope choice — TitleBar has additional drag-handle hit-testing complexity that would be invasive to migrate.
  • CI all green: build, analyze (cpp), check-paths, CodeQL, check-windows. Local build clean (only pre-existing unrelated warning).
  • Inline header-only helper — sensible for utility code at this size; each function is < 20 lines.
  • State via Qt dynamic properties (_aetherManualWindowMoveActive, _aetherManualWindowMovePressGlobal, _aetherManualWindowMoveStartPos) — gets automatic cleanup when widgets are destroyed. Slightly unusual vs. a member-struct approach, but works. The alternative would be a static QHash<QWidget*, MoveState> which would need destruction wiring.
  • mouseButtons() defensive recovery at FramelessMoveHelper.h:76-78 — if the user releases the button without the helper noticing, move() short-circuits to finish(). Good defensive pattern.

One stale-code finding worth noting (not a blocker)

src/gui/containers/ContainerWidget.cpp:154 still calls h->startSystemMove() unconditionally without a return-value check, fallback, or Q_OS_MAC guard:

void ContainerWidget::onTitleBarDragStart(const QPoint&) {
    if (!m_titleBar) return;
    if (m_dockMode == DockMode::Floating) {
        if (auto* w = window())
            if (auto* h = w->windowHandle()) {
                h->startSystemMove();   // ← unguarded; same pattern the PR is removing
                return;
            }
    }
    ...
}

Tracing: ContainerTitleBar::mousePressEvent (the migrated one) does an early return when m_isFloating == true and never sets m_pressed. mouseMoveEvent then short-circuits without emitting dragStartRequested. So ContainerWidget::onTitleBarDragStart is only reachable in the docked (non-floating) case — where m_dockMode == DockMode::Floating is false and the startSystemMove() branch is skipped.

That makes the line currently dead in the floating state but live in the docked state's fall-through (which doesn't enter that branch). I.e., the line at 154 isn't reachable in practice given the current state machine, but it would re-emerge if a future refactor lets m_isFloating and m_dockMode == Floating desync.

Worth a one-line follow-up either (a) removing the dead branch, or (b) routing through FramelessMoveHelper::start() to mirror the rest of the codebase. Not blocking this PR — the bug class this PR addresses doesn't reach that line.

Stale-code audit summary

Site Status
FramelessMoveHelper.h New helper, correct
EditorFramelessTitleBar Migrated to helper ✓
FramelessWindowTitleBar Migrated to helper ✓
ContainerTitleBar Migrated to helper ✓
AetherDspDialog event filter Routes to helper ✓
AetherialAudioStrip event filter Routes to helper ✓
NetworkDiagnosticsDialog event filter Routes to helper ✓
PanadapterApplet event filter Routes to helper ✓
StripFinalOutputPanel event filter Routes to helper ✓
TitleBar (main) Kept custom logic; Q_OS_MAC guard added ✓
ContainerWidget.cpp:154 Stale, dead but worth follow-up cleanup
FramelessResizer.h:12 comment Pre-existing comment referencing other call sites — informational only
EditorFramelessTitleBar.h:15 comment Documents the manual-move-on-macOS rationale — accurate after this PR

What I'd want from Jeremy

Per feedback_test_before_commit.md, manual verification on macOS before merge — open a frameless dialog (NetworkDiagnosticsDialog is the easiest), drag it once, release, immediately try to drag again. Pre-PR this would hit the 5–10 second dead zone; post-PR should work continuously. Worth eyeballing the AetherialAudioStrip + popped-out applet panels too since both use the new helper.

Linux/Windows regression check: the startSystemMove() path on those platforms still runs first per the helper's logic, so existing compositor-managed drag behavior is preserved. Worth a quick "drag still feels native on Linux/Windows" check.

Recommendation

Merge after macOS verification. The ContainerWidget.cpp:154 cleanup is a small follow-up worth filing as an issue (or just doing inline).

Thanks @rfoust — the diagnosis (Qt's native-move handoff "stalls or is refused" rather than failing cleanly) is the kind of platform-quirk knowledge that's hard to find without firsthand pain, and the centralized helper means the next title bar someone writes can't accidentally regress this.

73, Jeremy KK7GWY & Claude (AI dev partner)

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — centralized helper, all 6 frameless title bars consistent, built clean locally.

@ten9876 ten9876 merged commit b3bd9e8 into aethersdr:main May 12, 2026
5 checks passed
ten9876 pushed a commit that referenced this pull request May 14, 2026
…2645)

Removes the unreachable startSystemMove() branch from ContainerWidget::onTitleBarDragStart() along with the orphaned <QWindow> include. -13 / +0.

State-machine trace: ContainerTitleBar::mousePressEvent early-returns when m_isFloating without setting m_pressed; mouseMoveEvent short-circuits on !m_pressed; dragStartRequested only emits when not floating. The m_dockMode == DockMode::Floating branch inside the handler was therefore unreachable.

If floating-mode container drag returns, the correct integration point is FramelessMoveHelper (consistent with every other title bar after #2576) — not the bare QWindow::startSystemMove primitive removed here. Leaving an unguarded primitive as a "just in case" invites the macOS 5-10s dead-zone bug back.

Closes #2601.

Co-Authored-By: Ozy311 <Ozy311@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants