railbow 0.3.0 → 0.5.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.
@@ -10,40 +10,46 @@ module Railbow
10
10
 
11
11
  RESET = "\e[0m"
12
12
  WHITE = "\e[97m"
13
- GHOST_BG = "\e[48;5;52m" # deep red/maroon background stands out as abnormal
13
+ GHOST_BG = "\e[48;5;52m" # deep red/maroon background - stands out as abnormal
14
14
  GHOST_FG = "\e[38;5;217m" # warm pink foreground for contrast
15
+ DIMMED_FG = "\e[38;5;242m" # muted grey - a row that is not currently in effect
15
16
 
16
17
  attr_reader :columns, :theme
17
18
 
18
- def initialize(columns:, theme:, compact: {}, aliases: {})
19
+ # min_widths raises the resolved width of each column to at least the
20
+ # given value, which is how several tables rendered in one run line their
21
+ # columns up with each other. An explicit compact maxw still wins.
22
+ def initialize(columns:, theme:, compact: {}, aliases: {}, min_widths: nil)
19
23
  @compact = compact
20
24
  @aliases = aliases
25
+ @min_widths = min_widths
21
26
  @reverse_col_aliases = aliases[:columns]&.invert || {}
22
27
  @columns = apply_hidden_columns(columns)
23
28
  @theme = theme
24
29
  end
25
30
 
26
- def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, tick_rows: Set.new, tick_col: nil)
27
- return "" if columns.empty?
31
+ # The widths this table would resolve to on its own. Callers rendering
32
+ # several tables together take the per-column maximum and feed it back as
33
+ # min_widths.
34
+ def column_widths(rows)
35
+ return [] if columns.empty?
28
36
 
29
- # Remap rows if columns were hidden
30
- rows = remap_rows(rows) if @hidden_indices&.any?
37
+ resolve_widths(prepare_rows(rows))
38
+ end
31
39
 
32
- # Apply value aliases
33
- rows = apply_value_aliases(rows) if @aliases[:values]&.any?
40
+ # Width of everything left of the last column. The last column flexes to
41
+ # the terminal, so this is where furniture drawn around the table (a
42
+ # section rule, say) can stop without running past it.
43
+ def fixed_width(widths)
44
+ return 0 if columns.empty? || widths.nil? || widths.empty?
34
45
 
35
- # Pre-truncate non-last columns that have truncate + max_width
36
- rows = rows.map { |row|
37
- row.each_with_index.map { |cell, i|
38
- col = columns[i]
39
- if col&.truncate && col.max_width && i < columns.size - 1
40
- truncate_str(cell.to_s, col.max_width)
41
- else
42
- cell
43
- end
44
- }
45
- }
46
+ compute_prefix_width(widths, columns.size - 1)
47
+ end
48
+
49
+ def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
50
+ return "" if columns.empty?
46
51
 
52
+ rows = prepare_rows(rows)
47
53
  resolved = resolve_widths(rows)
48
54
 
49
55
  # In oneline mode, truncate non-sticky non-last columns at resolved width
@@ -64,12 +70,11 @@ module Railbow
64
70
  rows.each_with_index do |row, i|
65
71
  tc = (tick_rows.include?(i) && tick_col) ? tick_col : nil
66
72
  if separators.key?(i) && theme.format_separator
67
- sep_row = Array.new(columns.size, "")
68
- sep_row[1] = theme.format_separator.call(separators[i]) if columns.size > 1
69
- lines << render_row(sep_row, resolved, tick_col: tc, tick_cross: true)
73
+ lines << render_separator_row(separators[i], resolved, tick_col: tc)
70
74
  tc = nil # tick already shown on separator row
71
75
  end
72
- formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i), ghost: ghost_rows.include?(i))
76
+ formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i),
77
+ ghost: ghost_rows.include?(i), dim: dim_rows.include?(i))
73
78
  lines << formatted
74
79
  end
75
80
  lines.join("\n")
@@ -77,6 +82,25 @@ module Railbow
77
82
 
78
83
  private
79
84
 
85
+ # Everything that changes a cell's content, and therefore its width,
86
+ # before widths are resolved: hidden columns, value aliases, and the
87
+ # pre-truncation of non-last columns that cap themselves.
88
+ def prepare_rows(rows)
89
+ rows = remap_rows(rows) if @hidden_indices&.any?
90
+ rows = apply_value_aliases(rows) if @aliases[:values]&.any?
91
+
92
+ rows.map { |row|
93
+ row.each_with_index.map { |cell, i|
94
+ col = columns[i]
95
+ if col&.truncate && col.max_width && i < columns.size - 1
96
+ truncate_str(cell.to_s, col.max_width)
97
+ else
98
+ cell
99
+ end
100
+ }
101
+ }
102
+ end
103
+
80
104
  def resolve_widths(rows)
