Skip to content

Commit eb613b3

Browse files
committed
feat: add clipboard copy/paste (Ctrl+C/Ctrl+V) and switch exit to Ctrl+Q
- Add clipboard module with arboard crate for system clipboard access - Copy: Ctrl+C in navigation mode copies selection to clipboard (TSV) - Paste: Ctrl+V pastes clipboard content at cursor, auto-expanding worksheet - Paste respects MAX_ROWS/MAX_COLUMNS limits with boundary checks - Render dashed border for copied region, solid border for selection - Change exit shortcut from Ctrl+C to Ctrl+Q to avoid conflict - Rename ExitHandler::press_ctrl_c to press_ctrl_q
1 parent 07216b3 commit eb613b3

11 files changed

Lines changed: 677 additions & 37 deletions

File tree

Cargo.lock

Lines changed: 402 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ edition = "2024"
1010
color-eyre = "0.6.3"
1111
crossterm = "0.29.0"
1212
ratatui = "0.30.1"
13+
arboard = "3"
1314
replace-homedir = "0.1"
1415
serde = { version = "1", features = ["derive"] }
1516
serde_json = "1"

src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ impl App {
101101
}
102102

103103
fn dispatch_key(&mut self, key: crossterm::event::KeyEvent) {
104-
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
105-
self.exit_handler.press_ctrl_c();
104+
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
105+
self.exit_handler.press_ctrl_q();
106106
return;
107107
}
108108
match self.active_screen.handle_key(key) {

src/clipboard.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//! 剪贴板操作与 TSV 编解码。
2+
//!
3+
//! 复制时:单元格数据 → [`to_tsv`] → [`copy_to_clipboard`]
4+
//! 粘贴时:[`read_from_clipboard`] → [`from_tsv`] → 单元格数据
5+
6+
/// 复制文本到系统剪贴板。
7+
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
8+
let mut last_err = String::new();
9+
for _ in 0..3 {
10+
match arboard::Clipboard::new().and_then(|mut c| c.set_text(text)) {
11+
Ok(()) => return Ok(()),
12+
Err(e) => last_err = e.to_string(),
13+
}
14+
}
15+
Err(last_err)
16+
}
17+
18+
/// 从系统剪贴板读取文本。
19+
pub fn read_from_clipboard() -> Result<String, String> {
20+
let mut last_err = String::new();
21+
for _ in 0..3 {
22+
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
23+
Ok(text) => return Ok(text),
24+
Err(e) => last_err = e.to_string(),
25+
}
26+
}
27+
Err(last_err)
28+
}
29+
30+
/// 将二维单元格网格编码为 TSV 文本。
31+
///
32+
/// 行内用 `\t` 分隔列,行间用 `\n` 分隔。
33+
/// 空字符串代表空单元格。
34+
pub fn to_tsv(rows: &[Vec<String>]) -> String {
35+
rows.iter()
36+
.map(|row| row.join("\t"))
37+
.collect::<Vec<_>>()
38+
.join("\n")
39+
}
40+
41+
/// 将 TSV 文本解析为二维字符串网格。
42+
///
43+
/// - 按 `\n` 分行(同时处理 `\r\n`)
44+
/// - 去除末尾空行
45+
/// - 每行按 `\t` 分列
46+
pub fn from_tsv(text: &str) -> Vec<Vec<String>> {
47+
let trimmed = text.trim_end_matches('\n').trim_end_matches('\r');
48+
if trimmed.is_empty() {
49+
return vec![];
50+
}
51+
trimmed
52+
.lines()
53+
.map(|line| line.split('\t').map(|s| s.to_string()).collect())
54+
.collect()
55+
}

src/exit.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ impl ExitHandler {
2323
}
2424
}
2525

