railbow 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d599ddd7c1bd3a12893312ab22f40ff75def87f5d85d42dd2ee5a11da7183225
4
- data.tar.gz: 4413568aafb22c8cf2b1025bbd6f90696f4da6f3bcead307c48ffeac3e96f04c
3
+ metadata.gz: 61c0c3ed9ad2e0c2218e9f2b5e69f6eecec3253a82722408649e91b2822873be
4
+ data.tar.gz: 2179f41a4dfd77f4630940ce0280130a612d75202faede974611c27b0729058d
5
5
  SHA512:
6
- metadata.gz: a02d2dda50ba6fa9ae4b04d166ebc51148532dcfccd5ab1a7777179d14767d2bb87b2fc4a9a5d3aadbb0eeae114a68f5365cd3bfd0d27286958b192f5fa43967
7
- data.tar.gz: 5ad80b381822e25a9078093f82c8b52deccae5940334ce45645883e2d4c06a81070e2710876f58fc8af949cc3b96009369e141eb9945c3a1941233561c31f486
6
+ metadata.gz: c777b8cf30ac567ba71c6cc16f16e7f6bbfdc604d8fa1b161cafc17879cea825b446e02f72e43fa9f383d03752c2506a6653c951928302b6d290b689f5a4fa39
7
+ data.tar.gz: 87b8b0d21c779637d0f6c9c20271bec519677feda8d790cadd9644a33413d352550b6a883a96da0774bb7009f9f2667b55d9e23a670800684bbf824b58607b78
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-08-03
9
+
10
+ ### Fixed
11
+
12
+ - On a terminal too narrow for the full `db:migrate:status` row, the table no
13
+ longer wraps mid-cell. A width budget degrades it gracefully instead, one
14
+ sacrifice at a time until the rows fit: the Migration Name column narrows
15
+ first (down to 24 columns), then the Tables column is dropped, then the
16
+ formatted date (the raw Migration ID still carries the timestamp), and as a
17
+ last resort the Who column. Each table gives up only as much as its terminal
18
+ demands, and a wide terminal renders exactly as before.
19
+
8
20
  ## [0.5.0] - 2026-07-28
9
21
 
10
22
  ### Added
@@ -48,15 +48,17 @@ module Railbow
48
48
  table_columns = [
49
49
  Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
50
50
  Railbow::Table::Column.new(label: "Migration ID", sticky: true),
51
- Railbow::Table::Column.new(label: "Created At"),
51
+ Railbow::Table::Column.new(label: "Created At", droppable: 2),
52
52
  Railbow::Table::Column.new(label: "Migration Name",
53
53
  max_width: name_col_width,
54
- truncate: needs_name_truncation)
54
+ truncate: needs_name_truncation,
55
+ shrinkable: true, shrink_floor: 24)
55
56
  ]
56
57
  # No Author column in default config (author:me only highlights)
57
58
 
58
59
  tables_truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
59
- table_columns << Railbow::Table::Column.new(label: "Tables", truncate: true, truncate_fn: tables_truncate_fn)
60
+ table_columns << Railbow::Table::Column.new(label: "Tables", droppable: 1,
61
+ truncate: true, truncate_fn: tables_truncate_fn)
60
62
 
61
63
  # Build rows
62
64
  highlight_rows = Set.new
@@ -263,10 +263,22 @@ module Railbow
263
263
  head += "#{RULE * 4} #{shown_count(section)} of #{section.total_count} " if section.hidden_count > 0
264
264
 
265
265
  width = (rule_width > 0) ? rule_width : DEFAULT_RULE_WIDTH
266
+ if (term = terminal_width)
267
+ width = [width, term].min
268
+ end
266
269
  filler = [width - formatter.display_width(head), 4].max
267
270
  "#{PURPLE}#{head}#{RULE * filler}#{RESET}"
268
271
  end
269
272
 
273
+ # The rule frames the table, but on a narrow terminal the table itself
274
+ # gives up width, so the rule must never trust fixed_width alone.
275
+ def terminal_width
276
+ return $stdout.winsize[1] if $stdout.respond_to?(:winsize) && $stdout.tty?
277
+ nil
278
+ rescue
279
+ nil
280
+ end
281
+
270
282
  # A bare "10 of 50" reads as "10 fell inside the window". When the floor
271
283
  # overrode the window the selection is a tail rather than a window, so it
272
284
  # says "last 10 of 50" - the same words the single-database note uses.
@@ -30,6 +30,10 @@ module Railbow
30
30
  # with it for horizontal space (tags, authors, table names).
31
31
  NAME_COL_WIDTH = 60
32
32
 
33
+ # Floor the width budget may shrink the name column down to on a narrow
34
+ # terminal, before it starts dropping columns instead.
35
+ NAME_COL_MIN_WIDTH = 24
36
+
33
37
  NO_FILE = "NO FILE"
34
38
 
35
39
  # A version one database in a shard group has and another does not.
@@ -311,6 +315,11 @@ module Railbow
311
315
 
312
316
  # The name column only needs capping when something else competes for the
313
317
  # row: table tags, an author column, branch badges or landed badges.
318
+ #
319
+ # On a terminal too narrow for the full row the width budget degrades the
320
+ # table instead of letting it wrap: the name column shrinks first, then
321
+ # Tables is dropped, then the formatted date (the raw Migration ID keeps
322
+ # the timestamp), then Who.
314
323
  def build_columns
315
324
  @name_col_width = needs_name_truncation? ? NAME_COL_WIDTH : nil
316
325
 
@@ -318,15 +327,17 @@ module Railbow
318
327
  Table::Column.new(label: "Status", max_width: status_col_width,
319
328
  sticky: true, accent: true, aliased: !sharded?),
