Skip to content

Make whitespace inside selections visible #397

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 19, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 126 additions & 102 deletions src/buffer/mod.rs
Original file line number Diff line number Diff line change
@@ -44,7 +44,7 @@ use crate::framebuffer::{Framebuffer, IndexedColor};
use crate::helpers::*;
use crate::oklab::oklab_blend;
use crate::simd::memchr2;
use crate::unicode::{self, Cursor, MeasurementConfig};
use crate::unicode::{self, Cursor, MeasurementConfig, Utf8Chars};
use crate::{apperr, icu, simd};

/// The margin template is used for line numbers.
@@ -54,6 +54,10 @@ const MARGIN_TEMPLATE: &str = " │ ";
/// Just a bunch of whitespace you can use for turning tabs into spaces.
/// Happens to reuse MARGIN_TEMPLATE, because it has sufficient whitespace.
const TAB_WHITESPACE: &str = MARGIN_TEMPLATE;
const VISUAL_SPACE: &str = "・";
const VISUAL_SPACE_PREFIX_ADD: usize = '・'.len_utf8() - 1;
const VISUAL_TAB: &str = "→ ";
const VISUAL_TAB_PREFIX_ADD: usize = '→'.len_utf8() - 1;

/// Stores statistics about the whole document.
#[derive(Copy, Clone)]
@@ -1738,6 +1742,60 @@ impl TextBuffer {
}
}

let mut selection_off = 0..0;

// Figure out the selection range on this line, if any.
if cursor_beg.visual_pos.y == visual_line
&& selection_beg <= cursor_end.logical_pos
&& selection_end >= cursor_beg.logical_pos
{
let mut cursor = cursor_beg;

// By default, we assume the entire line is selected.
let mut selection_pos_beg = 0;
let mut selection_pos_end = COORD_TYPE_SAFE_MAX;
selection_off.start = cursor_beg.offset;
selection_off.end = cursor_end.offset;

// The start of the selection is within this line. We need to update selection_beg.
if selection_beg <= cursor_end.logical_pos
&& selection_beg >= cursor_beg.logical_pos
{
cursor = self.cursor_move_to_logical_internal(cursor, selection_beg);
selection_off.start = cursor.offset;
selection_pos_beg = cursor.visual_pos.x;
}

// The end of the selection is within this line. We need to update selection_end.
if selection_end <= cursor_end.logical_pos
&& selection_end >= cursor_beg.logical_pos
{
cursor = self.cursor_move_to_logical_internal(cursor, selection_end);
selection_off.end = cursor.offset;
selection_pos_end = cursor.visual_pos.x;
}

let left = destination.left + self.margin_width - origin.x;
let top = destination.top + y;
let rect = Rect {
left: left + selection_pos_beg.max(origin.x),
top,
right: left + selection_pos_end.min(origin.x + text_width),
bottom: top + 1,
};

let mut bg = oklab_blend(
fb.indexed(IndexedColor::Foreground),
fb.indexed_alpha(IndexedColor::BrightBlue, 1, 2),
);
if !focused {
bg = oklab_blend(bg, fb.indexed_alpha(IndexedColor::Background, 1, 2))
};
let fg = fb.contrasted(bg);
fb.blend_bg(rect, bg);
fb.blend_fg(rect, fg);
}

// Nothing to do if the entire line is empty.
if cursor_beg.offset != cursor_end.offset {
// If we couldn't reach the left edge, we may have stopped short due to a wide glyph.
@@ -1757,84 +1815,97 @@ impl TextBuffer {
}
}

fn find_control_char(text: &[u8], mut offset: usize) -> usize {
while offset < text.len() && (text[offset] >= 0x20 && text[offset] != 0x7f) {
offset += 1;
}
offset
}

let mut global_off = cursor_beg.offset;
let mut cursor_tab = cursor_beg;
let mut cursor_visualizer = cursor_beg;
let mut cursor_line = cursor_beg;

