lib/glimmer/libui/custom_control/refined_table.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 'csv'
require 'facets/string/underscore'
require 'glimmer/libui/custom_control'
module Glimmer
module LibUI
module CustomControl
class RefinedTable
include Glimmer::LibUI::CustomControl
FILTER_DEFAULT = lambda do |row_hash, query|
text = row_hash.values.map(&:to_s).map(&:downcase).join(' ')
if query != @last_query
@last_query = query
@query_words = []
query_text = query.strip
until query_text.empty?
exact_term_double_quoted_regexp = /"[^"]+"/
specific_column_double_quoted_column_name_regexp = /"[^":]+":[^": ]+/
specific_column_double_quoted_column_value_regexp = /[^": ]+:"[^":]+"/
specific_column_double_quoted_column_name_and_value_regexp = /"[^":]+":"[^":]+"/
single_word_regexp = /\S+/
query_match = (query_text + ' ').match(/^(#{exact_term_double_quoted_regexp}|#{specific_column_double_quoted_column_name_regexp}|#{specific_column_double_quoted_column_value_regexp}|#{specific_column_double_quoted_column_name_and_value_regexp}|#{single_word_regexp})\s+/)
if query_match && query_match[1]
query_word = query_match[1]
query_text = query_text.sub(query_word, '').strip
query_word = query_word.sub(/^"/, '').sub(/"$/, '') if query_word.start_with?('"') && query_word.end_with?('"') && !query_word.include?(':')
@query_words << query_word
end
end
end
@query_words.all? do |word|
if word.include?(':')
column_name, column_value = word.split(':')
column_value = column_value.to_s
column_name = column_name.sub(/^"/, '').sub(/"$/, '') if column_name.start_with?('"') && column_name.end_with?('"')
column_value = column_value.sub(/^"/, '').sub(/"$/, '') if column_value.start_with?('"') && column_value.end_with?('"')
column_human_name = row_hash.keys.find do |table_column_name|
table_column_name.underscore.start_with?(column_name.underscore)
end
if column_human_name
row_hash[column_human_name].downcase.include?(column_value.downcase)
else
text.downcase.include?(word.downcase)
end
else
text.downcase.include?(word.downcase)
end
end
end
option :model_array, default: []
option :table_columns, default: []
option :table_editable, default: false
option :per_page, default: 10
option :page, default: 1
option :pagination, default: true
option :visible_page_count, default: false
option :filter_query, default: ''
option :filter, default: FILTER_DEFAULT
attr_accessor :filtered_model_array # filtered model array (intermediary, non-paginated)
attr_accessor :refined_model_array # paginated filtered model array
attr_reader :table_proxy
before_body do
self.per_page = 1_000_000_000 if !pagination
init_model_array
end
after_body do
filter_model_array
observe(self, :model_array) do
init_model_array
end
observe(self, :filter_query) do
filter_model_array
end
end
body {
vertical_box {
table_filter
table_paginator if page_count > 1
@table_proxy = table {
table_columns.each do |column_name, column_details|
editable_value = on_clicked_value = nil
if column_details.is_a?(Symbol) || column_details.is_a?(String)
column_type = column_details
elsif column_details.is_a?(Hash)
column_type = column_details.keys.first
editable_value = column_details.values.first[:editable] || column_details.values.first['editable']
on_clicked_value = column_details.values.first[:on_clicked] || column_details.values.first['on_clicked']
end
send("#{column_type}_column", column_name) {
editable editable_value unless editable_value.nil?
on_clicked(&on_clicked_value) unless on_clicked_value.nil?
}
end
editable table_editable
sortable false # TODO disabled for now until we support it correctly in the future
cell_rows <=> [self, :refined_model_array]
}
}
}
def table_filter
search_entry {
stretchy false
text <=> [self, :filter_query]
}
end
def table_paginator
horizontal_box {
stretchy false
button('<<') {
enabled <= [self, :page, on_read: ->(val) {val > 1}]
on_clicked do
unless self.page == 0
self.page = 1
paginate_model_array
end
end
}
button('<') {
enabled <= [self, :page, on_read: ->(val) {val > 1}]
on_clicked do
unless self.page == 0
self.page = [page - 1, 1].max
paginate_model_array
end
end
}
entry {
text <=> [self, :page,
on_read: :to_s,
on_write: ->(val) { correct_page(val.to_i) },
after_write: ->(val) { paginate_model_array },
]
}
if visible_page_count
label {
text <= [self, :refined_model_array, on_read: ->(val) {"of #{page_count} pages"}]
}
end
button('>') {
enabled <= [self, :page, on_read: ->(val) {val < page_count}]
on_clicked do
unless self.page == 0
self.page = [page + 1, page_count].min
paginate_model_array
end
end
}
button('>>') {
enabled <= [self, :page, on_read: ->(val) {val < page_count}]
on_clicked do
unless self.page == 0
self.page = page_count
paginate_model_array
end
end
}
}
end
def init_model_array
@last_filter_query = nil
@filter_query_page_stack = {}
@filtered_model_array = model_array.dup
@filtered_model_array_stack = {'' => @filtered_model_array}
self.page = correct_page(page)
filter_model_array if @table_proxy
end
def filter_model_array
return unless (@last_filter_query.nil? || filter_query != @last_filter_query)
if !@filtered_model_array_stack.key?(filter_query)
table_column_names = @table_proxy.columns.map(&:name)
@filtered_model_array_stack[filter_query] = model_array.dup.filter do |model|
row_values = @table_proxy.expand_cell_row(model).map(&:to_s)
row_hash = Hash[table_column_names.zip(row_values)]
filter.call(row_hash, filter_query)
end
end
@filtered_model_array = @filtered_model_array_stack[filter_query]
if @last_filter_query.nil? || filter_query.size > @last_filter_query.size
@filter_query_page_stack[filter_query] = correct_page(page)
end
self.page = @filter_query_page_stack[filter_query] || correct_page(page)
paginate_model_array
@last_filter_query = filter_query
end
def paginate_model_array
self.refined_model_array = filtered_model_array[index, limit]
end
def index
[per_page * (page - 1), 0].max
end
def limit
[(filtered_model_array.count - index), per_page].min
end
def page_count
(filtered_model_array.count.to_f / per_page.to_f).ceil
end
def correct_page(page)
[[page, 1].max, page_count].min
end
# Ensure proxying properties to @table_proxy if body_root (vertical_box) doesn't support them
def respond_to?(method_name, *args, &block)
super || @table_proxy&.respond_to?(method_name, *args, &block)
end
def method_missing(method_name, *args, &block)
if @table_proxy&.respond_to?(method_name, *args, &block)
@table_proxy&.send(method_name, *args, &block)
else
super
end
end
end
end
end
end