~kklimonda/ubuntu/natty/hamster-applet/lp.697667

« back to all changes in this revision

Viewing changes to src/hamster/widgets/facttree.py

  • Committer: Bazaar Package Importer
  • Author(s): Chris Coulson
  • Date: 2010-02-10 02:52:31 UTC
  • mfrom: (1.1.14 upstream)
  • Revision ID: james.westby@ubuntu.com-20100210025231-x0q5h4q7nlvihl09
Tags: 2.29.90-0ubuntu1
* New upstream version
  - workspace tracking - switch activity, when switching desktops
  - chart improvements - theme friendly and less noisier
  - for those without GNOME panel there is now a standalone version, 
    accessible via Applications -> Accessories -> Time Tracker
  - overview window remembers position
  - maintaining cursor on the selected row after edits / refreshes
  - descriptions once again in the main input field, delimited by comma
  - activity suggestion box now sorts items by recency (Patryk Zawadzki)
  - searching
  - simplified save report dialog, thanks to the what you see is what you 
    report revamp
  - overview/stats replaced with activities / totals and stats accessible 
    from totals
  - interactive graphs to drill down in totals
  - miscellaneous performance improvements
  - pixel-perfect graphs
* Updated 01_startup-fix.patch to apply to new source layout
* debian/control:
  - Add build-depend on gnome-doc-utils

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# - coding: utf-8 -
 
2
 
 
3
# Copyright (C) 2008-2009 Toms Bauģis <toms.baugis at gmail.com>
 
4
 
 
5
# This file is part of Project Hamster.
 
6
 
 
7
# Project Hamster is free software: you can redistribute it and/or modify
 
8
# it under the terms of the GNU General Public License as published by
 
9
# the Free Software Foundation, either version 3 of the License, or
 
10
# (at your option) any later version.
 
11
 
 
12
# Project Hamster is distributed in the hope that it will be useful,
 
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
15
# GNU General Public License for more details.
 
16
 
 
17
# You should have received a copy of the GNU General Public License
 
18
# along with Project Hamster.  If not, see <http://www.gnu.org/licenses/>.
 
19
 
 
20
import gtk, gobject
 
21
import datetime as dt
 
22
 
 
23
from .hamster import stuff
 
24
from .hamster.stuff import format_duration, format_activity
 
25
from tags import Tag
 
26
 
 
27
import pango
 
28
 
 
29
def parent_painter(column, cell, model, iter):
 
30
    fact = model.get_value(iter, 0)
 
31
    parent_info = model.get_value(iter, 1)
 
32
 
 
33
    if fact is None:
 
34
        parent_info["first"] = model.get_path(iter) == (0,) # first row
 
35
        cell.set_property('data', parent_info)
 
36
    else:
 
37
        cell.set_property('data', fact)
 
38
 
 
39
def action_painter(column, cell, model, iter):
 
40
    cell.set_property('xalign', 1)
 
41
    cell.set_property('yalign', 0)
 
42
 
 
43
    if model.get_value(iter, 0) is None:
 
44
        cell.set_property("stock_id", "")
 
45
    else:
 
46
        cell.set_property("stock_id", "gtk-edit")
 
47
 
 
48
 
 
49
class FactTree(gtk.TreeView):
 
50
    __gsignals__ = {
 
51
        "edit-clicked": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, )),
 
52
        "double-click": (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, ))
 
53
    }
 
54
 
 
55
    def __init__(self):
 
56
        gtk.TreeView.__init__(self)
 
57
 
 
58
        self.set_headers_visible(False)
 
59
        self.set_show_expanders(False)
 
60
 
 
61
        # fact (None for parent), duration, parent data (if any)
 
62
        self.store_model = gtk.ListStore(gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT)
 
63
        self.set_model(self.store_model)
 
64
 
 
65
 
 
66
        fact_cell = FactCellRenderer()
 
67
        fact_column = gtk.TreeViewColumn("", fact_cell, data=0)
 
68
        fact_column.set_cell_data_func(fact_cell, parent_painter)
 
69
        fact_column.set_expand(True)
 
