piotrmurach/tty-reader

View on GitHub
lib/tty/reader/line.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

require "forwardable"

module TTY
  class Reader
    class Line
      ANSI_MATCHER = /(\[)?\033(\[)?[;?\d]*[\dA-Za-z](\])?/

      # The word break characters list used by shell
      DEFAULT_WORD_BREAK_CHARACTERS = " \t\n\"\\'`@$><=|&{("

      # Strip ANSI characters from the text
      #
      # @param [String] text
      #
      # @return [String]
      #
      # @api public
      def self.sanitize(text)
        text.dup.gsub(ANSI_MATCHER, "")
      end

      # The editable text
      # @api public
      attr_reader :text

      # The current cursor position witin the text
      # @api public
      attr_reader :cursor

      # The line mode
      # @api public
      attr_reader :mode

      # The prompt displayed before input
      # @api public
      attr_reader :prompt

      # The prompt size
      # @api public
      attr_reader :prompt_size

      # The word separator pattern for splitting the text
      #
      # @return [Regexp]
      #
      # @api public
      attr_reader :separator

      # Create a Line instance
      #
      # @api private
      def initialize(text = "", prompt: "", separator: nil)
        @text   = text.dup
        @prompt = prompt.dup
        @prompt_size = prompt_display_width
        break_chars = DEFAULT_WORD_BREAK_CHARACTERS.chars
        @separator = separator || Regexp.union(break_chars)
        @cursor = [0, @text.length].max
        @mode   = :edit

        yield self if block_given?
      end

      # Prompt display width
      #
      # @return [Integer]
      #   the prompt size
      #
      # @api public
      def prompt_display_width
        lines = self.class.sanitize(@prompt).split(/\r?\n/)
        return 0 if lines.empty?

        # return the length of each line + screen width for every line
        # past the first which accounts for multi-line prompts
        lines.join.length + ((lines.length - 1) * TTY::Screen.width)
      end

      # Check if line is in edit mode
      #
      # @return [Boolean]
      #
      # @public
      def editing?
        @mode == :edit
      end

      # Enable edit mode
      #
      # @return [Boolean]
      #
      # @public
      def edit_mode
        @mode = :edit
      end

      # Check if line is in replace mode
      #
      # @return [Boolean]
      #
      # @public
      def replacing?
        @mode == :replace
      end

      # Enable replace mode
      #
      # @return [Boolean]
      #
      # @public
      def replace_mode
        @mode = :replace
      end

      # Check if cursor reached beginning of the line
      #
      # @return [Boolean]
      #
      # @api public
      def start?
        @cursor.zero?
      end

      # Check if cursor reached end of the line
      #
      # @return [Boolean]
      #
      # @api public
      def end?
        @cursor == @text.length
      end

      # Move line position to the left by n chars
      #
      # @api public
      def left(n = 1)
        @cursor = [0, @cursor - n].max
      end

      # Move line position to the right by n chars
      #
      # @api public
      def right(n = 1)
        @cursor = [@text.length, @cursor + n].min
      end

      # Move cursor to beginning position
      #
      # @api public
      def move_to_start
        @cursor = 0
      end

      # Move cursor to end position
      #
      # @api public
      def move_to_end
        @cursor = @text.length # put cursor outside of text
      end

      # Insert characters inside a line. When the lines exceeds
      # maximum length, an extra space is added to accomodate index.
      #
      # @param [Integer] i
      #   the index to insert at
      #
      # @param [String] chars
      #   the characters to insert
      #
      # @example
      #   text = "aaa"
      #   line[5]= "b"
      #   => "aaa  b"
      #
      # @api public
      def []=(i, chars)
        edit_mode

        if i.is_a?(Range)
          @text[i] = chars
          @cursor += chars.length
          return
        end

        if i <= 0
          before_text = ""
          after_text = @text.dup
        elsif i > @text.length - 1 # insert outside of line input
          before_text = @text.dup
          after_text = ?\s * (i - @text.length)
          @cursor += after_text.length
        else
          before_text = @text[0..i-1].dup
          after_text  = @text[i..-1].dup
        end

        if i > @text.length - 1
          @text = before_text + after_text + chars
        else
          @text = before_text + chars + after_text
        end

        @cursor = i + chars.length
      end

      # Read character
      #
      # @api public
      def [](i)
        @text[i]
      end

      # Find a word under the cursor based on a separator
      #
      # @param [Boolean] before
      #   whether to start searching before or after a break character
      #
      # @return [String]
      #
      # @api public
      def word(before: true)
        start_pos = word_start_pos(before: before)
        @text[start_pos..word_end_pos(from: start_pos)]
      end

      # Find a word up to a cursor position
      #
      # @param [Boolean] before
      #   whether to start searching before or after a break character
      #
      # @return [String]
      #
      # @api public
      def word_to_complete(before: true)
        @text[word_start_pos(before: before)...@cursor]
      end

      # Find word start position
      #
      # @param [Integer] from
      #   the index to start searching from, defaults to cursor position
      #
      # @param [Symbol] before
      #   whether to start search before or after break character
      #
      # @return [Integer]
      #
      # @api public
      def word_start_pos(from: @cursor, before: true)
        # move back or forward by one character when at a word boundary
        if word_boundary?
          from = before ? from - 1 : from + 1
        end

        start_pos = @text.rindex(separator, from) || 0
        start_pos += 1 unless start_pos.zero?
        start_pos
      end

      # Find word end position
      #
      # @param [Integer] from
      #   the index to start searching from, defaults to cursor position
      #
      # @return [Integer]
      #
      # @api public
      def word_end_pos(from: @cursor)
        end_pos = @text.index(separator, from) || text_size
        end_pos -= 1 unless @text.empty?
        end_pos
      end

      # Check if cursor is at a word boundary
      #
      # @return [Boolean]
      #
      # @api private
      def word_boundary?
        @text[@cursor] =~ separator
      end

      # Replace current line with new text
      #
      # @param [String] text
      #
      # @api public
      def replace(text)
        @text = text
        @cursor = @text.length # put cursor outside of text
        replace_mode
      end

      # Insert char(s) at cursor position
      #
      # @api public
      def insert(chars)
        self[@cursor] = chars
      end

      # Add char and move cursor
      #
      # @api public
      def <<(char)
        @text << char
        @cursor += 1
      end

      # Remove char from the line at current position
      #
      # @api public
      def delete(n = 1)
        @text.slice!(@cursor, n)
      end

      # Remove char from the line in front of the cursor
      #
      # @param [Integer] n
      #   the number of chars to remove
      #
      # @api public
      def remove(n = 1)
        left(n)
        @text.slice!(@cursor, n)
      end

      # Full line with prompt as string
      #
      # @api public
      def to_s
        "#{@prompt}#{@text}"
      end
      alias inspect to_s

      # Text size
      #
      # @api public
      def text_size
        self.class.sanitize(@text).size
      end

      # Full line size with prompt
      #
      # @api public
      def size
        prompt_size + text_size
      end
      alias length size
    end # Line
  end # Reader
end # TTY