Skip to content

Commit 95bc736

Browse files
claudesinelaw
authored andcommitted
feat(whitespace): add newline and carriage-return indicators
The whitespace indicator feature could reveal spaces and tabs (leading, inner, and trailing) but gave no way to see line endings: an LF file and a CRLF file rendered identically, and nothing marked where lines end. Add two config options, both off by default and gated by the existing whitespace_show master toggle: - whitespace_newlines: render ↵ at the end of every line. - whitespace_carriage_returns: render ␍ for the CR half of the line break, so a CRLF file shows ␍↵ where an LF file shows ↵, and a Classic-Mac CR buffer shows ␍. (Stray CR bytes that are not part of the buffer's line ending already render as <0D> escapes.) The newline's view cell previously always rendered as empty. It now renders the indicator glyph(s) with the whitespace-indicator color, counts the emitted cells toward the row's rendered columns (the ␍↵ pair is two cells while the pipeline gives \n width 1) so tail fills and blank-line guides stay aligned, and keeps the end-of-line cursor on the indicator cell rather than one past it. A virtual-space cursor parked on the newline byte keeps its indicator at the virtual column instead of lighting up the glyph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015rBfqYynfutZdebh9hbZHG
1 parent 4cea1b3 commit 95bc736

7 files changed

Lines changed: 324 additions & 27 deletions

File tree

crates/fresh-editor/plugins/config-schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
"whitespace_tabs_leading": true,
9696
"whitespace_tabs_inner": true,
9797
"whitespace_tabs_trailing": true,
98+
"whitespace_newlines": false,
99+
"whitespace_carriage_returns": false,
98100
"use_tabs": false,
99101
"tab_size": 4,
100102
"auto_indent": true,
@@ -651,6 +653,18 @@
651653
"default": true,
652654
"x-section": "Whitespace"
653655
},
656+
"whitespace_newlines": {
657+
"description": "Show newline indicators (↵) at the end of every line.\nDefault: false",
658+
"type": "boolean",
659+
"default": false,
660+
"x-section": "Whitespace"
661+
},
662+
"whitespace_carriage_returns": {
663+
"description": "Show carriage-return indicators (␍) for the CR half of CRLF line\nendings, next to the newline position. Stray CR bytes that are not\npart of the buffer's line ending always render as `<0D>` escapes.\nDefault: false",
664+
"type": "boolean",
665+
"default": false,
666+
"x-section": "Whitespace"
667+
},
654668
"use_tabs": {
655669
"description": "Whether pressing Tab inserts a tab character instead of spaces.\nThis is the global default; individual languages can override it\nvia their own `use_tabs` setting.\nDefault: false (insert spaces)",
656670
"type": "boolean",

crates/fresh-editor/src/config.rs

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -680,18 +680,22 @@ pub struct WhitespaceVisibility {
680680
pub tabs_leading: bool,
681681
pub tabs_inner: bool,
682682
pub tabs_trailing: bool,
683+
pub newlines: bool,
684+
pub carriage_returns: bool,
683685
}
684686

685687
impl Default for WhitespaceVisibility {
686688
fn default() -> Self {
687-
// Match EditorConfig defaults: tabs all on, spaces all off
689+
// Match EditorConfig defaults: tabs all on, spaces and line endings all off
688690
Self {
689691
spaces_leading: false,
690692
spaces_inner: false,
691693
spaces_trailing: false,
692694
tabs_leading: true,
693695
tabs_inner: true,
694696
tabs_trailing: true,
697+
newlines: false,
698+
carriage_returns: false,
695699
}
696700
}
697701
}
@@ -700,14 +704,7 @@ impl WhitespaceVisibility {
700704
/// Resolve from EditorConfig flat fields (applying master toggle)
701705
pub fn from_editor_config(editor: &EditorConfig) -> Self {
702706
if !editor.whitespace_show {
703-
return Self {
704-
spaces_leading: false,
705-
spaces_inner: false,
706-
spaces_trailing: false,
707-
tabs_leading: false,
708-
tabs_inner: false,
709-
tabs_trailing: false,
710-
};
707+
return Self::hidden();
711708
}
712709
Self {
713710
spaces_leading: editor.whitespace_spaces_leading,
@@ -716,6 +713,8 @@ impl WhitespaceVisibility {
716713
tabs_leading: editor.whitespace_tabs_leading,
717714
tabs_inner: editor.whitespace_tabs_inner,
718715
tabs_trailing: editor.whitespace_tabs_trailing,
716+
newlines: editor.whitespace_newlines,
717+
carriage_returns: editor.whitespace_carriage_returns,
719718
}
720719
}
721720

@@ -740,9 +739,14 @@ impl WhitespaceVisibility {
740739
self.tabs_leading || self.tabs_inner || self.tabs_trailing
741740
}
742741

743-
/// Returns true if any indicator (space or tab) is enabled
742+
/// Returns true if any line-ending indicator (newline or CR) is enabled
743+
pub fn any_line_endings(&self) -> bool {
744+
self.newlines || self.carriage_returns
745+
}
746+
747+
/// Returns true if any indicator (space, tab, or line ending) is enabled
744748
pub fn any_visible(&self) -> bool {
745-
self.any_spaces() || self.any_tabs()
749+
self.any_spaces() || self.any_tabs() || self.any_line_endings()
746750
}
747751

748752
/// All indicators disabled — the "hidden" state of the master toggle.
@@ -754,6 +758,8 @@ impl WhitespaceVisibility {
754758
tabs_leading: false,
755759
tabs_inner: false,
756760
tabs_trailing: false,
761+
newlines: false,
762+
carriage_returns: false,
757763
}
758764
}
759765

@@ -1361,6 +1367,20 @@ pub struct EditorConfig {
13611367
#[schemars(extend("x-section" = "Whitespace"))]
13621368
pub whitespace_tabs_trailing: bool,
13631369

1370+
/// Show newline indicators (↵) at the end of every line.
1371+
/// Default: false
1372+
#[serde(default = "default_false")]
1373+
#[schemars(extend("x-section" = "Whitespace"))]
1374+
pub whitespace_newlines: bool,
1375+
1376+
/// Show carriage-return indicators (␍) for the CR half of CRLF line
1377+
/// endings, next to the newline position. Stray CR bytes that are not
1378+
/// part of the buffer's line ending always render as `<0D>` escapes.
1379+
/// Default: false
1380+
#[serde(default = "default_false")]
1381+
#[schemars(extend("x-section" = "Whitespace"))]
1382+
pub whitespace_carriage_returns: bool,
1383+
13641384
// ===== Editing =====
13651385
/// Whether pressing Tab inserts a tab character instead of spaces.
13661386
/// This is the global default; individual languages can override it
@@ -1917,6 +1937,8 @@ impl Default for EditorConfig {
19171937
whitespace_tabs_leading: true,
19181938
whitespace_tabs_inner: true,
19191939
whitespace_tabs_trailing: true,
1940+
whitespace_newlines: false,
1941+
whitespace_carriage_returns: false,
19201942
}
19211943
}
19221944
}

crates/fresh-editor/src/partial_config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ pub struct PartialEditorConfig {
232232
pub whitespace_tabs_leading: Option<bool>,
233233
pub whitespace_tabs_inner: Option<bool>,
234234
pub whitespace_tabs_trailing: Option<bool>,
235+
pub whitespace_newlines: Option<bool>,
236+
pub whitespace_carriage_returns: Option<bool>,
235237
}
236238

237239
impl Merge for PartialEditorConfig {
@@ -363,6 +365,10 @@ impl Merge for PartialEditorConfig {
363365
.merge_from(&other.whitespace_tabs_inner);
364366
self.whitespace_tabs_trailing
365367
.merge_from(&other.whitespace_tabs_trailing);
368+
self.whitespace_newlines
369+
.merge_from(&other.whitespace_newlines);
370+
self.whitespace_carriage_returns
371+
.merge_from(&other.whitespace_carriage_returns);
366372
}
367373
}
368374

@@ -683,6 +689,8 @@ impl From<&crate::config::EditorConfig> for PartialEditorConfig {
683689
whitespace_tabs_leading: Some(cfg.whitespace_tabs_leading),
684690
whitespace_tabs_inner: Some(cfg.whitespace_tabs_inner),
685691
whitespace_tabs_trailing: Some(cfg.whitespace_tabs_trailing),
692+
whitespace_newlines: Some(cfg.whitespace_newlines),
693+
whitespace_carriage_returns: Some(cfg.whitespace_carriage_returns),
686694
}
687695
}
688696
}
@@ -876,6 +884,12 @@ impl PartialEditorConfig {
876884
whitespace_tabs_trailing: self
877885
.whitespace_tabs_trailing
878886
.unwrap_or(defaults.whitespace_tabs_trailing),
887+
whitespace_newlines: self
888+
.whitespace_newlines
889+
.unwrap_or(defaults.whitespace_newlines),
890+
whitespace_carriage_returns: self
891+
.whitespace_carriage_returns
892+
.unwrap_or(defaults.whitespace_carriage_returns),
879893
}
880894
}
881895
}

crates/fresh-editor/src/view/ui/split_rendering/orchestration/render_line/cells.rs

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use super::super::selection_sweep::SelectionActiveSet;
2222
use super::{cursor_indicator_style, CursorTracker, SpanCursors};
2323
use crate::app::types::CellThemeInfo;
2424
use crate::config::IndentationGuideMode;
25+
use crate::model::buffer::LineEnding;
2526
use crate::primitives::ansi::AnsiParser;
2627
use crate::primitives::display_width::char_width;
2728
use crate::state::EditorState;
@@ -84,6 +85,10 @@ pub(super) struct CellPassOutput {
8485
/// Changed). Picked up by the tail-fill pass so the bg wash
8586
/// continues past the scoped text to the viewport's right edge.
8687
pub syntax_extend_bg: Option<Color>,
88+
/// Screen cells the newline's line-ending indicator occupied (0 when
89+
/// none rendered). The cursor-on-newline placement subtracts these so
90+
/// the cursor lands on the indicator, not past it.
91+
pub newline_indicator_cols: usize,
8792
}
8893

8994
/// Render one line's characters into `line_spans` / `line_view_map`.
@@ -150,6 +155,7 @@ pub(super) fn render_line_cells<'a, 'c>(
150155
first_line_byte_pos: None,
151156
last_line_byte_pos: None,
152157
syntax_extend_bg: None,
158+
newline_indicator_cols: 0,
153159
};
154160

