tlc: W6 visual/UX gap fixes — CJK width, bracket flash, search wrap, tab indicator

W6a: Fix CJK/wide character width handling
  - visual_width() now uses UnicodeWidthChar::width() instead of hardcoding 1
  - count_wrapped_rows() iterates UTF-8 chars instead of raw bytes
  - Control char fallback returns 0 (combining marks) instead of 1

W6b: Render bracket match flash highlight
  - bracket_flash (computed but never rendered) now applies accent bg style
  - push_rendered_text() accepts bracket_offsets + bracket_style params
  - All 4 call sites updated (3 selection segments + 1 non-selection)

W6c: Add search wrap-around notification
  - Editor: SearchState.last_wrapped field, 'Search wrapped' message on wrap
  - Viewer: Search.last_wrapped field, flash_msg in footer_text
  - Both find_next/find_prev and step() detect and report wrap

W6d: Editor status bar tab width indicator
  - Added 'Tab:{}' to status_string showing current tab_width
  - Hardcoded widget defaults verified as dead code (render_popup uses theme)

1381 tests pass, zero warnings.
This commit is contained in:
2026-07-06 05:18:10 +03:00
parent e8fa2ca97b
commit ca0ea881f4
5 changed files with 113 additions and 41 deletions
+14 -2
View File
@@ -1307,7 +1307,13 @@ impl Editor {
if let Some(m) = self.search.find_next(&self.buffer, from) {
self.buffer.set_cursor(m.range.start);
self.cursor.set_position(m.range.start, &self.buffer);
self.message = Some("Match found".to_string());
self.message = Some(
if self.search.last_wrapped() {
"Search wrapped".to_string()
} else {
"Match found".to_string()
},
);
} else {
self.message = Some("No more matches".to_string());
}
@@ -1317,7 +1323,13 @@ impl Editor {
if let Some(m) = self.search.find_prev(&self.buffer, from) {
self.buffer.set_cursor(m.range.start);
self.cursor.set_position(m.range.start, &self.buffer);
self.message = Some("Match found".to_string());
self.message = Some(
if self.search.last_wrapped() {
"Search wrapped".to_string()
} else {
"Match found".to_string()
},
);
} else {
self.message = Some("No more matches".to_string());
}
@@ -102,6 +102,7 @@ impl Editor {
let bookmark_bg = editor_bookmark.map(|p| p.bg).unwrap_or(body_bg);
let bookmarkfound_fg = editor_bookmarkfound.map(|p| p.fg).unwrap_or(bookmark_fg);
let bookmarkfound_bg = editor_bookmarkfound.map(|p| p.bg).unwrap_or(bookmark_bg);
let bracket_style = Style::default().fg(theme.background).bg(theme.accent);
// Block + title.
let block = Block::default()
@@ -191,6 +192,13 @@ impl Editor {
let len = self.buffer.line_length(line_idx);
let line_end = (off + len).min(full_text.len());
let line_text = full_text.get(off..line_end).unwrap_or("");
let local_brackets: Vec<usize> = self
.bracket_flash
.iter()
.flat_map(|&(s, e)| [s, e])
.filter(|&abs| abs >= off && abs < line_end)
.map(|abs| abs - off)
.collect();
let base_style = if self.has_bookmark_on_line(line_idx) {
if cursor_line == line_idx {
Style::default().fg(bookmarkfound_fg).bg(bookmarkfound_bg)
@@ -231,6 +239,7 @@ impl Editor {
}
}
let _ = is_first_visual_of_line;
let before_brackets: Vec<usize> = local_brackets.iter().copied().filter(|&b| b < rs).collect();
if rs > 0 {
if let Some(b) = line_text.get(..rs) {
push_rendered_text(
@@ -242,9 +251,12 @@ impl Editor {
nonprintable_fg,
nonprintable_bg,
self.show_whitespace,
&before_brackets,
bracket_style,
);
}
}
let sel_brackets: Vec<usize> = local_brackets.iter().copied().filter(|&b| b >= rs && b < re).map(|b| b - rs).collect();
if let Some(m) = line_text.get(rs..re) {
push_rendered_text(
&mut spans,
@@ -255,8 +267,11 @@ impl Editor {
marked_fg,
marked_bg,
self.show_whitespace,
&sel_brackets,
bracket_style,
);
}
let after_brackets: Vec<usize> = local_brackets.iter().copied().filter(|&b| b >= re).map(|b| b - re).collect();
if re < line_text.len() {
if let Some(a) = line_text.get(re..) {
push_rendered_text(
@@ -268,6 +283,8 @@ impl Editor {
nonprintable_fg,
nonprintable_bg,
self.show_whitespace,
&after_brackets,
bracket_style,
);
}
}
@@ -295,6 +312,8 @@ impl Editor {
nonprintable_fg,
nonprintable_bg,
self.show_whitespace,
&local_brackets,
bracket_style,
);
}
body_lines.push(Line::from(spans));
@@ -758,11 +777,12 @@ impl Editor {
},
};
format!(
" {} Ln {}/{} Col {} EOL: {} Bytes: {} Path: {}{}",
" {} Ln {}/{} Col {} Tab:{} EOL: {} Bytes: {} Path: {}{}",
modified,
line,
self.buffer.line_count(),
col,
self.tab_width,
eol,
self.buffer.len(),
self.path
@@ -873,19 +893,25 @@ fn push_rendered_text<'a>(
nonprintable_fg: Color,
nonprintable_bg: Color,
show_ws: bool,
bracket_offsets: &[usize],
bracket_style: Style,
) {
let mut byte_pos = 0usize;
for ch in text.chars() {
let char_len = ch.len_utf8();
let is_bracket = bracket_offsets.contains(&byte_pos);
let eff_style = if is_bracket { bracket_style } else { base_style };
match ch {
' ' if show_ws => spans.push(Span::styled(
"·".to_string(),
Style::default().fg(whitespace_fg).bg(whitespace_bg),
)),
' ' => spans.push(Span::styled(" ".to_string(), base_style)),
' ' => spans.push(Span::styled(" ".to_string(), eff_style)),
'\t' if show_ws => spans.push(Span::styled(
"".to_string(),
Style::default().fg(whitespace_fg).bg(whitespace_bg),
)),
'\t' => spans.push(Span::styled(" ".to_string(), base_style)),
'\t' => spans.push(Span::styled(" ".to_string(), eff_style)),
ch if ch.is_control() => {
let rendered = if ch == '\u{7f}' {
"^?".to_string()
@@ -899,8 +925,9 @@ fn push_rendered_text<'a>(
.bg(nonprintable_bg),
));
}
_ => spans.push(Span::styled(ch.to_string(), base_style)),
_ => spans.push(Span::styled(ch.to_string(), eff_style)),
}
byte_pos += char_len;
}
}
@@ -1005,19 +1032,13 @@ fn visual_width(ch: char, col: usize, tab_width: usize) -> usize {
if ch == '\t' {
tab_width.saturating_sub(col % tab_width).max(1)
} else if ch.is_control() {
// MC renders control chars as `^X` (2 cols). For other
// zero-width runes (combining marks, etc.) report 1 to
// avoid zero-width infinite loops.
if (ch as u32) < 0x20 || ch == '\u{7f}' {
2
} else {
1
0
}
} else {
// Treat all other chars as width 1. CJK/wide-char display
// width is a future refinement (MC itself only handles
// ASCII + control).
1
unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)
}
}
@@ -1039,15 +1060,15 @@ fn count_wrapped_rows(line_bytes: &[u8], body_width: usize, tab_width: usize) ->
if body_width == 0 {
return 1;
}
let s = std::str::from_utf8(line_bytes).unwrap_or("");
let mut rows = 1usize;
let mut col = 0usize;
let mut last_ws_col: Option<usize> = None;
let mut chars = line_bytes.iter().copied().peekable();
while let Some(b) = chars.next() {
if b == b'\n' {
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\n' {
break;
}
let ch = b as char;
let w = visual_width(ch, col, tab_width);
if col + w > body_width {
if let Some(ws_col) = last_ws_col {
@@ -62,6 +62,7 @@ pub struct SearchState {
regex: bool,
last_match: Option<Range<usize>>,
history: Vec<String>,
last_wrapped: bool,
}
impl SearchState {
@@ -118,6 +119,13 @@ impl SearchState {
self.last_match.clone()
}
/// Whether the most recent `find_next` / `find_prev` call wrapped
/// around the buffer boundary.
#[must_use]
pub fn last_wrapped(&self) -> bool {
self.last_wrapped
}
/// Push the current pattern to history. No-op if the pattern is
/// empty, or if it is identical to the most recent entry.
pub fn push_history(&mut self) {
@@ -150,6 +158,7 @@ impl SearchState {
pub fn find_next(&mut self, buf: &Buffer, from: usize) -> Option<Match> {
if self.pattern.is_empty() {
self.last_match = None;
self.last_wrapped = false;
return None;
}
let text = buf.to_bytes();
@@ -165,11 +174,13 @@ impl SearchState {
len,
) {
self.last_match = Some(m.range.clone());
self.last_wrapped = false;
return Some(m);
}
// Wrap: 0..from.
if from == 0 {
self.last_match = None;
self.last_wrapped = false;
return None;
}
if let Some(m) = scan(
@@ -181,9 +192,11 @@ impl SearchState {
from,
) {
self.last_match = Some(m.range.clone());
self.last_wrapped = true;
return Some(m);
}
self.last_match = None;
self.last_wrapped = false;
None
}
@@ -194,16 +207,12 @@ impl SearchState {
pub fn find_prev(&mut self, buf: &Buffer, from: usize) -> Option<Match> {
if self.pattern.is_empty() {
self.last_match = None;
self.last_wrapped = false;
return None;
}
let text = buf.to_bytes();
let len = text.len();
let from = from.min(len);
// First pass: search backward for the latest match whose
// end is <= from. Then verify that the match's start is < from
// (so we don't return the same match we just found going
// forward). If the only candidate is exactly at `from`, fall
// through to the wrap.
if let Some(m) = scan_rev(
&self.pattern,
self.regex,
@@ -212,15 +221,16 @@ impl SearchState {
0,
from,
) {
// If the match starts strictly before `from`, accept it.
if m.range.start < from {
self.last_match = Some(m.range.clone());
self.last_wrapped = false;
return Some(m);
}
}
// Wrap: from..len.
if from >= len {
self.last_match = None;
self.last_wrapped = false;
return None;
}
if let Some(m) = scan_rev(
@@ -232,9 +242,11 @@ impl SearchState {
len,
) {
self.last_match = Some(m.range.clone());
self.last_wrapped = true;
return Some(m);
}
self.last_match = None;
self.last_wrapped = false;
None
}
+20 -5
View File
@@ -134,9 +134,8 @@ pub struct Viewer {
/// Cleared after a successful save-to-disk. Triggers the
/// "Save before quit?" prompt on F10/Esc/Ctrl-Q.
hex_edit_modified: bool,
/// Last known body area height (set during render). Used by
/// key handlers to decide when to scroll vs move cursor.
last_height: u16,
flash_msg: Option<String>,
}
impl Viewer {
@@ -180,6 +179,7 @@ impl Viewer {
hex_edit_pending_high: None,
hex_edit_modified: false,
last_height: 0,
flash_msg: None,
})
}
@@ -218,6 +218,7 @@ impl Viewer {
hex_edit_pending_high: None,
hex_edit_modified: false,
last_height: 0,
flash_msg: None,
}
}
@@ -375,12 +376,20 @@ impl Viewer {
/// Move the search cursor forward/backward.
#[must_use]
pub fn search_next(&mut self) -> Option<search::Match> {
self.search.step(search::Direction::Forward)
let m = self.search.step(search::Direction::Forward);
if self.search.last_wrapped() {
self.flash_msg = Some("Search wrapped".to_string());
}
m
}
/// Search for the previous match (relative to the current cursor).
#[must_use]
pub fn search_prev(&mut self) -> Option<search::Match> {
self.search.step(search::Direction::Backward)
let m = self.search.step(search::Direction::Backward);
if self.search.last_wrapped() {
self.flash_msg = Some("Search wrapped".to_string());
}
m
}
/// Go to a line (1-based).
@@ -591,7 +600,7 @@ impl Viewer {
} else {
String::new()
};
match self.mode {
let base = match self.mode {
ViewMode::Text => format!(
" Line {} / {}{} Offset {} {} ",
self.current_line(),
@@ -616,6 +625,11 @@ impl Viewer {
format_size(self.size())
)
}
};
if let Some(ref msg) = self.flash_msg {
format!("{base} {msg}")
} else {
base
}
}
@@ -733,6 +747,7 @@ impl Viewer {
if self.prompt.is_some() {
self.render_prompt_overlay(frame, area, theme);
}
self.flash_msg = None;
}
/// Render the search / goto-line prompt at the top of the
@@ -60,16 +60,12 @@ impl Match {
/// The viewer search engine.
pub struct Search {
/// All matches found in the file (sorted by start offset).
matches: Vec<Match>,
/// Index into `matches` of the current match.
cursor: Option<usize>,
/// Last compiled regex (kept for highlighting).
last_regex: Option<Regex>,
/// History of search patterns (most recent last).
history: Vec<String>,
/// Maximum history length.
max_history: usize,
last_wrapped: bool,
}
impl Search {
@@ -82,6 +78,7 @@ impl Search {
last_regex: None,
history: Vec::new(),
max_history: 64,
last_wrapped: false,
}
}
@@ -253,23 +250,38 @@ impl Search {
/// Wraps around at the ends.
pub fn step(&mut self, dir: Direction) -> Option<Match> {
if self.matches.is_empty() {
self.last_wrapped = false;
return None;
}
let n = self.matches.len();
let cur = self.cursor.unwrap_or(0);
self.cursor = Some(match dir {
Direction::Forward => (cur + 1) % n,
Direction::Backward => {
if cur == 0 {
n - 1
let (new_cursor, wrapped) = match dir {
Direction::Forward => {
if cur + 1 >= n {
(0, true)
} else {
cur - 1
(cur + 1, false)
}
}
});
Direction::Backward => {
if cur == 0 {
(n - 1, true)
} else {
(cur - 1, false)
}
}
};
self.cursor = Some(new_cursor);
self.last_wrapped = wrapped;
self.current()
}
/// Whether the most recent `step` wrapped around.
#[must_use]
pub fn last_wrapped(&self) -> bool {
self.last_wrapped
}
/// Find the first match at or after `offset`.
pub fn first_at_or_after(&self, offset: Offset) -> Option<Match> {
self.matches.iter().find(|m| m.start >= offset).copied()