diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index e6842af..6def7e8 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -11,7 +11,7 @@ use crate::state::{AppState, ComposeAttach, NickTabComplete, ReplyTarget}; use crate::ui::search::{message_search_panel, SearchAction}; use crate::ui::widgets::{ avatar_circle, date_separator, empty_state, format_day_separator, message_bubble, - react_picker_overlay, MessageBubbleAction, + react_picker_overlay, text_edit_clipboard_menu, MessageBubbleAction, }; pub enum ChatAction { @@ -1167,7 +1167,12 @@ fn compose_input_row( egui::Modifiers::SHIFT, egui::Key::Enter, )); - text_resp = Some(ui.add_sized(Vec2::new(field_w, control_h), te)); + let resp = ui.add_sized(Vec2::new(field_w, control_h), te); + if field_interactive { + // Press-and-hold / right-click → Cut / Copy / Paste (APK system clipboard). + text_edit_clipboard_menu(ui, th, &resp); + } + text_resp = Some(resp); ui.add_space(gap); diff --git a/android/src/ui/chats.rs b/android/src/ui/chats.rs index e9f2eed..f17f599 100644 --- a/android/src/ui/chats.rs +++ b/android/src/ui/chats.rs @@ -4,7 +4,7 @@ use eframe::egui::{self, Align, Layout}; use vidya::{button, dim_label, primary_button, text_field_singleline, title, title_2, Theme}; use crate::state::AppState; -use crate::ui::widgets::{card, conversation_row, empty_state}; +use crate::ui::widgets::{card, conversation_row, empty_state, text_edit_clipboard_menu}; pub enum ChatsAction { None, @@ -53,6 +53,7 @@ pub fn chats_tab(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> ChatsAc .min_size(egui::vec2(0.0, th.spacing.control_height)) .hint_text("#channel"), ); + text_edit_clipboard_menu(ui, th, &resp); // singleline TextEdit surrenders focus on Enter — join when that happens. let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); diff --git a/android/src/ui/connect.rs b/android/src/ui/connect.rs index e2db457..cc98009 100644 --- a/android/src/ui/connect.rs +++ b/android/src/ui/connect.rs @@ -7,7 +7,7 @@ use vidya::{ }; use crate::state::{AppState, ConnectMode, ConnectionState}; -use crate::ui::widgets::{avatar_circle, card}; +use crate::ui::widgets::{avatar_circle, card, text_edit_clipboard_menu}; pub fn connect_screen(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> ConnectAction { let mut action = ConnectAction::None; @@ -255,6 +255,9 @@ fn handle_field(ui: &mut egui::Ui, th: &Theme, state: &mut AppState, login_loadi .hint_text("you.bsky.social") .interactive(!login_loading), ); + if !login_loading { + text_edit_clipboard_menu(ui, th, &resp); + } // Only sync while the user is editing — prefilled handles from prefs should // not open typeahead on cold start and hide the Recent chips. diff --git a/android/src/ui/discover.rs b/android/src/ui/discover.rs index 0fc581f..d223193 100644 --- a/android/src/ui/discover.rs +++ b/android/src/ui/discover.rs @@ -4,7 +4,7 @@ use eframe::egui::{self, Align, CursorIcon, Layout, RichText}; use vidya::{body, dim_label, primary_button, title, title_2, Theme}; use crate::state::{AppState, POPULAR_CHANNELS}; -use crate::ui::widgets::{avatar_circle, card}; +use crate::ui::widgets::{avatar_circle, card, text_edit_clipboard_menu}; pub enum DiscoverAction { None, @@ -36,6 +36,7 @@ pub fn discover_tab(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) -> Disc .min_size(egui::vec2(0.0, th.spacing.control_height)) .hint_text("#channel"), ); + text_edit_clipboard_menu(ui, th, &resp); // singleline TextEdit surrenders focus on Enter — join when that happens. let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); diff --git a/android/src/ui/mod.rs b/android/src/ui/mod.rs index 0031adc..e9d0187 100644 --- a/android/src/ui/mod.rs +++ b/android/src/ui/mod.rs @@ -21,5 +21,5 @@ pub use settings::{settings_tab, SettingsAction}; pub use widgets::{ avatar_circle, card, conversation_row, date_separator, format_day_separator, image_lightbox_overlay, message_bubble, react_picker_overlay, section_label, - MessageBubbleAction, + text_edit_clipboard_menu, MessageBubbleAction, }; diff --git a/android/src/ui/search.rs b/android/src/ui/search.rs index 631dd7c..7c6808c 100644 --- a/android/src/ui/search.rs +++ b/android/src/ui/search.rs @@ -6,6 +6,7 @@ use egui::text::{LayoutJob, TextFormat}; use vidya::{button, dim_label, lead_trail, Theme}; use crate::state::{AppState, MessageSearchHit}; +use crate::ui::widgets::text_edit_clipboard_menu; pub enum SearchAction { None, @@ -42,6 +43,7 @@ pub fn message_search_panel(ui: &mut egui::Ui, th: &Theme, state: &mut AppState) .min_size(egui::vec2(0.0, th.spacing.control_height)) .hint_text("Search messages…"), ); + text_edit_clipboard_menu(ui, th, &resp); if want_focus { resp.request_focus(); } diff --git a/android/src/ui/widgets.rs b/android/src/ui/widgets.rs index ac96151..5c2da28 100644 --- a/android/src/ui/widgets.rs +++ b/android/src/ui/widgets.rs @@ -726,6 +726,162 @@ fn touch_long_press(ui: &mut egui::Ui, press_id: Id, rect: Rect) -> (bool, bool) (long_this_frame, suppress_click) } +/// Cut / Copy / Paste for a `TextEdit`: right-click on desktop, press-and-hold on APK. +/// +/// egui does not ship a built-in TextEdit clipboard menu. Stock +/// [`Response::context_menu`] also closes on the next frame while the finger is +/// still down after a long-press (`hovered && primary_down`), so we use the same +/// touch-safe popup pattern as message bubbles (finger clearance + close on new +/// press, not release). Selecting **Paste** sends [`ViewportCommand::RequestPaste`], +/// which eframe turns into `Event::Paste` via the Android system clipboard hooks. +pub fn text_edit_clipboard_menu(ui: &mut egui::Ui, th: &Theme, response: &egui::Response) { + if !response.enabled() { + return; + } + + let menu_id = response.id.with("text_clipboard_menu"); + let press_id = menu_id.with("press"); + let touch_ui = + cfg!(target_os = "android") || ui.input(|i| i.any_touches() || i.has_touch_screen()); + + // egui maps press-and-hold → `secondary_clicked` / `long_touched`. Also run + // our own hold tracker so the menu still opens if TextEdit click-sense is + // contested (same approach as message bubbles). + let (long_press_anywhere, _) = touch_long_press(ui, press_id, response.rect); + let opening = response.secondary_clicked() || long_press_anywhere; + + if opening { + response.request_focus(); + let anchor = if let Some(finger) = ui.ctx().pointer_interact_pos() { + if touch_ui || long_press_anywhere || response.long_touched() { + const FINGER_CLEARANCE: f32 = 72.0; + MenuAnchor { + pos: Pos2::new(finger.x, finger.y - FINGER_CLEARANCE), + above_finger: true, + } + } else { + MenuAnchor { + pos: finger, + above_finger: false, + } + } + } else { + MenuAnchor { + pos: Pos2::new(response.rect.center().x, response.rect.top() - 8.0), + above_finger: true, + } + }; + ui.memory_mut(|m| m.open_popup(menu_id)); + ui.ctx().data_mut(|d| d.insert_temp(menu_id, anchor)); + } + + if !ui.memory(|m| m.is_popup_open(menu_id)) { + return; + } + + let anchor = ui + .ctx() + .data(|d| d.get_temp::(menu_id)) + .unwrap_or(MenuAnchor { + pos: response.rect.left_top(), + above_finger: touch_ui, + }); + + let p = &th.palette; + let sp = &th.spacing; + let mut close = false; + let pivot = if anchor.above_finger { + Align2::CENTER_BOTTOM + } else { + Align2::LEFT_TOP + }; + + let popup = egui::Area::new(menu_id.with("area")) + .kind(egui::UiKind::Popup) + .order(Order::Foreground) + .fixed_pos(anchor.pos) + .pivot(pivot) + .sense(Sense::click()) + .show(ui.ctx(), |ui| { + message_action_bar_frame(th, sp.sm).shadow(ui.style().visuals.popup_shadow).show( + ui, + |ui| { + ui.set_min_width(128.0); + ui.spacing_mut().item_spacing.y = 2.0; + for (label, cmd) in [ + ("Cut", egui::ViewportCommand::RequestCut), + ("Copy", egui::ViewportCommand::RequestCopy), + ("Paste", egui::ViewportCommand::RequestPaste), + ] { + let clicked = ui + .add_sized( + Vec2::new(ui.available_width().max(120.0), sp.control_height.min(40.0)), + egui::Button::new( + RichText::new(label) + .size(th.type_scale.body) + .color(p.text), + ) + .fill(Color32::TRANSPARENT) + .stroke(Stroke::NONE) + .corner_radius(sp.radius_sm), + ) + .on_hover_cursor(CursorIcon::PointingHand) + .clicked(); + if clicked { + response.request_focus(); + ui.ctx().send_viewport_cmd(cmd); + close = true; + } + } + }, + ); + }); + + if anchor.above_finger { + let screen = ui.ctx().screen_rect(); + let r = popup.response.rect; + let mut pos = anchor.pos; + let mut moved = false; + if r.left() < screen.left() + 4.0 { + pos.x += (screen.left() + 4.0) - r.left(); + moved = true; + } else if r.right() > screen.right() - 4.0 { + pos.x -= r.right() - (screen.right() - 4.0); + moved = true; + } + if r.top() < screen.top() + 4.0 { + pos.y += (screen.top() + 4.0) - r.top(); + moved = true; + } + if moved { + ui.ctx().data_mut(|d| { + d.insert_temp( + menu_id, + MenuAnchor { + pos, + above_finger: true, + }, + ) + }); + } + } + + let escape = ui.input(|i| i.key_pressed(Key::Escape)); + let press_outside = ui.input(|i| i.pointer.any_pressed()) + && !popup.response.contains_pointer() + && ui + .ctx() + .pointer_interact_pos() + .is_some_and(|p| !popup.response.rect.contains(p)); + if close || escape || (press_outside && !opening) { + ui.memory_mut(|m| m.close_popup()); + ui.ctx().data_mut(|d| { + d.remove::(menu_id); + d.remove::(press_id); + }); + } +} + /// Long-press (APK) popup listing nicknames who used one reaction emoji. fn reaction_reactors_popup( ui: &mut egui::Ui, @@ -1561,13 +1717,14 @@ pub fn react_picker_overlay( close = true; } let search_w = (ui.available_width() - 8.0).clamp(100.0, 200.0); - ui.add( + let search_resp = ui.add( egui::TextEdit::singleline(&mut state.react_picker_search) .id_salt(("react_emoji_search", msgid.as_str())) .desired_width(search_w) .hint_text("Search emoji…") .font(egui::TextStyle::Body), ); + text_edit_clipboard_menu(ui, th, &search_resp); }); }); @@ -2723,3 +2880,29 @@ mod day_separator_tests { assert!(!label.contains("Yesterday")); } } + +#[cfg(test)] +mod text_edit_clipboard_menu_tests { + /// Hold-to-paste on APK needs these viewport commands so eframe emits + /// Cut/Copy/`Event::Paste` after the JNI clipboard hooks run. + #[test] + fn clipboard_menu_viewport_commands_match_eframe_actions() { + use eframe::egui::ViewportCommand; + let cmds = [ + ViewportCommand::RequestCut, + ViewportCommand::RequestCopy, + ViewportCommand::RequestPaste, + ]; + // Discriminants must stay distinct — a typo collapsing Paste→Copy would + // silently break APK hold-to-paste while still compiling. + assert_ne!( + std::mem::discriminant(&cmds[0]), + std::mem::discriminant(&cmds[2]) + ); + assert_ne!( + std::mem::discriminant(&cmds[1]), + std::mem::discriminant(&cmds[2]) + ); + assert!(matches!(cmds[2], ViewportCommand::RequestPaste)); + } +} diff --git a/vendor/egui-winit/PATCHES.md b/vendor/egui-winit/PATCHES.md index 04e948b..8db366f 100644 --- a/vendor/egui-winit/PATCHES.md +++ b/vendor/egui-winit/PATCHES.md @@ -20,7 +20,9 @@ falls back to X11 — which still works for many image pastes when the composito 2. **`lib.rs` paste shortcut**: if clipboard text is missing/empty, still push `Event::Key` for the paste key so apps can handle Ctrl+V image paste. 3. **`clipboard.rs` Android hooks**: `set_android_clipboard_hooks` so long-press paste reads the - system `ClipboardManager` (stock fallback is in-app text only). + system `ClipboardManager` (stock fallback is in-app text only). Sleek attaches a Cut/Copy/Paste + menu on `TextEdit`s (`text_edit_clipboard_menu`) that issues `ViewportCommand::RequestPaste`; + without that menu the hooks never run for hold-to-paste. 4. **`lib.rs` Android back**: map `NamedKey::BrowserBack` (KEYCODE_BACK / gesture back) to `Key::Escape`. Upstream egui ≥0.32 adds `Key::BrowserBack` instead; drop this when upgrading.