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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella chart --help`: the kinds and what each reads, and a
5
+ # description of every option -- filled into the parser's switches
6
+ # after they are built, so the option definitions stay short.
7
+ class ChartCLI
8
+ module Help
9
+ BANNER = <<~TEXT
10
+ Usage: tessella chart [options] [file] (stdin if none)
11
+
12
+ Kinds (--kind) and the columns they read, a row a line:
13
+ line, scatter, stairs, density y; x y; or x and a y a series
14
+ bar label value; or label and a value a series
15
+ histogram values (one column)
16
+ box a column a box, a header naming them
17
+ stem one column, or two back to back
18
+ heatmap label then a row of values
19
+ candlestick x open high low close
20
+ pie label value
21
+
22
+ Options (some only for the kinds named):
23
+ TEXT
24
+
25
+ DESCRIPTIONS = {
26
+ "--kind" => ["the chart kind (default line)"],
27
+ "--width" => ["plot columns (default 40)"],
28
+ "--height" => ["plot rows (default 15)"],
29
+ "--title" => ["a title above the frame"],
30
+ "--xlabel" => ["the x axis label"],
31
+ "--ylabel" => ["the y axis label"],
32
+ "--xlim" => ["the x range, A,B"],
33
+ "--ylim" => ["the y range, A,B"],
34
+ "--canvas" => ["braille, block (quadrant), half, sextant,",
35
+ "octant (Unicode 16), dot,",
36
+ "ascii, density, or eighths (--area)"],
37
+ "--ascii" => ["ASCII only, from the first byte"],
38
+ "--color" => ["ANSI colours (off by default)"],
39
+ "--xscale" => ["ln, log2 or log10 on x"],
40
+ "--yscale" => ["ln, log2 or log10 on y (plotted kinds)"],
41
+ "--xdate" => ["x is ISO 8601 dates; FMT a strftime",
42
+ "format (plotted kinds, candlestick)"],
43
+ "--yerr" => ["x y error: error bars (line, scatter)"],
44
+ "--threshold" => ["a dashed rule at N (line, scatter,",
45
+ "stairs)"],
46
+ "--above" => ["colour beyond --threshold (red)"],
47
+ "--below" => ["colour before --threshold"],
48
+ "--bins" => ["histogram bins (Sturges by default)"],
49
+ "--closed" => ["histogram bins closed left or right"],
50
+ "--orientation" => ["horizontal or vertical: the value",
51
+ "axis's direction"],
52
+ "--blocks" => ["bar glyphs: eighths, braille, block,",
53
+ "quadrant, half, sextant or octant"],
54
+ "--cell-width" => ["heatmap columns a cell (2)"],
55
+ "--colormap" => ["heatmap colours, C,C,... (--color)"],
56
+ "--min" => ["heatmap range bottom"],
57
+ "--max" => ["heatmap range top"],
58
+ "--donut" => ["pie: a ring, hollow in the middle"],
59
+ "--rim" => ["pie edge: cells (default), smooth (shaped",
60
+ "glyphs) or basic (smooth, older fonts)"],
61
+ "--style" => ["stairs: post or pre"],
62
+ "--scale" => ["stem: the stem unit"],
63
+ "--area" => ["fill under the line (line, stairs)"],
64
+ "--gradient" => ["area colours by height, C,C,... or",
65
+ "none (--color)"],
66
+ "--stacked" => ["bar: stack the series"],
67
+ "--fill" => ["bar glyph (█, # in ASCII)"],
68
+ "--no-eighths" => ["bar: whole cells only"],
69
+ "--track" => ["bar track glyph, or none"],
70
+ "--help" => ["print this help"],
71
+ "--version" => ["print the version"]
72
+ }.freeze
73
+
74
+ # Each switch's description from DESCRIPTIONS, by its long
75
+ # name (--[no-]x and --no-x alike).
76
+ def self.describe!(parser)
77
+ parser.top.list.each do |switch|
78
+ name = Array(switch.long).first.to_s
79
+ text = DESCRIPTIONS[name.sub("[no-]", "")]
80
+ switch.desc.replace(text) if text && switch.desc.empty?
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # pie: label value, a row a slice, --donut for a ring (DESIGN.md's
5
+ # CLI input rules). A pie has no axes: --width, --xlim, --ylim,
6
+ # --canvas and --xscale are refused, --height sizes the disc.
7
+ class ChartCLI
8
+ class PieKind
9
+ REFUSED = { width: "--width", xlim: "--xlim", ylim: "--ylim",
10
+ canvas: "--canvas", xscale: "--xscale" }.freeze
11
+
12
+ def self.build(opts, rows)
13
+ refuse!(opts)
14
+ _header, data = Header.split(rows, bar_like: true)
15
+ two!(data)
16
+ ChartCLI.fills!(data.size, "slices")
17
+ Tessella.pie(data.map(&:first),
18
+ Columns.numeric(data, 1, "value"),
19
+ **ChartOpts.for(opts).except(*REFUSED.keys))
20
+ end
21
+
22
+ def self.refuse!(opts)
23
+ key = REFUSED.keys.find { opts.key?(it) }
24
+ return unless key
25
+
26
+ raise UsageError, "#{REFUSED[key]} is not valid with --kind pie"
27
+ end
28
+
29
+ def self.two!(data)
30
+ i = data.index { it.size != 2 }
31
+ return unless i
32
+
33
+ raise DataError, "row #{i + 1} has #{data[i].size} columns: " \
34
+ "pie reads label value"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella compose` (DESIGN.md, "Command line"): one composition
5
+ # per subcommand over blocks read from files ("-" for stdin, once).
6
+ # Exit 0 drawn, 1 bad input (a missing file, an empty block, a tab,
7
+ # non-ASCII under --ascii, output past --max-width), 2 usage.
8
+ class ComposeCLI
9
+ class UsageError < StandardError; end
10
+ class DataError < StandardError; end
11
+ # Raised by a subcommand's --help, carrying its text.
12
+ class Help < StandardError; end
13
+
14
+ USAGE = <<~TEXT
15
+ Usage: tessella compose frame [--title T] [--style S] [--pad N]
16
+ [--fill P] [--halo H] BLOCK
17
+ tessella compose shadow [--glyph G] BLOCK
18
+ tessella compose fill [--pattern P] [--halo H] BLOCK
19
+ tessella compose grid [--columns N] [--gap N] [--row-gap N]
20
+ [--align A] BLOCK...
21
+ tessella compose beside [--gap N] [--align A] BLOCK...
22
+ tessella compose stack [--gap N] [--rule C] [--center] BLOCK...
23
+ tessella compose indent --prefix STR [--suffix STR] BLOCK
24
+ tessella compose caption --text T [--above] BLOCK
25
+ Every subcommand also takes --[no-]ascii and --max-width N.
26
+ TEXT
27
+
28
+ def self.run(argv, stdin:, stdout:, stderr:)
29
+ return help(stdout) if %w[--help -h].include?(argv.first)
30
+
31
+ stdout.print(draw(argv.dup, stdin))
32
+ 0
33
+ rescue Help => e
34
+ stdout.print(e.message)
35
+ 0
36
+ rescue UsageError, OptionParser::ParseError => e
37
+ fail_with(stderr, e.message, 2)
38
+ rescue DataError => e
39
+ fail_with(stderr, e.message, 1)
40
+ end
41
+
42
+ # A subcommand's own lines out of USAGE: its line and the
43
+ # continuation lines under it.
44
+ def self.usage_line(sub)
45
+ lines = USAGE.lines
46
+ i = lines.index { it.include?("compose #{sub} ") }
47
+ more = lines[(i + 1)..].take_while do |line|
48
+ !line.include?("tessella") && line.start_with?(" ")
49
+ end
50
+ ["Usage: #{lines[i].strip.delete_prefix("Usage: ")}\n",
51
+ *more].join
52
+ end
53
+
54
+ def self.help(stdout)
55
+ stdout.print(USAGE)
56
+ 0
57
+ end
58
+
59
+ def self.fail_with(stderr, message, status)
60
+ stderr.puts("tessella compose: #{message}")
61
+ stderr.print(USAGE) if status == 2
62
+ status
63
+ end
64
+
65
+ def self.draw(argv, stdin)
66
+ sub = argv.shift or raise UsageError, "missing subcommand"
67
+ command = ComposeCommands.fetch(sub)
68
+ opts, paths = ComposeOptions.parse(sub, argv)
69
+ ComposeCommands.check_arity!(sub, paths)
70
+ blocks = ComposeReader.blocks(paths, opts, stdin)
71
+ finish(command, blocks, opts)
72
+ end
73
+
74
+ # Usage is settled by now; what the composition still refuses
75
+ # (a non-ASCII title, output past --max-width) is bad input.
76
+ def self.finish(command, blocks, opts)
77
+ figure = command.call(blocks, opts)
78
+ figure.check_width!(opts[:max_width]) if opts[:max_width]
79
+ figure.to_s
80
+ rescue ArgumentError => e
81
+ raise DataError, e.message
82
+ end
83
+ end
84
+
85
+ # The five subcommands: how many blocks each takes, and the call
86
+ # that composes them.
87
+ module ComposeCommands
88
+ ARITY = { "frame" => 1, "indent" => 1, "caption" => 1, "shadow" => 1,
89
+ "fill" => 1, "grid" => 1..,
90
+ "beside" => 2.., "stack" => 2.. }.freeze
91
+
92
+ def self.fetch(sub)
93
+ ARITY.fetch(sub) do
94
+ raise ComposeCLI::UsageError, "unknown subcommand #{sub}"
95
+ end
96
+ method(:"#{sub}_call")
97
+ end
98
+
99
+ def self.check_arity!(sub, paths)
100
+ arity = ARITY.fetch(sub)
101
+ return if arity === paths.size # rubocop:disable Style/CaseEquality
102
+
103
+ want = if arity.is_a?(Integer)
104
+ "exactly one BLOCK"
105
+ else
106
+ "at least #{arity.begin} BLOCK#{"s" if arity.begin > 1}"
107
+ end
108
+ raise ComposeCLI::UsageError, "#{sub} takes #{want}"
109
+ end
110
+
111
+ def self.frame_call(blocks, o)
112
+ Compose.frame(blocks.first, title: o[:title], style: o[:style],
113
+ pad: o.fetch(:pad, 1), ascii: o[:ascii],
114
+ fill: o[:fill],
115
+ halo: o.fetch(:halo, true))
116
+ end
117
+
118
+ def self.beside_call(blocks, o)
119
+ Compose.beside(*blocks, gap: o.fetch(:gap, 1),
120
+ align: o.fetch(:align, :top),
121
+ ascii: o[:ascii])
122
+ end
123
+
124
+ def self.stack_call(blocks, o)
125
+ Compose.stack(*blocks, gap: o.fetch(:gap, 0), rule: o[:rule],
126
+ center: o[:center], ascii: o[:ascii])
127
+ end
128
+
129
+ def self.indent_call(blocks, o)
130
+ Compose.indent(blocks.first, prefix: o[:prefix],
131
+ suffix: o[:suffix], ascii: o[:ascii])
132
+ end
133
+
134
+ def self.grid_call(blocks, o)
135
+ Compose.grid(*blocks, columns: o.fetch(:columns, 2),
136
+ gap: o.fetch(:gap, 2),
137
+ row_gap: o.fetch(:row_gap, 1),
138
+ align: o.fetch(:align, :top),
139
+ ascii: o[:ascii])
140
+ end
141
+
142
+ def self.fill_call(blocks, o)
143
+ Compose.fill(blocks.first, pattern: o[:pattern], ascii: o[:ascii],
144
+ halo: o.fetch(:halo, true))
145
+ end
146
+
147
+ def self.shadow_call(blocks, o)
148
+ Compose.shadow(blocks.first, glyph: o[:glyph], ascii: o[:ascii])
149
+ end
150
+
151
+ def self.caption_call(blocks, o)
152
+ Compose.caption(blocks.first, text: o[:text], above: o[:above],
153
+ ascii: o[:ascii])
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella compose` options, per subcommand: everything a usage
5
+ # error can be (an unknown style or align, a style against --ascii,
6
+ # a negative count, a missing --prefix or --text) is settled here.
7
+ # --style's checks for `tessella compose frame`: a known name (not
8
+ # another style against --ascii) or a valid custom set. Kept apart
9
+ # so ComposeOptions stays under budget.
10
+ module ComposeStyleCheck
11
+ private
12
+
13
+ def check_style!
14
+ style = @opts[:style]
15
+ return unless style
16
+ return check_custom_style!(style) if Borders.custom?(style)
17
+
18
+ usage!("unknown --style #{style}") unless
19
+ Borders::STYLES.key?(style)
20
+ usage!(ascii_conflict(style)) if @opts[:ascii] && style != :ascii
21
+ end
22
+
23
+ # Named for where ASCII came from: the flag, or the cjk
24
+ # target's default, which --no-ascii lifts.
25
+ def ascii_conflict(style)
26
+ return "--style #{style} conflicts with --ascii" if @ascii_flag
27
+
28
+ "--style #{style} needs --no-ascii under target cjk"
29
+ end
30
+
31
+ def check_custom_style!(style)
32
+ Borders.custom(style, ascii: @opts[:ascii])
33
+ rescue ArgumentError => e
34
+ usage!(e.message)
35
+ end
36
+ end
37
+
38
+ # The flags of `tessella compose`'s subcommands, one definer per
39
+ # flag (ComposeOptions::FLAGS names which a subcommand takes).
40
+ # Kept apart so ComposeOptions stays under budget.
41
+ module ComposeFlags
42
+ HALO = { "sides" => true, "around" => :around, "none" => false }.freeze
43
+
44
+ private
45
+
46
+ def add_title
47
+ @parser.on("--title T", "a title in the top edge") do |t|
48
+ @opts[:title] = t
49
+ end
50
+ end
51
+
52
+ def add_style
53
+ @parser.on("--style S", "a border style, or six glyphs") do |s|
54
+ @opts[:style] = Borders.custom?(s) ? s : Borders.name(s)
55
+ end
56
+ end
57
+
58
+ def add_pad = add_count("--pad", :pad, "blank columns inside each side")
59
+ def add_gap = add_count("--gap", :gap, "blank columns (rows) between")
60
+
61
+ def add_center
62
+ @parser.on("--center", "centre narrower blocks") do
63
+ @opts[:center] = true
64
+ end
65
+ end
66
+
67
+ def add_prefix
68
+ @parser.on("--prefix STR", "the text before every line") do |p|
69
+ @opts[:prefix] = p
70
+ end
71
+ end
72
+
73
+ def add_suffix
74
+ @parser.on("--suffix STR", "the text after every line,",
75
+ "rows padded to one width") { @opts[:suffix] = it }
76
+ end
77
+
78
+ def add_text = @parser.on("--text T", "caption") { @opts[:text] = it }
79
+
80
+ def add_glyph
81
+ @parser.on("--glyph G", "shadow glyph (░; # with --ascii)") do |g|
82
+ @opts[:glyph] = g
83
+ end
84
+ end
85
+
86
+ def add_fill
87
+ @parser.on("--fill P", "fill the inside: a pattern name or " \
88
+ "glyphs") { @opts[:fill] = it }
89
+ end
90
+
91
+ # --halo sides|around|none, or --no-halo for none.
92
+ def add_halo
93
+ @parser.on("--[no-]halo [H]", "blanks kept by text: sides,",
94
+ "around or none") do |h|
95
+ @opts[:halo] = h == false ? false : (h || "sides")
96
+ end
97
+ end
98
+
99
+ # --halo's optional H took the word after it: one that names no
100
+ # halo is the block, given back.
101
+ def unhalo!(argv)
102
+ mode = @opts[:halo]
103
+ return unless mode.is_a?(String)
104
+
105
+ unless HALO.key?(mode)
106
+ argv.unshift(mode)
107
+ mode = "sides"
108
+ end
109
+ @opts[:halo] = HALO[mode]
110
+ end
111
+
112
+ def add_columns
113
+ @parser.on("--columns N", Integer, "figures a row (2)") do |n|
114
+ usage!("--columns must be >= 1") if n < 1
115
+ @opts[:columns] = n
116
+ end
117
+ end
118
+
119
+ def add_row_gap = add_count("--row-gap", :row_gap, "blank rows apart")
120
+
121
+ def add_pattern
122
+ @parser.on("--pattern P", "pattern name or glyphs (light)") do |p|
123
+ @opts[:pattern] = p
124
+ end
125
+ end
126
+
127
+ def add_above
128
+ @parser.on("--above", "caption above, not below") do
129
+ @opts[:above] = true
130
+ end
131
+ end
132
+
133
+ def add_count(flag, key, text)
134
+ @parser.on("#{flag} N", Integer, text) do |n|
135
+ usage!("#{flag} must be >= 0") if n.negative?
136
+ @opts[key] = n
137
+ end
138
+ end
139
+
140
+ def add_align
141
+ @parser.on("--align A", "top, middle or bottom") do |a|
142
+ usage!("unknown --align #{a}") unless
143
+ Compose::ALIGNS.include?(a.to_sym)
144
+ @opts[:align] = a.to_sym
145
+ end
146
+ end
147
+
148
+ def add_rule
149
+ @parser.on("--rule C", "a rule of C between blocks") do |c|
150
+ usage!("--rule must be exactly one character") unless
151
+ c.length == 1
152
+ @opts[:rule] = c
153
+ end
154
+ end
155
+ end
156
+
157
+ class ComposeOptions
158
+ include ComposeStyleCheck
159
+ include ComposeFlags
160
+
161
+ FLAGS = {
162
+ "frame" => %i[title style pad fill halo],
163
+ "beside" => %i[gap align],
164
+ "stack" => %i[gap rule center], "indent" => %i[prefix suffix],
165
+ "caption" => %i[text above], "shadow" => %i[glyph],
166
+ "fill" => %i[pattern halo],
167
+ "grid" => %i[columns gap row_gap align]
168
+ }.freeze
169
+ REQUIRED = { "indent" => :prefix, "caption" => :text }.freeze
170
+
171
+ def self.parse(sub, argv)
172
+ new(sub).parse(argv)
173
+ end
174
+
175
+ def initialize(sub)
176
+ @sub = sub
177
+ @opts = { ascii: Target.ascii? }
178
+ @parser = OptionParser.new
179
+ @parser.banner = ComposeCLI.usage_line(sub)
180
+ FLAGS.fetch(sub).each { send(:"add_#{it}") }
181
+ add_common
182
+ end
183
+
184
+ def parse(argv)
185
+ @parser.parse!(argv)
186
+ unhalo!(argv)
187
+ check_required!
188
+ check_style!
189
+ [@opts, argv]
190
+ end
191
+
192
+ private
193
+
194
+ def usage!(message) = raise(ComposeCLI::UsageError, message)
195
+
196
+ def add_common
197
+ add_ascii
198
+ @parser.on("--max-width N", Integer,
199
+ "refuse a figure wider than N columns") do |n|
200
+ usage!("--max-width must be >= 1") if n < 1
201
+ @opts[:max_width] = n
202
+ end
203
+ add_help
204
+ end
205
+
206
+ def add_ascii
207
+ @parser.on("--[no-]ascii",
208
+ "ASCII glyphs; refuse non-ASCII text") do |v|
209
+ @opts[:ascii] = v
210
+ @ascii_flag = v
211
+ end
212
+ end
213
+
214
+ def add_help
215
+ @parser.on("--help", "print this help") do
216
+ raise ComposeCLI::Help, @parser.to_s
217
+ end
218
+ end
219
+
220
+ def check_required!
221
+ key = REQUIRED[@sub]
222
+ usage!("#{@sub} requires --#{key}") if key && !@opts[key]
223
+ end
224
+ end
225
+
226
+ # Blocks for `tessella compose`, one per file argument ("-" is
227
+ # stdin, at most once), each refused as bad input, naming the file
228
+ # and line, when it is missing, empty, holds a tab, or holds a
229
+ # non-ASCII byte under --ascii.
230
+ module ComposeReader
231
+ def self.blocks(paths, opts, stdin)
232
+ if paths.count("-") > 1
233
+ raise ComposeCLI::UsageError, "'-' given more than once"
234
+ end
235
+
236
+ paths.map { block(it, opts[:ascii], stdin) }
237
+ end
238
+
239
+ def self.block(path, ascii, stdin)
240
+ text = CLI.read_text(path == "-" ? nil : path, stdin,
241
+ ComposeCLI::DataError, "#{path}:")
242
+ lines = text.lines(chomp: true)
243
+ check!(path, lines, ascii)
244
+ Block.new(lines)
245
+ rescue ArgumentError
246
+ raise ComposeCLI::DataError, "#{path}: empty block"
247
+ end
248
+
249
+ def self.check!(path, lines, ascii)
250
+ lines.each_with_index do |line, i|
251
+ where = "#{path}:#{i + 1}"
252
+ raise ComposeCLI::DataError, "#{where}: tab character" if
253
+ line.include?("\t")
254
+ raise ComposeCLI::DataError, "#{where}: non-ASCII byte" if
255
+ ascii && !line.ascii_only? && !Target.text_free?
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella glyphs` (DESIGN.md, "Glyphs"): the named glyph sets and
5
+ # their width classes, and the width targets each is safe for
6
+ # (--target T lists only those). No argument lists every set; KIND lists one
7
+ # kind; KIND NAME lists one set glyph by glyph (code point, class);
8
+ # --check GLYPHS classifies a set of one's own. Exit 0; 1 when
9
+ # --check finds the glyphs mix classes; 2 usage (an unknown kind or
10
+ # set, too many arguments).
11
+ class GlyphsCLI
12
+ class UsageError < StandardError; end
13
+ # Raised by --help, carrying its text.
14
+ class Help < StandardError; end
15
+
16
+ BANNER = "Usage: tessella glyphs [--target T] [KIND [NAME]] | " \
17
+ "--check GLYPHS\n"
18
+ KINDS = %w[border pattern meter rule ramp head].freeze
19
+
20
+ def self.run(argv, stdout:, stderr:, **)
21
+ opts = parse(argv.dup)
22
+ return check(opts[:check], stdout) if opts[:check]
23
+
24
+ stdout.print(listing(*opts[:args], target: opts[:target]))
25
+ 0
26
+ rescue Help => e
27
+ stdout.print(e.message)
28
+ 0
29
+ rescue OptionParser::ParseError, UsageError, ArgumentError => e
30
+ stderr.puts("tessella glyphs: #{e.message}")
31
+ 2
32
+ end
33
+
34
+ def self.parse(argv)
35
+ opts = {}
36
+ parser = OptionParser.new(BANNER)
37
+ parser.on("--check GLYPHS", "classify a set of one's own") do |g|
38
+ opts[:check] = g
39
+ end
40
+ target(parser, opts)
41
+ parser.on("--help", "print this help") { raise Help, parser.to_s }
42
+ opts[:args] = parser.parse!(argv)
43
+ opts
44
+ end
45
+
46
+ def self.target(parser, opts)
47
+ parser.on("--target T", Target::TARGETS.map(&:to_s),
48
+ "only the sets safe for term, cjk or web") do |t|
49
+ opts[:target] = t.to_sym
50
+ end
51
+ end
52
+
53
+ def self.listing(kind = nil, name = nil, *rest, target: nil)
54
+ raise UsageError, "too many arguments" unless rest.empty?
55
+ return detail(Glyphs.fetch(kind!(kind), name)) if name
56
+
57
+ sets = Glyphs.all
58
+ sets = sets.select { it.kind == kind!(kind) } if kind
59
+ sets = sets.select { it.targets.include?(target) } if target
60
+ sets.map { row(it) }.join
61
+ end
62
+
63
+ def self.kind!(kind)
64
+ return kind.to_sym if KINDS.include?(kind)
65
+
66
+ raise UsageError, "unknown kind: #{kind} (#{KINDS.join(", ")})"
67
+ end
68
+
69
+ def self.row(set)
70
+ classes = set.classes.join("+")
71
+ flag = set.mixed? ? " mixed" : ""
72
+ note = set.note ? " (#{set.note})" : ""
73
+ line = format("%<kind>-8s %<name>-18s %<cls>-4s %<safe>-13s " \
74
+ "%<glyphs>s",
75
+ kind: set.kind, name: set.name, cls: classes,
76
+ safe: set.targets.join(" "),
77
+ glyphs: set.glyphs.strip)
78
+ "#{line}#{flag}#{note}\n"
79
+ end
80
+
81
+ def self.detail(set)
82
+ set.glyphs.chars.uniq.reject { it == " " }.map do |g|
83
+ format("%<g>s U+%<code>04X %<cls>s\n",
84
+ g: g, code: g.ord, cls: Width.class_of(g))
85
+ end.join
86
+ end
87
+
88
+ def self.check(glyphs, stdout)
89
+ classes = Width.classes(glyphs)
90
+ mixed = classes.size > 1
91
+ prefix = mixed ? "mixed: " : ""
92
+ stdout.puts(prefix + classes.join(" "))
93
+ stdout.puts("safe for: #{Glyphs.targets(glyphs).join(" ")}")
94
+ mixed ? 1 : 0
95
+ end
96
+ end
97
+ end