tessella 0.3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +900 -0
- data/doc/README.md +74 -0
- data/doc/bitfield.md +123 -0
- data/doc/charts.md +1370 -0
- data/doc/check.rb +75 -0
- data/doc/cli.md +483 -0
- data/doc/composition.md +599 -0
- data/doc/getting-started.md +121 -0
- data/doc/glyphs.md +134 -0
- data/doc/images.md +325 -0
- data/doc/library.md +239 -0
- data/doc/lint.md +270 -0
- data/doc/meter.md +210 -0
- data/doc/rule.md +51 -0
- data/doc/seq.md +169 -0
- data/doc/sparkline.md +63 -0
- data/doc/table.md +347 -0
- data/doc/targets.md +222 -0
- data/doc/text.md +84 -0
- data/doc/tree.md +108 -0
- data/doc/wave.md +122 -0
- data/examples/README.md +103 -0
- data/examples/ages.txt +9 -0
- data/examples/bad.csv +3 -0
- data/examples/boxes.txt +3 -0
- data/examples/broken.txt +4 -0
- data/examples/calibration.rb +12 -0
- data/examples/cities.csv +4 -0
- data/examples/cjk.rb +13 -0
- data/examples/comment.rb +9 -0
- data/examples/comment.sh +13 -0
- data/examples/cpu.txt +60 -0
- data/examples/dashboard.sh +25 -0
- data/examples/datasheet.rb +16 -0
- data/examples/disc.pgm +0 -0
- data/examples/disks.sh +18 -0
- data/examples/disks.txt +5 -0
- data/examples/dose.txt +7 -0
- data/examples/drift.txt +4 -0
- data/examples/ethernet.txt +3 -0
- data/examples/figure.c +5 -0
- data/examples/fit.rb +16 -0
- data/examples/floors.rb +21 -0
- data/examples/gallery.sh +20 -0
- data/examples/gauges.sh +31 -0
- data/examples/gradient.rb +16 -0
- data/examples/growth.txt +6 -0
- data/examples/hosts.csv +4 -0
- data/examples/icmp.txt +3 -0
- data/examples/ipv4.txt +12 -0
- data/examples/kinds.csv +4 -0
- data/examples/langs.csv +4 -0
- data/examples/lint.rb +16 -0
- data/examples/load.rb +14 -0
- data/examples/login.txt +10 -0
- data/examples/neg.csv +4 -0
- data/examples/normal.txt +200 -0
- data/examples/note.txt +2 -0
- data/examples/notes-fixed.md +17 -0
- data/examples/notes.md +17 -0
- data/examples/ohlc.txt +7 -0
- data/examples/os.csv +10 -0
- data/examples/outline.txt +8 -0
- data/examples/plot.txt +5 -0
- data/examples/refusals.rb +17 -0
- data/examples/report.sh +21 -0
- data/examples/rfc.sh +22 -0
- data/examples/samples.txt +7 -0
- data/examples/shapes.pgm +0 -0
- data/examples/sizes.tsv +3 -0
- data/examples/spi.txt +6 -0
- data/examples/status.sh +23 -0
- data/examples/stem.txt +12 -0
- data/examples/table-drift.txt +4 -0
- data/examples/temps.txt +31 -0
- data/examples/trig.sh +13 -0
- data/examples/trig.txt +102 -0
- data/examples/week.txt +8 -0
- data/examples/wide.txt +2 -0
- data/exe/tessella +6 -0
- data/lib/tessella/axis.rb +71 -0
- data/lib/tessella/bitfield.rb +194 -0
- data/lib/tessella/block.rb +56 -0
- data/lib/tessella/borders.rb +85 -0
- data/lib/tessella/canvas/area.rb +155 -0
- data/lib/tessella/canvas/ascii.rb +120 -0
- data/lib/tessella/canvas/block.rb +40 -0
- data/lib/tessella/canvas/braille.rb +30 -0
- data/lib/tessella/canvas/clip.rb +49 -0
- data/lib/tessella/canvas/density.rb +51 -0
- data/lib/tessella/canvas/dot.rb +27 -0
- data/lib/tessella/canvas/eighths.rb +43 -0
- data/lib/tessella/canvas/half.rb +51 -0
- data/lib/tessella/canvas/sextant.rb +36 -0
- data/lib/tessella/canvas.rb +168 -0
- data/lib/tessella/cell.rb +10 -0
- data/lib/tessella/chart.rb +211 -0
- data/lib/tessella/check.rb +52 -0
- data/lib/tessella/cli/bitfield.rb +121 -0
- data/lib/tessella/cli/chart.rb +615 -0
- data/lib/tessella/cli/chart_candle.rb +36 -0
- data/lib/tessella/cli/chart_heatmap.rb +21 -0
- data/lib/tessella/cli/chart_help.rb +85 -0
- data/lib/tessella/cli/chart_pie.rb +38 -0
- data/lib/tessella/cli/compose.rb +156 -0
- data/lib/tessella/cli/compose_options.rb +259 -0
- data/lib/tessella/cli/glyphs.rb +97 -0
- data/lib/tessella/cli/image.rb +250 -0
- data/lib/tessella/cli/input.rb +44 -0
- data/lib/tessella/cli/iso_time.rb +28 -0
- data/lib/tessella/cli/lint.rb +102 -0
- data/lib/tessella/cli/lint_options.rb +99 -0
- data/lib/tessella/cli/main.rb +121 -0
- data/lib/tessella/cli/meter.rb +80 -0
- data/lib/tessella/cli/rule.rb +57 -0
- data/lib/tessella/cli/seq.rb +117 -0
- data/lib/tessella/cli/sparkline.rb +72 -0
- data/lib/tessella/cli/table.rb +167 -0
- data/lib/tessella/cli/text.rb +78 -0
- data/lib/tessella/cli/tree.rb +100 -0
- data/lib/tessella/cli/wave.rb +148 -0
- data/lib/tessella/cli.rb +36 -0
- data/lib/tessella/color.rb +106 -0
- data/lib/tessella/compose.rb +243 -0
- data/lib/tessella/compose_fill.rb +176 -0
- data/lib/tessella/compose_grid.rb +36 -0
- data/lib/tessella/compose_shadow.rb +41 -0
- data/lib/tessella/error.rb +13 -0
- data/lib/tessella/frame.rb +170 -0
- data/lib/tessella/glyphs.rb +106 -0
- data/lib/tessella/image.rb +454 -0
- data/lib/tessella/image_octant.rb +46 -0
- data/lib/tessella/image_petscii.rb +116 -0
- data/lib/tessella/kinds/bar.rb +344 -0
- data/lib/tessella/kinds/box.rb +213 -0
- data/lib/tessella/kinds/box_upright.rb +110 -0
- data/lib/tessella/kinds/candlestick.rb +164 -0
- data/lib/tessella/kinds/columns.rb +259 -0
- data/lib/tessella/kinds/date_axis.rb +102 -0
- data/lib/tessella/kinds/error_bars.rb +71 -0
- data/lib/tessella/kinds/heatmap.rb +130 -0
- data/lib/tessella/kinds/line.rb +173 -0
- data/lib/tessella/kinds/multibar.rb +98 -0
- data/lib/tessella/kinds/multibar_columns.rb +130 -0
- data/lib/tessella/kinds/multibar_fine.rb +45 -0
- data/lib/tessella/kinds/multibar_rows.rb +93 -0
- data/lib/tessella/kinds/pie.rb +123 -0
- data/lib/tessella/kinds/pie_rim.rb +78 -0
- data/lib/tessella/kinds/sideways.rb +62 -0
- data/lib/tessella/kinds/stem.rb +134 -0
- data/lib/tessella/kinds/threshold.rb +73 -0
- data/lib/tessella/legend.rb +31 -0
- data/lib/tessella/lint/alphabet.rb +56 -0
- data/lib/tessella/lint/drift.rb +82 -0
- data/lib/tessella/lint/fences.rb +51 -0
- data/lib/tessella/lint/geometry.rb +120 -0
- data/lib/tessella/lint/geometry_checks.rb +224 -0
- data/lib/tessella/lint/lines.rb +115 -0
- data/lib/tessella/lint/mixing.rb +61 -0
- data/lib/tessella/lint/runner.rb +73 -0
- data/lib/tessella/lint/shadows.rb +120 -0
- data/lib/tessella/lint/target_rows.rb +66 -0
- data/lib/tessella/lint.rb +158 -0
- data/lib/tessella/meter.rb +133 -0
- data/lib/tessella/meter_upright.rb +43 -0
- data/lib/tessella/png.rb +456 -0
- data/lib/tessella/raster.rb +92 -0
- data/lib/tessella/rmagick_reader.rb +52 -0
- data/lib/tessella/rule.rb +59 -0
- data/lib/tessella/seq.rb +522 -0
- data/lib/tessella/sparkline.rb +57 -0
- data/lib/tessella/stats.rb +199 -0
- data/lib/tessella/table.rb +547 -0
- data/lib/tessella/table_cells.rb +225 -0
- data/lib/tessella/target.rb +105 -0
- data/lib/tessella/text.rb +76 -0
- data/lib/tessella/text_forms.rb +150 -0
- data/lib/tessella/tree.rb +221 -0
- data/lib/tessella/version.rb +5 -0
- data/lib/tessella/wave.rb +353 -0
- data/lib/tessella/width.rb +122 -0
- data/lib/tessella/width_class.rb +77 -0
- data/lib/tessella/width_invisible.rb +66 -0
- data/lib/tessella/width_invisible_high.rb +60 -0
- data/lib/tessella.rb +94 -0
- metadata +226 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# fill: a pattern in a figure's enclosed blank cells (DESIGN.md,
|
|
5
|
+
# "Blocks and composition"). A blank cell reachable from the
|
|
6
|
+
# figure's edge through blank cells is outside and stays blank;
|
|
7
|
+
# every other blank cell -- inside a frame, a box, any closed
|
|
8
|
+
# region -- takes the pattern, its glyphs tiled diagonally. Text
|
|
9
|
+
# and lines are never touched, and a halo of blank cells keeps the
|
|
10
|
+
# pattern off text (a glyph that is neither box drawing nor + - |):
|
|
11
|
+
# halo: true leaves the cell each side blank, :around the cells
|
|
12
|
+
# above and below too, false none. The pattern is a name from
|
|
13
|
+
# Glyphs::PATTERNS (light, hatch, stipple, ...) or glyphs of one's
|
|
14
|
+
# own, each one visible column and all of one width class; under
|
|
15
|
+
# ascii: true it must be ASCII (colons by default). frame(fill:)
|
|
16
|
+
# fills only the background the frame adds: what floods in from
|
|
17
|
+
# just inside its border, never a region the content encloses.
|
|
18
|
+
module Compose
|
|
19
|
+
FILL = :light
|
|
20
|
+
ASCII_FILL = :colons
|
|
21
|
+
|
|
22
|
+
HALOS = [true, false, :around].freeze
|
|
23
|
+
STRUCTURE = /[\u2500-\u257F+|-]/
|
|
24
|
+
|
|
25
|
+
def self.fill(figure, pattern: nil, ascii: Target.ascii?, halo: true)
|
|
26
|
+
block, = blocks([figure], ascii)
|
|
27
|
+
glyphs = pattern_glyphs!(pattern, ascii)
|
|
28
|
+
grid, codes = cell_grid(block)
|
|
29
|
+
inside = haloed(grid, enclosed(grid), halo)
|
|
30
|
+
Block.new(paint(grid, codes, inside, glyphs))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The background a frame adds to a block it has just framed.
|
|
34
|
+
def self.fill_frame(framed, pattern, ascii, halo)
|
|
35
|
+
glyphs = pattern_glyphs!(pattern, ascii)
|
|
36
|
+
grid, codes = cell_grid(framed)
|
|
37
|
+
region = flood(grid, inner_ring(grid))
|
|
38
|
+
Block.new(paint(grid, codes, haloed(grid, region, halo), glyphs))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The blank cells just inside a frame's border.
|
|
42
|
+
def self.inner_ring(grid)
|
|
43
|
+
last_r = grid.size - 2
|
|
44
|
+
last_c = grid.first.size - 2
|
|
45
|
+
ring = (1..last_r).flat_map { |r| [[r, 1], [r, last_c]] } +
|
|
46
|
+
(1..last_c).flat_map { |c| [[1, c], [last_r, c]] }
|
|
47
|
+
ring.select { |r, c| grid[r][c] == " " }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.pattern_glyphs!(pattern, ascii)
|
|
51
|
+
name = pattern || (ascii ? ASCII_FILL : FILL)
|
|
52
|
+
glyphs = pattern_text(name).chars
|
|
53
|
+
glyphs.each { glyph!(it, "pattern glyph", ascii) }
|
|
54
|
+
classes = Width.classes(glyphs.join)
|
|
55
|
+
return glyphs if classes.size <= 1
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "pattern #{name.inspect} mixes width " \
|
|
58
|
+
"classes: #{classes.join(", ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A name in Glyphs::PATTERNS, else the glyphs themselves.
|
|
62
|
+
def self.pattern_text(name)
|
|
63
|
+
key = Borders.name(name)
|
|
64
|
+
return Glyphs::PATTERNS[key] if Glyphs::PATTERNS.key?(key)
|
|
65
|
+
raise ArgumentError, "empty pattern" if name.to_s.empty?
|
|
66
|
+
|
|
67
|
+
name.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
SGR = /\e\[[0-9;]*m/
|
|
71
|
+
TOKEN = /(?:#{SGR})*(?:\X|\z)/o
|
|
72
|
+
|
|
73
|
+
# The block as rows of one-column cells, a wide glyph (a grapheme
|
|
74
|
+
# cluster, an emoji sequence or an accented letter whole) and an
|
|
75
|
+
# empty tail cell after it, every row the block's width; and
|
|
76
|
+
# the colour codes before each cell ({ [r, c] => codes }, a
|
|
77
|
+
# row's trailing ones at [r, :end]), set aside so a coloured
|
|
78
|
+
# block is measured and filled by what it shows.
|
|
79
|
+
def self.cell_grid(block)
|
|
80
|
+
codes = {}
|
|
81
|
+
grid = block.lines.each_with_index.map do |line, r|
|
|
82
|
+
cells = row_cells(line, r, codes)
|
|
83
|
+
cells + Array.new(block.width - cells.size, " ")
|
|
84
|
+
end
|
|
85
|
+
[grid, codes]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.row_cells(line, r, codes)
|
|
89
|
+
line.scan(TOKEN).each_with_object([]) do |token, cells|
|
|
90
|
+
lead = token[/\A(?:#{SGR})*/o]
|
|
91
|
+
glyph = token.delete_prefix(lead)
|
|
92
|
+
next codes[[r, :end]] = lead if glyph.empty?
|
|
93
|
+
|
|
94
|
+
codes[[r, cells.size]] = lead unless lead.empty?
|
|
95
|
+
cells << glyph
|
|
96
|
+
cells.concat(Array.new([Width.cluster(glyph) - 1, 0].max, ""))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The blank cells not reachable from the edge: { [r, c] => true }.
|
|
101
|
+
def self.enclosed(grid)
|
|
102
|
+
outside = flood(grid)
|
|
103
|
+
grid.each_with_index.with_object({}) do |(row, r), inside|
|
|
104
|
+
row.each_index do |c|
|
|
105
|
+
inside[[r, c]] = true if row[c] == " " && !outside[[r, c]]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.flood(grid, starts = edge_blanks(grid))
|
|
111
|
+
seen = {}
|
|
112
|
+
todo = starts.dup
|
|
113
|
+
until todo.empty?
|
|
114
|
+
r, c = todo.pop
|
|
115
|
+
next if seen[[r, c]]
|
|
116
|
+
|
|
117
|
+
seen[[r, c]] = true
|
|
118
|
+
todo.concat(blank_neighbours(grid, r, c))
|
|
119
|
+
end
|
|
120
|
+
seen
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.edge_blanks(grid)
|
|
124
|
+
last_r = grid.size - 1
|
|
125
|
+
last_c = (grid.first&.size || 0) - 1
|
|
126
|
+
cells = grid.each_index.flat_map { |r| [[r, 0], [r, last_c]] } +
|
|
127
|
+
(0..last_c).flat_map { |c| [[0, c], [last_r, c]] }
|
|
128
|
+
cells.select { |r, c| grid[r][c] == " " }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.blank_neighbours(grid, r, c)
|
|
132
|
+
[[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]].select do |y, x|
|
|
133
|
+
y.between?(0, grid.size - 1) &&
|
|
134
|
+
x.between?(0, grid[y].size - 1) && grid[y][x] == " "
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The halo: blank cells fill keeps beside text, so a label keeps
|
|
140
|
+
# its word shape. Text is any glyph but box drawing and + - |.
|
|
141
|
+
module Compose
|
|
142
|
+
# The region less the cells a halo keeps blank around text.
|
|
143
|
+
def self.haloed(grid, region, halo)
|
|
144
|
+
unless HALOS.include?(halo)
|
|
145
|
+
raise ArgumentError, "halo must be true, false or :around: " \
|
|
146
|
+
"#{halo.inspect}"
|
|
147
|
+
end
|
|
148
|
+
return region unless halo
|
|
149
|
+
|
|
150
|
+
region.reject { |(r, c), _| near_text?(grid, r, c, halo) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.near_text?(grid, r, c, halo)
|
|
154
|
+
near = [[r, c - 1], [r, c + 1]]
|
|
155
|
+
near += [[r - 1, c], [r + 1, c]] if halo == :around
|
|
156
|
+
near.any? { |y, x| text_cell?(grid, y, x) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.text_cell?(grid, r, c)
|
|
160
|
+
cell = r.between?(0, grid.size - 1) && c >= 0 && grid[r][c]
|
|
161
|
+
cell && !["", " "].include?(cell) && !cell.match?(STRUCTURE)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.paint(grid, codes, inside, glyphs)
|
|
165
|
+
grid.each_with_index.map do |row, r|
|
|
166
|
+
painted = row.each_with_index.map do |cell, c|
|
|
167
|
+
fill = glyphs[(r + c) % glyphs.size]
|
|
168
|
+
"#{codes[[r, c]]}#{inside[[r, c]] ? fill : cell}"
|
|
169
|
+
end
|
|
170
|
+
painted.join + codes[[r, :end]].to_s
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.fill(...) = Compose.fill(...)
|
|
176
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# grid: figures in rows of columns: (DESIGN.md, "Blocks and
|
|
5
|
+
# composition") -- the dashboard layout other libraries have, built
|
|
6
|
+
# from beside and stack: each row's figures side by side, gap:
|
|
7
|
+
# columns apart and aligned by align:, the rows stacked row_gap:
|
|
8
|
+
# rows apart. A short last row stays short.
|
|
9
|
+
module Compose
|
|
10
|
+
GRID = { columns: 2, gap: 2, row_gap: 1, align: :top }.freeze
|
|
11
|
+
|
|
12
|
+
# (*figures, columns: 2, gap: 2, row_gap: 1, align: :top,
|
|
13
|
+
# ascii: false)
|
|
14
|
+
def self.grid(*figures, ascii: Target.ascii?, **layout)
|
|
15
|
+
raise ArgumentError, "grid takes one figure or more" if
|
|
16
|
+
figures.empty?
|
|
17
|
+
|
|
18
|
+
o = GRID.merge(layout)
|
|
19
|
+
Check.positive_integer!(o[:columns], "columns")
|
|
20
|
+
rows = blocks(figures, ascii).each_slice(o[:columns]).map do |row|
|
|
21
|
+
grid_row(row, o, ascii)
|
|
22
|
+
end
|
|
23
|
+
return rows.first if rows.size == 1
|
|
24
|
+
|
|
25
|
+
stack(*rows, gap: o[:row_gap], ascii: ascii)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.grid_row(row, o, ascii)
|
|
29
|
+
return row.first if row.size == 1
|
|
30
|
+
|
|
31
|
+
beside(*row, gap: o[:gap], align: o[:align], ascii: ascii)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.grid(...) = Compose.grid(...)
|
|
36
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# shadow: a drop shadow, as the ascii-art skill's ui.md draws it
|
|
5
|
+
# and the lint checks it (DESIGN.md, "Blocks and composition"): the
|
|
6
|
+
# glyph one column right of every row but the first, and a bottom
|
|
7
|
+
# bar one row down, from one column in to that same column, so the
|
|
8
|
+
# L closes at both ends. ░ by default, # under ascii: true.
|
|
9
|
+
module Compose
|
|
10
|
+
SHADOW = "░"
|
|
11
|
+
ASCII_SHADOW = "#"
|
|
12
|
+
|
|
13
|
+
def self.shadow(figure, glyph: nil, ascii: Target.ascii?)
|
|
14
|
+
block, = blocks([figure], ascii)
|
|
15
|
+
g = shadow_glyph!(glyph, ascii)
|
|
16
|
+
width = block.width
|
|
17
|
+
rows = block.lines.each_with_index.map do |line, i|
|
|
18
|
+
i.zero? ? line : Width.ljust(line, width) + g
|
|
19
|
+
end
|
|
20
|
+
Block.new([*rows, " #{g * width}"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.shadow_glyph!(glyph, ascii)
|
|
24
|
+
glyph!(glyph || (ascii ? ASCII_SHADOW : SHADOW), "shadow glyph",
|
|
25
|
+
ascii)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A glyph drawn into a figure: one visible column, ASCII under
|
|
29
|
+
# ascii: true.
|
|
30
|
+
def self.glyph!(glyph, what, ascii)
|
|
31
|
+
unless glyph.is_a?(String) && glyph.length == 1 &&
|
|
32
|
+
Width.of(glyph) == 1 && glyph.match?(/\A[[:graph:]]\z/)
|
|
33
|
+
raise ArgumentError, "#{what} must be one visible column: " \
|
|
34
|
+
"#{glyph.inspect}"
|
|
35
|
+
end
|
|
36
|
+
structure!(what, glyph, ascii)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.shadow(...) = Compose.shadow(...)
|
|
41
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# The family every tessella refusal belongs to.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A value in the data that cannot be drawn: not a finite number,
|
|
8
|
+
# a negative pie slice, a candle whose low and high do not hold
|
|
9
|
+
# it, an empty sample. An ArgumentError, as every bad argument
|
|
10
|
+
# is, kept apart so the command line can call it bad input (exit
|
|
11
|
+
# 1) rather than a usage mistake (exit 2).
|
|
12
|
+
class ValueError < ArgumentError; end
|
|
13
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Turns a ready-made body of cells into text: border, title, axis
|
|
5
|
+
# labels and legend (DESIGN.md, "Frame and Color"). Frame knows
|
|
6
|
+
# nothing of data; the body and every label arrive as given. Labels
|
|
7
|
+
# are measured in display columns (Width), not characters, so a
|
|
8
|
+
# wide-character label keeps the frame aligned.
|
|
9
|
+
# The lines a Frame draws under its bottom border: a row of text
|
|
10
|
+
# aligned with the body (under:), and the x-axis extremes at its two
|
|
11
|
+
# ends (bottom:). Kept apart so Frame's own class body stays under
|
|
12
|
+
# budget.
|
|
13
|
+
module FrameBelow
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Rows of text under the body, column for column (a column
|
|
17
|
+
# chart's names, a legend wrapped by Legend).
|
|
18
|
+
def under_line
|
|
19
|
+
return nil if @under.to_s.empty?
|
|
20
|
+
|
|
21
|
+
indent = lead + (" " * Width.of(border_part(:l)))
|
|
22
|
+
@under.lines(chomp: true).map { indent + it }.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def bottom_extremes_line
|
|
26
|
+
left_text, right_text = Array(@bottom)
|
|
27
|
+
return nil unless left_text || right_text
|
|
28
|
+
|
|
29
|
+
extremes_line(left_text.to_s, right_text.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The two ends of the x axis, a space between them at least,
|
|
33
|
+
# even when they are wider than the body.
|
|
34
|
+
def extremes_line(left_text, right_text)
|
|
35
|
+
gap = extremes_gap(left_text, right_text)
|
|
36
|
+
gap = " " if gap.empty? && !left_text.empty? && !right_text.empty?
|
|
37
|
+
lead + left_text + gap + right_text
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extremes_gap(left_text, right_text)
|
|
41
|
+
lw = Width.of(left_text)
|
|
42
|
+
cnt1 = ((width / 2.0) - lw).round
|
|
43
|
+
cnt2 = width - Width.of(right_text) - lw + 2 - cnt1
|
|
44
|
+
pad(cnt1) + pad(cnt2)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pad(count) = count.positive? ? " " * count : ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Frame
|
|
51
|
+
include FrameBelow
|
|
52
|
+
|
|
53
|
+
MARGIN = 3
|
|
54
|
+
GUTTER = 1
|
|
55
|
+
|
|
56
|
+
UNICODE_BORDERS = {
|
|
57
|
+
box: { tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
58
|
+
t: "─", b: "─", l: "│", r: "│" },
|
|
59
|
+
bar: { tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
60
|
+
t: " ", b: " ", l: "┤", r: nil }
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
ASCII_BORDERS = {
|
|
64
|
+
box: { tl: "+", tr: "+", bl: "+", br: "+",
|
|
65
|
+
t: "-", b: "-", l: "|", r: "|" },
|
|
66
|
+
bar: { tl: "+", tr: "+", bl: "+", br: "+",
|
|
67
|
+
t: " ", b: " ", l: "|", r: nil }
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# DESIGN.md fixes this exact keyword interface: eleven named
|
|
71
|
+
# options.
|
|
72
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
73
|
+
def initialize(body:, border: :box, ascii: false, color: false,
|
|
74
|
+
title: nil, xlabel: nil, ylabel: nil,
|
|
75
|
+
left: {}, right: {}, bottom: nil, under: nil)
|
|
76
|
+
@body = body
|
|
77
|
+
@border = border == :none ? nil : borders(ascii).fetch(border)
|
|
78
|
+
@color = color
|
|
79
|
+
@title = title
|
|
80
|
+
@xlabel = xlabel
|
|
81
|
+
@ylabel = ylabel
|
|
82
|
+
@left = left
|
|
83
|
+
@right = right
|
|
84
|
+
@bottom = bottom
|
|
85
|
+
@under = under
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Under target :web, emoji-capable glyphs kept as text (Target).
|
|
89
|
+
def to_s
|
|
90
|
+
Target.adapt("#{lines.map(&:rstrip).join("\n")}\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def borders(ascii) = ascii ? ASCII_BORDERS : UNICODE_BORDERS
|
|
96
|
+
def width = @body.first&.length || 0
|
|
97
|
+
def height = @body.length
|
|
98
|
+
def ylabel = @ylabel.to_s.empty? ? nil : @ylabel
|
|
99
|
+
|
|
100
|
+
def max_left = @max_left ||= left_column_width
|
|
101
|
+
|
|
102
|
+
def lines
|
|
103
|
+
[title_line, top_line, *row_lines, bottom_line, under_line,
|
|
104
|
+
bottom_extremes_line, xlabel_line].compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def left_column_width
|
|
108
|
+
base = @left.values.map { Width.of(it) }.max || 0
|
|
109
|
+
ylabel ? base + Width.of(ylabel) + 1 : base
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def lead
|
|
113
|
+
" " * (MARGIN + max_left + GUTTER)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def title_line = label_line(@title, width)
|
|
117
|
+
def xlabel_line = label_line(@xlabel, width)
|
|
118
|
+
|
|
119
|
+
# Each line of text centred under the body.
|
|
120
|
+
def label_line(text, width)
|
|
121
|
+
return nil if text.to_s.empty?
|
|
122
|
+
|
|
123
|
+
text.to_s.lines(chomp: true).map do |line|
|
|
124
|
+
offset = [((width / 2.0) - (Width.of(line) / 2.0)).round, 0].max
|
|
125
|
+
lead + (" " * offset) + line
|
|
126
|
+
end.join("\n")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def top_line
|
|
130
|
+
return nil unless @border
|
|
131
|
+
|
|
132
|
+
lead + @border[:tl] + (@border[:t] * width) + @border[:tr]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def bottom_line
|
|
136
|
+
return nil unless @border
|
|
137
|
+
|
|
138
|
+
lead + @border[:bl] + (@border[:b] * width) + @border[:br]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def row_lines = (0...height).map { row_line(it) }
|
|
142
|
+
|
|
143
|
+
def row_line(row)
|
|
144
|
+
left_str = @left.fetch(row, "")
|
|
145
|
+
right_str = @right.fetch(row, "")
|
|
146
|
+
left_part(row, left_str) + (" " * GUTTER) + border_part(:l) +
|
|
147
|
+
body_row(row) + border_part(:r) + (" " * GUTTER) + right_str
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def left_part(row, left_str)
|
|
151
|
+
prefix = ylabel_row?(row) ? ylabel : ""
|
|
152
|
+
fill = max_left - Width.of(prefix) - Width.of(left_str)
|
|
153
|
+
(" " * MARGIN) + prefix + (" " * fill) + left_str
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def ylabel_row?(row) = ylabel && row == ylabel_row
|
|
157
|
+
def ylabel_row = (height / 2.0).round - 1
|
|
158
|
+
|
|
159
|
+
def border_part(side) = @border&.fetch(side, nil) || ""
|
|
160
|
+
|
|
161
|
+
def body_row(row)
|
|
162
|
+
cells = @body[row]
|
|
163
|
+
return cells.map(&:char).join unless @color
|
|
164
|
+
|
|
165
|
+
cells.chunk_while { |a, b| a.color == b.color }.map do |run|
|
|
166
|
+
Color.wrap(run.map(&:char).join, run.first.color)
|
|
167
|
+
end.join
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# The named glyph sets (DESIGN.md, "Glyphs"), from the ascii-art
|
|
5
|
+
# skill's glyph palette and ui.md: border styles, fill patterns,
|
|
6
|
+
# meter fill/track pairs, rule lines and tone ramps, each with the
|
|
7
|
+
# width classes its glyphs fall into, computed from Width, never
|
|
8
|
+
# typed in. A set whose glyphs span two classes is "mixed": fine in
|
|
9
|
+
# Western terminals, but it changes length in CJK-configured ones.
|
|
10
|
+
# Each set also says which width targets it is safe for.
|
|
11
|
+
module Glyphs
|
|
12
|
+
GlyphSet = Data.define(:kind, :name, :glyphs, :note) do
|
|
13
|
+
def classes = Width.classes(glyphs)
|
|
14
|
+
def mixed? = classes.size > 1
|
|
15
|
+
def targets = Glyphs.targets(glyphs)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The targets (Target) a glyph set is safe for: term always;
|
|
19
|
+
# cjk with no ambiguous glyph, as a CJK terminal widens those
|
|
20
|
+
# and nothing else; web with no wide glyph, which browser fonts
|
|
21
|
+
# do not hold to two columns, and no emoji-capable one.
|
|
22
|
+
def self.targets(glyphs)
|
|
23
|
+
chars = glyphs.chars.reject { it == " " }
|
|
24
|
+
[:term,
|
|
25
|
+
(:cjk unless Width.classes(glyphs).include?(:A)),
|
|
26
|
+
(:web unless chars.any? { web_risk?(it) })].compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Wide, emoji-capable, or braille, which a browser draws from
|
|
30
|
+
# a fallback font wider than a column.
|
|
31
|
+
def self.web_risk?(char)
|
|
32
|
+
Width.class_of(char) == :W || Target.emoji_risk?(char.ord) ||
|
|
33
|
+
char.ord.between?(0x2801, 0x28FF)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
PATTERNS = {
|
|
37
|
+
light: "░", medium: "▒", dark: "▓", solid: "█",
|
|
38
|
+
hatch: "╱", backhatch: "╲", cross: "╳", grid: "┼",
|
|
39
|
+
stipple: "∴∵", sparse: "·", dotted: "∘",
|
|
40
|
+
dots: ".", colons: ":", hash: "#", at: "@"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Fill then track, as ui.md's progress-bar styles.
|
|
44
|
+
METERS = {
|
|
45
|
+
arrow: "▸▹", chevron: "› ", classic: "= ", rectangle: "▮▯",
|
|
46
|
+
wave: "~_", blade: "▰▱", button: "⦿⦾", block: "█·",
|
|
47
|
+
box: "■□", circle: "●○", star: "★☆"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
RULES = {
|
|
51
|
+
light: "─", heavy: "━", double: "═", dashed: "┄", ascii: "-"
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# Light to dense, a blank first.
|
|
55
|
+
RAMPS = {
|
|
56
|
+
ramp: " .:-=+*#%@", blocks: " ░▒▓█",
|
|
57
|
+
long: " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZm" \
|
|
58
|
+
"wqpdbkhao*#MW&8%B@$",
|
|
59
|
+
hatch: " ·∘╱╳"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Arrow heads, right left up down (the ascii-art skill's rule):
|
|
63
|
+
# full-size for any target, though ▲ ▼ are ambiguous, two
|
|
64
|
+
# columns in a CJK terminal; small ones line up everywhere,
|
|
65
|
+
# ▴ ▾ standing in for ▲ ▼ there; ▶ ◀ may turn into wide emoji
|
|
66
|
+
# on the web.
|
|
67
|
+
HEADS = { full: "►◄▲▼", small: "▸◂▴▾", emoji: "▶◀" }.freeze
|
|
68
|
+
|
|
69
|
+
NOTES = {
|
|
70
|
+
meter: { blade: "patchy font coverage",
|
|
71
|
+
button: "patchy font coverage",
|
|
72
|
+
block: "house style" },
|
|
73
|
+
head: { full: "▴▾ under cjk", emoji: "+U+FE0E on web" }
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def self.all
|
|
77
|
+
@all ||= [*borders, *table(:pattern, PATTERNS),
|
|
78
|
+
*table(:meter, METERS), *table(:rule, RULES),
|
|
79
|
+
*table(:ramp, RAMPS), *table(:head, HEADS)].freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.borders
|
|
83
|
+
Borders::STYLES.map do |name, set|
|
|
84
|
+
GlyphSet.new(kind: :border, name: name,
|
|
85
|
+
glyphs: set.to_h.values.join, note: nil)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.table(kind, sets)
|
|
90
|
+
sets.map do |name, glyphs|
|
|
91
|
+
note = NOTES.dig(kind, name)
|
|
92
|
+
GlyphSet.new(kind: kind, name: name, glyphs: glyphs, note: note)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The set of that kind and name, or ArgumentError naming both.
|
|
97
|
+
def self.fetch(kind, name)
|
|
98
|
+
key = Borders.name(name)
|
|
99
|
+
all.find { it.kind == kind && it.name == key } or
|
|
100
|
+
raise ArgumentError, "unknown #{kind}: #{name.inspect} " \
|
|
101
|
+
"(#{names(kind).join(", ")})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.names(kind) = all.select { it.kind == kind }.map(&:name)
|
|
105
|
+
end
|
|
106
|
+
end
|