tabulo 2.4.0 → 2.6.2

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.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.4.0
1
+ 2.6.2
@@ -3,118 +3,77 @@ module Tabulo
3
3
  # @!visibility private
4
4
  class Border
5
5
 
6
+ Style = Struct.new(
7
+ :corner_top_left, :corner_top_right, :corner_bottom_right, :corner_bottom_left,
8
+ :edge_top, :edge_right, :edge_bottom, :edge_left,
9
+ :tee_top, :tee_right, :tee_bottom, :tee_left,
10
+ :divider_vertical, :divider_horizontal, :intersection)
11
+
6
12
  STYLES = {
7
- ascii: {
8
- corner_top_left: "+",
9
- corner_top_right: "+",
10
- corner_bottom_right: "+",
11
- corner_bottom_left: "+",
12
- edge_top: "-",
13
- edge_right: "|",
14
- edge_bottom: "-",
15
- edge_left: "|",
16
- tee_top: "+",
17
- tee_right: "+",
18
- tee_bottom: "+",
19
- tee_left: "+",
20
- divider_vertical: "|",
21
- divider_horizontal: "-",
22
- intersection: "+",
23
- },
24
- classic: {
25
- corner_top_left: "+",
26
- corner_top_right: "+",
27
- edge_top: "-",
28
- edge_right: "|",
29
- edge_left: "|",
30
- tee_top: "+",
31
- tee_right: "+",
32
- tee_left: "+",
33
- divider_vertical: "|",
34
- divider_horizontal: "-",
35
- intersection: "+",
36
- },
37
- reduced_ascii: {
38
- corner_top_left: "",
39
- corner_top_right: "",
40
- corner_bottom_right: "",
41
- corner_bottom_left: "",
42
- edge_top: "-",
43
- edge_right: "",
44
- edge_bottom: "-",
45
- edge_left: "",
46
- tee_top: " ",
47
- tee_right: "",
48
- tee_bottom: " ",
49
- tee_left: "",
50
- divider_vertical: " ",
51
- divider_horizontal: "-",
52
- intersection: " ",
53
- },
54
- reduced_modern: {
55
- corner_top_left: "",
56
- corner_top_right: "",
57
- corner_bottom_right: "",
58
- corner_bottom_left: "",
59
- edge_top: "─",
60
- edge_right: "",
61
- edge_bottom: "─",
62
- edge_left: "",
63
- tee_top: " ",
64
- tee_right: "",
65
- tee_bottom: " ",
66
- tee_left: "",
67
- divider_vertical: " ",
68
- divider_horizontal: "─",
69
- intersection: " ",
70
- },
71
- markdown: {
72
- corner_top_left: "",
73
- corner_top_right: "",
74
- corner_bottom_right: "",
75
- corner_bottom_left: "",
76
- edge_top: "",
77
- edge_right: "|",
78
- edge_bottom: "",
79
- edge_left: "|",
80
- tee_top: "",
81
- tee_right: "|",
82
- tee_bottom: "",
83
- tee_left: "|",
84
- divider_vertical: "|",
85
- divider_horizontal: "-",
86
- intersection: "|",
87
- },
88
- modern: {
89
- corner_top_left: "┌",
90
- corner_top_right: "┐",
91
- corner_bottom_right: "┘",
92
- corner_bottom_left: "└",
93
- edge_top: "─",
94
- edge_right: "│",
95
- edge_bottom: "─",
96
- edge_left: "│",
97
- tee_top: "┬",
98
- tee_right: "┤",
99
- tee_bottom: "┴",
100
- tee_left: "├",
101
- divider_vertical: "│",
102
- divider_horizontal: "─",
103
- intersection: "┼",
104
- },
105
- blank: {
106
- },
13
+ ascii:
14
+ Style.new(
15
+ "+", "+", "+", "+",
16
+ "-", "|", "-", "|",
17
+ "+", "+", "+", "+",
18
+ "|", "-", "+",
19
+ ),
20
+ classic:
21
+ Style.new(
22
+ "+", "+", "", "",
23
+ "-", "|", "", "|",
24
+ "+", "+", "", "+",
25
+ "|", "-", "+",
26
+ ),
27
+ reduced_ascii:
28
+ Style.new(
29
+ "", "", "", "",
30
+ "-", "", "-", "",
31
+ " ", "", " ", "",
32
+ " ", "-", " ",
33
+ ),
34
+ reduced_modern:
35
+ Style.new(
36
+ "", "", "", "",
37
+ "─", "", "─", "",
38
+ " ", "", " ", "",
39
+ " ", "─", " ",
40
+ ),
41
+ markdown:
42
+ Style.new(
43
+ "", "", "", "",
44
+ "", "|", "", "|",
45
+ "", "|", "", "|",
46
+ "|", "-", "|",
47
+ ),
48
+ modern:
49
+ Style.new(
50
+ "┌", "", "┘", "└",
51
+ "─", "", "─", "│",
52
+ "┬", "┤", "┴", "├",
53
+ "│", "", "┼",
54
+ ),
55
+ blank:
56
+ Style.new(
57
+ "", "", "", "",
58
+ "", "", "", "",
59
+ "", "", "", "",
60
+ "", "", "",
61
+ ),
107
62
  }
