railbow 0.5.0 โ†’ 0.6.2

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: de5537d849ecc612b80236db0a17543d6fad2ea18aeb0c3872dd48ced2dfdf5f
4
+ data.tar.gz: a6833af7828722eee5153a48bcf96f7008760948324337c46af90ecee3a86520
5
5
  SHA512:
6
- metadata.gz: a02d2dda50ba6fa9ae4b04d166ebc51148532dcfccd5ab1a7777179d14767d2bb87b2fc4a9a5d3aadbb0eeae114a68f5365cd3bfd0d27286958b192f5fa43967
7
- data.tar.gz: 5ad80b381822e25a9078093f82c8b52deccae5940334ce45645883e2d4c06a81070e2710876f58fc8af949cc3b96009369e141eb9945c3a1941233561c31f486
6
+ metadata.gz: e9d7ae02de3f895c3300dbcd547ad52ddb0309a53a999b2ad52694bc3d46430d90a0518451721df72868fed730f9b60e1c66ffa0e616d01207d2908b560950d0
7
+ data.tar.gz: 9fd9c71099808af2b42b186f13ff31112e9195e39af6b94f9650f649e6c7e1b7346d06133992229f9d354a24b955dd2c98ed9b4c77c85a1d727c31c7800bbdb6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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.2] - 2026-09-21
9
+
10
+ ### Changed
11
+
12
+ - Demo fixtures, README examples and spec data now use the PawShop sample
13
+ project throughout.
14
+
15
+ ## [0.6.1] - 2026-08-10
16
+
17
+ ### Fixed
18
+
19
+ - On a narrow terminal, a migration's branch and landing badges no longer
20
+ vanish from the `db:migrate:status` name column, leaving a stray `...`
21
+ hanging in the padding where they used to sit. The badges are padded flush
22
+ against the right edge of the column, which is exactly where the width
23
+ budget was cutting; the name now gives up the space instead and the badges
24
+ stay put. When a badge is so wide that keeping it would leave under 12
25
+ columns for the name, it is dropped outright rather than squeezing the name
26
+ down to nothing.
27
+
28
+ ## [0.6.0] - 2026-08-03
29
+
30
+ ### Fixed
31
+
32
+ - On a terminal too narrow for the full `db:migrate:status` row, the table no
33
+ longer wraps mid-cell. A width budget degrades it gracefully instead, one
34
+ sacrifice at a time until the rows fit: the Migration Name column narrows
35
+ first (down to 24 columns), then the Tables column is dropped, then the
36
+ formatted date (the raw Migration ID still carries the timestamp), and as a
37
+ last resort the Who column. Each table gives up only as much as its terminal
38
+ demands, and a wide terminal renders exactly as before.
39
+
8
40
  ## [0.5.0] - 2026-07-28
9
41
 
10
42
  ### Added
data/README.md CHANGED
@@ -158,13 +158,13 @@ By default you read one table: the first database in `database.yml` is rendered
158
158
  in full and every other one is summarized in a line each (`db: focus`).
159
159
 
160
160
  ```
161
- ๐Ÿ“Š 2 databases ยท primary, raw
161
+ ๐Ÿ“Š 2 databases ยท primary, log
162
162
 
163
- โ”€โ”€โ”€โ”€ primary ยท myapp_development โ”€โ”€โ”€โ”€ 122 of 1500 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
164
- โ†‘โ†‘ โ”‚ 20260721130000 โ”‚ 2026-07-21 13:00:00 โ”‚ Partition sms notifications โ— sms_notifications
165
- โ†“โ†“ โ”‚ 20260727170000 โ”‚ 2026-07-27 17:00:00 โ”‚ Add cr push columns to lines โ— lines
163
+ โ”€โ”€โ”€โ”€ primary ยท myapp_development โ”€โ”€โ”€โ”€ 12 of 240 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
164
+ โ†‘โ†‘ โ”‚ 20260721130000 โ”‚ 2026-07-21 13:00:00 โ”‚ Partition visit reminders โ— visit_reminders
165
+ โ†“โ†“ โ”‚ 20260727170000 โ”‚ 2026-07-27 17:00:00 โ”‚ Add microchip ids to animals โ— animals
166
166
 
167
- โ‹ฏ raw ยท 5 migrations, all applied ยท latest Jul 22 2026
167
+ โ‹ฏ log ยท 5 migrations, all applied ยท latest Jul 22 2026
168
168
  ```
