Radial: dual drag/click interaction, open at cursor without moving caret

The radial now supports both intended interactions from one widget:
- Hold-and-drag: opened with a button held, drag toward a slice and release
  to trigger the slice under the cursor (far flicks allowed).
- Release-and-click: a quick press/release that never leaves the centre dead
  zone switches to click mode and keeps the menu open; then hover and click a
  slice to trigger. A click outside the ring cancels instead of snapping.

Armed state is detected from QApplication::mouseButtons() at popup time, so
key-triggered radials start directly in click mode. mousePressEvent is
accepted to stop a Qt::Popup from self-dismissing on an in-widget press. The
menu opens centred on the given point; the RMB press is consumed upstream so
the caret does not move. All 5 ctest suites green.
This commit is contained in:
Levi Neely 2026-10-07 16:52:15 +02:00
parent 4be3ead30d
commit 9ecfd379c2
3 changed files with 67 additions and 12 deletions

View File

@ -88,8 +88,12 @@ Therefore the palette is replaced, not extended, and the replacement is built on
dead zone, far-flick snaps by angle, descend/ascend navigation. 14 tests.
- `src/radial/radialmenu.{h,cpp}`: frameless translucent `QWidget`; paints the
current ring (wedges, labels, branch ▸, central "back" hub), highlights the
slice under the pointer, release/click activates a leaf, descends a branch, or
ascends/cancels from the dead zone; Esc ascends/cancels.
slice under the pointer. Two interaction modes from one widget: a **drag
gesture** (opened with a button held — drag out and release to select; a
release that never left the dead zone switches to click mode and keeps the
menu open) and a **discrete click mode** (click a slice to select, click
outside the ring to cancel). Esc ascends/cancels. Opens centred on the given
point without moving the caret.
- `src/radial/radialconfig.{h,cpp}`: JSON → radial tree parser (+ built-in
default) so multiple radials can be bound to different triggers. 8 tests.
- Wired into the plugin: `OllieView::setupRadials()` registers a key trigger

View File

@ -5,6 +5,7 @@
#include <QApplication>
#include <QKeyEvent>
#include <QLineF>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
@ -38,6 +39,11 @@ void RadialMenu::popup(const RadialNode &root, const QPoint &globalCenter)
{
m_model = std::make_unique<RadialModel>(root);
m_hoverSlice = RadialModel::InvalidSlice;
// If a mouse button is held as we open (the RMB gesture), this is a
// drag-gesture candidate: a release that drags out selects, a release in
// place switches to discrete click mode. Opened from a key (no button) we
// start directly in click mode.
m_armed = (QApplication::mouseButtons() != Qt::NoButton);
// Size the widget to a square large enough for the outer ring plus labels,
// positioned so the ring centre lands on globalCenter — clamped to the
@ -149,17 +155,54 @@ void RadialMenu::mouseMoveEvent(QMouseEvent *event)
}
}
void RadialMenu::mouseReleaseEvent(QMouseEvent *event)
void RadialMenu::mousePressEvent(QMouseEvent *event)
{
handleActivationAt(event->position());
// Accept presses so a Qt::Popup does not treat an in-widget press as an
// outside click and dismiss itself. Selection happens on release.
event->accept();
}
void RadialMenu::handleActivationAt(const QPointF &localPos)
void RadialMenu::mouseReleaseEvent(QMouseEvent *event)
{
if (!m_model) {
return;
}
const int slice = m_model->sliceAt(localPos, currentLayout());
const QPointF pos = event->position();
if (m_armed) {
// End of the initial hold from the opening gesture.
m_armed = false;
const int slice = m_model->sliceAt(pos, currentLayout());
if (slice == RadialModel::InvalidSlice) {
// Released in place without dragging out: treat the gesture as a
// click-to-open and stay open for discrete clicks.
m_hoverSlice = RadialModel::InvalidSlice;
update();
return;
}
// Dragged out to a slice: select immediately (far flicks allowed).
handleActivationAt(pos, /*allowFarFlick=*/true);
return;
}
// Discrete click mode: require the click to land on the ring; a click
// beyond the outer edge dismisses rather than snapping by angle.
handleActivationAt(pos, /*allowFarFlick=*/false);
}
void RadialMenu::handleActivationAt(const QPointF &localPos, bool allowFarFlick)
{
if (!m_model) {
return;
}
int slice = m_model->sliceAt(localPos, currentLayout());
if (!allowFarFlick && slice != RadialModel::InvalidSlice) {
const double dist = QLineF(m_center, localPos).length();
if (dist > m_outerRadius) {
slice = RadialModel::InvalidSlice; // outside the ring => cancel/ascend
}
}
QString id;
switch (m_model->resolve(slice, id)) {
case RadialModel::Hit::Activate:

View File

@ -3,11 +3,14 @@
*
* RadialMenu — a frameless, multi-level radial (pie) menu widget.
*
* Shows the current ring of a RadialModel centred on a screen point. The slice
* under the pointer is highlighted; releasing over it activates a leaf (emits
* activatedId) or descends into a branch. Releasing in the central dead zone
* ascends one level, or cancels at the root. Works both as a gesture
* (press-drag-release) and as discrete clicks.
* Shows the current ring of a RadialModel centred on a screen point. Two
* interaction modes are supported from a single widget:
* - Drag gesture: opened with a mouse button held (the RMB gesture), the user
* drags toward a slice and releases to select; a release that never left the
* central dead zone instead switches to click mode (the menu stays open).
* - Click mode: discrete hover + click. A click on a slice selects, a click
* outside the ring cancels, the central hub goes back one level or cancels.
* Esc ascends one level, or cancels at the root.
*/
#ifndef KATECUSTOM_RADIALMENU_H
#define KATECUSTOM_RADIALMENU_H
@ -41,19 +44,24 @@ Q_SIGNALS:
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
RadialLayout currentLayout() const;
void handleActivationAt(const QPointF &localPos);
// allowFarFlick: when true, a point beyond the outer radius still selects
// the slice in that direction (confident drag-flick). When false, such a
// point is treated as "outside" and cancels/ascends (discrete click mode).
void handleActivationAt(const QPointF &localPos, bool allowFarFlick);
void finishActivated(const QString &id);
void finishCancelled();
std::unique_ptr<RadialModel> m_model;
QPointF m_center; // local widget coordinates of the ring centre
int m_hoverSlice = RadialModel::InvalidSlice;
bool m_armed = false; // opened with a button held => drag-gesture candidate
double m_innerRadius = 36.0;
double m_outerRadius = 130.0;