108
63
 
109
64
  # @!visibility private
110
65
  def self.from(initializer, styler = nil)
111
- new(options(initializer).merge(styler: styler))
66
+ new(**options(initializer).merge(styler: styler))
112
67
  end
113
68
 
114
69
  # @!visibility private
115
70
  def horizontal_rule(column_widths, position = :bottom)
116
71
  left, center, right, segment =
117
72
  case position
73
+ when :title_top
74
+ [@corner_top_left, @edge_top, @corner_top_right, @edge_top]
75
+ when :title_bottom
76
+ [@tee_left, @tee_top, @tee_right, @edge_top]
118
77
  when :top
119
78
  [@corner_top_left, @tee_top, @corner_top_right, @edge_top]
120
79
  when :middle
@@ -123,6 +82,11 @@ module Tabulo
123
82
  [@corner_bottom_left, @tee_bottom, @corner_bottom_right, @edge_bottom]
124
83
  end
125
84
  segments = column_widths.map { |width| segment * width }
85
+
86
+ # Prevent weird bottom edge of title if segments empty but right/left not empty, as in
87
+ # Markdown border.
88
+ left = right = "" if segments.all?(&:empty?)
89
+
126
90
  style("#{left}#{segments.join(center)}#{right}")
127
91
  end
128
92
 
@@ -138,7 +102,7 @@ module Tabulo
138
102
 
139
103
  def self.options(kind)
140
104
  opts = STYLES[kind]
141
- return opts if opts
105
+ return opts.to_h if opts
142
106
  raise InvalidBorderError
143
107
  end
144
108
 
@@ -13,7 +13,9 @@ module Tabulo
13
13
  alignment:,
14
14
  cell_data:,
15
15
  formatter:,
16
+ left_padding:,
16
17
  padding_character:,
18
+ right_padding:,
17
19
  styler:,
18
20
  truncation_indicator:,
19
21
  value:,
@@ -21,8 +23,10 @@ module Tabulo
21
23
 
22
24
  @alignment = alignment
23
25
  @cell_data = cell_data
24
- @padding_character = padding_character
25
26
  @formatter = formatter
27
+ @left_padding = left_padding
28
+ @padding_character = padding_character
29
+ @right_padding = right_padding
26
30
  @styler = styler
27
31
  @truncation_indicator = truncation_indicator
28
32
  @value = value
@@ -35,12 +39,12 @@ module Tabulo
35
39
  end
36
40
 
37
41
  # @!visibility private
38
- def padded_truncated_subcells(target_height, padding_amount_left, padding_amount_right)
39
- total_padding_amount = padding_amount_left + padding_amount_right
42
+ def padded_truncated_subcells(target_height)
43
+ total_padding_amount = @left_padding + @right_padding
40
44
  truncated = (height > target_height)
41
45
  (0...target_height).map do |subcell_index|
42
46
  append_truncator = (truncated && (total_padding_amount != 0) && (subcell_index + 1 == target_height))
43
- padded_subcell(subcell_index, padding_amount_left, padding_amount_right, append_truncator)
47
+ padded_subcell(subcell_index, append_truncator)
44
48
  end
45
49
  end
46
50
 
@@ -60,8 +64,11 @@ module Tabulo
60
64
  end
61
65
  end
62
66
 
63
- def apply_styler(content)
64
- if @styler.arity == 3
67
+ def apply_styler(content, line_index)
68
+ case @styler.arity
69
+ when 4
70
+ @styler.call(@value, content, @cell_data, line_index)
71
+ when 3
65
72
  @styler.call(@value, content, @cell_data)
66
73
  else
67
74
  @styler.call(@value, content)
@@ -72,13 +79,13 @@ module Tabulo
72
79
  @subcells ||= calculate_subcells
73
80
  end
74
81
 
75
- def padded_subcell(subcell_index, padding_amount_left, padding_amount_right, append_truncator)
76
- lpad = @padding_character * padding_amount_left
82
+ def padded_subcell(subcell_index, append_truncator)
83
+ lpad = @padding_character * @left_padding
77
84
  rpad =
78
85
  if append_truncator
79
- styled_truncation_indicator + padding(padding_amount_right - 1)
86
+ styled_truncation_indicator(subcell_index) + padding(@right_padding - 1)
80
87
  else
81
- padding(padding_amount_right)
88
+ padding(@right_padding)
82
89
  end
83
90
  inner = subcell_index < height ? subcells[subcell_index] : padding(@width)
84
91
  "#{lpad}#{inner}#{rpad}"
@@ -88,31 +95,35 @@ module Tabulo
88
95
  @padding_character * amount
89
96
  end
90
97
 
91
- def styled_truncation_indicator
92
- apply_styler(@truncation_indicator)
98
+ def styled_truncation_indicator(line_index)
99
+ apply_styler(@truncation_indicator, line_index)
93
100
  end
94
101
 
95
102
  def calculate_subcells
