Added page scrolling and cursor selection

This commit is contained in:
Joshua Barretto 2025-06-07 23:00:25 +01:00
parent d96fc47476
commit 81ab27cbbf
9 changed files with 251 additions and 104 deletions

View file

@ -13,7 +13,7 @@ pub enum Dir {
pub enum Action {
Char(char), // Insert a character
Backspace, // Backspace a character
Move(Dir), // Move the cursor
Move(Dir, bool, bool), // Move the cursor (dir, page, retain_base)
PaneMove(Dir), // Move panes
Cancel, // Cancels the current context
Go, // Search, accept, or select the current option
@ -92,22 +92,34 @@ impl RawEvent {
}
}
pub fn to_move(&self) -> Option<Dir> {
match &self.0 {
TerminalEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => match code {
KeyCode::Left => Some(Dir::Left),
KeyCode::Right => Some(Dir::Right),
KeyCode::Up => Some(Dir::Up),
KeyCode::Down => Some(Dir::Down),
_ => None,
},
_ => None,
}
pub fn to_move(&self) -> Option<Action> {
let TerminalEvent::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = &self.0
else {
return None;
};
let retain_base = match *modifiers {
KeyModifiers::NONE => false,
KeyModifiers::SHIFT => true,
_ => return None,
};
let (dir, page) = match code {
KeyCode::PageUp => (Dir::Up, true),
KeyCode::PageDown => (Dir::Down, true),
KeyCode::Left => (Dir::Left, false),
KeyCode::Right => (Dir::Right, false),
KeyCode::Up => (Dir::Up, false),
KeyCode::Down => (Dir::Down, false),
_ => return None,
};
Some(Action::Move(dir, page, retain_base))
}
pub fn to_open_prompt(&self) -> Option<Action> {

View file

@ -4,7 +4,7 @@ use crate::{
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use slotmap::{HopSlotMap, new_key_type};
use std::{io, path::PathBuf};
use std::{io, ops::Range, path::PathBuf};
new_key_type! {
pub struct BufferId;
@ -13,6 +13,7 @@ new_key_type! {
#[derive(Copy, Clone, Default)]
pub struct Cursor {
pub base: usize,
pub pos: usize,
// Used to 'remember' the desired column when skipping over shorter lines
desired_col: isize,
@ -22,6 +23,14 @@ impl Cursor {
fn reset_desired_col(&mut self, text: &Text) {
self.desired_col = text.to_coord(self.pos)[0];
}
pub fn selection(&self) -> Option<Range<usize>> {
if self.base == self.pos {
None
} else {
Some(self.base.min(self.pos)..self.base.max(self.pos))
}
}
}
pub struct Text {
@ -31,16 +40,17 @@ pub struct Text {
impl Text {
pub fn to_coord(&self, pos: usize) -> [isize; 2] {
let mut n = 0;
let mut i = 0;
let mut last_n = 0;
let mut i: usize = 0;
for line in self.lines() {
if (n..n + line.len() + 1).contains(&pos) {
return [(pos - n) as isize, i as isize];
} else {
n += line.len() + 1;
i += 1;
last_n = n;
i += 1;
if (n..n + line.len()).contains(&pos) {
break;
}
n += line.len();
}
[0, i as isize]
[(pos - last_n) as isize, i.saturating_sub(1) as isize]
}
pub fn to_pos(&self, mut coord: [isize; 2]) -> usize {
@ -50,16 +60,41 @@ impl Text {
let mut pos = 0;
for (i, line) in self.lines().enumerate() {
if i as isize == coord[1] {
return pos + coord[0].clamp(0, line.len() as isize) as usize;
return pos + coord[0].clamp(0, line.len().saturating_sub(1) as isize) as usize;
} else {
pos += line.len() + 1;
pos += line.len();
}
}
pos.min(self.chars.len())
}
/// Return an iterator over the lines of the text.
///
/// Guarantees:
/// - If you sum the lengths of each line, it will be the same as the length (in characters) of the text
pub fn lines(&self) -> impl Iterator<Item = &[char]> {
self.chars.split(|c| *c == '\n')
let mut start = 0;
let mut i = 0;
let mut finished = false;
core::iter::from_fn(move || {
loop {
let Some(c) = self.chars.get(i) else {
return if finished {
None
} else {
let line = &self.chars[start..];
finished = true;
Some(line)
};
};
i += 1;
if *c == '\n' {
let line = &self.chars[start..i];
start = i;
return Some(line);
}
}
})
}
}
@ -84,17 +119,23 @@ impl Buffer {
})
}
pub fn move_cursor(&mut self, cursor_id: CursorId, dir: Dir) {
pub fn move_cursor(
&mut self,
cursor_id: CursorId,
dir: Dir,
dist: [usize; 2],
retain_base: bool,
) {
let Some(cursor) = self.cursors.get_mut(cursor_id) else {
return;
};
match dir {
Dir::Left => {
cursor.pos = cursor.pos.saturating_sub(1);
cursor.pos = cursor.pos.saturating_sub(dist[0]);
cursor.reset_desired_col(&self.text);
}
Dir::Right => {
cursor.pos = (cursor.pos + 1).min(self.text.chars.len());
cursor.pos = (cursor.pos + dist[0]).min(self.text.chars.len());
cursor.reset_desired_col(&self.text);
}
Dir::Up => {
@ -104,19 +145,30 @@ impl Buffer {
cursor.pos = 0;
cursor.reset_desired_col(&self.text);
} else {
cursor.pos = self.text.to_pos([cursor.desired_col, coord[1] - 1]);
cursor.pos = self
.text
.to_pos([cursor.desired_col, coord[1] - dist[1] as isize]);
}
}
Dir::Down => {
let mut coord = self.text.to_coord(cursor.pos);
cursor.pos = self.text.to_pos([cursor.desired_col, coord[1] + 1]);
cursor.pos = self
.text
.to_pos([cursor.desired_col, coord[1] + dist[1] as isize]);
}
};
if !retain_base {
cursor.base = cursor.pos;
}
}
pub fn insert(&mut self, pos: usize, c: char) {
self.text.chars.insert(pos.min(self.text.chars.len()), c);
self.cursors.values_mut().for_each(|cursor| {
if cursor.base >= pos {
cursor.base += 1;
}
if cursor.pos >= pos {
cursor.pos += 1;
cursor.reset_desired_col(&self.text);
@ -124,35 +176,69 @@ impl Buffer {
});
}
pub fn remove(&mut self, pos: usize) {
pub fn enter(&mut self, cursor_id: CursorId, c: char) {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
};
if let Some(selection) = cursor.selection() {
self.remove(selection);
self.enter(cursor_id, c);
} else {
self.insert(cursor.pos, c);
}
}
// Assumes range is well-formed
pub fn remove(&mut self, range: Range<usize>) {
// TODO: Bell if false?
if self.text.chars.len() > pos {
self.text.chars.remove(pos);
self.cursors.values_mut().for_each(|cursor| {
if cursor.pos >= pos {
cursor.pos = cursor.pos.saturating_sub(1);
cursor.reset_desired_col(&self.text);
}
});
self.text.chars.drain(range.clone());
self.cursors.values_mut().for_each(|cursor| {
if cursor.base >= range.start {
cursor.base = cursor
.base
.saturating_sub(range.end - range.start)
.max(range.start);
}
if cursor.pos >= range.start {
cursor.pos = cursor
.pos
.saturating_sub(range.end - range.start)
.max(range.start);
cursor.reset_desired_col(&self.text);
}
});
}
pub fn backspace(&mut self, cursor_id: CursorId) {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
};
if let Some(selection) = cursor.selection() {
self.remove(selection);
} else {
if let Some(pos) = cursor.pos.checked_sub(1) {
self.remove(pos..pos + 1);
}
}
}
pub fn backspace(&mut self, pos: usize) {
if let Some(pos) = pos.checked_sub(1) {
self.remove(pos);
pub fn delete(&mut self, cursor_id: CursorId) {
let Some(cursor) = self.cursors.get(cursor_id) else {
return;
};
if let Some(selection) = cursor.selection() {
self.remove(selection);
} else {
self.remove(cursor.pos..cursor.pos + 1);
}
}
pub fn delete(&mut self, pos: usize) {
self.remove(pos);
}
pub fn start_session(&mut self) -> CursorId {
self.cursors.insert(Cursor::default())
}
pub fn end_session(&mut self, cursor: CursorId) {
self.cursors.remove(cursor);
pub fn end_session(&mut self, cursor_id: CursorId) {
self.cursors.remove(cursor_id);
}
}

View file

@ -187,19 +187,20 @@ impl<'a> Rect<'a> {
pub fn text<C: Borrow<char>>(
&mut self,
origin: [usize; 2],
origin: [isize; 2],
text: impl IntoIterator<Item = C>,
) -> Rect {
for (idx, c) in text.into_iter().enumerate() {
if origin[0] + idx >= self.size()[0] {
break;
} else {
if (0..self.size()[0] as isize).contains(&(origin[0] + idx as isize)) && origin[1] >= 0
{
let cell = Cell {
c: *c.borrow(),
fg: self.fg,
bg: self.bg,
};
if let Some(c) = self.get_mut([origin[0] + idx, origin[1]]) {
if let Some(c) =
self.get_mut([(origin[0] + idx as isize) as usize, origin[1] as usize])
{
*c = cell;
}
}
@ -349,7 +350,12 @@ impl<'a> Terminal<'a> {
stdout.queue(style::SetBackgroundColor(bg)).unwrap();
}
stdout.queue(style::Print(self.fb[0].cells[pos].c)).unwrap();
// Convert non-printable chars
let c = match self.fb[0].cells[pos].c {
c if c.is_whitespace() => ' ',
c => c,
};
stdout.queue(style::Print(c)).unwrap();
// Move cursor
cursor_pos[0] += 1;

View file

@ -39,6 +39,8 @@ pub struct Theme {
pub margin_line_num: Color,
pub border: BorderTheme,
pub focus_border: BorderTheme,
pub text: Color,
pub whitespace: Color,
}
impl Default for Theme {
@ -53,6 +55,8 @@ impl Default for Theme {
fg: Color::White,
..BorderTheme::default()
},
text: Color::Reset,
whitespace: Color::AnsiValue(245),
}
}
}

View file

@ -16,11 +16,7 @@ impl Input {
impl Element for Input {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
match event.to_action(|e| {
e.to_char()
.map(Action::Char)
.or_else(|| e.to_move().map(Action::Move))
}) {
match event.to_action(|e| e.to_char().map(Action::Char).or_else(|| e.to_move())) {
Some(Action::Char('\x08')) => {
self.cursor = self.cursor.saturating_sub(1);
if self.text.len() > self.cursor {
@ -33,11 +29,11 @@ impl Element for Input {
self.cursor += 1;
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Left)) => {
Some(Action::Move(Dir::Left, _, _)) => {
self.cursor = self.cursor.saturating_sub(1);
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Right)) => {
Some(Action::Move(Dir::Right, _, _)) => {
self.cursor = (self.cursor + 1).min(self.text.len());
Ok(Resp::handled(None))
}
@ -47,7 +43,7 @@ impl Element for Input {
}
impl Visual for Input {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with(|frame| {
frame.fill(' ');
frame.text([0, 0], self.preamble.chars());

View file

@ -63,7 +63,7 @@ pub trait Element<CanEnd = CannotEnd> {
}
pub trait Visual {
fn render(&self, state: &State, frame: &mut Rect);
fn render(&mut self, state: &State, frame: &mut Rect);
}
pub struct Label(String);
@ -76,10 +76,10 @@ impl std::ops::Deref for Label {
}
impl Visual for Label {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with_bg(state.theme.ui_bg).fill(' ').with(|frame| {
for (idx, line) in self.lines().enumerate() {
frame.text([0, idx], line.chars());
frame.text([0, idx as isize], line.chars());
}
});
}

View file

@ -12,6 +12,8 @@ pub struct Doc {
cursors: HashMap<BufferId, CursorId>,
// x/y location in the buffer that the pane is trying to focus on
focus: [isize; 2],
// Remember the last known size for things like scrolling
last_size: [usize; 2],
}
impl Doc {
@ -23,6 +25,7 @@ impl Doc {
.into_iter()
.collect(),
focus: [0, 0],
last_size: [1, 1],
}
}
@ -34,13 +37,20 @@ impl Doc {
return;
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
self.focus[0] = self.focus[0].clamp(cursor_coord[0] - 20, cursor_coord[0] - 4);
self.focus[1] = self.focus[1].clamp(cursor_coord[1] - 20, cursor_coord[1] - 4);
for i in 0..2 {
self.focus[i] = self.focus[i].clamp(
cursor_coord[i] - self.last_size[i] as isize + 1,
cursor_coord[i],
);
}
}
pub fn close(self, state: &mut State) {
for (buffer, cursor) in self.cursors {
state.buffers[buffer].end_session(cursor);
let Some(buffer) = state.buffers.get_mut(buffer) else {
continue;
};
buffer.end_session(cursor);
}
}
}
@ -54,7 +64,7 @@ impl Element for Doc {
match event.to_action(|e| {
e.to_char()
.map(Action::Char)
.or_else(|| e.to_move().map(Action::Move))
.or_else(|| e.to_move())
.or_else(|| e.to_pane_move().map(Action::PaneMove))
.or_else(|| e.to_open_switcher())
}) {
@ -72,21 +82,24 @@ impl Element for Doc {
Ok(Resp::handled(None))
}
Some(Action::Char(c)) => {
let Some(cursor) = buffer.cursors.get(self.cursors[&self.buffer]) else {
return Err(event);
};
let cursor_id = self.cursors[&self.buffer];
if c == '\x08' {
buffer.backspace(cursor.pos);
buffer.backspace(cursor_id);
} else if c == '\x7F' {
buffer.delete(cursor.pos);
buffer.delete(cursor_id);
} else {
buffer.insert(cursor.pos, c);
buffer.enter(cursor_id, c);
}
self.refocus(state);
Ok(Resp::handled(None))
}
Some(Action::Move(dir)) => {
buffer.move_cursor(self.cursors[&self.buffer], dir);
Some(Action::Move(dir, page, retain_base)) => {
let dist = if page {
self.last_size.map(|s| s.saturating_sub(3).max(1))
} else {
[1, 1]
};
buffer.move_cursor(self.cursors[&self.buffer], dir, dist, retain_base);
self.refocus(state);
Ok(Resp::handled(None))
}
@ -96,7 +109,7 @@ impl Element for Doc {
}
impl Visual for Doc {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
let Some(buffer) = state.buffers.get(self.buffer) else {
return;
};
@ -105,11 +118,20 @@ impl Visual for Doc {
};
let cursor_coord = buffer.text.to_coord(cursor.pos);
let line_num_w = buffer.text.lines().count().ilog10() as usize + 1;
let line_num_w = buffer.text.lines().count().max(1).ilog10() as usize + 1;
let margin_w = line_num_w + 2;
for (i, (line_num, line)) in buffer
self.last_size = [frame.size()[0] - margin_w, frame.size()[1]];
let mut pos = 0;
for (i, (line_num, (line_pos, line))) in buffer
.text
.lines()
.map(move |line| {
let line_pos = pos;
pos += line.len();
(line_pos, line)
})
.enumerate()
.skip(self.focus[1].max(0) as usize)
.enumerate()
@ -117,7 +139,7 @@ impl Visual for Doc {
{
// Margin
frame
.rect([0, i], [line_num_w + 2, 1])
.rect([0, i], [margin_w, 1])
.with_bg(state.theme.margin_bg)
.with_fg(state.theme.margin_line_num)
.fill(' ')
@ -125,14 +147,37 @@ impl Visual for Doc {
// Line
{
let mut frame = frame.rect([line_num_w + 2, i], [!0, 1]);
frame.text([0, 0], line);
let mut frame = frame.rect([margin_w, i], [!0, 1]);
for i in 0..frame.size()[0] {
let coord = self.focus[0] + i as isize;
if (0..line.len() as isize).contains(&coord) {
let pos = line_pos + coord as usize;
let selected = cursor.selection().map_or(false, |s| s.contains(&pos));
let (fg, c) = match line[coord as usize] {
'\n' if selected => (state.theme.whitespace, '⮠'),
c => (state.theme.text, c),
};
frame
.with_bg(if selected {
state.theme.select_bg
} else {
Color::Reset
})
.with_fg(fg)
.text([i as isize, 0], &[c]);
}
}
// Set cursor position
if cursor_coord[1] == line_num as isize {
frame.set_cursor([cursor_coord[0], 0], CursorStyle::BlinkingBar);
frame.set_cursor(
[cursor_coord[0] - self.focus[0], 0],
CursorStyle::BlinkingBar,
);
}
}
pos += line.len();
}
}
}
@ -200,10 +245,12 @@ impl Element for Panes {
}
impl Visual for Panes {
fn render(&self, state: &State, frame: &mut Rect) {
for (i, pane) in self.panes.iter().enumerate() {
let boundary = |i| frame.size()[0] * i / self.panes.len();
fn render(&mut self, state: &State, frame: &mut Rect) {
let n = self.panes.len();
let frame_w = frame.size()[0];
let boundary = |i| frame_w * i / n;
for (i, pane) in self.panes.iter_mut().enumerate() {
let (x0, x1) = (boundary(i), boundary(i + 1));
let is_selected = self.selected == i;

View file

@ -51,7 +51,7 @@ impl Element<CanEnd> for Prompt {
}
impl Visual for Prompt {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.with(|f| self.input.render(state, f));
}
}
@ -70,7 +70,7 @@ impl Element<CanEnd> for Show {
}
impl Visual for Show {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
let lines = self.label.lines().count();
self.label.render(
state,
@ -99,7 +99,7 @@ impl Element<CanEnd> for Confirm {
}
impl Visual for Confirm {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
let lines = self.label.lines().count();
self.label.render(
state,
@ -118,16 +118,12 @@ pub struct Switcher {
impl Element<CanEnd> for Switcher {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<CanEnd>, Event> {
match event.to_action(|e| {
e.to_cancel()
.or_else(|| e.to_go())
.or_else(|| e.to_move().map(Action::Move))
}) {
Some(Action::Move(Dir::Up)) => {
match event.to_action(|e| e.to_cancel().or_else(|| e.to_go()).or_else(|| e.to_move())) {
Some(Action::Move(Dir::Up, false, _)) => {
self.selected = (self.selected + self.options.len() - 1) % self.options.len();
Ok(Resp::handled(None))
}
Some(Action::Move(Dir::Down)) => {
Some(Action::Move(Dir::Down, false, _)) => {
self.selected = (self.selected + 1) % self.options.len();
Ok(Resp::handled(None))
}
@ -146,7 +142,7 @@ impl Element<CanEnd> for Switcher {
}
impl Visual for Switcher {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
for (i, buffer) in self.options.iter().enumerate() {
let Some(buffer) = state.buffers.get(*buffer) else {
continue;

View file

@ -100,7 +100,7 @@ impl Element<CanEnd> for Root {
}
impl Visual for Root {
fn render(&self, state: &State, frame: &mut Rect) {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame.fill(' ');
let task_has_focus = matches!(self.tasks.last(), Some(Task::Prompt(_)));
@ -117,7 +117,7 @@ impl Visual for Root {
Some("Prompt (press alt + enter)"),
)
.with(|frame| {
if let Some(Task::Prompt(p)) = self.tasks.last() {
if let Some(Task::Prompt(p)) = self.tasks.last_mut() {
p.render(state, frame);
}
});
@ -129,7 +129,7 @@ impl Visual for Root {
self.panes.render(state, frame);
});
if let Some(task) = self.tasks.last() {
if let Some(task) = self.tasks.last_mut() {
match task {
Task::Prompt(_) => {} // Prompt isn't rendered, it's always rendered above
Task::Show(s) => s.render(state, frame),