320
329
  Table::Column.new(label: "Migration ID", sticky: true),
321
- Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
330
+ Table::Column.new(label: (date_format == "full") ? "Created At" : "Date",
331
+ droppable: 2),
322
332
  Table::Column.new(label: "Migration Name",
323
333
  max_width: @name_col_width,
324
- truncate: !@name_col_width.nil?)
334
+ truncate: !@name_col_width.nil?,
335
+ shrinkable: true, shrink_floor: NAME_COL_MIN_WIDTH)
325
336
  ]
326
- cols << Table::Column.new(label: "Who") if author_mode == "all"
337
+ cols << Table::Column.new(label: "Who", droppable: 3) if author_mode == "all"
327
338
  if tables_enabled?
328
339
  truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
329
- cols << Table::Column.new(label: "Tables",
340
+ cols << Table::Column.new(label: "Tables", droppable: 1,
330
341
  truncate: Railbow::Params.compact_oneline?, truncate_fn: truncate_fn)
331
342
  end
332
343
  cols
@@ -4,14 +4,18 @@ module Railbow
4
4
  module Table
5
5
  class Column
6
6
  attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn,
7
- :sticky, :accent, :aliased
7
+ :sticky, :accent, :aliased, :shrinkable, :shrink_floor, :droppable
8
8
 
9
9
  # accent: the column carries its own meaning through color (a status
10
10
  # glyph, say), so it keeps that color when the row is dimmed.
11
11
  # aliased: value aliases from config may rewrite this column's cells.
12
12
  # Off for cells the caller already resolved, such as a cluster of status
13
13
  # glyphs, which no whole-cell alias could ever match.
14
- def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false, aliased: true)
14
+ # shrinkable: the width budget may narrow this column, down to
15
+ # shrink_floor, before it starts dropping columns.
16
+ # droppable: rank in the order the width budget drops columns entirely
17
+ # when shrinking is not enough - lower ranks go first. Nil: never dropped.
18
+ def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false, aliased: true, shrinkable: false, shrink_floor: nil, droppable: nil)
15
19
  @label = label
16
20
  @width = width
17
21
  @min_width = min_width
@@ -22,6 +26,9 @@ module Railbow
22
26
  @sticky = sticky
23
27
  @accent = accent
24
28
  @aliased = aliased
29
+ @shrinkable = shrinkable
30
+ @shrink_floor = shrink_floor
31
+ @droppable = droppable
25
32
  end
26
33
 
27
34
  def fixed?
@@ -14,15 +14,25 @@ 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
24
  # min_widths raises the resolved width of each column to at least the
20
25
  # given value, which is how several tables rendered in one run line their
21
26
  # columns up with each other. An explicit compact maxw still wins.
22
- def initialize(columns:, theme:, compact: {}, aliases: {}, min_widths: nil)
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)
23
32
  @compact = compact
24
33
  @aliases = aliases
25
34
  @min_widths = min_widths
35
+ @term_width = term_width
26
36
  @reverse_col_aliases = aliases[:columns]&.invert || {}
27
37
  @columns = apply_hidden_columns(columns)
28
38
  @theme = theme
@@ -50,7 +60,21 @@ module Railbow
50
60
  return "" if columns.empty?
51
61
 
52
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
75
+
53
76
  resolved = resolve_widths(rows)
77
+ shrink_to_fit!(resolved, rows)
54
78
 
55
79
  # In oneline mode, truncate non-sticky non-last columns at resolved width
56
80
  if @compact[:oneline]
@@ -209,7 +233,7 @@ module Railbow
209
233
  last_cell_plain = strip_ansi(last_cell_raw)
210
234
  term_w = terminal_width
211
235
  prefix_width = compute_prefix_width(widths, last)
212
- 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
213
237
 
214
238
  # Use custom truncate_fn if available (e.g. table tags with +N)
215
239
  if columns[last].truncate_fn && last_col_max &&
@@ -305,12 +329,98 @@ module Railbow
305
329
  end
306
330
 
307
331
  def terminal_width
332
+ return @term_width if @term_width
308
333
  return $stdout.winsize[1] if $stdout.respond_to?(:winsize) && $stdout.tty?
309
334
  nil
310
335
  rescue
311
336
  nil
312
337
  end
313
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
+
314
424
  def ansi_word_wrap(str, max_width)
315
425
  plain = strip_ansi(str)
316
426
  plain_lines = word_wrap(plain, max_width)
@@ -26,5 +26,35 @@ module Railbow
26
26
  end
27
27
  "#{truncated}..."
28
28
  end
29
+
30
+ # ANSI-aware counterpart to truncate_str: escape codes pass through
31
+ # unmeasured, and a color left open at the cut is closed before the
32
+ # ellipsis so it cannot leak into whatever follows the cell.
33
+ def truncate_ansi(str, max_width)
34
+ s = str.to_s
35
+ return s if display_width(strip_ansi(s)) <= max_width
36
+
37
+ result = +""
38
+ width = 0
39
+ open_color = false
40
+ i = 0
41
+ while i < s.length
42
+ if s[i] == "\e" && (close = s.index("m", i))
43
+ code = s[i..close]
44
+ result << code
45
+ open_color = (code != "\e[0m")
46
+ i = close + 1
47
+ else
48
+ ch = s[i]
49
+ ch_width = display_width(ch)
50
+ break if width + ch_width > max_width - 3
51
+ result << ch
52
+ width += ch_width
53
+ i += 1
54
+ end
55
+ end
56
+ result << "\e[0m" if open_color
57
+ "#{result}..."
58
+ end
29
59
  end
30
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railbow
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railbow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M