From 3aa2bfdbade7bb620fbbdd1be3d041fd7bf64405 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Wed, 12 Aug 2026 17:30:21 -0700 Subject: [PATCH] Keep dashes and ellipses with the words they cling to An em dash written as "---" joins two words into a single token, since the wrapper breaks only on whitespace. That was safe but crude: the pair moved as a unit, and the dash never got a break of its own. Sweeping the cursor across the margin against a fragment that opens with a dash -- the shape the navigation panel uses -- turned up something sharper. Such a line could run past the margin, to column 83 in an 80 column terminal. A fragment starting with punctuation is granted SafetyMargin columns so that a lone period is not stranded by itself; a hyphen is punctuation, so fifty characters of prose beginning with a dash collected a concession meant for one character. The terminal hides this behind the five columns PagedOutput holds back. --width and the web driver do not. Three rules now, and an ellipsis wants all of them as much as a dash does: - A break may fall on the far side of a run of two or more hyphens or periods, which is the one place inside a word where a line may end. The far side must be a word: to break "am I..." from "?" would strand the question mark, which is the fault being repaired rather than a second instance of it. - No break is chosen that would leave such a run at the head of a line. The mark belongs to the word behind it and travels with it. - The punctuation concession is for a stub of no more than SafetyMargin characters, not for any clause that merely opens with a mark. A single hyphen is left alone. It belongs to the word it joins, and "lead-lined" is not a place to break a line. The second rule governs the break the wrapper chooses. It does not reach the case where there is no room to choose anything -- a fragment opening with a dash, arriving when the line is already full. That one goes down to the next line and starts it with the mark, which is the same answer the wrapper has always given a word too long to fit: hold the margin, and let the line begin with whatever had to be moved. Overrunning the margin is the fault that shows in a narrow window. Starship and Gorreven produce byte-identical output across the change. Neither uses the unspaced forms yet, which is the point: this is inert on everything already written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013Ui8UMgev1U8LW5iyQdSsJ --- src/TestWrappedOutput.cc | 91 ++++++++++++++++++++++++++++++++++++++++ src/TestWrappedOutput.hh | 1 + src/WrappedOutput.cc | 67 ++++++++++++++++++++++++++++- 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/TestWrappedOutput.cc b/src/TestWrappedOutput.cc index 58a5ae3..cdb7f46 100644 --- a/src/TestWrappedOutput.cc +++ b/src/TestWrappedOutput.cc @@ -152,9 +152,100 @@ namespace archetype { out() << "TestWrappedOutput::testWrapAcrossPuts_ finished." << endl; } + void TestWrappedOutput::testClingingMarks_() { + UserOutput user_soutput = make_shared(); + StringOutput& strout(*dynamic_cast(user_soutput.get())); + UserOutput user_output = make_shared(user_soutput); + WrappedOutput& wrout(*dynamic_cast(user_output.get())); + + auto delta = [&](size_t& mark) { + string all = strout.getOutput(); + string d = all.substr(mark); + mark = all.size(); + return d; + }; + size_t mark = 0; + + // An em dash joins two words into one long token. The break falls on + // the far side of the dash, so the line ends with it and the next one + // opens with a word. + wrout.setMaxColumns(20); + user_output->put("I sat in a chair---sort of, anyway."); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), + string("I sat in a chair---\n" + "sort of, anyway.\n")); + + // The same for an ellipsis that runs into the next word. + wrout.resetCursor(); + user_output->put("I waited and then...something moved."); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), + string("I waited and then...\n" + "something moved.\n")); + + // But never between the mark and punctuation: breaking "am I..." + // from "?" would strand the question mark at the head of a line. + wrout.resetCursor(); + user_output->put("I cannot say who am I...?"); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), + string("I cannot say who am\n" + "I...?\n")); + + // A single hyphen is not a dash: it belongs to the word it joins, + // and is no place to break a line. + wrout.resetCursor(); + user_output->put("the lead-lined coveralls are heavy"); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), + string("the lead-lined\n" + "coveralls are heavy\n")); + + // A fragment arriving with the line already full has nowhere to put + // its dash but the head of the next line. No break was chosen to do + // that; there was no room to choose anything. The margin is what is + // held to here, since overrunning it is the fault that shows. + wrout.resetCursor(); + user_output->put("aaa bbb ccc ddd eeee"); + user_output->put("---probably that meteor"); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), + string("aaa bbb ccc ddd eeee\n" + "---probably that\n" + "meteor\n")); + + // The punctuation safety margin is for a stub and not for a clause. + // A dash-led fragment used to collect it and print past the margin. + wrout.resetCursor(); + user_output->put("aaa bbb ccc ddd"); + user_output->put("---and a long tail of words"); + user_output->endLine(); + for (const string& line : {string("aaa bbb ccc ddd---"), + string("and a long tail of"), + string("words")}) { + ARCHETYPE_TEST(line.size() <= 20); + } + ARCHETYPE_TEST_EQUAL(delta(mark), + string("aaa bbb ccc ddd---\n" + "and a long tail of\n" + "words\n")); + + // A stub still gets its concession: a lone period arriving on the + // margin stays with the sentence it ends. + wrout.resetCursor(); + user_output->put("aaa bbb ccc ddd eeee"); + user_output->put("."); + user_output->endLine(); + ARCHETYPE_TEST_EQUAL(delta(mark), string("aaa bbb ccc ddd eeee.\n")); + + out() << "TestWrappedOutput::testClingingMarks_ finished." << endl; + } + void TestWrappedOutput::runTests_() { testBasicWrap_(); testCenter_(); testWrapAcrossPuts_(); + testClingingMarks_(); } } diff --git a/src/TestWrappedOutput.hh b/src/TestWrappedOutput.hh index f328fbe..fa15497 100644 --- a/src/TestWrappedOutput.hh +++ b/src/TestWrappedOutput.hh @@ -16,6 +16,7 @@ namespace archetype { void testBasicWrap_(); void testCenter_(); void testWrapAcrossPuts_(); + void testClingingMarks_(); protected: virtual void runTests_() override; public: diff --git a/src/WrappedOutput.cc b/src/WrappedOutput.cc index eafd72e..aacb5e3 100644 --- a/src/WrappedOutput.cc +++ b/src/WrappedOutput.cc @@ -18,6 +18,37 @@ using namespace std; namespace archetype { const int SafetyMargin = 3; + namespace { + // A mark that clings to the word in front of it: the "--" and "---" + // that stand in for an em dash, and the "..." of an ellipsis. A lone + // hyphen belongs to the word it joins and a lone period ends a + // sentence, so neither of those needs a rule here. + bool isClingingMark(char c) { + return c == '-' or c == '.'; + } + + // Does the text at p open with such a mark? No break is chosen that + // would put one at the head of a line. A break forced for want of + // anywhere else to put it is another matter: the margin is held to + // in that case, the same as for a word too long to fit. + bool opensWithMark(std::string::const_iterator p, + std::string::const_iterator end) { + return end - p >= 2 and isClingingMark(p[0]) and p[1] == p[0]; + } + + // Would a line ending at p end with such a mark, and the next one + // open with a word? This is the one position inside a word where a + // break may fall: "the chair---" | "sort of". The word on the far + // side is the point of the test -- breaking "am I..." from "?" would + // strand the question mark, which is the very thing being avoided. + bool followsMark(std::string::const_iterator begin, + std::string::const_iterator p) { + return p - begin >= 2 and + isClingingMark(p[-1]) and p[-2] == p[-1] and + isalnum(static_cast(*p)); + } + } + WrappedOutput::WrappedOutput(UserOutput output, int max_columns): output_{output} { setMaxColumns(max_columns); @@ -47,14 +78,46 @@ namespace archetype { int remaining = max(0, maxColumns_ - cursor_); // Keep trailing punctuation from being orphaned on the next line. + // The concession is meant for a stub -- a period, a comma, a closing + // quote -- and not for a whole clause that merely opens with a mark, + // which is what a fragment beginning with a dash looks like from + // here. Handing those three columns to fifty characters of prose + // just prints a line past the margin. if (not s.empty() and ispunct(static_cast(s[0]))) { - remaining += SafetyMargin; + auto first_space = ranges::find_if(s, [](unsigned char c) { + return isspace(c); + }); + if (first_space - s.begin() <= SafetyMargin) { + remaining += SafetyMargin; + } } while (int(s.size()) > remaining) { + // Whether a break may fall at p. Whitespace is the usual place; + // the far side of a dash or an ellipsis is the other. Neither + // will do if what follows opens with such a mark, because a line + // may not begin with one: the mark belongs to the word behind it + // and has to travel with it. + auto marksNextLine = [&s](string::const_iterator p) { + while (p != s.cend() and isspace(static_cast(*p))) { + ++p; + } + return opensWithMark(p, s.cend()); + }; + auto breakable = [&](string::const_iterator p) { + if (p == s.cbegin()) { + return false; + } + if (not isspace(static_cast(*p)) and + not followsMark(s.cbegin(), p)) { + return false; + } + return not marksNextLine(p); + }; + // Walk backward to find a breaking point. auto cut_p = s.begin() + remaining; - while (not isspace(static_cast(*cut_p)) and cut_p != s.begin()) { + while (cut_p != s.begin() and not breakable(cut_p)) { --cut_p; }