badline 0.7.0 → 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.
data/doc/media.md ADDED
@@ -0,0 +1,69 @@
1
+ # Media
2
+
3
+ The [README](../README.md#media) lists the formats badline takes. This
4
+ page covers how device 8 and the true 1541 work, and how to attach media
5
+ from Ruby.
6
+
7
+ ## Device 8 without a drive
8
+
9
+ Apart from a `.g64`, which only a true drive can read, there is no 1541
10
+ unless `--true-drive` asks for one. Device 8 works by trapping the
11
+ KERNAL's `LOAD` and `SAVE` routines and its serial bus primitives, so
12
+ files open by name through `OPEN` and `CHRIN` as well. `LOAD"$",8` lists
13
+ the directory of any medium mounted there, as a 1541 does.
14
+
15
+ The command channel answers `I`, `B-P` and `U1` block reads, which
16
+ covers loaders that read blocks directly. On a writable disk image it
17
+ also takes `SAVE` (with `@0:` to replace a file), files opened for
18
+ writing or appending, `S` to scratch, `U2` and `B-W` block writes, and
19
+ `B-A` and `B-F`. It doesn't rename, copy, format or validate disks.
20
+ Loaders that upload their own code to the drive with `M-W` and `M-E`,
21
+ and copy protection that reads raw GCR, won't work there, but do on a
22
+ true drive.
23
+
24
+ A directory mounted as device 8 serves the `.prg` and `.p00` files in it
25
+ and the contents of any `.t64`, and `SAVE` writes a new `.prg`.
26
+
27
+ ## The true 1541
28
+
29
+ `--true-drive` puts an emulated 1541 on device 8 instead, running its
30
+ own DOS ROM on its own 6502 and talking to the machine over the serial
31
+ bus. It reads `.d64` and `.g64` images, which it autostarts with the
32
+ same `LOAD"*",8,1` and `RUN`, but not `.d71`, `.d81` or `.t64` images
33
+ or directories. Loading runs at the speed of a real 1541, and the
34
+ drive's red LED lights in the bottom right corner of the border.
35
+
36
+ A `.g64` holds the disk's raw GCR, half tracks and all, so copy
37
+ protection and fast loaders that read it work.
38
+
39
+ ## Write protection
40
+
41
+ Disks given on the command line, inserted from the pause menu or by an
42
+ `--at` insert go in write-protected, so the drive reports
43
+ `26,WRITE PROTECT ON` for any write and the image file stays as it was.
44
+ `--writable`, or WRITABLE on the pause menu's Drive page, lets writes go
45
+ straight back to the image file. An image the host can't write, and a
46
+ `.t64`, stay write-protected.
47
+
48
+ ## From Ruby
49
+
50
+ `Badline::Media.attach(computer, path)` attaches any medium the
51
+ command line takes, and autostarts it unless given `autostart: false`.
52
+ `Badline::Media.insert_disk(computer, path)` swaps the disk image or
53
+ directory in device 8 while the machine runs, for software that asks for
54
+ another disk. Both write to disk images unless told otherwise:
55
+ `insert_disk` takes `read_only: true`, and `attach` takes
56
+ `disk: { read_only: true }`, as the test harnesses under `bin/` do. A
57
+ `.g64` goes in the true 1541, and takes out a disk mounted through the
58
+ traps, so `LOAD` and `SAVE` reach the 1541 too. With the 1541 in device
59
+ 8, a `.d64` goes in its drive as well.
60
+
61
+ `Badline::Media::DiskSet.around(path)` lists the disks of the set a disk
62
+ image belongs to, found by name in its folder, which the pause menu's
63
+ PREVIOUS DISK and NEXT DISK step through.
64
+
65
+ ## The light pen
66
+
67
+ Control port 1's fire line is also the VIC-II's light pen input, so
68
+ joystick 1's fire button and the 1351's left button in port 1 latch the
69
+ light pen registers.
data/doc/sid-player.md ADDED
@@ -0,0 +1,132 @@
1
+ # The SID player
2
+
3
+ The [README](../README.md#playing-and-rendering-sid-tunes) shows how to
4
+ start it. This page covers the player in detail.
5
+
6
+ ```sh
7
+ badline sid ~/C64Music/MUSICIANS/H/Hubbard_Rob # play every tune below a directory
8
+ badline sid tune.sid other.sid # play a queue of tunes
9
+ badline --headless tune.sid # play, length from HVSC
10
+ badline --headless -s 3 tune.sid # play the third subtune
11
+ badline --headless --all-subtunes tune.sid # play on through every subtune
12
+ badline --seconds 180 tune.sid --audio-out out.aiff
13
+ badline -s 3 --rate 48000 tune.sid --audio-out out.wav
14
+ badline --headless --sid 8580 tune.sid
15
+ badline --filter-chunk 1 tune.sid --audio-out out.wav # exact filter, slower
16
+ ```
17
+
18
+ ## The queue
19
+
20
+ `badline sid FILE|DIR...` plays a queue of tunes in the SID player's
21
+ window, in the order given, and a directory adds every `.sid` tune below
22
+ it in path order. `badline sid --headless` plays the queue in the
23
+ terminal instead. `badline sid` on its own opens the window with an
24
+ empty queue. Dropping `.sid` files or folders on the window adds them
25
+ to the end of the queue, and an empty queue starts playing them. A file
26
+ that isn't a tune is skipped.
27
+
28
+ `badline tune.sid` plays the tune in the SID player too, unless an
29
+ option of the emulator's window, such as `--ntsc` or `--reu`, asks for
30
+ the machine: then a tune for one SID runs on the emulated C64, started
31
+ through a small driver after boot.
32
+
33
+ `--subtune` (or `-s`) picks the first tune's subtune, counting from 1 as
34
+ HVSC does, and defaults to the tune's own start subtune. Each tune plays
35
+ that one subtune, and when it ends the player goes on to the next tune,
36
+ stopping after the last. `a`, or `--all-subtunes`, turns on playing all
37
+ subtunes, so that a subtune that ends goes on to the tune's next
38
+ subtune, and a tune stepped to starts on its first subtune.
39
+
40
+ `--sid auto`, the default, fits each of a tune's SIDs the model its
41
+ header names. A tune written for 2 or 3 SIDs plays on as many, at the
42
+ addresses its header gives, in stereo: SID 1 on the left, SID 2 on the
43
+ right and SID 3 in the centre. A tune on one SID plays the same on both
44
+ channels.
45
+
46
+ Playback asks the device for 44.1 kHz and takes whatever rate it
47
+ offers, unless `--rate` says otherwise. Ctrl-C stops it. The emulator
48
+ window's own options, `--no-autostart`, `--writable`, `--sound`,
49
+ `--true-drive`, `--reu`, `--ntsc` and `--verbose`, don't apply.
50
+
51
+ ## The window
52
+
53
+ The window is worked with the mouse. Its header shows the tune's name,
54
+ author and release, the tune the subtune covers at the moment when
55
+ HVSC's STIL credits one, following the times STIL gives, and buttons
56
+ that switch between three views and between the tune's own SID model,
57
+ the 6581 and the 8580, which changes the chips playing on the spot. Its
58
+ footer shows the time played on a bar you can click to seek, and buttons
59
+ that pause, step between tunes and between a tune's subtunes, and turn
60
+ shuffle, looping and all subtunes on and off.
61
+
62
+ - The visualizer shows each voice's note, and how far off it is in
63
+ cents, over a scope of the voice's output, and the mixed output below
64
+ them. A tune on more than one SID gets a row of voices for each SID,
65
+ and the mix splits into its left and right.
66
+ - The SID view shows one SID at a time, and for a tune on more than one,
67
+ a row of SID 1, 2 and 3 buttons at its top picks which. It shows each
68
+ voice's output, its control bits, pulse width, envelope settings, and
69
+ the envelope's level and stage, and the filter's modes, cutoff,
70
+ resonance, volume and its response on the chip playing.
71
+ - The INFO view shows the tune's STIL entry and the subtune's, with a
72
+ scroll bar, the mouse wheel or the up and down keys for the long ones.
73
+
74
+ The terminal's keys work in the window too, along with Tab to switch
75
+ views and `c` to step through the SID models.
76
+
77
+ ## The terminal
78
+
79
+ Played on a terminal, `--headless` and `sid` show each tune's name,
80
+ author and release as it starts, then the tune's place in the queue, the
81
+ subtune number and the time played against the subtune's length.
82
+ `--headless` queues just the one tune.
83
+
84
+ → and ← step to the tune's next and previous subtune, stopping at its
85
+ first and last. `n` and `p` step to the next and previous tune. `s`
86
+ turns shuffle on and off, which plays the queue's tunes in a random
87
+ order, and `l` turns looping on and off, so that the end of the queue
88
+ goes on to its start. `,` and `.` seek 10 seconds back and forward
89
+ within the subtune: a seek forward runs on silently from where the
90
+ subtune is, and a seek back plays it again from its start up to the
91
+ point asked for. Space pauses and `q` quits. The status line shows which
92
+ modes are on, and they last until the player quits.
93
+
94
+ `--no-tui`, or output that isn't a terminal, gives plain progress output
95
+ instead and plays through the queue without the keys.
96
+
97
+ ## Rendering
98
+
99
+ `--audio-out` renders one subtune to a 16-bit stereo PCM file. The
100
+ file's extension picks the format, `.wav` or `.aiff`.
101
+
102
+ ## Lengths and STIL
103
+
104
+ A `.sid` file doesn't store its length, so the player looks the tune up
105
+ by MD5 in HVSC's `Songlengths.md5`. It finds the database through
106
+ `--songlengths`, in a `DOCUMENTS` directory in any of the tune's parent
107
+ directories (the layout of an HVSC collection), or under
108
+ `$HVSC_BASE/DOCUMENTS`. Without a database or `--seconds` it runs for 60
109
+ seconds, but a subtune that falls silent for 5 seconds before then ends
110
+ there, when played and when rendered alike. Silent means the output
111
+ holds within 16 steps of one level, since a 6581 idles at a DC offset
112
+ rather than at zero. A subtune with a known length plays to its length
113
+ whatever it sounds like.
114
+
115
+ A tune in an HVSC collection also gets its entry in HVSC's `STIL.txt`,
116
+ found the same way: comments, covers, and subtune names and composers.
117
+ The tune's own fields follow its header, and each subtune's print as it
118
+ starts.
119
+
120
+ ## Speed
121
+
122
+ `badline` plays PSID and RSID tunes in real time. In `badline-ruby`,
123
+ PSID tunes run on a CPU and RAM with only the SID clocked, at about
124
+ twice real time, so they play smoothly, but RSID tunes set up their own
125
+ interrupts, so they boot a full C64 first and run at about half real
126
+ time. They render fine but stutter when played, and `badline-ruby` says
127
+ so when it falls behind. Both builds run the same code, so they render
128
+ the same file sample for sample. See
129
+ [native/README.md](../native/README.md#without-the-window).
130
+
131
+ The filter steps four cycles at a time; `--filter-chunk 1` steps it
132
+ every cycle, which is exact and takes about twice as long.
data/doc/snapshots.md ADDED
@@ -0,0 +1,69 @@
1
+ # Snapshots
2
+
3
+ In the window, `F11` saves the whole machine to a new
4
+ `badline-<date>-<time>.vsf` in the working directory, and `F12` goes
5
+ back to the snapshot last saved or opened, in a new machine built as the
6
+ saved one was. Both executables do this, and both open a `.vsf` given as
7
+ the media. A snapshot that fails to open leaves the machine running as
8
+ it was.
9
+
10
+ ## From Ruby
11
+
12
+ `computer.save_snapshot(path)` saves, `computer.restore_snapshot(path)`
13
+ takes a machine back to a snapshot, and `Badline::Snapshot.load(path)`
14
+ builds a new machine as the saved one was built and restores it.
15
+ `computer.snapshot` and `computer.restore(state)` do the same in memory,
16
+ without a file. A restore that fails leaves the machine as it was.
17
+
18
+ ## What a snapshot holds
19
+
20
+ A snapshot holds the machine as it was on the cycle it was saved: the
21
+ chips down to the instruction step and the pixel pipeline, the RAM and
22
+ any +60K or +256K expansion, the cartridge with its RAM and flash, a
23
+ GEO-RAM, a true 1541 with its RAM, its VIAs and the disk under the head,
24
+ a disk or directory mounted through the traps with its open channels,
25
+ and the tape with its place on it. A restored machine runs on exactly
26
+ as the saved one would have.
27
+
28
+ A directory, a tape and a true drive's disk image open again from their
29
+ paths when the snapshot is restored, the disk with its tracks as the
30
+ drive last saw them. A disk image mounted through the traps comes back
31
+ with its contents from the snapshot. What the host holds stays the
32
+ host's: the keyboard, the joysticks, the mouse and paddles, sound, and
33
+ blocks given to `on_init` that hadn't run yet, which a restore reports.
34
+
35
+ A snapshot only restores in the badline version that wrote it, into a
36
+ machine with the same chip models, RAM expansion and REU.
37
+ `computer.snapshot` holds an REU's RAM, registers and transfer too, but
38
+ a machine with an REU doesn't save to a file yet, as badline doesn't
39
+ write VICE's REU module. Nor does an NTSC machine, as badline doesn't
40
+ write an NTSC VIC-II in VICE's terms yet.
41
+
42
+ ## VICE
43
+
44
+ Snapshots use VICE's `.vsf` format. badline writes VICE's modules for
45
+ the CPU, RAM and CPU port, both CIAs, the SID and the VIC-II, plus the
46
+ ones x64sc needs to open the file, with nothing attached to the
47
+ cartridge, tape or user ports and no true drive. A `BADLINE` module,
48
+ which VICE skips, holds the whole machine, and badline restores its own
49
+ snapshots from it.
50
+
51
+ x64sc 3.10 opens badline's snapshots, taken at the end of the
52
+ instruction the CPU was in, as long as its VIC-II model matches
53
+ (`-model c64` for the default machine). It keeps its own drives and
54
+ leaves out the cartridge, the expansions and the tape.
55
+
56
+ Snapshots x64sc saves open in badline through the same modules: the CPU
57
+ at its instruction boundary, RAM, the CPU port, the CIAs' registers,
58
+ timers and clocks, the SID's registers and reSID voice state, and the
59
+ VIC-II's registers, beam position, counters and colour RAM. The VIC-II's
60
+ pixel pipeline starts empty. badline reads the modules x64sc 3.7 to 3.10
61
+ write, and VICE's development versions' `MAINC64CPU` and `VIC-IISC`. A
62
+ module version it doesn't know is left out, or fails the restore for the
63
+ CPU and RAM. It reports the modules it leaves out, such as the 1541
64
+ drives, the cartridge, the datasette and the keyboard, and carries on
65
+ without them. A snapshot of an NTSC machine fails, as badline reads only
66
+ VICE's PAL VIC-II. Restored into a running machine, a VICE snapshot
67
+ fails for a machine built another way, such as a C64C's snapshot in a
68
+ C64, and otherwise takes the cartridge out and switches the machine off
69
+ and on.
@@ -8,6 +8,8 @@ module Badline
8
8
  # cartridge ROM in Ultimax mode. The cartridge lets go of NMI when its
9
9
  # software acknowledges the freeze.
10
10
  module Freezer
11
+ def freezer? = true
12
+
11
13
  def press_button
12
14
  @button = true
13
15
  self.nmi = true if freeze_allowed?
@@ -179,6 +179,9 @@ module Badline
179
179
  # The RES line on the expansion port.
180
180
  def reset; end
181
181
 
182
+ # Whether the cartridge has a freeze button.
183
+ def freezer? = false
184
+
182
185
  # The freeze button, which cartridges without one ignore.
183
186
  def press_button; end
184
187
 
@@ -7,8 +7,8 @@ module Badline
7
7
  # changed lines, present and wait. Pacer decides the cycles and the wait.
8
8
  class App
9
9
  SCALE = 2
10
+ DROPFILE = 0x1000
10
11
  TITLE = "Badline"
11
- STAGES = %w[events emulate audio blit present wait].freeze
12
12
 
13
13
  # Takes the frame limit, the pacing, the snapshot path, the sound and
14
14
  # the verbosity from Options, and runs the timeline's events.
@@ -22,11 +22,11 @@ module Badline
22
22
  @screen = Screen.new(computer.vic)
23
23
  @led = DriveLed.for(computer)
24
24
  @controls = Controls.new(computer)
25
- @spent = Array.new(STAGES.size, 0.0)
26
- @slowest = 0.0
27
25
  open_window
28
26
  @sound = Sound.new(computer.sid, options.sound?, @verbose)
29
27
  @gamepads = Gamepads.new(computer, @verbose)
28
+ @frame_report = FrameReport.new(@sound)
29
+ @menu = PauseMenu.new(Painter.new(@renderer), options)
30
30
  end
31
31
 
32
32
  def run
@@ -34,8 +34,7 @@ module Badline
34
34
  @running = true
35
35
  @started = @reported = now
36
36
  @pacer.start(@started)
37
- @reported_samples = 0
38
- frame while @running
37
+ @menu.open? ? paused_frame : frame while @running
39
38
  @snapshots.finish
40
39
  @computer.drive1541&.flush
41
40
  @gamepads.close
@@ -64,9 +63,7 @@ module Badline
64
63
  end
65
64
 
66
65
  def finish_frame(stamps)
67
- STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
68
- took = stamps[-2] - stamps.first
69
- @slowest = took if took > @slowest
66
+ @frame_report.add(stamps) if @verbose
70
67
  @frames += 1
71
68
  @timeline.run(@computer, @frames)
72
69
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
@@ -95,8 +92,6 @@ module Badline
95
92
  @texture = SDL.SDL_CreateTexture(
96
93
  @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
97
94
  )
98
- SDL.rect_w(SDL.rect, Screen::WIDTH)
99
- SDL.rect_h(SDL.rect, Screen::HEIGHT)
100
95
  end
101
96
 
102
97
  def close_window
@@ -107,7 +102,7 @@ module Badline
107
102
  end
108
103
 
109
104
  def handle_events
110
- handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
105
+ handle_event(SDL.event_type(SDL.event)) while !@menu.open? && SDL.SDL_PollEvent(SDL.event) != 0
111
106
  end
112
107
 
113
108
  def handle_event(type)
@@ -122,6 +117,7 @@ module Badline
122
117
  @controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
123
118
  when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
124
119
  @gamepads.rescan
120
+ when DROPFILE then resume(false) unless @menu.drop(@computer, @controls, @sound)
125
121
  end
126
122
  end
127
123
 
@@ -145,20 +141,45 @@ module Badline
145
141
  @controls.computer = computer
146
142
  @gamepads.computer = computer
147
143
  @sound.sid = computer.sid
144
+ @snapshots.computer = computer
148
145
  end
149
146
 
150
147
  def handle_toggle(scancode)
151
148
  if scancode == Keys::TAB
152
- @controls.cycle_mode(SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT) ? -1 : 1)
153
- SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
149
+ @controls.toggle_keys
154
150
  elsif scancode == Keys::F9
155
- @controls.swap_ports
151
+ return open_menu
156
152
  else
157
153
  @sound.toggle_mute
158
154
  end
159
155
  update_title
160
156
  end
161
157
 
158
+ def open_menu = @menu.show(@computer, @controls, @sound)
159
+
160
+ # Closes the menu and runs the machine on from where it stood,
161
+ # pressing the cartridge's freeze button if asked.
162
+ def resume(freeze)
163
+ @menu.close
164
+ SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
165
+ @timeline.press_freeze(@computer, @frames) if freeze
166
+ @reported = now
167
+ @pacer.start(@reported)
168
+ update_title
169
+ end
170
+
171
+ # Runs a frame of the menu, which has the keys and the mouse while the
172
+ # machine stands still.
173
+ def paused_frame
174
+ action = @menu.frame(@renderer, @texture, @controls)
175
+ if action == :quit
176
+ @running = false
177
+ elsif !action.nil?
178
+ swap(@menu.computer) if action == :swap
179
+ resume(action == :freeze)
180
+ end
181
+ end
182
+
162
183
  def update_title
163
184
  title = TITLE
164
185
  title += " [#{@controls.tag}]" unless @controls.tag.empty?
@@ -183,12 +204,12 @@ module Badline
183
204
 
184
205
  def upload
185
206
  @screen.update
186
- SDL.SDL_UpdateTexture(@texture, SDL.rect, @screen.pixels, Screen::ROW_BYTES)
207
+ SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
187
208
  end
188
209
 
189
210
  def draw
190
211
  SDL.SDL_RenderClear(@renderer)
191
- SDL.SDL_RenderCopy(@renderer, @texture, SDL.rect, SDL.rect)
212
+ SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
192
213
  @led&.draw(@renderer)
193
214
  @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
194
215
  SDL.SDL_RenderPresent(@renderer)
@@ -197,28 +218,10 @@ module Badline
197
218
  def report(at)
198
219
  @pacer.check(50, at - @reported, at)
199
220
  @pacer.measure(50, at - @reported)
200
- report_frames(at) if @verbose
201
- @spent = Array.new(STAGES.size, 0.0)
202
- @slowest = 0.0
221
+ @frame_report.show(at - @reported) if @verbose
203
222
  @reported = at
204
223
  end
205
224
 
206
- def report_frames(at)
207
- fps = 50 / (at - @reported)
208
- stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
209
- puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
210
- report_sound(at) if @sound.on?
211
- end
212
-
213
- def report_sound(at)
214
- sound = @sound
215
- rate = (sound.queued - @reported_samples) / (at - @reported)
216
- queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
217
- puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
218
- sound.reset_levels
219
- @reported_samples = sound.queued
220
- end
221
-
222
225
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
226
  end
224
227
  end
@@ -39,12 +39,19 @@ module Badline
39
39
  return computer
40
40
  end
41
41
 
42
+ start(options, media, options.writable?)
43
+ end
44
+
45
+ # A new machine built from the options, with `media`, if not nil,
46
+ # attached and started as the command line does, its disk writable if
47
+ # `writable`.
48
+ def self.start(options, media, writable)
42
49
  computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
43
50
  region: options.ntsc? ? Region::NTSC : Region::PAL)
44
51
  Media::TrueDrive.plug(computer) if options.true_drive?
45
52
  unless media.nil?
46
53
  puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
47
- disk: { read_only: options.read_only? })
54
+ disk: { read_only: !writable })
48
55
  end
