about summary refs log tree commit diff
diff options
context:
space:
mode:
authorsterni <sternenseemann@systemli.org>2021-07-06 21:16:20 +0200
committerSören Tempel <soeren+git@soeren-tempel.net>2021-07-15 13:28:33 +0200
commita92e1fe912cd87ddb2150ba95407d2669577e418 (patch)
treeeb7aada66a7bafec84339c07475c69831540d023
parent5379601217b663dc54f248ae88dec20790f77862 (diff)
Track text style in Terminal and reuse TextTags
This change addresses the previous layer violation mentioned in a
previous commit: Instead of inflating the Parser code by tracking
state which is not necessary for parsing, we now just emit events
describing text style changes in the parser code. The necessary
updates to actual display state is then handled in the Terminal
object.

We use this to map the changes to display style introduced by events
to _individual_ TextTags instead of creating a composite TextTag for
every styled stretch of text.

This makes TextTags reusable since they are less specific: Instead of
a single italic, green background and bold face tag, we now have three
different ones which are quite likely to occur again. Thus we'll store
(references to) already created tags in a dictionary to be reused.

Note that we can't use Gtk's TextTag lookup mechanism which allows to
reuse TextTags in the TextTagTable of a buffer by name since this
would not allow us to create TextTags on demand. Precreating all 13824
possible color tags is of course not an option.

While this change doesn't bring a big performance improvement it
should prevent performance from degrading over time since it limits
the number of tags being created, allocated and managed in the Gtk
TextBuffer.
-rw-r--r--saneterm/color.py7
-rw-r--r--saneterm/pty.py225
-rw-r--r--saneterm/terminal.py91
-rw-r--r--saneterm/termview.py7
4 files changed, 222 insertions, 108 deletions
diff --git a/saneterm/color.py b/saneterm/color.py
index a7cd1a8..06e1ade 100644
--- a/saneterm/color.py
+++ b/saneterm/color.py
@@ -114,6 +114,13 @@ class Color(object):
         self.type = t
         self.data = data
 
+    # TODO: can we prevent mutation of this object?
+    def __hash__(self):
+        return hash((self.type, self.data))
+
+    def __eq__(self, other):
+        return self.type == other.type and self.data == other.data
+
     def to_gdk(self):
         """
         Convert a Color into a Gdk.RGBA which TextTag accepts.
diff --git a/saneterm/pty.py b/saneterm/pty.py
index d25d0bf..c6fda6a 100644
--- a/saneterm/pty.py
+++ b/saneterm/pty.py
@@ -46,38 +46,51 @@ class EventType(Enum):
     BELL = auto()
     TEXT_STYLE = auto()
 
-class TextStyle(object):
-    def __init__(self):
-        self.reset_all()
-
-    def reset_all(self):
-        self.fg_color = None
-        self.bg_color = None
-        self.strikethrough = False
-        self.intensity = Pango.Weight.NORMAL
-        self.italic = False
-        self.underline = Pango.Underline.NONE
-        self.concealed = False
-
-    def to_tag(self, textbuffer):
-        keywords = {
-            'strikethrough' : self.strikethrough,
-            'underline'     : self.underline,
-            'weight'        : self.intensity,
-            'style'         : Pango.Style.ITALIC if self.italic else Pango.Style.NORMAL,
-            'invisible'     : self.concealed,
-        }
-
-        if self.fg_color is not None:
-            keywords['foreground_rgba'] = self.fg_color.to_gdk()
-
-        if self.bg_color is not None:
-            keywords['background_rgba'] = self.bg_color.to_gdk()
-
-        tag = textbuffer.create_tag(None, **keywords)
-
-        return tag
+class TextStyleChange(Enum):
+    """
+    Each TextStyleChange describes a way in which escape
+    sequences may influence the way text is displayed.
+    Together with an additional value (True or False,
+    a color, an enum from Pango, …) a TextStyleChange
+    can represent the actual impact an escape sequence
+    has on the font rendering.
+
+    The important invariant here is that all associated
+    represented changes are _mutually exclusive_:
+    E. g. (TextStyleChange.ITALIC, True) and
+    (TextStyleChange.ITALIC, False) can't be applied at
+    the same time — one will replace the other.
+    This invariant greatly simplifies state tracking.
+    """
 
+    # resets the display style to an arbitrary default
+    # No associated value
+    RESET = auto()
+    # Enables/disables italic text
+    # associated with a boolean
+    ITALIC = auto()
+    # Enables/disables text being crossed out
+    # associated with a boolean
+    STRIKETHROUGH = auto()
+    # Describes weight of the font to use
+    # associated with a Pango.Weight enum
+    WEIGHT = auto()
+    # Disables or enables an underline style
+    # associated with a Pango.Underline enum
+    UNDERLINE = auto()
+    # Hides/shows the text. If hidden, should
+    # not be readable, but in many implementations
+    # the text is still able to be copied.
+    # associated with a boolean
+    CONCEALED = auto()
+    # Sets the text's color or resets
+    # it to a default if None.
+    # associated with either None or a Color
+    FOREGROUND_COLOR = auto()
+    # Sets the text's background color or resets
+    # it to a default if None.
+    # associated with either None or a Color
+    BACKGROUND_COLOR = auto()
 
 class PositionedIterator(object):
     """
