Skip to content

Commit 5807fcf

Browse files
trepidityclaude
andcommitted
Add mouse text selection and right-click paste support
Since EnableMouseCapture prevents native terminal text selection, this adds buffer-based selection (left-click drag with visual highlight, auto-copy to system clipboard on release) and right-click paste into any active text input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4329e86 commit 5807fcf

16 files changed

Lines changed: 568 additions & 3 deletions

Cargo.lock

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

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ clap = { version = "4", features = ["derive"] }
5757
# Credentials
5858
keyring = "3"
5959

60+
# Clipboard
61+
arboard = "3"
62+
6063
# Utilities
6164
base64 = "0.22"
6265
chrono = { version = "0.4", features = ["serde"] }

crates/loom-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ strum = { workspace = true }
2424
anyhow = { workspace = true }
2525
tracing = { workspace = true }
2626
chrono = { workspace = true }
27+
arboard = { workspace = true }

crates/loom-tui/src/action.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ pub enum Action {
150150
ConnMgrDelete(usize), // delete saved profile by index
151151
ConnMgrConnect(usize), // connect from connections manager
152152

153+
// Paste
154+
PasteText(String),
155+
153156
// No-op
154157
None,
155158
}

crates/loom-tui/src/app.rs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use std::sync::Arc;
22
use std::time::Duration;
33

44
use crossterm::event::MouseEventKind;
5+
use ratatui::buffer::Buffer;
56
use ratatui::layout::{Constraint, Layout, Rect};
7+
use ratatui::style::Style;
68
use tokio::sync::Mutex;
79
use tracing::{debug, error};
810

@@ -52,6 +54,14 @@ enum DragTarget {
5254
Detail,
5355
}
5456

57+
/// Mouse text selection state.
58+
#[derive(Debug, Clone)]
59+
struct TextSelection {
60+
anchor: (u16, u16),
61+
end: (u16, u16),
62+
text: Option<String>,
63+
}
64+
5565
/// Backend for a connection tab — either live LDAP or offline/example.
5666
enum TabBackend {
5767
Live(Arc<Mutex<LdapConnection>>),
@@ -133,6 +143,10 @@ pub struct App {
133143
detail_split_pct: u16, // top panels height as % of content area
134144
drag_target: Option<DragTarget>,
135145

146+
// Mouse text selection & clipboard
147+
text_selection: Option<TextSelection>,
148+
clipboard: Option<arboard::Clipboard>,
149+
136150
// Async communication
137151
action_tx: tokio::sync::mpsc::UnboundedSender<Action>,
138152
action_rx: tokio::sync::mpsc::UnboundedReceiver<Action>,
@@ -187,6 +201,8 @@ impl App {
187201
tree_split_pct: 25,
188202
detail_split_pct: 75,
189203
drag_target: None,
204+
text_selection: None,
205+
clipboard: arboard::Clipboard::new().ok(),
190206
action_tx,
191207
action_rx,
192208
}
@@ -1012,20 +1028,31 @@ impl App {
10121028
}
10131029

10141030
fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) -> Action {
1015-
// Popups block mouse events; also clear any drag
1031+
// Popups block mouse events; also clear any drag/selection
10161032
if self.popup_active() {
10171033
self.drag_target = None;
1034+
self.text_selection = None;
10181035
return Action::None;
10191036
}
10201037

10211038
match mouse.kind {
10221039
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
1040+
// Clear any previous selection
1041+
self.text_selection = None;
1042+
10231043
// Check if click is on a panel divider (start drag)
10241044
if let Some(target) = self.divider_hit(mouse.column, mouse.row) {
10251045
self.drag_target = Some(target);
10261046
return Action::None;
10271047
}
10281048

1049+
// Start a text selection
1050+
self.text_selection = Some(TextSelection {
1051+
anchor: (mouse.column, mouse.row),
1052+
end: (mouse.column, mouse.row),
1053+
text: None,
1054+
});
1055+
10291056
let pos = Rect::new(mouse.column, mouse.row, 1, 1);
10301057

10311058
// Check layout bar clicks
@@ -1076,13 +1103,40 @@ impl App {
10761103
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
10771104
if let Some(target) = self.drag_target {
10781105
self.apply_drag(target, mouse.column, mouse.row);
1106+
} else if let Some(ref mut sel) = self.text_selection {
1107+
sel.end = (mouse.column, mouse.row);
1108+
}
1109+
Action::None
1110+
}
1111+
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
1112+
self.drag_target = None;
1113+
// Copy selected text to clipboard on mouse-up
1114+
if let Some(ref sel) = self.text_selection {
1115+
if let Some(ref text) = sel.text {
1116+
if !text.is_empty() {
1117+
if let Some(ref mut cb) = self.clipboard {
1118+
let _ = cb.set_text(text.clone());
1119+
}
1120+
}
1121+
}
10791122
}
10801123
Action::None
10811124
}
10821125
MouseEventKind::Up(_) => {
10831126
self.drag_target = None;
10841127
Action::None
10851128
}
1129+
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
1130+
// Right-click paste
1131+
if let Some(ref mut cb) = self.clipboard {
1132+
if let Ok(text) = cb.get_text() {
1133+
if !text.is_empty() {
1134+
return Action::PasteText(text);
1135+
}
1136+
}
1137+
}
1138+
Action::None
1139+
}
10861140
_ => Action::None,
10871141
}
10881142
}
@@ -1786,6 +1840,10 @@ impl App {
17861840
}
17871841
}
17881842