70
        self.append_column(fact_column)
 
71
 
 
72
        edit_cell = gtk.CellRendererPixbuf()
 
73
        edit_cell.set_property("ypad", 2)
 
74
        edit_cell.set_property("mode", gtk.CELL_RENDERER_MODE_ACTIVATABLE)
 
75
        self.edit_column = gtk.TreeViewColumn("", edit_cell)
 
76
        self.edit_column.set_cell_data_func(edit_cell, action_painter)
 
77
        self.append_column(self.edit_column)
 
78
 
 
79
        self.connect("row-activated", self._on_row_activated)
 
80
        self.connect("button-release-event", self._on_button_release_event)
 
81
        self.connect("key-release-event", self._on_key_released)
 
82
        self.connect("configure-event", lambda *args: self.columns_autosize())
 
83
 
 
84
        self.show()
 
85
 
 
86
        self.longest_activity_category = 0 # we will need this for the cell renderer
 
87
        self.longest_interval = 0 # we will need this for the cell renderer
 
88
        self.longest_duration = 0 # we will need this for the cell renderer
 
89
        self.stored_selection = []
 
90
 
 
91
        self.box = None
 
92
 
 
93
 
 
94
        pixmap = gtk.gdk.Pixmap(None, 10, 10, 24)
 
95
        _test_context = pixmap.cairo_create()
 
96
        self._test_layout = _test_context.create_layout()
 
97
        font = pango.FontDescription(gtk.Style().font_desc.to_string())
 
98
        self._test_layout.set_font_description(font)
 
99
 
 
100
 
 
101
    def fix_row_heights(self):
 
102
        alloc = self.get_allocation()
 
103
        if alloc != self.box:
 
104
            self.box = alloc
 
105
            self.columns_autosize()
 
106
 
 
107
    def clear(self):
 
108
        self.store_model.clear()
 
109
        self.longest_activity_category = 0
 
110
        self.longest_interval = 0
 
111
        self.longest_duration = 0
 
112
 
 
113
    def update_longest_dimensions(self, fact):
 
114
        interval = "%s -" % fact["start_time"].strftime("%H:%M")
 
115
        if fact["end_time"]:
 
116
            interval = "%s %s" % (interval, fact["end_time"].strftime("%H:%M"))
 
117
        self._test_layout.set_markup(interval)
 
118
        w, h = self._test_layout.get_pixel_size()
 
119
        self.longest_interval = max(self.longest_interval, w + 20)
 
120
 
 
121
 
 
122
        self._test_layout.set_markup("%s - <small>%s</small> " % (fact["name"], fact["category"]))
 
123
        w, h = self._test_layout.get_pixel_size()
 
124
        self.longest_activity_category = max(self.longest_activity_category, w + 10)
 
125
 
 
126
        self._test_layout.set_markup("%s" % stuff.format_duration(fact["delta"]))
 
127
        w, h = self._test_layout.get_pixel_size()
 
128
        self.longest_duration = max(self.longest_duration, w)
 
129
 
 
130
 
 
131
    def add_fact(self, fact):
 
132
        self.update_longest_dimensions(fact)
 
133
        self.store_model.append([fact, None])
 
134
 
 
135
 
 
136
    def add_group(self, group_label, group_date, facts):
 
137
        total = sum([stuff.duration_minutes(fact["delta"]) for fact in facts])
 
138
 
 
139
        # adds group of facts with the given label
 
140
        self.store_model.append([None, dict(date = group_date,
 
141
                                            label = group_label,
 
142
                                            duration = total)])
 
143
 
 
144
        for fact in facts:
 
145
            self.add_fact(fact)
 
146
 
 
147
 
 
148
    def get_row(self, path):
 
149
        """checks if the path is valid and if so, returns the model row"""
 
150
        if path is None or path < 0: return None
 
151
 
 
152
        try: # see if path is still valid
 
153
            iter = self.store_model.get_iter(path)
 
154
            return self.store_model[path]
 
155
        except:
 
156
            return None
 
157
 
 
158
    def id_or_label(self, path):
 
