lib/glimmer/libui/control_proxy.rb
# Copyright (c) 2021-2024 Andy Maleh
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require 'glimmer/libui/data_bindable'
require 'glimmer/libui/parent'
module Glimmer
module LibUI
# Proxy for LibUI control objects
#
# Follows the Proxy Design Pattern
class ControlProxy
class << self
def exists?(keyword)
::LibUI.respond_to?("new_#{keyword}") or
::LibUI.respond_to?(keyword) or
descendant_keyword_constant_map.keys.include?(keyword)
end
def create(keyword, parent, args, &block)
control_proxy_class(keyword).new(keyword, parent, args, &block).tap {|c| control_proxies << c}
end
def control_proxy_class(keyword)
descendant_keyword_constant_map[keyword] || ControlProxy
end
# autosave all controls in this array to avoid garbage collection
def control_proxies
@@control_proxies = [] unless defined?(@@control_proxies)
@@control_proxies
end
def main_window_proxy
control_proxies.find {|c| c.is_a?(WindowProxy)}
end
def menu_proxies
control_proxies.select {|c| c.keyword == 'menu' }
end
def image_proxies
control_proxies.select {|c| c.keyword == 'image' }
end
def new_control(keyword, args)
::LibUI.send("new_#{keyword}", *args)
end
def constant_symbol(keyword)
"#{keyword.camelcase(:upper)}Proxy".to_sym
end
def keyword(constant_symbol)
constant_symbol.to_s.underscore.sub(/_proxy$/, '')
end
def descendant_keyword_constant_map
@descendant_keyword_constant_map ||= add_aliases_to_keyword_constant_map(map_descendant_keyword_constants_for(self))
end
def reset_descendant_keyword_constant_map
@descendant_keyword_constant_map = nil
descendant_keyword_constant_map
end
def map_descendant_keyword_constants_for(klass, accumulator: {})
klass.constants.map do |constant_symbol|
[constant_symbol, klass.const_get(constant_symbol)]
end.select do |constant_symbol, constant|
constant.is_a?(Module) && !accumulator.values.include?(constant)
end.each do |constant_symbol, constant|
accumulator[keyword(constant_symbol)] = constant
map_descendant_keyword_constants_for(constant, accumulator: accumulator)
end
accumulator
end
private
def add_aliases_to_keyword_constant_map(keyword_constant_map)
KEYWORD_ALIASES.each do |alias_keyword, keyword|
keyword_constant_map[alias_keyword] = keyword_constant_map[keyword]
end
keyword_constant_map
end
end
include DataBindable
prepend Parent
KEYWORD_ALIASES = {
'message_box' => 'msg_box',
'message_box_error' => 'msg_box_error',
}
BOOLEAN_PROPERTIES = %w[
padded
checked
enabled toplevel visible
read_only
margined
borderless fullscreen
stretchy
]
STRING_PROPERTIES = %w[
text
title
]
# libui returns the contained LibUI object
attr_reader :parent_proxy, :parent_custom_control, :custom_control, :libui, :args, :options, :slot, :keyword, :block, :content_added
alias content_added? content_added
def initialize(keyword, parent, args, &block)
@keyword = keyword
@custom_control = CustomControl.custom_controls_being_interpreted.last
@parent_custom_control = parent if parent.is_a?(CustomControl)
@parent_proxy = parent.is_a?(CustomControl) ? parent.body_root : parent
@args = args
@options = args.last.is_a?(Hash) ? args.pop : {}
@slot = options[:slot]
custom_control.slot_controls[@slot] = self if @slot && custom_control
@block = block
@enabled = true
build_control
post_add_content if @block.nil?
end
# Subclasses may override to perform post add_content work (normally must call super)
def post_add_content
unless @content_added
if CustomControl.custom_controls_being_interpreted.last.nil? ||
CustomControl.custom_controls_being_interpreted.last.parent_proxy != @parent_proxy
@parent_proxy&.post_initialize_child(self)
end
@content_added = true
end
end
# Subclasses may override to perform post initialization work on an added child
def post_initialize_child(child)
# No Op by default
end
def window_proxy
found_proxy = self
until found_proxy.nil? || found_proxy.is_a?(WindowProxy)
found_proxy = found_proxy.parent_proxy
end
found_proxy
end
def can_handle_listener?(listener_name)
::LibUI.respond_to?("#{libui_api_keyword}_#{listener_name}") ||
::LibUI.respond_to?("control_#{listener_name}") ||
has_custom_listener?(listener_name)
end
def handle_listener(listener_name, &listener)
# replace first listener argument (control libui pointer) with actual Ruby libui object
safe_listener = Proc.new { |*args| listener.call(self, *args[1..-1]) }
if ::LibUI.respond_to?("#{libui_api_keyword}_#{listener_name}")
if listeners[listener_name].nil?
::LibUI.send("#{libui_api_keyword}_#{listener_name}", libui) do |*args|
listeners_for(listener_name).map { |listener| listener.call(*args) }.last
end
end
listeners_for(listener_name) << safe_listener
elsif ::LibUI.respond_to?("control_#{listener_name}")
if listeners[listener_name].nil?
::LibUI.send("control_#{listener_name}", libui) do |*args|
listeners_for(listener_name).map { |listener| listener.call(*args) }.last
end
end
listeners_for(listener_name) << safe_listener
elsif has_custom_listener?(listener_name)
handle_custom_listener(listener_name, &listener)
end
end
def listeners
@listeners ||= {}
end
def listeners_for(listener_name)
listeners[listener_name] ||= []
end
def has_custom_listener?(listener_name)
listener_name = listener_name.to_s
custom_listener_names.include?(listener_name) || custom_listener_name_aliases.stringify_keys.keys.include?(listener_name)
end
def custom_listener_names
self.class.constants.include?(:CUSTOM_LISTENER_NAMES) ? self.class::CUSTOM_LISTENER_NAMES : []
end
def custom_listener_name_aliases
self.class.constants.include?(:CUSTOM_LISTENER_NAME_ALIASES) ? self.class::CUSTOM_LISTENER_NAME_ALIASES : {}
end
def handle_custom_listener(listener_name, &listener)
listener_name = listener_name.to_s
listener_name = custom_listener_name_aliases.stringify_keys[listener_name] || listener_name
instance_variable_name = "@#{listener_name}_procs" # TODO ensure clearing custom listeners on destroy of a control
instance_variable_set(instance_variable_name, []) if instance_variable_get(instance_variable_name).nil?
if listener.nil?
instance_variable_get(instance_variable_name)
else
instance_variable_get(instance_variable_name) << listener
listener
end
end
def notify_custom_listeners(listener_name, *args)
handle_custom_listener(listener_name).map do |listener|
listener.call(*args)
end
end
def deregister_custom_listeners(listener_name)
handle_custom_listener(listener_name).clear
end
# deregisters all custom listeners except on_destroy, which can only be deregistered after destruction of a control, using deregister_custom_listeners
def deregister_all_custom_listeners
(custom_listener_names - ['on_destroy']).each { |listener_name| deregister_custom_listeners(listener_name) }
end
def respond_to?(method_name, *args, &block)
respond_to_libui?(method_name, *args, &block) ||
(
append_properties.include?(method_name.to_s) ||
(append_properties.include?(method_name.to_s.sub(/\?$/, '')) && BOOLEAN_PROPERTIES.include?(method_name.to_s.sub(/\?$/, ''))) ||
append_properties.include?(method_name.to_s.sub(/=$/, ''))
) ||
can_handle_listener?(method_name.to_s) ||
super(method_name, true)
end
def respond_to_libui?(method_name, *args, &block)
::LibUI.respond_to?("control_#{method_name}") or
(::LibUI.respond_to?("control_#{method_name.to_s.sub(/\?$/, '')}") && BOOLEAN_PROPERTIES.include?(method_name.to_s.sub(/\?$/, '')) ) or
::LibUI.respond_to?("control_set_#{method_name.to_s.sub(/=$/, '')}") or
::LibUI.respond_to?("#{libui_api_keyword}_#{method_name}") or
(::LibUI.respond_to?("#{libui_api_keyword}_#{method_name.to_s.sub(/\?$/, '')}") && BOOLEAN_PROPERTIES.include?(method_name.to_s.sub(/\?$/, '')) ) or
::LibUI.respond_to?("#{libui_api_keyword}_set_#{method_name.to_s.sub(/=$/, '')}")
end
def method_missing(method_name, *args, &block)
if respond_to_libui?(method_name, *args, &block)
send_to_libui(method_name, *args, &block)
elsif append_properties.include?(method_name.to_s) ||
append_properties.include?(method_name.to_s.sub(/(=|\?)$/, ''))
append_property(method_name, *args)
elsif can_handle_listener?(method_name.to_s)
handle_listener(method_name.to_s, &block)
else
super
end
end
def send_to_libui(method_name, *args, &block)
if ::LibUI.respond_to?("#{libui_api_keyword}_#{method_name.to_s.sub(/\?$/, '')}") && args.empty?
property = method_name.to_s.sub(/\?$/, '')
value = ::LibUI.send("#{libui_api_keyword}_#{property}", libui, *args)
handle_string_property(property, handle_boolean_property(property, value))
elsif ::LibUI.respond_to?("#{libui_api_keyword}_get_#{method_name.to_s.sub(/\?$/, '')}") && args.empty?
property = method_name.to_s.sub(/\?$/, '')
value = ::LibUI.send("#{libui_api_keyword}_get_#{property}", libui, *args)
handle_string_property(property, handle_boolean_property(property, value))
elsif ::LibUI.respond_to?("#{libui_api_keyword}_set_#{method_name.to_s.sub(/=$/, '')}") && !args.empty?
property = method_name.to_s.sub(/=$/, '')
args[0] = Glimmer::LibUI.boolean_to_integer(args.first) if BOOLEAN_PROPERTIES.include?(property) && (args.first.is_a?(TrueClass) || args.first.is_a?(FalseClass))
args[0] = '' if STRING_PROPERTIES.include?(property) && args.first == nil
if property.to_s == 'checked'
current_value = Glimmer::LibUI.integer_to_boolean(::LibUI.send("#{libui_api_keyword}_checked", libui), allow_nil: false)
new_value = Glimmer::LibUI.integer_to_boolean(args[0], allow_nil: false)
::LibUI.send("#{libui_api_keyword}_set_#{property}", libui, *args) if new_value != current_value
else
::LibUI.send("#{libui_api_keyword}_set_#{property}", libui, *args)
end
elsif ::LibUI.respond_to?("#{libui_api_keyword}_#{method_name}") && !args.empty?
::LibUI.send("#{libui_api_keyword}_#{method_name}", libui, *args)
elsif ::LibUI.respond_to?("control_#{method_name.to_s.sub(/\?$/, '')}") && args.empty?
property = method_name.to_s.sub(/\?$/, '')
value = ::LibUI.send("control_#{property}", libui, *args)
handle_string_property(property, handle_boolean_property(property, value))
elsif ::LibUI.respond_to?("control_set_#{method_name.to_s.sub(/=$/, '')}")
property = method_name.to_s.sub(/=$/, '')
args[0] = Glimmer::LibUI.boolean_to_integer(args.first) if BOOLEAN_PROPERTIES.include?(property) && (args.first.is_a?(TrueClass) || args.first.is_a?(FalseClass))
args[0] = '' if STRING_PROPERTIES.include?(property) && args.first == nil
::LibUI.send("control_set_#{method_name.to_s.sub(/=$/, '')}", libui, *args)
elsif ::LibUI.respond_to?("control_#{method_name}") && !args.empty?
::LibUI.send("control_#{method_name}", libui, *args)
end
end
def append_properties
@parent_proxy&.class&.constants&.include?(:APPEND_PROPERTIES) ? @parent_proxy.class::APPEND_PROPERTIES : []
end
def append_property(property, value = nil)
property = property.to_s.sub(/(=|\?)$/, '')
@append_property_hash ||= {}
if value.nil?
value = @append_property_hash[property]
handle_string_property(property, handle_boolean_property(property, value))
else
value = Glimmer::LibUI.boolean_to_integer(value) if BOOLEAN_PROPERTIES.include?(property) && (value.is_a?(TrueClass) || value.is_a?(FalseClass))
@append_property_hash[property] = value
end
end
def libui_api_keyword
@keyword
end
def destroy
# TODO exclude menus from this initial return
return if !is_a?(ControlProxy::WindowProxy) && ControlProxy.main_window_proxy&.destroying?
data_binding_model_attribute_observer_registrations.each(&:deregister)
if parent_proxy.nil?
default_destroy
else
parent_proxy.destroy_child(self)
end
end
def destroy_child(child)
child.default_destroy
children.delete(child)
end
def default_destroy
deregister_all_custom_listeners
send_to_libui('destroy')
ControlProxy.control_proxies.delete(self)
# TODO should we destroy all children too or at least remove them from the children collection?
end
def enabled(value = nil)
if value.nil?
@enabled
elsif value != @enabled
@enabled = value == 1 || value
if value == 1 || value
send_to_libui('enable')
else
send_to_libui('disable')
end
end
end
alias enabled? enabled
alias set_enabled enabled
alias enabled= enabled
def visible(value = nil)
current_value = send_to_libui('visible')
if value.nil?
current_value
elsif value != current_value
if value == 1 || value
send_to_libui('show')
else
send_to_libui('hide')
end
end
end
alias visible? visible
alias set_visible visible
alias visible= visible
def content(&block)
Glimmer::DSL::Engine.add_content(self, Glimmer::DSL::Libui::ControlExpression.new, @keyword, {post_add_content: @content_added}, &block)
end
# Data-binds the generation of nested content to a model/property (in binding args)
# consider providing an option to avoid initial rendering without any changes happening
def bind_content(*binding_args, &content_block)
# TODO in the future, consider optimizing code by diffing content if that makes sense
content_binding_work = proc do |*values|
children.dup.each { |child| child.destroy }
content(&content_block)
end
model_binding_observer = Glimmer::DataBinding::ModelBinding.new(*binding_args)
content_binding_observer = Glimmer::DataBinding::Observer.proc(&content_binding_work)
content_binding_observer.observe(model_binding_observer)
content_binding_work.call # TODO inspect if we need to pass args here (from observed attributes) [but it's simpler not to pass anything at first]
end
private
def build_control
@libui = if ::LibUI.respond_to?("new_#{keyword}")
ControlProxy.new_control(@keyword, @args)
elsif ::LibUI.respond_to?(keyword)
@args[0] = @args.first.libui if @args.first.is_a?(ControlProxy)
::LibUI.send(@keyword, *@args)
end
end
def handle_boolean_property(property, value)
BOOLEAN_PROPERTIES.include?(property) ? Glimmer::LibUI.integer_to_boolean(value) : value
end
def handle_string_property(property, value)
STRING_PROPERTIES.include?(property) ? value.to_s : value
end
end
end
end
Dir[File.expand_path("./#{File.basename(__FILE__, '.rb')}/*.rb", __dir__)].each {|f| require f}