Skip to content

Commit e2eeed0

Browse files
claudesinelaw
authored andcommitted
renderer: emit conceal replacement when concealed token is Space/Newline/Break
The renderer's `apply_conceal_ranges` had two code paths for tokens that overlap a conceal range: - Text tokens emit the conceal range's `replacement` text via `emitted_replacements`. - Space / Newline / Break tokens silently dropped the token *without* emitting the replacement. Fresh tokenizes whitespace separately from word characters, so matching a word that ends right before a space (e.g. typing `PID` in a buffer that contains `PID file lockup`) hits the second path. Result: the space cell disappears AND the conceal's replacement (flash plugin's label letter) never renders. Surrounding text shifts left by one cell. User saw `PIDfile lockup` instead of `PIDafile lockup` — a real layout-shifting bug. Fix: have the Space/Newline/Break branch run the same replacement- emission logic as the Text branch. `null` replacement still hides the byte range with no output (existing behaviour preserved); a non-empty `replacement` now emits its first char with the source offset (so cursor/click positioning still works) plus the rest as a continuation. Reproducer test (CONTRIBUTING #1): flash_label_does_not_eat_space_after_match — opens a buffer with `PID file lockup` and typed `PID`, then asserts the rendered screen does NOT contain `PIDfile`/`PIDfor`/`PIDline` (the buggy collapse) AND that at least one match shows `PID<pool-letter><word>` (the expected substituted-cell layout). Verified the test FAILS without the renderer fix and PASSES with it via a transient `git stash` cycle on the editor change. Tests: 7 flash + 41 markdown_compose e2e tests all pass. Markdown compose was the only other plugin using conceal — its substitutions target Text tokens, so the Space/Newline/Break path never fired for it and the fix is non-disruptive.
1 parent 851e4b7 commit e2eeed0

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

crates/fresh-editor/src/view/ui/split_rendering/transforms.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,8 +687,43 @@ pub(crate) fn apply_conceal_ranges(
687687
}
688688
}
689689
ViewTokenWireKind::Space | ViewTokenWireKind::Newline | ViewTokenWireKind::Break => {
690-
if is_concealed(conceal_ranges, &sorted, &mut conceal_cursor, offset).is_some() {
691-
// Skip concealed single-byte tokens
690+
if let Some(cidx) =
691+
is_concealed(conceal_ranges, &sorted, &mut conceal_cursor, offset)
692+
{
693+
// Concealed single-byte token. If the conceal
694+
// range carries a `replacement`, we still need
695+
// to emit it — the Text branch above does this
696+
// via `emitted_replacements`, and dropping the
697+
// token here without doing the same was a real
698+
// bug: e.g. flash plugin labels overlay the
699+
// next char after each match, and when that
700+
// next char is a space the renderer used to
701+
// eat the cell entirely (label letter never
702+
// shown, surrounding text shifted left).
703+
if let Some(repl) = conceal_ranges[cidx].1 {
704+
if !emitted_replacements.contains(&cidx) {
705+
emitted_replacements.insert(cidx);
706+
if !repl.is_empty() {
707+
let mut chars = repl.chars();
708+
if let Some(first_ch) = chars.next() {
709+
output.push(ViewTokenWire {
710+
source_offset: Some(conceal_ranges[cidx].0.start),
711+
kind: ViewTokenWireKind::Text(first_ch.to_string()),
712+
style: None,
713+
});
714+
let rest: String = chars.collect();
715+
if !rest.is_empty() {
716+
output.push(ViewTokenWire {
717+
source_offset: None,
718+
kind: ViewTokenWireKind::Text(rest),
719+
style: None,
720+
});
721+
}
722+
}
723+
}
724+
}
725+
}
726+
// null replacement = hide the byte range; nothing to emit.
692727
} else {
693728
output.push(token);
694729
}

crates/fresh-editor/tests/e2e/flash.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,88 @@ fn flash_label_substitutes_rendered_glyph() {
299299
);
300300
}
301301

302+
/// Regression for the conceal-eats-space bug, 2026-04: when the
303+
/// next char after a flash match is a Space (or Newline / Break)
304+
/// token, the renderer's `apply_conceal_ranges` used to drop the
305+
/// token without emitting the conceal-range's replacement text.
306+
/// Effect: the label letter never appeared and the surrounding
307+
/// text shifted left by one cell.
308+
///
309+
/// User-visible reproducer: type `PID` against a buffer containing
310+
/// `PID file lockup`. The space between `PID` and `file` was
311+
/// consumed by the bug, rendering `PIDfile lockup`. With the fix
312+
/// the conceal range emits its label letter (e.g. `a`) into the
313+
/// space's cell, producing `PIDafile lockup` (label letter painted
314+
/// magenta on top of where the space was) — same column count,
315+
/// no layout shift.
316+
#[test]
317+
fn flash_label_does_not_eat_space_after_match() {
318+
let (mut harness, _temp, project_root) = flash_harness(120, 24);
319+
let path = write_fixture(
320+
&project_root,
321+
"test.txt",
322+
"PID file lockup\nthe PID for that\nsome other PID line\n",
323+
);
324+
harness.open_file(&path).unwrap();
325+
harness.render().unwrap();
326+
327+
arm_flash(&mut harness);
328+
type_pattern(&mut harness, "PID");
329+
harness.render().unwrap();
330+
331+
let screen = harness.screen_to_string();
332+
333+
// The bug: with pattern "PID" the next char of every match is a
334+
// space, and the buggy renderer dropped the space without
335+
// emitting the conceal replacement, producing `PIDfile`,
336+
// `PIDfor`, `PIDline`. None of those should appear after the
337+
// fix.
338+
assert!(
339+
!screen.contains("PIDfile"),
340+
"rendered output collapsed `PID file` into `PIDfile` — \
341+
conceal range was applied but its label-letter replacement \
342+
was dropped. Screen:\n{}",
343+
screen,
344+
);
345+
assert!(
346+
!screen.contains("PIDfor"),
347+
"rendered output collapsed `PID for` into `PIDfor`. Screen:\n{}",
348+
screen,
349+
);
350+
assert!(
351+
!screen.contains("PIDline"),
352+
"rendered output collapsed `PID line` into `PIDline`. Screen:\n{}",
353+
screen,
354+
);
355+
356+
// Positive check: at least one of the three matches MUST have a
357+
// label letter rendered between `PID` and the following word.
358+
// Iterate the pool and match via concatenation — we don't care
359+
// which specific letter the labeler picked, only that *some*
360+
// pool character occupies the space's cell.
361+
let pool: &[char] = &[
362+
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'q', 'w', 'r', 't', 'y', 'u', 'i', 'o', 'p',
363+
'z', 'x', 'c', 'v', 'b', 'n', 'm',
364+
];
365+
let mut substituted_count = 0usize;
366+
for c in pool {
367+
let needle_file = format!("PID{}file", c);
368+
let needle_for = format!("PID{}for", c);
369+
let needle_line = format!("PID{}line", c);
370+
substituted_count += screen.matches(&needle_file).count()
371+
+ screen.matches(&needle_for).count()
372+
+ screen.matches(&needle_line).count();
373+
}
374+
assert!(
375+
substituted_count >= 1,
376+
"expected at least one match to render with a label letter \
377+
occupying the cell that was the space (e.g. `PIDafile`). \
378+
Without the fix, that cell is empty and the next word \
379+
shifts left. Screen:\n{}",
380+
screen,
381+
);
382+
}
383+
302384
#[test]
303385
fn flash_jumps_across_splits() {
304386
// Two vertical splits, each with a different buffer that contains

0 commit comments

Comments
 (0)