use concinnity_core::components::{FrameInput, InputKey, KeyEvent, SpriteFit, TextInput};
use concinnity_core::ecs::{Access, Entity, PipelineContext, StepResult, System};
use concinnity_core::gfx::overlay::OverlayTransform;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Edit {
Insert(char),
Backspace,
Delete,
Left,
Right,
Home,
End,
}
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),
InputKey::Home => Some(Edit::Home),
InputKey::End => Some(Edit::End),
_ => None,
}
}
fn edit_from_event(event: &KeyEvent) -> Option<Edit> {
match event {
KeyEvent::Text(ch) => Some(Edit::Insert(*ch)),
KeyEvent::Press(press) => command_from_key(press.key),
}
}
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),
Edit::Home => *caret = 0,
Edit::End => *caret = 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) -> Access {
Access::new()
.reads_components(crate::component_mask![FrameInput])
.writes_components(crate::component_mask![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 edits: Vec<Edit> = input
.key_events
.iter()
.filter_map(edit_from_event)
.collect();
let hit: Option<Entity> = if input.left_click {
let mut hit = None;
for (entity, ti) in ctx.query_with_entity::<TextInput>() {
if ti.visible && cursor_in_field(ti, input.mouse_x, input.mouse_y, input.viewport) {
hit = Some(entity);
}
}
hit
} else {
None
};
for (entity, ti) in ctx.query_mut_with_entity::<TextInput>() {
if input.left_click {
ti.focused = ti.visible && Some(entity) == hit;
}
if ti.focused {
let n = ti.content.chars().count();
ti.caret = if input.left_click { n } else { ti.caret.min(n) };
if ti.visible {
for &edit in &edits {
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;
use concinnity_core::ecs::asset_id::AssetId;
#[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::Home), Some(Edit::Home));
assert_eq!(command_from_key(InputKey::End), Some(Edit::End));
assert_eq!(command_from_key(InputKey::A), None);
assert_eq!(command_from_key(InputKey::Enter), None);
}
#[test]
fn home_and_end_jump_the_caret() {
let mut s = "abc".to_string();
let mut c = 1;
apply_edit(&mut s, &mut c, Edit::End, 0);
assert_eq!(c, 3);
apply_edit(&mut s, &mut c, Edit::Home, 0);
assert_eq!(c, 0);
}
#[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 concinnity_core::ecs::World;
#[test]
fn focused_field_types_through_the_schedule() {
let mut world = World::new();
world.push_identified(
AssetId(1),
TextInput {
focused: true,
screen: None,
..Default::default()
},
);
world.start(SYSTEMS).unwrap();
world.add_component(FrameInput {
key_events: vec![KeyEvent::Text('h')],
..Default::default()
});
world.step();
assert_eq!(world.query::<TextInput>().next().unwrap().content, "h");
}
#[test]
fn every_event_of_a_frame_applies_in_order() {
let mut world = World::new();
world.push_identified(
AssetId(1),
TextInput {
focused: true,
screen: None,
..Default::default()
},
);
world.start(SYSTEMS).unwrap();
world.add_component(FrameInput {
key_events: vec![
KeyEvent::press(InputKey::A),
KeyEvent::Text('a'),
KeyEvent::press(InputKey::B),
KeyEvent::Text('b'),
KeyEvent::press(InputKey::Backspace),
KeyEvent::press(InputKey::C),
KeyEvent::Text('c'),
KeyEvent::press(InputKey::Home),
KeyEvent::Text('>'),
],
..Default::default()
});
world.step();
let field = world.query::<TextInput>().next().unwrap();
assert_eq!(field.content, ">ac");
assert_eq!(field.caret, 1);
}
#[test]
fn click_focuses_the_field_under_the_cursor() {
let mut world = World::new();
world.push_identified(
AssetId(1),
TextInput {
x: 0.0,
y: 0.0,
width: 100.0,
height: 40.0,
screen: None,
..Default::default()
},
);
world.push_identified(
AssetId(2),
TextInput {
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
.join2::<TextInput, concinnity_core::components::Identity>()
.map(|(_, t, identity)| (identity.id().0, t.focused))
.collect();
assert!(focus.contains(&(1, false)));
assert!(focus.contains(&(2, true)));
}
}