feat: select and copy rectangular row ranges

This commit is contained in:
Valdemar Lorenzen
2026-10-21 10:17:32 +02:00
parent 0442e4b153
commit c533e71ecc
4 changed files with 328 additions and 10 deletions
+1 -1
View File
@@ -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)
+299 -3
View File
@@ -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 {
.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("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'\\' | '|' | '`' | '*' | '_' | '[' | ']' => {
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 | &lt;&amp;&gt;\\\\tail |\n")
);
assert_eq!(
app.selection_text(false, true).as_deref(),
Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\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);
+12
View File
@@ -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
View File
@@ -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.