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,522 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # UML-style sequence diagrams (this module matches the tessella
5
+ # skill's scripts/seq.rb byte for byte). One statement per line:
6
+ # "participant NAME", "A -> B: text" (call), "A --> B: text"
7
+ # (reply, dashed), "A ->> B: text" (async, a double arrowhead), a
8
+ # self call "A -> A: text" (a loop right of A), and "note over
9
+ # A[,B]: text". A name is any run of Unicode letters/digits, "_",
10
+ # "." or "-"; under ascii: true such a name must be ASCII.
11
+ # Columns are fixed by first appearance (or an explicit
12
+ # "participant" line) and widen just enough for every label and
13
+ # note that touches them -- never truncating. Every refusal (an
14
+ # unrecognised statement, a non-ASCII name under ascii: true, no
15
+ # statements or no participants in the input) is an ArgumentError
16
+ # naming the line ("line N: ..."; line 0 when there is no line).
17
+ module Seq
18
+ GAP = 2
19
+ LOOP_WIDTH = 3
20
+
21
+ # A name may hold "-" but not end with one, so "A-->B" is A
22
+ # replying to B, and "A-->>B", no arrow, is refused.
23
+ NAME = '[\p{L}\p{N}_.](?:[\p{L}\p{N}_.-]*[\p{L}\p{N}_.])?'
24
+ KIND = { "->" => :call, "-->" => :reply, "->>" => :async }.freeze
25
+ PART_RE = /\Aparticipant\s+(#{NAME})\s*\z/
26
+ ARROW_RE = /\A(#{NAME})\s*(->>|-->|->)\s*(#{NAME})\s*:\s*(.*)\z/
27
+ NOTE_RE = /\Anote\s+over\s+(#{NAME})(?:\s*,\s*(#{NAME}))?
28
+ \s*:\s*(.*)\z/x
29
+
30
+ UNI_G = { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘",
31
+ td: "┬", tu: "┴", dash: "┄" }.freeze
32
+ ASC_G = { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+",
33
+ td: "+", tu: "+", dash: "-" }.freeze
34
+
35
+ def self.bad!(lineno, msg)
36
+ raise ArgumentError, "line #{lineno}: #{msg}"
37
+ end
38
+
39
+ def self.style_for(ascii)
40
+ (ascii ? ASC_G : UNI_G).merge(ascii: ascii)
41
+ end
42
+
43
+ # The whole pipeline: parse, check names, number messages,
44
+ # fix the participant order, lay out columns, draw.
45
+ def self.build(text, ascii:, numbered:, footer:)
46
+ statements = parse(text)
47
+ check_ascii_names!(statements, ascii)
48
+ assign_numbers!(statements, numbered)
49
+ participants = gather_participants(statements)
50
+ bad!(0, "no participants in the input") if participants.empty?
51
+ lay = layout(participants, statements)
52
+ style = style_for(ascii)
53
+ Block.new(render(statements, lay, participants, style, footer))
54
+ end
55
+ end
56
+
57
+ # Parsing: one statement per non-blank, non-comment line, each
58
+ # tagged with its 1-based line number; a line matching none of
59
+ # the three shapes is refused by name, and so is empty input.
60
+ module Seq
61
+ def self.match_statement(line)
62
+ if (m = PART_RE.match(line))
63
+ { type: :participant, name: m[1] }
64
+ elsif (m = ARROW_RE.match(line))
65
+ { type: :message, kind: KIND[m[2]], from: m[1], to: m[3],
66
+ text: m[4].rstrip }
67
+ elsif (m = NOTE_RE.match(line))
68
+ { type: :note, names: [m[1], m[2]].compact.uniq,
69
+ text: m[3].rstrip }
70
+ end
71
+ end
72
+
73
+ def self.parse_line(raw, lineno)
74
+ line = raw.strip
75
+ return nil if line.empty? || line.start_with?("#")
76
+
77
+ stmt = match_statement(line)
78
+ bad!(lineno, line) unless stmt
79
+ stmt.merge(line: lineno)
80
+ end
81
+
82
+ def self.parse(text)
83
+ statements = text.split("\n", -1).each_with_index
84
+ .map { |raw, i| parse_line(raw, i + 1) }.compact
85
+ bad!(0, "no statements in the input") if statements.empty?
86
+ statements
87
+ end
88
+
89
+ def self.assign_numbers!(statements, numbered)
90
+ n = 0
91
+ statements.each do |s|
92
+ next unless s[:type] == :message
93
+
94
+ n += 1
95
+ s[:number] = numbered ? n : nil
96
+ end
97
+ end
98
+
99
+ def self.label_text(stmt)
100
+ return stmt[:text] unless stmt[:number]
101
+
102
+ "#{stmt[:number]}. #{stmt[:text]}"
103
+ end
104
+
105
+ def self.names_in(stmt)
106
+ case stmt[:type]
107
+ when :participant then [stmt[:name]]
108
+ when :message then [stmt[:from], stmt[:to]]
109
+ when :note then stmt[:names]
110
+ else []
111
+ end
112
+ end
113
+
114
+ def self.ascii_name?(name) = name.each_char.all? { it.ord < 128 }
115
+
116
+ # Under ascii: true names, labels and notes are ASCII -- but
117
+ # under :cjk, where ascii: only means the structure.
118
+ def self.check_ascii_names!(statements, ascii)
119
+ return unless ascii && !Target.text_free?
120
+
121
+ statements.each { check_ascii_statement!(it) }
122
+ end
123
+
124
+ def self.check_ascii_statement!(stmt)
125
+ name = names_in(stmt).find { !ascii_name?(it) }
126
+ line = stmt[:line]
127
+ bad!(line, "participant #{name.inspect} is not ASCII") if name
128
+ text = stmt[:text]
129
+ bad!(line, "text #{text.inspect} is not ASCII") if
130
+ text && !ascii_name?(text)
131
+ end
132
+
133
+ def self.gather_participants(stmts)
134
+ seen = {}
135
+ order = []
136
+ stmts.each do |s|
137
+ names_in(s).each do |n|
138
+ next if seen[n]
139
+
140
+ seen[n] = true
141
+ order << n
142
+ end
143
+ end
144
+ order
145
+ end
146
+ end
147
+
148
+ # Column layout, part 1: a participant's box (inner width and
149
+ # centre offset), the left-to-right positions gaps and margin
150
+ # produce, and the two primitives ("how much room is free to one
151
+ # side of a column") the growers below share.
152
+ module Seq
153
+ def self.measure_box(text)
154
+ inner = Width.of(text) + 2
155
+ inner += 1 if inner.even?
156
+ { inner: inner, width: inner + 2, offset: (inner + 1) / 2 }
157
+ end
158
+
159
+ def self.positions_for(boxes, gaps, left_margin)
160
+ cursor = left_margin
161
+ boxes.each_with_index.map do |box, i|
162
+ left = cursor
163
+ cursor += box[:width] + gaps[i]
164
+ { left: left, center: left + box[:offset], width: box[:width] }
165
+ end
166
+ end
167
+
168
+ def self.total_width(positions, gaps)
169
+ last = positions.last
170
+ last[:left] + last[:width] + gaps.last
171
+ end
172
+
173
+ def self.right_room(boxes, pos, idx, trailing_gap)
174
+ return trailing_gap if idx == boxes.size - 1
175
+
176
+ pos[idx + 1][:center] - pos[idx][:center] - 1
177
+ end
178
+
179
+ def self.left_room(pos, idx)
180
+ return pos[0][:center] if idx.zero?
181
+
182
+ pos[idx][:center] - pos[idx - 1][:center] - 1
183
+ end
184
+ end
185
+
186
+ # Column layout, part 2: state is { gaps:, margin:, boxes: },
187
+ # one Array entry per participant; ensure_width! grows the gap
188
+ # between two columns, grow_left!/grow_right! grow the margin or
189
+ # a neighbouring gap, and widen_for! dispatches a statement to
190
+ # whichever of those its shape (a message, a self call, a note
191
+ # over one or two participants) needs.
192
+ module Seq
193
+ def self.ensure_width!(state, lo, hi, required, pad)
194
+ pos = positions_for(state[:boxes], state[:gaps], state[:margin])
195
+ deficit = required - (pos[hi][:center] - pos[lo][:center] + pad)
196
+ state[:gaps][lo] += deficit if deficit.positive?
197
+ end
198
+
199
+ def self.grow_left!(state, idx, amount)
200
+ return unless amount.positive?
201
+
202
+ if idx.zero?
203
+ state[:margin] += amount
204
+ else
205
+ state[:gaps][idx - 1] += amount
206
+ end
207
+ end
208
+
209
+ def self.grow_right!(state, idx, needed)
210
+ boxes = state[:boxes]
211
+ pos = positions_for(boxes, state[:gaps], state[:margin])
212
+ amount = needed - right_room(boxes, pos, idx, state[:gaps][idx])
213
+ state[:gaps][idx] += amount if amount.positive?
214
+ end
215
+
216
+ def self.ensure_centered!(state, idx, note_box)
217
+ half = note_box[:offset] + 1
218
+ pos = positions_for(state[:boxes], state[:gaps], state[:margin])
219
+ grow_left!(state, idx, half - left_room(pos, idx))
220
+ grow_right!(state, idx, half)
221
+ end
222
+
223
+ def self.self_loop_need(label) = LOOP_WIDTH + 3 + Width.of(label)
224
+
225
+ def self.widen_message!(state, participants, stmt)
226
+ i = participants.index(stmt[:from])
227
+ j = participants.index(stmt[:to])
228
+ label = label_text(stmt)
229
+ return grow_right!(state, i, self_loop_need(label)) if i == j
230
+
231
+ lo, hi = [i, j].minmax
232
+ required = Width.of(label) + 2
233
+ ensure_width!(state, lo, hi, required, -1)
234
+ end
235
+
236
+ def self.widen_note!(state, participants, stmt)
237
+ idx = stmt[:names].map { participants.index(it) }.sort
238
+ if idx.size == 1
239
+ ensure_centered!(state, idx.first, measure_box(stmt[:text]))
240
+ else
241
+ required = Width.of(stmt[:text]) + 2
242
+ ensure_width!(state, idx.first, idx.last, required, 1)
243
+ end
244
+ end
245
+
246
+ def self.widen_for!(state, participants, stmt)
247
+ case stmt[:type]
248
+ when :message then widen_message!(state, participants, stmt)
249
+ when :note then widen_note!(state, participants, stmt)
250
+ end
251
+ end
252
+
253
+ def self.layout(participants, statements)
254
+ boxes = participants.map { measure_box(it) }
255
+ state = { gaps: Array.new(participants.size, GAP), margin: 0,
256
+ boxes: boxes }
257
+ statements.each { |s| widen_for!(state, participants, s) }
258
+ pos = positions_for(boxes, state[:gaps], state[:margin])
259
+ { positions: pos, boxes: boxes,
260
+ width: total_width(pos, state[:gaps]) }
261
+ end
262
+ end
263
+
264
+ # Drawing primitives on one row of characters: a blank row,
265
+ # placing text (a double-width glyph takes two columns, so the
266
+ # cell after it holds ""), a dashed or solid run, and a
267
+ # three-line box (top, centred text, bottom) with an optional
268
+ # tee in its top or bottom edge.
269
+ module Seq
270
+ def self.blank_row(width) = Array.new(width, " ")
271
+
272
+ def self.place!(row, col, text)
273
+ text.each_char do |ch|
274
+ w = Width.of(ch)
275
+ row[col] = ch
276
+ row[col + 1] = "" if w == 2
277
+ col += w
278
+ end
279
+ end
280
+
281
+ def self.dash_line(inner, fill, tee, offset)
282
+ s = fill * inner
283
+ s[offset - 1] = tee if tee
284
+ s
285
+ end
286
+
287
+ def self.box_rows(box, text, style, tee_top: nil, tee_bot: nil)
288
+ tl, tr, bl, br, h, v = style.values_at(:tl, :tr, :bl, :br, :h, :v)
289
+ inner = box[:inner]
290
+ offset = box[:offset]
291
+ top = tl + dash_line(inner, h, tee_top, offset) + tr
292
+ mid = v + Width.center(text, inner) + v
293
+ bot = bl + dash_line(inner, h, tee_bot, offset) + br
294
+ [top, mid, bot]
295
+ end
296
+
297
+ # Full-size heads (not emoji-capable, though a font lacking
298
+ # them gives them a fallback width on the web); async a double
299
+ # head, as ->> writes it.
300
+ HEADS = { call: { right: "►", left: "◄" },
301
+ async: { right: "►►", left: "◄◄" } }.freeze
302
+ ASCII_HEADS = { call: { right: ">", left: "<" },
303
+ async: { right: ">>", left: "<<" } }.freeze
304
+
305
+ def self.arrowhead(kind, dir, style)
306
+ heads = style[:ascii] ? ASCII_HEADS : HEADS
307
+ heads[kind == :async ? :async : :call][dir]
308
+ end
309
+
310
+ def self.alt_dashes(n) = (0...n).map { |i| i.even? ? "-" : " " }.join
311
+
312
+ def self.dash_run(n, dashed, style)
313
+ return "" if n <= 0
314
+ return alt_dashes(n) if style[:ascii] && dashed
315
+
316
+ (dashed ? style[:dash] : style[:h]) * n
317
+ end
318
+ end
319
+
320
+ # Row builders, part 1: the header/footer boxes and plain
321
+ # arrows. A lifeline row has the vertical bar at every
322
+ # participant's centre column; an arrow row adds its head and a
323
+ # dashed or solid run between two columns.
324
+ module Seq
325
+ def self.place_box!(rows, left, box)
326
+ box.each_with_index { |s, r| place!(rows[r], left, s) }
327
+ end
328
+
329
+ def self.participant_lines(lay, participants, style, tee_top:,
330
+ tee_bot:)
331
+ positions, boxes, width = lay.values_at(:positions, :boxes,
332
+ :width)
333
+ rows = Array.new(3) { blank_row(width) }
334
+ participants.each_with_index do |name, i|
335
+ br = box_rows(boxes[i], name, style,
336
+ tee_top: tee_top, tee_bot: tee_bot)
337
+ place_box!(rows, positions[i][:left], br)
338
+ end
339
+ rows.map(&:join)
340
+ end
341
+
342
+ def self.lifeline_row(lay, style)
343
+ row = blank_row(lay[:width])
344
+ lay[:positions].each { |p| place!(row, p[:center], style[:v]) }
345
+ row
346
+ end
347
+
348
+ def self.blank_line(lay, style) = lifeline_row(lay, style).join
349
+
350
+ def self.fill_arrow_row(row, spec, style, dashed)
351
+ lo, hi, kind, dir = spec.values_at(:lo, :hi, :kind, :dir)
352
+ gap = dash_run(hi - lo - 2, dashed, style)
353
+ head = arrowhead(kind, dir, style)
354
+ if dir == :right
355
+ place!(row, lo + 1, gap)
356
+ place!(row, hi - Width.of(head), head)
357
+ else
358
+ place!(row, lo + 2, gap)
359
+ place!(row, lo + 1, head)
360
+ end
361
+ end
362
+
363
+ def self.arrow_row(lay, spec, style)
364
+ row = lifeline_row(lay, style)
365
+ fill_arrow_row(row, spec, style, spec[:kind] == :reply)
366
+ row
367
+ end
368
+
369
+ # Only the label's own glyphs overwrite the lifeline row --
370
+ # not the padding either side of it -- so a lifeline the
371
+ # message happens to span still shows through the label row.
372
+ def self.centered_start(interior, text_width)
373
+ (interior - text_width) / 2
374
+ end
375
+
376
+ def self.arrow_rows(lay, spec, label, style)
377
+ lo, hi = spec.values_at(:lo, :hi)
378
+ lr = lifeline_row(lay, style)
379
+ interior = hi - lo - 1
380
+ start = lo + 1 + centered_start(interior, Width.of(label))
381
+ place!(lr, start, label)
382
+ [lr.join, arrow_row(lay, spec, style).join]
383
+ end
384
+ end
385
+
386
+ # Row builders, part 2: a self call loops to the right of its
387
+ # own lifeline; a note is a small box over one participant's
388
+ # centre column, or spanning the columns of two.
389
+ module Seq
390
+ def self.self_loop_top(lay, style, kind, col)
391
+ row = lifeline_row(lay, style)
392
+ place!(row, col + 1, dash_run(LOOP_WIDTH - 1, kind == :reply,
393
+ style))
394
+ place!(row, col + LOOP_WIDTH, style[:tr])
395
+ row
396
+ end
397
+
398
+ def self.self_loop_mid(lay, style, label, col)
399
+ row = lifeline_row(lay, style)
400
+ place!(row, col + LOOP_WIDTH, style[:v])
401
+ place!(row, col + LOOP_WIDTH + 2, label)
402
+ row
403
+ end
404
+
405
+ def self.self_loop_bot(lay, style, kind, col)
406
+ row = lifeline_row(lay, style)
407
+ place!(row, col + 2, dash_run(LOOP_WIDTH - 2, kind == :reply,
408
+ style))
409
+ place!(row, col + 1, arrowhead(kind, :left, style))
410
+ place!(row, col + LOOP_WIDTH, style[:br])
411
+ row
412
+ end
413
+
414
+ def self.self_loop_rows(lay, style, kind, label, col)
415
+ [self_loop_top(lay, style, kind, col),
416
+ self_loop_mid(lay, style, label, col),
417
+ self_loop_bot(lay, style, kind, col)].map(&:join)
418
+ end
419
+
420
+ def self.single_note(idx, positions, note_box)
421
+ { left: positions[idx][:center] - note_box[:offset],
422
+ box: note_box }
423
+ end
424
+
425
+ def self.span_note(lo, hi, positions)
426
+ width = positions[hi][:center] - positions[lo][:center] + 1
427
+ { left: positions[lo][:center],
428
+ box: { inner: width - 2, width: width } }
429
+ end
430
+
431
+ def self.note_spot(idx, positions, text)
432
+ return single_note(idx.first, positions, measure_box(text)) if
433
+ idx.size == 1
434
+
435
+ span_note(idx.first, idx.last, positions)
436
+ end
437
+
438
+ def self.overlay_rows(lay, style, rows, left)
439
+ canvas = rows.map { lifeline_row(lay, style) }
440
+ rows.each_with_index { |s, r| place!(canvas[r], left, s) }
441
+ canvas.map(&:join)
442
+ end
443
+
444
+ def self.note_rows(stmt, lay, participants, style)
445
+ idx = stmt[:names].map { participants.index(it) }.sort
446
+ spot = note_spot(idx, lay[:positions], stmt[:text])
447
+ rows = box_rows(spot[:box], stmt[:text], style)
448
+ overlay_rows(lay, style, rows, spot[:left])
449
+ end
450
+ end
451
+
452
+ # Dispatches a statement to its rows, and assembles the whole
453
+ # diagram: header boxes, a blank lifeline row and the statement's
454
+ # own rows for each statement that draws anything (a bare
455
+ # "participant" declaration draws nothing, and gets no spacer
456
+ # row either), a trailing blank row, and the footer boxes when
457
+ # asked for.
458
+ module Seq
459
+ def self.arrow_statement_rows(lay, idx_pair, kind, label, style)
460
+ i, j = idx_pair
461
+ lo, hi = idx_pair.minmax
462
+ dir = i < j ? :right : :left
463
+ pos = lay[:positions]
464
+ spec = { lo: pos[lo][:center], hi: pos[hi][:center],
465
+ kind: kind, dir: dir }
466
+ arrow_rows(lay, spec, label, style)
467
+ end
468
+
469
+ def self.message_statement_rows(stmt, lay, participants, style)
470
+ i = participants.index(stmt[:from])
471
+ j = participants.index(stmt[:to])
472
+ label = label_text(stmt)
473
+ if i == j
474
+ center = lay[:positions][i][:center]
475
+ return self_loop_rows(lay, style, stmt[:kind], label, center)
476
+ end
477
+ arrow_statement_rows(lay, [i, j], stmt[:kind], label, style)
478
+ end
479
+
480
+ def self.statement_rows(stmt, lay, participants, style)
481
+ case stmt[:type]
482
+ when :message
483
+ message_statement_rows(stmt, lay, participants, style)
484
+ when :note
485
+ note_rows(stmt, lay, participants, style)
486
+ else
487
+ []
488
+ end
489
+ end
490
+
491
+ def self.append_footer(lines, lay, participants, style, footer)
492
+ return lines unless footer
493
+
494
+ footer_style = { tee_top: style[:tu], tee_bot: nil }
495
+ lines.concat(participant_lines(lay, participants, style,
496
+ **footer_style))
497
+ end
498
+
499
+ def self.append_statement(lines, s, lay, participants, style)
500
+ rows = statement_rows(s, lay, participants, style)
501
+ return if rows.empty?
502
+
503
+ lines << blank_line(lay, style)
504
+ lines.concat(rows)
505
+ end
506
+
507
+ def self.render(statements, lay, participants, style, footer)
508
+ header_style = { tee_top: nil, tee_bot: style[:td] }
509
+ lines = participant_lines(lay, participants, style,
510
+ **header_style)
511
+ statements.each do |s|
512
+ append_statement(lines, s, lay, participants, style)
513
+ end
514
+ lines << blank_line(lay, style)
515
+ append_footer(lines, lay, participants, style, footer)
516
+ end
517
+ end
518
+
519
+ def self.seq(text, ascii: Target.ascii?, numbered: false, footer: false)
520
+ Seq.build(text, ascii: ascii, numbered: numbered, footer: footer)
521
+ end
522
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A sparkline (DESIGN.md, "Meters and rules"): one line, one cell a
5
+ # value, eight steps from the lowest to the highest -- "▁▂▃▅▇" -- or
6
+ # eight ASCII levels under ascii: true. width: averages the values
7
+ # into that many cells (or repeats them to fill it); min: and max:
8
+ # fix the range, so lines drawn apart share one scale.
9
+ module Sparkline
10
+ RISE = "▁▂▃▄▅▆▇█"
11
+ ASCII = "_.-:=+*#"
12
+
13
+ def self.build(values, width: nil, min: nil, max: nil,
14
+ ascii: Target.ascii?)
15
+ vals = Check.finite!(Array(values), "value")
16
+ raise ArgumentError, "a sparkline needs values" if vals.empty?
17
+
18
+ vals = resample(vals, Check.positive_integer!(width, "width")) if
19
+ width
20
+ glyphs = ascii ? ASCII : RISE
21
+ lo, hi = range(vals, min, max)
22
+ steps = glyphs.size
23
+ Block.new([vals.map { glyphs[level(it, lo, hi, steps)] }.join])
24
+ end
25
+
26
+ def self.range(vals, min, max)
27
+ lo = min || vals.min
28
+ hi = max || vals.max
29
+ Check.finite!([lo, hi], "min/max")
30
+ raise ArgumentError, "min #{lo} is above max #{hi}" if lo > hi
31
+
32
+ [lo, hi]
33
+ end
34
+
35
+ # The step for v: the middle one when every value is the same.
36
+ def self.level(v, lo, hi, steps)
37
+ return steps / 2 if hi == lo
38
+
39
+ (v.clamp(lo, hi) - lo).fdiv(hi - lo).*(steps - 1).round
40
+ end
41
+
42
+ # width cells: the mean of the values falling in each, or the
43
+ # nearest value when there are fewer values than cells.
44
+ def self.resample(vals, width)
45
+ n = vals.size
46
+ (0...width).map do |i|
47
+ lo = i * n / width
48
+ hi = [((i + 1) * n / width), lo + 1].max
49
+ bucket = vals[lo...hi]
50
+ bucket.sum.fdiv(bucket.size)
51
+ end
52
+ end
53
+ end
54
+
55
+ # values, width: nil, min: nil, max: nil, ascii: false.
56
+ def self.sparkline(values, **) = Sparkline.build(values, **)
57
+ end