diff --git a/ChatAI-Plasmoid.plasmoid b/ChatAI-Plasmoid.plasmoid index 49f2d86..964e7c5 100644 Binary files a/ChatAI-Plasmoid.plasmoid and b/ChatAI-Plasmoid.plasmoid differ diff --git a/README.md b/README.md index a53f47c..f10546d 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,10 @@ ChatAI-Plasmoid/ │ └── ui/ # QML: widget, WebView, provider registry, settings │ ├── settings/ # Shared settings pages (dialog + in-widget panel) │ └── assets/ # Provider and logo SVG icons +├── docs/ # Technical notes (not shipped in the package) ├── locale/ # Translation sources (.pot / .po / .json) +├── pkgbuild/ # Arch Linux / BigLinux package +├── tests/ # Live regression tests for a running Plasma session ├── metadata.json # Plasma package metadata ├── LICENSE └── README.md @@ -461,6 +464,9 @@ for po in locale/*.po; do msgfmt --check --check-format -o /dev/null "$po"; done # Run the widget in a standalone window plasmawindowed ChatAI-Plasmoid + +# Popup size regression test (opens the popup on screen; see docs/TESTING.md) +tests/popup-size-regression.sh 20 --fresh ``` Compiled translations (`contents/locale/**/*.mo`) ship in the package; regenerate diff --git a/contents/config/main.xml b/contents/config/main.xml index b5b0045..7ef018e 100644 --- a/contents/config/main.xml +++ b/contents/config/main.xml @@ -81,6 +81,8 @@ false false + 0 0 diff --git a/contents/ui/main.qml b/contents/ui/main.qml index da7d335..73f477e 100644 --- a/contents/ui/main.qml +++ b/contents/ui/main.qml @@ -96,32 +96,20 @@ PlasmoidItem { readonly property int defaultWidth: Math.max(Kirigami.Units.gridUnit * 28, minimumViewportWidth + Kirigami.Units.gridUnit * 2) readonly property int defaultHeight: Kirigami.Units.gridUnit * 39 + // Size of a popup the user never resized. Plasma's AppletPopup + // remembers the size the user picks (popupWidth/popupHeight in the + // applet config, saved on hide, restored on startup and kept within + // 95% of the screen), so these must not follow the measured size: the + // popup adds a 1px panel separator to them, and writing the measured + // size back (the old dialogWidth/dialogHeight) grew the popup by 1px + // every 500ms while it was open. Layout.minimumWidth: Kirigami.Units.gridUnit * 18 Layout.minimumHeight: Kirigami.Units.gridUnit * 18 - Layout.preferredWidth: plasmoid.configuration.dialogWidth > 0 ? plasmoid.configuration.dialogWidth : defaultWidth - Layout.preferredHeight: plasmoid.configuration.dialogHeight > 0 ? plasmoid.configuration.dialogHeight : defaultHeight + Layout.preferredWidth: defaultWidth + Layout.preferredHeight: defaultHeight spacing: 0 - // Debounced persistence of the popup size. - Timer { - id: saveSizeTimer - interval: 500 - repeat: false - onTriggered: { - const currentWidth = Math.round(mainLayout.width); - const currentHeight = Math.round(mainLayout.height); - if (currentWidth > 0 && currentHeight > 0 - && (currentWidth !== plasmoid.configuration.dialogWidth || currentHeight !== plasmoid.configuration.dialogHeight)) { - plasmoid.configuration.dialogWidth = currentWidth; - plasmoid.configuration.dialogHeight = currentHeight; - } - } - } - - onWidthChanged: saveSizeTimer.restart() - onHeightChanged: saveSizeTimer.restart() - Header { id: headerRoot diff --git a/docs/POPUP_SIZE_BUGFIX.md b/docs/POPUP_SIZE_BUGFIX.md new file mode 100644 index 0000000..b244567 --- /dev/null +++ b/docs/POPUP_SIZE_BUGFIX.md @@ -0,0 +1,115 @@ +# Popup grows taller on its own + +## Symptom + +Opening ChatAI from its panel icon, the popup got a little taller every time, +without the user resizing it, until it reached the 95% screen limit Plasma +enforces. On a horizontal panel only the height grew; on a vertical panel the +width grew instead. Both Wayland and X11 were affected. + +## Reproducing (before the fix) + +The growth happens in a Plasma session that started without a saved popup size +for the widget: a newly added widget, the first session after installing, or +after `popupWidth`/`popupHeight` were removed from its configuration. + +1. Add ChatAI to a horizontal panel (or remove `popupWidth`/`popupHeight` from + `[Containments][N][Applets][M][Configuration]` in + `~/.config/plasma-org.kde.plasma.desktop-appletsrc` and restart plasmashell). +2. Open the popup and leave it open: it grows 1 px every 500 ms. +3. Close and reopen it: the extra height is kept and keeps growing. + +`tests/popup-size-regression.sh --fresh` automates this (see +[TESTING.md](TESTING.md)). + +## Root cause + +A feedback loop in `contents/ui/main.qml` between the size ChatAI asked for and +the size it measured: + +```text +Layout.preferredHeight <- plasmoid.configuration.dialogHeight + | + v Plasma sizes the popup from it: preferredHeight + 1 + frame padding +mainLayout.height = preferredHeight + 1 + | + v onHeightChanged -> saveSizeTimer (500 ms) +plasmoid.configuration.dialogHeight = mainLayout.height -> back to the top, +1 px +``` + +The extra pixel comes from Plasma's popup wrapper for panel applets +(`/usr/share/plasma/shells/org.kde.plasma.desktop/contents/applet/CompactApplet.qml`). +It draws a 1 px separator line against the panel and adds it to the popup's size +hint (`appletParent.extraHeight: 1` for top/bottom panels, `extraWidth: 1` for +left/right panels). It only subtracts that pixel from the applet +(`fullRepresentation.anchors.bottomMargin`) if the separator is already visible +when the representation is attached, which was not the case here. So the applet +gets one pixel more than it asked for. + +Logged in Plasma 6.7.4 (frame padding 15 px vertically): + +```text +pref=1000x700 win=1016x716 content=1000x701 +SAVED content=702 pref=701 win=717 +SAVED content=703 pref=702 win=718 ... every 500 ms while open +``` + +Any difference between the requested and the received size would have caused +the same drift; the loop turned a constant 1 px offset into unbounded growth. +Whether that pixel shows up depends on how the separator is rounded at the +current scale: at 150% (`QT_SCALE_FACTOR=1.5`) the old code did not grow, at +100% and on a vertical panel it did. + +### Why it did not happen in every session + +Plasma 6's `AppletPopup` saves the popup size itself when the popup hides +(`popupWidth`/`popupHeight`, without the frame padding) and restores it when +plasmashell starts. A restored size makes Plasma ignore `Layout.preferredHeight` +for the rest of that session, so the loop could not resize the popup. In a +session without a saved size the loop ran freely, and Plasma then saved the +inflated size, which is why it stayed large afterwards. + +## Fix + +`main.qml` no longer persists the size. `Layout.preferredWidth` and +`Layout.preferredHeight` are constants (`defaultWidth`, `defaultHeight`): the +size of a popup the user never resized. The `saveSizeTimer` and the +`onWidthChanged`/`onHeightChanged` handlers are gone. + +Remembering a manual resize is left to Plasma, which already does it correctly: +it saves the size on hide, restores it on startup, keeps it within 95% of the +screen and is unaffected by theme margins. The popup is resized by Plasma's own +resize handles, so a user resize is the only thing that changes that saved size. + +`dialogWidth`/`dialogHeight` stay in `contents/config/main.xml` because +`Migration.js` uses `dialogWidth` to recognise older installations; they are +no longer written or read for sizing. + +## Before and after + +Same machine and session state (no saved popup size), 30 openings of 1.2 s each: + +| Opening | 1 | 10 | 20 | 30 | +|---|---|---|---|---| +| Before | 701 | 725 | 755 | 788 (+88 px) | +| After | 703 | 703 | 703 | 703 | + +A single 28 s opening grew the popup from 700 to 754 px before the fix. After +it, 100 consecutive openings all saved 816×703. On X11 with a left panel the +old code grew the width from 1175 to 1195 px in 5 openings; the fix kept +817×702. + +## Known limitations + +- A popup that already grew keeps its saved size: the fix stops further growth + but cannot tell an inflated size from one the user chose. Resizing it once + fixes it for good. +- The applet still receives `preferredHeight + 1` on a horizontal panel (the + separator compensation described above, in Plasma's own `CompactApplet.qml`). + It is now a constant pixel, not a cumulative one. +- In a session without a saved popup size, a change of the default size (for + example a different font size, which changes `Kirigami.Units.gridUnit`) makes + Plasma resize the popup to the new default. This is Plasma's behaviour for + every applet; once a size is saved it no longer applies. +- Validated on Wayland and X11, with bottom and left panels and at 100–200% + scale, on a single monitor; see [TESTING.md](TESTING.md). diff --git a/docs/TECHNICAL_REVIEW.md b/docs/TECHNICAL_REVIEW.md new file mode 100644 index 0000000..a8ccd09 --- /dev/null +++ b/docs/TECHNICAL_REVIEW.md @@ -0,0 +1,72 @@ +# Technical review: popup size fix + +## Changes + +| File | Change | +|---|---| +| `contents/ui/main.qml` | Constant `Layout.preferredWidth`/`Layout.preferredHeight`; removed `saveSizeTimer` and the size change handlers | +| `contents/config/main.xml` | Comment marking `dialogWidth`/`dialogHeight` as legacy | +| `ChatAI-Plasmoid.plasmoid` | Rebuilt: the committed package still contained the old `main.qml` | +| `tests/popup-size-regression.sh` | New live regression test | +| `docs/` | This review, [POPUP_SIZE_BUGFIX.md](POPUP_SIZE_BUGFIX.md) and [TESTING.md](TESTING.md) | +| `README.md` | `docs/` and `tests/` in the Development section | + +## Decisions + +**Let Plasma own the popup size.** Plasma 6's `AppletPopup` already persists the +popup size, saving it on hide and restoring it at startup, without frame margins +and limited to 95% of the screen. ChatAI kept a second copy of the same +information, and keeping both in sync is what created the loop. Removing ChatAI's +copy fixes the cause instead of compensating for it, removes code, and makes +ChatAI behave like the standard Plasma applets. + +Alternatives considered and rejected: + +- *Subtract the separator pixel before saving.* It depends on an internal detail + of Plasma's `CompactApplet.qml` (1 px, only on panel edges, only when not + compensated) and would break silently when that changes. +- *Save only when the popup hides.* The measured size still includes the extra + pixel, so the growth would only slow down to 1 px per opening. +- *Ignore size changes right after opening.* It needs a timing guess, and the + measured size is wrong for as long as the popup is open, not only at opening. +- *Read the saved size once, without a live binding.* It stops the loop within a + session but still saves a size 1 px off, which then becomes the next + session's starting size. + +**Keep the legacy keys.** `dialogWidth`/`dialogHeight` stay in the KConfig schema +because `Migration.js` treats `dialogWidth > 0` as a sign of an older +installation. Existing values are harmless: nothing reads them for sizing any +more. Users keep the size Plasma saved for them. + +**Defaults unchanged.** `defaultWidth` (at least 780 CSS px of viewport, so the +AI sites keep their desktop layout) and `defaultHeight` (39 grid units) are the +same values as before. + +## Performance + +- One `Timer` and two signal handlers fewer in `main.qml`. +- In the affected state, the old loop resized the popup window, and with it the + Qt WebEngine view, every 500 ms for as long as the popup was open, writing the + configuration file each time. That no longer happens. +- A CPU comparison over 20 s with the popup idle was dominated by the web page's + own activity and was inconclusive, so no figure is claimed. + +## Related audit findings + +- **Header animation.** `Behavior on Layout.preferredHeight` of the header only + redistributes space inside the layout. With an explicit preferred size on the + representation it cannot change the window, as the header tests in + [TESTING.md](TESTING.md) confirmed. It was kept. +- **Content cannot resize the window.** Plasma reads the representation's + `Layout.preferredHeight`, which is explicit, so the WebView, the settings panel + and the auto-hide reveal strip do not affect the popup size. +- **Committed package was stale.** `ChatAI-Plasmoid.plasmoid` had not been + rebuilt since the size persistence was last changed, so installing from it + would have kept the bug. It was rebuilt with the command from the README. +- **Not ChatAI's code.** A malformed `[Containments2Appletsts]` group with + `dialogWidth`/`dialogHeight` was found in the test machine's + `plasma-org.kde.plasma.desktop-appletsrc`. Nothing in this repository or in + biglinux-welcome writes it; it comes from another tool writing to a wrong + group path and is harmless to ChatAI. + +No visual or interaction changes were made beyond the fix. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b379552 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,108 @@ +# Testing the popup size + +How the fix in [POPUP_SIZE_BUGFIX.md](POPUP_SIZE_BUGFIX.md) was validated, and +how to check it again. + +## Automated regression test + +`tests/popup-size-regression.sh` opens and closes the ChatAI popup in the running +Plasma session and fails if the size Plasma saves on close ever changes. + +```bash +tests/popup-size-regression.sh # 20 openings, current session +tests/popup-size-regression.sh 100 # 100 openings +tests/popup-size-regression.sh 20 --fresh # first restart plasmashell without a saved size +``` + +- Needs a Plasma 6 session (Wayland or X11) with ChatAI on a panel. The popup + opens on screen while it runs. +- It toggles the popup through a temporary global shortcut (set with the + plasmashell scripting API, triggered over D-Bus through kglobalaccel) and + reads `popupWidth`/`popupHeight` after each close. +- `--fresh` reproduces the state where the bug happened. It restarts + plasmashell twice (the panel disappears for a few seconds) and restores the + saved size, ChatAI's legacy `dialogWidth`/`dialogHeight` and the shortcut at + the end. +- With the code before the fix it reports `FAIL`; with the fix, `PASS`. The + widget's configuration was byte-identical before and after each run. + +The test needs a real compositor and plasmashell, so it is not part of CI. + +## Static checks + +| Check | Result | +|---|---| +| `qmllint` (Qt 6.11) on every QML file | No new warnings: `main.qml` has the same 35 warnings as before (unqualified `plasmoid`/`i18n` access found throughout the project) | +| `xmllint --noout contents/config/main.xml` | OK | +| Package built from `metadata.json contents LICENSE` | Zip integrity OK, same file list as before, no dev files | +| `kpackagetool6 -i` of that package into a temporary root | Installed | + +## Live tests + +Common setup: Plasma 6.7.4, KDE Frameworks 6.29, Qt 6.11.2, one 1920×1080 +screen, a 40 px panel, ChatAI as a panel widget. The code under test was +installed as a temporary copy in `~/.local/share/plasma/plasmoids` (removed +afterwards); "before" means the same tests against the code before the fix. + +### Wayland, bottom panel, 100% scale + +Window and content sizes were logged from QML through an instrumented copy of +the widget. + +| Test | Result | +|---|---| +| 100 openings, no saved size | 100 × 816×703 | +| 30 openings, same timing, before | 701 → 788 px (+88) | +| 15 s opening | No size change while open (before: one 28 s opening grew 700 → 754 px) | +| 3 plasmashell restarts × 5 openings, saved 1000×700 | 1000×700 throughout | +| Resize the open popup by +60×+90 through the compositor (KWin script) | Saved as 1060×790, kept for 5 reopenings and after a restart; same in a session without a saved size (876×793) | +| Header auto-hide on/off, header hidden on/off, popup open | Window 892×808 in all 864 logged samples; the header animation (31 → 0 px) stayed inside the layout | +| Providers ChatGPT, Claude, Gemini, DeepSeek; pin on/off; popup open | Same 892×808 | +| CPU of plasmashell + WebEngine, 20 s open and idle | Inconclusive: 15.8–28.5% across runs, dominated by the page's own activity | + +### X11 + +Real mouse and keyboard input was generated with XTest (python-xlib), and the +popup window was measured from the X server (`_KDE_NET_WM_WINDOW_TYPE_APPLET_POPUP` +window of plasmashell). + +| Test | Before | After | +|---|---|---| +| Regression test, bottom panel, 100% | 457 → 481 px in 6 openings (`FAIL`) | 20 × 816×703 (`PASS`) | +| Regression test, left (vertical) panel | Width 1175 → 1195 px in 5 openings (`FAIL`) | 10 × 817×702 (`PASS`) | +| Regression test at `QT_SCALE_FACTOR` 1.25 / 1.5 / 2 | 1.5: stable 1073×454 | 816×703 / 816×669 / 816×498, all `PASS` | + +At 150% and 200% the height is Plasma's 95% screen limit in logical pixels +(1080 / 1.5 × 0.95 and 1080 / 2 × 0.95, minus the frame), so oversized popups +are clamped as expected. Before the fix the growth depended on how the +separator pixel was rounded: at 150% it did not happen. + +With the fix, on the bottom panel at 100%: + +| Test | Result | +|---|---| +| Window vs saved size | Window 1090×469, saved 1074×454: the saved size excludes the frame padding (16×15 px) | +| Drag the top edge 150 px up, then the left edge 100 px out, with the mouse | Window 1090×469 → 1090×619 → 1190×619, saved as 1174×604, kept for 3 reopenings and after a plasmashell restart | +| Auto-hide header: pointer on the page 4.5 s, on the top strip, away again | Header hidden → shown → hidden (screenshots); window 1190×619 throughout | +| Ctrl+F, then Escape | Find bar opens and closes; window unchanged | +| F11, then Escape | Full screen window covers the screen and closes; popup unchanged | +| Menu → About ChatAI, then Escape | In-widget settings open (About) and close; window unchanged | +| Close button, then reopen | Popup closes, a WebEngine process ends (5 → 4), reopening recreates it (5) and loads the page; size unchanged | + +## Not verified + +- Multiple monitors: only one screen was available. Creating virtual monitors + with `xrandr --setmonitor` would have made Plasma add desktops and panels to + the tester's session, so it was not done. +- Scale factors set in System Settings (they need a new login); the scale tests + used `QT_SCALE_FACTOR` on plasmashell, which scales it the same way. +- A widget inside the System Tray: ChatAI does not declare + `X-Plasma-NotificationArea`, so it cannot be placed there. + +## Observations (unchanged behaviour) + +- Escape does not close the popup while the web page has focus: Qt WebEngine + hands it to the page, which sites use for their own dialogs. The shortcut + list in About documents Escape only for the find bar, settings and full + screen. +- Tab moves focus inside the web page, not through the toolbar. diff --git a/tests/popup-size-regression.sh b/tests/popup-size-regression.sh new file mode 100755 index 0000000..1adc8e9 --- /dev/null +++ b/tests/popup-size-regression.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Regression test for the popup size: opens and closes the ChatAI popup in the +# running Plasma session and checks that the size Plasma saves on every close +# never changes. See docs/POPUP_SIZE_BUGFIX.md. +# +# tests/popup-size-regression.sh [cycles] [--fresh] +# +# cycles open/close cycles (default 20) +# --fresh restart plasmashell without Plasma's saved popup size first, the +# state of a newly added widget (where the old code grew the popup); +# the saved size is put back and plasmashell restarted at the end +# +# Needs a Plasma 6 session with ChatAI on a panel. The popup really opens on +# screen while the test runs. A temporary global shortcut is used to toggle it +# and is removed at the end, even on failure. + +set -euo pipefail + +cycles=20 +fresh=false +for arg in "$@"; do + case $arg in + --fresh) fresh=true ;; + ''|*[!0-9]*) echo "usage: $0 [cycles] [--fresh]" >&2; exit 2 ;; + *) cycles=$arg ;; + esac +done + +appletsrc=plasma-org.kde.plasma.desktop-appletsrc +shortcut="Ctrl+Alt+Meta+F12" + +evaluate() { + qdbus6 org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.evaluateScript "$1" +} + +# "containment applet" of the first ChatAI widget on a panel +location=$(evaluate 'var r = ""; panels().forEach(function (p) { p.widgets().forEach(function (w) { + if (!r && w.type === "ChatAI-Plasmoid") r = p.id + " " + w.id; }); }); print(r);') +if [ -z "$location" ]; then + echo "No ChatAI widget found on a panel" >&2 + exit 2 +fi +read -r containment applet <<< "$location" +action="activate widget $applet" + +# Sets "entry" to the kconfig arguments of a widget setting: "popupWidth" (the +# applet's Configuration group, where Plasma keeps the popup size), +# "General/dialogWidth" (ChatAI's own settings) or "Shortcuts/global" +widget_entry() { + entry=(--file "$appletsrc" --group Containments --group "$containment" --group Applets --group "$applet") + case $1 in + Shortcuts/*) entry+=(--group Shortcuts) ;; + */*) entry+=(--group Configuration --group "${1%%/*}") ;; + *) entry+=(--group Configuration) ;; + esac + entry+=(--key "${1##*/}") +} + +config() { + widget_entry "$1" + kreadconfig6 "${entry[@]}" +} + +write_config() { + widget_entry "$1" + kwriteconfig6 "${entry[@]}" "${@:2}" +} + +set_shortcut() { + evaluate "panels().forEach(function (p) { p.widgets().forEach(function (w) { + if (w.id === $applet) w.globalShortcut = '$1'; }); });" > /dev/null +} + +toggle() { + qdbus6 org.kde.kglobalaccel /component/plasmashell org.kde.kglobalaccel.Component.invokeShortcut "$action" > /dev/null +} + +restart_plasmashell() { + # optional KEY=VALUE pairs to write while plasmashell is stopped ("" deletes) + systemctl --user stop plasma-plasmashell + local pair + for pair in "$@"; do + if [ -n "${pair#*=}" ]; then + write_config "${pair%%=*}" "${pair#*=}" + else + write_config "${pair%%=*}" --delete + fi + done + systemctl --user start plasma-plasmashell + local i + for i in $(seq 1 30); do + evaluate 'print(1)' > /dev/null 2>&1 && break + sleep 1 + done + sleep 3 +} + +saved_width=$(config popupWidth) +saved_height=$(config popupHeight) +# Versions before the fix also write their own size here while the test runs +saved_dialog_width=$(config General/dialogWidth) +saved_dialog_height=$(config General/dialogHeight) +saved_shortcut=$(config Shortcuts/global) + +cleanup() { + set_shortcut "$saved_shortcut" || true + if $fresh; then + # plasmashell writes an empty "global=" back while running + restart_plasmashell "popupWidth=$saved_width" "popupHeight=$saved_height" \ + "General/dialogWidth=$saved_dialog_width" "General/dialogHeight=$saved_dialog_height" \ + "Shortcuts/global=$saved_shortcut" || true + fi + if [ -z "$saved_shortcut" ]; then + # kglobalaccel keeps the action in memory; editing its file is not enough + qdbus6 org.kde.kglobalaccel /kglobalaccel org.kde.KGlobalAccel.unregister plasmashell "$action" > /dev/null || true + fi +} +trap cleanup EXIT + +if $fresh; then + restart_plasmashell popupWidth= popupHeight= +fi + +set_shortcut "$shortcut" +for i in $(seq 1 30); do + qdbus6 org.kde.kglobalaccel /component/plasmashell org.kde.kglobalaccel.Component.shortcutNames \ + | grep -q "$action" && break + sleep 0.5 +done + +declare -A seen=() +first="" +for i in $(seq 1 "$cycles"); do + toggle; sleep 1.5 + toggle; sleep 1 + size="$(config popupWidth)x$(config popupHeight)" + printf '%3d %s\n' "$i" "$size" + first=${first:-$size} + seen[$size]=1 +done + +if [ "${#seen[@]}" -ne 1 ]; then + echo "FAIL: the popup size changed across $cycles openings (first $first, last $size)" >&2 + exit 1 +fi +echo "PASS: $cycles openings, size $first every time"