app_path = __FILE__
$0 = File::basename(app_path, ".rb") if app_path
require 'js'
require 'js/require_remote'
# our files are where the page's says - under a permalink
# (/de/methoden, server/app.rb) that is not where location.href points;
# without a the two are the same
JS::RequireRemote.instance.base_url = JS.global[:document][:baseURI].to_s
require 'json'
require 'stringio'
require 'time' # Time.parse: a cookie's expiry (the mini browser's jar)
require 'singleton'
# did_you_mean comes with Ruby, but ruby.wasm boots without it (no
# gem_prelude). Loaded, a misspelt name in a cell gets a suggestion below
# the error (ChunkyApp#suggestion_html): nmae -> name.
begin
require 'did_you_mean'
rescue LoadError
nil
end
# require_relative bridge (pattern from BrowserRubyKoans): files on the
# wasm filesystem first (stdlib, installed gems), remote URL fetch for our
# own source files (browser_gems.rb), which are not on that filesystem.
# Code that is (stdlib's net/https, gems) never falls through to the remote
# fetch - a missing file there is a plain LoadError, which the require hook
# may still answer with a shim.
module Kernel
alias original_require_relative require_relative
def require_relative(path)
location = caller_locations(1, 1).first
caller_path = (location.absolute_path || location.path).to_s
begin
original_require_relative(File.absolute_path(path, File.dirname(caller_path)))
rescue LoadError
raise if caller_path.start_with?("/")
# a workshop program requiring another of its files (workshop.rb)
loaded = defined?(Workshop) ? Workshop.require_relative(path, caller_path) : nil
return loaded unless loaded.nil?
JS::RequireRemote.instance.load(path)
end
end
end
# gems (roo, Tempfile users) expect a temp dir; the wasm filesystem starts
# without one, and reports no permission bits, which Dir.tmpdir rejects
Dir.mkdir("/tmp") unless Dir.exist?("/tmp")
require "tmpdir"
def Dir.tmpdir = "/tmp"
# ...nor owners or settable times: gems that set them after writing a file
# (rubyzip extracting, so roo) carry on, as the call can change nothing
class << File
{ chmod: 1, lchmod: 1, chown: 2, lchown: 2, utime: 2, lutime: 2 }.each do |name, leading|
next unless method_defined?(name)
alias_method :"wasm_orig_#{name}", name
define_method(name) do |*args|
send(:"wasm_orig_#{name}", *args)
rescue Errno::ENOSYS, Errno::ENOTSUP
args.size - leading
end
end
end
require_relative "browser_gems"
require_relative "rack_playground"
BrowserGems.cache_base = "gems/cache"
BrowserGems.proxy_base = "/rubygems"
# OFFLINE[:miss]: a download failed because the page is offline (index.html's
# helpers say "ERROR offline") - the install message says so
OFFLINE = { miss: false }
BrowserGems.fetch_text = lambda do |url|
text = JS.global.fetchTextSync(url).to_s
OFFLINE[:miss] = true if text == "ERROR offline"
text.start_with?("ERROR ") ? nil : text
end
BrowserGems.fetch_binary = lambda do |url|
base64 = JS.global.fetchBinaryBase64(url).to_s
OFFLINE[:miss] = true if base64 == "ERROR offline"
base64.start_with?("ERROR ") ? nil : base64.unpack1("m0")
end
# jsg (github.com/Largo/jsg) - the js gem with a friendlier syntax:
# el.textContent = "x", input.value, el.closest(".x") is nil when nothing
# matches, results come back as Ruby values. From the gem cache, so the two
# fetchers above are the only code here written without it.
BrowserGems.install("jsg")
require "jsg"
# Net::HTTP in the browser: the stdlib version cannot load (no io/wait, no
# sockets in WASI), so the require hook serves our API-compatible shim and
# we back it with the browser's own HTTP. CORS limits which hosts a page
# may call - these are reachable (proxied same-origin or CORS-open):
require "net/http"
unless Net::HTTP.respond_to?(:transport=)
# this wasm build managed to load the REAL net/http (io/wait present);
# it still cannot work without sockets - overlay the shim
Net.send(:remove_const, :HTTP)
BrowserGems.loaded.delete("(shims):net/http.rb")
BrowserGems.load_feature("net/http")
end
NET_HTTP_HOSTS = {
"www.ruby-lang.org" => "/proxy/ruby-lang",
"ruby-lang.org" => "/proxy/ruby-lang",
"rubygems.org" => "/rubygems",
"api.github.com" => "https://api.github.com"
}.freeze
# Minitest: the ruby.wasm 4.0 build does not bundle it, so install the
# cached pure-Ruby 5.x on demand. Its parallel executor spawns threads on
# Minitest.run - replace it with a serial stub once, at boot.
begin
require "minitest"
rescue LoadError
BrowserGems.install("minitest")
require "minitest"
end
unless Gem.respond_to?(:find_files)
# minitest's plugin scan needs this; the wasm build ships a stripped Gem
def Gem.find_files(*_args) = []
end
# ...which also lacks Gem::Deprecate, used by gems at load time
# (addressable, and so premailer)
begin
require "rubygems/deprecate" unless defined?(Gem::Deprecate)
rescue LoadError
nil
end
# ...and Gem::Version (rack-test, under Rails, compares Rack's release when
# it loads), Gem.path and Gem.default_dir (ActiveSupport's BacktraceCleaner
# drops the frames from gem directories, its file watcher skips them): the
# installed gems' directory is the one there is. Gem.win_platform?: no.
begin
require "rubygems/version" unless defined?(Gem::Version)
rescue LoadError
nil
end
def Gem.path = [BrowserGems::ROOT] unless Gem.respond_to?(:path)
def Gem.default_dir = BrowserGems::ROOT unless Gem.respond_to?(:default_dir)
def Gem.win_platform? = false unless Gem.respond_to?(:win_platform?)
# ErrorHighlight: on a computer Ruby loads it at startup (the "^^^^" under
# the culprit in an error message); the wasm image only declares the module.
# Rails' error page asks ErrorHighlight.spot for every frame of the backtrace.
begin
require "error_highlight"
rescue LoadError
nil
end
# ...and Kernel#gem, which activates a version of an installed gem (Active
# Record's SQLite adapter asks for sqlite3 >= 2.1 that way): a gem the
# installer put on the load path, or one the image has built in or stands in
# for (sqlite3, in NATIVE_GEMS), is there in the one version there is.
# Prepended, so it still answers after a gem loads the full RubyGems
# (railties does), whose own Kernel#gem knows none of the installed gems;
# any other name goes to that one, when it is there. No require in here: a
# gem that asks for itself while it loads would recurse until the VM traps.
module ChunkyKernelGem
def gem(name, *requirements)
name = name.to_s
return true if BrowserGems.installed.key?(name) || BrowserGems::NATIVE_GEMS.include?(name) ||
BrowserGems::DEFAULT_GEMS.include?(name)
defined?(super) ? super : true
end
private :gem
end
Kernel.prepend(ChunkyKernelGem)
# ...but the stub does not last: a gem that loads full RubyGems (rubyzip
# does, so ruby_pptx does) brings the real Gem.find_files, whose plugin scan
# finds the wasm image's bundled minitest 6 and loads it over this 5.x -
# after which every run_tests dies with an ArgumentError. Minitest's own
# switch skips the scan whatever Gem looks like.
ENV["MT_NO_PLUGINS"] = "1"
Minitest.parallel_executor = Object.new.tap do |stub|
def stub.start; end
def stub.shutdown; end
def stub.<<(_work)
raise NotImplementedError, "parallelize_me! is unavailable in the browser (no threads in WASI)"
end
end
# A binding at the top level with a scope of its own, like a file of its
# own. Bindings made from TOPLEVEL_BINDING all share one scope: a `using` in
# a cell (`using Processing`, lesson 36) would switch the refinement on in
# every lesson after it - `loop`, `text`, `size` would be Processing's - and
# in the page's own code. One compiled on its own keeps it to itself.
module TopLevel
def self.binding(file = "chunky.rb")
RubyVM::InstructionSequence.compile("proc { binding }.call", file).eval
end
end
# Simulations for the sandbox: virtual filesystem behind File/Dir,
# virtual sleep, cooperative SimThread as Thread.
require_relative "sandbox_sim"
# The workshop: the learner's own multi-file programs.
require_relative "workshop"
# Live runs: rehearsals a moment after the learner stops typing.
require_relative "autorun"
# The cells above a cell that have not run yet: ▶ runs them first.
require_relative "earlier_cells"
# ⏯ beside ▶ (a lesson with "stepper": true): a run recorded line by line,
# for the page's stepper (stepper.js). At boot: ~14 KB that evaluates in
# about 6 ms.
require_relative "step_recorder"
# Terminal colours (pastel, tty-*) in a cell's output, as HTML.
require_relative "ansi"
# show_objects: names and objects as boxes and arrows, an SVG through
# show_image (lessons 7, 8, 11). At boot, unlike shoes_dom.rb: ~17 KB that
# evaluates in about 10 ms.
require_relative "object_graph"
# turtle { forward 100 }: drawings with Chunky as the turtle, an animated SVG
# through show_image (lesson 10). At boot too: ~15 KB that evaluates in
# about 6 ms. Its texts follow the lesson's language (sync_state).
require_relative "turtle"
# show_game: a grid game the page drives frame by frame (game.rb here, the
# loop in game.js; lesson 41). At boot too: ~10 KB of plain Ruby.
require_relative "game"
# spinel(code), show_spinel_irb: Matz's AOT compiler as WebAssembly in the
# page (the shell's shell/spinel.rb and its workers, html/spinel/); the CRuby
# run of the program beside it is here (lesson 45)
require_relative "spinel"
# A lesson with "engine": "picoruby" runs on PicoRuby.wasm in a Web Worker
# (picoruby_lab.js); this turns its answers into values, errors and CRuby's
# syntax messages. ~3 KB at boot.
require_relative "picoruby_cells"
# A lesson with "engine": "jruby" runs on JRuby 10.1 (TeaVM) in a Web Worker
# (jruby_lab.js); its answers are read as PicoRuby's are, and its CRuby cells
# ("on": "cruby") read the same earthquake data. ~2 KB at boot.
require_relative "jruby_cells"
# (jruby_lab.js fetched it when the lesson opened; otherwise it fetches now)
JRubyCells.fetch_text = lambda do |url|
text = $window.chunkyJRuby.text(url).to_s
text.start_with?("ERROR ") ? nil : text
end
# A lesson with "engine": "ironruby" runs on IronRuby (.NET in WebAssembly)
# in a Web Worker (ironruby_lab.js); its answers are read as PicoRuby's are,
# and the pictures its cells show come back in the output. ~2 KB at boot.
require_relative "ironruby_cells"
# require "pycall" is the bridge to Pyodide (pycall.rb): the real gem needs
# libpython, which a browser does not have
require_relative "pycall"
BrowserGems.files["(shims)"]["pycall.rb"] = ""
# require "sqlite3" is the gem's API over sql.js (sqlite3_sqljs.rb): the real
# gem is a C extension. Sequel's SQLite adapter runs on it unchanged, and
# BrowserGems counts sqlite3 as built in, so install_gem "sqlite3" works.
require_relative "sqlite3_sqljs"
BrowserGems.files["(shims)"]["sqlite3.rb"] = ""
# require "numo/narray" (numo-narray-alt's "numo/narray/alt" too): Numo is
# written in C, numo_narray.rb is its API in pure Ruby - what Rumale needs
# (lesson 29). Fetched on the first require, like shoes_dom.rb; BrowserGems
# counts the numo gems as built in once it is there.
numo = <<~'RUBY'
unless defined?(Numo::NArray)
source = JSG.w.fetchTextSync("numo_narray.rb").to_s
raise LoadError, "could not fetch numo_narray.rb" if source.start_with?("ERROR ")
eval(source, TOPLEVEL_BINDING, "numo_narray.rb")
end
RUBY
BrowserGems.files["(shims)"]["numo/narray.rb"] = numo
BrowserGems.files["(shims)"]["numo/narray/alt.rb"] = numo
# require "processing": the gem draws through rays and reflexion (C++ on
# OpenGL); processing.rb is its API in pure Ruby, recording each frame for
# processing.js to paint (lesson 36). Fetched on the first require, like
# numo_narray.rb; the gem counts as built in once it is there.
BrowserGems.files["(shims)"]["processing.rb"] = <<~'RUBY'
unless defined?(Processing::Context)
source = JSG.w.fetchTextSync("processing.rb").to_s
raise LoadError, "could not fetch processing.rb" if source.start_with?("ERROR ")
eval(source, TOPLEVEL_BINDING, "processing.rb")
Processing.measure__ = lambda do |text, size, font|
JSG.w.chunkySketchTextWidth(text, size, font.to_s).to_s.to_f
end
end
RUBY
# require "ble" in a Pico lesson's CRuby cell (the laptop's side, lesson
# pico-ble): the `ble` gem's API over the bench's simulated radio
# (pico_laptop.rb; the real gem needs BlueZ and D-Bus). Fetched on the first
# require, like processing.rb.
BrowserGems.files["(shims)"]["ble.rb"] = <<~'RUBY'
unless defined?(PicoLaptop)
source = JSG.w.fetchTextSync("pico_laptop.rb").to_s
raise LoadError, "could not fetch pico_laptop.rb" if source.start_with?("ERROR ")
eval(source, TOPLEVEL_BINDING, "pico_laptop.rb")
end
PicoLaptop.cell = ChunkyApp.instance.laptop_cell__
RUBY
# require "ruby2d": the gem is Ruby around a C extension on SDL3. Its Ruby
# comes as it is (assets/ruby2d/ruby2d.rb, tools/vendor_ruby2d.rb), ruby2d.rb
# is the extension in Ruby: draw calls become commands game.js paints, and
# show hands the window to the page, which runs its frames like a show_game
# (mount_game; lesson 42). Fetched on the first require, ~210 KB; the gem
# counts as built in once it is there. The mixing into the top level is taken
# back when the lesson changes (sync_state), so later lessons do not find
# `show` or `Square` there; unmix__ also forgets this shim ran, so the next
# require "ruby2d" mixes again.
RUBY2D_FILES = %w[assets/ruby2d/ruby2d.rb ruby2d.rb].freeze
ruby2d_load = <<~'RUBY'
unless defined?(Ruby2D::Page)
AutoRun.untraced do
ENV["HOME"] ||= "/" # the gem's gamepad_events.rb reads ~ when it loads
RUBY2D_FILES.each do |name|
source = JSG.w.fetchTextSync(name).to_s
raise LoadError, "could not fetch #{name}" if source.start_with?("ERROR ")
eval(source, TOPLEVEL_BINDING, name)
end
end
Ruby2D.measure__ = lambda do |text, size, style|
JSG.w.chunkyCanvasTextWidth(text, size, style.to_i).to_s.to_f
end
Ruby2D.on_show__ = ->(runner) { ChunkyApp.instance.add_game(runner) }
Ruby2D.lang__ = ChunkyApp.instance.lang
Ruby2D.replay__ = ->(code) { eval(code, TopLevel.binding, ChunkyApp::EVAL_FILE) }
end
RUBY
BrowserGems.files["(shims)"]["ruby2d.rb"] = ruby2d_load + "Ruby2D.mix__\n"
BrowserGems.files["(shims)"]["ruby2d/core.rb"] = ruby2d_load
# require "herb": the gem is Ruby around one C extension, its parser
# ("herb/herb"); herb_bridge.rb is that extension, handing the source to the
# same parser compiled to WebAssembly (index.html: ensureHerb; lesson 38).
BrowserGems.files["(shims)"]["herb/herb.rb"] = <<~'RUBY'
unless defined?(Herb::Bridge)
source = JSG.w.fetchTextSync("herb_bridge.rb").to_s
raise LoadError, "could not fetch herb_bridge.rb" if source.start_with?("ERROR ")
eval(source, TOPLEVEL_BINDING, "herb_bridge.rb")
end
RUBY
Net::HTTP.transport = lambda do |_method, uri|
# a live run fetches nothing: it would freeze the typing and ask the
# server once per pause
raise AutoRun::NeedsRun if ChunkyApp.instance.auto_run?
prefix = NET_HTTP_HOSTS[uri.host.to_s.downcase]
unless prefix
raise SocketError, "#{uri.host} is not reachable from this browser playground " \
"(allowed: #{NET_HTTP_HOSTS.keys.join(', ')}) - on your own " \
"computer net/http can reach any URL"
end
data = JSON.parse(JSG.w.fetchHttpSync(prefix + uri.request_uri.to_s))
if data["status"].to_i.zero?
raise SocketError, "connection to #{uri.host} failed#{' - this page is offline' if data['body'] == 'offline'}"
end
[data["status"].to_i, { "content-type" => data["contentType"].to_s }, data["body"].to_s]
end
# A live run rehearsing the cells above (ChunkyApp#run_earlier) installs no
# gem for them, not even from the cache: installing and loading one takes
# seconds, which would freeze the typing in a cell further down. The cell
# then runs on its own; ▶ installs. (What the wasm image has built in is
# no install.)
module EarlierCells
class << self
attr_accessor :rehearsing
end
module NoInstalls
def install(name, seen = {})
name = name.to_s.strip
if EarlierCells.rehearsing && !installed[name] &&
!BrowserGems::NATIVE_GEMS.include?(name) && !BrowserGems::DEFAULT_GEMS.include?(name)
raise AutoRun::NeedsRun
end
super
end
end
end
BrowserGems.singleton_class.prepend(EarlierCells::NoInstalls)
# "Ruby lernen mit Chunky Bacon" - a notebook-style browser Ruby course built
# on the same ruby.wasm setup as BrowserRubyKoans (koans.idogawa.com).
# Lessons are sequences of text blocks and runnable code cells; all cells of
# a lesson share one binding (like a notebook kernel) and each cell shows the
# value of its last expression as "=> ..." so puts is never required.
#
# This is the KERNEL: it runs cells, checks, gems and the widgets. The page
# around it - index, lesson text, editors, language, routing, Chunky's
# bubble - is the shell (shell/*.rb on PicoRuby.wasm), which is on screen
# long before this 10 MB Ruby has loaded. The shell says which lesson is
# open and asks for runs through shell/bridge.js (chunky:* events on
# window); the kernel answers through window.ChunkyBridge.
class ChunkyApp
include Singleton
EVAL_FILE = "chunky.rb"
def initialize
$window = JSG.w
$d = JSG.d
@data = JSON.parse($window.LESSONS_JSON)
@lessons = @data["lessons"]
@browser_apps = []
@irb_sessions = []
@three_renderers = {}
@three_seq = 0
@sketches = {}
@shoes_apps = {}
@games = {}
@seq = nil
sync_state(bridge.state)
setup_elements
bridge.kernelReady(installed_json)
end
# ---------- the shell (shell/bridge.js) ----------
def bridge
$window.ChunkyBridge
end
# The shell keeps the code a cell runs, under the fingerprint of the cell
# it was written for (shell/store.rb's code_key); a page without the
# shell keeps nothing.
def save_code(idx, code)
return unless $window[:chunkySaveCode].typeof == "function"
$window.chunkySaveCode(idx.to_s, code)
end
# The lesson (or the workshop) the shell shows, in its language. A new seq
# means a new page or a lesson reset: a fresh binding, old stages gone.
def sync_state(state)
@lang = state.lang
@lang = "de" unless @data["ui"].key?(@lang)
Turtle.lang = @lang # its errors and the picture's alt text
Ruby2D.lang__ = @lang if ruby2d?
@lesson_id = state.lesson
@workshop = state.workshop
seq = state.seq.to_i
return if seq == @seq
first = @seq.nil?
@seq = seq
fresh_binding
# (not in the embedded cell: embed.html has no PicoRuby)
$window.chunkyPicoRuby.reset if $window[:chunkyPicoRuby].typeof == "object"
$window.chunkyJRuby.reset if $window[:chunkyJRuby].typeof == "object"
# the Pico's bench belongs to the page that went (pico_board.js). Not on
# the first sync, when Ruby has just booted: the shell drew the page's
# previews already and nothing can have run yet - rebuilding them would
# close a USB panel or stop a webcam the learner opened meanwhile.
$window.chunkyPicoBoard.unmount if !first && $window[:chunkyPicoBoard].typeof == "object"
PicoLaptop.forget if defined?(PicoLaptop)
$window.chunkyIronRuby.reset if $window[:chunkyIronRuby].typeof == "object"
dispose_three
dispose_shoes
dispose_sketches
dispose_games
unmix_ruby2d
load_lesson_files
end
# Files a lesson's code reads, next to it as on a computer (lessons.js:
# "files": { name => path on the site }) - digits.csv for Rumale. Virtual
# files, so File.read and File.exist? find them.
def load_lesson_files
return if workshop?
(current_lesson["files"] || {}).each do |name, path|
next if SandboxFS.exist?(name)
text = $window.fetchTextSync(path).to_s
SandboxFS.write(name, text) unless text.start_with?("ERROR ")
end
end
def installed_json
JSON.generate(BrowserGems.installed)
end
# ---------- helpers ----------
def ui
@data["ui"][@lang]
end
# the lesson the shell shows (sync_state)
def current_index
@lessons.index { |l| l["id"] == @lesson_id } || 0
end
# the workshop (#werkstatt): the learner's own programs
def workshop?
@workshop
end
def current_lesson
@lessons[current_index]
end
def l10n(lesson)
lesson[@lang] || lesson["de"]
end
def cells
l10n(current_lesson)["cells"]
end
def code_cell?(cell)
cell["t"] == "c" || cell["t"] == "x"
end
def escape_html(text)
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
end
# a new binding has run none of the lesson's cells (earlier_cells.rb)
def fresh_binding
@bind = TopLevel.binding(EVAL_FILE)
@ran = {}
# what the JRuby worker's binding has run (the JRuby lesson)
@jruby_ran = {}
# what the IronRuby worker's binding has run (the IronRuby lesson)
@ironruby_ran = {}
end
# ---------- setup ----------
# The shell handles the page (index, language, reset, Run buttons, the
# bubble, the gems panel); what is left here are the requests it sends
# and the widgets whose Ruby objects live in this VM.
def setup_elements
$window.addEventListener("chunky:run") do |event|
sync_state(event.detail)
finish_cell_run(event.detail.idx.to_i, event.detail.auto.to_s == "true", event.detail.step.to_s == "true")
end
$window.addEventListener("chunky:lesson") { |event| sync_state(event.detail) }
# a PicoRuby lesson's cells and IRBs answer later (picoruby_lab.js)
$window.addEventListener("chunky:picoruby") { |event| picoruby_answer(event.detail) }
# the JRuby lesson's cells answer later too (jruby_lab.js)
$window.addEventListener("chunky:jruby") { |event| jruby_answer(event.detail) }
# a tap past its limit ended JRuby's worker: nothing has run in the new one
$window.addEventListener("chunky:phone") { |event| @jruby_ran = {} if %w[stopped failed].include?(event.detail.status.to_s) }
# ... and what they print shows while they run (a Pico lesson's 60 s)
$window.addEventListener("chunky:picoruby-progress") { |event| picoruby_progress(event.detail) }
# the simulated Pico notified over BLE: the laptop cell's blocks (pico_laptop.rb)
$window.addEventListener("chunky:pico-ble") { |event| pico_laptop_notify(event.detail.text.to_s) }
# the IronRuby lesson's cells answer later too (ironruby_lab.js)
$window.addEventListener("chunky:ironruby") { |event| ironruby_answer(event.detail) }
$window.addEventListener("chunky:install") do |event|
sync_state(event.detail)
panel_install(event.detail.name)
end
# delegated listener for the mini-browser and file-explorer widgets;
# closest() answers nil when nothing matches
$d.getElementById("lessonBody").addEventListener("click") do |event|
target = event.target
css_class = target.className.to_s # an SVG's className is an object
if css_class.include?("mb-go")
widget = target.closest(".mini-browser")
navigate_browser(widget) if widget
elsif css_class.include?("fe-refresh")
widget = target.closest(".file-explorer")
refresh_file_widget(widget) if widget
elsif (row = target.closest(".fe-file"))
widget = row.closest(".file-explorer")
preview_file(widget, row.getAttribute("data-path")) if widget
elsif css_class.include?("mb-view") && (button = event.composedPath.first.closest("button, input[type=submit]")) &&
button.getAttribute("type").to_s != "button" && !button.form.nil?
# a form's submit button inside the fake browser: the form is sent
# to the app, as a browser would
event.preventDefault
widget = target.closest(".mini-browser")
submit_browser_form(widget, button.form, button) if widget
elsif css_class.include?("mb-view") && (link = event.composedPath.first.closest("a"))
# links inside the fake browser navigate the fake browser; its page
# is in a shadow root, so the click arrives with .mb-view as target
event.preventDefault
widget = target.closest(".mini-browser")
if widget
widget.querySelector(".mb-url").value = link.getAttribute("href").to_s
navigate_browser(widget)
end
end
end
# Enter in a mini-browser URL bar navigates; Enter in an IRB input evals
$d.getElementById("lessonBody").addEventListener("keydown") do |event|
next unless event.key == "Enter"
target = event.target
target_class = target.className.to_s
if target_class.include?("mb-url")
event.preventDefault
widget = target.closest(".mini-browser")
navigate_browser(widget) if widget
elsif target_class.include?("mb-view") && (field = event.composedPath.first).tagName.to_s == "INPUT" && !field.form.nil?
# Enter in a form's text field sends the form, as in a browser
event.preventDefault
widget = target.closest(".mini-browser")
submit_browser_form(widget, field.form, field.form.querySelector("button:not([type]), button[type=submit], input[type=submit]")) if widget
elsif target_class.include?("irb-input")
event.preventDefault
term = target.closest(".irb-term")
irb_submit(term) if term
end
end
end
# ---------- gems ----------
# the shell's gems panel shows the change (after every run, too)
# A live run installs only from the cache, and outside its time limit: a
# gem installed halfway would stay broken.
def install_gem_ui(name)
raise AutoRun::NeedsRun if auto_run? && !(BrowserGems.installed.key?(name) || BrowserGems.manifest.key?(name))
OFFLINE[:miss] = false
version = AutoRun.untraced { BrowserGems.install(name) }
"#{name} #{version}"
rescue BrowserGems::NativeGemError => e
# the gem with C code may be a dependency of the one asked for
message = e.message == name ? format(ui["nativeGem"], name) : format(ui["nativeDep"], name, e.message)
raise BrowserGems::NativeGemError, message
rescue BrowserGems::NotFoundError
raise BrowserGems::NotFoundError, format(ui[OFFLINE[:miss] ? "gemOffline" : "gemNotFound"], name)
end
# the panel's chips and its install button (Chunky's bubble says how it went)
def panel_install(name)
result = install_gem_ui(name)
bridge.installed(name, true, result)
rescue StandardError => e
bridge.installed(name, false, e.message)
ensure
bridge.gems(installed_json)
end
# alt: what the picture shows, in words (show_objects says it); a
# picture without one is decorative, alt=""
def add_image(data_url, alt = nil)
return unless @run_images
@run_images << data_url
@run_image_alts[data_url] = alt.to_s if alt
end
# the first bytes of the picture formats a browser shows
IMAGE_SIGNATURES = {
"\x89PNG".b => "image/png", "\xFF\xD8\xFF".b => "image/jpeg",
"GIF8".b => "image/gif", "RIFF".b => "image/webp"
}.freeze
# an SVG's text: