Philipp Hörist pushed to branch master at gajim / gajim
Commits: 6690921d by lovetox at 2021-12-14T22:43:04+01:00 Styling: Make it work with labels
- Styling: Parse byte position - Tests: Add and adapt tests - PlainWidget: Style URIs correctly
- - - - -
4 changed files:
- gajim/common/styling.py - gajim/gtk/conversation/plain_widget.py - gajim/gtk/util.py - test/no_gui/test_styling.py
Changes:
===================================== gajim/common/styling.py ===================================== @@ -1,24 +1,38 @@ +# 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 __future__ import annotations + +from typing import Union +from typing import Match + import string import re from dataclasses import dataclass from dataclasses import field
+from gi.repository import GLib + 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)(?!>))' @@ -34,6 +48,10 @@ ADDRESS_RX = re.compile(ADDRESS_RX)
+SD = 0 +SD_POS = 1 + + @dataclass class StyleObject: start: int @@ -41,28 +59,34 @@ class StyleObject: text: str
-class URIMarkup: - def get_markup_string(self): - return f'<a href="{self.text}">{self.text}</a>' +@dataclass +class BaseUri(StyleObject): + + start_byte: int + end_byte: int + + def get_markup_string(self) -> str: + text = GLib.markup_escape_text(self.text) + return f'<a href="{text}">{text}</a>'
@dataclass -class Uri(StyleObject, URIMarkup): +class Uri(BaseUri): name: str = field(default='uri', init=False)
@dataclass -class Address(StyleObject, URIMarkup): +class Address(BaseUri): name: str = field(default='address', init=False)
@dataclass -class XMPPAddress(StyleObject, URIMarkup): +class XMPPAddress(BaseUri): name: str = field(default='xmppadr', init=False)
@dataclass -class MailAddress(StyleObject, URIMarkup): +class MailAddress(BaseUri): name: str = field(default='mailadr', init=False)
@@ -70,17 +94,17 @@ class MailAddress(StyleObject, URIMarkup): class Block(StyleObject):
@classmethod - def from_match(cls, match): + def from_match(cls, match: Match[str]) -> Block: return cls(start=match.start(), end=match.end(), - text=str(match.group(cls.name))) + text=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) + spans: list[Span] = field(default_factory=list) + uris: list[BaseUri] = field(default_factory=list)
@dataclass @@ -91,34 +115,41 @@ class PreBlock(Block): @dataclass class QuoteBlock(Block): name: str = field(default='quote', init=False) - blocks: list = field(default_factory=list) + blocks: list[Block] = field(default_factory=list)
- def unquote(self): + def unquote(self) -> str: return UNQUOTE_RX.sub('', self.text)
@dataclass -class PlainSpan(StyleObject): +class Span(StyleObject): + start_byte: int + end_byte: int + name: str + + +@dataclass +class PlainSpan(Span): name: str = field(default='plain', init=False)
@dataclass -class StrongSpan(StyleObject): +class StrongSpan(Span): name: str = field(default='strong', init=False)
@dataclass -class EmphasisSpan(StyleObject): +class EmphasisSpan(Span): name: str = field(default='emphasis', init=False)
@dataclass -class PreTextSpan(StyleObject): +class PreTextSpan(Span): name: str = field(default='pre', init=False)
@dataclass -class StrikeSpan(StyleObject): +class StrikeSpan(Span): name: str = field(default='strike', init=False)
@@ -133,18 +164,33 @@ class StrikeSpan(StyleObject): @dataclass class ParsingResult: text: str - blocks: list + blocks: list[Block] +
+def find_byte_index(text: str, index: int): + byte_index = -1 + for index_, c in enumerate(text): + byte_index += len(c.encode()) + if index == index_: + return byte_index + + raise ValueError('index not in string: %s, %s' % (text, index)) + + +def process(text: Union[str, bytes], nested: bool = False) -> ParsingResult: + if isinstance(text, bytes): + text = text.decode()
-def process(text, nested=False): blocks = _parse_blocks(text, nested) for block in blocks: if isinstance(block, PlainBlock): offset = 0 + offset_bytes = 0 for line in block.text.splitlines(keepends=True): - block.spans += _parse_line(line, offset) - block.uris += _parse_uris(line, offset) + block.spans += _parse_line(line, offset, offset_bytes) + block.uris += _parse_uris(line, offset, offset_bytes) offset += len(line) + offset_bytes += len(line.encode())
if isinstance(block, QuoteBlock): result = process(block.unquote(), nested=True) @@ -153,8 +199,8 @@ def process(text, nested=False): return ParsingResult(text, blocks)
-def _parse_blocks(text, nested): - blocks = [] +def _parse_blocks(text: str, nested: bool) -> list[Block]: + blocks: list[Block] = [] text_len = len(text) last_end_pos = 0
@@ -162,9 +208,10 @@ def _parse_blocks(text, nested):
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()])) + 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() @@ -176,17 +223,19 @@ def _parse_blocks(text, nested): 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:])) + blocks.append(PlainBlock( + start=last_end_pos, + end=text_len, + text=text[last_end_pos:])) + return blocks
-def _parse_line(line, offset): - index = 0 +def _parse_line(line: str, offset: int, offset_bytes: int) -> list[Span]: + index: int = 0 length = len(line) - stack = [] - spans = [] + stack: list[tuple[str, int]] = [] + spans: list[Span] = []
while index < length: sd = line[index] @@ -204,7 +253,11 @@ def _parse_line(line, offset):
if is_valid_start: if sd == PRE: - index = _handle_pre_span(line, index, offset, spans) + index = _handle_pre_span(line, + index, + offset, + offset_bytes, + spans) continue
stack.append((sd, index)) @@ -222,27 +275,46 @@ def _parse_line(line, offset): continue
start_pos = _find_span_start_position(sd, stack) - spans.append(_make_span(line, sd, start_pos, index, offset)) + spans.append(_make_span(line, + sd, + start_pos, + index, + offset, + offset_bytes))
index += 1
return spans
-def _parse_uris(line, offset): - uris = [] +def _parse_uris(line: str, offset: int, offset_bytes: int) -> list[BaseUri]: + uris: list[BaseUri] = [] + + def make(match: Match[str], is_address: bool) -> BaseUri: + return _make_link(line, + match.start(), + match.end() - 1, + offset, + offset_bytes, + is_address) + for match in URI_RX.finditer(line): - uri = _make_uri(line, match.start(), match.end(), offset) + uri = make(match, False) uris.append(uri)
for match in ADDRESS_RX.finditer(line): - uri = _make_address(line, match.start(), match.end(), offset) + uri = make(match, True) uris.append(uri)
return uris
-def _handle_pre_span(line, index, offset, spans): +def _handle_pre_span(line: str, + index: int, + offset: int, + offset_bytes: int, + spans: list[Span]) -> int: + # Scan ahead for the end end = line.find(PRE, index + 1) if end == -1: @@ -252,40 +324,69 @@ def _handle_pre_span(line, index, offset, spans): # empty span return index + 1
- spans.append(_make_span(line, PRE, index, end, offset)) + spans.append(_make_span(line, PRE, index, end, offset, offset_bytes)) return end + 1
-def _make_span(line, sd, start, end, offset): +def _make_span(line: str, + sd: str, + start: int, + end: int, + offset: int, + offset_bytes: int) -> Span: + text = line[start:end + 1] + + start_byte = find_byte_index(line, start) + offset_bytes + end_byte = find_byte_index(line, end) + offset_bytes + 1 + start += offset end += offset + 1 span_class = SPAN_CLS_DICT.get(sd) - return span_class(start=start, end=end, text=text) + assert span_class is not None
+ return span_class(start=start, + start_byte=start_byte, + end=end, + end_byte=end_byte, + 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_link(line: str, + start: int, + end: int, + offset: int, + offset_bytes: int, + is_address: bool) -> BaseUri: + + text = line[start:end + 1] + + start_byte = find_byte_index(line, start) + offset_bytes + end_byte = find_byte_index(line, end) + offset_bytes + 1
-def _make_address(line, start, end, offset): - text = line[start:end] start += offset - end += offset + end += offset + 1 + + if not is_address: + cls_ = Uri + + elif text.startswith('xmpp'): + cls_ = XMPPAddress
- if text.startswith('xmpp'): - return XMPPAddress(start=start, end=end, text=text) + elif text.startswith('mailto'): + cls_ = MailAddress
- if text.startswith('mailto'): - return MailAddress(start=start, end=end, text=text) + else: + cls_ = Address
- return Address(start=start, end=end, text=text) + return cls_(start=start, + start_byte=start_byte, + end=end, + end_byte=end_byte, + text=text)
-def _is_span_empty(sd, index, stack): +def _is_span_empty(sd: str, index: int, stack: list[tuple[str, int]]) -> bool: start_sd = stack[-1][SD] if start_sd != sd: return False @@ -294,7 +395,7 @@ def _is_span_empty(sd, index, stack): return pos == index - 1
-def _find_span_start_position(sd, stack): +def _find_span_start_position(sd: str, stack: list[tuple[str, int]]) -> int: while stack: start_sd, pos = stack.pop() if start_sd == sd: @@ -303,7 +404,7 @@ def _find_span_start_position(sd, stack): raise ValueError('Unable to find opening span')
-def _is_valid_span_start(line, index): +def _is_valid_span_start(line: str, index: int) -> bool: ''' https://xmpp.org/extensions/xep-0393.html#span
@@ -328,7 +429,7 @@ def _is_valid_span_start(line, index): return char in VALID_SPAN_START
-def _is_valid_span_end(line, index): +def _is_valid_span_end(line: str, index: int) -> bool: ''' https://xmpp.org/extensions/xep-0393.html#span
===================================== gajim/gtk/conversation/plain_widget.py ===================================== @@ -35,7 +35,7 @@ from gajim.gui_menu_builder import get_conv_uri_context_menu
from ..util import get_cursor -from ..util import make_pango_attribute +from ..util import make_pango_attributes
log = logging.getLogger('gajim.gui.conversaion.plain_widget')
@@ -95,17 +95,17 @@ def _on_populate_popup(self, label: Gtk.Label, menu: Gtk.Menu) -> None: menu.show_all()
def print_text_with_styling(self, block: PlainBlock) -> None: - text = GLib.markup_escape_text(block.text.strip()) + text = '' + after = GLib.markup_escape_text(block.text.strip()) for uri in block.uris: - text = text.replace(uri.text, uri.get_markup_string()) - - attr_list = Pango.AttrList() - for span in block.spans: - attr = make_pango_attribute(span.name, span.start, span.end) - attr_list.insert(attr) + uri_escaped = GLib.markup_escape_text(uri.text) + before, _, after = after.partition(uri_escaped) + text += before + text += uri.get_markup_string() + text += after
self.set_markup(text) - self.set_attributes(attr_list) + self.set_attributes(make_pango_attributes(block))
def update_text_tags(self) -> None: pass
===================================== gajim/gtk/util.py ===================================== @@ -55,6 +55,7 @@ from gajim.common.const import Display from gajim.common.const import StyleAttr from gajim.common.nec import EventHelper as CommonEventHelper +from gajim.common.styling import PlainBlock
from .const import GajimIconSet from .const import WINDOW_MODULES @@ -888,19 +889,27 @@ def refresh(self) -> None: self.set_tooltip_text(_('Account: %s') % label)
-def make_pango_attribute(name: str, start: int, end: int) -> Pango.Attribute: +def make_pango_attributes(block: PlainBlock) -> Pango.AttrList: + attrlist = Pango.AttrList() + for span in block.spans: + attr = get_style_attribute_with_name(span.name) + attr.start_index = span.start_byte + attr.end_index = span.end_byte + attrlist.insert(attr) + return attrlist + + +def get_style_attribute_with_name(name: str) -> Pango.Attribute: if name == 'strong': - attr = Pango.attr_weight_new(Pango.Weight.BOLD) + return Pango.attr_weight_new(Pango.Weight.BOLD) + if name == 'strike': - attr = Pango.attr_strikethrough_new(True) + return Pango.attr_strikethrough_new(True) + if name == 'emphasis': - attr = Pango.attr_style_new(Pango.Style.ITALIC) - if name == 'pre': - attr = Pango.attr_family_new('monospace') + return Pango.attr_style_new(Pango.Style.ITALIC)
- else: - ValueError('unknown attribute %s', name) + if name == 'pre': + return Pango.attr_family_new('monospace')
- attr.start_index = start - attr.end_index = end - return attr + raise ValueError('unknown attribute %s' % name)
===================================== test/no_gui/test_styling.py ===================================== @@ -18,8 +18,8 @@ '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*`_') + PreTextSpan(start=8, start_byte=8, end=25, end_byte=25, text='`with *children*`'), + EmphasisSpan(start=0, start_byte=0, end=26, end_byte=26, text='_no pre `with *children*`_') ]) ] }, @@ -28,9 +28,9 @@ '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~*_'), + StrikeSpan(start=2, start_byte=2, end=12, end_byte=12, text='~children~'), + StrongSpan(start=1, start_byte=1, end=13, end_byte=13, text='*~children~*'), + EmphasisSpan(start=0, start_byte=0, end=14, end_byte=14, text='_*~children~*_'), ]) ] }, @@ -39,10 +39,10 @@ '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`') + StrongSpan(start=0, start_byte=0, end=8, end_byte=8, text='*strong*'), + EmphasisSpan(start=9, start_byte=9, end=15, end_byte=15, text='_emph_'), + StrikeSpan(start=15, start_byte=15, end=23, end_byte=23, text='~strike~'), + PreTextSpan(start=25, start_byte=25, end=30, end_byte=30, text='`pre`') ]) ] }, @@ -51,7 +51,7 @@ 'input': '*strong*plain*', 'tokens': [ PlainBlock(start=0, end=14, text='*strong*plain*', spans=[ - StrongSpan(start=0, end=8, text='*strong*') + StrongSpan(start=0, start_byte=0, end=8, end_byte=8, text='*strong*') ]) ] }, @@ -63,6 +63,26 @@ ] },
+ 'byte pos is different': { + 'input': '*ö* *öö*', + 'tokens': [ + PlainBlock(start=0, end=8, text='*ö* *öö*', spans=[ + StrongSpan(start=0, start_byte=0, end=3, end_byte=4, text='*ö*'), + StrongSpan(start=4, start_byte=5, end=8, end_byte=11, text='*öö*') + ]) + ] + }, + + 'byte pos is different with multiple blocks': { + 'input': '```\npre\n```\n*pláin*', + 'tokens': [ + PreBlock(start=0, end=12, text='```\npre\n```\n'), + PlainBlock(start=12, end=19, text='*pláin*', spans=[ + StrongSpan(start=0, start_byte=0, end=7, end_byte=8, text='*pláin*') + ]) + ] + }, + 'end span only': { 'input': 'not strong*', 'tokens': [ @@ -102,7 +122,7 @@ 'input': '* plain *strong*', 'tokens': [ PlainBlock(start=0, end=16, text='* plain *strong*', spans=[ - StrongSpan(start=8, end=16, text='*strong*') + StrongSpan(start=8, start_byte=8, end=16, end_byte=16, text='*strong*') ]) ] }, @@ -111,7 +131,7 @@ 'input': '*this is *uneven*', 'tokens': [ PlainBlock(start=0, end=17, text='*this is *uneven*', spans=[ - StrongSpan(start=9, end=17, text='*uneven*') + StrongSpan(start=9, start_byte=9, end=17, end_byte=17, text='*uneven*') ]) ] }, @@ -120,7 +140,7 @@ 'input': '*this cannot _overlap*_', 'tokens': [ PlainBlock(start=0, end=23, text='*this cannot _overlap*_', spans=[ - StrongSpan(start=0, end=22, text='*this cannot _overlap*') + StrongSpan(start=0, start_byte=0, end=22, end_byte=22, text='*this cannot _overlap*') ]) ] }, @@ -223,7 +243,7 @@ '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=[ - Uri(start=18, end=42, text='http://foo.com/blah_blah') + Uri(start=18, start_byte=18, end=42, end_byte=42, text='http://foo.com/blah_blah') ]) ] }, @@ -232,7 +252,7 @@ '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=[ - Uri(start=18, end=42, text='http://foo.com/blah_blah') + Uri(start=18, start_byte=18, end=42, end_byte=42, text='http://foo.com/blah_blah') ]) ] }, @@ -241,9 +261,19 @@ '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*') + StrongSpan(start=5, start_byte=5, end=11, end_byte=11, text='*kind*') ], uris=[ - Uri(start=20, end=44, text='http://foo.com/blah_blah') + Uri(start=20, start_byte=20, end=44, end_byte=44, text='http://foo.com/blah_blah') + ]) + ] + }, + + 'plain with multiple uris': { + 'input': 'some http://foo.com/blah_blah and http://foo.com/blah_blah/123', + 'tokens': [ + PlainBlock(start=0, end=62, text='some http://foo.com/blah_blah and http://foo.com/blah_blah/123', spans=[], uris=[ + Uri(start=5, start_byte=5, end=29, end_byte=29, text='http://foo.com/blah_blah'), + Uri(start=34, start_byte=34, end=62, end_byte=62, text='http://foo.com/blah_blah/123') ]) ] },
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/commit/6690921dc0057b98bbc93c6b6247986c8...