diff --git a/examples/motion_events.rb b/examples/motion_events.rb new file mode 100644 index 000000000..4889b1961 --- /dev/null +++ b/examples/motion_events.rb @@ -0,0 +1,20 @@ +Shoes.app width: 600, height: 600 do + stack do + para "Events and Menus" + flow do + @btn1 = button "button 1", width: 75 do + @eb.append "button 1 clicked\n" + end + click do + @eb.append "flow click\n" + end + hover do + @eb.append "flow hover\n" + end + end + @eb = edit_box width: 500, height: 350 + end + motion do |x, y, mods| + @eb.append "motion #{x},#{y} #{mods} " + end +end diff --git a/lib/scarpe/app.rb b/lib/scarpe/app.rb index 63de6c13d..6e84848b3 100644 --- a/lib/scarpe/app.rb +++ b/lib/scarpe/app.rb @@ -169,5 +169,17 @@ def border(...) current_slot.border(...) end + def motion(&block) + subscription_item(shoes_api_name: "motion", &block) + end + + def hover(&block) + subscription_item(shoes_api_name: "hover", &block) + end + + def click(&block) + subscription_item(shoes_api_name: "click", &block) + end + alias_method :info, :puts end diff --git a/lib/scarpe/display_service.rb b/lib/scarpe/display_service.rb index 5bc7be1c8..dd4d1a789 100644 --- a/lib/scarpe/display_service.rb +++ b/lib/scarpe/display_service.rb @@ -160,6 +160,10 @@ def send_shoes_event(*args, event_name:, target: nil, **kwargs) def bind_shoes_event(event_name:, target: nil, &handler) DisplayService.subscribe_to_event(event_name, target, &handler) end + + def unsub_shoes_event(unsub_id) + DisplayService.unsub_from_events(unsub_id) + end end # These methods are an interface to DisplayService objects. diff --git a/lib/scarpe/document_root.rb b/lib/scarpe/document_root.rb index bd9d8fa72..eaa85fd6e 100644 --- a/lib/scarpe/document_root.rb +++ b/lib/scarpe/document_root.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class Scarpe - class DocumentRoot < Scarpe::Slot + class DocumentRoot < Scarpe::Flow def initialize + @height = "100%" + @width = @margin = @padding = nil + @options = {} + super create_display_widget diff --git a/lib/scarpe/edit_box.rb b/lib/scarpe/edit_box.rb index e69a4687e..265c84d95 100644 --- a/lib/scarpe/edit_box.rb +++ b/lib/scarpe/edit_box.rb @@ -5,7 +5,7 @@ class EditBox < Scarpe::Widget display_properties :text, :height, :width def initialize(text = nil, height: nil, width: nil, &block) - @text = text.nil? ? block&.call : text || "" + @text = (text.nil? ? block&.call : text) || "" super @@ -20,5 +20,9 @@ def initialize(text = nil, height: nil, width: nil, &block) def change(&block) @callback = block end + + def append(new_text) + self.text = self.text + new_text + end end end diff --git a/lib/scarpe/flow.rb b/lib/scarpe/flow.rb index 8f2e3df8b..5d647c2bc 100644 --- a/lib/scarpe/flow.rb +++ b/lib/scarpe/flow.rb @@ -4,7 +4,9 @@ class Scarpe class Flow < Scarpe::Slot display_properties :width, :height, :margin, :padding - def initialize(width: nil, height: "100%", margin: nil, padding: nil, &block) + def initialize(width: nil, height: nil, margin: nil, padding: nil, **options, &block) + @options = options + super # Create the display-side widget *before* instance_eval, which will add child widgets with their display widgets diff --git a/lib/scarpe/slot.rb b/lib/scarpe/slot.rb index b8f3e1f6a..46c5860d7 100644 --- a/lib/scarpe/slot.rb +++ b/lib/scarpe/slot.rb @@ -3,4 +3,5 @@ class Scarpe::Slot < Scarpe::Widget include Scarpe::Background include Scarpe::Border + include Scarpe::Spacing end diff --git a/lib/scarpe/spacing.rb b/lib/scarpe/spacing.rb index ad2c16b73..6c4974d9c 100644 --- a/lib/scarpe/spacing.rb +++ b/lib/scarpe/spacing.rb @@ -3,7 +3,7 @@ class Scarpe module Spacing def self.included(includer) - includer.display_properties :margin, :padding + includer.display_properties :margin, :padding, :margin_top, :margin_left, :margin_right, :margin_bottom, :options end end end diff --git a/lib/scarpe/stack.rb b/lib/scarpe/stack.rb index 2442f1666..18dbb7ad5 100644 --- a/lib/scarpe/stack.rb +++ b/lib/scarpe/stack.rb @@ -2,14 +2,12 @@ class Scarpe class Stack < Scarpe::Slot - include Scarpe::Spacing + # TODO: sort out various margin and padding properties, including putting stuff into spacing + display_properties :width, :height, :scroll - display_properties :width, :height, :margin, :padding, :scroll, :margin_top, :margin_left, :margin_right, :margin_bottom, :options - - def initialize(width: nil, height: "100%", margin: nil, padding: nil, scroll: false, margin_top: nil, margin_bottom: nil, margin_left: nil, + def initialize(width: nil, height: nil, margin: nil, padding: nil, scroll: false, margin_top: nil, margin_bottom: nil, margin_left: nil, margin_right: nil, **options, &block) - # TODO: what are these options? Are they guaranteed serializable? @options = options super diff --git a/lib/scarpe/subscription_item.rb b/lib/scarpe/subscription_item.rb new file mode 100644 index 000000000..8ea207082 --- /dev/null +++ b/lib/scarpe/subscription_item.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Certain Shoes calls like motion and keydown are basically an +# event subscription, with no other visible presence. However, +# they have a place in the widget tree and can be deleted. +# +# Depending on the display library they may not have any +# direct visual (or similar) presence there either. +# +# Inheriting from Widget gives these a parent slot and a +# linkable_id automatically. +class SubscriptionItem < Scarpe::Widget + display_property :shoes_api_name + + def initialize(shoes_api_name:, &block) + super + + @callback = block + + case shoes_api_name + when "hover" + # Hover passes the Shoes widget as the block param + @unsub_id = bind_self_event("hover") do + @callback&.call(self) + end + when "motion" + # Shoes sends back x, y, mods as the args. + # Shoes3 uses the strings "control" "shift" and + # "control_shift" as the mods arg. + @unsub_id = bind_self_event("motion") do |x, y, ctrl_key, shift_key, **_kwargs| + mods = [ctrl_key ? "control" : nil, shift_key ? "shift" : nil].compact.join("_") + @callback&.call(x, y, mods) + end + when "click" + # Click has block params button, left, top + # button is the button number, left and top are coords + @unsub_id = bind_self_event("click") do |button, x, y, **_kwargs| + @callback&.call(button, x, y) + end + else + raise "Unknown Shoes API call #{shoes_api_name.inspect} passed to SubscriptionItem!" + end + + @unsub_id = bind_self_event(shoes_api_name) do |*args| + @callback&.call(*args) + end + + # This won't create a visible display widget, but will turn into + # an invisible widget and a stream of events. + create_display_widget + end + + def destroy + # TODO: we need a better way to do this automatically. See https://github.com/scarpe-team/scarpe/issues/291 + unsub_shoes_event(@unsub_id) if @unsub_id + @unsub_id = nil + + super + end +end diff --git a/lib/scarpe/widgets.rb b/lib/scarpe/widgets.rb index 012347b29..1f6bf23d9 100644 --- a/lib/scarpe/widgets.rb +++ b/lib/scarpe/widgets.rb @@ -12,11 +12,12 @@ require_relative "fill" require_relative "slot" -require_relative "document_root" require_relative "para" require_relative "stack" require_relative "flow" +require_relative "document_root" require_relative "download" +require_relative "subscription_item" require_relative "button" require_relative "image" require_relative "edit_box" diff --git a/lib/scarpe/wv.rb b/lib/scarpe/wv.rb index 2b5495bfc..5de670095 100644 --- a/lib/scarpe/wv.rb +++ b/lib/scarpe/wv.rb @@ -22,9 +22,11 @@ require_relative "wv/app" require_relative "wv/para" +require_relative "wv/slot" require_relative "wv/stack" require_relative "wv/flow" require_relative "wv/document_root" +require_relative "wv/subscription_item" require_relative "wv/button" require_relative "wv/image" require_relative "wv/edit_box" diff --git a/lib/scarpe/wv/flow.rb b/lib/scarpe/wv/flow.rb index 6a94d947c..3c57bebef 100644 --- a/lib/scarpe/wv/flow.rb +++ b/lib/scarpe/wv/flow.rb @@ -1,21 +1,12 @@ # frozen_string_literal: true class Scarpe - class WebviewFlow < Scarpe::WebviewWidget - include Scarpe::WebviewBackground - include Scarpe::WebviewBorder - + class WebviewFlow < Scarpe::WebviewSlot def initialize(properties) super end - def element(&block) - HTML.render do |h| - h.div(id: html_id, style:, &block) - end - end - - private + protected def style styles = super @@ -23,8 +14,6 @@ def style styles[:display] = "flex" styles["flex-direction"] = "row" styles["flex-wrap"] = "wrap" - styles[:width] = Dimensions.length(@width) if @width - styles[:height] = Dimensions.length(@height) if @height styles end diff --git a/lib/scarpe/wv/shape.rb b/lib/scarpe/wv/shape.rb index b28d7326a..6dfc676ea 100644 --- a/lib/scarpe/wv/shape.rb +++ b/lib/scarpe/wv/shape.rb @@ -3,6 +3,7 @@ require_relative "shape_helper" class Scarpe + # Should inherit from Slot? class WebviewShape < Scarpe::WebviewWidget include ShapeHelper diff --git a/lib/scarpe/wv/slot.rb b/lib/scarpe/wv/slot.rb new file mode 100644 index 000000000..8bc0cc7a7 --- /dev/null +++ b/lib/scarpe/wv/slot.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Scarpe + class WebviewSlot < Scarpe::WebviewWidget + include Scarpe::WebviewBackground + include Scarpe::WebviewBorder + include Scarpe::WebviewSpacing + + def initialize(properties) + @event_callbacks = {} + + super + end + + def element(&block) + HTML.render do |h| + h.div(attributes.merge(id: html_id, style: style), &block) + end + 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!" + end + + @event_callbacks[event_name][obj] = js_code + + update_dom_event(event_name) + end + + def remove_event_callback(obj, event_name) + event_name = event_name.to_s + @event_callbacks[event_name] ||= {} + @event_callbacks[event_name].delete(obj) + + update_dom_event(event_name) + end + + def remove_event_callbacks(obj) + changed = [] + + @event_callbacks.each do |event_name, items| + changed << event_name if items.delete(obj) + end + + changed.each { |event_name| update_dom_event(event_name) } + end + + protected + + def update_dom_event(event_name) + html_element.set_attribute(event_name, @event_callbacks[event_name].values.join(";")) + end + + def attributes + attr = {} + + @event_callbacks.each do |event_name, handlers| + attr[event_name] = handlers.values.join(";") + end + + 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/wv/spacing.rb b/lib/scarpe/wv/spacing.rb index c73831af2..0636e0224 100644 --- a/lib/scarpe/wv/spacing.rb +++ b/lib/scarpe/wv/spacing.rb @@ -5,7 +5,7 @@ module WebviewSpacing SPACING_DIRECTIONS = [:left, :right, :top, :bottom] def style - styles = (super if defined?(super)) || {} + styles = defined?(super) ? super : {} extract_spacing_styles_for(:margin, styles, @margin) extract_spacing_styles_for(:padding, styles, @padding) diff --git a/lib/scarpe/wv/stack.rb b/lib/scarpe/wv/stack.rb index 7c0575358..123d01bfa 100644 --- a/lib/scarpe/wv/stack.rb +++ b/lib/scarpe/wv/stack.rb @@ -1,39 +1,18 @@ # frozen_string_literal: true class Scarpe - class WebviewStack < Scarpe::WebviewWidget - include Scarpe::WebviewBackground - include Scarpe::WebviewBorder - include Scarpe::WebviewSpacing - - def initialize(properties) - super - end - - def element(&block) - HTML.render do |h| - h.div(id: html_id, style: style, &block) - end - end - + class WebviewStack < Scarpe::WebviewSlot def get_style style end - private + protected 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[:display] = "flex" styles["flex-direction"] = "column" - styles[:width] = Dimensions.length(@width) if @width - styles[:height] = Dimensions.length(@height) if @height styles["overflow"] = "auto" if @scroll styles diff --git a/lib/scarpe/wv/subscription_item.rb b/lib/scarpe/wv/subscription_item.rb new file mode 100644 index 000000000..3c215e547 --- /dev/null +++ b/lib/scarpe/wv/subscription_item.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Scarpe::WebviewSubscriptionItem < Scarpe::WebviewWidget + def initialize(properties) + super + + bind(@shoes_api_name) do |*args| + send_self_event(*args, event_name: @shoes_api_name) + end + end + + def element + "" + end + + # This will get called once we know the parent, which is useful for events + # like hover, where our subscription is likely to depend on what our parent is. + def set_parent(new_parent) + super + + 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. + # TODO: figure out how to handle alt and meta keys - does Shoes3 recognise those? + new_parent.set_event_callback( + self, + "onmousemove", + handler_js_code( + @shoes_api_name, + "arguments[0].x", + "arguments[0].y", + "arguments[0].ctrlKey", + "arguments[0].shiftKey", + ), + ) + when "hover" + new_parent.set_event_callback(self, "onmouseenter", 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")) + else + raise "Unknown Shoes event API: #{@shoes_api_name}!" + end + end + + def destroy_self + @parent.remove_event_callbacks(self) + super + end +end diff --git a/lib/scarpe/wv/web_wrangler.rb b/lib/scarpe/wv/web_wrangler.rb index 3d1c7fc5f..7ac028b8d 100644 --- a/lib/scarpe/wv/web_wrangler.rb +++ b/lib/scarpe/wv/web_wrangler.rb @@ -671,6 +671,10 @@ def inner_html=(new_html) @webwrangler.dom_change("document.getElementById(\"" + html_id + "\").innerHTML = `" + new_html + "`; true") end + def set_attribute(attribute, value) + @webwrangler.dom_change("document.getElementById(\"" + html_id + "\").setAttribute(" + attribute.inspect + "," + value.inspect + "); true") + end + def remove @webwrangler.dom_change("document.getElementById('" + html_id + "').remove(); true") end