tabscroll 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a07da9616f2254f2fd9bd14e11a5e8f327c132df
4
+ data.tar.gz: 854189ab82e34c155233f7b5b9b8ac0768105761
5
+ SHA512:
6
+ metadata.gz: 00977e49cde6a2ccf4666510585224a82fdd4efbc0d38b9e3f38008eced4f969d0d63b0b693285bea776feb6aa2589be7decd1ebba1346365c1ede6e80df4e86
7
+ data.tar.gz: d1a0f44480d736aa8f1c9a7b2f171d873d79b3d66c9a4d715b85eabee982552606c9d1aaa23cba8e37795b0136a7a11a6086f15b7be804154d5e68a437187ef6
data/bin/tabscroll ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # The main `tabscroll` executable
4
+
5
+ require 'tabscroll'
6
+
7
+ begin
8
+ filename = ARGV[0]
9
+ if not filename
10
+ puts "Usage:"
11
+ puts " $ #{PROGRAM_NAME} (filename)"
12
+ exit 666
13
+ end
14
+
15
+ $engine = Engine.new
16
+ if not $engine
17
+ puts 'Failed to start curses!'
18
+ exit 1337
19
+ end
20
+ $engine.timeout 10
21
+
22
+ win = Screen.new(0, 1, $engine.width, $engine.height - 2)
23
+ track = Track.new win
24
+ track.load filename
25
+ track.auto_scroll true
26
+
27
+ titlebar = Screen.new(0, 0, $engine.width, 1)
28
+ statusbar = Screen.new(0, ($engine.height - 1), $engine.width, 1)
29
+
30
+ bars_hidden = false
31
+ finished = false
32
+ while not finished
33
+ # WHY DOES GETCH CLEARS UP THE WHOLE SCREEN?
34
+ # IT DOESNT MAKE ANY SENSE
35
+ c = $engine.getchar
36
+ case c
37
+ when 'e'
38
+ track.end
39
+ when 'a'
40
+ track.begin
41
+ when 'h'
42
+ show_help_window
43
+ when 'o'
44
+ bars_hidden = (bars_hidden ? false : true)
45
+ Curses::clear
46
+ when '<'
47
+ track.scroll -5
48
+ when '>'
49
+ track.scroll 5
50
+ when Curses::KEY_LEFT
51
+ track.speed -= 1
52
+ when Curses::KEY_RIGHT
53
+ track.speed += 1
54
+ when Curses::KEY_DOWN, Curses::KEY_UP
55
+ track.speed = 0
56
+ when 'q'
57
+ quit
58
+ end
59
+
60
+ track.update
61
+ track.show
62
+ if not bars_hidden
63
+ titlebar.mvaddstr(0, 0, "#{filename} (#{track.percent_completed}%) ", Engine::Colors[:cyan])
64
+ titlebar.mvaddstr_right(0, " Speed: #{track.speed}", Engine::Colors[:cyan])
65
+
66
+ statusbar.mvaddstr_left(0, "#{PROGRAM_NAME} v#{PROGRAM_VERSION} - press `h` for help", Engine::Colors[:green])
67
+ end
68
+ end
69
+
70
+ quit
71
+ end
72
+
@@ -0,0 +1,95 @@
1
+
2
+ require 'curses'
3
+
4
+ # The main interface with Curses.
5
+ #
6
+ # This acts as a middleman, abstracting away curses' details.
7
+ class Engine
8
+
9
+ # All possible colors.
10
+ Colors = {
11
+ :black => 1,
12
+ :white => 2,
13
+ :red => 3,
14
+ :yellow => 4,
15
+ :magenta => 5,
16
+ :blue => 6,
17
+ :green => 7,
18
+ :cyan => 8
19
+ }.freeze
20
+
21
+ # Initializes Ncurses with minimal +width+ and +height+.
22
+ #
23
+ def initialize(min_width=nil, min_height=nil)
24
+ @has_colors = nil
25
+
26
+ @screen = Curses::init_screen
27
+ return nil if not @screen
28
+
29
+ if min_width and min_height
30
+ cur_width = @screen.maxx
31
+ cur_height = @screen.maxy
32
+
33
+ if cur_width < @width or cur_height < @height
34
+ self.exit
35
+ $stderr << "Error: Screen size too small (#{cur_width}x#{cur_height})\n"
36
+ $stderr << "Please resize your terminal to at least #{@width}x#{@height}\n"
37
+ return nil
38
+ end
39
+ end
40
+
41
+ @has_colors = Curses.has_colors?
42
+ if @has_colors
43
+ Curses.start_color
44
+ Curses.use_default_colors # will use default background
45
+
46
+ # Initializes: constant foreground bg
47
+ Curses.init_pair(Colors[:white], Curses::COLOR_BLACK, -1)
48
+ Curses.init_pair(Colors[:blue], Curses::COLOR_BLUE, -1)
49
+ Curses.init_pair(Colors[:red], Curses::COLOR_RED, -1)
50
+ Curses.init_pair(Colors[:green], Curses::COLOR_GREEN, -1)
51
+ Curses.init_pair(Colors[:magenta], Curses::COLOR_MAGENTA, -1)
52
+ Curses.init_pair(Colors[:yellow], Curses::COLOR_YELLOW, -1)
53
+ Curses.init_pair(Colors[:cyan], Curses::COLOR_CYAN, -1)
54
+ end
55
+
56
+ Curses::cbreak
57
+ Curses::curs_set 0
58
+ Curses::noecho
59
+ Curses::nonl
60
+ Curses::stdscr.keypad = true # extra keys
61
+ end
62
+
63
+ def width
64
+ return Curses::cols
65
+ end
66
+
67
+ def height
68
+ return Curses::lines
69
+ end
70
+
71
+ def exit
72
+ Curses::refresh
73
+ Curses::close_screen
74
+ end
75
+
76
+ def set_color color
77
+ if @has_colors
78
+ @screen.attron Curses::color_pair(color)
79
+ return self
80
+ else
81
+ return nil
82
+ end
83
+ end
84
+
85
+ def getchar
86
+ return Curses::getch
87
+ end
88
+
89
+ # +timeout+ says how many milliseconds we wait for a key to be
90
+ # pressed.
91
+ def timeout timeout
92
+ Curses::timeout = timeout
93
+ end
94
+ end
95
+
@@ -0,0 +1,60 @@
1
+
2
+ require_relative 'screen.rb'
3
+
4
+ # A simple centralized popup.
5
+ #
6
+ # It resizes as you place the text on it.
7
+ class Popup < Screen
8
+
9
+ def initialize(title, text)
10
+ @title = title
11
+ @text = []
12
+ text.each_line do |line|
13
+ @text += [line.chomp]
14
+ end
15
+
16
+ max_width = title.length
17
+ max_height = 1
18
+
19
+ @text.each do |line|
20
+ max_width = line.length if line.length > max_width
21
+ max_height += 1
22
+ end
23
+
24
+ max_width += 2 # left-right borders
25
+ max_height += 1 # down border
26
+
27
+ x = Curses::cols/2 - max_width/2
28
+ y = Curses::lines/2 - max_height/2
29
+
30
+ super(x, y, max_width, max_height)
31
+ self.background ' '
32
+ self.box
33
+
34
+ self.mvaddstr_center(0, title, Engine::Colors[:cyan])
35
+
36
+ y = 1
37
+ @text.each do |line|
38
+ self.mvaddstr(1, y, line)
39
+ y += 1
40
+ end
41
+ end
42
+
43
+ def show
44
+ finished = false
45
+ while not finished
46
+ c = Curses::getch
47
+ case c
48
+ when 'q'
49
+ return true
50
+ when 'h'
51
+ finished = true
52
+ end
53
+ end
54
+
55
+ Curses::stdscr.clear
56
+ Curses::stdscr.refresh
57
+ return false
58
+ end
59
+ end
60
+
@@ -0,0 +1,125 @@
1
+
2
+ require 'curses'
3
+
4
+ # A segment of the terminal screen.
5
+ #
6
+ # BUG WARNING HACK FUCK
7
+ # Whenever I use @win.attrset/@win.setpos/@win.addch
8
+ # it doesn't work at all.
9
+ # Apparently, when I do this, Curses::getch clears up
10
+ # the entire screen.
11
+ #
12
+ # DO NOT DO THIS
13
+ #
14
+ class Screen
15
+ attr_reader :width, :height
16
+
17
+ # Creates a Screen at `x` `y` `w` `h`.
18
+ def initialize(x, y, w, h)
19
+ @win = Curses::Window.new(h, w, y, x)
20
+ @width = w
21
+ @height = h
22
+ end
23
+
24
+ # Sets the current color of the Screen.
25
+ def set_color color
26
+ Curses::attrset(Curses::color_pair color)
27
+ end
28
+
29
+ # Executes a block of code encapsulated within a color on/off.
30
+ # Note that the color can be overrided.
31
+ def with_color(color=nil)
32
+ Curses::attron(Curses::color_pair color) if color
33
+ yield
34
+ Curses::attroff(Curses::color_pair color) if color
35
+ end
36
+
37
+ # Puts a character +c+ on (+x+, +y+) with optional +color+.
38
+ def mvaddch(x, y, c, color=nil)
39
+ return if x < 0 or x >= @width
40
+ return if y < 0 or y >= @height
41
+
42
+ self.with_color color do
43
+ Curses::setpos(@win.begy + y, @win.begx + x)
44
+ Curses::addch c
45
+ end
46
+ end
47
+
48
+ # Puts a string +str+ on (+x+, +y+) with optional +color+.
49
+ def mvaddstr(x, y, str, color=nil)
50
+ return if x < 0 or x >= @width
51
+ return if y < 0 or y >= @height
52
+
53
+ self.with_color color do
54
+ # @win.setpos(@win.begy + y, @win.begx + x)
55
+ # @win.addstr str
56
+ Curses::setpos(@win.begy + y, @win.begx + x)
57
+ Curses::addstr str
58
+ end
59
+ end
60
+
61
+ # Puts a string +str+ centered on +y+ with optional +color+.
62
+ def mvaddstr_center(y, str, color=nil)
63
+ x = (@width/2) - (str.length/2)
64
+ self.mvaddstr(x, y, str, color)
65
+ end
66
+
67
+ def mvaddstr_left(y, str, color=nil)
68
+ self.mvaddstr(0, y, str, color)
69
+ end
70
+
71
+ def mvaddstr_right(y, str, color=nil)
72
+ x = @width - str.length
73
+ self.mvaddstr(x, y, str, color)
74
+ end
75
+
76
+ # Erases all of the Screen's contents
77
+ def clear
78
+ @win.clear
79
+ end
80
+
81
+ # Commits the changes on the Screen.
82
+ def refresh
83
+ @win.refresh
84
+ end
85
+
86
+ # Moves window so that the upper-left corner is at `x` `y`.
87
+ def move(x, y)
88
+ @win.move(y, x)
89
+ end
90
+
91
+ # Resizes window to +width+ and +h+eight.
92
+ def resize(w, h)
93
+ @win.resize(h, w)
94
+ @width = w
95
+ @height = h
96
+ end
97
+
98
+ # Set block/nonblocking reads for window.
99
+ #
100
+ # * If `delay` is negative, blocking read is used.
101
+ # * If `delay` is zero, nonblocking read is used.
102
+ # * If `delay` is positive, waits for `delay` milliseconds and
103
+ # returns ERR of no input.
104
+ def timeout(delay=-1)
105
+ @win.timeout = delay
106
+ end
107
+
108
+ def background char
109
+ @win.bkgd char
110
+ @win.refresh
111
+ end
112
+
113
+ # Sets the Screen border.
114
+ #
115
+ # * If all arguments are set, that's ok.
116
+ # * If only the first 2 arguments are set, they are the vertical
117
+ # and horizontal chars.
118
+ #
119
+ def box(horizontal=0, vertical=0)
120
+ @win.box(horizontal, vertical)
121
+ @win.refresh
122
+ end
123
+
124
+ end
125
+
@@ -0,0 +1,89 @@
1
+ # A simple timer that counts in seconds.
2
+ #
3
+ # Usage:
4
+ # timer = Timer.new
5
+ # timer.start
6
+ # ...
7
+ # if timer.delta > 0.5 # half a second
8
+ # ...
9
+ #
10
+ class Timer
11
+
12
+ def initialize
13
+ @is_running = false
14
+ @is_paused = false
15
+ end
16
+
17
+ # Starts counting.
18
+ def start
19
+ return if @is_running
20
+
21
+ @start_time = Time.now
22
+ @stop_time = 0.0
23
+ @paused_time = 0.0
24
+ @is_running = true
25
+ @is_paused = false
26
+ end
27
+
28
+ # Stops counting.
29
+ def stop
30
+ return if not @is_running
31
+
32
+ @stop_time = Time.now
33
+ @is_running = false
34
+ @is_paused = false
35
+ end
36
+
37
+ def restart
38
+ self.stop
39
+ self.start
40
+ end
41
+
42
+ def pause
43
+ return if not @is_running or @is_paused
44
+
45
+ @paused_time = (Time.now - @start_time)
46
+ @is_running = false
47
+ @is_paused = true
48
+ end
49
+
50
+ def unpause
51
+ return if not @is_paused or @is_running
52
+
53
+ @start_time = (Time.now - @paused_time)
54
+ @is_running = true
55
+ @is_paused = false
56
+ end
57
+
58
+ def running?
59
+ @is_running
60
+ end
61
+
62
+ def paused?
63
+ @is_paused
64
+ end
65
+
66
+ # Returns the current delta in seconds (float).
67
+ def delta
68
+ if @is_running
69
+ return (Time.now.to_f - @start_time.to_f)
70
+ end
71
+
72
+ return @paused_time.to_f if @is_paused
73
+
74
+ return @start_time if @start_time == 0 # Something's wrong
75
+
76
+ return (@stop_time.to_f - @start_time.to_f)
77
+ end
78
+
79
+ # Converts the timer's delta to a formatted string.
80
+ def to_s
81
+ min = (self.delta / 60).to_i
82
+ sec = (self.delta).to_i
83
+ msec = (self.delta * 100).to_i
84
+
85
+ "#{min}:#{sec}:#{msec}"
86
+ end
87
+
88
+ end
89
+
@@ -0,0 +1,203 @@
1
+
2
+ require_relative 'screen.rb'
3
+ require_relative 'timer.rb'
4
+
5
+ # A full guitar tab, as shown on the screen.
6
+ # Note that it depends on an already-existing window to exist.
7
+ #
8
+ class Track
9
+ COMMENT_CHAR = '#'
10
+ attr_reader :screen, :percent_completed
11
+ attr_accessor :speed
12
+
13
+ # Creates a Track that will be shown on `screen`.
14
+ # See Screen.
15
+ def initialize(screen)
16
+ @offset = 0
17
+ @timer = Timer.new
18
+ @timer.start
19
+ @speed = 0
20
+ @screen = screen
21
+ @percent_completed = 0
22
+
23
+ @raw_track = []
24
+ @raw_track[0] = ""
25
+ @raw_track[1] = ""
26
+ @raw_track[2] = ""
27
+ @raw_track[3] = ""
28
+ @raw_track[4] = ""
29
+ @raw_track[5] = ""
30
+ @raw_track[6] = ""
31
+ end
32
+
33
+ # Loads and parses +filename+'s contents into Track.
34
+ def load filename
35
+ if not File.exist? filename
36
+ raise "Error: File '#{filename}' doesn't exist!"
37
+ end
38
+ if not File.file? filename
39
+ raise "Error: '#{filename}' is not a file!"
40
+ end
41
+
42
+ file = File.new filename
43
+
44
+ # The thing here is there's no way I can know in
45
+ # advance how many lines the tab track will have.
46
+ #
47
+ # People put lots of strange things on them like
48
+ # timing, comments, etecetera.
49
+ #
50
+ # So I will read all non-blank lines, creating a
51
+ # counter. Then I will use it to display the track
52
+ # onscreen.
53
+ #
54
+ # I will also make every line have the same width
55
+ # as of the biggest one.
56
+
57
+ # Any tab line MUST have EITHER ---1---9--| OR |---3----0
58
+ tab_line = /[-[:alnum:]]\||\|[-[:alnum:]]/
59
+
60
+ # Duration of each note only has those chars.
61
+ # So we look for anything BUT these chars.
62
+ not_duration_line = /[^WHQESTX \.]/
63
+
64
+ count = 0
65
+ max_width = 0
66
+
67
+ file.readlines.each do |line|
68
+ next if line[0] == COMMENT_CHAR
69
+
70
+ line.chomp!
71
+ if line.empty?
72
+
73
+ # Making sure everything will have the same width
74
+ @raw_track.each_with_index do |t, i|
75
+ if t.length < max_width
76
+ @raw_track[i] += (' ' * (max_width - t.length))
77
+ end
78
+ end
79
+
80
+ count = 0
81
+ max_width = 0
82
+
83
+ # Lines must be EITHER a tab_line OR a duration_line.
84
+ # not not duration line means that
85
+ # (I should find a better way of expressing myself on regexes)
86
+ elsif (line =~ tab_line) or (not line =~ not_duration_line)
87
+ @raw_track[count] += line
88
+
89
+ if @raw_track[count].length > max_width
90
+ max_width = @raw_track[count].length
91
+ end
92
+
93
+ count += 1
94
+
95
+ end # Ignoring any other kind of line
96
+
97
+ if count > 7
98
+ raise "Error: Invalid format on '#{filename}'"
99
+ end
100
+
101
+ end
102
+ end
103
+
104
+ # Prints the track on the screen, along with string indicators
105
+ # on the left.
106
+ #
107
+ # It is shown at the vertical center of the provided Screen,
108
+ # spanning it's whole width.
109
+ def show
110
+ x = 1
111
+ y = (@screen.height/2) - (@raw_track.size/2)
112
+
113
+ # This both prints EADGBE and clears the whole screen,
114
+ # printing spaces where the track was.
115
+ #
116
+ # Also, if we have only 5 tracks, we leave the sixth
117
+ # indicator out of the screen.
118
+ if not @raw_track[6] =~ /[:blank:]/
119
+ @screen.mvaddstr(0, y, "E" + (' ' * (@screen.width - 1)))
120
+ @screen.mvaddstr(0, y + 1, "B" + (' ' * (@screen.width - 1)))
121
+ @screen.mvaddstr(0, y + 2, "G" + (' ' * (@screen.width - 1)))
122
+ @screen.mvaddstr(0, y + 3, "D" + (' ' * (@screen.width - 1)))
123
+ @screen.mvaddstr(0, y + 4, "A" + (' ' * (@screen.width - 1)))
124
+ @screen.mvaddstr(0, y + 5, "E" + (' ' * (@screen.width - 1)))
125
+ else
126
+ @screen.mvaddstr(0, y, ' ' * @screen.width)
127
+ @screen.mvaddstr(0, y + 1, "E" + (' ' * (@screen.width - 1)))
128
+ @screen.mvaddstr(0, y + 2, "B" + (' ' * (@screen.width - 1)))
129
+ @screen.mvaddstr(0, y + 3, "G" + (' ' * (@screen.width - 1)))
130
+ @screen.mvaddstr(0, y + 4, "D" + (' ' * (@screen.width - 1)))
131
+ @screen.mvaddstr(0, y + 5, "A" + (' ' * (@screen.width - 1)))
132
+ @screen.mvaddstr(0, y + 6, "E" + (' ' * (@screen.width - 1)))
133
+ end
134
+
135
+ (0...@raw_track.size).each do |i|
136
+ str = @raw_track[i]
137
+ str = str[@offset..(@offset + @screen.width - 2)]
138
+ @screen.mvaddstr(x, y + i, str)
139
+ end
140
+ end
141
+
142
+ # Scrolls the guitar tab by `n`.
143
+ #
144
+ # * If `n` is positive, scroll forward.
145
+ # * If `n` is negative, scroll backward.
146
+ def scroll n
147
+ @offset += n
148
+
149
+ left_limit = 0
150
+ right_limit = (@raw_track[0].length - @screen.width + 1).abs
151
+
152
+ if @offset < left_limit then @offset = left_limit end
153
+ if @offset > right_limit then @offset = right_limit end
154
+ end
155
+
156
+ # Goes to the beginning of the Track.
157
+ def begin
158
+ @offset = 0
159
+ @speed = 0
160
+ end
161
+
162
+ # Goes to the end of the Track.
163
+ def end
164
+ @offset = (@raw_track[0].length - @screen.width + 1).abs
165
+ @speed = 0
166
+ end
167
+
168
+ # Turns on/off Track's auto scroll functionality.
169
+ #
170
+ # Note that it won't work anyways if you don't keep calling
171
+ # +update+ method.
172
+ def auto_scroll option
173
+ if option == true
174
+ @timer.start if not @timer.running?
175
+ else
176
+ @timer.stop
177
+ end
178
+ end
179
+
180
+ # Updates Track's auto scroll functionality.
181
+ def update
182
+ return if not @timer.running?
183
+
184
+ current_completed = @offset + @screen.width - 1
185
+ @percent_completed = ((100.0 * current_completed)/@raw_track[0].length).ceil
186
+
187
+ if @timer.running? and @speed != 0
188
+ if @timer.delta > (1/(@speed*0.5)).abs
189
+ if @speed > 0
190
+ self.scroll 1
191
+ self.show
192
+ else
193
+ self.scroll -1
194
+ self.show
195
+ end
196
+
197
+ @timer.restart
198
+ end
199
+ end
200
+ end
201
+
202
+ end
203
+
data/lib/tabscroll.rb ADDED
@@ -0,0 +1,40 @@
1
+ # The main executable file.
2
+
3
+ require_relative 'tabscroll/engine'
4
+ require_relative 'tabscroll/screen'
5
+ require_relative 'tabscroll/track'
6
+ require_relative 'tabscroll/popup'
7
+
8
+ # Global vars
9
+ $engine = nil
10
+ PROGRAM_NAME = "tabscroll"
11
+ PROGRAM_VERSION = "0.0.1"
12
+
13
+ # Terminates program's execution normally, finishing the engine.
14
+ def quit
15
+ $engine.exit
16
+ exit 0
17
+ end
18
+
19
+ # Displays a help window, waiting for a keypress.
20
+ def show_help_window
21
+ title = 'Help'
22
+ text = <<END_OF_TEXT
23
+ q quit
24
+ h help/go back
25
+ left/right auto-scroll left/right
26
+ up/down stop auto-scrolling
27
+ </> step scroll left/right
28
+ o toggle status/title bars
29
+
30
+
31
+ #{PROGRAM_NAME} v#{PROGRAM_VERSION}
32
+ homepage alexdantas.net/projects/tabscroll
33
+ author Alexandre Dantas <eu@alexdantas.net>
34
+ END_OF_TEXT
35
+
36
+ pop = Popup.new(title, text)
37
+ will_quit = pop.show
38
+ quit if will_quit
39
+ end
40
+
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tabscroll
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Alexandre Dantas
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-10-13 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Scrolls a textual guitar tab on the terminal.
14
+ email:
15
+ - eu@alexdantas.net
16
+ executables:
17
+ - tabscroll
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - lib/tabscroll.rb
22
+ - lib/tabscroll/engine.rb
23
+ - lib/tabscroll/screen.rb
24
+ - lib/tabscroll/track.rb
25
+ - lib/tabscroll/popup.rb
26
+ - lib/tabscroll/timer.rb
27
+ - bin/tabscroll
28
+ homepage: http://www.alexdantas.net/projects/tabscroll
29
+ licenses:
30
+ - GPL-3.0
31
+ metadata: {}
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubyforge_project:
48
+ rubygems_version: 2.1.7
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: Guitar tab scroller on the terminal
52
+ test_files: []