1843+
Action::PasteText(text) => {
1844+
self.paste_into_active_input(&text);
1845+
}
1846+
17891847
Action::Tick => {
17901848
// Dispatch tick to attribute editor for debounced DN search
17911849
if self.attribute_editor.visible {
@@ -2008,9 +2066,107 @@ impl App {
20082066
if self.log_panel.visible {
20092067
self.log_panel.render(frame, full);
20102068
}
2069+
2070+
// Draw text selection overlay (after all widgets/popups)
2071+
if let Some(ref sel) = self.text_selection {
2072+
if sel.anchor != sel.end {
2073+
let style = self.theme.selection_highlight;
2074+
extract_and_highlight_selection(
2075+
frame.buffer_mut(),
2076+
&mut self.text_selection,
2077+
style,
2078+
);
2079+
}
2080+
}
2081+
}
2082+
2083+
fn paste_into_active_input(&mut self, text: &str) {
2084+
if self.attribute_editor.visible {
2085+
self.attribute_editor.paste_text(text);
2086+
} else if self.attribute_picker.visible {
2087+
self.attribute_picker.paste_text(text);
2088+
} else if self.new_connection_dialog.visible {
2089+
self.new_connection_dialog.paste_text(text);
2090+
} else if self.credential_prompt.visible {
2091+
self.credential_prompt.paste_text(text);
2092+
} else if self.search_dialog.visible {
2093+
// search_dialog has no text input
2094+
} else if self.export_dialog.visible {
2095+
self.export_dialog.paste_text(text);
2096+
} else if self.bulk_update_dialog.visible {
2097+
self.bulk_update_dialog.paste_text(text);
2098+
} else if self.create_entry_dialog.visible {
2099+
self.create_entry_dialog.paste_text(text);
2100+
} else if self.schema_viewer.visible {
2101+
self.schema_viewer.paste_text(text);
2102+
} else if self.command_panel.input_active
2103+
&& self.active_layout == ActiveLayout::Browser
2104+
{
2105+
self.command_panel.paste_text(text);
2106+
} else if self.connection_form.is_editing()
2107+
&& self.active_layout == ActiveLayout::Profiles
2108+
{
2109+
self.connection_form.paste_text(text);
2110+
}
20112111
}
20122112
}
20132113

2114+
fn extract_and_highlight_selection(
2115+
buf: &mut Buffer,
2116+
sel: &mut Option<TextSelection>,
2117+
style: Style,
2118+
) {
2119+
let selection = match sel.as_mut() {
2120+
Some(s) => s,
2121+
None => return,
2122+
};
2123+
2124+
let (ax, ay) = selection.anchor;
2125+
let (ex, ey) = selection.end;
2126+
2127+
// Normalize so start is top-left
2128+
let (start_row, start_col, end_row, end_col) = if (ay, ax) <= (ey, ex) {
2129+
(ay, ax, ey, ex)
2130+
} else {
2131+
(ey, ex, ay, ax)
2132+
};
2133+
2134+
let buf_area = buf.area;
2135+
let mut lines: Vec<String> = Vec::new();
2136+
2137+
for row in start_row..=end_row {
2138+
if row < buf_area.y || row >= buf_area.y + buf_area.height {
2139+
continue;
2140+
}
2141+
let col_start = if row == start_row { start_col } else { buf_area.x };
2142+
let col_end = if row == end_row {
2143+
end_col
2144+
} else {
2145+
buf_area.x + buf_area.width - 1
2146+
};
2147+
2148+
let mut line = String::new();
2149+
for col in col_start..=col_end {
2150+
if col < buf_area.x || col >= buf_area.x + buf_area.width {
2151+
continue;
2152+
}
2153+
let cell = &buf[(col, row)];
2154+
let sym = cell.symbol();
2155+
// Skip empty symbols (wide-char continuations)
2156+
if !sym.is_empty() && sym != "\0" {
2157+
line.push_str(sym);
2158+
}
2159+
// Apply highlight style
2160+
buf[(col, row)].set_style(style);
2161+
}
2162+
// Trim trailing whitespace from each line
2163+
let trimmed = line.trim_end().to_string();
2164+
lines.push(trimmed);
2165+
}
2166+
2167+
selection.text = Some(lines.join("\n"));
2168+
}
2169+
20142170
/// Resolve password from the connection profile's credential method.
20152171
/// Returns empty string for Prompt method when LOOM_PASSWORD is not set,
20162172
/// which signals the caller to show an interactive credential prompt.

crates/loom-tui/src/components/attribute_editor.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ impl AttributeEditor {
144144
}
145145
}
146146