81
105
  all_rows = rows
82
106
  last = columns.size - 1
@@ -95,6 +119,9 @@ module Railbow
95
119
  w = [w, col.min_width].max if col.min_width
96
120
  w = [w, col.max_width].min if col.max_width
97
121
  end
122
+ # Alignment across tables raises the width; an explicit maxw caps it
123
+ # afterwards, so the user's cap stays authoritative.
124
+ w = [w, @min_widths[i].to_i].max if @min_widths && i != last && w > 0
98
125
  w = [w, global_maxw].min if global_maxw && i != last && w > 0
99
126
  w
100
127
  end
@@ -112,44 +139,65 @@ module Railbow
112
139
  }.join(theme.header_col_separator)
113
140
  end
114
141
 
115
- def render_row(row, widths, tick_col: nil, tick_cross: false, highlight: false, ghost: false)
142
+ # A separator row is furniture, not data: the walls are drawn empty and the
143
+ # label is laid over them starting at the second column. Writing over the
144
+ # blanks rather than filling a cell means a label wider than its column
145
+ # spills into the empty space to its right instead of shifting the row.
146
+ def render_separator_row(label, widths, tick_col: nil)
147
+ pad = effective_padding
148
+ cells = widths.map { |w| "#{pad}#{" " * w}#{pad}" }
149
+
150
+ skeleton = cells.first.to_s.dup
151
+ (1...cells.size).each do |i|
152
+ skeleton << "#{separator_at(i - 1, tick_col, cross: true)}#{cells[i]}"
153
+ end
154
+ return skeleton if cells.size < 2 || label.nil? || label.empty?
155
+
156
+ offset = display_width(cells[0]) + display_width(separator_at(0, tick_col, cross: true)) + display_width(pad)
157
+ tail = skeleton[(offset + display_width(label))..] || ""
158
+ "#{skeleton[0, offset]}#{theme.format_separator.call(label)}#{tail}"
159
+ end
160
+
161
+ # The separator character between column index and index + 1. Columns
162
+ # flanking the tick column get the tick variant so the week line reads
163
+ # as one continuous rule.
164
+ def separator_at(index, tick_col, cross: false)
165
+ flanks_tick = tick_col && (index == tick_col - 1 || index == tick_col)
166
+ return theme.col_separator unless flanks_tick
167
+
168
+ cross ? theme.tick_cross_separator : theme.tick_separator
169
+ end
170
+
171
+ def render_row(row, widths, tick_col: nil, highlight: false, ghost: false, dim: false)
116
172
  last = columns.size - 1
117
173
  pad = effective_padding
118
- default_sep = theme.col_separator
119
- tick_sep = tick_cross ? theme.tick_cross_separator : theme.tick_separator
120
174
 
121
175
  prefix_parts = row[0...last].each_with_index.map { |cell, i|
122
176
  s = cell.to_s
123
177
  cell_w = display_width(strip_ansi(s))
124
178
  padding = " " * [widths[i] - cell_w, 0].max
125
179
  content = (columns[i].align == :right) ? "#{padding}#{s}" : "#{s}#{padding}"
126
- if ghost
127
- content = "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
128
- elsif highlight
129
- content = "#{WHITE}#{content}#{RESET}"
130
- end
180
+ content = style_cell(content, highlight: highlight, ghost: ghost, dim: dim, accent: columns[i].accent)
131
181
  "#{pad}#{content}#{RESET}#{pad}"
132
182
  }
133
183
 
134
- # Join prefix parts with per-position separators
135
- # Loop index i joins column i-1 and column i (separator_index = i-1)
136
- # For tick_col, flanking separator indices are tick_col-1 and tick_col
137
- prefix = prefix_parts.first.to_s
184
+ # Join prefix parts with per-position separators.
185
+ # Loop index i joins column i-1 and column i (separator index = i-1).
186
+ # dup: appending to prefix_parts.first itself would corrupt the parts
187
+ # that render_last_cell measures for the wrapped-line indent.
188
+ prefix = prefix_parts.first.to_s.dup
138
189
  (1...prefix_parts.size).each do |i|
