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 @@ - + + + 0 + + + 0 + False GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK @@ -14,13 +20,13 @@ - + True False vertical 5 - + True False @@ -88,7 +94,7 @@ - + True False @@ -102,19 +108,48 @@ - + True - True - never - in + False + vertical + + + True + False + start + description + + + + + + + False + False + 0 + + - - 50 + True True - word-char - False + never + in + + + 50 + True + True + word-char + False + + + + True + True + 1 + @@ -124,13 +159,297 @@ - + + True + False + 4 + True + + + True + False + vertical + + + True + False + start + start + + + + + + + False + False + 0 + + + + + True + False + vertical + + + + + + False + True + 1 + + + + + True + True + True + True + + + True + True + 2019 + 8 + 15 + + + + + True + False + start date + + + + + False + True + 2 + + + + + True + True + 0 + + + + + True + False + vertical + + + True + False + start + end + + + + + + + False + False + 0 + + + + + True + False + vertical + + + + + + False + True + 1 + + + + + True + True + True + True + + + True + True + 2019 + 8 + 15 + + + + + True + False + end date + + + + + False + True + 2 + + + + + False + True + 2 + + + + + False + True + 3 + + + + + True + True + + + True + False + vertical + + + True + False + start + category + + + + + + + False + False + 0 + + + + + True + True + edit-clear-all-symbolic + activity completion + + + True + True + 1 + + + + + True + False + + + + + True + False + 4 + vertical + + + True + False + start + activity + + + + + + + False + False + 0 + + + + + True + True + edit-clear-all-symbolic + category completion + + + True + True + 1 + + + + + True + False + + + + + False + True + 4 + + + + + True + False + vertical + + + True + False + start + tags + + + + + + + False + False + 0 + + + + + + + + False + True + 5 + + + + True False gtk-delete - False True True True @@ -144,7 +463,7 @@ - + True False 8 @@ -152,7 +471,6 @@ gtk-cancel - False True True True @@ -168,7 +486,6 @@ gtk-save - False True True True @@ -192,10 +509,13 @@ False True - 3 + 6 + + + 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()