diff --git a/README.md b/README.md index 69392a6..5fe232c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Install the gem and add to the application's Gemfile by executing: $ bundle add scarpe-wasm -If bundler is not being used to manage dependencies, install the gem by executing: +If bundler is not being used to manage dependencies (NOT RECOMMENDED), install the gem by executing: $ gem install scarpe-wasm diff --git a/lib/scarpe/wasm.rb b/lib/scarpe/wasm.rb index dd925cf..cf7a0aa 100644 --- a/lib/scarpe/wasm.rb +++ b/lib/scarpe/wasm.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -# Scarpe WASM Display Service +# Scarpe Wasm Display Service # This file should be required on the Wasm side, not the Ruby side. -# So it's used to link to JS, and to instantiate widgets, but not +# So it's used to link to JS, and to instantiate drawables, but not # for e.g. packaging. require_relative "wasm/version" @@ -12,20 +12,12 @@ require_relative "wasm/web_wrangler" require_relative "wasm/control_interface" -require_relative "wasm/widget" +require_relative "wasm/drawable" require_relative "wasm/wasm_local_display" -require_relative "wasm/dimensions" -require_relative "wasm/html" - -require_relative "wasm/spacing" -require_relative "wasm/star" require_relative "wasm/radio" -require_relative "wasm/background" -require_relative "wasm/border" -require_relative "wasm/arc" -require_relative "wasm/font" +require_relative "wasm/art_drawables" require_relative "wasm/app" require_relative "wasm/para" @@ -39,12 +31,10 @@ require_relative "wasm/edit_box" require_relative "wasm/edit_line" require_relative "wasm/list_box" -require_relative "wasm/alert" require_relative "wasm/span" require_relative "wasm/shape" -require_relative "wasm/text_widget" +require_relative "wasm/text_drawable" require_relative "wasm/link" -require_relative "wasm/line" require_relative "wasm/video" require_relative "wasm/check" diff --git a/lib/scarpe/wasm/alert.rb b/lib/scarpe/wasm/alert.rb deleted file mode 100644 index bbfd3df..0000000 --- a/lib/scarpe/wasm/alert.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class WASMAlert < WASMWidget - def initialize(properties) - super - - bind("click") do - send_self_event(event_name: "click") - end - end - - def element - onclick = handler_js_code("click") - - HTML.render do |h| - h.div(id: html_id, style: overlay_style) do - h.div(style: modal_style) do - h.div(style: text_style) { @text } - h.button(style: button_style, onclick: onclick) { "OK" } - end - end - end - end - - protected - - # If the whole widget is hidden, the parent style adds display:none - def overlay_style - { - position: "fixed", - top: "0", - left: "0", - width: "100%", - height: "100%", - overflow: "auto", - "z-index": "1", - background: "rgba(0,0,0,0.4)", - display: "flex", - "align-items": "center", - "justify-content": "center", - }.merge(style) - end - - def modal_style - { - "min-width": "200px", - "min-height": "50px", - padding: "10px", - display: "flex", - background: "#fefefe", - "flex-direction": "column", - "justify-content": "space-between", - "border-radius": "9px", - } - end - - def text_style - {} - end - - def button_style - {} - end - end -end diff --git a/lib/scarpe/wasm/app.rb b/lib/scarpe/wasm/app.rb index 6fba27b..1e7426e 100644 --- a/lib/scarpe/wasm/app.rb +++ b/lib/scarpe/wasm/app.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -class Scarpe - # Scarpe::WASMApp must only be used from the main thread, due to GTK+ limitations. - class WASMApp < WASMWidget +module Scarpe::Wasm + # App must only be used from the main thread, due to GTK+ limitations. + class App < Drawable attr_reader :control_interface attr_writer :shoes_linkable_id @@ -10,22 +10,10 @@ class WASMApp < WASMWidget def initialize(properties) super - # It's possible to provide a Ruby script by setting - # SCARPE_TEST_CONTROL to its file path. This can - # allow pre-setting test options or otherwise - # performing additional actions not written into - # the Shoes app itself. - # - # The control interface is what lets these files see - # events, specify overrides and so on. @control_interface = ControlInterface.new - if ENV["SCARPE_TEST_CONTROL"] - require "scarpe/components/unit_test_helpers" - @control_interface.instance_eval File.read(ENV["SCARPE_TEST_CONTROL"]) - end # TODO: rename @view - @view = Scarpe::WebWrangler.new title: @title, + @view = Scarpe::Wasm::WebWrangler.new title: @title, width: @width, height: @height, resizable: @resizable @@ -62,12 +50,10 @@ def init def run @control_interface.dispatch_event(:init) - send_shoes_event("return", event_name: "custom_event_loop") - # This takes control of the main thread and never returns. And it *must* be run from - # the main thread. And it stops any Ruby background threads. - # That's totally cool and normal, right? + @view.empty_page = empty_page_element + @view.run end @@ -82,24 +68,28 @@ def destroy end end - # All JS callbacks to Scarpe widgets are dispatched + # All JS callbacks to Scarpe drawables are dispatched # via this handler def handle_callback(name, *args) - @callbacks[name].call(*args) + if @callbacks.key?(name) + @callbacks[name].call(*args) + else + raise Scarpe::UnknownEventTypeError, "No such Wasm callback: #{name.inspect}!" + end end # Bind a Scarpe callback name; see handle_callback above. - # See Scarpe::Widget for how the naming is set up + # See {Drawable} for how the naming is set up def bind(name, &block) @callbacks[name] = block end - # Request a full redraw if WASM is running. Otherwise + # Request a full redraw if Wasm is running. Otherwise # this is a no-op. # # @return [void] def request_redraw! - wrangler = WASMDisplayService.instance.wrangler + wrangler = DisplayService.instance.wrangler if wrangler.is_running wrangler.replace(@document_root.to_html) end diff --git a/lib/scarpe/wasm/arc.rb b/lib/scarpe/wasm/arc.rb deleted file mode 100644 index 5a3a5f1..0000000 --- a/lib/scarpe/wasm/arc.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class WASMArc < Scarpe::WASMWidget - def initialize(properties) - super(properties) - end - - def element(&block) - HTML.render do |h| - h.div(id: html_id, style: style) do - h.svg(width: @width, height: @height) do - h.path(d: arc_path) - end - block.call(h) if block_given? - end - end - end - - protected - - def style - super.merge({ - left: "#{@left}px", - top: "#{@top}px", - width: "#{@width}px", - height: "#{@height}px", - }) - end - - private - - def arc_path - center_x = @width / 2 - center_y = @height / 2 - radius_x = @width / 2 - radius_y = @height / 2 - start_angle_degrees = radians_to_degrees(@angle1) % 360 - end_angle_degrees = radians_to_degrees(@angle2) % 360 - large_arc_flag = (end_angle_degrees - start_angle_degrees) % 360 > 180 ? 1 : 0 - - "M#{center_x} #{center_y} L#{@width} #{center_y} " \ - "A#{radius_x} #{radius_y} 0 #{large_arc_flag} 0 " \ - "#{center_x + radius_x * Math.cos(degrees_to_radians(end_angle_degrees))} " \ - "#{center_y + radius_y * Math.sin(degrees_to_radians(end_angle_degrees))} Z" - end - - def degrees_to_radians(degrees) - degrees * Math::PI / 180 - end - - def radians_to_degrees(radians) - radians * (180.0 / Math::PI) - end - end -end diff --git a/lib/scarpe/wasm/art_drawables.rb b/lib/scarpe/wasm/art_drawables.rb new file mode 100644 index 0000000..38ffa77 --- /dev/null +++ b/lib/scarpe/wasm/art_drawables.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Scarpe::Wasm + class Arc < Drawable + def element(&block) + render("arc") + end + end + + class Arrow < Drawable + def element(&block) + render("arrow") + end + end + + class Line < Drawable + def element(&block) + render("line") + end + end + + class Rect < Drawable + def element(&block) + render("rect") + end + end + + class Star < Drawable + def element(&block) + render("star", &block) + end + end +end diff --git a/lib/scarpe/wasm/background.rb b/lib/scarpe/wasm/background.rb deleted file mode 100644 index c1770f7..0000000 --- a/lib/scarpe/wasm/background.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require "scarpe/components/base64" - -class Scarpe - module WASMBackground - include Components::Base64 - - def style - styles = (super if defined?(super)) || {} - return styles unless @background_color - - color = case @background_color - when Array - "rgba(#{@background_color.join(", ")})" - when Range - "linear-gradient(45deg, #{@background_color.first}, #{@background_color.last})" - when ->(value) { File.exist?(value) } - "url(data:image/png;base64,#{encode_file_to_base64(@background_color)})" - else - @background_color - end - - styles.merge(background: color) - end - end -end diff --git a/lib/scarpe/wasm/border.rb b/lib/scarpe/wasm/border.rb deleted file mode 100644 index 54e8a2d..0000000 --- a/lib/scarpe/wasm/border.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - module WASMBorder - def style - styles = (super if defined?(super)) || {} - return styles unless @border_color - - border_color = case @border_color - when Range - { "border-image": "linear-gradient(45deg, #{@border_color.first}, #{@border_color.last})" } - when Array - { "border-color": "rgba(#{@border_color.join(", ")})" } - else - { "border-color": @border_color } - end - styles.merge( - "border-style": "solid", - "border-width": "#{@options[:strokewidth] || 1}px", - "border-radius": "#{@options[:curve] || 0}px", - ).merge(border_color) - end - end -end diff --git a/lib/scarpe/wasm/button.rb b/lib/scarpe/wasm/button.rb index e921b31..04b8b32 100644 --- a/lib/scarpe/wasm/button.rb +++ b/lib/scarpe/wasm/button.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMButton < WASMWidget +module Scarpe::Wasm + class Button < Drawable def initialize(properties) super @@ -10,43 +10,14 @@ def initialize(properties) # This will be sent to the bind_self_event in Button send_self_event(event_name: "click") end - end - def element - HTML.render do |h| - h.button(id: html_id, onclick: handler_js_code("click"), style: style) do - @text - end + bind("hover") do + send_self_event(event_name: "hover") end end - protected - - def style - styles = super - - styles[:"background-color"] = @color - styles[:"padding-top"] = @padding_top - styles[:"padding-bottom"] = @padding_bottom - styles[:color] = @text_color - styles[:width] = Dimensions.length(@width) if @width - styles[:height] = Dimensions.length(@height) if @height - styles[:"font-size"] = @font_size - - styles[:top] = Dimensions.length(@top) if @top - styles[:left] = Dimensions.length(@left) if @left - styles[:position] = "absolute" if @top || @left - styles[:"font-size"] = Dimensions.length(font_size) if @size - styles[:"font-family"] = @font if @font - styles[:color] = rgb_to_hex(@stroke) if @stroke - - styles - end - - def font_size - font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size - - Dimensions.length(font_size) + def element + render("button") end end end diff --git a/lib/scarpe/wasm/check.rb b/lib/scarpe/wasm/check.rb index 8e0a633..be97d8a 100644 --- a/lib/scarpe/wasm/check.rb +++ b/lib/scarpe/wasm/check.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMCheck < Scarpe::WASMWidget +module Scarpe::Wasm + class Check < Drawable attr_reader :text def initialize(properties) @@ -21,9 +21,7 @@ def properties_changed(changes) end def element - HTML.render do |h| - h.input(type: :checkbox, id: html_id, onclick: handler_js_code("click"), value: "hmm #{text}", checked: @checked, style:) - end + render("check") end end end diff --git a/lib/scarpe/wasm/control_interface.rb b/lib/scarpe/wasm/control_interface.rb index bcb812f..ce88668 100644 --- a/lib/scarpe/wasm/control_interface.rb +++ b/lib/scarpe/wasm/control_interface.rb @@ -10,19 +10,22 @@ # And if you depend on this from the framework, I'll add a check-mode that # never dispatches any events to any handlers. Do NOT test me on this. -class Scarpe +module Scarpe::Wasm class ControlInterface include Shoes::Log SUBSCRIBE_EVENTS = [:init, :shutdown, :next_redraw, :every_redraw, :next_heartbeat, :every_heartbeat] DISPATCH_EVENTS = [:init, :shutdown, :redraw, :heartbeat] + INVALID_SYSTEM_COMPONENTS_MESSAGE = "Must pass non-nil app and wrangler to ControlInterface#set_system_components!" + CONTROL_INTERFACE_INIT_MESSAGE = "ControlInterface code needs to be wrapped in handlers like on_event(:init) " + + "to make sure they have access to app, doc_root, wrangler, etc!" attr_writer :doc_root attr_reader :do_shutdown # The control interface needs to see major system components to hook into their events def initialize - log_init("WV::ControlInterface") + log_init("Wasm::ControlInterface") @do_shutdown = false @event_handlers = {} @@ -35,11 +38,15 @@ def inspect # This should get called once, from Shoes::App def set_system_components(app:, doc_root:, wrangler:) - unless app && wrangler - @log.error("False app passed to set_system_components!") unless app - @log.error("False wrangler passed to set_system_components!") unless wrangler - raise "Must pass non-nil app and wrangler to ControlInterface#set_system_components!" + unless app + @log.error("False app passed to set_system_components!") + raise Scarpe::MissingAppError, INVALID_SYSTEM_COMPONENTS_MESSAGE end + unless wrangler + @log.error("False wrangler passed to set_system_components!") + raise Scarpe::MissingWranglerError, INVALID_SYSTEM_COMPONENTS_MESSAGE + end + @app = app @doc_root = doc_root # May be nil at this point @wrangler = wrangler @@ -50,28 +57,19 @@ def set_system_components(app:, doc_root:, wrangler:) end def app - unless @app - raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " + - "to make sure they have access to app, doc_root, wrangler, etc!" - end + raise Scarpe::MissingAppError, CONTROL_INTERFACE_INIT_MESSAGE unless @app @app end def doc_root - unless @doc_root - raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " + - "to make sure they have access to app, doc_root, wrangler, etc!" - end + raise Scarpe::MissingDocRootError, CONTROL_INTERFACE_INIT_MESSAGE unless @doc_root @doc_root end def wrangler - unless @wrangler - raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " + - "to make sure they have access to app, doc_root, wrangler, etc!" - end + raise Scarpe::MissingWranglerError, CONTROL_INTERFACE_INIT_MESSAGE unless @wrangler @wrangler end @@ -82,7 +80,7 @@ def wrangler # On recognised events, this sets a handler for that event def on_event(event, &block) unless SUBSCRIBE_EVENTS.include?(event) - raise "Illegal subscribe to event #{event.inspect}! Valid values are: #{SUBSCRIBE_EVENTS.inspect}" + raise Scarpe::IllegalSubscribeEventError, "Illegal subscribe to event #{event.inspect}! Valid values are: #{SUBSCRIBE_EVENTS.inspect}" end @unsub_id ||= 0 @@ -97,7 +95,7 @@ def dispatch_event(event, *args, **keywords) @log.debug("CTL event #{event.inspect} #{args.inspect} #{keywords.inspect}") unless DISPATCH_EVENTS.include?(event) - raise "Illegal dispatch of event #{event.inspect}! Valid values are: #{DISPATCH_EVENTS.inspect}" + raise Scarpe::IllegalDispatchEventError, "Illegal dispatch of event #{event.inspect}! Valid values are: #{DISPATCH_EVENTS.inspect}" end if @do_shutdown diff --git a/lib/scarpe/wasm/control_interface_test.rb b/lib/scarpe/wasm/control_interface_test.rb deleted file mode 100644 index 13914d5..0000000 --- a/lib/scarpe/wasm/control_interface_test.rb +++ /dev/null @@ -1,234 +0,0 @@ -# frozen_string_literal: true - -# The ControlInterface doesn't, by default, include much of a test framework. -# But writing a test framework in heredocs in test_helper.rb seems bad. -# So we write a test framework here, but don't include it by default. -# A running shoes app won't normally include it, but unit tests will. - -require "json" - -class Scarpe - DEFAULT_ASSERTION_TIMEOUT = 1.0 - - class ControlInterface - include Scarpe::Test::Helpers - - def timed_out? - @did_time_out - end - - def die_after(time) - t_start = Time.now - @die_after = [t_start, time] - - wrangler.periodic_code("scarpeTestTimeout") do |*_args| - t_delta = (Time.now - t_start).to_f - if t_delta > time - @did_time_out = true - @log.warn("die_after - timed out after #{t_delta.inspect} (threshold: #{time.inspect})") - return_results(false, "Timed out!") - app.destroy - end - end - end - - # This is returned alongside the actual results automatically - def test_metadata - data = {} - if @die_after - t_delta = (Time.now - @die_after[0]).to_f - data["die_after"] = { - t_start: @die_after[0].to_s, - threshold: @die_after[1], - passed: t_delta, - } - end - data - end - - # Need to be able to query widgets in test code - - def all_wv_widgets - known = [doc_root] - to_check = [doc_root] - - until to_check.empty? - next_layer = to_check.flat_map(&:children) - known += next_layer - to_check = next_layer - end - - # I don't *think* we'll ever have widget trees that merge back together, but just in case we'll de-dup - known.uniq - end - - # Shoes doesn't name widgets. We aren't guaranteed that the Shoes widgets are even in the same - # process, since we have the Relay display service for Webview. So mostly we can look by - # display service class. - def find_wv_widgets(*specifiers) - widgets = all_wv_widgets - - specifiers.each do |spec| - if spec.is_a?(Class) - widgets.select! { |w| spec === w } - else - raise "I don't know how to search for widgets by #{spec.inspect}!" - end - end - - widgets - end - - # We want an assertions library, but one that runs inside the spawned - # Webview sub-process. - - # Note that we do *not* extract this assertions library to use elsewhere - # because it's very focused on evented assertions that start and stop - # over a period of time. Instantaneous procedural asserts don't want to - # use this API. - - def return_when_assertions_done - assertions_may_exist - - wrangler.periodic_code("scarpeReturnWhenAssertionsDone") do |*_args| - if @assertions_pending.empty? - success = @assertions_failed.empty? - return_results success, "Assertions #{success ? "succeeded" : "failed"}", assertion_data_as_a_struct - app.destroy - end - end - end - - def assertions_may_exist - @assertions_pending ||= {} - @assertions_failed ||= {} - @assertions_passed ||= 0 - @assertion_counter ||= 0 - end - - def start_assertion(code) - assertions_may_exist - - this_assertion = @assertion_counter - @assertion_counter += 1 - - @assertions_pending[this_assertion] = { - id: this_assertion, - code: code, - } - - this_assertion - end - - def pass_assertion(id) - @assertions_pending.delete(id) - @assertions_passed += 1 - end - - def fail_assertion(id, fail_message) - item = @assertions_pending.delete(id) - item[:fail_message] = fail_message - @assertions_failed[id] = item - end - - def assertions_pending? - !@assertions_pending.empty? - end - - def assertion_data_as_a_struct - { - still_pending: @assertions_pending.size, - succeeded: @assertions_passed, - failed: @assertions_failed.size, - failures: @assertions_failed.values.map { |item| [item[:code], item[:failure_reason]] }, - } - end - - # Create a promise to do a JS assertion, normally after other ops have finished. - def assert_js(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT) - id = start_assertion(js_code) - - # this isn't a TestPromise, so it doesn't have the additional DSL entries - promise = wrangler.eval_js_async(js_code, wait_for: wait_for, timeout: timeout) - promise.on_rejected do - fail_assertion(id, "JS Eval failed: #{promise.reason.inspect}") - end - promise.on_fulfilled do - ret_val = promise.returned_value - if ret_val - pass_assertion(id) - else - fail_assertion(id, "Expected true JS value: #{ret_val.inspect}") - end - end - - # So we wrap it in a no-op TestPromise, to get the DSL entries. - TestPromise.new(iface: self, wait_for: [promise]).to_execute {} - end - - def assert(value, msg = nil) - id = start_assertion("#{caller[0]}: #{msg || "Value should be true!"}") - - if value - pass_assertion(id) - else - fail_assertion(id, "Expected true Ruby value: #{value.inspect}") - end - end - - def assert_equal(val1, val2, msg = nil) - assert val1 == val2, (msg || "Expected #{val2.inspect} to equal #{val1.inspect}!") - end - - # How do we signal an error? - def with_js_value(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block) - raise "Must give a block to with_js_value!" unless block - - js_promise = wrangler.eval_js_async(js_code, wait_for: wait_for, timeout: timeout) - ruby_promise = TestPromise.new(iface: self, wait_for: [js_promise]) - ruby_promise.to_execute(&block) - ruby_promise - end - - def with_js_dom_html(wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block) - with_js_value("document.getElementById('wrapper-wvroot').innerHTML", wait_for: wait_for, timeout: timeout, &block) - end - - def fully_updated(wait_for: []) - wrangler.promise_dom_fully_updated - end - end - - # A Promise but with helper functions - class TestPromise < Promise - def initialize(iface:, state: nil, wait_for: [], &scheduler) - @iface = iface - super(state: state, parents: wait_for, &scheduler) - end - - def inspect - "<#TestPromise::#{object_id} state=#{state.inspect} parents=#{parents.inspect} value=#{returned_value.inspect} reason=#{reason.inspect}>" - end - - # This method expects to wait for the parent TestPromise and then run a block of Ruby that returns - # another promise. This is useful for wrapping Promises like those from replace() that don't have - # the test DSL built in. The block will execute when this outer promise is scheduled -- so we don't do - # a replace() too early, for instance. And then the outer promise will fulfill when the inner one does. - def then_ruby_promise(wait_for: [], &block) - ruby_wrapper_promise = TestPromise.new iface: @iface, wait_for: ([self] + wait_for) - - ruby_wrapper_promise.on_scheduled do - inner_ruby_promise = block.call - inner_ruby_promise.on_fulfilled { ruby_wrapper_promise.fulfilled!(inner_ruby_promise.returned_value) } - end - end - - def then_with_js_value(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block) - @iface.with_js_value(js_code, wait_for: (wait_for + [self]), timeout:, &block) - end - - def then_with_js_dom_html(wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block) - @iface.with_js_dom_html(wait_for: (wait_for + [self]), timeout:, &block) - end - end -end diff --git a/lib/scarpe/wasm/dimensions.rb b/lib/scarpe/wasm/dimensions.rb deleted file mode 100644 index 1869a13..0000000 --- a/lib/scarpe/wasm/dimensions.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class Dimensions - class << self - def length(value) - case value - when Integer - if value < 0 - "calc(100% - #{value.abs}px)" - else - "#{value}px" - end - when Float - "#{value * 100}%" - else - value - end - end - end - end -end diff --git a/lib/scarpe/wasm/document_root.rb b/lib/scarpe/wasm/document_root.rb index 13c0aad..b04fc2b 100644 --- a/lib/scarpe/wasm/document_root.rb +++ b/lib/scarpe/wasm/document_root.rb @@ -1,8 +1,72 @@ # frozen_string_literal: true -class Scarpe - # A WASMDocumentRoot is a WASMFlow, with all the same properties - # and basic behavior. - class WASMDocumentRoot < Scarpe::WASMFlow +module Scarpe::Wasm + # A DocumentRoot is a {Scarpe::WASM::Flow}, with all the same properties + # and basic behavior. It also reserves space for Builtins like fonts, alerts, + # etc. which don't have individual {Shoes::Drawable} objects. + class DocumentRoot < Flow + def initialize(properties) + super + + @fonts = [] + @alerts = [] + + bind_shoes_event(event_name: "builtin") do |cmd_name, args| + case cmd_name + when "font" + @fonts << args[0] + needs_update! + when "alert" + bind_ok_event + @alerts << args[0] + needs_update! + else + raise Scarpe::UnknownBuiltinCommandError, "Unexpected builtin command: #{cmd_name.inspect}!" + end + end + end + + def element(&block) + contents = block ? block.call : "" + contents += builtin_contents + super { contents } + end + + private + + # This needs to occur after initialize() because the app isn't yet allocated initially, + # so we can't call bind() for app events yet. + def bind_ok_event + return if @ok_event_setup_done + @ok_event_setup_done = true + + # Done with the alert(s), delete them + bind("OK") do + @alerts = [] + needs_update! + end + end + + def builtin_contents + font_contents = @fonts.map do |font| + HTML.render do |h| + h.link(href: font, rel: "stylesheet") + h.style do + font_name = File.basename(font, ".*") + <<~CSS + @font-face { + font-family: #{font_name}; + src: url("data:font/truetype;base64,#{encode_file_to_base64(font)}") format('truetype'); + } + CSS + end + end + end.join + alert_contents = @alerts.map do |alert_text| + render("alert", { "text" => alert_text, "event_name" => "OK" }) + end.join + + font_contents + alert_contents + end end end diff --git a/lib/scarpe/wasm/widget.rb b/lib/scarpe/wasm/drawable.rb similarity index 68% rename from lib/scarpe/wasm/widget.rb rename to lib/scarpe/wasm/drawable.rb index 9d01e95..bb29605 100644 --- a/lib/scarpe/wasm/widget.rb +++ b/lib/scarpe/wasm/drawable.rb @@ -1,66 +1,70 @@ # frozen_string_literal: true -class Scarpe - # The WASMWidget parent class helps connect a WASM widget with - # its Shoes equivalent, render itself to the WASM DOM, handle - # Javascript events and generally keep things working in WASM. - class WASMWidget < Shoes::Linkable +module Scarpe::Wasm + # The Drawable parent class helps connect a Wasm drawable with + # its Shoes equivalent, render itself to the Wasm DOM, handle + # Javascript events and generally keep things working in Wasm. + class Drawable < Shoes::Linkable include Shoes::Log class << self - # Return the corresponding WASM class for a particular Shoes class name + # Return the corresponding Wasm class for a particular Shoes class name def display_class_for(scarpe_class_name) scarpe_class = Shoes.const_get(scarpe_class_name) unless scarpe_class.ancestors.include?(Shoes::Linkable) - raise "Scarpe WASM can only get display classes for Shoes " + - "linkable widgets, not #{scarpe_class_name.inspect}!" + raise Scarpe::InvalidClassError, "Scarpe Wasm can only get display classes for Shoes " + + "linkable drawables, not #{scarpe_class_name.inspect}!" end - klass = Scarpe.const_get("WASM" + scarpe_class_name.split("::")[-1]) + klass = Scarpe::Wasm.const_get(scarpe_class_name.split("::")[-1]) if klass.nil? - raise "Couldn't find corresponding Scarpe WASM class for #{scarpe_class_name.inspect}!" + raise Scarpe::MissingClassError, "Couldn't find corresponding Scarpe Wasm class for #{scarpe_class_name.inspect}!" end klass end end - # The Shoes ID corresponding to the Shoes widget for this WASM widget + # The Shoes ID corresponding to the Shoes drawable for this Wasm drawable attr_reader :shoes_linkable_id - # The WASMWidget parent of this widget + # The Drawable parent of this drawable attr_reader :parent - # An array of WASMWidget children (possibly empty) of this widget + # An array of Drawable children (possibly empty) of this drawable attr_reader :children - # Set instance variables for the display properties of this widget. Bind Shoes - # events for changes of parent widget and changes of property values. + # Set instance variables for the Shoes styles of this drawable. Bind Shoes + # events for changes of parent drawable and changes of property values. def initialize(properties) - log_init("WASM::Widget") + log_init("Wasm::Drawable") + + @shoes_style_names = properties.keys.map(&:to_s) - ["shoes_linkable_id"] # Call method, which looks up the parent @shoes_linkable_id = properties["shoes_linkable_id"] || properties[:shoes_linkable_id] unless @shoes_linkable_id - raise "Could not find property shoes_linkable_id in #{properties.inspect}!" + raise Scarpe::MissingAttributeError, "Could not find property shoes_linkable_id in #{properties.inspect}!" end - # Set the display properties + # Set the Shoes styles as instance variables properties.each do |k, v| next if k == "shoes_linkable_id" instance_variable_set("@" + k.to_s, v) end - # The parent field is *almost* simple enough that a typed display property would handle it. + # Must call this before bind + super(linkable_id: @shoes_linkable_id) + bind_shoes_event(event_name: "parent", target: shoes_linkable_id) do |new_parent_id| - display_parent = WASMDisplayService.instance.query_display_widget_for(new_parent_id) + display_parent = DisplayService.instance.query_display_drawable_for(new_parent_id) if @parent != display_parent set_parent(display_parent) end end - # When Shoes widgets change properties, we get a change notification here + # When Shoes drawables change properties, we get a change notification here bind_shoes_event(event_name: "prop_change", target: shoes_linkable_id) do |prop_changes| prop_changes.each do |k, v| instance_variable_set("@" + k, v) @@ -71,19 +75,25 @@ def initialize(properties) bind_shoes_event(event_name: "destroy", target: shoes_linkable_id) do destroy_self end + end - super(linkable_id: @shoes_linkable_id) + def shoes_styles + p = {} + @shoes_style_names.each do |prop_name| + p[prop_name] = instance_variable_get("@#{prop_name}") + end + p end # Properties_changed will be called automatically when properties change. - # The widget should delete any changes from the Hash that it knows how + # The drawable should delete any changes from the Hash that it knows how # to incrementally handle, and pass the rest to super. If any changes # go entirely un-handled, a full redraw will be scheduled. # This exists to be overridden by children watching for changes. # # @param changes [Hash] a Hash of new values for properties that have changed def properties_changed(changes) - # If a widget does something really nonstandard with its html_id or element, it will + # If a drawable does something really nonstandard with its html_id or element, it will # need to override to prevent this from happening. That's easy enough, though. if changes.key?("hidden") hidden = changes.delete("hidden") @@ -99,7 +109,7 @@ def properties_changed(changes) needs_update! unless changes.empty? end - # Give this widget a new parent, including managing the appropriate child lists for parent widgets. + # Give this drawable a new parent, including managing the appropriate child lists for parent drawables. def set_parent(new_parent) @parent&.remove_child(self) new_parent&.add_child(self) @@ -108,7 +118,7 @@ def set_parent(new_parent) # A shorter inspect text for prettier irb output def inspect - "#<#{self.class}:#{self.object_id} @shoes_linkable_id=#{@shoes_linkable_id} @parent=#{@parent.inspect} @children=#{@children.inspect}>" + "#<#{self.class}:#{self.object_id} @shoes_linkable_id=#{@shoes_linkable_id} @children=#{@children.inspect}>" end protected @@ -169,8 +179,8 @@ def style public - # This gets a mini-WASM for just this element and its children, if any. - # It is normally called by the widget itself to do its DOM management. + # This gets an updater for just this element and its children, if any. + # It is normally called by the drawable itself to do its DOM management. # # @return [Scarpe::WebWrangler::ElementWrangler] a DOM object manager def html_element @@ -192,7 +202,7 @@ def html_id end # to_html is intended to get the HTML DOM rendering of this object and its children. - # Calling it should be side-effect-free and NOT update the WASM. + # Calling it should be side-effect-free and NOT update the DOM. # # @return [String] the rendered HTML def to_html @@ -207,24 +217,26 @@ def to_html # This binds a Scarpe JS callback, handled via a single dispatch point in the app # - # @param event [String] the Scarpe widget event name + # @param event [String] the Scarpe drawable event name # @yield the block to call when the event occurs def bind(event, &block) - raise("Widget has no linkable_id! #{inspect}") unless linkable_id + raise(Scarpe::MissingAttributeError, "Drawable has no linkable_id! #{inspect}") unless linkable_id - WASMDisplayService.instance.app.bind("#{linkable_id}-#{event}", &block) + DisplayService.instance.app.bind("#{linkable_id}-#{event}", &block) end - # Removes the element from both the Ruby Widget tree and the HTML DOM. + # Removes the element from both the Ruby Drawable tree and the HTML DOM. + # Unsubscribe from all Shoes events. # Return a promise for when that HTML change will be visible. # # @return [Scarpe::Promise] a promise that is fulfilled when the HTML change is complete def destroy_self @parent&.remove_child(self) + unsub_all_shoes_events html_element.remove end - # Request a full redraw of all widgets. + # Request a full redraw of all drawables. # # It's really hard to do dirty-tracking here because the redraws are fully asynchronous. # And so we can't easily cancel one "in flight," and we can't easily pick up the latest @@ -232,16 +244,16 @@ def destroy_self # # @return [void] def needs_update! - WASMDisplayService.instance.app.request_redraw! + DisplayService.instance.app.request_redraw! end - # Generate JS code to trigger a specific event name on this widget with the supplies arguments. + # Generate JS code to trigger a specific event name on this drawable with the supplies arguments. # # @param handler_function_name [String] the event name - @see #bind # @param args [Array] additional arguments that will be passed to the event in the generated JS # @return [String] the generated JS code def handler_js_code(handler_function_name, *args) - raise("Widget has no linkable_id! #{inspect}") unless linkable_id + raise(Scarpe::MissingAttributeError, "Drawable has no linkable_id! #{inspect}") unless linkable_id js_args = ["'#{linkable_id}-#{handler_function_name}'", *args].join(", ") "scarpeHandler(#{js_args})" diff --git a/lib/scarpe/wasm/edit_box.rb b/lib/scarpe/wasm/edit_box.rb index 5f30ef4..7e6cf49 100644 --- a/lib/scarpe/wasm/edit_box.rb +++ b/lib/scarpe/wasm/edit_box.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Scarpe - class WASMEditBox < Scarpe::WASMWidget +module Scarpe::Wasm + class EditBox < Drawable attr_reader :text, :height, :width def initialize(properties) super - # The JS handler sends a "change" event, which we forward to the Shoes widget tree + # The JS handler sends a "change" event, which we forward to the Shoes drawable tree bind("change") do |new_text| send_self_event(new_text, event_name: "change") end @@ -23,20 +23,7 @@ def properties_changed(changes) end def element - oninput = handler_js_code("change", "this.value") - - HTML.render do |h| - h.textarea(id: html_id, oninput: oninput, style: style) { text } - end - end - - protected - - def style - super.merge({ - height: Dimensions.length(height), - width: Dimensions.length(width), - }.compact) + render("edit_box") end end end diff --git a/lib/scarpe/wasm/edit_line.rb b/lib/scarpe/wasm/edit_line.rb index f326a80..f5c46a3 100644 --- a/lib/scarpe/wasm/edit_line.rb +++ b/lib/scarpe/wasm/edit_line.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Scarpe - class WASMEditLine < WASMWidget +module Scarpe::Wasm + class EditLine < Drawable attr_reader :text, :width def initialize(properties) super - # The JS handler sends a "change" event, which we forward to the Shoes widget tree + # The JS handler sends a "change" event, which we forward to the Shoes drawable tree bind("change") do |new_text| send_self_event(new_text, event_name: "change") end @@ -23,21 +23,7 @@ def properties_changed(changes) end def element - oninput = handler_js_code("change", "this.value") - - HTML.render do |h| - h.input(id: html_id, oninput: oninput, value: @text, style: style) - end - end - - protected - - def style - styles = super - - styles[:width] = Dimensions.length(@width) if @width - - styles + render("edit_line") end end end diff --git a/lib/scarpe/wasm/errors.rb b/lib/scarpe/wasm/errors.rb new file mode 100644 index 0000000..bc4397a --- /dev/null +++ b/lib/scarpe/wasm/errors.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Scarpe + class UnknownShoesEventAPIError < Scarpe::Error; end + + class UnknownShapeCommandError < Scarpe::Error; end + + class UnknownBuiltinCommandError < Scarpe::Error; end + + class UnknownEventTypeError < Scarpe::Error; end + + class UnexpectedFiberTransferError < Scarpe::Error; end + + class MultipleDrawablesFoundError < Scarpe::Error; end + + class NoDrawablesFoundError < Scarpe::Error; end + + class InvalidPromiseError < Scarpe::Error; end + + class MissingAppError < Scarpe::Error; end + + class MissingDocRootError < Scarpe::Error; end + + class MissingWranglerError < Scarpe::Error; end + + class IllegalSubscribeEventError < Scarpe::Error; end + + class IllegalDispatchEventError < Scarpe::Error; end + + class MissingBlockError < Scarpe::Error; end + + class DuplicateCallbackError < Scarpe::Error; end + + class JSBindingError < Scarpe::Error; end + + class JSInitError < Scarpe::Error; end + + class PeriodicHandlerSetupError < Scarpe::Error; end + + class WebWranglerNotRunningError < Scarpe::Error; end + + class NonexistentEvalResultError < Scarpe::Error; end + + class JSRedrawError < Scarpe::Error; end + + class SingletonError < Scarpe::Error; end + + class ConnectionError < Scarpe::Error; end + + class DatagramSendError < Scarpe::Error; end + + class InvalidOperationError < Scarpe::Error; end + + class MissingAttributeError < Scarpe::Error; end + + # This error indicates a problem when running ConfirmedEval + class JSEvalError < Scarpe::Error + def initialize(data) + @data = data + super(data[:msg] || (self.class.name + "!")) + end + end + + # An error running the supplied JS code string in confirmed_eval + class JSRuntimeError < JSEvalError; end + + # The code timed out for some reason + class JSTimeoutError < JSEvalError; end + + # We got weird or nonsensical results that seem like an error on WebWrangler's part + class JSInternalError < JSEvalError; end + + # An error occurred which would normally be handled by shutting down the app + class AppShutdownError < Scarpe::Error; end + + class InvalidClassError < Scarpe::Error; end + + class MissingClassError < Scarpe::Error; end + + class MultipleShoesSpecRunsError < Scarpe::Error; end + + class EmptyPageNotSetError < Scarpe::Error; end + + class BadDisplayClassType < Scarpe::Error; end +end diff --git a/lib/scarpe/wasm/flow.rb b/lib/scarpe/wasm/flow.rb index 57bc4d9..03c52de 100644 --- a/lib/scarpe/wasm/flow.rb +++ b/lib/scarpe/wasm/flow.rb @@ -1,22 +1,6 @@ # frozen_string_literal: true -class Scarpe - class WASMFlow < Scarpe::WASMSlot - def initialize(properties) - super - end - - protected - - def style - { - display: "flex", - "flex-direction": "row", - "flex-wrap": "wrap", - "align-content": "flex-start", - "justify-content": "flex-start", - "align-items": "flex-start", - }.merge(super) - end +module Scarpe::Wasm + class Flow < Slot end end diff --git a/lib/scarpe/wasm/font.rb b/lib/scarpe/wasm/font.rb deleted file mode 100644 index 34d99c8..0000000 --- a/lib/scarpe/wasm/font.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require "scarpe/components/base64" - -class Scarpe - class WASMFont < WASMWidget - include Components::Base64 - attr_accessor :font - - def initialize(properties) - @font = properties[:font] - super - end - - def font_name - File.basename(@font, ".*") - end - - def element - HTML.render do |h| - h.link(href: @font, rel: "stylesheet") - h.style do - <<~CSS - @font-face { - font-family: #{font_name}; - src: url("data:font/truetype;base64,#{encode_file_to_base64(@font)}") format('truetype'); - } - * { - font-family: #{font_name}; - } - CSS - end - end - end - end -end diff --git a/lib/scarpe/wasm/html.rb b/lib/scarpe/wasm/html.rb deleted file mode 100644 index 7ea4131..0000000 --- a/lib/scarpe/wasm/html.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class HTML - CONTENT_TAGS = [:div, :p, :button, :ul, :li, :textarea, :a, :video, :strong, :style, :em, :code, :u, :line, :span, :svg].freeze - VOID_TAGS = [:input, :img, :polygon, :source, :link, :path].freeze - - TAGS = (CONTENT_TAGS + VOID_TAGS).freeze - - class << self - def render(&block) - new(&block).value - end - end - - def initialize(&block) - @buffer = "" - block.call(self) - end - - def value - @buffer - end - - def respond_to_missing?(name, include_all = false) - TAGS.include?(name) || super(name, include_all) - end - - def p(*args, &block) - method_missing(:p, *args, &block) - end - - def option(**attrs, &block) - tag(:option, **attrs, &block) - end - - def tag(name, **attrs, &block) - if VOID_TAGS.include?(name) - raise ArgumentError, "void tag #{name} cannot have content" if block_given? - - @buffer += "<#{name}#{render_attributes(attrs)} />" - else - @buffer += "<#{name}#{render_attributes(attrs)}>" - - if block_given? - result = block.call(self) - else - result = attrs[:content] - @buffer += result if result.is_a?(String) - end - @buffer += result if result.is_a?(String) - - @buffer += "#{name}>" - end - - nil - end - - def select(**attrs, &block) - tag(:select, **attrs, &block) - end - - def method_missing(name, *args, &block) - raise NoMethodError, "no method #{name} for #{self.class.name}" unless TAGS.include?(name) - - if VOID_TAGS.include?(name) - raise ArgumentError, "void tag #{name} cannot have content" if block_given? - - @buffer += "<#{name}#{render_attributes(*args)} />" - else - @buffer += "<#{name}#{render_attributes(*args)}>" - - if block_given? - result = block.call(self) - else - result = args.first - @buffer += result if result.is_a?(String) - end - @buffer += result if result.is_a?(String) - - @buffer += "#{name}>" - end - - nil - end - - private - - def render_attributes(attributes = {}) - return "" if attributes.empty? - - attributes[:style] = render_style(attributes[:style]) if attributes[:style] - attributes.compact! - - return if attributes.empty? - - result = attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ") - " #{result}" - end - - def render_style(style) - return style unless style.is_a?(Hash) - return if style.empty? - - style.map { |k, v| "#{k}:#{v}" }.join(";") - end - end -end diff --git a/lib/scarpe/wasm/image.rb b/lib/scarpe/wasm/image.rb index dbfa1d5..99234fd 100644 --- a/lib/scarpe/wasm/image.rb +++ b/lib/scarpe/wasm/image.rb @@ -2,40 +2,20 @@ require "scarpe/components/base64" -class Scarpe - class WASMImage < WASMWidget - include Components::Base64 +module Scarpe::Wasm + class Image < Drawable + include Scarpe::Components::Base64 + def initialize(properties) super - @url = valid_url?(@url) ? @url : "data:image/png;base64,#{encode_file_to_base64(@url)}" - end - - def element - if @click - HTML.render do |h| - h.a(id: html_id, href: @click) { h.img(id: html_id, src: @url, style:) } - end - else - HTML.render do |h| - h.img(id: html_id, src: @url, style:) - end + unless valid_url?(@url) + @url = "data:image/png;base64,#{encode_file_to_base64(@url)}" end end - protected - - def style - styles = super - - styles[:width] = Dimensions.length(@width) if @width - styles[:height] = Dimensions.length(@height) if @height - - styles[:top] = Dimensions.length(@top) if @top - styles[:left] = Dimensions.length(@left) if @left - styles[:position] = "absolute" if @top || @left - - styles + def element + render("image") end end end diff --git a/lib/scarpe/wasm/line.rb b/lib/scarpe/wasm/line.rb deleted file mode 100644 index 7967908..0000000 --- a/lib/scarpe/wasm/line.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class WASMLine < Scarpe::WASMWidget - def initialize(properties) - super(properties) - end - - def element - HTML.render do |h| - h.div(id: html_id, style: style) do - h.svg(width: @x2, height: @y2) do - h.line(x1: @left, y1: @top, x2: @x2, y2: @y2, style: line_style) - end - end - end - end - - protected - - def style - super.merge({ - left: "#{@left}px", - top: "#{@top}px", - }) - end - - def line_style - { - stroke: @draw_context["stroke"], - "stroke-width": "4", - } - end - end -end diff --git a/lib/scarpe/wasm/link.rb b/lib/scarpe/wasm/link.rb index 7e9cd00..435421d 100644 --- a/lib/scarpe/wasm/link.rb +++ b/lib/scarpe/wasm/link.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMLink < WASMWidget +module Scarpe::Wasm + class Link < Drawable def initialize(properties) super @@ -11,20 +11,7 @@ def initialize(properties) end def element - HTML.render do |h| - h.a(**attributes) do - @text - end - end - end - - def attributes - { - id: html_id, - href: @click, - onclick: (handler_js_code("click") if @has_block), - style: style, - }.compact + render "link" end end end diff --git a/lib/scarpe/wasm/list_box.rb b/lib/scarpe/wasm/list_box.rb index 8a1ee68..89495d5 100644 --- a/lib/scarpe/wasm/list_box.rb +++ b/lib/scarpe/wasm/list_box.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Scarpe - class WASMListBox < Scarpe::WASMWidget - attr_reader :selected_item, :items, :height, :width +module Scarpe::Wasm + class ListBox < Drawable + attr_reader :selected_item, :items, :height, :width, :choose def initialize(properties) - super(properties) + super - # The JS handler sends a "change" event, which we forward to the Shoes widget tree + # The JS handler sends a "change" event, which we forward to the Shoes drawable tree bind("change") do |new_item| send_self_event(new_item, event_name: "change") end diff --git a/lib/scarpe/wasm/para.rb b/lib/scarpe/wasm/para.rb index d4550ab..3f40d03 100644 --- a/lib/scarpe/wasm/para.rb +++ b/lib/scarpe/wasm/para.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMPara < WASMWidget +module Scarpe::Wasm + class Para < Drawable SIZES = { inscription: 10, ins: 10, @@ -14,10 +14,6 @@ class WASMPara < WASMWidget }.freeze private_constant :SIZES - def initialize(properties) - super - end - def properties_changed(changes) items = changes.delete("text_items") if items @@ -40,15 +36,13 @@ def items_to_display_children(items) if item.is_a?(String) item else - WASMDisplayService.instance.query_display_widget_for(item) + DisplayService.instance.query_display_drawable_for(item) end end end def element(&block) - HTML.render do |h| - h.p(**options, &block) - end + render("para", &block) end def to_html @@ -57,22 +51,6 @@ def to_html element { child_markup } end - protected - - def style - super.merge({ - color: rgb_to_hex(@stroke), - "font-size": font_size, - "font-family": @font, - }.compact) - end - - def font_size - font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size - - Dimensions.length(font_size) - end - private def child_markup @@ -84,9 +62,5 @@ def child_markup end end.join end - - def options - @html_attributes.merge(id: html_id, style: style) - end end end diff --git a/lib/scarpe/wasm/radio.rb b/lib/scarpe/wasm/radio.rb index eba4826..ccd4317 100644 --- a/lib/scarpe/wasm/radio.rb +++ b/lib/scarpe/wasm/radio.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -class Scarpe - class WASMRadio < Scarpe::WASMWidget +module Scarpe::Wasm + class Radio < Drawable attr_reader :text def initialize(properties) super bind("click") do - send_self_event(event_name: "click", target: shoes_linkable_id) + send_self_event(event_name: "click") end end @@ -20,15 +20,13 @@ def properties_changed(changes) end def element - HTML.render do |h| - h.input(type: :radio, id: html_id, onclick: handler_js_code("click"), name: group_name, value: "hmm #{text}", checked: @checked, style: style) - end - end + props = shoes_styles - private - - def group_name - @group || @parent + # If a group isn't set, default to the linkable ID of the parent slot + unless @group + props["group"] = @parent ? @parent.shoes_linkable_id : "no_group" + end + render("radio", props) end end end diff --git a/lib/scarpe/wasm/shape.rb b/lib/scarpe/wasm/shape.rb index ee76e96..4ab48ea 100644 --- a/lib/scarpe/wasm/shape.rb +++ b/lib/scarpe/wasm/shape.rb @@ -1,12 +1,7 @@ # frozen_string_literal: true -class Scarpe - # Should inherit from Slot? - class WASMShape < Scarpe::WASMWidget - def initialize(properties) - super(properties) - end - +module Scarpe::Wasm + class Shape < Drawable def to_html @children ||= [] child_markup = @children.map(&:to_html).join @@ -51,7 +46,7 @@ def path_from_shape_commands x, y = *args current_path += "L #{x} #{y} " else - raise "Unknown shape command! #{cmd.inspect}" + raise Scarpe::UnknownShapeCommandError, "Unknown shape command! #{cmd.inspect}" end end diff --git a/lib/scarpe/wasm/slot.rb b/lib/scarpe/wasm/slot.rb index cf57aed..1e00472 100644 --- a/lib/scarpe/wasm/slot.rb +++ b/lib/scarpe/wasm/slot.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMSlot < Scarpe::WASMWidget - include Scarpe::WASMBackground - include Scarpe::WASMBorder - include Scarpe::WASMSpacing - +module Scarpe::Wasm + class Slot < Drawable def initialize(properties) @event_callbacks = {} @@ -13,16 +9,16 @@ def initialize(properties) end def element(&block) - HTML.render do |h| - h.div(attributes.merge(id: html_id, style: style), &block) - end + props = shoes_styles.merge("html_attributes" => html_attributes) + render_name = self.class.name.split("::")[-1].downcase # usually "stack" or "flow" or "documentroot" + render(render_name, props, &block) end def set_event_callback(obj, event_name, js_code) event_name = event_name.to_s @event_callbacks[event_name] ||= {} if @event_callbacks[event_name][obj] - raise "Can't have two callbacks on the same event, from the same object, on the same parent!" + raise Scarpe::DuplicateCallbackError, "Can't have two callbacks on the same event, from the same object, on the same parent!" end @event_callbacks[event_name][obj] = js_code @@ -54,7 +50,8 @@ def update_dom_event(event_name) html_element.set_attribute(event_name, @event_callbacks[event_name].values.join(";")) end - def attributes + # These get added for event handlers and passed to Calzini + def html_attributes attr = {} @event_callbacks.each do |event_name, handlers| @@ -63,19 +60,5 @@ def attributes attr end - - def style - styles = super - - styles[:"margin-top"] = @margin_top if @margin_top - styles[:"margin-bottom"] = @margin_bottom if @margin_bottom - styles[:"margin-left"] = @margin_left if @margin_left - styles[:"margin-right"] = @margin_right if @margin_right - - styles[:width] = Dimensions.length(@width) if @width - styles[:height] = Dimensions.length(@height) if @height - - styles - end end end diff --git a/lib/scarpe/wasm/spacing.rb b/lib/scarpe/wasm/spacing.rb deleted file mode 100644 index 4e36aba..0000000 --- a/lib/scarpe/wasm/spacing.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - module WASMSpacing - SPACING_DIRECTIONS = [:left, :right, :top, :bottom] - - def style - styles = defined?(super) ? super : {} - - extract_spacing_styles_for(:margin, styles, @margin) - extract_spacing_styles_for(:padding, styles, @padding) - - styles - end - - def extract_spacing_styles_for(attribute, styles, values) - values ||= spacing_values_from_options(attribute) - - case values - when Hash - values.each do |direction, value| - styles["#{attribute}-#{direction}"] = Dimensions.length(value) - end - when Array - SPACING_DIRECTIONS.zip(values).to_h.compact.each do |direction, value| - styles["#{attribute}-#{direction}"] = Dimensions.length(value) - end - else - styles[attribute] = Dimensions.length(values) - end - - styles.compact! - end - - def spacing_values_from_options(attribute) - SPACING_DIRECTIONS.map do |direction| - @options.delete("#{attribute}_#{direction}".to_sym) - end - end - end -end diff --git a/lib/scarpe/wasm/span.rb b/lib/scarpe/wasm/span.rb index 4ab42f6..14c0f75 100644 --- a/lib/scarpe/wasm/span.rb +++ b/lib/scarpe/wasm/span.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMSpan < Scarpe::WASMWidget +module Scarpe::Wasm + class Span < Drawable SIZES = { inscription: 10, ins: 10, @@ -34,35 +34,11 @@ def properties_changed(changes) end def element(&block) - HTML.render do |h| - h.span(**options, &block) - end + render("span", &block) end def to_html element { @text } end - - protected - - def style - { - color: @stroke, - "font-size": font_size, - "font-family": @font, - }.compact - end - - private - - def options - @html_attributes.merge(id: html_id, style: style) - end - - def font_size - font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size - - Dimensions.length(font_size) - end end end diff --git a/lib/scarpe/wasm/stack.rb b/lib/scarpe/wasm/stack.rb index 1b9f067..0e0d4ab 100644 --- a/lib/scarpe/wasm/stack.rb +++ b/lib/scarpe/wasm/stack.rb @@ -1,22 +1,6 @@ # frozen_string_literal: true -class Scarpe - class WASMStack < Scarpe::WASMSlot - def get_style - style - end - - protected - - def style - { - display: "flex", - "flex-direction": "column", - "align-content": "flex-start", - "justify-content": "flex-start", - "align-items": "flex-start", - overflow: @scroll ? "auto" : nil, - }.compact.merge(super) - end +module Scarpe::Wasm + class Stack < Slot end end diff --git a/lib/scarpe/wasm/star.rb b/lib/scarpe/wasm/star.rb deleted file mode 100644 index 564656f..0000000 --- a/lib/scarpe/wasm/star.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class WASMStar < Scarpe::WASMWidget - def initialize(properties) - super(properties) - end - - def element(&block) - fill = @draw_context["fill"] - stroke = @draw_context["stroke"] - fill = "black" if fill == "" - stroke = "black" if stroke == "" - HTML.render do |h| - h.div(id: html_id, style: style) do - h.svg(width: @outer, height: @outer, style: "fill:#{fill};") do - h.polygon(points: star_points, style: "stroke:#{stroke};stroke-width:2") - end - block.call(h) if block_given? - end - end - end - - protected - - def style - super.merge({ - width: Dimensions.length(@width), - height: Dimensions.length(@height), - }) - end - - private - - def star_points - get_star_points.join(",") - end - - def get_star_points - angle = 2 * Math::PI / @points - coordinates = [] - - @points.times do |i| - outer_angle = i * angle - inner_angle = outer_angle + angle / 2 - - coordinates.concat(get_coordinates(outer_angle, inner_angle)) - end - - coordinates - end - - def get_coordinates(outer_angle, inner_angle) - outer_x = @outer / 2 + Math.cos(outer_angle) * @outer / 2 - outer_y = @outer / 2 + Math.sin(outer_angle) * @outer / 2 - - inner_x = @outer / 2 + Math.cos(inner_angle) * @inner / 2 - inner_y = @outer / 2 + Math.sin(inner_angle) * @inner / 2 - - [outer_x, outer_y, inner_x, inner_y] - end - end -end diff --git a/lib/scarpe/wasm/subscription_item.rb b/lib/scarpe/wasm/subscription_item.rb index e9c8f59..c59f2e0 100644 --- a/lib/scarpe/wasm/subscription_item.rb +++ b/lib/scarpe/wasm/subscription_item.rb @@ -1,12 +1,39 @@ # frozen_string_literal: true -class Scarpe::WASMSubscriptionItem < Scarpe::WASMWidget +class Scarpe::Wasm::SubscriptionItem < Scarpe::Wasm::Drawable + def initialize(properties) super bind(@shoes_api_name) do |*args| send_self_event(*args, event_name: @shoes_api_name) end + + @wrangler = Scarpe::Wasm::DisplayService.instance.wrangler + + case @shoes_api_name + when "animate" + frame_rate = (@args[0] || 10) + @counter = 0 + @wrangler.periodic_code("animate_#{@shoes_linkable_id}", 1.0 / frame_rate) do + @counter += 1 + send_self_event(@counter, event_name: @shoes_api_name) + end + when "every" + delay = @args[0] + @counter = 0 + @wrangler.periodic_code("every_#{@shoes_linkable_id}", delay) do + @counter += 1 + send_self_event(@counter, event_name: @shoes_api_name) + end + when "timer" + # JS setTimeout? + raise "Implement me!" + when "motion", "hover", "leave", "click", "release", "keypress" + # Wait for set_parent + else + raise Scarpe::UnknownShoesEventAPIError, "Unknown Shoes event API: #{@shoes_api_name}!" + end end def element @@ -21,7 +48,7 @@ def set_parent(new_parent) case @shoes_api_name when "motion" # TODO: what do we do for whole-screen mousemove outside the window? - # Those should be set on body, which right now doesn't have a widget. + # Those should be set on body, which right now doesn't have a drawable. # TODO: figure out how to handle alt and meta keys - does Shoes3 recognise those? new_parent.set_event_callback( self, @@ -36,15 +63,23 @@ def set_parent(new_parent) ) when "hover" new_parent.set_event_callback(self, "onmouseenter", handler_js_code(@shoes_api_name)) + when "leave" + new_parent.set_event_callback(self, "onmouseleave", handler_js_code(@shoes_api_name)) when "click" new_parent.set_event_callback(self, "onclick", handler_js_code(@shoes_api_name, "arguments[0].button", "arguments[0].x", "arguments[0].y")) + when "release" + new_parent.set_event_callback(self, "onmouseup", handler_js_code(@shoes_api_name, "arguments[0].button", "arguments[0].x", "arguments[0].y")) + when "keypress" + raise "Implement me!" + when "animate", "every", "timer" + # These were handled in initialize(), ignore them here else - raise "Unknown Shoes event API: #{@shoes_api_name}!" + raise Scarpe::UnknownShoesEventAPIError, "Unknown Shoes event API: #{@shoes_api_name}!" end end def destroy_self - @parent.remove_event_callbacks(self) + @parent&.remove_event_callbacks(self) super end end diff --git a/lib/scarpe/wasm/text_drawable.rb b/lib/scarpe/wasm/text_drawable.rb new file mode 100644 index 0000000..9712c4a --- /dev/null +++ b/lib/scarpe/wasm/text_drawable.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Scarpe::Wasm + class TextDrawable < Drawable + end + + class << self + def default_wasm_text_drawable_with(element) + wasm_class_name = element.capitalize + wasm_drawable_class = Class.new(Scarpe::Wasm::TextDrawable) do + def initialize(properties) + class_name = self.class.name.split("::")[-1] + @html_tag = class_name.delete_prefix("Wasm").downcase + super + end + + def element + render(@html_tag) { @content.to_s } + end + end + Scarpe::Wasm.const_set wasm_class_name, wasm_drawable_class + end + end +end + +Scarpe::Wasm.default_wasm_text_drawable_with(:code) +Scarpe::Wasm.default_wasm_text_drawable_with(:em) +Scarpe::Wasm.default_wasm_text_drawable_with(:strong) diff --git a/lib/scarpe/wasm/text_widget.rb b/lib/scarpe/wasm/text_widget.rb deleted file mode 100644 index 2754aed..0000000 --- a/lib/scarpe/wasm/text_widget.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class Scarpe - class WASMTextWidget < Scarpe::WASMWidget - end - - class << self - def default_wasm_text_widget_with(element) - wasm_class_name = "WASM#{element.capitalize}" - wasm_widget_class = Class.new(Scarpe::WASMTextWidget) do - def initialize(properties) - class_name = self.class.name.split("::")[-1] - @html_tag = class_name.delete_prefix("WASM").downcase - super - end - - def element - HTML.render do |h| - h.send(@html_tag) { @content.to_s } - end - end - end - Scarpe.const_set wasm_class_name, wasm_widget_class - end - end -end - -Scarpe.default_wasm_text_widget_with(:code) -Scarpe.default_wasm_text_widget_with(:em) -Scarpe.default_wasm_text_widget_with(:strong) diff --git a/lib/scarpe/wasm/video.rb b/lib/scarpe/wasm/video.rb index 0d0fec8..f3924d2 100644 --- a/lib/scarpe/wasm/video.rb +++ b/lib/scarpe/wasm/video.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Scarpe - class WASMVideo < Scarpe::WASMWidget +module Scarpe::Wasm + class Video < Drawable SUPPORTED_FORMATS = { "video/mp4" => [".mp4"], "video/webp" => [".webp"], @@ -9,26 +9,25 @@ class WASMVideo < Scarpe::WASMWidget "video/x-matroska" => [".mkv"], # Add more formats and their associated file extensions if needed }.freeze + FORMAT_FOR_EXT = {} + SUPPORTED_FORMATS.each do |format, extensions| + extensions.each do |ext| + if FORMAT_FOR_EXT.key?(ext) + raise "Internal error! Must have a specific format for each extension!" + end + FORMAT_FOR_EXT[ext] = format + end + end + FORMAT_FOR_EXT.freeze def initialize(properties) @url = properties[:url] super + @format = FORMAT_FOR_EXT[File.extname(@url)] end def element - HTML.render do |h| - h.video(id: html_id, style: style, controls: true) do - supported_formats.each do |format| - h.source(src: @url, type: format) - end - end - end - end - - private - - def supported_formats - SUPPORTED_FORMATS.select { |_format, extensions| extensions.include?(File.extname(@url)) }.keys + render "video", shoes_styles.merge("format" => @format) end end end diff --git a/lib/scarpe/wasm/wasm_calls.rb b/lib/scarpe/wasm/wasm_calls.rb index cd1296d..b386ece 100644 --- a/lib/scarpe/wasm/wasm_calls.rb +++ b/lib/scarpe/wasm/wasm_calls.rb @@ -2,12 +2,12 @@ require "js" -class Scarpe - class WASMInterops +module Scarpe + class WasmInterops include Shoes::Log def initialize - log_init("WASM") + log_init("Wasm") end def js_integer?(num) diff --git a/lib/scarpe/wasm/wasm_local_display.rb b/lib/scarpe/wasm/wasm_local_display.rb index 721775c..6c357d0 100644 --- a/lib/scarpe/wasm/wasm_local_display.rb +++ b/lib/scarpe/wasm/wasm_local_display.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -class Scarpe - # This is the simplest type of WASM DisplayService. It creates WASM widgets - # corresponding to Shoes widgets, manages the WASM and its DOM tree, and +module Scarpe::Wasm + # This is the simplest type of WASM DisplayService. It creates WASM drawables + # corresponding to Shoes drawables, manages the WASM and its DOM tree, and # generally keeps the Shoes/WASM connection working. # # This is an in-process WASM-based display service, with all the limitations that @@ -10,77 +10,91 @@ class Scarpe # process, too many or too large evals can crash the process, etc. # Frequently it's better to use a RelayDisplayService to a second # process containing one of these. - class WASMDisplayService < Shoes::DisplayService + class DisplayService < Shoes::DisplayService include Shoes::Log class << self attr_accessor :instance end - # The ControlInterface is used to handle internal events in WASM Scarpe + # The ControlInterface is used to handle internal events in Wasm Scarpe attr_reader :control_interface - # The DocumentRoot is the top widget of the WASM-side widget tree + # The DocumentRoot is the top drawable of the Wasm-side drawable tree attr_reader :doc_root - # app is the Scarpe::WASMApp + # app is the Scarpe::Wasm::App attr_reader :app # wrangler is the Scarpe::WebWrangler attr_reader :wrangler - # This is called before any of the various WASMWidgets are created, to be + # This is called before any of the various Drawables are created, to be # able to create them and look them up. def initialize - if WASMDisplayService.instance + if DisplayService.instance raise "ERROR! This is meant to be a singleton!" end - WASMDisplayService.instance = self + DisplayService.instance = self super() - log_init("WASM::WASMDisplayService") + log_init("Wasm::DisplayService") - @display_widget_for = {} + @display_drawable_for = {} end - # Create a WASM display widget for a specific Shoes widget, and pair it with - # the linkable ID for this Shoes widget. + # Create a Wasm display drawable for a specific Shoes drawable, and pair it with + # the linkable ID for this Shoes drawable. # - # @param widget_class_name [String] The class name of the Shoes widget, e.g. Shoes::Button - # @param widget_id [String] the linkable ID for widget events - # @param properties [Hash] a JSON-serialisable Hash with the widget's display properties - # @return [WASMWidget] the newly-created WASM widget - def create_display_widget_for(widget_class_name, widget_id, properties) - if widget_class_name == "App" + # @param drawable_class_name [String] The class name of the Shoes drawable, e.g. Shoes::Button + # @param drawable_id [String] the linkable ID for drawable events + # @param properties [Hash] a JSON-serialisable Hash with the drawable's display properties + # @param is_widget [Boolean] whether the class is a user-defined Shoes::Widget subclass + # @return [Wasm::Drawable] the newly-created Wasm drawable + def create_display_drawable_for(drawable_class_name, drawable_id, properties, is_widget:) + existing = query_display_drawable_for(drawable_id, nil_ok: true) + if existing + @log.warn("There is already a display drawable for #{drawable_id.inspect}! Returning #{existing.class.name}.") + return existing + end + + if drawable_class_name == "App" unless @doc_root - raise "WASMDocumentRoot is supposed to be created before WASMApp!" + raise Scarpe::MissingDocRootError, "DocumentRoot is supposed to be created before App!" end - display_app = Scarpe::WASMApp.new(properties) + display_app = Scarpe::Wasm::App.new(properties) display_app.document_root = @doc_root @control_interface = display_app.control_interface @control_interface.doc_root = @doc_root @app = @control_interface.app @wrangler = @control_interface.wrangler - set_widget_pairing(widget_id, display_app) + set_drawable_pairing(drawable_id, display_app) return display_app end - # Create a corresponding display widget - display_class = Scarpe::WASMWidget.display_class_for(widget_class_name) - display_widget = display_class.new(properties) - set_widget_pairing(widget_id, display_widget) + # Create a corresponding display drawable + if is_widget + display_class = Scarpe::Wasm::Flow + else + display_class = Scarpe::Wasm::Drawable.display_class_for(drawable_class_name) + unless display_class < Scarpe::Wasm::Drawable + raise Scarpe::BadDisplayClassType, "Wrong display class type #{display_class.inspect} for class name #{drawable_class_name.inspect}!" + end + end + display_drawable = display_class.new(properties) + set_drawable_pairing(drawable_id, display_drawable) - if widget_class_name == "DocumentRoot" - # WASMDocumentRoot is created before WASMApp. Mostly doc_root is just like any other widget, - # but we'll want a reference to it when we create WASMApp. - @doc_root = display_widget + if drawable_class_name == "DocumentRoot" + # DocumentRoot is created before App. Mostly doc_root is just like any other drawable, + # but we'll want a reference to it when we create App. + @doc_root = display_drawable end - display_widget + display_drawable end # Destroy the display service and the app. Quit the process (eventually.) @@ -88,7 +102,7 @@ def create_display_widget_for(widget_class_name, widget_id, properties) # @return [void] def destroy @app.destroy - WASMDisplayService.instance = nil + DisplayService.instance = nil end end end diff --git a/lib/scarpe/wasm/web_wrangler.rb b/lib/scarpe/wasm/web_wrangler.rb index 13b1035..971a5ab 100644 --- a/lib/scarpe/wasm/web_wrangler.rb +++ b/lib/scarpe/wasm/web_wrangler.rb @@ -7,7 +7,7 @@ # After creation, it starts in setup mode, and you can # use setup-mode callbacks. -class Scarpe +module Scarpe::Wasm class WebWrangler include Shoes::Log @@ -16,41 +16,18 @@ class WebWrangler attr_reader :heartbeat # This is the heartbeat duration in seconds, usually fractional attr_reader :control_interface - # This error indicates a problem when running ConfirmedEval - class JSEvalError < Scarpe::Error - def initialize(data) - @data = data - super(data[:msg] || (self.class.name + "!")) - end - end - - # We got an error running the supplied JS code string in confirmed_eval - class JSRuntimeError < JSEvalError - end - - # The code timed out for some reason - class JSTimeoutError < JSEvalError - end - - # We got weird or nonsensical results that seem like an error on WebWrangler's part - class InternalError < JSEvalError - end - # This is the JS function name for eval results EVAL_RESULT = "scarpeAsyncEvalResult" # Allow a half-second for wasm to finish our JS eval before we decide it's not going to EVAL_DEFAULT_TIMEOUT = 0.5 - def initialize(title:, width:, height:, resizable: false, debug: false, heartbeat: 0.1) - log_init("WASM::WebWrangler") + def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) + log_init("Wasm::WebWrangler") @log.debug("Creating WebWrangler...") - # For now, always allow inspect element - @wasm = Scarpe::WASMInterops.new - #@wasm = Scarpe::LoggedWrapper.new(@wasm, "wasmAPI") if debug - @init_refs = {} # Inits don't go away so keep a reference to them to prevent GC + @wasm = Scarpe::WasmInterops.new @title = title @width = width @@ -67,11 +44,7 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea @pending_evals = {} @eval_counter = 0 - @dom_wrangler = DOMWrangler.new(self) - - # bind("puts") do |*args| - # puts(*args) - # end + @dom_wrangler = Scarpe::WebWrangler::DOMWrangler.new(self) @wasm.bind(EVAL_RESULT) do |*results| receive_eval_result(*results) @@ -80,9 +53,6 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea # Ruby receives scarpeHeartbeat messages via the window library's main loop. # So this is a way for Ruby to be notified periodically, in time with that loop. @wasm.bind("scarpeHeartbeat") do - # return unless @wasm # I think GTK+ may continue to deliver events after shutdown - - # periodic_js_callback @heartbeat_handlers.each(&:call) @control_interface.dispatch_event(:heartbeat) end @@ -92,7 +62,7 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea # Shorter name for better stack trace messages def inspect - "Scarpe::WebWrangler:#{object_id}" + "Scarpe::WebWasm:#{object_id}" end attr_writer :control_interface @@ -100,17 +70,16 @@ def inspect ### Setup-mode Callbacks def bind(name, &block) - raise "App is running, javascript binding no longer works because it uses wasm init!" if @is_running + raise Scarpe::JSBindingError, "App is running, javascript binding no longer works because it uses wasm init!" if @is_running @wasm.bind(name, &block) end def init_code(name, &block) - raise "App is running, javascript init no longer works!" if @is_running + raise Scarpe::JSInitError, "App is running, javascript init no longer works!" if @is_running # Save a reference to the init string so that it goesn't get GC'd code_str = "#{name}();" - @init_refs[name] = code_str bind(name, &block) @wasm.init(code_str) @@ -128,12 +97,11 @@ def periodic_code(name, interval = heartbeat, &block) # new window. But will there ever be a new page/window? Can we just # use eval instead of init to set up a periodic handler and call it # good? - raise "App is running, can't set up new periodic handlers with init!" + raise Scarpe::PeriodicHandlerSetupError, "App is running, can't set up new periodic handlers with init!" end js_interval = (interval.to_f * 1_000.0).to_i code_str = "setInterval(#{name}, #{js_interval});" - @init_refs[name] = code_str bind(name, &block) @wasm.init(code_str) @@ -151,9 +119,7 @@ def periodic_code(name, interval = heartbeat, &block) # This method does *not* return a promise, and there is no way to track # its progress or its success or failure. def js_eventually(code) - raise "WebWrangler isn't running, eval doesn't work!" unless @is_running - - # @log.warn "Deprecated: please do NOT use js_eventually, it's basically never what you want!" unless ENV["CI"] + raise Scarpe::WebWranglerNotRunningError, "WebWrangler isn't running, eval doesn't work!" unless @is_running @wasm.eval(code) end @@ -172,52 +138,7 @@ def js_eventually(code) # in a JS function. EVAL_OPTS = [:timeout, :wait_for] def eval_js_async(code, opts = {}) - # bad_opts = opts.keys - EVAL_OPTS - # raise("Bad options given to eval_with_handler! #{bad_opts.inspect}") unless bad_opts.empty? - - # unless @is_running - # raise "WebWrangler isn't running, so evaluating JS won't work!" - # end - - # this_eval_serial = @eval_counter - # @eval_counter += 1 - - # @pending_evals[this_eval_serial] = { - # id: this_eval_serial, - # code: code, - # start_time: Time.now, - # timeout_if_not_scheduled: Time.now + EVAL_DEFAULT_TIMEOUT, - # } - - # # We'll need this inside the promise-scheduling block - # pending_evals = @pending_evals - # timeout = opts[:timeout] || EVAL_DEFAULT_TIMEOUT - - # promise = Scarpe::Promise.new(parents: (opts[:wait_for] || [])) do - # # Are we mid-shutdown? - # if @wasm - # wrapped_code = WebWrangler.js_wrapped_code(code, this_eval_serial) - - # # We've been scheduled! - # t_now = Time.now - # # Hard to be sure wasm keeps a proper reference to this, so we will - # pending_evals[this_eval_serial][:wrapped_code] = wrapped_code - - # pending_evals[this_eval_serial][:scheduled_time] = t_now - # pending_evals[this_eval_serial].delete(:timeout_if_not_scheduled) - - # pending_evals[this_eval_serial][:timeout_if_not_finished] = t_now + timeout - # @wasm.eval(wrapped_code) - # @log.debug("Scheduled JS: (#{this_eval_serial})\n#{wrapped_code}") - # else - # # We're mid-shutdown. No more scheduling things. - # end - # end - - # @pending_evals[this_eval_serial][:promise] = promise - # @pending_evals[this_eval_serial][:promise].await - # promise - js_eventually(code) + @wasm.eval(code) end def self.js_wrapped_code(code, eval_id) @@ -236,83 +157,28 @@ def self.js_wrapped_code(code, eval_id) private - # def periodic_js_callback - # time_out_eval_results - # end - def receive_eval_result(r_type, id, val) entry = @pending_evals.delete(id) unless entry - raise "Received an eval result for a nonexistent ID #{id.inspect}!" + raise Scarpe::NonexistentEvalResultError, "Received an eval result for a nonexistent ID #{id.inspect}!" end @log.debug("Got JS value: #{r_type} / #{id} / #{val.inspect}") - - # promise = entry[:promise] - - # case r_type - # when "success" - # promise.fulfilled!(val) - # when "error" - # promise.rejected! JSRuntimeError.new( - # msg: "JS runtime error: #{val.inspect}!", - # code: entry[:code], - # ret_value: val, - # ) - # else - # promise.rejected! InternalError.new( - # msg: "JS eval internal error! r_type: #{r_type.inspect}", - # code: entry[:code], - # ret_value: val, - # ) - # end end - # TODO: would be good to keep 'tombstone' results for awhile after timeout, maybe up to around a minute, - # so we can detect if we're timing things out and then having them return successfully after a delay. - # Then we could adjust the timeouts. We could also check if later serial numbers have returned, and time - # out earlier serial numbers... *if* we're sure wasm will always execute JS evals in order. - # This all adds complexity, though. For now, do timeouts on a simple max duration. - # def time_out_eval_results - # t_now = Time.now - # timed_out_from_scheduling = @pending_evals.keys.select do |id| - # t = @pending_evals[id][:timeout_if_not_scheduled] - # t && t_now >= t - # end - # timed_out_from_finish = @pending_evals.keys.select do |id| - # t = @pending_evals[id][:timeout_if_not_finished] - # t && t_now >= t - # end - # timed_out_from_scheduling.each do |id| - # @log.debug("JS timed out because it was never scheduled: (#{id}) #{@pending_evals[id][:code].inspect}") - # end - # timed_out_from_finish.each do |id| - # @log.debug("JS timed out because it never finished: (#{id}) #{@pending_evals[id][:code].inspect}") - # end - - # # A plus *should* be fine since nothing should ever be on both lists. But let's be safe. - # timed_out_ids = timed_out_from_scheduling | timed_out_from_finish - - # timed_out_ids.each do |id| - # @log.error "Timing out JS eval! #{@pending_evals[id][:code]}" - # entry = @pending_evals.delete(id) - # err = JSTimeoutError.new(msg: "JS timeout error!", code: entry[:code], ret_value: nil) - # entry[:promise].rejected!(err) - # end - # end - public + attr_writer :empty_page + # After setup, we call run to go to "running" mode. # No more setup callbacks, only running callbacks. def run @log.debug("Run...") - # From wasm: # 0 - Width and height are default size - # 1 - Width and height are minimum bonds - # 2 - Width and height are maximum bonds + # 1 - Width and height are minimum bounds + # 2 - Width and height are maximum bounds # 3 - Window size can not be changed by a user hint = @resizable ? 0 : 3 @@ -320,8 +186,6 @@ def run @wasm.set_size(@width, @height, hint) @wasm.navigate("data:text/html, #{empty}") - # monkey_patch_console(@wasm) - @is_running = true @wasm.run end @@ -339,49 +203,8 @@ def destroy private - # TODO: can this be an init()? - def monkey_patch_console(window) - # this forwards all console.log/info/error/warn calls also - # to the terminal that is running the scarpe app - # window.eval(" - # function patchConsole(fn) { - # const original = console[fn]; - # console[fn] = function(...args) { - # original(...args); - # puts(...args); - # } - # }; - # patchConsole('log'); - # patchConsole('info'); - # patchConsole('error'); - # patchConsole('warn'); - # ") - end - def empty - html = <<~HTML - -
- - - - - - - HTML - - CGI.escape(html) + Scarpe::Components::Calzini.empty_page_element end public @@ -402,12 +225,6 @@ def dom_change(js) @dom_wrangler.request_change(js) end - # Return whether the DOM is, right this moment, confirmed to be fully - # up to date or not. - # def dom_fully_updated? - # @dom_wrangler.fully_updated? - # end - # Return a promise that will be fulfilled when all current DOM changes # have committed (but not necessarily any future DOM changes.) def dom_redraw @@ -439,26 +256,19 @@ def on_every_redraw(&block) # changes waiting to catch the next bus. But we don't want more than one in flight, # since it seems like having too many pending RPC requests can crash wasm. So: # one redraw scheduled and one redraw promise waiting around, at maximum. -class Scarpe +module Scarpe class WebWrangler class DOMWrangler include Shoes::Log attr_reader :waiting_changes - # attr_reader :pending_redraw_promise - # attr_reader :waiting_redraw_promise - def initialize(web_wrangler, debug: false) - log_init("WASM::WebWrangler::DOMWrangler") + log_init("Wasm::WebWrangler::DOMWrangler") @wrangler = web_wrangler @waiting_changes = [] - # @pending_redraw_promise = nil - # @waiting_redraw_promise = nil - - # @fully_up_to_date_promise = nil # Initially we're waiting for a full replacement to happen. # It's possible to request updates/changes before we have @@ -467,19 +277,6 @@ def initialize(web_wrangler, debug: false) @first_draw_requested = false @redraw_handlers = [] - - # The "fully up to date" logic is complicated and not - # as well tested as I'd like. This makes it far less - # likely that the event simply won't fire. - # With more comprehensive testing, this should be - # removable. - # web_wrangler.periodic_code("scarpeDOMWranglerHeartbeat") do - # if @fully_up_to_date_promise && fully_updated? - # @log.info("Fulfilling up-to-date promise on heartbeat") - # @fully_up_to_date_promise.fulfilled! - # @fully_up_to_date_promise = nil - # end - # end end def request_change(js_code) @@ -510,124 +307,13 @@ def on_every_redraw(&block) @redraw_handlers << block end - # What are the states of redraw? - # "empty" - no waiting promise, no pending-redraw promise, no pending changes - # "pending only" - no waiting promise, but we have a pending redraw with some changes; it hasn't committed yet - # "pending and waiting" - we have a waiting promise for our unscheduled changes; we can add more unscheduled - # changes since we haven't scheduled them yet. - # - # This is often called after adding a new waiting change or replacing them, so the state may have just changed. - # It can also be called when no changes have been made and no updates need to happen. def redraw - # if fully_updated? - # # No changes to make, nothing in-process or waiting, so just return a pre-fulfilled promise - # @log.debug("Requesting redraw but there are no pending changes or promises, return pre-fulfilled") - # return Promise.fulfilled - # end - - # Already have a redraw requested *and* one on deck? Then all current changes will have committed - # when we (eventually) fulfill the waiting_redraw_promise. - # if @waiting_redraw_promise - # @log.debug("Promising eventual redraw of #{@waiting_changes.size} waiting unscheduled changes.") - # return @waiting_redraw_promise - # end - - # if @waiting_changes.empty? - # # There's no waiting_redraw_promise. There are no waiting changes. But we're not fully updated. - # # So there must be a redraw in flight, and we don't need to schedule a new waiting_redraw_promise. - # @log.debug("Returning in-flight redraw promise") - # return @pending_redraw_promise - # end - - # @log.debug("Requesting redraw with #{@waiting_changes.size} waiting changes - need to schedule something!") - - # We have at least one waiting change, possibly newly-added. We have no waiting_redraw_promise. - # Do we already have a redraw in-flight? - # if @pending_redraw_promise - # # Yes we do. Schedule a new waiting promise. When it turns into the pending_redraw_promise it will - # # grab all waiting changes. In the mean time, it sits here and waits. - # # - # # We *could* do a fancy promise thing and have it update @waiting_changes for itself, etc, when it - # # schedules itself. But we should always be calling promise_redraw or having a redraw fulfilled (see below) - # # when these things change. I'd rather keep the logic in this method. It's easier to reason through - # # all the cases. - # @waiting_redraw_promise = Promise.new - - # @log.debug("Creating a new waiting promise since a pending promise is already in place") - # return @waiting_redraw_promise - # end - - # We have no redraw in-flight and no pre-existing waiting line. The new change(s) are presumably right - # after things were fully up-to-date. We can schedule them for immediate redraw. - @log.debug("Requesting redraw with #{@waiting_changes.size} waiting changes - scheduling a new redraw for them!") - # promise = schedule_waiting_changes # This clears the waiting changes schedule_waiting_changes - # @pending_redraw_promise = promise @redraw_handlers.each(&:call) - # @pending_redraw_promise = nil - - # if @waiting_redraw_promise - # # While this redraw was in flight, more waiting changes got added and we made a promise - # # about when they'd complete. Now they get scheduled, and we'll fulfill the waiting - # # promise when that redraw finishes. Clear the old waiting promise. We'll add a new one - # # when/if more changes are scheduled during this redraw. - # old_waiting_promise = @waiting_redraw_promise - # @waiting_redraw_promise = nil - - # @log.debug "Fulfilled redraw with #{@waiting_changes.size} waiting changes - scheduling a new redraw for them!" - - # new_promise = promise_redraw - # new_promise.on_fulfilled { old_waiting_promise.fulfilled! } - # else - # The in-flight redraw completed, and there's still no waiting promise. Good! That means - # we should be fully up-to-date. - # @log.debug "Fulfilled redraw with no waiting changes - marking us as up to date!" - # if @waiting_changes.empty? - # # We're fully up to date! Fulfill the promise. Now we don't need it again until somebody asks - # # us for another. - # if @fully_up_to_date_promise - # @fully_up_to_date_promise.fulfilled! - # @fully_up_to_date_promise = nil - # end - # else - # @log.error "WHOAH, WHAT? My logic must be wrong, because there's " + - # "no waiting promise, but waiting changes!" - # end - # end - - # @log.debug("Redraw is now fully up-to-date") if fully_updated? - # end.on_rejected do - # @log.error "Could not complete JS redraw! #{promise.reason.full_message}" - # @log.debug("REDRAW FULLY UP TO DATE BUT JS FAILED") if fully_updated? - - # raise "JS Redraw failed! Bailing!" - - # # Later we should figure out how to handle this. Clear the promises and queues and request another redraw? - # end end - # def fully_updated? - # @pending_redraw_promise.nil? && @waiting_redraw_promise.nil? && @waiting_changes.empty? - # end - - # Return a promise which will be fulfilled when the DOM is fully up-to-date - # def promise_fully_updated - # if fully_updated? - # # No changes to make, nothing in-process or waiting, so just return a pre-fulfilled promise - # return Promise.fulfilled - # end - - # # Do we already have a promise for this? Return it. Everybody can share one. - # if @fully_up_to_date_promise - # return @fully_up_to_date_promise - # end - - # # We're not fully updated, so we need a promise. Create it, return it. - # @fully_up_to_date_promise = Promise.new - # end - private # Put together the waiting changes into a new in-flight redraw request. @@ -645,13 +331,13 @@ def schedule_waiting_changes # For now we don't need one of these to add DOM elements, just to manipulate them # after initial render. -class Scarpe +module Scarpe class WebWrangler class ElementWrangler attr_reader :html_id def initialize(html_id) - @webwrangler = WASMDisplayService.instance.wrangler + @webwrangler = Scarpe::Wasm::DisplayService.instance.wrangler @html_id = html_id end @@ -697,7 +383,6 @@ def toggle_input_button(mark) checked_value = mark ? "true" : "false" @webwrangler.dom_change("document.getElementById('#{html_id}').checked = #{checked_value};") end - end end end diff --git a/lib/scarpe/wasm_local.rb b/lib/scarpe/wasm_local.rb index 5c0c558..40e1450 100644 --- a/lib/scarpe/wasm_local.rb +++ b/lib/scarpe/wasm_local.rb @@ -6,12 +6,35 @@ require "shoes" require "lacci/scarpe_core" +require "scarpe/components/string_helpers" + # For Wasm, use simple no-dependency printing logger require "scarpe/components/print_logger" Shoes::Log.instance = Scarpe::Components::PrintLogImpl.new Shoes::Log.configure_logger(Shoes::Log::DEFAULT_LOG_CONFIG) +require "scarpe/components/segmented_file_loader" +loader = Scarpe::Components::SegmentedFileLoader.new +Shoes.add_file_loader loader + +# TODO: Shoes::Spec +#if ENV["SHOES_SPEC_TEST"] +# require_relative "shoes_spec" +# Shoes::Spec.instance = Scarpe::Test +#end + +require "scarpe/components/html" +module Scarpe::Wasm + HTML = Scarpe::Components::HTML + + class Drawable < Shoes::Linkable + require "scarpe/components/calzini" + # This is where we would make the HTML renderer modular by choosing another + include Scarpe::Components::Calzini + end +end + require_relative "wasm" require_relative "wasm/wasm_local_display" -Shoes::DisplayService.set_display_service_class(Scarpe::WASMDisplayService) +Shoes::DisplayService.set_display_service_class(Scarpe::Wasm::DisplayService) diff --git a/test/test_helper.rb b/test/test_helper.rb index acf6b2e..1c21335 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -52,13 +52,14 @@ def setup def build_test_wasm_package return if TEST_DATA[:wasm_built] - Dir.chdir TEST_CACHE_DIR + Dir.chdir TEST_CACHE_DIR # This needs to be true during the test, but is there a better place for it? FileUtils.touch "src/APP_NAME.rb" # Use this to have a boilerplate name to search/replace # Need to use the TEST_CACHE_DIR Bundler env, *not* the one for the test harness. Bundler.with_unbundled_env do system("bundle exec wasify src/APP_NAME.rb") || raise("Couldn't wasify-build!") end + raise "Wasify didn't create packed_ruby.wasm!" unless File.exist?("packed_ruby.wasm") TEST_DATA[:wasm_built] = true end