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:
parent
d227d6161b
commit
a98db2913c
20
README.md
20
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
|
||||
|
|
|
|||
27
docs/PLAN.md
27
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 <shell>`, etc. These populate M-x and the radial.
|
||||
- **Project = directory.** "Open folder == open project" (VSCode/Sublime model);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
add_subdirectory(fuzzy)
|
||||
add_subdirectory(palette)
|
||||
add_subdirectory(radial)
|
||||
add_subdirectory(plugin)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ kcoreaddons_add_plugin(olliepalette
|
|||
|
||||
target_link_libraries(olliepalette PRIVATE
|
||||
palette
|
||||
radial
|
||||
KF6::TextEditor
|
||||
KF6::XmlGui
|
||||
KF6::CoreAddons
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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> ¤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<int> m_path; // indices descended from root
|
||||
|
||||
const RadialNode &nodeAtPath() const;
|
||||
};
|
||||
|
||||
} // namespace katecustom
|
||||
|
||||
#endif
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue