tlc: editor visual polish — current-line highlight, bracket match, modified gutter mark

Current-line highlight (bg lightened by 12 per channel on cursor line),
bracket-match tracking (forward/backward search for ()[]{}<> pairs),
modified-line gutter mark (▌ in warning color when buffer is dirty).
bracket_flash field + find_matching_forward/backward methods on Editor.
This commit is contained in:
2026-06-20 00:07:26 +03:00
parent 445edc39db
commit 135439d763
2 changed files with 80 additions and 6 deletions
@@ -131,6 +131,10 @@ pub struct Editor {
/// Track scroll position to reset highlighter state on scroll.
#[cfg(feature = "syntect")]
last_render_top: usize,
/// Cached bracket-match pair (opening byte offset, closing byte
/// offset) for the flash highlight, or `None` when the cursor is
/// not on a bracket.
bracket_flash: Option<(usize, usize)>,
}
impl Editor {
@@ -165,6 +169,7 @@ impl Editor {
highlighter: hl,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
}
}
@@ -191,6 +196,7 @@ impl Editor {
highlighter: None,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
}
}
@@ -241,6 +247,63 @@ impl Editor {
self.modified
}
fn update_bracket_flash(&mut self) {
let pos = self.cursor.position();
let text = self.buffer.as_string();
if pos >= text.len() {
self.bracket_flash = None;
return;
}
let ch = text[pos..].chars().next().unwrap();
let open = "([{<";
let close = ")]}>";
let result = if let Some(idx) = open.find(ch) {
let target = close.as_bytes()[idx] as char;
Self::find_matching_forward(&text, pos, ch, target)
.map(|end| (pos, end))
} else if let Some(idx) = close.find(ch) {
let target = open.as_bytes()[idx] as char;
Self::find_matching_backward(&text, pos, ch, target)
.map(|start| (start, pos))
} else {
None
};
self.bracket_flash = result;
}
fn find_matching_forward(text: &str, start: usize, open: char, close: char) -> Option<usize> {
let mut depth = 1i32;
let mut offset = start + open.len_utf8();
for (i, ch) in text[offset..].char_indices() {
let abs = offset + i;
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return Some(abs);
}
}
offset = abs + ch.len_utf8();
}
None
}
fn find_matching_backward(text: &str, start: usize, close: char, open: char) -> Option<usize> {
let mut depth = 1i32;
for (i, ch) in text[..start].char_indices().rev() {
if ch == close {
depth += 1;
} else if ch == open {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
}
None
}
/// Set a named bookmark at the current cursor location.
pub fn set_bookmark(&mut self, name: char) -> Result<(), String> {
let line = self.buffer_line_of(self.cursor.position()) as u32;
@@ -28,6 +28,7 @@ impl Editor {
// (e.g. commit_prompt's GotoLine/GotoCol) changed the
// buffer cursor without calling cursor.set_position().
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.update_bracket_flash();
// Syntect is stateful: when the user scrolls the viewport
// we have to rebuild the parser state by replaying every
// line from the top of the file down to the new first
@@ -67,6 +68,10 @@ impl Editor {
let editor_bookmarkfound = mc_skin::color_pair(theme.name, "editor", "bookmarkfound");
let body_fg = editor_default.map(|p| p.fg).unwrap_or(theme.foreground);
let body_bg = editor_default.map(|p| p.bg).unwrap_or(theme.background);
let cur_line_bg = match body_bg {
Color::Rgb(r, g, b) => Color::Rgb(r.saturating_add(12), g.saturating_add(12), b.saturating_add(12)),
_ => theme.current_line_bg(),
};
let marked_fg = editor_marked.map(|p| p.fg).unwrap_or(theme.marked_fg);
let marked_bg = editor_marked.map(|p| p.bg).unwrap_or(theme.marked_bg);
let linestate_fg = editor_linestate.map(|p| p.fg).unwrap_or(theme.cursor_fg);
@@ -121,6 +126,7 @@ impl Editor {
.map(|row| {
let line_idx = top + row;
let n = line_idx + 1;
let is_mod_cursor = self.modified && cursor_line == line_idx;
let gutter_style = if self.has_bookmark_on_line(line_idx) {
if cursor_line == line_idx {
Style::default()
@@ -130,13 +136,17 @@ impl Editor {
} else {
Style::default().fg(bookmark_fg).bg(bookmark_bg)
}
} else if is_mod_cursor {
Style::default().fg(theme.warning).bg(body_bg)
} else {
Style::default().fg(frame_fg).bg(body_bg)
};
Line::from(Span::styled(
format!("{:>w$} ", n, w = (gutter_w - 1) as usize),
gutter_style,
))
let text = if is_mod_cursor {
format!("{:>w$}\u{258c}", n, w = (gutter_w - 1) as usize)
} else {
format!("{:>w$} ", n, w = (gutter_w - 1) as usize)
};
Line::from(Span::styled(text, gutter_style))
})
.collect();
frame.render_widget(Paragraph::new(gutter_lines), chunks[0]);
@@ -159,7 +169,7 @@ impl Editor {
let line_end = (off + len).min(full_text.len());
let line_text = full_text.get(off..line_end).unwrap_or("");
let base_style = if cursor_line == line_idx {
Style::default().fg(linestate_fg).bg(body_bg)
Style::default().fg(linestate_fg).bg(cur_line_bg)
} else {
Style::default().fg(body_fg).bg(body_bg)
};
@@ -170,6 +180,7 @@ impl Editor {
let sel_style = Style::default()
.fg(marked_fg)
.bg(marked_bg);
let line_bg = if cursor_line == line_idx { cur_line_bg } else { body_bg };
let mut spans: Vec<Span> = Vec::new();
#[cfg(feature = "syntect")]
{
@@ -179,7 +190,7 @@ impl Editor {
highlighted,
rs,
re,
body_bg,
line_bg,
marked_bg,
);
body_lines.push(Line::from(spans));