e7d108dfd9c7bcd891a41a2e24545b9d4500c369
[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         def __init__(self, widgetTree):
460                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
461
462                 self._dialog = widgetTree.get_widget("editTaskDialog")
463                 self._projectCombo = widgetTree.get_widget("edit-targetProjectCombo")
464                 self._taskName = widgetTree.get_widget("edit-taskNameEntry")
465                 self._pasteTaskNameButton = widgetTree.get_widget("edit-pasteTaskNameButton")
466                 self._priorityChoiceCombo = widgetTree.get_widget("edit-priorityChoiceCombo")
467                 self._dueDateDisplay = widgetTree.get_widget("edit-dueDateCalendar")
468                 self._clearDueDate = widgetTree.get_widget("edit-clearDueDate")
469
470                 self._addButton = widgetTree.get_widget("edit-commitEditTaskButton")
471                 self._cancelButton = widgetTree.get_widget("edit-cancelEditTaskButton")
472
473                 self._onPasteTaskId = None
474                 self._onClearDueDateId = None
475                 self._onAddId = None
476                 self._onCancelId = None
477
478         def enable(self, todoManager):
479                 self._populate_projects(todoManager)
480
481                 self._onPasteTaskId = self._pasteTaskNameButton.connect("clicked", self._on_name_paste)
482                 self._onClearDueDateId = self._clearDueDate.connect("clicked", self._on_clear_duedate)
483                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
484                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
485
486         def disable(self):
487                 self._pasteTaskNameButton.disconnect(self._onPasteTaskId)
488                 self._clearDueDate.disconnect(self._onClearDueDateId)
489                 self._addButton.disconnect(self._onAddId)
490                 self._cancelButton.disconnect(self._onAddId)
491
492                 self._projectsList.clear()
493                 self._projectCombo.set_model(None)
494
495         def request_task(self, todoManager, taskId, parentWindow = None):
496                 if parentWindow is not None:
497                         self._dialog.set_transient_for(parentWindow)
498
499                 taskDetails = todoManager.get_task_details(taskId)
500                 originalProjectId = taskDetails["projId"]
501                 originalProjectName = todoManager.get_project(originalProjectId)["name"]
502                 originalName = taskDetails["name"]
503                 originalPriority = str(taskDetails["priority"].get_nothrow(0))
504                 if taskDetails["dueDate"].is_good():
505                         originalDue = taskDetails["dueDate"].get()
506                 else:
507                         originalDue = None
508
509                 self._dialog.set_default_response(gtk.RESPONSE_OK)
510                 self._taskName.set_text(originalName)
511                 self._set_active_proj(originalProjectName)
512                 self._priorityChoiceCombo.set_active(int(originalPriority))
513                 if originalDue is not None:
514                         # Months are 0 indexed
515                         self._dueDateDisplay.select_month(originalDue.month - 1, originalDue.year)
516                         self._dueDateDisplay.select_day(originalDue.day)
517                 else:
518                         now = datetime.datetime.now()
519                         self._dueDateDisplay.select_month(now.month, now.year)
520                         self._dueDateDisplay.select_day(0)
521
522                 try:
523                         response = self._dialog.run()
524                         if response != gtk.RESPONSE_OK:
525                                 raise RuntimeError("Edit Cancelled")
526                 finally:
527                         self._dialog.hide()
528
529                 newProjectName = self._get_project(todoManager)
530                 newName = self._taskName.get_text()
531                 newPriority = self._get_priority()
532                 year, month, day = self._dueDateDisplay.get_date()
533                 if day != 0:
534                         # Months are 0 indexed
535                         date = datetime.date(year, month + 1, day)
536                         time = datetime.time()
537                         newDueDate = datetime.datetime.combine(date, time)
538                 else:
539                         newDueDate = None
540
541                 isProjDifferent = newProjectName != originalProjectName
542                 isNameDifferent = newName != originalName
543                 isPriorityDifferent = newPriority != originalPriority
544                 isDueDifferent = newDueDate != originalDue
545
546                 if isProjDifferent:
547                         newProjectId = todoManager.lookup_project(newProjectName)
548                         todoManager.set_project(taskId, newProjectId)
549                         print "PROJ CHANGE"
550                 if isNameDifferent:
551                         todoManager.set_name(taskId, newName)
552                         print "NAME CHANGE"
553                 if isPriorityDifferent:
554                         try:
555                                 priority = toolbox.Optional(int(newPriority))
556                         except ValueError:
557                                 priority = toolbox.Optional()
558                         todoManager.set_priority(taskId, priority)
559                         print "PRIO CHANGE"
560                 if isDueDifferent:
561                         if newDueDate:
562                                 due = toolbox.Optional(newDueDate)
563                         else:
564                                 due = toolbox.Optional()
565
566                         todoManager.set_duedate(taskId, due)
567                         print "DUE CHANGE"
568
569                 return {
570                         "projId": isProjDifferent,
571                         "name": isNameDifferent,
572                         "priority": isPriorityDifferent,
573                         "due": isDueDifferent,
574                 }
575
576         def _populate_projects(self, todoManager):
577                 for projectName in todoManager.get_projects():
578                         row = (projectName["name"], )
579                         self._projectsList.append(row)
580                 self._projectCombo.set_model(self._projectsList)
581                 cell = gtk.CellRendererText()
582                 self._projectCombo.pack_start(cell, True)
583                 self._projectCombo.add_attribute(cell, 'text', 0)
584                 self._projectCombo.set_active(0)
585
586         def _set_active_proj(self, projName):
587                 for i, row in enumerate(self._projectsList):
588                         if row[0] == projName:
589                                 self._projectCombo.set_active(i)
590                                 break
591                 else:
592                         raise ValueError("%s not in list" % projName)
593
594         def _get_project(self, todoManager):
595                 name = self._projectCombo.get_active_text()
596                 return name
597
598         def _get_priority(self):
599                 index = self._priorityChoiceCombo.get_active()
600                 assert index != -1
601                 if index < 1:
602                         return ""
603                 else:
604                         return str(index)
605
606         def _on_name_paste(self, *args):
607                 clipboard = gtk.clipboard_get()
608                 contents = clipboard.wait_for_text()
609                 if contents is not None:
610                         self._taskName.set_text(contents)
611
612         def _on_clear_duedate(self, *args):
613                 self._dueDateDisplay.select_day(0)
614
615         def _on_add_clicked(self, *args):
616                 self._dialog.response(gtk.RESPONSE_OK)
617
618         def _on_cancel_clicked(self, *args):
619                 self._dialog.response(gtk.RESPONSE_CANCEL)
620
621
622 if __name__ == "__main__":
623         if True:
624                 win = gtk.Window()
625                 win.set_title("Tap'N'Hold")
626                 eventBox = gtk.EventBox()
627                 win.add(eventBox)
628
629                 context = ContextHandler(eventBox, coroutines.printer_sink())
630                 context.enable()
631                 win.connect("destroy", lambda w: gtk.main_quit())
632
633                 win.show_all()
634
635         if False:
636                 cal = PopupCalendar(None, datetime.datetime.now())
637                 cal._popupWindow.connect("destroy", lambda w: gtk.main_quit())
638                 cal.run()
639
640         gtk.main()