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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The --verbose report every 50 frames: the frame rate, the time each
6
+ # stage of a frame took on average, the slowest frame's work, and how
7
+ # the sound's queue fared.
8
+ class FrameReport
9
+ STAGES = %w[events emulate audio blit present wait].freeze
10
+
11
+ def initialize(sound)
12
+ @sound = sound
13
+ @reported_samples = 0
14
+ clear
15
+ end
16
+
17
+ # Adds a frame's stamps, one as each stage starts and one after the
18
+ # last.
19
+ def add(stamps)
20
+ STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
21
+ took = stamps[-2] - stamps.first
22
+ @slowest = took if took > @slowest
23
+ end
24
+
25
+ # Reports the 50 frames added over the last `seconds`, and starts
26
+ # over.
27
+ def show(seconds)
28
+ stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
29
+ puts "#{(50 / seconds).round(1)} fps, per frame ms: #{stages.join(' ')}, " \
30
+ "slowest work #{(@slowest * 1000).round(2)}"
31
+ show_sound(seconds) if @sound.on?
32
+ clear
33
+ end
34
+
35
+ private
36
+
37
+ def clear
38
+ @spent = Array.new(STAGES.size, 0.0)
39
+ @slowest = 0.0
40
+ end
41
+
42
+ def show_sound(seconds)
43
+ sound = @sound
44
+ rate = (sound.queued - @reported_samples) / seconds
45
+ queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
46
+ puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
47
+ sound.reset_levels
48
+ @reported_samples = sound.queued
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's INFO view: the tune's STIL entry, and the playing
6
+ # subtune's, laid out as STIL.txt lays them out but reflowed to the
7
+ # window's width. A line of STIL's that runs to its width goes on into
8
+ # the next, and a shorter one ends a paragraph. Text longer than the view
9
+ # scrolls, by the wheel, the up and down keys, or a click or drag along
10
+ # the scroll bar beside it.
11
+ class InfoView
12
+ HEIGHT = VisualizerView::HEIGHT
13
+ LINE = 10
14
+ ROWS = HEIGHT / LINE
15
+ LEFT = SIDView::LEFT
16
+ BAR = 6
17
+ BAR_LEFT = PlayerHeader::WIDTH - 16 - BAR
18
+ COLUMNS = (BAR_LEFT - LEFT - 8) / Painter::GLYPH
19
+ LABEL = 9
20
+
21
+ # How long a line of STIL's is when it runs on into the next.
22
+ FULL = 60
23
+
24
+ attr_reader :offset
25
+
26
+ def initialize(painter, buttons, top)
27
+ @painter = painter
28
+ @buttons = buttons
29
+ @top = top
30
+ @lines = []
31
+ @offset = 0
32
+ end
33
+
34
+ # Shows the tune's fields, then the subtune's under its number, from
35
+ # the top.
36
+ def show(fields, subtune, subtune_fields)
37
+ lines = InfoView.layout(fields)
38
+ lines += ["", "(##{subtune})"] + InfoView.layout(subtune_fields) unless subtune_fields.empty?
39
+ @lines = lines.empty? ? ["No STIL entry for this tune."] : lines
40
+ @offset = 0
41
+ end
42
+
43
+ def scroll(rows)
44
+ @offset = (@offset + rows).clamp(0, last_offset)
45
+ end
46
+
47
+ # Scrolls to the place `top` points at along the scroll bar.
48
+ def scroll_along(top)
49
+ @offset = ((top - @top).to_f / HEIGHT * @lines.size).round.clamp(0, last_offset)
50
+ end
51
+
52
+ def draw
53
+ index = 0
54
+ while index < ROWS && @offset + index < @lines.size
55
+ @painter.text(LEFT, @top + (index * LINE), @lines[@offset + index], SIDView::TEXT)
56
+ index += 1
57
+ end
58
+ draw_bar if @lines.size > ROWS
59
+ end
60
+
61
+ # Each field's label, right-aligned as in STIL.txt, before its text
62
+ # reflowed into the columns beside it.
63
+ def self.layout(fields)
64
+ lines = []
65
+ fields.each do |field|
66
+ label = "#{field.name}:".rjust(LABEL - 1)
67
+ paragraphs(field.text).each do |paragraph|
68
+ wrap(paragraph, COLUMNS - LABEL).each do |line|
69
+ lines << "#{label} #{line}"
70
+ label = " " * (LABEL - 1)
71
+ end
72
+ end
73
+ end
74
+ lines
75
+ end
76
+
77
+ # The text's lines joined where one ran to STIL's width.
78
+ def self.paragraphs(text)
79
+ paragraphs = []
80
+ full = false
81
+ text.split("\n").each do |line|
82
+ if full
83
+ paragraphs[-1] = "#{paragraphs[-1]} #{line.strip}"
84
+ else
85
+ paragraphs << line.strip
86
+ end
87
+ full = line.length >= FULL
88
+ end
89
+ paragraphs
90
+ end
91
+
92
+ # The text broken at spaces into lines of at most `columns`, and a word
93
+ # longer than that broken where it has to be.
94
+ def self.wrap(text, columns)
95
+ lines = []
96
+ rest = text
97
+ while rest.length > columns
98
+ cut = rest.rindex(" ", columns)
99
+ cut = columns if cut.nil? || cut.zero?
100
+ lines << rest[0, cut].rstrip
101
+ rest = rest[cut, rest.length - cut].lstrip
102
+ end
103
+ lines << rest
104
+ lines
105
+ end
106
+
107
+ private
108
+
109
+ def last_offset = [@lines.size - ROWS, 0].max
110
+
111
+ def draw_bar
112
+ @buttons.area([BAR_LEFT, @top, BAR, HEIGHT], :scroll)
113
+ @painter.box(BAR_LEFT, @top, BAR, HEIGHT, SIDView::BOX)
114
+ thumb = [HEIGHT * ROWS / @lines.size, 8].max
115
+ place = (HEIGHT - thumb) * @offset / [last_offset, 1].max
116
+ @painter.box(BAR_LEFT, @top + place, BAR, thumb, SIDView::TEXT)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's screens that take over its panel: the file browser
6
+ # INSERT and Quick open bring up, and the question before a cartridge
7
+ # or Quick open power cycles the machine. A disk or tape picked goes
8
+ # straight in.
9
+ class MenuDialogs
10
+ EXTENSIONS = {
11
+ disk: PauseMenu::DISKS, tape: %w[.tap], cartridge: %w[.crt],
12
+ program: %w[.d64 .d71 .d81 .g64 .t64 .tap .crt .prg .p00 .sid]
13
+ }.freeze
14
+ TITLES = { disk: "INSERT A DISK", tape: "INSERT A TAPE", cartridge: "INSERT A CARTRIDGE",
15
+ program: "QUICK OPEN" }.freeze
16
+
17
+ ESCAPE = 41
18
+ RETURN = 40
19
+ SPACE = 44
20
+
21
+ # The machine Quick open started.
22
+ attr_reader :computer
23
+
24
+ def initialize(painter, buttons, media, options)
25
+ media_path = options.media_path.to_s
26
+ @buttons = buttons
27
+ @media = media
28
+ @options = options
29
+ @browser = FileBrowser.new(painter, buttons)
30
+ @confirmation = Confirmation.new(painter, buttons)
31
+ @browsing = nil
32
+ @browse_from = media_path.empty? ? Dir.pwd : File.dirname(File.expand_path(media_path))
33
+ @computer = nil
34
+ end
35
+
36
+ def open? = !@browsing.nil? || @confirmation.open?
37
+
38
+ # Opens the browser on the files for `kind`: :disk, :tape, :cartridge
39
+ # or :program.
40
+ def browse(kind)
41
+ @browsing = kind
42
+ @browser.open(@browse_from, EXTENSIONS.fetch(kind))
43
+ end
44
+
45
+ def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
46
+
47
+ # Puts a dropped disk or tape in its device, or asks before a
48
+ # cartridge or a program starts, and says whether it asks. Other
49
+ # files are left alone.
50
+ def drop(path)
51
+ extension = File.extname(path).downcase
52
+ return @media.insert(:disk, path) && false if EXTENSIONS[:disk].include?(extension)
53
+ return @media.insert(:tape, path) && false if EXTENSIONS[:tape].include?(extension)
54
+ return false unless EXTENSIONS[:program].include?(extension)
55
+
56
+ @confirmation.ask(extension == ".crt" ? :cartridge : :start, path)
57
+ true
58
+ end
59
+
60
+ # Handles a key, and returns :resume or :swap once a question is
61
+ # answered yes, or nil.
62
+ def key(scancode)
63
+ return browsed(@browser.key(scancode)) unless @browsing.nil?
64
+ return answer(:cancel) if scancode == ESCAPE
65
+ return answer(@buttons.focus) if [RETURN, SPACE].include?(scancode)
66
+
67
+ direction = PauseMenu::ARROWS[scancode]
68
+ @buttons.shift(direction == :up ? -1 : 1, 1) if %i[up down].include?(direction)
69
+ nil
70
+ end
71
+
72
+ # Handles a click on the browser's list at `top`, or on a question's
73
+ # answer, `action`.
74
+ def click(action, top) = action == :browse ? browsed(@browser.click(top)) : answer(action)
75
+
76
+ def scroll(rows) = @browser.scroll(rows)
77
+
78
+ def draw(left, top, width)
79
+ return @browser.draw(left, top, width, TITLES.fetch(@browsing)) unless @browsing.nil?
80
+
81
+ @confirmation.draw(left, top)
82
+ end
83
+
84
+ private
85
+
86
+ # Puts the disk or tape picked in, or asks before a cartridge or
87
+ # Quick open power cycles the machine, and leaves the browser, as Esc
88
+ # does.
89
+ def browsed(picked)
90
+ return nil if picked.nil?
91
+
92
+ kind = @browsing
93
+ @browse_from = @browser.directory
94
+ @browsing = nil
95
+ return nil if picked == :cancel
96
+
97
+ if %i[disk tape].include?(kind)
98
+ @media.insert(kind, picked)
99
+ else
100
+ @confirmation.ask(kind == :cartridge ? :cartridge : :start, picked)
101
+ end
102
+ nil
103
+ end
104
+
105
+ # Does what the question asked about if `action` is :confirm, and
106
+ # returns :resume, or :swap for a new machine.
107
+ def answer(action)
108
+ question = @confirmation.close
109
+ return nil unless action == :confirm
110
+
111
+ path = question[1]
112
+ case question[0]
113
+ when :cartridge then @media.attach_cartridge(path) ? :resume : nil
114
+ when :remove then @media.remove_cartridge && :resume
115
+ else
116
+ @computer = @media.start(@options, path)
117
+ @computer.nil? ? nil : :swap
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # What the pause menu puts in the machine and takes out: disks, tapes
6
+ # and cartridges, whether disks are writable, which carries over from
7
+ # one disk to the next, and the disk's set, which PREVIOUS and NEXT step
8
+ # through. It keeps what last went wrong for the page to show.
9
+ class MenuMedia
10
+ attr_reader :disk_path, :problem
11
+ attr_writer :computer
12
+
13
+ def initialize(disk_path, writable)
14
+ @disk_path = disk_path
15
+ @writable = writable
16
+ @set_path = ""
17
+ @set = []
18
+ @problem = ""
19
+ @computer = nil
20
+ end
21
+
22
+ def writable? = @writable
23
+
24
+ def inserted?
25
+ drive = @computer.drive1541
26
+ drive.nil? ? @computer.mounted? : !drive.disk.nil?
27
+ end
28
+
29
+ # Puts a disk in device 8 or a tape in the datasette, `kind` :disk or
30
+ # :tape.
31
+ def insert(kind, path)
32
+ @problem = ""
33
+ if kind == :disk
34
+ Media.insert_disk(@computer, path, read_only: !@writable)
35
+ @disk_path = path
36
+ else
37
+ @computer.datasette.insert(Storage::TAP.new(path))
38
+ end
39
+ rescue ArgumentError, SystemCallError, Storage::TAP::FormatError, Storage::T64::FormatError,
40
+ Storage::G64Image::FormatError => e
41
+ @problem = e.message
42
+ end
43
+
44
+ # Does what the Drive page's :eject_disk, :previous_disk, :next_disk,
45
+ # :protect or :unprotect asks.
46
+ def drive(action)
47
+ case action
48
+ when :eject_disk then eject_disk
49
+ when :previous_disk then step_disk(-1)
50
+ when :next_disk then step_disk(1)
51
+ else writable(action == :unprotect)
52
+ end
53
+ end
54
+
55
+ def eject_disk
56
+ drive = @computer.drive1541
57
+ drive.nil? ? @computer.unmount : drive.insert(nil)
58
+ @disk_path = ""
59
+ end
60
+
61
+ # Sets whether disks are writable, for the disk in the drive too,
62
+ # which goes in again as the notch now says.
63
+ def writable(writable)
64
+ @writable = writable
65
+ insert(:disk, @disk_path) if inserted? && !@disk_path.empty?
66
+ end
67
+
68
+ # The disks of the set the disk in the drive belongs to.
69
+ def disk_set
70
+ return [] if @disk_path.empty?
71
+
72
+ unless @set_path == @disk_path
73
+ @set = Media::DiskSet.around(@disk_path)
74
+ @set_path = @disk_path
75
+ end
76
+ @set
77
+ end
78
+
79
+ # Puts the disk `step` before or after this one in its set in the
80
+ # drive, if there is one.
81
+ def step_disk(step)
82
+ set = disk_set
83
+ index = set.index(@disk_path)
84
+ return if index.nil? || !(index + step).between?(0, set.size - 1)
85
+
86
+ insert(:disk, set[index + step])
87
+ end
88
+
89
+ # Swaps the cartridge for the one at `path`, which power cycles the
90
+ # machine, and says whether it went in.
91
+ def attach_cartridge(path)
92
+ @problem = ""
93
+ Media.attach(@computer, path)
94
+ true
95
+ rescue ArgumentError, SystemCallError, Storage::CRTFile::FormatError, Cartridge::UnsupportedTypeError => e
96
+ @problem = e.message
97
+ false
98
+ end
99
+
100
+ # The name the cartridge in the expansion port gives itself, or an
101
+ # empty one.
102
+ def cartridge_name
103
+ cartridge = @computer.address_bus.cartridge
104
+ cartridge.nil? ? "" : cartridge.name
105
+ end
106
+
107
+ # Takes the cartridge out with the power off.
108
+ def remove_cartridge
109
+ @computer.address_bus.detach_cartridge
110
+ @computer.power_cycle!
111
+ end
112
+
113
+ # A new machine started on `path`, as the command line would, or nil
114
+ # when it won't start.
115
+ def start(options, path)
116
+ @problem = ""
117
+ computer = Frontend.start(options, path, @writable)
118
+ @disk_path = PauseMenu.disk_path(path)
119
+ computer
120
+ rescue ArgumentError, SystemCallError, Media::TrueDrive::Error, Storage::SIDFile::FormatError,
121
+ Storage::T64::FormatError, Storage::TAP::FormatError, Storage::CRTFile::FormatError,
122
+ Storage::G64Image::FormatError, Cartridge::UnsupportedTypeError => e
123
+ @problem = e.message
124
+ nil
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's sections, each showing a part of the machine's
6
+ # hardware as it stands, in one column: what's there, and under it a
7
+ # row for each thing to do, a setting showing its value on the right,
8
+ # which a press steps on.
9
+ class MenuPages
10
+ TEXT = PauseMenu::TEXT
11
+ DIM = PauseMenu::DIM
12
+ LINE = 12
13
+ COLUMNS = 25
14
+ ROW_WIDTH = 200
15
+
16
+ WARNING = 0xff7a6b
17
+
18
+ PORT_DEVICES = %i[joystick mouse paddles].freeze
19
+ PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
20
+ PORT_ACTIONS = [%i[port1_joystick port1_mouse port1_paddles], %i[port2_joystick port2_mouse port2_paddles]].freeze
21
+ POTS = [%i[none mouse1 paddles1], %i[none mouse2 paddles2]].freeze
22
+
23
+ # The disks, tapes and cartridges the pages put in and take out.
24
+ attr_reader :media
25
+
26
+ def initialize(painter, buttons, disk_path, options)
27
+ @painter = painter
28
+ @buttons = buttons
29
+ @media = MenuMedia.new(disk_path, options.writable?)
30
+ @reu_kb = options.reu
31
+ @computer = nil
32
+ @controls = nil
33
+ @sound = nil
34
+ @left = 0
35
+ @top = 0
36
+ end
37
+
38
+ # The machine the pages show and change.
39
+ def machine(computer, controls, sound)
40
+ @computer = computer
41
+ @media.computer = computer
42
+ @controls = controls
43
+ @sound = sound
44
+ end
45
+
46
+ def draw(page, left, top)
47
+ problem = @media.problem
48
+ @painter.text(left, top + 186, problem[0, COLUMNS], WARNING) unless problem.empty?
49
+ @left = left
50
+ @top = top
51
+ case page
52
+ when :drive then draw_drive
53
+ when :datasette then draw_datasette
54
+ when :expansion then draw_expansion
55
+ when :ports then draw_ports
56
+ when :sound then draw_sound
57
+ else draw_power
58
+ end
59
+ end
60
+
61
+ def perform(action)
62
+ case action
63
+ when :eject_disk, :previous_disk, :next_disk, :protect, :unprotect then @media.drive(action)
64
+ when :tape_stop then @computer.datasette.stop!
65
+ when :tape_down then @computer.datasette.play!
66
+ when :tape_rewind then @computer.datasette.rewind
67
+ when :tape_eject then @computer.datasette.eject
68
+ when :keys_c64, :keys_joystick then keys(action == :keys_c64 ? :keyboard : :joystick)
69
+ when :swap then @controls.swap_ports
70
+ when :sound_on, :mute then @sound.toggle_mute unless @sound.muted? == (action == :mute)
71
+ when :sid6581, :sid8580 then @computer.sid.model = action == :sid8580 ? :mos8580 : :mos6581
72
+ when :reset then @computer.reset!
73
+ when :power_cycle then @computer.power_cycle!
74
+ else pick_port(action)
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def draw_drive
81
+ media = @media
82
+ inserted = media.inserted?
83
+ set = inserted ? media.disk_set : []
84
+ place = set.size < 2 ? "" : " #{set.index(media.disk_path).to_i + 1} OF #{set.size}"
85
+ info("DISK#{place}", inserted ? File.basename(media.disk_path) : "")
86
+ row("INSERT...", :insert_disk)
87
+ row("EJECT", :eject_disk)
88
+ unless set.size < 2
89
+ row("PREVIOUS DISK", :previous_disk)
90
+ row("NEXT DISK", :next_disk)
91
+ end
92
+ toggle("WRITABLE", :writable, [%w[OFF ON], %i[protect unprotect], media.writable? ? 1 : 0])
93
+ skip
94
+ info("DEVICE", @computer.drive1541.nil? ? "FAST LOADING" : "1541, TRUE DRIVE")
95
+ end
96
+
97
+ def draw_datasette
98
+ datasette = @computer.datasette
99
+ tape = datasette.tape
100
+ info("TAPE", tape.nil? ? "" : File.basename(tape.path))
101
+ row("INSERT...", :insert_tape)
102
+ row("EJECT", :tape_eject)
103
+ toggle("PLAY", :tape_play, [%w[UP DOWN], %i[tape_stop tape_down], datasette.playing? ? 1 : 0])
104
+ row("REWIND", :tape_rewind)
105
+ skip
106
+ info("MOTOR", datasette.motor? ? "ON" : "OFF")
107
+ end
108
+
109
+ def draw_expansion
110
+ cartridge = @computer.address_bus.cartridge
111
+ info("CARTRIDGE", cartridge.nil? ? "" : cartridge.name)
112
+ row("INSERT...", :insert_cartridge)
113
+ row("REMOVE", :remove_cartridge)
114
+ row("FREEZE", :freeze) if !cartridge.nil? && cartridge.freezer?
115
+ return if @reu_kb.nil?
116
+
117
+ skip
118
+ info("RAM EXPANSION", "REU, #{@reu_kb} KB")
119
+ end
120
+
121
+ def draw_ports
122
+ toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
123
+ toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
124
+ joystick = @controls.joystick_mode?
125
+ toggle("KEYS", :keys, [%w[C64 JOYSTICK], %i[keys_c64 keys_joystick], joystick ? 1 : 0])
126
+ return unless joystick
127
+
128
+ row("SWAP JOYSTICKS", :swap)
129
+ skip
130
+ arrows = @controls.arrows_port
131
+ info("ARROWS, SPACE", "PORT #{arrows}")
132
+ info("WASD, LEFT SHIFT", "PORT #{3 - arrows}")
133
+ end
134
+
135
+ def draw_sound
136
+ if @sound.on?
137
+ toggle("OUTPUT", :output, [%w[ON MUTED], %i[sound_on mute], @sound.muted? ? 1 : 0])
138
+ else
139
+ info("OUTPUT", "OFF")
140
+ end
141
+ toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], @computer.sid.model == :mos8580 ? 1 : 0])
142
+ end
143
+
144
+ def draw_power
145
+ row("RESET", :reset)
146
+ row("POWER CYCLE", :power_cycle)
147
+ skip
148
+ row("QUIT", :quit)
149
+ end
150
+
151
+ # A label, and its value under it, or NONE in the dim colour when the
152
+ # value is empty.
153
+ def info(label, value)
154
+ @painter.text(@left, @top, label, DIM)
155
+ if value.empty?
156
+ @painter.text(@left, @top + LINE, "NONE", DIM)
157
+ else
158
+ @painter.text(@left, @top + LINE, Painter.fit(value, COLUMNS), TEXT)
159
+ end
160
+ @top += (LINE * 2) + 6
161
+ end
162
+
163
+ def row(label, action, value = "")
164
+ @buttons.row([@left, @top, ROW_WIDTH], label, action, value)
165
+ @top += Buttons::HEIGHT + 2
166
+ end
167
+
168
+ def toggle(label, action, choices)
169
+ @buttons.toggle([@left, @top, ROW_WIDTH], label, action, choices)
170
+ @top += Buttons::HEIGHT + 2
171
+ end
172
+
173
+ def skip
174
+ @top += LINE
175
+ end
176
+
177
+ def keys(mode) = @controls.joystick_mode = mode == :joystick
178
+
179
+ def port_device(port)
180
+ index = POTS[port - 1].index(@controls.pot)
181
+ index.nil? ? :joystick : PORT_DEVICES[index]
182
+ end
183
+
184
+ # Plugs the device a choice such as :port1_mouse names into its port.
185
+ def pick_port(action)
186
+ index = PORT_ACTIONS[0].include?(action) ? 0 : 1
187
+ choice = PORT_ACTIONS[index].index(action)
188
+ plug_device(index + 1, choice) unless choice.nil? || choice == PORT_DEVICES.index(port_device(index + 1))
189
+ end
190
+
191
+ # Plugs PORT_DEVICES[choice] into the port. A joystick there takes out
192
+ # the pot device.
193
+ def plug_device(port, choice)
194
+ @controls.plug(POTS[port - 1][choice])
195
+ end
196
+ end
197
+ end
198
+ end