From 9f3a5380d3bc2e6995840a5f1049cdab9f0e8174 Mon Sep 17 00:00:00 2001 From: Brendon Lasley Date: Mon, 7 Sep 2026 20:50:34 -0400 Subject: [PATCH 1/3] Design: search focus and search-during-selection fixes Root-causes the two search bugs against the DMS wrapper's layer-shell keyboard-focus handling and the header/selection row swap, and specifies selection surviving a filter change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS --- docs/TODO.md | 92 +++++++++++ docs/plans/2026-09-07-search-fixes-design.md | 164 +++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 docs/TODO.md create mode 100644 docs/plans/2026-09-07-search-fixes-design.md diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..9942229 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,92 @@ +# dms-rss-widget backlog + +Post-2.3.3. Nothing here is committed to a release yet. + +## Bugs + +### B1 — Search field doesn't accept typing until you click it a second time +**Symptom:** click the search icon, the field appears, typing does nothing. Click +the field itself, then typing works. + +**Root cause (verified in DMS source, not a guess):** +`/usr/share/quickshell/dms/Modules/Plugins/DesktopPluginWrapper.qml:341` maps our +`acceptsKeyboardFocus` onto `WlrKeyboardFocus.OnDemand` (`None` when false). +Layer-shell `on_demand` means *the compositor* grants keyboard focus to the +surface when a click lands on it. Our `acceptsKeyboardFocus` is bound to +`searchActive` (DankRssWidget.qml:103), which is still **false** at the moment +the toggle is clicked — so that click lands on a surface with +`keyboard_interactivity: none` and grants nothing. The property flips true +afterwards, but no *new* click has landed, so the seat still has no focus on us. +`searchField.forceActiveFocus()` (line 1305) sets Qt's internal focus item on a +surface that has no Wayland keyboard focus at all — hence the silent no-op. + +**Fix direction:** make the surface focus-eligible *before* the click, e.g. +`acceptsKeyboardFocus: root.searchActive || widgetHoverArea.containsMouse`. +Keep the D8 intent (line 100-102): must still be `false` when the pointer is +away, so compositor keybinds aren't swallowed. Re-assert `forceActiveFocus()` +from a `Qt.callLater`/one-shot Timer as a belt-and-braces retry. + +### B2 — Search button disappears while items are checked +**Symptom:** with checkboxes ticked there's no way to open search; but if search +was already open it survives entering selection mode. + +**Root cause:** the filter/search header row is +`visible: root.allItems.length > 0 && root.selectedCount === 0` +(DankRssWidget.qml:1067) and the selection bar *replaces* it +(`visible: root.selectedCount > 0`, line 1187). The search **field** is a +separate row keyed only on `searchActive` (line 1286), which is exactly why it +survives — the asymmetry the user hit. + +**Fix direction:** either keep a search toggle in the selection bar, or stop +hiding the whole header and only swap the chip cluster. Decide whether +"search within selection" is even meaningful first — S10 (line 959) prunes +selection against the visible set, so searching while selected currently +*shrinks* the selection. That interaction needs a decision, not just a button. + +## Features + +### F1 — Keyboard navigation (feasible, with one hard limit) +Confirmed possible: the wrapper already forwards `acceptsKeyboardFocus`, so we +can hold `OnDemand` focus and attach `Keys.onPressed` handlers. +**Limit:** `OnDemand` means focus arrives only after a click on the widget. +There is no way to be keyboard-driven from cold without `Exclusive`, which would +steal every key from the compositor. So the model is: click once, then drive. + +Proposed bindings (Miniflux/Vim conventions): +`j`/`k` next/prev · `o`/`Enter` open · `m` toggle read · `s`/`f` star · +`/` focus search · `Esc` close search / clear selection · `Space` toggle +checkbox · `r` refresh · `g g`/`G` top/bottom. +Requires: a `currentIndex` on the ListView, a visible focus ring distinct from +hover, and `positionViewAtIndex` so the cursor stays on screen. + +### F2 — Google Reader API support (highest leverage backend work) +One protocol unlocks FreshRSS, Tiny Tiny RSS (via plugin), Inoreader, +TheOldReader, BazQux — and Miniflux, which speaks it too. Compare with adding +each backend one at a time. + +Prerequisite refactor: `sourceMode` is currently a two-valued string branched on +in ~10 places in DankRssWidget.qml (lines 344, 382, 407, 421, 470, 510, ...). +Adding a third mode by extending those branches will not scale. Extract a +backend interface — `fetch()`, `markRead(ids)`, `toggleStar(id)`, +`reconcile(items)` — with `standard` / `miniflux` as its first two +implementations, *then* add Google Reader as a third. + +### F3 — Fever API (cheap second protocol) +Simpler than Google Reader, covers FreshRSS + TT-RSS. **Read-only in Miniflux** +(can't subscribe through it), so it's a complement, never a replacement for F2. + +### F4 — Feature ideas from other readers +- **Full-text fetch** (Miniflux, FreshRSS): request the article body when a feed + only ships a summary. In Miniflux mode this is a single API call we already + have the token for. +- **Saved searches / filter rules** (NewsBlur, Inoreader): keyword rules that + auto-mark-read or auto-star. Our search already indexes title/text/source. +- **Feed grouping / categories** (all of them): Miniflux already returns + categories; we currently flatten them. +- **Hide-read-on-scroll** (Reeder, NetNewsWire): auto-mark items read as they + scroll past. +- **Send-to** integrations (Wallabag, Pocket-likes): one action to push an entry + elsewhere; would fit the existing selection bar. +- **Per-feed refresh intervals** (Feedbin): hourly feeds shouldn't poll like + minute feeds. +- **Unread count badges per source**, sort by oldest-first (Miniflux default). diff --git a/docs/plans/2026-09-07-search-fixes-design.md b/docs/plans/2026-09-07-search-fixes-design.md new file mode 100644 index 0000000..c66f5b7 --- /dev/null +++ b/docs/plans/2026-09-07-search-fixes-design.md @@ -0,0 +1,164 @@ +# Design: search focus + search-during-selection + +Date: 2026-09-07 +Status: approved, ready to implement +Scope: two bugs in `DankRssWidget.qml` + one behaviour change in `ReaderState.js` + +## Problem 1 — the search field ignores typing until you click it again + +Click the search icon, the field appears, typing does nothing. Click the field +itself and typing works. + +### Root cause + +Verified in DMS source, not inferred: +`/usr/share/quickshell/dms/Modules/Plugins/DesktopPluginWrapper.qml:341` maps a +plugin's `acceptsKeyboardFocus` onto `WlrKeyboardFocus.OnDemand`, falling back to +`WlrKeyboardFocus.None`. + +Layer-shell `on_demand` means **the compositor** grants keyboard focus to the +surface when a click lands on it. Our `acceptsKeyboardFocus` is bound to +`searchActive` (`DankRssWidget.qml:103`), which is still `false` at the moment +the toggle is clicked. So that click lands on a surface advertising +`keyboard_interactivity: none` and grants nothing. The property flips true +immediately after, making the surface eligible — but no *new* click has landed, +so the seat still holds no focus on us. + +`searchField.forceActiveFocus()` (`:1305`) then sets Qt's *internal* focus item +on a surface with no Wayland keyboard focus at all. It succeeds and does nothing. +The second click is what actually grants seat focus. + +### Fix + +Make the surface focus-eligible *before* the click, by widening the condition to +include pointer hover: + +```qml +HoverHandler { id: widgetHover } // on the root Rectangle + +property bool acceptsKeyboardFocus: root.searchActive || widgetHover.hovered +``` + +Use `HoverHandler`, **not** a root-level `MouseArea`. The widget is full of child +`MouseArea`s (`filterArea`, `markAllArea`, per-item areas); a parent MouseArea's +`containsMouse` goes false whenever a hover-enabled child takes the pointer, so +the flag would flicker exactly while the user is aiming at the search button. +`HoverHandler` is a pointer handler, not an item — it observes the pointer over +its parent's bounds without competing for the event. + +Also add a deferred retry, since focus arrival is not synchronous with the click: + +```qml +onVisibleChanged: if (visible) { forceActiveFocus(); Qt.callLater(forceActiveFocus); } +``` + +### Does this reintroduce the D8 problem? + +No. The `D8` comment at `:100-102` guards against the widget swallowing +compositor keybinds. `OnDemand` never takes focus on its own — it only makes the +surface *eligible* to receive focus from a click. While the pointer merely rests +over the widget and nothing is clicked, keys still go to niri. The behaviour +change is limited to: a click that lands on the widget while hovering can now +focus it, which is exactly what we want. + +**Verification is manual (GUI).** Node tests cannot cover this. + +## Problem 2 — the search button disappears while items are checked + +### Root cause + +The filter/search header row is +`visible: root.allItems.length > 0 && root.selectedCount === 0` (`:1067`), and +the selection bar *replaces* it (`visible: root.selectedCount > 0`, `:1187`). +The search **field** is a separate row keyed only on `searchActive` (`:1286`), +which is why an already-open search survives entering selection mode — the +asymmetry that made this confusing to report. + +### Fix + +Add a search toggle to the selection bar, mirroring the header's, bound to the +same `root.searchActive`. Keep the replace-the-row layout: selection is a +transient working mode and the filter chips are not useful inside it, but search +is. One row is always visible; no vertical churn. + +Factor the toggle into an inline component so the two copies cannot drift. + +## Problem 3 (behaviour change) — selection must survive filtering + +Requested explicitly: items selected before typing a query stay selected as the +list filters down, provided they still match. + +### Current behaviour + +`applyFilter()` ends with +`root.selectedMap = ReaderState.pruneSelected(root.selectedMap, visible)` +(`:961`) — it prunes against the **visible** set, so any selected item that the +query filters out is silently deselected. Type a query, clear it, and your +selection is gone. + +### New behaviour + +Prune against `root.allItems` — the full dataset — instead of `visible`. +Selection then survives both search and filter-chip changes, and an id is dropped +only when it leaves the dataset entirely (a refresh evicting an old item). + +`pruneSelected` itself does not change; only its argument and its comment do. +The `(S10)` comment on `ReaderState.js:391-392` currently states the old +invariant and must be rewritten — a stale comment defending removed behaviour is +exactly the failure mode this repo hit twice in 2.3.x. + +### Consequence: selection can exceed what is on screen + +Deliberate, and the point of the change: select a few, search, select a few more, +act on all of them. Two things follow. + +1. **Bulk actions apply to every selected id, including hidden ones.** No change + needed — `markSelectedRead`/bulk-bookmark already iterate `selectedMap`, not + the visible model. Document it. +2. **The count label must not lie.** `"3 selected"` while one item is on screen + reads as a bug. Add a helper and render the hidden portion: + +```js +// ReaderState.js — new, testable +function countSelectedIn(selectedMap, items) { /* selected ∩ items */ } +``` + +```qml +text: root.selectedCount + " selected" + + (hiddenSelected > 0 ? " (" + hiddenSelected + " hidden)" : "") +``` + +`Clear selection` (`:1277`) clears everything including hidden ids, which is the +only sane reading of the button. + +## Testing + +Node (`node --test tests/*.test.js`), added to `reader-state.test.js`: + +- `pruneSelected` keeps an id present in the dataset but absent from a filtered + view — the regression this change exists to prevent. +- `pruneSelected` still drops an id absent from the dataset entirely. +- `countSelectedIn` returns the intersection; `0` for an empty map; ignores + `false` values, matching `countSelected`. +- A selection round-trip: select 3 → filter to 1 → all 3 still selected → clear + query → all 3 still selected. + +Write these tests **before** the change and watch them fail. Per this repo's own +2.3.x history, every bug that shipped was caught by a human reading code or a +screenshot, never by the suite — tests written after the fact here have twice +asserted the broken behaviour. + +Manual (Brendon, GUI — cannot be automated from this session): + +1. Click the search icon once; type immediately. Text must appear. +2. Check two items; the search toggle must be present in the selection bar. +3. With two checked, search for a term matching only one. Both stay selected, + label reads `2 selected (1 hidden)`. +4. Clear the query; both still checked. +5. With search closed and the pointer over the widget, press a niri keybind + (e.g. `Alt+b`). It must still reach the compositor. + +## Out of scope + +Keyboard navigation (`j/k/o/m/s`) — it depends on the same hover/focus fix but +is a separate feature, specified in the roadmap design doc. From 85697a05c0d51c60ae2013abb969c727cde5cff9 Mon Sep 17 00:00:00 2001 From: Brendon Lasley Date: Mon, 7 Sep 2026 20:52:35 -0400 Subject: [PATCH 2/3] Design: v3 roadmap Provider abstractions for feed backends, local AI runtimes and notes export, plus keyboard navigation and the reader/annotation app. Verified rather than assumed: ollama serves an OpenAI-compatible /v1 (so vLLM et al. come free from the same client), and Quickshell's FileView does atomic writes (so the export provider needs no shell). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS --- docs/plans/2026-09-07-roadmap-design.md | 225 ++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/plans/2026-09-07-roadmap-design.md diff --git a/docs/plans/2026-09-07-roadmap-design.md b/docs/plans/2026-09-07-roadmap-design.md new file mode 100644 index 0000000..4d4da8a --- /dev/null +++ b/docs/plans/2026-09-07-roadmap-design.md @@ -0,0 +1,225 @@ +# Design: v3 roadmap + +Date: 2026-09-07 +Status: agreed in principle; per-phase specs still to be written +Scope: architecture for everything after the 2.3.x line + +## Guiding principle + +Every external thing this widget talks to — feed backend, AI runtime, notes +app — gets an **interface with presets**, never a hardcoded vendor. Where a de +facto standard protocol exists, the interface *is* that protocol and each +"provider" collapses into a base URL plus a label. That keeps the provider count +from becoming a code-path count. + +Two protocols make this cheap, and both were verified rather than assumed: + +- **Google Reader API** is spoken by FreshRSS, Tiny Tiny RSS (via plugin), + Inoreader, TheOldReader, BazQux *and* Miniflux. One client, six backends. +- **The OpenAI-compatible `/v1/chat/completions` endpoint** is spoken by ollama, + vLLM, llama.cpp's server, LM Studio and LocalAI. Verified locally against this + machine's ollama: `GET http://localhost:11434/v1/models` → `HTTP 200` with an + OpenAI-shaped `{"object":"list","data":[...]}` body. One client, every local + runtime. + +Transport for all of it stays what the Miniflux integration already uses: +`Proc.runCommand(null, argv, cb, undefined, timeoutMs)` with curl, argv as an +array, never `sh -c`, secrets always their own argv element +(`DankRssWidget.qml:738-771`). + +--- + +## Phase 0 — Backend provider abstraction (blocks almost everything) + +`sourceMode` is a two-valued string branched on inline in roughly ten places +(`DankRssWidget.qml:344, 382, 407, 421, 470, 510, ...`). Two values work as an +if/else. Three is where the branch you forgot to update ships as a bug. + +Extract an interface before adding a third mode: + +``` +fetch(cb) -> items[] // normalised, id-prefixed +markRead(ids) -> void +markUnread(ids) -> void +toggleStar(id) -> void +reconcile(serverEntries) -> {readMap, bookmarkMap} +capabilities -> { serverState, star, subscribe, categories, fullText } +``` + +`capabilities` is what stops the UI from growing its own `if (sourceMode === +...)` branches: the star button asks `backend.capabilities.star`, not which +backend it is. Fever, whenever it lands, is the case that proves this — it is +read-only in Miniflux, so it reports `subscribe: false` and the subscribe UI +disappears without a single mode check. + +Implementations: `StandardBackend` (direct feed fetching, local state) and +`MinifluxBackend` (lifted verbatim from today's branches — behaviour-preserving, +no fixes smuggled in). Id prefixes stay as they are (`g:`/`l:`/`h:`/`m:`, +`FeedParser.js:395-401`); Google Reader gets its own. + +This is a pure refactor. It must land with the existing suite green and no +observable behaviour change, as its own PR, before Phase 1 starts. + +## Phase 1 — Google Reader API backend + +A third implementation behind Phase 0's interface. ClientLogin token flow, +`/reader/api/0/stream/contents`, `edit-tag` for read/starred. Ships with a +Miniflux-hosted round-trip test since Miniflux speaks it too — the one backend +we can test locally against a server we already run. + +**Fever API is explicitly deferred.** It buys FreshRSS + TT-RSS, both of which +Google Reader already covers, and it is read-only in Miniflux. Reconsider only +if a contributor wants it for a backend that speaks nothing else. + +## Phase 2 — Keyboard navigation + +Depends on the hover/focus fix in the search-fixes design doc, which is what +makes the surface reliably focusable at all. + +Accepted constraint: the wrapper gives us `WlrKeyboardFocus.OnDemand`, so focus +arrives only after a click on the widget. Driving it from cold would need +`Exclusive`, which swallows every compositor key. **Click-then-drive is the +intended model, not a limitation to design around.** + +Bindings (Miniflux/vim conventions, so muscle memory transfers): + +| Key | Action | Key | Action | +|---|---|---|---| +| `j` / `k` | next / prev | `Space` | toggle checkbox | +| `o` / `Enter` | open | `/` | focus search | +| `m` | toggle read | `Esc` | close search, else clear selection | +| `s` / `f` | toggle star | `r` | refresh | +| `g g` / `G` | top / bottom | `A` | mark all read | + +Needs: a `currentIndex` on the ListView, a focus ring visually distinct from +hover, and `positionViewAtIndex` so the cursor never leaves the viewport. +`Esc` is deliberately layered — closing search before clearing selection — so it +never destroys a selection the user is mid-way through building. + +## Phase 3 — AI provider (`AiProvider.js`) + +**Interface:** OpenAI-compatible chat completions. A provider is +`{ label, baseUrl, model, apiKey?, timeoutMs }`. Nothing else. + +Presets: `ollama` (`http://localhost:11434/v1`), `vLLM` +(`http://localhost:8000/v1`), `llama.cpp`, `LM Studio` +(`http://localhost:1234/v1`), plus `Custom`. Adding a runtime later is a row in +a presets table, not a code path. + +Deliberately **not** using ollama's native `/api/generate`. It would work today +and lock out every other runtime tomorrow, which is the exact thing this phase +exists to avoid. + +Features on top, in order: + +1. **Per-article TL;DR** — on-demand button, result cached against the item id. +2. **Digest** — "last 24h across all feeds in six bullets", one call over + titles + descriptions. +3. **Interest ranking** — embeddings via `/v1/embeddings`, rank unread by + similarity to starred. Highest risk here: ranking that feels wrong is worse + than none. Ships behind a default-off toggle with a visible "why this ranked + high" and an obvious escape back to reverse-chronological. + +Model choice note carried over from Brendon's nvim work: this needs an +**instruct** tag, not a `-base` tag. A base model continues text rather than +following the summarise instruction. That trap already cost a day once. + +Degradation: no runtime reachable → AI affordances hide entirely. No error +toasts on a laptop that simply is not running ollama today. The widget must be +completely usable, and completely quiet, with no AI configured. + +## Phase 4 — Notes / export provider (`ExportProvider.js`) + +**Interface:** write a markdown document somewhere. Verified mechanism — +`Quickshell.Io`'s `FileView` with `setText()`, `blockWrites: true` and +`atomicWrites: true`, exactly as DMS itself writes its caches +(`/usr/share/quickshell/dms/Common/CacheData.qml:282-302`). + +``` +export(article, annotations) -> path +capabilities -> { openAfterWrite, appendToDaily, tags } +``` + +Providers: + +- **Markdown directory** — the base case. A path, a filename template, YAML + frontmatter. Every markdown tool on earth reads this. +- **Obsidian** — the markdown provider plus vault awareness: vault-relative + paths, wikilink-style tags, optional `obsidian://open?vault=…&file=…` callback + to jump to the note. First-class because it is what Brendon uses. +- **Neovim** — the markdown provider plus an optional `nvim --server … --remote` + to open the file in a running instance. + +**Evernote is not planned.** Its local API was retired; the only integration +paths left are email-in and manual import, neither of which fits this interface. +If someone wants it, the markdown provider plus their own sync script is the +honest answer. + +The insight worth keeping: Obsidian is not an integration, it is *a directory of +markdown files*. Building the file writer first and treating Obsidian as a +configured instance of it is what makes neovim and everything else nearly free. + +## Phase 5 — Reader / annotation app + +A standalone Quickshell window (DankCalendar-shaped), launched from the widget. +The widget stays the glanceable list; the app is where reading happens. + +- Full-text fetch (already a single API call in Miniflux mode). +- Reading-optimised typography, images, no chrome. +- Highlights and margin notes. +- One action to push the article plus its annotations through Phase 4. + +**The hard problem is anchor stability**, not the UI. A highlight must survive +the article being re-fetched with different whitespace, an added subscribe +banner, or a rewritten wrapper. Character offsets will not survive any of that. +Store `{ exactQuote, prefixContext, suffixContext }` and re-locate by fuzzy +match on load — the model the W3C annotation spec settled on for the same +reason. An anchor that fails to relocate degrades to an orphaned note attached +to the article, never a highlight silently landing on the wrong sentence. + +Depends on Phase 4. Blocked on the open questions below. + +## Phase 6 — Small items, independent of everything above + +Any of these can be picked up by a contributor at any time. + +- **Feed autodiscovery** — paste `arstechnica.com`, find the feed. Best + value-per-line on the whole roadmap; today you must already know the feed URL. +- **OPML export** — import exists (`DankRssWidgetSettings.qml:886`), export does + not. A tool you cannot leave is a bad look for an open one. +- **Categories/folders** — Miniflux returns them; we flatten them. +- **Per-feed refresh intervals** — hourly feeds should not poll like minute ones. +- **Audio enclosures → MPRIS** — `FeedParser.js` already parses enclosures for + images. Podcasts would appear in the DMS media widget like any other track. +- **Rule-based notifications** — upgrade "notify on new items" to "notify on + *interesting* items". The matcher already exists as the search filter. +- **Mark-read-on-scroll**, **per-source snooze**, **oldest-first sort**. + +--- + +## Sequencing + +``` +search fixes ──> Phase 2 (keyboard) + │ + └────────> Phase 0 (backend interface) ──> Phase 1 (Google Reader) + └──> Phase 3 (AI) ──┐ + ├──> Phase 5 (reader app) + Phase 4 (export)┘ +``` + +Phase 3 before Phase 5 on purpose: the AI work is a weekend that tells us whether +local-model features feel good at all, and if they do, Phase 5 launches with a +much stronger feature set than it would alone. + +Phase 6 runs in parallel throughout — it is the contributor on-ramp. + +## Open questions + +Listed in the handoff; recorded here so the doc stands alone. + +1. Reader app as a second plugin in this repo, or a separate repo? +2. Where do annotations live — plugin state, or markdown files in the vault as + the source of truth? +3. Is Phase 1 worth building without a non-Miniflux server to test against? +4. Does the AI config belong per-widget-instance or global? From 71b0106fde3895dc060eeb8a57ddcfd613811ffd Mon Sep 17 00:00:00 2001 From: Brendon Lasley Date: Mon, 7 Sep 2026 20:53:50 -0400 Subject: [PATCH 3/3] Add a Roadmap section to the README Gives contributors the phase order, what depends on what, and a marked set of good first issues. Also replaces the stale 'behaviour unproven' note on acceptsKeyboardFocus now that the wrapper's layer-shell mapping has been read, and drops the scratch TODO the design docs supersede. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS --- README.md | 47 ++++++++++++++++++++++++++- docs/TODO.md | 92 ---------------------------------------------------- 2 files changed, 46 insertions(+), 93 deletions(-) delete mode 100644 docs/TODO.md diff --git a/README.md b/README.md index e82aabd..92d33f9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,51 @@ A desktop widget plugin for [DankMaterialShell](https://github.com/AvengeMedia/D - Feed source labels per item - **Optional [Miniflux](https://miniflux.app/) mode** — sync with a self-hosted Miniflux server instead of fetching feeds directly, with bidirectional read/unread and starred sync +## Roadmap + +Full designs live in [`docs/plans/`](docs/plans/). Contributors welcome on +anything here — the **Phase 6** items are deliberately self-contained and are +the best place to start. + +**In progress** + +- Search focus and search-during-selection fixes — [design](docs/plans/2026-09-07-search-fixes-design.md) + +**Planned** — [full roadmap design](docs/plans/2026-09-07-roadmap-design.md) + +| Phase | Work | Depends on | +|---|---|---| +| 0 | Backend provider interface — replaces the inline `sourceMode` branches | — | +| 1 | Google Reader API backend (FreshRSS, TT-RSS, Inoreader, TheOldReader, BazQux, Miniflux) | 0 | +| 2 | Keyboard navigation (`j`/`k`/`o`/`m`/`s`, `/` to search) | search fixes | +| 3 | Local AI via any OpenAI-compatible runtime (ollama, vLLM, llama.cpp, LM Studio): per-article TL;DR, daily digest, interest ranking | 0 | +| 4 | Notes/export provider: markdown directory, Obsidian, Neovim | — | +| 5 | Reader + annotation app — a standalone window for reading, highlighting and note-taking | 3, 4 | +| 6 | Independent smaller items — see below | — | + +**Phase 6 / good first issues** + +- Feed autodiscovery (paste a site URL, find its feed) +- OPML **export** (import already exists) +- Categories/folders (Miniflux returns them; we flatten them) +- Per-feed refresh intervals +- Audio enclosures → MPRIS, so podcast feeds play through the DMS media widget +- Rule-based notifications (notify on *interesting* items, not just new ones) +- Mark-read-on-scroll, per-source snooze, oldest-first sort + +**Design principle for anything with a vendor in its name:** it gets an +interface with presets, never a hardcoded integration. Feed backends speak the +Google Reader API, AI runtimes speak the OpenAI-compatible chat API, and notes +apps are "write a markdown file to a directory". Adding ollama should not make +vLLM harder, and adding Obsidian should not make Neovim harder. + +**Not planned** + +- **Fever API** — covers only backends the Google Reader API already reaches, + and is read-only in Miniflux. +- **Evernote export** — its local API was retired; there is no integration + surface left that fits the export interface. Use the markdown provider. + ## Miniflux mode Instead of fetching RSS/Atom URLs directly, the widget can act as a front-end for a @@ -76,7 +121,7 @@ backed up, synced, or otherwise readable by other tools. - At very narrow widget widths (approaching the 100px floor) the filter chips can still crowd each other. Fully solving it would need chip wrapping or eliding, which is not implemented. At normal sizes (the default and above) this is not visible. - Compact view rows reserve slightly more vertical padding than their margins strictly need. This is a pre-existing cosmetic issue, not introduced or fixed in this release. -- The widget now declares `acceptsKeyboardFocus` (gated to when search is open) so the search field can receive typed input. No other widget in the installed DMS build uses this property, so while it is wired correctly per the documented mechanism, its behavior is unproven across DMS versions and may interact with compositor-specific layer-shell focus policy. +- `acceptsKeyboardFocus` gating search means the search field needs a **second click** before it accepts typing. The DMS wrapper maps this property onto layer-shell `WlrKeyboardFocus.OnDemand` (`Modules/Plugins/DesktopPluginWrapper.qml`), and `on_demand` grants keyboard focus only on a click that lands while the surface is already focus-eligible — which it is not at the instant the search toggle is clicked. Fix designed, see the roadmap. - The Miniflux settings layout (Connection section, read-only feed list, mode-gated visibility of the RSS-only sections) has not been visually verified in a live DMS session. ## Installation diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 9942229..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,92 +0,0 @@ -# dms-rss-widget backlog - -Post-2.3.3. Nothing here is committed to a release yet. - -## Bugs - -### B1 — Search field doesn't accept typing until you click it a second time -**Symptom:** click the search icon, the field appears, typing does nothing. Click -the field itself, then typing works. - -**Root cause (verified in DMS source, not a guess):** -`/usr/share/quickshell/dms/Modules/Plugins/DesktopPluginWrapper.qml:341` maps our -`acceptsKeyboardFocus` onto `WlrKeyboardFocus.OnDemand` (`None` when false). -Layer-shell `on_demand` means *the compositor* grants keyboard focus to the -surface when a click lands on it. Our `acceptsKeyboardFocus` is bound to -`searchActive` (DankRssWidget.qml:103), which is still **false** at the moment -the toggle is clicked — so that click lands on a surface with -`keyboard_interactivity: none` and grants nothing. The property flips true -afterwards, but no *new* click has landed, so the seat still has no focus on us. -`searchField.forceActiveFocus()` (line 1305) sets Qt's internal focus item on a -surface that has no Wayland keyboard focus at all — hence the silent no-op. - -**Fix direction:** make the surface focus-eligible *before* the click, e.g. -`acceptsKeyboardFocus: root.searchActive || widgetHoverArea.containsMouse`. -Keep the D8 intent (line 100-102): must still be `false` when the pointer is -away, so compositor keybinds aren't swallowed. Re-assert `forceActiveFocus()` -from a `Qt.callLater`/one-shot Timer as a belt-and-braces retry. - -### B2 — Search button disappears while items are checked -**Symptom:** with checkboxes ticked there's no way to open search; but if search -was already open it survives entering selection mode. - -**Root cause:** the filter/search header row is -`visible: root.allItems.length > 0 && root.selectedCount === 0` -(DankRssWidget.qml:1067) and the selection bar *replaces* it -(`visible: root.selectedCount > 0`, line 1187). The search **field** is a -separate row keyed only on `searchActive` (line 1286), which is exactly why it -survives — the asymmetry the user hit. - -**Fix direction:** either keep a search toggle in the selection bar, or stop -hiding the whole header and only swap the chip cluster. Decide whether -"search within selection" is even meaningful first — S10 (line 959) prunes -selection against the visible set, so searching while selected currently -*shrinks* the selection. That interaction needs a decision, not just a button. - -## Features - -### F1 — Keyboard navigation (feasible, with one hard limit) -Confirmed possible: the wrapper already forwards `acceptsKeyboardFocus`, so we -can hold `OnDemand` focus and attach `Keys.onPressed` handlers. -**Limit:** `OnDemand` means focus arrives only after a click on the widget. -There is no way to be keyboard-driven from cold without `Exclusive`, which would -steal every key from the compositor. So the model is: click once, then drive. - -Proposed bindings (Miniflux/Vim conventions): -`j`/`k` next/prev · `o`/`Enter` open · `m` toggle read · `s`/`f` star · -`/` focus search · `Esc` close search / clear selection · `Space` toggle -checkbox · `r` refresh · `g g`/`G` top/bottom. -Requires: a `currentIndex` on the ListView, a visible focus ring distinct from -hover, and `positionViewAtIndex` so the cursor stays on screen. - -### F2 — Google Reader API support (highest leverage backend work) -One protocol unlocks FreshRSS, Tiny Tiny RSS (via plugin), Inoreader, -TheOldReader, BazQux — and Miniflux, which speaks it too. Compare with adding -each backend one at a time. - -Prerequisite refactor: `sourceMode` is currently a two-valued string branched on -in ~10 places in DankRssWidget.qml (lines 344, 382, 407, 421, 470, 510, ...). -Adding a third mode by extending those branches will not scale. Extract a -backend interface — `fetch()`, `markRead(ids)`, `toggleStar(id)`, -`reconcile(items)` — with `standard` / `miniflux` as its first two -implementations, *then* add Google Reader as a third. - -### F3 — Fever API (cheap second protocol) -Simpler than Google Reader, covers FreshRSS + TT-RSS. **Read-only in Miniflux** -(can't subscribe through it), so it's a complement, never a replacement for F2. - -### F4 — Feature ideas from other readers -- **Full-text fetch** (Miniflux, FreshRSS): request the article body when a feed - only ships a summary. In Miniflux mode this is a single API call we already - have the token for. -- **Saved searches / filter rules** (NewsBlur, Inoreader): keyword rules that - auto-mark-read or auto-star. Our search already indexes title/text/source. -- **Feed grouping / categories** (all of them): Miniflux already returns - categories; we currently flatten them. -- **Hide-read-on-scroll** (Reeder, NetNewsWire): auto-mark items read as they - scroll past. -- **Send-to** integrations (Wallabag, Pocket-likes): one action to push an entry - elsewhere; would fit the existing selection bar. -- **Per-feed refresh intervals** (Feedbin): hourly feeds shouldn't poll like - minute feeds. -- **Unread count badges per source**, sort by oldest-first (Miniflux default).