root/trunk/src/conversation_textview.py

Revision 10729, 45.2 kB (checked in by asterix, 22 hours ago)

coding standard

Line 
1# -*- coding:utf-8 -*-
2## src/conversation_textview.py
3##
4## Copyright (C) 2005 Norman Rasmussen <norman AT rasmussen.co.za>
5## Copyright (C) 2005-2006 Alex Mauer <hawke AT hawkesnest.net>
6##                         Travis Shirk <travis AT pobox.com>
7## Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
8## Copyright (C) 2005-2008 Yann Leboulanger <asterix AT lagaule.org>
9## Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
10## Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
11## Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org>
12##                    Julien Pivotto <roidelapluie AT gmail.com>
13##                    Stephan Erb <steve-e AT h3c.de>
14##
15## This file is part of Gajim.
16##
17## Gajim is free software; you can redistribute it and/or modify
18## it under the terms of the GNU General Public License as published
19## by the Free Software Foundation; version 3 only.
20##
21## Gajim is distributed in the hope that it will be useful,
22## but WITHOUT ANY WARRANTY; without even the implied warranty of
23## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24## GNU General Public License for more details.
25##
26## You should have received a copy of the GNU General Public License
27## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
28##
29
30import random
31from tempfile import gettempdir
32from subprocess import Popen
33from threading import Timer # for smooth scrolling
34
35import gtk
36import pango
37import gobject
38import time
39import os
40import tooltips
41import dialogs
42import locale
43import Queue
44
45import gtkgui_helpers
46from common import gajim
47from common import helpers
48from calendar import timegm
49from common.fuzzyclock import FuzzyClock
50
51from htmltextview import HtmlTextView
52from common.exceptions import GajimGeneralException
53from common.exceptions import LatexError
54
55NOT_SHOWN = 0
56ALREADY_RECEIVED = 1
57SHOWN = 2
58
59def is_selection_modified(mark):
60        name = mark.get_name()
61        if name and name in ('selection_bound', 'insert'):
62                return True
63        else:
64                return False
65
66def has_focus(widget):
67        return widget.flags() & gtk.HAS_FOCUS == gtk.HAS_FOCUS
68
69class TextViewImage(gtk.Image):
70
71        def __init__(self, anchor):
72                super(TextViewImage, self).__init__()
73                self.anchor = anchor
74                self._selected = False
75                self._disconnect_funcs = []
76                self.connect('parent-set', self.on_parent_set)
77                self.connect('expose-event', self.on_expose)
78
79        def _get_selected(self):
80                parent = self.get_parent()
81                if not parent or not self.anchor: return False
82                buffer = parent.get_buffer()
83                position = buffer.get_iter_at_child_anchor(self.anchor)
84                bounds = buffer.get_selection_bounds()
85                if bounds and position.in_range(*bounds):
86                        return True
87                else:
88                        return False
89
90        def get_state(self):
91                parent = self.get_parent()
92                if not parent:
93                        return gtk.STATE_NORMAL
94                if self._selected:
95                        if has_focus(parent):
96                                return gtk.STATE_SELECTED
97                        else:
98                                return gtk.STATE_ACTIVE
99                else:
100                        return gtk.STATE_NORMAL
101
102        def _update_selected(self):
103                selected = self._get_selected()
104                if self._selected != selected:
105                        self._selected = selected
106                        self.queue_draw()
107
108        def _do_connect(self, widget, signal, callback):
109                id = widget.connect(signal, callback)
110                def disconnect():
111                        widget.disconnect(id)
112                self._disconnect_funcs.append(disconnect)
113
114        def _disconnect_signals(self):
115                for func in self._disconnect_funcs:
116                        func()
117                self._disconnect_funcs = []
118
119        def on_parent_set(self, widget, old_parent):
120                parent = self.get_parent()
121                if not parent:
122                        self._disconnect_signals()
123                        return
124
125                self._do_connect(parent, 'style-set', self.do_queue_draw)
126                self._do_connect(parent, 'focus-in-event', self.do_queue_draw)
127                self._do_connect(parent, 'focus-out-event', self.do_queue_draw)
128
129                textbuf = parent.get_buffer()
130                self._do_connect(textbuf, 'mark-set', self.on_mark_set)
131                self._do_connect(textbuf, 'mark-deleted', self.on_mark_deleted)
132
133        def do_queue_draw(self, *args):
134                self.queue_draw()
135                return False
136
137        def on_mark_set(self, buf, iterat, mark):
138                self.on_mark_modified(mark)
139                return False
140
141        def on_mark_deleted(self, buf, mark):
142                self.on_mark_modified(mark)
143                return False
144
145        def on_mark_modified(self, mark):
146                if is_selection_modified(mark):
147                        self._update_selected()
148
149        def on_expose(self, widget, event):
150                state = self.get_state()
151                if state != gtk.STATE_NORMAL:
152                        gc = widget.get_style().base_gc[state]
153                        area = widget.allocation
154                        widget.window.draw_rectangle(gc, True, area.x, area.y,
155                                area.width, area.height)
156                return False
157
158
159class ConversationTextview:
160        '''Class for the conversation textview (where user reads already said
161        messages) for chat/groupchat windows'''
162
163        FOCUS_OUT_LINE_PIXBUF = gtk.gdk.pixbuf_new_from_file(os.path.join(
164                gajim.DATA_DIR, 'pixmaps', 'muc_separator.png'))
165        XEP0184_WARNING_PIXBUF = gtk.gdk.pixbuf_new_from_file(os.path.join(
166                gajim.DATA_DIR, 'pixmaps', 'receipt_missing.png'))
167
168        # smooth scroll constants
169        MAX_SCROLL_TIME = 0.4 # seconds
170        SCROLL_DELAY = 33 # milliseconds
171
172        def __init__(self, account, used_in_history_window = False):
173                '''if used_in_history_window is True, then we do not show
174                Clear menuitem in context menu'''
175                self.used_in_history_window = used_in_history_window
176
177                # no need to inherit TextView, use it as atrribute is safer
178                self.tv = HtmlTextView()
179                self.tv.html_hyperlink_handler = self.html_hyperlink_handler
180
181                # set properties
182                self.tv.set_border_width(1)
183                self.tv.set_accepts_tab(True)
184                self.tv.set_editable(False)
185                self.tv.set_cursor_visible(False)
186                self.tv.set_wrap_mode(gtk.WRAP_WORD_CHAR)
187                self.tv.set_left_margin(2)
188                self.tv.set_right_margin(2)
189                self.handlers = {}
190                self.images = []
191                self.image_cache = {}
192                self.xep0184_marks = {}
193                self.xep0184_shown = {}
194
195                # It's True when we scroll in the code, so we can detect scroll from user
196                self.auto_scrolling = False
197
198                # connect signals
199                id = self.tv.connect('motion_notify_event',
200                        self.on_textview_motion_notify_event)
201                self.handlers[id] = self.tv
202                id = self.tv.connect('populate_popup', self.on_textview_populate_popup)
203                self.handlers[id] = self.tv
204                id = self.tv.connect('button_press_event',
205                        self.on_textview_button_press_event)
206                self.handlers[id] = self.tv
207
208                id = self.tv.connect('expose-event',
209                        self.on_textview_expose_event)
210                self.handlers[id] = self.tv
211
212
213                self.account = account
214                self.change_cursor = None
215                self.last_time_printout = 0
216
217                font = pango.FontDescription(gajim.config.get('conversation_font'))
218                self.tv.modify_font(font)
219                buffer = self.tv.get_buffer()
220                end_iter = buffer.get_end_iter()
221                buffer.create_mark('end', end_iter, False)
222
223                self.tagIn = buffer.create_tag('incoming')
224                color = gajim.config.get('inmsgcolor')
225                self.tagIn.set_property('foreground', color)
226                self.tagOut = buffer.create_tag('outgoing')
227                color = gajim.config.get('outmsgcolor')
228                self.tagOut.set_property('foreground', color)
229                self.tagStatus = buffer.create_tag('status')
230                color = gajim.config.get('statusmsgcolor')
231                self.tagStatus.set_property('foreground', color)
232
233                colors = gajim.config.get('gc_nicknames_colors')
234                colors = colors.split(':')
235                for i,color in enumerate(colors):
236                        tagname = 'gc_nickname_color_' + str(i)
237                        tag = buffer.create_tag(tagname)
238                        tag.set_property('foreground', color)
239
240                tag = buffer.create_tag('marked')
241                color = gajim.config.get('markedmsgcolor')
242                tag.set_property('foreground', color)
243                tag.set_property('weight', pango.WEIGHT_BOLD)
244
245                tag = buffer.create_tag('time_sometimes')
246                tag.set_property('foreground', 'darkgrey')
247                tag.set_property('scale', pango.SCALE_SMALL)
248                tag.set_property('justification', gtk.JUSTIFY_CENTER)
249
250                tag = buffer.create_tag('small')
251                tag.set_property('scale', pango.SCALE_SMALL)
252
253                tag = buffer.create_tag('restored_message')
254                color = gajim.config.get('restored_messages_color')
255                tag.set_property('foreground', color)
256
257                self.tagURL = buffer.create_tag('url')
258                color = gajim.config.get('urlmsgcolor')
259                self.tagURL.set_property('foreground', color)
260                self.tagURL.set_property('underline', pango.UNDERLINE_SINGLE)
261                id = self.tagURL.connect('event', self.hyperlink_handler, 'url')
262                self.handlers[id] = self.tagURL
263
264                self.tagMail = buffer.create_tag('mail')
265                self.tagMail.set_property('foreground', color)
266                self.tagMail.set_property('underline', pango.UNDERLINE_SINGLE)
267                id = self.tagMail.connect('event', self.hyperlink_handler, 'mail')
268                self.handlers[id] = self.tagMail
269
270                self.tagXMPP = buffer.create_tag('xmpp')
271                self.tagXMPP.set_property('foreground', color)
272                self.tagXMPP.set_property('underline', pango.UNDERLINE_SINGLE)
273                id = self.tagXMPP.connect('event', self.hyperlink_handler, 'xmpp')
274                self.handlers[id] = self.tagXMPP
275
276                self.tagSthAtSth = buffer.create_tag('sth_at_sth')
277                self.tagSthAtSth.set_property('foreground', color)
278                self.tagSthAtSth.set_property('underline', pango.UNDERLINE_SINGLE)
279                id = self.tagSthAtSth.connect('event', self.hyperlink_handler,
280                        'sth_at_sth')
281                self.handlers[id] = self.tagSthAtSth
282
283                tag = buffer.create_tag('bold')
284                tag.set_property('weight', pango.WEIGHT_BOLD)
285
286                tag = buffer.create_tag('italic')
287                tag.set_property('style', pango.STYLE_ITALIC)
288
289                tag = buffer.create_tag('underline')
290                tag.set_property('underline', pango.UNDERLINE_SINGLE)
291
292                buffer.create_tag('focus-out-line', justification = gtk.JUSTIFY_CENTER)
293
294                tag = buffer.create_tag('xep0184-warning')
295
296                # One mark at the begining then 2 marks between each lines
297                size = gajim.config.get('max_conversation_lines')
298                size = 2 * size - 1
299                self.marks_queue = Queue.Queue(size)
300
301                self.allow_focus_out_line = True
302                # holds a mark at the end of --- line
303                self.focus_out_end_mark = None
304
305                self.xep0184_warning_tooltip = tooltips.BaseTooltip()
306
307                self.line_tooltip = tooltips.BaseTooltip()
308                # use it for hr too
309                self.tv.focus_out_line_pixbuf = ConversationTextview.FOCUS_OUT_LINE_PIXBUF
310                self.smooth_id = None
311
312        def del_handlers(self):
313                for i in self.handlers.keys():
314                        if self.handlers[i].handler_is_connected(i):
315                                self.handlers[i].disconnect(i)
316                del self.handlers
317                self.tv.destroy()
318                #FIXME:
319                # self.line_tooltip.destroy()
320
321        def update_tags(self):
322                self.tagIn.set_property('foreground', gajim.config.get('inmsgcolor'))
323                self.tagOut.set_property('foreground', gajim.config.get('outmsgcolor'))
324                self.tagStatus.set_property('foreground',
325                        gajim.config.get('statusmsgcolor'))
326                self.tagURL.set_property('foreground', gajim.config.get('urlmsgcolor'))
327                self.tagMail.set_property('foreground', gajim.config.get('urlmsgcolor'))
328
329        def at_the_end(self):
330                buffer = self.tv.get_buffer()
331                end_iter = buffer.get_end_iter()
332                end_rect = self.tv.get_iter_location(end_iter)
333                visible_rect = self.tv.get_visible_rect()
334                if end_rect.y <= (visible_rect.y + visible_rect.height):
335                        return True
336                return False
337
338        # Smooth scrolling inspired by Pidgin code
339        def smooth_scroll(self):
340                parent = self.tv.get_parent()
341                if not parent:
342                        return False
343                vadj = parent.get_vadjustment()
344                max_val = vadj.upper - vadj.page_size + 1
345                cur_val = vadj.get_value()
346                # scroll by 1/3rd of remaining distance
347                onethird = cur_val + ((max_val - cur_val) / 3.0)
348                self.auto_scrolling = True
349                vadj.set_value(onethird)
350                self.auto_scrolling = False
351                if max_val - onethird < 0.01:
352                        self.smooth_id = None
353                        self.smooth_scroll_timer.cancel()
354                        return False
355                return True
356
357        def smooth_scroll_timeout(self):
358                gobject.idle_add(self.do_smooth_scroll_timeout)
359                return
360
361        def do_smooth_scroll_timeout(self):
362                if not self.smooth_id:
363                        # we finished scrolling
364                        return
365                gobject.source_remove(self.smooth_id)
366                self.smooth_id = None
367                parent = self.tv.get_parent()
368                if parent:
369                        vadj = parent.get_vadjustment()
370                        self.auto_scrolling = True
371                        vadj.set_value(vadj.upper - vadj.page_size + 1)
372                        self.auto_scrolling = False
373
374        def smooth_scroll_to_end(self):
375                if None != self.smooth_id: # already scrolling
376                        return False
377                self.smooth_id = gobject.timeout_add(self.SCROLL_DELAY,
378                        self.smooth_scroll)
379                self.smooth_scroll_timer = Timer(self.MAX_SCROLL_TIME,
380                        self.smooth_scroll_timeout)
381                self.smooth_scroll_timer.start()
382                return False
383
384        def scroll_to_end(self):
385                parent = self.tv.get_parent()
386                buffer = self.tv.get_buffer()
387                end_mark = buffer.get_mark('end')
388                if not end_mark:
389                        return False
390                self.auto_scrolling = True
391                self.tv.scroll_to_mark(end_mark, 0, True, 0, 1)
392                adjustment = parent.get_hadjustment()
393                adjustment.set_value(0)
394                self.auto_scrolling = False
395                return False # when called in an idle_add, just do it once
396
397        def bring_scroll_to_end(self, diff_y = 0,
398        use_smooth=gajim.config.get('use_smooth_scrolling')):
399                ''' scrolls to the end of textview if end is not visible '''
400                buffer = self.tv.get_buffer()
401                end_iter = buffer.get_end_iter()
402                end_rect = self.tv.get_iter_location(end_iter)
403                visible_rect = self.tv.get_visible_rect()
404                # scroll only if expected end is not visible
405                if end_rect.y >= (visible_rect.y + visible_rect.height + diff_y):
406                        if use_smooth:
407                                gobject.idle_add(self.smooth_scroll_to_end)
408                        else:
409                                gobject.idle_add(self.scroll_to_end_iter)
410
411        def scroll_to_end_iter(self):
412                buffer = self.tv.get_buffer()
413                end_iter = buffer.get_end_iter()
414                if not end_iter:
415                        return False
416                self.tv.scroll_to_iter(end_iter, 0, False, 1, 1)
417                return False # when called in an idle_add, just do it once
418
419        def stop_scrolling(self):
420                if self.smooth_id:
421                        gobject.source_remove(self.smooth_id)
422                        self.smooth_id = None
423                        self.smooth_scroll_timer.cancel()
424
425        def show_xep0184_warning(self, id_):
426                if id_ in self.xep0184_marks:
427                        return
428
429                buffer = self.tv.get_buffer()
430                buffer.begin_user_action()
431
432                self.xep0184_marks[id_] = buffer.create_mark(None,
433                        buffer.get_end_iter(), left_gravity=True)
434                self.xep0184_shown[id_] = NOT_SHOWN
435
436                def show_it():
437                        if (not id_ in self.xep0184_shown) or \
438                        self.xep0184_shown[id_] == ALREADY_RECEIVED:
439                                return False
440
441                        end_iter = buffer.get_iter_at_mark(
442                                self.xep0184_marks[id_])
443                        buffer.insert(end_iter, ' ')
444                        buffer.insert_pixbuf(end_iter,
445                                ConversationTextview.XEP0184_WARNING_PIXBUF)
446                        before_img_iter = buffer.get_iter_at_mark(
447                                self.xep0184_marks[id_])
448                        before_img_iter.forward_char()
449                        post_img_iter = before_img_iter.copy()
450                        post_img_iter.forward_char()
451                        buffer.apply_tag_by_name('xep0184-warning', before_img_iter,
452                                post_img_iter)
453
454                        self.xep0184_shown[id_] = SHOWN
455                        return False
456                gobject.timeout_add_seconds(3, show_it)
457
458                buffer.end_user_action()
459
460        def hide_xep0184_warning(self, id_):
461                if id_ not in self.xep0184_marks:
462                        return
463
464                if self.xep0184_shown[id_] == NOT_SHOWN:
465                        self.xep0184_shown[id_] = ALREADY_RECEIVED
466                        return
467
468                buffer = self.tv.get_buffer()
469                buffer.begin_user_action()
470
471                begin_iter = buffer.get_iter_at_mark(self.xep0184_marks[id_])
472
473                end_iter = begin_iter.copy()
474                # XXX: Is there a nicer way?
475                end_iter.forward_char()
476                end_iter.forward_char()
477
478                buffer.delete(begin_iter, end_iter)
479                buffer.delete_mark(self.xep0184_marks[id_])
480
481                buffer.end_user_action()
482
483                del self.xep0184_marks[id_]
484                del self.xep0184_shown[id_]
485
486        def show_focus_out_line(self):
487                if not self.allow_focus_out_line:
488                        # if room did not receive focus-in from the last time we added
489                        # --- line then do not readd
490                        return
491
492                print_focus_out_line = False
493                buffer = self.tv.get_buffer()
494
495                if self.focus_out_end_mark is None:
496                        # this happens only first time we focus out on this room
497                        print_focus_out_line = True
498
499                else:
500                        focus_out_end_iter = buffer.get_iter_at_mark(self.focus_out_end_mark)
501                        focus_out_end_iter_offset = focus_out_end_iter.get_offset()
502                        if focus_out_end_iter_offset != buffer.get_end_iter().get_offset():
503                                # this means after last-focus something was printed
504                                # (else end_iter's offset is the same as before)
505                                # only then print ---- line (eg. we avoid printing many following
506                                # ---- lines)
507                                print_focus_out_line = True
508
509                if print_focus_out_line and buffer.get_char_count() > 0:
510                        buffer.begin_user_action()
511
512                        # remove previous focus out line if such focus out line exists
513                        if self.focus_out_end_mark is not None:
514                                end_iter_for_previous_line = buffer.get_iter_at_mark(
515                                        self.focus_out_end_mark)
516                                begin_iter_for_previous_line = end_iter_for_previous_line.copy()
517                                # img_char+1 (the '\n')
518                                begin_iter_for_previous_line.backward_chars(2)
519
520                                # remove focus out line
521                                buffer.delete(begin_iter_for_previous_line,
522                                        end_iter_for_previous_line)
523                                buffer.delete_mark(self.focus_out_end_mark)
524
525                        # add the new focus out line
526                        end_iter = buffer.get_end_iter()
527                        buffer.insert(end_iter, '\n')
528                        buffer.insert_pixbuf(end_iter,
529                                ConversationTextview.FOCUS_OUT_LINE_PIXBUF)
530
531                        end_iter = buffer.get_end_iter()
532                        before_img_iter = end_iter.copy()
533                        # one char back (an image also takes one char)
534                        before_img_iter.backward_char()
535                        buffer.apply_tag_by_name('focus-out-line', before_img_iter, end_iter)
536
537                        self.allow_focus_out_line = False
538
539                        # update the iter we hold to make comparison the next time
540                        self.focus_out_end_mark = buffer.create_mark(None,
541                                buffer.get_end_iter(), left_gravity=True)
542
543                        buffer.end_user_action()
544
545                        # scroll to the end (via idle in case the scrollbar has appeared)
546                        gobject.idle_add(self.scroll_to_end)
547
548        def show_xep0184_warning_tooltip(self):
549                pointer = self.tv.get_pointer()
550                x, y = self.tv.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT,
551                        pointer[0], pointer[1])
552                tags = self.tv.get_iter_at_location(x, y).get_tags()
553                tag_table = self.tv.get_buffer().get_tag_table()
554                xep0184_warning = False
555                for tag in tags:
556                        if tag == tag_table.lookup('xep0184-warning'):
557                                xep0184_warning = True
558                                break
559                if xep0184_warning and not self.xep0184_warning_tooltip.win:
560                        # check if the current pointer is still over the line
561                        position = self.tv.window.get_origin()
562                        self.xep0184_warning_tooltip.show_tooltip(_('This icon indicates that '
563                                'this message has not yet\nbeen received by the remote end. '
564                                "If this icon stays\nfor a long time, it's likely the message got "
565                                'lost.'), 8, position[1] + pointer[1])
566
567        def show_line_tooltip(self):
568                pointer = self.tv.get_pointer()
569                x, y = self.tv.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT,
570                        pointer[0], pointer[1])
571                tags = self.tv.get_iter_at_location(x, y).get_tags()
572                tag_table = self.tv.get_buffer().get_tag_table()
573                over_line = False
574                for tag in tags:
575                        if tag == tag_table.lookup('focus-out-line'):
576                                over_line = True
577                                break
578                if over_line and not self.line_tooltip.win:
579                        # check if the current pointer is still over the line
580                        position = self.tv.window.get_origin()
581                        self.line_tooltip.show_tooltip(_('Text below this line is what has '
582                                'been said since the\nlast time you paid attention to this group '
583                                'chat'), 8, position[1] + pointer[1])
584
585        def on_textview_expose_event(self, widget, event):
586                expalloc = event.area
587                exp_x0 = expalloc.x
588                exp_y0 = expalloc.y
589                exp_x1 = exp_x0 + expalloc.width
590                exp_y1 = exp_y0 + expalloc.height
591
592                try:
593                        tryfirst = [self.image_cache[(exp_x0, exp_y0)]]
594                except KeyError:
595                        tryfirst = []
596
597                for image in tryfirst + self.images:
598                        imgalloc = image.allocation
599                        img_x0 = imgalloc.x
600                        img_y0 = imgalloc.y
601                        img_x1 = img_x0 + imgalloc.width
602                        img_y1 = img_y0 + imgalloc.height
603
604                        if img_x0 <= exp_x0 and img_y0 <= exp_y0 and \
605                        exp_x1 <= img_x1 and exp_y1 <= img_y1:
606                                self.image_cache[(img_x0, img_y0)] = image
607                                widget.propagate_expose(image, event)
608                                return True
609                return False
610
611        def on_textview_motion_notify_event(self, widget, event):
612                '''change the cursor to a hand when we are over a mail or an
613                url'''
614                pointer_x, pointer_y, spam = self.tv.window.get_pointer()
615                x, y = self.tv.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT,
616                        pointer_x, pointer_y)
617                tags = self.tv.get_iter_at_location(x, y).get_tags()
618                if self.change_cursor:
619                        self.tv.get_window(gtk.TEXT_WINDOW_TEXT).set_cursor(
620                                gtk.gdk.Cursor(gtk.gdk.XTERM))
621                        self.change_cursor = None
622                tag_table = self.tv.get_buffer().get_tag_table()
623                over_line = False
624                xep0184_warning = False