feat(chat): add native text editing, history and command completion

Use Bevy editable text with word navigation, keyboard and pointer selection, system clipboard integration and IME-aware submission. Add bounded history with draft restoration and permission-aware Tab completion. Cover native keyboard, mouse and clipboard event handling in tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 20:22:32 +02:00
parent 43051c9f5d
commit 4d7d777e86
6 changed files with 782 additions and 11 deletions

View File

@ -10,6 +10,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of - Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of
terrain, buildings and ground items, including health, script variables, machine terrain, buildings and ground items, including health, script variables, machine
contents and processing state. Imports replace the map while preserving characters. contents and processing state. Imports replace the map while preserving characters.
- Native chat text editing with word navigation, keyboard/mouse selection, system
clipboard copy/cut/paste, bounded sent-message history and role-aware command completion.
- Persistent-world station game with singleplayer, hosting (`--host`) and - Persistent-world station game with singleplayer, hosting (`--host`) and
joining (`--connect`), plus a dedicated `game-server`. joining (`--connect`), plus a dedicated `game-server`.
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`. - Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.

View File

@ -88,6 +88,8 @@ game-server --database postgres://station:secret@db/station --world europa
The schema is created and migrated automatically. To run the PostgreSQL tests, The schema is created and migrated automatically. To run the PostgreSQL tests,
set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped. set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped.
The native chat clipboard test can be enabled with `STATION_TEST_CLIPBOARD=1`
when running tests in a desktop session with an available system clipboard.
### Dedicated server configuration ### Dedicated server configuration
@ -146,6 +148,14 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
## Controls ## Controls
Chat supports cursor navigation (arrows, Home/End, Ctrl+Left/Right), Shift selection,
Ctrl+A, mouse positioning/drag selection, double-click word selection, and system
clipboard copy/cut/paste (Ctrl+C/X/V). On macOS, use the usual Command shortcuts and
Option for word navigation. Up/Down recall the last 100 sent messages and commands;
Down past the newest entry restores the unsent draft. Tab completes command names
and subcommands allowed for your role; repeated Tab cycles matches, and Shift+Tab
cycles backwards. Escape closes chat without sending.
| Key | Action | | Key | Action |
|---------------------|------------------------------------------------------| |---------------------|------------------------------------------------------|
| WASD / arrows | Move freely, also diagonally (you face that way) | | WASD / arrows | Move freely, also diagonally (you face that way) |

View File

@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
game_core = { workspace = true } game_core = { workspace = true }
game_server = { workspace = true } game_server = { workspace = true }
bevy = { workspace = true, default-features = true } bevy = { workspace = true, default-features = true, features = ["bevy_ui_widgets", "system_clipboard"] }
bevy_replicon = { workspace = true } bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true } bevy_replicon_renet = { workspace = true }
directories = { workspace = true } directories = { workspace = true }

View File

@ -37,17 +37,21 @@ impl Plugin for HudPlugin {
}) })
.add_observer(on_action) .add_observer(on_action)
.add_observer( .add_observer(
|_: On<SessionEnded>, mut log: ResMut<GameLogBuffer>, mut ui: ResMut<UiState>| { |_: On<SessionEnded>,
mut log: ResMut<GameLogBuffer>,
mut ui: ResMut<UiState>,
mut history: ResMut<ui::chat::ChatHistory>| {
*log = default(); *log = default();
*ui = default(); *ui = default();
*history = default();
}, },
) )
.add_systems(OnEnter(AppState::InGame), spawn_hud) .add_systems(OnEnter(AppState::InGame), spawn_hud)
.add_systems(PostUpdate, focus_chat.run_if(in_state(AppState::InGame)))
.add_systems( .add_systems(
Update, Update,
( (
hud_bindings, hud_bindings,
focus_chat,
inventory_widget, inventory_widget,
skills_widget, skills_widget,
log_widget, log_widget,
@ -172,13 +176,25 @@ fn focus_chat(
mut focus: ResMut<UiFocus>, mut focus: ResMut<UiFocus>,
mut opened: Local<bool>, mut opened: Local<bool>,
inputs: Query<(Entity, &ui::TextInput)>, inputs: Query<(Entity, &ui::TextInput)>,
mut native_focus: ResMut<bevy::input_focus::InputFocus>,
) { ) {
let chat = inputs.iter().find(|(_, i)| i.id == "chat").map(|(e, _)| e); let chat = inputs.iter().find(|(_, i)| i.id == "chat").map(|(e, _)| e);
if ui.chat_open && !*opened { if ui.chat_open && !*opened {
focus.input = chat; focus.input = chat;
if let Some(chat) = chat {
native_focus.set(chat, bevy::input_focus::FocusCause::Navigated);
}
} else if ui.chat_open && focus.input != chat { } else if ui.chat_open && focus.input != chat {
ui.chat_open = false; ui.chat_open = false;
} }
if !ui.chat_open {
if native_focus.get() == chat {
native_focus.clear();
}
if focus.input == chat {
focus.input = None;
}
}
*opened = ui.chat_open; *opened = ui.chat_open;
} }
@ -593,6 +609,7 @@ fn on_action(
state: Res<State<AppState>>, state: Res<State<AppState>>,
mut ui: ResMut<UiState>, mut ui: ResMut<UiState>,
mut form: ResMut<UiForm>, mut form: ResMut<UiForm>,
mut history: ResMut<ui::chat::ChatHistory>,
machine: Res<OpenMachine>, machine: Res<OpenMachine>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory)>, characters: Query<(&GridPos, &Facing, &Inventory)>,
@ -642,6 +659,7 @@ fn on_action(
form.0.insert("chat".into(), String::new()); form.0.insert("chat".into(), String::new());
ui.chat_open = false; ui.chat_open = false;
if !text.is_empty() { if !text.is_empty() {
history.remember(&text);
commands.client_trigger(Chat { text }); commands.client_trigger(Chat { text });
} }
} }

View File

@ -0,0 +1,693 @@
//! Chat-specific history and completion on top of Bevy's native text editor.
use std::collections::{BTreeSet, VecDeque};
use bevy::{
clipboard::Clipboard,
input::{
ButtonState,
keyboard::{Key, KeyboardInput},
},
input_focus::InputFocus,
prelude::*,
text::{EditableText, FontCx, LayoutCx, TextEdit},
};
use bevy_replicon::prelude::ClientId;
use game_server::commands::{CommandSource, help};
use super::{TextInput, UiAction, UiFocus, UiForm};
use crate::session::{SessionInfo, SessionRequest, SessionTarget};
const HISTORY_LIMIT: usize = 100;
#[derive(Resource, Default)]
pub struct ChatHistory {
entries: VecDeque<String>,
cursor: Option<usize>,
draft: String,
completion: Option<Completion>,
}
impl ChatHistory {
pub fn remember(&mut self, text: &str) {
if self.entries.back().is_none_or(|last| last != text) {
self.entries.push_back(text.to_owned());
if self.entries.len() > HISTORY_LIMIT {
self.entries.pop_front();
}
}
self.reset_navigation();
}
fn reset_navigation(&mut self) {
self.cursor = None;
self.draft.clear();
self.completion = None;
}
fn navigate(&mut self, current: &str, older: bool) -> Option<String> {
if self.entries.is_empty() {
return None;
}
let next = match (self.cursor, older) {
(None, true) => {
self.draft = current.to_owned();
Some(self.entries.len() - 1)
}
(Some(i), true) => Some(i.saturating_sub(1)),
(Some(i), false) if i + 1 < self.entries.len() => Some(i + 1),
(Some(_), false) => None,
(None, false) => return None,
};
self.cursor = next;
self.completion = None;
Some(next.map_or_else(|| self.draft.clone(), |i| self.entries[i].clone()))
}
fn complete(
&mut self,
value: &str,
caret: usize,
source: CommandSource,
backwards: bool,
) -> Option<(String, usize)> {
if let Some(cycle) = &mut self.completion
&& cycle.last_value == value
&& cycle.last_caret == caret
&& cycle.source == source
{
cycle.index = if backwards {
(cycle.index + cycle.candidates.len() - 1) % cycle.candidates.len()
} else {
(cycle.index + 1) % cycle.candidates.len()
};
return Some(cycle.result());
}
let before = value.get(..caret)?.strip_prefix('/')?;
let (head, prefix) = before
.rsplit_once(' ')
.map_or(("", before), |(h, p)| (h, p));
let word = before.split(' ').count() - 1;
let mut candidates = BTreeSet::new();
for (usage, _) in help(source) {
let parts: Vec<_> = usage.split(' ').collect();
let previous: Vec<_> = head.split(' ').filter(|s| !s.is_empty()).collect();
if previous.iter().enumerate().any(|(i, token)| {
parts
.get(i)
.is_none_or(|part| !part.split('|').any(|p| p == *token))
}) {
continue;
}
if let Some(part) = parts.get(word) {
candidates.extend(
part.split('|')
.filter(|p| {
p.chars().all(|c| c.is_ascii_lowercase()) && p.starts_with(prefix)
})
.map(str::to_owned),
);
}
}
if candidates.is_empty() {
self.completion = None;
return None;
}
let candidates: Vec<_> = candidates.into_iter().collect();
let index = if backwards { candidates.len() - 1 } else { 0 };
let mut cycle = Completion {
start: caret - prefix.len(),
suffix: value[caret..]
.find(char::is_whitespace)
.map_or("", |end| &value[caret + end..])
.to_owned(),
leading: value[..caret - prefix.len()].to_owned(),
source,
candidates,
index,
last_value: String::new(),
last_caret: 0,
};
let result = cycle.result();
self.completion = Some(cycle);
Some(result)
}
}
struct Completion {
start: usize,
leading: String,
suffix: String,
source: CommandSource,
candidates: Vec<String>,
index: usize,
last_value: String,
last_caret: usize,
}
impl Completion {
fn result(&mut self) -> (String, usize) {
let candidate = &self.candidates[self.index];
self.last_value = format!("{}{}{}", self.leading, candidate, self.suffix);
self.last_caret = self.start + candidate.len();
(self.last_value.clone(), self.last_caret)
}
}
#[derive(Component)]
pub struct ChatEditor {
synced: String,
}
impl ChatEditor {
pub fn new(value: String) -> Self {
Self { synced: value }
}
}
pub fn edit(
mut commands: Commands,
mut keys: MessageReader<KeyboardInput>,
modifiers: Res<ButtonInput<Key>>,
mut focus: ResMut<UiFocus>,
mut native_focus: ResMut<InputFocus>,
mut form: ResMut<UiForm>,
mut history: ResMut<ChatHistory>,
mut inputs: Query<(Entity, &TextInput, &mut EditableText, &mut ChatEditor)>,
mut fonts: ResMut<FontCx>,
mut layouts: ResMut<LayoutCx>,
mut clipboard: ResMut<Clipboard>,
session: Option<Res<SessionInfo>>,
request: Option<Res<SessionRequest>>,
) {
let Some((entity, input, mut text, mut editor)) = inputs.iter_mut().next() else {
keys.clear();
return;
};
if form.get(&input.id) != editor.synced {
let value = form.get(&input.id).to_owned();
replace(&mut text, &value, value.len(), &mut fonts, &mut layouts);
editor.synced = value;
}
let was_composing = text.is_composing();
text.apply_pending_edits(&mut fonts, &mut layouts.0, &mut clipboard, |c| {
!c.is_control()
});
let mut value = text.value().to_string();
if focus.input != Some(entity) {
keys.clear();
return;
}
let source = CommandSource::Player {
client: ClientId::Server,
admin: session.is_some_and(|s| s.admin),
owner: request.is_some_and(|r| matches!(r.target, SessionTarget::Local { .. })),
};
let shift = modifiers.pressed(Key::Shift);
let shortcut = modifiers.any_pressed([Key::Control, Key::Alt, Key::Super]);
for key in keys.read().filter(|k| k.state == ButtonState::Pressed) {
focus.consumed = true;
if was_composing || text.is_composing() {
continue;
}
match key.logical_key {
Key::Enter => {
form.0.insert(input.id.clone(), value.clone());
if let Some(action) = &input.submit {
commands.trigger(UiAction(action.clone()));
}
text.clear();
editor.synced = value.clone();
focus.input = None;
native_focus.clear();
break;
}
Key::Escape => {
history.reset_navigation();
focus.input = None;
native_focus.clear();
break;
}
Key::ArrowUp | Key::ArrowDown if !shift && !shortcut => {
if let Some(recalled) = history.navigate(&value, key.logical_key == Key::ArrowUp) {
replace(
&mut text,
&recalled,
recalled.len(),
&mut fonts,
&mut layouts,
);
value = recalled;
}
}
Key::Tab if !shortcut => {
let selection = text.editor.raw_selection();
let caret = selection.focus().index();
if selection.is_collapsed()
&& let Some((completed, caret)) = history.complete(&value, caret, source, shift)
&& completed.chars().count() <= input.max_len
{
replace(&mut text, &completed, caret, &mut fonts, &mut layouts);
value = completed;
}
}
Key::Shift | Key::Control | Key::Alt | Key::Super => {}
_ => history.completion = None,
}
}
if focus.input == Some(entity) && editor.synced != value {
form.0.insert(input.id.clone(), value.clone());
editor.synced = value;
}
}
fn replace(
text: &mut EditableText,
value: &str,
caret: usize,
fonts: &mut FontCx,
layouts: &mut LayoutCx,
) {
text.clear();
text.editor.set_text(value);
text.editor
.driver(fonts, &mut layouts.0)
.move_to_byte(caret);
text.queue_edit(TextEdit::CollapseSelection);
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::{
camera::RenderTarget,
input::{InputPlugin, keyboard::NativeKeyCode},
input_focus::{InputDispatchPlugin, InputFocusPlugin},
picking::{
backend::HitData,
events::{Drag, Pointer, Press, Release},
pointer::{Location, PointerButton, PointerId},
},
text::DEFAULT_FONT_DATA,
ui_widgets::EditableTextInputPlugin,
window::{Ime, PrimaryWindow, WindowRef},
};
fn app() -> (App, Entity, Entity) {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
InputFocusPlugin,
InputDispatchPlugin,
EditableTextInputPlugin,
))
.init_resource::<FontCx>()
.init_resource::<LayoutCx>()
.init_resource::<Clipboard>()
.init_resource::<UiScale>()
.init_resource::<UiFocus>()
.init_resource::<UiForm>()
.init_resource::<ChatHistory>()
.add_message::<Ime>()
.add_message::<Pointer<Release>>()
.add_systems(Update, edit);
let mut fonts = app.world_mut().resource_mut::<FontCx>();
fonts
.collection
.register_fonts(DEFAULT_FONT_DATA.to_vec().into(), None);
fonts.set_sans_serif_family("Fira Mono").unwrap();
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
let input = app
.world_mut()
.spawn((
TextInput {
id: "chat".into(),
placeholder: String::new(),
password: false,
max_len: 200,
submit: Some("chat_send".into()),
},
EditableText {
allow_newlines: false,
max_characters: Some(200),
..default()
},
ChatEditor::new(String::new()),
))
.id();
app.world_mut().resource_mut::<UiFocus>().input = Some(input);
app.world_mut()
.resource_mut::<InputFocus>()
.set(input, bevy::input_focus::FocusCause::Navigated);
app.finish();
app.cleanup();
app.update();
(app, window, input)
}
fn key(app: &mut App, window: Entity, logical_key: Key, text: Option<&str>) {
for state in [ButtonState::Pressed, ButtonState::Released] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Unidentified(NativeKeyCode::Unidentified),
logical_key: logical_key.clone(),
state,
text: text.map(Into::into),
repeat: false,
window,
});
app.update();
}
}
fn value(app: &App, input: Entity) -> String {
app.world()
.get::<EditableText>(input)
.unwrap()
.value()
.to_string()
}
#[test]
fn native_chat_keyboard_navigation_selection_history_and_submission() {
let (mut app, window, input) = app();
key(
&mut app,
window,
Key::Character("hello world".into()),
Some("hello world"),
);
assert_eq!(value(&app, input), "hello world");
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::ArrowLeft, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.focus()
.index(),
6
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Shift);
key(&mut app, window, Key::ArrowRight, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.text_range(),
6..11
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Shift);
key(
&mut app,
window,
Key::Character("café".into()),
Some("café"),
);
assert_eq!(value(&app, input), "hello café");
key(&mut app, window, Key::ArrowLeft, None);
key(&mut app, window, Key::Delete, None);
assert_eq!(value(&app, input), "hello caf");
app.world_mut()
.resource_mut::<ChatHistory>()
.remember("sent");
key(&mut app, window, Key::ArrowUp, None);
assert_eq!(value(&app, input), "sent");
key(&mut app, window, Key::ArrowDown, None);
assert_eq!(value(&app, input), "hello caf");
key(&mut app, window, Key::Enter, None);
assert_eq!(app.world().resource::<UiForm>().get("chat"), "hello caf");
assert!(value(&app, input).is_empty());
assert!(app.world().resource::<UiFocus>().input.is_none());
assert!(app.world().resource::<InputFocus>().get() != Some(input));
}
#[test]
fn native_chat_completion_and_external_drafts() {
let (mut app, window, input) = app();
app.insert_resource(SessionInfo {
admin: true,
..default()
});
app.world_mut()
.resource_mut::<UiForm>()
.0
.insert("chat".into(), "/exp".into());
app.update();
key(&mut app, window, Key::Tab, None);
assert_eq!(value(&app, input), "/export");
assert_eq!(app.world().resource::<UiForm>().get("chat"), "/export");
key(&mut app, window, Key::Escape, None);
assert!(app.world().resource::<UiFocus>().input.is_none());
}
#[test]
fn native_chat_enter_confirms_ime_before_submitting() {
let (mut app, window, input) = app();
app.world_mut()
.get_mut::<EditableText>(input)
.unwrap()
.queue_edit(TextEdit::ImeSetCompose {
value: "é".into(),
cursor: None,
});
app.update();
assert!(
app.world()
.get::<EditableText>(input)
.unwrap()
.is_composing()
);
app.world_mut().write_message(Ime::Commit {
window,
value: "é".into(),
});
key(&mut app, window, Key::Enter, None);
assert_eq!(value(&app, input), "é");
assert_eq!(app.world().resource::<UiFocus>().input, Some(input));
key(&mut app, window, Key::Enter, None);
assert!(value(&app, input).is_empty());
assert_eq!(app.world().resource::<UiForm>().get("chat"), "é");
}
#[test]
fn native_chat_mouse_selection_and_length_limits() {
let (mut app, window, input) = app();
key(
&mut app,
window,
Key::Character("hello world".into()),
Some("hello world"),
);
let target = RenderTarget::Window(WindowRef::Entity(window))
.normalize(Some(window))
.unwrap();
app.world_mut().trigger(Pointer::new(
PointerId::Mouse,
Location {
target: target.clone(),
position: Vec2::ZERO,
},
Press {
button: PointerButton::Primary,
hit: HitData::new(window, 0.0, None, None),
count: 1,
},
input,
));
assert!(
!app.world()
.get::<EditableText>(input)
.unwrap()
.pending_edits
.is_empty()
);
app.update();
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.focus()
.index(),
0
);
app.world_mut().trigger(Pointer::new(
PointerId::Mouse,
Location {
target,
position: Vec2::splat(10_000.0),
},
Drag {
button: PointerButton::Primary,
distance: Vec2::splat(10_000.0),
delta: Vec2::splat(10_000.0),
},
input,
));
app.update();
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.text_range(),
0..11
);
key(
&mut app,
window,
Key::Character("replacement".into()),
Some("replacement"),
);
assert_eq!(value(&app, input), "replacement");
let too_long = "x".repeat(201);
key(
&mut app,
window,
Key::Character(too_long.clone().into()),
Some(&too_long),
);
assert_eq!(value(&app, input), "replacement");
}
#[test]
fn native_chat_system_clipboard() {
if std::env::var("STATION_TEST_CLIPBOARD").as_deref() != Ok("1") {
eprintln!("STATION_TEST_CLIPBOARD not set; skipping system clipboard test");
return;
}
let (mut app, window, input) = app();
app.world_mut()
.resource_mut::<Clipboard>()
.set_text("paste me")
.unwrap();
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::Character("v".into()), None);
assert_eq!(value(&app, input), "paste me");
key(&mut app, window, Key::Character("a".into()), None);
key(&mut app, window, Key::Character("x".into()), None);
assert!(value(&app, input).is_empty());
assert_eq!(
app.world_mut()
.resource_mut::<Clipboard>()
.fetch_text()
.poll_result()
.unwrap()
.unwrap(),
"paste me"
);
key(&mut app, window, Key::Character("v".into()), None);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
key(&mut app, window, Key::Character("!".into()), Some("!"));
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::Character("a".into()), None);
key(&mut app, window, Key::Character("c".into()), None);
assert_eq!(
app.world_mut()
.resource_mut::<Clipboard>()
.fetch_text()
.poll_result()
.unwrap()
.unwrap(),
"paste me!"
);
}
#[test]
fn history_preserves_draft_and_is_bounded() {
let mut history = ChatHistory::default();
history.remember("first");
history.remember("second");
history.remember("second");
assert_eq!(history.entries.len(), 2);
assert_eq!(history.navigate("draft", true).as_deref(), Some("second"));
assert_eq!(history.navigate("edited", true).as_deref(), Some("first"));
assert_eq!(history.navigate("", true).as_deref(), Some("first"));
assert_eq!(history.navigate("", false).as_deref(), Some("second"));
assert_eq!(history.navigate("", false).as_deref(), Some("draft"));
assert_eq!(history.navigate("", false), None);
for i in 0..HISTORY_LIMIT + 1 {
history.remember(&i.to_string());
}
assert_eq!(history.entries.len(), HISTORY_LIMIT);
assert_eq!(history.entries.front().map(String::as_str), Some("1"));
}
fn player(admin: bool) -> CommandSource {
CommandSource::Player {
client: ClientId::Server,
admin,
owner: false,
}
}
#[test]
fn completion_uses_permissions_and_supports_subcommands_and_suffixes() {
let mut history = ChatHistory::default();
assert_eq!(history.complete("/exp", 4, player(false), false), None);
assert_eq!(
history.complete("/exp", 4, player(true), false),
Some(("/export".into(), 7))
);
assert_eq!(
history.complete("/imp file.tmx", 4, player(true), false),
Some(("/import file.tmx".into(), 7))
);
assert_eq!(
history.complete("/export file.tmx", 4, player(true), false),
Some(("/export file.tmx".into(), 7))
);
assert_eq!(
history.complete("/export file.tmx", 7, player(false), false),
None
);
assert_eq!(
history.complete("/whitelist a", 12, player(true), false),
Some(("/whitelist add".into(), 14))
);
assert_eq!(history.complete("hello", 5, player(true), false), None);
assert_eq!(history.complete("/export ", 8, player(true), false), None);
}
#[test]
fn completion_cycles_in_both_directions() {
let mut history = ChatHistory::default();
assert_eq!(
history.complete("/s", 2, CommandSource::Console, false),
Some(("/save".into(), 5))
);
assert_eq!(
history.complete("/save", 5, CommandSource::Console, false),
Some(("/say".into(), 4))
);
assert_eq!(
history.complete("/say", 4, CommandSource::Console, true),
Some(("/save".into(), 5))
);
}
}

View File

@ -3,6 +3,7 @@
//! to [`UiBindings`], inputs and checkboxes write to [`UiForm`] and anything //! to [`UiBindings`], inputs and checkboxes write to [`UiForm`] and anything
//! with a [`Tooltip`] shows it on hover. //! with a [`Tooltip`] shows it on hover.
pub mod chat;
pub mod layout; pub mod layout;
use std::{ use std::{
@ -17,6 +18,7 @@ use bevy::{
keyboard::{Key, KeyboardInput}, keyboard::{Key, KeyboardInput},
}, },
prelude::*, prelude::*,
text::{EditableText, EditableTextFilter, TextCursorStyle},
ui::FocusPolicy, ui::FocusPolicy,
window::PrimaryWindow, window::PrimaryWindow,
}; };
@ -28,6 +30,7 @@ pub struct UiFrameworkPlugin;
impl Plugin for UiFrameworkPlugin { impl Plugin for UiFrameworkPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(layout::load_theme()) app.insert_resource(layout::load_theme())
.init_resource::<chat::ChatHistory>()
.init_resource::<UiBindings>() .init_resource::<UiBindings>()
.init_resource::<UiForm>() .init_resource::<UiForm>()
.init_resource::<UiFocus>() .init_resource::<UiFocus>()
@ -37,6 +40,7 @@ impl Plugin for UiFrameworkPlugin {
Update, Update,
( (
text_editing.in_set(TextEditSystems), text_editing.in_set(TextEditSystems),
chat::edit.after(text_editing).in_set(TextEditSystems),
( (
clicks, clicks,
button_visuals, button_visuals,
@ -314,7 +318,7 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity,
} }
UiNode::Input(i) => { UiNode::Input(i) => {
let value = ctx.bindings.resolve(&i.value); let value = ctx.bindings.resolve(&i.value);
ctx.form.0.insert(i.id.clone(), value); ctx.form.0.insert(i.id.clone(), value.clone());
let mut entity = commands.spawn(( let mut entity = commands.spawn((
ChildOf(parent), ChildOf(parent),
TextInput { TextInput {
@ -335,8 +339,31 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity,
}, },
BackgroundColor(theme.color("input")), BackgroundColor(theme.color("input")),
BorderColor::all(theme.color("border")), BorderColor::all(theme.color("border")),
children![ctx.text("", theme.font_size, "text")],
)); ));
if i.id == "chat" {
entity.insert((
EditableText {
max_characters: Some(i.max_len),
allow_newlines: false,
visible_lines: Some(1.0),
..EditableText::new(&value)
},
EditableTextFilter::new(|c| !c.is_control()),
TextLayout::no_wrap(),
TextFont::from_font_size(theme.font_size),
TextColor(theme.color("text")),
TextCursorStyle {
color: theme.color("text"),
selection_color: theme.color("accent").with_alpha(0.5),
..default()
},
chat::ChatEditor::new(value),
));
} else {
entity.with_children(|parent| {
parent.spawn(ctx.text("", theme.font_size, "text"));
});
}
common(&mut entity, &i.tooltip, &i.visible); common(&mut entity, &i.tooltip, &i.visible);
} }
UiNode::Checkbox(c) => { UiNode::Checkbox(c) => {
@ -578,18 +605,28 @@ fn text_editing(
mut keys: MessageReader<KeyboardInput>, mut keys: MessageReader<KeyboardInput>,
mut focus: ResMut<UiFocus>, mut focus: ResMut<UiFocus>,
mut form: ResMut<UiForm>, mut form: ResMut<UiForm>,
inputs: Query<(Entity, &TextInput, &InheritedVisibility, &ComputedNode)>, inputs: Query<(
Entity,
&TextInput,
&InheritedVisibility,
&ComputedNode,
Has<EditableText>,
)>,
) { ) {
focus.consumed = false; focus.consumed = false;
let Some(entity) = focus.input else { let Some(entity) = focus.input else {
keys.clear(); keys.clear();
return; return;
}; };
let Ok((_, input, ..)) = inputs.get(entity) else { let Ok((_, input, _, _, native)) = inputs.get(entity) else {
focus.input = None; focus.input = None;
keys.clear(); keys.clear();
return; return;
}; };
if native {
focus.consumed = keys.read().any(|key| key.state == ButtonState::Pressed);
return;
}
for key in keys.read() { for key in keys.read() {
if key.state != ButtonState::Pressed { if key.state != ButtonState::Pressed {
continue; continue;
@ -615,7 +652,7 @@ fn text_editing(
// Next visible input in spawn order, wrapping around. // Next visible input in spawn order, wrapping around.
let mut visible: Vec<Entity> = inputs let mut visible: Vec<Entity> = inputs
.iter() .iter()
.filter(|(_, _, v, node)| v.get() && node.size() != Vec2::ZERO) .filter(|(_, _, v, node, _)| v.get() && node.size() != Vec2::ZERO)
.map(|(e, ..)| e) .map(|(e, ..)| e)
.collect(); .collect();
visible.sort(); visible.sort();
@ -642,11 +679,17 @@ fn input_visuals(
time: Res<Time>, time: Res<Time>,
form: Res<UiForm>, form: Res<UiForm>,
focus: Res<UiFocus>, focus: Res<UiFocus>,
mut inputs: Query<(Entity, &TextInput, &Children, &mut BorderColor)>, mut inputs: Query<(
Entity,
&TextInput,
Option<&Children>,
&mut BorderColor,
Has<EditableText>,
)>,
mut texts: Query<(&mut Text, &mut TextColor)>, mut texts: Query<(&mut Text, &mut TextColor)>,
) { ) {
let caret = ((time.elapsed_secs() * 2.0) as u32).is_multiple_of(2); let caret = ((time.elapsed_secs() * 2.0) as u32).is_multiple_of(2);
for (entity, input, children, mut border) in &mut inputs { for (entity, input, children, mut border, native) in &mut inputs {
let focused = focus.input == Some(entity); let focused = focus.input == Some(entity);
let value = form.get(&input.id); let value = form.get(&input.id);
let (mut shown, color) = if value.is_empty() && !focused { let (mut shown, color) = if value.is_empty() && !focused {
@ -663,7 +706,12 @@ fn input_visuals(
if *border != border_color { if *border != border_color {
*border = border_color; *border = border_color;
} }
if let Some(Ok((mut text, mut text_color))) = children.first().map(|c| texts.get_mut(*c)) { if native {
continue;
}
if let Some(Ok((mut text, mut text_color))) =
children.and_then(|c| c.first()).map(|c| texts.get_mut(*c))
{
if text.0 != shown { if text.0 != shown {
text.0 = shown; text.0 = shown;
} }