use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FloatingLabelBehavior {
#[default]
Auto,
Always,
Never,
}
impl FloatingLabelBehavior {
pub fn to_token(self) -> &'static str {
match self {
FloatingLabelBehavior::Auto => "auto",
FloatingLabelBehavior::Always => "always",
FloatingLabelBehavior::Never => "never",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"auto" => Some(FloatingLabelBehavior::Auto),
"always" => Some(FloatingLabelBehavior::Always),
"never" => Some(FloatingLabelBehavior::Never),
_ => None,
}
}
}
pub struct FloatingLabel {
base: BaseWidget,
text: String,
label: String,
placeholder: String,
is_focused: bool,
show_label_above: bool,
behavior: FloatingLabelBehavior,
travel: crate::style::PropertyDriver,
pub text_changed: Signal1<String>,
}
impl FloatingLabel {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::FloatingLabel, geometry, "FloatingLabel"),
text: String::new(),
label: String::new(),
placeholder: String::new(),
is_focused: false,
show_label_above: false,
behavior: FloatingLabelBehavior::Auto,
travel: crate::style::PropertyDriver::at(0.0, crate::style::MotionSlot::Fast),
text_changed: Signal1::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: String) {
self.text = text;
self.update_label_state();
self.text_changed.emit(self.text.clone());
self.base.request_redraw();
}
pub fn label(&self) -> &str {
&self.label
}
pub fn set_label(&mut self, label: String) {
self.label = label;
self.update_label_state();
self.base.request_redraw();
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: String) {
self.placeholder = placeholder;
self.base.request_redraw();
}
pub fn is_focused(&self) -> bool {
self.is_focused
}
pub fn show_label_above(&self) -> bool {
self.show_label_above
}
pub fn behavior(&self) -> FloatingLabelBehavior {
self.behavior
}
pub fn set_behavior(&mut self, behavior: FloatingLabelBehavior) {
if self.behavior != behavior {
self.behavior = behavior;
self.update_label_state();
self.base.request_redraw();
}
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn set_focused(&mut self, focused: bool) {
if self.is_focused != focused {
self.is_focused = focused;
self.update_label_state();
self.base.request_redraw();
}
}
fn update_label_state(&mut self) {
let should_float = match self.behavior {
FloatingLabelBehavior::Always => true,
FloatingLabelBehavior::Never => false,
FloatingLabelBehavior::Auto => self.is_focused || !self.text.is_empty(),
};
if should_float != self.show_label_above {
self.show_label_above = should_float;
self.travel.set_target(if should_float { 1.0 } else { 0.0 });
}
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
if !self.travel.tick(delta_ms) {
return false;
}
self.base.request_redraw();
true
}
pub fn animation_progress(&self) -> f32 {
self.travel.value()
}
fn field_background_color(&self) -> Color {
self.base
.style()
.background_color
.or_else(|| {
crate::style::resolved_theme_style("floating_label")
.and_then(|t| t.background_color)
})
.or_else(|| {
crate::style::resolved_theme_style("line_edit")
.and_then(|input| input.background_color)
})
.unwrap_or(Color::rgba(255, 255, 255, 255))
}
fn label_color(&self, ink: Color, field_background: Color, is_enabled: bool) -> Color {
if self.is_focused {
ink
} else if is_enabled {
ink.blend(&field_background, 0.3)
} else {
Color::rgba(180, 180, 180, 255)
}
}
fn draw_label(&self, context: &mut RenderContext, rect: Rect, ink: Color) {
if self.label.is_empty() {
return;
}
let field_background = self.field_background_color();
let is_enabled = self.base.is_enabled();
let input_font = Font::simple("sans-serif", INPUT_FONT_SIZE);
let label_font = Font::simple("sans-serif", LABEL_FONT_SIZE);
let label_x = rect.x + LABEL_PADDING;
let inline_band = Rect::new(rect.x, rect.y + INLINE_BAND_TOP, 1, FIELD_LINE_HEIGHT);
let inline_line = context.text_line(inline_band, &input_font);
let floating_band = Rect::new(rect.x, rect.y + LABEL_TOP_MARGIN, 1, LABEL_LINE_HEIGHT);
let floating_line = context.text_line(floating_band, &label_font);
if self.show_label_above || self.travel.value() > 0.0 {
let font = if self.travel.value() >= 1.0 { &label_font } else { &input_font };
let float_y = inline_line.y
+ ((floating_line.y - inline_line.y) as f32 * self.travel.value()) as i32;
context.draw_text(
Point::new(label_x, float_y),
&self.label,
font,
self.label_color(ink, field_background, is_enabled),
HorizontalAlignment::Left,
);
} else {
let inline_ink = if is_enabled {
ink.blend(&field_background, 0.45)
} else {
Color::rgba(180, 180, 180, 255)
};
context.draw_text(
Point::new(label_x, inline_line.y),
&self.label,
&input_font,
inline_ink,
HorizontalAlignment::Left,
);
}
}
}
impl Widget for FloatingLabel {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(200, 40)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn tick(&mut self, delta_ms: u32) -> bool {
FloatingLabel::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
self.travel.is_moving()
}
}
impl WidgetProperties for FloatingLabel {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"label" => Ok(CapabilityValue::String(self.label().to_string())),
"placeholder" => Ok(CapabilityValue::String(self.placeholder().to_string())),
"focused" => Ok(CapabilityValue::Bool(self.is_focused())),
"floating_label_behavior" => {
Ok(CapabilityValue::String(self.behavior().to_token().to_string()))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(expect_string(value)?);
Ok(())
}
"label" => {
self.set_label(expect_string(value)?);
Ok(())
}
"placeholder" => {
self.set_placeholder(expect_string(value)?);
Ok(())
}
"focused" => {
self.set_focused(expect_bool(value)?);
Ok(())
}
"floating_label_behavior" => {
let token = expect_string(value)?;
let behavior = FloatingLabelBehavior::from_token(&token)
.ok_or(CapabilityAccessError::OutOfRange)?;
self.set_behavior(behavior);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"text",
"label",
"placeholder",
"focused",
"floating_label_behavior",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for FloatingLabel {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let is_enabled = self.base.is_enabled();
let style = self.base.style().clone();
let field_background = self.field_background_color();
let ink = style
.text_color
.or_else(|| {
crate::style::resolved_theme_style("floating_label").and_then(|t| t.text_color)
})
.unwrap_or(Color::BLACK);
let border_color = style
.border_color
.or_else(|| {
crate::style::resolved_theme_style("floating_label").and_then(|t| t.border_color)
})
.or_else(|| {
crate::style::resolved_theme_style("floating_label").and_then(|t| t.text_color)
})
.unwrap_or_else(|| ink.blend(&field_background, 0.55));
let bg_color = if is_enabled { field_background } else { Color::rgba(240, 240, 240, 255) };
context.fill_rounded_rect(rect, 4, bg_color);
let underline_color = if self.is_focused { ink } else { border_color };
let underline_y = rect.y + rect.height as i32 - 2;
let underline_rect = Rect::new(rect.x + 2, underline_y, rect.width.saturating_sub(4), 2);
context.fill_rounded_rect(underline_rect, 1, underline_color);
let has_label = !self.label.is_empty();
let floats = has_label && self.show_label_above;
let input_font = Font::simple("sans-serif", INPUT_FONT_SIZE);
let input_band = Rect::new(
rect.x,
rect.y + if floats { FLOATED_BAND_TOP } else { INLINE_BAND_TOP },
1,
if floats { INLINE_LINE_HEIGHT } else { FIELD_LINE_HEIGHT },
);
let input_line = context.text_line(input_band, &input_font);
self.draw_label(context, rect, ink);
let show_placeholder =
self.text.is_empty() && !self.is_focused && (!has_label || self.show_label_above);
if show_placeholder && !self.placeholder.is_empty() {
context.draw_text(
Point::new(rect.x + LABEL_PADDING, input_line.y),
&self.placeholder,
&input_font,
ink.blend(&field_background, 0.55),
HorizontalAlignment::Left,
);
}
if !self.text.is_empty() {
let text_color = if is_enabled { ink } else { Color::rgba(160, 160, 160, 255) };
context.draw_text(
Point::new(rect.x + LABEL_PADDING, input_line.y),
&self.text,
&input_font,
text_color,
HorizontalAlignment::Left,
);
}
}
}
const LABEL_PADDING: i32 = 8;
const LABEL_FONT_SIZE: f32 = 11.0;
const INPUT_FONT_SIZE: f32 = 14.0;
const LABEL_TOP_MARGIN: i32 = 4;
const LABEL_LINE_HEIGHT: u32 = 12;
const INLINE_BAND_TOP: i32 = 6;
const FIELD_LINE_HEIGHT: u32 = 28;
const FLOATED_BAND_TOP: i32 = 20;
const INLINE_LINE_HEIGHT: u32 = 16;
const KEYCODE_TAB: u32 = 9;
const KEYCODE_ENTER: u32 = 13;
const KEYCODE_BACKSPACE: u32 = 8;
impl EventHandler for FloatingLabel {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } if *button == 1 => {
let rect = self.geometry();
if rect.contains_point(*pos) {
self.set_focused(true);
}
}
Event::KeyPress { key, modifiers: _ } => {
if *key == KEYCODE_TAB {
self.set_focused(false);
} else if *key == KEYCODE_ENTER {
self.set_focused(false);
} else if *key >= 32 && *key <= 126 {
let c = char::from_u32(*key).unwrap_or(' ');
if self.is_focused {
let mut new_text = self.text.clone();
new_text.push(c);
self.set_text(new_text);
}
} else if *key == KEYCODE_BACKSPACE {
if self.is_focused && !self.text.is_empty() {
let mut new_text = self.text.clone();
new_text.pop();
self.set_text(new_text);
}
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{Arc, Mutex};
#[cfg(not(alloc_frugal))]
fn ink_runs(svg: &str) -> Vec<(i32, i32, i32, i32)> {
let mut runs = Vec::new();
for line in svg.lines() {
let Some(path_at) = line.find("<path ") else { continue };
let Some(d_at) = line[path_at..].find("d=\"") else { continue };
let start = path_at + d_at + 3;
let Some(end) = line[start..].find('"') else { continue };
let mut bounds: Option<(i32, i32, i32, i32)> = None;
for subpath in line[start..start + end].split('M').skip(1) {
let numbers: Vec<i32> = subpath
.split(|c: char| !c.is_ascii_digit() && c != '-')
.filter(|part| !part.is_empty())
.filter_map(|part| part.parse().ok())
.collect();
if numbers.len() < 4 {
continue;
}
let (x, y, w, h) = (numbers[0], numbers[1], numbers[2], numbers[3]);
bounds = Some(match bounds {
None => (x, y, x + w, y + h),
Some((left, top, right, bottom)) => {
(left.min(x), top.min(y), right.max(x + w), bottom.max(y + h))
}
});
}
if let Some(bounds) = bounds {
runs.push(bounds);
}
}
runs
}
#[test]
fn floating_label_default_creation() {
let fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
assert_eq!(fl.kind(), WidgetKind::FloatingLabel);
assert!(fl.text().is_empty());
assert!(fl.label().is_empty());
assert!(fl.placeholder().is_empty());
assert!(!fl.is_focused());
assert!(fl.is_empty());
}
#[test]
fn floating_label_set_text_and_label() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Username".to_string());
assert_eq!(fl.label(), "Username");
fl.set_text("hello".to_string());
assert_eq!(fl.text(), "hello");
assert!(!fl.is_empty());
assert!(fl.show_label_above);
assert_eq!(fl.animation_progress(), 0.0);
assert!(fl.tick(16));
assert!(fl.animation_progress() > 0.0);
}
#[test]
fn floating_label_animation_reaches_target() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Email".to_string());
fl.set_focused(true);
assert!(fl.show_label_above);
assert!(!fl.tick(1000), "one long frame both arrives and reports settled");
assert_eq!(fl.animation_progress(), 1.0);
assert!(!fl.tick(1000));
fl.set_focused(false);
assert!(!fl.show_label_above);
assert!(!fl.tick(1000));
assert_eq!(fl.animation_progress(), 0.0);
fl.set_focused(true);
assert!(fl.tick(1), "one millisecond cannot cross the travel");
assert!(fl.animation_progress() > 0.0 && fl.animation_progress() < 1.0);
}
#[test]
fn floating_label_focus_toggle() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Email".to_string());
assert!(!fl.is_focused());
assert!(!fl.show_label_above);
fl.set_focused(true);
assert!(fl.is_focused());
assert!(fl.show_label_above);
fl.set_focused(false);
assert!(!fl.is_focused());
assert!(!fl.show_label_above);
}
#[test]
fn floating_label_text_changed_signal() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
let captured = Arc::new(Mutex::new(None::<String>));
fl.text_changed.connect({
let captured = Arc::clone(&captured);
move |val: Arc<String>| {
*captured.lock().unwrap() = Some(val.to_string());
}
});
fl.set_text("World".to_string());
assert_eq!(captured.lock().unwrap().as_deref(), Some("World"));
}
#[test]
fn floating_label_placeholder() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_placeholder("Enter text here...".to_string());
assert_eq!(fl.placeholder(), "Enter text here...");
}
#[test]
fn floating_label_focus_on_click() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
assert!(!fl.is_focused());
fl.handle_event(&Event::MousePress { pos: Point::new(10, 10), button: 1 });
assert!(fl.is_focused());
}
#[test]
fn floating_label_keyboard_input() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_focused(true);
fl.handle_event(&Event::KeyPress { key: 65, modifiers: 0 });
assert_eq!(fl.text(), "A");
fl.handle_event(&Event::KeyPress { key: 66, modifiers: 0 });
assert_eq!(fl.text(), "AB");
fl.handle_event(&Event::KeyPress { key: 8, modifiers: 0 });
assert_eq!(fl.text(), "A");
}
#[test]
fn floating_label_svg_output() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Name".to_string());
fl.set_placeholder("Enter name".to_string());
fl.set_text("John".to_string());
let svg = render_to_svg(&mut fl);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn floating_label_publishes_its_label_as_a_property() {
use crate::widget::capability::properties_trait::{
widget_property_get, widget_property_set,
};
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
assert!(fl.property_names().contains(&"label"));
assert!(fl.property_names().contains(&"floating_label_behavior"));
widget_property_set(&mut fl, "label", CapabilityValue::String("Username".into()))
.expect("`label` must be writable");
assert_eq!(fl.label(), "Username");
assert_eq!(
widget_property_get(&fl, "label"),
Ok(CapabilityValue::String("Username".to_string()))
);
assert!(fl.text().is_empty());
}
#[test]
fn floating_label_behavior_round_trips_as_tokens() {
use crate::widget::capability::properties_trait::{
widget_property_get, widget_property_set,
};
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
for behavior in [
FloatingLabelBehavior::Auto,
FloatingLabelBehavior::Always,
FloatingLabelBehavior::Never,
] {
let token = behavior.to_token();
widget_property_set(
&mut fl,
"floating_label_behavior",
CapabilityValue::String(token.to_string()),
)
.unwrap_or_else(|error| panic!("token {token:?} must be accepted, got {error:?}"));
assert_eq!(fl.behavior(), behavior);
assert_eq!(
widget_property_get(&fl, "floating_label_behavior"),
Ok(CapabilityValue::String(token.to_string()))
);
}
}
#[test]
fn floating_label_behavior_rejects_an_unknown_token() {
use crate::widget::capability::properties_trait::widget_property_set;
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
assert!(widget_property_set(
&mut fl,
"floating_label_behavior",
CapabilityValue::String("Always".to_string())
)
.is_err());
assert_eq!(fl.behavior(), FloatingLabelBehavior::Auto);
}
#[test]
fn floating_label_always_floats_even_when_empty_and_unfocused() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Email".to_string());
fl.set_behavior(FloatingLabelBehavior::Always);
assert!(!fl.is_focused());
assert!(fl.text().is_empty());
assert!(fl.show_label_above);
assert!(!fl.tick(1000), "a long frame completes the travel in one step");
assert_eq!(fl.animation_progress(), 1.0);
}
#[test]
fn floating_label_never_floats_even_when_focused_with_text() {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Email".to_string());
fl.set_behavior(FloatingLabelBehavior::Never);
fl.set_text("not-an-email".to_string());
fl.set_focused(true);
assert!(fl.is_focused());
assert!(!fl.text().is_empty());
assert!(!fl.show_label_above);
assert!(!fl.tick(1000));
assert_eq!(fl.animation_progress(), 0.0);
}
#[cfg(not(alloc_frugal))]
#[test]
fn floating_label_behavior_changes_the_drawn_label_position() {
let _theme_guard = crate::style::theme_test_guard();
fn label_ink(behavior: FloatingLabelBehavior, focused: bool) -> (i32, i32, i32, i32) {
let mut fl = FloatingLabel::new(Rect::new(0, 0, 200, 50));
fl.set_label("Email".to_string());
fl.set_behavior(behavior);
fl.set_focused(focused);
fl.tick(1000);
let svg = render_to_svg(&mut fl);
let mut runs = ink_runs(&svg);
assert!(!runs.is_empty(), "the label must be drawn as ink: {svg}");
runs.sort_by_key(|run| run.1);
runs[0]
}
let auto_unfocused = label_ink(FloatingLabelBehavior::Auto, false);
let auto_focused = label_ink(FloatingLabelBehavior::Auto, true);
let always_unfocused = label_ink(FloatingLabelBehavior::Always, false);
let never_focused = label_ink(FloatingLabelBehavior::Never, true);
assert!(
auto_focused.1 < auto_unfocused.1,
"auto must float on focus: {} -> {}",
auto_unfocused.1,
auto_focused.1
);
assert_eq!(
always_unfocused, auto_focused,
"`always` must float a field that `auto` would leave inline"
);
assert_eq!(
never_focused, auto_unfocused,
"`never` must keep the label where an unfocused `auto` field draws it"
);
}
}