Breaking out some logic and fixing some icons
[gc-dialer] / src / gc_views.py
1 #!/usr/bin/python2.5
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Mark Bergman bergman AT merctech DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20 """
21
22 from __future__ import with_statement
23
24 import threading
25 import warnings
26
27 import gobject
28 import gtk
29
30 import gtk_toolbox
31
32
33 def make_ugly(prettynumber):
34         """
35         function to take a phone number and strip out all non-numeric
36         characters
37
38         >>> make_ugly("+012-(345)-678-90")
39         '01234567890'
40         """
41         import re
42         uglynumber = re.sub('\D', '', prettynumber)
43         return uglynumber
44
45
46 def make_pretty(phonenumber):
47         """
48         Function to take a phone number and return the pretty version
49         pretty numbers:
50                 if phonenumber begins with 0:
51                         ...-(...)-...-....
52                 if phonenumber begins with 1: ( for gizmo callback numbers )
53                         1 (...)-...-....
54                 if phonenumber is 13 digits:
55                         (...)-...-....
56                 if phonenumber is 10 digits:
57                         ...-....
58         >>> make_pretty("12")
59         '12'
60         >>> make_pretty("1234567")
61         '123-4567'
62         >>> make_pretty("2345678901")
63         '(234)-567-8901'
64         >>> make_pretty("12345678901")
65         '1 (234)-567-8901'
66         >>> make_pretty("01234567890")
67         '+012-(345)-678-90'
68         """
69         if phonenumber is None or phonenumber is "":
70                 return ""
71
72         phonenumber = make_ugly(phonenumber)
73
74         if len(phonenumber) < 3:
75                 return phonenumber
76
77         if phonenumber[0] == "0":
78                 prettynumber = ""
79                 prettynumber += "+%s" % phonenumber[0:3]
80                 if 3 < len(phonenumber):
81                         prettynumber += "-(%s)" % phonenumber[3:6]
82                         if 6 < len(phonenumber):
83                                 prettynumber += "-%s" % phonenumber[6:9]
84                                 if 9 < len(phonenumber):
85                                         prettynumber += "-%s" % phonenumber[9:]
86                 return prettynumber
87         elif len(phonenumber) <= 7:
88                 prettynumber = "%s-%s" % (phonenumber[0:3], phonenumber[3:])
89         elif len(phonenumber) > 8 and phonenumber[0] == "1":
90                 prettynumber = "1 (%s)-%s-%s" % (phonenumber[1:4], phonenumber[4:7], phonenumber[7:])
91         elif len(phonenumber) > 7:
92                 prettynumber = "(%s)-%s-%s" % (phonenumber[0:3], phonenumber[3:6], phonenumber[6:])
93         return prettynumber
94
95
96 def make_idler(func):
97         """
98         Decorator that makes a generator-function into a function that will continue execution on next call
99         """
100         a = []
101
102         def decorated_func(*args, **kwds):
103                 if not a:
104                         a.append(func(*args, **kwds))
105                 try:
106                         a[0].next()
107                         return True
108                 except StopIteration:
109                         del a[:]
110                         return False
111
112         decorated_func.__name__ = func.__name__
113         decorated_func.__doc__ = func.__doc__
114         decorated_func.__dict__.update(func.__dict__)
115
116         return decorated_func
117
118
119 class DummyAddressBook(object):
120         """
121         Minimal example of both an addressbook factory and an addressbook
122         """
123
124         def clear_caches(self):
125                 pass
126
127         def get_addressbooks(self):
128                 """
129                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
130                 """
131                 yield self, "", "None"
132
133         def open_addressbook(self, bookId):
134                 return self
135
136         @staticmethod
137         def contact_source_short_name(contactId):
138                 return ""
139
140         @staticmethod
141         def factory_name():
142                 return ""
143
144         @staticmethod
145         def get_contacts():
146                 """
147                 @returns Iterable of (contact id, contact name)
148                 """
149                 return []
150
151         @staticmethod
152         def get_contact_details(contactId):
153                 """
154                 @returns Iterable of (Phone Type, Phone Number)
155                 """
156                 return []
157
158
159 class MergedAddressBook(object):
160         """
161         Merger of all addressbooks
162         """
163
164         def __init__(self, addressbookFactories, sorter = None):
165                 self.__addressbookFactories = addressbookFactories
166                 self.__addressbooks = None
167                 self.__sort_contacts = sorter if sorter is not None else self.null_sorter
168
169         def clear_caches(self):
170                 self.__addressbooks = None
171                 for factory in self.__addressbookFactories:
172                         factory.clear_caches()
173
174         def get_addressbooks(self):
175                 """
176                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
177                 """
178                 yield self, "", ""
179
180         def open_addressbook(self, bookId):
181                 return self
182
183         def contact_source_short_name(self, contactId):
184                 if self.__addressbooks is None:
185                         return ""
186                 bookIndex, originalId = contactId.split("-", 1)
187                 return self.__addressbooks[int(bookIndex)].contact_source_short_name(originalId)
188
189         @staticmethod
190         def factory_name():
191                 return "All Contacts"
192
193         def get_contacts(self):
194                 """
195                 @returns Iterable of (contact id, contact name)
196                 """
197                 if self.__addressbooks is None:
198                         self.__addressbooks = list(
199                                 factory.open_addressbook(id)
200                                 for factory in self.__addressbookFactories
201                                 for (f, id, name) in factory.get_addressbooks()
202                         )
203                 contacts = (
204                         ("-".join([str(bookIndex), contactId]), contactName)
205                                 for (bookIndex, addressbook) in enumerate(self.__addressbooks)
206                                         for (contactId, contactName) in addressbook.get_contacts()
207                 )
208                 sortedContacts = self.__sort_contacts(contacts)
209                 return sortedContacts
210
211         def get_contact_details(self, contactId):
212                 """
213                 @returns Iterable of (Phone Type, Phone Number)
214                 """
215                 if self.__addressbooks is None:
216                         return []
217                 bookIndex, originalId = contactId.split("-", 1)
218                 return self.__addressbooks[int(bookIndex)].get_contact_details(originalId)
219
220         @staticmethod
221         def null_sorter(contacts):
222                 """
223                 Good for speed/low memory
224                 """
225                 return contacts
226
227         @staticmethod
228         def basic_firtname_sorter(contacts):
229                 """
230                 Expects names in "First Last" format
231                 """
232                 contactsWithKey = [
233                         (contactName.rsplit(" ", 1)[0], (contactId, contactName))
234                                 for (contactId, contactName) in contacts
235                 ]
236                 contactsWithKey.sort()
237                 return (contactData for (lastName, contactData) in contactsWithKey)
238
239         @staticmethod
240         def basic_lastname_sorter(contacts):
241                 """
242                 Expects names in "First Last" format
243                 """
244                 contactsWithKey = [
245                         (contactName.rsplit(" ", 1)[-1], (contactId, contactName))
246                                 for (contactId, contactName) in contacts
247                 ]
248                 contactsWithKey.sort()
249                 return (contactData for (lastName, contactData) in contactsWithKey)
250
251         @staticmethod
252         def reversed_firtname_sorter(contacts):
253                 """
254                 Expects names in "Last, First" format
255                 """
256                 contactsWithKey = [
257                         (contactName.split(", ", 1)[-1], (contactId, contactName))
258                                 for (contactId, contactName) in contacts
259                 ]
260                 contactsWithKey.sort()
261                 return (contactData for (lastName, contactData) in contactsWithKey)
262
263         @staticmethod
264         def reversed_lastname_sorter(contacts):
265                 """
266                 Expects names in "Last, First" format
267                 """
268                 contactsWithKey = [
269                         (contactName.split(", ", 1)[0], (contactId, contactName))
270                                 for (contactId, contactName) in contacts
271                 ]
272                 contactsWithKey.sort()
273                 return (contactData for (lastName, contactData) in contactsWithKey)
274
275         @staticmethod
276         def guess_firstname(name):
277                 if ", " in name:
278                         return name.split(", ", 1)[-1]
279                 else:
280                         return name.rsplit(" ", 1)[0]
281
282         @staticmethod
283         def guess_lastname(name):
284                 if ", " in name:
285                         return name.split(", ", 1)[0]
286                 else:
287                         return name.rsplit(" ", 1)[-1]
288
289         @classmethod
290         def advanced_firstname_sorter(cls, contacts):
291                 contactsWithKey = [
292                         (cls.guess_firstname(contactName), (contactId, contactName))
293                                 for (contactId, contactName) in contacts
294                 ]
295                 contactsWithKey.sort()
296                 return (contactData for (lastName, contactData) in contactsWithKey)
297
298         @classmethod
299         def advanced_lastname_sorter(cls, contacts):
300                 contactsWithKey = [
301                         (cls.guess_lastname(contactName), (contactId, contactName))
302                                 for (contactId, contactName) in contacts
303                 ]
304                 contactsWithKey.sort()
305                 return (contactData for (lastName, contactData) in contactsWithKey)
306
307
308 class PhoneTypeSelector(object):
309
310         ACTION_CANCEL = "cancel"
311         ACTION_SELECT = "select"
312         ACTION_DIAL = "dial"
313         ACTION_SEND_SMS = "sms"
314
315         def __init__(self, widgetTree, gcBackend):
316                 self._gcBackend = gcBackend
317                 self._widgetTree = widgetTree
318
319                 self._dialog = self._widgetTree.get_widget("phonetype_dialog")
320                 self._smsDialog = SmsEntryDialog(self._widgetTree, self._gcBackend)
321
322                 self._smsButton = self._widgetTree.get_widget("sms_button")
323                 self._smsButton.connect("clicked", self._on_phonetype_send_sms)
324
325                 self._dialButton = self._widgetTree.get_widget("dial_button")
326                 self._dialButton.connect("clicked", self._on_phonetype_dial)
327
328                 self._selectButton = self._widgetTree.get_widget("select_button")
329                 self._selectButton.connect("clicked", self._on_phonetype_select)
330
331                 self._cancelButton = self._widgetTree.get_widget("cancel_button")
332                 self._cancelButton.connect("clicked", self._on_phonetype_cancel)
333
334                 self._typemodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
335                 self._typeviewselection = None
336
337                 self._message = self._widgetTree.get_widget("phoneSelectionMessage")
338                 self._typeview = self._widgetTree.get_widget("phonetypes")
339                 self._typeview.connect("row-activated", self._on_phonetype_select)
340
341                 self._action = self.ACTION_CANCEL
342
343         def run(self, contactDetails, message = "", parent = None):
344                 self._action = self.ACTION_CANCEL
345                 self._typemodel.clear()
346                 self._typeview.set_model(self._typemodel)
347
348                 # Add the column to the treeview
349                 textrenderer = gtk.CellRendererText()
350                 numberColumn = gtk.TreeViewColumn("Phone Numbers", textrenderer, text=0)
351                 self._typeview.append_column(numberColumn)
352
353                 textrenderer = gtk.CellRendererText()
354                 typeColumn = gtk.TreeViewColumn("Phone Type", textrenderer, text=1)
355                 self._typeview.append_column(typeColumn)
356
357                 self._typeviewselection = self._typeview.get_selection()
358                 self._typeviewselection.set_mode(gtk.SELECTION_SINGLE)
359
360                 for phoneType, phoneNumber in contactDetails:
361                         display = " - ".join((phoneNumber, phoneType))
362                         display = phoneType
363                         row = (phoneNumber, display)
364                         self._typemodel.append(row)
365
366                 self._typeviewselection.select_iter(self._typemodel.get_iter_first())
367                 if message:
368                         self._message.set_markup(message)
369                         self._message.show()
370                 else:
371                         self._message.set_markup("")
372                         self._message.hide()
373
374                 if parent is not None:
375                         self._dialog.set_transient_for(parent)
376
377                 try:
378                         userResponse = self._dialog.run()
379                 finally:
380                         self._dialog.hide()
381
382                 if userResponse == gtk.RESPONSE_OK:
383                         phoneNumber = self._get_number()
384                         phoneNumber = make_ugly(phoneNumber)
385                 else:
386                         phoneNumber = ""
387                 if not phoneNumber:
388                         self._action = self.ACTION_CANCEL
389
390                 if self._action == self.ACTION_SEND_SMS:
391                         smsMessage = self._smsDialog.run(phoneNumber, message, parent)
392                         if not smsMessage:
393                                 phoneNumber = ""
394                                 self._action = self.ACTION_CANCEL
395                 else:
396                         smsMessage = ""
397
398                 self._typeviewselection.unselect_all()
399                 self._typeview.remove_column(numberColumn)
400                 self._typeview.remove_column(typeColumn)
401                 self._typeview.set_model(None)
402
403                 return self._action, phoneNumber, smsMessage
404
405         def _get_number(self):
406                 model, itr = self._typeviewselection.get_selected()
407                 if not itr:
408                         return ""
409
410                 phoneNumber = self._typemodel.get_value(itr, 0)
411                 return phoneNumber
412
413         def _on_phonetype_dial(self, *args):
414                 self._dialog.response(gtk.RESPONSE_OK)
415                 self._action = self.ACTION_DIAL
416
417         def _on_phonetype_send_sms(self, *args):
418                 self._dialog.response(gtk.RESPONSE_OK)
419                 self._action = self.ACTION_SEND_SMS
420
421         def _on_phonetype_select(self, *args):
422                 self._dialog.response(gtk.RESPONSE_OK)
423                 self._action = self.ACTION_SELECT
424
425         def _on_phonetype_cancel(self, *args):
426                 self._dialog.response(gtk.RESPONSE_CANCEL)
427                 self._action = self.ACTION_CANCEL
428
429
430 class SmsEntryDialog(object):
431
432         MAX_CHAR = 160
433
434         def __init__(self, widgetTree, gcBackend):
435                 self._gcBackend = gcBackend
436                 self._widgetTree = widgetTree
437                 self._dialog = self._widgetTree.get_widget("smsDialog")
438
439                 self._smsButton = self._widgetTree.get_widget("sendSmsButton")
440                 self._smsButton.connect("clicked", self._on_send)
441
442                 self._cancelButton = self._widgetTree.get_widget("cancelSmsButton")
443                 self._cancelButton.connect("clicked", self._on_cancel)
444
445                 self._letterCountLabel = self._widgetTree.get_widget("smsLetterCount")
446                 self._message = self._widgetTree.get_widget("smsMessage")
447                 self._smsEntry = self._widgetTree.get_widget("smsEntry")
448                 self._smsEntry.get_buffer().connect("changed", self._on_entry_changed)
449
450         def run(self, number, message = "", parent = None):
451                 if message:
452                         self._message.set_markup(message)
453                         self._message.show()
454                 else:
455                         self._message.set_markup("")
456                         self._message.hide()
457                 self._smsEntry.get_buffer().set_text("")
458                 self._update_letter_count()
459
460                 if parent is not None:
461                         self._dialog.set_transient_for(parent)
462
463                 try:
464                         userResponse = self._dialog.run()
465                 finally:
466                         self._dialog.hide()
467
468                 if userResponse == gtk.RESPONSE_OK:
469                         entryBuffer = self._smsEntry.get_buffer()
470                         enteredMessage = entryBuffer.get_text(entryBuffer.get_start_iter(), entryBuffer.get_end_iter())
471                         enteredMessage = enteredMessage[0:self.MAX_CHAR]
472                 else:
473                         enteredMessage = ""
474
475                 return enteredMessage
476
477         def _update_letter_count(self, *args):
478                 entryLength = self._smsEntry.get_buffer().get_char_count()
479                 charsLeft = self.MAX_CHAR - entryLength
480                 self._letterCountLabel.set_text(str(charsLeft))
481                 if charsLeft < 0:
482                         self._smsButton.set_sensitive(False)
483                 else:
484                         self._smsButton.set_sensitive(True)
485
486         def _on_entry_changed(self, *args):
487                 self._update_letter_count()
488
489         def _on_send(self, *args):
490                 self._dialog.response(gtk.RESPONSE_OK)
491
492         def _on_cancel(self, *args):
493                 self._dialog.response(gtk.RESPONSE_CANCEL)
494
495
496 class Dialpad(object):
497
498         def __init__(self, widgetTree, errorDisplay):
499                 self._errorDisplay = errorDisplay
500                 self._numberdisplay = widgetTree.get_widget("numberdisplay")
501                 self._dialButton = widgetTree.get_widget("dial")
502                 self._phonenumber = ""
503                 self._prettynumber = ""
504                 self._clearall_id = None
505
506                 callbackMapping = {
507                         "on_dial_clicked": self._on_dial_clicked,
508                         "on_digit_clicked": self._on_digit_clicked,
509                         "on_clear_number": self._on_clear_number,
510                         "on_back_clicked": self._on_backspace,
511                         "on_back_pressed": self._on_back_pressed,
512                         "on_back_released": self._on_back_released,
513                 }
514                 widgetTree.signal_autoconnect(callbackMapping)
515
516         def enable(self):
517                 self._dialButton.grab_focus()
518
519         def disable(self):
520                 pass
521
522         def number_selected(self, action, number, message):
523                 """
524                 @note Actual dial function is patched in later
525                 """
526                 raise NotImplementedError
527
528         def get_number(self):
529                 return self._phonenumber
530
531         def set_number(self, number):
532                 """
533                 Set the callback phonenumber
534                 """
535                 try:
536                         self._phonenumber = make_ugly(number)
537                         self._prettynumber = make_pretty(self._phonenumber)
538                         self._numberdisplay.set_label("<span size='30000' weight='bold'>%s</span>" % (self._prettynumber))
539                 except TypeError, e:
540                         self._errorDisplay.push_exception(e)
541
542         def clear(self):
543                 self.set_number("")
544
545         @staticmethod
546         def name():
547                 return "Dialpad"
548
549         def load_settings(self, config, section):
550                 pass
551
552         def save_settings(self, config, section):
553                 """
554                 @note Thread Agnostic
555                 """
556                 pass
557
558         def _on_dial_clicked(self, widget):
559                 action = PhoneTypeSelector.ACTION_DIAL
560                 phoneNumber = self.get_number()
561                 message = ""
562                 self.number_selected(action, phoneNumber, message)
563
564         def _on_clear_number(self, *args):
565                 self.clear()
566
567         def _on_digit_clicked(self, widget):
568                 self.set_number(self._phonenumber + widget.get_name()[-1])
569
570         def _on_backspace(self, widget):
571                 self.set_number(self._phonenumber[:-1])
572
573         def _on_clearall(self):
574                 self.clear()
575                 return False
576
577         def _on_back_pressed(self, widget):
578                 self._clearall_id = gobject.timeout_add(1000, self._on_clearall)
579
580         def _on_back_released(self, widget):
581                 if self._clearall_id is not None:
582                         gobject.source_remove(self._clearall_id)
583                 self._clearall_id = None
584
585
586 class AccountInfo(object):
587
588         def __init__(self, widgetTree, backend, errorDisplay):
589                 self._errorDisplay = errorDisplay
590                 self._backend = backend
591                 self._isPopulated = False
592
593                 self._callbackList = gtk.ListStore(gobject.TYPE_STRING)
594                 self._accountViewNumberDisplay = widgetTree.get_widget("gcnumber_display")
595                 self._callbackCombo = widgetTree.get_widget("callbackcombo")
596                 self._onCallbackentryChangedId = 0
597
598                 self._defaultCallback = ""
599
600         def enable(self):
601                 assert self._backend.is_authed()
602                 self._accountViewNumberDisplay.set_use_markup(True)
603                 self.set_account_number("")
604                 self._callbackList.clear()
605                 self._onCallbackentryChangedId = self._callbackCombo.get_child().connect("changed", self._on_callbackentry_changed)
606                 self.update(force=True)
607
608         def disable(self):
609                 self._callbackCombo.get_child().disconnect(self._onCallbackentryChangedId)
610
611                 self.clear()
612
613                 self._callbackList.clear()
614
615         def get_selected_callback_number(self):
616                 return make_ugly(self._callbackCombo.get_child().get_text())
617
618         def set_account_number(self, number):
619                 """
620                 Displays current account number
621                 """
622                 self._accountViewNumberDisplay.set_label("<span size='23000' weight='bold'>%s</span>" % (number))
623
624         def update(self, force = False):
625                 if not force and self._isPopulated:
626                         return
627                 self._populate_callback_combo()
628                 self.set_account_number(self._backend.get_account_number())
629
630         def clear(self):
631                 self._callbackCombo.get_child().set_text("")
632                 self.set_account_number("")
633                 self._isPopulated = False
634
635         @staticmethod
636         def name():
637                 return "Account Info"
638
639         def load_settings(self, config, section):
640                 self._defaultCallback = config.get(section, "callback")
641
642         def save_settings(self, config, section):
643                 """
644                 @note Thread Agnostic
645                 """
646                 callback = self.get_selected_callback_number()
647                 config.set(section, "callback", callback)
648
649         def _populate_callback_combo(self):
650                 self._isPopulated = True
651                 self._callbackList.clear()
652                 try:
653                         callbackNumbers = self._backend.get_callback_numbers()
654                 except RuntimeError, e:
655                         self._errorDisplay.push_exception(e)
656                         self._isPopulated = False
657                         return
658
659                 for number, description in callbackNumbers.iteritems():
660                         self._callbackList.append((make_pretty(number),))
661
662                 self._callbackCombo.set_model(self._callbackList)
663                 self._callbackCombo.set_text_column(0)
664                 #callbackNumber = self._backend.get_callback_number()
665                 callbackNumber = self._defaultCallback
666                 self._callbackCombo.get_child().set_text(make_pretty(callbackNumber))
667
668         def _set_callback_number(self, number):
669                 """
670                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
671                 """
672                 try:
673                         if not self._backend.is_valid_syntax(number):
674                                 self._errorDisplay.push_message("%s is not a valid callback number" % number)
675                         elif number == self._backend.get_callback_number():
676                                 warnings.warn("Callback number already is %s" % self._backend.get_callback_number(), UserWarning, 2)
677                         else:
678                                 self._backend.set_callback_number(number)
679                                 warnings.warn("Callback number set to %s" % self._backend.get_callback_number(), UserWarning, 2)
680                 except RuntimeError, e:
681                         self._errorDisplay.push_exception(e)
682
683         def _on_callbackentry_changed(self, *args):
684                 """
685                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
686                 """
687                 text = self.get_selected_callback_number()
688                 self._set_callback_number(text)
689
690
691 class RecentCallsView(object):
692
693         NUMBER_IDX = 0
694         DATE_IDX = 1
695         ACTION_IDX = 2
696         FROM_IDX = 3
697
698         def __init__(self, widgetTree, backend, errorDisplay):
699                 self._errorDisplay = errorDisplay
700                 self._backend = backend
701
702                 self._isPopulated = False
703                 self._recentmodel = gtk.ListStore(
704                         gobject.TYPE_STRING, # number
705                         gobject.TYPE_STRING, # date
706                         gobject.TYPE_STRING, # action
707                         gobject.TYPE_STRING, # from
708                 )
709                 self._recentview = widgetTree.get_widget("recentview")
710                 self._recentviewselection = None
711                 self._onRecentviewRowActivatedId = 0
712
713                 textrenderer = gtk.CellRendererText()
714                 textrenderer.set_property("yalign", 0)
715                 self._dateColumn = gtk.TreeViewColumn("Date")
716                 self._dateColumn.pack_start(textrenderer, expand=True)
717                 self._dateColumn.add_attribute(textrenderer, "text", self.DATE_IDX)
718
719                 textrenderer = gtk.CellRendererText()
720                 textrenderer.set_property("yalign", 0)
721                 self._actionColumn = gtk.TreeViewColumn("Action")
722                 self._actionColumn.pack_start(textrenderer, expand=True)
723                 self._actionColumn.add_attribute(textrenderer, "text", self.ACTION_IDX)
724
725                 textrenderer = gtk.CellRendererText()
726                 textrenderer.set_property("yalign", 0)
727                 self._fromColumn = gtk.TreeViewColumn("From")
728                 self._fromColumn.pack_start(textrenderer, expand=True)
729                 self._fromColumn.add_attribute(textrenderer, "text", self.FROM_IDX)
730                 self._fromColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
731
732                 self._window = gtk_toolbox.find_parent_window(self._recentview)
733                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
734
735         def enable(self):
736                 assert self._backend.is_authed()
737                 self._recentview.set_model(self._recentmodel)
738
739                 self._recentview.append_column(self._dateColumn)
740                 self._recentview.append_column(self._actionColumn)
741                 self._recentview.append_column(self._fromColumn)
742                 self._recentviewselection = self._recentview.get_selection()
743                 self._recentviewselection.set_mode(gtk.SELECTION_SINGLE)
744
745                 self._onRecentviewRowActivatedId = self._recentview.connect("row-activated", self._on_recentview_row_activated)
746
747         def disable(self):
748                 self._recentview.disconnect(self._onRecentviewRowActivatedId)
749
750                 self.clear()
751
752                 self._recentview.remove_column(self._dateColumn)
753                 self._recentview.remove_column(self._actionColumn)
754                 self._recentview.remove_column(self._fromColumn)
755                 self._recentview.set_model(None)
756
757         def number_selected(self, action, number, message):
758                 """
759                 @note Actual dial function is patched in later
760                 """
761                 raise NotImplementedError
762
763         def update(self, force = False):
764                 if not force and self._isPopulated:
765                         return
766                 backgroundPopulate = threading.Thread(target=self._idly_populate_recentview)
767                 backgroundPopulate.setDaemon(True)
768                 backgroundPopulate.start()
769
770         def clear(self):
771                 self._isPopulated = False
772                 self._recentmodel.clear()
773
774         @staticmethod
775         def name():
776                 return "Recent Calls"
777
778         def load_settings(self, config, section):
779                 pass
780
781         def save_settings(self, config, section):
782                 """
783                 @note Thread Agnostic
784                 """
785                 pass
786
787         def _idly_populate_recentview(self):
788                 self._isPopulated = True
789                 self._recentmodel.clear()
790
791                 try:
792                         recentItems = self._backend.get_recent()
793                 except RuntimeError, e:
794                         self._errorDisplay.push_exception_with_lock(e)
795                         self._isPopulated = False
796                         recentItems = []
797
798                 for personName, phoneNumber, date, action in recentItems:
799                         if not personName:
800                                 personName = "Unknown"
801                         description = "%s (%s)" % (phoneNumber, personName)
802                         item = (phoneNumber, date, action.capitalize(), description)
803                         with gtk_toolbox.gtk_lock():
804                                 self._recentmodel.append(item)
805
806                 return False
807
808         def _on_recentview_row_activated(self, treeview, path, view_column):
809                 model, itr = self._recentviewselection.get_selected()
810                 if not itr:
811                         return
812
813                 number = self._recentmodel.get_value(itr, self.NUMBER_IDX)
814                 number = make_ugly(number)
815                 contactPhoneNumbers = [("Phone", number)]
816                 description = self._recentmodel.get_value(itr, self.FROM_IDX)
817
818                 action, phoneNumber, message = self._phoneTypeSelector.run(
819                         contactPhoneNumbers,
820                         message = description,
821                         parent = self._window,
822                 )
823                 if action == PhoneTypeSelector.ACTION_CANCEL:
824                         return
825                 assert phoneNumber
826
827                 self.number_selected(action, phoneNumber, message)
828                 self._recentviewselection.unselect_all()
829
830
831 class MessagesView(object):
832
833         NUMBER_IDX = 0
834         DATE_IDX = 1
835         HEADER_IDX = 2
836         MESSAGE_IDX = 3
837
838         def __init__(self, widgetTree, backend, errorDisplay):
839                 self._errorDisplay = errorDisplay
840                 self._backend = backend
841
842                 self._isPopulated = False
843                 self._messagemodel = gtk.ListStore(
844                         gobject.TYPE_STRING, # number
845                         gobject.TYPE_STRING, # date
846                         gobject.TYPE_STRING, # header
847                         gobject.TYPE_STRING, # message
848                 )
849                 self._messageview = widgetTree.get_widget("messages_view")
850                 self._messageviewselection = None
851                 self._onMessageviewRowActivatedId = 0
852
853                 textrenderer = gtk.CellRendererText()
854                 textrenderer.set_property("yalign", 0)
855                 self._dateColumn = gtk.TreeViewColumn("Date")
856                 self._dateColumn.pack_start(textrenderer, expand=True)
857                 self._dateColumn.add_attribute(textrenderer, "markup", self.DATE_IDX)
858
859                 textrenderer = gtk.CellRendererText()
860                 textrenderer.set_property("yalign", 0)
861                 self._headerColumn = gtk.TreeViewColumn("From")
862                 self._headerColumn.pack_start(textrenderer, expand=True)
863                 self._headerColumn.add_attribute(textrenderer, "markup", self.HEADER_IDX)
864
865                 textrenderer = gtk.CellRendererText()
866                 textrenderer.set_property("yalign", 0)
867                 self._messageColumn = gtk.TreeViewColumn("Messages")
868                 self._messageColumn.pack_start(textrenderer, expand=True)
869                 self._messageColumn.add_attribute(textrenderer, "markup", self.MESSAGE_IDX)
870                 self._messageColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
871
872                 self._window = gtk_toolbox.find_parent_window(self._messageview)
873                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
874
875         def enable(self):
876                 assert self._backend.is_authed()
877                 self._messageview.set_model(self._messagemodel)
878
879                 self._messageview.append_column(self._dateColumn)
880                 self._messageview.append_column(self._headerColumn)
881                 self._messageview.append_column(self._messageColumn)
882                 self._messageviewselection = self._messageview.get_selection()
883                 self._messageviewselection.set_mode(gtk.SELECTION_SINGLE)
884
885                 self._onMessageviewRowActivatedId = self._messageview.connect("row-activated", self._on_messageview_row_activated)
886
887         def disable(self):
888                 self._messageview.disconnect(self._onMessageviewRowActivatedId)
889
890                 self.clear()
891
892                 self._messageview.remove_column(self._dateColumn)
893                 self._messageview.remove_column(self._headerColumn)
894                 self._messageview.remove_column(self._messageColumn)
895                 self._messageview.set_model(None)
896
897         def number_selected(self, action, number, message):
898                 """
899                 @note Actual dial function is patched in later
900                 """
901                 raise NotImplementedError
902
903         def update(self, force = False):
904                 if not force and self._isPopulated:
905                         return
906                 backgroundPopulate = threading.Thread(target=self._idly_populate_messageview)
907                 backgroundPopulate.setDaemon(True)
908                 backgroundPopulate.start()
909
910         def clear(self):
911                 self._isPopulated = False
912                 self._messagemodel.clear()
913
914         @staticmethod
915         def name():
916                 return "Messages"
917
918         def load_settings(self, config, section):
919                 pass
920
921         def save_settings(self, config, section):
922                 """
923                 @note Thread Agnostic
924                 """
925                 pass
926
927         def _idly_populate_messageview(self):
928                 self._isPopulated = True
929                 self._messagemodel.clear()
930
931                 try:
932                         messageItems = self._backend.get_messages()
933                 except RuntimeError, e:
934                         self._errorDisplay.push_exception_with_lock(e)
935                         self._isPopulated = False
936                         messageItems = []
937
938                 for header, number, relativeDate, message in messageItems:
939                         number = make_ugly(number)
940                         row = (number, relativeDate, header, message)
941                         with gtk_toolbox.gtk_lock():
942                                 self._messagemodel.append(row)
943
944                 return False
945
946         def _on_messageview_row_activated(self, treeview, path, view_column):
947                 model, itr = self._messageviewselection.get_selected()
948                 if not itr:
949                         return
950
951                 contactPhoneNumbers = [("Phone", self._messagemodel.get_value(itr, self.NUMBER_IDX))]
952                 description = self._messagemodel.get_value(itr, self.MESSAGE_IDX)
953
954                 action, phoneNumber, message = self._phoneTypeSelector.run(
955                         contactPhoneNumbers,
956                         message = description,
957                         parent = self._window,
958                 )
959                 if action == PhoneTypeSelector.ACTION_CANCEL:
960                         return
961                 assert phoneNumber
962
963                 self.number_selected(action, phoneNumber, message)
964                 self._messageviewselection.unselect_all()
965
966
967 class ContactsView(object):
968
969         def __init__(self, widgetTree, backend, errorDisplay):
970                 self._errorDisplay = errorDisplay
971                 self._backend = backend
972
973                 self._addressBook = None
974                 self._addressBookFactories = [DummyAddressBook()]
975
976                 self._booksList = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
977                 self._booksSelectionBox = widgetTree.get_widget("addressbook_combo")
978
979                 self._isPopulated = False
980                 self._contactsmodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
981                 self._contactsviewselection = None
982                 self._contactsview = widgetTree.get_widget("contactsview")
983
984                 self._contactColumn = gtk.TreeViewColumn("Contact")
985                 displayContactSource = False
986                 if displayContactSource:
987                         textrenderer = gtk.CellRendererText()
988                         self._contactColumn.pack_start(textrenderer, expand=False)
989                         self._contactColumn.add_attribute(textrenderer, 'text', 0)
990                 textrenderer = gtk.CellRendererText()
991                 self._contactColumn.pack_start(textrenderer, expand=True)
992                 self._contactColumn.add_attribute(textrenderer, 'text', 1)
993                 textrenderer = gtk.CellRendererText()
994                 self._contactColumn.pack_start(textrenderer, expand=True)
995                 self._contactColumn.add_attribute(textrenderer, 'text', 4)
996                 self._contactColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
997                 self._contactColumn.set_sort_column_id(1)
998                 self._contactColumn.set_visible(True)
999
1000                 self._onContactsviewRowActivatedId = 0
1001                 self._onAddressbookComboChangedId = 0
1002                 self._window = gtk_toolbox.find_parent_window(self._contactsview)
1003                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
1004
1005         def enable(self):
1006                 assert self._backend.is_authed()
1007
1008                 self._contactsview.set_model(self._contactsmodel)
1009                 self._contactsview.append_column(self._contactColumn)
1010                 self._contactsviewselection = self._contactsview.get_selection()
1011                 self._contactsviewselection.set_mode(gtk.SELECTION_SINGLE)
1012
1013                 self._booksList.clear()
1014                 for (factoryId, bookId), (factoryName, bookName) in self.get_addressbooks():
1015                         if factoryName and bookName:
1016                                 entryName = "%s: %s" % (factoryName, bookName)
1017                         elif factoryName:
1018                                 entryName = factoryName
1019                         elif bookName:
1020                                 entryName = bookName
1021                         else:
1022                                 entryName = "Bad name (%d)" % factoryId
1023                         row = (str(factoryId), bookId, entryName)
1024                         self._booksList.append(row)
1025
1026                 self._booksSelectionBox.set_model(self._booksList)
1027                 cell = gtk.CellRendererText()
1028                 self._booksSelectionBox.pack_start(cell, True)
1029                 self._booksSelectionBox.add_attribute(cell, 'text', 2)
1030                 self._booksSelectionBox.set_active(0)
1031
1032                 self._onContactsviewRowActivatedId = self._contactsview.connect("row-activated", self._on_contactsview_row_activated)
1033                 self._onAddressbookComboChangedId = self._booksSelectionBox.connect("changed", self._on_addressbook_combo_changed)
1034
1035         def disable(self):
1036                 self._contactsview.disconnect(self._onContactsviewRowActivatedId)
1037                 self._booksSelectionBox.disconnect(self._onAddressbookComboChangedId)
1038
1039                 self.clear()
1040
1041                 self._booksSelectionBox.clear()
1042                 self._booksSelectionBox.set_model(None)
1043                 self._contactsview.set_model(None)
1044                 self._contactsview.remove_column(self._contactColumn)
1045
1046         def number_selected(self, action, number, message):
1047                 """
1048                 @note Actual dial function is patched in later
1049                 """
1050                 raise NotImplementedError
1051
1052         def get_addressbooks(self):
1053                 """
1054                 @returns Iterable of ((Factory Id, Book Id), (Factory Name, Book Name))
1055                 """
1056                 for i, factory in enumerate(self._addressBookFactories):
1057                         for bookFactory, bookId, bookName in factory.get_addressbooks():
1058                                 yield (i, bookId), (factory.factory_name(), bookName)
1059
1060         def open_addressbook(self, bookFactoryId, bookId):
1061                 self._addressBook = self._addressBookFactories[bookFactoryId].open_addressbook(bookId)
1062                 self.update(force=True)
1063
1064         def update(self, force = False):
1065                 if not force and self._isPopulated:
1066                         return
1067                 backgroundPopulate = threading.Thread(target=self._idly_populate_contactsview)
1068                 backgroundPopulate.setDaemon(True)
1069                 backgroundPopulate.start()
1070
1071         def clear(self):
1072                 self._isPopulated = False
1073                 self._contactsmodel.clear()
1074
1075         def clear_caches(self):
1076                 for factory in self._addressBookFactories:
1077                         factory.clear_caches()
1078                 self._addressBook.clear_caches()
1079
1080         def append(self, book):
1081                 self._addressBookFactories.append(book)
1082
1083         def extend(self, books):
1084                 self._addressBookFactories.extend(books)
1085
1086         @staticmethod
1087         def name():
1088                 return "Contacts"
1089
1090         def load_settings(self, config, section):
1091                 pass
1092
1093         def save_settings(self, config, section):
1094                 """
1095                 @note Thread Agnostic
1096                 """
1097                 pass
1098
1099         def _idly_populate_contactsview(self):
1100                 self._isPopulated = True
1101                 self.clear()
1102
1103                 # completely disable updating the treeview while we populate the data
1104                 self._contactsview.freeze_child_notify()
1105                 self._contactsview.set_model(None)
1106
1107                 addressBook = self._addressBook
1108                 try:
1109                         contacts = addressBook.get_contacts()
1110                 except RuntimeError, e:
1111                         contacts = []
1112                         self._isPopulated = False
1113                         self._errorDisplay.push_exception_with_lock(e)
1114                 for contactId, contactName in contacts:
1115                         contactType = (addressBook.contact_source_short_name(contactId), )
1116                         self._contactsmodel.append(contactType + (contactName, "", contactId) + ("", ))
1117
1118                 # restart the treeview data rendering
1119                 self._contactsview.set_model(self._contactsmodel)
1120                 self._contactsview.thaw_child_notify()
1121                 return False
1122
1123         def _on_addressbook_combo_changed(self, *args, **kwds):
1124                 itr = self._booksSelectionBox.get_active_iter()
1125                 if itr is None:
1126                         return
1127                 factoryId = int(self._booksList.get_value(itr, 0))
1128                 bookId = self._booksList.get_value(itr, 1)
1129                 self.open_addressbook(factoryId, bookId)
1130
1131         def _on_contactsview_row_activated(self, treeview, path, view_column):
1132                 model, itr = self._contactsviewselection.get_selected()
1133                 if not itr:
1134                         return
1135
1136                 contactId = self._contactsmodel.get_value(itr, 3)
1137                 contactName = self._contactsmodel.get_value(itr, 1)
1138                 try:
1139                         contactDetails = self._addressBook.get_contact_details(contactId)
1140                 except RuntimeError, e:
1141                         contactDetails = []
1142                         self._errorDisplay.push_exception(e)
1143                 contactPhoneNumbers = [phoneNumber for phoneNumber in contactDetails]
1144
1145                 if len(contactPhoneNumbers) == 0:
1146                         return
1147
1148                 action, phoneNumber, message = self._phoneTypeSelector.run(
1149                         contactPhoneNumbers,
1150                         message = contactName,
1151                         parent = self._window,
1152                 )
1153                 if action == PhoneTypeSelector.ACTION_CANCEL:
1154                         return
1155                 assert phoneNumber
1156
1157                 self.number_selected(action, phoneNumber, message)
1158                 self._contactsviewselection.unselect_all()