[hamster-applet/windows] Initial commit of Windows port
- From: Matthew Howle <mdhowle src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [hamster-applet/windows] Initial commit of Windows port
- Date: Fri, 25 Mar 2011 02:02:33 +0000 (UTC)
commit a626b0b393be0b86ff137c2a60a56982c4518c95
Author: Matthew Howle <matthew howle org>
Date: Thu Mar 24 21:52:59 2011 -0400
Initial commit of Windows port
win32/hamster-time-tracker | 515 +++++++
win32/hamster/.gitignore | 4 +
win32/hamster/__init__.py | 1 +
win32/hamster/about.py | 64 +
win32/hamster/client.py | 452 +++++++
win32/hamster/configuration.py | 332 +++++
win32/hamster/db.py | 1206 +++++++++++++++++
win32/hamster/defs.py.in | 5 +
win32/hamster/edit_activity.py | 306 +++++
win32/hamster/external.py | 110 ++
win32/hamster/idle.py | 138 ++
win32/hamster/lib/charting.py | 346 +++++
win32/hamster/lib/graphics.py | 1839 ++++++++++++++++++++++++++
win32/hamster/lib/i18n.py | 42 +
win32/hamster/lib/pytweener.py | 605 +++++++++
win32/hamster/lib/stuff.py | 362 +++++
win32/hamster/lib/trophies.py | 201 +++
win32/hamster/overview.py | 415 ++++++
win32/hamster/overview_activities.py | 203 +++
win32/hamster/overview_totals.py | 253 ++++
win32/hamster/preferences.py | 721 ++++++++++
win32/hamster/reports.py | 326 +++++
win32/hamster/stats.py | 450 +++++++
win32/hamster/widgets/__init__.py | 91 ++
win32/hamster/widgets/activityentry.py | 339 +++++
win32/hamster/widgets/dateinput.py | 186 +++
win32/hamster/widgets/dayline.py | 375 ++++++
win32/hamster/widgets/facttree.py | 662 +++++++++
win32/hamster/widgets/rangepick.py | 138 ++
win32/hamster/widgets/reportchooserdialog.py | 139 ++
win32/hamster/widgets/tags.py | 349 +++++
win32/hamster/widgets/timechart.py | 426 ++++++
win32/hamster/widgets/timeinput.py | 267 ++++
33 files changed, 11868 insertions(+), 0 deletions(-)
---
diff --git a/win32/hamster-time-tracker b/win32/hamster-time-tracker
new file mode 100755
index 0000000..a138481
--- /dev/null
+++ b/win32/hamster-time-tracker
@@ -0,0 +1,515 @@
+#!/usr/bin/env python
+# - coding: utf-8 -
+
+# Copyright (C) 2009, 2010 Toms Bauģis <toms.baugis at gmail.com>
+# Copyright (C) 2009 Patryk Zawadzki <patrys at pld-linux.org>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+import sys
+import logging
+import datetime as dt
+
+import gtk, gobject
+
+class ProjectHamster(object):
+ def __init__(self, window_name = None):
+ # load window of activity switcher and todays view
+ self._gui = load_ui_file("hamster.ui")
+ self.window = self._gui.get_object('hamster-window')
+ self.window.connect("delete_event", self.close_window)
+
+ gtk.window_set_default_icon_name("hamster-applet")
+
+ self.new_name = widgets.ActivityEntry()
+ self.new_name.connect("value-entered", self.on_switch_activity_clicked)
+ widgets.add_hint(self.new_name, _("Activity"))
+ self.get_widget("new_name_box").add(self.new_name)
+ self.new_name.connect("changed", self.on_activity_text_changed)
+
+ self.new_name.set_property("secondary-icon-name", "help")
+ self.new_name.connect("icon-press", self.on_more_info_button_clicked)
+
+ self.new_tags = widgets.TagsEntry()
+ self.new_tags.connect("tags_selected", self.on_switch_activity_clicked)
+ widgets.add_hint(self.new_tags, _("Tags"))
+ self.get_widget("new_tags_box").add(self.new_tags)
+
+ self.tag_box = widgets.TagBox(interactive = False)
+ self.get_widget("tag_box").add(self.tag_box)
+
+ self.treeview = widgets.FactTree()
+ self.treeview.connect("key-press-event", self.on_todays_keys)
+ self.treeview.connect("edit-clicked", self._open_edit_activity)
+ self.treeview.connect("row-activated", self.on_today_row_activated)
+
+ self.get_widget("today_box").add(self.treeview)
+ self.new_name.grab_focus()
+
+ # configuration
+ self.timeout_enabled = conf.get("enable_timeout")
+ self.notify_on_idle = conf.get("notify_on_idle")
+ self.notify_interval = conf.get("notify_interval")
+ self.workspace_tracking = conf.get("workspace_tracking")
+
+ conf.connect('conf-changed', self.on_conf_changed)
+
+ # Load today's data, activities and set label
+ self.last_activity = None
+ self.todays_facts = None
+
+ runtime.storage.connect('activities-changed',self.after_activity_update)
+ runtime.storage.connect('facts-changed',self.after_fact_update)
+ runtime.storage.connect('toggle-called', self.on_toggle_called)
+
+ self.screen = None
+ if self.workspace_tracking:
+ self.init_workspace_tracking()
+
+ self.notification = None
+ if pynotify:
+ self.notification = pynotify.Notification("Oh hi",
+ "Greetings from hamster!")
+ self.notification.set_urgency(pynotify.URGENCY_LOW) # lower than grass
+
+ self._gui.connect_signals(self)
+
+ self.prev_size = None
+
+ if conf.get("standalone_window_maximized"):
+ self.window.maximize()
+ else:
+ window_box = conf.get("standalone_window_box")
+ if window_box:
+ x,y,w,h = (int(i) for i in window_box)
+ self.window.move(x, y)
+ self.window.move(x, y)
+ self.window.resize(w, h)
+ else:
+ self.window.set_position(gtk.WIN_POS_CENTER)
+
+
+ # bindings
+ self.accel_group = self.get_widget("accelgroup")
+ self.window.add_accel_group(self.accel_group)
+
+ gtk.accel_map_add_entry("<hamster-applet>/tracking/add", gtk.keysyms.n, gtk.gdk.CONTROL_MASK)
+ gtk.accel_map_add_entry("<hamster-applet>/tracking/overview", gtk.keysyms.o, gtk.gdk.CONTROL_MASK)
+ gtk.accel_map_add_entry("<hamster-applet>/tracking/stats", gtk.keysyms.i, gtk.gdk.CONTROL_MASK)
+ gtk.accel_map_add_entry("<hamster-applet>/tracking/quit", gtk.keysyms.w, gtk.gdk.CONTROL_MASK)
+ gtk.accel_map_add_entry("<hamster-applet>/edit/prefs", gtk.keysyms.p, gtk.gdk.CONTROL_MASK)
+ gtk.accel_map_add_entry("<hamster-applet>/help/contents", gtk.keysyms.F1, 0)
+
+ self.window.show_all()
+
+ self.load_day()
+ # refresh hamster every 60 seconds to update duration
+ gobject.timeout_add_seconds(60, self.refresh_hamster)
+ self.refresh_hamster()
+
+
+ def init_workspace_tracking(self):
+ if not wnck: # can't track if we don't have the trackable
+ return
+
+ self.screen = wnck.screen_get_default()
+ self.screen.workspace_handler = self.screen.connect("active-workspace-changed", self.on_workspace_changed)
+ self.workspace_activities = {}
+
+ """UI functions"""
+ def refresh_hamster(self):
+ """refresh hamster every x secs - load today, check last activity etc."""
+ try:
+ self.check_user()
+ trophies.check_ongoing(self.todays_facts)
+ except Exception, e:
+ logging.error("Error while refreshing: %s" % e)
+ finally: # we want to go on no matter what, so in case of any error we find out about it sooner
+ return True
+
+ def check_user(self):
+# if not self.notification:
+# return
+
+ if self.notify_interval <= 0 or self.notify_interval >= 121:
+ return
+
+ now = dt.datetime.now()
+ message = None
+ if self.last_activity:
+ delta = now - self.last_activity.start_time
+ duration = delta.seconds / 60
+
+ if duration and duration % self.notify_interval == 0:
+ message = _(u"Working on <b>%s</b>") % self.last_activity.name
+
+ self.get_widget("last_activity_duration").set_text(stuff.format_duration(duration) or _("Just started"))
+
+ elif self.notify_on_idle:
+ #if we have no last activity, let's just calculate duration from 00:00
+ if (now.minute + now.hour *60) % self.notify_interval == 0:
+ message = _(u"No activity")
+
+
+ if self.notification and message:
+ self.notification.update(_("Time Tracker"), message, "hamster-applet")
+ self.notification.show()
+
+ def load_day(self):
+ """sets up today's tree and fills it with records
+ returns information about last activity"""
+
+ facts = self.todays_facts = runtime.storage.get_todays_facts()
+
+ self.treeview.detach_model()
+
+ if facts and facts[-1].end_time == None:
+ self.last_activity = facts[-1]
+ else:
+ self.last_activity = None
+
+ by_category = {}
+ for fact in facts:
+ duration = 24 * 60 * fact.delta.days + fact.delta.seconds / 60
+ by_category[fact.category] = \
+ by_category.setdefault(fact.category, 0) + duration
+ self.treeview.add_fact(fact)
+
+ self.treeview.attach_model()
+
+ if not facts:
+ self._gui.get_object("today_box").hide()
+ #self._gui.get_object("fact_totals").set_text(_("No records today"))
+ else:
+ self._gui.get_object("today_box").show()
+
+ self.set_last_activity()
+
+ def set_last_activity(self):
+ activity = self.last_activity
+ #sets all the labels and everything as necessary
+ self.get_widget("stop_tracking").set_sensitive(activity != None)
+
+
+ if activity:
+ self.get_widget("switch_activity").show()
+ self.get_widget("start_tracking").hide()
+
+ delta = dt.datetime.now() - activity.start_time
+ duration = delta.seconds / 60
+
+ if activity.category != _("Unsorted"):
+ self.get_widget("last_activity_name").set_text("%s - %s" % (activity.activity, activity.category))
+ else:
+ self.get_widget("last_activity_name").set_text(activity.activity)
+
+ self.get_widget("last_activity_duration").set_text(stuff.format_duration(duration) or _("Just started"))
+ self.get_widget("last_activity_description").set_text(activity.description or "")
+ self.get_widget("activity_info_box").show()
+
+ self.tag_box.draw(activity.tags)
+ else:
+ self.get_widget("switch_activity").hide()
+ self.get_widget("start_tracking").show()
+
+ self.get_widget("last_activity_name").set_text(_("No activity"))
+
+ self.get_widget("activity_info_box").hide()
+
+ self.tag_box.draw([])
+
+
+ def delete_selected(self):
+ fact = self.treeview.get_selected_fact()
+ runtime.storage.remove_fact(fact.id)
+
+
+ """events"""
+ def on_todays_keys(self, tree, event):
+ if (event.keyval == gtk.keysyms.Delete):
+ self.delete_selected()
+ return True
+
+ return False
+
+ def _open_edit_activity(self, row, fact):
+ """opens activity editor for selected row"""
+ dialogs.edit.show(self.window, fact_id = fact.id)
+
+ def on_today_row_activated(self, tree, path, column):
+ fact = tree.get_selected_fact()
+ fact = stuff.Fact(fact.activity,
+ category = fact.category,
+ description = fact.description,
+ tags = ", ".join(fact.tags))
+ if fact.activity:
+ runtime.storage.add_fact(fact)
+
+
+ """button events"""
+ def on_menu_add_earlier_activate(self, menu):
+ dialogs.edit.show(self.window)
+ def on_menu_overview_activate(self, menu_item):
+ dialogs.overview.show(self.window)
+ def on_menu_about_activate(self, component):
+ dialogs.about.show(self.window)
+ def on_menu_statistics_activate(self, component):
+ dialogs.stats.show(self.window)
+ def on_menu_preferences_activate(self, menu_item):
+ dialogs.prefs.show(self.window)
+ def on_menu_help_contents_activate(self, *args):
+ #TODO: provide some help; maybe local HTML files and launch the default browser
+ #gtk.show_uri(gtk.gdk.Screen(), "ghelp:hamster-applet", 0L)
+ trophies.unlock("basic_instructions")
+
+
+ """signals"""
+ def after_activity_update(self, widget):
+ self.new_name.refresh_activities()
+ self.load_day()
+
+ def after_fact_update(self, event):
+ self.load_day()
+
+ def on_idle_changed(self, event, state):
+ # state values: 0 = active, 1 = idle
+
+ # refresh when we are out of idle
+ # for example, instantly after coming back from suspend
+ if state == 0:
+ self.refresh_hamster()
+ elif self.timeout_enabled and self.last_activity and \
+ self.last_activity.end_time is None:
+ #TODO: need a Windows way to get idle time
+ runtime.storage.stop_tracking(end_time = dt.datetime.now())
+
+ def on_workspace_changed(self, screen, previous_workspace):
+ if not previous_workspace:
+ # wnck has a slight hiccup on init and after that calls
+ # workspace changed event with blank previous state that should be
+ # ignored
+ return
+
+ if not self.workspace_tracking:
+ return # default to not doing anything
+
+ current_workspace = screen.get_active_workspace()
+
+ # rely on workspace numbers as names change
+ prev = previous_workspace.get_number()
+ new = current_workspace.get_number()
+
+ # on switch, update our mapping between spaces and activities
+ self.workspace_activities[prev] = self.last_activity
+
+
+ activity = None
+ if "name" in self.workspace_tracking:
+ # first try to look up activity by desktop name
+ mapping = conf.get("workspace_mapping")
+
+ fact = None
+ if new < len(mapping):
+ fact = stuff.Fact(mapping[new])
+
+ if fact.activity:
+ category_id = None
+ if fact.category:
+ category_id = runtime.storage.get_category_id(fact.category)
+
+ activity = runtime.storage.get_activity_by_name(fact.activity,
+ category_id,
+ ressurect = False)
+ if activity:
+ # we need dict below
+ activity = dict(name = activity.name,
+ category = activity.category,
+ description = fact.description,
+ tags = fact.tags)
+
+
+ if not activity and "memory" in self.workspace_tracking:
+ # now see if maybe we have any memory of the new workspace
+ # (as in - user was here and tracking Y)
+ # if the new workspace is in our dict, switch to the specified activity
+ if new in self.workspace_activities and self.workspace_activities[new]:
+ activity = self.workspace_activities[new]
+
+ if not activity:
+ return
+
+ # check if maybe there is no need to switch, as field match:
+ if self.last_activity and \
+ self.last_activity.name.lower() == activity.name.lower() and \
+ (self.last_activity.category or "").lower() == (activity.category or "").lower() and \
+ ", ".join(self.last_activity.tags).lower() == ", ".join(activity.tags).lower():
+ return
+
+ # ok, switch
+ fact = stuff.Fact(activity.name,
+ tags = ", ".join(activity.tags),
+ category = activity.category,
+ description = activity.description);
+ runtime.storage.add_fact(fact)
+
+ if self.notification:
+ self.notification.update(_("Changed activity"),
+ _("Switched to '%s'") % activity.name,
+ "hamster-applet")
+ self.notification.show()
+
+ def on_toggle_called(self, client):
+ self.window.present()
+
+ def on_conf_changed(self, event, key, value):
+ if key == "enable_timeout":
+ self.timeout_enabled = value
+ elif key == "notify_on_idle":
+ self.notify_on_idle = value
+ elif key == "notify_interval":
+ self.notify_interval = value
+ elif key == "day_start_minutes":
+ self.load_day()
+
+ elif key == "workspace_tracking":
+ self.workspace_tracking = value
+ if self.workspace_tracking and not self.screen:
+ self.init_workspace_tracking()
+ elif not self.workspace_tracking:
+ if self.screen:
+ self.screen.disconnect(self.screen.workspace_handler)
+ self.screen = None
+
+ def on_activity_text_changed(self, widget):
+ self.get_widget("switch_activity").set_sensitive(widget.get_text() != "")
+
+ def on_switch_activity_clicked(self, widget):
+ activity, temporary = self.new_name.get_value()
+
+ fact = stuff.Fact(activity,
+ tags = self.new_tags.get_text().decode("utf8", "replace"))
+ if not fact.activity:
+ return
+
+ runtime.storage.add_fact(fact, temporary)
+ self.new_name.set_text("")
+ self.new_tags.set_text("")
+
+ def on_stop_tracking_clicked(self, widget):
+ runtime.storage.stop_tracking()
+ self.last_activity = None
+
+ def on_window_configure_event(self, window, event):
+ self.treeview.fix_row_heights()
+
+ def show(self):
+ self.window.show_all()
+ self.window.present()
+
+ def get_widget(self, name):
+ return self._gui.get_object(name)
+
+ def on_more_info_button_clicked(self, *args):
+ gtk.show_uri(gtk.gdk.Screen(), "ghelp:hamster-applet#input", 0L)
+ return False
+
+ def close_window(self, *args):
+ # properly saving window state and position
+ maximized = self.window.get_window().get_state() == gtk.gdk.WINDOW_STATE_MAXIMIZED
+ conf.set("standalone_window_maximized", maximized)
+
+ # make sure to remember dimensions only when in normal state
+ if maximized == False and not self.window.get_window().get_state() == gtk.gdk.WINDOW_STATE_ICONIFIED:
+ x, y = self.window.get_position()
+ w, h = self.window.get_size()
+ conf.set("standalone_window_box", [x, y, w, h])
+
+ gtk.main_quit()
+
+
+
+# maintain just one instance. this code feels hackish
+class WindowServer(gobject.GObject):
+ def __init__(self):
+ self.app = None
+
+ def main(self):
+ if self.app:
+ self.app.window.show()
+ self.app.window.present()
+ else:
+ self.app = ProjectHamster()
+
+ def edit(self): dialogs.edit.show(self.app)
+
+ def overview(self): dialogs.overview.show(self.app)
+
+ def about(self): dialogs.about.show(self.app)
+
+ def statistics(self): dialogs.stats.show(self.app)
+
+ def preferences(self): dialogs.prefs.show(self.app)
+
+
+if __name__ == "__main__":
+ from hamster.lib import i18n
+ i18n.setup_i18n()
+
+ # determine the window we will be launching
+ window = None
+ if len(sys.argv) == 1:
+ window = "main"
+ elif len(sys.argv) == 2 and sys.argv[1] in ("overview", "statistics", "edit", "preferences", "about", "toggle"):
+ window = sys.argv[1]
+ else:
+ usage = _(
+"""Hamster time tracker. Usage:
+ %(prog)s [overview|statistics|edit|preferences|about|toggle]
+""")
+ sys.exit(usage % {'prog': sys.argv[0]})
+
+ if window == "toggle":
+ from hamster.client import Storage
+ storage = Storage()
+ storage.toggle()
+ sys.exit()
+
+ from hamster.configuration import runtime, dialogs, conf, load_ui_file
+
+ # otherwise proceed and do all the import and everything
+ gtk.gdk.threads_init()
+ gtk.window_set_default_icon_name("hamster-applet")
+
+ from hamster import widgets, idle
+ from hamster.lib import stuff, trophies
+
+ try:
+ import wnck
+ except:
+ logging.warning("Could not import wnck - workspace tracking will be disabled")
+ wnck = None
+
+ try:
+ import pynotify
+ pynotify.init('Hamster Applet')
+ except:
+ logging.warning("Could not import pynotify - notifications will be disabled")
+ pynotify = None
+
+
+ getattr(WindowServer(), window)()
+ gtk.gdk.threads_enter()
+ gtk.main()
+ gtk.gdk.threads_leave()
diff --git a/win32/hamster/.gitignore b/win32/hamster/.gitignore
new file mode 100644
index 0000000..414abbf
--- /dev/null
+++ b/win32/hamster/.gitignore
@@ -0,0 +1,4 @@
+*.pyc
+defs.py
+
+
diff --git a/win32/hamster/__init__.py b/win32/hamster/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/win32/hamster/__init__.py
@@ -0,0 +1 @@
+
diff --git a/win32/hamster/about.py b/win32/hamster/about.py
new file mode 100644
index 0000000..68b6cd8
--- /dev/null
+++ b/win32/hamster/about.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007, 2008 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+from os.path import join
+from configuration import runtime
+import gtk
+
+def on_email(about, mail):
+ gtk.show_uri(gtk.gdk.Screen(), "mailto:%s" % mail, 0L)
+
+def on_url(about, link):
+ gtk.show_uri(gtk.gdk.Screen(), link, 0L)
+
+gtk.about_dialog_set_email_hook(on_email)
+gtk.about_dialog_set_url_hook(on_url)
+
+class About(object):
+ def __init__(self, parent = None):
+ about = gtk.AboutDialog()
+ self.window = about
+ infos = {
+ "program-name" : _("Time Tracker"),
+ "name" : _("Time Tracker"), #this should be deprecated in gtk 2.10
+ "version" : runtime.version,
+ "comments" : _(u"Project Hamster â?? track your time"),
+ "copyright" : _(u"Copyright © 2007â??2010 Toms BauÄ£is and others"),
+ "website" : "http://projecthamster.wordpress.com/",
+ "website-label" : _("Project Hamster Website"),
+ "title": _("About Time Tracker"),
+ "wrap-license": True
+ }
+
+ about.set_authors(["Toms Bauģis <toms baugis gmail com>",
+ "Patryk Zawadzki <patrys pld-linux org>",
+ "PÄ?teris Caune <cuu508 gmail com>",
+ "Juanje Ojeda <jojeda emergya es>"])
+ about.set_artists(["Kalle Persson <kalle kallepersson se>"])
+
+ about.set_translator_credits(_("translator-credits"))
+
+ for prop, val in infos.items():
+ about.set_property(prop, val)
+
+ about.set_logo_icon_name("hamster-applet")
+
+ about.connect("response", lambda self, *args: self.destroy())
+ about.show_all()
diff --git a/win32/hamster/client.py b/win32/hamster/client.py
new file mode 100644
index 0000000..050692b
--- /dev/null
+++ b/win32/hamster/client.py
@@ -0,0 +1,452 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2007 Patryk Zawadzki <patrys at pld-linux.org>
+# Copyright (C) 2007-2009 Toms Baugis <toms baugis gmail com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+import datetime as dt
+from calendar import timegm
+import db
+import gobject
+from lib import stuff, trophies
+
+def to_dbus_fact(fact):
+ """Perform the conversion between fact database query and
+ dbus supported data types
+ """
+ return (fact['id'],
+ timegm(fact['start_time'].timetuple()),
+ timegm(fact['end_time'].timetuple()) if fact['end_time'] else 0,
+ fact['description'] or '',
+ fact['name'] or '',
+ fact['activity_id'] or 0,
+ fact['category'] or '',
+ fact['tags'],
+ timegm(fact['date'].timetuple()),
+ fact['delta'].days * 24 * 60 * 60 + fact['delta'].seconds)
+
+def from_dbus_fact(fact):
+ """unpack the struct into a proper dict"""
+ return stuff.Fact(fact[4],
+ start_time = dt.datetime.utcfromtimestamp(fact[1]),
+ end_time = dt.datetime.utcfromtimestamp(fact[2]) if fact[2] else None,
+ description = fact[3],
+ activity_id = fact[5],
+ category = fact[6],
+ tags = fact[7],
+ date = dt.datetime.utcfromtimestamp(fact[8]).date(),
+ delta = dt.timedelta(days = fact[9] // (24 * 60 * 60),
+ seconds = fact[9] % (24 * 60 * 60)),
+ id = fact[0]
+ )
+
+class Storage(gobject.GObject):
+ """Hamster client class, communicating to hamster storage daemon via d-bus.
+ Subscribe to the `tags-changed`, `facts-changed` and `activities-changed`
+ signals to be notified when an appropriate factoid of interest has been
+ changed.
+
+ In storage a distinguishment is made between the classificator of
+ activities and the event in tracking log.
+ When talking about the event we use term 'fact'. For the classificator
+ we use term 'activity'.
+ The relationship is - one activity can be used in several facts.
+ The rest is hopefully obvious. But if not, please file bug reports!
+ """
+ __gsignals__ = {
+ "tags-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ "facts-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ "activities-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ "toggle-called": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+
+ self._connection = None # will be initiated on demand
+
+ @staticmethod
+ def _to_dict(columns, result_list):
+ return [dict(zip(columns, row)) for row in result_list]
+
+ @property
+ def conn(self):
+ if not self._connection:
+ self._connection = db.Storage()
+ return self._connection
+
+ def _on_dbus_connection_change(self, name, old, new):
+ self._connection = None
+
+ def _on_tags_changed(self):
+ self.emit("tags-changed")
+
+ def _on_facts_changed(self):
+ self.emit("facts-changed")
+
+ def _on_activities_changed(self):
+ self.emit("activities-changed")
+
+ def _on_toggle_called(self):
+ self.emit("toggle-called")
+
+ def toggle(self):
+ """toggle visibility of the main application window if any"""
+ self.conn.Toggle()
+
+ def get_todays_facts(self):
+ """returns facts of the current date, respecting hamster midnight
+ hamster midnight is stored in gconf, and presented in minutes
+ """
+ return [from_dbus_fact(fact) for fact in self.GetTodaysFacts()]
+
+ def get_facts(self, date, end_date = None, search_terms = ""):
+ """Returns facts for the time span matching the optional filter criteria.
+ In search terms comma (",") translates to boolean OR and space (" ")
+ to boolean AND.
+ Filter is applied to tags, categories, activity names and description
+ """
+ date = timegm(date.timetuple())
+ end_date = end_date or 0
+ if end_date:
+ end_date = timegm(end_date.timetuple())
+
+ return [from_dbus_fact(fact) for fact in self.GetFacts(date,
+ end_date,
+ search_terms)]
+
+ def get_activities(self, search = ""):
+ """returns list of activities name matching search criteria.
+ results are sorted by most recent usage.
+ search is case insensitive
+ """
+ return self._to_dict(('name', 'category'), self.GetActivities(search))
+
+ def get_categories(self):
+ """returns list of categories"""
+ return self._to_dict(('id', 'name'), self.GetCategories())
+
+ def get_tags(self, only_autocomplete = False):
+ """returns list of all tags. by default only those that have been set for autocomplete"""
+ return self._to_dict(('id', 'name', 'autocomplete'), self.GetTags(only_autocomplete))
+
+
+ def get_tag_ids(self, tags):
+ """find tag IDs by name. tags should be a list of labels
+ if a requested tag had been removed from the autocomplete list, it
+ will be ressurrected. if tag with such label does not exist, it will
+ be created.
+ on database changes the `tags-changed` signal is emitted.
+ """
+ return self._to_dict(('id', 'name', 'autocomplete'), self.GetTagIds(tags))
+
+ def update_autocomplete_tags(self, tags):
+ """update list of tags that should autocomplete. this list replaces
+ anything that is currently set"""
+ self.SetTagsAutocomplete(tags)
+
+ def get_fact(self, id):
+ """returns fact by it's ID"""
+ return from_dbus_fact(self.GetFact(id))
+
+ def add_fact(self, fact, temporary_activity = False):
+ """Add fact. activity name can use the
+ `[-]start_time[-end_time] activity category, description #tag1 #tag2`
+ syntax, or params can be stated explicitly.
+ Params will take precedence over the derived values.
+ start_time defaults to current moment.
+ """
+ if not fact.activity:
+ return None
+
+ serialized = fact.serialized_name()
+
+ start_timestamp = timegm((fact.start_time or dt.datetime.now()).timetuple())
+
+ end_timestamp = fact.end_time or 0
+ if end_timestamp:
+ end_timestamp = timegm(end_timestamp.timetuple())
+
+ new_id = self.AddFact(serialized,
+ start_timestamp,
+ end_timestamp,
+ temporary_activity)
+
+ # TODO - the parsing should happen just once and preferably here
+ # we should feed (serialized_activity, start_time, end_time) into AddFact and others
+ if new_id:
+ trophies.checker.check_fact_based(fact)
+ return new_id
+
+ def stop_tracking(self, end_time = None):
+ """Stop tracking current activity. end_time can be passed in if the
+ activity should have other end time than the current moment"""
+ end_time = timegm((end_time or dt.datetime.now()).timetuple())
+ return self.StopTracking(end_time)
+
+ def remove_fact(self, fact_id):
+ "delete fact from database"
+ self.RemoveFact(fact_id)
+
+ def update_fact(self, fact_id, fact, temporary_activity = False):
+ """Update fact values. See add_fact for rules.
+ Update is performed via remove/insert, so the
+ fact_id after update should not be used anymore. Instead use the ID
+ from the fact dict that is returned by this function"""
+
+
+ start_time = timegm((fact.start_time or dt.datetime.now()).timetuple())
+
+ end_time = fact.end_time or 0
+ if end_time:
+ end_time = timegm(end_time.timetuple())
+
+ new_id = self.UpdateFact(fact_id,
+ fact.serialized_name(),
+ start_time,
+ end_time,
+ temporary_activity)
+
+ trophies.checker.check_update_based(fact_id, new_id, fact)
+ return new_id
+
+
+ def get_category_activities(self, category_id = None):
+ """Return activities for category. If category is not specified, will
+ return activities that have no category"""
+ category_id = category_id or -1
+ return self._to_dict(('id', 'name', 'category_id', 'category'), self.GetCategoryActivities(category_id))
+
+ def get_category_id(self, category_name):
+ """returns category id by name"""
+ return self.GetCategoryId(category_name)
+
+ def get_activity_by_name(self, activity, category_id = None, resurrect = True):
+ """returns activity dict by name and optionally filtering by category.
+ if activity is found but is marked as deleted, it will be resurrected
+ unless told otherise in the resurrect param
+ """
+ category_id = category_id or 0
+ return self.GetActivityByName(activity, category_id, resurrect)
+
+ # category and activity manipulations (normally just via preferences)
+ def remove_activity(self, id):
+ self.RemoveActivity(id)
+
+ def remove_category(self, id):
+ self.RemoveCategory(id)
+
+ def change_category(self, id, category_id):
+ return self.ChangeCategory(id, category_id)
+
+ def update_activity(self, id, name, category_id):
+ return self.UpdateActivity(id, name, category_id)
+
+ def add_activity(self, name, category_id = -1):
+ return self.AddActivity(name, category_id)
+
+ def update_category(self, id, name):
+ return self.UpdateCategory(id, name)
+
+ def add_category(self, name):
+ return self.AddCategory(name)
+
+ def AddFact(self, fact, start_time, end_time, temporary = False):
+ start_time = start_time or None
+ if start_time:
+ start_time = dt.datetime.utcfromtimestamp(start_time)
+
+ end_time = end_time or None
+ if end_time:
+ end_time = dt.datetime.utcfromtimestamp(end_time)
+
+# self.start_transaction()
+ result = self.conn.__add_fact(fact, start_time, end_time, temporary)
+# self.end_transaction()
+
+ if result:
+ self._on_facts_changed()
+
+ return result or 0
+
+ def GetFact(self, fact_id):
+ """Get fact by id. For output format see GetFacts"""
+ fact = dict(self.conn.__get_fact(fact_id))
+ fact['date'] = fact['start_time'].date()
+ fact['delta'] = dt.timedelta()
+ return to_dbus_fact(fact)
+
+ def UpdateFact(self, fact_id, fact, start_time, end_time, temporary = False):
+ if start_time:
+ start_time = dt.datetime.utcfromtimestamp(start_time)
+ else:
+ start_time = None
+
+ if end_time:
+ end_time = dt.datetime.utcfromtimestamp(end_time)
+ else:
+ end_time = None
+
+# self.start_transaction()
+ self.conn.__remove_fact(fact_id)
+ result = self.conn.__add_fact(fact, start_time, end_time, temporary)
+
+# self.end_transaction()
+
+ if result:
+ self._on_facts_changed()
+ return result
+
+ def StopTracking(self, end_time):
+ """Stops tracking the current activity"""
+ end_time = dt.datetime.utcfromtimestamp(end_time)
+
+ facts = self.conn.__get_todays_facts()
+ if facts:
+ self.conn.__touch_fact(facts[-1], end_time)
+ self._on_facts_changed()
+
+ def RemoveFact(self, fact_id):
+ """Remove fact from storage by it's ID"""
+ fact = self.conn.__get_fact(fact_id)
+ if fact:
+ self.conn.__remove_fact(fact_id)
+ self._on_facts_changed()
+
+
+ def GetFacts(self, start_date, end_date, search_terms):
+ """Gets facts between the day of start_date and the day of end_date.
+ Parameters:
+ i start_date: Seconds since epoch (timestamp). Use 0 for today
+ i end_date: Seconds since epoch (timestamp). Use 0 for today
+ s search_terms: Bleh
+ Returns Array of fact where fact is struct of:
+ i id
+ i start_time
+ i end_time
+ s description
+ s activity name
+ i activity id
+ i category name
+ as List of fact tags
+ i date
+ i delta
+ """
+ #TODO: Assert start > end ?
+ start = dt.date.today()
+ if start_date:
+ start = dt.datetime.utcfromtimestamp(start_date).date()
+
+ end = None
+ if end_date:
+ end = dt.datetime.utcfromtimestamp(end_date).date()
+
+ return [to_dbus_fact(fact) for fact in self.conn.__get_facts(start, end, search_terms)]
+
+ def GetTodaysFacts(self):
+ """Gets facts of today, respecting hamster midnight. See GetFacts for
+ return info"""
+ return [to_dbus_fact(fact) for fact in self.conn.__get_todays_facts()]
+
+
+ # categories
+
+ def AddCategory(self, name):
+ res = self.conn.__add_category(name)
+ self._on_activities_changed()
+ return res
+
+
+ def GetCategoryId(self, category):
+ return self.conn.__get_category_id(category)
+
+
+ def UpdateCategory(self, id, name):
+ self.conn.__update_category(id, name)
+ self._on_activities_changed()
+
+ def RemoveCategory(self, id):
+ self.conn.__remove_category(id)
+ self._on_activities_changed()
+
+ def GetCategories(self):
+ return [(category['id'], category['name']) for category in self.conn.__get_categories()]
+
+ # activities
+
+ def AddActivity(self, name, category_id = -1):
+ new_id = self.conn.__add_activity(name, category_id)
+ self._on_activities_changed()
+ return new_id
+
+ def UpdateActivity(self, id, name, category_id):
+ self.conn.__update_activity(id, name, category_id)
+ self._on_activities_changed()
+
+
+
+ def RemoveActivity(self, id):
+ result = self.conn.__remove_activity(id)
+ self._on_activities_changed()
+ return result
+
+ def GetCategoryActivities(self, category_id = -1):
+
+ return [(row['id'],
+ row['name'],
+ row['category_id'],
+ row['name'] or '') for row in
+ self.conn.__get_category_activities(category_id = category_id)]
+
+
+ def GetActivities(self, search = ""):
+ return [(row['name'], row['category'] or '') for row in self.conn.__get_activities(search)]
+
+
+ def ChangeCategory(self, id, category_id):
+ changed = self.conn.__change_category(id, category_id)
+ if changed:
+ self._on_activities_changed()
+ return changed
+
+
+ def GetActivityByName(self, activity, category_id, resurrect = True):
+ category_id = category_id or None
+
+ if activity:
+ return dict(self.conn.__get_activity_by_name(activity, category_id, resurrect))
+ else:
+ return {}
+
+ # tags
+ def GetTags(self, only_autocomplete):
+ return [(tag['id'], tag['name'], tag['autocomplete']) for tag in self.conn.__get_tags(only_autocomplete)]
+
+
+ def GetTagIds(self, tags):
+ tags, new_added = self.conn.__get_tag_ids(tags)
+ if new_added:
+ self._on_tags_changed()
+ return [(tag['id'], tag['name'], tag['autocomplete']) for tag in tags]
+
+
+ def SetTagsAutocomplete(self, tags):
+ changes = self.conn.__update_autocomplete_tags(tags)
+ if changes:
+ self._on_tags_changed()
+
diff --git a/win32/hamster/configuration.py b/win32/hamster/configuration.py
new file mode 100644
index 0000000..8a78d97
--- /dev/null
+++ b/win32/hamster/configuration.py
@@ -0,0 +1,332 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2008 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+try:
+ import ConfigParser as configparser
+except ImportError:
+ import configparser
+
+import os
+from client import Storage
+import logging
+import datetime as dt
+import gobject, gtk
+
+import logging
+log = logging.getLogger("configuration")
+
+class Singleton(object):
+ def __new__(cls, *args, **kwargs):
+ if '__instance' not in vars(cls):
+ cls.__instance = object.__new__(cls, *args, **kwargs)
+ return cls.__instance
+
+class RuntimeStore(Singleton):
+ """
+ Handles one-shot configuration that is not stored between sessions
+ """
+ database_path = ""
+ database_file = None
+ last_etag = None
+ data_dir = ""
+ home_data_dir = ""
+ storage = None
+ conf = None
+
+
+ def __init__(self):
+ try:
+ import defs
+ self.data_dir = os.path.join(defs.DATA_DIR, "hamster-applet")
+ self.version = defs.VERSION
+ except ImportError:
+ # if defs is not there, we are running from sources
+ module_dir = os.path.dirname(os.path.realpath(__file__))
+ self.data_dir = os.path.join(module_dir, '..', '..', 'data')
+ self.version = "uninstalled"
+
+ self.data_dir = os.path.realpath(self.data_dir)
+
+
+ self.storage = Storage()
+
+ if os.environ.has_key('APPDATA'):
+ self.home_data_dir = os.path.realpath(os.path.join(os.environ['APPDATA'], "hamster-applet"))
+ else:
+ logging.error("APPDATA variable is not set")
+ raise Exception("APPDATA environment variable is not defined")
+
+
+
+ @property
+ def art_dir(self):
+ return os.path.join(self.data_dir, "art")
+
+
+runtime = RuntimeStore()
+
+
+class OneWindow(object):
+ def __init__(self, get_dialog_class):
+ self.dialogs = {}
+ self.get_dialog_class = get_dialog_class
+
+ def on_dialog_destroy(self, params):
+ del self.dialogs[params]
+ #self.dialogs[params] = None
+
+ def show(self, parent = None, **kwargs):
+ params = str(sorted(kwargs.items())) #this is not too safe but will work for most cases
+
+ if params in self.dialogs:
+ self.dialogs[params].window.present()
+ else:
+ if parent:
+ dialog = self.get_dialog_class()(parent, **kwargs)
+
+ if isinstance(parent, gtk.Widget):
+ dialog.window.set_transient_for(parent.get_toplevel())
+
+ # to make things simple, we hope that the target has defined self.window
+ dialog.window.connect("destroy",
+ lambda window, params: self.on_dialog_destroy(params),
+ params)
+
+ else:
+ dialog = self.get_dialog_class()(**kwargs)
+
+ # no parent means we close on window close
+ dialog.window.connect("destroy",
+ lambda window, params: gtk.main_quit(),
+ params)
+
+
+ self.dialogs[params] = dialog
+
+class Dialogs(Singleton):
+ """makes sure that we have single instance open for windows where it makes
+ sense"""
+ def __init__(self):
+ def get_edit_class():
+ from edit_activity import CustomFactController
+ return CustomFactController
+ self.edit = OneWindow(get_edit_class)
+
+ def get_overview_class():
+ from overview import Overview
+ return Overview
+ self.overview = OneWindow(get_overview_class)
+
+ def get_stats_class():
+ from stats import Stats
+ return Stats
+ self.stats = OneWindow(get_stats_class)
+
+ def get_about_class():
+ from about import About
+ return About
+ self.about = OneWindow(get_about_class)
+
+ def get_prefs_class():
+ from preferences import PreferencesEditor
+ return PreferencesEditor
+ self.prefs = OneWindow(get_prefs_class)
+
+dialogs = Dialogs()
+
+
+def load_ui_file(name):
+ ui = gtk.Builder()
+ ui.add_from_file(os.path.join(runtime.data_dir, name))
+ return ui
+
+class INIStore(gobject.GObject, Singleton):
+ """
+ Settings implementation which stores settings in an INI file.
+ """
+ SECTION = 'Settings' # Section to read/store settings in INI file
+ VALID_KEY_TYPES = (bool, str, int, list, tuple)
+ #TODO: Remove non-Windows related settings
+ DEFAULTS = {
+ 'enable_timeout' : False, # Should hamster stop tracking on idle
+ 'stop_on_shutdown' : False, # Should hamster stop tracking on shutdown
+ 'notify_on_idle' : False, # Remind also if no activity is set
+ 'notify_interval' : 27, # Remind of current activity every X minutes
+ 'day_start_minutes' : 5 * 60 + 30, # At what time does the day start (5:30AM)
+ 'overview_window_box' : [], # X, Y, W, H
+ 'overview_window_maximized' : False, # Is overview window maximized
+ 'workspace_tracking' : [], # Should hamster switch activities on workspace change 0,1,2
+ 'workspace_mapping' : [], # Mapping between workspace numbers and activities
+ 'standalone_window_box' : [], # X, Y, W, H
+ 'standalone_window_maximized' : False, # Is overview window maximized
+ 'activities_source' : "", # Source of TODO items ("", "evo", "gtg")
+ 'last_report_folder' : "~", # Path to directory where the last report was saved
+ }
+
+ __gsignals__ = {
+ "conf-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT))
+ }
+ def __init__(self):
+ self._client = configparser.ConfigParser()
+
+ #TODO: Store file in home_data_dir
+ self.config = "hamster.ini"
+ if not os.path.isfile(self.config):
+ self._client.add_section(self.SECTION)
+ self._flush()
+ try:
+ fcfg = open(self.config,'r')
+ self._client.readfp(fcfg)
+ fcfg.close()
+ except IOError,e:
+ log.error("Error reading configurationfile: %s" % e)
+ raise
+
+ gobject.GObject.__init__(self)
+ self._notifications = []
+
+ def _flush(self):
+ """Write configuration values to INI file"""
+ try:
+ fcfg = open(self.config,'w')
+ self._client.write(fcfg)
+ fcfg.close()
+ except IOError,e:
+ log.error("Error writing to configuration file: %s" % e)
+ raise
+
+ def _fix_key(self, key):
+ """
+ Appends the GCONF_PREFIX to the key if needed
+
+ @param key: The key to check
+ @type key: C{string}
+ @returns: The fixed key
+ @rtype: C{string}
+ """
+ #TODO: Remove calls to this function
+ return key
+
+# def _key_changed(self, client, cnxn_id, entry, data=None):
+ def _key_changed(self, key):
+ """
+ Callback when a gconf key changes
+ """
+ return #TODO: Fix or remove calls
+ #key = self._fix_key(entry.key)[len(self.GCONF_DIR):]
+ #value = self._get_value(entry.value, self.DEFAULTS[key])
+
+ #self.emit('conf-changed', key, value)
+
+
+ def _get_value(self, key, default):
+ """calls appropriate configparser function by the default value"""
+ vtype = type(default)
+ try:
+ if vtype is bool:
+ return self._client.getboolean(self.SECTION, key)
+ elif vtype is str:
+ return self._client.get(self.SECTION, key)
+ elif vtype is int:
+ return self._client.getint(self.SECTION, key)
+ elif vtype in (list, tuple):
+ l = []
+ temp = self._client.get(self.SECTION, key)
+ for i in temp.split(','):
+ l.append(i.strip())
+ return l
+ except configparser.NoOptionError:
+ return None
+ except TypeError:
+ return None
+ except AttributeError:
+ return None
+
+ return None
+
+ def get(self, key, default=None):
+ """
+ Returns the value of the key or the default value if the key is
+ not yet in config
+ """
+
+ #function arguments override defaults
+ if default is None:
+ default = self.DEFAULTS.get(key, None)
+ vtype = type(default)
+
+ #we now have a valid key and type
+ if default is None:
+ log.warn("Unknown key: %s, must specify default value" % key)
+ return None
+
+ if vtype not in self.VALID_KEY_TYPES:
+ log.warn("Invalid key type: %s" % vtype)
+ return None
+
+ #for gconf refer to the full key path
+ #key = self._fix_key(key)
+
+ #if key not in self._notifications:
+ # self._notifications.append(key)
+
+ value = self._get_value(key, default)
+ if value is None:
+ self.set(key, default)
+ return default
+ elif value is not None:
+ return value
+
+ log.warn("Unknown gconf key: %s" % key)
+ return None
+
+ def set(self, key, value):
+ """
+ Sets the key value in gconf and connects adds a signal
+ which is fired if the key changes
+ """
+ log.debug("Settings %s -> %s" % (key, value))
+ if key in self.DEFAULTS:
+ vtype = type(self.DEFAULTS[key])
+ else:
+ vtype = type(value)
+
+ if vtype not in self.VALID_KEY_TYPES:
+ log.warn("Invalid key type: %s" % vtype)
+ return False
+
+ #for gconf refer to the full key path
+ #key = self._fix_key(key)
+
+ if vtype is bool:
+ self._client.set(self.SECTION, key, value)
+ elif vtype is str:
+ self._client.set(self.SECTION, key, value)
+ elif vtype is int:
+ self._client.set(self.SECTION, key, value)
+ elif vtype in (list, tuple):
+ # flatten list/tuple
+ self._client.set(self.SECTION, key, ",".join([str(i) for i in value]))
+
+ self._flush()
+
+ return True
+
+
+conf = INIStore()
diff --git a/win32/hamster/db.py b/win32/hamster/db.py
new file mode 100644
index 0000000..893518b
--- /dev/null
+++ b/win32/hamster/db.py
@@ -0,0 +1,1206 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2007-2009 Toms Bauģis <toms.baugis at gmail.com>
+# Copyright (C) 2007 Patryk Zawadzki <patrys at pld-linux.org>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+"""separate file for database operations"""
+import logging
+
+try:
+ import sqlite3 as sqlite
+except ImportError:
+ try:
+ logging.warn("Using sqlite2")
+ from pysqlite2 import dbapi2 as sqlite
+ except ImportError:
+ logging.error("Neither sqlite3 nor pysqlite2 found")
+ raise
+
+import os, time
+import datetime
+from shutil import copy as copyfile
+import itertools
+import datetime as dt
+import gio
+
+from lib import stuff, trophies
+
+class Storage(object):
+ con = None # Connection will be created on demand
+ def __init__(self):
+ """
+ Delayed setup so we don't do everything at the same time
+ """
+
+ self.__con = None
+ self.__cur = None
+ self.__last_etag = None
+
+
+ self.db_path = self.__init_db_file()
+
+ # add file monitoring so the app does not have to be restarted
+ # when db file is rewritten
+ def on_db_file_change(monitor, gio_file, event_uri, event):
+ if event == gio.FILE_MONITOR_EVENT_CHANGES_DONE_HINT:
+ if gio_file.query_info(gio.FILE_ATTRIBUTE_ETAG_VALUE).get_etag() == self.__last_etag:
+ # ours
+ return
+ elif event == gio.FILE_MONITOR_EVENT_CREATED:
+ # treat case when instead of a move, a remove and create has been performed
+ self.con = None
+
+ if event in (gio.FILE_MONITOR_EVENT_CHANGES_DONE_HINT, gio.FILE_MONITOR_EVENT_CREATED):
+ print "DB file has been modified externally. Calling all stations"
+ self.dispatch_overwrite()
+
+ # plan "b" â?? synchronize the time tracker's database from external source while the tracker is running
+ trophies.unlock("plan_b")
+
+
+ self.__database_file = gio.File(self.db_path)
+ self.__db_monitor = self.__database_file.monitor_file()
+ self.__db_monitor.connect("changed", on_db_file_change)
+
+ self.run_fixtures()
+
+ def __init_db_file(self):
+ if os.environ.has_key('APPDATA'):
+ home_data_dir = os.path.realpath(os.path.join(os.environ['APPDATA'], "hamster-applet"))
+ else:
+ logging.error("APPDATA is not defined")
+ raise Exception("APPDATA environment variable is not defined")
+
+ if not os.path.exists(home_data_dir):
+ os.makedirs(home_data_dir, 0744)
+
+ # handle the move to xdg_data_home
+ old_db_file = os.path.expanduser("~/.gnome2/hamster-applet/hamster.db")
+ new_db_file = os.path.join(home_data_dir, "hamster.db")
+ if os.path.exists(old_db_file):
+ if os.path.exists(new_db_file):
+ logging.info("Have two database %s and %s" % (new_db_file, old_db_file))
+ else:
+ os.rename(old_db_file, new_db_file)
+
+
+ db_path = os.path.join(home_data_dir, "hamster.db")
+
+
+ # check if we have a database at all
+ if not os.path.exists(db_path):
+ # if not there, copy from the defaults
+ try:
+ import defs
+ data_dir = os.path.join(defs.DATA_DIR, "hamster-applet")
+ except:
+ # if defs is not there, we are running from sources
+ module_dir = os.path.dirname(os.path.realpath(__file__))
+ data_dir = os.path.join(module_dir, '..', '..', 'data')
+
+ data_dir = os.path.realpath(data_dir)
+
+ logging.info("Database not found in %s - installing default from %s!" % (db_path, data_dir))
+ copyfile(os.path.join(data_dir, 'hamster.db'), db_path)
+
+ #change also permissions - sometimes they are 444
+ os.chmod(db_path, 0664)
+
+ return db_path
+
+
+ def register_modification(self):
+ # db.execute calls this so we know that we were the ones
+ # that modified the DB and no extra refesh is not needed
+ self.__last_etag = self.__database_file.query_info(gio.FILE_ATTRIBUTE_ETAG_VALUE).get_etag()
+
+ #tags, here we come!
+ def __get_tags(self, only_autocomplete = False):
+ if only_autocomplete:
+ return self.fetchall("select * from tags where autocomplete != 'false' order by name")
+ else:
+ return self.fetchall("select * from tags order by name")
+
+ def __get_tag_ids(self, tags):
+ """look up tags by their name. create if not found"""
+
+ db_tags = self.fetchall("select * from tags where name in (%s)"
+ % ",".join(["?"] * len(tags)), tags) # bit of magic here - using sqlites bind variables
+
+ changes = False
+
+ # check if any of tags needs resurrection
+ set_complete = [str(tag["id"]) for tag in db_tags if tag["autocomplete"] == "false"]
+ if set_complete:
+ changes = True
+ self.execute("update tags set autocomplete='true' where id in (%s)" % ", ".join(set_complete))
+
+
+ found_tags = [tag["name"] for tag in db_tags]
+
+ add = set(tags) - set(found_tags)
+ if add:
+ statement = "insert into tags(name) values(?)"
+
+ self.execute([statement] * len(add), [(tag,) for tag in add])
+
+ return self.__get_tag_ids(tags)[0], True # all done, recurse
+ else:
+ return db_tags, changes
+
+ def GetTagIds(self, tags):
+ tags, new_added = self.__get_tag_ids(tags)
+ return [(tag['id'], tag['name'], tag['autocomplete']) for tag in tags]
+
+ def __update_autocomplete_tags(self, tags):
+ tags = [tag.strip() for tag in tags.split(",") if tag.strip()] # split by comma
+
+ #first we will create new ones
+ tags, changes = self.__get_tag_ids(tags)
+ tags = [tag["id"] for tag in tags]
+
+ #now we will find which ones are gone from the list
+ query = """
+ SELECT b.id as id, b.autocomplete, count(a.fact_id) as occurences
+ FROM tags b
+ LEFT JOIN fact_tags a on a.tag_id = b.id
+ WHERE b.id not in (%s)
+ GROUP BY b.id
+ """ % ",".join(["?"] * len(tags)) # bit of magic here - using sqlites bind variables
+
+ gone = self.fetchall(query, tags)
+
+ to_delete = [str(tag["id"]) for tag in gone if tag["occurences"] == 0]
+ to_uncomplete = [str(tag["id"]) for tag in gone if tag["occurences"] > 0 and tag["autocomplete"] == "true"]
+
+ if to_delete:
+ self.execute("delete from tags where id in (%s)" % ", ".join(to_delete))
+
+ if to_uncomplete:
+ self.execute("update tags set autocomplete='false' where id in (%s)" % ", ".join(to_uncomplete))
+
+ return changes or len(to_delete + to_uncomplete) > 0
+
+ def __get_categories(self):
+ return self.fetchall("SELECT id, name FROM categories ORDER BY lower(name)")
+
+ def __update_activity(self, id, name, category_id):
+ query = """
+ UPDATE activities
+ SET name = ?,
+ search_name = ?,
+ category_id = ?
+ WHERE id = ?
+ """
+ self.execute(query, (name, name.lower(), category_id, id))
+
+ affected_ids = [res[0] for res in self.fetchall("select id from facts where activity_id = ?", (id,))]
+ self.__remove_index(affected_ids)
+
+
+ def __change_category(self, id, category_id):
+ # first check if we don't have an activity with same name before us
+ activity = self.fetchone("select name from activities where id = ?", (id, ))
+ existing_activity = self.__get_activity_by_name(activity['name'], category_id)
+
+ if existing_activity and id == existing_activity['id']: # we are already there, go home
+ return False
+
+ if existing_activity: #ooh, we have something here!
+ # first move all facts that belong to movable activity to the new one
+ update = """
+ UPDATE facts
+ SET activity_id = ?
+ WHERE activity_id = ?
+ """
+
+ self.execute(update, (existing_activity['id'], id))
+
+ # and now get rid of our friend
+ self.__remove_activity(id)
+
+ else: #just moving
+ statement = """
+ UPDATE activities
+ SET category_id = ?
+ WHERE id = ?
+ """
+
+ self.execute(statement, (category_id, id))
+
+ affected_ids = [res[0] for res in self.fetchall("select id from facts where activity_id = ?", (id,))]
+ if existing_activity:
+ affected_ids.extend([res[0] for res in self.fetchall("select id from facts where activity_id = ?", (existing_activity['id'],))])
+ self.__remove_index(affected_ids)
+
+ return True
+
+ def __add_category(self, name):
+ query = """
+ INSERT INTO categories (name, search_name)
+ VALUES (?, ?)
+ """
+ self.execute(query, (name, name.lower()))
+ return self.__last_insert_rowid()
+
+ def __update_category(self, id, name):
+ if id > -1: # Update, and ignore unsorted, if that was somehow triggered
+ update = """
+ UPDATE categories
+ SET name = ?, search_name = ?
+ WHERE id = ?
+ """
+ self.execute(update, (name, name.lower(), id))
+
+ affected_query = """
+ SELECT id
+ FROM facts
+ WHERE activity_id in (SELECT id FROM activities where category_id=?)
+ """
+ affected_ids = [res[0] for res in self.fetchall(affected_query, (id,))]
+ self.__remove_index(affected_ids)
+
+
+ def __get_activity_by_name(self, name, category_id = None, resurrect = True):
+ """get most recent, preferably not deleted activity by it's name"""
+
+ if category_id:
+ query = """
+ SELECT a.id, a.name, a.deleted, coalesce(b.name, ?) as category
+ FROM activities a
+ LEFT JOIN categories b ON category_id = b.id
+ WHERE lower(a.name) = lower(?)
+ AND category_id = ?
+ ORDER BY a.deleted, a.id desc
+ LIMIT 1
+ """
+
+ res = self.fetchone(query, (_("Unsorted"), name, category_id))
+ else:
+ query = """
+ SELECT a.id, a.name, a.deleted, coalesce(b.name, ?) as category
+ FROM activities a
+ LEFT JOIN categories b ON category_id = b.id
+ WHERE lower(a.name) = lower(?)
+ ORDER BY a.deleted, a.id desc
+ LIMIT 1
+ """
+ res = self.fetchone(query, (_("Unsorted"), name, ))
+
+ if res:
+ keys = ('id', 'name', 'deleted', 'category')
+ res = dict([(key, res[key]) for key in keys])
+ res['deleted'] = res['deleted'] or False
+
+ # if the activity was marked as deleted, resurrect on first call
+ # and put in the unsorted category
+ if res['deleted'] and resurrect:
+ update = """
+ UPDATE activities
+ SET deleted = null, category_id = -1
+ WHERE id = ?
+ """
+ self.execute(update, (res['id'], ))
+
+ return res
+
+ return None
+
+ def __get_category_id(self, name):
+ """returns category by it's name"""
+
+ query = """
+ SELECT id from categories
+ WHERE lower(name) = lower(?)
+ ORDER BY id desc
+ LIMIT 1
+ """
+
+ res = self.fetchone(query, (name, ))
+
+ if res:
+ return res['id']
+
+ return None
+
+ def __get_fact(self, id):
+ query = """
+ SELECT a.id AS id,
+ a.start_time AS start_time,
+ a.end_time AS end_time,
+ a.description as description,
+ b.name AS name, b.id as activity_id,
+ coalesce(c.name, ?) as category, coalesce(c.id, -1) as category_id,
+ e.name as tag
+ FROM facts a
+ LEFT JOIN activities b ON a.activity_id = b.id
+ LEFT JOIN categories c ON b.category_id = c.id
+ LEFT JOIN fact_tags d ON d.fact_id = a.id
+ LEFT JOIN tags e ON e.id = d.tag_id
+ WHERE a.id = ?
+ ORDER BY e.name
+ """
+
+ return self.__group_tags(self.fetchall(query, (_("Unsorted"), id)))[0]
+
+ def __group_tags(self, facts):
+ """put the fact back together and move all the unique tags to an array"""
+ if not facts: return facts #be it None or whatever
+
+ grouped_facts = []
+ for fact_id, fact_tags in itertools.groupby(facts, lambda f: f["id"]):
+ fact_tags = list(fact_tags)
+
+ # first one is as good as the last one
+ grouped_fact = fact_tags[0]
+
+ # we need dict so we can modify it (sqlite.Row is read only)
+ # in python 2.5, sqlite does not have keys() yet, so we hardcode them (yay!)
+ keys = ["id", "start_time", "end_time", "description", "name",
+ "activity_id", "category", "tag"]
+ grouped_fact = dict([(key, grouped_fact[key]) for key in keys])
+
+ grouped_fact["tags"] = [ft["tag"] for ft in fact_tags if ft["tag"]]
+ grouped_facts.append(grouped_fact)
+ return grouped_facts
+
+
+ def __touch_fact(self, fact, end_time):
+ end_time = end_time or dt.datetime.now()
+ # tasks under one minute do not count
+ if end_time - fact['start_time'] < datetime.timedelta(minutes = 1):
+ self.__remove_fact(fact['id'])
+ else:
+ end_time = end_time.replace(microsecond = 0)
+ query = """
+ UPDATE facts
+ SET end_time = ?
+ WHERE id = ?
+ """
+ self.execute(query, (end_time, fact['id']))
+
+ def __squeeze_in(self, start_time):
+ """ tries to put task in the given date
+ if there are conflicts, we will only truncate the ongoing task
+ and replace it's end part with our activity """
+
+ # we are checking if our start time is in the middle of anything
+ # or maybe there is something after us - so we know to adjust end time
+ # in the latter case go only few hours ahead. everything else is madness, heh
+ query = """
+ SELECT a.*, b.name
+ FROM facts a
+ LEFT JOIN activities b on b.id = a.activity_id
+ WHERE ((start_time < ? and end_time > ?)
+ OR (start_time > ? and start_time < ? and end_time is null)
+ OR (start_time > ? and start_time < ?))
+ ORDER BY start_time
+ LIMIT 1
+ """
+ fact = self.fetchone(query, (start_time, start_time,
+ start_time - dt.timedelta(hours = 12),
+ start_time, start_time,
+ start_time + dt.timedelta(hours = 12)))
+ end_time = None
+ if fact:
+ if start_time > fact["start_time"]:
+ #we are in middle of a fact - truncate it to our start
+ self.execute("UPDATE facts SET end_time=? WHERE id=?",
+ (start_time, fact["id"]))
+
+ else: #otherwise we have found a task that is after us
+ end_time = fact["start_time"]
+
+ return end_time
+
+ def __solve_overlaps(self, start_time, end_time):
+ """finds facts that happen in given interval and shifts them to
+ make room for new fact
+ """
+ if end_time is None or start_time is None:
+ return
+
+ # possible combinations and the OR clauses that catch them
+ # (the side of the number marks if it catches the end or start time)
+ # |----------------- NEW -----------------|
+ # |--- old --- 1| |2 --- old --- 1| |2 --- old ---|
+ # |3 ----------------------- big old ------------------------ 3|
+ query = """
+ SELECT a.*, b.name, c.name as category
+ FROM facts a
+ LEFT JOIN activities b on b.id = a.activity_id
+ LEFT JOIN categories c on b.category_id = c.id
+ WHERE (end_time > ? and end_time < ?)
+ OR (start_time > ? and start_time < ?)
+ OR (start_time < ? and end_time > ?)
+ ORDER BY start_time
+ """
+ conflicts = self.fetchall(query, (start_time, end_time,
+ start_time, end_time,
+ start_time, end_time))
+
+ for fact in conflicts:
+ # won't eliminate as it is better to have overlapping entries than loosing data
+ if start_time < fact["start_time"] and end_time > fact["end_time"]:
+ continue
+
+ # split - truncate until beginning of new entry and create new activity for end
+ if fact["start_time"] < start_time < fact["end_time"] and \
+ fact["start_time"] < end_time < fact["end_time"]:
+
+ logging.info("splitting %s" % fact["name"])
+ # truncate until beginning of the new entry
+ self.execute("""UPDATE facts
+ SET end_time = ?
+ WHERE id = ?""", (start_time, fact["id"]))
+ fact_name = fact["name"]
+
+ # create new fact for the end
+ new_fact = stuff.Fact(fact["name"],
+ category = fact["category"],
+ description = fact["description"],
+ )
+ new_fact_id = self.__add_fact(new_fact.serialized_name(), end_time, fact["end_time"])
+
+ # copy tags
+ tag_update = """INSERT INTO fact_tags(fact_id, tag_id)
+ SELECT ?, tag_id
+ FROM fact_tags
+ WHERE fact_id = ?"""
+ self.execute(tag_update, (new_fact_id, fact["id"])) #clone tags
+
+ trophies.unlock("split")
+
+ # overlap start
+ elif start_time < fact["start_time"] < end_time:
+ logging.info("Overlapping start of %s" % fact["name"])
+ self.execute("UPDATE facts SET start_time=? WHERE id=?",
+ (end_time, fact["id"]))
+
+ # overlap end
+ elif start_time < fact["end_time"] < end_time:
+ logging.info("Overlapping end of %s" % fact["name"])
+ self.execute("UPDATE facts SET end_time=? WHERE id=?",
+ (start_time, fact["id"]))
+
+
+ def __add_fact(self, serialized_fact, start_time, end_time = None, temporary = False):
+ fact = stuff.Fact(serialized_fact,
+ start_time = start_time,
+ end_time = end_time)
+
+ if not fact.activity or start_time is None: # sanity check
+ return 0
+
+
+ # get tags from database - this will create any missing tags too
+ tags = [dict(zip(('id', 'name', 'autocomplete'), row))
+ for row in self.GetTagIds(fact.tags)]
+
+
+ now = datetime.datetime.now()
+ # if in future - roll back to past
+ if start_time > datetime.datetime.now():
+ start_time = dt.datetime.combine(now.date(), start_time.time())
+ if start_time > now:
+ start_time -= dt.timedelta(days = 1)
+
+ if end_time and end_time > now:
+ end_time = dt.datetime.combine(now.date(), end_time.time())
+ if end_time > now:
+ end_time -= dt.timedelta(days = 1)
+
+
+ # now check if maybe there is also a category
+ category_id = None
+ if fact.category:
+ category_id = self.__get_category_id(fact.category)
+ if not category_id:
+ category_id = self.__add_category(fact.category)
+
+ trophies.unlock("no_hands")
+
+ # try to find activity, resurrect if not temporary
+ activity_id = self.__get_activity_by_name(fact.activity,
+ category_id,
+ resurrect = not temporary)
+ if not activity_id:
+ activity_id = self.__add_activity(fact.activity,
+ category_id, temporary)
+ else:
+ activity_id = activity_id['id']
+
+ # if we are working on +/- current day - check the last_activity
+ if (dt.datetime.now() - start_time <= dt.timedelta(days=1)):
+ # pull in previous facts
+ facts = self.__get_todays_facts()
+
+ previous = None
+ if facts and facts[-1]["end_time"] == None:
+ previous = facts[-1]
+
+ if previous and previous['start_time'] < start_time:
+ # check if maybe that is the same one, in that case no need to restart
+ if previous["activity_id"] == activity_id \
+ and set(previous["tags"]) == set([tag["name"] for tag in tags]) \
+ and (previous["description"] or "") == (fact.description or ""):
+ return None
+
+ # if no description is added
+ # see if maybe previous was too short to qualify as an activity
+ if not previous["description"] \
+ and 60 >= (start_time - previous['start_time']).seconds >= 0:
+ self.__remove_fact(previous['id'])
+
+ # now that we removed the previous one, see if maybe the one
+ # before that is actually same as the one we want to start
+ # (glueing)
+ if len(facts) > 1 and 60 >= (start_time - facts[-2]['end_time']).seconds >= 0:
+ before = facts[-2]
+ if before["activity_id"] == activity_id \
+ and set(before["tags"]) == set([tag["name"] for tag in tags]):
+ # resume and return
+ update = """
+ UPDATE facts
+ SET end_time = null
+ WHERE id = ?
+ """
+ self.execute(update, (before["id"],))
+
+ return before["id"]
+ else:
+ # otherwise stop
+ update = """
+ UPDATE facts
+ SET end_time = ?
+ WHERE id = ?
+ """
+ self.execute(update, (start_time, previous["id"]))
+
+
+ # done with the current activity, now we can solve overlaps
+ if not end_time:
+ end_time = self.__squeeze_in(start_time)
+ else:
+ self.__solve_overlaps(start_time, end_time)
+
+
+ # finally add the new entry
+ insert = """
+ INSERT INTO facts (activity_id, start_time, end_time, description)
+ VALUES (?, ?, ?, ?)
+ """
+ self.execute(insert, (activity_id, start_time, end_time, fact.description))
+
+ fact_id = self.__last_insert_rowid()
+
+ #now link tags
+ insert = ["insert into fact_tags(fact_id, tag_id) values(?, ?)"] * len(tags)
+ params = [(fact_id, tag["id"]) for tag in tags]
+ self.execute(insert, params)
+
+ self.__remove_index([fact_id])
+ return fact_id
+
+ def __last_insert_rowid(self):
+ return self.fetchone("SELECT last_insert_rowid();")[0]
+
+
+ def __get_todays_facts(self):
+ from configuration import conf
+ day_start = conf.get("day_start_minutes")
+ day_start = dt.time(day_start / 60, day_start % 60)
+ today = (dt.datetime.now() - dt.timedelta(hours = day_start.hour,
+ minutes = day_start.minute)).date()
+ return self.__get_facts(today)
+
+
+ def __get_facts(self, date, end_date = None, search_terms = ""):
+ from configuration import conf
+ day_start = conf.get("day_start_minutes")
+ day_start = dt.time(day_start / 60, day_start % 60)
+
+ split_time = day_start
+ datetime_from = dt.datetime.combine(date, split_time)
+
+ end_date = end_date or date
+ datetime_to = dt.datetime.combine(end_date, split_time) + dt.timedelta(days = 1)
+
+ query = """
+ SELECT a.id AS id,
+ a.start_time AS start_time,
+ a.end_time AS end_time,
+ a.description as description,
+ b.name AS name, b.id as activity_id,
+ coalesce(c.name, ?) as category,
+ e.name as tag
+ FROM facts a
+ LEFT JOIN activities b ON a.activity_id = b.id
+ LEFT JOIN categories c ON b.category_id = c.id
+ LEFT JOIN fact_tags d ON d.fact_id = a.id
+ LEFT JOIN tags e ON e.id = d.tag_id
+ WHERE (a.end_time >= ? OR a.end_time IS NULL) AND a.start_time <= ?
+ """
+
+ if search_terms:
+ # check if we need changes to the index
+ self.__check_index(datetime_from, datetime_to)
+
+ search_terms = search_terms.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_').replace("'", "''")
+ query += """ AND a.id in (SELECT id
+ FROM fact_index
+ WHERE fact_index MATCH '%s')""" % search_terms
+
+
+
+ query += " ORDER BY a.start_time, e.name"
+
+ facts = self.fetchall(query, (_("Unsorted"),
+ datetime_from,
+ datetime_to))
+
+ #first let's put all tags in an array
+ facts = self.__group_tags(facts)
+
+ res = []
+ for fact in facts:
+ # heuristics to assign tasks to proper days
+
+ # if fact has no end time, set the last minute of the day,
+ # or current time if fact has happened in last 24 hours
+ if fact["end_time"]:
+ fact_end_time = fact["end_time"]
+ elif (dt.date.today() - fact["start_time"].date()) <= dt.timedelta(days=1):
+ fact_end_time = dt.datetime.now().replace(microsecond = 0)
+ else:
+ fact_end_time = fact["start_time"]
+
+ fact_start_date = fact["start_time"].date() \
+ - dt.timedelta(1 if fact["start_time"].time() < split_time else 0)
+ fact_end_date = fact_end_time.date() \
+ - dt.timedelta(1 if fact_end_time.time() < split_time else 0)
+ fact_date_span = fact_end_date - fact_start_date
+
+ # check if the task spans across two dates
+ if fact_date_span.days == 1:
+ datetime_split = dt.datetime.combine(fact_end_date, split_time)
+ start_date_duration = datetime_split - fact["start_time"]
+ end_date_duration = fact_end_time - datetime_split
+ if start_date_duration > end_date_duration:
+ # most of the task was done during the previous day
+ fact_date = fact_start_date
+ else:
+ fact_date = fact_end_date
+ else:
+ # either doesn't span or more than 24 hrs tracked
+ # (in which case we give up)
+ fact_date = fact_start_date
+
+ if fact_date < date or fact_date > end_date:
+ # due to spanning we've jumped outside of given period
+ continue
+
+ fact["date"] = fact_date
+ fact["delta"] = fact_end_time - fact["start_time"]
+ res.append(fact)
+
+ return res
+
+ def __remove_fact(self, fact_id):
+ statements = ["DELETE FROM fact_tags where fact_id = ?",
+ "DELETE FROM facts where id = ?"]
+ self.execute(statements, [(fact_id,)] * 2)
+
+ self.__remove_index([fact_id])
+
+ def __get_category_activities(self, category_id):
+ """returns list of activities, if category is specified, order by name
+ otherwise - by activity_order"""
+ query = """
+ SELECT a.id, a.name, a.category_id, b.name as category
+ FROM activities a
+ LEFT JOIN categories b on coalesce(b.id, -1) = a.category_id
+ WHERE category_id = ?
+ AND deleted is null
+ ORDER BY lower(a.name)
+ """
+
+ return self.fetchall(query, (category_id, ))
+
+
+ def __get_activities(self, search):
+ """returns list of activities for autocomplete,
+ activity names converted to lowercase"""
+
+ query = """
+ SELECT a.name AS name, b.name AS category
+ FROM activities a
+ LEFT JOIN categories b ON coalesce(b.id, -1) = a.category_id
+ LEFT JOIN facts f ON a.id = f.activity_id
+ WHERE deleted IS NULL
+ AND a.search_name LIKE ? ESCAPE '\\'
+ GROUP BY a.id
+ ORDER BY max(f.start_time) DESC, lower(a.name)
+ LIMIT 50
+ """
+ search = search.lower()
+ search = search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
+ activities = self.fetchall(query, (u'%s%%' % search, ))
+
+ return activities
+
+ def __remove_activity(self, id):
+ """ check if we have any facts with this activity and behave accordingly
+ if there are facts - sets activity to deleted = True
+ else, just remove it"""
+
+ query = "select count(*) as count from facts where activity_id = ?"
+ bound_facts = self.fetchone(query, (id,))['count']
+
+ if bound_facts > 0:
+ self.execute("UPDATE activities SET deleted = 1 WHERE id = ?", (id,))
+ else:
+ self.execute("delete from activities where id = ?", (id,))
+
+ # Finished! - deleted an activity with more than 50 facts on it
+ if bound_facts >= 50:
+ trophies.unlock("finished")
+
+ def __remove_category(self, id):
+ """move all activities to unsorted and remove category"""
+
+ affected_query = """
+ SELECT id
+ FROM facts
+ WHERE activity_id in (SELECT id FROM activities where category_id=?)
+ """
+ affected_ids = [res[0] for res in self.fetchall(affected_query, (id,))]
+
+ update = "update activities set category_id = -1 where category_id = ?"
+ self.execute(update, (id, ))
+
+ self.execute("delete from categories where id = ?", (id, ))
+
+ self.__remove_index(affected_ids)
+
+
+ def __add_activity(self, name, category_id = None, temporary = False):
+ # first check that we don't have anything like that yet
+ activity = self.__get_activity_by_name(name, category_id)
+ if activity:
+ return activity['id']
+
+ #now do the create bit
+ category_id = category_id or -1
+
+ deleted = None
+ if temporary:
+ deleted = 1
+
+
+ query = """
+ INSERT INTO activities (name, search_name, category_id, deleted)
+ VALUES (?, ?, ?, ?)
+ """
+ self.execute(query, (name, name.lower(), category_id, deleted))
+ return self.__last_insert_rowid()
+
+ def __remove_index(self, ids):
+ """remove affected ids from the index"""
+ if not ids:
+ return
+
+ ids = ",".join((str(id) for id in ids))
+ self.execute("DELETE FROM fact_index where id in (%s)" % ids)
+
+
+ def __check_index(self, start_date, end_date):
+ """check if maybe index needs rebuilding in the time span"""
+ index_query = """SELECT id
+ FROM facts
+ WHERE (end_time >= ? OR end_time IS NULL)
+ AND start_time <= ?
+ AND id not in(select id from fact_index)"""
+
+ rebuild_ids = ",".join([str(res[0]) for res in self.fetchall(index_query, (start_date, end_date))])
+
+ if rebuild_ids:
+ query = """
+ SELECT a.id AS id,
+ a.start_time AS start_time,
+ a.end_time AS end_time,
+ a.description as description,
+ b.name AS name, b.id as activity_id,
+ coalesce(c.name, ?) as category,
+ e.name as tag
+ FROM facts a
+ LEFT JOIN activities b ON a.activity_id = b.id
+ LEFT JOIN categories c ON b.category_id = c.id
+ LEFT JOIN fact_tags d ON d.fact_id = a.id
+ LEFT JOIN tags e ON e.id = d.tag_id
+ WHERE a.id in (%s)
+ ORDER BY a.id
+ """ % rebuild_ids
+
+ facts = self.__group_tags(self.fetchall(query, (_("Unsorted"), )))
+
+ insert = """INSERT INTO fact_index (id, name, category, description, tag)
+ VALUES (?, ?, ?, ?, ?)"""
+ params = [(fact['id'], fact['name'], fact['category'], fact['description'], " ".join(fact['tags'])) for fact in facts]
+
+ self.executemany(insert, params)
+
+
+ """ Here be dragons (lame connection/cursor wrappers) """
+ def get_connection(self):
+ if self.con is None:
+ self.con = sqlite.connect(self.db_path, detect_types=sqlite.PARSE_DECLTYPES|sqlite.PARSE_COLNAMES)
+ self.con.row_factory = sqlite.Row
+
+ return self.con
+
+ connection = property(get_connection, None)
+
+ def fetchall(self, query, params = None):
+ con = self.connection
+ cur = con.cursor()
+
+ logging.debug("%s %s" % (query, params))
+
+ if params:
+ cur.execute(query, params)
+ else:
+ cur.execute(query)
+
+ res = cur.fetchall()
+ cur.close()
+
+ return res
+
+ def fetchone(self, query, params = None):
+ res = self.fetchall(query, params)
+ if res:
+ return res[0]
+ else:
+ return None
+
+ def execute(self, statement, params = ()):
+ """
+ execute sql statement. optionally you can give multiple statements
+ to save on cursor creation and closure
+ """
+ con = self.__con or self.connection
+ cur = self.__cur or con.cursor()
+
+ if isinstance(statement, list) == False: # we expect to receive instructions in list
+ statement = [statement]
+ params = [params]
+
+ for state, param in zip(statement, params):
+ logging.debug("%s %s" % (state, param))
+ cur.execute(state, param)
+
+ if not self.__con:
+ con.commit()
+ cur.close()
+ self.register_modification()
+
+ def executemany(self, statement, params = []):
+ con = self.__con or self.connection
+ cur = self.__cur or con.cursor()
+
+ logging.debug("%s %s" % (statement, params))
+ cur.executemany(statement, params)
+
+ if not self.__con:
+ con.commit()
+ cur.close()
+ self.register_modification()
+
+
+
+ def start_transaction(self):
+ # will give some hints to execute not to close or commit anything
+ self.__con = self.connection
+ self.__cur = self.__con.cursor()
+
+ def end_transaction(self):
+ self.__con.commit()
+ self.__cur.close()
+ self.__con, self.__cur = None, None
+ self.register_modification()
+
+ def run_fixtures(self):
+ self.start_transaction()
+
+ # defaults
+ work_category = {"name": _("Work"),
+ "entries": [_("Reading news"),
+ _("Checking stocks"),
+ _("Super secret project X"),
+ _("World domination")]}
+
+ nonwork_category = {"name": _("Day-to-day"),
+ "entries": [_("Lunch"),
+ _("Watering flowers"),
+ _("Doing handstands")]}
+
+ """upgrade DB to hamster version"""
+ version = self.fetchone("SELECT version FROM version")["version"]
+ current_version = 9
+
+ if version < 2:
+ """moving from fact_date, fact_time to start_time, end_time"""
+
+ self.execute("""
+ CREATE TABLE facts_new
+ (id integer primary key,
+ activity_id integer,
+ start_time varchar2(12),
+ end_time varchar2(12))
+ """)
+
+ self.execute("""
+ INSERT INTO facts_new
+ (id, activity_id, start_time)
+ SELECT id, activity_id, fact_date || fact_time
+ FROM facts
+ """)
+
+ self.execute("DROP TABLE facts")
+ self.execute("ALTER TABLE facts_new RENAME TO facts")
+
+ # run through all facts and set the end time
+ # if previous fact is not on the same date, then it means that it was the
+ # last one in previous, so remove it
+ # this logic saves our last entry from being deleted, which is good
+ facts = self.fetchall("""
+ SELECT id, activity_id, start_time,
+ substr(start_time,1, 8) start_date
+ FROM facts
+ ORDER BY start_time
+ """)
+ prev_fact = None
+
+ for fact in facts:
+ if prev_fact:
+ if prev_fact['start_date'] == fact['start_date']:
+ self.execute("UPDATE facts SET end_time = ? where id = ?",
+ (fact['start_time'], prev_fact['id']))
+ else:
+ #otherwise that's the last entry of the day - remove it
+ self.execute("DELETE FROM facts WHERE id = ?", (prev_fact["id"],))
+
+ prev_fact = fact
+
+ #it was kind of silly not to have datetimes in first place
+ if version < 3:
+ self.execute("""
+ CREATE TABLE facts_new
+ (id integer primary key,
+ activity_id integer,
+ start_time timestamp,
+ end_time timestamp)
+ """)
+
+ self.execute("""
+ INSERT INTO facts_new
+ (id, activity_id, start_time, end_time)
+ SELECT id, activity_id,
+ substr(start_time,1,4) || "-"
+ || substr(start_time, 5, 2) || "-"
+ || substr(start_time, 7, 2) || " "
+ || substr(start_time, 9, 2) || ":"
+ || substr(start_time, 11, 2) || ":00",
+ substr(end_time,1,4) || "-"
+ || substr(end_time, 5, 2) || "-"
+ || substr(end_time, 7, 2) || " "
+ || substr(end_time, 9, 2) || ":"
+ || substr(end_time, 11, 2) || ":00"
+ FROM facts;
+ """)
+
+ self.execute("DROP TABLE facts")
+ self.execute("ALTER TABLE facts_new RENAME TO facts")
+
+
+ #adding categories table to categorize activities
+ if version < 4:
+ #adding the categories table
+ self.execute("""
+ CREATE TABLE categories
+ (id integer primary key,
+ name varchar2(500),
+ color_code varchar2(50),
+ category_order integer)
+ """)
+
+ # adding default categories, and make sure that uncategorized stays on bottom for starters
+ # set order to 2 in case, if we get work in next lines
+ self.execute("""
+ INSERT INTO categories
+ (id, name, category_order)
+ VALUES (1, ?, 2);
+ """, (nonwork_category["name"],))
+
+ #check if we have to create work category - consider work everything that has been determined so, and is not deleted
+ work_activities = self.fetchone("""
+ SELECT count(*) as work_activities
+ FROM activities
+ WHERE deleted is null and work=1;
+ """)['work_activities']
+
+ if work_activities > 0:
+ self.execute("""
+ INSERT INTO categories
+ (id, name, category_order)
+ VALUES (2, ?, 1);
+ """, (work_category["name"],))
+
+ # now add category field to activities, before starting the move
+ self.execute(""" ALTER TABLE activities
+ ADD COLUMN category_id integer;
+ """)
+
+
+ # starting the move
+
+ # first remove all deleted activities with no instances in facts
+ self.execute("""
+ DELETE FROM activities
+ WHERE deleted = 1
+ AND id not in(select activity_id from facts);
+ """)
+
+
+ # moving work / non-work to appropriate categories
+ # exploit false/true = 0/1 thing
+ self.execute(""" UPDATE activities
+ SET category_id = work + 1
+ WHERE deleted is null
+ """)
+
+ #finally, set category to -1 where there is none
+ self.execute(""" UPDATE activities
+ SET category_id = -1
+ WHERE category_id is null
+ """)
+
+ # drop work column and forget value of deleted
+ # previously deleted records are now unsorted ones
+ # user will be able to mark them as deleted again, in which case
+ # they won't appear in autocomplete, or in categories
+ # resurrection happens, when user enters the exact same name
+ self.execute("""
+ CREATE TABLE activities_new (id integer primary key,
+ name varchar2(500),
+ activity_order integer,
+ deleted integer,
+ category_id integer);
+ """)
+
+ self.execute("""
+ INSERT INTO activities_new
+ (id, name, activity_order, category_id)
+ SELECT id, name, activity_order, category_id
+ FROM activities;
+ """)
+
+ self.execute("DROP TABLE activities")
+ self.execute("ALTER TABLE activities_new RENAME TO activities")
+
+ if version < 5:
+ self.execute("ALTER TABLE facts add column description varchar2")
+
+ if version < 6:
+ # facts table could use an index
+ self.execute("CREATE INDEX idx_facts_start_end ON facts(start_time, end_time)")
+ self.execute("CREATE INDEX idx_facts_start_end_activity ON facts(start_time, end_time, activity_id)")
+
+ # adding tags
+ self.execute("""CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ name TEXT NOT NULL,
+ autocomplete BOOL DEFAULT true)""")
+ self.execute("CREATE INDEX idx_tags_name ON tags(name)")
+
+ self.execute("CREATE TABLE fact_tags(fact_id integer, tag_id integer)")
+ self.execute("CREATE INDEX idx_fact_tags_fact ON fact_tags(fact_id)")
+ self.execute("CREATE INDEX idx_fact_tags_tag ON fact_tags(tag_id)")
+
+
+ if version < 7:
+ self.execute("""CREATE TABLE increment_facts (id integer primary key autoincrement,
+ activity_id integer,
+ start_time timestamp,
+ end_time timestamp,
+ description varchar2)""")
+ self.execute("""INSERT INTO increment_facts(id, activity_id, start_time, end_time, description)
+ SELECT id, activity_id, start_time, end_time, description from facts""")
+ self.execute("DROP table facts")
+ self.execute("ALTER TABLE increment_facts RENAME TO facts")
+
+ if version < 8:
+ # working around sqlite's utf-f case sensitivity (bug 624438)
+ # more info: http://www.gsak.net/help/hs23820.htm
+ self.execute("ALTER TABLE activities ADD COLUMN search_name varchar2")
+
+ activities = self.fetchall("select * from activities")
+ statement = "update activities set search_name = ? where id = ?"
+ for activity in activities:
+ self.execute(statement, (activity['name'].lower(), activity['id']))
+
+ # same for categories
+ self.execute("ALTER TABLE categories ADD COLUMN search_name varchar2")
+ categories = self.fetchall("select * from categories")
+ statement = "update categories set search_name = ? where id = ?"
+ for category in categories:
+ self.execute(statement, (category['name'].lower(), category['id']))
+
+ if version < 9:
+ # adding full text search
+ self.execute("""CREATE VIRTUAL TABLE fact_index
+ USING fts3(id, name, category, description, tag)""")
+
+
+ # at the happy end, update version number
+ if version < current_version:
+ #lock down current version
+ self.execute("UPDATE version SET version = %d" % current_version)
+ print "updated database from version %d to %d" % (version, current_version)
+
+ # oldtimer â?? database version structure had been performed on startup (thus we know that he has been on at least 2 versions)
+ trophies.unlock("oldtimer")
+
+
+ """we start with an empty database and then populate with default
+ values. This way defaults can be localized!"""
+
+ category_count = self.fetchone("select count(*) from categories")[0]
+
+ if category_count == 0:
+ work_cat_id = self.__add_category(work_category["name"])
+ for entry in work_category["entries"]:
+ self.__add_activity(entry, work_cat_id)
+
+ nonwork_cat_id = self.__add_category(nonwork_category["name"])
+ for entry in nonwork_category["entries"]:
+ self.__add_activity(entry, nonwork_cat_id)
+
+
+ self.end_transaction()
diff --git a/win32/hamster/defs.py.in b/win32/hamster/defs.py.in
new file mode 100644
index 0000000..e7bfaac
--- /dev/null
+++ b/win32/hamster/defs.py.in
@@ -0,0 +1,5 @@
+DATA_DIR = "@DATADIR@"
+LIB_DIR = "@LIBDIR@"
+VERSION = "@VERSION@"
+PACKAGE = "@PACKAGE@"
+PYTHONDIR = "@PYEXECDIR@"
diff --git a/win32/hamster/edit_activity.py b/win32/hamster/edit_activity.py
new file mode 100644
index 0000000..7470ee5
--- /dev/null
+++ b/win32/hamster/edit_activity.py
@@ -0,0 +1,306 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk
+import time
+import datetime as dt
+
+""" TODO: hook into notifications and refresh our days if some evil neighbour
+ edit fact window has dared to edit facts
+"""
+import widgets
+from configuration import runtime, conf, load_ui_file
+from lib import stuff
+
+class CustomFactController:
+ def __init__(self, parent = None, fact_date = None, fact_id = None):
+
+ self._gui = load_ui_file("edit_activity.ui")
+ self.window = self.get_widget('custom_fact_window')
+
+ self.parent, self.fact_id = parent, fact_id
+ start_date, end_date = None, None
+
+ #TODO - should somehow hint that time is not welcome here
+ self.new_name = widgets.ActivityEntry()
+ self.get_widget("activity_box").add(self.new_name)
+
+ self.new_tags = widgets.TagsEntry()
+ self.get_widget("tags_box").add(self.new_tags)
+
+ day_start = conf.get("day_start_minutes")
+ self.day_start = dt.time(day_start / 60, day_start % 60)
+
+ if fact_id:
+ fact = runtime.storage.get_fact(fact_id)
+
+ label = fact.activity
+ if fact.category != _("Unsorted"):
+ label += "@%s" % fact.category
+
+ self.new_name.set_text(label)
+
+ self.new_tags.set_text(", ".join(fact.tags))
+
+
+ start_date = fact.start_time
+ end_date = fact.end_time
+
+ buf = gtk.TextBuffer()
+ buf.set_text(fact.description or "")
+ self.get_widget('description').set_buffer(buf)
+
+ self.get_widget("save_button").set_label("gtk-save")
+ self.window.set_title(_("Update activity"))
+
+ else:
+ # if there is previous activity with end time - attach to it
+ # otherwise let's start at 8am (unless it is today - in that case
+ # we will assume that the user wants to start from this moment)
+ fact_date = fact_date or dt.date.today()
+ if fact_date > dt.date.today():
+ fact_date = dt.date.today()
+
+ last_activity = runtime.storage.get_facts(fact_date)
+ if last_activity and last_activity[-1].end_time:
+ start_date = last_activity[-1].end_time
+
+ if fact_date != dt.date.today():
+ end_date = start_date + dt.timedelta(minutes=30)
+ else:
+ if fact_date == dt.date.today():
+ start_date = dt.datetime.now()
+ else:
+ start_date = dt.datetime(fact_date.year, fact_date.month,
+ fact_date.day, 8)
+
+
+ if not end_date:
+ self.get_widget("in_progress").set_active(True)
+ if (dt.datetime.now() - start_date).days == 0:
+ end_date = dt.datetime.now()
+
+
+ start_date = start_date or dt.datetime.now()
+ end_date = end_date or start_date + dt.timedelta(minutes = 30)
+
+
+ self.start_date = widgets.DateInput(start_date)
+ self.get_widget("start_date_placeholder").add(self.start_date)
+
+ self.start_time = widgets.TimeInput(start_date)
+ self.get_widget("start_time_placeholder").add(self.start_time)
+
+ self.end_time = widgets.TimeInput(end_date, start_date)
+ self.get_widget("end_time_placeholder").add(self.end_time)
+ self.set_end_date_label(end_date)
+
+
+ self.dayline = widgets.DayLine()
+ self.dayline.connect("on-time-chosen", self.update_time)
+ self._gui.get_object("day_preview").add(self.dayline)
+
+ self.on_in_progress_toggled(self.get_widget("in_progress"))
+
+ self.start_date.connect("date-entered", self.on_start_date_entered)
+ self.start_time.connect("time-entered", self.on_start_time_entered)
+ self.new_name.connect("changed", self.on_new_name_changed)
+ self.end_time.connect("time-entered", self.on_end_time_entered)
+ self._gui.connect_signals(self)
+
+ self.window.show_all()
+
+ def update_time(self, widget, start_time, end_time):
+ self.start_time.set_time(start_time)
+ self.on_start_time_entered(None)
+
+ self.start_date.set_date(start_time)
+
+ self.get_widget("in_progress").set_active(end_time is None)
+
+ if end_time:
+ if end_time > dt.datetime.now():
+ end_time = dt.datetime.now()
+
+ self.end_time.set_time(end_time)
+ self.set_end_date_label(end_time)
+
+ self.draw_preview(start_time, end_time)
+
+
+ def draw_preview(self, start_time, end_time = None):
+
+ view_date = (start_time - dt.timedelta(hours = self.day_start.hour,
+ minutes = self.day_start.minute)).date()
+
+ day_facts = runtime.storage.get_facts(view_date)
+ self.dayline.plot(view_date, day_facts, start_time, end_time)
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def show(self):
+ self.window.show()
+
+
+ def figure_description(self):
+ buf = self.get_widget('description').get_buffer()
+ description = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), 0)\
+ .decode("utf-8")
+ return description.strip()
+
+
+ def _get_datetime(self, prefix):
+ start_time = self.start_time.get_time()
+ start_date = self.start_date.get_date()
+
+ if prefix == "end":
+ end_time = self.end_time.get_time()
+ end_date = start_date
+ if end_time < start_time:
+ end_date = start_date + dt.timedelta(days=1)
+
+ if end_date:
+ self.set_end_date_label(end_date)
+ time, date = end_time, end_date
+ else:
+ time, date = start_time, start_date
+
+ if time is not None and date:
+ return dt.datetime.combine(date, time)
+ else:
+ return None
+
+ def on_save_button_clicked(self, button):
+ activity_name, temporary = self.new_name.get_value()
+
+ if self.get_widget("in_progress").get_active():
+ end_time = None
+ else:
+ end_time = self._get_datetime("end")
+
+ fact = stuff.Fact(activity_name,
+ description = self.figure_description(),
+ tags = self.new_tags.get_text().decode('utf8'),
+ start_time = self._get_datetime("start"),
+ end_time = end_time)
+ if not fact.activity:
+ return False
+
+ if self.fact_id:
+ runtime.storage.update_fact(self.fact_id, fact, temporary)
+ else:
+ runtime.storage.add_fact(fact, temporary)
+
+ self.close_window()
+
+ def on_activity_list_key_pressed(self, entry, event):
+ #treating tab as keydown to be able to cycle through available values
+ if event.keyval == gtk.keysyms.Tab:
+ event.keyval = gtk.keysyms.Down
+ return False
+
+ def on_in_progress_toggled(self, check):
+ sensitive = not check.get_active()
+ self.end_time.set_sensitive(sensitive)
+ self.get_widget("end_label").set_sensitive(sensitive)
+ self.get_widget("end_date_label").set_sensitive(sensitive)
+ self.validate_fields()
+
+ def on_cancel_clicked(self, button):
+ self.close_window()
+
+ def on_new_name_changed(self, combo):
+ self.validate_fields()
+
+ def on_start_date_entered(self, widget):
+ if dt.datetime.combine(self.start_date.get_date(), self.start_time.get_time()) > dt.datetime.now():
+ self.start_date.set_date(dt.date.today())
+
+ # if we are still over - push one more day back
+ if dt.datetime.combine(self.start_date.get_date(), self.start_time.get_time()) > dt.datetime.now():
+ self.start_date.set_date(dt.date.today() - dt.timedelta(days=1))
+
+ self.validate_fields()
+
+ def on_start_time_entered(self, widget):
+ start_time = self.start_time.get_time()
+
+ if not start_time:
+ return
+
+ if dt.datetime.combine(self.start_date.get_date(), start_time) > dt.datetime.now():
+ self.start_date.set_date(dt.date.today() - dt.timedelta(days=1))
+
+
+ self.end_time.set_start_time(start_time)
+ self.validate_fields()
+
+ def on_end_time_entered(self, widget):
+ self.validate_fields()
+
+ def set_end_date_label(self, some_date):
+ self.get_widget("end_date_label").set_text(some_date.strftime("%x"))
+
+ def validate_fields(self, widget = None):
+ activity_text, temporary = self.new_name.get_value()
+ start_time = self._get_datetime("start")
+
+ if self.get_widget("in_progress").get_active():
+ end_time = None
+ else:
+ end_time = self._get_datetime("end")
+ if end_time > dt.datetime.now():
+ end_time = dt.datetime.now()
+
+ # make sure we are within 24 hours of start time
+ end_time -= dt.timedelta(days=(end_time - start_time).days)
+
+ self.end_time.set_time(end_time)
+
+ self.draw_preview(start_time, end_time)
+
+ looks_good = activity_text is not None and start_time \
+ and (not end_time or (end_time - start_time).days == 0)
+
+
+ self.get_widget("save_button").set_sensitive(looks_good)
+ return looks_good
+
+ def on_window_key_pressed(self, tree, event_key):
+ if (event_key.keyval == gtk.keysyms.Escape
+ or (event_key.keyval == gtk.keysyms.w
+ and event_key.state & gtk.gdk.CONTROL_MASK)):
+
+ if self.start_date.popup.get_property("visible") or \
+ self.start_time.popup.get_property("visible") or \
+ self.end_time.popup.get_property("visible") or \
+ self.new_name.popup.get_property("visible") or \
+ self.new_tags.popup.get_property("visible"):
+ return False
+
+ self.close_window()
+
+ def on_close(self, widget, event):
+ self.close_window()
+
+ def close_window(self):
+ self.window.destroy()
+ return False
diff --git a/win32/hamster/external.py b/win32/hamster/external.py
new file mode 100644
index 0000000..774c5c0
--- /dev/null
+++ b/win32/hamster/external.py
@@ -0,0 +1,110 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2007 Patryk Zawadzki <patrys at pld-linux.org>
+# Copyright (C) 2008, 2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+from configuration import conf
+import gobject
+
+try:
+ import evolution
+ from evolution import ecal
+except:
+ evolution = None
+
+class ActivitiesSource(gobject.GObject):
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self.source = conf.get("activities_source")
+ self.__gtg_connection = None
+
+ if self.source == "evo" and not evolution:
+ self.source == "" # on failure pretend that there is no evolution
+ elif self.source == "gtg":
+ gobject.GObject.__init__(self)
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+
+ def get_activities(self, query = None):
+ if not self.source:
+ return []
+
+ if self.source == "evo":
+ return [activity for activity in get_eds_tasks()
+ if query is None or activity['name'].startswith(query)]
+
+ elif self.source == "gtg":
+ conn = self.__get_gtg_connection()
+ if not conn:
+ return []
+
+ activities = []
+
+ tasks = []
+ try:
+ tasks = conn.get_tasks()
+ except dbus.exceptions.DBusException: #TODO too lame to figure out how to connect to the disconnect signal
+ self.__gtg_connection = None
+ return self.get_activities(query) # reconnect
+
+
+ for task in tasks:
+ if query is None or task['title'].lower().startswith(query):
+ name = task['title']
+ if len(task['tags']):
+ name = "%s, %s" % (name, " ".join([tag.replace("@", "#") for tag in task['tags']]))
+
+ activities.append({"name": name,
+ "category": ""})
+
+ return activities
+
+ def __get_gtg_connection(self):
+ bus = dbus.SessionBus()
+ if self.__gtg_connection and bus.name_has_owner("org.GTG"):
+ return self.__gtg_connection
+
+ if bus.name_has_owner("org.GTG"):
+ self.__gtg_connection = dbus.Interface(bus.get_object('org.GTG', '/org/GTG'),
+ dbus_interface='org.GTG')
+ return self.__gtg_connection
+ else:
+ return None
+
+
+
+def get_eds_tasks():
+ try:
+ sources = ecal.list_task_sources()
+ tasks = []
+ if not sources:
+ # BUG - http://bugzilla.gnome.org/show_bug.cgi?id=546825
+ sources = [('default', 'default')]
+
+ for source in sources:
+ category = source[0]
+
+ data = ecal.open_calendar_source(source[1], ecal.CAL_SOURCE_TYPE_TODO)
+ if data:
+ for task in data.get_all_objects():
+ if task.get_status() in [ecal.ICAL_STATUS_NONE, ecal.ICAL_STATUS_INPROCESS]:
+ tasks.append({'name': task.get_summary(), 'category' : category})
+ return tasks
+ except Exception, e:
+ logging.warn(e)
+ return []
diff --git a/win32/hamster/idle.py b/win32/hamster/idle.py
new file mode 100644
index 0000000..f305db7
--- /dev/null
+++ b/win32/hamster/idle.py
@@ -0,0 +1,138 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008 Patryk Zawadzki <patrys at pld-linux.org>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+#
+#import logging
+#import dbus
+#import datetime as dt
+#import gobject
+# TODO: Create a Windows alternative
+#class DbusIdleListener(gobject.GObject):
+# """
+# Listen for idleness coming from org.gnome.ScreenSaver
+#
+# Monitors org.gnome.ScreenSaver for idleness. There are two types,
+# implicit (due to inactivity) and explicit (lock screen), that need to be
+# handled differently. An implicit idle state should subtract the
+# time-to-become-idle (as specified in the gconf) from the last activity,
+# but an explicit idle state should not.
+#
+# The signals are inspected for the "ActiveChanged" and "Lock"
+# members coming from the org.gnome.ScreenSaver interface and the
+# and is_screen_locked members are updated appropriately.
+# """
+# __gsignals__ = {
+# "idle-changed": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
+# }
+# def __init__(self):
+# gobject.GObject.__init__(self)
+#
+# self.screensaver_uri = "org.gnome.ScreenSaver"
+# self.screen_locked = False
+# self.idle_from = None
+# self.timeout_minutes = 0 # minutes after session is considered idle
+# self.idle_was_there = False # a workaround variable for pre 2.26
+#
+# try:
+# self.bus = dbus.SessionBus()
+# except:
+# return 0
+# # Listen for chatter on the screensaver interface.
+# # We cannot just add additional match strings to narrow down
+# # what we hear because match strings are ORed together.
+# # E.g., if we were to make the match string
+# # "interface='org.gnome.ScreenSaver', type='method_call'",
+# # we would not get only screensaver's method calls, rather
+# # we would get anything on the screensaver interface, as well
+# # as any method calls on *any* interface. Therefore the
+# # bus_inspector needs to do some additional filtering.
+# self.bus.add_match_string_non_blocking("interface='%s'" %
+# self.screensaver_uri)
+# self.bus.add_message_filter(self.bus_inspector)
+#
+#
+# def bus_inspector(self, bus, message):
+# """
+# Inspect the bus for screensaver messages of interest
+# """
+#
+# # We only care about stuff on this interface. We did filter
+# # for it above, but even so we still hear from ourselves
+# # (hamster messages).
+# if message.get_interface() != self.screensaver_uri:
+# return True
+#
+# member = message.get_member()
+#
+# if member in ("SessionIdleChanged", "ActiveChanged"):
+# logging.debug("%s -> %s" % (member, message.get_args_list()))
+#
+# idle_state = message.get_args_list()[0]
+# if idle_state:
+# self.idle_from = dt.datetime.now()
+#
+# # from gnome screensaver 2.24 to 2.28 they have switched
+# # configuration keys and signal types.
+# # luckily we can determine key by signal type
+# if member == "SessionIdleChanged":
+# delay_key = "/apps/gnome-screensaver/idle_delay"
+# else:
+# delay_key = "/desktop/gnome/session/idle_delay"
+#
+# client = gconf.client_get_default()
+# self.timeout_minutes = client.get_int(delay_key)
+#
+# else:
+# self.screen_locked = False
+# self.idle_from = None
+#
+# if member == "ActiveChanged":
+# # ActiveChanged comes before SessionIdleChanged signal
+# # as a workaround for pre 2.26, we will wait a second - maybe
+# # SessionIdleChanged signal kicks in
+# def dispatch_active_changed(idle_state):
+# if not self.idle_was_there:
+# self.emit('idle-changed', idle_state)
+# self.idle_was_there = False
+#
+# gobject.timeout_add_seconds(1, dispatch_active_changed, idle_state)
+#
+# else:
+# # dispatch idle status change to interested parties
+# self.idle_was_there = True
+# self.emit('idle-changed', idle_state)
+#
+# elif member == "Lock":
+# # in case of lock, lock signal will be sent first, followed by
+# # ActiveChanged and SessionIdle signals
+# logging.debug("Screen Lock Requested")
+# self.screen_locked = True
+#
+# return
+#
+#
+# def getIdleFrom(self):
+# if not self.idle_from:
+# return dt.datetime.now()
+#
+# if self.screen_locked:
+# return self.idle_from
+# else:
+# # Only subtract idle time from the running task when
+# # idleness is due to time out, not a screen lock.
+# return self.idle_from - dt.timedelta(minutes = self.timeout_minutes)
diff --git a/win32/hamster/lib/__init__.py b/win32/hamster/lib/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/win32/hamster/lib/charting.py b/win32/hamster/lib/charting.py
new file mode 100644
index 0000000..e5d68d6
--- /dev/null
+++ b/win32/hamster/lib/charting.py
@@ -0,0 +1,346 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk, gobject
+import pango
+import datetime as dt
+import time
+import graphics, stuff
+import locale
+
+class Bar(graphics.Sprite):
+ def __init__(self, key, value, normalized, label_color):
+ graphics.Sprite.__init__(self, cache_as_bitmap=True)
+ self.key, self.value, self.normalized = key, value, normalized
+
+ self.height = 0
+ self.width = 20
+ self.interactive = True
+ self.fill = None
+
+ self.label = graphics.Label(value, size=8, color=label_color)
+ self.label_background = graphics.Rectangle(self.label.width + 4, self.label.height + 4, 4, visible=False)
+ self.add_child(self.label_background)
+ self.add_child(self.label)
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ # invisible rectangle for the mouse, covering whole area
+ self.graphics.rectangle(0, 0, self.width, self.height)
+ self.graphics.fill("#000", 0)
+
+ size = round(self.width * self.normalized)
+
+ self.graphics.rectangle(0, 0, size, self.height, 3)
+ self.graphics.rectangle(0, 0, min(size, 3), self.height)
+ self.graphics.fill(self.fill)
+
+ self.label.y = (self.height - self.label.height) / 2
+
+ horiz_offset = min(10, self.label.y * 2)
+
+ if self.label.width < size - horiz_offset * 2:
+ #if it fits in the bar
+ self.label.x = size - self.label.width - horiz_offset
+ else:
+ self.label.x = size + 3
+
+ self.label_background.x = self.label.x - 2
+ self.label_background.y = self.label.y - 2
+
+
+class Chart(graphics.Scene):
+ __gsignals__ = {
+ "bar-clicked": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )),
+ }
+
+ def __init__(self, max_bar_width = 20, legend_width = 70, value_format = "%.2f", interactive = True):
+ graphics.Scene.__init__(self)
+
+ self.selected_keys = [] # keys of selected bars
+
+ self.bars = []
+ self.labels = []
+ self.data = None
+
+ self.max_width = max_bar_width
+ self.legend_width = legend_width
+ self.value_format = value_format
+ self.graph_interactive = interactive
+
+ self.plot_area = graphics.Sprite(interactive = False)
+ self.add_child(self.plot_area)
+
+ self.bar_color, self.label_color = None, None
+
+ self.connect("on-enter-frame", self.on_enter_frame)
+
+ if self.graph_interactive:
+ self.connect("on-mouse-over", self.on_mouse_over)
+ self.connect("on-mouse-out", self.on_mouse_out)
+ self.connect("on-click", self.on_click)
+
+ def find_colors(self):
+ bg_color = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+ self.bar_color = self.colors.contrast(bg_color, 30)
+
+ # now for the text - we want reduced contrast for relaxed visuals
+ fg_color = self.get_style().fg[gtk.STATE_NORMAL].to_string()
+ self.label_color = self.colors.contrast(fg_color, 80)
+
+
+ def on_mouse_over(self, scene, bar):
+ if bar.key not in self.selected_keys:
+ bar.fill = self.get_style().base[gtk.STATE_PRELIGHT].to_string()
+
+ def on_mouse_out(self, scene, bar):
+ if bar.key not in self.selected_keys:
+ bar.fill = self.bar_color
+
+ def on_click(self, scene, event, clicked_bar):
+ if not clicked_bar: return
+ self.emit("bar-clicked", clicked_bar.key)
+
+ def plot(self, keys, data):
+ self.data = data
+
+ bars = dict([(bar.key, bar.normalized) for bar in self.bars])
+
+ max_val = float(max(data or [0]))
+
+ new_bars, new_labels = [], []
+ for key, value in zip(keys, data):
+ if max_val:
+ normalized = value / max_val
+ else:
+ normalized = 0
+ bar = Bar(key, locale.format(self.value_format, value), normalized, self.label_color)
+ bar.interactive = self.graph_interactive
+
+ if key in bars:
+ bar.normalized = bars[key]
+ self.tweener.add_tween(bar, normalized=normalized)
+ new_bars.append(bar)
+
+ label = graphics.Label(stuff.escape_pango(key), size = 8, alignment = pango.ALIGN_RIGHT)
+ new_labels.append(label)
+
+
+ self.plot_area.remove_child(*self.bars)
+ self.remove_child(*self.labels)
+
+ self.bars, self.labels = new_bars, new_labels
+ self.add_child(*self.labels)
+ self.plot_area.add_child(*self.bars)
+
+ self.show()
+ self.redraw()
+
+
+ def on_enter_frame(self, scene, context):
+ # adjust sizes and positions on redraw
+
+ legend_width = self.legend_width
+ if legend_width < 1: # allow fractions
+ legend_width = int(self.width * legend_width)
+
+ self.find_colors()
+
+ self.plot_area.y = 0
+ self.plot_area.height = self.height - self.plot_area.y
+ self.plot_area.x = legend_width + 8
+ self.plot_area.width = self.width - self.plot_area.x
+
+ y = 0
+ for i, (label, bar) in enumerate(zip(self.labels, self.bars)):
+ bar_width = min(round((self.plot_area.height - y) / (len(self.bars) - i)), self.max_width)
+ bar.y = y
+ bar.height = bar_width
+ bar.width = self.plot_area.width
+
+ if bar.key in self.selected_keys:
+ bar.fill = self.get_style().bg[gtk.STATE_SELECTED].to_string()
+
+ if bar.normalized == 0:
+ bar.label.color = self.get_style().fg[gtk.STATE_SELECTED].to_string()
+ bar.label_background.fill = self.get_style().bg[gtk.STATE_SELECTED].to_string()
+ bar.label_background.visible = True
+ else:
+ bar.label_background.visible = False
+ if bar.label.x < round(bar.width * bar.normalized):
+ bar.label.color = self.get_style().fg[gtk.STATE_SELECTED].to_string()
+ else:
+ bar.label.color = self.label_color
+
+ if not bar.fill:
+ bar.fill = self.bar_color
+
+ bar.label.color = self.label_color
+ bar.label_background.fill = None
+
+ label.y = y + (bar_width - label.height) / 2 + self.plot_area.y
+
+ label.width = legend_width
+ if not label.color:
+ label.color = self.label_color
+
+ y += bar_width + 1
+
+
+
+
+class HorizontalDayChart(graphics.Scene):
+ """Pretty much a horizontal bar chart, except for values it expects tuple
+ of start and end time, and the whole thing hangs in air"""
+ def __init__(self, max_bar_width, legend_width):
+ graphics.Scene.__init__(self)
+ self.max_bar_width = max_bar_width
+ self.legend_width = legend_width
+ self.start_time, self.end_time = None, None
+ self.connect("on-enter-frame", self.on_enter_frame)
+
+ def plot_day(self, keys, data, start_time = None, end_time = None):
+ self.keys, self.data = keys, data
+ self.start_time, self.end_time = start_time, end_time
+ self.show()
+ self.redraw()
+
+ def on_enter_frame(self, scene, context):
+ g = graphics.Graphics(context)
+
+ rowcount, keys = len(self.keys), self.keys
+
+ start_hour = 0
+ if self.start_time:
+ start_hour = self.start_time
+ end_hour = 24 * 60
+ if self.end_time:
+ end_hour = self.end_time
+
+
+ # push graph to the right, so it doesn't overlap
+ legend_width = self.legend_width or self.longest_label(keys)
+
+ self.graph_x = legend_width
+ self.graph_x += 8 #add another 8 pixes of padding
+
+ self.graph_width = self.width - self.graph_x
+
+ # TODO - should handle the layout business in graphics
+ self.layout = context.create_layout()
+ default_font = pango.FontDescription(self.get_style().font_desc.to_string())
+ default_font.set_size(8 * pango.SCALE)
+ self.layout.set_font_description(default_font)
+
+
+ #on the botttom leave some space for label
+ self.layout.set_text("1234567890:")
+ label_w, label_h = self.layout.get_pixel_size()
+
+ self.graph_y, self.graph_height = 0, self.height - label_h - 4
+
+ if not self.data: #if we have nothing, let's go home
+ return
+
+
+ positions = {}
+ y = 0
+ bar_width = min(self.graph_height / float(len(self.keys)), self.max_bar_width)
+ for i, key in enumerate(self.keys):
+ positions[key] = (y + self.graph_y, round(bar_width - 1))
+
+ y = y + round(bar_width)
+ bar_width = min(self.max_bar_width,
+ (self.graph_height - y) / float(max(1, len(self.keys) - i - 1)))
+
+
+
+ max_bar_size = self.graph_width - 15
+
+
+ # now for the text - we want reduced contrast for relaxed visuals
+ fg_color = self.get_style().fg[gtk.STATE_NORMAL].to_string()
+ label_color = self.colors.contrast(fg_color, 80)
+
+ self.layout.set_alignment(pango.ALIGN_RIGHT)
+ self.layout.set_ellipsize(pango.ELLIPSIZE_END)
+
+ # bars and labels
+ self.layout.set_width(legend_width * pango.SCALE)
+
+ factor = max_bar_size / float(end_hour - start_hour)
+
+ # determine bar color
+ bg_color = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+ base_color = self.colors.contrast(bg_color, 30)
+
+ for i, label in enumerate(keys):
+ g.set_color(label_color)
+
+ self.layout.set_text(label)
+ label_w, label_h = self.layout.get_pixel_size()
+
+ context.move_to(0, positions[label][0] + (positions[label][1] - label_h) / 2)
+ context.show_layout(self.layout)
+
+ if isinstance(self.data[i], list) == False:
+ self.data[i] = [self.data[i]]
+
+ for row in self.data[i]:
+ bar_x = round((row[0]- start_hour) * factor)
+ bar_size = round((row[1] - start_hour) * factor - bar_x)
+
+ g.fill_area(round(self.graph_x + bar_x),
+ positions[label][0],
+ bar_size,
+ positions[label][1],
+ base_color)
+
+ #white grid and scale values
+ self.layout.set_width(-1)
+
+ context.set_line_width(1)
+
+ pace = ((end_hour - start_hour) / 3) / 60 * 60
+ last_position = positions[keys[-1]]
+
+
+ grid_color = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+
+ for i in range(start_hour + 60, end_hour, pace):
+ x = round((i - start_hour) * factor)
+
+ minutes = i % (24 * 60)
+
+ self.layout.set_markup(dt.time(minutes / 60, minutes % 60).strftime("%H<small><sup>%M</sup></small>"))
+ label_w, label_h = self.layout.get_pixel_size()
+
+ context.move_to(self.graph_x + x - label_w / 2,
+ last_position[0] + last_position[1] + 4)
+ g.set_color(label_color)
+ context.show_layout(self.layout)
+
+
+ g.set_color(grid_color)
+ g.move_to(round(self.graph_x + x) + 0.5, self.graph_y)
+ g.line_to(round(self.graph_x + x) + 0.5,
+ last_position[0] + last_position[1])
+
+
+ context.stroke()
diff --git a/win32/hamster/lib/graphics.py b/win32/hamster/lib/graphics.py
new file mode 100644
index 0000000..5dcc6b7
--- /dev/null
+++ b/win32/hamster/lib/graphics.py
@@ -0,0 +1,1839 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2010 Toms Bauģis <toms.baugis at gmail.com>
+# Dual licensed under the MIT or GPL Version 2 licenses.
+# See http://github.com/tbaugis/hamster_experiments/blob/master/README.textile
+
+import math
+import datetime as dt
+import gtk, gobject
+
+import pango, cairo
+import re
+
+try:
+ import pytweener
+except: # we can also live without tweener. Scene.animate will not work
+ pytweener = None
+
+import colorsys
+from collections import deque
+
+if cairo.version in ('1.8.2', '1.8.4'):
+ # in these two cairo versions the matrix multiplication was flipped
+ # http://bugs.freedesktop.org/show_bug.cgi?id=19221
+ def cairo_matrix_multiply(matrix1, matrix2):
+ return matrix2 * matrix1
+else:
+ def cairo_matrix_multiply(matrix1, matrix2):
+ return matrix1 * matrix2
+
+
+class Colors(object):
+ hex_color_normal = re.compile("#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})")
+ hex_color_short = re.compile("#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])")
+ hex_color_long = re.compile("#([a-fA-F0-9]{4})([a-fA-F0-9]{4})([a-fA-F0-9]{4})")
+
+ def parse(self, color):
+ assert color is not None
+
+ #parse color into rgb values
+ if isinstance(color, basestring):
+ match = self.hex_color_long.match(color)
+ if match:
+ color = [int(color, 16) / 65535.0 for color in match.groups()]
+ else:
+ match = self.hex_color_normal.match(color)
+ if match:
+ color = [int(color, 16) / 255.0 for color in match.groups()]
+ else:
+ match = self.hex_color_short.match(color)
+ color = [int(color + color, 16) / 255.0 for color in match.groups()]
+
+ elif isinstance(color, gtk.gdk.Color):
+ color = [color.red / 65535.0,
+ color.green / 65535.0,
+ color.blue / 65535.0]
+
+ else:
+ # otherwise we assume we have color components in 0..255 range
+ if color[0] > 1 or color[1] > 1 or color[2] > 1:
+ color = [c / 255.0 for c in color]
+
+ return color
+
+ def rgb(self, color):
+ return [c * 255 for c in self.parse(color)]
+
+ def gdk(self, color):
+ c = self.parse(color)
+ return gtk.gdk.Color(int(c[0] * 65535.0), int(c[1] * 65535.0), int(c[2] * 65535.0))
+
+ def is_light(self, color):
+ # tells you if color is dark or light, so you can up or down the
+ # scale for improved contrast
+ return colorsys.rgb_to_hls(*self.rgb(color))[1] > 150
+
+ def darker(self, color, step):
+ # returns color darker by step (where step is in range 0..255)
+ hls = colorsys.rgb_to_hls(*self.rgb(color))
+ return colorsys.hls_to_rgb(hls[0], hls[1] - step, hls[2])
+
+ def contrast(self, color, step):
+ """if color is dark, will return a lighter one, otherwise darker"""
+ hls = colorsys.rgb_to_hls(*self.rgb(color))
+ if self.is_light(color):
+ return colorsys.hls_to_rgb(hls[0], hls[1] - step, hls[2])
+ else:
+ return colorsys.hls_to_rgb(hls[0], hls[1] + step, hls[2])
+ # returns color darker by step (where step is in range 0..255)
+
+Colors = Colors() # this is a static class, so an instance will do
+
+
+class Graphics(object):
+ """If context is given upon contruction, will perform drawing
+ operations on context instantly. Otherwise queues up the drawing
+ instructions and performs them in passed-in order when _draw is called
+ with context.
+
+ Most of instructions are mapped to cairo functions by the same name.
+ Where there are differences, documenation is provided.
+
+ See http://cairographics.org/documentation/pycairo/2/reference/context.html
+ for detailed description of the cairo drawing functions.
+ """
+ def __init__(self, context = None):
+ self.context = context
+ self.colors = Colors # pointer to the color utilities instance
+ self.extents = None # bounds of the object, only if interactive
+ self.paths = None # paths for mouse hit checks
+ self._last_matrix = None
+ self.__new_instructions = [] # instruction set until it is converted into path-based instructions
+ self.__instruction_cache = []
+ self.cache_surface = None
+ self._cache_layout = None
+
+ def clear(self):
+ """clear all instructions"""
+ self.__new_instructions = []
+ self.__instruction_cache = []
+ self.paths = []
+
+ @staticmethod
+ def _stroke(context): context.stroke()
+ def stroke(self, color = None, alpha = 1):
+ if color or alpha < 1:self.set_color(color, alpha)
+ self._add_instruction(self._stroke,)
+
+ @staticmethod
+ def _fill(context): context.fill()
+ def fill(self, color = None, alpha = 1):
+ if color or alpha < 1:self.set_color(color, alpha)
+ self._add_instruction(self._fill,)
+
+ @staticmethod
+ def _mask(context, pattern): context.mask(pattern)
+ def mask(self, pattern):
+ self._add_instruction(self._mask, pattern)
+
+ @staticmethod
+ def _stroke_preserve(context): context.stroke_preserve()
+ def stroke_preserve(self, color = None, alpha = 1):
+ if color or alpha < 1:self.set_color(color, alpha)
+ self._add_instruction(self._stroke_preserve,)
+
+ @staticmethod
+ def _fill_preserve(context): context.fill_preserve()
+ def fill_preserve(self, color = None, alpha = 1):
+ if color or alpha < 1:self.set_color(color, alpha)
+ self._add_instruction(self._fill_preserve,)
+
+ @staticmethod
+ def _new_path(context): context.new_path()
+ def new_path(self):
+ self._add_instruction(self._new_path,)
+
+ @staticmethod
+ def _paint(context): context.paint()
+ def paint(self):
+ self._add_instruction(self._paint,)
+
+ @staticmethod
+ def _set_font_face(context, face): context.set_font_face(face)
+ def set_font_face(self, face):
+ self._add_instruction(self._set_font_face, face)
+
+ @staticmethod
+ def _set_font_size(context, size): context.set_font_size(size)
+ def set_font_size(self, size):
+ self._add_instruction(self._set_font_size, size)
+
+ @staticmethod
+ def _set_source(context, image):
+ context.set_source(image)
+ def set_source(self, image, x = 0, y = 0):
+ self._add_instruction(self._set_source, image)
+
+ @staticmethod
+ def _set_source_surface(context, surface, x, y):
+ context.set_source_surface(surface, x, y)
+ def set_source_surface(self, surface, x = 0, y = 0):
+ self._add_instruction(self._set_source_surface, surface, x, y)
+
+ @staticmethod
+ def _set_source_pixbuf(context, pixbuf, x, y):
+ context.set_source_pixbuf(pixbuf, x, y)
+ def set_source_pixbuf(self, pixbuf, x = 0, y = 0):
+ self._add_instruction(self._set_source_pixbuf, pixbuf, x, y)
+
+ @staticmethod
+ def _save_context(context): context.save()
+ def save_context(self):
+ self._add_instruction(self._save_context)
+
+ @staticmethod
+ def _restore_context(context): context.restore()
+ def restore_context(self):
+ self._add_instruction(self._restore_context)
+
+
+ @staticmethod
+ def _clip(context): context.clip()
+ def clip(self):
+ self._add_instruction(self._clip)
+
+ @staticmethod
+ def _translate(context, x, y): context.translate(x, y)
+ def translate(self, x, y):
+ self._add_instruction(self._translate, x, y)
+
+ @staticmethod
+ def _rotate(context, radians): context.rotate(radians)
+ def rotate(self, radians):
+ self._add_instruction(self._rotate, radians)
+
+ @staticmethod
+ def _move_to(context, x, y): context.move_to(x, y)
+ def move_to(self, x, y):
+ self._add_instruction(self._move_to, x, y)
+
+ @staticmethod
+ def _line_to(context, x, y): context.line_to(x, y)
+ def line_to(self, x, y = None):
+ if y is not None:
+ self._add_instruction(self._line_to, x, y)
+ elif isinstance(x, list) and y is None:
+ for x2, y2 in x:
+ self._add_instruction(self._line_to, x2, y2)
+
+
+ @staticmethod
+ def _rel_line_to(context, x, y): context.rel_line_to(x, y)
+ def rel_line_to(self, x, y = None):
+ if x and y:
+ self._add_instruction(self._rel_line_to, x, y)
+ elif isinstance(x, list) and y is None:
+ for x2, y2 in x:
+ self._add_instruction(self._rel_line_to, x2, y2)
+
+
+ @staticmethod
+ def _curve_to(context, x, y, x2, y2, x3, y3):
+ context.curve_to(x, y, x2, y2, x3, y3)
+ def curve_to(self, x, y, x2, y2, x3, y3):
+ """draw a curve. (x2, y2) is the middle point of the curve"""
+ self._add_instruction(self._curve_to, x, y, x2, y2, x3, y3)
+
+ @staticmethod
+ def _close_path(context): context.close_path()
+ def close_path(self):
+ self._add_instruction(self._close_path,)
+
+ @staticmethod
+ def _set_line_width(context, width):
+ context.set_line_width(width)
+ @staticmethod
+ def _set_dash(context, dash, dash_offset = 0):
+ context.set_dash(dash, dash_offset)
+
+ def set_line_style(self, width = None, dash = None, dash_offset = 0):
+ """change width and dash of a line"""
+ if width is not None:
+ self._add_instruction(self._set_line_width, width)
+
+ if dash is not None:
+ self._add_instruction(self._set_dash, dash, dash_offset)
+
+ def _set_color(self, context, r, g, b, a):
+ if a < 1:
+ context.set_source_rgba(r, g, b, a)
+ else:
+ context.set_source_rgb(r, g, b)
+
+ def set_color(self, color, alpha = 1):
+ """set active color. You can use hex colors like "#aaa", or you can use
+ normalized RGB tripplets (where every value is in range 0..1), or
+ you can do the same thing in range 0..65535.
+ also consider skipping this operation and specify the color on stroke and
+ fill.
+ """
+ color = self.colors.parse(color) # parse whatever we have there into a normalized triplet
+ if len(color) == 4 and alpha is None:
+ alpha = color[3]
+ r, g, b = color[:3]
+ self._add_instruction(self._set_color, r, g, b, alpha)
+
+ @staticmethod
+ def _arc(context, x, y, radius, start_angle, end_angle):
+ context.arc(x, y, radius, start_angle, end_angle)
+ def arc(self, x, y, radius, start_angle, end_angle):
+ """draw arc going counter-clockwise from start_angle to end_angle"""
+ self._add_instruction(self._arc, x, y, radius, start_angle, end_angle)
+
+ def circle(self, x, y, radius):
+ """draw circle"""
+ self._add_instruction(self._arc, x, y, radius, 0, math.pi * 2)
+
+ def ellipse(self, x, y, width, height, edges = None):
+ """draw 'perfect' ellipse, opposed to squashed circle. works also for
+ equilateral polygons"""
+ # the automatic edge case is somewhat arbitrary
+ steps = edges or max((32, width, height)) / 2
+
+ angle = 0
+ step = math.pi * 2 / steps
+ points = []
+ while angle < math.pi * 2:
+ points.append((width / 2.0 * math.cos(angle),
+ height / 2.0 * math.sin(angle)))
+ angle += step
+
+ min_x = min((point[0] for point in points))
+ min_y = min((point[1] for point in points))
+
+ self.move_to(points[0][0] - min_x + x, points[0][1] - min_y + y)
+ for p_x, p_y in points:
+ self.line_to(p_x - min_x + x, p_y - min_y + y)
+ self.line_to(points[0][0] - min_x + x, points[0][1] - min_y + y)
+
+
+ @staticmethod
+ def _arc_negative(context, x, y, radius, start_angle, end_angle):
+ context.arc_negative(x, y, radius, start_angle, end_angle)
+ def arc_negative(self, x, y, radius, start_angle, end_angle):
+ """draw arc going clockwise from start_angle to end_angle"""
+ self._add_instruction(self._arc_negative, x, y, radius, start_angle, end_angle)
+
+ @staticmethod
+ def _rounded_rectangle(context, x, y, x2, y2, corner_radius):
+ half_corner = corner_radius / 2
+
+ context.move_to(x + corner_radius, y)
+ context.line_to(x2 - corner_radius, y)
+ context.curve_to(x2 - half_corner, y, x2, y + half_corner, x2, y + corner_radius)
+ context.line_to(x2, y2 - corner_radius)
+ context.curve_to(x2, y2 - half_corner, x2 - half_corner, y2, x2 - corner_radius, y2)
+ context.line_to(x + corner_radius, y2)
+ context.curve_to(x + half_corner, y2, x, y2 - half_corner, x, y2 - corner_radius)
+ context.line_to(x, y + corner_radius)
+ context.curve_to(x, y + half_corner, x + half_corner, y, x + corner_radius, y)
+
+ @staticmethod
+ def _rectangle(context, x, y, w, h): context.rectangle(x, y, w, h)
+ def rectangle(self, x, y, width, height, corner_radius = 0):
+ "draw a rectangle. if corner_radius is specified, will draw rounded corners"
+ if corner_radius <= 0:
+ self._add_instruction(self._rectangle, x, y, width, height)
+ return
+
+ # make sure that w + h are larger than 2 * corner_radius
+ corner_radius = min(corner_radius, min(width, height) / 2)
+ x2, y2 = x + width, y + height
+ self._add_instruction(self._rounded_rectangle, x, y, x2, y2, corner_radius)
+
+ def fill_area(self, x, y, width, height, color, opacity = 1):
+ """fill rectangular area with specified color"""
+ self.rectangle(x, y, width, height)
+ self.fill(color, opacity)
+
+
+ def fill_stroke(self, fill = None, stroke = None, line_width = None):
+ """fill and stroke the drawn area in one go"""
+ if line_width: self.set_line_style(line_width)
+
+ if fill and stroke:
+ self.fill_preserve(fill)
+ elif fill:
+ self.fill(fill)
+
+ if stroke:
+ self.stroke(stroke)
+
+
+ @staticmethod
+ def _show_layout(context, layout, text, font_desc, alignment, width, wrap, ellipsize):
+ layout.set_font_description(font_desc)
+ layout.set_markup(text)
+ layout.set_width(width or -1)
+ layout.set_alignment(alignment)
+
+ if width > 0:
+ if wrap is not None:
+ layout.set_wrap(wrap)
+ else:
+ layout.set_ellipsize(ellipsize or pango.ELLIPSIZE_END)
+
+ context.show_layout(layout)
+
+ def create_layout(self, size = None):
+ """utility function to create layout with the default font. Size and
+ alignment parameters are shortcuts to according functions of the
+ pango.Layout"""
+ if not self.context:
+ # TODO - this is rather sloppy as far as exception goes
+ # should explain better
+ raise "Can not create layout without existing context!"
+
+ layout = self.context.create_layout()
+ font_desc = pango.FontDescription(gtk.Style().font_desc.to_string())
+ if size: font_desc.set_size(size * pango.SCALE)
+
+ layout.set_font_description(font_desc)
+ return layout
+
+
+ def show_label(self, text, size = None, color = None):
+ """display text with system's default font"""
+ font_desc = pango.FontDescription(gtk.Style().font_desc.to_string())
+ if color: self.set_color(color)
+ if size: font_desc.set_size(size * pango.SCALE)
+ self.show_layout(text, font_desc)
+
+
+ @staticmethod
+ def _show_text(context, text): context.show_text(text)
+ def show_text(self, text):
+ self._add_instruction(self._show_text, text)
+
+ @staticmethod
+ def _text_path(context, text): context.text_path(text)
+ def text_path(self, text):
+ """this function is most likely to change"""
+ self._add_instruction(self._text_path, text)
+
+ def show_layout(self, text, font_desc, alignment = pango.ALIGN_LEFT, width = -1, wrap = None, ellipsize = None):
+ """display text. font_desc is string of pango font description
+ often handier than calling this function directly, is to create
+ a class:Label object
+ """
+ layout = self._cache_layout = self._cache_layout or gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0))).create_layout()
+
+ self._add_instruction(self._show_layout, layout, text, font_desc, alignment, width, wrap, ellipsize)
+
+ def _add_instruction(self, function, *params):
+ if self.context:
+ function(self.context, *params)
+ else:
+ self.paths = None
+ self.__new_instructions.append((function, params))
+
+
+ def _draw(self, context, opacity):
+ """draw accumulated instructions in context"""
+
+ # if we have been moved around, we should update bounds
+ fresh_draw = self.__new_instructions and len(self.__new_instructions) > 0
+ if fresh_draw: #new stuff!
+ self.paths = []
+ self.__instruction_cache = self.__new_instructions
+ self.__new_instructions = []
+ else:
+ if not self.__instruction_cache:
+ return
+
+ for instruction, args in self.__instruction_cache:
+ if fresh_draw and instruction in (self._new_path, self._stroke, self._fill, self._clip):
+ self.paths.append(context.copy_path())
+
+ if opacity < 1 and instruction == self._set_color:
+ self._set_color(context, args[0], args[1], args[2], args[3] * opacity)
+ elif opacity < 1 and instruction == self._paint:
+ context.paint_with_alpha(opacity)
+ else:
+ instruction(context, *args)
+
+
+
+ def _draw_as_bitmap(self, context, opacity):
+ """
+ instead of caching paths, this function caches the whole drawn thing
+ use cache_as_bitmap on sprite to enable this mode
+ """
+ matrix = context.get_matrix()
+ matrix_changed = matrix != self._last_matrix
+ new_instructions = len(self.__new_instructions) > 0
+
+ if new_instructions or matrix_changed:
+ if new_instructions:
+ self.__instruction_cache = list(self.__new_instructions)
+ self.__new_instructions = deque()
+
+ self.paths = deque()
+ self.extents = None
+
+ if not self.__instruction_cache:
+ # no instructions - nothing to do
+ return
+
+ # instructions that end path
+ path_end_instructions = (self._new_path, self._clip, self._stroke, self._fill, self._stroke_preserve, self._fill_preserve)
+
+ # measure the path extents so we know the size of cache surface
+ # also to save some time use the context to paint for the first time
+ extents = gtk.gdk.Rectangle()
+ for instruction, args in self.__instruction_cache:
+ if instruction in path_end_instructions:
+ self.paths.append(context.copy_path())
+
+ ext = context.path_extents()
+ ext = gtk.gdk.Rectangle(int(ext[0]), int(ext[1]),
+ int(ext[2]-ext[0]), int(ext[3]-ext[1]))
+ if extents.width and extents.height:
+ if ext:
+ extents = extents.union(ext)
+ else:
+ extents = ext
+
+
+ if instruction in (self._set_source_pixbuf, self._set_source_surface):
+ # draw a rectangle around the pathless instructions so that the extents are correct
+ pixbuf = args[0]
+ x = args[1] if len(args) > 1 else 0
+ y = args[2] if len(args) > 2 else 0
+ self._rectangle(context, x, y, pixbuf.get_width(), pixbuf.get_height())
+
+ if instruction == self._paint and opacity < 1:
+ context.paint_with_alpha(opacity)
+ elif instruction == self._set_color and opacity < 1:
+ self._set_color(context, args[0], args[1], args[2], args[3] * opacity)
+ else:
+ instruction(context, *args)
+
+
+ if instruction not in path_end_instructions: # last one
+ self.paths.append(context.copy_path())
+
+ ext = context.path_extents()
+ if any((extents.x, extents.y, extents.width, extents.height)):
+ if ext:
+ extents = extents.union(gtk.gdk.Rectangle(int(ext[0]), int(ext[1]),
+ int(ext[2]-ext[0]), int(ext[3]-ext[1])))
+ else:
+ extents = ext
+
+
+ # avoid re-caching if we have just moved
+ just_transforms = new_instructions == False and \
+ matrix and self._last_matrix \
+ and all([matrix[i] == self._last_matrix[i] for i in range(4)])
+
+ # TODO - this does not look awfully safe
+ extents.x += matrix[4]
+ extents.y += matrix[5]
+ self.extents = extents
+
+ if not just_transforms:
+
+ # now draw the instructions on the caching surface
+ w = int(extents.width) + 1
+ h = int(extents.height) + 1
+ self.cache_surface = context.get_target().create_similar(cairo.CONTENT_COLOR_ALPHA, w, h)
+ ctx = gtk.gdk.CairoContext(cairo.Context(self.cache_surface))
+ ctx.translate(-extents.x, -extents.y)
+
+ ctx.transform(matrix)
+ for instruction, args in self.__instruction_cache:
+ instruction(ctx, *args)
+
+ self._last_matrix = matrix
+ else:
+ context.save()
+ context.identity_matrix()
+ context.translate(self.extents.x, self.extents.y)
+ context.set_source_surface(self.cache_surface)
+ if opacity < 1:
+ context.paint_with_alpha(opacity)
+ else:
+ context.paint()
+ context.restore()
+
+
+
+
+
+class Sprite(gtk.Object):
+ """The Sprite class is a basic display list building block: a display list
+ node that can display graphics and can also contain children.
+ Once you have created the sprite, use Scene's add_child to add it to
+ scene
+ """
+
+ __gsignals__ = {
+ "on-mouse-over": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ "on-mouse-out": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ "on-mouse-down": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-mouse-up": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-drag-start": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-drag": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-drag-finish": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-render": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ())
+ }
+
+ transformation_flags = set(('x', 'y', 'rotation', 'scale_x', 'scale_y', 'pivot_x', 'pivot_y'))
+ graphics_unrelated_flags = set(('drag_x', 'drag_y', '_matrix', 'sprites', '_stroke_context'))
+ dirty_flags = set(('opacity', 'visible', 'z_order'))
+
+ def __init__(self, x = 0, y = 0,
+ opacity = 1, visible = True,
+ rotation = 0, pivot_x = 0, pivot_y = 0,
+ scale_x = 1, scale_y = 1,
+ interactive = False, draggable = False,
+ z_order = 0, mouse_cursor = None,
+ cache_as_bitmap = False, snap_to_pixel = True):
+ gtk.Object.__init__(self)
+
+ #: list of children sprites. Use :func:`add_child` to add sprites
+ self.sprites = []
+
+ #: instance of :ref:`graphics` for this sprite
+ self.graphics = Graphics()
+
+ #: boolean denoting whether the sprite responds to mouse events
+ self.interactive = interactive
+
+ #: boolean marking if sprite can be automatically dragged
+ self.draggable = draggable
+
+ #: relative x coordinate of the sprites' rotation point
+ self.pivot_x = pivot_x
+
+ #: relative y coordinates of the sprites' rotation point
+ self.pivot_y = pivot_y
+
+ #: sprite opacity
+ self.opacity = opacity
+
+ #: boolean visibility flag
+ self.visible = visible
+
+ #: pointer to parent :class:`Sprite` or :class:`Scene`
+ self.parent = None
+
+ #: sprite coordinates
+ self.x, self.y = x, y
+
+ #: rotation of the sprite in radians (use :func:`math.degrees` to convert to degrees if necessary)
+ self.rotation = rotation
+
+ #: scale X
+ self.scale_x = scale_x
+
+ #: scale Y
+ self.scale_y = scale_y
+
+ #: drawing order between siblings. The one with the highest z_order will be on top.
+ self.z_order = z_order
+
+ #: mouse-over cursor of the sprite. See :meth:`Scene.mouse_cursor`
+ #: for possible values
+ self.mouse_cursor = mouse_cursor
+
+ #: x position of the cursor within mouse upon drag. change this value
+ #: in on-drag-start to adjust drag point
+ self.drag_x = 0
+
+ #: y position of the cursor within mouse upon drag. change this value
+ #: in on-drag-start to adjust drag point
+ self.drag_y = 0
+
+ #: Whether the sprite should be cached as a bitmap. Default: true
+ #: Generally good when you have many static sprites
+ self.cache_as_bitmap = cache_as_bitmap
+
+ #: Should the sprite coordinates always rounded to full pixel. Default: true
+ #: Mostly this is good for performance but in some cases that can lead
+ #: to rounding errors in positioning.
+ self.snap_to_pixel = snap_to_pixel
+
+ self.__dict__["_sprite_dirty"] = True # flag that indicates that the graphics object of the sprite should be rendered
+ self.__dict__["_sprite_moved"] = True # flag that indicates that the graphics object of the sprite should be rendered
+
+ self._matrix = None
+ self._prev_parent_matrix = None
+
+ self._extents = None
+ self._prev_extents = None
+
+
+
+
+ def __setattr__(self, name, val):
+ try:
+ setter = self.__class__.__dict__[name].__set__
+ except (AttributeError, KeyError):
+ if self.__dict__.get(name, "hamster_graphics_no_value_really") == val:
+ return
+ self.__dict__[name] = val
+
+ if name == 'parent':
+ self._prev_parent_matrix = None
+ return
+
+ if name == '_prev_parent_matrix':
+ self.__dict__['_extents'] = None
+ for sprite in self.sprites:
+ sprite._prev_parent_matrix = None
+ return
+
+
+ if name in self.transformation_flags:
+ self.__dict__['_matrix'] = None
+ self.__dict__['_extents'] = None
+ for sprite in self.sprites:
+ sprite._prev_parent_matrix = None
+
+
+ if name not in (self.transformation_flags ^ self.graphics_unrelated_flags ^ self.dirty_flags):
+ self.__dict__["_sprite_dirty"] = True
+ self.__dict__['_extents'] = None
+
+ if name == 'opacity' and self.__dict__.get("cache_as_bitmap") and self.__dict__.get("graphics"):
+ # invalidating cache for the bitmap version as that paints opacity in the image
+ self.graphics._last_matrix = None
+ elif name == 'interactive' and self.__dict__.get("graphics"):
+ # when suddenly item becomes interactive, it well can be that the extents had not been
+ # calculated
+ self.graphics._last_matrix = None
+ elif name == 'z_order' and self.__dict__.get('parent'):
+ self.parent._sort()
+
+ else:
+ setter(self, val)
+
+
+ self.redraw()
+
+ def _sort(self):
+ """sort sprites by z_order"""
+ self.sprites = sorted(self.sprites, key=lambda sprite:sprite.z_order)
+
+ def add_child(self, *sprites):
+ """Add child sprite. Child will be nested within parent"""
+ for sprite in sprites:
+ if sprite == self:
+ raise Exception("trying to add sprite to itself")
+ if sprite.parent:
+ sprite.x, sprite.y = self.from_scene_coords(*sprite.to_scene_coords())
+ sprite.parent.remove_child(sprite)
+
+ self.sprites.append(sprite)
+ sprite.parent = self
+ self._sort()
+
+
+ def remove_child(self, *sprites):
+ for sprite in sprites:
+ self.sprites.remove(sprite)
+ sprite.parent = None
+
+ def bring_to_front(self):
+ """adjusts sprite's z-order so that the sprite is on top of it's
+ siblings"""
+ if not self.parent:
+ return
+ self.z_order = self.parent.sprites[-1].z_order + 1
+
+ def send_to_back(self):
+ """adjusts sprite's z-order so that the sprite is behind it's
+ siblings"""
+ if not self.parent:
+ return
+ self.z_order = self.parent.sprites[0].z_order - 1
+
+
+ def get_extents(self):
+ """measure the extents of the sprite's graphics. if context is provided
+ will use that to draw the paths"""
+ if self._extents:
+ return self._extents
+
+ context = cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0))
+ context.transform(self.get_matrix())
+
+ if not self.graphics.paths:
+ self.graphics._draw(context, 1)
+
+
+ if not self.graphics.paths:
+ return None
+
+
+ for path in self.graphics.paths:
+ context.append_path(path)
+ context.identity_matrix()
+
+ ext = context.path_extents()
+ ext = gtk.gdk.Rectangle(int(ext[0]), int(ext[1]),
+ int(ext[2] - ext[0]), int(ext[3] - ext[1]))
+
+ self.__dict__['_extents'] = ext
+ self._stroke_context = context
+ return ext
+
+
+
+
+ def check_hit(self, x, y):
+ """check if the given coordinates are inside the sprite's fill or stroke
+ path"""
+
+ extents = self.get_extents()
+
+ if not extents:
+ return False
+
+ if extents.x <= x <= extents.x + extents.width and extents.y <= y <= extents.y + extents.height:
+ return self._stroke_context.in_fill(x, y)
+ else:
+ return False
+
+ def get_scene(self):
+ """returns class:`Scene` the sprite belongs to"""
+ if hasattr(self, 'parent') and self.parent:
+ if isinstance(self.parent, Sprite) == False:
+ return self.parent
+ else:
+ return self.parent.get_scene()
+ return None
+
+ def redraw(self):
+ """queue redraw of the sprite. this function is called automatically
+ whenever a sprite attribute changes. sprite changes that happen
+ during scene redraw are ignored in order to avoid echoes.
+ Call scene.redraw() explicitly if you need to redraw in these cases.
+ """
+ scene = self.get_scene()
+ if scene and scene._redraw_in_progress == False:
+ self.parent.redraw()
+
+ def animate(self, duration = None, easing = None, on_complete = None, on_update = None, **kwargs):
+ """Request paretn Scene to Interpolate attributes using the internal tweener.
+ Specify sprite's attributes that need changing.
+ `duration` defaults to 0.4 seconds and `easing` to cubic in-out
+ (for others see pytweener.Easing class).
+
+ Example::
+ # tween some_sprite to coordinates (50,100) using default duration and easing
+ self.animate(x = 50, y = 100)
+ """
+ scene = self.get_scene()
+ if scene:
+ scene.animate(self, duration, easing, on_complete, on_update, **kwargs)
+ else:
+ for key, val in kwargs.items():
+ setattr(self, key, val)
+
+ def get_local_matrix(self):
+ if not self._matrix:
+ self._matrix = cairo.Matrix()
+
+ if self.snap_to_pixel:
+ self._matrix.translate(int(self.x) + int(self.pivot_x), int(self.y) + int(self.pivot_y))
+ else:
+ self._matrix.translate(self.x + self.pivot_x, self.y + self.pivot_y)
+
+ if self.rotation:
+ self._matrix.rotate(self.rotation)
+
+
+ if self.snap_to_pixel:
+ self._matrix.translate(int(-self.pivot_x), int(-self.pivot_y))
+ else:
+ self._matrix.translate(-self.pivot_x, -self.pivot_y)
+
+
+ if self.scale_x != 1 or self.scale_y != 1:
+ self._matrix.scale(self.scale_x, self.scale_y)
+
+ return cairo.Matrix() * self._matrix
+
+
+ def get_matrix(self):
+ """return sprite's current transformation matrix"""
+ if self.parent:
+ return cairo_matrix_multiply(self.get_local_matrix(),
+ (self._prev_parent_matrix or self.parent.get_matrix()))
+ else:
+ return self.get_local_matrix()
+
+
+ def from_scene_coords(self, x=0, y=0):
+ """Converts x, y given in the scene coordinates to sprite's local ones
+ coordinates"""
+ matrix = self.get_matrix()
+ matrix.invert()
+ return matrix.transform_point(x, y)
+
+ def to_scene_coords(self, x=0, y=0):
+ """Converts x, y from sprite's local coordinates to scene coordinates"""
+ return self.get_matrix().transform_point(x, y)
+
+ def _draw(self, context, opacity = 1, parent_matrix = None):
+ if self.visible is False:
+ return
+
+ if (self._sprite_dirty): # send signal to redo the drawing when sprite is dirty
+ self.__dict__['_extents'] = None
+ self.emit("on-render")
+ self.__dict__["_sprite_dirty"] = False
+
+
+ parent_matrix = parent_matrix or cairo.Matrix()
+
+ # cache parent matrix
+ self._prev_parent_matrix = parent_matrix
+
+ matrix = self.get_local_matrix()
+ context.save()
+ context.transform(matrix)
+
+
+ if self.cache_as_bitmap:
+ self.graphics._draw_as_bitmap(context, self.opacity * opacity)
+ else:
+ self.graphics._draw(context, self.opacity * opacity)
+
+ self.__dict__['_prev_extents'] = self._extents or self.get_extents()
+
+ for sprite in self.sprites:
+ sprite._draw(context, self.opacity * opacity, cairo_matrix_multiply(matrix, parent_matrix))
+
+
+ context.restore()
+ context.new_path() #forget about us
+
+
+class BitmapSprite(Sprite):
+ """Caches given image data in a surface similar to targets, which ensures
+ that drawing it will be quick and low on CPU.
+ Image data can be either :class:`cairo.ImageSurface` or :class:`gtk.gdk.Pixbuf`
+ """
+ def __init__(self, image_data = None, **kwargs):
+ Sprite.__init__(self, **kwargs)
+
+ self._image_width, self._image_height = None, None
+ #: image data
+ self.image_data = image_data
+
+ self._surface = None
+
+ @property
+ def height(self):
+ return self._image_height
+
+ @property
+ def width(self):
+ return self._image_width
+
+
+ def __setattr__(self, name, val):
+ Sprite.__setattr__(self, name, val)
+ if name == 'image_data':
+ self.__dict__['_surface'] = None
+ if self.image_data:
+ self.__dict__['_image_width'] = self.image_data.get_width()
+ self.__dict__['_image_height'] = self.image_data.get_height()
+
+ def _draw(self, context, opacity = 1, parent_matrix = None):
+ if self.image_data is None or self.width is None or self.height is None:
+ return
+
+ if not self._surface:
+ # caching image on surface similar to the target
+ surface = context.get_target().create_similar(cairo.CONTENT_COLOR_ALPHA,
+ self.width,
+ self.height)
+
+
+ local_context = gtk.gdk.CairoContext(cairo.Context(surface))
+ if isinstance(self.image_data, gtk.gdk.Pixbuf):
+ local_context.set_source_pixbuf(self.image_data, 0, 0)
+ else:
+ local_context.set_source_surface(self.image_data)
+ local_context.paint()
+
+ # add instructions with the resulting surface
+ self.graphics.clear()
+ self.graphics.rectangle(0, 0, self.width, self.height)
+ self.graphics.clip()
+ self.graphics.set_source_surface(surface)
+ self.graphics.paint()
+ self._surface = surface
+
+
+ Sprite._draw(self, context, opacity, parent_matrix)
+
+
+class Image(BitmapSprite):
+ """Displays image by path. Currently supports only PNG images."""
+ def __init__(self, path, **kwargs):
+ BitmapSprite.__init__(self, **kwargs)
+
+ #: path to the image
+ self.path = path
+
+ def __setattr__(self, name, val):
+ BitmapSprite.__setattr__(self, name, val)
+ if name == 'path': # load when the value is set to avoid penalty on render
+ self.image_data = cairo.ImageSurface.create_from_png(self.path)
+
+
+
+class Icon(BitmapSprite):
+ """Displays icon by name and size in the theme"""
+ def __init__(self, name, size=24, **kwargs):
+ BitmapSprite.__init__(self, **kwargs)
+ self.theme = gtk.icon_theme_get_default()
+
+ #: icon name from theme
+ self.name = name
+
+ #: icon size in pixels
+ self.size = size
+
+ def __setattr__(self, name, val):
+ BitmapSprite.__setattr__(self, name, val)
+ if name in ('name', 'size'): # no other reason to discard cache than just on path change
+ if self.__dict__.get('name') and self.__dict__.get('size'):
+ self.image_data = self.theme.load_icon(self.name, self.size, 0)
+ else:
+ self.image_data = None
+
+
+class Label(Sprite):
+ __gsignals__ = {
+ "on-change": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+ def __init__(self, text = "", size = 10, color = None,
+ alignment = pango.ALIGN_LEFT, font_face = None,
+ max_width = None, wrap = None, ellipsize = None,
+ outline_color = None, outline_width = 5,
+ **kwargs):
+ Sprite.__init__(self, **kwargs)
+ self.width, self.height = None, None
+
+
+ self._test_context = gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A8, 0, 0)))
+ self._test_layout = self._test_context.create_layout()
+
+
+ #: pango.FontDescription, default is the system's font
+ self.font_desc = pango.FontDescription(gtk.Style().font_desc.to_string())
+ self.font_desc.set_size(size * pango.SCALE)
+
+ #: color of label either as hex string or an (r,g,b) tuple
+ self.color = color
+
+ #: color for text outline (currently works only with a custom font face)
+ self.outline_color = outline_color
+
+ #: text outline thickness (currently works only with a custom font face)
+ self.outline_width = outline_width
+
+ self._bounds_width = None
+
+ #: wrapping method. Can be set to pango. [WRAP_WORD, WRAP_CHAR,
+ #: WRAP_WORD_CHAR]
+ self.wrap = wrap
+
+ #: Ellipsize mode. Can be set to pango. [ELLIPSIZE_NONE,
+ #: ELLIPSIZE_START, ELLIPSIZE_MIDDLE, ELLIPSIZE_END]
+ self.ellipsize = ellipsize
+
+ #: alignment. one of pango.[ALIGN_LEFT, ALIGN_RIGHT, ALIGN_CENTER]
+ self.alignment = alignment
+
+ #: label's `FontFace <http://www.cairographics.org/documentation/pycairo/2/reference/text.html#cairo.FontFace>`_
+ self.font_face = font_face
+
+ #: font size
+ self.size = size
+
+
+ #: maximum width of the label in pixels. if specified, the label
+ #: will be wrapped or ellipsized depending on the wrap and ellpisize settings
+ self.max_width = max_width
+
+ self._ascent = None # used to determine Y position for when we have a font face
+
+ self.__surface = None
+
+ #: label text
+ self.text = text
+
+ self._letter_sizes = {}
+ self._measures = {}
+
+
+ self.connect("on-render", self.on_render)
+
+ self.graphics_unrelated_flags = self.graphics_unrelated_flags ^ set(("_letter_sizes", "__surface", "_ascent", "_bounds_width", "_measures"))
+
+
+ def __setattr__(self, name, val):
+ if self.__dict__.get(name, "hamster_graphics_no_value_really") != val:
+ if name == "width" and val and self.__dict__.get('_bounds_width') and val * pango.SCALE == self.__dict__['_bounds_width']:
+ return
+
+ Sprite.__setattr__(self, name, val)
+
+
+ if name == "width":
+ # setting width means consumer wants to contrain the label
+ if val is None or val == -1:
+ self.__dict__['_bounds_width'] = None
+ else:
+ self.__dict__['_bounds_width'] = val * pango.SCALE
+
+ if name in ("width", "text", "size", "font_desc", "wrap", "ellipsize", "max_width"):
+ self._measures = {}
+ # avoid chicken and egg
+ if hasattr(self, "text") and hasattr(self, "size") and hasattr(self, "font_face"):
+ self.__dict__['width'], self.__dict__['height'], self.__dict__['_ascent'] = self.measure(self.text)
+
+ if name in("font_desc", "size"):
+ self._letter_sizes = {}
+
+ if name == 'text':
+ self.emit('on-change')
+
+
+ def _wrap(self, text):
+ """wrapping text ourselves when we can't use pango"""
+ if not text:
+ return [], 0
+
+ context = self._test_context
+ context.set_font_face(self.font_face)
+ context.set_font_size(self.size)
+
+
+ if (not self._bounds_width and not self.max_width) or self.wrap is None:
+ return [(text, context.text_extents(text)[4])], context.font_extents()[2]
+
+
+ width = self.max_width or self.width
+
+ letters = {}
+ # measure individual letters
+ if self.wrap in (pango.WRAP_CHAR, pango.WRAP_WORD_CHAR):
+ letters = set(unicode(text))
+ sizes = [self._letter_sizes.setdefault(letter, context.text_extents(letter)[4]) for letter in letters]
+ letters = dict(zip(letters, sizes))
+
+
+ line = ""
+ lines = []
+ running_width = 0
+
+ if self.wrap in (pango.WRAP_WORD, pango.WRAP_WORD_CHAR):
+ # if we wrap by word then we split the whole thing in words
+ # and stick together while they fit. in case if the word does not
+ # fit at all, we break it in pieces
+ while text:
+ fragment, fragment_length = "", 0
+
+ word = re.search("\s", text)
+ if word:
+ fragment = text[:word.start()+1]
+ else:
+ fragment = text
+
+ fragment_length = context.text_extents(fragment)[4]
+
+
+ if (fragment_length > width) and self.wrap == pango.WRAP_WORD_CHAR:
+ # too big to fit in any way
+ # split in pieces so that we fit in current row as much
+ # as we can and trust the task of putting things in next row
+ # to the next run
+ while fragment and running_width + fragment_length > width:
+ fragment_length -= letters[fragment[-1]]
+ fragment = fragment[:-1]
+
+ lines.append((line + fragment, running_width + fragment_length))
+ running_width = 0
+ fragment_length = 0
+ line = ""
+
+
+
+ else:
+ # otherwise the usual squishing
+ if running_width + fragment_length <= width:
+ line += fragment
+ else:
+ lines.append((line, running_width))
+ running_width = 0
+ line = fragment
+
+
+
+ running_width += fragment_length
+ text = text[len(fragment):]
+
+ elif self.wrap == pango.WRAP_CHAR:
+ # brute force glueing while we have space
+ for fragment in text:
+ fragment_length = letters[fragment]
+
+ if running_width + fragment_length <= width:
+ line += fragment
+ else:
+ lines.append((line, running_width))
+ running_width = 0
+ line = fragment
+
+ running_width += fragment_length
+
+ if line:
+ lines.append((line, running_width))
+
+ return lines, context.font_extents()[2]
+
+
+
+
+ def measure(self, text):
+ """measures given text with label's font and size.
+ returns width, height and ascent. Ascent's null in case if the label
+ does not have font face specified (and is thusly using pango)"""
+
+ if text in self._measures:
+ return self._measures[text]
+
+ width, height, ascent = None, None, None
+
+ context = self._test_context
+ if self.font_face:
+ context.set_font_face(self.font_face)
+ context.set_font_size(self.size)
+ font_ascent, font_descent, font_height = context.font_extents()[:3]
+
+ if self._bounds_width or self.max_width:
+ lines, line_height = self._wrap(text)
+
+ if self._bounds_width:
+ width = self._bounds_width / pango.SCALE
+ else:
+ max_width = 0
+ for line, line_width in lines:
+ max_width = max(max_width, line_width)
+ width = max_width
+
+ height = len(lines) * line_height
+ ascent = font_ascent
+ else:
+ width = context.text_extents(text)[4]
+ ascent, height = font_ascent, font_ascent + font_descent
+
+ else:
+ layout = self._test_layout
+ layout.set_font_description(self.font_desc)
+ layout.set_markup(text)
+ layout.set_width((self._bounds_width or -1))
+ layout.set_ellipsize(pango.ELLIPSIZE_NONE)
+
+
+ if self.wrap is not None:
+ layout.set_wrap(self.wrap)
+ else:
+ layout.set_ellipsize(self.ellipsize or pango.ELLIPSIZE_END)
+
+ width, height = layout.get_pixel_size()
+
+
+ self._measures[text] = width, height, ascent
+
+ return self._measures[text]
+
+
+ def on_render(self, sprite):
+ if not self.text:
+ self.graphics.clear()
+ return
+
+ self.graphics.set_color(self.color)
+
+ rect_width = self.width
+
+ if self.font_face:
+ self.graphics.set_font_size(self.size)
+ self.graphics.set_font_face(self.font_face)
+ if self._bounds_width or self.max_width:
+ lines, line_height = self._wrap(self.text)
+
+ x, y = 0.5, int(self._ascent) + 0.5
+ for line, line_width in lines:
+ if self.alignment == pango.ALIGN_RIGHT:
+ x = self.width - line_width
+ elif self.alignment == pango.ALIGN_CENTER:
+ x = (self.width - line_width) / 2
+
+ if self.outline_color:
+ self.graphics.save_context()
+ self.graphics.move_to(x, y)
+ self.graphics.text_path(line)
+ self.graphics.set_line_style(width=self.outline_width)
+ self.graphics.fill_stroke(self.outline_color, self.outline_color)
+ self.graphics.restore_context()
+
+ self.graphics.move_to(x, y)
+ self.graphics.set_color(self.color)
+ self.graphics.show_text(line)
+
+ y += line_height
+
+ else:
+ if self.outline_color:
+ self.graphics.save_context()
+ self.graphics.move_to(0, self._ascent)
+ self.graphics.text_path(self.text)
+ self.graphics.set_line_style(width=self.outline_width)
+ self.graphics.fill_stroke(self.outline_color, self.outline_color)
+ self.graphics.restore_context()
+
+ self.graphics.move_to(0, self._ascent)
+ self.graphics.show_text(self.text)
+
+ else:
+ self.graphics.show_layout(self.text, self.font_desc,
+ self.alignment,
+ self._bounds_width,
+ self.wrap,
+ self.ellipsize)
+
+ if self._bounds_width:
+ rect_width = self._bounds_width / pango.SCALE
+
+ self.graphics.rectangle(0, 0, rect_width, self.height)
+ self.graphics.clip()
+
+
+
+class Rectangle(Sprite):
+ def __init__(self, w, h, corner_radius = 0, fill = None, stroke = None, line_width = 1, **kwargs):
+ Sprite.__init__(self, **kwargs)
+
+ #: width
+ self.width = w
+
+ #: height
+ self.height = h
+
+ #: fill color
+ self.fill = fill
+
+ #: stroke color
+ self.stroke = stroke
+
+ #: stroke line width
+ self.line_width = line_width
+
+ #: corner radius. Set bigger than 0 for rounded corners
+ self.corner_radius = corner_radius
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ self.graphics.set_line_style(width = self.line_width)
+ self.graphics.rectangle(0, 0, self.width, self.height, self.corner_radius)
+ self.graphics.fill_stroke(self.fill, self.stroke, self.line_width)
+
+
+class Polygon(Sprite):
+ def __init__(self, points, fill = None, stroke = None, line_width = 1, **kwargs):
+ Sprite.__init__(self, **kwargs)
+
+ #: list of (x,y) tuples that the line should go through. Polygon
+ #: will automatically close path.
+ self.points = points
+
+ #: fill color
+ self.fill = fill
+
+ #: stroke color
+ self.stroke = stroke
+
+ #: stroke line width
+ self.line_width = line_width
+
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ if not self.points: return
+
+ self.graphics.move_to(*self.points[0])
+ self.graphics.line_to(self.points)
+ self.graphics.close_path()
+
+ self.graphics.fill_stroke(self.fill, self.stroke, self.line_width)
+
+
+class Circle(Sprite):
+ def __init__(self, width, height, fill = None, stroke = None, line_width = 1, **kwargs):
+ Sprite.__init__(self, **kwargs)
+
+ #: circle width
+ self.width = width
+
+ #: circle height
+ self.height = height
+
+ #: fill color
+ self.fill = fill
+
+ #: stroke color
+ self.stroke = stroke
+
+ #: stroke line width
+ self.line_width = line_width
+
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ if self.width == self.height:
+ radius = self.width / 2.0
+ self.graphics.circle(radius, radius, radius)
+ else:
+ self.graphics.ellipse(0, 0, self.width, self.height)
+
+ self.graphics.fill_stroke(self.fill, self.stroke, self.line_width)
+
+
+class Scene(gtk.DrawingArea):
+ """ Drawing area for displaying sprites.
+ Add sprites to the Scene by calling :func:`add_child`.
+ Scene is descendant of `gtk.DrawingArea <http://www.pygtk.org/docs/pygtk/class-gtkdrawingarea.html>`_
+ and thus inherits all it's methods and everything.
+ """
+
+ __gsignals__ = {
+ "expose-event": "override",
+ "configure_event": "override",
+ "on-enter-frame": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )),
+ "on-finish-frame": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )),
+
+ "on-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ "on-drag": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ "on-drag-start": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ "on-drag-finish": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+
+ "on-mouse-move": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-mouse-down": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-mouse-up": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-mouse-over": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ "on-mouse-out": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+
+ "on-scroll": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
+ }
+
+ def __init__(self, interactive = True, framerate = 60,
+ background_color = None, scale = False, keep_aspect = True):
+ gtk.DrawingArea.__init__(self)
+ if interactive:
+ self.set_events(gtk.gdk.POINTER_MOTION_MASK
+ | gtk.gdk.LEAVE_NOTIFY_MASK | gtk.gdk.ENTER_NOTIFY_MASK
+ | gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON_RELEASE_MASK
+ | gtk.gdk.SCROLL_MASK
+ | gtk.gdk.KEY_PRESS_MASK)
+ self.connect("motion_notify_event", self.__on_mouse_move)
+ self.connect("enter_notify_event", self.__on_mouse_enter)
+ self.connect("leave_notify_event", self.__on_mouse_leave)
+ self.connect("button_press_event", self.__on_button_press)
+ self.connect("button_release_event", self.__on_button_release)
+ self.connect("scroll-event", self.__on_scroll)
+
+ #: list of sprites in scene. use :func:`add_child` to add sprites
+ self.sprites = []
+
+ #: framerate of animation. This will limit how often call for
+ #: redraw will be performed (that is - not more often than the framerate). It will
+ #: also influence the smoothness of tweeners.
+ self.framerate = framerate
+
+ #: Scene width. Will be `None` until first expose (that is until first
+ #: on-enter-frame signal below).
+ self.width = None
+
+ #: Scene height. Will be `None` until first expose (that is until first
+ #: on-enter-frame signal below).
+ self.height = None
+
+ #: instance of :class:`pytweener.Tweener` that is used by
+ #: :func:`animate` function, but can be also accessed directly for advanced control.
+ self.tweener = None
+ if pytweener:
+ self.tweener = pytweener.Tweener(0.4, pytweener.Easing.Cubic.ease_in_out)
+
+ #: instance of :class:`Colors` class for color parsing
+ self.colors = Colors
+
+ #: read only info about current framerate (frames per second)
+ self.fps = 0 # inner frames per second counter
+
+ #: Last known x position of the mouse (set on expose event)
+ self.mouse_x = None
+
+ #: Last known y position of the mouse (set on expose event)
+ self.mouse_y = None
+
+ #: Background color of the scene. Use either a string with hex color or an RGB triplet.
+ self.background_color = background_color
+
+ #: Mouse cursor appearance.
+ #: Replace with your own cursor or set to False to have no cursor.
+ #: None will revert back the default behavior
+ self.mouse_cursor = None
+
+ blank_pixmap = gtk.gdk.Pixmap(None, 1, 1, 1)
+ self._blank_cursor = gtk.gdk.Cursor(blank_pixmap, blank_pixmap, gtk.gdk.Color(), gtk.gdk.Color(), 0, 0)
+
+
+ #: Miminum distance in pixels for a drag to occur
+ self.drag_distance = 1
+
+ self._last_frame_time = None
+ self._mouse_sprite = None
+ self._drag_sprite = None
+ self._mouse_down_sprite = None
+ self.__drag_started = False
+ self.__drag_start_x, self.__drag_start_y = None, None
+
+ self._mouse_in = False
+ self.__last_cursor = None
+
+ self.__drawing_queued = False
+ self._redraw_in_progress = False
+
+ #: When specified, upon window resize the content will be scaled
+ #: relative to original window size. Defaults to False.
+ self.scale = scale
+
+ #: Should the stage maintain aspect ratio upon scale if
+ #: :attr:`Scene.scale` is enabled. Defaults to true.
+ self.keep_aspect = keep_aspect
+
+ self._original_width, self._original_height = None, None
+
+
+
+ def add_child(self, *sprites):
+ """Add one or several :class:`Sprite` objects to the scene"""
+ for sprite in sprites:
+ if sprite == self:
+ raise Exception("trying to add sprite to itself")
+ if sprite.parent:
+ sprite.x, sprite.y = sprite.to_scene_coords(0, 0)
+ sprite.parent.remove_child(sprite)
+ self.sprites.append(sprite)
+ sprite.parent = self
+ self._sort()
+
+ def _sort(self):
+ """sort sprites by z_order"""
+ self.sprites = sorted(self.sprites, key=lambda sprite:sprite.z_order)
+
+
+ def remove_child(self, *sprites):
+ """Remove one or several :class:`Sprite` sprites from scene """
+ for sprite in sprites:
+ self.sprites.remove(sprite)
+ sprite.parent = None
+
+ # these two mimic sprite functions so parent check can be avoided
+ def from_scene_coords(self, x, y): return x, y
+ def to_scene_coords(self, x, y): return x, y
+ def get_matrix(self): return cairo.Matrix()
+
+ def clear(self):
+ """Remove all sprites from scene"""
+ self.remove_child(*self.sprites)
+
+ def animate(self, sprite, duration = None, easing = None, on_complete = None, on_update = None, **kwargs):
+ """Interpolate attributes of the given object using the internal tweener
+ and redrawing scene after every tweener update.
+ Specify the sprite and sprite's attributes that need changing.
+ `duration` defaults to 0.4 seconds and `easing` to cubic in-out
+ (for others see pytweener.Easing class).
+
+ Redraw is requested right after creating the animation.
+ Example::
+
+ # tween some_sprite to coordinates (50,100) using default duration and easing
+ scene.animate(some_sprite, x = 50, y = 100)
+ """
+ if not self.tweener: # here we complain
+ raise Exception("pytweener was not found. Include it to enable animations")
+
+ tween = self.tweener.add_tween(sprite,
+ duration=duration,
+ easing=easing,
+ on_complete=on_complete,
+ on_update=on_update,
+ **kwargs)
+ self.redraw()
+ return tween
+
+
+ def redraw(self):
+ """Queue redraw. The redraw will be performed not more often than
+ the `framerate` allows"""
+ if self.__drawing_queued == False: #if we are moving, then there is a timeout somewhere already
+ self.__drawing_queued = True
+ self._last_frame_time = dt.datetime.now()
+ gobject.timeout_add(1000 / self.framerate, self.__redraw_loop)
+
+ def __redraw_loop(self):
+ """loop until there is nothing more to tween"""
+ self.queue_draw() # this will trigger do_expose_event when the current events have been flushed
+
+ self.__drawing_queued = self.tweener and self.tweener.has_tweens()
+ return self.__drawing_queued
+
+
+ def do_expose_event(self, event):
+ context = self.window.cairo_create()
+
+ # clip to the visible part
+ context.rectangle(event.area.x, event.area.y,
+ event.area.width, event.area.height)
+ if self.background_color:
+ color = self.colors.parse(self.background_color)
+ context.set_source_rgb(*color)
+ context.fill_preserve()
+ context.clip()
+
+ if self.scale:
+ aspect_x = self.width / self._original_width
+ aspect_y = self.height / self._original_height
+ if self.keep_aspect:
+ aspect_x = aspect_y = min(aspect_x, aspect_y)
+ context.scale(aspect_x, aspect_y)
+
+ self.mouse_x, self.mouse_y, mods = self.get_window().get_pointer()
+
+ self._redraw_in_progress = True
+
+ # update tweens
+ now = dt.datetime.now()
+ delta = (now - (self._last_frame_time or dt.datetime.now())).microseconds / 1000000.0
+ self._last_frame_time = now
+ if self.tweener:
+ self.tweener.update(delta)
+
+ if delta <= delta:
+ delta = 1
+ self.fps = 1 / delta
+
+
+ # start drawing
+ self.emit("on-enter-frame", context)
+ for sprite in self.sprites:
+ sprite._draw(context)
+
+ self.__check_mouse(self.mouse_x, self.mouse_y)
+ self.emit("on-finish-frame", context)
+ self._redraw_in_progress = False
+
+
+ def do_configure_event(self, event):
+ if self._original_width is None:
+ self._original_width = float(event.width)
+ self._original_height = float(event.height)
+
+ self.width, self.height = event.width, event.height
+
+ def all_visible_sprites(self):
+ """Returns flat list of the sprite tree for simplified iteration"""
+ def all_recursive(sprites):
+ for sprite in sprites:
+ if sprite.visible:
+ yield sprite
+ if sprite.sprites:
+ for child in all_recursive(sprite.sprites):
+ yield child
+
+ return all_recursive(self.sprites)
+
+
+ def get_sprite_at_position(self, x, y):
+ """Returns the topmost visible interactive sprite for given coordinates"""
+ over = None
+
+
+
+ for sprite in self.all_visible_sprites():
+ if (sprite.interactive or sprite.draggable) and sprite.check_hit(x, y):
+ over = sprite
+
+ return over
+
+
+
+ def __check_mouse(self, x, y):
+ if x is None or self._mouse_in == False:
+ return
+
+ cursor = gtk.gdk.ARROW # default
+
+ if self.mouse_cursor is not None:
+ cursor = self.mouse_cursor
+
+ if self._drag_sprite:
+ cursor = self._drag_sprite.mouse_cursor or self.mouse_cursor or gtk.gdk.FLEUR
+ else:
+ #check if we have a mouse over
+ over = self.get_sprite_at_position(x, y)
+ if self._mouse_sprite and self._mouse_sprite != over:
+ self._mouse_sprite.emit("on-mouse-out")
+ self.emit("on-mouse-out", self._mouse_sprite)
+ self.redraw()
+
+ if over:
+ if over.mouse_cursor is not None:
+ cursor = over.mouse_cursor
+
+ elif self.mouse_cursor is None:
+ # resort to defaults
+ if over.draggable:
+ cursor = gtk.gdk.FLEUR
+ else:
+ cursor = gtk.gdk.HAND2
+
+ if over != self._mouse_sprite:
+ over.emit("on-mouse-over")
+ self.emit("on-mouse-over", over)
+ self.redraw()
+
+ self._mouse_sprite = over
+
+ if cursor == False:
+ cursor = self._blank_cursor
+
+ if not self.__last_cursor or cursor != self.__last_cursor:
+ if isinstance(cursor, gtk.gdk.Cursor):
+ self.window.set_cursor(cursor)
+ else:
+ self.window.set_cursor(gtk.gdk.Cursor(cursor))
+
+ self.__last_cursor = cursor
+
+
+ """ mouse events """
+ def __on_mouse_move(self, area, event):
+ state = event.state
+
+
+ if self._mouse_down_sprite and self._mouse_down_sprite.draggable \
+ and gtk.gdk.BUTTON1_MASK & event.state:
+ # dragging around
+ drag_started = (self.__drag_start_x is not None and \
+ (self.__drag_start_x - event.x) ** 2 + \
+ (self.__drag_start_y - event.y) ** 2 > self.drag_distance ** 2)
+
+ if drag_started and not self.__drag_started:
+ self._drag_sprite = self._mouse_down_sprite
+
+ self._drag_sprite.drag_x, self._drag_sprite.drag_y = self._drag_sprite.x, self._drag_sprite.y
+
+ self._drag_sprite.emit("on-drag-start", event)
+ self.emit("on-drag-start", self._drag_sprite, event)
+ self.redraw()
+
+
+ self.__drag_started = self.__drag_started or drag_started
+
+ if self.__drag_started:
+ diff_x, diff_y = event.x - self.__drag_start_x, event.y - self.__drag_start_y
+ if isinstance(self._drag_sprite.parent, Sprite):
+ matrix = self._drag_sprite.parent.get_matrix()
+ matrix.invert()
+ diff_x, diff_y = matrix.transform_distance(diff_x, diff_y)
+
+ self._drag_sprite.x, self._drag_sprite.y = self._drag_sprite.drag_x + diff_x, self._drag_sprite.drag_y + diff_y
+
+ self._drag_sprite.emit("on-drag", event)
+ self.emit("on-drag", self._drag_sprite, event)
+ self.redraw()
+
+ else:
+ # avoid double mouse checks - the redraw will also check for mouse!
+ if not self.__drawing_queued:
+ self.__check_mouse(event.x, event.y)
+
+ self.emit("on-mouse-move", event)
+
+ def __on_mouse_enter(self, area, event):
+ self._mouse_in = True
+
+ def __on_mouse_leave(self, area, event):
+ self._mouse_in = False
+ if self._mouse_sprite:
+ self.emit("on-mouse-out", self._mouse_sprite)
+ self.redraw()
+ self._mouse_sprite = None
+
+
+ def __on_button_press(self, area, event):
+ target = self.get_sprite_at_position(event.x, event.y)
+ self.__drag_start_x, self.__drag_start_y = event.x, event.y
+
+ self._mouse_down_sprite = target
+
+ if target:
+ target.emit("on-mouse-down", event)
+ self.emit("on-mouse-down", event)
+
+ def __on_button_release(self, area, event):
+ target = self.get_sprite_at_position(event.x, event.y)
+
+ if target:
+ target.emit("on-mouse-up", event)
+ self.emit("on-mouse-up", event)
+
+ # trying to not emit click and drag-finish at the same time
+ click = not self.__drag_started or (event.x - self.__drag_start_x) ** 2 + \
+ (event.y - self.__drag_start_y) ** 2 < self.drag_distance
+ if (click and self.__drag_started == False) or not self._drag_sprite:
+ if target:
+ target.emit("on-click", event)
+
+ self.emit("on-click", event, target)
+ self.redraw()
+
+ if self._drag_sprite:
+ self._drag_sprite.emit("on-drag-finish", event)
+ self.emit("on-drag-finish", self._drag_sprite, event)
+ self.redraw()
+
+ self._drag_sprite.drag_x, self._drag_sprite.drag_y = None, None
+ self._drag_sprite = None
+ self._mouse_down_sprite = None
+
+ self.__drag_started = False
+ self.__drag_start_x, self__drag_start_y = None, None
+
+ def __on_scroll(self, area, event):
+ self.emit("on-scroll", event)
diff --git a/win32/hamster/lib/i18n.py b/win32/hamster/lib/i18n.py
new file mode 100644
index 0000000..7ea599c
--- /dev/null
+++ b/win32/hamster/lib/i18n.py
@@ -0,0 +1,42 @@
+# - coding: utf-8 -
+import os
+import locale, gettext
+
+
+def setup_i18n():
+ #determine location of po files
+ try:
+ from .. import defs
+ except:
+ defs = None
+
+
+ # to avoid confusion, we won't translate unless running installed
+ # reason for that is that bindtextdomain is expecting
+ # localedir/language/LC_MESSAGES/domain.mo format, but we have
+ # localedir/language.mo at it's best (after build)
+ # and there does not seem to be any way to run straight from sources
+ if defs:
+ locale_dir = os.path.realpath(os.path.join(defs.DATA_DIR, "locale"))
+
+ for module in (locale,gettext):
+ module.bindtextdomain('hamster-applet', locale_dir)
+ module.textdomain('hamster-applet')
+
+ module.bind_textdomain_codeset('hamster-applet','utf8')
+
+ gettext.install("hamster-applet", locale_dir, unicode = True)
+
+ else:
+ gettext.install("hamster-applet-uninstalled")
+
+
+def C_(ctx, s):
+ """Provide qualified translatable strings via context.
+ Taken from gnome-games.
+ """
+ translated = gettext.gettext('%s\x04%s' % (ctx, s))
+ if '\x04' in translated:
+ # no translation found, return input string
+ return s
+ return translated
diff --git a/win32/hamster/lib/pytweener.py b/win32/hamster/lib/pytweener.py
new file mode 100644
index 0000000..6c93340
--- /dev/null
+++ b/win32/hamster/lib/pytweener.py
@@ -0,0 +1,605 @@
+# pyTweener
+#
+# Tweening functions for python
+#
+# Heavily based on caurina Tweener: http://code.google.com/p/tweener/
+#
+# Released under M.I.T License - see above url
+# Python version by Ben Harling 2009
+# All kinds of slashing and dashing by Toms Baugis 2010
+import math
+import collections
+import datetime as dt
+import time
+import re
+
+class Tweener(object):
+ def __init__(self, default_duration = None, tween = None):
+ """Tweener
+ This class manages all active tweens, and provides a factory for
+ creating and spawning tween motions."""
+ self.current_tweens = collections.defaultdict(set)
+ self.default_easing = tween or Easing.Cubic.ease_in_out
+ self.default_duration = default_duration or 1.0
+
+ def has_tweens(self):
+ return len(self.current_tweens) > 0
+
+
+ def add_tween(self, obj, duration = None, easing = None, on_complete = None, on_update = None, delay = None, **kwargs):
+ """
+ Add tween for the object to go from current values to set ones.
+ Example: add_tween(sprite, x = 500, y = 200, duration = 0.4)
+ This will move the sprite to coordinates (500, 200) in 0.4 seconds.
+ For parameter "easing" you can use one of the pytweener.Easing
+ functions, or specify your own.
+ The tweener can handle numbers, dates and color strings in hex ("#ffffff")
+ """
+ duration = duration or self.default_duration
+ easing = easing or self.default_easing
+ delay = delay or 0
+
+ tw = Tween(obj, duration, easing, on_complete, on_update, delay, **kwargs )
+
+ if obj in self.current_tweens:
+ for current_tween in self.current_tweens[obj]:
+ prev_keys = set((tweenable.key for tweenable in current_tween.tweenables))
+ dif = prev_keys & set(kwargs.keys())
+
+ removable = [tweenable for tweenable in current_tween.tweenables if tweenable.key in dif]
+ for tweenable in removable:
+ current_tween.tweenables.remove(tweenable)
+
+
+ self.current_tweens[obj].add(tw)
+ return tw
+
+
+ def get_tweens(self, obj):
+ """Get a list of all tweens acting on the specified object
+ Useful for manipulating tweens on the fly"""
+ return self.current_tweens.get(obj, None)
+
+ def kill_tweens(self, obj = None):
+ """Stop tweening an object, without completing the motion or firing the
+ on_complete"""
+ if obj:
+ try:
+ del self.current_tweens[obj]
+ except:
+ pass
+ else:
+ self.current_tweens = collections.defaultdict(set)
+
+ def remove_tween(self, tween):
+ """"remove given tween without completing the motion or firing the on_complete"""
+ if tween.target in self.current_tweens and tween in self.current_tweens[tween.target]:
+ self.current_tweens[tween.target].remove(tween)
+
+ def finish(self):
+ """jump the the last frame of all tweens"""
+ for obj in self.current_tweens:
+ for t in self.current_tweens[obj]:
+ t._update(t.duration)
+ self.current_tweens = {}
+
+ def update(self, delta_seconds):
+ """update tweeners. delta_seconds is time in seconds since last frame"""
+
+ done_list = set()
+ for obj in self.current_tweens:
+ for tween in self.current_tweens[obj]:
+ done = tween._update(delta_seconds)
+ if done:
+ done_list.add(tween)
+
+ # remove all the completed tweens
+ for tween in done_list:
+ if tween.on_complete:
+ tween.on_complete(tween.target)
+
+ self.current_tweens[tween.target].remove(tween)
+ if not self.current_tweens[tween.target]:
+ del self.current_tweens[tween.target]
+
+
+class Tweenable(object):
+ hex_color_normal = re.compile("#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})")
+ hex_color_short = re.compile("#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])")
+
+ def __init__(self, key, start_value, target_value):
+ self.key = key
+ self.change = None
+ self.decode_func = lambda x: x
+ self.encode_func = lambda x: x
+ self.start_value = start_value
+ self.target_value = target_value
+
+ if isinstance(start_value, int) or isinstance(start_value, float):
+ self.start_value = start_value
+ self.change = target_value - start_value
+ else:
+ if isinstance(start_value, dt.datetime) or isinstance(start_value, dt.date):
+ self.decode_func = lambda x: time.mktime(x.timetuple())
+ if isinstance(start_value, dt.datetime):
+ self.encode_func = lambda x: dt.datetime.fromtimestamp(x)
+ else:
+ self.encode_func = lambda x: dt.date.fromtimestamp(x)
+
+ self.start_value = self.decode_func(start_value)
+ self.change = self.decode_func(target_value) - self.start_value
+
+ elif isinstance(start_value, basestring) \
+ and (self.hex_color_normal.match(start_value) or self.hex_color_short.match(start_value)):
+ # code below is mainly based on jquery-color plugin
+ self.encode_func = lambda val: "#%02x%02x%02x" % (max(min(val[0], 255), 0),
+ max(min(val[1], 255), 0),
+ max(min(val[2], 255), 0))
+ if self.hex_color_normal.match(start_value):
+ self.decode_func = lambda val: [int(match, 16)
+ for match in self.hex_color_normal.match(val).groups()]
+
+ elif self.hex_color_short.match(start_value):
+ self.decode_func = lambda val: [int(match + match, 16)
+ for match in self.hex_color_short.match(val).groups()]
+
+ if self.hex_color_normal.match(target_value):
+ target_value = [int(match, 16)
+ for match in self.hex_color_normal.match(target_value).groups()]
+ else:
+ target_value = [int(match + match, 16)
+ for match in self.hex_color_short.match(target_value).groups()]
+
+ self.start_value = self.decode_func(start_value)
+ self.change = [target - start for start, target in zip(self.start_value, target_value)]
+
+
+ def update(self, ease, delta, duration):
+ # list means we are dealing with a color triplet
+ if isinstance(self.start_value, list):
+ return self.encode_func([ease(delta, self.start_value[i],
+ self.change[i], duration)
+ for i in range(3)])
+ else:
+ return self.encode_func(ease(delta, self.start_value, self.change, duration))
+
+
+
+class Tween(object):
+ __slots__ = ('tweenables', 'target', 'delta', 'duration', 'delay',
+ 'ease', 'delta', 'on_complete',
+ 'on_update', 'complete', 'paused')
+
+ def __init__(self, obj, duration, easing, on_complete, on_update, delay, **kwargs):
+ """Tween object use Tweener.add_tween( ... ) to create"""
+ self.duration = duration
+ self.delay = delay
+ self.target = obj
+ self.ease = easing
+
+ # list of (property, start_value, delta)
+ self.tweenables = set()
+ for key, value in kwargs.items():
+ self.tweenables.add(Tweenable(key, self.target.__dict__[key], value))
+
+ self.delta = 0
+ self.on_complete = on_complete
+ self.on_update = on_update
+ self.complete = False
+
+ self.paused = self.delay > 0
+
+ def pause(self, seconds = -1):
+ """Pause this tween
+ do tween.pause( 2 ) to pause for a specific time
+ or tween.pause() which pauses indefinitely."""
+ self.paused = True
+ self.delay = seconds
+
+ def resume(self):
+ """Resume from pause"""
+ if self.paused:
+ self.paused=False
+
+ def _update(self, ptime):
+ """Update tween with the time since the last frame
+ if there is an update callback, it is always called
+ whether the tween is running or paused"""
+
+ if self.complete: return
+
+ if self.paused:
+ if self.delay > 0:
+ self.delay = max(0, self.delay - ptime)
+ if self.delay == 0:
+ self.paused = False
+ self.delay = -1
+ if self.on_update:
+ self.on_update()
+ return
+
+ self.delta = self.delta + ptime
+ if self.delta > self.duration:
+ self.delta = self.duration
+
+ for tweenable in self.tweenables:
+ self.target.__setattr__(tweenable.key,
+ tweenable.update(self.ease, self.delta, self.duration))
+
+ if self.delta == self.duration:
+ self.complete = True
+
+ if self.on_update:
+ self.on_update(self.target)
+
+ return self.complete
+
+
+"""Robert Penner's easing classes ported over from actionscript by Toms Baugis (at gmail com).
+There certainly is room for improvement, but wanted to keep the readability to some extent.
+
+================================================================================
+ Easing Equations
+ (c) 2003 Robert Penner, all rights reserved.
+ This work is subject to the terms in
+ http://www.robertpenner.com/easing_terms_of_use.html.
+================================================================================
+
+TERMS OF USE - EASING EQUATIONS
+
+Open source under the BSD License.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ * Neither the name of the author nor the names of contributors may be used
+ to endorse or promote products derived from this software without specific
+ prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+class Easing(object):
+ """Class containing easing classes to use together with the tweener.
+ All of the classes have :func:`ease_in`, :func:`ease_out` and
+ :func:`ease_in_out` functions."""
+
+ class Back(object):
+ @staticmethod
+ def ease_in(t, b, c, d, s = 1.70158):
+ t = t / d
+ return c * t * t * ((s+1) * t - s) + b
+
+ @staticmethod
+ def ease_out (t, b, c, d, s = 1.70158):
+ t = t / d - 1
+ return c * (t * t * ((s + 1) * t + s) + 1) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d, s = 1.70158):
+ t = t / (d * 0.5)
+ s = s * 1.525
+
+ if t < 1:
+ return c * 0.5 * (t * t * ((s + 1) * t - s)) + b
+
+ t = t - 2
+ return c / 2 * (t * t * ((s + 1) * t + s) + 2) + b
+
+ class Bounce(object):
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d
+ if t < 1 / 2.75:
+ return c * (7.5625 * t * t) + b
+ elif t < 2 / 2.75:
+ t = t - 1.5 / 2.75
+ return c * (7.5625 * t * t + 0.75) + b
+ elif t < 2.5 / 2.75:
+ t = t - 2.25 / 2.75
+ return c * (7.5625 * t * t + .9375) + b
+ else:
+ t = t - 2.625 / 2.75
+ return c * (7.5625 * t * t + 0.984375) + b
+
+ @staticmethod
+ def ease_in (t, b, c, d):
+ return c - Easing.Bounce.ease_out(d-t, 0, c, d) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ if t < d * 0.5:
+ return Easing.Bounce.ease_in (t * 2, 0, c, d) * .5 + b
+
+ return Easing.Bounce.ease_out (t * 2 -d, 0, c, d) * .5 + c*.5 + b
+
+
+
+ class Circ(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ t = t / d
+ return -c * (math.sqrt(1 - t * t) - 1) + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d - 1
+ return c * math.sqrt(1 - t * t) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ t = t / (d * 0.5)
+ if t < 1:
+ return -c * 0.5 * (math.sqrt(1 - t * t) - 1) + b
+
+ t = t - 2
+ return c*0.5 * (math.sqrt(1 - t * t) + 1) + b
+
+
+ class Cubic(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ t = t / d
+ return c * t * t * t + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d - 1
+ return c * (t * t * t + 1) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ t = t / (d * 0.5)
+ if t < 1:
+ return c * 0.5 * t * t * t + b
+
+ t = t - 2
+ return c * 0.5 * (t * t * t + 2) + b
+
+
+ class Elastic(object):
+ @staticmethod
+ def ease_in (t, b, c, d, a = 0, p = 0):
+ if t==0: return b
+
+ t = t / d
+ if t == 1: return b+c
+
+ if not p: p = d * .3;
+
+ if not a or a < abs(c):
+ a = c
+ s = p / 4
+ else:
+ s = p / (2 * math.pi) * math.asin(c / a)
+
+ t = t - 1
+ return - (a * math.pow(2, 10 * t) * math.sin((t*d-s) * (2 * math.pi) / p)) + b
+
+
+ @staticmethod
+ def ease_out (t, b, c, d, a = 0, p = 0):
+ if t == 0: return b
+
+ t = t / d
+ if (t == 1): return b + c
+
+ if not p: p = d * .3;
+
+ if not a or a < abs(c):
+ a = c
+ s = p / 4
+ else:
+ s = p / (2 * math.pi) * math.asin(c / a)
+
+ return a * math.pow(2,-10 * t) * math.sin((t * d - s) * (2 * math.pi) / p) + c + b
+
+
+ @staticmethod
+ def ease_in_out (t, b, c, d, a = 0, p = 0):
+ if t == 0: return b
+
+ t = t / (d * 0.5)
+ if t == 2: return b + c
+
+ if not p: p = d * (.3 * 1.5)
+
+ if not a or a < abs(c):
+ a = c
+ s = p / 4
+ else:
+ s = p / (2 * math.pi) * math.asin(c / a)
+
+ if (t < 1):
+ t = t - 1
+ return -.5 * (a * math.pow(2, 10 * t) * math.sin((t * d - s) * (2 * math.pi) / p)) + b
+
+ t = t - 1
+ return a * math.pow(2, -10 * t) * math.sin((t * d - s) * (2 * math.pi) / p) * .5 + c + b
+
+
+ class Expo(object):
+ @staticmethod
+ def ease_in(t, b, c, d):
+ if t == 0:
+ return b
+ else:
+ return c * math.pow(2, 10 * (t / d - 1)) + b - c * 0.001
+
+ @staticmethod
+ def ease_out(t, b, c, d):
+ if t == d:
+ return b + c
+ else:
+ return c * (-math.pow(2, -10 * t / d) + 1) + b
+
+ @staticmethod
+ def ease_in_out(t, b, c, d):
+ if t==0:
+ return b
+ elif t==d:
+ return b+c
+
+ t = t / (d * 0.5)
+
+ if t < 1:
+ return c * 0.5 * math.pow(2, 10 * (t - 1)) + b
+
+ return c * 0.5 * (-math.pow(2, -10 * (t - 1)) + 2) + b
+
+
+ class Linear(object):
+ @staticmethod
+ def ease_none(t, b, c, d):
+ return c * t / d + b
+
+ @staticmethod
+ def ease_in(t, b, c, d):
+ return c * t / d + b
+
+ @staticmethod
+ def ease_out(t, b, c, d):
+ return c * t / d + b
+
+ @staticmethod
+ def ease_in_out(t, b, c, d):
+ return c * t / d + b
+
+
+ class Quad(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ t = t / d
+ return c * t * t + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d
+ return -c * t * (t-2) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ t = t / (d * 0.5)
+ if t < 1:
+ return c * 0.5 * t * t + b
+
+ t = t - 1
+ return -c * 0.5 * (t * (t - 2) - 1) + b
+
+
+ class Quart(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ t = t / d
+ return c * t * t * t * t + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d - 1
+ return -c * (t * t * t * t - 1) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ t = t / (d * 0.5)
+ if t < 1:
+ return c * 0.5 * t * t * t * t + b
+
+ t = t - 2
+ return -c * 0.5 * (t * t * t * t - 2) + b
+
+
+ class Quint(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ t = t / d
+ return c * t * t * t * t * t + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ t = t / d - 1
+ return c * (t * t * t * t * t + 1) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ t = t / (d * 0.5)
+ if t < 1:
+ return c * 0.5 * t * t * t * t * t + b
+
+ t = t - 2
+ return c * 0.5 * (t * t * t * t * t + 2) + b
+
+ class Sine(object):
+ @staticmethod
+ def ease_in (t, b, c, d):
+ return -c * math.cos(t / d * (math.pi / 2)) + c + b
+
+ @staticmethod
+ def ease_out (t, b, c, d):
+ return c * math.sin(t / d * (math.pi / 2)) + b
+
+ @staticmethod
+ def ease_in_out (t, b, c, d):
+ return -c * 0.5 * (math.cos(math.pi * t / d) - 1) + b
+
+
+ class Strong(object):
+ @staticmethod
+ def ease_in(t, b, c, d):
+ return c * (t/d)**5 + b
+
+ @staticmethod
+ def ease_out(t, b, c, d):
+ return c * ((t / d - 1)**5 + 1) + b
+
+ @staticmethod
+ def ease_in_out(t, b, c, d):
+ t = t / (d * 0.5)
+
+ if t < 1:
+ return c * 0.5 * t * t * t * t * t + b
+
+ t = t - 2
+ return c * 0.5 * (t * t * t * t * t + 2) + b
+
+
+
+
+class _PerformanceTester(object):
+ def __init__(self, a, b, c):
+ self.a = a
+ self.b = b
+ self.c = c
+
+if __name__ == "__main__":
+ import datetime as dt
+
+ tweener = Tweener()
+ objects = []
+ for i in range(10000):
+ objects.append(_PerformanceTester(dt.datetime.now(), i-100, i-100))
+
+
+ total = dt.datetime.now()
+
+ t = dt.datetime.now()
+ for i, o in enumerate(objects):
+ tweener.add_tween(o, a = dt.datetime.now() - dt.timedelta(days=3), b = i, c = i, duration = 1.0)
+ print "add", dt.datetime.now() - t
+
+ tweener.finish()
+ print objects[0].a
diff --git a/win32/hamster/lib/stuff.py b/win32/hamster/lib/stuff.py
new file mode 100644
index 0000000..247909a
--- /dev/null
+++ b/win32/hamster/lib/stuff.py
@@ -0,0 +1,362 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+# some widgets that repeat all over the place
+# cells, columns, trees and other
+
+import logging
+import gtk
+import pango
+from pango import ELLIPSIZE_END
+
+from itertools import groupby
+import datetime as dt
+import calendar
+import time
+import re
+import locale
+import os
+try:
+ import _winreg as winreg
+except ImportError:
+ try:
+ import winreg
+ except ImportError:
+ logging.warn("WARNING - Could not load Registry module.")
+ winreg = None
+
+def format_duration(minutes, human = True):
+ """formats duration in a human readable format.
+ accepts either minutes or timedelta"""
+
+ if isinstance(minutes, dt.timedelta):
+ minutes = duration_minutes(minutes)
+
+ if not minutes:
+ if human:
+ return ""
+ else:
+ return "00:00"
+
+ hours = minutes / 60
+ minutes = minutes % 60
+ formatted_duration = ""
+
+ if human:
+ if minutes % 60 == 0:
+ # duration in round hours
+ formatted_duration += _("%dh") % (hours)
+ elif hours == 0:
+ # duration less than hour
+ formatted_duration += _("%dmin") % (minutes % 60.0)
+ else:
+ # x hours, y minutes
+ formatted_duration += _("%dh %dmin") % (hours, minutes % 60)
+ else:
+ formatted_duration += "%02d:%02d" % (hours, minutes)
+
+
+ return formatted_duration
+
+
+def format_range(start_date, end_date):
+ dates_dict = dateDict(start_date, "start_")
+ dates_dict.update(dateDict(end_date, "end_"))
+
+ if start_date == end_date:
+ # label of date range if looking on single day
+ # date format for overview label when only single day is visible
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ title = start_date.strftime(_("%B %d, %Y"))
+ elif start_date.year != end_date.year:
+ # label of date range if start and end years don't match
+ # letter after prefixes (start_, end_) is the one of
+ # standard python date formatting ones- you can use all of them
+ # see http://docs.python.org/library/time.html#time.strftime
+ title = _(u"%(start_B)s %(start_d)s, %(start_Y)s â?? %(end_B)s %(end_d)s, %(end_Y)s") % dates_dict
+ elif start_date.month != end_date.month:
+ # label of date range if start and end month do not match
+ # letter after prefixes (start_, end_) is the one of
+ # standard python date formatting ones- you can use all of them
+ # see http://docs.python.org/library/time.html#time.strftime
+ title = _(u"%(start_B)s %(start_d)s â?? %(end_B)s %(end_d)s, %(end_Y)s") % dates_dict
+ else:
+ # label of date range for interval in same month
+ # letter after prefixes (start_, end_) is the one of
+ # standard python date formatting ones- you can use all of them
+ # see http://docs.python.org/library/time.html#time.strftime
+ title = _(u"%(start_B)s %(start_d)s â?? %(end_d)s, %(end_Y)s") % dates_dict
+
+ return title
+
+
+
+def week(view_date):
+ # aligns start and end date to week
+ start_date = view_date - dt.timedelta(view_date.weekday() + 1)
+ start_date = start_date + dt.timedelta(locale_first_weekday())
+ end_date = start_date + dt.timedelta(6)
+ return start_date, end_date
+
+def month(view_date):
+ # aligns start and end date to month
+ start_date = view_date - dt.timedelta(view_date.day - 1) #set to beginning of month
+ first_weekday, days_in_month = calendar.monthrange(view_date.year, view_date.month)
+ end_date = start_date + dt.timedelta(days_in_month - 1)
+ return start_date, end_date
+
+
+def duration_minutes(duration):
+ """returns minutes from duration, otherwise we keep bashing in same math"""
+ if isinstance(duration, list):
+ res = dt.timedelta()
+ for entry in duration:
+ res += entry
+
+ return duration_minutes(res)
+ elif isinstance(duration, dt.timedelta):
+ return duration.seconds / 60 + duration.days * 24 * 60
+ else:
+ return duration
+
+
+def zero_hour(date):
+ return dt.datetime.combine(date.date(), dt.time(0,0))
+
+# it seems that python or something has bug of sorts, that breaks stuff for
+# japanese locale, so we have this locale from and to ut8 magic in some places
+# see bug 562298
+def locale_from_utf8(utf8_str):
+ try:
+ retval = unicode (utf8_str, "utf-8").encode(locale.getpreferredencoding())
+ except:
+ retval = utf8_str
+ return retval
+
+def locale_to_utf8(locale_str):
+ try:
+ retval = unicode (locale_str, locale.getpreferredencoding()).encode("utf-8")
+ except:
+ retval = locale_str
+ return retval
+
+def locale_first_weekday():
+ """figure if week starts on monday or sunday"""
+ first_weekday = 1 #by default settle on monday
+ if not winreg: return first_weekday
+
+ try:
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Control Panel\International")
+ value, vtype = winreg.QueryValueEx(key, "iFirstDayOfWeek")
+ first_weekday = (int(value)+1) % 7
+ except:
+ logging.warn("WARNING - Failed to get first weekday from locale")
+
+ return first_weekday
+
+
+def totals(iter, keyfunc, sumfunc):
+ """groups items by field described in keyfunc and counts totals using value
+ from sumfunc
+ """
+ data = sorted(iter, key=keyfunc)
+ res = {}
+
+ for k, group in groupby(data, keyfunc):
+ res[k] = sum([sumfunc(entry) for entry in group])
+
+ return res
+
+
+def dateDict(date, prefix = ""):
+ """converts date into dictionary, having prefix for all the keys"""
+ res = {}
+
+ res[prefix+"a"] = date.strftime("%a")
+ res[prefix+"A"] = date.strftime("%A")
+ res[prefix+"b"] = date.strftime("%b")
+ res[prefix+"B"] = date.strftime("%B")
+ res[prefix+"c"] = date.strftime("%c")
+ res[prefix+"d"] = date.strftime("%d")
+ res[prefix+"H"] = date.strftime("%H")
+ res[prefix+"I"] = date.strftime("%I")
+ res[prefix+"j"] = date.strftime("%j")
+ res[prefix+"m"] = date.strftime("%m")
+ res[prefix+"M"] = date.strftime("%M")
+ res[prefix+"p"] = date.strftime("%p")
+ res[prefix+"S"] = date.strftime("%S")
+ res[prefix+"U"] = date.strftime("%U")
+ res[prefix+"w"] = date.strftime("%w")
+ res[prefix+"W"] = date.strftime("%W")
+ res[prefix+"x"] = date.strftime("%x")
+ res[prefix+"X"] = date.strftime("%X")
+ res[prefix+"y"] = date.strftime("%y")
+ res[prefix+"Y"] = date.strftime("%Y")
+ res[prefix+"Z"] = date.strftime("%Z")
+
+ for i, value in res.items():
+ res[i] = locale_to_utf8(value)
+
+ return res
+
+def escape_pango(text):
+ if not text:
+ return text
+
+ text = text.replace ("&", "&")
+ text = text.replace("<", "<")
+ text = text.replace(">", ">")
+ return text
+
+def figure_time(str_time):
+ if not str_time or not str_time.strip():
+ return None
+
+ # strip everything non-numeric and consider hours to be first number
+ # and minutes - second number
+ numbers = re.split("\D", str_time)
+ numbers = filter(lambda x: x!="", numbers)
+
+ hours, minutes = None, None
+
+ if len(numbers) == 1 and len(numbers[0]) == 4:
+ hours, minutes = int(numbers[0][:2]), int(numbers[0][2:])
+ else:
+ if len(numbers) >= 1:
+ hours = int(numbers[0])
+ if len(numbers) >= 2:
+ minutes = int(numbers[1])
+
+ if (hours is None or minutes is None) or hours > 24 or minutes > 60:
+ return None #no can do
+
+ return dt.datetime.now().replace(hour = hours, minute = minutes,
+ second = 0, microsecond = 0)
+
+
+class Fact(object):
+ def __init__(self, activity, category = "", description = "", tags = "",
+ start_time = None, end_time = None, id = None, delta = None,
+ date = None, activity_id = None):
+ """the category, description and tags can be either passed in explicitly
+ or by using the "activity category, description #tag #tag" syntax.
+ explicitly stated values will take precedence over derived ones"""
+ self.original_activity = activity # unparsed version, mainly for trophies right now
+ self.activity = None
+ self.category = None
+ self.description = None
+ self.tags = []
+ self.start_time = None
+ self.end_time = None
+ self.id = id
+ self.ponies = False
+ self.delta = delta
+ self.date = date
+ self.activity_id = activity_id
+
+ # parse activity
+ input_parts = activity.strip().split(" ")
+ if len(input_parts) > 1 and re.match('^-?\d', input_parts[0]): #look for time only if there is more
+ potential_time = activity.split(" ")[0]
+ potential_end_time = None
+ if len(potential_time) > 1 and potential_time.startswith("-"):
+ #if starts with minus, treat as minus delta minutes
+ self.start_time = dt.datetime.now() + dt.timedelta(minutes = int(potential_time))
+
+ else:
+ if potential_time.find("-") > 0:
+ potential_time, potential_end_time = potential_time.split("-", 2)
+ self.end_time = figure_time(potential_end_time)
+
+ self.start_time = figure_time(potential_time)
+
+ #remove parts that worked
+ if self.start_time and potential_end_time and not self.end_time:
+ self.start_time = None #scramble
+ elif self.start_time:
+ activity = activity[activity.find(" ")+1:]
+
+ #see if we have description of activity somewhere here (delimited by comma)
+ if activity.find(",") > 0:
+ activity, self.description = activity.split(",", 1)
+ self.description = self.description.strip()
+
+ if "#" in self.description:
+ self.description, self.tags = self.description.split("#", 1)
+ self.tags = [tag.strip(", ") for tag in self.tags.split("#") if tag.strip(", ")]
+
+ if activity.find("@") > 0:
+ activity, self.category = activity.split("@", 1)
+ self.category = self.category.strip()
+
+ #this is most essential
+ if any([b in activity for b in ("bbq", "barbeque", "barbecue")]) and "omg" in activity:
+ self.ponies = True
+ self.description = "[ponies = 1], [rainbows = 0]"
+
+ #only thing left now is the activity name itself
+ self.activity = activity.strip()
+
+ tags = tags or ""
+ if tags and isinstance(tags, basestring):
+ tags = [tag.strip() for tag in tags.split(",") if tag.strip()]
+
+ # override implicit with explicit
+ self.category = category.replace("#", "").replace(",", "") or self.category or None
+ self.description = (description or "").replace("#", "") or self.description or None
+ self.tags = tags or self.tags or []
+ self.start_time = start_time or self.start_time or None
+ self.end_time = end_time or self.end_time or None
+
+
+ def __iter__(self):
+ keys = {
+ 'id': int(self.id),
+ 'activity': self.activity,
+ 'category': self.category,
+ 'description': self.description,
+ 'tags': [tag.encode("utf-8").strip() for tag in self.tags.split(",")],
+ 'date': calendar.timegm(self.date.timetuple()),
+ 'start_time': calendar.timegm(self.start_time.timetuple()),
+ 'end_time': calendar.timegm(self.end_time.timetuple()) if self.end_time else "",
+ 'delta': self.delta.seconds + self.delta.days * 24 * 60 * 60 #duration in seconds
+ }
+ return iter(keys.items())
+
+
+ def serialized_name(self):
+ res = self.activity
+
+ if self.category:
+ res += "@%s" % self.category
+
+ if self.description or self.tags:
+ res += ",%s %s" % (self.description or "",
+ " ".join(["#%s" % tag for tag in self.tags]))
+ return res
+
+ def __str__(self):
+ time = ""
+ if self.start_time:
+ self.start_time.strftime("%d-%m-%Y %H:%M")
+ if self.end_time:
+ time = "%s - %s" % (time, self.end_time.strftime("%H:%M"))
+ return "%s %s" % (time, self.serialized_name())
diff --git a/win32/hamster/lib/trophies.py b/win32/hamster/lib/trophies.py
new file mode 100644
index 0000000..2384cd9
--- /dev/null
+++ b/win32/hamster/lib/trophies.py
@@ -0,0 +1,201 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+"""Deal with trophies if there.
+ For now the trophy configuration of hamster reside in gnome-achievements, in
+ github:
+ http://github.com/tbaugis/gnome-achievements/blob/master/data/achievements/hamster-applet.trophies.xml
+ Eventually they will move into hamster.
+"""
+
+try:
+ from gnome_achievements import client as trophies_client
+ storage = trophies_client.Storage()
+except:
+ storage = None
+
+import stuff
+import datetime as dt
+
+def unlock(achievement_id):
+ if not storage: return
+ storage.unlock_achievement("hamster-applet", achievement_id)
+
+def check(achievement_id):
+ if not storage: return None
+ return storage.check_achievement("hamster-applet", achievement_id)
+
+def increment(counter_id, context = ""):
+ if not storage: return 0
+ return storage.increment_counter("hamster-applet", counter_id, context)
+
+
+
+def check_ongoing(todays_facts):
+ if not storage or not todays_facts: return
+
+ last_activity = None
+ if todays_facts[-1].end_time is None:
+ last_activity = todays_facts[-1]
+ last_activity.delta = dt.datetime.now() - last_activity.start_time
+
+ # overwhelmed: tracking for more than 16 hours during one day
+ total = stuff.duration_minutes([fact.delta for fact in todays_facts])
+ if total > 16 * 60:
+ unlock("overwhelmed")
+
+ if last_activity:
+ # Welcome! â?? track an activity for 10 minutes
+ if last_activity.delta >= dt.timedelta(minutes = 10):
+ unlock("welcome")
+
+ # in_the_zone - spend 6 hours non-stop on an activity
+ if last_activity.delta >= dt.timedelta(hours = 6):
+ unlock("in_the_zone")
+
+ # insomnia - meet the new day while tracking an activity
+ if last_activity.start_time.date() != dt.date.today():
+ unlock("insomnia")
+
+
+class Checker(object):
+ def __init__(self):
+ # use runtime flags where practical
+ self.flags = {}
+
+
+ def check_update_based(self, prev_id, new_id, fact):
+ if not storage: return
+
+ if not self.flags.get('last_update_id') or prev_id != self.flags['last_update_id']:
+ self.flags['same_updates_in_row'] = 0
+ elif self.flags['last_update_id'] == prev_id:
+ self.flags['same_updates_in_row'] +=1
+ self.flags['last_update_id'] = new_id
+
+
+ # all wrong â?? edited same activity 5 times in a row
+ if self.flags['same_updates_in_row'] == 5:
+ unlock("all_wrong")
+
+
+ def check_fact_based(self, fact):
+ """quite possibly these all could be called from the service as
+ there is bigger certainty as to what did actually happen"""
+
+ # checks fact based trophies
+ if not storage: return
+
+ # explicit over implicit
+ if not fact.activity: # TODO - parse_activity could return None for these cases
+ return
+
+ # full plate - use all elements of syntax parsing
+ derived_fact = stuff.Fact(fact.original_activity)
+ if all((derived_fact.category, derived_fact.description,
+ derived_fact.tags, derived_fact.start_time, derived_fact.end_time)):
+ unlock("full_plate")
+
+
+ # Jumper - hidden - made 10 switches within an hour (radical)
+ if not fact.end_time: # end time normally denotes switch
+ last_ten = self.flags.setdefault('last_ten_ongoing', [])
+ last_ten.append(fact)
+ last_ten = last_ten[-10:]
+
+ if len(last_ten) == 10 and (last_ten[-1].start_time - last_ten[0].start_time) <= dt.timedelta(hours=1):
+ unlock("jumper")
+
+ # good memory - entered three past activities during a day
+ if fact.end_time and fact.start_time.date() == dt.date.today():
+ good_memory = increment("past_activities", dt.date.today().strftime("%Y%m%d"))
+ if good_memory == 3:
+ unlock("good_memory")
+
+ # layering - entered 4 activities in a row in one of previous days, each one overlapping the previous one
+ # avoiding today as in that case the layering might be automotical
+ last_four = self.flags.setdefault('last_four', [])
+ last_four.append(fact)
+ last_four = last_four[-4:]
+ if len(last_four) == 4:
+ layered = True
+ for prev, next in zip(last_four, last_four[1:]):
+ if next.start_time.date() == dt.date.today() or \
+ next.start_time < prev.start_time or \
+ (prev.end_time and prev.end_time < next.start_time):
+ layered = False
+
+ if layered:
+ unlock("layered")
+
+ # wait a minute! - Switch to a new activity within 60 seconds
+ if len(last_four) >= 2:
+ prev, next = last_four[-2:]
+ if prev.end_time is None and next.end_time is None and (next.start_time - prev.start_time) < dt.timedelta(minutes = 1):
+ unlock("wait_a_minute")
+
+
+ # alpha bravo charlie â?? used delta times to enter at least 50 activities
+ if fact.start_time and fact.original_activity.startswith("-"):
+ counter = increment("hamster-applet", "alpha_bravo_charlie")
+ if counter == 50:
+ unlock("alpha_bravo_charlie")
+
+
+ # cryptic - hidden - used word shorter than 4 letters for the activity name
+ if len(fact.activity) < 4:
+ unlock("cryptic")
+
+ # madness â?? hidden â?? entered an activity in all caps
+ if fact.activity == fact.activity.upper():
+ unlock("madness")
+
+ # verbose - hidden - description longer than 5 words
+ if fact.description and len([word for word in fact.description.split(" ") if len(word.strip()) > 2]) >= 5:
+ unlock("verbose")
+
+ # overkill - used 8 or more tags on a single activity
+ if len(fact.tags) >=8:
+ unlock("overkill")
+
+ # ponies - hidden - discovered the ponies
+ if fact.ponies:
+ unlock("ponies")
+
+
+ # TODO - after the trophies have been unlocked there is not much point in going on
+ # patrys complains about who's gonna garbage collect. should think
+ # about this
+ if not check("ultra_focused"):
+ activity_count = increment("hamster-applet", "focused_%s %s" % (fact.activity, fact.category or ""))
+ # focused â?? 100 facts with single activity
+ if activity_count == 100:
+ unlock("focused")
+
+ # ultra focused â?? 500 facts with single activity
+ if activity_count == 500:
+ unlock("ultra_focused")
+
+ # elite - hidden - start an activity at 13:37
+ if dt.datetime.now().hour == 13 and dt.datetime.now().minute == 37:
+ unlock("elite")
+
+
+
+checker = Checker()
diff --git a/win32/hamster/overview.py b/win32/hamster/overview.py
new file mode 100644
index 0000000..ab20f49
--- /dev/null
+++ b/win32/hamster/overview.py
@@ -0,0 +1,415 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+import pygtk
+pygtk.require('2.0')
+
+import os
+import datetime as dt
+import calendar
+import webbrowser
+
+import gtk, gobject
+import pango
+
+import widgets, reports
+from configuration import runtime, conf, dialogs, load_ui_file
+from lib import stuff, trophies
+from lib.i18n import C_
+
+from overview_activities import OverviewBox
+from overview_totals import TotalsBox
+
+
+class Overview(object):
+ def __init__(self, parent = None):
+ self.parent = parent# determine if app should shut down on close
+ self._gui = load_ui_file("overview.ui")
+ self.report_chooser = None
+
+ self.facts = None
+
+ self.window = self.get_widget("tabs_window")
+
+ self.day_start = conf.get("day_start_minutes")
+ self.day_start = dt.time(self.day_start / 60, self.day_start % 60)
+
+ self.view_date = (dt.datetime.today() - dt.timedelta(hours = self.day_start.hour,
+ minutes = self.day_start.minute)).date()
+
+ self.range_pick = widgets.RangePick()
+ self.get_widget("range_pick_box").add(self.range_pick)
+ self.range_pick.connect("range-selected", self.on_range_selected)
+
+ #set to monday
+ self.start_date = self.view_date - dt.timedelta(self.view_date.weekday() + 1)
+
+ # look if we need to start on sunday or monday
+ self.start_date = self.start_date + dt.timedelta(stuff.locale_first_weekday())
+
+ # see if we have not gotten carried away too much in all these calculations
+ if (self.view_date - self.start_date) == dt.timedelta(7):
+ self.start_date += dt.timedelta(7)
+
+ self.end_date = self.start_date + dt.timedelta(6)
+
+ self.overview = OverviewBox()
+ self.get_widget("overview_tab").add(self.overview)
+ self.fact_tree = self.overview.fact_tree # TODO - this is upside down, should maybe get the overview tab over here
+ self.fact_tree.connect("cursor-changed", self.on_fact_selection_changed)
+
+ self.fact_tree.connect("button-press-event", self.on_fact_tree_button_press)
+
+ self.reports = TotalsBox()
+ self.get_widget("reports_tab").add(self.reports)
+
+ self.current_range = "week"
+
+ self.timechart = widgets.TimeChart()
+ self.timechart.connect("zoom-out-clicked", self.on_timechart_zoom_out_clicked)
+ self.timechart.connect("range-picked", self.on_timechart_new_range)
+ self.timechart.day_start = self.day_start
+
+ self.get_widget("by_day_box").add(self.timechart)
+
+ self._gui.connect_signals(self)
+ runtime.storage.connect('activities-changed',self.after_activity_update)
+ runtime.storage.connect('facts-changed',self.after_activity_update)
+
+ conf.connect('conf-changed', self.on_conf_change)
+
+ if conf.get("overview_window_maximized"):
+ self.window.maximize()
+ else:
+ window_box = conf.get("overview_window_box")
+ if window_box:
+ x, y, w, h = (int(i) for i in window_box)
+ self.window.move(x, y)
+ self.window.resize(w, h)
+ else:
+ self.window.set_position(gtk.WIN_POS_CENTER)
+
+ self.window.show_all()
+
+ self.search()
+
+ def on_fact_tree_button_press(self, treeview, event):
+ if event.button == 3:
+ x = int(event.x)
+ y = int(event.y)
+ time = event.time
+ pthinfo = treeview.get_path_at_pos(x, y)
+ if pthinfo is not None:
+ path, col, cellx, celly = pthinfo
+ treeview.grab_focus()
+ treeview.set_cursor( path, col, 0)
+ self.get_widget("fact_tree_popup").popup( None, None, None, event.button, time)
+ return True
+
+ def on_timechart_new_range(self, chart, start_date, end_date):
+ self.start_date = start_date
+ self.end_date = end_date
+ self.apply_range_select()
+
+ def on_timechart_zoom_out_clicked(self, chart):
+ if (self.end_date - self.start_date < dt.timedelta(days=6)):
+ self.on_week_activate(None)
+ elif (self.end_date - self.start_date < dt.timedelta(days=27)):
+ self.on_month_activate(None)
+ else:
+ self.current_range = "manual"
+ self.start_date = self.view_date.replace(day=1, month=1)
+ self.end_date = self.start_date.replace(year = self.start_date.year + 1) - dt.timedelta(days=1)
+ self.apply_range_select()
+
+
+
+
+ def search(self):
+ if self.start_date > self.end_date: # make sure the end is always after beginning
+ self.start_date, self.end_date = self.end_date, self.start_date
+
+ search_terms = self.get_widget("search").get_text().decode("utf8", "replace")
+ self.facts = runtime.storage.get_facts(self.start_date, self.end_date, search_terms)
+
+ self.get_widget("export").set_sensitive(len(self.facts) > 0)
+
+ self.set_title()
+
+ self.range_pick.set_range(self.start_date, self.end_date, self.view_date)
+
+ durations = [(fact.start_time, fact.delta) for fact in self.facts]
+ self.timechart.draw(durations, self.start_date, self.end_date)
+
+ if self.get_widget("window_tabs").get_current_page() == 0:
+ self.overview.search(self.start_date, self.end_date, self.facts)
+ self.reports.search(self.start_date, self.end_date, self.facts)
+ else:
+ self.reports.search(self.start_date, self.end_date, self.facts)
+ self.overview.search(self.start_date, self.end_date, self.facts)
+
+ def set_title(self):
+ self.title = stuff.format_range(self.start_date, self.end_date)
+ self.window.set_title(self.title.decode("utf-8"))
+
+
+ def on_conf_change(self, event, key, value):
+ if key == "day_start_minutes":
+ self.day_start = dt.time(value / 60, value % 60)
+ self.timechart.day_start = self.day_start
+ self.search()
+
+ def on_fact_selection_changed(self, tree):
+ """ enables and disables action buttons depending on selected item """
+ fact = tree.get_selected_fact()
+ real_fact = fact is not None and isinstance(fact, stuff.Fact)
+
+ self.get_widget('remove').set_sensitive(real_fact)
+ self.get_widget('edit').set_sensitive(real_fact)
+
+ return True
+
+ def after_activity_update(self, widget):
+ self.search()
+
+ def on_search_icon_press(self, widget, position, data):
+ if position == gtk.ENTRY_ICON_SECONDARY:
+ widget.set_text('')
+
+ self.search()
+
+ def on_search_activate(self, widget):
+ self.search()
+
+ def on_search_changed(self, widget):
+ has_text = len(widget.get_text()) > 0
+ widget.set_icon_sensitive(gtk.ENTRY_ICON_SECONDARY, has_text)
+
+ def on_export_activate(self, widget):
+ def on_report_chosen(widget, format, path):
+ self.report_chooser = None
+ reports.simple(self.facts, self.start_date, self.end_date, format, path)
+
+ if format == ("html"):
+ webbrowser.open_new("file://%s" % path)
+ else:
+ try:
+ gtk.show_uri(gtk.gdk.Screen(), "file://%s" % os.path.split(path)[0], 0L)
+ except:
+ pass # bug 626656 - no use in capturing this one i think
+
+ def on_report_chooser_closed(widget):
+ self.report_chooser = None
+
+ if not self.report_chooser:
+ self.report_chooser = widgets.ReportChooserDialog()
+ self.report_chooser.connect("report-chosen", on_report_chosen)
+ self.report_chooser.connect("report-chooser-closed",
+ on_report_chooser_closed)
+ self.report_chooser.show(self.start_date, self.end_date)
+ else:
+ self.report_chooser.present()
+
+
+ def apply_range_select(self):
+ if self.view_date < self.start_date:
+ self.view_date = self.start_date
+
+ if self.view_date > self.end_date:
+ self.view_date = self.end_date
+
+ self.search()
+
+
+ def on_range_selected(self, widget, range, start, end):
+ self.current_range = range
+ self.start_date = start
+ self.end_date = end
+ self.apply_range_select()
+
+ def on_day_activate(self, button):
+ self.current_range = "day"
+ self.start_date = self.view_date
+ self.end_date = self.start_date + dt.timedelta(0)
+ self.apply_range_select()
+
+ def on_week_activate(self, button):
+ self.current_range = "week"
+ self.start_date, self.end_date = stuff.week(self.view_date)
+ self.apply_range_select()
+
+ def on_month_activate(self, button):
+ self.current_range = "month"
+ self.start_date, self.end_date = stuff.month(self.view_date)
+ self.apply_range_select()
+
+ def on_manual_range_apply_clicked(self, button):
+ self.current_range = "manual"
+ cal_date = self.get_widget("start_calendar").get_date()
+ self.start_date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+
+ cal_date = self.get_widget("end_calendar").get_date()
+ self.end_date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+
+ self.apply_range_select()
+
+
+ def on_tabs_window_configure_event(self, window, event):
+ # this is required so that the rows would grow on resize
+ self.fact_tree.fix_row_heights()
+
+ def on_tabs_window_state_changed(self, window, event):
+ # not enough space - maximized the overview window
+ maximized = window.get_window().get_state() & gtk.gdk.WINDOW_STATE_MAXIMIZED
+ if maximized:
+ trophies.unlock("not_enough_space")
+
+
+
+
+ def on_prev_activate(self, action):
+ if self.current_range == "day":
+ self.start_date -= dt.timedelta(1)
+ self.end_date -= dt.timedelta(1)
+ elif self.current_range == "week":
+ self.start_date -= dt.timedelta(7)
+ self.end_date -= dt.timedelta(7)
+ elif self.current_range == "month":
+ self.end_date = self.start_date - dt.timedelta(1)
+ first_weekday, days_in_month = calendar.monthrange(self.end_date.year, self.end_date.month)
+ self.start_date = self.end_date - dt.timedelta(days_in_month - 1)
+ else:
+ # manual range - just jump to the next window
+ days = (self.end_date - self.start_date) + dt.timedelta(days = 1)
+ self.start_date = self.start_date - days
+ self.end_date = self.end_date - days
+
+ self.view_date = self.start_date
+ self.search()
+
+ def on_next_activate(self, action):
+ if self.current_range == "day":
+ self.start_date += dt.timedelta(1)
+ self.end_date += dt.timedelta(1)
+ elif self.current_range == "week":
+ self.start_date += dt.timedelta(7)
+ self.end_date += dt.timedelta(7)
+ elif self.current_range == "month":
+ self.start_date = self.end_date + dt.timedelta(1)
+ first_weekday, days_in_month = calendar.monthrange(self.start_date.year, self.start_date.month)
+ self.end_date = self.start_date + dt.timedelta(days_in_month - 1)
+ else:
+ # manual range - just jump to the next window
+ days = (self.end_date - self.start_date) + dt.timedelta(days = 1)
+ self.start_date = self.start_date + days
+ self.end_date = self.end_date + days
+
+ self.view_date = self.start_date
+ self.search()
+
+
+ def on_home_activate(self, action):
+ self.view_date = (dt.datetime.today() - dt.timedelta(hours = self.day_start.hour,
+ minutes = self.day_start.minute)).date()
+ if self.current_range == "day":
+ self.start_date = self.view_date
+ self.end_date = self.start_date + dt.timedelta(0)
+
+ elif self.current_range == "week":
+ self.start_date = self.view_date - dt.timedelta(self.view_date.weekday() + 1)
+ self.start_date = self.start_date + dt.timedelta(stuff.locale_first_weekday())
+ self.end_date = self.start_date + dt.timedelta(6)
+ elif self.current_range == "month":
+ self.start_date = self.view_date - dt.timedelta(self.view_date.day - 1) #set to beginning of month
+ first_weekday, days_in_month = calendar.monthrange(self.view_date.year, self.view_date.month)
+ self.end_date = self.start_date + dt.timedelta(days_in_month - 1)
+ else:
+ days = (self.end_date - self.start_date)
+ self.start_date = self.view_date
+ self.end_date = self.view_date + days
+
+ self.search()
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def on_window_tabs_switch_page(self, notebook, page, pagenum):
+ if pagenum == 0:
+ self.on_fact_selection_changed(self.fact_tree)
+ elif pagenum == 1:
+ self.get_widget('remove').set_sensitive(False)
+ self.get_widget('edit').set_sensitive(False)
+ self.reports.do_charts()
+
+
+ def on_add_activate(self, action):
+ fact = self.fact_tree.get_selected_fact()
+ if not fact:
+ selected_date = self.start_date
+ elif isinstance(fact, dt.date):
+ selected_date = fact
+ else:
+ selected_date = fact["date"]
+
+ dialogs.edit.show(self, fact_date = selected_date)
+
+ def on_remove_activate(self, button):
+ self.overview.delete_selected()
+
+
+ def on_edit_activate(self, button):
+ fact = self.fact_tree.get_selected_fact()
+ if not fact or isinstance(fact, dt.date):
+ return
+ dialogs.edit.show(self, fact_id = fact.id)
+
+ def on_tabs_window_deleted(self, widget, event):
+ self.close_window()
+
+ def on_window_key_pressed(self, tree, event_key):
+ if (event_key.keyval == gtk.keysyms.Escape
+ or (event_key.keyval == gtk.keysyms.w
+ and event_key.state & gtk.gdk.CONTROL_MASK)):
+ self.close_window()
+
+ def on_close_activate(self, action):
+ self.close_window()
+
+ def close_window(self):
+ # properly saving window state and position
+ maximized = self.window.get_window().get_state() == gtk.gdk.WINDOW_STATE_MAXIMIZED
+ conf.set("overview_window_maximized", maximized)
+
+ # make sure to remember dimensions only when in normal state
+ if maximized == False and not self.window.get_window().get_state() == gtk.gdk.WINDOW_STATE_ICONIFIED:
+ x, y = self.window.get_position()
+ w, h = self.window.get_size()
+ conf.set("overview_window_box", [x, y, w, h])
+
+
+ if not self.parent:
+ gtk.main_quit()
+ else:
+ self.window.destroy()
+ return False
+
+ def show(self):
+ self.window.show()
diff --git a/win32/hamster/overview_activities.py b/win32/hamster/overview_activities.py
new file mode 100644
index 0000000..6b7d0b7
--- /dev/null
+++ b/win32/hamster/overview_activities.py
@@ -0,0 +1,203 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+import pygtk
+pygtk.require('2.0')
+
+import os
+import gtk, gobject
+
+import webbrowser
+
+from itertools import groupby
+from gettext import ngettext
+
+import datetime as dt
+import calendar
+import time
+from collections import defaultdict
+
+import widgets
+from configuration import runtime, dialogs
+from lib import stuff, trophies
+from lib.i18n import C_
+
+
+class OverviewBox(gtk.VBox):
+ def __init__(self):
+ gtk.VBox.__init__(self)
+ self.set_border_width(6)
+
+ scroll = gtk.ScrolledWindow()
+ scroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
+ scroll.set_shadow_type(gtk.SHADOW_IN)
+
+ self.start_date, self.end_date = None, None
+ self.facts = []
+
+ self.fact_tree = widgets.FactTree()
+ self.fact_tree.connect("row-activated", self.on_facts_row_activated)
+ self.fact_tree.connect("key-press-event", self.on_facts_keys)
+ self.fact_tree.connect("edit-clicked", lambda tree, fact: self.on_edit_clicked(fact))
+
+ scroll.add(self.fact_tree)
+ self.add(scroll)
+
+
+ def search(self, start_date, end_date, facts):
+ self.start_date = start_date
+ self.end_date = end_date
+ self.facts = facts
+ self.fill_facts_tree()
+
+
+ def fill_facts_tree(self):
+ dates = defaultdict(list)
+
+ # fill blanks
+ for i in range((self.end_date - self.start_date).days + 1):
+ dates[self.start_date + dt.timedelta(i)] = []
+
+ #update with facts for the day
+ for date, facts in groupby(self.facts, lambda fact: fact.date):
+ dates[date] = list(facts)
+
+
+ # detach model to trigger selection memory and speeding up
+ self.fact_tree.detach_model()
+
+ # push facts in tree
+ for date, facts in sorted(dates.items(), key=lambda t: t[0]):
+ fact_date = date.strftime(C_("overview list", "%A, %b %d"))
+ self.fact_tree.add_group(fact_date, date, facts)
+
+ self.fact_tree.attach_model()
+
+
+ def delete_selected(self):
+ fact = self.fact_tree.get_selected_fact()
+ if not fact or isinstance(fact, dt.date):
+ return
+
+ runtime.storage.remove_fact(fact.id)
+
+ def copy_selected(self):
+ fact = self.fact_tree.get_selected_fact()
+
+ if isinstance(fact, dt.date):
+ return # heading
+
+ fact_str = "%s-%s %s" % (fact.start_time.strftime("%H:%M"),
+ (fact.end_time or dt.datetime.now()).strftime("%H:%M"),
+ fact["name"])
+
+ if fact.category:
+ fact_str += "@%s" % fact.category
+
+ if fact.description or fact.tags:
+ tag_str = " ".join("#%s" % tag for tag in fact.tags)
+ fact_str += ", %s" % ("%s %s" % (fact.description or "", tag_str)).strip()
+
+ clipboard = gtk.Clipboard()
+ clipboard.set_text(fact_str)
+
+
+ """ events """
+ def on_edit_clicked(self, fact):
+ self.launch_edit(fact)
+
+ def on_facts_row_activated(self, tree, path, column):
+ self.launch_edit(tree.get_selected_fact())
+
+ def launch_edit(self, fact_or_date):
+ if isinstance(fact_or_date, dt.date):
+ dialogs.edit.show(self, fact_date = fact_or_date)
+ else:
+ dialogs.edit.show(self, fact_id = fact_or_date.id)
+
+
+ def on_facts_keys(self, tree, event):
+ if (event.keyval == gtk.keysyms.Delete):
+ self.delete_selected()
+ return True
+ elif (event.keyval == gtk.keysyms.Insert):
+ self.launch_edit(self.fact_tree.get_selected_fact())
+ return True
+ elif event.keyval == gtk.keysyms.c and event.state & gtk.gdk.CONTROL_MASK:
+ self.copy_selected()
+ return True
+ elif event.keyval == gtk.keysyms.v and event.state & gtk.gdk.CONTROL_MASK:
+ self.check_clipboard()
+ return True
+
+ return False
+
+ def check_clipboard(self):
+ clipboard = gtk.Clipboard()
+ clipboard.request_text(self.on_clipboard_text)
+
+ def on_clipboard_text(self, clipboard, text, data):
+ # first check that we have a date selected
+ fact = self.fact_tree.get_selected_fact()
+
+ if not fact:
+ return
+
+ if isinstance(fact, dt.date):
+ selected_date = fact
+ else:
+ selected_date = fact.date
+
+ fact = stuff.Fact(text.decode("utf-8"))
+
+ if not all((fact.activity, fact.start_time, fact.end_time)):
+ return
+
+ fact.start_time = fact.start_time.replace(year = selected_date.year,
+ month = selected_date.month,
+ day = selected_date.day)
+ fact.end_time = fact.end_time.replace(year = selected_date.year,
+ month = selected_date.month,
+ day = selected_date.day)
+ new_id = runtime.storage.add_fact(fact)
+
+ # You can do that?! - copy/pasted an activity
+ trophies.unlock("can_do_that")
+
+ if new_id:
+ self.fact_tree.select_fact(new_id)
+
+if __name__ == "__main__":
+ gtk.window_set_default_icon_name("hamster-applet")
+ window = gtk.Window()
+ window.set_title("Hamster - reports")
+ window.set_size_request(800, 600)
+ overview = OverviewBox()
+ window.add(overview)
+ window.connect("delete_event", lambda *args: gtk.main_quit())
+ window.show_all()
+
+ start_date = dt.date.today() - dt.timedelta(days=30)
+ end_date = dt.date.today()
+ facts = runtime.storage.get_facts(start_date, end_date)
+ overview.search(start_date, end_date, facts)
+
+
+ gtk.main()
diff --git a/win32/hamster/overview_totals.py b/win32/hamster/overview_totals.py
new file mode 100644
index 0000000..dd6d13d
--- /dev/null
+++ b/win32/hamster/overview_totals.py
@@ -0,0 +1,253 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import datetime as dt
+import calendar
+import time
+import webbrowser
+from itertools import groupby
+import locale
+
+from gettext import ngettext
+
+import os
+import gtk, gobject
+from collections import defaultdict
+
+import widgets, reports
+from configuration import runtime, dialogs, load_ui_file
+from lib import stuff, charting
+from lib.i18n import C_
+
+
+class TotalsBox(gtk.VBox):
+ def __init__(self):
+ gtk.VBox.__init__(self)
+ self._gui = load_ui_file("overview_totals.ui")
+ self.get_widget("reports_vbox").reparent(self) #mine!
+
+ self.start_date, self.end_date = None, None
+
+ #graphs
+ x_offset = 0.4 # align all graphs to the left edge
+
+
+ self.category_chart = charting.Chart(max_bar_width = 20,
+ legend_width = x_offset,
+ value_format = "%.1f")
+ self.category_chart.connect("bar-clicked", self.on_category_clicked)
+ self.selected_categories = []
+ self.category_sums = None
+
+ self.get_widget("totals_by_category").add(self.category_chart);
+
+ self.activity_chart = charting.Chart(max_bar_width = 20,
+ legend_width = x_offset,
+ value_format = "%.1f")
+ self.activity_chart.connect("bar-clicked", self.on_activity_clicked)
+ self.selected_activities = []
+ self.activity_sums = None
+
+ self.get_widget("totals_by_activity").add(self.activity_chart);
+
+ self.tag_chart = charting.Chart(max_bar_width = 20,
+ legend_width = x_offset,
+ value_format = "%.1f")
+ self.tag_chart.connect("bar-clicked", self.on_tag_clicked)
+ self.selected_tags = []
+ self.tag_sums = None
+
+ self.get_widget("totals_by_tag").add(self.tag_chart);
+
+ self._gui.connect_signals(self)
+
+ self.report_chooser = None
+
+
+ def on_category_clicked(self, widget, key):
+ if key in self.category_chart.selected_keys:
+ self.category_chart.selected_keys.remove(key)
+ self.selected_categories.remove(key)
+ else:
+ self.category_chart.selected_keys.append(key)
+ self.selected_categories.append(key)
+
+ self.calculate_totals()
+ self.do_charts()
+
+ def on_activity_clicked(self, widget, key):
+ if key in self.activity_chart.selected_keys:
+ self.activity_chart.selected_keys.remove(key)
+ self.selected_activities.remove(key)
+ else:
+ self.activity_chart.selected_keys.append(key)
+ self.selected_activities.append(key)
+ self.calculate_totals()
+ self.do_charts()
+
+ def on_tag_clicked(self, widget, key):
+ if key in self.tag_chart.selected_keys:
+ self.tag_chart.selected_keys.remove(key)
+ self.selected_tags.remove(key)
+ else:
+ self.tag_chart.selected_keys.append(key)
+ self.selected_tags.append(key)
+ self.calculate_totals()
+ self.do_charts()
+
+
+ def search(self, start_date, end_date, facts):
+ self.facts = facts
+ self.category_sums, self.activity_sums, self.tag_sums = [], [], []
+ self.selected_categories, self.selected_activities, self.selected_tags = [], [], []
+ self.category_chart.selected_keys, self.activity_chart.selected_keys, self.tag_chart.selected_keys = [], [], []
+
+ self.start_date = start_date
+ self.end_date = end_date
+
+ self.do_graph()
+
+
+ def do_graph(self):
+ if self.facts:
+ self.get_widget("no_data_label").hide()
+ self.get_widget("charts").show()
+ self.get_widget("total_hours").show()
+ self.calculate_totals()
+ self.do_charts()
+ else:
+ self.get_widget("no_data_label").show()
+ self.get_widget("charts").hide()
+ self.get_widget("total_hours").hide()
+
+
+ def calculate_totals(self):
+ if not self.facts:
+ return
+ facts = self.facts
+
+ category_sums, activity_sums, tag_sums = defaultdict(dt.timedelta), defaultdict(dt.timedelta), defaultdict(dt.timedelta),
+
+ for fact in facts:
+ if self.selected_categories and fact.category not in self.selected_categories:
+ continue
+ if self.selected_activities and fact.activity not in self.selected_activities:
+ continue
+ if self.selected_tags and len(set(self.selected_tags) - set(fact.tags)) > 0:
+ continue
+
+ category_sums[fact.category] += fact.delta
+ activity_sums[fact.activity] += fact.delta
+
+ for tag in fact.tags:
+ tag_sums[tag] += fact.delta
+
+ total_label = _("%s hours tracked total") % locale.format("%.1f", stuff.duration_minutes([fact.delta for fact in facts]) / 60.0)
+ self.get_widget("total_hours").set_text(total_label)
+
+
+ for key in category_sums:
+ category_sums[key] = stuff.duration_minutes(category_sums[key]) / 60.0
+
+ for key in activity_sums:
+ activity_sums[key] = stuff.duration_minutes(activity_sums[key]) / 60.0
+
+ for key in tag_sums:
+ tag_sums[key] = stuff.duration_minutes(tag_sums[key]) / 60.0
+
+
+ #category totals
+ if category_sums:
+ if self.category_sums:
+ category_sums = [(key, category_sums[key] or 0) for key in self.category_sums[0]]
+ else:
+ category_sums = sorted(category_sums.items(), key=lambda x:x[1], reverse = True)
+
+ self.category_sums = zip(*category_sums)
+
+ # activity totals
+ if self.activity_sums:
+ activity_sums = [(key, activity_sums[key] or 0) for key in self.activity_sums[0]]
+ else:
+ activity_sums = sorted(activity_sums.items(), key=lambda x:x[1], reverse = True)
+
+ self.activity_sums = zip(*activity_sums)
+
+
+ # tag totals
+ if tag_sums:
+ if self.tag_sums:
+ tag_sums = [(key, tag_sums[key] or 0) for key in self.tag_sums[0]]
+ else:
+ tag_sums = sorted(tag_sums.items(), key=lambda x:x[1], reverse = True)
+ self.tag_sums = zip(*tag_sums)
+
+
+ def do_charts(self):
+ self.get_widget("totals_by_category").set_size_request(10,10)
+ if self.category_sums:
+ self.get_widget("totals_by_category").set_size_request(-1, len(self.category_sums[0]) * 20)
+ self.category_chart.plot(*self.category_sums)
+ else:
+ self.get_widget("totals_by_category").set_size_request(-1, 10)
+ self.category_chart.plot([],[])
+
+ if self.activity_sums:
+ self.get_widget("totals_by_activity").set_size_request(10,10)
+ self.get_widget("totals_by_activity").set_size_request(-1, len(self.activity_sums[0]) * 20)
+ self.activity_chart.plot(*self.activity_sums)
+ else:
+ self.get_widget("totals_by_category").set_size_request(-1, 10)
+ self.activity_chart.plot([],[])
+
+ self.get_widget("totals_by_tag").set_size_request(10,10)
+ if self.tag_sums:
+ self.get_widget("totals_by_tag").set_size_request(-1, len(self.tag_sums[0]) * 20)
+ self.tag_chart.plot(*self.tag_sums)
+ else:
+ self.get_widget("totals_by_tag").set_size_request(-1, 10)
+ self.tag_chart.plot([],[])
+
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def on_statistics_button_clicked(self, button):
+ dialogs.stats.show(self)
+
+
+
+if __name__ == "__main__":
+ gtk.window_set_default_icon_name("hamster-applet")
+ window = gtk.Window()
+ window.set_title("Hamster - reports")
+ window.set_size_request(800, 600)
+ reports = ReportsBox()
+ window.add(reports)
+ window.connect("delete_event", lambda *args: gtk.main_quit())
+ window.show_all()
+
+ start_date = dt.date.today() - dt.timedelta(days=30)
+ end_date = dt.date.today()
+ facts = runtime.storage.get_facts(start_date, end_date)
+ reports.search(start_date, end_date, facts)
+
+
+ gtk.main()
diff --git a/win32/hamster/preferences.py b/win32/hamster/preferences.py
new file mode 100755
index 0000000..3e9e757
--- /dev/null
+++ b/win32/hamster/preferences.py
@@ -0,0 +1,721 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007, 2008 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+import pygtk
+pygtk.require('2.0')
+
+import os
+import gobject
+import gtk
+
+import datetime as dt
+
+try:
+ import wnck
+except:
+ wnck = None
+
+def get_prev(selection, model):
+ (model, iter) = selection.get_selected()
+
+ #previous item
+ path = model.get_path(iter)[0] - 1
+ if path >= 0:
+ return model.get_iter_from_string(str(path))
+ else:
+ return None
+
+class CategoryStore(gtk.ListStore):
+ def __init__(self):
+ #id, name, color_code, order
+ gtk.ListStore.__init__(self, int, str)
+
+ def load(self):
+ category_list = runtime.storage.get_categories()
+
+ for category in category_list:
+ self.append([category['id'], category['name']])
+
+ self.unsorted_category = self.append([-1, _("Unsorted")]) # all activities without category
+
+
+class ActivityStore(gtk.ListStore):
+ def __init__(self):
+ #id, name, category_id, order
+ gtk.ListStore.__init__(self, int, str, int)
+
+ def load(self, category_id):
+ self.clear()
+
+ if category_id is None:
+ return
+
+ activity_list = runtime.storage.get_category_activities(category_id)
+
+ for activity in activity_list:
+ self.append([activity['id'],
+ activity['name'],
+ activity['category_id']])
+
+
+class WorkspaceStore(gtk.ListStore):
+ def __init__(self):
+ #id, name, color_code, order
+ gtk.ListStore.__init__(self, int, gobject.TYPE_PYOBJECT, str)
+
+formats = ["fixed", "symbolic", "minutes"]
+appearances = ["text", "icon", "both"]
+
+from configuration import runtime, conf, load_ui_file
+import widgets
+from lib import stuff, trophies
+
+class PreferencesEditor:
+ TARGETS = [
+ ('MY_TREE_MODEL_ROW', gtk.TARGET_SAME_WIDGET, 0),
+ ('MY_TREE_MODEL_ROW', gtk.TARGET_SAME_APP, 0),
+ ]
+
+
+ def __init__(self, parent = None):
+ self.parent = parent
+ self._gui = load_ui_file("preferences.ui")
+ self.window = self.get_widget('preferences_window')
+
+ # Translators: 'None' refers here to the Todo list choice in Hamster preferences (Tracking tab)
+ self.activities_sources = [("", _("None")),
+ ("evo", "Evolution"),
+ ("gtg", "Getting Things Gnome")]
+ self.todo_combo = gtk.combo_box_new_text()
+ for code, label in self.activities_sources:
+ self.todo_combo.append_text(label)
+ self.todo_combo.connect("changed", self.on_todo_combo_changed)
+ self.get_widget("todo_pick").add(self.todo_combo)
+
+
+ # create and fill activity tree
+ self.activity_tree = self.get_widget('activity_list')
+ self.get_widget("activities_label").set_mnemonic_widget(self.activity_tree)
+ self.activity_store = ActivityStore()
+
+ self.activityColumn = gtk.TreeViewColumn(_("Name"))
+ self.activityColumn.set_expand(True)
+ self.activityCell = gtk.CellRendererText()
+ self.activityCell.connect('edited', self.activity_name_edited_cb, self.activity_store)
+ self.activityColumn.pack_start(self.activityCell, True)
+ self.activityColumn.set_attributes(self.activityCell, text=1)
+ self.activityColumn.set_sort_column_id(1)
+ self.activity_tree.append_column(self.activityColumn)
+
+ self.activity_tree.set_model(self.activity_store)
+
+ self.selection = self.activity_tree.get_selection()
+ self.selection.connect('changed', self.activity_changed, self.activity_store)
+
+
+ # create and fill category tree
+ self.category_tree = self.get_widget('category_list')
+ self.get_widget("categories_label").set_mnemonic_widget(self.category_tree)
+ self.category_store = CategoryStore()
+
+ self.categoryColumn = gtk.TreeViewColumn(_("Category"))
+ self.categoryColumn.set_expand(True)
+ self.categoryCell = gtk.CellRendererText()
+ self.categoryCell.connect('edited', self.category_edited_cb, self.category_store)
+
+ self.categoryColumn.pack_start(self.categoryCell, True)
+ self.categoryColumn.set_attributes(self.categoryCell, text=1)
+ self.categoryColumn.set_sort_column_id(1)
+ self.categoryColumn.set_cell_data_func(self.categoryCell, self.unsorted_painter)
+ self.category_tree.append_column(self.categoryColumn)
+
+ self.category_store.load()
+ self.category_tree.set_model(self.category_store)
+
+ selection = self.category_tree.get_selection()
+ selection.connect('changed', self.category_changed_cb, self.category_store)
+
+ self.day_start = widgets.TimeInput(dt.time(5,30))
+ self.get_widget("day_start_placeholder").add(self.day_start)
+ self.day_start.connect("time-entered", self.on_day_start_changed)
+
+
+ self.load_config()
+
+ # Allow enable drag and drop of rows including row move
+ self.activity_tree.enable_model_drag_source( gtk.gdk.BUTTON1_MASK,
+ self.TARGETS,
+ gtk.gdk.ACTION_DEFAULT|
+ gtk.gdk.ACTION_MOVE)
+
+ self.category_tree.enable_model_drag_dest(self.TARGETS,
+ gtk.gdk.ACTION_MOVE)
+
+ self.activity_tree.connect("drag_data_get", self.drag_data_get_data)
+
+ self.category_tree.connect("drag_data_received",
+ self.on_category_drop)
+
+ #select first category
+ selection = self.category_tree.get_selection()
+ selection.select_path((0,))
+
+ self.prev_selected_activity = None
+ self.prev_selected_category = None
+
+
+ # create and fill workspace tree
+ self.workspace_tree = self.get_widget('workspace_list')
+# self.get_widget("workspaces_label").set_mnemonic_widget(self.workspace_tree)
+ self.workspace_store = WorkspaceStore()
+
+ self.wNameColumn = gtk.TreeViewColumn(_("Name"))
+ self.wNameColumn.set_expand(True)
+ self.wNameCell = gtk.CellRendererText()
+ self.wNameCell.set_property('editable', False)
+ self.wActivityColumn = gtk.TreeViewColumn(_("Activity"))
+ self.wActivityColumn.set_expand(True)
+ self.wActivityCell = gtk.CellRendererText()
+ self.wActivityCell.set_property('editable', True)
+ self.wActivityCell.connect('edited', self.on_workspace_activity_edited)
+
+ self.wNameColumn.pack_start(self.wNameCell, True)
+ self.wNameColumn.set_attributes(self.wNameCell)
+ self.wNameColumn.set_sort_column_id(1)
+ self.wNameColumn.set_cell_data_func(self.wNameCell, self.workspace_name_celldata)
+ self.workspace_tree.append_column(self.wNameColumn)
+ self.wActivityColumn.pack_start(self.wActivityCell, True)
+ self.wActivityColumn.set_attributes(self.wActivityCell, text=2)
+ self.wActivityColumn.set_sort_column_id(1)
+ self.workspace_tree.append_column(self.wActivityColumn)
+
+ self.workspace_tree.set_model(self.workspace_store)
+
+ # disable notification thing if pynotify is not available
+ try:
+ import pynotify
+ except:
+ self.get_widget("notification_preference_frame").hide()
+
+
+ # disable workspace tracking if wnck is not there
+ if wnck:
+ self.screen = wnck.screen_get_default()
+ for workspace in self.screen.get_workspaces():
+ self.on_workspace_created(self.screen, workspace)
+
+ self.screen.workspace_add_handler = self.screen.connect("workspace-created", self.on_workspace_created)
+ self.screen.workspace_del_handler = self.screen.connect("workspace-destroyed", self.on_workspace_deleted)
+ else:
+ self.get_widget("workspace_tab").hide()
+
+
+ self._gui.connect_signals(self)
+ self.window.show_all()
+
+
+ def on_todo_combo_changed(self, combo):
+ conf.set("activities_source", self.activities_sources[combo.get_active()][0])
+
+ def workspace_name_celldata(self, column, cell, model, iter, user_data=None):
+ name = model.get_value(iter, 1).get_name()
+ cell.set_property('text', str(name))
+
+ def on_workspace_created(self, screen, workspace, user_data=None):
+ workspace_number = workspace.get_number()
+ activity = ""
+ if workspace_number < len(self.workspace_mapping):
+ activity = self.workspace_mapping[workspace_number]
+
+ self.workspace_store.append([workspace_number, workspace, activity])
+
+ def on_workspace_deleted(self, screen, workspace, user_data=None):
+ row = self.workspace_store.get_iter_first()
+ while row:
+ if self.workspace_store.get_value(row, 1) == workspace:
+ if not self.workspace_store.remove(row):
+ # row is now invalid, stop iteration
+ break
+ else:
+ row = self.workspace_store.iter_next(row)
+
+ def on_workspace_activity_edited(self, cell, path, value):
+ index = int(path)
+ while index >= len(self.workspace_mapping):
+ self.workspace_mapping.append("")
+
+ value = value.decode("utf8", "replace")
+ self.workspace_mapping[index] = value
+ conf.set("workspace_mapping", self.workspace_mapping)
+ self.workspace_store[path][2] = value
+
+ def load_config(self, *args):
+ self.get_widget("shutdown_track").set_active(conf.get("stop_on_shutdown"))
+ self.get_widget("idle_track").set_active(conf.get("enable_timeout"))
+ self.get_widget("notify_interval").set_value(conf.get("notify_interval"))
+
+ 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.get_widget("workspace_tracking_name").set_active("name" in conf.get("workspace_tracking"))
+ self.get_widget("workspace_tracking_memory").set_active("memory" in conf.get("workspace_tracking"))
+
+ day_start = conf.get("day_start_minutes")
+ day_start = dt.time(day_start / 60, day_start % 60)
+ self.day_start.set_time(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))
+
+ self.workspace_mapping = conf.get("workspace_mapping")
+ self.get_widget("workspace_list").set_sensitive(self.get_widget("workspace_tracking_name").get_active())
+
+
+ current_source = conf.get("activities_source")
+ for i, (code, label) in enumerate(self.activities_sources):
+ if code == current_source:
+ self.todo_combo.set_active(i)
+
+
+ def on_autocomplete_tags_view_focus_out_event(self, view, event):
+ buf = self.get_widget("autocomplete_tags")
+ updated_tags = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), 0) \
+ .decode("utf-8")
+ if updated_tags == self.tags:
+ return
+
+ self.tags = updated_tags
+
+ runtime.storage.update_autocomplete_tags(updated_tags)
+
+
+ def drag_data_get_data(self, treeview, context, selection, target_id,
+ etime):
+ treeselection = treeview.get_selection()
+ model, iter = treeselection.get_selected()
+ data = model.get_value(iter, 0) #get activity ID
+ selection.set(selection.target, 0, str(data))
+
+ def select_activity(self, id):
+ model = self.activity_tree.get_model()
+ i = 0
+ for row in model:
+ if row[0] == id:
+ self.activity_tree.set_cursor((i, ))
+ i += 1
+
+ def select_category(self, id):
+ model = self.category_tree.get_model()
+ i = 0
+ for row in model:
+ if row[0] == id:
+ self.category_tree.set_cursor((i, ))
+ i += 1
+
+
+
+ def on_category_list_drag_motion(self, treeview, drag_context, x, y, eventtime):
+ self.prev_selected_category = None
+ try:
+ target_path, drop_position = treeview.get_dest_row_at_pos(x, y)
+ model, source = treeview.get_selection().get_selected()
+
+ except:
+ return
+
+ drop_yes = ("drop_yes", gtk.TARGET_SAME_APP, 0)
+ drop_no = ("drop_no", gtk.TARGET_SAME_APP, 0)
+
+ if drop_position != gtk.TREE_VIEW_DROP_AFTER and \
+ drop_position != gtk.TREE_VIEW_DROP_BEFORE:
+ treeview.enable_model_drag_dest(self.TARGETS, gtk.gdk.ACTION_MOVE)
+ else:
+ treeview.enable_model_drag_dest([drop_no], gtk.gdk.ACTION_MOVE)
+
+
+
+ def on_category_drop(self, treeview, context, x, y, selection,
+ info, etime):
+ model = self.category_tree.get_model()
+ data = selection.data
+ drop_info = treeview.get_dest_row_at_pos(x, y)
+
+ if drop_info:
+ path, position = drop_info
+ iter = model.get_iter(path)
+ changed = runtime.storage.change_category(int(data), model[iter][0])
+
+ context.finish(changed, True, etime)
+ else:
+ context.finish(False, True, etime)
+
+ return
+
+
+
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def get_store(self):
+ """returns store, so we can add some watchers in case if anything changes"""
+ return self.activity_store
+
+ def show(self):
+ self.window.show_all()
+
+ # callbacks
+ def category_edited_cb(self, cell, path, new_text, model):
+ id = model[path][0]
+ if id == -1:
+ return False #ignoring unsorted category
+
+ #look for dupes
+ categories = runtime.storage.get_categories()
+ for category in categories:
+ if category['name'].lower() == new_text.lower():
+ if id == -2: # that was a new category
+ self.category_store.remove(model.get_iter(path))
+ self.select_category(category['id'])
+ return False
+
+ if id == -2: #new category
+ id = runtime.storage.add_category(new_text.decode("utf-8"))
+ model[path][0] = id
+ else:
+ runtime.storage.update_category(id, new_text.decode("utf-8"))
+
+ model[path][1] = new_text
+
+
+ def activity_name_edited_cb(self, cell, path, new_text, model):
+ id = model[path][0]
+ category_id = model[path][2]
+
+ activities = runtime.storage.get_category_activities(category_id)
+ prev = None
+ for activity in activities:
+ if id == activity['id']:
+ prev = activity['name']
+ else:
+ # avoid two activities in same category with same name
+ if activity['name'].lower() == new_text.lower():
+ if id == -1: # that was a new activity
+ self.activity_store.remove(model.get_iter(path))
+ self.select_activity(activity['id'])
+ return False
+
+ if id == -1: #new activity -> add
+ model[path][0] = runtime.storage.add_activity(new_text.decode("utf-8"), category_id)
+ else: #existing activity -> update
+ new = new_text.decode("utf-8")
+ runtime.storage.update_activity(id, new, category_id)
+ # size matters - when editing activity name just changed the case (bar -> Bar)
+ if prev != new and prev.lower() == new.lower():
+ trophies.unlock("size_matters")
+
+ model[path][1] = new_text
+ return True
+
+
+ def category_changed_cb(self, selection, model):
+ """ enables and disables action buttons depending on selected item """
+ (model, iter) = selection.get_selected()
+ id = 0
+ if iter is None:
+ self.activity_store.clear()
+ else:
+ self.prev_selected_activity = None
+
+ id = model[iter][0]
+ self.activity_store.load(model[iter][0])
+
+ #start with nothing
+ self.get_widget('activity_edit').set_sensitive(False)
+ self.get_widget('activity_remove').set_sensitive(False)
+
+ return True
+
+ def _get_selected_category(self):
+ selection = self.get_widget('category_list').get_selection()
+ (model, iter) = selection.get_selected()
+
+ if iter:
+ return model[iter][0]
+ else:
+ return None
+
+
+ def activity_changed(self, selection, model):
+ """ enables and disables action buttons depending on selected item """
+ (model, iter) = selection.get_selected()
+
+ # treat any selected case
+ unsorted_selected = self._get_selected_category() == -1
+ self.get_widget('activity_edit').set_sensitive(iter != None)
+ self.get_widget('activity_remove').set_sensitive(iter != None)
+
+
+ def _del_selected_row(self, tree):
+ selection = tree.get_selection()
+ (model, iter) = selection.get_selected()
+
+ next_row = model.iter_next(iter)
+
+ if next_row:
+ selection.select_iter(next_row)
+ else:
+ path = model.get_path(iter)[0] - 1
+ if path > 0:
+ selection.select_path(path)
+
+ removable_id = model[iter][0]
+ model.remove(iter)
+ return removable_id
+
+ def unsorted_painter(self, column, cell, model, iter):
+ cell_id = model.get_value(iter, 0)
+ cell_text = model.get_value(iter, 1)
+ if cell_id == -1:
+ text = '<span color="#555" style="italic">%s</span>' % cell_text # TODO - should get color from theme
+ cell.set_property('markup', text)
+ else:
+ cell.set_property('text', cell_text)
+
+ return
+
+ def on_activity_list_button_pressed(self, tree, event):
+ self.activityCell.set_property("editable", False)
+
+
+ def on_activity_list_button_released(self, tree, event):
+ if event.button == 1 and tree.get_path_at_pos(int(event.x), int(event.y)):
+ # Get treeview path.
+ path, column, x, y = tree.get_path_at_pos(int(event.x), int(event.y))
+
+ if self.prev_selected_activity == path:
+ self.activityCell.set_property("editable", True)
+ tree.set_cursor(path, focus_column = self.activityColumn, start_editing = True)
+
+ self.prev_selected_activity = path
+
+ def on_category_list_button_pressed(self, tree, event):
+ self.activityCell.set_property("editable", False)
+
+ def on_category_list_button_released(self, tree, event):
+ if event.button == 1 and tree.get_path_at_pos(int(event.x), int(event.y)):
+ # Get treeview path.
+ path, column, x, y = tree.get_path_at_pos(int(event.x), int(event.y))
+
+ if self.prev_selected_category == path and \
+ self._get_selected_category() != -1: #do not allow to edit unsorted
+ self.categoryCell.set_property("editable", True)
+ tree.set_cursor(path, focus_column = self.categoryColumn, start_editing = True)
+ else:
+ self.categoryCell.set_property("editable", False)
+
+
+ self.prev_selected_category = path
+
+
+ def on_activity_remove_clicked(self, button):
+ self.remove_current_activity()
+
+ def on_activity_edit_clicked(self, button):
+ self.activityCell.set_property("editable", True)
+
+ selection = self.activity_tree.get_selection()
+ (model, iter) = selection.get_selected()
+ path = model.get_path(iter)[0]
+ self.activity_tree.set_cursor(path, focus_column = self.activityColumn, start_editing = True)
+
+
+
+ """keyboard events"""
+ def on_activity_list_key_pressed(self, tree, event_key):
+ key = event_key.keyval
+ selection = tree.get_selection()
+ (model, iter) = selection.get_selected()
+ if (event_key.keyval == gtk.keysyms.Delete):
+ self.remove_current_activity()
+
+ elif key == gtk.keysyms.F2 :
+ self.activityCell.set_property("editable", True)
+ path = model.get_path(iter)[0]
+ tree.set_cursor(path, focus_column = self.activityColumn, start_editing = True)
+ #tree.grab_focus()
+ #tree.set_cursor(path, start_editing = True)
+
+ def remove_current_activity(self):
+ selection = self.activity_tree.get_selection()
+ (model, iter) = selection.get_selected()
+ runtime.storage.remove_activity(model[iter][0])
+ self._del_selected_row(self.activity_tree)
+
+
+ def on_category_remove_clicked(self, button):
+ self.remove_current_category()
+
+ def on_category_edit_clicked(self, button):
+ self.categoryCell.set_property("editable", True)
+
+ selection = self.category_tree.get_selection()
+ (model, iter) = selection.get_selected()
+ path = model.get_path(iter)[0]
+ self.category_tree.set_cursor(path, focus_column = self.categoryColumn, start_editing = True)
+
+
+ def on_category_list_key_pressed(self, tree, event_key):
+ key = event_key.keyval
+
+ if self._get_selected_category() == -1:
+ return #ignoring unsorted category
+
+ selection = tree.get_selection()
+ (model, iter) = selection.get_selected()
+
+ if key == gtk.keysyms.Delete:
+ self.remove_current_category()
+ elif key == gtk.keysyms.F2:
+ self.categoryCell.set_property("editable", True)
+ path = model.get_path(iter)[0]
+ tree.set_cursor(path, focus_column = self.categoryColumn, start_editing = True)
+ #tree.grab_focus()
+ #tree.set_cursor(path, start_editing = True)
+
+ def remove_current_category(self):
+ selection = self.category_tree.get_selection()
+ (model, iter) = selection.get_selected()
+ id = model[iter][0]
+ if id != -1:
+ runtime.storage.remove_category(id)
+ self._del_selected_row(self.category_tree)
+
+ def on_preferences_window_key_press(self, widget, event):
+ # ctrl+w means close window
+ if (event.keyval == gtk.keysyms.w \
+ and event.state & gtk.gdk.CONTROL_MASK):
+ self.close_window()
+
+ # escape can mean several things
+ if event.keyval == gtk.keysyms.Escape:
+ #check, maybe we are editing stuff
+ if self.activityCell.get_property("editable"):
+ self.activityCell.set_property("editable", False)
+ return
+ if self.categoryCell.get_property("editable"):
+ self.categoryCell.set_property("editable", False)
+ return
+
+ self.close_window()
+
+ """button events"""
+ def on_category_add_clicked(self, button):
+ """ appends row, jumps to it and allows user to input name """
+
+ new_category = self.category_store.insert_before(self.category_store.unsorted_category,
+ [-2, _(u"New category")])
+
+ self.categoryCell.set_property("editable", True)
+ self.category_tree.set_cursor_on_cell((len(self.category_tree.get_model()) - 2, ),
+ focus_column = self.category_tree.get_column(0),
+ focus_cell = None,
+ start_editing = True)
+
+
+ def on_activity_add_clicked(self, button):
+ """ appends row, jumps to it and allows user to input name """
+ category_id = self._get_selected_category()
+
+ new_activity = self.activity_store.append([-1, _(u"New activity"), category_id])
+
+ (model, iter) = self.selection.get_selected()
+
+ self.activityCell.set_property("editable", True)
+ self.activity_tree.set_cursor_on_cell(model.get_string_from_iter(new_activity),
+ focus_column = self.activity_tree.get_column(0),
+ focus_cell = None,
+ start_editing = True)
+
+ def on_activity_remove_clicked(self, button):
+ removable_id = self._del_selected_row(self.activity_tree)
+ runtime.storage.remove_activity(removable_id)
+
+
+ def on_close_button_clicked(self, button):
+ self.close_window()
+
+ def on_close(self, widget, event):
+ self.close_window()
+
+ def close_window(self):
+ if not self.parent:
+ gtk.main_quit()
+ else:
+ self.window.destroy()
+ return False
+
+ def on_workspace_tracking_toggled(self, checkbox):
+ workspace_tracking = []
+ self.get_widget("workspace_list").set_sensitive(self.get_widget("workspace_tracking_name").get_active())
+ if self.get_widget("workspace_tracking_name").get_active():
+ workspace_tracking.append("name")
+
+ if self.get_widget("workspace_tracking_memory").get_active():
+ workspace_tracking.append("memory")
+
+ conf.set("workspace_tracking", workspace_tracking)
+
+ def on_shutdown_track_toggled(self, checkbox):
+ conf.set("stop_on_shutdown", checkbox.get_active())
+
+ def on_idle_track_toggled(self, checkbox):
+ conf.set("enable_timeout", checkbox.get_active())
+
+ def on_notify_on_idle_toggled(self, checkbox):
+ conf.set("notify_on_idle", checkbox.get_active())
+
+ def on_notify_interval_format_value(self, slider, value):
+ if value <=120:
+ # notify interval slider value label
+ label = _(u"%(interval_minutes)d minutes") % {'interval_minutes': value}
+ else:
+ # notify interval slider value label
+ label = _(u"Never")
+
+ return label
+
+ def on_notify_interval_value_changed(self, scale):
+ value = int(scale.get_value())
+ conf.set("notify_interval", value)
+ self.get_widget("notify_on_idle").set_sensitive(value <= 120)
+
+ def on_day_start_changed(self, widget):
+ day_start = self.day_start.get_time()
+ if day_start is None:
+ return
+
+ day_start = day_start.hour * 60 + day_start.minute
+
+ conf.set("day_start_minutes", day_start)
+
+ def on_preferences_window_destroy(self, window):
+ self.window = None
diff --git a/win32/hamster/reports.py b/win32/hamster/reports.py
new file mode 100644
index 0000000..8c2931f
--- /dev/null
+++ b/win32/hamster/reports.py
@@ -0,0 +1,326 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008 Toms Bauģis <toms.baugis at gmail.com>
+# Copyright (C) 2008 Nathan Samson <nathansamson at gmail dot com>
+# Copyright (C) 2008 Giorgos Logiotatidis <seadog at sealabs dot net>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+import os, sys
+import datetime as dt
+from xml.dom.minidom import Document
+import csv
+import copy
+import itertools
+import re
+from string import Template
+
+from configuration import runtime
+from lib import stuff, trophies
+from lib.i18n import C_
+
+from calendar import timegm
+
+def simple(facts, start_date, end_date, format, path):
+ facts = copy.deepcopy(facts) # dont want to do anything bad to the input
+ report_path = stuff.locale_from_utf8(path)
+
+ if format == "tsv":
+ writer = TSVWriter(report_path)
+ elif format == "xml":
+ writer = XMLWriter(report_path)
+ elif format == "ical":
+ writer = ICalWriter(report_path)
+ else: #default to HTML
+ writer = HTMLWriter(report_path, start_date, end_date)
+
+ writer.write_report(facts)
+
+ # some assembly required - hidden - saved a report for single day
+ if start_date == end_date:
+ trophies.unlock("some_assembly_required")
+
+ # I want this on my desk - generated over 10 different reports
+ if trophies.check("on_my_desk") == False:
+ current = trophies.increment("reports_generated")
+ if current == 10:
+ trophies.unlock("on_my_desk")
+
+
+class ReportWriter(object):
+ #a tiny bit better than repeating the code all the time
+ def __init__(self, path, datetime_format = "%Y-%m-%d %H:%M:%S"):
+ self.file = open(path, "w")
+ self.datetime_format = datetime_format
+
+ def write_report(self, facts):
+ try:
+ for fact in facts:
+ fact.activity= fact.activity.encode('utf-8')
+ fact.description = (fact.description or u"").encode('utf-8')
+ fact.category = (fact.category or _("Unsorted")).encode('utf-8')
+
+ if self.datetime_format:
+ fact.start_time = fact.start_time.strftime(self.datetime_format)
+
+ if fact.end_time:
+ fact.end_time = fact.end_time.strftime(self.datetime_format)
+ else:
+ fact.end_time = ""
+
+ fact.tags = ", ".join(fact.tags)
+
+ self._write_fact(self.file, fact)
+
+ self._finish(self.file, facts)
+ finally:
+ self.file.close()
+
+ def _start(self, file, facts):
+ raise NotImplementedError
+
+ def _write_fact(self, file, fact):
+ raise NotImplementedError
+
+ def _finish(self, file, facts):
+ raise NotImplementedError
+
+class ICalWriter(ReportWriter):
+ """a lame ical writer, could not be bothered with finding a library"""
+ def __init__(self, path):
+ ReportWriter.__init__(self, path, datetime_format = "%Y%m%dT%H%M%S")
+ self.file.write("BEGIN:VCALENDAR\nVERSION:1.0\n")
+
+
+ def _write_fact(self, file, fact):
+ #for now we will skip ongoing facts
+ if not fact.end_time: return
+
+ if fact.category == _("Unsorted"):
+ fact.category = None
+
+ self.file.write("""BEGIN:VEVENT
+CATEGORIES:%(category)s
+DTSTART:%(start_time)s
+DTEND:%(end_time)s
+SUMMARY:%(name)s
+DESCRIPTION:%(description)s
+END:VEVENT
+""" % fact)
+
+ def _finish(self, file, facts):
+ self.file.write("END:VCALENDAR\n")
+
+class TSVWriter(ReportWriter):
+ def __init__(self, path):
+ ReportWriter.__init__(self, path)
+ self.csv_writer = csv.writer(self.file, dialect='excel-tab')
+
+ headers = [# column title in the TSV export format
+ _("activity"),
+ # column title in the TSV export format
+ _("start time"),
+ # column title in the TSV export format
+ _("end time"),
+ # column title in the TSV export format
+ _("duration minutes"),
+ # column title in the TSV export format
+ _("category"),
+ # column title in the TSV export format
+ _("description"),
+ # column title in the TSV export format
+ _("tags")]
+ self.csv_writer.writerow([h.encode('utf-8') for h in headers])
+
+ def _write_fact(self, file, fact):
+ fact.delta = stuff.duration_minutes(fact.delta)
+ self.csv_writer.writerow([fact.activity,
+ fact.start_time,
+ fact.end_time,
+ fact.delta,
+ fact.category,
+ fact.description,
+ fact.tags])
+ def _finish(self, file, facts):
+ pass
+
+class XMLWriter(ReportWriter):
+ def __init__(self, path):
+ ReportWriter.__init__(self, path)
+ self.doc = Document()
+ self.activity_list = self.doc.createElement("activities")
+
+ def _write_fact(self, file, fact):
+ activity = self.doc.createElement("activity")
+ activity.setAttribute("name", fact.activity)
+ activity.setAttribute("start_time", fact.start_time)
+ activity.setAttribute("end_time", fact.end_time)
+ activity.setAttribute("duration_minutes", str(stuff.duration_minutes(fact.delta)))
+ activity.setAttribute("category", fact.category)
+ activity.setAttribute("description", fact.description)
+ activity.setAttribute("tags", fact.tags)
+ self.activity_list.appendChild(activity)
+
+ def _finish(self, file, facts):
+ self.doc.appendChild(self.activity_list)
+ file.write(self.doc.toxml())
+
+
+
+class HTMLWriter(ReportWriter):
+ def __init__(self, path, start_date, end_date):
+ ReportWriter.__init__(self, path, datetime_format = None)
+ self.start_date, self.end_date = start_date, end_date
+
+ dates_dict = stuff.dateDict(start_date, "start_")
+ dates_dict.update(stuff.dateDict(end_date, "end_"))
+
+ if start_date.year != end_date.year:
+ self.title = _(u"Activity report for %(start_B)s %(start_d)s, %(start_Y)s â?? %(end_B)s %(end_d)s, %(end_Y)s") % dates_dict
+ elif start_date.month != end_date.month:
+ self.title = _(u"Activity report for %(start_B)s %(start_d)s â?? %(end_B)s %(end_d)s, %(end_Y)s") % dates_dict
+ elif start_date == end_date:
+ self.title = _(u"Activity report for %(start_B)s %(start_d)s, %(start_Y)s") % dates_dict
+ else:
+ self.title = _(u"Activity report for %(start_B)s %(start_d)s â?? %(end_d)s, %(end_Y)s") % dates_dict
+
+
+ # read the template, allow override
+ self.override = os.path.exists(os.path.join(runtime.home_data_dir, "report_template.html"))
+ if self.override:
+ template = os.path.join(runtime.home_data_dir, "report_template.html")
+ else:
+ template = os.path.join(runtime.data_dir, "report_template.html")
+
+ self.main_template = ""
+ with open(template, 'r') as f:
+ self.main_template =f.read()
+
+
+ self.fact_row_template = self._extract_template('all_activities')
+
+ self.by_date_row_template = self._extract_template('by_date_activity')
+
+ self.by_date_template = self._extract_template('by_date')
+
+ self.fact_rows = []
+
+ def _extract_template(self, name):
+ pattern = re.compile('<%s>(.*)</%s>' % (name, name), re.DOTALL)
+
+ match = pattern.search(self.main_template)
+
+ if match:
+ self.main_template = self.main_template.replace(match.group(), "$%s_rows" % name)
+ return match.groups()[0]
+
+ return ""
+
+
+ def _write_fact(self, report, fact):
+ # no having end time is fine
+ end_time_str, end_time_iso_str = "", ""
+ if fact.end_time:
+ end_time_str = fact.end_time.strftime('%H:%M')
+ end_time_iso_str = fact.end_time.isoformat()
+
+ category = ""
+ if fact.category != _("Unsorted"): #do not print "unsorted" in list
+ category = fact.category
+
+
+ data = dict(
+ date = fact.date.strftime(
+ # date column format for each row in HTML report
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ C_("html report","%b %d, %Y")),
+ date_iso = fact.date.isoformat(),
+ activity = fact.activity,
+ category = category,
+ tags = fact.tags,
+ start = fact.start_time.strftime('%H:%M'),
+ start_iso = fact.start_time.isoformat(),
+ end = end_time_str,
+ end_iso = end_time_iso_str,
+ duration = stuff.format_duration(fact.delta) or "",
+ duration_minutes = "%d" % (stuff.duration_minutes(fact.delta)),
+ duration_decimal = "%.2f" % (stuff.duration_minutes(fact.delta) / 60.0),
+ description = fact.description or ""
+ )
+ self.fact_rows.append(Template(self.fact_row_template).safe_substitute(data))
+
+
+ def _finish(self, report, facts):
+
+ # group by date
+ by_date = []
+ for date, date_facts in itertools.groupby(facts, lambda fact:fact.date):
+ by_date.append((date, [dict(fact) for fact in date_facts]))
+ by_date = dict(by_date)
+
+ date_facts = []
+ date = self.start_date
+ while date <= self.end_date:
+ str_date = date.strftime(
+ # date column format for each row in HTML report
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ C_("html report","%b %d, %Y"))
+ date_facts.append([str_date, by_date.get(date, [])])
+ date += dt.timedelta(days=1)
+
+
+ data = dict(
+ title = self.title,
+ #grand_total = _("%s hours") % ("%.1f" % (total_duration.seconds / 60.0 / 60 + total_duration.days * 24)),
+
+ totals_by_day_title = _("Totals by Day"),
+ activity_log_title = _("Activity Log"),
+ totals_title = _("Totals"),
+
+ activity_totals_heading = _("activities"),
+ category_totals_heading = _("categories"),
+ tag_totals_heading = _("tags"),
+
+ show_prompt = _("Distinguish:"),
+
+ header_date = _("Date"),
+ header_activity = _("Activity"),
+ header_category = _("Category"),
+ header_tags = _("Tags"),
+ header_start = _("Start"),
+ header_end = _("End"),
+ header_duration = _("Duration"),
+ header_description = _("Description"),
+
+ data_dir = runtime.data_dir,
+ show_template = _("Show template"),
+ template_instructions = _("You can override it by storing your version in %(home_folder)s") % {'home_folder': runtime.home_data_dir},
+
+ start_date = timegm(self.start_date.timetuple()),
+ end_date = timegm(self.end_date.timetuple()),
+ facts = [dict(fact) for fact in facts],
+ date_facts = date_facts,
+
+ all_activities_rows = "\n".join(self.fact_rows)
+ )
+ report.write(Template(self.main_template).safe_substitute(data))
+
+ if self.override:
+ # my report is better than your report - overrode and ran the default report
+ trophies.unlock("my_report")
+
+ return
diff --git a/win32/hamster/stats.py b/win32/hamster/stats.py
new file mode 100644
index 0000000..3349bf7
--- /dev/null
+++ b/win32/hamster/stats.py
@@ -0,0 +1,450 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import pygtk
+pygtk.require('2.0')
+
+import os
+import time
+import datetime as dt
+import calendar
+from itertools import groupby
+from gettext import ngettext
+import locale
+import math
+
+import gtk, gobject
+import pango
+
+import widgets
+from lib import stuff, charting, graphics
+from configuration import runtime, conf, load_ui_file
+
+from lib.i18n import C_
+
+class Stats(object):
+ def __init__(self, parent = None):
+ self._gui = load_ui_file("stats.ui")
+ self.report_chooser = None
+ self.window = self.get_widget("stats_window")
+
+ self.parent = parent# determine if app should shut down on close
+
+ self.stat_facts = None
+
+ day_start = conf.get("day_start_minutes")
+ day_start = dt.time(day_start / 60, day_start % 60)
+ self.timechart = widgets.TimeChart()
+ self.timechart.interactive = False
+ self.timechart.day_start = day_start
+
+ self.get_widget("explore_everything").add(self.timechart)
+ self.get_widget("explore_everything").show_all()
+
+ runtime.storage.connect('activities-changed',self.after_fact_update)
+ runtime.storage.connect('facts-changed',self.after_fact_update)
+
+ self.init_stats()
+
+ self.window.set_position(gtk.WIN_POS_CENTER)
+
+ self._gui.connect_signals(self)
+ self.window.show_all()
+ self.stats()
+
+
+
+ def init_stats(self):
+ self.stat_facts = runtime.storage.get_facts(dt.date(1970, 1, 2), dt.date.today())
+
+ if not self.stat_facts or self.stat_facts[-1].start_time.year == self.stat_facts[0].start_time.year:
+ self.get_widget("explore_controls").hide()
+ else:
+ by_year = stuff.totals(self.stat_facts,
+ lambda fact: fact.start_time.year,
+ lambda fact: 1)
+
+ year_box = self.get_widget("year_box")
+ class YearButton(gtk.ToggleButton):
+ def __init__(self, label, year, on_clicked):
+ gtk.ToggleButton.__init__(self, label)
+ self.year = year
+ self.connect("clicked", on_clicked)
+
+ all_button = YearButton(C_("years", "All").encode("utf-8"),
+ None,
+ self.on_year_changed)
+ year_box.pack_start(all_button)
+ self.bubbling = True # TODO figure out how to properly work with togglebuttons as radiobuttons
+ all_button.set_active(True)
+ self.bubbling = False # TODO figure out how to properly work with togglebuttons as radiobuttons
+
+ years = sorted(by_year.keys())
+ for year in years:
+ year_box.pack_start(YearButton(str(year), year, self.on_year_changed))
+
+ year_box.show_all()
+
+ self.chart_category_totals = charting.Chart(value_format = "%.1f",
+ max_bar_width = 20,
+ legend_width = 70,
+ interactive = False)
+ self.get_widget("explore_category_totals").add(self.chart_category_totals)
+
+
+ self.chart_weekday_totals = charting.Chart(value_format = "%.1f",
+ max_bar_width = 20,
+ legend_width = 70,
+ interactive = False)
+ self.get_widget("explore_weekday_totals").add(self.chart_weekday_totals)
+
+ self.chart_weekday_starts_ends = charting.HorizontalDayChart(max_bar_width = 20,
+ legend_width = 70)
+ self.get_widget("explore_weekday_starts_ends").add(self.chart_weekday_starts_ends)
+
+ self.chart_category_starts_ends = charting.HorizontalDayChart(max_bar_width = 20,
+ legend_width = 70)
+ self.get_widget("explore_category_starts_ends").add(self.chart_category_starts_ends)
+
+
+
+
+ #ah, just want summary look just like all the other text on the page
+ class CairoText(graphics.Scene):
+ def __init__(self):
+ graphics.Scene.__init__(self)
+ self.text = ""
+ self.label = graphics.Label(self.text, 10)
+ self.label.wrap = pango.WRAP_WORD
+ self.add_child(self.label)
+ self.connect("on-enter-frame", self.on_enter_frame)
+
+ def set_text(self, text):
+ self.label.text = text
+ self.redraw()
+
+ def on_enter_frame(self, scene, context):
+ # now for the text - we want reduced contrast for relaxed visuals
+ fg_color = self.get_style().fg[gtk.STATE_NORMAL].to_string()
+ self.label.color = self.colors.contrast(fg_color, 80)
+
+ self.label.width = self.width
+
+
+ self.explore_summary = CairoText()
+ self.get_widget("explore_summary").add(self.explore_summary)
+ self.get_widget("explore_summary").show_all()
+
+ def stats(self, year = None):
+ facts = self.stat_facts
+ if year:
+ facts = filter(lambda fact: fact.start_time.year == year,
+ facts)
+
+ if not facts or (facts[-1].start_time - facts[0].start_time) < dt.timedelta(days=6):
+ self.get_widget("statistics_box").hide()
+ #self.get_widget("explore_controls").hide()
+ label = self.get_widget("not_enough_records_label")
+
+ if not facts:
+ label.set_text(_("""There is no data to generate statistics yet.
+A week of usage would be nice!"""))
+ else:
+ label.set_text(_(u"Collecting data â?? check back after a week has passed!"))
+
+ label.show()
+ return
+ else:
+ self.get_widget("statistics_box").show()
+ self.get_widget("explore_controls").show()
+ self.get_widget("not_enough_records_label").hide()
+
+ # All dates in the scope
+ durations = [(fact.start_time, fact.delta) for fact in facts]
+ self.timechart.draw(durations, facts[0].date, facts[-1].date)
+
+
+ # Totals by category
+ categories = stuff.totals(facts,
+ lambda fact: fact.category,
+ lambda fact: fact.delta.seconds / 60 / 60.0)
+ category_keys = sorted(categories.keys())
+ categories = [categories[key] for key in category_keys]
+ self.chart_category_totals.plot(category_keys, categories)
+
+ # Totals by weekday
+ weekdays = stuff.totals(facts,
+ lambda fact: (fact.start_time.weekday(),
+ fact.start_time.strftime("%a")),
+ lambda fact: fact.delta.seconds / 60 / 60.0)
+
+ weekday_keys = sorted(weekdays.keys(), key = lambda x: x[0]) #sort
+ weekdays = [weekdays[key] for key in weekday_keys] #get values in the order
+ weekday_keys = [key[1] for key in weekday_keys] #now remove the weekday and keep just the abbreviated one
+ self.chart_weekday_totals.plot(weekday_keys, weekdays)
+
+
+ split_minutes = 5 * 60 + 30 #the mystical hamster midnight
+
+ # starts and ends by weekday
+ by_weekday = {}
+ for date, date_facts in groupby(facts, lambda fact: fact.start_time.date()):
+ date_facts = list(date_facts)
+ weekday = (date_facts[0].start_time.weekday(),
+ date_facts[0].start_time.strftime("%a"))
+ by_weekday.setdefault(weekday, [])
+
+ start_times, end_times = [], []
+ for fact in date_facts:
+ start_time = fact.start_time.time()
+ start_time = start_time.hour * 60 + start_time.minute
+ if fact.end_time:
+ end_time = fact.end_time.time()
+ end_time = end_time.hour * 60 + end_time.minute
+
+ if start_time < split_minutes:
+ start_time += 24 * 60
+ if end_time < start_time:
+ end_time += 24 * 60
+
+ start_times.append(start_time)
+ end_times.append(end_time)
+ if start_times and end_times:
+ by_weekday[weekday].append((min(start_times), max(end_times)))
+
+
+ for day in by_weekday:
+ n = len(by_weekday[day])
+ # calculate mean and variance for starts and ends
+ means = (sum([fact[0] for fact in by_weekday[day]]) / n,
+ sum([fact[1] for fact in by_weekday[day]]) / n)
+ variances = (sum([(fact[0] - means[0]) ** 2 for fact in by_weekday[day]]) / n,
+ sum([(fact[1] - means[1]) ** 2 for fact in by_weekday[day]]) / n)
+
+ # In the normal distribution, the range from
+ # (mean - standard deviation) to infinit, or from
+ # -infinit to (mean + standard deviation), has an accumulated
+ # probability of 84.1%. Meaning we are using the place where if we
+ # picked a random start(or end), 84.1% of the times it will be
+ # inside the range.
+ by_weekday[day] = (int(means[0] - math.sqrt(variances[0])),
+ int(means[1] + math.sqrt(variances[1])))
+
+ min_weekday = min([by_weekday[day][0] for day in by_weekday])
+ max_weekday = max([by_weekday[day][1] for day in by_weekday])
+
+
+ weekday_keys = sorted(by_weekday.keys(), key = lambda x: x[0])
+ weekdays = [by_weekday[key] for key in weekday_keys]
+ weekday_keys = [key[1] for key in weekday_keys] # get rid of the weekday number as int
+
+
+ # starts and ends by category
+ by_category = {}
+ for date, date_facts in groupby(facts, lambda fact: fact.start_time.date()):
+ date_facts = sorted(list(date_facts), key = lambda x: x.category)
+
+ for category, category_facts in groupby(date_facts, lambda x: x.category):
+ category_facts = list(category_facts)
+ by_category.setdefault(category, [])
+
+ start_times, end_times = [], []
+ for fact in category_facts:
+ start_time = fact.start_time
+ start_time = start_time.hour * 60 + start_time.minute
+ if fact.end_time:
+ end_time = fact.end_time.time()
+ end_time = end_time.hour * 60 + end_time.minute
+
+ if start_time < split_minutes:
+ start_time += 24 * 60
+ if end_time < start_time:
+ end_time += 24 * 60
+
+ start_times.append(start_time)
+ end_times.append(end_time)
+
+ if start_times and end_times:
+ by_category[category].append((min(start_times), max(end_times)))
+
+ for cat in by_category:
+ # For explanation see the comments in the starts and ends by day
+ n = len(by_category[cat])
+ means = (sum([fact[0] for fact in by_category[cat]]) / n,
+ sum([fact[1] for fact in by_category[cat]]) / n)
+ variances = (sum([(fact[0] - means[0]) ** 2 for fact in by_category[cat]]) / n,
+ sum([(fact[1] - means[1]) ** 2 for fact in by_category[cat]]) / n)
+
+ by_category[cat] = (int(means[0] - math.sqrt(variances[0])),
+ int(means[1] + math.sqrt(variances[1])))
+
+ min_category = min([by_category[day][0] for day in by_category])
+ max_category = max([by_category[day][1] for day in by_category])
+
+ category_keys = sorted(by_category.keys(), key = lambda x: x[0])
+ categories = [by_category[key] for key in category_keys]
+
+
+ #get starting and ending hours for graph and turn them into exact hours that divide by 3
+ min_hour = min([min_weekday, min_category]) / 60 * 60
+ max_hour = max([max_weekday, max_category]) / 60 * 60
+
+ self.chart_weekday_starts_ends.plot_day(weekday_keys, weekdays, min_hour, max_hour)
+ self.chart_category_starts_ends.plot_day(category_keys, categories, min_hour, max_hour)
+
+
+ #now the factoids!
+ summary = ""
+
+ # first record
+ if not year:
+ # date format for the first record if the year has not been selected
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ first_date = facts[0].start_time.strftime(C_("first record", "%b %d, %Y"))
+ else:
+ # date of first record when year has been selected
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ first_date = facts[0].start_time.strftime(C_("first record", "%b %d"))
+
+ summary += _("First activity was recorded on %s.") % \
+ ("<b>%s</b>" % first_date)
+
+ # total time tracked
+ total_delta = dt.timedelta(days=0)
+ for fact in facts:
+ total_delta += fact.delta
+
+ if total_delta.days > 1:
+ human_years_str = ngettext("%(num)s year",
+ "%(num)s years",
+ total_delta.days / 365) % {
+ 'num': "<b>%s</b>" % locale.format("%.2f", (total_delta.days / 365.0))}
+ working_years_str = ngettext("%(num)s year",
+ "%(num)s years",
+ total_delta.days * 3 / 365) % {
+ 'num': "<b>%s</b>" % locale.format("%.2f", (total_delta.days * 3 / 365.0)) }
+ #FIXME: difficult string to properly pluralize
+ summary += " " + _("""Time tracked so far is %(human_days)s human days \
+(%(human_years)s) or %(working_days)s working days (%(working_years)s).""") % {
+ "human_days": ("<b>%d</b>" % total_delta.days),
+ "human_years": human_years_str,
+ "working_days": ("<b>%d</b>" % (total_delta.days * 3)), # 8 should be pretty much an average working day
+ "working_years": working_years_str }
+
+
+ # longest fact
+ max_fact = None
+ for fact in facts:
+ if not max_fact or fact.delta > max_fact.delta:
+ max_fact = fact
+
+ longest_date = max_fact.start_time.strftime(
+ # How the date of the longest activity should be displayed in statistics
+ # Using python datetime formatting syntax. See:
+ # http://docs.python.org/library/time.html#time.strftime
+ C_("date of the longest activity", "%b %d, %Y"))
+
+ num_hours = max_fact.delta.seconds / 60 / 60.0 + max_fact.delta.days * 24
+ hours = "<b>%s</b>" % locale.format("%.1f", num_hours)
+
+ summary += "\n" + ngettext("Longest continuous work happened on \
+%(date)s and was %(hours)s hour.",
+ "Longest continuous work happened on \
+%(date)s and was %(hours)s hours.",
+ int(num_hours)) % {"date": longest_date,
+ "hours": hours}
+
+ # total records (in selected scope)
+ summary += " " + ngettext("There is %s record.",
+ "There are %s records.",
+ len(facts)) % ("<b>%d</b>" % len(facts))
+
+
+ early_start, early_end = dt.time(5,0), dt.time(9,0)
+ late_start, late_end = dt.time(20,0), dt.time(5,0)
+
+
+ fact_count = len(facts)
+ def percent(condition):
+ matches = [fact for fact in facts if condition(fact)]
+ return round(len(matches) / float(fact_count) * 100)
+
+
+ early_percent = percent(lambda fact: early_start < fact.start_time.time() < early_end)
+ late_percent = percent(lambda fact: fact.start_time.time() > late_start or fact.start_time.time() < late_end)
+ short_percent = percent(lambda fact: fact.delta <= dt.timedelta(seconds = 60 * 15))
+
+ if fact_count < 100:
+ summary += "\n\n" + _("Hamster would like to observe you some more!")
+ elif early_percent >= 20:
+ summary += "\n\n" + _("With %s percent of all facts starting before \
+9am, you seem to be an early bird.") % ("<b>%d</b>" % early_percent)
+ elif late_percent >= 20:
+ summary += "\n\n" + _("With %s percent of all facts starting after \
+11pm, you seem to be a night owl.") % ("<b>%d</b>" % late_percent)
+ elif short_percent >= 20:
+ summary += "\n\n" + _("With %s percent of all tasks being shorter \
+than 15 minutes, you seem to be a busy bee.") % ("<b>%d</b>" % short_percent)
+
+ self.explore_summary.set_text(summary)
+
+
+
+ def on_year_changed(self, button):
+ if self.bubbling: return
+
+ for child in button.parent.get_children():
+ if child != button and child.get_active():
+ self.bubbling = True
+ child.set_active(False)
+ self.bubbling = False
+
+ self.stats(button.year)
+
+
+ def after_fact_update(self, event):
+ self.stat_facts = runtime.storage.get_facts(dt.date(1970, 1, 1), dt.date.today())
+ self.stats()
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def on_window_key_pressed(self, tree, event_key):
+ if (event_key.keyval == gtk.keysyms.Escape
+ or (event_key.keyval == gtk.keysyms.w
+ and event_key.state & gtk.gdk.CONTROL_MASK)):
+ self.close_window()
+
+ def on_stats_window_deleted(self, widget, event):
+ self.close_window()
+
+ def close_window(self):
+ if not self.parent:
+ gtk.main_quit()
+ else:
+ self.window.destroy()
+ return False
+
+
+if __name__ == "__main__":
+ stats_viewer = Stats()
+ gtk.main()
diff --git a/win32/hamster/widgets/__init__.py b/win32/hamster/widgets/__init__.py
new file mode 100644
index 0000000..b3837ca
--- /dev/null
+++ b/win32/hamster/widgets/__init__.py
@@ -0,0 +1,91 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2007-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import datetime as dt
+import gtk, pango
+
+# import our children
+from .activityentry import ActivityEntry
+from .dateinput import DateInput
+from .timeinput import TimeInput
+
+from .timechart import TimeChart
+
+from .dayline import DayLine
+
+from .tags import Tag
+from .tags import TagBox
+from .tags import TagsEntry
+
+from .reportchooserdialog import ReportChooserDialog
+
+from .facttree import FactTree
+
+from .rangepick import RangePick
+
+
+# handy wrappers
+def add_hint(entry, hint):
+ entry.hint = hint
+
+ def override_get_text(self):
+ #override get text so it does not return true when hint is in!
+ if self.real_get_text() == self.hint:
+ return ""
+ else:
+ return self.real_get_text()
+
+ def _set_hint(self, widget, event):
+ if self.get_text(): # do not mess with user entered text
+ return
+
+ self.modify_text(gtk.STATE_NORMAL, gtk.gdk.Color("gray"))
+ hint_font = pango.FontDescription(self.get_style().font_desc.to_string())
+ hint_font.set_style(pango.STYLE_ITALIC)
+ self.modify_font(hint_font)
+
+ self.set_text(self.hint)
+
+ def _set_normal(self, widget, event):
+ self.modify_text(gtk.STATE_NORMAL, self.get_style().fg[gtk.STATE_NORMAL])
+ hint_font = pango.FontDescription(self.get_style().font_desc.to_string())
+ hint_font.set_style(pango.STYLE_NORMAL)
+ self.modify_font(hint_font)
+
+ if self.real_get_text() == self.hint:
+ self.set_text("")
+
+ def _on_changed(self, widget):
+ if self.real_get_text() == "" and self.is_focus() == False:
+ self._set_hint(widget, None)
+
+ import types
+ instancemethod = types.MethodType
+
+ entry._set_hint = instancemethod(_set_hint, entry, gtk.Entry)
+ entry._set_normal = instancemethod(_set_normal, entry, gtk.Entry)
+ entry._on_changed = instancemethod(_on_changed, entry, gtk.Entry)
+ entry.real_get_text = entry.get_text
+ entry.get_text = instancemethod(override_get_text, entry, gtk.Entry)
+
+ entry.connect('focus-in-event', entry._set_normal)
+ entry.connect('focus-out-event', entry._set_hint)
+ entry.connect('changed', entry._on_changed)
+
+ entry._set_hint(entry, None)
diff --git a/win32/hamster/widgets/activityentry.py b/win32/hamster/widgets/activityentry.py
new file mode 100644
index 0000000..15878d6
--- /dev/null
+++ b/win32/hamster/widgets/activityentry.py
@@ -0,0 +1,339 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk, gobject, pango
+import datetime as dt
+
+from ..configuration import runtime
+from ..lib import stuff, graphics
+from .. import external
+
+class ActivityEntry(gtk.Entry):
+ __gsignals__ = {
+ 'value-entered': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+
+ def __init__(self):
+ gtk.Entry.__init__(self)
+ self.news = False
+ self.activities = None
+ self.external_activities = [] # suggestions from outer space
+ self.categories = None
+ self.filter = None
+ self.max_results = 10 # limit popup size to 10 results
+ self.external = external.ActivitiesSource()
+
+ self.popup = gtk.Window(type = gtk.WINDOW_POPUP)
+
+ box = gtk.ScrolledWindow()
+ box.set_shadow_type(gtk.SHADOW_IN)
+ box.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
+
+ self.tree = gtk.TreeView()
+ self.tree.set_headers_visible(False)
+ self.tree.set_hover_selection(True)
+
+ self.time_icon_cell = gtk.CellRendererPixbuf()
+ self.time_icon_cell.set_property("icon-name", "appointment-new")
+
+ self.time_icon_column = gtk.TreeViewColumn("", self.time_icon_cell)
+ self.tree.append_column(self.time_icon_column)
+
+ self.time_cell = gtk.CellRendererText()
+ self.time_cell.set_property("scale", 0.8)
+
+ self.time_column = gtk.TreeViewColumn("Time",
+ self.time_cell,
+ text = 3)
+ self.tree.append_column(self.time_column)
+
+
+ self.activity_column = gtk.TreeViewColumn("Activity",
+ gtk.CellRendererText(),
+ text=1)
+ self.activity_column.set_expand(True)
+ self.tree.append_column(self.activity_column)
+
+ self.category_cell = gtk.CellRendererText()
+ self.category_cell.set_property('alignment', pango.ALIGN_RIGHT)
+ self.category_cell.set_property('scale', pango.SCALE_SMALL)
+ self.category_cell.set_property('yalign', 0.0)
+
+ self.category_column = gtk.TreeViewColumn("Category",
+ self.category_cell,
+ text=2)
+ self.tree.append_column(self.category_column)
+
+
+
+ self.tree.connect("button-press-event", self._on_tree_button_press_event)
+
+ box.add(self.tree)
+ self.popup.add(box)
+
+ self.connect("button-press-event", self._on_button_press_event)
+ 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.connect("changed", self._on_text_changed)
+ self._parent_click_watcher = None # bit lame but works
+
+ runtime.storage.connect('activities-changed',self.after_activity_update)
+
+ self.show()
+ self.populate_suggestions()
+
+ def get_value(self):
+ activity_name = self.get_text().decode("utf-8").strip()
+ if not activity_name:
+ return None, False
+
+ # see if entered text matches something from the outer suggestions
+ # only consequence of if it does is that it will not attempt to
+ # ressurect the activity if it's deleted (hidden)
+ # thus avoiding polluting our local suggestions
+ external_names = set()
+ for activity in self.external_activities:
+ name = activity['name']
+ if activity['category']:
+ name = "%s %s" % name, activity['category']
+ external_names.add(name.lower())
+
+ return activity_name, activity_name in external_names
+
+ def hide_popup(self):
+ if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher):
+ self.get_toplevel().disconnect(self._parent_click_watcher)
+ self._parent_click_watcher = None
+ self.popup.hide()
+
+ def show_popup(self):
+ result_count = self.tree.get_model().iter_n_children(None)
+ if result_count <= 1:
+ self.hide_popup()
+ return
+
+ if not self._parent_click_watcher:
+ self._parent_click_watcher = self.get_toplevel().connect("button-press-event", self._on_focus_out_event)
+
+ fact = stuff.Fact(self.filter)
+ time = ''
+ if fact.start_time:
+ time = fact.start_time.strftime("%H:%M")
+ if fact.end_time:
+ time += "-%s" % fact.end_time.strftime("%H:%M")
+
+ self.time_icon_column.set_visible(fact.start_time is not None and self.filter.find("@") == -1)
+ self.time_column.set_visible(fact.start_time is not None and self.filter.find("@") == -1)
+
+
+ self.category_column.set_visible(self.filter.find("@") == -1)
+
+
+ #set proper background color (we can do that only on a realised widget)
+ bgcolor = self.get_style().bg[gtk.STATE_NORMAL]
+ self.time_icon_cell.set_property("cell-background", bgcolor)
+ self.time_cell.set_property("cell-background", bgcolor)
+
+ text_color = self.get_style().text[gtk.STATE_NORMAL]
+ category_color = graphics.Colors.contrast(text_color, 100)
+ self.category_cell.set_property('foreground-gdk', graphics.Colors.gdk(category_color))
+
+
+ #move popup under the widget
+ alloc = self.get_allocation()
+
+ #TODO - this is clearly unreliable as we calculate tree row size based on our gtk entry
+ popup_height = (alloc.height-6) * min([result_count, self.max_results])
+ self.tree.parent.set_size_request(alloc.width, popup_height)
+ self.popup.resize(alloc.width, popup_height)
+
+ x, y = self.get_parent_window().get_origin()
+ y = y + alloc.y
+
+ if y + alloc.height + popup_height < self.get_screen().get_height():
+ y = y + alloc.height
+ else:
+ y = y - popup_height
+
+ self.popup.move(x + alloc.x, y)
+
+ self.popup.show_all()
+
+ def complete_inline(self):
+ model = self.tree.get_model()
+ subject = self.get_text()
+
+ if not subject or model.iter_n_children(None) == 0:
+ return
+
+ prefix_length = 0
+
+ labels = [row[0] for row in model]
+ shortest = min([len(label) for label in labels])
+ first = labels[0] #since we are looking for common prefix, we do not care which label we use for comparisons
+
+ for i in range(len(subject), shortest):
+ letter_matching = all([label[i]==first[i] for label in labels])
+
+ if not letter_matching:
+ break
+
+ prefix_length +=1
+
+ if prefix_length:
+ prefix = first[len(subject):len(subject)+prefix_length]
+ self.set_text("%s%s" % (self.filter, prefix))
+ self.select_region(len(self.filter), len(self.filter) + prefix_length)
+
+ def refresh_activities(self):
+ # scratch category cache so it gets repopulated on demand
+ self.categories = None
+
+ def populate_suggestions(self):
+ if self.get_selection_bounds():
+ cursor = self.get_selection_bounds()[0]
+ else:
+ cursor = self.get_position()
+
+ if self.activities and self.categories and self.filter == self.get_text().decode('utf8', 'replace')[:cursor]:
+ return #same thing, no need to repopulate
+
+ self.filter = self.get_text().decode('utf8', 'replace')[:cursor]
+ fact = stuff.Fact(self.filter)
+
+ # do not cache as ordering and available options change over time
+ self.activities = runtime.storage.get_activities(fact.activity)
+ self.external_activities = self.external.get_activities(fact.activity)
+ self.activities.extend(self.external_activities)
+
+ self.categories = self.categories or runtime.storage.get_categories()
+
+
+ time = ''
+ if fact.start_time:
+ time = fact.start_time.strftime("%H:%M")
+ if fact.end_time:
+ time += "-%s" % fact.end_time.strftime("%H:%M")
+
+
+ store = self.tree.get_model()
+ if not store:
+ store = gtk.ListStore(str, str, str, str)
+ self.tree.set_model(store)
+ store.clear()
+
+ if self.filter.find("@") > 0:
+ key = self.filter[self.filter.find("@")+1:].decode('utf8', 'replace').lower()
+ for category in self.categories:
+ if key in category['name'].decode('utf8', 'replace').lower():
+ fillable = (self.filter[:self.filter.find("@") + 1] + category['name'])
+ store.append([fillable, category['name'], fillable, time])
+ else:
+ key = fact.activity.decode('utf8', 'replace').lower()
+ for activity in self.activities:
+ fillable = activity['name'].lower()
+ if activity['category']:
+ fillable += "@%s" % activity['category']
+
+ if time: #as we also support deltas, for the time we will grab anything up to first space
+ fillable = "%s %s" % (self.filter.split(" ", 1)[0], fillable)
+
+ store.append([fillable, activity['name'].lower(), activity['category'], time])
+
+ def after_activity_update(self, widget):
+ self.refresh_activities()
+
+ def _on_focus_out_event(self, widget, event):
+ self.hide_popup()
+
+ def _on_text_changed(self, widget):
+ self.news = True
+
+ 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 (gtk.keysyms.Return, gtk.keysyms.KP_Enter)):
+ if self.popup.get_property("visible"):
+ if self.tree.get_cursor()[0]:
+ self.set_text(self.tree.get_model()[self.tree.get_cursor()[0][0]][0])
+ self.hide_popup()
+ self.set_position(len(self.get_text()))
+ else:
+ self._on_selected()
+
+ elif (event.keyval == gtk.keysyms.Escape):
+ if self.popup.get_property("visible"):
+ self.hide_popup()
+ return True
+ else:
+ return False
+ elif event.keyval in (gtk.keysyms.Up, gtk.keysyms.Down):
+ return False
+ else:
+ self.populate_suggestions()
+ self.show_popup()
+
+ if event.keyval not in (gtk.keysyms.Delete, gtk.keysyms.BackSpace):
+ self.complete_inline()
+
+
+
+
+ def _on_key_press_event(self, entry, event):
+
+ if event.keyval in (gtk.keysyms.Up, gtk.keysyms.Down):
+ cursor = self.tree.get_cursor()
+
+ if not cursor or not cursor[0]:
+ self.tree.set_cursor(0)
+ return True
+
+ i = cursor[0][0]
+
+ if event.keyval == gtk.keysyms.Up:
+ i-=1
+ elif event.keyval == gtk.keysyms.Down:
+ i+=1
+
+ # keep it in the sane borders
+ i = min(max(i, 0), len(self.tree.get_model()) - 1)
+
+ self.tree.set_cursor(i)
+ self.tree.scroll_to_cell(i, use_align = True, row_align = 0.4)
+ return True
+ else:
+ return False
+
+ def _on_tree_button_press_event(self, tree, event):
+ model, iter = tree.get_selection().get_selected()
+ value = model.get_value(iter, 0)
+ self.set_text(value)
+ self.hide_popup()
+ self.set_position(len(self.get_text()))
+
+ def _on_selected(self):
+ if self.news and self.get_text().strip():
+ self.set_position(len(self.get_text()))
+ self.emit("value-entered")
+
+ self.news = False
diff --git a/win32/hamster/widgets/dateinput.py b/win32/hamster/widgets/dateinput.py
new file mode 100644
index 0000000..37cede8
--- /dev/null
+++ b/win32/hamster/widgets/dateinput.py
@@ -0,0 +1,186 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk
+import datetime as dt
+import calendar
+import gobject
+import re
+
+class DateInput(gtk.Entry):
+ """ a text entry widget with calendar popup"""
+ __gsignals__ = {
+ 'date-entered': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+
+ def __init__(self, date = None):
+ gtk.Entry.__init__(self)
+
+ self.set_width_chars(len(dt.datetime.now().strftime("%x"))) # size to default format length
+ self.date = date
+ if date:
+ self.set_date(date)
+
+ self.news = False
+ self.prev_cal_day = None #workaround
+ self.popup = gtk.Window(type = gtk.WINDOW_POPUP)
+ calendar_box = gtk.HBox()
+
+ self.date_calendar = gtk.Calendar()
+ self.date_calendar.mark_day(dt.datetime.today().day)
+ self.date_calendar.connect("day-selected", self._on_day_selected)
+ self.date_calendar.connect("day-selected-double-click",
+ self.__on_day_selected_double_click)
+ self.date_calendar.connect("button-press-event",
+ self._on_cal_button_press_event)
+ calendar_box.add(self.date_calendar)
+ self.popup.add(calendar_box)
+
+ self.connect("button-press-event", self._on_button_press_event)
+ self.connect("key-press-event", self._on_key_press_event)
+ self.connect("focus-in-event", self._on_focus_in_event)
+ self.connect("focus-out-event", self._on_focus_out_event)
+ self._parent_click_watcher = None # bit lame but works
+
+ self.connect("changed", self._on_text_changed)
+ self.show()
+
+ def set_date(self, date):
+ """sets date to specified, using default format"""
+ self.date = date
+ self.set_text(self._format_date(self.date))
+
+ def get_date(self):
+ """sets date to specified, using default format"""
+ self.date = self._figure_date(self.get_text())
+ self.set_text(self._format_date(self.date))
+ return self.date
+
+ def _figure_date(self, date_str):
+ try:
+ return dt.datetime.strptime(date_str, "%x").date()
+ except:
+ return self.date
+
+ def _format_date(self, date):
+ if not date:
+ return ""
+ else:
+ return date.strftime("%x")
+
+ def _on_text_changed(self, widget):
+ self.news = True
+
+ def __on_day_selected_double_click(self, calendar):
+ self.prev_cal_day = None
+ self._on_day_selected(calendar) #forward
+
+ def _on_cal_button_press_event(self, calendar, event):
+ self.prev_cal_day = calendar.get_date()[2]
+
+ def _on_day_selected(self, calendar):
+ if self.popup.get_property("visible") == False:
+ return
+
+ if self.prev_cal_day == calendar.get_date()[2]:
+ return
+
+ cal_date = calendar.get_date()
+
+ self.date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+ self.set_text(self._format_date(self.date))
+
+ self.hide_popup()
+ if self.news:
+ self.emit("date-entered")
+ self.news = False
+
+ def hide_popup(self):
+ self.popup.hide()
+ if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher):
+ self.get_toplevel().disconnect(self._parent_click_watcher)
+ self._parent_click_watcher = None
+
+ 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)
+
+ window = self.get_parent_window()
+ x, y = window.get_origin()
+
+ alloc = self.get_allocation()
+
+ date = self._figure_date(self.get_text())
+ if date:
+ self.prev_cal_day = date.day #avoid
+ self.date_calendar.select_month(date.month - 1, date.year)
+ self.date_calendar.select_day(date.day)
+
+ self.popup.move(x + alloc.x,y + alloc.y + alloc.height)
+ self.popup.show_all()
+
+ def _on_focus_in_event(self, entry, event):
+ self.show_popup()
+
+ def _on_button_press_event(self, button, event):
+ self.show_popup()
+
+
+ def _on_focus_out_event(self, event, something):
+ self.hide_popup()
+ if self.news:
+ self.emit("date-entered")
+ self.news = False
+
+ def _on_key_press_event(self, entry, event):
+ if self.popup.get_property("visible"):
+ cal_date = self.date_calendar.get_date()
+ date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+ else:
+ date = self._figure_date(entry.get_text())
+ if not date:
+ return
+
+ enter_pressed = False
+
+ if event.keyval == gtk.keysyms.Up:
+ date = date - dt.timedelta(days=1)
+ elif event.keyval == gtk.keysyms.Down:
+ date = date + dt.timedelta(days=1)
+ elif (event.keyval == gtk.keysyms.Return or
+ event.keyval == gtk.keysyms.KP_Enter):
+ enter_pressed = True
+ elif (event.keyval == gtk.keysyms.Escape):
+ self.hide_popup()
+ elif event.keyval in (gtk.keysyms.Left, gtk.keysyms.Right):
+ return False #keep calendar open and allow user to walk in text
+ else:
+ self.hide_popup()
+ return False
+
+ if enter_pressed:
+ self.prev_cal_day = "borken"
+ else:
+ #prev_cal_day is our only way of checking that date is right
+ self.prev_cal_day = date.day
+
+ self.date_calendar.select_month(date.month - 1, date.year)
+ self.date_calendar.select_day(date.day)
+ return True
diff --git a/win32/hamster/widgets/dayline.py b/win32/hamster/widgets/dayline.py
new file mode 100644
index 0000000..d4acf05
--- /dev/null
+++ b/win32/hamster/widgets/dayline.py
@@ -0,0 +1,375 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007-2010 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk
+import gobject
+
+import time
+import datetime as dt
+
+from ..lib import stuff, graphics, pytweener
+from ..configuration import conf
+
+
+class Selection(graphics.Sprite):
+ def __init__(self, start_time = None, end_time = None):
+ graphics.Sprite.__init__(self, z_order = 100)
+ self.start_time, self.end_time = None, None
+ self.width, self.height = None, None
+ self.fill = None # will be set to proper theme color on render
+ self.fixed = False
+
+ self.start_label = graphics.Label("", 8, "#333", visible = False)
+ self.end_label = graphics.Label("", 8, "#333", visible = False)
+ self.duration_label = graphics.Label("", 8, "#FFF", visible = False)
+
+ self.add_child(self.start_label, self.end_label, self.duration_label)
+ self.connect("on-render", self.on_render)
+
+
+ def on_render(self, sprite):
+ if not self.fill: # not ready yet
+ return
+
+ self.graphics.rectangle(0, 0, self.width, self.height)
+ self.graphics.fill(self.fill, 0.3)
+
+ self.graphics.rectangle(0.5, 0.5, self.width, self.height)
+ self.graphics.stroke(self.fill)
+
+
+ # adjust labels
+ self.start_label.visible = self.fixed == False and self.start_time is not None
+ if self.start_label.visible:
+ self.start_label.text = self.start_time.strftime("%H:%M")
+ if self.x - self.start_label.width - 5 > 0:
+ self.start_label.x = -self.start_label.width - 5
+ else:
+ self.start_label.x = 5
+
+ self.start_label.y = self.height
+
+ self.end_label.visible = self.fixed == False and self.end_time is not None
+ if self.end_label.visible:
+ self.end_label.text = self.end_time.strftime("%H:%M")
+ self.end_label.x = self.width + 5
+ self.end_label.y = self.height
+
+
+
+ duration = self.end_time - self.start_time
+ duration = int(duration.seconds / 60)
+ self.duration_label.text = "%02d:%02d" % (duration / 60, duration % 60)
+
+ self.duration_label.visible = self.duration_label.width < self.width
+ if self.duration_label.visible:
+ self.duration_label.y = (self.height - self.duration_label.height) / 2
+ self.duration_label.x = (self.width - self.duration_label.width) / 2
+ else:
+ self.duration_label.visible = False
+
+
+
+class DayLine(graphics.Scene):
+ __gsignals__ = {
+ "on-time-chosen": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ }
+
+ def __init__(self, start_time = None):
+ graphics.Scene.__init__(self)
+
+
+
+ day_start = conf.get("day_start_minutes")
+ self.day_start = dt.time(day_start / 60, day_start % 60)
+
+ self.view_time = start_time or dt.datetime.combine(dt.date.today(), self.day_start)
+
+ self.scope_hours = 24
+
+
+ self.fact_bars = []
+ self.categories = []
+
+ self.connect("on-enter-frame", self.on_enter_frame)
+ self.connect("on-mouse-move", self.on_mouse_move)
+ self.connect("on-mouse-down", self.on_mouse_down)
+ self.connect("on-mouse-up", self.on_mouse_up)
+ self.connect("on-click", self.on_click)
+
+
+ self.plot_area = graphics.Sprite()
+
+ self.selection = Selection()
+ self.chosen_selection = Selection()
+
+ self.plot_area.add_child(self.selection, self.chosen_selection)
+
+ self.drag_start = None
+ self.current_x = None
+ self.snap_points = []
+
+ self.add_child(self.plot_area)
+
+
+ def plot(self, date, facts, select_start, select_end = None):
+ for bar in self.fact_bars:
+ self.plot_area.sprites.remove(bar)
+
+ self.fact_bars = []
+ for fact in facts:
+ fact_bar = graphics.Rectangle(0, 0, fill = "#aaa", stroke="#aaa") # dimensions will depend on screen situation
+ fact_bar.fact = fact
+
+ if fact.category in self.categories:
+ fact_bar.category = self.categories.index(fact.category)
+ else:
+ fact_bar.category = len(self.categories)
+ self.categories.append(fact.category)
+
+ self.plot_area.add_child(fact_bar)
+ self.fact_bars.append(fact_bar)
+
+ self.view_time = dt.datetime.combine((select_start - dt.timedelta(hours=self.day_start.hour, minutes=self.day_start.minute)).date(), self.day_start)
+
+ if select_start and select_start > dt.datetime.now():
+ select_start = dt.datetime.now()
+ self.chosen_selection.start_time = select_start
+
+ if select_end and select_end > dt.datetime.now():
+ select_end = dt.datetime.now()
+ self.chosen_selection.end_time = select_end
+
+ self.chosen_selection.width = None
+ self.chosen_selection.fixed = True
+ self.chosen_selection.visible = True
+
+ self.redraw()
+
+
+ def on_mouse_down(self, scene, event):
+ self.drag_start = self.current_x
+ self.chosen_selection.visible = False
+
+ def on_mouse_up(self, scene, event):
+ if self.drag_start:
+ self.drag_start = None
+
+ start_time = self.selection.start_time
+ if start_time > dt.datetime.now():
+ start_time = dt.datetime.now()
+
+ end_time = self.selection.end_time
+ self.new_selection()
+ self.emit("on-time-chosen", start_time, end_time)
+
+ def on_click(self, scene, event, target):
+ self.drag_start = None
+
+ start_time = self.selection.start_time
+ if start_time > dt.datetime.now():
+ start_time = dt.datetime.now()
+
+
+ end_time = None
+ if self.fact_bars:
+ times = [bar.fact.start_time for bar in self.fact_bars if bar.fact.start_time - start_time > dt.timedelta(minutes=5)]
+ times.extend([bar.fact.start_time + bar.fact.delta for bar in self.fact_bars if bar.fact.start_time + bar.fact.delta - start_time > dt.timedelta(minutes=5)])
+ if times:
+ end_time = min(times)
+
+ self.new_selection()
+
+ self.emit("on-time-chosen", start_time, end_time)
+
+
+ def new_selection(self):
+ self.plot_area.sprites.remove(self.selection)
+ self.selection = Selection()
+ self.plot_area.add_child(self.selection)
+ self.redraw()
+
+ def on_mouse_move(self, scene, event):
+ if self.current_x:
+ active_bar = None
+ # find if we are maybe on a bar
+ for bar in self.fact_bars:
+ if bar.x < self.current_x < bar.x + bar.width:
+ active_bar = bar
+ break
+
+ if active_bar:
+ self.set_tooltip_text("%s - %s" % (active_bar.fact.activity, active_bar.fact.category))
+ else:
+ self.set_tooltip_text("")
+
+ self.redraw()
+
+
+ def on_enter_frame(self, scene, context):
+ g = graphics.Graphics(context)
+
+ self.plot_area.y = 15.5
+ self.plot_area.height = self.height - 30
+
+
+
+
+ vertical = min(self.plot_area.height / 5 , 7)
+ minute_pixel = (self.scope_hours * 60.0 - 15) / self.width
+
+ snap_points = []
+
+ g.set_line_style(width=1)
+
+
+
+
+ bottom = self.plot_area.y + self.plot_area.height
+
+ for bar in self.fact_bars:
+ bar.y = vertical * bar.category + 5
+ bar.height = vertical
+
+ bar_start_time = bar.fact.start_time - self.view_time
+ minutes = bar_start_time.seconds / 60 + bar_start_time.days * self.scope_hours * 60
+
+ bar.x = round(minutes / minute_pixel) + 0.5
+ bar.width = round((bar.fact.delta).seconds / 60 / minute_pixel)
+
+ if not snap_points or bar.x - snap_points[-1][0] > 1:
+ snap_points.append((bar.x, bar.fact.start_time))
+
+ if not snap_points or bar.x + bar.width - snap_points[-1][0] > 1:
+ snap_points.append((bar.x + bar.width, bar.fact.start_time + bar.fact.delta))
+
+ self.snap_points = snap_points
+
+
+ if self.chosen_selection.start_time and self.chosen_selection.width is None:
+ # we have time but no pixels
+ minutes = round((self.chosen_selection.start_time - self.view_time).seconds / 60 / minute_pixel) + 0.5
+ self.chosen_selection.x = minutes
+ if self.chosen_selection.end_time:
+ self.chosen_selection.width = round((self.chosen_selection.end_time - self.chosen_selection.start_time).seconds / 60 / minute_pixel)
+ else:
+ self.chosen_selection.width = 0
+ self.chosen_selection.height = self.chosen_selection.parent.height
+
+ # use the oportunity to set proper colors too
+ self.chosen_selection.fill = self.get_style().bg[gtk.STATE_SELECTED].to_string()
+ self.chosen_selection.duration_label.color = self.get_style().fg[gtk.STATE_SELECTED].to_string()
+
+
+ self.selection.visible = self._mouse_in # TODO - think harder about the mouse_out event
+
+ self.selection.width = 0
+ self.selection.height = self.selection.parent.height
+ if self.mouse_x:
+ start_x = max(min(self.mouse_x, self.width-1), 0) #mouse, but within screen regions
+
+ # check for snap points
+ start_x = start_x + 0.5
+ minutes = int(round(start_x * minute_pixel / 15)) * 15
+ start_time = self.view_time + dt.timedelta(hours = minutes / 60, minutes = minutes % 60)
+
+ if snap_points:
+ delta, closest_snap, time = min((abs(start_x - i), i, time) for i, time in snap_points)
+
+
+ if abs(closest_snap - start_x) < 5 and (not self.drag_start or self.drag_start != closest_snap):
+ start_x = closest_snap
+ minutes = (time.hour - self.day_start.hour) * 60 + time.minute - self.day_start.minute
+ start_time = time
+
+
+ self.current_x = minutes / minute_pixel
+
+
+ end_time, end_x = None, None
+ if self.drag_start:
+ minutes = int(self.drag_start * minute_pixel)
+ end_time = self.view_time + dt.timedelta(hours = minutes / 60, minutes = minutes % 60)
+ end_x = round(self.drag_start) + 0.5
+
+ if end_time and end_time < start_time:
+ start_time, end_time = end_time, start_time
+ start_x, end_x = end_x, start_x
+
+
+ self.selection.start_time = start_time
+ self.selection.end_time = end_time
+
+ self.selection.x = start_x
+ if end_time:
+ self.selection.width = end_x - start_x
+
+ self.selection.y = 0
+
+ self.selection.fill = self.get_style().bg[gtk.STATE_SELECTED].to_string()
+ self.selection.duration_label.color = self.get_style().fg[gtk.STATE_SELECTED].to_string()
+
+
+
+ #time scale
+ g.set_color("#000")
+
+ background = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+ text = self.get_style().text[gtk.STATE_NORMAL].to_string()
+
+ tick_color = g.colors.contrast(background, 80)
+
+ layout = g.create_layout(size = 8)
+ for i in range(self.scope_hours * 60):
+ label_time = (self.view_time + dt.timedelta(minutes=i))
+
+ g.set_color(tick_color)
+ if label_time.minute == 0:
+ g.move_to(round(i / minute_pixel) + 0.5, bottom - 15)
+ g.line_to(round(i / minute_pixel) + 0.5, bottom)
+ g.stroke()
+ elif label_time.minute % 15 == 0:
+ g.move_to(round(i / minute_pixel) + 0.5, bottom - 5)
+ g.line_to(round(i / minute_pixel) + 0.5, bottom)
+ g.stroke()
+
+
+
+ if label_time.minute == 0 and label_time.hour % 4 == 0:
+ if label_time.hour == 0:
+ g.move_to(round(i / minute_pixel) + 0.5, self.plot_area.y)
+ g.line_to(round(i / minute_pixel) + 0.5, bottom)
+ label_minutes = label_time.strftime("%b %d")
+ else:
+ label_minutes = label_time.strftime("%H<small><sup>%M</sup></small>")
+
+ g.set_color(text)
+ layout.set_markup(label_minutes)
+ label_w, label_h = layout.get_pixel_size()
+
+ g.move_to(round(i / minute_pixel) + 2, 0)
+ context.show_layout(layout)
+
+ #current time
+ if self.view_time < dt.datetime.now() < self.view_time + dt.timedelta(hours = self.scope_hours):
+ minutes = round((dt.datetime.now() - self.view_time).seconds / 60 / minute_pixel) + 0.5
+ g.move_to(minutes, self.plot_area.y)
+ g.line_to(minutes, bottom)
+ g.stroke("#f00", 0.4)
+ snap_points.append(minutes - 0.5)
diff --git a/win32/hamster/widgets/facttree.py b/win32/hamster/widgets/facttree.py
new file mode 100644
index 0000000..009b74b
--- /dev/null
+++ b/win32/hamster/widgets/facttree.py
@@ -0,0 +1,662 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+"""beware, this code has some major dragons in it. i'll clean it up one day!"""
+
+import gtk, gobject
+import cairo
+import datetime as dt
+
+from ..lib import stuff, graphics
+from tags import Tag
+
+import pango
+
+def parent_painter(column, cell, model, iter):
+ row = model.get_value(iter, 0)
+
+ if isinstance(row, FactRow):
+ cell.set_property('data', row)
+ else:
+ row.first = model.get_path(iter) == (0,) # first row
+ cell.set_property('data', row)
+
+def action_painter(column, cell, model, iter):
+ cell.set_property('xalign', 1)
+ cell.set_property('yalign', 0)
+
+ if isinstance(model.get_value(iter, 0), GroupRow):
+ cell.set_property("stock_id", "")
+ else:
+ cell.set_property("stock_id", "gtk-edit")
+
+
+
+class GroupRow(object):
+ def __init__(self, label, date, duration):
+ self.label = label
+ self.duration = duration
+ self.date = date
+ self.first = False # will be set by the painter, used
+
+ def __eq__(self, other):
+ return isinstance(other, GroupRow) \
+ and self.label == other.label \
+ and self.duration == other.duration \
+ and self.date == other.date
+
+ def __hash__(self):
+ return 1
+
+class FactRow(object):
+ def __init__(self, fact):
+ self.fact = fact
+ self.id = fact.id
+ self.name = fact.activity
+ self.category = fact.category
+ self.description = fact.description
+ self.tags = fact.tags
+ self.start_time = fact.start_time
+ self.end_time = fact.end_time
+ self.delta = fact.delta
+
+ def __eq__(self, other):
+ return isinstance(other, FactRow) and other.id == self.id \
+ and other.name == self.name \
+ and other.category == self.category \
+ and other.description == self.description \
+ and other.tags == self.tags \
+ and other.start_time == self.start_time \
+ and other.end_time == self.end_time \
+ and other.delta == self.delta
+
+
+ def __hash__(self):
+ return self.id
+
+class FactTree(gtk.TreeView):
+ __gsignals__ = {
+ "edit-clicked": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )),
+ "double-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, ))
+ }
+
+ def __init__(self):
+ gtk.TreeView.__init__(self)
+
+ self.set_headers_visible(False)
+ self.set_show_expanders(False)
+
+ # fact (None for parent), duration, parent data (if any)
+ self.store_model = gtk.ListStore(gobject.TYPE_PYOBJECT)
+ self.set_model(self.store_model)
+
+
+ fact_cell = FactCellRenderer()
+ fact_column = gtk.TreeViewColumn("", fact_cell, data=0)
+ fact_column.set_cell_data_func(fact_cell, parent_painter)
+ fact_column.set_expand(True)
+ self.append_column(fact_column)
+
+ edit_cell = gtk.CellRendererPixbuf()
+ edit_cell.set_property("ypad", 2)
+ edit_cell.set_property("mode", gtk.CELL_RENDERER_MODE_ACTIVATABLE)
+ self.edit_column = gtk.TreeViewColumn("", edit_cell)
+ self.edit_column.set_cell_data_func(edit_cell, action_painter)
+ self.append_column(self.edit_column)
+
+ self.connect("row-activated", self._on_row_activated)
+ self.connect("button-release-event", self._on_button_release_event)
+ self.connect("key-release-event", self._on_key_released)
+ self.connect("configure-event", lambda *args: self.fix_row_heights())
+ self.connect("motion-notify-event", self._on_motion)
+
+ self.show()
+
+ self.longest_activity_category = 0 # we will need this for the cell renderer
+ self.longest_interval = 0 # we will need this for the cell renderer
+ self.longest_duration = 0 # we will need this for the cell renderer
+ self.stored_selection = []
+
+ self.box = None
+
+
+ pixmap = gtk.gdk.Pixmap(None, 10, 10, 1)
+ _test_context = pixmap.cairo_create()
+ self._test_layout = _test_context.create_layout()
+ font = pango.FontDescription(gtk.Style().font_desc.to_string())
+ self._test_layout.set_font_description(font)
+ self.prev_rows = []
+ self.new_rows = []
+
+
+ def fix_row_heights(self):
+ alloc = self.get_allocation()
+ if alloc != self.box:
+ self.box = alloc
+ self.columns_autosize()
+
+ def clear(self):
+ #self.store_model.clear()
+ self.longest_activity_category = 0
+ self.longest_interval = 0
+ self.longest_duration = 0
+
+ def update_longest_dimensions(self, fact):
+ interval = "%s -" % fact.start_time.strftime("%H:%M")
+ if fact.end_time:
+ interval = "%s %s" % (interval, fact.end_time.strftime("%H:%M"))
+ self._test_layout.set_markup(interval)
+ w, h = self._test_layout.get_pixel_size()
+ self.longest_interval = max(self.longest_interval, w + 20)
+
+
+ self._test_layout.set_markup("%s - <small>%s</small> " % (stuff.escape_pango(fact.name),
+ stuff.escape_pango(fact.category)))
+ w, h = self._test_layout.get_pixel_size()
+ self.longest_activity_category = max(self.longest_activity_category, w + 10)
+
+ self._test_layout.set_markup("%s" % stuff.format_duration(fact.delta))
+ w, h = self._test_layout.get_pixel_size()
+ self.longest_duration = max(self.longest_duration, w)
+
+
+ def add_fact(self, fact):
+ fact = FactRow(fact)
+ self.update_longest_dimensions(fact)
+ self.new_rows.append(fact)
+
+
+ def add_group(self, group_label, group_date, facts):
+ total_duration = stuff.duration_minutes([fact.delta for fact in facts])
+
+ self.new_rows.append(GroupRow(group_label, group_date, total_duration))
+
+ for fact in facts:
+ self.add_fact(fact)
+
+
+ def get_row(self, path):
+ """checks if the path is valid and if so, returns the model row"""
+ if path is None or path < 0: return None
+
+ try: # see if path is still valid
+ iter = self.store_model.get_iter(path)
+ return self.store_model[path]
+ except:
+ return None
+
+ def id_or_label(self, path):
+ """returns id or date, id if it is a fact row or date if it is a group row"""
+ row = self.get_row(path)
+ if not row: return None
+
+ if isinstance(row[0], FactRow):
+ return row[0].id
+ else:
+ return row[0].label
+
+
+ def detach_model(self):
+ self.prev_rows = list(self.new_rows)
+ self.new_rows = []
+ # ooh, somebody is going for refresh!
+ # let's save selection too - maybe it will come handy
+ self.store_selection()
+
+ #self.parent.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER)
+
+
+ # and now do what we were asked to
+ #self.set_model()
+ self.clear()
+
+
+ def attach_model(self):
+ prev_rows = set(self.prev_rows)
+ new_rows = set(self.new_rows)
+ common = set(prev_rows) & set(new_rows)
+
+ if common: # do full refresh only if we don't recognize any rows
+ gone = prev_rows - new_rows
+ if gone:
+ all_rows = len(self.store_model)
+ rows = list(self.store_model)
+ rows.reverse()
+
+ for i, row in enumerate(rows):
+ if row[0] in gone:
+ self.store_model.remove(self.store_model.get_iter(all_rows - i-1))
+
+ self.prev_rows = [row[0] for row in self.store_model]
+
+ new = new_rows - prev_rows
+ if new:
+ for i, row in enumerate(self.new_rows):
+ if i <= len(self.store_model) - 1:
+ if row == self.store_model[i][0]:
+ continue
+
+ self.store_model.insert_before(self.store_model.get_iter(i), (row,))
+ else:
+ self.store_model.append((row, ))
+
+
+ else:
+ self.store_model.clear()
+ for row in self.new_rows:
+ self.store_model.append((row, ))
+
+ if self.stored_selection:
+ self.restore_selection()
+
+
+ def store_selection(self):
+ self.stored_selection = None
+ selection = self.get_selection()
+ if not selection:
+ return
+
+ model, iter = selection.get_selected()
+ if iter:
+ path = model.get_path(iter)[0]
+ prev, cur, next = path - 1, path, path + 1
+ self.stored_selection = ((prev, self.id_or_label(prev)),
+ (cur, self.id_or_label(cur)),
+ (next, self.id_or_label(next)))
+
+
+ def restore_selection(self):
+ """the code is quite hairy, but works with all kinds of deletes
+ and does not select row when it should not.
+ TODO - it might be worth replacing this with something much simpler"""
+ model = self.store_model
+
+ new_prev_val, new_cur_val, new_next_val = None, None, None
+ prev, cur, next = self.stored_selection
+
+ if cur: new_cur_val = self.id_or_label(cur[0])
+ if prev: new_prev_val = self.id_or_label(prev[0])
+ if next: new_next_val = self.id_or_label(next[0])
+
+ path = None
+ values = (new_prev_val, new_cur_val, new_next_val)
+ paths = (prev, cur, next)
+
+ if cur[1] and cur[1] in values: # simple case
+ # look if we can find previous current in the new threesome
+ path = paths[values.index(cur[1])][0]
+ elif prev[1] and prev[1] == new_prev_val and next[1] and next[1] == new_next_val:
+ # on update the ID changes so we find it by matching in between
+ path = cur[0]
+ elif prev[1] and prev[1] == new_prev_val: # all that's left is delete.
+ if new_cur_val:
+ path = cur[0]
+ else:
+ path = prev[0]
+ elif not new_prev_val and not new_next_val and new_cur_val:
+ # the only record in the tree (no next no previous, but there is current)
+ path = cur[0]
+
+
+ if path is not None:
+ selection = self.get_selection()
+ selection.select_path(path)
+
+ self.set_cursor_on_cell(path)
+
+ def select_fact(self, fact_id):
+ i = 0
+ while self.id_or_label(i) and self.id_or_label(i) != fact_id:
+ i +=1
+
+ if self.id_or_label(i) == fact_id:
+ selection = self.get_selection()
+ selection.select_path(i)
+
+ def get_selected_fact(self):
+ selection = self.get_selection()
+ (model, iter) = selection.get_selected()
+ if iter:
+ data = model[iter][0]
+ if isinstance(data, FactRow):
+ return data.fact
+ else:
+ return data.date
+ else:
+ return None
+
+
+ def _on_button_release_event(self, tree, event):
+ # a hackish solution to make edit icon keyboard accessible
+ pointer = event.window.get_pointer() # x, y, flags
+ path = self.get_path_at_pos(pointer[0], pointer[1]) #column, innerx, innery
+
+ if path and path[1] == self.edit_column:
+ self.emit("edit-clicked", self.get_selected_fact())
+ return True
+
+ return False
+
+ def _on_row_activated(self, tree, path, column):
+ if column == self.edit_column:
+ self.emit_stop_by_name ('row-activated')
+ self.emit("edit-clicked", self.get_selected_fact())
+ return True
+
+
+ def _on_key_released(self, tree, event):
+ # capture e keypress and pretend that user click on edit
+ if (event.keyval == gtk.keysyms.e):
+ self.emit("edit-clicked", self.get_selected_fact())
+ return True
+
+ return False
+
+
+ def _on_motion(self, view, event):
+ 'As the pointer moves across the view, show a tooltip.'
+
+ path = view.get_path_at_pos(int(event.x), int(event.y))
+
+ if path:
+ path, col, x, y = path
+
+ model = self.get_model()
+ data = model[path][0]
+
+ self.set_tooltip_text(None)
+
+ if isinstance(data, FactRow):
+ renderer = view.get_column(0).get_cell_renderers()[0]
+
+ label = data.description
+ self.set_tooltip_text(label)
+
+ self.trigger_tooltip_query()
+
+
+
+class FactCellRenderer(gtk.GenericCellRenderer):
+ """ We need all kinds of wrapping and spanning and the treeview just does
+ not cut it"""
+
+ __gproperties__ = {
+ "data": (gobject.TYPE_PYOBJECT, "Data", "Data", gobject.PARAM_READWRITE),
+ }
+
+ def __init__(self):
+ gtk.GenericCellRenderer.__init__(self)
+ self.height = 0
+ self.data = None
+
+ font = gtk.Style().font_desc
+ self.default_size = font.get_size() / pango.SCALE
+
+ self.labels = graphics.Sprite()
+
+ self.date_label = graphics.Label(size = self.default_size)
+
+ self.interval_label = graphics.Label(size = self.default_size)
+ self.labels.add_child(self.interval_label)
+
+ self.activity_label = graphics.Label(size = self.default_size)
+ self.labels.add_child(self.activity_label)
+
+ self.category_label = graphics.Label(size = self.default_size)
+ self.labels.add_child(self.category_label)
+
+ self.description_label = graphics.Label(size = self.default_size)
+ self.labels.add_child(self.description_label)
+
+ self.duration_label = graphics.Label(size=self.default_size)
+ self.labels.add_child(self.duration_label)
+
+ default_font = gtk.Style().font_desc.to_string()
+
+ self.tag = Tag("")
+
+ self.selected_color = None
+ self.normal_color = None
+
+ self.col_padding = 10
+ self.row_padding = 4
+
+ def do_set_property (self, pspec, value):
+ setattr(self, pspec.name, value)
+
+ def do_get_property(self, pspec):
+ return getattr (self, pspec.name)
+
+
+ def on_render (self, window, widget, background_area, cell_area, expose_area, flags):
+ if not self.data:
+ return
+
+ """
+ ASCII Art
+ --------------+--------------------------------------------+-------+---+
+ 13:12 - 17:18 | Some activity - category tag, tag, tag, | 14:44 | E |
+ | tag, tag, some description | | |
+ --------------+--------------------------------------------+-------+---+
+ """
+ # set the colors
+ self.selected_color = widget.get_style().text[gtk.STATE_SELECTED]
+ self.normal_color = widget.get_style().text[gtk.STATE_NORMAL]
+
+ context = window.cairo_create()
+
+ if isinstance(self.data, FactRow):
+ fact, parent = self.data, None
+ else:
+ parent, fact = self.data, None
+
+
+ x, y, width, height = cell_area
+ context.translate(x, y)
+
+ if flags & gtk.CELL_RENDERER_SELECTED:
+ text_color = self.selected_color
+ else:
+ text_color = self.normal_color
+
+ self.date_label.color = text_color
+ self.duration_label.color = text_color
+
+ if parent:
+ self.date_label.text = "<b>%s</b>" % stuff.escape_pango(parent.label)
+ self.date_label.x = 5
+
+ if self.data.first:
+ y = 5
+ else:
+ y = 20
+
+ self.date_label.y = y
+
+
+ self.duration_label.text = "<b>%s</b>" % stuff.format_duration(parent.duration)
+ self.duration_label.x = width - self.duration_label.width
+ self.duration_label.y = y
+
+ self.date_label._draw(context)
+ self.duration_label._draw(context)
+ else:
+ self.render_cell(context, (x,y,width,height), widget, flags)
+
+
+ def render_cell(self, context, bounds, widget, flags, really = True):
+ if not bounds:
+ return -1
+ x, y, cell_width, h = bounds
+
+ self.selected_color = widget.get_style().text[gtk.STATE_SELECTED]
+ self.normal_color = widget.get_style().text[gtk.STATE_NORMAL]
+
+ g = graphics.Graphics(context)
+
+ fact = self.data
+
+ selected = flags and flags & gtk.CELL_RENDERER_SELECTED
+
+ text_color = self.normal_color
+ if selected:
+ text_color = self.selected_color
+
+
+
+ """ start time and end time at beginning of column """
+ interval = fact.start_time.strftime("%H:%M -")
+ if fact.end_time:
+ interval = "%s %s" % (interval, fact.end_time.strftime("%H:%M"))
+
+ self.interval_label.text = interval
+ self.interval_label.color = text_color
+ self.interval_label.x = self.col_padding
+ self.interval_label.y = 2
+
+
+ """ duration at the end """
+ self.duration_label.text = stuff.format_duration(fact.delta)
+ self.duration_label.color = text_color
+ self.duration_label.x = cell_width - self.duration_label.width
+ self.duration_label.y = 2
+
+
+ """ activity, category, tags, description in middle """
+ # we want our columns look aligned, so we will do fixed offset from
+ # both sides, in letter length
+
+ cell_start = widget.longest_interval
+ cell_width = cell_width - widget.longest_interval - widget.longest_duration
+
+
+ # align activity and category (ellipsize activity if it does not fit)
+ category_width = 0
+
+ self.category_label.text = ""
+ if fact.category:
+ self.category_label.text = " - <small>%s</small>" % stuff.escape_pango(fact.category)
+ if not selected:
+ category_color = graphics.Colors.contrast(text_color, 100)
+
+ self.category_label.color = category_color
+ else:
+ self.category_label.color = text_color
+ category_width = self.category_label.width
+
+
+ self.activity_label.color = text_color
+ self.activity_label.width = None
+ self.activity_label.text = stuff.escape_pango(fact.name)
+
+ # if activity label does not fit, we will shrink it
+ if self.activity_label.width > cell_width - category_width:
+ self.activity_label.width = (cell_width - category_width - self.col_padding)
+ self.activity_label.ellipsize = pango.ELLIPSIZE_END
+ else:
+ self.activity_label.width = None
+ #self.activity_label.ellipsize = None
+
+ activity_width = self.activity_label.width
+
+ y = 2
+
+ self.activity_label.x = cell_start
+ self.activity_label.y = y
+
+
+ self.category_label.x = cell_start + activity_width
+ self.category_label.y = y
+
+
+ x = cell_start + activity_width + category_width + 12
+
+ current_height = 0
+ if fact.tags:
+ # try putting tags on same line if they fit
+ # otherwise move to the next line
+ tags_end = cell_start + cell_width
+
+ if x + self.tag.width > tags_end:
+ x = cell_start
+ y = self.activity_label.height + 4
+
+
+ for i, tag in enumerate(fact.tags):
+ self.tag.text = tag
+
+ if x + self.tag.width >= tags_end:
+ x = cell_start
+ y += self.tag.height + 4
+
+ self.tag.x, self.tag.y = x, y
+ if really:
+ self.tag._draw(context)
+
+ x += self.tag.width + 4
+
+ current_height = y + self.tag.height + 4
+
+
+ current_height = max(self.activity_label.height + 2, current_height)
+
+
+ # see if we can fit in single line
+ # if not, put description under activity
+ self.description_label.text = ""
+ if fact.description:
+ self.description_label.text = "<small>%s</small>" % stuff.escape_pango(fact.description)
+ self.description_label.color = text_color
+ self.description_label.wrap = pango.WRAP_WORD
+
+ description_width = self.description_label.width
+ width = cell_width - x
+
+ if description_width > width:
+ x = cell_start
+ y = current_height
+ self.description_label.width = cell_width
+ else:
+ self.description_label.width = None
+
+ self.description_label.x = x
+ self.description_label.y = y
+
+ current_height = max(current_height, self.description_label.y + self.description_label.height + 5)
+
+ self.labels._draw(context)
+
+ return current_height
+
+
+ def on_get_size(self, widget, cell_area):
+ if isinstance(self.data, GroupRow):
+ if self.data.first:
+ return (0, 0, 0, int((self.default_size + 10) * 1.5))
+ else:
+ return (0, 0, 0, (self.default_size + 10) * 2)
+
+
+ context = gtk.gdk.CairoContext(cairo.Context(cairo.ImageSurface(cairo.FORMAT_A1, 0, 0)))
+ area = widget.get_allocation()
+
+ area.width -= 40 # minus the edit column, scrollbar and padding (and the scrollbar part is quite lame)
+
+ cell_height = self.render_cell(context, area, widget, None, False)
+ return (0, 0, -1, cell_height)
diff --git a/win32/hamster/widgets/rangepick.py b/win32/hamster/widgets/rangepick.py
new file mode 100644
index 0000000..a49392b
--- /dev/null
+++ b/win32/hamster/widgets/rangepick.py
@@ -0,0 +1,138 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+from ..lib import stuff
+from ..configuration import load_ui_file
+
+import gtk, gobject, pango
+import datetime as dt
+import calendar
+import gobject
+import re
+
+class RangePick(gtk.ToggleButton):
+ """ a text entry widget with calendar popup"""
+ __gsignals__ = {
+ # day|week|month|manual, start, end
+ 'range-selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ }
+
+
+ def __init__(self, date = None):
+ gtk.ToggleButton.__init__(self)
+
+ self._gui = load_ui_file("range_pick.ui")
+ self.popup = self.get_widget("range_popup")
+
+ hbox = gtk.HBox()
+ hbox.set_spacing(3)
+ self.label = gtk.Label()
+ hbox.pack_start(self.label, False)
+ #self.get_widget("hbox1").pack_start(gtk.VSeparator())
+ hbox.pack_start(gtk.Arrow(gtk.ARROW_DOWN, gtk.SHADOW_ETCHED_IN), False)
+ self.add(hbox)
+
+ self.start_date, self.end_date, self.view_date = None, None, None
+
+ self.popup.connect("focus-out-event", self.on_focus_out)
+ self.connect("toggled", self.on_toggle)
+
+ self._gui.connect_signals(self)
+
+
+ def on_toggle(self, button):
+ if self.get_active():
+ self.show()
+ else:
+ self.hide()
+
+ def set_range(self, start_date, end_date, view_date):
+ self.start_date, self.end_date, self.view_date = start_date, end_date, view_date
+ self.label.set_markup('<big><b>%s</b></big>' % stuff.format_range(start_date, end_date).encode("utf-8"))
+
+ def get_widget(self, name):
+ """ skip one variable (huh) """
+ return self._gui.get_object(name)
+
+ def on_focus_out(self, window, event):
+ # avoid double-toggling when focus goes from window to the toggle button
+ if gtk.STATE_PRELIGHT & self.get_state():
+ return
+
+ self.set_active(False)
+
+
+
+ def hide(self):
+ self.set_active(False)
+ self.popup.hide()
+
+ def show(self):
+ x, y = self.get_window().get_origin()
+
+ alloc = self.get_allocation()
+
+ self.popup.move(x + alloc.x,y + alloc.y + alloc.height)
+
+ self.get_widget("day_preview").set_text(stuff.format_range(self.view_date, self.view_date).decode("utf-8"))
+ self.get_widget("week_preview").set_text(stuff.format_range(*stuff.week(self.view_date)).decode("utf-8"))
+ self.get_widget("month_preview").set_text(stuff.format_range(*stuff.month(self.view_date)).decode("utf-8"))
+
+ start_cal = self.get_widget("start_calendar")
+ start_cal.select_month(self.start_date.month - 1, self.start_date.year)
+ start_cal.select_day(self.start_date.day)
+
+ end_cal = self.get_widget("end_calendar")
+ end_cal.select_month(self.end_date.month - 1, self.end_date.year)
+ end_cal.select_day(self.end_date.day)
+
+ self.popup.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
+ self.popup.show_all()
+ self.get_widget("day").grab_focus()
+ self.set_active(True)
+
+
+ def emit_range(self, range, start, end):
+ self.hide()
+ self.emit("range-selected", range, start, end)
+
+ def on_day_clicked(self, button):
+ self.emit_range("day", self.view_date, self.view_date)
+
+ def on_week_clicked(self, button):
+ self.start_date, self.end_date = stuff.week(self.view_date)
+ self.emit_range("week", self.start_date, self.end_date)
+
+ def on_month_clicked(self, button):
+ self.start_date, self.end_date = stuff.month(self.view_date)
+ self.emit_range("month", self.start_date, self.end_date)
+
+ def on_manual_range_apply_clicked(self, button):
+ self.current_range = "manual"
+ cal_date = self.get_widget("start_calendar").get_date()
+ self.start_date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+
+ cal_date = self.get_widget("end_calendar").get_date()
+ self.end_date = dt.date(cal_date[0], cal_date[1] + 1, cal_date[2])
+
+ # make sure we always have a valid range
+ if self.end_date < self.start_date:
+ self.start_date, self.end_date = self.end_date, self.start_date
+
+ self.emit_range("manual", self.start_date, self.end_date)
diff --git a/win32/hamster/widgets/reportchooserdialog.py b/win32/hamster/widgets/reportchooserdialog.py
new file mode 100644
index 0000000..529edab
--- /dev/null
+++ b/win32/hamster/widgets/reportchooserdialog.py
@@ -0,0 +1,139 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+
+import pygtk
+pygtk.require('2.0')
+
+import os
+import gtk, gobject
+from ..configuration import conf
+
+class ReportChooserDialog(gtk.Dialog):
+ __gsignals__ = {
+ # format, path, start_date, end_date
+ 'report-chosen': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
+ (gobject.TYPE_STRING, gobject.TYPE_STRING)),
+ 'report-chooser-closed': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+ def __init__(self):
+ gtk.Dialog.__init__(self)
+
+
+ self.dialog = gtk.FileChooserDialog(title = _(u"Save Report â?? Time Tracker"),
+ parent = self,
+ action = gtk.FILE_CHOOSER_ACTION_SAVE,
+ buttons=(gtk.STOCK_CANCEL,
+ gtk.RESPONSE_CANCEL,
+ gtk.STOCK_SAVE,
+ gtk.RESPONSE_OK))
+
+ # try to set path to last known folder or fall back to home
+ report_folder = os.path.expanduser(conf.get("last_report_folder"))
+ if os.path.exists(report_folder):
+ self.dialog.set_current_folder(report_folder)
+ else:
+ self.dialog.set_current_folder(os.path.expanduser("~"))
+
+ self.filters = {}
+
+ filter = gtk.FileFilter()
+ filter.set_name(_("HTML Report"))
+ filter.add_mime_type("text/html")
+ filter.add_pattern("*.html")
+ filter.add_pattern("*.htm")
+ self.filters[filter] = "html"
+ self.dialog.add_filter(filter)
+
+ filter = gtk.FileFilter()
+ filter.set_name(_("Tab-Separated Values (TSV)"))
+ filter.add_mime_type("text/plain")
+ filter.add_pattern("*.tsv")
+ filter.add_pattern("*.txt")
+ self.filters[filter] = "tsv"
+ self.dialog.add_filter(filter)
+
+ filter = gtk.FileFilter()
+ filter.set_name(_("XML"))
+ filter.add_mime_type("text/xml")
+ filter.add_pattern("*.xml")
+ self.filters[filter] = "xml"
+ self.dialog.add_filter(filter)
+
+ filter = gtk.FileFilter()
+ filter.set_name(_("iCal"))
+ filter.add_mime_type("text/calendar")
+ filter.add_pattern("*.ics")
+ self.filters[filter] = "ical"
+ self.dialog.add_filter(filter)
+
+ filter = gtk.FileFilter()
+ filter.set_name("All files")
+ filter.add_pattern("*")
+ self.dialog.add_filter(filter)
+
+
+ def show(self, start_date, end_date):
+ """setting suggested name to something readable, replace backslashes
+ with dots so the name is valid in linux"""
+
+ # title in the report file name
+ vars = {"title": _("Time track"),
+ "start": start_date.strftime("%x").replace("/", "."),
+ "end": end_date.strftime("%x").replace("/", ".")}
+ if start_date != end_date:
+ filename = "%(title)s, %(start)s - %(end)s.html" % vars
+ else:
+ filename = "%(title)s, %(start)s.html" % vars
+
+ self.dialog.set_current_name(filename)
+
+ response = self.dialog.run()
+
+ if response != gtk.RESPONSE_OK:
+ self.emit("report-chooser-closed")
+ self.dialog.destroy()
+ else:
+ self.on_save_button_clicked()
+
+
+ def present(self):
+ self.dialog.present()
+
+ def on_save_button_clicked(self):
+ path, format = None, None
+
+ format = "html"
+ if self.dialog.get_filter() in self.filters:
+ format = self.filters[self.dialog.get_filter()]
+ path = self.dialog.get_filename()
+
+ # append correct extension if it is missing
+ # TODO - proper way would be to change extension on filter change
+ # only pointer in web is http://www.mail-archive.com/pygtk daa com au/msg08740.html
+ if path.endswith(".%s" % format) == False:
+ path = "%s.%s" % (path.rstrip("."), format)
+
+ categories = []
+
+ conf.set("last_report_folder", os.path.dirname(path))
+
+ # format, path, start_date, end_date
+ self.emit("report-chosen", format, path)
+ self.dialog.destroy()
diff --git a/win32/hamster/widgets/tags.py b/win32/hamster/widgets/tags.py
new file mode 100644
index 0000000..24946f9
--- /dev/null
+++ b/win32/hamster/widgets/tags.py
@@ -0,0 +1,349 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk, gobject
+import pango, cairo
+from math import pi
+
+from ..lib import graphics, stuff
+from ..configuration import runtime
+
+class TagsEntry(gtk.Entry):
+ __gsignals__ = {
+ 'tags-selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+ def __init__(self):
+ gtk.Entry.__init__(self)
+ self.tags = None
+ self.filter = None # currently applied filter string
+ self.filter_tags = [] #filtered tags
+
+ self.popup = gtk.Window(type = gtk.WINDOW_POPUP)
+ self.scroll_box = gtk.ScrolledWindow()
+ self.scroll_box.set_shadow_type(gtk.SHADOW_IN)
+ self.scroll_box.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
+ viewport = gtk.Viewport()
+ viewport.set_shadow_type(gtk.SHADOW_NONE)
+
+ self.tag_box = TagBox()
+ self.tag_box.connect("tag-selected", self.on_tag_selected)
+ self.tag_box.connect("tag-unselected", self.on_tag_unselected)
+
+
+ viewport.add(self.tag_box)
+ self.scroll_box.add(viewport)
+ self.popup.add(self.scroll_box)
+
+ self.connect("button-press-event", self._on_button_press_event)
+ 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
+
+ runtime.storage.connect('tags-changed', self.refresh_tags)
+ self.show()
+ self.populate_suggestions()
+
+
+ def refresh_tags(self, event):
+ self.tags = None
+
+ def get_tags(self):
+ # splits the string by comma and filters out blanks
+ return [tag.strip() for tag in self.get_text().decode('utf8', 'replace').split(",") if tag.strip()]
+
+ def on_tag_selected(self, tag_box, tag):
+ cursor_tag = self.get_cursor_tag()
+ if cursor_tag and tag.startswith(cursor_tag):
+ self.replace_tag(cursor_tag, tag)
+ tags = self.get_tags()
+ else:
+ tags = self.get_tags()
+ tags.append(tag)
+
+ self.tag_box.selected_tags = tags
+
+
+ self.set_text("%s, " % ", ".join(tags))
+ self.set_position(len(self.get_text()))
+
+ self.populate_suggestions()
+ self.show_popup()
+
+ def on_tag_unselected(self, tag_box, tag):
+ tags = self.get_tags()
+ while tag in tags: #it could be that dear user is mocking us and entering same tag over and over again
+ tags.remove(tag)
+
+ self.tag_box.selected_tags = tags
+
+ self.set_text("%s, " % ", ".join(tags))
+ self.set_position(len(self.get_text()))
+
+
+ def hide_popup(self):
+ self.popup.hide()
+ if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher):
+ self.get_toplevel().disconnect(self._parent_click_watcher)
+ self._parent_click_watcher = None
+
+ def show_popup(self):
+ if not self.filter_tags:
+ self.popup.hide()
+ return
+
+ if not self._parent_click_watcher:
+ 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()
+
+ self.popup.move(x + alloc.x,y + alloc.y + alloc.height)
+
+ w = alloc.width
+
+ height = self.tag_box.count_height(w)
+
+
+ self.tag_box.modify_bg(gtk.STATE_NORMAL, self.get_style().base[gtk.STATE_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)]
+
+ cursor_tag = self.get_cursor_tag()
+
+ self.filter = cursor_tag
+
+ 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.tag_box.draw(self.filter_tags)
+
+
+
+ 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 (gtk.keysyms.Return, gtk.keysyms.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 == gtk.keysyms.Escape):
+ if self.popup.get_property("visible"):
+ self.hide_popup()
+ return True
+ else:
+ return False
+ else:
+ self.populate_suggestions()
+ self.show_popup()
+
+ if event.keyval not in (gtk.keysyms.Delete, gtk.keysyms.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():
+ cursor = self.get_selection_bounds()[0]
+ else:
+ cursor = self.get_position()
+
+ text = self.get_text().decode('utf8', 'replace')
+
+ return text[text.rfind(",", 0, cursor)+1:max(text.find(",", cursor+1)+1, len(text))].strip()
+
+
+ def replace_tag(self, old_tag, new_tag):
+ tags = self.get_tags()
+ if old_tag in tags:
+ tags[tags.index(old_tag)] = new_tag
+
+ if self.get_selection_bounds():
+ cursor = self.get_selection_bounds()[0]
+ else:
+ cursor = self.get_position()
+
+ self.set_text(", ".join(tags))
+ self.set_position(len(self.get_text()))
+
+ def _on_key_press_event(self, entry, event):
+ if event.keyval == gtk.keysyms.Tab:
+ if self.popup.get_property("visible"):
+ #we have to replace
+ if self.get_text() and self.get_cursor_tag() != self.filter_tags[0]:
+ self.replace_tag(self.get_cursor_tag(), self.filter_tags[0])
+ return True
+ else:
+ return False
+ else:
+ return False
+
+ return False
+
+
+class TagBox(graphics.Scene):
+ __gsignals__ = {
+ 'tag-selected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (str,)),
+ 'tag-unselected': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (str,)),
+ }
+
+ def __init__(self, interactive = True):
+ graphics.Scene.__init__(self)
+ self.interactive = interactive
+ self.hover_tag = None
+ self.tags = []
+ self.selected_tags = []
+ self.layout = None
+
+ if self.interactive:
+ self.connect("on-mouse-over", self.on_mouse_over)
+ self.connect("on-mouse-out", self.on_mouse_out)
+ self.connect("on-click", self.on_tag_click)
+
+ self.connect("on-enter-frame", self.on_enter_frame)
+
+ def on_mouse_over(self, area, tag):
+ tag.color = tag.graphics.colors.darker(tag.color, -20)
+
+ def on_mouse_out(self, area, tag):
+ if tag.text in self.selected_tags:
+ tag.color = (242, 229, 97)
+ else:
+ tag.color = (241, 234, 170)
+
+
+ def on_tag_click(self, area, event, tag):
+ if not tag: return
+
+ if tag.text in self.selected_tags:
+ self.emit("tag-unselected", tag.text)
+ else:
+ self.emit("tag-selected", tag.text)
+ self.on_mouse_out(area, tag) #paint
+ self.redraw()
+
+ def draw(self, tags):
+ new_tags = []
+ for label in tags:
+ tag = Tag(label)
+ if label in self.selected_tags:
+ tag.tag.fill = (242, 229, 97)
+ new_tags.append(tag)
+
+ for tag in self.tags:
+ self.sprites.remove(tag)
+
+ self.add_child(*new_tags)
+ self.tags = new_tags
+
+ self.show()
+ self.redraw()
+
+ def count_height(self, width):
+ # reposition tags and see how much space we take up
+ self.width = width
+ w, h = self.on_enter_frame(None, None)
+ return h + 6
+
+ def on_enter_frame(self, scene, context):
+ cur_x, cur_y = 4, 4
+ tag = None
+ for tag in self.tags:
+ if cur_x + tag.width >= self.width - 5: #if we do not fit, we wrap
+ cur_x = 5
+ cur_y += tag.height + 6
+
+ tag.x = cur_x
+ tag.y = cur_y
+
+ cur_x += tag.width + 6 #some padding too, please
+
+ if tag:
+ cur_y += tag.height + 2 # the last one
+
+ return cur_x, cur_y
+
+class Tag(graphics.Sprite):
+ def __init__(self, text, interactive = True, color = "#F1EAAA"):
+ graphics.Sprite.__init__(self, interactive = interactive)
+
+ self.width, self.height = 0,0
+
+ font = gtk.Style().font_desc
+ font_size = int(font.get_size() * 0.8 / pango.SCALE) # 80% of default
+
+ self.label = graphics.Label(text, size = font_size, color = (30, 30, 30), y = 1)
+ self.color = color
+ self.add_child(self.label)
+
+ self.corner = int((self.label.height + 3) / 3) + 0.5
+ self.label.x = self.corner + 6
+
+ self.text = stuff.escape_pango(text)
+ self.connect("on-render", self.on_render)
+
+ def __setattr__(self, name, value):
+ graphics.Sprite.__setattr__(self, name, value)
+ if name == 'text' and hasattr(self, 'label'):
+ self.label.text = value
+ self.__dict__['width'], self.__dict__['height'] = int(self.label.x + self.label.width + self.label.height * 0.3), self.label.height + 3
+
+ def on_render(self, sprite):
+ self.graphics.set_line_style(width=1)
+
+ self.graphics.move_to(0.5, self.corner)
+ self.graphics.line_to([(self.corner, 0.5),
+ (self.width + 0.5, 0.5),
+ (self.width + 0.5, self.height - 0.5),
+ (self.corner, self.height - 0.5),
+ (0.5, self.height - self.corner)])
+ self.graphics.close_path()
+ self.graphics.fill_stroke(self.color, "#b4b4b4")
+
+ self.graphics.circle(6, self.height / 2, 2)
+ self.graphics.fill_stroke("#fff", "#b4b4b4")
diff --git a/win32/hamster/widgets/timechart.py b/win32/hamster/widgets/timechart.py
new file mode 100644
index 0000000..2c01155
--- /dev/null
+++ b/win32/hamster/widgets/timechart.py
@@ -0,0 +1,426 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+import os # for locale
+import gobject, gtk, pango
+
+from ..lib import graphics, stuff
+
+import time, datetime as dt
+import calendar
+
+from bisect import bisect
+
+DAY = dt.timedelta(1)
+WEEK = dt.timedelta(7)
+
+class VerticalBar(graphics.Sprite):
+ def __init__(self, key, format, value, normalized):
+ graphics.Sprite.__init__(self)
+
+ self.key, self.format = key, format,
+ self.value, self.normalized = value, normalized
+
+ # area dimensions - to be set externally
+ self.height = 0
+ self.width = 20
+ self.fill = None
+
+ self.key_label = graphics.Label(key.strftime(format), x=2, y=0, size=8, color="#000")
+
+ self.add_child(self.key_label)
+ self.show_label = True
+
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ # invisible rectangle for the mouse, covering whole area
+ self.graphics.set_color("#000", 0)
+ self.graphics.rectangle(0, 0, self.width, self.height)
+ self.graphics.stroke()
+
+ size = max(round(self.height * self.normalized * 0.8), 1)
+
+ self.graphics.rectangle(0, self.height - size, self.width, size, 3)
+ self.graphics.rectangle(0, self.height - min(size, 3), self.width, min(size, 3))
+ self.graphics.fill(self.fill)
+
+
+ if self.show_label and self.key_label not in self.sprites:
+ self.add_child(self.key_label)
+ elif self.show_label == False and self.key_label in self.sprites:
+ self.sprites.remove(self.key_label)
+
+class Icon(graphics.Sprite):
+ def __init__(self, pixbuf, **kwargs):
+ graphics.Sprite.__init__(self, **kwargs)
+ self.pixbuf = pixbuf
+ self.interactive = True
+ self.connect("on-render", self.on_render)
+
+ def on_render(self, sprite):
+ self.graphics.set_source_pixbuf(self.pixbuf, 0, 0)
+ self.graphics.paint()
+ self.graphics.rectangle(0,0,24,24) #transparent rectangle
+ self.graphics.stroke("#000", 0)
+
+
+class TimeChart(graphics.Scene):
+ """this widget is kind of half finished"""
+ __gsignals__ = {
+ 'range-picked': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)),
+ 'zoom-out-clicked': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+ def __init__(self):
+ graphics.Scene.__init__(self)
+ self.start_time, self.end_time = None, None
+ self.durations = []
+
+ self.day_start = dt.time() # ability to start day at another hour
+ self.first_weekday = stuff.locale_first_weekday()
+
+ self.interactive = True
+
+ self.minor_tick = None
+ self.tick_totals = []
+ self.bars = []
+
+ self.connect("on-enter-frame", self.on_enter_frame)
+ self.connect("on-mouse-over", self.on_mouse_over)
+ self.connect("on-mouse-out", self.on_mouse_out)
+ self.connect("on-click", self.on_click)
+
+ self.connect("enter_notify_event", self.on_mouse_enter)
+ self.connect("leave_notify_event", self.on_mouse_leave)
+
+ self.zoom_out_icon = Icon(self.render_icon(gtk.STOCK_ZOOM_OUT, gtk.ICON_SIZE_MENU),
+ visible = False, z_order = 500)
+ self.add_child(self.zoom_out_icon)
+
+
+ def on_mouse_enter(self, scene, event):
+ if (self.end_time - self.start_time) < dt.timedelta(days=356):
+ self.zoom_out_icon.visible = True
+ self.redraw()
+
+ def on_mouse_leave(self, scene, event):
+ self.zoom_out_icon.visible = False
+ self.redraw()
+
+ def on_mouse_over(self, scene, target):
+ if isinstance(target, VerticalBar):
+ bar = target
+ bar.fill = self.get_style().base[gtk.STATE_PRELIGHT].to_string()
+ self.set_tooltip_text(stuff.format_duration(bar.value))
+
+ self.redraw()
+
+ def on_mouse_out(self, scene, target):
+ if isinstance(target, VerticalBar):
+ bar = target
+ bar.fill = self.bar_color
+
+ def on_click(self, scene, event, target):
+ if not target: return
+
+ if target == self.zoom_out_icon:
+ self.emit("zoom-out-clicked")
+ elif isinstance(target, VerticalBar):
+ self.emit("range-picked", target.key.date(), (target.key + self.minor_tick - dt.timedelta(days=1)).date())
+ else:
+ self.emit("range-picked", target.parent.key.date(), (target.parent.key + dt.timedelta(days=6)).date())
+
+
+ def draw(self, durations, start_date, end_date):
+ self.durations = durations
+
+ if start_date > end_date:
+ start_date, end_date = end_date, start_date
+
+ # adjust to day starts and ends if we show
+ if end_date - start_date < dt.timedelta(days=1):
+ start_time = dt.datetime.combine(start_date, self.day_start.replace(minute=0))
+ end_time = dt.datetime.combine(end_date, self.day_start.replace(minute=0)) + dt.timedelta(days = 1)
+
+ durations_start_time, durations_end_time = start_time, end_time
+ if durations:
+ durations_start_time = durations[0][0]
+ durations_end_time = durations[-1][0] + durations[-1][1]
+
+ self.start_time = min([start_time, durations_start_time])
+ self.end_time = max([end_time, durations_end_time])
+
+ else:
+ start_time = dt.datetime.combine(start_date, dt.time())
+ end_time = dt.datetime.combine(end_date, dt.time(23, 59))
+
+ durations_start_time, durations_end_time = start_time, end_time
+ if durations:
+ durations_start_time = dt.datetime.combine(durations[0][0], dt.time())
+ durations_end_time = dt.datetime.combine(durations[-1][0], dt.time())
+
+ if isinstance(durations[0][0], dt.datetime):
+ durations_start_time = durations_start_time - dt.timedelta(1 if durations[0][0].time() < self.day_start else 0)
+ durations_end_time = durations_end_time - dt.timedelta(1 if durations[-1][0].time() < self.day_start else 0)
+
+ self.start_time = min([start_time, durations_start_time])
+ self.end_time = max([end_time, durations_end_time])
+
+ days = (end_date - start_date).days
+
+
+ # determine fraction and do addittional start time move
+ if days > 125: # about 4 month -> show per month
+ self.minor_tick = dt.timedelta(days = 30) #this is approximate and will be replaced by exact days in month
+ # make sure we start on first day of month
+ self.start_time = self.start_time - dt.timedelta(self.start_time.day - 1)
+
+ elif days > 40: # bit more than month -> show per week
+ self.minor_tick = WEEK
+ # make sure we start week on first day
+ #set to monday
+ start_time = self.start_time - dt.timedelta(self.start_time.weekday() + 1)
+ # look if we need to start on sunday or monday
+ start_time = start_time + dt.timedelta(self.first_weekday)
+ if self.start_time - start_time == WEEK:
+ start_time += WEEK
+ self.start_time = start_time
+ elif days >= 1: # more than two days -> show per day
+ self.minor_tick = DAY
+ else: # show per hour
+ self.minor_tick = dt.timedelta(seconds = 60 * 60)
+
+ self.count_hours()
+
+
+ self.zoom_out_icon.visible = (self.end_time - self.start_time) < dt.timedelta(days=356)
+
+ self.redraw()
+
+ def on_enter_frame(self, scene, context):
+ if not self.start_time or not self.end_time:
+ return
+
+ g = graphics.Graphics(context)
+
+
+ # figure out colors
+ bg_color = self.get_style().bg[gtk.STATE_NORMAL].to_string()
+ self.bar_color = g.colors.contrast(bg_color, 30)
+ self.tick_color = g.colors.contrast(bg_color, 50)
+
+
+
+ # now for the text - we want reduced contrast for relaxed visuals
+ fg_color = self.get_style().fg[gtk.STATE_NORMAL].to_string()
+ label_color = g.colors.contrast(fg_color, 70)
+
+
+ g.set_line_style(width=1)
+
+ # major ticks
+ if self.end_time - self.start_time < dt.timedelta(days=1): # about the same day
+ major_step = dt.timedelta(seconds = 60 * 60)
+ else:
+ major_step = dt.timedelta(days=1)
+
+
+ def first_weekday(date):
+ return (date.weekday() + 1 - self.first_weekday) % 7 == 0
+
+ # count ticks so we can correctly calculate the average bar width
+ ticks = []
+ for i, (current_time, total) in enumerate(self.tick_totals):
+ # move the x bit further when ticks kick in
+ if (major_step < DAY and current_time.time() == dt.time(0,0)) \
+ or (self.minor_tick == DAY and first_weekday(current_time)) \
+ or (self.minor_tick <= WEEK and current_time.day == 1) \
+ or (current_time.timetuple().tm_yday == 1):
+ ticks.append(current_time)
+
+
+ # calculate position of each bar
+ # essentially we care more about the exact 1px gap between bars than about the bar width
+ # so after each iteration, we adjust the bar width
+ exes = {}
+
+ x = 0
+ bar_width = round((float(self.width) - len(ticks) * 2) / len(self.tick_totals))
+ remaining_ticks = len(ticks)
+
+
+ self.text_color = self.get_style().text[gtk.STATE_NORMAL].to_string()
+
+ for i, bar in enumerate(self.bars):
+ if bar.key in ticks:
+ x += 2
+ remaining_ticks -= 1
+
+ bar.x = x
+ bar.width = bar_width - 1
+ bar.height = self.height
+ bar.key_label.color = self.text_color
+
+ if not bar.fill:
+ bar.fill = self.bar_color
+
+ bar.key_label.interactive = self.interactive and (self.end_time - self.start_time) > dt.timedelta(10) and self.minor_tick == DAY
+
+ if (self.end_time - self.start_time) > dt.timedelta(10) \
+ and self.minor_tick == DAY and first_weekday(bar.key) == False:
+ bar.show_label = False
+ else:
+ bar.show_label = True
+
+
+
+ exes[bar.key] = (x, int(bar_width)) #saving those as getting pixel precision is not an exact science
+
+ x = int(x + bar_width)
+ bar_width = round((self.width - x - remaining_ticks * 2) / float(max(len(self.tick_totals) - i - 1, 1)))
+
+
+
+ def line(x, color):
+ g.move_to(round(x) + 0.5, 0)
+ g.line_to(round(x) + 0.5, self.height)
+ g.stroke(color)
+
+ def somewhere_in_middle(time, color):
+ # draws line somewhere in middle of the minor tick
+ left_index = exes.keys()[bisect(exes.keys(), time) - 1]
+ #should yield something between 0 and 1
+ adjustment = stuff.duration_minutes(time - left_index) / float(stuff.duration_minutes(self.minor_tick))
+ x, width = exes[left_index]
+ line(x + round(width * adjustment) - 1, color)
+
+
+ # mark tick lines
+ current_time = self.start_time + major_step
+ while current_time < self.end_time:
+ if current_time in ticks:
+ line(exes[current_time][0] - 2, self.tick_color)
+ else:
+ if self.minor_tick <= WEEK and current_time.day == 1: # month change
+ somewhere_in_middle(current_time, self.tick_color)
+ # year change
+ elif current_time.timetuple().tm_yday == 1: # year change
+ somewhere_in_middle(current_time, self.tick_color)
+
+ current_time += major_step
+
+
+ self.zoom_out_icon.x = self.width - 24
+
+
+ def count_hours(self):
+ # go through facts and make array of time used by our fraction
+ fractions = []
+
+ current_time = self.start_time
+
+ minor_tick = self.minor_tick
+ while current_time <= self.end_time:
+ # if minor tick is month, the starting date will have been
+ # already adjusted to the first
+ # now we have to make sure to move month by month
+ if self.minor_tick >= dt.timedelta(days=28):
+ minor_tick = dt.timedelta(calendar.monthrange(current_time.year, current_time.month)[1]) # days in month
+
+ fractions.append(current_time)
+ current_time += minor_tick
+
+ hours = [0] * len(fractions)
+
+ tick_minutes = float(stuff.duration_minutes(self.minor_tick))
+
+ for start_time, duration in self.durations:
+ if isinstance(duration, dt.timedelta):
+ if self.minor_tick < dt.timedelta(1):
+ end_time = start_time + duration
+
+ # find in which fraction the fact starts and
+ # add duration up to the border of tick to that fraction
+ # then move cursor to the start of next fraction
+ first_index = bisect(fractions, start_time) - 1
+ step_time = fractions[first_index]
+ first_end = min(end_time, step_time + self.minor_tick)
+ first_tick = stuff.duration_minutes(first_end - start_time) / tick_minutes
+
+ hours[first_index] += first_tick
+ step_time = step_time + self.minor_tick
+
+ # now go through ticks until we reach end of the time
+ while step_time < end_time:
+ index = bisect(fractions, step_time) - 1
+ interval = min([1, stuff.duration_minutes(end_time - step_time) / tick_minutes])
+ hours[index] += interval
+
+ step_time += self.minor_tick
+ else:
+
+ duration_date = start_time.date() - dt.timedelta(1 if start_time.time() < self.day_start else 0)
+ hour_index = bisect(fractions, dt.datetime.combine(duration_date, dt.time())) - 1
+ hours[hour_index] += stuff.duration_minutes(duration)
+ else:
+ if isinstance(start_time, dt.datetime):
+ duration_date = start_time.date() - dt.timedelta(1 if start_time.time() < self.day_start else 0)
+ else:
+ duration_date = start_time
+
+ hour_index = bisect(fractions, dt.datetime.combine(duration_date, dt.time())) - 1
+ hours[hour_index] += duration
+
+
+ # now normalize
+ max_hour = max(hours)
+ normalized_hours = [hour / float(max_hour or 1) for hour in hours]
+
+ self.tick_totals = zip(fractions, normalized_hours)
+
+
+
+
+ # tick label format
+ if self.minor_tick >= dt.timedelta(days = 28): # month
+ step_format = "%b"
+
+ elif self.minor_tick == WEEK: # week
+ step_format = "%b %d"
+ elif self.minor_tick == DAY: # day
+ if (self.end_time - self.start_time) > dt.timedelta(10):
+ step_format = "%b %d"
+ else:
+ step_format = "%a"
+ else:
+ step_format = "%H<small><sup>%M</sup></small>"
+
+
+ for bar in self.bars: # remove any previous bars
+ self.sprites.remove(bar)
+
+ self.bars = []
+ for i, (key, value, normalized) in enumerate(zip(fractions, hours, normalized_hours)):
+ bar = VerticalBar(key, step_format, value, normalized)
+ bar.z_order = len(fractions) - i
+ bar.interactive = self.interactive and self.minor_tick >= DAY and bar.value > 0
+
+ self.add_child(bar)
+ self.bars.append(bar)
diff --git a/win32/hamster/widgets/timeinput.py b/win32/hamster/widgets/timeinput.py
new file mode 100644
index 0000000..c931205
--- /dev/null
+++ b/win32/hamster/widgets/timeinput.py
@@ -0,0 +1,267 @@
+# - coding: utf-8 -
+
+# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
+
+# This file is part 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 Project Hamster. If not, see <http://www.gnu.org/licenses/>.
+
+from ..lib.stuff import format_duration
+import gtk
+from gtk import keysyms
+import datetime as dt
+import calendar
+import gobject
+import re
+
+class TimeInput(gtk.Entry):
+ __gsignals__ = {
+ 'time-entered': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
+ }
+
+
+ def __init__(self, time = None, start_time = None):
+ gtk.Entry.__init__(self)
+ self.news = False
+ self.set_width_chars(7) #7 is like 11:24pm
+
+ self.set_time(time)
+ self.set_start_time(start_time)
+
+ self.popup = gtk.Window(type = gtk.WINDOW_POPUP)
+ time_box = gtk.ScrolledWindow()
+ time_box.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
+ time_box.set_shadow_type(gtk.SHADOW_IN)
+
+ self.time_tree = gtk.TreeView()
+ self.time_tree.set_headers_visible(False)
+ self.time_tree.set_hover_selection(True)
+
+ self.time_tree.append_column(gtk.TreeViewColumn("Time",
+ gtk.CellRendererText(),
+ text=0))
+ self.time_tree.connect("button-press-event",
+ self._on_time_tree_button_press_event)
+
+ time_box.add(self.time_tree)
+ self.popup.add(time_box)
+
+ self.connect("button-press-event", self._on_button_press_event)
+ self.connect("key-press-event", self._on_key_press_event)
+ self.connect("focus-in-event", self._on_focus_in_event)
+ self.connect("focus-out-event", self._on_focus_out_event)
+ self._parent_click_watcher = None # bit lame but works
+
+ self.connect("changed", self._on_text_changed)
+ self.show()
+
+
+ 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)
+
+ self.set_text(self._format_time(time))
+
+ 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
+ """
+ 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)
+ else:
+ self.start_time = dt.time(start_time.time().hour, start_time.time().minute)
+
+ def _on_text_changed(self, widget):
+ self.news = True
+
+ def figure_time(self, str_time):
+ if not str_time:
+ return self.time
+
+ # strip everything non-numeric and consider hours to be first number
+ # and minutes - second number
+ numbers = re.split("\D", str_time)
+ numbers = filter(lambda x: x!="", numbers)
+
+ hours, minutes = None, None
+
+ if len(numbers) == 1 and len(numbers[0]) == 4:
+ hours, minutes = int(numbers[0][:2]), int(numbers[0][2:])
+ else:
+ if len(numbers) >= 1:
+ hours = int(numbers[0])
+ if len(numbers) >= 2:
+ 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 dt.time(hours, minutes)
+
+
+ def _select_time(self, time_text):
+ #convert forth and back so we have text formated as we want
+ time = self.figure_time(time_text)
+ time_text = self._format_time(time)
+
+ self.set_text(time_text)
+ self.set_position(len(time_text))
+ self.hide_popup()
+ if self.news:
+ 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 ""
+ return time.strftime("%H:%M").lower()
+
+
+ def _on_focus_in_event(self, entry, event):
+ self.show_popup()
+
+ def _on_button_press_event(self, button, event):
+ self.show_popup()
+
+ def _on_focus_out_event(self, event, something):
+ self.hide_popup()
+ if self.news:
+ self.emit("time-entered")
+ self.news = False
+
+ def hide_popup(self):
+ if self._parent_click_watcher and self.get_toplevel().handler_is_connected(self._parent_click_watcher):
+ self.get_toplevel().disconnect(self._parent_click_watcher)
+ self._parent_click_watcher = None
+ self.popup.hide()
+
+ 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
+
+ if self.start_time:
+ end_time = i_time + dt.timedelta(hours = 12)
+ i_time += dt.timedelta(minutes = 15)
+ 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)
+
+
+ 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)
+
+ row_text += " (%s)" % delta_text
+
+ hours.append([row_text])
+
+
+ if focus_time and i_time <= focus_time <= i_time + \
+ dt.timedelta(minutes = 30):
+ focus_row = i
+
+ if self.start_time:
+ i_time += dt.timedelta(minutes = 15)
+ else:
+ i_time += dt.timedelta(minutes = 30)
+
+ i += 1
+
+ self.time_tree.set_model(hours)
+
+ #focus on row
+ if focus_row != None:
+ selection = self.time_tree.get_selection()
+ 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()
+ x, y= window.get_origin()
+
+ self.popup.move(x + alloc.x,y + alloc.y + alloc.height)
+ self.popup.show_all()
+
+
+ def _on_time_tree_button_press_event(self, tree, event):
+ model, iter = tree.get_selection().get_selected()
+ time = model.get_value(iter, 0)
+ self._select_time(time)
+
+
+ def _on_key_press_event(self, entry, event):
+ if event.keyval not in (keysyms.Up, keysyms.Down, keysyms.Return, keysyms.KP_Enter):
+ #any kind of other input
+ self.hide_popup()
+ return False
+
+ model, iter = self.time_tree.get_selection().get_selected()
+ if not iter:
+ return
+
+
+ i = model.get_path(iter)[0]
+ if event.keyval == gtk.keysyms.Up:
+ i-=1
+ elif event.keyval == gtk.keysyms.Down:
+ i+=1
+ elif (event.keyval == gtk.keysyms.Return or
+ event.keyval == gtk.keysyms.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.keysyms.Escape):
+ self.hide_popup()
+ return
+
+ # keep it in sane limits
+ i = min(max(i, 0), len(self.time_tree.get_model()) - 1)
+
+ self.time_tree.set_cursor(i)
+ self.time_tree.scroll_to_cell(i, use_align = True, row_align = 0.4)
+
+ # if popup is not visible, display it on up and down
+ if event.keyval in (gtk.keysyms.Up, gtk.keysyms.Down) and self.popup.props.visible == False:
+ self.show_popup()
+
+ return True
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]