[hamster-applet/windows] Initial commit of Windows port



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 ("&", "&amp;")
+    text = text.replace("<", "&lt;")
+    text = text.replace(">", "&gt;")
+    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]