badline 0.7.0 → 0.9.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +104 -292
  5. data/doc/library.md +69 -0
  6. data/doc/media.md +78 -0
  7. data/doc/pinned-behaviour.md +53 -9
  8. data/doc/sid-player.md +136 -0
  9. data/doc/snapshots.md +102 -0
  10. data/lib/badline/address_bus/roms.rb +32 -0
  11. data/lib/badline/address_bus.rb +4 -4
  12. data/lib/badline/audio/bare_player.rb +24 -0
  13. data/lib/badline/audio/checkpoints.rb +42 -0
  14. data/lib/badline/audio/jukebox.rb +11 -4
  15. data/lib/badline/audio/machine_player.rb +16 -0
  16. data/lib/badline/audio/renderer.rb +80 -9
  17. data/lib/badline/audio/stereo.rb +3 -5
  18. data/lib/badline/audio.rb +1 -0
  19. data/lib/badline/cartridge/freezer.rb +2 -0
  20. data/lib/badline/cartridge.rb +3 -0
  21. data/lib/badline/computer/attachments.rb +5 -5
  22. data/lib/badline/computer.rb +12 -10
  23. data/lib/badline/core.rb +48 -0
  24. data/lib/badline/data_path.rb +37 -0
  25. data/lib/badline/datasette.rb +24 -9
  26. data/lib/badline/drive1541/disk.rb +3 -3
  27. data/lib/badline/drive1541/disk_state.rb +14 -5
  28. data/lib/badline/frontend/app.rb +41 -37
  29. data/lib/badline/frontend/boot.rb +16 -4
  30. data/lib/badline/frontend/buttons.rb +101 -7
  31. data/lib/badline/frontend/confirmation.rb +56 -0
  32. data/lib/badline/frontend/controls.rb +44 -32
  33. data/lib/badline/frontend/file_browser.rb +225 -0
  34. data/lib/badline/frontend/frame_report.rb +52 -0
  35. data/lib/badline/frontend/menu_dialogs.rb +160 -0
  36. data/lib/badline/frontend/menu_media.rb +157 -0
  37. data/lib/badline/frontend/menu_pages.rb +220 -0
  38. data/lib/badline/frontend/name_field.rb +88 -0
  39. data/lib/badline/frontend/painter.rb +10 -0
  40. data/lib/badline/frontend/pause_menu.rb +264 -0
  41. data/lib/badline/frontend/player_window.rb +3 -3
  42. data/lib/badline/frontend/snapshots.rb +146 -18
  43. data/lib/badline/frontend/sound.rb +11 -6
  44. data/lib/badline/frontend/timeline.rb +5 -1
  45. data/lib/badline/frontend.rb +8 -0
  46. data/lib/badline/kernal_trap/load.rb +10 -0
  47. data/lib/badline/kernal_trap/routine.rb +28 -0
  48. data/lib/badline/kernal_trap/serial.rb +19 -7
  49. data/lib/badline/media/disk_list.rb +75 -0
  50. data/lib/badline/media/disk_set.rb +118 -0
  51. data/lib/badline/media.rb +9 -3
  52. data/lib/badline/model.rb +62 -0
  53. data/lib/badline/options/event.rb +5 -3
  54. data/lib/badline/options/help.rb +16 -12
  55. data/lib/badline/options/table.rb +6 -2
  56. data/lib/badline/options/validation.rb +9 -0
  57. data/lib/badline/options.rb +10 -7
  58. data/lib/badline/region.rb +16 -4
  59. data/lib/badline/reu/ram.rb +27 -0
  60. data/lib/badline/reu/saved_state.rb +27 -0
  61. data/lib/badline/roms/README +10 -0
  62. data/lib/badline/roms/kernal-sx64.rom +0 -0
  63. data/lib/badline/sid/decimator.rb +13 -0
  64. data/lib/badline/sid/filter.rb +6 -3
  65. data/lib/badline/sid.rb +19 -0
  66. data/lib/badline/snapshot/fields.rb +1 -0
  67. data/lib/badline/snapshot/setup.rb +20 -8
  68. data/lib/badline/snapshot/state.rb +14 -7
  69. data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
  70. data/lib/badline/snapshot/vice/peripherals.rb +12 -2
  71. data/lib/badline/snapshot/vice/restore.rb +3 -1
  72. data/lib/badline/snapshot/vice/reu1764.rb +55 -0
  73. data/lib/badline/snapshot/vice/vicii.rb +59 -13
  74. data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
  75. data/lib/badline/snapshot/vice.rb +25 -23
  76. data/lib/badline/storage/t64.rb +7 -4
  77. data/lib/badline/storage/tap.rb +7 -5
  78. data/lib/badline/storage/unavailable.rb +1 -1
  79. data/lib/badline/storage.rb +5 -5
  80. data/lib/badline/version.rb +1 -1
  81. data/lib/badline/vic/sprite/timing.rb +5 -9
  82. data/lib/badline/vic/sprite.rb +4 -3
  83. data/lib/badline.rb +2 -38
  84. metadata +22 -1
