spotify_cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,166 @@
1
+ require 'dex/ui'
2
+
3
+ module Dex
4
+ module UI
5
+ module Frame
6
+ class << self
7
+ DEFAULT_FRAME_COLOR = Dex::UI.resolve_color(:cyan)
8
+
9
+ # Can be invoked in two ways: block and blockless
10
+ # In block form, the frame is closed automatically when the block returns
11
+ # In blockless form, caller MUST call Frame.close when the frame is
12
+ # logically done.
13
+ # blockless form is strongly discouraged in cases where block form can be
14
+ # made to work.
15
+ def open(
16
+ text,
17
+ color: DEFAULT_FRAME_COLOR,
18
+ failure_text: nil,
19
+ success_text: nil,
20
+ timing: nil
21
+ )
22
+ color = Dex::UI.resolve_color(color)
23
+
24
+ unless block_given?
25
+ if failure_text
26
+ raise ArgumentError, "failure_text is not compatible with blockless invocation"
27
+ elsif success_text
28
+ raise ArgumentError, "success_text is not compatible with blockless invocation"
29
+ elsif !timing.nil?
30
+ raise ArgumentError, "timing is not compatible with blockless invocation"
31
+ end
32
+ end
33
+
34
+ timing = true if timing.nil?
35
+
36
+ t_start = Time.now.to_f
37
+ Dex::UI.raw do
38
+ puts edge(text, color: color, first: Dex::UI::Box::Heavy::TL)
39
+ end
40
+ FrameStack.push(color)
41
+
42
+ return unless block_given?
43
+
44
+ begin
45
+ success = false
46
+ success = yield
47
+ rescue
48
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
49
+ close(failure_text, color: :red, elapsed: t_diff)
50
+ raise
51
+ else
52
+ t_diff = timing ? (Time.now.to_f - t_start) : nil
53
+ if success != false
54
+ close(success_text, color: color, elapsed: t_diff)
55
+ else
56
+ close(failure_text, color: :red, elapsed: t_diff)
57
+ end
58
+ success
59
+ end
60
+ end
61
+
62
+ def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
63
+ color = Dex::UI.resolve_color(color)
64
+
65
+ FrameStack.pop
66
+ kwargs = {}
67
+ if elapsed
68
+ kwargs[:right_text] = "(#{elapsed.round(2)}s)"
69
+ end
70
+ Dex::UI.raw do
71
+ puts edge(text, color: color, first: Dex::UI::Box::Heavy::BL, **kwargs)
72
+ end
73
+ end
74
+
75
+ def divider(text, color: nil)
76
+ color = Dex::UI.resolve_color(color)
77
+ item = Dex::UI.resolve_color(FrameStack.pop)
78
+
79
+ Dex::UI.raw do
80
+ puts edge(text, color: (color || item), first: Dex::UI::Box::Heavy::DIV)
81
+ end
82
+ FrameStack.push(item)
83
+ end
84
+
85
+ def prefix(color: nil)
86
+ pfx = String.new
87
+ items = FrameStack.items
88
+ items[0..-2].each do |item|
89
+ pfx << Dex::UI.resolve_color(item).code << Dex::UI::Box::Heavy::VERT
90
+ end
91
+ if item = items.last
92
+ c = Thread.current[:dexui_frame_color_override] || color || item
93
+ pfx << Dex::UI.resolve_color(c).code \
94
+ << Dex::UI::Box::Heavy::VERT << ' ' << Dex::UI::Color::RESET.code
95
+ end
96
+ pfx
97
+ end
98
+
99
+ def with_frame_color_override(color)
100
+ prev = Thread.current[:dexui_frame_color_override]
101
+ Thread.current[:dexui_frame_color_override] = color
102
+ yield
103
+ ensure
104
+ Thread.current[:dexui_frame_color_override] = prev
105
+ end
106
+
107
+ private
108
+
109
+ def edge(text, color: raise, first: raise, right_text: nil)
110
+ color = Dex::UI.resolve_color(color)
111
+ text = Dex::UI.resolve_text("{{#{color.name}:#{text}}}")
112
+
113
+ prefix = String.new
114
+ FrameStack.items.each do |item|
115
+ prefix << Dex::UI.resolve_color(item).code << Dex::UI::Box::Heavy::VERT
116
+ end
117
+ prefix << color.code << first << (Dex::UI::Box::Heavy::HORZ * 2)
118
+ text ||= ''
119
+ unless text.empty?
120
+ prefix << ' ' << text << ' '
121
+ end
122
+
123
+ suffix = String.new
124
+ if right_text
125
+ suffix << ' ' << right_text << ' ' << color.code << (Dex::UI::Box::Heavy::HORZ * 2)
126
+ end
127
+
128
+ textwidth = Dex::UI::ANSI.printing_width(prefix + suffix)
129
+ termwidth = Dex::UI::Terminal.width
130
+ termwidth = 30 if termwidth < 30
131
+
132
+ if textwidth > termwidth
133
+ suffix = ''
134
+ prefix = prefix[0...termwidth]
135
+ textwidth = termwidth
136
+ end
137
+ padwidth = termwidth - textwidth
138
+ pad = Dex::UI::Box::Heavy::HORZ * padwidth
139
+
140
+ prefix + color.code + pad + suffix + Dex::UI::Color::RESET.code + "\n"
141
+ end
142
+
143
+ module FrameStack
144
+ ENVVAR = 'DEX_FRAME_STACK'
145
+
146
+ def self.items
147
+ ENV.fetch(ENVVAR, '').split(':').map(&:to_sym)
148
+ end
149
+
150
+ def self.push(item)
151
+ curr = items
152
+ curr << item.name
153
+ ENV[ENVVAR] = curr.join(':')
154
+ end
155
+
156
+ def self.pop
157
+ curr = items
158
+ ret = curr.pop
159
+ ENV[ENVVAR] = curr.join(':')
160
+ ret.nil? ? nil : ret.to_sym
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,49 @@
1
+ require 'dex/ui'
2
+
3
+ module Dex
4
+ module UI
5
+ class Glyph
6
+ MAP = {}
7
+
8
+ attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
9
+ def initialize(handle, codepoint, color)
10
+ @handle = handle
11
+ @codepoint = codepoint
12
+ @color = color
13
+ @char = [codepoint].pack('U')
14
+ @to_s = color.code + char + Color::RESET.code
15
+ @fmt = "{{#{color.name}:#{char}}}"
16
+
17
+ MAP[handle] = self
18
+ end
19
+
20
+ STAR = new('*', 0x2b51, Color::YELLOW) # BLACK SMALL STAR
21
+ INFO = new('i', 0x1d4be, Color::BLUE) # MATHEMATICAL SCRIPT SMALL I
22
+ QUESTION = new('?', 0x003f, Color::BLUE) # QUESTION MARK
23
+ CHECK = new('v', 0x2713, Color::GREEN) # CHECK MARK
24
+ X = new('x', 0x2717, Color::RED) # BALLOT X
25
+
26
+ class InvalidGlyphHandle < ArgumentError
27
+ def initialize(handle)
28
+ @handle = handle
29
+ end
30
+
31
+ def message
32
+ keys = Glyph.available.join(',')
33
+ "invalid glyph handle: #{@handle} " \
34
+ "-- must be one of Dex::UI::Glyph.available (#{keys})"
35
+ end
36
+ end
37
+
38
+ def self.lookup(name)
39
+ MAP.fetch(name.to_s)
40
+ rescue KeyError
41
+ raise InvalidGlyphHandle, name
42
+ end
43
+
44
+ def self.available
45
+ MAP.keys
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,19 @@
1
+ require 'dex/ui'
2
+
3
+ module Dex
4
+ module UI
5
+ module Progress
6
+ FILLED_BAR = Dex::UI::Glyph.new("◾", 0x2588, Color::CYAN)
7
+ UNFILLED_BAR = Dex::UI::Glyph.new("◽", 0x2588, Color::WHITE)
8
+
9
+ def self.progress(percent, width)
10
+ filled = (percent * width).ceil
11
+ unfilled = width - filled
12
+ Dex::UI.resolve_text [
13
+ (FILLED_BAR.to_s * filled),
14
+ (UNFILLED_BAR.to_s * unfilled)
15
+ ].join
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,121 @@
1
+ require 'dex/ui'
2
+ require 'readline'
3
+
4
+ module Dex
5
+ module UI
6
+ module Prompt
7
+ class << self
8
+ def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true)
9
+ if (default && !allow_empty) || (options && (default || is_file))
10
+ raise(ArgumentError, 'conflicting arguments')
11
+ end
12
+
13
+ if default
14
+ puts_question("#{question} (empty = #{default})")
15
+ else
16
+ puts_question(question)
17
+ end
18
+
19
+ if options
20
+ return ask_options(options)
21
+ end
22
+
23
+ loop do
24
+ line = readline(is_file: is_file)
25
+
26
+ if line.empty? && default
27
+ write_default_over_empty_input(default)
28
+ return default
29
+ end
30
+
31
+ if !line.empty? || allow_empty
32
+ return line
33
+ end
34
+ end
35
+ end
36
+
37
+ def confirm(question)
38
+ puts_question(question + ' {{yellow:[y/n]}}')
39
+
40
+ loop do
41
+ line = readline(is_file: false)
42
+ char = line[0].downcase
43
+ return true if char == 'y'
44
+ return false if char == 'n'
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def write_default_over_empty_input(default)
51
+ Dex::UI.raw do
52
+ STDERR.puts(
53
+ Dex::UI::ANSI.cursor_up(1) +
54
+ "\r" +
55
+ Dex::UI::ANSI.cursor_forward(4) + # TODO: width
56
+ default +
57
+ Dex::UI::Color::RESET.code
58
+ )
59
+ end
60
+ end
61
+
62
+ def puts_question(str)
63
+ Dex::UI.with_frame_color(:blue) do
64
+ STDOUT.puts(Dex::UI.fmt('{{?}} ' + str))
65
+ end
66
+ end
67
+
68
+ def ask_options(options)
69
+ puts_question("Your options are:")
70
+ options.each_with_index do |v, idx|
71
+ puts_question("#{idx + 1}) #{v}")
72
+ end
73
+ puts_question("Choose a number between 1 and #{options.length}")
74
+
75
+ buf = -1
76
+ available = (1..options.length).to_a
77
+ until available.include?(buf.to_i)
78
+ buf = readline(is_file: false)
79
+
80
+ if buf.nil?
81
+ STDERR.puts
82
+ next
83
+ end
84
+
85
+ if buf.is_a?(String)
86
+ buf = buf.chomp
87
+ end
88
+ buf = -1 if buf.empty?
89
+ buf = -1 if buf.to_i.to_s != buf
90
+ end
91
+
92
+ options[buf.to_i - 1]
93
+ end
94
+
95
+ def readline(is_file: false)
96
+ if is_file
97
+ Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
98
+ Readline.completion_append_character = ""
99
+ else
100
+ Readline.completion_proc = proc { |*| nil }
101
+ Readline.completion_append_character = " "
102
+ end
103
+
104
+ # because Readline is a C library, Dex::UI's hooks into $stdout don't
105
+ # work. We could work around this by having Dex::UI use a pipe and a
106
+ # thread to manage output, but the current strategy feels like a
107
+ # better tradeoff.
108
+ prefix = Dex::UI.with_frame_color(:blue) { Dex::UI::Frame.prefix }
109
+ prompt = prefix + Dex::UI.fmt('{{blue:> }}{{yellow:')
110
+
111
+ begin
112
+ Readline.readline(prompt, true).chomp
113
+ rescue Interrupt
114
+ Dex::UI.raw { STDERR.puts('^C' + Dex::UI::Color::RESET.code) }
115
+ raise
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,168 @@
1
+ require 'dex/ui'
2
+
3
+ module Dex
4
+ module UI
5
+ module Spinner
6
+ PERIOD = 0.1 # seconds
7
+
8
+ begin
9
+ runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10
+ colors = [Dex::UI::Color::CYAN.code] * 5 + [Dex::UI::Color::MAGENTA.code] * 5
11
+ raise unless runes.size == colors.size
12
+ GLYPHS = colors.zip(runes).map(&:join)
13
+ end
14
+
15
+ def self.spin(title, &block)
16
+ sg = SpinGroup.new
17
+ sg.add(title, &block)
18
+ sg.wait
19
+ end
20
+
21
+ class SpinGroup
22
+ def initialize
23
+ @m = Mutex.new
24
+ @consumed_lines = 0
25
+ @tasks = []
26
+ end
27
+
28
+ class Task
29
+ attr_reader :title, :exception, :success, :stdout, :stderr
30
+
31
+ def initialize(title, &block)
32
+ @title = title
33
+ @thread = Thread.new do
34
+ cap = Dex::UI::StdoutRouter::Capture.new(with_frame_inset: false, &block)
35
+ begin
36
+ cap.run
37
+ ensure
38
+ @stdout = cap.stdout
39
+ @stderr = cap.stderr
40
+ end
41
+ end
42
+
43
+ @done = false
44
+ @exception = nil
45
+ @success = false
46
+ end
47
+
48
+ def check
49
+ return true if @done
50
+ return false if @thread.alive?
51
+
52
+ @done = true
53
+ begin
54
+ status = @thread.join.status
55
+ @success = (status == false)
56
+ rescue => exc
57
+ @exception = exc
58
+ @success = false
59
+ end
60
+
61
+ @done
62
+ end
63
+
64
+ def render(index, force = true)
65
+ return full_render(index) if force
66
+ partial_render(index)
67
+ end
68
+
69
+ private
70
+
71
+ def full_render(index)
72
+ inset + glyph(index) + Dex::UI::Color::RESET.code + ' ' + Dex::UI.resolve_text(title)
73
+ end
74
+
75
+ def partial_render(index)
76
+ Dex::UI::ANSI.cursor_forward(inset_width) + glyph(index) + Dex::UI::Color::RESET.code
77
+ end
78
+
79
+ def glyph(index)
80
+ if @done
81
+ @success ? Dex::UI::Glyph::CHECK.to_s : Dex::UI::Glyph::X.to_s
82
+ else
83
+ GLYPHS[index]
84
+ end
85
+ end
86
+
87
+ def inset
88
+ @inset ||= Dex::UI::Frame.prefix
89
+ end
90
+
91
+ def inset_width
92
+ @inset_width ||= Dex::UI::ANSI.printing_width(inset)
93
+ end
94
+ end
95
+
96
+ def add(title, &block)
97
+ @m.synchronize do
98
+ @tasks << Task.new(title, &block)
99
+ end
100
+ end
101
+
102
+ def wait
103
+ idx = 0
104
+
105
+ loop do
106
+ all_done = true
107
+
108
+ @m.synchronize do
109
+ Dex::UI.raw do
110
+ @tasks.each.with_index do |task, int_index|
111
+ nat_index = int_index + 1
112
+ task_done = task.check
113
+ all_done = false unless task_done
114
+
115
+ if nat_index > @consumed_lines
116
+ print(task.render(idx, true) + "\n")
117
+ @consumed_lines += 1
118
+ else
119
+ offset = @consumed_lines - int_index
120
+ move_to = Dex::UI::ANSI.cursor_up(offset) + "\r"
121
+ move_from = "\r" + Dex::UI::ANSI.cursor_down(offset)
122
+
123
+ print(move_to + task.render(idx, idx.zero?) + move_from)
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ break if all_done
130
+
131
+ idx = (idx + 1) % GLYPHS.size
132
+ sleep(PERIOD)
133
+ end
134
+
135
+ debrief
136
+ end
137
+
138
+ def debrief
139
+ @m.synchronize do
140
+ @tasks.each do |task|
141
+ next if task.success
142
+
143
+ e = task.exception
144
+ out = task.stdout
145
+ err = task.stderr
146
+
147
+ Dex::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
148
+ if e
149
+ puts"#{e.class}: #{e.message}"
150
+ puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
151
+ end
152
+
153
+ Dex::UI::Frame.divider('STDOUT')
154
+ out = "(empty)" if out.strip.empty?
155
+ puts out
156
+
157
+ Dex::UI::Frame.divider('STDERR')
158
+ err = "(empty)" if err.strip.empty?
159
+ puts err
160
+ end
161
+ end
162
+ @tasks.all?(&:success)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,186 @@
1
+ require 'dex/ui'
2
+
3
+ module Dex
4
+ module UI
5
+ module StdoutRouter
6
+ class << self
7
+ attr_accessor :duplicate_output_to
8
+ end
9
+
10
+ class Writer
11
+ def initialize(stream, name)
12
+ @stream = stream
13
+ @name = name
14
+ end
15
+
16
+ def write(*args)
17
+ if auto_frame_inset?
18
+ str = args[0].dup # unfreeze
19
+ str = str.force_encoding(Encoding::UTF_8)
20
+ str = apply_line_prefix(str, Dex::UI::Frame.prefix)
21
+ args[0] = str
22
+ else
23
+ @pending_newline = false
24
+ end
25
+
26
+ hook = Thread.current[:dexui_output_hook]
27
+ # hook return of false suppresses output.
28
+ if !hook || hook.call(args.first, @name) != false
29
+ args.first
30
+ @stream.write_without_dex_ui(*args)
31
+ if dup = StdoutRouter.duplicate_output_to
32
+ dup.write(*args)
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def auto_frame_inset?
40
+ !Thread.current[:no_dexui_frame_inset]
41
+ end
42
+
43
+ def apply_line_prefix(str, prefix)
44
+ return '' if str.empty?
45
+ prefixed = String.new
46
+ str.force_encoding(Encoding::UTF_8).lines.each do |line|
47
+ if @pending_newline
48
+ prefixed << line
49
+ @pending_newline = false
50
+ else
51
+ prefixed << prefix << line
52
+ end
53
+ end
54
+ @pending_newline = !str.end_with?("\n")
55
+ prefixed
56
+ end
57
+ end
58
+
59
+ class Capture
60
+ @m = Mutex.new
61
+ @active_captures = 0
62
+ @saved_stdin = nil
63
+
64
+ def self.with_stdin_masked
65
+ @m.synchronize do
66
+ if @active_captures.zero?
67
+ @saved_stdin = $stdin
68
+ $stdin, w = IO.pipe
69
+ $stdin.close
70
+ w.close
71
+ end
72
+ @active_captures += 1
73
+ end
74
+
75
+ yield
76
+ ensure
77
+ @m.synchronize do
78
+ @active_captures -= 1
79
+ if @active_captures.zero?
80
+ $stdin = @saved_stdin
81
+ end
82
+ end
83
+ end
84
+
85
+ def initialize(with_frame_inset: true, &block)
86
+ @with_frame_inset = with_frame_inset
87
+ @block = block
88
+ end
89
+
90
+ attr_reader :stdout, :stderr
91
+
92
+ def run
93
+ StdoutRouter.assert_enabled!
94
+
95
+ out = StringIO.new
96
+ err = StringIO.new
97
+
98
+ prev_frame_inset = Thread.current[:no_dexui_frame_inset]
99
+ prev_hook = Thread.current[:dexui_output_hook]
100
+
101
+ self.class.with_stdin_masked do
102
+ Thread.current[:no_dexui_frame_inset] = !@with_frame_inset
103
+ Thread.current[:dexui_output_hook] = ->(data, stream) do
104
+ case stream
105
+ when :stdout then out.write(data)
106
+ when :stderr then err.write(data)
107
+ else raise
108
+ end
109
+ false # suppress writing to terminal
110
+ end
111
+
112
+ begin
113
+ @block.call
114
+ ensure
115
+ @stdout = out.string
116
+ @stderr = err.string
117
+ end
118
+ end
119
+ ensure
120
+ Thread.current[:dexui_output_hook] = prev_hook
121
+ Thread.current[:no_dexui_frame_inset] = prev_frame_inset
122
+ end
123
+ end
124
+
125
+ class << self
126
+ WRITE_WITHOUT_DEX_UI = :write_without_dex_ui
127
+
128
+ NotEnabled = Class.new(StandardError)
129
+
130
+ def assert_enabled!
131
+ raise NotEnabled unless enabled?
132
+ end
133
+
134
+ def with_enabled
135
+ enable
136
+ yield
137
+ ensure
138
+ disable
139
+ end
140
+
141
+ # TODO: remove this
142
+ def ensure_activated
143
+ enable unless enabled?
144
+ end
145
+
146
+ def enable
147
+ return false if enabled?($stdout) || enabled?($stderr)
148
+ activate($stdout, :stdout)
149
+ activate($stderr, :stderr)
150
+ true
151
+ end
152
+
153
+ def enabled?(stream = $stdout)
154
+ stream.respond_to?(WRITE_WITHOUT_DEX_UI)
155
+ end
156
+
157
+ def disable
158
+ return false unless enabled?($stdout) && enabled?($stderr)
159
+ deactivate($stdout)
160
+ deactivate($stderr)
161
+ true
162
+ end
163
+
164
+ private
165
+
166
+ def deactivate(stream)
167
+ sc = stream.singleton_class
168
+ sc.send(:remove_method, :write)
169
+ sc.send(:alias_method, :write, WRITE_WITHOUT_DEX_UI)
170
+ end
171
+
172
+ def activate(stream, streamname)
173
+ writer = StdoutRouter::Writer.new(stream, streamname)
174
+
175
+ raise if stream.respond_to?(WRITE_WITHOUT_DEX_UI)
176
+ stream.singleton_class.send(:alias_method, WRITE_WITHOUT_DEX_UI, :write)
177
+ stream.define_singleton_method(:write) do |*args|
178
+ writer.write(*args)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ Dex::UI::StdoutRouter.enable