139
- sep_idx = i - 1
140
- sep = (tick_col && (sep_idx == tick_col - 1 || sep_idx == tick_col)) ? tick_sep : default_sep
141
- prefix << "#{sep}#{prefix_parts[i]}"
190
+ prefix << "#{separator_at(i - 1, tick_col)}#{prefix_parts[i]}"
142
191
  end
143
192
 
144
193
  last_cell_raw = row[last].to_s
145
194
 
146
195
  # Separator before the last column has index (last - 1)
147
- last_sep_idx = last - 1
148
- last_sep = (tick_col && (last_sep_idx == tick_col - 1 || last_sep_idx == tick_col)) ? tick_sep : default_sep
149
- render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost)
196
+ last_sep = separator_at(last - 1, tick_col)
197
+ render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost, dim: dim)
150
198
  end
151
199
 
152
- def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false)
200
+ def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false, dim: false)
153
201
  pad = effective_padding
154
202
  sep = col_sep || theme.col_separator
155
203
 
@@ -167,7 +215,7 @@ module Railbow
167
215
  if columns[last].truncate_fn && last_col_max &&
168
216
  display_width(last_cell_plain) > last_col_max
169
217
  last_cell_raw = columns[last].truncate_fn.call(last_cell_raw, last_col_max)
170
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
218
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
171
219
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
172
220
  end
173
221
 
@@ -175,18 +223,18 @@ module Railbow
175
223
  if columns[last].truncate && !columns[last].max_width && last_col_max &&
176
224
  display_width(last_cell_plain) > last_col_max
177
225
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
178
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
226
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
179
227
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
180
228
  end
181
229
 
182
230
  # In oneline mode, truncate instead of wrapping
183
231
  if @compact[:oneline] && last_col_max && display_width(last_cell_plain) > last_col_max
184
232
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
185
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
233
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
186
234
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
187
235
  end
188
236
 
189
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
237
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
190
238
 
191
239
  if last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
192
240
  blank_prefix = prefix_parts.map { |part|
@@ -201,9 +249,17 @@ module Railbow
201
249
  end
202
250
  end
203
251
 
204
- def style_cell(content, highlight: false, ghost: false)
252
+ def style_cell(content, highlight: false, ghost: false, dim: false, accent: false)
205
253
  if ghost
206
254
  "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
255
+ elsif dim
256
+ # An accent column keeps its color - on a dimmed row it is the one
257
+ # cell still carrying meaning. Everything else drops its own colors:
258
+ # one flat grey is what reads as "not in effect", and the resets that
259
+ # end each inner color would break the grey run anyway.
260
+ return content if accent
261
+
262
+ "#{DIMMED_FG}#{strip_ansi(content)}#{RESET}"
207
263
  elsif highlight
208
264
  "#{WHITE}#{content}#{RESET}"
209
265
  else
@@ -315,7 +371,7 @@ module Railbow
315
371
  current << token
316
372
  current_width += token_width
317
373
  elsif current_width.zero?
318
- # Single token wider than max try to break on underscores
374
+ # Single token wider than max - try to break on underscores
319
375
  broken = break_long_token(token, max_width)
320
376
  lines.concat(broken[0...-1])
321
377
  current = +broken.last
@@ -400,6 +456,8 @@ module Railbow
400
456
  # Build column index → value alias map
401
457
  col_map = {}
402
458
  columns.each_with_index do |col, i|
459
+ next unless col.aliased
460
+
403
461
  label = col.label
404
462
  col_map[i] = value_aliases[label] if value_aliases[label]
405
463
  # Also look up by original name if column was renamed by alias
@@ -8,7 +8,8 @@ module Railbow
8
8
  :format_header_cell, :format_separator
9
9
 
10
10
  def initialize(col_separator:, header_col_separator:, cell_padding:,
11
- format_header_cell:, format_separator: nil, tick_separator: nil, tick_cross_separator: nil)
11
+ format_header_cell:, format_separator: nil,
12
+ tick_separator: nil, tick_cross_separator: nil)
12
13
  @col_separator = col_separator
13
14
  @tick_separator = tick_separator || col_separator
14
15
  @tick_cross_separator = tick_cross_separator || @tick_separator
@@ -23,7 +24,7 @@ module Railbow
23
24
  RESET = "\e[0m"
24
25
  BOLD = "\e[1m"
25
26
  WHITE = "\e[97m"
26
- PURPLE = "\e[38;5;141m"
27
+ PURPLE = "\e[38;5;97m" # separator rows: present, but never louder than the data
27
28
  BG_PURPLE = "\e[48;5;99m"
28
29
  DIM = "\e[2m"
29
30