use crate::ids::PaneId;
use crate::proto::attach::ViewReply;
use crate::view::PaneView;
pub const SCROLL_STEP: u32 = 3;
#[derive(Debug)]
pub struct CopySession {
pane: PaneId,
view: PaneView,
cursor: (u16, u16),
anchor: Option<(u16, u16)>,
dragging: bool,
wanted_offset: u32,
history: u32,
pending_read: Option<u64>,
next_request: u64,
notice: Option<&'static str>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CopyKey {
Left,
Right,
Up,
Down,
PageUp,
PageDown,
Anchor,
Copy,
Live,
Quit,
Escape,
}
pub enum CopyOutcome {
Continue,
Copied(String),
Finished,
}
impl CopySession {
#[must_use]
pub fn new(pane: PaneId, view: PaneView) -> Self {
let cursor = (
view.cursor.row.min(view.rows.saturating_sub(1)),
view.cursor.column.min(view.columns.saturating_sub(1)),
);
Self {
pane,
view,
cursor,
anchor: None,
dragging: false,
wanted_offset: 0,
history: 0,
pending_read: None,
next_request: 1,
notice: None,
}
}
pub const fn pane(&self) -> PaneId {
self.pane
}
pub fn view(&self) -> &PaneView {
&self.view
}
pub fn cursor(&self) -> (u16, u16) {
self.cursor
}
pub fn anchor(&self) -> Option<(u16, u16)> {
self.anchor
}
pub fn offset(&self) -> u32 {
self.view.offset
}
pub fn selecting(&self) -> bool {
self.anchor.is_some()
}
pub fn notice(&self) -> Option<&'static str> {
self.notice
}
pub fn take_read(&mut self) -> Option<(u64, PaneId, u32)> {
if self.pending_read.is_some() {
return None;
}
if self.wanted_offset == self.view.offset {
return None;
}
let request = self.next_request;
self.next_request = self.next_request.wrapping_add(1);
self.pending_read = Some(request);
Some((request, self.pane, self.wanted_offset))
}
pub fn awaiting_read(&self) -> bool {
self.pending_read.is_some()
}
pub fn install(&mut self, reply: ViewReply) -> bool {
if reply.pane != self.pane || self.pending_read != Some(reply.request) {
return true;
}
self.pending_read = None;
let Some(view) = reply
.view
.and_then(|view| PaneView::from_update(&view).ok())
else {
return false;
};
let resized = (view.rows, view.columns) != (self.view.rows, self.view.columns);
let moved = view.offset != self.view.offset;
if (resized || moved) && self.anchor.is_some() {
self.anchor = None;
self.notice = Some("Selection cleared: the view changed");
}
self.history = reply.history;
if view.offset != self.wanted_offset {
self.wanted_offset = view.offset;
}
self.view = view;
self.clamp_cursor();
true
}
fn clamp_cursor(&mut self) {
self.cursor = (
self.cursor.0.min(self.view.rows.saturating_sub(1)),
self.cursor.1.min(self.view.columns.saturating_sub(1)),
);
}
pub fn refresh_live(&mut self, live: &PaneView) {
if self.view.offset != 0 || self.pending_read.is_some() {
return;
}
if (live.rows, live.columns) != (self.view.rows, self.view.columns) && self.anchor.is_some()
{
self.anchor = None;
self.notice = Some("Selection cleared: the pane was resized");
}
if self.anchor.is_some() && live.cells != self.view.cells {
self.anchor = None;
self.notice = Some("Selection cleared: new output arrived");
}
self.view = live.clone();
self.clamp_cursor();
}
pub fn key(&mut self, key: CopyKey) -> CopyOutcome {
self.notice = None;
let rows = self.view.rows.saturating_sub(1);
let columns = self.view.columns.saturating_sub(1);
match key {
CopyKey::Left => self.cursor.1 = self.cursor.1.saturating_sub(1),
CopyKey::Right => self.cursor.1 = self.cursor.1.saturating_add(1).min(columns),
CopyKey::Up => {
if self.cursor.0 == 0 {
self.scroll(SCROLL_STEP as i64);
} else {
self.cursor.0 -= 1;
}
}
CopyKey::Down => {
if self.cursor.0 >= rows {
self.scroll(-(SCROLL_STEP as i64));
} else {
self.cursor.0 += 1;
}
}
CopyKey::PageUp => self.scroll(i64::from(self.view.rows.max(1))),
CopyKey::PageDown => self.scroll(-i64::from(self.view.rows.max(1))),
CopyKey::Anchor => self.anchor = Some(self.cursor),
CopyKey::Copy => {
if let Some(anchor) = self.anchor {
let text = self.view.text_between(anchor, self.cursor);
return CopyOutcome::Copied(text);
}
}
CopyKey::Live => {
self.wanted_offset = 0;
if self.anchor.is_some() {
self.anchor = None;
self.notice = Some("Selection cleared: returned to live output");
}
}
CopyKey::Quit => return CopyOutcome::Finished,
CopyKey::Escape => {
if self.anchor.is_some() {
self.anchor = None;
} else {
return CopyOutcome::Finished;
}
}
}
CopyOutcome::Continue
}
pub fn scroll(&mut self, delta: i64) {
let current = i64::from(self.wanted_offset);
let target = (current + delta).clamp(0, i64::from(u32::MAX));
let target = u32::try_from(target).unwrap_or(0);
if target != self.wanted_offset {
if self.anchor.is_some() {
self.anchor = None;
self.notice = Some("Selection cleared: scrolled");
}
self.wanted_offset = target;
}
}
pub fn drag(&mut self, row: u16, column: u16, release: bool) {
self.notice = None;
if self.view.rows == 0 || self.view.columns == 0 {
return;
}
let point = (
row.min(self.view.rows - 1),
column.min(self.view.columns - 1),
);
if !self.dragging {
self.anchor = Some(point);
}
self.cursor = point;
self.dragging = !release;
}
pub fn hint(&self) -> String {
let where_ = if self.view.offset == 0 {
"live".to_owned()
} else {
format!("history -{} of {}", self.view.offset, self.history)
};
if let Some(notice) = self.notice {
return format!("Copy · {where_} · {notice} · Esc back");
}
if self.selecting() {
format!("Copy selection · {where_} · arrows/hjkl extend · y/Enter copy · Esc clear")
} else {
format!(
"Copy · {where_} · arrows/hjkl move · Space select · u/d PgUp/PgDn scroll · g live · q finish · Esc back"
)
}
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::panic)]
mod tests {
use super::*;
fn view(rows: u16, columns: u16, text: &str, offset: u32) -> PaneView {
let mut parser = vt100::Parser::new(rows, columns, 0);
parser.process(text.as_bytes());
PaneView::from_screen(parser.screen(), "", offset, None).unwrap_or_default()
}
fn update(rows: u16, columns: u16, text: &str, offset: u32) -> crate::view::PaneUpdate {
let mut parser = vt100::Parser::new(rows, columns, 0);
parser.process(text.as_bytes());
crate::view::PaneUpdate::full_from_screen(parser.screen(), "", offset, None)
.unwrap_or_default()
}
#[test]
fn selection_copies_visible_text_and_clears_on_view_change() {
let mut session = CopySession::new(PaneId(1), view(3, 6, "hello\r\nworld", 0));
session.cursor = (0, 0);
assert!(matches!(
session.key(CopyKey::Anchor),
CopyOutcome::Continue
));
for _ in 0..4 {
session.key(CopyKey::Right);
}
session.key(CopyKey::Down);
match session.key(CopyKey::Copy) {
CopyOutcome::Copied(text) => assert_eq!(text, "hello\nworld"),
_ => panic!("expected copy"),
}
let mut session = CopySession::new(PaneId(1), view(3, 6, "hello", 0));
session.key(CopyKey::Anchor);
session.key(CopyKey::Up);
assert!(session.anchor().is_none(), "scrolling clears the selection");
assert_eq!(
session.take_read().map(|(_, pane, offset)| (pane, offset)),
Some((PaneId(1), 3))
);
assert!(session.take_read().is_none(), "one read outstanding");
let reply = ViewReply {
request: 1,
pane: PaneId(1),
view: Some(Box::new(update(3, 6, "older", 2))),
history: 2,
};
assert!(session.install(reply));
assert_eq!(session.offset(), 2);
assert!(session.hint().contains("history -2 of 2"));
session.key(CopyKey::Anchor);
session.refresh_live(&view(3, 6, "changed", 0));
assert!(
session.selecting(),
"live frames do not disturb history views"
);
session.key(CopyKey::Live);
assert!(!session.selecting());
let gone = ViewReply {
request: 2,
pane: PaneId(1),
view: None,
history: 0,
};
assert!(session.take_read().is_some());
assert!(!session.install(gone));
}
#[test]
fn new_output_under_a_live_selection_clears_it_with_feedback() {
let mut session = CopySession::new(PaneId(1), view(3, 6, "hello", 0));
session.key(CopyKey::Anchor);
session.refresh_live(&view(3, 6, "bye", 0));
assert!(!session.selecting());
assert!(
session
.notice()
.is_some_and(|notice| notice.contains("new output"))
);
session.refresh_live(&view(4, 8, "bye", 0));
assert_eq!(session.view().rows, 4);
}
#[test]
fn drag_selection_stays_within_the_pane() {
let mut session = CopySession::new(PaneId(1), view(3, 6, "abcdef", 0));
session.drag(0, 1, false);
session.drag(9, 99, false);
assert_eq!(session.anchor(), Some((0, 1)));
assert_eq!(session.cursor(), (2, 5));
session.drag(2, 5, true);
match session.key(CopyKey::Copy) {
CopyOutcome::Copied(text) => assert_eq!(text, "bcdef\n\n"),
_ => panic!("expected copy"),
}
let mut session = CopySession::new(PaneId(1), view(3, 6, "abcdef", 0));
session.drag(0, 1, false);
session.drag(0, 4, true);
match session.key(CopyKey::Copy) {
CopyOutcome::Copied(text) => assert_eq!(text, "bcde"),
_ => panic!("expected copy"),
}
}
}