M4: multi-level radial menu (mouse door), config-driven triggers

- RadialModel: pure tree + geometry (angle hit-test, dead zone, far-flick
  snap, descend/ascend). 14 tests.
- RadialMenu: frameless translucent widget painting the current ring with
  wedges, labels, branch markers and a central back/cancel hub; mouse hover
  highlights, release/click activates a leaf, descends a branch, or
  ascends/cancels from the hub; Esc ascends/cancels.
- RadialConfig: JSON -> radial tree parser with a built-in default, so
  multiple radials can bind to different triggers. 8 tests.
- Plugin wiring: one trigger action per configured radial (default Alt+R ->
  editing radial), popped at the caret via cursorPositionCoordinates().
  Slices reference Kate actions by objectName (resolved via findActionByName);
  the special ollie_command_palette slice opens the palette.

Honours the agreed model (keys/weapon-wheel triggers now; config carries a
per-radial trigger for mouse-button/gesture triggers later). All 5 ctest
suites green (36 tests).
This commit is contained in:
Levi Neely 2026-10-07 16:38:54 +02:00
parent d227d6161b
commit a98db2913c
15 changed files with 1098 additions and 10 deletions

View File

@ -62,8 +62,10 @@ install -D build/bin/kf6/ktexteditor/olliepalette.so \
```
Then in Kate: Settings → Configure Kate → Plugins → enable
**Ollie Command Palette**. Press **Alt+X** (M-x) to open the palette; type to
filter, Enter or click to run.
**Ollie Command Palette**. Press **Alt+X** (M-x) to open the command palette;
type to filter, Enter or click to run. Press **Alt+R** to open the editing
radial menu at the caret; move the mouse toward a slice and release (or click) —
a branch slice (marked ▸) opens a submenu, the centre hub goes back/cancels.
## Status
@ -77,7 +79,11 @@ See [docs/PLAN.md](docs/PLAN.md) for the full roadmap.
keyboard + mouse activation). 10 model unit tests, all green.
- Milestone 3 — KTextEditor plugin + M-x: **DONE.** Loadable `olliepalette.so`;
`Alt+X` opens the palette over every action in the window's GUI clients.
- Milestone 4 — radial caret menu (mouse door): next.
- Milestone 4 — radial caret menu: **DONE (first increment).** Multi-level
radial menu (`Alt+R` default) at the caret, mouse-driven selection, config
tree with per-radial triggers. Key/action triggers now; mouse-button and
gesture triggers next.
- Milestone 5 — command vocabulary + project-as-directory + switchers: next.
## Layout
@ -91,8 +97,14 @@ src/
palettemodel.{h,cpp} ranked, filterable list model (headless)
palettewidget.{h,cpp} frameless command-palette popup
test_palettemodel.cpp QTest suite
radial/
radialmodel.{h,cpp} radial tree + hit-test geometry (headless)
radialmenu.{h,cpp} frameless multi-level radial widget
radialconfig.{h,cpp} JSON → radial tree + built-in default
test_radialmodel.cpp QTest suite
test_radialconfig.cpp QTest suite
plugin/
ollieplugin.{h,cpp} KTextEditor::Plugin + M-x action registry
ollieplugin.{h,cpp} KTextEditor::Plugin + M-x + radial wiring
ollieplugin.json plugin metadata
docs/
PLAN.md goal, constraints, roadmap

View File

@ -82,13 +82,28 @@ Therefore the palette is replaced, not extended, and the replacement is built on
objectName → shortcut → numeric suffix. Each entry also gets a unique id so
activation always triggers the intended action.
### M4 — Radial caret menu (mouse door) — NEXT
- Mouse-triggered radial menu positioned at the caret
(`View::cursorToCoordinate`), flick-to-select by angle.
- Slices invoke registry actions; one slice opens the palette / command line, so
the keyboard-expensive paths are never required.
### M4 — Radial caret menu (mouse door) — DONE (first increment)
- `src/radial/radialmodel.{h,cpp}`: pure tree + geometry. Multi-level
(branch/leaf) `RadialNode` tree, angle hit-testing (0 = up, clockwise), inner
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.
- `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 one trigger
action per configured radial (default **Alt+R** → editing radial). The radial
pops at the caret (`View::cursorPositionCoordinates()` → global); selection is
mouse-driven. A slice may reference any Kate action by objectName, resolved via
`findActionByName`; the special `ollie_command_palette` slice opens the palette.
- Honours the agreed model: radials are triggerable by keys (weapon-wheel style),
and the config format carries a per-radial trigger so mouse-button / gesture
triggers can be added without code changes.
- Not yet done: mouse-button and gesture triggers (only key/action triggers so
far); user-file config loading (built-in default only); frecency.
### M5 — Command vocabulary + project model + switchers
### M5 — Command vocabulary + project model + switchers — NEXT
- `:`-verb pack via `KTextEditor::Command`: `sort`, `align`, `json`, `b64`,
`uuid`, `case`, `pipe <shell>`, etc. These populate M-x and the radial.
- **Project = directory.** "Open folder == open project" (VSCode/Sublime model);

