Skip to content

Add runner::set_whitespace_mode() to keep runs of spaces inside a line - #171

Merged
JBenda merged 5 commits into
JBenda:masterfrom
choosatron:fix/keep-interior-space-runs
Sep 18, 2026
Merged

JBenda merged 5 commits into
JBenda:masterfrom
choosatron:fix/keep-interior-space-runs

Conversation

@jerrytron

@jerrytron jerrytron commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

What this adds

runner::set_whitespace_mode(), a runtime setting for how runs of whitespace inside a line are treated:

runner thread = story->new_runner(globals);
thread->set_whitespace_mode(whitespace_mode::keep_runs);
mode behaviour
collapse (default) a run of spaces and tabs inside a line becomes one space, as the reference ink runtime does for lines
keep_runs runs inside a line are kept

The default is unchanged, so nothing moves for existing users.

Why

Text laid out for a fixed-width display — tables, ASCII art, anything aligned with gutters — loses its shape when runs collapse. inklecate writes the runs into the JSON ("^A B") and the compiler carries them through; only the runtime's final cleanup removes them. We hit this driving a 32-column thermal printer.

Scope

The mode lives on the output stream, so it covers everything a runner emits: lines, choice text and tags.

  • Whitespace where two glued fragments meet still reads as one character: Knock + again? → Knock again?.
  • A run at the start or end of a line is dropped whole.
  • It is deliberately not part of a snapshot: a runner restored from one starts at the default, like a new runner. It is a presentation choice rather than story state.

Bindings

set_whitespace_mode() and set_rng_seed() are both exposed now:

  • C: ink_runner_set_whitespace_mode() with InkWhitespaceMode, and ink_runner_set_rng_seed()
  • Python: Runner.set_whitespace_mode() with WhitespaceMode, and Runner.set_rng_seed()

set_rng_seed() was C++ only before.

Tests

  • inkcpp_test/WhitespaceMode.cpp covers both modes in the default build, so CI exercises both without a second configuration.
  • inkcpp_c/tests/WhitespaceMode.c and inkcpp_python/tests/test_WhitespaceMode.py cover the bindings.
  • Locally: ctest 8/8 (52 cases, 1312 assertions) and pytest 14 passed. Formatted with clang-format 18.1.8, matching .pre-commit-config.yaml.

Why an enum rather than a bool

There is a third behaviour worth room for: the reference runtime collapses runs in lines but does not collapse them in choice text, and neither value here matches that exactly. A future ink_compatible value is the natural home for it. Out of scope for this PR.

🤖 Generated with Claude Code

Comment thread inkcpp/output.cpp Outdated
@JBenda

JBenda commented Sep 14, 2026

Copy link
Copy Markdown
Owner

The PR has a good point; the implementation also looks good. I'm currently in doubt that the clean_string function does what it says (it seems it was broken before already).

`clean_string` dropped any space whose neighbour was also a space, so
`A    B` reached the reader as `A B`. Every layer below it keeps the run:
inklecate writes it into the JSON and the compiler carries it through.
Only the final assembly of the line threw it away.

It matters wherever ink drives a fixed-width display. Found on a 32-column
thermal printer, where it flattened every piece of ASCII art in a story
archive - the gutters drawing corridors and borders closed up, and 121
lines across five stories printed as a row of characters where a drawing
should have been.

Collapsing was doing one useful job, and it is kept: two fragments joined
by glue often bring a trailing space and a leading space to the same join
(`Knock ` + ` again?`), and that should read as one space. Move that to
the join itself, in `basic_stream::get` and `get_alloc`, where the seam is
actually known - so the interior of each fragment is left alone. Trailing
whitespace before a newline is still trimmed, as the spec asks.

Tests: an interior run reaches the output, and a seam still collapses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F52Q3KvwJXN7XLPDMQc4xF
@JBenda
JBenda force-pushed the fix/keep-interior-space-runs branch from d0316ba to dc55113 Compare September 14, 2026 16:09
The reference ink runtime collapses runs of spaces and tabs inside a
line to one space (StoryState.CleanOutputWhitespace, "HTML style"), and
inkjs does the same. inkcpp's existing collapse matches that, so it
stays the default: with the option OFF the compiled code is master's.

With INKCPP_KEEP_SPACE_RUNS ON, runs inside a line are kept, whitespace
where glued fragments meet still reads as one character, and a run at
the start or end of a line is dropped whole. The join check now treats
tabs like spaces but never skips a newline.

InteriorSpaces.cpp asserts the reference behaviour by default and the
kept runs under the option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jerrytron jerrytron changed the title Keep runs of spaces inside a line Add INKCPP_KEEP_SPACE_RUNS to keep runs of spaces inside a line Sep 14, 2026
@jerrytron

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Your doubt about clean_string was well placed, just in the other direction from this PR. I checked the reference runtime: StoryState.CleanOutputWhitespace in inkle/ink turns every run of spaces and tabs into a single space ("HTML style"), and inkjs does the same (A B → A B, even though the JSON keeps all four spaces). So inkcpp's existing collapse matches ink, and my original description was wrong about that.

I've reworked the PR so the default doesn't change: the new behaviour is now behind a CMake option, INKCPP_KEEP_SPACE_RUNS (default OFF), following the INKCPP_NO_* options. With it off, the code compiled is master's. The description is updated with the details.

Would you be OK with this as an opt-in? Our case is a fixed-width thermal printer, where collapsed runs flatten ASCII art.

