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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A hierarchy, drawn as a box-drawing tree (ported from the
5
+ # ascii-art skill's scripts/tree.rb -- DESIGN.md names it as a
6
+ # drawing module alongside Compose). `outline` is one node per
7
+ # line, depth given by leading-space indentation (the indent unit
8
+ # is inferred from the first indented line); an optional leading
9
+ # "- " or "* " bullet is stripped, and an optional "label # note"
10
+ # annotation (two or more spaces, then #) is pulled off and
11
+ # aligned into one comment column across the whole tree. Several
12
+ # top-level lines form a forest; root: gathers them under one
13
+ # synthesized root instead.
14
+ #
15
+ # Bad input (a tab, an indent that is not a whole multiple of the
16
+ # inferred unit, a line more than one level deeper than its
17
+ # predecessor, an empty label, no node at all) raises
18
+ # ArgumentError naming the input line, as does an unknown style,
19
+ # a style other than the default under ascii: true, and -- unlike
20
+ # the script, which lints afterwards -- non-ASCII content under
21
+ # ascii: true, refused at draw time rather than caught by a later
22
+ # pass.
23
+ module Tree
24
+ Result = Data.define(:lines, :entries)
25
+
26
+ STYLES = {
27
+ light: { sib: "├── ", last: "└── ", pipe: "│ " },
28
+ heavy: { sib: "┣━━ ", last: "┗━━ ", pipe: "┃ " },
29
+ rounded: { sib: "├── ", last: "╰── ", pipe: "│ " },
30
+ ascii: { sib: "|-- ", last: "`-- ", pipe: "| " }
31
+ }.freeze
32
+ BLANK = " "
33
+
34
+ def self.bad!(lineno, msg)
35
+ raise ArgumentError, "line #{lineno}: #{msg}"
36
+ end
37
+
38
+ def self.glyphs(style, ascii)
39
+ name = (style || (ascii ? :ascii : :light)).to_sym
40
+ if ascii && name != :ascii
41
+ raise ArgumentError, "style #{name} conflicts with ascii: true"
42
+ end
43
+
44
+ STYLES.fetch(name) do
45
+ raise ArgumentError, "unknown style: #{name.inspect}"
46
+ end
47
+ end
48
+
49
+ def self.render(outline, style: nil, ascii: Target.ascii?, root: nil)
50
+ lines = Parser.parse(outline)
51
+ roots = Builder.forest(lines)
52
+ roots = [Builder.wrap(root, roots)] if root
53
+ g = glyphs(style, ascii)
54
+ entries = []
55
+ Renderer.walk(roots, nil, g, entries)
56
+ rendered = Renderer.align(entries)
57
+ check_ascii!(root, rendered, entries) if
58
+ ascii && !Target.text_free?
59
+ Result.new(rendered, entries)
60
+ end
61
+
62
+ def self.check_ascii!(root, rendered, entries)
63
+ if root && !root.ascii_only?
64
+ bad!(0, "--root is non-ASCII under ascii: true")
65
+ end
66
+ i = rendered.each_index.find { |idx| !rendered[idx].ascii_only? }
67
+ return unless i
68
+
69
+ bad!(entries[i][:line], "is non-ASCII under ascii: true")
70
+ end
71
+
72
+ # Mirrors tree.rb's own --max-width check (naming the source
73
+ # node's line, not the rendered line), not Block#check_width!,
74
+ # whose message and line numbering are generic.
75
+ def self.check_max_width!(result, max_width)
76
+ return result unless max_width
77
+
78
+ i = result.lines.each_index.find do |idx|
79
+ Width.of(result.lines[idx]) > max_width
80
+ end
81
+ return result unless i
82
+
83
+ w = Width.of(result.lines[i])
84
+ bad!(result.entries[i][:line],
85
+ "is #{w} columns, over --max-width #{max_width}")
86
+ end
87
+ end
88
+
89
+ # Lines to nodes: depth from indentation, an optional bullet
90
+ # stripped, an optional " # note" annotation split off.
91
+ module Tree
92
+ module Parser
93
+ def self.parse(outline)
94
+ lines = outline.split("\n", -1).each_with_index
95
+ .map { |raw, i| parse_line(raw, i + 1) }
96
+ .compact
97
+ Tree.bad!(0, "no nodes in the input") if lines.empty?
98
+ lines
99
+ end
100
+
101
+ def self.parse_line(raw, lineno)
102
+ return nil if raw.strip.empty?
103
+ if raw.include?("\t")
104
+ Tree.bad!(lineno, "a tab in the indentation")
105
+ end
106
+
107
+ indent = raw[/\A */].size
108
+ rest = raw[indent..].sub(/\A[-*] /, "")
109
+ label, annotation = split_annotation(rest)
110
+ Tree.bad!(lineno, "an empty node label") if label.empty?
111
+ { indent: indent, label: label, annotation: annotation,
112
+ line: lineno }
113
+ end
114
+
115
+ def self.split_annotation(rest)
116
+ label, _, note = rest.partition(/ {2,}#\s?/)
117
+ [label.rstrip, note.empty? ? nil : note.rstrip]
118
+ end
119
+ end
120
+ end
121
+
122
+ # Nodes to a forest: a stack of the open ancestors at each depth,
123
+ # one per indent level; root: wraps the forest under one node.
124
+ module Tree
125
+ module Builder
126
+ def self.forest(lines)
127
+ unit = nil
128
+ stack = []
129
+ roots = []
130
+ lines.each do |l|
131
+ unit ||= l[:indent] if l[:indent].positive?
132
+ insert(l, unit, stack, roots)
133
+ end
134
+ roots
135
+ end
136
+
137
+ def self.insert(line, unit, stack, roots)
138
+ depth = resolve_depth(line[:indent], unit, line[:line])
139
+ node = make_node(line)
140
+ attach(node, depth, stack, roots)
141
+ stack[depth] = node
142
+ stack.slice!((depth + 1)..)
143
+ end
144
+
145
+ def self.resolve_depth(indent, unit, lineno)
146
+ return 0 if indent.zero?
147
+
148
+ skewed = (indent % unit).nonzero?
149
+ if skewed
150
+ Tree.bad!(lineno,
151
+ "indentation is not a multiple of #{unit}")
152
+ end
153
+ indent / unit
154
+ end
155
+
156
+ def self.make_node(line)
157
+ { label: line[:label], annotation: line[:annotation],
158
+ line: line[:line], children: [] }
159
+ end
160
+
161
+ def self.attach(node, depth, stack, roots)
162
+ prev = stack.size - 1
163
+ Tree.bad!(node[:line], "skips a level") if depth > prev + 1
164
+ return roots << node if depth.zero?
165
+
166
+ stack[depth - 1][:children] << node
167
+ end
168
+
169
+ def self.wrap(name, roots)
170
+ { label: name, annotation: nil, line: 0, children: roots }
171
+ end
172
+ end
173
+ end
174
+
175
+ # A forest to text: one line per node, its connector built from
176
+ # whether it is the last child at each ancestor level; notes
177
+ # aligned into one comment column across the whole render.
178
+ module Tree
179
+ module Renderer
180
+ def self.connector(prefix, last, g)
181
+ return "" if prefix.nil?
182
+
183
+ prefix + (last ? g[:last] : g[:sib])
184
+ end
185
+
186
+ def self.continuation(prefix, last, g)
187
+ return "" if prefix.nil?
188
+
189
+ prefix + (last ? Tree::BLANK : g[:pipe])
190
+ end
191
+
192
+ def self.walk(nodes, prefix, g, out)
193
+ nodes.each_with_index do |node, i|
194
+ last = i == nodes.size - 1
195
+ head = connector(prefix, last, g)
196
+ out << { text: head + node[:label],
197
+ annotation: node[:annotation],
198
+ line: node[:line] }
199
+ kids = node[:children]
200
+ next if kids.empty?
201
+
202
+ walk(kids, continuation(prefix, last, g), g, out)
203
+ end
204
+ end
205
+
206
+ def self.align(entries)
207
+ column = entries.map { Width.of(it[:text]) }.max + 2
208
+ entries.map do |e|
209
+ next e[:text] if e[:annotation].nil?
210
+
211
+ "#{Width.ljust(e[:text], column)}# #{e[:annotation]}"
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ def self.tree(outline, style: nil, ascii: Target.ascii?, root: nil)
218
+ result = Tree.render(outline, style: style, ascii: ascii, root: root)
219
+ Block.new(result.lines)
220
+ end
221
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A timing diagram from WaveDrom-like signal text (DESIGN.md): one
5
+ # signal per line, "name: wave" in WaveDrom's alphabet (0 low, 1
6
+ # high, p positive clock, n negative clock, x unknown, z high
7
+ # impedance, = a bus value, . repeat the previous state), with an
8
+ # optional "| label label ..." naming the bus values in order and
9
+ # a lone "---" line drawing an empty separator row. Ported from
10
+ # the ascii-art skill's scripts/wave.rb; Tessella.wave is the
11
+ # entry point, at the bottom of this file.
12
+ module Wave
13
+ ALPHABET = ".01pnxz=".chars.freeze
14
+
15
+ # One input line -> a WaveSignal, :separator, or nil (blank).
16
+ WaveSignal = Struct.new(:name, :wave, :labels, :lineno)
17
+
18
+ # One rendered cell: a level, its column width, the bus label
19
+ # (if any), the state it follows, and whether its own first
20
+ # column is a boundary (edge/tick/marker) rather than fill.
21
+ Segment = Struct.new(:kind, :width, :label, :prev_kind, :boundary)
22
+
23
+ def self.build(text, period:, ascii:, ruler:)
24
+ check_period!(period)
25
+ diagram = Diagram.parse(text, period, ascii, ruler)
26
+ Block.new(diagram.rows)
27
+ end
28
+
29
+ def self.check_period!(period)
30
+ return if period.is_a?(Integer) && period >= 2
31
+
32
+ raise ArgumentError,
33
+ "period must be an Integer >= 2: #{period.inspect}"
34
+ end
35
+ end
36
+
37
+ # One input line -> a WaveSignal, :separator, or nil (a blank
38
+ # line); every refusal names the line it is about.
39
+ module Wave
40
+ class LineParser
41
+ def self.parse(line, lineno)
42
+ text = line.strip
43
+ return nil if text.empty?
44
+ return :separator if text == "---"
45
+
46
+ new(text, lineno).signal
47
+ end
48
+
49
+ def initialize(text, lineno)
50
+ @text = text
51
+ @lineno = lineno
52
+ end
53
+
54
+ def signal
55
+ name, rest = split_name
56
+ wave, labels = split_wave(rest)
57
+ validate(wave)
58
+ Wave::WaveSignal.new(name, wave, labels, @lineno)
59
+ end
60
+
61
+ private
62
+
63
+ def split_name
64
+ idx = @text.index(":")
65
+ raise ArgumentError, "line #{@lineno}: missing ':'" unless idx
66
+
67
+ name = @text[0...idx].strip
68
+ if name.empty?
69
+ raise ArgumentError, "line #{@lineno}: empty signal name"
70
+ end
71
+
72
+ [name, @text[(idx + 1)..].strip]
73
+ end
74
+
75
+ def split_wave(rest)
76
+ wave_part, _, label_part = rest.partition("|")
77
+ wave = wave_part.gsub(/\s+/, "")
78
+ if wave.empty?
79
+ raise ArgumentError,
80
+ "line #{@lineno}: empty wave"
81
+ end
82
+
83
+ [wave, label_part.strip.split]
84
+ end
85
+
86
+ def validate(wave)
87
+ bad = wave.chars.reject { Wave::ALPHABET.include?(it) }.uniq
88
+ return if bad.empty?
89
+
90
+ raise ArgumentError,
91
+ "line #{@lineno}: unknown wave character " \
92
+ "#{bad.join(",")}"
93
+ end
94
+ end
95
+ end
96
+
97
+ # Wave characters -> Segments, resolving "." continuation,
98
+ # splitting a clock char into its rise/fall halves, and merging a
99
+ # "=" with the "." that follow it into one labelled bus run.
100
+ module Wave
101
+ class SegmentBuilder
102
+ CHAR_KIND = { "0" => :low, "1" => :high, "x" => :unknown,
103
+ "z" => :hiz }.freeze
104
+
105
+ def initialize(wave, labels, period)
106
+ @chars = wave.chars
107
+ @labels = labels.dup
108
+ @period = period
109
+ @prev = nil
110
+ @clock = nil
111
+ end
112
+
113
+ def segments
114
+ result = []
115
+ i = 0
116
+ i = step(result, i) while i < @chars.length
117
+ unless @labels.empty?
118
+ raise ArgumentError,
119
+ "too many labels for '='"
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ private
126
+
127
+ def step(result, i)
128
+ c = @chars[i]
129
+ return bus_run(result, i) if c == "="
130
+ return i + 1 if advance?(result, c)
131
+
132
+ raise ArgumentError, "unknown wave character #{c.inspect}"
133
+ end
134
+
135
+ def advance?(result, c)
136
+ case c
137
+ when "0", "1", "x", "z"
138
+ result << flat(CHAR_KIND.fetch(c), @period)
139
+ @clock = nil
140
+ when "." then result.concat(repeat)
141
+ when "p" then result.concat(pulse(:high, :low, :p))
142
+ when "n" then result.concat(pulse(:low, :high, :n))
143
+ else return false
144
+ end
145
+ true
146
+ end
147
+
148
+ def flat(kind, width)
149
+ seg = Wave::Segment.new(kind, width, nil, @prev, edge?(kind))
150
+ @prev = kind
151
+ seg
152
+ end
153
+
154
+ def edge?(kind)
155
+ return false if @prev.nil? || %i[unknown hiz].include?(kind)
156
+
157
+ kind != @prev
158
+ end
159
+
160
+ # "." repeats whatever the previous character did: another
161
+ # full cycle for a clock (its own rise and fall), a flat hold
162
+ # at the same level for anything else.
163
+ def repeat
164
+ raise ArgumentError, "'.' with no previous state" unless @prev
165
+ return pulse(:high, :low, :p) if @clock == :p
166
+ return pulse(:low, :high, :n) if @clock == :n
167
+
168
+ [Wave::Segment.new(@prev, @period, nil, @prev, false)]
169
+ end
170
+
171
+ def pulse(first, second, clock)
172
+ halves = [@period / 2, @period - (@period / 2)]
173
+ segs = [flat(first, halves[0]), flat(second, halves[1])]
174
+ @clock = clock
175
+ segs
176
+ end
177
+
178
+ def bus_run(result, i)
179
+ label = @labels.shift
180
+ raise ArgumentError, "not enough labels for '='" unless label
181
+ steps = 1
182
+ steps += 1 while @chars[i + steps] == "."
183
+ width = steps * @period
184
+ boundary = !@prev.nil?
185
+ check_fit(label, width - (boundary ? 1 : 0))
186
+ result << Wave::Segment.new(:bus, width, label, @prev, boundary)
187
+ @prev = :bus
188
+ i + steps
189
+ end
190
+
191
+ def check_fit(label, width)
192
+ return if Width.of(label) <= width
193
+
194
+ raise ArgumentError,
195
+ "label #{label.inspect} does not fit in " \
196
+ "#{width} columns"
197
+ end
198
+ end
199
+ end
200
+
201
+ # Segments -> the two printed rows (top: the high line; bottom:
202
+ # the low line, the bus baseline, and the name). One glyph table
203
+ # per mode.
204
+ module Wave
205
+ class RowRenderer
206
+ FILL = {
207
+ unicode: { low: "─", high: "─", unknown: "▒", hiz: "┄",
208
+ bus: "─" },
209
+ ascii: { low: "_", high: "_", unknown: "x", hiz: "-", bus: "_" }
210
+ }.freeze
211
+
212
+ def initialize(ascii)
213
+ @ascii = ascii
214
+ @fill = ascii ? FILL[:ascii] : FILL[:unicode]
215
+ end
216
+
217
+ def render(segments)
218
+ pairs = segments.map { cell(it) }
219
+ [pairs.map(&:first).join, pairs.map(&:last).join]
220
+ end
221
+
222
+ private
223
+
224
+ def cell(seg)
225
+ lead = seg.boundary ? 1 : 0
226
+ top, bot = fill(seg, seg.width - lead)
227
+ return [top, bot] unless seg.boundary
228
+
229
+ mtop, mbot = marker(seg)
230
+ [mtop + top, mbot + bot]
231
+ end
232
+
233
+ def fill(seg, width)
234
+ case seg.kind
235
+ when :low then [" " * width, @fill[:low] * width]
236
+ when :high then [@fill[:high] * width, " " * width]
237
+ when :unknown, :hiz
238
+ band = @fill[seg.kind] * width
239
+ [band, band]
240
+ when :bus then [" " * width, bus_label(seg.label, width)]
241
+ end
242
+ end
243
+
244
+ def bus_label(label, width)
245
+ gap = width - Width.of(label)
246
+ f = @fill[:bus]
247
+ (f * (gap / 2)) + label + (f * (gap - (gap / 2)))
248
+ end
249
+
250
+ def marker(seg)
251
+ return bus_marker if seg.kind == :bus || seg.prev_kind == :bus
252
+ return vertical_marker if
253
+ %i[unknown hiz].include?(seg.prev_kind)
254
+
255
+ edge_marker(seg)
256
+ end
257
+
258
+ def bus_marker = @ascii ? [" ", "X"] : %w[╳ ╳]
259
+
260
+ def vertical_marker = @ascii ? [" ", "|"] : %w[│ │]
261
+
262
+ def edge_marker(seg)
263
+ return vertical_marker if @ascii
264
+
265
+ seg.kind == :high ? %w[┌ ┘] : %w[┐ └]
266
+ end
267
+ end
268
+ end
269
+
270
+ # The whole figure: every signal's two rows (never rstripped --
271
+ # Block does that), a blank line between any two consecutive
272
+ # entries (a "---" entry has no rows of its own, so it reads as
273
+ # one extra blank), and an optional ruler row at the end.
274
+ module Wave
275
+ class Diagram
276
+ def self.parse(text, period, ascii, ruler)
277
+ entries = text.each_line.with_index(1).filter_map do |line, n|
278
+ Wave::LineParser.parse(line, n)
279
+ end
280
+ if entries.grep(Wave::WaveSignal).empty?
281
+ raise ArgumentError, "no signal lines"
282
+ end
283
+
284
+ new(entries, period, ascii, ruler)
285
+ end
286
+
287
+ def initialize(entries, period, ascii, ruler)
288
+ @entries = entries
289
+ @period = period
290
+ @ascii = ascii
291
+ @ruler = ruler
292
+ Diagram.check_ascii!(entries) if ascii
293
+ widths = entries.grep(Wave::WaveSignal).map { Width.of(it.name) }
294
+ @name_width = widths.max || 0
295
+ end
296
+
297
+ def rows
298
+ @rows ||= build_rows
299
+ end
300
+
301
+ def self.check_ascii!(entries)
302
+ entries.grep(Wave::WaveSignal).each do |s|
303
+ [s.name, *s.labels].each do |text|
304
+ Check.ascii!(text, "line #{s.lineno}")
305
+ end
306
+ end
307
+ end
308
+
309
+ private
310
+
311
+ def build_rows
312
+ rows = []
313
+ @entries.each_with_index do |e, i|
314
+ rows << "" if i.positive?
315
+ rows.concat(body(e))
316
+ end
317
+ rows.concat(ruler_rows) if @ruler
318
+ rows
319
+ end
320
+
321
+ def body(entry)
322
+ return [] if entry == :separator
323
+
324
+ top, bot = render_wave(entry)
325
+ [pad(nil) + top, pad(entry.name) + bot]
326
+ end
327
+
328
+ def render_wave(entry)
329
+ builder = Wave::SegmentBuilder.new(entry.wave, entry.labels,
330
+ @period)
331
+ Wave::RowRenderer.new(@ascii).render(builder.segments)
332
+ rescue ArgumentError => e
333
+ raise ArgumentError, "line #{entry.lineno}: #{e.message}"
334
+ end
335
+
336
+ def ruler_rows
337
+ steps = @entries.grep(Wave::WaveSignal)
338
+ .map { it.wave.length }.max || 0
339
+ line = (0...steps).map { it.to_s.ljust(@period) }.join
340
+ ["", pad(nil) + line]
341
+ end
342
+
343
+ def pad(name)
344
+ text = name ? Width.ljust(name, @name_width) : " " * @name_width
345
+ "#{text} "
346
+ end
347
+ end
348
+ end
349
+
350
+ def self.wave(text, period: 4, ascii: Target.ascii?, ruler: false)
351
+ Wave.build(text, period: period, ascii: ascii, ruler: ruler)
352
+ end
353
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Display width: the columns a terminal gives a string. East Asian
5
+ # Wide and Fullwidth glyphs take two, invisible ones (every
6
+ # nonspacing and enclosing mark, Mn and Me, in every script; the
7
+ # Hangul medial and final jamo; zero-width and format characters;
8
+ # variation selectors) none, everything else one -- ambiguous
9
+ # glyphs (box drawing, blocks) included, the Western terminal
10
+ # width. The two tables are generated from Unicode 15.1's
11
+ # UnicodeData.txt and EastAsianWidth.txt (a range WIDE holds is not
12
+ # repeated in INVISIBLE), shared with Tessella.lint (lint.rb); the
13
+ # markdown skill's check_md.rb keeps a copy that must stay
14
+ # identical to them.
15
+ module Width
16
+ WIDE = [
17
+ 0x1100..0x115F, 0x231A..0x231B, 0x2329..0x232A, 0x23E9..0x23EC,
18
+ 0x23F0..0x23F0, 0x23F3..0x23F3, 0x25FD..0x25FE, 0x2614..0x2615,
19
+ 0x2648..0x2653, 0x267F..0x267F, 0x2693..0x2693, 0x26A1..0x26A1,
20
+ 0x26AA..0x26AB, 0x26BD..0x26BE, 0x26C4..0x26C5, 0x26CE..0x26CE,
21
+ 0x26D4..0x26D4, 0x26EA..0x26EA, 0x26F2..0x26F3, 0x26F5..0x26F5,
22
+ 0x26FA..0x26FA, 0x26FD..0x26FD, 0x2705..0x2705, 0x270A..0x270B,
23
+ 0x2728..0x2728, 0x274C..0x274C, 0x274E..0x274E, 0x2753..0x2755,
24
+ 0x2757..0x2757, 0x2795..0x2797, 0x27B0..0x27B0, 0x27BF..0x27BF,
25
+ 0x2B1B..0x2B1C, 0x2B50..0x2B50, 0x2B55..0x2B55, 0x2E80..0x2E99,
26
+ 0x2E9B..0x2EF3, 0x2F00..0x2FD5, 0x2FF0..0x303E, 0x3041..0x3096,
27
+ 0x3099..0x30FF, 0x3105..0x312F, 0x3131..0x318E, 0x3190..0x31E3,
28
+ 0x31EF..0x321E, 0x3220..0x3247, 0x3250..0x4DBF, 0x4E00..0xA48C,
29
+ 0xA490..0xA4C6, 0xA960..0xA97C, 0xAC00..0xD7A3, 0xF900..0xFAFF,
30
+ 0xFE10..0xFE19, 0xFE30..0xFE52, 0xFE54..0xFE66, 0xFE68..0xFE6B,
31
+ 0xFF01..0xFF60, 0xFFE0..0xFFE6, 0x16FE0..0x16FE4, 0x16FF0..0x16FF1,
32
+ 0x17000..0x187F7, 0x18800..0x18CD5, 0x18D00..0x18D08,
33
+ 0x1AFF0..0x1AFF3, 0x1AFF5..0x1AFFB, 0x1AFFD..0x1AFFE,
34
+ 0x1B000..0x1B122, 0x1B132..0x1B132, 0x1B150..0x1B152,
35
+ 0x1B155..0x1B155, 0x1B164..0x1B167, 0x1B170..0x1B2FB,
36
+ 0x1F004..0x1F004, 0x1F0CF..0x1F0CF, 0x1F18E..0x1F18E,
37
+ 0x1F191..0x1F19A, 0x1F200..0x1F202, 0x1F210..0x1F23B,
38
+ 0x1F240..0x1F248, 0x1F250..0x1F251, 0x1F260..0x1F265,
39
+ 0x1F300..0x1F320, 0x1F32D..0x1F335, 0x1F337..0x1F37C,
40
+ 0x1F37E..0x1F393, 0x1F3A0..0x1F3CA, 0x1F3CF..0x1F3D3,
41
+ 0x1F3E0..0x1F3F0, 0x1F3F4..0x1F3F4, 0x1F3F8..0x1F43E,
42
+ 0x1F440..0x1F440, 0x1F442..0x1F4FC, 0x1F4FF..0x1F53D,
43
+ 0x1F54B..0x1F54E, 0x1F550..0x1F567, 0x1F57A..0x1F57A,
44
+ 0x1F595..0x1F596, 0x1F5A4..0x1F5A4, 0x1F5FB..0x1F64F,
45
+ 0x1F680..0x1F6C5, 0x1F6CC..0x1F6CC, 0x1F6D0..0x1F6D2,
46
+ 0x1F6D5..0x1F6D7, 0x1F6DC..0x1F6DF, 0x1F6EB..0x1F6EC,
47
+ 0x1F6F4..0x1F6FC, 0x1F7E0..0x1F7EB, 0x1F7F0..0x1F7F0,
48
+ 0x1F90C..0x1F93A, 0x1F93C..0x1F945, 0x1F947..0x1F9FF,
49
+ 0x1FA70..0x1FA7C, 0x1FA80..0x1FA88, 0x1FA90..0x1FABD,
50
+ 0x1FABF..0x1FAC5, 0x1FACE..0x1FADB, 0x1FAE0..0x1FAE8,
51
+ 0x1FAF0..0x1FAF8, 0x20000..0x2FFFD, 0x30000..0x3FFFD
52
+ ].freeze
53
+
54
+ # An ANSI colour sequence (a chart drawn with color: true) takes
55
+ # no column.
56
+ ANSI = /\e\[[0-9;]*m/
57
+
58
+ # What makes a cluster one emoji: a zero-width joiner, or a
59
+ # skin-tone modifier.
60
+ EMOJI_JOIN = /[\u200D\u{1F3FB}-\u{1F3FF}]/
61
+
62
+ # Summed over grapheme clusters, so an emoji sequence counts
63
+ # once (Width.cluster).
64
+ def self.of(text)
65
+ plain = text.to_s.gsub(ANSI, "")
66
+ return plain.length if plain.ascii_only?
67
+
68
+ plain.each_grapheme_cluster.sum { cluster(it) }
69
+ end
70
+
71
+ # One grapheme cluster's columns: its characters' sum, except an
72
+ # emoji sequence -- joined by ZWJ (👨‍👩‍👧) or skin-toned (👍🏽)
73
+ # -- which terminals draw as the one wide glyph it is: 2.
74
+ def self.cluster(text)
75
+ widths = text.each_char.map { char(it.ord) }
76
+ return 2 if widths.include?(2) && text.match?(EMOJI_JOIN)
77
+
78
+ widths.sum
79
+ end
80
+
81
+ def self.pad(count) = " " * [count, 0].max
82
+
83
+ # glyph repeated to fill cols columns: cols / its width copies,
84
+ # so a two-column box glyph (an ambiguous one under :cjk) runs
85
+ # half as many times.
86
+ def self.run(glyph, cols) = glyph * (cols / [of(glyph), 1].max)
87
+
88
+ # cols rounded up to a whole number of units: a frame's or a
89
+ # column's inner width, so its border glyphs fill it exactly.
90
+ def self.round_up(cols, unit) = ((cols + unit - 1) / unit) * unit
91
+ def self.ljust(text, cols) = text + pad(cols - of(text))
92
+ def self.rjust(text, cols) = pad(cols - of(text)) + text
93
+
94
+ def self.center(text, cols)
95
+ gap = cols - of(text)
96
+ pad(gap / 2) + text + pad(gap - (gap / 2))
97
+ end
98
+
99
+ # A character's columns under the current target (Target):
100
+ # an ambiguous glyph takes two under :cjk.
101
+ def self.char(code)
102
+ width = base(code)
103
+ return width unless width == 1 && code > 0x7F && Target.cjk?
104
+
105
+ ambiguous?(code) ? 2 : 1
106
+ end
107
+
108
+ # A character's columns in a Western terminal, whatever the
109
+ # target: what its width class is read from.
110
+ def self.base(code)
111
+ return 1 if code < 0x300
112
+ return 2 if in?(WIDE, code)
113
+
114
+ in?(INVISIBLE, code) ? 0 : 1
115
+ end
116
+
117
+ # Whether code falls in one of a table's sorted ranges.
118
+ def self.in?(table, code)
119
+ table.bsearch { it.end >= code }&.cover?(code) || false
120
+ end
121
+ end
122
+ end