diff --git a/lib/cyberarm_engine.rb b/lib/cyberarm_engine.rb index 8edd35a..ecc1343 100644 --- a/lib/cyberarm_engine.rb +++ b/lib/cyberarm_engine.rb @@ -30,6 +30,11 @@ require_relative "cyberarm_engine/text" require_relative "cyberarm_engine/timer" require_relative "cyberarm_engine/config_file" +require_relative "cyberarm_engine/console" +require_relative "cyberarm_engine/console/command" +require_relative "cyberarm_engine/console/subcommand" +require_relative "cyberarm_engine/console/commands/help_command" + require_relative "cyberarm_engine/ui/dsl" require_relative "cyberarm_engine/ui/theme" diff --git a/lib/cyberarm_engine/common.rb b/lib/cyberarm_engine/common.rb index 2e6ec33..0691098 100644 --- a/lib/cyberarm_engine/common.rb +++ b/lib/cyberarm_engine/common.rb @@ -97,5 +97,17 @@ module CyberarmEngine def window $window end + + def control_down? + Gosu.button_down?(Gosu::KB_LEFT_CONTROL) || Gosu.button_down?(Gosu::KB_RIGHT_CONTROL) + end + + def shift_down? + Gosu.button_down?(Gosu::KB_LEFT_SHIFT) || Gosu.button_down?(Gosu::KB_RIGHT_SHIFT) + end + + def alt_down? + Gosu.button_down?(Gosu::KB_LEFT_ALT) || Gosu.button_down?(Gosu::KB_RIGHT_ALT) + end end end diff --git a/lib/cyberarm_engine/console.rb b/lib/cyberarm_engine/console.rb new file mode 100644 index 0000000..bb23f87 --- /dev/null +++ b/lib/cyberarm_engine/console.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +module CyberarmEngine + class Console + Z = 100_000 + PADDING = 2 + include Common + + attr_reader :text_input + + def initialize(font: Gosu.default_font_name) + @text_input = Gosu::TextInput.new + @width = window.width / 4 * 3 + @height = window.height / 4 * 3 + + @input = Text.new("", x: 4, y: @height - (PADDING * 2), z: Console::Z + 1, font: font) + @input.y -= @input.height + + @history = Text.new("", x: 4, z: Console::Z + 1, font: font, border: true, border_color: Gosu::Color::BLACK) + update_history_y + + @command_history = [] + @command_history_index = 0 + + @memory = "" + + @background_color = Gosu::Color.rgba(0, 0, 0, 200) + @foreground_color = Gosu::Color.rgba(100, 100, 100, 100) + @input_color = Gosu::Color.rgba(100, 100, 100, 200) + + @showing_cursor = false + @active_text_input = nil + + @show_caret = true + @caret_last_change = Gosu.milliseconds + @caret_interval = 250 + @caret_color = Gosu::Color::WHITE + @selection_color = Gosu::Color.new(0x5522ff22) + end + + def draw + # Background/Border + draw_rect(0, 0, @width, @height, @background_color, Console::Z) + # Foregound/History + draw_rect(PADDING, PADDING, @width - (PADDING * 2), @height - (PADDING * 2), @foreground_color, Console::Z) + # Text bar + draw_rect(2, @input.y, @width - (PADDING * 2), @input.height, @input_color, Console::Z) + + @history.draw + @input.draw + # Caret + if @show_caret + draw_rect(@input.x + caret_from_left, @input.y, Console::PADDING, @input.height, @caret_color, Console::Z + 2) + end + # Caret selection + if caret_start != caret_end + if caret_start < @text_input.selection_start + draw_rect(@input.x + caret_from_left, @input.y, caret_selection_width, @input.height, @selection_color, Console::Z) + else + draw_rect((@input.x + caret_from_left) - caret_selection_width, @input.y, caret_selection_width, @input.height, @selection_color, Console::Z) + end + end + end + + def caret_from_left + return 0 if @text_input.caret_pos.zero? + + @input.textobject.text_width(@text_input.text[0..@text_input.caret_pos - 1]) + end + + def caret_selection_width + @input.textobject.text_width(@text_input.text[caret_start..(caret_end - 1)]) + end + + def caret_pos + @text_input.caret_pos + end + + def caret_start + @text_input.selection_start < @text_input.caret_pos ? @text_input.selection_start : @text_input.caret_pos + end + + def caret_end + @text_input.selection_start > @text_input.caret_pos ? @text_input.selection_start : @text_input.caret_pos + end + + def update + if Gosu.milliseconds - @caret_last_change >= @caret_interval + @caret_last_change = Gosu.milliseconds + @show_caret = !@show_caret + end + + if @width != window.width || @height != @height + @width = window.width / 4 * 3 + @height = window.height / 4 * 3 + + @input.y = @height - (PADDING * 2 + @input.height) + update_history_y + end + + @input.text = @text_input.text + end + + def button_down(id) + case id + when Gosu::KbEnter, Gosu::KbReturn + return unless @text_input.text.length.positive? + + @history.text += "\n> #{@text_input.text}" + @command_history << @text_input.text + @command_history_index = @command_history.size + update_history_y + handle_command + @text_input.text = "" + + when Gosu::KbUp + @command_history_index -= 1 + @command_history_index = 0 if @command_history_index.negative? + @text_input.text = @command_history[@command_history_index] + + when Gosu::KbDown + @command_history_index += 1 + if @command_history_index > @command_history.size - 1 + @text_input.text = "" unless @command_history_index > @command_history.size + @command_history_index = @command_history.size + else + @text_input.text = @command_history[@command_history_index] + end + + when Gosu::KbTab + split = @text_input.text.split(" ") + + if !@text_input.text.end_with?(" ") && split.size == 1 + list = abbrev_search(Console::Command.list_commands.map { |cmd| cmd.command.to_s }, @text_input.text) + + if list.size == 1 + @text_input.text = "#{list.first} " + elsif list.size.positive? + stdin("\n#{list.map { |cmd| Console::Style.highlight(cmd) }.join(', ')}") + end + elsif split.size.positive? && cmd = Console::Command.find(split.first) + cmd.autocomplete(self) + end + + when Gosu::KbBacktick + # Remove backtick character from input + @text_input.text = if @text_input.text.size > 1 + @text_input.text[0..@text_input.text.size - 2] + else + "" + end + + # Copy + when Gosu::KbC + if control_down? && shift_down? + @memory = @text_input.text[caret_start..caret_end - 1] if caret_start != caret_end + p @memory + elsif control_down? + @text_input.text = "" + end + + # Paste + when Gosu::KbV + if control_down? && shift_down? + string = @text_input.text.chars.insert(caret_pos, @memory).join + _caret_pos = caret_pos + @text_input.text = string + @text_input.caret_pos = _caret_pos + @memory.length + @text_input.selection_start = _caret_pos + @memory.length + end + + # Cut + when Gosu::KbX + if control_down? && shift_down? + @memory = @text_input.text[caret_start..caret_end - 1] if caret_start != caret_end + string = @text_input.text.chars + Array(caret_start..caret_end - 1).each_with_index do |i, j| + string.delete_at(i - j) + end + + @text_input.text = string.join + end + + # Delete word to left of caret + when Gosu::KbW + if control_down? + split = @text_input.text.split(" ") + split.delete(split.last) + @text_input.text = split.join(" ") + end + + # Clear history + when Gosu::KbL + @history.text = "" if control_down? + end + end + + def button_up(id) + end + + def update_history_y + @history.y = @height - (PADDING * 2) - @input.height - (@history.text.lines.count * @history.textobject.height) + end + + def handle_command + string = @text_input.text + split = string.split(" ") + command = split.first + arguments = split.length.positive? ? split[1..split.length - 1] : [] + + CyberarmEngine::Console::Command.use(command, arguments, self) + end + + def abbrev_search(array, text) + return [] unless text.length.positive? + + list = [] + Abbrev.abbrev(array).each do |abbrev, value| + next unless abbrev&.start_with?(text) + + list << value + end + + list.uniq + end + + def stdin(string) + @history.text += "\n#{string}" + update_history_y + end + + def focus + @active_text_input = window.text_input + window.text_input = @text_input + + @showing_cursor = window.needs_cursor + window.needs_cursor = true + + @show_caret = true + @caret_last_change = Gosu.milliseconds + end + + def blur + window.text_input = @active_text_input + window.needs_cursor = @showing_cursor + end + end +end diff --git a/lib/cyberarm_engine/console/command.rb b/lib/cyberarm_engine/console/command.rb new file mode 100644 index 0000000..01e1aaf --- /dev/null +++ b/lib/cyberarm_engine/console/command.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module CyberarmEngine + class Console + module Style + def self.error(string) + "#{string}" + end + + def self.warn(string) + "#{string}" + end + + def self.notice(string) + "#{string}" + end + + def self.highlight(string, color = "5555ff") + "#{string}" + end + end + + class Command + def self.inherited(subclass) + @list ||= [] + @commands ||= [] + @list << subclass + end + + def self.setup + @list ||= [] + @commands = [] + @list.each do |subclass| + cmd = subclass.new + if @commands.detect { |c| c.command == cmd.command } + raise "Command '#{cmd.command}' from '#{cmd.class}' already exists!" + end + + @commands << cmd + end + end + + def self.use(command, arguments, console) + found_command = @commands.detect { |cmd| cmd.command == command.to_sym } + + if found_command + found_command.handle(arguments, console) + else + console.stdin("Command #{Style.error(command)} not found.") + end + end + + def self.find(command) + @commands.detect { |cmd| cmd.command == command.to_sym } + end + + def self.list_commands + @commands + end + + def initialize + @store = {} + @subcommands = [] + + setup + end + + def setup + end + + def subcommand(command, type) + if @subcommands.detect { |subcmd| subcmd.command == command.to_sym } + raise "Subcommand '#{command}' for '#{self.command}' already exists!" + end + + @subcommands << SubCommand.new(self, command, type) + end + + def get(key) + @store[key] + end + + def set(key, value) + @store[key] = value + end + + def group + raise NotImplementedError + end + + def command + raise NotImplementedError + end + + def handle(arguments, console) + raise NotImplementedError + end + + def autocomplete(console) + split = console.text_input.text.split(" ") + + if @subcommands.size.positive? + if !console.text_input.text.end_with?(" ") && split.size == 2 + list = console.abbrev_search(@subcommands.map { |cmd| cmd.command.to_s }, split.last) + + if list.size == 1 + console.text_input.text = "#{split.first} #{list.first} " + else + return unless list.size.positive? + + console.stdin(list.map { |cmd| Console::Style.highlight(cmd) }.join(", ").to_s) + end + + # List available options on subcommand + elsif (console.text_input.text.end_with?(" ") && split.size == 2) || !console.text_input.text.end_with?(" ") && split.size == 3 + subcommand = @subcommands.detect { |cmd| cmd.command.to_s == (split[1]) } + + if subcommand + if split.size == 2 + console.stdin("Available options: #{subcommand.values.map { |value| Console::Style.highlight(value) }.join(',')}") + else + list = console.abbrev_search(subcommand.values, split.last) + if list.size == 1 + console.text_input.text = "#{split.first} #{split[1]} #{list.first} " + elsif list.size.positive? + console.stdin("Available options: #{list.map { |value| Console::Style.highlight(value) }.join(',')}") + end + end + end + + # List available subcommands if command was entered and has only a space after it + elsif console.text_input.text.end_with?(" ") && split.size == 1 + console.stdin("Available subcommands: #{@subcommands.map { |cmd| Console::Style.highlight(cmd.command) }.join(', ')}") + end + end + end + + def handle_subcommand(arguments, console) + if arguments.size.zero? + console.stdin(usage) + return + end + subcommand = arguments.delete_at(0) + + found_command = @subcommands.detect { |cmd| cmd.command == subcommand.to_sym } + if found_command + found_command.handle(arguments, console) + else + console.stdin("Unknown subcommand #{Style.error(subcommand)} for #{Style.highlight(command)}") + end + end + + def usage + raise NotImplementedError + end + end + end +end \ No newline at end of file diff --git a/lib/cyberarm_engine/console/commands/help_command.rb b/lib/cyberarm_engine/console/commands/help_command.rb new file mode 100644 index 0000000..38a9c67 --- /dev/null +++ b/lib/cyberarm_engine/console/commands/help_command.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module CyberarmEngine + class Console + class HelpCommand < CyberarmEngine::Console::Command + def group + :global + end + + def command + :help + end + + def handle(arguments, console) + console.stdin(usage(arguments.first)) + end + + def autocomplete(console) + split = console.text_input.text.split(" ") + if !console.text_input.text.start_with?(" ") && split.size == 2 + list = console.abbrev_search(Command.list_commands.map { |cmd| cmd.command.to_s }, split.last) + if list.size == 1 + console.text_input.text = "#{split.first} #{list.first} " + elsif list.size > 1 + console.stdin(list.map { |cmd| Style.highlight(cmd) }.join(", ")) + end + end + end + + def usage(command = nil) + if command + if cmd = Command.find(command) + cmd.usage + else + "#{Style.error(command)} is not a command" + end + else + "Available commands:\n#{Command.list_commands.map { |cmd| Style.highlight(cmd.command).to_s }.join(', ')}" + end + end + end + end +end diff --git a/lib/cyberarm_engine/console/subcommand.rb b/lib/cyberarm_engine/console/subcommand.rb new file mode 100644 index 0000000..97f1fcc --- /dev/null +++ b/lib/cyberarm_engine/console/subcommand.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module CyberarmEngine + class Console + class Command + class SubCommand + def initialize(parent, command, type) + @parent = parent + @command = command + @type = type + end + + attr_reader :command + + def handle(arguments, console) + if arguments.size > 1 + console.stdin("to many arguments for #{Style.highlight(command.to_s)}, got #{Style.error(arguments.size)} expected #{Style.notice(1)}.") + return + end + + case @type + when :boolean + case arguments.last + when "", nil + var = @parent.get(command.to_sym) || false + console.stdin("#{command}: #{Style.highlight(var)}") + when "on" + var = @parent.set(command.to_sym, true) + console.stdin("#{command} => #{Style.highlight(var)}") + when "off" + var = @parent.set(command.to_sym, false) + console.stdin("#{command} => #{Style.highlight(var)}") + else + console.stdin("Invalid argument for #{Style.highlight(command.to_s)}, got #{Style.error(arguments.last)} expected #{Style.notice('on')}, or #{Style.notice('off')}.") + end + when :string + case arguments.last + when "", nil + var = @parent.get(command.to_sym) || "\"\"" + console.stdin("#{command}: #{Style.highlight(var)}") + else + var = @parent.set(command.to_sym, arguments.last) + console.stdin("#{command} => #{Style.highlight(var)}") + end + when :integer + case arguments.last + when "", nil + var = @parent.get(command.to_sym) || "nil" + console.stdin("#{command}: #{Style.highlight(var)}") + else + begin + var = @parent.set(command.to_sym, Integer(arguments.last)) + console.stdin("#{command} => #{Style.highlight(var)}") + rescue ArgumentError + console.stdin("Error: #{Style.error("Expected an integer, got '#{arguments.last}'")}") + end + end + when :decimal + case arguments.last + when "", nil + var = @parent.get(command.to_sym) || "nil" + console.stdin("#{command}: #{Style.highlight(var)}") + else + begin + var = @parent.set(command.to_sym, Float(arguments.last)) + console.stdin("#{command} => #{Style.highlight(var)}") + rescue ArgumentError + console.stdin("Error: #{Style.error("Expected a decimal or integer, got '#{arguments.last}'")}") + end + end + else + raise RuntimeError + end + end + + def values + case @type + when :boolean + %w[on off] + else + [] + end + end + + def usage + case @type + when :boolean + "#{Style.highlight(command)} #{Style.notice('[on|off]')}" + when :string + "#{Style.highlight(command)} #{Style.notice('[string]')}" + when :integer + "#{Style.highlight(command)} #{Style.notice('[0]')}" + when :decimal + "#{Style.highlight(command)} #{Style.notice('[0.0]')}" + end + end + end + end + end +end \ No newline at end of file