AndyObtiva/glimmer-dsl-libui

View on GitHub
lib/glimmer/libui/control_proxy/table_proxy.rb

Summary

Maintainability
F
1 wk
Test Coverage
# 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/control_proxy'
require 'glimmer/libui/control_proxy/dual_column'
require 'glimmer/libui/control_proxy/triple_column'
require 'glimmer/data_binding/observer'
require 'glimmer/fiddle_consumer'

using ArrayIncludeMethods

module Glimmer
  module LibUI
    class ControlProxy
      # Proxy for LibUI table objects
      #
      # Follows the Proxy Design Pattern
      class TableProxy < ControlProxy
        include Glimmer::FiddleConsumer
        
        CUSTOM_LISTENER_NAMES = ['on_changed', 'on_edited']
        DEFAULT_COLUMN_SORT_BLOCK = lambda do |table_cell_row, column, table_proxy|
          if table_cell_row.is_a?(Array)
            value = table_cell_row[column]
          else
            attribute = table_proxy.column_attributes[column]
            value = table_cell_row.send(attribute)
          end
          if value.is_a?(Array)
            # This is needed to not crash on sorting an unsortable array
            value = value.map do |element|
              case element
              when true
                1
              when false
                0
              else
                element
              end
            end
          end
          value
        end
        
        attr_reader :model_handler, :model, :table_params, :columns
      
        def initialize(keyword, parent, args, &block)
          @keyword = keyword
          @parent_proxy = parent
          @args = args
          @block = block
          @enabled = true
          @columns = []
          @cell_rows = []
          @last_cell_rows = nil
          register_cell_rows_observer
          window_proxy.on_destroy do
            # the following unless condition is an exceptional condition stumbled upon that fails freeing the table model
            ::LibUI.free_table_model(@model) unless @destroyed && parent_proxy.is_a?(Box)
          end
        end
        
        def post_add_content
          build_control if !@content_added && @libui.nil?
          super
          # TODO consider automatically detecting what properties/listeners accumulated dynamically to avoid hardcoding code below
          register_listeners
          register_column_listeners
          configure_selection_mode
          configure_selection
          configure_header_visible
          configure_column_sort_indicators
          configure_sorting
        end
        
        def post_initialize_child(child)
          @columns << child
          # add extra complementary columns (:text, :color) if it is a dual/triple column (i.e. ImageTextColumnProxy or CheckboxTextColumnProxy
          case child
          when Column::ImageTextColumnProxy, Column::CheckboxTextColumnProxy
            @columns << :text
          when Column::TextColorColumnProxy
            @columns << :color
          when Column::CheckboxTextColorColumnProxy, Column::ImageTextColorColumnProxy
            @columns << :text
            @columns << :color
          end
        end
        
        def destroy
          super
          # TODO consider replacing unobserve with observer_registration.deregister
          @cell_rows_observer&.unobserve(self, :cell_rows, recursive: true, ignore_frozen: true, attribute_writer_type: [:attribute=, :set_attribute])
          @destroyed = true
        end
        
        def cell_rows(rows = nil)
          if rows.nil?
            @cell_rows
          else
            if !rows.equal?(@cell_rows)
              @cell_rows = rows
              # TODO instead of clearing cached cell rows, consider amending cached cell rows for better performance (to avoid regenerating them)
              clear_cached_cell_rows
              if @cell_rows.is_a?(Enumerator)
                @cell_rows.rewind
                @future_last_cell_rows = array_deep_dup(@cell_rows) # must be done after rewinding
              end
            end
            @cell_rows
          end
        end
        alias cell_rows= cell_rows
        alias set_cell_rows cell_rows
        
        def expand_cell_rows(cell_rows = nil)
          cell_rows ||= self.cell_rows
          cell_rows ||= []
          cell_rows.map { |cell_row| expand_cell_row(cell_row) }
        end
        alias expanded_cell_rows expand_cell_rows
        
        def expand_cell_row(cell_row)
          return cell_row if cell_row.nil?
          cell_row = expand_cell_row_from_model(cell_row) if !cell_row.is_a?(Array) && column_attributes.any?
          cell_row.flatten(1)
        end
        
        def expand_cell_row_from_model(cell_row)
          column_attributes.to_a.map {|attribute| cell_row.send(attribute) }
        end
        
        def editable(value = nil)
          if value.nil?
            @editable
          else
            @editable = !!value
          end
        end
        alias editable= editable
        alias set_editable editable
        alias editable? editable
        
        def selection_mode(value = nil)
          if value.nil?
            @selection_mode
          else
            value = LibUI.enum_symbol_to_value(:table_selection_mode, value)
            @selection_mode = value
          end
        end
        alias selection_mode= selection_mode
        alias set_selection_mode selection_mode
        
        def selection
          return @selection if !@content_added
          
          tsp = super
          ts = ::LibUI::FFI::TableSelection.new(tsp)
          if ts.NumRows > 0
            selection_array = ts.Rows[0, Fiddle::SIZEOF_INT * ts.NumRows].unpack("i*")
            selection_mode == ::LibUI::TableSelectionModeZeroOrMany ? selection_array : selection_array.first
          end
        ensure
          ::LibUI.free_table_selection(tsp)
        end
        
        def selection=(*value)
          value = value.first if value.size == 1
          @selection = value
          return @selection if !@content_added
          return if @selection.nil?
        
          ts = ::LibUI::FFI::TableSelection.malloc
          ts.NumRows = @selection.is_a?(Array) ? @selection.size : 1
          ts.Rows = [@selection].flatten.pack('i*')
          super(ts)
          # TODO figure out why ensure block is not working (perhaps libui auto-frees that resource upon setting selection)
          # Delete following code if not needed.