159
        """returns id or date, id if it is a fact row or date if it is a group row"""
 
160
        row = self.get_row(path)
 
161
        if not row: return None
 
162
 
 
163
        if row[0]:
 
164
            return row[0]['id']
 
165
        else:
 
166
            return row[1]['label']
 
167
 
 
168
    def detach_model(self):
 
169
        # ooh, somebody is going for refresh!
 
170
        # let's save selection too - maybe it will come handy
 
171
        self.store_selection()
 
172
 
 
173
        # and now do what we were asked to
 
174
        self.set_model()
 
175
 
 
176
 
 
177
    def attach_model(self):
 
178
        # attach model is also where we calculate the bounding box widths
 
179
        self.set_model(self.store_model)
 
180
 
 
181
        if self.stored_selection:
 
182
            self.restore_selection()
 
183
 
 
184
 
 
185
    def store_selection(self):
 
186
        selection = self.get_selection()
 
187
        model, iter = selection.get_selected()
 
188
        self.stored_selection = None
 
189
        if iter:
 
190
            path = model.get_path(iter)[0]
 
191
            prev, cur, next = path - 1, path, path + 1
 
192
            self.stored_selection = ((prev, self.id_or_label(prev)),
 
193
                                     (cur, self.id_or_label(cur)),
 
194
                                     (next, self.id_or_label(next)))
 
195
 
 
196
 
 
197
    def restore_selection(self):
 
198
        """the code is quite hairy, but works with all kinds of deletes
 
199
           and does not select row when it should not.
 
200
           TODO - it might be worth replacing this with something much simpler"""
 
201
        model = self.store_model
 
202
 
 
203
        new_prev_val, new_cur_val, new_next_val = None, None, None
 
204
        prev, cur, next = self.stored_selection
 
205
 
 
206
        if cur:  new_cur_val  = self.id_or_label(cur[0])
 
207
        if prev: new_prev_val = self.id_or_label(prev[0])
 
208
        if next: new_next_val = self.id_or_label(next[0])
 
209
 
 
210
        path = None
 
211
        values = (new_prev_val, new_cur_val, new_next_val)
 
212
        paths = (prev, cur, next)
 
213
 
 
214
        if cur[1] and cur[1] in values: # simple case
 
215
            # look if we can find previous current in the new threesome
 
216
            path = paths[values.index(cur[1])][0]
 
217
        elif prev[1] and prev[1] == new_prev_val and next[1] and next[1] == new_next_val:
 
218
            # on update the ID changes so we find it by matching in between
 
219
            path = cur[0]
 
220
        elif prev[1] and prev[1] == new_prev_val: # all that's left is delete.
 
221
            if new_cur_val:
 
222
                path = cur[0]
 
223
            else:
 
224
                path = prev[0]
 
225
        elif not new_prev_val and not new_next_val and new_cur_val:
 
226
            # the only record in the tree (no next no previous, but there is current)
 
227
            path = cur[0]
 
228
 
 
229
 
 
230
        if path is not None:
 
231
            selection = self.get_selection()
 
232
            selection.select_path(path)
 
233
 
 
234
            self.set_cursor_on_cell(path)
 
235
 
 
236
    def select_fact(self, fact_id):
 
237
        i = 0
 
238
        while self.id_or_label(i) and self.id_or_label(i) != fact_id:
 
239
            i +=1
 
240
 
 
241
        if self.id_or_label(i) == fact_id:
 
242
            selection = self.get_selection()
 
243
            selection.select_path(i)
 
244
 
 
245
    def get_selected_fact(self):
 
246
        selection = self.get_selection()
 
247
        (model, iter) = selection.get_selected()
 
248
        if iter:
 
249
            return model[iter][0] or model[iter][1]["date"]
 
250
        else:
 
251
            return None
 
252
 
 
253
 
 
254
    def _on_button_release_event(self, tree, event):
 
255
        # a hackish solution to make edit icon keyboard accessible
 
256
        pointer = event.window.get_pointer() # x, y, flags
 
257
        path = self.get_path_at_pos(pointer[0], pointer[1]) #column, innerx, innery
 
