ruby/lib/irb/pager.rb

213 lines
6.3 KiB
Ruby

# frozen_string_literal: true
require 'reline'
module IRB
# The implementation of this class is borrowed from RDoc's lib/rdoc/ri/driver.rb.
# Please do NOT use this class directly outside of IRB.
class Pager
PAGE_COMMANDS = [ENV['RI_PAGER'], ENV['PAGER'], 'less', 'more'].compact.uniq
class << self
def page_content(content, **options)
if content_exceeds_screen_height?(content)
page(**options) do |io|
io.puts content
end
else
$stdout.puts content
end
end
def page(retain_content: false)
if should_page? && pager = setup_pager(retain_content: retain_content)
begin
pid = pager.pid
yield pager
ensure
pager.close
end
else
yield $stdout
end
# When user presses Ctrl-C, IRB would raise `IRB::Abort`
# But since Pager is implemented by running paging commands like `less` in another process with `IO.popen`,
# the `IRB::Abort` exception only interrupts IRB's execution but doesn't affect the pager
# So to properly terminate the pager with Ctrl-C, we need to catch `IRB::Abort` and kill the pager process
rescue IRB::Abort
begin
begin
Process.kill("TERM", pid) if pid
rescue Errno::EINVAL
# SIGTERM not supported (windows)
Process.kill("KILL", pid)
end
rescue Errno::ESRCH
# Pager process already terminated
end
nil
rescue Errno::EPIPE
end
def should_page?
IRB.conf[:USE_PAGER] && STDIN.tty? && (ENV.key?("TERM") && ENV["TERM"] != "dumb")
end
def page_with_preview(width, height, formatter_proc)
overflow_callback = ->(lines) do
modified_output = formatter_proc.call(lines.join, true)
content, = take_first_page(width, [height - 2, 0].max) {|o| o.write modified_output }
content = content.chomp
content = "#{content}\e[0m" if Color.colorable?
$stdout.puts content
$stdout.puts 'Preparing full inspection value...'
end
out = PageOverflowIO.new(width, height, overflow_callback, delay: 0.1)
yield out
content = formatter_proc.call(out.string, out.multipage?)
if out.multipage?
page(retain_content: true) do |io|
io.puts content
end
else
$stdout.puts content
end
end
def take_first_page(width, height)
overflow_callback = proc do |lines|
return lines.join, true
end
out = Pager::PageOverflowIO.new(width, height, overflow_callback)
yield out
[out.string, false]
end
private
def content_exceeds_screen_height?(content)
screen_height, screen_width = begin
Reline.get_screen_size
rescue Errno::EINVAL
[24, 80]
end
pageable_height = screen_height - 3 # leave some space for previous and the current prompt
return true if content.lines.size > pageable_height
_, overflow = take_first_page(screen_width, pageable_height) {|out| out.write content }
overflow
end
def setup_pager(retain_content:)
require 'shellwords'
PAGE_COMMANDS.each do |pager_cmd|
cmd = Shellwords.split(pager_cmd)
next if cmd.empty?
if cmd.first == 'less'
cmd << '-R' unless cmd.include?('-R')
cmd << '-X' if retain_content && !cmd.include?('-X')
end
begin
io = IO.popen(cmd, 'w')
rescue
next
end
if $? && $?.pid == io.pid && $?.exited? # pager didn't work
next
end
return io
end
nil
end
end
# Writable IO that has page overflow callback
class PageOverflowIO
attr_reader :string, :first_page_lines
# Maximum size of a single cell in terminal
# Assumed worst case: "\e[1;3;4;9;38;2;255;128;128;48;2;128;128;255mA\e[0m"
# bold, italic, underline, crossed_out, RGB forgound, RGB background
MAX_CHAR_PER_CELL = 50
def initialize(width, height, overflow_callback, delay: nil)
@lines = []
@first_page_lines = nil
@width = width
@height = height
@buffer = +''
@overflow_callback = overflow_callback
@col = 0
@string = +''
@multipage = false
@delay_until = (Time.now + delay if delay)
end
def puts(text = '')
text = text.to_s unless text.is_a?(String)
write(text)
write("\n") unless text.end_with?("\n")
end
def write(text)
text = text.to_s unless text.is_a?(String)
@string << text
if @multipage
if @delay_until && Time.now > @delay_until
@overflow_callback.call(@first_page_lines)
@delay_until = nil
end
return
end
overflow_size = (@width * (@height - @lines.size) + @width - @col) * MAX_CHAR_PER_CELL
if text.size >= overflow_size
text = text[0, overflow_size]
overflow = true
end
@buffer << text
@col += Reline::Unicode.calculate_width(text, true)
if text.include?("\n") || @col >= @width
@buffer.lines.each do |line|
wrapped_lines = Reline::Unicode.split_by_width(line.chomp, @width).first.compact
wrapped_lines.pop if wrapped_lines.last == ''
@lines.concat(wrapped_lines)
if line.end_with?("\n")
if @lines.empty? || @lines.last.end_with?("\n")
@lines << "\n"
else
@lines[-1] += "\n"
end
end
end
@buffer.clear
@buffer << @lines.pop unless @lines.last.end_with?("\n")
@col = Reline::Unicode.calculate_width(@buffer, true)
end
if overflow || @lines.size > @height || (@lines.size == @height && @col > 0)
@first_page_lines = @lines.take(@height)
if !@delay_until || Time.now > @delay_until
@overflow_callback.call(@first_page_lines)
@delay_until = nil
end
@multipage = true
end
end
def multipage?
@multipage
end
alias print write
alias << write
end
end
end