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: ]*>\s*)?(?:\s*)*]/m # show_image's argument as a data: URL - a picture's bytes, an SVG's text, # a chart that writes SVG (a Rubyvis panel: drawn here, so a cell need not # call render), or the name of a file the cell wrote (virtual, or real: # File.binwrite, ChunkyPNG#save) def image_data_url(image) return image.to_data_url if image.respond_to?(:to_data_url) if image.respond_to?(:to_svg) image.render if image.respond_to?(:render) image = image.to_svg end bytes = image.respond_to?(:to_bytes) ? image.to_bytes.b : image.to_s.b unless IMAGE_SIGNATURES.any? { |magic, _type| bytes.start_with?(magic) } || bytes.match?(SVG_START) name = image.to_s bytes = (SandboxFS.virtual?(name) && SandboxFS.exist?(name) ? SandboxFS.read(name) : File.binread(name)).b end type = IMAGE_SIGNATURES.find { |magic, _type| bytes.start_with?(magic) }&.last type ||= bytes.match?(SVG_START) ? "image/svg+xml" : "image/png" "data:#{type};base64,#{[bytes].pack('m0')}" end def add_pdf(bytes) @run_pdfs << bytes.to_s.b if @run_pdfs end # The browser's own PDF viewer, on a Blob URL; the previous run's URLs of # this cell are released first. def pdfs_html(idx) @pdf_urls ||= {} (@pdf_urls[idx] || []).each { |url| $window.URL.revokeObjectURL(url) } @pdf_urls[idx] = @run_pdfs.map { |bytes| $window.makeDownloadUrl([bytes].pack("m0"), "application/pdf") } @pdf_urls[idx].map { |url| "" }.join end # ---------- sounds (show_audio) ---------- def add_audio(bytes) @run_audios << bytes.to_s.b if @run_audios end # A player on a Blob URL, like show_pdf, above it a picture of the wave # (ChunkyAudio.waveform_svg). The player is named "Ein Klang, 2,0 # Sekunden" (audioLabel), which #runStatus reads; the picture says # nothing more and is alt="". def audios_html(idx) @audio_urls ||= {} (@audio_urls[idx] || []).each { |url| $window.URL.revokeObjectURL(url) } @audio_urls[idx] = @run_audios.map { |bytes| $window.makeDownloadUrl([bytes].pack("m0"), "audio/wav") } @run_audios.zip(@audio_urls[idx]).map do |bytes, url| samples, rate = ChunkyAudio.pcm(bytes) # "1,5" - and "0,25" for a short sound, not "0,3" (or "0,0") seconds = format(samples.size >= rate ? "%.1f" : "%.2f", samples.size.fdiv(rate)) seconds = seconds.tr(".", ",") if @lang == "de" label = escape_html(format(ui["audioLabel"], seconds)) picture = samples.empty? ? "" : "\"\"" "
#{picture}
" end.join end # ---------- downloads ---------- DOWNLOAD_TYPES = { ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".png" => "image/png", ".jpg" => "image/jpeg", ".svg" => "image/svg+xml", ".csv" => "text/csv", ".json" => "application/json", ".html" => "text/html", ".txt" => "text/plain", ".md" => "text/markdown", ".zip" => "application/zip", ".db" => "application/vnd.sqlite3", ".sqlite" => "application/vnd.sqlite3", ".sqlite3" => "application/vnd.sqlite3", ".pdf" => "application/pdf", ".wav" => "audio/wav" }.freeze # A file to offer below the cell. The same name twice keeps the last. def add_download(name, bytes) return unless @run_downloads @run_downloads.reject! { |existing, _| existing == name } @run_downloads << [name, bytes.to_s.b] end def downloads_html(idx) (@download_urls[idx] || []).each { |url| $window.URL.revokeObjectURL(url) } @download_urls[idx] = [] links = @run_downloads.map do |name, bytes| type = DOWNLOAD_TYPES.fetch(File.extname(name).downcase, "application/octet-stream") url = $window.makeDownloadUrl([bytes].pack("m0"), type) @download_urls[idx] << url "" \ "⬇ #{escape_html(name)} #{format_size(bytes.bytesize)}" end "
#{links.join}
" end def format_size(bytes) bytes < 1024 ? "#{bytes} B" : "#{(bytes / 1024.0).round(1)} KB" end # ---------- file explorer widget ---------- def add_files_widget @run_files << true if @run_files end def files_list_html keys = SandboxFS.store.keys.sort dirs = keys.map { |k| k.include?("/") ? k.split("/").first : nil }.compact.uniq html = dirs.map { |d| "
  • 📁 #{escape_html(d)}/
  • " }.join html + keys.map do |k| size = SandboxFS.store[k].bytesize "
  • 📄 #{escape_html(k)}#{size} B
  • " end.join end def files_widget_html <<~HTML
    📁 timelog #{ui["filesTitle"]}
    HTML end def refresh_file_widget(widget) widget.querySelector(".fe-list").innerHTML = files_list_html widget.querySelector(".fe-preview").style.display = "none" end def preview_file(widget, path) preview = widget.querySelector(".fe-preview") content = begin SandboxFS.read(path) rescue StandardError "?" end preview.textContent = content preview.style.display = "block" end # ---------- Shoes app stage (lacci + ShoesDom) ---------- def add_shoes(options, block) return unless @run_shoes @run_shoes << { block: block, width: (options[:width] || 460).to_i, height: options[:height]&.to_i } end # ShoesDom subclasses Shoes::DisplayService, so it can only be defined once # Shoes is loaded. The lesson's first cell installs lacci and requires it; # a later Shoes cell run before it - or its live run while the learner # types - loads it here, from the cache. Evaluated without Shoes, # shoes_dom.rb stopped half way and left a ShoesDom without its classes, # which broke every Shoes cell until a reload: so it counts as loaded only # once its last class is there. Cells run synchronously, and # JS::RequireRemote needs an async context, so the source is fetched with # the same synchronous XHR the gem installer uses. def shoes_dom_loaded? return true if defined?(ShoesDom::DisplayService) unless defined?(Shoes::DisplayService) install_gem_ui("lacci") require "shoes" end source = $window.fetchTextSync("shoes_dom.rb") raise LoadError, "could not fetch shoes_dom.rb" if source.start_with?("ERROR ") eval(source, TOPLEVEL_BINDING, "shoes_dom.rb") true rescue StandardError, ScriptError => e $window.console.log("shoes_dom load failed: #{e.class}: #{e.message}") false end # Builds the app's DOM off-document and hands back the element to append # once the cell's own output is in place. Remembers the app per cell so a # re-run can take the old one down first. def build_shoes_stage(idx, spec) mounted = ShoesDom.mount(width: spec[:width], height: spec[:height], &spec[:block]) (@shoes_apps[idx] ||= []) << mounted @last_shoes_types = mounted.types mounted.element rescue StandardError => e $d.createElement("div").tap do |node| node.className = "cell-error" node.textContent = "#{e.class}: #{e.message}" end end def dispose_shoes(idx = nil) @shoes_apps ||= {} if idx.nil? @shoes_apps.clear ShoesDom.reset! if defined?(ShoesDom) else (@shoes_apps.delete(idx) || []).each(&:dispose) end end # ---------- the letter (show_letter) ---------- def add_letter(boxes, block) @run_letters << { boxes: boxes, block: block } if @run_letters end LETTER_LABELS = %w[From To Street Value Post Clear Sees Hint].freeze # The envelope is letter.js's; when the pen lifts it hands over the boxes' # digits (64 numbers each, as JSON) and writes our block's answer on the # letter - after the cell has run, so an error goes there too. def mount_letter(idx, out_el, spec, number) node = $d.createElement("div") out_el.appendChild(node) labels = LETTER_LABELS.to_h { |key| [key.downcase, ui["letter#{key}"].to_s] } key = "#{current_lesson['id']}-#{idx}-#{number}" letter = nil letter = $window.chunkyLetter(node, spec[:boxes], key, JSON.generate(labels)) do |json| next unless letter begin letter.answer(spec[:block].call(JSON.parse(json.to_s)).to_s, false) rescue StandardError, ScriptError => e letter.answer("#{e.class}: #{e.message}", true) end end end # ---------- Processing sketches (processing.rb + processing.js) ---------- # The gem opens its window when the file has run (at_exit); here the cell # is the file. Its sketch starts - setup and the first frame - while the # cell still runs, so an error there is the cell's, and a live run's time # limit covers it. Each cell run starts with a fresh context. def processing? = defined?(Processing::Context) && Processing.respond_to?(:start__) def start_sketch sketch = Processing.start__ if processing? @run_sketches << sketch if sketch && @run_sketches end # The canvas is processing.js's; it asks for every further frame with the # events since the last one. A frame that fails stops the sketch, its # error below the canvas; what a frame prints goes there too. def mount_sketch(idx, out_el, sketch) node = $d.createElement("div") out_el.appendChild(node) canvas = nil canvas = $window.chunkySketch(node) do |json| next unless canvas old_stdout = $stdout $stdout = StringIO.new begin frame = sketch.step__(json.to_s) canvas.paint(frame, sketch.looping__ ? 1 : 0) rescue StandardError, ScriptError => e canvas.error("#{e.class}: #{e.message}") ensure printed = $stdout.string $stdout = old_stdout canvas.print(printed) unless printed.empty? end end (@sketches[idx] ||= []) << canvas canvas.paint(JSON.generate(sketch.takeCommands__), sketch.looping__ ? 1 : 0) end def dispose_sketches(idx = nil) keys = idx.nil? ? @sketches.keys : [idx] keys.each { |key| (@sketches.delete(key) || []).each { |canvas| canvas.stop } } Processing.reset__ if idx.nil? && processing? end # ---------- ruby2d (ruby2d.rb: its windows are games, below) ---------- def ruby2d? = defined?(Ruby2D::Page) && Ruby2D.respond_to?(:reset__) def lang = @lang # require "ruby2d" mixed Ruby2D into the top level, as the gem does; the # next lesson starts without it, and its own require mixes it in again def unmix_ruby2d return unless ruby2d? Ruby2D.unmix__ BrowserGems.loaded.delete("(shims):ruby2d.rb") end # ---------- games (show_game: game.rb + game.js) ---------- def add_game(game) @run_games << game if @run_games end # ---------- Spinel (spinel.rb here, shell/spinel.rb on the page) ---------- def add_spinel(program) @run_spinels << program if @run_spinels end def add_spinel_irb @run_spinels << :irb if @run_spinels end # The page compiles and runs it: the shell (shell/spinel.rb) draws the # widget into the element named here, its workers compile; nothing comes # back to Ruby. Plain strings only - the shell is PicoRuby. def mount_spinel(out_el, program) node = $d.createElement("div") @spinel_seq = (@spinel_seq || 0) + 1 id = "spinel-#{@spinel_seq}" node[:id] = id out_el.appendChild(node) # a page without the shell (the embedded cell, embed.html) says so if $window[:chunkySpinelMount].typeof != "function" node.className = "cell-error" node.textContent = format(ui["spinelFailed"].to_s, "shell/spinel.rb") elsif program == :irb $window.chunkySpinelIrb(id) else $window.chunkySpinelMount(id, program.source, program.output, program.error.to_s, program.ms.round(1).to_s) end end # a tick that runs longer than this stops the game (an endless loop in an # every block would freeze the page for good: no ▶ is running to blame) GAME_TICK_LIMIT = 1.0 GAME_LABELS = { "play" => "gamePlay", "keys" => "gameKeys", "paused" => "gamePaused", "again" => "gameAgain" }.freeze R2D_LABELS = { "play" => "gamePlay", "keys" => "r2dKeys", "paused" => "gamePaused", "over" => "r2dClosed", "again" => "r2dAgain" }.freeze # game.js runs the loop and calls the block once per frame at most, when a # timer is due or keys came in; the game answers with the cells that # changed. A re-run of the cell (or another lesson) stops it here, and # game.js stops by itself once its node has left the page. # # The time limit: enabling a TracePoint costs ~6 ms in the page (CRuby # re-instruments every loaded method), far more than a tick (~1 ms). So # the guard is switched on when the game starts running ("f:1", game.js: # it has the focus and is not paused) and off when it stops ("f:0") - # while a game runs nothing else in the kernel does (▶, a live run, # another lesson all take the focus away first). Each step only moves # the deadline. def mount_game(idx, out_el, game) node = $d.createElement("div") out_el.appendChild(node) # a ruby2d window (ruby2d.rb's Page::Runner) is a canvas, not a grid; # closed, it does not start again - the cell runs it anew canvas = game.respond_to?(:canvas?) && game.canvas? labels = (canvas ? R2D_LABELS : GAME_LABELS).transform_values { |key| ui[key].to_s } labels["title"] = canvas ? format(ui["r2dTitle"].to_s, game.title, game.width, game.height) : format(ui["gameTitle"].to_s, game.width, game.height) opts = { w: game.width, h: game.height, first: game.full_json, labels: labels, canvas: canvas } guard = GameGuard.new(workshop? ? Workshop.paths : [EVAL_FILE], GAME_TICK_LIMIT) controller = $window.chunkyGame(node, JSON.generate(opts)) do |now, events| events = events.to_s if events.start_with?("f:") events == "f:1" ? guard.on : guard.off next nil end begin guard.step { game.step(now.to_f, events) } rescue AutoRun::Stopped guard.off game.error_json(format(ui["gameTooLong"].to_s, GAME_TICK_LIMIT)) end end (@games[idx] ||= []) << [controller, guard] end # The time limit for a game's steps: one TracePoint, on while the game # runs; raises AutoRun::Stopped on a line of the learner's own code once a # step has run longer than +seconds+ (as AutoRun.with_time_limit does). class GameGuard def initialize(paths, seconds) @paths = paths @seconds = seconds @deadline = Float::INFINITY end def on return if @trace events = 0 @late = false # as in AutoRun.with_time_limit: the clock every 128 events, the stop # on the next event of the learner's code (sampling both at once # missed `loop { }` forever, depending on how many events came before) @trace = TracePoint.new(:line, :b_call, :c_call) do |tp| events += 1 @late = clock > @deadline if (events & 127).zero? raise AutoRun::Stopped if @late && @paths.include?(tp.path) end @trace.enable end def off @trace&.disable @trace = nil end def step @deadline = clock + @seconds @late = false yield ensure @deadline = Float::INFINITY @late = false end private def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC) end def dispose_games(idx = nil) list = idx.nil? ? @games.values.flatten(1).tap { @games.clear } : (@games.delete(idx) || []) list.each do |controller, guard| guard.off controller.stop rescue StandardError nil end end # ---------- 3D stage (three-rb + three.js) ---------- # three-rb builds the scene graph in pure Ruby and hands the drawing to # three.js, which it picks up as globalThis.THREE. index.html imports that # module lazily (window.ensureThree), so a lesson that uses show_three # kicks the import off when it opens and we only mount once it is there. def three_ready? $window.threeReady? end def preload_three $window.ensureThree end # One backend for the whole page. It caches the three.js objects it built # for each Ruby object and then only pushes what changed, so a second # backend would start from an empty cache and rebuild a scene that has no # pending changes left as bare defaults (camera back at the origin, meshes # untransformed) - a blank canvas. Renderers are per canvas, three.js # objects are not, so they can all share this one. def three_backend @three_backend ||= Three::Backends::ThreeJS.new end def add_three(scene, camera, options, animate) return unless @run_three @run_three << { scene: scene, camera: camera, animate: animate, width: (options[:width] || 460).to_i, height: (options[:height] || 320).to_i, background: options[:background] || 0x151a20, orbit: !!options[:orbit] } end def three_widget_html(tid, spec) <<~HTML
    HTML end # Each WebGL canvas holds a real GPU context and browsers only allow a # handful of them, so a cell disposes the stages it created last time # (their DOM is replaced anyway) before it mounts new ones. def dispose_three(idx = nil) keys = idx.nil? ? @three_renderers.keys : [idx] keys.each do |key| (@three_renderers.delete(key) || []).each do |renderer, controls| begin renderer.handle.setAnimationLoop(JS::Null) controls&.dispose renderer.handle.dispose rescue StandardError nil end end end end def mount_three(idx, tid, spec) canvas = $d.getElementById("three-canvas-#{tid}") return unless canvas # preserveDrawingBuffer keeps the last frame readable after compositing, # which is what lets the smoke test look at the rendered pixels renderer = Three::Renderers::ThreeJSRenderer.new( canvas: canvas, backend: three_backend, antialias: true, preserveDrawingBuffer: true ) renderer.set_clear_color(spec[:background], 1) renderer.set_size(spec[:width], spec[:height]) scene = spec[:scene] camera = spec[:camera] animate = spec[:animate] controls = nil if spec[:orbit] controls = Three::Controls::OrbitControls.new( camera, renderer: renderer, enable_damping: true, damping_factor: 0.08 ) end (@three_renderers[idx] ||= []) << [renderer, controls] # prefers-reduced-motion: an animated scene stands still at its first # frame (orbiting by hand still works - controls do not move by themselves) animate = nil if animate && $window.matchMedia("(prefers-reduced-motion: reduce)")[:matches].to_s == "true" if animate || controls frame = 0 renderer.animation_loop do frame += 1 animate.call(frame) if animate controls&.update renderer.render(scene, camera) end else renderer.render(scene, camera) end end # ---------- mini browser ---------- def add_browser(app, path) @run_browsers << { app: app, path: path.to_s } if @run_browsers end def browser_widget_html(bid, path) <<~HTML
    http://localhost
    HTML end # what the app printed while it answered - a Rails app logs every request # ("Started GET "/menu" ... Completed 200 OK") - goes into the log pane # under the page, the last MB_LOG_LINES lines of it MB_LOG_LINES = 60 def browser_log(widget, printed) return if printed.empty? log_el = widget.querySelector(".mb-log") lines = (log_el.innerText.to_s + printed).lines.last(MB_LOG_LINES) log_el.innerHTML = AnsiHtml.to_html(lines.join) log_el.removeAttribute("hidden") log_el.scrollTop = log_el.scrollHeight end # The URL bar: a GET of the path in it. def navigate_browser(widget) input = widget.querySelector(".mb-url") path = input.value path = "/" + path unless path.start_with?("/") browser_request(widget, "GET", path) end # A form sent from the page: its fields urlencoded, as a browser sends # them (FormData, with the button pressed), to the form's action with its # method - a GET as a query string, a POST as the body. def submit_browser_form(widget, form, submitter) data = submitter.nil? ? JS.global[:FormData].new(form) : JS.global[:FormData].new(form, submitter) encoded = JS.global[:URLSearchParams].new(data).toString.to_s action = form.getAttribute("action").to_s action = widget.querySelector(".mb-url").value.to_s if action.empty? if form.method.to_s.downcase == "post" browser_request(widget, "POST", action, body: encoded) else browser_request(widget, "GET", "#{action.split("?").first}#{encoded.empty? ? "" : "?#{encoded}"}") end end # One request of the fake browser: the app's answer into the page, its # status into the chrome, what it printed into the log pane. Cookies the # app sets come back with the next request (a Rails session, the flash), # and a redirect is followed, as a browser does - the URL bar then shows # where it landed. def browser_request(widget, method, path, body: nil) bid = widget.getAttribute("data-bid").to_i app = @browser_apps[bid] return unless app status_el = widget.querySelector(".mb-status") printed = +"" page = begin status = headers = response = nil 5.times do status, headers, response, output = browser_call(bid, app, method, path, body) printed << output location = headers["location"] || headers["Location"] break unless [301, 302, 303, 307, 308].include?(status) && location path = location.to_s.sub(%r{\Ahttps?://[^/]*}, "") method, body = "GET", nil unless [307, 308].include?(status) end widget.querySelector(".mb-url").value = path content_type = (headers["content-type"] || headers["Content-Type"]).to_s status_el.innerText = status.to_s status_el.className = "mb-status #{status < 400 ? 'ok' : 'err'}" content_type.empty? || content_type.include?("html") ? response : "
    #{escape_html(response)}
    " rescue Exception => e status_el.innerText = "ERR" status_el.className = "mb-status err" "
    #{escape_html("#{e.class}: #{e.message}")}
    " end browser_page(widget.querySelector(".mb-view")).innerHTML = "#{MB_PAGE_STYLE}
    #{page_styles_scoped(page)}
    " browser_log(widget, printed) end # the app called once, with the widget's cookies; what it prints caught; # the cookies it sets kept (name=value; Max-Age=0 or an expiry in the # past forgets one) def browser_call(bid, app, method, path, body) jar = (@browser_cookies ||= {})[bid] ||= {} cookie = jar.map { |name, value| "#{name}=#{value}" }.join("; ") old_stdout = $stdout $stdout = StringIO.new begin status, headers, response = RackPlayground.request( app, path, method: method, body: body, content_type: body ? "application/x-www-form-urlencoded" : nil, cookie: cookie ) ensure output = $stdout.string $stdout = old_stdout end set = headers["set-cookie"] || headers["Set-Cookie"] Array(set).flat_map { |line| line.to_s.split("\n") }.each do |line| pair, *attributes = line.split(";").map(&:strip) name, value = pair.to_s.split("=", 2) next if name.nil? || name.empty? gone = attributes.any? { |a| a =~ /\Amax-age=0\z/i || (a =~ /\Aexpires=(.*)/i && (Time.parse($1) < Time.now rescue false)) } gone || value.to_s.empty? ? jar.delete(name) : jar[name] = value end [status, headers, response, output] end # In the shadow root there is no or (the fragment parser # drops them), so a page's rules for html, :root and body would match # nothing. Their selectors are pointed at the fake browser's view (:host) # and the page (.mb-page) instead, so Sinatra's 404 page still looks like # Sinatra's 404 page - inside the fake browser. Only selectors change: the # text before each "{" that is not an @-rule, and in it not what stands in # [attribute] brackets or quotes (a[href="body"] stays). PAGE_ROOTS = { "html" => ":host", ":root" => ":host", "body" => ".mb-page" }.freeze PAGE_ROOT_SELECTOR = /\[[^\]]*\]|"[^"]*"|'[^']*'|(?]*>)(.*?)()}mi) do tag, css, close = $1, $2, $3 css = css.gsub(/(\A|[{}])([^{}@]+)\{/) do "#{$1}#{$2.gsub(PAGE_ROOT_SELECTOR) { |part| PAGE_ROOTS.fetch(part.downcase, part) }}{" end "#{tag}#{css}#{close}" end end # The app's page goes into a shadow root of .mb-view, so a HTML def browser_page(view) view.shadowRoot || view.attachShadow($window.Object.new.tap { |options| options.mode = "open" }) end # ---------- IRB terminal widget ---------- def add_irb @run_irbs << true if @run_irbs end def irb_prompt(session) # a PicoRuby IRB (the PicoRuby lesson) has a prompt of its own, so it # does not pass for CRuby's return session[:buffer].empty? ? "irb>" : "irb*" if session[:pico] depth = session[:buffer].empty? ? 0 : session[:buffer].lines.length format("irb(main):%03d:%d%s", session[:line], depth, depth.positive? ? "*" : ">") end def irb_widget_html(sid) <<~HTML
    HTML end # incomplete expressions (open def/do/string) get a continuation prompt INCOMPLETE_RE = /unexpected end-of-input|expected an? `?end`?|unterminated string|unterminated regexp|expects an expression after/i def irb_submit(term) session = @irb_sessions[term.getAttribute("data-sid").to_i] return unless session input_el = term.querySelector(".irb-input") history = term.querySelector(".irb-history") line = input_el.value input_el.value = "" append = "
    #{escape_html(irb_prompt(session))} #{escape_html(line)}
    " if line.strip == "exit" || line.strip == "quit" session[:buffer] = "" append += "
    #{ui["irbExitNote"]}
    " elsif session[:pico] return picoruby_irb_line(term, session, line, append) else session[:buffer] = session[:buffer].empty? ? line : session[:buffer] + "\n" + line old_stdout = $stdout buffer = StringIO.new $stdout = buffer result = nil error = nil incomplete = false begin result = eval(session[:buffer], session[:bind], "irb") rescue SyntaxError => e if e.message =~ INCOMPLETE_RE incomplete = true else error = e end rescue Exception => e error = e ensure $stdout = old_stdout end unless incomplete session[:buffer] = "" session[:line] += 1 append += "
    #{AnsiHtml.to_html(buffer.string)}
    " unless buffer.string.empty? if error append += "
    #{escape_html("#{error.class}: #{error.message.lines.first.to_s.strip}")}
    " append += suggestion_html(error, "irb-suggest") else session[:bind].local_variable_set(:_, result) append += "
    => #{escape_html(inspect_result(result))}
    " end end end history.innerHTML += append term.querySelector(".irb-prompt").innerText = irb_prompt(session) history.scrollTop = history.scrollHeight input_el.focus end # ---------- PicoRuby (a lesson with "engine": "picoruby") ---------- # The lesson's cells and IRBs run on PicoRuby.wasm in a Web Worker # (picoruby_lab.js): a run goes out and settles when the answer comes # back, as a chunky:picoruby event - picoruby_answer shows it, checks an # exercise and tells the shell (ran), as finish_cell_run does for CRuby. # Code that does not parse never goes out: CRuby's Prism says why, with # the line - PicoRuby's compiler would only say no. def picoruby? = !workshop? && current_lesson && current_lesson["engine"] == "picoruby" # a lesson with "spinel": true (lesson 45): every cell's code is a program # for Spinel (spinel.rb's cell_source), no heredoc to write def spinel_lesson? = !workshop? && current_lesson && current_lesson["spinel"] == true def start_picoruby_run(idx, code, auto) $window.clearCellMarks(idx) syntax = PicoRubyCells.syntax_error(code, EVAL_FILE) return show_picoruby_run(idx, code, auto, error: syntax) if syntax @picoruby_codes ||= {} @picoruby_codes[idx] = code (@pico_live ||= {}).delete(idx) cell = cells[idx] || {} tabs = cell["tabs"] ? cell_tabs(idx) : "[]" board = pico_board_json(cell, true) $window.chunkyPicoBoard.mount(idx.to_s, board, pico_bench_ui) if board != "" && $window[:chunkyPicoBoard].typeof == "object" $window.chunkyPicoRuby.run(idx.to_s, code, auto, @seq.to_s, tabs, board) :pending end # A Pico lesson's cell (docs/HANDOVER.md §6r): its tabs as the editors # hold them ([{name, code, readonly}], index.html's getCellTabs) - the # editable ones are kept, as the main file is (the shell's save_tab) def cell_tabs(idx) json = $window.getCellTabs(idx.to_s).to_s unless workshop? JSON.parse(json).each do |tab| next if tab["readonly"] $window.chunkySaveTab(idx.to_s, tab["name"].to_s, tab["code"].to_s) if $window[:chunkySaveTab].typeof == "function" end end json rescue JSON::ParserError "[]" end # the board a cell runs on, as the worker's simulator takes it: the # lesson's Pico, the cell's parts and bench, and whether the run resets # the board (a cell's run does, a line in an IRB below it does not); "" # for a cell without one # the bench's strings (pico_board.js): the ui keys board*, this language def pico_bench_ui (@pico_bench_ui ||= {})[@lang] ||= JSON.generate(ui.select { |key, _| key.start_with?("board") }) end def pico_board_json(cell, reset) board = cell["board"] return "" unless board.is_a?(Hash) JSON.generate({ "board" => (current_lesson["pico"] || {})["board"] || "pico2w", "parts" => board["parts"] || [], "bench" => reset ? (cell["bench"] || []) : [], "reset" => reset }) end # the CRuby cell that runs now in a Pico lesson (pico_laptop.rb's shim) def laptop_cell__ = @laptop_cell # A line the simulated Pico sent over BLE: each notification block of the # laptop's cell runs, and what it prints goes below that cell's output def pico_laptop_notify(text) return unless defined?(PicoLaptop) && picoruby? PicoLaptop.deliver(text) do |idx, printed| next if idx.nil? || printed.empty? out_el = $d.getElementById("cell-out-#{idx}") next if out_el.nil? || out_el.to_s == "null" pre = out_el.querySelector("pre.pico-notified") if pre.nil? || pre.to_s == "null" out_el.insertAdjacentHTML("beforeend", "
    ")
            out_el.style.display = "block"
            pre = out_el.querySelector("pre.pico-notified")
          end
          pre.innerHTML = pre.innerHTML.to_s + AnsiHtml.to_html(printed)
        end
      end
    
      # what a run printed so far, below the cell (picoruby_lab.js's progress)
      def picoruby_progress(detail)
        return unless detail.seq.to_i == @seq
    
        @pico_live ||= {}
        if detail.kind.to_s == "irb"
          # a board IRB line runs up to 30 s: what it prints shows as it comes
          term = $d.querySelector(".irb-term[data-sid='#{detail.sid.to_i}']")
          return unless term
    
          history = term.querySelector(".irb-history")
          key = "irb#{detail.sid.to_i}"
          live = history.querySelector("pre.pico-live")
          unless @pico_live[key] && live
            history.insertAdjacentHTML("beforeend", "
    ")
            live = history.querySelector("pre.pico-live")
            @pico_live[key] = { style: {}, rest: "" }
          end
          live.insertAdjacentHTML("beforeend", pico_stream_html(@pico_live[key], detail.output.to_s))
          history.scrollTop = history.scrollHeight
          return
        end
        return unless detail.kind.to_s == "cell"
    
        idx = detail.idx.to_i
        out_el = $d.getElementById("cell-out-#{idx}")
        unless @pico_live[idx]
          # the first output of this run replaces the last run's
          out_el.innerHTML = "
    "
          out_el.style.display = "block"
          @pico_live[idx] = { style: {}, rest: "" }
        end
        live = out_el.querySelector("pre.pico-live")
        # appended, not innerHTML += (that parses the whole output again)
        live.insertAdjacentHTML("beforeend", pico_stream_html(@pico_live[idx], detail.output.to_s))
      end
    
      # one chunk of a run's output as HTML: the colour state goes on from the
      # last chunk, and an escape code cut at the end waits for the next one
      def pico_stream_html(state, text)
        text = state[:rest] + text
        cut = text =~ /\e(\[[0-9;:?]*)?\z/
        state[:rest] = cut ? text[cut..] : ""
        text = text[0...cut] if cut
        AnsiHtml.to_html(text, state[:style])
      end
    
      def picoruby_answer(detail)
        return unless detail.seq.to_i == @seq   # another page by now
    
        return picoruby_irb_answer(detail) if detail.kind == "irb"
    
        idx = detail.idx.to_i
        auto = detail.auto == true
        (@pico_live ||= {}).delete(idx)
        code = (@picoruby_codes || {}).delete(idx) || $window.getCellCode(idx).to_s
        status = detail.status.to_s
        message = detail.message.to_s
        # an error in a tab (a Pico lesson's driver) is marked there, not in
        # the cell's own code
        file = detail.file.to_s
        line = file.empty? ? detail.line.to_i : 0
        error = case status
                when "error" then PicoRubyCells.error(detail.errorClass.to_s, message, line, EVAL_FILE)
                when "syntax" then SyntaxError.new(ui["picoSyntax"])
                when "stopped" then auto ? AutoRun::Stopped.new : PicoRubyCells::Stopped.new("#{ui['picoStopped']} #{ui['picoRestarted']}")
                when "reset" then PicoRubyCells::Stopped.new("#{ui['boardReset']} #{ui['picoRestarted']}")
                when "failed" then PicoRubyCells::Unavailable.new("#{ui['picoFailed']} (#{message})")
                end
        $window.markCellTabLine(idx.to_s, file, detail.line.to_i.to_s) if status == "error" && !file.empty? && !auto
        outcome = begin
          show_picoruby_run(idx, code, auto, output: detail.output.to_s, error: error, irbs: detail.irbs.to_i,
                            result: status == "ok" ? PicoRubyCells.value(detail.value.to_s) : nil,
                            board: PicoRubyCells.board(detail.board.to_s))
        rescue Exception => e
          $window.console.error("picoruby #{idx}: #{e.class}: #{e.message}")
          :error
        end
        elapsed = detail.elapsed.to_f
        bridge.ran(idx, outcome.to_s, elapsed, auto, elapsed)
      end
    
      # the cell's output as run_cell writes it for CRuby: what was printed,
      # the error (explained where a rule knows it) or "=> value", IRBs
      def show_picoruby_run(idx, code, auto, output: "", result: nil, error: nil, irbs: 0, board: nil, shown: "", out_class: "")
        # what an exercise's check sees besides output and result: nothing
        @run_images = []
        @run_downloads = []
        @run_three = []
        @run_shoes = []
        @last_shoes_types = []
        @run_sketches = []
        @run_audios = []
        @run_games = []
        out_html = ""
        out_html += "
    #{AnsiHtml.to_html(output)}
    " unless output.empty? # the pictures an IronRuby cell showed (ironruby_cells.rb) out_html += shown hint = live_hint(error) # a run past the time limit took the worker, and the variables, with it hint = "#{hint} #{ui['picoRestarted']}" if error.is_a?(AutoRun::Stopped) hint ||= error.message if error.is_a?(PicoRubyCells::Stopped) || error.is_a?(PicoRubyCells::Unavailable) friendly = error && !hint && friendly_error(error, code, EVAL_FILE) if hint out_html += "
    #{escape_html(hint)}
    " elsif friendly out_html += friendly.to_html(brief: auto) elsif error out_html += "
    #{escape_html(PicoRubyCells.class_name(error))}: " \ "#{AnsiHtml.to_html(error.message.to_s)}
    " elsif !(result.nil? && (!output.empty? || irbs.positive? || !shown.empty?)) out_html += "
    => #{escape_html(inspect_result(result))}
    " end irbs.times do sid = @irb_sessions.length # a line in it runs on the cell's board, without resetting it @irb_sessions << { pico: true, line: 1, buffer: "", board: pico_board_json(cells[idx] || {}, false) } out_html += irb_widget_html(sid) end out_el = $d.getElementById("cell-out-#{idx}") out_el.innerHTML = out_html out_el.style.display = "block" out_el.classList.toggle("is-rehearsal", auto) if error return :stopped if error.is_a?(AutoRun::Stopped) line = !auto && error_line(error) $window.markCellLine(idx, line) if line return :error end cell = cells[idx] return :ok unless cell && cell["t"] == "x" check_exercise(cell, code, output, result, board: board) ? :pass : :fail end # A line in a PicoRuby IRB: an unfinished input waits for more (Prism # decides, as for CRuby's IRB), a finished one goes to the worker; the # answer is added below the echo by picoruby_irb_answer. def picoruby_irb_line(term, session, line, append) sid = term.getAttribute("data-sid").to_i history = term.querySelector(".irb-history") session[:buffer] = session[:buffer].empty? ? line : session[:buffer] + "\n" + line syntax = PicoRubyCells.syntax_error(session[:buffer], "(irb)") if syntax && syntax.message =~ INCOMPLETE_RE # the prompt changes below elsif syntax session[:buffer] = "" append += "
    #{escape_html("SyntaxError: #{syntax.message.lines.first.to_s.strip}")}
    " else $window.chunkyPicoRuby.irb(sid.to_s, session[:buffer], @seq.to_s, session[:board] || "") session[:buffer] = "" end history.innerHTML += append term.querySelector(".irb-prompt").innerText = irb_prompt(session) history.scrollTop = history.scrollHeight term.querySelector(".irb-input").focus end def picoruby_irb_answer(detail) term = $d.querySelector(".irb-term[data-sid='#{detail.sid.to_i}']") return unless term history = term.querySelector(".irb-history") # what streamed in while it ran: the answer brings all of it (@pico_live ||= {}).delete("irb#{detail.sid.to_i}") live = history.querySelector("pre.pico-live") live.remove if live output = detail.output.to_s append = output.empty? ? "" : "
    #{AnsiHtml.to_html(output)}
    " append += case detail.status.to_s when "ok" then "
    => #{escape_html(inspect_result(PicoRubyCells.value(detail.value.to_s)))}
    " when "error" then "
    #{escape_html("#{detail.errorClass}: #{detail.message}")}
    " when "stopped" then "
    #{escape_html("#{ui['picoStopped']} #{ui['picoRestarted']}")}
    " when "reset" then "
    #{escape_html("#{ui['boardReset']} #{ui['picoRestarted']}")}
    " else "
    #{escape_html(ui['picoFailed'])}
    " end history.innerHTML += append history.scrollTop = history.scrollHeight end # ---------- JRuby (a lesson with "engine": "jruby") ---------- # The lesson's cells run on JRuby 10.1 (TeaVM) in a Web Worker # (jruby_lab.js), as the PicoRuby lesson's run on PicoRuby: a run goes out # and settles when the answer comes back, as a chunky:jruby event. Unlike # PicoRuby's, the cells share one binding in JRuby, so the cells above # that have not run there yet go along and run first, quietly (as # earlier_cells.rb's do in CRuby). A cell with "on": "cruby" runs on the # page's ruby.wasm instead, and the exercise's check is CRuby's too, on the # same data - where it passes, CRuby runs the learner's code as well and # says whether both Rubies agree. No live runs ("live": false): JRuby can # only be stopped by ending its worker, and a new one takes 3 s to start. def jruby? = !workshop? && current_lesson && current_lesson["engine"] == "jruby" # the Ruboto lesson: JRuby with a simulated phone (docs/HANDOVER.md §6u). A # cell that defines an activity starts the app on it (html/ruboto_phone.js # draws it below the cell); the exercise's "probe" taps through the app in # JRuby, and its check reads the probe's inspect as `probe`, as the # IronRuby lesson's does. Nothing here runs on CRuby: Android is JRuby's. def android? = jruby? && current_lesson["android"] == true def start_jruby_run(idx, code) $window.clearCellMarks(idx) syntax = JRubyCells.syntax_error(code, EVAL_FILE) return show_jruby_run(idx, code, error: syntax) if syntax # the cells above that the worker's binding has not run, CRuby's left out jruby_only = cells.map { |cell| cell["on"] == "cruby" ? { "t" => "h" } : cell } earlier = EarlierCells.before(idx, jruby_only, @jruby_ran) { |i| $window.getCellCode(i).to_s } @jruby_pending ||= {} @jruby_pending[idx] = { code: code, earlier: earlier.to_h } probe = android? ? (cells[idx] || {})["probe"].to_s : "" $window.chunkyJRuby.run(idx.to_s, code, JSON.generate(earlier), @seq.to_s, probe, android?) :pending end def jruby_answer(detail) return unless detail.seq.to_i == @seq # another page by now idx = detail.idx.to_i pending = (@jruby_pending || {}).delete(idx) || { code: $window.getCellCode(idx).to_s, earlier: {} } code = pending[:code] status = detail.status.to_s message = detail.message.to_s failed = detail.earlierFailed.to_s.split(",").map(&:to_i) ran = detail.earlier.to_s.split(",").map(&:to_i) if %w[ok error].include?(status) (ran - failed).each { |i| @jruby_ran[i] = pending[:earlier][i] if pending[:earlier][i] } @jruby_ran[idx] = code if status == "ok" else @jruby_ran = {} # a new worker: nothing has run in it end error = case status when "error" then JRubyCells.error(detail.errorClass.to_s, message, detail.line.to_i, EVAL_FILE) when "stopped" then PicoRubyCells::Stopped.new("#{ui['jrubyStopped']} #{ui['jrubyRestarted']}") when "failed" then PicoRubyCells::Unavailable.new("#{ui['jrubyFailed']} (#{message})") end elapsed = detail.elapsed.to_f probe = detail.probe.to_s # a stopped worker took the phone with it $window.chunkyRubotoPhone.stale("stopped") if android? && %w[stopped failed].include?(status) outcome = begin show_jruby_run(idx, code, output: detail.output.to_s, error: error, elapsed: elapsed, result: status == "ok" ? JRubyCells.value(detail.value.to_s) : nil, probe: probe.empty? ? nil : JRubyCells.value(probe), phone: detail.phone.to_s) rescue Exception => e $window.console.error("jruby #{idx}: #{e.class}: #{e.message}") :error end bridge.ran(idx, outcome.to_s, elapsed, false, elapsed, ran.join(","), failed.join(","), "") end def show_jruby_run(idx, code, output: "", result: nil, error: nil, elapsed: nil, probe: nil, phone: "") cell = cells[idx] # the check reads the data in CRuby (JRubyCells.load_data) JRubyCells.load_data if cell && cell["t"] == "x" && !error && !android? # what the Ruboto lesson's check reads besides output, result and code @bind.local_variable_set(:probe, probe) if android? && cell && cell["t"] == "x" outcome = show_picoruby_run(idx, code, false, output: output, result: result, error: error) # the app this cell started, on the phone below it $window.chunkyRubotoPhone.mount(idx, phone) if android? && !phone.empty? out_el = $d.getElementById("cell-out-#{idx}") # a QR code of half blocks (█▀▄): drawn as square pixels above the text, # which stays (hidden) for copying - as text, its rows depend on the font # and a phone may not read it (JRubyCells.blocks_svg); any other output # with blocks gets lines with no gaps between them if output.match?(/[█▀▄]/) drawing = JRubyCells.blocks_svg(output, ui["jrubyBlocksAlt"]) pre = drawing ? %(#{drawing}