use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Pos {
pub line: u32,
pub col: u32,
}
impl Pos {
pub const ORIGIN: Pos = Pos { line: 0, col: 0 };
pub const fn new(line: u32, col: u32) -> Self {
Pos { line, col }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SelectionKind {
#[default]
Char,
Line,
Block,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Selection {
pub anchor: Pos,
pub head: Pos,
pub kind: SelectionKind,
}
impl Selection {
pub const fn caret(pos: Pos) -> Self {
Selection {
anchor: pos,
head: pos,
kind: SelectionKind::Char,
}
}
pub const fn char_range(anchor: Pos, head: Pos) -> Self {
Selection {
anchor,
head,
kind: SelectionKind::Char,
}
}
pub fn is_empty(&self) -> bool {
self.anchor == self.head
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectionSet {
pub items: Vec<Selection>,
pub primary: usize,
}
impl SelectionSet {
pub fn caret(pos: Pos) -> Self {
SelectionSet {
items: vec![Selection::caret(pos)],
primary: 0,
}
}
pub fn primary(&self) -> &Selection {
self.items
.get(self.primary)
.or_else(|| self.items.first())
.expect("SelectionSet must contain at least one selection")
}
}
impl Default for SelectionSet {
fn default() -> Self {
SelectionSet::caret(Pos::ORIGIN)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub range: Range<Pos>,
pub replacement: String,
}
impl Edit {
pub fn insert(at: Pos, text: impl Into<String>) -> Self {
Edit {
range: at..at,
replacement: text.into(),
}
}
pub fn delete(range: Range<Pos>) -> Self {
Edit {
range,
replacement: String::new(),
}
}
pub fn replace(range: Range<Pos>, text: impl Into<String>) -> Self {
Edit {
range,
replacement: text.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Normal,
Insert,
Visual,
Replace,
Command,
OperatorPending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CursorShape {
#[default]
Block,
Bar,
Underline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Style {
pub fg: Option<Color>,
pub bg: Option<Color>,
pub attrs: Attrs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Color(pub u8, pub u8, pub u8);
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct Attrs: u8 {
const BOLD = 1 << 0;
const ITALIC = 1 << 1;
const UNDERLINE = 1 << 2;
const REVERSE = 1 << 3;
const DIM = 1 << 4;
const STRIKE = 1 << 5;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HighlightKind {
Selection,
SearchMatch,
IncSearch,
MatchParen,
Syntax(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Highlight {
pub range: Range<Pos>,
pub kind: HighlightKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Options {
pub tabstop: u32,
pub shiftwidth: u32,
pub expandtab: bool,
pub iskeyword: String,
pub ignorecase: bool,
pub smartcase: bool,
pub hlsearch: bool,
pub incsearch: bool,
pub wrapscan: bool,
pub autoindent: bool,
pub timeout_len: core::time::Duration,
pub undo_levels: u32,
pub undo_break_on_motion: bool,
pub readonly: bool,
pub wrap: WrapMode,
pub textwidth: u32,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum WrapMode {
#[default]
None,
Char,
Word,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OptionValue {
Bool(bool),
Int(i64),
String(String),
}
impl Default for Options {
fn default() -> Self {
Options {
tabstop: 8,
shiftwidth: 8,
expandtab: false,
iskeyword: "@,48-57,_,192-255".to_string(),
ignorecase: false,
smartcase: false,
hlsearch: true,
incsearch: true,
wrapscan: true,
autoindent: true,
timeout_len: core::time::Duration::from_millis(1000),
undo_levels: 1000,
undo_break_on_motion: true,
readonly: false,
wrap: WrapMode::None,
textwidth: 79,
}
}
}
impl Options {
pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
macro_rules! set_bool {
($field:ident) => {{
self.$field = match val {
OptionValue::Bool(b) => b,
OptionValue::Int(n) => n != 0,
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects bool, got {other:?}"
)));
}
};
Ok(())
}};
}
macro_rules! set_u32 {
($field:ident) => {{
self.$field = match val {
OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
OptionValue::Int(n) => {
return Err(EngineError::Ex(format!(
"option `{name}` out of u32 range: {n}"
)));
}
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects int, got {other:?}"
)));
}
};
Ok(())
}};
}
macro_rules! set_string {
($field:ident) => {{
self.$field = match val {
OptionValue::String(s) => s,
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects string, got {other:?}"
)));
}
};
Ok(())
}};
}
match name {
"tabstop" | "ts" => set_u32!(tabstop),
"shiftwidth" | "sw" => set_u32!(shiftwidth),
"textwidth" | "tw" => set_u32!(textwidth),
"expandtab" | "et" => set_bool!(expandtab),
"iskeyword" | "isk" => set_string!(iskeyword),
"ignorecase" | "ic" => set_bool!(ignorecase),
"smartcase" | "scs" => set_bool!(smartcase),
"hlsearch" | "hls" => set_bool!(hlsearch),
"incsearch" | "is" => set_bool!(incsearch),
"wrapscan" | "ws" => set_bool!(wrapscan),
"autoindent" | "ai" => set_bool!(autoindent),
"timeoutlen" | "tm" => {
self.timeout_len = match val {
OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects non-negative int (millis), got {other:?}"
)));
}
};
Ok(())
}
"undolevels" | "ul" => set_u32!(undo_levels),
"undobreak" => set_bool!(undo_break_on_motion),
"readonly" | "ro" => set_bool!(readonly),
"wrap" => {
let on = match val {
OptionValue::Bool(b) => b,
OptionValue::Int(n) => n != 0,
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects bool, got {other:?}"
)));
}
};
self.wrap = match (on, self.wrap) {
(false, _) => WrapMode::None,
(true, WrapMode::Word) => WrapMode::Word,
(true, _) => WrapMode::Char,
};
Ok(())
}
"linebreak" | "lbr" => {
let on = match val {
OptionValue::Bool(b) => b,
OptionValue::Int(n) => n != 0,
other => {
return Err(EngineError::Ex(format!(
"option `{name}` expects bool, got {other:?}"
)));
}
};
self.wrap = match (on, self.wrap) {
(true, _) => WrapMode::Word,
(false, WrapMode::Word) => WrapMode::Char,
(false, other) => other,
};
Ok(())
}
other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
}
}
pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
Some(match name {
"tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
"shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
"textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
"expandtab" | "et" => OptionValue::Bool(self.expandtab),
"iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
"ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
"smartcase" | "scs" => OptionValue::Bool(self.smartcase),
"hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
"incsearch" | "is" => OptionValue::Bool(self.incsearch),
"wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
"autoindent" | "ai" => OptionValue::Bool(self.autoindent),
"timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
"undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
"undobreak" => OptionValue::Bool(self.undo_break_on_motion),
"readonly" | "ro" => OptionValue::Bool(self.readonly),
"wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
"linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
_ => return None,
})
}
}
pub use hjkl_buffer::Viewport;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BufferId(pub u64);
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Modifiers {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
pub super_: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SpecialKey {
Esc,
Enter,
Backspace,
Tab,
BackTab,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Insert,
Delete,
F(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseKind {
Press,
Release,
Drag,
ScrollUp,
ScrollDown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MouseEvent {
pub kind: MouseKind,
pub pos: Pos,
pub mods: Modifiers,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Input {
Char(char, Modifiers),
Key(SpecialKey, Modifiers),
Mouse(MouseEvent),
Paste(String),
FocusGained,
FocusLost,
Resize(u16, u16),
}
pub trait Host: Send {
type Intent;
fn write_clipboard(&mut self, text: String);
fn read_clipboard(&mut self) -> Option<String>;
fn now(&self) -> core::time::Duration;
fn should_cancel(&self) -> bool {
false
}
fn prompt_search(&mut self) -> Option<String>;
fn display_line_for(&self, pos: Pos) -> u32 {
pos.line
}
fn pos_for_display(&self, line: u32, col: u32) -> Pos {
Pos { line, col }
}
fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
let _ = range;
Vec::new()
}
fn emit_cursor_shape(&mut self, shape: CursorShape);
fn viewport(&self) -> &Viewport;
fn viewport_mut(&mut self) -> &mut Viewport;
fn emit_intent(&mut self, intent: Self::Intent);
}
#[derive(Debug)]
pub struct DefaultHost {
clipboard: Option<String>,
last_cursor_shape: CursorShape,
started: std::time::Instant,
viewport: Viewport,
}
impl Default for DefaultHost {
fn default() -> Self {
Self::new()
}
}
impl DefaultHost {
pub const DEFAULT_VIEWPORT: Viewport = Viewport {
top_row: 0,
top_col: 0,
width: 80,
height: 24,
wrap: hjkl_buffer::Wrap::None,
text_width: 80,
};
pub fn new() -> Self {
Self {
clipboard: None,
last_cursor_shape: CursorShape::Block,
started: std::time::Instant::now(),
viewport: Self::DEFAULT_VIEWPORT,
}
}
pub fn with_viewport(viewport: Viewport) -> Self {
Self {
clipboard: None,
last_cursor_shape: CursorShape::Block,
started: std::time::Instant::now(),
viewport,
}
}
pub fn last_cursor_shape(&self) -> CursorShape {
self.last_cursor_shape
}
}
impl Host for DefaultHost {
type Intent = ();
fn write_clipboard(&mut self, text: String) {
self.clipboard = Some(text);
}
fn read_clipboard(&mut self) -> Option<String> {
self.clipboard.clone()
}
fn now(&self) -> core::time::Duration {
self.started.elapsed()
}
fn prompt_search(&mut self) -> Option<String> {
None
}
fn emit_cursor_shape(&mut self, shape: CursorShape) {
self.last_cursor_shape = shape;
}
fn viewport(&self) -> &Viewport {
&self.viewport
}
fn viewport_mut(&mut self) -> &mut Viewport {
&mut self.viewport
}
fn emit_intent(&mut self, _intent: Self::Intent) {}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RenderFrame {
pub mode: SnapshotMode,
pub cursor_row: u32,
pub cursor_col: u32,
pub cursor_shape: CursorShape,
pub viewport_top: u32,
pub line_count: u32,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EditorSnapshot {
pub version: u32,
pub mode: SnapshotMode,
pub cursor: (u32, u32),
pub lines: Vec<String>,
pub viewport_top: u32,
pub registers: crate::Registers,
pub marks: std::collections::BTreeMap<char, (u32, u32)>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SnapshotMode {
#[default]
Normal,
Insert,
Visual,
VisualLine,
VisualBlock,
}
impl EditorSnapshot {
pub const VERSION: u32 = 4;
}
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("regex compile error: {0}")]
Regex(#[from] regex::Error),
#[error("invalid range: {0}")]
InvalidRange(String),
#[error("ex parse: {0}")]
Ex(String),
#[error("buffer is read-only")]
ReadOnly,
#[error("position out of bounds: {0:?}")]
OutOfBounds(Pos),
#[error("snapshot version mismatch: file={0}, expected={1}")]
SnapshotVersion(u32, u32),
}
pub(crate) mod sealed {
pub trait Sealed {}
}
pub trait Cursor: Send {
fn cursor(&self) -> Pos;
fn set_cursor(&mut self, pos: Pos);
fn byte_offset(&self, pos: Pos) -> usize;
fn pos_at_byte(&self, byte: usize) -> Pos;
}
pub trait Query: Send {
fn line_count(&self) -> u32;
fn line(&self, idx: u32) -> &str;
fn len_bytes(&self) -> usize;
fn slice(&self, range: core::ops::Range<Pos>) -> std::borrow::Cow<'_, str>;
fn dirty_gen(&self) -> u64 {
0
}
}
pub trait BufferEdit: Send {
fn insert_at(&mut self, pos: Pos, text: &str);
fn delete_range(&mut self, range: core::ops::Range<Pos>);
fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str);
fn replace_all(&mut self, text: &str) {
self.replace_range(
Pos::ORIGIN..Pos {
line: u32::MAX,
col: u32::MAX,
},
text,
);
}
}
pub trait Search: Send {
fn find_next(&self, from: Pos, pat: ®ex::Regex) -> Option<core::ops::Range<Pos>>;
fn find_prev(&self, from: Pos, pat: ®ex::Regex) -> Option<core::ops::Range<Pos>>;
}
pub trait Buffer: Cursor + Query + BufferEdit + Search + sealed::Sealed + Send {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FoldOp {
Add {
start_row: usize,
end_row: usize,
closed: bool,
},
RemoveAt(usize),
OpenAt(usize),
CloseAt(usize),
ToggleAt(usize),
OpenAll,
CloseAll,
ClearAll,
Invalidate { start_row: usize, end_row: usize },
}
pub trait FoldProvider: Send {
fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize>;
fn prev_visible_row(&self, row: usize) -> Option<usize>;
fn is_row_hidden(&self, row: usize) -> bool;
fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)>;
fn apply(&mut self, op: FoldOp) {
let _ = op;
}
fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
self.apply(FoldOp::Invalidate { start_row, end_row });
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopFoldProvider;
impl FoldProvider for NoopFoldProvider {
fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize> {
let last = row_count.saturating_sub(1);
if last == 0 && row == 0 {
return None;
}
let r = row.checked_add(1)?;
(r <= last).then_some(r)
}
fn prev_visible_row(&self, row: usize) -> Option<usize> {
row.checked_sub(1)
}
fn is_row_hidden(&self, _row: usize) -> bool {
false
}
fn fold_at_row(&self, _row: usize) -> Option<(usize, usize, bool)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn caret_is_empty() {
let sel = Selection::caret(Pos::new(2, 4));
assert!(sel.is_empty());
assert_eq!(sel.anchor, sel.head);
}
#[test]
fn selection_set_default_has_one_caret() {
let set = SelectionSet::default();
assert_eq!(set.items.len(), 1);
assert_eq!(set.primary, 0);
assert_eq!(set.primary().anchor, Pos::ORIGIN);
}
#[test]
fn edit_constructors() {
let p = Pos::new(0, 5);
assert_eq!(Edit::insert(p, "x").range, p..p);
assert!(Edit::insert(p, "x").replacement == "x");
assert!(Edit::delete(p..p).replacement.is_empty());
}
#[test]
fn attrs_flags() {
let a = Attrs::BOLD | Attrs::UNDERLINE;
assert!(a.contains(Attrs::BOLD));
assert!(!a.contains(Attrs::ITALIC));
}
#[test]
fn options_set_get_roundtrip() {
let mut o = Options::default();
o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
.unwrap();
match o.get_by_name("iskeyword") {
Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
other => panic!("expected String, got {other:?}"),
}
}
#[test]
fn options_unknown_name_errors_on_set() {
let mut o = Options::default();
assert!(matches!(
o.set_by_name("frobnicate", OptionValue::Int(1)),
Err(EngineError::Ex(_))
));
assert!(o.get_by_name("frobnicate").is_none());
}
#[test]
fn options_type_mismatch_errors() {
let mut o = Options::default();
assert!(matches!(
o.set_by_name("tabstop", OptionValue::String("nope".into())),
Err(EngineError::Ex(_))
));
assert!(matches!(
o.set_by_name("iskeyword", OptionValue::Int(7)),
Err(EngineError::Ex(_))
));
}
#[test]
fn options_int_to_bool_coercion() {
let mut o = Options::default();
o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
assert!(matches!(
o.get_by_name("ic"),
Some(OptionValue::Bool(false))
));
}
#[test]
fn options_wrap_linebreak_roundtrip() {
let mut o = Options::default();
assert_eq!(o.wrap, WrapMode::None);
o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
assert_eq!(o.wrap, WrapMode::Char);
o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
assert_eq!(o.wrap, WrapMode::Word);
assert!(matches!(
o.get_by_name("wrap"),
Some(OptionValue::Bool(true))
));
assert!(matches!(
o.get_by_name("lbr"),
Some(OptionValue::Bool(true))
));
o.set_by_name("linebreak", OptionValue::Bool(false))
.unwrap();
assert_eq!(o.wrap, WrapMode::Char);
o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
assert_eq!(o.wrap, WrapMode::None);
}
#[test]
fn options_default_matches_vim() {
let o = Options::default();
assert_eq!(o.tabstop, 8);
assert!(!o.expandtab);
assert!(o.hlsearch);
assert!(o.wrapscan);
assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
}
#[test]
fn editor_snapshot_version_const() {
assert_eq!(EditorSnapshot::VERSION, 4);
}
#[test]
fn editor_snapshot_default_shape() {
let s = EditorSnapshot {
version: EditorSnapshot::VERSION,
mode: SnapshotMode::Normal,
cursor: (0, 0),
lines: vec!["hello".to_string()],
viewport_top: 0,
registers: crate::Registers::default(),
marks: Default::default(),
};
assert_eq!(s.cursor, (0, 0));
assert_eq!(s.lines.len(), 1);
}
#[cfg(feature = "serde")]
#[test]
fn editor_snapshot_roundtrip() {
let mut marks = std::collections::BTreeMap::new();
marks.insert('A', (5u32, 2u32));
marks.insert('a', (1u32, 0u32));
let s = EditorSnapshot {
version: EditorSnapshot::VERSION,
mode: SnapshotMode::Insert,
cursor: (3, 7),
lines: vec!["alpha".into(), "beta".into()],
viewport_top: 2,
registers: crate::Registers::default(),
marks,
};
let json = serde_json::to_string(&s).unwrap();
let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(s.cursor, back.cursor);
assert_eq!(s.lines, back.lines);
assert_eq!(s.viewport_top, back.viewport_top);
}
#[test]
fn engine_error_display() {
let e = EngineError::ReadOnly;
assert_eq!(e.to_string(), "buffer is read-only");
let e = EngineError::OutOfBounds(Pos::new(3, 7));
assert!(e.to_string().contains("out of bounds"));
}
}