diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3714e2f..068136c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -135,6 +135,9 @@ pub trait Engine { /// Set preedit text at the current cursor position. fn set_preedit_string(&mut self, text: String, cursor_begin: i32, cursor_end: i32); + /// Paste clipboard text. + fn paste(&mut self, text: String); + /// Clear engine focus. fn clear_focus(&mut self); diff --git a/src/engine/webkit/mod.rs b/src/engine/webkit/mod.rs index 5676c05..203688f 100644 --- a/src/engine/webkit/mod.rs +++ b/src/engine/webkit/mod.rs @@ -39,8 +39,8 @@ use crate::engine::webkit::platform::WebKitDisplay; use crate::engine::{Engine, EngineId, Group, GroupId, BG}; use crate::storage::cookie_whitelist::CookieWhitelist; use crate::ui::overlay::option_menu::{Anchor, OptionMenuId, OptionMenuItem, OptionMenuPosition}; -use crate::window::TextInputChange; -use crate::{KeyboardFocus, Position, Size, State}; +use crate::window::{TextInputChange, WindowHandler}; +use crate::{KeyboardFocus, PasteTarget, Position, Size, State}; mod input_method_context; mod platform; @@ -95,9 +95,6 @@ trait WebKitHandler { /// Open URI in a new window. fn open_in_window(&mut self, uri: String); - /// Write text to the system clipboard. - fn set_clipboard(&mut self, text: String); - /// Add host to the cookie whitelist. fn add_cookie_exception(&mut self, host: String); @@ -238,8 +235,7 @@ impl WebKitHandler for State { (Position::new(x, y + height).into(), Some(width as u32)) }, None => { - let position = webkit_engine.last_input_position; - let position = Position::new(position.x.round() as i32, position.y.round() as i32); + let position = webkit_engine.last_input_position.i32_round(); let menu_position = OptionMenuPosition::new(position, Anchor::BottomRight); (menu_position, None) }, @@ -316,10 +312,6 @@ impl WebKitHandler for State { } } - fn set_clipboard(&mut self, text: String) { - self.set_clipboard(text); - } - fn add_cookie_exception(&mut self, host: String) { self.storage.cookie_whitelist.add(&host); } @@ -753,6 +745,10 @@ impl Engine for WebKitEngine { self.webkit_display.input_method_context().emit_by_name::<()>("preedit-finished", &[]); } + fn paste(&mut self, text: String) { + self.commit_string(text); + } + fn clear_focus(&mut self) { self.set_focused(false); } @@ -1068,6 +1064,10 @@ impl ContextMenu { n_items += 3; } + if self.context.contains(HitTestResultContext::EDITABLE) { + n_items += 1; + } + n_items } @@ -1103,7 +1103,15 @@ impl ContextMenu { 2 => return Some(ContextMenuItem::OpenInNewTab), _ => (), } - // index -= 3; + index -= 3; + } + + if self.context.contains(HitTestResultContext::EDITABLE) { + match index { + 0 => return Some(ContextMenuItem::Paste), + _ => (), + } + // index -= 1; } None @@ -1138,6 +1146,9 @@ impl ContextMenu { engine.queue.set_clipboard(uri); } }, + Some(ContextMenuItem::Paste) => { + engine.queue.request_paste(PasteTarget::Browser(engine.id())) + }, None => (), } } @@ -1165,6 +1176,7 @@ enum ContextMenuItem { OpenInNewTab, OpenInNewWindow, CopyLink, + Paste, } impl ContextMenuItem { @@ -1176,6 +1188,7 @@ impl ContextMenuItem { Self::OpenInNewTab => "Open in New Tab", Self::OpenInNewWindow => "Open in New Window", Self::CopyLink => "Copy Link", + Self::Paste => "Paste", } } } diff --git a/src/main.rs b/src/main.rs index 50e1389..17e51fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::cmp::Ordering; use std::collections::HashMap; +use std::io::Read; use std::ops::{Add, AddAssign, Div, Mul, Sub, SubAssign}; use std::os::fd::{AsFd, AsRawFd}; use std::ptr::NonNull; @@ -26,7 +27,7 @@ use smithay_client_toolkit::reexports::client::{ ConnectError, Connection, EventQueue, QueueHandle, }; use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers, RepeatInfo}; -use tracing::info; +use tracing::{info, warn}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use crate::engine::webkit::{WebKitError, WebKitState}; @@ -35,7 +36,7 @@ use crate::storage::history::History; use crate::storage::Storage; use crate::wayland::protocols::{KeyRepeat, ProtocolStates, TextInput}; use crate::wayland::WaylandDispatch; -use crate::window::{KeyboardFocus, Window, WindowId}; +use crate::window::{KeyboardFocus, PasteTarget, Window, WindowHandler, WindowId}; mod engine; mod storage; @@ -292,6 +293,35 @@ impl State { self.clipboard.text = text; } + + /// Request clipboard paste. + fn request_paste(&mut self, target: PasteTarget) { + // Get available Wayland text selection. + let selection_offer = match self.protocol_states.data_device.data().selection_offer() { + Some(selection_offer) => selection_offer, + None => return, + }; + let mut pipe = match selection_offer.receive("text/plain".into()) { + Ok(pipe) => pipe, + Err(err) => { + warn!("Clipboard paste failed: {err}"); + return; + }, + }; + + // Asynchronously write paste text to the window. + let mut queue = self.queue.clone(); + source::unix_fd_add_local(pipe.as_raw_fd(), IOCondition::IN, move |_, _| { + // Read available text from pipe. + let mut text = String::new(); + pipe.read_to_string(&mut text).unwrap(); + + // Forward text to the paste target. + queue.paste(target, text); + + ControlFlow::Break + }); + } } /// Key status tracking for WlKeyboard. @@ -416,6 +446,12 @@ impl Position { } } +impl Position { + fn i32_round(&self) -> Position { + Position::new(self.x.round() as i32, self.y.round() as i32) + } +} + impl From<(T, T)> for Position { fn from((x, y): (T, T)) -> Self { Self { x, y } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 57850f0..2e4931f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,9 +3,11 @@ use std::borrow::Cow; use std::mem; use std::ops::{Bound, Range, RangeBounds}; +use std::time::Duration; use _text_input::zwp_text_input_v3::{ChangeCause, ContentHint, ContentPurpose}; use funq::MtQueueHandle; +use glib::{source, ControlFlow, Priority, Source}; use glutin::display::Display; use pangocairo::cairo::LinearGradient; use pangocairo::pango::{Alignment, SCALE as PANGO_SCALE}; @@ -19,7 +21,7 @@ use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers}; use crate::storage::history::{HistoryMatch, MAX_MATCHES}; use crate::ui::renderer::{Renderer, TextLayout, TextOptions, Texture, TextureBuilder}; -use crate::window::{TextInputChange, TextInputState}; +use crate::window::{PasteTarget, TextInputChange, TextInputState, WindowHandler}; use crate::{gl, rect_contains, History, Position, Size, State, WindowId}; pub mod engine_backdrop; @@ -32,6 +34,9 @@ pub const MAX_TAP_DISTANCE: f64 = 400.; /// Maximum interval between taps to be considered a double/trible-tap. const MAX_MULTI_TAP_MILLIS: u32 = 300; +/// Minimum time before a tap is considered a long-press. +const LONG_PRESS_MILLIS: u32 = 300; + /// Logical height of the non-browser UI. const TOOLBAR_HEIGHT: u32 = 50; @@ -87,6 +92,14 @@ pub trait UiHandler { /// Hide history suggestions popup. fn close_history_menu(&mut self, window_id: WindowId); + + /// Show long-press text input popup. + fn open_text_menu( + &mut self, + window_id: WindowId, + position: Position, + selection: Option, + ); } impl UiHandler for State { @@ -128,6 +141,17 @@ impl UiHandler for State { window.close_history_menu(); } } + + fn open_text_menu( + &mut self, + window_id: WindowId, + position: Position, + selection: Option, + ) { + if let Some(window) = self.windows.get_mut(&window_id) { + window.open_text_menu(position, selection); + } + } } pub struct Ui { @@ -138,6 +162,7 @@ pub struct Ui { viewport: WpViewport, compositor: CompositorState, + origin: Position, size: Size, scale: f64, @@ -189,6 +214,7 @@ impl Ui { tabs_button: Default::default(), prev_button: Default::default(), separator: Default::default(), + origin: Default::default(), dirty: Default::default(), size: Default::default(), }; @@ -202,7 +228,8 @@ impl Ui { /// Update the logical UI size. pub fn set_size(&mut self, size: Size) { let toolbar_height = Self::toolbar_height(); - self.subsurface.set_position(0, (size.height - toolbar_height) as i32); + self.origin = Position::new(0, (size.height - toolbar_height) as i32); + self.subsurface.set_position(self.origin.x, self.origin.y); self.size = Size::new(size.width, toolbar_height); self.dirty = true; @@ -305,7 +332,7 @@ impl Ui { &mut self, time: u32, id: i32, - position: Position, + logical_position: Position, _modifiers: Modifiers, ) { // Only accept a single touch point in the UI. @@ -315,7 +342,7 @@ impl Ui { self.touch_point = Some(id); // Convert position to physical space. - let position = position * self.scale; + let position = logical_position * self.scale; // Get uribar constraints. let uribar_position = self.uribar_position().into(); @@ -323,7 +350,8 @@ impl Ui { if rect_contains(uribar_position, uribar_size, position) { // Forward touch event. - self.uribar.touch_down(time, position - uribar_position); + let absolute_logical_position = logical_position + self.origin.into(); + self.uribar.touch_down(time, absolute_logical_position, position - uribar_position); self.touch_focus = TouchFocusElement::UriBar; self.keyboard_focus_uribar(); @@ -372,7 +400,7 @@ impl Ui { } /// Handle touch release events. - pub fn touch_up(&mut self, _time: u32, id: i32, _modifiers: Modifiers) { + pub fn touch_up(&mut self, time: u32, id: i32, _modifiers: Modifiers) { // Ignore all unknown touch points. if self.touch_point != Some(id) { return; @@ -381,7 +409,7 @@ impl Ui { match self.touch_focus { // Forward touch event. - TouchFocusElement::UriBar => self.uribar.touch_up(), + TouchFocusElement::UriBar => self.uribar.touch_up(time), TouchFocusElement::TabsButton(position) => { let uribar_x = self.uribar_position().x as f64; let uribar_width = self.uribar_size().width as f64; @@ -406,7 +434,7 @@ impl Ui { } } - /// Insert text at the current cursor position. + /// Insert IME text at the current cursor position. pub fn commit_string(&mut self, text: String) { if let Some(KeyboardInputElement::UriBar) = self.keyboard_focus { self.uribar.text_field.commit_string(text); @@ -431,6 +459,13 @@ impl Ui { } } + /// Paste text at the current cursor position. + pub fn paste(&mut self, text: String) { + if let Some(KeyboardInputElement::UriBar) = self.keyboard_focus { + self.uribar.text_field.paste(text); + } + } + /// Update the URI bar's content. pub fn set_uri(&mut self, uri: &str) { self.uribar.set_uri(uri); @@ -461,13 +496,13 @@ impl Ui { /// Physical position of the URI bar. fn uribar_position(&self) -> Position { let horizontal_padding = X_PADDING * self.scale; - let prev_button_x = self.prev_button_position().x; - let prev_button_width = self.prev_button.size().width as i32; - let x = prev_button_x + prev_button_width + horizontal_padding.round() as i32; + let prev_button_x = self.prev_button_position().x as f64; + let prev_button_width = self.prev_button.size().width as f64; + let x = prev_button_x + prev_button_width + horizontal_padding.round(); let y = self.size.height as f64 * self.scale * (1. - URIBAR_HEIGHT_PERCENTAGE) / 2.; - Position::new(x, y.round() as i32) + Position::new(x, y).i32_round() } /// Physical size of the URI bar. @@ -486,14 +521,14 @@ impl Ui { fn tabs_button_position(&self) -> Position { let x = ((self.size.width - TABS_BUTTON_SIZE) as f64 - X_PADDING) * self.scale; let y = (self.size.height - TABS_BUTTON_SIZE) as f64 * self.scale / 2.; - Position::new(x.round() as i32, y.round() as i32) + Position::new(x, y).i32_round() } /// Physical position of the previous page button. fn prev_button_position(&self) -> Position { - let x = (X_PADDING * self.scale).round() as i32; + let x = X_PADDING * self.scale; let y = (self.size.height - PREV_BUTTON_SIZE) as f64 * self.scale / 2.; - Position::new(x, y.round() as i32) + Position::new(x, y).i32_round() } /// Physical position of the toolbar separator. @@ -525,7 +560,7 @@ struct Uribar { impl Uribar { fn new(window_id: WindowId, history: History, queue: MtQueueHandle) -> Self { // Setup text input with submission handling. - let mut text_field = TextField::new(FONT_SIZE); + let mut text_field = TextField::new(window_id, queue.clone(), FONT_SIZE); let mut submit_queue = queue.clone(); text_field.set_submit_handler(Box::new(move |uri| submit_queue.load_uri(window_id, uri))); text_field.set_purpose(ContentPurpose::Url); @@ -678,11 +713,16 @@ impl Uribar { } /// Handle touch press events. - pub fn touch_down(&mut self, time: u32, position: Position) { + pub fn touch_down( + &mut self, + time: u32, + absolute_logical_position: Position, + position: Position, + ) { // Forward event to text field. let mut relative_position = position; relative_position.x -= X_PADDING * self.scale; - self.text_field.touch_down(time, relative_position); + self.text_field.touch_down(time, absolute_logical_position, relative_position); } /// Handle touch motion events. @@ -694,9 +734,9 @@ impl Uribar { } /// Handle touch release events. - pub fn touch_up(&mut self) { + pub fn touch_up(&mut self, time: u32) { // Forward event to text field. - self.text_field.touch_up(); + self.text_field.touch_up(time); } } @@ -851,6 +891,7 @@ impl PrevButton { } /// Elements accepting keyboard focus. +#[derive(Debug)] enum KeyboardInputElement { UriBar, } @@ -874,11 +915,15 @@ pub struct TextField { selection: Option>, + long_press_source: Option, touch_state: TouchState, text_change_handler: Box, submit_handler: Box, + queue: MtQueueHandle, + window_id: WindowId, + autocomplete: String, preedit: (String, i32, i32), @@ -892,13 +937,16 @@ pub struct TextField { } impl TextField { - fn new(font_size: u8) -> Self { + fn new(window_id: WindowId, queue: MtQueueHandle, font_size: u8) -> Self { Self { + window_id, + queue, layout: TextLayout::new(font_size, 1.), text_change_handler: Box::new(|_| {}), submit_handler: Box::new(|_| {}), change_cause: ChangeCause::Other, purpose: ContentPurpose::Normal, + long_press_source: Default::default(), text_input_dirty: Default::default(), cursor_offset: Default::default(), scroll_offset: Default::default(), @@ -1006,6 +1054,14 @@ impl TextField { self.dirty = true; } + /// Get selection text. + #[cfg_attr(feature = "profiling", profiling::function)] + pub fn selection_text(&self) -> Option { + let selection = self.selection.as_ref()?; + let range = selection.start as usize..selection.end as usize; + Some(self.text()[range].to_owned()) + } + /// Submit current text input. pub fn submit(&mut self) { let text = self.text(); @@ -1129,6 +1185,14 @@ impl TextField { self.set_text(&text); self.emit_text_changed(); }, + (Keysym::XF86_Copy, ..) | (Keysym::C, true, true) => { + if let Some(text) = self.selection_text() { + self.queue.set_clipboard(text); + } + }, + (Keysym::XF86_Paste, ..) | (Keysym::V, true, true) => { + self.queue.request_paste(PasteTarget::Ui(self.window_id)) + }, (keysym, _, false) => { // Delete selection before writing new text. if let Some(selection) = self.selection.take() { @@ -1180,7 +1244,15 @@ impl TextField { } /// Handle touch press events. - pub fn touch_down(&mut self, time: u32, position: Position) { + /// + /// The `absolute_position` should be the text input's global location in + /// logical space and is used for opening popups. + pub fn touch_down( + &mut self, + time: u32, + absolute_logical_position: Position, + position: Position, + ) { // Get byte offset from X/Y position. let x = ((position.x - self.scroll_offset) * PANGO_SCALE as f64).round() as i32; let y = (position.y * PANGO_SCALE as f64).round() as i32; @@ -1189,6 +1261,26 @@ impl TextField { // Update touch state. self.touch_state.down(time, position, byte_index, self.focused); + + // Stage timer for option menu popup. + if self.touch_state.action == TouchAction::Tap { + // Ensure active timeouts are cleared. + self.clear_long_press_timeout(); + + // Create a new timeout. + let delay = Duration::from_millis(LONG_PRESS_MILLIS as u64); + + let position = absolute_logical_position.i32_round(); + let mut selection = self.selection_text(); + let mut queue = self.queue.clone(); + let window_id = self.window_id; + let source = source::timeout_source_new(delay, None, Priority::DEFAULT, move || { + queue.open_text_menu(window_id, position, selection.take()); + ControlFlow::Break + }); + source.attach(None); + self.long_press_source = Some(source); + } } /// Handle touch motion events. @@ -1203,6 +1295,9 @@ impl TextField { TouchAction::Drag => { self.scroll_offset += delta.x; self.clamp_scroll_offset(); + + self.clear_long_press_timeout(); + self.dirty = true; }, // Modify selection boundaries. @@ -1237,6 +1332,8 @@ impl TextField { // Ensure selection end stays visible. self.update_scroll_offset(); + self.clear_long_press_timeout(); + self.text_input_dirty = true; self.dirty = true; }, @@ -1246,7 +1343,10 @@ impl TextField { } /// Handle touch release events. - pub fn touch_up(&mut self) { + pub fn touch_up(&mut self, time: u32) { + // Always reset long-press timers. + self.clear_long_press_timeout(); + // Ignore release handling for drag actions. if matches!( self.touch_state.action, @@ -1266,7 +1366,10 @@ impl TextField { let byte_index = self.cursor_byte_index(index, offset); // Handle single/double/triple-taps. + let ms_since_down = time - self.touch_state.last_time; match self.touch_state.action { + // Long pressed is handled by a timer, so we just ignore the release. + TouchAction::Tap if ms_since_down >= LONG_PRESS_MILLIS => (), // Move cursor to tap location. TouchAction::Tap => { // Update cursor index. @@ -1338,29 +1441,10 @@ impl TextField { /// Insert text at the current cursor position. fn commit_string(&mut self, text: String) { - // Delete selection before writing new text. - if let Some(selection) = self.selection.take() { - self.delete_selected(selection); - } - - // Add text to input element. - let index = self.cursor_index() as usize; - let mut input_text = self.text(); - input_text.insert_str(index, &text); - self.layout.set_text(&input_text); - self.emit_text_changed(); - - // Move cursor behind the new characters. - self.cursor_index += text.len() as i32; - - // Ensure cursor is visible. - self.update_scroll_offset(); - // Set reason for next IME update. self.change_cause = ChangeCause::InputMethod; - self.text_input_dirty = true; - self.dirty = true; + self.paste(text); } /// Set preedit text at the current cursor position. @@ -1381,6 +1465,30 @@ impl TextField { self.dirty = true; } + /// Paste text into the input element. + fn paste(&mut self, text: String) { + // Delete selection before writing new text. + if let Some(selection) = self.selection.take() { + self.delete_selected(selection); + } + + // Add text to input element. + let index = self.cursor_index() as usize; + let mut input_text = self.text(); + input_text.insert_str(index, &text); + self.layout.set_text(&input_text); + self.emit_text_changed(); + + // Move cursor behind the new characters. + self.cursor_index += text.len() as i32; + + // Ensure cursor is visible. + self.update_scroll_offset(); + + self.text_input_dirty = true; + self.dirty = true; + } + /// Set autocomplete text. /// /// This is expected to have the common prefix removed already. @@ -1555,6 +1663,13 @@ impl TextField { self.dirty |= clamped_offset != self.scroll_offset; self.scroll_offset = clamped_offset; } + + /// Cancel active long-press popup timers. + fn clear_long_press_timeout(&mut self) { + if let Some(source) = self.long_press_source.take() { + source.destroy(); + } + } } /// Touch event tracking. diff --git a/src/ui/overlay/mod.rs b/src/ui/overlay/mod.rs index c12bfe0..7b58a3a 100644 --- a/src/ui/overlay/mod.rs +++ b/src/ui/overlay/mod.rs @@ -70,7 +70,7 @@ pub trait Popup { /// Delete text around the current cursor position. fn delete_surrounding_text(&mut self, _before_length: u32, _after_length: u32) {} - /// Insert text at the current cursor position. + /// Insert IME text at the current cursor position. fn commit_string(&mut self, _text: String) {} /// Set preedit text at the current cursor position. @@ -81,6 +81,9 @@ pub trait Popup { TextInputChange::Disabled } + /// Paste text at the current cursor position. + fn paste(&mut self, _text: String) {} + /// Check if popup has keyboard input element focused. fn has_keyboard_focus(&self) -> bool { false @@ -107,6 +110,8 @@ pub struct Overlay { size: Size, scale: f64, + + dirty: bool, } impl Overlay { @@ -131,6 +136,7 @@ impl Overlay { scale: 1.0, keyboard_focus: Default::default(), touch_focus: Default::default(), + dirty: Default::default(), size: Default::default(), } } @@ -190,9 +196,10 @@ impl Overlay { } // Don't redraw if rendering is up to date. - if popups.all(|popup| !popup.dirty()) { + if !self.dirty && popups.all(|popup| !popup.dirty()) { return false; } + self.dirty = false; drop(popups); self.update_regions(); @@ -216,9 +223,12 @@ impl Overlay { // Draw the popups. // + // The drawing happens in reverse order to ensure consistency with touch + // handling which happens in traditional iterator order. + // // NOTE: We still draw invisible popups to allow them to clear their dirty flags // after going invisible. Popups must not draw anything while invisible. - for popup in self.popups.iter_mut() { + for popup in self.popups.iter_mut().rev() { popup.draw(renderer); } } @@ -251,6 +261,16 @@ impl Overlay { position: Position, modifiers: Modifiers, ) { + // Close untouched option menus. + let mut menu_closed = false; + self.popups.option_menus.retain(|menu| { + let popup_position = menu.position().into(); + let touched = rect_contains(popup_position, menu.size().into(), position); + menu_closed |= !touched; + touched + }); + self.dirty |= menu_closed; + // Focus touched popup. for (i, popup) in self.popups.iter_mut().enumerate() { let popup_position = popup.position().into(); @@ -306,6 +326,14 @@ impl Overlay { } } + /// Insert text at the current cursor position. + pub fn paste(&mut self, text: String) { + let focused_popup = self.keyboard_focus.and_then(|focus| self.popups.iter_mut().nth(focus)); + if let Some(popup) = focused_popup { + popup.paste(text); + } + } + /// Set preedit text at the current cursor position. pub fn set_preedit_string(&mut self, text: String, cursor_begin: i32, cursor_end: i32) { let focused_popup = self.keyboard_focus.and_then(|focus| self.popups.iter_mut().nth(focus)); @@ -380,6 +408,7 @@ impl Overlay { /// Permanently discard an option menu. pub fn close_option_menu(&mut self, id: OptionMenuId) { self.popups.option_menus.retain(|menu| menu.id() != id); + self.dirty = true; } } @@ -412,23 +441,28 @@ impl Popups { } /// Non-mutable popup iterator. - fn iter(&self) -> Box + '_> { + fn iter(&self) -> Box { + let option_menus = self.option_menus.iter().filter(|m| m.visible()).map(|m| m as _); if self.tabs.visible() { - let option_menus = self.option_menus.iter().filter(|m| m.visible()).map(|m| m as _); Box::new(option_menus.chain([&self.tabs as _])) } else { - Box::new(self.option_menus.iter().filter(|menu| menu.visible()).map(|menu| menu as _)) + Box::new(option_menus) } } /// Mutable popup iterator. - fn iter_mut(&mut self) -> Box + '_> { + fn iter_mut(&mut self) -> Box + '_> { + let option_menus = self.option_menus.iter_mut().filter(|m| m.visible()).map(|m| m as _); if self.tabs.visible() { - let option_menus = self.option_menus.iter_mut().filter(|m| m.visible()).map(|m| m as _); Box::new(option_menus.chain([&mut self.tabs as _])) } else { - let iter = self.option_menus.iter_mut().filter(|menu| menu.visible()).map(|m| m as _); - Box::new(iter) + Box::new(option_menus) } } } + +// Wrappers for combining Iterator + DoubleEndedIterator trait bounds. +trait PopupIterator<'a>: Iterator + DoubleEndedIterator {} +impl<'a, T: Iterator + DoubleEndedIterator> PopupIterator<'a> for T {} +trait PopupIteratorMut<'a>: Iterator + DoubleEndedIterator {} +impl<'a, T: Iterator + DoubleEndedIterator> PopupIteratorMut<'a> for T {} diff --git a/src/ui/overlay/option_menu.rs b/src/ui/overlay/option_menu.rs index 268fb99..52dca8a 100644 --- a/src/ui/overlay/option_menu.rs +++ b/src/ui/overlay/option_menu.rs @@ -569,6 +569,7 @@ impl Popup for OptionMenu { } /// Entry in an option menu. +#[derive(Default)] pub struct OptionMenuItem { /// Option menu text. pub label: String, diff --git a/src/ui/overlay/tabs.rs b/src/ui/overlay/tabs.rs index e15ddf0..58d42d4 100644 --- a/src/ui/overlay/tabs.rs +++ b/src/ui/overlay/tabs.rs @@ -573,7 +573,13 @@ impl Popup for Tabs { } } - fn touch_down(&mut self, time: u32, id: i32, position: Position, _modifiers: Modifiers) { + fn touch_down( + &mut self, + time: u32, + id: i32, + logical_position: Position, + _modifiers: Modifiers, + ) { // Only accept a single touch point in the UI. if self.touch_state.slot.is_some() { return; @@ -581,7 +587,7 @@ impl Popup for Tabs { self.touch_state.slot = Some(id); // Convert position to physical space. - let position = position * self.scale; + let position = logical_position * self.scale; self.touch_state.position = position; self.touch_state.start = position; @@ -620,7 +626,7 @@ impl Popup for Tabs { } else if rect_contains(group_label_position, group_label_size, position) && self.group != NO_GROUP_ID { - self.group_label.touch_down(time, position - group_label_position); + self.group_label.touch_down(time, logical_position, position - group_label_position); self.touch_state.action = TouchAction::GroupLabelTouch; self.keyboard_focus = Some(KeyboardInputElement::GroupLabel); } else { @@ -670,7 +676,7 @@ impl Popup for Tabs { } } - fn touch_up(&mut self, _time: u32, id: i32, _modifiers: Modifiers) { + fn touch_up(&mut self, time: u32, id: i32, _modifiers: Modifiers) { // Ignore all unknown touch points. if self.touch_state.slot != Some(id) { return; @@ -732,7 +738,7 @@ impl Popup for Tabs { } }, // Forward group label events. - TouchAction::GroupLabelTouch => self.group_label.touch_up(), + TouchAction::GroupLabelTouch => self.group_label.touch_up(time), // Switch tabs for tap actions on a tab. TouchAction::TabTap => { if let Some((&RenderTab { engine, .. }, close)) = @@ -779,6 +785,12 @@ impl Popup for Tabs { } } + fn paste(&mut self, text: String) { + if let Some(KeyboardInputElement::GroupLabel) = self.keyboard_focus { + self.group_label.input.paste(text); + } + } + fn has_keyboard_focus(&self) -> bool { self.keyboard_focus.is_some() } @@ -1109,7 +1121,7 @@ struct GroupLabel { impl GroupLabel { fn new(window_id: WindowId, mut queue: MtQueueHandle) -> Self { - let mut input = TextField::new(FONT_SIZE); + let mut input = TextField::new(window_id, queue.clone(), FONT_SIZE); input.set_submit_handler(Box::new(move |label| queue.update_group_label(window_id, label))); Self { @@ -1224,14 +1236,19 @@ impl GroupLabel { } /// Handle touch press events. - pub fn touch_down(&mut self, time: u32, position: Position) { + pub fn touch_down( + &mut self, + time: u32, + absolute_position: Position, + position: Position, + ) { if !self.editing { return; } // Forward event to text field. let (text_position, _) = self.text_geometry(); - self.input.touch_down(time, position - text_position); + self.input.touch_down(time, absolute_position, position - text_position); } /// Handle touch motion events. @@ -1246,7 +1263,7 @@ impl GroupLabel { } /// Handle touch release events. - pub fn touch_up(&mut self) { + pub fn touch_up(&mut self, time: u32) { // Enable editing on touch release. if !self.editing { self.input.set_text(&self.text); @@ -1256,7 +1273,7 @@ impl GroupLabel { } // Forward event to text field. - self.input.touch_up(); + self.input.touch_up(time); } /// Get physical geometry of the text input area. diff --git a/src/window.rs b/src/window.rs index 58f375f..a4f6f40 100644 --- a/src/window.rs +++ b/src/window.rs @@ -10,7 +10,7 @@ use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; use _text_input::zwp_text_input_v3::{ChangeCause, ContentHint, ContentPurpose, ZwpTextInputV3}; -use funq::StQueueHandle; +use funq::{MtQueueHandle, StQueueHandle}; use glutin::display::Display; use indexmap::IndexMap; use smallvec::SmallVec; @@ -55,6 +55,15 @@ const DEFAULT_HEIGHT: u32 = 640; pub trait WindowHandler { /// Close a browser window. fn close_window(&mut self, window_id: WindowId); + + /// Write text to the system clipboard. + fn set_clipboard(&mut self, text: String); + + /// Request clipboard pasting. + fn request_paste(&mut self, target: PasteTarget); + + /// Paste text into the window. + fn paste(&mut self, target: PasteTarget, text: String); } impl WindowHandler for State { @@ -77,6 +86,41 @@ impl WindowHandler for State { self.storage.groups.delete_orphans(); } } + + fn set_clipboard(&mut self, text: String) { + self.set_clipboard(text); + } + + fn request_paste(&mut self, target: PasteTarget) { + self.request_paste(target); + } + + fn paste(&mut self, target: PasteTarget, text: String) { + let window = match self.windows.get_mut(&target.window_id()) { + Some(window) => window, + None => return, + }; + + match (window.keyboard_focus, target) { + (KeyboardFocus::Overlay, PasteTarget::Ui(_)) => { + window.overlay.paste(text); + window.unstall(); + }, + (KeyboardFocus::Ui, PasteTarget::Ui(_)) => { + window.ui.paste(text); + window.unstall(); + }, + (KeyboardFocus::Browser, PasteTarget::Browser(engine_id)) + if window.active_tab == Some(engine_id) => + { + if let Some(engine) = window.active_tab_mut() { + engine.paste(text); + } + }, + // Ignore paste requests if input focus has changed. + _ => (), + } + } } /// Wayland window. @@ -116,6 +160,9 @@ pub struct Window { session_storage: Session, group_storage: Groups, + text_menu: Option<(OptionMenuId, Option)>, + queue: MtQueueHandle, + last_rendered_engine: Option, stalled: bool, closed: bool, @@ -217,6 +264,7 @@ impl Window { session_storage: storage.session.clone(), group_storage: storage.groups.clone(), history: storage.history.clone(), + queue: queue.handle(), stalled: true, scale: 1., initial_configure_done: Default::default(), @@ -224,10 +272,11 @@ impl Window { last_rendered_engine: Default::default(), fullscreen_request: Default::default(), keyboard_focus: Default::default(), + fullscreened: Default::default(), history_menu: Default::default(), touch_points: Default::default(), text_input: Default::default(), - fullscreened: Default::default(), + text_menu: Default::default(), closed: Default::default(), groups: Default::default(), tabs: Default::default(), @@ -759,6 +808,11 @@ impl Window { if &self.engine_surface == surface { self.set_keyboard_focus(KeyboardFocus::Browser); + // Close active text input popups. + if let Some((id, _)) = self.text_menu.take() { + self.close_option_menu(id); + } + if let Some(engine) = self.active_tab_mut() { // Close all dropdowns when interacting with the page. engine.close_option_menu(None); @@ -772,6 +826,9 @@ impl Window { if let Some(engine) = self.active_tab_mut() { engine.close_option_menu(None); } + if let Some((id, _)) = self.text_menu.take() { + self.close_option_menu(id); + } self.ui.touch_down(time, id, position, modifiers); } else if self.overlay.surface() == surface { @@ -993,15 +1050,20 @@ impl Window { /// Handle submission for option menu spawned by the window. pub fn submit_option_menu(&mut self, menu_id: OptionMenuId, index: usize) { - // Ignore unknown menu IDs. - if Some(menu_id) != self.history_menu { - return; + if self.history_menu == Some(menu_id) { + // Load the selected URI. + let uri = self.history_menu_matches.swap_remove(index).uri; + self.ui.set_uri(&uri); + self.load_uri(uri, false); + } else if self.text_menu.as_ref().is_some_and(|(id, _)| *id == menu_id) { + let (_, selection) = self.text_menu.take().unwrap(); + match TextMenuItem::from_index(index as u8) { + Some(TextMenuItem::Paste) => self.queue.request_paste(PasteTarget::Ui(self.id)), + Some(TextMenuItem::Copy) => self.queue.set_clipboard(selection.unwrap()), + Some(TextMenuItem::_Invalid) | None => unreachable!(), + } + self.close_option_menu(menu_id); } - - // Load the selected URI. - let uri = self.history_menu_matches.swap_remove(index).uri; - self.ui.set_uri(&uri); - self.load_uri(uri, false); } /// Show history options menu. @@ -1023,7 +1085,7 @@ impl Window { } else { (m.title.clone(), m.uri.clone()) }; - OptionMenuItem { label, description, disabled: false, selected: false } + OptionMenuItem { label, description, ..Default::default() } }); match self.history_menu.and_then(|id| self.overlay.option_menu(id)) { @@ -1050,6 +1112,23 @@ impl Window { } } + /// Show text input long-press options menu. + #[cfg_attr(feature = "profiling", profiling::function)] + pub fn open_text_menu(&mut self, position: Position, selection: Option) { + if let Some((id, _)) = self.text_menu.take() { + self.close_option_menu(id); + } + + let menu_id = OptionMenuId::new(self.id); + let items: &[_] = match selection { + Some(_) => &[TextMenuItem::Paste, TextMenuItem::Copy], + None => &[TextMenuItem::Paste], + }; + let items = items.iter().copied().map(|item| item.into()); + self.open_option_menu(menu_id, position, None, items); + self.text_menu = Some((menu_id, selection)); + } + /// Hide history options menu. pub fn close_history_menu(&mut self) { if let Some(menu) = self.history_menu.and_then(|id| self.overlay.option_menu(id)) { @@ -1242,7 +1321,7 @@ impl Default for WindowId { } /// Keyboard focus surfaces. -#[derive(PartialEq, Eq, Copy, Clone, Default)] +#[derive(PartialEq, Eq, Copy, Clone, Default, Debug)] pub enum KeyboardFocus { None, #[default] @@ -1389,6 +1468,59 @@ pub enum TextInputChange { Dirty(TextInputState), } +/// Target for a clipboard paste action. +#[derive(Copy, Clone, Debug)] +pub enum PasteTarget { + Browser(EngineId), + Ui(WindowId), +} + +impl PasteTarget { + /// Get the target window ID. + pub fn window_id(&self) -> WindowId { + match self { + Self::Browser(engine_id) => engine_id.window_id(), + Self::Ui(window_id) => *window_id, + } + } +} + +/// Entries for the text input option menu. +#[repr(u8)] +#[derive(Copy, Clone)] +enum TextMenuItem { + Paste, + Copy, + // SAFETY: Must be last value, since it's used for "safe" transmute. + _Invalid, +} + +impl TextMenuItem { + /// Get item variant from its index. + fn from_index(index: u8) -> Option { + if index >= Self::_Invalid as u8 { + return None; + } + + Some(unsafe { mem::transmute::(index) }) + } + + /// Get the text label for this item. + fn label(&self) -> &'static str { + match self { + Self::Paste => "Paste", + Self::Copy => "Copy", + Self::_Invalid => unreachable!(), + } + } +} + +impl From for OptionMenuItem { + fn from(item: TextMenuItem) -> Self { + OptionMenuItem { label: item.label().into(), ..Default::default() } + } +} + #[allow(rustdoc::bare_urls)] /// Extract HTTP URI from uri bar input. ///