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
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