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