155161
for ch in line_content.chars() {
@@ -195,6 +201,11 @@ struct CellPass<'a, 'b, 'c> {
195201
first_line_byte_pos: Option<usize>,
196202
last_line_byte_pos: Option<usize>,
197203
syntax_extend_bg: Option<Color>,
204+
/// Screen cells emitted for the newline's line-ending indicator (0 when
205+
/// none rendered). The view pipeline gives `\n` visual width 1 but the
206+
/// indicator may occupy one or two cells (`↵` / `␍↵`), so the rendered
207+
/// column count is tracked separately from the pipeline width.
208+
newline_indicator_cols: usize,
198209
}
199210

200211
/// Resolved style and theme-inspector metadata for one cell.
@@ -286,7 +297,16 @@ impl CellPass<'_, '_, '_> {
286297
let is_selected =
287298
!exclude_from_selection && self.selection_sweep.contains(byte_pos, self.byte_index);
288299

289-
let resolved = self.resolve_cell_style(byte_pos, ansi_style, is_cursor, is_selected);
300+
// A virtual-space cursor "at" the newline byte sits visually past the
301+
// content end — its indicator is drawn at the virtual column by
302+
// `place_cell_cursor`, so the newline cell itself (which may render a
303+
// line-ending indicator) must not be styled as the cursor.
304+
let newline_virtual_cursor = is_cursor
305+
&& ch == '\n'
306+
&& byte_pos.is_some_and(|bp| self.input.selection.virtual_cols_at.contains_key(&bp));
307+
let style_as_cursor = is_cursor && !newline_virtual_cursor;
308+
309+
let resolved = self.resolve_cell_style(byte_pos, ansi_style, style_as_cursor, is_selected);
290310
self.record_cell_theme(&resolved);
291311

292312
// `indicator_buf` holds the UTF-8 bytes of a single fallback indicator
@@ -309,8 +329,15 @@ impl CellPass<'_, '_, '_> {
309329
};
310330
(guide_glyph, false)
311331
} else {
312-
self.display_cell_text(ch, is_cursor, is_tab_start, &mut indicator_buf)
332+
self.display_cell_text(ch, byte_pos, is_cursor, is_tab_start, &mut indicator_buf)
313333
};
334+
// A newline cell normally renders as nothing; when it renders a
335+
// line-ending indicator instead, remember how many cells landed so
336+
// position bookkeeping (rendered_cols, the cursor-on-newline
337+
// indicator) accounts for them.
338+
if ch == '\n' && is_whitespace_indicator {
339+
self.newline_indicator_cols = display_char.chars().count();
340+
}
314341

315342
// Apply subdued indicator colors from theme. Cursor styling keeps
316343
// precedence (so guides do not obscure the caret), but selection does
@@ -322,15 +349,21 @@ impl CellPass<'_, '_, '_> {
322349
let mut style = resolved.style;
323350
if is_indentation_guide && !is_cursor {
324351
style = style.fg(self.indentation_guide_color());
325-
} else if is_whitespace_indicator && !is_cursor && !is_selected {
352+
} else if is_whitespace_indicator && !style_as_cursor && !is_selected {
326353
style = style.fg(self.input.theme.whitespace_indicator_fg);
327354
}
328355

329356
if !display_char.is_empty() {
330357
self.emit_cell(display_char, style, byte_pos, ch);
331358
}
332359

333-
self.place_cell_cursor(ch, byte_pos, is_cursor, resolved.is_secondary_cursor);
360+
// Recover the secondary-cursor flag for virtual-space cursors whose
361+
// styling was suppressed above — the indicator span they rely on is
362+
// still placed by `place_cell_cursor`.
363+
let is_secondary_cursor = resolved.is_secondary_cursor
364+
|| (newline_virtual_cursor
365+
&& byte_pos != Some(self.input.selection.primary_cursor_position));
366+
self.place_cell_cursor(ch, byte_pos, is_cursor, is_secondary_cursor);
334367
}
335368

336369
/// Whether the current leading-whitespace cell should render as an
@@ -553,11 +586,13 @@ impl CellPass<'_, '_, '_> {
553586
}
554587

555588
/// What to draw for this character: the char itself, a whitespace
556-
/// indicator (→ / ·), an LSP-waiting marker, a debug escape, or
557-
/// nothing (newline). Tabs are already expanded by ViewLineIterator.
589+
/// indicator (→ / · / ↵ / ␍), an LSP-waiting marker, a debug escape,
590+
/// or nothing (newline with line-ending indicators disabled). Tabs are
591+
/// already expanded by ViewLineIterator.
558592
fn display_cell_text<'buf>(
559593
&self,
560594
ch: char,
595+
byte_pos: Option<usize>,
561596
is_cursor: bool,
562597
is_tab_start: bool,
563598
indicator_buf: &'buf mut [u8; 4],
@@ -590,7 +625,8 @@ impl CellPass<'_, '_, '_> {
590625
// Debug mode: show LF explicitly
591626
("\\n", false)
592627
} else if ch == '\n' {
593-
("", false)
628+
let indicator = self.newline_indicator(byte_pos);
629+
(indicator, !indicator.is_empty())
594630
} else if ws_show_tab {
595631
// Visual indicator for tab: show → at the first position
596632
('→'.encode_utf8(indicator_buf), true)
@@ -602,6 +638,41 @@ impl CellPass<'_, '_, '_> {
602638
}
603639
}
604640

