#!/usr/bin/env ruby
# ----------------------------------------------------------------------------- #
# File: tablewidget.rb
# Description: A tabular widget based on textpad
# Author: rkumar http://github.com/rkumar/rbcurse/
# Date: 2013-03-29 - 20:07
# License: Same as Ruby's License (http://www.ruby-lang.org/LICENSE.txt)
# Last update: 2013-04-23 16:33
# ----------------------------------------------------------------------------- #
# tablewidget.rb Copyright (C) 2012-2013 rahul kumar
require 'logger'
require 'rbcurse'
require 'rbcurse/core/widgets/textpad'
##
# The motivation to create yet another table widget is because tabular_widget
# is based on textview etc which have a lot of complex processing and rendering
# whereas textpad is quite simple. It is easy to just add one's own renderer
# making the code base simpler to understand and maintain.
# TODO
# _ compare to tabular_widget and see what's missing
# _ filtering rows without losing data
# . selection stuff
# x test with resultset from sqlite to see if we can use Array or need to make model
# should we use a datamodel so resultsets can be sent in, what about tabular
# _ header to handle events ?
#
#
module RubyCurses
# column data, one instance for each column
# index is the index in the data of this column. This index will not change.
# Order of printing columns is determined by the ordering of the objects.
class ColumnInfo < Struct.new(:name, :index, :offset, :width, :align, :hidden, :attrib, :color, :bgcolor)
end
# a structure that maintains position and gives
# next and previous taking max index into account.
# it also circles. Can be used for traversing next component
# in a form, or container, or columns in a table.
class Circular < Struct.new(:max_index, :current_index)
attr_reader :last_index
attr_reader :current_index
def initialize m, c=0
raise "max index cannot be nil" unless m
@max_index = m
@current_index = c
@last_index = c
end
def next
@last_index = @current_index
if @current_index + 1 > @max_index
@current_index = 0
else
@current_index += 1
end
end
def previous
@last_index = @current_index
if @current_index - 1 < 0
@current_index = @max_index
else
@current_index -= 1
end
end
def is_last?
@current_index == @max_index
end
end
# This is our default table row sorter.
# It does a multiple sort and allows for reverse sort also.
# It's a pretty simple sorter and uses sort, not sort_by.
# Improvements welcome.
# Usage: provide model in constructor or using model method
# Call toggle_sort_order(column_index)
# Call sort.
# Currently, this sorts the provided model in-place. Future versions
# may maintain a copy, or use a table that provides a mapping of model to result.
# # TODO check if column_sortable
class DefaultTableRowSorter
attr_reader :sort_keys
# model is array of data
def initialize data_model=nil
self.model = data_model
@columns_sort = []
@sort_keys = nil
end
def model=(model)
@model = model
@sort_keys = nil
end
def sortable colindex, tf
@columns_sort[colindex] = tf
end
def sortable? colindex
return false if @columns_sort[colindex]==false
return true
end
# should to_s be used for this column
def use_to_s colindex
return true # TODO
end
# sorts the model based on sort keys and reverse flags
# @sort_keys contains indices to sort on
# @reverse_flags is an array of booleans, true for reverse, nil or false for ascending
def sort
return unless @model
return if @sort_keys.empty?
$log.debug "TABULAR SORT KEYS #{sort_keys} "
# first row is the header which should remain in place
# We could have kept column headers separate, but then too much of mucking around
# with textpad, this way we avoid touching it
header = @model.delete_at 0
begin
# next line often can give error "array within array" - i think on date fields that
# contain nils
@model.sort!{|x,y|
res = 0
@sort_keys.each { |ee|
e = ee.abs-1 # since we had offsetted by 1 earlier
abse = e.abs
if ee < 0
xx = x[abse]
yy = y[abse]
# the following checks are since nil values cause an error to be raised
if xx.nil? && yy.nil?
res = 0
elsif xx.nil?
res = 1
elsif yy.nil?
res = -1
else
res = y[abse] <=> x[abse]
end
else
xx = x[e]
yy = y[e]
# the following checks are since nil values cause an error to be raised
# whereas we want a nil to be wither treated as a zero or a blank
if xx.nil? && yy.nil?
res = 0
elsif xx.nil?
res = -1
elsif yy.nil?
res = 1
else
res = x[e] <=> y[e]
end
end
break if res != 0
}
res
}
ensure
@model.insert 0, header if header
end
end
# toggle the sort order if given column offset is primary sort key
# Otherwise, insert as primary sort key, ascending.
def toggle_sort_order index
index += 1 # increase by 1, since 0 won't multiple by -1
# internally, reverse sort is maintained by multiplying number by -1
@sort_keys ||= []
if @sort_keys.first && index == @sort_keys.first.abs
@sort_keys[0] *= -1
else
@sort_keys.delete index # in case its already there
@sort_keys.delete(index*-1) # in case its already there
@sort_keys.unshift index
# don't let it go on increasing
if @sort_keys.size > 3
@sort_keys.pop
end
end
end
def set_sort_keys list
@sort_keys = list
end
end #class
#
# TODO see how jtable does the renderers and columns stuff.
#
# perhaps we can combine the two but have different methods or some flag
# that way oter methods can be shared
class DefaultTableRenderer
# source is the textpad or extending widget needed so we can call show_colored_chunks
# if the user specifies column wise colors
def initialize source
@source = source
@y = '|'
@x = '+'
@coffsets = []
@header_color = :red
@header_bgcolor = :white
@header_attrib = NORMAL
@color = :white
@bgcolor = :black
@color_pair = $datacolor
@attrib = NORMAL
@_check_coloring = nil
end
def header_colors fg, bg
@header_color = fg
@header_bgcolor = bg
end
def header_attrib att
@header_attrib = att
end
# set fg and bg color of content rows, default is $datacolor (white on black).
def content_colors fg, bg
@color = fg
@bgcolor = bg
@color_pair = get_color($datacolor, fg, bg)
end
def content_attrib att
@attrib = att
end
def column_model c
@chash = c
end
##
# Takes the array of row data and formats it using column widths
# and returns a string which is used for printing
#
# TODO return an array so caller can color columns if need be
def convert_value_to_text r
str = []
fmt = nil
field = nil
# we need to loop through chash and get index from it and get that row from r
#r.each_with_index { |e, i|
#c = @chash[i]
#@chash.each_with_index { |c, i|
#next if c.hidden
each_column {|c,i|
e = r[c.index]
w = c.width
l = e.to_s.length
# if value is longer than width, then truncate it
if l > w
fmt = "%.#{w}s "
else
case c.align
when :right
fmt = "%#{w}s "
else
fmt = "%-#{w}s "
end
end
field = fmt % e
# if we really want to print a single column with color, we need to print here itself
# each cell. If we want the user to use tmux formatting in the column itself ...
# FIXME - this must not be done for headers.
#if c.color
#field = "#[fg=#{c.color}]#{field}#[/end]"
#end
str << field
}
return str
end
#
# @param pad for calling print methods on
# @param lineno the line number on the pad to print on
# @param text data to print
def render pad, lineno, str
#lineno += 1 # header_adjustment
return render_header pad, lineno, 0, str if lineno == 0
#text = str.join " | "
#text = @fmstr % str
text = convert_value_to_text str
if @_check_coloring
$log.debug "XXX: INSIDE COLORIIN"
text = colorize pad, lineno, text
return
end
# check if any specific colors , if so then print colors in a loop with no dependence on colored chunks
# then we don't need source pointer
text = text.join
$log.debug "XXX: NOTINSIDE COLORIIN"
#if text.index "#["
#require 'rbcurse/core/include/chunk'
#@parser ||= Chunks::ColorParser.new :tmux
#text = @parser.convert_to_chunk text
#FFI::NCurses.wmove pad, lineno, 0
#@source.show_colored_chunks text, nil, nil
#return
#end
# FIXME why repeatedly getting this colorpair
cp = @color_pair
att = @attrib
FFI::NCurses.wattron(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
FFI::NCurses.mvwaddstr(pad, lineno, 0, text)
FFI::NCurses.wattroff(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
end
def render_header pad, lineno, col, columns
# I could do it once only but if user sets colors midway we can check once whenvever
# repainting
check_colors #if @_check_coloring.nil?
#text = columns.join " | "
#text = @fmstr % columns
text = convert_value_to_text columns
text = text.join
bg = @header_bgcolor
fg = @header_color
att = @header_attrib
#cp = $datacolor
cp = get_color($datacolor, fg, bg)
FFI::NCurses.wattron(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
FFI::NCurses.mvwaddstr(pad, lineno, col, text)
FFI::NCurses.wattroff(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
end
# check if we need to individually color columns or we can do the entire
# row in one shot
def check_colors
each_column {|c,i|
#@chash.each_with_index { |c, i|
#next if c.hidden
if c.color || c.bgcolor || c.attrib
@_check_coloring = true
return
end
@_check_coloring = false
}
end
def each_column
@chash.each_with_index { |c, i|
next if c.hidden
yield c,i if block_given?
}
end
def colorize pad, lineno, r
# the incoming data is already in the order of display based on chash,
# so we cannot run chash on it again, so how do we get the color info
_offset = 0
# we need to get coffsets here FIXME
#@chash.each_with_index { |c, i|
#next if c.hidden
each_column {|c,i|
text = r[i]
color = c.color
bg = c.bgcolor
if color || bg
cp = get_color(@color_pair, color || @color, bg || @bgcolor)
else
cp = @color_pair
end
att = c.attrib || @attrib
FFI::NCurses.wattron(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
FFI::NCurses.mvwaddstr(pad, lineno, _offset, text)
FFI::NCurses.wattroff(pad,FFI::NCurses.COLOR_PAIR(cp) | att)
_offset += text.length
}
end
end
# If we make a pad of the whole thing then the columns will also go out when scrolling
# So then there's no point storing columns separately. Might as well keep in content
# so scrolling works fine, otherwise textpad will have issues scrolling.
# Making a pad of the content but not column header complicates stuff,
# do we make a pad of that, or print it like the old thing.
class TableWidget < TextPad
dsl_accessor :print_footer
#attr_reader :columns
attr_accessor :table_row_sorter
def initialize form = nil, config={}, &block
# hash of column info objects, for some reason a hash and not an array
@chash = []
# chash should be an array which is basically the order of rows to be printed
# it contains index, which is the offset of the row in the data @content
# When printing we should loop through chash and get the index in data
#
# should be zero here, but then we won't get textpad correct
@_header_adjustment = 0 #1
@col_min_width = 3
super
bind_key(?w, "next column") { self.next_column }
bind_key(?b, "prev column") { self.prev_column }
bind_key(?-, "contract column") { self.contract_column }
bind_key(?+, "expand column") { self.expand_column }
bind_key(?=, "expand column to width") { self.expand_column_to_width }
bind_key(?\M-=, "expand column to width") { self.expand_column_to_max_width }
end
# retrieve the column info structure for the given offset. The offset
# pertains to the visible offset not actual offset in data model.
# These two differ when we move a column.
# @return ColumnInfo object containing width align color bgcolor attrib hidden
def get_column index
return @chash[index] if @chash[index]
# create a new entry since none present
c = ColumnInfo.new
c.index = index
@chash[index] = c
return c
end
##
# returns collection of ColumnInfo objects
def column_model
@chash
end
# calculate pad width based on widths of columns
def content_cols
total = 0
#@chash.each_pair { |i, c|
#@chash.each_with_index { |c, i|
#next if c.hidden
each_column {|c,i|
w = c.width
# if you use prepare_format then use w+2 due to separator symbol
total += w + 1
}
return total
end
#
# This calculates and stores the offset at which each column starts.
# Used when going to next column or doing a find for a string in the table.
# TODO store this inside the hash so it's not calculated again in renderer
#
def _calculate_column_offsets
@coffsets = []
total = 0
#@chash.each_pair { |i, c|
#@chash.each_with_index { |c, i|
#next if c.hidden
each_column {|c,i|
w = c.width
@coffsets[i] = total
c.offset = total
# if you use prepare_format then use w+2 due to separator symbol
total += w + 1
}
end
# Convert current cursor position to a table column
# calculate column based on curpos since user may not have
# user w and b keys (:next_column)
# @return [Fixnum] column index base 0
def _convert_curpos_to_column #:nodoc:
_calculate_column_offsets unless @coffsets
x = 0
@coffsets.each_with_index { |i, ix|
if @curpos < i
break
else
x += 1
end
}
x -= 1 # since we start offsets with 0, so first auto becoming 1
return x
end
# jump cursor to next column
# TODO : if cursor goes out of view, then pad should scroll right or left and down
def next_column
# TODO take care of multipliers
_calculate_column_offsets unless @coffsets
c = @column_pointer.next
cp = @coffsets[c]
#$log.debug " next_column #{c} , #{cp} "
@curpos = cp if cp
down() if c < @column_pointer.last_index
end
# jump cursor to previous column
# TODO : if cursor goes out of view, then pad should scroll right or left and down
def prev_column
# TODO take care of multipliers
_calculate_column_offsets unless @coffsets
c = @column_pointer.previous
cp = @coffsets[c]
#$log.debug " prev #{c} , #{cp} "
@curpos = cp if cp
up() if c > @column_pointer.last_index
end
def expand_column
x = _convert_curpos_to_column
w = get_column(x).width
column_width x, w+1 if w
@coffsets = nil
fire_dimension_changed
end
def expand_column_to_width w=nil
x = _convert_curpos_to_column
unless w
# expand to width of current cell
s = @content[@current_index][x]
w = s.to_s.length + 1
end
column_width x, w
@coffsets = nil
fire_dimension_changed
end
# find the width of the longest item in the current columns and expand the width
# to that.
def expand_column_to_max_width
x = _convert_curpos_to_column
w = calculate_column_width x
expand_column_to_width w
end
def contract_column
x = _convert_curpos_to_column
w = get_column(x).width
return if w <= @col_min_width
column_width x, w-1 if w
@coffsets = nil
fire_dimension_changed
end
#def method_missing(name, *args)
#@tp.send(name, *args)
#end
#
# supply a custom renderer that implements +render()+
# @see render
def renderer r
@renderer = r
end
##
# Set column titles with given array of strings.
# NOTE: This is only required to be called if first row of file or content does not contain
# titles. In that case, this should be called before setting the data as the array passed
# is appended into the content array.
#
def columns=(array)
@_header_adjustment = 1
# I am eschewing using a separate field for columns. This is simpler for textpad.
# We always assume first row is columns.
#@columns = array
# should we just clear column, otherwise there's no way to set the whole thing with new data
# but then if we need to change columns what do it do, on moving or hiding a column ?
# Maybe we need a separate clear method or remove_all TODO
@content ||= []
@content << array
# This needs to go elsewhere since this method will not be called if file contains
# column titles as first row.
_init_model array
end
alias :headings= :columns=
# returns array of column names as Strings
def columns
@content[0]
end
# size each column based on widths of this row of data.
# Only changed width if no width for that column
def _init_model array
array.each_with_index { |c,i|
# if columns added later we could be overwriting the width
c = get_column(i)
c.width ||= 10
}
# maintains index in current pointer and gives next or prev
@column_pointer = Circular.new array.size()-1
end
def model_row index
array = @content[index]
array.each_with_index { |c,i|
# if columns added later we could be overwriting the width
ch = get_column(i)
ch.width = c.to_s.length + 2
}
# maintains index in current pointer and gives next or prev
@column_pointer = Circular.new array.size()-1
end
##
# insert entire database in one shot
# WARNING: overwrites columns if put there, should contain columns already as in CSV data
# @param lines is an array or arrays
def text lines, fmt=:none
_init_model lines[0]
fire_dimension_changed
super
end
##
# set column array and data array in one shot
# Erases any existing content
def resultset columns, data
@content = []
_init_model columns
@content << columns
@_header_adjustment = 1
@content.concat( data)
fire_dimension_changed
end
## add a row to the table
def add array
unless @content
# columns were not added, this most likely is the title
@content ||= []
_init_model array
end
@content << array
fire_dimension_changed
self
end
def delete_at ix
return unless @content
fire_dimension_changed
@content.delete_at ix
end
alias :<< :add
# convenience method to set width of a column
# @param index of column
# @param width
# For setting other attributes, use get_column(index)
def column_width colindex, width
get_column(colindex).width = width
_invalidate_width_cache
end
# convenience method to set alignment of a column
# @param index of column
# @param align - :right (any other value is taken to be left)
def column_align colindex, align
get_column(colindex).align = align
end
# convenience method to hide or unhide a column
# Provided since column offsets need to be recalculated in the case of a width
# change or visibility change
def column_hidden colindex, hidden
get_column(colindex).hidden = hidden
_invalidate_width_cache
end
# http://www.opensource.apple.com/source/gcc/gcc-5483/libjava/javax/swing/table/DefaultTableColumnModel.java
def _invalidate_width_cache #:nodoc:
@coffsets = nil
end
##
# should all this move into table column model or somepn
# move a column from offset ix to offset newix
def move_column ix, newix
acol = @chash.delete_at ix
@chash.insert newix, acol
_invalidate_width_cache
#tmce = TableColumnModelEvent.new(ix, newix, self, :MOVE)
#fire_handler :TABLE_COLUMN_MODEL_EVENT, tmce
end
def add_column tc
raise "to figure out add_column"
_invalidate_width_cache
end
def remove_column tc
raise "to figure out add_column"
_invalidate_width_cache
end
def calculate_column_width col, maxrows=99
ret = 3
ctr = 0
@content.each_with_index { |r, i|
#next if i < @toprow # this is also a possibility, it checks visible rows
break if ctr > maxrows
ctr += 1
#next if r == :separator
c = r[col]
x = c.to_s.length
ret = x if x > ret
}
ret
end
##
# refresh pad onto window
# overrides super
def padrefresh
top = @window.top
left = @window.left
sr = @startrow + top
sc = @startcol + left
# first do header always in first row
retval = FFI::NCurses.prefresh(@pad,0,@pcol, sr , sc , 2 , @cols+ sc );
# now print rest of data
# h is header_adjustment
h = 1
retval = FFI::NCurses.prefresh(@pad,@prow + h,@pcol, sr + h , sc , @rows + sr , @cols+ sc );
$log.warn "XXX: PADREFRESH #{retval}, #{@prow}, #{@pcol}, #{sr}, #{sc}, #{@rows+sr}, #{@cols+sc}." if retval == -1
# padrefresh can fail if width is greater than NCurses.COLS
end
def create_default_sorter
raise "Data not sent in." unless @content
@table_row_sorter = DefaultTableRowSorter.new @content
end
def header_row?
@prow == 0
end
def fire_action_event
if header_row?
if @table_row_sorter
x = _convert_curpos_to_column
c = @chash[x]
# convert to index in data model since sorter only has data_model
index = c.index
@table_row_sorter.toggle_sort_order index
@table_row_sorter.sort
fire_dimension_changed
end
end
super
end
##
# Find the next row that contains given string
# Overrides textpad since each line is an array
# NOTE does not go to next match within row
# NOTE: FIXME ensure_visible puts prow = current_index so in this case, the header
# overwrites the matched row.
# @return row and col offset of match, or nil
# @param String to find
def next_match str
_calculate_column_offsets unless @coffsets
first = nil
## content can be string or Chunkline, so we had to write index for this.
@content.each_with_index do |fields, ix|
#col = line.index str
#fields.each_with_index do |f, jx|
#@chash.each_with_index do |c, jx|
#next if c.hidden
each_column do |c,jx|
f = fields[c.index]
# value can be numeric
col = f.to_s.index str
if col
col += @coffsets[jx]
first ||= [ ix, col ]
if ix > @current_index
return [ix, col]
end
end
end
end
return first
end
# yields each column to caller method
# for true returned, collects index of row into array and returns the array
# @returns array of indices which can be empty
# Value yielded can be fixnum or date etc
def matching_indices
raise "block required for matching_indices" unless block_given?
@indices = []
## content can be string or Chunkline, so we had to write index for this.
@content.each_with_index do |fields, ix|
flag = yield ix, fields
if flag
@indices << ix
end
end
$log.debug "XXX: INDICES found #{@indices}"
if @indices.count > 0
fire_dimension_changed
init_vars
else
@indices = nil
end
#return @indices
end
def clear_matches
# clear previous match so all data can show again
if @indices && @indices.count > 0
fire_dimension_changed
init_vars
end
@indices = nil
end
##
# Ensure current row is visible, if not make it first row
# This overrides textpad due to header_adjustment, otherwise
# during next_match, the header overrides the found row.
# @param current_index (default if not given)
#
def ensure_visible row = @current_index
unless is_visible? row
@prow = @current_index - @_header_adjustment
end
end
#
# yields non-hidden columns (ColumnInfo) and the offset/index
# This is the order in which columns are to be printed
def each_column
@chash.each_with_index { |c, i|
next if c.hidden
yield c,i if block_given?
}
end
def render_all
if @indices && @indices.count > 0
@indices.each_with_index do |ix, jx|
render @pad, jx, @content[ix]
end
else
@content.each_with_index { |line, ix|
#FFI::NCurses.mvwaddstr(@pad,ix, 0, @content[ix])
render @pad, ix, line
}
end
end
end # class TableWidget
##
# Handles selection of items in a list or table or tree that uses stable indices.
# Indexes are in the order they were places, not sorted.
# This is just a wrapper over an array, except that it fires an event so users can bind
# to row selection and deselection
# TODO - fire events to listeners
#
class ListSelectionModel
##
# obj is the source object, I am wondering whether i need it or not
def initialize component
@obj = component
@selected_indices = []
end
def toggle_row_selection crow
if is_row_selected? crow
unselect crow
else
select crow
end
end
def select ix
@selected_indices << ix
_fire_event ix, ix, :INSERT
end
def unselect ix
@selected_indices.delete ix
_fire_event ix, ix, :DELETE
end
alias :add_to_selection :select
alias :remove_from_selection :unselect
def clear_selection
@selected_indices = []
_fire_event 0, 0, :CLEAR
end
def is_row_selected? crow
@selected_indices.include? crow
end
def is_selection_empty?
return @selected_indices.empty?
end
# if row deleted in list, then synch with list
# (No listeners are informed)
def remove_index crow
@selected_indices.delete crow
end
def _fire_event firsti, lasti, event
lse = ListSelectionEvent.new(firsti, lasti, self, event)
fire_handler :LIST_SELECTION_EVENT, lse
end
def select_all
# how do we do this since we don't know what the indices are.
# What is the user using as identifier?
end
# returns a list of selected indices in the same order as added
def selected_rows
@selected_indices
end
end # class
class ListSelectionEvent < Struct.new(:firstrow, :lastrow, :source, :type)
end
end # module