Minor fixes found while working on packaging
[doneit] / src / gtk_toolbox.py
1 #!/usr/bin/python
2
3 """
4 @todo Implement a custom label that encodes random info (Not set in stone, just throwing these out there)
5         Background color based on Location
6         Text intensity based on time estimate
7         Short/long version with long including tags colored specially
8 """
9
10 import sys
11 import traceback
12 import datetime
13 import contextlib
14 import warnings
15
16 import gobject
17 import gtk
18
19 import toolbox
20 import coroutines
21
22
23 @contextlib.contextmanager
24 def gtk_lock():
25         gtk.gdk.threads_enter()
26         try:
27                 yield
28         finally:
29                 gtk.gdk.threads_leave()
30
31
32 class ContextHandler(object):
33
34         HOLD_TIMEOUT = 1000
35         MOVE_THRESHHOLD = 10
36
37         def __init__(self, actionWidget, eventTarget = coroutines.null_sink()):
38                 self._actionWidget = actionWidget
39                 self._eventTarget = eventTarget
40
41                 self._actionPressId = None
42                 self._actionReleaseId = None
43                 self._motionNotifyId = None
44                 self._popupMenuId = None
45                 self._holdTimerId = None
46
47                 self._respondOnRelease = False
48                 self._startPosition = None
49
50         def enable(self):
51                 self._actionPressId = self._actionWidget.connect("button-press-event", self._on_press)
52                 self._actionReleaseId = self._actionWidget.connect("button-release-event", self._on_release)
53                 self._motionNotifyId = self._actionWidget.connect("motion-notify-event", self._on_motion)
54                 self._popupMenuId = self._actionWidget.connect("popup-menu", self._on_popup)
55
56         def disable(self):
57                 self._clear()
58                 self._actionWidget.disconnect(self._actionPressId)
59                 self._actionWidget.disconnect(self._actionReleaseId)
60                 self._actionWidget.disconnect(self._motionNotifyId)
61                 self._actionWidget.disconnect(self._popupMenuId)
62
63         def _respond(self, position):
64                 widgetPosition = 0, 0 # @todo Figure out how to get a widgets root position
65                 responsePosition = (
66                         widgetPosition[0] + position[0],
67                         widgetPosition[1] + position[1],
68                 )
69                 self._eventTarget.send((self._actionWidget, responsePosition))
70
71         def _clear(self):
72                 if self._holdTimerId is not None:
73                         gobject.source_remove(self._holdTimerId)
74                 self._respondOnRelease = False
75                 self._startPosition = None
76
77         def _is_cleared(self):
78                 return self._startPosition is None
79
80         def _on_press(self, widget, event):
81                 if not self._is_cleared():
82                         return
83
84                 self._startPosition = event.get_coords()
85                 if event.button == 1:
86                         # left click
87                         self._holdTimerId = gobject.timeout_add(self.HOLD_TIMEOUT, self._on_hold_timeout)
88                 else:
89                         # any other click
90                         self._respondOnRelease = True
91
92         def _on_release(self, widget, event):
93                 if self._is_cleared():
94                         return
95
96                 if self._respondOnRelease:
97                         position = self._startPosition
98                         self._clear()
99                         self._respond(position)
100                 else:
101                         self._clear()
102
103         def _on_hold_timeout(self):
104                 assert not self._is_cleared()
105                 gobject.source_remove(self._holdTimerId)
106                 self._holdTimerId = None
107
108                 position = self._startPosition
109                 self._clear()
110                 self._respond(position)
111
112         def _on_motion(self, widget, event):
113                 if self._is_cleared():
114                         return
115                 curPosition = event.get_coords()
116                 dx, dy = (
117                         curPosition[1] - self._startPosition[1],
118                         curPosition[1] - self._startPosition[1],
119                 )
120                 delta = (dx ** 2 + dy ** 2) ** (0.5)
121                 if self.MOVE_THRESHHOLD <= delta:
122                         self._clear()
123
124         def _on_popup(self, widget):
125                 self._clear()
126                 position = 0, 0
127                 self._respond(position)
128
129
130 class LoginWindow(object):
131
132         def __init__(self, widgetTree):
133                 """
134                 @note Thread agnostic
135                 """
136                 self._dialog = widgetTree.get_widget("loginDialog")
137                 self._parentWindow = widgetTree.get_widget("mainWindow")
138                 self._serviceCombo = widgetTree.get_widget("serviceCombo")
139                 self._usernameEntry = widgetTree.get_widget("usernameentry")
140                 self._passwordEntry = widgetTree.get_widget("passwordentry")
141
142                 self._serviceList = gtk.ListStore(gobject.TYPE_INT, gobject.TYPE_STRING)
143                 self._serviceCombo.set_model(self._serviceList)
144                 cell = gtk.CellRendererText()
145                 self._serviceCombo.pack_start(cell, True)
146                 self._serviceCombo.add_attribute(cell, 'text', 1)
147                 self._serviceCombo.set_active(0)
148
149                 callbackMapping = {
150                         "on_loginbutton_clicked": self._on_loginbutton_clicked,
151                         "on_loginclose_clicked": self._on_loginclose_clicked,
152                 }
153                 widgetTree.signal_autoconnect(callbackMapping)
154
155         def request_credentials(self, parentWindow = None):
156                 """
157                 @note UI Thread
158                 """
159                 if parentWindow is None:
160                         parentWindow = self._parentWindow
161
162                 self._serviceCombo.hide()
163                 self._serviceList.clear()
164
165                 try:
166                         self._dialog.set_transient_for(parentWindow)
167                         self._dialog.set_default_response(gtk.RESPONSE_OK)
168                         response = self._dialog.run()
169                         if response != gtk.RESPONSE_OK:
170                                 raise RuntimeError("Login Cancelled")
171
172                         username = self._usernameEntry.get_text()
173                         password = self._passwordEntry.get_text()
174                         self._passwordEntry.set_text("")
175                 finally:
176                         self._dialog.hide()
177
178                 return username, password
179
180         def request_credentials_from(self, services, parentWindow = None):
181                 """
182                 @note UI Thread
183                 """
184                 if parentWindow is None:
185                         parentWindow = self._parentWindow
186
187                 self._serviceList.clear()
188                 for serviceIdserviceName in services.iteritems():
189                         self._serviceList.append(serviceIdserviceName)
190                 self._serviceCombo.set_active(0)
191                 self._serviceCombo.show()
192
193                 try:
194                         self._dialog.set_transient_for(parentWindow)
195                         self._dialog.set_default_response(gtk.RESPONSE_OK)
196                         response = self._dialog.run()
197                         if response != gtk.RESPONSE_OK:
198                                 raise RuntimeError("Login Cancelled")
199
200                         username = self._usernameEntry.get_text()
201                         password = self._passwordEntry.get_text()
202                         self._passwordEntry.set_text("")
203                 finally:
204                         self._dialog.hide()
205
206                 itr = self._serviceCombo.get_active_iter()
207                 serviceId = int(self._serviceList.get_value(itr, 0))
208                 self._serviceList.clear()
209                 return serviceId, username, password
210
211         def _on_loginbutton_clicked(self, *args):
212                 self._dialog.response(gtk.RESPONSE_OK)
213
214         def _on_loginclose_clicked(self, *args):
215                 self._dialog.response(gtk.RESPONSE_CANCEL)
216
217
218 class ErrorDisplay(object):
219
220         def __init__(self, widgetTree):
221                 super(ErrorDisplay, self).__init__()
222                 self.__errorBox = widgetTree.get_widget("errorEventBox")
223                 self.__errorDescription = widgetTree.get_widget("errorDescription")
224                 self.__errorClose = widgetTree.get_widget("errorClose")
225                 self.__parentBox = self.__errorBox.get_parent()
226
227                 self.__errorBox.connect("button_release_event", self._on_close)
228
229                 self.__messages = []
230                 self.__parentBox.remove(self.__errorBox)
231
232         def push_message_with_lock(self, message):
233                 gtk.gdk.threads_enter()
234                 try:
235                         self.push_message(message)
236                 finally:
237                         gtk.gdk.threads_leave()
238
239         def push_message(self, message):
240                 if 0 < len(self.__messages):
241                         self.__messages.append(message)
242                 else:
243                         self.__show_message(message)
244
245         def push_exception(self, exception = None):
246                 if exception is None:
247                         userMessage = sys.exc_value.message
248                         warningMessage = traceback.format_exc()
249                 else:
250                         userMessage = exception.message
251                         warningMessage = exception
252                 self.push_message(userMessage)
253                 warnings.warn(warningMessage, stacklevel=3)
254
255         def pop_message(self):
256                 if 0 < len(self.__messages):
257                         self.__show_message(self.__messages[0])
258                         del self.__messages[0]
259                 else:
260                         self.__hide_message()
261
262         def _on_close(self, *args):
263                 self.pop_message()
264
265         def __show_message(self, message):
266                 self.__errorDescription.set_text(message)
267                 self.__parentBox.pack_start(self.__errorBox, False, False)
268                 self.__parentBox.reorder_child(self.__errorBox, 1)
269
270         def __hide_message(self):
271                 self.__errorDescription.set_text("")
272                 self.__parentBox.remove(self.__errorBox)
273
274
275 class MessageBox(gtk.MessageDialog):
276
277         def __init__(self, message):
278                 parent = None
279                 gtk.MessageDialog.__init__(
280                         self,
281                         parent,
282                         gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT,
283                         gtk.MESSAGE_ERROR,
284                         gtk.BUTTONS_OK,
285                         message,
286                 )
287                 self.set_default_response(gtk.RESPONSE_OK)
288                 self.connect('response', self._handle_clicked)
289
290         def _handle_clicked(self, *args):
291                 self.destroy()
292
293
294 class MessageBox2(gtk.MessageDialog):
295
296         def __init__(self, message):
297                 parent = None
298                 gtk.MessageDialog.__init__(
299                         self,
300                         parent,
301                         gtk.DIALOG_DESTROY_WITH_PARENT,
302                         gtk.MESSAGE_ERROR,
303                         gtk.BUTTONS_OK,
304                         message,
305                 )
306                 self.set_default_response(gtk.RESPONSE_OK)
307                 self.connect('response', self._handle_clicked)
308
309         def _handle_clicked(self, *args):
310                 self.destroy()
311
312
313 class PopupCalendar(object):
314
315         def __init__(self, parent, displayDate):
316                 self._displayDate = displayDate
317
318                 self._calendar = gtk.Calendar()
319                 self._calendar.select_month(self._displayDate.month, self._displayDate.year)
320                 self._calendar.select_day(self._displayDate.day)
321                 self._calendar.set_display_options(
322                         gtk.CALENDAR_SHOW_HEADING |
323                         gtk.CALENDAR_SHOW_DAY_NAMES |
324                         gtk.CALENDAR_NO_MONTH_CHANGE |
325                         0
326                 )
327                 self._calendar.connect("day-selected", self._on_day_selected)
328
329                 self._popupWindow = gtk.Window()
330                 self._popupWindow.set_title("")
331                 self._popupWindow.add(self._calendar)
332                 self._popupWindow.set_transient_for(parent)
333                 self._popupWindow.set_modal(True)
334
335         def run(self):
336                 self._popupWindow.show_all()
337
338         def _on_day_selected(self, *args):
339                 try:
340                         self._calendar.select_month(self._displayDate.month, self._displayDate.year)
341                         self._calendar.select_day(self._displayDate.day)
342                 except StandardError, e:
343                         warnings.warn(e.message)
344
345
346 class NotesDialog(object):
347
348         def __init__(self, widgetTree):
349                 self._dialog = widgetTree.get_widget("notesDialog")
350                 self._notesBox = widgetTree.get_widget("notes-notesBox")
351                 self._addButton = widgetTree.get_widget("notes-addButton")
352                 self._saveButton = widgetTree.get_widget("notes-saveButton")
353                 self._cancelButton = widgetTree.get_widget("notes-cancelButton")
354                 self._onAddId = None
355                 self._onSaveId = None
356                 self._onCancelId = None
357
358                 self._notes = []
359                 self._notesToDelete = []
360
361         def enable(self):
362                 self._dialog.set_default_size(800, 300)
363                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
364                 self._onSaveId = self._saveButton.connect("clicked", self._on_save_clicked)
365                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
366
367         def disable(self):
368                 self._addButton.disconnect(self._onAddId)
369                 self._saveButton.disconnect(self._onSaveId)
370                 self._cancelButton.disconnect(self._onAddId)
371
372         def run(self, todoManager, taskId, parentWindow = None):
373                 if parentWindow is not None:
374                         self._dialog.set_transient_for(parentWindow)
375
376                 taskDetails = todoManager.get_task_details(taskId)
377
378                 self._dialog.set_default_response(gtk.RESPONSE_OK)
379                 for note in taskDetails["notes"]:
380                         noteBox, titleEntry, noteDeleteButton, noteEntry = self._append_notebox(note)
381                         noteDeleteButton.connect("clicked", self._on_delete_existing, note["id"], noteBox)
382
383                 try:
384                         response = self._dialog.run()
385                         if response != gtk.RESPONSE_OK:
386                                 raise RuntimeError("Edit Cancelled")
387                 finally:
388                         self._dialog.hide()
389
390                 for note in self._notes:
391                         noteId = note[0]
392                         noteTitle = note[2].get_text()
393                         noteBody = note[4].get_buffer().get_text()
394                         if noteId is None:
395                                 print "New note:", note
396                                 todoManager.add_note(taskId, noteTitle, noteBody)
397                         else:
398                                 # @todo Provide way to only update on change
399                                 print "Updating note:", note
400                                 todoManager.update_note(noteId, noteTitle, noteBody)
401
402                 for deletedNoteId in self._notesToDelete:
403                         print "Deleted note:", deletedNoteId
404                         todoManager.delete_note(noteId)
405
406         def _append_notebox(self, noteDetails = None):
407                 if noteDetails is None:
408                         noteDetails = {"id": None, "title": "", "body": ""}
409
410                 noteBox = gtk.VBox()
411
412                 titleBox = gtk.HBox()
413                 titleEntry = gtk.Entry()
414                 titleEntry.set_text(noteDetails["title"])
415                 titleBox.pack_start(titleEntry, True, True)
416                 noteDeleteButton = gtk.Button(stock=gtk.STOCK_DELETE)
417                 titleBox.pack_end(noteDeleteButton, False, False)
418                 noteBox.pack_start(titleBox, False, True)
419
420                 noteEntryScroll = gtk.ScrolledWindow()
421                 noteEntryScroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
422                 noteEntry = gtk.TextView()
423                 noteEntry.set_editable(True)
424                 noteEntry.set_wrap_mode(gtk.WRAP_WORD)
425                 noteEntry.get_buffer().set_text(noteDetails["body"])
426                 noteEntry.set_size_request(-1, 150)
427                 noteEntryScroll.add(noteEntry)
428                 noteBox.pack_start(noteEntryScroll, True, True)
429
430                 self._notesBox.pack_start(noteBox, True, True)
431                 noteBox.show_all()
432
433                 note = noteDetails["id"], noteBox, titleEntry, noteDeleteButton, noteEntry
434                 self._notes.append(note)
435                 return note[1:]
436
437         def _on_add_clicked(self, *args):
438                 noteBox, titleEntry, noteDeleteButton, noteEntry = self._append_notebox()
439                 noteDeleteButton.connect("clicked", self._on_delete_new, noteBox)
440
441         def _on_save_clicked(self, *args):
442                 self._dialog.response(gtk.RESPONSE_OK)
443
444         def _on_cancel_clicked(self, *args):
445                 self._dialog.response(gtk.RESPONSE_CANCEL)
446
447         def _on_delete_new(self, widget, noteBox):
448                 self._notesBox.remove(noteBox)
449                 self._notes = [note for note in self._notes if note[1] is not noteBox]
450
451         def _on_delete_existing(self, widget, noteId, noteBox):
452                 self._notesBox.remove(noteBox)
453                 self._notes = [note for note in self._notes if note[1] is not noteBox]
454                 self._notesToDelete.append(noteId)
455
456
457 class EditTaskDialog(object):
458         """
459         @bug The dialog doens't fit well on the maemo screen
460         """
461
462         def __init__(self, widgetTree):
463                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
464
465                 self._dialog = widgetTree.get_widget("editTaskDialog")
466                 self._projectCombo = widgetTree.get_widget("edit-targetProjectCombo")
467                 self._taskName = widgetTree.get_widget("edit-taskNameEntry")
468                 self._pasteTaskNameButton = widgetTree.get_widget("edit-pasteTaskNameButton")
469                 self._priorityChoiceCombo = widgetTree.get_widget("edit-priorityChoiceCombo")
470                 self._dueDateDisplay = widgetTree.get_widget("edit-dueDateCalendar")
471                 self._clearDueDate = widgetTree.get_widget("edit-clearDueDate")
472
473                 self._addButton = widgetTree.get_widget("edit-commitEditTaskButton")
474                 self._cancelButton = widgetTree.get_widget("edit-cancelEditTaskButton")
475
476                 self._onPasteTaskId = None
477                 self._onClearDueDateId = None
478                 self._onAddId = None
479                 self._onCancelId = None
480
481         def enable(self, todoManager):
482                 self._populate_projects(todoManager)
483
484                 self._onPasteTaskId = self._pasteTaskNameButton.connect("clicked", self._on_name_paste)
485                 self._onClearDueDateId = self._clearDueDate.connect("clicked", self._on_clear_duedate)
486                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
487                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
488
489         def disable(self):
490                 self._pasteTaskNameButton.disconnect(self._onPasteTaskId)
491                 self._clearDueDate.disconnect(self._onClearDueDateId)
492                 self._addButton.disconnect(self._onAddId)
493                 self._cancelButton.disconnect(self._onAddId)
494
495                 self._projectsList.clear()
496                 self._projectCombo.set_model(None)
497
498         def request_task(self, todoManager, taskId, parentWindow = None):
499                 if parentWindow is not None:
500                         self._dialog.set_transient_for(parentWindow)
501
502                 taskDetails = todoManager.get_task_details(taskId)
503                 originalProjectId = taskDetails["projId"]
504                 originalProjectName = todoManager.get_project(originalProjectId)["name"]
505                 originalName = taskDetails["name"]
506                 originalPriority = str(taskDetails["priority"].get_nothrow(0))
507                 if taskDetails["dueDate"].is_good():
508                         originalDue = taskDetails["dueDate"].get()
509                 else:
510                         originalDue = None
511
512                 self._dialog.set_default_response(gtk.RESPONSE_OK)
513                 self._taskName.set_text(originalName)
514                 self._set_active_proj(originalProjectName)
515                 self._priorityChoiceCombo.set_active(int(originalPriority))
516                 if originalDue is not None:
517                         # Months are 0 indexed
518                         self._dueDateDisplay.select_month(originalDue.month - 1, originalDue.year)
519                         self._dueDateDisplay.select_day(originalDue.day)
520                 else:
521                         now = datetime.datetime.now()
522                         self._dueDateDisplay.select_month(now.month, now.year)
523                         self._dueDateDisplay.select_day(0)
524
525                 try:
526                         response = self._dialog.run()
527                         if response != gtk.RESPONSE_OK:
528                                 raise RuntimeError("Edit Cancelled")
529                 finally:
530                         self._dialog.hide()
531
532                 newProjectName = self._get_project(todoManager)
533                 newName = self._taskName.get_text()
534                 newPriority = self._get_priority()
535                 year, month, day = self._dueDateDisplay.get_date()
536                 if day != 0:
537                         # Months are 0 indexed
538                         date = datetime.date(year, month + 1, day)
539                         time = datetime.time()
540                         newDueDate = datetime.datetime.combine(date, time)
541                 else:
542                         newDueDate = None
543
544                 isProjDifferent = newProjectName != originalProjectName
545                 isNameDifferent = newName != originalName
546                 isPriorityDifferent = newPriority != originalPriority
547                 isDueDifferent = newDueDate != originalDue
548
549                 if isProjDifferent:
550                         newProjectId = todoManager.lookup_project(newProjectName)
551                         todoManager.set_project(taskId, newProjectId)
552                         print "PROJ CHANGE"
553                 if isNameDifferent:
554                         todoManager.set_name(taskId, newName)
555                         print "NAME CHANGE"
556                 if isPriorityDifferent:
557                         try:
558                                 priority = toolbox.Optional(int(newPriority))
559                         except ValueError:
560                                 priority = toolbox.Optional()
561                         todoManager.set_priority(taskId, priority)
562                         print "PRIO CHANGE"
563                 if isDueDifferent:
564                         if newDueDate:
565                                 due = toolbox.Optional(newDueDate)
566                         else:
567                                 due = toolbox.Optional()
568
569                         todoManager.set_duedate(taskId, due)
570                         print "DUE CHANGE"
571
572                 return {
573                         "projId": isProjDifferent,
574                         "name": isNameDifferent,
575                         "priority": isPriorityDifferent,
576                         "due": isDueDifferent,
577                 }
578
579         def _populate_projects(self, todoManager):
580                 for projectName in todoManager.get_projects():
581                         row = (projectName["name"], )
582                         self._projectsList.append(row)
583                 self._projectCombo.set_model(self._projectsList)
584                 cell = gtk.CellRendererText()
585                 self._projectCombo.pack_start(cell, True)
586                 self._projectCombo.add_attribute(cell, 'text', 0)
587                 self._projectCombo.set_active(0)
588
589         def _set_active_proj(self, projName):
590                 for i, row in enumerate(self._projectsList):
591                         if row[0] == projName:
592                                 self._projectCombo.set_active(i)
593                                 break
594                 else:
595                         raise ValueError("%s not in list" % projName)
596
597         def _get_project(self, todoManager):
598                 name = self._projectCombo.get_active_text()
599                 return name
600
601         def _get_priority(self):
602                 index = self._priorityChoiceCombo.get_active()
603                 assert index != -1
604                 if index < 1:
605                         return ""
606                 else:
607                         return str(index)
608
609         def _on_name_paste(self, *args):
610                 clipboard = gtk.clipboard_get()
611                 contents = clipboard.wait_for_text()
612                 if contents is not None:
613                         self._taskName.set_text(contents)
614
615         def _on_clear_duedate(self, *args):
616                 self._dueDateDisplay.select_day(0)
617
618         def _on_add_clicked(self, *args):
619                 self._dialog.response(gtk.RESPONSE_OK)
620
621         def _on_cancel_clicked(self, *args):
622                 self._dialog.response(gtk.RESPONSE_CANCEL)
623
624
625 if __name__ == "__main__":
626         if True:
627                 win = gtk.Window()
628                 win.set_title("Tap'N'Hold")
629                 eventBox = gtk.EventBox()
630                 win.add(eventBox)
631
632                 context = ContextHandler(eventBox, coroutines.printer_sink())
633                 context.enable()
634                 win.connect("destroy", lambda w: gtk.main_quit())
635
636                 win.show_all()
637
638         if False:
639                 cal = PopupCalendar(None, datetime.datetime.now())
640                 cal._popupWindow.connect("destroy", lambda w: gtk.main_quit())
641                 cal.run()
642
643         gtk.main()