implement custom search providers feature

This commit is contained in:
yggverse 2025-01-30 15:53:26 +02:00
parent 58d4439fcf
commit b8a8fb49de
16 changed files with 668 additions and 30 deletions

View File

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

View File

@ -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({

View File

@ -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<Cancellable>,
driver: Rc<Driver>,
page: Rc<Page>,
profile: Rc<Profile>,
}
impl Client {
// Constructors
/// Create new `Self`
pub fn init(page: &Rc<Page>) -> Self {
pub fn init(profile: &Rc<Profile>, page: &Rc<Page>) -> 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<Profile>,
query: &str,
cancellable: Cancellable,
callback: impl FnOnce(Rc<Feature>, Cancellable, Result<Uri, Uri>) + '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

View File

@ -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<Profile>);
fn show_search_dialog(&self, profile: &Rc<Profile>);
// 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<Profile>) {
// 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<Profile>) {
use search::Search;
AlertDialog::search(profile).present(Some(self))
}
// Setters
fn to_download(&self) {

View File

@ -54,6 +54,6 @@ pub fn from(request: &str) -> PrimaryIcon {
PrimaryIcon::Search {
name: "system-search-symbolic",
tooltip: "Search",
tooltip: "Choose default search provider",
}
}

View File

@ -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<Profile>) -> Self;
}
impl Search for AlertDialog {
// Constructors
/// Create new `Self`
fn search(profile: &Rc<Profile>) -> 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::<Item>()
.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());
}
}
}

View File

@ -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<List>,
pub query: Entry,
pub g_box: Box,
}
impl Form {
// Constructors
/// Create new `Self`
pub fn build(profile: &Rc<Profile>) -> 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,
}
}
}

View File

@ -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<Profile>, list: &Rc<List>) -> Self;
}
impl Drop for Button {
// Constructors
/// Create new `Self`
fn drop(profile: &Rc<Profile>, list: &Rc<List>) -> 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
}
}

View File

@ -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<Profile>) -> Self {
// Init dropdown items
let new_search_provider = Item::add();
// Init model
let list_store = ListStore::new::<Item>();
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::<ListItem>()
.unwrap()
.set_child(Some(&child));
});
factory.connect_bind(|_, this| {
// Downcast requirements
let list_item = this.downcast_ref::<ListItem>().unwrap();
let item = list_item.item().and_downcast::<Item>().unwrap();
let child = list_item.child().and_downcast::<Box>().unwrap();
// Bind `title`
match child.first_child().and_downcast::<Label>() {
Some(label) => {
label.set_label(&item.title());
label.set_css_classes(if item.is_default() { &["accent"] } else { &[] });
item.bind_property("title", &label, "label").build(); // sync label
item.bind_property("is-default", &label, "css-classes")
.transform_to(|_, is_default| {
if is_default {
Some(vec!["accent".to_string()])
} else {
Some(vec![])
}
})
.build(); // sync class by status
}
None => todo!(),
}
});
// Init main widget
let dropdown = DropDown::builder()
.model(&list_store)
.selected(
list_store
.find_with_equal_func(|item| {
item.dynamic_cast_ref::<Item>().unwrap().is_default()
})
.unwrap_or_default(),
)
.factory(&factory)
.build();
// Return activated `Self`
Self {
dropdown,
list_store,
}
}
// Actions
/// Find list item by `profile_search_id`
/// * return `position` found
pub fn find(&self, profile_search_id: i64) -> Option<u32> {
self.list_store.find_with_equal_func(|this| {
profile_search_id == this.downcast_ref::<Item>().unwrap().value()
})
}
/// Remove list item by `profile_search_id`
/// * return `position` of removed list item
pub fn remove(&self, profile_search_id: i64) -> Option<u32> {
match self.find(profile_search_id) {
Some(position) => {
self.list_store.remove(position);
Some(position)
}
None => todo!(),
}
}
// Getters
/// Get selected `Item` GObject
pub fn selected(&self) -> Item {
self.dropdown
.selected_item()
.and_downcast::<Item>()
.unwrap()
}
}

View File

@ -0,0 +1,46 @@
mod imp;
pub mod value;
use gtk::glib::{self, Object, Uri, UriFlags};
pub use value::Value;
glib::wrapper! {
pub struct Item(ObjectSubclass<imp::Item>);
}
// C-type property `value` conversion for `Item`
// * values > 0 reserved for `profile_search_id`
const G_VALUE_ADD: i64 = 0;
impl Item {
// Constructors
pub fn add() -> Self {
Object::builder()
.property("value", G_VALUE_ADD)
.property("title", "Add new..")
.property("is-default", false)
.build()
}
pub fn profile_search_id(profile_search_id: i64, query: &str, is_default: bool) -> Self {
Object::builder()
.property("value", profile_search_id)
.property(
"title",
Uri::parse(query, UriFlags::NONE).unwrap().host().unwrap(),
) // @TODO handle
.property("is-default", is_default)
.build()
}
// Getters
/// Get `Self` C-value as `Value`
pub fn value_enum(&self) -> Value {
match self.value() {
G_VALUE_ADD => Value::Add,
value => Value::ProfileSearchId(value),
}
}
}

