#include "browserwindow.h" #include "adblock.h" #include "commandline.h" #include "config.h" #include "completionview.h" #include "greasemonkey.h" #include "hints.h" #include "history.h" #include "statusbar.h" #include "urlutil.h" #include "useragent.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr int kScrollStep = 60; constexpr int kCompletionLimit = 100; // What QtWebEngine accepts. constexpr double kZoomMin = 0.25; constexpr double kZoomMax = 5.0; // ":open [-w|-t] " or ":o ..."; captures the prefix, flag included, // and the query. const QRegularExpression &openCommand() { static const QRegularExpression re(QStringLiteral("^(:(?:open|o) +(?:-[wt] +)?)(.*)$")); return re; } // ":useragent " or ":ua ..."; captures the prefix and the name. const QRegularExpression &userAgentCommand() { static const QRegularExpression re(QStringLiteral("^(:(?:useragent|ua) +)(.*)$")); return re; } } // namespace BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent) : QMainWindow(parent) , 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(); connect(m_commandLine, &CommandLine::commandEntered, this, [this](const QString &c) { setMode(Mode::Normal); runCommand(c); }); 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_session.adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage); connect(m_session.greasemonkey, &Greasemonkey::message, m_status, &StatusBar::showMessage); qApp->installEventFilter(this); setMode(Mode::Normal); setZoom(config::kZoomDefault, false); m_view->setUrl(startUrl); focusView(); } void BrowserWindow::setupUi() { setWindowTitle(QStringLiteral("att_browser")); resize(1280, 800); auto *central = new QWidget(this); auto *layout = new QVBoxLayout(central); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(m_view, 1); layout->addWidget(m_status); layout->addWidget(m_commandLine); setCentralWidget(central); // Nothing that comes and goes may change the page's size: every resize // makes the page re-layout, which on YouTube means the player visibly // jumps. So the completion floats over the bottom of the page instead of // taking space in the layout (see placeCompletion), and the status bar and // the command line, which replace each other, get the same fixed height. m_completion->setParent(central); m_status->ensurePolished(); m_commandLine->ensurePolished(); const int barHeight = qMax(m_status->sizeHint().height(), m_commandLine->sizeHint().height()); m_status->setFixedHeight(barHeight); m_commandLine->setFixedHeight(barHeight); m_commandLine->hide(); } void BrowserWindow::connectView() { connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) { m_status->setUrl(url); // The hints belong to the page that is going away. if (m_mode == Mode::Hint) setMode(Mode::Normal); }); connect(m_view, &QWebEngineView::loadProgress, m_status, &StatusBar::setProgress); // Off by default in QtWebEngine: without it, element.requestFullscreen() // (YouTube's fullscreen button, double-clicking a video) silently fails. m_view->settings()->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, true); connect(m_view->page(), &QWebEnginePage::fullScreenRequested, this, [this](QWebEngineFullScreenRequest request) { request.accept(); setPageFullScreen(request.toggleOn()); }); connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) { setWindowTitle(title.isEmpty() ? QStringLiteral("att_browser") : QStringLiteral("%1 - att_browser").arg(title)); m_session.history->updateTitle(m_view->url(), title); }); connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) { m_status->setProgress(100); 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_session.history->addVisit(m_view->url(), m_view->title()); }); } void BrowserWindow::showMessage(const QString &text, bool isError) { m_status->showMessage(text, isError); } void BrowserWindow::setMode(Mode mode) { if (m_mode == Mode::Hint && mode != Mode::Hint) runHintScript(hints::clearScript()); m_mode = mode; m_pendingKeys.clear(); if (mode != Mode::Command) m_completion->setEntries({}); switch (mode) { case Mode::Normal: m_status->setMode(QString()); focusView(); break; case Mode::Insert: m_status->setMode(QStringLiteral("insert")); focusView(); break; case Mode::Command: updateCompletion(); break; case Mode::Hint: m_status->setMode(QStringLiteral("hint")); break; } updateStatusVisibility(); } void BrowserWindow::setPageFullScreen(bool on) { if (on == m_pageFullScreen) return; m_pageFullScreen = on; if (on) { m_windowStateBeforeFullScreen = windowState(); showFullScreen(); } else { setWindowState(m_windowStateBeforeFullScreen); } updateStatusVisibility(); } void BrowserWindow::updateStatusVisibility() { // The command line replaces the status bar, and a fullscreen video gets the // whole screen. Decided in one place so the bar is never shown just to be // hidden again: toggling it while fullscreen knocks the window out of // fullscreen. m_status->setVisible(m_mode != Mode::Command && !m_pageFullScreen); } bool BrowserWindow::eventFilter(QObject *watched, QEvent *event) { if (watched == m_view && event->type() == QEvent::Resize && m_completion->isVisible()) placeCompletion(); if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast(watched))) return QMainWindow::eventFilter(watched, event); auto *key = static_cast(event); if (m_mode == Mode::Command) return QMainWindow::eventFilter(watched, event); if (key->key() == Qt::Key_Escape || (key->key() == Qt::Key_BracketLeft && key->modifiers() == Qt::ControlModifier)) { // Escape is swallowed here, so the page would never see it to leave // fullscreen itself. In insert/hint mode it still just returns to normal // mode first, as everywhere else. if (m_mode == Mode::Normal && m_pageFullScreen) m_view->triggerPageAction(QWebEnginePage::ExitFullScreen); else setMode(Mode::Normal); return true; } if (m_mode == Mode::Insert) return QMainWindow::eventFilter(watched, event); if (m_mode == Mode::Hint) { handleHintModeKey(key); return true; } if (handleNormalModeKey(key)) return true; return QMainWindow::eventFilter(watched, event); } bool BrowserWindow::handleNormalModeKey(QKeyEvent *event) { const QString text = event->text(); const Qt::KeyboardModifiers mods = event->modifiers(); if (mods.testFlag(Qt::ControlModifier)) { switch (event->key()) { case Qt::Key_D: scrollPages(0.5); return true; case Qt::Key_U: scrollPages(-0.5); return true; case Qt::Key_F: scrollPages(1.0); return true; case Qt::Key_B: scrollPages(-1.0); return true; default: return false; } } if (mods.testFlag(Qt::AltModifier) || mods.testFlag(Qt::MetaModifier)) return false; // Two-key sequences starting with "g". if (!m_pendingKeys.isEmpty()) { const QString sequence = m_pendingKeys + text; m_pendingKeys.clear(); if (sequence == QLatin1String("gg")) { 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. openCommandLine((sequence == QLatin1String("go") ? QStringLiteral(":open ") : QStringLiteral(":open -w ")) + m_view->url().toString()); return true; } return true; // Swallow the unknown sequence rather than leaking it. } if (text == QLatin1String("g")) { m_pendingKeys = text; return true; } if (text == QLatin1String("o")) { openCommandLine(QStringLiteral(":open ")); return true; } if (text == QLatin1String("O")) { openCommandLine(QStringLiteral(":open -w ")); return true; } if (text == QLatin1String("d")) { close(); return true; } if (text == QLatin1String(":")) { openCommandLine(QStringLiteral(":")); return true; } if (text == QLatin1String("i")) { setMode(Mode::Insert); return true; } if (text == QLatin1String("f")) { startHints(); return true; } if (text == QLatin1String("r")) { m_view->reload(); return true; } if (text == QLatin1String("R")) { m_view->triggerPageAction(QWebEnginePage::ReloadAndBypassCache); return true; } if (text == QLatin1String("H")) { m_view->back(); return true; } if (text == QLatin1String("L")) { m_view->forward(); return true; } if (text == QLatin1String("j")) { scrollByCssPx(0, config::kScrollStepPx); return true; } if (text == QLatin1String("k")) { scrollByCssPx(0, -config::kScrollStepPx); return true; } if (text == QLatin1String("h")) { scrollBy(-kScrollStep, 0); return true; } if (text == QLatin1String("l")) { scrollBy(kScrollStep, 0); return true; } if (text == QLatin1String("G")) { scrollToEnd(true); return true; } if (text == QLatin1String("y")) { const QString url = m_view->url().toString(); qApp->clipboard()->setText(url); m_status->showMessage(QStringLiteral("Yanked %1").arg(url)); return true; } if (text == QLatin1String("+")) { zoomStep(1); return true; } if (text == QLatin1String("-")) { zoomStep(-1); return true; } if (text == QLatin1String("=")) { setZoom(config::kZoomDefault); return true; } return false; } void BrowserWindow::handleHintModeKey(QKeyEvent *event) { if (event->key() == Qt::Key_Backspace) { runHintScript(hints::backspaceScript()); return; } const QString text = event->text().toLower(); if (text.size() == 1 && hints::chars().contains(text)) runHintScript(hints::keyScript(text.at(0))); // Anything else is swallowed, so a stray key can't trigger a page shortcut. } void BrowserWindow::startHints() { setMode(Mode::Hint); 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, [this](const QVariant &result) { handleHintResult(result.toMap()); }); } void BrowserWindow::handleHintResult(const QVariantMap &result) { // Results can arrive after the user already left hint mode, e.g. a key that // was still in flight when Escape was pressed. if (m_mode != Mode::Hint) return; const QString state = result.value(QStringLiteral("state")).toString(); if (state == QLatin1String("shown")) { if (result.value(QStringLiteral("count")).toInt() == 0) { setMode(Mode::Normal); m_status->showMessage(QStringLiteral("No elements found"), true); } } else if (state == QLatin1String("follow")) { const QPointF pos(result.value(QStringLiteral("x")).toDouble(), result.value(QStringLiteral("y")).toDouble()); setMode(Mode::Normal); clickAt(pos * m_view->zoomFactor()); if (result.value(QStringLiteral("editable")).toBool()) setMode(Mode::Insert); } else if (state == QLatin1String("gone")) { setMode(Mode::Normal); m_status->showMessage(QStringLiteral("Element went away"), true); } else if (state == QLatin1String("none") || state.isEmpty()) { // The script failed, or the page navigated and took the hints with it. setMode(Mode::Normal); } } void BrowserWindow::clickAt(const QPointF &pos) { // A real mouse click rather than element.click(): plenty of sites only // listen for mousedown/pointerdown, and it gives the page a trusted event. QWidget *target = m_view->focusProxy(); if (!target) return; const QPointF global = target->mapToGlobal(pos); QMouseEvent press(QEvent::MouseButtonPress, pos, global, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QMouseEvent release(QEvent::MouseButtonRelease, pos, global, Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QCoreApplication::sendEvent(target, &press); QCoreApplication::sendEvent(target, &release); } void BrowserWindow::runCommand(const QString &command) { const QString name = command.section(QLatin1Char(' '), 0, 0); const QString args = command.section(QLatin1Char(' '), 1).trimmed(); if (name == QLatin1String("open") || name == QLatin1String("o")) { // 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")) { m_view->back(); } else if (name == QLatin1String("forward")) { m_view->forward(); } else if (name == QLatin1String("stop")) { m_view->stop(); } else if (name == QLatin1String("version")) { // The Chromium security patch level is what says how far behind // Chrome's fixes this build is; "nix flake update" moves it forward. m_status->showMessage(QStringLiteral("att_browser %1, Qt %2, Chromium %3, security patches %4") .arg(QCoreApplication::applicationVersion(), QString::fromLatin1(qVersion()), QString::fromLatin1(qWebEngineChromiumVersion()), QString::fromLatin1(qWebEngineChromiumSecurityPatchVersion()))); } else if (name == QLatin1String("zoom")) { if (args.isEmpty()) { setZoom(config::kZoomDefault); } else { // "150" or "150%", like qutebrowser. QString level = args; if (level.endsWith(QLatin1Char('%'))) level.chop(1); bool ok = false; const double percent = level.toDouble(&ok); if (ok && percent >= kZoomMin * 100 && percent <= kZoomMax * 100) setZoom(percent / 100); else m_status->showMessage(QStringLiteral("Zoom level must be %1% to %2%: %3") .arg(kZoomMin * 100).arg(kZoomMax * 100).arg(args), true); } } else if (name == QLatin1String("zoom-in")) { zoomStep(1); } else if (name == QLatin1String("zoom-out")) { zoomStep(-1); } else if (name == QLatin1String("useragent") || name == QLatin1String("ua")) { if (args.isEmpty()) { m_status->showMessage(QStringLiteral("User agent: %1").arg(useragent::current())); } else if (useragent::apply(m_session.profile, args)) { // Requests use it at once; navigator.userAgent changes with the // next load, so say so rather than leave the page inconsistent. m_status->showMessage( QStringLiteral("User agent: %1 (applies from the next page load)").arg(args)); } else { m_status->showMessage(QStringLiteral("Unknown user agent: %1").arg(args), true); } } else if (name == QLatin1String("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")); } else { m_status->showMessage(QStringLiteral("Could not clear history"), true); } } else if (name == QLatin1String("greasemonkey-reload")) { m_session.greasemonkey->reload(); } else if (name == QLatin1String("adblock-update")) { m_session.adBlocker->update(); } else if (name == QLatin1String("adblock-toggle")) { 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); } } void BrowserWindow::openCommandLine(const QString &text) { // Hide the status bar before the command line appears: showing a widget // re-runs the layout immediately, and with both bars visible for a moment // the page would shrink and grow back (two resizes) every time. m_status->hide(); m_commandLine->openWith(text); setMode(Mode::Command); } void BrowserWindow::updateCompletion() { const QString text = m_commandLine->text(); if (const QRegularExpressionMatch match = openCommand().match(text); match.hasMatch()) { const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); m_completion->setEntries(m_session.history->search(words, kCompletionLimit)); } else if (const QRegularExpressionMatch match = userAgentCommand().match(text); match.hasMatch()) { // Preset name in the first column, the string it sends in the second. const QString query = match.captured(2).trimmed(); QList entries; for (const useragent::Preset &preset : useragent::presets()) { if (preset.name.contains(query, Qt::CaseInsensitive)) entries.append({preset.name, preset.userAgent}); } m_completion->setEntries(entries); } else { m_completion->setEntries({}); return; } placeCompletion(); } void BrowserWindow::placeCompletion() { // Bottom-aligned over the page, just above the command line. const QRect page = m_view->geometry(); const int height = qMin(m_completion->height(), page.height()); m_completion->setGeometry(page.left(), page.bottom() + 1 - height, page.width(), height); m_completion->raise(); } void BrowserWindow::cycleCompletion(int step) { if (!m_completion->isVisible()) return; const QString choice = m_completion->cycle(step); if (choice.isEmpty()) return; // Keep the command as the user typed it (":o " or ":open ", ":ua " or // ":useragent "). const QString text = m_commandLine->text(); QRegularExpressionMatch match = userAgentCommand().match(text); if (!match.hasMatch()) match = openCommand().match(text); const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open "); m_commandLine->setCommandText(prefix + choice); } 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(); } void BrowserWindow::scrollBy(int dx, int dy) { // A pixel-precise wheel event, which is what a touchpad sends, rather than // window.scrollBy(): Chromium then scrolls whatever is under the pointer, // the way the touchpad does. window.scrollBy() only ever moves the // document, so on sites that scroll an inner element instead (most web // apps) it did nothing at all. QWidget *target = m_view->focusProxy(); if (!target) return; QPointF pos = target->mapFromGlobal(QCursor::pos()); if (!target->rect().contains(pos.toPoint())) pos = QRectF(target->rect()).center(); // Qt's wheel deltas point the way the content moves, the opposite of the // scroll direction. const QPoint pixels(-dx, -dy); QWheelEvent event(pos, target->mapToGlobal(pos), pixels, pixels * 2, Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); QCoreApplication::sendEvent(target, &event); } void BrowserWindow::scrollByCssPx(int dx, int dy) { // Wheel deltas are screen pixels, which the page zoom shrinks (at 125% a // 25px wheel moves 20 CSS px). qutebrowser's scroll-px counts CSS pixels. const double zoom = m_view->zoomFactor(); scrollBy(qRound(dx * zoom), qRound(dy * zoom)); } void BrowserWindow::scrollPages(double factor) { scrollBy(0, qRound(m_view->height() * factor)); } void BrowserWindow::scrollToEnd(bool bottom) { // Far more than any page is tall; Chromium stops at the end. constexpr int kFar = 10'000'000; scrollBy(0, bottom ? kFar : -kFar); } void BrowserWindow::setZoom(double factor, bool announce) { const double zoom = qBound(kZoomMin, factor, kZoomMax); m_view->setZoomFactor(zoom); if (announce) m_status->showMessage(QStringLiteral("Zoom level: %1%").arg(qRound(zoom * 100))); } void BrowserWindow::zoomStep(int direction) { // The next level strictly beyond the current zoom, so a zoom set with // ":zoom 130" still steps to 150 / 125. The epsilon absorbs 0.33 vs 1/3. constexpr double kEpsilon = 0.001; const double current = m_view->zoomFactor(); const auto &levels = config::kZoomLevels; if (direction > 0) { for (double level : levels) { if (level > current + kEpsilon) { setZoom(level); return; } } } else { for (auto it = levels.rbegin(); it != levels.rend(); ++it) { if (*it < current - kEpsilon) { setZoom(*it); return; } } } m_status->showMessage(QStringLiteral("Zoom level: %1% (limit)").arg(qRound(current * 100))); } void BrowserWindow::focusView() { m_view->setFocus(Qt::OtherFocusReason); }