diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py index 7bf19ea766bbf24fc66d1855c4f895b4c1063da8..bb45a5b70f9552f658c66873bf6b6ffee73aebd1 100644 --- a/gajim/chat_control_base.py +++ b/gajim/chat_control_base.py @@ -54,7 +54,6 @@ from gajim.gui.dialogs import ConfirmationDialog from gajim.gui.dialogs import PastePreviewDialog from gajim.gui.message_input import MessageInputTextView -from gajim.gui.util import at_the_end from gajim.gui.util import get_hardware_key_codes from gajim.gui.util import get_builder from gajim.gui.util import generate_account_badge diff --git a/gajim/common/styling.py b/gajim/common/styling.py new file mode 100644 index 0000000000000000000000000000000000000000..7ffc5e782857c1e84b9a2fd6859c8e1ec874cbd6 --- /dev/null +++ b/gajim/common/styling.py @@ -0,0 +1,337 @@ +import string +import re +from dataclasses import dataclass +from dataclasses import field + +PRE = '`' +STRONG = '*' +STRIKE = '~' +EMPH = '_' + +QUOTE = '> ' +PRE_TEXT = '```' + +WHITESPACE = set(string.whitespace) +BLOCK_DIRS = set([QUOTE, PRE_TEXT]) +SPAN_DIRS = set([PRE, STRONG, STRIKE, EMPH]) +VALID_SPAN_START = WHITESPACE | SPAN_DIRS + +SD = 0 +SD_POS = 1 + +PRE_RX = r'(?P<pre>^```.+?(^```$(.|\Z)))' +PRE_NESTED_RX = r'(?P<pre>^```.+?((^```$(.|\Z))|\Z))' +QUOTE_RX = r'(?P<quote>^(?=>).*?(^|\Z)(?!>))' + +BLOCK_RX = re.compile(PRE_RX + '|' + QUOTE_RX, re.S | re.M) +BLOCK_NESTED_RX = re.compile(PRE_NESTED_RX + '|' + QUOTE_RX, re.S | re.M) +UNQUOTE_RX = re.compile(r'^> |^>', re.M) + +URI_RX = r'((?P<protocol>[\w-]+://?|www[.])[\S()<>]+?(?=[,]?(\s|\Z)+))' +URI_RX = re.compile(URI_RX) + +ADDRESS_RX = r'(\b(?P<protocol>(xmpp|mailto)+:)?[\w-]*@(.*?\.)+[\w]+([\?].*?(?=([\s\),]|$)))?)' +ADDRESS_RX = re.compile(ADDRESS_RX) + + +@dataclass +class StyleObject: + start: int + end: int + text: str + + +@dataclass +class Uri(StyleObject): + name: str = field(default='uri', init=False) + + +@dataclass +class Address(StyleObject): + name: str = field(default='address', init=False) + + +@dataclass +class XMPPAddress(StyleObject): + name: str = field(default='xmppadr', init=False) + + +@dataclass +class MailAddress(StyleObject): + name: str = field(default='mailadr', init=False) + + +@dataclass +class Block(StyleObject): + + @classmethod + def from_match(cls, match): + return cls(start=match.start(), + end=match.end(), + text=str(match.group(cls.name))) + + +@dataclass +class PlainBlock(Block): + name: str = field(default='plain', init=False) + spans: list = field(default_factory=list) + uris: list = field(default_factory=list) + + +@dataclass +class PreBlock(Block): + name: str = field(default='pre', init=False) + + +@dataclass +class QuoteBlock(Block): + name: str = field(default='quote', init=False) + blocks: list = field(default_factory=list) + + def unquote(self): + return UNQUOTE_RX.sub('', self.text) + + +@dataclass +class PlainSpan(StyleObject): + name: str = field(default='plain', init=False) + + +@dataclass +class StrongSpan(StyleObject): + name: str = field(default='strong', init=False) + + +@dataclass +class EmphasisSpan(StyleObject): + name: str = field(default='emphasis', init=False) + + +@dataclass +class PreTextSpan(StyleObject): + name: str = field(default='pre', init=False) + + +@dataclass +class StrikeSpan(StyleObject): + name: str = field(default='strike', init=False) + + +SPAN_CLS_DICT = { + STRONG: StrongSpan, + EMPH: EmphasisSpan, + PRE: PreTextSpan, + STRIKE: StrikeSpan +} + + +@dataclass +class ParsingResult: + blocks: list + + +def process(text, nested=False): + blocks = _parse_blocks(text, nested) + for block in blocks: + if isinstance(block, PlainBlock): + offset = block.start + for line in block.text.splitlines(keepends=True): + block.spans += _parse_line(line, offset) + block.uris += _parse_uris(line, offset) + offset += len(line) + + if isinstance(block, QuoteBlock): + result = process(block.unquote(), nested=True) + block.blocks = result.blocks + + return ParsingResult(blocks) + + +def _parse_blocks(text, nested): + blocks = [] + text_len = len(text) + last_end_pos = 0 + + rx = BLOCK_NESTED_RX if nested else BLOCK_RX + + for match in rx.finditer(text): + if match.start() != last_end_pos: + blocks.append(PlainBlock(start=last_end_pos, + end=match.start(), + text=text[last_end_pos:match.start()])) + + last_end_pos = match.end() + group_dict = match.groupdict() + + if group_dict.get('quote') is not None: + blocks.append(QuoteBlock.from_match(match)) + + if group_dict.get('pre') is not None: + blocks.append(PreBlock.from_match(match)) + + if last_end_pos != text_len: + blocks.append(PlainBlock(start=last_end_pos, + end=text_len, + text=text[last_end_pos:])) + return blocks + + +def _parse_line(line, offset): + index = 0 + length = len(line) + stack = [] + spans = [] + + while index < length: + sd = line[index] + if sd not in SPAN_DIRS: + index += 1 + continue + + is_valid_start = _is_valid_span_start(line, index) + is_valid_end = _is_valid_span_end(line, index) + + if is_valid_start and is_valid_end: + # Favor end over new start, this means parsing is done non-greedy + if sd in [open_sd for open_sd, _ in stack]: + is_valid_start = False + + if is_valid_start: + if sd == PRE: + index = _handle_pre_span(line, index, offset, spans) + continue + + stack.append((sd, index)) + index += 1 + continue + + if is_valid_end: + if sd not in [open_sd for open_sd, _ in stack]: + index += 1 + continue + + if _is_span_empty(sd, index, stack): + stack.pop() + index += 1 + continue + + start_pos = _find_span_start_position(sd, stack) + spans.append(_make_span(line, sd, start_pos, index, offset)) + + index += 1 + + return spans + + +def _parse_uris(line, offset): + uris = [] + for match in URI_RX.finditer(line): + uri = _make_uri(line, match.start(), match.end(), offset) + uris.append(uri) + + for match in ADDRESS_RX.finditer(line): + uri = _make_address(line, match.start(), match.end(), offset) + uris.append(uri) + + return uris + + +def _handle_pre_span(line, index, offset, spans): + # Scan ahead for the end + end = line.find(PRE, index + 1) + if end == -1: + return index + 1 + + if end - index == 1: + # empty span + return index + 1 + + spans.append(_make_span(line, PRE, index, end, offset)) + return end + 1 + + +def _make_span(line, sd, start, end, offset): + text = line[start:end + 1] + start += offset + end += offset + 1 + span_class = SPAN_CLS_DICT.get(sd) + return span_class(start=start, end=end, text=text) + + +def _make_uri(line, start, end, offset): + text = line[start:end] + start += offset + end += offset + return Uri(start=start, end=end, text=text) + + +def _make_address(line, start, end, offset): + text = line[start:end] + start += offset + end += offset + + if text.startswith('xmpp'): + return XMPPAddress(start=start, end=end, text=text) + + if text.startswith('mailto'): + return MailAddress(start=start, end=end, text=text) + + return Address(start=start, end=end, text=text) + + +def _is_span_empty(sd, index, stack): + start_sd = stack[-1][SD] + if start_sd != sd: + return False + + pos = stack[-1][SD_POS] + return pos == index - 1 + + +def _find_span_start_position(sd, stack): + while stack: + start_sd, pos = stack.pop() + if start_sd == sd: + return pos + + raise ValueError('Unable to find opening span') + + +def _is_valid_span_start(line, index): + ''' + https://xmpp.org/extensions/xep-0393.html#span + + ... The opening styling directive MUST be located at the beginning + of the line, after a whitespace character, or after a different opening + styling directive. The opening styling directive MUST NOT be followed + by a whitespace character ... + ''' + + try: + char = line[index + 1] + except IndexError: + return False + + if char in WHITESPACE: + return False + + if index == 0: + return True + + char = line[index - 1] + return char in VALID_SPAN_START + + +def _is_valid_span_end(line, index): + ''' + https://xmpp.org/extensions/xep-0393.html#span + + ... and the closing styling directive MUST NOT be preceeded + by a whitespace character ... + ''' + + if index == 0: + return False + + char = line[index - 1] + return char not in WHITESPACE diff --git a/gajim/gtk/conversation/code_widget.py b/gajim/gtk/conversation/code_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..8601bce027c1a4b96ca93a2d8bc2cb2526b8711b --- /dev/null +++ b/gajim/gtk/conversation/code_widget.py @@ -0,0 +1,59 @@ +# 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/>. + +import gi +gi.require_version('GtkSource', '4') +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import GtkSource + + +class CodeWidget(Gtk.Box): + def __init__(self, account): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self.set_vexpand(True) + + self._account = account + + self._textview = CodeTextview() + # self._scrolled = Gtk.ScrolledWindow() + # self._scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, + # Gtk.PolicyType.AUTOMATIC) + # self._scrolled.set_hexpand(True) + # self._scrolled.set_vexpand(True) + # self._scrolled.set_propagate_natural_height(True) + # self._scrolled.set_max_content_height(400) + # self._scrolled.add(self._textview) + + self.add(self._textview) + + def add_content(self, block): + self._textview.print_code(block) + + +class CodeTextview(GtkSource.View): + def __init__(self): + GtkSource.View.__init__(self) + self.set_editable(False) + self.set_cursor_visible(False) + self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + + self._source_manager = GtkSource.LanguageManager.get_default() + + def print_code(self, block): + buffer_ = self.get_buffer() + lang = self._source_manager.get_language('python3') + buffer_.set_language(lang) + self.set_show_line_numbers(True) + buffer_.insert(buffer_.get_start_iter(), block.text) diff --git a/gajim/gtk/conversation/message_widget.py b/gajim/gtk/conversation/message_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..cb4fab76c9c808c5132b6a156719c550709ee95e --- /dev/null +++ b/gajim/gtk/conversation/message_widget.py @@ -0,0 +1,47 @@ +# 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/>. + +from gi.repository import Gtk + +from .code_widget import CodeWidget +from .quote_widget import QuoteWidget +from .plain_widget import PlainWidget + + +class MessageWidget(Gtk.Box): + def __init__(self, account): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self._account = account + + def add_content(self, blocks): + for block in blocks: + if block.name == 'plain': + widget = PlainWidget(self._account) + widget.add_content(block) + self.add(widget) + continue + + if block.name == 'pre': + widget = CodeWidget(self._account) + widget.add_content(block) + self.add(widget) + continue + + if block.name == 'quote': + message_widget = MessageWidget(self._account) + message_widget.add_content(block.blocks) + widget = QuoteWidget(self._account) + widget.attach_message_widget(message_widget) + self.add(widget) + continue diff --git a/gajim/gtk/conversation/plain_widget.py b/gajim/gtk/conversation/plain_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..3e3f7caf287c0311a97c4b1ea9da9badc2641bd5 --- /dev/null +++ b/gajim/gtk/conversation/plain_widget.py @@ -0,0 +1,335 @@ +# 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/>. + +from urllib.parse import quote + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Pango +from gi.repository import Gdk +from gi.repository import GLib + +from gajim.common import app +from gajim.common import i18n +from gajim.common.const import StyleAttr +from gajim.common.helpers import open_uri +from gajim.common.helpers import reduce_chars_newlines +from gajim.common.helpers import parse_uri +from gajim.common.i18n import _ + +from .util import get_cursor + + +URI_TAGS = ['uri', 'address', 'xmppadr', 'mailadr'] +STYLE_TAGS = ['strong', 'emphasis', 'strike', 'pre'] + + +class PlainWidget(Gtk.Box): + def __init__(self, account): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self.set_vexpand(True) + + self._account = account + + self._textview = MessageTextview(self._account) + self.add(self._textview) + + def add_content(self, block): + self._textview.print_text_with_styling(block) + + +class MessageTextview(Gtk.TextView): + + __gsignals__ = { + 'quote': ( + GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION, + None, + (str, ) + ), + } + + def __init__(self, account): + Gtk.TextView.__init__(self) + self.set_hexpand(True) + self.set_margin_start(0) + self.set_margin_end(0) + self.set_border_width(0) + self.set_left_margin(0) + self.set_right_margin(0) + self.set_has_tooltip(True) + self.set_editable(False) + self.set_cursor_visible(False) + self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + + self.connect('query-tooltip', self._query_tooltip) + self.connect('button-press-event', self._on_button_press) + self.connect('populate-popup', self._on_populate_popup) + + self._account = account + + # Used for changing the mouse pointer when hovering clickable URIs + self._cursor_changed = False + + # Keeps text selections for quoting and search actions + self._selected_text = '' + + self.get_style_context().add_class('gajim-conversation-font') + + # Create Tags + self._create_url_tags() + self.get_buffer().create_tag('strong', weight=Pango.Weight.BOLD) + self.get_buffer().create_tag('emphasis', style=Pango.Style.ITALIC) + self.get_buffer().create_tag('strike', strikethrough=True) + self.get_buffer().create_tag('pre', family='monospace') + + self.connect('destroy', self._on_destroy) + + def _on_destroy(self, *args): + pass + + def _create_url_tags(self): + color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR) + for name in URI_TAGS: + tag = self.get_buffer().create_tag(name, + foreground=color, + underline=Pango.Underline.SINGLE) + tag.connect('event', self._on_uri_clicked, tag) + + def update_tags(self): + tag_table = self.get_buffer().get_tag_table() + color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR) + + for tag in URI_TAGS: + tag_table.lookup(tag).set_property('foreground', color) + + def clear(self): + buffer_ = self.get_buffer() + start, end = buffer_.get_bounds() + buffer_.delete(start, end) + + def get_text(self): + buffer_ = self.get_buffer() + start, end = buffer_.get_bounds() + return buffer_.get_text(start, end, False) + + def print_text_with_styling(self, block): + buffer_ = self.get_buffer() + buffer_.insert(buffer_.get_start_iter(), block.text) + + for span in block.spans: + start_iter = buffer_.get_iter_at_offset(span.start) + end_iter = buffer_.get_iter_at_offset(span.end) + buffer_.apply_tag_by_name(span.name, start_iter, end_iter) + + for uri in block.uris: + start_iter = buffer_.get_iter_at_offset(uri.start) + end_iter = buffer_.get_iter_at_offset(uri.end) + buffer_.apply_tag_by_name(uri.name, start_iter, end_iter) + + def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip): + window = widget.get_window(Gtk.TextWindowType.TEXT) + x_pos, y_pos = self.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, x_pos, y_pos) + + iter_ = self.get_iter_at_position(x_pos, y_pos)[1] + for tag in iter_.get_tags(): + tag_name = tag.get_property('name') + if getattr(tag, 'is_anchor', False): + text = getattr(tag, 'title', False) + if text: + if len(text) > 50: + text = reduce_chars_newlines(text, 47, 1) + tooltip.set_text(text) + window.set_cursor(get_cursor('pointer')) + self._cursor_changed = True + return True + if tag_name in URI_TAGS: + window.set_cursor(get_cursor('pointer')) + self._cursor_changed = True + return False + + if self._cursor_changed: + window.set_cursor(get_cursor('text')) + self._cursor_changed = False + return False + + def _on_button_press(self, _widget, event): + ''' + We don’t open the standard context menu when receiving + a click on tagged text. + If it’s untagged text, check if something is selected + ''' + self._selected_text = '' + + if event.button != 3: + # If it’s not a right click + return False + + x_pos, y_pos = self.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, + int(event.x), + int(event.y)) + iter_ = self.get_iter_at_location(x_pos, y_pos) + if isinstance(iter_, tuple): + iter_ = iter_[1] + tags = iter_.get_tags() + + if tags: + # A tagged text fragment has been clicked + for tag in tags: + tag_name = tag.get_property('name') + if tag_name in URI_TAGS: + # Block regular context menu + return True + + # Check if there is a selection and make it available for + # _on_populate_popup + buffer_ = self.get_buffer() + return_val = buffer_.get_selection_bounds() + if return_val: + # Something has been selected, get the text + start_sel, finish_sel = return_val[0], return_val[1] + self._selected_text = buffer_.get_text( + start_sel, finish_sel, True) + elif iter_.get_char() and ord(iter_.get_char()) > 31: + # Clicked on a word, take whole word for selection + start_sel = iter_.copy() + if not start_sel.starts_word(): + start_sel.backward_word_start() + finish_sel = iter_.copy() + if not finish_sel.ends_word(): + finish_sel.forward_word_end() + self._selected_text = buffer_.get_text( + start_sel, finish_sel, True) + return False + + def _on_populate_popup(self, _textview, menu): + ''' + Overrides the default context menu. + If text is selected, a submenu with actions on the selection is added. + (see _on_button_press) + ''' + if not self._selected_text: + menu.show_all() + return + + if not self._history_mode: + item = Gtk.MenuItem.new_with_mnemonic(_('_Quote')) + id_ = item.connect('activate', self._on_quote) + self.handlers[id_] = item + menu.prepend(item) + + selected_text_short = reduce_chars_newlines( + self._selected_text, 25, 2) + item = Gtk.MenuItem.new_with_mnemonic( + _('_Actions for "%s"') % selected_text_short) + menu.prepend(item) + submenu = Gtk.Menu() + item.set_submenu(submenu) + uri_text = quote(self._selected_text.encode('utf-8')) + + if app.settings.get('always_english_wikipedia'): + uri = (f'https://en.wikipedia.org/wiki/' + f'Special:Search?search={uri_text}') + else: + uri = (f'https://{i18n.get_short_lang_code()}.wikipedia.org/' + f'wiki/Special:Search?search={uri_text}') + item = Gtk.MenuItem.new_with_mnemonic(_('Read _Wikipedia Article')) + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + item = Gtk.MenuItem.new_with_mnemonic( + _('Look it up in _Dictionary')) + dict_link = app.settings.get('dictionary_url') + if dict_link == 'WIKTIONARY': + # Default is wikitionary.org + if app.settings.get('always_english_wiktionary'): + uri = (f'https://en.wiktionary.org/wiki/' + f'Special:Search?search={uri_text}') + else: + uri = (f'https://{i18n.get_short_lang_code()}.wiktionary.org/' + f'wiki/Special:Search?search={uri_text}') + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + else: + if dict_link.find('%s') == -1: + # There has to be a '%s' in the url if it’s not WIKTIONARY + item = Gtk.MenuItem.new_with_label( + _('Dictionary URL is missing a "%s"')) + item.set_sensitive(False) + else: + uri = dict_link % uri_text + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + search_link = app.settings.get('search_engine') + if search_link.find('%s') == -1: + # There has to be a '%s' in the url + item = Gtk.MenuItem.new_with_label( + _('Web Search URL is missing a "%s"')) + item.set_sensitive(False) + else: + item = Gtk.MenuItem.new_with_mnemonic(_('Web _Search for it')) + uri = search_link % uri_text + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link')) + id_ = item.connect('activate', self._visit_uri, uri) + self.handlers[id_] = item + submenu.append(item) + + menu.show_all() + + def _on_uri_clicked(self, texttag, _widget, event, iter_, _kind): + if event.type != Gdk.EventType.BUTTON_PRESS: + return Gdk.EVENT_PROPAGATE + + begin_iter = iter_.copy() + # we get the beginning of the tag + while not begin_iter.starts_tag(texttag): + begin_iter.backward_char() + end_iter = iter_.copy() + # we get the end of the tag + while not end_iter.ends_tag(texttag): + end_iter.forward_char() + + # Detect XHTML-IM link + word = getattr(texttag, 'href', None) + if not word: + word = self.get_buffer().get_text(begin_iter, end_iter, True) + + uri = parse_uri(word) + if event.button.button == 3: # right click + self.show_context_menu(uri) + return Gdk.EVENT_STOP + + # self.plugin_modified = False + # app.plugin_manager.extension_point( + # 'hyperlink_handler', uri, self, self.get_toplevel()) + # if self.plugin_modified: + # return Gdk.EVENT_STOP + + open_uri(uri, account=self._account) + return Gdk.EVENT_STOP + + def _on_quote(self, _widget): + self.emit('quote', self._selected_text) + + @staticmethod + def _visit_uri(_widget, uri): + open_uri(uri) diff --git a/gajim/gtk/conversation/quote_widget.py b/gajim/gtk/conversation/quote_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..a66d5089e4e98092c16e107dc794fb976d111599 --- /dev/null +++ b/gajim/gtk/conversation/quote_widget.py @@ -0,0 +1,33 @@ +# 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/>. + + +from gi.repository import Gtk + + +class QuoteWidget(Gtk.Box): + def __init__(self, account): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + self.set_vexpand(True) + + self._account = account + + self._message_widget = None + + def attach_message_widget(self, message_widget): + # Purpose of this method is to prevent circular imports + if self._message_widget is not None: + raise ValueError('QuoteWidget already has a MessageWidget attached') + self._message_widget = message_widget + self.add(message_widget) diff --git a/gajim/gtk/conversation/rows/base.py b/gajim/gtk/conversation/rows/base.py index 55a043acda3fd6b304d5e107d9ea10bd07a1004a..6710b4d8ea55e82d48795855ce6f0e90a0630da4 100644 --- a/gajim/gtk/conversation/rows/base.py +++ b/gajim/gtk/conversation/rows/base.py @@ -30,7 +30,7 @@ class BaseRow(Gtk.ListBoxRow): - def __init__(self, account, widget='label', history_mode=False): + def __init__(self, account, widget=None, history_mode=False): Gtk.ListBoxRow.__init__(self) self.type = '' self.timestamp = None @@ -46,18 +46,12 @@ def __init__(self, account, widget='label', history_mode=False): self.grid = Gtk.Grid(row_spacing=3, column_spacing=12) self.add(self.grid) - if widget == 'textview': - self.label = None - self.textview = ConversationTextview( - account, history_mode=history_mode) - else: - self.textview = None + if widget == 'label': self.label = Gtk.Label() self.label.set_selectable(True) self.label.set_line_wrap(True) self.label.set_xalign(0) - self.label.set_line_wrap_mode( - Pango.WrapMode.WORD_CHAR) + self.label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) @property def is_merged(self): diff --git a/gajim/gtk/conversation/rows/date.py b/gajim/gtk/conversation/rows/date.py index a44b9713d0b7ccab22c4f904c709e65e97fc7236..34e1c997ea545e7537c610fc060241e89f9dc725 100644 --- a/gajim/gtk/conversation/rows/date.py +++ b/gajim/gtk/conversation/rows/date.py @@ -20,7 +20,7 @@ class DateRow(BaseRow): def __init__(self, account, timestamp): - BaseRow.__init__(self, account) + BaseRow.__init__(self, account, widget='label') self.set_selectable(False) self.set_activatable(False) diff --git a/gajim/gtk/conversation/rows/info.py b/gajim/gtk/conversation/rows/info.py index 31e9c6d4bffd4439eaf1279ea52a9dc0f60f4d6b..2c9f83685682522a5fff55d12c1ae3425330f25f 100644 --- a/gajim/gtk/conversation/rows/info.py +++ b/gajim/gtk/conversation/rows/info.py @@ -33,8 +33,7 @@ def __init__(self, subject, graphics, history_mode=False): - BaseRow.__init__(self, account, widget='textview', - history_mode=history_mode) + BaseRow.__init__(self, account, history_mode=history_mode) self.type = 'info' self.timestamp = datetime.fromtimestamp(timestamp) self.db_timestamp = timestamp diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py index b23f57e7965ec9d1964ec7dd97b307a18a6f7818..7979305a19f9fb0db7f68822c385de81329f990f 100644 --- a/gajim/gtk/conversation/rows/message.py +++ b/gajim/gtk/conversation/rows/message.py @@ -24,11 +24,13 @@ from gajim.common.const import TRUST_SYMBOL_DATA from gajim.common.helpers import reduce_chars_newlines from gajim.common.helpers import to_user_string +from gajim.common.styling import process from gajim.common.i18n import _ from gajim.common.i18n import Q_ from .base import BaseRow from .base import MoreMenuButton +from ..message_widget import MessageWidget from ...util import format_fingerprint @@ -60,8 +62,7 @@ def __init__(self, # other_tags_for_time were always empty? - BaseRow.__init__(self, account, widget='textview', - history_mode=history_mode) + BaseRow.__init__(self, account, history_mode=history_mode) self.type = 'chat' self.timestamp = datetime.fromtimestamp(timestamp) self.db_timestamp = timestamp @@ -80,13 +81,9 @@ def __init__(self, self.get_style_context().add_class( 'conversation-mention-highlight') - self.textview.connect('quote', self._on_quote_selection) - self.textview.print_text( - text, - other_text_tags=other_text_tags, - kind=kind, - name=name, - additional_data=additional_data) + result = process(text) + message_widget = MessageWidget(account) + message_widget.add_content(result.blocks) self._meta_box = Gtk.Box(spacing=6) self._meta_box.pack_start( @@ -132,7 +129,7 @@ def __init__(self, avatar_placeholder.add(self._avatar_surface) bottom_box = Gtk.Box(spacing=6) - bottom_box.add(self.textview) + bottom_box.add(message_widget) bottom_box.add(MoreMenuButton(self, history_mode=history_mode)) self.grid.attach(avatar_placeholder, 0, 0, 1, 2) diff --git a/gajim/gtk/conversation/rows/read_marker.py b/gajim/gtk/conversation/rows/read_marker.py index f0fe69a511892aa523d8184edd532fa3a86f16b2..10a84751a4d9de09bda1db4fcaf564c60dff1bc9 100644 --- a/gajim/gtk/conversation/rows/read_marker.py +++ b/gajim/gtk/conversation/rows/read_marker.py @@ -23,7 +23,7 @@ class ReadMarkerRow(BaseRow): def __init__(self, account, contact): - BaseRow.__init__(self, account) + BaseRow.__init__(self, account, widget='label') self.type = 'read_marker' self.timestamp = datetime.fromtimestamp(0) diff --git a/gajim/gtk/conversation/rows/scroll_hint.py b/gajim/gtk/conversation/rows/scroll_hint.py index e68326d1335b54aaaac6c7459a610f4d4eb6c310..b3d1079144f94407507dbddfe5b6bd5bb37c35e9 100644 --- a/gajim/gtk/conversation/rows/scroll_hint.py +++ b/gajim/gtk/conversation/rows/scroll_hint.py @@ -23,7 +23,7 @@ class ScrollHintRow(BaseRow): def __init__(self, account, history_mode=False): - BaseRow.__init__(self, account) + BaseRow.__init__(self, account, widget='label') self.set_selectable(False) self.set_activatable(False) diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py index c63cc24be50885954c065e6feb0841b5ea62139b..141729b783c9cc87d474ad17d015688e00595e4a 100644 --- a/gajim/gtk/conversation/view.py +++ b/gajim/gtk/conversation/view.py @@ -210,6 +210,7 @@ def _insert_message(self, message): self.add(message) self._add_date_row(message.timestamp) self._check_for_merge(message) + GLib.idle_add(message.queue_resize) def _add_date_row(self, timestamp): start_of_day = get_start_of_day(timestamp) diff --git a/test/no_gui/test_styling.py b/test/no_gui/test_styling.py new file mode 100644 index 0000000000000000000000000000000000000000..de38e4c2a34baaaa0d07f54d3c3ce68333881481 --- /dev/null +++ b/test/no_gui/test_styling.py @@ -0,0 +1,352 @@ +import unittest + +import gajim.common.styling as styling +from gajim.common.styling import PlainBlock +from gajim.common.styling import PreBlock +from gajim.common.styling import QuoteBlock +from gajim.common.styling import PreTextSpan +from gajim.common.styling import StrongSpan +from gajim.common.styling import EmphasisSpan +from gajim.common.styling import StrikeSpan +from gajim.common.styling import Url +from gajim.common.styling import URL_RX +from gajim.common.styling import ADDRESS_RX + + +STYLING = { + 'pre cannot have children': { + 'input': '_no pre `with *children*`_', + 'tokens': [ + PlainBlock(start=0, end=26, text='_no pre `with *children*`_', spans=[ + PreTextSpan(start=8, end=25, text='`with *children*`'), + EmphasisSpan(start=0, end=26, text='_no pre `with *children*`_') + ]) + ] + }, + + 'nested spans': { + 'input': '_*~children~*_', + 'tokens': [ + PlainBlock(start=0, end=14, text='_*~children~*_', spans=[ + StrikeSpan(start=2, end=12, text='~children~'), + StrongSpan(start=1, end=13, text='*~children~*'), + EmphasisSpan(start=0, end=14, text='_*~children~*_'), + ]) + ] + }, + + 'spans': { + 'input': '*strong* _emph_~strike~ `pre`', + 'tokens': [ + PlainBlock(start=0, end=30, text='*strong* _emph_~strike~ `pre`', spans=[ + StrongSpan(start=0, end=8, text='*strong*'), + EmphasisSpan(start=9, end=15, text='_emph_'), + StrikeSpan(start=15, end=23, text='~strike~'), + PreTextSpan(start=25, end=30, text='`pre`') + ]) + ] + }, + + 'spans lazily match': { + 'input': '*strong*plain*', + 'tokens': [ + PlainBlock(start=0, end=14, text='*strong*plain*', spans=[ + StrongSpan(start=0, end=8, text='*strong*') + ]) + ] + }, + + 'start span only': { + 'input': '*not strong', + 'tokens': [ + PlainBlock(start=0, end=11, text='*not strong', spans=[]) + ] + }, + + 'end span only': { + 'input': 'not strong*', + 'tokens': [ + PlainBlock(start=0, end=11, text='not strong*', spans=[]) + ] + }, + + 'invalid end span': { + 'input': '*not *strong', + 'tokens': [ + PlainBlock(start=0, end=12, text='*not *strong', spans=[]) + ] + }, + + 'empty span': { + 'input': '**', + 'tokens': [ + PlainBlock(start=0, end=2, text='**', spans=[]) + ] + }, + + '3 unmatched directives': { + 'input': '***', + 'tokens': [ + PlainBlock(start=0, end=3, text='***', spans=[]) + ] + }, + + '4 unmatched directives': { + 'input': '****', + 'tokens': [ + PlainBlock(start=0, end=4, text='****', spans=[]) + ] + }, + + 'invalid diretives ignored': { + 'input': '* plain *strong*', + 'tokens': [ + PlainBlock(start=0, end=16, text='* plain *strong*', spans=[ + StrongSpan(start=8, end=16, text='*strong*') + ]) + ] + }, + + 'uneven start directives': { + 'input': '*this is *uneven*', + 'tokens': [ + PlainBlock(start=0, end=17, text='*this is *uneven*', spans=[ + StrongSpan(start=9, end=17, text='*uneven*') + ]) + ] + }, + + 'overlapping directives': { + 'input': '*this cannot _overlap*_', + 'tokens': [ + PlainBlock(start=0, end=23, text='*this cannot _overlap*_', spans=[ + StrongSpan(start=0, end=22, text='*this cannot _overlap*') + ]) + ] + }, + + 'plain blocks': { + 'input': 'one\nand two', + 'tokens': [ + PlainBlock(start=0, end=11, text='one\nand two', spans=[]) + ] + }, + + 'pre block with closing': { + 'input': '```\npre *fmt* ```\n```\nplain', + 'tokens': [ + PreBlock(start=0, end=22, text='```\npre *fmt* ```\n```\n', spans=[]), + PlainBlock(start=22, end=27, text='plain', spans=[]) + ] + }, + + 'pre block EOF': { + 'input': '````\na\n```', + 'tokens': [ + PreBlock(start=0, end=10, text='````\na\n```', spans=[]) + ] + }, + + 'pre block no terminator EOF': { + 'input': '```\na```', + 'tokens': [ + PlainBlock(start=0, end=8, text='```\na```', spans=[]) + ] + }, + + 'pre block no body EOF': { + 'input': '```newtoken\n', + 'tokens': [ + PlainBlock(start=0, end=12, text='```newtoken\n', spans=[]) + ] + }, + + 'single level block quote': { + 'input': '> quoted\nnot quoted', + 'tokens': [ + QuoteBlock(start=0, end=10, text='> quoted\n', spans=[], blocks=[ + PlainBlock(start=0, end=8, text=' quoted\n', spans=[]) + ]), + PlainBlock(start=10, end=20, text='not quoted', spans=[]) + ] + }, + + 'multi level block quote': { + 'input': '> quoted\n>> quote > 2\n>quote 1\n\nnot quoted', + 'tokens': [ + QuoteBlock(start=0, end=34, text='> quoted\n>> quote > 2\n>quote 1\n', spans=[], blocks=[ + PlainBlock(start=0, end=8, text=' quoted\n', spans=[]), + QuoteBlock(start=8, end=22, text='> quote > 2\n', spans=[], blocks=[ + PlainBlock(start=0, end=12, text=' quote > 2\n', spans=[]) + ]), + PlainBlock(start=22, end=30, text='quote 1\n', spans=[]) + ]), + PlainBlock(start=34, end=45, text='\nnot quoted', spans=[]) + ] + }, + + 'quote start then EOF': { + 'input': '> ', + 'tokens': [ + QuoteBlock(start=0, end=2, text='> ', spans=[], blocks=[]) + ] + }, + + 'quote with children': { + 'input': '> ```\n> pre\n> ```\n> not pre', + 'tokens': [ + QuoteBlock(start=0, end=27, text='> ```\n> pre\n> ```\n> not pre', spans=[], blocks=[ + PreBlock(start=0, end=12, text='```\npre\n```\n', spans=[]), + PlainBlock(start=12, end=19, text='not pre', spans=[]) + ]) + ] + }, + + 'pre end of parent': { + 'input': '> ``` \n> pre\nplain', + 'tokens': [ + QuoteBlock(start=0, end=13, text='> ``` \n> pre\n', spans=[], blocks=[ + PreBlock(start=0, end=9, text='``` \npre\n', spans=[]) + ]), + PlainBlock(start=13, end=18, text='plain', spans=[]) + ] + }, + + 'span lines': { + 'input': '*not \n strong*', + 'tokens': [ + PlainBlock(start=0, end=14, text='*not \n strong*', spans=[]) + ] + }, + + 'plain with uri': { + 'input': 'some kind of link http://foo.com/blah_blah', + 'tokens': [ + PlainBlock(start=0, end=42, text='some kind of link http://foo.com/blah_blah', spans=[], uris=[ + Url(start=18, end=42, text='http://foo.com/blah_blah') + ]) + ] + }, + + 'plain with uri don’t consider comma': { + 'input': 'some kind of link http://foo.com/blah_blah,', + 'tokens': [ + PlainBlock(start=0, end=43, text='some kind of link http://foo.com/blah_blah,', spans=[], uris=[ + Url(start=18, end=42, text='http://foo.com/blah_blah') + ]) + ] + }, + + 'plain with uri and styling': { + 'input': 'some *kind* of link http://foo.com/blah_blah', + 'tokens': [ + PlainBlock(start=0, end=44, text='some *kind* of link http://foo.com/blah_blah', spans=[ + StrongSpan(start=5, end=11, text='*kind*') + ], uris=[ + Url(start=20, end=44, text='http://foo.com/blah_blah') + ]) + ] + }, + +} + + +URLS = [ + 'http://foo.com/blah_blah', + 'http://foo.com/blah_blah/', + 'http://foo.com/blah_blah_(wikipedia)', + 'http://foo.com/blah_blah_(wikipedia)_(again)', + 'http://www.example.com/wpstyle/?p=364', + 'https://www.example.com/foo/?bar=baz&inga=42&quux', + 'http://✪df.ws/123', + 'http://userid:password@example.com:8080', + 'http://userid:password@example.com:8080/', + 'http://userid@example.com', + 'http://userid@example.com/', + 'http://userid@example.com:8080', + 'http://userid@example.com:8080/', + 'http://userid:password@example.com', + 'http://userid:password@example.com/', + 'http://142.42.1.1/', + 'http://142.42.1.1:8080/', + 'http://âž¡.ws/䨹', + 'http://⌘.ws', + 'http://⌘.ws/', + 'http://foo.com/blah_(wikipedia)#cite-1', + 'http://foo.com/blah_(wikipedia)_blah#cite-1', + 'http://foo.com/unicode_(✪)_in_parens', + 'http://foo.com/(something)?after=parens', + 'http://☺.damowmow.com/', + 'http://code.google.com/events/#&product=browser', + 'http://j.mp', + 'ftp://foo.bar/baz', + 'http://foo.bar/?q=Test%20URL-encoded%20stuff', + 'http://مثال.إختبار', + 'http://例å.测试', + 'http://उदाहरण.परीकà¥à¤·à¤¾', + 'http://-.~_!$&\'()*+,;=:%40:80%2f::::::@example.com', + 'http://1337.net', + 'http://a.b-c.de', + 'http://223.255.255.254', + 'https://foo_bar.example.com/', +] + + +EMAILS = [ + 'asd@asd.at', + 'asd@asd.asd.at', +] + + +URL_WITH_TEXT = [ + ('see this http://userid@example.com/ link', 'http://userid@example.com/'), + ('see this http://userid@example.com/, and ..', 'http://userid@example.com/'), +] + +XMPP_URIS = [ + ('see xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message ...', 'xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message'), +] + + +class Test(unittest.TestCase): + def test_styling(self): + for _name, params in STYLING.items(): + result = styling.process(params['input']) + self.assertTrue(result.blocks == params['tokens']) + + def test_urls(self): + for url in URLS: + match = URL_RX.search(url) + self.assertIsNotNone(match) + start = match.start() + end = match.end() + self.assertTrue(url[start:end] == url) + + def test_emails(self): + for email in EMAILS: + match = ADDRESS_RX.search(email) + self.assertIsNotNone(match) + start = match.start() + end = match.end() + self.assertTrue(email[start:end] == email) + + def test_url_with_text(self): + for text, result in URL_WITH_TEXT: + match = URL_RX.search(text) + self.assertIsNotNone(match) + start = match.start() + end = match.end() + self.assertTrue(text[start:end] == result) + + def test_xmpp_uris(self): + for text, result in XMPP_URIS: + match = ADDRESS_RX.search(text) + self.assertIsNotNone(match) + start = match.start() + end = match.end() + self.assertTrue(text[start:end] == result) + + + +if __name__ == "__main__": + unittest.main()