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 @@ + + + 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/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' ]