feat(widget): add editable_input widget

This commit is contained in:
Michael Aaron Murphy 2024-03-23 23:44:33 -07:00 committed by Michael Murphy
parent 02acbd343c
commit 0ac4172b74
2 changed files with 184 additions and 63 deletions

View file

@ -11,6 +11,7 @@ use iced_core::Color;
pub enum TextInput {
#[default]
Default,
EditableText,
ExpandableSearch,
Search,
Inline,
@ -52,6 +53,22 @@ impl StyleSheet for crate::Theme {
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::EditableText => Appearance {
background: Color::TRANSPARENT.into(),
border_radius: corner.radius_0.into(),
border_width: 0.0,
border_offset: None,
border_color: Color::TRANSPARENT,
icon_color: container.on.into(),
text_color: container.on.into(),
placeholder_color: {
let color: Color = container.on.into();
color.blend_alpha(background, 0.7)
},
selected_text_color: palette.on_accent_color().into(),
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::ExpandableSearch => Appearance {
background: Color::TRANSPARENT.into(),
border_radius: corner.radius_xl.into(),
@ -147,7 +164,7 @@ impl StyleSheet for crate::Theme {
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::Inline => Appearance {
TextInput::EditableText | TextInput::Inline => Appearance {
background: Color::TRANSPARENT.into(),
border_radius: corner.radius_0.into(),
border_width: 0.0,
@ -226,6 +243,22 @@ impl StyleSheet for crate::Theme {
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::EditableText => Appearance {
background: Color::TRANSPARENT.into(),
border_radius: corner.radius_0.into(),
border_width: 0.0,
border_offset: None,
border_color: Color::TRANSPARENT,
icon_color: container.on.into(),
text_color: container.on.into(),
placeholder_color: {
let color: Color = container.on.into();
color.blend_alpha(background, 0.7)
},
selected_text_color: palette.on_accent_color().into(),
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::Inline => Appearance {
background: Color::from(container.component.hover).into(),
border_radius: corner.radius_0.into(),
@ -289,6 +322,24 @@ impl StyleSheet for crate::Theme {
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::EditableText => Appearance {
background: Color::TRANSPARENT.into(),
border_radius: corner.radius_0.into(),
border_width: 0.0,
border_offset: None,
border_color: Color::TRANSPARENT,
icon_color: container.on.into(),
// TODO use regular text color here after text rendering handles multiple colors
// in this case, for selected and unselected text
text_color: container.on.into(),
placeholder_color: {
let color: Color = container.on.into();
color.blend_alpha(background, 0.7)
},
selected_text_color: palette.on_accent_color().into(),
selected_fill: palette.accent_color().into(),
label_color: label_color.into(),
},
TextInput::Inline => Appearance {
background: Color::from(palette.accent.base).into(),
border_radius: corner.radius_0.into(),

View file

@ -60,6 +60,27 @@ where
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
@ -161,6 +182,8 @@ pub struct TextInput<'a, Message> {
placeholder: Cow<'a, str>,
value: Value,
is_secure: bool,
is_editable: bool,
is_read_only: bool,
font: Option<<crate::Renderer as iced_core::text::Renderer>::Font>,
width: Length,
padding: Padding,
@ -172,6 +195,7 @@ pub struct TextInput<'a, Message> {
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,
@ -201,6 +225,8 @@ where
placeholder: placeholder.into(),
value: Value::new(v.as_ref()),
is_secure: false,
is_editable: false,
is_read_only: false,
font: None,
width: Length::Fill,
padding: [spacing, spacing, spacing, spacing].into(),
@ -210,6 +236,7 @@ where
on_input: None,
on_paste: None,
on_submit: None,
on_toggle_edit: None,
leading_icon: None,
trailing_icon: None,
error: None,
@ -260,6 +287,16 @@ where
self
}
fn editable(mut self) -> Self {
self.is_editable = true;
self
}
fn editing(mut self, enable: bool) -> Self {
self.is_read_only = !enable;
self
}
/// Sets the message that should be produced when some text is typed into
/// the [`TextInput`].
///
@ -285,6 +322,14 @@ where
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 {
@ -458,14 +503,14 @@ where
}
fn state(&self) -> tree::State {
tree::State::new(State::new(self.is_secure))
tree::State::new(State::new(self.is_secure, self.is_read_only))
}
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() {
if self.on_input.is_none() || state.is_read_only {
state.last_click = None;
state.is_focused = None;
state.is_pasting = None;
@ -502,6 +547,10 @@ where
state.dirty = true;
}
if state.is_read_only != self.is_read_only {
self.is_read_only = state.is_read_only;
}
let mut children: Vec<_> = self
.leading_icon
.iter_mut()
@ -664,68 +713,36 @@ where
viewport: &Rectangle,
) -> event::Status {
let text_layout = self.text_layout(layout);
let mut index = 0;
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 let (Some(leading_icon), Some(tree)) =
(self.leading_icon.as_mut(), tree.children.get_mut(index))
{
let mut children = text_layout.children();
children.next();
let leading_icon_layout = children.next().unwrap();
if cursor_position.is_over(leading_icon_layout.bounds())
|| matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
)
if self.is_editable {
let index = tree.children.len() - 1;
if let (Some(trailing_icon), Some(tree)) =
(self.trailing_icon.as_mut(), tree.children.get_mut(index))
{
let res = leading_icon.as_widget_mut().on_event(
tree,
event.clone(),
leading_icon_layout,
cursor_position,
renderer,
clipboard,
shell,
viewport,
);
if res == event::Status::Captured {
return res;
}
}
index += 1;
}
if let (Some(trailing_icon), Some(tree)) =
(self.trailing_icon.as_mut(), tree.children.get_mut(index))
{
let mut children = text_layout.children();
children.next();
if self.leading_icon.is_some() {
children.next();
}
let trailing_icon_layout = children.next().unwrap();
let children = text_layout.children();
trailing_icon_layout = Some(children.last().unwrap());
if cursor_position.is_over(trailing_icon_layout.bounds())
| matches!(
event,
Event::Mouse(mouse::Event::CursorMoved { .. } | mouse::Event::CursorLeft)
)
{
let res = trailing_icon.as_widget_mut().on_event(
tree,
event.clone(),
trailing_icon_layout,
cursor_position,
renderer,
clipboard,
shell,
viewport,
);
if res == event::Status::Captured {
return res;
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;
}
}
}
}
}
@ -733,6 +750,7 @@ where
update(
event,
text_layout.children().next().unwrap(),
trailing_icon_layout,
cursor_position,
clipboard,
shell,
@ -740,9 +758,11 @@ where
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(),
self.dnd_icon,
@ -1093,6 +1113,7 @@ pub fn layout<Message>(
pub fn update<'a, Message>(
event: Event,
text_layout: Layout<'_>,
trailing_icon_layout: Option<Layout<'_>>,
cursor_position: mouse::Cursor,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
@ -1100,9 +1121,11 @@ pub fn update<'a, Message>(
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_icon: bool,
@ -1132,7 +1155,7 @@ where
let state = state();
let is_clicked = cursor_position.is_over(text_layout.bounds()) && on_input.is_some();
state.is_focused = if is_clicked {
state.is_focused = if is_clicked && !state.is_read_only {
state.is_focused.or_else(|| {
let now = Instant::now();
Some(Focus {
@ -1300,6 +1323,47 @@ where
return event::Status::Captured;
}
if is_editable {
if let Some(trailing_layout) = trailing_icon_layout {
let is_trailing_clicked = cursor_position.is_over(trailing_layout.bounds())
&& on_toggle_edit.is_some();
if is_trailing_clicked {
let Some(pos) = cursor_position.position() else {
return event::Status::Ignored;
};
let click = mouse::Click::new(pos, 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();
state.is_focused = Some(Focus {
updated_at: now,
now,
});
state.move_cursor_to_end();
return event::Status::Captured;
}
}
_ => {
state.dragging_state = None;
}
}
}
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
| Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => {
@ -1332,6 +1396,10 @@ where
return event::Status::Ignored;
};
if state.is_read_only {
return event::Status::Ignored;
}
let modifiers = state.keyboard_modifiers;
focus.updated_at = Instant::now();
@ -2263,6 +2331,7 @@ pub struct State {
pub helper_text: crate::Paragraph,
pub dirty: bool,
pub is_secure: bool,
pub is_read_only: bool,
is_focused: Option<Focus>,
dragging_state: Option<DraggingState>,
#[cfg(feature = "wayland")]
@ -2282,9 +2351,10 @@ struct Focus {
impl State {
/// Creates a new [`State`], representing an unfocused [`TextInput`].
pub fn new(is_secure: bool) -> Self {
pub fn new(is_secure: bool, is_read_only: bool) -> Self {
Self {
is_secure,
is_read_only,
..Self::default()
}
}
@ -2314,14 +2384,14 @@ impl State {
}
/// Creates a new [`State`], representing a focused [`TextInput`].
pub fn focused(is_secure: bool) -> Self {
pub fn focused(is_secure: bool, is_read_only: bool) -> Self {
Self {
is_secure,
value: crate::Paragraph::new(),
placeholder: crate::Paragraph::new(),
label: crate::Paragraph::new(),
helper_text: crate::Paragraph::new(),
is_read_only,
is_focused: None,
dragging_state: None,
#[cfg(feature = "wayland")]