# typed: true # frozen_string_literal: true require_relative '../work_queue' module CLI module UI module Spinner class SpinGroup DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK : CLI::UI::Glyph::X } class << self extend T::Sig sig { returns(Mutex) } attr_reader :pause_mutex sig { returns(T::Boolean) } def paused? @paused end sig do type_parameters(:T) .params(block: T.proc.returns(T.type_parameter(:T))) .returns(T.type_parameter(:T)) end def pause_spinners(&block) previous_paused = T.let(nil, T.nilable(T::Boolean)) @pause_mutex.synchronize do previous_paused = @paused @paused = true end block.call ensure @pause_mutex.synchronize do @paused = previous_paused end end end @pause_mutex = Mutex.new @paused = false extend T::Sig # Initializes a new spin group # This lets you add +Task+ objects to the group to multi-thread work # # ==== Options # # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true # * +:interrupt_debrief+ - Automatically debrief on interrupt. Default to false # * +:max_concurrent+ - Maximum number of concurrent tasks. Default is 0 (effectively unlimited) # * +:work_queue+ - Custom WorkQueue instance. If not provided, a new one will be created # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods, # or under Sorbet, IO or StringIO. Defaults to $stdout # # ==== Example Usage # # CLI::UI::SpinGroup.new do |spin_group| # spin_group.add('Title') { |spinner| sleep 3.0 } # spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 } # end # # Output: # # https://user-images.githubusercontent.com/3074765/33798558-c452fa26-dce8-11e7-9e90-b4b34df21a46.gif # sig do params( auto_debrief: T::Boolean, interrupt_debrief: T::Boolean, max_concurrent: Integer, work_queue: T.nilable(WorkQueue), to: IOLike, ).void end def initialize(auto_debrief: true, interrupt_debrief: false, max_concurrent: 0, work_queue: nil, to: $stdout) @m = Mutex.new @tasks = [] @puts_above = [] @auto_debrief = auto_debrief @interrupt_debrief = interrupt_debrief @start = Time.new @stopped = false @internal_work_queue = work_queue.nil? @work_queue = T.let( work_queue || WorkQueue.new(max_concurrent.zero? ? 1024 : max_concurrent), WorkQueue, ) if block_given? yield self wait(to: to) end end class Task extend T::Sig sig { returns(String) } attr_reader :title, :stdout, :stderr sig { returns(T::Boolean) } attr_reader :success sig { returns(T::Boolean) } attr_reader :done sig { returns(T.nilable(Exception)) } attr_reader :exception # Initializes a new Task # This is managed entirely internally by +SpinGroup+ # # ==== Attributes # # * +title+ - Title of the task # * +block+ - Block for the task, will be provided with an instance of the spinner # sig do params( title: String, final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)), merged_output: T::Boolean, duplicate_output_to: IO, work_queue: WorkQueue, block: T.proc.params(task: Task).returns(T.untyped), ).void end def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, work_queue:, &block) @title = title @final_glyph = final_glyph @always_full_render = title =~ Formatter::SCAN_WIDGET @future = work_queue.enqueue do cap = CLI::UI::StdoutRouter::Capture.new( merged_output: merged_output, duplicate_output_to: duplicate_output_to, ) { block.call(self) } begin cap.run ensure @stdout = cap.stdout @stderr = cap.stderr end end @m = Mutex.new @force_full_render = false @done = false @exception = nil @success = false end sig { params(block: T.proc.params(task: Task).void).void } def on_done(&block) @on_done = block end # Checks if a task is finished # sig { returns(T::Boolean) } def check return true if @done return false unless @future.completed? @done = true begin result = @future.value @success = true @success = false if result == TASK_FAILED rescue => exc @exception = exc @success = false end @on_done&.call(self) @done end # Re-renders the task if required: # # We try to be as lazy as possible in re-rendering the full line. The # spinner rune will change on each render for the most part, but the # body text will rarely have changed. If the body text *has* changed, # we set @force_full_render. # # Further, if the title string includes any CLI::UI::Widgets, we # assume that it may change from render to render, since those # evaluate more dynamically than the rest of our format codes, which # are just text formatters. This is controlled by @always_full_render. # # ==== Attributes # # * +index+ - index of the task # * +force+ - force rerender of the task # * +width+ - current terminal width to format for # sig { params(index: Integer, force: T::Boolean, width: Integer).returns(String) } def render(index, force = true, width: CLI::UI::Terminal.width) @m.synchronize do if !CLI::UI.enable_cursor? || force || @always_full_render || @force_full_render full_render(index, width) else partial_render(index) end ensure @force_full_render = false end end # Update the spinner title # # ==== Attributes # # * +title+ - title to change the spinner to # sig { params(new_title: String).void } def update_title(new_title) @m.synchronize do @always_full_render = new_title =~ Formatter::SCAN_WIDGET @title = new_title @force_full_render = true end end private sig { params(index: Integer, terminal_width: Integer).returns(String) } def full_render(index, terminal_width) o = +'' o << inset o << glyph(index) o << ' ' truncation_width = terminal_width - CLI::UI::ANSI.printing_width(o) o << CLI::UI.resolve_text(title, truncate_to: truncation_width) o << ANSI.clear_to_end_of_line if CLI::UI.enable_cursor? o end sig { params(index: Integer).returns(String) } def partial_render(index) o = +'' o << CLI::UI::ANSI.cursor_forward(inset_width) o << glyph(index) o end sig { params(index: Integer).returns(String) } def glyph(index) if @done final_glyph = @final_glyph.call(@success) if final_glyph.is_a?(Glyph) CLI::UI.enable_color? ? final_glyph.to_s : final_glyph.char else final_glyph end elsif CLI::UI.enable_cursor? if !@future.started? CLI::UI.enable_color? ? Glyph::HOURGLASS.to_s : Glyph::HOURGLASS.char else CLI::UI.enable_color? ? GLYPHS[index] : RUNES[index] end else Glyph::HOURGLASS.char end end sig { returns(String) } def inset @inset ||= CLI::UI::Frame.prefix end sig { returns(Integer) } def inset_width @inset_width ||= CLI::UI::ANSI.printing_width(inset) end end # Add a new task # # ==== Attributes # # * +title+ - Title of the task # * +block+ - Block for the task, will be provided with an instance of the spinner # # ==== Example Usage: # spin_group = CLI::UI::SpinGroup.new # spin_group.add('Title') { |spinner| sleep 1.0 } # spin_group.wait # sig do params( title: String, final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)), merged_output: T::Boolean, duplicate_output_to: IO, block: T.proc.params(task: Task).void, ).void end def add( title, final_glyph: DEFAULT_FINAL_GLYPH, merged_output: false, duplicate_output_to: File.new(File::NULL, 'w'), &block ) @m.synchronize do @tasks << Task.new( title, final_glyph: final_glyph, merged_output: merged_output, duplicate_output_to: duplicate_output_to, work_queue: @work_queue, &block ) end end sig { void } def stop # If we already own the mutex (called from within another synchronized block), # set stopped directly to avoid deadlock if @m.owned? return if @stopped @stopped = true else @m.synchronize do return if @stopped @stopped = true end end # Interrupt is thread-safe on its own, so we can call it outside the mutex @work_queue.interrupt end sig { returns(T::Boolean) } def stopped? if @m.owned? @stopped else @m.synchronize { @stopped } end end # Tells the group you're done adding tasks and to wait for all of them to finish # # ==== Options # # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods, # or under Sorbet, IO or StringIO. Defaults to $stdout # # ==== Example Usage: # spin_group = CLI::UI::SpinGroup.new # spin_group.add('Title') { |spinner| sleep 1.0 } # spin_group.wait # sig { params(to: IOLike).returns(T::Boolean) } def wait(to: $stdout) idx = 0 consumed_lines = 0 @work_queue.close if @internal_work_queue tasks_seen = @tasks.map { false } tasks_seen_done = @tasks.map { false } loop do break if stopped? done_count = 0 width = CLI::UI::Terminal.width self.class.pause_mutex.synchronize do next if self.class.paused? @m.synchronize do CLI::UI.raw do force_full_render = false unless @puts_above.empty? to.print(CLI::UI::ANSI.cursor_up(consumed_lines)) if CLI::UI.enable_cursor? while (message = @puts_above.shift) to.print(CLI::UI::ANSI.insert_lines(message.lines.count)) if CLI::UI.enable_cursor? message.lines.each do |line| to.print(CLI::UI::Frame.prefix + CLI::UI.fmt(line)) end to.print("\n") end # we descend with newlines rather than ANSI.cursor_down as the inserted lines may've # pushed the spinner off the front of the buffer, so we can't move back down below it to.print("\n" * consumed_lines) if CLI::UI.enable_cursor? force_full_render = true end @tasks.each.with_index do |task, int_index| nat_index = int_index + 1 task_done = task.check done_count += 1 if task_done if CLI::UI.enable_cursor? if nat_index > consumed_lines to.print(task.render(idx, true, width: width) + "\n") consumed_lines += 1 else offset = consumed_lines - int_index move_to = CLI::UI::ANSI.cursor_up(offset) + "\r" move_from = "\r" + CLI::UI::ANSI.cursor_down(offset) to.print(move_to + task.render(idx, idx.zero? || force_full_render, width: width) + move_from) end elsif !tasks_seen[int_index] || (task_done && !tasks_seen_done[int_index]) to.print(task.render(idx, true, width: width) + "\n") end tasks_seen[int_index] = true tasks_seen_done[int_index] ||= task_done end end end end break if done_count == @tasks.size idx = (idx + 1) % GLYPHS.size Spinner.index = idx sleep(PERIOD) end if @auto_debrief debrief(to: to) else all_succeeded? end rescue Interrupt @work_queue.interrupt debrief(to: to) if @interrupt_debrief stopped? ? false : raise end sig { params(message: String).void } def puts_above(message) @m.synchronize do @puts_above << message end end # Provide an alternative debriefing for failed tasks sig do params( block: T.proc.params(title: String, exception: T.nilable(Exception), out: String, err: String).void, ).void end def failure_debrief(&block) @failure_debrief = block end # Provide a debriefing for successful tasks sig do params( block: T.proc.params(title: String, out: String, err: String).void, ).void end def success_debrief(&block) @success_debrief = block end sig { returns(T::Boolean) } def all_succeeded? @m.synchronize do @tasks.all?(&:success) end end # Debriefs failed tasks is +auto_debrief+ is true # # ==== Options # # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods, # or under Sorbet, IO or StringIO. Defaults to $stdout # sig { params(to: IOLike).returns(T::Boolean) } def debrief(to: $stdout) @m.synchronize do @tasks.each do |task| next unless task.done title = task.title out = task.stdout err = task.stderr if task.success next @success_debrief&.call(title, out, err) end # exception will not be set if the wait loop is stopped before the task is checked e = task.exception next @failure_debrief.call(title, e, out, err) if @failure_debrief CLI::UI::Frame.open('Task Failed: ' + title, color: :red, timing: Time.new - @start) do if e to.puts("#{e.class}: #{e.message}") to.puts("\tfrom #{e.backtrace.join("\n\tfrom ")}") end CLI::UI::Frame.divider('STDOUT') out = '(empty)' if out.nil? || out.strip.empty? to.puts(out) CLI::UI::Frame.divider('STDERR') err = '(empty)' if err.nil? || err.strip.empty? to.puts(err) end end @tasks.all?(&:success) end end end end end end