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..8df2754 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,82 @@ - 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 + + + Ctrl+Shift+O 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..5c67c35 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,53 @@ 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)") + 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/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 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..71a7128 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,258 @@ 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)); + var url = webview.url.toString(); + var css = ""; - 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(); - } + 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 (chatGPTButton) { - event.preventDefault(); - chatGPTButton.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'; - if (googleGeminiButton) { - event.preventDefault(); - googleGeminiButton.click(); - waitForTextareaEnabledAndFocus(); - } + 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); } - } - }); - - 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); + }); + + // 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'; } - }, interval); + }); + 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; } - waitForTextareaEnabledAndFocus(); - "); - } + document.head.appendChild(s); + })(); + "); } - 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); + + // 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(); + + 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); + } + + // Inject new-chat shortcut remapping (on all sites) + var newChatJS = buildNewChatShortcutJS(); + if (newChatJS) webview.runJavaScript(newChatJS); + } + } onPrintRequested: function () { webview.triggerWebAction(WebEngineView.Print); } @@ -445,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() @@ -453,12 +818,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 +829,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 +918,7 @@ Item { } } + MouseArea { id: mouseArea @@ -571,47 +932,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 +965,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 }