96
- formatted_content.split($/, -1).flat_map do |substr|
103
+ line_index = 0
104
+ formatted_content.split(Util::NEWLINE, -1).flat_map do |substr|
97
105
  subsubcells, subsubcell, subsubcell_width = [], String.new(""), 0
98
106
 
99
107
  substr.scan(/\X/).each do |grapheme_cluster|
100
108
  grapheme_cluster_width = Unicode::DisplayWidth.of(grapheme_cluster)
101
109
  if subsubcell_width + grapheme_cluster_width > @width
102
- subsubcells << style_and_align_cell_content(subsubcell)
110
+ subsubcells << style_and_align_cell_content(subsubcell, line_index)
103
111
  subsubcell_width = 0
104
112
  subsubcell.clear
113
+ line_index += 1
105
114
  end
106
115
 
107
116
  subsubcell << grapheme_cluster
108
117
  subsubcell_width += grapheme_cluster_width
109
118
  end
110
119
 
111
- subsubcells << style_and_align_cell_content(subsubcell)
120
+ subsubcells << style_and_align_cell_content(subsubcell, line_index)
121
+ line_index += 1
122
+ subsubcells
112
123
  end
113
124
  end
114
125
 
115
- def style_and_align_cell_content(content)
126
+ def style_and_align_cell_content(content, line_index)
116
127
  padding = Util.max(@width - Unicode::DisplayWidth.of(content), 0)
117
128
  left_padding, right_padding =
118
129
  case real_alignment
@@ -125,7 +136,7 @@ module Tabulo
125
136
  [padding, 0]
126
137
  end
127
138
 
128
- "#{' ' * left_padding}#{apply_styler(content)}#{' ' * right_padding}"
139
+ "#{' ' * left_padding}#{apply_styler(content, line_index)}#{' ' * right_padding}"
129
140
  end
130
141
 
131
142
  def real_alignment
@@ -5,7 +5,8 @@ module Tabulo
5
5
  # @attr source [Object] The member of this {Cell}'s {Table}'s underlying enumerable from which
6
6
  # this {Cell}'s {Row} was derived.
7
7
  # @attr row_index [Integer] The positional index of the {Cell}'s {Row}. The topmost {Row} of the
8
- # {Table} has index 0, the next has index 1, etc..
8
+ # {Table} has index 0, the next has index 1, etc.. The header row(s) are not counted for the purpose
9
+ # of this numbering.
9
10
  # @attr column_index [Integer] The positional index of the {Cell}'s {Column}. The leftmost {Column}
10
11
  # of the {Table} has index 0, the next has index 1, etc..
11
12
  CellData = Struct.new(:source, :row_index, :column_index)
@@ -6,6 +6,8 @@ module Tabulo
6
6
  attr_accessor :width
7
7
  attr_reader :header
8
8
  attr_reader :index
9
+ attr_reader :left_padding
10
+ attr_reader :right_padding
9
11
 
10
12
  def initialize(
11
13
  align_body:,
@@ -15,7 +17,9 @@ module Tabulo
15
17
  header:,
16
18
  header_styler:,
17
19
  index:,
20
+ left_padding:,
18
21
  padding_character:,
22
+ right_padding:,
19
23
  styler:,
20
24
  truncation_indicator:,
21
25
  width:)
@@ -26,12 +30,19 @@ module Tabulo
26
30
  @formatter = formatter
27
31
  @header = header
28
32
  @index = index
33
+ @left_padding = left_padding
34
+ @right_padding = right_padding
29
35
 
30
36
  @header_styler =
31
- if header_styler && (header_styler.arity == 2)
32
- -> (_, str, cell_data) { header_styler.call(str, cell_data.column_index) }
33
- elsif header_styler
34
- -> (_, str) { header_styler.call(str) }
37
+ if header_styler
38
+ case header_styler.arity
39
+ when 3
40
+ -> (_, str, cell_data, line_index) { header_styler.call(str, cell_data.column_index, line_index) }
41
+ when 2
42
+ -> (_, str, cell_data) { header_styler.call(str, cell_data.column_index) }
43
+ else
44
+ -> (_, str) { header_styler.call(str) }
45
+ end
35
46
  else
36
47
  -> (_, str) { str }
37
48
  end
@@ -43,14 +54,16 @@ module Tabulo
43
54
  end
44
55
 
45
56
  def header_cell
46
- if @header_styler.arity == 3
57
+ if @header_styler.arity >= 3
47
58
  cell_data = CellData.new(nil, nil, @index)
48
59
  end
