An ltk app pastes what other applications copied, the middle button pastes the selection just made, a browser desktop's applications share the clipboard and the primary selection, and the office icons are drawn like the terminal's
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Pasting from other applications. Neither Ctrl+V nor the middle button pasted into an ltk app anything copied in another application; only what was copied within the same process pasted. The Wayland backend bound its `wl_data_device` and its primary-selection device in `SeatHandler::new_seat`, which smithay-client-toolkit calls only for seats announced after start-up: `SeatState::new` binds the seats already in the registry without calling it, and on every compositor the seat is there when the app connects. The app therefore never had either device, the compositor never offered it another client's selection, and what it copied did not reach other applications through the compositor either. A Wayland trace of prompt shows both managers bound and neither `get_data_device` nor `get_device` sent. Both devices are now bound in `new_capability`, which comes for every seat, the first time it does; `new_seat` does nothing.
The middle button and a selection just made. A compositor offers the primary selection to the window it gives the keyboard, and gives it on the press, so a middle click on a window that did not have the keyboard arrives together with the offer. ltk reads an offer on a thread of its own and pasted at once what it held, so after selecting in another application an ltk app pasted the selection before that. `Platform::primary_selection_pending` says whether another application's selection is still being read: the Wayland backend counts the reader threads it starts and the selections that arrive (`PrimarySelection::pending`), and the other backends answer `false`. A middle-button paste, into the text field under the pointer (`PrimaryPaste::Field`) or for an app that asked with `SelectionKind::Primary` (`PrimaryPaste::App`), goes through `AppData::paste_primary`, which while a read is pending keeps it in `pending_primary_paste`; `check_primary_paste`, run before every draw, makes it once the selection has arrived or after 500 ms with what there is, and `next_primary_paste_wakeup` wakes the loop every 10 ms meanwhile, since the reading thread does not. A field paste is made only if the field still has the focus.
The clipboard and the primary selection in a browser desktop. On a page with forge/web, what an app copies goes to forge/web as a `set_selection` request as well as to the browser's clipboard, and the selection the middle button pastes, which did nothing in a browser, as `set_primary_selection` (`Connection::set_selection`). forge/web's `selection` and `primary_selection` events, which every client hears when one sets them and each hears on connecting, become `PageEvent::Selection` and so `AppData::clipboard` and `AppData::primary_selection`, as another client's selection does on Wayland. Copying, and selecting and pasting with the middle button, thus work between the page's applications whether or not the browser lets a page write its clipboard. A `paste` from the browser still brings what other applications of the computer copied; a browser has no primary selection a page can reach.
The office icons. The default theme's word processor, spreadsheet and presentation icons came from Figma with a `foreignObject` backdrop blur that resvg ignores, and showed as flat white discs with pale glyphs. They are drawn again as the terminal's are: a dark gradient disc in each one's colour (blue, green, orange), a bright glyph and the terminal's filter.
The doc comment of `ltk::files` links `write()` as a function, since `write` alone was ambiguous with the macro for rustdoc.
`CHANGELOG.md` describes the changes.
This commit is contained in:
2026-10-23 19:20:10 +02:00
parent cf187a3dcc
commit 8f689ec479
18 changed files with 203 additions and 302 deletions
+6
View File
@@ -104,6 +104,12 @@ pub( crate ) trait Platform: 'static
/// Offer `text`, which the runtime also keeps in `AppData::primary_selection`, to other applications as the selection the middle button pastes; no-op where there is none.
fn publish_primary_selection( &mut self, text: &str );
/// Whether another application's primary selection is still being read, so `AppData::primary_selection` is not yet the one it set.
fn primary_selection_pending( &self ) -> bool
{
false
}
/// Whether `main` is an application window the user can move, resize and minimise, rather than a shell or lock surface.
fn is_toplevel( &self, main: &Self::Surface ) -> bool;
+10 -19
View File
@@ -427,25 +427,7 @@ impl<A: App> SeatHandler for AppData<A>
&mut self.platform.seat_state
}
fn new_seat(
&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: WlSeat,
)
{
// First seat → first data device. We only need one device
// regardless of how many seats appear (the typical desktop
// session has exactly one); additional seats reuse the same
// device-bound clipboard semantics by construction because
// `wl_data_device_manager.get_data_device` takes the seat as
// argument and the compositor manages routing.
if self.platform.data_device.is_none()
{
if let Some( ref ddm ) = self.platform.data_device_manager
{
self.platform.data_device = Some( ddm.get_data_device( qh, &seat ) );
}
}
self.bind_primary_selection( qh, &seat );
}
fn new_seat( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _seat: WlSeat ) {}
fn new_capability(
&mut self,
@@ -455,6 +437,15 @@ impl<A: App> SeatHandler for AppData<A>
capability: Capability,
)
{
// sctk calls `new_seat` only for seats announced after start-up, so the selection devices are bound here.
if self.platform.data_device.is_none()
{
if let Some( ref ddm ) = self.platform.data_device_manager
{
self.platform.data_device = Some( ddm.get_data_device( qh, &seat ) );
}
}
self.bind_primary_selection( qh, &seat );
match capability
{
Capability::Keyboard if self.platform.keyboard.is_none() =>
+5
View File
@@ -116,6 +116,11 @@ impl<A: App> Platform for WaylandPlatform<A>
primary_selection::publish( self );
}
fn primary_selection_pending( &self ) -> bool
{
self.primary.pending()
}
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
{
cursor::set_shape( self, shape )
+13 -2
View File
@@ -36,6 +36,8 @@ pub( crate ) struct PrimarySelection
source: Option<PrimarySelectionSource>,
inbox_tx: mpsc::Sender<String>,
inbox_rx: mpsc::Receiver<String>,
/// The reader threads started and not yet heard from.
reading: usize,
}
impl PrimarySelection
@@ -43,13 +45,21 @@ impl PrimarySelection
pub( crate ) fn new( manager: Option<PrimarySelectionManagerState> ) -> Self
{
let ( inbox_tx, inbox_rx ) = mpsc::channel();
Self { manager, device: None, source: None, inbox_tx, inbox_rx }
Self { manager, device: None, source: None, inbox_tx, inbox_rx, reading: 0 }
}
/// The latest selection the reader threads brought from other applications, if any arrived.
pub( crate ) fn drain( &mut self ) -> Option<String>
{
self.inbox_rx.try_iter().last()
let arrived: Vec<String> = self.inbox_rx.try_iter().collect();
self.reading = self.reading.saturating_sub( arrived.len() );
arrived.into_iter().last()
}
/// Whether a selection another application offered is still being read.
pub( crate ) fn pending( &self ) -> bool
{
self.reading > 0
}
}
@@ -86,6 +96,7 @@ impl<A: App> PrimarySelectionDeviceHandler for AppData<A>
let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return };
let Ok( pipe ) = offer.receive( mime ) else { return };
read_selection( pipe, self.platform.primary.inbox_tx.clone() );
self.platform.primary.reading += 1;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Copies to the system clipboard through `navigator.clipboard`, which web-sys keeps behind its unstable APIs. Pasting needs no reading: the page's `paste` event carries the text (`input.rs`).
//! Copies to the system clipboard through `navigator.clipboard`, which web-sys keeps behind its unstable APIs. Pasting needs no reading: the page's `paste` event carries the text (`input.rs`). On a page with forge/web the copy also goes to the desktop, which hands it to the page's other applications, as it does the primary selection (`desktop.rs`).
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{ JsCast, JsValue };
+12
View File
@@ -69,6 +69,16 @@ impl Connection
}
}
/// Make `text` the desktop's clipboard, or with `primary` what the middle button pastes, for the page's other applications.
pub( super ) fn set_selection( &self, primary: bool, text: &str )
{
let kind = if primary { "set_primary_selection" } else { "set_selection" };
if let Err( e ) = self.request( &json!( { "type": kind, "text": text } ) )
{
web_sys::console::warn_1( &format!( "[ltk] {kind}: {e}" ).into() );
}
}
pub( super ) fn close( &self )
{
shell::connected( None );
@@ -106,6 +116,8 @@ fn on_event( json: String, data: JsValue )
Some( "fs_changed" ) => push( PageEvent::FilesChanged( event[ "path" ].as_str().unwrap_or_default().into() ) ),
Some( "fs_mounts" ) => push( PageEvent::Mounts( files::mounts( &event ) ) ),
Some( "open_files" ) => push( PageEvent::OpenFiles( files::paths( &event ) ) ),
Some( "selection" ) => push( PageEvent::Selection { primary: false, text: event[ "text" ].as_str().unwrap_or_default().to_string() } ),
Some( "primary_selection" ) => push( PageEvent::Selection { primary: true, text: event[ "text" ].as_str().unwrap_or_default().to_string() } ),
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
}
}
+5 -1
View File
@@ -116,9 +116,13 @@ impl<A: App> Platform for WebPlatform<A>
fn publish_clipboard( &mut self, text: &str )
{
clipboard::write( text );
if let Some( desktop ) = &self.desktop { desktop.set_selection( false, text ); }
}
fn publish_primary_selection( &mut self, _text: &str ) {}
fn publish_primary_selection( &mut self, text: &str )
{
if let Some( desktop ) = &self.desktop { desktop.set_selection( true, text ); }
}
fn set_cursor_shape( &mut self, shape: CursorShape ) -> bool
{
+8
View File
@@ -49,6 +49,12 @@ pub( super ) enum PageEvent
Mounts( Vec<crate::files::Mount> ),
/// The files the app was launched to open.
OpenFiles( Vec<std::path::PathBuf> ),
/// The desktop's clipboard, or with `primary` what the middle button pastes, as an application of the page set it.
Selection
{
primary: bool,
text: String,
},
}
/// An event listener, registered for as long as it lives.
@@ -314,6 +320,8 @@ impl<A: App> AppData<A>
{
if let Some( msg ) = self.app.on_open_files( paths ) { self.pending_msgs.push( msg ); }
}
PageEvent::Selection { primary: true, text } => self.primary_selection = text,
PageEvent::Selection { primary: false, text } => self.clipboard = text,
}
}
self.dispatch_sources();