diff --git a/CHANGELOG.md b/CHANGELOG.md index 182145af63..ed37e552b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cultists clothing - You can start the game by pressing "enter" from the character selection menu - Added server-side character saving +- Added tab completion in chat for player names and chat commands ### Changed @@ -87,6 +88,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rewrote the humanoid skeleton to be more ideal for attack animations - Arrows can no longer hurt their owners - Increased overall character scale +- `/sudo player /tp` is short for `/sudo player /tp me` +- The `/object` command can create any object in comp::object::Body +- The `/help` command takes an optional argument. `/help /sudo` will show you information about only the sudo command. ### Removed diff --git a/client/src/cmd.rs b/client/src/cmd.rs new file mode 100644 index 0000000000..9b05f9b779 --- /dev/null +++ b/client/src/cmd.rs @@ -0,0 +1,121 @@ +use crate::Client; +use common::cmd::*; + +trait TabComplete { + fn complete(&self, part: &str, client: &Client) -> Vec; +} + +impl TabComplete for ArgumentSpec { + fn complete(&self, part: &str, client: &Client) -> Vec { + match self { + ArgumentSpec::PlayerName(_) => complete_player(part, &client), + ArgumentSpec::Float(_, x, _) => { + if part.is_empty() { + vec![format!("{:.1}", x)] + } else { + vec![] + } + }, + ArgumentSpec::Integer(_, x, _) => { + if part.is_empty() { + vec![format!("{}", x)] + } else { + vec![] + } + }, + ArgumentSpec::Any(_, _) => vec![], + ArgumentSpec::Command(_) => complete_command(part), + ArgumentSpec::Message => complete_player(part, &client), + ArgumentSpec::SubCommand => complete_command(part), + ArgumentSpec::Enum(_, strings, _) => strings + .iter() + .filter(|string| string.starts_with(part)) + .map(|c| c.to_string()) + .collect(), + } + } +} + +fn complete_player(part: &str, client: &Client) -> Vec { + client + .player_list + .values() + .filter(|alias| alias.starts_with(part)) + .cloned() + .collect() +} + +fn complete_command(part: &str) -> Vec { + CHAT_COMMANDS + .iter() + .map(|com| com.keyword()) + .filter(|kwd| kwd.starts_with(part) || format!("/{}", kwd).starts_with(part)) + .map(|c| format!("/{}", c)) + .collect() +} + +// Get the byte index of the nth word. Used in completing "/sudo p /subcmd" +fn nth_word(line: &str, n: usize) -> Option { + let mut is_space = false; + let mut j = 0; + for (i, c) in line.char_indices() { + match (is_space, c.is_whitespace()) { + (true, true) => {}, + (true, false) => { + is_space = false; + j += 1; + }, + (false, true) => { + is_space = true; + }, + (false, false) => {}, + } + if j == n { + return Some(i); + } + } + return None; +} + +pub fn complete(line: &str, client: &Client) -> Vec { + let word = if line.chars().last().map_or(true, char::is_whitespace) { + "" + } else { + line.split_whitespace().last().unwrap_or("") + }; + if line.chars().next() == Some('/') { + let mut iter = line.split_whitespace(); + let cmd = iter.next().unwrap(); + let i = iter.count() + if word.is_empty() { 1 } else { 0 }; + if i == 0 { + // Completing chat command name + complete_command(word) + } else { + if let Ok(cmd) = cmd.parse::() { + if let Some(arg) = cmd.data().args.get(i - 1) { + // Complete ith argument + arg.complete(word, &client) + } else { + // Complete past the last argument + match cmd.data().args.last() { + Some(ArgumentSpec::SubCommand) => { + if let Some(index) = nth_word(line, cmd.data().args.len()) { + complete(&line[index..], &client) + } else { + vec![] + } + }, + Some(ArgumentSpec::Message) => complete_player(word, &client), + _ => vec![], // End of command. Nothing to complete + } + } + } else { + // Completing for unknown chat command + complete_player(word, &client) + } + } + } else { + // Not completing a command + complete_player(word, &client) + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index df4b3bd8bb..3c57766420 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_code)] #![feature(label_break_value)] +pub mod cmd; pub mod error; // Reexports diff --git a/common/src/assets/mod.rs b/common/src/assets/mod.rs index b7a3e63627..003ec94a54 100644 --- a/common/src/assets/mod.rs +++ b/common/src/assets/mod.rs @@ -12,7 +12,7 @@ use std::{ fmt, fs::{self, File, ReadDir}, io::{BufReader, Read}, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, RwLock}, }; @@ -57,8 +57,33 @@ impl From for Error { lazy_static! { /// The HashMap where all loaded assets are stored in. - static ref ASSETS: RwLock>> = + pub static ref ASSETS: RwLock>> = RwLock::new(HashMap::new()); + + /// List of item specifiers. Useful for tab completing + pub static ref ITEM_SPECS: Vec = { + let base = ASSETS_PATH.join("common").join("items"); + let mut items = vec![]; + fn list_items (path: &Path, base: &Path, mut items: &mut Vec) -> std::io::Result<()>{ + for entry in std::fs::read_dir(path)? { + let path = entry?.path(); + if path.is_dir(){ + list_items(&path, &base, &mut items)?; + } else { + if let Ok(path) = path.strip_prefix(base) { + let path = path.to_string_lossy().trim_end_matches(".ron").replace('/', "."); + items.push(path); + } + } + } + Ok(()) + } + if list_items(&base, &ASSETS_PATH, &mut items).is_err() { + warn!("There was a problem listing some item assets"); + } + items.sort(); + items + }; } // TODO: Remove this function. It's only used in world/ in a really ugly way.To diff --git a/common/src/cmd.rs b/common/src/cmd.rs new file mode 100644 index 0000000000..be265b5203 --- /dev/null +++ b/common/src/cmd.rs @@ -0,0 +1,429 @@ +use crate::{assets, comp, npc}; +use lazy_static::lazy_static; +use std::{ops::Deref, str::FromStr}; + +/// Struct representing a command that a user can run from server chat. +pub struct ChatCommandData { + /// A format string for parsing arguments. + pub args: Vec, + /// A one-line message that explains what the command does + pub description: &'static str, + /// A boolean that is used to check whether the command requires + /// administrator permissions or not. + pub needs_admin: bool, +} + +impl ChatCommandData { + pub fn new(args: Vec, description: &'static str, needs_admin: bool) -> Self { + Self { + args, + description, + needs_admin, + } + } +} + +// Please keep this sorted alphabetically :-) +#[derive(Copy, Clone)] +pub enum ChatCommand { + Adminify, + Alias, + Build, + Debug, + DebugColumn, + Explosion, + GiveExp, + GiveItem, + Goto, + Health, + Help, + Jump, + Kill, + KillNpcs, + Lantern, + Light, + Object, + Players, + RemoveLights, + SetLevel, + Spawn, + Sudo, + Tell, + Time, + Tp, + Version, + Waypoint, +} + +// Thank you for keeping this sorted alphabetically :-) +pub static CHAT_COMMANDS: &'static [ChatCommand] = &[ + ChatCommand::Adminify, + ChatCommand::Alias, + ChatCommand::Build, + ChatCommand::Debug, + ChatCommand::DebugColumn, + ChatCommand::Explosion, + ChatCommand::GiveExp, + ChatCommand::GiveItem, + ChatCommand::Goto, + ChatCommand::Health, + ChatCommand::Help, + ChatCommand::Jump, + ChatCommand::Kill, + ChatCommand::KillNpcs, + ChatCommand::Lantern, + ChatCommand::Light, + ChatCommand::Object, + ChatCommand::Players, + ChatCommand::RemoveLights, + ChatCommand::SetLevel, + ChatCommand::Spawn, + ChatCommand::Sudo, + ChatCommand::Tell, + ChatCommand::Time, + ChatCommand::Tp, + ChatCommand::Version, + ChatCommand::Waypoint, +]; + +lazy_static! { + static ref ALIGNMENTS: Vec = vec!["wild", "enemy", "npc", "pet"] + .iter() + .map(|s| s.to_string()) + .collect(); + static ref ENTITIES: Vec = { + let npc_names = &*npc::NPC_NAMES; + npc::ALL_NPCS + .iter() + .map(|&npc| npc_names[npc].keyword.clone()) + .collect() + }; + static ref OBJECTS: Vec = comp::object::ALL_OBJECTS + .iter() + .map(|o| o.to_string().to_string()) + .collect(); + static ref TIMES: Vec = vec![ + "midnight", "night", "dawn", "morning", "day", "noon", "dusk" + ] + .iter() + .map(|s| s.to_string()) + .collect(); +} + +impl ChatCommand { + pub fn data(&self) -> ChatCommandData { + use ArgumentSpec::*; + use Requirement::*; + let cmd = ChatCommandData::new; + match self { + ChatCommand::Adminify => cmd( + vec![PlayerName(Required)], + "Temporarily gives a player admin permissions or removes them", + true, + ), + ChatCommand::Alias => cmd(vec![Any("name", Required)], "Change your alias", false), + ChatCommand::Build => cmd(vec![], "Toggles build mode on and off", true), + ChatCommand::Debug => cmd(vec![], "Place all debug items into your pack.", true), + ChatCommand::DebugColumn => cmd( + vec![Integer("x", 15000, Required), Integer("y", 15000, Required)], + "Prints some debug information about a column", + false, + ), + ChatCommand::Explosion => cmd( + vec![Float("radius", 5.0, Required)], + "Explodes the ground around you", + true, + ), + ChatCommand::GiveExp => cmd( + vec![Integer("amount", 50, Required)], + "Give experience to yourself", + true, + ), + ChatCommand::GiveItem => cmd( + vec![ + Enum("item", assets::ITEM_SPECS.clone(), Required), + Integer("num", 1, Optional), + ], + "Give yourself some items", + true, + ), + ChatCommand::Goto => cmd( + vec![ + Float("x", 0.0, Required), + Float("y", 0.0, Required), + Float("z", 0.0, Required), + ], + "Teleport to a position", + true, + ), + ChatCommand::Health => cmd( + vec![Integer("hp", 100, Required)], + "Set your current health", + true, + ), + ChatCommand::Help => ChatCommandData::new( + vec![Command(Optional)], + "Display information about commands", + false, + ), + ChatCommand::Jump => cmd( + vec![ + Float("x", 0.0, Required), + Float("y", 0.0, Required), + Float("z", 0.0, Required), + ], + "Offset your current position", + true, + ), + ChatCommand::Kill => cmd(vec![], "Kill yourself", false), + ChatCommand::KillNpcs => cmd(vec![], "Kill the NPCs", true), + ChatCommand::Lantern => cmd( + vec![ + Float("strength", 5.0, Required), + Float("r", 1.0, Optional), + Float("g", 1.0, Optional), + Float("b", 1.0, Optional), + ], + "Change your lantern's strength and color", + true, + ), + ChatCommand::Light => cmd( + vec![ + Float("r", 1.0, Optional), + Float("g", 1.0, Optional), + Float("b", 1.0, Optional), + Float("x", 0.0, Optional), + Float("y", 0.0, Optional), + Float("z", 0.0, Optional), + Float("strength", 5.0, Optional), + ], + "Spawn entity with light", + true, + ), + ChatCommand::Object => cmd( + vec![Enum("object", OBJECTS.clone(), Required)], + "Spawn an object", + true, + ), + ChatCommand::Players => cmd(vec![], "Lists players currently online", false), + ChatCommand::RemoveLights => cmd( + vec![Float("radius", 20.0, Optional)], + "Removes all lights spawned by players", + true, + ), + ChatCommand::SetLevel => cmd( + vec![Integer("level", 10, Required)], + "Set player Level", + true, + ), + ChatCommand::Spawn => cmd( + vec![ + Enum("alignment", ALIGNMENTS.clone(), Required), + Enum("entity", ENTITIES.clone(), Required), + Integer("amount", 1, Optional), + ], + "Spawn a test entity", + true, + ), + ChatCommand::Sudo => cmd( + vec![PlayerName(Required), SubCommand], + "Run command as if you were another player", + true, + ), + ChatCommand::Tell => cmd( + vec![PlayerName(Required), Message], + "Send a message to another player", + false, + ), + ChatCommand::Time => cmd( + vec![Enum("time", TIMES.clone(), Optional)], + "Set the time of day", + true, + ), + ChatCommand::Tp => cmd( + vec![PlayerName(Optional)], + "Teleport to another player", + true, + ), + ChatCommand::Version => cmd(vec![], "Prints server version", false), + ChatCommand::Waypoint => { + cmd(vec![], "Set your waypoint to your current position", true) + }, + } + } + + pub fn keyword(&self) -> &'static str { + match self { + ChatCommand::Adminify => "adminify", + ChatCommand::Alias => "alias", + ChatCommand::Build => "build", + ChatCommand::Debug => "debug", + ChatCommand::DebugColumn => "debug_column", + ChatCommand::Explosion => "explosion", + ChatCommand::GiveExp => "give_exp", + ChatCommand::GiveItem => "give_item", + ChatCommand::Goto => "goto", + ChatCommand::Health => "health", + ChatCommand::Help => "help", + ChatCommand::Jump => "jump", + ChatCommand::Kill => "kill", + ChatCommand::KillNpcs => "kill_npcs", + ChatCommand::Lantern => "lantern", + ChatCommand::Light => "light", + ChatCommand::Object => "object", + ChatCommand::Players => "players", + ChatCommand::RemoveLights => "remove_lights", + ChatCommand::SetLevel => "set_level", + ChatCommand::Spawn => "spawn", + ChatCommand::Sudo => "sudo", + ChatCommand::Tell => "tell", + ChatCommand::Time => "time", + ChatCommand::Tp => "tp", + ChatCommand::Version => "version", + ChatCommand::Waypoint => "waypoint", + } + } + + pub fn help_string(&self) -> String { + let data = self.data(); + let usage = std::iter::once(format!("/{}", self.keyword())) + .chain(data.args.iter().map(|arg| arg.usage_string())) + .collect::>() + .join(" "); + format!("{}: {}", usage, data.description) + } + + pub fn needs_admin(&self) -> bool { self.data().needs_admin } + + pub fn arg_fmt(&self) -> String { + self.data() + .args + .iter() + .map(|arg| match arg { + ArgumentSpec::PlayerName(_) => "{}", + ArgumentSpec::Float(_, _, _) => "{}", + ArgumentSpec::Integer(_, _, _) => "{d}", + ArgumentSpec::Any(_, _) => "{}", + ArgumentSpec::Command(_) => "{}", + ArgumentSpec::Message => "{/.*/}", + ArgumentSpec::SubCommand => "{} {/.*/}", + ArgumentSpec::Enum(_, _, _) => "{}", // TODO + }) + .collect::>() + .join(" ") + } +} + +impl FromStr for ChatCommand { + type Err = (); + + fn from_str(keyword: &str) -> Result { + let kwd = if keyword.chars().next() == Some('/') { + &keyword[1..] + } else { + &keyword[..] + }; + for c in CHAT_COMMANDS { + if kwd == c.keyword() { + return Ok(*c); + } + } + return Err(()); + } +} + +pub enum Requirement { + Required, + Optional, +} +impl Deref for Requirement { + type Target = bool; + + fn deref(&self) -> &bool { + match self { + Requirement::Required => &true, + Requirement::Optional => &false, + } + } +} + +/// Representation for chat command arguments +pub enum ArgumentSpec { + /// The argument refers to a player by alias + PlayerName(Requirement), + /// The argument is a float. The associated values are + /// * label + /// * suggested tab-completion + /// * whether it's optional + Float(&'static str, f32, Requirement), + /// The argument is a float. The associated values are + /// * label + /// * suggested tab-completion + /// * whether it's optional + Integer(&'static str, i32, Requirement), + /// The argument is any string that doesn't contain spaces + Any(&'static str, Requirement), + /// The argument is a command name (such as in /help) + Command(Requirement), + /// This is the final argument, consuming all characters until the end of + /// input. + Message, + /// This command is followed by another command (such as in /sudo) + SubCommand, + /// The argument is likely an enum. The associated values are + /// * label + /// * Predefined string completions + /// * whether it's optional + Enum(&'static str, Vec, Requirement), +} + +impl ArgumentSpec { + pub fn usage_string(&self) -> String { + match self { + ArgumentSpec::PlayerName(req) => { + if **req { + "".to_string() + } else { + "[player]".to_string() + } + }, + ArgumentSpec::Float(label, _, req) => { + if **req { + format!("<{}>", label) + } else { + format!("[{}]", label) + } + }, + ArgumentSpec::Integer(label, _, req) => { + if **req { + format!("<{}>", label) + } else { + format!("[{}]", label) + } + }, + ArgumentSpec::Any(label, req) => { + if **req { + format!("<{}>", label) + } else { + format!("[{}]", label) + } + }, + ArgumentSpec::Command(req) => { + if **req { + "<[/]command>".to_string() + } else { + "[[/]command]".to_string() + } + }, + ArgumentSpec::Message => "".to_string(), + ArgumentSpec::SubCommand => "<[/]command> [args...]".to_string(), + ArgumentSpec::Enum(label, _, req) => { + if **req { + format! {"<{}>", label} + } else { + format! {"[{}]", label} + } + }, + } + } +} diff --git a/common/src/comp/body/object.rs b/common/src/comp/body/object.rs index d179884062..e3121eb9a5 100644 --- a/common/src/comp/body/object.rs +++ b/common/src/comp/body/object.rs @@ -65,7 +65,7 @@ impl Body { } } -const ALL_OBJECTS: [Body; 52] = [ +pub const ALL_OBJECTS: [Body; 53] = [ Body::Arrow, Body::Bomb, Body::Scarecrow, @@ -114,8 +114,69 @@ const ALL_OBJECTS: [Body; 52] = [ Body::CarpetHumanSquare, Body::CarpetHumanSquare2, Body::CarpetHumanSquircle, + Body::Pouch, Body::CraftingBench, Body::BoltFire, Body::BoltFireBig, Body::ArrowSnake, ]; + +impl Body { + pub fn to_string(&self) -> &str { + match self { + Body::Arrow => "arrow", + Body::Bomb => "bomb", + Body::Scarecrow => "scarecrow", + Body::Cauldron => "cauldron", + Body::ChestVines => "chest_vines", + Body::Chest => "chest", + Body::ChestDark => "chest_dark", + Body::ChestDemon => "chest_demon", + Body::ChestGold => "chest_gold", + Body::ChestLight => "chest_light", + Body::ChestOpen => "chest_open", + Body::ChestSkull => "chest_skull", + Body::Pumpkin => "pumpkin", + Body::Pumpkin2 => "pumpkin_2", + Body::Pumpkin3 => "pumpkin_3", + Body::Pumpkin4 => "pumpkin_4", + Body::Pumpkin5 => "pumpkin_5", + Body::Campfire => "campfire", + Body::CampfireLit => "campfire_lit", + Body::LanternGround => "lantern_ground", + Body::LanternGroundOpen => "lantern_ground_open", + Body::LanternStanding => "lantern_standing", + Body::LanternStanding2 => "lantern_standing_2", + Body::PotionRed => "potion_red", + Body::PotionBlue => "potion_blue", + Body::PotionGreen => "potion_green", + Body::Crate => "crate", + Body::Tent => "tent", + Body::WindowSpooky => "window_spooky", + Body::DoorSpooky => "door_spooky", + Body::Anvil => "anvil", + Body::Gravestone => "gravestone", + Body::Gravestone2 => "gravestone_2", + Body::Bench => "bench", + Body::Chair => "chair", + Body::Chair2 => "chair_2", + Body::Chair3 => "chair_3", + Body::Table => "table", + Body::Table2 => "table_2", + Body::Table3 => "table_3", + Body::Drawer => "drawer", + Body::BedBlue => "bed_blue", + Body::Carpet => "carpet", + Body::Bedroll => "bedroll", + Body::CarpetHumanRound => "carpet_human_round", + Body::CarpetHumanSquare => "carpet_human_square", + Body::CarpetHumanSquare2 => "carpet_human_square_2", + Body::CarpetHumanSquircle => "carpet_human_squircle", + Body::Pouch => "pouch", + Body::CraftingBench => "crafting_bench", + Body::BoltFire => "bolt_fire", + Body::BoltFireBig => "bolt_fire_big", + Body::ArrowSnake => "arrow_snake", + } + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 1ea3df4c51..12600ed3d4 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,6 +16,7 @@ pub mod assets; pub mod astar; pub mod character; pub mod clock; +pub mod cmd; pub mod comp; pub mod effect; pub mod event; diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 9b63633afc..9beaf292c3 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -5,7 +5,9 @@ use crate::{Server, StateExt}; use chrono::{NaiveTime, Timelike}; use common::{ - assets, comp, + assets, + cmd::{ChatCommand, CHAT_COMMANDS}, + comp, event::{EventBus, ServerEvent}, msg::{PlayerListUpdate, ServerMsg}, npc::{self, get_npc_name}, @@ -20,274 +22,73 @@ use specs::{Builder, Entity as EcsEntity, Join, WorldExt}; use vek::*; use world::util::Sampler; -use lazy_static::lazy_static; use log::error; use scan_fmt::{scan_fmt, scan_fmt_some}; -/// Struct representing a command that a user can run from server chat. -pub struct ChatCommand { - /// The keyword used to invoke the command, omitting the leading '/'. - pub keyword: &'static str, - /// A format string for parsing arguments. - arg_fmt: &'static str, - /// A message that explains how the command is used. - help_string: &'static str, - /// A boolean that is used to check whether the command requires - /// administrator permissions or not. - needs_admin: bool, - /// Handler function called when the command is executed. - /// # Arguments - /// * `&mut Server` - the `Server` instance executing the command. - /// * `EcsEntity` - an `Entity` corresponding to the player that invoked the - /// command. - /// * `EcsEntity` - an `Entity` for the player on whom the command is - /// invoked. This differs from the previous argument when using /sudo - /// * `String` - a `String` containing the part of the command after the - /// keyword. - /// * `&ChatCommand` - the command to execute with the above arguments. - /// Handler functions must parse arguments from the the given `String` - /// (`scan_fmt!` is included for this purpose). - handler: fn(&mut Server, EcsEntity, EcsEntity, String, &ChatCommand), +pub trait ChatCommandExt { + fn execute(&self, server: &mut Server, entity: EcsEntity, args: String); } - -impl ChatCommand { - /// Creates a new chat command. - pub fn new( - keyword: &'static str, - arg_fmt: &'static str, - help_string: &'static str, - needs_admin: bool, - handler: fn(&mut Server, EcsEntity, EcsEntity, String, &ChatCommand), - ) -> Self { - Self { - keyword, - arg_fmt, - help_string, - needs_admin, - handler, - } - } - - /// Calls the contained handler function, passing `&self` as the last - /// argument. - pub fn execute(&self, server: &mut Server, entity: EcsEntity, args: String) { - if self.needs_admin { - if !server.entity_is_admin(entity) { - server.notify_client( - entity, - ServerMsg::private(format!( - "You don't have permission to use '/{}'.", - self.keyword - )), - ); - return; - } else { - (self.handler)(server, entity, entity, args, self); - } +impl ChatCommandExt for ChatCommand { + fn execute(&self, server: &mut Server, entity: EcsEntity, args: String) { + let cmd_data = self.data(); + if cmd_data.needs_admin && !server.entity_is_admin(entity) { + server.notify_client( + entity, + ServerMsg::private(format!( + "You don't have permission to use '/{}'.", + self.keyword() + )), + ); + return; } else { - (self.handler)(server, entity, entity, args, self); + get_handler(self)(server, entity, entity, args, &self); } } } -lazy_static! { - /// Static list of chat commands available to the server. - pub static ref CHAT_COMMANDS: Vec = vec![ - ChatCommand::new( - "give_item", - "{} {d}", - "/give_item [num]\n\ - Example items: common/items/apple, common/items/debug/boost", - true, - handle_give,), - ChatCommand::new( - "jump", - "{d} {d} {d}", - "/jump : Offset your current position", - true, - handle_jump, - ), - ChatCommand::new( - "goto", - "{d} {d} {d}", - "/goto : Teleport to a position", - true, - handle_goto, - ), - ChatCommand::new( - "alias", - "{}", - "/alias : Change your alias", - false, - handle_alias, - ), - ChatCommand::new( - "tp", - "{}", - "/tp : Teleport to another player", - true, - handle_tp, - ), - ChatCommand::new( - "kill", - "{}", - "/kill : Kill yourself", - false, - handle_kill, - ), - ChatCommand::new( - "time", - "{} {s}", - "/time or [Time of day] : Set the time of day", - true, - handle_time, - ), - ChatCommand::new( - "spawn", - "{} {} {d}", - "/spawn [amount] : Spawn a test entity", - true, - handle_spawn, - ), - ChatCommand::new( - "players", - "{}", - "/players : Lists players currently online", - false, - handle_players, - ), - ChatCommand::new( - "help", "", "/help: Display this message", false, handle_help), - ChatCommand::new( - "health", - "{}", - "/health : Set your current health", - true, - handle_health, - ), - ChatCommand::new( - "build", - "", - "/build : Toggles build mode on and off", - true, - handle_build, - ), - ChatCommand::new( - "tell", - "{}", - "/tell : Send a message to another player", - false, - handle_tell, - ), - ChatCommand::new( - "killnpcs", - "{}", - "/killnpcs : Kill the NPCs", - true, - handle_killnpcs, - ), - ChatCommand::new( - "object", - "{}", - "/object [Name]: Spawn an object", - true, - handle_object, - ), - ChatCommand::new( - "light", - "{} {} {} {} {} {} {}", - "/light > < > <>>: Spawn entity with light", - true, - handle_light, - ), - ChatCommand::new( - "lantern", - "{} {} {} {}", - "/lantern [ ]: Change your lantern's strength and color", - true, - handle_lantern, - ), - ChatCommand::new( - "explosion", - "{}", - "/explosion : Explodes the ground around you", - true, - handle_explosion, - ), - ChatCommand::new( - "waypoint", - "{}", - "/waypoint : Set your waypoint to your current position", - true, - handle_waypoint, - ), - ChatCommand::new( - "adminify", - "{}", - "/adminify : Temporarily gives a player admin permissions or removes them", - true, - handle_adminify, - ), - ChatCommand::new( - "debug_column", - "{} {}", - "/debug_column : Prints some debug information about a column", - false, - handle_debug_column, - ), - ChatCommand::new( - "give_exp", - "{d} {}", - "/give_exp : Give experience to yourself or specify a target player", - true, - handle_exp, - ), - ChatCommand::new( - "set_level", - "{d} {}", - "/set_level : Set own Level or specify a target player", - true, - handle_level - ), - ChatCommand::new( - "removelights", - "{}", - "/removelights [radius] : Removes all lights spawned by players", - true, - handle_remove_lights, - ), - ChatCommand::new( - "debug", - "", - "/debug : Place all debug items into your pack.", - true, - handle_debug, - ), - ChatCommand::new( - "sudo", - "{} {} {/.*/}", - "/sudo / [args...] : Run command as if you were another player", - true, - handle_sudo, - ), - ChatCommand::new( - "version", - "", - "/version : Prints server version", - false, - handle_version, - ), - ]; +type CommandHandler = fn(&mut Server, EcsEntity, EcsEntity, String, &ChatCommand); +fn get_handler(cmd: &ChatCommand) -> CommandHandler { + match cmd { + ChatCommand::Adminify => handle_adminify, + ChatCommand::Alias => handle_alias, + ChatCommand::Build => handle_build, + ChatCommand::Debug => handle_debug, + ChatCommand::DebugColumn => handle_debug_column, + ChatCommand::Explosion => handle_explosion, + ChatCommand::GiveExp => handle_give_exp, + ChatCommand::GiveItem => handle_give_item, + ChatCommand::Goto => handle_goto, + ChatCommand::Health => handle_health, + ChatCommand::Help => handle_help, + ChatCommand::Jump => handle_jump, + ChatCommand::Kill => handle_kill, + ChatCommand::KillNpcs => handle_kill_npcs, + ChatCommand::Lantern => handle_lantern, + ChatCommand::Light => handle_light, + ChatCommand::Object => handle_object, + ChatCommand::Players => handle_players, + ChatCommand::RemoveLights => handle_remove_lights, + ChatCommand::SetLevel => handle_set_level, + ChatCommand::Spawn => handle_spawn, + ChatCommand::Sudo => handle_sudo, + ChatCommand::Tell => handle_tell, + ChatCommand::Time => handle_time, + ChatCommand::Tp => handle_tp, + ChatCommand::Version => handle_version, + ChatCommand::Waypoint => handle_waypoint, + } } -fn handle_give( +fn handle_give_item( server: &mut Server, client: EcsEntity, target: EcsEntity, args: String, action: &ChatCommand, ) { - if let (Some(item_name), give_amount_opt) = scan_fmt_some!(&args, action.arg_fmt, String, u32) { + if let (Some(item_name), give_amount_opt) = + scan_fmt_some!(&args, &action.arg_fmt(), String, u32) + { let give_amount = give_amount_opt.unwrap_or(1); if let Ok(item) = assets::load_cloned(&item_name) { let mut item: Item = item; @@ -346,7 +147,10 @@ fn handle_give( ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -357,7 +161,7 @@ fn handle_jump( args: String, action: &ChatCommand, ) { - if let Ok((x, y, z)) = scan_fmt!(&args, action.arg_fmt, f32, f32, f32) { + if let Ok((x, y, z)) = scan_fmt!(&args, &action.arg_fmt(), f32, f32, f32) { match server.state.read_component_cloned::(target) { Some(current_pos) => { server @@ -380,7 +184,7 @@ fn handle_goto( args: String, action: &ChatCommand, ) { - if let Ok((x, y, z)) = scan_fmt!(&args, action.arg_fmt, f32, f32, f32) { + if let Ok((x, y, z)) = scan_fmt!(&args, &action.arg_fmt(), f32, f32, f32) { if server .state .read_component_cloned::(target) @@ -397,7 +201,10 @@ fn handle_goto( ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -432,7 +239,7 @@ fn handle_time( args: String, action: &ChatCommand, ) { - let time = scan_fmt_some!(&args, action.arg_fmt, String); + let time = scan_fmt_some!(&args, &action.arg_fmt(), String); let new_time = match time.as_ref().map(|s| s.as_str()) { Some("midnight") => NaiveTime::from_hms(0, 0, 0), Some("night") => NaiveTime::from_hms(20, 0, 0), @@ -490,7 +297,7 @@ fn handle_health( args: String, action: &ChatCommand, ) { - if let Ok(hp) = scan_fmt!(&args, action.arg_fmt, u32) { + if let Ok(hp) = scan_fmt!(&args, &action.arg_fmt(), u32) { if let Some(stats) = server .state .ecs() @@ -519,7 +326,7 @@ fn handle_alias( args: String, action: &ChatCommand, ) { - if let Ok(alias) = scan_fmt!(&args, action.arg_fmt, String) { + if let Ok(alias) = scan_fmt!(&args, &action.arg_fmt(), String) { server .state .ecs_mut() @@ -540,7 +347,10 @@ fn handle_alias( server.state.notify_registered_clients(msg); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -551,41 +361,47 @@ fn handle_tp( args: String, action: &ChatCommand, ) { - if let Ok(alias) = scan_fmt!(&args, action.arg_fmt, String) { + let opt_player = if let Some(alias) = scan_fmt_some!(&args, &action.arg_fmt(), String) { let ecs = server.state.ecs(); - let opt_player = (&ecs.entities(), &ecs.read_storage::()) + (&ecs.entities(), &ecs.read_storage::()) .join() .find(|(_, player)| player.alias == alias) - .map(|(entity, _)| entity); - match server.state.read_component_cloned::(target) { - Some(_pos) => match opt_player { - Some(player) => match server.state.read_component_cloned::(player) { - Some(pos) => { - server.state.write_component(target, pos); - server.state.write_component(target, comp::ForceUpdate); - }, - None => server.notify_client( - client, - ServerMsg::private(format!("Unable to teleport to player '{}'!", alias)), - ), - }, - None => { - server.notify_client( - client, - ServerMsg::private(format!("Player '{}' not found!", alias)), - ); - server.notify_client( - client, - ServerMsg::private(String::from(action.help_string)), - ); - }, - }, - None => { - server.notify_client(client, ServerMsg::private(format!("You have no position!"))); - }, + .map(|(entity, _)| entity) + } else { + if client != target { + Some(client) + } else { + server.notify_client( + client, + ServerMsg::private("You must specify a player name".to_string()), + ); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); + return; + } + }; + if let Some(_pos) = server.state.read_component_cloned::(target) { + if let Some(player) = opt_player { + if let Some(pos) = server.state.read_component_cloned::(player) { + server.state.write_component(target, pos); + server.state.write_component(target, comp::ForceUpdate); + } else { + server.notify_client( + client, + ServerMsg::private(format!("Unable to teleport to player!")), + ); + } + } else { + server.notify_client(client, ServerMsg::private(format!("Player not found!"))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client(client, ServerMsg::private(format!("You have no position!"))); } } @@ -596,7 +412,7 @@ fn handle_spawn( args: String, action: &ChatCommand, ) { - match scan_fmt_some!(&args, action.arg_fmt, String, npc::NpcBody, String) { + match scan_fmt_some!(&args, &action.arg_fmt(), String, npc::NpcBody, String) { (Some(opt_align), Some(npc::NpcBody(id, mut body)), opt_amount) => { if let Some(alignment) = parse_alignment(target, &opt_align) { let amount = opt_amount @@ -659,7 +475,10 @@ fn handle_spawn( } }, _ => { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); }, } } @@ -733,12 +552,16 @@ fn handle_help( server: &mut Server, client: EcsEntity, _target: EcsEntity, - _args: String, - _action: &ChatCommand, + args: String, + action: &ChatCommand, ) { - for cmd in CHAT_COMMANDS.iter() { - if !cmd.needs_admin || server.entity_is_admin(client) { - server.notify_client(client, ServerMsg::private(String::from(cmd.help_string))); + if let Some(cmd) = scan_fmt_some!(&args, &action.arg_fmt(), ChatCommand) { + server.notify_client(client, ServerMsg::private(String::from(cmd.help_string()))); + } else { + for cmd in CHAT_COMMANDS.iter() { + if !cmd.needs_admin() || server.entity_is_admin(client) { + server.notify_client(client, ServerMsg::private(String::from(cmd.help_string()))); + } } } } @@ -753,7 +576,7 @@ fn parse_alignment(owner: EcsEntity, alignment: &str) -> Option } } -fn handle_killnpcs( +fn handle_kill_npcs( server: &mut Server, client: EcsEntity, _target: EcsEntity, @@ -781,9 +604,9 @@ fn handle_object( client: EcsEntity, target: EcsEntity, args: String, - _action: &ChatCommand, + action: &ChatCommand, ) { - let obj_type = scan_fmt!(&args, _action.arg_fmt, String); + let obj_type = scan_fmt!(&args, &action.arg_fmt(), String); let pos = server .state @@ -802,85 +625,39 @@ fn handle_object( .with(ori);*/ if let (Some(pos), Some(ori)) = (pos, ori) { let obj_str_res = obj_type.as_ref().map(String::as_str); - let obj_type = match obj_str_res { - Ok("scarecrow") => comp::object::Body::Scarecrow, - Ok("cauldron") => comp::object::Body::Cauldron, - Ok("chest_vines") => comp::object::Body::ChestVines, - Ok("chest") => comp::object::Body::Chest, - Ok("chest_dark") => comp::object::Body::ChestDark, - Ok("chest_demon") => comp::object::Body::ChestDemon, - Ok("chest_gold") => comp::object::Body::ChestGold, - Ok("chest_light") => comp::object::Body::ChestLight, - Ok("chest_open") => comp::object::Body::ChestOpen, - Ok("chest_skull") => comp::object::Body::ChestSkull, - Ok("pumpkin") => comp::object::Body::Pumpkin, - Ok("pumpkin_2") => comp::object::Body::Pumpkin2, - Ok("pumpkin_3") => comp::object::Body::Pumpkin3, - Ok("pumpkin_4") => comp::object::Body::Pumpkin4, - Ok("pumpkin_5") => comp::object::Body::Pumpkin5, - Ok("campfire") => comp::object::Body::Campfire, - Ok("campfire_lit") => comp::object::Body::CampfireLit, - Ok("lantern_ground") => comp::object::Body::LanternGround, - Ok("lantern_ground_open") => comp::object::Body::LanternGroundOpen, - Ok("lantern_2") => comp::object::Body::LanternStanding2, - Ok("lantern") => comp::object::Body::LanternStanding, - Ok("potion_blue") => comp::object::Body::PotionBlue, - Ok("potion_green") => comp::object::Body::PotionGreen, - Ok("potion_red") => comp::object::Body::PotionRed, - Ok("crate") => comp::object::Body::Crate, - Ok("tent") => comp::object::Body::Tent, - Ok("bomb") => comp::object::Body::Bomb, - Ok("window_spooky") => comp::object::Body::WindowSpooky, - Ok("door_spooky") => comp::object::Body::DoorSpooky, - Ok("carpet") => comp::object::Body::Carpet, - Ok("table_human") => comp::object::Body::Table, - Ok("table_human_2") => comp::object::Body::Table2, - Ok("table_human_3") => comp::object::Body::Table3, - Ok("drawer") => comp::object::Body::Drawer, - Ok("bed_human_blue") => comp::object::Body::BedBlue, - Ok("anvil") => comp::object::Body::Anvil, - Ok("gravestone") => comp::object::Body::Gravestone, - Ok("gravestone_2") => comp::object::Body::Gravestone2, - Ok("chair") => comp::object::Body::Chair, - Ok("chair_2") => comp::object::Body::Chair2, - Ok("chair_3") => comp::object::Body::Chair3, - Ok("bench_human") => comp::object::Body::Bench, - Ok("bedroll") => comp::object::Body::Bedroll, - Ok("carpet_human_round") => comp::object::Body::CarpetHumanRound, - Ok("carpet_human_square") => comp::object::Body::CarpetHumanSquare, - Ok("carpet_human_square_2") => comp::object::Body::CarpetHumanSquare2, - Ok("carpet_human_squircle") => comp::object::Body::CarpetHumanSquircle, - Ok("crafting_bench") => comp::object::Body::CraftingBench, - _ => { - return server.notify_client( - client, - ServerMsg::private(String::from("Object not found!")), - ); - }, - }; - server - .state - .create_object(pos, obj_type) - .with(comp::Ori( - // converts player orientation into a 90° rotation for the object by using the axis - // with the highest value - Dir::from_unnormalized(ori.0.map(|e| { - if e.abs() == ori.0.map(|e| e.abs()).reduce_partial_max() { - e - } else { - 0.0 - } - })) - .unwrap_or_default(), - )) - .build(); - server.notify_client( - client, - ServerMsg::private(format!( - "Spawned: {}", - obj_str_res.unwrap_or("") - )), - ); + if let Some(obj_type) = comp::object::ALL_OBJECTS + .iter() + .find(|o| Ok(o.to_string()) == obj_str_res) + { + server + .state + .create_object(pos, *obj_type) + .with(comp::Ori( + // converts player orientation into a 90° rotation for the object by using the + // axis with the highest value + Dir::from_unnormalized(ori.0.map(|e| { + if e.abs() == ori.0.map(|e| e.abs()).reduce_partial_max() { + e + } else { + 0.0 + } + })) + .unwrap_or_default(), + )) + .build(); + server.notify_client( + client, + ServerMsg::private(format!( + "Spawned: {}", + obj_str_res.unwrap_or("") + )), + ); + } else { + return server.notify_client( + client, + ServerMsg::private(String::from("Object not found!")), + ); + } } else { server.notify_client(client, ServerMsg::private(format!("You have no position!"))); } @@ -894,7 +671,7 @@ fn handle_light( action: &ChatCommand, ) { let (opt_r, opt_g, opt_b, opt_x, opt_y, opt_z, opt_s) = - scan_fmt_some!(&args, action.arg_fmt, f32, f32, f32, f32, f32, f32, f32); + scan_fmt_some!(&args, &action.arg_fmt(), f32, f32, f32, f32, f32, f32, f32); let mut light_emitter = comp::LightEmitter::default(); let mut light_offset_opt = None; @@ -955,7 +732,7 @@ fn handle_lantern( args: String, action: &ChatCommand, ) { - if let (Some(s), r, g, b) = scan_fmt_some!(&args, action.arg_fmt, f32, f32, f32, f32) { + if let (Some(s), r, g, b) = scan_fmt_some!(&args, &action.arg_fmt(), f32, f32, f32, f32) { if let Some(light) = server .state .ecs() @@ -987,7 +764,10 @@ fn handle_lantern( ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -998,7 +778,7 @@ fn handle_explosion( args: String, action: &ChatCommand, ) { - let power = scan_fmt!(&args, action.arg_fmt, f32).unwrap_or(8.0); + let power = scan_fmt!(&args, &action.arg_fmt(), f32).unwrap_or(8.0); if power > 512.0 { server.notify_client( @@ -1062,7 +842,7 @@ fn handle_adminify( args: String, action: &ChatCommand, ) { - if let Ok(alias) = scan_fmt!(&args, action.arg_fmt, String) { + if let Ok(alias) = scan_fmt!(&args, &action.arg_fmt(), String) { let ecs = server.state.ecs(); let opt_player = (&ecs.entities(), &ecs.read_storage::()) .join() @@ -1082,11 +862,17 @@ fn handle_adminify( client, ServerMsg::private(format!("Player '{}' not found!", alias)), ); - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); }, } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -1104,7 +890,7 @@ fn handle_tell( ); return; } - if let Ok(alias) = scan_fmt!(&args, action.arg_fmt, String) { + if let Ok(alias) = scan_fmt!(&args, &action.arg_fmt(), String) { let ecs = server.state.ecs(); let msg = &args[alias.len()..args.len()]; if let Some(player) = (&ecs.entities(), &ecs.read_storage::()) @@ -1152,7 +938,10 @@ fn handle_tell( ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -1180,7 +969,7 @@ fn handle_debug_column( ) { let sim = server.world.sim(); let sampler = server.world.sample_columns(); - if let Ok((x, y)) = scan_fmt!(&args, action.arg_fmt, i32, i32) { + if let Ok((x, y)) = scan_fmt!(&args, &action.arg_fmt(), i32, i32) { let wpos = Vec2::new(x, y); /* let chunk_pos = wpos.map2(TerrainChunkSize::RECT_SIZE, |e, sz: u32| { e / sz as i32 @@ -1244,7 +1033,10 @@ spawn_rate {:?} "#, ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } @@ -1264,14 +1056,14 @@ fn find_target( } } -fn handle_exp( +fn handle_give_exp( server: &mut Server, client: EcsEntity, target: EcsEntity, args: String, action: &ChatCommand, ) { - let (a_exp, a_alias) = scan_fmt_some!(&args, action.arg_fmt, i64, String); + let (a_exp, a_alias) = scan_fmt_some!(&args, &action.arg_fmt(), i64, String); if let Some(exp) = a_exp { let ecs = server.state.ecs_mut(); @@ -1298,14 +1090,14 @@ fn handle_exp( } } -fn handle_level( +fn handle_set_level( server: &mut Server, client: EcsEntity, target: EcsEntity, args: String, action: &ChatCommand, ) { - let (a_lvl, a_alias) = scan_fmt_some!(&args, action.arg_fmt, u32, String); + let (a_lvl, a_alias) = scan_fmt_some!(&args, &action.arg_fmt(), u32, String); if let Some(lvl) = a_lvl { let ecs = server.state.ecs_mut(); @@ -1378,7 +1170,7 @@ fn handle_remove_lights( args: String, action: &ChatCommand, ) { - let opt_radius = scan_fmt_some!(&args, action.arg_fmt, f32); + let opt_radius = scan_fmt_some!(&args, &action.arg_fmt(), f32); let opt_player_pos = server.state.read_component_cloned::(target); let mut to_delete = vec![]; @@ -1430,7 +1222,7 @@ fn handle_sudo( action: &ChatCommand, ) { if let (Some(player_alias), Some(cmd), cmd_args) = - scan_fmt_some!(&args, action.arg_fmt, String, String, String) + scan_fmt_some!(&args, &action.arg_fmt(), String, String, String) { let cmd_args = cmd_args.unwrap_or(String::from("")); let cmd = if cmd.chars().next() == Some('/') { @@ -1438,14 +1230,14 @@ fn handle_sudo( } else { cmd }; - if let Some(action) = CHAT_COMMANDS.iter().find(|c| c.keyword == cmd) { + if let Some(action) = CHAT_COMMANDS.iter().find(|c| c.keyword() == cmd) { let ecs = server.state.ecs(); let entity_opt = (&ecs.entities(), &ecs.read_storage::()) .join() .find(|(_, player)| player.alias == player_alias) .map(|(entity, _)| entity); if let Some(entity) = entity_opt { - (action.handler)(server, client, entity, cmd_args, action); + get_handler(action)(server, client, entity, cmd_args, action); } else { server.notify_client( client, @@ -1459,7 +1251,10 @@ fn handle_sudo( ); } } else { - server.notify_client(client, ServerMsg::private(String::from(action.help_string))); + server.notify_client( + client, + ServerMsg::private(String::from(action.help_string())), + ); } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 97126cb648..eb29844b80 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -22,11 +22,12 @@ use crate::{ auth_provider::AuthProvider, chunk_generator::ChunkGenerator, client::{Client, RegionSubscription}, - cmd::CHAT_COMMANDS, + cmd::ChatCommandExt, state_ext::StateExt, sys::sentinel::{DeletedEntities, TrackedComps}, }; use common::{ + cmd::ChatCommand, comp, event::{EventBus, ServerEvent}, msg::{ClientMsg, ClientState, ServerInfo, ServerMsg}, @@ -534,18 +535,16 @@ impl Server { }; // Find the command object and run its handler. - let action_opt = CHAT_COMMANDS.iter().find(|x| x.keyword == kwd); - match action_opt { - Some(action) => action.execute(self, entity, args), - // Unknown command - None => { - if let Some(client) = self.state.ecs().write_storage::().get_mut(entity) { - client.notify(ServerMsg::private(format!( - "Unknown command '/{}'.\nType '/help' for available commands", - kwd - ))); - } - }, + if let Ok(command) = kwd.parse::() { + command.execute(self, entity, args); + } else { + self.notify_client( + entity, + ServerMsg::private(format!( + "Unknown command '/{}'.\nType '/help' for available commands", + kwd + )), + ); } } diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs index 2a3197fdaa..82b5d571eb 100644 --- a/voxygen/src/hud/chat.rs +++ b/voxygen/src/hud/chat.rs @@ -3,14 +3,17 @@ use super::{ META_COLOR, PRIVATE_COLOR, SAY_COLOR, TELL_COLOR, TEXT_COLOR, }; use crate::{ui::fonts::ConrodVoxygenFonts, GlobalState}; -use client::Event as ClientEvent; +use client::{cmd, Client, Event as ClientEvent}; use common::{msg::validate_chat_msg, ChatType}; use conrod_core::{ input::Key, position::Dimension, - text::cursor::Index, + text::{ + self, + cursor::{self, Index}, + }, widget::{self, Button, Id, List, Rectangle, Text, TextEdit}, - widget_ids, Colorable, Positionable, Sizeable, UiCell, Widget, WidgetCommon, + widget_ids, Colorable, Positionable, Sizeable, Ui, UiCell, Widget, WidgetCommon, }; use std::collections::VecDeque; @@ -18,9 +21,10 @@ widget_ids! { struct Ids { message_box, message_box_bg, - input, - input_bg, + chat_input, + chat_input_bg, chat_arrow, + completion_box, } } @@ -31,6 +35,7 @@ pub struct Chat<'a> { new_messages: &'a mut VecDeque, force_input: Option, force_cursor: Option, + force_completions: Option>, global_state: &'a GlobalState, imgs: &'a Imgs, @@ -54,6 +59,7 @@ impl<'a> Chat<'a> { new_messages, force_input: None, force_cursor: None, + force_completions: None, imgs, fonts, global_state, @@ -62,6 +68,15 @@ impl<'a> Chat<'a> { } } + pub fn prepare_tab_completion(mut self, input: String, client: &Client) -> Self { + if let Some(index) = input.find('\t') { + self.force_completions = Some(cmd::complete(&input[..index], &client)); + } else { + self.force_completions = None; + } + self + } + pub fn input(mut self, input: String) -> Self { if let Ok(()) = validate_chat_msg(&input) { self.force_input = Some(input); @@ -97,9 +112,15 @@ pub struct State { // Index into the history Vec, history_pos == 0 is history not in use // otherwise index is history_pos -1 history_pos: usize, + completions: Vec, + // Index into the completion Vec + completions_index: Option, + // At which character is tab completion happening + completion_cursor: Option, } pub enum Event { + TabCompletionStart(String), SendMessage(String), Focus(Id), } @@ -115,6 +136,9 @@ impl<'a> Widget for Chat<'a> { messages: VecDeque::new(), history: VecDeque::new(), history_pos: 0, + completions: Vec::new(), + completions_index: None, + completion_cursor: None, ids: Ids::new(id_gen), } } @@ -137,51 +161,106 @@ impl<'a> Widget for Chat<'a> { } }); - // If up or down are pressed move through history - // TODO: move cursor to the end of the last line - match ui.widget_input(state.ids.input).presses().key().fold( - (false, false), - |(up, down), key_press| match key_press.key { - Key::Up => (true, down), - Key::Down => (up, true), - _ => (up, down), - }, - ) { - (true, false) => { - if state.history_pos < state.history.len() { - state.update(|s| { - s.history_pos += 1; - s.input = s.history.get(s.history_pos - 1).unwrap().to_owned(); - }); + if let Some(comps) = &self.force_completions { + state.update(|s| s.completions = comps.clone()); + } + + let mut force_cursor = self.force_cursor; + + // If up or down are pressed: move through history + // If any key other than up, down, or tab is pressed: stop completion. + let (history_dir, tab_dir, stop_tab_completion) = + ui.widget_input(state.ids.chat_input).presses().key().fold( + (0isize, 0isize, false), + |(n, m, tc), key_press| match key_press.key { + Key::Up => (n + 1, m - 1, tc), + Key::Down => (n - 1, m + 1, tc), + Key::Tab => (n, m + 1, tc), + _ => (n, m, true), + }, + ); + + // Handle tab completion + let request_tab_completions = if stop_tab_completion { + // End tab completion + state.update(|s| { + if s.completion_cursor.is_some() { + s.completion_cursor = None; } - }, - (false, true) => { - if state.history_pos > 0 { + s.completions_index = None; + }); + false + } else if let Some(cursor) = state.completion_cursor { + // Cycle through tab completions of the current word + if state.input.contains('\t') { + state.update(|s| s.input.retain(|c| c != '\t')); + //tab_dir + 1 + } + if !state.completions.is_empty() { + if tab_dir != 0 || state.completions_index.is_none() { state.update(|s| { - s.history_pos -= 1; - if s.history_pos > 0 { - s.input = s.history.get(s.history_pos - 1).unwrap().to_owned(); - } else { - s.input.clear(); + let len = s.completions.len(); + s.completions_index = Some( + (s.completions_index.unwrap_or(0) + (tab_dir + len as isize) as usize) + % len, + ); + if let Some(replacement) = &s.completions.get(s.completions_index.unwrap()) + { + let (completed, offset) = + do_tab_completion(cursor, &s.input, replacement); + force_cursor = + cursor_offset_to_index(offset, &completed, &ui, &self.fonts); + s.input = completed; } }); } - }, - _ => {}, + } + false + } else if let Some(cursor) = state.input.find('\t') { + // Begin tab completion + state.update(|s| s.completion_cursor = Some(cursor)); + true + } else { + // Not tab completing + false + }; + + // Move through history + if history_dir != 0 && state.completion_cursor.is_none() { + state.update(|s| { + if history_dir > 0 { + if s.history_pos < s.history.len() { + s.history_pos += 1; + } + } else { + if s.history_pos > 0 { + s.history_pos -= 1; + } + } + if s.history_pos > 0 { + s.input = s.history.get(s.history_pos - 1).unwrap().to_owned(); + force_cursor = + cursor_offset_to_index(s.input.len(), &s.input, &ui, &self.fonts); + } else { + s.input.clear(); + } + }); } let keyboard_capturer = ui.global_input().current.widget_capturing_keyboard; if let Some(input) = &self.force_input { - state.update(|s| s.input = input.clone()); + state.update(|s| s.input = input.to_string()); } let input_focused = - keyboard_capturer == Some(state.ids.input) || keyboard_capturer == Some(id); + keyboard_capturer == Some(state.ids.chat_input) || keyboard_capturer == Some(id); // Only show if it has the keyboard captured. // Chat input uses a rectangle as its background. if input_focused { + // Any changes to this TextEdit's width and font size must be reflected in + // `cursor_offset_to_index` below. let mut text_edit = TextEdit::new(&state.input) .w(460.0) .restrict_to_height(false) @@ -190,7 +269,7 @@ impl<'a> Widget for Chat<'a> { .font_size(self.fonts.opensans.scale(15)) .font_id(self.fonts.opensans.conrod_id); - if let Some(pos) = self.force_cursor { + if let Some(pos) = force_cursor { text_edit = text_edit.cursor_pos(pos); } @@ -202,11 +281,11 @@ impl<'a> Widget for Chat<'a> { .rgba(0.0, 0.0, 0.0, transp + 0.1) .bottom_left_with_margins_on(ui.window, 10.0, 10.0) .w(470.0) - .set(state.ids.input_bg, ui); + .set(state.ids.chat_input_bg, ui); if let Some(str) = text_edit - .top_left_with_margins_on(state.ids.input_bg, 1.0, 1.0) - .set(state.ids.input, ui) + .top_left_with_margins_on(state.ids.chat_input_bg, 1.0, 1.0) + .set(state.ids.chat_input, ui) { let mut input = str.to_owned(); input.retain(|c| c != '\n'); @@ -221,7 +300,7 @@ impl<'a> Widget for Chat<'a> { .rgba(0.0, 0.0, 0.0, transp) .and(|r| { if input_focused { - r.up_from(state.ids.input_bg, 0.0) + r.up_from(state.ids.chat_input_bg, 0.0) } else { r.bottom_left_with_margins_on(ui.window, 10.0, 10.0) } @@ -298,14 +377,17 @@ impl<'a> Widget for Chat<'a> { } } - // If the chat widget is focused, return a focus event to pass the focus to the - // input box. - if keyboard_capturer == Some(id) { - Some(Event::Focus(state.ids.input)) + // We've started a new tab completion. Populate tab completion suggestions. + if request_tab_completions { + Some(Event::TabCompletionStart(state.input.to_string())) + // If the chat widget is focused, return a focus event to pass the focus + // to the input box. + } else if keyboard_capturer == Some(id) { + Some(Event::Focus(state.ids.chat_input)) } // If enter is pressed and the input box is not empty, send the current message. else if ui - .widget_input(state.ids.input) + .widget_input(state.ids.chat_input) .presses() .key() .any(|key_press| match key_press.key { @@ -330,3 +412,64 @@ impl<'a> Widget for Chat<'a> { } } } + +fn do_tab_completion(cursor: usize, input: &str, word: &str) -> (String, usize) { + let mut pre_ws = None; + let mut post_ws = None; + for (char_i, (byte_i, c)) in input.char_indices().enumerate() { + if c.is_whitespace() && c != '\t' { + if char_i < cursor { + pre_ws = Some(byte_i); + } else { + assert_eq!(post_ws, None); // TODO debug + post_ws = Some(byte_i); + break; + } + } + } + + match (pre_ws, post_ws) { + (None, None) => (word.to_string(), word.chars().count()), + (None, Some(i)) => ( + format!("{}{}", word, input.split_at(i).1), + word.chars().count(), + ), + (Some(i), None) => { + let l_split = input.split_at(i).0; + let completed = format!("{} {}", l_split, word); + ( + completed, + l_split.chars().count() + 1 + word.chars().count(), + ) + }, + (Some(i), Some(j)) => { + let l_split = input.split_at(i).0; + let r_split = input.split_at(j).1; + let completed = format!("{} {}{}", l_split, word, r_split); + ( + completed, + l_split.chars().count() + 1 + word.chars().count(), + ) + }, + } +} + +fn cursor_offset_to_index( + offset: usize, + text: &str, + ui: &Ui, + fonts: &ConrodVoxygenFonts, +) -> Option { + // This moves the cursor to the given offset. Conrod is a pain. + //let iter = cursor::xys_per_line_from_text(&text, &[], &font, font_size, + // Justify::Left, Align::Start, 2.0, Rect{x: Range{start: 0.0, end: width}, y: + // Range{start: 0.0, end: 12.345}}); + // cursor::closest_cursor_index_and_xy([f64::MAX, f64::MAX], iter).map(|(i, _)| + // i) Width and font must match that of the chat TextEdit + let width = 460.0; + let font = ui.fonts.get(fonts.opensans.conrod_id)?; + let font_size = fonts.opensans.scale(15); + let infos = text::line::infos(&text, &font, font_size).wrap_by_whitespace(width); + + cursor::index_before_char(infos, offset) +} diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 9268d31170..c47ea9cdae 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -445,6 +445,7 @@ pub struct Hud { force_ungrab: bool, force_chat_input: Option, force_chat_cursor: Option, + tab_complete: Option, pulse: f32, velocity: f32, voxygen_i18n: std::sync::Arc, @@ -518,6 +519,7 @@ impl Hud { force_ungrab: false, force_chat_input: None, force_chat_cursor: None, + tab_complete: None, pulse: 0.0, velocity: 0.0, voxygen_i18n, @@ -1740,9 +1742,15 @@ impl Hud { &self.fonts, ) .and_then(self.force_chat_input.take(), |c, input| c.input(input)) + .and_then(self.tab_complete.take(), |c, input| { + c.prepare_tab_completion(input, &client) + }) .and_then(self.force_chat_cursor.take(), |c, pos| c.cursor_pos(pos)) .set(self.ids.chat, ui_widgets) { + Some(chat::Event::TabCompletionStart(input)) => { + self.tab_complete = Some(input); + }, Some(chat::Event::SendMessage(message)) => { events.push(Event::SendMessage(message)); }, @@ -2312,6 +2320,27 @@ impl Hud { camera: &Camera, dt: Duration, ) -> Vec { + // conrod eats tabs. Un-eat a tabstop so tab completion can work + if self.ui.ui.global_input().events().any(|event| { + use conrod_core::{event, input}; + match event { + //event::Event::Raw(event::Input::Press(input::Button::Keyboard(input::Key::Tab))) + // => true, + event::Event::Ui(event::Ui::Press( + _, + event::Press { + button: event::Button::Keyboard(input::Key::Tab), + .. + }, + )) => true, + _ => false, + } + }) { + self.ui + .ui + .handle_event(conrod_core::event::Input::Text("\t".to_string())); + } + if let Some(maybe_id) = self.to_focus.take() { self.ui.focus_widget(maybe_id); }