Fix :j / utils.justify wiping empty lines. Resolve #3649

This commit is contained in:
Griatch 2026-02-15 10:13:59 +01:00
parent ff76bd2388
commit cd0d896620
4 changed files with 100 additions and 40 deletions

View file

@ -34,6 +34,7 @@
avoid inconsistencies with PostgreSQL databases (Griatch)
- [Fix][issue3513]: Fixed issue where OnDemandHandler could traceback on an
un-pickle-able object and cause an error at server shutdown (Griatch)
- [Fix][issue3649]: The `:j` command in EvEditor would squash empty lines (Griatch)
- [Doc][pull3801]: Move Evennia doc build system to latest Sphinx/myST
(PowershellNinja, also honorary mention to electroglyph)
- [Doc][pull3800]: Describe support for Telnet SSH in HAProxy documentation (holl0wstar)
@ -63,6 +64,7 @@
[pull3733]: https://github.com/evennia/evennia/pull/3853
[issue3858]: https://github.com/evennia/evennia/issues/3858
[issue3813]: https://github.com/evennia/evennia/issues/3513
[issue3649]: https://github.com/evennia/evennia/issues/3649
## Evennia 5.0.1

View file

@ -338,6 +338,33 @@ class TestEvEditor(BaseEvenniaCommandTest):
self.assertEqual(l3, " " * 37 + "l 3" + " " * 38)
self.assertEqual(l4, "l" + " " * 76 + "4")
def test_eveditor_COLON_J_preserves_paragraph_breaks(self):
"""
Test to verify fix of issue #3649 (:j command broke input)
"""
eveditor.EvEditor(self.char1)
self.call(
eveditor.CmdEditorGroup(),
"",
raw_string=":",
msg="Line Editor []\n01\n[l:01 w:000 c:0000](:h for help)",
)
text = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\n"
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
)
self.char1.ndb._eveditor.update_buffer(text)
self.call(
eveditor.CmdEditorGroup(),
"=40",
raw_string=":j",
msg="Left-justified lines 1-3.",
)
lines = self.char1.ndb._eveditor.get_buffer().split("\n")
blank_line_index = lines.index(" " * 40)
self.assertEqual(blank_line_index, 2)
self.assertTrue(lines[blank_line_index + 1].startswith("Sed do eiusmod"))
def test_eveditor_bad_commands(self):
eveditor.EvEditor(self.char1)
self.call(

View file

@ -811,6 +811,25 @@ class TestJustify(TestCase):
self.assertIn(ANSI_RED, str(result))
def test_justify_preserves_paragraph_breaks(self):
text = "Para one words here.\n\nPara two words there."
result = utils.justify(text, width=20, align="l")
self.assertEqual(
"Para one words here.\n \nPara two words \nthere. ",
result,
)
def test_justify_preserves_paragraph_breaks_with_ansi(self):
from evennia.utils.ansi import ANSI_RED
text = ANSIString("Para one has |rred|n text.\n\nPara two.")
result = utils.justify(text, width=20, align="l")
clean_lines = result.clean().split("\n")
self.assertIn(ANSI_RED, str(result))
self.assertEqual(" " * 20, clean_lines[2])
self.assertEqual("Para two. ", clean_lines[3])
class TestAtSearchResult(TestCase):
"""

View file

@ -249,13 +249,13 @@ def justify(text, width=None, align="l", indent=0, fillchar=" "):
is_ansi = isinstance(text, _ANSISTRING)
lb = _ANSISTRING("\n") if is_ansi else "\n"
def _process_line(line):
def _process_line(line, line_word_length, line_gaps):
"""
helper function that distributes extra spaces between words. The number
of gaps is nwords - 1 but must be at least 1 for single-word lines. We
distribute odd spaces to one of the gaps.
"""
line_rest = width - (wlen + ngaps)
line_rest = width - (line_word_length + line_gaps)
gap = _ANSISTRING(" ") if is_ansi else " "
@ -277,8 +277,8 @@ def justify(text, width=None, align="l", indent=0, fillchar=" "):
else:
line[-1] = line[-1] + pad + sp * (line_rest % 2)
else: # align 'f'
gap += sp * (line_rest // max(1, ngaps))
rest_gap = line_rest % max(1, ngaps)
gap += sp * (line_rest // max(1, line_gaps))
rest_gap = line_rest % max(1, line_gaps)
for i in range(rest_gap):
line[i] += sp
elif not any(line):
@ -302,49 +302,61 @@ def justify(text, width=None, align="l", indent=0, fillchar=" "):
# all other aligns requires splitting into paragraphs and words
# split into paragraphs and words
paragraphs = [text] # re.split("\n\s*?\n", text, re.MULTILINE)
words = []
for ip, paragraph in enumerate(paragraphs):
if ip > 0:
words.append(("\n", 0))
words.extend((word, m_len(word)) for word in paragraph.split())
def _justify_paragraph(words):
ngaps = 0
wlen = 0
line = []
lines = []
if not words:
# Just whitespace!
return sp * width
ngaps = 0
wlen = 0
line = []
lines = []
while words:
if not line:
# start a new line
word = words.pop(0)
wlen = word[1]
line.append(word[0])
elif (words[0][1] + wlen + ngaps) >= width:
# next word would exceed word length of line + smallest gaps
lines.append(_process_line(line))
ngaps, wlen, line = 0, 0, []
else:
# put a new word on the line
word = words.pop(0)
line.append(word[0])
if word[1] == 0:
# a new paragraph, process immediately
lines.append(_process_line(line))
while words:
if not line:
# start a new line
word = words.pop(0)
wlen = word[1]
line.append(word[0])
elif (words[0][1] + wlen + ngaps) >= width:
# next word would exceed word length of line + smallest gaps
lines.append(_process_line(line, wlen, ngaps))
ngaps, wlen, line = 0, 0, []
else:
# put a new word on the line
word = words.pop(0)
line.append(word[0])
wlen += word[1]
ngaps += 1
if line: # catch any line left behind
lines.append(_process_line(line))
if line: # catch any line left behind
lines.append(_process_line(line, wlen, ngaps))
return lines
paragraphs = []
paragraph_words = []
for input_line in text.split("\n"):
line_words = [(word, m_len(word)) for word in input_line.split()]
if line_words:
paragraph_words.extend(line_words)
else:
if paragraph_words:
paragraphs.append(paragraph_words)
paragraph_words = []
paragraphs.append(None)
if paragraph_words:
paragraphs.append(paragraph_words)
if not paragraphs:
# Just whitespace!
return sp * width
blank_line = _ANSISTRING(sp * width) if is_ansi else sp * width
lines = []
for paragraph in paragraphs:
if paragraph is None:
lines.append(blank_line)
else:
lines.extend(_justify_paragraph(paragraph[:]))
indentstring = sp * indent
out = lb.join([indentstring + line for line in lines])
return lb.join([indentstring + line for line in lines])