piotrmurach/tty-screen

View on GitHub
lib/tty/screen.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# frozen_string_literal: true

begin
  require "rbconfig"
rescue LoadError
end

require_relative "screen/version"

module TTY
  # Responsible for detecting terminal screen size
  #
  # @api public
  module Screen
    # The Ruby configuration
    #
    # @return [Hash]
    #
    # @api private
    RUBY_CONFIG = defined?(::RbConfig) ? ::RbConfig::CONFIG : {}
    private_constant :RUBY_CONFIG

    # Define module method as private
    #
    # @return [void]
    #
    # @api private
    def self.private_module_function(name)
      module_function(name)
      private_class_method(name)
    end

    case RUBY_CONFIG["host_os"] || ::RUBY_PLATFORM
    when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
      # Detect Windows system
      #
      # @return [Boolean]
      #
      # @api private
      def windows?; true end
    else
      def windows?; false end
    end
    module_function :windows?

    case RUBY_CONFIG["ruby_install_name"] || ::RUBY_ENGINE
    when /jruby/
      # Detect JRuby
      #
      # @return [Boolean]
      #
      # @api private
      def jruby?; true end
    else
      def jruby?; false end
    end
    module_function :jruby?

    # The default terminal screen size
    #
    # @return [Array(Integer, Integer)]
    #
    # @api private
    DEFAULT_SIZE = [27, 80].freeze

    @env = ENV
    @output = $stderr

    class << self
      # The environment variables
      #
      # @example
      #   TTY::Screen.env
      #
      # @example
      #   TTY::Screen.env = {"ROWS" => "51", "COLUMNS" => "211"}
      #
      # @return [Enumerable]
      #
      # @api public
      attr_accessor :env

      # The output stream with standard error as default
      #
      # @example
      #   TTY::Screen.output
      #
      # @example
      #   TTY::Screen.output = $stdout
      #
      # @return [IO]
      #
      # @api public
      attr_accessor :output
    end

    # Detect terminal screen size
    #
    # @example
    #   TTY::Screen.size # => [51, 211]
    #
    # @return [Array(Integer, Integer)]
    #   the terminal screen size
    #
    # @api public
    def size(verbose: false)
      size_from_java(verbose: verbose) ||
      size_from_win_api(verbose: verbose) ||
      size_from_ioctl ||
      size_from_io_console(verbose: verbose) ||
      size_from_readline(verbose: verbose) ||
      size_from_tput ||
      size_from_stty ||
      size_from_env ||
      size_from_ansicon ||
      size_from_default
    end
    module_function :size

    # Detect terminal screen width
    #
    # @example
    #   TTY::Screen.width # => 211
    #
    # @return [Integer]
    #
    # @api public
    def width
      size[1]
    end
    module_function :width

    alias columns width
    alias cols width
    module_function :columns
    module_function :cols

    # Detect terminal screen height
    #
    # @example
    #   TTY::Screen.height # => 51
    #
    # @return [Integer]
    #
    # @api public
    def height
      size[0]
    end
    module_function :height

    alias rows height
    alias lines height
    module_function :rows
    module_function :lines

    # Detect terminal screen size from default
    #
    # @return [Array(Integer, Integer)]
    #
    # @api private
    def size_from_default
      DEFAULT_SIZE
    end
    module_function :size_from_default

    if windows?
      # The standard output handle
      #
      # @return [Integer]
      #
      # @api private
      STDOUT_HANDLE = 0xFFFFFFF5

      # Detect terminal screen size from Windows API
      #
      # @param [Boolean] verbose
      #   the verbose mode, by default false
      #
      # @return [Array(Integer, Integer), nil]
      #   the terminal screen size or nil
      #
      # @api private
      def size_from_win_api(verbose: false)
        require "fiddle" unless defined?(Fiddle)

        kernel32 = Fiddle::Handle.new("kernel32")
        get_std_handle = Fiddle::Function.new(
          kernel32["GetStdHandle"], [-Fiddle::TYPE_INT], Fiddle::TYPE_INT)
        get_console_buffer_info = Fiddle::Function.new(
          kernel32["GetConsoleScreenBufferInfo"],
          [Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)

        format = "SSSSSssssSS"
        buffer = ([0] * format.size).pack(format)
        stdout_handle = get_std_handle.(STDOUT_HANDLE)

        get_console_buffer_info.(stdout_handle, buffer)
        _, _, _, _, _, left, top, right, bottom, = buffer.unpack(format)
        size = [bottom - top + 1, right - left + 1]
        size if nonzero_column?(size[1] - 1)
      rescue LoadError
        warn "no native fiddle module found" if verbose
      rescue Fiddle::DLError
        # non windows platform or no kernel32 lib
      end
    else
      def size_from_win_api(verbose: false); nil end
    end
    module_function :size_from_win_api

    if jruby?
      # Detect terminal screen size from Java
      #
      # @param [Boolean] verbose
      #   the verbose mode, by default false
      #
      # @return [Array(Integer, Integer), nil]
      #   the terminal screen size or nil
      #
      # @api private
      def size_from_java(verbose: false)
        require "java"

        java_import "jline.TerminalFactory"
        terminal = TerminalFactory.get
        size = [terminal.get_height, terminal.get_width]
        size if nonzero_column?(size[1])
      rescue
        warn "failed to import java terminal package" if verbose
      end
    else
      def size_from_java(verbose: false); nil end
    end
    module_function :size_from_java

    # Detect terminal screen size from io-console
    #
    # On Windows, the io-console falls back to reading environment
    # variables. This means any user changes to the terminal screen
    # size will not be reflected in the runtime of the Ruby application.
    #
    # @param [Boolean] verbose
    #   the verbose mode, by default false
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_io_console(verbose: false)
      return unless output.tty?

      require "io/console" unless IO.method_defined?(:winsize)
      return unless output.respond_to?(:winsize)

      size = output.winsize
      size if nonzero_column?(size[1])
    rescue Errno::EOPNOTSUPP
      # no support for winsize on output
    rescue LoadError
      warn "no native io/console support or io-console gem" if verbose
    end
    module_function :size_from_io_console

    if !jruby? && output.respond_to?(:ioctl)
      # The get window size control code for Linux
      #
      # @return [Integer]
      #
      # @api private
      TIOCGWINSZ = 0x5413

      # The get window size control code for FreeBSD and macOS
      #
      # @return [Integer]
      #
      # @api private
      TIOCGWINSZ_PPC = 0x40087468

      # The get window size control code for Solaris
      #
      # @return [Integer]
      #
      # @api private
      TIOCGWINSZ_SOL = 0x5468

      # The ioctl window size buffer format
      #
      # @return [String]
      #
      # @api private
      TIOCGWINSZ_BUF_FMT = "SSSS"
      private_constant :TIOCGWINSZ_BUF_FMT

      # The ioctl window size buffer length
      #
      # @return [Integer]
      #
      # @api private
      TIOCGWINSZ_BUF_LEN = TIOCGWINSZ_BUF_FMT.length
      private_constant :TIOCGWINSZ_BUF_LEN

      # Detect terminal screen size from ioctl
      #
      # @return [Array(Integer, Integer), nil]
      #   the terminal screen size or nil
      #
      # @api private
      def size_from_ioctl
        buffer = Array.new(TIOCGWINSZ_BUF_LEN, 0).pack(TIOCGWINSZ_BUF_FMT)

        if ioctl?(TIOCGWINSZ, buffer) ||
           ioctl?(TIOCGWINSZ_PPC, buffer) ||
           ioctl?(TIOCGWINSZ_SOL, buffer)

          rows, cols, = buffer.unpack(TIOCGWINSZ_BUF_FMT)
          [rows, cols] if nonzero_column?(cols)
        end
      end

      # Check if the ioctl call gets window size on any standard stream
      #
      # @param [Integer] control
      #   the control code
      # @param [String] buf
      #   the window size buffer
      #
      # @return [Boolean]
      #   true if the ioctl call gets window size, false otherwise
      #
      # @api private
      def ioctl?(control, buf)
        ($stdout.ioctl(control, buf) >= 0) ||
          ($stdin.ioctl(control, buf) >= 0) ||
          ($stderr.ioctl(control, buf) >= 0)
      rescue SystemCallError
        false
      end
      module_function :ioctl?
    else
      def size_from_ioctl; nil end
    end
    module_function :size_from_ioctl

    # Detect terminal screen size from readline
    #
    # @param [Boolean] verbose
    #   the verbose mode, by default false
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_readline(verbose: false)
      return unless output.tty?

      require "readline" unless defined?(::Readline)
      return unless ::Readline.respond_to?(:get_screen_size)

      size = ::Readline.get_screen_size
      size if nonzero_column?(size[1])
    rescue LoadError
      warn "no readline gem" if verbose
    rescue NotImplementedError
    end
    module_function :size_from_readline

    # Detect terminal screen size from tput
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_tput
      return unless output.tty? && command_exist?("tput")

      lines = run_command("tput", "lines")
      return unless lines

      cols = run_command("tput", "cols")
      [lines.to_i, cols.to_i] if nonzero_column?(cols)
    end
    module_function :size_from_tput

    # Detect terminal screen size from stty
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_stty
      return unless output.tty? && command_exist?("stty")

      out = run_command("stty", "size")
      return unless out

      size = out.split.map(&:to_i)
      size if nonzero_column?(size[1])
    end
    module_function :size_from_stty

    # Detect terminal screen size from environment variables
    #
    # After executing Ruby code, when the user changes terminal
    # screen size during code runtime, the code will not be
    # notified, and hence will not see the new size reflected
    # in its copy of LINES and COLUMNS environment variables.
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_env
      return unless env["COLUMNS"] =~ /^\d+$/

      size = [(env["LINES"] || env["ROWS"]).to_i, env["COLUMNS"].to_i]
      size if nonzero_column?(size[1])
    end
    module_function :size_from_env

    # Detect terminal screen size from the ANSICON environment variable
    #
    # @return [Array(Integer, Integer), nil]
    #   the terminal screen size or nil
    #
    # @api private
    def size_from_ansicon
      return unless env["ANSICON"] =~ /\((.*)x(.*)\)/

      size = [::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i]
      size if nonzero_column?(size[1])
    end
    module_function :size_from_ansicon

    # Check if a command exists
    #
    # @param [String] command
    #   the command to check
    #
    # @return [Boolean]
    #
    # @api private
    def command_exist?(command)
      exts = env.fetch("PATHEXT", "").split(::File::PATH_SEPARATOR)
      env.fetch("PATH", "").split(::File::PATH_SEPARATOR).any? do |dir|
        file = ::File.join(dir, command)
        ::File.exist?(file) ||
          exts.any? { |ext| ::File.exist?("#{file}#{ext}") }
      end
    end
    private_module_function :command_exist?

    # Run command capturing the standard output
    #
    # @param [Array<String>] args
    #   the command arguments
    #
    # @return [String, nil]
    #   the command output or nil
    #
    # @api private
    def run_command(*args)
      %x(#{args.join(" ")})
    rescue IOError, SystemCallError
      nil
    end
    private_module_function :run_command

    # Check if a number is non-zero
    #
    # @param [Integer, String] column
    #   the column to check
    #
    # @return [Boolean]
    #
    # @api private
    def nonzero_column?(column)
      column.to_i > 0
    end
    private_module_function :nonzero_column?
  end # Screen
end # TTY