Frecency: usage-aware ranking across all palettes
FrecencyStore (palette lib, Qt::Core, 11 tests): per-id {count,last}
persisted as JSON at $XDG_CONFIG_HOME/olliepalette/frecency.json via
QSaveFile. bonus() = recency bucket x capped visit count, in [0,100] —
below the substring tier (400) so it nudges/tie-breaks without overriding
a stronger textual match, and floats habitual choices up on an empty
query.
Plugin: OllieView loads the store, records every activation (M-x
runAction + file/symbol switcher lambdas) and sets PaletteItem::frecency
before showing each palette. File/symbol frecency keys are
project-qualified so usage does not bleed between repos.
This commit is contained in:
parent
cf0cf7e950
commit
7195a6c11b
21
docs/PLAN.md
21
docs/PLAN.md
|
|
@ -86,8 +86,9 @@ Therefore the palette is replaced, not extended, and the replacement is built on
|
||||||
and feeds them to `PaletteWidget`. The action's current shortcut is shown in
|
and feeds them to `PaletteWidget`. The action's current shortcut is shown in
|
||||||
the group column. Accepting a row triggers the underlying `QAction`.
|
the group column. Accepting a row triggers the underlying `QAction`.
|
||||||
- Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`.
|
- Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`.
|
||||||
- Not yet wired: frecency (the model supports a bonus, but the plugin currently
|
- Frecency: wired. `OllieView` holds a persisted `FrecencyStore`; activations
|
||||||
passes 0 for every action — usage tracking is deferred).
|
are recorded and `PaletteItem::frecency` is populated for every palette (see
|
||||||
|
the Frecency entry under M5).
|
||||||
- Duplicate handling: actions are deduplicated only by pointer identity (the
|
- Duplicate handling: actions are deduplicated only by pointer identity (the
|
||||||
exact same action object reachable through several GUI clients is listed
|
exact same action object reachable through several GUI clients is listed
|
||||||
once — functionally lossless). Distinct actions are never dropped. When two
|
once — functionally lossless). Distinct actions are never dropped. When two
|
||||||
|
|
@ -225,6 +226,22 @@ Therefore the palette is replaced, not extended, and the replacement is built on
|
||||||
1-based → cursor 0-based). Also in the M-x palette (`ollie:switch:symbol`)
|
1-based → cursor 0-based). Also in the M-x palette (`ollie:switch:symbol`)
|
||||||
and reachable from a radial by objectName `ollie_goto_symbol`.
|
and reachable from a radial by objectName `ollie_goto_symbol`.
|
||||||
(Alt+R is reserved for the radial key trigger, so this uses Alt+G.)
|
(Alt+R is reserved for the radial key trigger, so this uses Alt+G.)
|
||||||
|
- Frecency (usage-aware ranking) — DONE.
|
||||||
|
`src/palette/frecencystore.{h,cpp}` (in `palette`, Qt::Core only, 11 unit
|
||||||
|
tests):
|
||||||
|
- Per-id `{count, last-used}` persisted as flat JSON (via `QSaveFile`) at
|
||||||
|
`$XDG_CONFIG_HOME/olliepalette/frecency.json`. `bonus(id, now)` combines a
|
||||||
|
recency bucket (<1d=100, <1w=70, <1mo=50, <3mo=30, else 10) with a visit
|
||||||
|
count capped at 10 → an integer in [0,100]. The cap keeps the bonus well
|
||||||
|
below a textual-match tier (substring=400) so frecency nudges ordering and
|
||||||
|
breaks ties without overriding a clearly better match; on an empty query it
|
||||||
|
floats habitual choices to the top.
|
||||||
|
- Wired into the plugin: `OllieView` loads the store in its ctor, every
|
||||||
|
activation (`runAction` for the M-x palette; the file/symbol switcher
|
||||||
|
lambdas) calls `recordUsage` (bump + save), and each palette sets
|
||||||
|
`PaletteItem::frecency` from `bonus()` before showing. File/symbol keys are
|
||||||
|
**project-qualified** (prefixed by the resolved root) so usage never bleeds
|
||||||
|
between repos that share relative paths.
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ add_library(palette STATIC
|
||||||
palettemodel.h
|
palettemodel.h
|
||||||
palettewidget.cpp
|
palettewidget.cpp
|
||||||
palettewidget.h
|
palettewidget.h
|
||||||
|
frecencystore.cpp
|
||||||
|
frecencystore.h
|
||||||
)
|
)
|
||||||
target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets)
|
target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets)
|
||||||
target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
@ -11,4 +13,8 @@ if(Qt6Test_FOUND)
|
||||||
add_executable(test_palettemodel test_palettemodel.cpp)
|
add_executable(test_palettemodel test_palettemodel.cpp)
|
||||||
target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets)
|
target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets)
|
||||||
add_test(NAME palettemodel COMMAND test_palettemodel)
|
add_test(NAME palettemodel COMMAND test_palettemodel)
|
||||||
|
|
||||||
|
add_executable(test_frecencystore test_frecencystore.cpp)
|
||||||
|
target_link_libraries(test_frecencystore PRIVATE palette Qt6::Test)
|
||||||
|
add_test(NAME frecencystore COMMAND test_frecencystore)
|
||||||
endif()
|
endif()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
#include "frecencystore.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QSaveFile>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr qint64 kDay = 24 * 60 * 60;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FrecencyStore::FrecencyStore(const QString &filePath)
|
||||||
|
: m_filePath(filePath)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FrecencyStore::load()
|
||||||
|
{
|
||||||
|
m_entries.clear();
|
||||||
|
if (m_filePath.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QFile f(m_filePath);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QByteArray data = f.readAll();
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
QJsonParseError err{};
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
|
||||||
|
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QJsonObject root = doc.object();
|
||||||
|
for (auto it = root.constBegin(); it != root.constEnd(); ++it) {
|
||||||
|
if (!it.value().isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QJsonObject o = it.value().toObject();
|
||||||
|
Entry e;
|
||||||
|
e.count = o.value(QStringLiteral("count")).toInt();
|
||||||
|
e.last = static_cast<qint64>(o.value(QStringLiteral("last")).toDouble());
|
||||||
|
if (e.count > 0) {
|
||||||
|
m_entries.insert(it.key(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FrecencyStore::save() const
|
||||||
|
{
|
||||||
|
if (m_filePath.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QFileInfo info(m_filePath);
|
||||||
|
if (!QDir().mkpath(info.absolutePath())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject root;
|
||||||
|
for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) {
|
||||||
|
QJsonObject o;
|
||||||
|
o.insert(QStringLiteral("count"), it.value().count);
|
||||||
|
o.insert(QStringLiteral("last"), static_cast<double>(it.value().last));
|
||||||
|
root.insert(it.key(), o);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSaveFile f(m_filePath);
|
||||||
|
if (!f.open(QIODevice::WriteOnly)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
f.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||||
|
return f.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FrecencyStore::bump(const QString &id, qint64 nowEpoch)
|
||||||
|
{
|
||||||
|
if (id.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Entry &e = m_entries[id];
|
||||||
|
++e.count;
|
||||||
|
e.last = nowEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
int FrecencyStore::computeBonus(const Entry &e, qint64 nowEpoch)
|
||||||
|
{
|
||||||
|
if (e.count <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Recency bucket: how long since last use. Newer buckets weigh more. A
|
||||||
|
// negative age (clock skew / future timestamp) is treated as "just now".
|
||||||
|
const qint64 age = nowEpoch - e.last;
|
||||||
|
int recency;
|
||||||
|
if (age < kDay) {
|
||||||
|
recency = 100;
|
||||||
|
} else if (age < 7 * kDay) {
|
||||||
|
recency = 70;
|
||||||
|
} else if (age < 30 * kDay) {
|
||||||
|
recency = 50;
|
||||||
|
} else if (age < 90 * kDay) {
|
||||||
|
recency = 30;
|
||||||
|
} else {
|
||||||
|
recency = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequency factor: 1..10 visits scale the recency weight linearly, so a
|
||||||
|
// single recent use scores 10 and ten-plus recent uses saturate at the full
|
||||||
|
// recency weight (100). Capped so the bonus never dwarfs a textual-match
|
||||||
|
// tier (substring = 400) — frecency nudges, it does not override.
|
||||||
|
const int visits = qBound(1, e.count, 10);
|
||||||
|
return (recency * visits) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
int FrecencyStore::bonus(const QString &id, qint64 nowEpoch) const
|
||||||
|
{
|
||||||
|
auto it = m_entries.constFind(id);
|
||||||
|
if (it == m_entries.constEnd()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return computeBonus(it.value(), nowEpoch);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* FrecencyStore — persisted per-id usage ("frecency": frequency + recency)
|
||||||
|
* feeding the palette's ranking bonus.
|
||||||
|
*
|
||||||
|
* Each entry records how many times an id was activated and when it was last
|
||||||
|
* used. bonus() combines a recency bucket with a capped visit count into a
|
||||||
|
* small integer (0..100) that PaletteModel adds to the match score — enough to
|
||||||
|
* float habitual choices to the top on an empty query and to break ties among
|
||||||
|
* comparable matches, but capped well below a strong textual-match tier so it
|
||||||
|
* never overrides a clearly better match.
|
||||||
|
*
|
||||||
|
* Persistence is a flat JSON object at an explicit file path (so tests use a
|
||||||
|
* temp file); the plugin picks the real path from QStandardPaths. The class is
|
||||||
|
* Qt::Core only and has no display or event-loop dependency.
|
||||||
|
*/
|
||||||
|
#ifndef KATECUSTOM_FRECENCYSTORE_H
|
||||||
|
#define KATECUSTOM_FRECENCYSTORE_H
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace katecustom
|
||||||
|
{
|
||||||
|
|
||||||
|
class FrecencyStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
struct Entry {
|
||||||
|
int count = 0; // number of recorded activations
|
||||||
|
qint64 last = 0; // last activation, seconds since epoch (UTC)
|
||||||
|
};
|
||||||
|
|
||||||
|
/*! Construct backed by \a filePath. The file is not read until load(). */
|
||||||
|
explicit FrecencyStore(const QString &filePath = QString());
|
||||||
|
|
||||||
|
/*! Replace the backing file path (does not reload). */
|
||||||
|
void setFilePath(const QString &filePath) { m_filePath = filePath; }
|
||||||
|
QString filePath() const { return m_filePath; }
|
||||||
|
|
||||||
|
/*! Read entries from the backing file. Missing/invalid file clears to empty
|
||||||
|
* and is not an error. Returns true if a file was read. */
|
||||||
|
bool load();
|
||||||
|
|
||||||
|
/*! Write entries to the backing file, creating parent dirs. Returns true on
|
||||||
|
* success. */
|
||||||
|
bool save() const;
|
||||||
|
|
||||||
|
/*! Record one activation of \a id at \a nowEpoch (UTC seconds): increments
|
||||||
|
* the count and sets the last-used time. */
|
||||||
|
void bump(const QString &id, qint64 nowEpoch);
|
||||||
|
|
||||||
|
/*! Ranking bonus for \a id at \a nowEpoch, in [0, 100]. Unknown ids yield 0.
|
||||||
|
* Combines a recency bucket (newer = higher) with a capped visit count. */
|
||||||
|
int bonus(const QString &id, qint64 nowEpoch) const;
|
||||||
|
|
||||||
|
/*! Direct entry access (empty Entry if unknown). */
|
||||||
|
Entry entry(const QString &id) const { return m_entries.value(id); }
|
||||||
|
int size() const { return m_entries.size(); }
|
||||||
|
|
||||||
|
/*! Pure scoring used by bonus(); exposed for testing. */
|
||||||
|
static int computeBonus(const Entry &e, qint64 nowEpoch);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_filePath;
|
||||||
|
QHash<QString, Entry> m_entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace katecustom
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: LGPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* Unit tests for FrecencyStore: bump/bonus scoring (recency buckets, frequency
|
||||||
|
* scaling, caps) and JSON round-trip persistence to a temp file.
|
||||||
|
*/
|
||||||
|
#include "frecencystore.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
using namespace katecustom;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr qint64 kDay = 24 * 60 * 60;
|
||||||
|
constexpr qint64 kNow = 1'700'000'000; // fixed reference epoch
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class TestFrecencyStore : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private Q_SLOTS:
|
||||||
|
void unknownIdYieldsZero()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
QCOMPARE(s.bonus(QStringLiteral("nope"), kNow), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void singleRecentUseScoresTen()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
s.bump(QStringLiteral("a"), kNow);
|
||||||
|
// recency 100 (<1 day), 1 visit -> 100*1/10 = 10.
|
||||||
|
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
void frequentRecentUseSaturatesAtHundred()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
for (int i = 0; i < 15; ++i) {
|
||||||
|
s.bump(QStringLiteral("a"), kNow);
|
||||||
|
}
|
||||||
|
// visits capped at 10 -> 100*10/10 = 100.
|
||||||
|
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recencyDecaysWithAge()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
s.bump(QStringLiteral("a"), kNow - 10 * kDay); // 10 days ago
|
||||||
|
// 10 days -> <30 day bucket (50), 1 visit -> 5.
|
||||||
|
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 5);
|
||||||
|
|
||||||
|
FrecencyStore s2;
|
||||||
|
s2.bump(QStringLiteral("a"), kNow - 200 * kDay); // very old
|
||||||
|
// >90 days -> bucket 10, 1 visit -> 1.
|
||||||
|
QCOMPARE(s2.bonus(QStringLiteral("a"), kNow), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void recentBeatsOldAtEqualCount()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
s.bump(QStringLiteral("recent"), kNow);
|
||||||
|
s.bump(QStringLiteral("old"), kNow - 60 * kDay);
|
||||||
|
QVERIFY(s.bonus(QStringLiteral("recent"), kNow)
|
||||||
|
> s.bonus(QStringLiteral("old"), kNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
void futureTimestampTreatedAsNow()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
s.bump(QStringLiteral("a"), kNow + 5 * kDay); // clock skew
|
||||||
|
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 10); // age<0 -> recency 100
|
||||||
|
}
|
||||||
|
|
||||||
|
void roundTripsThroughFile()
|
||||||
|
{
|
||||||
|
QTemporaryDir tmp;
|
||||||
|
QVERIFY(tmp.isValid());
|
||||||
|
const QString path = QDir(tmp.path()).filePath(QStringLiteral("sub/frecency.json"));
|
||||||
|
|
||||||
|
FrecencyStore a(path);
|
||||||
|
a.bump(QStringLiteral("x"), kNow);
|
||||||
|
a.bump(QStringLiteral("x"), kNow);
|
||||||
|
a.bump(QStringLiteral("y"), kNow - 40 * kDay);
|
||||||
|
QVERIFY(a.save()); // creates the "sub" dir
|
||||||
|
|
||||||
|
FrecencyStore b(path);
|
||||||
|
QVERIFY(b.load());
|
||||||
|
QCOMPARE(b.size(), 2);
|
||||||
|
QCOMPARE(b.entry(QStringLiteral("x")).count, 2);
|
||||||
|
QCOMPARE(b.entry(QStringLiteral("x")).last, kNow);
|
||||||
|
QCOMPARE(b.bonus(QStringLiteral("x"), kNow), 20); // 100*2/10
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMissingFileIsEmptyNotError()
|
||||||
|
{
|
||||||
|
FrecencyStore s(QStringLiteral("/no/such/dir/frecency.json"));
|
||||||
|
QVERIFY(!s.load()); // nothing read
|
||||||
|
QCOMPARE(s.size(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emptyPathSaveAndLoadFail()
|
||||||
|
{
|
||||||
|
FrecencyStore s;
|
||||||
|
QVERIFY(!s.save());
|
||||||
|
QVERIFY(!s.load());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(TestFrecencyStore)
|
||||||
|
#include "test_frecencystore.moc"
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "ollieplugin.h"
|
#include "ollieplugin.h"
|
||||||
|
|
||||||
#include "palettewidget.h"
|
#include "palettewidget.h"
|
||||||
|
#include "frecencystore.h"
|
||||||
#include "radialmenu.h"
|
#include "radialmenu.h"
|
||||||
#include "radialconfig.h"
|
#include "radialconfig.h"
|
||||||
#include "olliecommands.h"
|
#include "olliecommands.h"
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QEvent>
|
#include <QEvent>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
|
@ -32,6 +34,7 @@
|
||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
#include <QStandardPaths>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
|
@ -215,6 +218,14 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
||||||
: QObject(plugin)
|
: QObject(plugin)
|
||||||
, m_mainWindow(mainWindow)
|
, m_mainWindow(mainWindow)
|
||||||
{
|
{
|
||||||
|
// Load persisted usage so frecency ranking is available from the first
|
||||||
|
// palette invocation. The store lives under the user's generic config dir.
|
||||||
|
const QString cfgDir =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
|
||||||
|
m_frecency.setFilePath(QDir(cfgDir).filePath(
|
||||||
|
QStringLiteral("olliepalette/frecency.json")));
|
||||||
|
m_frecency.load();
|
||||||
|
|
||||||
// The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the
|
// The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the
|
||||||
// keys sit together on a compact layout.
|
// keys sit together on a compact layout.
|
||||||
auto *openAction = new QAction(i18n("Command Palette (M-x)"), this);
|
auto *openAction = new QAction(i18n("Command Palette (M-x)"), this);
|
||||||
|
|
@ -440,6 +451,12 @@ void OllieView::showCommandPalette()
|
||||||
items.push_back(item);
|
items.push_back(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the persisted frecency bonus so habitual commands rank higher.
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
for (PaletteItem &item : items) {
|
||||||
|
item.frecency = m_frecency.bonus(item.id, now);
|
||||||
|
}
|
||||||
|
|
||||||
m_palette->setItems(items);
|
m_palette->setItems(items);
|
||||||
|
|
||||||
// Center the palette near the top of the active window.
|
// Center the palette near the top of the active window.
|
||||||
|
|
@ -453,8 +470,21 @@ void OllieView::showCommandPalette()
|
||||||
m_palette->activate();
|
m_palette->activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OllieView::recordUsage(const QString &id)
|
||||||
|
{
|
||||||
|
if (id.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_frecency.bump(id, QDateTime::currentSecsSinceEpoch());
|
||||||
|
m_frecency.save();
|
||||||
|
}
|
||||||
|
|
||||||
void OllieView::runAction(const QString &actionId)
|
void OllieView::runAction(const QString &actionId)
|
||||||
{
|
{
|
||||||
|
// Count every command-palette activation toward frecency (verbs, switcher
|
||||||
|
// entries, and harvested actions all flow through here).
|
||||||
|
recordUsage(actionId);
|
||||||
|
|
||||||
// A ":"-verb entry: execute the command directly on the active view.
|
// A ":"-verb entry: execute the command directly on the active view.
|
||||||
// We call OllieCommands::runVerb rather than Editor::queryCommand because
|
// We call OllieCommands::runVerb rather than Editor::queryCommand because
|
||||||
// our Command's auto-registration is not reliably visible to queryCommand.
|
// our Command's auto-registration is not reliably visible to queryCommand.
|
||||||
|
|
@ -492,13 +522,19 @@ void OllieView::showFileSwitcher()
|
||||||
connect(m_filePalette, &PaletteWidget::activatedId, this,
|
connect(m_filePalette, &PaletteWidget::activatedId, this,
|
||||||
[this](const QString &id) {
|
[this](const QString &id) {
|
||||||
if (id.startsWith(kFilePrefix)) {
|
if (id.startsWith(kFilePrefix)) {
|
||||||
openProjectFile(id.mid(QString(kFilePrefix).size()));
|
const QString rel = id.mid(QString(kFilePrefix).size());
|
||||||
|
// Record under a project-qualified key so frecency does
|
||||||
|
// not bleed between repos that share relative paths.
|
||||||
|
recordUsage(kFilePrefix + m_fileSwitcherRoot
|
||||||
|
+ QLatin1Char('\t') + rel);
|
||||||
|
openProjectFile(rel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
m_fileSwitcherRoot = projectRootFor(m_mainWindow);
|
m_fileSwitcherRoot = projectRootFor(m_mainWindow);
|
||||||
const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot);
|
const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot);
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
|
||||||
QList<PaletteItem> items;
|
QList<PaletteItem> items;
|
||||||
items.reserve(files.size());
|
items.reserve(files.size());
|
||||||
|
|
@ -511,6 +547,8 @@ void OllieView::showFileSwitcher()
|
||||||
// the whole path. The base name still ranks well because an exact or
|
// the whole path. The base name still ranks well because an exact or
|
||||||
// substring hit on it outscores a scattered subsequence.
|
// substring hit on it outscores a scattered subsequence.
|
||||||
item.label = rel;
|
item.label = rel;
|
||||||
|
item.frecency = m_frecency.bonus(
|
||||||
|
kFilePrefix + m_fileSwitcherRoot + QLatin1Char('\t') + rel, now);
|
||||||
items.push_back(item);
|
items.push_back(item);
|
||||||
}
|
}
|
||||||
m_filePalette->setItems(items);
|
m_filePalette->setItems(items);
|
||||||
|
|
@ -544,7 +582,11 @@ void OllieView::showSymbolSwitcher()
|
||||||
connect(m_symbolPalette, &PaletteWidget::activatedId, this,
|
connect(m_symbolPalette, &PaletteWidget::activatedId, this,
|
||||||
[this](const QString &id) {
|
[this](const QString &id) {
|
||||||
if (id.startsWith(kSymbolPrefix)) {
|
if (id.startsWith(kSymbolPrefix)) {
|
||||||
goToSymbol(id.mid(QString(kSymbolPrefix).size()));
|
const QString loc = id.mid(QString(kSymbolPrefix).size());
|
||||||
|
// Project-qualified key, as for the file switcher.
|
||||||
|
recordUsage(kSymbolPrefix + m_symbolSwitcherRoot
|
||||||
|
+ QLatin1Char('\t') + loc);
|
||||||
|
goToSymbol(loc);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -552,6 +594,7 @@ void OllieView::showSymbolSwitcher()
|
||||||
m_symbolSwitcherRoot = projectRootFor(m_mainWindow);
|
m_symbolSwitcherRoot = projectRootFor(m_mainWindow);
|
||||||
const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot);
|
const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot);
|
||||||
const QList<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
|
const QList<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
|
||||||
QList<PaletteItem> items;
|
QList<PaletteItem> items;
|
||||||
items.reserve(symbols.size());
|
items.reserve(symbols.size());
|
||||||
|
|
@ -559,7 +602,8 @@ void OllieView::showSymbolSwitcher()
|
||||||
PaletteItem item;
|
PaletteItem item;
|
||||||
// The id encodes the jump target as "relpath\tline"; the location is
|
// The id encodes the jump target as "relpath\tline"; the location is
|
||||||
// resolved against m_symbolSwitcherRoot on activation.
|
// resolved against m_symbolSwitcherRoot on activation.
|
||||||
item.id = kSymbolPrefix + s.file + QLatin1Char('\t') + QString::number(s.line);
|
const QString loc = s.file + QLatin1Char('\t') + QString::number(s.line);
|
||||||
|
item.id = kSymbolPrefix + loc;
|
||||||
|
|
||||||
// Label: scope-qualified name, then kind + file as visible provenance.
|
// Label: scope-qualified name, then kind + file as visible provenance.
|
||||||
// The delegate renders only the label and FuzzyRanker scores against it,
|
// The delegate renders only the label and FuzzyRanker scores against it,
|
||||||
|
|
@ -578,6 +622,8 @@ void OllieView::showSymbolSwitcher()
|
||||||
label += QStringLiteral(" — ") + suffix;
|
label += QStringLiteral(" — ") + suffix;
|
||||||
}
|
}
|
||||||
item.label = label;
|
item.label = label;
|
||||||
|
item.frecency = m_frecency.bonus(
|
||||||
|
kSymbolPrefix + m_symbolSwitcherRoot + QLatin1Char('\t') + loc, now);
|
||||||
|
|
||||||
items.push_back(item);
|
items.push_back(item);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
|
|
||||||
#include <KTextEditor/Plugin>
|
#include <KTextEditor/Plugin>
|
||||||
|
|
||||||
|
#include "frecencystore.h"
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
|
@ -66,6 +68,9 @@ private Q_SLOTS:
|
||||||
void goToSymbol(const QString &locationId);
|
void goToSymbol(const QString &locationId);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Record one activation of \a id and persist. Feeds the frecency bonus so
|
||||||
|
// habitual choices float up in every palette.
|
||||||
|
void recordUsage(const QString &id);
|
||||||
// One harvested, runnable action with the provenance needed to build a
|
// One harvested, runnable action with the provenance needed to build a
|
||||||
// unique id and to disambiguate labels shared by several commands.
|
// unique id and to disambiguate labels shared by several commands.
|
||||||
struct ActionEntry {
|
struct ActionEntry {
|
||||||
|
|
@ -121,6 +126,7 @@ private:
|
||||||
bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press
|
bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press
|
||||||
bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord
|
bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord
|
||||||
QHash<QString, QPointer<QAction>> m_actionsById;
|
QHash<QString, QPointer<QAction>> m_actionsById;
|
||||||
|
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace katecustom
|
} // namespace katecustom
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue