mirror of
https://github.com/YGGverse/Yoda.git
synced 2025-09-08 21:01:53 +00:00
implement custom search providers feature
This commit is contained in:
parent
58d4439fcf
commit
b8a8fb49de
@ -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)
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -54,6 +54,6 @@ pub fn from(request: &str) -> PrimaryIcon {
|
||||
|
||||
PrimaryIcon::Search {
|
||||
name: "system-search-symbolic",
|
||||
tooltip: "Search",
|
||||
tooltip: "Choose default search provider",
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
#[derive(Debug)]
|
||||
pub enum Value {
|
||||
Add,
|
||||
ProfileSearchId(i64),
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()),
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user