ui crate, editor
This commit is contained in:
+4
-1
@@ -4,5 +4,8 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# text-only clipboard: drop the default `image-data` deps (image/tiff/jpeg), add Linux Wayland support
|
||||
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
||||
ratatui = { workspace = true }
|
||||
tui-input = { workspace = true }
|
||||
syntect = "5.3.0"
|
||||
unicode-width = "0.2"
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
%YAML 1.2
|
||||
---
|
||||
# http://www.sublimetext.com/docs/3/syntax.html
|
||||
name: Rhai
|
||||
file_extensions:
|
||||
- rhai
|
||||
scope: source.rhai
|
||||
contexts:
|
||||
main:
|
||||
- include: core
|
||||
brackets:
|
||||
- include: round-brackets
|
||||
- include: square-brackets
|
||||
- include: curly-brackets
|
||||
comments:
|
||||
- match: '/\*\*(?![/|\*])'
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.documentation.rhai
|
||||
push:
|
||||
- meta_scope: comment.block.documentation.rhai
|
||||
- match: \*/
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.documentation.rhai
|
||||
pop: true
|
||||
- include: comments
|
||||
- match: /\*
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.rhai
|
||||
push:
|
||||
- meta_scope: comment.block.rhai
|
||||
- match: \*/
|
||||
captures:
|
||||
0: punctuation.definition.comment.block.rhai
|
||||
pop: true
|
||||
- include: comments
|
||||
- match: '(///)[^/].*$\n?'
|
||||
scope: comment.line.documentation.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.documentation.rhai
|
||||
- match: (//).*$\n?
|
||||
scope: comment.line.double-slash.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.double-slash.rhai
|
||||
- match: ^(#!).*$\n?
|
||||
scope: comment.line.shebang.rhai
|
||||
captures:
|
||||
1: punctuation.definition.comment.rhai
|
||||
core:
|
||||
- include: expression
|
||||
curly-brackets:
|
||||
- match: '\{'
|
||||
captures:
|
||||
0: meta.brace.curly.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.curly
|
||||
- match: '\}'
|
||||
captures:
|
||||
0: meta.brace.curly.rhai
|
||||
pop: true
|
||||
- include: main
|
||||
expression:
|
||||
- include: literal-closure-labels
|
||||
- include: literal-labels
|
||||
- include: literal-keywords
|
||||
- include: support
|
||||
- include: literal-function
|
||||
- include: literal-closure
|
||||
- include: literal-constant
|
||||
- include: literal-template-string
|
||||
- include: literal-language-variable
|
||||
- include: literal-module
|
||||
- include: literal-method-call
|
||||
- include: literal-function-call
|
||||
- include: comments
|
||||
- include: brackets
|
||||
- include: literal-operators
|
||||
- include: literal-namespace
|
||||
- include: literal-variable
|
||||
- include: literal-punctuation
|
||||
function-declaration-parameters:
|
||||
- match: \(
|
||||
captures:
|
||||
0: punctuation.definition.parameters.begin.rhai
|
||||
push:
|
||||
- match: \)
|
||||
captures:
|
||||
0: punctuation.definition.parameters.end.rhai
|
||||
pop: true
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.parameter.function.rhai
|
||||
- match: \,
|
||||
scope: punctuation.separator.parameter.function.rhai
|
||||
- include: parameters-list
|
||||
literal-closure:
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
- match: '(\b[_a-zA-Z]\w*)\s*(=)\s*(\|)'
|
||||
captures:
|
||||
1: entity.name.function.closure.rhai
|
||||
2: keyword.operator.assignment.rhai
|
||||
3: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-closure-labels:
|
||||
- match: '(\b[_a-zA-Z]\w*)\s*(\:)\s*(\|)'
|
||||
captures:
|
||||
1: string.unquoted.label.rhai entity.name.function.method.rhai
|
||||
2: punctuation.separator.key-value.rhai
|
||||
3: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
- match: '((\")((?:[^"]|\\")*)(\"))\s*(:)\s*(\|)'
|
||||
captures:
|
||||
1: string.quoted.double.rhai
|
||||
2: punctuation.definition.string.begin.rhai
|
||||
3: entity.name.function.method.rhai
|
||||
4: punctuation.definition.string.end.rhai
|
||||
5: punctuation.separator.key-value.rhai
|
||||
6: punctuation.definition.parameters.closure.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.closure.rhai
|
||||
- match: (\|)
|
||||
captures:
|
||||
1: punctuation.definition.parameters.closure.end.rhai
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-constant:
|
||||
- include: literal-number
|
||||
- include: literal-string
|
||||
- include: literal-language-constant
|
||||
literal-function:
|
||||
- match: |-
|
||||
(?x) (?:\b(private)\s+)?
|
||||
\s*(fn)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
captures:
|
||||
1: storage.modifier.rhai
|
||||
2: storage.type.function.rhai
|
||||
3: entity.name.function.rhai
|
||||
push:
|
||||
- meta_scope: meta.function.rhai
|
||||
- match: (?<=\))
|
||||
pop: true
|
||||
- include: parameters-list
|
||||
literal-function-call:
|
||||
- match: |-
|
||||
(?x) ([_a-zA-Z]\w*)(!)?\s*
|
||||
(\(\s*\))
|
||||
scope: meta.function-call.without-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: punctuation.function-call.capture.rhai
|
||||
3: meta.group.braces.round.function.arguments.rhai
|
||||
- match: |-
|
||||
(?x) ([_a-zA-Z]\w*)(!)?\s*
|
||||
(?=\()
|
||||
scope: meta.function-call.with-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: punctuation.function-call.capture.rhai
|
||||
literal-keyword-storage:
|
||||
- match: \b(const|let)\b
|
||||
scope: storage.type.rhai
|
||||
literal-keywords:
|
||||
- include: literal-keyword-storage
|
||||
- match: \b(return)\b
|
||||
scope: keyword.control.flow.rhai
|
||||
- match: \b(if|else|switch)\b
|
||||
scope: keyword.control.conditional.rhai
|
||||
- match: \b(throw|try|catch)\b
|
||||
scope: keyword.control.trycatch.rhai
|
||||
- match: \b(for|in|loop|do|while|until|break|continue)\b
|
||||
scope: keyword.control.loop.rhai
|
||||
literal-labels:
|
||||
- match: |-
|
||||
(?x) (?<!\?)(?<!\?\s)
|
||||
(?=((")((?:[^\:"]|\\")*)("))\s*:)
|
||||
push:
|
||||
- match: ":"
|
||||
captures:
|
||||
0: punctuation.separator.key-value.rhai
|
||||
pop: true
|
||||
- include: literal-string
|
||||
- match: '(?<!\.|\?|\?\s)([_a-zA-Z]\w*)\s*(:)(?!\:)'
|
||||
scope: constant.other.object.key.rhai
|
||||
captures:
|
||||
1: string.unquoted.label.rhai
|
||||
2: punctuation.separator.key-value.rhai
|
||||
literal-language-constant:
|
||||
- match: \btrue\b
|
||||
scope: constant.language.boolean.true.rhai
|
||||
- match: \bfalse\b
|
||||
scope: constant.language.boolean.false.rhai
|
||||
literal-language-namespace:
|
||||
- match: '(?<!\:\:)\s*((global)\s*(\:\:))(?!<)'
|
||||
captures:
|
||||
1: meta.path.rhai
|
||||
2: constant.language.namespace.global.rhai entity.name.namespace.rhai
|
||||
3: punctuation.separator.namespace.rhai
|
||||
literal-language-variable:
|
||||
- match: \bthis\b
|
||||
scope: variable.language.this.rhai
|
||||
literal-method-call:
|
||||
- match: |-
|
||||
(?x) (?<=\.)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
(\(\s*\))
|
||||
scope: meta.function-call.method.without-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
2: meta.group.braces.round.function.arguments.rhai
|
||||
- match: |-
|
||||
(?x) (?<=\.)
|
||||
\s*([_a-zA-Z]\w*)\s*
|
||||
(?=\()
|
||||
scope: meta.function-call.method.with-arguments.rhai
|
||||
captures:
|
||||
1: entity.name.function.rhai
|
||||
literal-module:
|
||||
- match: \b(import|export|as)\b
|
||||
scope: keyword.control.import.rhai
|
||||
literal-namespace:
|
||||
- include: literal-language-namespace
|
||||
- match: '([_a-zA-Z]\w*)\s*(\:\:)(?!<)'
|
||||
scope: meta.path.rhai
|
||||
captures:
|
||||
1: entity.name.namespace.rhai
|
||||
2: punctuation.separator.namespace.rhai
|
||||
- match: '(?<=\:\:)(\s*([_a-zA-Z]\w*))\s*(?!\:\:)'
|
||||
captures:
|
||||
1: meta.path.rhai
|
||||
2: variable.other.constant.rhai
|
||||
literal-number:
|
||||
- match: |-
|
||||
(?xi) (?:
|
||||
\b0b[0-1][_0-1]*| # binary
|
||||
\b0o[0-7][_0-7]*| # octal
|
||||
\b0x[\da-f][_\da-f]*| # hex
|
||||
(\B[+\-])?\b\d[_\d]*\.\d[_\d]*(e[+\-]?\d[_\d]*)?| # e.g. 999.999, 999.99e+123
|
||||
(\B[+\-])?\b\d[_\d]*\.\B| # e.g. 999.
|
||||
(\B[+\-])?\b\d[_\d]*(e[+\-]?\d[_\d]*)? # e.g. 999, 999e+123
|
||||
)
|
||||
scope: constant.numeric.rhai
|
||||
literal-operators:
|
||||
- match: |-
|
||||
(?x) !(?!=)| # logical-not right-to-left right
|
||||
&& | # logical-and left-to-right both
|
||||
\|\| # logical-or left-to-right both
|
||||
scope: keyword.operator.logical.rhai
|
||||
- match: "(?x) =(?![=>]) # assignment right-to-left both"
|
||||
scope: keyword.operator.assignment.rhai
|
||||
- match: |-
|
||||
(?x) %= | # assignment right-to-left both
|
||||
&= | # assignment right-to-left both
|
||||
\*\*=| # assignment right-to-left both
|
||||
(?<!\*)\*= | # assignment right-to-left both
|
||||
\+= | # assignment right-to-left both
|
||||
-= | # assignment right-to-left both
|
||||
/= | # assignment right-to-left both
|
||||
\^= | # assignment right-to-left both
|
||||
\|= | # assignment right-to-left both
|
||||
<<= | # assignment right-to-left both
|
||||
>>= # assignment right-to-left both
|
||||
scope: keyword.operator.assignment.augmented.rhai
|
||||
- match: |-
|
||||
(?x) << | # bitwise-shift left-to-right both
|
||||
>> | # bitwise-shift left-to-right both
|
||||
& | # bitwise-and left-to-right both
|
||||
\^ | # bitwise-xor left-to-right both
|
||||
\| # bitwise-or left-to-right both
|
||||
scope: keyword.operator.bitwise.rhai
|
||||
- match: |-
|
||||
(?x) <= | # relational left-to-right both
|
||||
>= | # relational left-to-right both
|
||||
<(?!-) | # relational left-to-right both
|
||||
(?<!-)> # relational left-to-right both
|
||||
scope: keyword.operator.relational.rhai
|
||||
- match: |-
|
||||
(?x) ==(?!=) | # equality left-to-right both
|
||||
!=(?!=) # equality left-to-right both
|
||||
scope: keyword.operator.comparison.rhai
|
||||
- match: |-
|
||||
(?x) / | # division left-to-right both
|
||||
% | # modulus left-to-right both
|
||||
\*\* | # power left-to-right both
|
||||
\*(?!\)) | # multiplication left-to-right both
|
||||
\+(?!\+) | # addition left-to-right both
|
||||
-(?![>-]) # subtraction left-to-right both
|
||||
scope: keyword.operator.arithmetic.rhai
|
||||
- match: \.(?!\.)
|
||||
scope: keyword.operator.accessor.rhai punctuation.accessor.rhai
|
||||
- match: "=>"
|
||||
scope: punctuation.separator.switch.case.rhai
|
||||
- match: '(\(\*|\*\)|\+\+|--|\.\.+|~|#(?!{)|@|\$(?!{)|->|<-|===|!==|\:=|\:\:<)'
|
||||
scope: invalid.illegal.operator.rhai
|
||||
literal-punctuation:
|
||||
- match: \;
|
||||
scope: punctuation.terminator.statement.rhai
|
||||
- match: \,
|
||||
scope: meta.delimiter.comma.rhai
|
||||
literal-string:
|
||||
- match: '(''([^''\\]|\\([tnr''\\]|x\h{2}|u\h{4}|U\h{8}))'')'
|
||||
scope: string.quoted.single.rhai
|
||||
- match: (")
|
||||
captures:
|
||||
1: punctuation.definition.string.begin.rhai
|
||||
push:
|
||||
- meta_scope: string.quoted.double.rhai
|
||||
- match: (")
|
||||
captures:
|
||||
1: punctuation.definition.string.end.rhai
|
||||
pop: true
|
||||
- include: string-content
|
||||
- match: (?<!\\)\n
|
||||
scope: invalid.illegal.newline.rhai
|
||||
- match: \\\n
|
||||
scope: constant.character.escape.newline.rhai punctuation.separator.continuation
|
||||
literal-template-string:
|
||||
- match: "`"
|
||||
captures:
|
||||
0: punctuation.definition.string.begin.rhai
|
||||
push:
|
||||
- meta_scope: string.interpolated.rhai
|
||||
- match: "`"
|
||||
captures:
|
||||
0: punctuation.definition.string.end.rhai
|
||||
pop: true
|
||||
- include: string-content
|
||||
- match: '\${'
|
||||
captures:
|
||||
0: punctuation.section.interpolation.begin.rhai
|
||||
push:
|
||||
- meta_scope: meta.interpolation.rhai
|
||||
- match: "}"
|
||||
captures:
|
||||
0: punctuation.section.interpolation.end.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
literal-variable:
|
||||
- match: '[A-Z][_\dA-Z]*\b'
|
||||
scope: variable.other.constant.rhai
|
||||
- match: '(?<!\.)\s*([_a-zA-Z]\w*)\s*(?=\.)'
|
||||
captures:
|
||||
1: variable.other.object.rhai
|
||||
- match: '(?<=\.)\s*([_a-zA-Z]\w*)'
|
||||
captures:
|
||||
1: variable.other.property.rhai entity.name.property.rhai
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.other.readwrite.rhai
|
||||
parameters-list:
|
||||
- match: '[_a-zA-Z]\w*'
|
||||
scope: variable.parameter.function.rhai
|
||||
- match: \,
|
||||
scope: punctuation.separator.parameter.function.rhai
|
||||
- include: comments
|
||||
round-brackets:
|
||||
- match: \((?!\*)
|
||||
captures:
|
||||
0: meta.brace.round.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.round
|
||||
- match: (?<!\*)\)
|
||||
captures:
|
||||
0: meta.brace.round.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
square-brackets:
|
||||
- match: '\['
|
||||
captures:
|
||||
0: meta.brace.square.rhai
|
||||
push:
|
||||
- meta_scope: meta.group.braces.square
|
||||
- match: '\]'
|
||||
captures:
|
||||
0: meta.brace.square.rhai
|
||||
pop: true
|
||||
- include: expression
|
||||
string-content:
|
||||
- match: '\\(x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8}|t|r|n|\\)'
|
||||
scope: constant.character.escape.rhai
|
||||
- match: '\\[^xuUtrn\\\n]'
|
||||
scope: invalid.illegal.escape.rhai
|
||||
support:
|
||||
- match: \b(print|debug|call|curry|eval|type_of|is_def_var|is_def_fn|is_shared)\b
|
||||
scope: support.function.rhai
|
||||
- match: \b(var|static|shared|goto|exit|match|case|public|protected|new|use|with|module|package|super|thread|spawn|go|await|async|sync|yield|default|void|null|nil)\b
|
||||
scope: invalid.illegal.keyword.rhai
|
||||
@@ -0,0 +1,51 @@
|
||||
//! A clipboard shared by the text widgets ([`code_editor`](crate::code_editor),
|
||||
//! [`text_field`](crate::text_field)).
|
||||
//!
|
||||
//! It is backed by an always-present internal buffer, mirrored to the system clipboard
|
||||
//! when one is available — so copy/paste works the same everywhere, degrading cleanly.
|
||||
|
||||
/// A clipboard backed by an always-present internal buffer, mirrored to the system
|
||||
/// clipboard when one is available.
|
||||
///
|
||||
/// `arboard::Clipboard::new()` fails with no display (headless Linux, etc.) and isn't
|
||||
/// available on the web; in those cases `system` is `None` and copy/paste still work
|
||||
/// *within* the app via `internal`. When the system clipboard is present we write
|
||||
/// through to it (so other apps see copies) and prefer it on read (so we see their
|
||||
/// copies), falling back to `internal` if a read fails.
|
||||
pub(crate) struct Clipboard {
|
||||
system: Option<arboard::Clipboard>,
|
||||
internal: String,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
/// Creates a clipboard, attaching the system one if it initializes.
|
||||
///
|
||||
/// Under `cfg(test)` the system clipboard is never attached: arboard isn't safe to
|
||||
/// initialize from many threads at once (the parallel test harness would otherwise
|
||||
/// crash on macOS), and tests exercise copy/paste through the internal buffer anyway.
|
||||
pub(crate) fn new() -> Self {
|
||||
#[cfg(not(test))]
|
||||
let system = arboard::Clipboard::new().ok();
|
||||
#[cfg(test)]
|
||||
let system = None;
|
||||
Self { system, internal: String::new() }
|
||||
}
|
||||
|
||||
/// Stores `text` internally and (best-effort) on the system clipboard.
|
||||
pub(crate) fn set(&mut self, text: String) {
|
||||
if let Some(sys) = &mut self.system {
|
||||
let _ = sys.set_text(&text);
|
||||
}
|
||||
self.internal = text;
|
||||
}
|
||||
|
||||
/// Returns the system clipboard text if readable, else the internal buffer.
|
||||
pub(crate) fn get(&mut self) -> String {
|
||||
if let Some(sys) = &mut self.system
|
||||
&& let Ok(text) = sys.get_text()
|
||||
{
|
||||
return text;
|
||||
}
|
||||
self.internal.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
//! A small, purpose-built **modeless, keyboard-only** code editor widget.
|
||||
//!
|
||||
//! It edits like an ordinary text box (no vim modes): arrow keys move (wrapping across line
|
||||
//! ends), printable keys insert, `shift`+arrows select, `ctrl`/`⌘`+`c`/`x`/`v` use the system
|
||||
//! clipboard, `ctrl`/`⌘`+`z`/`y` undo/redo, and `Esc` asks the host to close. It shows a
|
||||
//! line-number gutter and syntect syntax highlighting (the embedded Rhai syntax).
|
||||
//!
|
||||
//! Mouse is intentionally out of scope for now (its click hit-testing — gutter + scroll +
|
||||
//! unicode width + tab expansion — is the hard part); the same per-line width model here
|
||||
//! would invert to support it later. Like [`dialog`](crate::dialog) this is presentation +
|
||||
//! input only; the host owns the loop and decides what open/close means.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{Theme, ThemeSet};
|
||||
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};
|
||||
|
||||
/// The embedded Rhai Sublime-syntax definition used for highlighting.
|
||||
const RHAI_SYNTAX: &str = include_str!("../resources/rhai.sublime-syntax");
|
||||
/// A dark syntect theme that ships with `ThemeSet::load_defaults`.
|
||||
const THEME_NAME: &str = "base16-ocean.dark";
|
||||
/// Maximum retained undo (and redo) snapshots.
|
||||
const UNDO_DEPTH: usize = 200;
|
||||
/// Default text color when no syntax color applies.
|
||||
const DEFAULT_FG: Color = Color::Rgb(220, 220, 220);
|
||||
/// Background of selected text.
|
||||
const SELECT_BG: Color = Color::Rgb(40, 60, 110);
|
||||
/// Line-number gutter color.
|
||||
const GUTTER_FG: Color = Color::Rgb(90, 100, 120);
|
||||
|
||||
/// What a key/paste event did to the editor, reported back to the host.
|
||||
pub enum CodeEditorOutcome {
|
||||
/// The editor stays open.
|
||||
Continue,
|
||||
/// The user pressed `Esc`; the host should close the editor.
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// A cursor/selection position: `col` is a **character** index within `row`'s line.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct Pos {
|
||||
row: usize,
|
||||
col: usize,
|
||||
}
|
||||
|
||||
/// Which kind of edit just happened, used to coalesce undo steps.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EditKind {
|
||||
/// A run of single-character insertions.
|
||||
Insert,
|
||||
/// A run of single-character deletions.
|
||||
Delete,
|
||||
/// Any structural edit (newline, paste, selection delete) — never coalesced.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A point-in-time buffer snapshot for undo/redo.
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
lines: Vec<String>,
|
||||
cursor: Pos,
|
||||
}
|
||||
|
||||
/// Cached syntect pieces for highlighting (reused across frames).
|
||||
struct HighlightParts {
|
||||
theme: Theme,
|
||||
syntax_ref: SyntaxReference,
|
||||
syntax_set: Arc<SyntaxSet>,
|
||||
}
|
||||
|
||||
/// A modeless, keyboard-driven code editor over a single script's text.
|
||||
pub struct CodeEditor {
|
||||
/// The script name, shown in the title border and used by the host on save.
|
||||
name: String,
|
||||
/// The text buffer, one entry per line (always at least one line).
|
||||
lines: Vec<String>,
|
||||
/// The cursor position.
|
||||
cursor: Pos,
|
||||
/// Remembered display intent for vertical movement (so up/down keep a column).
|
||||
desired_col: usize,
|
||||
/// Selection anchor; the selection spans `anchor..cursor` when `Some`.
|
||||
anchor: Option<Pos>,
|
||||
/// Scroll offset as `(top row, left display column)`, kept following the cursor.
|
||||
scroll: (usize, usize),
|
||||
/// Visible text rows from the last `draw`, used by PageUp/PageDown.
|
||||
view_rows: usize,
|
||||
/// Syntax-highlighting data, or `None` if the syntax/theme failed to load.
|
||||
highlight: Option<HighlightParts>,
|
||||
/// Clipboard: the system one when available, always backed by an internal buffer.
|
||||
clipboard: Clipboard,
|
||||
/// Undo/redo snapshot stacks and the open-edit-group marker for coalescing.
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
last_edit: Option<EditKind>,
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Builds an editor for `name` pre-filled with `text`.
|
||||
pub fn new(name: &str, text: &str) -> Self {
|
||||
let lines: Vec<String> = if text.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
text.split('\n').map(str::to_string).collect()
|
||||
};
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
lines,
|
||||
cursor: Pos { row: 0, col: 0 },
|
||||
desired_col: 0,
|
||||
anchor: None,
|
||||
scroll: (0, 0),
|
||||
view_rows: 0,
|
||||
highlight: build_highlight_parts(),
|
||||
clipboard: Clipboard::new(),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
last_edit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The script name (the host writes [`text`](CodeEditor::text) back under it).
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The current buffer contents (lines joined with `\n`).
|
||||
pub fn text(&self) -> String {
|
||||
self.lines.join("\n")
|
||||
}
|
||||
|
||||
/// Handles a key/paste event. `Esc` requests close; unrecognized keys are ignored.
|
||||
pub fn handle_event(&mut self, event: &Event) -> CodeEditorOutcome {
|
||||
match event {
|
||||
// Terminal (bracketed) paste: insert the text as-is.
|
||||
Event::Paste(text) => {
|
||||
self.break_group();
|
||||
self.replace_selection();
|
||||
self.insert_text(text);
|
||||
}
|
||||
Event::Key(key) => {
|
||||
// Treat ⌘ as ctrl (terminals report Super for Cmd under the kitty protocol).
|
||||
let key = super_to_ctrl(*key);
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
match key.code {
|
||||
KeyCode::Esc => return CodeEditorOutcome::Exit,
|
||||
// Clipboard / undo / select-all chords.
|
||||
KeyCode::Char(c) if ctrl => match c.to_ascii_lowercase() {
|
||||
'c' => self.copy(),
|
||||
'x' => self.cut(),
|
||||
'v' => self.paste(),
|
||||
'a' => self.select_all(),
|
||||
'z' => {
|
||||
if shift {
|
||||
self.redo()
|
||||
} else {
|
||||
self.undo()
|
||||
}
|
||||
}
|
||||
'y' => self.redo(),
|
||||
_ => {}
|
||||
},
|
||||
// Printable text.
|
||||
KeyCode::Char(c) if !alt => self.insert_char(c),
|
||||
KeyCode::Tab => self.insert_tab(),
|
||||
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::Home => self.moved(shift, Self::move_home),
|
||||
KeyCode::End => self.moved(shift, Self::move_end),
|
||||
KeyCode::PageUp => self.moved(shift, Self::move_page_up),
|
||||
KeyCode::PageDown => self.moved(shift, Self::move_page_down),
|
||||
_ => {} // ignore anything else — never panic on an unknown key
|
||||
}
|
||||
}
|
||||
_ => {} // mouse and other events are ignored for now
|
||||
}
|
||||
CodeEditorOutcome::Continue
|
||||
}
|
||||
|
||||
// ── Movement ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a movement, first setting/clearing the selection anchor per `shift`.
|
||||
fn moved(&mut self, shift: bool, mv: fn(&mut Self)) {
|
||||
self.break_group();
|
||||
if shift {
|
||||
// Begin (or continue) a selection from the pre-move cursor.
|
||||
if self.anchor.is_none() {
|
||||
self.anchor = Some(self.cursor);
|
||||
}
|
||||
} else {
|
||||
self.anchor = None;
|
||||
}
|
||||
mv(self);
|
||||
}
|
||||
|
||||
/// Number of characters in line `row`.
|
||||
fn line_len(&self, row: usize) -> usize {
|
||||
self.lines[row].chars().count()
|
||||
}
|
||||
|
||||
fn move_left(&mut self) {
|
||||
if self.cursor.col > 0 {
|
||||
self.cursor.col -= 1;
|
||||
} else if self.cursor.row > 0 {
|
||||
// Wrap to the end of the previous line.
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_right(&mut self) {
|
||||
if self.cursor.col < self.line_len(self.cursor.row) {
|
||||
self.cursor.col += 1;
|
||||
} else if self.cursor.row + 1 < self.lines.len() {
|
||||
// Wrap to the start of the next line.
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
if self.cursor.row > 0 {
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
if self.cursor.row + 1 < self.lines.len() {
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
}
|
||||
|
||||
/// ctrl+Left: jump to the previous word start (wrapping to the previous line's end).
|
||||
fn move_word_left(&mut self) {
|
||||
if self.cursor.col == 0 && self.cursor.row > 0 {
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
} else {
|
||||
self.cursor.col = prev_word(&self.lines[self.cursor.row], self.cursor.col);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
} else {
|
||||
self.cursor.col = next_word(&self.lines[self.cursor.row], self.cursor.col);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// 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.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
/// ctrl+Down: jump to the nearest blank/whitespace-only line below, else the last line.
|
||||
fn move_paragraph_down(&mut self) {
|
||||
let last = self.lines.len() - 1;
|
||||
self.cursor.row = (self.cursor.row + 1..self.lines.len())
|
||||
.find(|&r| self.is_blank_line(r))
|
||||
.unwrap_or(last);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
/// Whether line `row` is empty or contains only whitespace.
|
||||
fn is_blank_line(&self, row: usize) -> bool {
|
||||
self.lines[row].trim().is_empty()
|
||||
}
|
||||
|
||||
fn move_home(&mut self) {
|
||||
self.cursor.col = 0;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
fn move_end(&mut self) {
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn move_page_up(&mut self) {
|
||||
let step = self.view_rows.max(1);
|
||||
self.cursor.row = self.cursor.row.saturating_sub(step);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
fn move_page_down(&mut self) {
|
||||
let step = self.view_rows.max(1);
|
||||
self.cursor.row = (self.cursor.row + step).min(self.lines.len() - 1);
|
||||
self.cursor.col = self.desired_col.min(self.line_len(self.cursor.row));
|
||||
}
|
||||
|
||||
// ── Editing ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn insert_char(&mut self, c: char) {
|
||||
self.replace_selection();
|
||||
self.begin_edit(EditKind::Insert);
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].insert(b, c);
|
||||
self.cursor.col += 1;
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
/// Inserts spaces to advance to the next tab stop.
|
||||
fn insert_tab(&mut self) {
|
||||
self.replace_selection();
|
||||
let pad = TAB_WIDTH - (self.display_col(self.cursor.row, self.cursor.col) % TAB_WIDTH);
|
||||
for _ in 0..pad {
|
||||
self.insert_char(' ');
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_newline(&mut self) {
|
||||
self.replace_selection();
|
||||
self.begin_edit(EditKind::Other);
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
let tail = self.lines[self.cursor.row].split_off(b);
|
||||
self.lines.insert(self.cursor.row + 1, tail);
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
/// Inserts arbitrary text (possibly multi-line) at the cursor.
|
||||
fn insert_text(&mut self, text: &str) {
|
||||
self.begin_edit(EditKind::Other);
|
||||
for (i, part) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
// Each '\n' splits the current line.
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
let tail = self.lines[self.cursor.row].split_off(b);
|
||||
self.lines.insert(self.cursor.row + 1, tail);
|
||||
self.cursor.row += 1;
|
||||
self.cursor.col = 0;
|
||||
}
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].insert_str(b, part);
|
||||
self.cursor.col += part.chars().count();
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn backspace(&mut self) {
|
||||
if self.anchor.is_some() {
|
||||
self.replace_selection();
|
||||
return;
|
||||
}
|
||||
self.begin_edit(EditKind::Delete);
|
||||
if self.cursor.col > 0 {
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col - 1);
|
||||
self.lines[self.cursor.row].remove(b);
|
||||
self.cursor.col -= 1;
|
||||
} else if self.cursor.row > 0 {
|
||||
// Join with the previous line.
|
||||
let line = self.lines.remove(self.cursor.row);
|
||||
self.cursor.row -= 1;
|
||||
self.cursor.col = self.line_len(self.cursor.row);
|
||||
self.lines[self.cursor.row].push_str(&line);
|
||||
}
|
||||
self.desired_col = self.cursor.col;
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) {
|
||||
if self.anchor.is_some() {
|
||||
self.replace_selection();
|
||||
return;
|
||||
}
|
||||
self.begin_edit(EditKind::Delete);
|
||||
if self.cursor.col < self.line_len(self.cursor.row) {
|
||||
let b = byte_of(&self.lines[self.cursor.row], self.cursor.col);
|
||||
self.lines[self.cursor.row].remove(b);
|
||||
} else if self.cursor.row + 1 < self.lines.len() {
|
||||
// Pull the next line up onto this one.
|
||||
let next = self.lines.remove(self.cursor.row + 1);
|
||||
self.lines[self.cursor.row].push_str(&next);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// The selection as an ordered `(start, end)` pair, if any.
|
||||
fn selection(&self) -> Option<(Pos, Pos)> {
|
||||
let a = self.anchor?;
|
||||
let c = self.cursor;
|
||||
if (a.row, a.col) <= (c.row, c.col) {
|
||||
Some((a, c))
|
||||
} else {
|
||||
Some((c, a))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
self.begin_edit(EditKind::Other);
|
||||
if a.row == b.row {
|
||||
let s = &mut self.lines[a.row];
|
||||
let (ba, bb) = (byte_of(s, a.col), byte_of(s, b.col));
|
||||
s.replace_range(ba..bb, "");
|
||||
} else {
|
||||
let bb = byte_of(&self.lines[b.row], b.col);
|
||||
let tail = self.lines[b.row][bb..].to_string();
|
||||
let ba = byte_of(&self.lines[a.row], a.col);
|
||||
self.lines[a.row].truncate(ba);
|
||||
self.lines[a.row].push_str(&tail);
|
||||
self.lines.drain(a.row + 1..=b.row);
|
||||
}
|
||||
self.cursor = a;
|
||||
self.desired_col = a.col;
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
/// The text covered by an ordered selection `(a, b)`.
|
||||
fn selected_text(&self, a: Pos, b: Pos) -> String {
|
||||
if a.row == b.row {
|
||||
let s = &self.lines[a.row];
|
||||
return s[byte_of(s, a.col)..byte_of(s, b.col)].to_string();
|
||||
}
|
||||
let mut out = self.lines[a.row][byte_of(&self.lines[a.row], a.col)..].to_string();
|
||||
for row in a.row + 1..b.row {
|
||||
out.push('\n');
|
||||
out.push_str(&self.lines[row]);
|
||||
}
|
||||
out.push('\n');
|
||||
out.push_str(&self.lines[b.row][..byte_of(&self.lines[b.row], b.col)]);
|
||||
out
|
||||
}
|
||||
|
||||
fn select_all(&mut self) {
|
||||
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) };
|
||||
}
|
||||
|
||||
// ── Clipboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn copy(&mut self) {
|
||||
if let Some((a, b)) = self.selection() {
|
||||
let text = self.selected_text(a, b);
|
||||
self.clipboard.set(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn cut(&mut self) {
|
||||
self.copy();
|
||||
self.replace_selection();
|
||||
}
|
||||
|
||||
fn paste(&mut self) {
|
||||
let text = self.clipboard.get();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.break_group();
|
||||
self.replace_selection();
|
||||
self.insert_text(&text);
|
||||
}
|
||||
|
||||
// ── Undo / redo ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Begins an edit of `kind`, snapshotting state unless it coalesces with the run in
|
||||
/// progress (consecutive inserts, or consecutive deletes, collapse to one undo step).
|
||||
fn begin_edit(&mut self, kind: EditKind) {
|
||||
let coalesce = kind != EditKind::Other && self.last_edit == Some(kind);
|
||||
if !coalesce {
|
||||
self.push_snapshot();
|
||||
}
|
||||
self.last_edit = Some(kind);
|
||||
}
|
||||
|
||||
/// Ends the current edit run so the next edit starts a fresh undo step.
|
||||
fn break_group(&mut self) {
|
||||
self.last_edit = None;
|
||||
}
|
||||
|
||||
/// 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 });
|
||||
if self.undo.len() > UNDO_DEPTH {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
fn undo(&mut self) {
|
||||
if let Some(prev) = self.undo.pop() {
|
||||
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.restore(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the buffer/cursor with a snapshot, ending any edit run and selection.
|
||||
fn restore(&mut self, snap: Snapshot) {
|
||||
self.lines = snap.lines;
|
||||
self.cursor = snap.cursor;
|
||||
self.desired_col = self.cursor.col;
|
||||
self.anchor = None;
|
||||
self.last_edit = None;
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Display column (terminal cells) of character index `col` on line `row` (tabs to the
|
||||
/// next [`TAB_WIDTH`] stop, wide chars as two cells).
|
||||
fn display_col(&self, row: usize, col: usize) -> usize {
|
||||
display_width(&self.lines[row], col)
|
||||
}
|
||||
|
||||
/// Renders the editor (titled border, gutter, highlighted text, cursor) into `area`.
|
||||
pub fn draw(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::bordered().title(format!(" {} ", self.name));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = inner.height as usize;
|
||||
self.view_rows = rows;
|
||||
// Gutter holds the largest line number plus a trailing space.
|
||||
let gutter_w = (self.lines.len().to_string().len() + 1) as u16;
|
||||
let text_x = inner.x + gutter_w;
|
||||
let text_w = inner.width.saturating_sub(gutter_w);
|
||||
if text_w == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_scroll(rows, text_w as usize);
|
||||
let (top, left) = self.scroll;
|
||||
let bottom = (top + rows).min(self.lines.len());
|
||||
|
||||
// Highlight from line 0 to the viewport bottom (syntect needs prior lines for context).
|
||||
let colors = self.highlight_through(bottom);
|
||||
let sel = self.selection();
|
||||
|
||||
for (i, row) in (top..bottom).enumerate() {
|
||||
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));
|
||||
// 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 };
|
||||
frame.render_widget(Paragraph::new(line).scroll((0, left as u16)), rect);
|
||||
}
|
||||
|
||||
// Place the real terminal cursor if it's within the visible window.
|
||||
let cx = self.display_col(self.cursor.row, self.cursor.col);
|
||||
if (top..bottom).contains(&self.cursor.row) && cx >= left && cx - left < text_w as usize {
|
||||
let x = text_x + (cx - left) as u16;
|
||||
let y = inner.y + (self.cursor.row - top) as u16;
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts `scroll` so the cursor stays visible (vertical by row, horizontal by cell).
|
||||
fn update_scroll(&mut self, rows: usize, text_w: usize) {
|
||||
let (mut top, mut left) = self.scroll;
|
||||
if self.cursor.row < top {
|
||||
top = self.cursor.row;
|
||||
} else if self.cursor.row >= top + rows {
|
||||
top = self.cursor.row + 1 - rows;
|
||||
}
|
||||
let cx = self.display_col(self.cursor.row, self.cursor.col);
|
||||
if cx < left {
|
||||
left = cx;
|
||||
} else if cx >= left + text_w {
|
||||
left = cx + 1 - text_w;
|
||||
}
|
||||
self.scroll = (top, left);
|
||||
}
|
||||
|
||||
/// Per-character foreground colors for lines `0..upto`, via syntect (empty if disabled).
|
||||
fn highlight_through(&self, upto: usize) -> Vec<Vec<Color>> {
|
||||
let Some(hl) = &self.highlight else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut h = HighlightLines::new(&hl.syntax_ref, &hl.theme);
|
||||
let mut out = Vec::with_capacity(upto);
|
||||
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 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);
|
||||
colors.extend(text.chars().map(|_| c));
|
||||
}
|
||||
colors.truncate(line.chars().count());
|
||||
out.push(colors);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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 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);
|
||||
}
|
||||
if c == '\t' {
|
||||
let pad = TAB_WIDTH - (col_cells % TAB_WIDTH);
|
||||
spans.push(Span::styled(" ".repeat(pad), style));
|
||||
col_cells += pad;
|
||||
} else {
|
||||
spans.push(Span::styled(c.to_string(), style));
|
||||
col_cells += char_cells(c, col_cells);
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether character `(row, col)` lies within ordered selection `[a, b)`.
|
||||
fn pos_in_range(row: usize, col: usize, a: Pos, b: Pos) -> bool {
|
||||
let after_start = row > a.row || (row == a.row && col >= a.col);
|
||||
let before_end = row < b.row || (row == b.row && col < b.col);
|
||||
after_start && before_end
|
||||
}
|
||||
|
||||
/// Parses the embedded Rhai syntax and a default theme into reusable highlight parts.
|
||||
/// Returns `None` (highlighting disabled, editor still works) on any failure.
|
||||
fn build_highlight_parts() -> Option<HighlightParts> {
|
||||
let syntax = SyntaxDefinition::load_from_str(RHAI_SYNTAX, true, None).ok()?;
|
||||
let mut builder = SyntaxSetBuilder::new();
|
||||
builder.add(syntax);
|
||||
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) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::KeyEvent;
|
||||
|
||||
/// Sends a bare key code to the editor.
|
||||
fn send(ed: &mut CodeEditor, code: KeyCode) {
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(code, KeyModifiers::NONE)));
|
||||
}
|
||||
/// Sends a key code with modifiers (e.g. ctrl/shift).
|
||||
fn send_mod(ed: &mut CodeEditor, code: KeyCode, mods: KeyModifiers) {
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(code, mods)));
|
||||
}
|
||||
/// Types each character of `s` in order.
|
||||
fn typ(ed: &mut CodeEditor, s: &str) {
|
||||
for c in s.chars() {
|
||||
send(ed, KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_rhai_syntax_parses_and_theme_loads() {
|
||||
assert!(build_highlight_parts().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_round_trips_and_typing_inserts() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "let x = 1;");
|
||||
assert_eq!(ed.text(), "let x = 1;");
|
||||
assert_eq!(CodeEditor::new("s", "a\nb\nc").text(), "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_at_col0_wraps_to_previous_line_end() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::Down); // (1,0)
|
||||
send(&mut ed, KeyCode::Left); // should wrap to end of line 0 = (0,2)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "abX\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_at_line_end_wraps_down() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::End); // (0,2)
|
||||
send(&mut ed, KeyCode::Right); // wrap to (1,0)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "ab\nXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_col0_joins_lines() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::Down); // (1,0)
|
||||
send(&mut ed, KeyCode::Backspace);
|
||||
assert_eq!(ed.text(), "abcd");
|
||||
typ(&mut ed, "X"); // cursor should sit at the join (0,2)
|
||||
assert_eq!(ed.text(), "abXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_selection_then_delete_removes_it() {
|
||||
let mut ed = CodeEditor::new("s", "abcd");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
send(&mut ed, KeyCode::Backspace);
|
||||
assert_eq!(ed.text(), "cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracketed_paste_replaces_selection() {
|
||||
let mut ed = CodeEditor::new("s", "abcd");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
ed.handle_event(&Event::Paste("XY\nZ".to_string()));
|
||||
assert_eq!(ed.text(), "XY\nZcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_then_paste_round_trips_via_internal_clipboard() {
|
||||
// Works even headless: the internal buffer backs copy/paste when there's no display.
|
||||
let mut ed = CodeEditor::new("s", "abc");
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT);
|
||||
send_mod(&mut ed, KeyCode::Right, KeyModifiers::SHIFT); // select "ab"
|
||||
send_mod(&mut ed, KeyCode::Char('c'), KeyModifiers::CONTROL); // copy
|
||||
send(&mut ed, KeyCode::End); // clear selection, go to end
|
||||
send_mod(&mut ed, KeyCode::Char('v'), KeyModifiers::CONTROL); // paste
|
||||
assert_eq!(ed.text(), "abcab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_round_trip() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
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);
|
||||
assert_eq!(ed.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_breaks_undo_coalescing() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "ab");
|
||||
send(&mut ed, KeyCode::Left); // breaks the typing group
|
||||
typ(&mut ed, "c"); // "acb" (inserted before 'b')... actually at (0,1): "acb"
|
||||
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::CONTROL);
|
||||
assert_eq!(ed.text(), "ab"); // only the post-move insert is undone
|
||||
}
|
||||
|
||||
/// Sends a key code with modifiers and types a marker, returning where it landed.
|
||||
fn ctrl(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_right_jumps_to_next_word_start() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar baz"); // cursor at (0,0) via Home
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
|
||||
typ(&mut ed, "|"); // insert marker at the landing column
|
||||
assert_eq!(ed.text(), "foo |bar baz"); // landed at start of "bar"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_left_jumps_to_previous_word_start() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar baz");
|
||||
send(&mut ed, KeyCode::End); // move to end (0,11) first
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Left)));
|
||||
typ(&mut ed, "|");
|
||||
assert_eq!(ed.text(), "foo bar |baz"); // landed at start of "baz"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_right_wraps_at_end_of_line() {
|
||||
let mut ed = CodeEditor::new("s", "ab\ncd");
|
||||
send(&mut ed, KeyCode::End); // (0,2) end of first line
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Right)));
|
||||
typ(&mut ed, "X"); // should land at start of line 2
|
||||
assert_eq!(ed.text(), "ab\nXcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_down_and_up_jump_to_blank_lines() {
|
||||
let mut ed = CodeEditor::new("s", "alpha\nbeta\n\ngamma");
|
||||
send(&mut ed, KeyCode::Home); // (0,0)
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Down))); // → blank line (row 2)
|
||||
typ(&mut ed, "X");
|
||||
assert_eq!(ed.text(), "alpha\nbeta\nX\ngamma");
|
||||
// From the blank line, ctrl+Up returns to the top (no blank above).
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(ctrl(KeyCode::Up)));
|
||||
typ(&mut ed, "Y");
|
||||
assert_eq!(ed.text(), "Yalpha\nbeta\nX\ngamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_ctrl_right_selects_a_word() {
|
||||
let mut ed = CodeEditor::new("s", "foo bar");
|
||||
send(&mut ed, KeyCode::Home);
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(
|
||||
KeyCode::Right,
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
))); // select "foo "
|
||||
send(&mut ed, KeyCode::Backspace); // delete the selection
|
||||
assert_eq!(ed.text(), "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_requests_exit() {
|
||||
let mut ed = CodeEditor::new("s", "x");
|
||||
assert!(matches!(
|
||||
ed.handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))),
|
||||
CodeEditorOutcome::Exit
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_z_maps_to_ctrl_undo() {
|
||||
let mut ed = CodeEditor::new("s", "");
|
||||
typ(&mut ed, "hi");
|
||||
send_mod(&mut ed, KeyCode::Char('z'), KeyModifiers::SUPER);
|
||||
assert_eq!(ed.text(), "");
|
||||
}
|
||||
}
|
||||
+32
-48
@@ -5,21 +5,21 @@
|
||||
//! supplies its own; the editor uses its `EditorState`). While a dialog is open the
|
||||
//! host routes all input to it; when dismissed it fires a stored callback with the
|
||||
//! result **and** `&mut Ctx`, so the callback can apply the choice to host state. The
|
||||
//! text field is backed by [`tui_input::Input`], driven through [`InputRequest`] so we
|
||||
//! never depend on `tui-input`'s own event backend (avoiding any crossterm-version coupling).
|
||||
//! text/filter field is a [`TextField`](crate::text_field::TextField) (the same in-house
|
||||
//! single-line editor used elsewhere in kiln-ui).
|
||||
//!
|
||||
//! Drawing is a method ([`Dialog::draw`]) taking `&mut Frame` rather than a ratatui
|
||||
//! `Widget` because it needs the [`Frame`] to place a real terminal cursor in the
|
||||
//! active text field.
|
||||
|
||||
use crate::dim_area;
|
||||
use crate::text_field::TextField;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{Event, KeyCode};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph};
|
||||
use tui_input::{Input, InputRequest};
|
||||
|
||||
/// Background color filling a dialog window.
|
||||
const BG: Color = Color::Rgb(10, 15, 35);
|
||||
@@ -47,11 +47,11 @@ pub enum ListDialogResponse {
|
||||
/// What kind of field a dialog shows, plus its associated state.
|
||||
enum DialogBody {
|
||||
/// A single free-text field.
|
||||
Text { input: Input },
|
||||
Text { field: TextField },
|
||||
/// A filter field above a selectable list. `selected` indexes the *filtered*
|
||||
/// view (entries of `all` whose text starts with the filter).
|
||||
List {
|
||||
input: Input,
|
||||
field: TextField,
|
||||
all: Vec<String>,
|
||||
selected: usize,
|
||||
allow_create: bool,
|
||||
@@ -102,7 +102,7 @@ impl<Ctx> Dialog<Ctx> {
|
||||
Self {
|
||||
title: title.into(),
|
||||
body: DialogBody::Text {
|
||||
input: Input::new(initial.into()),
|
||||
field: TextField::new(initial),
|
||||
},
|
||||
callback: DialogCallback::Text(Box::new(on_done)),
|
||||
}
|
||||
@@ -122,7 +122,7 @@ impl<Ctx> Dialog<Ctx> {
|
||||
Self {
|
||||
title: title.into(),
|
||||
body: DialogBody::List {
|
||||
input: Input::new(String::new()),
|
||||
field: TextField::new(""),
|
||||
all: items,
|
||||
selected: 0,
|
||||
allow_create,
|
||||
@@ -168,12 +168,10 @@ impl<Ctx> Dialog<Ctx> {
|
||||
DialogResult::Continue
|
||||
}
|
||||
// Everything else edits the text/filter field.
|
||||
code => {
|
||||
if let Some(req) = key_to_request(code) {
|
||||
self.input_mut().handle(req);
|
||||
// The filter changed, so the previous selection index may now be
|
||||
// out of range — clamp it back into the filtered list. Compute the
|
||||
// new length first (immutable borrow), then clamp (mutable borrow).
|
||||
_ => {
|
||||
if self.field_mut().handle_key(*key) {
|
||||
// The filter may have changed, so the previous selection index may now
|
||||
// be out of range — clamp it back (compute length first, then clamp).
|
||||
let n = self.filtered_len();
|
||||
if let DialogBody::List { selected, .. } = &mut self.body {
|
||||
*selected = (*selected).min(n.saturating_sub(1));
|
||||
@@ -190,12 +188,12 @@ impl<Ctx> Dialog<Ctx> {
|
||||
pub fn finish(self, result: DialogResult, ctx: &mut Ctx) {
|
||||
let submit = matches!(result, DialogResult::Submit);
|
||||
match (self.body, self.callback) {
|
||||
(DialogBody::Text { input }, DialogCallback::Text(cb)) => {
|
||||
cb(submit.then(|| input.value().to_string()), ctx);
|
||||
(DialogBody::Text { field }, DialogCallback::Text(cb)) => {
|
||||
cb(submit.then(|| field.value().to_string()), ctx);
|
||||
}
|
||||
(
|
||||
DialogBody::List {
|
||||
input,
|
||||
field,
|
||||
all,
|
||||
selected,
|
||||
allow_create,
|
||||
@@ -204,13 +202,13 @@ impl<Ctx> Dialog<Ctx> {
|
||||
) => {
|
||||
let resp = if !submit {
|
||||
ListDialogResponse::Cancel
|
||||
} else if let Some(entry) = filtered_items(&all, input.value())
|
||||
} else if let Some(entry) = filtered_items(&all, field.value())
|
||||
.into_iter()
|
||||
.nth(selected)
|
||||
{
|
||||
ListDialogResponse::Select(entry.clone())
|
||||
} else if allow_create {
|
||||
ListDialogResponse::Create(input.value().to_string())
|
||||
ListDialogResponse::Create(field.value().to_string())
|
||||
} else {
|
||||
ListDialogResponse::Cancel
|
||||
};
|
||||
@@ -226,17 +224,17 @@ impl<Ctx> Dialog<Ctx> {
|
||||
match &self.body {
|
||||
DialogBody::Text { .. } => true,
|
||||
DialogBody::List {
|
||||
input,
|
||||
field,
|
||||
allow_create,
|
||||
..
|
||||
} => self.filtered_len() > 0 || (*allow_create && !input.value().is_empty()),
|
||||
} => self.filtered_len() > 0 || (*allow_create && !field.value().is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable access to whichever [`Input`] field this dialog owns.
|
||||
fn input_mut(&mut self) -> &mut Input {
|
||||
/// Mutable access to whichever [`TextField`] this dialog owns.
|
||||
fn field_mut(&mut self) -> &mut TextField {
|
||||
match &mut self.body {
|
||||
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
|
||||
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,22 +279,22 @@ impl<Ctx> Dialog<Ctx> {
|
||||
let [body_area, footer_area] =
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
|
||||
|
||||
let input = match &self.body {
|
||||
DialogBody::Text { input } | DialogBody::List { input, .. } => input,
|
||||
let field = match &self.body {
|
||||
DialogBody::Text { field } | DialogBody::List { field, .. } => field,
|
||||
};
|
||||
|
||||
if let DialogBody::List {
|
||||
all,
|
||||
selected,
|
||||
input,
|
||||
field,
|
||||
..
|
||||
} = &self.body
|
||||
{
|
||||
// List dialog: the filter field sits atop the scrolling list.
|
||||
let [field_area, list_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(body_area);
|
||||
draw_field(frame, field_area, input);
|
||||
draw_list(frame, list_area, all, input.value(), *selected);
|
||||
draw_field(frame, field_area, field);
|
||||
draw_list(frame, list_area, all, field.value(), *selected);
|
||||
} else {
|
||||
// Text dialog: a single field box, centered horizontally and vertically.
|
||||
let fw = body_area.width.saturating_sub(4).max(1);
|
||||
@@ -306,7 +304,7 @@ impl<Ctx> Dialog<Ctx> {
|
||||
width: fw,
|
||||
height: 1,
|
||||
};
|
||||
draw_field(frame, field_area, input);
|
||||
draw_field(frame, field_area, field);
|
||||
}
|
||||
|
||||
// Footer: [enter] OK (dimmed when disabled) and [esc] Cancel.
|
||||
@@ -329,7 +327,7 @@ impl<Ctx> Dialog<Ctx> {
|
||||
/// Counts the entries passing a list body's current filter (0 for a text body).
|
||||
fn filtered_count(body: &DialogBody) -> usize {
|
||||
match body {
|
||||
DialogBody::List { all, input, .. } => filtered_items(all, input.value()).len(),
|
||||
DialogBody::List { all, field, .. } => filtered_items(all, field.value()).len(),
|
||||
DialogBody::Text { .. } => 0,
|
||||
}
|
||||
}
|
||||
@@ -339,33 +337,19 @@ fn filtered_items<'a>(all: &'a [String], filter: &str) -> Vec<&'a String> {
|
||||
all.iter().filter(|s| s.starts_with(filter)).collect()
|
||||
}
|
||||
|
||||
/// Maps a key code to a [`tui_input`] edit request, or `None` if it doesn't edit text.
|
||||
fn key_to_request(code: KeyCode) -> Option<InputRequest> {
|
||||
match code {
|
||||
KeyCode::Char(c) => Some(InputRequest::InsertChar(c)),
|
||||
KeyCode::Backspace => Some(InputRequest::DeletePrevChar),
|
||||
KeyCode::Delete => Some(InputRequest::DeleteNextChar),
|
||||
KeyCode::Left => Some(InputRequest::GoToPrevChar),
|
||||
KeyCode::Right => Some(InputRequest::GoToNextChar),
|
||||
KeyCode::Home => Some(InputRequest::GoToStart),
|
||||
KeyCode::End => Some(InputRequest::GoToEnd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an editable text field into `area`: a distinct [`FIELD_BG`] background
|
||||
/// (so it reads as an input box), the current value, and a real terminal cursor at
|
||||
/// the input's visual cursor column.
|
||||
fn draw_field(frame: &mut Frame, area: Rect, input: &Input) {
|
||||
/// the field's display cursor column.
|
||||
fn draw_field(frame: &mut Frame, area: Rect, field: &TextField) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
// The paragraph's style fills the whole field area, including empty cells.
|
||||
frame.render_widget(
|
||||
Paragraph::new(input.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
|
||||
Paragraph::new(field.value()).style(Style::default().fg(Color::White).bg(FIELD_BG)),
|
||||
area,
|
||||
);
|
||||
let cur_x = area.x + (input.visual_cursor() as u16).min(area.width.saturating_sub(1));
|
||||
let cur_x = area.x + (field.display_cursor() as u16).min(area.width.saturating_sub(1));
|
||||
frame.set_cursor_position((cur_x, area.y));
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
|
||||
mod clipboard;
|
||||
pub mod code_editor;
|
||||
pub mod dialog;
|
||||
mod text;
|
||||
pub mod text_field;
|
||||
|
||||
/// Dims every cell in `area` so a modal overlay drawn on top stands out and the
|
||||
/// content beneath does not show through.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Small char/width/key helpers shared by the text widgets
|
||||
//! ([`code_editor`](crate::code_editor), [`text_field`](crate::text_field)).
|
||||
|
||||
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Columns a tab advances to (tab stop).
|
||||
pub(crate) const TAB_WIDTH: usize = 4;
|
||||
|
||||
/// Byte offset of character index `col` within `s` (clamped to `s.len()`).
|
||||
pub(crate) fn byte_of(s: &str, col: usize) -> usize {
|
||||
s.char_indices().nth(col).map_or(s.len(), |(b, _)| b)
|
||||
}
|
||||
|
||||
/// Display width of `c` at running display column `at` (tabs advance to the next
|
||||
/// [`TAB_WIDTH`] stop; wide characters are two cells; zero-width control chars are 0).
|
||||
pub(crate) fn char_cells(c: char, at: usize) -> usize {
|
||||
if c == '\t' {
|
||||
TAB_WIDTH - (at % TAB_WIDTH)
|
||||
} else {
|
||||
UnicodeWidthChar::width(c).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Display width (terminal cells) of the first `col` characters of `s`.
|
||||
pub(crate) fn display_width(s: &str, col: usize) -> usize {
|
||||
let mut w = 0;
|
||||
for c in s.chars().take(col) {
|
||||
w += char_cells(c, w);
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Character index after moving one word forward from `col` in `line`: skip the current
|
||||
/// run of non-whitespace, then the following whitespace (lands on the next word's start).
|
||||
pub(crate) fn next_word(line: &str, col: usize) -> usize {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = col;
|
||||
while i < n && !chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
while i < n && chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Character index after moving one word backward from `col` in `line`: skip whitespace,
|
||||
/// then the preceding word (lands on that word's start).
|
||||
pub(crate) fn prev_word(line: &str, col: usize) -> usize {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut i = col;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Rewrites a `⌘`-modified key to its `ctrl` equivalent (terminals report Super for Cmd
|
||||
/// under the kitty protocol; edtui/our bindings key off ctrl).
|
||||
pub(crate) fn super_to_ctrl(mut key: KeyEvent) -> KeyEvent {
|
||||
if key.modifiers.contains(KeyModifiers::SUPER) {
|
||||
key.modifiers.remove(KeyModifiers::SUPER);
|
||||
key.modifiers.insert(KeyModifiers::CONTROL);
|
||||
}
|
||||
key
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! A single-line editable text field — a one-line sibling of [`code_editor`](crate::code_editor),
|
||||
//! used by [`dialog`](crate::dialog) for its text and list-filter inputs.
|
||||
//!
|
||||
//! Presentation is left to the caller (it reads [`value`](TextField::value) and
|
||||
//! [`display_cursor`](TextField::display_cursor)); the field only owns the text, the cursor,
|
||||
//! and a [`Clipboard`] for paste. No newlines, no selection (yet) — deliberately minimal.
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::clipboard::Clipboard;
|
||||
use crate::text::{byte_of, display_width, next_word, prev_word, super_to_ctrl};
|
||||
|
||||
/// A single-line text input with a character-indexed cursor.
|
||||
pub struct TextField {
|
||||
/// The current text (never contains newlines).
|
||||
text: String,
|
||||
/// Cursor position as a character index in `0..=text.chars().count()`.
|
||||
cursor: usize,
|
||||
/// Clipboard for `ctrl`/`⌘`+`v` paste.
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
|
||||
impl TextField {
|
||||
/// Creates a field pre-filled with `initial`, cursor at the end.
|
||||
pub fn new(initial: impl Into<String>) -> Self {
|
||||
let text = initial.into();
|
||||
let cursor = text.chars().count();
|
||||
Self { text, cursor, clipboard: Clipboard::new() }
|
||||
}
|
||||
|
||||
/// The current text.
|
||||
pub fn value(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// The cursor's display column (terminal cells from the start), for placing the caret.
|
||||
pub fn display_cursor(&self) -> usize {
|
||||
display_width(&self.text, self.cursor)
|
||||
}
|
||||
|
||||
/// Number of characters in the field.
|
||||
fn len(&self) -> usize {
|
||||
self.text.chars().count()
|
||||
}
|
||||
|
||||
/// Handles a key event. Returns `true` if it was consumed (edited or moved the cursor).
|
||||
///
|
||||
/// Editing keys only: printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves),
|
||||
/// Home/End, and `ctrl`/`⌘`+`v` paste. Esc/Enter/Up/Down are left to the caller.
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
let key = super_to_ctrl(key);
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
match key.code {
|
||||
KeyCode::Char('v') if ctrl => self.paste(),
|
||||
KeyCode::Char(_) if ctrl => return false, // other chords aren't ours
|
||||
KeyCode::Char(c) if !alt => self.insert(c),
|
||||
KeyCode::Backspace => return self.backspace(),
|
||||
KeyCode::Delete => return self.delete_forward(),
|
||||
KeyCode::Left if ctrl => self.cursor = prev_word(&self.text, self.cursor),
|
||||
KeyCode::Right if ctrl => self.cursor = next_word(&self.text, self.cursor),
|
||||
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
|
||||
KeyCode::Right => self.cursor = (self.cursor + 1).min(self.len()),
|
||||
KeyCode::Home => self.cursor = 0,
|
||||
KeyCode::End => self.cursor = self.len(),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn insert(&mut self, c: char) {
|
||||
let b = byte_of(&self.text, self.cursor);
|
||||
self.text.insert(b, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
fn backspace(&mut self) -> bool {
|
||||
if self.cursor == 0 {
|
||||
return false;
|
||||
}
|
||||
let b = byte_of(&self.text, self.cursor - 1);
|
||||
self.text.remove(b);
|
||||
self.cursor -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self) -> bool {
|
||||
if self.cursor >= self.len() {
|
||||
return false;
|
||||
}
|
||||
let b = byte_of(&self.text, self.cursor);
|
||||
self.text.remove(b);
|
||||
true
|
||||
}
|
||||
|
||||
/// 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 b = byte_of(&self.text, self.cursor);
|
||||
self.text.insert_str(b, &clean);
|
||||
self.cursor += clean.chars().count();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_inserts_and_value_reflects_it() {
|
||||
let mut f = TextField::new("");
|
||||
for c in "abc".chars() {
|
||||
assert!(f.handle_key(key(KeyCode::Char(c))));
|
||||
}
|
||||
assert_eq!(f.value(), "abc");
|
||||
assert_eq!(f.display_cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_and_delete() {
|
||||
let mut f = TextField::new("abc"); // cursor at end (3)
|
||||
assert!(f.handle_key(key(KeyCode::Backspace)));
|
||||
assert_eq!(f.value(), "ab");
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
assert!(f.handle_key(key(KeyCode::Delete)));
|
||||
assert_eq!(f.value(), "b");
|
||||
// Nothing to delete at the boundaries.
|
||||
assert!(!TextField::new("").handle_key(key(KeyCode::Backspace)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_movement_inserts_at_right_place() {
|
||||
let mut f = TextField::new("ac");
|
||||
f.handle_key(key(KeyCode::Left)); // between a and c
|
||||
f.handle_key(key(KeyCode::Char('b')));
|
||||
assert_eq!(f.value(), "abc");
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
f.handle_key(key(KeyCode::Char('X')));
|
||||
assert_eq!(f.value(), "Xabc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_v_pastes_clipboard_with_newlines_stripped() {
|
||||
let mut f = TextField::new("");
|
||||
f.clipboard.set("multi\nline".to_string());
|
||||
assert!(f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
|
||||
assert_eq!(f.value(), "multiline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_v_maps_to_ctrl_v() {
|
||||
let mut f = TextField::new("");
|
||||
f.clipboard.set("hi".to_string());
|
||||
f.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER));
|
||||
assert_eq!(f.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_cursor_counts_wide_chars() {
|
||||
let mut f = TextField::new("世"); // one double-width char, cursor at end
|
||||
assert_eq!(f.display_cursor(), 2);
|
||||
f.handle_key(key(KeyCode::Home));
|
||||
assert_eq!(f.display_cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_keys_return_false() {
|
||||
let mut f = TextField::new("x");
|
||||
assert!(!f.handle_key(key(KeyCode::Enter)));
|
||||
assert!(!f.handle_key(key(KeyCode::Up)));
|
||||
assert!(!f.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user