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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/CLAUDE.md +3 -1
- data/README.md +132 -247
- data/doc/library.md +62 -0
- data/doc/media.md +69 -0
- data/doc/sid-player.md +132 -0
- data/doc/snapshots.md +69 -0
- data/exe/badline-ruby +9 -1
- data/lib/badline/address_bus/extra_sids.rb +30 -0
- data/lib/badline/address_bus/sid_slots.rb +42 -0
- data/lib/badline/address_bus.rb +5 -0
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +153 -58
- data/lib/badline/audio/jukebox.rb +121 -36
- data/lib/badline/audio/machine_player.rb +25 -10
- data/lib/badline/audio/pcm_writer.rb +10 -6
- data/lib/badline/audio/playback.rb +6 -5
- data/lib/badline/audio/queued_tune.rb +89 -0
- data/lib/badline/audio/renderer.rb +57 -10
- data/lib/badline/audio/stereo.rb +97 -0
- data/lib/badline/audio/terminal.rb +39 -6
- data/lib/badline/audio/wav.rb +1 -1
- data/lib/badline/audio.rb +2 -0
- data/lib/badline/cartridge/freezer.rb +2 -0
- data/lib/badline/cartridge.rb +3 -0
- data/lib/badline/frontend/app.rb +38 -35
- data/lib/badline/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +9 -2
- data/lib/badline/frontend/buttons.rb +184 -0
- data/lib/badline/frontend/confirmation.rb +50 -0
- data/lib/badline/frontend/controls.rb +44 -32
- data/lib/badline/frontend/file_browser.rb +225 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_report.rb +52 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/menu_dialogs.rb +122 -0
- data/lib/badline/frontend/menu_media.rb +128 -0
- data/lib/badline/frontend/menu_pages.rb +198 -0
- data/lib/badline/frontend/painter.rb +185 -0
- data/lib/badline/frontend/pause_menu.rb +246 -0
- data/lib/badline/frontend/player_commands.rb +33 -0
- data/lib/badline/frontend/player_footer.rb +86 -0
- data/lib/badline/frontend/player_header.rb +60 -0
- data/lib/badline/frontend/player_screen.rb +124 -0
- data/lib/badline/frontend/player_state.rb +67 -0
- data/lib/badline/frontend/player_window.rb +228 -0
- data/lib/badline/frontend/scope.rb +42 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/snapshots.rb +1 -1
- data/lib/badline/frontend/sound.rb +0 -1
- data/lib/badline/frontend/timeline.rb +5 -1
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +21 -0
- data/lib/badline/media/disk_set.rb +108 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +14 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +5 -3
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +65 -46
- data/lib/badline/sdl.rb +16 -6
- data/lib/badline/sid/catch_up.rb +11 -1
- data/lib/badline/sid/filter.rb +9 -0
- data/lib/badline/sid/voice.rb +6 -0
- data/lib/badline/sid/waveform.rb +20 -5
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/storage/hvsc.rb +41 -0
- data/lib/badline/storage/sid_file.rb +45 -25
- data/lib/badline/storage/song_lengths.rb +6 -29
- data/lib/badline/storage/stil/credit.rb +50 -0
- data/lib/badline/storage/stil.rb +126 -0
- data/lib/badline/storage.rb +8 -0
- data/lib/badline/version.rb +1 -1
- 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
|