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
@@ -20,7 +20,8 @@ module Badline
20
20
  computer = boot(options)
21
21
  rescue Media::TrueDrive::Error, Storage::SIDFile::FormatError, Storage::T64::FormatError,
22
22
  Storage::TAP::FormatError, Storage::CRTFile::FormatError, Storage::G64Image::FormatError,
23
- Cartridge::UnsupportedTypeError, Snapshot::FormatError => e
23
+ Cartridge::UnsupportedTypeError, Snapshot::FormatError, Media::DiskList::Error,
24
+ Datasette::Missing => e
24
25
  warn "#{options.program}: #{options.media_path}: #{e.message}"
25
26
  return 1
26
27
  end
@@ -39,12 +40,23 @@ module Badline
39
40
  return computer
40
41
  end
41
42
 
42
- computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
43
- region: options.ntsc? ? Region::NTSC : Region::PAL)
43
+ start(options, media, options.writable?)
44
+ end
45
+
46
+ # A new machine built from the options, with `media`, if not nil,
47
+ # attached and started as the command line does, its disk writable if
48
+ # `writable`. The model gives the chips, and a .sid tune or --sid the
49
+ # SID.
50
+ def self.start(options, media, writable)
51
+ model = Model.named(options.model)
52
+ computer = Computer.new(vic_model: model.vic_model, cia_model: model.cia_model,
53
+ sid_model: options.sid_model || Media.sid_model(media, otherwise: model.sid_model),
54
+ region: model.region, reu: options.reu, kernal: model.kernal,
55
+ datasette: model.datasette)
44
56
  Media::TrueDrive.plug(computer) if options.true_drive?
45
57
  unless media.nil?
46
58
  puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
47
- disk: { read_only: options.read_only? })
59
+ disk: { read_only: !writable })
48
60
  end
49
61
  computer
50
62
  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] &&
@@ -0,0 +1,56 @@
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
+ # or replaces a file: a cartridge going in or out, Quick open starting a
7
+ # file, or a save overwriting one of the same name. CANCEL has the
8
+ # focus, so Return alone changes nothing.
9
+ class Confirmation
10
+ TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN",
11
+ overwrite: "SAVE AS" }.freeze
12
+ LOST = "ANYTHING NOT SAVED IS LOST."
13
+ WARNINGS = { cartridge: ["INSERTING IT POWER CYCLES THE MACHINE.", LOST],
14
+ remove: ["REMOVING IT POWER CYCLES THE MACHINE.", LOST],
15
+ start: ["THIS STARTS A NEW MACHINE.", LOST],
16
+ overwrite: ["OVERWRITE EXISTING SAVE?", ""] }.freeze
17
+ ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START", overwrite: "OVERWRITE" }.freeze
18
+
19
+ def initialize(painter, buttons)
20
+ @painter = painter
21
+ @buttons = buttons
22
+ @kind = :start
23
+ @path = ""
24
+ @open = false
25
+ end
26
+
27
+ def open? = @open
28
+
29
+ # Asks about `kind`, :cartridge, :remove, :start or :overwrite, for the
30
+ # file at `path`, or the save named so.
31
+ def ask(kind, path)
32
+ @kind = kind
33
+ @path = path
34
+ @open = true
35
+ @buttons.focus = :cancel
36
+ end
37
+
38
+ # Puts the question away, and returns its kind and path.
39
+ def close
40
+ @open = false
41
+ [@kind, @path]
42
+ end
43
+
44
+ def draw(left, top)
45
+ painter = @painter
46
+ painter.text(left, top, TITLES.fetch(@kind), PauseMenu::BRIGHT)
47
+ painter.text(left, top + 20, Painter.fit(File.basename(@path), 40), PauseMenu::TEXT) unless @path.empty?
48
+ warning = WARNINGS.fetch(@kind)
49
+ painter.text(left, top + 44, warning[0], PauseMenu::TEXT)
50
+ painter.text(left, top + 56, warning[1], PauseMenu::TEXT) unless warning[1].empty?
51
+ @buttons.row([left, top + 80, MenuPages::ROW_WIDTH], "CANCEL", :cancel)
52
+ @buttons.row([left, top + 80 + Buttons::HEIGHT + 2, MenuPages::ROW_WIDTH], ANSWERS.fetch(@kind), :confirm)
53
+ end
54
+ end
55
+ end
56
+ 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,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