Skip to content

[fix] Frameless floating windows: resize, state persistence, geometry#2008

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/frameless-floating-window-state
Apr 26, 2026
Merged

[fix] Frameless floating windows: resize, state persistence, geometry#2008
ten9876 merged 1 commit into
aethersdr:mainfrom
chibondking:fix/frameless-floating-window-state

Conversation

@chibondking

Copy link
Copy Markdown
Collaborator

What changed

New: FramelessResizer (src/gui/FramelessResizer.h/.cpp)

Added a reusable QObject event-filter that provides all-8-edge resize for any Qt::FramelessWindowHint top-level window using QWindow::startSystemResize() — the same compositor-managed path already used for drag-to-move via startSystemMove().

Previously, frameless pop-out windows (panadapters and applets) only had a QSizeGrip in the bottom-right corner. On Linux/Wayland this grip often does nothing. The new resizer installs itself recursively on the window and all child widgets (picking up new children via ChildAdded events), translates cursor positions to window-local coordinates, detects which of the 8 edges/corners the mouse is near (within 6 px), updates the cursor shape, and calls startSystemResize() on press. When the window is not frameless the resizer is a no-op so it does not interfere with native decorations.

The old QSizeGrip rows in PanFloatingWindow and FloatingContainerWindow were removed since the resizer supersedes them.


Fix 1: Frameless mode toggle not propagating to floating windows

Symptom: Switching View -> Frameless Window updated the main window chrome but left any already-open floating panadapter or applet windows unchanged.

Root cause: setFramelessWindow() in MainWindow only modified its own Qt::FramelessWindowHint flag. PanFloatingWindow and FloatingContainerWindow had the flag hardcoded at construction and were never updated.

Changes:

  • PanFloatingWindow constructor now reads AppSettings["FramelessWindow"] instead of hardcoding Qt::FramelessWindowHint.
  • PanFloatingWindow::setFramelessMode(bool) added — same setWindowFlags + setGeometry + show() pattern as MainWindow::setFramelessWindow.
  • FloatingContainerWindow constructor same fix.
  • FloatingContainerWindow::setFramelessMode(bool) added.
  • PanadapterStack::setFramelessMode(bool) added — iterates all live floating windows and calls setFramelessMode on each.
  • ContainerManager::setFramelessMode(bool) added — same for applet windows.
  • MainWindow::setFramelessWindow() now calls m_panStack->setFramelessMode() and containerManager()->setFramelessMode() after updating itself.

Fix 2: Floating panadapter state not saved or restored across restarts

Symptom: Popped-out panadapters came back docked after every restart.

Root cause: PanadapterStack saved window geometry (position/size) but never recorded which pans were floating. There was no restore path.

Changes:

  • PanadapterStack::saveFloatingState() — writes the comma-separated list of currently-floating pan IDs to AppSettings["FloatingPanIds"]. Called automatically from floatPanadapter(), dockPanadapter(), and prepareShutdown().
  • PanadapterStack::restoreFloatingState() — reads "FloatingPanIds" and calls floatPanadapter() for each ID that exists and is not already floating. Safe on reconnect (unknown IDs are skipped, already-floating pans are no-ops).
  • MainWindow m_layoutRestoreTimer lambda: previously did an early return for single-pan sessions, blocking any restore. Restructured to always call restoreFloatingState() regardless of pan count after layout settles (~1 s after radio connect).

Fix 3: Floating applet state not restored across restarts

Symptom: Popped-out applet containers (EQ, S-Meter, etc.) came back docked after every restart.

Root cause: ContainerManager::restoreState() was fully implemented and ContainerManager::saveState() was already called on every float/dock transition, but restoreState() was never called anywhere. The ContainerTree JSON key was written at runtime but never read back on launch.

Changes:

  • ContainerManager::prepareShutdown() now calls saveState() first, guaranteeing the ContainerTree JSON reflects the final floating/visibility state before windows close — previously a crash or force-quit could leave it stale.
  • MainWindow: after m_appletPanel = new AppletPanel(splitter), a QTimer::singleShot(0) schedules containerManager()->restoreState(). The one-event-loop delay lets AppletPanel's own legacy-migration singleShot(0) timers fire first (they write ContainerTree for pre-container-system saves), then restoreState() reads the up-to-date JSON and re-floats containers.

Fix 4: Floating window geometry wrong after frameless mode change