258
 
 
259
        if path and path[1] == self.edit_column:
 
260
            self.emit("edit-clicked", self.get_selected_fact())
 
261
            return True
 
262
 
 
263
        return False
 
264
 
 
265
    def _on_row_activated(self, tree, path, column):
 
266
        if column == self.edit_column:
 
267
            self.emit_stop_by_name ('row-activated')
 
268
            self.emit("edit-clicked", self.get_selected_fact())
 
269
            return True
 
270
 
 
271
 
 
272
    def _on_key_released(self, tree, event):
 
273
        # capture e keypress and pretend that user click on edit
 
274
        if (event.keyval == gtk.keysyms.e):
 
275
            self.emit("edit-clicked", self.get_selected_fact())
 
276
            return True
 
277
 
 
278
        return False
 
279
 
 
280
 
 
281
 
 
282
class FactCellRenderer(gtk.GenericCellRenderer):
 
283
    """ We need all kinds of wrapping and spanning and the treeview just does
 
284
        not cut it"""
 
285
 
 
286
    __gproperties__ = {
 
287
        "data": (gobject.TYPE_PYOBJECT, "Data", "Data", gobject.PARAM_READWRITE),
 
288
    }
 
289
 
 
290
    def __init__(self):
 
291
        gtk.GenericCellRenderer.__init__(self)
 
292
        self.height = 0
 
293
        self.data = None
 
294
 
 
295
        default_font = gtk.Style().font_desc.to_string()
 
296
        self.label_font = pango.FontDescription(default_font)
 
297
        self.label_font_size = 10
 
298
 
 
299
        self.selected_color = gtk.Style().text[gtk.STATE_SELECTED]
 
300
        self.normal_color = gtk.Style().text[gtk.STATE_NORMAL]
 
301
 
 
302
        self.tag_font = pango.FontDescription(default_font)
 
303
        self.tag_font.set_size(pango.SCALE * 8)
 
304
 
 
305
        self.layout, self.tag_layout = None, None
 
306
 
 
307
        self.col_padding = 10
 
308
        self.row_padding = 4
 
309
 
 
310
 
 
311
        self.labels = {}
 
312
 
 
313
    def do_set_property (self, pspec, value):
 
314
        setattr(self, pspec.name, value)
 
315
 
 
316
    def do_get_property(self, pspec):
 
317
        return getattr (self, pspec.name)
 
318
 
 
319
 
 
320
    def set_text(self, text):
 
321
        # sets text and returns width and height of the layout
 
322
        self.layout.set_text(text)
 
323
        w, h = self.layout.get_pixel_size()
 
324
        return w, h
 
325
 
 
326
    def on_render (self, window, widget, background_area, cell_area, expose_area, flags):
 
327
        if not self.data:
 
328
            return
 
329
 
 
330
        """
 
331
          ASCII Art
 
332
          --------------+--------------------------------------------+-------+---+
 
333
          13:12 - 17:18 | Some activity - category, tag, tag, tag,   | 14:44 | E |
 
334
                        | tag, tag, some description in grey italics |       |   |
 
335
          --------------+--------------------------------------------+-------+---+
 
336
        """
 
337
 
 
338
 
 
339
        context = window.cairo_create()
 
340
        if not self.layout:
 
341
            self.layout = context.create_layout()
 
342
            self.layout.set_font_description(self.label_font)
 
343
 
 
344
            self.tag_layout = context.create_layout()
 
345
            self.tag_layout.set_font_description(self.tag_font)
 
346
 
 
347
 
 
348
        if "id" in self.data:
 
349
            fact, parent = self.data, None
 
350
        else:
 
351
            parent, fact = self.data, None
 
352
 
 
353
 
 
354
 
 
355
 
 
356
        x, y, width, height = cell_area
 
357
        context.translate(x, y)
 
358
 
 
359
        current_fact = widget.get_selected_fact()
 
360
 
 
361
        if parent:
 
362
            text_color = self.normal_color
 
363
            # if we are selected, change font color appropriately
 
