feat: select and copy rectangular row ranges
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
- [ ] add selection + copy support in the rows panel.
|
||||
- [x] add selection + copy support in the rows panel.
|
||||
- should be able to select/highlight an arbitrary rectangle (like vim visual block basically)
|
||||
- should be able to copy selection
|
||||
- copied data should be formatted nicely before inserting into the clipboard (system clipboard)
|
||||
|
||||
+305
-9
@@ -29,6 +29,9 @@ pub struct App {
|
||||
page: Page,
|
||||
page_index: usize,
|
||||
rows_state: TableState,
|
||||
selection_anchor: Option<(usize, usize)>,
|
||||
copy_headers: Option<bool>,
|
||||
copy_status: Option<String>,
|
||||
schema_state: TableState,
|
||||
column_offset: usize,
|
||||
show_schema: bool,
|
||||
@@ -55,6 +58,9 @@ impl App {
|
||||
},
|
||||
page_index: 0,
|
||||
rows_state: TableState::default(),
|
||||
selection_anchor: None,
|
||||
copy_headers: None,
|
||||
copy_status: None,
|
||||
schema_state: TableState::default(),
|
||||
column_offset: 0,
|
||||
show_schema: false,
|
||||
@@ -94,13 +100,127 @@ impl App {
|
||||
self.query.position()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool {
|
||||
let pending = self.copy_headers.take();
|
||||
if self.show_schema {
|
||||
return false;
|
||||
}
|
||||
if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(headers) = pending {
|
||||
match key.code {
|
||||
KeyCode::Char(format @ ('m' | 'c')) => {
|
||||
if let Some(text) = self.selection_text(headers, format == 'm') {
|
||||
match copy_to_clipboard(&text) {
|
||||
Ok(()) => {
|
||||
self.error = None;
|
||||
self.copy_status = Some(format!(
|
||||
" Copied {} {} headers ",
|
||||
if format == 'm' { "Markdown" } else { "CSV" },
|
||||
if headers { "with" } else { "without" },
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(format!("Clipboard: {}", error_text(error.as_ref())));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => return true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('v') => {
|
||||
self.copy_status = None;
|
||||
self.selection_anchor = if self.selection_anchor.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.rows_state.selected_cell()
|
||||
};
|
||||
true
|
||||
}
|
||||
KeyCode::Char(character @ ('y' | 'Y')) => {
|
||||
self.copy_status = None;
|
||||
if self.selection_bounds().is_some() {
|
||||
self.copy_headers = Some(character == 'y');
|
||||
}
|
||||
true
|
||||
}
|
||||
KeyCode::Esc if self.selection_anchor.is_some() => {
|
||||
self.selection_anchor = None;
|
||||
self.copy_status = None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_selection(&mut self) {
|
||||
self.selection_anchor = None;
|
||||
self.copy_headers = None;
|
||||
self.copy_status = None;
|
||||
}
|
||||
|
||||
fn selection_bounds(&self) -> Option<(usize, usize, usize, usize)> {
|
||||
if self.show_schema {
|
||||
return None;
|
||||
}
|
||||
let (row, column) = self.rows_state.selected_cell()?;
|
||||
let (anchor_row, anchor_column) = self.selection_anchor.unwrap_or((row, column));
|
||||
Some((
|
||||
row.min(anchor_row),
|
||||
row.max(anchor_row),
|
||||
column.min(anchor_column),
|
||||
column.max(anchor_column),
|
||||
))
|
||||
}
|
||||
|
||||
fn selection_text(&self, headers: bool, markdown: bool) -> Option<String> {
|
||||
let (top, bottom, left, right) = self.selection_bounds()?;
|
||||
let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page);
|
||||
let rows = page.rows.get(top..=bottom)?;
|
||||
let mut output = String::new();
|
||||
if headers {
|
||||
append_record(
|
||||
&mut output,
|
||||
(left..=right).map(|column| {
|
||||
self.query_result.as_ref().map_or_else(
|
||||
|| self.columns[column].label.as_str(),
|
||||
|result| result.headers[column].as_str(),
|
||||
)
|
||||
}),
|
||||
markdown,
|
||||
);
|
||||
} else if markdown {
|
||||
append_record(&mut output, (left..=right).map(|_| ""), true);
|
||||
}
|
||||
if markdown {
|
||||
append_record(&mut output, (left..=right).map(|_| "---"), true);
|
||||
}
|
||||
for row in rows {
|
||||
append_record(
|
||||
&mut output,
|
||||
row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")),
|
||||
markdown,
|
||||
);
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||
self.copy_status = None;
|
||||
match key.code {
|
||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
||||
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
||||
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
|
||||
KeyCode::Char('s') if self.query_result.is_none() => {
|
||||
self.clear_selection();
|
||||
self.show_schema = !self.show_schema;
|
||||
self.column_offset = 0;
|
||||
}
|
||||
@@ -204,6 +324,7 @@ impl App {
|
||||
fn execute_query(&mut self) {
|
||||
match self.db.query(&self.query.value) {
|
||||
Ok(result) => {
|
||||
self.clear_selection();
|
||||
self.error = None;
|
||||
if self.db.access_mode() == crate::db::AccessMode::ReadWrite {
|
||||
self.refresh();
|
||||
@@ -254,6 +375,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||
self.clear_selection();
|
||||
self.active = index;
|
||||
self.query_result = None;
|
||||
self.columns.clear();
|
||||
@@ -286,6 +408,7 @@ impl App {
|
||||
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
||||
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||
Ok(page) => {
|
||||
self.clear_selection();
|
||||
self.rows_state = TableState::default()
|
||||
.with_selected((!page.rows.is_empty()).then_some(0))
|
||||
.with_selected_column(
|
||||
@@ -340,7 +463,14 @@ impl App {
|
||||
focus == Focus::Query,
|
||||
Some(sql::text(&self.query.value, &self.query_highlights)),
|
||||
);
|
||||
let title = if self.query_result.is_some() {
|
||||
let title = if let Some(headers) = self.copy_headers {
|
||||
if headers { " Copy with headers · m Markdown / c CSV " }
|
||||
else { " Copy without headers · m Markdown / c CSV " }
|
||||
} else if self.selection_anchor.is_some() {
|
||||
" VISUAL BLOCK · ym/yc with headers · Ym/Yc without "
|
||||
} else if let Some(status) = &self.copy_status {
|
||||
status.as_str()
|
||||
} else if self.query_result.is_some() {
|
||||
" Results "
|
||||
} else if self.show_schema {
|
||||
" Schema · s for rows "
|
||||
@@ -474,18 +604,30 @@ impl App {
|
||||
|result| result.headers[index].as_str(),
|
||||
)
|
||||
});
|
||||
let rows = page.rows.iter().map(|row| {
|
||||
let bounds = self.selection_anchor.and_then(|_| self.selection_bounds());
|
||||
let rows = page.rows.iter().enumerate().map(|(row_index, row)| {
|
||||
Row::new(
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.skip(self.column_offset)
|
||||
.take(visible)
|
||||
.map(|value| match value {
|
||||
Some(value) => Cell::from(value.as_str()),
|
||||
None => Cell::from("NULL").style(
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
.map(|(column_index, value)| {
|
||||
let cell = match value {
|
||||
Some(value) => Cell::from(value.as_str()),
|
||||
None => Cell::from("NULL").style(
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
};
|
||||
if bounds.is_some_and(|(top, bottom, left, right)| {
|
||||
(top..=bottom).contains(&row_index)
|
||||
&& (left..=right).contains(&column_index)
|
||||
}) {
|
||||
cell.style(selected_style())
|
||||
} else {
|
||||
cell
|
||||
}
|
||||
}),
|
||||
)
|
||||
});
|
||||
@@ -507,6 +649,76 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn append_record<'a>(
|
||||
output: &mut String,
|
||||
values: impl Iterator<Item = &'a str>,
|
||||
markdown: bool,
|
||||
) {
|
||||
if markdown {
|
||||
output.push('|');
|
||||
}
|
||||
for (index, value) in values.enumerate() {
|
||||
if markdown {
|
||||
output.push(' ');
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'&' => output.push_str("&"),
|
||||
'<' => output.push_str("<"),
|
||||
'>' => output.push_str(">"),
|
||||
'\\' | '|' | '`' | '*' | '_' | '[' | ']' => {
|
||||
output.push('\\');
|
||||
output.push(character);
|
||||
}
|
||||
_ => output.push(character),
|
||||
}
|
||||
}
|
||||
output.push_str(" |");
|
||||
} else {
|
||||
if index > 0 {
|
||||
output.push(',');
|
||||
}
|
||||
output.push('"');
|
||||
for character in value.chars() {
|
||||
if character == '"' {
|
||||
output.push('"');
|
||||
}
|
||||
output.push(character);
|
||||
}
|
||||
output.push('"');
|
||||
}
|
||||
}
|
||||
output.push_str(if markdown { "\n" } else { "\r\n" });
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> Result<()> {
|
||||
use std::{io::Write, process::{Command, Stdio}};
|
||||
|
||||
let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||
("pbcopy", &[])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
("powershell.exe", &["-NoProfile", "-Command",
|
||||
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"])
|
||||
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
("wl-copy", &[])
|
||||
} else {
|
||||
("xclip", &["-selection", "clipboard"])
|
||||
};
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| format!("could not start {program}: {error}"))?;
|
||||
let written = child.stdin.take().unwrap().write_all(text.as_bytes());
|
||||
let status = child.wait()?;
|
||||
if !status.success() {
|
||||
return Err(format!("{program} failed ({status})").into());
|
||||
}
|
||||
written?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn panel(title: &str, focused: bool) -> Block<'_> {
|
||||
Block::bordered()
|
||||
.title(title)
|
||||
@@ -543,6 +755,90 @@ fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<us
|
||||
mod tests {
|
||||
use super::moved_index;
|
||||
|
||||
#[test]
|
||||
fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> crate::Result<()> {
|
||||
use super::{App, Focus};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
rusqlite::Connection::open(file.path())?.execute_batch(
|
||||
"CREATE TABLE cells (a, b, c, d);
|
||||
INSERT INTO cells VALUES
|
||||
('outside', 'a|b', 'say \"hi\",界', 'outside'),
|
||||
('outside', NULL, '<&>\\tail', 'outside'),
|
||||
('outside', 'outside', 'outside', 'outside');
|
||||
WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101)
|
||||
INSERT INTO cells SELECT x,x,x,x FROM n;",
|
||||
)?;
|
||||
let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?;
|
||||
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
|
||||
// Anchor at the bottom right, extend up and left across a scrolled viewport.
|
||||
for character in ['j', 'l', 'l'] {
|
||||
app.handle_key(key(character));
|
||||
}
|
||||
let mut terminal = Terminal::new(TestBackend::new(60, 20))?;
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?;
|
||||
assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
|
||||
app.handle_key(key('k'));
|
||||
app.handle_key(key('h'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?;
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(app.column_offset, 1);
|
||||
for position in [(1, 12), (22, 12), (1, 13), (22, 13)] {
|
||||
assert_eq!(buffer[position].bg, Color::Blue);
|
||||
}
|
||||
assert_ne!(buffer[(1, 14)].bg, Color::Blue);
|
||||
assert_eq!(
|
||||
app.selection_text(true, true).as_deref(),
|
||||
Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n")
|
||||
);
|
||||
assert_eq!(
|
||||
app.selection_text(false, true).as_deref(),
|
||||
Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n")
|
||||
);
|
||||
assert_eq!(
|
||||
app.selection_text(true, false).as_deref(),
|
||||
Some("\"b\",\"c\"\r\n\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n")
|
||||
);
|
||||
assert_eq!(
|
||||
app.selection_text(false, false).as_deref(),
|
||||
Some("\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n")
|
||||
);
|
||||
assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
|
||||
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"a|b\"\r\n"));
|
||||
// A prefix cancels on focus changes rather than capturing a later key.
|
||||
app.handle_rows_command(key('y'));
|
||||
assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
|
||||
assert!(app.copy_headers.is_none());
|
||||
app.handle_rows_command(key('v'));
|
||||
app.handle_key(key('n'));
|
||||
assert!(app.selection_anchor.is_none());
|
||||
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"101\"\r\n"));
|
||||
app.handle_rows_command(key('v'));
|
||||
app.handle_key(key('s'));
|
||||
assert!(app.selection_anchor.is_none());
|
||||
assert!(app.selection_text(true, true).is_none());
|
||||
app.handle_key(key('s'));
|
||||
app.handle_rows_command(key('v'));
|
||||
app.query.value = "SELECT 42 AS answer".into();
|
||||
app.execute_query();
|
||||
assert!(app.selection_anchor.is_none());
|
||||
assert_eq!(app.selection_text(true, false).as_deref(), Some("\"answer\"\r\n\"42\"\r\n"));
|
||||
app.handle_rows_command(key('v'));
|
||||
app.query.value = "SELECT missing FROM nowhere".into();
|
||||
app.execute_query();
|
||||
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"42\"\r\n"));
|
||||
assert!(app.selection_anchor.is_some());
|
||||
app.open_relation(Some(0));
|
||||
assert!(app.selection_anchor.is_none());
|
||||
app.query.value = "SELECT * FROM cells WHERE 0".into();
|
||||
app.execute_query();
|
||||
app.handle_rows_command(key('v'));
|
||||
assert!(app.selection_text(true, true).is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_clamps_to_available_items() {
|
||||
assert_eq!(moved_index(None, 0, 1), None);
|
||||
|
||||
@@ -221,6 +221,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
_ if focus == Focus::Rows
|
||||
&& browser.as_mut().is_some_and(|app| app.handle_rows_command(key)) => {}
|
||||
KeyCode::Char('?')
|
||||
if focus != Focus::Query
|
||||
|| !browser.as_ref().is_some_and(App::query_insert_mode) =>
|
||||
@@ -423,6 +425,16 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
|
||||
s Toggle rows / column schema\n\
|
||||
n/p, PgDn/PgUp Next / previous 100-row relation page\n\
|
||||
r Refresh relations and active relation\n\n\
|
||||
Rows / query results\n\
|
||||
v / Ctrl-V Toggle rectangular block; hjkl/arrows extend it\n\
|
||||
ym / yc Copy Markdown / CSV with column headers\n\
|
||||
Ym / Yc Copy Markdown / CSV without column names\n\
|
||||
Esc Cancel pending copy or block before leaving rows\n\
|
||||
With no block, copy uses the current cell. Blocks stay within the loaded page.\n\
|
||||
Paging, refresh, schema/relation changes, and successful queries clear blocks.\n\
|
||||
Copy uses full displayed values; NULL/control escapes are preserved.\n\
|
||||
Headerless Markdown has blank headings. CSV quotes fields with CRLF endings.\n\
|
||||
Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\
|
||||
Query editor\n\
|
||||
i/a/I/A Enter Insert mode (at/after/start/end)\n\
|
||||
hjkl / arrows Move cursor in Normal mode\n\
|
||||
|
||||
+10
@@ -72,6 +72,16 @@ Navigation:
|
||||
s Toggle rows and column schema
|
||||
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
|
||||
n/p, PgDn/Up Next / previous 100-row relation page
|
||||
v / Ctrl-V Toggle rectangular selection in rows/results; move with hjkl/arrows
|
||||
ym / yc Copy the block (or current cell) as Markdown / CSV with column headers
|
||||
Ym / Yc Copy as Markdown / CSV without column names
|
||||
Esc Cancel a pending copy or block selection before returning to explorer
|
||||
Blocks cover the current loaded page, including cells scrolled out of view.
|
||||
Paging, refreshing, opening a relation, schema changes, and successful queries clear blocks.
|
||||
Copies use full displayed cell values (NULL and escaped control characters included).
|
||||
Headerless Markdown uses blank headings; CSV quotes fields and uses CRLF line endings.
|
||||
Clipboard tools: pbcopy on macOS, PowerShell on Windows, wl-copy on Wayland,
|
||||
or xclip on X11. A missing/failing tool reports an error without clearing the block.
|
||||
c Add a configured database in a floating window
|
||||
i Show configured database info (Esc/Enter closes; passwords omitted)
|
||||
Works on a saved database entry or any of its table/view children.
|
||||
|
||||
Reference in New Issue
Block a user