message_textview.py 15.3 KB
Newer Older
Philipp Hörist's avatar
Philipp Hörist committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
# Copyright (C) 2008-2009 Julien Pivotto <roidelapluie AT gmail.com>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
nkour's avatar
nkour committed
19

20
from gi.repository import Gtk
Yann Leboulanger's avatar
Yann Leboulanger committed
21
from gi.repository import GLib
22
from gi.repository import GObject
23
from gi.repository import Pango
Alexander Cherniuk's avatar
Alexander Cherniuk committed
24

25
from gajim.common import app
26
from gajim.common.i18n import _
27
from gajim.common.const import StyleAttr
Philipp Hörist's avatar
Philipp Hörist committed
28 29

from gajim.gtk.util import scroll_to_end
nkour's avatar
nkour committed
30

31
if app.is_installed('GSPELL'):
32
    from gi.repository import Gspell  # pylint: disable=ungrouped-imports
Philipp Hörist's avatar
Philipp Hörist committed
33 34


35
class MessageTextView(Gtk.TextView):
36 37 38 39
    """
    Class for the message textview (where user writes new messages) for
    chat/groupchat windows
    """
40
    __gsignals__ = {
Philipp Hörist's avatar
Philipp Hörist committed
41
        'text-changed': (GObject.SignalFlags.RUN_LAST, None, (Gtk.TextBuffer,))
42 43
    }

44
    UNDO_LIMIT = 20
Yann Leboulanger's avatar
Yann Leboulanger committed
45
    PLACEHOLDER = _('Write a message…')
46 47

    def __init__(self):
48
        Gtk.TextView.__init__(self)
49 50

        # set properties
51
        self.set_border_width(3)
52 53 54
        self.set_accepts_tab(True)
        self.set_editable(True)
        self.set_cursor_visible(True)
55
        self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
56 57 58 59
        self.set_left_margin(2)
        self.set_right_margin(2)
        self.set_pixels_above_lines(2)
        self.set_pixels_below_lines(2)
Philipp Hörist's avatar
Philipp Hörist committed
60
        self.get_style_context().add_class('gajim-conversation-font')
61 62 63 64 65

        # set undo list
        self.undo_list = []
        # needed to know if we undid something
        self.undo_pressed = False
Philipp Hörist's avatar
Philipp Hörist committed
66

67
        buffer_ = self.get_buffer()
68 69 70
        buffer_.connect('changed', self._on_buffer_changed)
        self._last_text = ''

71 72 73 74 75
        self.begin_tags = {}
        self.end_tags = {}
        self.color_tags = []
        self.fonts_tags = []
        self.other_tags = {}
76 77 78

        color = app.css_config.get_value(
            '.gajim-message-placeholder', StyleAttr.COLOR)
79
        self.placeholder_tag = buffer_.create_tag('placeholder')
80 81
        self.placeholder_tag.set_property('foreground', color)

82
        self.other_tags['bold'] = buffer_.create_tag('bold')
83
        self.other_tags['bold'].set_property('weight', Pango.Weight.BOLD)
84 85
        self.begin_tags['bold'] = '<strong>'
        self.end_tags['bold'] = '</strong>'
86
        self.other_tags['italic'] = buffer_.create_tag('italic')
87
        self.other_tags['italic'].set_property('style', Pango.Style.ITALIC)
88 89
        self.begin_tags['italic'] = '<em>'
        self.end_tags['italic'] = '</em>'
90
        self.other_tags['underline'] = buffer_.create_tag('underline')
91
        self.other_tags['underline'].set_property('underline', Pango.Underline.SINGLE)
92 93
        self.begin_tags['underline'] = '<span style="text-decoration: underline;">'
        self.end_tags['underline'] = '</span>'
94
        self.other_tags['strike'] = buffer_.create_tag('strike')
95 96 97 98
        self.other_tags['strike'].set_property('strikethrough', True)
        self.begin_tags['strike'] = '<span style="text-decoration: line-through;">'
        self.end_tags['strike'] = '</span>'

99 100
        self.connect('paste-clipboard', self._paste_clipboard)
        self.connect_after('paste-clipboard', self._after_paste_clipboard)
101
        self.connect('focus-in-event', self._on_grab_focus)
102
        self.connect('grab-focus', self._on_grab_focus)
