# Copyright (C) 2019 Thomas Baron # # This file is part of term_utils. # # term_utils is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3 of the License. # # term_utils is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with term_utils. If not, see . module TermUtils # The tab module provides a way to print formatted tables. module Tab # Represents a table error. class TableError < StandardError def initialize(msg) super end end # Creates initial column properties. # @return [Hash] `:offset`, `:column_separator_width`. def self.init_table_props {:offset => 0, :column_separator_width => 2} end # Creates initial column properties. # @return [Hash] `:width`, `:align`, `:fixed`, `:ellipsis`, `:format`. def self.init_column_props {:width => 8, :align => :left, :fixed => false, :ellipsis => "?", :format => nil} end # Assigns table properties. # @param target [Hash] # @param source [Hash] `:offset`, `:column_separator_width`. # @return [Hash] def self.assign_table_props(target, source) if (source.has_key? :offset) && (source[:offset].is_a? Integer) && (source[:offset] >= 0) target[:offset] = source[:offset] end if (source.has_key? :column_separator_width) && (source[:column_separator_width].is_a? Integer) && (source[:column_separator_width] > 0) target[:column_separator_width] = source[:column_separator_width] end target end # Assigns column properties. # @param target [Hash] # @param source [Hash] `:width`, `:align`, `:fixed`, `:ellipsis`, `:format`. # @return [Hash] def self.assign_column_props(target, source) if (source.has_key? :width) && (source[:width].is_a? Integer) && (source[:width] > 0) target[:width] = source[:width] end if (source.has_key? :align) && %i[left right].index(source[:align]) target[:align] = source[:align] end if (source.has_key? :fixed) && (!!source[:fixed] == source[:fixed]) target[:fixed] = source[:fixed] end if (source.has_key? :ellipsis) && (source[:ellipsis].is_a? String) target[:ellipsis] = source[:ellipsis] end if (source.has_key? :format) && ((source[:ellipsis] == nil) || (source[:ellipsis].is_a? Proc) || (source[:ellipsis].is_a? String)) target[:format] = source[:format] end target end # Aligns and cuts a given string. # @param src [String] # @param align [Symbol] `:left`, `:right`. # @param fixed [Boolean] Whether the column width is fixed. # @param width [Integer] The column width. # @param ellipsis [String] The ellipsis when not fixed. # @return [String] def self.align_cut(src, align, fixed, width, ellipsis) res = src if align == :left # Align left if fixed && (src.length > width) if ellipsis.length >= width res = ellipsis[0..(width - 1)] else res = "%s%s" % [src[0..(width - (ellipsis.length + 1))], ellipsis] end else res = "%-*s" % [width, src] end elsif align == :right # Align right if fixed && (src.length > width) if ellipsis.length >= width res = ellipsis[0..(width - 1)] else res = "%s%s" % [ellipsis, src[(src.length - width + ellipsis.length)..(src.length - 1)]] end else res = "%*s" % [width, src] end end res end # Represents a table. class Table # @return [Symbol] attr_accessor :id # @return [Integer] attr_accessor :offset # @return [Integer] attr_accessor :column_separator_width # @return [Hash] `:width`, `:align`, `:fixed`, `:ellipsis`, `:format`. attr_accessor :column_defaults # @return [Array] attr_accessor :columns # @param opts [Hash] # @option opts [Symbol] :id # @option opts [Integer] :offset # @option opts [Hash] :column_defaults def initialize(opts = {}) opts = TermUtils::Tab.init_table_props.merge(opts) @id = opts.fetch(:id, nil) @offset = opts.fetch(:offset) @column_separator_width = opts.fetch(:column_separator_width) if opts.has_key? :column_defaults @column_defaults = opts[:column_defaults].dup else @column_defaults = TermUtils::Tab.default_column_props end @columns = [] end # Returns the properties of this one. # @return [Hash] def props {:offset => @offset, :column_separator_width => @column_separator_width} end # Sets column default properties. # @param opts [Hash] # @option opts [Integer] :width # @option opts [Symbol] :align # @option opts [Boolean] :fixed # @option opts [String] :ellipsis # @option opts [Proc, String, nil] :format def set_column_defaults(opts = {}) TermUtils::Tab.assign_column_props(@column_defaults, opts) end # Defines a column. # @param id [Symbol] # @param opts [Hash] # @option opts [Integer] :width # @option opts [Symbol] :align # @option opts [Boolean] :fixed # @option opts [String] :ellipsis # @option opts [Proc, String, nil] :format # @return [Tab::Column] def define_column(id, opts = {}, &block) col = @columns.find { |c| c.id == id } if col block.call(col) if block col.validate else opts[:id] = id opts[:index] = @columns.length col = Column.new(@column_defaults.merge(opts)) block.call(col) if block col.validate @columns << col end col end # Finds a column. # @param id [Symbol] # @return [Tab::Column, nil] def find_column(id) @columns.find { |c| c.id == id } end # Creates a new table printer. # @param io [#puts] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [Tab::Printer] def printer(io, opts = {}, &block) ptr = Printer.new(self, io, props.merge(opts)) block.call(ptr) if block ptr end # Prints a header row. # @param io [#puts] # @param values [Array, Hash] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] # @raise [TermUtils::Tab::TableError] def print_header(io, values = nil, opts = {}) vals = values if values.nil? vals = @columns.map { |col| col.header.title } elsif values.is_a? Hash vals = [] @columns.each do |col| vals << values[col.id] end end raise TermUtils::Tab::TableError, "wrong values (not array)" unless vals.is_a? Array offset = opts.fetch(:offset) column_separator_width = opts.fetch(:column_separator_width) sb = StringIO.new sb << " " * offset if offset > 0 @columns.each do |col| sb << " " * column_separator_width if col.index > 0 sb << col.render_header(vals[col.index]) end io.puts sb.string end # Prints a data row. # @param io [#puts] # @param values [Array, Hash] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] # @raise [TermUtils::Tab::TableError] def print_data(io, values, opts = {}) vals = values if values.is_a? Hash vals = [] @columns.each do |col| vals << values[col.id] end end raise TermUtils::Tab::TableError, "wrong values (not array)" unless vals.is_a? Array offset = opts.fetch(:offset) column_separator_width = opts.fetch(:column_separator_width) sb = StringIO.new sb << " " * offset if offset > 0 @columns.each do |col| sb << " " * column_separator_width if col.index > 0 sb << col.render_data(vals[col.index]) end io.puts sb.string end # Prints a separator row. # @param io [#puts] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] def print_separator(io, opts = {}) offset = opts.fetch(:offset) column_separator_width = opts.fetch(:column_separator_width) sb = StringIO.new sb << " " * offset if offset > 0 @columns.each do |col| sb << " " * column_separator_width if col.index > 0 sb << "-" * col.width end io.puts sb.string end # Returns column titles. # @return [Hash] def titles h = {} @columns.each do |col| h[col.id] = col.id.to_s end h end end # Represents a table column. class Column # @return [Symbol] attr_accessor :id # @return [Integer] attr_accessor :index # @return [Integer] attr_accessor :width # @return [Symbol] `:left`, `:right`. attr_accessor :align # @return [Boolean] attr_accessor :fixed # @return [String] attr_accessor :ellipsis # @return [Proc, String, nil] attr_accessor :format # @return [TermUtils::Tab::Header] attr_accessor :header # @param opts [Hash] # @option opts [Symbol] :id # @option opts [Integer] :index # @option opts [Integer] :width # @option opts [Symbol] :align # @option opts [Boolean] :fixed # @option opts [String] :ellipsis # @option opts [Proc, String, nil] :format def initialize(opts = {}) @id = opts.fetch(:id) @index = opts.fetch(:index) @width = opts.fetch(:width, 8) @align = opts.fetch(:align, :left) @fixed = opts.fetch(:fixed, false) @ellipsis = opts.fetch(:ellipsis, "?") @format = opts.fetch(:format, nil) @header = TermUtils::Tab::Header.new(:title => @id.to_s, :align => @align) end # Validates the column represented by this one. # @return [nil] # @raise [TermUtils::Tab::TableError] def validate raise TermUtils::Tab::TableError, "missing column id (nil)" if @id.nil? raise TermUtils::Tab::TableError, "missing column index (nil)" if @index.nil? raise TermUtils::Tab::TableError, "wrong column index (not integer)" unless @index.is_a? Integer raise TermUtils::Tab::TableError, "wrong column index (not >= 0)" if @index < 0 raise TermUtils::Tab::TableError, "missing column width (nil)" if @width.nil? raise TermUtils::Tab::TableError, "wrong column width (not integer)" unless @width.is_a? Integer raise TermUtils::Tab::TableError, "wrong column width (not > 0)" if @width <= 0 raise TermUtils::Tab::TableError, "wrong column align (not :left or :right)" unless %i[left right].index(@align) @header.validate end # Renders a given header. # @param val [Object] # return [String] def render_header(val) src = (val.is_a? String) ? val : val.to_s TermUtils::Tab.align_cut(src, @header.align, @fixed, @width, @ellipsis) end # Renders a given value. # @param val [Object] # return [String] def render_data(val) src = val if val if @format.is_a? Proc src = @format.call(val) elsif @format.is_a? String src = @format % val end end src = (src.is_a? String) ? src : src.to_s TermUtils::Tab.align_cut(src, @align, @fixed, @width, @ellipsis) end end # Represents a column header. class Header # @return [String] attr_accessor :title # @return [Symbol] `:left`, `:right`. attr_accessor :align # Constructs a new Header. # @param opts [Hash] # @option opts [String] :title # @option opts [Symbol] :align def initialize(opts = {}) @title = opts.fetch(:title) @align = opts.fetch(:align, :left) end # Validates the column represented by this one. # @return [nil] # @raise [TermUtils::Tab::TableError] def validate raise TermUtils::Tab::TableError, "missing header title (nil)" if @title.nil? raise TermUtils::Tab::TableError, "wrong header align (not :left or :right)" unless %i[left right].index(@align) end end # Represents a table printer. class Printer # @return [Tab::Table] attr_accessor :table # @return [IO] attr_accessor :io # @return [Hash] attr_accessor :options # @param table [Tab::Table] # @param io [IO] # @param options [Hash] def initialize(table, io, options) @table = table @io = io @options = options end # Prints an empty line. def line @io.puts "" end # Prints a header row. # @param values [Array, Hash] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] def header(values = nil, opts = nil) @table.print_header(@io, values, opts ? @options.merge(opts) : @options) end # Prints a data row. # @param values [Array, Hash] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] def data(values, opts = nil) @table.print_data(@io, values, opts ? @options.merge(opts) : @options) end # Prints a separator. # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [nil] def separator(opts = nil) @table.print_separator(@io, opts ? @options.merge(opts) : @options) end end # Represents a holder of tables. class Holder # @return [Hash] `:offset`, `:column_separator_width`. attr_accessor :table_defaults # @return [Hash] `:width`, `:align`, `:fixed`, `:ellipsis`, `:format`. attr_accessor :column_defaults # @return [Hash] attr_accessor :tables def initialize(opts = {}) @table_defaults = TermUtils::Tab.init_table_props @column_defaults = TermUtils::Tab.init_column_props @tables = {} end # Sets table default properties. # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Symbol] :column_separator_width def set_table_defaults(opts = {}) TermUtils::Tab.assign_table_props(@table_defaults, opts) end # Sets column default properties. # @param opts [Hash] # @option opts [Integer] :width # @option opts [Symbol] :align # @option opts [Boolean] :fixed # @option opts [String] :ellipsis # @option opts [Proc, String, nil] :format def set_column_defaults(opts = {}) TermUtils::Tab.assign_column_props(@column_defaults, opts) end # Creates a new table, using default properties, without registering it. # @param opts [Hash] # @return [Tab::Table] def create_table(opts = {}, &block) opts[:offset] = @table_defaults.fetch(:offset) opts[:column_separator_width] = @table_defaults.fetch(:column_separator_width) opts[:column_defaults] = @column_defaults.dup new_tab = Table.new(opts) block.call(new_tab) if block new_tab end # Defines a table, using default properties. # @param id [Symbol] # @param opts [Hash] # @return [Tab::Table] def define_table(id, opts = {}, &block) if @tables.has_key? id block.call(@tables[id]) if block else opts[:id] = id opts[:offset] = @table_defaults.fetch(:offset) opts[:column_separator_width] = @table_defaults.fetch(:column_separator_width) opts[:column_defaults] = @column_defaults.dup new_tab = Table.new(opts) block.call(new_tab) if block @tables[id] = new_tab end @tables[id] end # Finds a table. # @param id [Symbol] # @return [Tab::Table, nil] def find_table(id) @tables[id] end # Creates a new table printer. # @param id [Symbol] # @param io [IO] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [Tab::Printer] def printer(id, io, opts = {}, &block) find_table(id).printer(io, opts, &block) end end @@default_holder = Holder.new # Sets table default properties. # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Symbol] :column_separator_width def self.set_table_defaults(opts = {}) @@default_holder.set_table_defaults(opts) end # Sets column default properties. # @param opts [Hash] # @option opts [Integer] :width # @option opts [Symbol] :align # @option opts [Boolean] :fixed # @option opts [String] :ellipsis # @option opts [Proc, String, nil] :format def self.set_column_defaults(opts = {}) @@default_holder.set_column_defaults(opts) end # Creates a new Table, using default properties, without registering it. # @param opts [Hash] # @return [Tab::Table] def self.create_table(opts = {}, &block) @@default_holder.create_table(opts, &block) end # Defines a new Table, using default properties, and registers it. # @param id [Symbol] # @param opts [Hash] # @return [Tab::Table] def self.define_table(id, opts = {}, &block) @@default_holder.define_table(id, opts, &block) end # Finds a registered table. # @param id [Symbol] # @return [Tab::Table, nil] def self.find_table(id) @@default_holder.find_table(id) end # Creates a new Printer for a registered Table. # @param id [Symbol] # @param io [IO] # @param opts [Hash] # @option opts [Integer] :offset # @option opts [Integer] :column_separator_width # @return [Tab::Printer] def self.printer(id, io, opts = {}, &block) @@default_holder.find_table(id).printer(io, opts, &block) end end end