tabulo 2.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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