49
56
  computer
50
57
  end
@@ -5,27 +5,43 @@ module Badline
5
5
  # The SID player's buttons: each is drawn where it goes on every frame,
6
6
  # and remembers its place, so a click finds the action under it. The
7
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.
8
11
  class Buttons
9
12
  PAD = 2
10
13
  HEIGHT = Painter::GLYPH + (PAD * 2)
11
14
 
12
- def initialize(painter)
15
+ def initialize(painter, colors = [SIDView::TEXT, SIDView::BRIGHT, PlayerScreen::BACKGROUND])
13
16
  @painter = painter
17
+ @text = colors[0]
18
+ @bright = colors[1]
19
+ @back = colors[2]
20
+ @fill = colors.size > 3 ? colors[3] : -1
14
21
  @lefts = []
15
22
  @tops = []
16
23
  @widths = []
17
24
  @heights = []
18
25
  @actions = []
26
+ @focusable = []
27
+ @toggles = {}
19
28
  @pointer_x = -1
20
29
  @pointer_y = -1
30
+ @focus = nil
21
31
  end
22
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
+
23
37
  def forget
24
38
  @lefts.clear
25
39
  @tops.clear
26
40
  @widths.clear
27
41
  @heights.clear
28
42
  @actions.clear
43
+ @focusable.clear
44
+ @toggles.clear
29
45
  end
