diff --git a/README.md b/README.md index 912fbaa9e..143265cae 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ commands). Older versions are not supported. ###### Ubuntu (tested in 19.04 and 18.04) ```bash -sudo apt install gettext intltool python3-gi python3-cairo python3-gi-cairo python3-distutils python3-dbus libglib2.0-dev libglib2.0-bin gir1.2-gtk-3.0 gtk-update-icon-cache +sudo apt install gettext intltool python3-gi python3-cairo python3-gi-cairo python3-distutils python3-dbus libglib2.0-dev libglib2.0-bin gir1.2-gtk-4.0 gtk-update-icon-cache # and for documentation sudo apt install itstool yelp ``` @@ -307,7 +307,7 @@ for some time now. Hopefully the effort will be renewed in the future. In the meantime, this sub-project aims to pursue development of the "legacy" Hamster code base, maintaining database compatibility with the widely installed [v1.04](https://github.com/projecthamster/hamster/releases/tag/hamster-time-tracker-1.04), -but migrating to `Gtk3` and `python3`. +but migrating to `Gtk4` and `python3`. This will allow package maintainers to provide new packages for recent releases of mainstream Linux distributions for which the old 1.04-based versions are no longer provided. diff --git a/data/date_range.ui b/data/date_range.ui index 4ba08d8d5..ba21633e5 100644 --- a/data/date_range.ui +++ b/data/date_range.ui @@ -1,319 +1,161 @@ - - + - False - - - True - False - 0 - out - - - True - False - 12 - 12 + + + + - True - False vertical 8 + 8 + 8 + 8 + 8 - False - True - True - True - none - - + 1 1 + 1 - True - False 8 100 - True - False 1 - Today: + Today: - - False - True - 0 - - True - False 0 Jun 17 - - False - True - 1 - - - True - True - 0 - - False - True - True - True - none - - + 1 1 + 1 - True - False 8 100 - True - False 1 - Week: + Week: - - False - True - 0 - - True - False 0 Jun 14 - 20, 2010 - - False - True - 1 - - - True - True - 1 - - False - True - True - True - none - - + 1 1 + 1 - True - False 8 100 - True - False 1 - Month: + Month: - - False - True - 0 - - True - False 0 Jun 1-30, 2010 - - False - True - 1 - - - True - True - 2 - - - True - False - 4 + + 1 + - True - False 8 100 - True - False 1 - 0 - 7 - Range: + 0 Range: - - False - True - 0 - - True - False vertical 8 - True - False + 1 12 - - True - False + + - True - True + 1 2010 5 11 - - - False - True - 0 - - True - False - 0 - 7 - to + 0 to - - False - True - 1 - - - True - False + + - True - True + 1 2010 5 11 - - - False - True - 2 - - - True - True - 0 - - - True - False - end + + - Apply - False - True - True - True - - - - False - False - 1 - - + Apply 1 + 1 + - - False - True - 1 - - - False - True - 1 - - - True - True - 3 - @@ -323,6 +165,6 @@ - + diff --git a/data/edit_activity.ui b/data/edit_activity.ui index f3e378bf7..ba24dec8d 100644 --- a/data/edit_activity.ui +++ b/data/edit_activity.ui @@ -1,538 +1,288 @@ - - - - 0 - - - 0 - + - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 12 - Add Earlier Activity - True + Add Earlier Activity + 1 org.gnome.Hamster.GUI - True - - - + - True - False vertical 5 + 10 + 10 + 10 + 10 - True - False - True - True - True - none - - + 1 + 1 - True - False go-previous-symbolic - - False - True - 0 - - + + 1 70 - True - False + - - True - True - 1 - - True - True - True - none - - + 1 + 1 - True - False go-next-symbolic - - False - True - 2 - - - False - True - 0 - cmdline box - True - False vertical - True - False start - cmdline + cmdline - - + + - - False - True - 0 - - - False - True - 1 - - True - False + 1 vertical - True - False + center start - description + description - - + + - - False - False - 0 - - True - True - never - in - + 1 + 1 + never 50 - True - True + 1 word-char - False + 0 - + - - True - True - 1 - - - True - True - 2 - - True - False 4 - True + 1 - True - False + 1 vertical - True - False + center start - start + start - - + + - - False - False - 0 - - True - False vertical - - False - True - 1 - - True - True - True - True - True - True - 2019 - 8 - 15 + 1 - True - False - start date + start date - - False - True - 2 - - - True - True - 0 - - True - False vertical - True - False + center start - end + end - - + + - - False - False - 0 - - True - False vertical - - False - True - 1 - - True - True - True - True - True - True - 2019 - 8 - 15 + 1 - True - False - end date + end date - - False - True - 2 - - - False - True - 2 - - - False - True - 3 - - True - True - + 0 + 0 + 1 + - True - False vertical - True - False + center start - category + category - - + + - - False - False - 0 - - True - True + 1 + 1 edit-clear-all-symbolic - Unsorted - activity completion + Unsorted - - True - True - 1 - - - True - False - - - + + - True - False - 4 + 4 vertical - True - False + center start - activity + activity - - + + - - False - False - 0 - - True - True + 1 + 1 edit-clear-all-symbolic - category completion - - True - True - 1 - - - True - False - - + - - False - True - 4 - - True - False vertical - True - False + center start - tags + tags - - + + - - False - False - 0 - - - False - True - 5 - - True - False - gtk-delete - True - True - True - True - - - - False - False - 0 - + center + Delete + 1 + 1 - - True - False - 8 - end - + + 1 + + 8 - gtk-cancel - True - True - True - True - - - - False - False - 1 - - + Cancel + 1 + 1 - gtk-save - True - True - True - True - - - - False - False - 2 - - + Save + 1 + 1 - - True - True - 1 - - - False - True - 6 - - + diff --git a/data/org.gnome.Hamster.flatpak.yml b/data/org.gnome.Hamster.flatpak.yml index 7539591fc..e4f0e4a24 100644 --- a/data/org.gnome.Hamster.flatpak.yml +++ b/data/org.gnome.Hamster.flatpak.yml @@ -1,7 +1,7 @@ --- app-id: org.gnome.Hamster runtime: org.gnome.Platform -runtime-version: '45' +runtime-version: '47' sdk: org.gnome.Sdk command: hamster modules: diff --git a/data/preferences.ui b/data/preferences.ui index 37c81ecb3..a50fb9be7 100644 --- a/data/preferences.ui +++ b/data/preferences.ui @@ -1,549 +1,289 @@ - - + - - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 12 - Time Tracker Preferences - center - 450 + Time Tracker Preferences 450 500 - True + 1 org.gnome.Hamster.GUI - - + - True - False vertical 8 + 10 + 10 + 10 + 10 - True - True + 1 + 1 - - True - False - 12 - 8 - 4 - 4 - - - True - False - start - start - vertical - 8 + + + + 8 + 8 + 8 + 8 - - True - False + + start + start + vertical + 8 - - True - False - New day starts at - - - False - True - 4 - 0 - - - - - True - False + - + + New day starts at + + + + + + + + - - False - True - 1 - - - False - True - 2 - - - - - - - True - False - Tracking + + + + Tracking + + - - False - - - True - False - 12 - 8 - 4 - 4 - - - True - False - vertical - 15 - True + + 1 + + - - True - False - 6 + + vertical + 15 + 1 - - True - True + + 1 - - 150 - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - vertical - 4 - - - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - _Categories - True - category_list - 0 - - - False - True - 0 - - - - - True - True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - in - - - True - True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - False - False - - - - - - - - - - Category list - - - - - - - True - True - 1 - - - - - True - False + + 0 + 1 + + + 150 vertical 4 - - True - True - True - - - - True - False - list-add - - - - - Add category - - + _Categories + 1 + category_list + 0 - - False - True - 0 - - - True - True - True - - - - True - False - list-remove - - - - - Remove category + + 1 + 1 + 1 + + 1 0 + 0 + + + - + - - False - True - 1 - - - True - True - True - + + 4 - - True - False - gtk-edit + + 1 + 1 + + + list-add-symbolic + + - - - Edit category + + + 1 + 1 + + + list-remove-symbolic + + - - - False - True - 2 - - - - - - - - False - True - 2 - - - - - False - True - - - - - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - vertical - 4 - - - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - _Activities - True - activity_list - 0 - - - False - True - 0 - - - - - True - False - - - True - True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - in - - True - True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - False - True - False - True - - - - - - - - - Activity list + + 1 + 1 + + + document-edit-symbolic + + + - - True - True - 1 - - - - - True - False + + + + 1 + vertical 4 - - True - True - True - + _Activities + 1 + activity_list + 0 + + + + + 1 - - True - False - list-add - - - - - Add activity + + 1 + 1 + + 1 0 0 + 1 + + + + + - - False - True - 0 - - - True - True - True - + + 4 - - True - False - list-remove + + 1 + 1 + + + list-add-symbolic + + - - - Remove activity + + + 1 + 1 + + + list-remove-symbolic + + - - - False - True - 1 - - - - - True - True - True - - - True - False - gtk-edit + + 1 + 1 + + + document-edit-symbolic + + - - - Edit activity - + + - - False - True - 2 - - - - - - False - True - 2 - - + - - True - True - - - - True - True - 0 - - - - - True - False - 0 - none - - True - False - 8 - 12 - - - True - True - in + + 1 + 1 + + 1 - - True - True - word-char - 4 - 4 - autocomplete_tags - False - + + 1 + 1 + + 1 + 1 + 1 + word-char + 4 + 4 + autocomplete_tags + 0 + + - - - - - True - False - Tags that should appear in autocomplete - - - + + + Tags that should appear in autocomplete + + + + + - - True - True - 1 - - - - - 1 - - - - - True - False - Categories and Tags + + + + Categories and Tags + + - - 1 - False - - - True - True - 0 - - True - False end - gtk-close - True - True - True - True - - - - False - True - 0 - + Close + 1 + 1 - - False - True - end - 1 - - + diff --git a/data/stats.ui b/data/stats.ui deleted file mode 100644 index b1879012f..000000000 --- a/data/stats.ui +++ /dev/null @@ -1,266 +0,0 @@ - - - - - - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 10 - Statistics - center - 800 - 550 - org.gnome.Hamster.GUI - - - - - True - vertical - 24 - - - True - 0 - - - True - 2 - 12 - 6 - - - True - 0 - Year: - - - GTK_FILL - GTK_FILL - - - - - True - 0 - 0 - - - True - - - - - - - - - - - - - - 1 - 2 - - - - - - - False - 0 - - - - - True - - - True - 5 - 5 - - - True - vertical - - - True - vertical - 24 - - - 70 - True - - - - - - False - 0 - - - - - True - - - True - vertical - - - True - True - - - True - 0 - 8 - Starts and ends - - - - - - 0 - - - - - True - 0 - Totals - - - - - - 1 - - - - - False - 0 - - - - - 160 - True - True - - - True - - - - - - 0 - - - - - True - - - - - - 1 - - - - - False - 1 - - - - - 100 - True - True - - - True - - - - - - 0 - - - - - True - - - - - - 1 - - - - - 2 - - - - - 0 - - - - - 200 - True - 0 - 0 - 32 - 30 - - - - - - False - 1 - - - - - 1 - - - - - 0 - - - - - Text we say when there is not enough data - True - - - 1 - - - - - - - - - 1 - - - - - - diff --git a/po/wscript b/po/wscript index d2d892aba..cddaaa2c5 100644 --- a/po/wscript +++ b/po/wscript @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python3 # encoding: utf-8 # Thomas Nagy, 2006-2009 (ita) diff --git a/src/hamster-cli.py b/src/hamster-cli.py index 9c6786787..79c6f025d 100755 --- a/src/hamster-cli.py +++ b/src/hamster-cli.py @@ -27,8 +27,8 @@ import re import gi -gi.require_version('Gdk', '3.0') # noqa: E402 -gi.require_version('Gtk', '3.0') # noqa: E402 +gi.require_version('Gdk', '4.0') # noqa: E402 +gi.require_version('Gtk', '4.0') # noqa: E402 from gi.repository import GLib as glib from gi.repository import Gdk as gdk from gi.repository import Gtk as gtk @@ -170,9 +170,7 @@ def _open_window(self, name, data=None): logger.debug("new About") controller = self.about_controller elif name in ("add", "clone", "edit"): - if self.fact_controller: - # Something is already going on, with other arguments, present it. - # Or should we just discard the forgotten one ? + if self.fact_controller and self.fact_controller.window: logger.warning("Fact controller already active. Please close first.") else: fact_id = data.get_int32() if data else None @@ -200,8 +198,6 @@ def _open_window(self, name, data=None): # https://specifications.freedesktop.org/wm-spec/wm-spec-1.3.html if name != "overview" and self.overview_controller: window.set_transient_for(self.overview_controller.window) - # so the dialog appears on top of the transient-for: - window.set_type_hint(gdk.WindowTypeHint.DIALOG) else: # toplevel window.set_transient_for(None) @@ -455,7 +451,7 @@ def version(self): logger.debug("app instanciated") import signal - signal.signal(signal.SIGINT, signal.SIG_DFL) # gtk3 screws up ctrl+c + signal.signal(signal.SIGINT, signal.SIG_DFL) parser = argparse.ArgumentParser( description="Time tracking utility", diff --git a/src/hamster-service.py b/src/hamster-service.py index 29e7c1e06..8a85b7cc5 100755 --- a/src/hamster-service.py +++ b/src/hamster-service.py @@ -154,7 +154,7 @@ def AddFact(self, fact_str, start_time, end_time, temporary): Returns: fact id (int), 0 means failure. - Note: see datetime.utcfromtimestamp documentation + Note: see datetime.fromtimestamp documentation for the precise meaning of timestamps. """ fact = Fact.parse(fact_str) @@ -166,12 +166,12 @@ def AddFact(self, fact_str, start_time, end_time, temporary): if start_time == -1: fact.start_time = None elif start_time != 0: - fact.start_time = dt.datetime.utcfromtimestamp(start_time) + fact.start_time = dt.datetime.fromtimestamp(start_time, tz=dt.timezone.utc).replace(tzinfo=None) if end_time == -1: fact.end_time = None elif end_time != 0: - fact.end_time = dt.datetime.utcfromtimestamp(end_time) + fact.end_time = dt.datetime.fromtimestamp(end_time, tz=dt.timezone.utc).replace(tzinfo=None) return self.add_fact(fact) @@ -249,11 +249,11 @@ def GetFactJSON(self, fact_id): def UpdateFact(self, fact_id, fact, start_time, end_time, temporary): start_time = start_time or None if start_time: - start_time = dt.datetime.utcfromtimestamp(start_time) + start_time = dt.datetime.fromtimestamp(start_time, tz=dt.timezone.utc).replace(tzinfo=None) end_time = end_time or None if end_time: - end_time = dt.datetime.utcfromtimestamp(end_time) + end_time = dt.datetime.fromtimestamp(end_time, tz=dt.timezone.utc).replace(tzinfo=None) return self.update_fact(fact_id, fact, start_time, end_time, temporary) @@ -278,7 +278,7 @@ def StopTracking(self, end_time): """Stops tracking the current activity""" end_time = end_time or None if end_time: - end_time = dt.datetime.utcfromtimestamp(end_time) + end_time = dt.datetime.fromtimestamp(end_time, tz=dt.timezone.utc).replace(tzinfo=None) return self.stop_tracking(end_time) @@ -310,11 +310,11 @@ def GetFacts(self, start_date, end_date, search_terms): #TODO: Assert start > end ? start = dt.date.today() if start_date: - start = dt.datetime.utcfromtimestamp(start_date).date() + start = dt.datetime.fromtimestamp(start_date, tz=dt.timezone.utc).date() end = None if end_date: - end = dt.datetime.utcfromtimestamp(end_date).date() + end = dt.datetime.fromtimestamp(end_date, tz=dt.timezone.utc).date() return [to_dbus_fact(fact) for fact in self.get_facts(start, end, search_terms)] diff --git a/src/hamster/__init__.py b/src/hamster/__init__.py index 4970a59f3..3a4c51694 100644 --- a/src/hamster/__init__.py +++ b/src/hamster/__init__.py @@ -1,5 +1,5 @@ import gi -gi.require_version('Gtk', '3.0') # noqa: E402 +gi.require_version('Gtk', '4.0') # noqa: E402 gi.require_version('PangoCairo', '1.0') # noqa: E402 # for some reason performance is improved by importing Gtk early from gi.repository import Gtk as gtk diff --git a/src/hamster/about.py b/src/hamster/about.py index 9813da28a..eaa2b4b10 100644 --- a/src/hamster/about.py +++ b/src/hamster/about.py @@ -25,7 +25,9 @@ class About(object): def __init__(self, parent=None): - about = gtk.AboutDialog(parent=parent) + about = gtk.AboutDialog() + if parent: + about.set_transient_for(parent) self.window = about infos = { "program-name" : "Hamster", @@ -51,8 +53,7 @@ def __init__(self, parent=None): about.set_logo_icon_name("org.gnome.Hamster.GUI") - about.connect("response", lambda self, *args: self.destroy()) - about.show_all() + about.connect("close-request", lambda w: w.destroy()) def present(self): self.window.present() diff --git a/src/hamster/edit_activity.py b/src/hamster/edit_activity.py index 72ddc4c12..0c480755d 100644 --- a/src/hamster/edit_activity.py +++ b/src/hamster/edit_activity.py @@ -60,11 +60,13 @@ def __init__(self, action, fact_id=None): category_widget=self.category_entry) self.cmdline = widgets.CmdLineEntry(parent=self.get_widget("cmdline box")) - self.cmdline.connect("focus_in_event", self.on_cmdline_focus_in_event) - self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event) + cmdline_focus = gtk.EventControllerFocus() + cmdline_focus.connect("enter", lambda c: self.on_cmdline_focus_in_event(self.cmdline)) + cmdline_focus.connect("leave", lambda c: self.on_cmdline_focus_out_event(self.cmdline)) + self.cmdline.add_controller(cmdline_focus) self.dayline = widgets.DayLine() - self._gui.get_object("day_preview").add(self.dayline) + self._gui.get_object("day_preview").append(self.dayline) self.description_box = self.get_widget('description') self.description_buffer = self.description_box.get_buffer() @@ -110,6 +112,9 @@ def __init__(self, action, fact_id=None): # This signal should be emitted only after a manual modification, # not at init time when cmdline might not always be fully parsable. self.cmdline.connect("changed", self.on_cmdline_changed) + self.cmdline.connect("activate", self.on_entry_activated) + self.start_time.connect("activate", self.on_entry_activated) + self.end_time.connect("activate", self.on_entry_activated) self.description_buffer.connect("changed", self.on_description_changed) self.start_time.connect("changed", self.on_start_time_changed) self.start_date.connect("day-selected", self.on_start_date_changed) @@ -123,9 +128,26 @@ def __init__(self, action, fact_id=None): self.category_entry.connect("changed", self.on_category_changed) self.tags_entry.connect("changed", self.on_tags_changed) - self._gui.connect_signals(self) + # Manual signal connections (replacing connect_signals removed in GTK4) + self.window.connect("close-request", self.on_close) + self.get_widget("button-prev-day").connect("clicked", self.on_prev_day_clicked) + self.get_widget("button-next-day").connect("clicked", self.on_next_day_clicked) + self.get_widget("delete_button").connect("clicked", self.on_delete_clicked) + self.get_widget("cancel_button").connect("clicked", self.on_cancel_clicked) + self.get_widget("save_button").connect("clicked", self.on_save_button_clicked) + sc = gtk.ShortcutController() + sc.set_scope(gtk.ShortcutScope.LOCAL) + for trigger, callback in [ + ("Escape", lambda w, a: self.close_window()), + ("w", lambda w, a: self.close_window()), + ]: + sc.add_shortcut(gtk.Shortcut( + trigger=gtk.ShortcutTrigger.parse_string(trigger), + action=gtk.CallbackAction.new(callback), + )) + self.window.add_controller(sc) + self.validate_fields() - self.window.show_all() @property def date(self): @@ -191,10 +213,10 @@ def on_cmdline_changed(self, widget): self.fact = fact self.update_fields() - def on_cmdline_focus_in_event(self, widget, event): + def on_cmdline_focus_in_event(self, widget): self.master_is_cmdline = True - def on_cmdline_focus_out_event(self, widget, event): + def on_cmdline_focus_out_event(self, widget): self.master_is_cmdline = False def on_description_changed(self, text): @@ -307,13 +329,13 @@ def update_status(self, status, markup): """Set save button sensitivity and tooltip.""" self.save_button.set_tooltip_markup(markup) if status == "looks good": - self.save_button.set_label("gtk-save") + self.save_button.set_label(_("Save")) self.save_button.set_sensitive(True) elif status == "warning": - self.save_button.set_label("gtk-dialog-warning") + self.save_button.set_label(_("Save")) self.save_button.set_sensitive(True) elif status == "wrong": - self.save_button.set_label("gtk-save") + self.save_button.set_label(_("Save")) self.save_button.set_sensitive(False) else: raise ValueError("unknown status: '{}'".format(status)) @@ -361,9 +383,13 @@ def on_delete_clicked(self, button): def on_cancel_clicked(self, button): self.close_window() - def on_close(self, widget, event): + def on_close(self, widget): self.close_window() + def on_entry_activated(self, entry): + if self.validate_fields(): + self.on_save_button_clicked(None) + def on_save_button_clicked(self, button): if self.action == "edit": runtime.storage.update_fact(self.fact_id, self.fact) @@ -371,23 +397,3 @@ def on_save_button_clicked(self, button): runtime.storage.add_fact(self.fact) self.close_window() - def on_window_key_pressed(self, tree, event_key): - popups = (self.cmdline.popup.get_property("visible") - or self.start_time.popup.get_property("visible") - or self.end_time.popup.get_property("visible") - or self.tags_entry.popup.get_property("visible")) - - if (event_key.keyval == gdk.KEY_Escape or \ - (event_key.keyval == gdk.KEY_w and event_key.state & gdk.ModifierType.CONTROL_MASK)): - if popups: - return False - - self.close_window() - - elif event_key.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter): - if popups: - return False - if self.description_box.has_focus(): - return False - if self.validate_fields(): - self.on_save_button_clicked(None) diff --git a/src/hamster/lib/configuration.py b/src/hamster/lib/configuration.py index 516fa161e..276fe206f 100644 --- a/src/hamster/lib/configuration.py +++ b/src/hamster/lib/configuration.py @@ -54,15 +54,14 @@ def __init__(self, ui_file=""): self._gui = None self.window = gtk.Window() - self.window.connect("delete-event", self.window_delete_event) - if self._gui: - self._gui.connect_signals(self) + self.window.connect("close-request", self.window_delete_event) + # connect_signals removed in GTK4; signals connected manually in subclasses def get_widget(self, name): """ skip one variable (huh) """ return self._gui.get_object(name) - def window_delete_event(self, widget, event): + def window_delete_event(self, widget): self.close_window() def close_window(self): diff --git a/src/hamster/lib/datetime.py b/src/hamster/lib/datetime.py index ffd3d5749..4b9647422 100644 --- a/src/hamster/lib/datetime.py +++ b/src/hamster/lib/datetime.py @@ -15,6 +15,8 @@ import datetime as pdt # standard datetime import re +timezone = pdt.timezone + from collections import namedtuple from textwrap import dedent from functools import lru_cache @@ -337,6 +339,10 @@ def from_pdt(cls, t): t.second, t.microsecond, t.tzinfo, **kwargs) + @classmethod + def fromtimestamp(cls, timestamp, tz=None): + return cls.from_pdt(pdt.datetime.fromtimestamp(timestamp, tz=tz)) + @classmethod def now(cls): """Current datetime.""" @@ -468,14 +474,14 @@ def format(self, default_day=None, explicit_none=True): @classmethod def parse(cls, text, - position="exact", separator="\s+", default_day=None, ref="now"): + position="exact", separator=r"\s+", default_day=None, ref="now"): """Parse a start-end range from text. position (str): "exact" to match exactly the full text "head" to search only at the beginning of text, and "tail" to search only at the end. - separator (str): regexp pattern (e.g. '\s+') meant to separate the datetime + separator (str): regexp pattern (e.g. ``\\s+``) meant to separate the datetime from the rest. Discarded for "exact" position. default_day (date): If start is given without any date (e.g. just hh:mm), diff --git a/src/hamster/lib/dbus.py b/src/hamster/lib/dbus.py index f282e4fdc..6fca7456d 100644 --- a/src/hamster/lib/dbus.py +++ b/src/hamster/lib/dbus.py @@ -95,8 +95,8 @@ def from_dbus_fact(dbus_fact): Legacy: to besuperceded by from_dbus_fact_json at some point. """ return Fact(activity=dbus_fact[4], - start_time=dt.datetime.utcfromtimestamp(dbus_fact[1]), - end_time=dt.datetime.utcfromtimestamp(dbus_fact[2]) if dbus_fact[2] else None, + start_time=dt.datetime.fromtimestamp(dbus_fact[1], tz=dt.timezone.utc).replace(tzinfo=None), + end_time=dt.datetime.fromtimestamp(dbus_fact[2], tz=dt.timezone.utc).replace(tzinfo=None) if dbus_fact[2] else None, description=dbus_fact[3], activity_id=dbus_fact[5], category=dbus_fact[6], diff --git a/src/hamster/lib/graphics.py b/src/hamster/lib/graphics.py index af4ceb750..f04219f4c 100644 --- a/src/hamster/lib/graphics.py +++ b/src/hamster/lib/graphics.py @@ -27,12 +27,27 @@ import colorsys from collections import deque +from dataclasses import dataclass, field +@dataclass +class SceneEvent: + """Shim event object for GTK4 — replaces GTK3 Gdk.Event objects + that were passed through the Scene/Sprite event system.""" + x: float = 0 + y: float = 0 + state: int = 0 + keyval: int = 0 + keycode: int = 0 + button: int = 1 -# lemme know if you know a better way how to get default font -_test_label = gtk.Label("Hello") -_font_desc = _test_label.get_style().font_desc.to_string() + def copy(self): + return SceneEvent(self.x, self.y, self.state, self.keyval, + self.keycode, self.button) + + + +_font_desc = "Sans 10" class ColorUtils(object): @@ -74,11 +89,6 @@ def parse(self, color): match = self.hex_color_short.match(color) color = [int(color + color, 16) / 255.0 for color in match.groups()] - elif isinstance(color, gdk.Color): - color = [color.red / 65535.0, - color.green / 65535.0, - color.blue / 65535.0] - elif isinstance(color, (list, tuple)): # otherwise we assume we have color components in 0..255 range if color[0] > 1 or color[1] > 1 or color[2] > 1: @@ -94,9 +104,9 @@ def rgb(self, color): return [c * 255 for c in self.parse(color)] def gdk(self, color): - """returns gdk.Color object of the given color""" + """returns gdk.RGBA object of the given color""" c = self.parse(color) - return gdk.Color.from_floats(c) + return gdk.RGBA(red=c[0], green=c[1], blue=c[2], alpha=1.0) def hex(self, color): if isinstance(color, gdk.RGBA): @@ -1028,15 +1038,15 @@ def __setattr__(self, name, val): def _get_mouse_cursor(self): """Determine mouse cursor. By default look for self.mouse_cursor is defined and take that. - Otherwise use gdk.CursorType.FLEUR for draggable sprites and gdk.CursorType.HAND2 for + Otherwise use "move" cursor for draggable sprites and "pointer" cursor for interactive sprites. Defaults to scenes cursor. """ if self.mouse_cursor is not None: return self.mouse_cursor elif self.interactive and self.draggable: - return gdk.CursorType.FLEUR + return gdk.Cursor.new_from_name("move") elif self.interactive: - return gdk.CursorType.HAND2 + return gdk.Cursor.new_from_name("pointer") def bring_to_front(self): """adjusts sprite's z-order so that the sprite is on top of it's @@ -1395,6 +1405,10 @@ def _draw(self, context, opacity = 1, parent_matrix = None): local_context = cairo.Context(surface) if isinstance(self.image_data, GdkPixbuf.Pixbuf): gdk.cairo_set_source_pixbuf(local_context, self.image_data, 0, 0) + elif isinstance(self.image_data, gdk.Texture): + self.image_data.download(surface.get_data(), surface.get_stride()) + surface.mark_dirty() + local_context.set_source_surface(surface) else: local_context.set_source_surface(self.image_data) local_context.paint() @@ -1430,7 +1444,7 @@ class Icon(BitmapSprite): """Displays icon by name and size in the theme""" def __init__(self, name, size=24, **kwargs): BitmapSprite.__init__(self, **kwargs) - self.theme = gtk.IconTheme.get_default() + self.theme = gtk.IconTheme.get_for_display(gdk.Display.get_default()) #: icon name from theme self.name = name @@ -1442,7 +1456,17 @@ def __setattr__(self, name, val): BitmapSprite.__setattr__(self, name, val) if name in ('name', 'size'): # no other reason to discard cache than just on path change if self.__dict__.get('name') and self.__dict__.get('size'): - self.image_data = self.theme.load_icon(self.name, self.size, 0) + icon_info = self.theme.lookup_icon( + self.name, None, self.size, 1, + gtk.TextDirection.NONE, gtk.IconLookupFlags.FORCE_SYMBOLIC) + icon_file = icon_info.get_file() + if icon_file: + path = icon_file.get_path() + if path and path.endswith('.png'): + self.image_data = cairo.ImageSurface.create_from_png(path) + elif path and path.endswith('.svg'): + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(path, self.size, self.size) + self.image_data = pixbuf else: self.image_data = None @@ -1776,11 +1800,9 @@ def __init__(self, interactive = True, framerate = 60, style_class=None): gtk.DrawingArea.__init__(self) - self._style = self.get_style_context() - #: widget style. One of gtk.STYLE_CLASS_*. By default it's BACKGROUND - self.style_class = style_class or gtk.STYLE_CLASS_BACKGROUND - self._style.add_class(self.style_class) # so we know our colors + self.style_class = style_class or "background" + self.add_css_class(self.style_class) #: list of sprites in scene. use :func:`add_child` to add sprites self.sprites = [] @@ -1835,7 +1857,7 @@ def __init__(self, interactive = True, framerate = 60, #: can be overidden by child sprites self.default_mouse_cursor = None - self._blank_cursor = gdk.Cursor(gdk.CursorType.BLANK_CURSOR) + self._blank_cursor = gdk.Cursor.new_from_name("none") self.__previous_mouse_signal_time = None @@ -1869,23 +1891,33 @@ def __init__(self, interactive = True, framerate = 60, self.__last_mouse_move = None - self.connect("realize", self.__on_realize) + self.set_draw_func(self._do_draw) if interactive: self.set_can_focus(True) - self.set_events(gdk.EventMask.POINTER_MOTION_MASK - | gdk.EventMask.LEAVE_NOTIFY_MASK | gdk.EventMask.ENTER_NOTIFY_MASK - | gdk.EventMask.BUTTON_PRESS_MASK | gdk.EventMask.BUTTON_RELEASE_MASK - | gdk.EventMask.SCROLL_MASK - | gdk.EventMask.KEY_PRESS_MASK) - self.connect("motion-notify-event", self.__on_mouse_move) - self.connect("enter-notify-event", self.__on_mouse_enter) - self.connect("leave-notify-event", self.__on_mouse_leave) - self.connect("button-press-event", self.__on_button_press) - self.connect("button-release-event", self.__on_button_release) - self.connect("scroll-event", self.__on_scroll) - self.connect("key-press-event", self.__on_key_press) - self.connect("key-release-event", self.__on_key_release) + self.set_focusable(True) + + motion = gtk.EventControllerMotion() + motion.connect("motion", self.__on_mouse_move) + motion.connect("enter", self.__on_mouse_enter) + motion.connect("leave", self.__on_mouse_leave) + self.add_controller(motion) + + click = gtk.GestureClick() + click.set_button(0) + click.connect("pressed", self.__on_button_press) + click.connect("released", self.__on_button_release) + self.add_controller(click) + + scroll = gtk.EventControllerScroll( + flags=gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self.__on_scroll) + self.add_controller(scroll) + + key = gtk.EventControllerKey() + key.connect("key-pressed", self.__on_key_press) + key.connect("key-released", self.__on_key_release) + self.add_controller(key) @@ -1905,14 +1937,20 @@ def __setattr__(self, name, val): val._do_focus() elif name == "style_class": if hasattr(self, "style_class"): - self._style.remove_class(self.style_class) - self._style.add_class(val) + self.remove_css_class(self.style_class) + self.add_css_class(val) elif name == "background_color": if val: - self.override_background_color(gtk.StateType.NORMAL, - gdk.RGBA(*Colors.parse(val))) + rgba = gdk.RGBA() + rgba.parse(Colors.hex(val)) + css = "* {{ background-color: {}; }}".format(rgba.to_string()) + provider = gtk.CssProvider() + provider.load_from_data(css.encode()) + self.get_style_context().add_provider( + provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) else: - self.override_background_color(gtk.StateType.NORMAL, None) + # Removing CSS provider is complex, skip for now + pass self.__dict__[name] = val @@ -1976,7 +2014,17 @@ def __redraw_loop(self): return self.__drawing_queued - def do_draw(self, context): + def _do_draw(self, area, context, draw_width, draw_height): + old_w, old_h = self.width, self.height + self.width, self.height = draw_width, draw_height + + if self._original_width is None: + self._original_width = float(draw_width) + self._original_height = float(draw_height) + + if old_w != draw_width or old_h != draw_height: + self.emit("on-resize", SceneEvent()) + if self.scale: aspect_x = self.width / self._original_width aspect_y = self.height / self._original_height @@ -1987,19 +2035,19 @@ def do_draw(self, context): if self.fps is None: self.emit("on-first-frame", context) - cursor, self.mouse_x, self.mouse_y, mods = self._window.get_pointer() - + if self.mouse_x is None: + self.mouse_x = 0 + if self.mouse_y is None: + self.mouse_y = 0 - # update tweens now = dt.datetime.now() delta = (now - (self._last_frame_time or dt.datetime.now())).total_seconds() self._last_frame_time = now if self.tweener: self.tweener.update(delta) - self.fps = 1 / delta + self.fps = 1 / delta if delta > 0 else 60 - # start drawing self.emit("on-enter-frame", context) for sprite in self._z_ordered_sprites: sprite._draw(context) @@ -2007,22 +2055,9 @@ def do_draw(self, context): self.__check_mouse(self.mouse_x, self.mouse_y) self.emit("on-finish-frame", context) - # reset the mouse signal time as redraw means we are good now self.__previous_mouse_signal_time = None - def do_configure_event(self, event): - if self._original_width is None: - self._original_width = float(event.width) - self._original_height = float(event.height) - - width, height = self.width, self.height - self.width, self.height = event.width, event.height - - if width != event.width or height != event.height: - self.emit("on-resize", event) # so that sprites can listen to it - - def all_mouse_sprites(self): """Returns flat list of the sprite tree for simplified iteration""" @@ -2084,45 +2119,51 @@ def __check_mouse(self, x, y): self._mouse_sprite = over if cursor is None: - cursor = self.default_mouse_cursor or gdk.CursorType.ARROW # default + cursor = self.default_mouse_cursor or gdk.Cursor.new_from_name("default") elif cursor is False: cursor = self._blank_cursor if self.__last_cursor is None or cursor != self.__last_cursor: if isinstance(cursor, gdk.Cursor): - self._window.set_cursor(cursor) + self.set_cursor(cursor) + elif isinstance(cursor, str): + self.set_cursor(gdk.Cursor.new_from_name(cursor)) else: - self._window.set_cursor(gdk.Cursor(cursor)) + self.set_cursor(cursor) self.__last_cursor = cursor """ mouse events """ - def __on_mouse_move(self, scene, event): + def __on_mouse_move(self, controller, x, y): if self.__last_mouse_move: gobject.source_remove(self.__last_mouse_move) self.__last_mouse_move = None - self.mouse_x, self.mouse_y = event.x, event.y + self.mouse_x, self.mouse_y = x, y + state = controller.get_current_event_state() if hasattr(controller, 'get_current_event_state') else 0 + event = SceneEvent(x=x, y=y, state=state) - # don't emit mouse move signals more often than every 0.05 seconds timeout = dt.timedelta(seconds=0.05) if self.__previous_mouse_signal_time and dt.datetime.now() - self.__previous_mouse_signal_time < timeout: - self.__last_mouse_move = gobject.timeout_add((timeout - (dt.datetime.now() - self.__previous_mouse_signal_time)).microseconds / 1000, - self.__on_mouse_move, - scene, - event.copy()) + self.__last_mouse_move = gobject.timeout_add( + int((timeout - (dt.datetime.now() - self.__previous_mouse_signal_time)).microseconds / 1000), + self._deferred_mouse_move, event.copy()) return - state = event.state + self._process_mouse_move(event) + def _deferred_mouse_move(self, event): + self.__last_mouse_move = None + self._process_mouse_move(event) + return False + def _process_mouse_move(self, event): if self._mouse_down_sprite and self._mouse_down_sprite.interactive \ and self._mouse_down_sprite.draggable and gdk.ModifierType.BUTTON1_MASK & event.state: - # dragging around if not self.__drag_started: - drag_started = (self.__drag_start_x is not None and \ - (self.__drag_start_x - event.x) ** 2 + \ + drag_started = (self.__drag_start_x is not None and + (self.__drag_start_x - event.x) ** 2 + (self.__drag_start_y - event.y) ** 2 > self.drag_distance ** 2) if drag_started: @@ -2132,7 +2173,6 @@ def __on_mouse_move(self, scene, event): self.start_drag(self._drag_sprite, self.__drag_start_x, self.__drag_start_y) else: - # avoid double mouse checks - the redraw will also check for mouse! if not self.__drawing_queued: self.__check_mouse(event.x, event.y) @@ -2150,7 +2190,6 @@ def __on_mouse_move(self, scene, event): if self._mouse_sprite: sprite_event = event.copy() - sprite_event.x, sprite_event.y = self._mouse_sprite.from_scene_coords(event.x, event.y) self._mouse_sprite._do_mouse_move(sprite_event) @@ -2168,10 +2207,10 @@ def start_drag(self, sprite, cursor_x = None, cursor_y = None): self.__drag_started = True - def __on_mouse_enter(self, scene, event): + def __on_mouse_enter(self, controller, x, y): self._mouse_in = True - def __on_mouse_leave(self, scene, event): + def __on_mouse_leave(self, controller): self._mouse_in = False if self._mouse_sprite: self._mouse_sprite._do_mouse_out() @@ -2179,93 +2218,90 @@ def __on_mouse_leave(self, scene, event): self._mouse_sprite = None - def __on_button_press(self, scene, event): - target = self.get_sprite_at_position(event.x, event.y) + def __on_button_press(self, controller, n_press, x, y): + event = SceneEvent(x=x, y=y, button=controller.get_current_button()) + target = self.get_sprite_at_position(x, y) if not self.__drag_started: - self.__drag_start_x, self.__drag_start_y = event.x, event.y + self.__drag_start_x, self.__drag_start_y = x, y self._mouse_down_sprite = target - # differentiate between the click count! - if event.type == gdk.EventType.BUTTON_PRESS: + if n_press == 1: self.emit("on-mouse-down", event) if target: target_event = event.copy() - target_event.x, target_event.y = target.from_scene_coords(event.x, event.y) + target_event.x, target_event.y = target.from_scene_coords(x, y) target._do_mouse_down(target_event) else: - scene._focus_sprite = None # lose focus if mouse ends up nowhere - elif event.type == gdk.EventType._2BUTTON_PRESS: + self._focus_sprite = None + elif n_press == 2: self.emit("on-double-click", event) if target: target_event = event.copy() - target_event.x, target_event.y = target.from_scene_coords(event.x, event.y) + target_event.x, target_event.y = target.from_scene_coords(x, y) target._do_double_click(target_event) - elif event.type == gdk.EventType._3BUTTON_PRESS: + elif n_press == 3: self.emit("on-triple-click", event) if target: target_event = event.copy() - target_event.x, target_event.y = target.from_scene_coords(event.x, event.y) + target_event.x, target_event.y = target.from_scene_coords(x, y) target._do_triple_click(target_event) - self.__check_mouse(event.x, event.y) - return True + self.__check_mouse(x, y) - def __on_button_release(self, scene, event): - target = self.get_sprite_at_position(event.x, event.y) + def __on_button_release(self, controller, n_press, x, y): + event = SceneEvent(x=x, y=y, button=controller.get_current_button()) + target = self.get_sprite_at_position(x, y) if target: target._do_mouse_up(event) self.emit("on-mouse-up", event) - # trying to not emit click and drag-finish at the same time - click = not self.__drag_started or (event.x - self.__drag_start_x) ** 2 + \ - (event.y - self.__drag_start_y) ** 2 < self.drag_distance + click = not self.__drag_started or (x - self.__drag_start_x) ** 2 + \ + (y - self.__drag_start_y) ** 2 < self.drag_distance if (click and self.__drag_started == False) or not self._drag_sprite: if target and target == self._mouse_down_sprite: target_event = event.copy() - target_event.x, target_event.y = target.from_scene_coords(event.x, event.y) + target_event.x, target_event.y = target.from_scene_coords(x, y) target._do_click(target_event) self.emit("on-click", event, target) self._mouse_down_sprite = None self.__drag_started = False - self.__drag_start_x, self__drag_start_y = None, None + self.__drag_start_x, self.__drag_start_y = None, None if self._drag_sprite: self._drag_sprite.drag_x, self._drag_sprite.drag_y = None, None drag_sprite, self._drag_sprite = self._drag_sprite, None drag_sprite.emit("on-drag-finish", event) self.emit("on-drag-finish", drag_sprite, event) - self.__check_mouse(event.x, event.y) - return True + self.__check_mouse(x, y) - def __on_realize(self, widget): - # Store as soon as available. Maybe for performance reasons, - # to avoid get_window() calls in __on_mouse_move ? - self._window = self.get_window() - - def __on_scroll(self, scene, event): + def __on_scroll(self, controller, dx, dy): + event = SceneEvent(x=self.mouse_x or 0, y=self.mouse_y or 0) + event.dy = dy target = self.get_sprite_at_position(event.x, event.y) if target: target.emit("on-mouse-scroll", event) self.emit("on-mouse-scroll", event) return True - def __on_key_press(self, scene, event): + def __on_key_press(self, controller, keyval, keycode, state): + event = SceneEvent(keyval=keyval, keycode=keycode, state=state) handled = False if self._focus_sprite: handled = self._focus_sprite._do_key_press(event) if not handled: self.emit("on-key-press", event) - return True + return handled - def __on_key_release(self, scene, event): + def __on_key_release(self, controller, keyval, keycode, state): + event = SceneEvent(keyval=keyval, keycode=keycode, state=state) handled = False if self._focus_sprite: handled = self._focus_sprite._do_key_release(event) if not handled: self.emit("on-key-release", event) - return True + return handled diff --git a/src/hamster/lib/layout.py b/src/hamster/lib/layout.py index 2ba81f4dc..955238ac3 100644 --- a/src/hamster/lib/layout.py +++ b/src/hamster/lib/layout.py @@ -209,8 +209,10 @@ def vertical_padding(self): return self.padding_top + self.padding_bottom def __on_mouse_over(self, sprite): - cursor, mouse_x, mouse_y, mods = sprite.get_scene().get_window().get_pointer() - if self.tooltip and not gdk.ModifierType.BUTTON1_MASK & mods: + scene = sprite.get_scene() + mouse_x = scene.mouse_x if scene else 0 + mouse_y = scene.mouse_y if scene else 0 + if self.tooltip: self._set_tooltip(self.tooltip) diff --git a/src/hamster/overview.py b/src/hamster/overview.py index 1af1b0b80..7cbf2fd17 100644 --- a/src/hamster/overview.py +++ b/src/hamster/overview.py @@ -28,6 +28,7 @@ from gi.repository import GLib as glib from gi.repository import Gtk as gtk from gi.repository import Gdk as gdk +from gi.repository import Gio as gio from gi.repository import GObject as gobject from gi.repository import PangoCairo as pangocairo from gi.repository import Pango as pango @@ -53,64 +54,49 @@ class HeaderBar(gtk.HeaderBar): def __init__(self): gtk.HeaderBar.__init__(self) - self.set_show_close_button(True) + self.set_show_title_buttons(True) - box = gtk.Box(False) - self.time_back = gtk.Button.new_from_icon_name("go-previous-symbolic", gtk.IconSize.MENU) - self.time_forth = gtk.Button.new_from_icon_name("go-next-symbolic", gtk.IconSize.MENU) + box = gtk.Box() + self.time_back = gtk.Button.new_from_icon_name("go-previous-symbolic") + self.time_forth = gtk.Button.new_from_icon_name("go-next-symbolic") - box.add(self.time_back) - box.add(self.time_forth) - gtk.StyleContext.add_class(box.get_style_context(), "linked") + box.append(self.time_back) + box.append(self.time_forth) + box.add_css_class("linked") self.pack_start(box) self.range_pick = RangePick(dt.hday.today()) self.pack_start(self.range_pick) self.system_button = gtk.MenuButton() - self.system_button.set_image(gtk.Image.new_from_icon_name( - "open-menu-symbolic", gtk.IconSize.MENU)) + self.system_button.set_icon_name("open-menu-symbolic") self.system_button.set_tooltip_markup(_("Menu")) self.pack_end(self.system_button) self.search_button = gtk.ToggleButton() - self.search_button.set_image(gtk.Image.new_from_icon_name( - "edit-find-symbolic", gtk.IconSize.MENU)) + self.search_button.set_icon_name("edit-find-symbolic") self.search_button.set_tooltip_markup(_("Filter activities")) self.pack_end(self.search_button) self.stop_button = gtk.Button() - self.stop_button.set_image(gtk.Image.new_from_icon_name( - "process-stop-symbolic", gtk.IconSize.MENU)) + self.stop_button.set_icon_name("process-stop-symbolic") self.stop_button.set_tooltip_markup(_("Stop tracking (Ctrl-SPACE)")) self.pack_end(self.stop_button) self.add_activity_button = gtk.Button() - self.add_activity_button.set_image(gtk.Image.new_from_icon_name( - "list-add-symbolic", gtk.IconSize.MENU)) + self.add_activity_button.set_icon_name("list-add-symbolic") self.add_activity_button.set_tooltip_markup(_("Add activity (Ctrl-+)")) self.pack_end(self.add_activity_button) - self.system_menu = gtk.Menu() - self.system_button.set_popup(self.system_menu) - self.menu_export = gtk.MenuItem(label=_("Export...")) - self.system_menu.append(self.menu_export) - self.menu_prefs = gtk.MenuItem(label=_("Tracking Settings")) - self.system_menu.append(self.menu_prefs) - self.menu_help = gtk.MenuItem(label=_("Help")) - self.system_menu.append(self.menu_help) - self.system_menu.show_all() - + menu = gio.Menu() + menu.append(_("Export..."), "app.hamster-export") + menu.append(_("Tracking Settings"), "app.hamster-prefs") + menu.append(_("Help"), "app.hamster-help") + self.system_button.set_menu_model(menu) self.time_back.connect("clicked", self.on_time_back_click) self.time_forth.connect("clicked", self.on_time_forth_click) - self.connect("button-press-event", self.on_button_press) - - def on_button_press(self, bar, event): - """swallow clicks on the interactive parts to avoid triggering - switch to full-window""" - return True def on_time_back_click(self, button): self.range_pick.prev_range() @@ -278,10 +264,10 @@ def __init__(self): box.add_child(self.category_totals, self.stacked_bar) self.totals = {} - self.mouse_cursor = gdk.CursorType.HAND2 + self.mouse_cursor = gdk.Cursor.new_from_name("pointer") self.instructions_label = layout.Label(_("Click to see stats"), - color=self._style.get_color(gtk.StateFlags.NORMAL), + color=self.get_color(), padding=10, expand=False) @@ -310,10 +296,11 @@ def __init__(self): self.add_child(self.height_proxy) self.connect("on-click", self.on_click) - self.connect("enter-notify-event", self.on_mouse_enter) - self.connect("leave-notify-event", self.on_mouse_leave) + self.connect("on-mouse-over", lambda scene, sprite: self.on_mouse_enter()) + self.connect("on-mouse-out", lambda scene, sprite: self.on_mouse_leave()) self.connect("state-flags-changed", self.on_state_flags_changed) - self.connect("style-updated", self.on_style_changed) + self.connect("notify::css-classes", lambda *a: self.update_colors()) + self.connect("map", lambda w: self.update_colors()) def set_facts(self, facts): @@ -354,9 +341,9 @@ def on_click(self, scene, sprite, event): self.change_height(300) self.instructions_label.visible = False - self.mouse_cursor = gdk.CursorType.HAND2 if self.collapsed else None + self.mouse_cursor = gdk.Cursor.new_from_name("pointer") if self.collapsed else None - def on_mouse_enter(self, scene, event): + def on_mouse_enter(self, *args): if not self.collapsed: return @@ -368,7 +355,7 @@ def delayed_leave(sprite): on_update=lambda sprite: sprite.redraw()) - def on_mouse_leave(self, scene, event): + def on_mouse_leave(self, *args): if not self.collapsed: return @@ -379,10 +366,7 @@ def delayed_leave(sprite): on_complete=delayed_leave, on_update=lambda sprite: sprite.redraw()) - def on_state_flags_changed(self, previous_state, _): - self.update_colors() - - def on_style_changed(self, _): + def on_state_flags_changed(self, *args): self.update_colors() def change_height(self, new_height): @@ -395,14 +379,22 @@ def on_update_dummy(sprite): on_update=on_update_dummy, easing=Easing.Expo.ease_out) + def _get_fg_color(self): + root = self.get_root() + if root: + return root.get_color() + return self.get_color() + def update_colors(self): - color = self._style.get_color(self.get_state()) + color = self._get_fg_color() self.instructions_label.color = color self.category_totals.color = color self.activities_chart.label_color = color self.categories_chart.label_color = color self.tag_chart.label_color = color - bg_color = self._style.get_background_color(self.get_state()) + success, bg_color = self.get_style_context().lookup_color("theme_bg_color") + if not success: + bg_color = gdk.RGBA(1, 1, 1, 1) bar_color = self.colors.mix(bg_color, color, 0.6) self.activities_chart.bar_color = bar_color self.categories_chart.bar_color = bar_color @@ -415,7 +407,6 @@ def __init__(self): self.prefs_dialog = None # preferences dialog controller - self.window.set_position(gtk.WindowPosition.CENTER) self.window.set_default_icon_name("org.gnome.Hamster.GUI") self.window.set_default_size(700, 500) @@ -426,24 +417,29 @@ def __init__(self): self.header_bar = HeaderBar() self.window.set_titlebar(self.header_bar) - main = gtk.Box(orientation=1) - self.window.add(main) + main = gtk.Box(orientation=gtk.Orientation.VERTICAL) + self.window.set_child(main) self.report_chooser = None self.search_box = gtk.Revealer() - space = gtk.Box(border_width=5) - self.search_box.add(space) + space = gtk.Box() + space.set_margin_start(5) + space.set_margin_end(5) + space.set_margin_top(5) + space.set_margin_bottom(5) + self.search_box.set_child(space) self.filter_entry = gtk.Entry() self.filter_entry.set_icon_from_icon_name(gtk.EntryIconPosition.PRIMARY, "edit-find-symbolic") self.filter_entry.connect("changed", self.on_search_changed) self.filter_entry.connect("icon-press", self.on_search_icon_press) - space.pack_start(self.filter_entry, True, True, 0) - main.pack_start(self.search_box, False, True, 0) + self.filter_entry.set_hexpand(True) + space.append(self.filter_entry) + main.append(self.search_box) window = gtk.ScrolledWindow() @@ -452,11 +448,12 @@ def __init__(self): self.fact_tree.connect("on-activate-row", self.on_row_activated) self.fact_tree.connect("on-delete-called", self.on_row_delete_called) - window.add(self.fact_tree) - main.pack_start(window, True, True, 1) + window.set_child(self.fact_tree) + window.set_vexpand(True) + main.append(window) self.totals = Totals() - main.pack_start(self.totals, False, True, 1) + main.append(self.totals) # FIXME: should store and recall date_range from hamster.lib.configuration.conf hamster_day = dt.hday.today() @@ -466,62 +463,72 @@ def __init__(self): self.header_bar.stop_button.connect("clicked", self.on_stop_clicked) self.header_bar.search_button.connect("toggled", self.on_search_toggled) - self.header_bar.menu_prefs.connect("activate", self.on_prefs_clicked) - self.header_bar.menu_export.connect("activate", self.on_export_clicked) - self.header_bar.menu_help.connect("activate", self.on_help_clicked) - - - self.window.connect("key-press-event", self.on_key_press) + app = gtk.Application.get_default() + if app: + export_action = gio.SimpleAction.new("hamster-export", None) + export_action.connect("activate", lambda a, p: self.on_export_clicked(None)) + app.add_action(export_action) + + prefs_action = gio.SimpleAction.new("hamster-prefs", None) + prefs_action.connect("activate", lambda a, p: self.on_prefs_clicked(None)) + app.add_action(prefs_action) + + help_action = gio.SimpleAction.new("hamster-help", None) + help_action.connect("activate", lambda a, p: self.on_help_clicked(None)) + app.add_action(help_action) + + # Ctrl+shortcuts via ShortcutController (doesn't intercept normal typing) + sc = gtk.ShortcutController() + sc.set_scope(gtk.ShortcutScope.MANAGED) + for trigger, callback in [ + ("f", lambda w, a: self.header_bar.search_button.set_active(True)), + ("n", lambda w, a: self.start_new_fact(clone_selected=False)), + ("r", lambda w, a: self.start_new_fact(clone_selected=True, fallback=False)), + ("space", lambda w, a: self.storage.stop_or_restart_tracking()), + ("plus", lambda w, a: self.start_new_fact(clone_selected=True, fallback=True)), + ("KP_Add", lambda w, a: self.start_new_fact(clone_selected=True, fallback=True)), + ("Escape", lambda w, a: self.close_window()), + ("w", lambda w, a: self.close_window()), + ]: + sc.add_shortcut(gtk.Shortcut( + trigger=gtk.ShortcutTrigger.parse_string(trigger), + action=gtk.CallbackAction.new(callback), + )) + self.window.add_controller(sc) + + # Arrow/nav keys forwarded to fact_tree via EventControllerKey + key_controller = gtk.EventControllerKey() + key_controller.connect("key-pressed", self.on_key_press) + self.window.add_controller(key_controller) self.facts = [] self.find_facts() # update every minute (necessary if an activity is running) gobject.timeout_add_seconds(60, self.on_timeout) - self.window.show_all() - def on_key_press(self, window, event): + def on_key_press(self, controller, keyval, keycode, state): + if not self.window.is_active(): + return False if self.filter_entry.has_focus(): - if event.keyval == gdk.KEY_Escape: - self.filter_entry.set_text("") - self.header_bar.search_button.set_active(False) - return True - elif event.keyval in (gdk.KEY_Up, gdk.KEY_Down, - gdk.KEY_Home, gdk.KEY_End, - gdk.KEY_Page_Up, gdk.KEY_Page_Down, - gdk.KEY_Return, gdk.KEY_Delete): - # These keys should work even when fact_tree does not have focus - self.fact_tree.on_key_press(self, event) - return True # stop event propagation - elif event.keyval == gdk.KEY_Left: + return False + from hamster.lib.graphics import SceneEvent + event = SceneEvent(keyval=keyval, keycode=keycode, state=state) + if keyval in (gdk.KEY_Up, gdk.KEY_Down, + gdk.KEY_Home, gdk.KEY_End, + gdk.KEY_Page_Up, gdk.KEY_Page_Down, + gdk.KEY_Return, gdk.KEY_Delete): + if not self.fact_tree.has_focus(): + self.fact_tree.on_key_press(self, event) + return True + elif keyval == gdk.KEY_Left: self.header_bar.time_back.emit("clicked") return True - elif event.keyval == gdk.KEY_Right: + elif keyval == gdk.KEY_Right: self.header_bar.time_forth.emit("clicked") return True - - if self.fact_tree.has_focus() or self.totals.has_focus(): - if event.keyval == gdk.KEY_Tab: - pass # TODO - deal with tab as our scenes eat up navigation - - if event.state & gdk.ModifierType.CONTROL_MASK: - # the ctrl+things - if event.keyval == gdk.KEY_f: - self.header_bar.search_button.set_active(True) - elif event.keyval == gdk.KEY_n: - self.start_new_fact(clone_selected=False) - elif event.keyval == gdk.KEY_r: - # Resume/run; clear separation between Ctrl-R and Ctrl-N - self.start_new_fact(clone_selected=True, fallback=False) - elif event.keyval == gdk.KEY_space: - self.storage.stop_or_restart_tracking() - elif event.keyval in (gdk.KEY_KP_Add, gdk.KEY_plus): - # same as pressing the + icon - self.start_new_fact(clone_selected=True, fallback=True) - - if event.keyval == gdk.KEY_Escape: - self.close_window() + return False def find_facts(self, scroll_to_top=False): start, end = self.header_bar.range_pick.get_range() @@ -581,12 +588,16 @@ def on_timeout(self): def on_help_clicked(self, menu): uri = "help:hamster" try: - gtk.show_uri(None, uri, gdk.CURRENT_TIME) + gtk.show_uri(self.window, uri, gdk.CURRENT_TIME) except glib.Error: msg = sys.exc_info()[1].args[0] - dialog = gtk.MessageDialog(self.window, 0, gtk.MessageType.ERROR, - gtk.ButtonsType.CLOSE, - _("Failed to open {}").format(uri)) + dialog = gtk.MessageDialog( + transient_for=self.window, + modal=True, + message_type=gtk.MessageType.ERROR, + buttons=gtk.ButtonsType.CLOSE, + text=_("Failed to open {}").format(uri) + ) fmt = _('Error: "{}" - is a help browser installed on this computer?') dialog.format_secondary_text(fmt.format(msg)) dialog.run() @@ -611,7 +622,7 @@ def on_report_chosen(widget, format, path): webbrowser.open_new("file://%s" % path) else: try: - gtk.show_uri(None, "file://%s" % path, gdk.CURRENT_TIME) + gtk.show_uri(self.window, "file://%s" % path, gdk.CURRENT_TIME) except: pass # bug 626656 - no use in capturing this one i think diff --git a/src/hamster/preferences.py b/src/hamster/preferences.py index 34d7b8381..95fde16ef 100644 --- a/src/hamster/preferences.py +++ b/src/hamster/preferences.py @@ -72,10 +72,8 @@ def load(self, category_id): class PreferencesEditor(Controller): - TARGETS = [ - ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_WIDGET, 0), - ('MY_TREE_MODEL_ROW', gtk.TargetFlags.SAME_APP, 0), - ] + # DnD temporarily removed during GTK4 migration + # TODO: Re-implement with Gtk.DragSource/DropTarget controllers def __init__(self): Controller.__init__(self, ui_file="preferences.ui") @@ -137,18 +135,8 @@ def __init__(self): self.load_config() - # Allow enable drag and drop of rows including row move - self.activity_tree.enable_model_drag_source(gdk.ModifierType.BUTTON1_MASK, - self.TARGETS, - gdk.DragAction.DEFAULT| - gdk.DragAction.MOVE) - - self.category_tree.enable_model_drag_dest(self.TARGETS, - gdk.DragAction.MOVE) - - self.activity_tree.connect("drag_data_get", self.drag_data_get_data) - - self.category_tree.connect("drag_data_received", self.on_category_drop) + # DnD temporarily removed during GTK4 migration + # TODO: Re-implement with Gtk.DragSource/DropTarget controllers #select first category selection = self.category_tree.get_selection() @@ -161,11 +149,45 @@ def __init__(self): (self.day_start, self.day_start.connect("time-entered", self.on_day_start_changed)) ]) + # Manual signal connections (replacing connect_signals removed in GTK4) + self.get_widget("close_button").connect("clicked", self.on_close_button_clicked) + sc = gtk.ShortcutController() + sc.set_scope(gtk.ShortcutScope.MANAGED) + for trigger in ("Escape", "w"): + sc.add_shortcut(gtk.Shortcut( + trigger=gtk.ShortcutTrigger.parse_string(trigger), + action=gtk.CallbackAction.new(lambda w, a: self.close_window()), + )) + self.window.add_controller(sc) + self.get_widget("category_add").connect("clicked", self.on_category_add_clicked) + self.get_widget("category_remove").connect("clicked", self.on_category_remove_clicked) + self.get_widget("category_edit").connect("clicked", self.on_category_edit_clicked) + self.get_widget("activity_add").connect("clicked", self.on_activity_add_clicked) + self.get_widget("activity_remove").connect("clicked", self.on_activity_remove_clicked) + self.get_widget("activity_edit").connect("clicked", self.on_activity_edit_clicked) + # Click controllers for tree lists (for double-click-to-edit) + for tree, press_handler, release_handler in [ + (self.category_tree, + self.on_category_list_button_pressed, + self.on_category_list_button_released), + (self.activity_tree, + self.on_activity_list_button_pressed, + self.on_activity_list_button_released), + ]: + click = gtk.GestureClick() + click.connect("pressed", lambda c, n, x, y, h=press_handler: h(c.get_widget(), n, x, y)) + click.connect("released", lambda c, n, x, y, h=release_handler: h(c.get_widget(), n, x, y)) + tree.add_controller(click) + + focus_ctrl = gtk.EventControllerFocus() + focus_ctrl.connect("leave", lambda c: self.on_autocomplete_tags_view_focus_out_event( + self.get_widget("autocomplete_tags"), None)) + self.get_widget("autocomplete_tags_view").add_controller(focus_ctrl) + self.show() def show(self): self.get_widget("notebook1").set_current_page(0) - self.window.show_all() def load_config(self, *args): self.day_start.time = conf.day_start @@ -183,13 +205,6 @@ def on_autocomplete_tags_view_focus_out_event(self, view, event): runtime.storage.update_autocomplete_tags(updated_tags) - def drag_data_get_data(self, treeview, context, selection, target_id, - etime): - treeselection = treeview.get_selection() - model, iter = treeselection.get_selected() - data = model.get_value(iter, 0) #get activity ID - selection.set(selection.target, 0, str(data)) - def select_activity(self, id): model = self.activity_tree.get_model() i = 0 @@ -206,41 +221,6 @@ def select_category(self, id): self.category_tree.set_cursor((i, )) i += 1 - def on_category_list_drag_motion(self, treeview, drag_context, x, y, eventtime): - self.prev_selected_category = None - try: - target_path, drop_position = treeview.get_dest_row_at_pos(x, y) - model, source = treeview.get_selection().get_selected() - - except: - return - - drop_yes = ("drop_yes", gtk.TargetFlags.SAME_APP, 0) - drop_no = ("drop_no", gtk.TargetFlags.SAME_APP, 0) - - if drop_position != gtk.TREE_VIEW_DROP_AFTER and \ - drop_position != gtk.TREE_VIEW_DROP_BEFORE: - treeview.enable_model_drag_dest(self.TARGETS, gdk.DragAction.MOVE) - else: - treeview.enable_model_drag_dest([drop_no], gdk.DragAction.MOVE) - - def on_category_drop(self, treeview, context, x, y, selection, - info, etime): - model = self.category_tree.get_model() - data = selection.data - drop_info = treeview.get_dest_row_at_pos(x, y) - - if drop_info: - path, position = drop_info - iter = model.get_iter(path) - changed = runtime.storage.change_category(int(data), model[iter][0]) - - context.finish(changed, True, etime) - else: - context.finish(False, True, etime) - - return - # callbacks def category_edited_cb(self, cell, path, new_text, model): id = model[path][0] @@ -344,42 +324,34 @@ def unsorted_painter(self, column, cell, model, iter, data): cell_id = model.get_value(iter, 0) cell_text = model.get_value(iter, 1) if cell_id == -1: - text = '%s' % cell_text # TODO - should get color from theme - cell.set_property('markup', text) + cell.set_property('markup', '%s' % cell_text) + cell.set_property('sensitive', False) else: cell.set_property('text', cell_text) + cell.set_property('sensitive', True) - return - - def on_activity_list_button_pressed(self, tree, event): + def on_activity_list_button_pressed(self, tree, n_press, x, y): self.activityCell.set_property("editable", False) - def on_activity_list_button_released(self, tree, event): - if event.button == 1 and tree.get_path_at_pos(int(event.x), int(event.y)): - # Get treeview path. - path, column, x, y = tree.get_path_at_pos(int(event.x), int(event.y)) + def on_activity_list_button_released(self, tree, n_press, x, y): + if tree.get_path_at_pos(int(x), int(y)): + path, column, cx, cy = tree.get_path_at_pos(int(x), int(y)) if self.prev_selected_activity == path: - self.activityCell.set_property("editable", True) - tree.set_cursor_on_cell(path, self.activityColumn, self.activityCell, True) + self.on_activity_edit_clicked(None) self.prev_selected_activity = path - def on_category_list_button_pressed(self, tree, event): + def on_category_list_button_pressed(self, tree, n_press, x, y): self.activityCell.set_property("editable", False) - def on_category_list_button_released(self, tree, event): - if event.button == 1 and tree.get_path_at_pos(int(event.x), int(event.y)): - # Get treeview path. - path, column, x, y = tree.get_path_at_pos(int(event.x), int(event.y)) + def on_category_list_button_released(self, tree, n_press, x, y): + if tree.get_path_at_pos(int(x), int(y)): + path, column, cx, cy = tree.get_path_at_pos(int(x), int(y)) if self.prev_selected_category == path and \ - self._get_selected_category() != -1: #do not allow to edit unsorted - self.categoryCell.set_property("editable", True) - tree.set_cursor_on_cell(path, self.categoryColumn, self.categoryCell, True) - else: - self.categoryCell.set_property("editable", False) - + self._get_selected_category() != -1: + self.on_category_edit_clicked(None) self.prev_selected_category = path @@ -387,25 +359,26 @@ def on_activity_remove_clicked(self, button): self.remove_current_activity() def on_activity_edit_clicked(self, button): - self.activityCell.set_property("editable", True) - selection = self.activity_tree.get_selection() (model, iter) = selection.get_selected() - path = model.get_path(iter) - self.activity_tree.set_cursor_on_cell(path, self.activityColumn, self.activityCell, True) + if not iter: + return + current_name = model[iter][1] + activity_id = model[iter][0] + category_id = model[iter][2] + def on_name(name): + runtime.storage.update_activity(activity_id, name, category_id) + model[iter][1] = name + self._show_edit_dialog(_("Edit Activity"), current_name, on_name) """keyboard events""" - def on_activity_list_key_pressed(self, tree, event_key): - key = event_key.keyval + def on_activity_list_key_pressed(self, tree, keyval, keycode, state): selection = tree.get_selection() (model, iter) = selection.get_selected() - if (event_key.keyval == gdk.KEY_Delete): + if keyval == gdk.KEY_Delete: self.remove_current_activity() - - elif key == gdk.KEY_F2 : - self.activityCell.set_property("editable", True) - path = model.get_path(iter) - tree.set_cursor_on_cell(path, self.activityColumn, self.activityCell, True) + elif keyval == gdk.KEY_F2: + self.on_activity_edit_clicked(None) def remove_current_activity(self): selection = self.activity_tree.get_selection() @@ -417,28 +390,30 @@ def on_category_remove_clicked(self, button): self.remove_current_category() def on_category_edit_clicked(self, button): - self.categoryCell.set_property("editable", True) - selection = self.category_tree.get_selection() (model, iter) = selection.get_selected() - path = model.get_path(iter) - self.category_tree.set_cursor_on_cell(path, self.categoryColumn, self.categoryCell, True) - - def on_category_list_key_pressed(self, tree, event_key): - key = event_key.keyval + if not iter: + return + cat_id = model[iter][0] + if cat_id == -1: + return + current_name = model[iter][1] + def on_name(name): + runtime.storage.update_category(cat_id, name) + model[iter][1] = name + self._show_edit_dialog(_("Edit Category"), current_name, on_name) + def on_category_list_key_pressed(self, tree, keyval, keycode, state): if self._get_selected_category() == -1: - return #ignoring unsorted category + return selection = tree.get_selection() (model, iter) = selection.get_selected() - if key == gdk.KEY_Delete: + if keyval == gdk.KEY_Delete: self.remove_current_category() - elif key == gdk.KEY_F2: - self.categoryCell.set_property("editable", True) - path = model.get_path(iter) - tree.set_cursor_on_cell(path, self.categoryColumn, self.categoryCell, True) + elif keyval == gdk.KEY_F2: + self.on_category_edit_clicked(None) def remove_current_category(self): selection = self.category_tree.get_selection() @@ -448,52 +423,92 @@ def remove_current_category(self): runtime.storage.remove_category(id) self._del_selected_row(self.category_tree) - def on_preferences_window_key_press(self, widget, event): - # ctrl+w means close window - if (event.keyval == gdk.KEY_w \ - and event.state & gdk.ModifierType.CONTROL_MASK): - self.close_window() - - # escape can mean several things - if event.keyval == gdk.KEY_Escape: - #check, maybe we are editing stuff - if self.activityCell.get_property("editable"): - self.activityCell.set_property("editable", False) - return - if self.categoryCell.get_property("editable"): - self.categoryCell.set_property("editable", False) - return + def _show_edit_dialog(self, title, current_text, callback): + """Show a simple dialog to edit a name, since GTK 4.22 broke + TreeView inline cell editing (css_node_insert_after assertion).""" + dialog = gtk.Window(title=title) + dialog.set_transient_for(self.window) + dialog.set_modal(True) + dialog.set_default_size(300, -1) + + box = gtk.Box(orientation=gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_start(15) + box.set_margin_end(15) + box.set_margin_top(15) + box.set_margin_bottom(15) + + entry = gtk.Entry() + entry.set_text(current_text) + box.append(entry) + + btn_box = gtk.Box(spacing=5) + btn_box.set_halign(gtk.Align.END) + cancel_btn = gtk.Button(label=_("Cancel")) + ok_btn = gtk.Button(label=_("OK")) + btn_box.append(cancel_btn) + btn_box.append(ok_btn) + box.append(btn_box) + + dialog.set_child(box) + + def on_ok(*args): + text = entry.get_text().strip() + if text: + callback(text) + dialog.destroy() + + ok_btn.connect("clicked", on_ok) + entry.connect("activate", on_ok) + cancel_btn.connect("clicked", lambda b: dialog.destroy()) + + sc = gtk.ShortcutController() + sc.set_scope(gtk.ShortcutScope.LOCAL) + sc.add_shortcut(gtk.Shortcut( + trigger=gtk.ShortcutTrigger.parse_string("Escape"), + action=gtk.CallbackAction.new(lambda w, a: dialog.destroy()), + )) + dialog.add_controller(sc) + + dialog.present() + entry.grab_focus() + + def _on_delete_key(self, widget, args): + if self.activity_tree.has_focus(): + self.remove_current_activity() + elif self.category_tree.has_focus(): + self.remove_current_category() - self.close_window() + def _on_f2_key(self, widget, args): + if self.activity_tree.has_focus(): + self.on_activity_edit_clicked(None) + elif self.category_tree.has_focus(): + self.on_category_edit_clicked(None) """button events""" def on_category_add_clicked(self, button): - """ appends row, jumps to it and allows user to input name """ - - new_category = self.category_store.insert_before(self.category_store.unsorted_category, - [-2, _("New category")]) - - model = self.category_tree.get_model() - - self.categoryCell.set_property("editable", True) - self.category_tree.set_cursor_on_cell(model.get_path(new_category), - focus_column = self.category_tree.get_column(0), - focus_cell = None, - start_editing = True) + def on_name(name): + categories = runtime.storage.get_categories() + for cat in categories: + if cat['name'].lower() == name.lower(): + self.select_category(cat['id']) + return + new_id = runtime.storage.add_category(name) + self.category_store.load() + self.select_category(new_id) + self._show_edit_dialog(_("New Category"), "", on_name) def on_activity_add_clicked(self, button): - """ appends row, jumps to it and allows user to input name """ category_id = self._get_selected_category() - - new_activity = self.activity_store.append([-1, _("New activity"), category_id]) - - (model, iter) = self.selection.get_selected() - - self.activityCell.set_property("editable", True) - self.activity_tree.set_cursor_on_cell(model.get_path(new_activity), - focus_column = self.activity_tree.get_column(0), - focus_cell = None, - start_editing = True) + def on_name(name): + activities = runtime.storage.get_category_activities(category_id) + for act in activities: + if act['name'].lower() == name.lower(): + self.select_activity(act['id']) + return + new_id = runtime.storage.add_activity(name, category_id) + self.activity_store.load(category_id) + self.select_activity(new_id) + self._show_edit_dialog(_("New Activity"), "", on_name) def on_activity_remove_clicked(self, button): removable_id = self._del_selected_row(self.activity_tree) diff --git a/src/hamster/widgets/__init__.py b/src/hamster/widgets/__init__.py index 47108e149..49f3f18d9 100644 --- a/src/hamster/widgets/__init__.py +++ b/src/hamster/widgets/__init__.py @@ -38,50 +38,4 @@ # handy wrappers def add_hint(entry, hint): - entry.hint = hint - - def override_get_text(self): - #override get text so it does not return true when hint is in! - if self.real_get_text() == self.hint: - return "" - else: - return self.real_get_text() - - def _set_hint(self, widget, event): - if self.get_text(): # do not mess with user entered text - return - - self.modify_text(gtk.StateType.NORMAL, gdk.Color.parse("gray")[1]) - hint_font = pango.FontDescription(self.get_style().font_desc.to_string()) - hint_font.set_style(pango.Style.ITALIC) - self.modify_font(hint_font) - - self.set_text(self.hint) - - def _set_normal(self, widget, event): - #self.modify_text(gtk.StateType.NORMAL, self.get_style().fg[gtk.StateType.NORMAL]) - hint_font = pango.FontDescription(self.get_style().font_desc.to_string()) - hint_font.set_style(pango.Style.NORMAL) - self.modify_font(hint_font) - - if self.real_get_text() == self.hint: - self.set_text("") - - def _on_changed(self, widget): - if self.real_get_text() == "" and self.is_focus() == False: - self._set_hint(widget, None) - - import types - instancemethod = types.MethodType - - entry._set_hint = instancemethod(_set_hint, entry, gtk.Entry) - entry._set_normal = instancemethod(_set_normal, entry, gtk.Entry) - entry._on_changed = instancemethod(_on_changed, entry, gtk.Entry) - entry.real_get_text = entry.get_text - entry.get_text = instancemethod(override_get_text, entry, gtk.Entry) - - entry.connect('focus-in-event', entry._set_normal) - entry.connect('focus-out-event', entry._set_hint) - entry.connect('changed', entry._on_changed) - - entry._set_hint(entry, None) + entry.set_placeholder_text(hint) diff --git a/src/hamster/widgets/activityentry.py b/src/hamster/widgets/activityentry.py index df68c047f..80ba59456 100644 --- a/src/hamster/widgets/activityentry.py +++ b/src/hamster/widgets/activityentry.py @@ -98,7 +98,7 @@ class CompleteTree(graphics.Scene): def __init__(self): - graphics.Scene.__init__(self, style_class=gtk.STYLE_CLASS_VIEW) + graphics.Scene.__init__(self, style_class="view") self.set_can_focus(False) @@ -107,7 +107,7 @@ def __init__(self): self.current_row = None self.rows = [] - self.style = self._style + self.style = self.get_style_context() self.label = Label(x=5, y=3) self.row_height = self.label.height + 10 @@ -167,11 +167,13 @@ def on_enter_frame(self, scene, context): return colors = { - "normal": self.style.get_color(gtk.StateFlags.NORMAL), - "normal_bg": self.style.get_background_color(gtk.StateFlags.NORMAL), - "selected": self.style.get_color(gtk.StateFlags.SELECTED), - "selected_bg": self.style.get_background_color(gtk.StateFlags.SELECTED), + "normal": self.style.get_color(), + "selected": self.style.get_color(), } + success, c = self.style.lookup_color("theme_bg_color") + colors["normal_bg"] = c if success else gdk.RGBA(1, 1, 1, 1) + success, c = self.style.lookup_color("theme_selected_bg_color") + colors["selected_bg"] = c if success else gdk.RGBA(0.2, 0.4, 0.8, 1) g = graphics.Graphics(context) g.set_line_style(1) @@ -203,8 +205,10 @@ def on_enter_frame(self, scene, context): class CmdLineEntry(gtk.Entry): - def __init__(self, *, parent, **kwargs): - gtk.Entry.__init__(self, parent=parent, **kwargs) + def __init__(self, *, parent=None, **kwargs): + gtk.Entry.__init__(self, **kwargs) + if parent: + parent.append(self) # default day for times without date self.default_day = None @@ -212,19 +216,17 @@ def __init__(self, *, parent, **kwargs): # to be set by the caller, if editing an existing fact self.original_fact = None - self.popup = gtk.Window(type = gtk.WindowType.POPUP) - self.popup.set_type_hint(gdk.WindowTypeHint.COMBO) # why not - self.popup.set_attached_to(self) # attributes - self.popup.set_transient_for(self.get_ancestor(gtk.Window)) # position + self.popup = gtk.Popover() + self.popup.set_autohide(False) + self.popup.set_parent(self) box = gtk.Frame() - box.set_shadow_type(gtk.ShadowType.IN) - self.popup.add(box) + self.popup.set_child(box) self.complete_tree = CompleteTree() self.tree_checker = self.complete_tree.connect("on-select-row", self.on_tree_select_row) self.complete_tree.connect("on-click", self.on_tree_click) - box.add(self.complete_tree) + box.set_child(self.complete_tree) self.storage = client.Storage() self.load_suggestions() @@ -233,8 +235,17 @@ def __init__(self, *, parent, **kwargs): self.set_icon_from_icon_name(gtk.EntryIconPosition.SECONDARY, "go-down-symbolic") self.checker = self.connect("changed", self.on_changed) - self.connect("key-press-event", self.on_key_press) - self.connect("focus-out-event", self.on_focus_out) + + key_ctrl = gtk.EventControllerKey() + key_ctrl.set_propagation_phase(gtk.PropagationPhase.CAPTURE) + key_ctrl.connect("key-pressed", self._on_key_pressed_gtk4) + self.add_controller(key_ctrl) + + # Replace focus-out-event with EventControllerFocus + focus_ctrl = gtk.EventControllerFocus() + focus_ctrl.connect("leave", self._on_focus_out_gtk4) + self.add_controller(focus_ctrl) + self.connect("icon-press", self.on_icon_press) @@ -258,12 +269,17 @@ def complete(): self.select_region(len(text), -1) gobject.timeout_add(0, complete) - def on_focus_out(self, entry, event): - self.popup.hide() + def _on_key_pressed_gtk4(self, controller, keyval, keycode, state): + from hamster.lib.graphics import SceneEvent + event = SceneEvent(keyval=keyval, keycode=keycode, state=state) + return self.on_key_press(self, event) + + def _on_focus_out_gtk4(self, controller): + self.popup.popdown() - def on_icon_press(self, entry, icon, event): + def on_icon_press(self, entry, icon): if self.popup.get_visible(): - self.popup.hide() + self.popup.popdown() else: self.grab_focus() self.show_suggestions(self.get_text()) @@ -273,7 +289,7 @@ def on_key_press(self, entry, event=None): self.ignore_stroke = True elif event.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter, gdk.KEY_Escape): - self.popup.hide() + self.popup.popdown() self.set_position(-1) elif event.keyval in (gdk.KEY_Up, gdk.KEY_Down): @@ -283,7 +299,7 @@ def on_key_press(self, entry, event=None): return True def on_tree_click(self, entry, tree, event): - self.popup.hide() + self.popup.popdown() def on_tree_select_row(self, tree, row): with self.handler_block(self.checker): @@ -378,7 +394,7 @@ def update_suggestions(self, text=""): break - fragments = [f for f in re.split("[\s|#]", text)] + fragments = [f for f in re.split(r"[\s|#]", text)] current_fragment = fragments[-1] if fragments else "" @@ -453,209 +469,145 @@ def update_suggestions(self, text=""): def show_suggestions(self, text): - if not self.get_window(): + if not self.get_native(): return - entry_alloc = self.get_allocation() - entry_x, entry_y = self.get_window().get_origin()[1:] - x, y = entry_x + entry_alloc.x, entry_y + entry_alloc.y + entry_alloc.height - - self.popup.show_all() - self.update_suggestions(text) + entry_alloc = self.get_allocation() tree_w, tree_h = self.complete_tree.get_size_request() + # Set minimum size for popover content + self.complete_tree.set_size_request(entry_alloc.width, tree_h) - self.popup.move(x, y) - self.popup.resize(entry_alloc.width, tree_h) - self.popup.show_all() - + self.popup.popup() -class ActivityEntry(): - """Activity entry widget. - widget (gtk.Entry): the associated activity entry - category_widget (gtk.Entry): the associated category entry - """ - def __init__(self, widget=None, category_widget=None, **kwds): - # widget and completion may be defined already - # e.g. in the glade edit_activity.ui file - self.widget = widget - if not self.widget: - self.widget = gtk.Entry(**kwds) +class _AutocompleteEntry(): + """Entry with Popover-based autocomplete (replaces GTK3 EntryCompletion).""" - self.category_widget = category_widget - - # internal list of actions added to the suggestions - self._action_list = [] - self.completion = self.widget.get_completion() - if not self.completion: - self.completion = gtk.EntryCompletion() - self.widget.set_completion(self.completion) - - # text to display/filter on, activity, category - self.text_column = 0 - self.activity_column = 1 - self.category_column = 2 - - # whether the category choice limit the activity suggestions - self.filter_on_category = True if self.category_widget else False - self.model = gtk.ListStore(str, str, str) - self.completion.set_model(self.model) - self.completion.set_text_column(self.text_column) - self.completion.set_match_func(self.match_func, None) - # enable selection with up and down arrow - self.completion.set_inline_selection(True) - # It is not possible to change actions later dynamically; - # once actions are removed, - # they can not be added back (they are not visible). - # => nevermind, showing all actions. - self.add_action("show all", "Show all activities") - self.add_action("filter on category", "Filter on selected category") - - self.connect("icon-release", self.on_icon_release) - self.connect("focus-in-event", self.on_focus_in_event) - self.completion.connect('match-selected', self.on_match_selected) - self.completion.connect("action_activated", self.on_action_activated) - - def add_action(self, name, text): - """Add an action to the suggestions. - - name (str): unique label, use to retrieve the action index. - text (str): text used to display the action. - """ - markup = "{}".format(stuff.escape_pango(text)) - idx = len(self._action_list) - self.completion.insert_action_markup(idx, markup) - self._action_list.append(name) - - def clear(self, notify=True): - self.widget.set_text("") - if notify: - self.emit("changed") - - def match_func(self, completion, key, iter, *user_data): - if not key.strip(): - # show all keys if entry is empty - return True - else: - # return whether the entered string is - # anywhere in the first column data - stripped_key = key.strip() - activities = self.model.get_value(iter, self.activity_column).lower() - categories = self.model.get_value(iter, self.category_column).lower() - key_in_activity = stripped_key in activities - key_in_category = stripped_key in categories - return key_in_activity or key_in_category - - def on_action_activated(self, completion, index): - name = self._action_list[index] - if name == "clear": - self.clear(notify=False) - elif name == "show all": - self.filter_on_category = False - self.populate_completions() - elif name == "filter on category": - self.filter_on_category = True - self.populate_completions() - - def on_focus_in_event(self, widget, event): - self.populate_completions() - - def on_icon_release(self, entry, icon_pos, event): - self.grab_focus() - self.set_text("") - self.emit("changed") - - def on_match_selected(self, entry, model, iter): - activity_name = model[iter][self.activity_column] - category_name = model[iter][self.category_column] - combined = model[iter][self.text_column] - if self.category_widget: - self.set_text(activity_name) - if not self.filter_on_category: - self.category_widget.set_text(category_name) + def __init__(self, widget=None, **kwds): + self.widget = widget or gtk.Entry(**kwds) + self._storage = client.Storage() + + self._popup = gtk.Popover() + self._popup.set_autohide(False) + self._popup.set_parent(self.widget) + sw = gtk.ScrolledWindow() + sw.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.AUTOMATIC) + sw.set_max_content_height(200) + sw.set_propagate_natural_height(True) + self._listbox = gtk.ListBox() + self._listbox.set_selection_mode(gtk.SelectionMode.SINGLE) + self._listbox.connect("row-activated", self._on_row_activated) + sw.set_child(self._listbox) + self._popup.set_child(sw) + + self.widget.set_icon_from_icon_name( + gtk.EntryIconPosition.SECONDARY, "pan-down-symbolic") + self._changed_handler = self.widget.connect("changed", self._on_changed) + self.widget.connect("icon-press", self._on_icon_press) + + key_ctrl = gtk.EventControllerKey() + key_ctrl.connect("key-pressed", self._on_key_press) + self.widget.add_controller(key_ctrl) + + focus_ctrl = gtk.EventControllerFocus() + focus_ctrl.connect("leave", lambda c: self._popup.popdown()) + self.widget.add_controller(focus_ctrl) + + def _get_suggestions(self): + return [] + + def _suggestion_label(self, item): + return str(item) + + def _on_select(self, text): + with self.widget.handler_block(self._changed_handler): + self.widget.set_text(text) + self.widget.set_position(-1) + self.widget.emit("changed") + + def _update_listbox(self, text): + while child := self._listbox.get_first_child(): + self._listbox.remove(child) + text_lower = text.lower().strip() + for item in self._get_suggestions(): + label = self._suggestion_label(item) + if text_lower and text_lower not in label.lower(): + continue + row = gtk.ListBoxRow() + row._value = label + row.set_child(gtk.Label(label=label, xalign=0)) + self._listbox.append(row) + + def _show_suggestions(self): + self._update_listbox(self.widget.get_text()) + if self._listbox.get_first_child(): + entry_alloc = self.widget.get_allocation() + self._listbox.set_size_request(entry_alloc.width, -1) + self._popup.popup() else: - self.set_text(combined) - return True # prevent the standard callback from overwriting text + self._popup.popdown() + + def _on_changed(self, entry): + if self.widget.has_focus(): + self._show_suggestions() + + def _on_icon_press(self, entry, icon): + if icon == gtk.EntryIconPosition.SECONDARY: + if self._popup.get_visible(): + self._popup.popdown() + else: + self.widget.grab_focus() + self._show_suggestions() + elif icon == gtk.EntryIconPosition.PRIMARY: + self.widget.grab_focus() + with self.widget.handler_block(self._changed_handler): + self.widget.set_text("") + self.widget.emit("changed") + + def _on_key_press(self, controller, keyval, keycode, state): + if keyval in (gdk.KEY_Escape, gdk.KEY_Return, gdk.KEY_KP_Enter): + self._popup.popdown() + return False + if keyval in (gdk.KEY_Up, gdk.KEY_Down) and self._popup.get_visible(): + row = self._listbox.get_selected_row() + if keyval == gdk.KEY_Down: + next_row = row.get_next_sibling() if row else self._listbox.get_first_child() + else: + next_row = row.get_prev_sibling() if row else None + if next_row: + self._listbox.select_row(next_row) + self._on_select(next_row._value) + return True + return False - def populate_completions(self): - self.model.clear() - if self.filter_on_category: - category_names = [self.category_widget.get_text()] - else: - category_names = [category['name'] - for category in runtime.storage.get_categories()] - for category_name in category_names: - category_id = runtime.storage.get_category_id(category_name) - activities = runtime.storage.get_category_activities(category_id) - for activity in activities: - activity_name = activity["name"] - text = "{}@{}".format(activity_name, category_name) - self.model.append([text, activity_name, category_name]) + def _on_row_activated(self, listbox, row): + self._on_select(row._value) + self._popup.popdown() def __getattr__(self, name): return getattr(self.widget, name) -class CategoryEntry(): - """Category entry widget. +class ActivityEntry(_AutocompleteEntry): + def __init__(self, widget=None, category_widget=None, **kwds): + super().__init__(widget=widget, **kwds) + self.category_widget = category_widget - widget (gtk.Entry): the associated category entry - """ - def __init__(self, widget=None, **kwds): - # widget and completion are already defined - # e.g. in the glade edit_activity.ui file - self.widget = widget - if not self.widget: - self.widget = gtk.Entry(**kwds) - - self.completion = self.widget.get_completion() - if not self.completion: - self.completion = gtk.EntryCompletion() - self.widget.set_completion(self.completion) - self.completion.insert_action_markup(0, "Clear ({})".format(_("Unsorted"))) - self.unsorted_action_index = 0 - - self.model = gtk.ListStore(str) - self.completion.set_model(self.model) - self.completion.set_text_column(0) - self.completion.set_match_func(self.match_func, None) - - self.widget.connect("icon-release", self.on_icon_release) - self.widget.connect("focus-in-event", self.on_focus_in_event) - self.completion.connect("action_activated", self.on_action_activated) - - def clear(self, notify=True): - self.widget.set_text("") - if notify: - self.emit("changed") - - def match_func(self, completion, key, iter, *user_data): - if not key.strip(): - # show all keys if entry is empty - return True - else: - # return whether the entered string is - # anywhere in the first column data - return key.strip() in self.model.get_value(iter, 0).lower() + def _get_suggestions(self): + return self._storage.get_activities() - def on_action_activated(self, completion, index): - if index == self.unsorted_action_index: - self.clear(notify=False) + def _suggestion_label(self, item): + return item['name'] - def on_focus_in_event(self, widget, event): - self.populate_completions() - def on_icon_release(self, entry, icon_pos, event): - self.widget.grab_focus() - # do not emit changed on the primary (clear) button - self.clear() +class CategoryEntry(_AutocompleteEntry): + def __init__(self, widget=None, **kwds): + super().__init__(widget=widget, **kwds) - def populate_completions(self): - self.model.clear() - for category in runtime.storage.get_categories(): - self.model.append([category['name']]) + def _get_suggestions(self): + return self._storage.get_categories() - def __getattr__(self, name): - return getattr(self.widget, name) + def _suggestion_label(self, item): + return item['name'] diff --git a/src/hamster/widgets/dates.py b/src/hamster/widgets/dates.py index 6d3f63711..7d502ddc8 100644 --- a/src/hamster/widgets/dates.py +++ b/src/hamster/widgets/dates.py @@ -46,27 +46,19 @@ def __init__(self, widget, expander=None): @property def date(self): """Selected day, as datetime.date.""" - year, month, day = self.widget.get_date() - # months start at 0 in Gtk.Calendar and at 1 in python date - month += 1 - return dt.date(year=year, month=month, day=day) if day else None + gdate = self.widget.get_date() + return dt.date(year=gdate.get_year(), + month=gdate.get_month(), + day=gdate.get_day_of_month()) @date.setter def date(self, value): - """Set date. - - value can be a python date or datetime. - """ if value is None: - # unselect day - self.widget.select_day(0) - else: - year = value.year - # months start at 0 in Gtk.Calendar and at 1 in python date - month = value.month - 1 - day = value.day - self.widget.select_month(month, year) - self.widget.select_day(day) + return + from gi.repository import GLib + gdate = GLib.DateTime.new_local(value.year, value.month, value.day, + 0, 0, 0) + self.widget.select_day(gdate) def on_date_changed(self, widget): if self.expander: @@ -98,17 +90,22 @@ def __init__(self, today): self.today = today - hbox = gtk.HBox() + hbox = gtk.Box(orientation=gtk.Orientation.HORIZONTAL) hbox.set_spacing(3) self.label = gtk.Label() - hbox.add(self.label) - hbox.add(gtk.Arrow(gtk.ArrowType.DOWN, gtk.ShadowType.ETCHED_IN)) - self.add(hbox) + hbox.append(self.label) + hbox.append(gtk.Image.new_from_icon_name("pan-down-symbolic")) + self.set_child(hbox) self.start_date, self.end_date = None, None self.current_range = None - self._ui.connect_signals(self) + self.get_widget("day").connect("clicked", self.on_day_clicked) + self.get_widget("week").connect("clicked", self.on_week_clicked) + self.get_widget("month").connect("clicked", self.on_month_clicked) + self.get_widget("start_calendar").connect("day-selected", self.on_manual_range_apply_clicked) + self.get_widget("end_calendar").connect("day-selected", self.on_manual_range_apply_clicked) + self.get_widget("manual_range_apply").connect("clicked", self.on_manual_range_apply_clicked) def set_range(self, start_date, end_date=None): end_date = end_date or start_date @@ -174,13 +171,14 @@ def on_show(self, user_data): self.get_widget("week_preview").set_text(stuff.format_range(*stuff.week(self.today))) self.get_widget("month_preview").set_text(stuff.format_range(*stuff.month(self.today))) + from gi.repository import GLib start_cal = self.get_widget("start_calendar") - start_cal.select_month(self.start_date.month - 1, self.start_date.year) - start_cal.select_day(self.start_date.day) + start_cal.select_day(GLib.DateTime.new_local( + self.start_date.year, self.start_date.month, self.start_date.day, 0, 0, 0)) end_cal = self.get_widget("end_calendar") - end_cal.select_month(self.end_date.month - 1, self.end_date.year) - end_cal.select_day(self.end_date.day) + end_cal.select_day(GLib.DateTime.new_local( + self.end_date.year, self.end_date.month, self.end_date.day, 0, 0, 0)) self.get_widget("day").grab_focus() @@ -201,12 +199,11 @@ def on_month_clicked(self, button): def on_manual_range_apply_clicked(self, button): self.current_range = "manual" - # GtkCalendar January is 0, hence the + 1 - year, month, day = self.get_widget("start_calendar").get_date() - self.start_date = dt.date(year, month + 1, day) + gdate = self.get_widget("start_calendar").get_date() + self.start_date = dt.date(gdate.get_year(), gdate.get_month(), gdate.get_day_of_month()) - year, month, day = self.get_widget("end_calendar").get_date() - self.end_date = dt.date(year, month + 1, day) + gdate = self.get_widget("end_calendar").get_date() + self.end_date = dt.date(gdate.get_year(), gdate.get_month(), gdate.get_day_of_month()) # make sure we always have a valid range if self.end_date < self.start_date: diff --git a/src/hamster/widgets/dayline.py b/src/hamster/widgets/dayline.py index d08bb1066..42e062c9c 100644 --- a/src/hamster/widgets/dayline.py +++ b/src/hamster/widgets/dayline.py @@ -19,6 +19,7 @@ import time +from gi.repository import Gdk as gdk from gi.repository import Gtk as gtk from gi.repository import GObject as gobject from gi.repository import PangoCairo as pangocairo @@ -88,7 +89,9 @@ def on_render(self, sprite): class DayLine(graphics.Scene): def __init__(self, start_time = None): graphics.Scene.__init__(self) - self.set_can_focus(False) # no interaction + self.set_hexpand(True) + self.set_vexpand(True) + self.set_can_focus(False) self.day_start = conf.day_start @@ -112,7 +115,7 @@ def __init__(self, start_time = None): self.drag_start = None self.current_x = None - self.date_label = graphics.Label(color=self._style.get_color(gtk.StateFlags.NORMAL), + self.date_label = graphics.Label(color=self.get_color(), x=5, y=16) self.add_child(self.plot_area, self.date_label) @@ -155,6 +158,8 @@ def on_enter_frame(self, scene, context): self.plot_area.height = self.height - 30 + if not self.width: + return vertical = min(self.plot_area.height / 5, 7) minute_pixel = (self.scope_hours * 60.0 - 15) / self.width @@ -162,12 +167,15 @@ def on_enter_frame(self, scene, context): g.translate(0.5, 0.5) + style = self.get_style_context() colors = { - "normal": self._style.get_color(gtk.StateFlags.NORMAL), - "normal_bg": self._style.get_background_color(gtk.StateFlags.NORMAL), - "selected": self._style.get_color(gtk.StateFlags.SELECTED), - "selected_bg": self._style.get_background_color(gtk.StateFlags.SELECTED), + "normal": self.get_color(), + "selected": self.get_color(), } + success, c = style.lookup_color("theme_bg_color") + colors["normal_bg"] = c if success else gdk.RGBA(1, 1, 1, 1) + success, c = style.lookup_color("theme_selected_bg_color") + colors["selected_bg"] = c if success else gdk.RGBA(0.2, 0.4, 0.8, 1) bottom = self.plot_area.y + self.plot_area.height diff --git a/src/hamster/widgets/facttree.py b/src/hamster/widgets/facttree.py index ee98ca71b..13872e986 100644 --- a/src/hamster/widgets/facttree.py +++ b/src/hamster/widgets/facttree.py @@ -40,7 +40,7 @@ def __init__(self): self.restart = graphics.Icon("view-refresh-symbolic", size=18, interactive=True, - mouse_cursor=gdk.CursorType.HAND1, + mouse_cursor=gdk.Cursor.new_from_name("pointer"), y=4) self.add_child(self.restart) @@ -298,7 +298,7 @@ class FactTree(graphics.Scene, gtk.Scrollable): vscroll_policy = gobject.property(type=gtk.ScrollablePolicy, default=gtk.ScrollablePolicy.MINIMUM) def __init__(self): - graphics.Scene.__init__(self, style_class=gtk.STYLE_CLASS_VIEW) + graphics.Scene.__init__(self, style_class="view") self.date_label = Label(10, 3) fontdesc = pango.FontDescription(graphics._font_desc) @@ -322,7 +322,7 @@ def __init__(self): self.hover_fact = None self.current_fact = None - self.style = self._style + self.style = self.get_style_context() self.visible_range = None self.set_size_request(500, 400) @@ -603,21 +603,25 @@ def on_scroll(self, scene=None, event=None): self.visible_range = self.get_visible_range() def on_enter_frame(self, scene, context): - has_focus = self.get_toplevel().has_toplevel_focus() + has_focus = self.get_root().is_active() if self.get_root() else True if has_focus: colors = { - "normal": self.style.get_color(gtk.StateFlags.NORMAL), - "normal_bg": self.style.get_background_color(gtk.StateFlags.NORMAL), - "selected": self.style.get_color(gtk.StateFlags.SELECTED), - "selected_bg": self.style.get_background_color(gtk.StateFlags.SELECTED), + "normal": self.style.get_color(), + "selected": self.style.get_color(), } + success, c = self.style.lookup_color("theme_bg_color") + colors["normal_bg"] = c if success else gdk.RGBA(1, 1, 1, 1) + success, c = self.style.lookup_color("theme_selected_bg_color") + colors["selected_bg"] = c if success else gdk.RGBA(0.2, 0.4, 0.8, 1) else: colors = { - "normal": self.style.get_color(gtk.StateFlags.BACKDROP), - "normal_bg": self.style.get_background_color(gtk.StateFlags.BACKDROP), - "selected": self.style.get_color(gtk.StateFlags.BACKDROP), - "selected_bg": self.style.get_background_color(gtk.StateFlags.BACKDROP), + "normal": self.style.get_color(), + "selected": self.style.get_color(), } + success, c = self.style.lookup_color("theme_bg_color") + colors["normal_bg"] = c if success else gdk.RGBA(1, 1, 1, 1) + success, c = self.style.lookup_color("theme_selected_bg_color") + colors["selected_bg"] = c if success else gdk.RGBA(0.2, 0.4, 0.8, 1) if not self.height: return diff --git a/src/hamster/widgets/reportchooserdialog.py b/src/hamster/widgets/reportchooserdialog.py index 0db0e981b..646787be5 100644 --- a/src/hamster/widgets/reportchooserdialog.py +++ b/src/hamster/widgets/reportchooserdialog.py @@ -21,119 +21,103 @@ import os from gi.repository import GObject as gobject from gi.repository import Gtk as gtk +from gi.repository import Gio as gio from hamster.lib.configuration import conf -class ReportChooserDialog(gtk.Dialog): + +class ReportChooserDialog(gobject.GObject): __gsignals__ = { - # format, path, start_date, end_date + # format, path 'report-chosen': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_STRING, gobject.TYPE_STRING)), 'report-chooser-closed': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } + def __init__(self): - gtk.Dialog.__init__(self) + gobject.GObject.__init__(self) + self._dialog = None + self._filters = {} + def show(self, start_date, end_date): + """Create and show the file save dialog with a suggested filename. - self.dialog = gtk.FileChooserDialog(title = _("Save Report — Time Tracker"), - parent = self, - action = gtk.FileChooserAction.SAVE, - buttons=(gtk.STOCK_CANCEL, - gtk.ResponseType.CANCEL, - gtk.STOCK_SAVE, - gtk.ResponseType.OK)) + Setting suggested name to something readable, replace backslashes + with dots so the name is valid in linux. + """ + dialog = gtk.FileDialog() + dialog.set_title(_("Save Report — Time Tracker")) - # try to set path to last known folder or fall back to home + # Try to set path to last known folder or fall back to home report_folder = os.path.expanduser(conf.get("last-report-folder")) if os.path.exists(report_folder): - self.dialog.set_current_folder(report_folder) + dialog.set_initial_folder(gio.File.new_for_path(report_folder)) else: - self.dialog.set_current_folder(os.path.expanduser("~")) - - self.filters = {} - - filter = gtk.FileFilter() - filter.set_name(_("HTML Report")) - filter.add_mime_type("text/html") - filter.add_pattern("*.html") - filter.add_pattern("*.htm") - self.filters[filter] = "html" - self.dialog.add_filter(filter) - - filter = gtk.FileFilter() - filter.set_name(_("Tab-Separated Values (TSV)")) - filter.add_mime_type("text/plain") - filter.add_pattern("*.tsv") - filter.add_pattern("*.txt") - self.filters[filter] = "tsv" - self.dialog.add_filter(filter) - - filter = gtk.FileFilter() - filter.set_name(_("XML")) - filter.add_mime_type("text/xml") - filter.add_pattern("*.xml") - self.filters[filter] = "xml" - self.dialog.add_filter(filter) - - filter = gtk.FileFilter() - filter.set_name(_("iCal")) - filter.add_mime_type("text/calendar") - filter.add_pattern("*.ics") - self.filters[filter] = "ical" - self.dialog.add_filter(filter) - - filter = gtk.FileFilter() - filter.set_name("All files") - filter.add_pattern("*") - self.dialog.add_filter(filter) - + dialog.set_initial_folder(gio.File.new_for_path(os.path.expanduser("~"))) - def show(self, start_date, end_date): - """setting suggested name to something readable, replace backslashes - with dots so the name is valid in linux""" - - # title in the report file name - vars = {"title": _("Time track"), - "start": start_date.strftime("%x").replace("/", "."), - "end": end_date.strftime("%x").replace("/", ".")} + # Set suggested filename + start = start_date.strftime("%Y-%m-%d") if start_date != end_date: - filename = "%(title)s, %(start)s - %(end)s.html" % vars + end = end_date.strftime("%Y-%m-%d") + filename = "Time track {} - {}.html".format(start, end) else: - filename = "%(title)s, %(start)s.html" % vars - - self.dialog.set_current_name(filename) - - response = self.dialog.run() - - if response != gtk.ResponseType.OK: + filename = "Time track {}.html".format(start) + + dialog.set_initial_name(filename) + + # Create filters + filter_list = gio.ListStore.new(gtk.FileFilter) + + filters = {} + for name, mime, patterns, key in [ + (_("HTML Report"), "text/html", ["*.html", "*.htm"], "html"), + (_("Tab-Separated Values (TSV)"), "text/plain", ["*.tsv", "*.txt"], "tsv"), + (_("XML"), "text/xml", ["*.xml"], "xml"), + (_("iCal"), "text/calendar", ["*.ics"], "ical"), + ]: + f = gtk.FileFilter() + f.set_name(name) + f.add_mime_type(mime) + for p in patterns: + f.add_pattern(p) + filters[f] = key + filter_list.append(f) + + all_filter = gtk.FileFilter() + all_filter.set_name("All files") + all_filter.add_pattern("*") + filter_list.append(all_filter) + + dialog.set_filters(filter_list) + self._filters = filters + self._dialog = dialog + + # Save async + dialog.save(None, None, self._on_save_response) + + def _on_save_response(self, dialog, result): + """Handle the async response from the file save dialog.""" + try: + file = dialog.save_finish(result) + except Exception: + # User cancelled or error occurred self.emit("report-chooser-closed") - self.dialog.destroy() - self.dialog = None - else: - self.on_save_button_clicked() + return + path = file.get_path() - def present(self): - self.dialog.present() - - def on_save_button_clicked(self): - path, format = None, None - + # Determine format from the selected filter format = "html" - if self.dialog.get_filter() in self.filters: - format = self.filters[self.dialog.get_filter()] - path = self.dialog.get_filename() + current_filter = self._dialog.get_default_filter() + if current_filter in self._filters: + format = self._filters[current_filter] - # append correct extension if it is missing + # Append correct extension if it is missing # TODO - proper way would be to change extension on filter change # only pointer in web is http://www.mail-archive.com/pygtk@daa.com.au/msg08740.html - if path.endswith(".%s" % format) == False: + if not path.endswith(".%s" % format): path = "%s.%s" % (path.rstrip("."), format) - categories = [] - conf.set("last-report-folder", os.path.dirname(path)) - # format, path, start_date, end_date + # format, path self.emit("report-chosen", format, path) - self.dialog.destroy() - self.dialog = None diff --git a/src/hamster/widgets/tags.py b/src/hamster/widgets/tags.py index 82710d892..93d603b27 100644 --- a/src/hamster/widgets/tags.py +++ b/src/hamster/widgets/tags.py @@ -32,38 +32,43 @@ class TagsEntry(gtk.Entry): 'tags-selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } - def __init__(self, *, parent): - gtk.Entry.__init__(self, parent=parent) + def __init__(self, *, parent=None): + gtk.Entry.__init__(self) + if parent: + parent.append(self) self.ac_tags = None # "autocomplete" tags self.filter = None # currently applied filter string self.filter_tags = [] #filtered tags - self.popup = gtk.Window(type = gtk.WindowType.POPUP) - self.popup.set_attached_to(self) - self.popup.set_transient_for(self.get_ancestor(gtk.Window)) + self.popup = gtk.Popover() + self.popup.set_parent(self) self.scroll_box = gtk.ScrolledWindow() - self.scroll_box.set_shadow_type(gtk.ShadowType.IN) self.scroll_box.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.AUTOMATIC) viewport = gtk.Viewport() - viewport.set_shadow_type(gtk.ShadowType.NONE) self.tag_box = TagBox() self.tag_box.connect("tag-selected", self.on_tag_selected) self.tag_box.connect("tag-unselected", self.on_tag_unselected) - viewport.add(self.tag_box) - self.scroll_box.add(viewport) - self.popup.add(self.scroll_box) + viewport.set_child(self.tag_box) + self.scroll_box.set_child(viewport) + self.popup.set_child(self.scroll_box) self.set_icon_from_icon_name(gtk.EntryIconPosition.SECONDARY, "go-down-symbolic") self.connect("icon-press", self._on_icon_press) - self.connect("key-press-event", self._on_key_press_event) - self.connect("focus-out-event", self._on_focus_out_event) - self._parent_click_watcher = None # bit lame but works + # Replace key-press-event with EventControllerKey + key_ctrl = gtk.EventControllerKey() + key_ctrl.connect("key-pressed", self._on_key_press_gtk4) + self.add_controller(key_ctrl) + + # Replace focus-out-event with EventControllerFocus + focus_ctrl = gtk.EventControllerFocus() + focus_ctrl.connect("leave", self._on_focus_out_gtk4) + self.add_controller(focus_ctrl) self.external_listeners = [ (runtime.storage, runtime.storage.connect('tags-changed', self.refresh_ac_tags)) @@ -115,31 +120,24 @@ def on_tag_unselected(self, tag_box, tag): self.update_tagsline(add=True) def hide_popup(self): - self.popup.hide() - if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher): - self.get_toplevel().disconnect(self._parent_click_watcher) - self._parent_click_watcher = None + # GTK4: Popover handles click-outside dismissal automatically + self.popup.popdown() def show_popup(self): if not self.filter_tags: - self.popup.hide() + self.popup.popdown() return - if not self._parent_click_watcher: - self._parent_click_watcher = self.get_toplevel().connect("button-press-event", self._on_focus_out_event) + # GTK4: Popover handles click-outside dismissal automatically alloc = self.get_allocation() - _, x, y = self.get_parent_window().get_origin() - - self.popup.move(x + alloc.x,y + alloc.y + alloc.height) - - w = alloc.width - + w = max(alloc.width, 300) height = self.tag_box.count_height(w) - self.scroll_box.set_size_request(w, height) - self.popup.resize(w, height) - self.popup.show_all() + self.scroll_box.set_size_request(w, min(height, 300)) + + # GTK4: Popover auto-positions, no need for manual move/resize + self.popup.popup() def refresh_activities(self): # scratch activities and categories so that they get repopulated on demand @@ -162,12 +160,10 @@ def populate_suggestions(self): self.tag_box.draw(self.filter_tags) - - - def _on_focus_out_event(self, widget, event): + def _on_focus_out_gtk4(self, controller): self.hide_popup() - def _on_icon_press(self, entry, icon_pos, event): + def _on_icon_press(self, entry, icon_pos): # otherwise Esc could not hide popup self.grab_focus() # toggle popup @@ -222,9 +218,17 @@ def update_tagsline(self, add=False): self.set_text(text) self.set_position(len(self.get_text())) + def _on_key_press_gtk4(self, controller, keyval, keycode, state): + # Create event-like object for compatibility + class Event: + pass + event = Event() + event.keyval = keyval + return self._on_key_press_event(self, event) + def _on_key_press_event(self, entry, event): if event.keyval == gdk.KEY_Tab: - if self.popup.get_property("visible"): + if self.popup.get_visible(): #we have to replace if self.get_text() and self.get_cursor_tag() != self.filter_tags[0]: self.replace_tag(self.get_cursor_tag(), self.filter_tags[0]) @@ -235,7 +239,7 @@ def _on_key_press_event(self, entry, event): return False elif event.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter): - if self.popup.get_property("visible"): + if self.popup.get_visible(): if self.get_text(): self.hide_popup() return True @@ -245,7 +249,7 @@ def _on_key_press_event(self, entry, event): return False elif event.keyval == gdk.KEY_Escape: - if self.popup.get_property("visible"): + if self.popup.get_visible(): self.hide_popup() return True else: @@ -344,9 +348,7 @@ class Tag(graphics.Sprite): def __init__(self, text, interactive = True, color = "#F1EAAA"): graphics.Sprite.__init__(self, interactive = interactive) - self.width, self.height = 0,0 - - font = gtk.Style().font_desc + font = pango.FontDescription(graphics._font_desc) font_size = int(font.get_size() * 0.8 / pango.SCALE) # 80% of default self.label = graphics.Label(text, size = font_size, color = (30, 30, 30), y = 1) diff --git a/src/hamster/widgets/timeinput.py b/src/hamster/widgets/timeinput.py index b437696c2..23405829f 100644 --- a/src/hamster/widgets/timeinput.py +++ b/src/hamster/widgets/timeinput.py @@ -33,8 +33,10 @@ class TimeInput(gtk.Entry): } - def __init__(self, time=None, start_time=None, *, parent, **kwargs): - gtk.Entry.__init__(self, parent=parent, **kwargs) + def __init__(self, time=None, start_time=None, *, parent=None, **kwargs): + gtk.Entry.__init__(self, **kwargs) + if parent: + parent.append(self) self.news = False self.set_width_chars(7) #7 is like 11:24pm @@ -42,14 +44,11 @@ def __init__(self, time=None, start_time=None, *, parent, **kwargs): self.set_start_time(start_time) - self.popup = gtk.Window(type = gtk.WindowType.POPUP) - self.popup.set_type_hint(gdk.WindowTypeHint.COMBO) # why not - self.popup.set_attached_to(self) # attributes - self.popup.set_transient_for(self.get_ancestor(gtk.Window)) # position + self.popup = gtk.Popover() + self.popup.set_parent(self) time_box = gtk.ScrolledWindow() time_box.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.ALWAYS) - time_box.set_shadow_type(gtk.ShadowType.IN) self.time_tree = gtk.TreeView() self.time_tree.set_headers_visible(False) @@ -58,20 +57,29 @@ def __init__(self, time=None, start_time=None, *, parent, **kwargs): self.time_tree.append_column(gtk.TreeViewColumn("Time", gtk.CellRendererText(), text=0)) - self.time_tree.connect("button-press-event", - self._on_time_tree_button_press_event) + # Replace button-press-event with GestureClick + click = gtk.GestureClick() + click.connect("pressed", self._on_tree_click_gtk4) + self.time_tree.add_controller(click) - time_box.add(self.time_tree) - self.popup.add(time_box) + time_box.set_child(self.time_tree) + self.popup.set_child(time_box) self.set_icon_from_icon_name(gtk.EntryIconPosition.PRIMARY, "edit-clear-all-symbolic") + self.set_icon_from_icon_name(gtk.EntryIconPosition.SECONDARY, "pan-down-symbolic") - self.connect("icon-release", self._on_icon_release) - self.connect("button-press-event", self._on_button_press_event) - self.connect("key-press-event", self._on_key_press_event) - self.connect("focus-in-event", self._on_focus_in_event) - self.connect("focus-out-event", self._on_focus_out_event) - self._parent_click_watcher = None # bit lame but works + self.connect("icon-press", self._on_icon_press) + + # Replace key-press-event with EventControllerKey + key_ctrl = gtk.EventControllerKey() + key_ctrl.connect("key-pressed", self._on_key_press_gtk4) + self.add_controller(key_ctrl) + + # Replace focus-in-event and focus-out-event with EventControllerFocus + focus_ctrl = gtk.EventControllerFocus() + focus_ctrl.connect("enter", self._on_focus_in_gtk4) + focus_ctrl.connect("leave", self._on_focus_out_gtk4) + self.add_controller(focus_ctrl) self.connect("changed", self._on_text_changed) self.show() @@ -138,7 +146,7 @@ def figure_time(self, str_time): # strip everything non-numeric and consider hours to be first number # and minutes - second number - numbers = re.split("\D", str_time) + numbers = re.split(r"\D", str_time) numbers = [x for x in numbers if x!=""] hours, minutes = None, None @@ -174,33 +182,34 @@ def _format_time(self, time): return "" return time.strftime("%H:%M").lower() + def _on_tree_click_gtk4(self, gesture, n_press, x, y): + model, iter = self.time_tree.get_selection().get_selected() + if iter: + time = model.get_value(iter, 0) + self._select_time(time) - def _on_focus_in_event(self, entry, event): - self.show_popup() - - def _on_button_press_event(self, button, event): - self.show_popup() + def _on_focus_in_gtk4(self, controller): + pass - def _on_focus_out_event(self, event, something): - self.hide_popup() + def _on_focus_out_gtk4(self, controller): if self.news: self.emit("time-entered") self.news = False - def _on_icon_release(self, entry, icon_pos, event): - self.grab_focus() - self.set_text("") - self.emit("changed") + def _on_icon_press(self, entry, icon_pos): + if icon_pos == gtk.EntryIconPosition.PRIMARY: + self.grab_focus() + self.set_text("") + self.emit("changed") + elif icon_pos == gtk.EntryIconPosition.SECONDARY: + self.toggle_popup() def hide_popup(self): - if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher): - self.get_toplevel().disconnect(self._parent_click_watcher) - self._parent_click_watcher = None - self.popup.hide() + # GTK4: Popover handles click-outside dismissal automatically + self.popup.popdown() def show_popup(self): - if not self._parent_click_watcher: - self._parent_click_watcher = self.get_toplevel().connect("button-press-event", self._on_focus_out_event) + # GTK4: Popover handles click-outside dismissal automatically # we will be adding things, need datetime i_time_0 = dt.datetime.combine(self.start_date or dt.date.today(), @@ -246,65 +255,61 @@ def show_popup(self): selection.select_path(focus_row) self.time_tree.scroll_to_cell(focus_row, use_align = True, row_align = 0.4) - #move popup under the widget + # Set size for popup content alloc = self.get_allocation() w = alloc.width self.time_tree.set_size_request(w, alloc.height * 5) - window = self.get_parent_window() - dmmy, x, y= window.get_origin() - - self.popup.move(x + alloc.x,y + alloc.y + alloc.height) - self.popup.resize(*self.time_tree.get_size_request()) - self.popup.show_all() + # GTK4: Popover auto-positions, no need for manual move/resize + self.popup.popup() def toggle_popup(self): - if self.popup.get_property("visible"): + if self.popup.get_visible(): self.hide_popup() else: self.show_popup() - def _on_time_tree_button_press_event(self, tree, event): - model, iter = tree.get_selection().get_selected() - time = model.get_value(iter, 0) - self._select_time(time) - + def _on_key_press_gtk4(self, controller, keyval, keycode, state): + # Create event-like object for compatibility + class Event: + pass + event = Event() + event.keyval = keyval + return self._on_key_press_event(self, event) def _on_key_press_event(self, entry, event): if event.keyval not in (gdk.KEY_Up, gdk.KEY_Down, gdk.KEY_Return, gdk.KEY_KP_Enter): - #any kind of other input self.hide_popup() return False + if event.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter): + model, iter = self.time_tree.get_selection().get_selected() + if self.popup.get_visible() and iter: + i = model.get_path(iter)[0] + self._select_time(self.time_tree.get_model()[i][0]) + else: + self._select_time(entry.get_text()) + self.emit("activate") + return True + + # Up/Down: navigate the popup tree model, iter = self.time_tree.get_selection().get_selected() if not iter: - return - + if not self.popup.get_visible(): + self.show_popup() + return True i = model.get_path(iter)[0] if event.keyval == gdk.KEY_Up: - i-=1 + i -= 1 elif event.keyval == gdk.KEY_Down: - i+=1 - elif (event.keyval == gdk.KEY_Return or - event.keyval == gdk.KEY_KP_Enter): - - if self.popup.get_property("visible"): - self._select_time(self.time_tree.get_model()[i][0]) - else: - self._select_time(entry.get_text()) - elif (event.keyval == gdk.KEY_Escape): - self.hide_popup() - return + i += 1 - # keep it in sane limits i = min(max(i, 0), len(self.time_tree.get_model()) - 1) - self.time_tree.set_cursor(i) - self.time_tree.scroll_to_cell(i, use_align = True, row_align = 0.4) + self.time_tree.scroll_to_cell(i, use_align=True, row_align=0.4) - # if popup is not visible, display it on up and down - if event.keyval in (gdk.KEY_Up, gdk.KEY_Down) and self.popup.props.visible == False: + if not self.popup.get_visible(): self.show_popup() return True diff --git a/tests/test_gtk4_smoke.py b/tests/test_gtk4_smoke.py new file mode 100644 index 000000000..e90356207 --- /dev/null +++ b/tests/test_gtk4_smoke.py @@ -0,0 +1,50 @@ +"""Smoke tests verifying GTK4 widgets instantiate without exceptions.""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Gdk', '4.0') +gi.require_version('PangoCairo', '1.0') + +from gi.repository import Gtk as gtk + + +class TestGtk4WidgetInstantiation(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pass + + def test_scene_creates(self): + from hamster.lib.graphics import Scene + scene = Scene() + self.assertIsNotNone(scene) + + def test_scene_event_creates(self): + from hamster.lib.graphics import SceneEvent + event = SceneEvent(x=10, y=20, keyval=65, state=0) + copy = event.copy() + self.assertEqual(copy.x, 10) + self.assertEqual(copy.keyval, 65) + + def test_builder_loads_ui_files(self): + data_dir = os.path.join(os.path.dirname(__file__), '..', 'data') + for ui_file in ['date_range.ui', 'edit_activity.ui', 'preferences.ui']: + path = os.path.join(data_dir, ui_file) + if os.path.exists(path): + builder = gtk.Builder() + builder.add_from_file(path) + self.assertIsNotNone(builder) + + def test_stats_ui_deleted(self): + data_dir = os.path.join(os.path.dirname(__file__), '..', 'data') + self.assertFalse(os.path.exists(os.path.join(data_dir, 'stats.ui'))) + + +if __name__ == '__main__': + unittest.main()