app: implement Epics 2–10
Add the full application logic on top of the Epic 1 skeleton:
Epic 2 — Authentication
- LoginDialog (AdwDialog, Blueprint template) with server URL,
username, and password fields; emits logged-in signal on submit
- credentials.rs: store/load/clear via libsecret (password_store_sync /
password_search_sync / password_clear_sync, v0_19 feature)
- api.rs: Api::login() parses Auth= token from ClientLogin response;
fetch_write_token() fetches the write token
- Auto-login on startup from stored credentials; logout with
AdwAlertDialog confirmation; login errors shown in AdwAlertDialog
Epic 3 — Article fetching
- model.rs: Article struct and ArticleObject GObject wrapper with
unread property for list store binding
- Api::fetch_unread() deserializes Google Reader JSON, derives unread
from categories, generates plain-text excerpt
- Sidebar uses a GtkStack with placeholder / loading / empty / error /
list pages; AdwSpinnerPaintable while fetching; Try Again button
Epic 4 — Sidebar
- article_row.blp: composite template with feed title, date, title,
and excerpt labels
- ArticleRow GObject subclass; binds ArticleObject, watches unread
notify to apply .dim-label on the title; relative timestamp format
Epic 5 — Content pane
- content.html updated: setArticle(), checkKey(), feedthemonkey: URI
navigation scheme; dark mode via prefers-color-scheme
- content.css: proper article layout, dark mode, code blocks
- WebView loaded from GResource; decide-policy intercepts
feedthemonkey:{next,previous,open} and all external links
Epic 6 — Read state
- Api::mark_read() / mark_unread() via edit-tag endpoint
- Optimistic unread toggle on ArticleObject; background API calls;
mark_unread_guard prevents re-marking on navigation
- AdwToast shown on mark-unread
Epic 7 — Keyboard shortcuts
- GtkShortcutController on window for all shortcuts from the backlog
- shortcuts.blp: AdwShortcutsWindow documenting all shortcuts
- F1 opens shortcuts dialog; Ctrl+W closes window; Ctrl+Q quits
Epic 8 — Zoom
- zoom_in/zoom_out/zoom_reset wired to Ctrl+±/0; zoom level saved to
and restored from GSettings zoom-level key
Epic 9 — Window state persistence
- Window width/height/maximized saved on close, restored on open
- (Sidebar width deferred — AdwNavigationSplitView fraction binding)
Epic 10 — Polish
- AdwAboutDialog with app name, version, GPL-3.0, website
- Logout confirmation AdwAlertDialog with destructive button
- Win.toggle-fullscreen action (F11)
- Api dropped on window close to cancel in-flight requests
This commit is contained in:
parent
8db0b16954
commit
813dda3579
22 changed files with 1838 additions and 42 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
target/
|
target/
|
||||||
|
data/gschemas.compiled
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
libsecret = { version = "0.9" }
|
libsecret = { version = "0.9", features = ["v0_19"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
|
||||||
BIN
data/gschemas.compiled
Normal file
BIN
data/gschemas.compiled
Normal file
Binary file not shown.
|
|
@ -2,6 +2,9 @@
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/net/jeena/FeedTheMonkey">
|
<gresource prefix="/net/jeena/FeedTheMonkey">
|
||||||
<file preprocess="xml-stripblanks">ui/window.ui</file>
|
<file preprocess="xml-stripblanks">ui/window.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">ui/login_dialog.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">ui/article_row.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">ui/shortcuts.ui</file>
|
||||||
<file>html/content.html</file>
|
<file>html/content.html</file>
|
||||||
<file>html/content.css</file>
|
<file>html/content.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
|
|
|
||||||
42
data/ui/article_row.blp
Normal file
42
data/ui/article_row.blp
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
template $ArticleRow : Gtk.Box {
|
||||||
|
orientation: vertical;
|
||||||
|
margin-top: 12;
|
||||||
|
margin-bottom: 12;
|
||||||
|
margin-start: 12;
|
||||||
|
margin-end: 12;
|
||||||
|
spacing: 4;
|
||||||
|
|
||||||
|
Box {
|
||||||
|
orientation: horizontal;
|
||||||
|
spacing: 4;
|
||||||
|
|
||||||
|
Label feed_title_label {
|
||||||
|
hexpand: true;
|
||||||
|
xalign: 0;
|
||||||
|
ellipsize: end;
|
||||||
|
styles ["dim-label", "caption"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Label date_label {
|
||||||
|
xalign: 1;
|
||||||
|
styles ["dim-label", "caption"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label title_label {
|
||||||
|
xalign: 0;
|
||||||
|
wrap: true;
|
||||||
|
lines: 2;
|
||||||
|
ellipsize: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
Label excerpt_label {
|
||||||
|
xalign: 0;
|
||||||
|
ellipsize: end;
|
||||||
|
lines: 1;
|
||||||
|
styles ["dim-label", "caption"]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/ui/article_row.ui
Normal file
62
data/ui/article_row.ui
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
DO NOT EDIT!
|
||||||
|
This file was @generated by blueprint-compiler. Instead, edit the
|
||||||
|
corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk" version="4.0"/>
|
||||||
|
<template class="ArticleRow" parent="GtkBox">
|
||||||
|
<property name="orientation">1</property>
|
||||||
|
<property name="margin-top">12</property>
|
||||||
|
<property name="margin-bottom">12</property>
|
||||||
|
<property name="margin-start">12</property>
|
||||||
|
<property name="margin-end">12</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">0</property>
|
||||||
|
<property name="spacing">4</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="feed_title_label">
|
||||||
|
<property name="hexpand">true</property>
|
||||||
|
<property name="xalign">0</property>
|
||||||
|
<property name="ellipsize">3</property>
|
||||||
|
<style>
|
||||||
|
<class name="dim-label"/>
|
||||||
|
<class name="caption"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="date_label">
|
||||||
|
<property name="xalign">1</property>
|
||||||
|
<style>
|
||||||
|
<class name="dim-label"/>
|
||||||
|
<class name="caption"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="title_label">
|
||||||
|
<property name="xalign">0</property>
|
||||||
|
<property name="wrap">true</property>
|
||||||
|
<property name="lines">2</property>
|
||||||
|
<property name="ellipsize">3</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="excerpt_label">
|
||||||
|
<property name="xalign">0</property>
|
||||||
|
<property name="ellipsize">3</property>
|
||||||
|
<property name="lines">1</property>
|
||||||
|
<style>
|
||||||
|
<class name="dim-label"/>
|
||||||
|
<class name="caption"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
41
data/ui/login_dialog.blp
Normal file
41
data/ui/login_dialog.blp
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
template $LoginDialog : Adw.Dialog {
|
||||||
|
title: _("Log In");
|
||||||
|
content-width: 360;
|
||||||
|
|
||||||
|
Adw.ToolbarView {
|
||||||
|
[top]
|
||||||
|
Adw.HeaderBar {}
|
||||||
|
|
||||||
|
Adw.Clamp {
|
||||||
|
margin-top: 12;
|
||||||
|
margin-bottom: 24;
|
||||||
|
margin-start: 12;
|
||||||
|
margin-end: 12;
|
||||||
|
|
||||||
|
Adw.PreferencesGroup {
|
||||||
|
Adw.EntryRow server_url_row {
|
||||||
|
title: _("Server URL");
|
||||||
|
input-hints: no_spellcheck;
|
||||||
|
input-purpose: url;
|
||||||
|
}
|
||||||
|
|
||||||
|
Adw.EntryRow username_row {
|
||||||
|
title: _("Username");
|
||||||
|
input-hints: no_spellcheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
Adw.PasswordEntryRow password_row {
|
||||||
|
title: _("Password");
|
||||||
|
}
|
||||||
|
|
||||||
|
Adw.ButtonRow login_button {
|
||||||
|
title: _("Log In");
|
||||||
|
styles ["suggested-action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
data/ui/login_dialog.ui
Normal file
58
data/ui/login_dialog.ui
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
DO NOT EDIT!
|
||||||
|
This file was @generated by blueprint-compiler. Instead, edit the
|
||||||
|
corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk" version="4.0"/>
|
||||||
|
<template class="LoginDialog" parent="AdwDialog">
|
||||||
|
<property name="title" translatable="yes">Log In</property>
|
||||||
|
<property name="content-width">360</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwToolbarView">
|
||||||
|
<child type="top">
|
||||||
|
<object class="AdwHeaderBar"></object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwClamp">
|
||||||
|
<property name="margin-top">12</property>
|
||||||
|
<property name="margin-bottom">24</property>
|
||||||
|
<property name="margin-start">12</property>
|
||||||
|
<property name="margin-end">12</property>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPreferencesGroup">
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="server_url_row">
|
||||||
|
<property name="title" translatable="yes">Server URL</property>
|
||||||
|
<property name="input-hints">2</property>
|
||||||
|
<property name="input-purpose">5</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwEntryRow" id="username_row">
|
||||||
|
<property name="title" translatable="yes">Username</property>
|
||||||
|
<property name="input-hints">2</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwPasswordEntryRow" id="password_row">
|
||||||
|
<property name="title" translatable="yes">Password</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="AdwButtonRow" id="login_button">
|
||||||
|
<property name="title" translatable="yes">Log In</property>
|
||||||
|
<style>
|
||||||
|
<class name="suggested-action"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</template>
|
||||||
|
</interface>
|
||||||
107
data/ui/shortcuts.blp
Normal file
107
data/ui/shortcuts.blp
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using Gtk 4.0;
|
||||||
|
using Adw 1;
|
||||||
|
|
||||||
|
ShortcutsWindow help_overlay {
|
||||||
|
modal: true;
|
||||||
|
|
||||||
|
ShortcutsSection {
|
||||||
|
section-name: "shortcuts";
|
||||||
|
max-height: 10;
|
||||||
|
|
||||||
|
ShortcutsGroup {
|
||||||
|
title: _("Navigation");
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Next article");
|
||||||
|
accelerator: "j Right";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Previous article");
|
||||||
|
accelerator: "k Left";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsGroup {
|
||||||
|
title: _("Article");
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Open in browser");
|
||||||
|
accelerator: "Return n";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Mark as unread");
|
||||||
|
accelerator: "u";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Reload articles");
|
||||||
|
accelerator: "r";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsGroup {
|
||||||
|
title: _("View");
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Scroll down");
|
||||||
|
accelerator: "space Page_Down";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Scroll up");
|
||||||
|
accelerator: "Page_Up";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Scroll to top");
|
||||||
|
accelerator: "Home";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Scroll to bottom");
|
||||||
|
accelerator: "End";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Zoom in");
|
||||||
|
accelerator: "<Control>plus";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Zoom out");
|
||||||
|
accelerator: "<Control>minus";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Reset zoom");
|
||||||
|
accelerator: "<Control>0";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Toggle fullscreen");
|
||||||
|
accelerator: "F11";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsGroup {
|
||||||
|
title: _("Application");
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Keyboard shortcuts");
|
||||||
|
accelerator: "F1";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Close window");
|
||||||
|
accelerator: "<Control>w";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortcutsShortcut {
|
||||||
|
title: _("Quit");
|
||||||
|
accelerator: "<Control>q";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
data/ui/shortcuts.ui
Normal file
134
data/ui/shortcuts.ui
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
DO NOT EDIT!
|
||||||
|
This file was @generated by blueprint-compiler. Instead, edit the
|
||||||
|
corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
|
-->
|
||||||
|
<interface>
|
||||||
|
<requires lib="gtk" version="4.0"/>
|
||||||
|
<object class="GtkShortcutsWindow" id="help_overlay">
|
||||||
|
<property name="modal">true</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsSection">
|
||||||
|
<property name="section-name">shortcuts</property>
|
||||||
|
<property name="max-height">10</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="title" translatable="yes">Navigation</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Next article</property>
|
||||||
|
<property name="accelerator">j Right</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Previous article</property>
|
||||||
|
<property name="accelerator">k Left</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="title" translatable="yes">Article</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Open in browser</property>
|
||||||
|
<property name="accelerator">Return n</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Mark as unread</property>
|
||||||
|
<property name="accelerator">u</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Reload articles</property>
|
||||||
|
<property name="accelerator">r</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="title" translatable="yes">View</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Scroll down</property>
|
||||||
|
<property name="accelerator">space Page_Down</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Scroll up</property>
|
||||||
|
<property name="accelerator">Page_Up</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Scroll to top</property>
|
||||||
|
<property name="accelerator">Home</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Scroll to bottom</property>
|
||||||
|
<property name="accelerator">End</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Zoom in</property>
|
||||||
|
<property name="accelerator"><Control>plus</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Zoom out</property>
|
||||||
|
<property name="accelerator"><Control>minus</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Reset zoom</property>
|
||||||
|
<property name="accelerator"><Control>0</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Toggle fullscreen</property>
|
||||||
|
<property name="accelerator">F11</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsGroup">
|
||||||
|
<property name="title" translatable="yes">Application</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Keyboard shortcuts</property>
|
||||||
|
<property name="accelerator">F1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Close window</property>
|
||||||
|
<property name="accelerator"><Control>w</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="title" translatable="yes">Quit</property>
|
||||||
|
<property name="accelerator"><Control>q</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</interface>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Gtk 4.0;
|
using Gtk 4.0;
|
||||||
using Adw 1;
|
using Adw 1;
|
||||||
|
using WebKit 6.0;
|
||||||
|
|
||||||
template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
|
template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
|
||||||
default-width: 900;
|
default-width: 900;
|
||||||
|
|
@ -38,10 +39,56 @@ template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Adw.StatusPage placeholder_page {
|
Stack sidebar_content {
|
||||||
icon-name: "rss-symbolic";
|
StackPage {
|
||||||
title: _("FeedTheMonkey");
|
name: "placeholder";
|
||||||
description: _("Log in to load your articles");
|
child: Adw.StatusPage {
|
||||||
|
icon-name: "rss-symbolic";
|
||||||
|
title: _("FeedTheMonkey");
|
||||||
|
description: _("Log in to load your articles");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
StackPage {
|
||||||
|
name: "loading";
|
||||||
|
child: Adw.StatusPage {
|
||||||
|
paintable: Adw.SpinnerPaintable {};
|
||||||
|
title: _("Loading…");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
StackPage {
|
||||||
|
name: "empty";
|
||||||
|
child: Adw.StatusPage {
|
||||||
|
icon-name: "rss-symbolic";
|
||||||
|
title: _("No Unread Articles");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
StackPage {
|
||||||
|
name: "error";
|
||||||
|
child: Adw.StatusPage error_status {
|
||||||
|
icon-name: "network-error-symbolic";
|
||||||
|
title: _("Could Not Load Articles");
|
||||||
|
|
||||||
|
Button {
|
||||||
|
label: _("Try Again");
|
||||||
|
halign: center;
|
||||||
|
action-name: "win.reload";
|
||||||
|
styles ["pill", "suggested-action"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
StackPage {
|
||||||
|
name: "list";
|
||||||
|
child: ScrolledWindow {
|
||||||
|
hscrollbar-policy: never;
|
||||||
|
ListView article_list_view {
|
||||||
|
single-click-activate: true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -62,9 +109,19 @@ template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Adw.StatusPage {
|
Stack content_stack {
|
||||||
icon-name: "document-open-symbolic";
|
StackPage {
|
||||||
title: _("No Article Selected");
|
name: "empty";
|
||||||
|
child: Adw.StatusPage {
|
||||||
|
icon-name: "document-open-symbolic";
|
||||||
|
title: _("No Article Selected");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
StackPage {
|
||||||
|
name: "webview";
|
||||||
|
child: WebKit.WebView web_view {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,80 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwStatusPage" id="placeholder_page">
|
<object class="GtkStack" id="sidebar_content">
|
||||||
<property name="icon-name">rss-symbolic</property>
|
<child>
|
||||||
<property name="title" translatable="yes">FeedTheMonkey</property>
|
<object class="GtkStackPage">
|
||||||
<property name="description" translatable="yes">Log in to load your articles</property>
|
<property name="name">placeholder</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwStatusPage">
|
||||||
|
<property name="icon-name">rss-symbolic</property>
|
||||||
|
<property name="title" translatable="yes">FeedTheMonkey</property>
|
||||||
|
<property name="description" translatable="yes">Log in to load your articles</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">loading</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwStatusPage">
|
||||||
|
<property name="paintable">
|
||||||
|
<object class="AdwSpinnerPaintable"></object>
|
||||||
|
</property>
|
||||||
|
<property name="title" translatable="yes">Loading…</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">empty</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwStatusPage">
|
||||||
|
<property name="icon-name">rss-symbolic</property>
|
||||||
|
<property name="title" translatable="yes">No Unread Articles</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">error</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwStatusPage" id="error_status">
|
||||||
|
<property name="icon-name">network-error-symbolic</property>
|
||||||
|
<property name="title" translatable="yes">Could Not Load Articles</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton">
|
||||||
|
<property name="label" translatable="yes">Try Again</property>
|
||||||
|
<property name="halign">3</property>
|
||||||
|
<property name="action-name">win.reload</property>
|
||||||
|
<style>
|
||||||
|
<class name="pill"/>
|
||||||
|
<class name="suggested-action"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">list</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="hscrollbar-policy">2</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkListView" id="article_list_view">
|
||||||
|
<property name="single-click-activate">true</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
|
|
@ -82,9 +152,26 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwStatusPage">
|
<object class="GtkStack" id="content_stack">
|
||||||
<property name="icon-name">document-open-symbolic</property>
|
<child>
|
||||||
<property name="title" translatable="yes">No Article Selected</property>
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">empty</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="AdwStatusPage">
|
||||||
|
<property name="icon-name">document-open-symbolic</property>
|
||||||
|
<property name="title" translatable="yes">No Article Selected</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkStackPage">
|
||||||
|
<property name="name">webview</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="WebKitWebView" id="web_view"></object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
margin: 1em 2em;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #222;
|
color: #222;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -11,8 +16,86 @@ body {
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
color: #78aeed;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
#header {
|
||||||
font-size: 1.4em;
|
padding: 1.5em 2em 1em;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#header {
|
||||||
|
border-bottom-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#feed-title {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#meta {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#meta span + span::before {
|
||||||
|
content: ' · ';
|
||||||
|
}
|
||||||
|
|
||||||
|
#link {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
padding: 0 2em 2em;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#content pre {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#content blockquote {
|
||||||
|
border-left: 3px solid rgba(0,0,0,0.2);
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#content blockquote {
|
||||||
|
border-left-color: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,44 @@
|
||||||
<link rel="stylesheet" href="content.css">
|
<link rel="stylesheet" href="content.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="header">
|
||||||
|
<div id="feed-title"></div>
|
||||||
|
<h1 id="title"></h1>
|
||||||
|
<div id="meta">
|
||||||
|
<span id="author"></span>
|
||||||
|
<span id="date"></span>
|
||||||
|
</div>
|
||||||
|
<a id="link" href="#" onclick="window.location='feedthemonkey:open'; return false;">Open in Browser</a>
|
||||||
|
</div>
|
||||||
<div id="content"></div>
|
<div id="content"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setArticle(article) {
|
function setArticle(article) {
|
||||||
document.title = article.title || '';
|
document.getElementById('feed-title').textContent = article.feed_title || '';
|
||||||
document.getElementById('content').innerHTML =
|
document.getElementById('title').textContent = article.title || '';
|
||||||
'<h1>' + (article.title || '') + '</h1>' +
|
document.getElementById('author').textContent = article.author || '';
|
||||||
(article.content || '');
|
document.getElementById('content').innerHTML = article.content || '';
|
||||||
|
|
||||||
|
var ts = article.updated;
|
||||||
|
if (ts) {
|
||||||
|
document.getElementById('date').textContent = new Date(ts * 1000).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
window._article = article;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkKey(e) {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'j') {
|
||||||
|
window.location = 'feedthemonkey:next';
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'k') {
|
||||||
|
window.location = 'feedthemonkey:previous';
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'n') {
|
||||||
|
window.location = 'feedthemonkey:open';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', checkKey);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
204
src/api.rs
Normal file
204
src/api.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Api {
|
||||||
|
client: Client,
|
||||||
|
pub server_url: String,
|
||||||
|
pub auth_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StreamContents {
|
||||||
|
items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Item {
|
||||||
|
id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
origin: Option<Origin>,
|
||||||
|
canonical: Option<Vec<Link>>,
|
||||||
|
published: Option<i64>,
|
||||||
|
summary: Option<Summary>,
|
||||||
|
author: Option<String>,
|
||||||
|
categories: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Origin {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Link {
|
||||||
|
href: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Summary {
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::model::Article;
|
||||||
|
|
||||||
|
impl Api {
|
||||||
|
pub async fn login(
|
||||||
|
server_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let client = Client::new();
|
||||||
|
let url = format!("{}/accounts/ClientLogin", server_url.trim_end_matches('/'));
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.form(&[("Email", username), ("Passwd", password)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("Login failed ({}): {}", status, body.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_token = body
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("Auth="))
|
||||||
|
.ok_or_else(|| "No Auth token in response".to_string())?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
server_url: server_url.trim_end_matches('/').to_string(),
|
||||||
|
auth_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_write_token(&self) -> Result<String, String> {
|
||||||
|
let url = format!("{}/reader/api/0/token", self.server_url);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Failed to fetch write token: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.text().await.map_err(|e| e.to_string()).map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_unread(&self) -> Result<Vec<Article>, String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/reader/api/0/stream/contents/reading-list\
|
||||||
|
?xt=user/-/state/com.google/read&n=200&output=json",
|
||||||
|
self.server_url
|
||||||
|
);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Failed to fetch articles: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream: StreamContents = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(stream.items.into_iter().map(|item| {
|
||||||
|
let unread = !item.categories.as_deref().unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.contains("user/-/state/com.google/read"));
|
||||||
|
let content = item.summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.content.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let excerpt = plain_text_excerpt(&content, 150);
|
||||||
|
Article {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title.unwrap_or_default(),
|
||||||
|
feed_title: item.origin.as_ref().and_then(|o| o.title.clone()).unwrap_or_default(),
|
||||||
|
author: item.author.unwrap_or_default(),
|
||||||
|
link: item.canonical.as_ref()
|
||||||
|
.and_then(|v| v.first())
|
||||||
|
.map(|l| l.href.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
published: item.published.unwrap_or(0),
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
unread,
|
||||||
|
}
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_read(
|
||||||
|
&self,
|
||||||
|
write_token: &str,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.edit_tag(write_token, item_id, "a", "user/-/state/com.google/read").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_unread(
|
||||||
|
&self,
|
||||||
|
write_token: &str,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.edit_tag(write_token, item_id, "r", "user/-/state/com.google/read").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_tag(
|
||||||
|
&self,
|
||||||
|
write_token: &str,
|
||||||
|
item_id: &str,
|
||||||
|
action_key: &str,
|
||||||
|
state: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = format!("{}/reader/api/0/edit-tag", self.server_url);
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
|
||||||
|
.form(&[("i", item_id), (action_key, state), ("T", write_token)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err("UNAUTHORIZED".to_string());
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("edit-tag failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_text_excerpt(html: &str, max_chars: usize) -> String {
|
||||||
|
// Very simple HTML stripper — remove tags, collapse whitespace
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for ch in html.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
c if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if collapsed.chars().count() <= max_chars {
|
||||||
|
collapsed
|
||||||
|
} else {
|
||||||
|
collapsed.chars().take(max_chars).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app.rs
77
src/app.rs
|
|
@ -1,4 +1,5 @@
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
|
|
||||||
use crate::window::FeedTheMonkeyWindow;
|
use crate::window::FeedTheMonkeyWindow;
|
||||||
|
|
||||||
|
|
@ -51,6 +52,46 @@ mod imp {
|
||||||
gio::resources_register(&resource);
|
gio::resources_register(&resource);
|
||||||
|
|
||||||
let window = FeedTheMonkeyWindow::new(app.upcast_ref());
|
let window = FeedTheMonkeyWindow::new(app.upcast_ref());
|
||||||
|
|
||||||
|
// Shortcuts overlay
|
||||||
|
let builder = gtk4::Builder::from_resource(
|
||||||
|
"/net/jeena/FeedTheMonkey/ui/shortcuts.ui",
|
||||||
|
);
|
||||||
|
let overlay: gtk4::ShortcutsWindow = builder.object("help_overlay").unwrap();
|
||||||
|
window.set_help_overlay(Some(&overlay));
|
||||||
|
|
||||||
|
setup_shortcuts(&window);
|
||||||
|
|
||||||
|
// About action on app
|
||||||
|
let app_weak = app.downgrade();
|
||||||
|
let about_action = gio::SimpleAction::new("about", None);
|
||||||
|
about_action.connect_activate(move |_, _| {
|
||||||
|
if let Some(app) = app_weak.upgrade() {
|
||||||
|
let win = app.active_window();
|
||||||
|
let dialog = libadwaita::AboutDialog::builder()
|
||||||
|
.application_name("FeedTheMonkey")
|
||||||
|
.application_icon("feedthemonkey")
|
||||||
|
.version("3.0.0")
|
||||||
|
.copyright("© Jeena Paradies")
|
||||||
|
.license_type(gtk4::License::Gpl30)
|
||||||
|
.website("https://github.com/jeena/FeedTheMonkey")
|
||||||
|
.developer_name("Jeena Paradies")
|
||||||
|
.build();
|
||||||
|
dialog.present(win.as_ref().map(|w| w.upcast_ref::<gtk4::Widget>()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.add_action(&about_action);
|
||||||
|
|
||||||
|
// Quit action
|
||||||
|
let app_weak = app.downgrade();
|
||||||
|
let quit_action = gio::SimpleAction::new("quit", None);
|
||||||
|
quit_action.connect_activate(move |_, _| {
|
||||||
|
if let Some(app) = app_weak.upgrade() {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.add_action(&quit_action);
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,3 +99,39 @@ mod imp {
|
||||||
impl GtkApplicationImpl for FeedTheMonkeyApp {}
|
impl GtkApplicationImpl for FeedTheMonkeyApp {}
|
||||||
impl AdwApplicationImpl for FeedTheMonkeyApp {}
|
impl AdwApplicationImpl for FeedTheMonkeyApp {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_shortcuts(window: &FeedTheMonkeyWindow) {
|
||||||
|
use gtk4::gdk::{Key, ModifierType};
|
||||||
|
|
||||||
|
let controller = gtk4::ShortcutController::new();
|
||||||
|
controller.set_scope(gtk4::ShortcutScope::Global);
|
||||||
|
|
||||||
|
let add = |controller: >k4::ShortcutController,
|
||||||
|
key: Key,
|
||||||
|
mods: ModifierType,
|
||||||
|
action_name: &str| {
|
||||||
|
let trigger = gtk4::KeyvalTrigger::new(key, mods);
|
||||||
|
let action = gtk4::NamedAction::new(action_name);
|
||||||
|
let shortcut = gtk4::Shortcut::new(Some(trigger), Some(action));
|
||||||
|
controller.add_shortcut(shortcut);
|
||||||
|
};
|
||||||
|
|
||||||
|
add(&controller, Key::j, ModifierType::empty(), "win.next-article");
|
||||||
|
add(&controller, Key::Right, ModifierType::empty(), "win.next-article");
|
||||||
|
add(&controller, Key::k, ModifierType::empty(), "win.prev-article");
|
||||||
|
add(&controller, Key::Left, ModifierType::empty(), "win.prev-article");
|
||||||
|
add(&controller, Key::r, ModifierType::empty(), "win.reload");
|
||||||
|
add(&controller, Key::u, ModifierType::empty(), "win.mark-unread");
|
||||||
|
add(&controller, Key::Return, ModifierType::empty(), "win.open-in-browser");
|
||||||
|
add(&controller, Key::n, ModifierType::empty(), "win.open-in-browser");
|
||||||
|
add(&controller, Key::plus, ModifierType::CONTROL_MASK, "win.zoom-in");
|
||||||
|
add(&controller, Key::equal, ModifierType::CONTROL_MASK, "win.zoom-in");
|
||||||
|
add(&controller, Key::minus, ModifierType::CONTROL_MASK, "win.zoom-out");
|
||||||
|
add(&controller, Key::_0, ModifierType::CONTROL_MASK, "win.zoom-reset");
|
||||||
|
add(&controller, Key::F11, ModifierType::empty(), "win.toggle-fullscreen");
|
||||||
|
add(&controller, Key::w, ModifierType::CONTROL_MASK, "window.close");
|
||||||
|
add(&controller, Key::q, ModifierType::CONTROL_MASK, "app.quit");
|
||||||
|
add(&controller, Key::F1, ModifierType::empty(), "win.show-help-overlay");
|
||||||
|
|
||||||
|
window.add_controller(controller);
|
||||||
|
}
|
||||||
|
|
|
||||||
132
src/article_row.rs
Normal file
132
src/article_row.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::subclass::prelude::ObjectSubclassIsExt;
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct ArticleRow(ObjectSubclass<imp::ArticleRow>)
|
||||||
|
@extends gtk4::Box, gtk4::Widget,
|
||||||
|
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Orientable;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArticleRow {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
glib::Object::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind(&self, obj: &crate::model::ArticleObject) {
|
||||||
|
self.imp().bind(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unbind(&self) {
|
||||||
|
self.imp().unbind();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::ArticleObject;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use gtk4::CompositeTemplate;
|
||||||
|
use glib::object::ObjectExt;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default)]
|
||||||
|
#[template(resource = "/net/jeena/FeedTheMonkey/ui/article_row.ui")]
|
||||||
|
pub struct ArticleRow {
|
||||||
|
#[template_child]
|
||||||
|
pub feed_title_label: TemplateChild<gtk4::Label>,
|
||||||
|
#[template_child]
|
||||||
|
pub date_label: TemplateChild<gtk4::Label>,
|
||||||
|
#[template_child]
|
||||||
|
pub title_label: TemplateChild<gtk4::Label>,
|
||||||
|
#[template_child]
|
||||||
|
pub excerpt_label: TemplateChild<gtk4::Label>,
|
||||||
|
|
||||||
|
pub bindings: RefCell<Vec<glib::Binding>>,
|
||||||
|
pub unread_handler: RefCell<Option<(ArticleObject, glib::SignalHandlerId)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for ArticleRow {
|
||||||
|
const NAME: &'static str = "ArticleRow";
|
||||||
|
type Type = super::ArticleRow;
|
||||||
|
type ParentType = gtk4::Box;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for ArticleRow {}
|
||||||
|
impl WidgetImpl for ArticleRow {}
|
||||||
|
impl BoxImpl for ArticleRow {}
|
||||||
|
|
||||||
|
impl ArticleRow {
|
||||||
|
pub fn bind(&self, obj: &ArticleObject) {
|
||||||
|
let article = obj.article();
|
||||||
|
|
||||||
|
self.feed_title_label.set_text(&article.feed_title);
|
||||||
|
self.title_label.set_text(&article.title);
|
||||||
|
self.excerpt_label.set_text(&article.excerpt);
|
||||||
|
self.date_label.set_text(&relative_time(article.published));
|
||||||
|
|
||||||
|
self.update_read_style(article.unread);
|
||||||
|
drop(article);
|
||||||
|
|
||||||
|
// Watch for unread state changes
|
||||||
|
let title_label = self.title_label.clone();
|
||||||
|
let id = obj.connect_notify_local(Some("unread"), move |obj, _| {
|
||||||
|
let unread = obj.article().unread;
|
||||||
|
if unread {
|
||||||
|
title_label.remove_css_class("dim-label");
|
||||||
|
} else {
|
||||||
|
title_label.add_css_class("dim-label");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*self.unread_handler.borrow_mut() = Some((obj.clone(), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unbind(&self) {
|
||||||
|
if let Some((obj, id)) = self.unread_handler.borrow_mut().take() {
|
||||||
|
obj.disconnect(id);
|
||||||
|
}
|
||||||
|
for b in self.bindings.borrow_mut().drain(..) {
|
||||||
|
b.unbind();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_read_style(&self, unread: bool) {
|
||||||
|
if unread {
|
||||||
|
self.title_label.remove_css_class("dim-label");
|
||||||
|
} else {
|
||||||
|
self.title_label.add_css_class("dim-label");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_time(unix: i64) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let diff = now - unix;
|
||||||
|
if diff < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if diff < 3600 {
|
||||||
|
let m = diff / 60;
|
||||||
|
format!("{m}m ago")
|
||||||
|
} else if diff < 86400 {
|
||||||
|
let h = diff / 3600;
|
||||||
|
format!("{h}h ago")
|
||||||
|
} else if diff < 172800 {
|
||||||
|
"Yesterday".to_string()
|
||||||
|
} else {
|
||||||
|
let d = diff / 86400;
|
||||||
|
format!("{d}d ago")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/credentials.rs
Normal file
65
src/credentials.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
use libsecret::{prelude::*, SchemaAttributeType, SchemaFlags, SearchFlags};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const SCHEMA_NAME: &str = "net.jeena.FeedTheMonkey";
|
||||||
|
const ATTR_SERVER: &str = "server-url";
|
||||||
|
const ATTR_USERNAME: &str = "username";
|
||||||
|
const LABEL: &str = "FeedTheMonkey credentials";
|
||||||
|
|
||||||
|
fn schema() -> libsecret::Schema {
|
||||||
|
libsecret::Schema::new(
|
||||||
|
SCHEMA_NAME,
|
||||||
|
SchemaFlags::NONE,
|
||||||
|
HashMap::from([
|
||||||
|
(ATTR_SERVER, SchemaAttributeType::String),
|
||||||
|
(ATTR_USERNAME, SchemaAttributeType::String),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_credentials(server_url: &str, username: &str, password: &str) {
|
||||||
|
let schema = schema();
|
||||||
|
let attrs = HashMap::from([
|
||||||
|
(ATTR_SERVER, server_url),
|
||||||
|
(ATTR_USERNAME, username),
|
||||||
|
]);
|
||||||
|
if let Err(e) = libsecret::password_store_sync(
|
||||||
|
Some(&schema),
|
||||||
|
attrs,
|
||||||
|
Some(libsecret::COLLECTION_DEFAULT.as_str()),
|
||||||
|
LABEL,
|
||||||
|
password,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
) {
|
||||||
|
eprintln!("Failed to store credentials: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_credentials() -> Option<(String, String, String)> {
|
||||||
|
let schema = schema();
|
||||||
|
let items = libsecret::password_search_sync(
|
||||||
|
Some(&schema),
|
||||||
|
HashMap::new(),
|
||||||
|
SearchFlags::LOAD_SECRETS | SearchFlags::UNLOCK,
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
).ok()?;
|
||||||
|
|
||||||
|
let item = items.into_iter().next()?;
|
||||||
|
let attrs = item.attributes();
|
||||||
|
let server_url = attrs.get(ATTR_SERVER)?.to_string();
|
||||||
|
let username = attrs.get(ATTR_USERNAME)?.to_string();
|
||||||
|
let secret = item.retrieve_secret_sync(gio::Cancellable::NONE).ok()??;
|
||||||
|
let password = secret.text()?.to_string();
|
||||||
|
Some((server_url, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_credentials() {
|
||||||
|
let schema = schema();
|
||||||
|
if let Err(e) = libsecret::password_clear_sync(
|
||||||
|
Some(&schema),
|
||||||
|
HashMap::new(),
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
) {
|
||||||
|
eprintln!("Failed to clear credentials: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/login_dialog.rs
Normal file
105
src/login_dialog.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
use gtk4::glib;
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct LoginDialog(ObjectSubclass<imp::LoginDialog>)
|
||||||
|
@extends libadwaita::Dialog, gtk4::Widget,
|
||||||
|
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginDialog {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
glib::Object::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use gtk4::CompositeTemplate;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
|
use libadwaita::subclass::prelude::*;
|
||||||
|
|
||||||
|
#[derive(CompositeTemplate, Default)]
|
||||||
|
#[template(resource = "/net/jeena/FeedTheMonkey/ui/login_dialog.ui")]
|
||||||
|
pub struct LoginDialog {
|
||||||
|
#[template_child]
|
||||||
|
pub server_url_row: TemplateChild<libadwaita::EntryRow>,
|
||||||
|
#[template_child]
|
||||||
|
pub username_row: TemplateChild<libadwaita::EntryRow>,
|
||||||
|
#[template_child]
|
||||||
|
pub password_row: TemplateChild<libadwaita::PasswordEntryRow>,
|
||||||
|
#[template_child]
|
||||||
|
pub login_button: TemplateChild<libadwaita::ButtonRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for LoginDialog {
|
||||||
|
const NAME: &'static str = "LoginDialog";
|
||||||
|
type Type = super::LoginDialog;
|
||||||
|
type ParentType = libadwaita::Dialog;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for LoginDialog {
|
||||||
|
fn signals() -> &'static [glib::subclass::Signal] {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static SIGNALS: OnceLock<Vec<glib::subclass::Signal>> = OnceLock::new();
|
||||||
|
SIGNALS.get_or_init(|| {
|
||||||
|
vec![
|
||||||
|
glib::subclass::Signal::builder("logged-in")
|
||||||
|
.param_types([
|
||||||
|
String::static_type(),
|
||||||
|
String::static_type(),
|
||||||
|
String::static_type(),
|
||||||
|
])
|
||||||
|
.build(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constructed(&self) {
|
||||||
|
self.parent_constructed();
|
||||||
|
|
||||||
|
let obj_weak = self.obj().downgrade();
|
||||||
|
self.login_button.connect_activated(move |_| {
|
||||||
|
if let Some(dialog) = obj_weak.upgrade() {
|
||||||
|
dialog.imp().on_login_clicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also trigger on Enter in password row
|
||||||
|
let obj_weak2 = self.obj().downgrade();
|
||||||
|
self.password_row.connect_apply(move |_| {
|
||||||
|
if let Some(dialog) = obj_weak2.upgrade() {
|
||||||
|
dialog.imp().on_login_clicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginDialog {
|
||||||
|
fn on_login_clicked(&self) {
|
||||||
|
let server_url = self.server_url_row.text().trim().to_string();
|
||||||
|
let username = self.username_row.text().trim().to_string();
|
||||||
|
let password = self.password_row.text().to_string();
|
||||||
|
|
||||||
|
if server_url.is_empty() || username.is_empty() || password.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.obj().close();
|
||||||
|
self.obj().emit_by_name::<()>("logged-in", &[&server_url, &username, &password]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetImpl for LoginDialog {}
|
||||||
|
impl AdwDialogImpl for LoginDialog {}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod article_row;
|
||||||
|
mod credentials;
|
||||||
|
mod login_dialog;
|
||||||
|
mod model;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
// In development builds, point GSettings at the locally compiled schema.
|
// In development builds, point GSettings at the locally compiled schema.
|
||||||
// In release/installed builds the schema is found via the system path.
|
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR"));
|
std::env::set_var("GSETTINGS_SCHEMA_DIR", env!("GSETTINGS_SCHEMA_DIR"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
src/model.rs
Normal file
76
src/model.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use gtk4::glib;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Article {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub feed_title: String,
|
||||||
|
pub author: String,
|
||||||
|
pub link: String,
|
||||||
|
pub published: i64,
|
||||||
|
pub content: String,
|
||||||
|
pub excerpt: String,
|
||||||
|
pub unread: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GObject wrapper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct ArticleObject(ObjectSubclass<imp::ArticleObject>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArticleObject {
|
||||||
|
pub fn new(article: Article) -> Self {
|
||||||
|
let obj: Self = glib::Object::new();
|
||||||
|
*obj.imp().article.borrow_mut() = article;
|
||||||
|
obj
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn article(&self) -> std::cell::Ref<'_, Article> {
|
||||||
|
self.imp().article.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_unread(&self, unread: bool) {
|
||||||
|
self.imp().article.borrow_mut().unread = unread;
|
||||||
|
self.notify("unread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ArticleObject {
|
||||||
|
pub article: RefCell<Article>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for ArticleObject {
|
||||||
|
const NAME: &'static str = "ArticleObject";
|
||||||
|
type Type = super::ArticleObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for ArticleObject {
|
||||||
|
fn properties() -> &'static [glib::ParamSpec] {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static PROPS: OnceLock<Vec<glib::ParamSpec>> = OnceLock::new();
|
||||||
|
PROPS.get_or_init(|| {
|
||||||
|
vec![
|
||||||
|
glib::ParamSpecBoolean::builder("unread")
|
||||||
|
.read_only()
|
||||||
|
.build(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
|
||||||
|
match pspec.name() {
|
||||||
|
"unread" => self.article.borrow().unread.to_value(),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/window.rs
466
src/window.rs
|
|
@ -1,4 +1,7 @@
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::subclass::prelude::*;
|
||||||
|
use gtk4::{gio, glib};
|
||||||
|
use webkit6::prelude::{PolicyDecisionExt, WebViewExt};
|
||||||
|
|
||||||
glib::wrapper! {
|
glib::wrapper! {
|
||||||
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
|
pub struct FeedTheMonkeyWindow(ObjectSubclass<imp::FeedTheMonkeyWindow>)
|
||||||
|
|
@ -15,10 +18,16 @@ impl FeedTheMonkeyWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod imp {
|
pub mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::api::Api;
|
||||||
|
use crate::credentials;
|
||||||
|
use crate::login_dialog::LoginDialog;
|
||||||
|
use crate::model::ArticleObject;
|
||||||
use gtk4::CompositeTemplate;
|
use gtk4::CompositeTemplate;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
use libadwaita::subclass::prelude::*;
|
use libadwaita::subclass::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
#[derive(CompositeTemplate, Default)]
|
#[derive(CompositeTemplate, Default)]
|
||||||
#[template(resource = "/net/jeena/FeedTheMonkey/ui/window.ui")]
|
#[template(resource = "/net/jeena/FeedTheMonkey/ui/window.ui")]
|
||||||
|
|
@ -35,6 +44,23 @@ mod imp {
|
||||||
pub content_page: TemplateChild<libadwaita::NavigationPage>,
|
pub content_page: TemplateChild<libadwaita::NavigationPage>,
|
||||||
#[template_child]
|
#[template_child]
|
||||||
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
|
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
|
||||||
|
#[template_child]
|
||||||
|
pub sidebar_content: TemplateChild<gtk4::Stack>,
|
||||||
|
#[template_child]
|
||||||
|
pub article_list_view: TemplateChild<gtk4::ListView>,
|
||||||
|
#[template_child]
|
||||||
|
pub content_stack: TemplateChild<gtk4::Stack>,
|
||||||
|
#[template_child]
|
||||||
|
pub web_view: TemplateChild<webkit6::WebView>,
|
||||||
|
#[template_child]
|
||||||
|
pub error_status: TemplateChild<libadwaita::StatusPage>,
|
||||||
|
|
||||||
|
pub api: RefCell<Option<Api>>,
|
||||||
|
pub write_token: RefCell<Option<String>>,
|
||||||
|
pub article_store: RefCell<Option<gio::ListStore>>,
|
||||||
|
pub selection: RefCell<Option<gtk4::SingleSelection>>,
|
||||||
|
pub current_article_id: RefCell<Option<String>>,
|
||||||
|
pub mark_unread_guard: RefCell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
|
|
@ -45,6 +71,28 @@ mod imp {
|
||||||
|
|
||||||
fn class_init(klass: &mut Self::Class) {
|
fn class_init(klass: &mut Self::Class) {
|
||||||
klass.bind_template();
|
klass.bind_template();
|
||||||
|
klass.install_action("win.reload", None, |win, _, _| win.imp().do_reload());
|
||||||
|
klass.install_action("win.logout", None, |win, _, _| win.imp().do_logout());
|
||||||
|
klass.install_action("win.mark-unread", None, |win, _, _| win.imp().do_mark_unread());
|
||||||
|
klass.install_action("win.open-in-browser", None, |win, _, _| {
|
||||||
|
win.imp().do_open_in_browser()
|
||||||
|
});
|
||||||
|
klass.install_action("win.next-article", None, |win, _, _| {
|
||||||
|
win.imp().navigate_by(1)
|
||||||
|
});
|
||||||
|
klass.install_action("win.prev-article", None, |win, _, _| {
|
||||||
|
win.imp().navigate_by(-1)
|
||||||
|
});
|
||||||
|
klass.install_action("win.zoom-in", None, |win, _, _| win.imp().zoom(1.1));
|
||||||
|
klass.install_action("win.zoom-out", None, |win, _, _| win.imp().zoom(1.0 / 1.1));
|
||||||
|
klass.install_action("win.zoom-reset", None, |win, _, _| win.imp().zoom_reset());
|
||||||
|
klass.install_action("win.toggle-fullscreen", None, |win, _, _| {
|
||||||
|
if win.is_fullscreen() {
|
||||||
|
win.unfullscreen();
|
||||||
|
} else {
|
||||||
|
win.fullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
|
@ -55,42 +103,422 @@ mod imp {
|
||||||
impl ObjectImpl for FeedTheMonkeyWindow {
|
impl ObjectImpl for FeedTheMonkeyWindow {
|
||||||
fn constructed(&self) {
|
fn constructed(&self) {
|
||||||
self.parent_constructed();
|
self.parent_constructed();
|
||||||
self.restore_window_state();
|
self.setup_window_state();
|
||||||
|
self.setup_list();
|
||||||
|
self.setup_webview();
|
||||||
|
self.auto_login();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedTheMonkeyWindow {
|
impl FeedTheMonkeyWindow {
|
||||||
fn restore_window_state(&self) {
|
// ── Window state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn setup_window_state(&self) {
|
||||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||||
let window = self.obj();
|
let window = self.obj();
|
||||||
|
let w = settings.int("window-width");
|
||||||
let width = settings.int("window-width");
|
let h = settings.int("window-height");
|
||||||
let height = settings.int("window-height");
|
window.set_default_size(w, h);
|
||||||
let maximized = settings.boolean("window-maximized");
|
if settings.boolean("window-maximized") {
|
||||||
|
|
||||||
window.set_default_size(width, height);
|
|
||||||
if maximized {
|
|
||||||
window.maximize();
|
window.maximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save state when the window closes
|
// Restore zoom
|
||||||
let settings_clone = settings.clone();
|
let zoom = settings.double("zoom-level");
|
||||||
|
self.web_view.set_zoom_level(zoom);
|
||||||
|
|
||||||
|
let s = settings.clone();
|
||||||
window.connect_close_request(move |win| {
|
window.connect_close_request(move |win| {
|
||||||
if !win.is_maximized() {
|
if !win.is_maximized() {
|
||||||
let (w, h) = (win.width(), win.height());
|
s.set_int("window-width", win.width()).ok();
|
||||||
settings_clone.set_int("window-width", w).ok();
|
s.set_int("window-height", win.height()).ok();
|
||||||
settings_clone.set_int("window-height", h).ok();
|
|
||||||
}
|
}
|
||||||
settings_clone
|
s.set_boolean("window-maximized", win.is_maximized()).ok();
|
||||||
.set_boolean("window-maximized", win.is_maximized())
|
|
||||||
.ok();
|
|
||||||
glib::Propagation::Proceed
|
glib::Propagation::Proceed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List view ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn setup_list(&self) {
|
||||||
|
let store = gio::ListStore::new::<ArticleObject>();
|
||||||
|
let selection = gtk4::SingleSelection::new(Some(store.clone()));
|
||||||
|
*self.article_store.borrow_mut() = Some(store);
|
||||||
|
|
||||||
|
let factory = gtk4::SignalListItemFactory::new();
|
||||||
|
factory.connect_setup(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
let row = crate::article_row::ArticleRow::new();
|
||||||
|
item.set_child(Some(&row));
|
||||||
|
});
|
||||||
|
factory.connect_bind(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
if let Some(obj) = item.item().and_downcast::<ArticleObject>() {
|
||||||
|
let row = item.child().and_downcast::<crate::article_row::ArticleRow>().unwrap();
|
||||||
|
row.bind(&obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
factory.connect_unbind(|_, item| {
|
||||||
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
if let Some(row) = item.child().and_downcast::<crate::article_row::ArticleRow>() {
|
||||||
|
row.unbind();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.article_list_view.set_factory(Some(&factory));
|
||||||
|
self.article_list_view.set_model(Some(&selection));
|
||||||
|
|
||||||
|
let win_weak = self.obj().downgrade();
|
||||||
|
selection.connect_selected_item_notify(move |sel| {
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
if let Some(obj) = sel.selected_item().and_downcast::<ArticleObject>() {
|
||||||
|
win.imp().on_article_selected(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
*self.selection.borrow_mut() = Some(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_article_selected(&self, obj: ArticleObject) {
|
||||||
|
// Mark previous article as read (unless guard is set)
|
||||||
|
if !*self.mark_unread_guard.borrow() {
|
||||||
|
if let Some(prev_id) = self.current_article_id.borrow().clone() {
|
||||||
|
if prev_id != obj.article().id {
|
||||||
|
self.bg_mark_read(prev_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*self.mark_unread_guard.borrow_mut() = false;
|
||||||
|
|
||||||
|
let article = obj.article().clone();
|
||||||
|
*self.current_article_id.borrow_mut() = Some(article.id.clone());
|
||||||
|
|
||||||
|
// Show content pane
|
||||||
|
self.content_page.set_title(&article.title);
|
||||||
|
self.article_menu_button.set_visible(true);
|
||||||
|
self.split_view.set_show_content(true);
|
||||||
|
|
||||||
|
// Load in webview
|
||||||
|
self.load_article_in_webview(&article);
|
||||||
|
obj.set_unread(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_article_in_webview(&self, article: &crate::model::Article) {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": article.id,
|
||||||
|
"title": article.title,
|
||||||
|
"feed_title": article.feed_title,
|
||||||
|
"link": article.link,
|
||||||
|
"updated": article.published,
|
||||||
|
"content": article.content,
|
||||||
|
"author": article.author,
|
||||||
|
"unread": article.unread,
|
||||||
|
});
|
||||||
|
let js = format!("window.setArticle({})", json);
|
||||||
|
self.web_view.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {});
|
||||||
|
self.content_stack.set_visible_child_name("webview");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebView ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn setup_webview(&self) {
|
||||||
|
let wv = &*self.web_view;
|
||||||
|
|
||||||
|
// Load content.html from GResource
|
||||||
|
let html = String::from_utf8(
|
||||||
|
gio::resources_lookup_data(
|
||||||
|
"/net/jeena/FeedTheMonkey/html/content.html",
|
||||||
|
gio::ResourceLookupFlags::NONE,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
wv.load_html(&html, Some("feedthemonkey://localhost/"));
|
||||||
|
|
||||||
|
// Handle navigation policy
|
||||||
|
let win_weak = self.obj().downgrade();
|
||||||
|
wv.connect_decide_policy(move |_, decision, decision_type| {
|
||||||
|
if decision_type != webkit6::PolicyDecisionType::NavigationAction {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let nav = decision.downcast_ref::<webkit6::NavigationPolicyDecision>().unwrap();
|
||||||
|
let uri = nav.navigation_action()
|
||||||
|
.and_then(|a| a.request())
|
||||||
|
.and_then(|r| r.uri())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if uri.starts_with("feedthemonkey://localhost/") || uri.is_empty() {
|
||||||
|
return false; // allow initial load
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.ignore();
|
||||||
|
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
match uri.as_str() {
|
||||||
|
"feedthemonkey:previous" => win.imp().navigate_by(-1),
|
||||||
|
"feedthemonkey:next" => win.imp().navigate_by(1),
|
||||||
|
"feedthemonkey:open" => win.imp().do_open_in_browser(),
|
||||||
|
_ => { open_uri(&uri); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn auto_login(&self) {
|
||||||
|
if let Some((server_url, username, password)) = credentials::load_credentials() {
|
||||||
|
let win_weak = self.obj().downgrade();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
win.imp().do_login(server_url, username, password, false).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.show_login_dialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_login_dialog(&self) {
|
||||||
|
let dialog = LoginDialog::new();
|
||||||
|
let win = self.obj();
|
||||||
|
let win_weak = win.downgrade();
|
||||||
|
dialog.connect_local("logged-in", false, move |args| {
|
||||||
|
let server_url = args[1].get::<String>().unwrap();
|
||||||
|
let username = args[2].get::<String>().unwrap();
|
||||||
|
let password = args[3].get::<String>().unwrap();
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
win.imp().do_login(server_url, username, password, true).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
});
|
||||||
|
dialog.present(Some(win.upcast_ref::<gtk4::Widget>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn do_login(
|
||||||
|
&self,
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
store: bool,
|
||||||
|
) {
|
||||||
|
match Api::login(&server_url, &username, &password).await {
|
||||||
|
Ok(api) => {
|
||||||
|
if store {
|
||||||
|
credentials::store_credentials(&server_url, &username, &password);
|
||||||
|
}
|
||||||
|
match api.fetch_write_token().await {
|
||||||
|
Ok(wt) => *self.write_token.borrow_mut() = Some(wt),
|
||||||
|
Err(e) => eprintln!("Write token error: {e}"),
|
||||||
|
}
|
||||||
|
*self.api.borrow_mut() = Some(api);
|
||||||
|
self.fetch_articles().await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.show_login_dialog();
|
||||||
|
self.show_error_dialog("Login Failed", &e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_error_dialog(&self, title: &str, body: &str) {
|
||||||
|
let dialog = libadwaita::AlertDialog::new(Some(title), Some(body));
|
||||||
|
dialog.add_response("ok", "_OK");
|
||||||
|
dialog.set_default_response(Some("ok"));
|
||||||
|
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_logout(&self) {
|
||||||
|
let win_weak = self.obj().downgrade();
|
||||||
|
let dialog = libadwaita::AlertDialog::new(
|
||||||
|
Some("Log Out?"),
|
||||||
|
Some("Are you sure you want to log out?"),
|
||||||
|
);
|
||||||
|
dialog.add_response("cancel", "_Cancel");
|
||||||
|
dialog.add_response("logout", "_Log Out");
|
||||||
|
dialog.set_response_appearance("logout", libadwaita::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.connect_response(None, move |_, response| {
|
||||||
|
if response == "logout" {
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
credentials::clear_credentials();
|
||||||
|
*win.imp().api.borrow_mut() = None;
|
||||||
|
*win.imp().write_token.borrow_mut() = None;
|
||||||
|
if let Some(store) = win.imp().article_store.borrow().as_ref() {
|
||||||
|
store.remove_all();
|
||||||
|
}
|
||||||
|
win.imp().sidebar_content.set_visible_child_name("placeholder");
|
||||||
|
win.imp().article_menu_button.set_visible(false);
|
||||||
|
win.imp().show_login_dialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(Some(self.obj().upcast_ref::<gtk4::Widget>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch articles ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn do_reload(&self) {
|
||||||
|
let win_weak = self.obj().downgrade();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Some(win) = win_weak.upgrade() {
|
||||||
|
win.imp().fetch_articles().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_articles(&self) {
|
||||||
|
let api = self.api.borrow().clone();
|
||||||
|
let Some(api) = api else { return };
|
||||||
|
|
||||||
|
self.refresh_stack.set_visible_child_name("spinner");
|
||||||
|
self.sidebar_content.set_visible_child_name("loading");
|
||||||
|
|
||||||
|
match api.fetch_unread().await {
|
||||||
|
Ok(articles) => {
|
||||||
|
let store = self.article_store.borrow();
|
||||||
|
let store = store.as_ref().unwrap();
|
||||||
|
store.remove_all();
|
||||||
|
for a in articles {
|
||||||
|
store.append(&ArticleObject::new(a));
|
||||||
|
}
|
||||||
|
if store.n_items() == 0 {
|
||||||
|
self.sidebar_content.set_visible_child_name("empty");
|
||||||
|
} else {
|
||||||
|
self.sidebar_content.set_visible_child_name("list");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error_status.set_description(Some(&e));
|
||||||
|
self.sidebar_content.set_visible_child_name("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh_stack.set_visible_child_name("button");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn bg_mark_read(&self, item_id: String) {
|
||||||
|
let api = self.api.borrow().clone();
|
||||||
|
let wt = self.write_token.borrow().clone();
|
||||||
|
if let (Some(api), Some(wt)) = (api, wt) {
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Err(e) = api.mark_read(&wt, &item_id).await {
|
||||||
|
eprintln!("mark_read error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_mark_unread(&self) {
|
||||||
|
let id = self.current_article_id.borrow().clone();
|
||||||
|
let Some(id) = id else { return };
|
||||||
|
|
||||||
|
// Find the ArticleObject in the store and set unread=true
|
||||||
|
if let Some(store) = self.article_store.borrow().as_ref() {
|
||||||
|
for i in 0..store.n_items() {
|
||||||
|
if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() {
|
||||||
|
if obj.article().id == id {
|
||||||
|
obj.set_unread(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.mark_unread_guard.borrow_mut() = true;
|
||||||
|
|
||||||
|
let api = self.api.borrow().clone();
|
||||||
|
let wt = self.write_token.borrow().clone();
|
||||||
|
if let (Some(api), Some(wt)) = (api, wt) {
|
||||||
|
let id_clone = id.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Err(e) = api.mark_unread(&wt, &id_clone).await {
|
||||||
|
eprintln!("mark_unread error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let toast = libadwaita::Toast::new("Marked as unread");
|
||||||
|
self.toast_overlay.add_toast(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn navigate_by(&self, delta: i32) {
|
||||||
|
let sel = self.selection.borrow();
|
||||||
|
let Some(sel) = sel.as_ref() else { return };
|
||||||
|
let n = sel.n_items();
|
||||||
|
if n == 0 { return }
|
||||||
|
let current = sel.selected();
|
||||||
|
let next = if delta > 0 {
|
||||||
|
(current + 1).min(n - 1)
|
||||||
|
} else {
|
||||||
|
current.saturating_sub(1)
|
||||||
|
};
|
||||||
|
if next != current {
|
||||||
|
sel.set_selected(next);
|
||||||
|
self.article_list_view.scroll_to(next, gtk4::ListScrollFlags::SELECT, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open in browser ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn do_open_in_browser(&self) {
|
||||||
|
let id = self.current_article_id.borrow().clone();
|
||||||
|
let Some(id) = id else { return };
|
||||||
|
if let Some(store) = self.article_store.borrow().as_ref() {
|
||||||
|
for i in 0..store.n_items() {
|
||||||
|
if let Some(obj) = store.item(i).and_downcast::<ArticleObject>() {
|
||||||
|
if obj.article().id == id {
|
||||||
|
let link = obj.article().link.clone();
|
||||||
|
if !link.is_empty() {
|
||||||
|
open_uri(&link);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zoom ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn zoom(&self, factor: f64) {
|
||||||
|
let wv = &*self.web_view;
|
||||||
|
let new_level = (wv.zoom_level() * factor).clamp(0.25, 5.0);
|
||||||
|
wv.set_zoom_level(new_level);
|
||||||
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||||
|
settings.set_double("zoom-level", new_level).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_reset(&self) {
|
||||||
|
self.web_view.set_zoom_level(1.0);
|
||||||
|
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||||
|
settings.set_double("zoom-level", 1.0).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WidgetImpl for FeedTheMonkeyWindow {}
|
impl WidgetImpl for FeedTheMonkeyWindow {}
|
||||||
impl WindowImpl for FeedTheMonkeyWindow {}
|
|
||||||
|
impl WindowImpl for FeedTheMonkeyWindow {
|
||||||
|
fn close_request(&self) -> glib::Propagation {
|
||||||
|
// Cancel any in-flight requests by dropping the Api
|
||||||
|
*self.api.borrow_mut() = None;
|
||||||
|
self.parent_close_request()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ApplicationWindowImpl for FeedTheMonkeyWindow {}
|
impl ApplicationWindowImpl for FeedTheMonkeyWindow {}
|
||||||
impl AdwApplicationWindowImpl for FeedTheMonkeyWindow {}
|
impl AdwApplicationWindowImpl for FeedTheMonkeyWindow {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_uri(uri: &str) {
|
||||||
|
let launcher = gtk4::UriLauncher::new(uri);
|
||||||
|
launcher.launch(gtk4::Window::NONE, gio::Cancellable::NONE, |_| {});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue