diff --git a/CHANGELOG.md b/CHANGELOG.md index 9938cc4..3df286d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **The clipboard and the primary selection between a browser desktop's applications** — on a page with forge/web, what an app copies (`publish_clipboard`) goes to forge/web as a `set_selection` request as well as to the browser's clipboard, and the selection the middle button pastes (`publish_primary_selection`, which did nothing in a browser) as `set_primary_selection`. forge/web's `selection` and `primary_selection` events, which every client hears when one sets them and each hears on connecting, become `AppData::clipboard` and `AppData::primary_selection`, as another client's selection does on Wayland, so copying, and selecting and pasting with the middle button, 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 copied; a browser has no primary selection a page can reach. - **The user's files, alike natively and in a browser: `ltk::files`** — asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Natively they run `std::fs` on a worker thread of ltk's, and each copy on a thread of its own; in a browser they are forge/web's `fs_*` requests over the app's connection, contents travelling as a `Uint8Array` beside the JSON as the second argument of the connection's `request` and of the client's event handler, and what is asked before the app connects is sent once it has, or fails with `Unsupported` on a page without a desktop. Contents travel in chunks: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, and its contents appear only once the writer is closed, a new file showing empty until then in a browser; natively it is written beside the file and renamed over it, and a writer dropped unclosed leaves the file as it was. A copy runs where the files are, reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind. Errors are the same `io::ErrorKind`s on every platform. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `mount` asks the browser for a folder of the user's computer, from the user's press, and fails with `Unsupported` natively. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, the resource directory's `mime/globs2` first, which `scripts/android-assets.sh`, and so `web-assets.sh`, now copies into the tree with shared-mime-info's `subclasses` (`MIME_DIR` overrides where from), then the system's. `rename` never replaces what is at `to`, natively as in a browser. Tests cover writing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs. - **`ltk::web::shell_request( json )`** — a shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser. - **`Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )`** — `draw_image_data` with `key` naming the pixels in place of a fingerprint of them, so a caller that draws the same images on every frame, such as a terminal's glyphs, does not hash every one of them each time. Every draw with the same `key` must pass the same pixels, which are read only when the image is not cached; a key never names an image drawn by content. @@ -89,6 +90,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Fixed +- **Pasting what another application copied.** 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, not for the seat already there when the app connects. An app therefore never had either device, the compositor never offered it another client's selection, and neither Ctrl+V nor the middle button pasted anything copied outside the app, nor did what it copied reach other applications through the compositor. Both devices are now bound on the seat's first capability, which comes for every seat. +- **The middle button pastes the selection just made in another application.** 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 reaches it together with the offer. ltk reads an offer on a thread of its own and pasted at once what it had read before, so after selecting in another application an ltk app pasted the selection before that. A middle-button paste, into the text field under the pointer or one an app asks for with `SelectionKind::Primary`, now waits while a selection is being read (`Platform::primary_selection_pending`, which the Wayland backend counts) and is made once it has been, or after 500 ms with what there is. - **A held button is not also tapped.** A button with `on_long_press` and no `on_drag_start` stayed a tap after its hold fired, so releasing it sent its `on_press` as well; it no longer does. - **Drawing many small images no longer slows down as the image caches fill.** Every hit in the GLES texture cache, the GLES surface cache and the software canvas's premultiplied cache looked its key up in a linear LRU order, so an app that draws thousands of small images, a terminal's glyphs in each of their colours, walked thousands of entries for each one on every frame. The caches now find, insert and evict an entry in O(log n), with the same budgets and eviction order. - **The cursor's shape over a browser app's overlays** — the web backend set the CSS `cursor` on the main surface's element only, so over an overlay, such as crustace's dock or a panel, the pointer kept the page's arrow. Each surface now keeps the element that takes its input, the cursor's shape moves to that of the surface the pointer enters, and the last shape asked for is kept for it. diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 98eafec..544064f 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -39,6 +39,8 @@ pub struct AppData> pub primary_selection: String, /// The selection of the focused text field when it was last looked at. pub primary_field: Option, + /// A middle-button paste waiting for the primary selection being read, and when it was asked for. + pub pending_primary_paste: Option<( super::clipboard::PrimaryPaste, crate::time::Instant )>, /// AccessKit / AT-SPI2 adapter. `None` when the platform adapter /// could not be created (no AT-SPI2 daemon on the session bus, diff --git a/src/event_loop/clipboard.rs b/src/event_loop/clipboard.rs index 5068736..61b5be1 100644 --- a/src/event_loop/clipboard.rs +++ b/src/event_loop/clipboard.rs @@ -1,14 +1,69 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use std::time::Duration; + use super::app_data::AppData; use super::surface::SurfaceFocus; use crate::app::App; use crate::platform::Platform; +use crate::time::Instant; use crate::tree::find_handlers; +/// How long a middle-button paste waits for another application's primary selection before it takes what there is. +const PRIMARY_WAIT: Duration = Duration::from_millis( 500 ); +/// How often the runtime looks whether it has arrived meanwhile, since the thread reading it does not wake the runtime. +const PRIMARY_POLL: Duration = Duration::from_millis( 10 ); + +/// Where a middle-button paste goes. +pub( crate ) enum PrimaryPaste +{ + /// The text field `idx` of a surface, which the press focused. + Field( SurfaceFocus, usize ), + /// The app, which asked for the primary selection with [`App::take_paste_request`]. + App, +} + impl AppData { + /// Paste the primary selection where `target` says: at once, or, while another application's is still being read, once it has been, so a press on a window that has just been given the selection does not paste the one before. + pub( crate ) fn paste_primary( &mut self, target: PrimaryPaste ) + { + if self.platform.primary_selection_pending() + { + self.pending_primary_paste = Some( ( target, Instant::now() ) ); + return; + } + self.deliver_primary( target ); + } + + /// Carry out the paste waiting for the primary selection once it has been read, or once it has waited long enough. + pub( crate ) fn check_primary_paste( &mut self ) + { + let Some( ( _, since ) ) = &self.pending_primary_paste else { return }; + if self.platform.primary_selection_pending() && since.elapsed() < PRIMARY_WAIT { return; } + if let Some( ( target, _ ) ) = self.pending_primary_paste.take() { self.deliver_primary( target ); } + } + + pub( crate ) fn next_primary_paste_wakeup( &self ) -> Option + { + self.pending_primary_paste.as_ref().map( |_| PRIMARY_POLL ) + } + + fn deliver_primary( &mut self, target: PrimaryPaste ) + { + let text = self.primary_selection.clone(); + if text.is_empty() { return; } + match target + { + PrimaryPaste::Field( focus, idx ) => + { + if self.try_surface( focus ).is_some_and( |ss| ss.focused_idx == Some( idx ) ) { self.handle_text_insert( focus, &text ); } + } + PrimaryPaste::App => self.app.on_paste( &text ), + } + } + /// Make `text` the clipboard, for other applications as well when the system allows. pub( crate ) fn publish_clipboard( &mut self, text: String ) { diff --git a/src/event_loop/setup.rs b/src/event_loop/setup.rs index 8f90b74..35914ed 100644 --- a/src/event_loop/setup.rs +++ b/src/event_loop/setup.rs @@ -32,6 +32,7 @@ impl AppData external_text_input: None, primary_selection: String::new(), primary_field: None, + pending_primary_paste: None, a11y, shift_pressed: false, ctrl_pressed: false, diff --git a/src/event_loop/step.rs b/src/event_loop/step.rs index 16c86ad..b6d68be 100644 --- a/src/event_loop/step.rs +++ b/src/event_loop/step.rs @@ -20,7 +20,7 @@ impl AppData /// The longest the backend may sleep with no event: until a still press turns long, a tooltip shows or the built-in keyboard acts. Frames are paced by the system, so nothing else needs a deadline. pub( crate ) fn next_wakeup( &self ) -> Option { - [ self.next_long_press_wakeup(), self.next_tooltip_wakeup(), self.next_osk_wakeup() ] + [ self.next_long_press_wakeup(), self.next_tooltip_wakeup(), self.next_osk_wakeup(), self.next_primary_paste_wakeup() ] .into_iter() .flatten() .min() @@ -32,6 +32,7 @@ impl AppData self.check_long_press_deadlines(); self.check_tooltip_deadline(); self.check_osk_deadlines(); + self.check_primary_paste(); let ext: Vec<_> = self.app.poll_external(); self.pending_msgs.extend( ext ); @@ -82,14 +83,15 @@ impl AppData { self.publish_primary_selection( text ); } - if let Some( kind ) = self.app.take_paste_request() + match self.app.take_paste_request() { - let text = match kind + Some( SelectionKind::Clipboard ) => { - SelectionKind::Clipboard => self.clipboard.clone(), - SelectionKind::Primary => self.primary_selection.clone(), - }; - if !text.is_empty() { self.app.on_paste( &text ); } + let text = self.clipboard.clone(); + if !text.is_empty() { self.app.on_paste( &text ); } + } + Some( SelectionKind::Primary ) => self.paste_primary( super::clipboard::PrimaryPaste::App ), + None => {} } self.apply_focus_request( drawn ); diff --git a/src/files/mod.rs b/src/files/mod.rs index 7ad223c..7844054 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -3,7 +3,7 @@ //! The user's files, asynchronously and alike on every platform: natively through `std::fs` on a worker thread of ltk's, in a browser through forge/web's file requests (forge/web/DESIGN.md, section 11). Every operation hands its result to `done`, whose message reaches the app through its [`ChannelSender`], so nothing blocks the app's thread. Results come in no set order, so an app that may have moved on checks that a result is still the one it waits for. //! -//! Contents travel in chunks: a large file is read in ranges with [`read_range`] and written through a [`Writer`], one chunk after the other is answered, [`CHUNK`] bytes at a time; [`read`] and [`write`] are for small files. A copy runs where the files are, its contents never passing through the app. +//! Contents travel in chunks: a large file is read in ranges with [`read_range`] and written through a [`Writer`], one chunk after the other is answered, [`CHUNK`] bytes at a time; [`read`] and [`write()`] are for small files. A copy runs where the files are, its contents never passing through the app. //! //! Errors are `io::Error`s of the same kinds on every platform: `NotFound`, `AlreadyExists`, `NotADirectory`, `IsADirectory`, `DirectoryNotEmpty`, `PermissionDenied`, `Unsupported`, and `Interrupted` for a cancelled copy. //! diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index c9c2c58..aa919cc 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -71,8 +71,7 @@ impl AppData self.press_on_focused = self.surface( focus ).focused_idx == Some( idx ); if !self.press_on_focused { self.set_focus( focus, Some( idx ) ); } self.handle_text_pointer_down( focus, idx, pos ); - let text = self.primary_selection.clone(); - if !text.is_empty() { self.handle_text_insert( focus, &text ); } + self.paste_primary( crate::event_loop::clipboard::PrimaryPaste::Field( focus, idx ) ); } pub( super ) fn on_pointer_left_press( &mut self, input: &PointerInput ) diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 8323e59..a1b3dd6 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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; diff --git a/src/platform/wayland/handlers.rs b/src/platform/wayland/handlers.rs index 5eb180e..e18bddb 100644 --- a/src/platform/wayland/handlers.rs +++ b/src/platform/wayland/handlers.rs @@ -427,25 +427,7 @@ impl SeatHandler for AppData &mut self.platform.seat_state } - fn new_seat( - &mut self, _conn: &Connection, qh: &QueueHandle, 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, _seat: WlSeat ) {} fn new_capability( &mut self, @@ -455,6 +437,15 @@ impl SeatHandler for AppData 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() => diff --git a/src/platform/wayland/mod.rs b/src/platform/wayland/mod.rs index 39f8600..6b64485 100644 --- a/src/platform/wayland/mod.rs +++ b/src/platform/wayland/mod.rs @@ -116,6 +116,11 @@ impl Platform for WaylandPlatform 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 ) diff --git a/src/platform/wayland/primary_selection.rs b/src/platform/wayland/primary_selection.rs index 6bf9866..3010db2 100644 --- a/src/platform/wayland/primary_selection.rs +++ b/src/platform/wayland/primary_selection.rs @@ -36,6 +36,8 @@ pub( crate ) struct PrimarySelection source: Option, inbox_tx: mpsc::Sender, inbox_rx: mpsc::Receiver, + /// 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 ) -> 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 { - self.inbox_rx.try_iter().last() + let arrived: Vec = 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 PrimarySelectionDeviceHandler for AppData 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; } } diff --git a/src/platform/web/clipboard.rs b/src/platform/web/clipboard.rs index d351a68..ee0e8f0 100644 --- a/src/platform/web/clipboard.rs +++ b/src/platform/web/clipboard.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! 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 }; diff --git a/src/platform/web/desktop.rs b/src/platform/web/desktop.rs index bf0dc35..0e42ac0 100644 --- a/src/platform/web/desktop.rs +++ b/src/platform/web/desktop.rs @@ -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() ), } } diff --git a/src/platform/web/mod.rs b/src/platform/web/mod.rs index 325033f..3374dd4 100644 --- a/src/platform/web/mod.rs +++ b/src/platform/web/mod.rs @@ -116,9 +116,13 @@ impl Platform for WebPlatform 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 { diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs index c37f1cc..dac5078 100644 --- a/src/platform/web/run.rs +++ b/src/platform/web/run.rs @@ -49,6 +49,12 @@ pub( super ) enum PageEvent Mounts( Vec ), /// The files the app was launched to open. OpenFiles( Vec ), + /// 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 AppData { 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(); diff --git a/themes/default/icons/apps/slides.svg b/themes/default/icons/apps/slides.svg index 21fd284..d5a0e70 100644 --- a/themes/default/icons/apps/slides.svg +++ b/themes/default/icons/apps/slides.svg @@ -1,105 +1,54 @@ - -
- - + + + + -
- - - - - + + + - + - - + + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - +
diff --git a/themes/default/icons/apps/spreadsheet.svg b/themes/default/icons/apps/spreadsheet.svg index 81bfd07..d388be1 100644 --- a/themes/default/icons/apps/spreadsheet.svg +++ b/themes/default/icons/apps/spreadsheet.svg @@ -1,133 +1,63 @@ - -
- - - -
- - -
- - + + + + + + + + + + + + + + + + - + - - + + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - +
diff --git a/themes/default/icons/apps/wordprocessor.svg b/themes/default/icons/apps/wordprocessor.svg index b58eb3b..7250c9d 100644 --- a/themes/default/icons/apps/wordprocessor.svg +++ b/themes/default/icons/apps/wordprocessor.svg @@ -1,133 +1,56 @@ - -
- - - -
- - -
- - + + + + + + + + + - + - - + + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - +