From b8a8fb49ded270f57795a98514c9e583d6ee4da9 Mon Sep 17 00:00:00 2001 From: yggverse Date: Thu, 30 Jan 2025 15:53:26 +0200 Subject: [PATCH] implement custom search providers feature --- README.md | 1 + src/app/browser/window/tab/item.rs | 2 +- src/app/browser/window/tab/item/client.rs | 35 +++-- .../tab/item/page/navigation/request.rs | 27 +++- .../page/navigation/request/primary_icon.rs | 2 +- .../item/page/navigation/request/search.rs | 138 ++++++++++++++++++ .../page/navigation/request/search/form.rs | 43 ++++++ .../navigation/request/search/form/drop.rs | 105 +++++++++++++ .../navigation/request/search/form/list.rs | 138 ++++++++++++++++++ .../request/search/form/list/item.rs | 46 ++++++ .../request/search/form/list/item/imp.rs | 34 +++++ .../request/search/form/list/item/value.rs | 5 + .../navigation/request/search/form/query.rs | 58 ++++++++ src/profile.rs | 2 +- src/profile/search.rs | 48 +++++- src/profile/search/memory.rs | 14 +- 16 files changed, 668 insertions(+), 30 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/drop.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/list.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/list/item.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/list/item/imp.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/list/item/value.rs create mode 100644 src/app/browser/window/tab/item/page/navigation/request/search/form/query.rs diff --git a/README.md b/README.md index 2ebabd76..391a15f4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ GTK 4 / Libadwaita client written in Rust * [x] Request * [ ] History * [ ] User settings + * [x] Custom search providers ### Protocols * [ ] [Gemini](https://geminiprotocol.net/docs/protocol-specification.gmi) diff --git a/src/app/browser/window/tab/item.rs b/src/app/browser/window/tab/item.rs index 5495bbe7..30bcbbf5 100644 --- a/src/app/browser/window/tab/item.rs +++ b/src/app/browser/window/tab/item.rs @@ -65,7 +65,7 @@ impl Item { )); // Update tab loading indicator - let client = Rc::new(Client::init(&page)); + let client = Rc::new(Client::init(profile, &page)); // Connect events action.home.connect_activate({ diff --git a/src/app/browser/window/tab/item/client.rs b/src/app/browser/window/tab/item/client.rs index 17948df6..3272fe4d 100644 --- a/src/app/browser/window/tab/item/client.rs +++ b/src/app/browser/window/tab/item/client.rs @@ -1,7 +1,7 @@ mod driver; mod feature; -use super::Page; +use super::{Page, Profile}; use driver::Driver; use feature::Feature; use gtk::{ @@ -16,17 +16,19 @@ pub struct Client { cancellable: Cell, driver: Rc, page: Rc, + profile: Rc, } impl Client { // Constructors /// Create new `Self` - pub fn init(page: &Rc) -> Self { + pub fn init(profile: &Rc, page: &Rc) -> Self { Self { cancellable: Cell::new(Cancellable::new()), driver: Rc::new(Driver::build(page)), page: page.clone(), + profile: profile.clone(), } } @@ -56,7 +58,7 @@ impl Client { } // run async resolver to detect Uri, scheme-less host, or search query - lookup(request, self.cancellable(), { + lookup(&self.profile, request, self.cancellable(), { let driver = self.driver.clone(); let page = self.page.clone(); move |feature, cancellable, result| { @@ -103,6 +105,7 @@ impl Client { /// Create request using async DNS resolver (slow method) /// * return suggestion [Uri](https://docs.gtk.org/glib/struct.Uri.html) on failure (to handle as redirect) fn lookup( + profile: &Rc, query: &str, cancellable: Cancellable, callback: impl FnOnce(Rc, Cancellable, Result) + 'static, @@ -138,6 +141,7 @@ fn lookup( &connectable.hostname(), Some(&cancellable.clone()), { + let profile = profile.clone(); let query = query.to_owned(); move |resolve| { callback( @@ -146,33 +150,32 @@ fn lookup( if resolve.is_ok() { match Uri::parse(&suggestion, UriFlags::NONE) { Ok(uri) => Err(uri), - Err(_) => Err(search(&query)), + Err(_) => Err(search(&profile, &query)), } } else { - Err(search(&query)) + Err(search(&profile, &query)) }, ) } }, ), - Err(_) => callback(feature, cancellable, Err(search(query))), + Err(_) => callback(feature, cancellable, Err(search(profile, query))), } } } } /// Convert `query` to default search provider [Uri](https://docs.gtk.org/glib/struct.Uri.html) -fn search(query: &str) -> Uri { - Uri::build( +fn search(profile: &Profile, query: &str) -> Uri { + Uri::parse( + &format!( + "{}?{}", + profile.search.default().unwrap().query, // @TODO handle + Uri::escape_string(query, None, false) + ), UriFlags::NONE, - "gemini", - None, - Some("kennedy.gemi.dev"), // tlgs.one was replaced by response time issue - -1, - "/search", - Some(&Uri::escape_string(query, None, false)), - None, - ) // @TODO optional settings + ) + .unwrap() // @TODO handle or skip extra URI parse by String return } /// Make new history record in related components diff --git a/src/app/browser/window/tab/item/page/navigation/request.rs b/src/app/browser/window/tab/item/page/navigation/request.rs index f8f1696f..e62f7eb6 100644 --- a/src/app/browser/window/tab/item/page/navigation/request.rs +++ b/src/app/browser/window/tab/item/page/navigation/request.rs @@ -1,6 +1,7 @@ mod database; mod identity; mod primary_icon; +mod search; use adw::{prelude::AdwDialogExt, AlertDialog}; use primary_icon::PrimaryIcon; @@ -44,6 +45,7 @@ pub trait Request { fn update_primary_icon(&self, profile: &Profile); fn show_identity_dialog(&self, profile: &Rc); + fn show_search_dialog(&self, profile: &Rc); // Setters @@ -78,7 +80,13 @@ impl Request for Entry { entry.connect_icon_release({ let profile = profile.clone(); move |this, position| match position { - EntryIconPosition::Primary => this.show_identity_dialog(&profile), + EntryIconPosition::Primary => { + if matches!(primary_icon::from(&this.text()), PrimaryIcon::Search { .. }) { + this.show_search_dialog(&profile) + } else { + this.show_identity_dialog(&profile) + } + } EntryIconPosition::Secondary => { this.activate(); } @@ -217,11 +225,10 @@ impl Request for Entry { // Update primary icon self.first_child().unwrap().remove_css_class("success"); // @TODO handle - self.set_primary_icon_activatable(false); - self.set_primary_icon_sensitive(false); - match primary_icon::from(&self.text()) { PrimaryIcon::Download { name, tooltip } => { + self.set_primary_icon_activatable(false); + self.set_primary_icon_sensitive(false); self.set_primary_icon_name(Some(name)); self.set_primary_icon_tooltip_text(Some(tooltip)); } @@ -237,17 +244,21 @@ impl Request for Entry { } } PrimaryIcon::Search { name, tooltip } => { + self.set_primary_icon_activatable(true); + self.set_primary_icon_sensitive(true); self.set_primary_icon_name(Some(name)); self.set_primary_icon_tooltip_text(Some(tooltip)); } PrimaryIcon::Source { name, tooltip } => { + self.set_primary_icon_activatable(false); + self.set_primary_icon_sensitive(false); self.set_primary_icon_name(Some(name)); self.set_primary_icon_tooltip_text(Some(tooltip)); } } } - /// Present identity [AlertDialog](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.AlertDialog.html) for `Self` + /// Present Identity [AlertDialog](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.AlertDialog.html) for `Self` fn show_identity_dialog(&self, profile: &Rc) { // connect identity traits use identity::{Common, Unsupported}; @@ -273,6 +284,12 @@ impl Request for Entry { AlertDialog::unsupported().present(Some(self)); } + /// Present Search providers [AlertDialog](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.AlertDialog.html) for `Self` + fn show_search_dialog(&self, profile: &Rc) { + use search::Search; + AlertDialog::search(profile).present(Some(self)) + } + // Setters fn to_download(&self) { diff --git a/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs b/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs index 4fe8e741..7db23798 100644 --- a/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs +++ b/src/app/browser/window/tab/item/page/navigation/request/primary_icon.rs @@ -54,6 +54,6 @@ pub fn from(request: &str) -> PrimaryIcon { PrimaryIcon::Search { name: "system-search-symbolic", - tooltip: "Search", + tooltip: "Choose default search provider", } } diff --git a/src/app/browser/window/tab/item/page/navigation/request/search.rs b/src/app/browser/window/tab/item/page/navigation/request/search.rs new file mode 100644 index 00000000..fbb4774c --- /dev/null +++ b/src/app/browser/window/tab/item/page/navigation/request/search.rs @@ -0,0 +1,138 @@ +mod form; + +use crate::Profile; +use adw::AlertDialog; +use adw::{ + prelude::{AlertDialogExt, AlertDialogExtManual}, + ResponseAppearance, +}; +use form::{list::item::Value, list::Item, Form, Query}; +use gtk::prelude::{EditableExt, WidgetExt}; +use sourceview::prelude::CastNone; +use std::rc::Rc; + +// Response variants +const RESPONSE_APPLY: (&str, &str) = ("apply", "Apply"); +const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel"); +// const RESPONSE_MANAGE: (&str, &str) = ("manage", "Manage"); + +pub trait Search { + fn search(profile: &Rc) -> Self; +} + +impl Search for AlertDialog { + // Constructors + + /// Create new `Self` + fn search(profile: &Rc) -> Self { + // Init child container + let form = Rc::new(Form::build(profile)); + + // Init main widget + let alert_dialog = AlertDialog::builder() + .heading("Search") + .body("Select default provider") + .close_response(RESPONSE_CANCEL.0) + .default_response(RESPONSE_APPLY.0) + .extra_child(&form.g_box) + .build(); + + alert_dialog.add_responses(&[ + RESPONSE_CANCEL, + // RESPONSE_MANAGE, + RESPONSE_APPLY, + ]); + + // Init events + + form.list.dropdown.connect_selected_item_notify({ + let alert_dialog = alert_dialog.clone(); + let form = form.clone(); + move |_| update(&alert_dialog, &form) + }); + + form.query.connect_changed({ + let alert_dialog = alert_dialog.clone(); + let form = form.clone(); + move |_| update(&alert_dialog, &form) + }); + + alert_dialog.connect_realize({ + let form = form.clone(); + move |this| update(this, &form) + }); + + alert_dialog.connect_response(Some(RESPONSE_APPLY.0), { + let form = form.clone(); + let profile = profile.clone(); + move |this, response| { + // Prevent double-click action + this.set_response_enabled(response, false); + + match form.list.selected().value_enum() { + Value::ProfileSearchId(profile_search_id) => { + if profile.search.set_default(profile_search_id).is_err() { + todo!() // unexpected @TODO handle + } + } + Value::Add => { + if profile + .search + .add(&form.query.uri().unwrap(), true) + .is_err() + { + todo!() // unexpected @TODO handle + } + } + } // @TODO thread::spawn(|| {}) + } + }); + + // Deactivate not implemented feature @TODO + // alert_dialog.set_response_enabled(RESPONSE_MANAGE.0, false); + + // Decorate default response preset + alert_dialog.set_response_appearance(RESPONSE_APPLY.0, ResponseAppearance::Suggested); + /* contrast issue with Ubuntu orange accents + alert_dialog.set_response_appearance(RESPONSE_CANCEL.0, ResponseAppearance::Destructive); */ + + // Return new activated `Self` + alert_dialog + } +} + +fn update(alert_dialog: &AlertDialog, form: &Form) { + match form + .list + .dropdown + .selected_item() + .and_downcast::() + .unwrap() + .value_enum() + { + Value::Add => { + form.drop.set_visible(false); + form.query.set_visible(true); + if form.query.focus_child().is_none() { + form.query.grab_focus(); + } + form.query.remove_css_class("error"); + alert_dialog.set_response_enabled( + RESPONSE_APPLY.0, + if form.query.is_valid() { + true + } else { + if !form.query.text().is_empty() { + form.query.add_css_class("error"); + } + false + }, + ); + } + Value::ProfileSearchId(_) => { + form.drop.set_visible(true); + form.query.set_visible(false); + alert_dialog.set_response_enabled(RESPONSE_APPLY.0, !form.list.selected().is_default()); + } + } +} diff --git a/src/app/browser/window/tab/item/page/navigation/request/search/form.rs b/src/app/browser/window/tab/item/page/navigation/request/search/form.rs new file mode 100644 index 00000000..7b62df07 --- /dev/null +++ b/src/app/browser/window/tab/item/page/navigation/request/search/form.rs @@ -0,0 +1,43 @@ +pub mod drop; +pub mod list; +pub mod query; + +use crate::Profile; +use drop::Drop; +use gtk::{prelude::BoxExt, Box, Button, Entry, Orientation}; +use list::List; +pub use query::Query; +use std::rc::Rc; + +pub struct Form { + pub drop: Button, + pub list: Rc, + pub query: Entry, + pub g_box: Box, +} + +impl Form { + // Constructors + + /// Create new `Self` + pub fn build(profile: &Rc) -> Self { + // Init components + let list = Rc::new(List::build(profile)); + let query = Entry::query(); + let drop = Button::drop(profile, &list); + + // Init main container + let g_box = Box::builder().orientation(Orientation::Vertical).build(); + + g_box.append(&list.dropdown); + g_box.append(&query); + g_box.append(&drop); + + Self { + drop, + list, + query, + g_box, + } + } +} diff --git a/src/app/browser/window/tab/item/page/navigation/request/search/form/drop.rs b/src/app/browser/window/tab/item/page/navigation/request/search/form/drop.rs new file mode 100644 index 00000000..ab178eb2 --- /dev/null +++ b/src/app/browser/window/tab/item/page/navigation/request/search/form/drop.rs @@ -0,0 +1,105 @@ +use super::list::{item::Value, List}; +use crate::profile::Profile; +use adw::{ + prelude::{AdwDialogExt, AlertDialogExt, AlertDialogExtManual}, + AlertDialog, ResponseAppearance, +}; +use gtk::{ + prelude::{ButtonExt, WidgetExt}, + Button, +}; +use std::rc::Rc; + +pub trait Drop { + fn drop(profile: &Rc, list: &Rc) -> Self; +} + +impl Drop for Button { + // Constructors + + /// Create new `Self` + fn drop(profile: &Rc, list: &Rc) -> Self { + // Defaults + + const LABEL: &str = "Delete"; + const TOOLTIP_TEXT: &str = "Drop selected provider from profile"; + const MARGIN: i32 = 8; + + const HEADING: &str = "Delete"; + const BODY: &str = "Delete selected provider from profile?"; + const RESPONSE_CANCEL: (&str, &str) = ("cancel", "Cancel"); + const RESPONSE_CONFIRM: (&str, &str) = ("confirm", "Confirm"); + + // Init main widget + let button = Button::builder() + .label(LABEL) + .margin_top(MARGIN) + .tooltip_text(TOOLTIP_TEXT) + .visible(false) + .build(); + + // Init events + button.connect_clicked({ + let button = button.clone(); + let list = list.clone(); + let profile = profile.clone(); + move |_| { + match list.selected().value_enum() { + Value::ProfileSearchId(profile_search_id) => { + // Init sub-widget + let alert_dialog = AlertDialog::builder() + .heading(HEADING) + .body(BODY) + .close_response(RESPONSE_CANCEL.0) + .default_response(RESPONSE_CANCEL.0) + .build(); + + // Set response variants + alert_dialog.add_responses(&[RESPONSE_CANCEL, RESPONSE_CONFIRM]); + + // Decorate default response preset + alert_dialog.set_response_appearance( + RESPONSE_CONFIRM.0, + ResponseAppearance::Suggested, + ); + + /* contrast issue with Ubuntu orange accents + alert_dialog.set_response_appearance( + RESPONSE_CANCEL.0, + ResponseAppearance::Destructive, + ); */ + + // Connect confirmation event + alert_dialog.connect_response(Some(RESPONSE_CONFIRM.0), { + let button = button.clone(); + let list = list.clone(); + let profile = profile.clone(); + move |_, _| match profile.search.delete(profile_search_id) { + Ok(_) => { + if list.remove(profile_search_id).is_some() { + button.set_css_classes(&["success"]); + button.set_label("Provider successfully deleted") + } else { + button.set_css_classes(&["error"]); + button.set_label("List item not found") + } + } + Err(e) => { + button.set_css_classes(&["error"]); + button.set_label(&e.to_string()) + } + } + }); + + // Show dialog + alert_dialog.present(Some(&button)) + } + _ => todo!(), // unexpected + } + } + }); + + // Return activated `Self` + button + } +} diff --git a/src/app/browser/window/tab/item/page/navigation/request/search/form/list.rs b/src/app/browser/window/tab/item/page/navigation/request/search/form/list.rs new file mode 100644 index 00000000..6dada90f --- /dev/null +++ b/src/app/browser/window/tab/item/page/navigation/request/search/form/list.rs @@ -0,0 +1,138 @@ +pub mod item; + +use crate::profile::Profile; +use gtk::{ + gio::{ + prelude::{Cast, CastNone}, + ListStore, + }, + prelude::{BoxExt, ListItemExt, ObjectExt, WidgetExt}, + Align, Box, DropDown, Label, ListItem, Orientation, SignalListItemFactory, +}; +pub use item::Item; +use std::rc::Rc; + +pub struct List { + pub dropdown: DropDown, + list_store: ListStore, +} + +impl List { + // Constructors + + /// Create new `Self` + pub fn build(profile: &Rc) -> Self { + // Init dropdown items + let new_search_provider = Item::add(); + + // Init model + let list_store = ListStore::new::(); + + list_store.append(&new_search_provider); + for record in profile.search.records() { + list_store.append(&Item::profile_search_id( + record.id, + &record.query, + record.is_default, + )) + } + + // Setup item factory + // * wanted only to append items after `DropDown` init + let factory = SignalListItemFactory::new(); + + factory.connect_setup(|_, this| { + // Init widget for dropdown item + // * legacy container, exists because maybe some other elements will be added later + let child = Box::builder() + .orientation(Orientation::Vertical) + .valign(Align::Center) + .build(); + + // Title + child.append(&Label::builder().halign(Align::Start).build()); + + // Done + this.downcast_ref::() + .unwrap() + .set_child(Some(&child)); + }); + + factory.connect_bind(|_, this| { + // Downcast requirements + let list_item = this.downcast_ref::().unwrap(); + let item = list_item.item().and_downcast::().unwrap(); + let child = list_item.child().and_downcast::().unwrap(); + + // Bind `title` + match child.first_child().and_downcast::