From ebb38008e12c3482f63e298b7e39540332ee7bd3 Mon Sep 17 00:00:00 2001 From: yggverse Date: Fri, 25 Jul 2025 15:52:14 +0300 Subject: [PATCH] implement separated dialogs for the Bookmarks and History menu items --- Cargo.toml | 1 - src/app/browser.rs | 20 +++ src/app/browser/action.rs | 12 ++ src/app/browser/action/bookmarks.rs | 31 ++++ src/app/browser/action/history.rs | 31 ++++ src/app/browser/bookmarks.rs | 117 +++++++++++++++ src/app/browser/history.rs | 132 +++++++++++++++++ src/app/browser/window.rs | 3 +- src/app/browser/window/header.rs | 15 +- src/app/browser/window/header/bar.rs | 11 +- src/app/browser/window/header/bar/menu.rs | 169 +++------------------- src/profile/bookmark.rs | 4 +- src/profile/bookmark/database.rs | 2 +- src/profile/bookmark/item.rs | 1 + 14 files changed, 378 insertions(+), 171 deletions(-) create mode 100644 src/app/browser/action/bookmarks.rs create mode 100644 src/app/browser/action/history.rs create mode 100644 src/app/browser/bookmarks.rs create mode 100644 src/app/browser/history.rs diff --git a/Cargo.toml b/Cargo.toml index 8890083a..2094f961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,6 @@ anyhow = "1.0.97" async-channel = "2.5.0" ggemini = "0.19.0" ggemtext = "0.7.0" -indexmap = "2.7.0" itertools = "0.14.0" # libspelling = "0.4.0" maxminddb = "0.26.0" diff --git a/src/app/browser.rs b/src/app/browser.rs index 59109ffc..d5eb35b0 100644 --- a/src/app/browser.rs +++ b/src/app/browser.rs @@ -1,12 +1,16 @@ mod about; mod action; +mod bookmarks; mod database; +mod history; mod proxy; mod widget; pub mod window; use about::About; use action::Action; +use bookmarks::Bookmarks; +use history::History; use proxy::Proxy; use widget::Widget; use window::Window; @@ -93,6 +97,22 @@ impl Browser { } }); + action.history.connect_activate({ + let profile = profile.clone(); + let window = window.clone(); + move || { + PreferencesDialog::history(&window.action, &profile).present(Some(&window.g_box)) + } + }); + + action.bookmarks.connect_activate({ + let profile = profile.clone(); + let window = window.clone(); + move || { + PreferencesDialog::bookmarks(&window.action, &profile).present(Some(&window.g_box)) + } + }); + action.proxy.connect_activate({ let profile = profile.clone(); let window = window.clone(); diff --git a/src/app/browser/action.rs b/src/app/browser/action.rs index 9a71a9bd..11d5d0b3 100644 --- a/src/app/browser/action.rs +++ b/src/app/browser/action.rs @@ -1,12 +1,16 @@ mod about; +mod bookmarks; mod close; mod debug; +mod history; mod profile; mod proxy; use about::About; +use bookmarks::Bookmarks; use close::Close; use debug::Debug; +use history::History; use profile::Profile; use proxy::Proxy; @@ -21,8 +25,10 @@ use std::rc::Rc; pub struct Action { // Actions pub about: Rc, + pub bookmarks: Rc, pub close: Rc, pub debug: Rc, + pub history: Rc, pub profile: Rc, pub proxy: Rc, // Group @@ -43,8 +49,10 @@ impl Action { pub fn new() -> Self { // Init actions let about = Rc::new(About::new()); + let bookmarks = Rc::new(Bookmarks::new()); let close = Rc::new(Close::new()); let debug = Rc::new(Debug::new()); + let history = Rc::new(History::new()); let profile = Rc::new(Profile::new()); let proxy = Rc::new(Proxy::new()); @@ -56,18 +64,22 @@ impl Action { // Add action to given group simple_action_group.add_action(&about.simple_action); + simple_action_group.add_action(&bookmarks.simple_action); simple_action_group.add_action(&close.simple_action); simple_action_group.add_action(&debug.simple_action); + simple_action_group.add_action(&history.simple_action); simple_action_group.add_action(&profile.simple_action); simple_action_group.add_action(&proxy.simple_action); // Done Self { about, + bookmarks, close, debug, profile, proxy, + history, id, simple_action_group, } diff --git a/src/app/browser/action/bookmarks.rs b/src/app/browser/action/bookmarks.rs new file mode 100644 index 00000000..d887c82a --- /dev/null +++ b/src/app/browser/action/bookmarks.rs @@ -0,0 +1,31 @@ +use gtk::{gio::SimpleAction, glib::uuid_string_random}; + +/// [SimpleAction](https://docs.gtk.org/gio/class.SimpleAction.html) wrapper for `Profile` action of `Browser` group +pub struct Bookmarks { + pub simple_action: SimpleAction, +} + +impl Default for Bookmarks { + fn default() -> Self { + Self::new() + } +} + +impl Bookmarks { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + simple_action: SimpleAction::new(&uuid_string_random(), None), + } + } + + // Events + + /// Define callback function for + /// [SimpleAction::activate](https://docs.gtk.org/gio/signal.SimpleAction.activate.html) signal + pub fn connect_activate(&self, callback: impl Fn() + 'static) { + self.simple_action.connect_activate(move |_, _| callback()); + } +} diff --git a/src/app/browser/action/history.rs b/src/app/browser/action/history.rs new file mode 100644 index 00000000..565fcbc8 --- /dev/null +++ b/src/app/browser/action/history.rs @@ -0,0 +1,31 @@ +use gtk::{gio::SimpleAction, glib::uuid_string_random}; + +/// [SimpleAction](https://docs.gtk.org/gio/class.SimpleAction.html) wrapper for `Profile` action of `Browser` group +pub struct History { + pub simple_action: SimpleAction, +} + +impl Default for History { + fn default() -> Self { + Self::new() + } +} + +impl History { + // Constructors + + /// Create new `Self` + pub fn new() -> Self { + Self { + simple_action: SimpleAction::new(&uuid_string_random(), None), + } + } + + // Events + + /// Define callback function for + /// [SimpleAction::activate](https://docs.gtk.org/gio/signal.SimpleAction.activate.html) signal + pub fn connect_activate(&self, callback: impl Fn() + 'static) { + self.simple_action.connect_activate(move |_, _| callback()); + } +} diff --git a/src/app/browser/bookmarks.rs b/src/app/browser/bookmarks.rs new file mode 100644 index 00000000..003c4514 --- /dev/null +++ b/src/app/browser/bookmarks.rs @@ -0,0 +1,117 @@ +//! Browser bookmarks dialog + +use super::Profile; +use crate::app::browser::window::action::{Action as WindowAction, Position}; +use adw::{ + ActionRow, PreferencesGroup, PreferencesPage, + prelude::{ + ActionRowExt, AdwDialogExt, ExpanderRowExt, PreferencesDialogExt, PreferencesGroupExt, + PreferencesPageExt, + }, +}; +use gtk::glib::{DateTime, GString, Uri, UriFlags}; +use std::{collections::HashMap, rc::Rc}; + +struct Record { + time: DateTime, + request: String, + title: Option, +} + +pub trait Bookmarks { + fn bookmarks(window_action: &Rc, profile: &Rc) -> Self; +} + +impl Bookmarks for adw::PreferencesDialog { + fn bookmarks(window_action: &Rc, profile: &Rc) -> Self { + let mut index: HashMap> = HashMap::new(); + for bookmark in profile.bookmark.recent(None) { + match Uri::parse(&bookmark.request, UriFlags::NONE) { + Ok(uri) => index + .entry(match uri.host() { + Some(host) => host, + None => uri.to_str(), + }) + .or_default() + .push(Record { + request: bookmark.request, + time: bookmark.time, + title: bookmark.title, + }), + Err(_) => continue, // @TODO + } + } + + let d = adw::PreferencesDialog::builder() + .search_enabled(true) + .title("Bookmarks") + .build(); + + d.add(&{ + let p = PreferencesPage::builder() + .icon_name("document-open-recent-symbolic") + .title("All") + .build(); + + for (group, records) in index { + p.add(&{ + let g = PreferencesGroup::new(); + g.add(&{ + let e = adw::ExpanderRow::builder() + .enable_expansion(true) + .expanded(false) + .subtitle( + records + .iter() + .max_by_key(|r| r.time.to_unix()) + .unwrap() + .time + .format_iso8601() + .unwrap(), + ) + .title_selectable(true) + .title(group) + .build(); + + for record in records { + e.add_row(&{ + let a = ActionRow::builder() + .activatable(true) + // @TODO use button widget to open the links on click + //.title_selectable(true) + .title(match record.title { + Some(title) => title, + None => record.time.format_iso8601().unwrap().to_string(), + }) + .subtitle_selectable(true) + .subtitle(&record.request) + .build(); + + a.connect_activated({ + let a = window_action.clone(); + let d = d.clone(); + move |_| { + a.append.activate_stateful_once( + Position::After, + Some(record.request.clone()), + false, + true, + true, + true, + ); + d.close(); + } + }); + a + }) + } + e + }); + g + }); + } + p + }); + d + } +} diff --git a/src/app/browser/history.rs b/src/app/browser/history.rs new file mode 100644 index 00000000..9c7c64ea --- /dev/null +++ b/src/app/browser/history.rs @@ -0,0 +1,132 @@ +//! Browser history dialog + +use super::Profile; +use crate::app::browser::window::action::{Action as WindowAction, Position}; +use adw::{ + ActionRow, PreferencesGroup, PreferencesPage, + prelude::{ + ActionRowExt, AdwDialogExt, ExpanderRowExt, PreferencesDialogExt, PreferencesGroupExt, + PreferencesPageExt, + }, +}; +use gtk::glib::{DateTime, GString, Uri, UriFlags, gformat}; +use std::{collections::HashMap, rc::Rc}; + +pub struct Event { + pub time: DateTime, + pub count: usize, +} + +struct Record { + event: Event, + request: GString, + title: Option, +} + +pub trait History { + fn history(window_action: &Rc, profile: &Rc) -> Self; +} + +impl History for adw::PreferencesDialog { + fn history(window_action: &Rc, profile: &Rc) -> Self { + let mut visited: HashMap> = HashMap::new(); + // @TODO recently closed + + for history in profile.history.recently_opened(None) { + match Uri::parse(&history.request, UriFlags::NONE) { + Ok(uri) => visited + .entry(match uri.host() { + Some(host) => host, + None => uri.to_str(), + }) + .or_default() + .push(Record { + event: Event { + time: history.opened.time, + count: history.opened.count, + }, + request: history.request, + title: history.title, + }), + Err(_) => continue, // @TODO + } + } + + let d = adw::PreferencesDialog::builder() + .search_enabled(true) + .title("History") + .build(); + + d.add(&{ + let p = PreferencesPage::builder() + .icon_name("document-open-recent-symbolic") + .title("Recently visited") + .build(); + + for (group, records) in visited { + p.add(&{ + let g = PreferencesGroup::new(); + g.add(&{ + let e = adw::ExpanderRow::builder() + .enable_expansion(true) + .expanded(false) + .subtitle( + records + .iter() + .max_by_key(|r| r.event.time.to_unix()) + .unwrap() + .event + .time + .format_iso8601() + .unwrap(), + ) + .title_selectable(true) + .title(group) + .build(); + + for record in records { + e.add_row(&{ + let a = ActionRow::builder() + .activatable(true) + // @TODO use button widget to open the links on click + //.title_selectable(true) + .title(match record.title { + Some(title) => title, + None => gformat!( + "{} ({})", + record.event.time.format_iso8601().unwrap(), + record.event.count + ), + }) + .subtitle_selectable(true) + .subtitle(&*record.request) + .build(); + + a.connect_activated({ + let a = window_action.clone(); + let d = d.clone(); + move |_| { + a.append.activate_stateful_once( + Position::After, + Some(record.request.to_string()), + false, + true, + true, + true, + ); + d.close(); + } + }); + a + }) + } + e + }); + g + }); + } + p + }); + d + } +} diff --git a/src/app/browser/window.rs b/src/app/browser/window.rs index f143b1b8..731f905e 100644 --- a/src/app/browser/window.rs +++ b/src/app/browser/window.rs @@ -1,4 +1,4 @@ -mod action; +pub mod action; mod database; mod header; pub mod tab; @@ -119,7 +119,6 @@ impl Window { let g_box = Box::builder().orientation(Orientation::Vertical).build(); g_box.append(&ToolbarView::header( (browser_action, &action), - profile, &tab.tab_view, )); g_box.append(&tab.tab_view); diff --git a/src/app/browser/window/header.rs b/src/app/browser/window/header.rs index 24e33a2b..d9286052 100644 --- a/src/app/browser/window/header.rs +++ b/src/app/browser/window/header.rs @@ -1,17 +1,13 @@ mod bar; -use super::{Action as WindowAction, BrowserAction, Profile}; +use super::{Action as WindowAction, BrowserAction}; use adw::{TabView, ToolbarView}; use bar::Bar; use gtk::Box; use std::rc::Rc; pub trait Header { - fn header( - action: (&Rc, &Rc), - profile: &Rc, - tab_view: &TabView, - ) -> Self; + fn header(action: (&Rc, &Rc), tab_view: &TabView) -> Self; } impl Header for ToolbarView { @@ -20,16 +16,11 @@ impl Header for ToolbarView { /// Build new `Self` fn header( (browser_action, window_action): (&Rc, &Rc), - profile: &Rc, tab_view: &TabView, ) -> Self { let toolbar_view = ToolbarView::builder().build(); - toolbar_view.add_top_bar(&Box::bar( - (browser_action, window_action), - profile, - tab_view, - )); + toolbar_view.add_top_bar(&Box::bar((browser_action, window_action), tab_view)); toolbar_view } diff --git a/src/app/browser/window/header/bar.rs b/src/app/browser/window/header/bar.rs index 16b998d8..dc0c8d65 100644 --- a/src/app/browser/window/header/bar.rs +++ b/src/app/browser/window/header/bar.rs @@ -6,17 +6,13 @@ use control::Control; use menu::Menu; use tab::Tab; -use super::{BrowserAction, Profile, WindowAction}; +use super::{BrowserAction, WindowAction}; use adw::{TabBar, TabView}; use gtk::{Box, MenuButton, Orientation, prelude::BoxExt}; use std::rc::Rc; pub trait Bar { - fn bar( - action: (&Rc, &Rc), - profile: &Rc, - view: &TabView, - ) -> Self; + fn bar(action: (&Rc, &Rc), view: &TabView) -> Self; } impl Bar for Box { @@ -25,7 +21,6 @@ impl Bar for Box { /// Build new `Self` fn bar( (browser_action, window_action): (&Rc, &Rc), - profile: &Rc, view: &TabView, ) -> Self { let g_box = Box::builder() @@ -34,7 +29,7 @@ impl Bar for Box { .build(); g_box.append(&TabBar::tab(window_action, view)); - g_box.append(&MenuButton::menu((browser_action, window_action), profile)); + g_box.append(&MenuButton::menu((browser_action, window_action))); g_box.append(&Control::new().window_controls); g_box } diff --git a/src/app/browser/window/header/bar/menu.rs b/src/app/browser/window/header/bar/menu.rs index 2e822298..9ddcd8ea 100644 --- a/src/app/browser/window/header/bar/menu.rs +++ b/src/app/browser/window/header/bar/menu.rs @@ -1,18 +1,15 @@ -use super::{BrowserAction, Profile, WindowAction}; +use super::{BrowserAction, WindowAction}; use gtk::{ Align, MenuButton, gio::{self}, - glib::{GString, Uri, UriFlags}, - prelude::{ActionExt, ToVariant}, + prelude::ActionExt, }; -use indexmap::IndexMap; use std::rc::Rc; // Config options -const LABEL_MAX_LENGTH: usize = 28; pub trait Menu { - fn menu(action: (&Rc, &Rc), profile: &Rc) -> Self; + fn menu(actions: (&Rc, &Rc)) -> Self; } #[rustfmt::skip] // @TODO template builder? @@ -22,7 +19,6 @@ impl Menu for MenuButton { /// Build new `Self` fn menu( (browser_action, window_action): (&Rc, &Rc), - profile: &Rc, ) -> Self { // Main let main = gio::Menu::new(); @@ -116,7 +112,7 @@ impl Menu for MenuButton { window_action.history_forward.simple_action.name() ))); - main_page_navigation.append_submenu(Some("Navigation history"), &main_page_navigation_history); + main_page_navigation.append_submenu(Some("Navigation"), &main_page_navigation_history); main_page.append_section(None, &main_page_navigation); @@ -139,29 +135,22 @@ impl Menu for MenuButton { main.append_submenu(Some("Page"), &main_page); - // Main > Bookmark - // * menu items dynamically generated using profile memory pool and `set_create_popup_func` - let main_bookmarks = gio::Menu::new(); - - main.append_submenu(Some("Bookmarks"), &main_bookmarks); + // Main > Bookmarks + main.append(Some("Bookmarks"), Some(&format!( + "{}.{}", + browser_action.id, + browser_action.bookmarks.simple_action.name() + ))); // Main > History - let main_history = gio::Menu::new(); - - // Main > History > Recently closed - // * menu items dynamically generated using profile memory pool and `set_create_popup_func` - let main_history_tab = gio::Menu::new(); - main_history.append_submenu(Some("Recently closed"), &main_history_tab); - - // Main > History > Recent requests - // * menu items dynamically generated using profile memory pool and `set_create_popup_func` - let main_history_request = gio::Menu::new(); - main_history.append_section(None, &main_history_request); - - main.append_submenu(Some("History"), &main_history); + main.append(Some("History"), Some(&format!( + "{}.{}", + browser_action.id, + browser_action.history.simple_action.name() + ))); // Main > Proxy - main.append(Some("Proxy settings"), Some(&format!( + main.append(Some("Proxy"), Some(&format!( "{}.{}", browser_action.id, browser_action.proxy.simple_action.name() @@ -198,124 +187,12 @@ impl Menu for MenuButton { ))); // Init main widget - let menu_button = MenuButton::builder() - .css_classes(["flat"]) - .icon_name("open-menu-symbolic") - .menu_model(&main) - .tooltip_text("Menu") - .valign(Align::Center) - .build(); - - // Generate dynamical menu items - menu_button.set_create_popup_func({ - let profile = profile.clone(); - let main_bookmarks = main_bookmarks.clone(); - let window_action = window_action.clone(); - move |_| { - // Bookmarks - main_bookmarks.remove_all(); - for bookmark in profile.bookmark.recent(None) { - let menu_item = gio::MenuItem::new(Some(&ellipsize(&bookmark.request, LABEL_MAX_LENGTH)), None); - menu_item.set_action_and_target_value(Some(&format!( - "{}.{}", - window_action.id, - window_action.load.simple_action.name() - )), Some(&bookmark.request.to_variant())); - - main_bookmarks.append_item(&menu_item); - } // @TODO `menu_item` - - // Recently closed history - main_history_tab.remove_all(); - for history in profile.history.recently_closed(None) { - let menu_item = gio::MenuItem::new(Some(&ellipsize(&history.request, LABEL_MAX_LENGTH)), None); - menu_item.set_action_and_target_value(Some(&format!( - "{}.{}", - window_action.id, - window_action.load.simple_action.name() - )), Some(&history.request.to_variant())); - - main_history_tab.append_item(&menu_item); - } // @TODO `menu_item` - - // Recently visited history - // * in first iteration, group records by it hostname - // * in second iteration, collect uri path as the menu sub-item label - main_history_request.remove_all(); - - let mut list: IndexMap> = IndexMap::new(); - for history in profile.history.recently_opened(None) { - match Uri::parse(&history.request, UriFlags::NONE) { - Ok(uri) => list.entry(match uri.host() { - Some(host) => host, - None => uri.to_str(), - }).or_default().push(uri), - Err(_) => continue // @TODO - } - } - - for (group, items) in list { - let list = gio::Menu::new(); - - // Show first menu item only without children menu - if items.len() == 1 { - main_history_request.append_item(&menu_item(&window_action, &items[0], true)); - - // Create children menu items related to parental host item - } else { - for uri in items { - list.append_item(&menu_item(&window_action, &uri, false)); - } - main_history_request.append_submenu(Some(&group), &list); - } - } - } - }); - - menu_button + MenuButton::builder() + .css_classes(["flat"]) + .icon_name("open-menu-symbolic") + .menu_model(&main) + .tooltip_text("Menu") + .valign(Align::Center) + .build() } } - -/// Format dynamically generated strings for menu item label -/// * crop resulting string at the middle position on new `value` longer than `limit` -fn ellipsize(value: &str, limit: usize) -> String { - if value.len() <= limit { - return value.to_string(); - } - - let length = (limit - 2) / 2; - - format!("{}..{}", &value[..length], &value[value.len() - length..]) -} - -/// Format [Uri](https://docs.gtk.org/glib/struct.Uri.html) -/// as [MenuItem](https://docs.gtk.org/gio/class.MenuItem.html) label -fn uri_to_label(uri: &Uri, is_parent: bool) -> GString { - let path = uri.path(); - if path == "/" || path.is_empty() { - if is_parent { - uri.host().unwrap_or(uri.to_str()) - } else { - gtk::glib::gformat!("{}{path}", uri.host().unwrap_or(uri.to_str())) - } - } else { - path - } -} - -/// Shared helper to create new [MenuItem](https://docs.gtk.org/gio/class.MenuItem.html) -fn menu_item(action: &WindowAction, uri: &Uri, is_parent: bool) -> gio::MenuItem { - let item = gio::MenuItem::new( - Some(&ellipsize(&uri_to_label(uri, is_parent), LABEL_MAX_LENGTH)), - None, - ); - item.set_action_and_target_value( - Some(&format!( - "{}.{}", - action.id, - action.load.simple_action.name() - )), - Some(&uri.to_string().to_variant()), - ); - item -} diff --git a/src/profile/bookmark.rs b/src/profile/bookmark.rs index 509b5647..34c4abea 100644 --- a/src/profile/bookmark.rs +++ b/src/profile/bookmark.rs @@ -50,9 +50,11 @@ impl Bookmark { false } None => { + let time = DateTime::now_local()?; memory.add(Item { - id: self.database.add(DateTime::now_local()?, request, title)?, + id: self.database.add(time.clone(), request, title)?, request: request.into(), + time, title: title.map(|t| t.to_string()), }); true diff --git a/src/profile/bookmark/database.rs b/src/profile/bookmark/database.rs index a488f5bc..133d6125 100644 --- a/src/profile/bookmark/database.rs +++ b/src/profile/bookmark/database.rs @@ -111,7 +111,7 @@ pub fn select( Ok(Item { id: row.get(0)?, //profile_id: row.get(1)?, - //time: DateTime::from_unix_local(row.get(2)?).unwrap(), + time: DateTime::from_unix_local(row.get(2)?).unwrap(), request: row.get(3)?, title: row.get(4)?, }) diff --git a/src/profile/bookmark/item.rs b/src/profile/bookmark/item.rs index fbb3f8c6..fc0b0444 100644 --- a/src/profile/bookmark/item.rs +++ b/src/profile/bookmark/item.rs @@ -2,5 +2,6 @@ pub struct Item { pub id: i64, pub request: String, + pub time: gtk::glib::DateTime, pub title: Option, }