while global_off < cursor_end.offset {
let chunk = self.read_forward(global_off);
let chunk = &chunk[..chunk.len().min(cursor_end.offset - global_off)];

let mut chunk_off = 0;
while chunk_off < chunk.len() {
let beg = chunk_off;
chunk_off = find_control_char(chunk, beg);

for chunk in chunk[beg..chunk_off].utf8_chunks() {
if !chunk.valid().is_empty() {
line.push_str(chunk.valid());
}
if !chunk.invalid().is_empty() {
line.push('\u{FFFD}');
let mut it = Utf8Chars::new(chunk, 0);

// TODO: Looping char-by-char is bad for performance.
// >25% of the total rendering time is spent here.
loop {
let chunk_off = it.offset();
let global_off = global_off + chunk_off;
let Some(ch) = it.next() else {
break;
};

if ch == ' ' || ch == '\t' {
let is_tab = ch == '\t';
let visualize = selection_off.contains(&global_off);
let mut whitespace = TAB_WHITESPACE;
let mut prefix_add = 0;

if is_tab || visualize {
// We need the character's visual position in order to either compute the tab size,
// or set the foreground color of the visualizer, respectively.
// TODO: Doing this char-by-char is of course also bad for performance.
cursor_line =
self.cursor_move_to_offset_internal(cursor_line, global_off);
}
}

while chunk_off < chunk.len()
&& (chunk[chunk_off] < 0x20 || chunk[chunk_off] == 0x7f)
{
let ch = chunk[chunk_off];
chunk_off += 1;
let tab_size = if is_tab {
self.tab_size - (cursor_line.column % self.tab_size)
} else {
1
};

if ch == b'\t' {
cursor_tab = self.cursor_move_to_offset_internal(
cursor_tab,
global_off + chunk_off - 1,
if visualize {
// If the whitespace is part of the selection,
// we replace " " with "・" and "\t" with "→".
(whitespace, prefix_add) = if is_tab {
(VISUAL_TAB, VISUAL_TAB_PREFIX_ADD)
} else {
(VISUAL_SPACE, VISUAL_SPACE_PREFIX_ADD)
};

// Make the visualized characters slightly gray.
let visualizer_rect = {
let left = destination.left
+ self.margin_width
+ cursor_line.visual_pos.x
- origin.x;
let top = destination.top + cursor_line.visual_pos.y - origin.y;
Rect { left, top, right: left + 1, bottom: top + 1 }
};
fb.blend_fg(
visualizer_rect,
fb.indexed_alpha(IndexedColor::Foreground, 1, 2),
);
let tab_size = self.tab_size - (cursor_tab.column % self.tab_size);
line.push_str(&TAB_WHITESPACE[..tab_size as usize]);

// Since we know that we just aligned ourselves to the next tab stop,
// we can trivially process any successive tabs.
while chunk_off < chunk.len() && chunk[chunk_off] == b'\t' {
line.push_str(&TAB_WHITESPACE[..self.tab_size as usize]);
chunk_off += 1;
}
continue;
}

visualizer_buf[2] = if ch == 0x7F {
line.push_str(&whitespace[..prefix_add + tab_size as usize]);
} else if ch <= '\x1f' || ('\u{7f}'..='\u{9f}').contains(&ch) {
// Append a Unicode representation of the C0 or C1 control character.
visualizer_buf[2] = if ch <= '\x1f' {
0x80 | ch as u8 // U+2400..=U+241F
} else if ch == '\x7f' {
0xA1 // U+2421
} else {
0x80 | ch // 0x00..=0x1F => U+2400..=U+241F
0xA6 // U+2426, because there are no pictures for C1 control characters.
};

// Our manually constructed UTF8 is never going to be invalid. Trust.
line.push_str(unsafe { str::from_utf8_unchecked(&visualizer_buf) });

cursor_visualizer = self.cursor_move_to_offset_internal(
cursor_visualizer,
global_off + chunk_off - 1,
);
// Highlight the control character yellow.
cursor_line =
self.cursor_move_to_offset_internal(cursor_line, global_off);
let visualizer_rect = {
let left = destination.left
+ self.margin_width
+ cursor_visualizer.visual_pos.x
- origin.x;
let top =
destination.top + cursor_visualizer.visual_pos.y - origin.y;
let left =
destination.left + self.margin_width + cursor_line.visual_pos.x
- origin.x;
let top = destination.top + cursor_line.visual_pos.y - origin.y;
Rect { left, top, right: left + 1, bottom: top + 1 }
};

let bg = fb.indexed(IndexedColor::Yellow);
let fg = fb.contrasted(bg);
fb.blend_bg(visualizer_rect, bg);
fb.blend_fg(visualizer_rect, fg);
} else {
line.push(ch);
}
}

@@ -1846,53 +1917,6 @@ impl TextBuffer {

fb.replace_text(destination.top + y, destination.left, destination.right, &line);

// Draw the selection on this line, if any.
// FYI: `cursor_beg.visual_pos.y == visual_line` is necessary as the `visual_line`
// may be past the end of the document, and so it may not receive a highlight.
if cursor_beg.visual_pos.y == visual_line
&& selection_beg <= cursor_end.logical_pos
&& selection_end >= cursor_beg.logical_pos
{
// By default, we assume the entire line is selected.
let mut beg = 0;
let mut end = COORD_TYPE_SAFE_MAX;
let mut cursor = cursor_beg;

// The start of the selection is within this line. We need to update selection_beg.
if selection_beg <= cursor_end.logical_pos
&& selection_beg >= cursor_beg.logical_pos
{
cursor = self.cursor_move_to_logical_internal(cursor, selection_beg);
beg = cursor.visual_pos.x;
}

// The end of the selection is within this line. We need to update selection_end.
if selection_end <= cursor_end.logical_pos
&& selection_end >= cursor_beg.logical_pos
{
cursor = self.cursor_move_to_logical_internal(cursor, selection_end);
end = cursor.visual_pos.x;
}

beg = beg.max(origin.x);
end = end.min(origin.x + text_width);

let left = destination.left + self.margin_width - origin.x;
let top = destination.top + y;
let rect = Rect { left: left + beg, top, right: left + end, bottom: top + 1 };

let mut bg = oklab_blend(
fb.indexed(IndexedColor::Foreground),
fb.indexed_alpha(IndexedColor::BrightBlue, 1, 2),
);
if !focused {
bg = oklab_blend(bg, fb.indexed_alpha(IndexedColor::Background, 1, 2))
};
let fg = fb.contrasted(bg);
fb.blend_bg(rect, bg);
fb.blend_fg(rect, fg);
}

cursor = cursor_end;
}