tabulo 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ module Tabulo
2
+
3
+ # @!visibility private
4
+ module Deprecation
5
+
6
+ # @!visibility private
7
+ def self.skipping_warnings
8
+ @skipping_warnings ||= false
9
+ end
10
+
11
+ # @!visibility private
12
+ def self.skipping_warnings=(v)
13
+ @skipping_warnings = v
14
+ end
15
+
16
+ # @!visibility private
17
+ def self.without_warnings
18
+ original = skipping_warnings
19
+ self.skipping_warnings = true
20
+ yield
21
+ ensure
22
+ self.skipping_warnings = original
23
+ end
24
+
25
+ # @!visibility private
26
+ def self.warn(deprecated, replacement, stack_level = 1)
27
+ return if skipping_warnings
28
+
29
+ kaller = Kernel.caller[stack_level]
30
+ Kernel.warn "#{kaller}: [DEPRECATION] #{deprecated} is deprecated. Please use #{replacement} instead."
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ module Tabulo
2
+
3
+ # Error indicating that the label of a column is invalid.
4
+ class InvalidColumnLabelError < StandardError; end
5
+
6
+ # Error indicating that an attempt was made to use an invalid truncation indicator for
7
+ # the table.
8
+ class InvalidTruncationIndicatorError < StandardError; end
9
+
10
+ # Error indicating the table border configuration is invalid.
11
+ class InvalidBorderError < StandardError; end
12
+ end
@@ -0,0 +1,51 @@
1
+ module Tabulo
2
+
3
+ class Row
4
+ include Enumerable
5
+
6
+ # @return the element of the {Table}'s underlying enumerable to which this {Row} corresponds
7
+ attr_reader :source
8
+
9
+ # @!visibility private
10
+ def initialize(table, source, divider:, header:, index:)
11
+ @table = table
12
+ @source = source
13
+ @divider = divider
14
+ @header = header
15
+ @index = index
16
+ end
17
+
18
+ # Calls the given block once for each {Cell} in the {Row}, passing that {Cell} as parameter.
19
+ #
20
+ # @example
21
+ # table = Tabulo::Table.new([1, 10], columns: %i(itself even?))
22
+ # row = table.first
23
+ # row.each do |cell|
24
+ # puts cell.value # => 1, => false
25
+ # end
26
+ def each
27
+ @table.column_registry.each_with_index do |(_, column), column_index|
28
+ yield column.body_cell(@source, row_index: @index, column_index: column_index)
29
+ end
30
+ end
31
+
32
+ # @return a String being an "ASCII" graphical representation of the {Row}, including
33
+ # any column headers or row divider that appear just above it in the {Table} (depending on where
34
+ # this Row is in the {Table}, and how the {Table} was configured with respect to header frequency
35
+ # and divider frequency).
36
+ def to_s
37
+ if @table.column_registry.any?
38
+ @table.formatted_body_row(@source, divider: @divider, header: @header, index: @index)
39
+ else
40
+ ""
41
+ end
42
+ end
43
+
44
+ # @return a Hash representation of the {Row}, with column labels acting as keys and the {Cell}s the values.
45
+ def to_h
46
+ @table.column_registry.map.with_index do |(label, column), column_index|
47
+ [label, column.body_cell(@source, row_index: @index, column_index: column_index)]
48
+ end.to_h
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,763 @@
1
+ require "tty-screen"
2
+ require "unicode/display_width"
3
+
4
+ module Tabulo
5
+
6
+ # Represents a table primarily intended for "pretty-printing" in a fixed-width font.
7
+ #
8
+ # A Table is also an Enumerable, of which each element is a {Row}.
9
+ class Table
10
+ include Enumerable
11
+
12
+ # @!visibility public
13
+ DEFAULT_BORDER = :ascii
14
+
15
+ # @!visibility public
16
+ DEFAULT_COLUMN_WIDTH = 12
17
+
18
+ # @!visibility public
19
+ DEFAULT_COLUMN_PADDING = 1
20
+
21
+ # @!visibility public
22
+ DEFAULT_TRUNCATION_INDICATOR = "~"
23
+
24
+ # @!visibility private
25
+ PADDING_CHARACTER = " "
26
+
27
+ # @!visibility private
28
+ attr_reader :column_registry
29
+
30
+ # @return [Enumerable] the underlying enumerable from which the table derives its data
31
+ attr_accessor :sources
32
+
33
+ # @param [Enumerable] sources the underlying Enumerable from which the table will derive its data
34
+ # @param [Array[Symbol]] columns Specifies the initial columns. The Symbols provided must
35
+ # be unique. Each element of the Array will be used to create a column whose content is
36
+ # created by calling the corresponding method on each element of sources. Note
37
+ # the {#add_column} method is a much more flexible way to set up columns on the table.
38
+ # @param [:left, :right, :center, :auto] align_body (:auto) Determines the alignment of body cell
39
+ # (i.e. non-header) content within columns in this Table. Can be overridden for individual columns
40
+ # using the <tt>align_body</tt> option passed to {#add_column}. If passed <tt>:auto</tt>,
41
+ # alignment is determined by cell content, with numbers aligned right, booleans
42
+ # center-aligned, and other values left-aligned.
43
+ # @param [:left, :right, :center] align_header (:center) Determines the alignment of header text
44
+ # for columns in this Table. Can be overridden for individual columns using the
45
+ # <tt>align_header</tt> option passed to {#add_column}
46
+ # @param [:left, :right, :center] align_header (:center) Determines the alignment of the table
47
+ # title, if present.
48
+ # @param [:ascii, :markdown, :modern, :blank, nil] border (nil) Determines the characters used
49
+ # for the Table border, including both the characters around the outside of table, and the lines drawn
50
+ # within the table to separate columns from each other and the header row from the Table body.
51
+ # If <tt>nil</tt>, then the value of {DEFAULT_BORDER} will be used.
52
+ # Possible values are:
53
+ # - `:ascii` Uses ASCII characters only
54
+ # - `:markdown` Produces a GitHub-flavoured Markdown table. Note: Using the `title`
55
+ # option in combination with this border type will cause the rendered
56
+ # table not to be valid Markdown, since Markdown engines do not generally
57
+ # support adding a caption element (i.e. title) to tables.
58
+ # - `:modern` Uses non-ASCII Unicode characters to render a border with smooth continuous lines
59
+ # - `:blank` No border characters are rendered
60
+ # - `:reduced_ascii` Like `:ascii`, but without left or right borders, and with internal vertical
61
+ # borders and intersection characters consisting of whitespace only
62
+ # - `:reduced_modern` Like `:modern`, but without left or right borders, and with internal vertical
63
+ # borders and intersection characters consisting of whitespace only
64
+ # - `:classic` Like `:ascii`, but does not have a horizontal line at the bottom of the
65
+ # table. This reproduces the default behaviour in `tabulo` v1.
66
+ # @param [nil, #to_proc] border_styler (nil) A lambda or other callable object taking
67
+ # a single parameter, representing a section of the table's borders (which for this purpose
68
+ # include any horizontal and vertical lines inside the table), and returning a string.
69
+ # If passed <tt>nil</tt>, then no additional styling will be applied to borders. If passed a
70
+ # callable, then that callable will be called for each border section, with the
71
+ # resulting string rendered in place of that border. The extra width of the string returned by the
72
+ # <tt>border_styler</tt> is not taken into consideration by the internal table rendering calculations
73
+ # Thus it can be used to apply ANSI escape codes to border characters, to colour the borders
74
+ # for example, without breaking the table formatting.
75
+ # @param [nil, Integer, Array] column_padding (1) Determines the amount of blank space with which to pad
76
+ # either side of each column. If passed an Integer, then the given amount of padding is
77
+ # applied to each side of each column. If passed a two-element Array, then the first element of the
78
+ # Array indicates the amount of padding to apply to the left of each column, and the second
79
+ # element indicates the amount to apply to the right. This setting can be overridden for
80
+ # individual columns using the `padding` option of {#add_column}.
81
+ # @param [Integer, nil] column_width The default column width for columns in this
82
+ # table, not excluding padding. If <tt>nil</tt>, then {DEFAULT_COLUMN_WIDTH} will be used.
83
+ # @param [nil, #to_proc] formatter (:to_s.to_proc) The default formatter for columns in this
84
+ # table. See `formatter` option of {#add_column} for details.
85
+ # @param [:start, nil, Integer] header_frequency (:start) Controls the display of column headers.
86
+ # If passed <tt>:start</tt>, headers will be shown at the top of the table only. If passed <tt>nil</tt>,
87
+ # headers will not be shown. If passed an Integer N (> 0), headers will be shown at the top of the table,
88
+ # then repeated every N rows.
89
+ # @param [nil, #to_proc] header_styler (nil) The default header styler for columns in this
90
+ # table. See `header_styler` option of {#add_column} for details.
91
+ # @param [nil, Integer] row_divider_frequency (nil) Controls the display of horizontal row dividers within
92
+ # the table body. If passed <tt>nil</tt>, dividers will not be shown. If passed an Integer N (> 0),
93
+ # dividers will be shown after every N rows. The characters used to form the dividers are
94
+ # determined by the `border` option, and are the same as those used to form the bottom edge of the
95
+ # header row.
96
+ # @param [nil, #to_proc] styler (nil) The default styler for columns in this table. See `styler`
97
+ # option of {#add_column} for details.
98
+ # @param [nil, String] title (nil) If passed a String, will arrange for a title to be shown at the top
99
+ # of the table. Note: If the `border` option is set to `:markdown`, adding a title to the table
100
+ # will cause it to cease being valid Markdown when rendered, since Markdown engines do not generally
101
+ # support adding a caption element (i.e. title) to tables.
102
+ # @param [nil, #to_proc] title_styler (nil) A lambda or other callable object that will
103
+ # determine the colors or other styling applied to the table title. Can be passed
104
+ # <tt>nil</tt>, or can be passed a callable that takes either 1 or 2 parametes:
105
+ # * If passed <tt>nil</tt>, then no additional styling will be applied to the title.
106
+ # * If passed a callable, then that callable will be called for each line of
107
+ # the title, and the resulting string rendered in place of that line.
108
+ # The extra width of the string returned by the <tt>title_styler</tt> is not taken into
109
+ # consideration by the internal table and cell width calculations involved in rendering the
110
+ # table. Thus it can be used to apply ANSI escape codes to title content, to color the
111
+ # content for example, without breaking the table formatting.
112
+ # * If the passed callable takes 1 parameter, then the first parameter is a string
113
+ # representing a single line within the title. For example, if the title
114
+ # is wrapped over three lines, then the <tt>title_styler</tt> will be called
115
+ # three times, once for each line of content.
116
+ # * If the passed callable takes 2 parameters, then the first parameter is as above, and the
117
+ # second parameter is an Integer representing the index of the line within the
118
+ # title that is currently being styled. For example, if the title is wrapped over 3
119
+ # lines, then the callable will be called first with a line index of 0, to style the first line,
120
+ # then with a line index of 1, to style the second line, and finally with a line index of 2, for
121
+ # the third and final wrapped line of the cell.
122
+ #
123
+ # @param [nil, String] truncation_indicator Determines the character used to indicate that a
124
+ # cell's content has been truncated. If omitted or passed <tt>nil</tt>,
125
+ # defaults to {DEFAULT_TRUNCATION_INDICATOR}. If passed something other than <tt>nil</tt> or
126
+ # a single-character String, raises {InvalidTruncationIndicatorError}.
127
+ # @param [nil, Integer] wrap_body_cells_to Controls wrapping behaviour for table cells (excluding
128
+ # headers), if their content is longer than the column's fixed width. If passed <tt>nil</tt>, content will
129
+ # be wrapped for as many rows as required to accommodate it. If passed an Integer N (> 0), content will be
130
+ # wrapped up to N rows and then truncated thereafter.
131
+ # headers), if their content is longer than the column's fixed width. If passed <tt>nil</tt>, content will
132
+ # be wrapped for as many rows as required to accommodate it. If passed an Integer N (> 0), content will be
133
+ # wrapped up to N rows and then truncated thereafter.
134
+ # @param [nil, Integer] wrap_header_cells_to Controls wrapping behaviour for header
135
+ # cells if the content thereof is longer than the column's fixed width. If passed <tt>nil</tt> (default),
136
+ # content will be wrapped for as many rows as required to accommodate it. If passed an Integer N (> 0),
137
+ # content will be wrapped up to N rows and then truncated thereafter.
138
+ # @return [Table] a new {Table}
139
+ # @raise [InvalidColumnLabelError] if non-unique Symbols are provided to columns.
140
+ # @raise [InvalidBorderError] if invalid option passed to `border` parameter.
141
+ def initialize(sources, *columns, align_body: :auto, align_header: :center, align_title: :center,
142
+ border: nil, border_styler: nil, column_padding: nil, column_width: nil, formatter: :to_s.to_proc,
143
+ header_frequency: :start, header_styler: nil, row_divider_frequency: nil, styler: nil,
144
+ title: nil, title_styler: nil, truncation_indicator: nil, wrap_body_cells_to: nil,
145
+ wrap_header_cells_to: nil)
146
+
147
+ @sources = sources
148
+
149
+ @align_body = align_body
150
+ @align_header = align_header
151
+ @align_title = align_title
152
+ @border = (border || DEFAULT_BORDER)
153
+ @border_styler = border_styler
154
+ @border_instance = Border.from(@border, @border_styler)
155
+ @column_padding = (column_padding || DEFAULT_COLUMN_PADDING)
156
+
157
+ @left_column_padding, @right_column_padding =
158
+ (Array === @column_padding ? @column_padding : [@column_padding, @column_padding])
159
+
160
+ @column_width = (column_width || DEFAULT_COLUMN_WIDTH)
161
+ @formatter = formatter
162
+ @header_frequency = header_frequency
163
+ @header_styler = header_styler
164
+ @row_divider_frequency = row_divider_frequency
165
+ @styler = styler
166
+ @title = title
167
+ @title_styler = title_styler
168
+ @truncation_indicator = validate_character(truncation_indicator,
169
+ DEFAULT_TRUNCATION_INDICATOR, InvalidTruncationIndicatorError, "truncation indicator")
170
+ @wrap_body_cells_to = wrap_body_cells_to
171
+ @wrap_header_cells_to = wrap_header_cells_to
172
+
173
+ @column_registry = { }
174
+ columns.each { |item| add_column(item) }
175
+
176
+ yield self if block_given?
177
+ end
178
+
179
+ # Adds a column to the Table.
180
+ #
181
+ # @param [Symbol, String, Integer] label A unique identifier for this column, which by
182
+ # default will also be used as the column header text (see also the header param). If the
183
+ # extractor argument is not also provided, then the label argument should correspond to
184
+ # a method to be called on each item in the table sources to provide the content
185
+ # for this column. If a String is passed as the label, then it will be converted to
186
+ # a Symbol for the purpose of serving as this label.
187
+ # @param [:left, :center, :right, :auto, nil] align_body (nil) Specifies how the cell body contents
188
+ # should be aligned. If <tt>nil</tt> is passed, then the alignment is determined
189
+ # by the Table-level setting passed to the <tt>align_body</tt> option on Table initialization
190
+ # (which itself defaults to <tt>:auto</tt>). Otherwise this option determines the alignment of
191
+ # this column. If <tt>:auto</tt> is passed, the alignment is determined by the type of the cell
192
+ # value, with numbers aligned right, booleans center-aligned, and other values left-aligned.
193
+ # Note header text alignment is configured separately using the :align_header param.
194
+ # @param [:left, :center, :right, nil] align_header (nil) Specifies how the header text
195
+ # should be aligned. If <tt>nil</tt> is passed, then the alignment is determined
196
+ # by the Table-level setting passed to the <tt>align_header</tt> (which itself defaults
197
+ # to <tt>:center</tt>). Otherwise, this option determines the alignment of the header
198
+ # content for this column.
199
+ # @param [Symbol, String, Integer, nil] before (nil) The label of the column before (i.e. to
200
+ # the left of) which the new column should inserted. If <tt>nil</tt> is passed, it will be
201
+ # inserted after all other columns. If there is no column with the given label, then an
202
+ # {InvalidColumnLabelError} will be raised. A non-Integer labelled column can be identified
203
+ # in either String or Symbol form for this purpose.
204
+ # @param [#to_proc] formatter (nil) A lambda or other callable object that
205
+ # will be passed the calculated value of each cell to determine how it should be displayed. This
206
+ # is distinct from the extractor and the styler (see below).
207
+ # For example, if the extractor for this column generates a Date, then the formatter might format
208
+ # that Date in a particular way.
209
+ # * If <tt>nil</tt> is provided, then the callable that was passed to the `formatter` option
210
+ # of the table itself on its creation (see {#initialize}) (which itself defaults to
211
+ # `:to_s.to_proc`), will be used as the formatter for the column.
212
+ # * If a 1-parameter callable is passed, then this callable will be called with the calculated
213
+ # value of the cell; it should then return a String, and this String will be displayed as
214
+ # the formatted value of the cell.
215
+ # * If a 2-parameter callable is passed, then the first parameter represents the calculated
216
+ # value of the cell, and the second parameter is a {CellData} instance, containing
217
+ # additional information about the cell that may be relevant to what formatting should
218
+ # be applied. For example, the {CellData#row_index} attribute can be inspected to determine
219
+ # whether the {Cell} is an odd- or even-numbered {Row}, to arrange for different formatting
220
+ # to be applied to alternating rows.
221
+ # See the documentation for {CellData} for more.
222
+ # @param [nil, #to_s] header (nil) Text to be displayed in the column header. If passed nil,
223
+ # the column's label will also be used as its header text.
224
+ # @param [nil, #to_proc] header_styler (nil) A lambda or other callable object that will
225
+ # determine the colors or other styling applied to the header content. Can be passed
226
+ # <tt>nil</tt>, or can be passed a callable that takes 1, 2 or 3 parameters:
227
+ # * If passed <tt>nil</tt>, then no additional styling will be applied to the cell content
228
+ # (other than what was already applied by the <tt>formatter</tt>).
229
+ # * If passed a callable, then that callable will be called for each line of content within
230
+ # the header cell, and the resulting string rendered in place of that line.
231
+ # The extra width of the string returned by the <tt>header_styler</tt> is not taken into
232
+ # consideration by the internal table and cell width calculations involved in rendering the
233
+ # table. Thus it can be used to apply ANSI escape codes to header cell content, to color the
234
+ # cell content for example, without breaking the table formatting.
235
+ # * If the passed callable takes 1 parameter, then the first parameter is a string
236
+ # representing a single formatted line within the header cell. For example, if the header
237
+ # cell content is wrapped over three lines, then the <tt>header_styler</tt> will be called
238
+ # three times for that header cell, once for each line of content.
239
+ # * If the passed callable takes 2 parameters, then the first parameter is as above, and the
240
+ # second parameter is an Integer representing the positional index of this header's {Column},
241
+ # with the leftmost column having index 0, the next having index 1 etc.. This can be
242
+ # used, for example, to apply different styles to alternating {Column}s.
243
+ # * If the passed callable takes 3 parameters, then the first and second parameters are as above,
244
+ # and the third parameter is an Integer representing the index of the line within the
245
+ # header cell that is currently being styled. For example, if the cell content is wrapped over 3
246
+ # lines, then the callable will be called first with a line index of 0, to style the first line,
247
+ # then with a line index of 1, to style the second line, and finally with a line index of 2, for
248
+ # the third and final wrapped line of the cell.
249
+ #
250
+ # Note that if the header content is truncated, then any <tt>header_styler</tt> will be applied to the
251
+ # truncation indicator character as well as to the truncated content.
252
+ # @param [nil, Integer, Array] padding (nil) Determines the amount of blank space with which to
253
+ # pad either side of the column. If passed nil, then the `column_padding` setting of the
254
+ # {Table} will determine the column's padding. (See {#initialize}.) Otherwise, this option
255
+ # overrides, for this column, the `column_padding` that was set at the table level: if passed an Integer,
256
+ # then the given amount of padding is applied to either side of the column; or if passed a two-element Array,
257
+ # then the first element of the Array indicates the amount of padding to apply to the left of the column,
258
+ # and the second element indicates the amount to apply to the right.
259
+ # @param [nil, #to_proc] styler (nil) A lambda or other callable object that will determine
260
+ # the colors or other styling applied to the formatted value of the cell. Can be passed
261
+ # <tt>nil</tt>, or can be passed a callable that takes either 2 or 3 parameters:
262
+ # * If passed <tt>nil</tt>, then no additional styling will be applied to the cell content
263
+ # (other than what was already applied by the <tt>formatter</tt>).
264
+ # * If passed a callable, then that callable will be called for each line of content within
265
+ # the cell, and the resulting string rendered in place of that line.
266
+ # The <tt>styler</tt> option differs from the <tt>formatter</tt> option in that the extra width of the
267
+ # string returned by <tt>styler</tt> is not taken into consideration by the internal table and
268
+ # cell width calculations involved in rendering the table. Thus it can be used to apply
269
+ # ANSI escape codes to cell content, to color the cell content for example, without
270
+ # breaking the table formatting.
271
+ # * If the passed callable takes 2 parameters, then the first parameter is the calculated
272
+ # value of the cell (prior to the <tt>formatter</tt> being applied); and the second parameter is
273
+ # a string representing a single formatted line within the cell. For example, if the cell
274
+ # content is wrapped over three lines, then for that cell, the <tt>styler</tt> will be called
275
+ # three times, once for each line of content within the cell.
276
+ # * If the passed callable takes 3 parameters, then the first two parameters are as above,
277
+ # and the third parameter is a {CellData} instance, containing additional information
278
+ # about the cell that may be relevant to what styles should be applied. For example, the
279
+ # {CellData#row_index} attribute can be inspected to determine whether the {Cell} is an
280
+ # odd- or even-numbered {Row}, to arrange for different styling to be applied to
281
+ # alternating rows. See the documentation for {CellData} for more.
282
+ # * If the passed callable takes 4 parameters, then the first three parameters are as above,
283
+ # and the fourth parameter is an Integer representing the index of the line within the
284
+ # cell that is currently being styled. For example, if the cell content is wrapped over 3
285
+ # lines, then the callable will be called first with a line index of 0, to style the first
286
+ # line, then with a line index of 1, to style the second line, and finally with a line
287
+ # index of 2, to style the third and final wrapped line of the cell.
288
+ #
289
+ # Note that if the content of a cell is truncated, then the whatever styling is applied by the
290
+ # <tt>styler</tt> to the cell content will also be applied to the truncation indicator character.
291
+ # @param [Integer] width (nil) Specifies the width of the column, excluding padding. If
292
+ # nil, then the column will take the width provided by the `column_width` param
293
+ # with which the Table was initialized.
294
+ # @param [#to_proc] extractor A block or other callable that will be passed each of the {Table}
295
+ # sources to determine the value in each cell of this column.
296
+ # * If this is not provided, then the column label will be treated as a method to be called on
297
+ # each source item to determine each cell's value.
298
+ # * If provided a single-parameter callable, then this callable will be passed each of the
299
+ # {Table} sources to determine the cell value for each row in this column.
300
+ # * If provided a 2-parameter callable, then for each of the {Table} sources, this callable
301
+ # will be passed the source, and the row index, to determine the cell value for that row.
302
+ # For this purpose, the first body row (not counting the header row) has an index of 0,
303
+ # the next an index of 1, etc..
304
+ # @raise [InvalidColumnLabelError] if label has already been used for another column in this
305
+ # Table. (This is case-sensitive, but is insensitive to whether a String or Symbol is passed
306
+ # to the label parameter.)
307
+ def add_column(label, align_body: nil, align_header: nil, before: nil, formatter: nil,
308
+ header: nil, header_styler: nil, padding: nil, styler: nil, width: nil, &extractor)
309
+
310
+ column_label = normalize_column_label(label)
311
+
312
+ left_padding, right_padding =
313
+ if padding
314
+ Array === padding ? padding : [padding, padding]
315
+ else
316
+ [@left_column_padding, @right_column_padding]
317
+ end
318
+
319
+ if column_registry.include?(column_label)
320
+ raise InvalidColumnLabelError, "Column label already used in this table."
321
+ end
322
+
323
+ column = Column.new(
324
+ align_body: align_body || @align_body,
325
+ align_header: align_header || @align_header,
326
+ extractor: extractor || label.to_proc,
327
+ formatter: formatter || @formatter,
328
+ header: (header || label).to_s,
329
+ header_styler: header_styler || @header_styler,
330
+ index: column_registry.count,
331
+ left_padding: left_padding,
332
+ padding_character: PADDING_CHARACTER,
333
+ right_padding: right_padding,
334
+ styler: styler || @styler,
335
+ truncation_indicator: @truncation_indicator,
336
+ width: width || @column_width,
337
+ )
338
+
339
+ if before == nil
340
+ add_column_final(column, column_label)
341
+ else
342
+ add_column_before(column, column_label, before)
343
+ end
344
+ end
345
+
346
+ # Removes the column identifed by the passed label.
347
+ #
348
+ # @example
349
+ # table = Table.new(1..10, :itself, :even?, :odd?)
350
+ # table.add_column(:even2, header: "even?") { |n| n.even? }
351
+ # table.remove_column(:even2)
352
+ # table.remove_column(:odd?)
353
+ #
354
+ # @param [Symbol, String, Integer] label The unique identifier for the column to be removed,
355
+ # corresponding to the label that was passed as the first parameter to {#add_column} (or was
356
+ # used in the table initializer) when the column was originally added. For columns that were
357
+ # originally added with a String or Symbol label, either a String or Symbol form of that label
358
+ # can be passed to {#remove_column}, indifferently. For example, if the label passed to
359
+ # {#add_column} had been `"height"`, then that column could be removed by passing either
360
+ # `"height"` or `:height` to {#remove_column}. (However, if an Integer was originally passed
361
+ # as the label to {#add_column}, then only that same Integer, as an Integer, can be passed to
362
+ # {#remove_column} to remove that column.)
363
+ # @return [true, false] If the label identifies a column in the table, then the column will be
364
+ # removed and true will be returned; otherwise no column will be removed, and false will be returned.
365
+ def remove_column(label)
366
+ !!column_registry.delete(Integer === label ? label : label.to_sym)
367
+ end
368
+
369
+ # @return [String] a graphical "ASCII" representation of the Table, suitable for
370
+ # display in a fixed-width font.
371
+ def to_s
372
+ if column_registry.any?
373
+ bottom_edge = horizontal_rule(:bottom)
374
+ rows = map(&:to_s)
375
+ bottom_edge.empty? ? Util.join_lines(rows) : Util.join_lines(rows + [bottom_edge])
376
+ else
377
+ ""
378
+ end
379
+ end
380
+
381
+ # Calls the given block once for each {Row} in the Table, passing that {Row} as parameter.
382
+ #
383
+ # @example
384
+ # table.each do |row|
385
+ # puts row
386
+ # end
387
+ #
388
+ # Note that when printed, the first row will visually include the headers (assuming these
389
+ # were not disabled when the Table was initialized).
390
+ def each
391
+ @sources.each_with_index do |source, index|
392
+ header =
393
+ if (index == 0) && @header_frequency
394
+ :top
395
+ elsif (Integer === @header_frequency) && Util.divides?(@header_frequency, index)
396
+ :middle
397
+ end
398
+
399
+ show_divider = @row_divider_frequency && (index != 0) && Util.divides?(@row_divider_frequency, index)
400
+
401
+ yield Row.new(self, source, header: header, divider: show_divider, index: index)
402
+ end
403
+ end
404
+
405
+ # @return [String] a graphical representation of the Table column headers formatted with fixed
406
+ # width plain text.
407
+ def formatted_header
408
+ cells = get_columns.map(&:header_cell)
409
+ format_row(cells, @wrap_header_cells_to)
410
+ end
411
+
412
+ # Produce a horizontal dividing line suitable for printing at the top, bottom or middle
413
+ # of the table.
414
+ #
415
+ # @param [:top, :middle, :bottom, :title_top, :title_bottom] position (:bottom)
416
+ # Specifies the position for which the resulting horizontal dividing line is intended to
417
+ # be printed. This determines the border characters that are used to construct the line.
418
+ # The `:title_top` and `:title_bottom` options are used internally for adding borders
419
+ # above and below the table title text.
420
+ # @return [String] an "ASCII" graphical representation of a horizontal
421
+ # dividing line.
422
+ # @example Print a horizontal divider between each pair of rows, and again at the bottom:
423
+ #
424
+ # table.each_with_index do |row, i|
425
+ # puts table.horizontal_rule(:middle) unless i == 0
426
+ # puts row
427
+ # end
428
+ # puts table.horizontal_rule(:bottom)
429
+ #
430
+ # It may be that `:top`, `:middle` and `:bottom` all look the same. Whether
431
+ # this is the case depends on the characters used for the table border.
432
+ def horizontal_rule(position = :bottom)
433
+ column_widths = get_columns.map { |column| column.width + column.total_padding }
434
+ @border_instance.horizontal_rule(column_widths, position)
435
+ end
436
+
437
+ # Resets all the column widths so that each column is *just* wide enough to accommodate
438
+ # its header text as well as the formatted content of each its cells for the entire
439
+ # collection, together with a single character of padding on either side of the column,
440
+ # without any wrapping. In addition, if the table has a title but is not wide enough to
441
+ # accommodate (without wrapping) the title text (with a character of padding either side),
442
+ # widens the columns roughly evenly until the table as a whole is just wide enough to
443
+ # accommodate the title text.
444
+ #
445
+ # Note that calling this method will cause the entire source Enumerable to
446
+ # be traversed and all the column extractors and formatters to be applied in order
447
+ # to calculate the required widths.
448
+ #
449
+ # Note also that this method causes column widths to be fixed as appropriate to the
450
+ # formatted cell contents given the state of the source Enumerable at the point it
451
+ # is called. If the source Enumerable changes between that point, and the point when
452
+ # the Table is printed, then columns will *not* be resized yet again on printing.
453
+ #
454
+ # @param [nil, Numeric] max_table_width (:auto) With no args, or if passed <tt>:auto</tt>,
455
+ # stops the total table width (including padding and borders) from expanding beyond the
456
+ # bounds of the terminal screen.
457
+ # If passed <tt>nil</tt>, the table width will not be capped.
458
+ # Width is deducted from columns if required to achieve this, with one character progressively
459
+ # deducted from the width of the widest column until the target is reached. When the
460
+ # table is printed, wrapping or truncation will then occur in these columns as required
461
+ # (depending on how they were configured).
462
+ # Note that regardless of the value passed to max_table_width, the table will always be left wide
463
+ # enough to accommodate at least 1 character's width of content, 1 character of left padding and
464
+ # 1 character of right padding in each column, together with border characters (1 on each side
465
+ # of the table and 1 between adjacent columns). I.e. there is a certain width below width the
466
+ # Table will refuse to shrink itself.
467
+ # @return [Table] the Table itself
468
+ def pack(max_table_width: :auto)
469
+ get_columns.each { |column| column.width = Util.wrapped_width(column.header) }
470
+
471
+ @sources.each_with_index do |source, row_index|
472
+ get_columns.each_with_index do |column, column_index|
473
+ cell = column.body_cell(source, row_index: row_index, column_index: column_index)
474
+ cell_width = Util.wrapped_width(cell.formatted_content)
475
+ column.width = Util.max(column.width, cell_width)
476
+ end
477
+ end
478
+
479
+ shrink_to(max_table_width == :auto ? TTY::Screen.width : max_table_width) if max_table_width
480
+
481
+ if @title
482
+ border_edge_width = (@border == :blank ? 0 : 2)
483
+ columns = get_columns
484
+ expand_to(
485
+ Unicode::DisplayWidth.of(@title) +
486
+ columns.first.left_padding +
487
+ columns.last.right_padding +
488
+ border_edge_width
489
+ )
490
+ end
491
+
492
+ self
493
+ end
494
+
495
+ # Creates a new {Table} from the current Table, transposed, that is rotated 90 degrees,
496
+ # relative to the current Table, so that the header names of the current Table form the
497
+ # content of left-most column of the new Table, and each column thereafter corresponds to one of the
498
+ # elements of the current Table's <tt>sources</tt>, with the header of that column being the String
499
+ # value of that element.
500
+ #
501
+ # @example
502
+ # puts Tabulo::Table.new(-1..1, :even?, :odd?, :abs).transpose
503
+ # # => +-------+--------------+--------------+--------------+
504
+ # # | | -1 | 0 | 1 |
505
+ # # +-------+--------------+--------------+--------------+
506
+ # # | even? | false | true | false |
507
+ # # | odd? | true | false | true |
508
+ # # | abs | 1 | 0 | 1 |
509
+ #
510
+ # @param [Hash] opts Options for configuring the new, transposed {Table}.
511
+ # The following options are the same as the keyword params for the {#initialize} method for
512
+ # {Table}: <tt>column_width</tt>, <tt>column_padding</tt>, <tt>formatter</tt>,
513
+ # <tt>header_frequency</tt>, <tt>row_divider_frequency</tt>, <tt>wrap_header_cells_to</tt>,
514
+ # <tt>wrap_body_cells_to</tt>, <tt>border</tt>, <tt>border_styler</tt>, <tt>title</tt>,
515
+ # <tt>title_styler</tt>, <tt>truncation_indicator</tt>, <tt>align_header</tt>, <tt>align_body</tt>,
516
+ # <tt>align_title</tt>.
517
+ # These are applied in the same way as documented for {#initialize}, when
518
+ # creating the new, transposed Table. Any options not specified explicitly in the call to {#transpose}
519
+ # will inherit their values from the original {Table} (with the exception of settings
520
+ # for the left-most column, containing the field names, which are determined as described
521
+ # below). In addition, the following options also apply to {#transpose}:
522
+ # @option opts [nil, Integer] :field_names_width Determines the width of the left-most column of the
523
+ # new Table, which contains the names of "fields" (corresponding to the original Table's
524
+ # column headings). If this is not provided, then by default this column will be made just
525
+ # wide enough to accommodate its contents.
526
+ # @option opts [String] :field_names_header ("") By default the left-most column will have a
527
+ # blank header; but this can be overridden by passing a String to this option.
528
+ # @option opts [:left, :center, :right] :field_names_header_alignment (:right) Specifies how the
529
+ # header text of the left-most column (if it has header text) should be aligned.
530
+ # @option opts [:left, :center, :right] :field_names_body_alignment (:right) Specifies how the
531
+ # body text of the left-most column should be aligned.
532
+ # @option opts [#to_proc] :headers (:to_s.to_proc) A lambda or other callable object that
533
+ # will be passed in turn each of the elements of the current Table's <tt>sources</tt>
534
+ # Enumerable, to determine the text to be displayed in the header of each column of the
535
+ # new Table (other than the left-most column's header, which is determined as described
536
+ # above).
537
+ # @return [Table] a new {Table}
538
+ # @raise [InvalidBorderError] if invalid argument passed to `border` parameter.
539
+ def transpose(opts = {})
540
+ default_opts = [:align_body, :align_header, :align_title, :border, :border_styler, :column_padding,
541
+ :column_width, :formatter, :header_frequency, :row_divider_frequency, :title, :title_styler,
542
+ :truncation_indicator, :wrap_body_cells_to, :wrap_header_cells_to].map do |sym|
543
+ [sym, instance_variable_get("@#{sym}")]
544
+ end.to_h
545
+
546
+ initializer_opts = default_opts.merge(Util.slice_hash(opts, *default_opts.keys))
547
+ default_extra_opts = { field_names_body_alignment: :right, field_names_header: "",
548
+ field_names_header_alignment: :right, field_names_width: nil, headers: :to_s.to_proc }
549
+ extra_opts = default_extra_opts.merge(Util.slice_hash(opts, *default_extra_opts.keys))
550
+
551
+ # The underlying enumerable for the new table, is the columns of the original table.
552
+ fields = column_registry.values
553
+
554
+ Table.new(fields, **initializer_opts) do |t|
555
+
556
+ # Left hand column of new table, containing field names
557
+ width_opt = extra_opts[:field_names_width]
558
+ field_names_width = (width_opt.nil? ? fields.map { |f| f.header.length }.max : width_opt)
559
+
560
+ t.add_column(:dummy, align_body: extra_opts[:field_names_body_alignment],
561
+ align_header: extra_opts[:field_names_header_alignment], header: extra_opts[:field_names_header],
562
+ width: field_names_width, &:header)
563
+
564
+ # Add a column to the new table for each of the original table's sources
565
+ sources.each_with_index do |source, i|
566
+ t.add_column(i, header: extra_opts[:headers].call(source)) do |original_column|
567
+ original_column.body_cell_value(source, row_index: i, column_index: original_column.index)
568
+ end
569
+ end
570
+ end
571
+ end
572
+
573
+ # @!visibility private
574
+ def formatted_body_row(source, header:, divider:, index:)
575
+ cells = get_columns.map.with_index { |c, i| c.body_cell(source, row_index: index, column_index: i) }
576
+ inner = format_row(cells, @wrap_body_cells_to)
577
+
578
+ if @title && header == :top
579
+ Util.condense_lines([horizontal_rule(:title_top), formatted_title, horizontal_rule(:title_bottom),
580
+ formatted_header, horizontal_rule(:middle), inner])
581
+ elsif header == :top
582
+ Util.condense_lines([horizontal_rule(:top), formatted_header, horizontal_rule(:middle), inner])
583
+ elsif header
584
+ Util.condense_lines([horizontal_rule(:middle), formatted_header, horizontal_rule(:middle), inner])
585
+ elsif divider
586
+ Util.condense_lines([horizontal_rule(:middle), inner])
587
+ else
588
+ inner
589
+ end
590
+ end
591
+
592
+ private
593
+
594
+ # @!visibility private
595
+ def get_columns
596
+ column_registry.values
597
+ end
598
+
599
+ # @!visibility private
600
+ def add_column_before(column, label, before)
601
+ old_column_entries = @column_registry.to_a
602
+ new_column_entries = []
603
+
604
+ old_column_entries.each do |entry|
605
+ new_column_entries << [label, column] if entry[0] == before
606
+ new_column_entries << entry
607
+ end
608
+
609
+ found = (new_column_entries.size == old_column_entries.size + 1)
610
+ raise InvalidColumnLabelError, "There is no column with label #{before}" unless found
611
+
612
+ @column_registry = new_column_entries.to_h
613
+ end
614
+
615
+ # @!visibility private
616
+ def add_column_final(column, label)
617
+ @column_registry[label] = column
618
+ end
619
+
620
+ # @visibility private
621
+ def formatted_title
622
+ columns = get_columns
623
+
624
+ extra_for_internal_dividers = (@border == :blank ? 0 : 1)
625
+
626
+ title_cell_width = columns.inject(0) do |total_width, column|
627
+ total_width + column.padded_width + extra_for_internal_dividers
628
+ end
629
+
630
+ title_cell_width -= (columns.first.left_padding + columns.last.right_padding + extra_for_internal_dividers)
631
+
632
+ styler =
633
+ if @title_styler
634
+ case @title_styler.arity
635
+ when 1
636
+ -> (_val, str) { @title_styler.call(str) }
637
+ when 2
638
+ -> (_val, str, _cell_data, line_index) { @title_styler.call(str, line_index) }
639
+ end
640
+ else
641
+ -> (_val, str) { str }
642
+ end
643
+
644
+ title_cell = Cell.new(
645
+ alignment: @align_title,
646
+ cell_data: nil,
647
+ formatter: -> (s) { s },
648
+ left_padding: columns.first.left_padding,
649
+ padding_character: PADDING_CHARACTER,
650
+ right_padding: columns.last.right_padding,
651
+ styler: styler,
652
+ truncation_indicator: @truncation_indicator,
653
+ value: @title,
654
+ width: title_cell_width
655
+ )
656
+ cells = [title_cell]
657
+ max_cell_height = cells.map(&:height).max
658
+ row_height = ([nil, max_cell_height].compact.min || 1)
659
+ subcell_stacks = cells.map do |cell|
660
+ cell.padded_truncated_subcells(row_height)
661
+ end
662
+ subrows = subcell_stacks.transpose.map do |subrow_components|
663
+ @border_instance.join_cell_contents(subrow_components)
664
+ end
665
+
666
+ Util.join_lines(subrows)
667
+ end
668
+
669
+ # @!visibility private
670
+ def normalize_column_label(label)
671
+ case label
672
+ when Integer, Symbol
673
+ label
674
+ when String
675
+ label.to_sym
676
+ end
677
+ end
678
+
679
+ # @!visibility private
680
+ def expand_to(min_table_width)
681
+ columns = get_columns
682
+ num_columns = columns.count
683
+ total_columns_padded_width = columns.inject(0) { |sum, column| sum + column.padded_width }
684
+ total_borders = num_columns + 1
685
+ unadjusted_table_width = total_columns_padded_width + total_borders
686
+ required_increase = Util.max(min_table_width - unadjusted_table_width, 0)
687
+
688
+ required_increase.times do
689
+ narrowest_column = columns.inject(columns.first) do |narrowest, column|
690
+ column.width <= narrowest.width ? column : narrowest
691
+ end
692
+
693
+ narrowest_column.width += 1
694
+ end
695
+ end
696
+
697
+ # @!visibility private
698
+ def shrink_to(max_table_width)
699
+ columns = get_columns
700
+ num_columns = columns.count
701
+ total_columns_padded_width = columns.inject(0) { |sum, column| sum + column.padded_width }
702
+ total_padding = columns.inject(0) { |sum, column| sum + column.total_padding }
703
+ total_borders = num_columns + 1
704
+ unadjusted_table_width = total_columns_padded_width + total_borders
705
+
706
+ # Ensure max table width is at least wide enough to accommodate table borders and padding
707
+ # and one character of content.
708
+ min_table_width = total_padding + total_borders + column_registry.count
709
+ max_table_width = Util.max(min_table_width, max_table_width)
710
+ required_reduction = Util.max(unadjusted_table_width - max_table_width, 0)
711
+
712
+ required_reduction.times do
713
+ widest_column = columns.inject(columns.first) do |widest, column|
714
+ column.width >= widest.width ? column : widest
715
+ end
716
+
717
+ widest_column.width -= 1
718
+ end
719
+ end
720
+
721
+ # @!visibility private
722
+ #
723
+ # Formats a single header row or body row as a String.
724
+ #
725
+ # @param [String[][]] cells an Array of Array-of-Strings, each of which represents a
726
+ # "stack" of "subcells". Each such stack represents the wrapped content of a given
727
+ # "cell" in this row, from the top down, one String for each "line".
728
+ # Each String includes the spaces, if any, on either side required for the
729
+ # "internal padding" of the cell to carry out the cell content alignment -- but
730
+ # does not include the single character of padding around each column.
731
+ # @param [Integer] wrap_cells_to the number of "lines" of wrapped content to allow
732
+ # before truncating.
733
+ # @return [String] the entire formatted row including all padding and borders.
734
+ def format_row(cells, wrap_cells_to)
735
+ max_cell_height = cells.map(&:height).max
736
+ row_height = ([wrap_cells_to, max_cell_height].compact.min || 1)
737
+ subcell_stacks = cells.map do |cell|
738
+ cell.padded_truncated_subcells(row_height)
739
+ end
740
+ subrows = subcell_stacks.transpose.map do |subrow_components|
741
+ @border_instance.join_cell_contents(subrow_components)
742
+ end
743
+
744
+ Util.join_lines(subrows)
745
+ end
746
+
747
+ # @!visibility private
748
+ def validate_character(character, default, exception_class, message_fragment)
749
+ case (c = (character || default))
750
+ when nil
751
+ ; # do nothing
752
+ when String
753
+ if Unicode::DisplayWidth.of(c) != 1
754
+ raise exception_class, "#{message_fragment} is neither nil nor a single-character String"
755
+ end
756
+ else
757
+ raise exception_class, "#{message_fragment} is neither nil nor a single-character String"
758
+ end
759
+ c
760
+ end
761
+
762
+ end
763
+ end