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.
Files changed (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. 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