49
60
  Cell.new(
50
61
  alignment: @align_header,
51
62
  cell_data: cell_data,
52
63
  formatter: -> (s) { s },
64
+ left_padding: @left_padding,
53
65
  padding_character: @padding_character,
66
+ right_padding: @right_padding,
54
67
  styler: @header_styler,
55
68
  truncation_indicator: @truncation_indicator,
56
69
  value: @header,
@@ -66,7 +79,9 @@ module Tabulo
66
79
  alignment: @align_body,
67
80
  cell_data: cell_data,
68
81
  formatter: @formatter,
82
+ left_padding: @left_padding,
69
83
  padding_character: @padding_character,
84
+ right_padding: @right_padding,
70
85
  styler: @styler,
71
86
  truncation_indicator: @truncation_indicator,
72
87
  value: body_cell_value(source, row_index: row_index, column_index: column_index),
@@ -82,6 +97,14 @@ module Tabulo
82
97
  end
83
98
  end
84
99
 
100
+ def padded_width
101
+ width + total_padding
102
+ end
103
+
104
+ def total_padding
105
+ @left_padding + @right_padding
106
+ end
107
+
85
108
  private
86
109
 
87
110
  def body_cell_data_required?
@@ -35,21 +35,26 @@ module Tabulo
35
35
  # be unique. Each element of the Array will be used to create a column whose content is
36
36
  # created by calling the corresponding method on each element of sources. Note
37
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
38
+ # @param [:left, :right, :center, :auto] align_body Determines the alignment of body cell
39
39
  # (i.e. non-header) content within columns in this Table. Can be overridden for individual columns
40
40
  # using the <tt>align_body</tt> option passed to {#add_column}. If passed <tt>:auto</tt>,
41
41
  # alignment is determined by cell content, with numbers aligned right, booleans
42
42
  # center-aligned, and other values left-aligned.
43
- # @param [:left, :right, :center] align_header (:center) Determines the alignment of header text
43
+ # @param [:left, :right, :center] align_header Determines the alignment of header text
44
44
  # for columns in this Table. Can be overridden for individual columns using the
45
45
  # <tt>align_header</tt> option passed to {#add_column}
46
- # @param [:ascii, :markdown, :modern, :blank, nil] border (nil) Determines the characters used
46
+ # @param [:left, :right, :center] align_header Determines the alignment of the table
47
+ # title, if present.
48
+ # @param [:ascii, :markdown, :modern, :blank, nil] border Determines the characters used
47
49
  # for the Table border, including both the characters around the outside of table, and the lines drawn
48
50
  # within the table to separate columns from each other and the header row from the Table body.
49
51
  # If <tt>nil</tt>, then the value of {DEFAULT_BORDER} will be used.
50
52
  # Possible values are:
51
53
  # - `:ascii` Uses ASCII characters only
52
- # - `:markdown` Produces a GitHub-flavoured Markdown table
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.
53
58
  # - `:modern` Uses non-ASCII Unicode characters to render a border with smooth continuous lines
54
59
  # - `:blank` No border characters are rendered
55
60
  # - `:reduced_ascii` Like `:ascii`, but without left or right borders, and with internal vertical
@@ -58,7 +63,7 @@ module Tabulo
58
63
  # borders and intersection characters consisting of whitespace only
59
64
  # - `:classic` Like `:ascii`, but does not have a horizontal line at the bottom of the
60
65
  # table. This reproduces the default behaviour in `tabulo` v1.
61
- # @param [nil, #to_proc] border_styler (nil) A lambda or other callable object taking
66
+ # @param [nil, #to_proc] border_styler A lambda or other callable object taking
62
67
  # a single parameter, representing a section of the table's borders (which for this purpose
63
68
  # include any horizontal and vertical lines inside the table), and returning a string.
64
69
  # If passed <tt>nil</tt>, then no additional styling will be applied to borders. If passed a
@@ -67,28 +72,54 @@ module Tabulo
67
72
  # <tt>border_styler</tt> is not taken into consideration by the internal table rendering calculations
68
73
  # Thus it can be used to apply ANSI escape codes to border characters, to colour the borders
69
74
  # for example, without breaking the table formatting.
70
- # @param [nil, Integer, Array] column_padding (1) Determines the amount of blank space with which to pad
75
+ # @param [nil, Integer, Array] column_padding Determines the amount of blank space with which to pad
71
76
  # either side of each column. If passed an Integer, then the given amount of padding is
72
77
  # applied to each side of each column. If passed a two-element Array, then the first element of the
73
78
  # Array indicates the amount of padding to apply to the left of each column, and the second
74
- # element indicates the amount to apply to the right.
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}.
75
81
  # @param [Integer, nil] column_width The default column width for columns in this
76
82
  # table, not excluding padding. If <tt>nil</tt>, then {DEFAULT_COLUMN_WIDTH} will be used.
77
- # @param [nil, #to_proc] formatter (:to_s.to_proc) The default formatter for columns in this
83
+ # @param [nil, #to_proc] formatter The default formatter for columns in this
78
84
  # table. See `formatter` option of {#add_column} for details.
79
85
  # @param [:start, nil, Integer] header_frequency (:start) Controls the display of column headers.
80
86
  # If passed <tt>:start</tt>, headers will be shown at the top of the table only. If passed <tt>nil</tt>,
81
87
  # headers will not be shown. If passed an Integer N (> 0), headers will be shown at the top of the table,
82
88
  # then repeated every N rows.
83
- # @param [nil, #to_proc] header_styler (nil) The default header styler for columns in this
89
+ # @param [nil, #to_proc] header_styler The default header styler for columns in this
84
90
  # table. See `header_styler` option of {#add_column} for details.
