7.5 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
att is a small, keyboard-driven QtWebEngine browser modelled on qutebrowser, in C++20 / Qt 6 (pinned nixpkgs ships Qt 6.11). The README is the user-facing reference for key bindings, modes and commands. When you add a binding, mode or command, update its tables there.
Build and run
Everything goes through the Nix flake:
nix develop -c sh -c 'cmake -B build -G Ninja && cmake --build build' # dev build
nix develop -c ./build/att [url] # run the dev build
nix build # package build (→ ./result)
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, and git added: 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.
Architecture
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), notkeyPressEvent. QtWebEngine delivers keys straight to its internal render widget, so the window would never see them otherwise. Dispatch order:- Command mode passes keys through to the
CommandLine. - Escape / Ctrl-[ always return to normal mode.
- Insert mode passes keys through to the page.
- Hint mode swallows every key.
- Normal mode goes to
handleNormalModeKey. Bindings areif (text == "x")chains, and two-key sequences (gg) usem_pendingKeys.
- Command mode passes keys through to the
setModeis where entering and leaving a mode takes effect. It updates the status line and focus. LeavingHintalso runs the hint-clear script.- Page interaction goes through
page()->runJavaScript. Scrolling, for example, is done with JS calls rather than Qt APIs. - Hint mode (
src/hints.cpp):- The state lives in JS (
window.__attHints), injected intoQWebEngineScript::ApplicationWorld. That world shares the DOM with the page but keeps its JS globals isolated from the page's scripts. - C++ sends one call per key (
keyScript/backspaceScript/clearScript). JS returns a{state: ...}map, andhandleHintResultacts on it. That map is the protocol, and it is documented inhints.h. - Results are asynchronous, so
handleHintResultignores them once the mode is no longerHint. - Following a hint sends a real
QMouseEventpress/release tom_view->focusProxy()(the render widget). The position is in CSS px multiplied by the zoom factor.
- The state lives in JS (
CommandLinekeeps the leading:in its text; deleting it cancels. It emitscommandEntered(cmd)without the:, andBrowserWindow::runCommandparses it as name plus the rest of the line.urlutil::fromUserInputturns what the user typed into a URL. In order, it tries an existing local path, a full URL, then something that looks like a host (getshttps://). Anything else becomes a DuckDuckGo search.- The
QWebEngineProfileis created inmain.cppand passed intoBrowserWindow. It is a named, on-disk profile withForcePersistentCookies, because Qt 6's default profile is off-the-record. It is parented to theQApplicationso it outlives the view's page. Qt warns if a profile is destroyed while a page still uses it. - Adblocking is split across two parts:
adblock/is a Rust staticlib exposing a hand-written C API (att_adblock.h, kept in sync withsrc/lib.rsby hand) over Brave'sadblockcrate. Corrosion builds it as part ofcmake --build, always in release. Itssingle-threaddefault feature is off, which makesEngineSend + Sync.AdBlocker(src/adblock.cpp) is the profile'sQWebEngineUrlRequestInterceptor.interceptRequestmay run off the GUI thread, so it reads the engine through a mutex-guardedshared_ptrthatupdate()swaps.- Lists are compiled on a
QtConcurrentworker and cached asadblock-cache.datin AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download. main.cppcreatesAdBlockerafter 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.visitsis an append-only log;urls(one row per URL, upserted) is what completion searches. Visits are recorded onloadFinished(true), noturlChanged. URLs are stored astoDisplayString()with the fragment removed, so atitleChangedafter an in-page jump still updates the right row. :opencompletion:BrowserWindow::updateCompletionruns on the command line'stextEditedand on entering command mode. It parses:open/:oand showsHistory::searchresults inCompletionView, which never takes focus.CommandLine::eventintercepts Tab/Backtab, becauseQWidget::eventwould otherwise turn them into focus changes. Picking an entry usessetCommandText, which doesn't emittextEdited, so it doesn't re-filter.- Greasemonkey (
Greasemonkey,src/greasemonkey.cpp, part of theSession) inserts eachgreasemonkey/*.jsintoprofile->scripts()as aQWebEngineScriptnamedatt-greasemonkey:<file>, inMainWorld.- URL matching is left to QtWebEngine, which parses the
==UserScript==block duringsetSourceCodeand checks@include/@exclude/@matchin Chromium. So the block must stay in the source, andsetInjectionPointmust come aftersetSourceCode. - att's own
parseMetadatais for run-at, noframes,@require(cached by SHA-1 of the URL) andGM_info. - Scripts are wrapped in an IIFE (
kWrapperHead/kWrapperTail) that defines the GM API as locals. - At
document-start,document.documentElementis still null, which the GM helpers must tolerate.
- URL matching is left to QtWebEngine, which parses the
- The UI stylesheet is the
kStyleSheetstring insrc/main.cpp. Widgets are styled byobjectNameand by dynamic properties:StatusBartoggleserrorand re-polishes the label to pick up the change.
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.