147+
pub fn paste_text(&mut self, text: &str) {
148+
if self.focus == EditorFocus::Input {
149+
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
150+
self.input_buffer.insert_str(self.cursor_pos, &filtered);
151+
self.cursor_pos += filtered.len();
152+
if self.is_dn_search {
153+
self.search_dirty = true;
154+
}
155+
}
156+
}
157+
147158
pub fn hide(&mut self) {
148159
self.visible = false;
149160
self.op = None;

crates/loom-tui/src/components/attribute_picker.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ impl AttributePicker {
4848
}
4949

5050
/// Open the picker with the given DN and candidate attributes.
51+
pub fn paste_text(&mut self, text: &str) {
52+
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
53+
self.input.insert_str(self.cursor, &filtered);
54+
self.cursor += filtered.len();
55+
self.refilter();
56+
}
57+
5158
pub fn show(&mut self, dn: String, candidates: Vec<(String, String)>) {
5259
self.dn = dn;
5360
self.input.clear();

crates/loom-tui/src/components/bulk_update_dialog.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ impl BulkUpdateDialog {
6868
}
6969
}
7070

71+
pub fn paste_text(&mut self, text: &str) {
72+
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
73+
self.active_buffer_mut().push_str(&filtered);
74+
}
75+
7176
pub fn show(&mut self) {
7277
self.filter.clear();
7378
self.attribute.clear();

crates/loom-tui/src/components/command_panel.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ pub struct StatusMessage {
2424
}
2525

2626
impl CommandPanel {
27+
pub fn paste_text(&mut self, text: &str) {
28+
if self.input_active {
29+
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
30+
self.input_buffer.push_str(&filtered);
31+
}
32+
}
33+
2734
pub fn new(theme: Theme) -> Self {
2835
Self {
2936
messages: Vec::new(),

crates/loom-tui/src/components/connection_form.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,24 @@ impl ConnectionForm {
253253
}
254254
}
255255

256+
pub fn paste_text(&mut self, text: &str) {
257+
if !self.is_editing() {
258+
return;
259+
}
260+
let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
261+
if matches!(
262+
self.active_field,
263+
Field::Port | Field::PageSize | Field::Timeout
264+
) {
265+
let digits: String = filtered.chars().filter(|c| c.is_ascii_digit()).collect();
266+
if let Some(buf) = self.active_buffer_mut() {
267+
buf.push_str(&digits);
268+
}
269+
} else if let Some(buf) = self.active_buffer_mut() {
270+
buf.push_str(&filtered);
271+
}
272+
}
273+
256274
fn active_buffer_mut(&mut self) -> Option<&mut String> {
257275
match self.active_field {
258276
Field::Name => Some(&mut self.name),

0 commit comments

Comments
 (0)