diff --git a/src/hamster-lite b/src/hamster-lite index 627c9d5..80a8946 100755 --- a/src/hamster-lite +++ b/src/hamster-lite @@ -91,6 +91,10 @@ class HamsterClient(object): app = HamsterLite() app.run() + def edit(self, *args): + app = HamsterLite('edit') + app.run() + def add(self, *args): from gi.repository import Gtk as gtk dialogs.edit.show() @@ -148,13 +152,11 @@ class HamsterClient(object): for category in self.storage.get_categories(): print(category['name']) - def list(self, *dates): """list facts within a date range""" start_date, end_date = parse_dates(dates or []) self._list(start_date, end_date) - def current(self, *args): """prints current activity. kinda minimal right now""" facts = self.storage.get_todays_facts() @@ -218,7 +220,7 @@ class HamsterClient(object): print(" {}".format(line)) if pretty_fact['tags']: - for line in word_wrap(pretty_fact['tags'], 76): + for line in stuff.word_wrap(pretty_fact['tags'], 76): print(" {}".format(line)) print("-" * min(row_width, 80)) diff --git a/src/hamster_lite/fact_editor.py b/src/hamster_lite/fact_editor.py new file mode 100644 index 0000000..988eae6 --- /dev/null +++ b/src/hamster_lite/fact_editor.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2020 Gerald Jansen +# Copyright (C) 2007-2009, 2014 Toms Bauģis + +# This file is part of Hamster-lite (a fork of Project Hamster). + +# Project Hamster is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# Project Hamster is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with Hamster-lite. If not, see . + +from gi.repository import Gtk as gtk +from gi.repository import Gdk as gdk +import datetime as dt + +from hamster_lite import widgets +from hamster_lite.lib.configuration import conf +from hamster_lite.lib.stuff import ( + hamsterday_time_to_datetime, hamster_today, hamster_now) +from hamster_lite.lib import Fact, parse_fact + + +class FactEditor(gtk.Window): + + def __init__(self, app=None, parent=None, fact_id=None, base_fact=None): + super().__init__(title=_("Edit activity"), + border_width=12, + icon_name='hamster-lite') + + self._app = app + + self.set_size_request(500, 1) + self.parent = parent + + # None if creating a new fact, instead of editing one + self.fact_id = fact_id + + self.date = hamster_today() + + mainbox = gtk.Box(spacing=6, orientation='vertical', can_focus=False) + self.add(mainbox) + + self.cmdline = widgets.CmdLineEntry() + box1 = gtk.Box(orientation='vertical', can_focus=False) + label = gtk.Label(xalign=0.) + label.set_markup('%s' % _('Activity@Category')) + box1.pack_start(label, 0, 0, 0) + box1.pack_start(self.cmdline, 0, 1, 0) + mainbox.pack_start(box1, 0, 1, 0) + + self.date_button = gtk.Button(str(hamster_today())) + self.date_button.connect('clicked', self.on_date_button_clicked) + self.date_popover = gtk.Popover() + cal = gtk.Calendar() + cal.connect("day-selected", self.on_day_selected) + self.date_popover.add(cal) + + self.start_time = widgets.TimeInput() + self.end_time = widgets.TimeInput() + + box2 = gtk.Box(orientation='horizontal', can_focus=False) + box2.pack_start(self.date_button, 1, 1, 0) + box2.pack_start(gtk.Label(_('Start')), 1, 1, 0) + box2.pack_start(self.start_time, 1, 1, 0) + box2.pack_start(gtk.Label(_('End')), 1, 1, 0) + box2.pack_start(self.end_time, 1, 1, 0) + mainbox.pack_start(box2, 0, 1, 0) + + box3 = gtk.Box(orientation='vertical', can_focus=False) + box3win = gtk.ScrolledWindow( + visible=True, can_focus=True, shadow_type="in", + hscrollbar_policy="never") + self.description = gtk.TextView( + height_request=50, visible=True, can_focus=True, + wrap_mode="word-char", accepts_tab=False) + self.description_buffer = self.description.get_buffer() + box3win.add(self.description) + label = gtk.Label(xalign=0.) + label.set_markup('%s' % _('Description')) + box3.pack_start(label, 0, 0, 0) + box3.pack_start(box3win, 1, 1, 0) + mainbox.pack_start(box3, 1, 1, 0) + + self.tags_entry = widgets.TagsEntry() + box4 = gtk.Box(orientation='vertical', can_focus=False) + label = gtk.Label(xalign=0.) + label.set_markup('%s' % _('Tags')) + box4.pack_start(label, 0, 0, 0) + box4.pack_start(self.tags_entry, 0, 1, 0) + mainbox.pack_start(box4, 0, 1, 0) + + self.delete_button = gtk.Button(_('Delete')) + self.cancel_button = gtk.Button(_('Cancel')) + self.save_button = gtk.Button(_('Save')) + self.delete_button.connect('clicked', self.on_delete_clicked) + self.cancel_button.connect('clicked', self.on_cancel_clicked) + self.save_button.connect('clicked', self.on_save_clicked) + + lastbox = gtk.Box(orientation='horizontal', spacing=8, can_focus=False) + lastbox.pack_start(self.delete_button, 0, 1, 0) + lastbox.pack_end(self.save_button, 0, 0, 0) + lastbox.pack_end(self.cancel_button, 0, 1, 0) + + mainbox.pack_start(lastbox, 0, 1, 0) + + # this will set self.master_is_cmdline + self.cmdline.grab_focus() + + if fact_id: + # editing + self.fact = self._app.db.get_fact(fact_id) + self.date = self.fact.date + self.set_title(_("Update activity")) + else: + self.set_title(_("Add activity")) + self.date = hamster_today() + self.delete_button.set_sensitive(False) + if base_fact: + # start a clone now. + self.fact = base_fact.copy(start_time=hamster_now(), + end_time=None) + else: + self.fact = Fact(start_time=hamster_now()) + + self.update_fields() + self.update_cmdline() + + # 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("focus_in_event", self.on_cmdline_focus_in_event) + self.cmdline.connect("focus_out_event", self.on_cmdline_focus_out_event) + self.description_buffer.connect("changed", self.on_description_changed) + self.start_time.connect("changed", self.on_start_time_changed) + self.end_time.connect("changed", self.on_end_time_changed) + self.tags_entry.connect("changed", self.on_tags_changed) + + self.connect("key-press-event", self.on_window_key_pressed) + + self.validate_fields() + self.show_all() + + def show(self): + self.show() + + + def on_cmdline_focus_in_event(self, widget, event): + pass + + def on_cmdline_focus_out_event(self, widget, event): + self.update_fields() + self.update_cmdline() + + def on_cmdline_changed(self, widget): + fact_dict = parse_fact(self.cmdline.get_text(), date=self.date) + update = False + for key, value in fact_dict.items(): + if value and value != getattr(self.fact, key): + setattr(self.fact, key, value) + update = True + if update: + self.update_fields() + + def on_description_changed(self, text): + buf = self.description_buffer + text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), 0) + self.fact.description = text.strip() + self.validate_fields() + + def on_date_button_clicked(self, button): + self.date_popover.set_relative_to(button) + self.date_popover.show_all() + self.date_popover.popup() + + def on_day_selected(self, calendar): + date = calendar.get_date() + self.date = dt.date(date.year, date.month + 1, date.day) + self.date_button.set_label(str(self.date)) + self.date_popover.hide() + if self.fact.start_time: + delta = self.date - self.fact.start_time.date() + self.fact.start_time += delta + if self.fact.end_time: + # preserve fact duration + self.fact.end_time += delta + self.validate_fields() + + def on_start_time_changed(self, widget): + # 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() + + def on_end_time_changed(self, widget): + # 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.validate_fields() + + def on_tags_changed(self, widget): + self.fact.tags = self.tags_entry.get_tags() + + def update_cmdline(self): + """Update the cmdline entry content.""" + label = self.fact.activity or "" + if self.fact.category: + label += '@' + self.fact.category + with self.cmdline.handler_block(self.cmdline.checker): + self.cmdline.set_text(label) + + def update_fields(self): + """Update gui fields content.""" + if self.fact.start_time: + self.date = self.fact.start_time.date() + self.start_time.time = self.fact.start_time + if self.fact.end_time: + self.end_time.time = self.fact.end_time + self.end_time.set_start_time(self.fact.start_time) + if self.fact.description: + self.description_buffer.set_text(self.fact.description) + if self.fact.tags: + self.tags_entry.set_tags(self.fact.tags) + self.validate_fields() + + def update_status(self, status, markup): + """Set save button sensitivity and tooltip.""" + self.save_button.set_tooltip_markup(markup) + if status == "okay": + self.save_button.set_label(_('Save')) + self.save_button.set_sensitive(True) + elif status == "warning": + self.save_button.set_label(_('Warning')) + self.save_button.set_sensitive(True) + elif status == "wrong": + self.save_button.set_label(_('Save')) + self.save_button.set_sensitive(False) + else: + raise ValueError("unknown status: '{}'".format(status)) + + def validate_fields(self): + """Check for start_time and activity entries.""" + if not self.fact.activity: + self.update_status(status="wrong", markup=_("Missing activity")) + return None + self.update_status(status="okay", markup="") + return True + + def on_delete_clicked(self, button): + self._app.db.remove_fact(self.fact_id) + self.close_window() + + def on_cancel_clicked(self, button): + self.close_window() + + def on_close(self, widget, event): + self.close_window() + + def on_save_clicked(self, button): + if self.fact_id: + self._app.db.update_fact(self.fact_id, self.fact) + else: + self._app.db.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")) + #or self.date_popover.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.has_focus(): + return False + if self.validate_fields(): + self.on_save_clicked(None) + + def close_window(self): + self._gui = None + self.destroy() diff --git a/src/hamster_lite/lib/__init__.py b/src/hamster_lite/lib/__init__.py index 0e30f07..e01d712 100644 --- a/src/hamster_lite/lib/__init__.py +++ b/src/hamster_lite/lib/__init__.py @@ -155,8 +155,9 @@ def date(self): """ return datetime_to_hamsterday(self.start_time) - @date.setter - def date(self, value): + # @date.setter + # def date(self, value): + def set_date(self, value): if self.start_time: previous_start_time = self.start_time self.start_time = hamsterday_time_to_datetime(value, self.start_time.time()) @@ -184,7 +185,7 @@ def description(self, value): @classmethod def parse(cls, string, date=None): fact = Fact() - fact.date = date + fact.set_date(date) phase = "start_time" if date else "date" for key, val in parse_fact(string, phase, {}, date).items(): setattr(fact, key, val) diff --git a/src/hamster_lite/lib/i18n.py b/src/hamster_lite/lib/i18n.py index a222f4d..2116fd6 100644 --- a/src/hamster_lite/lib/i18n.py +++ b/src/hamster_lite/lib/i18n.py @@ -19,8 +19,6 @@ def setup_i18n(): module.bindtextdomain('hamster-lite', locale_dir) module.textdomain('hamster-lite') - module.bind_textdomain_codeset('hamster-lite','utf8') - gettext.install("hamster-lite", locale_dir) else: diff --git a/src/hamster_lite/main.py b/src/hamster_lite/main.py index f3a4f54..339233a 100755 --- a/src/hamster_lite/main.py +++ b/src/hamster_lite/main.py @@ -33,8 +33,9 @@ from hamster_lite import reports from hamster_lite import logger as hamster_logger -from hamster_lite.lib import default_logger, Fact, stuff, i18n, DATE_FMT +from hamster_lite.lib import default_logger, i18n from hamster_lite.overview import Overview +from hamster_lite.fact_editor import FactEditor from hamster_lite import storage i18n.setup_i18n() @@ -55,7 +56,7 @@ def __init__(self): class HamsterLite(Gtk.Application): """Main application class.""" - def __init__(self): + def __init__(self, name="overview"): """Setup instance and make sure default signals are connected to methods.""" super().__init__(application_id='org.hamster-lite') @@ -64,6 +65,7 @@ def __init__(self): self.connect('startup', self._startup) self.connect('activate', self._activate) self.connect('shutdown', self._shutdown) + self.name = name self.window = None def _startup(self, app): @@ -75,7 +77,15 @@ def _startup(self, app): def _activate(self, app): """Triggered in regular use after startup.""" if not self.window: - self.window = Overview(app) + if self.name == "edit": + print('edit: argv', sys.argv) + if sys.argv[-1] == 'edit': + self.window = FactEditor(app) + else: + self.window = FactEditor(app, fact_id=int(sys.argv[-1])) + else: + self.window = Overview(app) + app.add_window(self.window) self.window.show_all() self.window.present() diff --git a/src/hamster_lite/storage.py b/src/hamster_lite/storage.py index 0621029..dbfc343 100644 --- a/src/hamster_lite/storage.py +++ b/src/hamster_lite/storage.py @@ -731,7 +731,7 @@ def get_facts(self, date, end_date=None, search_terms=""): # ignoring old on-going facts continue - fact.date = fact_date + fact.set_date(fact_date) res.append(fact) return res diff --git a/src/hamster_lite/widgets/activityentry.py b/src/hamster_lite/widgets/activityentry.py index 645dfcb..fde5e18 100644 --- a/src/hamster_lite/widgets/activityentry.py +++ b/src/hamster_lite/widgets/activityentry.py @@ -202,9 +202,6 @@ class CmdLineEntry(gtk.Entry): def __init__(self, updating=True, **kwargs): gtk.Entry.__init__(self) - # to be set by the caller, if editing an existing fact - self.original_fact = None - self.popup = gtk.Window(type = gtk.WindowType.POPUP) box = gtk.Frame() box.set_shadow_type(gtk.ShadowType.IN) @@ -383,64 +380,11 @@ def update_suggestions(self, text=""): matches = sorted(matches, key=lambda x: x[1], reverse=True)[:7] for match, score in matches: - label = (fact.start_time or now).strftime("%H:%M") - if fact.end_time: - label += fact.end_time.strftime("-%H:%M") - - markup_label = label + " " + (stuff.escape_pango(match).replace(search, "%s" % search) if search else match) - label += " " + match + markup_label = stuff.escape_pango(match).replace(search, "%s" % search) if search else match + label = match or "" res.append(DataRow(markup_label, match, label)) - # list of tuples (description, variant) - variants = [] - - if self.original_fact: - # editing an existing fact - - variant_fact = None - if self.original_fact.end_time is None: - description = "stop now" - variant_fact = self.original_fact.copy() - variant_fact.end_time = now - elif self.original_fact == self.todays_facts[-1]: - # that one is too dangerous, except for the last entry - description = "keep up" - # Do not use Fact(..., end_time=None): it would be a no-op - variant_fact = self.original_fact.copy() - variant_fact.end_time = None - - if variant_fact: - variant_fact.description = None - variant = variant_fact.serialized(prepend_date=False) - variants.append((description, variant)) - - else: - # brand new fact - description = "start now" - variant = now.strftime("%H:%M ") - variants.append((description, variant)) - - prev_fact = self.todays_facts[-1] if self.todays_facts else None - if prev_fact and prev_fact.end_time: - since = stuff.format_duration(now - prev_fact.end_time) - description = "from previous activity, %s ago" % since - variant = prev_fact.end_time.strftime("%H:%M ") - variants.append((description, variant)) - - description = "start activity -n minutes ago (1 or 3 digits allowed)" - variant = "-" - variants.append((description, variant)) - - text = text.strip() - if text: - description = "clear" - variant = "" - variants.append((description, variant)) - - for (description, variant) in variants: - res.append(DataRow(variant, description=description)) - self.complete_tree.set_rows(res) diff --git a/src/hamster_lite/widgets/timeinput.py b/src/hamster_lite/widgets/timeinput.py index 38f135b..c371196 100644 --- a/src/hamster_lite/widgets/timeinput.py +++ b/src/hamster_lite/widgets/timeinput.py @@ -33,8 +33,8 @@ class TimeInput(gtk.Entry): } - def __init__(self, time = None, start_time = None): - gtk.Entry.__init__(self) + def __init__(self, time=None, start_time=None, **kwargs): + gtk.Entry.__init__(self, **kwargs) self.news = False self.set_width_chars(7) #7 is like 11:24pm @@ -43,6 +43,10 @@ def __init__(self, time = None, start_time = None): self.popup = gtk.Window(type = gtk.WindowType.POPUP) + self.popup.set_type_hint(gdk.WindowTypeHint.COMBO) + self.popup.set_attached_to(self) + self.popup.set_transient_for(self.get_ancestor(gtk.Window)) + time_box = gtk.ScrolledWindow() time_box.set_policy(gtk.PolicyType.NEVER, gtk.PolicyType.ALWAYS) time_box.set_shadow_type(gtk.ShadowType.IN)