Philipp Hörist's avatar
Philipp Hörist committed
103 104
        self.connect('focus-out-event', self._on_focus_out)

105 106 107 108 109 110 111 112 113 114
    def _on_buffer_changed(self, *args):
        text = self.get_text()
        # Because of inserting and removing PLACEHOLDER text
        # the signal becomes unreliable when determining if the user
        # typed in new text. So we send our own signal depending on if
        # there is real new text that is not the PLACEHOLDER
        if text != self._last_text:
            self.emit('text-changed', self.get_buffer())
            self._last_text = text

Philipp Hörist's avatar
Philipp Hörist committed
115
    def has_text(self):
Philipp Hörist's avatar
Philipp Hörist committed
116 117 118
        buf = self.get_buffer()
        start, end = buf.get_bounds()
        text = buf.get_text(start, end, True)
119
        return text not in (self.PLACEHOLDER, '')
Philipp Hörist's avatar
Philipp Hörist committed
120

121 122 123 124 125 126 127 128 129
    def get_text(self):
        # gets the text if its not PLACEHOLDER
        buf = self.get_buffer()
        start, end = buf.get_bounds()
        text = self.get_buffer().get_text(start, end, True)
        if text == self.PLACEHOLDER:
            return ''
        return text

Philipp Hörist's avatar
Philipp Hörist committed
130 131 132 133 134
    def is_placeholder(self):
        buf = self.get_buffer()
        start, end = buf.get_bounds()
        text = buf.get_text(start, end, True)
        return text == self.PLACEHOLDER
Philipp Hörist's avatar
Philipp Hörist committed
135

136
    def remove_placeholder(self):
Philipp Hörist's avatar
Philipp Hörist committed
137
        if self.is_placeholder():
Philipp Hörist's avatar
Philipp Hörist committed
138
            self.get_buffer().set_text('')
Philipp Hörist's avatar
Philipp Hörist committed
139
        self.toggle_speller(True)
Philipp Hörist's avatar
Philipp Hörist committed
140

141 142 143
    def _on_grab_focus(self, *args):
        self.remove_placeholder()

Philipp Hörist's avatar
Philipp Hörist committed
144 145 146 147 148 149 150
    def _on_focus_out(self, *args):
        buf = self.get_buffer()
        start, end = buf.get_bounds()
        text = buf.get_text(start, end, True)
        if text == '':
            buf.insert_with_tags(
                start, self.PLACEHOLDER, self.placeholder_tag)
Philipp Hörist's avatar
Philipp Hörist committed
151 152 153
            self.toggle_speller(False)

    def toggle_speller(self, activate):
154
        if app.is_installed('GSPELL') and app.config.get('use_speller'):
Philipp Hörist's avatar
Philipp Hörist committed
155 156
            spell_view = Gspell.TextView.get_from_gtk_text_view(self)
            spell_view.set_inline_spell_checking(activate)
Philipp Hörist's avatar
Philipp Hörist committed
157

158 159 160 161 162 163 164
    @staticmethod
    def _paste_clipboard(textview):
        if textview.is_placeholder():
            textview.get_buffer().set_text('')

    @staticmethod
    def _after_paste_clipboard(textview):
165 166 167 168
        buffer_ = textview.get_buffer()
        mark = buffer_.get_insert()
        iter_ = buffer_.get_iter_at_mark(mark)
        if iter_.get_offset() == buffer_.get_end_iter().get_offset():
Philipp Hörist's avatar
Philipp Hörist committed
169
            GLib.idle_add(scroll_to_end, textview.get_parent())
170

171 172 173 174 175 176 177 178
    def make_clickable_urls(self, text):
        _buffer = self.get_buffer()

        start = 0
        end = 0
        index = 0

        new_text = ''
179
        iterator = app.interface.link_pattern_re.finditer(text)
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
        for match in iterator:
            start, end = match.span()
            url = text[start:end]
            if start != 0:
                text_before_special_text = text[index:start]
            else:
                text_before_special_text = ''
            # we insert normal text
            new_text += text_before_special_text + \
            '<a href="'+ url +'">' + url + '</a>'

            index = end # update index

        if end < len(text):
            new_text += text[end:]

        return new_text # the position after *last* special text

    def get_active_tags(self):
199
        start = self.get_active_iters()[0]
