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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's buttons: each is drawn where it goes on every frame,
6
+ # and remembers its place, so a click finds the action under it. The
7
+ # one under the pointer lights up, and one that's on is drawn reversed.
8
+ # They take the player's colours unless given others: the text, the text
9
+ # under the pointer, the background and, if any, a fill behind each
10
+ # button.
11
+ class Buttons
12
+ PAD = 2
13
+ HEIGHT = Painter::GLYPH + (PAD * 2)
14
+
15
+ def initialize(painter, colors = [SIDView::TEXT, SIDView::BRIGHT, PlayerScreen::BACKGROUND])
16
+ @painter = painter
17
+ @text = colors[0]
18
+ @bright = colors[1]
19
+ @back = colors[2]
20
+ @fill = colors.size > 3 ? colors[3] : -1
21
+ @lefts = []
22
+ @tops = []
23
+ @widths = []
24
+ @heights = []
25
+ @actions = []
26
+ @focusable = []
27
+ @toggles = {}
28
+ @pointer_x = -1
29
+ @pointer_y = -1
30
+ @focus = nil
31
+ end
32
+
33
+ # The action of the button the keys have moved to, which lights up as
34
+ # the one under the pointer does, or nil.
35
+ attr_accessor :focus
36
+
37
+ def forget
38
+ @lefts.clear
39
+ @tops.clear
40
+ @widths.clear
41
+ @heights.clear
42
+ @actions.clear
43
+ @focusable.clear
44
+ @toggles.clear
45
+ end
46
+
47
+ def point(left, top)
48
+ @pointer_x = left
49
+ @pointer_y = top
50
+ end
51
+
52
+ # Draws a button labelled `label` with its top left at `left`, `top`,
53
+ # and returns its width.
54
+ def text(left, top, label, action, on: false)
55
+ width = Painter.width(label) + (PAD * 2)
56
+ place(left, top, width, HEIGHT, action)
57
+ @painter.box(left, top, width, HEIGHT, @fill) if !on && @fill >= 0
58
+ @painter.box(left, top, width, HEIGHT, action == @focus ? @bright : @text) if on
59
+ @painter.text(left + PAD, top + PAD, label, on ? @back : color(@actions.size - 1))
60
+ width
61
+ end
62
+
63
+ # Draws a button as #text does, without the fill.
64
+ def plain(left, top, label, action, on: false)
65
+ fill = @fill
66
+ @fill = -1
67
+ width = text(left, top, label, action, on:)
68
+ @fill = fill
69
+ width
70
+ end
71
+
72
+ # Draws a button as wide as `box`, its left and top and width, with
73
+ # `label` at its left and `value`, if any, at its right, as a menu's
74
+ # row.
75
+ def row(box, label, action, value = "")
76
+ left = box[0]
77
+ top = box[1]
78
+ width = box[2]
79
+ place(left, top, width, HEIGHT, action)
80
+ @painter.box(left, top, width, HEIGHT, @fill) if @fill >= 0
81
+ color = color(@actions.size - 1)
82
+ @painter.text(left + PAD, top + PAD, label, color)
83
+ @painter.text(left + width - PAD - Painter.width(value), top + PAD, value, color) unless value.empty?
84
+ end
85
+
86
+ # Draws a row as #row does, with its choices at its right, `choices`
87
+ # holding their labels, their actions and the index of the one on.
88
+ # A click picks a choice, and pressing the row, `action`, picks the
89
+ # next, which #resolve gives.
90
+ def toggle(box, label, action, choices)
91
+ row(box, label, action)
92
+ labels = choices[0]
93
+ @toggles[action] = choices[1][(choices[2] + 1) % labels.size]
94
+ right = box[0] + box[2] - PAD
95
+ index = labels.size - 1
96
+ while index >= 0
97
+ width = Painter.width(labels[index]) + (PAD * 2)
98
+ right -= width
99
+ choice(right, box[1], labels[index], choices[1][index], index == choices[2])
100
+ right -= 2
101
+ index -= 1
102
+ end
103
+ end
104
+
105
+ # Draws an icon button `scale` times the size of a glyph, and returns
106
+ # its width.
107
+ def icon(left, top, name, action, scale: 1)
108
+ size = (Painter::GLYPH * scale) + (PAD * 2)
109
+ place(left, top, size, size, action)
110
+ @painter.icon(name, left + PAD, top + PAD, color(@actions.size - 1), scale:)
111
+ size
112
+ end
113
+
114
+ # Makes a box drawn elsewhere clickable, such as the bar showing the
115
+ # time played.
116
+ def area(box, action)
117
+ place(box[0], box[1], box[2], box[3], action)
118
+ end
119
+
120
+ # The action of the button at `left`, `top`, or nil: a toggle's
121
+ # choice before the row it sits on.
122
+ def action_at(left, top)
123
+ found = nil
124
+ index = 0
125
+ while index < @actions.size
126
+ if inside?(index, left, top)
127
+ return @actions[index] unless @focusable[index]
128
+
129
+ found ||= @actions[index]
130
+ end
131
+ index += 1
132
+ end
133
+ found
134
+ end
135
+
136
+ # Moves the focus `delta` buttons on, among those drawn from the
137
+ # `first` on, stopping at either end.
138
+ def shift(delta, first)
139
+ return if @actions.size <= first
140
+
141
+ focusable = []
142
+ (first...@actions.size).each { |index| focusable << @actions[index] if @focusable[index] }
143
+ return if focusable.empty?
144
+
145
+ from = focusable.index(@focus)
146
+ @focus = focusable[from.nil? ? 0 : (from + delta).clamp(0, focusable.size - 1)]
147
+ end
148
+
149
+ # The action pressing a button stands for: for a toggle's row, its
150
+ # next choice's.
151
+ def resolve(action) = @toggles.fetch(action, action)
152
+
153
+ # The action of the button drawn `index`th, or nil.
154
+ def action(index) = @actions[index]
155
+
156
+ private
157
+
158
+ def place(left, top, width, height, action)
159
+ @lefts << left
160
+ @tops << top
161
+ @widths << width
162
+ @heights << height
163
+ @actions << action
164
+ @focusable << true
165
+ end
166
+
167
+ # A choice of a toggle, which a click picks but the keys pass by.
168
+ def choice(left, top, label, action, on)
169
+ width = Painter.width(label) + (PAD * 2)
170
+ place(left, top, width, HEIGHT, action)
171
+ @focusable[-1] = false
172
+ @painter.box(left, top, width, HEIGHT, @text) if on
173
+ @painter.text(left + PAD, top + PAD, label, on ? @back : color(@actions.size - 1))
174
+ end
175
+
176
+ def color(index) = inside?(index, @pointer_x, @pointer_y) || @actions[index] == @focus ? @bright : @text
177
+
178
+ def inside?(index, left, top)
179
+ left >= @lefts[index] && left < @lefts[index] + @widths[index] &&
180
+ top >= @tops[index] && top < @tops[index] + @heights[index]
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's question before something power cycles the machine:
6
+ # a cartridge going in or out, or Quick open starting a file. CANCEL
7
+ # has the focus, so Return alone changes nothing.
8
+ class Confirmation
9
+ TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN" }.freeze
10
+ WARNINGS = { cartridge: "INSERTING IT POWER CYCLES THE MACHINE.", remove: "REMOVING IT POWER CYCLES THE MACHINE.",
11
+ start: "THIS STARTS A NEW MACHINE." }.freeze
12
+ ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START" }.freeze
13
+
14
+ def initialize(painter, buttons)
15
+ @painter = painter
16
+ @buttons = buttons
17
+ @kind = :start
18
+ @path = ""
19
+ @open = false
20
+ end
21
+
22
+ def open? = @open
23
+
24
+ # Asks about `kind`, :cartridge, :remove or :start, for the file at
25
+ # `path`.
26
+ def ask(kind, path)
27
+ @kind = kind
28
+ @path = path
29
+ @open = true
30
+ @buttons.focus = :cancel
31
+ end
32
+
33
+ # Puts the question away, and returns its kind and path.
34
+ def close
35
+ @open = false
36
+ [@kind, @path]
37
+ end
38
+
39
+ def draw(left, top)
40
+ painter = @painter
41
+ painter.text(left, top, TITLES.fetch(@kind), PauseMenu::BRIGHT)
42
+ painter.text(left, top + 20, Painter.fit(File.basename(@path), 40), PauseMenu::TEXT) unless @path.empty?
43
+ painter.text(left, top + 44, WARNINGS.fetch(@kind), PauseMenu::TEXT)
44
+ painter.text(left, top + 56, "ANYTHING NOT SAVED IS LOST.", PauseMenu::TEXT)
45
+ @buttons.row([left, top + 80, MenuPages::ROW_WIDTH], "CANCEL", :cancel)
46
+ @buttons.row([left, top + 80 + Buttons::HEIGHT + 2, MenuPages::ROW_WIDTH], ANSWERS.fetch(@kind), :confirm)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,53 +2,72 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # Routes host input to the machine in one of the input modes, which Tab
6
- # steps through and shift-Tab steps back through:
5
+ # Routes host input to the machine. Two settings decide where it goes:
7
6
  #
