Files
att_browser/CLAUDE.md
T

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), 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.
    2. Escape / Ctrl-[ always return to normal mode.
    3. Insert mode passes keys through to the page.
    4. Hint mode swallows every key.
    5. Normal mode goes to handleNormalModeKey. Bindings are if (text == "x") chains, and two-key sequences (gg) use m_pendingKeys.
  • setMode is where entering and leaving a mode takes effect. It updates the status line and focus. Leaving Hint also 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 into QWebEngineScript::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, and handleHintResult acts on it. That map is the protocol, and it is documented in hints.h.
    • Results are asynchronous, so handleHintResult ignores them once the mode is no longer Hint.
    • Following a hint sends a real QMouseEvent press/release to m_view->focusProxy() (the render widget). The position is in CSS px multiplied by the zoom factor.
  • CommandLine keeps the leading : in its text; deleting it cancels. It emits commandEntered(cmd) without the :, and BrowserWindow::runCommand parses it as name plus the rest of the line.
  • urlutil::fromUserInput turns 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 (gets https://). Anything else becomes a DuckDuckGo search.
  • The QWebEngineProfile is created in main.cpp and passed into BrowserWindow. It is a named, on-disk profile with ForcePersistentCookies, because Qt 6's default profile is off-the-record. It is parented to the QApplication so 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 with src/lib.rs by hand) over Brave's adblock crate. Corrosion builds it as part of cmake --build, always in release. Its single-thread default feature is off, which makes Engine Send + Sync.
    • 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.
  • Greasemonkey (Greasemonkey, src/greasemonkey.cpp, part of the Session) inserts each greasemonkey/*.js into profile->scripts() as a QWebEngineScript named att-greasemonkey:<file>, in MainWorld.
    • URL matching is left to QtWebEngine, which parses the ==UserScript== block during setSourceCode and checks @include/@exclude/@match in Chromium. So the block must stay in the source, and setInjectionPoint must come after setSourceCode.
    • att's own parseMetadata is for run-at, noframes, @require (cached by SHA-1 of the URL) and GM_info.
    • Scripts are wrapped in an IIFE (kWrapperHead/kWrapperTail) that defines the GM API as locals.
    • At document-start, document.documentElement is still null, which the GM helpers must tolerate.
  • 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.

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.