364
            if current_fact and isinstance(current_fact, dt.date) \
 
365
               and current_fact == parent["date"]:
 
366
                text_color = self.selected_color
 
367
 
 
368
            self.set_color(context, text_color)
 
369
 
 
370
            self.layout.set_markup("<b>%s</b>" % stuff.escape_pango(parent["label"]))
 
371
            if self.data["first"]:
 
372
                y = 5
 
373
            else:
 
374
                y = 20
 
375
 
 
376
            context.move_to(5, y)
 
377
            context.show_layout(self.layout)
 
378
 
 
379
            self.layout.set_markup("<b>%s</b>" % stuff.format_duration(parent["duration"]))
 
380
            label_w, label_h = self.layout.get_pixel_size()
 
381
 
 
382
            context.move_to(width - label_w, y)
 
383
            context.show_layout(self.layout)
 
384
 
 
385
        else:
 
386
            text_color = self.normal_color
 
387
            selected = False
 
388
            # if we are selected, change font color appropriately
 
389
            if current_fact and isinstance(current_fact, dt.date) == False \
 
390
               and current_fact["id"] == fact["id"]:
 
391
                text_color = self.selected_color
 
392
                selected = True
 
393
 
 
394
            def show_label(label, x, y, w):
 
395
                self.layout.set_markup(label)
 
396
                context.move_to(x, y)
 
397
                if w:
 
398
                    self.layout.set_width(w)
 
399
                context.show_layout(self.layout)
 
400
 
 
401
            self.set_color(context, text_color)
 
402
 
 
403
            labels = self.labels[fact["id"]]
 
404
            show_label(*labels["interval"])
 
405
 
 
406
            # for the right-aligned delta with have reserved space for scrollbar
 
407
            # but about it's existance we find only on expose, so we realign
 
408
            self.layout.set_markup(labels["delta"][0])
 
409
            w, h = self.layout.get_pixel_size()
 
410
            context.move_to(width - w, labels["delta"][2])
 
411
            context.show_layout(self.layout)
 
412
 
 
413
            show_label(*labels["activity"])
 
414
 
 
415
            if fact["category"]:
 
416
                if not selected:
 
417
                    self.set_color(context, widget.get_style().text[gtk.STATE_INSENSITIVE])
 
418
 
 
419
                show_label(*labels["category"])
 
420
 
 
421
 
 
422
            if fact["tags"]:
 
423
                start_x, start_y, cell_end = labels["tags"][1:]
 
424
                cur_x, cur_y = start_x, start_y
 
425
 
 
426
                for i, tag in enumerate(fact["tags"]):
 
427
                    tag_w, tag_h = Tag.tag_size(tag, self.tag_layout)
 
428
 
 
429
                    if i > 0 and cur_x + tag_w >= cell_end:
 
430
                        cur_x = start_x
 
431
                        cur_y += tag_h + 4
 
432
 
 
433
                    Tag(context, self.tag_layout, True, tag, None,
 
434
                        gtk.gdk.Rectangle(cur_x, cur_y, cell_end - cur_x, height - cur_y))
 
435
 
 
436
                    cur_x += tag_w + 4
 
437
 
 
438
 
 
439
            if fact["description"]:
 
440
                self.set_color(context, text_color)
 
441
                show_label(*labels["description"])
 
442
 
 
443
 
 
444
 
 
445
    def set_color(self, context, color):
 
446
        context.set_source_rgba(*self.color_to_cairo_rgba(color))
 
447
 
 
448
    def color_to_cairo_rgba(self, c, a=1):
 
449
        return c.red/65535.0, c.green/65535.0, c.blue/65535.0, a
 
450
 
 
451
 
 
452
    def get_fact_size(self, widget):
 
453
        """determine size and save calculated coordinates"""
 
454
 
 
455
        if not self.data or "id" not in self.data:
 
456
            return None
 
457
        fact = self.data
 
458
        pixmap = gtk.gdk.Pixmap(None, 10, 10, 24)
 
459
        context = pixmap.cairo_create()
 
460
 
 
461
        layout = context.create_layout()
 
