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/delete_tag.py b/GTG/gtk/browser/delete_tag.py
index 7273033e62..48aa23e27e 100644
--- a/GTG/gtk/browser/delete_tag.py
+++ b/GTG/gtk/browser/delete_tag.py
@@ -23,12 +23,11 @@
from gettext import gettext as _, ngettext
-class DeleteTagsDialog():
+class DeleteTagsDialog:
MAXIMUM_TAGS_TO_SHOW = 5
- def __init__(self, req, browser):
- self.req = req
+ def __init__(self, browser):
self.browser = browser
self.tags_todelete = []
@@ -37,16 +36,12 @@ def on_delete_confirm(self):
otherwise, we will look which tid is selected"""
for tag in self.tags_todelete:
- self.req.delete_tag(tag)
-
- # TODO: New Core
- the_tag = self.browser.app.ds.tags.find(tag)
- tasks = self.browser.app.ds.tasks.filter(Filter.TAG, the_tag)
+ tasks = self.browser.app.ds.tasks.filter(Filter.TAG, tag)
for t in tasks:
- t.remove_tag(tag)
+ t.remove_tag(tag.name)
- self.browser.app.ds.tags.remove(the_tag.id)
+ self.browser.app.ds.tags.remove(tag.id)
self.tags_todelete = []
@@ -61,7 +56,7 @@ def on_response(self, dialog, response, tagslist, callback):
if callback:
callback(tagslist)
- def delete_tags_async(self, tags=None, callback=None):
+ def show(self, tags=None, callback=None):
self.tags_todelete = tags or self.tags_todelete
if not self.tags_todelete:
@@ -97,9 +92,9 @@ def delete_tags_async(self, tags=None, callback=None):
if len(tagslist) == 1:
# Don't show a bulleted list if there's only one item
- titles = "".join(tag for tag in tagslist)
+ titles = "".join(tag.name for tag in tagslist)
else:
- titles = "".join("\nā¢ " + tag for tag in tagslist)
+ titles = "".join("\nā¢ " + tag.name for tag in tagslist)
# Build and run dialog
dialog = Gtk.MessageDialog(transient_for=self.browser, modal=True)
diff --git a/GTG/gtk/browser/main_window.py b/GTG/gtk/browser/main_window.py
index 00616ef86f..e09cc1bd4c 100644
--- a/GTG/gtk/browser/main_window.py
+++ b/GTG/gtk/browser/main_window.py
@@ -38,7 +38,8 @@
from GTG.gtk.browser.backend_infobar import BackendInfoBar
from GTG.gtk.browser.modify_tags import ModifyTagsDialog
from GTG.gtk.browser.delete_tag import DeleteTagsDialog
-from GTG.gtk.browser.tag_context_menu import TagContextMenu
+# from GTG.gtk.browser.tag_context_menu import TagContextMenu
+from GTG.gtk.browser.sidebar import Sidebar
# from GTG.gtk.browser.treeview_factory import TreeviewFactory
from GTG.gtk.editor.calendar import GTGCalendar
from GTG.gtk.tag_completion import TagCompletion
@@ -113,6 +114,8 @@ def __init__(self, requester, app):
# Timeout handler for search
self.search_timeout = None
+ self.sidebar_container.set_child(Sidebar(app, app.ds))
+
# Treeviews handlers
# self.vtree_panes = {}
# self.tv_factory = TreeviewFactory(self.req, self.config)
@@ -281,7 +284,7 @@ def _init_ui_widget(self):
# self.modifytags_dialog.set_transient_for(self)
self.modifytags_dialog = None
- self.deletetags_dialog = DeleteTagsDialog(self.req, self)
+ self.deletetags_dialog = DeleteTagsDialog(self)
self.calendar = GTGCalendar()
self.calendar.set_transient_for(self)
self.calendar.connect("date-changed", self.on_date_changed)
@@ -316,10 +319,10 @@ def init_tags_sidebar(self):
self.tagtreeview = self.tv_factory.tags_treeview(self.tagtree)
self.tagtreeview.get_selection().connect('changed', self.on_select_tag)
- self.tagpopup = TagContextMenu(self.req, self.app)
- self.tagpopup.set_parent(self.sidebar_container)
- self.tagpopup.set_halign(Gtk.Align.START)
- self.tagpopup.set_position(Gtk.PositionType.BOTTOM)
+ # self.tagpopup = TagContextMenu(self.req, self.app)
+ # self.tagpopup.set_parent(self.sidebar_container)
+ # self.tagpopup.set_halign(Gtk.Align.START)
+ # self.tagpopup.set_position(Gtk.PositionType.BOTTOM)
tagtree_gesture_single = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY)
tagtree_gesture_single.connect('begin', self.on_tag_treeview_click_begin)
@@ -977,9 +980,9 @@ def on_tag_treeview_key_press_event(self, controller, keyval, keycode, state):
self.on_delete_tag_activate()
return True
- def on_delete_tag_activate(self):
- tags = self.get_selected_tags()
- self.deletetags_dialog.delete_tags_async(tags)
+ def on_delete_tag_activate(self, tags=[]):
+ tags = tags or self.get_selected_tags()
+ self.deletetags_dialog.show(tags)
def on_delete_tag(self, event):
tags = self.get_selected_tags()
@@ -1786,4 +1789,4 @@ def expand_search_tag(self):
model = self.tagtreeview.get_model()
search_iter = model.my_get_iter((SEARCH_TAG, ))
search_path = model.get_path(search_iter)
- self.tagtreeview.expand_row(search_path, False)
+ self.tagtreeview.expand_row(search_path, False)
\ No newline at end of file
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
new file mode 100644
index 0000000000..4d8bd1207a
--- /dev/null
+++ b/GTG/gtk/browser/sidebar.py
@@ -0,0 +1,554 @@
+# -----------------------------------------------------------------------------
+# Getting Things GNOME! - a personal organizer for the GNOME desktop
+# Copyright (c) - The GTG Team
+#
+# 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 .
+# -----------------------------------------------------------------------------
+
+"""Sidebar widgets."""
+
+from gi.repository import Gtk, GObject, Gdk
+
+from GTG.core.tags2 import Tag2
+from GTG.core.saved_searches import SavedSearch
+from GTG.core.datastore2 import Datastore2
+from GTG.gtk.browser.sidebar_context_menu import TagContextMenu, SearchesContextMenu
+
+
+class TagBox(Gtk.Box):
+ """Box subclass to keep a pointer to the tag object"""
+
+ tag = GObject.Property(type=Tag2)
+
+
+class SearchBox(Gtk.Box):
+ """Box subclass to keep a pointer to the search object"""
+
+ search = GObject.Property(type=SavedSearch)
+
+
+def unwrap(row, expected_type):
+ """Find an item in TreeRow widget (sometimes nested)."""
+
+ item = row.get_item()
+
+ while type(item) is not expected_type:
+ item = item.get_item()
+
+ return item
+
+
+# Shorthands
+BIND_FLAGS = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE
+signal_block = GObject.signal_handler_block
+
+
+class Sidebar(Gtk.ScrolledWindow):
+ """The sidebar widget"""
+
+ def __init__(self, app, ds: Datastore2):
+
+ super(Sidebar, self).__init__()
+ self.ds = ds
+ self.app = app
+
+ wrap_box = Gtk.Box()
+ wrap_box.set_orientation(Gtk.Orientation.VERTICAL)
+ wrap_box.set_vexpand(True)
+ wrap_box.set_hexpand(True)
+
+ # -------------------------------------------------------------------------------
+ # General Filters
+ # -------------------------------------------------------------------------------
+
+ self.general_box = Gtk.ListBox()
+ self.general_box.get_style_context().add_class('navigation-sidebar')
+ self.box_handle = self.general_box.connect('row-selected', self.on_general_box_selected)
+
+ all_count = str(ds.task_count['open']['all'])
+ untag_count = str(ds.task_count['open']['untagged'])
+
+ self.all_btn = self.btn_item('emblem-documents-symbolic', 'All Tasks', all_count)
+ self.none_btn = self.btn_item('task-past-due-symbolic', 'Tasks with no tags', untag_count)
+
+ self.general_box.append(self.all_btn)
+ self.general_box.append(self.none_btn)
+ wrap_box.append(self.general_box)
+
+ separator = Gtk.Separator()
+ separator.set_sensitive(False)
+ wrap_box.append(separator)
+
+ # -------------------------------------------------------------------------------
+ # Saved Searches Section
+ # -------------------------------------------------------------------------------
+ searches_btn_box = Gtk.Box()
+ searches_button = Gtk.Button()
+ searches_button_label = Gtk.Label()
+ searches_button_label.set_markup('Saved Searches')
+ searches_button_label.set_xalign(0)
+ searches_button.get_style_context().add_class('flat')
+
+ searches_button.set_margin_top(6)
+ searches_button.set_margin_bottom(6)
+ searches_button.set_margin_start(6)
+ searches_button.set_margin_end(6)
+
+ searches_icon = Gtk.Image.new_from_icon_name('folder-saved-search-symbolic')
+ searches_icon.set_margin_end(6)
+ searches_btn_box.append(searches_icon)
+ searches_btn_box.append(searches_button_label)
+ searches_button.set_child(searches_btn_box)
+ searches_button.connect('clicked', self.on_search_reveal)
+
+ self.searches_selection = Gtk.SingleSelection.new(ds.saved_searches.model)
+ self.searches_selection.set_can_unselect(True)
+ self.search_handle = self.searches_selection.connect('selection-changed', self.on_search_selected)
+
+ searches_signals = Gtk.SignalListItemFactory()
+ searches_signals.connect('setup', self.searches_setup_cb)
+ searches_signals.connect('bind', self.searches_bind_cb)
+
+ searches_view = Gtk.ListView.new(self.searches_selection, searches_signals)
+ searches_view.get_style_context().add_class('navigation-sidebar')
+ searches_view.set_hexpand(True)
+
+ self.searches_revealer = Gtk.Revealer()
+ self.searches_revealer.set_child(searches_view)
+ self.searches_revealer.set_reveal_child(True)
+
+ # -------------------------------------------------------------------------------
+ # Tags Section
+ # -------------------------------------------------------------------------------
+ self.tag_selection = Gtk.MultiSelection.new(ds.tags.tree_model)
+ self.tag_handle = self.tag_selection.connect('selection-changed', self.on_tag_selected)
+
+ tags_signals = Gtk.SignalListItemFactory()
+ tags_signals.connect('setup', self.tags_setup_cb)
+ tags_signals.connect('bind', self.tags_bind_cb)
+
+ view = Gtk.ListView.new(self.tag_selection, tags_signals)
+ view.get_style_context().add_class('navigation-sidebar')
+ view.set_vexpand(True)
+ view.set_hexpand(True)
+
+ view_drop = Gtk.DropTarget.new(Tag2, Gdk.DragAction.COPY)
+ view_drop.connect("drop", self.on_toplevel_tag_drop)
+ view.add_controller(view_drop)
+
+ self.revealer = Gtk.Revealer()
+ self.revealer.set_child(view)
+ self.revealer.set_reveal_child(True)
+
+ btn_box = Gtk.Box()
+ button = Gtk.Button()
+ button_label = Gtk.Label()
+ button_label.set_markup('Tags')
+ button_label.set_xalign(0)
+ button.get_style_context().add_class('flat')
+
+ button.set_margin_top(6)
+ button.set_margin_bottom(6)
+ button.set_margin_start(6)
+ button.set_margin_end(6)
+
+ tags_icon = Gtk.Image.new_from_icon_name('view-list-symbolic')
+ tags_icon.set_margin_end(6)
+ btn_box.append(tags_icon)
+ btn_box.append(button_label)
+ button.set_child(btn_box)
+ button.connect('clicked', self.on_tag_reveal)
+
+ self.expanders = set()
+
+ # -------------------------------------------------------------------------------
+ # Bring everything together
+ # -------------------------------------------------------------------------------
+
+ wrap_box.append(searches_button)
+ wrap_box.append(self.searches_revealer)
+ wrap_box.append(button)
+ wrap_box.append(self.revealer)
+ self.set_child(wrap_box)
+
+
+ def on_tag_RMB_click(self, gesture, sequence) -> None:
+ """Callback when right-clicking on a tag."""
+
+ menu = TagContextMenu(self.ds, self.app, gesture.get_widget().tag)
+ 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 on_searches_RMB_click(self, gesture, sequence) -> None:
+ """Callback when right-clicking on a search."""
+
+ 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:
+ """Generate a button for the main listbox."""
+
+ box = Gtk.Box()
+
+ icon = Gtk.Image.new_from_icon_name(icon_name)
+ icon.set_margin_end(6)
+ box.append(icon)
+
+ label = Gtk.Label()
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+ label.set_text(text)
+ box.append(label)
+
+ count_label = Gtk.Label()
+ count_label.set_halign(Gtk.Align.START)
+ count_label.add_css_class('dim-label')
+ count_label.set_text(count)
+ box.append(count_label)
+
+ return box
+
+
+ def tags_setup_cb(self, factory, listitem, user_data=None) -> None:
+ """Setup for a row in the tags listview"""
+
+ box = TagBox()
+ label = Gtk.Label()
+ expander = Gtk.TreeExpander()
+ icon = Gtk.Label()
+ color = Gtk.Button()
+ count_label = Gtk.Label()
+
+ expander.set_margin_end(6)
+ expander.add_css_class('arrow-only-expander')
+ icon.set_margin_end(6)
+ color.set_sensitive(False)
+ color.set_margin_end(6)
+ color.set_valign(Gtk.Align.CENTER)
+ color.set_halign(Gtk.Align.CENTER)
+ color.set_vexpand(False)
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+
+ count_label.set_halign(Gtk.Align.START)
+ count_label.add_css_class('dim-label')
+ count_label.set_text('0')
+
+ # Drag ...
+ source = Gtk.DragSource()
+ source.connect('prepare', self.prepare)
+ source.connect('drag-begin', self.drag_begin)
+ source.connect('drag-end', self.drag_end)
+ box.add_controller(source)
+
+ # ... and drop
+ drop = Gtk.DropTarget.new(Tag2, Gdk.DragAction.COPY)
+ drop.connect('drop', self.drag_drop)
+ drop.connect('enter', self.drop_enter)
+ box.add_controller(drop)
+
+ box.append(expander)
+ box.append(color)
+ box.append(icon)
+ box.append(label)
+ box.append(count_label)
+ listitem.set_child(box)
+
+ # Right click event controller
+ tags_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY)
+ tags_RMB_controller.connect('begin', self.on_tag_RMB_click)
+ box.add_controller(tags_RMB_controller)
+
+ self.expanders.add(expander)
+
+
+ def tags_bind_cb(self, signallistitem, listitem, user_data=None) -> None:
+ """Bind properties for a specific row in the tags listview"""
+
+ expander = listitem.get_child().get_first_child()
+ color = expander.get_next_sibling()
+ icon = color.get_next_sibling()
+ label = icon.get_next_sibling()
+ count_label = label.get_next_sibling()
+
+ box = listitem.get_child()
+ item = unwrap(listitem, Tag2)
+
+ box.props.tag = item
+ expander.set_list_row(listitem.get_item())
+
+ item.bind_property('name', label, 'label', BIND_FLAGS)
+ item.bind_property('icon', icon, 'label', BIND_FLAGS)
+
+ try:
+ count = str(self.ds.task_count['open'][item.props.name])
+ count_label.set_text(count)
+ except KeyError:
+ pass
+
+ if item.parent:
+ parent = item
+ depth = 0
+
+ while parent.parent:
+ depth += 1
+ parent = parent.parent
+
+ box.set_margin_start((18 * depth) + 16)
+ else:
+ box.set_margin_start(18)
+
+ if not item.children:
+ expander.set_visible(False)
+ else:
+ expander.set_visible(True)
+
+ if item.props.icon:
+ icon.set_visible(True)
+ color.set_visible(False)
+
+ elif item.props.color:
+ color.set_visible(True)
+ background = str.encode(f'* {{ background: #{item.props.color}; }}')
+ cssProvider = Gtk.CssProvider()
+ cssProvider.load_from_data(background)
+ color.add_css_class('color-pill')
+ color.get_style_context().add_provider(cssProvider,
+ Gtk.STYLE_PROVIDER_PRIORITY_USER)
+ else:
+ icon.set_visible(False)
+ color.set_visible(False)
+
+
+ def unselect_tags(self) -> None:
+ """Clear tags selection"""
+
+ with signal_block(self.tag_selection, self.tag_handle):
+ self.tag_selection.unselect_all()
+
+
+ def unselect_searches(self) -> None:
+ """Clear selection for saved searches"""
+
+ with signal_block(self.searches_selection, self.search_handle):
+ search_id = self.searches_selection.get_selected()
+ self.searches_selection.unselect_item(search_id)
+
+
+ def unselect_general_box(self) -> None:
+ """Clear selection for saved searches"""
+
+ with signal_block(self.general_box, self.box_handle):
+ self.general_box.unselect_all()
+
+
+ def on_general_box_selected(self, row, user_data=None):
+ """Callback when clicking on a row in the general listbox"""
+
+ if row:
+ self.unselect_tags()
+ self.unselect_searches()
+
+
+ def on_search_selected(self, model, position, user_data=None):
+ """Callback when selecting a saved search"""
+
+ self.unselect_tags()
+ self.unselect_general_box()
+
+
+ def on_tag_selected(self, model, position, n_items, user_data=None):
+ """Callback when selecting one or more tags"""
+
+ self.unselect_general_box()
+ self.unselect_searches()
+
+ selection = model.get_selection()
+ result, iterator, _ = Gtk.BitsetIter.init_first(selection)
+ selected = []
+
+ while iterator.is_valid():
+ val = iterator.get_value()
+ selected.append(unwrap(model.get_item(val), Tag2))
+ iterator.next()
+
+
+ def on_tag_reveal(self, event) -> None:
+ """Callback for clicking on the tags title button (revealer)."""
+
+ self.revealer.set_reveal_child(not self.revealer.get_reveal_child())
+
+
+ def on_search_reveal(self, event) -> None:
+ """Callback for clicking on the search title button (revealer)."""
+
+ self.searches_revealer.set_reveal_child(not self.searches_revealer.get_reveal_child())
+
+
+ def searches_setup_cb(self, factory, listitem, user_data=None) -> None:
+ """Setup for a row in the saved searches listview"""
+
+ box = SearchBox()
+ label = Gtk.Label()
+ icon = Gtk.Label()
+ count_label = Gtk.Label()
+
+ icon.set_margin_end(6)
+ label.set_halign(Gtk.Align.START)
+ label.set_hexpand(True)
+
+ count_label.set_halign(Gtk.Align.START)
+ count_label.add_css_class('dim-label')
+ count_label.set_text('0')
+
+ box.set_margin_start(18)
+
+ box.append(icon)
+ box.append(label)
+ box.append(count_label)
+ listitem.set_child(box)
+
+ 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:
+ """Bind properties for a specific row in the searches listview"""
+
+ icon = listitem.get_child().get_first_child()
+ label = icon.get_next_sibling()
+ count_label = icon.get_next_sibling()
+ box = listitem.get_child()
+
+ item = unwrap(listitem, SavedSearch)
+ box.search = item
+
+ item.bind_property('name', label, 'label', BIND_FLAGS)
+ item.bind_property('icon', icon, 'label', BIND_FLAGS)
+
+
+ # -------------------------------------------------------------------------------------------
+ # Drag and drop
+ # -------------------------------------------------------------------------------------------
+
+ def prepare(self, source, x, y):
+ """Callback to prepare for the DnD operation"""
+
+ # Get content from source
+ data = source.get_widget().props.tag
+
+ # Set it as content provider
+ content = Gdk.ContentProvider.new_for_value(data)
+
+ return content
+
+
+ def drag_begin(self, source, drag):
+ """Callback when DnD beings"""
+
+ source.get_widget().set_opacity(0.25)
+ icon = Gtk.DragIcon.get_for_drag(drag)
+ frame = Gtk.Frame()
+ picture = Gtk.Picture.new_for_paintable(
+ Gtk.WidgetPaintable.new(source.get_widget()))
+
+ frame.set_child(picture)
+ icon.set_child(frame)
+
+
+ def drag_end(self, source, drag, unused):
+ """Callback when DnD ends"""
+
+ if source.get_widget():
+ source.get_widget().set_opacity(1)
+
+
+ def check_parent(self, value, target) -> bool:
+ """Check to parenting a parent to its own children"""
+
+ item = target
+ while item.parent:
+ if item.parent == value:
+ return False
+
+ item = item.parent
+
+ return True
+
+
+ def drag_drop(self, target, value, x, y):
+ """Callback when dropping onto a target"""
+
+ dropped = target.get_widget().props.tag
+
+ if not self.check_parent(value, dropped):
+ return
+
+ if value.parent:
+ self.ds.tags.unparent(value.id, value.parent.id)
+
+ self.ds.tags.parent(value.id, dropped.id)
+ self.ds.tags.tree_model.emit('items-changed', 0, 0, 0)
+
+
+ def drop_enter(self, target, x, y, user_data=None):
+ """Callback when the mouse hovers over the drop target."""
+
+ expander = target.get_widget().get_first_child()
+
+ if target.get_widget().tag.children:
+ expander.activate_action('listitem.expand')
+
+ # There's a funny bug in here. If the expansion of the row
+ # makes the window larger, Gtk won't recognize the new drop areas
+ # and will think you're dragging outside the window.
+
+ return Gdk.DragAction.COPY
+
+
+ def on_toplevel_tag_drop(self, drop_target, tag, x, y):
+ if tag.parent:
+ self.ds.tags.unparent(tag.id, tag.parent.id)
+
+ try:
+ for expander in self.expanders:
+ expander.activate_action('listitem.toggle-expand')
+ expander.activate_action('listitem.toggle-expand')
+ except RuntimeError:
+ pass
+
+ self.ds.tags.tree_model.emit('items-changed', 0, 0, 0)
+ return True
+ else:
+ return False
diff --git a/GTG/gtk/browser/tag_context_menu.py b/GTG/gtk/browser/sidebar_context_menu.py
similarity index 59%
rename from GTG/gtk/browser/tag_context_menu.py
rename to GTG/gtk/browser/sidebar_context_menu.py
index ce3f44aa13..e475d6f272 100644
--- a/GTG/gtk/browser/tag_context_menu.py
+++ b/GTG/gtk/browser/sidebar_context_menu.py
@@ -35,61 +35,85 @@
class TagContextMenu(Gtk.PopoverMenu):
"""Context menu fo the tag i the sidebar"""
- def __init__(self, req, app, tag=None):
+ def __init__(self, ds, app, tag):
super().__init__(has_arrow=False)
- self.req = req
- self.app = app
self.tag = tag
+ self.app = app
+ self.ds = ds
- for action_disc in [
+ actions = [
("edit_tag", self.on_mi_cc_activate),
("generate_tag_color", self.on_mi_ctag_activate),
- ("delete_search_tag", self.on_mi_del_activate),
- ("delete_tag", lambda w, a, p : self.app.browser.on_delete_tag_activate())
- ]:
+ ("delete_tag", lambda w, a, p : self.app.browser.on_delete_tag_activate([self.tag]))
+ ]
+
+ for action_disc in actions:
self.install_action(
- ".".join(["tags_popup", action_disc[0]]), None, action_disc[1]
- )
+ ".".join(["tags_popup", action_disc[0]]), None, action_disc[1])
# Build up the menu
- self.set_tag(tag)
- self.__build_menu()
+ self.build_menu()
+
- def __build_menu(self):
+ def build_menu(self):
"""Build up the widget"""
- if self.tag is not None:
+ if self.tag:
menu_builder = Gtk.Builder()
menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE)
menu_model = menu_builder.get_object("tag_context_menu")
-
- if self.tag.is_search_tag():
- self.mi_del_search_tag = Gio.MenuItem.new(_("Delete"), "tags_popup.delete_search_tag")
- menu_model.append_item(self.mi_del_search_tag)
- else:
- self.mi_del_tag = Gio.MenuItem.new(_("Delete"), "tags_popup.delete_tag")
- menu_model.append_item(self.mi_del_tag)
+ self.mi_del_tag = Gio.MenuItem.new(_("Delete"), "tags_popup.delete_tag")
+ menu_model.append_item(self.mi_del_tag)
self.set_menu_model(menu_model)
- # PUBLIC API ##############################################################
- def set_tag(self, tag):
- """Update the context menu items using the tag attributes."""
- self.tag = tag
- self.__build_menu()
# 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)
+
def on_mi_ctag_activate(self, widget, action_name, param):
- random_color = generate_tag_color()
- present_color = self.tag.get_attribute('color')
- if present_color is not None:
- color_remove(present_color)
- self.tag.set_attribute('color', random_color)
- color_add(random_color)
-
- def on_mi_del_activate(self, wudget, action_name, param):
- """ delete a selected search """
- self.req.remove_tag(self.tag.get_name())
+
+ self.tag.color = self.ds.tags.generate_color()
+ 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/browser/tag_editor.py b/GTG/gtk/browser/tag_editor.py
index e52b7054ab..fe0925bc01 100644
--- a/GTG/gtk/browser/tag_editor.py
+++ b/GTG/gtk/browser/tag_editor.py
@@ -47,14 +47,15 @@ def __init__(self, req, app, tag=None):
set_icon_shortcut = Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("I"),
- Gtk.CallbackAction.new(self._set_icon)
- )
+ Gtk.CallbackAction.new(self._set_icon))
+
self.add_shortcut(set_icon_shortcut)
self.req = req
self.app = app
self.tag = tag
+ self.set_transient_for(app.browser)
self._title_format = self.get_title()
self._emoji_chooser.set_parent(self._icon_button)
@@ -73,24 +74,29 @@ def __init__(self, req, app, tag=None):
@GObject.Property(type=bool, default=False)
def has_color(self):
"""Whenever the tag has a color."""
+
return self._has_color
@has_color.setter
def has_color(self, value: bool):
+
self._has_color = value
@GObject.Property(type=Gdk.RGBA)
def tag_rgba(self):
"""The color of the tag. Alpha is ignored."""
+
return self._tag_rgba
@tag_rgba.setter
def tag_rgba(self, value: Gdk.RGBA):
+
self._tag_rgba = value
@GObject.Property(type=str, default='')
def tag_name(self):
"""The (new) name of the tag."""
+
return self._tag_name
@tag_name.setter
@@ -103,6 +109,7 @@ def tag_is_actionable(self):
"""
Whenever the tag should show up in the actionable tab.
"""
+
return self._tag_is_actionable
@tag_is_actionable.setter
@@ -114,6 +121,7 @@ def is_valid(self):
"""
Whenever it is valid to apply the changes (like malformed tag name).
"""
+
return self._is_valid
@is_valid.setter
@@ -125,6 +133,7 @@ def has_icon(self):
"""
Whenever the tag will have an icon.
"""
+
return bool(self._emoji)
def _validate(self):
@@ -135,6 +144,7 @@ def _validate(self):
On failure, the widgets are modified accordingly to show the user
why it doesn't accept it.
"""
+
valid = True
valid &= self._validate_tag_name()
self.is_valid = valid
@@ -147,7 +157,7 @@ def _validate_tag_name(self):
On failure, the widgets are modified accordingly to show the user
why it doesn't accept it.
"""
- # TODO: Possibly add more restrictions.
+
if self.tag_name == '':
self._name_entry.add_css_class("error")
self._name_entry.props.tooltip_text = \
@@ -158,7 +168,6 @@ def _validate_tag_name(self):
self._name_entry.props.tooltip_text = ""
return True
- # PUBLIC API #####
def set_tag(self, tag):
"""
Set the tag to edit.
@@ -169,24 +178,26 @@ def set_tag(self, tag):
if tag is None:
return
- icon = tag.get_attribute('icon')
+ icon = tag.icon
self._set_emoji(self._emoji_chooser, text=icon if icon else '')
- self.tag_name = tag.get_friendly_name()
+ self.tag_name = tag.name
self.set_title(self._title_format % ('@' + self.tag_name,))
rgba = Gdk.RGBA()
rgba.red, rgba.green, rgba.blue, rgba.alpha = 1.0, 1.0, 1.0, 1.0
- if color := tag.get_attribute('color'):
- if not rgba.parse(color):
+
+ if color := tag.color:
+ if not rgba.parse('#' + color):
log.warning("Failed to parse tag color for %r: %r",
- tag.get_name(), color)
+ tag.name, color)
+
self.has_color = bool(color)
self.tag_rgba = rgba
- self.tag_is_actionable = \
- self.tag.get_attribute("nonactionable") != "True"
+ self.tag_is_actionable = self.tag.actionable
def do_destroy(self):
+
self.app.close_tag_editor()
super().destroy()
@@ -195,6 +206,7 @@ def _cancel(self):
Cancel button has been clicked, closing the editor window without
applying changes.
"""
+
self.destroy()
def _apply(self):
@@ -208,30 +220,35 @@ def _apply(self):
return
if self.has_icon and self._emoji:
- self.tag.set_attribute('icon', self._emoji)
+ self.tag.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.tag.get_name())
- self.tag.del_attribute('icon')
+ self.tag.name)
+ self.tag.icon = None
else:
- self.tag.del_attribute('icon')
+ self.tag.icon = None
if self.has_color:
color = rgb_to_hex(self.tag_rgba)
color_add(color)
- self.tag.set_attribute('color', color)
+ self.tag.color = color
else:
- if tag_color := self.tag.get_attribute('color'):
+ if tag_color := self.tag.color:
color_remove(tag_color)
- self.tag.del_attribute('color')
- self.tag.set_attribute('nonactionable', str(not self.tag_is_actionable))
+ self.tag.color = None
- if self.tag_name != self.tag.get_friendly_name():
- log.debug("Renaming %r ā %r", self.tag.get_name(), self.tag_name)
- self.req.rename_tag(self.tag.get_name(), self.tag_name)
- self.tag = self.req.get_tag(self.tag_name)
+ self.tag.actionable = self.tag_is_actionable
+ if self.tag_name != self.tag.name:
+ log.debug("Renaming %r ā %r", self.tag.name, self.tag_name)
+
+ for t in self.app.ds.tasks.lookup.values():
+ t.rename_tag(self.tag.name, self.tag_name)
+
+ self.tag.name = self.tag_name
+
+ self.app.ds.tags.tree_model.emit('items-changed', 0, 0, 0)
self.destroy()
# CALLBACKS #####
@@ -249,20 +266,18 @@ def _random_color(self, widget: GObject.Object):
with an random color.
"""
self.has_color = True
+ color = self.app.ds.tags.generate_color()
c = Gdk.RGBA()
- c.red, c.green, c.blue, c.alpha = (
- random.uniform(0.0, 1.0),
- random.uniform(0.0, 1.0),
- random.uniform(0.0, 1.0),
- 1.0
- )
+ c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0
+ c.parse('#' + color)
self.tag_rgba = c
-
+
@Gtk.Template.Callback('activate_color')
def _activate_color(self, widget: GObject.Object):
"""
Enable using the selected color because a color has been selected.
"""
+
self.has_color = True
@Gtk.Template.Callback('set_icon')
@@ -278,6 +293,7 @@ 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)
@@ -288,6 +304,7 @@ def _set_emoji(self, widget: GObject.Object, text: str = 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')
@@ -295,6 +312,7 @@ def _remove_icon(self, widget: GObject.Object):
"""
Callback to remove the icon.
"""
+
self._set_emoji(self._emoji_chooser, text='')
@Gtk.Template.Callback('remove_color')
@@ -302,6 +320,7 @@ def _remove_color(self, widget: GObject.Object):
"""
Callback to remove the color.
"""
+
c = Gdk.RGBA()
c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0
self.tag_rgba = c
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/data/style.css b/GTG/gtk/data/style.css
index e3d607d4c0..68b270696f 100644
--- a/GTG/gtk/data/style.css
+++ b/GTG/gtk/data/style.css
@@ -66,3 +66,21 @@ textview check {
.icon {
font-size: 36px;
}
+
+.hide { opacity: 0.2; }
+
+.color-pill {
+ background: #999;
+ padding: 0;
+ min-height: 16px;
+ min-width: 16px;
+ border: none;
+}
+
+.arrow-only-expander {
+ border-spacing: 0;
+}
+
+.arrow-only-expander indent {
+ -gtk-icon-size: 0px;
+}
diff --git a/GTG/gtk/meson.build b/GTG/gtk/meson.build
index f7807fd5f8..f59c10416c 100644
--- a/GTG/gtk/meson.build
+++ b/GTG/gtk/meson.build
@@ -37,10 +37,12 @@ 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',
+ 'browser/sidebar.py',
]
gtg_data_sources = [
@@ -54,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'
]