Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show a quick preview of inspect result before pager launch #1040

Merged
merged 5 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 27 additions & 31 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -516,40 +516,36 @@ def signal_status(status)
end

def output_value(omit = false) # :nodoc:
str = @context.inspect_last_value
multiline_p = str.include?("\n")
if omit
winwidth = @context.io.winsize.last
if multiline_p
first_line = str.split("\n").first
result = @context.newline_before_multiline_output? ? (@context.return_format % first_line) : first_line
output_width = Reline::Unicode.calculate_width(result, true)
diff_size = output_width - Reline::Unicode.calculate_width(first_line, true)
if diff_size.positive? and output_width > winwidth
lines, _ = Reline::Unicode.split_by_width(first_line, winwidth - diff_size - 3)
str = "%s..." % lines.first
str += "\e[0m" if Color.colorable?
multiline_p = false
else
str = str.gsub(/(\A.*?\n).*/m, "\\1...")
str += "\e[0m" if Color.colorable?
end
else
output_width = Reline::Unicode.calculate_width(@context.return_format % str, true)
diff_size = output_width - Reline::Unicode.calculate_width(str, true)
if diff_size.positive? and output_width > winwidth
lines, _ = Reline::Unicode.split_by_width(str, winwidth - diff_size - 3)
str = "%s..." % lines.first
str += "\e[0m" if Color.colorable?
end
end
unless @context.return_format.include?('%')
puts @context.return_format
return
end

if multiline_p && @context.newline_before_multiline_output?
str = "\n" + str
winheight, winwidth = @context.io.winsize
if omit
content, overflow = Pager.take_first_page(winwidth, 1) do |out|
@context.inspect_last_value(out)
end
if overflow
content = "\n#{content}" if @context.newline_before_multiline_output?
content = "#{content}..."
content = "#{content}\e[0m" if Color.colorable?
end
puts format(@context.return_format, content.chomp)
elsif Pager.should_page? && @context.inspector_support_stream_output?
formatter_proc = ->(content, multipage) do
content = content.chomp
content = "\n#{content}" if @context.newline_before_multiline_output? && (multipage || content.include?("\n"))
format(@context.return_format, content)
end
Pager.page_with_preview(winwidth, winheight, formatter_proc) do |out|
@context.inspect_last_value(out)
end
else
content = @context.inspect_last_value.chomp
content = "\n#{content}" if @context.newline_before_multiline_output? && content.include?("\n")
Pager.page_content(format(@context.return_format, content), retain_content: true)
end

Pager.page_content(format(@context.return_format, str), retain_content: true)
end

# Outputs the local variables to this current session, including #signal_status
Expand Down
2 changes: 1 addition & 1 deletion lib/irb/command/copy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def execute(arg)
arg = '_' if arg.to_s.strip.empty?

value = irb_context.workspace.binding.eval(arg)
output = irb_context.inspect_method.inspect_value(value, colorize: false)
output = irb_context.inspect_method.inspect_value(value, +'', colorize: false).chomp

if clipboard_available?
copy_to_clipboard(output)
Expand Down
8 changes: 6 additions & 2 deletions lib/irb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,12 @@ def colorize_input(input, complete:)
end
end

def inspect_last_value # :nodoc:
@inspect_method.inspect_value(@last_value)
def inspect_last_value(output = +'') # :nodoc:
@inspect_method.inspect_value(@last_value, output)
end

def inspector_support_stream_output?
@inspect_method.support_stream_output?
end

NOPRINTING_IVARS = ["@last_value"] # :nodoc:
Expand Down
13 changes: 9 additions & 4 deletions lib/irb/inspector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,14 @@ def init
@init.call if @init
end

def support_stream_output?
second_parameter_type = @inspect.parameters[1]&.first
second_parameter_type == :req || second_parameter_type == :opt
end

# Proc to call when the input is evaluated and output in irb.
def inspect_value(v, colorize: true)
@inspect.call(v, colorize: colorize)
def inspect_value(v, output, colorize: true)
support_stream_output? ? @inspect.call(v, output, colorize: colorize) : output << @inspect.call(v, colorize: colorize)
rescue => e
puts "An error occurred when inspecting the object: #{e.inspect}"

