badline 0.6.1 → 0.8.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +132 -247
  5. data/doc/library.md +62 -0
  6. data/doc/media.md +69 -0
  7. data/doc/sid-player.md +132 -0
  8. data/doc/snapshots.md +69 -0
  9. data/exe/badline-ruby +9 -1
  10. data/lib/badline/address_bus/extra_sids.rb +30 -0
  11. data/lib/badline/address_bus/sid_slots.rb +42 -0
  12. data/lib/badline/address_bus.rb +5 -0
  13. data/lib/badline/audio/aiff.rb +2 -2
  14. data/lib/badline/audio/bare_player.rb +23 -12
  15. data/lib/badline/audio/cli.rb +153 -58
  16. data/lib/badline/audio/jukebox.rb +121 -36
  17. data/lib/badline/audio/machine_player.rb +25 -10
  18. data/lib/badline/audio/pcm_writer.rb +10 -6
  19. data/lib/badline/audio/playback.rb +6 -5
  20. data/lib/badline/audio/queued_tune.rb +89 -0
  21. data/lib/badline/audio/renderer.rb +57 -10
  22. data/lib/badline/audio/stereo.rb +97 -0
  23. data/lib/badline/audio/terminal.rb +39 -6
  24. data/lib/badline/audio/wav.rb +1 -1
  25. data/lib/badline/audio.rb +2 -0
  26. data/lib/badline/cartridge/freezer.rb +2 -0
  27. data/lib/badline/cartridge.rb +3 -0
  28. data/lib/badline/frontend/app.rb +38 -35
  29. data/lib/badline/frontend/audio_sink.rb +6 -5
  30. data/lib/badline/frontend/boot.rb +9 -2
  31. data/lib/badline/frontend/buttons.rb +184 -0
  32. data/lib/badline/frontend/confirmation.rb +50 -0
  33. data/lib/badline/frontend/controls.rb +44 -32
  34. data/lib/badline/frontend/file_browser.rb +225 -0
  35. data/lib/badline/frontend/filter_view.rb +96 -0
  36. data/lib/badline/frontend/frame_report.rb +52 -0
  37. data/lib/badline/frontend/info_view.rb +120 -0
  38. data/lib/badline/frontend/menu_dialogs.rb +122 -0
  39. data/lib/badline/frontend/menu_media.rb +128 -0
  40. data/lib/badline/frontend/menu_pages.rb +198 -0
  41. data/lib/badline/frontend/painter.rb +185 -0
  42. data/lib/badline/frontend/pause_menu.rb +246 -0
  43. data/lib/badline/frontend/player_commands.rb +33 -0
  44. data/lib/badline/frontend/player_footer.rb +86 -0
  45. data/lib/badline/frontend/player_header.rb +60 -0
  46. data/lib/badline/frontend/player_screen.rb +124 -0
  47. data/lib/badline/frontend/player_state.rb +67 -0
  48. data/lib/badline/frontend/player_window.rb +228 -0
  49. data/lib/badline/frontend/scope.rb +42 -0
  50. data/lib/badline/frontend/sid_history.rb +175 -0
  51. data/lib/badline/frontend/sid_view.rb +140 -0
  52. data/lib/badline/frontend/snapshots.rb +1 -1
  53. data/lib/badline/frontend/sound.rb +0 -1
  54. data/lib/badline/frontend/timeline.rb +5 -1
  55. data/lib/badline/frontend/visualizer_view.rb +101 -0
  56. data/lib/badline/frontend.rb +21 -0
  57. data/lib/badline/media/disk_set.rb +108 -0
  58. data/lib/badline/media/queue.rb +158 -0
  59. data/lib/badline/media.rb +14 -8
  60. data/lib/badline/options/help.rb +34 -5
  61. data/lib/badline/options/option.rb +1 -1
  62. data/lib/badline/options/table.rb +5 -3
  63. data/lib/badline/options/validation.rb +54 -0
  64. data/lib/badline/options.rb +65 -46
  65. data/lib/badline/sdl.rb +16 -6
  66. data/lib/badline/sid/catch_up.rb +11 -1
  67. data/lib/badline/sid/filter.rb +9 -0
  68. data/lib/badline/sid/voice.rb +6 -0
  69. data/lib/badline/sid/waveform.rb +20 -5
  70. data/lib/badline/sid.rb +35 -3
  71. data/lib/badline/storage/hvsc.rb +41 -0
  72. data/lib/badline/storage/sid_file.rb +45 -25
  73. data/lib/badline/storage/song_lengths.rb +6 -29
  74. data/lib/badline/storage/stil/credit.rb +50 -0
  75. data/lib/badline/storage/stil.rb +126 -0
  76. data/lib/badline/storage.rb +8 -0
  77. data/lib/badline/version.rb +1 -1
  78. metadata +36 -1
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Draws on an SDL renderer in the C64's colours and its character ROM's
6
+ # font: filled boxes, lines and text. The font is the ROM's second set,
7
+ # with both cases, turned into a texture once: 16 by 16 glyphs of 8 by 8
8
+ # pixels, white where the glyph is set and clear elsewhere, tinted to
9
+ # the colour asked for as each glyph is drawn.
10
+ class Painter
11
+ GLYPH = 8
12
+ ATLAS = GLYPH * 16
13
+
14
+ # An opaque white pixel, in the low half of a word and in the high
15
+ # half, where it only fits a signed 64-bit word as a negative number.
16
+ SET = 0xffffffff
17
+ SET_HIGH = -0x1_0000_0000
18
+
19
+ SPACE = 0x20
20
+
21
+ # Icons drawn in the C64's 8 by 8 cells, which take the places of the
22
+ # first reversed glyphs, from ICON on.
23
+ ICON = 0x80
24
+ ICONS = {
25
+ play: %w[.X...... .XX..... .XXX.... .XXXX... .XXXX... .XXX.... .XX..... .X......],
26
+ pause: %w[........ .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. ........],
27
+ previous: %w[X.....X. X....XX. X...XXX. X..XXXX. X..XXXX. X...XXX. X....XX. X.....X.],
28
+ next: %w[.X.....X .XX....X .XXX...X .XXXX..X .XXXX..X .XXX...X .XX....X .X.....X],
29
+ left: %w[........ ....X... ...XX... ..XXX... ..XXX... ...XX... ....X... ........],
30
+ right: %w[........ ...X.... ...XX... ...XXX.. ...XXX.. ...XX... ...X.... ........]
31
+ }.freeze
32
+ ICON_NAMES = ICONS.keys.freeze
33
+
34
+ # The screen code of each printable ASCII character in the second set.
35
+ # The rest show as a question mark.
36
+ SCREEN_CODES = Array.new(128) do |ascii|
37
+ if ascii.between?(0x20, 0x3f) || ascii.between?(0x41, 0x5a) then ascii
38
+ elsif ascii == 0x40 then 0
39
+ elsif ascii.between?(0x61, 0x7a) then ascii - 0x60
40
+ elsif ascii == 0x5b then 27
41
+ elsif ascii == 0x5d then 29
42
+ elsif ascii == 0x5e then 30
43
+ elsif ascii == 0x5f then 100
44
+ else 63
45
+ end
46
+ end.freeze
47
+
48
+ def initialize(renderer)
49
+ @renderer = renderer
50
+ @font = SDL.SDL_CreateTexture(renderer, SDL::PIXELFORMAT_ARGB8888, SDL::TEXTUREACCESS_STREAMING, ATLAS, ATLAS)
51
+ SDL.SDL_SetTextureBlendMode(@font, SDL::BLENDMODE_BLEND)
52
+ load_font
53
+ SDL.rect_w(SDL.glyph_rect, GLYPH)
54
+ SDL.rect_h(SDL.glyph_rect, GLYPH)
55
+ @tint = -1
56
+ @size = 0
57
+ end
58
+
59
+ def close = SDL.SDL_DestroyTexture(@font)
60
+
61
+ def box(left, top, width, height, rgb)
62
+ @size = 0
63
+ rect = SDL.rect
64
+ SDL.rect_x(rect, left)
65
+ SDL.rect_y(rect, top)
66
+ SDL.rect_w(rect, width)
67
+ SDL.rect_h(rect, height)
68
+ pen(rgb)
69
+ SDL.SDL_RenderFillRect(@renderer, rect)
70
+ end
71
+
72
+ # Joins the points, each made by Painter.point, with lines.
73
+ def polyline(points, rgb)
74
+ pen(rgb)
75
+ SDL.SDL_RenderDrawLines(@renderer, points, points.size)
76
+ end
77
+
78
+ # A point for #polyline: an SDL_Point, two 32-bit ints packed into one
79
+ # of the words an Integer Array hands to C, x in the low half.
80
+ def self.point(left, top) = left | (top << 32)
81
+
82
+ # Draws the text with its top left at `left` and `top`, each glyph
83
+ # `scale` times its size, and returns the width drawn.
84
+ def text(left, top, string, rgb, scale: 1)
85
+ tint(rgb)
86
+ place(top, GLYPH * scale)
87
+ x = left
88
+ string.each_char do |char|
89
+ code = screen_code(char)
90
+ glyph(code, x) unless code == SPACE
91
+ x += @size
92
+ end
93
+ x - left
94
+ end
95
+
96
+ def self.width(string, scale: 1) = string.length * GLYPH * scale
97
+
98
+ # The string cut to `columns` characters in its middle, so both its
99
+ # start and its end show.
100
+ def self.fit(string, columns)
101
+ return string if string.length <= columns
102
+
103
+ head = (columns - 3) / 2
104
+ tail = columns - 3 - head
105
+ "#{string[0, head]}...#{string[string.length - tail, tail]}"
106
+ end
107
+
108
+ def icon(name, left, top, rgb, scale: 1)
109
+ tint(rgb)
110
+ place(top, GLYPH * scale)
111
+ glyph(ICON + ICON_NAMES.index(name), left)
112
+ end
113
+
114
+ private
115
+
116
+ def screen_code(char)
117
+ ascii = char.ord
118
+ ascii < 128 ? SCREEN_CODES[ascii] : 63
119
+ end
120
+
121
+ # Sets the row and size glyphs go at, which a run of text shares.
122
+ def place(top, size)
123
+ to = SDL.rect
124
+ SDL.rect_y(to, top)
125
+ return if size == @size
126
+
127
+ @size = size
128
+ SDL.rect_w(to, size)
129
+ SDL.rect_h(to, size)
130
+ end
131
+
132
+ def glyph(code, left)
133
+ from = SDL.glyph_rect
134
+ SDL.rect_x(from, (code % 16) * GLYPH)
135
+ SDL.rect_y(from, (code / 16) * GLYPH)
136
+ to = SDL.rect
137
+ SDL.rect_x(to, left)
138
+ SDL.SDL_RenderCopy(@renderer, @font, from, to)
139
+ end
140
+
141
+ def pen(rgb)
142
+ SDL.SDL_SetRenderDrawColor(@renderer, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255)
143
+ end
144
+
145
+ def tint(rgb)
146
+ return if rgb == @tint
147
+
148
+ @tint = rgb
149
+ SDL.SDL_SetTextureColorMod(@font, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff)
150
+ end
151
+
152
+ # Two pixels to a word, the left one in the low half, as Screen packs
153
+ # them.
154
+ def load_font
155
+ rom = ROM.read("character.rom")
156
+ words = Array.new(ATLAS * ATLAS / 2, 0)
157
+ 256.times { |code| unpack_glyph(rom[0x800 + (code * GLYPH), GLYPH], code, words) }
158
+ ICON_NAMES.each_with_index { |name, index| unpack_glyph(icon_rows(name), ICON + index, words) }
159
+ rect = SDL.glyph_rect
160
+ SDL.rect_x(rect, 0)
161
+ SDL.rect_y(rect, 0)
162
+ SDL.rect_w(rect, ATLAS)
163
+ SDL.rect_h(rect, ATLAS)
164
+ SDL.SDL_UpdateTexture(@font, rect, words, ATLAS * 4)
165
+ end
166
+
167
+ def icon_rows(name)
168
+ ICONS.fetch(name).map { |row| row.tr(".X", "01").to_i(2) }
169
+ end
170
+
171
+ def unpack_glyph(rows, code, words)
172
+ GLYPH.times do |row|
173
+ bits = rows[row]
174
+ y = ((code / 16) * GLYPH) + row
175
+ x = (code % 16) * GLYPH
176
+ 4.times do |pair|
177
+ left = bits[7 - (pair * 2)] == 1 ? SET : 0
178
+ right = bits[6 - (pair * 2)] == 1 ? SET_HIGH : 0
179
+ words[(((y * ATLAS) + x) / 2) + pair] = left | right
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The emulator's pause menu, which F9 opens over the frozen picture.
6
+ # The machine stands still while it's open. Its sections follow the
7
+ # machine's hardware: the drive, the datasette, the expansion port, the
8
+ # control ports, the sound and the power. It's drawn in colours outside
9
+ # the C64's palette, so it reads as the emulator's and not the
10
+ # machine's, and F9 or Esc closes it again.
11
+ #
12
+ # INSERT on a page opens the file browser for that device. Quick open
13
+ # starts a file as the command line does, in a new machine, and it and
14
+ # a cartridge going in or out ask first, as each power cycles the
15
+ # machine.
16
+ class PauseMenu
17
+ SECTIONS = ["DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
18
+ PAGES = %i[drive datasette expansion ports sound power].freeze
19
+ DISKS = %w[.d64 .d71 .d81 .g64 .t64].freeze
20
+
21
+ PANEL = 0x1d2230
22
+ EDGE = 0x3a4256
23
+ TEXT = 0xc9d1e0
24
+ BRIGHT = 0xffc66d
25
+ DIM = 0x6b7489
26
+ FILL = 0x343c50
27
+
28
+ LEFT = 16
29
+ TOP = 16
30
+ WIDTH = Screen::WIDTH - 32
31
+ HEIGHT = Screen::HEIGHT - 32
32
+ BODY = 154
33
+
34
+ ESCAPE = 41
35
+ RETURN = 40
36
+ SPACE = 44
37
+ ARROWS = { 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
38
+ MOUSEWHEEL = 0x403
39
+
40
+ # Takes the media, the REU and whether disks are writable from Options.
41
+ def initialize(painter, options)
42
+ media_path = options.media_path.to_s
43
+ @painter = painter
44
+ @buttons = Buttons.new(painter, [TEXT, BRIGHT, PANEL, FILL])
45
+ @pages = MenuPages.new(painter, @buttons, PauseMenu.disk_path(media_path), options)
46
+ @dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options)
47
+ @open = false
48
+ @section = 0
49
+ end
50
+
51
+ # The path of the disk the command line puts in device 8, or an empty
52
+ # one for other media.
53
+ def self.disk_path(path)
54
+ return "" if path.empty?
55
+ return path if File.directory?(path)
56
+
57
+ DISKS.include?(File.extname(path).downcase) ? path : ""
58
+ end
59
+
60
+ def open? = @open
61
+
62
+ # The machine Quick open started, which the app runs in place of the
63
+ # one before once #frame returns :swap.
64
+ def computer = @dialogs.computer
65
+
66
+ def show(computer, controls, sound)
67
+ @open = true
68
+ @buttons.focus = PAGES[@section]
69
+ @pages.machine(computer, controls, sound)
70
+ SDL.SDL_SetRelativeMouseMode(0)
71
+ draw
72
+ end
73
+
74
+ def close
75
+ @open = false
76
+ end
77
+
78
+ # Opens on the file the SDL event says was dropped on the window,
79
+ # and says whether the menu stays open to ask about it: a disk or tape
80
+ # goes straight in, and anything else asks first.
81
+ def drop(computer, controls, sound)
82
+ show(computer, controls, sound)
83
+ file = SDL.event_file(SDL.event)
84
+ asks = @dialogs.drop(LibC.strstr(file, ""))
85
+ LibC.free(file)
86
+ asks
87
+ end
88
+
89
+ # Handles a key pressed while the menu is open: the arrows move
90
+ # between the buttons, and a section opens as they reach it, Return
91
+ # or Space presses the button, and F9 or Esc closes the menu. While
92
+ # the file browser or a question is open, the keys go to it, and Esc
93
+ # goes back to the page. Returns the action the app has to take on,
94
+ # as #click does.
95
+ def key(scancode)
96
+ return :resume if scancode == Keys::F9
97
+ return @dialogs.key(scancode) if @dialogs.open?
98
+ return :resume if scancode == ESCAPE
99
+ return choose(@buttons.focus) if [RETURN, SPACE].include?(scancode)
100
+
101
+ direction = ARROWS[scancode]
102
+ move(direction) unless direction.nil?
103
+ nil
104
+ end
105
+
106
+ # Handles a click, and returns the action the app has to take on, if
107
+ # any: :resume, :quit, :freeze or :swap.
108
+ def click(left, top)
109
+ action = @buttons.action_at(left, top)
110
+ return @dialogs.click(action, top) if %i[browse confirm cancel].include?(action)
111
+
112
+ choose(action)
113
+ end
114
+
115
+ # Handles the events that came in, draws the menu over the frozen
116
+ # picture, dimmed, and returns the action the app has to take on, if
117
+ # any, leaving the events after one for the machine. Key releases
118
+ # still reach the machine, so no key stays held down while it stands
119
+ # still.
120
+ def frame(renderer, texture, controls)
121
+ action = nil
122
+ action = event(SDL.event_type(SDL.event), controls) while action.nil? && SDL.SDL_PollEvent(SDL.event) != 0
123
+ SDL.SDL_SetTextureColorMod(texture, 96, 96, 96)
124
+ SDL.SDL_RenderClear(renderer)
125
+ SDL.SDL_RenderCopy(renderer, texture, nil, nil)
126
+ SDL.SDL_SetTextureColorMod(texture, 255, 255, 255)
127
+ draw
128
+ SDL.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
129
+ SDL.SDL_RenderPresent(renderer)
130
+ SDL.SDL_Delay(10)
131
+ action
132
+ end
133
+
134
+ def draw
135
+ painter = @painter
136
+ @buttons.forget
137
+ painter.box(LEFT, TOP, WIDTH, HEIGHT, PANEL)
138
+ painter.box(LEFT, TOP + 24, WIDTH, 1, EDGE)
139
+ painter.text(LEFT + 8, TOP + 9, "PAUSED", BRIGHT)
140
+ resume = "RESUME"
141
+ @buttons.plain(LEFT + WIDTH - 8 - Painter.width(resume) - (Buttons::PAD * 2), TOP + 7, resume, :resume)
142
+ return @dialogs.draw(LEFT + 8, TOP + 34, WIDTH - 16) if @dialogs.open?
143
+
144
+ painter.box(BODY - 8, TOP + 32, 1, HEIGHT - 56, EDGE)
145
+ draw_sections
146
+ @buttons.plain(LEFT + 6, TOP + HEIGHT - 28, "QUICK OPEN...", :quick_open)
147
+ painter.text(LEFT + 8, TOP + HEIGHT - 12, "F9/ESC: RESUME", DIM)
148
+ @pages.draw(PAGES[@section], BODY, TOP + 34)
149
+ end
150
+
151
+ private
152
+
153
+ def choose(pressed)
154
+ return nil if pressed.nil?
155
+
156
+ action = @buttons.resolve(pressed)
157
+ index = PAGES.index(action)
158
+ unless index.nil?
159
+ @section = index
160
+ return nil
161
+ end
162
+ return action if %i[resume quit freeze].include?(action)
163
+
164
+ menu_action(action)
165
+ end
166
+
167
+ def menu_action(action)
168
+ case action
169
+ when :insert_disk then @dialogs.browse(:disk)
170
+ when :insert_tape then @dialogs.browse(:tape)
171
+ when :insert_cartridge then @dialogs.browse(:cartridge)
172
+ when :quick_open then @dialogs.browse(:program)
173
+ when :remove_cartridge then @dialogs.ask_remove
174
+ else @pages.perform(action)
175
+ end
176
+ nil
177
+ end
178
+
179
+ # Moves the focus: down from RESUME to the sections, through them,
180
+ # where each opens as it's reached, on to Quick open, right into a
181
+ # page, up and down it, and left out of it again.
182
+ def move(direction)
183
+ focus = @buttons.focus
184
+ section = focus == :quick_open ? PAGES.size : PAGES.index(focus)
185
+ if focus == :resume
186
+ @buttons.focus = PAGES[@section] if %i[down left].include?(direction)
187
+ elsif section.nil?
188
+ move_in_page(direction)
189
+ else
190
+ move_in_sections(section, direction)
191
+ end
192
+ end
193
+
194
+ # Up and down go through the page's rows, and left goes back to its
195
+ # section.
196
+ def move_in_page(direction)
197
+ case direction
198
+ when :up, :down then @buttons.shift(direction == :up ? -1 : 1, PAGES.size + 2)
199
+ when :left then @buttons.focus = PAGES[@section]
200
+ end
201
+ end
202
+
203
+ def move_in_sections(section, direction)
204
+ case direction
205
+ when :up then section.zero? ? @buttons.focus = :resume : open_section(section - 1)
206
+ when :down then section < PAGES.size - 1 ? open_section(section + 1) : @buttons.focus = :quick_open
207
+ when :right then @buttons.focus = @buttons.action(PAGES.size + 2) || @buttons.focus if section < PAGES.size
208
+ end
209
+ end
210
+
211
+ # Opens a section, laid out at once, so keys that come before the next
212
+ # frame find its rows.
213
+ def open_section(index)
214
+ @section = index
215
+ @buttons.focus = PAGES[index]
216
+ draw
217
+ end
218
+
219
+ def event(type, controls)
220
+ event = SDL.event
221
+ case type
222
+ when SDL::QUIT then :quit
223
+ when SDL::KEYDOWN then key_down(SDL.event_scancode(event), SDL.event_repeat(event))
224
+ when SDL::KEYUP then controls.key(SDL.event_scancode(event), false) && nil
225
+ when SDL::MOUSEMOTION then @buttons.point(SDL.event_x(event), SDL.event_y(event)) && nil
226
+ when SDL::MOUSEBUTTONDOWN
227
+ SDL.event_button(event) == 1 ? click(SDL.event_x(event), SDL.event_y(event)) : nil
228
+ when MOUSEWHEEL then @dialogs.scroll(-3 * SDL.event_x(event)) && nil
229
+ end
230
+ end
231
+
232
+ # A key held down repeats only the arrows.
233
+ def key_down(scancode, repeat)
234
+ repeat.zero? || ARROWS.key?(scancode) ? key(scancode) : nil
235
+ end
236
+
237
+ def draw_sections
238
+ top = TOP + 32
239
+ SECTIONS.each_with_index do |name, index|
240
+ @buttons.plain(LEFT + 6, top, name.ljust(14), PAGES[index], on: index == @section)
241
+ top += Buttons::HEIGHT + 2
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # What the SID player's window does with an action a key or a click asks
6
+ # for: one that picks or steps through the views or the SID models, or
7
+ # picks the SID shown, changes the PlayerState, and any other goes on to
8
+ # the jukebox.
9
+ class PlayerCommands
10
+ VIEWS = PlayerHeader::VIEWS
11
+ CHIPS = PlayerHeader::CHIPS
12
+ SIDS = SIDView::SIDS
13
+
14
+ def initialize(state)
15
+ @state = state
16
+ end
17
+
18
+ # Returns the action for the jukebox, or nil when the window has
19
+ # handled it.
20
+ def handle(action)
21
+ state = @state
22
+ if action == :view then state.view = (state.view + 1) % VIEWS.size
23
+ elsif action == :chip then state.chip = (state.chip + 1) % CHIPS.size
24
+ elsif VIEWS.include?(action) then state.view = VIEWS.index(action)
25
+ elsif CHIPS.include?(action) then state.chip = CHIPS.index(action)
26
+ elsif SIDS.include?(action) then state.sid = SIDS.index(action)
27
+ else return action
28
+ end
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The bottom of the SID player's window: how far the subtune has played,
6
+ # on a bar a click seeks along, marked with where a seek is headed, and the buttons that pause and step
7
+ # through the queue's tunes and the tune's subtunes and turn the queue's
8
+ # modes on and off.
9
+ class PlayerFooter
10
+ HEIGHT = 44
11
+ WIDTH = PlayerHeader::WIDTH
12
+ CLOCK = "00:00 / 00:00"
13
+ BAR_LEFT = 16
14
+ BAR_WIDTH = WIDTH - 32 - (CLOCK.length * Painter::GLYPH) - 8
15
+ TOGGLES = ["SHUFFLE", "LOOP", "ALL SUBTUNES"].freeze
16
+ TOGGLE_ACTIONS = %i[shuffle loop all_subtunes].freeze
17
+ TOGGLE_NOTES = ["shuffle", "loop", "all subtunes"].freeze
18
+
19
+ def initialize(painter, buttons)
20
+ @painter = painter
21
+ @buttons = buttons
22
+ end
23
+
24
+ def draw(state, top)
25
+ draw_progress(state, top + 4)
26
+ row = top + 18
27
+ x = 12
28
+ x += @buttons.icon(x, row, :previous, :previous, scale: 2) + 4
29
+ x += @buttons.icon(x, row, state.paused? ? :play : :pause, :pause, scale: 2) + 4
30
+ x += @buttons.icon(x, row, :next, :next, scale: 2) + 8
31
+ unless state.tunes.zero?
32
+ x += @painter.text(x, row + 6, "TUNE #{state.tune}/#{state.tunes}", SIDView::BRIGHT) + 24
33
+ draw_subtunes(state, x, row + 4)
34
+ end
35
+ draw_toggles(state, row + 4)
36
+ end
37
+
38
+ # The seconds into the subtune a click `left` along the bar asks for.
39
+ def self.seek(left, length) = ((left - BAR_LEFT).to_f / BAR_WIDTH).clamp(0.0, 1.0) * length
40
+
41
+ private
42
+
43
+ def draw_subtunes(state, left, row)
44
+ x = left + @painter.text(left, row + 2, "SUBTUNE", SIDView::TEXT) + 4
45
+ x += @buttons.icon(x, row, :left, :previous_subtune)
46
+ x += @painter.text(x + 2, row + 2, "#{state.subtune}/#{state.subtunes}", SIDView::BRIGHT) + 4
47
+ @buttons.icon(x, row, :right, :next_subtune)
48
+ end
49
+
50
+ def draw_progress(state, top)
51
+ played = state.played
52
+ width = BAR_WIDTH
53
+ @buttons.area([BAR_LEFT, top - 2, width, 12], :seek)
54
+ @painter.box(BAR_LEFT, top + 2, width, 4, SIDView::BOX)
55
+ draw_target(state, top) if state.seeking?
56
+ @painter.box(BAR_LEFT, top + 2, along(state, played), 4, SIDView::BRIGHT)
57
+ @painter.text(WIDTH - 16 - Painter.width(CLOCK), top, "#{clock(played)} / #{clock(state.length)}",
58
+ SIDView::TEXT)
59
+ end
60
+
61
+ def draw_target(state, top)
62
+ target = along(state, state.target)
63
+ @painter.box(BAR_LEFT, top + 2, target, 4, SIDView::DIM)
64
+ @painter.box(BAR_LEFT + [target, BAR_WIDTH - 1].min, top - 1, 1, 10, SIDView::BRIGHT)
65
+ end
66
+
67
+ # How far along the bar `seconds` into the subtune is.
68
+ def along(state, seconds)
69
+ length = state.length
70
+ length.positive? ? (BAR_WIDTH * seconds / length).round.clamp(0, BAR_WIDTH) : 0
71
+ end
72
+
73
+ def draw_toggles(state, row)
74
+ x = PlayerHeader.right_aligned(TOGGLES)
75
+ TOGGLES.each_with_index do |label, toggle|
76
+ x += @buttons.text(x, row, label, TOGGLE_ACTIONS[toggle], on: state.on?(TOGGLE_NOTES[toggle])) + 4
77
+ end
78
+ end
79
+
80
+ def clock(seconds)
81
+ whole = seconds.floor
82
+ format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The top of the SID player's window: the tune's name, author and
6
+ # release, the STIL credit for what the subtune covers at the moment, a
7
+ # warning when there is one, and the buttons for the views and the SID
8
+ # model.
9
+ class PlayerHeader
10
+ WIDTH = 640
11
+ VIEW_NAMES = %w[VISUALIZER SID INFO].freeze
12
+ VIEWS = %i[visualizer sid info].freeze
13
+ CHIP_NAMES = %w[AUTO 6581 8580].freeze
14
+ CHIPS = %i[auto mos6581 mos8580].freeze
15
+
16
+ def initialize(painter, buttons)
17
+ @painter = painter
18
+ @buttons = buttons
19
+ end
20
+
21
+ # Underlines the models playing while AUTO picks them.
22
+ def draw(state, tune, playing_models, credit)
23
+ unless tune.nil?
24
+ @painter.text(16, 8, fit(tune.name, 26), SIDView::BRIGHT, scale: 2)
25
+ @painter.text(16, 28, fit([tune.author, tune.released].reject(&:empty?).join(" - "), 50), SIDView::TEXT)
26
+ @painter.text(16, 40, fit(credit, 76), SIDView::BRIGHT)
27
+ @painter.text(16, 52, warning(state), PlayerWindow::WARNING)
28
+ end
29
+ draw_views(state)
30
+ draw_chips(state, playing_models)
31
+ end
32
+
33
+ def self.right_aligned(labels) = WIDTH - 12 - labels.sum { |label| Painter.width(label) + 8 }
34
+
35
+ private
36
+
37
+ def warning(state) = state.on?("below real time") ? "Running below real time" : ""
38
+
39
+ def draw_views(state)
40
+ x = PlayerHeader.right_aligned(VIEW_NAMES)
41
+ VIEW_NAMES.each_with_index do |name, view|
42
+ x += @buttons.text(x, 6, name, VIEWS[view], on: view == state.view) + 4
43
+ end
44
+ end
45
+
46
+ def draw_chips(state, playing_models)
47
+ x = PlayerHeader.right_aligned(CHIP_NAMES)
48
+ CHIP_NAMES.each_with_index do |name, chip|
49
+ width = @buttons.text(x, 24, name, CHIPS[chip], on: chip == state.chip)
50
+ if state.chip.zero? && playing_models.include?(CHIPS[chip])
51
+ @painter.box(x + 2, 37, width - 4, 1, SIDView::BRIGHT)
52
+ end
53
+ x += width + 4
54
+ end
55
+ end
56
+
57
+ def fit(text, chars) = text.length > chars ? "#{text[0, chars - 2]}.." : text
58
+ end
59
+ end
60
+ end