File: table.rb

package info (click to toggle)
ruby-terminal-table 3.0.2-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 224 kB
  • sloc: ruby: 874; makefile: 3
file content (372 lines) | stat: -rw-r--r-- 10,785 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
require 'unicode/display_width'

module Terminal
  class Table

    attr_reader :title
    attr_reader :headings

    ##
    # Generates a ASCII/Unicode table with the given _options_.

    def initialize options = {}, &block
      @elaborated = false
      @headings = []
      @rows = []
      @column_widths = []
      self.style = options.fetch :style, {}
      self.headings = options.fetch :headings, []
      self.rows = options.fetch :rows, []
      self.title = options.fetch :title, nil
      yield_or_eval(&block) if block

      style.on_change(:width) { require_column_widths_recalc }
    end

    ##
    # Align column _n_ to the given _alignment_ of :center, :left, or :right.

    def align_column n, alignment
      # nil forces the column method to return the cell itself
      column(n, nil).each do |cell|
        cell.alignment = alignment unless cell.alignment?
      end
    end

    ##
    # Add a row.

    def add_row array
      row = array == :separator ? Separator.new(self) : Row.new(self, array)
      @rows << row
      require_column_widths_recalc unless row.is_a?(Separator)
    end
    alias :<< :add_row

    ##
    # Add a separator.

    def add_separator(border_type: :div)
      @rows << Separator.new(self, border_type: border_type)
    end

    def cell_spacing
      cell_padding + style.border_y_width
    end

    def cell_padding
      style.padding_left + style.padding_right
    end

    ##
    # Return column _n_.

    def column n, method = :value, array = rows
      array.map { |row|
        # for each cells in a row, find the column with index
        # just greater than the required one, and go back one.
        index = col = 0
        row.cells.each do |cell|
          break if index > n
          index += cell.colspan
          col += 1
        end
        cell = row[col - 1]
        cell && method ? cell.__send__(method) : cell
      }.compact
    end

    ##
    # Return _n_ column including headings.

    def column_with_headings n, method = :value
      column n, method, headings_with_rows
    end

    ##
    # Return columns.

    def columns
      (0...number_of_columns).map { |n| column n }
    end

    ##
    # Return length of column _n_.

    def column_width n
      column_widths[n] || 0
    end
    alias length_of_column column_width # for legacy support

    ##
    # Return total number of columns available.

    def number_of_columns
      headings_with_rows.map { |r| r.number_of_columns }.max || 0
    end

    ##
    # Set the headings

    def headings= arrays
      arrays = [arrays] unless arrays.first.is_a?(Array)
      @headings = arrays.map do |array|
        row = Row.new(self, array)
        require_column_widths_recalc
        row
      end
    end

    ##
    # Elaborate rows to form an Array of Rows and Separators with adjacency properties added.
    #
    # This is separated from the String rendering so that certain features may be tweaked
    # before the String is built.

    def elaborate_rows

      buffer = style.border_top ? [Separator.new(self, border_type: :top, implicit: true)] : []
      unless @title.nil?
        buffer << Row.new(self, [title_cell_options])
        buffer << Separator.new(self, implicit: true)
      end
      @headings.each do |row|
        unless row.cells.empty?
          buffer << row
          buffer << Separator.new(self, border_type: :double, implicit: true)
        end
      end
      if style.all_separators
        @rows.each_with_index do |row, idx|
          # last separator is bottom, others are :div
          border_type = (idx == @rows.size - 1) ? :bot : :div
          buffer << row
          buffer << Separator.new(self, border_type: border_type, implicit: true)
        end
      else
        buffer += @rows
        buffer << Separator.new(self, border_type: :bot, implicit: true) if style.border_bottom
      end

      # After all implicit Separators are inserted we need to save off the
      # adjacent rows so that we can decide what type of intersections to use
      # based on column spans in the adjacent row(s).
      buffer.each_with_index do |r, idx|
        if r.is_a?(Separator)
          prev_row = idx > 0 ? buffer[idx - 1] : nil
          next_row = buffer.fetch(idx + 1, nil)
          r.save_adjacent_rows(prev_row, next_row)
        end
      end
      
      @elaborated = true
      @rows = buffer
    end

    ##
    # Render the table.

    def render
      elaborate_rows unless @elaborated
      @rows.map { |r| style.margin_left + r.render.rstrip }.join("\n")
    end
    alias :to_s :render

    ##
    # Return rows without separator rows.

    def rows
      @rows.reject { |row| row.is_a? Separator }
    end

    def rows= array
      @rows = []
      array.each { |arr| self << arr }
    end

    def style=(options)
      style.apply options
    end

    def style
      @style ||= Style.new
    end

    def title=(title)
      @title = title
      require_column_widths_recalc
    end

    ##
    # Check if _other_ is equal to self. _other_ is considered equal
    # if it contains the same headings and rows.

    def == other
      if other.respond_to? :render and other.respond_to? :rows
        self.headings == other.headings and self.rows == other.rows
      end
    end

    private

    def columns_width
      column_widths.inject(0) { |s, i| s + i + cell_spacing } + style.border_y_width
    end

    def recalc_column_widths
      @require_column_widths_recalc = false
      n_cols = number_of_columns
      space_width = cell_spacing
      return if n_cols == 0

      # prepare rows
      all_rows = headings_with_rows
      all_rows << Row.new(self, [title_cell_options]) unless @title.nil?

      # DP states, dp[colspan][index][split_offset] => column_width.
      dp = []

      # prepare initial value for DP.
      all_rows.each do |row|
        index = 0
        row.cells.each do |cell|
          cell_value = cell.value_for_column_width_recalc
          cell_width = Unicode::DisplayWidth.of(cell_value.to_s)
          colspan = cell.colspan

          # find column width from each single cell.
          dp[colspan] ||= []
          dp[colspan][index] ||= [0]        # add a fake cell with length 0.
          dp[colspan][index][colspan] ||= 0 # initialize column length to 0.

          # the last index `colspan` means width of the single column (split
          # at end of each column), not a width made up of multiple columns.
          single_column_length = [cell_width, dp[colspan][index][colspan]].max
          dp[colspan][index][colspan] = single_column_length

          index += colspan
        end
      end

      # run DP.
      (1..n_cols).each do |colspan|
        dp[colspan] ||= []
        (0..n_cols-colspan).each do |index|
          dp[colspan][index] ||= [1]
          (1...colspan).each do |offset|
            # processed level became reverse map from width => [offset, ...].
            left_colspan = offset
            left_index = index
            left_width = dp[left_colspan][left_index].keys.first

            right_colspan = colspan - left_colspan
            right_index = index + offset
            right_width = dp[right_colspan][right_index].keys.first

            dp[colspan][index][offset] = left_width + right_width + space_width
          end

          # reverse map it for resolution (max width and short offset first).
          rmap = {}
          dp[colspan][index].each_with_index do |width, offset|
            rmap[width] ||= []
            rmap[width] << offset
          end

          # sort reversely and store it back.
          dp[colspan][index] = Hash[rmap.sort.reverse]
        end
      end

      resolve = lambda do |colspan, full_width, index = 0|
        # stop if reaches the bottom level.
        return @column_widths[index] = full_width if colspan == 1

        # choose best split offset for partition, or second best result
        # if first one is not dividable.
        candidate_offsets = dp[colspan][index].collect(&:last).flatten
        offset = candidate_offsets[0]
        offset = candidate_offsets[1] if offset == colspan

        # prepare for next round.
        left_colspan = offset
        left_index = index
        left_width = dp[left_colspan][left_index].keys.first

        right_colspan = colspan - left_colspan
        right_index = index + offset
        right_width = dp[right_colspan][right_index].keys.first

        # calculate reference column width, give remaining spaces to left.
        total_non_space_width = full_width - (colspan - 1) * space_width
        ref_column_width = total_non_space_width / colspan
        remainder = total_non_space_width % colspan
        rem_left_width = [remainder, left_colspan].min
        rem_right_width = remainder - rem_left_width
        ref_left_width = ref_column_width * left_colspan +
                         (left_colspan - 1) * space_width + rem_left_width
        ref_right_width = ref_column_width * right_colspan +
                          (right_colspan - 1) * space_width + rem_right_width

        # at most one width can be greater than the reference width.
        if left_width <= ref_left_width and right_width <= ref_right_width
          # use refernce width (evenly partition).
          left_width = ref_left_width
          right_width = ref_right_width
        else
          # the wider one takes its value, shorter one takes the rest.
          if left_width > ref_left_width
            right_width = full_width - left_width - space_width
          else
            left_width = full_width - right_width - space_width
          end
        end

        # run next round.
        resolve.call(left_colspan, left_width, left_index)
        resolve.call(right_colspan, right_width, right_index)
      end

      full_width = dp[n_cols][0].keys.first
      unless style.width.nil?
        new_width = style.width - space_width - style.border_y_width
        if new_width < full_width
          raise "Table width exceeds wanted width " +
                "of #{style.width} characters."
        end
        full_width = new_width
      end

      resolve.call(n_cols, full_width)
    end

    ##
    # Return headings combined with rows.

    def headings_with_rows
      @headings + rows
    end

    def yield_or_eval &block
      return unless block
      if block.arity > 0
        yield self
      else
        self.instance_eval(&block)
      end
    end

    def title_cell_options
      {:value => @title, :alignment => :center, :colspan => number_of_columns}
    end

    def require_column_widths_recalc
      @require_column_widths_recalc = true
    end

    def column_widths
      recalc_column_widths if @require_column_widths_recalc
      @column_widths
    end
  end
end