30
46
 
31
47
  def point(left, top)
@@ -38,11 +54,54 @@ module Badline
38
54
  def text(left, top, label, action, on: false)
39
55
  width = Painter.width(label) + (PAD * 2)
40
56
  place(left, top, width, HEIGHT, action)
41
- @painter.box(left, top, width, HEIGHT, SIDView::TEXT) if on
42
- @painter.text(left + PAD, top + PAD, label, on ? PlayerScreen::BACKGROUND : color(@actions.size - 1))
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))
43
60
  width
44
61
  end
45
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
+
46
105
  # Draws an icon button `scale` times the size of a glyph, and returns
47
106
  # its width.
48
107
  def icon(left, top, name, action, scale: 1)
@@ -58,17 +117,42 @@ module Badline
58
117
  place(box[0], box[1], box[2], box[3], action)
59
118
  end
60
119
 
61
- # The action of the button at `left`, `top`, or nil.
120
+ # The action of the button at `left`, `top`, or nil: a toggle's
121
+ # choice before the row it sits on.
62
122
  def action_at(left, top)
123
+ found = nil
63
124
  index = 0
64
125
  while index < @actions.size
65
- return @actions[index] if inside?(index, left, top)
126
+ if inside?(index, left, top)
127
+ return @actions[index] unless @focusable[index]
66
128
 
129
+ found ||= @actions[index]
130
+ end
67
131
  index += 1
68
132
  end
69
- nil
133
+ found
70
134
  end
71
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
+
72
156
  private
73
157
 
74
158
  def place(left, top, width, height, action)
@@ -77,9 +161,19 @@ module Badline
77
161
  @widths << width
78
162
  @heights << height
79
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))
80
174
  end
81
175
 
82
- def color(index) = inside?(index, @pointer_x, @pointer_y) ? SIDView::BRIGHT : SIDView::TEXT
176
+ def color(index) = inside?(index, @pointer_x, @pointer_y) || @actions[index] == @focus ? @bright : @text
83
177
 
84
178
  def inside?(index, left, top)
85
179
  left >= @lefts[index] && left < @lefts[index] + @widths[index] &&