Better text selection

This commit is contained in:
2026-03-21 01:55:06 -04:00
parent 84077b718f
commit 6954c8c74d
7 changed files with 844 additions and 159 deletions

View File

@@ -99,6 +99,14 @@ fn log_platform_event(event: &PlatformEvent) {
"internal wake event received"
);
}
PlatformEvent::ClipboardText { window_id, text } => {
tracing::debug!(
event = "clipboard_text",
window_id = window_id.raw(),
text,
"clipboard text received"
);
}
PlatformEvent::PrimarySelectionText { window_id, text } => {
tracing::debug!(
event = "primary_selection_text",

View File

@@ -69,6 +69,10 @@ pub enum PlatformEvent {
window_id: WindowId,
event: KeyboardEvent,
},
ClipboardText {
window_id: WindowId,
text: String,
},
PrimarySelectionText {
window_id: WindowId,
text: String,
@@ -107,6 +111,13 @@ pub enum PlatformRequest {
window_id: WindowId,
scene: SceneSnapshot,
},
SetClipboardText {
window_id: WindowId,
text: String,
},
RequestClipboardText {
window_id: WindowId,
},
SetPrimarySelectionText {
window_id: WindowId,
text: String,
@@ -254,6 +265,21 @@ impl PlatformProxy {
self.send(PlatformRequest::RequestPrimarySelectionText { window_id })
}
pub fn set_clipboard_text(
&self,
window_id: WindowId,
text: impl Into<String>,
) -> Result<(), PlatformClosed> {
self.send(PlatformRequest::SetClipboardText {
window_id,
text: text.into(),
})
}
pub fn request_clipboard_text(&self, window_id: WindowId) -> Result<(), PlatformClosed> {
self.send(PlatformRequest::RequestClipboardText { window_id })
}
pub fn set_cursor_icon(
&self,
window_id: WindowId,
@@ -328,6 +354,8 @@ pub fn start_headless() -> PlatformRuntime {
PlatformRequest::ReplaceScene { window_id, scene } => {
handle_replace_scene(&state, window_id, scene);
}
PlatformRequest::SetClipboardText { .. } => {}
PlatformRequest::RequestClipboardText { .. } => {}
PlatformRequest::SetPrimarySelectionText { .. } => {}
PlatformRequest::RequestPrimarySelectionText { .. } => {}
PlatformRequest::SetCursorIcon { .. } => {}

View File

@@ -117,6 +117,16 @@ impl WindowController {
self.proxy.set_primary_selection_text(self.id, text)
}
/// Copies plain text to the platform clipboard for this window.
pub fn set_clipboard_text(&self, text: impl Into<String>) -> Result<(), PlatformClosed> {
self.proxy.set_clipboard_text(self.id, text)
}
/// Requests the current plain-text clipboard contents from the platform.
pub fn request_clipboard_text(&self) -> Result<(), PlatformClosed> {
self.proxy.request_clipboard_text(self.id)
}
/// Requests the current plain-text primary selection contents from the platform.
pub fn request_primary_selection_text(&self) -> Result<(), PlatformClosed> {
self.proxy.request_primary_selection_text(self.id)

View File

@@ -240,6 +240,77 @@ impl PreparedText {
))
}
pub fn previous_char_boundary(&self, offset: usize) -> usize {
let offset = offset.min(self.text.len());
self.text[..offset]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0)
}
pub fn next_char_boundary(&self, offset: usize) -> usize {
let offset = offset.min(self.text.len());
if offset >= self.text.len() {
return self.text.len();
}
self.text
.char_indices()
.find_map(|(index, _)| (index > offset).then_some(index))
.unwrap_or(self.text.len())
}
pub fn line_start_offset(&self, offset: usize) -> Option<usize> {
Some(self.line_for_offset(offset)?.text_start)
}
pub fn line_end_offset(&self, offset: usize) -> Option<usize> {
Some(self.line_for_offset(offset)?.text_end)
}
pub fn vertical_offset(&self, offset: usize, line_delta: isize) -> Option<usize> {
if line_delta == 0 {
return Some(offset.min(self.text.len()));
}
let offset = offset.min(self.text.len());
let line = self.line_for_offset(offset)?;
let current_index = self.lines.iter().position(|candidate| candidate == line)?;
let next_index = current_index.checked_add_signed(line_delta)?;
let next_line = self.lines.get(next_index)?;
let target_x = self.caret_x_for_line_offset(line, offset);
Some(self.byte_offset_for_line_position(next_line, target_x))
}
pub fn word_range_for_offset(&self, offset: usize) -> Range<usize> {
if self.text.is_empty() {
return 0..0;
}
let offset = offset.min(self.text.len());
let target = self.word_class_offset(offset);
let Some((target_start, target_ch)) = self.char_at(target) else {
return 0..self.text.len();
};
let target_class = classify_word_char(target_ch);
let mut start = target_start;
while let Some((previous_start, previous_ch)) = self.char_before(start) {
if classify_word_char(previous_ch) != target_class {
break;
}
start = previous_start;
}
let mut end = target_start + target_ch.len_utf8();
while let Some((next_start, next_ch)) = self.char_at(end) {
if classify_word_char(next_ch) != target_class {
break;
}
end = next_start + next_ch.len_utf8();
}
start..end
}
pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
let Some(selected_color) = self.selection_style.text_color else {
return;
@@ -336,6 +407,50 @@ impl PreparedText {
.map(|glyph| glyph.position.x + glyph.advance.max(0.0))
.unwrap_or(line.rect.origin.x)
}
fn char_at(&self, offset: usize) -> Option<(usize, char)> {
if offset >= self.text.len() {
return None;
}
self.text[offset..].chars().next().map(|ch| (offset, ch))
}
fn char_before(&self, offset: usize) -> Option<(usize, char)> {
let offset = offset.min(self.text.len());
self.text[..offset].char_indices().last()
}
fn word_class_offset(&self, offset: usize) -> usize {
if offset >= self.text.len() {
return self.previous_char_boundary(offset);
}
if offset > 0
&& let (Some((_, previous)), Some((_, current))) =
(self.char_before(offset), self.char_at(offset))
&& classify_word_char(current) == WordClass::Whitespace
&& classify_word_char(previous) == WordClass::Word
{
return self.previous_char_boundary(offset);
}
offset
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WordClass {
Word,
Whitespace,
Punctuation,
}
fn classify_word_char(ch: char) -> WordClass {
if ch.is_whitespace() {
WordClass::Whitespace
} else if ch.is_alphanumeric() || ch == '_' {
WordClass::Word
} else {
WordClass::Punctuation
}
}
#[derive(Clone, Debug, PartialEq)]
@@ -484,4 +599,58 @@ mod tests {
Some(Rect::new(42.0, 20.0, 2.0, 16.0))
);
}
#[test]
fn prepared_text_word_range_tracks_words_and_punctuation() {
let text = PreparedText::monospace(
"alpha beta, gamma",
Point::new(10.0, 20.0),
16.0,
8.0,
Color::rgb(0xFF, 0xFF, 0xFF),
);
assert_eq!(text.word_range_for_offset(1), 0..5);
assert_eq!(text.word_range_for_offset(6), 6..10);
assert_eq!(text.word_range_for_offset(10), 10..11);
assert_eq!(text.word_range_for_offset(text.text.len()), 12..17);
}
#[test]
fn prepared_text_vertical_offset_moves_between_lines() {
let mut text = PreparedText::monospace(
"abcdwxyz",
Point::new(10.0, 20.0),
16.0,
8.0,
Color::rgb(0xFF, 0xFF, 0xFF),
);
text.lines = vec![
super::PreparedTextLine {
rect: Rect::new(10.0, 20.0, 32.0, 16.0),
text_start: 0,
text_end: 4,
glyph_start: 0,
glyph_end: 4,
},
super::PreparedTextLine {
rect: Rect::new(10.0, 36.0, 32.0, 16.0),
text_start: 4,
text_end: 8,
glyph_start: 4,
glyph_end: 8,
},
];
for (index, glyph) in text.glyphs.iter_mut().enumerate() {
if index >= 4 {
glyph.position.y = 36.0;
glyph.position.x = 10.0 + ((index - 4) as f32 * 8.0);
}
}
assert_eq!(text.vertical_offset(2, 1), Some(6));
assert_eq!(text.vertical_offset(6, -1), Some(2));
assert_eq!(text.line_start_offset(6), Some(4));
assert_eq!(text.line_end_offset(2), Some(4));
}
}

View File

@@ -29,7 +29,8 @@ use tracing::Level;
use tracing::{debug, trace};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{
wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_surface,
wl_callback, wl_compositor, wl_data_device, wl_data_device_manager, wl_data_offer,
wl_data_source, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_surface,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop, event_created_child,
@@ -117,6 +118,8 @@ struct WindowWorkerState {
enum WindowWorkerCommand {
ReplaceScene(SceneSnapshot),
SetClipboardText(String),
RequestClipboardText,
SetPrimarySelectionText(String),
RequestPrimarySelectionText,
SetCursorIcon(CursorIcon),
@@ -137,6 +140,8 @@ struct State {
_toplevel: xdg_toplevel::XdgToplevel,
_wm_base: xdg_wm_base::XdgWmBase,
_seat: wl_seat::WlSeat,
clipboard_manager: Option<wl_data_device_manager::WlDataDeviceManager>,
clipboard_device: Option<wl_data_device::WlDataDevice>,
cursor_shape_manager: Option<wp_cursor_shape_manager_v1::WpCursorShapeManagerV1>,
cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
primary_selection_manager:
@@ -161,6 +166,10 @@ struct State {
keyboard_repeat_rate: i32,
keyboard_repeat_delay: Duration,
keyboard_repeat: Option<KeyboardRepeatState>,
clipboard_source: Option<wl_data_source::WlDataSource>,
clipboard_text: Option<String>,
clipboard_offer: Option<wl_data_offer::WlDataOffer>,
clipboard_offer_mime_types: Vec<String>,
primary_selection_source: Option<zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1>,
primary_selection_text: Option<String>,
primary_selection_offer: Option<zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1>,
@@ -234,6 +243,14 @@ fn keyboard_modifiers_from_xkb(state: &xkb::State) -> KeyboardModifiers {
}
}
fn preferred_plain_text_mime(mime_types: &[String]) -> Option<String> {
mime_types
.iter()
.find(|mime| mime.as_str() == "text/plain;charset=utf-8")
.or_else(|| mime_types.iter().find(|mime| mime.as_str() == "text/plain"))
.cloned()
}
fn keyboard_text_for_xkb(state: &xkb::State, keycode: xkb::Keycode) -> Option<String> {
let text = state.key_get_utf8(keycode);
if text.is_empty() || text.chars().any(char::is_control) {
@@ -276,6 +293,12 @@ impl WaylandWindow {
let compositor: wl_compositor::WlCompositor = globals.bind(&qh, 4..=6, ())?;
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=9, ())?;
let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
let clipboard_manager = globals.bind(&qh, 1..=3, ()).ok();
let clipboard_device = clipboard_manager
.as_ref()
.map(|manager: &wl_data_device_manager::WlDataDeviceManager| {
manager.get_data_device(&seat, &qh, ())
});
let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok();
let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok();
let primary_selection_device = primary_selection_manager.as_ref().map(
@@ -324,6 +347,8 @@ impl WaylandWindow {
_toplevel: toplevel,
_wm_base: wm_base,
_seat: seat,
clipboard_manager,
clipboard_device,
cursor_shape_manager,
cursor_shape_device: None,
primary_selection_manager,
@@ -347,6 +372,10 @@ impl WaylandWindow {
keyboard_repeat_rate: 25,
keyboard_repeat_delay: Duration::from_millis(500),
keyboard_repeat: None,
clipboard_source: None,
clipboard_text: None,
clipboard_offer: None,
clipboard_offer_mime_types: Vec::new(),
primary_selection_source: None,
primary_selection_text: None,
primary_selection_offer: None,
@@ -496,6 +525,64 @@ impl WaylandWindow {
self.state.request_redraw();
}
pub fn set_clipboard_text(&mut self, text: impl Into<String>) -> Result<(), Box<dyn Error>> {
let text = text.into();
let Some(clipboard_manager) = self.state.clipboard_manager.as_ref() else {
debug!(
target: "ruin_ui_platform_wayland::clipboard",
"Wayland compositor does not expose wl_data_device_manager; skipping clipboard copy"
);
return Ok(());
};
let Some(clipboard_device) = self.state.clipboard_device.as_ref() else {
debug!(
target: "ruin_ui_platform_wayland::clipboard",
"Wayland seat does not expose a clipboard data device; skipping clipboard copy"
);
return Ok(());
};
let Some(serial) = self.state.last_selection_serial else {
return Err(Box::new(std::io::Error::other(
"clipboard copy requires a recent input serial",
)));
};
let source = clipboard_manager.create_data_source(&self.state.qh, ());
source.offer("text/plain;charset=utf-8".to_owned());
source.offer("text/plain".to_owned());
clipboard_device.set_selection(Some(&source), serial);
self.state.clipboard_source = Some(source);
self.state.clipboard_text = Some(text);
self.state._connection.flush()?;
Ok(())
}
pub fn read_clipboard_text(&mut self) -> Result<Option<String>, Box<dyn Error>> {
let preferred_mime = preferred_plain_text_mime(&self.state.clipboard_offer_mime_types);
let Some(mime_type) = preferred_mime else {
return Ok(self.state.clipboard_text.clone());
};
let Some(offer) = self.state.clipboard_offer.as_ref() else {
return Ok(self.state.clipboard_text.clone());
};
let mut pipe_fds = [0; 2];
let pipe_result = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
if pipe_result != 0 {
return Err(Box::new(std::io::Error::last_os_error()));
}
let read_fd = unsafe { OwnedFd::from_raw_fd(pipe_fds[0]) };
let write_fd = unsafe { OwnedFd::from_raw_fd(pipe_fds[1]) };
offer.receive(mime_type, write_fd.as_fd());
self.state._connection.flush()?;
drop(write_fd);
let mut file = File::from(read_fd);
let mut text = String::new();
file.read_to_string(&mut text)?;
Ok((!text.is_empty()).then_some(text))
}
pub fn set_primary_selection_text(
&mut self,
text: impl Into<String>,
@@ -532,18 +619,7 @@ impl WaylandWindow {
}
pub fn read_primary_selection_text(&mut self) -> Result<Option<String>, Box<dyn Error>> {
let preferred_mime = self
.state
.primary_selection_offer_mime_types
.iter()
.find(|mime| mime.as_str() == "text/plain;charset=utf-8")
.or_else(|| {
self.state
.primary_selection_offer_mime_types
.iter()
.find(|mime| mime.as_str() == "text/plain")
})
.cloned();
let preferred_mime = preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types);
let Some(mime_type) = preferred_mime else {
return Ok(self.state.primary_selection_text.clone());
};
@@ -677,6 +753,12 @@ fn run_wayland_platform(mut endpoint: PlatformEndpoint) {
PlatformRequest::ReplaceScene { window_id, scene } => {
handle_replace_scene(&state, window_id, scene);
}
PlatformRequest::SetClipboardText { window_id, text } => {
handle_set_clipboard_text(&state, window_id, text);
}
PlatformRequest::RequestClipboardText { window_id } => {
handle_request_clipboard_text(&state, window_id);
}
PlatformRequest::SetPrimarySelectionText { window_id, text } => {
handle_set_primary_selection_text(&state, window_id, text);
}
@@ -854,6 +936,22 @@ fn handle_set_primary_selection_text(
}
}
fn handle_set_clipboard_text(
state: &Rc<RefCell<WaylandBackendState>>,
window_id: WindowId,
text: String,
) {
let command_tx = state.borrow().windows.get(&window_id).and_then(|record| {
record
.worker
.as_ref()
.map(|worker| worker.command_tx.clone())
});
if let Some(command_tx) = command_tx {
let _ = command_tx.send(WindowWorkerCommand::SetClipboardText(text));
}
}
fn handle_request_primary_selection_text(
state: &Rc<RefCell<WaylandBackendState>>,
window_id: WindowId,
@@ -869,6 +967,18 @@ fn handle_request_primary_selection_text(
}
}
fn handle_request_clipboard_text(state: &Rc<RefCell<WaylandBackendState>>, window_id: WindowId) {
if let Some(command_tx) = state
.borrow()
.windows
.get(&window_id)
.and_then(|record| record.worker.as_ref())
.map(|worker| worker.command_tx.clone())
{
let _ = command_tx.send(WindowWorkerCommand::RequestClipboardText);
}
}
fn handle_set_cursor_icon(
state: &Rc<RefCell<WaylandBackendState>>,
window_id: WindowId,
@@ -972,6 +1082,37 @@ fn spawn_window_worker(
state_ref.latest_scene = Some(scene);
state_ref.window.request_redraw();
}
WindowWorkerCommand::SetClipboardText(text) => {
let mut state_ref = state.borrow_mut();
if let Err(error) = state_ref.window.set_clipboard_text(text) {
debug!(
target: "ruin_ui_platform_wayland::clipboard",
window_id = state_ref.window_id.raw(),
error = %error,
"failed to set clipboard text"
);
}
}
WindowWorkerCommand::RequestClipboardText => {
let mut state_ref = state.borrow_mut();
match state_ref.window.read_clipboard_text() {
Ok(Some(text)) => {
let _ = state_ref.event_tx.send(PlatformEvent::ClipboardText {
window_id: state_ref.window_id,
text,
});
}
Ok(None) => {}
Err(error) => {
debug!(
target: "ruin_ui_platform_wayland::clipboard",
window_id = state_ref.window_id.raw(),
error = %error,
"failed to read clipboard text"
);
}
}
}
WindowWorkerCommand::SetPrimarySelectionText(text) => {
let mut state_ref = state.borrow_mut();
if let Err(error) =
@@ -1284,6 +1425,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
delegate_noop!(State: ignore wl_compositor::WlCompositor);
delegate_noop!(State: ignore wl_surface::WlSurface);
delegate_noop!(State: ignore wl_data_device_manager::WlDataDeviceManager);
delegate_noop!(State: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1);
delegate_noop!(State: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1);
delegate_noop!(
@@ -1516,6 +1658,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state);
}
wl_keyboard::Event::Key {
serial,
key,
state: key_state,
..
@@ -1547,6 +1690,9 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
}
WEnum::Value(_) | WEnum::Unknown(_) => return,
};
if matches!(kind, KeyboardEventKind::Pressed) {
state.last_selection_serial = Some(serial);
}
let keycode = xkb::Keycode::new(key + 8);
xkb_state.update_key(keycode, direction);
state.keyboard_modifiers = keyboard_modifiers_from_xkb(xkb_state);
@@ -1598,6 +1744,72 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
}
}
impl Dispatch<wl_data_device::WlDataDevice, ()> for State {
fn event(
state: &mut Self,
_data_device: &wl_data_device::WlDataDevice,
event: wl_data_device::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
if let wl_data_device::Event::Selection { id } = event {
state.clipboard_offer = id;
state.clipboard_offer_mime_types.clear();
}
}
event_created_child!(State, wl_data_device::WlDataDevice, [
wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ())
]);
}
impl Dispatch<wl_data_offer::WlDataOffer, ()> for State {
fn event(
state: &mut Self,
offer: &wl_data_offer::WlDataOffer,
event: wl_data_offer::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
if let wl_data_offer::Event::Offer { mime_type } = event
&& state.clipboard_offer.as_ref() == Some(offer)
{
state.clipboard_offer_mime_types.push(mime_type);
}
}
}
impl Dispatch<wl_data_source::WlDataSource, ()> for State {
fn event(
state: &mut Self,
data_source: &wl_data_source::WlDataSource,
event: wl_data_source::Event,
_data: &(),
_conn: &Connection,
_qh: &QueueHandle<Self>,
) {
match event {
wl_data_source::Event::Send { mime_type, fd } => {
if mime_type == "text/plain" || mime_type == "text/plain;charset=utf-8" {
let mut file = File::from(fd);
if let Some(text) = state.clipboard_text.as_deref() {
let _ = file.write_all(text.as_bytes());
}
}
}
wl_data_source::Event::Cancelled => {
if state.clipboard_source.as_ref() == Some(data_source) {
state.clipboard_source = None;
state.clipboard_text = None;
}
}
_ => {}
}
}
}
impl Dispatch<zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1, ()> for State {
fn event(
state: &mut Self,