View File

@ -1,3 +1,4 @@
add_subdirectory(fuzzy)
add_subdirectory(palette)
add_subdirectory(radial)
add_subdirectory(plugin)

View File

@ -17,6 +17,7 @@ kcoreaddons_add_plugin(olliepalette
target_link_libraries(olliepalette PRIVATE
palette
radial
KF6::TextEditor
KF6::XmlGui
KF6::CoreAddons

View File

@ -4,8 +4,11 @@
#include "ollieplugin.h"
#include "palettewidget.h"
#include "radialmenu.h"
#include "radialconfig.h"
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <KActionCollection>
#include <KXMLGUIClient>
@ -59,11 +62,14 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
if (QWidget *w = m_mainWindow->window()) {
w->addAction(openAction);
}
setupRadials();
}
OllieView::~OllieView()
{
delete m_palette;
delete m_radial;
}
QList<OllieView::ActionEntry> OllieView::collectActions() const
@ -229,6 +235,81 @@ void OllieView::runAction(const QString &actionId)
}
}
QAction *OllieView::findActionByName(const QString &objectName) const
{
KXMLGUIFactory *factory = m_mainWindow->guiFactory();
if (!factory || objectName.isEmpty()) {
return nullptr;
}
const QList<KXMLGUIClient *> clients = factory->clients();
for (KXMLGUIClient *client : clients) {
KActionCollection *coll = client->actionCollection();
if (!coll) {
continue;
}
if (QAction *a = coll->action(objectName)) {
return a;
}
}
return nullptr;
}
void OllieView::setupRadials()
{
// User config could be loaded here later; for now ship the built-in set.
m_radialDefs = RadialConfig::builtinDefault();
QWidget *w = m_mainWindow->window();
for (int i = 0; i < m_radialDefs.size(); ++i) {
const RadialDef &def = m_radialDefs.at(i);
auto *trigger = new QAction(this);
trigger->setObjectName(QStringLiteral("ollie_radial_") + def.name);
trigger->setText(i18n("Radial Menu: %1", def.name));
if (!def.trigger.isEmpty()) {
trigger->setShortcut(QKeySequence(def.trigger));
trigger->setShortcutContext(Qt::ApplicationShortcut);
}
connect(trigger, &QAction::triggered, this, [this, i]() { showRadial(i); });
if (w) {
w->addAction(trigger);
}
}
}
void OllieView::showRadial(int defIndex)
{
if (defIndex < 0 || defIndex >= m_radialDefs.size()) {
return;
}
if (!m_radial) {
m_radial = new RadialMenu(m_mainWindow->window());
connect(m_radial, &RadialMenu::activatedId, this, &OllieView::onRadialActivated);
}
// Centre on the caret when a view is active; otherwise the window centre.
QPoint global;
if (KTextEditor::View *view = m_mainWindow->activeView()) {
global = view->mapToGlobal(view->cursorPositionCoordinates());
} else if (QWidget *w = m_mainWindow->window()) {
global = w->mapToGlobal(w->rect().center());
}
m_radial->popup(m_radialDefs.at(defIndex).root, global);
}
void OllieView::onRadialActivated(const QString &actionId)
{
// The palette is reachable from inside a radial as a special slice.
if (actionId == QLatin1String("ollie_command_palette")) {
showCommandPalette();
return;
}
if (QAction *a = findActionByName(actionId)) {
a->trigger();
}
}
} // namespace katecustom
// K_PLUGIN_FACTORY generates the factory and the moc-level registration. The

View File

