diff --git a/src/storied/display.py b/src/storied/display.py index 6a8b2ad..d7dd424 100644 --- a/src/storied/display.py +++ b/src/storied/display.py @@ -73,6 +73,11 @@ class StreamRenderer: - SOL peek: buffers start-of-line characters to classify line type - Inline text: streams characters with bold/italic/code ANSI escapes - Block: accumulates lines, renders Rich Panel on closing fence + + Word wrapping: inline text is buffered word-by-word. At each word + boundary (space), we check whether the word fits on the current line + and wrap to the next line if not. ANSI escapes ride along in the + word buffer but don't count toward visible width. """ def __init__(self, console: Console) -> None: @@ -93,6 +98,16 @@ class StreamRenderer: self._at_sol = True self._sol_buf = "" + # Word wrapping state + self._col = 0 + self._word_buf = "" + self._word_width = 0 + self._pending_space = False + + @property + def _width(self) -> int: + return self._console.width + def feed(self, chunk: str) -> None: """Process a text chunk, streaming output to terminal.""" for char in chunk: @@ -107,10 +122,13 @@ class StreamRenderer: def flush(self) -> None: """Flush all pending state at end of stream.""" self._flush_hold() + self._flush_word() if self._sol_buf: for c in self._sol_buf: self._feed_inline(c) self._sol_buf = "" + self._flush_hold() + self._flush_word() if self._block is not None: for line in self._block["lines"]: self._out.write(line + "\n") @@ -120,6 +138,8 @@ class StreamRenderer: self._block_line = "" self._reset_styles() self._at_sol = True + self._col = 0 + self._pending_space = False self._out.flush() # ── Block mode ─────────────────────────────────────────────────── @@ -195,42 +215,62 @@ class StreamRenderer: heading = re.match(r"^(#{1,3})\s+(.*)", line) if heading: text = heading.group(2) - self._out.write(BOLD_ON) + self._word_buf += BOLD_ON self._emit_inline_text(text) self._flush_hold() + self._flush_word() self._out.write(BOLD_OFF + "\n") + self._col = 0 + self._pending_space = False self._out.flush() return if line.startswith("- ") or line.startswith("* "): - self._out.write(" • ") + self._out.write(" \u2022 ") + self._col = 4 self._emit_inline_text(line[2:]) self._flush_hold() + self._flush_word() self._out.write("\n") + self._col = 0 + self._pending_space = False self._out.flush() return if line.startswith("> "): - self._out.write(" " + DIM_ON) + self._out.write(" ") + self._word_buf += DIM_ON + self._col = 2 self._emit_inline_text(line[2:]) self._flush_hold() + self._flush_word() self._out.write(DIM_OFF + "\n") + self._col = 0 + self._pending_space = False self._out.flush() return numbered = re.match(r"^(\d+\.\s)(.*)", line) if numbered: - self._out.write(" " + numbered.group(1)) + prefix = " " + numbered.group(1) + self._out.write(prefix) + self._col = len(prefix) self._emit_inline_text(numbered.group(2)) self._flush_hold() + self._flush_word() self._out.write("\n") + self._col = 0 + self._pending_space = False self._out.flush() return # Fallback: regular text that happened to be line-buffered self._emit_inline_text(line) self._flush_hold() + self._flush_word() self._out.write("\n") + self._col = 0 + self._pending_space = False self._out.flush() # ── Inline text mode ───────────────────────────────────────────── @@ -241,23 +281,58 @@ class StreamRenderer: self._hold = "" if prev == "*" and char == "*": self._bold = not self._bold - self._out.write(BOLD_ON if self._bold else BOLD_OFF) + self._word_buf += BOLD_ON if self._bold else BOLD_OFF return # Single * — toggle italic, then process current char self._italic = not self._italic - self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) + self._word_buf += ITALIC_ON if self._italic else ITALIC_OFF if char == "*" and not self._code: self._hold = "*" elif char == "`": self._code = not self._code - self._out.write(CODE_ON if self._code else CODE_OFF) + self._word_buf += CODE_ON if self._code else CODE_OFF elif char == "\n": + self._flush_word() self._out.write("\n") + self._col = 0 + self._pending_space = False self._at_sol = True self._sol_buf = "" + elif char == " ": + self._flush_word() + self._pending_space = True else: - self._out.write(char) + self._word_buf += char + self._word_width += 1 + + def _flush_word(self) -> None: + """Flush the buffered word, wrapping to the next line if needed.""" + if not self._word_buf: + if self._pending_space and self._col > 0: + self._out.write(" ") + self._col += 1 + self._pending_space = False + return + + needed = self._word_width + if self._pending_space: + needed += 1 + + if self._col > 0 and self._col + needed > self._width: + self._out.write("\n") + self._col = 0 + self._pending_space = False + + if self._pending_space and self._col > 0: + self._out.write(" ") + self._col += 1 + self._pending_space = False + + self._out.write(self._word_buf) + self._col += self._word_width + self._word_buf = "" + self._word_width = 0 def _emit_inline_text(self, text: str) -> None: """Emit a string through inline markdown processing.""" @@ -268,7 +343,7 @@ class StreamRenderer: """Emit any held * character.""" if self._hold: self._italic = not self._italic - self._out.write(ITALIC_ON if self._italic else ITALIC_OFF) + self._word_buf += ITALIC_ON if self._italic else ITALIC_OFF self._hold = "" def _reset_styles(self) -> None: diff --git a/tests/test_display.py b/tests/test_display.py index 407a1e7..0ca3003 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -287,3 +287,84 @@ class TestFlush: renderer.feed("##") # SOL buffer, waiting for more text = rendered(renderer, out) assert "##" in text + + +# ── Word wrapping ─────────────────────────────────────────────────────── + + +@pytest.fixture +def narrow_renderer(out: io.StringIO) -> StreamRenderer: + """Renderer with a narrow terminal width for testing word wrap.""" + console = Console(file=out, force_terminal=True, no_color=False, width=20) + return StreamRenderer(console) + + +class TestWordWrap: + + def test_short_line_no_wrap(self, narrow_renderer: StreamRenderer, out: io.StringIO): + narrow_renderer.feed("hello world\n") + text = rendered(narrow_renderer, out) + assert "hello world" in text + assert text.count("\n") == 1 # just the trailing newline + + def test_wraps_at_word_boundary(self, narrow_renderer: StreamRenderer, out: io.StringIO): + # "one two three four" = 18 chars, fits. Add "five" and it wraps. + narrow_renderer.feed("one two three four five\n") + text = rendered(narrow_renderer, out) + assert "four\n" in text or "four \n" not in text + # "five" should be on the next line + lines = text.strip().split("\n") + assert len(lines) == 2 + assert "five" in lines[1] + + def test_no_mid_word_break(self, narrow_renderer: StreamRenderer, out: io.StringIO): + narrow_renderer.feed("aaa bbb ccccccccccccc ddd\n") + text = rendered(narrow_renderer, out) + # "ccccccccccccc" is 13 chars, should not be broken + for line in text.split("\n"): + assert "ccccccc" not in line or "ccccccccccccc" in line + + def test_word_longer_than_width_overflows(self, narrow_renderer: StreamRenderer, out: io.StringIO): + # A single word longer than 20 chars just overflows (no crash) + narrow_renderer.feed("superlongwordthatexceedstwentycharacters end\n") + text = rendered(narrow_renderer, out) + assert "superlongwordthatexceedstwentycharacters" in text + assert "end" in text + + def test_wrap_preserves_bold(self, narrow_renderer: StreamRenderer, out: io.StringIO): + narrow_renderer.feed("aaa bbb **ccc ddd eee fff** ggg\n") + text = rendered(narrow_renderer, out) + assert BOLD_ON in text + assert BOLD_OFF in text + # All words should be present + for word in ("aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg"): + assert word in text + + def test_wrap_preserves_italic(self, narrow_renderer: StreamRenderer, out: io.StringIO): + narrow_renderer.feed("aaa bbb *ccc ddd eee fff* ggg\n") + text = rendered(narrow_renderer, out) + assert ITALIC_ON in text + assert ITALIC_OFF in text + + def test_bullet_wrap_accounts_for_prefix(self, narrow_renderer: StreamRenderer, out: io.StringIO): + # " • " = 4 chars, so only 16 chars of content before wrap + narrow_renderer.feed("- aaa bbb ccc ddd eee\n") + text = rendered(narrow_renderer, out) + assert "•" in text + lines = text.strip().split("\n") + assert len(lines) >= 2 # should wrap + + def test_streaming_chunks_wrap_correctly(self, narrow_renderer: StreamRenderer, out: io.StringIO): + # Words arrive across multiple chunks + narrow_renderer.feed("one two thr") + narrow_renderer.feed("ee four five ") + narrow_renderer.feed("six\n") + text = rendered(narrow_renderer, out) + lines = text.strip().split("\n") + assert len(lines) >= 2 + # No word should be broken + all_words = set() + for line in lines: + all_words.update(line.split()) + for word in ("one", "two", "three", "four", "five", "six"): + assert word in all_words