Expand All @@ -113,8 +118,8 @@ def inspect_value(v, colorize: true)
Inspector.def_inspector([:p, :inspect]){|v, colorize: true|
Color.colorize_code(v.inspect, colorable: colorize && Color.colorable? && Color.inspect_colorable?(v))
}
Inspector.def_inspector([true, :pp, :pretty_inspect], proc{require_relative "color_printer"}){|v, colorize: true|
IRB::ColorPrinter.pp(v, +'', colorize: colorize).chomp
Inspector.def_inspector([true, :pp, :pretty_inspect], proc{require_relative "color_printer"}){|v, output, colorize: true|
IRB::ColorPrinter.pp(v, output, colorize: colorize)
}
Inspector.def_inspector([:yaml, :YAML], proc{require "yaml"}){|v|
begin
Expand Down
122 changes: 116 additions & 6 deletions lib/irb/pager.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'reline'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: do you think pager should be part of Reline?
If we position Reline as Ruby's default terminal application utility library, it kinda makes sense for it to have a pager class too 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the basic pager should be in Reline, because it's mainly about command process names(less, more) and options(-R, -X).
The added preview part is IRB specific that we can't assume pretty_print not printing a log.
If we can assume no log while pritty-printing, there is an easy way: just stream the output to pager io.

Making a pure-ruby pager library (key handling, rendering, scrolling, etc): I think it is very interesting.


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.
Expand Down Expand Up @@ -47,12 +49,42 @@ def page(retain_content: false)
rescue Errno::EPIPE
end

private

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
tompng marked this conversation as resolved.
Show resolved Hide resolved
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)
st0012 marked this conversation as resolved.
Show resolved Hide resolved
overflow_callback = proc do |lines|
return lines.join, true
end
out = Pager::PageOverflowIO.new(width, height, overflow_callback)
yield out
[out.string, false]
st0012 marked this conversation as resolved.
Show resolved Hide resolved
end

private

def content_exceeds_screen_height?(content)
screen_height, screen_width = begin
Reline.get_screen_size
Expand All @@ -62,10 +94,10 @@ def content_exceeds_screen_height?(content)

pageable_height = screen_height - 3 # leave some space for previous and the current prompt

# If the content has more lines than the pageable height
content.lines.count > pageable_height ||
# Or if the content is a few long lines
pageable_height * screen_width < Reline::Unicode.calculate_width(content, true)
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:)
Expand Down Expand Up @@ -96,5 +128,83 @@ def setup_pager(retain_content:)
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 = '')
write(text)
write("\n") unless text.end_with?("\n")
end

def write(text)
@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)
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 @lines.empty?
@lines << "\n"
elsif line.end_with?("\n")
@lines[-1] += "\n"
end
end
@buffer.clear
@buffer << @lines.pop unless @lines.last.end_with?("\n")
@col = Reline::Unicode.calculate_width(@buffer)
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
2 changes: 1 addition & 1 deletion test/irb/test_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ def test_omit_multiline_on_assignment
irb.eval_input
end
assert_empty err
assert_equal("=> #{value_first_line[0..(input.winsize.last - 9)]}...\n=> \n#{value}\n", out)
assert_equal("=> \n#{value_first_line[0, input.winsize.last]}...\n=> \n#{value}\n", out)
irb.context.evaluate_expression('A.remove_method(:inspect)', 0)

input.reset
Expand Down
66 changes: 66 additions & 0 deletions test/irb/test_pager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: false
require 'irb/pager'

require_relative 'helper'

module TestIRB
class PagerTest < TestCase
def test_take_first_page
assert_equal ['a' * 40, true], IRB::Pager.take_first_page(10, 4) {|io| io.puts 'a' * 41; raise 'should not reach here' }
assert_equal ['a' * 39, false], IRB::Pager.take_first_page(10, 4) {|io| io.write 'a' * 39 }
assert_equal ['a' * 39 + 'b', false], IRB::Pager.take_first_page(10, 4) {|io| io.write 'a' * 39 + 'b' }
assert_equal ['a' * 39 + 'b', true], IRB::Pager.take_first_page(10, 4) {|io| io.write 'a' * 39 + 'bc' }
assert_equal ["a\nb\nc\nd\n", false], IRB::Pager.take_first_page(10, 4) {|io| io.write "a\nb\nc\nd\n" }
assert_equal ["a\nb\nc\nd\n", true], IRB::Pager.take_first_page(10, 4) {|io| io.write "a\nb\nc\nd\ne" }
assert_equal ['a' * 15 + "\n" + 'b' * 20, true], IRB::Pager.take_first_page(10, 4) {|io| io.puts 'a' * 15; io.puts 'b' * 30 }
assert_equal ["\e[31mA\e[0m" * 10 + 'x' * 30, true], IRB::Pager.take_first_page(10, 4) {|io| io.puts "\e[31mA\e[0m" * 10 + 'x' * 31; }
end
end

class PageOverflowIOTest < TestCase
def test_overflow
actual_events = []
overflow_callback = ->(lines) do
actual_events << [:callback_called, lines]
end
out = IRB::Pager::PageOverflowIO.new(10, 4, overflow_callback)
out.puts 'a' * 15
out.write 'b' * 15

actual_events << :before_write
out.write 'c' * 1000
actual_events << :after_write

out.puts 'd' * 1000
out.write 'e' * 1000

expected_events = [
:before_write,
[:callback_called, ['a' * 10, 'a' * 5 + "\n", 'b' * 10, 'b' * 5 + 'c' * 5]],
:after_write,
]
assert_equal expected_events, actual_events

expected_whole_content = 'a' * 15 + "\n" + 'b' * 15 + 'c' * 1000 + 'd' * 1000 + "\n" + 'e' * 1000
assert_equal expected_whole_content, out.string
end

def test_callback_delay
actual_events = []
overflow_callback = ->(lines) do
actual_events << [:callback_called, lines]
end
out = IRB::Pager::PageOverflowIO.new(10, 4, overflow_callback, delay: 0.2)
out.write 'a' * 1000
assert_equal ['a' * 10] * 4, out.first_page_lines
out.write 'b'
actual_events << :before_delay
sleep 0.2
out.write 'c'
actual_events << :after_delay
out.write 'd'
assert_equal 'a' * 1000 + 'bcd', out.string
assert_equal [:before_delay, [:callback_called, ['a' * 10] * 4], :after_delay], actual_events
end
end
end
18 changes: 17 additions & 1 deletion test/irb/yamatanooroti/test_rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -385,12 +385,28 @@ def test_long_evaluation_output_is_paged
write("'a' * 80 * 11\n")
write("'foo' + 'bar'\n") # eval something to make sure IRB resumes

assert_screen(/(a{80}\n){8}/)
assert_screen(/"a{79}\n(a{80}\n){7}/)
# because pager is invoked, foobar will not be evaluated
assert_screen(/\A(?!foobar)/)
close
end

def test_pretty_print_preview_with_slow_inspect
write_irbrc <<~'LINES'
require "irb/pager"
LINES
start_terminal(10, 80, %W{ruby -I#{@pwd}/lib #{@pwd}/exe/irb}, startup_message: /irb\(main\)/)
write("o1 = Object.new; def o1.inspect; 'INSPECT'; end\n")
write("o2 = Object.new; def o2.inspect; sleep 0.1; 'SLOW'; end\n")
# preview should be shown even if pretty_print is not completed.
write("[o1] * 20 + [o2] * 100\n")
assert_screen(/=>\n\[INSPECT,\n( INSPECT,\n){6}Preparing full inspection value\.\.\./)
write("\C-c") # abort pretty_print
write("'foo' + 'bar'\n") # eval something to make sure IRB resumes
assert_screen(/foobar/)
close
end

def test_long_evaluation_output_is_preserved_after_paging
write_irbrc <<~'LINES'
require "irb/pager"
Expand Down
Loading