Previously entering text in text inputs with the compose key would insert one or more NUL bytes before the inserted character.
2683 lines
92 KiB
Rust
2683 lines
92 KiB
Rust
// Copyright 2019 H<>ctor Ram<61>n, Iced contributors
|
||
// Copyright 2023 System76 <info@system76.com>
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
//! Display fields that can be filled with text.
|
||
//!
|
||
//! A [`TextInput`] has some local [`State`].
|
||
use std::borrow::Cow;
|
||
use std::cell::{Cell, LazyCell};
|
||
|
||
use crate::ext::ColorExt;
|
||
use crate::theme::THEME;
|
||
|
||
use super::cursor;
|
||
pub use super::cursor::Cursor;
|
||
use super::editor::Editor;
|
||
use super::style::StyleSheet;
|
||
pub use super::value::Value;
|
||
|
||
use apply::Apply;
|
||
use cosmic_theme::Theme;
|
||
use iced::clipboard::dnd::{DndAction, DndEvent, OfferEvent, SourceEvent};
|
||
use iced::clipboard::mime::AsMimeTypes;
|
||
use iced::Limits;
|
||
use iced_core::event::{self, Event};
|
||
use iced_core::mouse::{self, click};
|
||
use iced_core::overlay::Group;
|
||
use iced_core::renderer::{self, Renderer as CoreRenderer};
|
||
use iced_core::text::{self, Paragraph, Renderer, Text};
|
||
use iced_core::time::{Duration, Instant};
|
||
use iced_core::touch;
|
||
use iced_core::widget::operation::{self, Operation};
|
||
use iced_core::widget::tree::{self, Tree};
|
||
use iced_core::widget::Id;
|
||
use iced_core::window;
|
||
use iced_core::{alignment, Background};
|
||
use iced_core::{keyboard, Border, Shadow};
|
||
use iced_core::{layout, overlay};
|
||
use iced_core::{
|
||
Clipboard, Color, Element, Layout, Length, Padding, Pixels, Point, Rectangle, Shell, Size,
|
||
Vector, Widget,
|
||
};
|
||
#[cfg(feature = "wayland")]
|
||
use iced_renderer::core::event::{wayland, PlatformSpecific};
|
||
#[cfg(feature = "wayland")]
|
||
use iced_runtime::platform_specific;
|
||
use iced_runtime::{task, Action, Task};
|
||
|
||
thread_local! {
|
||
// Prevents two inputs from being focused at the same time.
|
||
static LAST_FOCUS_UPDATE: LazyCell<Cell<Instant>> = LazyCell::new(|| Cell::new(Instant::now()));
|
||
}
|
||
|
||
/// Creates a new [`TextInput`].
|
||
///
|
||
/// [`TextInput`]: widget::TextInput
|
||
pub fn text_input<'a, Message>(
|
||
placeholder: impl Into<Cow<'a, str>>,
|
||
value: impl Into<Cow<'a, str>>,
|
||
) -> TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
TextInput::new(placeholder, value)
|
||
}
|
||
|
||
/// A text label whiich can transform into a text input on activation.
|
||
pub fn editable_input<'a, Message: Clone + 'static>(
|
||
placeholder: impl Into<Cow<'a, str>>,
|
||
text: impl Into<Cow<'a, str>>,
|
||
editing: bool,
|
||
on_toggle_edit: impl Fn(bool) -> Message + 'a,
|
||
) -> TextInput<'a, Message> {
|
||
let icon = crate::widget::icon::from_name(if editing {
|
||
"edit-clear-symbolic"
|
||
} else {
|
||
"edit-symbolic"
|
||
});
|
||
|
||
TextInput::new(placeholder, text)
|
||
.style(crate::theme::TextInput::EditableText)
|
||
.editable()
|
||
.editing(editing)
|
||
.on_toggle_edit(on_toggle_edit)
|
||
.trailing_icon(icon.size(16).into())
|
||
}
|
||
|
||
/// Creates a new search [`TextInput`].
|
||
///
|
||
/// [`TextInput`]: widget::TextInput
|
||
pub fn search_input<'a, Message>(
|
||
placeholder: impl Into<Cow<'a, str>>,
|
||
value: impl Into<Cow<'a, str>>,
|
||
) -> TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
let spacing = THEME.lock().unwrap().cosmic().space_xxs();
|
||
|
||
TextInput::new(placeholder, value)
|
||
.padding([0, spacing])
|
||
.style(crate::theme::TextInput::Search)
|
||
.leading_icon(
|
||
crate::widget::icon::from_name("system-search-symbolic")
|
||
.size(16)
|
||
.apply(crate::widget::container)
|
||
.padding(8)
|
||
.into(),
|
||
)
|
||
}
|
||
/// Creates a new secure [`TextInput`].
|
||
///
|
||
/// [`TextInput`]: widget::TextInput
|
||
pub fn secure_input<'a, Message>(
|
||
placeholder: impl Into<Cow<'a, str>>,
|
||
value: impl Into<Cow<'a, str>>,
|
||
on_visible_toggle: Option<Message>,
|
||
hidden: bool,
|
||
) -> TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
let spacing = THEME.lock().unwrap().cosmic().space_xxs();
|
||
let mut input = TextInput::new(placeholder, value)
|
||
.padding([0, spacing])
|
||
.style(crate::theme::TextInput::Default)
|
||
.leading_icon(
|
||
crate::widget::icon::from_name("system-lock-screen-symbolic")
|
||
.size(16)
|
||
.apply(crate::widget::container)
|
||
.padding(8)
|
||
.into(),
|
||
);
|
||
if hidden {
|
||
input = input.password();
|
||
}
|
||
if let Some(msg) = on_visible_toggle {
|
||
input.trailing_icon(
|
||
crate::widget::icon::from_name(if hidden {
|
||
"document-properties-symbolic"
|
||
} else {
|
||
"image-red-eye-symbolic"
|
||
})
|
||
.size(16)
|
||
.apply(crate::widget::button::custom)
|
||
.class(crate::theme::Button::Icon)
|
||
.on_press(msg)
|
||
.padding(8)
|
||
.into(),
|
||
)
|
||
} else {
|
||
input
|
||
}
|
||
}
|
||
|
||
/// Creates a new inline [`TextInput`].
|
||
///
|
||
/// [`TextInput`]: widget::TextInput
|
||
pub fn inline_input<'a, Message>(
|
||
placeholder: impl Into<Cow<'a, str>>,
|
||
value: impl Into<Cow<'a, str>>,
|
||
) -> TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
let spacing = THEME.lock().unwrap().cosmic().space_xxs();
|
||
|
||
TextInput::new(placeholder, value)
|
||
.style(crate::theme::TextInput::Inline)
|
||
.padding(spacing)
|
||
}
|
||
|
||
pub(crate) const SUPPORTED_TEXT_MIME_TYPES: &[&str; 6] = &[
|
||
"text/plain;charset=utf-8",
|
||
"text/plain;charset=UTF-8",
|
||
"UTF8_STRING",
|
||
"STRING",
|
||
"text/plain",
|
||
"TEXT",
|
||
];
|
||
|
||
/// A field that can be filled with text.
|
||
#[allow(missing_debug_implementations)]
|
||
#[must_use]
|
||
pub struct TextInput<'a, Message> {
|
||
id: Id,
|
||
placeholder: Cow<'a, str>,
|
||
value: Value,
|
||
is_secure: bool,
|
||
is_editable: bool,
|
||
is_read_only: bool,
|
||
select_on_focus: bool,
|
||
font: Option<<crate::Renderer as iced_core::text::Renderer>::Font>,
|
||
width: Length,
|
||
padding: Padding,
|
||
size: Option<f32>,
|
||
helper_size: f32,
|
||
label: Option<&'a str>,
|
||
helper_text: Option<&'a str>,
|
||
error: Option<&'a str>,
|
||
on_input: Option<Box<dyn Fn(String) -> Message + 'a>>,
|
||
on_paste: Option<Box<dyn Fn(String) -> Message + 'a>>,
|
||
on_submit: Option<Message>,
|
||
on_toggle_edit: Option<Box<dyn Fn(bool) -> Message + 'a>>,
|
||
leading_icon: Option<Element<'a, Message, crate::Theme, crate::Renderer>>,
|
||
trailing_icon: Option<Element<'a, Message, crate::Theme, crate::Renderer>>,
|
||
style: <crate::Theme as StyleSheet>::Style,
|
||
on_create_dnd_source: Option<Box<dyn Fn(State) -> Message + 'a>>,
|
||
surface_ids: Option<(window::Id, window::Id)>,
|
||
dnd_icon: bool,
|
||
line_height: text::LineHeight,
|
||
helper_line_height: text::LineHeight,
|
||
always_active: bool,
|
||
}
|
||
|
||
impl<'a, Message> TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
/// Creates a new [`TextInput`].
|
||
///
|
||
/// It expects:
|
||
/// - a placeholder,
|
||
/// - the current value
|
||
pub fn new(placeholder: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
|
||
let spacing = THEME.lock().unwrap().cosmic().space_xxs();
|
||
|
||
let v: Cow<'a, str> = value.into();
|
||
TextInput {
|
||
id: Id::unique(),
|
||
placeholder: placeholder.into(),
|
||
value: Value::new(v.as_ref()),
|
||
is_secure: false,
|
||
is_editable: false,
|
||
is_read_only: false,
|
||
select_on_focus: false,
|
||
font: None,
|
||
width: Length::Fill,
|
||
padding: spacing.into(),
|
||
size: None,
|
||
helper_size: 10.0,
|
||
helper_line_height: text::LineHeight::Absolute(14.0.into()),
|
||
on_input: None,
|
||
on_paste: None,
|
||
on_submit: None,
|
||
on_toggle_edit: None,
|
||
leading_icon: None,
|
||
trailing_icon: None,
|
||
error: None,
|
||
style: crate::theme::TextInput::default(),
|
||
on_create_dnd_source: None,
|
||
surface_ids: None,
|
||
dnd_icon: false,
|
||
line_height: text::LineHeight::default(),
|
||
label: None,
|
||
helper_text: None,
|
||
always_active: false,
|
||
}
|
||
}
|
||
|
||
fn dnd_id(&self) -> u128 {
|
||
match &self.id.0 {
|
||
iced_core::id::Internal::Custom(id, _) | iced_core::id::Internal::Unique(id) => {
|
||
*id as u128
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
|
||
/// Sets the input to be always active.
|
||
/// This makes it behave as if it was always focused.
|
||
pub fn always_active(mut self) -> Self {
|
||
self.always_active = true;
|
||
self
|
||
}
|
||
|
||
/// Sets the text of the [`TextInput`].
|
||
pub fn label(mut self, label: &'a str) -> Self {
|
||
self.label = Some(label);
|
||
self
|
||
}
|
||
|
||
/// Sets the helper text of the [`TextInput`].
|
||
pub fn helper_text(mut self, helper_text: &'a str) -> Self {
|
||
self.helper_text = Some(helper_text);
|
||
self
|
||
}
|
||
|
||
/// Sets the [`Id`] of the [`TextInput`].
|
||
pub fn id(mut self, id: Id) -> Self {
|
||
self.id = id;
|
||
self
|
||
}
|
||
|
||
/// Sets the error message of the [`TextInput`].
|
||
pub fn error(mut self, error: &'a str) -> Self {
|
||
self.error = Some(error);
|
||
self
|
||
}
|
||
|
||
/// Sets the [`LineHeight`] of the [`TextInput`].
|
||
pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
|
||
self.line_height = line_height.into();
|
||
self
|
||
}
|
||
|
||
/// Converts the [`TextInput`] into a secure password input.
|
||
pub fn password(mut self) -> Self {
|
||
self.is_secure = true;
|
||
self
|
||
}
|
||
|
||
pub fn editable(mut self) -> Self {
|
||
self.is_editable = true;
|
||
self
|
||
}
|
||
|
||
pub fn editing(mut self, enable: bool) -> Self {
|
||
self.is_read_only = !enable;
|
||
self
|
||
}
|
||
|
||
/// Selects all text when the text input is focused
|
||
pub fn select_on_focus(mut self, select_on_focus: bool) -> Self {
|
||
self.select_on_focus = select_on_focus;
|
||
self
|
||
}
|
||
|
||
/// Sets the message that should be produced when some text is typed into
|
||
/// the [`TextInput`].
|
||
///
|
||
/// If this method is not called, the [`TextInput`] will be disabled.
|
||
pub fn on_input<F>(mut self, callback: F) -> Self
|
||
where
|
||
F: 'a + Fn(String) -> Message,
|
||
{
|
||
self.on_input = Some(Box::new(callback));
|
||
self
|
||
}
|
||
|
||
/// Sets the message that should be produced when the [`TextInput`] is
|
||
/// focused and the enter key is pressed.
|
||
pub fn on_submit(self, message: Message) -> Self {
|
||
self.on_submit_maybe(Some(message))
|
||
}
|
||
|
||
/// Maybe sets the message that should be produced when the [`TextInput`] is
|
||
/// focused and the enter key is pressed.
|
||
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
||
self.on_submit = message;
|
||
self
|
||
}
|
||
|
||
pub fn on_toggle_edit<F>(mut self, callback: F) -> Self
|
||
where
|
||
F: 'a + Fn(bool) -> Message,
|
||
{
|
||
self.on_toggle_edit = Some(Box::new(callback));
|
||
self
|
||
}
|
||
|
||
/// Sets the message that should be produced when some text is pasted into
|
||
/// the [`TextInput`].
|
||
pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
|
||
self.on_paste = Some(Box::new(on_paste));
|
||
self
|
||
}
|
||
|
||
/// Sets the [`Font`] of the [`TextInput`].
|
||
///
|
||
/// [`Font`]: text::Renderer::Font
|
||
pub fn font(mut self, font: <crate::Renderer as iced_core::text::Renderer>::Font) -> Self {
|
||
self.font = Some(font);
|
||
self
|
||
}
|
||
|
||
/// Sets the start [`Icon`] of the [`TextInput`].
|
||
pub fn leading_icon(
|
||
mut self,
|
||
icon: Element<'a, Message, crate::Theme, crate::Renderer>,
|
||
) -> Self {
|
||
self.leading_icon = Some(icon);
|
||
self
|
||
}
|
||
|
||
/// Sets the end [`Icon`] of the [`TextInput`].
|
||
pub fn trailing_icon(
|
||
mut self,
|
||
icon: Element<'a, Message, crate::Theme, crate::Renderer>,
|
||
) -> Self {
|
||
self.trailing_icon = Some(icon);
|
||
self
|
||
}
|
||
|
||
/// Sets the width of the [`TextInput`].
|
||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||
self.width = width.into();
|
||
self
|
||
}
|
||
|
||
/// Sets the [`Padding`] of the [`TextInput`].
|
||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||
self.padding = padding.into();
|
||
self
|
||
}
|
||
|
||
/// Sets the text size of the [`TextInput`].
|
||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||
self.size = Some(size.into().0);
|
||
self
|
||
}
|
||
|
||
/// Sets the style of the [`TextInput`].
|
||
pub fn style(mut self, style: impl Into<<crate::Theme as StyleSheet>::Style>) -> Self {
|
||
self.style = style.into();
|
||
self
|
||
}
|
||
|
||
/// Draws the [`TextInput`] with the given [`Renderer`], overriding its
|
||
/// [`Value`] if provided.
|
||
///
|
||
/// [`Renderer`]: text::Renderer
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn draw(
|
||
&self,
|
||
tree: &Tree,
|
||
renderer: &mut crate::Renderer,
|
||
theme: &crate::Theme,
|
||
layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
value: Option<&Value>,
|
||
style: &renderer::Style,
|
||
) {
|
||
let text_layout = self.text_layout(layout);
|
||
draw(
|
||
renderer,
|
||
theme,
|
||
layout,
|
||
text_layout,
|
||
cursor_position,
|
||
tree,
|
||
value.unwrap_or(&self.value),
|
||
&self.placeholder,
|
||
self.size,
|
||
self.font,
|
||
self.on_input.is_none(),
|
||
self.is_secure,
|
||
self.leading_icon.as_ref(),
|
||
self.trailing_icon.as_ref(),
|
||
&self.style,
|
||
self.dnd_icon,
|
||
self.line_height,
|
||
self.error,
|
||
self.label,
|
||
self.helper_text,
|
||
self.helper_size,
|
||
self.helper_line_height,
|
||
&layout.bounds(),
|
||
style,
|
||
);
|
||
}
|
||
|
||
/// Sets the start dnd handler of the [`TextInput`].
|
||
#[cfg(feature = "wayland")]
|
||
pub fn on_start_dnd(mut self, on_start_dnd: impl Fn(State) -> Message + 'a) -> Self {
|
||
self.on_create_dnd_source = Some(Box::new(on_start_dnd));
|
||
self
|
||
}
|
||
|
||
/// Sets the window id of the [`TextInput`] and the window id of the drag icon.
|
||
/// Both ids are required to be unique.
|
||
/// This is required for the dnd to work.
|
||
pub fn surface_ids(mut self, window_id: (window::Id, window::Id)) -> Self {
|
||
self.surface_ids = Some(window_id);
|
||
self
|
||
}
|
||
|
||
/// Sets the mode of this [`TextInput`] to be a drag and drop icon.
|
||
pub fn dnd_icon(mut self, dnd_icon: bool) -> Self {
|
||
self.dnd_icon = dnd_icon;
|
||
self
|
||
}
|
||
|
||
pub fn on_clear(self, on_clear: Message) -> Self {
|
||
self.trailing_icon(
|
||
crate::widget::icon::from_name("edit-clear-symbolic")
|
||
.size(16)
|
||
.apply(crate::widget::button::custom)
|
||
.class(crate::theme::Button::Icon)
|
||
.on_press(on_clear)
|
||
.padding(8)
|
||
.into(),
|
||
)
|
||
}
|
||
|
||
/// Get the layout node of the actual text input
|
||
fn text_layout<'b>(&'a self, layout: Layout<'b>) -> Layout<'b> {
|
||
if self.dnd_icon {
|
||
layout
|
||
} else if self.label.is_some() {
|
||
let mut nodes = layout.children();
|
||
nodes.next();
|
||
nodes.next().unwrap()
|
||
} else {
|
||
layout.children().next().unwrap()
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<'a, Message> Widget<Message, crate::Theme, crate::Renderer> for TextInput<'a, Message>
|
||
where
|
||
Message: Clone + 'static,
|
||
{
|
||
fn tag(&self) -> tree::Tag {
|
||
tree::Tag::of::<State>()
|
||
}
|
||
|
||
fn state(&self) -> tree::State {
|
||
tree::State::new(State::new(
|
||
self.is_secure,
|
||
self.is_read_only,
|
||
self.always_active,
|
||
self.select_on_focus,
|
||
))
|
||
}
|
||
|
||
fn diff(&mut self, tree: &mut Tree) {
|
||
let state = tree.state.downcast_mut::<State>();
|
||
|
||
// Unfocus text input if it becomes disabled
|
||
if self.on_input.is_none() {
|
||
state.last_click = None;
|
||
state.is_focused = None;
|
||
state.is_pasting = None;
|
||
state.dragging_state = None;
|
||
}
|
||
let old_value = state
|
||
.value
|
||
.raw()
|
||
.buffer()
|
||
.lines
|
||
.iter()
|
||
.map(|l| l.text())
|
||
.collect::<String>();
|
||
if state.is_secure != self.is_secure
|
||
|| old_value != self.value.to_string()
|
||
|| state
|
||
.label
|
||
.raw()
|
||
.buffer()
|
||
.lines
|
||
.iter()
|
||
.map(|l| l.text())
|
||
.collect::<String>()
|
||
!= self.label.unwrap_or_default()
|
||
|| state
|
||
.helper_text
|
||
.raw()
|
||
.buffer()
|
||
.lines
|
||
.iter()
|
||
.map(|l| l.text())
|
||
.collect::<String>()
|
||
!= self.helper_text.unwrap_or_default()
|
||
{
|
||
state.is_secure = self.is_secure;
|
||
state.dirty = true;
|
||
}
|
||
|
||
self.is_read_only = state.is_read_only;
|
||
|
||
if self.always_active && state.is_focused.is_none() {
|
||
let now = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(now));
|
||
state.is_focused = Some(Focus {
|
||
updated_at: now,
|
||
now,
|
||
});
|
||
}
|
||
|
||
// if the previous state was at the end of the text, keep it there
|
||
let old_value = Value::new(&old_value);
|
||
if state.is_focused.is_some() {
|
||
match state.cursor.state(&old_value) {
|
||
cursor::State::Index(index) => {
|
||
if index == old_value.len() {
|
||
state.cursor.move_to(self.value.len());
|
||
}
|
||
}
|
||
_ => {}
|
||
};
|
||
}
|
||
|
||
if !state.is_focused.as_ref().map_or(false, |f| {
|
||
f.updated_at == LAST_FOCUS_UPDATE.with(|f| f.get())
|
||
}) {
|
||
state.is_focused = None;
|
||
}
|
||
|
||
let mut children: Vec<_> = self
|
||
.leading_icon
|
||
.iter_mut()
|
||
.chain(self.trailing_icon.iter_mut())
|
||
.map(iced_core::Element::as_widget_mut)
|
||
.collect();
|
||
tree.diff_children(children.as_mut_slice());
|
||
}
|
||
|
||
fn children(&self) -> Vec<Tree> {
|
||
self.leading_icon
|
||
.iter()
|
||
.chain(self.trailing_icon.iter())
|
||
.map(|icon| Tree::new(icon))
|
||
.collect()
|
||
}
|
||
|
||
fn size(&self) -> Size<Length> {
|
||
Size {
|
||
width: self.width,
|
||
height: Length::Shrink,
|
||
}
|
||
}
|
||
|
||
fn layout(
|
||
&self,
|
||
tree: &mut Tree,
|
||
renderer: &crate::Renderer,
|
||
limits: &layout::Limits,
|
||
) -> layout::Node {
|
||
let font = self.font.unwrap_or_else(|| renderer.default_font());
|
||
if self.dnd_icon {
|
||
let state = tree.state.downcast_mut::<State>();
|
||
let limits = limits.width(Length::Shrink).height(Length::Shrink);
|
||
|
||
let size = self.size.unwrap_or_else(|| renderer.default_size().0);
|
||
|
||
let bounds = limits.max();
|
||
|
||
let value_paragraph = &mut state.value;
|
||
let v = self.value.to_string();
|
||
value_paragraph.update(Text {
|
||
content: if self.value.is_empty() {
|
||
self.placeholder.as_ref()
|
||
} else {
|
||
&v
|
||
},
|
||
font,
|
||
bounds,
|
||
size: iced::Pixels(size),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Center,
|
||
line_height: text::LineHeight::default(),
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
});
|
||
|
||
let Size { width, height } =
|
||
limits.resolve(Length::Shrink, Length::Shrink, value_paragraph.min_bounds());
|
||
|
||
let size = limits.resolve(width, height, Size::new(width, height));
|
||
layout::Node::with_children(size, vec![layout::Node::new(size)])
|
||
} else {
|
||
let res = layout(
|
||
renderer,
|
||
limits,
|
||
self.width,
|
||
self.padding,
|
||
self.size,
|
||
self.leading_icon.as_ref(),
|
||
self.trailing_icon.as_ref(),
|
||
self.line_height,
|
||
self.label,
|
||
self.helper_text,
|
||
self.helper_size,
|
||
self.helper_line_height,
|
||
font,
|
||
tree,
|
||
);
|
||
|
||
// XXX not ideal, but we need to update the cache when is_secure changes
|
||
let size = self.size.unwrap_or_else(|| renderer.default_size().0);
|
||
let line_height = self.line_height;
|
||
let state = tree.state.downcast_mut::<State>();
|
||
if state.dirty {
|
||
state.dirty = false;
|
||
let value = if self.is_secure {
|
||
self.value.secure()
|
||
} else {
|
||
self.value.clone()
|
||
};
|
||
replace_paragraph(
|
||
state,
|
||
Layout::new(&res),
|
||
&value,
|
||
font,
|
||
iced::Pixels(size),
|
||
line_height,
|
||
);
|
||
}
|
||
res
|
||
}
|
||
}
|
||
|
||
fn operate(
|
||
&self,
|
||
tree: &mut Tree,
|
||
_layout: Layout<'_>,
|
||
_renderer: &crate::Renderer,
|
||
operation: &mut dyn Operation<()>,
|
||
) {
|
||
let state = tree.state.downcast_mut::<State>();
|
||
|
||
operation.custom(state, Some(&self.id));
|
||
operation.focusable(state, Some(&self.id));
|
||
operation.text_input(state, Some(&self.id));
|
||
}
|
||
|
||
fn overlay<'b>(
|
||
&'b mut self,
|
||
tree: &'b mut Tree,
|
||
layout: Layout<'_>,
|
||
renderer: &crate::Renderer,
|
||
translation: Vector,
|
||
) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
|
||
let mut layout_ = Vec::with_capacity(2);
|
||
if self.leading_icon.is_some() {
|
||
let mut children = self.text_layout(layout).children();
|
||
children.next();
|
||
layout_.push(children.next().unwrap());
|
||
}
|
||
if self.trailing_icon.is_some() {
|
||
let mut children = self.text_layout(layout).children();
|
||
children.next();
|
||
if self.leading_icon.is_some() {
|
||
children.next();
|
||
}
|
||
layout_.push(children.next().unwrap());
|
||
};
|
||
let children = self
|
||
.leading_icon
|
||
.iter_mut()
|
||
.chain(self.trailing_icon.iter_mut())
|
||
.zip(&mut tree.children)
|
||
.zip(layout_)
|
||
.filter_map(|((child, state), layout)| {
|
||
child
|
||
.as_widget_mut()
|
||
.overlay(state, layout, renderer, translation)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
(!children.is_empty()).then(|| Group::with_children(children).overlay())
|
||
}
|
||
|
||
fn on_event(
|
||
&mut self,
|
||
tree: &mut Tree,
|
||
event: Event,
|
||
layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
renderer: &crate::Renderer,
|
||
clipboard: &mut dyn Clipboard,
|
||
shell: &mut Shell<'_, Message>,
|
||
viewport: &Rectangle,
|
||
) -> event::Status {
|
||
let text_layout = self.text_layout(layout);
|
||
let mut trailing_icon_layout = None;
|
||
let font = self.font.unwrap_or_else(|| renderer.default_font());
|
||
let size = self.size.unwrap_or_else(|| renderer.default_size().0);
|
||
let line_height = self.line_height;
|
||
|
||
if self.is_editable {
|
||
if let Some(ref on_edit) = self.on_toggle_edit {
|
||
let state = tree.state.downcast_mut::<State>();
|
||
if !state.is_read_only && state.is_focused.is_none() {
|
||
state.is_read_only = true;
|
||
shell.publish((on_edit)(false));
|
||
}
|
||
}
|
||
}
|
||
|
||
if tree.children.len() > 0 {
|
||
let index = tree.children.len() - 1;
|
||
if let (Some(trailing_icon), Some(tree)) =
|
||
(self.trailing_icon.as_mut(), tree.children.get_mut(index))
|
||
{
|
||
let children = text_layout.children();
|
||
trailing_icon_layout = Some(children.last().unwrap());
|
||
|
||
if let Some(trailing_layout) = trailing_icon_layout {
|
||
if cursor_position.is_over(trailing_layout.bounds()) {
|
||
let res = trailing_icon.as_widget_mut().on_event(
|
||
tree,
|
||
event.clone(),
|
||
trailing_layout,
|
||
cursor_position,
|
||
renderer,
|
||
clipboard,
|
||
shell,
|
||
viewport,
|
||
);
|
||
|
||
if res == event::Status::Captured {
|
||
return res;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let dnd_id = self.dnd_id();
|
||
let id = Widget::id(self);
|
||
update(
|
||
id,
|
||
event,
|
||
text_layout.children().next().unwrap(),
|
||
trailing_icon_layout,
|
||
cursor_position,
|
||
clipboard,
|
||
shell,
|
||
&mut self.value,
|
||
size,
|
||
font,
|
||
self.is_secure,
|
||
self.is_editable,
|
||
self.on_input.as_deref(),
|
||
self.on_paste.as_deref(),
|
||
&self.on_submit,
|
||
self.on_toggle_edit.as_deref(),
|
||
|| tree.state.downcast_mut::<State>(),
|
||
self.on_create_dnd_source.as_deref(),
|
||
dnd_id,
|
||
line_height,
|
||
layout,
|
||
)
|
||
}
|
||
|
||
fn draw(
|
||
&self,
|
||
tree: &Tree,
|
||
renderer: &mut crate::Renderer,
|
||
theme: &crate::Theme,
|
||
style: &renderer::Style,
|
||
layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
viewport: &Rectangle,
|
||
) {
|
||
let text_layout = self.text_layout(layout);
|
||
draw(
|
||
renderer,
|
||
theme,
|
||
layout,
|
||
text_layout,
|
||
cursor_position,
|
||
tree,
|
||
&self.value,
|
||
&self.placeholder,
|
||
self.size,
|
||
self.font,
|
||
self.on_input.is_none(),
|
||
self.is_secure,
|
||
self.leading_icon.as_ref(),
|
||
self.trailing_icon.as_ref(),
|
||
&self.style,
|
||
self.dnd_icon,
|
||
self.line_height,
|
||
self.error,
|
||
self.label,
|
||
self.helper_text,
|
||
self.helper_size,
|
||
self.helper_line_height,
|
||
viewport,
|
||
style,
|
||
);
|
||
}
|
||
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Tree,
|
||
layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
viewport: &Rectangle,
|
||
renderer: &crate::Renderer,
|
||
) -> mouse::Interaction {
|
||
let layout = self.text_layout(layout);
|
||
let mut index = 0;
|
||
if let (Some(leading_icon), Some(tree)) =
|
||
(self.leading_icon.as_ref(), state.children.get(index))
|
||
{
|
||
let mut children = layout.children();
|
||
children.next();
|
||
let leading_icon_layout = children.next().unwrap();
|
||
|
||
if cursor_position.is_over(leading_icon_layout.bounds()) {
|
||
return leading_icon.as_widget().mouse_interaction(
|
||
tree,
|
||
layout,
|
||
cursor_position,
|
||
viewport,
|
||
renderer,
|
||
);
|
||
}
|
||
index += 1;
|
||
}
|
||
|
||
if let (Some(trailing_icon), Some(tree)) =
|
||
(self.trailing_icon.as_ref(), state.children.get(index))
|
||
{
|
||
let mut children = layout.children();
|
||
children.next();
|
||
// skip if there is no leading icon
|
||
if self.leading_icon.is_some() {
|
||
children.next();
|
||
}
|
||
let trailing_icon_layout = children.next().unwrap();
|
||
|
||
if cursor_position.is_over(trailing_icon_layout.bounds()) {
|
||
return trailing_icon.as_widget().mouse_interaction(
|
||
tree,
|
||
layout,
|
||
cursor_position,
|
||
viewport,
|
||
renderer,
|
||
);
|
||
}
|
||
}
|
||
let mut children = layout.children();
|
||
let layout = children.next().unwrap();
|
||
mouse_interaction(layout, cursor_position, self.on_input.is_none())
|
||
}
|
||
|
||
fn id(&self) -> Option<Id> {
|
||
Some(self.id.clone())
|
||
}
|
||
|
||
fn set_id(&mut self, id: Id) {
|
||
self.id = id;
|
||
}
|
||
|
||
fn drag_destinations(
|
||
&self,
|
||
state: &Tree,
|
||
layout: Layout<'_>,
|
||
renderer: &crate::Renderer,
|
||
dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
|
||
) {
|
||
if let Some(input) = layout.children().last() {
|
||
let Rectangle {
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
} = input.bounds();
|
||
dnd_rectangles.push(iced::clipboard::dnd::DndDestinationRectangle {
|
||
id: self.dnd_id(),
|
||
rectangle: iced::clipboard::dnd::Rectangle {
|
||
x: x as f64,
|
||
y: y as f64,
|
||
width: width as f64,
|
||
height: height as f64,
|
||
},
|
||
mime_types: SUPPORTED_TEXT_MIME_TYPES
|
||
.iter()
|
||
.map(|s| Cow::Borrowed(*s))
|
||
.collect(),
|
||
actions: DndAction::Move,
|
||
preferred: DndAction::Move,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<'a, Message> From<TextInput<'a, Message>>
|
||
for Element<'a, Message, crate::Theme, crate::Renderer>
|
||
where
|
||
Message: 'static + Clone,
|
||
{
|
||
fn from(
|
||
text_input: TextInput<'a, Message>,
|
||
) -> Element<'a, Message, crate::Theme, crate::Renderer> {
|
||
Element::new(text_input)
|
||
}
|
||
}
|
||
|
||
/// Produces a [`Task`] that focuses the [`TextInput`] with the given [`Id`].
|
||
pub fn focus<Message: 'static>(id: Id) -> Task<Message> {
|
||
task::effect(Action::widget(operation::focusable::focus(id)))
|
||
}
|
||
|
||
/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the
|
||
/// end.
|
||
pub fn move_cursor_to_end<Message: 'static>(id: Id) -> Task<Message> {
|
||
task::effect(Action::widget(operation::text_input::move_cursor_to_end(
|
||
id,
|
||
)))
|
||
}
|
||
|
||
/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the
|
||
/// front.
|
||
pub fn move_cursor_to_front<Message: 'static>(id: Id) -> Task<Message> {
|
||
task::effect(Action::widget(operation::text_input::move_cursor_to_front(
|
||
id,
|
||
)))
|
||
}
|
||
|
||
/// Produces a [`Task`] that moves the cursor of the [`TextInput`] with the given [`Id`] to the
|
||
/// provided position.
|
||
pub fn move_cursor_to<Message: 'static>(id: Id, position: usize) -> Task<Message> {
|
||
task::effect(Action::widget(operation::text_input::move_cursor_to(
|
||
id, position,
|
||
)))
|
||
}
|
||
|
||
/// Produces a [`Task`] that selects all the content of the [`TextInput`] with the given [`Id`].
|
||
pub fn select_all<Message: 'static>(id: Id) -> Task<Message> {
|
||
task::effect(Action::widget(operation::text_input::select_all(id)))
|
||
}
|
||
|
||
/// Computes the layout of a [`TextInput`].
|
||
#[allow(clippy::cast_precision_loss)]
|
||
#[allow(clippy::too_many_arguments)]
|
||
#[allow(clippy::too_many_lines)]
|
||
pub fn layout<Message>(
|
||
renderer: &crate::Renderer,
|
||
limits: &layout::Limits,
|
||
width: Length,
|
||
padding: Padding,
|
||
size: Option<f32>,
|
||
leading_icon: Option<&Element<'_, Message, crate::Theme, crate::Renderer>>,
|
||
trailing_icon: Option<&Element<'_, Message, crate::Theme, crate::Renderer>>,
|
||
line_height: text::LineHeight,
|
||
label: Option<&str>,
|
||
helper_text: Option<&str>,
|
||
helper_text_size: f32,
|
||
helper_text_line_height: text::LineHeight,
|
||
font: iced_core::Font,
|
||
tree: &mut Tree,
|
||
) -> layout::Node {
|
||
let limits = limits.width(width);
|
||
let spacing = THEME.lock().unwrap().cosmic().space_xxs();
|
||
let mut nodes = Vec::with_capacity(3);
|
||
|
||
let text_pos = if let Some(label) = label {
|
||
let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY);
|
||
let state = tree.state.downcast_mut::<State>();
|
||
let label_paragraph = &mut state.label;
|
||
label_paragraph.update(Text {
|
||
content: label,
|
||
font,
|
||
bounds: text_bounds,
|
||
size: iced::Pixels(size.unwrap_or_else(|| renderer.default_size().0)),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Center,
|
||
line_height,
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
});
|
||
let label_size = label_paragraph.min_bounds();
|
||
|
||
nodes.push(layout::Node::new(label_size));
|
||
Vector::new(0.0, label_size.height + f32::from(spacing))
|
||
} else {
|
||
Vector::ZERO
|
||
};
|
||
|
||
let text_size = size.unwrap_or_else(|| renderer.default_size().0);
|
||
let mut text_input_height = line_height.to_absolute(text_size.into()).0;
|
||
let padding = padding.fit(Size::ZERO, limits.max());
|
||
|
||
let helper_pos = if leading_icon.is_some() || trailing_icon.is_some() {
|
||
let children = &mut tree.children;
|
||
// TODO configurable icon spacing, maybe via appearance
|
||
let limits_copy = limits;
|
||
|
||
let limits = limits.shrink(padding);
|
||
let icon_spacing = 8.0;
|
||
let mut c_i = 0;
|
||
let (leading_icon_width, mut leading_icon) =
|
||
if let Some((icon, tree)) = leading_icon.zip(children.get_mut(c_i)) {
|
||
let size = icon.as_widget().size();
|
||
let icon_node = icon.as_widget().layout(
|
||
tree,
|
||
renderer,
|
||
&Limits::NONE.width(size.width).height(size.height),
|
||
);
|
||
text_input_height = text_input_height.max(icon_node.bounds().height);
|
||
c_i += 1;
|
||
(icon_node.bounds().width + icon_spacing, Some(icon_node))
|
||
} else {
|
||
(0.0, None)
|
||
};
|
||
|
||
let (trailing_icon_width, mut trailing_icon) =
|
||
if let Some((icon, tree)) = trailing_icon.zip(children.get_mut(c_i)) {
|
||
let size = icon.as_widget().size();
|
||
let icon_node = icon.as_widget().layout(
|
||
tree,
|
||
renderer,
|
||
&Limits::NONE.width(size.width).height(size.height),
|
||
);
|
||
text_input_height = text_input_height.max(icon_node.bounds().height);
|
||
(icon_node.bounds().width + icon_spacing, Some(icon_node))
|
||
} else {
|
||
(0.0, None)
|
||
};
|
||
let text_limits = limits
|
||
.width(width)
|
||
.height(line_height.to_absolute(text_size.into()));
|
||
|
||
let text_bounds = text_limits.resolve(width, Length::Shrink, Size::INFINITY);
|
||
|
||
let text_node = layout::Node::new(
|
||
text_bounds - Size::new(leading_icon_width + trailing_icon_width, 0.0),
|
||
)
|
||
.move_to(Point::new(
|
||
padding.left + leading_icon_width,
|
||
padding.top
|
||
+ ((text_input_height - line_height.to_absolute(text_size.into()).0) / 2.0)
|
||
.max(0.0),
|
||
));
|
||
let mut node_list: Vec<_> = Vec::with_capacity(3);
|
||
|
||
let text_node_bounds = text_node.bounds();
|
||
node_list.push(text_node);
|
||
|
||
if let Some(leading_icon) = leading_icon.take() {
|
||
node_list.push(leading_icon.clone().move_to(Point::new(
|
||
padding.left,
|
||
padding.top + ((text_input_height - leading_icon.bounds().height) / 2.0).max(0.0),
|
||
)));
|
||
}
|
||
if let Some(trailing_icon) = trailing_icon.take() {
|
||
let trailing_icon = trailing_icon.clone().move_to(Point::new(
|
||
text_node_bounds.x + text_node_bounds.width + f32::from(spacing),
|
||
padding.top + ((text_input_height - trailing_icon.bounds().height) / 2.0).max(0.0),
|
||
));
|
||
node_list.push(trailing_icon);
|
||
}
|
||
|
||
let text_input_size = Size::new(
|
||
text_node_bounds.x + text_node_bounds.width + trailing_icon_width,
|
||
text_input_height,
|
||
)
|
||
.expand(padding);
|
||
|
||
let input_limits = limits_copy
|
||
.width(width)
|
||
.height(text_input_height.max(text_input_size.height))
|
||
.min_width(text_input_size.width);
|
||
let input_bounds = input_limits.resolve(
|
||
width,
|
||
text_input_height.max(text_input_size.height),
|
||
text_input_size,
|
||
);
|
||
let input_node = layout::Node::with_children(input_bounds, node_list).translate(text_pos);
|
||
let y_pos = input_node.bounds().y + input_node.bounds().height + f32::from(spacing);
|
||
nodes.push(input_node);
|
||
|
||
Vector::new(0.0, y_pos)
|
||
} else {
|
||
let limits = limits
|
||
.width(width)
|
||
.height(text_input_height + padding.vertical())
|
||
.shrink(padding);
|
||
let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY);
|
||
|
||
let text = layout::Node::new(text_bounds).move_to(Point::new(padding.left, padding.top));
|
||
|
||
let node = layout::Node::with_children(text_bounds.expand(padding), vec![text])
|
||
.translate(text_pos);
|
||
let y_pos = node.bounds().y + node.bounds().height + f32::from(spacing);
|
||
|
||
nodes.push(node);
|
||
|
||
Vector::new(0.0, y_pos)
|
||
};
|
||
|
||
if let Some(helper_text) = helper_text {
|
||
let limits = limits
|
||
.width(width)
|
||
.shrink(padding)
|
||
.height(helper_text_line_height.to_absolute(helper_text_size.into()));
|
||
let text_bounds = limits.resolve(width, Length::Shrink, Size::INFINITY);
|
||
let state = tree.state.downcast_mut::<State>();
|
||
let helper_text_paragraph = &mut state.helper_text;
|
||
helper_text_paragraph.update(Text {
|
||
content: helper_text,
|
||
font,
|
||
bounds: text_bounds,
|
||
size: iced::Pixels(helper_text_size),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Center,
|
||
line_height: helper_text_line_height,
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
});
|
||
let helper_text_size = helper_text_paragraph.min_bounds();
|
||
let helper_text_node = layout::Node::new(helper_text_size).translate(helper_pos);
|
||
nodes.push(helper_text_node);
|
||
};
|
||
|
||
let mut size = nodes.iter().fold(Size::ZERO, |size, node| {
|
||
Size::new(
|
||
size.width.max(node.bounds().width),
|
||
size.height + node.bounds().height,
|
||
)
|
||
});
|
||
size.height += (nodes.len() - 1) as f32 * f32::from(spacing);
|
||
|
||
let limits = limits
|
||
.width(width)
|
||
.height(size.height)
|
||
.min_width(size.width);
|
||
|
||
layout::Node::with_children(limits.resolve(width, size.height, size), nodes)
|
||
}
|
||
|
||
/// Processes an [`Event`] and updates the [`State`] of a [`TextInput`]
|
||
/// accordingly.
|
||
#[allow(clippy::too_many_arguments)]
|
||
#[allow(clippy::too_many_lines)]
|
||
#[allow(clippy::missing_panics_doc)]
|
||
#[allow(clippy::cast_lossless)]
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
pub fn update<'a, Message: 'static>(
|
||
id: Option<Id>,
|
||
event: Event,
|
||
text_layout: Layout<'_>,
|
||
trailing_icon_layout: Option<Layout<'_>>,
|
||
cursor: mouse::Cursor,
|
||
clipboard: &mut dyn Clipboard,
|
||
shell: &mut Shell<'_, Message>,
|
||
value: &mut Value,
|
||
size: f32,
|
||
font: <crate::Renderer as iced_core::text::Renderer>::Font,
|
||
is_secure: bool,
|
||
is_editable: bool,
|
||
on_input: Option<&dyn Fn(String) -> Message>,
|
||
on_paste: Option<&dyn Fn(String) -> Message>,
|
||
on_submit: &Option<Message>,
|
||
on_toggle_edit: Option<&dyn Fn(bool) -> Message>,
|
||
state: impl FnOnce() -> &'a mut State,
|
||
#[allow(unused_variables)] on_start_dnd_source: Option<&dyn Fn(State) -> Message>,
|
||
#[allow(unused_variables)] dnd_id: u128,
|
||
line_height: text::LineHeight,
|
||
layout: Layout<'_>,
|
||
) -> event::Status
|
||
where
|
||
Message: Clone,
|
||
{
|
||
let update_cache = |state, value| {
|
||
replace_paragraph(state, layout, value, font, iced::Pixels(size), line_height);
|
||
};
|
||
|
||
let mut secured_value = if is_secure {
|
||
value.secure()
|
||
} else {
|
||
value.clone()
|
||
};
|
||
let unsecured_value = value;
|
||
let value = &mut secured_value;
|
||
|
||
match event {
|
||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||
| Event::Touch(touch::Event::FingerPressed { .. }) => {
|
||
let state = state();
|
||
|
||
let click_position = if on_input.is_some() {
|
||
cursor.position_over(layout.bounds())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if click_position.is_some() {
|
||
state.is_focused = state.is_focused.or_else(|| {
|
||
let now = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(now));
|
||
Some(Focus {
|
||
updated_at: now,
|
||
now,
|
||
})
|
||
});
|
||
}
|
||
|
||
if let Some(cursor_position) = click_position {
|
||
let text_layout = layout.children().next().unwrap();
|
||
let target = cursor_position.x - text_layout.bounds().x;
|
||
|
||
let click =
|
||
mouse::Click::new(cursor_position, mouse::Button::Left, state.last_click);
|
||
|
||
match (
|
||
&state.dragging_state,
|
||
click.kind(),
|
||
state.cursor().state(value),
|
||
) {
|
||
#[cfg(feature = "wayland")]
|
||
(None, click::Kind::Single, cursor::State::Selection { start, end }) => {
|
||
// if something is already selected, we can start a drag and drop for a
|
||
// single click that is on top of the selected text
|
||
// is the click on selected text?
|
||
|
||
if let Some(on_input) = on_input {
|
||
let left = start.min(end);
|
||
let right = end.max(start);
|
||
|
||
let (left_position, _left_offset) = measure_cursor_and_scroll_offset(
|
||
state.value.raw(),
|
||
text_layout.bounds(),
|
||
left,
|
||
);
|
||
|
||
let (right_position, _right_offset) = measure_cursor_and_scroll_offset(
|
||
state.value.raw(),
|
||
text_layout.bounds(),
|
||
right,
|
||
);
|
||
|
||
let width = right_position - left_position;
|
||
let selection_bounds = Rectangle {
|
||
x: text_layout.bounds().x + left_position,
|
||
y: text_layout.bounds().y,
|
||
width,
|
||
height: text_layout.bounds().height,
|
||
};
|
||
|
||
if cursor.is_over(selection_bounds) {
|
||
// XXX never start a dnd if the input is secure
|
||
if is_secure {
|
||
return event::Status::Ignored;
|
||
}
|
||
let input_text =
|
||
state.selected_text(&value.to_string()).unwrap_or_default();
|
||
state.dragging_state = Some(DraggingState::Dnd(
|
||
DndAction::empty(),
|
||
input_text.clone(),
|
||
));
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
editor.delete();
|
||
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
let message = (on_input)(contents);
|
||
shell.publish(message);
|
||
if let Some(on_start_dnd) = on_start_dnd_source {
|
||
shell.publish(on_start_dnd(state.clone()));
|
||
}
|
||
let state_clone = state.clone();
|
||
|
||
iced_core::clipboard::start_dnd(
|
||
clipboard,
|
||
false,
|
||
id.map(|id| iced_core::clipboard::DndSource::Widget(id)),
|
||
Some((
|
||
Element::from(
|
||
TextInput::<'static, ()>::new("", input_text.clone())
|
||
.dnd_icon(true),
|
||
),
|
||
iced_core::widget::tree::State::new(state_clone),
|
||
)),
|
||
Box::new(TextInputString(input_text)),
|
||
DndAction::Move,
|
||
);
|
||
|
||
update_cache(state, &unsecured_value);
|
||
} else {
|
||
update_cache(state, value);
|
||
// existing logic for setting the selection
|
||
let position = if target > 0.0 {
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
state.cursor.move_to(position.unwrap_or(0));
|
||
state.dragging_state = Some(DraggingState::Selection);
|
||
}
|
||
} else {
|
||
// existing logic for setting the selection
|
||
let position = if target > 0.0 {
|
||
update_cache(state, value);
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
state.cursor.move_to(position.unwrap_or(0));
|
||
state.dragging_state = Some(DraggingState::Selection);
|
||
}
|
||
}
|
||
(None, click::Kind::Single, _) => {
|
||
// existing logic for setting the selection
|
||
let position = if target > 0.0 {
|
||
update_cache(state, value);
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
state.cursor.move_to(position.unwrap_or(0));
|
||
state.dragging_state = Some(DraggingState::Selection);
|
||
}
|
||
(None | Some(DraggingState::Selection), click::Kind::Double, _) => {
|
||
update_cache(state, value);
|
||
|
||
if is_secure {
|
||
state.cursor.select_all(value);
|
||
} else {
|
||
let position =
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
.unwrap_or(0);
|
||
|
||
state.cursor.select_range(
|
||
value.previous_start_of_word(position),
|
||
value.next_end_of_word(position),
|
||
);
|
||
}
|
||
state.dragging_state = Some(DraggingState::Selection);
|
||
}
|
||
(None | Some(DraggingState::Selection), click::Kind::Triple, _) => {
|
||
update_cache(state, value);
|
||
state.cursor.select_all(value);
|
||
state.dragging_state = Some(DraggingState::Selection);
|
||
}
|
||
_ => {
|
||
state.dragging_state = None;
|
||
}
|
||
}
|
||
|
||
// Enable write mode when an editable input label is clicked
|
||
if is_editable
|
||
&& state.is_read_only
|
||
&& matches!(state.dragging_state, None | Some(DraggingState::Selection))
|
||
{
|
||
state.is_read_only = false;
|
||
if let Some(on_toggle_edit) = on_toggle_edit {
|
||
let message = (on_toggle_edit)(!state.is_read_only);
|
||
shell.publish(message);
|
||
|
||
let now = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(now));
|
||
state.is_focused = Some(Focus {
|
||
updated_at: now,
|
||
now,
|
||
});
|
||
|
||
state.move_cursor_to_end();
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
|
||
state.last_click = Some(click);
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
let mut is_trailing_clicked = false;
|
||
if is_editable {
|
||
if let Some(trailing_layout) = trailing_icon_layout {
|
||
is_trailing_clicked = cursor.is_over(trailing_layout.bounds());
|
||
|
||
if is_trailing_clicked && on_toggle_edit.is_some() {
|
||
let Some(pos) = cursor.position() else {
|
||
return event::Status::Ignored;
|
||
};
|
||
|
||
let click = mouse::Click::new(pos, mouse::Button::Left, state.last_click);
|
||
|
||
match (
|
||
&state.dragging_state,
|
||
click.kind(),
|
||
state.cursor().state(value),
|
||
) {
|
||
(None, click::Kind::Single, _) => {
|
||
state.is_read_only = !state.is_read_only;
|
||
if let Some(on_toggle_edit) = on_toggle_edit {
|
||
let message = (on_toggle_edit)(!state.is_read_only);
|
||
shell.publish(message);
|
||
|
||
let now = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(now));
|
||
state.is_focused = Some(Focus {
|
||
updated_at: now,
|
||
now,
|
||
});
|
||
|
||
state.move_cursor_to_end();
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
_ => {
|
||
state.dragging_state = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !is_trailing_clicked && click_position.is_none() {
|
||
state.is_focused = None;
|
||
state.dragging_state = None;
|
||
state.is_pasting = None;
|
||
|
||
state.keyboard_modifiers = keyboard::Modifiers::default();
|
||
}
|
||
}
|
||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||
| Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => {
|
||
let state = state();
|
||
state.dragging_state = None;
|
||
}
|
||
Event::Mouse(mouse::Event::CursorMoved { position })
|
||
| Event::Touch(touch::Event::FingerMoved { position, .. }) => {
|
||
let state = state();
|
||
|
||
if matches!(state.dragging_state, Some(DraggingState::Selection)) {
|
||
let target = position.x - text_layout.bounds().x;
|
||
|
||
update_cache(state, value);
|
||
let position =
|
||
find_cursor_position(text_layout.bounds(), value, state, target).unwrap_or(0);
|
||
|
||
state
|
||
.cursor
|
||
.select_range(state.cursor.start(value), position);
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
Event::Keyboard(keyboard::Event::KeyPressed { key, text, .. }) => {
|
||
let state = state();
|
||
|
||
if let Some(focus) = &mut state.is_focused {
|
||
let Some(on_input) = on_input else {
|
||
return event::Status::Ignored;
|
||
};
|
||
|
||
if state.is_read_only {
|
||
return event::Status::Ignored;
|
||
}
|
||
|
||
let modifiers = state.keyboard_modifiers;
|
||
focus.updated_at = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at));
|
||
|
||
match key {
|
||
keyboard::Key::Named(keyboard::key::Named::Enter) => {
|
||
if let Some(on_submit) = on_submit.clone() {
|
||
shell.publish(on_submit);
|
||
}
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::Backspace) => {
|
||
if platform::is_jump_modifier_pressed(modifiers)
|
||
&& state.cursor.selection(value).is_none()
|
||
{
|
||
if is_secure {
|
||
let cursor_pos = state.cursor.end(value);
|
||
state.cursor.select_range(0, cursor_pos);
|
||
} else {
|
||
state.cursor.select_left_by_words(value);
|
||
}
|
||
}
|
||
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
editor.backspace();
|
||
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
let message = (on_input)(editor.contents());
|
||
shell.publish(message);
|
||
|
||
let value = if is_secure {
|
||
unsecured_value.secure()
|
||
} else {
|
||
unsecured_value
|
||
};
|
||
update_cache(state, &value);
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::Delete) => {
|
||
if platform::is_jump_modifier_pressed(modifiers)
|
||
&& state.cursor.selection(value).is_none()
|
||
{
|
||
if is_secure {
|
||
let cursor_pos = state.cursor.end(unsecured_value);
|
||
state.cursor.select_range(cursor_pos, unsecured_value.len());
|
||
} else {
|
||
state.cursor.select_right_by_words(unsecured_value);
|
||
}
|
||
}
|
||
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
editor.delete();
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
let message = (on_input)(contents);
|
||
shell.publish(message);
|
||
let value = if is_secure {
|
||
unsecured_value.secure()
|
||
} else {
|
||
unsecured_value
|
||
};
|
||
|
||
update_cache(state, &value);
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
|
||
if platform::is_jump_modifier_pressed(modifiers) && !is_secure {
|
||
if modifiers.shift() {
|
||
state.cursor.select_left_by_words(value);
|
||
} else {
|
||
state.cursor.move_left_by_words(value);
|
||
}
|
||
} else if modifiers.shift() {
|
||
state.cursor.select_left(value);
|
||
} else {
|
||
state.cursor.move_left(value);
|
||
}
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
|
||
if platform::is_jump_modifier_pressed(modifiers) && !is_secure {
|
||
if modifiers.shift() {
|
||
state.cursor.select_right_by_words(value);
|
||
} else {
|
||
state.cursor.move_right_by_words(value);
|
||
}
|
||
} else if modifiers.shift() {
|
||
state.cursor.select_right(value);
|
||
} else {
|
||
state.cursor.move_right(value);
|
||
}
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::Home) => {
|
||
if modifiers.shift() {
|
||
state.cursor.select_range(state.cursor.start(value), 0);
|
||
} else {
|
||
state.cursor.move_to(0);
|
||
}
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::End) => {
|
||
if modifiers.shift() {
|
||
state
|
||
.cursor
|
||
.select_range(state.cursor.start(value), value.len());
|
||
} else {
|
||
state.cursor.move_to(value.len());
|
||
}
|
||
}
|
||
keyboard::Key::Character(ref c)
|
||
if "c" == c && state.keyboard_modifiers.command() =>
|
||
{
|
||
if !is_secure {
|
||
if let Some((start, end)) = state.cursor.selection(value) {
|
||
clipboard.write(
|
||
iced_core::clipboard::Kind::Standard,
|
||
value.select(start, end).to_string(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// XXX if we want to allow cutting of secure text, we need to
|
||
// update the cache and decide which value to cut
|
||
keyboard::Key::Character(c)
|
||
if "x" == c && state.keyboard_modifiers.command() =>
|
||
{
|
||
if !is_secure {
|
||
if let Some((start, end)) = state.cursor.selection(value) {
|
||
clipboard.write(
|
||
iced_core::clipboard::Kind::Standard,
|
||
value.select(start, end).to_string(),
|
||
);
|
||
}
|
||
|
||
let mut editor = Editor::new(value, &mut state.cursor);
|
||
editor.delete();
|
||
|
||
let message = (on_input)(editor.contents());
|
||
|
||
shell.publish(message);
|
||
}
|
||
}
|
||
keyboard::Key::Character(c)
|
||
if "v" == c && state.keyboard_modifiers.command() =>
|
||
{
|
||
let content = if let Some(content) = state.is_pasting.take() {
|
||
content
|
||
} else {
|
||
let content: String = clipboard
|
||
.read(iced_core::clipboard::Kind::Standard)
|
||
.unwrap_or_default()
|
||
.chars()
|
||
.filter(|c| !c.is_control())
|
||
.collect();
|
||
|
||
Value::new(&content)
|
||
};
|
||
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
|
||
editor.paste(content.clone());
|
||
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
let message = if let Some(paste) = &on_paste {
|
||
(paste)(contents)
|
||
} else {
|
||
(on_input)(contents)
|
||
};
|
||
shell.publish(message);
|
||
|
||
state.is_pasting = Some(content);
|
||
|
||
let value = if is_secure {
|
||
unsecured_value.secure()
|
||
} else {
|
||
unsecured_value
|
||
};
|
||
|
||
update_cache(state, &value);
|
||
}
|
||
keyboard::Key::Character(c)
|
||
if "a" == c && state.keyboard_modifiers.command() =>
|
||
{
|
||
state.cursor.select_all(value);
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::Escape) => {
|
||
state.is_focused = None;
|
||
state.dragging_state = None;
|
||
state.is_pasting = None;
|
||
|
||
state.keyboard_modifiers = keyboard::Modifiers::default();
|
||
}
|
||
keyboard::Key::Named(
|
||
keyboard::key::Named::Tab
|
||
| keyboard::key::Named::ArrowUp
|
||
| keyboard::key::Named::ArrowDown,
|
||
) => {
|
||
return event::Status::Ignored;
|
||
}
|
||
keyboard::Key::Character(_)
|
||
| keyboard::Key::Named(keyboard::key::Named::Space) => {
|
||
if state.is_pasting.is_none()
|
||
&& !state.keyboard_modifiers.command()
|
||
&& !modifiers.control()
|
||
{
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
|
||
let character =
|
||
text.unwrap_or_default().chars().next().unwrap_or_default();
|
||
if !character.is_control() {
|
||
editor.insert(character);
|
||
}
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
let message = (on_input)(contents);
|
||
shell.publish(message);
|
||
|
||
focus.updated_at = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(focus.updated_at));
|
||
|
||
let value = if is_secure {
|
||
unsecured_value.secure()
|
||
} else {
|
||
unsecured_value
|
||
};
|
||
update_cache(state, &value);
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) => {
|
||
let state = state();
|
||
|
||
if state.is_focused.is_some() {
|
||
match key {
|
||
keyboard::Key::Character(c) if "v" == c => {
|
||
state.is_pasting = None;
|
||
}
|
||
keyboard::Key::Named(keyboard::key::Named::Tab)
|
||
| keyboard::Key::Named(keyboard::key::Named::ArrowUp)
|
||
| keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
|
||
return event::Status::Ignored;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
|
||
let state = state();
|
||
|
||
state.keyboard_modifiers = modifiers;
|
||
}
|
||
Event::Window(window::Event::RedrawRequested(now)) => {
|
||
let state = state();
|
||
|
||
if let Some(focus) = &mut state.is_focused {
|
||
focus.now = now;
|
||
|
||
let millis_until_redraw = CURSOR_BLINK_INTERVAL_MILLIS
|
||
- (now - focus.updated_at).as_millis() % CURSOR_BLINK_INTERVAL_MILLIS;
|
||
|
||
shell.request_redraw(window::RedrawRequest::At(
|
||
now + Duration::from_millis(u64::try_from(millis_until_redraw).unwrap()),
|
||
));
|
||
}
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Source(SourceEvent::Finished | SourceEvent::Cancelled)) => {
|
||
let state = state();
|
||
if matches!(state.dragging_state, Some(DraggingState::Dnd(..))) {
|
||
state.dragging_state = None;
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Offer(
|
||
rectangle,
|
||
OfferEvent::Enter {
|
||
x,
|
||
y,
|
||
mime_types,
|
||
surface,
|
||
},
|
||
)) if rectangle == Some(dnd_id) => {
|
||
let state = state();
|
||
let is_clicked = text_layout.bounds().contains(Point {
|
||
x: x as f32,
|
||
y: y as f32,
|
||
});
|
||
|
||
let mut accepted = false;
|
||
for m in &mime_types {
|
||
if SUPPORTED_TEXT_MIME_TYPES.contains(&m.as_str()) {
|
||
let clone = m.clone();
|
||
accepted = true;
|
||
}
|
||
}
|
||
if accepted {
|
||
let target = x as f32 - text_layout.bounds().x;
|
||
state.dnd_offer =
|
||
DndOfferState::HandlingOffer(mime_types.clone(), DndAction::empty());
|
||
// existing logic for setting the selection
|
||
let position = if target > 0.0 {
|
||
update_cache(state, value);
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
state.cursor.move_to(position.unwrap_or(0));
|
||
return event::Status::Captured;
|
||
}
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Motion { x, y }))
|
||
if rectangle == Some(dnd_id) =>
|
||
{
|
||
let state = state();
|
||
|
||
let target = x as f32 - text_layout.bounds().x;
|
||
// existing logic for setting the selection
|
||
let position = if target > 0.0 {
|
||
update_cache(state, value);
|
||
find_cursor_position(text_layout.bounds(), value, state, target)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
state.cursor.move_to(position.unwrap_or(0));
|
||
return event::Status::Captured;
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Drop)) if rectangle == Some(dnd_id) => {
|
||
let state = state();
|
||
if let DndOfferState::HandlingOffer(mime_types, _action) = state.dnd_offer.clone() {
|
||
let Some(mime_type) = SUPPORTED_TEXT_MIME_TYPES
|
||
.iter()
|
||
.find(|m| mime_types.contains(&(**m).to_string()))
|
||
else {
|
||
state.dnd_offer = DndOfferState::None;
|
||
return event::Status::Captured;
|
||
};
|
||
state.dnd_offer = DndOfferState::Dropped;
|
||
}
|
||
|
||
return event::Status::Ignored;
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Offer(
|
||
rectangle,
|
||
OfferEvent::Leave | OfferEvent::LeaveDestination,
|
||
)) if rectangle == Some(dnd_id) => {
|
||
let state = state();
|
||
// ASHLEY TODO we should be able to reset but for now we don't if we are handling a
|
||
// drop
|
||
match state.dnd_offer {
|
||
DndOfferState::Dropped => {}
|
||
_ => {
|
||
state.dnd_offer = DndOfferState::None;
|
||
}
|
||
};
|
||
return event::Status::Captured;
|
||
}
|
||
#[cfg(feature = "wayland")]
|
||
Event::Dnd(DndEvent::Offer(rectangle, OfferEvent::Data { data, mime_type }))
|
||
if rectangle == Some(dnd_id) =>
|
||
{
|
||
let state = state();
|
||
if let DndOfferState::Dropped = state.dnd_offer.clone() {
|
||
state.dnd_offer = DndOfferState::None;
|
||
if !SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type.as_str()) || data.is_empty() {
|
||
return event::Status::Captured;
|
||
}
|
||
let Ok(content) = String::from_utf8(data) else {
|
||
return event::Status::Captured;
|
||
};
|
||
|
||
let mut editor = Editor::new(unsecured_value, &mut state.cursor);
|
||
|
||
editor.paste(Value::new(content.as_str()));
|
||
let contents = editor.contents();
|
||
let unsecured_value = Value::new(&contents);
|
||
|
||
if let Some(on_paste) = on_paste.as_ref() {
|
||
let message = (on_paste)(contents);
|
||
shell.publish(message);
|
||
}
|
||
|
||
let value = if is_secure {
|
||
unsecured_value.secure()
|
||
} else {
|
||
unsecured_value
|
||
};
|
||
update_cache(state, &value);
|
||
return event::Status::Captured;
|
||
}
|
||
return event::Status::Ignored;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
event::Status::Ignored
|
||
}
|
||
|
||
/// Draws the [`TextInput`] with the given [`Renderer`], overriding its
|
||
/// [`Value`] if provided.
|
||
///
|
||
/// [`Renderer`]: text::Renderer
|
||
#[allow(clippy::too_many_arguments)]
|
||
#[allow(clippy::too_many_lines)]
|
||
#[allow(clippy::missing_panics_doc)]
|
||
pub fn draw<'a, Message>(
|
||
renderer: &mut crate::Renderer,
|
||
theme: &crate::Theme,
|
||
layout: Layout<'_>,
|
||
text_layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
tree: &Tree,
|
||
value: &Value,
|
||
placeholder: &str,
|
||
size: Option<f32>,
|
||
font: Option<<crate::Renderer as iced_core::text::Renderer>::Font>,
|
||
is_disabled: bool,
|
||
is_secure: bool,
|
||
icon: Option<&Element<'a, Message, crate::Theme, crate::Renderer>>,
|
||
trailing_icon: Option<&Element<'a, Message, crate::Theme, crate::Renderer>>,
|
||
style: &<crate::Theme as StyleSheet>::Style,
|
||
dnd_icon: bool,
|
||
line_height: text::LineHeight,
|
||
error: Option<&str>,
|
||
label: Option<&str>,
|
||
helper_text: Option<&str>,
|
||
helper_text_size: f32,
|
||
helper_line_height: text::LineHeight,
|
||
viewport: &Rectangle,
|
||
renderer_style: &renderer::Style,
|
||
) {
|
||
// all children should be icon images
|
||
let children = &tree.children;
|
||
|
||
let state = tree.state.downcast_ref::<State>();
|
||
let secure_value = is_secure.then(|| value.secure());
|
||
let value = secure_value.as_ref().unwrap_or(value);
|
||
|
||
let mut children_layout = layout.children();
|
||
|
||
let (label_layout, layout, helper_text_layout) = if label.is_some() && helper_text.is_some() {
|
||
let label_layout = children_layout.next();
|
||
let layout = children_layout.next().unwrap();
|
||
let helper_text_layout = children_layout.next();
|
||
(label_layout, layout, helper_text_layout)
|
||
} else if label.is_some() {
|
||
let label_layout = children_layout.next();
|
||
let layout = children_layout.next().unwrap();
|
||
(label_layout, layout, None)
|
||
} else if helper_text.is_some() {
|
||
let layout = children_layout.next().unwrap();
|
||
let helper_text_layout = children_layout.next();
|
||
(None, layout, helper_text_layout)
|
||
} else {
|
||
let layout = children_layout.next().unwrap();
|
||
|
||
(None, layout, None)
|
||
};
|
||
|
||
let mut children_layout = layout.children();
|
||
let bounds = layout.bounds();
|
||
// XXX Dnd widget may not have a layout with children, so we just use the text_layout
|
||
let text_bounds = children_layout.next().unwrap_or(text_layout).bounds();
|
||
|
||
let is_mouse_over = cursor_position.is_over(bounds);
|
||
|
||
let appearance = if is_disabled {
|
||
theme.disabled(style)
|
||
} else if error.is_some() {
|
||
theme.error(style)
|
||
} else if state.is_focused() {
|
||
theme.focused(style)
|
||
} else if is_mouse_over {
|
||
theme.hovered(style)
|
||
} else {
|
||
theme.active(style)
|
||
};
|
||
|
||
let mut icon_color = appearance.icon_color.unwrap_or(renderer_style.icon_color);
|
||
let mut text_color = appearance.text_color.unwrap_or(renderer_style.text_color);
|
||
|
||
// TODO: iced will not render alpha itself on text or icon colors.
|
||
if is_disabled {
|
||
let background = theme.current_container().component.base.into();
|
||
icon_color = icon_color.blend_alpha(background, 0.5);
|
||
text_color = text_color.blend_alpha(background, 0.5);
|
||
}
|
||
|
||
// draw background and its border
|
||
if let Some(border_offset) = appearance.border_offset {
|
||
let offset_bounds = Rectangle {
|
||
x: bounds.x - border_offset,
|
||
y: bounds.y - border_offset,
|
||
width: border_offset.mul_add(2.0, bounds.width),
|
||
height: border_offset.mul_add(2.0, bounds.height),
|
||
};
|
||
renderer.fill_quad(
|
||
renderer::Quad {
|
||
bounds,
|
||
border: Border {
|
||
radius: appearance.border_radius,
|
||
width: appearance.border_width,
|
||
..Default::default()
|
||
},
|
||
shadow: Shadow {
|
||
offset: Vector::new(0.0, 1.0),
|
||
color: Color::TRANSPARENT,
|
||
blur_radius: 0.0,
|
||
},
|
||
},
|
||
appearance.background,
|
||
);
|
||
renderer.fill_quad(
|
||
renderer::Quad {
|
||
bounds: offset_bounds,
|
||
border: Border {
|
||
width: appearance.border_width,
|
||
color: appearance.border_color,
|
||
radius: appearance.border_radius,
|
||
},
|
||
shadow: Shadow {
|
||
offset: Vector::new(0.0, 1.0),
|
||
color: Color::TRANSPARENT,
|
||
blur_radius: 0.0,
|
||
},
|
||
},
|
||
Background::Color(Color::TRANSPARENT),
|
||
);
|
||
} else {
|
||
renderer.fill_quad(
|
||
renderer::Quad {
|
||
bounds,
|
||
border: Border {
|
||
width: appearance.border_width,
|
||
color: appearance.border_color,
|
||
radius: appearance.border_radius,
|
||
},
|
||
shadow: Shadow {
|
||
offset: Vector::new(0.0, 1.0),
|
||
color: Color::TRANSPARENT,
|
||
blur_radius: 0.0,
|
||
},
|
||
},
|
||
appearance.background,
|
||
);
|
||
}
|
||
|
||
// draw the label if it exists
|
||
if let (Some(label_layout), Some(label)) = (label_layout, label) {
|
||
renderer.fill_text(
|
||
Text {
|
||
content: label.to_string(),
|
||
size: iced::Pixels(size.unwrap_or_else(|| renderer.default_size().0)),
|
||
font: font.unwrap_or_else(|| renderer.default_font()),
|
||
bounds: label_layout.bounds().size(),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Top,
|
||
line_height,
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
},
|
||
label_layout.bounds().position(),
|
||
appearance.label_color,
|
||
*viewport,
|
||
);
|
||
}
|
||
let mut child_index = 0;
|
||
let leading_icon_tree = children.get(child_index);
|
||
// draw the start icon in the text input
|
||
let has_start_icon = icon.is_some();
|
||
if let (Some(icon), Some(tree)) = (icon, leading_icon_tree) {
|
||
let mut children = text_layout.children();
|
||
let _ = children.next().unwrap();
|
||
let icon_layout = children.next().unwrap();
|
||
|
||
icon.as_widget().draw(
|
||
tree,
|
||
renderer,
|
||
theme,
|
||
&renderer::Style {
|
||
icon_color,
|
||
text_color,
|
||
scale_factor: renderer_style.scale_factor,
|
||
},
|
||
icon_layout,
|
||
cursor_position,
|
||
viewport,
|
||
);
|
||
child_index += 1;
|
||
}
|
||
|
||
let text = value.to_string();
|
||
let font = font.unwrap_or_else(|| renderer.default_font());
|
||
let size = size.unwrap_or_else(|| renderer.default_size().0);
|
||
|
||
let radius_0 = THEME.lock().unwrap().cosmic().corner_radii.radius_0.into();
|
||
#[cfg(feature = "wayland")]
|
||
let handling_dnd_offer = !matches!(state.dnd_offer, DndOfferState::None);
|
||
#[cfg(not(feature = "wayland"))]
|
||
let handling_dnd_offer = false;
|
||
let (cursor, offset) = if let Some(focus) = &state.is_focused.or_else(|| {
|
||
handling_dnd_offer.then(|| Focus {
|
||
updated_at: Instant::now(),
|
||
now: Instant::now(),
|
||
})
|
||
}) {
|
||
match state.cursor.state(value) {
|
||
cursor::State::Index(position) => {
|
||
let (text_value_width, offset) =
|
||
measure_cursor_and_scroll_offset(state.value.raw(), text_bounds, position);
|
||
|
||
let is_cursor_visible = handling_dnd_offer
|
||
|| ((focus.now - focus.updated_at).as_millis() / CURSOR_BLINK_INTERVAL_MILLIS)
|
||
% 2
|
||
== 0;
|
||
|
||
if is_cursor_visible {
|
||
if dnd_icon {
|
||
(None, 0.0)
|
||
} else {
|
||
(
|
||
Some((
|
||
renderer::Quad {
|
||
bounds: Rectangle {
|
||
x: text_bounds.x + text_value_width - offset,
|
||
y: text_bounds.y,
|
||
width: 1.0,
|
||
height: text_bounds.height,
|
||
},
|
||
border: Border {
|
||
width: 0.0,
|
||
color: Color::TRANSPARENT,
|
||
radius: radius_0,
|
||
},
|
||
shadow: Shadow {
|
||
offset: Vector::ZERO,
|
||
color: Color::TRANSPARENT,
|
||
blur_radius: 0.0,
|
||
},
|
||
},
|
||
text_color,
|
||
)),
|
||
offset,
|
||
)
|
||
}
|
||
} else {
|
||
(None, offset)
|
||
}
|
||
}
|
||
cursor::State::Selection { start, end } => {
|
||
let left = start.min(end);
|
||
let right = end.max(start);
|
||
|
||
let value_paragraph = &state.value;
|
||
let (left_position, left_offset) =
|
||
measure_cursor_and_scroll_offset(value_paragraph.raw(), text_bounds, left);
|
||
|
||
let (right_position, right_offset) =
|
||
measure_cursor_and_scroll_offset(value_paragraph.raw(), text_bounds, right);
|
||
|
||
let width = right_position - left_position;
|
||
|
||
if dnd_icon {
|
||
(None, 0.0)
|
||
} else {
|
||
(
|
||
Some((
|
||
renderer::Quad {
|
||
bounds: Rectangle {
|
||
x: text_bounds.x + left_position,
|
||
y: text_bounds.y,
|
||
width,
|
||
height: text_bounds.height,
|
||
},
|
||
border: Border {
|
||
width: 0.0,
|
||
color: Color::TRANSPARENT,
|
||
radius: radius_0,
|
||
},
|
||
shadow: Shadow {
|
||
offset: Vector::ZERO,
|
||
color: Color::TRANSPARENT,
|
||
blur_radius: 0.0,
|
||
},
|
||
},
|
||
appearance.selected_fill,
|
||
)),
|
||
if end == right {
|
||
right_offset
|
||
} else {
|
||
left_offset
|
||
},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
(None, 0.0)
|
||
};
|
||
|
||
let text_width = state.value.min_width();
|
||
|
||
let render = |renderer: &mut crate::Renderer| {
|
||
if let Some((cursor, color)) = cursor {
|
||
renderer.fill_quad(cursor, color);
|
||
} else {
|
||
renderer.with_translation(Vector::ZERO, |_| {});
|
||
}
|
||
|
||
let bounds = Rectangle {
|
||
x: text_bounds.x - offset,
|
||
y: text_bounds.center_y(),
|
||
width: f32::INFINITY,
|
||
..text_bounds
|
||
};
|
||
let color = if text.is_empty() {
|
||
appearance.placeholder_color
|
||
} else {
|
||
text_color
|
||
};
|
||
|
||
renderer.fill_text(
|
||
Text {
|
||
content: if text.is_empty() {
|
||
placeholder.to_string()
|
||
} else {
|
||
text.clone()
|
||
},
|
||
font,
|
||
bounds: bounds.size(),
|
||
size: iced::Pixels(size),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Center,
|
||
line_height: text::LineHeight::default(),
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
},
|
||
bounds.position(),
|
||
color,
|
||
*viewport,
|
||
);
|
||
};
|
||
|
||
renderer.with_layer(text_bounds, render);
|
||
|
||
let trailing_icon_tree = children.get(child_index);
|
||
|
||
// draw the end icon in the text input
|
||
if let (Some(icon), Some(tree)) = (trailing_icon, trailing_icon_tree) {
|
||
let mut children = text_layout.children();
|
||
let mut icon_layout = children.next().unwrap();
|
||
if has_start_icon {
|
||
icon_layout = children.next().unwrap();
|
||
}
|
||
icon_layout = children.next().unwrap();
|
||
|
||
icon.as_widget().draw(
|
||
tree,
|
||
renderer,
|
||
theme,
|
||
&renderer::Style {
|
||
icon_color,
|
||
text_color,
|
||
scale_factor: renderer_style.scale_factor,
|
||
},
|
||
icon_layout,
|
||
cursor_position,
|
||
viewport,
|
||
);
|
||
}
|
||
|
||
// draw the helper text if it exists
|
||
if let (Some(helper_text_layout), Some(helper_text)) = (helper_text_layout, helper_text) {
|
||
renderer.fill_text(
|
||
Text {
|
||
content: helper_text.to_string(), // TODO remove to_string?
|
||
size: iced::Pixels(helper_text_size),
|
||
font,
|
||
bounds: helper_text_layout.bounds().size(),
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Top,
|
||
line_height: helper_line_height,
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
},
|
||
helper_text_layout.bounds().position(),
|
||
text_color,
|
||
*viewport,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Computes the current [`mouse::Interaction`] of the [`TextInput`].
|
||
#[must_use]
|
||
pub fn mouse_interaction(
|
||
layout: Layout<'_>,
|
||
cursor_position: mouse::Cursor,
|
||
is_disabled: bool,
|
||
) -> mouse::Interaction {
|
||
if cursor_position.is_over(layout.bounds()) {
|
||
if is_disabled {
|
||
mouse::Interaction::NotAllowed
|
||
} else {
|
||
mouse::Interaction::Text
|
||
}
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
|
||
/// A string which can be sent to the clipboard or drag-and-dropped.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TextInputString(pub String);
|
||
|
||
#[cfg(feature = "wayland")]
|
||
impl AsMimeTypes for TextInputString {
|
||
fn available(&self) -> Cow<'static, [String]> {
|
||
Cow::Owned(
|
||
SUPPORTED_TEXT_MIME_TYPES
|
||
.iter()
|
||
.cloned()
|
||
.map(String::from)
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
}
|
||
|
||
fn as_bytes(&self, mime_type: &str) -> Option<Cow<'static, [u8]>> {
|
||
if SUPPORTED_TEXT_MIME_TYPES.contains(&mime_type) {
|
||
Some(Cow::Owned(self.0.clone().as_bytes().to_vec()))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum DraggingState {
|
||
Selection,
|
||
#[cfg(feature = "wayland")]
|
||
Dnd(DndAction, String),
|
||
}
|
||
|
||
#[cfg(feature = "wayland")]
|
||
#[derive(Debug, Default, Clone)]
|
||
pub(crate) enum DndOfferState {
|
||
#[default]
|
||
None,
|
||
HandlingOffer(Vec<String>, DndAction),
|
||
Dropped,
|
||
}
|
||
#[derive(Debug, Default, Clone)]
|
||
#[cfg(not(feature = "wayland"))]
|
||
pub(crate) struct DndOfferState;
|
||
|
||
/// The state of a [`TextInput`].
|
||
#[derive(Debug, Default, Clone)]
|
||
#[must_use]
|
||
pub struct State {
|
||
pub value: crate::Plain,
|
||
pub placeholder: crate::Plain,
|
||
pub label: crate::Plain,
|
||
pub helper_text: crate::Plain,
|
||
pub dirty: bool,
|
||
pub is_secure: bool,
|
||
pub is_read_only: bool,
|
||
select_on_focus: bool,
|
||
is_focused: Option<Focus>,
|
||
dragging_state: Option<DraggingState>,
|
||
dnd_offer: DndOfferState,
|
||
is_pasting: Option<Value>,
|
||
last_click: Option<mouse::Click>,
|
||
cursor: Cursor,
|
||
keyboard_modifiers: keyboard::Modifiers,
|
||
// TODO: Add stateful horizontal scrolling offset
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct Focus {
|
||
updated_at: Instant,
|
||
now: Instant,
|
||
}
|
||
|
||
impl State {
|
||
/// Creates a new [`State`], representing an unfocused [`TextInput`].
|
||
pub fn new(
|
||
is_secure: bool,
|
||
is_read_only: bool,
|
||
always_active: bool,
|
||
select_on_focus: bool,
|
||
) -> Self {
|
||
Self {
|
||
is_secure,
|
||
is_read_only,
|
||
is_focused: always_active.then(|| {
|
||
let now = Instant::now();
|
||
Focus {
|
||
updated_at: now,
|
||
now,
|
||
}
|
||
}),
|
||
select_on_focus,
|
||
..Self::default()
|
||
}
|
||
}
|
||
|
||
/// Returns the current value of the selected text in the [`TextInput`].
|
||
#[must_use]
|
||
pub fn selected_text(&self, text: &str) -> Option<String> {
|
||
let value = Value::new(text);
|
||
match self.cursor.state(&value) {
|
||
cursor::State::Index(_) => None,
|
||
cursor::State::Selection { start, end } => {
|
||
let left = start.min(end);
|
||
let right = end.max(start);
|
||
Some(text[left..right].to_string())
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "wayland")]
|
||
/// Returns the current value of the dragged text in the [`TextInput`].
|
||
#[must_use]
|
||
pub fn dragged_text(&self) -> Option<String> {
|
||
match self.dragging_state.as_ref() {
|
||
Some(DraggingState::Dnd(_, text)) => Some(text.clone()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Creates a new [`State`], representing a focused [`TextInput`].
|
||
pub fn focused(is_secure: bool, is_read_only: bool) -> Self {
|
||
Self {
|
||
is_secure,
|
||
value: crate::Plain::default(),
|
||
placeholder: crate::Plain::default(),
|
||
label: crate::Plain::default(),
|
||
helper_text: crate::Plain::default(),
|
||
is_read_only,
|
||
is_focused: None,
|
||
select_on_focus: false,
|
||
dragging_state: None,
|
||
dnd_offer: DndOfferState::default(),
|
||
is_pasting: None,
|
||
last_click: None,
|
||
cursor: Cursor::default(),
|
||
keyboard_modifiers: keyboard::Modifiers::default(),
|
||
dirty: false,
|
||
}
|
||
}
|
||
|
||
/// Returns whether the [`TextInput`] is currently focused or not.
|
||
#[must_use]
|
||
pub fn is_focused(&self) -> bool {
|
||
self.is_focused.is_some()
|
||
}
|
||
|
||
/// Returns the [`Cursor`] of the [`TextInput`].
|
||
#[must_use]
|
||
pub fn cursor(&self) -> Cursor {
|
||
self.cursor
|
||
}
|
||
|
||
/// Focuses the [`TextInput`].
|
||
pub fn focus(&mut self) {
|
||
let now = Instant::now();
|
||
LAST_FOCUS_UPDATE.with(|x| x.set(now));
|
||
self.is_read_only = false;
|
||
self.is_focused = Some(Focus {
|
||
updated_at: now,
|
||
now,
|
||
});
|
||
|
||
if self.select_on_focus {
|
||
self.select_all()
|
||
} else {
|
||
self.move_cursor_to_end();
|
||
}
|
||
}
|
||
|
||
/// Unfocuses the [`TextInput`].
|
||
pub fn unfocus(&mut self) {
|
||
self.is_focused = None;
|
||
}
|
||
|
||
/// Moves the [`Cursor`] of the [`TextInput`] to the front of the input text.
|
||
pub fn move_cursor_to_front(&mut self) {
|
||
self.cursor.move_to(0);
|
||
}
|
||
|
||
/// Moves the [`Cursor`] of the [`TextInput`] to the end of the input text.
|
||
pub fn move_cursor_to_end(&mut self) {
|
||
self.cursor.move_to(usize::MAX);
|
||
}
|
||
|
||
/// Moves the [`Cursor`] of the [`TextInput`] to an arbitrary location.
|
||
pub fn move_cursor_to(&mut self, position: usize) {
|
||
self.cursor.move_to(position);
|
||
}
|
||
|
||
/// Selects all the content of the [`TextInput`].
|
||
pub fn select_all(&mut self) {
|
||
self.cursor.select_range(0, usize::MAX);
|
||
}
|
||
}
|
||
|
||
impl operation::Focusable for State {
|
||
fn is_focused(&self) -> bool {
|
||
Self::is_focused(self)
|
||
}
|
||
|
||
fn focus(&mut self) {
|
||
Self::focus(self);
|
||
}
|
||
|
||
fn unfocus(&mut self) {
|
||
Self::unfocus(self);
|
||
}
|
||
}
|
||
|
||
impl operation::TextInput for State {
|
||
fn move_cursor_to_front(&mut self) {
|
||
Self::move_cursor_to_front(self);
|
||
}
|
||
|
||
fn move_cursor_to_end(&mut self) {
|
||
Self::move_cursor_to_end(self);
|
||
}
|
||
|
||
fn move_cursor_to(&mut self, position: usize) {
|
||
Self::move_cursor_to(self, position);
|
||
}
|
||
|
||
fn select_all(&mut self) {
|
||
Self::select_all(self);
|
||
}
|
||
}
|
||
|
||
fn measure_cursor_and_scroll_offset(
|
||
paragraph: &impl text::Paragraph,
|
||
text_bounds: Rectangle,
|
||
cursor_index: usize,
|
||
) -> (f32, f32) {
|
||
let grapheme_position = paragraph
|
||
.grapheme_position(0, cursor_index)
|
||
.unwrap_or(Point::ORIGIN);
|
||
|
||
let offset = ((grapheme_position.x + 5.0) - text_bounds.width).max(0.0);
|
||
|
||
(grapheme_position.x, offset)
|
||
}
|
||
|
||
/// Computes the position of the text cursor at the given X coordinate of
|
||
/// a [`TextInput`].
|
||
fn find_cursor_position(
|
||
text_bounds: Rectangle,
|
||
value: &Value,
|
||
state: &State,
|
||
x: f32,
|
||
) -> Option<usize> {
|
||
let offset = offset(text_bounds, value, state);
|
||
let value = value.to_string();
|
||
|
||
let char_offset = state
|
||
.value
|
||
.raw()
|
||
.hit_test(Point::new(x + offset, text_bounds.height / 2.0))
|
||
.map(text::Hit::cursor)?;
|
||
|
||
Some(
|
||
unicode_segmentation::UnicodeSegmentation::graphemes(
|
||
&value[..char_offset.min(value.len())],
|
||
true,
|
||
)
|
||
.count(),
|
||
)
|
||
}
|
||
|
||
fn replace_paragraph(
|
||
state: &mut State,
|
||
layout: Layout<'_>,
|
||
value: &Value,
|
||
font: <crate::Renderer as iced_core::text::Renderer>::Font,
|
||
text_size: Pixels,
|
||
line_height: text::LineHeight,
|
||
) {
|
||
let mut children_layout = layout.children();
|
||
let text_bounds = children_layout.next().unwrap().bounds();
|
||
|
||
state.value = crate::Plain::new(Text {
|
||
font,
|
||
line_height,
|
||
content: &value.to_string(),
|
||
bounds: Size::new(f32::INFINITY, text_bounds.height),
|
||
size: text_size,
|
||
horizontal_alignment: alignment::Horizontal::Left,
|
||
vertical_alignment: alignment::Vertical::Top,
|
||
shaping: text::Shaping::Advanced,
|
||
wrapping: text::Wrapping::default(),
|
||
});
|
||
}
|
||
|
||
const CURSOR_BLINK_INTERVAL_MILLIS: u128 = 500;
|
||
|
||
mod platform {
|
||
use iced_core::keyboard;
|
||
|
||
pub fn is_jump_modifier_pressed(modifiers: keyboard::Modifiers) -> bool {
|
||
if cfg!(target_os = "macos") {
|
||
modifiers.alt()
|
||
} else {
|
||
modifiers.control()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn offset(text_bounds: Rectangle, value: &Value, state: &State) -> f32 {
|
||
if state.is_focused() {
|
||
let cursor = state.cursor();
|
||
|
||
let focus_position = match cursor.state(value) {
|
||
cursor::State::Index(i) => i,
|
||
cursor::State::Selection { end, .. } => end,
|
||
};
|
||
|
||
let (_, offset) =
|
||
measure_cursor_and_scroll_offset(state.value.raw(), text_bounds, focus_position);
|
||
|
||
offset
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|