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"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
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
|
# 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-
|
# 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).
|
# 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::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||||
use crate::presence::PresenceMode;
|
use crate::presence::PresenceMode;
|
||||||
use crate::theme::{AppTheme, Palette};
|
use crate::theme::{AppTheme, Palette};
|
||||||
|
use crate::widget::context_input::context_input;
|
||||||
|
|
||||||
use iced::widget::{
|
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,
|
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||||||
rich_text, span, responsive,
|
rich_text, span, responsive,
|
||||||
};
|
};
|
||||||
|
use iced::widget::text_input;
|
||||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||||
use iced::{
|
use iced::{
|
||||||
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
|
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![
|
let nickname_input = column![
|
||||||
text("Nickname").size(14).color(color_subtext),
|
text("Nickname").size(14).color(color_subtext),
|
||||||
vertical_space(4.0),
|
vertical_space(4.0),
|
||||||
text_input("Enter nickname...", &state.name)
|
context_input("Enter nickname...", &state.name)
|
||||||
.on_input(AppMessage::NicknameChanged)
|
.on_input(AppMessage::NicknameChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(10)
|
.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
|
// 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.
|
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
|
||||||
let create_group = column![
|
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_input(AppMessage::RoomNameChanged)
|
||||||
.on_submit(AppMessage::CreatePressed)
|
.on_submit(AppMessage::CreatePressed)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
@@ -2649,7 +2651,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let join_group = column![
|
let join_group = column![
|
||||||
text("Join Existing Room").size(14).color(color_subtext),
|
text("Join Existing Room").size(14).color(color_subtext),
|
||||||
vertical_space(4.0),
|
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)
|
.on_input(AppMessage::TicketInputChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(10),
|
.padding(10),
|
||||||
@@ -2875,7 +2877,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
};
|
};
|
||||||
friend_rows = friend_rows.push(
|
friend_rows = friend_rows.push(
|
||||||
row![
|
row![
|
||||||
text_input("name", &f.name)
|
context_input("name", &f.name)
|
||||||
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
@@ -2909,13 +2911,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let add_form = column![
|
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)
|
.on_input(AppMessage::FriendAddIdChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6),
|
.padding(6),
|
||||||
vertical_space(6.0),
|
vertical_space(6.0),
|
||||||
row![
|
row![
|
||||||
text_input("Name (optional)", &state.friend_add_name)
|
context_input("Name (optional)", &state.friend_add_name)
|
||||||
.on_input(AppMessage::FriendAddNameChanged)
|
.on_input(AppMessage::FriendAddNameChanged)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(6)
|
.padding(6)
|
||||||
@@ -3164,7 +3166,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
horizontal_space(),
|
horizontal_space(),
|
||||||
validation_widget,
|
validation_widget,
|
||||||
].spacing(6).align_y(iced::alignment::Vertical::Center),
|
].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))
|
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(8)
|
.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.")
|
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||||||
.size(11).color(color_subtext),
|
.size(11).color(color_subtext),
|
||||||
row![
|
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)
|
.on_input(AppMessage::GameMapExeChanged)
|
||||||
.width(iced::Length::Fill),
|
.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)
|
.on_input(AppMessage::GameMapNameChanged)
|
||||||
.width(iced::Length::Fill),
|
.width(iced::Length::Fill),
|
||||||
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||||||
@@ -4836,7 +4838,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.on_press(AppMessage::PickAttachmentFile)
|
.on_press(AppMessage::PickAttachmentFile)
|
||||||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||||||
.padding(8),
|
.padding(8),
|
||||||
text_input("Message the room…", &state.chat_input)
|
context_input("Message the room…", &state.chat_input)
|
||||||
.on_input(AppMessage::ChatInputChanged)
|
.on_input(AppMessage::ChatInputChanged)
|
||||||
.on_submit(AppMessage::ChatSubmit)
|
.on_submit(AppMessage::ChatSubmit)
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod discovery;
|
|||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
|
pub mod widget;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
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