Add a read-only-but-selectable "locked" mode to the A9 ContextInput so share-critical values (full node ID, full room ticket) can be drag-selected and copied with the mouse/keyboard, in addition to the existing one-click Copy buttons (which are kept). - context_input.rs: add `locked` flag + builder + `locked_value(value, noop)` constructor. A controlled text_input with a no-op on_input stays focusable and selection-capable while never mutating (iced treats on_input==None as Disabled, verified against iced_widget-0.14.2 source). - Extract overlay gating into a pure `menu_action_enabled` seam: when locked, Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and Select All is enabled when there's a value. +1 unit test. - app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in width-capped locked fields beside their existing Copy buttons. Phase 2 (cross-message selectable chat transcript) intentionally deferred: it requires a transcript-level custom widget that owns selection/layout/hit- testing while preserving A13 links and attachment rows — out of scope for a bounded edit. Design path recorded in the Codex task report. Tests-green only (460 lib, clippy clean, release build green); wants a quick field check of mouse drag-select + right-click Copy + Ctrl+A/C. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
944 lines
26 KiB
Rust
944 lines
26 KiB
Rust
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 fn locked_value<'a, Message, Theme, Renderer>(
|
|
value: &str,
|
|
noop: Message,
|
|
) -> ContextInput<'a, Message, Theme, Renderer>
|
|
where
|
|
Message: Clone + 'a,
|
|
Theme: text_input::Catalog + 'a,
|
|
Renderer: text::Renderer,
|
|
{
|
|
ContextInput::new("", value)
|
|
.on_input(move |_| noop.clone())
|
|
.locked(true)
|
|
}
|
|
|
|
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,
|
|
locked: 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,
|
|
locked: 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 locked(mut self, yes: bool) -> Self {
|
|
self.locked = yes;
|
|
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,
|
|
locked: self.locked,
|
|
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,
|
|
locked: 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();
|
|
|
|
menu_action_enabled(
|
|
action,
|
|
has_selection,
|
|
has_value,
|
|
self.is_secure,
|
|
self.locked,
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
fn menu_action_enabled(
|
|
action: MenuAction,
|
|
has_selection: bool,
|
|
has_value: bool,
|
|
is_secure: bool,
|
|
locked: bool,
|
|
) -> bool {
|
|
match action {
|
|
MenuAction::Cut => has_selection && !is_secure && !locked,
|
|
MenuAction::Copy => has_selection && !is_secure,
|
|
MenuAction::Paste => !locked,
|
|
MenuAction::SelectAll => has_value,
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_menu_allows_copy_and_select_all_only() {
|
|
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
|
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
|
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
|
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
|
|
|
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
|
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
|
}
|
|
}
|