summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-02 17:02:18 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:52:15 +0200
commit83669b23c371ee0bdea178fce9920585dd55704b (patch)
tree98d09b34dd5c0da4196620296757ce5e26095bcd
parentd217d976ccdc439803003a26252904ca8abdd520 (diff)
downloadqtmaildir-83669b23c371ee0bdea178fce9920585dd55704b.tar.gz
qtmaildir-83669b23c371ee0bdea178fce9920585dd55704b.zip
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().
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/keymap.cpp82
-rw-r--r--src/keymap.h32
-rw-r--r--src/main_placeholder.cpp3
-rw-r--r--tests/CMakeLists.txt2
-rw-r--r--tests/test_keymap.cpp131
6 files changed, 248 insertions, 4 deletions
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 <QSettings>
+
+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<QString, QString> 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 <QHash>
+#include <QKeySequence>
+#include <QStringList>
+
+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<QKeySequence, QString> 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 <QtTest>
+#include <QTemporaryDir>
+#include <QSettings>
+#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"