lib/scarpe/wv/web_wrangler.rb in scarpe-0.2.1 vs lib/scarpe/wv/web_wrangler.rb in scarpe-0.2.2
- old
+ new
@@ -7,27 +7,61 @@
# After creation, it starts in setup mode, and you can
# use setup-mode callbacks.
class Scarpe
+ # The Scarpe WebWrangler, for Webview, manages a lot of Webviews quirks. It provides
+ # a simpler underlying abstraction for DOMWrangler and the Webview widgets.
+ # Webview can be picky - if you send it too many messages, it can crash. If the
+ # messages you send it are too large, it can crash. If you don't return control
+ # to its event loop, it can crash. It doesn't save references to all event handlers,
+ # so if you don't save references to them, garbage collection will cause it to
+ # crash.
+ #
+ # As well, Webview only supports asynchronous JS code evaluation with no value
+ # being returned. One of WebWrangler's responsibilities is to make asynchronous
+ # JS calls, detect when they return a value or time out, and make the result clear
+ # to other Scarpe code.
+ #
+ # Some Webview API functions will crash on some platforms if called from a
+ # background thread. Webview will halt all background threads when it runs its
+ # event loop. So it's best to assume no Ruby background threads will be available
+ # while Webview is running. If a Ruby app wants ongoing work to occur, that work
+ # should be registered via a heartbeat handler on the Webview.
+ #
+ # A WebWrangler is initially in Setup mode, where the underlying Webview exists
+ # but does not yet control the event loop. In Setup mode you can bind JS functions,
+ # set up initialization code, but nothing is yet running.
+ #
+ # Once run() is called on WebWrangler, we will hand control of the event loop to
+ # the Webview. This will also stop any background threads in Ruby.
class WebWrangler
- include Scarpe::Log
+ include Shoes::Log
+ # Whether Webview has been started. Once Webview is running you can't add new
+ # Javascript bindings. Until it is running, you can't use eval to run Javascript.
attr_reader :is_running
+
+ # Once Webview is marked terminated, it's attempting to shut down. If we get
+ # events (e.g. heartbeats) after that, we should ignore them.
attr_reader :is_terminated
- attr_reader :heartbeat # This is the heartbeat duration in seconds, usually fractional
+
+ # This is the time between heartbeats in seconds, usually fractional
+ attr_reader :heartbeat
+
+ # A reference to the control_interface that manages internal Scarpe Webview events.
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
+ # 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
@@ -35,34 +69,41 @@
# 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
+ # This is the JS function name for eval results (internal-only)
EVAL_RESULT = "scarpeAsyncEvalResult"
- # Allow a half-second for Webview to finish our JS eval before we decide it's not going to
+ # Allow this many seconds for Webview 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)
+ # Create a new WebWrangler.
+ #
+ # @param title [String] window title
+ # @param width [Integer] window width in pixels
+ # @param height [Integer] window height in pixels
+ # @param resizable [Boolean] whether the window should be resizable by the user
+ # @param heartbeat [Float] time between heartbeats in seconds
+ def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1)
log_init("WV::WebWrangler")
@log.debug("Creating WebWrangler...")
- # For now, always allow inspect element
+ # For now, always allow inspect element, so pass debug: true
@webview = WebviewRuby::Webview.new debug: true
- @webview = Scarpe::LoggedWrapper.new(@webview, "WebviewAPI") if debug
+ @webview = Shoes::LoggedWrapper.new(@webview, "WebviewAPI") if ENV["SCARPE_DEBUG"]
@init_refs = {} # Inits don't go away so keep a reference to them to prevent GC
@title = title
@width = width
@height = height
@resizable = resizable
@heartbeat = heartbeat
- # Better to have a single setInterval than many when we don't care too much
- # about the timing.
+ # JS setInterval uses RPC and is quite expensive. For many periodic operations
+ # we can group them under a single heartbeat handler and avoid extra JS calls or RPC.
@heartbeat_handlers = []
# Need to keep track of which WebView Javascript evals are still pending,
# what handlers to call when they return, etc.
@pending_evals = {}
@@ -98,30 +139,48 @@
attr_writer :control_interface
### Setup-mode Callbacks
+ # Bind a Javascript-callable function by name. When JS calls the function,
+ # an async message is sent to Ruby via RPC and will eventually cause the
+ # block to be called. This method only works in setup mode, before the
+ # underlying Webview has been told to run.
+ #
+ # @param name [String] the Javascript name for the new function
+ # @yield The Ruby block to be invoked when JS calls the function
def bind(name, &block)
raise "App is running, javascript binding no longer works because it uses WebView init!" if @is_running
@webview.bind(name, &block)
end
+ # Request that this block of code be run initially when the Webview is run.
+ # This operates via #init and will not work if Webview is already running.
+ #
+ # @param name [String] the Javascript name for the init function
+ # @yield The Ruby block to be invoked when Webview runs
def init_code(name, &block)
raise "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
+ # Save a reference to the init string so that it doesn't get GC'd
code_str = "#{name}();"
@init_refs[name] = code_str
bind(name, &block)
@webview.init(code_str)
end
# Run the specified code periodically, every "interval" seconds.
- # If interface is unspecified, run per-heartbeat, which is very
- # slightly more efficient.
+ # If interval is unspecified, run per-heartbeat. This avoids extra
+ # RPC and Javascript overhead. This may use the #init mechanism,
+ # so it should be invoked when the WebWrangler is in setup mode,
+ # before the Webview is running.
+ #
+ # @param name [String] the name of the Javascript init function, if needed
+ # @param interval [Float] the duration between invoking this block
+ # @yield the Ruby block to invoke periodically
def periodic_code(name, interval = heartbeat, &block)
if interval == heartbeat
@heartbeat_handlers << block
else
if @is_running
@@ -141,43 +200,52 @@
end
end
# Running callbacks
- # js_eventually is a simple JS evaluation. On syntax error, nothing happens.
+ # js_eventually is a native Webview JS evaluation. On syntax error, nothing happens.
# On runtime error, execution stops at the error with no further
# effect or notification. This is rarely what you want.
# The js_eventually code is run asynchronously, returning neither error
# nor value.
#
# This method does *not* return a promise, and there is no way to track
# its progress or its success or failure.
+ #
+ # @param code [String] the Javascript code to attempt to execute
+ # @return [void]
def js_eventually(code)
raise "WebWrangler isn't running, eval doesn't work!" unless @is_running
- @log.warning "Deprecated: please do NOT use js_eventually, it's basically never what you want!" unless ENV["CI"]
+ @log.warn "Deprecated: please do NOT use js_eventually, it's basically never what you want!" unless ENV["CI"]
@webview.eval(code)
end
# Eval a chunk of JS code asynchronously. This method returns a
# promise which will be fulfilled or rejected after the JS executes
# or times out.
#
- # Note that we *both* care whether the JS has finished after it was
+ # We *both* care whether the JS has finished after it was
# scheduled *and* whether it ever got scheduled at all. If it
- # depends on tasks that never fulfill or reject then it may wait
- # in limbo, potentially forever.
+ # depends on tasks that never fulfill or reject then it will
+ # raise a timed-out exception.
#
- # Right now we can't/don't handle arguments from previous fulfilled
- # promises. To do that, we'd probably need to know we were passing
- # 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?
-
+ # Right now we can't/don't pass arguments through from previous fulfilled
+ # promises. To do that, you can schedule the JS to run after the
+ # other promises succeed.
+ #
+ # Webview does not allow interacting with a JS eval once it has
+ # been scheduled. So there is no way to guarantee that a piece of JS has
+ # not executed, or will not execute in the future. A timeout exception
+ # only means that WebWrangler will no longer wait for confirmation or
+ # fulfill the promise if the JS later completes.
+ #
+ # @param code [String] the Javascript code to execute
+ # @param timeout [Float] how long to allow before raising a timeout exception
+ # @param wait_for [Array<Promise>] promises that must complete successfully before this JS is scheduled
+ def eval_js_async(code, timeout: EVAL_DEFAULT_TIMEOUT, wait_for: [])
unless @is_running
raise "WebWrangler isn't running, so evaluating JS won't work!"
end
this_eval_serial = @eval_counter
@@ -190,13 +258,12 @@
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
+ promise = Scarpe::Promise.new(parents: wait_for) do
# Are we mid-shutdown?
if @webview
wrapped_code = WebWrangler.js_wrapped_code(code, this_eval_serial)
# We've been scheduled!
@@ -218,10 +285,20 @@
@pending_evals[this_eval_serial][:promise] = promise
promise
end
+ # This method takes a piece of Javascript code and wraps it in the WebWrangler
+ # boilerplate to see if it parses successfully, run it, and see if it succeeds.
+ # This function would normally be used by testing code, to mock Webview and
+ # watch for code being run. Javascript code containing backticks
+ # could potentially break this abstraction layer, which would cause the resulting
+ # code to fail to parse and Webview would return no error. This should not be
+ # used for random or untrusted code.
+ #
+ # @param code [String] the Javascript code to be wrapped
+ # @param eval_id [Integer] the tracking code to use when calling EVAL_RESULT
def self.js_wrapped_code(code, eval_id)
<<~JS_CODE
(function() {
var code_string = #{JSON.dump code};
try {
@@ -266,15 +343,15 @@
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 Webview will always execute JS evals in order.
- # This all adds complexity, though. For now, do timeouts on a simple max duration.
+ # @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 Webview 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
@@ -302,12 +379,11 @@
end
public
# After setup, we call run to go to "running" mode.
- # No more setup callbacks, only running callbacks.
-
+ # No more setup callbacks should be called, only running callbacks.
def run
@log.debug("Run...")
# From webview:
# 0 - Width and height are default size
@@ -327,10 +403,12 @@
@is_running = false
@webview.destroy
@webview = nil
end
+ # Request destruction of WebWrangler, including terminating the underlying
+ # Webview and (when possible) destroying it.
def destroy
@log.debug("Destroying WebWrangler...")
@log.debug(" (WebWrangler was already terminated)") if @is_terminated
@log.debug(" (WebWrangler was already destroyed)") unless @webview
if @webview && !@is_terminated
@@ -387,73 +465,116 @@
CGI.escape(html)
end
public
- # For now, the WebWrangler gets a bunch of fairly low-level requests
- # to mess with the HTML DOM. This needs to be turned into a nicer API,
- # but first we'll get it all into one place and see what we're doing.
-
# Replace the entire DOM - return a promise for when this has been done.
# This will often get rid of smaller changes in the queue, which is
# a good thing since they won't have to be run.
+ #
+ # @param html_text [String] The new HTML for the new full DOM
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the update is complete
def replace(html_text)
@dom_wrangler.request_replace(html_text)
end
# Request a DOM change - return a promise for when this has been done.
+ # If a full replacement (see #replace) is requested, this change may
+ # be lost. Only use it for changes that are preserved by a full update.
+ #
+ # @param js [String] the JS to execute to alter the DOM
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the update is complete
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.
+ #
+ # @return [Boolean] true if the window is fully updated, false if changes are pending
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.)
+ # have committed. If other changes are requested before these
+ # complete, the promise will ***not*** wait for them. If you wish to
+ # wait until all changes from all sources have completed, use
+ # #promise_dom_fully_updated.
+ #
+ # @return [Scarpe::Promise] a promise that will be fulfilled when all current changes complete
def dom_promise_redraw
@dom_wrangler.promise_redraw
end
# Return a promise which will be fulfilled the next time the DOM is
- # fully up to date. Note that a slow trickle of changes can make this
- # take a long time, since it is *not* only changes up to this point.
- # If you want to know that some specific change is done, it's often
- # easiest to use the promise returned by dom_change(), which will
- # be fulfilled when that specific change commits.
+ # fully up to date. A slow trickle of changes can make this
+ # take a long time, since it includes all current and future changes,
+ # not just changes before this call.
+ #
+ # If you want to know that some specific individual change is done, it's often
+ # easiest to use the promise returned by #dom_change, which will
+ # be fulfilled when that specific change is verified complete.
+ #
+ # If no changes are pending, promise_dom_fully_updated will
+ # return a promise that is already fulfilled.
+ #
+ # @return [Scarpe::Promise] a promise that will be fulfilled when all changes are complete
def promise_dom_fully_updated
@dom_wrangler.promise_fully_updated
end
+ # DOMWrangler will frequently schedule and confirm small JS updates.
+ # A handler registered with on_every_redraw will be called after each
+ # small update.
+ #
+ # @yield Called after each update or batch of updates is verified complete
+ # @return [void]
def on_every_redraw(&block)
@dom_wrangler.on_every_redraw(&block)
end
end
end
-# Leaving DOM changes as "meh, async, we'll see when it happens" is terrible for testing.
-# Instead, we need to track whether particular changes have committed yet or not.
-# So we add a single gateway for all DOM changes, and we make sure its work is done
-# before we consider a redraw complete.
-#
-# DOMWrangler batches up changes - it's fine to have a redraw "in flight" and have
-# 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 Webview. So:
-# one redraw scheduled and one redraw promise waiting around, at maximum.
class Scarpe
class WebWrangler
+ # Leaving DOM changes as "meh, async, we'll see when it happens" is terrible for testing.
+ # Instead, we need to track whether particular changes have committed yet or not.
+ # So we add a single gateway for all DOM changes, and we make sure its work is done
+ # before we consider a redraw complete.
+ #
+ # DOMWrangler batches up changes into fewer RPC calls. It's fine to have a redraw
+ # "in flight" and have 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 Webview. So we allow one redraw scheduled and one redraw promise waiting,
+ # at maximum.
+ #
+ # A WebWrangler will create and wrap a DOMWrangler, serving as the interface
+ # for all DOM operations.
+ #
+ # A batch of DOMWrangler changes may be removed if a full update is scheduled. That
+ # update is considered to replace the previous incremental changes. Any changes that
+ # need to execute even if a full update happens should be scheduled through
+ # WebWrangler#eval_js_async, not DOMWrangler.
class DOMWrangler
- include Scarpe::Log
+ include Shoes::Log
+ # Changes that have not yet been executed
attr_reader :waiting_changes
+
+ # A Scarpe::Promise for JS that has been scheduled to execute but is not yet verified complete
attr_reader :pending_redraw_promise
+
+ # A Scarpe::Promise for waiting changes - it will be fulfilled when all waiting changes
+ # have been verified complete, or when a full redraw that removed them has been
+ # verified complete. If many small changes are scheduled, the same promise will be
+ # returned for many of them.
attr_reader :waiting_redraw_promise
- def initialize(web_wrangler, debug: false)
+ # Create a DOMWrangler that is paired with a WebWrangler. The WebWrangler is
+ # treated as an underlying abstraction for reliable JS evaluation.
+ def initialize(web_wrangler)
log_init("WV::WebWrangler::DOMWrangler")
@wrangler = web_wrangler
@waiting_changes = []
@@ -508,10 +629,14 @@
def on_every_redraw(&block)
@redraw_handlers << block
end
+ # promise_redraw returns a Scarpe::Promise which will be fulfilled after all current
+ # pending or waiting changes have completed. This may require creating a new
+ # promise.
+ #
# 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.
@@ -641,39 +766,94 @@
end
end
end
end
-# For now we don't need one of these to add DOM elements, just to manipulate them
-# after initial render.
class Scarpe
class WebWrangler
+ # An ElementWrangler provides a way for a Widget to manipulate is DOM element(s)
+ # via their HTML IDs. The most straightforward Widgets can have a single HTML ID
+ # and use a single ElementWrangler to make any needed changes.
+ #
+ # For now we don't need an ElementWrangler to add DOM elements, just to manipulate them
+ # after initial render. New DOM objects for Widgets are normally added via full
+ # redraws rather than incremental updates.
+ #
+ # Any changes made via ElementWrangler may be cancelled if a full redraw occurs,
+ # since it is assumed that small DOM manipulations are no longer needed. If a
+ # change would need to be made even if a full redraw occurred, it should be
+ # scheduled via WebWrangler#eval_js_async, not via an ElementWrangler.
class ElementWrangler
attr_reader :html_id
+ # Create an ElementWrangler for the given HTML ID
+ #
+ # @param html_id [String] the HTML ID for the DOM element
def initialize(html_id)
@webwrangler = WebviewDisplayService.instance.wrangler
@html_id = html_id
end
+ # Return a promise that will be fulfilled when all changes scheduled via
+ # this ElementWrangler are verified complete.
+ #
+ # @return [Scarpe::Promise] a promise that will be fulfilled when scheduled changes are complete
def promise_update
@webwrangler.dom_promise_redraw
end
+ # Update the JS DOM element's value. The given Ruby value will be converted to string and assigned in backquotes.
+ #
+ # @param new_value [String] the new value
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the change is complete
def value=(new_value)
@webwrangler.dom_change("document.getElementById('" + html_id + "').value = `" + new_value + "`; true")
end
+ # Update the JS DOM element's inner_text. The given Ruby value will be converted to string and assigned in single-quotes.
+ #
+ # @param new_text [String] the new inner_text
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the change is complete
def inner_text=(new_text)
@webwrangler.dom_change("document.getElementById('" + html_id + "').innerText = '" + new_text + "'; true")
end
+ # Update the JS DOM element's inner_html. The given Ruby value will be converted to string and assigned in backquotes.
+ #
+ # @param new_html [String] the new inner_html
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the change is complete
def inner_html=(new_html)
@webwrangler.dom_change("document.getElementById(\"" + html_id + "\").innerHTML = `" + new_html + "`; true")
end
+ # Update the JS DOM element's inner_html. The given Ruby value will be inspected and assigned.
+ #
+ # @param attribute [String] the attribute name
+ # @param value [String] the new attribute value
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the change is complete
+ def set_attribute(attribute, value)
+ @webwrangler.dom_change("document.getElementById(\"" + html_id + "\").setAttribute(" + attribute.inspect + "," + value.inspect + "); true")
+ end
+
+ # Update an attribute of the JS DOM element's style. The given Ruby value will be inspected and assigned.
+ #
+ # @param style_attr [String] the style attribute name
+ # @param value [String] the new style attribute value
+ # @return [Scarpe::Promise] a promise that will be fulfilled when the change is complete
+ def set_style(style_attr, value)
+ @webwrangler.dom_change("document.getElementById(\"" + html_id + "\").style.#{style_attr} = " + value.inspect + "; true")
+ end
+
+ # Remove the specified DOM element
+ #
+ # @return [Scarpe::Promise] a promise that wil be fulfilled when the element is removed
def remove
@webwrangler.dom_change("document.getElementById('" + html_id + "').remove(); true")
+ end
+
+ def toggle_input_button(mark)
+ checked_value = mark ? "true" : "false"
+ @webwrangler.dom_change("document.getElementById('#{html_id}').checked = #{checked_value};")
end
end
end
end