Symptom: A window saved while frameless would appear at an offset position after switching to decorated mode (or vice versa), by roughly the height of the OS title bar.

Root cause: QWidget::saveGeometry() serialises frame-extent data (the platform's window decoration size) alongside the window position. restoreGeometry() then applies those frame extents when placing the window. If the decoration mode has changed between save and restore, the wrong frame size is applied, shifting the window by the title bar height (~30 px on most Linux/Windows themes).

Changes (PanFloatingWindow + FloatingContainerWindow):

  • saveWindowGeometry() / saveGeometryToKey() now store plain "x,y,w,h" from geometry() (the client-area rect, always frame-agnostic) instead of the opaque QWidget::saveGeometry() blob.
  • restoreWindowGeometry() / restoreAndEnsureVisible() parse the "x,y,w,h" format and call setGeometry() directly — no frame arithmetic, works identically frameless or decorated.
  • Legacy fallback: if the stored value is not four integers (i.e. it is a base64 blob from a build before this fix), the code falls through to the old restoreGeometry() path so existing saves are not lost on upgrade.
  • Screen clamping preserved: if the saved top-left is not on any currently-attached screen, the window is recentred on the primary screen.

Files changed

CMakeLists.txt add FramelessResizer to all targets
src/gui/FramelessResizer.h new: all-edge resize event-filter
src/gui/FramelessResizer.cpp new
src/gui/PanFloatingWindow.h add setFramelessMode()
src/gui/PanFloatingWindow.cpp fix constructor, setFramelessMode,
frame-agnostic geometry save/restore
src/gui/PanadapterStack.h add setFramelessMode, save/restoreFloatingState
src/gui/PanadapterStack.cpp implement above, hook into lifecycle
src/gui/containers/FloatingContainerWindow.h add setFramelessMode()
src/gui/containers/FloatingContainerWindow.cpp fix constructor, setFramelessMode,
frame-agnostic geometry save/restore
src/gui/containers/ContainerManager.h add setFramelessMode()
src/gui/containers/ContainerManager.cpp implement setFramelessMode,
saveState in prepareShutdown
src/gui/MainWindow.cpp propagate frameless toggle,
call restoreState on startup,
restore floating pans in layout timer

## What changed

### New: FramelessResizer (src/gui/FramelessResizer.h/.cpp)

Added a reusable QObject event-filter that provides all-8-edge resize for
any Qt::FramelessWindowHint top-level window using QWindow::startSystemResize()
— the same compositor-managed path already used for drag-to-move via
startSystemMove().

Previously, frameless pop-out windows (panadapters and applets) only had a
QSizeGrip in the bottom-right corner. On Linux/Wayland this grip often does
nothing. The new resizer installs itself recursively on the window and all
child widgets (picking up new children via ChildAdded events), translates
cursor positions to window-local coordinates, detects which of the 8
edges/corners the mouse is near (within 6 px), updates the cursor shape, and
calls startSystemResize() on press. When the window is not frameless the
resizer is a no-op so it does not interfere with native decorations.

The old QSizeGrip rows in PanFloatingWindow and FloatingContainerWindow were
removed since the resizer supersedes them.

---

### Fix 1: Frameless mode toggle not propagating to floating windows

**Symptom:** Switching View -> Frameless Window updated the main window chrome
but left any already-open floating panadapter or applet windows unchanged.

**Root cause:** setFramelessWindow() in MainWindow only modified its own
Qt::FramelessWindowHint flag. PanFloatingWindow and FloatingContainerWindow
had the flag hardcoded at construction and were never updated.

**Changes:**
- PanFloatingWindow constructor now reads AppSettings["FramelessWindow"]
  instead of hardcoding Qt::FramelessWindowHint.
- PanFloatingWindow::setFramelessMode(bool) added — same setWindowFlags +
  setGeometry + show() pattern as MainWindow::setFramelessWindow.
- FloatingContainerWindow constructor same fix.
- FloatingContainerWindow::setFramelessMode(bool) added.
- PanadapterStack::setFramelessMode(bool) added — iterates all live floating
  windows and calls setFramelessMode on each.
- ContainerManager::setFramelessMode(bool) added — same for applet windows.
- MainWindow::setFramelessWindow() now calls m_panStack->setFramelessMode()
  and containerManager()->setFramelessMode() after updating itself.

---

### Fix 2: Floating panadapter state not saved or restored across restarts

**Symptom:** Popped-out panadapters came back docked after every restart.

**Root cause:** PanadapterStack saved window geometry (position/size) but
never recorded which pans were floating. There was no restore path.

**Changes:**
- PanadapterStack::saveFloatingState() — writes the comma-separated list of
  currently-floating pan IDs to AppSettings["FloatingPanIds"]. Called
  automatically from floatPanadapter(), dockPanadapter(), and prepareShutdown().
- PanadapterStack::restoreFloatingState() — reads "FloatingPanIds" and calls
  floatPanadapter() for each ID that exists and is not already floating. Safe
  on reconnect (unknown IDs are skipped, already-floating pans are no-ops).
- MainWindow m_layoutRestoreTimer lambda: previously did an early return for
  single-pan sessions, blocking any restore. Restructured to always call
  restoreFloatingState() regardless of pan count after layout settles (~1 s
  after radio connect).

---

### Fix 3: Floating applet state not restored across restarts

**Symptom:** Popped-out applet containers (EQ, S-Meter, etc.) came back
docked after every restart.

**Root cause:** ContainerManager::restoreState() was fully implemented and
ContainerManager::saveState() was already called on every float/dock
transition, but restoreState() was never called anywhere. The ContainerTree
JSON key was written at runtime but never read back on launch.

**Changes:**
- ContainerManager::prepareShutdown() now calls saveState() first, guaranteeing
  the ContainerTree JSON reflects the final floating/visibility state before
  windows close — previously a crash or force-quit could leave it stale.
- MainWindow: after `m_appletPanel = new AppletPanel(splitter)`, a
  QTimer::singleShot(0) schedules containerManager()->restoreState(). The
  one-event-loop delay lets AppletPanel's own legacy-migration singleShot(0)
  timers fire first (they write ContainerTree for pre-container-system saves),
  then restoreState() reads the up-to-date JSON and re-floats containers.

---

### Fix 4: Floating window geometry wrong after frameless mode change

**Symptom:** A window saved while frameless would appear at an offset position
after switching to decorated mode (or vice versa), by roughly the height of
the OS title bar.

**Root cause:** QWidget::saveGeometry() serialises frame-extent data (the
platform's window decoration size) alongside the window position.
restoreGeometry() then applies those frame extents when placing the window. If
the decoration mode has changed between save and restore, the wrong frame size
is applied, shifting the window by the title bar height (~30 px on most
Linux/Windows themes).

**Changes (PanFloatingWindow + FloatingContainerWindow):**
- saveWindowGeometry() / saveGeometryToKey() now store plain "x,y,w,h" from
  geometry() (the client-area rect, always frame-agnostic) instead of the
  opaque QWidget::saveGeometry() blob.
- restoreWindowGeometry() / restoreAndEnsureVisible() parse the "x,y,w,h"
  format and call setGeometry() directly — no frame arithmetic, works
  identically frameless or decorated.
- Legacy fallback: if the stored value is not four integers (i.e. it is a
  base64 blob from a build before this fix), the code falls through to the old
  restoreGeometry() path so existing saves are not lost on upgrade.
- Screen clamping preserved: if the saved top-left is not on any
  currently-attached screen, the window is recentred on the primary screen.

---

## Files changed

  CMakeLists.txt                                 add FramelessResizer to all targets
  src/gui/FramelessResizer.h                     new: all-edge resize event-filter
  src/gui/FramelessResizer.cpp                   new
  src/gui/PanFloatingWindow.h                    add setFramelessMode()
  src/gui/PanFloatingWindow.cpp                  fix constructor, setFramelessMode,
                                                 frame-agnostic geometry save/restore
  src/gui/PanadapterStack.h                      add setFramelessMode, save/restoreFloatingState
  src/gui/PanadapterStack.cpp                    implement above, hook into lifecycle
  src/gui/containers/FloatingContainerWindow.h   add setFramelessMode()
  src/gui/containers/FloatingContainerWindow.cpp fix constructor, setFramelessMode,
                                                 frame-agnostic geometry save/restore
  src/gui/containers/ContainerManager.h          add setFramelessMode()
  src/gui/containers/ContainerManager.cpp        implement setFramelessMode,
                                                 saveState in prepareShutdown
  src/gui/MainWindow.cpp                         propagate frameless toggle,
                                                 call restoreState on startup,
                                                 restore floating pans in layout timer
@chibondking chibondking requested a review from ten9876 as a code owner April 26, 2026 09:44
@chibondking

Copy link
Copy Markdown
Collaborator Author

@AetherClaude what are your thoughts on this PR?

@aethersdr-agent

Copy link
Copy Markdown
Contributor

This is a really solid PR — four distinct bugs fixed with clean, well-scoped changes and a nice new reusable primitive in FramelessResizer. Some thoughts:

FramelessResizer design — Using startSystemResize() to match the existing startSystemMove() path is the right call. The recursive attachToWidget + ChildAdded listener pattern ensures complete coverage without requiring every child widget to cooperate. The no-op when FramelessWindowHint is absent is a good guard.

Frame-agnostic geometry ("x,y,w,h") — This is the correct fix for the title-bar offset bug. QWidget::saveGeometry() bakes in frame extents that become wrong when the decoration mode changes between save and restore. Storing the client-area rect directly sidesteps it entirely. The legacy base64 fallback is a nice touch for seamless upgrades.

restoreFloatingState() restructuring — The old if (panCount <= 1) return early-exit was quietly blocking single-pan float restore. Wrapping just the layout logic in if (panCount > 1) while letting restoreFloatingState() always run is the minimal fix.

ContainerManager::prepareShutdown() calling saveState() — Good catch. Previously a force-quit could leave the ContainerTree JSON stale, so containers would come back in the wrong state on next launch.

A couple of things I'd flag:

  1. QMouseEvent::pos() deprecation — In Qt 6, pos() is deprecated in favor of position().toPoint(). The FramelessResizer uses me->pos() in both the MouseMove and MouseButtonPress handlers. It still compiles and works (it's a trivial wrapper internally), but depending on your -Wdeprecated flags it could generate warnings. Might be worth switching to me->position().toPoint() while you're in there.

  2. Wayland position caveatsetFramelessMode() saves the geometry rect and calls setGeometry(geom) after setWindowFlags() to preserve window position. On X11/macOS/Windows this works fine, but on Wayland the compositor controls window placement and setGeometry() position coordinates are generally ignored for top-level windows. The window will keep its size but may jump to a compositor-chosen position after a frameless toggle. This is a known Qt/Wayland limitation and not something you can easily fix here — just worth documenting so users on Wayland know to expect it.

  3. QSizeGrip removal from FloatingContainerWindow::takeContainer() — The PR removes the grip row from PanFloatingWindow::adoptApplet() since the resizer supersedes it. Make sure the same removal happened in FloatingContainerWindow::takeContainer(), which currently also creates a QSizeGrip + stretch row (lines 63-71 on main). The PR description lists this file as changed so I expect it's covered, but the diff was truncated before I could verify.

Overall this is well-reasoned work. The fixes are targeted at real root causes rather than papering over symptoms, and the FramelessResizer is a clean abstraction that earns its keep by replacing platform-inconsistent QSizeGrip behavior with compositor-managed resize across the board.

@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.

Nice contribution, @chibondking. The PR description is excellent — thorough root-cause analysis for each fix, and the changes are well-scoped. Some notes after reading the full diff:

Looks good

  • FramelessResizer is clean and well-designed. Using startSystemResize() (the compositor-managed path) is the right call, especially for Wayland. Recursive event filter installation with ChildAdded pickup covers late-added widgets correctly, and the no-op when FramelessWindowHint is absent is a nice safety rail.
  • Frame-agnostic geometry ("x,y,w,h" via geometry() instead of saveGeometry() blobs) is a solid fix for the title-bar-height offset problem. The legacy base64 fallback means existing user settings survive the upgrade — thoughtful.
  • setFramelessMode pattern in both PanFloatingWindow and FloatingContainerWindow correctly snapshots geometry before setWindowFlags (which hides the window internally) and restores it after. Identical pattern to how MainWindow::setFramelessWindow already works.
  • saveFloatingState/restoreFloatingState — the guard m_pans.contains(id) && !m_floatingWindows.contains(id) in restore is safe for reconnect and stale-ID scenarios.
  • prepareShutdown calling saveState() in ContainerManager closes the race where force-quit could leave stale JSON.
  • Layout timer restructure — wrapping the multi-pan logic in if (panCount > 1) and unconditionally calling restoreFloatingState() after is the right fix for single-pan-float restore.

Minor items to consider

  1. FramelessResizer::attachToWidget sets setMouseTracking(true) on every child widget. This is needed for the hover cursor updates, but it's a broad side-effect. If a child widget explicitly disables mouse tracking for performance reasons (unlikely in this codebase, but possible), this would override that. Since the resizer is a no-op when FramelessWindowHint is off, the mouse tracking overhead is always present even in decorated mode. Not a blocker — just worth being aware of.

  2. FloatingContainerWindow::restoreAndEnsureVisible screen clamping diverges slightly from PanFloatingWindow::restoreWindowGeometry. In PanFloatingWindow, you clamp to primary screen center when off-screen. In FloatingContainerWindow, you clamp relative to the anchor widget's screen. Both are reasonable but the inconsistency is worth noting — if a user has all floating windows on a now-disconnected monitor, pan windows jump to primary while container windows jump to the anchor's screen. Probably fine in practice.

  3. ContainerManager::setFramelessMode checks if (win) but PanadapterStack::setFramelessMode does not. The QPointer-style null check in ContainerManager is defensive against a window being destroyed mid-iteration (e.g., from a close event). PanadapterStack uses raw pointers in m_floatingWindows, so if a window were somehow destroyed before being removed from the map, this would crash. Low risk since shutdown ordering looks correct, but a null guard would be consistent.

Overall this is solid work — fixes real UX bugs with the right platform APIs. Thanks for the detailed write-up on each fix. 73!

@ten9876 ten9876 merged commit 46fd798 into aethersdr:main Apr 26, 2026
5 checks passed
@ten9876

ten9876 commented Apr 26, 2026

Copy link
Copy Markdown
Collaborator

Claude here on Jeremy's behalf — heads up that we're reverting this PR (#2042) because of a regression in the Aetherial Audio container after the merge. Multiple applets in the popped-out tx_dsp window stopped responding correctly to mouse events — knob drags, chain-tile drag-to-reorder, and sub-container interactions all broke.

Most likely cause: the new FramelessResizer recursively installs its event filter on every descendant widget (via the initial attachToWidget walk + the ChildAdded handler). For a deeply nested widget tree like the Aetherial Audio container — frameless window → ContainerWidget → 13 sub-containers → applets → knobs/sliders — the filter ends up sitting in front of every control's event stream, and the 6 px edge-detection logic likely intercepts mouse events that the inner controls need.

The other three fixes in the PR are genuinely valuable on their own and don't share this risk:

  • Frameless-mode propagation to floating child windows
  • Floating-pan state persistence across restarts
  • Floating-applet state persistence across restarts
  • Frame-agnostic "x,y,w,h" geometry format

If you'd like to split those four into separate PRs (any subset is welcome) we'd land them quickly. The FramelessResizer piece needs a scope tighten before it can re-land — probably attach only to the top-level window itself, not its descendants, and let the existing startSystemMove path on the title bar continue handling drag. Happy to pair on that whenever you're ready.

Sorry for the round-trip — your diagnosis of all four root causes was solid; this is purely a deployment regression.

73, Jeremy KK7GWY & Claude (AI dev partner)

ten9876 added a commit that referenced this pull request Apr 26, 2026
ten9876 added a commit that referenced this pull request Apr 27, 2026
…windows (#2068)

Originally by @chibondking — popped-out panadapter and applet windows
use Qt::FramelessWindowHint which removes OS-provided resize borders.
Previously a single QSizeGrip in the bottom-right corner; unreliable
on Wayland and only one corner.

FramelessResizer is a small QObject event filter installed on the
QWindow (native handle) — sees mouse events at the platform level
before they're dispatched to the widget hierarchy.  Edge proximity
within 6 px → cursor change + startSystemResize on left-click.
Crucially, never touches the widget event stream, so nested controls
(knobs, drag tiles, chain widget) respond normally even when near
the window edge.  Resolves the regression that caused the v1
implementation (#2008) to be reverted.

Two cursor-leak guards added on top of the cherry-pick:

- Destructor calls leaveEdgeZone() so windows destroyed while at an
  edge don't leak the override cursor onto the desktop.
- The existing leaveEdgeZone() inside the eventFilter Phase 2 early
  return (when the window flips to decorated mode at runtime via
  #2059's setFramelessMode) ensures the override is restored when
  the resizer becomes a no-op.

Closes #2058.

Co-authored-by: CJ Johnson <cj@cedrick.io>
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.

2 participants