641+
/// Line-ending indicator glyphs for the newline cell ("" when disabled).
642+
///
643+
/// The buffer's newline token covers the whole line break — in a CRLF
644+
/// buffer it spans the `\r\n` pair (the `\n` half is skipped by the view
645+
/// pipeline) — so this one cell carries both the CR and newline
646+
/// indicators. Classic-Mac CR buffers store their breaks as `\n` in
647+
/// memory but save them as `\r`, so their indicator reflects the on-disk
648+
/// ending. Only source newlines qualify: plugin-injected line breaks
649+
/// (`byte_pos == None`) are not part of the file's content.
650+
fn newline_indicator(&self, byte_pos: Option<usize>) -> &'static str {
651+
if byte_pos.is_none() {
652+
return "";
653+
}
654+
let ws = &self.input.state.buffer_settings.whitespace;
655+
if !ws.newlines && !ws.carriage_returns {
656+
return "";
657+
}
658+
match self.input.state.buffer.line_ending() {
659+
LineEnding::CRLF => match (ws.carriage_returns, ws.newlines) {
660+
(true, true) => "␍↵",
661+
(true, false) => "␍",
662+
(false, true) => "↵",
663+
(false, false) => "",
664+
},
665+
LineEnding::CR if ws.carriage_returns => "␍",
666+
LineEnding::CR | LineEnding::LF => {
667+
if ws.newlines {
668+
"↵"
669+
} else {
670+
""
671+
}
672+
}
673+
}
674+
}
675+
605676
/// Push the cell's text through the span accumulator, wrapped in
606677
/// debug reveal-code tags when debug mode is on.
607678
fn emit_cell(&mut self, display_char: &str, style: Style, byte_pos: Option<usize>, ch: char) {
@@ -663,16 +734,21 @@ impl CellPass<'_, '_, '_> {
663734
} else {
664735
true
665736
};
666-
if should_add_indicator {
737+
// A virtual-space cursor sits past the content end: pad the
738+
// indicator out to its on-screen column. Cells already emitted
739+
// for a line-ending indicator occupy the start of that gap.
740+
let virtual_pad = byte_pos
741+
.and_then(|bp| self.input.selection.virtual_cols_at.get(&bp))
742+
.copied()
743+
.unwrap_or(0)
744+
.saturating_sub(self.newline_indicator_cols);
745+
// When the newline rendered a line-ending indicator, that cell
746+
// already carries the cursor styling from resolve_cell_style —
747+
// an extra indicator cell would land one column too far right.
748+
if should_add_indicator && (self.newline_indicator_cols == 0 || virtual_pad > 0) {
667749
// Flush accumulated text before adding the cursor indicator
668750
// so the indicator appears after the line content, not before
669751
self.span_acc.flush(self.line_spans, self.line_view_map);
670-
// A virtual-space cursor sits past the content end: pad the
671-
// indicator out to its on-screen column.
672-
let virtual_pad = byte_pos
673-
.and_then(|bp| self.input.selection.virtual_cols_at.get(&bp))
674-
.copied()
675-
.unwrap_or(0);
676752
if virtual_pad > 0 {
677753
push_span_with_map(
678754
self.line_spans,
@@ -715,12 +791,16 @@ impl CellPass<'_, '_, '_> {
715791
.unwrap_or(self.line_total_visual_width);
716792
let ch_width = next_col_for_char.saturating_sub(self.col_offset);
717793
// `\n` gets visual width 1 from the view pipeline but renders as
718-
// empty — don't count it as an on-screen cell.
794+
// empty — don't count it as an on-screen cell. When it rendered a
795+
// line-ending indicator instead, count the indicator's actual cells
796+
// (which may exceed the pipeline width: `␍↵` is two cells).
719797
let was_rendered = self.col_offset >= self.input.left_col && ch != '\n';
720798
self.col_offset = next_col_for_char;
721799
self.visible_char_count += ch_width;
722800
if was_rendered {
723801
self.rendered_cols += ch_width;
802+
} else if ch == '\n' {
803+
self.rendered_cols += self.newline_indicator_cols;
724804
}
725805
}
726806

@@ -747,6 +827,7 @@ impl CellPass<'_, '_, '_> {
747827
first_line_byte_pos: self.first_line_byte_pos,
748828
last_line_byte_pos: self.last_line_byte_pos,
749829
syntax_extend_bg: self.syntax_extend_bg,
830+
newline_indicator_cols: self.newline_indicator_cols,
750831
}
751832
}
752833
}

0 commit comments

Comments
 (0)