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; }