Login
Artifact [bcd329f311]
Login

Artifact bcd329f3113e7ffb643846e64ace377a6b19d9499c22d324d7900d5a8be865b2:


#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify
#### it under the terms of the GNU General Public License as published
#### the Free Software Foundation, either version 3 of the License, or
#### (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
#### GNU General Public License for more details.
####
#### You should have received a copy of the GNU General Public License
#### along with this program.If not, see<http:####www.gnu.org/licenses/.>
require "./ansi"

module RemiLib::Console
  # A console-based progress bar.  This uses ANSI control characters to draw the
  # bar on one line.
  class ProgressBar(T)
    class ProgressBarDoneError < Exception
    end

    # The current step.
    getter step : T = T.zero

    # The max number of steps.
    getter max : T

    # The maximum width for the progress bar label.  If this is 0, then the max
    # width is computed to be 1/5th of the width of the console.
    getter labelWidth : UInt32 = 0u32

    # The maximum width for the progress bar's post-bar label.  If
    # this is 0, then the max width is computed to be 1/5th of the
    # width of the console.
    getter postLabelWidth : UInt32 = 0u32

    # An optional label that can be printed after the percentage at
    # the end of the progress bar.  If this is `nil`, then no extra
    # label is printed.  This can be up to `postLabelWidth` characters
    # long.
    getter postLabel : String? = nil

    # When `true`, the progress bar will not be redrawn automatically, and will
    # only be redrawn when `#refresh` is called, or when `#done` is called.  The
    # default is `false`.
    property? noAutoRefresh : Bool = false

    # When `true` (the default), ellipses ("...") will be added to a clipped
    # label if possible.
    property? addEllipses : Bool = true

    def initialize(@label : String, @max : T, @output : IO = STDOUT)
      raise "ProgressBar max cannot be 0 or less" if @max <= 0
    end

    # When `true`, then the progress bar is finished and will not refresh itself
    # anymore or allow changes to its properties.
    getter? done : Bool = false

    # When `false`, then percentages that are above 100 are capped at 100.  The
    # default is `true`.
    property? allowOver100 : Bool = true

    # Refreshes the bar, then prints a newline to the output and sets `#done?`
    # to `true`.  Calling this more than once is effectively a non-op.
    @[AlwaysInline]
    def done : Nil
      return if @done
      printBar
      @output << '\n'
      @done = true
    end

    # Re-draws the progress bar.  This can be called even when `#done?` is
    # `true`.
    @[AlwaysInline]
    def refresh : Nil
      printBar
    end

    # Increases the step by one, then refreshes the bar.  This will
    # automatically redraw the bar unless `#noAutoRefresh` is `true`.
    def pump : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      @step += 1
      printBar unless @noAutoRefresh
    end

    # Sets the step to `val`, then refreshes the bar.  `val` cannot be less than
    # zero.  If it is greater than `#max`, then it is clamped to `#max`.  This
    # will automatically redraw the bar unless `#noAutoRefresh` is `true`.
    def step=(val : T) : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      raise "Cannot set progress bar step to a negative value." if val < 0
      @step = val
      printBar unless @noAutoRefresh
    end

    # Changes the label of the progress bar.  This will automatically redraw the
    # bar unless `#noAutoRefresh` is `true`.
    def label=(newLabel : String) : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      @label = newLabel
      printBar unless @noAutoRefresh
    end

    # Changes the post-bar label of the progress bar.  This is printed after the
    # percentage.  This will automatically redraw the bar unless
    # `#noAutoRefresh` is `true`.
    def postLabel=(newLabel : String) : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      @postLabel = newLabel
      printBar unless @noAutoRefresh
    end

    # Changes the maximum value of the progress bar.  This cannot be zero or
    # negative.
    #
    # If the new maximum is greater than the current `#step`, then the current
    # `#step` is set to the new maximum.
    #
    # This will automatically redraw the bar unless `#noAutoRefresh` is `true`.
    def max=(newMax : T) : Nil
      raise "ProgressBar max cannot be 0 or less" if newMax <= 0
      @max = newMax
      @step = @max if @step > @max
      printBar unless @noAutoRefresh
    end

    # Changes the label width.  This cannot be negative.  This will
    # automatically redraw the bar unless `#noAutoRefresh` is `true`.
    def labelWidth=(value : Int) : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      raise "Label width cannot be negative" if value < 0
      @labelWidth = value.to_u32
      printBar unless @noAutoRefresh
    end

    # Changes the post-bar label width.  This cannot be negative.  This will
    # automatically redraw the bar unless `#noAutoRefresh` is `true`.
    def postLabelWidth=(value : Int) : Nil
      raise ProgressBarDoneError.new("Bar is finished") if @done
      raise "postLabelWidth width cannot be negative" if value < 0
      @postLabelWidth = value.to_u32
      printBar unless @noAutoRefresh
    end

    private def printBar : Nil
      RemiLib::Console.cursor(@output, 1)
      #RemiLib::Console.erase(@output) # Likely not needed

      # Get console width.
      _, conWidth = RemiLib::Console.getWinSize
      return if conWidth < 10
      width = conWidth.to_i32 - 1

      lblWidth = if @labelWidth == 0
                   ret = width // 5
                   ret < 5 ? 5 : ret
                 else
                   @labelWidth
                 end
      if @label.size < lblWidth
        lblWidth = @label.size
      end

      postLblWidth = if @postLabelWidth == 0
                       ret = width // 5
                       ret < 5 ? 5 : ret
                     else
                       @postLabelWidth
                     end
      @postLabel.try do |lbl|
        if lbl.size < postLblWidth
          postLblWidth = lbl.size
        end
      end

      # The -5 is to account for the percent display.  The -2 is to account for
      # the two vertical bars.  The -1 is to account for a space between the
      # label and bar.
      barWidth = width - lblWidth - 5 - 2 - 1
      if @postLabel
        # Extra space after the percentage if we have a post-bar label.
        barWidth -= 1

        # Subtract post-bar label width as well
        barWidth -= postLblWidth
      end

      raise "Bar width went negative" if barWidth < 0

      # Write label
      if @label.size < lblWidth + 1
        @output << @label
      else
        if @addEllipses && lblWidth >= 4
          @output << @label[0...(lblWidth - 3)] << "..."
        else
          @output << @label[0...lblWidth]
        end
      end
      @output << ": "

      # Write bar
      percent = (@step / @max) * 100
      percent = 100 if percent > 100 && !@allowOver100
      progress = (::Math.min(percent, 100.0) * barWidth) / 100_f32
      @output << '|' << ("*" * progress.to_i) << ("-" * (barWidth - progress.to_i)) << '|'

      # Write percent
      @output << "#{sprintf("%4i", percent.to_i)}%"

      # Write post-bar label
      @postLabel.try do |lbl|
        @output << ' '
        if lbl.size < postLblWidth + 1
          @output << lbl
        else
          if @addEllipses && postLblWidth >= 4
            @output << lbl[0...(postLblWidth - 3)] << "..."
          else
            @output << lbl[0...postLblWidth]
          end
        end
      end
    end
  end

  # Creates a new `RemiLib::Console::ProgressBar` with the given parameters,
  # then yields it to the block.  A newline will be automatically printed once
  # the block is finished by calling `RemiLib::Console::ProgressBar#done`.
  def withProgress(label : String, max : Int|Float, output : IO = STDOUT, &) : Nil
    bar = ProgressBar.new(label, max, output)
    yield bar
    bar.done
  end
end