#         ensure
#           ::LibUI.free_table_selection(ts)
        end
        alias set_selection selection=
        
        def header_visible
          return @header_visible if !@content_added
          
          result = ::LibUI.table_header_visible(@libui)
          LibUI.integer_to_boolean(result)
        end
        alias header_visible? header_visible
        
        def header_visible=(value)
          @header_visible = value
          return @header_visible if !@content_added
          return if value.nil?
          
          value = LibUI.boolean_to_integer(value)
          ::LibUI.table_header_set_visible(@libui, value)
        end
        alias set_header_visible header_visible=
        
        def sortable
          @sortable = true if @sortable.nil?
          @sortable
        end
        alias sortable? sortable
        
        def sortable=(value)
          @sortable = value
        end
        alias set_sortable sortable=
        
        def column_attributes
          @column_attributes ||= columns.select {|column| column.is_a?(Column)}.map(&:name).map(&:underscore)
        end
        
        def data_bind_read(property, model_binding)
          if model_binding.binding_options[:column_attributes].is_a?(Array)
            @column_attributes = model_binding.binding_options[:column_attributes]
          else
            column_attribute_mapping = model_binding.binding_options[:column_attributes].is_a?(Hash) ? model_binding.binding_options[:column_attributes] : {}
            @column_attributes = columns.select {|column| column.is_a?(Column)}.map(&:name).map {|column_name| column_attribute_mapping[column_name] || column_name.underscore}
          end
          model_attribute_observer = model_attribute_observer_registration = nil
          model_attribute_observer = Glimmer::DataBinding::Observer.proc do
            new_value = model_binding.evaluate_property
            if !new_value.is_a?(Enumerator) &&
              (
                model_binding.binding_options[:column_attributes] ||
                (!new_value.nil? && (!new_value.is_a?(String) || !new_value.empty?) && (!new_value.is_a?(Array) || !new_value.first.is_a?(Array)))
              )
              @model_attribute_array_observer_registration&.deregister
              @model_attribute_array_observer_registration = model_attribute_observer.observe(new_value, column_attributes, ignore_frozen: true, attribute_writer_type: [:attribute=, :set_attribute])
              model_attribute_observer.add_dependent(model_attribute_observer_registration => @model_attribute_array_observer_registration)
            end
            # TODO look if multiple notifications are happening as a result of observing array and observing model binding
            last_cell_rows = @last_cell_rows || [] # initialize with empty array of first time loading table (only time when @last_cell_rows is nil)
            send("#{property}=", new_value) unless last_cell_rows == new_value
          end
          model_attribute_observer_registration = model_attribute_observer.observe(model_binding, attribute_writer_type: [:attribute=, :set_attribute])
          model_attribute_observer.call # initial update
          data_binding_model_attribute_observer_registrations << model_attribute_observer_registration
          model_attribute_observer
        end
        
        def data_bind_write(property, model_binding)
          case property
          when 'cell_rows'
            handle_listener('on_edited') { model_binding.call(cell_rows) }
          when 'selection'
            handle_listener('on_selection_changed') { model_binding.call(selection) }
          end
        end
        
        def array_deep_dup(array_or_object)
          if array_or_object.is_a?(Array)
            array_or_object.map do |element|
              array_deep_dup(element)
            end
          else
            array_or_object.dup
          end
        end
        
        def compound_column_index_for(expanded_column_index)
          compound_column = @columns.find { |compound_column| compound_column.respond_to?(:column_index) && compound_column.column_index == expanded_column_index }
          compound_columns = @columns.select { |compound_column| compound_column.is_a?(Column) }
          compound_columns.index(compound_column)
        end
        
        def column_proxies
          @columns&.select {|c| c.is_a?(Column)}
        end
        
        def handle_listener(listener_name, &listener)
          # if content has been added, then you can register listeners immediately (without accumulation)
          if CUSTOM_LISTENER_NAMES.include?(listener_name) || @content_added
            actual_listener = listener
            case listener_name
            when 'on_selection_changed'
              actual_listener = Proc.new do |myself, *args|
                added_selection = selection.is_a?(Array) ? (selection - @old_selection.to_a) : selection
                removed_selection = selection.is_a?(Array) ? (@old_selection.to_a - selection) : @old_selection
                listener.call(myself, selection, added_selection, removed_selection)
              end
            end
            super(listener_name, &actual_listener)
          else
            # if content is not added yet, then accumulate listeners to register later when table content is closed
            @table_listeners ||= []
            @table_listeners << [listener_name, listener]
          end
        end
        
        private
        
        # returns table cell for row and column (expanded)
        def expanded_cell_for(row, column)
          cell_row = expanded_cell_row_for(row)
          cell = cell_row && cell_row[column]
        end
        
        def expanded_cell_row_for(row)
          expand_cell_row(cell_row_for(row))
        end
        
        def cell_row_for(row)
          @cached_cell_rows ||= []
          if @cached_cell_rows[row].nil?
            cell_rows = self.cell_rows || []
            if cell_rows.is_a?(Enumerator)
              @cached_cell_rows_size ||= @cached_cell_rows.size
              @cached_cell_rows_enumerator_index ||= 0
              # TODO consider handling size being nil and needing to force Enumerator count instead
              while @cached_cell_rows_enumerator_index <= row
                begin
                  @cached_cell_rows << cell_rows.next
                  @cached_cell_rows_enumerator_index += 1
                rescue StopIteration => e
                  break
                end
              end
            else
              @cached_cell_rows = cell_rows
            end
          end
          @cached_cell_rows[row]
        end
        
        def last_cell_row_for(row)
          # TODO refactor to share code with cell_row_for
          @cached_last_cell_rows ||= []
          if @cached_last_cell_rows[row].nil?
            last_cell_rows = @last_cell_rows || []
            if last_cell_rows.is_a?(Enumerator)
              @cached_last_cell_rows_size ||= @cached_last_cell_rows.size
              @cached_last_cell_rows_enumerator_index ||= 0
              # TODO consider handling size being nil and needing to force Enumerator count instead
              while @cached_last_cell_rows_enumerator_index <= row
                begin
                  @cached_last_cell_rows << last_cell_rows.next
                  @cached_last_cell_rows_enumerator_index += 1
                rescue StopIteration => e
                  break
                end
              end
            else
              @cached_last_cell_rows = last_cell_rows
            end
          end
          @cached_last_cell_rows[row]
        end
        
        def last_last_cell_row_for(row)
          # TODO refactor to share code with cell_row_for
          @cached_last_last_cell_rows ||= []
          if @cached_last_last_cell_rows[row].nil?
            last_last_cell_rows = @last_last_cell_rows || []
            if last_last_cell_rows.is_a?(Enumerator)
              @cached_last_last_cell_rows_size ||= @cached_last_last_cell_rows.size
              @cached_last_last_cell_rows_enumerator_index ||= 0
              # TODO consider handling size being nil and needing to force Enumerator count instead
              while @cached_last_last_cell_rows_enumerator_index <= row
                begin
                  @cached_last_last_cell_rows << last_last_cell_rows.next
                  @cached_last_last_cell_rows_enumerator_index += 1
                rescue StopIteration => e
                  break
                end
              end
            else
              @cached_last_last_cell_rows = last_last_cell_rows
            end
          end
          @cached_last_last_cell_rows[row]
        end
        
        def dup_last_cell_rows
          return (@last_cell_rows = nil) if @cell_rows.nil?
          # using future last cell rows guarantees that Enumerator is rewound
          @last_cell_rows = @future_last_cell_rows || array_deep_dup(@cell_rows)
          @future_last_last_cell_rows = array_deep_dup(@last_cell_rows)
          @future_last_cell_rows = nil
          clear_cached_last_cell_rows
          @last_cell_rows
        end
        
        def dup_last_last_cell_rows
          return (@last_last_cell_rows = nil) if @last_cell_rows.nil?
          # using future last cell rows guarantees that Enumerator is rewound
          @last_last_cell_rows = @future_last_last_cell_rows || array_deep_dup(@last_cell_rows)
          @future_last_last_cell_rows = nil
          clear_cached_last_last_cell_rows
          @last_last_cell_rows
        end
        
        def build_control
          @model_handler = ::LibUI::FFI::TableModelHandler.malloc
          @model_handler.NumColumns   = fiddle_closure_block_caller(4) { @columns.map {|c| c.is_a?(DualColumn) ? 2 : (c.is_a?(TripleColumn) ? 3 : 1)}.sum }
          @model_handler.ColumnType   = fiddle_closure_block_caller(4, [1, 1, 4]) do |_, _, column|
            # TODO consider refactoring to use Glimmer::LibUI.enum_symbols(:table_value_type)
            case @columns[column]
            when Column::TextColumnProxy, Column::ButtonColumnProxy, Column::TextColorColumnProxy, :text
              0
            when Column::ImageColumnProxy, Column::ImageTextColumnProxy, Column::ImageTextColorColumnProxy
              1
            when Column::CheckboxColumnProxy, Column::CheckboxTextColumnProxy, Column::CheckboxTextColorColumnProxy, Column::ProgressBarColumnProxy
              2
            when Column::BackgroundColorColumnProxy, :color
              3
            end
          end
          @model_handler.NumRows      = fiddle_closure_block_caller(4) do
            # Note: there is a double-delete bug in Windows when performing table_model_row_deleted, which requires pre-adding and extra empty row
            cell_rows.size + (OS.windows? ? 1 : 0)
            # TODO consider handling case of Enumerator not having size, but having count that needs to be forced instead
          end
          @model_handler.CellValue    = fiddle_closure_block_caller(1, [1, 1, 4, 4]) do |_, _, row, column|
            cell_rows = self.cell_rows || []
            the_cell = expanded_cell_for(row, column)
            case @columns[column]
            when Column::TextColumnProxy, Column::ButtonColumnProxy, Column::TextColorColumnProxy, :text
              ::LibUI.new_table_value_string((the_cell).to_s)
            when Column::ImageColumnProxy, Column::ImageTextColumnProxy, Column::ImageTextColorColumnProxy
              if OS.windows? && row == cell_rows.size
                img = Glimmer::LibUI::ICON
              else
                img = the_cell
              end
              img = ControlProxy::ImageProxy.create('image', nil, img) if img.is_a?(Array)
              img = ControlProxy::ImageProxy.create('image', nil, [img]) if img.is_a?(String)
              img = img.respond_to?(:libui) ? img.libui : img
              ::LibUI.new_table_value_image(img)
            when Column::CheckboxColumnProxy, Column::CheckboxTextColumnProxy, Column::CheckboxTextColorColumnProxy
              ::LibUI.new_table_value_int(((the_cell == 1 || the_cell.to_s.strip.downcase == 'true' ? 1 : 0)) || 0)
            when Column::ProgressBarColumnProxy
              value = (the_cell).to_i
              expanded_last_last_cell_row = expand_cell_row(last_last_cell_row_for(row))
              # TODO consider only caching old value when displayed in CellValue before, but otherwise not worrying about it (if row was hiding, this fix shouldn't be needed on Windows)
              old_value = (expanded_last_last_cell_row && expanded_last_last_cell_row[column]).to_i
              if OS.windows? && old_value == -1 && value >= 0
                Glimmer::Config.logger.error('Switching a progress bar value from -1 to a positive value is not supported on Windows')
                cell_row = cell_row_for(row)
                if cell_row.is_a?(Array)
                  cell_row[compound_column_index_for(column)] = -1
                else
                  attribute = column_attributes[column]
                  cell_row.send("#{attribute}=", -1)
                end
                ::LibUI.new_table_value_int(old_value)
              else
                ::LibUI.new_table_value_int((the_cell).to_i)
              end
            when Column::BackgroundColorColumnProxy
              background_color = Glimmer::LibUI.interpret_color(the_cell) || {r: 255, g: 255, b: 255}
              ::LibUI.new_table_value_color(background_color[:r] / 255.0, background_color[:g] / 255.0, background_color[:b] / 255.0, background_color[:a] || 1.0)
            when :color
              color = Glimmer::LibUI.interpret_color(the_cell) || {r: 0, g: 0, b: 0}
              ::LibUI.new_table_value_color(color[:r] / 255.0, color[:g] / 255.0, color[:b] / 255.0, color[:a] || 1.0)
            end
          end
          @model_handler.SetCellValue = fiddle_closure_block_caller(0, [1, 1, 4, 4, 1]) do |_, _, row, column, val|
            table_cell_row = cell_row_for(row)
            case @columns[column]
            when Column::TextColumnProxy
              column = @columns[column].index
              if table_cell_row.is_a?(Array)
                table_cell_row[column] = ::LibUI.table_value_string(val).to_s
              else
                attribute = column_attributes[column]
                table_cell_row.send("#{attribute}=", ::LibUI.table_value_string(val).to_s)
              end
            when Column::TextColorColumnProxy
              column = @columns[column].index
              if table_cell_row.is_a?(Array)
                table_cell_row[column] ||= []
                table_cell_row[column][0] = ::LibUI.table_value_string(val).to_s
              else
                attribute = column_attributes[column]
                table_cell_row.send("#{attribute}=", []) unless table_cell_row.send(attribute)
                table_cell_row.send(attribute)[0] = ::LibUI.table_value_string(val).to_s
              end
            when :text
              column = @columns[column - 1].index
              if table_cell_row.is_a?(Array)
                table_cell_row[column] ||= []
                table_cell_row[column][1] = ::LibUI.table_value_string(val).to_s
              else
                attribute = column_attributes[column]
                table_cell_row.send("#{attribute}=", []) unless table_cell_row.send(attribute)
                table_cell_row.send(attribute)[1] = ::LibUI.table_value_string(val).to_s
              end
            when Column::ButtonColumnProxy
              @columns[column].notify_custom_listeners('on_clicked', row)
            when Column::CheckboxColumnProxy
              column = @columns[column].index
              if table_cell_row.is_a?(Array)
                table_cell_row[column] = ::LibUI.table_value_int(val).to_i == 1
              else
                attribute = column_attributes[column]
                table_cell_row.send("#{attribute}=", ::LibUI.table_value_int(val).to_i == 1)
              end
            when Column::CheckboxTextColumnProxy, Column::CheckboxTextColorColumnProxy
              column = @columns[column].index
              if table_cell_row.is_a?(Array)
                table_cell_row[column] ||= []
                table_cell_row[column][0] = ::LibUI.table_value_int(val).to_i == 1
              else
                attribute = column_attributes[column]
                table_cell_row.send("#{attribute}=", []) unless table_cell_row.send(attribute)
                table_cell_row.send(attribute)[0] = ::LibUI.table_value_int(val).to_i == 1
              end
            end
            notify_custom_listeners('on_edited', row, table_cell_row)
          end
          
          @model = ::LibUI.new_table_model(@model_handler)
          
          # figure out and reserve column indices for columns
          @columns.each { |column| column.respond_to?(:column_index) && column.column_index }
          
          @table_params = ::LibUI::FFI::TableParams.malloc
          @table_params.Model = @model
          @table_params.RowBackgroundColorModelColumn = @columns.find {|column| column.is_a?(Column::BackgroundColorColumnProxy)}&.column_index || -1
          
          @libui = ControlProxy.new_control(@keyword, [@table_params])
          @libui.tap do
            @columns.each {|column| column.respond_to?(:build_control, true) && column.send(:build_control) }
          end

          if !@applied_windows_fix && OS.windows?
            @applied_windows_fix = true
            apply_windows_fix
          end
        end
        
        def clear_cached_cell_rows
          @cached_cell_rows = nil
          @cached_cell_rows_size = nil
          @cached_cell_rows_enumerator_index = nil
        end
        
        def clear_cached_last_cell_rows
          @cached_last_cell_rows = nil
          @cached_last_cell_rows_size = nil
          @cached_last_cell_rows_enumerator_index = nil
        end
        
        def clear_cached_last_last_cell_rows
          @cached_last_last_cell_rows = nil
          @cached_last_last_cell_rows_size = nil
          @cached_last_last_cell_rows_enumerator_index = nil
        end
        
        def next_column_index
          @next_column_index ||= -1
          @next_column_index += 1
        end
        
        def register_cell_rows_observer
          # TODO update all the each_with_index calls below to work differently when value is an Enumerator
          # Perhaps, call ::LibUI.table_model_row_inserted(model, row) not from here, yet from the place where
          # we call `next` on the Enumerator to grab more elements
          # There will need to be extra intelligence for figuring out when to delete instead of
          # deleting a million rows when a change occurs
          @cell_rows_observer = Glimmer::DataBinding::Observer.proc do |new_cell_rows|
            if !@last_cell_rows.nil? # not initial load of table
              if @cell_rows.size < @last_cell_rows.size
                # TODO avoid inefficient delete/change notifications by only updating cells that need updates (only cells that have been displayed in CellValue)
                # instead of updating everything
                @last_cell_rows.each_with_index do |old_row_data, row|
                  new_row_data = cell_row_for(row)
                  if old_row_data != new_row_data && model
                    ::LibUI.table_model_row_changed(model, row)
                    notify_custom_listeners('on_changed', row, :changed, new_row_data)
                  end
                end
                count_of_cells_to_delete = @last_cell_rows.size - @cell_rows.size
                count_of_cells_to_delete.times do |n|
                  row = @last_cell_rows.size - n - 1
                  if model
                    ::LibUI.table_model_row_deleted(model, row)
                    notify_custom_listeners('on_changed', row, :deleted, last_cell_row_for(row))
                  end
                end
              elsif @cell_rows.size > @last_cell_rows.size
                (@cell_rows.size - @last_cell_rows.size).times do |n|
                  row = @last_cell_rows.size + n
                  if model
                    ::LibUI.table_model_row_inserted(model, row)
                    notify_custom_listeners('on_changed', row, :inserted, cell_row_for(row))
                  end
                end
                # TODO avoid inefficient insert/change notifications by only updating cells that need updates (only cells that have been displayed in CellValue)
                # instead of updating everything
                @cell_rows.each_with_index do |new_row_data, row|
                  if new_row_data != last_cell_row_for(row) && model
                    ::LibUI.table_model_row_changed(model, row)
                    notify_custom_listeners('on_changed', row, :changed, new_row_data)
                  end
                end
              elsif @cell_rows != @last_cell_rows
                @cell_rows.each_with_index do |new_row_data, row|
                  if new_row_data != last_cell_row_for(row) && model
                    ::LibUI.table_model_row_changed(model, row)
                    notify_custom_listeners('on_changed', row, :changed, new_row_data)
                  end
                end
              end
            end
            # TODO look into performance implications of deep cloning an entire array,
            # which wastes time looping through all elements even if we are displaying about
            # 20 of them only at the moment, thus preventing lazy loading from happening
            # Consider alternatively caching rows per index when needed instead
            # This seems to add about a full second to certain apps, especially when using enumerators
            # to avoid loading everything at once
            
            dup_last_last_cell_rows if OS.windows?
            dup_last_cell_rows
            
            if !@applied_windows_fix_on_first_cell_rows_update && OS.windows?
              @applied_windows_fix_on_first_cell_rows_update = true
              apply_windows_fix
            end
          end
          @cell_rows_observer_registration = @cell_rows_observer.observe(self, :cell_rows, recursive: true, ignore_frozen: true, attribute_writer_type: [:attribute=, :set_attribute])
          @cell_rows_observer.call
        end

        def apply_windows_fix
          # TODO will this require that @cell_rows_observer is called with queue_main too to avoid issue with multiple immediate model updates breaking this?
          Glimmer::LibUI.queue_main do
            new_row = @columns&.select {|column| column.is_a?(Column)}&.map {|column| column.class.default_value}
            if new_row && !@cell_rows.is_a?(Enumerator)
              @cell_rows << new_row
              @cell_rows.pop
            end
          end
        end
        
        def register_listeners
          # register accumulated listeners after table content is closed
          @table_listeners&.each do |listener_name, listener|
            handle_listener(listener_name, &listener)
          end
          handle_listener('on_selection_changed') do |myself, selection, added_selection, removed_selection|
            @old_selection = selection
          end
        end
        
        def register_column_listeners
          # register accumulated column listeners after table content is closed
          return if @columns.nil? || @columns.empty?
          if @columns.any? {|column| column.is_a?(Column)}
            ::LibUI.table_header_on_clicked(@libui) do |_, column_index|
              actual_columns = @columns.select {|column| column.is_a?(Column)}
              column = actual_columns[column_index]
              if column.is_a?(Column) && !column.is_a?(Column::ButtonColumnProxy)
                column_listeners = column.column_listeners_for('on_clicked')
                column_listeners.each do |column_listener|
                  column_listener.call(column, column_index)
                end
              end
            end
          end
        end
        
        def configure_selection_mode
          send_to_libui('selection_mode=', @selection_mode) if @selection_mode
        end
        
        def configure_selection
          self.selection = @selection
        end
        
        def configure_header_visible
          self.header_visible = @header_visible
        end
        
        def configure_column_sort_indicators
          column_proxies.each(&:configure_sort_indicator)
        end
        
        def configure_sorting
          if sortable?
            columns.each do |column_object|
              next unless column_object.is_a?(Column) && !column_object.is_a?(Column::ButtonColumnProxy)
              column_object.on_clicked do |column_proxy, column|
                sort_by_column(column_proxy, column)
              end
            end
          end
        end
        
        def sort_by_column(column_proxy, column)
          return unless sortable? && cell_rows.is_a?(Array)
          old_selection = backup_selection
          column_proxy.toggle_sort_indicator
          cell_rows.sort_by! {|table_cell_row| DEFAULT_COLUMN_SORT_BLOCK.call(table_cell_row, column, self) }
          cell_rows.reverse! if column_proxy.sort_indicator == :descending
          restore_selection(old_selection)
        end
        
        def backup_selection
          if selection_mode == ::LibUI::TableSelectionModeZeroOrMany
            selected_rows = selection&.map { |row| cell_rows[row] }
          else
            selected_row = selection && cell_rows[selection]
          end
        end
        
        def restore_selection(old_selection)
          if selection_mode == ::LibUI::TableSelectionModeZeroOrMany
            selected_rows = old_selection
            self.selection = selected_rows&.map {|row_data| cell_rows.index(row_data) }
          else
            selected_row = old_selection
            self.selection = cell_rows.index(selected_row)
          end
        end
        
      end
    end
  end
end