diff --git a/CLAUDE.md b/CLAUDE.md index fc7d0cf..7e84a0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ nix run . -- example.com Run `./build/att` from inside `nix develop`. The shell hook exports `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH` and `QTWEBENGINEPROCESS_PATH`, because `wrapQtAppsHook` only wraps *installed* binaries. Run outside the dev shell, the binary can't find Qt's plugins. -`package.nix` uses a `lib.fileset` source: `CMakeLists.txt`, `src/` and the listed `adblock/` files. Every new source file must also be added to `qt_add_executable` in `CMakeLists.txt`. The crate's cargo dependencies are vendored from `adblock/Cargo.lock` (`importCargoLock` + `cargoSetupHook`) for the offline sandbox build, so commit `Cargo.lock` changes along with `Cargo.toml` ones. +`package.nix` uses a `lib.fileset` source: `CMakeLists.txt`, `src/` and the listed `adblock/` files. Every new source file must also be added to `qt_add_executable` in `CMakeLists.txt`, and `git add`ed: the flake only sees tracked files, so `nix build` fails on untracked sources (`nix build path:.` works around that). The crate's cargo dependencies are vendored from `adblock/Cargo.lock` (`importCargoLock` + `cargoSetupHook`) for the offline sandbox build, so commit `Cargo.lock` changes along with `Cargo.toml` ones. The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo test'`). There is no linter. The dev shell includes `clang-tools` (clang-format, clangd) and `gdb`. @@ -49,6 +49,8 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo - `AdBlocker` (`src/adblock.cpp`) is the profile's `QWebEngineUrlRequestInterceptor`. `interceptRequest` may run off the GUI thread, so it reads the engine through a mutex-guarded `shared_ptr` that `update()` swaps. - Lists are compiled on a `QtConcurrent` worker and cached as `adblock-cache.dat` in AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download. - `main.cpp` creates `AdBlocker` *after* the profile, so it is destroyed after it. +- **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `visits` is an append-only log; `urls` (one row per URL, upserted) is what completion searches. Visits are recorded on `loadFinished(true)`, not `urlChanged`. URLs are stored as `toDisplayString()` with the fragment removed, so a `titleChanged` after an in-page jump still updates the right row. +- **`:open` completion:** `BrowserWindow::updateCompletion` runs on the command line's `textEdited` and on entering command mode. It parses `:open`/`:o` and shows `History::search` results in `CompletionView`, which never takes focus. `CommandLine::event` intercepts Tab/Backtab, because `QWidget::event` would otherwise turn them into focus changes. Picking an entry uses `setCommandText`, which doesn't emit `textEdited`, so it doesn't re-filter. - The UI stylesheet is the `kStyleSheet` string in `src/main.cpp`. Widgets are styled by `objectName` and by dynamic properties: `StatusBar` toggles `error` and re-polishes the label to pick up the change. There are no tabs yet. `createWindow` isn't implemented, so `target=_blank` and `window.open` go nowhere. To work around this, hint mode rewrites link targets to `_self`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 50d40fc..005ff78 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,7 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) -find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets Network Concurrent WebEngineCore WebEngineWidgets) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets Network Concurrent Sql WebEngineCore WebEngineWidgets) find_package(Corrosion REQUIRED) qt_standard_project_setup() @@ -34,10 +34,14 @@ qt_add_executable(att src/browserwindow.h src/commandline.cpp src/commandline.h + src/completionview.cpp + src/completionview.h src/gpuworkaround.cpp src/gpuworkaround.h src/hints.cpp src/hints.h + src/history.cpp + src/history.h src/statusbar.cpp src/statusbar.h src/urlutil.cpp @@ -53,6 +57,7 @@ target_link_libraries(att PRIVATE Qt6::Widgets Qt6::Network Qt6::Concurrent + Qt6::Sql Qt6::WebEngineCore Qt6::WebEngineWidgets ) diff --git a/README.md b/README.md index 21b8f40..04e120c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ Like qutebrowser, keys mean different things depending on the mode: The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`, or if you delete the leading `:`. +While typing `:open` (or `:o`), a completion list of previously visited pages +appears above it, most recent first. Every word you type must appear in the URL +or the title. `Tab` / `Shift-Tab` cycle through the list and put the selected URL +on the command line; `Enter` opens it. + ## Hint mode `f` labels every clickable element in the viewport with home-row letters @@ -82,7 +87,12 @@ shadow DOM yet. ## Browsing data -Cookies, local storage and history are kept in a persistent QtWebEngine profile +Browsing history is kept in `~/.local/share/att/att/history.sqlite` (tables +`visits`, one row per page load, and `urls`, one row per URL for completion). +A page is recorded once it finishes loading, so redirect hops and in-page +`#anchor` jumps don't create entries. `:history-clear` empties it. + +Cookies, local storage and Chromium's own state are kept in a persistent QtWebEngine profile under `~/.local/share/att/att/QtWebEngine/default` (the cache is in `~/.cache/att/att/`). Session cookies are saved too, like qutebrowser's `content.cookies.store`, so you stay logged in across restarts. Delete that @@ -104,7 +114,7 @@ on, for when a site breaks. ## Commands `:open ` (alias `:o`), `:quit` (alias `:q`), `:reload`, `:back`, -`:forward`, `:stop`, `:adblock-update`, `:adblock-toggle`. +`:forward`, `:stop`, `:history-clear`, `:adblock-update`, `:adblock-toggle`. `:open` accepts a full URL, a bare host (`example.com` → `https://example.com`), an existing local path, or free text, which goes to DuckDuckGo. @@ -118,6 +128,8 @@ an existing local path, or free text, which goes to DuckDuckGo. | `src/main.cpp` | `QApplication`, CLI parsing, stylesheet | | `src/browserwindow` | the window, mode state machine, key dispatch, commands | | `src/commandline` | the `:` prompt | +| `src/history` | SQLite browsing history | +| `src/completionview`| the `:open` completion list | | `src/statusbar` | mode / URL / progress line | | `src/adblock` | request interceptor around the adblock engine | | `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion | diff --git a/src/browserwindow.cpp b/src/browserwindow.cpp index 18c36fd..b6a63e1 100644 --- a/src/browserwindow.cpp +++ b/src/browserwindow.cpp @@ -2,6 +2,7 @@ #include "adblock.h" #include "commandline.h" +#include "completionview.h" #include "hints.h" #include "statusbar.h" #include "urlutil.h" @@ -11,8 +12,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -21,6 +24,14 @@ namespace { constexpr int kScrollStep = 60; constexpr double kZoomStep = 0.1; +constexpr int kCompletionLimit = 100; + +// ":open " or ":o "; captures the prefix and the query. +const QRegularExpression &openCommand() +{ + static const QRegularExpression re(QStringLiteral("^(:(?:open|o) +)(.*)$")); + return re; +} } // namespace @@ -31,6 +42,7 @@ BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, , m_view(new QWebEngineView(profile, this)) , m_status(new StatusBar(this)) , m_commandLine(new CommandLine(this)) + , m_completion(new CompletionView(this)) { setupUi(); connectView(); @@ -42,6 +54,8 @@ BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, connect(m_commandLine, &CommandLine::cancelled, this, [this] { setMode(Mode::Normal); }); + connect(m_commandLine, &QLineEdit::textEdited, this, &BrowserWindow::updateCompletion); + connect(m_commandLine, &CommandLine::completionCycled, this, &BrowserWindow::cycleCompletion); connect(m_adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage); qApp->installEventFilter(this); @@ -61,6 +75,7 @@ void BrowserWindow::setupUi() layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(m_view, 1); + layout->addWidget(m_completion); layout->addWidget(m_status); layout->addWidget(m_commandLine); setCentralWidget(central); @@ -80,11 +95,17 @@ void BrowserWindow::connectView() connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) { setWindowTitle(title.isEmpty() ? QStringLiteral("att") : QStringLiteral("%1 - att").arg(title)); + m_history.updateTitle(m_view->url(), title); }); connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) { m_status->setProgress(100); - if (!ok) + if (!ok) { m_status->showMessage(QStringLiteral("Failed to load page"), true); + return; + } + // Here rather than on urlChanged, which also fires for every redirect + // hop and in-page #anchor jump. + m_history.addVisit(m_view->url(), m_view->title()); }); } @@ -96,6 +117,9 @@ void BrowserWindow::setMode(Mode mode) m_mode = mode; m_pendingKeys.clear(); + if (mode != Mode::Command) + m_completion->setEntries({}); + switch (mode) { case Mode::Normal: m_status->setMode(QString()); @@ -109,6 +133,7 @@ void BrowserWindow::setMode(Mode mode) break; case Mode::Command: m_status->hide(); + updateCompletion(); break; case Mode::Hint: m_status->setMode(QStringLiteral("hint")); @@ -363,6 +388,14 @@ void BrowserWindow::runCommand(const QString &command) m_view->forward(); } else if (name == QLatin1String("stop")) { m_view->stop(); + } else if (name == QLatin1String("history-clear")) { + if (m_history.clear()) { + // Also forget Chromium's own record, which colours visited links. + m_view->page()->profile()->clearAllVisitedLinks(); + m_status->showMessage(QStringLiteral("History cleared")); + } else { + m_status->showMessage(QStringLiteral("Could not clear history"), true); + } } else if (name == QLatin1String("adblock-update")) { m_adBlocker->update(); } else if (name == QLatin1String("adblock-toggle")) { @@ -374,6 +407,30 @@ void BrowserWindow::runCommand(const QString &command) } } +void BrowserWindow::updateCompletion() +{ + const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); + if (!match.hasMatch()) { + m_completion->setEntries({}); + return; + } + const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); + m_completion->setEntries(m_history.search(words, kCompletionLimit)); +} + +void BrowserWindow::cycleCompletion(int step) +{ + if (!m_completion->isVisible()) + return; + const QString url = m_completion->cycle(step); + if (url.isEmpty()) + return; + // Keep whichever of ":o " / ":open " the user typed. + const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); + const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open "); + m_commandLine->setCommandText(prefix + url); +} + void BrowserWindow::openUrl(const QString &input) { const QUrl url = urlutil::fromUserInput(input); diff --git a/src/browserwindow.h b/src/browserwindow.h index 1ce90d5..32a3f1c 100644 --- a/src/browserwindow.h +++ b/src/browserwindow.h @@ -1,5 +1,7 @@ #pragma once +#include "history.h" + #include #include #include @@ -7,6 +9,7 @@ class AdBlocker; class CommandLine; +class CompletionView; class StatusBar; class QWebEngineProfile; class QWebEngineView; @@ -45,6 +48,8 @@ private: void handleHintResult(const QVariantMap &result); void clickAt(const QPointF &pos); void runCommand(const QString &command); + void updateCompletion(); + void cycleCompletion(int step); void openUrl(const QString &input); void scrollBy(int dx, int dy); void scrollPages(double factor); @@ -55,6 +60,8 @@ private: QWebEngineView *m_view; StatusBar *m_status; CommandLine *m_commandLine; + CompletionView *m_completion; + History m_history; Mode m_mode = Mode::Normal; QString m_pendingKeys; diff --git a/src/commandline.cpp b/src/commandline.cpp index 82c24c8..64647ec 100644 --- a/src/commandline.cpp +++ b/src/commandline.cpp @@ -1,5 +1,6 @@ #include "commandline.h" +#include #include #include @@ -27,6 +28,26 @@ void CommandLine::openWith(const QString &text) end(false); } +void CommandLine::setCommandText(const QString &text) +{ + setTextSilently(text); + end(false); +} + +bool CommandLine::event(QEvent *event) +{ + // QWidget::event turns Tab into a focus change before keyPressEvent would + // ever see it. + if (event->type() == QEvent::KeyPress) { + const int key = static_cast(event)->key(); + if (key == Qt::Key_Tab || key == Qt::Key_Backtab) { + emit completionCycled(key == Qt::Key_Tab ? 1 : -1); + return true; + } + } + return QLineEdit::event(event); +} + void CommandLine::keyPressEvent(QKeyEvent *event) { switch (event->key()) { @@ -96,6 +117,8 @@ void CommandLine::recallHistory(int direction) setTextSilently(pos == m_history.size() ? QStringLiteral(":") : QLatin1Char(':') + m_history.at(pos)); end(false); + // Recalling is an edit as far as completion is concerned. + emit textEdited(text()); } void CommandLine::cancel() diff --git a/src/commandline.h b/src/commandline.h index 703f7ab..137b582 100644 --- a/src/commandline.h +++ b/src/commandline.h @@ -14,13 +14,19 @@ public: // text should include the leading ":", e.g. ":open ". void openWith(const QString &text); + // Replaces the text without emitting textEdited, e.g. when a completion + // is picked. + void setCommandText(const QString &text); signals: // Emitted with the command only, i.e. without the leading ":". void commandEntered(const QString &command); void cancelled(); + // Tab (1) / Shift-Tab (-1). + void completionCycled(int step); protected: + bool event(QEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void focusOutEvent(QFocusEvent *event) override; diff --git a/src/completionview.cpp b/src/completionview.cpp new file mode 100644 index 0000000..d66a909 --- /dev/null +++ b/src/completionview.cpp @@ -0,0 +1,66 @@ +#include "completionview.h" + +#include + +namespace { + +constexpr int kMaxVisibleRows = 12; + +} // namespace + +CompletionView::CompletionView(QWidget *parent) + : QTreeWidget(parent) +{ + setObjectName(QStringLiteral("completion")); + setFocusPolicy(Qt::NoFocus); + setColumnCount(2); + setHeaderHidden(true); + setRootIsDecorated(false); + setUniformRowHeights(true); + setFrameShape(QFrame::NoFrame); + setSelectionMode(QAbstractItemView::SingleSelection); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setTextElideMode(Qt::ElideRight); + header()->setStretchLastSection(false); + header()->setSectionResizeMode(QHeaderView::Stretch); + hide(); +} + +void CompletionView::setEntries(const QList &entries) +{ + clear(); + if (entries.isEmpty()) { + hide(); + return; + } + + QList items; + items.reserve(entries.size()); + for (const History::Entry &entry : entries) { + auto *item = new QTreeWidgetItem({entry.url, entry.title}); + item->setToolTip(0, entry.url); + items.append(item); + } + addTopLevelItems(items); + + const int rows = qMin(int(entries.size()), kMaxVisibleRows); + setFixedHeight(rows * sizeHintForRow(0) + 2 * frameWidth()); + show(); +} + +QString CompletionView::cycle(int step) +{ + const int count = topLevelItemCount(); + if (count == 0) + return {}; + + QTreeWidgetItem *current = currentItem(); + int row = current && current->isSelected() ? indexOfTopLevelItem(current) + step + : (step > 0 ? 0 : count - 1); + row = ((row % count) + count) % count; + + QTreeWidgetItem *item = topLevelItem(row); + setCurrentItem(item); + scrollToItem(item); + return item->text(0); +} diff --git a/src/completionview.h b/src/completionview.h new file mode 100644 index 0000000..331c7d9 --- /dev/null +++ b/src/completionview.h @@ -0,0 +1,22 @@ +#pragma once + +#include "history.h" + +#include + +// The list above the command line offering history entries for ":open", with +// URL and title columns, like qutebrowser's completion widget. It never takes +// focus: keys stay with the CommandLine, which drives it via cycle(). +class CompletionView : public QTreeWidget +{ + Q_OBJECT + +public: + explicit CompletionView(QWidget *parent = nullptr); + + // Shows the entries with nothing selected, or hides the view if empty. + void setEntries(const QList &entries); + + // Moves the selection step rows (wrapping) and returns the selected URL. + QString cycle(int step); +}; diff --git a/src/history.cpp b/src/history.cpp new file mode 100644 index 0000000..4b1fbd0 --- /dev/null +++ b/src/history.cpp @@ -0,0 +1,173 @@ +#include "history.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr auto kConnection = "history"; + +// LIKE treats % and _ as wildcards; escape them so they match literally. +QString likePattern(const QString &word) +{ + QString escaped = word; + escaped.replace(QLatin1Char('\\'), QLatin1String("\\\\")); + escaped.replace(QLatin1Char('%'), QLatin1String("\\%")); + escaped.replace(QLatin1Char('_'), QLatin1String("\\_")); + return QLatin1Char('%') + escaped + QLatin1Char('%'); +} + +QString storedUrl(const QUrl &url) +{ + // The same readable form the status bar shows: it is what completion + // displays and what the user types to match against. Pretty-decoding keeps + // escapes that matter, like %2F, so the URL still round-trips. + // + // Without the #fragment: anchors on one page are one entry, and a title + // set after an in-page jump still finds the row its load created. + return url.adjusted(QUrl::RemoveFragment).toDisplayString(); +} + +} // namespace + +History::History() +{ + const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QDir().mkpath(dir); + + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QString::fromLatin1(kConnection)); + m_db.setDatabaseName(dir + QStringLiteral("/history.sqlite")); + if (!m_db.open()) { + qWarning("history: cannot open database: %s", qPrintable(m_db.lastError().text())); + return; + } + + m_open = exec(QStringLiteral("PRAGMA journal_mode=WAL")) + && exec(QStringLiteral("PRAGMA synchronous=NORMAL")) + && exec(QStringLiteral("CREATE TABLE IF NOT EXISTS visits (" + " url TEXT NOT NULL," + " title TEXT NOT NULL," + " at INTEGER NOT NULL)")) + && exec(QStringLiteral("CREATE TABLE IF NOT EXISTS urls (" + " url TEXT PRIMARY KEY," + " title TEXT NOT NULL," + " last_visit INTEGER NOT NULL," + " visits INTEGER NOT NULL DEFAULT 1)")) + && exec(QStringLiteral("CREATE INDEX IF NOT EXISTS urls_last_visit" + " ON urls(last_visit DESC)")); +} + +History::~History() +{ + m_db.close(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(QString::fromLatin1(kConnection)); +} + +bool History::isRecordable(const QUrl &url) +{ + const QString scheme = url.scheme(); + return scheme == QLatin1String("https") || scheme == QLatin1String("http") + || scheme == QLatin1String("file"); +} + +void History::addVisit(const QUrl &url, const QString &title) +{ + if (!m_open || !isRecordable(url)) + return; + + const QString u = storedUrl(url); + const qint64 now = QDateTime::currentSecsSinceEpoch(); + + m_db.transaction(); + + QSqlQuery visit(m_db); + visit.prepare(QStringLiteral("INSERT INTO visits (url, title, at) VALUES (?, ?, ?)")); + visit.addBindValue(u); + visit.addBindValue(title); + visit.addBindValue(now); + + QSqlQuery upsert(m_db); + upsert.prepare(QStringLiteral( + "INSERT INTO urls (url, title, last_visit) VALUES (?, ?, ?)" + " ON CONFLICT(url) DO UPDATE SET" + " title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END," + " last_visit = excluded.last_visit," + " visits = visits + 1")); + upsert.addBindValue(u); + upsert.addBindValue(title); + upsert.addBindValue(now); + + if (visit.exec() && upsert.exec()) { + m_db.commit(); + } else { + qWarning("history: cannot record visit: %s", + qPrintable((visit.lastError().isValid() ? visit : upsert).lastError().text())); + m_db.rollback(); + } +} + +void History::updateTitle(const QUrl &url, const QString &title) +{ + if (!m_open || title.isEmpty() || !isRecordable(url)) + return; + + QSqlQuery query(m_db); + query.prepare(QStringLiteral("UPDATE urls SET title = ? WHERE url = ?")); + query.addBindValue(title); + query.addBindValue(storedUrl(url)); + query.exec(); +} + +QList History::search(const QStringList &words, int limit) const +{ + QList entries; + if (!m_open) + return entries; + + QString sql = QStringLiteral("SELECT url, title FROM urls"); + for (qsizetype i = 0; i < words.size(); ++i) { + sql += i == 0 ? QStringLiteral(" WHERE ") : QStringLiteral(" AND "); + sql += QStringLiteral("(url LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\')"); + } + sql += QStringLiteral(" ORDER BY last_visit DESC LIMIT ?"); + + QSqlQuery query(m_db); + query.setForwardOnly(true); + query.prepare(sql); + for (const QString &word : words) { + const QString pattern = likePattern(word); + query.addBindValue(pattern); + query.addBindValue(pattern); + } + query.addBindValue(limit); + + if (!query.exec()) { + qWarning("history: search failed: %s", qPrintable(query.lastError().text())); + return entries; + } + while (query.next()) + entries.append({query.value(0).toString(), query.value(1).toString()}); + return entries; +} + +bool History::clear() +{ + if (!m_open) + return false; + return exec(QStringLiteral("DELETE FROM visits")) && exec(QStringLiteral("DELETE FROM urls")) + && exec(QStringLiteral("VACUUM")); +} + +bool History::exec(const QString &sql) +{ + QSqlQuery query(m_db); + if (query.exec(sql)) + return true; + qWarning("history: %s: %s", qPrintable(sql), qPrintable(query.lastError().text())); + return false; +} diff --git a/src/history.h b/src/history.h new file mode 100644 index 0000000..6316da0 --- /dev/null +++ b/src/history.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Browsing history in history.sqlite under AppDataLocation. Two tables: +// "visits" is an append-only log of every page load; "urls" holds one row per +// URL with its latest title and visit time, and is what completion searches. +// +// Queries run synchronously on the GUI thread. That is fine for SQLite at +// browser-history sizes, with WAL so a write never waits on a full fsync. +class History +{ +public: + struct Entry { + QString url; + QString title; + }; + + History(); + ~History(); + + History(const History &) = delete; + History &operator=(const History &) = delete; + + bool isOpen() const { return m_open; } + + void addVisit(const QUrl &url, const QString &title); + // Pages often set their title after the load finished. + void updateTitle(const QUrl &url, const QString &title); + + // Most recently visited first. Every word must appear in the URL or the + // title, case-insensitively. + QList search(const QStringList &words, int limit) const; + + bool clear(); + + // Whether a URL belongs in history at all: no internal or data pages. + static bool isRecordable(const QUrl &url); + +private: + bool exec(const QString &sql); + + QSqlDatabase m_db; + bool m_open = false; +}; diff --git a/src/main.cpp b/src/main.cpp index 108827d..9ba4db9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,14 @@ constexpr auto kStyleSheet = R"( QLabel#statusProgress { color: #8a8a8a; } + QTreeWidget#completion { + background-color: #262626; + color: #d8d8d8; + font-family: monospace; + border-top: 1px solid #3a3a3a; + selection-background-color: #e8c000; + selection-color: #000000; + } QLineEdit#commandLine { padding: 1px 4px; selection-background-color: #445566;