Switching from a quick and dirty callback to using python signal stuff
[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 warnings
11 import contextlib
12 import datetime
13
14 import gobject
15 import gtk
16
17 import toolbox
18 import coroutines
19
20
21 @contextlib.contextmanager
22 def gtk_lock():
23         gtk.gdk.threads_enter()
24         try:
25                 yield
26         finally:
27                 gtk.gdk.threads_leave()
28
29
30 class ContextHandler(object):
31
32         HOLD_TIMEOUT = 1000
33         MOVE_THRESHHOLD = 10
34
35         def __init__(self, actionWidget, eventTarget = coroutines.null_sink()):
36                 self._actionWidget = actionWidget
37                 self._eventTarget = eventTarget
38
39                 self._actionPressId = None
40                 self._actionReleaseId = None
41                 self._motionNotifyId = None
42                 self._popupMenuId = None
43                 self._holdTimerId = None
44
45                 self._respondOnRelease = False
46                 self._startPosition = None
47
48         def enable(self):
49                 self._actionPressId = self._actionWidget.connect("button-press-event", self._on_press)
50                 self._actionReleaseId = self._actionWidget.connect("button-release-event", self._on_release)
51                 self._motionNotifyId = self._actionWidget.connect("motion-notify-event", self._on_motion)
52                 self._popupMenuId = self._actionWidget.connect("popup-menu", self._on_popup)
53
54         def disable(self):
55                 self._clear()
56                 self._actionWidget.disconnect(self._actionPressId)
57                 self._actionWidget.disconnect(self._actionReleaseId)
58                 self._actionWidget.disconnect(self._motionNotifyId)
59                 self._actionWidget.disconnect(self._popupMenuId)
60
61         def _respond(self, position):
62                 widgetPosition = 0, 0 # @todo Figure out how to get a widgets root position
63                 responsePosition = (
64                         widgetPosition[0] + position[0],
65                         widgetPosition[1] + position[1],
66                 )
67                 self._eventTarget.send((self._actionWidget, responsePosition))
68
69         def _clear(self):
70                 if self._holdTimerId is not None:
71                         gobject.source_remove(self._holdTimerId)
72                 self._respondOnRelease = False
73                 self._startPosition = None
74
75         def _is_cleared(self):
76                 return self._startPosition is None
77
78         def _on_press(self, widget, event):
79                 if not self._is_cleared():
80                         return
81
82                 self._startPosition = event.get_coords()
83                 if event.button == 1:
84                         # left click
85                         self._holdTimerId = gobject.timeout_add(self.HOLD_TIMEOUT, self._on_hold_timeout)
86                 else:
87                         # any other click
88                         self._respondOnRelease = True
89
90         def _on_release(self, widget, event):
91                 if self._is_cleared():
92                         return
93
94                 if self._respondOnRelease:
95                         position = self._startPosition
96                         self._clear()
97                         self._respond(position)
98                 else:
99                         self._clear()
100
101         def _on_hold_timeout(self):
102                 assert not self._is_cleared()
103                 gobject.source_remove(self._holdTimerId)
104                 self._holdTimerId = None
105
106                 position = self._startPosition
107                 self._clear()
108                 self._respond(position)
109
110         def _on_motion(self, widget, event):
111                 if self._is_cleared():
112                         return
113                 curPosition = event.get_coords()
114                 dx, dy = (
115                         curPosition[1] - self._startPosition[1],
116                         curPosition[1] - self._startPosition[1],
117                 )
118                 delta = (dx ** 2 + dy ** 2) ** (0.5)
119                 if self.MOVE_THRESHHOLD <= delta:
120                         self._clear()
121
122         def _on_popup(self, widget):
123                 self._clear()
124                 position = 0, 0
125                 self._respond(position)
126
127
128 class LoginWindow(object):
129
130         def __init__(self, widgetTree):
131                 """
132                 @note Thread agnostic
133                 """
134                 self._dialog = widgetTree.get_widget("loginDialog")
135                 self._parentWindow = widgetTree.get_widget("mainWindow")
136                 self._serviceCombo = widgetTree.get_widget("serviceCombo")
137                 self._usernameEntry = widgetTree.get_widget("usernameentry")
138                 self._passwordEntry = widgetTree.get_widget("passwordentry")
139
140                 self._serviceList = gtk.ListStore(gobject.TYPE_INT, gobject.TYPE_STRING)
141                 self._serviceCombo.set_model(self._serviceList)
142                 cell = gtk.CellRendererText()
143                 self._serviceCombo.pack_start(cell, True)
144                 self._serviceCombo.add_attribute(cell, 'text', 1)
145                 self._serviceCombo.set_active(0)
146
147                 callbackMapping = {
148                         "on_loginbutton_clicked": self._on_loginbutton_clicked,
149                         "on_loginclose_clicked": self._on_loginclose_clicked,
150                 }
151                 widgetTree.signal_autoconnect(callbackMapping)
152
153         def request_credentials(self, parentWindow = None):
154                 """
155                 @note UI Thread
156                 """
157                 if parentWindow is None:
158                         parentWindow = self._parentWindow
159
160                 self._serviceCombo.hide()
161                 self._serviceList.clear()
162
163                 try:
164                         self._dialog.set_transient_for(parentWindow)
165                         self._dialog.set_default_response(gtk.RESPONSE_OK)
166                         response = self._dialog.run()
167                         if response != gtk.RESPONSE_OK:
168                                 raise RuntimeError("Login Cancelled")
169
170                         username = self._usernameEntry.get_text()
171                         password = self._passwordEntry.get_text()
172                         self._passwordEntry.set_text("")
173                 finally:
174                         self._dialog.hide()
175
176                 return username, password
177
178         def request_credentials_from(self, services, parentWindow = None):
179                 """
180                 @note UI Thread
181                 """
182                 if parentWindow is None:
183                         parentWindow = self._parentWindow
184
185                 self._serviceList.clear()
186                 for serviceIdserviceName in services.iteritems():
187                         self._serviceList.append(serviceIdserviceName)
188                 self._serviceCombo.set_active(0)
189                 self._serviceCombo.show()
190
191                 try:
192                         self._dialog.set_transient_for(parentWindow)
193                         self._dialog.set_default_response(gtk.RESPONSE_OK)
194                         response = self._dialog.run()
195                         if response != gtk.RESPONSE_OK:
196                                 raise RuntimeError("Login Cancelled")
197
198                         username = self._usernameEntry.get_text()
199                         password = self._passwordEntry.get_text()
200                         self._passwordEntry.set_text("")
201                 finally:
202                         self._dialog.hide()
203
204                 itr = self._serviceCombo.get_active_iter()
205                 serviceId = int(self._serviceList.get_value(itr, 0))
206                 self._serviceList.clear()
207                 return serviceId, username, password
208
209         def _on_loginbutton_clicked(self, *args):
210                 self._dialog.response(gtk.RESPONSE_OK)
211
212         def _on_loginclose_clicked(self, *args):
213                 self._dialog.response(gtk.RESPONSE_CANCEL)
214
215
216 class ErrorDisplay(object):
217
218         def __init__(self, widgetTree):
219                 super(ErrorDisplay, self).__init__()
220                 self.__errorBox = widgetTree.get_widget("errorEventBox")
221                 self.__errorDescription = widgetTree.get_widget("errorDescription")
222                 self.__errorClose = widgetTree.get_widget("errorClose")
223                 self.__parentBox = self.__errorBox.get_parent()
224
225                 self.__errorBox.connect("button_release_event", self._on_close)
226
227                 self.__messages = []
228                 self.__parentBox.remove(self.__errorBox)
229
230         def push_message_with_lock(self, message):
231                 gtk.gdk.threads_enter()
232                 try:
233                         self.push_message(message)
234                 finally:
235                         gtk.gdk.threads_leave()
236
237         def push_message(self, message):
238                 if 0 < len(self.__messages):
239                         self.__messages.append(message)
240                 else:
241                         self.__show_message(message)
242
243         def push_exception(self, exception):
244                 self.push_message(exception.message)
245                 warnings.warn(exception, stacklevel=3)
246
247         def pop_message(self):
248                 if 0 < len(self.__messages):
249                         self.__show_message(self.__messages[0])
250                         del self.__messages[0]
251                 else:
252                         self.__hide_message()
253
254         def _on_close(self, *args):
255                 self.pop_message()
256
257         def __show_message(self, message):
258                 self.__errorDescription.set_text(message)
259                 self.__parentBox.pack_start(self.__errorBox, False, False)
260                 self.__parentBox.reorder_child(self.__errorBox, 1)
261
262         def __hide_message(self):
263                 self.__errorDescription.set_text("")
264                 self.__parentBox.remove(self.__errorBox)
265
266
267 class MessageBox(gtk.MessageDialog):
268
269         def __init__(self, message):
270                 parent = None
271                 gtk.MessageDialog.__init__(
272                         self,
273                         parent,
274                         gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT,
275                         gtk.MESSAGE_ERROR,
276                         gtk.BUTTONS_OK,
277                         message,
278                 )
279                 self.set_default_response(gtk.RESPONSE_OK)
280                 self.connect('response', self._handle_clicked)
281
282         def _handle_clicked(self, *args):
283                 self.destroy()
284
285
286 class MessageBox2(gtk.MessageDialog):
287
288         def __init__(self, message):
289                 parent = None
290                 gtk.MessageDialog.__init__(
291                         self,
292                         parent,
293                         gtk.DIALOG_DESTROY_WITH_PARENT,
294                         gtk.MESSAGE_ERROR,
295                         gtk.BUTTONS_OK,
296                         message,
297                 )
298                 self.set_default_response(gtk.RESPONSE_OK)
299                 self.connect('response', self._handle_clicked)
300
301         def _handle_clicked(self, *args):
302                 self.destroy()
303
304
305 class PopupCalendar(object):
306
307         def __init__(self, parent, eventTarget = coroutines.null_sink()):
308                 self._eventTarget = eventTarget
309
310                 self.__calendar = gtk.Calendar()
311                 self.__calendar.connect("day-selected-double-click", self._on_date_select)
312
313                 self.__popupWindow = gtk.Window(type = gtk.WINDOW_POPUP)
314                 self.__popupWindow.set_title("")
315                 self.__popupWindow.add(self.__calendar)
316                 self.__popupWindow.set_transient_for(parent)
317                 self.__popupWindow.set_modal(True)
318
319         def get_date(self):
320                 year, month, day = self.__calendar.get_date()
321                 month += 1 # Seems to be 0 indexed
322                 return datetime.date(year, month, day)
323
324         def run(self):
325                 self.__popupWindow.show_all()
326
327         def _on_date_select(self, *args):
328                 self.__popupWindow.hide()
329                 self._eventTarget.send((self, self.get_date()))
330
331
332 class EditTaskDialog(object):
333
334         def __init__(self, widgetTree):
335                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
336
337                 self._dialog = widgetTree.get_widget("editTaskDialog")
338                 self._projectCombo = widgetTree.get_widget("edit-targetProjectCombo")
339                 self._taskName = widgetTree.get_widget("edit-taskNameEntry")
340                 self._pasteTaskNameButton = widgetTree.get_widget("edit-pasteTaskNameButton")
341                 self._priorityChoiceCombo = widgetTree.get_widget("edit-priorityChoiceCombo")
342                 self._dueDateDisplay = widgetTree.get_widget("edit-dueDateDisplay")
343                 self._dueDateProperties = widgetTree.get_widget("edit-dueDateProperties")
344                 self._clearDueDate = widgetTree.get_widget("edit-clearDueDate")
345
346                 self._addButton = widgetTree.get_widget("edit-commitEditTaskButton")
347                 self._cancelButton = widgetTree.get_widget("edit-cancelEditTaskButton")
348
349                 self._popupCalendar = PopupCalendar(self._dialog, coroutines.func_sink(self._update_duedate))
350
351         def enable(self, todoManager):
352                 self._populate_projects(todoManager)
353                 self._pasteTaskNameButton.connect("clicked", self._on_name_paste)
354                 self._dueDateProperties.connect("clicked", self._on_choose_duedate)
355                 self._clearDueDate.connect("clicked", self._on_clear_duedate)
356
357                 self._addButton.connect("clicked", self._on_add_clicked)
358                 self._cancelButton.connect("clicked", self._on_cancel_clicked)
359
360         def disable(self):
361                 self._pasteTaskNameButton.disconnect("clicked", self._on_name_paste)
362                 self._dueDateProperties.disconnect("clicked", self._on_choose_duedate)
363                 self._clearDueDate.disconnect("clicked", self._on_clear_duedate)
364                 self._projectsList.clear()
365                 self._projectCombo.set_model(None)
366
367         def request_task(self, todoManager, taskId, parentWindow = None):
368                 if parentWindow is not None:
369                         self._dialog.set_transient_for(parentWindow)
370
371                 taskDetails = todoManager.get_task_details(taskId)
372                 originalProjectId = taskDetails["projId"]
373                 originalProjectName = todoManager.get_project(originalProjectId)["name"]
374                 originalName = taskDetails["name"]
375                 originalPriority = str(taskDetails["priority"].get_nothrow(0))
376                 if taskDetails["dueDate"].is_good():
377                         originalDue = taskDetails["dueDate"].get().strftime("%Y-%m-%d %H:%M:%S")
378                 else:
379                         originalDue = ""
380
381                 self._dialog.set_default_response(gtk.RESPONSE_OK)
382                 self._taskName.set_text(originalName)
383                 self._set_active_proj(originalProjectName)
384                 self._priorityChoiceCombo.set_active(int(originalPriority))
385                 self._dueDateDisplay.set_text(originalDue)
386
387                 try:
388                         response = self._dialog.run()
389                         if response != gtk.RESPONSE_OK:
390                                 raise RuntimeError("Edit Cancelled")
391                 finally:
392                         self._dialog.hide()
393
394                 newProjectName = self._get_project(todoManager)
395                 newName = self._taskName.get_text()
396                 newPriority = self._get_priority()
397                 newDueDate = self._dueDateDisplay.get_text()
398
399                 isProjDifferent = newProjectName != originalProjectName
400                 isNameDifferent = newName != originalName
401                 isPriorityDifferent = newPriority != originalPriority
402                 isDueDifferent = newDueDate != originalDue
403
404                 if isProjDifferent:
405                         newProjectId = todoManager.lookup_project(newProjectName)
406                         todoManager.set_project(taskId, newProjectId)
407                         print "PROJ CHANGE"
408                 if isNameDifferent:
409                         todoManager.set_name(taskId, newName)
410                         print "NAME CHANGE"
411                 if isPriorityDifferent:
412                         try:
413                                 priority = toolbox.Optional(int(newPriority))
414                         except ValueError:
415                                 priority = toolbox.Optional()
416                         todoManager.set_priority(taskId, priority)
417                         print "PRIO CHANGE"
418                 if isDueDifferent:
419                         if newDueDate:
420                                 due = datetime.datetime.strptime(newDueDate, "%Y-%m-%d %H:%M:%S")
421                                 due = toolbox.Optional(due)
422                         else:
423                                 due = toolbox.Optional()
424
425                         todoManager.set_duedate(taskId, due)
426                         print "DUE CHANGE"
427
428                 return {
429                         "projId": isProjDifferent,
430                         "name": isNameDifferent,
431                         "priority": isPriorityDifferent,
432                         "due": isDueDifferent,
433                 }
434
435         def _populate_projects(self, todoManager):
436                 for projectName in todoManager.get_projects():
437                         row = (projectName["name"], )
438                         self._projectsList.append(row)
439                 self._projectCombo.set_model(self._projectsList)
440                 cell = gtk.CellRendererText()
441                 self._projectCombo.pack_start(cell, True)
442                 self._projectCombo.add_attribute(cell, 'text', 0)
443                 self._projectCombo.set_active(0)
444
445         def _set_active_proj(self, projName):
446                 for i, row in enumerate(self._projectsList):
447                         if row[0] == projName:
448                                 self._projectCombo.set_active(i)
449                                 break
450                 else:
451                         raise ValueError("%s not in list" % projName)
452
453         def _get_project(self, todoManager):
454                 name = self._projectCombo.get_active_text()
455                 return name
456
457         def _get_priority(self):
458                 index = self._priorityChoiceCombo.get_active()
459                 assert index != -1
460                 if index < 1:
461                         return ""
462                 else:
463                         return str(index)
464
465         def _update_duedate(self, eventData):
466                 widget, date = eventData
467                 time = datetime.time()
468                 dueDate = datetime.datetime.combine(date, time)
469
470                 formttedDate = dueDate.strftime("%Y-%m-%d %H:%M:%S")
471                 self._dueDateDisplay.set_text(formttedDate)
472
473         def _on_name_paste(self, *args):
474                 clipboard = gtk.clipboard_get()
475                 contents = clipboard.wait_for_text()
476                 if contents is not None:
477                         self._taskName.set_text(contents)
478
479         def _on_choose_duedate(self, *args):
480                 self._popupCalendar.run()
481
482         def _on_clear_duedate(self, *args):
483                 self._dueDateDisplay.set_text("")
484
485         def _on_add_clicked(self, *args):
486                 self._dialog.response(gtk.RESPONSE_OK)
487
488         def _on_cancel_clicked(self, *args):
489                 self._dialog.response(gtk.RESPONSE_CANCEL)
490
491
492 if __name__ == "__main__":
493         if True:
494                 win = gtk.Window()
495                 win.set_title("Tap'N'Hold")
496                 eventBox = gtk.EventBox()
497                 win.add(eventBox)
498
499                 context = ContextHandler(eventBox, coroutines.printer_sink())
500                 context.enable()
501                 win.connect("destroy", lambda w: gtk.main_quit())
502
503                 win.show_all()
504                 gtk.main()