200 201 202 203 204 205 206 207 208 209 210 211 212 213
        active_tags = []
        for tag in start.get_tags():
            active_tags.append(tag.get_property('name'))
        return active_tags

    def get_active_iters(self):
        _buffer = self.get_buffer()
        return_val = _buffer.get_selection_bounds()
        if return_val: # if sth was selected
            start, finish = return_val[0], return_val[1]
        else:
            start, finish = _buffer.get_bounds()
        return (start, finish)

214
    def set_tag(self, tag):
215 216 217 218 219 220 221 222 223 224 225
        _buffer = self.get_buffer()
        start, finish = self.get_active_iters()
        if start.has_tag(self.other_tags[tag]):
            _buffer.remove_tag_by_name(tag, start, finish)
        else:
            if tag == 'underline':
                _buffer.remove_tag_by_name('strike', start, finish)
            elif tag == 'strike':
                _buffer.remove_tag_by_name('underline', start, finish)
            _buffer.apply_tag_by_name(tag, start, finish)

226
    def clear_tags(self):
227 228 229 230
        _buffer = self.get_buffer()
        start, finish = self.get_active_iters()
        _buffer.remove_all_tags(start, finish)

231
    def color_set(self, widget, response):
232
        if response in (-6, -4):
233 234
            widget.destroy()
            return
235 236

        color = widget.get_property('rgba')
237
        widget.destroy()
238 239 240 241 242
        _buffer = self.get_buffer()
        # Create #aabbcc color string from rgba color
        color_string = '#%02X%02X%02X' % (round(color.red*255),
            round(color.green*255), round(color.blue*255))

243 244 245 246
        tag_name = 'color' + color_string
        if not tag_name in self.color_tags:
            tagColor = _buffer.create_tag(tag_name)
            tagColor.set_property('foreground', color_string)
247
            self.begin_tags[tag_name] = '<span style="color: %s;">' % color_string
248 249 250 251 252 253 254 255 256 257
            self.end_tags[tag_name] = '</span>'
            self.color_tags.append(tag_name)

        start, finish = self.get_active_iters()

        for tag in self.color_tags:
            _buffer.remove_tag_by_name(tag, start, finish)

        _buffer.apply_tag_by_name(tag_name, start, finish)

258
    def font_set(self, widget, response, start, finish):
259
        if response in (-6, -4):
260 261 262
            widget.destroy()
            return

263 264
        font = widget.get_font()
        font_desc = widget.get_font_desc()
265 266
        family = font_desc.get_family()
        size = font_desc.get_size()
267
        size = size / Pango.SCALE
268 269 270 271 272
        weight = font_desc.get_weight()
        style = font_desc.get_style()

        widget.destroy()

273 274
        _buffer = self.get_buffer()

275 276 277 278 279 280 281 282 283 284 285 286 287 288 289
        tag_name = 'font' + font
        if not tag_name in self.fonts_tags:
            tagFont = _buffer.create_tag(tag_name)
            tagFont.set_property('font', family + ' ' + str(size))
            self.begin_tags[tag_name] = \
                    '<span style="font-family: ' + family + '; ' + \
                    'font-size: ' + str(size) + 'px">'
            self.end_tags[tag_name] = '</span>'
            self.fonts_tags.append(tag_name)

        for tag in self.fonts_tags:
            _buffer.remove_tag_by_name(tag, start, finish)

        _buffer.apply_tag_by_name(tag_name, start, finish)

290
        if weight == Pango.Weight.BOLD:
291 292 293 294
            _buffer.apply_tag_by_name('bold', start, finish)
        else:
            _buffer.remove_tag_by_name('bold', start, finish)

295
        if style == Pango.Style.ITALIC:
296 297 298 299 300 301 302 303 304
            _buffer.apply_tag_by_name('italic', start, finish)
        else:
            _buffer.remove_tag_by_name('italic', start, finish)

    def get_xhtml(self):
        _buffer = self.get_buffer()
        old = _buffer.get_start_iter()
        tags = {}
        tags['bold'] = False
305
        iter_ = _buffer.get_start_iter()
306 307 308 309 310 311 312 313 314 315 316
        old = _buffer.get_start_iter()
        text = ''
        modified = False

        def xhtml_special(text):
            text = text.replace('<', '&lt;')
            text = text.replace('>', '&gt;')
            text = text.replace('&', '&amp;')
            text = text.replace('\n', '<br />')
            return text

317
        for tag in iter_.get_toggled_tags(True):
