A9: right-click context menu (Cut/Copy/Paste/Select All) for all text fields
iced 0.14 ships no native right-click menu on text_input. Add a custom ContextInput widget (src/widget/context_input.rs) that wraps text_input, intercepts right-click to read the inner text_input::State selection, and renders a themed 4-action overlay menu operating on that selection. - Pure, grapheme-indexed edit seam (copy/cut/paste/select_all over iced text_input::Value), unit-tested for ASCII and multi-byte/emoji. - iced::advanced Widget + overlay::Overlay; clipboard via &mut dyn Clipboard, edits published through the existing on_input/on_paste. - Cut/Copy disabled on empty selection (and on secure fields), Select All disabled on empty field, Paste always enabled; dismiss on click-out / Esc / item-click. - Route all 10 text_input call sites in app/mod.rs through context_input. - Cargo.toml: enable iced "advanced" feature (same crate, no new dep). 459 lib tests (+5), clippy --all-targets clean, release green. Implemented by Codex (gpt-5.5), senior-audited against the 5-point brief and re-verified (tests/clippy/release) here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
This commit is contained in:
+1
-1
@@ -57,7 +57,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
|
||||
+13
-11
@@ -11,12 +11,14 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||
use crate::presence::PresenceMode;
|
||||
use crate::theme::{AppTheme, Palette};
|
||||
use crate::widget::context_input::context_input;
|
||||
|
||||
use iced::widget::{
|
||||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||||
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
|
||||
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||||
rich_text, span, responsive,
|
||||
};
|
||||
use iced::widget::text_input;
|
||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||
use iced::{
|
||||
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
|
||||
@@ -2624,7 +2626,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let nickname_input = column![
|
||||
text("Nickname").size(14).color(color_subtext),
|
||||
vertical_space(4.0),
|
||||
text_input("Enter nickname...", &state.name)
|
||||
context_input("Enter nickname...", &state.name)
|
||||
.on_input(AppMessage::NicknameChanged)
|
||||
.style(t_style)
|
||||
.padding(10)
|
||||
@@ -2633,7 +2635,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
// Optional cosmetic room label (W7) above the Create button: it rides in the
|
||||
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
|
||||
let create_group = column![
|
||||
text_input("Room name (optional)", &state.room_name_input)
|
||||
context_input("Room name (optional)", &state.room_name_input)
|
||||
.on_input(AppMessage::RoomNameChanged)
|
||||
.on_submit(AppMessage::CreatePressed)
|
||||
.style(t_style)
|
||||
@@ -2649,7 +2651,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let join_group = column![
|
||||
text("Join Existing Room").size(14).color(color_subtext),
|
||||
vertical_space(4.0),
|
||||
text_input("Paste room ticket here...", &state.ticket_input)
|
||||
context_input("Paste room ticket here...", &state.ticket_input)
|
||||
.on_input(AppMessage::TicketInputChanged)
|
||||
.style(t_style)
|
||||
.padding(10),
|
||||
@@ -2875,7 +2877,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
};
|
||||
friend_rows = friend_rows.push(
|
||||
row![
|
||||
text_input("name", &f.name)
|
||||
context_input("name", &f.name)
|
||||
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
||||
.style(t_style)
|
||||
.padding(6)
|
||||
@@ -2909,13 +2911,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
};
|
||||
|
||||
let add_form = column![
|
||||
text_input("Friend's node ID", &state.friend_add_id)
|
||||
context_input("Friend's node ID", &state.friend_add_id)
|
||||
.on_input(AppMessage::FriendAddIdChanged)
|
||||
.style(t_style)
|
||||
.padding(6),
|
||||
vertical_space(6.0),
|
||||
row![
|
||||
text_input("Name (optional)", &state.friend_add_name)
|
||||
context_input("Name (optional)", &state.friend_add_name)
|
||||
.on_input(AppMessage::FriendAddNameChanged)
|
||||
.style(t_style)
|
||||
.padding(6)
|
||||
@@ -3164,7 +3166,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
horizontal_space(),
|
||||
validation_widget,
|
||||
].spacing(6).align_y(iced::alignment::Vertical::Center),
|
||||
text_input("Default (embedded)...", path)
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
@@ -3834,10 +3836,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||||
.size(11).color(color_subtext),
|
||||
row![
|
||||
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||||
context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||||
.on_input(AppMessage::GameMapExeChanged)
|
||||
.width(iced::Length::Fill),
|
||||
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||||
context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||||
.on_input(AppMessage::GameMapNameChanged)
|
||||
.width(iced::Length::Fill),
|
||||
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||||
@@ -4836,7 +4838,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_press(AppMessage::PickAttachmentFile)
|
||||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||||
.padding(8),
|
||||
text_input("Message the room…", &state.chat_input)
|
||||
context_input("Message the room…", &state.chat_input)
|
||||
.on_input(AppMessage::ChatInputChanged)
|
||||
.on_submit(AppMessage::ChatSubmit)
|
||||
.style(t_style)
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
pub mod game;
|
||||
pub mod widget;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
use iced::advanced::clipboard::{self, Clipboard};
|
||||
use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::overlay;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text;
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::{self, Widget};
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text_input;
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Edit {
|
||||
pub value: String,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
||||
let value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
(start != end).then(|| value.select(start, end).to_string())
|
||||
}
|
||||
|
||||
pub fn cut_selection(
|
||||
value: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (Edit, Option<String>) {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
if start == end {
|
||||
return (
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let selected = value.select(start, end).to_string();
|
||||
value.remove_many(start, end);
|
||||
|
||||
(
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
Some(selected),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
let clip = text_input::Value::new(clip);
|
||||
let cursor = start + clip.len();
|
||||
|
||||
if start != end {
|
||||
value.remove_many(start, end);
|
||||
}
|
||||
value.insert_many(start, clip);
|
||||
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||
let value = text_input::Value::new(value);
|
||||
|
||||
(0, value.len())
|
||||
}
|
||||
|
||||
fn normalized_range(
|
||||
value: &text_input::Value,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (usize, usize) {
|
||||
let len = value.len();
|
||||
|
||||
(start.min(end).min(len), start.max(end).min(len))
|
||||
}
|
||||
|
||||
type InputStyleFn<'a, Theme> =
|
||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||
|
||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||
placeholder: &str,
|
||||
value: &str,
|
||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
ContextInput::new(placeholder, value)
|
||||
}
|
||||
|
||||
pub struct ContextInput<
|
||||
'a,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||
value: String,
|
||||
is_secure: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
pub fn new(placeholder: &str, value: &str) -> Self {
|
||||
Self {
|
||||
input: text_input::TextInput::new(placeholder, value),
|
||||
value: value.to_owned(),
|
||||
is_secure: false,
|
||||
on_input: None,
|
||||
on_paste: None,
|
||||
style: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
||||
self.input = self.input.id(id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secure(mut self, is_secure: bool) -> Self {
|
||||
self.is_secure = is_secure;
|
||||
self.input = self.input.secure(is_secure);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_input(
|
||||
mut self,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_input);
|
||||
let input_callback = Rc::clone(&on_input);
|
||||
|
||||
self.input =
|
||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
||||
self.on_input = Some(on_input);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit(mut self, message: Message) -> Self {
|
||||
self.input = self.input.on_submit(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
||||
self.input = self.input.on_submit_maybe(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_paste(
|
||||
mut self,
|
||||
on_paste: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_paste);
|
||||
let paste_callback = Rc::clone(&on_paste);
|
||||
|
||||
self.input =
|
||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
||||
self.on_paste = Some(on_paste);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font(mut self, font: Renderer::Font) -> Self {
|
||||
self.input = self.input.font(font);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
|
||||
self.input = self.input.icon(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.input = self.input.width(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.input = self.input.padding(padding);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.input = self.input.size(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(
|
||||
mut self,
|
||||
line_height: impl Into<text::LineHeight>,
|
||||
) -> Self {
|
||||
self.input = self.input.line_height(line_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Horizontal>,
|
||||
) -> Self {
|
||||
self.input = self.input.align_x(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(
|
||||
mut self,
|
||||
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
|
||||
) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
|
||||
{
|
||||
let style: InputStyleFn<'a, Theme> = Rc::new(style);
|
||||
let input_style = Rc::clone(&style);
|
||||
|
||||
self.input = self
|
||||
.input
|
||||
.style(move |theme, status| input_style.as_ref()(theme, status));
|
||||
self.style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.input = self.input.class(class);
|
||||
self.style = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContextInputState {
|
||||
menu: Option<MenuState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MenuState {
|
||||
anchor: Point,
|
||||
selection: (usize, usize),
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for ContextInput<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone,
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<ContextInputState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(ContextInputState::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
if tree.children.is_empty() {
|
||||
tree.children
|
||||
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
|
||||
} else {
|
||||
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
|
||||
tree.children.truncate(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Widget::size(&self.input)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
Widget::size_hint(&self.input)
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
Widget::operate(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let right_click_on_input = matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
) && cursor.is_over(layout.bounds());
|
||||
|
||||
if right_click_on_input {
|
||||
let value = text_input::Value::new(&self.value);
|
||||
let input_state = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
|
||||
let selection = match input_state.cursor().state(&value) {
|
||||
text_input::cursor::State::Index(index) => {
|
||||
let index = index.min(value.len());
|
||||
(index, index)
|
||||
}
|
||||
text_input::cursor::State::Selection { start, end } => {
|
||||
normalized_range(&value, start, end)
|
||||
}
|
||||
};
|
||||
|
||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
||||
cursor.position().map(|anchor| MenuState {
|
||||
anchor,
|
||||
selection,
|
||||
});
|
||||
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
Widget::update(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
Widget::draw(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
Widget::mouse_interaction(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
_layout: Layout<'a>,
|
||||
_renderer: &Renderer,
|
||||
_viewport: &Rectangle,
|
||||
_translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let Tree {
|
||||
state, children, ..
|
||||
} = tree;
|
||||
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
|
||||
|
||||
if menu.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_state = children[0]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||
|
||||
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
|
||||
menu,
|
||||
input_state,
|
||||
value: &self.value,
|
||||
is_secure: self.is_secure,
|
||||
on_input: self.on_input.clone(),
|
||||
on_paste: self.on_paste.clone(),
|
||||
style: self.style.clone(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
input: ContextInput<'a, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(input)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
menu: &'a mut Option<MenuState>,
|
||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||
value: &'a str,
|
||||
is_secure: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MenuAction {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
SelectAll,
|
||||
}
|
||||
|
||||
impl MenuAction {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::Cut,
|
||||
Self::Copy,
|
||||
Self::Paste,
|
||||
Self::SelectAll,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cut => "Cut",
|
||||
Self::Copy => "Copy",
|
||||
Self::Paste => "Paste",
|
||||
Self::SelectAll => "Select All",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MENU_WIDTH: f32 = 136.0;
|
||||
const ITEM_HEIGHT: f32 = 28.0;
|
||||
const TEXT_SIZE: f32 = 13.0;
|
||||
const MENU_PADDING_X: f32 = 10.0;
|
||||
|
||||
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return layout::Node::new(Size::ZERO);
|
||||
};
|
||||
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
|
||||
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
|
||||
|
||||
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||
let hovered_style =
|
||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||
let bounds = layout.bounds();
|
||||
let viewport = Rectangle::INFINITE;
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: Border {
|
||||
radius: 5.0.into(),
|
||||
width: 1.0,
|
||||
color: active_style.border.color,
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
|
||||
offset: Vector::new(0.0, 4.0),
|
||||
blur_radius: 10.0,
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
active_style.background,
|
||||
);
|
||||
|
||||
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
|
||||
let item_bounds = item_bounds(bounds, index);
|
||||
let enabled = self.enabled(action);
|
||||
let hovered = enabled && cursor.is_over(item_bounds);
|
||||
|
||||
if hovered {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: item_bounds,
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(hovered_style.selection),
|
||||
);
|
||||
}
|
||||
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content: action.label().to_owned(),
|
||||
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
|
||||
size: Pixels(TEXT_SIZE),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Default,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::default(),
|
||||
},
|
||||
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
|
||||
if enabled {
|
||||
active_style.value
|
||||
} else {
|
||||
disabled_color(active_style.value)
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
match event {
|
||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
..
|
||||
}) => {
|
||||
self.close(shell);
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
let Some(position) = cursor.position() else {
|
||||
self.close(shell);
|
||||
return;
|
||||
};
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if !bounds.contains(position) {
|
||||
self.close(shell);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(action) = self.hit_action(bounds, position) {
|
||||
if self.enabled(action) {
|
||||
self.perform(action, clipboard, shell);
|
||||
}
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
let should_close = cursor
|
||||
.position()
|
||||
.is_none_or(|position| !layout.bounds().contains(position));
|
||||
|
||||
if should_close {
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let Some(position) = cursor.position() else {
|
||||
return mouse::Interaction::default();
|
||||
};
|
||||
|
||||
if self.hit_action(layout.bounds(), position).is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn enabled(&self, action: MenuAction) -> bool {
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let has_selection = menu.selection.0 != menu.selection.1;
|
||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||
|
||||
match action {
|
||||
MenuAction::Cut | MenuAction::Copy => {
|
||||
has_selection && !self.is_secure
|
||||
}
|
||||
MenuAction::Paste => true,
|
||||
MenuAction::SelectAll => has_value,
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_action(
|
||||
&self,
|
||||
bounds: Rectangle,
|
||||
position: Point,
|
||||
) -> Option<MenuAction> {
|
||||
if !bounds.contains(position) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
|
||||
|
||||
MenuAction::ALL.get(index).copied()
|
||||
}
|
||||
|
||||
fn perform(
|
||||
&mut self,
|
||||
action: MenuAction,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let Some(menu) = self.menu.as_ref().copied() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = menu.selection;
|
||||
|
||||
match action {
|
||||
MenuAction::Cut => {
|
||||
let (edit, selected) = cut_selection(self.value, start, end);
|
||||
|
||||
if let Some(selected) = selected {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
self.publish_edit(edit, shell);
|
||||
}
|
||||
}
|
||||
MenuAction::Copy => {
|
||||
if let Some(selected) = copy_selection(self.value, start, end) {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
}
|
||||
}
|
||||
MenuAction::Paste => {
|
||||
let clip = clipboard
|
||||
.read(clipboard::Kind::Standard)
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>();
|
||||
let edit = paste(self.value, start, end, &clip);
|
||||
|
||||
self.publish_paste(edit, shell);
|
||||
}
|
||||
MenuAction::SelectAll => {
|
||||
let (start, end) = select_all_range(self.value);
|
||||
|
||||
self.input_state.select_range(start, end);
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
if let Some(on_input) = &self.on_input {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
|
||||
if let Some(on_paste) = &self.on_paste {
|
||||
shell.publish(on_paste.as_ref()(edit.value));
|
||||
} else if let Some(on_input) = &self.on_input {
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
}
|
||||
|
||||
shell.request_redraw();
|
||||
}
|
||||
|
||||
fn close(&mut self, shell: &mut Shell<'_, Message>) {
|
||||
*self.menu = None;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: menu_bounds.x + 3.0,
|
||||
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
|
||||
width: menu_bounds.width - 6.0,
|
||||
height: ITEM_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_style<Theme: text_input::Catalog>(
|
||||
theme: &Theme,
|
||||
style: Option<&InputStyleFn<'_, Theme>>,
|
||||
status: text_input::Status,
|
||||
) -> text_input::Style {
|
||||
if let Some(style) = style {
|
||||
style.as_ref()(theme, status)
|
||||
} else {
|
||||
let class = <Theme as text_input::Catalog>::default();
|
||||
|
||||
theme.style(&class, status)
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_color(color: Color) -> Color {
|
||||
Color {
|
||||
a: color.a * 0.45,
|
||||
..color
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copy_selection_returns_middle_substring_and_empty_none() {
|
||||
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
|
||||
assert_eq!(copy_selection("abcdef", 3, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_selection_removes_range_and_copies_selection() {
|
||||
let (edit, clip) = cut_selection("abcdef", 2, 5);
|
||||
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abf".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, Some("cde".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("abcdef", 3, 3);
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abcdef".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||
assert_eq!(
|
||||
paste("abcdef", 2, 5, "XY"),
|
||||
Edit {
|
||||
value: "abXYf".to_owned(),
|
||||
cursor: 4,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
paste("abcdef", 3, 3, "XY"),
|
||||
Edit {
|
||||
value: "abcXYdef".to_owned(),
|
||||
cursor: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_range_uses_grapheme_length() {
|
||||
assert_eq!(select_all_range(""), (0, 0));
|
||||
assert_eq!(select_all_range("abé🦀"), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_selection_boundaries_are_grapheme_correct() {
|
||||
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
|
||||
assert_eq!(clip, Some("🦀".to_owned()));
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "aéz".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
paste("aéz", 2, 2, "🦀"),
|
||||
Edit {
|
||||
value: "aé🦀z".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod context_input;
|
||||
Reference in New Issue
Block a user