85
- # @param [nil, Integer] row_divider_frequency (nil) Controls the display of horizontal row dividers within
91
+ # @param [nil, Integer] row_divider_frequency Controls the display of horizontal row dividers within
86
92
  # the table body. If passed <tt>nil</tt>, dividers will not be shown. If passed an Integer N (> 0),
87
93
  # dividers will be shown after every N rows. The characters used to form the dividers are
88
94
  # determined by the `border` option, and are the same as those used to form the bottom edge of the
89
95
  # header row.
90
- # @param [nil, #to_proc] styler (nil) The default styler for columns in this table. See `styler`
96
+ # @param [nil, #to_proc] styler The default styler for columns in this table. See `styler`
91
97
  # option of {#add_column} for details.
98
+ # @param [nil, String] title 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 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
+ #
92
123
  # @param [nil, String] truncation_indicator Determines the character used to indicate that a
93
124
  # cell's content has been truncated. If omitted or passed <tt>nil</tt>,
94
125
  # defaults to {DEFAULT_TRUNCATION_INDICATOR}. If passed something other than <tt>nil</tt> or
@@ -107,22 +138,24 @@ module Tabulo
107
138
  # @return [Table] a new {Table}
108
139
  # @raise [InvalidColumnLabelError] if non-unique Symbols are provided to columns.
109
140
  # @raise [InvalidBorderError] if invalid option passed to `border` parameter.
110
- def initialize(sources, *columns, align_body: :auto, align_header: :center, border: nil,
111
- border_styler: nil, column_padding: nil, column_width: nil, formatter: :to_s.to_proc,
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,
112
143
  header_frequency: :start, header_styler: nil, row_divider_frequency: nil, styler: nil,
113
- truncation_indicator: nil, wrap_body_cells_to: nil, wrap_header_cells_to: nil)
144
+ title: nil, title_styler: nil, truncation_indicator: nil, wrap_body_cells_to: nil,
145
+ wrap_header_cells_to: nil)
114
146
 
115
147
  @sources = sources
116
148
 
117
149
  @align_body = align_body
118
150
  @align_header = align_header
151
+ @align_title = align_title
119
152
  @border = (border || DEFAULT_BORDER)
120
153
  @border_styler = border_styler
121
154
  @border_instance = Border.from(@border, @border_styler)
122
155
  @column_padding = (column_padding || DEFAULT_COLUMN_PADDING)
123
156
 
124
157
  @left_column_padding, @right_column_padding =
125
- Array === @column_padding ? @column_padding : [@column_padding, @column_padding]
158
+ (Array === @column_padding ? @column_padding : [@column_padding, @column_padding])
126
159
 
127
160
  @column_width = (column_width || DEFAULT_COLUMN_WIDTH)
128
161
  @formatter = formatter
@@ -130,6 +163,8 @@ module Tabulo
130
163
  @header_styler = header_styler
131
164
  @row_divider_frequency = row_divider_frequency
132
165
  @styler = styler
166
+ @title = title
167
+ @title_styler = title_styler
133
168
  @truncation_indicator = validate_character(truncation_indicator,
134
169
  DEFAULT_TRUNCATION_INDICATOR, InvalidTruncationIndicatorError, "truncation indicator")
135
170
  @wrap_body_cells_to = wrap_body_cells_to
@@ -149,24 +184,24 @@ module Tabulo
149
184
  # a method to be called on each item in the table sources to provide the content
150
185
  # for this column. If a String is passed as the label, then it will be converted to
151
186
  # a Symbol for the purpose of serving as this label.
152
- # @param [:left, :center, :right, :auto, nil] align_body (nil) Specifies how the cell body contents
187
+ # @param [:left, :center, :right, :auto, nil] align_body Specifies how the cell body contents
153
188
  # should be aligned. If <tt>nil</tt> is passed, then the alignment is determined
154
189
  # by the Table-level setting passed to the <tt>align_body</tt> option on Table initialization
155
190
  # (which itself defaults to <tt>:auto</tt>). Otherwise this option determines the alignment of
156
191
  # this column. If <tt>:auto</tt> is passed, the alignment is determined by the type of the cell
157
192
  # value, with numbers aligned right, booleans center-aligned, and other values left-aligned.
158
193
  # Note header text alignment is configured separately using the :align_header param.
159
- # @param [:left, :center, :right, nil] align_header (nil) Specifies how the header text
194
+ # @param [:left, :center, :right, nil] align_header Specifies how the header text
160
195
  # should be aligned. If <tt>nil</tt> is passed, then the alignment is determined
161
196
  # by the Table-level setting passed to the <tt>align_header</tt> (which itself defaults
162
197
  # to <tt>:center</tt>). Otherwise, this option determines the alignment of the header
163
198
  # content for this column.
164
- # @param [Symbol, String, Integer, nil] before (nil) The label of the column before (i.e. to
199
+ # @param [Symbol, String, Integer, nil] before The label of the column before (i.e. to
165
200
  # the left of) which the new column should inserted. If <tt>nil</tt> is passed, it will be
166
201
  # inserted after all other columns. If there is no column with the given label, then an
