Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/escape-sequence-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
133 changes: 133 additions & 0 deletions docs/docs/features/osc-53-selection-protocol.md
Original file line number Diff line number Diff line change
@@ -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 ; <operation> ; <parameters> ST
```

Where:
- `OSC` = `ESC ]` (0x1B 0x5D)
- `ST` = String Terminator (`ESC \` or `BEL`)
- `<operation>` = Operation code (see Operations section)
- `<parameters>` = Operation-specific parameters

## Operations

### Set Selection (operation: `s`)

Defines a text selection region using start and end coordinates.

```
OSC 53 ; s ; <start_row>,<start_col>;<end_row>,<end_col> 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 ; <start_row>,<start_col>;<end_row>,<end_col> 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.
118 changes: 118 additions & 0 deletions rio-backend/src/crosswords/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3035,6 +3035,124 @@ impl<U: EventListener> Handler for Crosswords<U> {
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 {
Expand Down
82 changes: 82 additions & 0 deletions rio-backend/src/performer/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -909,6 +928,69 @@ impl<U: Handler, T: Timeout> 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::<i32>(),
start_parts[1].parse::<u16>(),
end_parts[0].parse::<i32>(),
end_parts[1].parse::<u16>(),
) {
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() {
Expand Down
Loading