tessella 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +900 -0
- data/doc/README.md +74 -0
- data/doc/bitfield.md +123 -0
- data/doc/charts.md +1370 -0
- data/doc/check.rb +75 -0
- data/doc/cli.md +483 -0
- data/doc/composition.md +599 -0
- data/doc/getting-started.md +121 -0
- data/doc/glyphs.md +134 -0
- data/doc/images.md +325 -0
- data/doc/library.md +239 -0
- data/doc/lint.md +270 -0
- data/doc/meter.md +210 -0
- data/doc/rule.md +51 -0
- data/doc/seq.md +169 -0
- data/doc/sparkline.md +63 -0
- data/doc/table.md +347 -0
- data/doc/targets.md +222 -0
- data/doc/text.md +84 -0
- data/doc/tree.md +108 -0
- data/doc/wave.md +122 -0
- data/examples/README.md +103 -0
- data/examples/ages.txt +9 -0
- data/examples/bad.csv +3 -0
- data/examples/boxes.txt +3 -0
- data/examples/broken.txt +4 -0
- data/examples/calibration.rb +12 -0
- data/examples/cities.csv +4 -0
- data/examples/cjk.rb +13 -0
- data/examples/comment.rb +9 -0
- data/examples/comment.sh +13 -0
- data/examples/cpu.txt +60 -0
- data/examples/dashboard.sh +25 -0
- data/examples/datasheet.rb +16 -0
- data/examples/disc.pgm +0 -0
- data/examples/disks.sh +18 -0
- data/examples/disks.txt +5 -0
- data/examples/dose.txt +7 -0
- data/examples/drift.txt +4 -0
- data/examples/ethernet.txt +3 -0
- data/examples/figure.c +5 -0
- data/examples/fit.rb +16 -0
- data/examples/floors.rb +21 -0
- data/examples/gallery.sh +20 -0
- data/examples/gauges.sh +31 -0
- data/examples/gradient.rb +16 -0
- data/examples/growth.txt +6 -0
- data/examples/hosts.csv +4 -0
- data/examples/icmp.txt +3 -0
- data/examples/ipv4.txt +12 -0
- data/examples/kinds.csv +4 -0
- data/examples/langs.csv +4 -0
- data/examples/lint.rb +16 -0
- data/examples/load.rb +14 -0
- data/examples/login.txt +10 -0
- data/examples/neg.csv +4 -0
- data/examples/normal.txt +200 -0
- data/examples/note.txt +2 -0
- data/examples/notes-fixed.md +17 -0
- data/examples/notes.md +17 -0
- data/examples/ohlc.txt +7 -0
- data/examples/os.csv +10 -0
- data/examples/outline.txt +8 -0
- data/examples/plot.txt +5 -0
- data/examples/refusals.rb +17 -0
- data/examples/report.sh +21 -0
- data/examples/rfc.sh +22 -0
- data/examples/samples.txt +7 -0
- data/examples/shapes.pgm +0 -0
- data/examples/sizes.tsv +3 -0
- data/examples/spi.txt +6 -0
- data/examples/status.sh +23 -0
- data/examples/stem.txt +12 -0
- data/examples/table-drift.txt +4 -0
- data/examples/temps.txt +31 -0
- data/examples/trig.sh +13 -0
- data/examples/trig.txt +102 -0
- data/examples/week.txt +8 -0
- data/examples/wide.txt +2 -0
- data/exe/tessella +6 -0
- data/lib/tessella/axis.rb +71 -0
- data/lib/tessella/bitfield.rb +194 -0
- data/lib/tessella/block.rb +56 -0
- data/lib/tessella/borders.rb +85 -0
- data/lib/tessella/canvas/area.rb +155 -0
- data/lib/tessella/canvas/ascii.rb +120 -0
- data/lib/tessella/canvas/block.rb +40 -0
- data/lib/tessella/canvas/braille.rb +30 -0
- data/lib/tessella/canvas/clip.rb +49 -0
- data/lib/tessella/canvas/density.rb +51 -0
- data/lib/tessella/canvas/dot.rb +27 -0
- data/lib/tessella/canvas/eighths.rb +43 -0
- data/lib/tessella/canvas/half.rb +51 -0
- data/lib/tessella/canvas/sextant.rb +36 -0
- data/lib/tessella/canvas.rb +168 -0
- data/lib/tessella/cell.rb +10 -0
- data/lib/tessella/chart.rb +211 -0
- data/lib/tessella/check.rb +52 -0
- data/lib/tessella/cli/bitfield.rb +121 -0
- data/lib/tessella/cli/chart.rb +615 -0
- data/lib/tessella/cli/chart_candle.rb +36 -0
- data/lib/tessella/cli/chart_heatmap.rb +21 -0
- data/lib/tessella/cli/chart_help.rb +85 -0
- data/lib/tessella/cli/chart_pie.rb +38 -0
- data/lib/tessella/cli/compose.rb +156 -0
- data/lib/tessella/cli/compose_options.rb +259 -0
- data/lib/tessella/cli/glyphs.rb +97 -0
- data/lib/tessella/cli/image.rb +250 -0
- data/lib/tessella/cli/input.rb +44 -0
- data/lib/tessella/cli/iso_time.rb +28 -0
- data/lib/tessella/cli/lint.rb +102 -0
- data/lib/tessella/cli/lint_options.rb +99 -0
- data/lib/tessella/cli/main.rb +121 -0
- data/lib/tessella/cli/meter.rb +80 -0
- data/lib/tessella/cli/rule.rb +57 -0
- data/lib/tessella/cli/seq.rb +117 -0
- data/lib/tessella/cli/sparkline.rb +72 -0
- data/lib/tessella/cli/table.rb +167 -0
- data/lib/tessella/cli/text.rb +78 -0
- data/lib/tessella/cli/tree.rb +100 -0
- data/lib/tessella/cli/wave.rb +148 -0
- data/lib/tessella/cli.rb +36 -0
- data/lib/tessella/color.rb +106 -0
- data/lib/tessella/compose.rb +243 -0
- data/lib/tessella/compose_fill.rb +176 -0
- data/lib/tessella/compose_grid.rb +36 -0
- data/lib/tessella/compose_shadow.rb +41 -0
- data/lib/tessella/error.rb +13 -0
- data/lib/tessella/frame.rb +170 -0
- data/lib/tessella/glyphs.rb +106 -0
- data/lib/tessella/image.rb +454 -0
- data/lib/tessella/image_octant.rb +46 -0
- data/lib/tessella/image_petscii.rb +116 -0
- data/lib/tessella/kinds/bar.rb +344 -0
- data/lib/tessella/kinds/box.rb +213 -0
- data/lib/tessella/kinds/box_upright.rb +110 -0
- data/lib/tessella/kinds/candlestick.rb +164 -0
- data/lib/tessella/kinds/columns.rb +259 -0
- data/lib/tessella/kinds/date_axis.rb +102 -0
- data/lib/tessella/kinds/error_bars.rb +71 -0
- data/lib/tessella/kinds/heatmap.rb +130 -0
- data/lib/tessella/kinds/line.rb +173 -0
- data/lib/tessella/kinds/multibar.rb +98 -0
- data/lib/tessella/kinds/multibar_columns.rb +130 -0
- data/lib/tessella/kinds/multibar_fine.rb +45 -0
- data/lib/tessella/kinds/multibar_rows.rb +93 -0
- data/lib/tessella/kinds/pie.rb +123 -0
- data/lib/tessella/kinds/pie_rim.rb +78 -0
- data/lib/tessella/kinds/sideways.rb +62 -0
- data/lib/tessella/kinds/stem.rb +134 -0
- data/lib/tessella/kinds/threshold.rb +73 -0
- data/lib/tessella/legend.rb +31 -0
- data/lib/tessella/lint/alphabet.rb +56 -0
- data/lib/tessella/lint/drift.rb +82 -0
- data/lib/tessella/lint/fences.rb +51 -0
- data/lib/tessella/lint/geometry.rb +120 -0
- data/lib/tessella/lint/geometry_checks.rb +224 -0
- data/lib/tessella/lint/lines.rb +115 -0
- data/lib/tessella/lint/mixing.rb +61 -0
- data/lib/tessella/lint/runner.rb +73 -0
- data/lib/tessella/lint/shadows.rb +120 -0
- data/lib/tessella/lint/target_rows.rb +66 -0
- data/lib/tessella/lint.rb +158 -0
- data/lib/tessella/meter.rb +133 -0
- data/lib/tessella/meter_upright.rb +43 -0
- data/lib/tessella/png.rb +456 -0
- data/lib/tessella/raster.rb +92 -0
- data/lib/tessella/rmagick_reader.rb +52 -0
- data/lib/tessella/rule.rb +59 -0
- data/lib/tessella/seq.rb +522 -0
- data/lib/tessella/sparkline.rb +57 -0
- data/lib/tessella/stats.rb +199 -0
- data/lib/tessella/table.rb +547 -0
- data/lib/tessella/table_cells.rb +225 -0
- data/lib/tessella/target.rb +105 -0
- data/lib/tessella/text.rb +76 -0
- data/lib/tessella/text_forms.rb +150 -0
- data/lib/tessella/tree.rb +221 -0
- data/lib/tessella/version.rb +5 -0
- data/lib/tessella/wave.rb +353 -0
- data/lib/tessella/width.rb +122 -0
- data/lib/tessella/width_class.rb +77 -0
- data/lib/tessella/width_invisible.rb +66 -0
- data/lib/tessella/width_invisible_high.rb +60 -0
- data/lib/tessella.rb +94 -0
- metadata +226 -0
|
@@ -0,0 +1,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,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
|