implement separated dialogs for the Bookmarks and History menu items

This commit is contained in:
yggverse 2025-07-25 15:52:14 +03:00
parent e548efc93f
commit ebb38008e1
14 changed files with 378 additions and 171 deletions

View file

@ -34,7 +34,6 @@ anyhow = "1.0.97"
async-channel = "2.5.0" async-channel = "2.5.0"
ggemini = "0.19.0" ggemini = "0.19.0"
ggemtext = "0.7.0" ggemtext = "0.7.0"
indexmap = "2.7.0"
itertools = "0.14.0" itertools = "0.14.0"
# libspelling = "0.4.0" # libspelling = "0.4.0"
maxminddb = "0.26.0" maxminddb = "0.26.0"

View file

@ -1,12 +1,16 @@
mod about; mod about;
mod action; mod action;
mod bookmarks;
mod database; mod database;
mod history;
mod proxy; mod proxy;
mod widget; mod widget;
pub mod window; pub mod window;
use about::About; use about::About;
use action::Action; use action::Action;
use bookmarks::Bookmarks;
use history::History;
use proxy::Proxy; use proxy::Proxy;
use widget::Widget; use widget::Widget;
use window::Window; 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({ action.proxy.connect_activate({
let profile = profile.clone(); let profile = profile.clone();
let window = window.clone(); let window = window.clone();

View file

@ -1,12 +1,16 @@
mod about; mod about;
mod bookmarks;
mod close; mod close;
mod debug; mod debug;
mod history;
mod profile; mod profile;
mod proxy; mod proxy;
use about::About; use about::About;
use bookmarks::Bookmarks;
use close::Close; use close::Close;
use debug::Debug; use debug::Debug;
use history::History;
use profile::Profile; use profile::Profile;
use proxy::Proxy; use proxy::Proxy;
@ -21,8 +25,10 @@ use std::rc::Rc;
pub struct Action { pub struct Action {
// Actions // Actions
pub about: Rc<About>, pub about: Rc<About>,
pub bookmarks: Rc<Bookmarks>,
pub close: Rc<Close>, pub close: Rc<Close>,
pub debug: Rc<Debug>, pub debug: Rc<Debug>,
pub history: Rc<History>,
pub profile: Rc<Profile>, pub profile: Rc<Profile>,
pub proxy: Rc<Proxy>, pub proxy: Rc<Proxy>,
// Group // Group
@ -43,8 +49,10 @@ impl Action {
pub fn new() -> Self { pub fn new() -> Self {
// Init actions // Init actions
let about = Rc::new(About::new()); let about = Rc::new(About::new());
let bookmarks = Rc::new(Bookmarks::new());
let close = Rc::new(Close::new()); let close = Rc::new(Close::new());
let debug = Rc::new(Debug::new()); let debug = Rc::new(Debug::new());
let history = Rc::new(History::new());
let profile = Rc::new(Profile::new()); let profile = Rc::new(Profile::new());
let proxy = Rc::new(Proxy::new()); let proxy = Rc::new(Proxy::new());
@ -56,18 +64,22 @@ impl Action {
// Add action to given group // Add action to given group
simple_action_group.add_action(&about.simple_action); 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(&close.simple_action);
simple_action_group.add_action(&debug.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(&profile.simple_action);
simple_action_group.add_action(&proxy.simple_action); simple_action_group.add_action(&proxy.simple_action);
// Done // Done
Self { Self {
about, about,
bookmarks,
close, close,
debug, debug,
profile, profile,
proxy, proxy,
history,
id, id,
simple_action_group, simple_action_group,
} }

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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<String>,
}
pub trait Bookmarks {
fn bookmarks(window_action: &Rc<WindowAction>, profile: &Rc<Profile>) -> Self;
}
impl Bookmarks for adw::PreferencesDialog {
fn bookmarks(window_action: &Rc<WindowAction>, profile: &Rc<Profile>) -> Self {
let mut index: HashMap<GString, Vec<Record>> = 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
}
}

132
src/app/browser/history.rs Normal file
View file

@ -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<GString>,
}
pub trait History {
fn history(window_action: &Rc<WindowAction>, profile: &Rc<Profile>) -> Self;
}
impl History for adw::PreferencesDialog {
fn history(window_action: &Rc<WindowAction>, profile: &Rc<Profile>) -> Self {
let mut visited: HashMap<GString, Vec<Record>> = 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
}
}

View file

@ -1,4 +1,4 @@
mod action; pub mod action;
mod database; mod database;
mod header; mod header;
pub mod tab; pub mod tab;
@ -119,7 +119,6 @@ impl Window {
let g_box = Box::builder().orientation(Orientation::Vertical).build(); let g_box = Box::builder().orientation(Orientation::Vertical).build();
g_box.append(&ToolbarView::header( g_box.append(&ToolbarView::header(
(browser_action, &action), (browser_action, &action),
profile,
&tab.tab_view, &tab.tab_view,
)); ));
g_box.append(&tab.tab_view); g_box.append(&tab.tab_view);

View file

@ -1,17 +1,13 @@
mod bar; mod bar;
use super::{Action as WindowAction, BrowserAction, Profile}; use super::{Action as WindowAction, BrowserAction};
use adw::{TabView, ToolbarView}; use adw::{TabView, ToolbarView};
use bar::Bar; use bar::Bar;
use gtk::Box; use gtk::Box;
use std::rc::Rc; use std::rc::Rc;
pub trait Header { pub trait Header {
fn header( fn header(action: (&Rc<BrowserAction>, &Rc<WindowAction>), tab_view: &TabView) -> Self;
action: (&Rc<BrowserAction>, &Rc<WindowAction>),
profile: &Rc<Profile>,
tab_view: &TabView,
) -> Self;
} }
impl Header for ToolbarView { impl Header for ToolbarView {
@ -20,16 +16,11 @@ impl Header for ToolbarView {
/// Build new `Self` /// Build new `Self`
fn header( fn header(
(browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>), (browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>),
profile: &Rc<Profile>,
tab_view: &TabView, tab_view: &TabView,
) -> Self { ) -> Self {
let toolbar_view = ToolbarView::builder().build(); let toolbar_view = ToolbarView::builder().build();
toolbar_view.add_top_bar(&Box::bar( toolbar_view.add_top_bar(&Box::bar((browser_action, window_action), tab_view));
(browser_action, window_action),
profile,
tab_view,
));
toolbar_view toolbar_view
} }

View file

@ -6,17 +6,13 @@ use control::Control;
use menu::Menu; use menu::Menu;
use tab::Tab; use tab::Tab;
use super::{BrowserAction, Profile, WindowAction}; use super::{BrowserAction, WindowAction};
use adw::{TabBar, TabView}; use adw::{TabBar, TabView};
use gtk::{Box, MenuButton, Orientation, prelude::BoxExt}; use gtk::{Box, MenuButton, Orientation, prelude::BoxExt};
use std::rc::Rc; use std::rc::Rc;
pub trait Bar { pub trait Bar {
fn bar( fn bar(action: (&Rc<BrowserAction>, &Rc<WindowAction>), view: &TabView) -> Self;
action: (&Rc<BrowserAction>, &Rc<WindowAction>),
profile: &Rc<Profile>,
view: &TabView,
) -> Self;
} }
impl Bar for Box { impl Bar for Box {
@ -25,7 +21,6 @@ impl Bar for Box {
/// Build new `Self` /// Build new `Self`
fn bar( fn bar(
(browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>), (browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>),
profile: &Rc<Profile>,
view: &TabView, view: &TabView,
) -> Self { ) -> Self {
let g_box = Box::builder() let g_box = Box::builder()
@ -34,7 +29,7 @@ impl Bar for Box {
.build(); .build();
g_box.append(&TabBar::tab(window_action, view)); 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.append(&Control::new().window_controls);
g_box g_box
} }

View file

@ -1,18 +1,15 @@
use super::{BrowserAction, Profile, WindowAction}; use super::{BrowserAction, WindowAction};
use gtk::{ use gtk::{
Align, MenuButton, Align, MenuButton,
gio::{self}, gio::{self},
glib::{GString, Uri, UriFlags}, prelude::ActionExt,
prelude::{ActionExt, ToVariant},
}; };
use indexmap::IndexMap;
use std::rc::Rc; use std::rc::Rc;
// Config options // Config options
const LABEL_MAX_LENGTH: usize = 28;
pub trait Menu { pub trait Menu {
fn menu(action: (&Rc<BrowserAction>, &Rc<WindowAction>), profile: &Rc<Profile>) -> Self; fn menu(actions: (&Rc<BrowserAction>, &Rc<WindowAction>)) -> Self;
} }
#[rustfmt::skip] // @TODO template builder? #[rustfmt::skip] // @TODO template builder?
@ -22,7 +19,6 @@ impl Menu for MenuButton {
/// Build new `Self` /// Build new `Self`
fn menu( fn menu(
(browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>), (browser_action, window_action): (&Rc<BrowserAction>, &Rc<WindowAction>),
profile: &Rc<Profile>,
) -> Self { ) -> Self {
// Main // Main
let main = gio::Menu::new(); let main = gio::Menu::new();
@ -116,7 +112,7 @@ impl Menu for MenuButton {
window_action.history_forward.simple_action.name() 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); main_page.append_section(None, &main_page_navigation);
@ -139,29 +135,22 @@ impl Menu for MenuButton {
main.append_submenu(Some("Page"), &main_page); main.append_submenu(Some("Page"), &main_page);
// Main > Bookmark // Main > Bookmarks
// * menu items dynamically generated using profile memory pool and `set_create_popup_func` main.append(Some("Bookmarks"), Some(&format!(
let main_bookmarks = gio::Menu::new(); "{}.{}",
browser_action.id,
main.append_submenu(Some("Bookmarks"), &main_bookmarks); browser_action.bookmarks.simple_action.name()
)));
// Main > History // Main > History
let main_history = gio::Menu::new(); main.append(Some("History"), Some(&format!(
"{}.{}",
// Main > History > Recently closed browser_action.id,
// * menu items dynamically generated using profile memory pool and `set_create_popup_func` browser_action.history.simple_action.name()
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 > Proxy // Main > Proxy
main.append(Some("Proxy settings"), Some(&format!( main.append(Some("Proxy"), Some(&format!(
"{}.{}", "{}.{}",
browser_action.id, browser_action.id,
browser_action.proxy.simple_action.name() browser_action.proxy.simple_action.name()
@ -198,124 +187,12 @@ impl Menu for MenuButton {
))); )));
// Init main widget // Init main widget
let menu_button = MenuButton::builder() MenuButton::builder()
.css_classes(["flat"]) .css_classes(["flat"])
.icon_name("open-menu-symbolic") .icon_name("open-menu-symbolic")
.menu_model(&main) .menu_model(&main)
.tooltip_text("Menu") .tooltip_text("Menu")
.valign(Align::Center) .valign(Align::Center)
.build(); .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<GString, Vec<Uri>> = 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
} }
} }
/// 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
}

View file

@ -50,9 +50,11 @@ impl Bookmark {
false false
} }
None => { None => {
let time = DateTime::now_local()?;
memory.add(Item { memory.add(Item {
id: self.database.add(DateTime::now_local()?, request, title)?, id: self.database.add(time.clone(), request, title)?,
request: request.into(), request: request.into(),
time,
title: title.map(|t| t.to_string()), title: title.map(|t| t.to_string()),
}); });
true true

View file

@ -111,7 +111,7 @@ pub fn select(
Ok(Item { Ok(Item {
id: row.get(0)?, id: row.get(0)?,
//profile_id: row.get(1)?, //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)?, request: row.get(3)?,
title: row.get(4)?, title: row.get(4)?,
}) })

View file

@ -2,5 +2,6 @@
pub struct Item { pub struct Item {
pub id: i64, pub id: i64,
pub request: String, pub request: String,
pub time: gtk::glib::DateTime,
pub title: Option<String>, pub title: Option<String>,
} }