@ -16,6 +16,7 @@
#include <QObject>
#include <QPointer>
#include <QHash>
#include <QList>
class QAction;
@ -28,6 +29,8 @@ namespace katecustom
{
class PaletteWidget;
class RadialMenu;
struct RadialDef;
class OlliePlugin : public KTextEditor::Plugin
{
@ -67,8 +70,19 @@ private:
// window's GUI factory clients, deduplicated by pointer identity.
QList<ActionEntry> collectActions() const;
// Find a Kate action by its raw objectName across all GUI clients. Used to
// resolve radial-menu slice ids (which reference Kate action names).
QAction *findActionByName(const QString &objectName) const;
// Build the configured radials and bind each to its trigger key.
void setupRadials();
void showRadial(int defIndex);
void onRadialActivated(const QString &actionId);
KTextEditor::MainWindow *m_mainWindow;
PaletteWidget *m_palette = nullptr;
RadialMenu *m_radial = nullptr;
QList<RadialDef> m_radialDefs;
QHash<QString, QPointer<QAction>> m_actionsById;
};

20
src/radial/CMakeLists.txt Normal file
View File

@ -0,0 +1,20 @@
add_library(radial STATIC
radialmodel.cpp
radialmodel.h
radialmenu.cpp
radialmenu.h
radialconfig.cpp
radialconfig.h
)
target_link_libraries(radial PUBLIC Qt6::Core Qt6::Gui Qt6::Widgets)
target_include_directories(radial PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
if(Qt6Test_FOUND)
add_executable(test_radialmodel test_radialmodel.cpp)
target_link_libraries(test_radialmodel PRIVATE radial Qt6::Test Qt6::Widgets)
add_test(NAME radialmodel COMMAND test_radialmodel)
add_executable(test_radialconfig test_radialconfig.cpp)
target_link_libraries(test_radialconfig PRIVATE radial Qt6::Test Qt6::Widgets)
add_test(NAME radialconfig COMMAND test_radialconfig)
endif()

122
src/radial/radialconfig.cpp Normal file
View File

@ -0,0 +1,122 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialconfig.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
namespace katecustom
{
namespace
{
// Recursively parse a slice node. A node with a non-empty "slices" array is a
// branch; otherwise it is a leaf bound to "action".
RadialNode parseNode(const QJsonObject &obj)
{
RadialNode node;
node.label = obj.value(QStringLiteral("label")).toString();
node.actionId = obj.value(QStringLiteral("action")).toString();
const QJsonArray slices = obj.value(QStringLiteral("slices")).toArray();
for (const QJsonValue &v : slices) {
if (v.isObject()) {
node.children.push_back(parseNode(v.toObject()));
}
}
// A branch must not also act as a leaf; the children take precedence.
if (!node.children.isEmpty()) {
node.actionId.clear();
}
return node;
}
} // namespace
QList<RadialDef> RadialConfig::fromJsonObject(const QJsonObject &obj)
{
QList<RadialDef> defs;
const QJsonArray radials = obj.value(QStringLiteral("radials")).toArray();
for (const QJsonValue &rv : radials) {
if (!rv.isObject()) {
continue;
}
const QJsonObject robj = rv.toObject();
RadialDef def;
def.name = robj.value(QStringLiteral("name")).toString();
def.trigger = robj.value(QStringLiteral("trigger")).toString();
const QJsonArray slices = robj.value(QStringLiteral("slices")).toArray();
for (const QJsonValue &sv : slices) {
if (sv.isObject()) {
def.root.children.push_back(parseNode(sv.toObject()));
}
}
// A radial with no slices is useless; skip it.
if (!def.root.children.isEmpty()) {
defs.push_back(def);
}
}
return defs;
}
QList<RadialDef> RadialConfig::fromJson(const QByteArray &json, QString *error)
{
QJsonParseError perr{};
const QJsonDocument doc = QJsonDocument::fromJson(json, &perr);
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
if (error) {
*error = perr.error != QJsonParseError::NoError
? perr.errorString()
: QStringLiteral("top-level JSON value is not an object");
}
return {};
}
return fromJsonObject(doc.object());
}
QList<RadialDef> RadialConfig::builtinDefault()
{
// One editing radial, triggered by Alt+R, built from common Kate actions.
// Leaf ids are Kate action objectNames; a trailing branch groups more.
auto leaf = [](const QString &label, const QString &action) {
RadialNode n;
n.label = label;
n.actionId = action;
return n;
};
RadialDef edit;
edit.name = QStringLiteral("edit");
edit.trigger = QStringLiteral("Alt+R");
RadialNode more;
more.label = QStringLiteral("More");
more.children = {
leaf(QStringLiteral("Comment"), QStringLiteral("comment")),
leaf(QStringLiteral("Uncomment"), QStringLiteral("uncomment")),
leaf(QStringLiteral("Find"), QStringLiteral("edit_find")),
leaf(QStringLiteral("Replace"), QStringLiteral("edit_replace")),
// Opens the full command palette (handled specially by the plugin).
leaf(QStringLiteral("Command Palette…"), QStringLiteral("ollie_command_palette")),
};
edit.root.children = {
leaf(QStringLiteral("Cut"), QStringLiteral("edit_cut")),
leaf(QStringLiteral("Copy"), QStringLiteral("edit_copy")),
leaf(QStringLiteral("Paste"), QStringLiteral("edit_paste")),
leaf(QStringLiteral("Undo"), QStringLiteral("edit_undo")),
leaf(QStringLiteral("Redo"), QStringLiteral("edit_redo")),
leaf(QStringLiteral("Select All"), QStringLiteral("edit_select_all")),
more,
};
return {edit};
}
} // namespace katecustom

63
src/radial/radialconfig.h Normal file
View File

@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* RadialConfig — load radial-menu definitions from JSON, with a built-in
* default. A config describes one or more named radials, each bound to a
* trigger (a key sequence, a mouse button, or a gesture name) and each a tree
* of slices. Leaves carry a Kate action name (objectName); branches carry
* nested slices.
*
* JSON shape:
* {
* "radials": [
* {
* "name": "edit",
* "trigger": "Alt+R",
* "slices": [
* { "label": "Cut", "action": "edit_cut" },
* { "label": "Copy", "action": "edit_copy" },
* { "label": "More", "slices": [ ... ] }
* ]
* }
* ]
* }
*
* Pure parsing/data — no widget or plugin dependency, so it is unit testable.
*/
#ifndef KATECUSTOM_RADIALCONFIG_H
#define KATECUSTOM_RADIALCONFIG_H
#include "radialmodel.h"
#include <QList>
#include <QString>
class QJsonObject;
namespace katecustom
{
struct RadialDef {
QString name;
QString trigger; // e.g. "Alt+R", "MiddleButton"
RadialNode root; // root.children are the top-level slices
};
class RadialConfig
{
public:
/*! Parse a radial list from a top-level JSON object. Invalid entries are
* skipped. Returns the radials found (possibly empty). */
static QList<RadialDef> fromJsonObject(const QJsonObject &obj);
/*! Parse from raw JSON text. On parse error returns an empty list and sets
* \a error (if provided). */
static QList<RadialDef> fromJson(const QByteArray &json, QString *error = nullptr);
/*! The built-in default set, used when no user config is present. */
static QList<RadialDef> builtinDefault();
};
} // namespace katecustom
#endif

208
src/radial/radialmenu.cpp Normal file
View File

@ -0,0 +1,208 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialmenu.h"
#include <QApplication>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QScreen>
#include <QtMath>
namespace katecustom
{
RadialMenu::RadialMenu(QWidget *parent)
: QWidget(parent, Qt::Popup | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_TranslucentBackground);
setMouseTracking(true);
setFocusPolicy(Qt::StrongFocus);
}
RadialMenu::~RadialMenu() = default;
RadialLayout RadialMenu::currentLayout() const
{
RadialLayout l;
l.center = m_center;
l.innerRadius = m_innerRadius;
l.outerRadius = m_outerRadius;
l.sliceCount = m_model ? m_model->currentSlices().size() : 0;
return l;
}
void RadialMenu::popup(const RadialNode &root, const QPoint &globalCenter)
{
m_model = std::make_unique<RadialModel>(root);
m_hoverSlice = RadialModel::InvalidSlice;
// 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
// screen so the whole menu stays visible.
const int pad = 60;
const int side = static_cast<int>((m_outerRadius + pad) * 2.0);
m_center = QPointF(side / 2.0, side / 2.0);
QPoint topLeft = globalCenter - QPoint(side / 2, side / 2);
if (QScreen *screen = QApplication::screenAt(globalCenter)) {
const QRect avail = screen->availableGeometry();
topLeft.setX(qBound(avail.left(), topLeft.x(), avail.right() - side));
topLeft.setY(qBound(avail.top(), topLeft.y(), avail.bottom() - side));
// Keep the ring centred on the requested point by shifting the local
// centre to compensate for any clamping.
m_center = QPointF(globalCenter.x() - topLeft.x(), globalCenter.y() - topLeft.y());
}
setGeometry(topLeft.x(), topLeft.y(), side, side);
show();
raise();
setFocus();
update();
}
void RadialMenu::paintEvent(QPaintEvent *)
{
if (!m_model) {
return;
}
const QList<RadialNode> &slices = m_model->currentSlices();
const int count = slices.size();
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
const QPalette pal = palette();
const QColor baseFill = pal.color(QPalette::Window);
const QColor hoverFill = pal.color(QPalette::Highlight);
const QColor textCol = pal.color(QPalette::WindowText);
const QColor hoverText = pal.color(QPalette::HighlightedText);
const QColor border = pal.color(QPalette::Mid);
// Central hub (dead zone) — release here to go up / cancel.
p.setBrush(baseFill);
p.setPen(QPen(border, 1.5));
p.drawEllipse(m_center, m_innerRadius, m_innerRadius);
if (m_model && !m_model->atRoot()) {
p.setPen(textCol);
p.drawText(QRectF(m_center.x() - m_innerRadius, m_center.y() - m_innerRadius,
m_innerRadius * 2, m_innerRadius * 2),
Qt::AlignCenter, QStringLiteral("‹ back"));
}
const RadialLayout l = currentLayout();
for (int i = 0; i < count; ++i) {
double a0 = 0.0;
double a1 = 0.0;
m_model->sliceAngles(i, count, a0, a1);
// Convert "0=up, clockwise" to Qt's "0=east, counter-clockwise, in 1/16 deg".
// Qt angle (deg, CCW from +x) for our up-CW angle 'a': qtDeg = 90 - deg(a).
auto toQtDeg = [](double upCwRad) { return 90.0 - qRadiansToDegrees(upCwRad); };
const double startDeg = toQtDeg(a1); // a1 is the clockwise-further edge
const double spanDeg = qRadiansToDegrees(a1 - a0);
QPainterPath wedge;
wedge.moveTo(m_center);
const QRectF outerRect(m_center.x() - m_outerRadius, m_center.y() - m_outerRadius,
m_outerRadius * 2, m_outerRadius * 2);
wedge.arcTo(outerRect, startDeg, spanDeg);
wedge.closeSubpath();
// Carve out the inner hub.
QPainterPath hub;
hub.addEllipse(m_center, m_innerRadius, m_innerRadius);
wedge = wedge.subtracted(hub);
const bool hot = (i == m_hoverSlice);
p.setBrush(hot ? hoverFill : baseFill);
p.setPen(QPen(border, 1.0));
p.drawPath(wedge);
// Label at the slice centre.
const QPointF c = m_model->sliceCenter(i, l);
QString text = slices.at(i).label;
if (slices.at(i).isBranch()) {
text += QStringLiteral(" ▸");
}
p.setPen(hot ? hoverText : textCol);
const QRectF textRect(c.x() - m_outerRadius / 2.0, c.y() - 12,
m_outerRadius, 24);
p.drawText(textRect, Qt::AlignCenter, text);
}
}
void RadialMenu::mouseMoveEvent(QMouseEvent *event)
{
if (!m_model) {
return;
}
const int slice = m_model->sliceAt(event->position(), currentLayout());
if (slice != m_hoverSlice) {
m_hoverSlice = slice;
update();
}
}
void RadialMenu::mouseReleaseEvent(QMouseEvent *event)
{
handleActivationAt(event->position());
}
void RadialMenu::handleActivationAt(const QPointF &localPos)
{
if (!m_model) {
return;
}
const int slice = m_model->sliceAt(localPos, currentLayout());
QString id;
switch (m_model->resolve(slice, id)) {
case RadialModel::Hit::Activate:
finishActivated(id);
break;
case RadialModel::Hit::Descend:
m_model->descend(slice);
m_hoverSlice = RadialModel::InvalidSlice;
update();
break;
case RadialModel::Hit::Ascend:
m_model->ascend();
m_hoverSlice = RadialModel::InvalidSlice;
update();
break;
case RadialModel::Hit::Cancel:
finishCancelled();
break;
}
}
void RadialMenu::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Escape) {
if (m_model && !m_model->atRoot()) {
m_model->ascend();
m_hoverSlice = RadialModel::InvalidSlice;
update();
} else {
finishCancelled();
}
return;
}
QWidget::keyPressEvent(event);
}
void RadialMenu::finishActivated(const QString &id)
{
hide();
Q_EMIT activatedId(id);
}
void RadialMenu::finishCancelled()
{
hide();
Q_EMIT cancelled();
}
} // namespace katecustom

64
src/radial/radialmenu.h Normal file
View File

@ -0,0 +1,64 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* 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.
*/
#ifndef KATECUSTOM_RADIALMENU_H
#define KATECUSTOM_RADIALMENU_H
#include "radialmodel.h"
#include <QWidget>
#include <memory>
namespace katecustom
{
class RadialMenu : public QWidget
{
Q_OBJECT
public:
explicit RadialMenu(QWidget *parent = nullptr);
~RadialMenu() override;
/*!
* Show the menu for \a root, centred on the global point \a globalCenter.
* Any previously shown tree is replaced and navigation resets to the root.
*/
void popup(const RadialNode &root, const QPoint &globalCenter);
Q_SIGNALS:
void activatedId(const QString &id);
void cancelled();
protected:
void paintEvent(QPaintEvent *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);
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;
double m_innerRadius = 36.0;
double m_outerRadius = 130.0;
};
} // namespace katecustom
#endif

119
src/radial/radialmodel.cpp Normal file
View File

@ -0,0 +1,119 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialmodel.h"
#include <QtMath>
namespace katecustom
{
RadialModel::RadialModel(RadialNode root)
: m_root(std::move(root))
{
}
const RadialNode &RadialModel::nodeAtPath() const
{
const RadialNode *node = &m_root;
for (int idx : m_path) {
node = &node->children.at(idx);
}
return *node;
}
const QList<RadialNode> &RadialModel::currentSlices() const
{
return nodeAtPath().children;
}
void RadialModel::sliceAngles(int index, int sliceCount, double &startAngle, double &endAngle) const
{
// Even wedges starting at the top (0 = up), going clockwise. Each slice is
// centred on its nominal direction, so wedge i spans [i-0.5, i+0.5) steps.
const double step = (2.0 * M_PI) / static_cast<double>(sliceCount);
startAngle = (static_cast<double>(index) - 0.5) * step;
endAngle = (static_cast<double>(index) + 0.5) * step;
}
int RadialModel::sliceAt(const QPointF &point, const RadialLayout &layout) const
{
const int count = currentSlices().size();
if (count == 0 || layout.sliceCount == 0) {
return InvalidSlice;
}
const double dx = point.x() - layout.center.x();
const double dy = point.y() - layout.center.y();
const double dist = std::sqrt(dx * dx + dy * dy);
// Inner dead zone: ambiguous direction, treat as "no slice".
if (dist < layout.innerRadius) {
return InvalidSlice;
}
// Angle measured from "up" (screen: -y), increasing clockwise (toward +x).
double angle = std::atan2(dx, -dy); // 0 = up, +pi/2 = right
if (angle < 0.0) {
angle += 2.0 * M_PI;
}
const double step = (2.0 * M_PI) / static_cast<double>(count);
// Shift by half a step so slice 0 is centred on "up".
int index = static_cast<int>(std::floor((angle + step / 2.0) / step));
index %= count;
return index;
}
QPointF RadialModel::sliceCenter(int index, const RadialLayout &layout) const
{
const int count = currentSlices().size();
if (count == 0) {
return layout.center;
}
const double step = (2.0 * M_PI) / static_cast<double>(count);
const double angle = static_cast<double>(index) * step; // 0 = up, CW
const double r = (layout.innerRadius + layout.outerRadius) / 2.0;
// Convert "up, clockwise" angle back to screen coordinates.
const double x = layout.center.x() + r * std::sin(angle);
const double y = layout.center.y() - r * std::cos(angle);
return QPointF(x, y);
}
bool RadialModel::descend(int index)
{
const QList<RadialNode> &slices = currentSlices();
if (index < 0 || index >= slices.size()) {
return false;
}
if (!slices.at(index).isBranch()) {
return false;
}
m_path.push_back(index);
return true;
}
bool RadialModel::ascend()
{
if (m_path.isEmpty()) {
return false;
}
m_path.removeLast();
return true;
}
RadialModel::Hit RadialModel::resolve(int index, QString &outActionId) const
{
const QList<RadialNode> &slices = currentSlices();
if (index < 0 || index >= slices.size()) {
return atRoot() ? Hit::Cancel : Hit::Ascend;
}
const RadialNode &node = slices.at(index);
if (node.isBranch()) {
return Hit::Descend;
}
outActionId = node.actionId;
return Hit::Activate;
}
} // namespace katecustom