@@ -0,0 +1,160 @@
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, Quick open and LOAD bring up, the question before a cartridge
7
+ # or Quick open power cycles the machine, and the prompt for a save's
8
+ # name. A disk or tape picked goes straight in, and a save loads.
9
+ class MenuDialogs
10
+ EXTENSIONS = {
11
+ disk: %w[.d64 .d71 .d81 .g64 .t64 .m3u .vfl], tape: %w[.tap], cartridge: %w[.crt],
12
+ program: %w[.d64 .d71 .d81 .g64 .t64 .m3u .vfl .tap .crt .prg .p00 .sid], snapshot: %w[.vsf]
13
+ }.freeze
14
+ TITLES = { disk: "INSERT A DISK", tape: "INSERT A TAPE", cartridge: "INSERT A CARTRIDGE",
15
+ program: "QUICK OPEN", snapshot: "LOAD A SAVE" }.freeze
16
+
17
+ ESCAPE = 41
18
+ RETURN = 40
19
+ SPACE = 44
20
+
21
+ # The machine Quick open started or a save loaded.
22
+ attr_reader :computer
23
+
24
+ def initialize(painter, buttons, media, options, snapshots)
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
+ @name_field = NameField.new(painter)
32
+ @snapshots = snapshots
33
+ @browsing = nil
34
+ @browse_from = media_path.empty? ? Dir.pwd : File.dirname(File.expand_path(media_path))
35
+ @computer = nil
36
+ end
37
+
38
+ def open? = !@browsing.nil? || @confirmation.open? || @name_field.open?
39
+
40
+ # Opens the browser on the files for `kind`: :disk, :tape, :cartridge,
41
+ # :program, or :snapshot, which starts in the saves folder.
42
+ def browse(kind)
43
+ @browsing = kind
44
+ @browser.open(kind == :snapshot ? Badline.data_folder("saves") : @browse_from, EXTENSIONS.fetch(kind))
45
+ end
46
+
47
+ # Asks for a save's name, starting from `name`.
48
+ def ask_name(name) = @name_field.open("SAVE AS", name)
49
+
50
+ # Loads the save at `path` into a new machine, and returns :swap, or
51
+ # nil when it won't load.
52
+ def load(path)
53
+ return nil unless @snapshots.load(path)
54
+
55
+ @computer = @snapshots.computer
56
+ :swap
57
+ end
58
+
59
+ def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
60
+
61
+ # Puts a dropped disk or tape in its device, or asks before a
62
+ # cartridge or a program starts, and says whether it asks. Other
63
+ # files are left alone.
64
+ def drop(path)
65
+ extension = File.extname(path).downcase
66
+ return @media.insert(:disk, path) && false if EXTENSIONS[:disk].include?(extension)
67
+ return @media.insert(:tape, path) && false if EXTENSIONS[:tape].include?(extension)
68
+ return false unless EXTENSIONS[:program].include?(extension)
69
+
70
+ @confirmation.ask(extension == ".crt" ? :cartridge : :start, path)
71
+ true
72
+ end
73
+
74
+ # Handles a key, and returns :resume or :swap once a question is
75
+ # answered yes, or nil.
76
+ def key(scancode)
77
+ return browsed(@browser.key(scancode)) unless @browsing.nil?
78
+ if @name_field.open? && !@confirmation.open?
79
+ return named(@name_field.key(scancode, SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT)))
80
+ end
81
+ return answer(:cancel) if scancode == ESCAPE
82
+ return answer(@buttons.focus) if [RETURN, SPACE].include?(scancode)
83
+
84
+ direction = PauseMenu::ARROWS[scancode]
85
+ @buttons.shift(direction == :up ? -1 : 1, 1) if %i[up down].include?(direction)
86
+ nil
87
+ end
88
+
89
+ # Handles a click on the browser's list at `top`, or on a question's
90
+ # answer, `action`.
91
+ def click(action, top) = action == :browse ? browsed(@browser.click(top)) : answer(action)
92
+
93
+ def scroll(rows) = @browser.scroll(rows)
94
+
95
+ def draw(left, top, width)
96
+ return @browser.draw(left, top, width, TITLES.fetch(@browsing)) unless @browsing.nil?
97
+ return @confirmation.draw(left, top) if @confirmation.open?
98
+
99
+ @name_field.draw(left, top, width)
100
+ end
101
+
102
+ private
103
+
104
+ # Puts the disk or tape picked in, or asks before a cartridge or
105
+ # Quick open power cycles the machine, and leaves the browser, as Esc
106
+ # does.
107
+ def browsed(picked)
108
+ return nil if picked.nil?
109
+
110
+ kind = @browsing
111
+ @browse_from = @browser.directory unless kind == :snapshot
112
+ @browsing = nil
113
+ return nil if picked == :cancel
114
+ return load(picked) if kind == :snapshot
115
+
116
+ if %i[disk tape].include?(kind)
117
+ @media.insert(kind, picked)
118
+ else
119
+ @confirmation.ask(kind == :cartridge ? :cartridge : :start, picked)
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Saves the machine under the name typed once Return takes it, and
125
+ # asks first when a save has that name already.
126
+ def named(result)
127
+ name = @name_field.text.strip
128
+ if result == :cancel
129
+ @name_field.close
130
+ elsif result == :done && name.empty?
131
+ @name_field.refuse("TYPE A NAME")
132
+ elsif result == :done
133
+ @snapshots.named?(name) ? @confirmation.ask(:overwrite, name) : save_as(name)
134
+ end
135
+ nil
136
+ end
137
+
138
+ def save_as(name, replace: false)
139
+ @name_field.close if @snapshots.save_named(name, replace:)
140
+ end
141
+
142
+ # Does what the question asked about if `action` is :confirm, and
143
+ # returns :resume, or :swap for a new machine.
144
+ def answer(action)
145
+ question = @confirmation.close
146
+ return nil unless action == :confirm
147
+
148
+ path = question[1]
149
+ case question[0]
150
+ when :cartridge then @media.attach_cartridge(path) ? :resume : nil
151
+ when :remove then @media.remove_cartridge && :resume
152
+ when :overwrite then save_as(path, replace: true) && nil
153
+ else
154
+ @computer = @media.start(@options, path)
155
+ @computer.nil? ? nil : :swap
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,157 @@
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: the .m3u or .vfl list it came from, while that lists it, or
9
+ # the set DiskSet finds. It keeps what last went wrong for the page to
10
+ # show.
11
+ class MenuMedia
12
+ attr_reader :disk_path, :problem
13
+ attr_writer :computer
14
+
15
+ # Starts on the disk the command line's `media_path` put in.
16
+ def initialize(media_path, writable)
17
+ @disk_path = PauseMenu.disk_path(media_path)
18
+ @list = Media::DiskList.list?(media_path) ? media_path : ""
19
+ @writable = writable
20
+ @set_path = ""
21
+ @set = []
22
+ @problem = ""
23
+ @computer = nil
24
+ end
25
+
26
+ def writable? = @writable
27
+
28
+ def inserted?
29
+ drive = @computer.drive1541
30
+ drive.nil? ? @computer.mounted? : !drive.disk.nil?
31
+ end
32
+
33
+ # Puts a disk in device 8 or a tape in the datasette, `kind` :disk or
34
+ # :tape. A list puts its first disk in.
35
+ def insert(kind, path)
36
+ @problem = ""
37
+ if kind == :disk
38
+ disk = Media::DiskList.disk(path)
39
+ Media.insert_disk(@computer, disk, read_only: !@writable)
40
+ list(path)
41
+ @disk_path = disk
42
+ else
43
+ @computer.datasette.insert(Storage::TAP.new(path))
44
+ end
45
+ rescue ArgumentError, SystemCallError, Storage::TAP::FormatError, Storage::T64::FormatError,
46
+ Storage::G64Image::FormatError => e
47
+ @problem = e.message
48
+ end
49
+
50
+ # Does what the Drive page's :eject_disk, :previous_disk, :next_disk,
51
+ # :protect or :unprotect asks.
52
+ def drive(action)
53
+ case action
54
+ when :eject_disk then eject_disk
55
+ when :previous_disk then step_disk(-1)
56
+ when :next_disk then step_disk(1)
57
+ else writable(action == :unprotect)
58
+ end
59
+ end
60
+
61
+ def eject_disk
62
+ drive = @computer.drive1541
63
+ drive.nil? ? @computer.unmount : drive.insert(nil)
64
+ @disk_path = ""
65
+ end
66
+
67
+ # Sets whether disks are writable, for the disk in the drive too,
68
+ # which goes in again as the notch now says.
69
+ def writable(writable)
70
+ @writable = writable
71
+ insert(:disk, @disk_path) if inserted? && !@disk_path.empty?
72
+ end
73
+
74
+ # The disks of the set the disk in the drive belongs to.
75
+ def disk_set
76
+ return [] if @disk_path.empty?
77
+
78
+ unless @set_path == @disk_path
79
+ listed = @list.empty? ? [] : Media::DiskList.disks(@list)
80
+ @set = listed.include?(@disk_path) ? listed : Media::DiskSet.around(@disk_path)
81
+ @set_path = @disk_path
82
+ end
83
+ @set
84
+ end
85
+
86
+ # Puts the disk `step` before or after this one in its set in the
87
+ # drive, if there is one.
88
+ def step_disk(step)
89
+ set = disk_set
90
+ index = set.index(@disk_path)
91
+ return if index.nil? || !(index + step).between?(0, set.size - 1)
92
+
93
+ insert(:disk, set[index + step])
94
+ end
95
+
96
+ # Swaps the cartridge for the one at `path`, which power cycles the
97
+ # machine, and says whether it went in.
98
+ def attach_cartridge(path)
99
+ @problem = ""
100
+ Media.attach(@computer, path)
101
+ true
102
+ rescue ArgumentError, SystemCallError, Storage::CRTFile::FormatError, Cartridge::UnsupportedTypeError => e
103
+ @problem = e.message
104
+ false
105
+ end
106
+
107
+ # What a save of the machine is named after: the cartridge, the disk,
108
+ # the tape, or BASIC, without the bracketed parts of a file's name.
109
+ def game_name
110
+ name = cartridge_name
111
+ name = File.basename(@disk_path, File.extname(@disk_path)) if name.empty? && !@disk_path.empty?
112
+ tape = @computer.datasette.tape
113
+ name = File.basename(tape.path, File.extname(tape.path)) if name.empty? && !tape.nil?
114
+ name = name.sub(/\s*[(\[].*\z/, "").strip
115
+ name.empty? ? "BASIC" : name
116
+ end
117
+
118
+ # The name the cartridge in the expansion port gives itself, or an
119
+ # empty one.
120
+ def cartridge_name
121
+ cartridge = @computer.address_bus.cartridge
122
+ cartridge.nil? ? "" : cartridge.name
123
+ end
124
+
125
+ # Takes the cartridge out with the power off.
126
+ def remove_cartridge
127
+ @computer.address_bus.detach_cartridge
128
+ @computer.power_cycle!
129
+ end
130
+
131
+ # A new machine started on `path`, as the command line would, or nil
132
+ # when it won't start.
133
+ def start(options, path)
134
+ @problem = ""
135
+ computer = Frontend.start(options, path, @writable)
136
+ @disk_path = PauseMenu.disk_path(path)
137
+ list(path)
138
+ computer
139
+ rescue ArgumentError, SystemCallError, Media::TrueDrive::Error, Storage::SIDFile::FormatError,
140
+ Storage::T64::FormatError, Storage::TAP::FormatError, Storage::CRTFile::FormatError,
141
+ Storage::G64Image::FormatError, Cartridge::UnsupportedTypeError => e
142
+ @problem = e.message
143
+ nil
144
+ end
145
+
146
+ private
147
+
148
+ # Keeps `path` as the list the set comes from, if it's a list.
149
+ def list(path)
150
+ return unless Media::DiskList.list?(path)
151
+
152
+ @list = path
153
+ @set_path = ""
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,220 @@
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
+ RECENT = %i[recent0 recent1 recent2 recent3 recent4 recent5 recent6 recent7].freeze
18
+
19
+ PORT_DEVICES = %i[joystick mouse paddles].freeze
20
+ PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
21
+ PORT_ACTIONS = [%i[port1_joystick port1_mouse port1_paddles], %i[port2_joystick port2_mouse port2_paddles]].freeze
22
+ POTS = [%i[none mouse1 paddles1], %i[none mouse2 paddles2]].freeze
23
+
24
+ # The disks, tapes and cartridges the pages put in and take out.
25
+ attr_reader :media
26
+
27
+ def initialize(painter, buttons, media_path, options, snapshots)
28
+ @snapshots = snapshots
29
+ @recent = []
30
+ @painter = painter
31
+ @buttons = buttons
32
+ @media = MenuMedia.new(media_path, options.writable?)
33
+ @reu_kb = options.reu
34
+ @computer = nil
35
+ @controls = nil
36
+ @sound = nil
37
+ @left = 0
38
+ @top = 0
39
+ end
40
+
41
+ # The machine the pages show and change.
42
+ def machine(computer, controls, sound)
43
+ @computer = computer
44
+ @media.computer = computer
45
+ @controls = controls
46
+ @sound = sound
47
+ end
48
+
49
+ def draw(page, left, top)
50
+ problem = @media.problem
51
+ @painter.text(left, top + 186, problem[0, COLUMNS], WARNING) unless problem.empty?
52
+ @left = left
53
+ @top = top
54
+ case page
55
+ when :drive then draw_drive
56
+ when :datasette then draw_datasette
57
+ when :expansion then draw_expansion
58
+ when :ports then draw_ports
59
+ when :sound then draw_sound
60
+ when :snapshots then draw_snapshots
61
+ else draw_power
62
+ end
63
+ end
64
+
65
+ # The path of the quicksave or autosave a RECENT action stands for.
66
+ def recent(action) = @recent[RECENT.index(action)].to_s
67
+
68
+ def perform(action)
69
+ case action
70
+ when :eject_disk, :previous_disk, :next_disk, :protect, :unprotect then @media.drive(action)
71
+ when :tape_stop then @computer.datasette.stop!
72
+ when :tape_down then @computer.datasette.play!
73
+ when :tape_rewind then @computer.datasette.rewind
74
+ when :tape_eject then @computer.datasette.eject
75
+ when :keys_c64, :keys_joystick then keys(action == :keys_c64 ? :keyboard : :joystick)
76
+ when :swap then @controls.swap_ports
77
+ when :sound_on, :mute then @sound.toggle_mute unless @sound.muted? == (action == :mute)
78
+ when :sid6581, :sid8580 then @computer.sid.model = action == :sid8580 ? :mos8580 : :mos6581
79
+ when :reset then @computer.reset!
80
+ when :power_cycle then @computer.power_cycle!
81
+ else pick_port(action)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def draw_drive
88
+ media = @media
89
+ inserted = media.inserted?
90
+ set = inserted ? media.disk_set : []
91
+ place = set.size < 2 ? "" : " #{set.index(media.disk_path).to_i + 1} OF #{set.size}"
92
+ info("DISK#{place}", inserted ? File.basename(media.disk_path) : "")
93
+ row("INSERT...", :insert_disk)
94
+ row("EJECT", :eject_disk)
95
+ unless set.size < 2
96
+ row("PREVIOUS DISK", :previous_disk)
97
+ row("NEXT DISK", :next_disk)
98
+ end
99
+ toggle("WRITABLE", :writable, [%w[OFF ON], %i[protect unprotect], media.writable? ? 1 : 0])
100
+ skip
101
+ info("DEVICE", @computer.drive1541.nil? ? "FAST LOADING" : "1541, TRUE DRIVE")
102
+ end
103
+
104
+ def draw_datasette
105
+ datasette = @computer.datasette
106
+ tape = datasette.tape
107
+ info("TAPE", tape.nil? ? "" : File.basename(tape.path))
108
+ row("INSERT...", :insert_tape)
109
+ row("EJECT", :tape_eject)
110
+ toggle("PLAY", :tape_play, [%w[UP DOWN], %i[tape_stop tape_down], datasette.playing? ? 1 : 0])
111
+ row("REWIND", :tape_rewind)
112
+ skip
113
+ info("MOTOR", datasette.motor? ? "ON" : "OFF")
114
+ end
115
+
116
+ def draw_expansion
117
+ cartridge = @computer.address_bus.cartridge
118
+ info("CARTRIDGE", cartridge.nil? ? "" : cartridge.name)
119
+ row("INSERT...", :insert_cartridge)
120
+ row("REMOVE", :remove_cartridge)
121
+ row("FREEZE", :freeze) if !cartridge.nil? && cartridge.freezer?
122
+ return if @reu_kb.nil?
123
+
124
+ skip
125
+ info("RAM EXPANSION", "REU, #{@reu_kb} KB")
126
+ end
127
+
128
+ def draw_ports
129
+ toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
130
+ toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
131
+ joystick = @controls.joystick_mode?
132
+ toggle("KEYS", :keys, [%w[C64 JOYSTICK], %i[keys_c64 keys_joystick], joystick ? 1 : 0])
133
+ return unless joystick
134
+
135
+ row("SWAP JOYSTICKS", :swap)
136
+ skip
137
+ arrows = @controls.arrows_port
138
+ info("ARROWS, SPACE", "PORT #{arrows}")
139
+ info("WASD, LEFT SHIFT", "PORT #{3 - arrows}")
140
+ end
141
+
142
+ def draw_sound
143
+ if @sound.on?
144
+ toggle("OUTPUT", :output, [%w[ON MUTED], %i[sound_on mute], @sound.muted? ? 1 : 0])
145
+ else
146
+ info("OUTPUT", "OFF")
147
+ end
148
+ toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], @computer.sid.model == :mos8580 ? 1 : 0])
149
+ end
150
+
151
+ # Saving now and by name, loading a named save, and the quicksaves
152
+ # and autosaves, newest first, which a press loads.
153
+ def draw_snapshots
154
+ row("QUICKSAVE", :quicksave_now)
155
+ row("SAVE...", :save_as)
156
+ row("LOAD...", :load_save)
157
+ skip
158
+ saves = (@snapshots.list("quicksaves") + @snapshots.list("autosaves")).sort_by { |path| -File.mtime(path).to_f }
159
+ @recent = saves.first(RECENT.size)
160
+ @recent.each_with_index do |path, index|
161
+ kind = File.basename(path).start_with?("autosave") ? "AUTOSAVE" : "QUICKSAVE"
162
+ row(kind, RECENT[index], @snapshots.time_of(path))
163
+ end
164
+ end
165
+
166
+ def draw_power
167
+ row("RESET", :reset)
168
+ row("POWER CYCLE", :power_cycle)
169
+ skip
170
+ row("QUIT", :quit)
171
+ end
172
+
173
+ # A label, and its value under it, or NONE in the dim colour when the
174
+ # value is empty.
175
+ def info(label, value)
176
+ @painter.text(@left, @top, label, DIM)
177
+ if value.empty?
178
+ @painter.text(@left, @top + LINE, "NONE", DIM)
179
+ else
180
+ @painter.text(@left, @top + LINE, Painter.fit(value, COLUMNS), TEXT)
181
+ end
182
+ @top += (LINE * 2) + 6
183
+ end
184
+
185
+ def row(label, action, value = "")
186
+ @buttons.row([@left, @top, ROW_WIDTH], label, action, value)
187
+ @top += Buttons::HEIGHT + 2
188
+ end
189
+
190
+ def toggle(label, action, choices)
191
+ @buttons.toggle([@left, @top, ROW_WIDTH], label, action, choices)
192
+ @top += Buttons::HEIGHT + 2
193
+ end
194
+
195
+ def skip
196
+ @top += LINE
197
+ end
198
+
199
+ def keys(mode) = @controls.joystick_mode = mode == :joystick
200
+
201
+ def port_device(port)
202
+ index = POTS[port - 1].index(@controls.pot)
203
+ index.nil? ? :joystick : PORT_DEVICES[index]
204
+ end
205
+
206
+ # Plugs the device a choice such as :port1_mouse names into its port.
207
+ def pick_port(action)
208
+ index = PORT_ACTIONS[0].include?(action) ? 0 : 1
209
+ choice = PORT_ACTIONS[index].index(action)
210
+ plug_device(index + 1, choice) unless choice.nil? || choice == PORT_DEVICES.index(port_device(index + 1))
211
+ end
212
+
213
+ # Plugs PORT_DEVICES[choice] into the port. A joystick there takes out
214
+ # the pot device.
215
+ def plug_device(port, choice)
216
+ @controls.plug(POTS[port - 1][choice])
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The pause menu's prompt for a name, typed with the keys by their
6
+ # place on a US keyboard: letters, with Shift for capitals, digits,
7
+ # space, - and _ and the full stop. Backspace deletes, Return takes the
8
+ # name and Esc leaves it.
9
+ class NameField
10
+ COLUMNS = 30
11
+ LETTERS = "abcdefghijklmnopqrstuvwxyz"
12
+ DIGITS = "1234567890"
13
+ OTHERS = { 44 => [" ", " "], 45 => ["-", "_"], 55 => [".", "."] }.freeze
14
+
15
+ RETURN = 40
16
+ ESCAPE = 41
17
+ BACKSPACE = 42
18
+
19
+ attr_reader :text
20
+
21
+ def initialize(painter)
22
+ @painter = painter
23
+ @open = false
24
+ @title = ""
25
+ @text = ""
26
+ @problem = ""
27
+ end
28
+
29
+ def open? = @open
30
+
31
+ def open(title, text)
32
+ @open = true
33
+ @title = title
34
+ @text = text
35
+ @problem = ""
36
+ end
37
+
38
+ def close
39
+ @open = false
40
+ end
41
+
42
+ # Says why the name won't do, under the field.
43
+ def refuse(problem)
44
+ @problem = problem
45
+ end
46
+
47
+ # Handles a key, and returns :done for Return, :cancel for Esc, or
48
+ # nil.
49
+ def key(scancode, shift)
50
+ return :done if scancode == RETURN
51
+ return :cancel if scancode == ESCAPE
52
+
53
+ @problem = ""
54
+ if scancode == BACKSPACE
55
+ @text = @text[0, @text.length - 1].to_s
56
+ else
57
+ char = character(scancode, shift)
58
+ @text += char unless char.empty? || @text.length >= COLUMNS
59
+ end
60
+ nil
61
+ end
62
+
63
+ def draw(left, top, width)
64
+ painter = @painter
65
+ painter.text(left, top, @title, PauseMenu::BRIGHT)
66
+ painter.box(left, top + 18, width, Buttons::HEIGHT + 4, PauseMenu::FILL)
67
+ painter.text(left + 4, top + 22, "#{@text}_", PauseMenu::TEXT)
68
+ painter.text(left, top + 42, @problem, MenuPages::WARNING) unless @problem.empty?
69
+ painter.text(left, top + 60, "RETURN: SAVE ESC: CANCEL", PauseMenu::DIM)
70
+ end
71
+
72
+ private
73
+
74
+ def character(scancode, shift)
75
+ if scancode.between?(4, 29)
76
+ letter = LETTERS[scancode - 4]
77
+ shift ? letter.upcase : letter
78
+ elsif scancode.between?(30, 39)
79
+ DIGITS[scancode - 30]
80
+ elsif OTHERS.key?(scancode)
81
+ OTHERS[scancode][shift ? 1 : 0]
82
+ else
83
+ ""
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -95,6 +95,16 @@ module Badline
95
95
 
96
96
  def self.width(string, scale: 1) = string.length * GLYPH * scale
97
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
+
98
108
  def icon(name, left, top, rgb, scale: 1)
99
109
  tint(rgb)
100
110
  place(top, GLYPH * scale)