462
        layout.set_font_description(self.label_font)
 
463
        x, y, width, height = widget.get_allocation()
 
464
 
 
465
        labels = {}
 
466
 
 
467
        cell_width = width - 45
 
468
 
 
469
        """ start time and end time at beginning of column """
 
470
        interval = fact["start_time"].strftime("%H:%M -")
 
471
        if fact["end_time"]:
 
472
            interval = "%s %s" % (interval, fact["end_time"].strftime("%H:%M"))
 
473
        labels["interval"] = (interval, self.col_padding, 2, -1)
 
474
 
 
475
        """ duration at the end """
 
476
        delta = stuff.format_duration(fact["delta"])
 
477
        layout.set_markup(delta)
 
478
        duration_w, duration_h = layout.get_pixel_size()
 
479
        labels["delta"] = (delta, cell_width - duration_w, 2, -1)
 
480
 
 
481
 
 
482
        """ activity, category, tags, description in middle """
 
483
        # we want our columns look aligned, so we will do fixed offset from
 
484
        # both sides, in letter length
 
485
 
 
486
        cell_start = widget.longest_interval
 
487
        cell_width = cell_width - widget.longest_interval - widget.longest_duration
 
488
 
 
489
 
 
490
        layout.set_markup(stuff.escape_pango(fact["name"]))
 
491
        label_w, label_h = layout.get_pixel_size()
 
492
 
 
493
        labels["activity"] = (stuff.escape_pango(fact["name"]), cell_start, 2, -1)
 
494
        labels["category"] = (" - <small>%s</small>" % stuff.escape_pango(fact["category"]),
 
495
                              cell_start + label_w, 2, -1)
 
496
 
 
497
 
 
498
        tag_cell_start = cell_start + widget.longest_activity_category
 
499
        tag_cell_end = cell_start + cell_width
 
500
 
 
501
        cell_height = label_h + 4
 
502
 
 
503
        cur_x, cur_y = tag_cell_start, 2
 
504
        if fact["tags"]:
 
505
            layout.set_font_description(self.tag_font)
 
506
 
 
507
            for i, tag in enumerate(fact["tags"]):
 
508
                tag_w, tag_h = Tag.tag_size(tag, layout)
 
509
 
 
510
                if i > 0 and cur_x + tag_w >= tag_cell_end:
 
511
                    cur_x = tag_cell_start
 
512
                    cur_y += tag_h + 4
 
513
                cur_x += tag_w + 4
 
514
 
 
515
            cell_height = max(cell_height, cur_y + tag_h + 4)
 
516
 
 
517
            labels["tags"] = (None, tag_cell_start, 2, tag_cell_end)
 
518
 
 
519
            layout.set_font_description(self.label_font)
 
520
 
 
521
 
 
522
        # see if we can fit in single line
 
523
        # if not, put description under activity
 
524
        if fact["description"]:
 
525
            description = "<small>%s</small>" % stuff.escape_pango(fact["description"])
 
526
            layout.set_markup(description)
 
527
            label_w, label_h = layout.get_pixel_size()
 
528
 
 
529
            x, y = cur_x, cur_y
 
530
            width = cell_start + cell_width - x
 
531
 
 
532
            if x + label_w > width:
 
533
                x = cell_start
 
534
                y = cell_height
 
535
                width = cell_width
 
536
 
 
537
            layout.set_width(width * pango.SCALE)
 
538
            label_w, label_h = layout.get_pixel_size()
 
539
 
 
540
            labels["description"] = (description, x, y, width * pango.SCALE)
 
541
 
 
542
            cell_height = y + label_h + 4
 
543
 
 
544
        self.labels[fact["id"]] = labels
 
545
        return (0, 0, 0, cell_height)
 
546
 
 
547
 
 
548
    def on_get_size (self, widget, cell_area):
 
549
        if "id" in self.data: # fact
 
550
            return self.get_fact_size(widget)
 
551
        else:
 
552
            if self.data["first"]:
 
553
                return (0, 0, 0, 25)
 
554
            else:
 
555
                return (0, 0, 0, 40)