unicode_plot 0.0.1

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.
@@ -0,0 +1,234 @@
1
+ module UnicodePlot
2
+ module BorderMaps
3
+ BORDER_SOLID = {
4
+ tl: "┌",
5
+ tr: "┐",
6
+ bl: "└",
7
+ br: "┘",
8
+ t: "─",
9
+ l: "│",
10
+ b: "─",
11
+ r: "│"
12
+ }.freeze
13
+
14
+ BORDER_BARPLOT = {
15
+ tl: "┌",
16
+ tr: "┐",
17
+ bl: "└",
18
+ br: "┘",
19
+ t: " ",
20
+ l: "┤",
21
+ b: " ",
22
+ r: " ",
23
+ }.freeze
24
+ end
25
+
26
+ BORDER_MAP = {
27
+ solid: BorderMaps::BORDER_SOLID,
28
+ barplot: BorderMaps::BORDER_BARPLOT,
29
+ }.freeze
30
+
31
+ module BorderPrinter
32
+ include StyledPrinter
33
+
34
+ def print_border_top(out, padding, length, border=:solid, color: :light_black)
35
+ return if border == :none
36
+ b = BORDER_MAP[border]
37
+ print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
38
+ end
39
+
40
+ def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
41
+ return if border == :none
42
+ b = BORDER_MAP[border]
43
+ print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
44
+ end
45
+ end
46
+
47
+ class Renderer
48
+ include BorderPrinter
49
+
50
+ def self.render(out, plot)
51
+ new(plot).render(out)
52
+ end
53
+
54
+ def initialize(plot)
55
+ @plot = plot
56
+ @out = nil
57
+ end
58
+
59
+ attr_reader :plot
60
+ attr_reader :out
61
+
62
+ def render(out)
63
+ @out = out
64
+ init_render
65
+
66
+ render_top
67
+ render_rows
68
+ render_bottom
69
+ end
70
+
71
+ private
72
+
73
+ def render_top
74
+ # plot the title and the top border
75
+ print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
76
+ puts if plot.title_given?
77
+
78
+ if plot.show_labels?
79
+ topleft_str = plot.decorations.fetch(:tl, "")
80
+ topleft_col = plot.colors_deco.fetch(:tl, :light_black)
81
+ topmid_str = plot.decorations.fetch(:t, "")
82
+ topmid_col = plot.colors_deco.fetch(:t, :light_black)
83
+ topright_str = plot.decorations.fetch(:tr, "")
84
+ topright_col = plot.colors_deco.fetch(:tr, :light_black)
85
+
86
+ if topleft_str != "" || topright_str != "" || topmid_str != ""
87
+ topleft_len = topleft_str.length
88
+ topmid_len = topmid_str.length
89
+ topright_len = topright_str.length
90
+ print_styled(out, @border_padding, topleft_str, color: topleft_col)
91
+ cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
92
+ pad = cnt > 0 ? " " * cnt : ""
93
+ print_styled(out, pad, topmid_str, color: topmid_col)
94
+ cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
95
+ pad = cnt > 0 ? " " * cnt : ""
96
+ print_styled(out, pad, topright_str, "\n", color: topright_col)
97
+ end
98
+ end
99
+
100
+ print_border_top(out, @border_padding, @border_length, plot.border)
101
+ print(" " * @max_len_r, @plot_padding, "\n")
102
+ end
103
+
104
+ # render all rows
105
+ def render_rows
106
+ (0 ... plot.n_rows).each {|row| render_row(row) }
107
+ end
108
+
109
+ def render_row(row)
110
+ # Current labels to left and right of the row and their length
111
+ left_str = plot.labels_left.fetch(row, "")
112
+ left_col = plot.colors_left.fetch(row, :light_black)
113
+ right_str = plot.labels_right.fetch(row, "")
114
+ right_col = plot.colors_right.fetch(row, :light_black)
115
+ left_len = left_str.length
116
+ right_len = right_str.length
117
+
118
+ unless color?(out)
119
+ left_str = nocolor_string(left_str)
120
+ right_str = nocolor_string(right_str)
121
+ end
122
+
123
+ # print left annotations
124
+ print(" " * plot.margin)
125
+ if plot.show_labels?
126
+ if row == @y_lab_row
127
+ # print ylabel
128
+ print_styled(out, plot.ylabel, color: :normal)
129
+ print(" " * (@max_len_l - plot.ylabel_length - left_len))
130
+ else
131
+ # print padding to fill ylabel length
132
+ print(" " * (@max_len_l - left_len))
133
+ end
134
+ # print the left annotation
135
+ print_styled(out, left_str, color: left_col)
136
+ end
137
+
138
+ # print left border
139
+ print_styled(out, @plot_padding, @b[:l], color: :light_black)
140
+
141
+ # print canvas row
142
+ plot.print_row(out, row)
143
+
144
+ #print right label and padding
145
+ print_styled(out, @b[:r], color: :light_black)
146
+ if plot.show_labels?
147
+ print(@plot_padding)
148
+ print_styled(out, right_str, color: right_col)
149
+ print(" " * (@max_len_r - right_len))
150
+ end
151
+ puts
152
+ end
153
+
154
+ def render_bottom
155
+ # draw bottom border and bottom labels
156
+ print_border_bottom(out, @border_padding, @border_length, plot.border)
157
+ print(" " * @max_len_r, @plot_padding)
158
+ if plot.show_labels?
159
+ botleft_str = plot.decorations.fetch(:bl, "")
160
+ botleft_col = plot.colors_deco.fetch(:bl, :light_black)
161
+ botmid_str = plot.decorations.fetch(:b, "")
162
+ botmid_col = plot.colors_deco.fetch(:b, :light_black)
163
+ botright_str = plot.decorations.fetch(:br, "")
164
+ botright_col = plot.colors_deco.fetch(:br, :light_black)
165
+
166
+ if botleft_str != "" || botright_str != "" || botmid_str != ""
167
+ puts
168
+ botleft_len = botleft_str.length
169
+ botmid_len = botmid_str.length
170
+ botright_len = botright_str.length
171
+ print_styled(out, @border_padding, botleft_str, color: botleft_col)
172
+ cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
173
+ pad = cnt > 0 ? " " * cnt : ""
174
+ print_styled(out, pad, botmid_str, color: botmid_col)
175
+ cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
176
+ pad = cnt > 0 ? " " * cnt : ""
177
+ print_styled(out, pad, botright_str, color: botright_col)
178
+ end
179
+
180
+ # abuse the print_title function to print the xlabel. maybe refactor this
181
+ puts if plot.xlabel_given?
182
+ print_title(@border_padding, plot.xlabel, p_width: @border_length)
183
+ end
184
+ end
185
+
186
+ def init_render
187
+ @b = BORDER_MAP[plot.border]
188
+ @border_length = plot.n_columns
189
+
190
+ # get length of largest strings to the left and right
191
+ @max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
192
+ plot.labels_left.each_value.map {|l| l.to_s.length }.max :
193
+ 0
194
+ @max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
195
+ plot.labels_right.each_value.map {|l| l.to_s.length }.max :
196
+ 0
197
+ if plot.show_labels? && plot.ylabel_given?
198
+ @max_len_l += plot.ylabel_length + 1
199
+ end
200
+
201
+ # offset where the plot (incl border) begins
202
+ @plot_offset = @max_len_l + plot.margin + plot.padding
203
+
204
+ # padding-string from left to border
205
+ @plot_padding = " " * plot.padding
206
+
207
+ # padding-string between labels and border
208
+ @border_padding = " " * @plot_offset
209
+
210
+ # compute position of ylabel
211
+ @y_lab_row = (plot.n_rows / 2.0).round
212
+ end
213
+
214
+ def print_title(padding, title, p_width: 0, color: :normal)
215
+ return unless title && title != ""
216
+ offset = (p_width / 2.0 - title.length / 2.0).round
217
+ offset = [offset, 0].max
218
+ tpad = " " * offset
219
+ print_styled(out, padding, tpad, title, color: color)
220
+ end
221
+
222
+ def print(*args)
223
+ out.print(*args)
224
+ end
225
+
226
+ def puts(*args)
227
+ out.puts(*args)
228
+ end
229
+
230
+ def nocolor_string(str)
231
+ str.to_s.gsub(/\e\[[0-9]+m/, "")
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,91 @@
1
+ module UnicodePlot
2
+ module StyledPrinter
3
+ TEXT_COLORS = {
4
+ black: "\033[30m",
5
+ red: "\033[31m",
6
+ green: "\033[32m",
7
+ yellow: "\033[33m",
8
+ blue: "\033[34m",
9
+ magenta: "\033[35m",
10
+ cyan: "\033[36m",
11
+ white: "\033[37m",
12
+ gray: "\033[90m",
13
+ light_black: "\033[90m",
14
+ light_red: "\033[91m",
15
+ light_green: "\033[92m",
16
+ light_yellow: "\033[93m",
17
+ light_blue: "\033[94m",
18
+ light_magenta: "\033[95m",
19
+ light_cyan: "\033[96m",
20
+ normal: "\033[0m",
21
+ default: "\033[39m",
22
+ bold: "\033[1m",
23
+ underline: "\033[4m",
24
+ blink: "\033[5m",
25
+ reverse: "\033[7m",
26
+ hidden: "\033[8m",
27
+ nothing: "",
28
+ }
29
+
30
+ 0.upto(255) do |i|
31
+ TEXT_COLORS[i] = "\033[38;5;#{i}m"
32
+ end
33
+
34
+ TEXT_COLORS.freeze
35
+
36
+ DISABLE_TEXT_STYLE = {
37
+ bold: "\033[22m",
38
+ underline: "\033[24m",
39
+ blink: "\033[25m",
40
+ reverse: "\033[27m",
41
+ hidden: "\033[28m",
42
+ normal: "",
43
+ default: "",
44
+ nothing: "",
45
+ }.freeze
46
+
47
+ COLOR_ENCODE = {
48
+ normal: 0b000,
49
+ blue: 0b001,
50
+ red: 0b010,
51
+ magenta: 0b011,
52
+ green: 0b100,
53
+ cyan: 0b101,
54
+ yellow: 0b110,
55
+ white: 0b111
56
+ }.freeze
57
+
58
+ COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
59
+
60
+ def print_styled(out, *args, bold: false, color: :normal)
61
+ return out.print(*args) unless color?(out)
62
+
63
+ str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
64
+ color = :nothing if bold && color == :bold
65
+ enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
66
+ (bold ? TEXT_COLORS[:bold] : "")
67
+ disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
68
+ DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
69
+ first = true
70
+ StringIO.open do |sio|
71
+ str.each_line do |line|
72
+ sio.puts unless first
73
+ first = false
74
+ continue if line.empty?
75
+ sio.print(enable_ansi, line, disable_ansi)
76
+ end
77
+ sio.close
78
+ out.print(sio.string)
79
+ end
80
+ end
81
+
82
+ def print_color(out, color, *args)
83
+ color = COLOR_DECODE[color]
84
+ print_styled(out, *args, color: color)
85
+ end
86
+
87
+ def color?(out)
88
+ out&.tty? || false
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,43 @@
1
+ module UnicodePlot
2
+ module ValueTransformer
3
+ PREDEFINED_TRANSFORM_FUNCTIONS = {
4
+ log: Math.method(:log),
5
+ ln: Math.method(:log),
6
+ log10: Math.method(:log10),
7
+ lg: Math.method(:log10),
8
+ log2: Math.method(:log2),
9
+ lb: Math.method(:log2),
10
+ }.freeze
11
+
12
+ def transform_values(func, values)
13
+ return values unless func
14
+
15
+ unless func.respond_to?(:call)
16
+ func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
17
+ unless func.respond_to?(:call)
18
+ raise ArgumentError, "func must be callable"
19
+ end
20
+ end
21
+
22
+ case values
23
+ when Numeric
24
+ func.(values)
25
+ else
26
+ values.map(&func)
27
+ end
28
+ end
29
+
30
+ module_function def transform_name(func, basename="")
31
+ return basename unless func
32
+ case func
33
+ when String, Symbol
34
+ name = func
35
+ when ->(f) { f.respond_to?(:name) }
36
+ name = func.name
37
+ else
38
+ name = "custom"
39
+ end
40
+ "#{basename} [#{name}]"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ module UnicodePlot
2
+ VERSION = "0.0.1"
3
+
4
+ module Version
5
+ numbers, TAG = VERSION.split("-", 2)
6
+ MAJOR, MINOR, MICRO = numbers.split(".", 3).map(&:to_i)
7
+ STRING = VERSION
8
+ end
9
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,2 @@
1
+ require_relative "helper/fixture"
2
+ require_relative "helper/with_term"
@@ -0,0 +1,14 @@
1
+ require 'pathname'
2
+
3
+ module Helper
4
+ module Fixture
5
+ def fixture_dir
6
+ test_dir = Pathname(File.expand_path("../..", __FILE__))
7
+ test_dir.join("fixtures")
8
+ end
9
+
10
+ def fixture_path(*components)
11
+ fixture_dir.join(*components)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ require 'stringio'
2
+
3
+ module Helper
4
+ module WithTerm
5
+ def with_term(tty=true)
6
+ sio = StringIO.new
7
+ def sio.tty?; true; end if tty
8
+
9
+ orig_stdout, $stdout = $stdout, sio
10
+ orig_env = ENV.to_h.dup
11
+ ENV['TERM'] = 'xterm-256color'
12
+
13
+ result = yield
14
+ sio.close
15
+
16
+ [result, sio.string]
17
+ ensure
18
+ $stdout.close
19
+ $stdout = orig_stdout
20
+ ENV.replace(orig_env) if orig_env
21
+ end
22
+ end
23
+ end
data/test/run-test.rb ADDED
@@ -0,0 +1,12 @@
1
+ base_dir = File.expand_path("../..", __FILE__)
2
+ lib_dir = File.join(base_dir, "lib")
3
+ test_dir = File.join(base_dir, "test")
4
+
5
+ $LOAD_PATH.unshift(lib_dir)
6
+
7
+ require "test/unit"
8
+ require "unicode_plot"
9
+
10
+ require_relative "helper"
11
+
12
+ exit Test::Unit::AutoRunner.run(true, test_dir)
@@ -0,0 +1,59 @@
1
+ class BarplotTest < Test::Unit::TestCase
2
+ include Helper::Fixture
3
+ include Helper::WithTerm
4
+
5
+ sub_test_case("UnicodePlot.barplot") do
6
+ sub_test_case("print to tty") do
7
+ test("the output is colored") do
8
+ data = { bar: 23, foo: 37 }
9
+ plot = UnicodePlot.barplot(data: data)
10
+ _, output = with_term do
11
+ plot.render($stdout)
12
+ end
13
+ assert_equal(fixture_path("barplot/default.txt").read,
14
+ output)
15
+ end
16
+ end
17
+
18
+ sub_test_case("print to non-tty IO") do
19
+ test("the output is not colored") do
20
+ data = { bar: 23, foo: 37 }
21
+ plot = UnicodePlot.barplot(data: data)
22
+ output = StringIO.open do |sio|
23
+ sio.print(plot)
24
+ sio.close
25
+ sio.string
26
+ end
27
+ assert_equal(fixture_path("barplot/nocolor.txt").read,
28
+ output)
29
+ end
30
+ end
31
+
32
+ sub_test_case("xscale: :log10") do
33
+ test("default") do
34
+ plot = UnicodePlot.barplot(
35
+ [:a, :b, :c, :d, :e],
36
+ [0, 1, 10, 100, 1000],
37
+ title: "Logscale Plot",
38
+ xscale: :log10
39
+ )
40
+ _, output = with_term { plot.render($stdout) }
41
+ assert_equal(fixture_path("barplot/log10.txt").read,
42
+ output)
43
+ end
44
+
45
+ test("with custom label") do
46
+ plot = UnicodePlot.barplot(
47
+ [:a, :b, :c, :d, :e],
48
+ [0, 1, 10, 100, 1000],
49
+ title: "Logscale Plot",
50
+ xlabel: "custom label",
51
+ xscale: :log10
52
+ )
53
+ _, output = with_term { plot.render($stdout) }
54
+ assert_equal(fixture_path("barplot/log10_label.txt").read,
55
+ output)
56
+ end
57
+ end
58
+ end
59
+ end