Better text selection
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user