26-
pub fn press_ctrl_c(&mut self) {
26+
pub fn press_ctrl_q(&mut self) {
2727
let now = Instant::now();
2828
self.state = match self.state {
2929
State::Idle => State::ConfirmOnce(now),
@@ -67,7 +67,7 @@ impl ExitHandler {
6767

6868
pub fn hint_text(&self) -> Option<&'static str> {
6969
if matches!(self.state, State::ConfirmOnce(_)) {
70-
Some("再次按下 Ctrl+C 以退出")
70+
Some("再次按下 Ctrl+Q 以退出")
7171
} else {
7272
None
7373
}

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! mini-excel —— 基于 Ratatui 的 TUI 迷你 Excel 应用。
22
//!
3-
//! 按两次 Ctrl+C 退出。
3+
//! 按两次 Ctrl+Q 退出。
44
55
mod app;
6+
mod clipboard;
67
mod exit;
78
mod model;
89
mod screen;

src/model/workbook.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::io;
55
use std::path::Path;
66

77
use super::cell::{Cell, CellAddress, CellValue};
8+
use super::limits;
89

910
/// 清空单元格内容的范围。
1011
///
@@ -94,6 +95,14 @@ impl Workbook {
9495
self.cells.remove(&addr);
9596
}
9697

98+
/// 确保表格行列数不小于指定值。
99+
///
100+
/// 粘贴时自动扩展表格。不会超过最大行列限制。
101+
pub fn ensure_size(&mut self, rows: usize, cols: usize) {
102+
self.rows = self.rows.max(rows).min(limits::MAX_ROWS);
103+
self.columns = self.columns.max(cols).min(limits::MAX_COLUMNS);
104+
}
105+
97106
/// 删除指定行,该行上方的行不动,下方行上移,总行数减一。
98107
///
99108
/// 至少保留一行。该方法只维护工作簿尺寸和单元格位置;

src/screen/editor/context.rs

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
use crate::{model::workbook::Workbook, screen::ScreenCommand, theme::Theme};
1+
use crate::{
2+
model::{cell::CellAddress, workbook::Workbook},
3+
screen::ScreenCommand,
4+
theme::Theme,
5+
};
26

37
use super::{mode::Selection, viewport::Viewport};
48

@@ -12,6 +16,7 @@ pub struct TableContext {
1216
path: String,
1317
wb: Workbook,
1418
selection: Option<Selection>,
19+
copied_region: Option<Selection>,
1520
/// 模式需要切换屏幕时写入,随后由 `ModeHost` 取走。
1621
pending_command: Option<ScreenCommand>,
1722
}
@@ -25,6 +30,7 @@ impl TableContext {
2530
wb,
2631
viewport: Viewport::new(),
2732
selection: None,
33+
copied_region: None,
2834
pending_command: None,
2935
}
3036
}
@@ -64,6 +70,13 @@ impl TableContext {
6470
self.selection = None;
6571
}
6672

73+
/// 已复制的区域(用于渲染虚线边框)。
74+
///
75+
/// `None` 表示没有待粘贴的复制内容。
76+
pub fn copied_region(&self) -> Option<&Selection> {
77+
self.copied_region.as_ref()
78+
}
79+
6780
/// 光标所在单元格的原始文本。
6881
pub fn current_cell_raw(&self) -> String {
6982
self.wb
@@ -99,6 +112,89 @@ impl TableContext {
99112
self.wb.clear_cell(self.viewport.cursor());
100113
}
101114

115+
/// 复制当前选区的单元格内容到系统剪贴板。
116+
///
117+
/// - 无选区 → 复制光标所在单元格
118+
/// - Range → 复制矩形区域内所有单元格
119+
/// - Row → 复制整行(所有列)
120+
/// - Column → 复制整列(所有行)
121+
///
122+
/// 不改变选区或光标状态。
123+
pub fn copy_selection(&mut self) -> Result<(), String> {
124+
let cells = self.collect_selection();
125+
let tsv = crate::clipboard::to_tsv(&cells);
126+
crate::clipboard::copy_to_clipboard(&tsv)?;
127+
self.copied_region = match &self.selection {
128+
Some(sel) => Some(*sel),
129+
None => {
130+
let cur = self.viewport.cursor();
131+
Some(Selection::Range {
132+
anchor: cur,
133+
cursor: cur,
134+
})
135+
}
136+
};
137+
Ok(())
138+
}
139+
140+
/// 从系统剪贴板粘贴到光标位置。
141+
///
142+
/// 自动扩展表格行列(不超过最大限制)。
143+
/// 粘贴后创建 Range 选区覆盖整个粘贴区域。
144+
/// 光标位置不变。
145+
pub fn paste_from_clipboard(&mut self) -> Result<(), String> {
146+
let text = crate::clipboard::read_from_clipboard()?;
147+
let rows = crate::clipboard::from_tsv(&text);
148+
if rows.is_empty() {
149+
return Ok(());
150+
}
151+
152+
let paste_cols = rows[0].len();
153+
let paste_rows = rows.len();
154+
let start = self.viewport.cursor();
155+
156+
self.wb
157+
.ensure_size(start.row + paste_rows, start.col + paste_cols);
158+
159+
for (r, row_cells) in rows.iter().enumerate() {
160+
let target_row = start.row + r;
161+
if target_row >= self.wb.rows {
162+
break;
163+
}
164+
for (c, cell_text) in row_cells.iter().enumerate() {
165+
let target_col = start.col + c;
166+
if target_col >= self.wb.columns {
167+
break;
168+
}
169+
let addr = CellAddress {
170+
row: target_row,
171+
col: target_col,
172+
};
173+
if cell_text.is_empty() {
174+
self.wb.clear_cell(addr);
175+
} else {
176+
self.wb.set_text(addr, cell_text.clone());
177+
}
178+
}
179+
}
180+
181+
let end_row = (start.row + paste_rows).min(self.wb.rows).saturating_sub(1);
182+
let end_col = (start.col + paste_cols)
183+
.min(self.wb.columns)
184+
.saturating_sub(1);
185+
186+
self.copied_region = None;
187+
self.set_selection(Selection::Range {
188+
anchor: start,
189+
cursor: CellAddress {
190+
row: end_row,
191+
col: end_col,
192+
},
193+
});
194+
195+
Ok(())
196+
}
197+
102198
/// 清空当前选区,并退出选区状态。
103199
pub fn clear_selection_cells(&mut self) {
104200
if let Some(spec) = self.selection_clear_spec() {
@@ -135,4 +231,42 @@ impl TableContext {
135231
},
136232
})
137233
}
234+
235+
fn collect_selection(&self) -> Vec<Vec<String>> {
236+
match &self.selection {
237+
None => {
238+
vec![vec![self.current_cell_raw()]]
239+
}
240+
Some(Selection::Range { anchor, cursor }) => {
241+
let r1 = anchor.row.min(cursor.row);
242+
let r2 = anchor.row.max(cursor.row);
243+
let c1 = anchor.col.min(cursor.col);
244+
let c2 = anchor.col.max(cursor.col);
245+
(r1..=r2)
246+
.map(|r| (c1..=c2).map(|c| self.cell_raw_at(r, c)).collect())
247+
.collect()
248+
}
249+
Some(Selection::Row(r)) => {
250+
let r = *r;
251+
vec![
252+
(0..self.wb.columns)
253+
.map(|c| self.cell_raw_at(r, c))
254+
.collect(),
255+
]
256+
}
257+
Some(Selection::Column(c)) => {
258+
let c = *c;
259+
(0..self.wb.rows)
260+
.map(|r| vec![self.cell_raw_at(r, c)])
261+
.collect()
262+
}
263+
}
264+
}
265+
266+
fn cell_raw_at(&self, row: usize, col: usize) -> String {
267+
self.wb
268+
.get_cell(CellAddress { row, col })
269+
.map(|cell| cell.raw.clone())
270+
.unwrap_or_default()
271+
}
138272
}

src/screen/editor/host.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ impl ModeHost {
9494
self.mode = Box::new(DeleteMode::new());
9595
return Some(EventResult::Handled);
9696
}
97+
if Self::is_ctrl_c(key) && self.mode.kind() == ModeKind::Navigation {
98+
let _ = ctx.copy_selection();
99+
return Some(EventResult::Handled);
100+
}
101+
if Self::is_ctrl_v(key) && self.mode.kind() == ModeKind::Navigation {
102+
let _ = ctx.paste_from_clipboard();
103+
return Some(EventResult::Handled);
104+
}
97105
None
98106
}
99107

@@ -108,4 +116,12 @@ impl ModeHost {
108116
fn is_ctrl_d(key: KeyEvent) -> bool {
109117
key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL)
110118
}
119+
120+
fn is_ctrl_c(key: KeyEvent) -> bool {
121+
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
122+
}
123+
124+
fn is_ctrl_v(key: KeyEvent) -> bool {
125+
key.code == KeyCode::Char('v') && key.modifiers.contains(KeyModifiers::CONTROL)
126+
}
111127
}

src/screen/editor/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ impl Screen for TableScreen {
7070

7171
let edit_buffer = self.host.edit_buffer();
7272
let selection = self.ctx.selection();
73+
let copied_region = self.ctx.copied_region();
7374
TableGrid::render(
7475
frame,
7576
inner,
@@ -79,6 +80,7 @@ impl Screen for TableScreen {
7980
theme: self.ctx.theme,
8081
edit_buffer,
8182
selection,
83+
copied_region,
8284
},
8385
);
8486
}

0 commit comments

Comments
 (0)