diff --git a/.gitignore b/.gitignore index 2a32391..0889a59 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,7 @@ build-iPhoneSimulator/ *.dll *.so +*.exp +*.lib .vscode diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..39f0bd1 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1738875470426 + + + + + + + + + \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..eaf79ae Binary files /dev/null and b/.ruby-version differ diff --git a/README.md b/README.md index 4e20eed..617e156 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,30 @@ #### Windows -I recommend to install Ruby via [Scoop](https://scoop.sh/), then +I recommend to install Ruby via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): + +`winget install RubyInstallerTeam.RubyWithDevKit.3.2` + +The reason why you need the dev kit is that the event machine gem needs to be compiled on the target machine. + +Then install the dependencies (you likely need to close all terminals first): - `gem install ffi` - `gem install eventmachine` +- `gem install rx` -`RUBY_DLL_PATH` must be set: +`RUBY_DLL_PATH` must be set, e.g.: `$env:RUBY_DLL_PATH="C:\dev\xframes-ruby"` +For convenience, you may launch `main.bat` or `main.ps1` depending on whether you are using a regular command line or PowerShell. + #### Linux - `sudo apt install ruby-full` - `sudo gem install ffi` - `sudo gem install eventmachine` +- `sudo gem install rx` ### Run the application @@ -34,4 +44,3 @@ Windows 11 Raspberry Pi 5 ![image](https://github.com/user-attachments/assets/190f8603-a6db-45c6-a5f0-cfd4dc1b87e2) - diff --git a/main.bat b/main.bat new file mode 100644 index 0000000..3194836 --- /dev/null +++ b/main.bat @@ -0,0 +1,4 @@ +@echo off +set RUBY_DLL_PATH=%CD% +echo RUBY_DLL_PATH set to %RUBY_DLL_PATH% +ruby main.rb \ No newline at end of file diff --git a/main.ps1 b/main.ps1 new file mode 100644 index 0000000..5676523 --- /dev/null +++ b/main.ps1 @@ -0,0 +1,3 @@ +$env:RUBY_DLL_PATH = Get-Location +Write-Output "RUBY_DLL_PATH set to $env:RUBY_DLL_PATH" +ruby main.rb \ No newline at end of file diff --git a/main.rb b/main.rb index ee7f62d..3c86be8 100644 --- a/main.rb +++ b/main.rb @@ -1,63 +1,13 @@ require 'ffi' require 'json' +require 'thread' +require 'concurrent-ruby' require 'eventmachine' - -ImGuiCol = { - Text: 0, - TextDisabled: 1, - WindowBg: 2, - ChildBg: 3, - PopupBg: 4, - Border: 5, - BorderShadow: 6, - FrameBg: 7, - FrameBgHovered: 8, - FrameBgActive: 9, - TitleBg: 10, - TitleBgActive: 11, - TitleBgCollapsed: 12, - MenuBarBg: 13, - ScrollbarBg: 14, - ScrollbarGrab: 15, - ScrollbarGrabHovered: 16, - ScrollbarGrabActive: 17, - CheckMark: 18, - SliderGrab: 19, - SliderGrabActive: 20, - Button: 21, - ButtonHovered: 22, - ButtonActive: 23, - Header: 24, - HeaderHovered: 25, - HeaderActive: 26, - Separator: 27, - SeparatorHovered: 28, - SeparatorActive: 29, - ResizeGrip: 30, - ResizeGripHovered: 31, - ResizeGripActive: 32, - Tab: 33, - TabHovered: 34, - TabActive: 35, - TabUnfocused: 36, - TabUnfocusedActive: 37, - PlotLines: 38, - PlotLinesHovered: 39, - PlotHistogram: 40, - PlotHistogramHovered: 41, - TableHeaderBg: 42, - TableBorderStrong: 43, - TableBorderLight: 44, - TableRowBg: 45, - TableRowBgAlt: 46, - TextSelectedBg: 47, - DragDropTarget: 48, - NavHighlight: 49, - NavWindowingHighlight: 50, - NavWindowingDimBg: 51, - ModalWindowDimBg: 52, - COUNT: 53 -} +require_relative 'theme' +require_relative 'sampleapp' +require_relative 'services' +require_relative 'treetraversal' +require_relative 'xframes' # Colors for theme generation theme2Colors = { @@ -144,90 +94,13 @@ font_defs_json = JSON.pretty_generate(defs: font_size_pairs) theme_json = JSON.pretty_generate(theme2) -class Node - attr_accessor :id, :root - - def initialize(id, root) - @type = 'node' - @id = id - @root = root - end - - def to_json(*options) - { - type: @type, - id: @id, - root: @root - }.to_json(*options) - end - end - - class UnformattedText - attr_accessor :id, :text - - def initialize(id, text) - @type = 'unformatted-text' - @id = id - @text = text - end - - def to_json(*options) - { - type: @type, - id: @id, - text: @text - }.to_json(*options) - end - end - - -module XFrames - extend FFI::Library - if RUBY_PLATFORM =~ /win32|mingw|cygwin/ - ffi_lib './xframesshared.dll' - else - ffi_lib './libxframesshared.so' - end - - # Define callback types - callback :OnInitCb, [:pointer], :void - callback :OnTextChangedCb, [:int, :string], :void - callback :OnComboChangedCb, [:int, :int], :void - callback :OnNumericValueChangedCb, [:int, :float], :void - callback :OnBooleanValueChangedCb, [:int, :int], :void - callback :OnMultipleNumericValuesChangedCb, [:int, :pointer, :int], :void - callback :OnClickCb, [:int], :void - - attach_function :init, [ - :string, # assetsBasePath - :string, # rawFontDefinitions - :string, # rawStyleOverrideDefinitions - :OnInitCb, - :OnTextChangedCb, - :OnComboChangedCb, - :OnNumericValueChangedCb, - :OnBooleanValueChangedCb, - :OnMultipleNumericValuesChangedCb, - :OnClickCb - ], :void - - attach_function :setElement, [:string], :void - - attach_function :setChildren, [:int, :string], :void -end +service = WidgetRegistrationService.new +shadow_node_traversal_helper = ShadowNodeTraversalHelper.new(service) on_init = FFI::Function.new(:void, []) do - puts "OnInit called!" - - node = Node.new(0, true) - unformatted_text = UnformattedText.new(1, "Hello, world") + root = Root.new() - children_ids = [1] - - XFrames.setElement(node.to_json) - XFrames.setElement(unformatted_text.to_json) - - XFrames.setChildren(0, children_ids.to_json) + shadow_node_traversal_helper.traverse_tree(root) end on_text_changed = FFI::Function.new(:void, [:int, :string]) do |id, text| @@ -252,6 +125,8 @@ module XFrames end on_click = FFI::Function.new(:void, [:int]) do |id| + service.dispatch_on_click_event(id) + puts "Button clicked: ID=#{id}" end diff --git a/sampleapp.rb b/sampleapp.rb new file mode 100644 index 0000000..62d9ca3 --- /dev/null +++ b/sampleapp.rb @@ -0,0 +1,115 @@ +require 'rx' +require_relative 'theme' +require_relative 'widgetnode' +require 'thread' +require 'concurrent-ruby' + +class TodoItem + attr_accessor :text, :done + + def initialize(text, done) + @text = text + @done = done + end +end + +class AppState + attr_accessor :todo_text, :todo_items + + def initialize(todo_text, todo_items) + @todo_text = todo_text + @todo_items = todo_items + end +end + +$sample_app_state = Rx::BehaviorSubject.new(AppState.new("", [])) + +def on_click + promise = Concurrent::Promise.execute do + new_todo_item = TodoItem.new("New Todo", false) + current_state = $sample_app_state.value + new_state = AppState.new( + current_state.todo_text, + current_state.todo_items + [new_todo_item] + ) + $sample_app_state.on_next(new_state) + end + + promise.wait +end + +$text_style = WidgetStyle.new( + style: WidgetStyleDef.new( + style_rules: StyleRules.new( + font: FontDef.new(name: "roboto-regular", size: 32) + ) + ) +) + +$button_style = WidgetStyle.new( + style: WidgetStyleDef.new( + style_rules: StyleRules.new( + font: FontDef.new(name: "roboto-regular", size: 32) + ), + layout: YogaStyle.new( + width: "50%", + padding: {Edge[:Vertical] => 10}, + margin: {Edge[:Left] => 140} + ) + ) +) + +class App < BaseComponent + def initialize + super({}) + + promise = Concurrent::Promise.execute do + $sample_app_state.subscribe do |latest_app_state| + puts "app state changed" + + @props.on_next({ + "todo_text" => latest_app_state.todo_text, + "todo_items" => latest_app_state.todo_items + }) + end + end + + promise.wait + + @props.on_next({ + "todo_text" => "", + "todo_items" => [TodoItem.new("New Todo", false)] + }) + end + + def render + children = [button("Add todo", Proc.new { + on_click() + }, $button_style)] + + promise = Concurrent::Promise.execute do + @props.value["todo_items"].each do |todo_item| + text = "#{todo_item.text} (#{todo_item.done ? 'done' : 'to do'})." + children << unformatted_text(text, $text_style) + end + end + + promise.wait + + node(children) + end + + def dispose + @app_state_subscription.dispose + end +end + +class Root < BaseComponent + def initialize + super({}) + end + + def render + root_node([App.new]) + end +end diff --git a/services.rb b/services.rb new file mode 100644 index 0000000..30a65b4 --- /dev/null +++ b/services.rb @@ -0,0 +1,162 @@ +require 'json' +require 'rx' +require 'thread' +require_relative 'xframes' + +# $events_subject = Rx::ReplaySubject.new() + +class WidgetRegistrationService + def initialize + @id_generator_lock = Mutex.new + @id_widget_registration_lock = Mutex.new + @id_event_registration_lock = Mutex.new + + + # @events_subject = Rx::ReplaySubject.new() + # @events_subject.debounce(0.001).subscribe(Proc.new { |fn| fn.call }) + # promise = Concurrent::Promise.execute do + # $events_subject.subscribe do |cb| + # puts "yo" + # cb() + # end + # end + # promise.wait + + @widget_registry = {} + @on_click_registry = Rx::BehaviorSubject.new({}) + + @last_widget_id = 0 + @last_component_id = 0 + end + + def get_widget_by_id(widget_id) + @id_widget_registration_lock.synchronize do + @widget_registry[widget_id] + end + end + + def register_widget(widget_id, widget) + @id_widget_registration_lock.synchronize do + @widget_registry[widget_id] = widget + end + end + + def get_next_widget_id + @id_generator_lock.synchronize do + widget_id = @last_widget_id + @last_widget_id += 1 + widget_id + end + end + + def get_next_component_id + @id_generator_lock.synchronize do + component_id = @last_component_id + @last_component_id += 1 + component_id + end + end + + def register_on_click(widget_id, on_click) + @id_event_registration_lock.synchronize do + new_registry = @on_click_registry.value.dup + new_registry[widget_id] = on_click + @on_click_registry.on_next(new_registry) + end + end + + def dispatch_on_click_event(widget_id) + promise = Concurrent::Promise.execute do + on_click = @on_click_registry.value[widget_id] + + if on_click + # promise = Concurrent::Promise.execute do + on_click() + # $events_subject.on_next(on_click) + # end + + + else + puts "Widget with id #{widget_id} has no on_click handler" + end + end + + promise.wait + end + + def create_widget(widget) + widget_json = widget.to_hash.to_json + set_element(widget_json) + end + + def patch_widget(widget_id, widget) + widget_json = widget.to_json + patch_element(widget_id, widget_json) + end + + def link_children(widget_id, child_ids) + children_json = child_ids.to_json + set_children(widget_id, children_json) + end + + def set_data(widget_id, data) + data_json = data.to_json + element_internal_op(widget_id, data_json) + end + + def append_data(widget_id, data) + data_json = data.to_json + element_internal_op(widget_id, data_json) + end + + def reset_data(widget_id) + data_json = "".to_json + element_internal_op(widget_id, data_json) + end + + def append_data_to_plot_line(widget_id, x, y) + plot_data = { x: x, y: y } + element_internal_op(widget_id, plot_data.to_json) + end + + def set_plot_line_axes_decimal_digits(widget_id, x, y) + axes_data = { x: x, y: y } + element_internal_op(widget_id, axes_data.to_json) + end + + def append_text_to_clipped_multi_line_text_renderer(widget_id, text) + extern_append_text(widget_id, text) + end + + def set_input_text_value(widget_id, value) + input_text_data = { value: value } + element_internal_op(widget_id, value) + end + + def set_combo_selected_index(widget_id, index) + selected_index_data = { index: index } + element_internal_op(widget_id, selected_index_data.to_json) + end + + private + + def set_element(json_data) + XFrames.setElement(json_data) + end + + def patch_element(widget_id, json_data) + # Implement patch logic if needed + end + + def set_children(widget_id, json_data) + XFrames.setChildren(widget_id, json_data) + end + + def element_internal_op(widget_id, json_data) + # Implement internal operation if needed + end + + def extern_append_text(widget_id, text) + # Handle external append text logic + end +end diff --git a/theme.rb b/theme.rb new file mode 100644 index 0000000..87eb681 --- /dev/null +++ b/theme.rb @@ -0,0 +1,490 @@ + +ImGuiCol = { + Text: 0, + TextDisabled: 1, + WindowBg: 2, + ChildBg: 3, + PopupBg: 4, + Border: 5, + BorderShadow: 6, + FrameBg: 7, + FrameBgHovered: 8, + FrameBgActive: 9, + TitleBg: 10, + TitleBgActive: 11, + TitleBgCollapsed: 12, + MenuBarBg: 13, + ScrollbarBg: 14, + ScrollbarGrab: 15, + ScrollbarGrabHovered: 16, + ScrollbarGrabActive: 17, + CheckMark: 18, + SliderGrab: 19, + SliderGrabActive: 20, + Button: 21, + ButtonHovered: 22, + ButtonActive: 23, + Header: 24, + HeaderHovered: 25, + HeaderActive: 26, + Separator: 27, + SeparatorHovered: 28, + SeparatorActive: 29, + ResizeGrip: 30, + ResizeGripHovered: 31, + ResizeGripActive: 32, + Tab: 33, + TabHovered: 34, + TabActive: 35, + TabUnfocused: 36, + TabUnfocusedActive: 37, + PlotLines: 38, + PlotLinesHovered: 39, + PlotHistogram: 40, + PlotHistogramHovered: 41, + TableHeaderBg: 42, + TableBorderStrong: 43, + TableBorderLight: 44, + TableRowBg: 45, + TableRowBgAlt: 46, + TextSelectedBg: 47, + DragDropTarget: 48, + NavHighlight: 49, + NavWindowingHighlight: 50, + NavWindowingDimBg: 51, + ModalWindowDimBg: 52, + COUNT: 53 +} + +ImPlotScale = { + Linear: 0, + Time: 1, + Log10: 2, + SymLog: 3 +} + +ImPlotMarker = { + None_: -1, + Circle: 0, + Square: 1, + Diamond: 2, + Up: 3, + Down: 4, + Left: 5, + Right: 6, + Cross: 7, + Plus: 8, + Asterisk: 9 +} + +ImGuiStyleVar = { + Alpha: 0, + DisabledAlpha: 1, + WindowPadding: 2, + WindowRounding: 3, + WindowBorderSize: 4, + WindowMinSize: 5, + WindowTitleAlign: 6, + ChildRounding: 7, + ChildBorderSize: 8, + PopupRounding: 9, + PopupBorderSize: 10, + FramePadding: 11, + FrameRounding: 12, + FrameBorderSize: 13, + ItemSpacing: 14, + ItemInnerSpacing: 15, + IndentSpacing: 16, + CellPadding: 17, + ScrollbarSize: 18, + ScrollbarRounding: 19, + GrabMinSize: 20, + GrabRounding: 21, + TabRounding: 22, + TabBorderSize: 23, + TabBarBorderSize: 24, + TableAngledHeadersAngle: 25, + TableAngledHeadersTextAlign: 26, + ButtonTextAlign: 27, + SelectableTextAlign: 28, + SeparatorTextBorderSize: 29, + SeparatorTextAlign: 30, + SeparatorTextPadding: 31 +} + +Align = { + Left: "left", + Right: "right" +} + +Direction = { + Inherit: "inherit", + Ltr: "ltr", + Rtl: "rtl" +} + +FlexDirection = { + Column: "column", + ColumnReverse: "column-reverse", + Row: "row", + RowReverse: "row-reverse" +} + +JustifyContent = { + FlexStart: "flex-start", + Center: "center", + FlexEnd: "flex-end", + SpaceBetween: "space-between", + SpaceAround: "space-around", + SpaceEvenly: "space-evenly" +} + +AlignContent = { + Auto: "auto", + FlexStart: "flex-start", + Center: "center", + FlexEnd: "flex-end", + Stretch: "stretch", + SpaceBetween: "space-between", + SpaceAround: "space-around", + SpaceEvenly: "space-evenly" +} + +AlignItems = { + Auto: "auto", + FlexStart: "flex-start", + Center: "center", + FlexEnd: "flex-end", + Stretch: "stretch", + Baseline: "baseline" +} + +AlignSelf = { + Auto: "auto", + FlexStart: "flex-start", + Center: "center", + FlexEnd: "flex-end", + Stretch: "stretch", + Baseline: "baseline" +} + +PositionType = { + Static: "static", + Relative: "relative", + Absolute: "absolute" +} + +FlexWrap = { + NoWrap: "no-wrap", + Wrap: "wrap", + WrapReverse: "wrap-reverse" +} + +Overflow = { + Visible: "visible", + Hidden: "hidden", + Scroll: "scroll" +} + +Display = { + Flex: "flex", + DisplayNone: "none" +} + +Edge = { + Left: "left", + Top: "top", + Right: "right", + Bottom: "bottom", + Start: "start", + End: "end", + Horizontal: "horizontal", + Vertical: "vertical", + All: "all" +} + +Gutter = { + Column: "column", + Row: "row", + All: "all" +} + +RoundCorners = { + All: "all", + TopLeft: "topLeft", + TopRight: "topRight", + BottomLeft: "bottomLeft", + BottomRight: "bottomRight" +} + +class FontDef + attr_accessor :name, :size + + def initialize(name:, size:) + @name = name + @size = size + end + + def to_hash + { "name" => @name, "size" => @size } + end +end + +class ImVec2 + attr_accessor :x, :y + + def initialize(x:, y:) + @x = x + @y = y + end + + def to_hash + { "x" => @x, "y" => @y } + end +end + + +class StyleRules + attr_accessor :align, :font, :colors, :vars + + def initialize(align: nil, font: nil, colors: nil, vars: nil) + @align = align + @font = font + @colors = colors + @vars = vars + end + + def to_hash + out = {} + + out[:align] = @align if @align + out[:font] = @font.to_hash if @font + out[:colors] = @colors.transform_keys(&:to_s) if @colors + out[:vars] = @vars.transform_keys(&:to_s) if @vars + + out + end +end + + +class BorderStyle + attr_reader :color, :thickness + + def initialize(color:, thickness: nil) + @color = color + @thickness = thickness + end + + def to_hash + out = { 'color' => @color } + + out['thickness'] = @thickness if @thickness + + out + end +end + + +class YogaStyle + attr_reader :direction, :flex_direction, :justify_content, :align_content, + :align_items, :align_self, :position_type, :flex_wrap, :overflow, + :display, :flex, :flex_grow, :flex_shrink, :flex_basis, :flex_basis_percent, + :position, :margin, :padding, :gap, :aspect_ratio, :width, + :min_width, :max_width, :height, :min_height, :max_height + + def initialize(direction: nil, flex_direction: nil, justify_content: nil, align_content: nil, + align_items: nil, align_self: nil, position_type: nil, flex_wrap: nil, overflow: nil, + display: nil, flex: nil, flex_grow: nil, flex_shrink: nil, flex_basis: nil, flex_basis_percent: nil, + position: nil, margin: nil, padding: nil, gap: nil, aspect_ratio: nil, width: nil, + min_width: nil, max_width: nil, height: nil, min_height: nil, max_height: nil) + @direction = direction + @flex_direction = flex_direction + @justify_content = justify_content + @align_content = align_content + @align_items = align_items + @align_self = align_self + @position_type = position_type + @flex_wrap = flex_wrap + @overflow = overflow + @display = display + @flex = flex + @flex_grow = flex_grow + @flex_shrink = flex_shrink + @flex_basis = flex_basis + @flex_basis_percent = flex_basis_percent + @position = position + @margin = margin + @padding = padding + @gap = gap + @aspect_ratio = aspect_ratio + @width = width + @min_width = min_width + @max_width = max_width + @height = height + @min_height = min_height + @max_height = max_height + end + + def to_hash + out = {} + + add_to_hash(out, 'direction', @direction) + add_to_hash(out, 'flexDirection', @flex_direction) + add_to_hash(out, 'justifyContent', @justify_content) + add_to_hash(out, 'alignContent', @align_content) + add_to_hash(out, 'alignItems', @align_items) + add_to_hash(out, 'alignSelf', @align_self) + add_to_hash(out, 'positionType', @position_type) + add_to_hash(out, 'flexWrap', @flex_wrap) + add_to_hash(out, 'overflow', @overflow) + add_to_hash(out, 'display', @display) + add_to_hash(out, 'flex', @flex) + add_to_hash(out, 'flexGrow', @flex_grow) + add_to_hash(out, 'flexShrink', @flex_shrink) + add_to_hash(out, 'flexBasis', @flex_basis) + add_to_hash(out, 'flexBasisPercent', @flex_basis_percent) + add_to_hash_with_edges(out, 'position', @position) + add_to_hash_with_edges(out, 'margin', @margin) + add_to_hash_with_edges(out, 'padding', @padding) + add_to_hash_with_gutters(out, 'gap', @gap) + add_to_hash(out, 'aspectRatio', @aspect_ratio) + add_to_hash(out, 'width', @width) + add_to_hash(out, 'minWidth', @min_width) + add_to_hash(out, 'maxWidth', @max_width) + add_to_hash(out, 'height', @height) + add_to_hash(out, 'minHeight', @min_height) + add_to_hash(out, 'maxHeight', @max_height) + + out + end + + private + + def add_to_hash(hash, key, value) + hash[key] = value unless value.nil? + end + + def add_to_hash_with_edges(hash, key, value) + return if value.nil? + + hash[key] = value.transform_keys(&:to_s) if value.is_a?(Hash) + end + + def add_to_hash_with_gutters(hash, key, value) + return if value.nil? + + hash[key] = value.transform_keys(&:to_s) if value.is_a?(Hash) + end +end + +class BaseDrawStyle + attr_accessor :background_color, :border, :border_top, :border_right, :border_bottom, :border_left, :rounding, :round_corners + + def initialize(background_color: nil, border: nil, border_top: nil, border_right: nil, border_bottom: nil, border_left: nil, rounding: nil, round_corners: nil) + @background_color = background_color + @border = border + @border_top = border_top + @border_right = border_right + @border_bottom = border_bottom + @border_left = border_left + @rounding = rounding + @round_corners = round_corners + end + + def to_hash + out = {} + + out['backgroundColor'] = @background_color if @background_color + out['border'] = @border.to_hash if @border + out['borderTop'] = @border_top.to_hash if @border_top + out['borderRight'] = @border_right.to_hash if @border_right + out['borderBottom'] = @border_bottom.to_hash if @border_bottom + out['borderLeft'] = @border_left.to_hash if @border_left + out['rounding'] = @rounding if @rounding + out['roundCorners'] = @round_corners if @round_corners + + out + end +end + +class NodeStyleDef + attr_accessor :layout, :base_draw + + def initialize(layout: nil, base_draw: nil) + @layout = layout + @base_draw = base_draw + end + + def to_hash + out = {} + + out.merge!(layout.to_hash) if layout + out.merge!(base_draw.to_hash) if base_draw + + out + end +end + +class WidgetStyleDef + attr_accessor :style_rules, :layout, :base_draw + + def initialize(style_rules: nil, layout: nil, base_draw: nil) + @style_rules = style_rules + @layout = layout + @base_draw = base_draw + end + + def to_hash + out = {} + + out.merge!(style_rules.to_hash) if style_rules + out.merge!(layout.to_hash) if layout + out.merge!(base_draw.to_hash) if base_draw + + out + end +end + +class NodeStyle + attr_accessor :style, :hover_style, :active_style, :disabled_style + + def initialize(style: nil, hover_style: nil, active_style: nil, disabled_style: nil) + @style = style + @hover_style = hover_style + @active_style = active_style + @disabled_style = disabled_style + end + + def to_hash + out = {} + + out[:style] = style.to_hash if style + out[:hover_style] = hover_style.to_hash if hover_style + out[:active_style] = active_style.to_hash if active_style + out[:disabled_style] = disabled_style.to_hash if disabled_style + + out + end +end + +class WidgetStyle + attr_accessor :style, :hover_style, :active_style, :disabled_style + + def initialize(style: nil, hover_style: nil, active_style: nil, disabled_style: nil) + @style = style + @hover_style = hover_style + @active_style = active_style + @disabled_style = disabled_style + end + + def to_hash + out = {} + + out[:style] = style.to_hash if style + out[:hover_style] = hover_style.to_hash if hover_style + out[:active_style] = active_style.to_hash if active_style + out[:disabled_style] = disabled_style.to_hash if disabled_style + + out + end +end diff --git a/treetraversal.rb b/treetraversal.rb new file mode 100644 index 0000000..8284271 --- /dev/null +++ b/treetraversal.rb @@ -0,0 +1,140 @@ +require 'rx' +require 'json' +require_relative 'widgetnode' +require_relative 'widgettypes' +require_relative 'services' + +class ShadowNode + attr_accessor :id, :renderable, :current_props, :children, :props_change_subscription, :children_change_subscription + + def initialize(id, renderable) + @id = id + @renderable = renderable + @current_props = {} + @children = [] + @props_change_subscription = nil + @children_change_subscription = nil + end + + def to_hash + { + "id" => @id, + "current_props" => @current_props, + "children" => @children.map(&:to_hash) + } + end + + def get_linkable_children + out = [] + @children.each do |child| + next if child.nil? || child.renderable.nil? + + if child.renderable.is_a?(WidgetNode) + out << child + elsif !child.children.empty? + out.concat(child.get_linkable_children) + end + end + out + end +end + +class ShadowNodeTraversalHelper + def initialize(widget_registration_service) + @widget_registration_service = widget_registration_service + end + + def are_props_equal(props1, props2) + props1 == props2 + end + + def subscribe_to_props_helper(shadow_node) + if shadow_node.props_change_subscription + shadow_node.props_change_subscription.dispose + end + + promise = Concurrent::Promise.execute do + + if shadow_node.renderable.is_a?(BaseComponent) + component = shadow_node.renderable + shadow_node.props_change_subscription = component.props.pipe( + Rx::Operators.skip(1) + ).subscribe { |new_props| handle_component_props_change(shadow_node, component, new_props) } + elsif shadow_node.renderable.is_a?(WidgetNode) + shadow_node.props_change_subscription = shadow_node.renderable.props.pipe( + Rx::Operators.skip(1) + ).subscribe { |new_props| handle_widget_node_props_change(shadow_node, shadow_node.renderable, new_props) } + end + + end + + promise.wait + end + + def handle_widget_node(widget) + if widget.type == WidgetTypes[:Button] + on_click = widget.props["on_click"] + if on_click + @widget_registration_service.register_on_click(widget.id, on_click) + else + puts "Button widget must have on_click prop" + end + end + end + + def handle_component_props_change(shadow_node, component, new_props) + return if are_props_equal(shadow_node.current_props, new_props) + + shadow_child = component.render + shadow_node.children = [traverse_tree(shadow_child)] + shadow_node.current_props = new_props + + linkable_children = shadow_node.get_linkable_children + @widget_registration_service.link_children(shadow_node.id, linkable_children.map(&:id)) + end + + def handle_widget_node_props_change(shadow_node, widget_node, new_props) + @widget_registration_service.create_widget( + WidgetNode.create_raw_childless_widget_node_with_id(shadow_node.id, widget_node) + ) + + shadow_children = widget_node.children.map { |child| traverse_tree(child) } + shadow_node.children = shadow_children + shadow_node.current_props = new_props + + @widget_registration_service.link_children(shadow_node.id, shadow_node.children.map(&:id)) + end + + def traverse_tree(renderable) + if renderable.is_a?(BaseComponent) + rendered_child = renderable.render + shadow_child = traverse_tree(rendered_child) + id = @widget_registration_service.get_next_component_id + shadow_node = ShadowNode.new(id, renderable) + shadow_node.children = [shadow_child] + # shadow_node.current_props = renderable.props.value + subscribe_to_props_helper(shadow_node) + return shadow_node + elsif renderable.is_a?(WidgetNode) + id = @widget_registration_service.get_next_widget_id + raw_node = create_raw_childless_widget_node_with_id(id, renderable) + handle_widget_node(raw_node) + @widget_registration_service.create_widget(raw_node) + + shadow_node = ShadowNode.new(id, renderable) + shadow_node.children = renderable.children.value.map { |child| traverse_tree(child) } + shadow_node.current_props = renderable.props.value + + linkable_children = shadow_node.get_linkable_children + if !linkable_children.empty? + @widget_registration_service.link_children(id, linkable_children.map(&:id)) + end + + subscribe_to_props_helper(shadow_node) + + return shadow_node + else + raise 'Unrecognised renderable' + end + end +end diff --git a/widgetnode.rb b/widgetnode.rb new file mode 100644 index 0000000..de86288 --- /dev/null +++ b/widgetnode.rb @@ -0,0 +1,107 @@ +require 'rx' +require 'json' +require_relative 'theme' +require_relative 'widgettypes' + +class BaseComponent + def initialize(props) + @props = Rx::BehaviorSubject.new(props) + end + + # Abstract method to be overridden + def render + raise NotImplementedError, 'You must implement the render method' + end +end + +class WidgetNode + attr_accessor :type, :props, :children + + def initialize(type, props = {}, children = []) + @type = type + @props = Rx::BehaviorSubject.new(props) + @children = Rx::BehaviorSubject.new(children) + end +end + +class RawChildlessWidgetNodeWithId + attr_reader :id, :type, :props + + def initialize(id, type, props = {}) + @id = id + @type = type + @props = props + end + + def to_hash + out = { + 'id' => @id, + 'type' => @type.to_s + } + + @props.each do |key, value| + unless value.is_a?(Proc) + if (value.is_a?(WidgetStyleDef) || value.is_a?(NodeStyleDef)) + out[key] = value.to_hash + else + out[key] = value + end + end + end + + out + end +end + +def widget_node_factory(widget_type, props = {}, children = []) + WidgetNode.new(widget_type, props, children) +end + +def create_raw_childless_widget_node_with_id(id, node) + RawChildlessWidgetNodeWithId.new(id, node.type, node.props.value) +end + +def init_props_with_style(style = nil) + props = {} + + if style + props['style'] = style.style if style.style + props['activeStyle'] = style.active_style if style.active_style + props['hoverStyle'] = style.hover_style if style.hover_style + props['disabledStyle'] = style.disabled_style if style.disabled_style + end + + props +end + +def root_node(children, style = nil) + props = init_props_with_style(style) + props['root'] = true + + widget_node_factory(WidgetTypes[:Node], props, children) +end + +def node(children, style = nil) + props = init_props_with_style(style) + props['root'] = false + + widget_node_factory(WidgetTypes[:Node], props, children) +end + +def unformatted_text(text, style = nil) + props = init_props_with_style(style) + props['text'] = text + + widget_node_factory(WidgetTypes[:UnformattedText], props, []) +end + +def button(label, on_click = nil, style = nil) + raise TypeError, 'on_click must be a callable' if on_click && !on_click.is_a?(Proc) + + props = init_props_with_style(style) + props['label'] = label + props['on_click'] = on_click if on_click + + widget_node_factory(WidgetTypes[:Button], props, []) +end + diff --git a/widgettypes.rb b/widgettypes.rb new file mode 100644 index 0000000..303e61f --- /dev/null +++ b/widgettypes.rb @@ -0,0 +1,5 @@ +WidgetTypes = { + Node: 'node', + UnformattedText: 'unformatted-text', + Button: 'di-button' +} \ No newline at end of file diff --git a/xframes.rb b/xframes.rb new file mode 100644 index 0000000..270b403 --- /dev/null +++ b/xframes.rb @@ -0,0 +1,36 @@ +require 'ffi' + +module XFrames + extend FFI::Library + if RUBY_PLATFORM =~ /win32|mingw|cygwin/ + ffi_lib './xframesshared.dll' + else + ffi_lib './libxframesshared.so' + end + + # Define callback types + callback :OnInitCb, [:pointer], :void + callback :OnTextChangedCb, [:int, :string], :void + callback :OnComboChangedCb, [:int, :int], :void + callback :OnNumericValueChangedCb, [:int, :float], :void + callback :OnBooleanValueChangedCb, [:int, :int], :void + callback :OnMultipleNumericValuesChangedCb, [:int, :pointer, :int], :void + callback :OnClickCb, [:int], :void + + attach_function :init, [ + :string, # assetsBasePath + :string, # rawFontDefinitions + :string, # rawStyleOverrideDefinitions + :OnInitCb, + :OnTextChangedCb, + :OnComboChangedCb, + :OnNumericValueChangedCb, + :OnBooleanValueChangedCb, + :OnMultipleNumericValuesChangedCb, + :OnClickCb + ], :void + + attach_function :setElement, [:string], :void + + attach_function :setChildren, [:int, :string], :void +end \ No newline at end of file