318 319 320 321 322
            tag_name = tag.get_property('name')
            if tag_name not in self.begin_tags:
                continue
            text += self.begin_tags[tag_name]
            modified = True
323 324
        while (iter_.forward_to_tag_toggle(None) and not iter_.is_end()):
            text += xhtml_special(_buffer.get_text(old, iter_, True))
325 326
            old.forward_to_tag_toggle(None)
            new_tags, old_tags, end_tags = [], [], []
327
            for tag in iter_.get_toggled_tags(True):
328 329 330 331 332 333
                tag_name = tag.get_property('name')
                if tag_name not in self.begin_tags:
                    continue
                new_tags.append(tag_name)
                modified = True

334
            for tag in iter_.get_tags():
335 336 337 338 339 340
                tag_name = tag.get_property('name')
                if tag_name not in self.begin_tags or tag_name not in self.end_tags:
                    continue
                if tag_name not in new_tags:
                    old_tags.append(tag_name)

341
            for tag in iter_.get_toggled_tags(False):
342 343 344 345 346 347 348 349 350 351 352 353 354 355
                tag_name = tag.get_property('name')
                if tag_name not in self.end_tags:
                    continue
                end_tags.append(tag_name)

            for tag in old_tags:
                text += self.end_tags[tag]
            for tag in end_tags:
                text += self.end_tags[tag]
            for tag in new_tags:
                text += self.begin_tags[tag]
            for tag in old_tags:
                text += self.begin_tags[tag]

356 357
        text += xhtml_special(_buffer.get_text(old, _buffer.get_end_iter(), True))
        for tag in iter_.get_toggled_tags(False):
358 359 360 361 362 363 364
            tag_name = tag.get_property('name')
            if tag_name not in self.end_tags:
                continue
            text += self.end_tags[tag_name]

        if modified:
            return '<p>' + self.make_clickable_urls(text) + '</p>'
365
        return None
366

367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
    def replace_emojis(self):
        theme = app.config.get('emoticons_theme')
        if not theme or theme == 'font':
            return

        def replace(anchor):
            if anchor is None:
                return
            image = anchor.get_widgets()[0]
            if hasattr(image, 'codepoint'):
                # found emoji
                self.replace_char_at_iter(iter_, image.codepoint)
                image.destroy()

        iter_ = self.get_buffer().get_start_iter()
        replace(iter_.get_child_anchor())

        while iter_.forward_char():
            replace(iter_.get_child_anchor())

    def replace_char_at_iter(self, iter_, new_char):
        buffer_ = self.get_buffer()
        iter_2 = iter_.copy()
        iter_2.forward_char()
        buffer_.delete(iter_, iter_2)
        buffer_.insert(iter_, new_char)

    def insert_emoji(self, codepoint, pixbuf):
        self.remove_placeholder()
        buffer_ = self.get_buffer()
        if buffer_.get_char_count():
            # buffer contains text
            buffer_.insert_at_cursor(' ')

        insert_mark = buffer_.get_insert()
        insert_iter = buffer_.get_iter_at_mark(insert_mark)

        if pixbuf is None:
            buffer_.insert(insert_iter, codepoint)
        else:
            anchor = buffer_.create_child_anchor(insert_iter)
            image = Gtk.Image.new_from_pixbuf(pixbuf)
            image.codepoint = codepoint
            image.show()
            self.add_child_at_anchor(image, anchor)
        buffer_.insert_at_cursor(' ')

414
    def clear(self, widget=None):
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435
        """
        Clear text in the textview
        """
        _buffer = self.get_buffer()
        start, end = _buffer.get_bounds()
        _buffer.delete(start, end)

    def save_undo(self, text):
        self.undo_list.append(text)
        if len(self.undo_list) > self.UNDO_LIMIT:
            del self.undo_list[0]
        self.undo_pressed = False

    def undo(self, widget=None):
        """
        Undo text in the textview
        """
        _buffer = self.get_buffer()
        if self.undo_list:
            _buffer.set_text(self.undo_list.pop())
        self.undo_pressed = True
436

437 438 439
    def get_sensitive(self):
        # get sensitive is not in GTK < 2.18
        try:
Yann Leboulanger's avatar
Yann Leboulanger committed
440
            return super(MessageTextView, self).get_sensitive()
441 442
        except AttributeError:
            return self.get_property('sensitive')