diff --git a/docs/docs/escape-sequence-support.md b/docs/docs/escape-sequence-support.md index 4c468c6ecb..4a164a9e25 100644 --- a/docs/docs/escape-sequence-support.md +++ b/docs/docs/escape-sequence-support.md @@ -103,6 +103,7 @@ brevity. | `OSC 12` | IMPLEMENTED | | | `OSC 50` | IMPLEMENTED | Only `CursorShape` is supported | | `OSC 52` | IMPLEMENTED | Only Clipboard and primary selection supported | +| `OSC 53` | PROPOSED | [Terminal text selection protocol](/docs/features/osc-53-selection-protocol) | | `OSC 104` | IMPLEMENTED | | | `OSC 110` | IMPLEMENTED | | | `OSC 111` | IMPLEMENTED | | diff --git a/docs/docs/features/osc-53-selection-protocol.md b/docs/docs/features/osc-53-selection-protocol.md new file mode 100644 index 0000000000..0b15d6f8de --- /dev/null +++ b/docs/docs/features/osc-53-selection-protocol.md @@ -0,0 +1,133 @@ +--- +title: 'OSC 53 - Terminal Text Selection Protocol' +language: 'en' +--- + +# OSC 53 - Terminal Text Selection Protocol + +## Overview + +OSC 53 is a terminal control sequence protocol originally created by Rio Terminal to provide a standardized mechanism for applications to programmatically control text selection within the terminal viewport. This protocol enables precise selection of text regions using row and column coordinates, facilitating advanced terminal applications and automation workflows. + +## Protocol Origin + +This protocol was designed and first implemented by Rio Terminal to address the lack of a standard method for programmatic text selection in terminal emulators. While terminals have long supported mouse-based selection and clipboard operations via OSC 52, there was no standard way for applications to define selections programmatically. + +## Syntax + +``` +OSC 53 ; ; ST +``` + +Where: +- `OSC` = `ESC ]` (0x1B 0x5D) +- `ST` = String Terminator (`ESC \` or `BEL`) +- `` = Operation code (see Operations section) +- `` = Operation-specific parameters + +## Operations + +### Set Selection (operation: `s`) + +Defines a text selection region using start and end coordinates. + +``` +OSC 53 ; s ; ,;, ST +``` + +Parameters: +- `start_row`: Starting row (0-based, relative to viewport) +- `start_col`: Starting column (0-based) +- `end_row`: Ending row (0-based, relative to viewport) +- `end_col`: Ending column (0-based) + +Example: +``` +OSC 53 ; s ; 5,10;8,45 ST +``` +Selects text from row 5, column 10 to row 8, column 45. + +### Clear Selection (operation: `c`) + +Clears the current selection. + +``` +OSC 53 ; c ST +``` + +### Query Selection (operation: `q`) + +Requests the current selection coordinates. The terminal responds with: + +``` +OSC 53 ; r ; ,;, ST +``` + +If no selection exists, the terminal responds with: +``` +OSC 53 ; r ; none ST +``` + +### Copy Selection (operation: `y`) + +Copies the current selection to the system clipboard. + +``` +OSC 53 ; y ST +``` + +## Coordinate System + +- **Origin**: Top-left corner of the viewport is (0,0) +- **Row Range**: 0 to (visible_rows - 1) +- **Column Range**: 0 to (terminal_width - 1) +- **Direction**: Selection can be forward (start < end) or backward (start > end) +- **Scrollback**: Negative row values may be used to select into scrollback buffer (implementation-specific) + +## Selection Behavior + +1. **Boundary Handling**: Coordinates exceeding viewport boundaries are clamped to valid ranges +2. **Line Wrapping**: Selection respects line wrapping; wrapped lines are treated as continuous +3. **Double-Width Characters**: Column coordinates account for character cell width +4. **Empty Selection**: When start equals end, selection is cleared +5. **Visual Feedback**: Terminal should provide visual indication of selected text + +## Error Handling + +Invalid operations or malformed parameters should be silently ignored. No error response is generated to maintain backward compatibility with terminals that do not support this protocol. + +## Security Considerations + +1. **Clipboard Access**: Copy operations should respect system security policies +2. **Selection Limits**: Implementations may impose reasonable limits on selection size +3. **User Override**: Users should be able to disable programmatic selection via terminal preferences + +## Examples + +```bash +# Select entire first line +printf '\033]53;s;0,0;0,79\033\\' + +# Select rectangular region +printf '\033]53;s;10,20;15,40\033\\' + +# Clear selection +printf '\033]53;c\033\\' + +# Query current selection +printf '\033]53;q\033\\' + +# Copy selection to clipboard +printf '\033]53;y\033\\' +``` + +## Implementation Notes + +- Terminals supporting this protocol should advertise capability via terminfo/termcap +- Selection operations should integrate with native terminal selection mechanisms +- Mouse selection should update the programmatic selection state accordingly +- The protocol is designed to be extensible for future operations + +## Adoption + +As the originator of this protocol, Rio Terminal provides the reference implementation. Other terminal emulators are encouraged to adopt this specification to provide consistent programmatic selection capabilities across different terminal environments. \ No newline at end of file diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs index bf493e039a..aabeaf9f97 100644 --- a/rio-backend/src/crosswords/mod.rs +++ b/rio-backend/src/crosswords/mod.rs @@ -3035,6 +3035,124 @@ impl Handler for Crosswords { self.event_proxy .send_event(RioEvent::PtyWrite(response), self.window_id); } + + #[inline] + fn set_text_selection( + &mut self, + start_row: i32, + start_col: u16, + end_row: i32, + end_col: u16, + ) { + debug!( + "OSC 53 set_text_selection: start=({},{}), end=({},{})", + start_row, start_col, end_row, end_col + ); + + // Convert viewport-relative coordinates to grid positions + // In Rio's coordinate system, viewport row 0 is Line(0) - display_offset + let display_offset = self.grid.display_offset() as i32; + let num_lines = self.grid.screen_lines(); + + debug!( + "Display offset: {}, num_lines: {}", + display_offset, num_lines + ); + + // Calculate actual grid positions + // Viewport coordinates need to be adjusted by subtracting display_offset + let start_line = Line(start_row) - display_offset; + let end_line = Line(end_row) - display_offset; + + // Clamp to valid grid bounds + let bottommost = self.grid.bottommost_line(); + let topmost = self.grid.topmost_line(); + + let start_line = start_line.max(topmost).min(bottommost); + let end_line = end_line.max(topmost).min(bottommost); + + let max_col = self.grid.columns() - 1; + let start_col = Column(start_col as usize).min(Column(max_col)); + let end_col = Column(end_col as usize).min(Column(max_col)); + + // Create selection + let start_pos = Pos::new(start_line, start_col); + let end_pos = Pos::new(end_line, end_col); + + debug!("Grid positions: start={:?}, end={:?}", start_pos, end_pos); + + // Clear selection if start equals end + if start_pos == end_pos { + self.selection = None; + self.mark_fully_damaged(); + debug!("Selection cleared (start == end)"); + } else { + // Determine the correct order + let (selection_start, selection_end) = if start_pos <= end_pos { + (start_pos, end_pos) + } else { + (end_pos, start_pos) + }; + + // Create a simple selection + let mut selection = + Selection::new(SelectionType::Simple, selection_start, Side::Left); + selection.update(selection_end, Side::Right); + + // Update selection and mark damaged + self.selection = Some(selection); + self.mark_fully_damaged(); + debug!( + "Selection set: {:?} to {:?}", + selection_start, selection_end + ); + } + } + + #[inline] + fn clear_text_selection(&mut self) { + if self.selection.is_some() { + self.selection = None; + self.mark_fully_damaged(); + } + } + + #[inline] + fn query_text_selection(&mut self, terminator: &str) { + let response = if let Some(ref selection) = self.selection { + // Get the selection range + if let Some(range) = selection.to_range(self) { + let display_offset = self.grid.display_offset() as i32; + + // Convert grid positions back to viewport-relative coordinates + // Grid position + display_offset = viewport row + let start_row = range.start.row.0 + display_offset; + let start_col = range.start.col.0; + let end_row = range.end.row.0 + display_offset; + let end_col = range.end.col.0; + + format!( + "\x1b]53;r;{},{};{},{}{}", + start_row, start_col, end_row, end_col, terminator + ) + } else { + format!("\x1b]53;r;none{}", terminator) + } + } else { + format!("\x1b]53;r;none{}", terminator) + }; + + self.event_proxy + .send_event(RioEvent::PtyWrite(response), self.window_id); + } + + #[inline] + fn copy_text_selection(&mut self) { + if let Some(text) = self.selection_to_string() { + // Use the existing clipboard store mechanism + self.clipboard_store(b'c', text.as_bytes()); + } + } } pub struct CrosswordsSize { diff --git a/rio-backend/src/performer/handler.rs b/rio-backend/src/performer/handler.rs index bce09c882c..d56216158a 100644 --- a/rio-backend/src/performer/handler.rs +++ b/rio-backend/src/performer/handler.rs @@ -394,6 +394,25 @@ pub trait Handler { /// Handle XTGETTCAP response. fn xtgettcap_response(&mut self, _response: String) {} + + /// Set text selection using row and column coordinates. + fn set_text_selection( + &mut self, + _start_row: i32, + _start_col: u16, + _end_row: i32, + _end_col: u16, + ) { + } + + /// Clear current text selection. + fn clear_text_selection(&mut self) {} + + /// Query current text selection coordinates. + fn query_text_selection(&mut self, _terminator: &str) {} + + /// Copy current text selection to clipboard. + fn copy_text_selection(&mut self) {} } pub trait Timeout: Default { @@ -909,6 +928,69 @@ impl copa::Perform for Performer<'_, U, T> { } } + // Terminal text selection protocol. + b"53" => { + if params.len() < 2 { + return unhandled(params); + } + + match params[1] { + // Set selection. + b"s" => { + if params.len() < 3 { + return unhandled(params); + } + + // Parse coordinates: start_row,start_col;end_row,end_col + if let Ok(coords_str) = simd_utf8::from_utf8_fast(params[2]) { + let parts: Vec<&str> = coords_str.split(';').collect(); + if parts.len() == 2 { + let start_parts: Vec<&str> = + parts[0].split(',').collect(); + let end_parts: Vec<&str> = parts[1].split(',').collect(); + + if start_parts.len() == 2 && end_parts.len() == 2 { + if let ( + Ok(start_row), + Ok(start_col), + Ok(end_row), + Ok(end_col), + ) = ( + start_parts[0].parse::(), + start_parts[1].parse::(), + end_parts[0].parse::(), + end_parts[1].parse::(), + ) { + self.handler.set_text_selection( + start_row, start_col, end_row, end_col, + ); + return; + } + } + } + } + unhandled(params); + } + + // Clear selection. + b"c" => { + self.handler.clear_text_selection(); + } + + // Query selection. + b"q" => { + self.handler.query_text_selection(terminator); + } + + // Copy selection. + b"y" => { + self.handler.copy_text_selection(); + } + + _ => unhandled(params), + } + } + b"104" => { // Reset all color indexes when no parameters are given. if params.len() == 1 || params[1].is_empty() {