167
202
  # {InvalidColumnLabelError} will be raised. A non-Integer labelled column can be identified
168
203
  # in either String or Symbol form for this purpose.
169
- # @param [#to_proc] formatter (nil) A lambda or other callable object that
204
+ # @param [#to_proc] formatter A lambda or other callable object that
170
205
  # will be passed the calculated value of each cell to determine how it should be displayed. This
171
206
  # is distinct from the extractor and the styler (see below).
172
207
  # For example, if the extractor for this column generates a Date, then the formatter might format
@@ -184,11 +219,11 @@ module Tabulo
184
219
  # whether the {Cell} is an odd- or even-numbered {Row}, to arrange for different formatting
185
220
  # to be applied to alternating rows.
186
221
  # See the documentation for {CellData} for more.
187
- # @param [nil, #to_s] header (nil) Text to be displayed in the column header. If passed nil,
222
+ # @param [nil, #to_s] header Text to be displayed in the column header. If passed nil,
188
223
  # the column's label will also be used as its header text.
189
224
  # @param [nil, #to_proc] header_styler (nil) A lambda or other callable object that will
190
225
  # determine the colors or other styling applied to the header content. Can be passed
191
- # <tt>nil</tt>, or can be passed a callable that takes either 1 or 2 parameters:
226
+ # <tt>nil</tt>, or can be passed a callable that takes 1, 2 or 3 parameters:
192
227
  # * If passed <tt>nil</tt>, then no additional styling will be applied to the cell content
193
228
  # (other than what was already applied by the <tt>formatter</tt>).
194
229
  # * If passed a callable, then that callable will be called for each line of content within
@@ -205,10 +240,23 @@ module Tabulo
205
240
  # second parameter is an Integer representing the positional index of this header's {Column},
206
241
  # with the leftmost column having index 0, the next having index 1 etc.. This can be
207
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.
208
249
  #
209
250
  # Note that if the header content is truncated, then any <tt>header_styler</tt> will be applied to the
210
251
  # truncation indicator character as well as to the truncated content.
211
- # @param [nil, #to_proc] styler (nil) A lambda or other callable object that will determine
252
+ # @param [nil, Integer, Array] padding 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 A lambda or other callable object that will determine
212
260
  # the colors or other styling applied to the formatted value of the cell. Can be passed
213
261
  # <tt>nil</tt>, or can be passed a callable that takes either 2 or 3 parameters:
214
262
  # * If passed <tt>nil</tt>, then no additional styling will be applied to the cell content
@@ -231,10 +279,16 @@ module Tabulo
231
279
  # {CellData#row_index} attribute can be inspected to determine whether the {Cell} is an
232
280
  # odd- or even-numbered {Row}, to arrange for different styling to be applied to
233
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.
234
288
  #
235
289
  # Note that if the content of a cell is truncated, then the whatever styling is applied by the
236
290
  # <tt>styler</tt> to the cell content will also be applied to the truncation indicator character.
237
- # @param [Integer] width (nil) Specifies the width of the column, excluding padding. If
291
+ # @param [Integer] width Specifies the width of the column, excluding padding. If
238
292
  # nil, then the column will take the width provided by the `column_width` param
239
293
  # with which the Table was initialized.
240
294
  # @param [#to_proc] extractor A block or other callable that will be passed each of the {Table}
@@ -251,10 +305,17 @@ module Tabulo
251
305
  # Table. (This is case-sensitive, but is insensitive to whether a String or Symbol is passed
252
306
  # to the label parameter.)
253
307
  def add_column(label, align_body: nil, align_header: nil, before: nil, formatter: nil,
254
- header: nil, header_styler: nil, styler: nil, width: nil, &extractor)
308
+ header: nil, header_styler: nil, padding: nil, styler: nil, width: nil, &extractor)
255
309
 
256
310
  column_label = normalize_column_label(label)
257
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
+
258
319
  if column_registry.include?(column_label)
259
320
  raise InvalidColumnLabelError, "Column label already used in this table."
260
321
  end
@@ -267,7 +328,9 @@ module Tabulo
267
328
  header: (header || label).to_s,
268
329
  header_styler: header_styler || @header_styler,
269
330
  index: column_registry.count,
331
+ left_padding: left_padding,
270
332
  padding_character: PADDING_CHARACTER,
333
+ right_padding: right_padding,
271
334
  styler: styler || @styler,
272
335
  truncation_indicator: @truncation_indicator,
273
336
  width: width || @column_width,
@@ -309,7 +372,7 @@ module Tabulo
309
372
  if column_registry.any?
310
373
  bottom_edge = horizontal_rule(:bottom)
311
374
  rows = map(&:to_s)
312
- bottom_edge.empty? ? join_lines(rows) : join_lines(rows + [bottom_edge])
375
+ bottom_edge.empty? ? Util.join_lines(rows) : Util.join_lines(rows + [bottom_edge])
313
376
  else
314
377
  ""
315
378
  end
@@ -339,7 +402,8 @@ module Tabulo
339
402
  end
340
403
  end
341
404
 
