I looked at that example in detail and built up my implementation from scratch - I think I had originally missed a trait bound/generic somewhere and there were too many things going on at once for me to isolate it.
Anyway, it turned out to be quite involved and very gnarly in places.
use std::{cell::RefCell, cmp::Ordering};
use iced::{
Alignment, Element, Event, Font, Length, Padding, Rectangle, Size,
advanced::{
Layout, Shell, Widget,
layout::{Limits, Node},
mouse, renderer, text as advanced_text,
widget::{self, Tree, tree::Tag},
},
keyboard::{self, Key, key::Named},
mouse::ScrollDelta,
widget::{
Button, TextInput, button, text,
text_input::{self, Value},
},
};
use rust_decimal::Decimal;
#[derive(Debug, Clone)]
enum TextEvent {
TextChanged(String),
}
#[derive(Debug, Clone)]
enum ButtonEvent {
IncrementButtonPressed,
DecrementButtonPressed,
}
pub struct DecimalInput2<'a, Decimal, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Decimal: NumberInput,
Theme: Catalog,
Renderer: advanced_text::Renderer,
{
content: &'a Content<Decimal>,
text_input: TextInput<'a, TextEvent, Theme, Renderer>,
increment_button: Button<'a, ButtonEvent, Theme, Renderer>,
decrement_button: Button<'a, ButtonEvent, Theme, Renderer>,
value: text_input::Value,
on_change: Box<dyn Fn(Decimal) -> Message>,
}
impl<'a, Decimal, Message, Theme, Renderer> DecimalInput2<'a, Decimal, Message, Theme, Renderer>
where
Decimal: NumberInput,
Theme: Catalog + widget::text::Catalog + 'a,
Renderer: advanced_text::Renderer + 'a,
<Renderer as iced::advanced::text::Renderer>::Font: From<iced::Font>,
{
pub fn new(
content: &'a Content<Decimal>,
value: &Decimal,
on_change: impl Fn(Decimal) -> Message + 'static,
) -> Self {
let text_input = TextInput::new("", &content.value())
.on_input(TextEvent::TextChanged)
.width(Length::Fill)
.class(Theme::default_input());
let increment_button = button(
text("\u{25B4}")
.font(Font::with_name("icons"))
.size(15)
.center(),
)
.on_press_maybe(
(<Decimal as NumberInput>::can_increment(value))
.then_some(ButtonEvent::IncrementButtonPressed),
)
.padding(Padding::ZERO.left(3).right(3))
.class(Theme::default_button());
let decrement_button = button(
text("\u{25BE}")
.font(Font::with_name("icons"))
.size(15)
.center(),
)
.on_press_maybe(
(<Decimal as NumberInput>::can_decrement(value))
.then_some(ButtonEvent::DecrementButtonPressed),
)
.padding(Padding::ZERO.left(3).right(3))
.class(Theme::default_button());
let value = value.to_string();
Self {
content,
text_input,
increment_button,
decrement_button,
value: text_input::Value::new(&value),
on_change: Box::new(on_change),
}
}
/// Sets the style of the input of the [`DecimalInput2`].
#[must_use]
pub fn input_style(
mut self,
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
) -> Self
where
<Theme as text_input::Catalog>::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
{
self.text_input = self.text_input.style(style);
self
}
#[must_use]
pub fn input_class(
mut self,
class: impl Into<<Theme as text_input::Catalog>::Class<'a>>,
) -> Self {
self.text_input = self.text_input.class(class);
self
}
/// Sets the style of the input of the [`DecimalInput2`].
#[must_use]
pub fn button_style(
mut self,
increment_style: impl Fn(&Theme, button::Status) -> button::Style + 'a,
decrement_style: impl Fn(&Theme, button::Status) -> button::Style + 'a,
) -> Self
where
<Theme as button::Catalog>::Class<'a>: From<button::StyleFn<'a, Theme>>,
{
self.increment_button = self.increment_button.style(increment_style);
self.decrement_button = self.decrement_button.style(decrement_style);
self
}
#[must_use]
pub fn button_class(
mut self,
incremenet_class: impl Into<<Theme as button::Catalog>::Class<'a>>,
decremenet_class: impl Into<<Theme as button::Catalog>::Class<'a>>,
) -> Self {
self.increment_button = self.increment_button.class(incremenet_class);
self.decrement_button = self.decrement_button.class(decremenet_class);
self
}
fn do_increment(&self, shell: &mut Shell<'_, Message>, step_size: &StepSize) {
self.content.with_inner_mut(|state| {
let current_value = &state.last_valid_value;
let new_value = Decimal::increment(current_value, step_size);
state.last_valid_value = new_value.clone();
state.value = state.last_valid_value.to_string();
shell.publish((self.on_change)(new_value));
});
}
fn do_decrement(&self, shell: &mut Shell<'_, Message>, step_size: &StepSize) {
self.content.with_inner_mut(|state| {
let current_value = &state.last_valid_value;
let new_value = Decimal::decrement(current_value, step_size);
state.last_valid_value = new_value.clone();
state.value = state.last_valid_value.to_string();
shell.publish((self.on_change)(new_value));
});
}
}
impl<'a, Decimal, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for DecimalInput2<'a, Decimal, Message, Theme, Renderer>
where
Decimal: NumberInput,
Message: Clone + 'a,
Theme: Catalog + button::Catalog + widget::text::Catalog + 'a,
Renderer: advanced_text::Renderer + 'a,
<Renderer as advanced_text::Renderer>::Font: From<Font>,
{
fn size(&self) -> Size<Length> {
Widget::<TextEvent, Theme, Renderer>::size(&self.text_input)
}
fn tag(&self) -> Tag {
Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<Tree> {
vec![
Tree::new(&self.text_input as &dyn Widget<_, _, _>),
Tree::new(&self.increment_button as &dyn Widget<_, _, _>),
Tree::new(&self.decrement_button as &dyn Widget<_, _, _>),
]
}
fn diff(&self, _tree: &mut Tree) {
// do nothing so the children don't get cleared
}
fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node {
let is_focused = {
let text_input_state = tree.children[0]
.state
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
text_input_state.is_focused()
};
let text_input_node = self.text_input.layout(
&mut tree.children[0],
renderer,
limits,
(!is_focused).then_some(&self.value),
);
// if we want to add spacing, we might need to shrink the font size, lest the buttons shrink to nothing
let button_spacing = 0.0;
let limits = limits.height((text_input_node.size().height / 2.0) - button_spacing / 2.0);
let increment_button_node = self
.increment_button
.layout(&mut tree.children[1], renderer, &limits)
.align(Alignment::End, Alignment::Start, text_input_node.size());
let decrement_button_node = self
.decrement_button
.layout(&mut tree.children[2], renderer, &limits)
.align(Alignment::End, Alignment::End, text_input_node.size());
Node::with_children(
text_input_node.size(),
vec![
text_input_node,
increment_button_node,
decrement_button_node,
],
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &iced::Rectangle,
) {
self.text_input.draw(
&tree.children[0],
renderer,
theme,
layout.children().nth(0).unwrap(),
cursor,
None,
viewport,
);
self.increment_button.draw(
&tree.children[1],
renderer,
theme,
style,
layout.children().nth(1).unwrap(),
cursor,
viewport,
);
self.decrement_button.draw(
&tree.children[2],
renderer,
theme,
style,
layout.children().nth(2).unwrap(),
cursor,
viewport,
);
}
fn update(
&mut self,
tree: &mut Tree,
event: &iced::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn iced::advanced::Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
if let &Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.keyboard_modifiers = modifiers;
}
// Handle unfocus events; return early if so
let now_focused = {
let text_input_state = tree.children[0]
.state
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
text_input_state.is_focused()
};
let was_focused = state.is_focused;
state.is_focused = now_focused;
if !now_focused && was_focused {
let current_text = self.content.value();
let new = Decimal::resolve(¤t_text);
self.content.with_inner_mut(|state| {
state.last_valid_value = new.clone();
state.value = state.last_valid_value.to_string();
});
shell.publish((self.on_change)(new));
return;
}
// Handle the buttons being clicked; return early if so
// Create a new list of local messages
let mut local_messages = Vec::new();
let mut local_shell = Shell::new(&mut local_messages);
// Provide it to the widget
self.increment_button.update(
&mut tree.children[1],
event,
layout.children().nth(1).unwrap(),
cursor,
renderer,
clipboard,
&mut local_shell,
viewport,
);
self.decrement_button.update(
&mut tree.children[2],
event,
layout.children().nth(2).unwrap(),
cursor,
renderer,
clipboard,
&mut local_shell,
viewport,
);
if local_shell.is_event_captured() {
shell.capture_event();
shell.request_redraw_at(local_shell.redraw_request());
for m in local_messages {
match m {
ButtonEvent::IncrementButtonPressed => {
self.do_increment(shell, &StepSize::Normal);
}
ButtonEvent::DecrementButtonPressed => {
self.do_decrement(shell, &StepSize::Normal);
}
}
}
let text_input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
text_input_state.unfocus();
state.is_focused = false;
return;
}
// Handle the scroll wheel input next; return early if so
if now_focused || cursor.is_over(layout.bounds()) {
if let &Event::Mouse(mouse::Event::WheelScrolled { delta }) = event {
let step_size = match state.keyboard_modifiers.command() {
true => StepSize::Large,
false => StepSize::Normal,
};
match delta {
ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } => {
if y.is_sign_positive() {
self.do_increment(shell, &step_size);
shell.capture_event();
} else {
self.do_decrement(shell, &step_size);
shell.capture_event();
}
}
}
return;
}
}
// Handle Arrow{Up|Down} and Page{Up|Down} and Enter; return early if so
if now_focused {
if let &Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
text,
..
}) = &event
{
match (named, text) {
(keyboard::key::Named::ArrowUp, None) => {
return self.do_increment(shell, &StepSize::Normal);
}
(keyboard::key::Named::PageUp, None) => {
return self.do_increment(shell, &StepSize::Large);
}
(keyboard::key::Named::ArrowDown, None) => {
return self.do_decrement(shell, &StepSize::Normal);
}
(keyboard::key::Named::PageDown, None) => {
return self.do_decrement(shell, &StepSize::Large);
}
(keyboard::key::Named::Enter, _) => {
let text_input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
// Convert a submit event into an unfocus event
return text_input_state.unfocus();
}
_ => (),
};
}
}
let event = event.clone();
let event = match event {
Event::Keyboard(ref kb_event) => match kb_event {
keyboard::Event::KeyPressed {
modified_key,
physical_key,
location,
modifiers,
text: Some(text),
..
} if text.chars().any(|c| {
c.is_ascii_digit() || c == '.' || c == '-' || c == '\u{7f}' || c == '\u{8}'
}) =>
{
let text_input_state = tree.children[0]
.state
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
let cursor = text_input_state.cursor();
let new_char = text.chars().next().unwrap();
let is_backspace = new_char == '\u{8}';
let is_delete = new_char == '\u{7f}';
let new_char = text
.chars()
.find(|c| c.is_ascii_digit() || *c == '.' || *c == '-');
let current_content = self.content.value();
let mut next_content = current_content.clone();
let current_cursor_idx = match cursor.state(&Value::new(¤t_content)) {
text_input::cursor::State::Index(idx) => {
if is_backspace && idx > 0 {
next_content.remove(idx - 1);
} else if is_delete && idx < current_content.len() {
next_content.remove(idx);
} else if let Some(c) = new_char {
next_content.insert(idx, c);
}
idx
}
text_input::cursor::State::Selection { start, end } => {
let (start, end) = if start > end {
(end, start)
} else {
(start, end)
};
next_content.drain(start..end);
if let Some(c) = new_char {
next_content.insert(start, c);
}
start
}
};
let next_content = next_content;
let next_content_valid = Decimal::maybe_valid(&next_content);
if next_content_valid {
Some(event)
} else if new_char == Some('.') {
if Some(current_cursor_idx) == current_content.find('.')
&& Decimal::precision() > 0
{
let e = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
modified_key: modified_key.clone(),
physical_key: *physical_key,
location: *location,
modifiers: *modifiers,
text: None,
});
Some(e)
} else {
None
}
} else if new_char == Some('-') {
if current_cursor_idx == 0 && current_content.contains('-') {
let e = Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
modified_key: modified_key.clone(),
physical_key: *physical_key,
location: *location,
modifiers: *modifiers,
text: None,
});
Some(e)
} else {
None
}
} else {
None
}
}
keyboard::Event::KeyPressed {
key: Key::Named(named),
text: None,
..
} => match named {
Named::ArrowLeft | Named::ArrowRight | Named::Home | Named::End => Some(event),
_ => None,
},
keyboard::Event::KeyPressed { .. } => None,
_ => Some(event),
},
_ => Some(event),
};
if let Some(event) = event {
// Create a new list of local messages
let mut local_messages = Vec::new();
let mut local_shell = Shell::new(&mut local_messages);
// Provide it to the widget
self.text_input.update(
&mut tree.children[0],
&event,
layout,
cursor,
renderer,
clipboard,
&mut local_shell,
viewport,
);
if local_shell.is_event_captured() {
shell.capture_event();
}
shell.request_redraw_at(local_shell.redraw_request());
shell.request_input_method(local_shell.input_method());
for m in local_messages {
match m {
TextEvent::TextChanged(txt) => {
self.content.with_inner_mut(|state| {
state.value = txt;
});
shell.invalidate_widgets();
}
}
}
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
let incr_button = layout.children().nth(1).unwrap().bounds();
if cursor.is_over(incr_button) {
return self.increment_button.mouse_interaction(
&tree.children[1],
layout,
cursor,
viewport,
renderer,
);
}
let decr_button = layout.children().nth(2).unwrap().bounds();
if cursor.is_over(decr_button) {
return self.increment_button.mouse_interaction(
&tree.children[2],
layout,
cursor,
viewport,
renderer,
);
}
self.text_input
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
}
#[derive(Debug, Clone, Copy, Default)]
struct State {
is_focused: bool,
keyboard_modifiers: keyboard::Modifiers,
}
pub trait NumberInput: Clone {
const DEFAULT: Decimal;
const MAX: Decimal;
const MIN: Decimal;
fn as_decimal(&self) -> &Decimal;
fn from_decimal(decimal: Decimal) -> Self;
fn maybe_valid(string: &str) -> bool {
if let Some(idx) = string.find('-') {
if Self::MIN.is_sign_positive()
|| idx != 0
|| string.chars().filter(|c| *c == '-').count() > 1
{
return false;
}
}
let n_dots = string.chars().filter(|c| *c == '.').count();
if n_dots > 1 || (Self::precision() == 0 && n_dots > 0) {
return false;
}
let mantissa = match string.split_once('.') {
Some((integer, fraction)) => {
if fraction.len() > Self::precision() as usize {
return false;
}
integer
.chars()
.chain(fraction.chars().chain(std::iter::repeat_n(
'0',
Self::precision() as usize - fraction.len(),
)))
.collect::<String>()
}
None => if string.is_empty() || string == "-" {
"0".to_string()
} else {
string.to_string()
}
.chars()
.chain(std::iter::repeat_n('0', Self::precision() as usize))
.collect::<String>(),
};
dbg!(&mantissa);
if let Ok(mantissa) = mantissa.parse() {
let decimal = Decimal::new(mantissa, Self::precision());
match decimal.is_sign_positive() {
true => decimal <= Self::MAX,
false => decimal >= Self::MIN,
}
} else {
false
}
}
fn resolve(value: &str) -> Self {
let value = value.trim();
if value.is_empty() {
return Self::from_decimal(Self::DEFAULT);
}
let (integer, fraction) = match value.split_once('.') {
Some((integer, fraction)) => {
let integer = if integer.is_empty() {
"0".to_string()
} else if integer == "-" {
"-0".to_string()
} else {
integer.to_string()
};
let fraction = match fraction.len().cmp(&(Self::precision() as usize)) {
Ordering::Equal => fraction.to_string(),
Ordering::Less => fraction
.chars()
.chain(std::iter::repeat_n(
'0',
Self::precision() as usize - fraction.len(),
))
.collect::<String>(),
Ordering::Greater => fraction
.chars()
.take(Self::precision() as usize)
.collect::<String>(),
};
(integer, fraction)
}
None => {
let fraction =
std::iter::repeat_n('0', Self::precision() as usize).collect::<String>();
let integer = if fraction.is_empty() && (value.is_empty() || value == "-") {
"0".to_string()
} else {
value.to_string()
};
(integer, fraction)
}
};
let decimal = match format!("{integer}{fraction}").parse::<i64>() {
Ok(mantissa) => {
let decimal = Decimal::new(mantissa, Self::precision());
if decimal > Self::MAX {
Self::MAX
} else if decimal < Self::MIN {
Self::MIN
} else {
decimal
}
}
Err(_) => Self::DEFAULT,
};
Self::from_decimal(decimal)
}
fn precision() -> u32 {
Self::DEFAULT.scale()
}
fn to_string(&self) -> String {
self.as_decimal().to_string()
}
fn can_increment(&self) -> bool {
self.as_decimal() < &Self::MAX
}
fn can_decrement(&self) -> bool {
self.as_decimal() > &Self::MIN
}
fn step(step_size: &StepSize) -> Decimal {
match step_size {
StepSize::Normal => Decimal::new(1, Self::precision()),
StepSize::Large => Decimal::new(10, Self::precision()),
}
}
fn increment(&self, step_size: &StepSize) -> Self {
let new = self.as_decimal() + Self::step(step_size);
Self::from_decimal(if new > Self::MAX { Self::MAX } else { new })
}
fn decrement(&self, step_size: &StepSize) -> Self {
let new = self.as_decimal() - Self::step(step_size);
Self::from_decimal(if new < Self::MIN { Self::MIN } else { new })
}
}
pub enum StepSize {
Normal,
Large,
}
/// The local state of a [`DecimalInput2`].
#[derive(Debug, Clone)]
pub struct Content<Decimal>
where
Decimal: NumberInput,
{
inner: RefCell<Inner<Decimal>>,
}
#[derive(Debug, Clone)]
struct Inner<Decimal>
where
Decimal: NumberInput,
{
last_valid_value: Decimal,
value: String,
}
impl<Decimal> Content<Decimal>
where
Decimal: NumberInput,
{
pub fn new(value: Decimal) -> Self {
let scale = <Decimal as NumberInput>::precision();
assert_eq!(<Decimal as NumberInput>::MIN.scale(), scale);
assert_eq!(<Decimal as NumberInput>::MAX.scale(), scale);
assert!(
(<Decimal as NumberInput>::MIN..=<Decimal as NumberInput>::MAX)
.contains(&<Decimal as NumberInput>::DEFAULT)
);
assert!(
(<Decimal as NumberInput>::MIN..=<Decimal as NumberInput>::MAX)
.contains(value.as_decimal())
);
let inner_value = value.to_string();
Self {
inner: RefCell::new(Inner {
value: inner_value,
last_valid_value: value,
}),
}
}
fn value(&self) -> String {
let inner = self.inner.borrow();
inner.value.clone()
}
fn with_inner_mut(&self, f: impl FnOnce(&mut Inner<Decimal>)) {
let mut inner = self.inner.borrow_mut();
f(&mut inner);
}
}
/// The theme catalog of a [`DecimalInput2`].
pub trait Catalog: text_input::Catalog + button::Catalog {
/// The default class for the text input of the [`DecimalInput2`].
fn default_input<'a>() -> <Self as text_input::Catalog>::Class<'a> {
<Self as text_input::Catalog>::default()
}
/// The default class for the menu of the [`DecimalInput2`].
fn default_button<'a>() -> <Self as button::Catalog>::Class<'a> {
<Self as button::Catalog>::default()
}
}
impl Catalog for iced::Theme {}
impl<'a, T, Message, Theme, Renderer> From<DecimalInput2<'a, T, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
T: NumberInput + 'static,
Message: Clone + 'a,
Theme: Catalog + button::Catalog + widget::text::Catalog + 'a,
Renderer: advanced_text::Renderer + 'a,
<Renderer as advanced_text::Renderer>::Font: From<Font>,
{
fn from(decimal_input: DecimalInput2<'a, T, Message, Theme, Renderer>) -> Self {
Self::new(decimal_input)
}
}
A couple of bits and pieces there are specific for my usecase (the codepoint for the up/down arrow icons, the lack of builder methods on the main struct, etc). Thereās also one or two extraneous bits left in which need factoring out when I next look at it.
It uses rust_decimal::Decimal
internally, and for a user to actually use the widget, they need to define a newtype which wraps Decimal
and impl
s NumberInput
(terrible name). The NumberInput
trait requires the definition of a min, max and default value, as well as a way to produce the newtype from a Decimal
and convert their newtype to a Decimal
.
@airstrike I felt that given how much custom handling I need in terms of validating partial inputs, controlling focus of the text_input, etc, it would be better as a Widget
, although I agree that most (all?) could be done as a āComponentā pattern as per the unofficial guide(s).
Edit: just to add, I know that thereās a lot of scope to refactor pretty much all of this. In particular, I think if let chains
would be really beneficial once it hits stable as the nesting gets pretty intense. Breaking out a lot of the logic into standalone functions would make handling control flow/returning early much clearer, too.