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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Axis extremes and number labels for a chart's scale (DESIGN.md).
5
+ module Axis
6
+ INT64_RANGE =
7
+ (-9_223_372_036_854_775_808..9_223_372_036_854_775_807)
8
+
9
+ # lim [a, b] given and not [0, 0]: returned as is; otherwise the
10
+ # data's extent widened to round values exactly as unicode_plot
11
+ # widens it (its axis labels are the oracle).
12
+ def self.limits(values, lim = nil)
13
+ lim ||= [0, 0]
14
+ auto = lim == [0, 0]
15
+ mi, ma = (auto ? values : lim).minmax.map(&:to_f)
16
+ mi, ma = widen(mi) if mi == ma
17
+ auto ? narrow(mi, ma) : [mi, ma]
18
+ end
19
+
20
+ # Integer as is; Float in the shortest round-tripping form, no
21
+ # exponent for |x| in 1e-4...1e15 (Ruby's own Float#to_s already
22
+ # holds to that range, and keeps a Float's digits where an
23
+ # Integer input has none).
24
+ def self.label(number) = number.to_s
25
+
26
+ # An axis extreme: limits are Floats, and a whole one prints
27
+ # without its ".0" (10, not 10.0), as unicode_plot prints them;
28
+ # any other value as label does. For value labels, use label.
29
+ def self.tick(number)
30
+ number = number.to_i if number.is_a?(Float) && roundable?(number)
31
+ label(number)
32
+ end
33
+
34
+ # +-1, as unicode_plot widens a constant; from about 1e16 up a
35
+ # Float cannot hold mi +- 1, so the step grows by tens until it
36
+ # changes mi (unicode_plot crashes there instead).
37
+ def self.widen(mi)
38
+ d = 1.0
39
+ d *= 10 while mi + d == mi || mi - d == mi
40
+ [mi - d, mi + d]
41
+ end
42
+
43
+ def self.narrow(mi, ma)
44
+ diff = ma - mi
45
+ lo = round_subtick(mi, diff, :down)
46
+ hi = round_subtick(ma, diff, :up)
47
+ [lo.to_f, hi.to_f]
48
+ end
49
+
50
+ def self.round_subtick(x, m, dir)
51
+ return 0.0 if x.zero?
52
+
53
+ digits = neg_log10_digits(m)
54
+ up = dir == :up
55
+ if x.positive?
56
+ up ? x.ceil(digits) : x.floor(digits)
57
+ else
58
+ up ? -(-x).floor(digits) : -(-x).ceil(digits)
59
+ end
60
+ end
61
+
62
+ def self.neg_log10_digits(m)
63
+ v = -Math.log10(m)
64
+ roundable?(v) ? v.ceil + 1 : v.floor + 1
65
+ end
66
+
67
+ def self.roundable?(x)
68
+ x.finite? && INT64_RANGE.cover?(x) && x.to_i == x
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Bitfield (ported byte for byte from the ascii-art skill's
5
+ # scripts/bitfield.rb): an RFC-style packet / bit-field diagram --
6
+ # a two-row bit ruler, "+-+" tick rows, and each field's name
7
+ # centred in its own cell. Pure ASCII always; RFC 2360's own
8
+ # convention, so there is no Unicode mode (no "ascii:" option).
9
+ #
10
+ # `fields` is either the script's "name:bits" text (# comments and
11
+ # blank lines skipped) or an Array of [name, bits] pairs. A field
12
+ # wider than the bits left in its row continues on the next
13
+ # row(s), the name repeated (or truncated under abbrev:) in each
14
+ # one, as RFC 791 draws its Options field.
15
+ module Bitfield
16
+ WORD_SIZES = [8, 16, 32, 64].freeze
17
+ FIELD_RE = /\A\s*([^:]+?)\s*:\s*(\d+)\s*\z/
18
+
19
+ Field = Struct.new(:name, :bits, :line)
20
+ RowState = Struct.new(:cursor, :row, :rows)
21
+
22
+ def self.block(fields, word:, octets:, ruler:, abbrev:)
23
+ word!(word)
24
+ rows = layout_rows(ascii_names!(fields_for(fields)), word, abbrev)
25
+ show_ruler = ruler && !octets
26
+ Block.new(render(rows, word, show_ruler, octets))
27
+ end
28
+
29
+ def self.word!(word)
30
+ return word if WORD_SIZES.include?(word)
31
+
32
+ raise ArgumentError, "word must be 8, 16, 32 or 64: " \
33
+ "#{word.inspect}"
34
+ end
35
+
36
+ # The script's own text format, or the fields already given.
37
+ # A bit-field is drawn in ASCII only, so its names must be.
38
+ def self.ascii_names!(fields)
39
+ fields.each_with_index do |f, i|
40
+ next if f.name.ascii_only?
41
+
42
+ where = f.line ? "line #{f.line}" : "field #{i + 1}"
43
+ raise ArgumentError, "#{where}: name #{f.name.inspect} is " \
44
+ "non-ASCII (a bit-field is ASCII)"
45
+ end
46
+ end
47
+
48
+ def self.fields_for(fields)
49
+ return parse(fields) if fields.is_a?(String)
50
+
51
+ fields.each_with_index.map { |(name, bits), i| pair(name, bits, i) }
52
+ end
53
+
54
+ def self.pair(name, bits, index)
55
+ unless bits.is_a?(Integer) && bits.positive?
56
+ raise ArgumentError, "field #{index + 1} (#{name.inspect}): " \
57
+ "bits must be a positive Integer: " \
58
+ "#{bits.inspect}"
59
+ end
60
+ Field.new(name.to_s, bits, nil)
61
+ end
62
+ end
63
+
64
+ # Parsing the "name:bits" text format: one field per line, "#"
65
+ # comments and blank lines skipped.
66
+ module Bitfield
67
+ def self.parse(text)
68
+ fields = text.each_line.with_index(1).filter_map do |raw, lineno|
69
+ line = raw.chomp
70
+ next if line.strip.empty? || line.lstrip.start_with?("#")
71
+
72
+ parse_line(line, lineno)
73
+ end
74
+ raise ArgumentError, "no fields given" if fields.empty?
75
+
76
+ fields
77
+ end
78
+
79
+ def self.parse_line(line, lineno)
80
+ m = FIELD_RE.match(line)
81
+ unless m
82
+ raise ArgumentError,
83
+ "line #{lineno}: #{line.inspect} not name:bits"
84
+ end
85
+ Field.new(m[1], bits_for(m[2], line, lineno), lineno)
86
+ end
87
+
88
+ def self.bits_for(digits, line, lineno)
89
+ bits = digits.to_i
90
+ return bits unless bits.zero?
91
+
92
+ raise ArgumentError, "line #{lineno}: #{line.inspect} has 0 bits"
93
+ end
94
+ end
95
+
96
+ # Laying fields into rows of word: bits each, a field that outgrows
97
+ # its row continuing on the next (RowState tracks the row being
98
+ # filled, and the rows closed so far).
99
+ module Bitfield
100
+ # A name that already fits returns as-is; one too wide is bad
101
+ # input unless abbrev truncates it to width - 1 characters plus
102
+ # a dot.
103
+ def self.fit_label(name, width, abbrev)
104
+ return name if name.length <= width
105
+ unless abbrev
106
+ raise ArgumentError, "name #{name.inspect} needs " \
107
+ "#{name.length} columns, this cell " \
108
+ "only has #{width}"
109
+ end
110
+ "#{name[0, width - 1]}."
111
+ end
112
+
113
+ def self.close_row(state, word)
114
+ return unless state.cursor == word
115
+
116
+ state.rows << state.row
117
+ state.row = []
118
+ state.cursor = 0
119
+ end
120
+
121
+ def self.place_field(field, word, abbrev, state)
122
+ remaining = field.bits
123
+ until remaining.zero?
124
+ take = [remaining, word - state.cursor].min
125
+ label = fit_label(field.name, (2 * take) - 1, abbrev)
126
+ state.row << { bits: take, label: label }
127
+ state.cursor += take
128
+ remaining -= take
129
+ close_row(state, word)
130
+ end
131
+ end
132
+
133
+ def self.layout_rows(fields, word, abbrev)
134
+ state = RowState.new(0, [], [])
135
+ fields.each { place_field(it, word, abbrev, state) }
136
+ state.rows << state.row unless state.row.empty?
137
+ state.rows
138
+ end
139
+
140
+ def self.row_bits(row) = row.sum { it[:bits] }
141
+ end
142
+
143
+ # Rendering rows into text lines: the bit ruler, a tick line per
144
+ # row (and a closing one), and each row's content line.
145
+ module Bitfield
146
+ def self.tick_line(bits, step)
147
+ positions = (0..bits).step(step).to_a
148
+ positions << bits if positions.last != bits
149
+ line = +"+"
150
+ positions.each_cons(2) do |p0, p1|
151
+ line << ("-" * ((2 * (p1 - p0)) - 1)) << "+"
152
+ end
153
+ line
154
+ end
155
+
156
+ def self.ruler_lines(word)
157
+ tens = +""
158
+ units = +""
159
+ word.times do |i|
160
+ tens << " " << ((i % 10).zero? ? (i / 10).to_s : " ")
161
+ units << " " << (i % 10).to_s
162
+ end
163
+ [tens.rstrip, units.rstrip]
164
+ end
165
+
166
+ def self.content_line(segments)
167
+ line = +"|"
168
+ segments.each do |seg|
169
+ line << Width.center(seg[:label], (2 * seg[:bits]) - 1) << "|"
170
+ end
171
+ line
172
+ end
173
+
174
+ # A tick line between two rows spans the wider of them, so a
175
+ # short last row does not leave the row above it open.
176
+ def self.render(rows, word, show_ruler, octets)
177
+ step = octets ? 8 : 1
178
+ lines = show_ruler ? ruler_lines(word) : []
179
+ above = 0
180
+ rows.each do |row|
181
+ lines << tick_line([above, row_bits(row)].max, step)
182
+ lines << content_line(row)
183
+ above = row_bits(row)
184
+ end
185
+ lines << tick_line(above, step)
186
+ end
187
+ end
188
+
189
+ def self.bitfield(fields, word: 32, octets: false, ruler: true,
190
+ abbrev: false)
191
+ Bitfield.block(fields, word: word, octets: octets, ruler: ruler,
192
+ abbrev: abbrev)
193
+ end
194
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A finished figure as lines of text: what every module returns and
5
+ # what composition takes (DESIGN.md, "Blocks and composition"). A
6
+ # block is never rewritten, only padded on the right; its trailing
7
+ # blank lines are dropped, a tab is refused (its width is up to the
8
+ # viewer), and so is a block with no line left.
9
+ class Block
10
+ attr_reader :lines
11
+
12
+ # A Block, a String, or anything whose to_s is the figure (a
13
+ # chart): the composition functions take any of them.
14
+ def self.from(figure)
15
+ figure.is_a?(Block) ? figure : new(figure.to_s.lines(chomp: true))
16
+ end
17
+
18
+ def initialize(lines)
19
+ lines = lines.map(&:to_s)
20
+ lines.pop while lines.last&.strip&.empty?
21
+ raise ArgumentError, "empty block" if lines.empty?
22
+
23
+ check_tabs!(lines)
24
+ @lines = lines.freeze
25
+ freeze
26
+ end
27
+
28
+ def width = @lines.map { Width.of(it) }.max
29
+ def height = @lines.size
30
+
31
+ # Every line without its trailing blanks, each ended by "\n";
32
+ # under target :web, emoji-capable glyphs kept as text (Target).
33
+ def to_s = Target.adapt(@lines.map { "#{it.rstrip}\n" }.join)
34
+
35
+ def ascii? = @lines.all?(&:ascii_only?)
36
+
37
+ # The first line over max columns raises, naming its width.
38
+ def check_width!(max)
39
+ @lines.each_with_index do |line, i|
40
+ w = Width.of(line.rstrip)
41
+ next if w <= max
42
+
43
+ raise ArgumentError, "output is #{w} columns on line " \
44
+ "#{i + 1} (max-width #{max})"
45
+ end
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ def check_tabs!(lines)
52
+ i = lines.index { it.include?("\t") }
53
+ raise ArgumentError, "line #{i + 1}: tab character" if i
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # The border glyph sets every framed figure draws with (a frame, a
5
+ # table): corners, a horizontal and a vertical run. Under ascii:
6
+ # true the set is "ascii" from the first byte; asking for another
7
+ # one with it is refused rather than silently overridden.
8
+ #
9
+ # Besides the named sets, a custom one: a Set, or a String of six
10
+ # glyphs in Set order (tl tr bl br h v), or of eleven for a table,
11
+ # whose five more are its tees (top_t bot_t sep_l sep_t sep_r:
12
+ # down from the top edge, up from the bottom one, and the left
13
+ # tee, cross and right tee of a separator row). Each glyph is one
14
+ # visible column; under ascii: true each must be ASCII.
15
+ module Borders
16
+ Set = Data.define(:tl, :tr, :bl, :br, :h, :v)
17
+
18
+ STYLES = {
19
+ light: Set.new("┌", "┐", "└", "┘", "─", "│"),
20
+ heavy: Set.new("┏", "┓", "┗", "┛", "━", "┃"),
21
+ double: Set.new("╔", "╗", "╚", "╝", "═", "║"),
22
+ rounded: Set.new("╭", "╮", "╰", "╯", "─", "│"),
23
+ dashed: Set.new("┌", "┐", "└", "┘", "┄", "┆"),
24
+ dashed_fine: Set.new("┌", "┐", "└", "┘", "┈", "┊"),
25
+ heavy_dashed: Set.new("┏", "┓", "┗", "┛", "┅", "┇"),
26
+ heavy_dashed_fine: Set.new("┏", "┓", "┗", "┛", "┉", "┋"),
27
+ double_h: Set.new("╒", "╕", "╘", "╛", "═", "│"),
28
+ double_v: Set.new("╓", "╖", "╙", "╜", "─", "║"),
29
+ ascii: Set.new("+", "+", "+", "+", "-", "|")
30
+ }.freeze
31
+
32
+ TEE_KEYS = %i[top_t bot_t sep_l sep_t sep_r].freeze
33
+
34
+ def self.fetch(style, ascii: false)
35
+ return custom(style, ascii: ascii).first if custom?(style)
36
+
37
+ name = Borders.name(style || (ascii ? :ascii : :light))
38
+ if ascii && name != :ascii
39
+ raise ArgumentError, "style #{name} conflicts with ascii: true"
40
+ end
41
+
42
+ STYLES.fetch(name) do
43
+ raise ArgumentError, "unknown style: #{name.inspect}"
44
+ end
45
+ end
46
+
47
+ # A style's name as a Symbol: "double-h" and :double_h alike.
48
+ def self.name(style) = style.to_s.tr("-", "_").to_sym
49
+
50
+ # A Set, or a String that names no style: a glyph set.
51
+ def self.custom?(style)
52
+ style.is_a?(Set) ||
53
+ (style.is_a?(String) && !STYLES.key?(name(style)) &&
54
+ !style.match?(/\A[a-z_-]+\z/))
55
+ end
56
+
57
+ # [Set, tees Hash or nil] for a custom style, checked.
58
+ def self.custom(style, ascii: false)
59
+ glyphs = style.is_a?(Set) ? style.to_h.values : style.chars
60
+ unless [6, 11].include?(glyphs.size)
61
+ raise ArgumentError, "a border set is 6 glyphs (tl tr bl br " \
62
+ "h v), or 11 with a table's tees: " \
63
+ "#{style.inspect}"
64
+ end
65
+ glyphs.each { check_glyph!(it, ascii) }
66
+ [Set.new(*glyphs.first(6)), tees(glyphs)]
67
+ end
68
+
69
+ def self.tees(glyphs)
70
+ glyphs.size == 11 ? TEE_KEYS.zip(glyphs.last(5)).to_h : nil
71
+ end
72
+
73
+ def self.check_glyph!(glyph, ascii)
74
+ unless glyph.is_a?(String) && glyph.length == 1 &&
75
+ Width.of(glyph) == 1 && glyph.match?(/\A[[:graph:]]\z/)
76
+ raise ArgumentError, "a border glyph is one visible " \
77
+ "column: #{glyph.inspect}"
78
+ end
79
+ return unless ascii && !glyph.ascii_only?
80
+
81
+ raise ArgumentError, "border glyph #{glyph} conflicts with " \
82
+ "ascii: true"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # An area under a curve (a line chart's area: true), as btop
6
+ # draws its graphs: every pixel column the series spans is set
7
+ # from the canvas floor up to the curve, so a braille cell holds
8
+ # two independent bars of 0..4 dots and a block cell two of
9
+ # 0..2. A column's height is the curve's value at its centre,
10
+ # or the highest point falling inside it, so a peak narrower
11
+ # than a column is not lost.
12
+ module Area
13
+ # color: one colour name for every cell, or a callable
14
+ # taking a cell row (0 at the top) and returning one.
15
+ # across: true fills each pixel row instead, from the left
16
+ # edge out to the curve (x the value, y the position), the
17
+ # callable then taking a cell column.
18
+ def area(xs, ys, color: nil, across: false)
19
+ return area_across(xs, ys, color) if across
20
+
21
+ (0...pixel_width).each do |px|
22
+ value = column_value(xs, ys, px)
23
+ fill_column(px, column_top(value), color) if value
24
+ end
25
+ end
26
+
27
+ # The value at the middle of a cell row (0 the top) or column.
28
+ def cell_row_value(row)
29
+ @yhi - ((row + 0.5) * (@yhi - @ylo) / height)
30
+ end
31
+
32
+ def cell_column_value(col)
33
+ @xlo + ((col + 0.5) * (@xhi - @xlo) / width)
34
+ end
35
+
36
+ # A dashed line in data coordinates: every other pixel set.
37
+ def dashed(x0, y0, x1, y1, color: nil)
38
+ from = to_pixel(x0, y0).map(&:floor)
39
+ to = to_pixel(x1, y1).map(&:floor)
40
+ steps = from.zip(to).map { |a, b| (b - a).abs }.max
41
+ (0..steps).step(2) do |i|
42
+ t = steps.zero? ? 0 : i.fdiv(steps)
43
+ draw_pixel(*between(from, to, t), color)
44
+ end
45
+ end
46
+
47
+ # The pixel t (0..1) of the way from one pixel to another.
48
+ def between(from, to, t)
49
+ from.zip(to).map { |a, b| (a + ((b - a) * t)).round }
50
+ end
51
+
52
+ # A bar along pixel row py (0 the top), length pixels from
53
+ # the left edge: a horizontal bar chart's bar on a canvas.
54
+ def row_bar(py, length, color: nil)
55
+ length.clamp(0, pixel_width).times do |px|
56
+ set_sub_pixel(px, py, color)
57
+ end
58
+ end
59
+
60
+ # A bar up pixel column px, height pixels from the floor: a
61
+ # column chart's column on a canvas.
62
+ def column_bar(px, height, color: nil)
63
+ ((pixel_height - height.clamp(0, pixel_height))...pixel_height)
64
+ .each { set_sub_pixel(px, it, color) }
65
+ end
66
+
67
+ private
68
+
69
+ def area_across(xs, ys, color)
70
+ (0...pixel_height).each do |py|
71
+ value = row_value(xs, ys, py)
72
+ next unless value
73
+
74
+ share = (value - @xlo).fdiv(@xhi - @xlo).clamp(0.0, 1.0)
75
+ (share * pixel_width).round.times do |px|
76
+ set_sub_pixel(px, py, shade_across(color, px, py))
77
+ end
78
+ end
79
+ end
80
+
81
+ # The value at a pixel row: the curve at its centre (x as a
82
+ # function of y), or the highest x of a point inside it.
83
+ def row_value(xs, ys, py)
84
+ lo, hi = row_span(py)
85
+ inside = ys.each_index.select { ys[it] > lo && ys[it] <= hi }
86
+ [*inside.map { xs[it] }, curve_at(ys, xs, (lo + hi) / 2.0)]
87
+ .compact.max
88
+ end
89
+
90
+ def row_span(py)
91
+ step = (@yhi - @ylo).fdiv(pixel_height)
92
+ hi = @yhi - (py * step)
93
+ [hi - step, hi]
94
+ end
95
+
96
+ # A callable of (row, col), as threshold: gives, or of the
97
+ # one coordinate a gradient runs along.
98
+ def shade_across(color, px, py)
99
+ return color unless color.respond_to?(:call)
100
+ return cell_color(color, px, py) if color.arity == 2
101
+
102
+ color.call(px / x_pixel_per_char)
103
+ end
104
+
105
+ def column_value(xs, ys, px)
106
+ lo, hi = column_span(px)
107
+ inside = xs.each_index.select { xs[it] >= lo && xs[it] < hi }
108
+ [*inside.map { ys[it] }, curve_at(xs, ys, (lo + hi) / 2.0)]
109
+ .compact.max
110
+ end
111
+
112
+ def column_span(px)
113
+ step = (@xhi - @xlo).fdiv(pixel_width)
114
+ [@xlo + (px * step), @xlo + ((px + 1) * step)]
115
+ end
116
+
117
+ # The highest y any segment of the path takes at x, or nil
118
+ # where the path does not reach.
119
+ def curve_at(xs, ys, x)
120
+ (0...(xs.size - 1)).filter_map do |i|
121
+ segment_at(xs[i], ys[i], xs[i + 1], ys[i + 1], x)
122
+ end.max
123
+ end
124
+
125
+ def segment_at(x0, y0, x1, y1, x)
126
+ return nil unless x.between?(*[x0, x1].minmax)
127
+ return [y0, y1].max if x0 == x1
128
+
129
+ y0 + ((y1 - y0) * (x - x0).fdiv(x1 - x0))
130
+ end
131
+
132
+ # The first pixel row filled: the column's height in pixels,
133
+ # rounded, counted down from the top.
134
+ def column_top(value)
135
+ share = (value - @ylo).fdiv(@yhi - @ylo).clamp(0.0, 1.0)
136
+ pixel_height - (share * pixel_height).round
137
+ end
138
+
139
+ def fill_column(px, top, color)
140
+ (top...pixel_height).each do |py|
141
+ set_sub_pixel(px, py, shade(color, px, py))
142
+ end
143
+ end
144
+
145
+ def shade(color, px, py)
146
+ return color unless color.respond_to?(:call)
147
+ return cell_color(color, px, py) if color.arity == 2
148
+
149
+ color.call(py / y_pixel_per_char)
150
+ end
151
+ end
152
+
153
+ include Area
154
+ end
155
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # 3x3 sub-pixels per cell, drawn with ASCII only. There is no
6
+ # Unicode or ASCII standard for "this 3x3 dot pattern means
7
+ # this character" the way there is for braille dots or block
8
+ # quadrants, so the glyph follows an original rule, by where
9
+ # the set sub-pixels lie (D2): one populated row reads as a
10
+ # mark along that row (top: ` left, ' centre or right, " for
11
+ # two or more; middle: -; bottom: . one, _ for two or more);
12
+ # a full middle row with pixels elsewhere is still - unless
13
+ # some column is also full, which (with a full row) is a
14
+ # crossing; a column two- or three-deep with nothing in the
15
+ # other columns is |; a dominant diagonal is / or \; a
16
+ # crossing (both diagonals, or a full row and a full column)
17
+ # is +; anything else falls back to whichever of - or | its
18
+ # coverage resembles more. # overrides all of the above once
19
+ # 7 or more of the 9 sub-pixels are set: by then a full row
20
+ # and a full column always coexist (pigeonhole: 3 rows summing
21
+ # to >= 7 force one to be full, and likewise for columns), so
22
+ # "dense" and "a crossing" are never really distinct cases --
23
+ # density is checked first and reads as solid ink, not a plus
24
+ # sign. bits index as sy * SIZE + sx (top row first).
25
+ class Ascii < Canvas
26
+ SIZE = 3
27
+
28
+ private
29
+
30
+ def x_pixel_per_char = SIZE
31
+ def y_pixel_per_char = SIZE
32
+
33
+ def encode(code, sub_x, sub_y)
34
+ code | (1 << ((sub_y * SIZE) + sub_x))
35
+ end
36
+
37
+ def decode(code)
38
+ return " " if code.zero?
39
+ glyph_for(code)
40
+ end
41
+
42
+ def glyph_for(code)
43
+ rows = row_counts(code)
44
+ cols = col_counts(code)
45
+ row_only_glyph(code, rows) ||
46
+ full_middle_glyph(rows, cols) || column_glyph(cols) ||
47
+ dense_glyph(code) ||
48
+ diag_or_cross_glyph(code, rows, cols) ||
49
+ fallback_glyph(rows, cols)
50
+ end
51
+
52
+ def dense_glyph(code)
53
+ "#" if (0...9).count { code[it] == 1 } >= 7
54
+ end
55
+
56
+ def row_counts(code)
57
+ (0..2).map { |r| (0..2).count { code[(r * SIZE) + it] == 1 } }
58
+ end
59
+
60
+ def col_counts(code)
61
+ (0..2).map { |c| (0..2).count { code[(it * SIZE) + c] == 1 } }
62
+ end
63
+
64
+ def only_row?(rows, idx)
65
+ others = (0..2).to_a - [idx]
66
+ rows[idx].positive? && others.all? { rows[it].zero? }
67
+ end
68
+
69
+ def row_only_glyph(code, rows)
70
+ return top_row_glyph(code) if only_row?(rows, 0)
71
+ return bottom_row_glyph(code) if only_row?(rows, 2)
72
+ "-" if only_row?(rows, 1)
73
+ end
74
+
75
+ def top_row_glyph(code)
76
+ set = (0..2).select { code[it] == 1 }
77
+ return "\"" if set.size >= 2
78
+ set.first.zero? ? "`" : "'"
79
+ end
80
+
81
+ def bottom_row_glyph(code)
82
+ (0..2).count { code[6 + it] == 1 } >= 2 ? "_" : "."
83
+ end
84
+
85
+ def full_middle_glyph(rows, cols)
86
+ return nil unless rows[1] == 3
87
+ "-" unless cols.any? { it == 3 }
88
+ end
89
+
90
+ def column_glyph(cols)
91
+ idx = (0..2).find { cols[it] >= 2 }
92
+ return nil unless idx
93
+ "|" if (0..2).all? { it == idx || cols[it].zero? }
94
+ end
95
+
96
+ def diagonal_counts(code)
97
+ [[6, 4, 2].count { code[it] == 1 },
98
+ [0, 4, 8].count { code[it] == 1 }]
99
+ end
100
+
101
+ def crossing?(rising, falling, rows, cols)
102
+ (rising >= 2 && falling >= 2) ||
103
+ (rows.any? { it == 3 } && cols.any? { it == 3 })
104
+ end
105
+
106
+ def diag_or_cross_glyph(code, rows, cols)
107
+ rising, falling = diagonal_counts(code)
108
+ return "+" if crossing?(rising, falling, rows, cols)
109
+ return "/" if rising >= 2 && rising > falling
110
+ "\\" if falling >= 2 && falling > rising
111
+ end
112
+
113
+ def fallback_glyph(rows, cols)
114
+ rows.max >= cols.max ? "-" : "|"
115
+ end
116
+ end
117
+
118
+ register(:ascii, Ascii)
119
+ end
120
+ end