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
}