diff --git a/.rspec b/.rspec deleted file mode 100644 index 34c5164..0000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---color ---require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index e3462a7..dc2917a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.6 + TargetRubyVersion: 3.2.2 Style/StringLiterals: Enabled: true diff --git a/Gemfile b/Gemfile index 363d8ed..9690e88 100644 --- a/Gemfile +++ b/Gemfile @@ -5,8 +5,8 @@ source "https://rubygems.org" # Specify your gem's dependencies in dynamic_curses_input.gemspec gemspec -gem "rake", "~> 13.0" +gem "rake" -gem "rspec", "~> 3.0" +gem "rubocop" -gem "rubocop", "~> 1.21" +gem "curses" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..2f928b3 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,60 @@ +PATH + remote: . + specs: + dynamic_curses_input (1.0.0) + curses + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + curses (1.4.4) + diff-lcs (1.5.0) + json (2.6.3) + parallel (1.23.0) + parser (3.2.2.1) + ast (~> 2.4.1) + rainbow (3.1.1) + rake (13.0.6) + regexp_parser (2.8.0) + rexml (3.2.5) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.0) + rubocop (1.52.0) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) + unicode-display_width (2.4.2) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + curses + dynamic_curses_input! + rake + rspec + rubocop + +BUNDLED WITH + 2.4.13 diff --git a/dynamic_curses_input-1.0.0.gem b/dynamic_curses_input-1.0.0.gem new file mode 100644 index 0000000..87355d6 Binary files /dev/null and b/dynamic_curses_input-1.0.0.gem differ diff --git a/dynamic_curses_input.gemspec b/dynamic_curses_input.gemspec index 9d99280..eaf4275 100644 --- a/dynamic_curses_input.gemspec +++ b/dynamic_curses_input.gemspec @@ -9,10 +9,17 @@ Gem::Specification.new do |spec| spec.email = ["vetheon@pixelatedstudios.net"] spec.summary = "A simple library for making Curses TUI input more dynamic and user-friendly" - spec.description = "Dynamic Curses Input is a highly simple, yet powerful gem that allows simple implementation of dynamic typing in curses TUI menus built in Ruby. For example, one can't simply use their arrow keys to navigate and edit inputs in Cursese TUI menus without adding a bunch of extra code to your project to handle it. A lot of which can be tricky to handle. This gem eliminates the need for that code, by providing simple to use methods that allow developers to capture user input, while allowing the special keys to work as the average user would expect. IE: When you press the left arrow key, the cursor moves to the left and allows you to delete a character you entered that isn't the last character you entered." + spec.description = "Dynamic Curses Input is a highly simple, yet powerful gem that allows simple implementation of + dynamic typing in curses TUI menus built in Ruby. For example, one can't simply use their arrow + keys to navigate and edit inputs in Cursese TUI menus without adding a bunch of extra code to your + project to handle it. A lot of which can be tricky to handle. This gem eliminates the need for + that code, by providing simple to use methods that allow developers to capture user input, while + allowing the special keys to work as the average user would expect. + IE: When you press the left arrow key, the cursor moves to the left and allows you to delete a + character you entered that isn't the last character you entered." spec.homepage = "https://github.com/Pixelated-Studios/dynamic_curses_input" spec.license = "MIT" - spec.required_ruby_version = ">= 3.0.0" + spec.required_ruby_version = "3.2.2" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/Pixelated-Studios/dynamic_curses_input" @@ -22,10 +29,11 @@ Gem::Specification.new do |spec| # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| - (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor]) + f.start_with?("spec", ".rspec") || (File.expand_path(f) == __FILE__) end end spec.require_paths = ["lib"] spec.add_dependency "curses" + spec.add_development_dependency "rubocop" end diff --git a/lib/dynamic_curses_input.rb b/lib/dynamic_curses_input.rb index d8a34f1..cf4c0b0 100644 --- a/lib/dynamic_curses_input.rb +++ b/lib/dynamic_curses_input.rb @@ -1,8 +1,25 @@ # frozen_string_literal: true -require_relative "dynamic_curses_input/version" +# lib/dynamic_curses_input.rb +require_relative "dynamic_curses_input/version" +require_relative "dynamic_curses_input/input_handler" + +# The module entrypoint for our Gem module DynamicCursesInput class Error < StandardError; end - # Your code goes here... + + def self.catch_input(echo) + InputHandler.catch_input(echo) + end + + def self.ask_question(question, echo) + Curses.clear + Curses.setpos(1, 0) + Curses.addstr(question) + Curses.refresh + catch_input(echo) + end end + +DCI = DynamicCursesInput diff --git a/lib/dynamic_curses_input/input_handler.rb b/lib/dynamic_curses_input/input_handler.rb index a853c57..08b1859 100644 --- a/lib/dynamic_curses_input/input_handler.rb +++ b/lib/dynamic_curses_input/input_handler.rb @@ -5,42 +5,124 @@ require "curses" module DynamicCursesInput - # our main class for actually handling our user input + # our main class for handling input class InputHandler - def self.catch_input(echo) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - Curses.stdscr.keypad(true) - input = "" - cursor_pos = 0 - initial_y = Curses.stdscr.cury - initial_x = Curses.stdscr.curx - Curses.noecho unless echo - while (ch = Curses.getch) - case ch - when Curses::KEY_LEFT - cursor_pos -= 1 unless cursor_pos.zero? - when Curses::KEY_RIGHT - cursor_pos += 1 unless cursor_pos == input.length - when Curses::KEY_BACKSPACE, 127 - if cursor_pos.positive? - input = input[0...cursor_pos - 1] + input[cursor_pos..] - cursor_pos -= 1 - end - when 10, 13 - break - else - if ch.is_a?(String) && !ch.nil? - input = input[0...cursor_pos] + ch + input[cursor_pos..] - cursor_pos += 1 - end - end - Curses.setpos(initial_y, initial_x) - Curses.addstr(" " * (Curses.cols - initial_x)) - Curses.setpos(initial_y, initial_x) - Curses.addstr(input) if echo - Curses.setpos(initial_y, initial_x + cursor_pos) + # Class method that initializes a new instance of InputHandler and calls the instance method catch_input + def self.catch_input(echo) + new(echo).catch_input + end + + # Initialize instance variables and setup curses + def initialize(echo) + @echo = echo # Determines whether input should be echoed to the screen + @input = "" # Stores the input string + @cursor_pos = 0 # Stores the current cursor position + @initial_y = Curses.stdscr.cury # Stores the initial y-coordinate of the cursor + @initial_x = Curses.stdscr.curx # Stores the initial x-coordinate of the cursor + setup_curses # Setup curses + end + + # Main method that catches user input + def catch_input + # Loop until the user hits the enter key (represented by :break) + while (chk = Curses.getch) + break if handle_key(chk) == :break + + redraw_input # Redraw the input string end - Curses.echo if echo - input + Curses.echo if @echo # Echo the input if @echo is true + @input # Return the input string + end + + private + + # Setup curses + def setup_curses + Curses.stdscr.keypad(true) # Enable keypad of the user's terminal + Curses.noecho unless @echo # Don't echo the input if @echo is false + end + + # Handle key press + def handle_key(chk) + case chk + when Curses::KEY_LEFT then handle_left_key # Move cursor left + when Curses::KEY_RIGHT then handle_right_key # Move cursor right + when Curses::KEY_BACKSPACE, 127 then handle_backspace_key # Delete character + when 10, 13 then handle_enter_key # Break loop if enter key is pressed + else handle_default_key(chk) # Add character to input string + end + end + + # Move cursor left + def handle_left_key + @cursor_pos = CursorMover.left(@cursor_pos) + end + + # Move cursor right + def handle_right_key + @cursor_pos = CursorMover.right(@cursor_pos, @input.length) + end + + # Delete character + def handle_backspace_key + @input, @cursor_pos = CharacterDeleter.delete(@input, @cursor_pos) + end + + # Break loop if enter key is pressed + # This is a bit unconventional, but it's a simple way to break the loop from within the handle_key method + def handle_enter_key + :break + end + + # Add character to input string + def handle_default_key(chk) + return unless chk.is_a?(String) && !chk.nil? + + @input, @cursor_pos = CharacterAdder.add(chk, @input, @cursor_pos) + end + + # Redraw the input string + def redraw_input + Curses.setpos(@initial_y, @initial_x) # Move cursor to initial position + Curses.addstr(" " * (Curses.cols - @initial_x)) # Clear line + Curses.setpos(@initial_y, @initial_x) # Move cursor to initial position + Curses.addstr(@input) if @echo # Draw input string if @echo is true + Curses.setpos(@initial_y, @initial_x + @cursor_pos) # Move cursor to current position + end + end + + # Class for moving the cursor + class CursorMover + # Move cursor left + def self.left(cursor_pos) + cursor_pos.zero? ? cursor_pos : cursor_pos - 1 + end + + # Move cursor right + def self.right(cursor_pos, length) + cursor_pos == length ? cursor_pos : cursor_pos + 1 + end + end + + # Class for deleting characters + class CharacterDeleter + # Delete character at cursor position + def self.delete(input, cursor_pos) + if cursor_pos.positive? + input = input[0...cursor_pos - 1] + input[cursor_pos..] + cursor_pos -= 1 + end + [input, cursor_pos] + end + end + + # Class for adding characters + class CharacterAdder + # Add character at cursor position + def self.add(chk, input, cursor_pos) + input = input[0...cursor_pos] + chk + input[cursor_pos..] + cursor_pos += 1 + [input, cursor_pos] end end end diff --git a/spec/dynamic_curses_input_spec.rb b/spec/dynamic_curses_input_spec.rb deleted file mode 100644 index 85f9790..0000000 --- a/spec/dynamic_curses_input_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe DynamicCursesInput do - it "has a version number" do - expect(DynamicCursesInput::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index bf31e70..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "dynamic_curses_input" - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - # Disable RSpec exposing methods globally on `Module` and `main` - config.disable_monkey_patching! - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end