99
src/radial/radialmodel.h Normal file
View File

@ -0,0 +1,99 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* RadialModel — the data and geometry of a multi-level radial menu.
*
* A radial menu is a tree of RadialNode. A leaf node activates an action (by
* the same id scheme the command palette uses). A branch node opens a nested
* ring of children. The model is pure: it owns the tree, tracks which branch is
* currently open, and answers geometry questions (which slice does a point fall
* in, where is a slice's centre). It has no widget dependency so the layout and
* hit-testing can be unit tested without a display.
*
* Angle convention: 0 radians points up (12 o'clock); angles increase
* clockwise. Slices are distributed evenly starting at the top.
*/
#ifndef KATECUSTOM_RADIALMODEL_H
#define KATECUSTOM_RADIALMODEL_H
#include <QString>
#include <QList>
#include <QPointF>
#include <memory>
namespace katecustom
{
struct RadialNode {
QString label;
QString actionId; // non-empty => leaf that triggers this action
QList<RadialNode> children; // non-empty => branch that opens a submenu
bool isBranch() const { return !children.isEmpty(); }
bool isLeaf() const { return children.isEmpty(); }
};
/*! Geometry of the ring currently shown. */
struct RadialLayout {
QPointF center;
double innerRadius = 0.0; // dead zone (release here = cancel / go up)
double outerRadius = 0.0;
int sliceCount = 0;
};
class RadialModel
{
public:
static constexpr int InvalidSlice = -1;
explicit RadialModel(RadialNode root);
/*! The nodes shown in the ring at the current level. */
const QList<RadialNode> &currentSlices() const;
/*! True if we have descended into at least one submenu. */
bool atRoot() const { return m_path.isEmpty(); }
/*!
* Which slice index does \a point fall in, given \a layout? Returns
* InvalidSlice if the point is inside the inner dead zone (or there are no
* slices). Points beyond the outer radius still map to the nearest slice by
* angle, so a confident flick outward need not be precise about distance.
*/
int sliceAt(const QPointF &point, const RadialLayout &layout) const;
/*! Centre point of slice \a index for label placement. */
QPointF sliceCenter(int index, const RadialLayout &layout) const;
/*! Angular span [startAngle, endAngle) of a slice, radians, 0=up CW. */
void sliceAngles(int index, int sliceCount, double &startAngle, double &endAngle) const;
/*!
* Enter the branch at slice \a index. Returns true if it was a branch and
* we descended; false if it was a leaf or invalid.
*/
bool descend(int index);
/*! Pop one level back toward the root. Returns false if already at root. */
bool ascend();
/*!
* Resolve activation of slice \a index at the current level:
* - leaf => sets \a outActionId and returns Activate
* - branch=> returns Descend (caller should call descend())
* - invalid (dead zone) => returns Cancel at root, Ascend otherwise
*/
enum class Hit { Cancel, Ascend, Descend, Activate };
Hit resolve(int index, QString &outActionId) const;
private:
RadialNode m_root;
QList<int> m_path; // indices descended from root
const RadialNode &nodeAtPath() const;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,116 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialconfig.h"
#include <QTest>
using namespace katecustom;
class TestRadialConfig : public QObject
{
Q_OBJECT
private Q_SLOTS:
void parsesFlatRadial()
{
const QByteArray json = R"({
"radials": [
{
"name": "edit",
"trigger": "Alt+R",
"slices": [
{ "label": "Cut", "action": "edit_cut" },
{ "label": "Copy", "action": "edit_copy" }
]
}
]
})";
QString err;
const QList<RadialDef> defs = RadialConfig::fromJson(json, &err);
QVERIFY2(err.isEmpty(), qPrintable(err));
QCOMPARE(defs.size(), 1);
QCOMPARE(defs.first().name, QStringLiteral("edit"));
QCOMPARE(defs.first().trigger, QStringLiteral("Alt+R"));
QCOMPARE(defs.first().root.children.size(), 2);
QCOMPARE(defs.first().root.children.at(0).actionId, QStringLiteral("edit_cut"));
QVERIFY(defs.first().root.children.at(0).isLeaf());
}
void parsesNestedSubmenu()
{
const QByteArray json = R"({
"radials": [
{
"name": "edit",
"slices": [
{ "label": "Cut", "action": "edit_cut" },
{ "label": "More", "slices": [
{ "label": "Comment", "action": "comment" },
{ "label": "Format", "action": "fmt" }
]}
]
}
]
})";
const QList<RadialDef> defs = RadialConfig::fromJson(json);
QCOMPARE(defs.size(), 1);
const RadialNode &more = defs.first().root.children.at(1);
QVERIFY(more.isBranch());
QCOMPARE(more.children.size(), 2);
QCOMPARE(more.children.at(0).actionId, QStringLiteral("comment"));
}
void branchWithChildrenIsNotLeaf()
{
// A node with both "action" and "slices" must behave as a branch.
const QByteArray json = R"({
"radials": [
{ "name": "x", "slices": [
{ "label": "B", "action": "should_be_ignored", "slices": [
{ "label": "C", "action": "c" }
]}
]}
]
})";
const QList<RadialDef> defs = RadialConfig::fromJson(json);
const RadialNode &b = defs.first().root.children.at(0);
QVERIFY(b.isBranch());
QVERIFY(b.actionId.isEmpty());
}
void skipsEmptyRadial()
{
const QByteArray json = R"({ "radials": [ { "name": "empty", "slices": [] } ] })";
QCOMPARE(RadialConfig::fromJson(json).size(), 0);
}
void reportsParseError()
{
QString err;
const QList<RadialDef> defs = RadialConfig::fromJson(QByteArray("{ not json"), &err);
QVERIFY(defs.isEmpty());
QVERIFY(!err.isEmpty());
}
void builtinDefaultIsUsable()
{
const QList<RadialDef> defs = RadialConfig::builtinDefault();
QVERIFY(!defs.isEmpty());
const RadialDef &d = defs.first();
QVERIFY(!d.trigger.isEmpty());
QVERIFY(d.root.children.size() >= 4);
// Must contain at least one branch (the "More" submenu).
bool hasBranch = false;
for (const RadialNode &n : d.root.children) {
if (n.isBranch()) {
hasBranch = true;
}
}
QVERIFY(hasBranch);
}
};
QTEST_APPLESS_MAIN(TestRadialConfig)
#include "test_radialconfig.moc"

