spotify_cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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