398 lines
13 KiB
Rust

mod database;
mod identity;
mod primary_icon;
mod search;
mod suggestion;
use super::{ItemAction, Profile};
use adw::{prelude::AdwDialogExt, AlertDialog};
use anyhow::Result;
use gtk::{
glib::{gformat, GString, Uri, UriFlags},
prelude::{EditableExt, EntryExt, WidgetExt},
Entry, EntryIconPosition, StateFlags,
};
use primary_icon::PrimaryIcon;
use sqlite::Transaction;
use std::{cell::Cell, rc::Rc};
use suggestion::Suggestion;
const PREFIX_DOWNLOAD: &str = "download:";
const PREFIX_SOURCE: &str = "source:";
pub struct Request {
pub entry: Entry,
suggestion: Rc<Suggestion>,
profile: Rc<Profile>,
}
impl Request {
// Constructors
/// Build new `Self`
pub fn build(item_action: &Rc<ItemAction>, profile: &Rc<Profile>) -> Self {
// Init main widget
let entry = Entry::builder()
.placeholder_text("URL or search term...")
.secondary_icon_tooltip_text("Go to the location")
.hexpand(true)
.build();
update_primary_icon(&entry, profile);
let suggestion = Rc::new(Suggestion::build(profile, &entry));
entry.add_controller({
use gtk::{
gdk::{Key, ModifierType},
glib::Propagation,
};
let c = gtk::EventControllerKey::builder().build();
c.connect_key_pressed({
let entry = entry.clone();
let suggestion = suggestion.clone();
move |_, k, _, m| {
if suggestion.is_visible()
&& !matches!(
m,
ModifierType::SHIFT_MASK
| ModifierType::ALT_MASK
| ModifierType::CONTROL_MASK
)
{
if matches!(k, Key::Up | Key::KP_Up | Key::Page_Up | Key::KP_Page_Up) {
if !suggestion.back() {
entry.error_bell()
}
return Propagation::Stop;
} else if matches!(
k,
Key::Down | Key::KP_Down | Key::Page_Down | Key::KP_Page_Down
) {
if !suggestion.next() {
entry.error_bell()
}
return Propagation::Stop;
}
}
Propagation::Proceed
}
});
c
});
entry.connect_icon_release({
let profile = profile.clone();
move |this, position| match position {
EntryIconPosition::Primary => {
if matches!(primary_icon::from(&this.text()), PrimaryIcon::Search { .. }) {
show_search_dialog(this, &profile)
} else {
show_identity_dialog(this, &profile)
}
}
EntryIconPosition::Secondary => this.emit_activate(),
_ => todo!(), // unexpected
}
});
entry.connect_has_focus_notify(update_secondary_icon);
suggestion
.signal_handler_id
.borrow_mut()
.replace(entry.connect_changed({
let profile = profile.clone();
let item_action = item_action.clone();
let suggestion = suggestion.clone();
move |this| {
// Update actions
item_action.reload.set_enabled(!this.text().is_empty());
item_action.home.set_enabled(home(this).is_some());
// Update icons
update_primary_icon(this, &profile);
update_secondary_icon(this);
// Show search suggestions
if this.focus_child().is_some() {
suggestion.update(None);
}
}
})); // `suggestion` wants `signal_handler_id` to block this event on autocomplete navigation
entry.connect_activate({
let item_action = item_action.clone();
let suggestion = suggestion.clone();
move |_| {
use gtk::prelude::ActionExt;
item_action.reload.activate(None);
suggestion.hide();
}
});
entry.connect_state_flags_changed({
// Define last focus state container
let has_focus = Cell::new(false);
move |this, state| {
// Select entire text on first click (release)
// this behavior implemented in most web-browsers,
// to simply overwrite current request with new value
// Note:
// * Custom GestureClick is not an option here, as GTK Entry has default controller
// * This is experimental feature does not follow native GTK behavior @TODO make optional
if !has_focus.take()
&& state.contains(StateFlags::ACTIVE | StateFlags::FOCUS_WITHIN)
&& this.selection_bounds().is_none()
{
this.select_region(0, -1);
}
// Update last focus state
has_focus.replace(state.contains(StateFlags::FOCUS_WITHIN));
}
});
Self {
entry,
suggestion,
profile: profile.clone(),
}
}
// Actions
pub fn escape(&self) {
self.suggestion.hide()
}
/// Try build home [Uri](https://docs.gtk.org/glib/struct.Uri.html) for `Self`
/// * return `None` if current request already match home or Uri not parsable
pub fn home(&self) -> Option<Uri> {
home(&self.entry)
}
/// Try get current request value as [Uri](https://docs.gtk.org/glib/struct.Uri.html)
/// * `strip_prefix` on parse
pub fn uri(&self) -> Option<Uri> {
uri(&self.entry)
}
pub fn show_identity_dialog(&self) {
show_identity_dialog(&self.entry, &self.profile)
}
pub fn clean(
&self,
transaction: &Transaction,
app_browser_window_tab_item_page_navigation_id: &i64,
) -> Result<()> {
for record in database::select(transaction, app_browser_window_tab_item_page_navigation_id)?
{
database::delete(transaction, &record.id)?;
// Delegate clean action to the item childs
// nothing yet..
}
Ok(())
}
pub fn restore(
&self,
transaction: &Transaction,
app_browser_window_tab_item_page_navigation_id: &i64,
) -> Result<()> {
for record in database::select(transaction, app_browser_window_tab_item_page_navigation_id)?
{
if let Some(text) = record.text {
self.entry.set_text(&text);
}
// Delegate restore action to the item childs
// nothing yet..
}
Ok(())
}
pub fn save(
&self,
transaction: &Transaction,
app_browser_window_tab_item_page_navigation_id: &i64,
) -> Result<()> {
// Keep value in memory until operation complete
let text = self.entry.text();
let _id = database::insert(
transaction,
app_browser_window_tab_item_page_navigation_id,
match text.is_empty() {
true => None,
false => Some(text.as_str()),
},
)?;
// Delegate save action to childs
// nothing yet..
Ok(())
}
// Setters
pub fn to_download(&self) {
self.entry.set_text(&self.download());
}
pub fn to_source(&self) {
self.entry.set_text(&self.source());
}
// Getters
pub fn is_file(&self) -> bool {
self.entry.text().starts_with("file://")
}
// Tools
/// Get request value with formatted `download` prefix
fn download(&self) -> GString {
gformat!("{PREFIX_DOWNLOAD}{}", prefix_less(&self.entry))
}
/// Get request value with formatted `source` prefix
fn source(&self) -> GString {
gformat!("{PREFIX_SOURCE}{}", prefix_less(&self.entry))
}
}
// Tools
pub fn migrate(tx: &Transaction) -> Result<()> {
// Migrate self components
database::init(tx)?;
// Delegate migration to childs
// nothing yet..
// Success
Ok(())
}
fn update_primary_icon(entry: &Entry, profile: &Profile) {
entry.first_child().unwrap().remove_css_class("success"); // @TODO handle
match primary_icon::from(&entry.text()) {
PrimaryIcon::Download { name, tooltip } | PrimaryIcon::File { name, tooltip } => {
entry.set_primary_icon_activatable(false);
entry.set_primary_icon_sensitive(false);
entry.set_primary_icon_name(Some(name));
entry.set_primary_icon_tooltip_text(Some(tooltip));
}
PrimaryIcon::Gemini { name, tooltip } | PrimaryIcon::Titan { name, tooltip } => {
entry.set_primary_icon_activatable(true);
entry.set_primary_icon_sensitive(true);
entry.set_primary_icon_name(Some(name));
if profile.identity.get(&prefix_less(entry)).is_some() {
entry.first_child().unwrap().add_css_class("success"); // @TODO handle
entry.set_primary_icon_tooltip_text(Some(tooltip.1));
} else {
entry.set_primary_icon_tooltip_text(Some(tooltip.0));
}
}
PrimaryIcon::Search { name, tooltip } => {
entry.set_primary_icon_activatable(true);
entry.set_primary_icon_sensitive(true);
entry.set_primary_icon_name(Some(name));
entry.set_primary_icon_tooltip_text(Some(tooltip));
}
PrimaryIcon::Source { name, tooltip } => {
entry.set_primary_icon_activatable(false);
entry.set_primary_icon_sensitive(false);
entry.set_primary_icon_name(Some(name));
entry.set_primary_icon_tooltip_text(Some(tooltip));
}
}
}
fn update_secondary_icon(entry: &Entry) {
if !entry.text().is_empty() && entry.focus_child().is_some_and(|text| text.has_focus()) {
entry.set_secondary_icon_name(Some("pan-end-symbolic"));
} else {
entry.set_secondary_icon_name(None);
entry.select_region(0, 0);
}
}
/// Present Identity [AlertDialog](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.AlertDialog.html) for `Self`
fn show_identity_dialog(entry: &Entry, profile: &Rc<Profile>) {
// connect identity traits
use identity::{Common, Unsupported};
if let Some(uri) = uri(entry) {
if ["gemini", "titan"].contains(&uri.scheme().as_str()) {
return AlertDialog::common(
profile,
&uri,
&Rc::new({
let profile = profile.clone();
let entry = entry.clone();
move |is_reload| {
update_primary_icon(&entry, &profile);
if is_reload {
entry.emit_activate();
}
}
}),
)
.present(Some(entry));
}
}
AlertDialog::unsupported().present(Some(entry));
}
/// Present Search providers [AlertDialog](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/class.AlertDialog.html) for `Self`
fn show_search_dialog(entry: &Entry, profile: &Rc<Profile>) {
use search::Search;
AlertDialog::search(profile).present(Some(entry))
}
/// Get current request value without system prefix
/// * the `prefix` is not `scheme`
fn prefix_less(entry: &Entry) -> GString {
let mut request = entry.text();
if let Some(postfix) = request.strip_prefix(PREFIX_SOURCE) {
request = postfix.into()
}
if let Some(postfix) = request.strip_prefix(PREFIX_DOWNLOAD) {
request = postfix.into()
}
request
}
/// Try get current request value as [Uri](https://docs.gtk.org/glib/struct.Uri.html)
/// * `strip_prefix` on parse
fn uri(entry: &Entry) -> Option<Uri> {
match Uri::parse(&prefix_less(entry), UriFlags::NONE) {
Ok(uri) => Some(uri),
_ => None,
}
}
/// Try build home [Uri](https://docs.gtk.org/glib/struct.Uri.html) for `Self`
/// * return `None` if current request already match home or Uri not parsable
fn home(entry: &Entry) -> Option<Uri> {
let uri = uri(entry)?;
if uri.path().len() > 1 || uri.query().is_some() || uri.fragment().is_some() {
Some(Uri::build(
UriFlags::NONE,
&if uri.scheme() == "titan" {
GString::from("gemini")
} else {
uri.scheme()
},
uri.userinfo().as_deref(),
uri.host().as_deref(),
uri.port(),
"/",
None,
None,
))
} else {
None
}
}