use crate::tui::thread::Thread;
use crate::workspace::Repository;
pub const WEIGHT: u16 = 3;
pub const MAX_WEIGHT: u16 = 9;
pub struct Pane {
pub thread: Thread,
pub repository: Option<Repository>,
pub prompt: String,
pub cursor: Option<usize>,
pub history_at: Option<usize>,
pub follow: bool,
pub weight: u16,
pub scroll: u16,
}
impl Default for Pane {
fn default() -> Self {
Self {
thread: Thread::default(),
repository: None,
prompt: String::new(),
cursor: None,
history_at: None,
follow: true,
weight: WEIGHT,
scroll: 0,
}
}
}
impl Pane {
pub fn new(repository: Option<Repository>) -> Self {
Self {
repository,
..Self::default()
}
}
pub fn title(&self) -> String {
if let Some(repo) = &self.repository {
return repo.name.clone();
}
match self.thread.turns.first() {
Some(turn) => turn.prompt.clone(),
None => "new".to_string(),
}
}
pub fn running(&self) -> bool {
self.thread.running()
}
pub fn at(&self) -> usize {
let Some(at) = self.cursor else {
return self.prompt.len();
};
let mut at = at.min(self.prompt.len());
while !self.prompt.is_char_boundary(at) {
at -= 1;
}
at
}
pub fn replace(&mut self, task: String) {
self.prompt = task;
self.cursor = None;
}
pub fn insert(&mut self, c: char) {
if self.cursor.is_none() {
self.prompt.push(c);
return;
}
let at = self.at();
self.prompt.insert(at, c);
let next = at + c.len_utf8();
self.cursor = (next < self.prompt.len()).then_some(next);
}
pub fn backspace(&mut self) {
let at = self.at();
let Some(c) = self.prompt[..at].chars().next_back() else {
return;
};
let from = at - c.len_utf8();
self.prompt.remove(from);
if self.cursor.is_some() {
self.cursor = Some(from);
}
}
pub fn left(&mut self) {
let mut at = self.at();
while let Some(c) = self.prompt[..at].chars().next_back() {
at -= c.len_utf8();
if !mark(c) {
break;
}
}
self.cursor = (at < self.prompt.len()).then_some(at);
}
pub fn right(&mut self) {
let mut at = self.at();
if let Some(c) = self.prompt[at..].chars().next() {
at += c.len_utf8();
while let Some(c) = self.prompt[at..].chars().next().filter(|c| mark(*c)) {
at += c.len_utf8();
}
}
self.cursor = (at < self.prompt.len()).then_some(at);
}
pub fn cursor_row(&self) -> (usize, usize) {
let before = &self.prompt[..self.at()];
let start = before.rfind('\n').map_or(0, |i| i + 1);
(before.matches('\n').count(), before.len() - start)
}
pub fn place(&mut self, row: usize, column: u16) {
let rows: Vec<&str> = self.prompt.split('\n').collect();
let row = row.min(rows.len() - 1);
let start: usize = rows[..row].iter().map(|r| r.len() + 1).sum();
let line = rows[row];
let mut cells = 0u16;
let mut offset = line.len();
for (i, c) in line.char_indices() {
if mark(c) {
continue;
}
let width = cells_of(c);
if column < cells + width {
offset = i;
break;
}
cells += width;
}
let at = start + offset;
self.cursor = (at < self.prompt.len()).then_some(at);
}
}
fn cells_of(c: char) -> u16 {
ratatui::text::Span::raw(c.to_string()).width() as u16
}
fn mark(c: char) -> bool {
c != '\n' && cells_of(c) == 0
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn repository(name: &str) -> Repository {
Repository {
name: name.to_string(),
path: PathBuf::from("/p").join(name),
}
}
#[test]
fn a_pane_is_named_for_the_repository_it_works_in() {
assert_eq!(Pane::new(Some(repository("scratch"))).title(), "scratch");
}
fn written(task: &str) -> Pane {
let mut pane = Pane::default();
for c in task.chars() {
pane.insert(c);
}
pane
}
#[test]
fn typing_lands_where_the_cursor_is() {
let mut pane = written("hello world");
for _ in 0.." world".len() {
pane.left();
}
pane.insert(',');
assert_eq!(pane.prompt, "hello, world");
pane.backspace();
pane.backspace();
assert_eq!(pane.prompt, "hell world");
}
#[test]
fn the_cursor_stops_at_either_end() {
let mut pane = written("ab");
for _ in 0..5 {
pane.left();
}
assert_eq!(pane.at(), 0);
pane.backspace();
assert_eq!(
pane.prompt, "ab",
"backspace at the start deleted something"
);
for _ in 0..5 {
pane.right();
}
assert_eq!(pane.cursor, None, "the end is the end, not an offset");
}
#[test]
fn a_task_replaced_whole_puts_the_cursor_after_it() {
let mut pane = written("a long first task");
pane.left();
pane.replace("short".to_string());
pane.insert('!');
assert_eq!(pane.prompt, "short!");
pane.cursor = Some(40);
pane.prompt = "tiny".to_string();
assert_eq!(pane.at(), 4);
}
#[test]
fn a_mark_moves_with_its_letter() {
let mut pane = written("\u{0e01}\u{0e48}x");
pane.left();
pane.left();
assert_eq!(pane.at(), 0);
pane.right();
assert_eq!(pane.at(), "\u{0e01}\u{0e48}".len());
}
#[test]
fn a_click_finds_the_character_in_that_cell() {
let mut pane = written("first\nsecond row");
pane.place(1, 3);
assert_eq!(pane.cursor_row(), (1, 3));
pane.place(0, 60);
assert_eq!(pane.cursor_row(), (0, 5));
pane.place(9, 0);
assert_eq!(pane.cursor_row(), (1, 0));
let mut wide = written("\u{4f60}\u{597d}x");
wide.place(0, 3);
assert_eq!(wide.at(), "\u{4f60}".len());
wide.place(0, 4);
assert_eq!(wide.at(), "\u{4f60}\u{597d}".len());
}
#[test]
fn a_cursor_at_the_end_is_always_spelled_as_the_end() {
let mut pane = Pane::default();
pane.left();
assert_eq!(pane.cursor, None);
pane.insert('a');
assert_eq!(pane.cursor, None);
pane.left();
pane.insert('b');
assert_eq!(pane.prompt, "ba");
assert_eq!(pane.cursor, Some(1));
}
#[test]
fn a_pane_with_nowhere_to_work_yet_says_so_rather_than_being_blank() {
assert_eq!(Pane::new(None).title(), "new");
}
}