Class: Typr::Browser

Inherits:
Grid show all
Defined in:
lib/browser.rb

Overview

A file-system directory browser built on Grid. Renders entries as rows with columns for name, symlink target, permissions, owner/group, size, timestamps, and MIME type. Directories sort to the top and appear in yellow; files get per-MIME-type foreground colors.

Column Views

:simple   - name
:compact  - name, size, type
:stats    - name, size, permissions, owner, group, modified
:full     - all columns
[h]             | help
[r]             | reset view
[d]             | directory history
[m] / [M]       | mark / mark range
[a] / [i]       | mark all / invert mark
[`] / [~] / [..] | root / home / back
[v]             | switch view
[s] / [f]       | sort / filter
Return          | confirm (cd on directories)
Escape          | quit, returns nil

Constant Summary

Constants included from Typr

ALT_SCREEN_OFF, ALT_SCREEN_ON, COLORS, COLOR_MAP, DEFAULT_KEYS, ERASE_LINE, INPUT, KEY_ALT_BACKSPACE, KEY_ALT_LEFT, KEY_ALT_RIGHT, KEY_BACK_TAB, KEY_ESCAPE, KEY_PAGEDOWN, KEY_PAGEUP, KEY_RETURN, KEY_TAB, MIMETYPES, MODES, MOUSE_OFF, MOUSE_ON, ORIG_COLORS, TERMINFO

Instance Attribute Summary collapse

Attributes inherited from Grid

#align, #data, #filters, #format, #map, #procs, #rawfilter, #rawsort, #reverse, #sequence

Attributes inherited from Stack

#header, #hints, #hints_start, #keymap, #modifier, #selected, #separator, #start

Attributes inherited from Space

#borders, #colors, #interval, #left, #margin, #right

Instance Method Summary collapse

Methods inherited from Grid

#<<, #[], #[]=, #add_filter, #clear, #columns, #filter, #header=, #ids, #is_hash?, #page, #positions, #print, #reset, #rows, #sort_by, #sorted_by, #width_for

Methods inherited from Stack

#cycle, #cycle_back, #data_at, #down, #draw_hints, #headspace, #height, #hit, #page, #page_down, #page_up, #print, #reset, #to_bottom, #to_top, #up

Methods inherited from Space

#border=, #data_at, #draw_border, #extract_key, #height, #start, #stop, #symbolize, #width

Methods included from Typr

#background, clear, #clip, #coerce_type, #color, #color_code, column, decode_mouse, #draw, exit, #fade, #foreground, #get_background, #get_foreground, height, init, #mode, #mode_code, #move, #move_code, on_resize, position, #prepare, read_key, read_line, #real_size, row, #sanitize, size, slice_width, text_width, width, word_next, word_prev

Constructor Details

#initialize(args = {}) ⇒ Browser

Construct a Browser widget. Keyword arguments:

Option               | Default                         | Description
--------------------|---------------------------------|-----------------------------------
+directory+          | +"~"+                           | Starting directory to browse
+mimetype_db+       | true                            | Load MIME from file extensions
+mimetype_magic+    | true                            | Use +file --mime-type+ for unknowns
+colors[:actions]+  | { "[f]ilter": :red, ... }       | Status bar item colors
+colors[:dir_history]+ | :yellow                      | History picker column color
+colors[:types]+     | (see below)                    | Per MIME-type foreground color

Type Color Defaults

image: :blue          audio: :green         video: :cyan
text: 146             pdf: 136              inode: :magenta
directory: :yellow    symlink: 123          'x-empty': :grey70
'x-msdownload': :red  'x-pie-executable': :red


440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'lib/browser.rb', line 440

def initialize args={}
  @mimetype_db, @mimetype_magic = true, true
  @view, @sort, @views, @positions, @show_hidden, @show_path = :compact, 0, {}, {}, false, false
  @filter_dirs = args[:filter_dirs]
  super
  @left ||= 0
  @top ||= 0
  @directory ||= ?~
  @colors[:actions] = { "[f]ilter": :red, "[s]ort": :blue, "[v]iews": :green
    }.merge @colors[:actions] || {}
  @colors[ :types ] = {
        image: :blue,
        audio: :green,
        video: :cyan,
         text: 146, pdf: 136, 'epub+zip': 136,
         html: 150, xhtml: 150, javascript: 150,
    'x-bzip2': 190, 'x-xz': 190, 'x-compressed': 190,
        inode: :magenta, blockdevice:148, chardevice:144, symlink:123,
    directory: :yellow,
    'x-empty': :grey70, '?': :grey70,
    'x-msdownload': :red, 'x-pie-executable': :red
  }.merge( @colors[ :types ] || {} )

  @keymap = { back: KEY_BACKSPACE, confirm: KEY_RETURN, help: ?h,
    home: ?~, views: ?v,         sort_view: ?s, search: ?/,
    mark: ?m, mark_range: ?M, mark_all: ?a, mark_invert: ?i,
    filter_view: ?f, reset_view: ?r,
    toggle_hidden: ?H}.merge @keymap

  self.header = %w[ name target permissions executable owner group size
    accessed modified created type subtype path ]

  @format[NAME] = :max
  @format[TYPE] = 5
  @ignore[NAME] = @ignore[TARGET] = true
  @hints = "1234567890"
  [NAME, SIZE, ACCESSED, MODIFIED, CREATED].each{|col| @rawsort[col] = true}

  @procs[ACCESSED] = @procs[MODIFIED] = @procs[CREATED] = :datetime
  @procs[SIZE] = :magnitudes
  @procs[OWNER] = Proc.new{|uid| Etc.getpwuid(uid).name rescue uid }
  @procs[GROUP] = Proc.new{|gid| Etc.getgrgid(gid).name rescue gid }

  @procs[NAME] = Proc.new{ |name, row| target = @data[row][TARGET]
    File.basename(name.to_s) + ( target ? " > " + color_code(File.exist?(target) ? :green : :red) +
      target : "" ) }

  @views = { simple: i[ name ],
    compact: i[ name size type],
    stats:   i[ name size permissions owner group modified ],
    full:    i[ name size permissions executable owner group
                 accessed modified created type subtype ] }.merge @views
  @parent = self
  @head ||= Grid.new( parent:self, left: ->{@parent.left}, margin: '',
    right: ->{@parent.width + @parent.left - 1}, top: ->{@parent.top-1},
    input: [ [->{Etc.getpwuid(Process.euid).name rescue ENV["USER"] || Etc.getlogin}, ->{@parent.directory},
      ->{@parent.filter_display},
      ->{ (s = @parent.sorted_by(true)) &&
        "#{s} #{@parent.reverse ? "\u2193" : "\u2191"}"},
      ->{@parent.view} ] ], format: [:min, :max, :min, :min, :min],
    colors: { columns: [@colors[:default], @colors[:default],
      @colors[:actions][:"[f]ilter"], @colors[:actions][:"[s]ort"],
      @colors[:actions][:"[v]iews"]] } )
  @head.define_singleton_method( :data_at ) do |x, y|
    return unless x and y and y.between?( top + 1, bottom + 1 ) and
      x.between?( left, right )
    offs = positions( 0 )
    col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
    @parent.instance_variable_get( :@positions ).keys[ col - 2 ] if col and col >= 2
  end
  @head.define_singleton_method( :path_at ) do |x, y|
    return unless x and y and y.between?( top + 1, bottom + 1 )
    x -= 1
    return unless x.between?( left, right )
    offs = positions( 0 )
    col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
    return (Etc.getpwuid(Process.euid).dir rescue (Dir.home rescue "~")) if col == 0
    return unless col == 1 and @parent.directory.is_a?( String )
    text = ((@layer and @layer[0] and @layer[0][1]) || @data[0][1].call).to_s
    rel = x - ( left + offs[1] )
    return if rel < 0 or rel >= real_size( text )
    pre = prepare( text, rel + 1 )
    ( pre + ( pre.end_with?( "/" ) ? "" : text[pre.length..-1][/\A[^\/]*/] ) ).sub( %r{(?<=.)/$}, "" )
  end

  @user ||= Line.new( parent:self, left:->{@parent.left},top:->{@parent.bottom+1},
    bindings: %w[ [h]elp [/]:search ] + @colors[:actions].map{|name,color|
      color_code( color ) + name.to_s + color_code(@colors[:default]) } +
        %w[ [r]eset [d]irectories [m]ark [M]ark_range mark_[a]ll
        [i]nvert_mark [~]:home [H]idden_files [back]:.. [up] [down] [pageup] [pagedown] [home] [end] [esc]:quit [return]:confirm\  ])
  cd @directory
end

Instance Attribute Details

#directoryObject

Returns the value of attribute directory.



36
37
38
# File 'lib/browser.rb', line 36

def directory
  @directory
end

#mimetype_dbObject

Returns the value of attribute mimetype_db.



37
38
39
# File 'lib/browser.rb', line 37

def mimetype_db
  @mimetype_db
end

#mimetype_magicObject

Returns the value of attribute mimetype_magic.



37
38
39
# File 'lib/browser.rb', line 37

def mimetype_magic
  @mimetype_magic
end

#pwdObject

Returns the value of attribute pwd.



36
37
38
# File 'lib/browser.rb', line 36

def pwd
  @pwd
end

#userObject

Returns the value of attribute user.



36
37
38
# File 'lib/browser.rb', line 36

def user
  @user
end

#viewObject

Returns the value of attribute view.



36
37
38
# File 'lib/browser.rb', line 36

def view
  @view
end

Instance Method Details

#backObject

Navigate up one directory.

browser.back   # equivalent to cd ".."


231
# File 'lib/browser.rb', line 231

def back; cd ".." end

#bottomObject

Height minus 2 rows reserved for path bar and command line.



41
# File 'lib/browser.rb', line 41

def bottom; super - 1 end

#cd(dir, append = false) ⇒ Object

Change to dir (absolute path, "..", "~", or integer index into dir_history). When append is false (default), clears data and colors before scanning. Pushes onto dir_history.

browser.cd "/tmp"        # => changes to /tmp
browser.cd "~"           # => changes to home
browser.cd ".."          # => parent directory
browser.cd 2             # => jump to history entry 2


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/browser.rb', line 57

def cd dir, append=false
  return unless dir
  data, missing = [],{}
  unless append
    begin
      path = File.expand_path( dir )
      Dir.chdir( path )
      entries = Dir.new( path ).children
    rescue SystemCallError
      @user.show "cannot open directory: #{dir}"
      Typr.read_key
      return false
    end
    @colors[:fields] = {}
    clear
    @directory = path
  else
    entries = Dir.new( @directory ).children
  end
  data = entries.reject{|f|
    !@show_hidden and f[0] == ?. }.map { |file| scan_file file }

  data.each_with_index{ |row, id|
    file = row[NAME]
    ftype = File.ftype(file) rescue next
    ftype = File.stat(file).ftype if ftype == 'link' and File.exist?(file)
    mime = { 'directory' => 'inode/directory', 'characterSpecial' => 'inode/chardevice',
      'blockSpecial' => 'inode/blockdevice', 'link' => 'inode/symlink',
      'fifo' => 'inode/fifo', 'socket' => 'inode/socket' }[ftype]
    unless mime
      if @mimetype_db and ext = file.match(/.+\.(\w+)$/)
        mime = MIMETYPES[ext[1].to_sym]
      end
      missing[file] = id unless mime
    end
    row[TYPE, 2] = mime.to_s.split( ?/ ) if mime
  }

  if @mimetype_magic and missing.any?
    begin
      out = IO.popen(['file', '--mime-type', *missing.keys]){ |io| io.read }
    rescue Errno::ENOENT
      out = ''
    end
    out.split($/).each{ |line|
      file, mime = line.match(/^(.*):\s+(\w+\/[\w\-\.]+)$/)&.captures
      next unless file
      data[ missing[file] ][TYPE, 2] = mime.split( ?/ ) if missing[file] }
  end
  data.each { |row| row[0] = File.join(@directory, row[0]) }
  self << data
  sort
  filter
  unless append
    @directory += ?/ unless @directory == ?/
    change_view @view
    reset :position
  end
  return
end

#change_view(view = nil) ⇒ Object

Switch to a named or indexed column view. Resets format widths.

browser.change_view :full      # all columns
browser.change_view 0          # first view key
browser.change_view :stats     # name, size, perms, owner, group, modified


158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/browser.rb', line 158

def change_view view=nil
  return unless view
  view = @views.keys[ view ] if view.is_a? Integer
  @sequence = @views[ view ].map{|title| @header.index title.to_s}
  if @show_path
    @data.each { |row| row[PATH] = File.dirname(row[NAME]) } if @data.any?
    @sequence.insert(@sequence.index(NAME) || 0, PATH) unless @sequence.include?(PATH)
  end
  reset :format
  @view = view
  return
end

#check(row) ⇒ Object

True when the row is a directory or passes base Grid filtering.

browser.check 0   # => true if navigable


298
# File 'lib/browser.rb', line 298

def check row; super or ( !@filter_dirs and @data[row][SUBTYPE] == "directory" ) end

#confirmObject

Returns the current directory path as the confirm result.



198
# File 'lib/browser.rb', line 198

def confirm; return @directory end

#directoriesObject

Returns row ids for all directories in the current listing.

browser.directories   # => [0, 1, 3]


390
# File 'lib/browser.rb', line 390

def directories; ids.select{ |id| @data[id][SUBTYPE] == 'directory' } end

#filter_displayString

Formats active filters as 'column: query', joined by ' / '.

browser.filter_display  # => "name: foo / size: big"

Returns:

  • (String)


378
379
380
381
382
383
384
# File 'lib/browser.rb', line 378

def filter_display
  return "no filter" if @filters.empty?
  @filters.map { |col, q|
    q = "/#{q.source}/" if q.is_a?(Regexp)
    "#{col.is_a?(Symbol) ? col : @header[col] || col}:#{q}"
  }.join(" / ")
end

#filter_viewObject

Open a filter popup on the chosen row/column and apply the input.

browser.filter_view   # prompts for filter text


269
270
# File 'lib/browser.rb', line 269

def filter_view; add_filter *@user.ask(
filter: [ popup( type: :"[f]ilter" ), :row, NAME, { column: 0 }], with: :line ) end

#helpObject

Display a help grid listing all keybindings.



256
# File 'lib/browser.rb', line 256

def help; @user.help end

#homeObject

Navigate to the home directory.

browser.home   # => cd "~"


243
# File 'lib/browser.rb', line 243

def home; cd ?~ end

#markObject

Prompts to select rows by index. Returns chosen ids or nil.

browser.mark   # => [0, 2, 5] or nil


204
# File 'lib/browser.rb', line 204

def mark; return @user.ask( 'mark files': [self, :rows] ) end

#mark_allObject

Selects all non-directory rows.

browser.mark_all   # => [0, 3, 7] (file ids only)


219
# File 'lib/browser.rb', line 219

def mark_all; return @selected[:rows] = ids - directories end

#mark_invertObject

Inverts selection across all non-directory rows.

browser.mark_invert   # => toggled ids


225
# File 'lib/browser.rb', line 225

def mark_invert; return @selected[:rows] = ids - @selected[:rows] end

#mark_rangeObject

Toggles selection for every row within a "from..to" range.

browser.mark_range   # prompts for from/to, toggles selection


210
211
212
213
# File 'lib/browser.rb', line 210

def mark_range; return ids[ ( eval @user.ask(
      'mark from': [self, :relative_row], 'to': [self, :relative_row]
      ).join '..' ) ].each{|id| @selected[:rows].include?(id) ?
@selected[:rows].delete(id) : @selected[:rows] << id } end

#pick(type = 'file', **kw) ⇒ Object

Interactive file/directory picker. Blocks until a selection or Escape. Return on a file yields its raw absolute path; multi-select joins the chosen paths double-quoted with spaces. Enter on a directory triggers cd. type optionally filters by MIME family (audio, video, image, text, application, inode). Mouse: left-click a row to pick it; wheel up/down scrolls the listing.

browser.pick              # => /home/user/file.txt
browser.pick 'image'      # => /path/to/photo.png (images only)
browser.pick nil          # => anything, same as 'file'


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/browser.rb', line 312

def pick type='file', **kw
  type ||= 'file'
  type = type.to_s
  return super(type, **kw) if type[/row|column|field|none/]
  types = %w[audio video image text application inode]
  add_filter( types.include?( type ) ? TYPE : SUBTYPE, type ) if type == 'directory' or types.include?( type )
  loop do
    show
    draw_hints :row
    key = Typr.read_key
    if key == @keymap[:exit]
      @filters.clear
      sort
      return
    end
    if key == @keymap[:confirm] and type == 'directory'
      @filters.clear
      sort
      return @directory
    end
    if key.is_a?(Typr::Mouse)
      choice = send key
      next if choice == :scroll or choice == :outside
    else
      next unless choice = send( key )
    end
    next unless choice
    case choice
      when Array; choice = choice.map{ |id| @data[id][NAME] }.join('" "')
      when Integer; file = @data[ choice ]
        if file[SUBTYPE] == 'directory'; cd file[TARGET] || file[NAME]; next
        else choice = file[NAME] end
    end
    @filters.clear
    sort
    return choice
  end
end

Create a small Grid popup under an info bar item for column selection. args must include :type matching an actions key for positioning.

browser.popup type: :"[s]ort"   # sort-picker popup


412
413
414
415
416
417
418
419
# File 'lib/browser.rb', line 412

def popup args={}
  Grid.new( { alternate:false, border: :light,
    borders:{ top:nil, top_left:nil, top_right:nil },
    top: top, align: [:right], input:@header,
    left: left + ( @positions[args[:type]] || 0 ) -
      ( args[:input] || @header ).map( &:size ).max - 1,
    colors:{ columns: [ @colors[:actions][args[:type]] ] } }.merge args )
end

#process(row) ⇒ Object

Colors symlink names green/red based on target existence and applies per-MIME-type foreground colors from @colors[:types].

browser.process 0   # => sets colors for row 0


145
146
147
148
149
# File 'lib/browser.rb', line 145

def process row; super
  color = @colors[:types][@data[row][SUBTYPE].to_sym] ||
    @colors[:types][@data[row][TYPE].to_sym]
  @colors[:fields][[row,NAME]] = color if color
end

#reset_viewObject

Reset display, selection, and position state then re-sort.

browser.reset_view


263
# File 'lib/browser.rb', line 263

def reset_view; cd @directory; reset [:display, :selection, :position]; sort end

#rootObject

Navigate to the root directory.

browser.root   # => cd "/"


237
# File 'lib/browser.rb', line 237

def root; cd ?/ end

#scan_file(file) ⇒ Object

Build a row for a single directory entry from filesystem metadata only. MIME type detection (extension database and file --mime-type) happens in a batch pass inside #cd; type/subtype are left as "?" here.

browser.scan_file "image.png"   # => 12-column row


125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/browser.rb', line 125

def scan_file file
  target = nil
  if File.symlink? file
    target = File.readlink( file ) rescue $!.to_s
    return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless File.exist?(file)
  end
  stat = File.stat(file) rescue nil
  return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless stat
  [  file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
    "rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
    ( File.executable?(file) and stat.file? ), stat.uid, stat.gid, stat.size,
    stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i, "?", "?", nil ]
end

#searchObject

Open live / search filtered on the name column.

browser.search   # type to narrow, Escape restores, Enter commits


276
277
278
279
280
# File 'lib/browser.rb', line 276

def search
  @suppress_user = true
  pick :none, column: NAME
  @suppress_user = false
end

#send(key) ⇒ Object

Resolves hint characters typed on a row. Returns the matching row id for fast navigation, otherwise falls through to Stack#send.

browser.send "1"   # => row id if hint "1" is visible


177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/browser.rb', line 177

def send key
  if key.is_a?(Typr::Mouse) and key.press? and key.left?
    [@head, @user].each do |w|
      next unless w.respond_to?(:data_at)
      value = w.data_at(key.x, key.y)
      next unless value
      key = extract_key(value.to_s)
      while key.is_a?(String)
        key = send(key)
      end
      return key
    end
  end
  if key.is_a?(String) and
    id = @hints[0..[height, rows-1+headspace].min-@hints_start-1].index(key)
    return page[id+@hints_start]
  else super end
end

#showObject

Renders the grid with an info bar above showing the current directory path and colored status items for filter, sort, and view.

browser.show   # shows browser with info bar


357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/browser.rb', line 357

def show; super
  user = Etc.getpwuid(Process.euid).name rescue ENV["USER"] || Etc.getlogin
  @head.colors[:columns][0] = user == "root" ? [:red, :black] : [:green, :black]
  @head.reset :format
  @head.show
  move @head.positions(0)[1] - @head.separator.size, @parent.top - 1
  color @colors[:default]
  draw '@'
  @positions = { :"[f]ilter" => 2, :"[s]ort" => 3, :"[v]iews" => 4
    }.transform_values do |col|
      @head.left + @head.positions(0)[col] + @head.width_for(col) - 1
    end
  @user.show unless @suppress_user
end

#sortObject

Sort by NAME by default; directories always appear before files within each prefix grouping.

browser.sort   # re-sorts with dirs first


398
399
400
401
402
403
404
# File 'lib/browser.rb', line 398

def sort
  @sort ||= NAME
  super
  dirs = ids.select{ |id| @data[id][SUBTYPE] == 'directory' }
  @map = (directories + ids).uniq
  nil
end

#sort_viewObject

Open a sort-picker popup to choose a column to sort by.

browser.sort_view   # pick a column header to sort


286
# File 'lib/browser.rb', line 286

def sort_view; sort_by @user.ask( "sort by": [ popup( type: :"[s]ort" ), :row, nil, { column: 0 }] ) end

#toggle_hiddenObject

Toggle visibility of hidden files (those starting with a dot).

browser.toggle_hidden


249
250
251
252
# File 'lib/browser.rb', line 249

def toggle_hidden
  @show_hidden = !@show_hidden
  cd @directory
end

#topObject

Top offset plus one row for the path info bar.



45
# File 'lib/browser.rb', line 45

def top; super + 1 end

#viewsObject

Open a view-picker popup and switch to the selected view.

browser.views   # choose :simple, :compact, :stats, or :full


292
# File 'lib/browser.rb', line 292

def views; change_view( popup( type: :"[v]iews", input: @views.keys ).pick( :row, column: 0 ) )  end