typr 1.1.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.
data/lib/browser.rb ADDED
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env ruby
2
+ # coding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require 'etc'
6
+ require 'find'
7
+ require_relative 'grid.rb'
8
+ require_relative 'line.rb'
9
+
10
+ module Typr
11
+
12
+ MIMETYPES = eval File.read( __dir__ + "/../share/mimetypes" )
13
+
14
+ # A file-system directory browser built on Grid. Renders entries as rows
15
+ # with columns for name, symlink target, permissions, owner/group, size,
16
+ # timestamps, and MIME type. Directories sort to the top and appear in
17
+ # yellow; files get per-MIME-type foreground colors.
18
+ #
19
+ # == Column Views
20
+ #
21
+ # :simple - name
22
+ # :compact - name, size, type
23
+ # :stats - name, size, permissions, owner, group, modified
24
+ # :full - all columns
25
+ #
26
+ # == Navigation Keys (interactive picker)
27
+ #
28
+ # [h] | help
29
+ # [r] | reset view
30
+ # [d] | directory history
31
+ # [m] / [M] | mark / mark range
32
+ # [a] / [i] | mark all / invert mark
33
+ # [`] / [~] / [..] | root / home / back
34
+ # [v] | switch view
35
+ # [s] / [f] | sort / filter
36
+ # Return | confirm (cd on directories)
37
+ # Escape | quit, returns nil
38
+
39
+ class Browser < Grid
40
+ attr_accessor :directory, :user, :pwd, :view, :dir_history
41
+ attr_accessor :mimetype_db, :mimetype_magic
42
+
43
+ ## Height minus 2 rows reserved for path bar and command line.
44
+
45
+ def bottom; super - 2 end
46
+
47
+ ## Top offset plus one row for the path info bar.
48
+
49
+ def top; super + 1 end
50
+
51
+ ##
52
+ # Change to +dir+ (absolute path, +".."+, +"~"+, or integer index into
53
+ # dir_history). When +append+ is false (default), clears data and colors
54
+ # before scanning. Pushes onto dir_history.
55
+ #
56
+ # browser.cd "/tmp" # => changes to /tmp
57
+ # browser.cd "~" # => changes to home
58
+ # browser.cd ".." # => parent directory
59
+ # browser.cd 2 # => jump to history entry 2
60
+
61
+ def cd dir, append=false
62
+ return unless dir
63
+ data, missing = [],{}
64
+ unless append
65
+ @colors[:fields] = {}
66
+ dir = @dir_history[dir+1] if dir.is_a? Integer
67
+ clear
68
+ Dir.chdir( @directory = File.expand_path( dir ) )
69
+ unless [?/, ENV["HOME"]].include? dir
70
+ @dir_history.unshift @directory
71
+ @dir_history.uniq!
72
+ end
73
+ end
74
+ data = Dir.new(@directory).children.map.with_index do |file,id|
75
+ if File.symlink? file
76
+ target = File.readlink( file ) rescue $!.to_s
77
+ next [file, target] + [??]*7 + ["inode","symlink"] unless File.exist?(file)
78
+ end
79
+ stat = File.stat(file)
80
+ type = stat.ftype.to_sym
81
+
82
+ mime = { "directory": "inode/directory",
83
+ "characterSpecial": "inode/chardevice",
84
+ "blockSpecial": "inode/blockdevice", "link": "inode/symlink",
85
+ "fifo": "inode/fifo", "socket": "inode/socket"}[type]
86
+ if type == :file and @mimetype_db
87
+ ext = file.match(/.+\.(\w+)$/)
88
+ mime ||= MIMETYPES[ext[1].to_sym] if ext
89
+ end
90
+
91
+ ( missing[file] = id ; mime = "?/?" ) unless mime
92
+ [ file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
93
+ "rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
94
+ stat.uid, stat.gid, stat.size,
95
+ stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i,
96
+ *mime.to_s.split( ?/ ) ]
97
+ end
98
+
99
+ `file --mime-type '#{missing.keys.join "' '"}'
100
+ `.split($/).each.with_index{ |line,id|
101
+ next unless line[/^.+:\s+\w+\/[\w\-\.]+$/]
102
+ file, mime = line.split(/:\s*/)
103
+ data[ missing[file] ][-2,2] = mime.rstrip.split( ?/ ) } if
104
+ @mimetype_magic and not missing.empty?
105
+ self << data
106
+ sort
107
+ filter
108
+ unless append
109
+ @directory += ?/ unless @directory == ?/
110
+ switch_to @view
111
+ reset :position
112
+ end
113
+ return
114
+ end
115
+
116
+ ##
117
+ # Colors symlink names green/red based on target existence and applies
118
+ # per-MIME-type foreground colors from +@colors[:types]+.
119
+ #
120
+ # browser.process 0 # => sets colors for row 0
121
+
122
+ def process row; super
123
+ color = @colors[:types][@data[row][SUBTYPE].to_sym] ||
124
+ @colors[:types][@data[row][TYPE].to_sym]
125
+ @colors[:fields][[row,NAME]] = color if color
126
+ end
127
+
128
+ ##
129
+ # Switch to a named or indexed column view. Resets format widths.
130
+ #
131
+ # browser.switch_to :full # all columns
132
+ # browser.switch_to 0 # first view key
133
+ # browser.switch_to :stats # name, size, perms, owner, group, modified
134
+
135
+ def switch_to view=nil
136
+ return unless view
137
+ view = @views.keys[ view ] if view.is_a? Integer
138
+ @sequence = @views[ view ].map{|title| @header.index title.to_s}
139
+ reset :format
140
+ @view = view
141
+ return
142
+ end
143
+
144
+ ##
145
+ # Resolves hint characters typed on a row. Returns the matching row id
146
+ # for fast navigation, otherwise falls through to Stack#send.
147
+ #
148
+ # browser.send "1" # => row id if hint "1" is visible
149
+
150
+ def send key
151
+ if key.is_a?(String) and
152
+ id = @hints[0..[height, rows-1+headspace].min-@hints_start-1].index(key)
153
+ return page[id+@hints_start]
154
+ else super end
155
+ end
156
+
157
+ ## Returns the current directory path as the confirm result.
158
+
159
+ def confirm; return @directory end
160
+
161
+ ## Prompts to select rows by index. Returns chosen ids or nil.
162
+ #
163
+ # browser.mark # => [0, 2, 5] or nil
164
+
165
+ def mark; return @user.ask( 'mark files': [self, :rows] ) end
166
+
167
+ ## Toggles selection for every row within a "from..to" range.
168
+ #
169
+ # browser.mark_range # prompts for from/to, toggles selection
170
+
171
+ def mark_range; return ids[ ( eval @user.ask(
172
+ 'mark from': [self, :relative_row], 'to': [self, :relative_row]
173
+ ).join '..' ) ].each{|id| @selected[:rows].include?(id) ?
174
+ @selected[:rows].delete(id) : @selected[:rows] << id } end
175
+
176
+ ## Selects all non-directory rows.
177
+ #
178
+ # browser.mark_all # => [0, 3, 7] (file ids only)
179
+
180
+ def mark_all; return @selected[:rows] = ids - directories end
181
+
182
+ ## Inverts selection across all non-directory rows.
183
+ #
184
+ # browser.mark_invert # => toggled ids
185
+
186
+ def mark_invert; return @selected[:rows] = ids - @selected[:rows] end
187
+
188
+ ## Navigate up one directory.
189
+ #
190
+ # browser.back # equivalent to cd ".."
191
+
192
+ def back; cd ".." end
193
+
194
+ ## Navigate to the root directory.
195
+ #
196
+ # browser.root # => cd "/"
197
+
198
+ def root; cd ?/ end
199
+
200
+ ## Navigate to the home directory.
201
+ #
202
+ # browser.home # => cd "~"
203
+
204
+ def home; cd ?~ end
205
+
206
+ ## Display a help grid listing all keybindings.
207
+
208
+ def help; @user.help end
209
+
210
+ ##
211
+ # Recurse through the directory tree, adding subdirectory entries inline
212
+ # via +cd(path, true)+. Sorts by name with the filetree processor.
213
+ #
214
+ # browser.recurse # flattens tree into leaf names
215
+
216
+ def recurse
217
+ sort_by NAME
218
+ @procs[NAME] = :filetree
219
+ end
220
+
221
+ ## Reset display, selection, and position state then re-sort.
222
+ #
223
+ # browser.reset_view
224
+
225
+ def reset_view; reset [:display, :selection, :position]; sort end
226
+
227
+ ## Open a filter popup on the chosen row/column and apply the input.
228
+ #
229
+ # browser.filter_view # prompts for filter text
230
+
231
+ def filter_view; add_filter *@user.ask(
232
+ filter: [ popup( type: :"[f]ilter" ), :row], with: :line ) end
233
+
234
+ ## Open a sort-picker popup to choose a column to sort by.
235
+ #
236
+ # browser.sort_view # pick a column header to sort
237
+
238
+ def sort_view; sort_by @user.ask( "sort by": [ popup( type: :"[s]ort" ), :row] ) end
239
+
240
+ ## Open a view-picker popup and switch to the selected view.
241
+ #
242
+ # browser.views # choose :simple, :compact, :stats, or :full
243
+
244
+ def views; switch_to( popup( type: :"[v]iews", input: @views.keys ).pick :row ) end
245
+
246
+ ## Open a directory history picker and cd to the chosen entry.
247
+ #
248
+ # browser.dir_history # pick from previously visited dirs
249
+
250
+ def dir_history; cd( popup( { input: @dir_history[1..-1],
251
+ colors: { columns: [ colors[:dir_history] ] },
252
+ align: [:left], left: left } ).pick(:row) ) end
253
+
254
+ ## True when the row is a directory or passes base Grid filtering.
255
+ #
256
+ # browser.check 0 # => true if navigable
257
+
258
+ def check row; super or @data[row][SUBTYPE] == "directory" end
259
+
260
+ ##
261
+ # Interactive file/directory picker. Blocks until a selection or Escape.
262
+ # Return on a file wraps the path in single quotes. Enter on a directory
263
+ # triggers cd. +type+ optionally filters by MIME family (audio, video,
264
+ # image, text, application, inode).
265
+ #
266
+ # browser.pick # => '/home/user/file.txt'
267
+ # browser.pick 'image' # => '/path/to/photo.png' (images only)
268
+ # browser.pick nil # => anything, same as 'file'
269
+
270
+ def pick type='file'
271
+ type ||= 'file'
272
+ type = type.to_s
273
+ return super if type[/row|column|field|none/]
274
+ add_filter ( %w[audio video image text application inode].include?(
275
+ type) ? TYPE : SUBTYPE ), type if type != 'file'
276
+ loop do
277
+ draw
278
+ draw_hints :row
279
+ key = Typr.read( :key )
280
+ next unless choice = send( key )
281
+ case choice
282
+ when Array; choice = ?' + choice.map{ |id|
283
+ @directory+?/+@data[id][NAME] }.join("' '") + ?'
284
+ when Integer; file = @data[ choice ]
285
+ if file[SUBTYPE] == 'directory'; cd file[TARGET] || file[NAME]; next
286
+ else choice = ?'+@directory+?/+file[NAME]+?' end
287
+ end
288
+ reset :display
289
+ return choice
290
+ end
291
+ end
292
+
293
+ ##
294
+ # Renders the grid with an info bar above showing the current directory
295
+ # path and colored status items for filter, sort, and view.
296
+ #
297
+ # browser.draw # draws browser with info bar
298
+
299
+ def draw; super
300
+ info = { "[f]ilter": @filters.empty? ? "no filter" : @filters.to_s[1..-2],
301
+ "[s]ort": sorted_by(true), "[v]iews": @view }
302
+ move left, top - 1
303
+ show prepare( @directory, width - info.values.join.size - 3, :left, :left, 5 )
304
+ @colors[:actions].each do |type,col|
305
+ color col
306
+ show ' ' + info[type].to_s
307
+ @positions[type] = Typr.column - (type==@positions.keys.last ? 0:1 )
308
+ end
309
+ @user.draw
310
+ end
311
+
312
+ ## Returns row ids for all directories in the current listing.
313
+ #
314
+ # browser.directories # => [0, 1, 3]
315
+
316
+ def directories; ids.select{ |id| @data[id][SUBTYPE] == 'directory' } end
317
+
318
+ ##
319
+ # Sort by NAME by default; directories always appear before files
320
+ # within each prefix grouping.
321
+ #
322
+ # browser.sort # re-sorts with dirs first
323
+
324
+ def sort
325
+ @sort ||= NAME
326
+ super
327
+ dirs = ids.select{ |id| @data[id][SUBTYPE] == 'directory' }
328
+ @map = (directories + ids).uniq
329
+ nil
330
+ end
331
+
332
+ ##
333
+ # Create a small Grid popup under an info bar item for column selection.
334
+ # +args+ must include +:type+ matching an actions key for positioning.
335
+ #
336
+ # browser.popup type: :"[s]ort" # sort-picker popup
337
+
338
+ def popup args={}
339
+ Grid.new( { alternate:false, border: :light,
340
+ borders:{ top:nil, top_left:nil, top_right:nil },
341
+ top: top, align: [:right], input:@header,
342
+ left: left + ( @positions[args[:type]] || 0 ) -
343
+ ( args[:input] || @header ).map( &:size ).max - 1,
344
+ colors:{ columns: [ @colors[:actions][args[:type]] ] } }.merge args )
345
+ end
346
+
347
+ ##
348
+ # Construct a Browser widget. Keyword arguments:
349
+ #
350
+ # Option | Default | Description
351
+ # --------------------|---------------------------------|-----------------------------------
352
+ # +directory+ | +"~"+ | Starting directory to browse
353
+ # +mimetype_db+ | true | Load MIME from file extensions
354
+ # +mimetype_magic+ | true | Use +file --mime-type+ for unknowns
355
+ # +colors[:actions]+ | { "[f]ilter": :red, ... } | Status bar item colors
356
+ # +colors[:dir_history]+ | :yellow | History picker column color
357
+ # +colors[:types]+ | (see below) | Per MIME-type foreground color
358
+ #
359
+ # == Type Color Defaults
360
+ #
361
+ # image: :blue audio: :green video: :cyan
362
+ # text: 146 pdf: 136 inode: :magenta
363
+ # directory: :yellow symlink: 123 'x-empty': :grey70
364
+ # 'x-msdownload': :red 'x-pie-executable': :red
365
+
366
+ def initialize args={}
367
+ @dir_history, @mimetype_db, @mimetype_magic = [], true, true
368
+ @view, @sort, @views, @positions = :compact, 0, {}, {}
369
+ super
370
+ @directory ||= ?~
371
+ @colors[:actions] = { "[f]ilter": :red, "[s]ort": :blue, "[v]iews": :green
372
+ }.merge @colors[:actions] || {}
373
+ @colors[:dir_history] ||= :yellow
374
+ @colors[ :types ] = {
375
+ image: :blue,
376
+ audio: :green,
377
+ video: :cyan,
378
+ text: 146, pdf: 136, 'epub+zip': 136,
379
+ html: 150, xhtml: 150, javascript: 150,
380
+ 'x-bzip2': 190, 'x-xz': 190, 'x-compressed': 190,
381
+ inode: :magenta, blockdevice:148, chardevice:144, symlink:123,
382
+ directory: :yellow,
383
+ 'x-empty': :grey70, '?': :grey70,
384
+ 'x-msdownload': :red, 'x-pie-executable': :red
385
+ }.merge( @colors[ :types ] || {} )
386
+
387
+ @keymap = { back: KEY_BACKSPACE, confirm: KEY_RETURN, help: ?h,
388
+ home: ?~, root: ?`, views: ?v, sort_view: ?s, dir_history: ?d,
389
+ mark: ?m, mark_range: ?M, mark_all: ?a, mark_invert: ?i,
390
+ filter_view: ?f, reset_view: ?r, recurse: ?R}.merge @keymap
391
+
392
+ self.header = %w[ name target permissions owner group size accessed
393
+ modified created type subtype ]
394
+
395
+ @format[NAME] = :max
396
+ @format[TYPE] = 5
397
+ @ignore[NAME] = @ignore[TARGET] = true
398
+ @hints = "1234567890"
399
+ [NAME, SIZE, ACCESSED, MODIFIED, CREATED].each{|col| @rawsort[col] = true}
400
+
401
+ @procs[ACCESSED] = @procs[MODIFIED] = @procs[CREATED] = :datetime
402
+ @procs[SIZE] = :magnitudes
403
+ @procs[OWNER] = Proc.new{|uid| Etc.getpwuid(uid).name rescue uid }
404
+ @procs[GROUP] = Proc.new{|gid| Etc.getgrgid(gid).name rescue gid }
405
+
406
+ @procs[NAME] = Proc.new{ |name, row| target = @data[row][TARGET]
407
+ name + ( target ? " > " + color_code(File.exist?(target) ? :green : :red) +
408
+ target : "" ) }
409
+
410
+ @views = { simple: %i[ name ],
411
+ compact: %i[ name size type],
412
+ stats: %i[ name size permissions owner group modified ],
413
+ full: %i[ name size permissions owner group
414
+ accessed modified created type subtype ] }.merge @views
415
+ @parent = self
416
+ @head ||= Grid.new( parent:self, left: ->{@parent.left}, top: ->{@parent.top-1},
417
+ input: [ [->{@parent.directory}, ->{@parent.filters.empty? ? "no filter" :
418
+ @parent.filters.to_s[1..-2] }, ->{@parent.sorted_by(true)},
419
+ ->{@parent.view} ] ], format: [:max, :min, :min, :min],
420
+ colors: { columns: [@colors[:default]] + @colors[:actions].values } )
421
+
422
+ @user ||= Line.new( parent:self, left:->{@parent.left},top:->{@parent.bottom+1},
423
+ bindings: %w[ [h]elp ] + @colors[:actions].map{|name,color|
424
+ color_code( color ) + name.to_s + color_code(@colors[:default]) } +
425
+ %w[ [r]eset [d]irectories [m]ark [M]ark\ range mark\ [a]ll
426
+ [i]nvert\ mark [`]:root [~]:home [back]:.. [up] [down] [pageup] [pagedown] [home] [end] [esc]:quit [return]:confirm\ ])
427
+ cd @directory
428
+ end
429
+ end
430
+
431
+ end
data/lib/curses.rb ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'ncursesw'
4
+ require_relative 'frontend'
5
+
6
+ module Visuals
7
+
8
+ include Ncurses
9
+ KEY_ESCAPE = 27
10
+ KEY_RETURN = 10
11
+ KEY_TAB = 9
12
+ KEY_PAGEDOWN = KEY_NPAGE
13
+ KEY_PAGEUP = KEY_PPAGE
14
+ KEY_INSERT = KEY_IC
15
+ KEY_DELETE = KEY_DC
16
+
17
+ $colors = COLORS.keys
18
+ $pairs = []
19
+ # COLORS = %w[ black red green yellow blue magenta cyan white grey]
20
+ # KEY_TAB = 9
21
+ # HEIGHT,WIDTH = IO.console.winsize
22
+
23
+ %w[color pair].each{ |name| method = <<STR
24
+ def %s_for obj; #return unless obj
25
+ if id = $%ss.index( obj ) then return id
26
+ else last = ( $%ss << obj.dup ).count - 1
27
+ Ncurses.init_%s( last, *obj )
28
+ return last
29
+ end
30
+ end
31
+ STR
32
+ eval( method % ([name]*4) )
33
+ }
34
+
35
+ def self.width; Ncurses.COLS end
36
+ def self.height; Ncurses.LINES end
37
+ def self.row; Ncurses.getcury $creen end
38
+ def self.column; Ncurses.getcurx $creen end
39
+
40
+ def self.exit
41
+ Ncurses.echo
42
+ Ncurses.nocbreak
43
+ Ncurses.nl
44
+ Ncurses.endwin
45
+ # system "reset"
46
+ end
47
+
48
+ def read input
49
+ case input
50
+ when :key
51
+ key = Ncurses.getch
52
+ key = key.chr if key.between? 32, 126
53
+ return key
54
+ when :line #string
55
+ Ncurses.echo
56
+ Ncurses.curs_set 1
57
+ # Ncurses.getstr str=""
58
+ str = Readline.readline
59
+ Ncurses.curs_set 0
60
+ Ncurses.noecho
61
+ return str
62
+ end
63
+ end
64
+
65
+ def move x=0,y=0; Ncurses.move y, x end
66
+ def refresh; Ncurses.refresh end
67
+ def enable attr; Ncurses.attron attr end
68
+ def disable attr; Ncurses.attroff attr end
69
+ def clear type=:screen;
70
+ case type
71
+ when :screen; Ncurses.erase
72
+ when :line; Ncurses.clrtoeol
73
+ when :down; Ncurses.clrtobot
74
+ end
75
+ end
76
+ def change; Ncurses.color_set pair_for( $color ), 0 end
77
+ def show string; Ncurses.addstr string.to_s end
78
+
79
+ def get_background; $color[1] end
80
+ def get_foreground; $color[0] end
81
+ def background color;
82
+ $color[1] = (color ? color_for( color ) : $default[1]); change
83
+ end
84
+ def foreground color;
85
+ $color[0] = (color ? color_for( color ) : $default[0]); change
86
+ end
87
+
88
+ def color c
89
+ c = [c] unless c.is_a? Array and c.count == 2
90
+ foreground c[0] if c[0]
91
+ background c[1] if c[1]
92
+ end
93
+
94
+ # def color_pair pair
95
+ # if id = $color_pairs.index( pair ) return id
96
+ # else last = ( $color_pairs << c ).count - 1
97
+ # Ncurses.init_pair( last, *pair )
98
+ # return last
99
+ # end
100
+ # pair = p.map{ |col|
101
+ # unless id = $colors.index( col )
102
+ # id = ($colors << col).count - 1
103
+ # Ncurses.init_color id, *col
104
+ # end
105
+ # id
106
+ # }
107
+ # unless ( id = $color_pairs.index( pair ) )
108
+ # id = ( $color_pairs << pair ).count - 1
109
+ # Ncurses.init_pair id, *pair
110
+ # end
111
+ # id
112
+ # end
113
+
114
+ def self.init args={}
115
+ # SCREEN = Ncurses.initscr
116
+ $creen = Ncurses.initscr
117
+ Ncurses.cbreak # provide unbuffered input
118
+ Ncurses.noecho # turn on input echoing
119
+ Ncurses.nl # turn on newline translation
120
+ # Ncurses.nonl # turn off newline translation
121
+ Ncurses.curs_set 0
122
+ Ncurses.start_color
123
+ Ncurses.stdscr.intrflush(false) # turn off flush-on-interrupt
124
+ Ncurses.stdscr.keypad(true) # turn on keypad mode
125
+
126
+ $default = [
127
+ color_for( args[:foreground] || :grey80 ),
128
+ color_for( args[:background] || :grey10 ) ]
129
+ $color = $default.dup
130
+
131
+ COLORS.each_with_index{ |c,i|
132
+ $pairs << [ i, $default[1] ]
133
+ Ncurses.init_color i, *c[1]
134
+ Ncurses.init_pair i, i, $default[1] }
135
+
136
+ Ncurses.bkgd Ncurses::COLOR_PAIR( pair_for $default )
137
+
138
+ end
139
+
140
+ end
141
+
142
+ if __FILE__ == $0
143
+ include Visuals
144
+ Visuals.init #background: :blue
145
+ characters = (32..126).map(&:chr).join
146
+ loop do
147
+ clear
148
+ $colors.each_with_index{ |c,i|
149
+ foreground c; move 0,i; show characters }
150
+ refresh
151
+ exit if read(:key) == KEY_ESCAPE
152
+ end
153
+ end
data/lib/frontend.rb ADDED
@@ -0,0 +1,33 @@
1
+ require 'readline'
2
+
3
+ module Visuals
4
+
5
+ # COLORS = {
6
+ # black: [ 0, 0, 0],
7
+ # red: [ 700, 400, 400],
8
+ # green: [ 400, 700, 400],
9
+ # yellow: [ 700, 700, 400],
10
+ # blue: [ 400, 400, 700],
11
+ # magenta: [ 700, 400, 700],
12
+ # cyan: [ 400, 700, 700],
13
+ # white: [ 999, 999, 999],
14
+ # grey: [ 618, 618, 618] }
15
+
16
+ COLORS = {
17
+ black: [ 0, 0, 0],
18
+ red: [ 180, 100, 100],
19
+ green: [ 100, 180, 100],
20
+ yellow: [ 180, 180, 100],
21
+ blue: [ 100, 100, 180],
22
+ magenta: [ 180, 100, 180],
23
+ cyan: [ 100, 180, 180],
24
+ white: [ 255, 255, 255],
25
+ grey: [ 158, 158, 158] }
26
+
27
+ COLORS.merge! 1.upto(9).map{ |i|
28
+ ["grey#{ i*10 }".to_sym, [i*25]*3 ] }.to_h
29
+
30
+ # COLORS.merge! 1.upto(9).map{ |i|
31
+ # ["grey#{ i*10 }".to_sym, [i*100]*3 ] }.to_h
32
+
33
+ end