ui: overhaul sidebar, add content filters and state improvements
Sidebar layout: - Replace AdwNavigationSplitView with GtkPaned for a resizable sidebar with a persistent width stored in GSettings. - Apply navigation-sidebar CSS class to the content Stack only (not the ToolbarView) so both header bars share the same colour and height. - Override Adwaita's automatic paned-first-child header tint and gap via application-level CSS. - Remove the gap between the sidebar header and the first list item. - Add toggle-sidebar button and F9 shortcut; sidebar visibility and width are persisted across restarts. Loading indicator: - Replace the large AdwSpinner status page + header Stack with a small Gtk.Spinner (16×16) in the header Stack so the header height never changes during loading. Article row: - Add hexpand to title and excerpt labels so text reflows when the sidebar is resized. Content: - Inline CSS into the HTML template at load time (/*INJECT_CSS*/ placeholder) so WebKit does not need a custom URI scheme handler. - Fix max-width centering and padding for article body and header. - Fix embedded video/iframe auto-opening in browser by checking NavigationType::LinkClicked instead of is_user_gesture(). Content filters: - Add Preferences dialog with a TextView for content-rewrite rules stored in GSettings (content-filters key). - Rule format: "domain find replace [find replace …]" one per line. - Rules are applied to article HTML before display and reloaded on every refresh. Shortcuts: - Add Ctrl+W to close, Ctrl+Q to quit, F1 for keyboard shortcuts overlay, j/k and arrow-key navigation via a capture-phase controller so keys work regardless of which widget has focus. Misc: - Set window title to "FeedTheMonkey" (fixes Hyprland title bar). - Update About dialog website URL.
This commit is contained in:
parent
141f9ee32d
commit
8fd52dd8a0
16 changed files with 680 additions and 320 deletions
8
build.rs
8
build.rs
|
|
@ -44,6 +44,14 @@ fn main() {
|
|||
let gresource_xml = data_dir.join("resources.gresource.xml");
|
||||
println!("cargo:rerun-if-changed={}", gresource_xml.display());
|
||||
|
||||
// Watch HTML/CSS so changes trigger a resource rebuild
|
||||
let html_dir = manifest_dir.join("html");
|
||||
if let Ok(entries) = std::fs::read_dir(&html_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
println!("cargo:rerun-if-changed={}", entry.path().display());
|
||||
}
|
||||
}
|
||||
|
||||
let gresource_out = out_dir.join("feedthemonkey.gresource");
|
||||
let status = Command::new("glib-compile-resources")
|
||||
.arg(format!("--sourcedir={}", data_dir.display()))
|
||||
|
|
|
|||
|
|
@ -21,5 +21,9 @@
|
|||
<default>1.0</default>
|
||||
<summary>WebView zoom level</summary>
|
||||
</key>
|
||||
<key name="content-filters" type="s">
|
||||
<default>''</default>
|
||||
<summary>Content rewrite rules, one per line: domain from to [from to …]</summary>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<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 preprocess="xml-stripblanks">ui/preferences_dialog.ui</file>
|
||||
<file>html/content.html</file>
|
||||
<file>html/content.css</file>
|
||||
</gresource>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ template $ArticleRow : Gtk.Box {
|
|||
}
|
||||
|
||||
Label title_label {
|
||||
hexpand: true;
|
||||
xalign: 0;
|
||||
wrap: true;
|
||||
lines: 2;
|
||||
|
|
@ -34,6 +35,7 @@ template $ArticleRow : Gtk.Box {
|
|||
}
|
||||
|
||||
Label excerpt_label {
|
||||
hexpand: true;
|
||||
xalign: 0;
|
||||
ellipsize: end;
|
||||
lines: 1;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="title_label">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="wrap">true</property>
|
||||
<property name="lines">2</property>
|
||||
|
|
@ -49,6 +50,7 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="excerpt_label">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="ellipsize">3</property>
|
||||
<property name="lines">1</property>
|
||||
|
|
|
|||
29
data/ui/preferences_dialog.blp
Normal file
29
data/ui/preferences_dialog.blp
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Gtk 4.0;
|
||||
using Adw 1;
|
||||
|
||||
template $PreferencesDialog : Adw.Dialog {
|
||||
title: _("Preferences");
|
||||
content-width: 500;
|
||||
content-height: 400;
|
||||
|
||||
Adw.ToolbarView {
|
||||
[top]
|
||||
Adw.HeaderBar {}
|
||||
|
||||
Adw.PreferencesPage {
|
||||
Adw.PreferencesGroup {
|
||||
title: _("Content Filters");
|
||||
description: _("One rule per line: domain find replace [find replace …]\n\nExample:\n www.imycomic.com -150x150.jpg .jpg");
|
||||
|
||||
TextView filters_text_view {
|
||||
monospace: true;
|
||||
wrap-mode: word;
|
||||
top-margin: 8;
|
||||
bottom-margin: 8;
|
||||
left-margin: 8;
|
||||
right-margin: 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
data/ui/preferences_dialog.ui
Normal file
44
data/ui/preferences_dialog.ui
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?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="PreferencesDialog" parent="AdwDialog">
|
||||
<property name="title" translatable="yes">Preferences</property>
|
||||
<property name="content-width">500</property>
|
||||
<property name="content-height">400</property>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar"></object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesPage">
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Content Filters</property>
|
||||
<property name="description" translatable="yes">One rule per line: domain find replace [find replace …]
|
||||
|
||||
Example:
|
||||
www.imycomic.com -150x150.jpg .jpg</property>
|
||||
<child>
|
||||
<object class="GtkTextView" id="filters_text_view">
|
||||
<property name="monospace">true</property>
|
||||
<property name="wrap-mode">2</property>
|
||||
<property name="top-margin">8</property>
|
||||
<property name="bottom-margin">8</property>
|
||||
<property name="left-margin">8</property>
|
||||
<property name="right-margin">8</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
@ -79,6 +79,11 @@ ShortcutsWindow help_overlay {
|
|||
accelerator: "<Control>0";
|
||||
}
|
||||
|
||||
ShortcutsShortcut {
|
||||
title: _("Toggle sidebar");
|
||||
accelerator: "F9";
|
||||
}
|
||||
|
||||
ShortcutsShortcut {
|
||||
title: _("Toggle fullscreen");
|
||||
accelerator: "F11";
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
|||
<property name="accelerator"><Control>0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes">Toggle sidebar</property>
|
||||
<property name="accelerator">F9</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title" translatable="yes">Toggle fullscreen</property>
|
||||
|
|
|
|||
|
|
@ -7,121 +7,140 @@ template $FeedTheMonkeyWindow : Adw.ApplicationWindow {
|
|||
default-height: 600;
|
||||
|
||||
Adw.ToastOverlay toast_overlay {
|
||||
Adw.NavigationSplitView split_view {
|
||||
sidebar: Adw.NavigationPage {
|
||||
title: _("FeedTheMonkey");
|
||||
Paned paned {
|
||||
focusable: false;
|
||||
shrink-start-child: false;
|
||||
resize-start-child: false;
|
||||
|
||||
Adw.ToolbarView {
|
||||
[top]
|
||||
Adw.HeaderBar {
|
||||
[start]
|
||||
Stack refresh_stack {
|
||||
StackPage {
|
||||
name: "button";
|
||||
child: Button refresh_button {
|
||||
icon-name: "view-refresh-symbolic";
|
||||
tooltip-text: _("Refresh");
|
||||
action-name: "win.reload";
|
||||
};
|
||||
}
|
||||
start-child: Adw.ToolbarView sidebar_toolbar {
|
||||
top-bar-style: raised;
|
||||
|
||||
StackPage {
|
||||
name: "spinner";
|
||||
child: Adw.Spinner {};
|
||||
}
|
||||
[top]
|
||||
Adw.HeaderBar {
|
||||
show-start-title-buttons: false;
|
||||
show-end-title-buttons: false;
|
||||
|
||||
title-widget: Box {};
|
||||
|
||||
[start]
|
||||
Stack refresh_stack {
|
||||
StackPage {
|
||||
name: "button";
|
||||
child: Button refresh_button {
|
||||
icon-name: "view-refresh-symbolic";
|
||||
tooltip-text: _("Refresh");
|
||||
action-name: "win.reload";
|
||||
};
|
||||
}
|
||||
|
||||
[end]
|
||||
MenuButton menu_button {
|
||||
icon-name: "open-menu-symbolic";
|
||||
primary: true;
|
||||
menu-model: primary_menu;
|
||||
StackPage {
|
||||
name: "spinner";
|
||||
child: Spinner {
|
||||
spinning: true;
|
||||
width-request: 16;
|
||||
height-request: 16;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Stack sidebar_content {
|
||||
StackPage {
|
||||
name: "placeholder";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "rss-symbolic";
|
||||
title: _("FeedTheMonkey");
|
||||
description: _("Log in to load your articles");
|
||||
};
|
||||
}
|
||||
[end]
|
||||
MenuButton menu_button {
|
||||
icon-name: "open-menu-symbolic";
|
||||
primary: true;
|
||||
menu-model: primary_menu;
|
||||
}
|
||||
}
|
||||
|
||||
StackPage {
|
||||
name: "loading";
|
||||
child: Adw.StatusPage {
|
||||
paintable: Adw.SpinnerPaintable {};
|
||||
title: _("Loading…");
|
||||
};
|
||||
}
|
||||
Stack sidebar_content {
|
||||
styles ["sidebar-content"]
|
||||
|
||||
StackPage {
|
||||
name: "empty";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "rss-symbolic";
|
||||
title: _("No Unread Articles");
|
||||
};
|
||||
}
|
||||
StackPage {
|
||||
name: "placeholder";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "rss-symbolic";
|
||||
title: _("FeedTheMonkey");
|
||||
description: _("Log in to load your articles");
|
||||
};
|
||||
}
|
||||
|
||||
StackPage {
|
||||
name: "error";
|
||||
child: Adw.StatusPage error_status {
|
||||
icon-name: "network-error-symbolic";
|
||||
title: _("Could Not Load Articles");
|
||||
StackPage {
|
||||
name: "loading";
|
||||
child: Adw.StatusPage {
|
||||
title: _("Loading…");
|
||||
};
|
||||
}
|
||||
|
||||
Button {
|
||||
label: _("Try Again");
|
||||
halign: center;
|
||||
action-name: "win.reload";
|
||||
styles ["pill", "suggested-action"]
|
||||
}
|
||||
};
|
||||
}
|
||||
StackPage {
|
||||
name: "empty";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "rss-symbolic";
|
||||
title: _("No Unread Articles");
|
||||
};
|
||||
}
|
||||
|
||||
StackPage {
|
||||
name: "list";
|
||||
child: ScrolledWindow {
|
||||
hscrollbar-policy: never;
|
||||
ListView article_list_view {
|
||||
single-click-activate: true;
|
||||
}
|
||||
};
|
||||
}
|
||||
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: false;
|
||||
show-separators: true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
content: Adw.NavigationPage content_page {
|
||||
title: _("FeedTheMonkey");
|
||||
end-child: Adw.ToolbarView {
|
||||
top-bar-style: raised;
|
||||
|
||||
Adw.ToolbarView {
|
||||
top-bar-style: raised;
|
||||
|
||||
[top]
|
||||
Adw.HeaderBar {
|
||||
[end]
|
||||
MenuButton article_menu_button {
|
||||
icon-name: "view-more-symbolic";
|
||||
menu-model: article_menu;
|
||||
visible: false;
|
||||
}
|
||||
[top]
|
||||
Adw.HeaderBar {
|
||||
[start]
|
||||
Button toggle_sidebar_button {
|
||||
icon-name: "sidebar-show-symbolic";
|
||||
tooltip-text: _("Toggle Sidebar");
|
||||
action-name: "win.toggle-sidebar";
|
||||
}
|
||||
|
||||
Stack content_stack {
|
||||
StackPage {
|
||||
name: "empty";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "document-open-symbolic";
|
||||
title: _("No Article Selected");
|
||||
};
|
||||
}
|
||||
title-widget: Adw.WindowTitle {
|
||||
title: _("FeedTheMonkey");
|
||||
};
|
||||
|
||||
StackPage {
|
||||
name: "webview";
|
||||
child: WebKit.WebView web_view {};
|
||||
}
|
||||
[end]
|
||||
MenuButton article_menu_button {
|
||||
icon-name: "view-more-symbolic";
|
||||
menu-model: article_menu;
|
||||
visible: false;
|
||||
}
|
||||
}
|
||||
|
||||
Stack content_stack {
|
||||
StackPage {
|
||||
name: "empty";
|
||||
child: Adw.StatusPage {
|
||||
icon-name: "document-open-symbolic";
|
||||
title: _("No Article Selected");
|
||||
};
|
||||
}
|
||||
|
||||
StackPage {
|
||||
name: "webview";
|
||||
child: WebKit.WebView web_view {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -137,6 +156,13 @@ menu primary_menu {
|
|||
}
|
||||
}
|
||||
|
||||
section {
|
||||
item {
|
||||
label: _("Preferences");
|
||||
action: "win.preferences";
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
item {
|
||||
label: _("Keyboard Shortcuts");
|
||||
|
|
|
|||
|
|
@ -12,166 +12,182 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
|||
<child>
|
||||
<object class="AdwToastOverlay" id="toast_overlay">
|
||||
<child>
|
||||
<object class="AdwNavigationSplitView" id="split_view">
|
||||
<property name="sidebar">
|
||||
<object class="AdwNavigationPage">
|
||||
<property name="title" translatable="yes">FeedTheMonkey</property>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<child type="start">
|
||||
<object class="GtkStack" id="refresh_stack">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">button</property>
|
||||
<property name="child">
|
||||
<object class="GtkButton" id="refresh_button">
|
||||
<property name="icon-name">view-refresh-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Refresh</property>
|
||||
<property name="action-name">win.reload</property>
|
||||
</object>
|
||||
</property>
|
||||
<object class="GtkPaned" id="paned">
|
||||
<property name="focusable">false</property>
|
||||
<property name="shrink-start-child">false</property>
|
||||
<property name="resize-start-child">false</property>
|
||||
<property name="start-child">
|
||||
<object class="AdwToolbarView" id="sidebar_toolbar">
|
||||
<property name="top-bar-style">1</property>
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-start-title-buttons">false</property>
|
||||
<property name="show-end-title-buttons">false</property>
|
||||
<property name="title-widget">
|
||||
<object class="GtkBox"></object>
|
||||
</property>
|
||||
<child type="start">
|
||||
<object class="GtkStack" id="refresh_stack">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">button</property>
|
||||
<property name="child">
|
||||
<object class="GtkButton" id="refresh_button">
|
||||
<property name="icon-name">view-refresh-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Refresh</property>
|
||||
<property name="action-name">win.reload</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">spinner</property>
|
||||
<property name="child">
|
||||
<object class="AdwSpinner"></object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="menu_button">
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="primary">true</property>
|
||||
<property name="menu-model">primary_menu</property>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">spinner</property>
|
||||
<property name="child">
|
||||
<object class="GtkSpinner">
|
||||
<property name="spinning">true</property>
|
||||
<property name="width-request">16</property>
|
||||
<property name="height-request">16</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="menu_button">
|
||||
<property name="icon-name">open-menu-symbolic</property>
|
||||
<property name="primary">true</property>
|
||||
<property name="menu-model">primary_menu</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="sidebar_content">
|
||||
<style>
|
||||
<class name="sidebar-content"/>
|
||||
</style>
|
||||
<child>
|
||||
<object class="GtkStack" id="sidebar_content">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<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 class="GtkStackPage">
|
||||
<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>
|
||||
</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>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">loading</property>
|
||||
<property name="child">
|
||||
<object class="AdwStatusPage">
|
||||
<property name="title" translatable="yes">Loading…</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</property>
|
||||
</child>
|
||||
</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>
|
||||
</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">false</property>
|
||||
<property name="show-separators">true</property>
|
||||
</object>
|
||||
</property>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
<property name="content">
|
||||
<object class="AdwNavigationPage" id="content_page">
|
||||
<property name="title" translatable="yes">FeedTheMonkey</property>
|
||||
<property name="end-child">
|
||||
<object class="AdwToolbarView">
|
||||
<property name="top-bar-style">1</property>
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<child type="start">
|
||||
<object class="GtkButton" id="toggle_sidebar_button">
|
||||
<property name="icon-name">sidebar-show-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Toggle Sidebar</property>
|
||||
<property name="action-name">win.toggle-sidebar</property>
|
||||
</object>
|
||||
</child>
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle">
|
||||
<property name="title" translatable="yes">FeedTheMonkey</property>
|
||||
</object>
|
||||
</property>
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="article_menu_button">
|
||||
<property name="icon-name">view-more-symbolic</property>
|
||||
<property name="menu-model">article_menu</property>
|
||||
<property name="visible">false</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<property name="top-bar-style">1</property>
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<child type="end">
|
||||
<object class="GtkMenuButton" id="article_menu_button">
|
||||
<property name="icon-name">view-more-symbolic</property>
|
||||
<property name="menu-model">article_menu</property>
|
||||
<property name="visible">false</property>
|
||||
<object class="GtkStack" id="content_stack">
|
||||
<child>
|
||||
<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>
|
||||
</child>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStack" id="content_stack">
|
||||
<child>
|
||||
<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 class="GtkStackPage">
|
||||
<property name="name">webview</property>
|
||||
<property name="child">
|
||||
<object class="WebKitWebView" id="web_view"></object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -190,6 +206,12 @@ corresponding .blp file and regenerate this file with blueprint-compiler.
|
|||
<attribute name="action">win.logout</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Preferences</attribute>
|
||||
<attribute name="action">win.preferences</attribute>
|
||||
</item>
|
||||
</section>
|
||||
<section>
|
||||
<item>
|
||||
<attribute name="label" translatable="yes">Keyboard Shortcuts</attribute>
|
||||
|
|
|
|||
158
html/content.css
158
html/content.css
|
|
@ -1,101 +1,131 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
/* CSS custom properties are set from Rust via AdwStyleManager.
|
||||
The :root defaults below act as a light-mode fallback only. */
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--fg-dim: rgba(0,0,0,0.55);
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--header-bg: #f6f5f4;
|
||||
--link: #1c71d8;
|
||||
--code-bg: rgba(0,0,0,0.06);
|
||||
--blockquote-border: rgba(0,0,0,0.2);
|
||||
--font: sans-serif;
|
||||
--font-size: 15px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
:root[data-dark="1"] {
|
||||
--bg: #1e1e1e;
|
||||
--fg: rgba(255,255,255,0.87);
|
||||
--fg-dim: rgba(255,255,255,0.5);
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--header-bg: #242424;
|
||||
--link: #78aeed;
|
||||
--code-bg: rgba(255,255,255,0.06);
|
||||
--blockquote-border: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.6;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font);
|
||||
font-size: var(--font-size);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #ddd;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
a {
|
||||
color: #78aeed;
|
||||
}
|
||||
img {
|
||||
opacity: 0.85;
|
||||
}
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#header {
|
||||
article a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5em 2em 1em;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
margin-bottom: 1em;
|
||||
background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#header {
|
||||
border-bottom-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
header > .inner,
|
||||
article {
|
||||
max-width: 720px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#feed-title {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 0.25em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
header > .inner {
|
||||
padding: 0 2em;
|
||||
}
|
||||
|
||||
#title {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.5em;
|
||||
header h1 {
|
||||
font-size: 1.3em;
|
||||
margin: 0.2em 0 0.4em;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
#meta {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 0.5em;
|
||||
header h1 a {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
#meta span + span::before {
|
||||
content: ' · ';
|
||||
}
|
||||
|
||||
#link {
|
||||
header p {
|
||||
color: var(--fg-dim);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: 0 2em 2em;
|
||||
max-width: 800px;
|
||||
article {
|
||||
line-height: 1.6;
|
||||
padding: 1.5em 2em 2em;
|
||||
}
|
||||
|
||||
#content img {
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#content pre {
|
||||
overflow-x: auto;
|
||||
background: rgba(0,0,0,0.05);
|
||||
div > a:only-child img,
|
||||
figure > a:only-child img,
|
||||
p > a:only-child img,
|
||||
figure > img:only-child,
|
||||
div > img:only-child,
|
||||
p > img:only-child {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#content pre {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
code {
|
||||
background: var(--code-bg);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#content blockquote {
|
||||
border-left: 3px solid rgba(0,0,0,0.2);
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--blockquote-border);
|
||||
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);
|
||||
}
|
||||
color: var(--fg-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,35 +2,47 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>FeedTheMonkey</title>
|
||||
<link rel="stylesheet" href="content.css">
|
||||
<style>/*INJECT_CSS*/</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<div id="feed-title"></div>
|
||||
<h1 id="title"></h1>
|
||||
<div id="meta">
|
||||
<span id="author"></span>
|
||||
<span id="date"></span>
|
||||
<header>
|
||||
<div class="inner">
|
||||
<p><span id="feed_title"></span> <span id="author"></span></p>
|
||||
<h1><a id="title" href=""></a></h1>
|
||||
<p><time id="date"></time></p>
|
||||
</div>
|
||||
<a id="link" href="#" onclick="window.location='feedthemonkey:open'; return false;">Open in Browser</a>
|
||||
</div>
|
||||
<div id="content"></div>
|
||||
</header>
|
||||
<article id="article"></article>
|
||||
|
||||
<script>
|
||||
function setArticle(article) {
|
||||
document.getElementById('feed-title').textContent = article.feed_title || '';
|
||||
document.getElementById('title').textContent = article.title || '';
|
||||
document.getElementById('author').textContent = article.author || '';
|
||||
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;
|
||||
document.getElementById('date').textContent = '';
|
||||
document.getElementById('title').textContent = '';
|
||||
document.getElementById('title').href = '';
|
||||
document.getElementById('feed_title').textContent = '';
|
||||
document.getElementById('author').textContent = '';
|
||||
document.getElementById('article').innerHTML = '';
|
||||
if (!article) return;
|
||||
document.getElementById('date').textContent =
|
||||
new Date(parseInt(article.updated, 10) * 1000).toLocaleDateString();
|
||||
document.getElementById('title').textContent = article.title || '';
|
||||
document.getElementById('title').href = article.link || '';
|
||||
document.getElementById('feed_title').textContent = article.feed_title || '';
|
||||
if (article.author && article.author.length > 0)
|
||||
document.getElementById('author').textContent = '\u2013 ' + article.author;
|
||||
document.getElementById('article').innerHTML = article.content || '';
|
||||
}
|
||||
|
||||
function setDark(isDark) {
|
||||
document.documentElement.setAttribute('data-dark', isDark ? '1' : '0');
|
||||
}
|
||||
|
||||
function setFont(family, sizePx) {
|
||||
document.documentElement.style.setProperty('--font', family);
|
||||
document.documentElement.style.setProperty('--font-size', sizePx + 'px');
|
||||
}
|
||||
|
||||
function checkKey(e) {
|
||||
|
|
|
|||
36
src/app.rs
36
src/app.rs
|
|
@ -51,6 +51,33 @@ mod imp {
|
|||
.expect("failed to load GResource");
|
||||
gio::resources_register(&resource);
|
||||
|
||||
// Apply application-level CSS tweaks
|
||||
let css = gtk4::CssProvider::new();
|
||||
css.load_from_string(
|
||||
"paned > :first-child .top-bar headerbar {
|
||||
background-color: @headerbar_bg_color;
|
||||
box-shadow: none;
|
||||
}
|
||||
paned > :first-child > toolbarview > .content {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.sidebar-content row:not(:selected) {
|
||||
background-color: alpha(@window_fg_color, 0.07);
|
||||
}
|
||||
.sidebar-content row:not(:selected):hover {
|
||||
background-color: alpha(@window_fg_color, 0.14);
|
||||
}
|
||||
.sidebar-content row:selected {
|
||||
background-color: alpha(@window_fg_color, 0.22);
|
||||
}"
|
||||
);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&css,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
|
||||
let window = FeedTheMonkeyWindow::new(app.upcast_ref());
|
||||
|
||||
// Shortcuts overlay
|
||||
|
|
@ -74,7 +101,7 @@ mod imp {
|
|||
.version("3.0.0")
|
||||
.copyright("© Jeena Paradies")
|
||||
.license_type(gtk4::License::Gpl30)
|
||||
.website("https://github.com/jeena/FeedTheMonkey")
|
||||
.website("https://git.jeena.net/jeena/FeedTheMonkey")
|
||||
.developer_name("Jeena Paradies")
|
||||
.build();
|
||||
dialog.present(win.as_ref().map(|w| w.upcast_ref::<gtk4::Widget>()));
|
||||
|
|
@ -116,10 +143,8 @@ fn setup_shortcuts(window: &FeedTheMonkeyWindow) {
|
|||
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");
|
||||
// j/k/Left/Right are handled by a capture-phase key controller in window.rs
|
||||
// so they work regardless of which widget has focus.
|
||||
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");
|
||||
|
|
@ -128,6 +153,7 @@ fn setup_shortcuts(window: &FeedTheMonkeyWindow) {
|
|||
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::F9, ModifierType::empty(), "win.toggle-sidebar");
|
||||
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");
|
||||
|
|
|
|||
68
src/filters.rs
Normal file
68
src/filters.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/// Content rewrite rules stored in GSettings key "content-filters".
|
||||
///
|
||||
/// Format — one rule per line, tokens separated by spaces:
|
||||
///
|
||||
/// domain from to [from to …]
|
||||
///
|
||||
/// Examples:
|
||||
///
|
||||
/// www.imycomic.com -150x150.jpg .jpg
|
||||
/// www.stuttmann-karikaturen.de /thumbs/ /
|
||||
/// existentialcomics.com src="//static src="https://static
|
||||
///
|
||||
/// The domain is matched as a substring of the article's GUID and link URL.
|
||||
/// Blank lines and lines starting with # are ignored.
|
||||
|
||||
use gtk4::gio;
|
||||
use gtk4::prelude::SettingsExt;
|
||||
|
||||
pub struct Rule {
|
||||
pub pattern: String,
|
||||
pub replacements: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Parse the multi-line text from the GSettings key into rules.
|
||||
pub fn parse(text: &str) -> Vec<Rule> {
|
||||
let mut rules = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = line.splitn(usize::MAX, ' ')
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if tokens.len() < 3 || tokens.len() % 2 == 0 {
|
||||
// Need: domain + at least one from/to pair (odd total ≥ 3)
|
||||
eprintln!("filters: skipping malformed line: {line}");
|
||||
continue;
|
||||
}
|
||||
let pattern = tokens[0].to_string();
|
||||
let replacements = tokens[1..]
|
||||
.chunks(2)
|
||||
.map(|c| (c[0].to_string(), c[1].to_string()))
|
||||
.collect();
|
||||
rules.push(Rule { pattern, replacements });
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
/// Load rules from GSettings.
|
||||
pub fn load_rules() -> Vec<Rule> {
|
||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||
parse(&settings.string("content-filters"))
|
||||
}
|
||||
|
||||
/// Apply all matching rules to `content`.
|
||||
pub fn apply(rules: &[Rule], guid: &str, link: &str, content: &str) -> String {
|
||||
let mut out = content.to_string();
|
||||
for rule in rules {
|
||||
if guid.contains(&rule.pattern) || link.contains(&rule.pattern) {
|
||||
for (from, to) in &rule.replacements {
|
||||
out = out.replace(from.as_str(), to.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
75
src/preferences_dialog.rs
Normal file
75
src/preferences_dialog.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use gtk4::prelude::*;
|
||||
use gtk4::subclass::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct PreferencesDialog(ObjectSubclass<imp::PreferencesDialog>)
|
||||
@extends libadwaita::Dialog, gtk4::Widget,
|
||||
@implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget;
|
||||
}
|
||||
|
||||
impl PreferencesDialog {
|
||||
pub fn new() -> Self {
|
||||
glib::Object::builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
pub mod imp {
|
||||
use super::*;
|
||||
use gtk4::CompositeTemplate;
|
||||
use libadwaita::subclass::prelude::*;
|
||||
|
||||
#[derive(CompositeTemplate, Default)]
|
||||
#[template(resource = "/net/jeena/FeedTheMonkey/ui/preferences_dialog.ui")]
|
||||
pub struct PreferencesDialog {
|
||||
#[template_child]
|
||||
pub filters_text_view: TemplateChild<gtk4::TextView>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for PreferencesDialog {
|
||||
const NAME: &'static str = "PreferencesDialog";
|
||||
type Type = super::PreferencesDialog;
|
||||
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 PreferencesDialog {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
self.load();
|
||||
// Save on every text change
|
||||
let obj_weak = self.obj().downgrade();
|
||||
self.filters_text_view.buffer().connect_changed(move |_| {
|
||||
if let Some(obj) = obj_weak.upgrade() {
|
||||
obj.imp().save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PreferencesDialog {
|
||||
fn load(&self) {
|
||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||
let text = settings.string("content-filters");
|
||||
self.filters_text_view.buffer().set_text(&text);
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
let buf = self.filters_text_view.buffer();
|
||||
let text = buf.text(&buf.start_iter(), &buf.end_iter(), false);
|
||||
let settings = gio::Settings::new("net.jeena.FeedTheMonkey");
|
||||
settings.set_string("content-filters", &text).ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for PreferencesDialog {}
|
||||
impl AdwDialogImpl for PreferencesDialog {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue