From dd7845d847a3d6f961f0302551c33245d1f12bdc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 2 Aug 2026 17:02:18 +0200 Subject: feat: add KeyMap with defaults and INI overrides Maps key sequences to action name strings, with hardcoded vim-style defaults and QSettings-based [keys] overrides. Unknown actions and unparseable sequences are collected as warnings rather than treated as fatal, so a typo in the config cannot silently misbind or crash. Note: QKeySequence::fromString() on Qt 6.11 does not return an empty sequence for unparseable input (e.g. "NotAKey++") -- it returns a non-empty sequence whose toString() is empty. Detection uses that instead of isEmpty(). --- src/CMakeLists.txt | 2 +- src/keymap.cpp | 82 +++++++++++++++++++++++++++++ src/keymap.h | 32 ++++++++++++ src/main_placeholder.cpp | 3 -- tests/CMakeLists.txt | 2 + tests/test_keymap.cpp | 131 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 src/keymap.cpp create mode 100644 src/keymap.h delete mode 100644 src/main_placeholder.cpp create mode 100644 tests/test_keymap.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e9e28e5..aeb425f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,5 @@ add_library(qtmaildir_lib STATIC - main_placeholder.cpp + keymap.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/keymap.cpp b/src/keymap.cpp new file mode 100644 index 0000000..6e43f1b --- /dev/null +++ b/src/keymap.cpp @@ -0,0 +1,82 @@ +#include "keymap.h" + +#include + +QStringList KeyMap::knownActions() +{ + // Keep in sync with the actions MainWindow registers. + return { + QStringLiteral("next_thread"), + QStringLiteral("prev_thread"), + QStringLiteral("open_thread"), + QStringLiteral("archive"), + QStringLiteral("delete"), + QStringLiteral("spam"), + QStringLiteral("toggle_unread"), + QStringLiteral("flag"), + QStringLiteral("focus_query"), + QStringLiteral("toggle_html"), + QStringLiteral("load_remote"), + QStringLiteral("undo"), + QStringLiteral("sync"), + QStringLiteral("quit"), + }; +} + +void KeyMap::loadDefaults() +{ + const QHash defaults = { + { QStringLiteral("j"), QStringLiteral("next_thread") }, + { QStringLiteral("k"), QStringLiteral("prev_thread") }, + { QStringLiteral("Return"), QStringLiteral("open_thread") }, + { QStringLiteral("a"), QStringLiteral("archive") }, + { QStringLiteral("d"), QStringLiteral("delete") }, + { QStringLiteral("N"), QStringLiteral("toggle_unread") }, + { QStringLiteral("F"), QStringLiteral("flag") }, + { QStringLiteral("/"), QStringLiteral("focus_query") }, + { QStringLiteral("h"), QStringLiteral("toggle_html") }, + { QStringLiteral("u"), QStringLiteral("undo") }, + { QStringLiteral("G"), QStringLiteral("sync") }, + { QStringLiteral("Ctrl+Q"), QStringLiteral("quit") }, + }; + + for (auto it = defaults.cbegin(); it != defaults.cend(); ++it) + m_bindings.insert(QKeySequence::fromString(it.key()), it.value()); +} + +void KeyMap::loadOverrides(QSettings &settings) +{ + const QStringList known = knownActions(); + + settings.beginGroup(QStringLiteral("keys")); + const QStringList keys = settings.childKeys(); + for (const QString &key : keys) { + const QString action = settings.value(key).toString(); + + const QKeySequence sequence = QKeySequence::fromString(key); + // QKeySequence::fromString() does not return an empty sequence for + // unparseable input; it returns a non-empty sequence whose + // toString() is empty (verified on Qt 6.11). Use that to detect + // garbage input instead. + if (sequence.isEmpty() || sequence.toString().isEmpty()) { + m_warnings.append( + QStringLiteral("Unparseable key sequence '%1' in [keys]").arg(key)); + continue; + } + + if (!known.contains(action)) { + m_warnings.append( + QStringLiteral("Unknown action '%1' bound to '%2' in [keys]") + .arg(action, key)); + continue; + } + + m_bindings.insert(sequence, action); + } + settings.endGroup(); +} + +QString KeyMap::actionFor(const QKeySequence &sequence) const +{ + return m_bindings.value(sequence); +} diff --git a/src/keymap.h b/src/keymap.h new file mode 100644 index 0000000..3f0f85c --- /dev/null +++ b/src/keymap.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +class QSettings; + +/// Maps key sequences to action names. Action names are plain strings so this +/// class has no dependency on the widgets that implement the actions. +class KeyMap +{ +public: + /// Every action name the application understands. loadOverrides() rejects + /// anything not in this set, so a typo in the config cannot bind silently. + static QStringList knownActions(); + + void loadDefaults(); + + /// Reads the [keys] group. Invalid sequences and unknown action names are + /// collected into warnings() rather than throwing or aborting. + void loadOverrides(QSettings &settings); + + /// Empty string when nothing is bound. + QString actionFor(const QKeySequence &sequence) const; + + QStringList warnings() const { return m_warnings; } + +private: + QHash m_bindings; + QStringList m_warnings; +}; diff --git a/src/main_placeholder.cpp b/src/main_placeholder.cpp deleted file mode 100644 index 9ddb3f2..0000000 --- a/src/main_placeholder.cpp +++ /dev/null @@ -1,3 +0,0 @@ -// Placeholder so the library target has a source file before real code lands. -// Removed in Task 2. -namespace { int qtmaildir_placeholder = 0; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3344d4a..d4f0d3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,3 +4,5 @@ function(add_qtmaildir_test name) target_link_libraries(test_${name} PRIVATE qtmaildir_lib Qt6::Test) add_test(NAME ${name} COMMAND test_${name}) endfunction() + +add_qtmaildir_test(keymap) diff --git a/tests/test_keymap.cpp b/tests/test_keymap.cpp new file mode 100644 index 0000000..10863f2 --- /dev/null +++ b/tests/test_keymap.cpp @@ -0,0 +1,131 @@ +#include +#include +#include +#include "keymap.h" + +class TestKeyMap : public QObject +{ + Q_OBJECT +private slots: + void defaultsAreLoaded(); + void iniOverridesDefault(); + void iniAddsNewBinding(); + void chordSequenceParses(); + void unknownActionIsReported(); + void invalidSequenceIsReported(); +}; + +void TestKeyMap::defaultsAreLoaded() +{ + KeyMap map; + map.loadDefaults(); + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("j"))), + QStringLiteral("next_thread")); + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("a"))), + QStringLiteral("archive")); +} + +void TestKeyMap::iniOverridesDefault() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("j"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + map.loadDefaults(); + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("j"))), + QStringLiteral("archive")); + // An untouched default survives. + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("k"))), + QStringLiteral("prev_thread")); +} + +void TestKeyMap::iniAddsNewBinding() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("Ctrl+Shift+A"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + map.loadDefaults(); + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("Ctrl+Shift+A"))), + QStringLiteral("archive")); +} + +void TestKeyMap::chordSequenceParses() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("g,i"), QStringLiteral("focus_query")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + const QKeySequence chord = QKeySequence::fromString(QStringLiteral("g,i")); + QCOMPARE(chord.count(), 2); + QCOMPARE(map.actionFor(chord), QStringLiteral("focus_query")); +} + +void TestKeyMap::unknownActionIsReported() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("z"), QStringLiteral("no_such_action")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + // Reported, not fatal, and not bound. + QCOMPARE(map.warnings().size(), 1); + QVERIFY(map.warnings().first().contains(QStringLiteral("no_such_action"))); + QVERIFY(map.actionFor(QKeySequence(QStringLiteral("z"))).isEmpty()); +} + +void TestKeyMap::invalidSequenceIsReported() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("NotAKey++"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.warnings().size(), 1); +} + +QTEST_MAIN(TestKeyMap) +#include "test_keymap.moc" -- cgit v1.2.3