diff --git a/.gitignore b/.gitignore
index d9650a100..bd21efa3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+# backups (eg. glade)
+*~
*.pyc
autom4te.cache/
m4/
diff --git a/data/edit_activity.ui b/data/edit_activity.ui
index 11fad15fc..4174d2716 100644
--- a/data/edit_activity.ui
+++ b/data/edit_activity.ui
@@ -1,7 +1,13 @@
-
+
+
+
diff --git a/src/hamster/edit_activity.py b/src/hamster/edit_activity.py
index b1d1e6532..4f3d5120d 100644
--- a/src/hamster/edit_activity.py
+++ b/src/hamster/edit_activity.py
@@ -29,7 +29,8 @@
"""
from hamster import widgets
from hamster.lib.configuration import runtime, conf, load_ui_file
-from hamster.lib.stuff import hamster_today, hamster_now, escape_pango
+from hamster.lib.stuff import (
+ hamsterday_time_to_datetime, hamster_today, hamster_now, escape_pango)
from hamster.lib import Fact
@@ -48,64 +49,88 @@ def __init__(self, parent=None, fact_id=None, base_fact=None):
# None if creating a new fact, instead of editing one
self.fact_id = fact_id
- self.activity = widgets.ActivityEntry()
- self.activity.connect("changed", self.on_activity_changed)
- self.get_widget("activity_box").add(self.activity)
+ self.category_entry = widgets.CategoryEntry(widget=self.get_widget('category'))
+ self.activity_entry = widgets.ActivityEntry(widget=self.get_widget('activity'),
+ category_widget=self.category_entry)
- self.day_start = conf.day_start
+ self.cmdline = widgets.CmdLineEntry()
+ self.get_widget("command line box").add(self.cmdline)
+ self.cmdline.connect("focus_in_event", self.on_cmdline_focus_in_event)
+ self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event)
self.dayline = widgets.DayLine()
self._gui.get_object("day_preview").add(self.dayline)
self.description_box = self.get_widget('description')
self.description_buffer = self.description_box.get_buffer()
- self.description_buffer.connect("changed", self.on_description_changed)
+
+ self.end_date = widgets.Calendar(widget=self.get_widget("end date"),
+ expander=self.get_widget("end date expander"))
+
+ self.end_time = widgets.TimeInput()
+ self.get_widget("end time box").add(self.end_time)
+
+ self.start_date = widgets.Calendar(widget=self.get_widget("start date"),
+ expander=self.get_widget("start date expander"))
+
+ self.start_time = widgets.TimeInput()
+ self.get_widget("start time box").add(self.start_time)
+
+ self.tags_entry = widgets.TagsEntry()
+ self.get_widget("tags box").add(self.tags_entry)
self.save_button = self.get_widget("save_button")
- self.activity.grab_focus()
+ # this will set self.master_is_cmdline
+ self.cmdline.grab_focus()
+
if fact_id:
# editing
- fact = runtime.storage.get_fact(fact_id)
- self.date = fact.date
- original_fact = fact
+ self.fact = runtime.storage.get_fact(fact_id)
+ self.date = self.fact.date
self.window.set_title(_("Update activity"))
else:
self.date = hamster_today()
self.get_widget("delete_button").set_sensitive(False)
if base_fact:
# start a clone now.
- original_fact = base_fact.copy(start_time=hamster_now(),
- end_time=None)
+ self.fact = base_fact.copy(start_time=hamster_now(),
+ end_time=None)
else:
- original_fact = None
+ self.fact = Fact(start_time=hamster_now())
- if original_fact:
- stripped_fact = original_fact.copy()
- stripped_fact.description = None
- label = stripped_fact.serialized(prepend_date=False)
- with self.activity.handler_block(self.activity.checker):
- self.activity.set_text(label)
- time_len = len(label) - len(stripped_fact.serialized_name())
- self.activity.select_region(0, time_len - 1)
- self.description_buffer.set_text(original_fact.description)
+ original_fact = self.fact
- self.activity.original_fact = original_fact
+ self.update_fields()
+ self.update_cmdline(select=True)
+
+ self.cmdline.original_fact = original_fact
+
+ # 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.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)
+ self.start_date.expander.connect("activate",
+ self.on_start_date_expander_activated)
+ self.end_time.connect("changed", self.on_end_time_changed)
+ self.end_date.connect("day-selected", self.on_end_date_changed)
+ self.end_date.expander.connect("activate",
+ self.on_end_date_expander_activated)
+ self.activity_entry.connect("changed", self.on_activity_changed)
+ self.category_entry.connect("changed", self.on_category_changed)
+ self.tags_entry.connect("changed", self.on_tags_changed)
self._gui.connect_signals(self)
self.validate_fields()
self.window.show_all()
- def on_description_changed(self, text):
- self.validate_fields()
-
def on_prev_day_clicked(self, button):
- self.date = self.date - dt.timedelta(days=1)
- self.validate_fields()
+ self.increment_date(-1)
def on_next_day_clicked(self, button):
- self.date = self.date + dt.timedelta(days=1)
- self.validate_fields()
+ self.increment_date(+1)
def draw_preview(self, start_time, end_time=None):
day_facts = runtime.storage.get_facts(self.date)
@@ -115,6 +140,15 @@ def get_widget(self, name):
""" skip one variable (huh) """
return self._gui.get_object(name)
+ def increment_date(self, days):
+ delta = dt.timedelta(days=days)
+ self.date += delta
+ if self.fact.start_time:
+ self.fact.start_time += delta
+ if self.fact.end_time:
+ self.fact.end_time += delta
+ self.update_fields()
+
def show(self):
self.window.show()
@@ -123,24 +157,139 @@ def figure_description(self):
description = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), 0)
return description.strip()
- def localized_fact(self):
- """Make sure fact has the correct start_time."""
- fact = Fact.parse(self.activity.get_text())
- if fact.start_time:
- fact.date = self.date
- else:
- fact.start_time = hamster_now()
- return fact
-
- def on_save_button_clicked(self, button):
- fact = self.validate_fields()
- if self.fact_id:
- runtime.storage.update_fact(self.fact_id, fact)
- else:
- runtime.storage.add_fact(fact)
- self.close_window()
+ def on_activity_changed(self, widget):
+ if not self.master_is_cmdline:
+ self.fact.activity = self.activity_entry.get_text()
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_category_changed(self, widget):
+ if not self.master_is_cmdline:
+ self.fact.category = self.category_entry.get_text()
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_cmdline_changed(self, widget):
+ if self.master_is_cmdline:
+ fact = Fact.parse(self.cmdline.get_text(), date=self.date)
+ previous_cmdline_fact = self.cmdline_fact
+ # copy the entered fact before any modification
+ self.cmdline_fact = fact.copy()
+ if fact.start_time is None:
+ fact.start_time = hamster_now()
+ if fact.description == previous_cmdline_fact.description:
+ # no change to description here, keep the main one
+ fact.description = self.fact.description
+ self.fact = fact
+ self.update_fields()
+
+ def on_cmdline_focus_in_event(self, widget, event):
+ self.master_is_cmdline = True
+
+ def on_cmdline_focus_out_event(self, widget, event):
+ self.master_is_cmdline = False
- def on_activity_changed(self, combo):
+ def on_description_changed(self, text):
+ if not self.master_is_cmdline:
+ self.fact.description = self.figure_description()
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_end_date_changed(self, widget):
+ if not self.master_is_cmdline:
+ if self.fact.end_time:
+ time = self.fact.end_time.time()
+ self.fact.end_time = dt.datetime.combine(self.end_date.date, time)
+ self.validate_fields()
+ self.update_cmdline()
+ elif self.end_date.date:
+ # No end time means on-going, hence date would be meaningless.
+ # And a default end date may be provided when end time is set,
+ # so there should never be a date without time.
+ self.end_date.date = None
+
+ def on_end_date_expander_activated(self, widget):
+ # state has not changed yet, toggle also start_date calendar visibility
+ previous_state = self.end_date.expander.get_expanded()
+ self.start_date.expander.set_expanded(not previous_state)
+
+ def on_end_time_changed(self, widget):
+ if not self.master_is_cmdline:
+ # self.end_time.start_time() was given a datetime,
+ # so self.end_time.time is a datetime too.
+ end = self.end_time.time
+ self.fact.end_time = end
+ self.end_date.date = end.date() if end else None
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_start_date_changed(self, widget):
+ if not self.master_is_cmdline:
+ if self.fact.start_time:
+ previous_date = self.fact.start_time.date()
+ new_date = self.start_date.date
+ delta = new_date - previous_date
+ self.fact.start_time += delta
+ if self.fact.end_time:
+ # preserve fact duration
+ self.fact.end_time += delta
+ self.end_date.date = self.fact.end_time
+ self.date = self.fact.date or hamster_today()
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_start_date_expander_activated(self, widget):
+ # state has not changed yet, toggle also end_date calendar visibility
+ previous_state = self.start_date.expander.get_expanded()
+ self.end_date.expander.set_expanded(not previous_state)
+
+ def on_start_time_changed(self, widget):
+ if not self.master_is_cmdline:
+ # note: resist the temptation to preserve duration here;
+ # for instance, end time might be at the beginning of next fact.
+ new_time = self.start_time.time
+ if new_time:
+ if self.fact.start_time:
+ new_start_time = dt.datetime.combine(self.fact.start_time.date(),
+ new_time)
+ else:
+ # date not specified; result must fall in current hamster_day
+ new_start_time = hamsterday_time_to_datetime(hamster_today(),
+ new_time)
+ else:
+ new_start_time = None
+ self.fact.start_time = new_start_time
+ # let start_date extract date or handle None
+ self.start_date.date = new_start_time
+ self.validate_fields()
+ self.update_cmdline()
+
+ def on_tags_changed(self, widget):
+ if not self.master_is_cmdline:
+ self.fact.tags = self.tags_entry.get_tags()
+ self.update_cmdline()
+
+ def update_cmdline(self, select=None):
+ """Update the cmdline entry content."""
+ self.cmdline_fact = self.fact.copy(description=None)
+ label = self.cmdline_fact.serialized(prepend_date=False)
+ with self.cmdline.handler_block(self.cmdline.checker):
+ self.cmdline.set_text(label)
+ if select:
+ time_str = self.cmdline_fact.serialized_time(prepend_date=False)
+ self.cmdline.select_region(0, len(time_str))
+
+ def update_fields(self):
+ """Update gui fields content."""
+ self.start_time.time = self.fact.start_time
+ self.end_time.time = self.fact.end_time
+ self.end_time.set_start_time(self.fact.start_time)
+ self.start_date.date = self.fact.start_time
+ self.end_date.date = self.fact.end_time
+ self.activity_entry.set_text(self.fact.activity)
+ self.category_entry.set_text(self.fact.category)
+ self.description_buffer.set_text(self.fact.description)
+ self.tags_entry.set_tags(self.fact.tags)
self.validate_fields()
def update_status(self, status, markup):
@@ -166,7 +315,7 @@ def validate_fields(self):
Return the consolidated fact if successful, or None.
"""
- fact = self.localized_fact()
+ fact = self.fact
now = hamster_now()
self.get_widget("button-next-day").set_sensitive(self.date < now.date())
@@ -187,25 +336,6 @@ def validate_fields(self):
self.update_status(status="wrong", markup="Missing activity")
return None
- description_box_content = self.figure_description()
- if fact.description and description_box_content:
- escaped_cmd = escape_pango(fact.description)
- escaped_box = escape_pango(description_box_content)
- markup = dedent("""\
- Duplicate description
- command line:
- '{}'
- description box:
- '''{}'''
- """).format(escaped_cmd, escaped_box)
- self.update_status(status="wrong",
- markup=markup)
- return None
-
- # Good to go, no description ambiguity
- if description_box_content:
- fact.description = description_box_content
-
if (fact.delta < dt.timedelta(0)) and fact.end_time:
fact.end_time += dt.timedelta(days=1)
markup = dedent("""\
@@ -239,8 +369,18 @@ def on_cancel_clicked(self, button):
def on_close(self, widget, event):
self.close_window()
+ def on_save_button_clicked(self, button):
+ if self.fact_id:
+ runtime.storage.update_fact(self.fact_id, self.fact)
+ else:
+ runtime.storage.add_fact(self.fact)
+ self.close_window()
+
def on_window_key_pressed(self, tree, event_key):
- popups = self.activity.popup.get_property("visible");
+ 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)):
@@ -254,7 +394,8 @@ def on_window_key_pressed(self, tree, event_key):
return False
if self.description_box.has_focus():
return False
- self.on_save_button_clicked(None)
+ if self.validate_fields():
+ self.on_save_button_clicked(None)
def close_window(self):
if not self.parent:
diff --git a/src/hamster/lib/__init__.py b/src/hamster/lib/__init__.py
index f8e354b9a..cbe8172b5 100644
--- a/src/hamster/lib/__init__.py
+++ b/src/hamster/lib/__init__.py
@@ -212,7 +212,9 @@ def serialized(self, prepend_date=True):
"""Return a string fully representing the fact."""
name = self.serialized_name()
datetime = self.serialized_time(prepend_date)
- return "%s %s" % (datetime, name)
+ # no need for space if name or datetime is missing
+ space = " " if name and datetime else ""
+ return "{}{}{}".format(datetime, space, name)
def _set(self, **kwds):
"""Modify attributes.
diff --git a/src/hamster/lib/stuff.py b/src/hamster/lib/stuff.py
index 02b287d8a..632d4b89e 100644
--- a/src/hamster/lib/stuff.py
+++ b/src/hamster/lib/stuff.py
@@ -46,6 +46,9 @@ def datetime_to_hamsterday(civil_date_time):
The hamster day start is taken into account.
"""
+ if civil_date_time is None:
+ return None
+
# work around cyclic imports
from hamster.lib.configuration import conf
@@ -61,7 +64,15 @@ def datetime_to_hamsterday(civil_date_time):
def hamster_now():
# current datetime truncated to the minute
- return dt.datetime.now().replace(second=0, microsecond=0)
+ return hamster_round(dt.datetime.now())
+
+
+def hamster_round(time):
+ """Round time or datetime."""
+ if time is None:
+ return None
+ else:
+ return time.replace(second=0, microsecond=0)
def hamster_today():
diff --git a/src/hamster/preferences.py b/src/hamster/preferences.py
index adfe0d918..38ade3803 100755
--- a/src/hamster/preferences.py
+++ b/src/hamster/preferences.py
@@ -205,7 +205,7 @@ def load_config(self, *args):
self.get_widget("notify_on_idle").set_active(conf.get("notify_on_idle"))
self.get_widget("notify_on_idle").set_sensitive(conf.get("notify_interval") <=120)
- self.day_start.set_time(conf.day_start)
+ self.day_start.time = conf.day_start
self.tags = [tag["name"] for tag in runtime.storage.get_tags(only_autocomplete=True)]
self.get_widget("autocomplete_tags").set_text(", ".join(self.tags))
@@ -587,7 +587,7 @@ def on_notify_interval_value_changed(self, scale):
self.get_widget("notify_on_idle").set_sensitive(value <= 120)
def on_day_start_changed(self, widget):
- day_start = self.day_start.get_time()
+ day_start = self.day_start.time
if day_start is None:
return
diff --git a/src/hamster/storage/db.py b/src/hamster/storage/db.py
index 0048d18e6..feff76d0b 100644
--- a/src/hamster/storage/db.py
+++ b/src/hamster/storage/db.py
@@ -51,6 +51,8 @@
from hamster.lib.stuff import hamster_today, hamster_now
+UNSORTED_ID = -1
+
class Storage(storage.Storage):
con = None # Connection will be created on demand
@@ -344,6 +346,8 @@ def __get_activity_by_name(self, name, category_id = None, resurrect = True):
def __get_category_id(self, name):
"""returns category by it's name"""
+ if not name:
+ return UNSORTED_ID
query = """
SELECT id from categories
diff --git a/src/hamster/widgets/__init__.py b/src/hamster/widgets/__init__.py
index 58b7dbef6..6ec4d49b4 100644
--- a/src/hamster/widgets/__init__.py
+++ b/src/hamster/widgets/__init__.py
@@ -23,13 +23,17 @@
from gi.repository import Pango as pango
# import our children
-from hamster.widgets.activityentry import ActivityEntry
+from hamster.widgets.activityentry import (
+ ActivityEntry,
+ CategoryEntry,
+ CmdLineEntry,
+ )
from hamster.widgets.timeinput import TimeInput
from hamster.widgets.dayline import DayLine
from hamster.widgets.tags import Tag, TagBox, TagsEntry
from hamster.widgets.reportchooserdialog import ReportChooserDialog
from hamster.widgets.facttree import FactTree
-from hamster.widgets.dates import RangePick
+from hamster.widgets.dates import Calendar, RangePick
# handy wrappers
diff --git a/src/hamster/widgets/activityentry.py b/src/hamster/widgets/activityentry.py
index 9fcc81a02..87555ee56 100644
--- a/src/hamster/widgets/activityentry.py
+++ b/src/hamster/widgets/activityentry.py
@@ -39,6 +39,7 @@
from hamster.lib import Fact, looks_like_time
from hamster.lib import stuff
from hamster.lib import graphics
+from hamster.lib.configuration import runtime
def extract_search(text):
@@ -199,7 +200,7 @@ def on_enter_frame(self, scene, context):
-class ActivityEntry(gtk.Entry):
+class CmdLineEntry(gtk.Entry):
def __init__(self, updating=True, **kwargs):
gtk.Entry.__init__(self)
@@ -462,3 +463,193 @@ def show_suggestions(self, text):
self.popup.move(x, y)
self.popup.resize(entry_alloc.width, tree_h)
self.popup.show_all()
+
+
+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)
+
+ 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)
+ else:
+ self.set_text(combined)
+ return True # prevent the standard callback from overwriting text
+
+ 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 __getattr__(self, name):
+ return getattr(self.widget, name)
+
+
+class CategoryEntry():
+ """Category entry 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)
+
+ def on_action_activated(self, completion, index):
+ if index == self.unsorted_action_index:
+ self.clear(notify=False)
+
+ 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()
+
+ def populate_completions(self):
+ self.model.clear()
+ for category in runtime.storage.get_categories():
+ self.model.append([category['name']])
+
+ def __getattr__(self, name):
+ return getattr(self.widget, name)
diff --git a/src/hamster/widgets/dates.py b/src/hamster/widgets/dates.py
index 216b4a7b3..5be7494e3 100644
--- a/src/hamster/widgets/dates.py
+++ b/src/hamster/widgets/dates.py
@@ -28,6 +28,57 @@
from hamster.lib import stuff
from hamster.lib.configuration import load_ui_file
+
+class Calendar():
+ """Python date interface to a Gtk.Calendar.
+
+ widget (Gtk.Calendar):
+ the associated Gtk widget.
+ expander (Gtk.expander):
+ An optional expander which contains the widget.
+ The expander label displays the date.
+ """
+ def __init__(self, widget, expander=None):
+ self.widget = widget
+ self.expander = expander
+ self.widget.connect("day-selected", self.on_date_changed)
+
+ @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
+
+ @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)
+
+ def on_date_changed(self, widget):
+ if self.expander:
+ if self.date:
+ self.expander.set_label(self.date.strftime("%A %Y-%m-%d"))
+ else:
+ self.expander.set_label("")
+
+ def __getattr__(self, name):
+ return getattr(self.widget, name)
+
+
class RangePick(gtk.ToggleButton):
""" a text entry widget with calendar popup"""
__gsignals__ = {
diff --git a/src/hamster/widgets/tags.py b/src/hamster/widgets/tags.py
index 5a6344ae0..90bb363b2 100644
--- a/src/hamster/widgets/tags.py
+++ b/src/hamster/widgets/tags.py
@@ -18,6 +18,7 @@
# along with Project Hamster. If not, see .
from gi.repository import GObject as gobject
+from gi.repository import Gdk as gdk
from gi.repository import Gtk as gtk
from gi.repository import Pango as pango
import cairo
@@ -33,7 +34,7 @@ class TagsEntry(gtk.Entry):
def __init__(self):
gtk.Entry.__init__(self)
- self.tags = None
+ self.ac_tags = None # "autocomplete" tags
self.filter = None # currently applied filter string
self.filter_tags = [] #filtered tags
@@ -53,15 +54,16 @@ def __init__(self):
self.scroll_box.add(viewport)
self.popup.add(self.scroll_box)
- self.connect("button-press-event", self._on_button_press_event)
+ 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("key-release-event", self._on_key_release_event)
self.connect("focus-out-event", self._on_focus_out_event)
self._parent_click_watcher = None # bit lame but works
self.external_listeners = [
- (runtime.storage, runtime.storage.connect('tags-changed', self.refresh_tags))
+ (runtime.storage, runtime.storage.connect('tags-changed', self.refresh_ac_tags))
]
self.show()
self.populate_suggestions()
@@ -74,8 +76,8 @@ def on_destroy(self, window):
self.popup = None
- def refresh_tags(self, event):
- self.tags = None
+ def refresh_ac_tags(self, event):
+ self.ac_tags = None
def get_tags(self):
# splits the string by comma and filters out blanks
@@ -93,8 +95,8 @@ def on_tag_selected(self, tag_box, tag):
self.tag_box.selected_tags = tags
- self.set_text("%s, " % ", ".join(tags))
- self.set_position(len(self.get_text()))
+ self.set_tags(tags)
+ self.update_tagsline(add=True)
self.populate_suggestions()
self.show_popup()
@@ -106,9 +108,8 @@ def on_tag_unselected(self, tag_box, tag):
self.tag_box.selected_tags = tags
- self.set_text("%s, " % ", ".join(tags))
- self.set_position(len(self.get_text()))
-
+ self.set_tags(tags)
+ self.update_tagsline(add=True)
def hide_popup(self):
self.popup.hide()
@@ -125,7 +126,7 @@ def show_popup(self):
self._parent_click_watcher = self.get_toplevel().connect("button-press-event", self._on_focus_out_event)
alloc = self.get_allocation()
- x, y = self.get_parent_window().get_origin()
+ _, x, y = self.get_parent_window().get_origin()
self.popup.move(x + alloc.x,y + alloc.y + alloc.height)
@@ -133,25 +134,18 @@ def show_popup(self):
height = self.tag_box.count_height(w)
-
- self.tag_box.modify_bg(gtk.StateType.NORMAL, "#eee") #self.get_style().base[gtk.StateType.NORMAL])
-
self.scroll_box.set_size_request(w, height)
self.popup.resize(w, height)
self.popup.show_all()
-
-
- def complete_inline(self):
- return
-
def refresh_activities(self):
# scratch activities and categories so that they get repopulated on demand
self.activities = None
self.categories = None
def populate_suggestions(self):
- self.tags = self.tags or [tag["name"] for tag in runtime.storage.get_tags(only_autocomplete=True)]
+ self.ac_tags = self.ac_tags or [tag["name"] for tag in
+ runtime.storage.get_tags(only_autocomplete=True)]
cursor_tag = self.get_cursor_tag()
@@ -160,7 +154,8 @@ def populate_suggestions(self):
entered_tags = self.get_tags()
self.tag_box.selected_tags = entered_tags
- self.filter_tags = [tag for tag in self.tags if (tag or "").lower().startswith((self.filter or "").lower())]
+ self.filter_tags = [tag for tag in self.ac_tags
+ if (tag or "").lower().startswith((self.filter or "").lower())]
self.tag_box.draw(self.filter_tags)
@@ -169,34 +164,20 @@ def populate_suggestions(self):
def _on_focus_out_event(self, widget, event):
self.hide_popup()
- def _on_button_press_event(self, button, event):
- self.populate_suggestions()
- self.show_popup()
-
- def _on_key_release_event(self, entry, event):
- if (event.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter)):
- if self.popup.get_property("visible"):
- if self.get_text():
- self.hide_popup()
- return True
- else:
- if self.get_text():
- self.emit("tags-selected")
- return False
- elif (event.keyval == gdk.KEY_Escape):
- if self.popup.get_property("visible"):
- self.hide_popup()
- return True
- else:
- return False
+ def _on_icon_press(self, entry, icon_pos, event):
+ # otherwise Esc could not hide popup
+ self.grab_focus()
+ # toggle popup
+ if self.popup.get_visible():
+ # remove trailing comma if any
+ self.update_tagsline(add=False)
+ self.hide_popup()
else:
+ # add trailing comma
+ self.update_tagsline(add=True)
self.populate_suggestions()
self.show_popup()
- if event.keyval not in (gdk.KEY_Delete, gdk.KEY_BackSpace):
- self.complete_inline()
-
-
def get_cursor_tag(self):
#returns the tag on which the cursor is on right now
if self.get_selection_bounds():
@@ -219,7 +200,23 @@ def replace_tag(self, old_tag, new_tag):
else:
cursor = self.get_position()
- self.set_text(", ".join(tags))
+ self.set_tags(tags)
+ self.set_position(len(self.get_text()))
+
+ def set_tags(self, tags):
+ self.tags = tags
+ self.update_tagsline()
+
+ def update_tagsline(self, add=False):
+ """Update tags line text.
+
+ If add is True, prepare to add tags to the list:
+ a comma is appended and the popup is displayed.
+ """
+ text = ", ".join(self.tags)
+ if add and text:
+ text = "{}, ".format(text)
+ self.set_text(text)
self.set_position(len(self.get_text()))
def _on_key_press_event(self, entry, event):
@@ -234,6 +231,27 @@ def _on_key_press_event(self, entry, event):
else:
return False
+ elif event.keyval in (gdk.KEY_Return, gdk.KEY_KP_Enter):
+ if self.popup.get_property("visible"):
+ if self.get_text():
+ self.hide_popup()
+ return True
+ else:
+ if self.get_text():
+ self.emit("tags-selected")
+ return False
+
+ elif event.keyval == gdk.KEY_Escape:
+ if self.popup.get_property("visible"):
+ self.hide_popup()
+ return True
+ else:
+ return False
+
+ else:
+ self.populate_suggestions()
+ self.show_popup()
+
return False
diff --git a/src/hamster/widgets/timeinput.py b/src/hamster/widgets/timeinput.py
index deefb7e51..8b871e5bf 100644
--- a/src/hamster/widgets/timeinput.py
+++ b/src/hamster/widgets/timeinput.py
@@ -25,7 +25,7 @@
from gi.repository import Gtk as gtk
from gi.repository import GObject as gobject
-from hamster.lib.stuff import format_duration
+from hamster.lib.stuff import format_duration, hamster_round
class TimeInput(gtk.Entry):
__gsignals__ = {
@@ -38,9 +38,10 @@ def __init__(self, time = None, start_time = None):
self.news = False
self.set_width_chars(7) #7 is like 11:24pm
- self.set_time(time)
+ self.time = time
self.set_start_time(start_time)
+
self.popup = gtk.Window(type = gtk.WindowType.POPUP)
time_box = gtk.ScrolledWindow()
time_box.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.ALWAYS)
@@ -59,6 +60,9 @@ def __init__(self, time = None, start_time = None):
time_box.add(self.time_tree)
self.popup.add(time_box)
+ self.set_icon_from_icon_name(gtk.EntryIconPosition.PRIMARY, "edit-clear-all-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)
@@ -69,36 +73,64 @@ def __init__(self, time = None, start_time = None):
self.show()
self.connect("destroy", self.on_destroy)
+ @property
+ def time(self):
+ """Displayed time.
+
+ None,
+ or time type,
+ or datetime if start_time() was given a datetime.
+ """
+ time = self.figure_time(self.get_text())
+ if self.start_date and time:
+ # recombine (since self.start_time contains only the time part)
+ start = dt.datetime.combine(self.start_date, self.start_time)
+ new = dt.datetime.combine(self.start_date, time)
+ if new < start:
+ # a bit hackish, valid only because
+ # duration can not be negative if start_time was given,
+ # and we accept that it can not exceed 24h.
+ # For longer durations,
+ # date will have to be changed subsequently.
+ return new + dt.timedelta(days=1)
+ else:
+ return new
+ else:
+ return time
+
+ @time.setter
+ def time(self, value):
+ time = hamster_round(value)
+ self.set_text(self._format_time(time))
+ return time
+
def on_destroy(self, window):
self.popup.destroy()
self.popup = None
- def set_time(self, time):
- time = time or dt.time()
- if isinstance(time, dt.time): # ensure that we operate with time and strip seconds
- self.time = dt.time(time.hour, time.minute)
- else:
- self.time = dt.time(time.time().hour, time.time().minute)
+ def set_start_time(self, start_time):
+ """ Set the start time.
- self.set_text(self._format_time(time))
+ When start time is set, drop down list will start from start time,
+ and duration will be displayed in brackets.
- def set_start_time(self, start_time):
- """ set the start time. when start time is set, drop down list
- will start from start time and duration will be displayed in
- brackets
+ self.time will have the same type as start_time.
"""
- start_time = start_time or dt.time()
- if isinstance(start_time, dt.time): # ensure that we operate with time
- self.start_time = dt.time(start_time.hour, start_time.minute)
+ start_time = hamster_round(start_time)
+ if isinstance(start_time, dt.datetime):
+ self.start_date = start_time.date()
+ # timeinput works on time only
+ start_time = start_time.time()
else:
- self.start_time = dt.time(start_time.time().hour, start_time.time().minute)
+ self.start_date = None
+ self.start_time = start_time
def _on_text_changed(self, widget):
self.news = True
def figure_time(self, str_time):
if not str_time:
- return self.time
+ return None
# strip everything non-numeric and consider hours to be first number
# and minutes - second number
@@ -116,7 +148,7 @@ def figure_time(self, str_time):
minutes = int(numbers[1])
if (hours is None or minutes is None) or hours > 24 or minutes > 60:
- return self.time #no can do
+ return None # no can do
return dt.time(hours, minutes)
@@ -133,11 +165,6 @@ def _select_time(self, time_text):
self.emit("time-entered")
self.news = False
- def get_time(self):
- self.time = self.figure_time(self.get_text())
- self.set_text(self._format_time(self.time))
- return self.time
-
def _format_time(self, time):
if time is None:
return ""
@@ -156,6 +183,11 @@ def _on_focus_out_event(self, event, something):
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 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)
@@ -166,41 +198,39 @@ 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)
- # will be going either 24 hours or from start time to start time + 12 hours
- start_time = dt.datetime.combine(dt.date.today(), self.start_time) # we will be adding things
- i_time = start_time # we will be adding things
+ # we will be adding things, need datetime
+ i_time_0 = dt.datetime.combine(self.start_date or dt.date.today(),
+ self.start_time or dt.time())
- if self.start_time:
- end_time = i_time + dt.timedelta(hours = 12)
- i_time += dt.timedelta(minutes = 15)
+ if self.start_time is None:
+ # full 24 hours
+ i_time = i_time_0
+ interval = dt.timedelta(minutes = 15)
+ end_time = i_time_0 + dt.timedelta(days = 1)
else:
- end_time = i_time + dt.timedelta(days = 1)
-
-
- focus_time = dt.datetime.combine(dt.date.today(), self.figure_time(self.get_text()))
- hours = gtk.ListStore(gobject.TYPE_STRING)
+ # from start time to start time + 12 hours
+ interval = dt.timedelta(minutes = 15)
+ i_time = i_time_0 + interval
+ end_time = i_time_0 + dt.timedelta(hours = 12)
+ time = self.figure_time(self.get_text())
+ focus_time = dt.datetime.combine(dt.date.today(), time) if time else None
+ hours = gtk.ListStore(str)
i, focus_row = 0, None
while i_time < end_time:
row_text = self._format_time(i_time)
- if self.start_time:
- delta = (i_time - start_time).seconds / 60
- delta_text = format_duration(delta)
+ if self.start_time is not None:
+ delta_text = format_duration(i_time - i_time_0)
row_text += " (%s)" % delta_text
hours.append([row_text])
-
- if focus_time and i_time <= focus_time <= i_time + \
- dt.timedelta(minutes = 30):
+ if focus_time and i_time <= focus_time < i_time + interval:
focus_row = i
- if self.start_time:
- i_time += dt.timedelta(minutes = 15)
- else:
- i_time += dt.timedelta(minutes = 30)
+ i_time += interval
i += 1
@@ -212,12 +242,9 @@ 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
alloc = self.get_allocation()
w = alloc.width
- if self.start_time:
- w = w * 2
self.time_tree.set_size_request(w, alloc.height * 5)
window = self.get_parent_window()
@@ -227,6 +254,11 @@ def show_popup(self):
self.popup.resize(*self.time_tree.get_size_request())
self.popup.show_all()
+ def toggle_popup(self):
+ if self.popup.get_property("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()
@@ -246,18 +278,18 @@ def _on_key_press_event(self, entry, event):
i = model.get_path(iter)[0]
- if event.keyval == gtk.gdk.KEY_Up:
+ if event.keyval == gdk.KEY_Up:
i-=1
- elif event.keyval == gtk.gdk.KEY_Down:
+ elif event.keyval == gdk.KEY_Down:
i+=1
- elif (event.keyval == gtk.gdk.KEY_Return or
- event.keyval == gtk.gdk.KEY_KP_Enter):
+ 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 == gtk.gdk.KEY_Escape):
+ elif (event.keyval == gdk.KEY_Escape):
self.hide_popup()
return
@@ -268,7 +300,7 @@ def _on_key_press_event(self, entry, event):
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 (gtk.gdk.KEY_Up, gtk.gdk.KEY_Down) and self.popup.props.visible == False:
+ if event.keyval in (gdk.KEY_Up, gdk.KEY_Down) and self.popup.props.visible == False:
self.show_popup()
return True
diff --git a/tests/stuff_test.py b/tests/stuff_test.py
index 7013d2e0d..6bd0de113 100644
--- a/tests/stuff_test.py
+++ b/tests/stuff_test.py
@@ -169,6 +169,9 @@ def test_spaces(self):
# space between category and tag
fact2 = Fact.parse("11:00 12:00 BPC-261 - Task title@Project #code")
self.assertEqual(fact.serialized(), fact2.serialized())
+ # empty fact
+ fact3 = Fact()
+ self.assertEqual(fact3.serialized(), "")
if __name__ == '__main__':
unittest.main()