169
169
 
170
170
  **A database with migrations pending is always expanded**, however focused the
@@ -179,14 +179,14 @@ db: "" # or RBW_DB= on the command line
179
179
  ```
180
180
 
181
181
  ```
182
- โ”€โ”€โ”€โ”€ primary ยท myapp_development โ”€โ”€โ”€โ”€ 122 of 1500 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
183
- โ†‘โ†‘ โ”‚ 20260721130000 โ”‚ 2026-07-21 13:00:00 โ”‚ Partition sms notifications โ— sms_notifications
182
+ โ”€โ”€โ”€โ”€ primary ยท myapp_development โ”€โ”€โ”€โ”€ 12 of 240 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
183
+ โ†‘โ†‘ โ”‚ 20260721130000 โ”‚ 2026-07-21 13:00:00 โ”‚ Partition visit reminders โ— visit_reminders
184
184
 
185
- โ”€โ”€โ”€โ”€ raw ยท raw_development โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
186
- โ†‘โ†‘ โ”‚ 20260722120000 โ”‚ 2026-07-22 12:00:00 โ”‚ Drop archived activity logs โ— archived_activity_logs
185
+ โ”€โ”€โ”€โ”€ log ยท log_development โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
186
+ โ†‘โ†‘ โ”‚ 20260722120000 โ”‚ 2026-07-22 12:00:00 โ”‚ Drop archived visit logs โ— archived_visit_logs
187
187
  ```
188
188
 
189
- A section header counts what it is showing: `122 of 1500` means 122 fell inside
189
+ A section header counts what it is showing: `12 of 240` means 12 fell inside
190
190
  the time window. When the window found too few and the `since_min` floor topped
191
191
  the section back up, it reads `last 10 of 50` instead, because the rows are
192
192
  then the newest ten rather than a window's worth.
@@ -5,15 +5,15 @@ require "date"
5
5
  module Railbow
6
6
  module Demo
7
7
  module Fixtures
8
- # Fictional veterinary SaaS โ€” "PawClinic"
8
+ # Fictional pet shop SaaS โ€” "PawShop"
9
9
  # 4 authors: Alice Chen, Bob Wilson, Carol Davis, Me (current user)
10
10
  DEMO_USER_NAME = "Me"
11
11
  DEMO_USER_EMAIL = "me@example.com"
12
12
 
13
13
  AUTHORS = {
14
- alice: {name: "Alice Chen", email: "alice@pawclinic.dev"},
15
- bob: {name: "Bob Wilson", email: "bob@pawclinic.dev"},
16
- carol: {name: "Carol Davis", email: "carol@pawclinic.dev"},
14
+ alice: {name: "Alice Chen", email: "alice@pawshop.dev"},
15
+ bob: {name: "Bob Wilson", email: "bob@pawshop.dev"},
16
+ carol: {name: "Carol Davis", email: "carol@pawshop.dev"},
17
17
  me: {name: DEMO_USER_NAME, email: DEMO_USER_EMAIL}
18
18
  }.freeze
19
19
 
@@ -48,15 +48,18 @@ 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
+ truncate_fn: ->(cell_raw, max_w) { formatter.name_with_tags_fitted(cell_raw, max_w) },
56
+ shrinkable: true, shrink_floor: 24)
55
57
  ]
56
58
  # No Author column in default config (author:me only highlights)
57
59
 
58
60
  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)
61
+ table_columns << Railbow::Table::Column.new(label: "Tables", droppable: 1,
62
+ truncate: true, truncate_fn: tables_truncate_fn)
60
63
 
61
64
  # Build rows
62
65
  highlight_rows = Set.new
@@ -34,6 +34,9 @@ module Railbow
34
34
 
35
35
  BRIGHT_WHITE = "\e[1;97m"
36
36
 
37
+ # Narrowest name a tagged cell will accept before the tags are dropped.
38
+ NAME_TAGS_FLOOR = 12
39
+
37
40
  def dim(str) = "#{DIM}#{str}#{RESET}"
38
41
  def green(str) = "#{GREEN}#{str}#{RESET}"
39
42
  def yellow(str) = "#{YELLOW}#{str}#{RESET}"
@@ -115,6 +118,32 @@ module Railbow
115
118
  "+#{total}"
116
119
  end
117
120
 
121
+ # Re-fits a Migration Name cell whose tags (branch, landed) were padded
122
+ # flush against the right edge of a wider column. Truncating such a cell
123
+ # from the right eats the tags whole and leaves a bare ellipsis floating
124
+ # after the padding, so the name gives up the space instead and the tags
125
+ # stay put. Below NAME_TAGS_FLOOR there is no room for both and the tags
126
+ # are dropped outright - the name is what the row is about.
127
+ def name_with_tags_fitted(cell, max_width)
128
+ str = cell.to_s
129
+ return truncate_ansi(str, max_width) if display_width(strip_ansi(str)) <= max_width
130
+
131
+ split = str.rindex(/\s{2,}\e\[/)
132
+ return truncate_ansi(str, max_width) unless split
133
+
134
+ # rindex stops at the last two spaces of the padding run, so the rest of
135
+ # it still hangs off the name and would be truncated in place of it.
136
+ name = str[0...split].rstrip
137
+ tags = str[split..].lstrip
138
+ tags_width = display_width(strip_ansi(tags))
139
+ name_room = max_width - tags_width - 2
140
+ return truncate_ansi(name, max_width) if name_room < NAME_TAGS_FLOOR
141
+
142
+ name = truncate_ansi(name, name_room)
143
+ padding = max_width - display_width(strip_ansi(name)) - tags_width
144
+ "#{name}#{" " * [padding, 2].max}#{tags}"
145
+ end
146
+
118
147
  def format_timing(seconds)
119
148
  milliseconds = (seconds * 1000).round(1)
120
149
 
data/lib/railbow/init.rb CHANGED
@@ -37,7 +37,7 @@ module Railbow
37
37
  # diff - tag migrations by git origin branch
38
38
  # base:<branch> - base branch for diff (default: auto-detected)
39
39
  # mask:auto - auto-extract ticket id from branch name
40
- # mask:<re> - custom regex to extract branch label, e.g. mask:(WS-[^/]+)/
40
+ # mask:<re> - custom regex to extract branch label, e.g. mask:(PS-[^/]+)/
41
41
  git: "author:all,diff,mask:auto"
42
42
 
43
43
  # Author display format - preset or custom pattern (FF L, FFF LL, etc.):
@@ -189,7 +189,7 @@ module Railbow
189
189
  end
190
190
 
191
191
  # Built-in regex for extracting ticket/task identifiers from branch names.
192
- # Matches patterns like: ws-123, 123, abc123, ab-123-456, feat/ws-1234, feat/123-some-feature
192
+ # Matches patterns like: ps-123, 123, abc123, ab-123-456, feat/ps-1234, feat/123-some-feature
193
193
  TICKET_RE = /(?:^|\/)([a-z]{1,5}-?\d+(?:-\d+)*|\d+(?:-\d+)*)/i
194
194
 
195
195
  # Extract a ticket identifier from a branch name.
@@ -86,7 +86,7 @@ module Railbow
86
86
  diff - tag migrations by git origin
87
87
  base:<branch> - base branch for diff (default: auto-detected)
88
88
  mask:<re> - regex to extract branch label
89
- e.g. mask:(WS-[^/]+)/
89
+ e.g. mask:(PS-[^/]+)/
90
90
  mask:auto - auto-extract ticket id from branch name
91
91
 
92
92
  RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
@@ -109,7 +109,7 @@ module Railbow
109
109
  RBW_DATE='custom(%b %d, %Y)' rake db:migrate:status
110
110
  RBW_GIT=diff rake db:migrate:status
111
111
  RBW_GIT=diff,base:develop rake db:migrate:status
112
- RBW_GIT=diff,mask:(WS-[^/]+)/ rake db:migrate:status
112
+ RBW_GIT=diff,mask:(PS-[^/]+)/ rake db:migrate:status
113
113
  RBW_DB= rake db:migrate:status
114
114
  RBW_DB=only:primary rake db:migrate:status
115
115
  RBW_DB=inline rake db:migrate:status
@@ -254,7 +254,7 @@ module Railbow
254
254
  end
255
255
  end
256
256
 
257
- # "โ”€โ”€โ”€โ”€ primary ยท westside-dev โ”€โ”€โ”€โ”€ 111 of 1489 โ”€โ”€โ”€โ”€", ruled out to where
257
+ # "โ”€โ”€โ”€โ”€ primary ยท pawshop-dev โ”€โ”€โ”€โ”€ 12 of 240 โ”€โ”€โ”€โ”€", ruled out to where
258
258
  # the last column starts so it frames the table rather than floating.
259
259
  def section_header(section, rule_width)
260
260
  label = section.db_names.join(" + ")
@@ -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,18 @@ 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
+ truncate_fn: ->(cell_raw, max_w) { formatter.name_with_tags_fitted(cell_raw, max_w) },
336
+ shrinkable: true, shrink_floor: NAME_COL_MIN_WIDTH)
325
337
  ]
326
- cols << Table::Column.new(label: "Who") if author_mode == "all"
338
+ cols << Table::Column.new(label: "Who", droppable: 3) if author_mode == "all"
327
339
  if tables_enabled?
328
340
  truncate_fn = ->(cell_raw, max_w) { formatter.table_tags_fitted(cell_raw, max_w) }
329
- cols << Table::Column.new(label: "Tables",
341
+ cols << Table::Column.new(label: "Tables", droppable: 1,
330
342
  truncate: Railbow::Params.compact_oneline?, truncate_fn: truncate_fn)
331
343
  end
332
344
  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,102 @@ 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
+ # A column that knows how to re-fit its own cells does it here: a
392
+ # blind cut from the right would drop whatever the cell parked at its
393
+ # far edge (right-aligned tags) instead of shortening the content.
394
+ fit = col.truncate_fn || method(:truncate_ansi)
395
+ rows.each { |row| row[i] = fit.call(row[i].to_s, widths[i]) }
396
+ end
397
+ end
398
+
399
+ # Never below the header label: a column narrower than its own header
400
+ # would push the header row out of alignment.
401
+ def shrink_floor(col)
402
+ [col.shrink_floor || MIN_SHRINK_WIDTH, display_width(effective_label(col))].max
403
+ end
404
+
405
+ def fully_shrunk_widths(rows)
406
+ widths = resolve_widths(rows)
407
+ last = columns.size - 1
408
+ columns.each_with_index do |col, i|
409
+ next if i == last || !col.shrinkable
410
+
411
+ widths[i] = [widths[i], shrink_floor(col)].min
412
+ end
413
+ widths
414
+ end
415
+
416
+ # What the table needs from the terminal: every fixed column plus a
417
+ # reserve for the flexing last column, which truncates down to
418
+ # MIN_LAST_COL but never below it. The last column's header label counts
419
+ # too - it is drawn as-is, so a label wider than every cell would poke
420
+ # past the terminal edge otherwise.
421
+ def required_width(widths, rows)
422
+ last = columns.size - 1
423
+ last_w = rows.map { |row| display_width(strip_ansi(row[last].to_s)) }.max || 0
424
+ last_w = [last_w, display_width(effective_label(columns[last]))].max unless @compact[:noheader]
425
+ compute_prefix_width(widths, last) + [last_w, MIN_LAST_COL].min + display_width(effective_padding)
426
+ end
427
+
314
428
  def ansi_word_wrap(str, max_width)
315
429
  plain = strip_ansi(str)
316
430
  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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M