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:
parent
43051c9f5d
commit
4d7d777e86
|
|
@ -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
|
||||
terrain, buildings and ground items, including health, script variables, machine
|
||||
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
|
||||
joining (`--connect`), plus a dedicated `game-server`.
|
||||
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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,
|
||||
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
|
||||
|
||||
|
|
@ -146,6 +148,14 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
|
|||
|
||||
## 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 |
|
||||
|---------------------|------------------------------------------------------|
|
||||
| WASD / arrows | Move freely, also diagonally (you face that way) |
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
game_core = { 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_renet = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -37,17 +37,21 @@ impl Plugin for HudPlugin {
|
|||
})
|
||||
.add_observer(on_action)
|
||||
.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();
|
||||
*ui = default();
|
||||
*history = default();
|
||||
},
|
||||
)
|
||||
.add_systems(OnEnter(AppState::InGame), spawn_hud)
|
||||
.add_systems(PostUpdate, focus_chat.run_if(in_state(AppState::InGame)))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
hud_bindings,
|
||||
focus_chat,
|
||||
inventory_widget,
|
||||
skills_widget,
|
||||
log_widget,
|
||||
|
|
@ -172,13 +176,25 @@ fn focus_chat(
|
|||
mut focus: ResMut<UiFocus>,
|
||||
mut opened: Local<bool>,
|
||||
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);
|
||||
if ui.chat_open && !*opened {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -593,6 +609,7 @@ fn on_action(
|
|||
state: Res<State<AppState>>,
|
||||
mut ui: ResMut<UiState>,
|
||||
mut form: ResMut<UiForm>,
|
||||
mut history: ResMut<ui::chat::ChatHistory>,
|
||||
machine: Res<OpenMachine>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<(&GridPos, &Facing, &Inventory)>,
|
||||
|
|
@ -642,6 +659,7 @@ fn on_action(
|
|||
form.0.insert("chat".into(), String::new());
|
||||
ui.chat_open = false;
|
||||
if !text.is_empty() {
|
||||
history.remember(&text);
|
||||
commands.client_trigger(Chat { text });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
//! to [`UiBindings`], inputs and checkboxes write to [`UiForm`] and anything
|
||||
//! with a [`Tooltip`] shows it on hover.
|
||||
|
||||
pub mod chat;
|
||||
pub mod layout;
|
||||
|
||||
use std::{
|
||||
|
|
@ -17,6 +18,7 @@ use bevy::{
|
|||
keyboard::{Key, KeyboardInput},
|
||||
},
|
||||
prelude::*,
|
||||
text::{EditableText, EditableTextFilter, TextCursorStyle},
|
||||
ui::FocusPolicy,
|
||||
window::PrimaryWindow,
|
||||
};
|
||||
|
|
@ -28,6 +30,7 @@ pub struct UiFrameworkPlugin;
|
|||
impl Plugin for UiFrameworkPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(layout::load_theme())
|
||||
.init_resource::<chat::ChatHistory>()
|
||||
.init_resource::<UiBindings>()
|
||||
.init_resource::<UiForm>()
|
||||
.init_resource::<UiFocus>()
|
||||
|
|
@ -37,6 +40,7 @@ impl Plugin for UiFrameworkPlugin {
|
|||
Update,
|
||||
(
|
||||
text_editing.in_set(TextEditSystems),
|
||||
chat::edit.after(text_editing).in_set(TextEditSystems),
|
||||
(
|
||||
clicks,
|
||||
button_visuals,
|
||||
|
|
@ -314,7 +318,7 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity,
|
|||
}
|
||||
UiNode::Input(i) => {
|
||||
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((
|
||||
ChildOf(parent),
|
||||
TextInput {
|
||||
|
|
@ -335,8 +339,31 @@ pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity,
|
|||
},
|
||||
BackgroundColor(theme.color("input")),
|
||||
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);
|
||||
}
|
||||
UiNode::Checkbox(c) => {
|
||||
|
|
@ -578,18 +605,28 @@ fn text_editing(
|
|||
mut keys: MessageReader<KeyboardInput>,
|
||||
mut focus: ResMut<UiFocus>,
|
||||
mut form: ResMut<UiForm>,
|
||||
inputs: Query<(Entity, &TextInput, &InheritedVisibility, &ComputedNode)>,
|
||||
inputs: Query<(
|
||||
Entity,
|
||||
&TextInput,
|
||||
&InheritedVisibility,
|
||||
&ComputedNode,
|
||||
Has<EditableText>,
|
||||
)>,
|
||||
) {
|
||||
focus.consumed = false;
|
||||
let Some(entity) = focus.input else {
|
||||
keys.clear();
|
||||
return;
|
||||
};
|
||||
let Ok((_, input, ..)) = inputs.get(entity) else {
|
||||
let Ok((_, input, _, _, native)) = inputs.get(entity) else {
|
||||
focus.input = None;
|
||||
keys.clear();
|
||||
return;
|
||||
};
|
||||
if native {
|
||||
focus.consumed = keys.read().any(|key| key.state == ButtonState::Pressed);
|
||||
return;
|
||||
}
|
||||
for key in keys.read() {
|
||||
if key.state != ButtonState::Pressed {
|
||||
continue;
|
||||
|
|
@ -615,7 +652,7 @@ fn text_editing(
|
|||
// Next visible input in spawn order, wrapping around.
|
||||
let mut visible: Vec<Entity> = inputs
|
||||
.iter()
|
||||
.filter(|(_, _, v, node)| v.get() && node.size() != Vec2::ZERO)
|
||||
.filter(|(_, _, v, node, _)| v.get() && node.size() != Vec2::ZERO)
|
||||
.map(|(e, ..)| e)
|
||||
.collect();
|
||||
visible.sort();
|
||||
|
|
@ -642,11 +679,17 @@ fn input_visuals(
|
|||
time: Res<Time>,
|
||||
form: Res<UiForm>,
|
||||
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)>,
|
||||
) {
|
||||
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 value = form.get(&input.id);
|
||||
let (mut shown, color) = if value.is_empty() && !focused {
|
||||
|
|
@ -663,7 +706,12 @@ fn input_visuals(
|
|||
if *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 {
|
||||
text.0 = shown;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue