spinners and fmt

This commit is contained in:
2026-06-21 01:32:47 -05:00
parent d32914d99d
commit 8e90084b53
31 changed files with 1445 additions and 284 deletions
+4 -1
View File
@@ -28,7 +28,10 @@ impl Clipboard {
let system = arboard::Clipboard::new().ok();
#[cfg(test)]
let system = None;
Self { system, internal: String::new() }
Self {
system,
internal: String::new(),
}
}
/// Stores `text` internally and (best-effort) on the system clipboard.
+93 -26
View File
@@ -24,7 +24,9 @@ use syntect::parsing::syntax_definition::SyntaxDefinition;
use syntect::parsing::{SyntaxReference, SyntaxSet, SyntaxSetBuilder};
use crate::clipboard::Clipboard;
use crate::text::{TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl};
use crate::text::{
TAB_WIDTH, byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl,
};
/// The embedded Rhai Sublime-syntax definition used for highlighting.
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
@@ -178,17 +180,38 @@ impl CodeEditor {
KeyCode::Enter => self.insert_newline(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete_forward(),
KeyCode::Left => {
self.moved(shift, if ctrl { Self::move_word_left } else { Self::move_left })
}
KeyCode::Right => {
self.moved(shift, if ctrl { Self::move_word_right } else { Self::move_right })
}
KeyCode::Up => {
self.moved(shift, if ctrl { Self::move_paragraph_up } else { Self::move_up })
}
KeyCode::Down => self
.moved(shift, if ctrl { Self::move_paragraph_down } else { Self::move_down }),
KeyCode::Left => self.moved(
shift,
if ctrl {
Self::move_word_left
} else {
Self::move_left
},
),
KeyCode::Right => self.moved(
shift,
if ctrl {
Self::move_word_right
} else {
Self::move_right
},
),
KeyCode::Up => self.moved(
shift,
if ctrl {
Self::move_paragraph_up
} else {
Self::move_up
},
),
KeyCode::Down => self.moved(
shift,
if ctrl {
Self::move_paragraph_down
} else {
Self::move_down
},
),
KeyCode::Home => self.moved(shift, Self::move_home),
KeyCode::End => self.moved(shift, Self::move_end),
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
@@ -271,7 +294,9 @@ impl CodeEditor {
/// ctrl+Right: jump to the next word start (wrapping to the next line's start).
fn move_word_right(&mut self) {
if self.cursor.col == self.line_len(self.cursor.row) && self.cursor.row + 1 < self.lines.len() {
if self.cursor.col == self.line_len(self.cursor.row)
&& self.cursor.row + 1 < self.lines.len()
{
self.cursor.row += 1;
self.cursor.col = 0;
} else {
@@ -282,7 +307,10 @@ impl CodeEditor {
/// ctrl+Up: jump to the nearest blank/whitespace-only line above, else the first line.
fn move_paragraph_up(&mut self) {
self.cursor.row = (0..self.cursor.row).rev().find(|&r| self.is_blank_line(r)).unwrap_or(0);
self.cursor.row = (0..self.cursor.row)
.rev()
.find(|&r| self.is_blank_line(r))
.unwrap_or(0);
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
}
@@ -423,7 +451,9 @@ impl CodeEditor {
/// Deletes the current selection (if any) and places the cursor at its start.
fn replace_selection(&mut self) {
let Some((a, b)) = self.selection() else { return };
let Some((a, b)) = self.selection() else {
return;
};
self.begin_edit(EditKind::Other);
if a.row == b.row {
let s = &mut self.lines[a.row];
@@ -462,7 +492,10 @@ impl CodeEditor {
self.break_group();
let last = self.lines.len() - 1;
self.anchor = Some(Pos { row: 0, col: 0 });
self.cursor = Pos { row: last, col: self.line_len(last) };
self.cursor = Pos {
row: last,
col: self.line_len(last),
};
}
// ── Clipboard ──────────────────────────────────────────────────────────────
@@ -508,7 +541,10 @@ impl CodeEditor {
/// Pushes the current state to the undo stack and clears redo.
fn push_snapshot(&mut self) {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
@@ -517,14 +553,20 @@ impl CodeEditor {
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.redo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
self.redo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(prev);
}
}
fn redo(&mut self) {
if let Some(next) = self.redo.pop() {
self.undo.push(Snapshot { lines: self.lines.clone(), cursor: self.cursor });
self.undo.push(Snapshot {
lines: self.lines.clone(),
cursor: self.cursor,
});
self.restore(next);
}
}
@@ -577,10 +619,17 @@ impl CodeEditor {
let y = inner.y + i as u16;
// Right-aligned line number in the gutter (temporary buffer borrow per call).
let num = format!("{:>w$} ", row + 1, w = gutter_w as usize - 1);
frame.buffer_mut().set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
frame
.buffer_mut()
.set_string(inner.x, y, num, Style::default().fg(GUTTER_FG));
// Styled, tab-expanded line; Paragraph applies the horizontal scroll + clipping.
let line = self.render_line(row, colors.get(row).map(Vec::as_slice), sel);
let rect = Rect { x: text_x, y, width: text_w, height: 1 };
let rect = Rect {
x: text_x,
y,
width: text_w,
height: 1,
};
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
}
@@ -620,7 +669,9 @@ impl CodeEditor {
for line in self.lines.iter().take(upto) {
// Feed a trailing newline so multi-line constructs keep state, then drop it.
let with_nl = format!("{line}\n");
let regions = h.highlight_line(&with_nl, &hl.syntax_set).unwrap_or_default();
let regions = h
.highlight_line(&with_nl, &hl.syntax_set)
.unwrap_or_default();
let mut colors = Vec::with_capacity(line.chars().count());
for (style, text) in regions {
let c = Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
@@ -634,11 +685,19 @@ impl CodeEditor {
/// Builds one display [`Line`]: per-char fg (from `colors`), selection background, and
/// tabs expanded to spaces. Width/clipping/scroll are left to the rendering `Paragraph`.
fn render_line(&self, row: usize, colors: Option<&[Color]>, sel: Option<(Pos, Pos)>) -> Line<'static> {
fn render_line(
&self,
row: usize,
colors: Option<&[Color]>,
sel: Option<(Pos, Pos)>,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut col_cells = 0; // running display column, for tab expansion
for (i, c) in self.lines[row].chars().enumerate() {
let fg = colors.and_then(|cs| cs.get(i)).copied().unwrap_or(DEFAULT_FG);
let fg = colors
.and_then(|cs| cs.get(i))
.copied()
.unwrap_or(DEFAULT_FG);
let mut style = Style::default().fg(fg);
if sel.is_some_and(|(a, b)| pos_in_range(row, i, a, b)) {
style = style.bg(SELECT_BG);
@@ -672,7 +731,11 @@ fn build_highlight_parts() -> Option<HighlightParts> {
let syntax_set = builder.build();
let syntax_ref = syntax_set.find_syntax_by_extension("rhai")?.clone();
let theme = ThemeSet::load_defaults().themes.get(THEME_NAME)?.clone();
Some(HighlightParts { theme, syntax_ref, syntax_set: Arc::new(syntax_set) })
Some(HighlightParts {
theme,
syntax_ref,
syntax_set: Arc::new(syntax_set),
})
}
#[cfg(test)]
@@ -772,7 +835,11 @@ mod tests {
typ(&mut ed, "abc"); // coalesces to one undo step
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(ed.text(), "");
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
send_mod(
&mut ed,
KeyCode::Char('z'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(ed.text(), "abc");
}
+1 -3
View File
@@ -242,7 +242,6 @@ impl<Ctx> Dialog<Ctx> {
fn filtered_len(&self) -> usize {
filtered_count(&self.body)
}
}
impl<Ctx> CursorOverlay for Dialog<Ctx> {
@@ -482,8 +481,7 @@ mod tests {
fn text_dialog_reports_value_on_submit_and_none_on_cancel() {
// Submit returns the edited value.
let mut got: Option<Option<String>> = None;
let mut d: Dialog<Option<Option<String>>> =
Dialog::text("t", "hi", |v, c| *c = Some(v));
let mut d: Dialog<Option<Option<String>>> = Dialog::text("t", "hi", |v, c| *c = Some(v));
d.handle_event(&key(KeyCode::Char('!')));
let res = d.handle_event(&key(KeyCode::Enter));
d.finish(res, &mut got);
+65 -21
View File
@@ -235,7 +235,11 @@ impl<Ctx> GlyphDialog<Ctx> {
}
let i = stops.iter().position(|f| *f == self.focus).unwrap_or(0);
let n = stops.len();
let j = if forward { (i + 1) % n } else { (i + n - 1) % n };
let j = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
self.focus = stops[j];
DialogResult::Continue
}
@@ -248,7 +252,11 @@ impl<Ctx> GlyphDialog<Ctx> {
KeyCode::Left => picker.move_select(-1),
KeyCode::Right => picker.move_select(1),
KeyCode::Down if picker.is_custom() => {
self.focus = if is_fg { Focus::FgField } else { Focus::BgField };
self.focus = if is_fg {
Focus::FgField
} else {
Focus::BgField
};
}
_ => {}
}
@@ -258,7 +266,11 @@ impl<Ctx> GlyphDialog<Ctx> {
/// edits the field.
fn handle_field(&mut self, key: ratatui::crossterm::event::KeyEvent, is_fg: bool) {
if matches!(key.code, KeyCode::Up) {
self.focus = if is_fg { Focus::FgStrip } else { Focus::BgStrip };
self.focus = if is_fg {
Focus::FgStrip
} else {
Focus::BgStrip
};
return;
}
let picker = if is_fg { &mut self.fg } else { &mut self.bg };
@@ -315,7 +327,14 @@ impl<Ctx> GlyphDialog<Ctx> {
/// Draws a color strip into `area`: a focusable label, then a solid swatch per named
/// color plus a final custom slot. The selected slot is marked with `●`; the custom
/// slot is otherwise marked `+`.
fn draw_strip(&self, buf: &mut Buffer, area: Rect, label: &str, picker: &ColorPicker, focused: bool) {
fn draw_strip(
&self,
buf: &mut Buffer,
area: Rect,
label: &str,
picker: &ColorPicker,
focused: bool,
) {
draw_label(buf, area.x, area.y, label, focused);
let sx = area.x + 3;
let y = area.y;
@@ -374,7 +393,12 @@ impl<Ctx> GlyphDialog<Ctx> {
Style::default().fg(Color::White).bg(INVALID_BG)
};
let box_x = x + 1;
buf.set_string(box_x, y, format!("{val:<width$}", width = w as usize), style);
buf.set_string(
box_x,
y,
format!("{val:<width$}", width = w as usize),
style,
);
focused.then(|| {
let cur = box_x + (picker.field.display_cursor() as u16).min(w.saturating_sub(1));
Position::new(cur, y)
@@ -444,27 +468,37 @@ impl<Ctx> CursorOverlay for GlyphDialog<Ctx> {
block.render(rect, buf);
// Stack: grid, gap, fg strip + hex, bg strip + hex, filler, footer.
let [grid_area, _gap, fg_strip, fg_field, bg_strip, bg_field, _filler, footer_area] =
Layout::vertical([
Constraint::Length(GRID_ROWS as u16),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
let [
grid_area,
_gap,
fg_strip,
fg_field,
bg_strip,
bg_field,
_filler,
footer_area,
] = Layout::vertical([
Constraint::Length(GRID_ROWS as u16),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
// Both colors must resolve for the grid to preview them; otherwise gray-on-black.
let colors = self.fg.color().zip(self.bg.color());
self.draw_grid(buf, grid_area, colors);
self.draw_strip(buf, fg_strip, "fg ", &self.fg, self.focus == Focus::FgStrip);
let fg_cursor = self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
let fg_cursor =
self.draw_color_field(buf, fg_field, &self.fg, self.focus == Focus::FgField);
self.draw_strip(buf, bg_strip, "bg ", &self.bg, self.focus == Focus::BgStrip);
let bg_cursor = self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
let bg_cursor =
self.draw_color_field(buf, bg_field, &self.bg, self.focus == Focus::BgField);
self.draw_footer(buf, footer_area);
@@ -536,8 +570,18 @@ mod tests {
fn sample() -> Glyph {
Glyph {
tile: 5,
fg: Rgba8 { r: 0xFF, g: 0x00, b: 0x00, a: 255 }, // not a named color
bg: Rgba8 { r: 0x00, g: 0x00, b: 0xFF, a: 255 }, // not a named color
fg: Rgba8 {
r: 0xFF,
g: 0x00,
b: 0x00,
a: 255,
}, // not a named color
bg: Rgba8 {
r: 0x00,
g: 0x00,
b: 0xFF,
a: 255,
}, // not a named color
}
}
+26 -6
View File
@@ -27,14 +27,24 @@ impl TextField {
pub fn new(initial: impl Into<String>) -> Self {
let text = initial.into();
let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new(), max_length: None }
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: None,
}
}
pub fn sized(initial: impl Into<String>, max_length: usize) -> Self {
let mut text = initial.into();
text.truncate(max_length);
let cursor = text.chars().count();
Self { text, cursor, clipboard: Clipboard::new(), max_length: Some(max_length) }
Self {
text,
cursor,
clipboard: Clipboard::new(),
max_length: Some(max_length),
}
}
/// The current text.
@@ -91,11 +101,17 @@ impl TextField {
fn insert(&mut self, c: char) {
let b = byte_of(&self.text, self.cursor);
if self.max_length.is_none_or(|max_length| max_length > self.text.len() ) {
if self
.max_length
.is_none_or(|max_length| max_length > self.text.len())
{
self.text.insert(b, c);
}
if self.max_length.is_none_or(|max_length| max_length > self.cursor ) {
if self
.max_length
.is_none_or(|max_length| max_length > self.cursor)
{
self.cursor += 1;
}
}
@@ -121,12 +137,16 @@ impl TextField {
/// Inserts clipboard text at the cursor, with any newlines stripped (single-line).
fn paste(&mut self) {
let clean: String = self.clipboard.get().chars().filter(|c| !matches!(c, '\n' | '\r')).collect();
let clean: String = self
.clipboard
.get()
.chars()
.filter(|c| !matches!(c, '\n' | '\r'))
.collect();
let b = byte_of(&self.text, self.cursor);
self.text.insert_str(b, &clean);
self.cursor += clean.chars().count();
}
}
#[cfg(test)]