1# Copyright (C) 2003-2014 Yann Leboulanger <asterix AT lagaule.org>
2# Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
3# Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
4# Copyright (C) 2008-2009 Julien Pivotto <roidelapluie AT gmail.com>
5#
6# This file is part of Gajim.
7#
8# Gajim is free software; you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published
10# by the Free Software Foundation; version 3 only.
11#
12# Gajim 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
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
19
20from gi.repository import Gtk
21from gi.repository import Gdk
22from gi.repository import GLib
23from gi.repository import Pango
24
25from nbxmpp.modules.misc import build_xhtml_body
26
27from gajim.common import app
28from gajim.common.regex import LINK_REGEX
29
30from .util import scroll_to_end
31
32if app.is_installed('GSPELL'):
33    from gi.repository import Gspell  # pylint: disable=ungrouped-imports
34
35
36class MessageInputTextView(Gtk.TextView):
37    """
38    Class for the message textview (where user writes new messages) for
39    chat/groupchat windows
40    """
41    UNDO_LIMIT = 20
42
43    def __init__(self):
44        Gtk.TextView.__init__(self)
45
46        # set properties
47        self.set_border_width(3)
48        self.set_accepts_tab(True)
49        self.set_editable(True)
50        self.set_cursor_visible(True)
51        self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
52        self.set_left_margin(2)
53        self.set_right_margin(2)
54        self.set_pixels_above_lines(2)
55        self.set_pixels_below_lines(2)
56        self.get_style_context().add_class('gajim-conversation-font')
57
58        self.drag_dest_unset()
59
60        # set undo list
61        self.undo_list = []
62        # needed to know if we undid something
63        self.undo_pressed = False
64
65        self._last_text = ''
66
67        self.begin_tags = {}
68        self.end_tags = {}
69        self.color_tags = []
70        self.fonts_tags = []
71        self.other_tags = {}
72
73        buffer_ = self.get_buffer()
74        self.other_tags['bold'] = buffer_.create_tag('bold')
75        self.other_tags['bold'].set_property('weight', Pango.Weight.BOLD)
76        self.begin_tags['bold'] = '<strong>'
77        self.end_tags['bold'] = '</strong>'
78        self.other_tags['italic'] = buffer_.create_tag('italic')
79        self.other_tags['italic'].set_property('style', Pango.Style.ITALIC)
80        self.begin_tags['italic'] = '<em>'
81        self.end_tags['italic'] = '</em>'
82        self.other_tags['underline'] = buffer_.create_tag('underline')
83        self.other_tags['underline'].set_property('underline',
84                                                  Pango.Underline.SINGLE)
85        underline = '<span style="text-decoration: underline;">'
86        self.begin_tags['underline'] = underline
87        self.end_tags['underline'] = '</span>'
88        self.other_tags['strike'] = buffer_.create_tag('strike')
89        self.other_tags['strike'].set_property('strikethrough', True)
90        strike = '<span style="text-decoration: line-through;">'
91        self.begin_tags['strike'] = strike
92        self.end_tags['strike'] = '</span>'
93
94        self.connect_after('paste-clipboard', self._after_paste_clipboard)
95        self.connect('focus-in-event', self._on_focus_in)
96        self.connect('focus-out-event', self._on_focus_out)
97        self.connect('destroy', self._on_destroy)
98
99    def _on_destroy(self, *args):
100        # We restore the TextView’s drag destination to avoid a GTK warning
101        # when closing the control. ChatControlBase.shutdown() calls destroy()
102        # on the control’s main box, causing GTK to recursively destroy the
103        # child widgets. GTK then tries to set a target list on the TextView,
104        # resulting in a warning because the Widget has no drag destination.
105        self.drag_dest_set(
106            Gtk.DestDefaults.ALL,
107            None,
108            Gdk.DragAction.DEFAULT)
109
110    def _on_focus_in(self, _widget, _event):
111        self.toggle_speller(True)
112        scrolled = self.get_parent()
113        scrolled.get_style_context().add_class('message-input-focus')
114        return False
115
116    def _on_focus_out(self, _widget, _event):
117        scrolled = self.get_parent()
118        scrolled.get_style_context().remove_class('message-input-focus')
119        if not self.has_text():
120            self.toggle_speller(False)
121        return False
122
123    def insert_text(self, text):
124        self.get_buffer().insert_at_cursor(text)
125
126    def insert_newline(self):
127        buffer_ = self.get_buffer()
128        buffer_.insert_at_cursor('\n')
129        mark = buffer_.get_insert()
130        iter_ = buffer_.get_iter_at_mark(mark)
131        if buffer_.get_end_iter().equal(iter_):
132            GLib.idle_add(scroll_to_end, self.get_parent())
133
134    def has_text(self):
135        buf = self.get_buffer()
136        start, end = buf.get_bounds()
137        text = buf.get_text(start, end, True)
138        return text != ''
139
140    def get_text(self):
141        buf = self.get_buffer()
142        start, end = buf.get_bounds()
143        text = self.get_buffer().get_text(start, end, True)
144        return text
145
146    def toggle_speller(self, activate):
147        if app.is_installed('GSPELL') and app.settings.get('use_speller'):
148            spell_view = Gspell.TextView.get_from_gtk_text_view(self)
149            spell_view.set_inline_spell_checking(activate)
150
151    @staticmethod
152    def _after_paste_clipboard(textview):
153        buffer_ = textview.get_buffer()
154        mark = buffer_.get_insert()
155        iter_ = buffer_.get_iter_at_mark(mark)
156        if iter_.get_offset() == buffer_.get_end_iter().get_offset():
157            GLib.idle_add(scroll_to_end, textview.get_parent())
158
159    def make_clickable_urls(self, text):
160        _buffer = self.get_buffer()
161
162        start = 0
163        end = 0
164        index = 0
165
166        new_text = ''
167        iterator = LINK_REGEX.finditer(text)
168        for match in iterator:
169            start, end = match.span()
170            url = text[start:end]
171            if start != 0:
172                text_before_special_text = text[index:start]
173            else:
174                text_before_special_text = ''
175            # we insert normal text
176            new_text += text_before_special_text + \
177            '<a href="'+ url +'">' + url + '</a>'
178
179            index = end # update index
180
181        if end < len(text):
182            new_text += text[end:]
183
184        return new_text # the position after *last* special text
185
186    def get_active_tags(self):
187        start = self.get_active_iters()[0]
188        active_tags = []
189        for tag in start.get_tags():
190            active_tags.append(tag.get_property('name'))
191        return active_tags
192
193    def get_active_iters(self):
194        _buffer = self.get_buffer()
195        return_val = _buffer.get_selection_bounds()
196        if return_val: # if sth was selected
197            start, finish = return_val[0], return_val[1]
198        else:
199            start, finish = _buffer.get_bounds()
200        return (start, finish)
201
202    def set_tag(self, tag):
203        _buffer = self.get_buffer()
204        start, finish = self.get_active_iters()
205        if start.has_tag(self.other_tags[tag]):
206            _buffer.remove_tag_by_name(tag, start, finish)
207        else:
208            if tag == 'underline':
209                _buffer.remove_tag_by_name('strike', start, finish)
210            elif tag == 'strike':
211                _buffer.remove_tag_by_name('underline', start, finish)
212            _buffer.apply_tag_by_name(tag, start, finish)
213
214    def clear_tags(self):
215        _buffer = self.get_buffer()
216        start, finish = self.get_active_iters()
217        _buffer.remove_all_tags(start, finish)
218
219    def color_set(self, widget, response):
220        if response in (-6, -4):
221            widget.destroy()
222            return
223
224        color = widget.get_property('rgba')
225        widget.destroy()
226        _buffer = self.get_buffer()
227        # Create #aabbcc color string from rgba color
228        color_string = '#%02X%02X%02X' % (round(color.red*255),
229                                          round(color.green*255),
230                                          round(color.blue*255))
231
232        tag_name = 'color' + color_string
233        if not tag_name in self.color_tags:
234            tag_color = _buffer.create_tag(tag_name)
235            tag_color.set_property('foreground', color_string)
236            begin = '<span style="color: %s;">' % color_string
237            self.begin_tags[tag_name] = begin
238            self.end_tags[tag_name] = '</span>'
239            self.color_tags.append(tag_name)
240
241        start, finish = self.get_active_iters()
242
243        for tag in self.color_tags:
244            _buffer.remove_tag_by_name(tag, start, finish)
245
246        _buffer.apply_tag_by_name(tag_name, start, finish)
247
248    def font_set(self, widget, response, start, finish):
249        if response in (-6, -4):
250            widget.destroy()
251            return
252
253        font = widget.get_font()
254        font_desc = widget.get_font_desc()
255        family = font_desc.get_family()
256        size = font_desc.get_size()
257        size = size / Pango.SCALE
258        weight = font_desc.get_weight()
259        style = font_desc.get_style()
260
261        widget.destroy()
262
263        _buffer = self.get_buffer()
264
265        tag_name = 'font' + font
266        if not tag_name in self.fonts_tags:
267            tag_font = _buffer.create_tag(tag_name)
268            tag_font.set_property('font', family + ' ' + str(size))
269            self.begin_tags[tag_name] = \
270                    '<span style="font-family: ' + family + '; ' + \
271                    'font-size: ' + str(size) + 'px">'
272            self.end_tags[tag_name] = '</span>'
273            self.fonts_tags.append(tag_name)
274
275        for tag in self.fonts_tags:
276            _buffer.remove_tag_by_name(tag, start, finish)
277
278        _buffer.apply_tag_by_name(tag_name, start, finish)
279
280        if weight == Pango.Weight.BOLD:
281            _buffer.apply_tag_by_name('bold', start, finish)
282        else:
283            _buffer.remove_tag_by_name('bold', start, finish)
284
285        if style == Pango.Style.ITALIC:
286            _buffer.apply_tag_by_name('italic', start, finish)
287        else:
288            _buffer.remove_tag_by_name('italic', start, finish)
289
290    def get_xhtml(self):
291        _buffer = self.get_buffer()
292        old = _buffer.get_start_iter()
293        tags = {}
294        tags['bold'] = False
295        iter_ = _buffer.get_start_iter()
296        old = _buffer.get_start_iter()
297        text = ''
298        modified = False
299
300        def xhtml_special(text):
301            text = text.replace('<', '&lt;')
302            text = text.replace('>', '&gt;')
303            text = text.replace('&', '&amp;')
304            text = text.replace('\n', '<br />')
305            return text
306
307        for tag in iter_.get_toggled_tags(True):
308            tag_name = tag.get_property('name')
309            if tag_name not in self.begin_tags:
310                continue
311            text += self.begin_tags[tag_name]
312            modified = True
313        while (iter_.forward_to_tag_toggle(None) and not iter_.is_end()):
314            text += xhtml_special(_buffer.get_text(old, iter_, True))
315            old.forward_to_tag_toggle(None)
316            new_tags, old_tags, end_tags = [], [], []
317            for tag in iter_.get_toggled_tags(True):
318                tag_name = tag.get_property('name')
319                if tag_name not in self.begin_tags:
320                    continue
321                new_tags.append(tag_name)
322                modified = True
323
324            for tag in iter_.get_tags():
325                tag_name = tag.get_property('name')
326                if (tag_name not in self.begin_tags or
327                        tag_name not in self.end_tags):
328                    continue
329                if tag_name not in new_tags:
330                    old_tags.append(tag_name)
331
332            for tag in iter_.get_toggled_tags(False):
333                tag_name = tag.get_property('name')
334                if tag_name not in self.end_tags:
335                    continue
336                end_tags.append(tag_name)
337
338            for tag in old_tags:
339                text += self.end_tags[tag]
340            for tag in end_tags:
341                text += self.end_tags[tag]
342            for tag in new_tags:
343                text += self.begin_tags[tag]
344            for tag in old_tags:
345                text += self.begin_tags[tag]
346
347        buffer_text = _buffer.get_text(old, _buffer.get_end_iter(), True)
348        text += xhtml_special(buffer_text)
349        for tag in iter_.get_toggled_tags(False):
350            tag_name = tag.get_property('name')
351            if tag_name not in self.end_tags:
352                continue
353            text += self.end_tags[tag_name]
354
355        if modified:
356            wrapped_text = '<p>%s</p>' % self.make_clickable_urls(text)
357            return build_xhtml_body(wrapped_text)
358        return None
359
360    def replace_emojis(self):
361        theme = app.settings.get('emoticons_theme')
362        if not theme or theme == 'font':
363            return
364
365        def replace(anchor):
366            if anchor is None:
367                return
368            image = anchor.get_widgets()[0]
369            if hasattr(image, 'codepoint'):
370                # found emoji
371                self.replace_char_at_iter(iter_, image.codepoint)
372                image.destroy()
373
374        iter_ = self.get_buffer().get_start_iter()
375        replace(iter_.get_child_anchor())
376
377        while iter_.forward_char():
378            replace(iter_.get_child_anchor())
379
380    def replace_char_at_iter(self, iter_, new_char):
381        buffer_ = self.get_buffer()
382        iter_2 = iter_.copy()
383        iter_2.forward_char()
384        buffer_.delete(iter_, iter_2)
385        buffer_.insert(iter_, new_char)
386
387    def insert_emoji(self, codepoint, pixbuf):
388        buffer_ = self.get_buffer()
389        if buffer_.get_char_count():
390            # buffer contains text
391            buffer_.insert_at_cursor(' ')
392
393        insert_mark = buffer_.get_insert()
394        insert_iter = buffer_.get_iter_at_mark(insert_mark)
395
396        if pixbuf is None:
397            buffer_.insert(insert_iter, codepoint)
398        else:
399            anchor = buffer_.create_child_anchor(insert_iter)
400            image = Gtk.Image.new_from_pixbuf(pixbuf)
401            image.codepoint = codepoint
402            image.show()
403            self.add_child_at_anchor(image, anchor)
404        buffer_.insert_at_cursor(' ')
405
406    def clear(self, _widget=None):
407        """
408        Clear text in the textview
409        """
410        _buffer = self.get_buffer()
411        start, end = _buffer.get_bounds()
412        _buffer.delete(start, end)
413
414    def save_undo(self, text):
415        self.undo_list.append(text)
416        if len(self.undo_list) > self.UNDO_LIMIT:
417            del self.undo_list[0]
418        self.undo_pressed = False
419
420    def undo(self, _widget=None):
421        """
422        Undo text in the textview
423        """
424        _buffer = self.get_buffer()
425        if self.undo_list:
426            _buffer.set_text(self.undo_list.pop())
427        self.undo_pressed = True
428