342
- # @return [String] an "ASCII" graphical representation of the Table column headers.
405
+ # @return [String] a graphical representation of the Table column headers formatted with fixed
406
+ # width plain text.
343
407
  def formatted_header
344
408
  cells = get_columns.map(&:header_cell)
345
409
  format_row(cells, @wrap_header_cells_to)
@@ -348,9 +412,11 @@ module Tabulo
348
412
  # Produce a horizontal dividing line suitable for printing at the top, bottom or middle
349
413
  # of the table.
350
414
  #
351
- # @param [:top, :middle, :bottom] position (:bottom) Specifies the position
352
- # for which the resulting horizontal dividing line is intended to be printed.
353
- # This determines the border characters that are used to construct the line.
415
+ # @param [:top, :middle, :bottom, :title_top, :title_bottom] position
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.
354
420
  # @return [String] an "ASCII" graphical representation of a horizontal
355
421
  # dividing line.
356
422
  # @example Print a horizontal divider between each pair of rows, and again at the bottom:
@@ -364,14 +430,17 @@ module Tabulo
364
430
  # It may be that `:top`, `:middle` and `:bottom` all look the same. Whether
365
431
  # this is the case depends on the characters used for the table border.
366
432
  def horizontal_rule(position = :bottom)
367
- column_widths = get_columns.map { |column| column.width + total_column_padding }
433
+ column_widths = get_columns.map { |column| column.width + column.total_padding }
368
434
  @border_instance.horizontal_rule(column_widths, position)
369
435
  end
370
436
 
371
- # Reset all the column widths so that each column is *just* wide enough to accommodate
437
+ # Resets all the column widths so that each column is *just* wide enough to accommodate
372
438
  # its header text as well as the formatted content of each its cells for the entire
373
439
  # collection, together with a single character of padding on either side of the column,
374
- # without any wrapping.
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.
375
444
  #
376
445
  # Note that calling this method will cause the entire source Enumerable to
377
446
  # be traversed and all the column extractors and formatters to be applied in order
@@ -382,7 +451,7 @@ module Tabulo
382
451
  # is called. If the source Enumerable changes between that point, and the point when
383
452
  # the Table is printed, then columns will *not* be resized yet again on printing.
384
453
  #
385
- # @param [nil, Numeric] max_table_width (:auto) With no args, or if passed <tt>:auto</tt>,
454
+ # @param [nil, Numeric] max_table_width With no args, or if passed <tt>:auto</tt>,
386
455
  # stops the total table width (including padding and borders) from expanding beyond the
387
456
  # bounds of the terminal screen.
388
457
  # If passed <tt>nil</tt>, the table width will not be capped.
@@ -397,18 +466,27 @@ module Tabulo
397
466
  # Table will refuse to shrink itself.
398
467
  # @return [Table] the Table itself
399
468
  def pack(max_table_width: :auto)
400
- get_columns.each { |column| column.width = wrapped_width(column.header) }
469
+ get_columns.each { |column| column.width = Util.wrapped_width(column.header) }
401
470
 
402
471
  @sources.each_with_index do |source, row_index|
403
472
  get_columns.each_with_index do |column, column_index|
404
473
  cell = column.body_cell(source, row_index: row_index, column_index: column_index)
405
- cell_width = wrapped_width(cell.formatted_content)
474
+ cell_width = Util.wrapped_width(cell.formatted_content)
406
475
  column.width = Util.max(column.width, cell_width)
407
476
  end
408
477
  end
409
478
 
410
- if max_table_width
411
- shrink_to(max_table_width == :auto ? TTY::Screen.width : max_table_width)
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
+ )
412
490
  end
413
491
 
414
492
  self
@@ -433,8 +511,9 @@ module Tabulo
433
511
  # The following options are the same as the keyword params for the {#initialize} method for
434
512
  # {Table}: <tt>column_width</tt>, <tt>column_padding</tt>, <tt>formatter</tt>,
435
513
  # <tt>header_frequency</tt>, <tt>row_divider_frequency</tt>, <tt>wrap_header_cells_to</tt>,
436
- # <tt>wrap_body_cells_to</tt>, <tt>border</tt>, <tt>border_styler</tt>, <tt>truncation_indicator</tt>,
437
- # <tt>align_header</tt>, <tt>align_body</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>.
438
517
  # These are applied in the same way as documented for {#initialize}, when
439
518
  # creating the new, transposed Table. Any options not specified explicitly in the call to {#transpose}
440
519
  # will inherit their values from the original {Table} (with the exception of settings
@@ -444,13 +523,13 @@ module Tabulo
444
523
  # new Table, which contains the names of "fields" (corresponding to the original Table's
445
524
  # column headings). If this is not provided, then by default this column will be made just
446
525
  # wide enough to accommodate its contents.
447
- # @option opts [String] :field_names_header ("") By default the left-most column will have a
526
+ # @option opts [String] :field_names_header By default the left-most column will have a
448
527
  # blank header; but this can be overridden by passing a String to this option.
449
- # @option opts [:left, :center, :right] :field_names_header_alignment (:right) Specifies how the
528
+ # @option opts [:left, :center, :right] :field_names_header_alignment Specifies how the
450
529
  # header text of the left-most column (if it has header text) should be aligned.
