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