diff --git a/CLAUDE.md b/CLAUDE.md index 7e84a0a..94d2609 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo ## Architecture -`BrowserWindow` (`src/browserwindow.cpp`) is the core of the browser. It owns a single `QWebEngineView`, the `StatusBar` and the `CommandLine`, and it runs the mode state machine (`Normal`, `Insert`, `Command`, `Hint`). +`BrowserWindow` (`src/browserwindow.cpp`) is the core of the browser. Each one owns a single `QWebEngineView`, the `StatusBar` and the `CommandLine`, and it runs the mode state machine (`Normal`, `Insert`, `Command`, `Hint`). - **Key handling uses an application-wide event filter** (`qApp->installEventFilter`, `BrowserWindow::eventFilter`), not `keyPressEvent`. QtWebEngine delivers keys straight to its internal render widget, so the window would never see them otherwise. Dispatch order: 1. Command mode passes keys through to the `CommandLine`. @@ -53,4 +53,6 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo - **`: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`. +**Windows, not tabs.** att deliberately has no tab bar, because the user's window manager does the tabbing. Map qutebrowser's tab features onto top-level windows: `d` closes the window, `O` / `:open -w` (or `-t`) opens a new one. Each `BrowserWindow` is one page, with `WA_DeleteOnClose`. All windows share a `Session` (profile, `AdBlocker`, `History`) that `main()` owns. After `app.exec()`, `main()` deletes any window still alive before the history and profile go away. `:quit` closes all windows; `:close` closes one. + +`newWindowRequested` isn't handled yet, so `target=_blank` and `window.open` go nowhere. Hint mode rewrites link targets to `_self` to work around this. diff --git a/README.md b/README.md index 05d3791..021b30c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,10 @@ A small, keyboard-driven QtWebEngine browser in the spirit of qutebrowser. -This is the skeleton: one window, one page, a qutebrowser-style status line and -`:` command line, `f` link hints and adblocking. No tabs and no config file yet. +One page per window, a qutebrowser-style status line and `:` command line, `f` +link hints and adblocking. There is deliberately no tab bar: where qutebrowser +opens or closes a tab, att opens or closes a window, and your window manager +does the tabbing. No config file yet. ## Running @@ -52,9 +54,12 @@ is hidden while a page is fullscreen. | Key | Action | | -------------- | ---------------------------------------- | | `o` | open the command line prefilled `:open ` | -| `O` | same, prefilled with the current URL | +| `O` | same, but opens in a new window (`-w`) | +| `go` / `gO` | edit the current URL, here / new window | +| `d` | close this window | | `:` | open a bare command line | | `i` | enter insert mode | +| `gi` | focus the first text field, insert mode | | `f` | hint mode: follow a link by its label | | `Escape` | back to normal mode | | `h` `j` `k` `l`| scroll | @@ -80,8 +85,7 @@ on the command line; `Enter` opens it. (`asdfghjkl`). Type a label to click it; `Backspace` undoes a letter, `Escape` cancels. The click is a real mouse event sent to the render widget, so it works on sites that ignore `element.click()`. Following a text field drops you into -insert mode. Links that would open a new window are opened in place, since there -are no tabs yet. +insert mode. Links that would open a new window are opened in place for now. The hint script runs in `QWebEngineScript::ApplicationWorld`, so the page's own JavaScript can't see or interfere with it. It doesn't descend into iframes or @@ -115,8 +119,10 @@ on, for when a site breaks. ## Commands -`:open ` (alias `:o`), `:quit` (alias `:q`), `:reload`, `:back`, -`:forward`, `:stop`, `:history-clear`, `:adblock-update`, `:adblock-toggle`. +`:open [-w] ` (alias `:o`; `-w`, or qutebrowser's `-t`, opens a new +window), `:close` (alias `:tab-close`, closes this window), `:quit` (alias `:q`, +closes all windows), `:reload`, `:back`, `: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. @@ -145,10 +151,11 @@ parent window never sees them otherwise. ## Next steps -- Tabs (`QTabWidget` + a per-tab `QWebEngineView`), `gt`/`gT`, `d` to close. +- Open `target=_blank` links, `window.open` and middle-clicks in new windows + (`QWebEnginePage::newWindowRequested`). - Auto insert mode: inject a `QWebEngineScript` that reports `focusin` / `focusout` on editable elements back over `QWebChannel`. -- More hint modes: `F` (new tab, once there are tabs), `;y` to yank a link. +- More hint modes: `F` (follow in a new window), `;y` to yank a link. - `/` and `?` search with `n`/`N`, using `QWebEnginePage::findText`. - A config file for bindings, start page and search engine. - Counts before commands (`3j`) and a proper command parser with completion. diff --git a/src/browserwindow.cpp b/src/browserwindow.cpp index af7997a..45d290a 100644 --- a/src/browserwindow.cpp +++ b/src/browserwindow.cpp @@ -4,6 +4,7 @@ #include "commandline.h" #include "completionview.h" #include "hints.h" +#include "history.h" #include "statusbar.h" #include "urlutil.h" @@ -28,24 +29,25 @@ constexpr int kScrollStep = 60; constexpr double kZoomStep = 0.1; constexpr int kCompletionLimit = 100; -// ":open " or ":o "; captures the prefix and the query. +// ":open [-w|-t] " or ":o ..."; captures the prefix, flag included, +// and the query. const QRegularExpression &openCommand() { - static const QRegularExpression re(QStringLiteral("^(:(?:open|o) +)(.*)$")); + static const QRegularExpression re(QStringLiteral("^(:(?:open|o) +(?:-[wt] +)?)(.*)$")); return re; } } // namespace -BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, - const QUrl &startUrl, QWidget *parent) +BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent) : QMainWindow(parent) - , m_adBlocker(adBlocker) - , m_view(new QWebEngineView(profile, this)) + , m_session(session) + , m_view(new QWebEngineView(session.profile, this)) , m_status(new StatusBar(this)) , m_commandLine(new CommandLine(this)) , m_completion(new CompletionView(this)) { + setAttribute(Qt::WA_DeleteOnClose); setupUi(); connectView(); @@ -58,7 +60,7 @@ BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, }); 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); + connect(m_session.adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage); qApp->installEventFilter(this); @@ -106,7 +108,7 @@ 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); + m_session.history->updateTitle(m_view->url(), title); }); connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) { m_status->setProgress(100); @@ -116,7 +118,7 @@ void BrowserWindow::connectView() } // 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()); + m_session.history->addVisit(m_view->url(), m_view->title()); }); } @@ -246,6 +248,18 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) scrollToEnd(false); return true; } + if (sequence == QLatin1String("gi")) { + focusInput(); + return true; + } + if (sequence == QLatin1String("go") || sequence == QLatin1String("gO")) { + // Edit the current URL, to open it here or in a new window. + m_commandLine->openWith((sequence == QLatin1String("go") ? QStringLiteral(":open ") + : QStringLiteral(":open -w ")) + + m_view->url().toString()); + setMode(Mode::Command); + return true; + } return true; // Swallow the unknown sequence rather than leaking it. } @@ -260,11 +274,14 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) return true; } if (text == QLatin1String("O")) { - // Edit the current URL. - m_commandLine->openWith(QStringLiteral(":open ") + m_view->url().toString()); + m_commandLine->openWith(QStringLiteral(":open -w ")); setMode(Mode::Command); return true; } + if (text == QLatin1String("d")) { + close(); + return true; + } if (text == QLatin1String(":")) { m_commandLine->openWith(QStringLiteral(":")); setMode(Mode::Command); @@ -355,6 +372,21 @@ void BrowserWindow::startHints() runHintScript(hints::showScript()); } +void BrowserWindow::focusInput() +{ + m_view->page()->runJavaScript(hints::focusInputScript(), QWebEngineScript::ApplicationWorld, + [this](const QVariant &focused) { + // Ignore a result that arrives after the + // user already moved on, e.g. to ":". + if (m_mode != Mode::Normal) + return; + if (focused.toBool()) + setMode(Mode::Insert); + else + m_status->showMessage(QStringLiteral("No input field found"), true); + }); +} + void BrowserWindow::runHintScript(const QString &script) { m_view->page()->runJavaScript(script, QWebEngineScript::ApplicationWorld, @@ -416,9 +448,15 @@ void BrowserWindow::runCommand(const QString &command) const QString args = command.section(QLatin1Char(' '), 1).trimmed(); if (name == QLatin1String("open") || name == QLatin1String("o")) { - openUrl(args); - } else if (name == QLatin1String("quit") || name == QLatin1String("q")) { + // qutebrowser's -t (new tab) means the same as -w here: a new window. + const QString flag = args.section(QLatin1Char(' '), 0, 0); + const bool newWindow = flag == QLatin1String("-w") || flag == QLatin1String("-t"); + openUrl(newWindow ? args.section(QLatin1Char(' '), 1).trimmed() : args, newWindow); + } else if (name == QLatin1String("close") || name == QLatin1String("tab-close")) { close(); + } else if (name == QLatin1String("quit") || name == QLatin1String("q")) { + // Like qutebrowser: quit the browser, not just this window. + QApplication::closeAllWindows(); } else if (name == QLatin1String("reload")) { m_view->reload(); } else if (name == QLatin1String("back")) { @@ -428,7 +466,7 @@ void BrowserWindow::runCommand(const QString &command) } else if (name == QLatin1String("stop")) { m_view->stop(); } else if (name == QLatin1String("history-clear")) { - if (m_history.clear()) { + if (m_session.history->clear()) { // Also forget Chromium's own record, which colours visited links. m_view->page()->profile()->clearAllVisitedLinks(); m_status->showMessage(QStringLiteral("History cleared")); @@ -436,10 +474,10 @@ void BrowserWindow::runCommand(const QString &command) m_status->showMessage(QStringLiteral("Could not clear history"), true); } } else if (name == QLatin1String("adblock-update")) { - m_adBlocker->update(); + m_session.adBlocker->update(); } else if (name == QLatin1String("adblock-toggle")) { - m_adBlocker->setEnabled(!m_adBlocker->isEnabled()); - m_status->showMessage(m_adBlocker->isEnabled() ? QStringLiteral("Adblock enabled") + m_session.adBlocker->setEnabled(!m_session.adBlocker->isEnabled()); + m_status->showMessage(m_session.adBlocker->isEnabled() ? QStringLiteral("Adblock enabled") : QStringLiteral("Adblock disabled")); } else { m_status->showMessage(QStringLiteral("Unknown command: %1").arg(name), true); @@ -454,7 +492,7 @@ void BrowserWindow::updateCompletion() return; } const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); - m_completion->setEntries(m_history.search(words, kCompletionLimit)); + m_completion->setEntries(m_session.history->search(words, kCompletionLimit)); } void BrowserWindow::cycleCompletion(int step) @@ -470,13 +508,17 @@ void BrowserWindow::cycleCompletion(int step) m_commandLine->setCommandText(prefix + url); } -void BrowserWindow::openUrl(const QString &input) +void BrowserWindow::openUrl(const QString &input, bool newWindow) { const QUrl url = urlutil::fromUserInput(input); if (!url.isValid() || url.isEmpty()) { m_status->showMessage(QStringLiteral("Invalid URL: %1").arg(input), true); return; } + if (newWindow) { + (new BrowserWindow(m_session, url))->show(); + return; + } m_view->setUrl(url); focusView(); } diff --git a/src/browserwindow.h b/src/browserwindow.h index 8555da3..e7c16be 100644 --- a/src/browserwindow.h +++ b/src/browserwindow.h @@ -1,7 +1,5 @@ #pragma once -#include "history.h" - #include #include #include @@ -10,10 +8,21 @@ class AdBlocker; class CommandLine; class CompletionView; +class History; class StatusBar; class QWebEngineProfile; class QWebEngineView; +// What all windows share. Owned by main(), and outlives every window. +struct Session { + QWebEngineProfile *profile; + AdBlocker *adBlocker; + History *history; +}; + +// One page per window: att has no tabs. Where qutebrowser opens or closes a +// tab, att opens or closes a window and leaves tabbing to the window manager. +// Windows delete themselves when closed. class BrowserWindow : public QMainWindow { Q_OBJECT @@ -26,8 +35,7 @@ public: Hint, // keys pick a link label }; - BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker, const QUrl &startUrl, - QWidget *parent = nullptr); + BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent = nullptr); void setMode(Mode mode); @@ -44,13 +52,14 @@ private: bool handleNormalModeKey(QKeyEvent *event); void handleHintModeKey(QKeyEvent *event); void startHints(); + void focusInput(); void runHintScript(const QString &script); 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 openUrl(const QString &input, bool newWindow); void scrollBy(int dx, int dy); void scrollPages(double factor); void scrollToEnd(bool bottom); @@ -58,12 +67,11 @@ private: void setPageFullScreen(bool on); void updateStatusVisibility(); - AdBlocker *m_adBlocker; + Session m_session; 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/hints.cpp b/src/hints.cpp index e65697b..5eeb047 100644 --- a/src/hints.cpp +++ b/src/hints.cpp @@ -2,7 +2,7 @@ namespace { -constexpr auto kChars = "asdfghjkl"; +constexpr auto kChars = "1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p"; // Installs window.__attHints and shows the labels. "@CHARS@" is substituted // with kChars before running. @@ -178,6 +178,75 @@ constexpr auto kShowScript = R"JS( })(); )JS"; +constexpr auto kFocusInputScript = R"JS( +(function () { + 'use strict'; + const TEXT_INPUTS = new Set(['', 'text', 'search', 'email', 'url', 'tel', + 'password', 'number']); + + function isTextField(el) { + if (el.disabled || el.readOnly) + return false; + if (el.tagName === 'TEXTAREA') + return true; + // Only the root of an editable region, not every element inside it. + if (el.isContentEditable) + return !el.parentElement?.isContentEditable; + return el.tagName === 'INPUT' && TEXT_INPUTS.has((el.getAttribute('type') || '').toLowerCase()); + } + + function isVisible(el) { + const style = getComputedStyle(el); + if (style.visibility !== 'visible' || style.display === 'none') + return false; + const r = el.getBoundingClientRect(); + return r.width >= 1 && r.height >= 1; + } + + function inViewport(el) { + const r = el.getBoundingClientRect(); + return r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; + } + + // Document order, descending into each open shadow root right after its host. + function* elements(root) { + for (const el of root.querySelectorAll('*')) { + yield el; + if (el.shadowRoot) + yield* elements(el.shadowRoot); + } + } + + let fallback = null; + let target = null; + for (const el of elements(document)) { + if (!isTextField(el) || !isVisible(el)) + continue; + if (inViewport(el)) { + target = el; + break; + } + fallback = fallback || el; + } + target = target || fallback; + if (!target) + return false; + + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + target.focus(); + // Put the cursor after any existing text, ready to type or edit. + if (typeof target.setSelectionRange === 'function') { + try { + target.setSelectionRange(target.value.length, target.value.length); + } catch (e) { + // type=email/number don't support selection ranges. + } + } + // From outside a shadow root, document.activeElement is only its host. + return target.getRootNode().activeElement === target; +})(); +)JS"; + QString callScript(const QString &call) { return QStringLiteral("window.__attHints ? window.__attHints.%1 : { state: 'none' };") @@ -214,4 +283,9 @@ QString clearScript() return callScript(QStringLiteral("clear()")); } +QString focusInputScript() +{ + return QString::fromUtf8(kFocusInputScript); +} + } // namespace hints diff --git a/src/hints.h b/src/hints.h index 663fa4f..3b47b84 100644 --- a/src/hints.h +++ b/src/hints.h @@ -26,4 +26,9 @@ QString keyScript(QChar key); QString backspaceScript(); QString clearScript(); +// "gi": focuses the first visible text field, preferring one in the viewport, +// and evaluates to true if there was one. Like qutebrowser, it looks into open +// shadow roots too, where sites built from web components keep their inputs. +QString focusInputScript(); + } // namespace hints diff --git a/src/main.cpp b/src/main.cpp index 9ba4db9..05ea6be 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "adblock.h" #include "browserwindow.h" #include "gpuworkaround.h" +#include "history.h" #include "urlutil.h" #include @@ -82,10 +83,24 @@ int main(int argc, char *argv[]) auto *adBlocker = new AdBlocker(&app); profile->setUrlRequestInterceptor(adBlocker); - BrowserWindow window(profile, adBlocker, startUrl); - window.show(); + // Shared by every window; after QApplication, which QtSql needs. + History history; + const Session session{profile, adBlocker, &history}; + + // Windows delete themselves on close; the app quits with the last one. + (new BrowserWindow(session, startUrl))->show(); // After the window exists, so its status bar shows the update messages. adBlocker->start(); - return app.exec(); + const int status = app.exec(); + + // A window closed last may still be waiting for its deleteLater. Delete it + // now, while the history and the profile it uses are still alive: Qt warns + // about a profile destroyed before its pages, too. + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (qobject_cast(widget)) + delete widget; + } + return status; }