diff --git a/GTG/gtk/application.py b/GTG/gtk/application.py index e2546913ae..bf205e2c68 100644 --- a/GTG/gtk/application.py +++ b/GTG/gtk/application.py @@ -44,6 +44,7 @@ from GTG.core.dates import Date from GTG.gtk.backends import BackendsDialog from GTG.gtk.browser.tag_editor import TagEditor +from GTG.gtk.browser.search_editor import SearchEditor from GTG.core.timer import Timer from GTG.gtk.errorhandler import do_error_dialog @@ -513,6 +514,19 @@ def open_tag_editor(self, tag): self.edit_tag_dialog.set_transient_for(self.browser) self.edit_tag_dialog.insert_action_group('app', self) + + def open_search_editor(self, search): + """Open Saved search editor dialog.""" + + self.edit_search_dialog = SearchEditor(self.req, self, search) + self.edit_search_dialog.set_transient_for(self.browser) + self.edit_search_dialog.insert_action_group('app', self) + + def close_search_editor(self): + """Close search editor dialog.""" + + self.edit_search_dialog = None + def close_tag_editor(self): """Close tag editor dialog.""" diff --git a/GTG/gtk/browser/__init__.py b/GTG/gtk/browser/__init__.py index 0a9c53c209..8c2b3ecc3f 100644 --- a/GTG/gtk/browser/__init__.py +++ b/GTG/gtk/browser/__init__.py @@ -32,3 +32,4 @@ class GnomeConfig(): MENUS_UI_FILE = os.path.join(data, "context_menus.ui") MODIFYTAGS_UI_FILE = os.path.join(data, "modify_tags.ui") TAG_EDITOR_UI_FILE = os.path.join(data, "tag_editor.ui") + SEARCH_EDITOR_UI_FILE = os.path.join(data, "search_editor.ui") diff --git a/GTG/gtk/browser/search_editor.py b/GTG/gtk/browser/search_editor.py new file mode 100644 index 0000000000..ff946a8f76 --- /dev/null +++ b/GTG/gtk/browser/search_editor.py @@ -0,0 +1,243 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +""" +This module contains the SearchEditor class which is a window that allows the +user to edit a saved search properties. +""" +from gi.repository import GObject, Gtk, Gdk, GdkPixbuf, GLib + +import logging +import random +from gettext import gettext as _ +from GTG.gtk.browser import GnomeConfig + +log = logging.getLogger(__name__) + + +@Gtk.Template(filename=GnomeConfig.SEARCH_EDITOR_UI_FILE) +class SearchEditor(Gtk.Dialog): + """ + A window to edit certain properties of a saved search. + """ + + __gtype_name__ = 'GTG_SearchEditor' + _emoji_chooser = Gtk.Template.Child('emoji-chooser') + _icon_button = Gtk.Template.Child('icon-button') + _name_entry = Gtk.Template.Child('name-entry') + + def __init__(self, req, app, search=None): + super().__init__(use_header_bar=1) + + set_icon_shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string("I"), + Gtk.CallbackAction.new(self._set_icon)) + + self.add_shortcut(set_icon_shortcut) + + self.req = req + self.app = app + self.search = search + + self.set_transient_for(app.browser) + self._title_format = self.get_title() + self._emoji_chooser.set_parent(self._icon_button) + + self.is_valid = True + self._emoji = None + self.use_icon = False + self._search_name = '' + + self.set_search(search) + self.show() + + + @GObject.Property(type=str, default='') + def search_name(self): + """The (new) name of the search.""" + + return self._search_name + + @search_name.setter + def search_name(self, value: str): + self._search_name = value + self._validate() + + @GObject.Property(type=str, default='') + def search_query(self): + """The (new) name of the search.""" + + return self._search_query + + @search_query.setter + def search_query(self, value: str): + self._search_query = value + self._validate() + + @GObject.Property(type=bool, default=True) + def is_valid(self): + """ + Whenever it is valid to apply the changes (like malformed search name). + """ + + return self._is_valid + + @is_valid.setter + def is_valid(self, value: bool): + self._is_valid = value + + @GObject.Property(type=bool, default=False) + def has_icon(self): + """ + Whenever the search will have an icon. + """ + + return bool(self._emoji) + + def _validate(self): + """ + Validates the current search preferences. + Returns true whenever it passes validation, False otherwise, + and modifies the is_valid property appropriately. + On failure, the widgets are modified accordingly to show the user + why it doesn't accept it. + """ + + valid = True + valid &= self._validate_search_name() + self.is_valid = valid + return valid + + def _validate_search_name(self): + """ + Validates the current search name. + Returns true whenever it passes validation, False otherwise. + On failure, the widgets are modified accordingly to show the user + why it doesn't accept it. + """ + + if self.search_name == '': + self._name_entry.add_css_class("error") + self._name_entry.props.tooltip_text = \ + _("search name can not be empty") + return False + else: + self._name_entry.remove_css_class("error") + self._name_entry.props.tooltip_text = "" + return True + + def set_search(self, search): + """ + Set the search to edit. + Widgets are updated with the information of the search, + the previous information/state is lost. + """ + self.search = search + if search is None: + return + + icon = search.icon + self._set_emoji(self._emoji_chooser, text=icon if icon else '') + + self.search_name = search.name + self.search_query = search.query + self.set_title(self._title_format % self.search_name) + + + def do_destroy(self): + + self.app.close_search_editor() + super().destroy() + + def _cancel(self): + """ + Cancel button has been clicked, closing the editor window without + applying changes. + """ + + self.destroy() + + def _apply(self): + """ + Apply button has been clicked, applying the settings and closing the + editor window. + """ + if self.search is None: + log.warning("Trying to apply but no search set, shouldn't happen") + self._cancel() + return + + if self.has_icon and self._emoji: + self.search.icon = self._emoji + elif self.has_icon: # Should never happen, but just in case + log.warning("Tried to set icon for %r but no icon given", + self.search.name) + self.search.icon = None + else: + self.search.icon = None + + if self.search_name != self.search.name: + log.debug("Renaming %r → %r", self.search.name, self.search_name) + self.search.name = self.search_name + + self.app.ds.saved_searches.model.emit('items-changed', 0, 0, 0) + self.destroy() + + # CALLBACKS ##### + @Gtk.Template.Callback('response') + def _response(self, widget: GObject.Object, response: Gtk.ResponseType): + if response == Gtk.ResponseType.APPLY: + self._apply() + else: + self._cancel() + + + @Gtk.Template.Callback('set_icon') + def _set_icon(self, widget: GObject.Object, shargs: GLib.Variant = None): + """ + Button to set the icon/emoji has been clicked. + """ + self._emoji_chooser.popup() + + @Gtk.Template.Callback('emoji_set') + def _set_emoji(self, widget: GObject.Object, text: str = None): + """ + Callback when an emoji has been inserted. + """ + self._emoji = text if text else None + + if text: + self._emoji = text + self._icon_button.set_label(text) + if label := self._icon_button.get_child(): + label.set_opacity(1) + else: + self._emoji = None + self._icon_button.set_label('🏷️') + if label := self._icon_button.get_child(): + label.set_opacity(0.4) + + self.notify('has-icon') + + @Gtk.Template.Callback('remove_icon') + def _remove_icon(self, widget: GObject.Object): + """ + Callback to remove the icon. + """ + + self._set_emoji(self._emoji_chooser, text='') diff --git a/GTG/gtk/browser/sidebar.py b/GTG/gtk/browser/sidebar.py index 3c8f54ac3b..49707a6057 100644 --- a/GTG/gtk/browser/sidebar.py +++ b/GTG/gtk/browser/sidebar.py @@ -23,7 +23,7 @@ from GTG.core.tags2 import Tag2 from GTG.core.saved_searches import SavedSearch from GTG.core.datastore2 import Datastore2 -from GTG.gtk.browser.tag_context_menu import TagContextMenu +from GTG.gtk.browser.sidebar_context_menu import TagContextMenu, SearchesContextMenu @@ -33,6 +33,12 @@ class TagBox(Gtk.Box): tag = GObject.Property(type=Tag2) +class SearchBox(Gtk.Box): + """Box subclass to keep a pointer to the tag object""" + + search = GObject.Property(type=SavedSearch) + + class Sidebar(Gtk.ScrolledWindow): def __init__(self, app, ds: Datastore2): @@ -159,7 +165,7 @@ def __init__(self, app, ds: Datastore2): - def on_RMB_click(self, gesture, sequence): + def on_tag_RMB_click(self, gesture, sequence): menu = TagContextMenu(self.ds, self.app, gesture.get_widget().tag) menu.set_parent(gesture.get_widget()) @@ -174,6 +180,21 @@ def on_RMB_click(self, gesture, sequence): menu.popup() + def on_searches_RMB_click(self, gesture, sequence): + + menu = SearchesContextMenu(self.ds, self.app, gesture.get_widget().search) + menu.set_parent(gesture.get_widget()) + menu.set_halign(Gtk.Align.START) + menu.set_position(Gtk.PositionType.BOTTOM) + + point = gesture.get_point(sequence) + rect = Gdk.Rectangle() + rect.x = point.x + rect.y = point.y + menu.set_pointing_to(rect) + menu.popup() + + def btn_item(self, icon_name:str, text: str, count: str) -> Gtk.Box: box = Gtk.Box() @@ -241,7 +262,7 @@ def tags_setup_cb(self, factory, listitem, user_data=None) -> None: listitem.set_child(box) tags_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) - tags_RMB_controller.connect('begin', self.on_RMB_click) + tags_RMB_controller.connect('begin', self.on_tag_RMB_click) box.add_controller(tags_RMB_controller) self.expanders.add(expander) @@ -322,7 +343,7 @@ def on_search_reveal(self, event) -> None: def searches_setup_cb(self, factory, listitem, user_data=None) -> None: - box = Gtk.Box() + box = SearchBox() label = Gtk.Label() icon = Gtk.Label() count_label = Gtk.Label() @@ -342,9 +363,9 @@ def searches_setup_cb(self, factory, listitem, user_data=None) -> None: box.append(count_label) listitem.set_child(box) - # tags_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) - # tags_RMB_controller.connect('begin', self.on_RMB_click) - # box.add_controller(tags_RMB_controller) + searches_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) + searches_RMB_controller.connect('begin', self.on_searches_RMB_click) + box.add_controller(searches_RMB_controller) def searches_bind_cb(self, signallistitem, listitem, user_data=None) -> None: @@ -354,11 +375,14 @@ def searches_bind_cb(self, signallistitem, listitem, user_data=None) -> None: count_label = icon.get_next_sibling() box = listitem.get_child() + # HACK: Ugly! But apparently necessary item = listitem.get_item() while type(item) is not SavedSearch: item = item.get_item() + box.search = item + item.bind_property('name', label, 'label', GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE) item.bind_property('icon', icon, 'label', GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE) diff --git a/GTG/gtk/browser/tag_context_menu.py b/GTG/gtk/browser/sidebar_context_menu.py similarity index 70% rename from GTG/gtk/browser/tag_context_menu.py rename to GTG/gtk/browser/sidebar_context_menu.py index 1a939c9fde..e475d6f272 100644 --- a/GTG/gtk/browser/tag_context_menu.py +++ b/GTG/gtk/browser/sidebar_context_menu.py @@ -77,4 +77,43 @@ def on_mi_cc_activate(self, widget, action_name, param): def on_mi_ctag_activate(self, widget, action_name, param): self.tag.color = self.ds.tags.generate_color() - self.ds.tags.tree_model.emit('items-changed', 0, 0, 0) \ No newline at end of file + self.ds.tags.tree_model.emit('items-changed', 0, 0, 0) + + +class SearchesContextMenu(Gtk.PopoverMenu): + """Context menu fo the tag i the sidebar""" + + def __init__(self, ds, app, search): + super().__init__(has_arrow=False) + self.search = search + self.app = app + self.ds = ds + + actions = [ + ("edit_search", lambda w, a, p: app.open_search_editor(search)), + ("delete_search", lambda w, a, p : ds.saved_searches.remove(self.search.id)) + ] + + for action_disc in actions: + self.install_action( + ".".join(["search_popup", action_disc[0]]), None, action_disc[1]) + + # Build up the menu + self.build_menu() + + + def build_menu(self): + """Build up the widget""" + + menu_builder = Gtk.Builder() + menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE) + menu_model = menu_builder.get_object("search_context_menu") + + self.set_menu_model(menu_model) + + + # CALLBACKS ############################################################### + def on_mi_cc_activate(self, widget, action_name, param): + """Callback: show the tag editor upon request""" + + # self.app.open_tag_editor(self.tag) \ No newline at end of file diff --git a/GTG/gtk/data/context_menus.ui b/GTG/gtk/data/context_menus.ui index 286e1daef0..92f48830ee 100644 --- a/GTG/gtk/data/context_menus.ui +++ b/GTG/gtk/data/context_menus.ui @@ -241,4 +241,16 @@ + + + Edit... + search_popup.edit_search + + + Delete + search_popup.delete_search + + + + diff --git a/GTG/gtk/data/search_editor.ui b/GTG/gtk/data/search_editor.ui new file mode 100644 index 0000000000..467fc626a6 --- /dev/null +++ b/GTG/gtk/data/search_editor.ui @@ -0,0 +1,140 @@ + + + + + + + + + + diff --git a/GTG/gtk/meson.build b/GTG/gtk/meson.build index b19fefe362..f59c10416c 100644 --- a/GTG/gtk/meson.build +++ b/GTG/gtk/meson.build @@ -37,7 +37,8 @@ gtg_browser_sources = [ 'browser/main_window.py', 'browser/modify_tags.py', 'browser/simple_color_selector.py', - 'browser/tag_context_menu.py', + 'browser/sidebar_context_menu.py', + 'browser/search_editor.py', 'browser/tag_editor.py', 'browser/treeview_factory.py', 'browser/quick_add.py', @@ -55,6 +56,7 @@ gtg_data_sources = [ 'data/preferences.ui', 'data/style.css', 'data/tag_editor.ui', + 'data/search_editor.ui', 'data/task_editor.ui', 'data/recurring_menu.ui' ]