Working on debugability and usability with the task list
[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, title = ""):
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(title)
331                 self._popupWindow.add(self._calendar)
332                 self._popupWindow.set_transient_for(parent)
333                 self._popupWindow.set_modal(True)
334                 self._popupWindow.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
335                 self._popupWindow.set_skip_pager_hint(True)
336                 self._popupWindow.set_skip_taskbar_hint(True)
337
338         def run(self):
339                 self._popupWindow.show_all()
340
341         def _on_day_selected(self, *args):
342                 try:
343                         self._calendar.select_month(self._displayDate.month, self._displayDate.year)
344                         self._calendar.select_day(self._displayDate.day)
345                 except StandardError, e:
346                         warnings.warn(e.message)
347
348
349 class NotesDialog(object):
350
351         def __init__(self, widgetTree):
352                 self._dialog = widgetTree.get_widget("notesDialog")
353                 self._notesBox = widgetTree.get_widget("notes-notesBox")
354                 self._addButton = widgetTree.get_widget("notes-addButton")
355                 self._saveButton = widgetTree.get_widget("notes-saveButton")
356                 self._cancelButton = widgetTree.get_widget("notes-cancelButton")
357                 self._onAddId = None
358                 self._onSaveId = None
359                 self._onCancelId = None
360
361                 self._notes = []
362                 self._notesToDelete = []
363
364         def enable(self):
365                 self._dialog.set_default_size(800, 300)
366                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
367                 self._onSaveId = self._saveButton.connect("clicked", self._on_save_clicked)
368                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
369
370         def disable(self):
371                 self._addButton.disconnect(self._onAddId)
372                 self._saveButton.disconnect(self._onSaveId)
373                 self._cancelButton.disconnect(self._onAddId)
374
375         def run(self, todoManager, taskId, parentWindow = None):
376                 if parentWindow is not None:
377                         self._dialog.set_transient_for(parentWindow)
378
379                 taskDetails = todoManager.get_task_details(taskId)
380
381                 self._dialog.set_default_response(gtk.RESPONSE_OK)
382                 for note in taskDetails["notes"]:
383                         noteBox, titleEntry, noteDeleteButton, noteEntry = self._append_notebox(note)
384                         noteDeleteButton.connect("clicked", self._on_delete_existing, note["id"], noteBox)
385
386                 try:
387                         response = self._dialog.run()
388                         if response != gtk.RESPONSE_OK:
389                                 raise RuntimeError("Edit Cancelled")
390                 finally:
391                         self._dialog.hide()
392
393                 for note in self._notes:
394                         noteId = note[0]
395                         noteTitle = note[2].get_text()
396                         noteBody = note[4].get_buffer().get_text()
397                         if noteId is None:
398                                 print "New note:", note
399                                 todoManager.add_note(taskId, noteTitle, noteBody)
400                         else:
401                                 # @todo Provide way to only update on change
402                                 print "Updating note:", note
403                                 todoManager.update_note(noteId, noteTitle, noteBody)
404
405                 for deletedNoteId in self._notesToDelete:
406                         print "Deleted note:", deletedNoteId
407                         todoManager.delete_note(noteId)
408
409         def _append_notebox(self, noteDetails = None):
410                 if noteDetails is None:
411                         noteDetails = {"id": None, "title": "", "body": ""}
412
413                 noteBox = gtk.VBox()
414
415                 titleBox = gtk.HBox()
416                 titleEntry = gtk.Entry()
417                 titleEntry.set_text(noteDetails["title"])
418                 titleBox.pack_start(titleEntry, True, True)
419                 noteDeleteButton = gtk.Button(stock=gtk.STOCK_DELETE)
420                 titleBox.pack_end(noteDeleteButton, False, False)
421                 noteBox.pack_start(titleBox, False, True)
422
423                 noteEntryScroll = gtk.ScrolledWindow()
424                 noteEntryScroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
425                 noteEntry = gtk.TextView()
426                 noteEntry.set_editable(True)
427                 noteEntry.set_wrap_mode(gtk.WRAP_WORD)
428                 noteEntry.get_buffer().set_text(noteDetails["body"])
429                 noteEntry.set_size_request(-1, 150)
430                 noteEntryScroll.add(noteEntry)
431                 noteBox.pack_start(noteEntryScroll, True, True)
432
433                 self._notesBox.pack_start(noteBox, True, True)
434                 noteBox.show_all()
435
436                 note = noteDetails["id"], noteBox, titleEntry, noteDeleteButton, noteEntry
437                 self._notes.append(note)
438                 return note[1:]
439
440         def _on_add_clicked(self, *args):
441                 noteBox, titleEntry, noteDeleteButton, noteEntry = self._append_notebox()
442                 noteDeleteButton.connect("clicked", self._on_delete_new, noteBox)
443
444         def _on_save_clicked(self, *args):
445                 self._dialog.response(gtk.RESPONSE_OK)
446
447         def _on_cancel_clicked(self, *args):
448                 self._dialog.response(gtk.RESPONSE_CANCEL)
449
450         def _on_delete_new(self, widget, noteBox):
451                 self._notesBox.remove(noteBox)
452                 self._notes = [note for note in self._notes if note[1] is not noteBox]
453
454         def _on_delete_existing(self, widget, noteId, noteBox):
455                 self._notesBox.remove(noteBox)
456                 self._notes = [note for note in self._notes if note[1] is not noteBox]
457                 self._notesToDelete.append(noteId)
458
459
460 class EditTaskDialog(object):
461         """
462         @bug The dialog doens't fit well on the maemo screen
463         """
464
465         def __init__(self, widgetTree):
466                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
467
468                 self._dialog = widgetTree.get_widget("editTaskDialog")
469                 self._projectCombo = widgetTree.get_widget("edit-targetProjectCombo")
470                 self._taskName = widgetTree.get_widget("edit-taskNameEntry")
471                 self._pasteTaskNameButton = widgetTree.get_widget("edit-pasteTaskNameButton")
472                 self._priorityChoiceCombo = widgetTree.get_widget("edit-priorityChoiceCombo")
473                 self._dueDateDisplay = widgetTree.get_widget("edit-dueDateCalendar")
474                 self._clearDueDate = widgetTree.get_widget("edit-clearDueDate")
475
476                 self._addButton = widgetTree.get_widget("edit-commitEditTaskButton")
477                 self._cancelButton = widgetTree.get_widget("edit-cancelEditTaskButton")
478
479                 self._onPasteTaskId = None
480                 self._onClearDueDateId = None
481                 self._onAddId = None
482                 self._onCancelId = None
483
484         def enable(self, todoManager):
485                 self._populate_projects(todoManager)
486
487                 self._onPasteTaskId = self._pasteTaskNameButton.connect("clicked", self._on_name_paste)
488                 self._onClearDueDateId = self._clearDueDate.connect("clicked", self._on_clear_duedate)
489                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
490                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
491
492         def disable(self):
493                 self._pasteTaskNameButton.disconnect(self._onPasteTaskId)
494                 self._clearDueDate.disconnect(self._onClearDueDateId)
495                 self._addButton.disconnect(self._onAddId)
496                 self._cancelButton.disconnect(self._onAddId)
497
498                 self._projectsList.clear()
499                 self._projectCombo.set_model(None)
500
501         def request_task(self, todoManager, taskId, parentWindow = None):
502                 if parentWindow is not None:
503                         self._dialog.set_transient_for(parentWindow)
504
505                 taskDetails = todoManager.get_task_details(taskId)
506                 originalProjectId = taskDetails["projId"]
507                 originalProjectName = todoManager.get_project(originalProjectId)["name"]
508                 originalName = taskDetails["name"]
509                 originalPriority = str(taskDetails["priority"].get_nothrow(0))
510                 if taskDetails["dueDate"].is_good():
511                         originalDue = taskDetails["dueDate"].get()
512                 else:
513                         originalDue = None
514
515                 self._dialog.set_default_response(gtk.RESPONSE_OK)
516                 self._taskName.set_text(originalName)
517                 self._set_active_proj(originalProjectName)
518                 self._priorityChoiceCombo.set_active(int(originalPriority))
519                 if originalDue is not None:
520                         # Months are 0 indexed
521                         self._dueDateDisplay.select_month(originalDue.month - 1, originalDue.year)
522                         self._dueDateDisplay.select_day(originalDue.day)
523                 else:
524                         now = datetime.datetime.now()
525                         self._dueDateDisplay.select_month(now.month, now.year)
526                         self._dueDateDisplay.select_day(0)
527
528                 try:
529                         response = self._dialog.run()
530                         if response != gtk.RESPONSE_OK:
531                                 raise RuntimeError("Edit Cancelled")
532                 finally:
533                         self._dialog.hide()
534
535                 newProjectName = self._get_project(todoManager)
536                 newName = self._taskName.get_text()
537                 newPriority = self._get_priority()
538                 year, month, day = self._dueDateDisplay.get_date()
539                 if day != 0:
540                         # Months are 0 indexed
541                         date = datetime.date(year, month + 1, day)
542                         time = datetime.time()
543                         newDueDate = datetime.datetime.combine(date, time)
544                 else:
545                         newDueDate = None
546
547                 isProjDifferent = newProjectName != originalProjectName
548                 isNameDifferent = newName != originalName
549                 isPriorityDifferent = newPriority != originalPriority
550                 isDueDifferent = newDueDate != originalDue
551
552                 if isProjDifferent:
553                         newProjectId = todoManager.lookup_project(newProjectName)
554                         todoManager.set_project(taskId, newProjectId)
555                         print "PROJ CHANGE"
556                 if isNameDifferent:
557                         todoManager.set_name(taskId, newName)
558                         print "NAME CHANGE"
559                 if isPriorityDifferent:
560                         try:
561                                 priority = toolbox.Optional(int(newPriority))
562                         except ValueError:
563                                 priority = toolbox.Optional()
564                         todoManager.set_priority(taskId, priority)
565                         print "PRIO CHANGE"
566                 if isDueDifferent:
567                         if newDueDate:
568                                 due = toolbox.Optional(newDueDate)
569                         else:
570                                 due = toolbox.Optional()
571
572                         todoManager.set_duedate(taskId, due)
573                         print "DUE CHANGE"
574
575                 return {
576                         "projId": isProjDifferent,
577                         "name": isNameDifferent,
578                         "priority": isPriorityDifferent,
579                         "due": isDueDifferent,
580                 }
581
582         def _populate_projects(self, todoManager):
583                 for projectName in todoManager.get_projects():
584                         row = (projectName["name"], )
585                         self._projectsList.append(row)
586                 self._projectCombo.set_model(self._projectsList)
587                 cell = gtk.CellRendererText()
588                 self._projectCombo.pack_start(cell, True)
589                 self._projectCombo.add_attribute(cell, 'text', 0)
590                 self._projectCombo.set_active(0)
591
592         def _set_active_proj(self, projName):
593                 for i, row in enumerate(self._projectsList):
594                         if row[0] == projName:
595                                 self._projectCombo.set_active(i)
596                                 break
597                 else:
598                         raise ValueError("%s not in list" % projName)
599
600         def _get_project(self, todoManager):
601                 name = self._projectCombo.get_active_text()
602                 return name
603
604         def _get_priority(self):
605                 index = self._priorityChoiceCombo.get_active()
606                 assert index != -1
607                 if index < 1:
608                         return ""
609                 else:
610                         return str(index)
611
612         def _on_name_paste(self, *args):
613                 clipboard = gtk.clipboard_get()
614                 contents = clipboard.wait_for_text()
615                 if contents is not None:
616                         self._taskName.set_text(contents)
617
618         def _on_clear_duedate(self, *args):
619                 self._dueDateDisplay.select_day(0)
620
621         def _on_add_clicked(self, *args):
622                 self._dialog.response(gtk.RESPONSE_OK)
623
624         def _on_cancel_clicked(self, *args):
625                 self._dialog.response(gtk.RESPONSE_CANCEL)
626
627
628 if __name__ == "__main__":
629         if True:
630                 win = gtk.Window()
631                 win.set_title("Tap'N'Hold")
632                 eventBox = gtk.EventBox()
633                 win.add(eventBox)
634
635                 context = ContextHandler(eventBox, coroutines.printer_sink())
636                 context.enable()
637                 win.connect("destroy", lambda w: gtk.main_quit())
638
639                 win.show_all()
640
641         if False:
642                 cal = PopupCalendar(None, datetime.datetime.now())
643                 cal._popupWindow.connect("destroy", lambda w: gtk.main_quit())
644                 cal.run()
645
646         gtk.main()