beuvy_runtime/input/
text.rs1use 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}