railbow 0.1.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.
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require_relative "../text_utils"
5
+
6
+ module Railbow
7
+ module Table
8
+ class Renderer
9
+ include TextUtils
10
+
11
+ RESET = "\e[0m"
12
+ WHITE = "\e[97m"
13
+ GHOST_BG = "\e[48;5;52m" # deep red/maroon background — stands out as abnormal
14
+ GHOST_FG = "\e[38;5;217m" # warm pink foreground for contrast
15
+
16
+ attr_reader :columns, :theme
17
+
18
+ def initialize(columns:, theme:, compact: {}, aliases: {})
19
+ @compact = compact
20
+ @aliases = aliases
21
+ @reverse_col_aliases = aliases[:columns]&.invert || {}
22
+ @columns = apply_hidden_columns(columns)
23
+ @theme = theme
24
+ end
25
+
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?
28
+
29
+ # Remap rows if columns were hidden
30
+ rows = remap_rows(rows) if @hidden_indices&.any?
31
+
32
+ # Apply value aliases
33
+ rows = apply_value_aliases(rows) if @aliases[:values]&.any?
34
+
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
+
47
+ resolved = resolve_widths(rows)
48
+
49
+ # In oneline mode, truncate non-sticky non-last columns at resolved width
50
+ if @compact[:oneline]
51
+ rows = rows.map { |row|
52
+ row.each_with_index.map { |cell, i|
53
+ col = columns[i]
54
+ if col && i < columns.size - 1 && !col.sticky && !col.truncate
55
+ truncate_str(cell.to_s, resolved[i])
56
+ else
57
+ cell
58
+ end
59
+ }
60
+ }
61
+ end
62
+ lines = []
63
+ lines << render_header(resolved) unless @compact[:noheader]
64
+ rows.each_with_index do |row, i|
65
+ tc = (tick_rows.include?(i) && tick_col) ? tick_col : nil
66
+ 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)
70
+ tc = nil # tick already shown on separator row
71
+ end
72
+ formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i), ghost: ghost_rows.include?(i))
73
+ lines << formatted
74
+ end
75
+ lines.join("\n")
76
+ end
77
+
78
+ private
79
+
80
+ def resolve_widths(rows)
81
+ all_rows = rows
82
+ last = columns.size - 1
83
+ global_maxw = @compact[:maxw]
84
+
85
+ columns.each_with_index.map do |col, i|
86
+ if col.fixed?
87
+ w = col.width
88
+ elsif i == last
89
+ # Last column: don't pad, will be wrapped if it overflows
90
+ w = 0
91
+ else
92
+ header_w = display_width(effective_label(col))
93
+ content_w = all_rows.map { |row| display_width(strip_ansi(row[i].to_s)) }.max || 0
94
+ w = [header_w, content_w].max
95
+ w = [w, col.min_width].max if col.min_width
96
+ w = [w, col.max_width].min if col.max_width
97
+ end
98
+ w = [w, global_maxw].min if global_maxw && i != last && w > 0
99
+ w
100
+ end
101
+ end
102
+
103
+ def render_header(widths)
104
+ last = columns.size - 1
105
+ pad = effective_padding
106
+
107
+ columns.each_with_index.map { |col, i|
108
+ text = effective_label(col)
109
+ padding = (i == last) ? "" : " " * [widths[i] - display_width(text), 0].max
110
+ cell = theme.format_header_cell.call("#{text}#{padding}", padding)
111
+ "#{pad}#{cell}#{pad}"
112
+ }.join(theme.header_col_separator)
113
+ end
114
+
115
+ def render_row(row, widths, tick_col: nil, tick_cross: false, highlight: false, ghost: false)
116
+ last = columns.size - 1
117
+ pad = effective_padding
118
+ default_sep = theme.col_separator
119
+ tick_sep = tick_cross ? theme.tick_cross_separator : theme.tick_separator
120
+
121
+ prefix_parts = row[0...last].each_with_index.map { |cell, i|
122
+ s = cell.to_s
123
+ cell_w = display_width(strip_ansi(s))
124
+ padding = " " * [widths[i] - cell_w, 0].max
125
+ 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
131
+ "#{pad}#{content}#{RESET}#{pad}"
132
+ }
133
+
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
138
+ (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]}"
142
+ end
143
+
144
+ last_cell_raw = row[last].to_s
145
+
146
+ # 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)
150
+ end
151
+
152
+ def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false)
153
+ pad = effective_padding
154
+ sep = col_sep || theme.col_separator
155
+
156
+ # Truncate if configured via column settings
157
+ if columns[last].truncate && columns[last].max_width
158
+ last_cell_raw = truncate_str(last_cell_raw, columns[last].max_width)
159
+ end
160
+
161
+ last_cell_plain = strip_ansi(last_cell_raw)
162
+ term_w = terminal_width
163
+ prefix_width = compute_prefix_width(widths, last)
164
+ last_col_max = term_w ? [term_w - prefix_width - display_width(pad), 10].max : nil
165
+
166
+ # Use custom truncate_fn if available (e.g. table tags with +N)
167
+ if columns[last].truncate_fn && last_col_max &&
168
+ display_width(last_cell_plain) > last_col_max
169
+ 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)
171
+ return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
172
+ end
173
+
174
+ # Truncate to terminal width (by whole words) instead of wrapping
175
+ if columns[last].truncate && !columns[last].max_width && last_col_max &&
176
+ display_width(last_cell_plain) > last_col_max
177
+ 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)
179
+ return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
180
+ end
181
+
182
+ # In oneline mode, truncate instead of wrapping
183
+ if @compact[:oneline] && last_col_max && display_width(last_cell_plain) > last_col_max
184
+ 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)
186
+ return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
187
+ end
188
+
189
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
190
+
191
+ if last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
192
+ blank_prefix = prefix_parts.map { |part|
193
+ " " * display_width(strip_ansi(part))
194
+ }.join(sep)
195
+
196
+ wrapped = ansi_word_wrap(last_cell_raw, last_col_max)
197
+ "#{prefix}#{sep}#{pad}#{wrapped.first}#{RESET}#{pad}\n" +
198
+ wrapped[1..].map { |line| "#{blank_prefix}#{sep}#{pad}#{line}#{RESET}#{pad}" }.join("\n")
199
+ else
200
+ "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
201
+ end
202
+ end
203
+
204
+ def style_cell(content, highlight: false, ghost: false)
205
+ if ghost
206
+ "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
207
+ elsif highlight
208
+ "#{WHITE}#{content}#{RESET}"
209
+ else
210
+ content
211
+ end
212
+ end
213
+
214
+ def compute_prefix_width(widths, last)
215
+ pad_w = display_width(effective_padding)
216
+ sep_w = display_width(theme.col_separator)
217
+ # Each non-last column: pad + content + pad, joined by separator
218
+ total = 0
219
+ (0...last).each do |i|
220
+ total += pad_w + widths[i] + pad_w
221
+ end
222
+ # Separators between columns + trailing separator before last column
223
+ total += sep_w * last
224
+ # Plus pad on last column
225
+ total += pad_w
226
+ total
227
+ end
228
+
229
+ def truncate_by_words(str, max_width)
230
+ return str if display_width(strip_ansi(str)) <= max_width
231
+
232
+ segments = str.scan(/\S+\s*/)
233
+ result = +""
234
+ width = 0
235
+
236
+ segments.each do |seg|
237
+ seg_plain = strip_ansi(seg)
238
+ seg_width = display_width(seg_plain)
239
+ if width + seg_width + 3 > max_width && width > 0
240
+ result.rstrip!
241
+ result << "..."
242
+ return result
243
+ end
244
+ result << seg
245
+ width += seg_width
246
+ end
247
+
248
+ result
249
+ end
250
+
251
+ def terminal_width
252
+ return $stdout.winsize[1] if $stdout.respond_to?(:winsize) && $stdout.tty?
253
+ nil
254
+ rescue
255
+ nil
256
+ end
257
+
258
+ def ansi_word_wrap(str, max_width)
259
+ plain = strip_ansi(str)
260
+ plain_lines = word_wrap(plain, max_width)
261
+
262
+ result = []
263
+ pos = 0
264
+ last_color = nil
265
+ plain_lines.each do |plain_line|
266
+ target = plain_line.lstrip
267
+ line = +""
268
+ line << last_color if last_color
269
+ visible_consumed = 0
270
+ skipping_leading = true
271
+
272
+ while pos < str.length && visible_consumed < display_width(target)
273
+ if str[pos] == "\e"
274
+ esc_end = str.index("m", pos) || pos
275
+ code = str[pos..esc_end]
276
+ line << code
277
+ last_color = (code == RESET) ? nil : code
278
+ pos = esc_end + 1
279
+ else
280
+ ch = str[pos]
281
+ unless skipping_leading && ch.match?(/\s/) && visible_consumed == 0
282
+ skipping_leading = false
283
+ line << ch
284
+ visible_consumed += display_width(ch)
285
+ end
286
+ pos += 1
287
+ end
288
+ end
289
+
290
+ while pos < str.length && str[pos] == "\e"
291
+ esc_end = str.index("m", pos) || pos
292
+ code = str[pos..esc_end]
293
+ line << code
294
+ last_color = (code == RESET) ? nil : code
295
+ pos = esc_end + 1
296
+ end
297
+
298
+ result << line
299
+ end
300
+
301
+ result
302
+ end
303
+
304
+ def word_wrap(str, max_width)
305
+ return [str] if display_width(str) <= max_width
306
+
307
+ lines = []
308
+ current = +""
309
+ current_width = 0
310
+
311
+ str.split(/(\s+)/).each do |token|
312
+ token_width = display_width(token)
313
+
314
+ if current_width + token_width <= max_width
315
+ current << token
316
+ current_width += token_width
317
+ elsif current_width.zero?
318
+ # Single token wider than max — try to break on underscores
319
+ broken = break_long_token(token, max_width)
320
+ lines.concat(broken[0...-1])
321
+ current = +broken.last
322
+ current_width = display_width(current)
323
+ else
324
+ lines << current.rstrip
325
+ token = token.lstrip
326
+ current = +token
327
+ current_width = display_width(token)
328
+ end
329
+ end
330
+
331
+ lines << current.rstrip unless current.strip.empty?
332
+ lines
333
+ end
334
+
335
+ def break_long_token(token, max_width)
336
+ return [token] unless token.include?("_")
337
+
338
+ parts = token.split(/(?<=_)/) # split keeping _ at end of each part
339
+ lines = []
340
+ current = +""
341
+ current_width = 0
342
+
343
+ parts.each do |part|
344
+ part_width = display_width(part)
345
+ if current_width + part_width <= max_width
346
+ current << part
347
+ current_width += part_width
348
+ elsif current_width.zero?
349
+ lines << part
350
+ else
351
+ lines << current
352
+ current = +part
353
+ current_width = part_width
354
+ end
355
+ end
356
+
357
+ lines << current unless current.empty?
358
+ lines.empty? ? [token] : lines
359
+ end
360
+
361
+ # --- Compact support ---
362
+
363
+ def effective_padding
364
+ @compact[:dense] ? "" : theme.cell_padding
365
+ end
366
+
367
+ def effective_label(col)
368
+ col_aliases = @aliases[:columns]
369
+ return col.label unless col_aliases
370
+
371
+ col_aliases[col.label] || col.label
372
+ end
373
+
374
+ def apply_hidden_columns(columns)
375
+ hidden = @compact[:hidden_columns]
376
+ return columns unless hidden&.any?
377
+
378
+ @hidden_indices = []
379
+ filtered = []
380
+ columns.each_with_index do |col, i|
381
+ if hidden.any? { |h| h.downcase == col.label.downcase }
382
+ @hidden_indices << i
383
+ else
384
+ filtered << col
385
+ end
386
+ end
387
+ filtered
388
+ end
389
+
390
+ def remap_rows(rows)
391
+ rows.map do |row|
392
+ row.each_with_index.reject { |_, i| @hidden_indices.include?(i) }.map(&:first)
393
+ end
394
+ end
395
+
396
+ def apply_value_aliases(rows)
397
+ value_aliases = @aliases[:values]
398
+ return rows unless value_aliases&.any?
399
+
400
+ # Build column index → value alias map
401
+ col_map = {}
402
+ columns.each_with_index do |col, i|
403
+ label = col.label
404
+ col_map[i] = value_aliases[label] if value_aliases[label]
405
+ # Also look up by original name if column was renamed by alias
406
+ original = @reverse_col_aliases[label]
407
+ col_map[i] = value_aliases[original] if original && value_aliases[original]
408
+ end
409
+
410
+ return rows if col_map.empty?
411
+
412
+ rows.map do |row|
413
+ row.each_with_index.map do |cell, i|
414
+ aliases_for_col = col_map[i]
415
+ if aliases_for_col
416
+ apply_cell_alias(cell.to_s, aliases_for_col)
417
+ else
418
+ cell
419
+ end
420
+ end
421
+ end
422
+ end
423
+
424
+ def apply_cell_alias(cell, aliases_for_col)
425
+ plain = strip_ansi(cell)
426
+ # Try exact match first, then prefix match for cells with appended indicators
427
+ replacement = aliases_for_col[plain]
428
+ if replacement
429
+ cell.sub(plain) { replacement }
430
+ else
431
+ key = aliases_for_col.keys.find { |k| plain.start_with?(k) && plain[k.length..] =~ /\A\s/ }
432
+ if key
433
+ cell.sub(key) { aliases_for_col[key] }
434
+ else
435
+ cell
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railbow
4
+ module Table
5
+ class Theme
6
+ attr_reader :col_separator, :tick_separator, :tick_cross_separator,
7
+ :header_col_separator, :cell_padding,
8
+ :format_header_cell, :format_separator
9
+
10
+ def initialize(col_separator:, header_col_separator:, cell_padding:,
11
+ format_header_cell:, format_separator: nil, tick_separator: nil, tick_cross_separator: nil)
12
+ @col_separator = col_separator
13
+ @tick_separator = tick_separator || col_separator
14
+ @tick_cross_separator = tick_cross_separator || @tick_separator
15
+ @header_col_separator = header_col_separator
16
+ @cell_padding = cell_padding
17
+ @format_header_cell = format_header_cell
18
+ @format_separator = format_separator
19
+ end
20
+ end
21
+
22
+ module Themes
23
+ RESET = "\e[0m"
24
+ BOLD = "\e[1m"
25
+ WHITE = "\e[97m"
26
+ PURPLE = "\e[38;5;141m"
27
+ BG_PURPLE = "\e[48;5;99m"
28
+ DIM = "\e[2m"
29
+
30
+ WALLS = Theme.new(
31
+ col_separator: "\u2502",
32
+ tick_separator: "\u252c",
33
+ tick_cross_separator: "\u253c",
34
+ header_col_separator: " ",
35
+ cell_padding: " ",
36
+ format_header_cell: ->(text, _padding) {
37
+ "#{BG_PURPLE}#{BOLD}#{WHITE}#{text}#{RESET}"
38
+ },
39
+ format_separator: ->(label) {
40
+ "#{PURPLE}#{label}#{RESET}"
41
+ }
42
+ )
43
+
44
+ PLAIN = Theme.new(
45
+ col_separator: " ",
46
+ header_col_separator: " ",
47
+ cell_padding: "",
48
+ format_header_cell: ->(text, _padding) {
49
+ "#{BOLD}#{text}#{RESET}"
50
+ }
51
+ )
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "table/column"
4
+ require_relative "table/theme"
5
+ require_relative "table/renderer"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :railbow do
4
+ desc "Generate a railbow config file with commented defaults"
5
+ task :init do
6
+ require "railbow/init"
7
+
8
+ Railbow::Init.run
9
+ end
10
+ end