View File

@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "radialmodel.h"
#include <QTest>
using namespace katecustom;
namespace
{
// Build a radial tree:
// root: [Cut, Copy, Paste, Edit>]
// Edit: [Comment, Format]
RadialNode sampleTree()
{
RadialNode root;
root.children = {
{QStringLiteral("Cut"), QStringLiteral("kate/edit_cut"), {}},
{QStringLiteral("Copy"), QStringLiteral("kate/edit_copy"), {}},
{QStringLiteral("Paste"), QStringLiteral("kate/edit_paste"), {}},
{QStringLiteral("Edit"), {},
{
{QStringLiteral("Comment"), QStringLiteral("kate/comment"), {}},
{QStringLiteral("Format"), QStringLiteral("fmt/format"), {}},
}},
};
return root;
}
RadialLayout layout(int sliceCount)
{
RadialLayout l;
l.center = QPointF(100, 100);
l.innerRadius = 20;
l.outerRadius = 80;
l.sliceCount = sliceCount;
return l;
}
}
class TestRadialModel : public QObject
{
Q_OBJECT
private Q_SLOTS:
void topIsSliceZero()
{
RadialModel m(sampleTree());
const RadialLayout l = layout(4);
// A point straight up from centre (smaller y) is slice 0.
QCOMPARE(m.sliceAt(QPointF(100, 40), l), 0);
}
void clockwiseOrdering()
{
RadialModel m(sampleTree()); // 4 slices => up, right, down, left
const RadialLayout l = layout(4);
QCOMPARE(m.sliceAt(QPointF(100, 40), l), 0); // up
QCOMPARE(m.sliceAt(QPointF(160, 100), l), 1); // right
QCOMPARE(m.sliceAt(QPointF(100, 160), l), 2); // down
QCOMPARE(m.sliceAt(QPointF(40, 100), l), 3); // left
}
void deadZoneIsInvalid()
{
RadialModel m(sampleTree());
const RadialLayout l = layout(4);
QCOMPARE(m.sliceAt(QPointF(100, 100), l), RadialModel::InvalidSlice);
QCOMPARE(m.sliceAt(QPointF(105, 100), l), RadialModel::InvalidSlice);
}
void farFlickStillSnapsByAngle()
{
RadialModel m(sampleTree());
const RadialLayout l = layout(4);
// Way beyond the outer radius, straight up: still slice 0.
QCOMPARE(m.sliceAt(QPointF(100, -500), l), 0);
}
void descendOpensSubmenu()
{
RadialModel m(sampleTree());
QCOMPARE(m.currentSlices().size(), 4);
QVERIFY(m.atRoot());
// Slice 3 ("Edit") is a branch.
QVERIFY(m.descend(3));
QVERIFY(!m.atRoot());
QCOMPARE(m.currentSlices().size(), 2);
QCOMPARE(m.currentSlices().at(0).label, QStringLiteral("Comment"));
}
void descendRefusesLeaf()
{
RadialModel m(sampleTree());
QVERIFY(!m.descend(0)); // "Cut" is a leaf
QVERIFY(m.atRoot());
}
void ascendReturnsToParent()
{
RadialModel m(sampleTree());
QVERIFY(m.descend(3));
QVERIFY(m.ascend());
QVERIFY(m.atRoot());
QVERIFY(!m.ascend()); // already at root
}
void resolveLeafActivates()
{
RadialModel m(sampleTree());
QString id;
QCOMPARE(m.resolve(1, id), RadialModel::Hit::Activate);
QCOMPARE(id, QStringLiteral("kate/edit_copy"));
}
void resolveBranchDescends()
{
RadialModel m(sampleTree());
QString id;
QCOMPARE(m.resolve(3, id), RadialModel::Hit::Descend);
}
void resolveDeadZoneCancelsAtRoot()
{
RadialModel m(sampleTree());
QString id;
QCOMPARE(m.resolve(RadialModel::InvalidSlice, id), RadialModel::Hit::Cancel);
}
void resolveDeadZoneAscendsInSubmenu()
{
RadialModel m(sampleTree());
QVERIFY(m.descend(3));
QString id;
QCOMPARE(m.resolve(RadialModel::InvalidSlice, id), RadialModel::Hit::Ascend);
}
void sliceCenterRoundTrips()
{
RadialModel m(sampleTree());
const RadialLayout l = layout(4);
// The centre of each slice must hit-test back to that same slice.
for (int i = 0; i < 4; ++i) {
const QPointF c = m.sliceCenter(i, l);
QCOMPARE(m.sliceAt(c, l), i);
}
}
};
QTEST_APPLESS_MAIN(TestRadialModel)
#include "test_radialmodel.moc"