diff --git a/src/ui/forms/entry.rs b/src/ui/forms/entry.rs index 7dbcb80..9d9d388 100644 --- a/src/ui/forms/entry.rs +++ b/src/ui/forms/entry.rs @@ -1,6 +1,7 @@ use super::base::*; +use crate::ui::suggestion_entry::PSSuggestionEntry; use crate::utils::string::StringExt; -use gtk::{glib, prelude::*}; +use gtk::prelude::*; pub fn form_entry() -> gtk::Entry { gtk::Entry::builder() @@ -10,26 +11,13 @@ pub fn form_entry() -> gtk::Entry { .build() } -pub fn form_entry_with_completion(items: &[String]) -> gtk::Entry { - let model = gtk::ListStore::new(&[glib::Type::STRING]); +pub fn form_entry_with_completion(items: &[String]) -> PSSuggestionEntry { + let model = gtk::StringList::new(&[]); for item in items { - let iter = model.append(); - model.set_value(&iter, 0, &glib::Value::from(item)); + model.append(item); } - let completion = gtk::EntryCompletion::builder() - .model(&model) - .popup_set_width(true) - .build(); - // workaround for a bug in GTK https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=805110 - completion.set_text_column(0); - - gtk::Entry::builder() - .can_focus(true) - .activates_default(true) - .hexpand(true) - .completion(&completion) - .build() + PSSuggestionEntry::new(model.upcast_ref()) } pub fn form_password_entry() -> gtk::Entry { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4cb403c..b9817fc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,4 +20,5 @@ pub mod record_type_popover; pub mod record_view; pub mod search_bar; pub mod search_pane; +pub mod suggestion_entry; pub mod toast; diff --git a/src/ui/suggestion_entry/mod.rs b/src/ui/suggestion_entry/mod.rs new file mode 100644 index 0000000..906648e --- /dev/null +++ b/src/ui/suggestion_entry/mod.rs @@ -0,0 +1,317 @@ +use super::forms::base::FormWidget; +use crate::utils::string::StringExt; +use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*}; + +mod imp { + use super::*; + use crate::utils::style::StaticCssExt; + use std::cell::RefCell; + + pub struct PSSuggestionEntry { + pub selection: gtk::SingleSelection, + pub entry: gtk::Entry, + changed_id: RefCell>, + pub popup: gtk::Popover, + pub list: gtk::ListView, + pub filter: gtk::StringFilter, + } + + #[glib::object_subclass] + impl ObjectSubclass for PSSuggestionEntry { + const NAME: &'static str = "PSSuggestionEntry"; + type Type = super::PSSuggestionEntry; + type ParentType = gtk::Widget; + + fn new() -> Self { + Self { + selection: Default::default(), + entry: Default::default(), + changed_id: Default::default(), + popup: Default::default(), + list: Default::default(), + filter: gtk::StringFilter::builder() + .ignore_case(true) + .match_mode(gtk::StringFilterMatchMode::Substring) + .expression(>k::PropertyExpression::new( + gtk::StringObject::static_type(), + gtk::Expression::NONE, + "string", + )) + .build(), + } + } + } + + impl ObjectImpl for PSSuggestionEntry { + fn constructed(&self) { + self.parent_constructed(); + + let obj = self.obj(); + obj.set_layout_manager(Some(gtk::BinLayout::new())); + + obj.add_static_css( + include_str!("style.css"), + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + obj.add_css_class("suggestion"); + + self.entry.set_parent(&*obj); + let changed_id = self.entry.connect_changed(glib::clone!( + #[weak(rename_to = imp)] + self, + move |_| imp.text_changed() + )); + *self.changed_id.borrow_mut() = Some(changed_id); + + self.entry.connect_activate(glib::clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.set_popup_visible(false); + imp.accept_current_selection(); + } + )); + + self.popup.set_position(gtk::PositionType::Bottom); + self.popup.set_autohide(false); + self.popup.set_has_arrow(false); + self.popup.set_halign(gtk::Align::Start); + self.popup.add_css_class("menu"); + self.popup.set_parent(&*obj); + + let sw = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .max_content_height(400) + .propagate_natural_height(true) + .build(); + self.popup.set_child(Some(&sw)); + + self.list.set_single_click_activate(true); + self.list.connect_activate(glib::clone!( + #[weak(rename_to = imp)] + self, + move |_, _| { + imp.set_popup_visible(false); + imp.accept_current_selection(); + } + )); + + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder().xalign(0_f32).build(); + list_item.set_child(Some(&label)); + }); + factory.connect_bind(|_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + return; + }; + let Some(item) = list_item.item().and_downcast::() else { + return; + }; + let Some(label) = list_item.child().and_downcast::() else { + return; + }; + label.set_label(&item.string()); + }); + self.list.set_factory(Some(&factory)); + + sw.set_child(Some(&self.list)); + + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(glib::clone!( + #[weak(rename_to = imp)] + self, + #[upgrade_or] + glib::Propagation::Proceed, + move |_, keyval, _, state| imp.key_pressed(keyval, state) + )); + self.entry.add_controller(key_controller); + + // let leave_controller = gtk::EventControllerFocus::new(); + // leave_controller.connect_leave(glib::clone!( + // #[weak(rename_to = imp)] + // self, + // move |_| { + // if imp.popup.is_mapped() { + // imp.set_popup_visible(false); + // imp.accept_current_selection(); + // } + // } + // )); + // self.entry.add_controller(leave_controller); + } + + fn dispose(&self) { + while let Some(child) = self.obj().first_child() { + child.unparent(); + } + } + } + + impl WidgetImpl for PSSuggestionEntry {} + + impl PSSuggestionEntry { + pub fn set_model(&self, model: &gio::ListModel) { + let filter_model = + gtk::FilterListModel::new(Some(model.clone()), Some(self.filter.clone())); + + self.selection.set_model(Some(&filter_model)); + self.selection.set_autoselect(false); + self.selection.set_can_unselect(true); + self.selection.set_selected(gtk::INVALID_LIST_POSITION); + self.list.set_model(Some(&self.selection)); + } + + fn set_popup_visible(&self, visible: bool) { + if self.popup.is_visible() == visible { + return; + } + + if visible { + if !self.entry.has_focus() { + self.entry.grab_focus_without_selecting(); + } + + self.selection.set_selected(gtk::INVALID_LIST_POSITION); + self.popup.popup(); + } else { + self.popup.popdown(); + } + } + + fn text_changed(&self) { + let text = self.entry.text(); + self.filter.set_search(Some(text.as_str())); + self.set_popup_visible(self.selection.n_items() > 0); + } + + fn key_pressed(&self, keyval: gdk::Key, state: gdk::ModifierType) -> glib::Propagation { + const PAGE_STEP: i32 = 10; + + if !state.is_empty() { + return glib::Propagation::Proceed; + } + + match keyval { + gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter => { + self.set_popup_visible(false); + self.accept_current_selection(); + return glib::Propagation::Stop; + } + gdk::Key::Escape => { + if self.popup.is_mapped() { + self.set_popup_visible(false); + return glib::Propagation::Stop; + } + } + gdk::Key::Right | gdk::Key::KP_Right => { + self.entry.set_position(-1); + return glib::Propagation::Stop; + } + gdk::Key::Left | gdk::Key::KP_Left => { + return glib::Propagation::Proceed; + } + gdk::Key::Tab | gdk::Key::KP_Tab | gdk::Key::ISO_Left_Tab => { + self.set_popup_visible(false); + return glib::Propagation::Proceed; + } + _ => {} + } + + let delta = match keyval { + gdk::Key::Up | gdk::Key::KP_Up => Some(-1), + gdk::Key::Down | gdk::Key::KP_Down => Some(1), + gdk::Key::Page_Up => Some(-PAGE_STEP), + gdk::Key::Page_Down => Some(PAGE_STEP), + _ => None, + }; + if let Some(delta) = delta { + let total = self.selection.n_items(); + let selected = self.selection.selected(); + let new_selected = incr(selected, total, delta); + + self.selection.set_selected(new_selected); + self.list + .scroll_to(new_selected, gtk::ListScrollFlags::SELECT, None); + return glib::Propagation::Stop; + } + + return glib::Propagation::Proceed; + } + + fn accept_current_selection(&self) { + let Some(item) = self.selection.selected_item() else { + return; + }; + let Some(value) = item.downcast_ref::() else { + return; + }; + + self.set_text_without_handler(&value.string()); + } + + pub fn set_text_without_handler(&self, text: &str) { + if let Some(ref handler_id) = *self.changed_id.borrow() { + self.entry.block_signal(handler_id); + } + self.entry.set_text(text); + self.entry.set_position(-1); + if let Some(ref handler_id) = *self.changed_id.borrow() { + self.entry.unblock_signal(handler_id); + } + } + } +} + +glib::wrapper! { + pub struct PSSuggestionEntry(ObjectSubclass) + @extends gtk::Widget; +} + +impl PSSuggestionEntry { + pub fn new(model: &gio::ListModel) -> Self { + let this: Self = glib::Object::builder().build(); + this.imp().set_model(model); + this + } +} + +fn incr(value: u32, size: u32, delta: i32) -> u32 { + if value == gtk::INVALID_LIST_POSITION { + if delta < 0 { + size - 1 + } else if delta > 0 { + 0 + } else { + gtk::INVALID_LIST_POSITION + } + } else { + value.saturating_add_signed(delta).clamp(0, size - 1) + } +} + +impl FormWidget for PSSuggestionEntry { + fn get_widget(&self) -> gtk::Widget { + self.clone().upcast() + } + + fn get_value(&self) -> Option { + self.imp().entry.text().non_empty().map(|gs| gs.to_string()) + } + + fn set_value(&self, value: Option<&String>) { + self.imp() + .set_text_without_handler(&value.map(String::as_str).unwrap_or_default()); + } + + fn connect_changed(&mut self, callback: Box)>) { + self.imp().entry.connect_changed(move |entry| { + let value = entry.text().non_empty().map(|gs| gs.to_string()); + callback(value.as_ref()); + }); + } +} diff --git a/src/ui/suggestion_entry/style.css b/src/ui/suggestion_entry/style.css new file mode 100644 index 0000000..a698455 --- /dev/null +++ b/src/ui/suggestion_entry/style.css @@ -0,0 +1,28 @@ +entry.suggestion > popover.menu.background > contents { + padding: 0; +} + +entry.suggestion arrow { + -gtk-icon-source: -gtk-icontheme('pan-down-symbolic'); + min-height: 16px; + min-width: 16px; +} + +entry.suggestion > popover { + margin-top: 6px; + padding: 0; +} + +entry.suggestion > popover listview { + margin: 8px 0; +} + +entry.suggestion > popover listview > row { + padding: 8px; +} + +entry.suggestion > popover listview > row:selected { + outline-color: rgba(1,1,1,0.2); + color: @theme_text_color; + background-color: shade(#f6f5f4, 0.97); +}