451
- # @option opts [:left, :center, :right] :field_names_body_alignment (:right) Specifies how the
530
+ # @option opts [:left, :center, :right] :field_names_body_alignment Specifies how the
452
531
  # body text of the left-most column should be aligned.
453
- # @option opts [#to_proc] :headers (:to_s.to_proc) A lambda or other callable object that
532
+ # @option opts [#to_proc] :headers A lambda or other callable object that
454
533
  # will be passed in turn each of the elements of the current Table's <tt>sources</tt>
455
534
  # Enumerable, to determine the text to be displayed in the header of each column of the
456
535
  # new Table (other than the left-most column's header, which is determined as described
@@ -458,9 +537,9 @@ module Tabulo
458
537
  # @return [Table] a new {Table}
459
538
  # @raise [InvalidBorderError] if invalid argument passed to `border` parameter.
460
539
  def transpose(opts = {})
461
- default_opts = [:align_body, :align_header, :border, :border_styler, :column_padding, :column_width,
462
- :formatter, :header_frequency, :row_divider_frequency, :truncation_indicator, :wrap_body_cells_to,
463
- :wrap_header_cells_to].map do |sym|
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|
464
543
  [sym, instance_variable_get("@#{sym}")]
465
544
  end.to_h
466
545
 
@@ -496,25 +575,15 @@ module Tabulo
496
575
  cells = get_columns.map.with_index { |c, i| c.body_cell(source, row_index: index, column_index: i) }
497
576
  inner = format_row(cells, @wrap_body_cells_to)
498
577
 
499
- if header == :top
500
- join_lines([
501
- horizontal_rule(:top),
502
- formatted_header,
503
- horizontal_rule(:middle),
504
- inner
505
- ].reject(&:empty?))
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])
506
583
  elsif header
507
- join_lines([
508
- horizontal_rule(:middle),
509
- formatted_header,
510
- horizontal_rule(:middle),
511
- inner
512
- ].reject(&:empty?))
584
+ Util.condense_lines([horizontal_rule(:middle), formatted_header, horizontal_rule(:middle), inner])
513
585
  elsif divider
514
- join_lines([
515
- horizontal_rule(:middle),
516
- inner
517
- ].reject(&:empty?))
586
+ Util.condense_lines([horizontal_rule(:middle), inner])
518
587
  else
519
588
  inner
520
589
  end
@@ -548,6 +617,55 @@ module Tabulo
548
617
  @column_registry[label] = column
549
618
  end
550
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
+
551
669
  # @!visibility private
552
670
  def normalize_column_label(label)
553
671
  case label
@@ -558,14 +676,32 @@ module Tabulo
558
676
  end
559
677
  end
560
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
+
561
697
  # @!visibility private
562
698
  def shrink_to(max_table_width)
563
699
  columns = get_columns
564
700
  num_columns = columns.count
565
- total_columns_width = columns.inject(0) { |sum, column| sum + column.width }
566
- total_padding = num_columns * total_column_padding
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 }
567
703
  total_borders = num_columns + 1
568
- unadjusted_table_width = total_columns_width + total_padding + total_borders
704
+ unadjusted_table_width = total_columns_padded_width + total_borders
569
705
 
570
706
  # Ensure max table width is at least wide enough to accommodate table borders and padding
571
707
  # and one character of content.
@@ -582,11 +718,6 @@ module Tabulo
582
718
  end
583
719
  end
584
720
 
585
- # @!visibility private
586
- def total_column_padding
587
- @left_column_padding + @right_column_padding
588
- end
589
-
590
721
  # @!visibility private
591
722
  #
592
723
  # Formats a single header row or body row as a String.
@@ -604,18 +735,13 @@ module Tabulo
604
735
  max_cell_height = cells.map(&:height).max
605
736
  row_height = ([wrap_cells_to, max_cell_height].compact.min || 1)
606
737
  subcell_stacks = cells.map do |cell|
607
- cell.padded_truncated_subcells(row_height, @left_column_padding, @right_column_padding)
738
+ cell.padded_truncated_subcells(row_height)
608
739
  end
609
740
  subrows = subcell_stacks.transpose.map do |subrow_components|
610
741
  @border_instance.join_cell_contents(subrow_components)
611
742
  end
612
743
 
613
- join_lines(subrows)
614
- end
615
-
616
- # @!visibility private
617
- def join_lines(lines)
618
- lines.join($/) # join strings with cross-platform newline
744
+ Util.join_lines(subrows)
619
745
  end
620
746
 
621
747
  # @!visibility private
@@ -633,13 +759,5 @@ module Tabulo
633
759
  c
634
760
  end
635
761
 
636
- # @!visibility private
637
- # @return [Integer] the length of the longest segment of str when split by newlines
638
- def wrapped_width(str)
639
- segments = str.split($/)
640
- segments.inject(1) do |longest_length_so_far, segment|
641
- Util.max(longest_length_so_far, Unicode::DisplayWidth.of(segment))
642
- end
643
- end
644
762
  end
645
763
  end