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,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
module Lint
|
|
5
|
+
# Drop shadows: ░ (U+2591) one column right of a frame on every
|
|
6
|
+
# row but the first, and a bottom bar one row down, from one
|
|
7
|
+
# column in to the shadow column. The slips are a shadow on the
|
|
8
|
+
# closing row only and a bar one cell short; both look fine a
|
|
9
|
+
# line at a time. Only a frame that already carries a shadow is
|
|
10
|
+
# inspected: ░ as fill never sits against a box-drawing edge.
|
|
11
|
+
module Shadows
|
|
12
|
+
SHADOW = "░"
|
|
13
|
+
TOP_LEFT = "┌┏╔╭┎┍"
|
|
14
|
+
BOT_LEFT = "└┗╚╰┖┕"
|
|
15
|
+
SHADOW_TAIL = /#{SHADOW}+\z/
|
|
16
|
+
SHADOW_BAR = /\A *#{SHADOW}+\z/
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def check_shadows(sec)
|
|
21
|
+
row = 0
|
|
22
|
+
row = shadow_at(sec, row) while row < sec.lines.length
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The row to resume from: past a shadowed frame opened at
|
|
26
|
+
# row, once checked, or the next row.
|
|
27
|
+
def shadow_at(sec, row)
|
|
28
|
+
span = frame_span(sec.lines, row)
|
|
29
|
+
return row + 1 unless span&.any? { shadow_tail?(sec.lines[it]) }
|
|
30
|
+
|
|
31
|
+
check_shadow_geometry(sec, span)
|
|
32
|
+
span.last + 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Rows top..bottom of the frame opened at top, or nil: the
|
|
36
|
+
# run ends at the first row whose left edge is a bottom-left
|
|
37
|
+
# corner in the same display column.
|
|
38
|
+
def frame_span(lines, top)
|
|
39
|
+
left = left_edge(lines[top])
|
|
40
|
+
return nil unless left && TOP_LEFT.include?(left[0])
|
|
41
|
+
|
|
42
|
+
((top + 1)...lines.length).each do |i|
|
|
43
|
+
edge = left_edge(lines[i])
|
|
44
|
+
return nil unless edge && edge[1] == left[1]
|
|
45
|
+
return top..i if BOT_LEFT.include?(edge[0])
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [first non-blank char, its display column], or nil.
|
|
51
|
+
def left_edge(line)
|
|
52
|
+
indent = line[/\A */]
|
|
53
|
+
return nil if indent.length == line.length
|
|
54
|
+
|
|
55
|
+
[line[indent.length], @overhead + indent.length + 1]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A shadow cell hanging off a box-drawing edge; a run wider
|
|
59
|
+
# than one cell is a column error, left to the geometry.
|
|
60
|
+
def shadow_tail?(line)
|
|
61
|
+
rest = unshadowed(line)
|
|
62
|
+
rest.length < line.length && rest[-1].to_s.match?(BOX_CHAR)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unshadowed(line) = line.sub(SHADOW_TAIL, "")
|
|
66
|
+
|
|
67
|
+
def check_shadow_geometry(sec, span)
|
|
68
|
+
top = sec.lines[span.first]
|
|
69
|
+
if shadow_tail?(top)
|
|
70
|
+
report(sec, span.first, "drop shadow on the frame's " \
|
|
71
|
+
"top row (offset it one row " \
|
|
72
|
+
"down)")
|
|
73
|
+
end
|
|
74
|
+
# Off the frame edge, so a stray top-row shadow does not
|
|
75
|
+
# drag the reference column with it.
|
|
76
|
+
col = end_column(unshadowed(top)) + 1
|
|
77
|
+
span.drop(1).each { check_shadow_row(sec, it, col) }
|
|
78
|
+
check_shadow_bar(sec, span, col)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_shadow_row(sec, i, col)
|
|
82
|
+
line = sec.lines[i]
|
|
83
|
+
unless shadow_tail?(line)
|
|
84
|
+
return report(sec, i, "row has no drop shadow at " \
|
|
85
|
+
"column #{col}")
|
|
86
|
+
end
|
|
87
|
+
actual = end_column(line)
|
|
88
|
+
return if actual == col
|
|
89
|
+
|
|
90
|
+
report(sec, i, "drop shadow at column #{actual}, frame's " \
|
|
91
|
+
"is at #{col}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The bar runs from one column right of the frame's left
|
|
95
|
+
# edge to the shadow column, so the L closes on both ends.
|
|
96
|
+
def check_shadow_bar(sec, span, col)
|
|
97
|
+
bar = sec.lines[span.last + 1]
|
|
98
|
+
unless bar&.match?(SHADOW_BAR)
|
|
99
|
+
return report(sec, span.last,
|
|
100
|
+
"drop shadow has no bottom bar")
|
|
101
|
+
end
|
|
102
|
+
left = left_edge(sec.lines[span.first])[1]
|
|
103
|
+
check_bar_ends(sec, span.last + 1, bar, [left + 1, col])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def check_bar_ends(sec, i, bar, ends)
|
|
107
|
+
start = left_edge(bar)[1]
|
|
108
|
+
stop = end_column(bar)
|
|
109
|
+
if start != ends[0]
|
|
110
|
+
report(sec, i, "bottom bar starts at column #{start}, " \
|
|
111
|
+
"should start at #{ends[0]}")
|
|
112
|
+
end
|
|
113
|
+
return if stop == ends[1]
|
|
114
|
+
|
|
115
|
+
report(sec, i, "bottom bar ends at column #{stop}, " \
|
|
116
|
+
"should reach the shadow column #{ends[1]}")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
module Lint
|
|
5
|
+
# The checks a target adds to a line (DESIGN.md, "Width
|
|
6
|
+
# targets"): under :web, emoji-capable glyphs, and wide glyphs
|
|
7
|
+
# or braille on an aligned row, which a browser does not keep a
|
|
8
|
+
# column wide; under :cjk, ambiguous glyphs on an aligned row,
|
|
9
|
+
# two columns there. Kept apart so Lines stays under budget.
|
|
10
|
+
module TargetRows
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def check_target_row(sec, i, line)
|
|
14
|
+
check_web_row(sec, i, line) if @opts[:web]
|
|
15
|
+
check_cjk_row(sec, i, line) if @opts[:target] == :cjk
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_web_char(sec, i, ch)
|
|
19
|
+
return unless Lint.emoji_risk?(ch.ord)
|
|
20
|
+
|
|
21
|
+
report(sec, i, "#{glyph("emoji-capable", ch)} (web target)")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check_web_row(sec, i, line)
|
|
25
|
+
return unless line.match?(STRUCTURAL)
|
|
26
|
+
|
|
27
|
+
line.each_char do |ch|
|
|
28
|
+
next unless Lint.wide?(ch.ord)
|
|
29
|
+
|
|
30
|
+
report(sec, i, "#{glyph("wide", ch)} on aligned row " \
|
|
31
|
+
"(web target)")
|
|
32
|
+
end
|
|
33
|
+
check_web_braille(sec, i, line)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Braille comes from a fallback font in a browser, wider
|
|
37
|
+
# than a column: named once a row, as a chart has many.
|
|
38
|
+
def check_web_braille(sec, i, line)
|
|
39
|
+
ch = line[BRAILLE] or return
|
|
40
|
+
|
|
41
|
+
report(sec, i, "#{glyph("braille", ch)} on aligned row " \
|
|
42
|
+
"(web target: not a column wide)")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Under :cjk an ambiguous glyph that is not box drawing (·, é,
|
|
46
|
+
# ●) on a row that must line up takes two columns: named,
|
|
47
|
+
# as the drift and geometry checks measure it so.
|
|
48
|
+
def check_cjk_row(sec, i, line)
|
|
49
|
+
return unless line.match?(STRUCTURAL)
|
|
50
|
+
|
|
51
|
+
line.each_char.grep_v(BOX_CHAR).uniq.each do |ch|
|
|
52
|
+
next unless Width.class_of(ch) == :A
|
|
53
|
+
|
|
54
|
+
report(sec, i, "#{glyph("ambiguous", ch)} on aligned row " \
|
|
55
|
+
"(cjk target: 2 columns#{stand_in(ch)})")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The small head that lines up in place of a full-size one.
|
|
60
|
+
def stand_in(ch)
|
|
61
|
+
small = { "▲" => "▴", "▼" => "▾" }[ch]
|
|
62
|
+
small ? "; #{small} lines up" : ""
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A lint for text figures (DESIGN.md, "Lint"), ported from the
|
|
5
|
+
# ascii-art skill's scripts/artlint.rb: tabs, invisible glyphs,
|
|
6
|
+
# trailing blanks, a column budget, forbidden comment-breaking
|
|
7
|
+
# sequences, drop-shadow geometry, ASCII structure mixed with
|
|
8
|
+
# Unicode box drawing, a vertical anchor one column off -- and, on
|
|
9
|
+
# request, box sides and connectors (geometry: true) and one
|
|
10
|
+
# Markdown fence at a time (fences: true). Tessella.lint and
|
|
11
|
+
# Tessella.alphabet, at the bottom of this file, are the entries.
|
|
12
|
+
module Lint
|
|
13
|
+
# One finding: the line of the text it is on, 1-based, and what
|
|
14
|
+
# is wrong there.
|
|
15
|
+
Finding = Data.define(:line, :message)
|
|
16
|
+
|
|
17
|
+
# One --ruler row: a line's rendered width (the prefix and
|
|
18
|
+
# suffix included) and the display columns of its verticals.
|
|
19
|
+
Ruler = Data.define(:line, :width, :verticals)
|
|
20
|
+
|
|
21
|
+
# What Tessella.lint returns: every finding, in the order
|
|
22
|
+
# artlint.rb reported them, and a Ruler per line linted.
|
|
23
|
+
Report = Data.define(:findings, :rulers) do
|
|
24
|
+
def clean? = findings.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A run of lines linted as one text: the whole input, or one
|
|
28
|
+
# fenced block's body. first is the input line of lines[0];
|
|
29
|
+
# exempt says an allow-mixed marker came just before the fence.
|
|
30
|
+
Section = Data.define(:lines, :first, :exempt)
|
|
31
|
+
|
|
32
|
+
VERTICALS = "│┃║╎╏┆┇┊┋|"
|
|
33
|
+
BRAILLE = /[\u2801-\u28FF]/
|
|
34
|
+
STRUCTURAL = /[─-╿|]/
|
|
35
|
+
BOX_CHAR = /[─-╿]/
|
|
36
|
+
|
|
37
|
+
# Box structure in ASCII: a line with one of these pairs, or a
|
|
38
|
+
# | at its first or last non-blank column. The bare "--" is
|
|
39
|
+
# guarded by word boundaries, so "--align" in a label and
|
|
40
|
+
# "well--known" in a caption are words, not a rule.
|
|
41
|
+
ASCII_STRUCT_PAIR =
|
|
42
|
+
/\+-|-\+|(?<!\w)--(?!\w)|\|-|-\||\+\||\|\+|->|<-/
|
|
43
|
+
INLINE_CODE = /`[^`\n]*`/
|
|
44
|
+
|
|
45
|
+
# Exempts the one figure that follows it from the mixing check,
|
|
46
|
+
# whatever comment syntax wraps it: <!-- artlint: allow-mixed -->,
|
|
47
|
+
# # tessella: allow-mixed, ...
|
|
48
|
+
MIXED_MARKER = /(?:artlint|tessella):\s*allow-mixed/
|
|
49
|
+
|
|
50
|
+
# Comment-breaking sequences per host syntax, for forbid:.
|
|
51
|
+
# :trailing_backslash is a positional check, not a substring.
|
|
52
|
+
FORBID_PRESETS = {
|
|
53
|
+
"xml" => ["--"], "html" => ["--"],
|
|
54
|
+
"c-block" => ["*/", "/*"], "rust" => ["*/", "/*"],
|
|
55
|
+
"c-line" => [:trailing_backslash],
|
|
56
|
+
"haskell" => ["{-", "-}"], "ocaml" => ["(*", "*)"],
|
|
57
|
+
"pascal" => ["}", "(*", "*)"], "d" => ["/+", "+/"],
|
|
58
|
+
"lisp" => ["#|", "|#"], "lua" => ["]]"],
|
|
59
|
+
"python-doc" => ['"""'], "cdata" => ["]]>"],
|
|
60
|
+
"markdown-fence" => ["```", "~~~"]
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
DEFAULTS = { width: 78, prefix: "", suffix: "", web: false,
|
|
64
|
+
target: nil,
|
|
65
|
+
forbid: [],
|
|
66
|
+
no_wide: false, ascii: false, allow_mixed: false,
|
|
67
|
+
fences: false, geometry: false, borders: [] }.freeze
|
|
68
|
+
|
|
69
|
+
def self.wide?(code) = Width.char(code) == 2
|
|
70
|
+
def self.invisible?(code) = Width.char(code).zero?
|
|
71
|
+
|
|
72
|
+
# A space separator (Zs) other than U+0020: U+00A0, U+202F,
|
|
73
|
+
# U+3000, ... Drawn as a blank, so no display tells it from a
|
|
74
|
+
# space, yet a figure holding one is always an accident.
|
|
75
|
+
def self.look_alike_space?(ch) = ch != " " && ch.match?(/\p{Zs}/)
|
|
76
|
+
|
|
77
|
+
def self.emoji_risk?(code) = Target.emoji_risk?(code)
|
|
78
|
+
|
|
79
|
+
def self.width_of(text) = text.each_char.sum { Width.char(it.ord) }
|
|
80
|
+
|
|
81
|
+
# Linted for opts[:target] (web: true is :web), else the
|
|
82
|
+
# current one (Target): widths are that target's.
|
|
83
|
+
def self.run(source, opts)
|
|
84
|
+
resolved = Options.resolve(DEFAULTS.merge(opts))
|
|
85
|
+
Target.with(resolved[:target]) do
|
|
86
|
+
Runner.new(resolved).run(text(source))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The source as UTF-8, invalid bytes kept for the lint to name:
|
|
91
|
+
# a String of bytes, or an IO read to its end.
|
|
92
|
+
def self.text(source)
|
|
93
|
+
raw = source.respond_to?(:read) ? source.read : source
|
|
94
|
+
unless raw.is_a?(String)
|
|
95
|
+
raise ArgumentError, "source must be a String or IO: " \
|
|
96
|
+
"#{source.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raw.dup.force_encoding(Encoding::UTF_8)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Validates Tessella.lint's options: width a positive Integer,
|
|
104
|
+
# prefix and suffix Strings, and forbid a list of preset names or literal
|
|
105
|
+
# sequences, resolved here to the sequences.
|
|
106
|
+
module Lint
|
|
107
|
+
module Options
|
|
108
|
+
def self.resolve(opts)
|
|
109
|
+
Check.positive_integer!(opts[:width], "width")
|
|
110
|
+
%i[prefix suffix].each { string!(opts, it) }
|
|
111
|
+
target = target(opts)
|
|
112
|
+
opts.merge(forbid: forbid(opts[:forbid]),
|
|
113
|
+
borders: borders(opts[:borders]),
|
|
114
|
+
target: target, web: target == :web)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.string!(opts, key)
|
|
118
|
+
return if opts[key].is_a?(String)
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "#{key} must be a String: " \
|
|
121
|
+
"#{opts[key].inspect}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.target(opts)
|
|
125
|
+
return Target.check!(opts[:target]) if opts[:target]
|
|
126
|
+
|
|
127
|
+
opts[:web] ? :web : Target.current
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Each a Borders name, Set or glyph String, as its Set.
|
|
131
|
+
def self.borders(list)
|
|
132
|
+
Array(list).map do |style|
|
|
133
|
+
next Borders.custom(style).first if Borders.custom?(style)
|
|
134
|
+
|
|
135
|
+
Borders.fetch(style)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.forbid(list)
|
|
140
|
+
Array(list).flat_map do |seq|
|
|
141
|
+
seq = seq.to_s
|
|
142
|
+
raise ArgumentError, "forbid: empty sequence" if
|
|
143
|
+
seq.empty?
|
|
144
|
+
|
|
145
|
+
FORBID_PRESETS.fetch(seq) { [seq] }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# width: 78, prefix: "", suffix: "", web: false, forbid: [],
|
|
152
|
+
# no_wide: false, ascii: false, allow_mixed: false, fences: false,
|
|
153
|
+
# geometry: false, borders: [] (Lint::DEFAULTS); returns a Lint::Report.
|
|
154
|
+
def self.lint(source, **opts) = Lint.run(source, opts)
|
|
155
|
+
|
|
156
|
+
# "ascii", or "utf-8" and the glyph classes the source holds.
|
|
157
|
+
def self.alphabet(source) = Lint::Alphabet.of(Lint.text(source))
|
|
158
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A one-line meter (DESIGN.md, "Meters and rules"): a progress bar
|
|
5
|
+
# or gauge, as the ascii-art skill's ui.md draws one --
|
|
6
|
+
# "[██████······] 50 %". The fill/track pair is a style from
|
|
7
|
+
# Glyphs::METERS or glyphs of one's own, of one width class; the
|
|
8
|
+
# block style fills its last cell by eighths. title: goes before
|
|
9
|
+
# the bar, the percentage after it unless label: false;
|
|
10
|
+
# title_width: pads the title to that many columns, so meters with
|
|
11
|
+
# titles of different lengths line up. orientation: :vertical
|
|
12
|
+
# stands the bar up (MeterUpright).
|
|
13
|
+
module Meter
|
|
14
|
+
EIGHTHS = " ▏▎▍▌▋▊▉".chars.freeze
|
|
15
|
+
|
|
16
|
+
DEFAULTS = { style: :block, title: nil, label: true,
|
|
17
|
+
brackets: true, ascii: false, title_width: nil }.freeze
|
|
18
|
+
|
|
19
|
+
# orientation: :horizontal takes width: (20), :vertical height:
|
|
20
|
+
# (8); the other is refused.
|
|
21
|
+
def self.build(fraction, orientation: :horizontal, **opts)
|
|
22
|
+
share = fraction!(fraction)
|
|
23
|
+
opts = sized!(orientation,
|
|
24
|
+
DEFAULTS.merge(ascii: Target.ascii?, **opts))
|
|
25
|
+
return MeterUpright.build(share, opts) if orientation == :vertical
|
|
26
|
+
|
|
27
|
+
Block.new([line(share, opts)])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.sized!(orientation, opts)
|
|
31
|
+
sizes = { horizontal: [:width, 20], vertical: [:height, 8] }
|
|
32
|
+
own, default = sizes.fetch(orientation) do
|
|
33
|
+
raise ArgumentError, "orientation must be :horizontal or " \
|
|
34
|
+
":vertical: #{orientation.inspect}"
|
|
35
|
+
end
|
|
36
|
+
other = (%i[width height] - [own]).first
|
|
37
|
+
raise ArgumentError, "a #{orientation} meter takes no #{other}:" if
|
|
38
|
+
opts.key?(other)
|
|
39
|
+
|
|
40
|
+
opts.merge(own => opts.fetch(own, default))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.line(share, opts)
|
|
44
|
+
Check.positive_integer!(opts[:width], "width")
|
|
45
|
+
bar = bar(share, opts[:width], glyphs(opts), eighths?(opts))
|
|
46
|
+
[title_of(opts), opts[:brackets] ? "[#{bar}]" : bar,
|
|
47
|
+
(percent(share) if opts[:label])].compact.join(" ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [fill, track]: the style's, or classic's under ascii:.
|
|
51
|
+
def self.glyphs(opts)
|
|
52
|
+
pair!(opts[:ascii] ? :classic : opts[:style], opts[:ascii])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.title_of(opts)
|
|
56
|
+
opts[:title] && title!(opts[:title], opts[:title_width],
|
|
57
|
+
opts[:ascii])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.eighths?(opts)
|
|
61
|
+
Borders.name(opts[:style]) == :block && !opts[:ascii]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The title, padded to title_width: columns when given.
|
|
65
|
+
def self.title!(title, title_width, ascii)
|
|
66
|
+
text = Compose.text!("title", title, ascii)
|
|
67
|
+
return text unless title_width
|
|
68
|
+
|
|
69
|
+
Check.positive_integer!(title_width, "title_width")
|
|
70
|
+
if Width.of(text) > title_width
|
|
71
|
+
raise ArgumentError, "title #{text.inspect} is wider than " \
|
|
72
|
+
"title_width #{title_width}"
|
|
73
|
+
end
|
|
74
|
+
Width.ljust(text, title_width)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A number 0..1, or a String "45%" / "0.45".
|
|
78
|
+
def self.fraction!(value)
|
|
79
|
+
number = value.is_a?(String) ? parse(value) : value
|
|
80
|
+
return number.to_f if number.is_a?(Numeric) &&
|
|
81
|
+
number.to_f.between?(0.0, 1.0)
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "fraction must be 0..1 or 0%..100%: " \
|
|
84
|
+
"#{value.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.parse(text)
|
|
88
|
+
return Float(text.delete_suffix("%"), exception: false)&./(100) if
|
|
89
|
+
text.end_with?("%")
|
|
90
|
+
|
|
91
|
+
Float(text, exception: false)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# [fill, track] from a style name or two glyphs of one's own.
|
|
95
|
+
def self.pair!(style, ascii)
|
|
96
|
+
key = Borders.name(style)
|
|
97
|
+
glyphs = Glyphs::METERS.fetch(key) { style.to_s }.chars
|
|
98
|
+
unless glyphs.size == 2
|
|
99
|
+
raise ArgumentError, "unknown meter style: #{style.inspect} " \
|
|
100
|
+
"(#{Glyphs::METERS.keys.join(", ")}, " \
|
|
101
|
+
"or a fill and a track glyph)"
|
|
102
|
+
end
|
|
103
|
+
same_class!(glyphs, style)
|
|
104
|
+
glyphs.each { Compose.text!("meter glyph", it, ascii) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.same_class!(glyphs, style)
|
|
108
|
+
classes = Width.classes(glyphs.join)
|
|
109
|
+
return if classes.size <= 1
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "meter style #{style.inspect} mixes width " \
|
|
112
|
+
"classes: #{classes.join(", ")}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# width glyphs, filled then tracked: counted in glyphs, not
|
|
116
|
+
# columns, so a two-column pair (or a cjk target's) keeps its
|
|
117
|
+
# length as it fills.
|
|
118
|
+
def self.bar(share, width, glyphs, eighths)
|
|
119
|
+
fill, track = glyphs
|
|
120
|
+
units = (share * width * (eighths ? 8 : 1)).round
|
|
121
|
+
full, rem = eighths ? units.divmod(8) : [units, 0]
|
|
122
|
+
cells = (fill * full) + (rem.zero? ? "" : EIGHTHS[rem])
|
|
123
|
+
cells + (track * (width - cells.length))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.percent(share) = "#{(share * 100).round} %"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# fraction (0..1, or "45%"), style: :block, width: 20, title: nil,
|
|
130
|
+
# label: true, brackets: true, ascii: false, title_width: nil,
|
|
131
|
+
# orientation: :horizontal (:vertical takes height: 8 instead).
|
|
132
|
+
def self.meter(fraction, **) = Meter.build(fraction, **)
|
|
133
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A meter standing up (orientation: :vertical): the bar a column of
|
|
5
|
+
# height: cells (8 by default), filled from the bottom -- the block
|
|
6
|
+
# style tops its fill by eighths, ▁ to ▇ -- between a ─ above and
|
|
7
|
+
# below (- in ASCII; none with brackets: false), the title over it
|
|
8
|
+
# and the percentage under it, each line centred on the bar. Meters
|
|
9
|
+
# side by side are `compose beside`'s.
|
|
10
|
+
module MeterUpright
|
|
11
|
+
RISE = " ▁▂▃▄▅▆▇".chars.freeze
|
|
12
|
+
|
|
13
|
+
def self.build(share, opts)
|
|
14
|
+
Check.positive_integer!(opts[:height], "height")
|
|
15
|
+
column = column(share, opts[:height], Meter.glyphs(opts),
|
|
16
|
+
Meter.eighths?(opts))
|
|
17
|
+
Block.new(centre(lines(share, column, opts)))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The bar's cells, top to bottom.
|
|
21
|
+
def self.column(share, height, glyphs, eighths)
|
|
22
|
+
fill, track = glyphs
|
|
23
|
+
units = (share * height * (eighths ? 8 : 1)).round
|
|
24
|
+
full, rem = eighths ? units.divmod(8) : [units, 0]
|
|
25
|
+
cells = Array.new(full, fill)
|
|
26
|
+
cells << RISE[rem] unless rem.zero?
|
|
27
|
+
(Array.new(height - cells.size, track) + cells.reverse)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.lines(share, column, opts)
|
|
31
|
+
cap = opts[:ascii] ? "-" : "─"
|
|
32
|
+
bar = opts[:brackets] ? [cap, *column, cap] : column
|
|
33
|
+
[Meter.title_of(opts), *bar,
|
|
34
|
+
(Meter.percent(share) if opts[:label])].compact
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every line centred on the widest; trailing blanks dropped.
|
|
38
|
+
def self.centre(lines)
|
|
39
|
+
widest = lines.map { Width.of(it) }.max
|
|
40
|
+
lines.map { Width.center(it, widest).rstrip }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|