unicode_plot 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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