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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +10 -10
- data/lib/railbow/demo/fixtures.rb +4 -4
- data/lib/railbow/demo/status_demo.rb +6 -3
- data/lib/railbow/formatters/base.rb +29 -0
- data/lib/railbow/init.rb +1 -1
- data/lib/railbow/params.rb +1 -1
- data/lib/railbow/status/help.rb +2 -2
- data/lib/railbow/status/printer.rb +13 -1
- data/lib/railbow/status/section.rb +16 -4
- data/lib/railbow/table/column.rb +9 -2
- data/lib/railbow/table/renderer.rb +116 -2
- data/lib/railbow/text_utils.rb +30 -0
- data/lib/railbow/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de5537d849ecc612b80236db0a17543d6fad2ea18aeb0c3872dd48ced2dfdf5f
|
|
4
|
+
data.tar.gz: a6833af7828722eee5153a48bcf96f7008760948324337c46af90ecee3a86520
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
161
|
+
๐ 2 databases ยท primary, log
|
|
162
162
|
|
|
163
|
-
โโโโ primary ยท myapp_development โโโโ
|
|
164
|
-
โโ โ 20260721130000 โ 2026-07-21 13:00:00 โ Partition
|
|
165
|
-
โโ โ 20260727170000 โ 2026-07-27 17:00:00 โ Add
|
|
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
|
-
โฏ
|
|
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 โโโโ
|
|
183
|
-
โโ โ 20260721130000 โ 2026-07-21 13:00:00 โ Partition
|
|
182
|
+
โโโโ primary ยท myapp_development โโโโ 12 of 240 โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
183
|
+
โโ โ 20260721130000 โ 2026-07-21 13:00:00 โ Partition visit reminders โ visit_reminders
|
|
184
184
|
|
|
185
|
-
โโโโ
|
|
186
|
-
โโ โ 20260722120000 โ 2026-07-22 12:00:00 โ Drop archived
|
|
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: `
|
|
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
|
|
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@
|
|
15
|
-
bob: {name: "Bob Wilson", email: "bob@
|
|
16
|
-
carol: {name: "Carol Davis", email: "carol@
|
|
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",
|
|
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:(
|
|
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.):
|
data/lib/railbow/params.rb
CHANGED
|
@@ -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:
|
|
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.
|
data/lib/railbow/status/help.rb
CHANGED
|
@@ -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:(
|
|
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:(
|
|
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 ยท
|
|
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
|
data/lib/railbow/table/column.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|
|
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)
|
data/lib/railbow/text_utils.rb
CHANGED
|
@@ -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
|
data/lib/railbow/version.rb
CHANGED