Two things I noticed but left alone:

  • CI builds only the default, so the ON path isn't exercised there. I'm happy to add a build entry if you'd like one.
  • The reference runtime does not collapse runs in choice text (* [Pick me] stays as is), while inkcpp's default does.

@JBenda

JBenda commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Do you think it should be a compile-time option? Or should we allow when spawning threads/runner to pass an argument if inner spaces should be kept? For your case it is potentially potato-potato, but in general I think the runtime overhead is small enough to keep the flexibility.

@jerrytron

Copy link
Copy Markdown
Contributor Author

A runtime option works better, I agree. It also fixes the CI gap I mentioned: InteriorSpaces.cpp could test both modes in the default build instead of needing a second configuration.

I'd suggest a setter on the runner rather than an argument when spawning it, the same way set_rng_seed() works:

runner thread = story->new_runner(globals);
thread->set_keep_space_runs(true);

Why a setter:

  • It leaves new_runner() and new_runner_from_snapshot() unchanged, along with everything that wraps them in the C API, Python and Unreal.
  • It's a presentation choice rather than story state, so I wouldn't store it in snapshots. With a setter, a restored runner is configured the same way as a new one. As a spawn argument, it would look like something a snapshot ought to remember.

Internally the flag lives on the output stream, the two #ifdef blocks in get() and get_alloc() become plain branches, and the CMake option goes away. The default stays as it is now, matching the reference runtime. The setting applies to text produced after the call, so the docs should say to set it before the first line is read.

Two questions:

  1. A bool or an enum? An enum would leave room for a mode matching the reference runtime's handling of choice text, which it doesn't collapse. I'd keep that mode out of this PR either way.

    enum class whitespace_mode {
        collapse,  // default: runs become one space, as in the reference runtime
        keep_runs, // runs inside a line are kept
    };
    
    runner thread = story->new_runner(globals);
    thread->set_whitespace_mode(whitespace_mode::keep_runs);
  2. Bindings: set_rng_seed() is currently C++ only, with nothing in the C API, Python or Unreal. Is C++ only fine for this PR too, or would you like the C API function (and Python) included?

@JBenda

JBenda commented Sep 16, 2026

Copy link
Copy Markdown
Owner

I favor a setter. An enum would be a better fit because of future options and better readability. The setter should also be available for C and Python.
If it is ok for you, I would appreciate it if you could implement it and take set_rng_seed() along; it is not intentional and not in the other APIs.

Replaces the INKCPP_KEEP_SPACE_RUNS compile-time option with a setter on
the runner, as discussed in JBenda#171: whitespace_mode::collapse (the default,
what the reference ink runtime does for lines) and keep_runs.

The mode lives on the output stream, so it reaches everything a runner
emits - lines, choice text and tags - and the two #ifdef blocks in get()
and get_alloc() become plain branches. It is deliberately not part of a
snapshot: a restored runner starts at the default, like a new one.

Both modes are now covered by one default build, so CI exercises them:
inkcpp_test/WhitespaceMode.cpp replaces InteriorSpaces.cpp.

set_whitespace_mode() and set_rng_seed() are both exposed in the C API
(InkWhitespaceMode) and in Python (WhitespaceMode), with tests for each;
set_rng_seed was missing from both, which was not intentional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jerrytron jerrytron changed the title Add INKCPP_KEEP_SPACE_RUNS to keep runs of spaces inside a line Add runner::set_whitespace_mode() to keep runs of spaces inside a line Sep 16, 2026
@jerrytron

Copy link
Copy Markdown
Contributor Author

Done — a setter with an enum, in C++, C and Python, with set_rng_seed() brought along.

  • runner::set_whitespace_mode(whitespace_mode), values collapse (default) and keep_runs
  • C: ink_runner_set_whitespace_mode() / InkWhitespaceMode, Python: Runner.set_whitespace_mode() / WhitespaceMode
  • set_rng_seed() added to the C API and Python, with tests
  • The CMake option and both #ifdef blocks are gone. The mode lives on the output stream, so it covers lines, choice text and tags, and choice.cpp picks it up without a signature change.
  • Both modes are now covered by the default build, so the CI gap I mentioned is closed.

Two decisions I'd rather you sanity-check:

  1. Not stored in snapshots. A restored runner starts at the default, like a new one, on the grounds that this is presentation rather than story state. Easy to change if you disagree.
  2. Two enum values, not three. The reference runtime collapses runs in lines but keeps them in choice text, and neither value here matches that. That third mode (ink_compatible, say) seems worth leaving room for rather than adding now.

Verified locally: ctest 8/8 and pytest 14 passed, and formatted with clang-format 18.1.8 to match .pre-commit-config.yaml.

I pushed on top rather than force-pushing so your review thread keeps its anchor — the intermediate commit describing the compile-time option disappears on squash-merge.

@JBenda

JBenda commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Nice job, I found an already existing error in the space collapse logic, which I would like to push in the same PR. Please pull the branch on top.
https://github.com/JBenda/inkcpp/tree/fix/keep-interior-space-runs

@github-actions

Copy link
Copy Markdown

Ink Proof Results

These results are obtained by running the Ink-Proof Testing Suite on the compiled binaries in this pull request.

System Results
Linux x64 130/130 passed
MacOSX-ARM DISABLED
MacOSX DISABLED
Windows x64 130/130 passed

@JBenda
JBenda merged commit a247c7f into JBenda:master Sep 18, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants