railbow 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,38 +14,67 @@ module Railbow
14
14
  GHOST_FG = "\e[38;5;217m" # warm pink foreground for contrast
15
15
  DIMMED_FG = "\e[38;5;242m" # muted grey - a row that is not currently in effect
16
16
 
17
+ # The flexing last column never gets less than this from the terminal.
18
+ MIN_LAST_COL = 10
19
+ # Default floor for shrinkable columns that name no shrink_floor.
20
+ MIN_SHRINK_WIDTH = 16
21
+
17
22
  attr_reader :columns, :theme
18
23
 
19
- def initialize(columns:, theme:, compact: {}, aliases: {})
24
+ # min_widths raises the resolved width of each column to at least the
25
+ # given value, which is how several tables rendered in one run line their
26
+ # columns up with each other. An explicit compact maxw still wins.
27
+ #
28
+ # term_width overrides the detected terminal width; render re-invokes
29
+ # itself through a reduced renderer when the budget drops a column, and
30
+ # the width it fits must be the same one this instance measured.
31
+ def initialize(columns:, theme:, compact: {}, aliases: {}, min_widths: nil, term_width: nil)
20
32
  @compact = compact
21
33
  @aliases = aliases
34
+ @min_widths = min_widths
35
+ @term_width = term_width
22
36
  @reverse_col_aliases = aliases[:columns]&.invert || {}
23
37
  @columns = apply_hidden_columns(columns)
24
38
  @theme = theme
25
39
  end
26
40
 
27
- def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
28
- return "" if columns.empty?
41
+ # The widths this table would resolve to on its own. Callers rendering
42
+ # several tables together take the per-column maximum and feed it back as
43
+ # min_widths.
44
+ def column_widths(rows)
45
+ return [] if columns.empty?
29
46
 
30
- # Remap rows if columns were hidden
31
- rows = remap_rows(rows) if @hidden_indices&.any?
47
+ resolve_widths(prepare_rows(rows))
48
+ end
32
49
 
33
- # Apply value aliases
34
- rows = apply_value_aliases(rows) if @aliases[:values]&.any?
50
+ # Width of everything left of the last column. The last column flexes to
51
+ # the terminal, so this is where furniture drawn around the table (a
52
+ # section rule, say) can stop without running past it.
53
+ def fixed_width(widths)
54
+ return 0 if columns.empty? || widths.nil? || widths.empty?
35
55
 
36
- # Pre-truncate non-last columns that have truncate + max_width
37
- rows = rows.map { |row|
38
- row.each_with_index.map { |cell, i|
39
- col = columns[i]
40
- if col&.truncate && col.max_width && i < columns.size - 1
41
- truncate_str(cell.to_s, col.max_width)
42
- else
43
- cell
44
- end
45
- }
46
- }
56
+ compute_prefix_width(widths, columns.size - 1)
57
+ end
58
+
59
+ def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
60
+ return "" if columns.empty?
61
+
62
+ rows = prepare_rows(rows)
63
+
64
+ # Width budget: when even fully shrunk columns cannot bring the table
65
+ # under the terminal width, sacrifice the most expendable column and
66
+ # render without it. One column per pass - the reduced renderer asks
67
+ # again with what is left.
68
+ if (drop = drop_index_to_fit(rows))
69
+ return dropped_renderer(drop).render(
70
+ rows.map { |row| row.reject.with_index { |_, i| i == drop } },
71
+ separators: separators, highlight_rows: highlight_rows, ghost_rows: ghost_rows,
72
+ dim_rows: dim_rows, tick_rows: tick_rows, tick_col: shift_tick_col(tick_col, drop)
73
+ )
74
+ end
47
75
 
48
76
  resolved = resolve_widths(rows)
77
+ shrink_to_fit!(resolved, rows)
49
78
 
50
79
  # In oneline mode, truncate non-sticky non-last columns at resolved width
51
80
  if @compact[:oneline]
@@ -77,6 +106,25 @@ module Railbow
77
106
 
78
107
  private
79
108
 
109
+ # Everything that changes a cell's content, and therefore its width,
110
+ # before widths are resolved: hidden columns, value aliases, and the
111
+ # pre-truncation of non-last columns that cap themselves.
112
+ def prepare_rows(rows)
113
+ rows = remap_rows(rows) if @hidden_indices&.any?
114
+ rows = apply_value_aliases(rows) if @aliases[:values]&.any?
115
+
116
+ rows.map { |row|
117
+ row.each_with_index.map { |cell, i|
118
+ col = columns[i]
119
+ if col&.truncate && col.max_width && i < columns.size - 1
120
+ truncate_str(cell.to_s, col.max_width)
121
+ else
122
+ cell
123
+ end
124
+ }
125
+ }
126
+ end
127
+
80
128
  def resolve_widths(rows)
81
129
  all_rows = rows
82
130
  last = columns.size - 1
@@ -95,6 +143,9 @@ module Railbow
95
143
  w = [w, col.min_width].max if col.min_width
96
144
  w = [w, col.max_width].min if col.max_width
97
145
  end
146
+ # Alignment across tables raises the width; an explicit maxw caps it
147
+ # afterwards, so the user's cap stays authoritative.
148
+ w = [w, @min_widths[i].to_i].max if @min_widths && i != last && w > 0
98
149
  w = [w, global_maxw].min if global_maxw && i != last && w > 0
99
150
  w
100
151
  end
@@ -182,7 +233,7 @@ module Railbow
182
233
  last_cell_plain = strip_ansi(last_cell_raw)
183
234
  term_w = terminal_width
184
235
  prefix_width = compute_prefix_width(widths, last)
185
- last_col_max = term_w ? [term_w - prefix_width - display_width(pad), 10].max : nil
236
+ last_col_max = term_w ? [term_w - prefix_width - display_width(pad), MIN_LAST_COL].max : nil
186
237
 
187
238
  # Use custom truncate_fn if available (e.g. table tags with +N)
188
239
  if columns[last].truncate_fn && last_col_max &&
@@ -278,12 +329,98 @@ module Railbow
278
329
  end
279
330
 
280
331
  def terminal_width
332
+ return @term_width if @term_width
281
333
  return $stdout.winsize[1] if $stdout.respond_to?(:winsize) && $stdout.tty?
282
334
  nil
283
335
  rescue
284
336
  nil
285
337
  end
286
338
 
339
+ # --- Width budget ---
340
+
341
+ # The index of the column to sacrifice, or nil while shrinking alone can
342
+ # still fit the table into the terminal.
343
+ def drop_index_to_fit(rows)
344
+ return nil unless terminal_width
345
+ return nil if rows.empty?
346
+
347
+ candidate = columns.each_index
348
+ .select { |i| columns[i].droppable }
349
+ .min_by { |i| columns[i].droppable }
350
+ return nil unless candidate
351
+ return nil if required_width(fully_shrunk_widths(rows), rows) <= terminal_width
352
+
353
+ candidate
354
+ end
355
+
356
+ def dropped_renderer(drop)
357
+ self.class.new(
358
+ columns: columns.reject.with_index { |_, i| i == drop },
359
+ theme: theme,
360
+ compact: @compact,
361
+ aliases: @aliases,
362
+ min_widths: @min_widths&.reject&.with_index { |_, i| i == drop },
363
+ term_width: terminal_width
364
+ )
365
+ end
366
+
367
+ def shift_tick_col(tick_col, drop)
368
+ return nil if tick_col.nil? || drop == tick_col
369
+
370
+ (drop < tick_col) ? tick_col - 1 : tick_col
371
+ end
372
+
373
+ # Narrows shrinkable columns just enough to close the overflow, and
374
+ # re-truncates their cells to the new width.
375
+ def shrink_to_fit!(widths, rows)
376
+ return if rows.empty? || !terminal_width
377
+
378
+ overflow = required_width(widths, rows) - terminal_width
379
+ return if overflow <= 0
380
+
381
+ last = columns.size - 1
382
+ columns.each_with_index do |col, i|
383
+ break if overflow <= 0
384
+ next if i == last || !col.shrinkable
385
+
386
+ cut = [widths[i] - shrink_floor(col), overflow].min
387
+ next if cut <= 0
388
+
389
+ widths[i] -= cut
390
+ overflow -= cut
391
+ rows.each { |row| row[i] = truncate_ansi(row[i].to_s, widths[i]) }
392
+ end
393
+ end
394
+
395
+ # Never below the header label: a column narrower than its own header
396
+ # would push the header row out of alignment.
397
+ def shrink_floor(col)
398
+ [col.shrink_floor || MIN_SHRINK_WIDTH, display_width(effective_label(col))].max
399
+ end
400
+
401
+ def fully_shrunk_widths(rows)
402
+ widths = resolve_widths(rows)
403
+ last = columns.size - 1
404
+ columns.each_with_index do |col, i|
405
+ next if i == last || !col.shrinkable
406
+
407
+ widths[i] = [widths[i], shrink_floor(col)].min
408
+ end
409
+ widths
410
+ end
411
+
412
+ # What the table needs from the terminal: every fixed column plus a
413
+ # reserve for the flexing last column, which truncates down to
414
+ # MIN_LAST_COL but never below it. The last column's header label counts
415
+ # too - it is drawn as-is, so a label wider than every cell would poke
416
+ # past the terminal edge otherwise.
417
+ def required_width(widths, rows)
418
+ last = columns.size - 1
419
+ last_w = rows.map { |row| display_width(strip_ansi(row[last].to_s)) }.max || 0
420
+ last_w = [last_w, display_width(effective_label(columns[last]))].max unless @compact[:noheader]
421
+ compute_prefix_width(widths, last) + [last_w, MIN_LAST_COL].min + display_width(effective_padding)
422
+ end
423
+
287
424
  def ansi_word_wrap(str, max_width)
288
425
  plain = strip_ansi(str)
289
426
  plain_lines = word_wrap(plain, max_width)
@@ -429,6 +566,8 @@ module Railbow
429
566
  # Build column index → value alias map
430
567
  col_map = {}
431
568
  columns.each_with_index do |col, i|
569
+ next unless col.aliased
570
+
432
571
  label = col.label
433
572
  col_map[i] = value_aliases[label] if value_aliases[label]
434
573
  # Also look up by original name if column was renamed by alias