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 @@
-
-
+
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 @@
-
-
-
-
+
-
- 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()