diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index ebc29e249b..352e28017b 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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()); } diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index 22ed3dc230..c0bf6d667f 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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 = 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 = 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 = 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 = 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 = 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 { diff --git a/local/recipes/tui/tlc/source/src/editor/search.rs b/local/recipes/tui/tlc/source/src/editor/search.rs index b425e82ba3..99c2326556 100644 --- a/local/recipes/tui/tlc/source/src/editor/search.rs +++ b/local/recipes/tui/tlc/source/src/editor/search.rs @@ -62,6 +62,7 @@ pub struct SearchState { regex: bool, last_match: Option>, history: Vec, + 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 { 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 { 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 } diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 1e437cef24..fdfd4a9cff 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -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, } 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 { - 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 { - 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 diff --git a/local/recipes/tui/tlc/source/src/viewer/search.rs b/local/recipes/tui/tlc/source/src/viewer/search.rs index 2368777cc9..5a2ebf6425 100644 --- a/local/recipes/tui/tlc/source/src/viewer/search.rs +++ b/local/recipes/tui/tlc/source/src/viewer/search.rs @@ -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, - /// Index into `matches` of the current match. cursor: Option, - /// Last compiled regex (kept for highlighting). last_regex: Option, - /// History of search patterns (most recent last). history: Vec, - /// 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 { 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 { self.matches.iter().find(|m| m.start >= offset).copied()