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 @@
+
+
+
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 @@
+
+
+
+
+ Edit %s
+ apply
+
+
+
+
+
+
+
+
+ cancel
+ apply
+
+
+
+
+
+
+
+
+
+
+
+
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'
]