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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user