Retool word wrapping
authorJeremy Stanley <fungi@yuggoth.org>
Fri, 25 May 2018 06:10:52 +0000 (06:10 +0000)
committerJeremy Stanley <fungi@yuggoth.org>
Fri, 25 May 2018 06:25:57 +0000 (06:25 +0000)
Better handle CR+LF injection when encountering words longer than
the terminal width. If a word is so wide it cannot be wrapped, leave
it on a line by itself and allow the terminal to apply its own
wrapping rules instead. Fixes a bug where excessive EOL markers
would get added in such situations. Also more accurately handles
skipping ANSI escape sequences in subsequently wrapped content.

Include a word-wrapping test in the selftests to avoid regressing
here.

mudpy/misc.py
mudpy/tests/selftest.py

index 4355586..439e00a 100644 (file)
@@ -1116,8 +1116,10 @@ def wrap_ansi_text(text, width):
     # ignoring color escape sequences
     rel_pos = 0
 
-    # the absolute position of the most recent whitespace character
-    last_whitespace = 0
+    # the absolute and relative positions of the most recent whitespace
+    # character
+    last_abs_whitespace = 0
+    last_rel_whitespace = 0
 
     # whether the current character is part of a color escape sequence
     escape = False
@@ -1131,39 +1133,38 @@ def wrap_ansi_text(text, width):
         # the current character is the escape character
         if each_character == "\x1b" and not escape:
             escape = True
+            rel_pos -= 1
 
         # the current character is within an escape sequence
         elif escape:
-
-            # the current character is m, which terminates the
-            # escape sequence
+            rel_pos -= 1
             if each_character == "m":
+                # the current character is m, which terminates the
+                # escape sequence
                 escape = False
 
-        # the current character is a newline, so reset the relative
-        # position (start a new line)
-        elif each_character == "\n":
-            rel_pos = 0
-            last_whitespace = abs_pos
-
-        # the current character meets the requested maximum line width,
-        # so we need to backtrack and find a space at which to wrap;
-        # special care is taken to avoid an off-by-one in case the
-        # current character is a double-width glyph
-        elif each_character != "\r" and (
-            rel_pos >= width or (
-                rel_pos >= width - 1 and glyph_columns(
-                    each_character
-                ) == 2
-            )
-        ):
-
-            # it's always possible we landed on whitespace
-            if unicodedata.category(each_character) in ("Cc", "Zs"):
-                last_whitespace = abs_pos
-
-            # insert an eol in place of the space
-            text = text[:last_whitespace] + "\r\n" + text[last_whitespace + 1:]
+        # track the most recent whitespace we've seen
+        # TODO(fungi) exclude non-breaking spaces (\x0a)
+        elif unicodedata.category(each_character) in ("Cc", "Zs"):
+            if each_character == "\n":
+                # the current character is a newline, so reset the relative
+                # position too (start a new line)
+                rel_pos = 0
+            if each_character != "\r":
+                # the current character is not a carriage return, so mark it as
+                # whitespace (we don't want to break and wrap between CR+LF)
+                last_abs_whitespace = abs_pos
+                last_rel_whitespace = rel_pos
+
+        # the current character meets the requested maximum line width, so we
+        # need to wrap unless the current word is wider than the terminal (in
+        # which case we let it do the wrapping instead)
+        if last_rel_whitespace != 0 and (rel_pos > width or (
+                rel_pos > width - 1 and glyph_columns(each_character) == 2)):
+
+            # insert an eol in place of the last space
+            text = (text[:last_abs_whitespace] + "\r\n" +
+                    text[last_abs_whitespace + 1:])
 
             # increase the absolute position because an eol is two
             # characters but the space it replaced was only one
@@ -1171,9 +1172,8 @@ def wrap_ansi_text(text, width):
 
             # now we're at the begining of a new line, plus the
             # number of characters wrapped from the previous line
-            rel_pos = 0
-            for remaining_characters in text[last_whitespace:abs_pos]:
-                rel_pos += glyph_columns(remaining_characters)
+            rel_pos -= last_rel_whitespace
+            last_rel_whitespace = 0
 
         # as long as the character is not a carriage return and the
         # other above conditions haven't been met, count it as a
@@ -1181,7 +1181,9 @@ def wrap_ansi_text(text, width):
         elif each_character != "\r":
             rel_pos += glyph_columns(each_character)
             if unicodedata.category(each_character) in ("Cc", "Zs"):
-                last_whitespace = abs_pos
+                # TODO(fungi) exclude non-breaking spaces (\x0a)
+                last_abs_whitespace = abs_pos
+                last_rel_whitespace = rel_pos
 
         # increase the absolute position for every character
         abs_pos += 1
index b859484..bf32434 100644 (file)
@@ -96,6 +96,11 @@ test_chat_mode = (
     (0, r'says, "Now less chatty\."', ""),
 )
 
+test_wrapping = (
+    (0, '> ', "say " + 100 * "o"),
+    (1, r'says,\r\n"O[o]+\."', ""),
+)
+
 test_movement = (
     (0, "> ", "move north"),
     (0, r"You exit to the north\.", ""),
@@ -257,6 +262,7 @@ dialogue = (
     (test_typo_replacement, "typo replacement"),
     (test_sentence_capitalization, "sentence capitalization"),
     (test_chat_mode, "chat mode"),
+    (test_wrapping, "wrapping"),
     (test_movement, "movement"),
     (test_actor_disappears, "actor spontaneous disappearance"),
     (test_account1_teardown, "second account teardown"),