Skip to main content

beuvy_runtime/
input.rs

1mod build;
2mod clipboard;
3mod edit;
4mod range;
5mod state;
6mod text;
7mod text_engine;
8mod value;
9
10pub use text::{set_input_disabled, set_input_value};
11
12use bevy::input::keyboard::{Key, KeyboardInput};
13use bevy::input_focus::InputFocus;
14use bevy::prelude::*;
15use bevy::ui::UiSystems;
16use bevy::window::Ime;
17
18pub(crate) use clipboard::InputClipboard;
19pub use clipboard::UndoStack;
20pub use edit::{PreeditState, SelectionDirection, TextEditState};
21pub(crate) use text_engine::InputTextEngine;
22
23pub struct InputPlugin;
24
25#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum InputSet {
27    Build,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum InputType {
32    #[default]
33    Text,
34    Textarea,
35    Number,
36    Range,
37    Checkbox,
38    Radio,
39    Password,
40}
41
42/// Declarative request to materialize an input field using the active UI theme.
43#[derive(Component, Debug, Clone)]
44pub struct AddInput {
45    pub name: String,
46    pub input_type: InputType,
47    pub value: String,
48    pub checked: bool,
49    pub placeholder: String,
50    pub size_chars: Option<usize>,
51    pub rows: Option<usize>,
52    pub min: Option<f32>,
53    pub max: Option<f32>,
54    pub step: Option<f32>,
55    pub class: Option<String>,
56    pub text_class: Option<String>,
57    pub disabled: bool,
58}
59
60impl Default for AddInput {
61    fn default() -> Self {
62        Self {
63            name: String::new(),
64            input_type: InputType::Text,
65            value: String::new(),
66            checked: false,
67            placeholder: String::new(),
68            size_chars: None,
69            rows: None,
70            min: None,
71            max: None,
72            step: None,
73            class: None,
74            text_class: None,
75            disabled: false,
76        }
77    }
78}
79
80#[derive(Component, Debug, Clone)]
81pub struct InputField {
82    pub name: String,
83    pub input_type: InputType,
84    pub checked: bool,
85    pub placeholder: String,
86    pub viewport_entity: Option<Entity>,
87    pub text_entity: Option<Entity>,
88    pub selection_entity: Option<Entity>,
89    pub caret_entity: Option<Entity>,
90    pub edit_state: TextEditState,
91    pub initial_value: String,
92    pub initial_checked: bool,
93    pub min: Option<f32>,
94    pub max: Option<f32>,
95    pub step: Option<f32>,
96    pub caret_blink_resume_at: f64,
97    pub preferred_caret_x: Option<f32>,
98    pub undo_stack: UndoStack,
99}
100
101impl InputField {
102    pub fn submitted_value(&self) -> String {
103        match self.input_type {
104            InputType::Checkbox => self.checked.to_string(),
105            InputType::Radio => self.value().to_string(),
106            _ => self.value().to_string(),
107        }
108    }
109}
110
111#[derive(Component, Debug, Clone)]
112pub(crate) struct RangeState {
113    pub track: Entity,
114    pub fill: Entity,
115    pub thumb: Entity,
116    pub drag_start_value: f32,
117}
118
119impl InputField {
120    pub fn value(&self) -> &str {
121        self.edit_state.committed()
122    }
123
124    pub fn set_value(&mut self, value: impl Into<String>) {
125        self.edit_state.set_text(value);
126    }
127
128    pub fn numeric_value(&self) -> Option<f32> {
129        value::parse_number_buffer(self.value())
130    }
131
132    pub fn is_multiline(&self) -> bool {
133        matches!(self.input_type, InputType::Textarea)
134    }
135
136    pub fn is_text_like(&self) -> bool {
137        matches!(
138            self.input_type,
139            InputType::Text
140                | InputType::Textarea
141                | InputType::Number
142                | InputType::Range
143                | InputType::Password
144        )
145    }
146
147    pub fn is_checkable(&self) -> bool {
148        matches!(self.input_type, InputType::Checkbox | InputType::Radio)
149    }
150    pub fn is_toggle(&self) -> bool {
151        matches!(self.input_type, InputType::Checkbox | InputType::Radio)
152    }
153
154    pub fn reset(&mut self) {
155        self.edit_state.set_text(self.initial_value.clone());
156        self.checked = self.initial_checked;
157    }
158    pub fn step_by(&mut self, direction: f32) -> Option<String> {
159        if !matches!(self.input_type, InputType::Number | InputType::Range) || direction == 0.0 {
160            return None;
161        }
162        let current = self
163            .numeric_value()
164            .unwrap_or_else(|| self.min.unwrap_or(0.0));
165        let step = self.step.unwrap_or(1.0);
166        let next = value::snap_numeric_value(
167            current + step * direction.signum(),
168            self.min,
169            self.max,
170            self.step,
171        );
172        let next_value = value::format_numeric_value(next, self.step);
173        if self.value() == next_value {
174            return None;
175        }
176        self.set_value(next_value.clone());
177        Some(next_value)
178    }
179}
180
181#[derive(Component, Debug, Clone, Copy)]
182pub struct InputText;
183
184#[allow(dead_code)]
185#[derive(Component, Debug, Clone, Copy)]
186pub(crate) struct InputIndicator;
187
188#[derive(Component, Debug, Clone, Copy)]
189pub(crate) struct InputViewport;
190
191#[derive(Component, Debug, Clone, Copy, Default)]
192pub struct DisabledInput;
193
194#[derive(Component, Debug, Clone, Copy)]
195pub struct InputSelection;
196
197#[derive(Component, Debug, Clone, Copy)]
198pub(crate) struct InputSelectionSegment;
199
200#[derive(Resource, Debug, Default)]
201pub(crate) struct SelectionSegmentPool {
202    pub available: usize,
203    pub max_needed: usize,
204}
205
206#[derive(Component, Debug, Clone, Copy)]
207pub struct InputCaret;
208
209#[allow(dead_code)]
210#[derive(Component, Debug, Clone, Copy)]
211pub(crate) struct InputCheckRoot;
212
213#[derive(Component, Debug, Clone, Copy)]
214pub struct InputCursorPosition {
215    pub x: f32,
216    pub y: f32,
217}
218
219#[derive(Component, Debug, Clone, Copy, Default)]
220pub(crate) struct InputScrollOffset {
221    pub x: f32,
222    pub y: f32,
223}
224
225#[derive(Component, Debug, Clone, Copy, Default)]
226pub(crate) struct InputClickState {
227    pub last_click_time: f64,
228    pub click_count: u8,
229    pub last_click_position: Option<Vec2>,
230}
231
232#[derive(Component, Debug, Clone, Copy)]
233pub(crate) struct RangeTrack {
234    pub input: Entity,
235}
236
237#[derive(Component, Debug, Clone, Copy)]
238pub(crate) struct RangeFill;
239
240#[derive(Component, Debug, Clone, Copy)]
241pub(crate) struct RangeThumb;
242
243#[derive(Component, Debug, Clone, Copy)]
244pub(crate) struct ToggleIndicator;
245
246#[derive(Component, Debug, Clone, Copy)]
247pub(crate) struct ToggleFill;
248
249#[derive(Component, Debug, Clone, Copy)]
250pub(crate) struct CheckboxMark;
251
252#[derive(Message, Debug, Clone)]
253pub struct InputValueChangedMessage {
254    pub entity: Entity,
255    pub name: String,
256    pub value: String,
257    pub runtime_value: InputRuntimeValue,
258}
259
260#[derive(Message, Debug, Clone)]
261pub struct InputSubmitMessage {
262    pub entity: Entity,
263    pub name: String,
264}
265
266impl Plugin for InputPlugin {
267    fn build(&self, app: &mut App) {
268        app.add_message::<InputValueChangedMessage>()
269            .add_message::<InputSubmitMessage>()
270            .add_message::<KeyboardInput>()
271            .add_message::<Ime>()
272            .add_message::<Pointer<Click>>()
273            .init_resource::<InputFocus>()
274            .init_resource::<SelectionSegmentPool>()
275            .init_resource::<InputTextEngine>()
276            .insert_non_send_resource(InputClipboard::new())
277            .add_systems(
278                Update,
279                (
280                    build::add_input.in_set(InputSet::Build),
281                    build::sync_toggle_visuals,
282                    state::sync_radio_groups,
283                    state::clear_input_focus_on_foreign_click,
284                    state::sync_input_focus_visuals,
285                    range::sync_range_visuals,
286                ),
287            )
288            .add_systems(
289                Update,
290                (
291                    state::handle_keyboard_input,
292                    state::handle_ime_input,
293                    state::sync_input_ime_state,
294                ),
295            )
296            .add_systems(
297                PostUpdate,
298                state::sync_input_edit_visuals.after(UiSystems::PostLayout),
299            );
300    }
301}
302
303fn active_input_entity(
304    input_focus: &InputFocus,
305    fields: &Query<(), With<InputField>>,
306) -> Option<Entity> {
307    input_focus.get().filter(|entity| fields.contains(*entity))
308}
309
310fn set_input_focus(input_focus: &mut InputFocus, entity: Entity) {
311    input_focus.set(entity);
312}
313
314fn clear_input_focus(input_focus: &mut InputFocus) {
315    input_focus.clear();
316}
317
318pub(crate) fn push_value_changed(
319    value_changed: &mut MessageWriter<InputValueChangedMessage>,
320    entity: Entity,
321    field: &InputField,
322) {
323    value_changed.write(InputValueChangedMessage {
324        entity,
325        name: field.name.clone(),
326        value: field.value().to_string(),
327        runtime_value: input_runtime_value(field),
328    });
329}
330
331pub(crate) fn push_range_value_changed(
332    value_changed: &mut MessageWriter<InputValueChangedMessage>,
333    entity: Entity,
334    name: &str,
335    value: String,
336) {
337    let runtime_value = value
338        .parse::<f64>()
339        .map(InputRuntimeValue::Number)
340        .unwrap_or_else(|_| InputRuntimeValue::Text(value.clone()));
341    value_changed.write(InputValueChangedMessage {
342        entity,
343        name: name.to_string(),
344        value,
345        runtime_value,
346    });
347}
348
349#[derive(Debug, Clone, PartialEq)]
350pub enum InputRuntimeValue {
351    Text(String),
352    Bool(bool),
353    Number(f64),
354}
355
356pub(crate) fn input_runtime_value(field: &InputField) -> InputRuntimeValue {
357    match field.input_type {
358        InputType::Checkbox => InputRuntimeValue::Bool(field.checked),
359        InputType::Radio => InputRuntimeValue::Text(field.value().to_string()),
360        InputType::Number | InputType::Range => field
361            .numeric_value()
362            .map(|value| InputRuntimeValue::Number(value as f64))
363            .unwrap_or_else(|| InputRuntimeValue::Text(field.value().to_string())),
364        InputType::Text | InputType::Textarea | InputType::Password => {
365            InputRuntimeValue::Text(field.value().to_string())
366        }
367    }
368}
369
370fn is_printable_char(chr: char) -> bool {
371    let is_in_private_use_area = ('\u{e000}'..='\u{f8ff}').contains(&chr);
372    !chr.is_control() && !is_in_private_use_area
373}
374
375fn key_is_submit(key: &Key) -> bool {
376    matches!(key, Key::Enter)
377}
378
379fn sync_window_ime(primary_window: &mut Window, enabled: bool, position: Vec2) {
380    primary_window.ime_enabled = enabled;
381    primary_window.ime_position = position;
382}