Module: Ruby2D::CLI::Browser
- Defined in:
- lib/ruby2d/cli/browser.rb
Overview
Generic interactive arrow-key TUI for browsing a list of items. Used by
both ruby2d examples and ruby2d usage. Items are hashes with at least
:title (shown in the list) and :description (shown beneath the list).
Defined Under Namespace
Modules: WinConsole
Constant Summary collapse
- RED =
"\e[38;2;246;60;56m"- RESET =
"\e[0m"- BOLD =
"\e[1m"- DIM =
"\e[2m"- WINDOWS =
Native-Windows Ruby (RubyInstaller) needs its own input handling: on some builds
IO.console.rawdoesn't drop line/echo input, and even in raw mode the console won't emit arrow keys as bytes unless virtual-terminal input is on.WinConsolesets both directly;read_keythen parses the\e[A-style sequences that result. POSIX terminals are untouched. Gem.win_platform?
Class Method Summary collapse
-
.classify_char(c) ⇒ Object
Single-byte keys, identical on every platform.
- .classify_escape(seq) ⇒ Object
-
.classify_scancode(c) ⇒ Object
Legacy (non-VT) Windows consoles report arrows and navigation keys as two bytes: a
\x00or\xE0prefix, consumed byread_console, then the scan code mapped here. -
.enter_alt_screen ⇒ Object
Enabling mouse-event reporting (1000 + 1006/SGR) stops most terminals from translating the scroll wheel into arrow keys while the alt screen is active.
- .leave_alt_screen ⇒ Object
-
.read_console(io) ⇒ Object
Classify one keypress from an already-raw stream.
-
.read_escape_sequence(io) ⇒ Object
Read one CSI/SS3 escape sequence after the leading ESC.
-
.read_escape_windows(io) ⇒ Object
Read a CSI/SS3 escape sequence on Windows without
IO.selectorread_nonblock(unsupported on a console handle). -
.read_key(managed = false) ⇒ Object
Block for one keypress and map it to an action.
- .render(list, selected, label, footer_action) ⇒ Object
-
.run(list:, label:, footer_action:, fallback: nil, &on_select) ⇒ Object
Run the interactive browser.
- .wrap(text, width) ⇒ Object
Class Method Details
.classify_char(c) ⇒ Object
Single-byte keys, identical on every platform.
178 179 180 181 182 183 184 185 186 187 |
# File 'lib/ruby2d/cli/browser.rb', line 178 def self.classify_char(c) case c when "\r", "\n" then :enter when 'q', 'Q', "\x03", "\x04" then :quit when 'k' then :up when 'j' then :down when 'g' then :home when 'G' then :last end end |
.classify_escape(seq) ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/ruby2d/cli/browser.rb', line 239 def self.classify_escape(seq) return nil if seq.start_with?('[<') && seq.end_with?('M', 'm') # SGR mouse return nil if seq.start_with?('[M') # X10 mouse case seq when '' then :quit # bare ESC when '[A', 'OA' then :up when '[B', 'OB' then :down when '[H', '[1~', '[7~', 'OH' then :home when '[F', '[4~', '[8~', 'OF' then :last when '[5~' then :pgup when '[6~' then :pgdn end end |
.classify_scancode(c) ⇒ Object
Legacy (non-VT) Windows consoles report arrows and navigation keys as two
bytes: a \x00 or \xE0 prefix, consumed by read_console, then the
scan code mapped here. With VIRTUAL_TERMINAL_INPUT on they arrive as
\e[A sequences instead, but this keeps the fallback working too.
(Left/Right are unused and fall through.)
194 195 196 197 198 199 200 201 202 203 |
# File 'lib/ruby2d/cli/browser.rb', line 194 def self.classify_scancode(c) case c when 'H' then :up # 0x48 when 'P' then :down # 0x50 when 'I' then :pgup # 0x49 when 'Q' then :pgdn # 0x51 when 'G' then :home # 0x47 when 'O' then :last # 0x4F (End) end end |
.enter_alt_screen ⇒ Object
Enabling mouse-event reporting (1000 + 1006/SGR) stops most terminals
from translating the scroll wheel into arrow keys while the alt screen
is active. Mouse sequences are ignored in read_key.
142 143 144 |
# File 'lib/ruby2d/cli/browser.rb', line 142 def self.enter_alt_screen print "\e[?1049h\e[?25l\e[?1000h\e[?1006h" end |
.leave_alt_screen ⇒ Object
146 147 148 |
# File 'lib/ruby2d/cli/browser.rb', line 146 def self.leave_alt_screen print "\e[?1006l\e[?1000l\e[?1049l\e[?25h" end |
.read_console(io) ⇒ Object
Classify one keypress from an already-raw stream. Windows and POSIX share
the single-byte keys and diverge only on how escape sequences are read:
POSIX's read_escape_sequence uses IO.select/read_nonblock (fine on
a pty), which native-Windows Ruby can't use on a console handle.
166 167 168 169 170 171 172 173 174 175 |
# File 'lib/ruby2d/cli/browser.rb', line 166 def self.read_console(io) c = io.getc if c.nil? then :quit elsif WINDOWS && c == "\n" then nil # LF half of CR+LF; the CR already fired :enter elsif (action = classify_char(c)) then action elsif WINDOWS && (c == "\x00" || c == "\xE0") then classify_scancode(io.getc) elsif c == "\e" classify_escape(WINDOWS ? read_escape_windows(io) : read_escape_sequence(io)) end end |
.read_escape_sequence(io) ⇒ Object
Read one CSI/SS3 escape sequence after the leading ESC. Waits briefly for each byte so chunked delivery doesn't cut us short, and stops at the first byte in the CSI final-byte range (0x40-0x7E).
227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/ruby2d/cli/browser.rb', line 227 def self.read_escape_sequence(io) seq = '' 32.times do break unless IO.select([io], nil, nil, 0.01) c = io.read_nonblock(1, exception: false) break if c.nil? || c == :wait_readable seq << c break if seq.length >= 2 && c.match?(/[A-Za-z~]/) end seq end |
.read_escape_windows(io) ⇒ Object
Read a CSI/SS3 escape sequence on Windows without IO.select or
read_nonblock (unsupported on a console handle). An arrow key's whole
sequence lands in the input buffer at once, so a blocking getc returns
each following byte right away. A lone ESC has no follow-on byte and
would block here, so on Windows we quit with q, not ESC. If the byte
after ESC isn't a CSI/SS3 introducer we stop at once, swallowing nothing
extra.
212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/ruby2d/cli/browser.rb', line 212 def self.read_escape_windows(io) seq = io.getc.to_s return seq unless seq == '[' || seq == 'O' 14.times do c = io.getc break if c.nil? seq << c break if c.match?(/[A-Za-z~]/) end seq end |
.read_key(managed = false) ⇒ Object
Block for one keypress and map it to an action.
managed is true when WinConsole already put the console in raw + VT
input mode, so we read IO.console directly. Otherwise (POSIX, or
Windows without a settable console) we go through io/console's raw.
154 155 156 157 158 159 160 |
# File 'lib/ruby2d/cli/browser.rb', line 154 def self.read_key(managed = false) if managed read_console(IO.console) else IO.console.raw { |io| read_console(io) } end end |
.render(list, selected, label, footer_action) ⇒ Object
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/ruby2d/cli/browser.rb', line 271 def self.render(list, selected, label, ) rows, cols = IO.console.winsize rows = 24 if rows.nil? || rows < 10 cols = 80 if cols.nil? || cols < 40 print "\e[2J\e[H" title = " #{'Ruby 2D'.ruby2d_red.bold} — #{label} #{"(#{selected + 1}/#{list.length})".dim}" = " #{"↑/↓ navigate · Enter to #{} · q to quit".dim}" desc_height = 3 desc_lines = wrap(list[selected][:description], cols - 4)[0, desc_height] # 1 (blank) + title + 1 + viewport + 1 + desc_height + 1 + footer = rows = rows - (5 + desc_height) = [, 5].max = [, list.length].min start = [selected - / 2, 0].max start = [start, list.length - ].min start = 0 if start.negative? width = list.length.to_s.length puts puts title puts .times do |i| idx = start + i item = list[idx] num = (idx + 1).to_s.rjust(width) if idx == selected puts " #{RED}◆ #{BOLD}#{num}#{RESET} #{RED}#{BOLD}#{item[:title]}#{RESET}" else puts " #{DIM}#{num}#{RESET} #{item[:title]}" end end puts desc_height.times { |i| puts " #{DIM}#{desc_lines[i] || ''}#{RESET}" } puts print end |
.run(list:, label:, footer_action:, fallback: nil, &on_select) ⇒ Object
Run the interactive browser. list array of item hashes with :title and :description label header label, e.g. 'Examples' or 'Usage Guide' footer_action verb after 'Enter to', e.g. 'run' or 'view' fallback called when there is no usable TTY block invoked with the selected item when Enter is pressed
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/ruby2d/cli/browser.rb', line 91 def self.run(list:, label:, footer_action:, fallback: nil, &on_select) return if list.empty? unless $stdout.tty? && IO.console fallback&.call return end selected = 0 prev_mode = nil begin enter_alt_screen # On Windows, take over the console mode ourselves; `managed` is true # only when that succeeded (otherwise fall back to `io/console` raw). prev_mode = WinConsole.enable_raw if WINDOWS managed = !prev_mode.nil? # Managed mode reads IO.console directly; binmode stops Windows CRLF # read-translation from stalling Enter (it holds the CR of CR+LF # waiting to see whether an LF follows). IO.console.binmode if managed loop do render(list, selected, label, ) case read_key(managed) when :up then selected = (selected - 1) % list.length when :down then selected = (selected + 1) % list.length when :pgup then selected = [selected - 10, 0].max when :pgdn then selected = [selected + 10, list.length - 1].min when :home then selected = 0 when :last then selected = list.length - 1 when :enter print "\e[2J\e[H\e[?25h\e[?1000l\e[?1006l" on_select.call(list[selected]) # The example shared this console; re-assert our mode and drop # anything typed while it ran so stray keys (e.g. a queued Enter) # don't fire actions back in the picker. WinConsole.reassert_raw if managed IO.console.iflush if IO.console.respond_to?(:iflush) print "\e[?25l\e[?1000h\e[?1006h" when :quit break end end ensure WinConsole.restore(prev_mode) if WINDOWS leave_alt_screen end end |
.wrap(text, width) ⇒ Object
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/ruby2d/cli/browser.rb', line 253 def self.wrap(text, width) return [] if text.nil? || text.empty? || width <= 0 lines = [] current = '' text.split(' ').each do |word| if current.empty? current = word elsif current.length + 1 + word.length <= width current << ' ' << word else lines << current current = word end end lines << current unless current.empty? lines end |