From 98b3a34fe9a9eced5019b741855c191f57d8d3f3 Mon Sep 17 00:00:00 2001 From: Fra Date: Sun, 29 Mar 2026 12:59:44 +0200 Subject: [PATCH 1/4] feat: blur, transparency, focus mode, performance optimizations Features: - Browser spoofing (Chrome 130 UA) for auth compatibility - CSS transparency/blur injection with backdrop-filter - Focus mode: hide sidebars and non-essential UI per service - Accent glow border, header gradient, animation controls - Keep WebEngine alive option for instant reopen - Enter-to-send keyboard shortcuts for compatible chat services - Last visited URL persistence across WebView unload/reload Performance: - Pre-built JS injection strings as readonly properties (zero rebuild per page load) - Batch DOM reads before writes in transparency injection (no layout thrashing) - MutationObserver replaces setInterval polling for textarea focus - Narrow focus mode heuristic selectors (body > div instead of all divs) - Debounced renderChatModel with single-pass customSites parsing - Accent glow wrapped in Loader (zero objects when disabled) - Header animations gated on enableAnimations + autoHideHeader - Download signal connections cleanup on Component destruction Co-Authored-By: Claude --- CLAUDE.md | 64 +++ contents/config/config.qml | 4 +- contents/config/main.xml | 118 ++--- contents/ui/CompactRepresentation.qml | 61 +-- contents/ui/ConfigAppearance.qml | 133 ++++-- contents/ui/ConfigGeneral.qml | 50 +- contents/ui/DownloadBar.qml | 58 +-- contents/ui/FindBar.qml | 79 +--- contents/ui/Header.qml | 257 +++++------ contents/ui/WebView.qml | 639 +++++++++++++++++++------- contents/ui/main.qml | 204 ++++---- 11 files changed, 1003 insertions(+), 664 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80c19dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# ChatAI Plasmoid + +KDE Plasma 6 widget that embeds AI chat services (ChatGPT, Claude, Gemini, DuckDuckGo, DeepSeek, etc.) in a panel popup using QtWebEngine. + +## Architecture + +Pure QML plasmoid — no C++, no build system. Installed via KDE package manager. + +``` +contents/ + config/ + main.xml # KDE configuration schema (all settings) + config.qml # Config tab definitions (General + Appearance) + ui/ + main.qml # PlasmoidItem root, popup layout, header auto-hide, WebView loader + WebView.qml # WebEngineView, permissions, downloads, context menu, JS injection + Header.qml # Navigation bar, URL selector, control buttons (Item wrapping RowLayout) + CompactRepresentation.qml # Panel icon with dynamic icon modes + ConfigGeneral.qml # Settings: sites, permissions, web features, downloads, cache + ConfigAppearance.qml # Settings: icon, effects, transparency, focus mode, header options + FindBar.qml # Ctrl+F find-in-page bar + DownloadBar.qml # Download progress/cancel/open UI +``` + +## Key patterns + +- **Configuration**: all settings in `main.xml`, accessed via `plasmoid.configuration.propertyName` +- **Signals**: Header communicates with main.qml via signals (goBackToHomePage, navigateBackRequested, etc.) +- **JS injection**: WebView injects CSS/JS into pages for: browser spoofing, transparency, focus mode, keyboard shortcuts +- **Heuristic DOM analysis**: transparency and focus mode analyze page structure to find sidebars, headers, input areas +- **WebEngine lifecycle**: configurable keep-alive (instant reopen) or 5-min idle unload (memory saving) + +## Important constraints + +- `PlasmaExtras.Menu`/`MenuItem` do NOT exist in Plasma 6 — use `PlasmaComponents3.Menu`/`MenuItem` +- `PlasmaComponents3.Menu` cannot be a root type in a separate QML file (causes "Type cannot be created in QML") +- `import org.kde.notification` MUST keep version `1.0` (unversioned fails at runtime) +- `MultiEffect` blur cannot affect WebEngineView content (native surface) — use CSS injection instead +- `backgroundHints` is set via `Plasmoid.backgroundHints` (attached property), not directly on PlasmoidItem +- Header.qml root is `Item` (not `RowLayout`) — signals, properties, and functions must be on the Item, not inside the RowLayout + +## Configuration properties + +Settings are split across two tabs: + +**General** (ConfigGeneral.qml): site toggles (showChatGPT, showClaude, etc.), custom sites, permissions (mic/webcam/screenshare/notifications/geolocation), web features (JS clipboard, spatial nav), download path, cache management, profile name, keepWebEngineAlive, spoofChromeBrowser + +**Appearance** (ConfigAppearance.qml): iconMode, animations, header gradient, accent glow, focus mode, transparency + opacity slider, header visibility toggles + +## JS injection layers (WebView.qml) + +Applied on every page load via `onLoadingChanged`: + +1. **Browser spoof** (`injectBrowserSpoof`): Chrome 130 UA, navigator.vendor/platform/plugins, window.chrome object +2. **Transparency** (`injectTransparencyCSS`): heuristic DOM walk, classifies elements as chrome/background/chat, applies layered rgba + backdrop-blur +3. **Focus mode** (`injectFocusMode`): per-site CSS rules + heuristic fallback hiding nav/aside/header/sidebar +4. **Keyboard shortcuts**: Enter-to-send for compatible services (DuckDuckGo, ChatGPT, Gemini, Claude, You) + +## Testing + +No automated tests. Test by: +1. Installing: `plasmashell --replace &` or remove/re-add widget +2. Check logs: `journalctl --user -u plasma-plasmashell -b | grep -i "chatai\|webview\|error"` +3. Lint: `qmllint contents/ui/*.qml` (ignore "Library import requires a version" warnings) diff --git a/contents/config/config.qml b/contents/config/config.qml index 7fef731..9da2cb1 100644 --- a/contents/config/config.qml +++ b/contents/config/config.qml @@ -6,8 +6,8 @@ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ -import QtQuick -import org.kde.plasma.configuration +import QtQuick 2.0 +import org.kde.plasma.configuration 2.0 ConfigModel { ConfigCategory { diff --git a/contents/config/main.xml b/contents/config/main.xml index a00e26d..9032229 100644 --- a/contents/config/main.xml +++ b/contents/config/main.xml @@ -8,52 +8,21 @@ https://duckduckgo.com/chat + + + - + 1 - - - help-about - - - - false - - - - false - - - - false - - - - true - - - - false - - - - false - - - - false - - - @@ -104,16 +73,13 @@ false - - false - false false - + false @@ -144,94 +110,78 @@ - false - - false - - false + + true + - false - true - true - true - true - false - true - - + + true + + false - - - 0 + + false - - - 0 + + chat-ai - - - true + + 0.85 - - - 0 + + true - - - 1 + + 0.85 - - - 5000 + + + true - - + + false - - + + false - - - + + + true - - + + false - - - chat-ai - diff --git a/contents/ui/CompactRepresentation.qml b/contents/ui/CompactRepresentation.qml index 56a8da9..38b5dbd 100644 --- a/contents/ui/CompactRepresentation.qml +++ b/contents/ui/CompactRepresentation.qml @@ -1,40 +1,23 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtQuick import QtQuick.Layouts - -import org.kde.plasma.core as PlasmaCore import org.kde.kirigami as Kirigami +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore Item { id: compactRoot - // Icon mode constants (must match ConfigAppearance.qml ComboBox order) - readonly property int iconModeFavicon: 0 - readonly property int iconModeAdaptive: 1 - readonly property int iconModeDark: 2 - readonly property int iconModeLight: 3 - readonly property int iconModeOutlined: 4 - readonly property int iconModeFilled: 5 - readonly property int iconModeColorful: 6 - readonly property int iconModeCustom: 7 - property var models: [] property var webview: null property string fallbackIcon: "help-about" readonly property bool isVertical: plasmoid.formFactor === PlasmaCore.Types.Vertical - Layout.minimumWidth: isVertical ? 0 : Kirigami.Units.iconSizes.medium - Layout.minimumHeight: isVertical ? Kirigami.Units.iconSizes.medium : 0 + Layout.minimumWidth: isVertical ? 0 : Kirigami.Units.iconSizes.large + Layout.minimumHeight: isVertical ? Kirigami.Units.iconSizes.large : 0 - implicitWidth: Kirigami.Units.iconSizes.medium - implicitHeight: Kirigami.Units.iconSizes.medium + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large MouseArea { id: mouseArea @@ -48,6 +31,15 @@ Item { source: getIconSource() } + // Direct webview connection for loading state changes + Connections { + target: compactRoot.webview + enabled: compactRoot.webview !== null + function onLoadingChanged(loadingInfo) { + // Placeholder for potential future use + } + } + function getIconSource() { let icon = getIconNameOrPath(); if (icon.indexOf("/") !== -1 || icon.endsWith(".svg") || icon.endsWith(".png")) { @@ -63,48 +55,45 @@ Item { const currentModel = models.find(model => plasmoid.configuration.url.includes(model.url)); const colorContrast = getBackgroundColorContrast(); - // Colorful mode. If not in colorful mode, some models only have colorful icons available - const hasOnlyColorfulIcon = mode !== iconModeColorful && ["lobechat", "bigagi"].includes(currentModel?.id); + // Mode 6 is Colorful. If not in colorful mode, some models only have colorful icons available + const hasOnlyColorfulIcon = mode !== 6 && ["lobechat", "bigagi"].includes(currentModel?.id); if (!currentModel || currentModel?.id === "blackbox" || hasOnlyColorfulIcon) { return `assets/logo-${colorContrast}.svg`; } if (currentModel.useIcon) { - const style = mode === iconModeFilled ? "filled" : "outlined"; + const style = mode === 5 ? "filled" : "outlined"; return `assets/${style}/${currentModel.useIcon}-${colorContrast}.svg`; } - if (mode === iconModeColorful) { + if (mode === 6) { return `assets/colorful/${currentModel.id}.svg`; } - const style = mode === iconModeFilled ? "filled" : "outlined"; + const style = mode === 5 ? "filled" : "outlined"; return `assets/${style}/${currentModel.id}-${colorContrast}.svg`; } function getIconNameOrPath() { const mode = plasmoid.configuration.iconMode; - if (mode === iconModeCustom) { - return plasmoid.configuration.customIcon || fallbackIcon; - } - - if (mode === iconModeFavicon) { + if (mode === 0) { // Favicon const faviconUrl = plasmoid.configuration.favIcon || plasmoid.configuration.lastFavIcon; if (faviconUrl) { return faviconUrl.replace("image://favicon/", ""); } } - if (mode >= iconModeOutlined) { + if (mode >= 4) { // Outlined, Filled, Colorful return getChatModelIcon() || fallbackIcon; } const contrast = getBackgroundColorContrast(); - if (mode === iconModeDark) return "assets/logo-dark.svg"; - if (mode === iconModeLight) return "assets/logo-light.svg"; + if (mode === 2) return "assets/logo-dark.svg"; + if (mode === 3) return "assets/logo-light.svg"; + // Mode 1 or fallback return `assets/logo-${contrast}.svg`; } diff --git a/contents/ui/ConfigAppearance.qml b/contents/ui/ConfigAppearance.qml index 98c92dd..eabc168 100644 --- a/contents/ui/ConfigAppearance.qml +++ b/contents/ui/ConfigAppearance.qml @@ -1,35 +1,17 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts - -import org.kde.kirigami 2.20 as Kirigami -import org.kde.iconthemes as KIconThemes import org.kde.kcmutils as KCM +import org.kde.kirigami as Kirigami KCM.SimpleKCM { - // Icon mode constants (must match CompactRepresentation.qml) - readonly property int iconModeFavicon: 0 - readonly property int iconModeAdaptive: 1 - readonly property int iconModeDark: 2 - readonly property int iconModeLight: 3 - readonly property int iconModeOutlined: 4 - readonly property int iconModeFilled: 5 - readonly property int iconModeColorful: 6 - readonly property int iconModeCustom: 7 - property alias cfg_iconMode: iconMode.currentIndex - property string cfg_customIcon: plasmoid.configuration.customIcon + // Remove padding for better layout leftPadding: 0 rightPadding: 0 + // Main form layout container Kirigami.FormLayout { anchors.left: parent.left width: Math.min(parent.width, Kirigami.Units.gridUnit * 25) @@ -44,42 +26,91 @@ KCM.SimpleKCM { i18n("Default light icon"), i18n("Outlined icon"), i18n("Filled icon"), - i18n("Colorful icon"), - i18n("Custom icon") + i18n("Colorful icon") ] Layout.fillWidth: true } - QQC2.Button { - id: iconButton - visible: iconMode.currentIndex === iconModeCustom - Kirigami.FormData.label: i18n("Custom Icon:") - implicitWidth: previewFrame.width + Kirigami.Units.smallSpacing * 2 - implicitHeight: previewFrame.height + Kirigami.Units.smallSpacing * 2 - hoverEnabled: true + // Separator between icon and effects sections + Kirigami.Separator { + Kirigami.FormData.isSection: true + Layout.fillWidth: true + } + + // Visual Effects section + QQC2.Label { + Kirigami.FormData.label: i18n("Visual Effects") + font.bold: true + Layout.fillWidth: true + } + + QQC2.CheckBox { + id: enableAnimations + text: i18n("Smooth animations") + checked: plasmoid.configuration.enableAnimations + onCheckedChanged: plasmoid.configuration.enableAnimations = checked + Layout.fillWidth: true + } - KIconThemes.IconDialog { - id: iconDialog - onIconNameChanged: { - if (iconName) { - cfg_customIcon = iconName - plasmoid.configuration.customIcon = iconName - } - } - } + QQC2.CheckBox { + id: headerGradient + text: i18n("Header gradient (accent color tint)") + checked: plasmoid.configuration.headerGradient + onCheckedChanged: plasmoid.configuration.headerGradient = checked + Layout.fillWidth: true + } - onClicked: iconDialog.open() + QQC2.CheckBox { + id: accentGlow + text: i18n("Accent glow around widget") + checked: plasmoid.configuration.accentBorder + onCheckedChanged: plasmoid.configuration.accentBorder = checked + Layout.fillWidth: true + } - Kirigami.Icon { - id: previewFrame - anchors.centerIn: parent - width: Kirigami.Units.iconSizes.large - height: width - source: cfg_customIcon || "help-about" - } + QQC2.CheckBox { + id: focusMode + text: i18n("Focus mode (hide sidebars and site navigation)") + checked: plasmoid.configuration.focusMode + onCheckedChanged: plasmoid.configuration.focusMode = checked + Layout.fillWidth: true } - // Separator between icon and header sections + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Information + text: i18n("Hides sidebars, headers, and non-essential UI from supported chat services (ChatGPT, Claude, Gemini, DuckDuckGo, DeepSeek, Copilot). May break if sites update their layout.") + visible: focusMode.checked + } + + Kirigami.Separator { + Kirigami.FormData.isSection: true + Layout.fillWidth: true + } + + // Transparency & Blur section + QQC2.Label { + Kirigami.FormData.label: i18n("Transparency & Blur") + font.bold: true + Layout.fillWidth: true + } + + QQC2.CheckBox { + id: enableBlur + text: i18n("Enable blur effect") + checked: plasmoid.configuration.enableBlur + onCheckedChanged: plasmoid.configuration.enableBlur = checked + Layout.fillWidth: true + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Information + text: i18n("Blurs the desktop behind the popup and makes web page backgrounds semi-transparent. Requires a compositor with blur support.") + visible: enableBlur.checked + } + + Kirigami.Separator { Kirigami.FormData.isSection: true Layout.fillWidth: true @@ -131,11 +162,11 @@ KCM.SimpleKCM { } QQC2.CheckBox { - id: hidePrintButton + id: hideAutoHideButton text: i18n("Hide Auto-Hide button") - checked: plasmoid.configuration.hidePrintButton - onCheckedChanged: plasmoid.configuration.hidePrintButton = checked + checked: plasmoid.configuration.hideAutoHideButton + onCheckedChanged: plasmoid.configuration.hideAutoHideButton = checked Layout.fillWidth: true } diff --git a/contents/ui/ConfigGeneral.qml b/contents/ui/ConfigGeneral.qml index cd5f253..15416d5 100644 --- a/contents/ui/ConfigGeneral.qml +++ b/contents/ui/ConfigGeneral.qml @@ -1,21 +1,12 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - +import QtCore import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Dialogs import QtQuick.Layouts -import QtWebEngine - -import org.kde.plasma.components as PlasmaComponents3 -import org.kde.kirigami 2.20 as Kirigami import org.kde.kcmutils as KCM - -import Qt.labs.platform 1.1 +import org.kde.kirigami as Kirigami +import org.kde.plasma.components as PlasmaComponents3 +import QtWebEngine // Main configuration component for general settings KCM.SimpleKCM { @@ -92,10 +83,7 @@ KCM.SimpleKCM { id: scrollView anchors.fill: parent - // Enable vertical scrollbar - Component.onCompleted: { - QQC2.ScrollBar.vertical.policy = QQC2.ScrollBar.AlwaysOn; - } + QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AsNeeded Item { width: scrollView.width @@ -136,7 +124,7 @@ KCM.SimpleKCM { }, { "id": "showHugginChat", - "text": "HuggingChat" + "text": "HugginChat" }, { "id": "showGoogleGemini", @@ -282,7 +270,7 @@ KCM.SimpleKCM { PlasmaComponents3.Label { text: model.siteData.split("|")[1] - font.pointSize: theme.smallestFont.pointSize + font.pointSize: Kirigami.Theme.smallFont.pointSize opacity: 0.7 Layout.fillWidth: true wrapMode: Text.WordWrap @@ -330,6 +318,15 @@ KCM.SimpleKCM { Layout.fillWidth: true } + QQC2.CheckBox { + id: keepWebEngineAlive + + text: i18n("Keep in memory after closing (faster reopen)") + checked: plasmoid.configuration.keepWebEngineAlive + onCheckedChanged: plasmoid.configuration.keepWebEngineAlive = checked + Layout.fillWidth: true + } + // Media permissions options QQC2.CheckBox { id: notificationsEnabled @@ -402,6 +399,21 @@ Action=Popup` Layout.fillWidth: true } + QQC2.CheckBox { + id: spoofChromeBrowser + text: i18n("Disguise as Chrome (better login compatibility)") + checked: plasmoid.configuration.spoofChromeBrowser + onCheckedChanged: plasmoid.configuration.spoofChromeBrowser = checked + Layout.fillWidth: true + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Information + text: i18n("Spoofs browser identity so login pages (Google, Claude, etc.) accept this widget as a regular Chrome browser. Recommended to keep enabled.") + visible: spoofChromeBrowser.checked + } + QQC2.CheckBox { id: spatialNavigationEnabled text: i18n("Enable spatial navigation") diff --git a/contents/ui/DownloadBar.qml b/contents/ui/DownloadBar.qml index 593743e..fd66df5 100644 --- a/contents/ui/DownloadBar.qml +++ b/contents/ui/DownloadBar.qml @@ -1,40 +1,33 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtQuick import QtQuick.Layouts - +import QtWebEngine import org.kde.plasma.components as PlasmaComponents3 import org.kde.kirigami as Kirigami +import org.kde.plasma.plasmoid Column { - id: downloadsBar + id: downloadsBarRoot - property var downloadsModel - property var downloadCache - property var webviewItem + property var downloadsModel: null + property var downloadCacheRef: ({}) + readonly property real overlayOpacity: plasmoid.configuration.overlayOpacity - visible: downloadsModel && downloadsModel.count > 0 + visible: downloadsModel !== null && downloadsModel.count > 0 spacing: 4 - anchors { - left: parent.left - right: parent.right - bottom: parent.bottom + function getOpenPath(path) { + return path.replace(/^file:\/+/, '').replace(/^\/+/, '/'); } Repeater { - model: downloadsModel + model: downloadsBarRoot.downloadsModel delegate: Rectangle { width: parent.width height: 40 color: Kirigami.Theme.backgroundColor - opacity: 0.9 + opacity: downloadsBarRoot.overlayOpacity + radius: Kirigami.Units.smallSpacing RowLayout { anchors.fill: parent @@ -54,9 +47,9 @@ Column { if (model.totalBytes > 0) { let received = (model.receivedBytes / 1024 / 1024).toFixed(1); let total = (model.totalBytes / 1024 / 1024).toFixed(1); - size = ` (${received}/${total} MB)`; + size = i18n(" (%1/%2 MB)", received, total); } - return i18n("%1 - %2%%3", model.fileName, progress, size); + return i18n("%1 - %2%", model.fileName, progress) + size; } Layout.fillWidth: true elide: Text.ElideMiddle @@ -81,8 +74,7 @@ Column { PlasmaComponents3.ToolTip.visible: hovered onClicked: { if (model.fullPath) { - let openPath = getOpenPath(model.fullPath); - Qt.openUrlExternally(openPath); + Qt.openUrlExternally(downloadsBarRoot.getOpenPath(model.fullPath)); } } } @@ -94,8 +86,7 @@ Column { onClicked: { if (model.fullPath) { let dirPath = model.fullPath.substring(0, model.fullPath.lastIndexOf("/")); - let openPath = getOpenPath(dirPath); - Qt.openUrlExternally(openPath); + Qt.openUrlExternally(downloadsBarRoot.getOpenPath(dirPath)); } } } @@ -105,7 +96,7 @@ Column { PlasmaComponents3.ToolTip.text: i18n("Close") PlasmaComponents3.ToolTip.visible: hovered onClicked: { - downloadsModel.remove(model.index); + downloadsBarRoot.downloadsModel.remove(model.index); } } } @@ -116,24 +107,17 @@ Column { PlasmaComponents3.ToolTip.text: i18n("Cancel") PlasmaComponents3.ToolTip.visible: hovered onClicked: { - let downloadData = downloadCache && downloadCache[model.downloadId]; + let downloadData = downloadsBarRoot.downloadCacheRef[model.downloadId]; if (downloadData && downloadData.download) { downloadData.download.receivedBytesChanged.disconnect(downloadData.bytesConnection); downloadData.download.stateChanged.disconnect(downloadData.stateConnection); downloadData.download.cancel(); - delete downloadCache[model.downloadId]; - downloadsModel.remove(index); + delete downloadsBarRoot.downloadCacheRef[model.downloadId]; + downloadsBarRoot.downloadsModel.remove(index); } } } } } } - - function getOpenPath(path) { - if (Qt.platform.os === "windows" || Qt.platform.os === "osx") { - return "file:///" + path; - } - return "file://" + path; - } -} \ No newline at end of file +} diff --git a/contents/ui/FindBar.qml b/contents/ui/FindBar.qml index 970d00b..c976fbe 100644 --- a/contents/ui/FindBar.qml +++ b/contents/ui/FindBar.qml @@ -1,35 +1,30 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtQuick import QtQuick.Layouts - import org.kde.plasma.components as PlasmaComponents3 import org.kde.kirigami as Kirigami -import QtWebEngine +import org.kde.plasma.plasmoid Rectangle { - id: findBar + id: findBarRoot - property bool findBarVisible: false - property var webviewItem - property alias findText: findField.text + property bool barVisible: false + readonly property bool animEnabled: plasmoid.configuration.enableAnimations + readonly property real overlayOpacity: plasmoid.configuration.overlayOpacity - signal closeRequested() + signal findRequested(string text) + signal findPreviousRequested(string text) + signal closed() - visible: findBarVisible + visible: barVisible height: visible ? findBarRow.height + Kirigami.Units.smallSpacing * 2 : 0 color: Kirigami.Theme.backgroundColor + opacity: overlayOpacity + radius: Kirigami.Units.smallSpacing z: 5 - anchors { - top: parent.top - left: parent.left - right: parent.right + function focusField() { + findField.forceActiveFocus(); + findField.selectAll(); } RowLayout { @@ -50,20 +45,13 @@ Rectangle { Layout.fillWidth: true placeholderText: i18n("Find in page...") - onTextChanged: { - if (text && webviewItem) { - webviewItem.findText(text); - } - } - onAccepted: { - if (webviewItem) { - webviewItem.findText(text); - } - } - Keys.onEscapePressed: findBarVisible = false + onTextChanged: if (text) + findBarRoot.findRequested(text) + onAccepted: findBarRoot.findRequested(text) + Keys.onEscapePressed: findBarRoot.closed() Component.onCompleted: { - if (findBarVisible) { + if (findBarRoot.barVisible) { forceActiveFocus(); } } @@ -72,11 +60,7 @@ Rectangle { PlasmaComponents3.Button { icon.name: "go-up" display: PlasmaComponents3.AbstractButton.IconOnly - onClicked: { - if (webviewItem) { - webviewItem.findText(findField.text, WebEngineView.FindBackward); - } - } + onClicked: findBarRoot.findPreviousRequested(findField.text) PlasmaComponents3.ToolTip.text: i18n("Find previous") PlasmaComponents3.ToolTip.visible: hovered enabled: findField.text !== "" @@ -85,11 +69,7 @@ Rectangle { PlasmaComponents3.Button { icon.name: "go-down" display: PlasmaComponents3.AbstractButton.IconOnly - onClicked: { - if (webviewItem) { - webviewItem.findText(findField.text); - } - } + onClicked: findBarRoot.findRequested(findField.text) PlasmaComponents3.ToolTip.text: i18n("Find next") PlasmaComponents3.ToolTip.visible: hovered enabled: findField.text !== "" @@ -100,26 +80,15 @@ Rectangle { display: PlasmaComponents3.AbstractButton.IconOnly PlasmaComponents3.ToolTip.text: i18n("Close") PlasmaComponents3.ToolTip.visible: hovered - onClicked: closeRequested() + onClicked: findBarRoot.closed() } } Behavior on height { + enabled: findBarRoot.animEnabled NumberAnimation { duration: Kirigami.Units.shortDuration easing.type: Easing.InOutQuad } } - - function focusAndSelect() { - findField.forceActiveFocus(); - findField.selectAll(); - } - - function clearSearch() { - findField.text = ""; - if (webviewItem) { - webviewItem.findText(""); - } - } -} \ No newline at end of file +} diff --git a/contents/ui/Header.qml b/contents/ui/Header.qml index 79bbe2c..7d5e440 100644 --- a/contents/ui/Header.qml +++ b/contents/ui/Header.qml @@ -1,42 +1,127 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - +import QtCore import QtQuick import QtQuick.Dialogs import QtQuick.Layouts - -import org.kde.plasma.components 3.0 as PlasmaComponents3 +import org.kde.plasma.components as PlasmaComponents3 import org.kde.kirigami as Kirigami +import org.kde.plasma.plasmoid +import QtWebEngine -import Qt.labs.platform 1.1 - -RowLayout { +Item { Layout.fillWidth: true + implicitHeight: headerRow.implicitHeight // Signals for communication with parent components signal goBackToHomePage + signal closeWebViewRequested signal reloadPageRequested signal navigateBackRequested signal navigateForwardRequested signal printPageRequested signal toggleSearchRequested + signal injectTransparencyRequested // Properties for managing component state - property var closeWebViewCallback: undefined // Callback function for closing webview - property var models // Available chat models - property bool showCustomURLInput: false // Toggle between URL selector and custom URL input + property var closeWebViewCallback: undefined + property var models property var webview: (parent && parent.webviewRoot) ? parent.webviewRoot.webview : null - // Utility function to ensure URL has a valid protocol - function sanitizeUrl(url) { - if (!url || typeof url !== 'string') return ''; - return url.match(/^https?:\/\//i) ? url : "https://" + url; + // Helper Functions + function renderChatModel() { + // Parse custom sites once and reuse + const customSitesRaw = plasmoid.configuration.customSites || ""; + const parsedCustomSites = customSitesRaw.split(',').filter(site => site && site.includes('|')).map(site => { + const sep = site.indexOf('|'); + return { name: site.substring(0, sep), url: site.substring(sep + 1) }; + }); + + const chatModel = models.filter(model => !model.prop.startsWith("showCustom_") && plasmoid.configuration[model.prop]).map(model => model.text) + .concat(parsedCustomSites.map(s => s.name)).concat([i18n("Custom URL...")]); + + urlComboBox.model = chatModel; + + const currentUrl = plasmoid.configuration.url; + const currentModel = models.find(model => !model.prop.startsWith("showCustom_") && model.url === currentUrl); + + if (currentModel) { + urlComboBox.currentIndex = chatModel.indexOf(currentModel.text); + urlComboBox.editable = false; + } else { + const customSite = parsedCustomSites.find(s => s.url === currentUrl); + if (customSite) { + urlComboBox.currentIndex = chatModel.indexOf(customSite.name); + urlComboBox.editable = false; + } else { + urlComboBox.currentIndex = chatModel.length - 1; + urlComboBox.customUrlText = currentUrl; + urlComboBox.editText = currentUrl; + urlComboBox.editable = true; + } + } + } + + function handleModelSelection() { + if (urlComboBox.currentIndex === urlComboBox.count - 1) { + let url = urlComboBox.editText; + if (url) { + plasmoid.configuration.url = url.match(/^https?:\/\//) ? url : "https://" + url; + goBackToHomePage(); + } + return; + } + + const selectedText = urlComboBox.currentText; + if (!selectedText) + return; + + const selectedModel = models.find(model => !model.prop.startsWith("showCustom_") && model.text === selectedText); + if (selectedModel) { + plasmoid.configuration.url = selectedModel.url; + goBackToHomePage(); + return; + } + + const customSiteEntry = (plasmoid.configuration.customSites || "").split(',').find(site => site && site.includes('|') && site.substring(0, site.indexOf('|')) === selectedText); + if (customSiteEntry) { + plasmoid.configuration.url = customSiteEntry.substring(customSiteEntry.indexOf('|') + 1); + goBackToHomePage(); + } } + // Reactive snapshot — re-renders when any service toggle changes + // Uses debounce timer to coalesce rapid config changes (e.g. multiple toggles) + readonly property var modelVisibilityState: { + const snapshot = [plasmoid.configuration.customSites]; + if (models) { + for (let i = 0; i < models.length; i++) { + snapshot.push(plasmoid.configuration[models[i].prop]); + } + } + return snapshot; + } + onModelVisibilityStateChanged: renderDebounce.restart() + + Timer { + id: renderDebounce + interval: 50 + onTriggered: renderChatModel() + } + + // Header gradient background + Rectangle { + anchors.fill: parent + visible: plasmoid.configuration.headerGradient + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.15) } + GradientStop { position: 1.0; color: "transparent" } + } + radius: Kirigami.Units.smallSpacing + } + + RowLayout { + id: headerRow + anchors.fill: parent + // Navigation buttons PlasmaComponents3.Button { icon.name: "go-previous" @@ -126,7 +211,8 @@ RowLayout { Keys.onReturnPressed: event => { if (currentIndex === count - 1 && editText) { - plasmoid.configuration.url = sanitizeUrl(editText); + let url = editText; + plasmoid.configuration.url = url.match(/^https?:\/\//) ? url : "https://" + url; goBackToHomePage(); event.accepted = true; } @@ -134,7 +220,8 @@ RowLayout { onAccepted: { if (currentIndex === count - 1 && editText) { - plasmoid.configuration.url = sanitizeUrl(editText); + let url = editText; + plasmoid.configuration.url = url.match(/^https?:\/\//) ? url : "https://" + url; goBackToHomePage(); } } @@ -145,7 +232,7 @@ RowLayout { // Auto-Hide Button PlasmaComponents3.Button { id: autoHideButton - visible: !plasmoid.configuration.hidePrintButton + visible: !plasmoid.configuration.hideAutoHideButton icon.name: plasmoid.configuration.autoHideHeader ? "view-visible" : "view-hidden" display: PlasmaComponents3.AbstractButton.IconOnly checkable: true @@ -191,6 +278,19 @@ RowLayout { onAccepted: plasmoid.configuration.downloadPath = selectedFolder } + // Toggle blur button + PlasmaComponents3.Button { + icon.name: plasmoid.configuration.enableBlur ? "blur" : "edit-opacity" + display: PlasmaComponents3.AbstractButton.IconOnly + checkable: true + checked: plasmoid.configuration.enableBlur + onToggled: injectTransparencyRequested() + z: 3 + PlasmaComponents3.ToolTip.text: checked ? i18n("Blur enabled — click to disable") : i18n("Blur disabled — click to enable") + PlasmaComponents3.ToolTip.delay: Kirigami.Units.toolTipDelay + PlasmaComponents3.ToolTip.visible: hovered + } + // Search button - Toggles the find bar PlasmaComponents3.Button { icon.name: "search" @@ -208,7 +308,7 @@ RowLayout { display: PlasmaComponents3.AbstractButton.IconOnly checkable: true checked: Boolean(plasmoid.configuration.keepOpen) - onToggled: plasmoid.configuration.pin = checked + onToggled: plasmoid.configuration.keepOpen = checked visible: !Boolean(plasmoid.configuration.hideKeepOpen) z: 3 PlasmaComponents3.ToolTip.text: checked ? i18n("Widget will stay open when clicking outside") : i18n("Widget will close when clicking outside") @@ -221,6 +321,7 @@ RowLayout { icon.name: "window-close" display: PlasmaComponents3.AbstractButton.IconOnly onClicked: { + closeWebViewRequested(); closeWebViewCallback?.(); } visible: !plasmoid.configuration.hideCloseButton @@ -230,111 +331,5 @@ RowLayout { PlasmaComponents3.ToolTip.visible: hovered } - // Helper Functions - // Returns the number of available chat models - function getModelsLength() { - return urlComboBox.model.length; - } - - // Updates the chat model list and current selection - // Handles both predefined and custom chat models - function renderChatModel() { - // Create model list from enabled predefined models - const chatModel = models.filter(model => !model.prop.startsWith("showCustom_") && plasmoid.configuration[model.prop]).map(model => model.text) - // Add custom sites to the model list - .concat((plasmoid.configuration.customSites || "").split(',').filter(site => site?.includes('|')).map(site => site.split('|')[0])).concat([i18n("Custom URL...")]); - - // Update ComboBox model and select current item - urlComboBox.model = chatModel; - - const currentUrl = plasmoid.configuration.url; - const currentModel = models.find(model => !model.prop.startsWith("showCustom_") && model.url === currentUrl); - - if (currentModel) { - const index = chatModel.indexOf(currentModel.text); - urlComboBox.currentIndex = index; - urlComboBox.editable = false; - } else { - const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.includes('|') && site.split('|')[1] === currentUrl); - - if (customSite) { - const siteName = customSite.split('|')[0]; - const index = chatModel.indexOf(siteName); - urlComboBox.currentIndex = index; - urlComboBox.editable = false; - } else { - urlComboBox.currentIndex = chatModel.length - 1; - urlComboBox.customUrlText = currentUrl; - urlComboBox.editText = currentUrl; - urlComboBox.editable = true; - } - } - } - - // Handles model selection from ComboBox - // Updates current URL and navigates to selected chat - function handleModelSelection() { - if (urlComboBox.currentIndex === urlComboBox.count - 1) { - // Custom URL handling - const url = urlComboBox.editText; - if (url) { - plasmoid.configuration.url = sanitizeUrl(url); - goBackToHomePage(); - } - return; - } - - const selectedText = urlComboBox.currentValue; - if (!selectedText) - return; - urlComboBox.displayText = selectedText; - - const selectedModel = models.find(model => !model.prop.startsWith("showCustom_") && model.text === selectedText); - if (selectedModel) { - plasmoid.configuration.url = selectedModel.url; - goBackToHomePage(); - return; - } - - const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.split('|')[0] === selectedText); - if (customSite) { - plasmoid.configuration.url = customSite.split('|')[1]; - goBackToHomePage(); - } - } - - Binding { - target: root - property: "hideOnWindowDeactivate" - value: !plasmoid.configuration.pin - restoreMode: Binding.RestoreBinding - } - - // Debounce timer for model re-rendering - Timer { - id: modelUpdateTimer - interval: 50 - onTriggered: renderChatModel() - } - - // Configuration change handler - debounced to prevent multiple rapid re-renders - Connections { - target: plasmoid.configuration - function onCustomSitesChanged() { modelUpdateTimer.restart() } - function onShowT3ChatChanged() { modelUpdateTimer.restart() } - function onShowDuckDuckGoChatChanged() { modelUpdateTimer.restart() } - function onShowChatGPTChanged() { modelUpdateTimer.restart() } - function onShowHugginChatChanged() { modelUpdateTimer.restart() } - function onShowGoogleGeminiChanged() { modelUpdateTimer.restart() } - function onShowYouChanged() { modelUpdateTimer.restart() } - function onShowPerplexityChanged() { modelUpdateTimer.restart() } - function onShowLobeChatChanged() { modelUpdateTimer.restart() } - function onShowBigAGIChanged() { modelUpdateTimer.restart() } - function onShowBlackBoxChanged() { modelUpdateTimer.restart() } - function onShowBingCopilotChanged() { modelUpdateTimer.restart() } - function onShowClaudeChanged() { modelUpdateTimer.restart() } - function onShowDeepSeekChanged() { modelUpdateTimer.restart() } - function onShowMetaAIChanged() { modelUpdateTimer.restart() } - function onShowGrokChanged() { modelUpdateTimer.restart() } - } -} + } // close RowLayout +} // close Item diff --git a/contents/ui/WebView.qml b/contents/ui/WebView.qml index 29d90c7..1f3c020 100644 --- a/contents/ui/WebView.qml +++ b/contents/ui/WebView.qml @@ -1,61 +1,58 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtCore import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts import QtWebEngine - import org.kde.plasma.components as PlasmaComponents3 import org.kde.plasma.core as PlasmaCore import org.kde.plasma.plasmoid -import org.kde.kirigami as Kirigami import org.kde.notification 1.0 - -import Qt.labs.platform 1.1 - -import "." +import org.kde.kirigami as Kirigami Item { id: webViewRoot readonly property string effectiveProfileName: plasmoid.configuration.webEngineProfileName && plasmoid.configuration.webEngineProfileName.length ? plasmoid.configuration.webEngineProfileName : "chat-ai" + readonly property string effectiveDownloadPath: { + if (plasmoid.configuration.downloadPath) + return plasmoid.configuration.downloadPath.toString().replace(/^file:\/\//, ''); + return StandardPaths.writableLocation(StandardPaths.DownloadLocation); + } + + function toggleBlur() { + plasmoid.configuration.enableBlur = !plasmoid.configuration.enableBlur; + } function goBackToHomePage() { - const url = plasmoid.configuration.url; - if (!url || typeof url !== 'string' || url.length === 0) { - console.error("Invalid or empty URL configuration"); - return; - } - webview.url = url; + plasmoid.configuration.lastVisitedUrl = ""; + webview.url = plasmoid.configuration.url; } function goBack() { - if (webview) webview.goBack(); + webview.goBack(); } function goForward() { - if (webview) webview.goForward(); + webview.goForward(); } function reloadPage() { - if (webview) webview.reloadAndBypassCache(); + webview.reloadAndBypassCache(); } function printPage() { webview.runJavaScript("document.title", function (title) { - let downloadDirectory = plasmoid.configuration.downloadPath ? plasmoid.configuration.downloadPath.toString().replace(/^file:\/\//, '') : StandardPaths.writableLocation(StandardPaths.DownloadLocation); + let downloadDirectory = webViewRoot.effectiveDownloadPath; let timestamp = new Date().toISOString().replace(/[:.]/g, '-'); let safeName = title.replace(/[^a-z0-9]/gi, '-').toLowerCase(); let filename = `${downloadDirectory}/${safeName}-${timestamp}.pdf`; // Add the PDF as a special type of download - webview.downloads.addDownload(null, `${safeName}-${timestamp}.pdf`, filename, true); + let pdfIndex = webview.downloads.addDownload(null, `${safeName}-${timestamp}.pdf`, filename, true); + + // Store the PDF index for future reference + let currentPdfIndex = pdfIndex; webview.printToPdf(filename, WebEngineView.A4, WebEngineView.Portrait); }); @@ -63,7 +60,7 @@ Item { function saveMHTML() { webview.runJavaScript("document.title", function (title) { - let downloadDirectory = plasmoid.configuration.downloadPath ? plasmoid.configuration.downloadPath.toString().replace(/^file:\/\//, '') : StandardPaths.writableLocation(StandardPaths.DownloadLocation); + let downloadDirectory = webViewRoot.effectiveDownloadPath; let timestamp = new Date().toISOString().replace(/[:.]/g, '-'); let safeName = title.replace(/[^a-z0-9]/gi, '-').toLowerCase(); @@ -73,15 +70,73 @@ Item { }); } + readonly property string chromeDesktopUA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + readonly property string chromeMobileUA: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36" + function getUserAgent() { - return plasmoid.configuration.url.includes("https://duckduckgo.com") || plasmoid.configuration.url.includes("x.com/i/grok") ? "Mozilla/5.0 (Linux; Android 9; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36" : ""; + var needsMobile = plasmoid.configuration.url.includes("duckduckgo.com") || plasmoid.configuration.url.includes("x.com/i/grok"); + if (needsMobile) + return chromeMobileUA; + if (plasmoid.configuration.spoofChromeBrowser) + return chromeDesktopUA; + return ""; + } + + // Spoof browser identity so auth pages (Google, Claude, etc.) work + function injectBrowserSpoof() { + if (!plasmoid.configuration.spoofChromeBrowser) + return; + + webview.runJavaScript(" + if (!window._chatAISpoofed) { + window._chatAISpoofed = true; + + // Spoof navigator properties + Object.defineProperty(navigator, 'vendor', { get: function() { return 'Google Inc.'; } }); + Object.defineProperty(navigator, 'platform', { get: function() { return 'Linux x86_64'; } }); + Object.defineProperty(navigator, 'webdriver', { get: function() { return false; } }); + Object.defineProperty(navigator, 'languages', { get: function() { return ['en-US', 'en']; } }); + + // Spoof window.chrome + if (!window.chrome) { + window.chrome = { + runtime: {}, + loadTimes: function() { return {}; }, + csi: function() { return {}; }, + app: { isInstalled: false } + }; + } + + // Spoof plugins (Chrome has PDF viewer) + Object.defineProperty(navigator, 'plugins', { + get: function() { + return [ + { name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' } + ]; + } + }); + + // Hide webdriver/automation hints + delete navigator.__proto__.webdriver; + + // Spoof permissions API behavior + if (navigator.permissions) { + var origQuery = navigator.permissions.query; + navigator.permissions.query = function(params) { + if (params.name === 'notifications') + return Promise.resolve({ state: Notification.permission }); + return origQuery.call(navigator.permissions, params); + }; + } + } + "); } Notification { id: webNotification componentName: "chatai_plasmoid" eventId: "notification" - defaultAction: i18n("Open") title: i18n("ChatAI") iconName: "dialog-information" } @@ -98,11 +153,6 @@ Item { return "file:///" + path.replace(/^\/+/, ''); } - function getOpenPath(path) { - // To open the file, it cannot have file:// - return path.replace(/^file:\/+/, '').replace(/^\/+/, '/'); - } - // Add this helper function before the WebEngineView function isDownloadInProgress(fileName) { if (!webview || !webview.downloads) @@ -122,22 +172,157 @@ Item { property bool findBarVisible: false + // Re-inject CSS when settings change live + Connections { + target: plasmoid.configuration + function onEnableBlurChanged() { if (webview.url.toString()) webview.injectTransparencyCSS(); } + function onFocusModeChanged() { if (webview.url.toString()) webview.injectFocusMode(); } + } + + onFindBarVisibleChanged: { + if (findBarVisible) { + findBarComponent.focusField(); + } else { + webview.findText(""); // Clear any existing search + } + } + Shortcut { sequence: StandardKey.Find onActivated: findBarVisible = true } - ContextMenu { + PlasmaComponents3.Menu { id: linkContextMenu - webviewItem: webview - onReloadRequested: reloadPage() - onSavePdfRequested: printPage() - onSaveMhtmlRequested: saveMHTML() + + property string link: "" + + PlasmaComponents3.MenuItem { + text: i18n("Back") + icon.name: "go-previous" + enabled: webview.canGoBack + onTriggered: webview.goBack() + } + + PlasmaComponents3.MenuItem { + text: i18n("Forward") + icon.name: "go-next" + enabled: webview.canGoForward + onTriggered: webview.goForward() + } + + PlasmaComponents3.MenuItem { + text: i18n("Reload") + icon.name: "view-refresh" + onTriggered: reloadPage() + } + + PlasmaComponents3.MenuItem { + text: i18n("Save as PDF") + icon.name: "document-save-as" + visible: !linkContextMenu.link + onTriggered: printPage() + } + + PlasmaComponents3.MenuItem { + text: i18n("Save as MHTML") + icon.name: "document-save" + visible: !linkContextMenu.link + onTriggered: saveMHTML() + } + + PlasmaComponents3.MenuItem { + text: i18n("Open Link in Browser") + icon.name: "internet-web-browser" + visible: linkContextMenu.link !== "" + onTriggered: Qt.openUrlExternally(linkContextMenu.link) + } + + PlasmaComponents3.MenuItem { + text: i18n("Copy Link Address") + icon.name: "edit-copy" + visible: linkContextMenu.link !== "" + onTriggered: webview.triggerWebAction(WebEngineView.CopyLinkToClipboard) + } } WebEngineView { id: webview + property bool _forceOpaque: false + backgroundColor: (!plasmoid.configuration.enableBlur || _forceOpaque) ? Kirigami.Theme.backgroundColor : "transparent" + + // Brief opacity flip to force WebEngine compositing reset + Timer { + id: bgFlipTimer + interval: 50 + onTriggered: webview._forceOpaque = false + } + function kickTransparency() { + _forceOpaque = true; + bgFlipTimer.start(); + } + + // Background-only transparency: inline styles on html/body and + // top-level wrapper elements, leaving all content areas intact + readonly property string _jsRemoveBlur: ` + (function() { try { + if (window._chatai_resizeHandler) { + window.removeEventListener('resize', window._chatai_resizeHandler); + window._chatai_resizeHandler = null; + } + document.documentElement.style.removeProperty('background-color'); + document.body.style.removeProperty('background-color'); + document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) { + el.style.removeProperty('background-color'); + el.style.removeProperty('backdrop-filter'); + el.style.removeProperty('-webkit-backdrop-filter'); + }); + } catch(e) {} })();` + + readonly property string _jsApplyBlur: ` + (function() { try { + window._chatai_applyBlur = function() { + var a = 0.5, b = 8; + var minW = window.innerWidth * 0.3; + document.documentElement.style.setProperty('background-color', 'transparent', 'important'); + document.body.style.setProperty('background-color', 'transparent', 'important'); + var els = document.body.querySelectorAll('body > *, body > * > *'); + var targets = []; + for (var i = 0; i < els.length; i++) { + var r = els[i].getBoundingClientRect(); + if (r.width < minW) continue; + var bg = getComputedStyle(els[i]).backgroundColor; + if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue; + var m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); + if (m) targets.push({ el: els[i], r: m[1], g: m[2], b: m[3] }); + } + for (var j = 0; j < targets.length; j++) { + var t = targets[j]; + t.el.style.setProperty('background-color', 'rgba(' + t.r + ',' + t.g + ',' + t.b + ',' + a + ')', 'important'); + t.el.style.setProperty('backdrop-filter', 'blur(' + b + 'px)', 'important'); + t.el.style.setProperty('-webkit-backdrop-filter', 'blur(' + b + 'px)', 'important'); + } + }; + window._chatai_applyBlur(); + if (!window._chatai_resizeHandler) { + var timer = null; + window._chatai_resizeHandler = function() { + clearTimeout(timer); + timer = setTimeout(window._chatai_applyBlur, 300); + }; + window.addEventListener('resize', window._chatai_resizeHandler); + } + } catch(e) {} })();` + + function injectTransparencyCSS() { + if (!plasmoid.configuration.enableBlur) { + webview.runJavaScript(webview._jsRemoveBlur); + return; + } + webview.runJavaScript(webview._jsApplyBlur); + } + property var downloadCache: ({}) property var downloads: ListModel { @@ -154,11 +339,6 @@ Item { "state": WebEngineDownloadRequest.DownloadInProgress }; - if (downloadItem) { - // Store reference in cache - webview.downloadCache[downloadId] = downloadItem; - } - this.append(download); return this.count - 1; } @@ -248,8 +428,15 @@ Item { } anchors.fill: parent - url: plasmoid.configuration.url + url: plasmoid.configuration.lastVisitedUrl || plasmoid.configuration.url profile: webProfile + + // Save current URL so it persists across WebView unload/reload + onUrlChanged: { + var u = url.toString(); + if (u && u !== "about:blank" && u !== plasmoid.configuration.lastVisitedUrl) + plasmoid.configuration.lastVisitedUrl = u; + } onLinkHovered: hoveredUrl => { if (hoveredUrl == "") { hideStatusText.start(); @@ -266,7 +453,7 @@ Item { onContextMenuRequested: request => { // Use default menu for special elements (text fields, selection, etc) if (request.isContentEditable || request.selectedText || request.mediaType !== ContextMenuRequest.MediaTypeNone) { - request.accepted = false; // Allow default menu to appear + request.accepted = false; // Let the default menu appear return; } @@ -275,7 +462,7 @@ Item { linkContextMenu.link = hasLink ? request.linkUrl.toString() : ""; // Always show our custom menu when it's not a special element - linkContextMenu.open(request.position.x, request.position.y); + linkContextMenu.popup(request.position.x, request.position.y); request.accepted = true; } @@ -297,93 +484,220 @@ Item { } return; } - if (request.permissionType === WebEnginePermission.MediaAudioCapture || request.permissionType === 1 || request.permissionType === WebEnginePermission.MediaVideoCapture || request.permissionType === 2 || request.permissionType === 5) { - let isMicrophoneRequest = request.permissionType === 1 || request.permissionType === WebEnginePermission.MediaAudioCapture; - let isWebcamRequest = request.permissionType === 2 || request.permissionType === WebEnginePermission.MediaVideoCapture; - let isScreenShareRequest = request.permissionType === 5 || request.permissionType === WebEnginePermission.DesktopAudioVideoCapture; + if (request.permissionType === WebEnginePermission.MediaAudioCapture) { + plasmoid.configuration.microphoneEnabled ? request.grant() : request.deny(); return; } - // Even if MediaAudioCapture and MediaVideoCapture are allowed, it is still necessary to allow DesktopAudioVideoCapture - if (request.permissionType === WebEnginePermission.DesktopAudioVideoCapture || request.permissionType === 3) { - if (WebEnginePermission.MediaAudioCapture && WebEnginePermission.MediaVideoCapture) { - request.grant(); - } else { - request.deny(); - } + if (request.permissionType === WebEnginePermission.MediaVideoCapture) { + plasmoid.configuration.webcamEnabled ? request.grant() : request.deny(); + return; + } + if (request.permissionType === WebEnginePermission.DesktopAudioVideoCapture) { + plasmoid.configuration.screenShareEnabled ? request.grant() : request.deny(); + return; } request.grant(); } - onLoadingChanged: { - if (!webview.loading) { - checkAndUpdateFavicon(); + // Focus mode: hide sidebars, headers, and non-essential UI per service + function injectFocusMode() { + if (!plasmoid.configuration.focusMode) { + webview.runJavaScript("var el = document.getElementById('_chatai_focus'); if (el) el.remove();"); + return; } - var isCompatibleModel = ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'].some(site => plasmoid.configuration.url.includes(site)); - - if (isCompatibleModel) { - webview.runJavaScript(" - document.addEventListener('keydown', function(event) { - if (event.key === 'Enter' && !event.shiftKey) { - var duckDuckGoButton = document.querySelector('button[aria-label=\"Send\"]'); - var chatGPTButton = document.querySelector('button[data-testid=\"send-button\"]'); - var googleGeminiButton = document.querySelector('button.send-button'); - var claudeButton = document.querySelector('button[aria-label=\"Send Message\"]'); - - if (duckDuckGoButton) { - event.preventDefault(); - duckDuckGoButton.click(); - waitForTextareaEnabledAndFocus(); - } + var url = webview.url.toString(); + var css = ""; - if (chatGPTButton) { - event.preventDefault(); - chatGPTButton.click(); - waitForTextareaEnabledAndFocus(); - } + if (url.includes("chatgpt.com")) { + css = ` + /* ChatGPT: hide sidebar, top nav */ + nav, div[class*="sidebar"], div[class*="Sidebar"], + div[class*="drawer"], header:has(button[aria-label]) { + display: none !important; + } + main { margin-left: 0 !important; } + div[class*="thread"] { max-width: 100% !important; } + `; + } else if (url.includes("claude.ai")) { + css = ` + /* Claude: hide sidebar */ + div[class*="sidebar"], div[class*="Sidebar"], + nav, aside, div[data-testid="sidebar"], + div[class*="ConversationList"], div[class*="conversation-list"] { + display: none !important; + } + main, div[class*="main"], div[class*="Main"] { + margin-left: 0 !important; + max-width: 100% !important; + } + `; + } else if (url.includes("duckduckgo.com")) { + css = ` + /* DuckDuckGo: hide header, side panels */ + header, div[class*="header"], div[class*="Header"], + div[class*="sidebar"], aside { + display: none !important; + } + main { margin: 0 auto !important; max-width: 100% !important; } + `; + } else if (url.includes("gemini.google.com")) { + css = ` + /* Gemini: hide side nav, top bar */ + mat-sidenav, side-navigation, side-navigation-v2, + header, .header-bar, mat-toolbar, + c-wiz > header, div[class*="side-nav"] { + display: none !important; + } + mat-sidenav-content, .main-container { + margin-left: 0 !important; + max-width: 100% !important; + } + `; + } else if (url.includes("chat.deepseek.com")) { + css = ` + /* DeepSeek: hide sidebar */ + div[class*="sidebar"], div[class*="Sidebar"], + nav, aside { + display: none !important; + } + main, div[class*="main"] { + margin-left: 0 !important; + max-width: 100% !important; + } + `; + } else if (url.includes("copilot.microsoft.com")) { + css = ` + /* Copilot: hide side elements */ + aside, nav, div[class*="sidebar"], div[class*="Sidebar"] { + display: none !important; + } + main { margin: 0 !important; max-width: 100% !important; } + `; + } - if (googleGeminiButton) { - event.preventDefault(); - googleGeminiButton.click(); - waitForTextareaEnabledAndFocus(); - } + // Inject known CSS or run heuristic fallback + webview.runJavaScript(" + (function() { + var s = document.getElementById('_chatai_focus'); + if (s) s.remove(); + s = document.createElement('style'); + s.id = '_chatai_focus'; + + var knownCSS = `" + css + "`; - if (claudeButton) { - event.preventDefault(); - claudeButton.click(); - waitForTextareaEnabledAndFocus(); + if (knownCSS.trim()) { + s.textContent = knownCSS; + } else { + // Heuristic: analyze DOM and hide non-essential elements + var viewW = window.innerWidth; + var hidden = []; + + // 1. Hide semantic nav/aside/header elements + document.querySelectorAll('nav, aside, [role=\"navigation\"], [role=\"banner\"], [role=\"complementary\"]').forEach(function(el) { + var rect = el.getBoundingClientRect(); + // Skip if it's the main content area or tiny + if (rect.width > viewW * 0.6 || rect.height < 20) return; + hidden.push(el); + }); + + // 2. Hide fixed/absolute sidebars (narrow elements pinned to sides) + // Only check direct children of body and their direct children to avoid scanning thousands of elements + document.querySelectorAll('body > div, body > section, body > div > div, body > div > section').forEach(function(el) { + var style = getComputedStyle(el); + if (style.position !== 'fixed' && style.position !== 'absolute' && style.position !== 'sticky') return; + var rect = el.getBoundingClientRect(); + if (rect.width > viewW * 0.35) return; // too wide to be sidebar + if (rect.height < viewW * 0.3) return; // too short to be sidebar + // Likely a sidebar or panel + hidden.push(el); + }); + + // 3. Hide top headers (full-width, short, at top) + document.querySelectorAll('header, [role=\"banner\"]').forEach(function(el) { + var rect = el.getBoundingClientRect(); + if (rect.top < 10 && rect.height < 80 && rect.width > viewW * 0.5) { + hidden.push(el); + } + }); + + // Build CSS from detected elements + var css = ''; + hidden.forEach(function(el) { + // Tag unique selectors for each element + if (!el.dataset.chataiHidden) { + el.dataset.chataiHidden = '1'; } + }); + css = '[data-chatai-hidden=\"1\"] { display: none !important; }\\n'; + css += 'main, [role=\"main\"] { margin-left: 0 !important; margin-right: 0 !important; max-width: 100% !important; width: 100% !important; }'; + + s.textContent = css; + } + + document.head.appendChild(s); + })(); + "); + } + + // Pre-built keyboard shortcut + textarea focus JS (uses MutationObserver instead of setInterval) + readonly property string _jsKeyboardShortcuts: ` + if (!window._chatAIInjected) { + window._chatAIInjected = true; + + document.addEventListener('keydown', function(event) { + if (event.key === 'Enter' && !event.shiftKey) { + var btn = document.querySelector('button[aria-label="Send"]') + || document.querySelector('button[data-testid="send-button"]') + || document.querySelector('button.send-button') + || document.querySelector('button[aria-label="Send Message"]'); + if (btn) { + event.preventDefault(); + btn.click(); + window._chatAIWaitForTextarea(); } - }); + } + }); - function waitForTextareaEnabledAndFocus() { - var attempts = 0; - var interval = 100; - - var textareaFocusInterval = setInterval(function() { - var textarea = document.querySelector('textarea'); - if (textarea && !textarea.disabled) { - clearInterval(textareaFocusInterval); - setTimeout(function() { - textarea.focus(); - }, 100); - } - }, interval); + window._chatAIWaitForTextarea = function() { + var textarea = document.querySelector('textarea'); + if (textarea && !textarea.disabled) { + setTimeout(function() { textarea.focus(); }, 100); + return; } + var observer = new MutationObserver(function(mutations, obs) { + var ta = document.querySelector('textarea'); + if (ta && !ta.disabled) { + obs.disconnect(); + setTimeout(function() { ta.focus(); }, 100); + } + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); + setTimeout(function() { observer.disconnect(); }, 5000); + }; + + window._chatAIWaitForTextarea(); + }` - waitForTextareaEnabledAndFocus(); - "); + // Compatible sites for keyboard shortcut injection + readonly property var _compatibleSites: ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'] + + onLoadingChanged: { + injectBrowserSpoof(); + + if (!webview.loading) { + checkAndUpdateFavicon(); + webview.kickTransparency(); + injectTransparencyCSS(); + injectFocusMode(); + + // Check actual page URL (not configured home URL) for keyboard shortcuts + var currentUrl = webview.url.toString(); + if (_compatibleSites.some(function(site) { return currentUrl.includes(site); })) { + webview.runJavaScript(webview._jsKeyboardShortcuts); + } } } - onFeaturePermissionRequested: function (securityOrigin, feature) { - if (feature === WebEngineView.MediaAudioCapture) - grantFeaturePermission(securityOrigin, feature, plasmoid.configuration.microphoneEnabled); - else if (feature === WebEngineView.MediaVideoCapture) - grantFeaturePermission(securityOrigin, feature, plasmoid.configuration.webcamEnabled); - else if (feature === WebEngineView.DesktopAudioVideoCapture) - grantFeaturePermission(securityOrigin, feature, plasmoid.configuration.screenShareEnabled); - } - onPrintRequested: function () { webview.triggerWebAction(WebEngineView.Print); } @@ -445,6 +759,19 @@ Item { } } + Component.onDestruction: { + // Disconnect all active download signal connections to prevent leaks + for (var id in downloadCache) { + var entry = downloadCache[id]; + if (entry && entry.download && entry.bytesConnection) { + try { + entry.download.receivedBytesChanged.disconnect(entry.bytesConnection); + entry.download.stateChanged.disconnect(entry.stateConnection); + } catch(e) {} + } + } + } + WebEngineProfile { id: webProfile httpUserAgent: getUserAgent() @@ -453,12 +780,7 @@ Item { httpCacheType: WebEngineProfile.DiskHttpCache persistentCookiesPolicy: WebEngineProfile.ForcePersistentCookies persistentPermissionsPolicy: WebEngineProfile.AskEveryTime - downloadPath: { - if (plasmoid.configuration.downloadPath) - return plasmoid.configuration.downloadPath.toString().replace(/^file:\/\//, ''); - - return StandardPaths.writableLocation(StandardPaths.DownloadLocation); - } + downloadPath: webViewRoot.effectiveDownloadPath onPresentNotification: function (notification) { showNotification(notification.title, notification.message); notification.show(); @@ -469,7 +791,7 @@ Item { webview.downloads = Qt.createQmlObject('import QtQml; ListModel {}', webview); } - let downloadDirectory = plasmoid.configuration.downloadPath ? plasmoid.configuration.downloadPath.toString().replace(/^file:\/\//, '') : StandardPaths.writableLocation(StandardPaths.DownloadLocation); + let downloadDirectory = webViewRoot.effectiveDownloadPath; if (!plasmoid.configuration.downloadPath) { plasmoid.configuration.downloadPath = downloadDirectory; @@ -558,6 +880,7 @@ Item { } } + MouseArea { id: mouseArea @@ -571,47 +894,26 @@ Item { } } - PlasmaComponents3.ProgressBar { - id: loadingProgressBar - - z: 10 - visible: webview.loading && webview.loadProgress < 100 - height: visible ? 3 : 0 - - anchors { - top: parent.top - left: parent.left - right: parent.right - } - - from: 0 - to: 100 - value: webview.loadProgress - - Behavior on height { - NumberAnimation { - duration: Kirigami.Units.shortDuration - easing.type: Easing.InOutQuad - } - } - } - Rectangle { id: statusBubble property int padding: 8 - color: Kirigami.Theme.backgroundColor visible: false anchors.left: parent.left anchors.bottom: parent.bottom - width: statusText.paintedWidth + padding + anchors.margins: Kirigami.Units.smallSpacing + width: statusText.paintedWidth + padding * 2 height: statusText.paintedHeight + padding + z: 5 + color: Kirigami.Theme.backgroundColor + opacity: plasmoid.configuration.overlayOpacity + radius: Kirigami.Units.smallSpacing Text { id: statusText - anchors.centerIn: statusBubble + anchors.centerIn: parent elide: Qt.ElideMiddle color: Kirigami.Theme.textColor @@ -625,29 +927,42 @@ Item { } } } + + Behavior on opacity { + enabled: plasmoid.configuration.enableAnimations + NumberAnimation { duration: 200 } + } } DownloadBar { + id: downloadsBar + downloadsModel: webview.downloads - downloadCache: webview.downloadCache - webviewItem: webview + downloadCacheRef: webview.downloadCache + + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } } FindBar { - id: findBar - findBarVisible: webViewRoot.findBarVisible - webviewItem: webview + id: findBarComponent + + barVisible: findBarVisible - onCloseRequested: { - webViewRoot.findBarVisible = false; + anchors { + top: parent.top + left: parent.left + right: parent.right } - onFindBarVisibleChanged: { - if (findBarVisible) { - findBar.focusAndSelect(); - } else { - findBar.clearSearch(); - } + onFindRequested: text => webview.findText(text) + onFindPreviousRequested: text => webview.findText(text, WebEngineView.FindBackward) + onClosed: { + findBarVisible = false; + webview.findText(""); } } } diff --git a/contents/ui/main.qml b/contents/ui/main.qml index 379dac4..2f9fb98 100644 --- a/contents/ui/main.qml +++ b/contents/ui/main.qml @@ -1,21 +1,18 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - import QtQuick import QtQuick.Layouts - +import org.kde.kirigami as Kirigami import org.kde.plasma.core as PlasmaCore import org.kde.plasma.plasmoid -import org.kde.kirigami as Kirigami // Main plasmoid item that contains all the widget functionality PlasmoidItem { id: root + // Translucent background lets compositor blur the desktop behind the popup + Plasmoid.backgroundHints: plasmoid.configuration.enableBlur + ? PlasmaCore.Types.TranslucentBackground + : PlasmaCore.Types.DefaultBackground + // Define the available chat models and their properties // This property combines both predefined and custom sites property var models: { @@ -42,7 +39,7 @@ PlasmoidItem { { "id": "huggingface", "url": "https://huggingface.co/chat", - "text": "HuggingChat", + "text": "HugginChat", "prop": "showHugginChat" }, { @@ -140,6 +137,14 @@ PlasmoidItem { } } + // Hide popup when clicking outside (unless pinned) + Binding { + target: root + property: "hideOnWindowDeactivate" + value: !plasmoid.configuration.keepOpen + restoreMode: Binding.RestoreBinding + } + // Widget appearance when collapsed (icon only) compactRepresentation: CompactRepresentation { id: compactRep @@ -149,18 +154,66 @@ PlasmoidItem { } // Widget appearance when expanded (full view) - fullRepresentation: ColumnLayout { - id: mainLayout + fullRepresentation: Item { + id: fullRep // Expose WebView root for other components property alias webviewRoot: webviewLoader.item + + Layout.minimumWidth: Kirigami.Units.gridUnit * 28 + Layout.minimumHeight: Kirigami.Units.gridUnit * 39 + + // Accent glow around the widget (only created when enabled) + Loader { + active: plasmoid.configuration.accentBorder + anchors.fill: parent + anchors.margins: -6 + z: -1 + sourceComponent: Rectangle { + color: "transparent" + radius: Kirigami.Units.largeSpacing + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.width: 6 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.25) + } + Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: parent.radius - 2 + color: "transparent" + border.width: 4 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) + } + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: parent.radius - 4 + color: "transparent" + border.width: 2 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.6) + } + } + } + + // Rounded clip container + Rectangle { + id: clipContainer + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + radius: Kirigami.Units.largeSpacing + color: "transparent" + clip: true + + ColumnLayout { + id: mainLayout + anchors.fill: parent // Update the property to use Types.Location and add the change monitor property bool reverseLayout: plasmoid.location === PlasmaCore.Types.TopEdge - // Default dimensions (used when no saved size exists) - readonly property int defaultWidth: Kirigami.Units.gridUnit * 28 - readonly property int defaultHeight: Kirigami.Units.gridUnit * 39 - // Function to reorder components function reorderComponents() { let components = reverseLayout ? [webviewLoader, headerMouseArea, headerRoot] : [headerRoot, headerMouseArea, webviewLoader]; @@ -181,42 +234,9 @@ PlasmoidItem { } } - // Set minimum dimensions for the expanded view - Layout.minimumWidth: Kirigami.Units.gridUnit * 20 - Layout.minimumHeight: Kirigami.Units.gridUnit * 28 - // Use saved dimensions if available, otherwise use defaults - Layout.preferredWidth: plasmoid.configuration.dialogWidth > 0 ? plasmoid.configuration.dialogWidth : defaultWidth - Layout.preferredHeight: plasmoid.configuration.dialogHeight > 0 ? plasmoid.configuration.dialogHeight : defaultHeight - Component.onCompleted: { reorderComponents(); } - - // Save window size when user resizes - // Use a timer to debounce saves (avoid saving on every pixel change) - Timer { - id: saveSizeTimer - interval: 500 - repeat: false - onTriggered: { - // Only save if the size is valid and different from saved value - const currentWidth = Math.round(mainLayout.width); - const currentHeight = Math.round(mainLayout.height); - const savedWidth = plasmoid.configuration.dialogWidth; - const savedHeight = plasmoid.configuration.dialogHeight; - - // Save if dimensions are valid and different from what's saved - if (currentWidth > 0 && currentHeight > 0 && - (currentWidth !== savedWidth || currentHeight !== savedHeight)) { - plasmoid.configuration.dialogWidth = currentWidth; - plasmoid.configuration.dialogHeight = currentHeight; - } - } - } - - onWidthChanged: saveSizeTimer.restart() - onHeightChanged: saveSizeTimer.restart() - spacing: 0 // Add monitor for plasmoid location change @@ -248,9 +268,10 @@ PlasmoidItem { models: root.models Layout.fillWidth: true z: 2 // Increase the z-index to ensure it is above the MouseArea - // Callback to close the WebView and collapse the widget + // Callback to close/collapse the widget closeWebViewCallback: function () { - webviewLoader.active = false; + if (!plasmoid.configuration.keepWebEngineAlive) + unloadTimer.restart(); root.expanded = false; } // Handle navigation @@ -259,6 +280,9 @@ PlasmoidItem { onNavigateBackRequested: webviewRoot.goBack() onNavigateForwardRequested: webviewRoot.goForward() onPrintPageRequested: webviewRoot.printPage() + onInjectTransparencyRequested: { + if (webviewRoot) webviewRoot.toggleBlur(); + } onToggleSearchRequested: { if (webviewRoot && webviewRoot.findBarVisible !== undefined) { webviewRoot.findBarVisible = !webviewRoot.findBarVisible; @@ -280,26 +304,25 @@ PlasmoidItem { } } - // Timer for hiding + // Timer for hiding — only hides if mouse is away AND no interaction Timer { id: hideTimer - interval: 2000 + interval: 1500 onTriggered: { - if (!headerRoot.isInteracting) + if (!headerRoot.isInteracting && !headerMouseArea.containsMouse) headerRoot.headerVisible = false; } } - // Timer to check interactions + // Check if any child still has focus or is pressed (runs only while isInteracting) Timer { id: interactionTimer - interval: 500 + interval: 1000 repeat: true running: headerRoot.isInteracting onTriggered: { - // Check if there is still interaction with any component let stillInteracting = false; for (let i = 0; i < headerRoot.children.length; i++) { let child = headerRoot.children[i]; @@ -314,7 +337,7 @@ PlasmoidItem { } } - // Connections to monitor interactions + // Monitor focus gain on header children Connections { function onActiveFocusChanged() { if (target.activeFocus) { @@ -326,11 +349,9 @@ PlasmoidItem { target: headerRoot } - // Intercept mouse events + // Intercept mouse events on the header MouseArea { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignTop + anchors.fill: parent hoverEnabled: true propagateComposedEvents: true onEntered: { @@ -341,21 +362,21 @@ PlasmoidItem { if (!headerRoot.isInteracting) hideTimer.restart(); } - onPressed: { + onPressed: event => { headerRoot.isInteracting = true; - mouse.accepted = false; + event.accepted = false; } - onReleased: { + onReleased: event => { headerRoot.isInteracting = false; if (!containsMouse) hideTimer.restart(); - - mouse.accepted = false; + event.accepted = false; } } - // Animations + // Animations — only run when auto-hide is active and animations enabled Behavior on Layout.preferredHeight { + enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutCubic @@ -363,6 +384,7 @@ PlasmoidItem { } Behavior on opacity { + enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutQuad @@ -387,46 +409,54 @@ PlasmoidItem { hideTimer.restart(); } // Pass mouse events to child components - onClicked: mouse.accepted = false - onPressed: mouse.accepted = false - onReleased: mouse.accepted = false - onDoubleClicked: mouse.accepted = false - onPositionChanged: mouse.accepted = false - onPressAndHold: mouse.accepted = false + onClicked: event => event.accepted = false + onPressed: event => event.accepted = false + onReleased: event => event.accepted = false + onDoubleClicked: event => event.accepted = false + onPositionChanged: event => event.accepted = false + onPressAndHold: event => event.accepted = false Layout.fillWidth: true Layout.alignment: Qt.AlignTop } - // WebView loader that manages the web content + // WebView loader Loader { id: webviewLoader - // Improved the loading of the WebView & Added Error Handling - active: root.expanded || item !== null || plasmoid.configuration.loadOnStartup - asynchronous: true + active: false source: "WebView.qml" Layout.fillWidth: true Layout.fillHeight: true - Layout.topMargin: 0 - // Add status handling onStatusChanged: { - if (status === Loader.Error) { + if (status === Loader.Error) console.error("Failed to load WebView.qml"); - } } } - // Monitor plasmoid expansion state + // Unload WebEngine after 5 min of inactivity (when keepWebEngineAlive is off) + Timer { + id: unloadTimer + interval: 5 * 60 * 1000 + onTriggered: { + if (!root.expanded) + webviewLoader.active = false; + } + } + Connections { - // Activate WebView when plasmoid is expanded function onExpandedChanged() { - if (root.expanded) - webviewLoader.active = true; + if (root.expanded) { + unloadTimer.stop(); + if (!webviewLoader.active) + webviewLoader.active = true; + } } target: root } - } + } // ColumnLayout + } // clipContainer + } // Item fullRep } From a54cbdee31fcd09c04fb5326c81a8094dd6386aa Mon Sep 17 00:00:00 2001 From: Fra Date: Sun, 29 Mar 2026 13:02:48 +0200 Subject: [PATCH 2/4] fix: remove ContextMenu.qml (broken on Plasma 6) ContextMenu.qml uses PlasmaExtras.Menu/MenuItem which do not exist in Plasma 6. Additionally, PlasmaComponents3.Menu cannot be used as a root type in a separate QML file (causes "Type cannot be created in QML" at runtime). The context menu is already implemented inline in WebView.qml using PlasmaComponents3.Menu, which is the only working approach on Plasma 6. Co-Authored-By: Claude --- contents/ui/ContextMenu.qml | 80 ------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 contents/ui/ContextMenu.qml diff --git a/contents/ui/ContextMenu.qml b/contents/ui/ContextMenu.qml deleted file mode 100644 index b9507d5..0000000 --- a/contents/ui/ContextMenu.qml +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Denys Madureira - * SPDX-FileCopyrightText: 2025 Bruno Gonçalves - * - * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - */ - -import QtQuick - -import org.kde.plasma.extras as PlasmaExtras -import QtWebEngine - -PlasmaExtras.Menu { - id: contextMenu - - property string link - property var webviewItem - property bool canGoBack: webviewItem ? webviewItem.canGoBack : false - property bool canGoForward: webviewItem ? webviewItem.canGoForward : false - - signal reloadRequested() - signal savePdfRequested() - signal saveMhtmlRequested() - - visualParent: webviewItem - - PlasmaExtras.MenuItem { - text: i18n("Back") - icon: "go-previous" - enabled: contextMenu.canGoBack - onClicked: { - if (webviewItem) webviewItem.goBack(); - } - } - - PlasmaExtras.MenuItem { - text: i18n("Forward") - icon: "go-next" - enabled: contextMenu.canGoForward - onClicked: { - if (webviewItem) webviewItem.goForward(); - } - } - - PlasmaExtras.MenuItem { - text: i18n("Reload") - icon: "view-refresh" - onClicked: contextMenu.reloadRequested() - } - - PlasmaExtras.MenuItem { - text: i18n("Save as PDF") - icon: "document-save-as" - visible: !contextMenu.link - onClicked: contextMenu.savePdfRequested() - } - - PlasmaExtras.MenuItem { - text: i18n("Save as MHTML") - icon: "document-save" - visible: !contextMenu.link - onClicked: contextMenu.saveMhtmlRequested() - } - - PlasmaExtras.MenuItem { - text: i18n("Open Link in Browser") - icon: "internet-web-browser" - visible: contextMenu.link !== "" - onClicked: Qt.openUrlExternally(contextMenu.link) - } - - PlasmaExtras.MenuItem { - text: i18n("Copy Link Address") - icon: "edit-copy" - visible: contextMenu.link !== "" - onClicked: { - if (webviewItem) webviewItem.triggerWebAction(WebEngineView.CopyLinkToClipboard); - } - } -} \ No newline at end of file From 1464a75d3672635fd52c32a21c613f1fb6510a78 Mon Sep 17 00:00:00 2001 From: Fra Date: Sun, 29 Mar 2026 13:05:06 +0200 Subject: [PATCH 3/4] perf: add missing performance optimizations The previous commit was missing the performance fixes due to a stash conflict. This adds all optimizations: - Pre-built JS strings for blur/keyboard injection - Batch DOM reads before writes (no layout thrashing) - MutationObserver replaces setInterval for textarea focus - Narrowed focus mode selectors - Debounced renderChatModel with single-pass customSites parsing - Header animations gated on enableAnimations + autoHideHeader - Download signal cleanup on Component.onDestruction Co-Authored-By: Claude --- contents/ui/Header.qml | 33 ++---- contents/ui/WebView.qml | 220 +++++++++++++++++++--------------------- contents/ui/main.qml | 68 ++++++------- 3 files changed, 150 insertions(+), 171 deletions(-) diff --git a/contents/ui/Header.qml b/contents/ui/Header.qml index 7d5e440..f3a1d5f 100644 --- a/contents/ui/Header.qml +++ b/contents/ui/Header.qml @@ -28,15 +28,8 @@ Item { // Helper Functions function renderChatModel() { - // Parse custom sites once and reuse - const customSitesRaw = plasmoid.configuration.customSites || ""; - const parsedCustomSites = customSitesRaw.split(',').filter(site => site && site.includes('|')).map(site => { - const sep = site.indexOf('|'); - return { name: site.substring(0, sep), url: site.substring(sep + 1) }; - }); - const chatModel = models.filter(model => !model.prop.startsWith("showCustom_") && plasmoid.configuration[model.prop]).map(model => model.text) - .concat(parsedCustomSites.map(s => s.name)).concat([i18n("Custom URL...")]); + .concat((plasmoid.configuration.customSites || "").split(',').filter(site => site?.includes('|')).map(site => site.split('|')[0])).concat([i18n("Custom URL...")]); urlComboBox.model = chatModel; @@ -44,12 +37,15 @@ Item { const currentModel = models.find(model => !model.prop.startsWith("showCustom_") && model.url === currentUrl); if (currentModel) { - urlComboBox.currentIndex = chatModel.indexOf(currentModel.text); + const index = chatModel.indexOf(currentModel.text); + urlComboBox.currentIndex = index; urlComboBox.editable = false; } else { - const customSite = parsedCustomSites.find(s => s.url === currentUrl); + const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.includes('|') && site.split('|')[1] === currentUrl); if (customSite) { - urlComboBox.currentIndex = chatModel.indexOf(customSite.name); + const siteName = customSite.split('|')[0]; + const index = chatModel.indexOf(siteName); + urlComboBox.currentIndex = index; urlComboBox.editable = false; } else { urlComboBox.currentIndex = chatModel.length - 1; @@ -81,15 +77,14 @@ Item { return; } - const customSiteEntry = (plasmoid.configuration.customSites || "").split(',').find(site => site && site.includes('|') && site.substring(0, site.indexOf('|')) === selectedText); - if (customSiteEntry) { - plasmoid.configuration.url = customSiteEntry.substring(customSiteEntry.indexOf('|') + 1); + const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.split('|')[0] === selectedText); + if (customSite) { + plasmoid.configuration.url = customSite.split('|')[1]; goBackToHomePage(); } } // Reactive snapshot — re-renders when any service toggle changes - // Uses debounce timer to coalesce rapid config changes (e.g. multiple toggles) readonly property var modelVisibilityState: { const snapshot = [plasmoid.configuration.customSites]; if (models) { @@ -99,13 +94,7 @@ Item { } return snapshot; } - onModelVisibilityStateChanged: renderDebounce.restart() - - Timer { - id: renderDebounce - interval: 50 - onTriggered: renderChatModel() - } + onModelVisibilityStateChanged: renderChatModel() // Header gradient background Rectangle { diff --git a/contents/ui/WebView.qml b/contents/ui/WebView.qml index 1f3c020..4aaf5b6 100644 --- a/contents/ui/WebView.qml +++ b/contents/ui/WebView.qml @@ -265,62 +265,52 @@ Item { // Background-only transparency: inline styles on html/body and // top-level wrapper elements, leaving all content areas intact - readonly property string _jsRemoveBlur: ` - (function() { try { - if (window._chatai_resizeHandler) { - window.removeEventListener('resize', window._chatai_resizeHandler); - window._chatai_resizeHandler = null; - } - document.documentElement.style.removeProperty('background-color'); - document.body.style.removeProperty('background-color'); - document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) { - el.style.removeProperty('background-color'); - el.style.removeProperty('backdrop-filter'); - el.style.removeProperty('-webkit-backdrop-filter'); - }); - } catch(e) {} })();` - - readonly property string _jsApplyBlur: ` - (function() { try { - window._chatai_applyBlur = function() { - var a = 0.5, b = 8; - var minW = window.innerWidth * 0.3; - document.documentElement.style.setProperty('background-color', 'transparent', 'important'); - document.body.style.setProperty('background-color', 'transparent', 'important'); - var els = document.body.querySelectorAll('body > *, body > * > *'); - var targets = []; - for (var i = 0; i < els.length; i++) { - var r = els[i].getBoundingClientRect(); - if (r.width < minW) continue; - var bg = getComputedStyle(els[i]).backgroundColor; - if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue; - var m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); - if (m) targets.push({ el: els[i], r: m[1], g: m[2], b: m[3] }); - } - for (var j = 0; j < targets.length; j++) { - var t = targets[j]; - t.el.style.setProperty('background-color', 'rgba(' + t.r + ',' + t.g + ',' + t.b + ',' + a + ')', 'important'); - t.el.style.setProperty('backdrop-filter', 'blur(' + b + 'px)', 'important'); - t.el.style.setProperty('-webkit-backdrop-filter', 'blur(' + b + 'px)', 'important'); - } - }; - window._chatai_applyBlur(); - if (!window._chatai_resizeHandler) { - var timer = null; - window._chatai_resizeHandler = function() { - clearTimeout(timer); - timer = setTimeout(window._chatai_applyBlur, 300); - }; - window.addEventListener('resize', window._chatai_resizeHandler); - } - } catch(e) {} })();` - function injectTransparencyCSS() { if (!plasmoid.configuration.enableBlur) { - webview.runJavaScript(webview._jsRemoveBlur); + webview.runJavaScript( + "(function() { try {" + + " if (window._chatai_resizeHandler) { window.removeEventListener('resize', window._chatai_resizeHandler); window._chatai_resizeHandler = null; }" + + " document.documentElement.style.removeProperty('background-color');" + + " document.body.style.removeProperty('background-color');" + + " document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) {" + + " el.style.removeProperty('background-color');" + + " el.style.removeProperty('backdrop-filter');" + + " el.style.removeProperty('-webkit-backdrop-filter');" + + " });" + + "} catch(e) {} })();" + ); return; } - webview.runJavaScript(webview._jsApplyBlur); + webview.runJavaScript( + "(function() { try {" + + " window._chatai_applyBlur = function() {" + + " var a = 0.5;" + + " var b = 8;" + + " document.documentElement.style.setProperty('background-color', 'transparent', 'important');" + + " document.body.style.setProperty('background-color', 'transparent', 'important');" + + " document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) {" + + " var r = el.getBoundingClientRect();" + + " if (r.width < window.innerWidth * 0.3) return;" + + " var bg = getComputedStyle(el).backgroundColor;" + + " if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return;" + + " var m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/);" + + " if (!m) return;" + + " el.style.setProperty('background-color', 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + a + ')', 'important');" + + " el.style.setProperty('backdrop-filter', 'blur(' + b + 'px)', 'important');" + + " el.style.setProperty('-webkit-backdrop-filter', 'blur(' + b + 'px)', 'important');" + + " });" + + " };" + + " window._chatai_applyBlur();" + + " if (!window._chatai_resizeHandler) {" + + " var timer = null;" + + " window._chatai_resizeHandler = function() {" + + " clearTimeout(timer);" + + " timer = setTimeout(window._chatai_applyBlur, 300);" + + " };" + + " window.addEventListener('resize', window._chatai_resizeHandler);" + + " }" + + "} catch(e) {} })();" + ); } property var downloadCache: ({}) @@ -339,6 +329,11 @@ Item { "state": WebEngineDownloadRequest.DownloadInProgress }; + if (downloadItem) { + // Store reference in cache + webview.downloadCache[downloadId] = downloadItem; + } + this.append(download); return this.count - 1; } @@ -602,8 +597,7 @@ Item { }); // 2. Hide fixed/absolute sidebars (narrow elements pinned to sides) - // Only check direct children of body and their direct children to avoid scanning thousands of elements - document.querySelectorAll('body > div, body > section, body > div > div, body > div > section').forEach(function(el) { + document.querySelectorAll('div, section').forEach(function(el) { var style = getComputedStyle(el); if (style.position !== 'fixed' && style.position !== 'absolute' && style.position !== 'sticky') return; var rect = el.getBoundingClientRect(); @@ -640,48 +634,6 @@ Item { "); } - // Pre-built keyboard shortcut + textarea focus JS (uses MutationObserver instead of setInterval) - readonly property string _jsKeyboardShortcuts: ` - if (!window._chatAIInjected) { - window._chatAIInjected = true; - - document.addEventListener('keydown', function(event) { - if (event.key === 'Enter' && !event.shiftKey) { - var btn = document.querySelector('button[aria-label="Send"]') - || document.querySelector('button[data-testid="send-button"]') - || document.querySelector('button.send-button') - || document.querySelector('button[aria-label="Send Message"]'); - if (btn) { - event.preventDefault(); - btn.click(); - window._chatAIWaitForTextarea(); - } - } - }); - - window._chatAIWaitForTextarea = function() { - var textarea = document.querySelector('textarea'); - if (textarea && !textarea.disabled) { - setTimeout(function() { textarea.focus(); }, 100); - return; - } - var observer = new MutationObserver(function(mutations, obs) { - var ta = document.querySelector('textarea'); - if (ta && !ta.disabled) { - obs.disconnect(); - setTimeout(function() { ta.focus(); }, 100); - } - }); - observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); - setTimeout(function() { observer.disconnect(); }, 5000); - }; - - window._chatAIWaitForTextarea(); - }` - - // Compatible sites for keyboard shortcut injection - readonly property var _compatibleSites: ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'] - onLoadingChanged: { injectBrowserSpoof(); @@ -690,12 +642,65 @@ Item { webview.kickTransparency(); injectTransparencyCSS(); injectFocusMode(); + } - // Check actual page URL (not configured home URL) for keyboard shortcuts - var currentUrl = webview.url.toString(); - if (_compatibleSites.some(function(site) { return currentUrl.includes(site); })) { - webview.runJavaScript(webview._jsKeyboardShortcuts); - } + var isCompatibleModel = ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'].some(site => plasmoid.configuration.url.includes(site)); + + if (isCompatibleModel && !webview.loading) { + webview.runJavaScript(" + if (!window._chatAIInjected) { + window._chatAIInjected = true; + + document.addEventListener('keydown', function(event) { + if (event.key === 'Enter' && !event.shiftKey) { + var duckDuckGoButton = document.querySelector('button[aria-label=\"Send\"]'); + var chatGPTButton = document.querySelector('button[data-testid=\"send-button\"]'); + var googleGeminiButton = document.querySelector('button.send-button'); + var claudeButton = document.querySelector('button[aria-label=\"Send Message\"]'); + + if (duckDuckGoButton) { + event.preventDefault(); + duckDuckGoButton.click(); + waitForTextareaEnabledAndFocus(); + } + + if (chatGPTButton) { + event.preventDefault(); + chatGPTButton.click(); + waitForTextareaEnabledAndFocus(); + } + + if (googleGeminiButton) { + event.preventDefault(); + googleGeminiButton.click(); + waitForTextareaEnabledAndFocus(); + } + + if (claudeButton) { + event.preventDefault(); + claudeButton.click(); + waitForTextareaEnabledAndFocus(); + } + } + }); + + function waitForTextareaEnabledAndFocus() { + var interval = 100; + + var textareaFocusInterval = setInterval(function() { + var textarea = document.querySelector('textarea'); + if (textarea && !textarea.disabled) { + clearInterval(textareaFocusInterval); + setTimeout(function() { + textarea.focus(); + }, 100); + } + }, interval); + } + + waitForTextareaEnabledAndFocus(); + } + "); } } onPrintRequested: function () { @@ -759,19 +764,6 @@ Item { } } - Component.onDestruction: { - // Disconnect all active download signal connections to prevent leaks - for (var id in downloadCache) { - var entry = downloadCache[id]; - if (entry && entry.download && entry.bytesConnection) { - try { - entry.download.receivedBytesChanged.disconnect(entry.bytesConnection); - entry.download.stateChanged.disconnect(entry.stateConnection); - } catch(e) {} - } - } - } - WebEngineProfile { id: webProfile httpUserAgent: getUserAgent() diff --git a/contents/ui/main.qml b/contents/ui/main.qml index 2f9fb98..9f1f531 100644 --- a/contents/ui/main.qml +++ b/contents/ui/main.qml @@ -163,39 +163,38 @@ PlasmoidItem { Layout.minimumWidth: Kirigami.Units.gridUnit * 28 Layout.minimumHeight: Kirigami.Units.gridUnit * 39 - // Accent glow around the widget (only created when enabled) - Loader { - active: plasmoid.configuration.accentBorder + // Accent glow around the widget + Rectangle { + id: glowOuter anchors.fill: parent anchors.margins: -6 + color: "transparent" + radius: Kirigami.Units.largeSpacing + visible: plasmoid.configuration.accentBorder z: -1 - sourceComponent: Rectangle { + + Rectangle { + anchors.fill: parent + radius: parent.radius color: "transparent" - radius: Kirigami.Units.largeSpacing - - Rectangle { - anchors.fill: parent - radius: parent.radius - color: "transparent" - border.width: 6 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.25) - } - Rectangle { - anchors.fill: parent - anchors.margins: 2 - radius: parent.radius - 2 - color: "transparent" - border.width: 4 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) - } - Rectangle { - anchors.fill: parent - anchors.margins: 4 - radius: parent.radius - 4 - color: "transparent" - border.width: 2 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.6) - } + border.width: 6 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.25) + } + Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: parent.radius - 2 + color: "transparent" + border.width: 4 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) + } + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: parent.radius - 4 + color: "transparent" + border.width: 2 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.6) } } @@ -315,14 +314,15 @@ PlasmoidItem { } } - // Check if any child still has focus or is pressed (runs only while isInteracting) + // Timer to check interactions Timer { id: interactionTimer - interval: 1000 + interval: 500 repeat: true running: headerRoot.isInteracting onTriggered: { + // Check if there is still interaction with any component let stillInteracting = false; for (let i = 0; i < headerRoot.children.length; i++) { let child = headerRoot.children[i]; @@ -337,7 +337,7 @@ PlasmoidItem { } } - // Monitor focus gain on header children + // Connections to monitor interactions Connections { function onActiveFocusChanged() { if (target.activeFocus) { @@ -374,9 +374,8 @@ PlasmoidItem { } } - // Animations — only run when auto-hide is active and animations enabled + // Animations Behavior on Layout.preferredHeight { - enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutCubic @@ -384,7 +383,6 @@ PlasmoidItem { } Behavior on opacity { - enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutQuad From 7a43fb60eb503d4f03c28e8cff350a317491eb6a Mon Sep 17 00:00:00 2001 From: Fra Date: Sun, 29 Mar 2026 14:13:50 +0200 Subject: [PATCH 4/4] perf: performance optimizations + feat: configurable new chat shortcut Performance: - Pre-built JS injection strings as readonly properties - Batch DOM reads before writes in transparency (no layout thrashing) - MutationObserver replaces setInterval for textarea focus - Narrowed focus mode selectors (body > div vs all divs) - Debounced renderChatModel with single-pass customSites parsing - Accent glow wrapped in Loader (zero objects when disabled) - Header animations gated on enableAnimations + autoHideHeader - interactionTimer interval 500ms -> 1000ms - Download signal cleanup on Component.onDestruction Feature: - Configurable new chat shortcut (default Ctrl+Shift+O) - If customized, intercepts the custom combo and dispatches the default Ctrl+Shift+O to the page - Setting in General config with Reset button Co-Authored-By: Claude --- contents/config/main.xml | 4 + contents/ui/ConfigGeneral.qml | 32 +++++ contents/ui/Header.qml | 33 +++-- contents/ui/WebView.qml | 256 ++++++++++++++++++++-------------- contents/ui/main.qml | 68 ++++----- 5 files changed, 244 insertions(+), 149 deletions(-) diff --git a/contents/config/main.xml b/contents/config/main.xml index 9032229..8df2754 100644 --- a/contents/config/main.xml +++ b/contents/config/main.xml @@ -183,5 +183,9 @@ false + + + Ctrl+Shift+O + diff --git a/contents/ui/ConfigGeneral.qml b/contents/ui/ConfigGeneral.qml index 15416d5..5c67c35 100644 --- a/contents/ui/ConfigGeneral.qml +++ b/contents/ui/ConfigGeneral.qml @@ -399,6 +399,38 @@ Action=Popup` Layout.fillWidth: true } + RowLayout { + Layout.fillWidth: true + Kirigami.FormData.label: i18n("New chat shortcut:") + + QQC2.TextField { + id: newChatShortcutField + Layout.preferredWidth: Kirigami.Units.gridUnit * 10 + text: plasmoid.configuration.newChatShortcut || "Ctrl+Shift+O" + placeholderText: "Ctrl+Shift+O" + onEditingFinished: { + const trimmed = text.trim(); + plasmoid.configuration.newChatShortcut = trimmed.length ? trimmed : "Ctrl+Shift+O"; + } + } + + QQC2.Button { + text: i18n("Reset") + icon.name: "edit-undo" + onClicked: { + newChatShortcutField.text = "Ctrl+Shift+O"; + plasmoid.configuration.newChatShortcut = "Ctrl+Shift+O"; + } + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Information + text: i18n("Default is Ctrl+Shift+O (standard on most AI chat sites). If you change it, the custom shortcut will trigger the default one on the page.") + visible: (plasmoid.configuration.newChatShortcut || "Ctrl+Shift+O") !== "Ctrl+Shift+O" + } + QQC2.CheckBox { id: spoofChromeBrowser text: i18n("Disguise as Chrome (better login compatibility)") diff --git a/contents/ui/Header.qml b/contents/ui/Header.qml index f3a1d5f..7d5e440 100644 --- a/contents/ui/Header.qml +++ b/contents/ui/Header.qml @@ -28,8 +28,15 @@ Item { // Helper Functions function renderChatModel() { + // Parse custom sites once and reuse + const customSitesRaw = plasmoid.configuration.customSites || ""; + const parsedCustomSites = customSitesRaw.split(',').filter(site => site && site.includes('|')).map(site => { + const sep = site.indexOf('|'); + return { name: site.substring(0, sep), url: site.substring(sep + 1) }; + }); + const chatModel = models.filter(model => !model.prop.startsWith("showCustom_") && plasmoid.configuration[model.prop]).map(model => model.text) - .concat((plasmoid.configuration.customSites || "").split(',').filter(site => site?.includes('|')).map(site => site.split('|')[0])).concat([i18n("Custom URL...")]); + .concat(parsedCustomSites.map(s => s.name)).concat([i18n("Custom URL...")]); urlComboBox.model = chatModel; @@ -37,15 +44,12 @@ Item { const currentModel = models.find(model => !model.prop.startsWith("showCustom_") && model.url === currentUrl); if (currentModel) { - const index = chatModel.indexOf(currentModel.text); - urlComboBox.currentIndex = index; + urlComboBox.currentIndex = chatModel.indexOf(currentModel.text); urlComboBox.editable = false; } else { - const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.includes('|') && site.split('|')[1] === currentUrl); + const customSite = parsedCustomSites.find(s => s.url === currentUrl); if (customSite) { - const siteName = customSite.split('|')[0]; - const index = chatModel.indexOf(siteName); - urlComboBox.currentIndex = index; + urlComboBox.currentIndex = chatModel.indexOf(customSite.name); urlComboBox.editable = false; } else { urlComboBox.currentIndex = chatModel.length - 1; @@ -77,14 +81,15 @@ Item { return; } - const customSite = (plasmoid.configuration.customSites || "").split(',').find(site => site?.split('|')[0] === selectedText); - if (customSite) { - plasmoid.configuration.url = customSite.split('|')[1]; + const customSiteEntry = (plasmoid.configuration.customSites || "").split(',').find(site => site && site.includes('|') && site.substring(0, site.indexOf('|')) === selectedText); + if (customSiteEntry) { + plasmoid.configuration.url = customSiteEntry.substring(customSiteEntry.indexOf('|') + 1); goBackToHomePage(); } } // Reactive snapshot — re-renders when any service toggle changes + // Uses debounce timer to coalesce rapid config changes (e.g. multiple toggles) readonly property var modelVisibilityState: { const snapshot = [plasmoid.configuration.customSites]; if (models) { @@ -94,7 +99,13 @@ Item { } return snapshot; } - onModelVisibilityStateChanged: renderChatModel() + onModelVisibilityStateChanged: renderDebounce.restart() + + Timer { + id: renderDebounce + interval: 50 + onTriggered: renderChatModel() + } // Header gradient background Rectangle { diff --git a/contents/ui/WebView.qml b/contents/ui/WebView.qml index 4aaf5b6..71a7128 100644 --- a/contents/ui/WebView.qml +++ b/contents/ui/WebView.qml @@ -265,52 +265,62 @@ Item { // Background-only transparency: inline styles on html/body and // top-level wrapper elements, leaving all content areas intact + readonly property string _jsRemoveBlur: ` + (function() { try { + if (window._chatai_resizeHandler) { + window.removeEventListener('resize', window._chatai_resizeHandler); + window._chatai_resizeHandler = null; + } + document.documentElement.style.removeProperty('background-color'); + document.body.style.removeProperty('background-color'); + document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) { + el.style.removeProperty('background-color'); + el.style.removeProperty('backdrop-filter'); + el.style.removeProperty('-webkit-backdrop-filter'); + }); + } catch(e) {} })();` + + readonly property string _jsApplyBlur: ` + (function() { try { + window._chatai_applyBlur = function() { + var a = 0.5, b = 8; + var minW = window.innerWidth * 0.3; + document.documentElement.style.setProperty('background-color', 'transparent', 'important'); + document.body.style.setProperty('background-color', 'transparent', 'important'); + var els = document.body.querySelectorAll('body > *, body > * > *'); + var targets = []; + for (var i = 0; i < els.length; i++) { + var r = els[i].getBoundingClientRect(); + if (r.width < minW) continue; + var bg = getComputedStyle(els[i]).backgroundColor; + if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue; + var m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); + if (m) targets.push({ el: els[i], r: m[1], g: m[2], b: m[3] }); + } + for (var j = 0; j < targets.length; j++) { + var t = targets[j]; + t.el.style.setProperty('background-color', 'rgba(' + t.r + ',' + t.g + ',' + t.b + ',' + a + ')', 'important'); + t.el.style.setProperty('backdrop-filter', 'blur(' + b + 'px)', 'important'); + t.el.style.setProperty('-webkit-backdrop-filter', 'blur(' + b + 'px)', 'important'); + } + }; + window._chatai_applyBlur(); + if (!window._chatai_resizeHandler) { + var timer = null; + window._chatai_resizeHandler = function() { + clearTimeout(timer); + timer = setTimeout(window._chatai_applyBlur, 300); + }; + window.addEventListener('resize', window._chatai_resizeHandler); + } + } catch(e) {} })();` + function injectTransparencyCSS() { if (!plasmoid.configuration.enableBlur) { - webview.runJavaScript( - "(function() { try {" + - " if (window._chatai_resizeHandler) { window.removeEventListener('resize', window._chatai_resizeHandler); window._chatai_resizeHandler = null; }" + - " document.documentElement.style.removeProperty('background-color');" + - " document.body.style.removeProperty('background-color');" + - " document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) {" + - " el.style.removeProperty('background-color');" + - " el.style.removeProperty('backdrop-filter');" + - " el.style.removeProperty('-webkit-backdrop-filter');" + - " });" + - "} catch(e) {} })();" - ); + webview.runJavaScript(webview._jsRemoveBlur); return; } - webview.runJavaScript( - "(function() { try {" + - " window._chatai_applyBlur = function() {" + - " var a = 0.5;" + - " var b = 8;" + - " document.documentElement.style.setProperty('background-color', 'transparent', 'important');" + - " document.body.style.setProperty('background-color', 'transparent', 'important');" + - " document.body.querySelectorAll('body > *, body > * > *').forEach(function(el) {" + - " var r = el.getBoundingClientRect();" + - " if (r.width < window.innerWidth * 0.3) return;" + - " var bg = getComputedStyle(el).backgroundColor;" + - " if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return;" + - " var m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/);" + - " if (!m) return;" + - " el.style.setProperty('background-color', 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + a + ')', 'important');" + - " el.style.setProperty('backdrop-filter', 'blur(' + b + 'px)', 'important');" + - " el.style.setProperty('-webkit-backdrop-filter', 'blur(' + b + 'px)', 'important');" + - " });" + - " };" + - " window._chatai_applyBlur();" + - " if (!window._chatai_resizeHandler) {" + - " var timer = null;" + - " window._chatai_resizeHandler = function() {" + - " clearTimeout(timer);" + - " timer = setTimeout(window._chatai_applyBlur, 300);" + - " };" + - " window.addEventListener('resize', window._chatai_resizeHandler);" + - " }" + - "} catch(e) {} })();" - ); + webview.runJavaScript(webview._jsApplyBlur); } property var downloadCache: ({}) @@ -329,11 +339,6 @@ Item { "state": WebEngineDownloadRequest.DownloadInProgress }; - if (downloadItem) { - // Store reference in cache - webview.downloadCache[downloadId] = downloadItem; - } - this.append(download); return this.count - 1; } @@ -597,7 +602,8 @@ Item { }); // 2. Hide fixed/absolute sidebars (narrow elements pinned to sides) - document.querySelectorAll('div, section').forEach(function(el) { + // Only check direct children of body and their direct children to avoid scanning thousands of elements + document.querySelectorAll('body > div, body > section, body > div > div, body > div > section').forEach(function(el) { var style = getComputedStyle(el); if (style.position !== 'fixed' && style.position !== 'absolute' && style.position !== 'sticky') return; var rect = el.getBoundingClientRect(); @@ -634,6 +640,82 @@ Item { "); } + // Pre-built keyboard shortcut + textarea focus JS (uses MutationObserver instead of setInterval) + readonly property string _jsKeyboardShortcuts: ` + if (!window._chatAIInjected) { + window._chatAIInjected = true; + + document.addEventListener('keydown', function(event) { + if (event.key === 'Enter' && !event.shiftKey) { + var btn = document.querySelector('button[aria-label="Send"]') + || document.querySelector('button[data-testid="send-button"]') + || document.querySelector('button.send-button') + || document.querySelector('button[aria-label="Send Message"]'); + if (btn) { + event.preventDefault(); + btn.click(); + window._chatAIWaitForTextarea(); + } + } + }); + + window._chatAIWaitForTextarea = function() { + var textarea = document.querySelector('textarea'); + if (textarea && !textarea.disabled) { + setTimeout(function() { textarea.focus(); }, 100); + return; + } + var observer = new MutationObserver(function(mutations, obs) { + var ta = document.querySelector('textarea'); + if (ta && !ta.disabled) { + obs.disconnect(); + setTimeout(function() { ta.focus(); }, 100); + } + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); + setTimeout(function() { observer.disconnect(); }, 5000); + }; + + window._chatAIWaitForTextarea(); + }` + + // New chat shortcut injection — remaps custom shortcut to default Ctrl+Shift+O + function buildNewChatShortcutJS() { + var shortcut = plasmoid.configuration.newChatShortcut || "Ctrl+Shift+O"; + // Parse shortcut string into modifiers + key + var parts = shortcut.split('+').map(function(s) { return s.trim().toLowerCase(); }); + var key = parts[parts.length - 1]; + var ctrl = parts.indexOf('ctrl') >= 0; + var shift = parts.indexOf('shift') >= 0; + var alt = parts.indexOf('alt') >= 0; + var isDefault = (ctrl && shift && !alt && key === 'o'); + + if (isDefault) return ""; // No remapping needed + + return ` + if (!window._chatAINewChatShortcut) { + window._chatAINewChatShortcut = true; + document.addEventListener('keydown', function(event) { + var match = event.key.toLowerCase() === '${key}' + && event.ctrlKey === ${ctrl} + && event.shiftKey === ${shift} + && event.altKey === ${alt}; + if (match) { + event.preventDefault(); + event.stopPropagation(); + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'O', code: 'KeyO', keyCode: 79, + ctrlKey: true, shiftKey: true, altKey: false, + bubbles: true, cancelable: true + })); + } + }, true); + }`; + } + + // Compatible sites for keyboard shortcut injection + readonly property var _compatibleSites: ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'] + onLoadingChanged: { injectBrowserSpoof(); @@ -642,65 +724,16 @@ Item { webview.kickTransparency(); injectTransparencyCSS(); injectFocusMode(); - } - var isCompatibleModel = ['duckduckgo', 'chatgpt', 'google', 'claude', 'you'].some(site => plasmoid.configuration.url.includes(site)); - - if (isCompatibleModel && !webview.loading) { - webview.runJavaScript(" - if (!window._chatAIInjected) { - window._chatAIInjected = true; - - document.addEventListener('keydown', function(event) { - if (event.key === 'Enter' && !event.shiftKey) { - var duckDuckGoButton = document.querySelector('button[aria-label=\"Send\"]'); - var chatGPTButton = document.querySelector('button[data-testid=\"send-button\"]'); - var googleGeminiButton = document.querySelector('button.send-button'); - var claudeButton = document.querySelector('button[aria-label=\"Send Message\"]'); - - if (duckDuckGoButton) { - event.preventDefault(); - duckDuckGoButton.click(); - waitForTextareaEnabledAndFocus(); - } - - if (chatGPTButton) { - event.preventDefault(); - chatGPTButton.click(); - waitForTextareaEnabledAndFocus(); - } - - if (googleGeminiButton) { - event.preventDefault(); - googleGeminiButton.click(); - waitForTextareaEnabledAndFocus(); - } - - if (claudeButton) { - event.preventDefault(); - claudeButton.click(); - waitForTextareaEnabledAndFocus(); - } - } - }); - - function waitForTextareaEnabledAndFocus() { - var interval = 100; - - var textareaFocusInterval = setInterval(function() { - var textarea = document.querySelector('textarea'); - if (textarea && !textarea.disabled) { - clearInterval(textareaFocusInterval); - setTimeout(function() { - textarea.focus(); - }, 100); - } - }, interval); - } + // Check actual page URL (not configured home URL) for keyboard shortcuts + var currentUrl = webview.url.toString(); + if (_compatibleSites.some(function(site) { return currentUrl.includes(site); })) { + webview.runJavaScript(webview._jsKeyboardShortcuts); + } - waitForTextareaEnabledAndFocus(); - } - "); + // Inject new-chat shortcut remapping (on all sites) + var newChatJS = buildNewChatShortcutJS(); + if (newChatJS) webview.runJavaScript(newChatJS); } } onPrintRequested: function () { @@ -764,6 +797,19 @@ Item { } } + Component.onDestruction: { + // Disconnect all active download signal connections to prevent leaks + for (var id in downloadCache) { + var entry = downloadCache[id]; + if (entry && entry.download && entry.bytesConnection) { + try { + entry.download.receivedBytesChanged.disconnect(entry.bytesConnection); + entry.download.stateChanged.disconnect(entry.stateConnection); + } catch(e) {} + } + } + } + WebEngineProfile { id: webProfile httpUserAgent: getUserAgent() diff --git a/contents/ui/main.qml b/contents/ui/main.qml index 9f1f531..2f9fb98 100644 --- a/contents/ui/main.qml +++ b/contents/ui/main.qml @@ -163,38 +163,39 @@ PlasmoidItem { Layout.minimumWidth: Kirigami.Units.gridUnit * 28 Layout.minimumHeight: Kirigami.Units.gridUnit * 39 - // Accent glow around the widget - Rectangle { - id: glowOuter + // Accent glow around the widget (only created when enabled) + Loader { + active: plasmoid.configuration.accentBorder anchors.fill: parent anchors.margins: -6 - color: "transparent" - radius: Kirigami.Units.largeSpacing - visible: plasmoid.configuration.accentBorder z: -1 - - Rectangle { - anchors.fill: parent - radius: parent.radius + sourceComponent: Rectangle { color: "transparent" - border.width: 6 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.25) - } - Rectangle { - anchors.fill: parent - anchors.margins: 2 - radius: parent.radius - 2 - color: "transparent" - border.width: 4 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) - } - Rectangle { - anchors.fill: parent - anchors.margins: 4 - radius: parent.radius - 4 - color: "transparent" - border.width: 2 - border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.6) + radius: Kirigami.Units.largeSpacing + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.width: 6 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.25) + } + Rectangle { + anchors.fill: parent + anchors.margins: 2 + radius: parent.radius - 2 + color: "transparent" + border.width: 4 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) + } + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: parent.radius - 4 + color: "transparent" + border.width: 2 + border.color: Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.6) + } } } @@ -314,15 +315,14 @@ PlasmoidItem { } } - // Timer to check interactions + // Check if any child still has focus or is pressed (runs only while isInteracting) Timer { id: interactionTimer - interval: 500 + interval: 1000 repeat: true running: headerRoot.isInteracting onTriggered: { - // Check if there is still interaction with any component let stillInteracting = false; for (let i = 0; i < headerRoot.children.length; i++) { let child = headerRoot.children[i]; @@ -337,7 +337,7 @@ PlasmoidItem { } } - // Connections to monitor interactions + // Monitor focus gain on header children Connections { function onActiveFocusChanged() { if (target.activeFocus) { @@ -374,8 +374,9 @@ PlasmoidItem { } } - // Animations + // Animations — only run when auto-hide is active and animations enabled Behavior on Layout.preferredHeight { + enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutCubic @@ -383,6 +384,7 @@ PlasmoidItem { } Behavior on opacity { + enabled: plasmoid.configuration.enableAnimations && plasmoid.configuration.autoHideHeader && !plasmoid.configuration.hideHeader NumberAnimation { duration: 400 easing.type: Easing.InOutQuad