module Doing::Prompt

Terminal Prompt methods

Attributes

default_answer[W]
force_answer[W]

Public Class Methods

choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: []) click to toggle source

Generate a menu of options and allow user selection

@return [String] The selected option

# File lib/doing/prompt.rb, line 228
def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
  return nil unless $stdout.isatty

  # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
  default_args = []
  default_args << %(--prompt="#{prompt}")
  default_args << "--height=#{options.count + 2}"
  default_args << '--info=inline'
  default_args << '--multi' if multiple
  header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
  default_args << %(--header="#{header}")
  default_args.concat(fzf_args)
  options.sort! if sorted

  res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{default_args.join(' ')}`
  return false if res.strip.size.zero?

  res
end
choose_from_items(items, **opt) click to toggle source

Create an interactive menu to select from a set of Items

@param items [Array] list of items @param opt Additional options

@option opt [Boolean] :include_section Include section name for each item in menu @option opt [String] :header A custom header string @option opt [String] :prompt A custom prompt string @option opt [String] :query Initial query @option opt [Boolean] :show_if_single Show menu even if there's only one option @option opt [Boolean] :menu Show menu @option opt [Boolean] :sort Sort options @option opt [Boolean] :multiple Allow multiple selections @option opt [Symbol] :case (:sensitive, :ignore, :smart)

# File lib/doing/prompt.rb, line 264
def choose_from_items(items, **opt)
  return items unless $stdout.isatty

  return nil unless items.count.positive?

  case_sensitive = opt.fetch(:case, :smart).normalize_case
  header = opt.fetch(:header, 'Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit')
  prompt = opt.fetch(:prompt, 'Select entries to act on > ')
  query = opt.fetch(:query) { opt.fetch(:search, '') }
  include_section = opt.fetch(:include_section, false)

  pad = items.length.to_s.length
  options = items.map.with_index do |item, i|
    out = [
      format("%#{pad}d", i),
      ') ',
      format('%16s', item.date.strftime('%Y-%m-%d %H:%M')),
      ' | ',
      item.title
    ]
    if include_section
      out.concat([
        ' (',
        item.section,
        ') '
      ])
    end
    out.join('')
  end

  fzf_args = [
    %(--header="#{header}"),
    %(--prompt="#{prompt.sub(/ *$/, ' ')}"),
    opt.fetch(:multiple) ? '--multi' : '--no-multi',
    '-0',
    '--bind ctrl-a:select-all',
    %(-q "#{query}"),
    '--info=inline'
  ]
  fzf_args.push('-1') unless opt.fetch(:show_if_single, false)
  fzf_args << case case_sensitive
              when :sensitive
                '+i'
              when :ignore
                '-i'
              end
  fzf_args << '-e' if opt.fetch(:exact, false)


  unless opt.fetch(:menu)
    raise InvalidArgument, "Can't skip menu when no query is provided" unless query && !query.empty?

    fzf_args.concat([%(--filter="#{query}"), opt.fetch(:sort) ? '' : '--no-sort'])
  end

  res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
  selected = []
  res.split(/\n/).each do |item|
    idx = item.match(/^ *(\d+)\)/)[1].to_i
    selected.push(items[idx])
  end

  opt.fetch(:multiple) ? selected : selected[0]
end
clear_screen(msg = nil) click to toggle source

Clear the terminal screen

# File lib/doing/prompt.rb, line 14
def clear_screen(msg = nil)
  puts "\e[H\e[2J" if STDOUT.tty?
  puts msg if msg.good?
end
default_answer() click to toggle source
# File lib/doing/prompt.rb, line 23
def default_answer
  @default_answer ||= false
end
enter_text(prompt, default_response: '') click to toggle source
# File lib/doing/prompt.rb, line 27
def enter_text(prompt, default_response: '')
  $stdin.reopen('/dev/tty')
  return default_response if @default_answer

  print "#{yellow(prompt).sub(/:?$/, ':')} #{reset}"
  $stdin.gets.strip
end
force_answer() click to toggle source
# File lib/doing/prompt.rb, line 19
def force_answer
  @force_answer ||= nil
end
fzf() click to toggle source
# File lib/doing/prompt.rb, line 157
def fzf
  @fzf ||= install_fzf
end
install_fzf(force: false) click to toggle source
# File lib/doing/prompt.rb, line 186
def install_fzf(force: false)
  if force
    uninstall_fzf
  elsif which_fzf
    return which_fzf
  end

  fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
  FileUtils.mkdir_p(fzf_dir) unless File.directory?(fzf_dir)
  fzf_bin = File.join(fzf_dir, 'bin/fzf')
  return fzf_bin if File.exist?(fzf_bin)

  prev_level = Doing.logger.level
  Doing.logger.adjust_verbosity({ log_level: :info })
  Doing.logger.log_now(:warn, 'fzf:', 'Compiling and installing fzf -- this will only happen once')
  Doing.logger.log_now(:warn, 'fzf:', 'fzf is copyright Junegunn Choi, MIT License <https://github.com/junegunn/fzf/blob/master/LICENSE>')

  silence_std
  `'#{fzf_dir}/install' --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish &> /dev/null`
  unless File.exist?(fzf_bin)
    restore_std
    Doing.logger.log_now(:warn, 'Error installing, trying again as root')
    silence_std
    `sudo '#{fzf_dir}/install' --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish &> /dev/null`
  end
  restore_std
  unless File.exist?(fzf_bin)
    Doing.logger.error('fzf:', 'unable to install fzf. You can install manually and Doing will use the system version.')
    Doing.logger.error('fzf:', 'see https://github.com/junegunn/fzf#installation')
    raise RuntimeError.new('Error installing fzf, please report at https://github.com/ttscoff/doing/issues')
  end

  Doing.logger.info('fzf:', "installed to #{fzf}")
  Doing.logger.adjust_verbosity({ log_level: prev_level })
  fzf_bin
end
read_line(prompt: 'Enter text', completions: [], default_response: '') click to toggle source
# File lib/doing/prompt.rb, line 35
def read_line(prompt: 'Enter text', completions: [], default_response: '')
  $stdin.reopen('/dev/tty')
  return default_response if @default_answer

  unless completions.empty?
    completions.sort!
    comp = proc { |s| completions.grep(/^#{Regexp.escape(s)}/) }
    Readline.completion_append_character = ' '
    Readline.completion_proc = comp
  end

  begin
    Readline.readline("#{yellow(prompt).sub(/:?$/, ':')} #{reset}", true).strip
  rescue Interrupt
    raise UserCancelled
  end
end
read_lines(prompt: 'Enter text', completions: [], default_response: '') click to toggle source
# File lib/doing/prompt.rb, line 53
def read_lines(prompt: 'Enter text', completions: [], default_response: '')
  $stdin.reopen('/dev/tty')
  return default_response if @default_answer

  completions.sort!
  comp = proc { |s| completions.grep(/^#{Regexp.escape(s)}/) }
  Readline.completion_append_character = ' '
  Readline.completion_proc = comp
  puts format(['%<promptcolor>s%<prompt>s %<textcolor>sEnter a blank line',
    '(%<keycolor>sreturn twice%<textcolor>s)',
    'to end editing and save,',
    '%<keycolor>sCTRL-C%<textcolor>s to cancel%<reset>s'].join(' '),
    { promptcolor: boldgreen, prompt: prompt.sub(/:?$/, ':'),
      textcolor: yellow, keycolor: boldwhite, reset: reset })

  res = []

  begin
    while (line = Readline.readline('> ', true))
      break if line.strip.empty?

      res << line.chomp
    end
  rescue Interrupt
    raise UserCancelled
  end

  res.join("\n").strip
end
request_lines(prompt: 'Enter text', default_response: '') click to toggle source
# File lib/doing/prompt.rb, line 83
def request_lines(prompt: 'Enter text', default_response: '')
  $stdin.reopen('/dev/tty')
  return default_response if @default_answer

  ask_note = []
  reader = TTY::Reader.new(interrupt: -> { raise Errors::UserCancelled }, track_history: false)
  puts "#{boldgreen(prompt.sub(/:?$/, ':'))} #{yellow('Hit return for a new line, ')}#{boldwhite('enter a blank line (')}#{boldyellow('return twice')}#{boldwhite(') to end editing')}"
  loop do
    res = reader.read_line(green('> '))
    break if res.strip.empty?

    ask_note.push(res)
  end
  ask_note.join("\n").strip
end
restore_std() click to toggle source
# File lib/doing/prompt.rb, line 181
def restore_std
  $stdout = STDOUT
  $stderr = STDERR
end
silence_std(file = '/dev/null') click to toggle source
# File lib/doing/prompt.rb, line 176
def silence_std(file = '/dev/null')
  $stdout = File.new(file, 'w')
  $stderr = File.new(file, 'w')
end
uninstall_fzf() click to toggle source
# File lib/doing/prompt.rb, line 161
def uninstall_fzf
  fzf_bin = File.join(File.dirname(__FILE__), '../helpers/fzf/bin/fzf')
  FileUtils.rm_f(fzf_bin) if File.exist?(fzf_bin)
  Doing.logger.warn('fzf:', "removed #{fzf_bin}")
end
which_fzf() click to toggle source
# File lib/doing/prompt.rb, line 167
def which_fzf
  fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
  fzf_bin = File.join(fzf_dir, 'bin/fzf')
  return fzf_bin if File.exist?(fzf_bin)

  Doing.logger.debug('fzf:', 'Using user-installed fzf')
  TTY::Which.which('fzf')
end
yn(question, default_response: false) click to toggle source

Ask a yes or no question in the terminal

@param question [String] The question to ask @param default_response (Bool) default response if no input

@return (Bool) yes or no

# File lib/doing/prompt.rb, line 109
def yn(question, default_response: false)
  return @force_answer == :yes ? true : false unless @force_answer.nil?

  $stdin.reopen('/dev/tty')

  default = if default_response.is_a?(String)
              default_response =~ /y/i ? true : false
            else
              default_response
            end

  # if global --default is set, answer default
  return default if @default_answer

  # if this isn't an interactive shell, answer default
  return default unless $stdout.isatty

  # clear the buffer
  if ARGV&.length
    ARGV.length.times do
      ARGV.shift
    end
  end
  system 'stty cbreak'

  cw = white
  cbw = boldwhite
  cbg = boldgreen
  cd = Color.default

  options = unless default.nil?
              "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
            else
              "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
            end
  $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
  res = $stdin.sysread 1
  puts
  system 'stty cooked'

  res.chomp!
  res.downcase!

  return default if res.empty?

  res =~ /y/i ? true : false
end