* Fixed a bug when resetting the task list introduced when breaking the item 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_info()[1].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, eventTarget = coroutines.null_sink()):
316                 self._eventTarget = eventTarget
317
318                 self.__calendar = gtk.Calendar()
319                 self.__calendar.connect("day-selected-double-click", self._on_date_select)
320
321                 self.__popupWindow = gtk.Window(type = gtk.WINDOW_POPUP)
322                 self.__popupWindow.set_title("")
323                 self.__popupWindow.add(self.__calendar)
324                 self.__popupWindow.set_transient_for(parent)
325                 self.__popupWindow.set_modal(True)
326
327         def get_date(self):
328                 year, month, day = self.__calendar.get_date()
329                 month += 1 # Seems to be 0 indexed
330                 return datetime.date(year, month, day)
331
332         def run(self):
333                 self.__popupWindow.show_all()
334
335         def _on_date_select(self, *args):
336                 self.__popupWindow.hide()
337                 self._eventTarget.send((self, self.get_date()))
338
339
340 class EditTaskDialog(object):
341
342         def __init__(self, widgetTree):
343                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
344
345                 self._dialog = widgetTree.get_widget("editTaskDialog")
346                 self._projectCombo = widgetTree.get_widget("edit-targetProjectCombo")
347                 self._taskName = widgetTree.get_widget("edit-taskNameEntry")
348                 self._pasteTaskNameButton = widgetTree.get_widget("edit-pasteTaskNameButton")
349                 self._priorityChoiceCombo = widgetTree.get_widget("edit-priorityChoiceCombo")
350                 self._dueDateDisplay = widgetTree.get_widget("edit-dueDateCalendar")
351                 self._clearDueDate = widgetTree.get_widget("edit-clearDueDate")
352
353                 self._addButton = widgetTree.get_widget("edit-commitEditTaskButton")
354                 self._cancelButton = widgetTree.get_widget("edit-cancelEditTaskButton")
355
356                 self._onPasteTaskId = None
357                 self._onClearDueDateId = None
358                 self._onAddId = None
359                 self._onCancelId = None
360
361         def enable(self, todoManager):
362                 self._populate_projects(todoManager)
363
364                 self._onPasteTaskId = self._pasteTaskNameButton.connect("clicked", self._on_name_paste)
365                 self._onClearDueDateId = self._clearDueDate.connect("clicked", self._on_clear_duedate)
366                 self._onAddId = self._addButton.connect("clicked", self._on_add_clicked)
367                 self._onCancelId = self._cancelButton.connect("clicked", self._on_cancel_clicked)
368
369         def disable(self):
370                 self._pasteTaskNameButton.disconnect(self._onPasteTaskId)
371                 self._clearDueDate.disconnect(self._onClearDueDateId)
372                 self._addButton.disconnect(self._onAddId)
373                 self._cancelButton.disconnect(self._onAddId)
374
375                 self._projectsList.clear()
376                 self._projectCombo.set_model(None)
377
378         def request_task(self, todoManager, taskId, parentWindow = None):
379                 if parentWindow is not None:
380                         self._dialog.set_transient_for(parentWindow)
381
382                 taskDetails = todoManager.get_task_details(taskId)
383                 originalProjectId = taskDetails["projId"]
384                 originalProjectName = todoManager.get_project(originalProjectId)["name"]
385                 originalName = taskDetails["name"]
386                 originalPriority = str(taskDetails["priority"].get_nothrow(0))
387                 if taskDetails["dueDate"].is_good():
388                         originalDue = taskDetails["dueDate"].get()
389                 else:
390                         originalDue = None
391
392                 self._dialog.set_default_response(gtk.RESPONSE_OK)
393                 self._taskName.set_text(originalName)
394                 self._set_active_proj(originalProjectName)
395                 self._priorityChoiceCombo.set_active(int(originalPriority))
396                 if originalDue is not None:
397                         # Months are 0 indexed
398                         self._dueDateDisplay.select_month(originalDue.month - 1, originalDue.year)
399                         self._dueDateDisplay.select_day(originalDue.day)
400                 else:
401                         now = datetime.datetime.now()
402                         self._dueDateDisplay.select_month(now.month, now.year)
403                         self._dueDateDisplay.select_day(0)
404
405                 try:
406                         response = self._dialog.run()
407                         if response != gtk.RESPONSE_OK:
408                                 raise RuntimeError("Edit Cancelled")
409                 finally:
410                         self._dialog.hide()
411
412                 newProjectName = self._get_project(todoManager)
413                 newName = self._taskName.get_text()
414                 newPriority = self._get_priority()
415                 year, month, day = self._dueDateDisplay.get_date()
416                 if day != 0:
417                         # Months are 0 indexed
418                         date = datetime.date(year, month + 1, day)
419                         time = datetime.time()
420                         newDueDate = datetime.datetime.combine(date, time)
421                 else:
422                         newDueDate = None
423
424                 isProjDifferent = newProjectName != originalProjectName
425                 isNameDifferent = newName != originalName
426                 isPriorityDifferent = newPriority != originalPriority
427                 isDueDifferent = newDueDate != originalDue
428
429                 if isProjDifferent:
430                         newProjectId = todoManager.lookup_project(newProjectName)
431                         todoManager.set_project(taskId, newProjectId)
432                         print "PROJ CHANGE"
433                 if isNameDifferent:
434                         todoManager.set_name(taskId, newName)
435                         print "NAME CHANGE"
436                 if isPriorityDifferent:
437                         try:
438                                 priority = toolbox.Optional(int(newPriority))
439                         except ValueError:
440                                 priority = toolbox.Optional()
441                         todoManager.set_priority(taskId, priority)
442                         print "PRIO CHANGE"
443                 if isDueDifferent:
444                         if newDueDate:
445                                 due = toolbox.Optional(newDueDate)
446                         else:
447                                 due = toolbox.Optional()
448
449                         todoManager.set_duedate(taskId, due)
450                         print "DUE CHANGE"
451
452                 return {
453                         "projId": isProjDifferent,
454                         "name": isNameDifferent,
455                         "priority": isPriorityDifferent,
456                         "due": isDueDifferent,
457                 }
458
459         def _populate_projects(self, todoManager):
460                 for projectName in todoManager.get_projects():
461                         row = (projectName["name"], )
462                         self._projectsList.append(row)
463                 self._projectCombo.set_model(self._projectsList)
464                 cell = gtk.CellRendererText()
465                 self._projectCombo.pack_start(cell, True)
466                 self._projectCombo.add_attribute(cell, 'text', 0)
467                 self._projectCombo.set_active(0)
468
469         def _set_active_proj(self, projName):
470                 for i, row in enumerate(self._projectsList):
471                         if row[0] == projName:
472                                 self._projectCombo.set_active(i)
473                                 break
474                 else:
475                         raise ValueError("%s not in list" % projName)
476
477         def _get_project(self, todoManager):
478                 name = self._projectCombo.get_active_text()
479                 return name
480
481         def _get_priority(self):
482                 index = self._priorityChoiceCombo.get_active()
483                 assert index != -1
484                 if index < 1:
485                         return ""
486                 else:
487                         return str(index)
488
489         def _on_name_paste(self, *args):
490                 clipboard = gtk.clipboard_get()
491                 contents = clipboard.wait_for_text()
492                 if contents is not None:
493                         self._taskName.set_text(contents)
494
495         def _on_clear_duedate(self, *args):
496                 self._dueDateDisplay.select_day(0)
497
498         def _on_add_clicked(self, *args):
499                 self._dialog.response(gtk.RESPONSE_OK)
500
501         def _on_cancel_clicked(self, *args):
502                 self._dialog.response(gtk.RESPONSE_CANCEL)
503
504
505 if __name__ == "__main__":
506         if True:
507                 win = gtk.Window()
508                 win.set_title("Tap'N'Hold")
509                 eventBox = gtk.EventBox()
510                 win.add(eventBox)
511
512                 context = ContextHandler(eventBox, coroutines.printer_sink())
513                 context.enable()
514                 win.connect("destroy", lambda w: gtk.main_quit())
515
516                 win.show_all()
517                 gtk.main()