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