diff --git a/README.md b/README.md index c9d027d..8c63bb6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index 927fffa..09732be 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 `, etc. These populate M-x and the radial. - **Project = directory.** "Open folder == open project" (VSCode/Sublime model); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 77ccd88..b641fa2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,4 @@ add_subdirectory(fuzzy) add_subdirectory(palette) +add_subdirectory(radial) add_subdirectory(plugin) diff --git a/src/plugin/CMakeLists.txt b/src/plugin/CMakeLists.txt index d16ae40..1d1e8c2 100644 --- a/src/plugin/CMakeLists.txt +++ b/src/plugin/CMakeLists.txt @@ -17,6 +17,7 @@ kcoreaddons_add_plugin(olliepalette target_link_libraries(olliepalette PRIVATE palette + radial KF6::TextEditor KF6::XmlGui KF6::CoreAddons diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 0bdb3e9..32855de 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -4,8 +4,11 @@ #include "ollieplugin.h" #include "palettewidget.h" +#include "radialmenu.h" +#include "radialconfig.h" #include +#include #include #include @@ -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::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 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 diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index dc22736..3857565 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -16,6 +16,7 @@ #include #include #include +#include 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 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 m_radialDefs; QHash> m_actionsById; }; diff --git a/src/radial/CMakeLists.txt b/src/radial/CMakeLists.txt new file mode 100644 index 0000000..517dd71 --- /dev/null +++ b/src/radial/CMakeLists.txt @@ -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() diff --git a/src/radial/radialconfig.cpp b/src/radial/radialconfig.cpp new file mode 100644 index 0000000..b233ede --- /dev/null +++ b/src/radial/radialconfig.cpp @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialconfig.h" + +#include +#include +#include +#include +#include + +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 RadialConfig::fromJsonObject(const QJsonObject &obj) +{ + QList 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 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 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 diff --git a/src/radial/radialconfig.h b/src/radial/radialconfig.h new file mode 100644 index 0000000..f323c4a --- /dev/null +++ b/src/radial/radialconfig.h @@ -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 +#include + +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 fromJsonObject(const QJsonObject &obj); + + /*! Parse from raw JSON text. On parse error returns an empty list and sets + * \a error (if provided). */ + static QList fromJson(const QByteArray &json, QString *error = nullptr); + + /*! The built-in default set, used when no user config is present. */ + static QList builtinDefault(); +}; + +} // namespace katecustom + +#endif diff --git a/src/radial/radialmenu.cpp b/src/radial/radialmenu.cpp new file mode 100644 index 0000000..ab191f6 --- /dev/null +++ b/src/radial/radialmenu.cpp @@ -0,0 +1,208 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialmenu.h" + +#include +#include +#include +#include +#include +#include +#include + +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(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((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 &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 diff --git a/src/radial/radialmenu.h b/src/radial/radialmenu.h new file mode 100644 index 0000000..64838f9 --- /dev/null +++ b/src/radial/radialmenu.h @@ -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 + +#include + +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 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 diff --git a/src/radial/radialmodel.cpp b/src/radial/radialmodel.cpp new file mode 100644 index 0000000..fcf7c51 --- /dev/null +++ b/src/radial/radialmodel.cpp @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialmodel.h" + +#include + +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 &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(sliceCount); + startAngle = (static_cast(index) - 0.5) * step; + endAngle = (static_cast(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(count); + // Shift by half a step so slice 0 is centred on "up". + int index = static_cast(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(count); + const double angle = static_cast(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 &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 &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 diff --git a/src/radial/radialmodel.h b/src/radial/radialmodel.h new file mode 100644 index 0000000..9519b86 --- /dev/null +++ b/src/radial/radialmodel.h @@ -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 +#include +#include + +#include + +namespace katecustom +{ + +struct RadialNode { + QString label; + QString actionId; // non-empty => leaf that triggers this action + QList 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 ¤tSlices() 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 m_path; // indices descended from root + + const RadialNode &nodeAtPath() const; +}; + +} // namespace katecustom + +#endif diff --git a/src/radial/test_radialconfig.cpp b/src/radial/test_radialconfig.cpp new file mode 100644 index 0000000..1e28c82 --- /dev/null +++ b/src/radial/test_radialconfig.cpp @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialconfig.h" + +#include + +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 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 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 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 defs = RadialConfig::fromJson(QByteArray("{ not json"), &err); + QVERIFY(defs.isEmpty()); + QVERIFY(!err.isEmpty()); + } + + void builtinDefaultIsUsable() + { + const QList 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" diff --git a/src/radial/test_radialmodel.cpp b/src/radial/test_radialmodel.cpp new file mode 100644 index 0000000..4ddc0d2 --- /dev/null +++ b/src/radial/test_radialmodel.cpp @@ -0,0 +1,153 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "radialmodel.h" + +#include + +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"