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