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:
Jeena 2026-03-20 11:22:19 +00:00
parent 3196988c98
commit 3339bb5ec8
15 changed files with 3761 additions and 2 deletions

420
BACKLOG.md Normal file
View 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.