View File

@ -0,0 +1,34 @@
//! Custom `GObject` implementation for dropdown
//! [ListStore](https://docs.gtk.org/gio/class.ListStore.html) menu item
use gtk::{
gio::subclass::prelude::{DerivedObjectProperties, ObjectImpl, ObjectImplExt, ObjectSubclass},
glib::{self, Object, Properties},
prelude::ObjectExt,
};
use std::cell::{Cell, RefCell};
#[derive(Properties, Default)]
#[properties(wrapper_type = super::Item)]
pub struct Item {
#[property(get, set)]
value: Cell<i64>,
#[property(get, set)]
title: RefCell<String>,
#[property(get, set)]
is_default: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for Item {
const NAME: &'static str = "SearchItem"; // @TODO make globally unique
type Type = super::Item;
type ParentType = Object;
}
#[glib::derived_properties]
impl ObjectImpl for Item {
fn constructed(&self) {
self.parent_constructed();
}
}

View File

@ -0,0 +1,5 @@
#[derive(Debug)]
pub enum Value {
Add,
ProfileSearchId(i64),
}

View File

@ -0,0 +1,58 @@
use gtk::{
glib::{Uri, UriFlags},
prelude::{EditableExt, EntryExt},
Entry,
};
const MIN_LENGTH: u16 = 1;
const MAX_LENGTH: u16 = 1024;
pub trait Query {
// Constructors
fn query() -> Self;
// Actions
fn uri(&self) -> Result<Uri, String>;
// Getters
fn is_valid(&self) -> bool;
}
impl Query for Entry {
// Constructors
/// Create new `Self`
fn query() -> Self {
Entry::builder()
.margin_top(8)
.max_length(MAX_LENGTH as i32)
.placeholder_text("Provider query URL")
.visible(false)
.build()
}
fn uri(&self) -> Result<Uri, String> {
match Uri::parse(&self.text(), UriFlags::NONE) {
Ok(uri) => {
if !uri.scheme().is_empty()
&& uri.host().is_some_and(|host| !host.is_empty())
&& uri.query().is_none()
{
Ok(uri)
} else {
Err("Invalid query URL".to_string())
}
}
Err(e) => Err(e.to_string()),
}
}
// Getters
fn is_valid(&self) -> bool {
self.text_length() >= MIN_LENGTH && self.text_length() <= MAX_LENGTH && self.uri().is_ok()
}
}

View File

@ -106,7 +106,7 @@ impl Profile {
// Init components
let bookmark = Rc::new(Bookmark::build(&connection, &profile_id));
let history = Rc::new(History::build(&connection, &profile_id));
let search = Rc::new(Search::build(&connection, &profile_id));
let search = Rc::new(Search::build(&connection, &profile_id).unwrap()); // @TODO handle
let identity = Rc::new(match Identity::build(&connection, &profile_id) {
Ok(result) => result,
Err(e) => todo!("{:?}", e.to_string()),

View File

@ -23,11 +23,21 @@ impl Search {
let database = Database::init(connection, profile_id);
let memory = Memory::init();
// Build initial index
index(&database, &memory)?;
match database.records() {
Ok(records) => {
// Init default search providers list on database empty
if records.is_empty() {
restore_defaults(&database)?
}
// Return new `Self`
Ok(Self { database, memory })
// Build initial index
index(&database, &memory)?;
// Return new `Self`
Ok(Self { database, memory })
}
Err(e) => Err(Error::Database(e)),
}
}
// Actions
@ -57,10 +67,25 @@ impl Search {
/// Delete record from `database` and `memory` index
pub fn delete(&self, id: i64) -> Result<(), Error> {
match self.database.delete(id) {
Ok(_) => Ok(index(&self.database, &self.memory)?),
Ok(_) => match self.database.records() {
Ok(records) => {
if records.is_empty() {
restore_defaults(&self.database)?
}
Ok(index(&self.database, &self.memory)?)
}
Err(e) => Err(Error::Database(e)),
},
Err(e) => Err(Error::Database(e)),
}
}
// Getters
/// Get default search provider from memory
pub fn default(&self) -> Option<database::Row> {
self.memory.default()
}
}
// Tools
@ -91,3 +116,16 @@ fn index(database: &Database, memory: &Memory) -> Result<(), Error> {
}
Ok(())
}
/// Create default search providers list for given profile
fn restore_defaults(database: &Database) -> Result<(), Error> {
for (provider, is_default) in &[
("gemini://kennedy.gemi.dev/search", true),
("gemini://tlgs.one/search/search", false),
] {
if let Err(e) = database.add(provider.to_string(), *is_default) {
return Err(Error::Database(e));
}
}
Ok(())
}

View File

@ -32,8 +32,20 @@ impl Memory {
self.index.borrow_mut().clear()
}
/// Get record by `ID`
// Getters
/// Get all records
pub fn records(&self) -> Vec<Row> {
self.index.borrow().clone()
}
/// Get all records
pub fn default(&self) -> Option<Row> {
for record in self.index.borrow().iter() {
if record.is_default {
return Some(record.clone());
}
}
None
}
}