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