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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella wave` (DESIGN.md, "Command line"): draws one timing
5
+ # diagram from a file or stdin, ported from the ascii-art skill's
6
+ # scripts/wave.rb (minus its --no-lint: the lint pass stays in the
7
+ # skill). Exit 0 drawn, 1 bad input (a bad signal line, an unknown
8
+ # wave character, missing or leftover labels, a label too wide,
9
+ # output past --max-width), 2 usage (a bad option, too many file
10
+ # arguments, a file that cannot be opened).
11
+ class WaveCLI
12
+ class UsageError < StandardError; end
13
+ class DataError < StandardError; end
14
+
15
+ # Raised by --help to short-circuit parsing with the text and
16
+ # status already decided.
17
+ class Halt < StandardError
18
+ attr_reader :status, :text
19
+
20
+ def initialize(status, text)
21
+ @status = status
22
+ @text = text
23
+ super(text)
24
+ end
25
+ end
26
+
27
+ BANNER = "Usage: tessella wave [options] [file] " \
28
+ "(stdin if no file)\n"
29
+
30
+ def self.run(argv, stdin:, stdout:, stderr:)
31
+ draw(argv, stdin, stdout)
32
+ rescue Halt => e
33
+ CLI.halt(e, stdout, stderr)
34
+ rescue OptionParser::ParseError, UsageError => e
35
+ fail_with(stderr, e.message, 2)
36
+ rescue DataError => e
37
+ fail_with(stderr, e.message, 1)
38
+ end
39
+
40
+ def self.draw(argv, stdin, stdout)
41
+ opts, path = Options.parse(argv.dup)
42
+ block = build(opts, CLI.read_text(path, stdin, DataError))
43
+ Overflow.check!(block, opts[:max_width]) if opts[:max_width]
44
+ stdout.print(block.to_s)
45
+ 0
46
+ end
47
+
48
+ def self.build(opts, text)
49
+ Tessella.wave(text, period: opts[:period], ascii: opts[:ascii],
50
+ ruler: opts[:ruler])
51
+ rescue ArgumentError => e
52
+ raise DataError, e.message
53
+ end
54
+
55
+ def self.fail_with(stderr, message, status)
56
+ stderr.puts("tessella wave: #{message}")
57
+ status
58
+ end
59
+ end
60
+
61
+ # Parses argv into opts (:period, :ascii, :max_width, :ruler) plus
62
+ # the one file argument, or nil for stdin. Raises Halt for --help,
63
+ # or lets OptionParser::ParseError escape for a bad flag, missing
64
+ # argument, or disallowed value.
65
+ class WaveCLI
66
+ class Options
67
+ def self.parse(argv)
68
+ new.parse(argv)
69
+ end
70
+
71
+ def initialize
72
+ @opts = { period: 4, ascii: Target.ascii?, ruler: false }
73
+ @parser = OptionParser.new
74
+ @parser.banner = WaveCLI::BANNER
75
+ build
76
+ end
77
+
78
+ def parse(argv)
79
+ @parser.parse!(argv)
80
+ raise WaveCLI::UsageError, "too many arguments" if argv.size > 1
81
+
82
+ [@opts, argv.first]
83
+ end
84
+
85
+ private
86
+
87
+ def build
88
+ add_period
89
+ add_ascii
90
+ add_max_width
91
+ add_ruler
92
+ add_help
93
+ end
94
+
95
+ def add_period
96
+ @parser.on("--period N", Integer,
97
+ "columns per time step (default 4)") do |n|
98
+ raise WaveCLI::UsageError, "--period must be >= 2" if
99
+ n < 2
100
+ @opts[:period] = n
101
+ end
102
+ end
103
+
104
+ def add_ascii
105
+ @parser.on("--[no-]ascii", "pure ASCII output") do |v|
106
+ @opts[:ascii] = v
107
+ end
108
+ end
109
+
110
+ def add_max_width
111
+ @parser.on("--max-width N", Integer,
112
+ "error if output exceeds N columns") do |n|
113
+ @opts[:max_width] = n
114
+ end
115
+ end
116
+
117
+ def add_ruler
118
+ @parser.on("--ruler", "add a time-step index row") do
119
+ @opts[:ruler] = true
120
+ end
121
+ end
122
+
123
+ def add_help
124
+ @parser.on("--help", "print this help") do
125
+ raise WaveCLI::Halt.new(0, @parser.to_s)
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ # --max-width, after the diagram is drawn: the first line over it
132
+ # refuses the whole figure, naming the line and its width, exactly
133
+ # as wave.rb's own check does.
134
+ class WaveCLI
135
+ module Overflow
136
+ def self.check!(block, max)
137
+ block.lines.each_with_index do |line, i|
138
+ w = Width.of(line.rstrip)
139
+ next if w <= max
140
+
141
+ raise WaveCLI::DataError,
142
+ "line #{i + 1}: #{w} columns exceeds " \
143
+ "--max-width #{max}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The tessella command line, on top of the library: `require
4
+ # "tessella/cli"` for Tessella::CLI and the per-command classes
5
+ # (ChartCLI, ComposeCLI, ...); `require "tessella"` alone loads none
6
+ # of them, nor optparse. As lib/tessella.rb does for the library,
7
+ # this file is the only place the command line's files are required,
8
+ # in this order, and tools/bundle.rb follows it after the library's.
9
+
10
+ require "optparse"
11
+
12
+ require_relative "../tessella"
13
+
14
+ require_relative "cli/iso_time"
15
+ require_relative "cli/chart_help"
16
+ require_relative "cli/chart"
17
+ require_relative "cli/chart_heatmap"
18
+ require_relative "cli/chart_candle"
19
+ require_relative "cli/chart_pie"
20
+ require_relative "cli/compose"
21
+ require_relative "cli/compose_options"
22
+ require_relative "cli/tree"
23
+ require_relative "cli/table"
24
+ require_relative "cli/bitfield"
25
+ require_relative "cli/wave"
26
+ require_relative "cli/seq"
27
+ require_relative "cli/image"
28
+ require_relative "cli/lint"
29
+ require_relative "cli/lint_options"
30
+ require_relative "cli/glyphs"
31
+ require_relative "cli/meter"
32
+ require_relative "cli/rule"
33
+ require_relative "cli/text"
34
+ require_relative "cli/sparkline"
35
+ require_relative "cli/input"
36
+ require_relative "cli/main"
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Colours to ANSI SGR, and nothing else (DESIGN.md, "Frame and
5
+ # Color"). Color.wrap is the only way a cell or label becomes
6
+ # coloured text; every other part treats colour as a value, never
7
+ # an escape code. A colour is one of the eight basic names, a
8
+ # 256-colour index (0..255), or a truecolor "#rrggbb".
9
+ module Color
10
+ NAMES = %i[red green yellow blue magenta cyan white].freeze
11
+
12
+ CODES = {
13
+ red: 31, green: 32, yellow: 33, blue: 34,
14
+ magenta: 35, cyan: 36, white: 37
15
+ }.freeze
16
+
17
+ HEX = /\A#\h{6}\z/
18
+
19
+ RESET = "\e[0m"
20
+
21
+ # name nil => text as is, never an escape. [fg, bg] sets the
22
+ # background too: a cell split between two colours, the glyph's
23
+ # ink one and the rest of the cell the other.
24
+ def self.wrap(text, name)
25
+ return text if name.nil?
26
+
27
+ codes = if name.is_a?(Array)
28
+ [code(name[0]), background(name[1])]
29
+ else
30
+ [code(name)]
31
+ end
32
+ "\e[#{codes.join(";")}m#{text}#{RESET}"
33
+ end
34
+
35
+ # The background code for a colour: 4x for 3x, 48 for 38.
36
+ def self.background(color) = code(color).to_s.sub(/\A3/, "4")
37
+
38
+ def self.code(color)
39
+ case color
40
+ when Integer then "38;5;#{color}" if valid?(color)
41
+ when HEX then "38;2;#{rgb(color).join(";")}"
42
+ else CODES[color.to_s.to_sym]
43
+ end || (raise ArgumentError, "unknown colour: #{color}")
44
+ end
45
+
46
+ def self.valid?(color)
47
+ return color.between?(0, 255) if color.is_a?(Integer)
48
+ return true if color.is_a?(String) && color.match?(HEX)
49
+
50
+ CODES.key?(color.to_s.to_sym)
51
+ end
52
+
53
+ # A colour from a command-line word: a name, an index, a #hex.
54
+ def self.parse(word)
55
+ return word.to_i if word.match?(/\A\d+\z/)
56
+
57
+ word.start_with?("#") ? word.downcase : word.to_sym
58
+ end
59
+
60
+ def self.rgb(hex) = hex[1..].scan(/../).map { it.to_i(16) }
61
+ end
62
+
63
+ # A gradient: colours (stops) to one colour per row of a figure,
64
+ # row 0 the bottom. Truecolor stops blend smoothly, the first stop
65
+ # on the bottom row and the last on the top one; any other set is
66
+ # split into equal bands, the first at the bottom.
67
+ module Color
68
+ module Gradient
69
+ def self.check!(stops)
70
+ return if stops.is_a?(Array) && !stops.empty? &&
71
+ stops.all? { Color.valid?(it) }
72
+
73
+ raise ArgumentError, "gradient must be colours (names " \
74
+ "#{Color::NAMES.join(", ")}, 0..255 " \
75
+ "or #rrggbb): #{stops.inspect}"
76
+ end
77
+
78
+ # The colour of row (0 the bottom) of rows.
79
+ def self.at(stops, row, rows)
80
+ return band(stops, row.fdiv(rows)) unless smooth?(stops)
81
+
82
+ blend(stops, rows > 1 ? row.fdiv(rows - 1) : 0.0)
83
+ end
84
+
85
+ def self.smooth?(stops)
86
+ stops.size > 1 && stops.all?(String)
87
+ end
88
+
89
+ def self.band(stops, share)
90
+ stops[[(share * stops.size).floor, stops.size - 1].min]
91
+ end
92
+
93
+ # The colour share (0..1) of the way through the stops.
94
+ def self.blend(stops, share)
95
+ pos = share.clamp(0.0, 1.0) * (stops.size - 1)
96
+ i = [pos.floor, stops.size - 2].min
97
+ mix(Color.rgb(stops[i]), Color.rgb(stops[i + 1]), pos - i)
98
+ end
99
+
100
+ def self.mix(from, to, t)
101
+ rgb = from.zip(to).map { |a, b| (a + ((b - a) * t)).round }
102
+ "##{rgb.map { format("%<v>02x", v: it) }.join}"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Composition (DESIGN.md, "Blocks and composition"): a frame around
5
+ # a block, blocks side by side or stacked, a prefix on every line, a
6
+ # caption. Each takes figures (Block.from) and returns a Block,
7
+ # measuring by display width, so a CJK table and a braille chart
8
+ # line up. A block's own lines are never rewritten, only padded on
9
+ # the right -- except stack's center:, which shifts a narrower
10
+ # block as a whole. Under ascii: true the glyphs added are ASCII and
11
+ # a non-ASCII block or text is refused.
12
+ module Compose
13
+ ALIGNS = %i[top middle bottom].freeze
14
+
15
+ def self.blocks(figures, ascii)
16
+ figures.each_with_index.map do |figure, i|
17
+ block = Block.from(figure)
18
+ if ascii && !block.ascii? && !Target.text_free?
19
+ raise ArgumentError,
20
+ "block #{i + 1} is non-ASCII under ascii: true"
21
+ end
22
+ block
23
+ end
24
+ end
25
+
26
+ # Text drawn into a figure: one line, and ASCII under ascii:
27
+ # true -- but under :cjk, where ascii: only means the structure.
28
+ def self.text!(name, text, ascii)
29
+ if text.to_s.include?("\n")
30
+ raise ArgumentError, "#{name} must be one line: #{text.inspect}"
31
+ end
32
+ return text unless ascii && !text.to_s.ascii_only? &&
33
+ !Target.text_free?
34
+
35
+ raise ArgumentError, "#{name} is non-ASCII under ascii: true"
36
+ end
37
+
38
+ # A glyph of the structure (a rule, a shadow, a pattern): ASCII
39
+ # under ascii: true, whatever the target.
40
+ def self.structure!(name, glyph, ascii)
41
+ return glyph unless ascii && !glyph.to_s.ascii_only?
42
+
43
+ raise ArgumentError, "#{name} is non-ASCII under ascii: true"
44
+ end
45
+
46
+ def self.count!(name, value)
47
+ return value if value.is_a?(Integer) && !value.negative?
48
+
49
+ raise ArgumentError, "#{name} must be an Integer >= 0: " \
50
+ "#{value.inspect}"
51
+ end
52
+
53
+ def self.align!(align)
54
+ return align.to_sym if ALIGNS.include?(align&.to_sym)
55
+
56
+ raise ArgumentError, "unknown align: #{align.inspect}"
57
+ end
58
+ end
59
+
60
+ # frame: a box around one block, title centred in the top edge;
61
+ # pad: is horizontal only, the frame hugs its content vertically.
62
+ # Its inner width is a whole number of border glyphs, so under
63
+ # target :cjk, where a Unicode box glyph takes two columns, the
64
+ # frame still closes.
65
+ module Compose
66
+ # fill: a pattern for the background the frame adds, halo: as
67
+ # Compose.fill's (compose_fill.rb).
68
+ # rubocop:disable-next Metrics/ParameterLists
69
+ def self.frame(figure, title: nil, style: nil, pad: 1,
70
+ ascii: Target.ascii?, fill: nil, halo: true)
71
+ block, = blocks([figure], ascii)
72
+ text!("title", title, ascii)
73
+ g = Borders.fetch(style, ascii: ascii)
74
+ field = frame_field(block, title, count!("pad", pad), g.h)
75
+ framed = Block.new([frame_top(g, title, field),
76
+ *frame_rows(block, g, pad, field),
77
+ g.bl + Width.run(g.h, field) + g.br])
78
+ fill ? fill_frame(framed, fill, ascii, halo) : framed
79
+ end
80
+
81
+ # The width inside the frame: the block and its pads, or room
82
+ # for the title and a run of border on each side, in whole h.
83
+ def self.frame_field(block, title, pad, h)
84
+ least = [block.width + (2 * pad),
85
+ title ? Width.of(title) + 4 : 0].max
86
+ Width.round_up(least, Width.of(h))
87
+ end
88
+
89
+ def self.frame_rows(block, g, pad, field)
90
+ inner = field - (2 * pad)
91
+ margin = " " * pad
92
+ block.lines.map do |l|
93
+ g.v + margin + Width.ljust(l, inner) + margin + g.v
94
+ end
95
+ end
96
+
97
+ def self.frame_top(g, title, field)
98
+ return g.tl + Width.run(g.h, field) + g.tr unless title
99
+
100
+ g.tl + titled(g.h, deco(title, field, Width.of(g.h)), field) +
101
+ g.tr
102
+ end
103
+
104
+ # " title ", a blank more when the border around it would
105
+ # otherwise end half a glyph short.
106
+ def self.deco(title, field, unit)
107
+ deco = " #{title} "
108
+ deco += " " until ((field - Width.of(deco)) % unit).zero?
109
+ deco
110
+ end
111
+
112
+ # deco centred in a run of h field columns long, the odd one
113
+ # right; h whole, so under :cjk a two-column one splits evenly.
114
+ def self.titled(h, deco, field)
115
+ rest = field - Width.of(deco)
116
+ unit = Width.of(h)
117
+ left = (rest / unit / 2) * unit
118
+ Width.run(h, left) + deco + Width.run(h, rest - left)
119
+ end
120
+ end
121
+
122
+ # beside: blocks side by side, each padded to its own width, the
123
+ # shorter ones filled vertically by align:.
124
+ module Compose
125
+ def self.beside(*figures, gap: 1, align: :top, ascii: Target.ascii?)
126
+ raise ArgumentError, "beside takes two figures or more" if
127
+ figures.size < 2
128
+
129
+ cols = columns(blocks(figures, ascii), align!(align))
130
+ sep = " " * count!("gap", gap)
131
+ Block.new((0...cols.first.size).map do |i|
132
+ cols.map { it[i] }.join(sep)
133
+ end)
134
+ end
135
+
136
+ def self.columns(blocks, align)
137
+ height = blocks.map(&:height).max
138
+ blocks.map do |b|
139
+ w = b.width
140
+ vertical(b.lines.map { Width.ljust(it, w) }, height, align, w)
141
+ end
142
+ end
143
+
144
+ def self.vertical(lines, height, align, width)
145
+ extra = height - lines.size
146
+ top = { top: 0, middle: extra / 2, bottom: extra }.fetch(align)
147
+ blank = " " * width
148
+ ([blank] * top) + lines + ([blank] * (extra - top))
149
+ end
150
+ end
151
+
152
+ # stack: blocks one under another; rule: draws one separator line
153
+ # as wide as the widest block, gap: blank rows around it (or between
154
+ # blocks without one); center: centres a narrower block as a whole.
155
+ module Compose
156
+ def self.stack(*figures, gap: 0, rule: nil, center: false,
157
+ ascii: Target.ascii?)
158
+ raise ArgumentError, "stack takes two figures or more" if
159
+ figures.size < 2
160
+
161
+ list = blocks(figures, ascii)
162
+ width = list.map(&:width).max
163
+ list = list.map { centred(it, width) } if center
164
+ Block.new(joined(list, separator(gap, rule!(rule, ascii), width)))
165
+ end
166
+
167
+ def self.rule!(rule, ascii)
168
+ return nil if rule.nil?
169
+ unless rule.length == 1
170
+ raise ArgumentError,
171
+ "rule must be one character: #{rule.inspect}"
172
+ end
173
+
174
+ structure!("rule", rule, ascii)
175
+ end
176
+
177
+ def self.centred(block, width)
178
+ own = block.width
179
+ left = (width - own) / 2
180
+ margin = [" " * left, " " * (width - own - left)]
181
+ Block.new(block.lines.map do |l|
182
+ margin[0] + Width.ljust(l, own) + margin[1]
183
+ end)
184
+ end
185
+
186
+ # gap blank rows between blocks, or gap on each side of a rule.
187
+ def self.separator(gap, rule, width)
188
+ rows = [""] * count!("gap", gap)
189
+ rule ? rows + [Width.run(rule, width)] + rows : rows
190
+ end
191
+
192
+ def self.joined(blocks, separator)
193
+ blocks.each_with_index.flat_map do |b, i|
194
+ i.zero? ? b.lines : separator + b.lines
195
+ end
196
+ end
197
+ end
198
+
199
+ # indent: prefix: on every line, a blank line taking the prefix
200
+ # without its trailing blank -- or, with suffix:, every row padded
201
+ # to the block's width and closed by it; caption: one centred line of text:,
202
+ # above: or below the block, a blank line between.
203
+ module Compose
204
+ def self.indent(figure, prefix:, suffix: nil, ascii: Target.ascii?)
205
+ block, = blocks([figure], ascii)
206
+ text!("prefix", prefix, ascii)
207
+ return suffixed(block, prefix, suffix!(suffix, ascii)) if suffix
208
+
209
+ Block.new(block.lines.map do |l|
210
+ l.empty? ? prefix.rstrip : prefix + l
211
+ end)
212
+ end
213
+
214
+ # Every row padded to the block's width, so the suffix closes
215
+ # them all at one column; a blank row carries it too.
216
+ def self.suffixed(block, prefix, suffix)
217
+ Block.new(block.lines.map do |l|
218
+ prefix + Width.ljust(l, block.width) + suffix
219
+ end)
220
+ end
221
+
222
+ # The suffix ends every row, so a trailing blank in it is
223
+ # refused rather than stripped.
224
+ def self.suffix!(suffix, ascii)
225
+ text!("suffix", suffix, ascii)
226
+ return suffix unless suffix.to_s.match?(/\s\z/)
227
+
228
+ raise ArgumentError, "suffix ends in a blank: #{suffix.inspect}"
229
+ end
230
+
231
+ def self.caption(figure, text:, above: false, ascii: Target.ascii?)
232
+ block, = blocks([figure], ascii)
233
+ cap = Width.center(text!("text", text, ascii), block.width)
234
+ Block.new(above ? [cap, "", *block.lines] : [*block.lines, "", cap])
235
+ end
236
+ end
237
+
238
+ def self.frame(...) = Compose.frame(...)
239
+ def self.beside(...) = Compose.beside(...)
240
+ def self.stack(...) = Compose.stack(...)
241
+ def self.indent(...) = Compose.indent(...)
242
+ def self.caption(...) = Compose.caption(...)
243
+ end