@@ -307,7 +320,6 @@ class Parser(object):
     def __init__(self):
         # unparsed output left from the last call to parse
         self.__leftover = ''
-        self.__text_style = TextStyle()
 
     def parse(self, input):
         """
@@ -352,9 +364,9 @@ class Parser(object):
             # from start to flush_until will be emitted as
             # a TEXT event
             flush_until = None
-            # if not None, will be yielded as is, but only
-            # after any necessary flushing
-            special_ev = None
+            # if not empty, each of its elements will be yield
+            # one by one, but only after any necessary flushing
+            special_evs = []
 
             # control characters flush before advancing pos
             # in order to not add them to the buffer -- we
@@ -362,7 +374,7 @@ class Parser(object):
             # relying of gtk's default behavior.
             if code == '\a':
                 flush_until = it.pos
-                special_ev = (EventType.BELL, None)
+                special_evs.append((EventType.BELL, None))
             elif code == '\033':
                 # ignore_esc can be set if we encounter a '\033'
                 # which is followed by a sequence we don't understand.
@@ -412,10 +424,6 @@ class Parser(object):
                                     # as separators.
                                     args = re.split(r'[:;]', params)
 
-                                    # track if we support the used sequences,
-                                    # only emit an event if that is the case
-                                    supported = False
-
                                     arg_it = iter(args)
                                     for arg in arg_it:
                                         if len(arg) == 0:
@@ -427,93 +435,111 @@ class Parser(object):
                                             except ValueError:
                                                 raise AssertionError("Invalid Integer")
 
-                                        this_supported = True
+                                        change_payload = None
+
+                                        # Not supported:
+                                        #   5-6     blink
+                                        #   7       invert
+                                        #   10      default font
+                                        #   11-19   alternative font
+                                        #   20      blackletter font
+                                        #   25      disable blinking
+                                        #   26      proportional spacing
+                                        #   27      disable inversion
+                                        #   50      disable proportional spacing
+                                        #   51      framed
+                                        #   52      encircled
+                                        #   53      overlined (TODO: implement via GTK 4 TextTag)
+                                        #   54      neither framed nor encircled
+                                        #   55      not overlined
+                                        #   60-65   ideograms (TODO: find out what this is supposed to do)
+                                        #   58-59   underline color, non-standard
+                                        #   73-65   sub/superscript, non-standard (TODO: via scale and rise)
                                         if sgr_type == 0:
-                                            self.__text_style.reset_all()
+                                            change_payload = (TextStyleChange.RESET, None)
                                         elif sgr_type == 1:
-                                            self.__text_style.intensity = Pango.Weight.BOLD
+                                            change_payload = (TextStyleChange.WEIGHT, Pango.Weight.BOLD)
                                         elif sgr_type == 2:
-                                            self.__text_style.intensity = Pango.Weight.THIN
+                                            change_payload = (TextStyleChange.WEIGHT, Pango.Weight.THIN)
                                         elif sgr_type == 3:
-                                            self.__text_style.italic = True
+                                            change_payload = (TextStyleChange.ITALIC, True)
                                         elif sgr_type == 4:
-                                            self.__text_style.underline = Pango.Underline.SINGLE
+                                            change_payload = (TextStyleChange.UNDERLINE, Pango.Underline.SINGLE)
                                         elif sgr_type == 8:
-                                            self.__text_style.concealed = True
+                                            change_payload = (TextStyleChange.CONCEALED, True)
                                         elif sgr_type == 9:
-                                            self.__text_style.strikethrough = True
+                                            change_payload = (TextStyleChange.STRIKETHROUGH, True)
                                         elif sgr_type == 21:
-                                            self.__text_style.underline = Pango.Underline.DOUBLE
+                                            change_payload = (TextStyleChange.UNDERLINE, Pango.Underline.DOUBLE)
                                         elif sgr_type == 22:
-                                            self.__text_style.intensity = Pango.Weight.NORMAL
+                                            change_payload = (TextStyleChange.WEIGHT, Pango.Weight.NORMAL)
                                         elif sgr_type == 23:
                                             # also theoretically should disable blackletter
-                                            self.__text_style.italic = False
+                                            change_payload = (TextStyleChange.ITALIC, False)
                                         elif sgr_type == 24:
-                                            self.__text_style.underline = Pango.Underline.NONE
+                                            change_payload = (TextStyleChange.UNDERLINE, Pango.Underline.NONE)
                                         elif sgr_type == 28:
-                                            self.__text_style.concealed = False
+                                            change_payload = (TextStyleChange.CONCEALED, False)
                                         elif sgr_type == 29:
-                                            self.__text_style.strikethrough = False
+                                            change_payload = (TextStyleChange.STRIKETHROUGH, False)
                                         elif sgr_type >= 30 and sgr_type <= 37:
-                                            self.__text_style.fg_color = Color(
-                                                ColorType.NUMBERED_8,
-                                                BasicColor(sgr_type - 30)
+                                            change_payload = (
+                                                TextStyleChange.FOREGROUND_COLOR,
+                                                Color(
+                                                    ColorType.NUMBERED_8,
+                                                    BasicColor(sgr_type - 30)
+                                                )
                                             )
                                         elif sgr_type == 38:
                                             try:
-                                                self.__text_style.fg_color = parse_extended_color(arg_it)
+                                                change_payload = (
+                                                    TextStyleChange.FOREGROUND_COLOR,
+                                                    parse_extended_color(arg_it)
+                                                )
                                             except AssertionError:
-                                                this_supported = False
+                                                # TODO: maybe fail here?
+                                                pass
                                         elif sgr_type == 39:
-                                            self.__text_style.fg_color = None
+                                            change_payload = (TextStyleChange.FOREGROUND_COLOR, None)
                                         elif sgr_type >= 40 and sgr_type <= 47:
-                                            self.__text_style.bg_color = Color(
-                                                ColorType.NUMBERED_8,
-                                                BasicColor(sgr_type - 40)
+                                            change_payload = (
+                                                TextStyleChange.BACKGROUND_COLOR,
+                                                Color(
+                                                    ColorType.NUMBERED_8,
+                                                    BasicColor(sgr_type - 40)
+                                                )
                                             )
                                         elif sgr_type == 48:
                                             try:
-                                                self.__text_style.bg_color = parse_extended_color(arg_it)
+                                                change_payload = (
+                                                    TextStyleChange.BACKGROUND_COLOR,
+                                                    parse_extended_color(arg_it)
+                                                )
                                             except AssertionError:
-                                                this_supported = False
+                                                # TODO: maybe fail here?
+                                                pass
                                         elif sgr_type == 49:
-                                            self.__text_style.bg_color = None
+                                            change_payload = (TextStyleChange.BACKGROUND_COLOR, None)
                                         elif sgr_type >= 90 and sgr_type <= 97:
-                                            self.__text_style.fg_color = Color(
-                                                ColorType.NUMBERED_8_BRIGHT,
-                                                BasicColor(sgr_type - 90)
+                                            change_payload = (
+                                                TextStyleChange.FOREGROUND_COLOR,
+                                                Color(
+                                                    ColorType.NUMBERED_8_BRIGHT,
+                                                    BasicColor(sgr_type - 90)
+                                                )
                                             )
                                         elif sgr_type >= 100 and sgr_type <= 107:
-                                            self.__text_style.bg_color = Color(
-                                                ColorType.NUMBERED_8_BRIGHT,
-                                                BasicColor(sgr_type - 100)
+                                            change_payload = (
+                                                TextStyleChange.BACKGROUND_COLOR,
+                                                Color(
+                                                    ColorType.NUMBERED_8_BRIGHT,
+                                                    BasicColor(sgr_type - 100)
+                                                )
                                             )
-                                        else:
-                                            # Not supported:
-                                            #   5-6     blink
-                                            #   7       invert
-                                            #   10      default font
-                                            #   11-19   alternative font
-                                            #   20      blackletter font
-                                            #   25      disable blinking
-                                            #   26      proportional spacing
-                                            #   27      disable inversion
-                                            #   50      disable proportional spacing
-                                            #   51      framed
-                                            #   52      encircled
-                                            #   53      overlined (TODO: implement via GTK 4 TextTag)
-                                            #   54      neither framed nor encircled
-                                            #   55      not overlined
-                                            #   60-65   ideograms (TODO: find out what this is supposed to do)
-                                            #   58-59   underline color, non-standard
-                                            #   73-65   sub/superscript, non-standard (TODO: via scale and rise)
-                                            this_supported = False
-
-                                        supported = supported or this_supported
-
-                                    if supported:
-                                        special_ev = (EventType.TEXT_STYLE, self.__text_style)
+
+                                        if change_payload != None:
+                                            special_evs.append((EventType.TEXT_STYLE, change_payload))
+
 
                             except AssertionError:
                                 # invalid CSI sequence, we'll render it as text for now
@@ -552,5 +578,6 @@ class Parser(object):
             if flush_until != None:
                 start = it.pos + 1
 
-            if special_ev != None:
-                yield special_ev
+            if len(special_evs) > 0:
+                for ev in special_evs:
+                    yield ev
diff --git a/saneterm/terminal.py b/saneterm/terminal.py
index eadd6a1..068d714 100644
--- a/saneterm/terminal.py
+++ b/saneterm/terminal.py
@@ -38,8 +38,10 @@ class Terminal(Gtk.Window):
         self.pty.attach(None)
 
         self.pty_parser = pty.Parser()
-        # gtk TextTag to use, generated from TEXT_STYLE events
-        self.text_insert_tag = None
+        # Gtk TextTags to use, generated from TEXT_STYLE events
+        self.active_text_tags = {}
+        # Already created TextTags which are reused to save on allocs
+        self.cached_text_tags = {}
 
         self.termview = TermView(self.complete, limit)
 
@@ -155,6 +157,64 @@ class Terminal(Gtk.Window):
         ws = struct.pack('HHHH', rows, cols, width, height) # struct winsize
         fcntl.ioctl(self.pty.master, termios.TIOCSWINSZ, ws)
 
+    def get_tag_for(self, ev_data):
+        """
+        Return a Gtk TextTag for the text formatting encoded in
+        the given TEXT_STYLE event's payload (consisting of a
+        tuple of a TextStyleChange and an associated value).
+        If the tag is requested for the first time, the tag
+        is added to the TextTagTable in the TermView's buffer
+        and a reference to it stored in self.cached_text_tags.
+        From the next request onwards the cached tag is returned.
+        """
+        # check for previously created tag
+        if ev_data in self.cached_text_tags:
+            return self.cached_text_tags[ev_data]
+
+        # otherwise create a new tag or cache None
+        # which indicates that the default Gtk style is appropriate
+        (change, value) = ev_data
+        buf = self.termview.get_buffer()
+
+        if change is pty.TextStyleChange.ITALIC:
+            tag = buf.create_tag(None, style=Pango.Style.ITALIC) if value else None
+        elif change is pty.TextStyleChange.STRIKETHROUGH:
+            tag = buf.create_tag(None, strikethrough=value) if value else None
+        elif change is pty.TextStyleChange.WEIGHT:
+            if value is Pango.Weight.NORMAL:
+                tag = None
+            else:
+                tag = buf.create_tag(None, weight=value)
+        elif change is pty.TextStyleChange.UNDERLINE:
+            if value is Pango.Underline.NONE:
+                tag = None
+            else:
+                tag = buf.create_tag(None, underline=value)
+        elif change is pty.TextStyleChange.CONCEALED:
+            tag = buf.create_tag(None, invisible=value) if value else None
+        elif change is pty.TextStyleChange.FOREGROUND_COLOR:
+            if value is None:
+                tag = None
+            else:
+                rgba = value.to_gdk()
+                tag = buf.create_tag(None, foreground_rgba=rgba)
+        elif change is pty.TextStyleChange.BACKGROUND_COLOR:
+            if value is None:
+                tag = None
+            else:
+                rgba = value.to_gdk()
+                tag = buf.create_tag(None, background_rgba=rgba)
+        else:
+            # should never be called with TextStyleChange.RESET
+            raise AssertionError("unknown event or not applicable for this style change")
+
+        # note that we cache using the whole event data
+        # as opposed to using just the TextStyleChange
+        # when tracking the current display state
+        self.cached_text_tags[ev_data] = tag
+
+        return tag
+
     def handle_pty(self, source, tag, master):
         cond = source.query_unix_fd(tag)
         if cond & GLib.IOCondition.HUP:
@@ -169,12 +229,35 @@ class Terminal(Gtk.Window):
 
         for (ev, data) in self.pty_parser.parse(decoded):
             if ev is pty.EventType.TEXT:
-                self.termview.insert_data(data, self.text_insert_tag)
+                self.termview.insert_data(data, *self.active_text_tags.values())
             elif ev is pty.EventType.BELL:
                 self.termview.error_bell()
                 self.set_urgency_hint(True)
             elif ev is pty.EventType.TEXT_STYLE:
-                self.text_insert_tag = data.to_tag(self.termview.get_buffer())
+                (change, _) = data
+
+                if change is pty.TextStyleChange.RESET:
+                    # On RESET we just use the default style of the TermView
+                    self.active_text_tags = {}
+                else:
+                    # To avoid creating an unnecessary amount of TextTags,
+                    # we let get_tag_for create and cache TextTags.
+                    new_tag = self.get_tag_for(data)
+
+                    # Instead of a single tag which has the exact attributes
+                    # we need, we apply multiple tags which makes the tags
+                    # more cacheable. We track the currently active tags in
+                    # a dict mapping from TextStyleChanges to TextTags.
+                    # Since all tags associated with the same TextStyleChange
+                    # are mutually exclusive, we get the correct state updates
+                    # for free. In cases where the default style of TermView
+                    # is appropriate, get_tag_for() returns None and we delete
+                    # the respective entry from the dict.
+                    if new_tag is None:
+                        if change in self.active_text_tags:
+                            del self.active_text_tags[change]
+                    else:
+                        self.active_text_tags[change] = new_tag
             else:
                 raise AssertionError("unknown pty.EventType")
 
diff --git a/saneterm/termview.py b/saneterm/termview.py
index 1ce35a7..eda9b6e 100644
--- a/saneterm/termview.py
+++ b/saneterm/termview.py
@@ -121,11 +121,8 @@ class TermView(Gtk.TextView):
                 GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE,
                 (GObject.TYPE_PYOBJECT,))
 
-    def insert_data(self, str, tag=None):
-        if tag is None:
-            self._textbuffer.insert(self._textbuffer.get_end_iter(), str)
-        else:
-            self._textbuffer.insert_with_tags(self._textbuffer.get_end_iter(), str, tag)
+    def insert_data(self, str, *tags):
+        self._textbuffer.insert_with_tags(self._textbuffer.get_end_iter(), str, *tags)
 
         end = self._textbuffer.get_end_iter()
         self._last_mark = self._textbuffer.create_mark(None, end, True)