lib/hirb/helpers/table.rb in hirb-0.2.9 vs lib/hirb/helpers/table.rb in hirb-0.2.10
- old
+ new
@@ -1,5 +1,8 @@
+require 'hirb/helpers/table/filters'
+require 'hirb/helpers/table/resizer'
+
module Hirb
# Base Table class from which other table classes inherit.
# By default, a table is constrained to a default width but this can be adjusted
# via the max_width option or Hirb::View.width.
# Rows can be an array of arrays or an array of hashes.
@@ -54,65 +57,82 @@
# For a thorough example, see {Boson::Pipe}[http://github.com/cldwalker/boson/blob/master/lib/boson/pipe.rb].
#--
# derived from http://gist.github.com/72234
class Helpers::Table
BORDER_LENGTH = 3 # " | " and "-+-" are the borders
+ MIN_FIELD_LENGTH = 3
class TooManyFieldsForWidthError < StandardError; end
class << self
# Main method which returns a formatted table.
# ==== Options:
- # [:fields] An array which overrides the default fields and can be used to indicate field order.
- # [:headers] A hash of fields and their header names. Fields that aren't specified here default to their name.
- # This option can also be an array but only for array rows.
- # [:field_lengths] A hash of fields and their maximum allowed lengths. If a field exceeds it's maximum
- # length than it's truncated and has a ... appended to it. Fields that aren't specified here have no maximum allowed
- # length.
- # [:max_width] The maximum allowed width of all fields put together. This option is enforced except when the field_lengths option is set.
- # This doesn't count field borders as part of the total.
- # [:number] When set to true, numbers rows by adding a :hirb_number column as the first column. Default is false.
- # [:change_fields] A hash to change old field names to new field names. This can also be an array of new names but only for array rows.
- # This is useful when wanting to change auto-generated keys to more user-friendly names i.e. for array rows.
- # [:filters] A hash of fields and the filters that each row in the field must run through. The filter converts the cell's value by applying
- # a given proc or an array containing a method and optional arguments to it.
- # [:vertical] When set to true, renders a vertical table using Hirb::Helpers::VerticalTable. Default is false.
- # [:all_fields] When set to true, renders fields in all rows. Valid only in rows that are hashes. Default is false.
- # [:description] When set to true, renders row count description at bottom. Default is true.
- # [:no_newlines] When set to true, stringifies newlines so they don't disrupt tables. Default is false for vertical tables
- # and true for anything else.
+ # [*:fields*] An array which overrides the default fields and can be used to indicate field order.
+ # [*:headers*] A hash of fields and their header names. Fields that aren't specified here default to their name.
+ # This option can also be an array but only for array rows.
+ # [*:max_fields*] A hash of fields and their maximum allowed lengths. Maximum length can also be a percentage of the total width
+ # (decimal less than one). When a field exceeds it's maximum then it's
+ # truncated and has a ... appended to it. Fields that aren't specified have no maximum.
+ # [*:max_width*] The maximum allowed width of all fields put together including field borders. Only valid when :resize is true.
+ # Default is Hirb::View.width.
+ # [*:resize*] Resizes table to display all columns in allowed :max_width. Default is true. Setting this false will display the full
+ # length of each field.
+ # [*:number*] When set to true, numbers rows by adding a :hirb_number column as the first column. Default is false.
+ # [*:change_fields*] A hash to change old field names to new field names. This can also be an array of new names but only for array rows.
+ # This is useful when wanting to change auto-generated keys to more user-friendly names i.e. for array rows.
+ # [*:filters*] A hash of fields and their filters, applied to every row in a field. A filter can be a proc, an instance method
+ # applied to the field value or a Filters method. Also see the filter_classes attribute below.
+ # [*:header_filter*] A filter, like one in :filters, that is applied to all headers after the :headers option.
+ # [*:filter_any*] When set to true, any cell defaults to being filtered by its class in :filter_classes.
+ # Default Hirb::Helpers::Table.filter_any().
+ # [*:filter_classes*] Hash which maps classes to filters. Default is Hirb::Helpers::Table.filter_classes().
+ # [*:vertical*] When set to true, renders a vertical table using Hirb::Helpers::VerticalTable. Default is false.
+ # [*:all_fields*] When set to true, renders fields in all rows. Valid only in rows that are hashes. Default is false.
+ # [*:description*] When set to true, renders row count description at bottom. Default is true.
+ # [*:escape_special_chars*] When set to true, escapes special characters \n,\t,\r so they don't disrupt tables. Default is false for
+ # vertical tables and true for anything else.
+ # [*:return_rows*] When set to true, returns rows that have been initialized but not rendered. Default is false.
# Examples:
# Hirb::Helpers::Table.render [[1,2], [2,3]]
- # Hirb::Helpers::Table.render [[1,2], [2,3]], :field_lengths=>{0=>10}
- # Hirb::Helpers::Table.render [['a',1], ['b',2]], :change_fields=>%w{letters numbers}
+ # Hirb::Helpers::Table.render [[1,2], [2,3]], :max_fields=>{0=>10}, :header_filter=>:capitalize
+ # Hirb::Helpers::Table.render [['a',1], ['b',2]], :change_fields=>%w{letters numbers}, :max_fields=>{'numbers'=>0.4}
# Hirb::Helpers::Table.render [{:age=>10, :weight=>100}, {:age=>80, :weight=>500}]
# Hirb::Helpers::Table.render [{:age=>10, :weight=>100}, {:age=>80, :weight=>500}], :headers=>{:weight=>"Weight(lbs)"}
# Hirb::Helpers::Table.render [{:age=>10, :weight=>100}, {:age=>80, :weight=>500}], :filters=>{:age=>[:to_f]}
def render(rows, options={})
- options[:vertical] ? Helpers::VerticalTable.render(rows, options) : new(rows, options).render
+ options[:vertical] ? Helpers::VerticalTable.render(rows, options) :
+ options[:return_rows] ? new(rows, options).instance_variable_get("@rows") : new(rows, options).render
rescue TooManyFieldsForWidthError
$stderr.puts "", "** Error: Too many fields for the current width. Configure your width " +
"and/or fields to avoid this error. Defaulting to a vertical table. **"
Helpers::VerticalTable.render(rows, options)
end
+
+ # A hash which maps a cell value's class to a filter. This serves to set a default filter per field if all of its
+ # values are a class in this hash. By default, Array values are comma joined and Hashes are inspected.
+ # See the :filter_any option to apply this filter per value.
+ attr_accessor :filter_classes
+ # Boolean which sets the default for :filter_any option.
+ attr_accessor :filter_any
+ # Holds last table object created
+ attr_accessor :last_table
end
-
+ self.filter_classes = { Array=>:comma_join, Hash=>:inspect }
+
#:stopdoc:
+ attr_accessor :width, :max_fields, :field_lengths, :fields
def initialize(rows, options={})
- @options = {:description=>true, :filters=>{}, :change_fields=>{}, :no_newlines=>true}.merge(options)
- @options[:change_fields] = array_to_indices_hash(@options[:change_fields]) if @options[:change_fields].is_a?(Array)
+ @options = {:description=>true, :filters=>{}, :change_fields=>{}, :escape_special_chars=>true,
+ :filter_any=>Helpers::Table.filter_any, :resize=>true}.merge(options)
@fields = set_fields(rows)
- @rows = setup_rows(rows)
- @headers = @fields.inject({}) {|h,e| h[e] = e.to_s; h}
- if @options.has_key?(:headers)
- @headers = @options[:headers].is_a?(Hash) ? @headers.merge(@options[:headers]) :
- (@options[:headers].is_a?(Array) ? array_to_indices_hash(@options[:headers]) : @options[:headers])
- end
+ @rows = set_rows(rows)
+ @headers = set_headers
if @options[:number]
@headers[:hirb_number] = "number"
@fields.unshift :hirb_number
end
+ Helpers::Table.last_table = self
end
def set_fields(rows)
fields = if @options[:fields]
@options[:fields].dup
@@ -122,17 +142,18 @@
keys.sort {|a,b| a.to_s <=> b.to_s}
else
rows[0].is_a?(Array) ? (0..rows[0].length - 1).to_a : []
end
end
+ @options[:change_fields] = array_to_indices_hash(@options[:change_fields]) if @options[:change_fields].is_a?(Array)
@options[:change_fields].each do |oldf, newf|
(index = fields.index(oldf)) ? fields[index] = newf : fields << newf
end
fields
end
- def setup_rows(rows)
+ def set_rows(rows)
rows = Array(rows)
if rows[0].is_a?(Array)
rows = rows.inject([]) {|new_rows, row|
new_rows << array_to_indices_hash(row)
}
@@ -140,17 +161,32 @@
@options[:change_fields].each do |oldf, newf|
rows.each {|e| e[newf] = e.delete(oldf) if e.key?(oldf) }
end
rows = filter_values(rows)
rows.each_with_index {|e,i| e[:hirb_number] = (i + 1).to_s} if @options[:number]
- methods.grep(/_callback$/).sort.each do |meth|
+ deleted_callbacks = Array(@options[:delete_callbacks]).map {|e| "#{e}_callback" }
+ (methods.grep(/_callback$/) - deleted_callbacks).sort.each do |meth|
rows = send(meth, rows, @options.dup)
end
validate_values(rows)
rows
end
-
+
+ def set_headers
+ headers = @fields.inject({}) {|h,e| h[e] = e.to_s; h}
+ if @options.has_key?(:headers)
+ headers = @options[:headers].is_a?(Hash) ? headers.merge(@options[:headers]) :
+ (@options[:headers].is_a?(Array) ? array_to_indices_hash(@options[:headers]) : @options[:headers])
+ end
+ if @options[:header_filter]
+ headers.each {|k,v|
+ headers[k] = call_filter(@options[:header_filter], v)
+ }
+ end
+ headers
+ end
+
def render
body = []
unless @rows.length == 0
setup_field_lengths
body += render_header
@@ -201,63 +237,36 @@
"#{@rows.length} #{@rows.length == 1 ? 'row' : 'rows'} in set"
end
def setup_field_lengths
@field_lengths = default_field_lengths
- if @options[:field_lengths]
- @field_lengths.merge!(@options[:field_lengths])
+ if @options[:resize]
+ raise TooManyFieldsForWidthError if @fields.size > self.actual_width.to_f / MIN_FIELD_LENGTH
+ Resizer.resize!(self)
else
- table_max_width = @options.has_key?(:max_width) ? @options[:max_width] : View.width
- restrict_field_lengths(@field_lengths, table_max_width) if table_max_width
+ enforce_field_constraints
end
end
-
- def restrict_field_lengths(field_lengths, max_width)
- max_width -= @fields.size * BORDER_LENGTH + 1
- original_field_lengths = field_lengths.dup
- @min_field_length = BORDER_LENGTH
- adjust_long_fields(field_lengths, max_width)
- rescue TooManyFieldsForWidthError
- raise
- rescue
- default_restrict_field_lengths(field_lengths, original_field_lengths, max_width)
+
+ def enforce_field_constraints
+ max_fields.each {|k,max| @field_lengths[k] = max if @field_lengths[k].to_i > max }
end
- # Simple algorithm which given a max width, allows smaller fields to be displayed while
- # restricting longer fields at an average_long_field_length.
- def adjust_long_fields(field_lengths, max_width)
- total_length = field_lengths.values.inject {|t,n| t += n}
- while total_length > max_width
- raise TooManyFieldsForWidthError if @fields.size > max_width.to_f / @min_field_length
- average_field_length = total_length / @fields.size.to_f
- long_lengths = field_lengths.values.select {|e| e > average_field_length}
- if long_lengths.empty?
- raise "Algorithm didn't work, resort to default"
- else
- total_long_field_length = (long_lengths.inject {|t,n| t += n}) * max_width/total_length
- average_long_field_length = total_long_field_length / long_lengths.size
- field_lengths.each {|f,length|
- field_lengths[f] = average_long_field_length if length > average_long_field_length
- }
- end
- total_length = field_lengths.values.inject {|t,n| t += n}
- end
+ def max_fields
+ @max_fields ||= (@options[:max_fields] ||= {}).each {|k,v|
+ @options[:max_fields][k] = (actual_width * v.to_f.abs).floor if v.to_f.abs < 1
+ }
end
- # Produces a field_lengths which meets the max_width requirement
- def default_restrict_field_lengths(field_lengths, original_field_lengths, max_width)
- original_total_length = original_field_lengths.values.inject {|t,n| t += n}
- relative_lengths = original_field_lengths.values.map {|v| (v / original_total_length.to_f * max_width).to_i }
- # set fields by their relative weight to original length
- if relative_lengths.all? {|e| e > @min_field_length} && (relative_lengths.inject {|a,e| a += e} <= max_width)
- original_field_lengths.each {|k,v| field_lengths[k] = (v / original_total_length.to_f * max_width).to_i }
- else
- # set all fields the same if nothing else works
- field_lengths.each {|k,v| field_lengths[k] = max_width / @fields.size}
- end
+ def actual_width
+ @actual_width ||= self.width - (@fields.size * BORDER_LENGTH + 1)
end
+ def width
+ @width ||= @options[:max_width] || View.width
+ end
+
# find max length for each field; start with the headers
def default_field_lengths
field_lengths = @headers ? @headers.inject({}) {|h,(k,v)| h[k] = String.size(v); h} : {}
@rows.each do |row|
@fields.each do |field|
@@ -267,35 +276,40 @@
end
field_lengths
end
def set_filter_defaults(rows)
- if @options[:_original_class] == Hash && @fields[1] && rows.any? {|e| e[@fields[1]].is_a?(Hash) }
- (@options[:filters] ||= {})[@fields[1]] ||= :inspect
+ @filter_classes.each do |klass, filter|
+ @fields.each {|field|
+ if rows.all? {|r| r[field].class == klass }
+ @options[:filters][field] ||= filter
+ end
+ }
end
end
def filter_values(rows)
- set_filter_defaults(rows)
+ @filter_classes = Helpers::Table.filter_classes.merge @options[:filter_classes] || {}
+ set_filter_defaults(rows) unless @options[:filter_any]
rows.map {|row|
- new_row = {}
- @fields.each {|f|
- if @options[:filters][f]
- new_row[f] = @options[:filters][f].is_a?(Proc) ? @options[:filters][f].call(row[f]) :
- row[f].send(*@options[:filters][f])
- else
- new_row[f] = row[f]
- end
+ @fields.inject({}) {|new_row,f|
+ (filter = @options[:filters][f]) || (@options[:filter_any] && (filter = @filter_classes[row[f].class]))
+ new_row[f] = filter ? call_filter(filter, row[f]) : row[f]
+ new_row
}
- new_row
}
end
+ def call_filter(filter, val)
+ filter.is_a?(Proc) ? filter.call(val) :
+ val.respond_to?(Array(filter)[0]) ? val.send(*filter) : Filters.send(filter, val)
+ end
+
def validate_values(rows)
rows.each {|row|
@fields.each {|f|
row[f] = row[f].to_s || ''
- row[f].gsub!("\n", '\n') if @options[:no_newlines]
+ row[f] = row[f].gsub(/(\t|\r|\n)/) {|e| e.dump.gsub('"','') } if @options[:escape_special_chars]
}
}
end
# Converts an array to a hash mapping a numerical index to its array value.