# frozen-string-literal: true # Copyright (C) 2020 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.key? :offset) && (source[:offset].is_a? Integer) && (source[:offset] >= 0) target[:offset] = source[:offset] end if (source.key? :column_separator_width) && (source[:column_separator_width].is_a? Integer) && source[:column_separator_width].positive? 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.key? :width) && (source[:width].is_a? Integer) && source[:width].positive? target[:width] = source[:width] end if (source.key? :align) && %i[left right].index(source[:align]) target[:align] = source[:align] end if (source.key? :fixed) && (!!source[:fixed] == source[:fixed]) target[:fixed] = source[:fixed] end if (source.key? :ellipsis) && (source[:ellipsis].is_a? String) target[:ellipsis] = source[:ellipsis] end if (source.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) if align == :left # Align left if fixed && (src.length > width) if ellipsis.length >= width ellipsis[0..(width - 1)] else format '%s%s', value: src[0..(width - (ellipsis.length + 1))], ellipsis: ellipsis end else src.ljust(width) end elsif align == :right # Align right if fixed && (src.length > width) if ellipsis.length >= width ellipsis[0..(width - 1)] else format '%s%s', ellipsis: ellipsis, value: src[(src.length - width + ellipsis.length)..(src.length - 1)] end else src.rjust(width) end end 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) @column_defaults = opts.key?(:column_defaults) ? opts[:column_defaults].dup : TermUtils::Tab.default_column_props @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) col.validate else opts[:id] = id opts[:index] = @columns.length col = Column.new(@column_defaults.merge(opts)) block&.call(col) 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) 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.positive? @columns.each do |col| sb << ' ' * column_separator_width if col.index.positive? 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.positive? @columns.each do |col| sb << ' ' * column_separator_width if col.index.positive? 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.positive? @columns.each do |col| sb << ' ' * column_separator_width if col.index.positive? 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.negative? 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 Table(s). 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 # Creates a new Holder. def initialize @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) 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.key? id block&.call(@tables[id]) 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) @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 # rubocop:disable Style/ClassVars # 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