scaffold: Epic 1 — project scaffold
Add the full Rust + GTK4 + libadwaita project skeleton: - Cargo.toml with all dependencies (gtk4 0.11, libadwaita 0.9, webkit6 0.6, reqwest, serde, tokio, libsecret) - build.rs that compiles Blueprint .blp files and bundles a GResource - data/ui/window.blp — AdwApplicationWindow with AdwNavigationSplitView, sidebar with refresh button/spinner and primary menu, content page with article menu - data/resources.gresource.xml bundling UI, HTML, and CSS - data/net.jeena.FeedTheMonkey.gschema.xml with all GSettings keys - html/content.html and html/content.css (minimal placeholders) - src/main.rs, src/app.rs — AdwApplication with APP_ID net.jeena.FeedTheMonkey - src/window.rs — AdwApplicationWindow GObject subclass loading the Blueprint template and persisting window size in GSettings - COPYING (GPL-3.0) restored from master The app compiles and the binary is ready to open a blank window.
This commit is contained in:
parent
3196988c98
commit
3339bb5ec8
15 changed files with 3761 additions and 2 deletions
420
BACKLOG.md
Normal file
420
BACKLOG.md
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
# FeedTheMonkey v3 — Backlog
|
||||
|
||||
A rewrite of FeedTheMonkey from Qt5/C++/QML to Rust + GTK4 + libadwaita.
|
||||
The app is a FreshRSS client using the Google Reader compatible API.
|
||||
|
||||
## Design principles
|
||||
|
||||
- Idiomatic libadwaita: follows the GNOME HIG, not a port of the Qt UI
|
||||
- Dark mode follows the system (AdwStyleManager), no in-app toggle
|
||||
- Credentials stored in the system keyring (libsecret), not a config file
|
||||
- App settings stored in GSettings
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Language: Rust (stable)
|
||||
- UI: GTK4 + libadwaita
|
||||
- Web content: webkit6 crate (webkitgtk-6.0)
|
||||
- UI templates: Blueprint (.blp) compiled at build time via blueprint-compiler
|
||||
- HTTP: reqwest (async, TLS)
|
||||
- JSON: serde + serde_json
|
||||
- Async: tokio
|
||||
- Credentials: libsecret crate
|
||||
- Settings: GSettings (net.jeena.FeedTheMonkey schema)
|
||||
- API: FreshRSS Google Reader compatible API
|
||||
|
||||
## API reference
|
||||
|
||||
Base URL: `https://rss.jeena.net/api/greader.php`
|
||||
|
||||
| Purpose | Method | Path | Notes |
|
||||
|---------------|--------|-----------------------------------------------|--------------------------------------------|
|
||||
| Login | POST | /accounts/ClientLogin | Body: `Email=&Passwd=` → `Auth=<token>` |
|
||||
| Write token | GET | /reader/api/0/token | Header: `Authorization: GoogleLogin auth=` |
|
||||
| Fetch unread | GET | /reader/api/0/stream/contents/reading-list | `?xt=user/-/state/com.google/read&n=200&output=json` |
|
||||
| Mark read | POST | /reader/api/0/edit-tag | Body: `i=<id>&a=user/-/state/com.google/read&T=<token>` |
|
||||
| Mark unread | POST | /reader/api/0/edit-tag | Body: `i=<id>&r=user/-/state/com.google/read&T=<token>` |
|
||||
|
||||
### Article JSON → content.html field mapping
|
||||
|
||||
| Google Reader field | JS template field |
|
||||
|-----------------------|-------------------|
|
||||
| title | title |
|
||||
| origin.title | feed_title |
|
||||
| canonical[0].href | link |
|
||||
| published (unix ts) | updated |
|
||||
| summary.content | content |
|
||||
| author | author |
|
||||
| unread | categories does NOT contain `user/-/state/com.google/read` |
|
||||
|
||||
## UI structure
|
||||
|
||||
```
|
||||
AdwApplicationWindow
|
||||
└── AdwToastOverlay
|
||||
└── AdwNavigationSplitView
|
||||
├── sidebar: AdwNavigationPage (title="FeedTheMonkey")
|
||||
│ └── AdwToolbarView
|
||||
│ ├── top: AdwHeaderBar
|
||||
│ │ ├── start: GtkButton (view-refresh-symbolic)
|
||||
│ │ │ → AdwSpinner while loading (via GtkStack)
|
||||
│ │ └── end: GtkMenuButton (open-menu-symbolic, primary=True)
|
||||
│ │ └── GMenu: Log Out, Keyboard Shortcuts, About
|
||||
│ └── content: GtkScrolledWindow → GtkListView (ArticleRow)
|
||||
│ or AdwStatusPage (empty / loading / error)
|
||||
└── content: AdwNavigationPage (title = article title, dynamic)
|
||||
└── AdwToolbarView (top-bar-style=raised)
|
||||
├── top: AdwHeaderBar (auto back button on narrow)
|
||||
│ └── end: GtkMenuButton (view-more-symbolic)
|
||||
│ └── GMenu: Mark Unread, Open in Browser
|
||||
└── content: WebKitWebView (loads html/content.html)
|
||||
or AdwStatusPage ("Select an article")
|
||||
```
|
||||
|
||||
## GSettings schema
|
||||
|
||||
Schema ID: `net.jeena.FeedTheMonkey`
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|------------------|--------|---------|----------------------------|
|
||||
| window-width | int | 900 | |
|
||||
| window-height | int | 600 | |
|
||||
| window-maximized | bool | false | |
|
||||
| sidebar-width | int | 280 | |
|
||||
| zoom-level | double | 1.0 | WebKitWebView zoom level |
|
||||
|
||||
Credentials (libsecret, not GSettings):
|
||||
- server-url
|
||||
- username
|
||||
- password
|
||||
|
||||
---
|
||||
|
||||
## Epic 1 — Project scaffold
|
||||
|
||||
Set up a working Rust + GTK4 + libadwaita project that opens a blank
|
||||
AdwApplicationWindow. No logic, just the skeleton.
|
||||
|
||||
### Stories
|
||||
|
||||
**1.1** Create the `v3` git branch from `master`.
|
||||
|
||||
**1.2** Write `Cargo.toml` with all dependencies:
|
||||
- gtk4, libadwaita, webkit6, gio, glib
|
||||
- reqwest (with rustls-tls feature, no native-tls)
|
||||
- serde, serde_json
|
||||
- tokio (full features)
|
||||
- secret (libsecret)
|
||||
- blueprint-compiler is a build-time tool, not a crate
|
||||
|
||||
**1.3** Write `build.rs` that:
|
||||
1. Runs `blueprint-compiler batch-compile` on all `.blp` files in `data/ui/`
|
||||
to produce `.ui` files alongside them
|
||||
2. Runs `glib-compile-resources` on `data/resources.gresource.xml`
|
||||
to produce a compiled `.gresource` file, then registers it
|
||||
|
||||
**1.4** Write `data/resources.gresource.xml` bundling:
|
||||
- Compiled `.ui` files from `data/ui/`
|
||||
- `html/content.html` and `html/content.css`
|
||||
|
||||
**1.5** Write the GSettings schema `data/net.jeena.FeedTheMonkey.gschema.xml`
|
||||
with the keys listed above.
|
||||
|
||||
**1.6** Write `data/ui/window.blp` — a minimal `AdwApplicationWindow`
|
||||
containing `AdwNavigationSplitView` with placeholder sidebar and content pages.
|
||||
|
||||
**1.7** Write `src/main.rs` and `src/app.rs` — `AdwApplication` with
|
||||
APP_ID `net.jeena.FeedTheMonkey`, activates the main window.
|
||||
|
||||
**1.8** Write `src/window.rs` — `AdwApplicationWindow` GObject subclass
|
||||
that loads the window Blueprint template and restores window size from GSettings.
|
||||
|
||||
**1.9** Verify the app compiles and opens a blank window.
|
||||
|
||||
---
|
||||
|
||||
## Epic 2 — Authentication
|
||||
|
||||
Login dialog, credential storage, session management, logout.
|
||||
|
||||
### Stories
|
||||
|
||||
**2.1** Write `data/ui/login_dialog.blp` — an `AdwDialog` with:
|
||||
- `AdwToolbarView` + `AdwHeaderBar` (title "Log In")
|
||||
- `AdwClamp` → `AdwPreferencesGroup` containing:
|
||||
- `AdwEntryRow` — Server URL (placeholder: `https://example.com`)
|
||||
- `AdwEntryRow` — Username
|
||||
- `AdwPasswordEntryRow` — Password
|
||||
- `AdwButtonRow` — "Log In" (style: `.suggested-action`)
|
||||
|
||||
**2.2** Write `src/login_dialog.rs` — `LoginDialog` GObject subclass
|
||||
using the Blueprint template. Emits a `logged-in` signal on success.
|
||||
|
||||
**2.3** Write `src/settings.rs` — libsecret helpers:
|
||||
- `store_credentials(server_url, username, password)`
|
||||
- `load_credentials() -> Option<(server_url, username, password)>`
|
||||
- `clear_credentials()`
|
||||
|
||||
**2.4** Write `src/api.rs` — `Api` struct with:
|
||||
- `login(server_url, username, password) -> Result<AuthToken>`
|
||||
Calls `POST /accounts/ClientLogin`, parses `Auth=` line from response
|
||||
- `fetch_write_token(auth_token) -> Result<WriteToken>`
|
||||
Calls `GET /reader/api/0/token`
|
||||
|
||||
**2.5** Connect login dialog to `Api::login()`. On success:
|
||||
- Store credentials via libsecret
|
||||
- Store `AuthToken` and `WriteToken` in application state
|
||||
- Close the dialog, trigger article fetch (Epic 3)
|
||||
|
||||
**2.6** On app start: call `load_credentials()`. If found, call `Api::login()`
|
||||
automatically and skip showing the login dialog.
|
||||
|
||||
**2.7** Implement logout: clear libsecret credentials, discard tokens,
|
||||
show the login dialog again.
|
||||
|
||||
**2.8** Show login errors via `AdwAlertDialog` (title "Login Failed",
|
||||
body = server error message, single "OK" button).
|
||||
|
||||
---
|
||||
|
||||
## Epic 3 — Article fetching
|
||||
|
||||
Fetch unread articles from the server and populate the list model.
|
||||
|
||||
### Stories
|
||||
|
||||
**3.1** Write `src/model.rs` — `Article` struct (plain Rust, not GObject):
|
||||
```
|
||||
id: String
|
||||
title: String
|
||||
feed_title: String
|
||||
author: String
|
||||
link: String
|
||||
published: i64 ← unix timestamp
|
||||
content: String
|
||||
excerpt: String ← plain text, first ~150 chars of content stripped of HTML
|
||||
unread: bool
|
||||
```
|
||||
|
||||
Also a GObject wrapper `ArticleObject` (implementing `glib::Object`) so
|
||||
it can be stored in a `gio::ListStore`.
|
||||
|
||||
**3.2** Add `Api::fetch_unread(auth_token) -> Result<Vec<Article>>` that:
|
||||
- Calls `GET /reader/api/0/stream/contents/reading-list?xt=...&n=200&output=json`
|
||||
- Deserializes the JSON response
|
||||
- Maps Google Reader fields to `Article` struct
|
||||
- Derives `unread` from `categories` not containing `user/-/state/com.google/read`
|
||||
|
||||
**3.3** In `src/window.rs`: after successful login, call `fetch_unread()` in
|
||||
a background tokio task and populate a `gio::ListStore<ArticleObject>`.
|
||||
|
||||
**3.4** Show `AdwStatusPage` with `AdwSpinnerPaintable` and title "Loading…"
|
||||
in the sidebar while fetching is in progress.
|
||||
|
||||
**3.5** Show `AdwStatusPage` with icon `rss-symbolic` and title
|
||||
"No unread articles" when the list is empty after a successful fetch.
|
||||
|
||||
**3.6** Show `AdwStatusPage` with icon `network-error-symbolic` and title
|
||||
"Could not load articles" (description = error message) on network/API error.
|
||||
Include a "Try Again" button that re-triggers the fetch.
|
||||
|
||||
**3.7** Implement reload: the refresh button in the sidebar header bar
|
||||
re-runs `fetch_unread()`, replaces the list store contents, and scrolls
|
||||
the list back to the top.
|
||||
|
||||
---
|
||||
|
||||
## Epic 4 — Sidebar
|
||||
|
||||
Article list view with custom rows, navigation, and split view behavior.
|
||||
|
||||
### Stories
|
||||
|
||||
**4.1** Write `data/ui/article_row.blp` — composite template for `ArticleRow`:
|
||||
```
|
||||
GtkBox (orientation=vertical, margin=12, spacing=4)
|
||||
├── GtkBox (orientation=horizontal, spacing=4)
|
||||
│ ├── GtkLabel (id=feed_title, hexpand=True, xalign=0, .dim-label, small font)
|
||||
│ └── GtkLabel (id=date, xalign=1, .dim-label, small font)
|
||||
├── GtkLabel (id=title, xalign=0, wrap=True, lines=2)
|
||||
└── GtkLabel (id=excerpt, xalign=0, .dim-label, small font, ellipsize=end, lines=1)
|
||||
```
|
||||
|
||||
**4.2** Write `src/article_row.rs` — `ArticleRow` GObject subclass.
|
||||
- Bind `ArticleObject` properties to labels
|
||||
- Apply `.dim-label` style to the title label when article is read
|
||||
- Format `published` timestamp as relative time ("2 hours ago", "Yesterday", etc.)
|
||||
|
||||
**4.3** Set up `GtkListView` in the sidebar with a `GtkSignalListItemFactory`
|
||||
that creates and binds `ArticleRow` widgets.
|
||||
|
||||
**4.4** Connect list selection (`GtkSingleSelection`) to show the selected
|
||||
article in the content pane and call `adw_navigation_split_view_set_show_content(true)`
|
||||
on narrow screens.
|
||||
|
||||
**4.5** Implement `next_article()` and `previous_article()` functions that
|
||||
move the selection in the list view (used by keyboard shortcuts).
|
||||
|
||||
**4.6** Persist and restore `sidebar-width` from GSettings using
|
||||
`AdwNavigationSplitView::sidebar-width-fraction` or equivalent.
|
||||
|
||||
---
|
||||
|
||||
## Epic 5 — Content pane
|
||||
|
||||
WebKitWebView loading content.html, link handling, empty state.
|
||||
|
||||
### Stories
|
||||
|
||||
**5.1** Update `html/content.html`:
|
||||
- Change `<title>` from "TTRSS" to "FeedTheMonkey"
|
||||
- Remove `setFont()` and `setNightmode()` JavaScript functions (no longer needed)
|
||||
- Remove `"loading"` and `"empty"` string handling from `setArticle()`
|
||||
(these states are now handled by `AdwStatusPage` in Rust)
|
||||
- Remove the `"logout"` string handling
|
||||
- Keep `setArticle(article)` for JSON object input
|
||||
- Keep `checkKey()` and the keyboard event listener
|
||||
|
||||
**5.2** Update `html/content.css`:
|
||||
- Replace the `.nightmode` body class block with
|
||||
`@media (prefers-color-scheme: dark) { ... }`
|
||||
- Keep all existing styles, just change the selector
|
||||
|
||||
**5.3** Write `src/content_view.rs` — wrapper around `webkit::WebView`:
|
||||
- Loads `content.html` from GResource on init
|
||||
- Exposes `set_article(article: &Article)` which calls `window.setArticle()`
|
||||
via `WebView::evaluate_javascript()`
|
||||
- Handles `decide-policy` signal: intercept `feedthemonkey:previous`,
|
||||
`feedthemonkey:next`, `feedthemonkey:open` URIs and emit corresponding
|
||||
GObject signals; intercept all other link clicks and open with `gtk::show_uri()`
|
||||
- Exposes `zoom_level` property (get/set), used by Epic 8
|
||||
|
||||
**5.4** Show `AdwStatusPage` with icon `document-open-symbolic` and
|
||||
title "No Article Selected" when nothing is selected in the sidebar.
|
||||
|
||||
**5.5** Ensure WebKit loads the local `content.html` from GResource using
|
||||
a custom URI scheme or `load_html()` with a base URI so that `content.css`
|
||||
is resolved correctly.
|
||||
|
||||
---
|
||||
|
||||
## Epic 6 — Read state
|
||||
|
||||
Auto-mark-read, manual mark-unread, API sync.
|
||||
|
||||
### Stories
|
||||
|
||||
**6.1** Add `Api::mark_read(auth_token, write_token, item_id) -> Result<()>`
|
||||
that POSTs to `/reader/api/0/edit-tag` with
|
||||
`i=<id>&a=user/-/state/com.google/read&T=<write_token>`.
|
||||
|
||||
**6.2** Add `Api::mark_unread(auth_token, write_token, item_id) -> Result<()>`
|
||||
that POSTs to `/reader/api/0/edit-tag` with
|
||||
`i=<id>&r=user/-/state/com.google/read&T=<write_token>`.
|
||||
|
||||
**6.3** When the selected article changes (user navigates to next/previous),
|
||||
mark the *previously selected* article as read via `Api::mark_read()`.
|
||||
Update `ArticleObject::unread` to `false` so the row styling updates immediately
|
||||
(optimistic update — no waiting for the API response).
|
||||
|
||||
**6.4** Implement "Mark Unread" action (key `u`, content pane menu):
|
||||
- Set `ArticleObject::unread` to `true` (optimistic update)
|
||||
- Call `Api::mark_unread()` in the background
|
||||
- Show an `AdwToast` "Marked as unread" in the `AdwToastOverlay`
|
||||
- Set a guard flag so navigating away does not re-mark the article as read
|
||||
|
||||
**6.5** Handle API errors from mark_read/mark_unread silently
|
||||
(log to stderr, do not show a dialog — these are background operations).
|
||||
|
||||
---
|
||||
|
||||
## Epic 7 — Keyboard shortcuts
|
||||
|
||||
Full keyboard navigation matching the feature set of v2.
|
||||
|
||||
### Stories
|
||||
|
||||
**7.1** Register `GtkShortcutController` on the main window for all shortcuts:
|
||||
|
||||
| Key(s) | Action |
|
||||
|-----------------|-----------------------------------------|
|
||||
| j, Right | Next article |
|
||||
| k, Left | Previous article |
|
||||
| Enter, n | Open current article in browser |
|
||||
| r | Reload articles |
|
||||
| u | Mark current article unread |
|
||||
| Space, Page_Down| Scroll content down one page |
|
||||
| Page_Up | Scroll content up one page |
|
||||
| Home | Scroll content to top |
|
||||
| End | Scroll content to bottom |
|
||||
| Ctrl+plus | Zoom in |
|
||||
| Ctrl+minus | Zoom out |
|
||||
| Ctrl+0 | Reset zoom |
|
||||
| F11 | Toggle fullscreen |
|
||||
| Ctrl+W | Close window |
|
||||
| Ctrl+Q | Quit application |
|
||||
|
||||
**7.2** Write `data/ui/shortcuts.blp` — `AdwShortcutsDialog` documenting
|
||||
all shortcuts, grouped by category (Navigation, Article, View, Application).
|
||||
|
||||
**7.3** Connect the "Keyboard Shortcuts" menu item to show the shortcuts dialog.
|
||||
Also register `F1` as an alias.
|
||||
|
||||
---
|
||||
|
||||
## Epic 8 — Zoom
|
||||
|
||||
WebView zoom with keyboard shortcuts and persistence.
|
||||
|
||||
### Stories
|
||||
|
||||
**8.1** Implement `zoom_in()`, `zoom_out()`, `zoom_reset()` in `content_view.rs`:
|
||||
- `zoom_in`: multiply `WebView::zoom_level` by 1.1
|
||||
- `zoom_out`: divide by 1.1
|
||||
- `zoom_reset`: set to 1.0
|
||||
|
||||
**8.2** On zoom change, save the new zoom level to GSettings (`zoom-level` key).
|
||||
|
||||
**8.3** On app start, read `zoom-level` from GSettings and apply to the WebView.
|
||||
|
||||
---
|
||||
|
||||
## Epic 9 — Window state persistence
|
||||
|
||||
Save and restore window size and sidebar width.
|
||||
|
||||
### Stories
|
||||
|
||||
**9.1** On window close, save `window-width`, `window-height`, `window-maximized`
|
||||
to GSettings.
|
||||
|
||||
**9.2** On window open, restore width, height, and maximized state from GSettings.
|
||||
|
||||
**9.3** Save `sidebar-width` to GSettings when the sidebar is resized.
|
||||
Restore on startup.
|
||||
|
||||
---
|
||||
|
||||
## Epic 10 — Polish
|
||||
|
||||
Final details for a shippable app.
|
||||
|
||||
### Stories
|
||||
|
||||
**10.1** Write `data/net.jeena.FeedTheMonkey.desktop` — `.desktop` file
|
||||
with `Name=FeedTheMonkey`, `Exec=feedthemonkey`, `Icon=feedthemonkey`,
|
||||
`Categories=Network;Feed;`.
|
||||
|
||||
**10.2** Include the existing `misc/feedthemonkey.xpm` icon in the GResource
|
||||
bundle and reference it in the `.desktop` file.
|
||||
|
||||
**10.3** Implement `AdwAboutDialog` with app name, version, license (GPL-3.0),
|
||||
website, and developer name. Connect to "About" menu item.
|
||||
|
||||
**10.4** Handle the write token expiry: if an `edit-tag` call returns 401,
|
||||
fetch a new write token and retry once.
|
||||
|
||||
**10.5** Add `AdwAlertDialog` for logout confirmation ("Are you sure you want
|
||||
to log out?" with "Log Out" destructive button and "Cancel").
|
||||
|
||||
**10.6** Graceful shutdown: cancel in-flight API requests when the window closes.
|
||||
Loading…
Add table
Add a link
Reference in a new issue