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,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
|