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.
16 KiB
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:
- Runs
blueprint-compiler batch-compileon all.blpfiles indata/ui/to produce.uifiles alongside them - Runs
glib-compile-resourcesondata/resources.gresource.xmlto produce a compiled.gresourcefile, then registers it
1.4 Write data/resources.gresource.xml bundling:
- Compiled
.uifiles fromdata/ui/ html/content.htmlandhtml/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→AdwPreferencesGroupcontaining:AdwEntryRow— Server URL (placeholder:https://example.com)AdwEntryRow— UsernameAdwPasswordEntryRow— PasswordAdwButtonRow— "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>CallsPOST /accounts/ClientLogin, parsesAuth=line from responsefetch_write_token(auth_token) -> Result<WriteToken>CallsGET /reader/api/0/token
2.5 Connect login dialog to Api::login(). On success:
- Store credentials via libsecret
- Store
AuthTokenandWriteTokenin 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
Articlestruct - Derives
unreadfromcategoriesnot containinguser/-/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
ArticleObjectproperties to labels - Apply
.dim-labelstyle to the title label when article is read - Format
publishedtimestamp 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()andsetNightmode()JavaScript functions (no longer needed) - Remove
"loading"and"empty"string handling fromsetArticle()(these states are now handled byAdwStatusPagein 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
.nightmodebody 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.htmlfrom GResource on init - Exposes
set_article(article: &Article)which callswindow.setArticle()viaWebView::evaluate_javascript() - Handles
decide-policysignal: interceptfeedthemonkey:previous,feedthemonkey:next,feedthemonkey:openURIs and emit corresponding GObject signals; intercept all other link clicks and open withgtk::show_uri() - Exposes
zoom_levelproperty (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::unreadtotrue(optimistic update) - Call
Api::mark_unread()in the background - Show an
AdwToast"Marked as unread" in theAdwToastOverlay - 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: multiplyWebView::zoom_levelby 1.1zoom_out: divide by 1.1zoom_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.