spinners and fmt
This commit is contained in:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user