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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella meter` (DESIGN.md, "Meters and rules"): one meter line
5
+ # for FRACTION (0..1, or 45%). Exit 0 drawn; 2 usage (a bad flag,
6
+ # an unknown style, a mixed-class pair, a fraction out of range).
7
+ class MeterCLI
8
+ class UsageError < StandardError; end
9
+ # Raised by --help, carrying its text.
10
+ class Help < StandardError; end
11
+
12
+ BANNER = "Usage: tessella meter [options] FRACTION (0..1, or 45%)\n"
13
+
14
+ def self.run(argv, stdout:, stderr:, **)
15
+ stdout.print(draw(argv))
16
+ 0
17
+ rescue Help => e
18
+ stdout.print(e.message)
19
+ 0
20
+ rescue OptionParser::ParseError, UsageError, ArgumentError => e
21
+ stderr.puts("tessella meter: #{e.message}")
22
+ 2
23
+ end
24
+
25
+ def self.draw(argv)
26
+ opts, args = parse(argv.dup)
27
+ raise UsageError, "one FRACTION, got #{args.size}" unless
28
+ args.size == 1
29
+
30
+ Tessella.meter(args.first, **opts).to_s
31
+ end
32
+
33
+ def self.parse(argv)
34
+ opts = {}
35
+ parser = OptionParser.new(BANNER)
36
+ options(parser, opts)
37
+ [opts, parser.parse!(argv)]
38
+ end
39
+
40
+ def self.options(parser, opts)
41
+ styles = Glyphs::METERS.keys.each_slice(4)
42
+ .map { "#{it.join(", ")}," }
43
+ parser.on("--style S", *styles,
44
+ "or a fill and a track glyph") { opts[:style] = it }
45
+ layout(parser, opts)
46
+ titles(parser, opts)
47
+ flags(parser, opts)
48
+ end
49
+
50
+ def self.layout(parser, opts)
51
+ parser.on("--width N", Integer, "horizontal bar cells " \
52
+ "(default 20)") do |n|
53
+ opts[:width] = n
54
+ end
55
+ parser.on("--orientation O", %w[horizontal vertical],
56
+ "horizontal, or vertical: filled bottom-up") do |v|
57
+ opts[:orientation] = v.to_sym
58
+ end
59
+ parser.on("--height N", Integer,
60
+ "vertical bar cells (default 8)") { opts[:height] = it }
61
+ end
62
+
63
+ def self.titles(parser, opts)
64
+ parser.on("--title T", "text before the bar") { opts[:title] = it }
65
+ parser.on("--title-width N", Integer, "pad the title to N " \
66
+ "columns") do |n|
67
+ opts[:title_width] = n
68
+ end
69
+ end
70
+
71
+ def self.flags(parser, opts)
72
+ parser.on("--no-label", "no percentage") { opts[:label] = false }
73
+ parser.on("--no-brackets", "no [ ]") { opts[:brackets] = false }
74
+ parser.on("--[no-]ascii", "the classic style: [===== ]") do |v|
75
+ opts[:ascii] = v
76
+ end
77
+ parser.on("--help", "print this help") { raise Help, parser.to_s }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella rule` (DESIGN.md, "Meters and rules"): one divider line.
5
+ # Exit 0 drawn; 2 usage (a bad flag, an unknown style, an ornament
6
+ # too wide, a non-ASCII ornament under --ascii, an argument).
7
+ class RuleCLI
8
+ # Raised by --help, carrying its text.
9
+ class Help < StandardError; end
10
+
11
+ BANNER = "Usage: tessella rule [options]\n"
12
+
13
+ def self.run(argv, stdout:, stderr:, **)
14
+ stdout.print(draw(argv))
15
+ 0
16
+ rescue Help => e
17
+ stdout.print(e.message)
18
+ 0
19
+ rescue OptionParser::ParseError, ArgumentError => e
20
+ stderr.puts("tessella rule: #{e.message}")
21
+ 2
22
+ end
23
+
24
+ def self.draw(argv)
25
+ opts, args = parse(argv.dup)
26
+ raise ArgumentError, "no argument expected: #{args.first}" if
27
+ args.any?
28
+
29
+ Tessella.rule(**opts).to_s
30
+ end
31
+
32
+ def self.parse(argv)
33
+ opts = {}
34
+ parser = OptionParser.new(BANNER)
35
+ options(parser, opts)
36
+ [opts, parser.parse!(argv)]
37
+ end
38
+
39
+ def self.options(parser, opts)
40
+ parser.on("--width N", Integer, "columns (default 78)") do |n|
41
+ opts[:width] = n
42
+ end
43
+ parser.on("--ornament T", "text centred in the line") do |t|
44
+ opts[:ornament] = t
45
+ end
46
+ parser.on("--style S", Glyphs::RULES.keys.join(", ")) do |s|
47
+ opts[:style] = s
48
+ end
49
+ flags(parser, opts)
50
+ end
51
+
52
+ def self.flags(parser, opts)
53
+ parser.on("--[no-]ascii", "a line of -") { opts[:ascii] = it }
54
+ parser.on("--help", "print this help") { raise Help, parser.to_s }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella seq`: reads one file or stdin, draws a sequence
5
+ # diagram via Tessella.seq, and prints it. Exit 0 drawn, 1 bad
6
+ # input (an unrecognised statement, a non-ASCII name under
7
+ # --ascii, no statements or participants, output past
8
+ # --max-width), 2 usage. The artlint pass the skill's seq.rb runs
9
+ # by default stays in the skill; there is no --no-lint here.
10
+ class SeqCLI
11
+ class Error < StandardError; end
12
+ class UsageError < Error; end
13
+ class DataError < Error; end
14
+
15
+ # Raised by --help to short-circuit parsing with a message
16
+ # and a 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 seq [options] [file]"
28
+
29
+ def self.run(argv, stdin:, stdout:, stderr:)
30
+ draw(argv, stdin, stdout)
31
+ rescue Halt => e
32
+ CLI.halt(e, stdout, stderr)
33
+ rescue OptionParser::ParseError, UsageError => e
34
+ usage_error(e, stderr)
35
+ rescue DataError, ArgumentError => e
36
+ data_error(e, stderr)
37
+ end
38
+
39
+ def self.draw(argv, stdin, stdout)
40
+ opts, path = Options.parse(argv.dup)
41
+ text = CLI.read_text(path, stdin, DataError)
42
+ block = Tessella.seq(text, ascii: opts[:ascii],
43
+ numbered: opts[:numbered],
44
+ footer: opts[:footer])
45
+ check_max_width!(block, opts[:max_width])
46
+ stdout.print(block.to_s)
47
+ 0
48
+ end
49
+
50
+ def self.check_max_width!(block, max_width)
51
+ return unless max_width
52
+
53
+ w = block.lines.map { Width.of(it.rstrip) }.max
54
+ return if w <= max_width
55
+
56
+ raise DataError,
57
+ "output is #{w} columns, over --max-width #{max_width}"
58
+ end
59
+
60
+ def self.usage_error(error, stderr)
61
+ stderr.puts("tessella seq: #{error.message}")
62
+ stderr.puts(BANNER)
63
+ 2
64
+ end
65
+
66
+ def self.data_error(error, stderr)
67
+ stderr.puts("tessella seq: #{error.message}")
68
+ 1
69
+ end
70
+ end
71
+
72
+ # Parses argv into { ascii:, numbered:, footer:, max_width: } plus
73
+ # at most one file argument; raises Halt for --help, or lets
74
+ # OptionParser::ParseError escape for a bad flag or value.
75
+ class SeqCLI
76
+ class Options
77
+ def self.parse(argv)
78
+ new.parse(argv)
79
+ end
80
+
81
+ def initialize
82
+ @opts = { ascii: Target.ascii? }
83
+ @parser = OptionParser.new
84
+ @parser.banner = SeqCLI::BANNER
85
+ build
86
+ end
87
+
88
+ def parse(argv)
89
+ @parser.parse!(argv)
90
+ raise SeqCLI::UsageError, "one file argument at most" if
91
+ argv.size > 1
92
+
93
+ [@opts, argv.first]
94
+ end
95
+
96
+ private
97
+
98
+ def build
99
+ @parser.on("--[no-]ascii") { @opts[:ascii] = it }
100
+ @parser.on("--numbered") { @opts[:numbered] = true }
101
+ @parser.on("--footer") { @opts[:footer] = true }
102
+ add_max_width
103
+ add_help
104
+ end
105
+
106
+ def add_max_width
107
+ @parser.on("--max-width N", Integer) { @opts[:max_width] = it }
108
+ end
109
+
110
+ def add_help
111
+ @parser.on("-h", "--help") do
112
+ raise SeqCLI::Halt.new(0, @parser.to_s)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella sparkline` (DESIGN.md, "Meters and rules"): one line
5
+ # from the VALUES given, or read from stdin (split on blanks and
6
+ # commas). Exit 0 drawn; 1 bad input (no value, a non-number); 2
7
+ # usage.
8
+ class SparklineCLI
9
+ class DataError < StandardError; end
10
+ # Raised by --help, carrying its text.
11
+ class Help < StandardError; end
12
+
13
+ BANNER = "Usage: tessella sparkline [options] [VALUE ...] " \
14
+ "(stdin if none)\n"
15
+
16
+ def self.run(argv, stdin:, stdout:, stderr:)
17
+ stdout.print(draw(argv, stdin))
18
+ 0
19
+ rescue Help => e
20
+ stdout.print(e.message)
21
+ 0
22
+ rescue DataError => e
23
+ fail_with(stderr, e.message, 1)
24
+ rescue OptionParser::ParseError, ArgumentError => e
25
+ fail_with(stderr, e.message, 2)
26
+ end
27
+
28
+ def self.draw(argv, stdin)
29
+ opts, words = parse(argv.dup)
30
+ source = words.empty? ? stdin.read : words.join(" ")
31
+ CLI.utf8!(source, DataError)
32
+ Tessella.sparkline(numbers(source), **opts).to_s
33
+ end
34
+
35
+ def self.fail_with(stderr, message, status)
36
+ stderr.puts("tessella sparkline: #{message}")
37
+ status
38
+ end
39
+
40
+ def self.numbers(text)
41
+ fields = text.split(/[\s,]+/).reject(&:empty?)
42
+ raise DataError, "no value" if fields.empty?
43
+
44
+ fields.map do |f|
45
+ value = Float(f, exception: false)
46
+ raise DataError, "not a number: #{f.inspect}" unless
47
+ value&.finite?
48
+
49
+ value
50
+ end
51
+ end
52
+
53
+ def self.parse(argv)
54
+ opts = {}
55
+ parser = OptionParser.new(BANNER)
56
+ options(parser, opts)
57
+ [opts, parser.parse!(argv)]
58
+ end
59
+
60
+ def self.options(parser, opts)
61
+ parser.on("--width N", Integer, "cells (one a value)") do |n|
62
+ opts[:width] = n
63
+ end
64
+ parser.on("--min A", Float, "range bottom") { opts[:min] = it }
65
+ parser.on("--max B", Float, "range top") { opts[:max] = it }
66
+ parser.on("--[no-]ascii", "eight ASCII levels: _.-:=+*#") do |v|
67
+ opts[:ascii] = v
68
+ end
69
+ parser.on("--help", "print this help") { raise Help, parser.to_s }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella table` options (ported from the ascii-art skill's
5
+ # scripts/table.rb): everything a usage error can be -- an
6
+ # unknown --style, a malformed --align, a negative --max-width,
7
+ # more than one file -- is settled here, before any row is read.
8
+ class TableOptions
9
+ def self.parse(argv)
10
+ new.parse(argv)
11
+ end
12
+
13
+ def initialize
14
+ @opts = { ascii: Target.ascii?, tsv: false, no_header: false }
15
+ @parser = build_parser
16
+ end
17
+
18
+ def parse(argv)
19
+ @parser.parse!(argv)
20
+ raise TableCLI::UsageError, "too many arguments" if argv.size > 1
21
+
22
+ check_custom_style!
23
+ [@opts, argv]
24
+ end
25
+
26
+ private
27
+
28
+ def usage!(message) = raise(TableCLI::UsageError, message)
29
+
30
+ def build_parser
31
+ OptionParser.new do |op|
32
+ op.banner = TableCLI::BANNER
33
+ add_input(op)
34
+ add_style(op)
35
+ add_output(op)
36
+ end
37
+ end
38
+
39
+ def add_input(op)
40
+ op.on("--tsv") { @opts[:tsv] = true }
41
+ op.on("--no-header") { @opts[:no_header] = true }
42
+ op.on("--align SPEC") { |s| @opts[:align] = align!(s) }
43
+ end
44
+
45
+ # A name, or a glyph set checked as a table's (11 glyphs).
46
+ def style!(spec, names)
47
+ name = Borders.name(spec)
48
+ return name if names.include?(name.to_s)
49
+
50
+ usage!("unknown --style #{spec}") unless Borders.custom?(spec)
51
+ Table::Glyphs.custom(spec)
52
+ spec
53
+ rescue ArgumentError => e
54
+ usage!(e.message)
55
+ end
56
+
57
+ def check_custom_style!
58
+ style = @opts[:style]
59
+ Borders.custom(style, ascii: @opts[:ascii]) if
60
+ Borders.custom?(style)
61
+ rescue ArgumentError => e
62
+ usage!(e.message)
63
+ end
64
+
65
+ def align!(spec)
66
+ usage!("--align must be letters l, r or c") unless
67
+ /\A[lrc]+\z/i.match?(spec)
68
+ spec.downcase
69
+ end
70
+
71
+ def add_style(op)
72
+ names = Table::STYLE_NAMES.map(&:to_s)
73
+ op.on("--style S", "#{names.join(", ")},",
74
+ "or 11 glyphs: tl tr bl br h v top_t bot_t",
75
+ "sep_l sep_t sep_r") do |s|
76
+ @opts[:style] = style!(s, names)
77
+ end
78
+ op.on("--[no-]ascii") { @opts[:ascii] = it }
79
+ op.on("--title TEXT") { |t| @opts[:title] = t }
80
+ end
81
+
82
+ def add_output(op)
83
+ op.on("--max-width N", Integer) do |n|
84
+ usage!("--max-width must be >= 1") if n < 1
85
+ @opts[:max_width] = n
86
+ end
87
+ op.on("-h", "--help") do
88
+ @opts[:help] = true
89
+ end
90
+ end
91
+ end
92
+
93
+ # `tessella table` (DESIGN.md, "Command line"): one table from
94
+ # one CSV/TSV file argument ("-" or none for stdin). Exit 0 drawn,
95
+ # 1 bad input (a ragged row, no rows, malformed CSV, output past
96
+ # --max-width), 2 usage. The script's --no-lint has no equivalent
97
+ # here: the artlint.rb pass stays in the skill.
98
+ class TableCLI
99
+ class UsageError < StandardError; end
100
+ class DataError < StandardError; end
101
+
102
+ BANNER = <<~TEXT
103
+ Usage: tessella table [options] [file] (stdin if none)
104
+ --tsv --no-header --align SPEC --style NAME|GLYPHS --[no-]ascii
105
+ --title TEXT --max-width N
106
+ TEXT
107
+
108
+ def self.run(argv, stdin:, stdout:, stderr:)
109
+ opts, paths = TableOptions.parse(argv.dup)
110
+ return help(stdout) if opts[:help]
111
+
112
+ stdout.print(draw(paths.first, opts, stdin))
113
+ 0
114
+ rescue UsageError, OptionParser::ParseError => e
115
+ fail_with(stderr, e.message, 2)
116
+ rescue DataError => e
117
+ fail_with(stderr, e.message, 1)
118
+ end
119
+
120
+ def self.help(stdout)
121
+ stdout.print(BANNER)
122
+ 0
123
+ end
124
+
125
+ def self.fail_with(stderr, message, status)
126
+ stderr.puts("tessella table: #{message}")
127
+ stderr.print(BANNER) if status == 2
128
+ status
129
+ end
130
+
131
+ def self.draw(path, opts, stdin)
132
+ text = CLI.read_text(path == "-" ? nil : path, stdin, DataError)
133
+ rows = Table.parse(text, tsv: opts[:tsv])
134
+ header = !opts[:no_header]
135
+ block = build(rows, header, opts)
136
+ check_max_width!(block, opts[:max_width])
137
+ block.to_s
138
+ rescue ArgumentError => e
139
+ raise DataError, e.message
140
+ end
141
+
142
+ def self.build(rows, header, opts)
143
+ head, body = Table.split(rows, header)
144
+ check_align_length!(opts[:align], (head || body.first).size)
145
+ Tessella.table(rows, header: header, style: opts[:style],
146
+ ascii: opts[:ascii], align: opts[:align],
147
+ title: opts[:title])
148
+ end
149
+
150
+ def self.check_align_length!(align, ncols)
151
+ return unless align && align.length != ncols
152
+
153
+ raise UsageError,
154
+ "--align has #{align.length} letters, #{ncols} columns"
155
+ end
156
+
157
+ def self.check_max_width!(block, max_width)
158
+ return unless max_width
159
+
160
+ over = block.lines.map(&:rstrip).find { Width.of(it) > max_width }
161
+ return unless over
162
+
163
+ raise DataError, "output is #{Width.of(over)} columns, over " \
164
+ "--max-width #{max_width}"
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella text` (DESIGN.md, "Text styles"): TEXT (the words given,
5
+ # or stdin when none) in a style. A letter or digit with no form in
6
+ # it passes through, named on stderr; --strict makes that exit 1.
7
+ # --plain turns any style back to plain text. Exit 0; 1 under
8
+ # --strict when a character has no form; 2 usage (no --style, an
9
+ # unknown style, --ascii).
10
+ class TextCLI
11
+ class UsageError < StandardError; end
12
+ class DataError < StandardError; end
13
+ # Raised by --help, carrying its text.
14
+ class Help < StandardError; end
15
+
16
+ BANNER = "Usage: tessella text --style S [--strict] [TEXT ...] " \
17
+ "(stdin if none)\n tessella text --plain [TEXT ...]\n"
18
+
19
+ def self.run(argv, stdin:, stdout:, stderr:)
20
+ opts, words = parse(argv.dup)
21
+ convert(input(words, stdin), opts, stdout, stderr)
22
+ rescue Help => e
23
+ stdout.print(e.message)
24
+ 0
25
+ rescue DataError, OptionParser::ParseError, UsageError,
26
+ ArgumentError => e
27
+ stderr.puts("tessella text: #{e.message}")
28
+ e.is_a?(DataError) ? 1 : 2
29
+ end
30
+
31
+ # The words given, or stdin; UTF-8 either way.
32
+ def self.input(words, stdin)
33
+ text = words.empty? ? stdin.read : "#{words.join(" ")}\n"
34
+ CLI.utf8!(text, DataError)
35
+ end
36
+
37
+ def self.convert(text, opts, stdout, stderr)
38
+ style = opts[:style] or raise UsageError, "--style S or --plain"
39
+ raise UsageError, "text styles are not ASCII" if opts[:ascii]
40
+
41
+ missing = Text.unmapped(text, style)
42
+ stdout.print(Tessella.text(text, style: style)) unless
43
+ opts[:strict] && missing.any?
44
+ return 0 if missing.empty?
45
+
46
+ name = Text.dashed(Borders.name(style))
47
+ stderr.puts("tessella text: no #{name} form for: " \
48
+ "#{missing.join(" ")}")
49
+ opts[:strict] ? 1 : 0
50
+ end
51
+
52
+ def self.parse(argv)
53
+ opts = {}
54
+ parser = OptionParser.new(BANNER)
55
+ options(parser, opts)
56
+ [opts, parser.parse!(argv)]
57
+ end
58
+
59
+ def self.options(parser, opts)
60
+ names = (Text::STYLES - [:plain]).map { Text.dashed(it) }
61
+ lines = names.each_slice(3).map { "#{it.join(", ")}," }
62
+ parser.on("--style S", *lines) { opts[:style] = it }
63
+ parser.on("--plain", "any style back to plain") do
64
+ opts[:style] = "plain"
65
+ end
66
+ flags(parser, opts)
67
+ end
68
+
69
+ def self.flags(parser, opts)
70
+ parser.on("--strict", "exit 1 when a letter or digit has no " \
71
+ "form") { opts[:strict] = true }
72
+ parser.on("--ascii", "refused: every style is non-ASCII") do
73
+ opts[:ascii] = true
74
+ end
75
+ parser.on("--help", "print this help") { raise Help, parser.to_s }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # `tessella tree` (DESIGN.md, "Command line"): draws one tree from
5
+ # a file argument or stdin. Same options as the ascii-art skill's
6
+ # scripts/tree.rb minus --no-lint (its lint pass stays in the
7
+ # skill) plus --help; messages are prefixed "tessella tree: ".
8
+ # Exit 0 drawn; 1 bad input, an ArgumentError from Tree itself
9
+ # (a tab, a bad indent, a skipped level, an unknown style, a
10
+ # render past --max-width); 2 usage (an unknown option, more than
11
+ # one file argument, or a missing file, as tree.rb itself exits
12
+ # for the last of those).
13
+ class TreeCLI
14
+ class UsageError < StandardError; end
15
+ class Help < StandardError; end
16
+
17
+ USAGE_ERRORS = [OptionParser::ParseError, UsageError].freeze
18
+
19
+ USAGE = <<~TEXT
20
+ Usage: tessella tree [--style S] [--[no-]ascii] [--root NAME]
21
+ [--max-width N] [file] (stdin if none)
22
+ TEXT
23
+
24
+ def self.run(argv, stdin:, stdout:, stderr:)
25
+ opts, paths = TreeOptions.parse(argv.dup)
26
+ check_arity!(paths)
27
+ stdout.print(draw(input(paths, stdin), opts))
28
+ 0
29
+ rescue Help
30
+ help(stdout)
31
+ rescue *USAGE_ERRORS => e
32
+ fail_with(stderr, e.message, 2)
33
+ rescue ArgumentError => e
34
+ fail_with(stderr, e.message, 1)
35
+ end
36
+
37
+ def self.input(paths, stdin)
38
+ CLI.read_text(paths.first, stdin, ArgumentError)
39
+ end
40
+
41
+ def self.help(stdout)
42
+ stdout.print(USAGE)
43
+ 0
44
+ end
45
+
46
+ def self.fail_with(stderr, message, status)
47
+ stderr.puts("tessella tree: #{message}")
48
+ status
49
+ end
50
+
51
+ def self.check_arity!(paths)
52
+ raise UsageError, "too many arguments" if paths.size > 1
53
+ end
54
+
55
+ # Usage is settled by now; style is ignored under --ascii, as
56
+ # tree.rb itself ignores it rather than refusing the conflict
57
+ # (Tree.render's own refusal is for a direct Tessella.tree
58
+ # call made with both; this CLI never reaches it, since it
59
+ # drops style whenever --ascii is given, same as the script).
60
+ def self.draw(text, opts)
61
+ style = opts[:ascii] ? nil : opts[:style]
62
+ result = Tree.render(text, style: style, ascii: opts[:ascii],
63
+ root: opts[:root])
64
+ Tree.check_max_width!(result, opts[:max_width])
65
+ Block.new(result.lines).to_s
66
+ end
67
+ end
68
+
69
+ # `tessella tree`'s options: --style (one of STYLE_NAMES; another
70
+ # value is OptionParser's own usage error, as tree.rb's own
71
+ # `--style S, STYLES.keys` is), --ascii, --root NAME, --max-width
72
+ # N, and -h/--help, which raises immediately on being parsed (as
73
+ # tree.rb's own `-h`/`--help` prints its usage and exits on the
74
+ # spot, so an error in an option before it on the line still wins).
75
+ class TreeOptions
76
+ STYLE_NAMES = %w[light heavy rounded].freeze
77
+
78
+ def self.parse(argv)
79
+ opts = { style: nil, ascii: Target.ascii?, root: nil,
80
+ max_width: nil }
81
+ build_parser(opts).parse!(argv)
82
+ [opts, argv]
83
+ end
84
+
85
+ def self.build_parser(opts)
86
+ OptionParser.new do |op|
87
+ op.banner = TreeCLI::USAGE
88
+ add_options(op, opts)
89
+ end
90
+ end
91
+
92
+ def self.add_options(op, opts)
93
+ op.on("--style S", STYLE_NAMES) { |s| opts[:style] = s.to_sym }
94
+ op.on("--[no-]ascii") { opts[:ascii] = it }
95
+ op.on("--root NAME") { |n| opts[:root] = n }
96
+ op.on("--max-width N", Integer) { |n| opts[:max_width] = n }
97
+ op.on("-h", "--help") { raise TreeCLI::Help }
98
+ end
99
+ end
100
+ end