jcraigk/story_key

View on GitHub
lib/story_key/console/recover.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true
class StoryKey::Console::Recover < StoryKey::Base
  include Remedy

  BG_BLUE = 44
  BG_MAGENTA = 45
  CYAN = 36
  BG_RED = 41
  EXIT_WORD = :control_x
  FRAME_HORIZONTAL = '─'
  GREEN = 32
  NUM_OPTIONS = 5
  RED = 31

  def call
    @entry_idx = 0
    @option_idx = 0
    clear_options
    clear_user_str
    @prompt = 'Enter word(s): '

    ask_version_slug
    ask_num_phrases
    ask_num_tail_entries
    interactive_phrase_recovery
  end

  attr_accessor :num_phrases, :num_tail_entries, :instructions, :prompt,
                :user_str, :options, :option_idx, :entry_idx

  private

  def listen # rubocop:disable Metrics/MethodLength
    console.loop do |key|
      key = key.to_s.to_sym
      case key
      when EXIT_WORD then quit_console
      when :up, :down then move_option_cursor(key)
      when :left, :right, :tab then move_entry_cursor(key)
      when :backspace, :delete then input_backspace
      when :carriage_return, :control_m then input_enter
      else input_misc_key(key)
      end

      draw
    end
  end

  def input_misc_key(key)
    return unless key.match?(/([a-zA-Z0-9\s]|dash)/)
    @user_str += key.to_s
    refresh_options
  end

  def input_enter
    return if (entry = options[option_idx]).blank?
    entries[entry_idx] = decolorize(entry)
    return decode_and_hault if board_complete?
    move_entry_cursor(:right)
  end

  def clear_user_str
    @user_str = ''
  end

  def input_backspace
    @user_str = @user_str[0..-2]
    refresh_options
  end

  def clear_options
    @options = []
    @option_idx = 0
  end

  def decode_and_hault
    clear_user_str
    clear_options
    @entry_idx = -1
    @instructions = decode_story
    @prompt = colorize('(press any key to exit)', RED)
    draw
    console.get_key
    quit_console
  end

  def decode_story
    key = StoryKey.decode(story: "#{StoryKey::VERSION_SLUG} #{entries.join(' ')}")
    "#{colorize('Key:', BG_BLUE)} #{colorize(key, GREEN)}"
  rescue StoryKey::InvalidChecksum
    colorize('Checksum failed! Invalid story.', BG_RED)
  end

  def refresh_options
    chars = user_str.chars
    lexicon = current_lexicon
    substr_matches = current_substr_matches(lexicon)
    fuzzy_matches = current_fuzzy_matches(lexicon, chars)
    @options = (substr_matches + fuzzy_matches - entries).uniq.take(NUM_OPTIONS)
    @options.map! do |opt|
      opt.gsub(/#{chars.join('|')}/) { |char| colorize(char, CYAN) }
    end
    @option_idx = 0
  end

  def current_lexicon
    lex.entries[parts_of_speech[entry_idx].to_sym].map(&:text)
  end

  def current_fuzzy_matches(lexicon, chars)
    lexicon.grep(/.*#{chars.join('.*')}.*/i)
  end

  def current_substr_matches(lexicon)
    user_str.size > 2 ? lexicon.grep(/.*#{user_str}.*/i) : []
  end

  def interactive_phrase_recovery
    init_viewport
    listen
  end

  def ask_version_slug
    print "Did your story happen in #{StoryKey::VERSION_SLUG}? [Y/n] "
    key = console.get_key
    puts
    return if confirm?(key)
    quit('Sorry, this version of StoryKey can\'t decode your story')
  end

  def num_parts
    StoryKey::GRAMMAR.keys.max
  end

  def default_num_phrases
    ((StoryKey::DEFAULT_BITSIZE / StoryKey::BITS_PER_ENTRY.to_f) / num_parts).ceil
  end

  def max_num_phrases
    ((StoryKey::MAX_BITSIZE / StoryKey::BITS_PER_ENTRY.to_f) / num_parts).ceil
  end

  def ask_num_phrases
    print "How many phrases? [#{default_num_phrases}] "
    ARGV.clear
    input = gets
    input = default_num_phrases if input.blank?
    @num_phrases = input.to_i.tap do |i|
      quit('Invalid number') unless i.in?(1..max_num_phrases)
    end
  end

  def ask_num_tail_entries
    default = 3
    print "How many parts in last phrase? [#{default}] "
    input = gets
    input = default if input.blank?
    @num_tail_entries = input.to_i.tap do |i|
      quit('Invalid number') unless i.in?(1..max_parts_in_phrase)
    end
  end

  def max_parts_in_phrase
    StoryKey::GRAMMAR.keys.max
  end

  def entries
    return @entries if @entries
    ary = []
    num_phrases.times do |idx|
      grammar_idx = idx + 1 == num_phrases ? num_tail_entries : max_parts_in_phrase
      grammar = StoryKey::GRAMMAR[grammar_idx]
      ary += grammar.map { |part_of_speech| "[#{part_of_speech}]" }
    end
    @entries = ary
  end

  def board_rows # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
    [version_lead].tap do |ary|
      idx = 0
      entry_slices.map do |entry_group, row|
        parts = []
        last_row = row == num_phrases - 1
        parts << "#{row + 1}." if num_phrases > 1
        entry_group.each do |entry|
          parts << (entry_idx == idx ? colorize(entry, BG_MAGENTA) : entry)
          idx += 1
        end

        str = parts.join(' ')
        str += (last_row ? '.' : ',')
        ary << str
      end
    end
  end

  def version_lead
    "In #{StoryKey::VERSION_SLUG} I saw"
  end

  def entry_slices
    entries.each_slice(StoryKey::GRAMMAR.keys.max).to_a.each_with_index
  end

  def option_rows
    options.each_with_index.map do |opt, idx|
      "#{' ' * prompt.size}#{idx == option_idx ? colorize(opt, BG_MAGENTA) : opt}"
    end
  end

  def move_entry_cursor(key)
    @entry_idx = entry_idx.send((key == :left ? '-' : '+'), 1) % entries.size
    clear_user_str
    clear_options
  end

  def move_option_cursor(key)
    @option_idx =
      if options.any?
        option_idx.send((key == :up ? '-' : '+'), 1) % options.size
      else
        0
      end
  end

  def board_complete?
    entries.grep(/\[.+\]/).empty?
  end

  def num_entries
    (num_phrases - 1)
  end

  def init_viewport
    @instructions = colorize \
      "\u2190 \u2192  Story   \u2191 \u2193 Suggestions   Ctr-X", BG_BLUE
    ANSI.screen.safe_reset!
    ANSI.cursor.home!
    ANSI.command.clear_screen!
    draw
  end

  def console
    @console ||= Interaction.new
  end

  def colorize(text, num)
    return text if text.blank? || num.blank?
    "\e[#{num}m#{decolorize(text)}\e[0m"
  end

  def decolorize(text)
    text.gsub(/\e\[\d+m/, '')
  end

  def draw
    viewport.draw(user_prompt, Size([0, 0]), board_view, options_view)
  end

  def user_prompt
    hr = FRAME_HORIZONTAL * 36
    Partial.new([hr, instructions, hr, "#{prompt}#{user_str}"])
  end

  def board_view
    Partial.new(board_rows)
  end

  def options_view
    Partial.new(option_rows)
  end

  def viewport
    @viewport ||= Viewport.new
  end

  def quit_console
    ANSI.cursor.home!
    ANSI.command.clear_down!
    ANSI.cursor.show!
    exit
  end

  def quit(msg)
    puts msg
    exit
  end

  def confirm?(key)
    key.to_s.in?(%w[Y y carriage_return control_m])
  end

  def parts_of_speech
    @parts_of_speech ||= entries.map { |w| w.tr('[]', '') }
  end

  def lex
    @lex ||= StoryKey::Lexicon.new
  end
end