8
- # - keyboard: every host key goes to the C64 keyboard.
9
- # - joystick: the arrow cluster and space drive one joystick and WASD
10
- # and left shift the other. The arrows start on joystick 2, and F9
11
- # swaps them.
12
- # - mouse1, mouse2: the host mouse is a 1351 mouse on control port 1 or
13
- # 2, and paddles1, paddles2 a pair of paddles there. Motion turns the
14
- # paddles or moves the mouse, and the host's left and right buttons go
15
- # to whichever lines the device puts them on.
7
+ # - The keys: every host key goes to the C64 keyboard, or, as Tab
8
+ # switches, the arrow cluster and space drive one joystick and WASD
9
+ # and left shift the other. The arrows start on joystick 2, and
10
+ # #swap_ports swaps them.
11
+ # - A pot device the pause menu plugs into a control port: a 1351
12
+ # mouse or a pair of paddles, which the host mouse drives. Motion
13
+ # turns the paddles or moves the mouse, and the host's left and right
14
+ # buttons go to whichever lines the device puts them on.
16
15
  class Controls
17
- MODES = %i[keyboard joystick mouse1 mouse2 paddles1 paddles2].freeze
18
- TAGS = ["", "JOY", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
16
+ POTS = %i[none mouse1 mouse2 paddles1 paddles2].freeze
17
+ TAGS = ["", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
19
18
 
20
19
  # SDL mouse button numbers.
21
20
  MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
22
21
 
23
- attr_reader :mode, :arrows_port
22
+ # The pot device plugged in, one of POTS.
23
+ attr_reader :pot, :arrows_port
24
24
 
25
25
  def initialize(computer)
26
26
  @computer = computer
27
- @mode = :keyboard
27
+ @joystick = false
28
+ @pot = :none
28
29
  @arrows_port = 2
29
30
  @mouse = Input::Mouse1351.new
30
31
  @paddles = Input::Paddles.new
31
32
  end
32
33
 
33
- # Routes the input to another machine, the mode's mouse or paddles
34
- # going into its port.
34
+ # Routes the input to another machine, the pot device going into its
35
+ # port.
35
36
  def computer=(computer)
36
37
  release_all
37
38
  @computer = computer
38
39
  attach_pot_device
39
40
  end
40
41
 
41
- def joystick_mode? = @mode == :joystick
42
+ def joystick_mode? = @joystick
43
+
44
+ # Sends the keys to the joysticks, or with false to the keyboard.
45
+ def joystick_mode=(joystick)
46
+ @joystick = joystick
47
+ release_all
48
+ end
49
+
50
+ def toggle_keys
51
+ self.joystick_mode = !@joystick
52
+ end
53
+
54
+ # Plugs in one of POTS, in place of the one before.
55
+ def plug(pot)
56
+ @pot = pot
57
+ release_all
58
+ attach_pot_device
59
+ end
42
60
 
43
61
  # Whether the host mouse drives a pot device, and so should be held
44
62
  # in relative mode.
45
- def pot_device? = MODES.index(@mode) >= 2
63
+ def pot_device? = @pot != :none
46
64
 
47
- # The title bar's tag for the mode, empty for the keyboard.
65
+ # The title bar's tag for the settings, empty for the keyboard alone.
48
66
  def tag
49
- return "JOY #{@arrows_port}" if joystick_mode?
50
-
51
- TAGS[MODES.index(@mode)]
67
+ tags = []
68
+ tags << "JOY #{@arrows_port}" if @joystick
69
+ tags << TAGS[POTS.index(@pot)] if pot_device?
70
+ tags.join(", ")
52
71
  end
53
72
 
54
73
  def key(scancode, down)
@@ -59,13 +78,6 @@ module Badline
59
78
  end
60
79
  end
61
80
 
62
- # Steps to the next mode, or back with a negative step.
63
- def cycle_mode(step)
64
- @mode = MODES[(MODES.index(@mode) + step) % MODES.size]
65
- release_all
66
- attach_pot_device
67
- end
68
-
69
81
  def swap_ports
70
82
  @arrows_port = 3 - @arrows_port
71
83
  release_all
@@ -99,9 +111,9 @@ module Badline
99
111
  end
100
112
  end
101
113
 
102
- def paddles? = MODES.index(@mode) >= 4
114
+ def paddles? = POTS.index(@pot) >= 3
103
115
 
104
- # A fresh device on the mode's port, and none on the other.
116
+ # A fresh pot device on its port, and none on the other.
105
117
  def attach_pot_device
106
118
  ports = @computer.control_ports
107
119
  ports.device1 = nil
@@ -133,7 +145,7 @@ module Badline
133
145
  end
134
146
  end
135
147
 
136
- def port_one? = %i[mouse1 paddles1].include?(@mode)
148
+ def port_one? = %i[mouse1 paddles1].include?(@pot)
137
149
 
138
150
  def joystick_key(scancode, down)
139
151
  direction = Keys.arrows(scancode)
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's file browser, which lists a folder's subfolders and
6
+ # the files a device takes, folders first, each by name. The keys move
7
+ # through the list, open a folder or pick a file, go up a folder, and
8
+ # jump to the next name starting with a letter or digit. A click picks
9
+ # the row under it, and the wheel scrolls.
10
+ class FileBrowser
11
+ ROW = 12
12
+ ROWS = 12
13
+ COLUMNS = 40
14
+
15
+ RETURN = 40
16
+ ESCAPE = 41
17
+ BACKSPACE = 42
18
+ HOME = 74
19
+ PAGE_UP = 75
20
+ END_KEY = 77
21
+ PAGE_DOWN = 78
22
+ RIGHT = 79
23
+ LEFT = 80
24
+ DOWN = 81
25
+ UP = 82
26
+ DIGITS = "1234567890"
27
+
28
+ attr_reader :directory
29
+
30
+ def initialize(painter, buttons)
31
+ @painter = painter
32
+ @buttons = buttons
33
+ @open = false
34
+ @directory = Dir.pwd
35
+ @extensions = []
36
+ @names = []
37
+ @folders = 0
38
+ @selected = 0
39
+ @top = 0
40
+ @list_top = 0
41
+ end
42
+
43
+ def open? = @open
44
+
45
+ # What a name sorts by: lower case, with its numbers padded, so disk 2
46
+ # comes before disk 10.
47
+ def self.order(name) = name.downcase.gsub(/\d+/) { |digits| digits.rjust(10, "0") }
48
+
49
+ # Lists `directory` for the files with one of `extensions`.
50
+ def open(directory, extensions)
51
+ @open = true
52
+ @extensions = extensions
53
+ enter(directory)
54
+ end
55
+
56
+ def close
57
+ @open = false
58
+ end
59
+
60
+ # Handles a key, and returns the path of a file picked, :cancel when
61
+ # Esc leaves the browser, or nil.
62
+ def key(scancode)
63
+ return :cancel if scancode == ESCAPE
64
+ return pick if [RETURN, RIGHT].include?(scancode)
65
+ return up if [LEFT, BACKSPACE].include?(scancode)
66
+
67
+ select(@selected + step(scancode)) unless step(scancode).zero?
68
+ jump(scancode)
69
+ nil
70
+ end
71
+
72
+ # Picks the row at `top`, as Return would.
73
+ def click(top)
74
+ row = (top - @list_top) / ROW
75
+ return nil if row.negative? || @top + row >= @names.size
76
+
77
+ @selected = @top + row
78
+ pick
79
+ end
80
+
81
+ def scroll(rows)
82
+ @top = (@top + rows).clamp(0, [@names.size - ROWS, 0].max)
83
+ end
84
+
85
+ def draw(left, top, width, title)
86
+ @painter.text(left, top, title, PauseMenu::BRIGHT)
87
+ @painter.text(left, top + ROW + 2, shown_directory, PauseMenu::DIM)
88
+ @list_top = top + (ROW * 2) + 8
89
+ @buttons.area([left, @list_top, width, ROW * ROWS], :browse)
90
+ draw_rows(left, width)
91
+ draw_bar(left + width - 2) if @names.size > ROWS
92
+ @painter.text(left, @list_top + (ROW * ROWS) + 6, "RETURN: OPEN LEFT: BACK ESC: CANCEL",
93
+ PauseMenu::DIM)
94
+ end
95
+
96
+ private
97
+
98
+ # The folder's path, cut at the start to fit.
99
+ def shown_directory
100
+ length = @directory.length
101
+ length > COLUMNS ? "...#{@directory[length - COLUMNS + 3, COLUMNS - 3]}" : @directory
102
+ end
103
+
104
+ def draw_rows(left, width)
105
+ ROWS.times do |row|
106
+ index = @top + row
107
+ break if index >= @names.size
108
+
109
+ name = label(index)
110
+ top = @list_top + (row * ROW)
111
+ if index == @selected
112
+ @painter.box(left - 2, top - 2, width - 4, ROW, PauseMenu::TEXT)
113
+ @painter.text(left, top, name, PauseMenu::PANEL)
114
+ else
115
+ @painter.text(left, top, name, index < @folders ? PauseMenu::BRIGHT : PauseMenu::TEXT)
116
+ end
117
+ end
118
+ end
119
+
120
+ def draw_bar(left)
121
+ height = ROW * ROWS
122
+ size = [(height * ROWS) / @names.size, 4].max
123
+ offset = ((height - size) * @top) / [@names.size - ROWS, 1].max
124
+ @painter.box(left, @list_top - 2, 2, height, PauseMenu::EDGE)
125
+ @painter.box(left, @list_top - 2 + offset, 2, size, PauseMenu::TEXT)
126
+ end
127
+
128
+ def label(index)
129
+ name = @names[index]
130
+ name += "/" if index < @folders
131
+ Painter.fit(name, COLUMNS)
132
+ end
133
+
134
+ def step(scancode)
135
+ case scancode
136
+ when UP then -1
137
+ when DOWN then 1
138
+ when PAGE_UP then -ROWS
139
+ when PAGE_DOWN then ROWS
140
+ when HOME then -@names.size
141
+ when END_KEY then @names.size
142
+ else 0
143
+ end
144
+ end
145
+
146
+ def select(index)
147
+ return if @names.empty?
148
+
149
+ @selected = index.clamp(0, @names.size - 1)
150
+ @top = @selected if @selected < @top
151
+ @top = @selected - ROWS + 1 if @selected >= @top + ROWS
152
+ end
153
+
154
+ # Selects the next name after the one selected that starts with the
155
+ # key's letter or digit.
156
+ def jump(scancode)
157
+ char = if scancode.between?(4, 29) then (97 + scancode - 4).chr
158
+ elsif scancode.between?(30, 39) then DIGITS[scancode - 30]
159
+ else ""
160
+ end
161
+ return if char.empty? || @names.empty?
162
+
163
+ @names.size.times do |offset|
164
+ index = (@selected + 1 + offset) % @names.size
165
+ next unless @names[index].downcase.start_with?(char)
166
+
167
+ select(index)
168
+ break
169
+ end
170
+ end
171
+
172
+ def pick
173
+ return nil if @names.empty?
174
+
175
+ path = File.join(@directory, @names[@selected])
176
+ return path unless @selected < @folders
177
+
178
+ @names[@selected] == ".." ? up : enter(path)
179
+ end
180
+
181
+ def up
182
+ parent = File.dirname(@directory)
183
+ return nil if parent == @directory
184
+
185
+ from = File.basename(@directory)
186
+ enter(parent)
187
+ index = @names.index(from)
188
+ select(index) unless index.nil?
189
+ nil
190
+ end
191
+
192
+ def enter(directory)
193
+ @directory = File.expand_path(directory)
194
+ folders = []
195
+ files = []
196
+ children(@directory).each do |name|
197
+ next if name.start_with?(".")
198
+
199
+ if File.directory?(File.join(@directory, name))
200
+ folders << name
201
+ elsif @extensions.include?(File.extname(name).downcase)
202
+ files << name
203
+ end
204
+ end
205
+ listing(folders, files)
206
+ nil
207
+ end
208
+
209
+ def listing(folders, files)
210
+ folders = folders.sort_by { |name| FileBrowser.order(name) }
211
+ folders.unshift("..") unless File.dirname(@directory) == @directory
212
+ @folders = folders.size
213
+ @names = folders + files.sort_by { |name| FileBrowser.order(name) }
214
+ @selected = 0
215
+ @top = 0
216
+ end
217
+
218
+ def children(directory)
219
+ Dir.children(directory)
220
+ rescue SystemCallError
221
+ []
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Draws the SID's filter as the player shows it: its modes, cutoff,
6
+ # resonance and volume, and its response across the audible range on
7
+ # the chip playing, whose cutoff curve the two models don't share.
8
+ class FilterView
9
+ TEXT = SIDView::TEXT
10
+ BRIGHT = SIDView::BRIGHT
11
+ DIM = SIDView::DIM
12
+ BOX = SIDView::BOX
13
+
14
+ MODES = ["LP", "BP", "HP", "3 OFF"].freeze
15
+
16
+ LEFT = SIDView::LEFT
17
+ CURVE_LEFT = 224
18
+ WIDTH = SIDView::WAVE_WIDTH
19
+ HEIGHT = SIDView::PANEL_HEIGHT
20
+
21
+ # The lowest and highest frequencies on the curve's axis.
22
+ LOW_HZ = 30.0
23
+ HIGH_HZ = 16_000.0
24
+
25
+ def initialize(painter)
26
+ @painter = painter
27
+ @curve = Array.new(WIDTH + 1, 0)
28
+ end
29
+
30
+ def draw(history, frame, top, model)
31
+ cutoff = (history.register(frame, 0x15) & 0x07) | (history.register(frame, 0x16) << 3)
32
+ resonance = history.register(frame, 0x17) >> 4
33
+ mode_volume = history.register(frame, 0x18)
34
+ hertz = SID::Filter::W0.fetch(model)[cutoff] / (2 * Math::PI * SID::Filter::SCALE)
35
+ paint = @painter
36
+ paint.text(LEFT, top, "FILTER", BRIGHT)
37
+ x = LEFT + 64
38
+ MODES.each_with_index do |name, bit|
39
+ x += paint.text(x, top, name, mode_volume[bit + 4] == 1 ? BRIGHT : DIM) + 8
40
+ end
41
+ paint.text(LEFT, top + 16, format("CUTOFF $%<cutoff>03X", cutoff:), TEXT)
42
+ paint.text(LEFT, top + 28, format(" %<hertz>d HZ", hertz: hertz.round), TEXT)
43
+ paint.text(LEFT, top + 40, format("RES %<res>X VOL %<vol>X", res: resonance, vol: mode_volume & 0x0f), TEXT)
44
+ draw_curve(hertz, resonance, mode_volume, top)
45
+ end
46
+
47
+ private
48
+
49
+ # The gain for the modes selected on a logarithmic axis, with the
50
+ # cutoff marked.
51
+ def draw_curve(cutoff_hertz, resonance, mode_volume, top)
52
+ paint = @painter
53
+ paint.box(CURVE_LEFT, top, WIDTH, HEIGHT, BOX)
54
+ return paint.text(CURVE_LEFT + 8, top + 8, "NO MODE", DIM) if mode_volume.nobits?(0x70)
55
+
56
+ quality = 0.707 + (resonance / 15.0)
57
+ cutoff = [cutoff_hertz, 1.0].max
58
+ points = @curve
59
+ x = 0
60
+ while x <= WIDTH
61
+ hertz = LOW_HZ * ((HIGH_HZ / LOW_HZ)**(x.to_f / WIDTH))
62
+ decibels = gain(hertz / cutoff, quality, mode_volume).clamp(-36.0, 12.0)
63
+ points[x] = Painter.point(CURVE_LEFT + x, top + 8 + (((12.0 - decibels) * (HEIGHT - 12)) / 48.0).round)
64
+ x += 1
65
+ end
66
+ paint.polyline(points, BRIGHT)
67
+ marker = (WIDTH * Math.log(cutoff / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ)).round
68
+ paint.box(CURVE_LEFT + marker, top, 1, HEIGHT, DIM) if marker.between?(0, WIDTH)
69
+ end
70
+
71
+ # The gain in decibels of a two-pole state-variable filter at `ratio`
72
+ # of its cutoff, summing the low-, band- and high-pass outputs chosen.
73
+ def gain(ratio, quality, mode_volume)
74
+ real = 1.0 - (ratio * ratio)
75
+ imaginary = ratio / quality
76
+ denominator = (real * real) + (imaginary * imaginary)
77
+ out_real = 0.0
78
+ out_imaginary = 0.0
79
+ if mode_volume.anybits?(0x10)
80
+ out_real += real / denominator
81
+ out_imaginary -= imaginary / denominator
82
+ end
83
+ if mode_volume.anybits?(0x20)
84
+ out_real += ratio * imaginary / denominator
85
+ out_imaginary += ratio * real / denominator
86
+ end
87
+ if mode_volume.anybits?(0x40)
88
+ out_real -= ratio * ratio * real / denominator
89
+ out_imaginary += ratio * ratio * imaginary / denominator
90
+ end
91
+ magnitude = Math.sqrt((out_real * out_real) + (out_imaginary * out_imaginary))
92
+ 20.0 * Math.log([magnitude, 1.0e-6].max) / Math.log(10)
93
+ end
94
+ end
95
+ end
96
+ end