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,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