use crate::components::{FrameInput, InputKey, SpriteFit, TextInput};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{PipelineContext, StepResult, System};
use concinnity_core::gfx::overlay::OverlayTransform;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Edit {
Insert(char),
Backspace,
Delete,
Left,
Right,
}
fn command_from_key(key: InputKey) -> Option<Edit> {
match key {
InputKey::Backspace => Some(Edit::Backspace),
InputKey::Delete => Some(Edit::Delete),
InputKey::Left => Some(Edit::Left),
InputKey::Right => Some(Edit::Right),
_ => None,
}
}
fn char_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn apply_edit(content: &mut String, caret: &mut usize, edit: Edit, max_len: usize) {
let n = content.chars().count();
let c = (*caret).min(n);
match edit {
Edit::Insert(ch) => {
if max_len != 0 && n >= max_len {
return;
}
let byte = char_byte(content, c);
content.insert(byte, ch);
*caret = c + 1;
}
Edit::Backspace => {
if c > 0 {
let start = char_byte(content, c - 1);
let end = char_byte(content, c);
content.replace_range(start..end, "");
*caret = c - 1;
}
}
Edit::Delete => {
if c < n {
let start = char_byte(content, c);
let end = char_byte(content, c + 1);
content.replace_range(start..end, "");
*caret = c;
}
}
Edit::Left => *caret = c.saturating_sub(1),
Edit::Right => *caret = (c + 1).min(n),
}
}
fn cursor_in_field(ti: &TextInput, mx: f32, my: f32, viewport: [f32; 2]) -> bool {
let (qx, qy) = if ti.screen.is_none() {
(mx, my)
} else {
let overlay = match ti.fit {
SpriteFit::Bottom => OverlayTransform::bottom_anchored_from_viewport(viewport),
SpriteFit::Cover => OverlayTransform::cover_from_viewport(viewport),
SpriteFit::Fit => OverlayTransform::from_viewport(viewport),
};
overlay.inverse(mx, my)
};
qx >= ti.x && qx < ti.x + ti.width && qy >= ti.y && qy < ti.y + ti.height
}
#[derive(Debug, Default)]
pub(crate) struct TextInputSystem;
impl TextInputSystem {
pub(crate) fn new() -> Self {
Self
}
}
impl System for TextInputSystem {
fn access(&self) -> crate::ecs::Access {
crate::ecs::Access::new()
.reads_components(crate::component_mask![crate::components::FrameInput])
.writes_components(crate::component_mask![crate::components::TextInput])
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let input = match ctx.query::<FrameInput>().last().cloned() {
Some(i) => i,
None => return StepResult::Continue,
};
let edit = if let Some(ch) = input.typed_char {
Some(Edit::Insert(ch))
} else {
input.captured_key.and_then(command_from_key)
};
let hit_id: Option<AssetId> = if input.left_click {
let mut hit = None;
for ti in ctx.query::<TextInput>() {
if ti.visible && cursor_in_field(ti, input.mouse_x, input.mouse_y, input.viewport) {
hit = Some(ti.asset_id);
}
}
hit
} else {
None
};
for ti in ctx.query_mut::<TextInput>() {
if input.left_click {
ti.focused = ti.visible && Some(ti.asset_id) == hit_id;
}
if ti.focused {
let n = ti.content.chars().count();
ti.caret = if input.left_click { n } else { ti.caret.min(n) };
if ti.visible
&& let Some(edit) = edit
{
apply_edit(&mut ti.content, &mut ti.caret, edit, ti.max_len as usize);
}
}
}
StepResult::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::SYSTEMS;
#[test]
fn insert_appends_and_advances_caret() {
let mut s = String::new();
let mut c = 0;
apply_edit(&mut s, &mut c, Edit::Insert('h'), 0);
apply_edit(&mut s, &mut c, Edit::Insert('i'), 0);
assert_eq!(s, "hi");
assert_eq!(c, 2);
}
#[test]
fn insert_in_the_middle() {
let mut s = "ac".to_string();
let mut c = 1;
apply_edit(&mut s, &mut c, Edit::Insert('b'), 0);
assert_eq!(s, "abc");
assert_eq!(c, 2);
}
#[test]
fn backspace_removes_char_before_caret() {
let mut s = "abc".to_string();
let mut c = 2;
apply_edit(&mut s, &mut c, Edit::Backspace, 0);
assert_eq!(s, "ac");
assert_eq!(c, 1);
let mut c0 = 0;
let mut s0 = "x".to_string();
apply_edit(&mut s0, &mut c0, Edit::Backspace, 0);
assert_eq!(s0, "x");
assert_eq!(c0, 0);
}
#[test]
fn delete_removes_char_at_caret() {
let mut s = "abc".to_string();
let mut c = 1;
apply_edit(&mut s, &mut c, Edit::Delete, 0);
assert_eq!(s, "ac");
assert_eq!(c, 1);
let mut c2 = 2;
apply_edit(&mut s, &mut c2, Edit::Delete, 0);
assert_eq!(s, "ac");
}
#[test]
fn max_len_clamps_inserts() {
let mut s = "ab".to_string();
let mut c = 2;
apply_edit(&mut s, &mut c, Edit::Insert('c'), 2);
assert_eq!(s, "ab");
assert_eq!(c, 2);
}
#[test]
fn arrows_move_and_clamp_caret() {
let mut s = "abc".to_string();
let mut c = 1;
apply_edit(&mut s, &mut c, Edit::Left, 0);
assert_eq!(c, 0);
apply_edit(&mut s, &mut c, Edit::Left, 0); assert_eq!(c, 0);
apply_edit(&mut s, &mut c, Edit::Right, 0);
assert_eq!(c, 1);
c = 3;
apply_edit(&mut s, &mut c, Edit::Right, 0); assert_eq!(c, 3);
}
#[test]
fn edits_stay_on_char_boundaries() {
let mut s = "aé".to_string(); let mut c = 2;
apply_edit(&mut s, &mut c, Edit::Backspace, 0);
assert_eq!(s, "a");
assert_eq!(c, 1);
}
#[test]
fn command_keys_map_to_edits() {
assert_eq!(command_from_key(InputKey::Backspace), Some(Edit::Backspace));
assert_eq!(command_from_key(InputKey::Delete), Some(Edit::Delete));
assert_eq!(command_from_key(InputKey::Left), Some(Edit::Left));
assert_eq!(command_from_key(InputKey::Right), Some(Edit::Right));
assert_eq!(command_from_key(InputKey::A), None);
assert_eq!(command_from_key(InputKey::Enter), None);
}
#[test]
fn hud_field_hit_tests_in_window_pixels() {
let ti = TextInput {
x: 100.0,
y: 50.0,
width: 200.0,
height: 40.0,
screen: None,
..Default::default()
};
assert!(cursor_in_field(&ti, 150.0, 60.0, [1280.0, 720.0]));
assert!(!cursor_in_field(&ti, 50.0, 60.0, [1280.0, 720.0]));
assert!(!cursor_in_field(&ti, 150.0, 100.0, [1280.0, 720.0]));
}
use crate::ecs::World;
#[test]
fn focused_field_types_through_the_schedule() {
let mut world = World::new();
world.add_component(TextInput {
asset_id: AssetId(1),
focused: true,
screen: None,
..Default::default()
});
world.start(SYSTEMS).unwrap();
world.add_component(FrameInput {
typed_char: Some('h'),
..Default::default()
});
world.step();
assert_eq!(world.query::<TextInput>().next().unwrap().content, "h");
}
#[test]
fn click_focuses_the_field_under_the_cursor() {
let mut world = World::new();
world.add_component(TextInput {
asset_id: AssetId(1),
x: 0.0,
y: 0.0,
width: 100.0,
height: 40.0,
screen: None,
..Default::default()
});
world.add_component(TextInput {
asset_id: AssetId(2),
x: 200.0,
y: 0.0,
width: 100.0,
height: 40.0,
screen: None,
..Default::default()
});
world.start(SYSTEMS).unwrap();
world.add_component(FrameInput {
mouse_x: 250.0,
mouse_y: 20.0,
left_click: true,
viewport: [1280.0, 720.0],
..Default::default()
});
world.step();
let focus: Vec<(u32, bool)> = world
.query::<TextInput>()
.map(|t| (t.asset_id.0, t.focused))
.collect();
assert!(focus.contains(&(1, false)));
assert!(focus.contains(&(2, true)));
}
}