@@ -2,7 +2,9 @@ use std::sync::Arc;
22use std:: time:: Duration ;
33
44use crossterm:: event:: MouseEventKind ;
5+ use ratatui:: buffer:: Buffer ;
56use ratatui:: layout:: { Constraint , Layout , Rect } ;
7+ use ratatui:: style:: Style ;
68use tokio:: sync:: Mutex ;
79use 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.
5666enum 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.
0 commit comments