Skip to main content

beuvy_runtime/input/
text.rs

1use super::{AddInput, DisabledInput, InputField, InputText, InputType};
2use crate::style::{
3    control_radius, font_size_control, regular_border, text_disabled_color,
4    text_placeholder_color, text_primary_color,
5};
6use crate::text::{AddText, FontResource, control_typography, set_plain_text};
7use bevy::prelude::*;
8use bevy::text::{LineBreak, TextLayout};
9
10pub(crate) fn default_input_node(size_chars: Option<usize>) -> Node {
11    let width = size_chars.map(input_width_for_chars).unwrap_or(96.0);
12    Node {
13        width: size_chars.map_or(Val::Auto, |chars| Val::Px(input_width_for_chars(chars))),
14        min_width: Val::Px(width),
15        ..default()
16    }
17}
18
19pub(crate) fn default_textarea_node(size_chars: Option<usize>, rows: Option<usize>) -> Node {
20    let width = size_chars.map(input_width_for_chars).unwrap_or(180.0);
21    let rows = rows.unwrap_or(3).max(1) as f32;
22    let line_height = font_size_control() * 1.5;
23    let content_height = line_height * rows;
24    Node {
25        width: size_chars.map_or(Val::Auto, |chars| Val::Px(input_width_for_chars(chars))),
26        min_width: Val::Px(width),
27        min_height: Val::Px(content_height + 20.0),
28        height: Val::Px(content_height + 20.0),
29        align_items: AlignItems::Start,
30        ..default()
31    }
32}
33
34fn input_width_for_chars(size_chars: usize) -> f32 {
35    let content_width = size_chars.max(1) as f32 * (font_size_control() * 0.6);
36    content_width + 20.0
37}
38
39pub(crate) fn input_text_node() -> Node {
40    Node {
41        display: Display::Block,
42        position_type: PositionType::Absolute,
43        left: Val::Px(0.0),
44        top: Val::Px(0.0),
45        min_width: Val::Px(0.0),
46        ..default()
47    }
48}
49
50pub(crate) fn input_text_bundle(add_input: &AddInput) -> AddText {
51    let preview = if add_input.input_type == InputType::Password && !add_input.value.is_empty() {
52        mask_password(&add_input.value)
53    } else if add_input.value.is_empty() {
54        add_input.placeholder.clone()
55    } else {
56        add_input.value.clone()
57    };
58    let color = if add_input.disabled {
59        text_disabled_color()
60    } else if add_input.value.is_empty() {
61        text_placeholder_color()
62    } else {
63        text_primary_color()
64    };
65
66    AddText {
67        text: preview,
68        size: font_size_control(),
69        color,
70        layout: if add_input.input_type == InputType::Textarea {
71            TextLayout::new_with_linebreak(LineBreak::WordBoundary)
72        } else {
73            TextLayout::new_with_no_wrap()
74        },
75        ..default()
76    }
77    .typography(control_typography())
78}
79
80pub(crate) fn update_input_text(
81    commands: &mut Commands,
82    font_resource: &FontResource,
83    field: &InputField,
84    disabled: bool,
85) {
86    let Some(text_entity) = field.text_entity else {
87        return;
88    };
89
90    let display_text = field.edit_state.display_text_string(&field.placeholder);
91    let text = if matches!(field.input_type, InputType::Password) {
92        if field.value().is_empty() {
93            if disabled || display_text.is_placeholder {
94                field.placeholder.clone()
95            } else {
96                String::new()
97            }
98        } else {
99            mask_password(field.value())
100        }
101    } else if disabled && field.edit_state.preedit().is_some() {
102        field.value().to_string()
103    } else if disabled {
104        if field.value().is_empty() {
105            field.placeholder.clone()
106        } else {
107            field.value().to_string()
108        }
109    } else {
110        display_text.text
111    };
112    set_plain_text(commands, text_entity, text);
113
114    let Ok(mut entity_commands) = commands.get_entity(text_entity) else {
115        return;
116    };
117    let color = if disabled {
118        text_disabled_color()
119    } else if display_text.is_placeholder {
120        text_placeholder_color()
121    } else {
122        text_primary_color()
123    };
124    let text_font = font_resource
125        .primary_font
126        .clone()
127        .map(TextFont::from)
128        .unwrap_or_default()
129        .with_font_size(font_size_control());
130    entity_commands.try_insert((text_font, TextColor(color)));
131}
132
133pub fn set_input_value(
134    commands: &mut Commands,
135    font_resource: &FontResource,
136    field: &mut InputField,
137    disabled: bool,
138    value: impl Into<String>,
139) -> bool {
140    let value = value.into();
141    if field.value() == value && field.edit_state.preedit().is_none() {
142        return false;
143    }
144
145    field.set_value(value);
146    update_input_text(commands, font_resource, field, disabled);
147    true
148}
149
150#[allow(dead_code)]
151pub(crate) fn default_check_input_node() -> Node {
152    Node {
153        min_width: Val::Px(18.0),
154        min_height: Val::Px(18.0),
155        width: Val::Px(18.0),
156        height: Val::Px(18.0),
157        justify_content: JustifyContent::Center,
158        align_items: AlignItems::Center,
159        border: regular_border(),
160        border_radius: control_radius(),
161        ..default()
162    }
163}
164
165pub(crate) fn apply_check_input_shape(node: &mut Node, input_type: InputType) {
166    if matches!(input_type, InputType::Radio) {
167        node.border_radius = BorderRadius::all(Val::Px(999.0));
168    } else {
169        node.border_radius = BorderRadius::ZERO;
170    }
171}
172
173#[allow(dead_code)]
174pub(crate) fn default_check_indicator_node(input_type: InputType) -> Node {
175    let mut node = Node {
176        width: Val::Px(10.0),
177        height: Val::Px(10.0),
178        ..default()
179    };
180    if matches!(input_type, InputType::Radio) {
181        node.border_radius = BorderRadius::all(Val::Px(999.0));
182    } else {
183        node.width = Val::Px(9.0);
184        node.height = Val::Px(9.0);
185    }
186    node
187}
188
189fn mask_password(value: &str) -> String {
190    value.chars().map(|_| '*').collect()
191}
192
193pub fn set_input_disabled(
194    commands: &mut Commands,
195    font_resource: &FontResource,
196    entity: Entity,
197    field: &InputField,
198    disabled: bool,
199) {
200    let Ok(mut entity_commands) = commands.get_entity(entity) else {
201        return;
202    };
203
204    if disabled {
205        entity_commands.try_insert((DisabledInput, crate::interaction_style::UiDisabled));
206    } else {
207        entity_commands
208            .try_remove::<DisabledInput>()
209            .try_remove::<crate::interaction_style::UiDisabled>();
210    }
211
212    update_input_text(commands, font_resource, field, disabled);
213}
214
215pub(crate) fn input_text_marker() -> InputText {
216    InputText
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::input::InputType;
223    use bevy::text::LineBreak;
224
225    #[test]
226    fn input_text_bundle_disables_soft_wrap() {
227        let add_input = AddInput {
228            value: "Long single-line value".to_string(),
229            ..Default::default()
230        };
231
232        let bundle = input_text_bundle(&add_input);
233
234        assert_eq!(bundle.layout.linebreak, LineBreak::NoWrap);
235    }
236
237    #[test]
238    fn empty_input_text_bundle_uses_placeholder_color() {
239        let add_input = AddInput {
240            placeholder: "Hint".to_string(),
241            ..Default::default()
242        };
243
244        let bundle = input_text_bundle(&add_input);
245
246        assert_eq!(bundle.text, "Hint");
247        assert_eq!(bundle.color, text_placeholder_color());
248    }
249
250    #[test]
251    fn textarea_text_bundle_enables_soft_wrap() {
252        let add_input = AddInput {
253            input_type: InputType::Textarea,
254            value: "Long multi-line value".to_string(),
255            ..Default::default()
256        };
257
258        let bundle = input_text_bundle(&add_input);
259
260        assert_eq!(bundle.layout.linebreak, LineBreak::WordBoundary);
261    }
262}