Skip to content

Commit c52dd97

Browse files
authored
ansi color truncation (#18)
* wip * Update ANSI truncate tests * Refine ansi truncate handling * wip * wip * wip
1 parent b101f2d commit c52dd97

File tree

13 files changed

+188
-102
lines changed

13 files changed

+188
-102
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ jobs:
1313
max-parallel: 3
1414
matrix:
1515
os: [macos, ubuntu, windows]
16-
ruby-version: [3.0, 3.4]
16+
ruby-version: [3.0, 3.4, 4.0]
1717
runs-on: ${{ matrix.os }}-latest
1818
steps:
19-
- uses: actions/checkout@v4
19+
- uses: actions/checkout@v5
2020
- uses: taiki-e/install-action@just
2121
- uses: ruby/setup-ruby@v1
2222
with:

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Purpose
2+
3+
TableTennis is a popular ruby gem for rendering ANSI-colored tables from csv-like rows (hash-ish objects). Beauty > performance, but keep small-table latency snappy.
4+
5+
# Key flow
6+
7+
- Public API: `TableTennis.new` -> `TableTennis::Table` (`lib/table_tennis/table.rb`).
8+
- Pipeline: `Format` -> `Layout` -> `Painter` -> `Render` (`lib/table_tennis/stage/*.rb`).
9+
- Data model: `TableData`, `Column`, `Row` (`lib/table_tennis/table_data.rb`, `lib/table_tennis/column.rb`, `lib/table_tennis/row.rb`).
10+
- Config/theming: `Config`, `Theme`, `Util::Colors/Console/Strings/Termbg` (`lib/table_tennis/config.rb`, `lib/table_tennis/theme.rb`, `lib/table_tennis/util/*`).
11+
12+
# dev (prefer `just`)
13+
14+
- `just test`
15+
- `just lint` / `just format`
16+
- `just check`
17+
18+
# Style/conventions
19+
20+
- Defaults and validation live in `Config` (`MagicOptions`).
21+
- Rendering cost is mostly in `Stage::Render` (ANSI/string work); memoization via `MemoWise` is used in hot paths.
22+
- Prefer short methods, short variable names, and comment lightly when code is complex
23+
- Ruby one-liners are find and even preferred for trivial methods

Gemfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ source "https://rubygems.org"
22
gemspec
33

44
group :development, :test do
5-
gem "amazing_print", "~> 1.7"
5+
gem "amazing_print", "~> 2.0"
66
gem "image_optim", "~> 0.31"
77
gem "image_optim_pack", "~> 0.12"
8-
gem "minitest", "~> 5.25"
8+
gem "minitest", "~> 5.27"
99
gem "minitest-hooks", "~> 1.5"
10-
gem "mocha", "~> 2.7"
10+
gem "mocha", "~> 3.0"
1111
gem "ostruct", "~> 0.6" # required for Ruby 3.5+
1212
gem "pry", "~> 0.15"
1313
gem "rake", "~> 13.2"

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ We love CSV tools and use them all the time! Here are a few that we rely on:
172172

173173
### Changelog
174174

175+
#### 1.0.0 (unreleased but soon)
176+
177+
- better truncation with ansi colors, inspired by strings-truncation gem
178+
- truncation no longer supports graphemes (breaking)
179+
- added AGENTS.md and mise.toml
180+
175181
#### 0.0.7 (Aug '25)
176182

177183
- handle data that already contains ANSI colors (thx @ronaldtse, #12)

justfile

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
set quiet := true
2+
13
default: test
24

35
# check repo - lint & test
46
check: lint test
57

68
# for ci. don't bother linting on windows
79
ci:
8-
@if [[ "{{os()}}" != "windows" ]]; then just lint ; fi
9-
@just test
10+
if [[ "{{os()}}" != "windows" ]]; then just lint ; fi
11+
just test
1012

1113
# check test coverage
1214
coverage:
@@ -17,23 +19,30 @@ coverage:
1719
format: (lint "-a")
1820

1921
gem-local:
20-
@just _banner rake install:local...
22+
just banner rake install:local...
2123
bundle exec rake install:local
2224

2325
# this will tag, build and push to rubygems
2426
gem-push: check
25-
@if rg -g '!justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
26-
@just _banner rake release...
27+
if rg -g '!justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
28+
just banner rake release...
2729
bundle exec rake release
2830

2931
# optimize images
3032
image_optim:
31-
@# advpng/pngout are slow. consider --verbose as well
32-
@bundle exec image_optim --allow-lossy --svgo-precision=1 --no-advpng --no-pngout -r .
33+
# advpng/pngout are slow. consider --verbose as well
34+
bundle exec image_optim --allow-lossy --svgo-precision=1 --no-advpng --no-pngout -r .
35+
36+
# check for outdated deps
37+
outdated:
38+
just banner Here are the easy ones:
39+
bundle outdated --filter-minor || true
40+
just banner The full list:
41+
bundle outdated || true
3342

3443
# lint with rubocop
3544
lint *ARGS:
36-
@just _banner lint...
45+
just banner lint...
3746
bundle exec rubocop {{ARGS}}
3847

3948
# start pry with the lib loaded
@@ -42,31 +51,35 @@ pry:
4251

4352
# run tennis repeatedly
4453
tennis-watch *ARGS:
45-
@watchexec --stop-timeout=0 --clear clear tennis {{ARGS}}
54+
watchexec --stop-timeout=0 --clear clear tennis {{ARGS}}
4655

4756
# run tests
4857
test *ARGS:
49-
@just _banner rake test {{ARGS}}
50-
@bundle exec rake test {{ARGS}}
58+
just banner rake test {{ARGS}}
59+
bundle exec rake test {{ARGS}}
5160

5261
# run tests repeatedly
5362
test-watch *ARGS:
5463
watchexec --stop-timeout=0 --clear clear just test "{{ARGS}}"
5564

5665
# create sceenshot using vhs
5766
vhs:
58-
@just _banner "running vhs..."
67+
just banner "running vhs..."
5968
vhs demo.tape
6069
magick /tmp/dark.png -crop 1448x1004+18+16 screenshots/dark.png
6170

6271
#
6372
# util
6473
#
6574

66-
_banner *ARGS: (_message BG_GREEN ARGS)
67-
_warning *ARGS: (_message BG_YELLOW ARGS)
68-
_fatal *ARGS: (_message BG_RED ARGS)
69-
@exit 1
70-
_message color *ARGS:
71-
@msg=$(printf "[%s] %s" $(date +%H:%M:%S) "{{ARGS}}") ; \
72-
printf "{{color+BOLD+WHITE}}%-72s{{ NORMAL }}\n" "$msg"
75+
TRUWHITE := '\e[38;5;231m'
76+
GREEN := '\e[48;2;064;160;043m'
77+
ORANGE := '\e[48;2;251;100;011m'
78+
RED := '\e[48;2;210;015;057m'
79+
80+
banner +ARGS: (_banner GREEN ARGS)
81+
warning +ARGS: (_banner ORANGE ARGS)
82+
fatal +ARGS: (_banner RED ARGS)
83+
exit 1
84+
_banner BG +ARGS:
85+
printf '{{BOLD+TRUWHITE+BG}}[%s] %-72s {{NORMAL}}\n' "$(date +%H:%M:%S)" "{{ARGS}}"

lib/table_tennis/util/colors.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ def ansi_color_to_hex(num)
489489

490490
# print all colors to $stdout, helps with creating themes
491491
def spectrum
492+
# paint goes bg, fg
492493
max = NAMED.keys.map(&:length).max
493494
fmt = " %-#{max}s %s %0.3f "
494495
NAMED.each do |name, color|
@@ -500,6 +501,17 @@ def spectrum
500501
puts "#{str1}#{str2}#{str3}#{str4}"
501502
end
502503

504+
# with 256 - 38/5/fg or 38/5/bg
505+
fmt = " ansi256 %d"
506+
(1..256).each do |color|
507+
str = sprintf(" ansi256 %3d ", color)
508+
str1 = Paint[str, 38, 5, color, "white"]
509+
str2 = Paint[str, 38, 5, color, "black"]
510+
str3 = Paint[" white ", "white", 48, 5, color]
511+
str4 = Paint[" black ", "black", 48, 5, color]
512+
puts "#{str1}#{str2}#{str3}#{str4}"
513+
end
514+
503515
fmt = " ansi %-6s %-8s "
504516
colors = Paint::ANSI_COLORS_FOREGROUND.keys
505517
puts

lib/table_tennis/util/strings.rb

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ module Strings
66

77
module_function
88

9+
ANSI_CODE = /\e\[[0-9;]*m/
10+
911
# does this string contain ansi codes?
1012
def painted?(str) = str.match?(/\e/)
1113

1214
# strip ansi codes
13-
def unpaint(str) = str.gsub(/\e\[[0-9;]*m/, "")
15+
def unpaint(str) = str.gsub(ANSI_CODE, "")
1416

1517
# similar to rails titleize
1618
def titleize(str)
@@ -49,50 +51,50 @@ def hyperlink(str)
4951
end
5052
end
5153

52-
# truncate a string based on the display width of the grapheme clusters.
53-
# Should handle emojis and international characters. Painted strings too.
54+
ELLIPSIS = "…"
55+
INVISIBLE = /\A(\e\[[0-9;]*m|\u200B)*\z/
56+
57+
# Truncate a string based on the display width of characters. Does not
58+
# attempt to handle graphemes. Should handle emojis and international
59+
# characters. Painted strings too.
5460
def truncate(str, stop)
55-
if simple?(str)
56-
(str.length > stop) ? "#{str[0, stop - 1]}…" : str
57-
elsif painted?(str)
58-
# generate truncated plain version
59-
plain = truncate0(unpaint(str), stop)
60-
# make a best effort to apply the colors
61-
if (opening_codes = str[/\e\[(?:[0-9];?)+m/])
62-
"#{opening_codes}#{plain}#{Paint::NOTHING}"
63-
else
64-
plain
65-
end
61+
if str.bytesize <= stop
62+
str
63+
elsif simple?(str)
64+
(str.length <= stop) ? str : "#{str[0, stop - 1]}#{ELLIPSIS}"
6665
else
6766
truncate0(str, stop)
6867
end
6968
end
7069

71-
# slow, but handles graphemes
70+
# This is a slower truncate to handle ansi colors and wide characters like
71+
# emojis. Inspired by piotrmurach/strings-truncation
7272
def truncate0(text, stop)
73-
# get grapheme clusters, and attach zero width graphemes to the previous grapheme
74-
list = [].tap do |accum|
75-
text.grapheme_clusters.each do
76-
if width(_1) == 0 && !accum.empty?
77-
accum[-1] = "#{accum[-1]}#{_1}"
78-
else
79-
accum << _1
73+
[].tap do |buf|
74+
scan, len, painting = StringScanner.new(text), 0, false
75+
until scan.eos?
76+
# are we looking at an ansi code?
77+
if scan.scan(ANSI_CODE)
78+
buf << scan.matched
79+
painting = scan.matched != Paint::NOTHING
80+
next
8081
end
81-
end
82-
end
8382

84-
width = 0
85-
list.each_index do
86-
w = Unicode::DisplayWidth.of(list[_1])
87-
next if (width += w) <= stop
83+
# what's next?
84+
ch = scan.getch
8885

89-
# we've gone too far. do we need to pop for the ellipsis?
90-
text = list[0, _1]
91-
text.pop if width - w == stop
92-
return "#{text.join}…"
93-
end
86+
# done? append one final char, possible an ELLIPSIS
87+
len += Unicode::DisplayWidth.of(ch)
88+
if len >= stop
89+
buf << (scan.check(INVISIBLE) ? ch : ELLIPSIS)
90+
break
91+
end
9492

95-
text
93+
# keep going
94+
buf << ch
95+
end
96+
buf << Paint::NOTHING if painting
97+
end.join
9698
end
9799
private_class_method :truncate0
98100

lib/table_tennis/util/termbg.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def in_foreground?
182182
def load_ffi!
183183
module_eval do
184184
extend FFI::Library
185+
185186
ffi_lib "c"
186187
attach_function :tcgetpgrp, %i[int], :int32
187188
debug("ffi attach libc.tcgetpgrp => success")

lib/table_tennis/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module TableTennis
2-
VERSION = "0.0.7"
2+
VERSION = "1.0.0"
33
end

mise.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[env]
2+
_.path = ['{{config_root}}/bin']
3+
4+
[hooks]
5+
postinstall = ['bundle']
6+
7+
[tools]
8+
ruby = "3.4.2"

0 commit comments

Comments
 (0)