hackmac 1.11.0 → 2.0.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 +4 -4
- data/CHANGES.md +44 -13
- data/Rakefile +1 -0
- data/VERSION +1 -1
- data/bin/gfxmon +6 -9
- data/hackmac.gemspec +5 -4
- data/lib/hackmac/version.rb +1 -1
- data/lib/hackmac.rb +1 -1
- metadata +15 -9
- data/lib/hackmac/graph/display/cell.rb +0 -48
- data/lib/hackmac/graph/display.rb +0 -494
- data/lib/hackmac/graph/formatters.rb +0 -111
- data/lib/hackmac/graph.rb +0 -457
data/lib/hackmac/graph.rb
DELETED
|
@@ -1,457 +0,0 @@
|
|
|
1
|
-
require 'term/ansicolor'
|
|
2
|
-
require 'tins'
|
|
3
|
-
require 'digest/md5'
|
|
4
|
-
|
|
5
|
-
require 'hackmac/graph/formatters.rb'
|
|
6
|
-
|
|
7
|
-
# A class that provides graphical display functionality for terminal-based data
|
|
8
|
-
# visualization
|
|
9
|
-
#
|
|
10
|
-
# The Graph class enables the creation of dynamic, real-time visualizations of
|
|
11
|
-
# data values within a terminal environment. It manages the rendering of
|
|
12
|
-
# graphical representations such as line charts or graphs, updating them
|
|
13
|
-
# continuously based on provided data sources. The class handles terminal
|
|
14
|
-
# control operations, including cursor positioning, color management, and
|
|
15
|
-
# screen clearing to ensure smooth visual updates. It also supports
|
|
16
|
-
# configuration of display parameters like title, formatting strategies for
|
|
17
|
-
# values, update intervals, and color schemes for different data series.
|
|
18
|
-
#
|
|
19
|
-
# @example
|
|
20
|
-
# graph = Hackmac::Graph.new(
|
|
21
|
-
# title: 'CPU Usage',
|
|
22
|
-
# value: ->(i) { rand(100) },
|
|
23
|
-
# format_value: :as_percent,
|
|
24
|
-
# sleep: 1,
|
|
25
|
-
# color: 33
|
|
26
|
-
# )
|
|
27
|
-
# graph.start
|
|
28
|
-
# # Starts the graphical display loop
|
|
29
|
-
#
|
|
30
|
-
# @example
|
|
31
|
-
# graph = Hackmac::Graph.new(
|
|
32
|
-
# title: 'Memory Usage',
|
|
33
|
-
# value: ->(i) { `vm_stat`.match(/Pages free: (\d+)/)[1].to_i },
|
|
34
|
-
# format_value: :as_bytes,
|
|
35
|
-
# sleep: 2
|
|
36
|
-
# )
|
|
37
|
-
# graph.start
|
|
38
|
-
# # Starts a memory usage graph with custom data source and formatting
|
|
39
|
-
class Hackmac::Graph
|
|
40
|
-
include Term::ANSIColor
|
|
41
|
-
include Hackmac::Graph::Formatters
|
|
42
|
-
|
|
43
|
-
# The initialize method sets up a Graph instance by configuring its display parameters and internal state.
|
|
44
|
-
#
|
|
45
|
-
# This method configures the graph visualization with title, value provider,
|
|
46
|
-
# formatting options, update interval, and color settings. It initializes
|
|
47
|
-
# internal data structures for storing historical values and manages
|
|
48
|
-
# synchronization through a mutex for thread-safe operations.
|
|
49
|
-
#
|
|
50
|
-
# @param title [ String ] the title to display at the bottom of the graph
|
|
51
|
-
# @param value [ Proc ] a proc that takes an index and returns a numeric
|
|
52
|
-
# value for plotting
|
|
53
|
-
# @param format_value [ Proc, Symbol, nil ] formatting strategy for
|
|
54
|
-
# displaying values
|
|
55
|
-
# @param sleep [ Numeric ] time in seconds between updates
|
|
56
|
-
# @param color [ Object, Proc, nil ] color or proc or nil. nil
|
|
57
|
-
# determineicolor dynamically from the title
|
|
58
|
-
# @param color_secondary [ Object, nil ] secondary color or nil
|
|
59
|
-
# for enhanced visuals, nil derives secondary color from (primary) color
|
|
60
|
-
# @param adjust_brightness [ Symbol ] the method to call on the color for
|
|
61
|
-
# brightness adjustment (:lighten or :darken), defaults to :lighten
|
|
62
|
-
# @param adjust_brightness_percentage [ Float ] the percentage value to use
|
|
63
|
-
# for the brightness adjustment
|
|
64
|
-
# @param foreground_color [ Symbol ] the default text color for the display
|
|
65
|
-
# @param background_color [ Symbol ] the default background color for the
|
|
66
|
-
# display
|
|
67
|
-
# @param resolution [ Symbol ] the resolution mode (:single or :double) for
|
|
68
|
-
# the graph display, defaults to :double.
|
|
69
|
-
#
|
|
70
|
-
# @raise [ ArgumentError ] if the sleep parameter is negative
|
|
71
|
-
# @raise [ TypeError ] if the sleep parameter is not numeric
|
|
72
|
-
# @raise [ ArgumentError ] if the resolution parameter is not :single or :double
|
|
73
|
-
def initialize(
|
|
74
|
-
title:,
|
|
75
|
-
value: -> i { 0 },
|
|
76
|
-
format_value: nil,
|
|
77
|
-
sleep: 5,
|
|
78
|
-
color: nil,
|
|
79
|
-
color_secondary: nil,
|
|
80
|
-
adjust_brightness: :lighten,
|
|
81
|
-
adjust_brightness_percentage: 15,
|
|
82
|
-
foreground_color: :white,
|
|
83
|
-
background_color: :black,
|
|
84
|
-
resolution: :double
|
|
85
|
-
)
|
|
86
|
-
@title = title
|
|
87
|
-
@value = value
|
|
88
|
-
@format_value = format_value
|
|
89
|
-
sleep = Float(sleep)
|
|
90
|
-
sleep >= 0 or raise ArgumentError, 'sleep has to be >= 0'
|
|
91
|
-
@sleep = sleep
|
|
92
|
-
@continue = false
|
|
93
|
-
@data = []
|
|
94
|
-
@color = color
|
|
95
|
-
@color_secondary = color_secondary
|
|
96
|
-
adjust_brightness = adjust_brightness.to_sym
|
|
97
|
-
if %i[ lighten darken ].include? adjust_brightness
|
|
98
|
-
@adjust_brightness = adjust_brightness
|
|
99
|
-
else
|
|
100
|
-
raise ArgumentError, 'adjust_brightness required to be either :lighten or :darken'
|
|
101
|
-
end
|
|
102
|
-
@adjust_brightness_percentage = Float(adjust_brightness_percentage)
|
|
103
|
-
@foreground_color = foreground_color
|
|
104
|
-
@background_color = background_color
|
|
105
|
-
resolution = resolution.to_sym
|
|
106
|
-
if %i[ single double ].include? resolution
|
|
107
|
-
@resolution = resolution
|
|
108
|
-
else
|
|
109
|
-
raise ArgumentError, 'resolution required to be either :single or :double'
|
|
110
|
-
end
|
|
111
|
-
@mutex = Mutex.new
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# The start method initiates the graphical display process by setting up
|
|
115
|
-
# signal handlers, performing an initial terminal reset, and entering the
|
|
116
|
-
# main update loop
|
|
117
|
-
#
|
|
118
|
-
# This method serves as the entry point for starting the graph visualization
|
|
119
|
-
# functionality. It configures the necessary signal handlers for graceful
|
|
120
|
-
# shutdown and terminal resizing, performs an initial full reset of the
|
|
121
|
-
# display state, and then begins the continuous loop that updates and renders
|
|
122
|
-
# graphical data.
|
|
123
|
-
def start
|
|
124
|
-
install_handlers
|
|
125
|
-
full_reset
|
|
126
|
-
start_loop
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# The stop method terminates the graphical display process by performing a
|
|
130
|
-
# full reset and setting the continue flag to false
|
|
131
|
-
#
|
|
132
|
-
# This method serves as the shutdown mechanism for the graph visualization
|
|
133
|
-
# functionality. It ensures that all display resources are properly cleaned
|
|
134
|
-
# up and the terminal state is restored to its original condition before
|
|
135
|
-
# stopping the continuous update loop.
|
|
136
|
-
def stop
|
|
137
|
-
full_reset
|
|
138
|
-
@continue = false
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
private
|
|
142
|
-
|
|
143
|
-
# Draws the graphical representation of the data on the display.
|
|
144
|
-
#
|
|
145
|
-
# This method renders the data as a graph using Unicode block characters (▀)
|
|
146
|
-
# to achieve 2px vertical resolution in terminal graphics. Each data point is
|
|
147
|
-
# plotted with appropriate color blending for visual appeal.
|
|
148
|
-
def draw_graph
|
|
149
|
-
y_width = data_range
|
|
150
|
-
color = pick_color
|
|
151
|
-
color_secondary = pick_secondary_color(
|
|
152
|
-
color,
|
|
153
|
-
adjust_brightness: @adjust_brightness,
|
|
154
|
-
adjust_brightness_percentage: @adjust_brightness_percentage
|
|
155
|
-
)
|
|
156
|
-
data.each_with_index do |value, i|
|
|
157
|
-
x = 1 + i + columns - data.size
|
|
158
|
-
y0 = ((value - data.min) * lines / y_width.to_f)
|
|
159
|
-
y = lines - y0.round + 1
|
|
160
|
-
y.upto(lines) do |iy|
|
|
161
|
-
if iy > y
|
|
162
|
-
@display.at(iy, x).on_color(color_secondary).write(' ')
|
|
163
|
-
else
|
|
164
|
-
case @resolution
|
|
165
|
-
when :double
|
|
166
|
-
fract = 1 - (y0 - y0.floor).abs
|
|
167
|
-
case
|
|
168
|
-
when (0...0.5) === fract
|
|
169
|
-
@display.at(iy, x).on_color(@background_color).color(color).write(?▄)
|
|
170
|
-
else
|
|
171
|
-
@display.at(iy, x).on_color(color).color(color_secondary).write(?▄)
|
|
172
|
-
end
|
|
173
|
-
else
|
|
174
|
-
@display.at(iy, x).on_color(color).color(color_secondary).write(' ')
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# The data_range method calculates the range of data values by computing the
|
|
182
|
-
# difference between the maximum and minimum values in the data set and
|
|
183
|
-
# converting the result to a float
|
|
184
|
-
#
|
|
185
|
-
# @return [ Float ] the calculated range of the data values as a float
|
|
186
|
-
def data_range
|
|
187
|
-
(data.max - data.min).abs.to_f
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# The start_loop method executes a continuous loop to update and display
|
|
191
|
-
# graphical data
|
|
192
|
-
#
|
|
193
|
-
# This method manages the main execution loop for rendering graphical
|
|
194
|
-
# representations of data values over time. It initializes display state,
|
|
195
|
-
# processes incoming data, calculates visual representations, and handles
|
|
196
|
-
# terminal updates while respecting configured timing intervals.
|
|
197
|
-
#
|
|
198
|
-
# It continuously updates the display and handles data processing in a loop
|
|
199
|
-
# until explicitly stopped.
|
|
200
|
-
def start_loop
|
|
201
|
-
full_reset
|
|
202
|
-
@counter = -1
|
|
203
|
-
@continue = true
|
|
204
|
-
while @continue
|
|
205
|
-
@start = Time.now
|
|
206
|
-
@full_reset and full_reset
|
|
207
|
-
perform hide_cursor
|
|
208
|
-
|
|
209
|
-
@data << @value.(@counter += 1)
|
|
210
|
-
@data = data.last(columns)
|
|
211
|
-
|
|
212
|
-
if data_range.zero?
|
|
213
|
-
@display.reset.bottom.styled(:bold).
|
|
214
|
-
write_centered("#@title / #{sleep_duration}").
|
|
215
|
-
reset.centered.styled(:italic).write_centered("no data")
|
|
216
|
-
perform_display_diff
|
|
217
|
-
sleep_now
|
|
218
|
-
next
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
@display.reset
|
|
222
|
-
draw_graph
|
|
223
|
-
|
|
224
|
-
@display.reset.bottom.styled(:bold).
|
|
225
|
-
write_centered("#@title #{format_value(data.last)} / #{sleep_duration}")
|
|
226
|
-
@display.reset.styled(:bold).
|
|
227
|
-
left.top.write(format_value(data.max)).
|
|
228
|
-
left.bottom.write(format_value(data.min))
|
|
229
|
-
|
|
230
|
-
perform_display_diff
|
|
231
|
-
sleep_now
|
|
232
|
-
end
|
|
233
|
-
rescue Interrupt
|
|
234
|
-
ensure
|
|
235
|
-
stop
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def perform(*a)
|
|
239
|
-
print(*a)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# The columns method returns the number of columns in the display
|
|
243
|
-
#
|
|
244
|
-
# This method provides access to the horizontal dimension of the graphical
|
|
245
|
-
# display by returning the total number of columns available for rendering
|
|
246
|
-
# content
|
|
247
|
-
#
|
|
248
|
-
# @return [ Integer ] the number of columns (characters per line) in the display object
|
|
249
|
-
def columns
|
|
250
|
-
@display.columns
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# The lines method returns the number of lines in the display
|
|
254
|
-
#
|
|
255
|
-
# This method provides access to the vertical dimension of the graphical
|
|
256
|
-
# display by returning the total number of rows available for rendering
|
|
257
|
-
# content
|
|
258
|
-
#
|
|
259
|
-
# @return [ Integer ] the number of lines (rows) in the display object
|
|
260
|
-
def lines
|
|
261
|
-
@display.lines
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# The data reader method provides access to the data attribute that was set
|
|
265
|
-
# during object initialization.
|
|
266
|
-
#
|
|
267
|
-
# This method returns the value of the data instance variable, which
|
|
268
|
-
# typically contains structured information that has been processed or
|
|
269
|
-
# collected by the object.
|
|
270
|
-
#
|
|
271
|
-
# @return [ Array<Object>, Hash, nil ] the data value stored in the instance variable, or nil if not set
|
|
272
|
-
attr_reader :data
|
|
273
|
-
|
|
274
|
-
# The sleep_duration method returns a string representation of the configured
|
|
275
|
-
# sleep interval with the 's' suffix appended to indicate seconds.
|
|
276
|
-
#
|
|
277
|
-
# @return [ String ] a formatted string containing the sleep duration in seconds
|
|
278
|
-
def sleep_duration
|
|
279
|
-
"#{@sleep}s"
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# The format_value method processes a given value using the configured
|
|
283
|
-
# formatting strategy
|
|
284
|
-
#
|
|
285
|
-
# This method applies the appropriate formatting to a value based on the
|
|
286
|
-
# \@format_value instance variable configuration It supports different
|
|
287
|
-
# formatting approaches including custom Proc objects, Symbol-based method
|
|
288
|
-
# calls, and default formatting
|
|
289
|
-
#
|
|
290
|
-
# @param value [ Object ] the value to be formatted according to the configured strategy
|
|
291
|
-
#
|
|
292
|
-
# @return [ String ] the formatted string representation of the input value
|
|
293
|
-
def format_value(value)
|
|
294
|
-
case @format_value
|
|
295
|
-
when Proc
|
|
296
|
-
@format_value.(value)
|
|
297
|
-
when Symbol
|
|
298
|
-
send(@format_value, value)
|
|
299
|
-
else
|
|
300
|
-
send(:as_default, value)
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# The pick_color method determines and returns an ANSI color attribute based
|
|
305
|
-
# on the configured color setting
|
|
306
|
-
#
|
|
307
|
-
# This method evaluates the @color instance variable to decide how to select
|
|
308
|
-
# a color attribute. If @color is a Proc, it invokes the proc with the @title
|
|
309
|
-
# to determine the color. If @color is nil, it derives a color from the title
|
|
310
|
-
# string. Otherwise, it uses the @color value directly as an index into the
|
|
311
|
-
# ANSI color attributes.
|
|
312
|
-
#
|
|
313
|
-
# @return [ Term::ANSIColor::Attribute ] the selected color attribute object
|
|
314
|
-
def pick_color
|
|
315
|
-
Term::ANSIColor::Attribute[
|
|
316
|
-
case @color
|
|
317
|
-
when Proc
|
|
318
|
-
@color.(@title)
|
|
319
|
-
when nil
|
|
320
|
-
derive_color_from_string(@title)
|
|
321
|
-
else
|
|
322
|
-
@color
|
|
323
|
-
end
|
|
324
|
-
]
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# The pick_secondary_color method determines a secondary color based on a
|
|
328
|
-
# primary color and brightness adjustment parameters It returns the
|
|
329
|
-
# pre-configured secondary color if one exists, otherwise
|
|
330
|
-
# calculates a new color by adjusting the brightness of the primary color
|
|
331
|
-
#
|
|
332
|
-
# @param color [ Term::ANSIColor::Attribute ] the primary color attribute to
|
|
333
|
-
# be used as a base for calculation
|
|
334
|
-
# @param adjust_brightness [ Symbol ] the method to call on the color for
|
|
335
|
-
# brightness adjustment
|
|
336
|
-
# @param adjust_brightness_percentage [ Integer ] the percentage value to use
|
|
337
|
-
# for the brightness adjustment
|
|
338
|
-
# @return [ Term::ANSIColor::Attribute ] the secondary color attribute,
|
|
339
|
-
# either pre-configured or calculated from the primary color
|
|
340
|
-
def pick_secondary_color(color, adjust_brightness:, adjust_brightness_percentage:)
|
|
341
|
-
@color_secondary and return @color_secondary
|
|
342
|
-
color_primary = color.to_rgb_triple.to_hsl_triple
|
|
343
|
-
color_primary.send(adjust_brightness, adjust_brightness_percentage) rescue color
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# The sleep_now method calculates and executes a sleep duration based on the
|
|
347
|
-
# configured sleep time and elapsed time since start
|
|
348
|
-
#
|
|
349
|
-
# This method determines how long to sleep by calculating the difference
|
|
350
|
-
# between the configured sleep interval and the time elapsed since the last
|
|
351
|
-
# operation started. If no start time is recorded, it uses the full
|
|
352
|
-
# configured sleep duration. The method ensures that negative sleep durations
|
|
353
|
-
# are not used by taking the maximum of the calculated duration and zero.
|
|
354
|
-
def sleep_now
|
|
355
|
-
duration = if @start
|
|
356
|
-
[ @sleep - (Time.now - @start).to_f, 0 ].max
|
|
357
|
-
else
|
|
358
|
-
@sleep
|
|
359
|
-
end
|
|
360
|
-
sleep duration
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
# The perform_display_diff method calculates and displays the difference
|
|
364
|
-
# between the current and previous display states to update only the changed
|
|
365
|
-
# portions of the terminal output
|
|
366
|
-
#
|
|
367
|
-
# This method synchronizes access to shared display resources using a mutex,
|
|
368
|
-
# then compares the current display with the previous state to determine what
|
|
369
|
-
# needs updating. It handles dimension mismatches by resetting the old
|
|
370
|
-
# display, computes the visual difference, and outputs only the modified
|
|
371
|
-
# portions to reduce terminal update overhead
|
|
372
|
-
#
|
|
373
|
-
# When the DEBUG_BYTESIZE environment variable is set, it also outputs
|
|
374
|
-
# debugging information about the size of the diff and the time elapsed since
|
|
375
|
-
# the last debug output
|
|
376
|
-
def perform_display_diff
|
|
377
|
-
@mutex.synchronize do
|
|
378
|
-
unless @old_display && @old_display.dimensions == @display.dimensions
|
|
379
|
-
@old_display = @display.dup.clear
|
|
380
|
-
end
|
|
381
|
-
diff = @display - @old_display
|
|
382
|
-
if ENV['DEBUG_BYTESIZE']
|
|
383
|
-
unless @last
|
|
384
|
-
STDERR.puts %w[ size duration ] * ?\t
|
|
385
|
-
else
|
|
386
|
-
STDERR.puts [ diff.size, (Time.now - @last).to_f ] * ?\t
|
|
387
|
-
end
|
|
388
|
-
@last = Time.now
|
|
389
|
-
end
|
|
390
|
-
perform diff
|
|
391
|
-
@display, @old_display = @old_display.clear, @display
|
|
392
|
-
perform move_to(lines, columns)
|
|
393
|
-
end
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
# The normalize_value method converts a value to its appropriate numeric
|
|
398
|
-
# representation
|
|
399
|
-
#
|
|
400
|
-
# This method takes an input value and normalizes it to either a Float or
|
|
401
|
-
# Integer type depending on its original form. If the value is already a
|
|
402
|
-
# Float, it is returned as-is. For all other types, the method attempts to
|
|
403
|
-
# convert the value to an integer using to_i
|
|
404
|
-
#
|
|
405
|
-
# @param value [ Object ] the value to be normalized
|
|
406
|
-
#
|
|
407
|
-
# @return [ Float, Integer ] the normalized numeric value as either a Float
|
|
408
|
-
# or Integer
|
|
409
|
-
def normalize_value(value)
|
|
410
|
-
case value
|
|
411
|
-
when Float
|
|
412
|
-
value
|
|
413
|
-
else
|
|
414
|
-
value.to_i
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
# The full_reset method performs a complete reset of the display and terminal
|
|
419
|
-
# state
|
|
420
|
-
#
|
|
421
|
-
# This method synchronizes access to shared resources using a mutex, then
|
|
422
|
-
# executes a series of terminal control operations to reset the terminal
|
|
423
|
-
# state, clear the screen, move the cursor to the home position, and make the
|
|
424
|
-
# cursor visible. It also initializes new display objects with the current
|
|
425
|
-
# terminal dimensions and updates the internal
|
|
426
|
-
# display state.
|
|
427
|
-
def full_reset
|
|
428
|
-
@mutex.synchronize do
|
|
429
|
-
perform reset, clear_screen, move_home, show_cursor
|
|
430
|
-
winsize = Tins::Terminal.winsize
|
|
431
|
-
opts = {
|
|
432
|
-
color: @foreground_color,
|
|
433
|
-
on_color: @background_color,
|
|
434
|
-
}
|
|
435
|
-
@display = Hackmac::Graph::Display.new(*winsize, **opts)
|
|
436
|
-
@old_display = Hackmac::Graph::Display.new(*winsize, **opts)
|
|
437
|
-
perform @display
|
|
438
|
-
@full_reset = false
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# The install_handlers method sets up signal handlers for graceful shutdown
|
|
443
|
-
# and terminal resize handling
|
|
444
|
-
#
|
|
445
|
-
# This method configures two signal handlers: one for the exit hook that
|
|
446
|
-
# performs a full reset, and another for the SIGWINCH signal that handles
|
|
447
|
-
# terminal resize events by setting a flag and displaying a sleeping message
|
|
448
|
-
def install_handlers
|
|
449
|
-
at_exit { full_reset }
|
|
450
|
-
trap(:SIGWINCH) do
|
|
451
|
-
@full_reset = true
|
|
452
|
-
perform reset, clear_screen, move_home, 'Zzz…'
|
|
453
|
-
end
|
|
454
|
-
end
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
require 'hackmac/graph/display'
|