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
|
- 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`.
|
||||||
|
|
|
||||||
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,
|
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) |
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue