piotrmurach / tty-progressbar

Display a single or multiple progress bars in the terminal.

Home Page:https://ttytoolkit.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Error if "complete" is Unicode

esotericpig opened this issue · comments

Using Unicode in the complete option breaks it with a negative array size error.

# encoding: UTF-8
# frozen_string_literal: true

require 'tty-progressbar'

p = TTY::ProgressBar.new(':title [:bar] :percent :eta',
  incomplete: '〜',head: '語',complete: '本',total: 100,width: 33)

p.start()

0.upto(5) do
  sleep(1)
  p.advance(3)
end

p.finish()

It will produce this error:

Traceback (most recent call last):
 ...
 9: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar.rb:360:in `finish'
 8: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar.rb:284:in `render'
 7: from ~/.rubies/ruby-2.7.0/lib/ruby/2.7.0/forwardable.rb:235:in `decorate'
 6: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/pipeline.rb:34:in `decorate'
 5: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/pipeline.rb:34:in `inject'
 4: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/pipeline.rb:34:in `each'
 3: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/pipeline.rb:37:in `block in decorate'
 2: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/formatter/bar.rb:48:in `format'
 1: from ~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/formatter/bar.rb:48:in `new'
~/.gem/ruby/2.7.0/gems/tty-progressbar-0.17.0/lib/tty/progressbar/formatter/bar.rb:48:in `initialize': negative array size (ArgumentError)

If you change complete to English, it works fine.

Environment

  • Ruby 2.7.0p0 (2019-12-25 revision 647ee6f091)
  • TTY::ProgressBar v0.17.0

Looks like this is actually a scaling (width) issue. If you remove the width: 33 it works. For example, this code produces the same error (on ruby 2.6.5):

# encoding: UTF-8
# frozen_string_literal: true

require 'tty-progressbar'

p = TTY::ProgressBar.new(':title [:bar] :percent :eta',
  incomplete: 'yy',head: 'xx',complete: 'zz',total: 100, width: 33)

p.start()

0.upto(5) do
  sleep(1)
  p.advance(3)
end

p.finish()

What seems to be happening is in BarFormatter, it's calculating by "length" of incomplete/complete chars and comes up with a negative number of incomplete items due to the odd-numbered width. It calculates the width of each character to be 2 (for Unicode & my example), and calculates the "complete_items" for 100% complete to be 17 (33/2 = 16.5, rounded). Then, it calculates the incomplete items:

(33 width - ( 17 complete items * 2 complete_char_length) ) / 2 incomplete_char_length ... = -1

I think clamping incomplete_items to >=0 is likely a safe solution to this, but I can't guarantee it won't have any edge cases. The only other thing I can think of is overriding the user-provided width if it won't fit the character spacing.. but that seems less ideal than just accounting for it in the formatter.

diff --git a/lib/tty/progressbar/formatter/bar.rb b/lib/tty/progressbar/formatter/bar.rb
index 71b1070..b5928a6 100644
--- a/lib/tty/progressbar/formatter/bar.rb
+++ b/lib/tty/progressbar/formatter/bar.rb
@@ -42,7 +42,7 @@ module TTY
         # decimal number of items only when unicode chars are used
         # otherwise it has no effect on regular ascii chars
         complete_items   = (complete_bar_length / complete_char_length.to_f).round
-        incomplete_items = (width - complete_items * complete_char_length) / incomplete_char_length
+        incomplete_items = [(width - complete_items * complete_char_length) / incomplete_char_length, 0].max
 
         complete   = Array.new(complete_items, @progress.complete)
         incomplete = Array.new(incomplete_items, @progress.incomplete)

@slowbro Thanks for investigating this issue. I appreciate it a lot. Your fix suggestion seems reasonable. On the other hand, I'm thinking of making the complete_items to be more conservative and always floor the calculation? The reason why I'm leaning towards this approach is:

  • We don't want to exceed a specified width, in this instance I see 33 as a hard limit. This is especially true for terminal display where every character matters.
  • When displaying Unicode characters it's probably better to err on the side of caution and show the maximum amount we can fit in the given width, in this instance, 16 complete characters.

Any thoughts?

Agreed, I think that taking the given width as "absolute maximum" makes things less confusing; if it's a little shorter to fit within the constraints, it makes more sense than going over the constraint.

The problem I run in to with floor is on the first iteration where you have some progress (i.e. complete_bar_length > 0), `complete_items is still 0 and you get a:

tty-progressbar/lib/tty/progressbar/formatter/bar.rb:50:in `format': index -1 too small for array; minimum: 0 (IndexError)

In addition to this, it gets more complex when the head character is of a different length than the complete character - since we're essentially swapping the last 'complete' char for a 'head' char. In order to not have the bar vary in size, the 'head' character would need to be right-padded with space(s) to account for the difference - or, in the case that the 'head' char was longer than the 'complete' char, take this in to account for calculating the number of complete chars.

I'm trying to find a nice way to calculate all of this. I'm thinking we'd need to find the least common multiple of the complete, head, and incomplete chars that fits within the given width, and then clamp the actual width to that - padding right-padding the head char as needed to maintain spacing.

I'll continue working on it, but right now my brain is not cooperating.

@slowbro This proved to be much more involved than anticipated. I added tests for all possible permutations for complete, incomplete and head options. In the complete and incomplete case, I chose to see how many of the opposing characters 'can fit' in. This is based on the observation that a bar at the start or finish is full of either complete or incomplete characters that need to balance out within the given width at every rendering step. I also covered the head edge case that you described - it's awkward but works. I admit this is far from optimal solution and there may be a much simpler way but it's late and I cannot think of anything better. It'd be great to have another pair of eyes look at this.

I like it, but it seemed to "jump" around for me in my test cases. I am testing, specifically (in order of the image below): 1-char complete/incomplete w/ 2-char head, 2-char complete/incomplete w/ 1-char head, and all unicode (2-char length). I modified it like so and it produced consistent output:

diff --git a/lib/tty/progressbar/formatter/bar.rb b/lib/tty/progressbar/formatter/bar.rb
index b03f66e..4f9f4d1 100644
--- a/lib/tty/progressbar/formatter/bar.rb
+++ b/lib/tty/progressbar/formatter/bar.rb
@@ -59,14 +59,15 @@ module TTY
         complete   = Array.new(complete_items, @progress.complete)
         incomplete = Array.new(incomplete_items, @progress.incomplete)
 
-        if complete.size > 0 && head_char_length > 0
-          extra_space = width - complete_width -
-                        incomplete_items * incomplete_char_length
-          if 0 < extra_space && (head_char_length == extra_space &&
-                                 complete_char_length == extra_space)
-            complete << @progress.head
-          else
+        if complete.size > 0 && head_char_length > 0 && incomplete.size > 0
+          if head_char_length == complete_char_length
             complete[-1] = @progress.head
+          else
+            multiple    = complete_char_length * head_char_length
+            extra_space = multiple - head_char_length
+            complete.pop( head_char_length )
+            complete << @progress.head
+            complete << " " * extra_space
           end
         end

I modified the BarFormatter to print a newline to show each step. This is 0..50% by 10s then a jump to 100% w/ width: 33. Also tested with an even width.

2020-07-07-104347_410x577_scrot

Good catch! Inspired by your code, I implemented a version that handles any size head. Added test to demonstrate behaviour with a 3-characters-long head that uses the incomplete space when required. One thing that I need to sleep on is whether the head should disappear when the bar is done. This is breaking change so didn't want to rush into any decisions yet. Any thoughts?

I have generally been operating that way, but I guess that's not how it 'usually is' - somehow I had gotten that mixed up. For example, pv doesn't get rid of it:

 100MiB 0:00:00 [ 306MiB/s] [=======================================================================>] 100%

I'm indifferent. I think it looks good both ways, to be honest. Maybe it could be an option?

@slowbro Added :clear_head option which by default keeps the head character when progress is done. Thank you for the reviews and feedback! ❤️

Thank you very much!