typr 1.3.0 → 1.5.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/CHANGELOG.md +42 -0
- data/README.md +1 -1
- data/example/grid +22 -27
- data/lib/browser.rb +100 -45
- data/lib/graphical.rb +1 -1
- data/lib/grid.rb +29 -4
- data/lib/line.rb +38 -10
- data/lib/space.rb +21 -0
- data/lib/stack.rb +49 -16
- data/lib/terminal.rb +115 -39
- data/lib/text.rb +31 -19
- data/typr.gemspec +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f464a51f6c8356a1571fcebe51bbe315428ec054d9b1b819753a688764ce347
|
|
4
|
+
data.tar.gz: '0367917e4ce7b2f67c0605bb321f358aa81e17f27ab555ab558a6655b646dc9a'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f3f59d78bfc8831542c843de4eb51aa1507774b95436f97d9cf906f12945808f25c1add663945ad3d94ba8f33a543bc8f2cb960eac2adf995e6c6ec62fb715e
|
|
7
|
+
data.tar.gz: bf0a175749a29714fa07e0e8124b72a031dd9606c7d077cdcf7f214dc81783d28a2cce6b0e265afbbee9155a82cef742916c0a4956522c3edf56b66b6c223bbd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v1.5.0] — 2026-09-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Shell TAB completion in `read_line` — when `completions: true`, each keypress queries the shell's completion system (`/usr/share/bash-completion/completions/[cmd]`) via `IO.popen` and shows a pickable `Grid` above the edit line; selecting an item inserts it at the cursor. Handles `+`, `|`, `=` command prefixes
|
|
7
|
+
- `Space#data_at` / `Space#extract_key` — the clickable-element lookup every widget overrides (`Stack` returns an Integer row id, `Line` returns a String binding, `Browser`'s head maps its filter/sort/views columns to actions) and the trigger-key extraction for labels like "[h]elp"; `Stack#hit` now delegates to `data_at`
|
|
8
|
+
- Mouse dispatch through `Stack#send` — the mouse is handled in `send` itself (wheel scrolls and returns `:scroll`, a left or right press returns `data_at`), so pickers and `Browser#pick` route clicks through `send`; `Browser#send` intercepts clicks on `@head` and `@user` to dispatch their bindings, and `Grid#send` sorts by column header on click
|
|
9
|
+
- `Line#list` — the line's keybindings as a clickable auto-width KEYBINDINGS grid (starting at column 1, floored to fit its header) and `Line#help` as `list.pick :row`: click a row or press its hint digit to return its row id, Escape or Return to return nil. Apps compose their own help around the grid — Filecon aligns a HELP text panel at `list.right + 2` and mirrors navigation by sending every event to both panels
|
|
10
|
+
- Clicking outside a popup cancels it — `Stack#send` returns `:outside` for a press outside a widget's bounds, and pickers (`Stack#pick`, `Grid#pick`'s column filter, `Line#help`) treat it like Escape: state is restored and nil is returned, while the main browser listing ignores outside clicks
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `Browser#send` chains string dispatches from `@head`/`@user` binding clicks — the modifier-while returned immediately after the first `send`, so clicking a bottom-bar binding that opened a sub-screen (e.g. help) and returned a trigger key would drop it; the loop now re-dispatches until the result is no longer a String
|
|
14
|
+
- Mousewheel scrolling works in live-filter popups (`Grid#pick` with `column:`) — the wheel scrolled `@start` but the trailing filter reapply then reset it to the top, so the popup never visibly moved; mouse events now skip that reset and just redraw
|
|
15
|
+
- `extract_key` strips ANSI color codes before matching — colored binding labels like "\e[31m[f]ilter\e[0m" now resolve to their trigger key (previously the color code's own `[31m` matched and returned nil, so colored bottom-bar bindings and help-grid rows were not clickable)
|
|
16
|
+
- `Browser`'s head `data_at` matches the 1-based mouse row — the head action columns (filter/sort/views) are clickable in a real terminal again, not just at test coordinates
|
|
17
|
+
- `Line#data_at` matches the bottom-bar row — mouse coordinates are 1-based while widget rows are 0-based, so the clickable line sat one row above the rendered bindings; the row check now adds 1, matching `Stack#data_at`
|
|
18
|
+
- `Text` rebuilds on width change instead of blanking — `show` re-wraps the existing content whenever the width differs from the width it was built at (a `left` proc that resolves later, or a terminal resize), so a `Text` panel no longer renders an empty body after its first draw
|
|
19
|
+
- `Text` rows fit the panel interior — `print` draws `width - margin` cells so content rows stay inside the box instead of overflowing onto the next line (the overflow wrapped spaces with the alternating background into the neighboring widget's left-border column)
|
|
20
|
+
- `draw_border` allows the last column and row — the guard now only skips `x > width` / `y > height` (genuinely off-screen), so a panel flush with the terminal edge draws its right border and corners on the last column
|
|
21
|
+
- `Text#build` wraps ANSI-colored content correctly — the capacity calculation uses display width instead of byte length, so escape sequences don't inflate the line count
|
|
22
|
+
- `reset_view` reloads the directory again — it calls `cd @directory` before clearing display/selection/position and re-sorting, so reset picks up new and removed files instead of only reshuffling the stale listing
|
|
23
|
+
- Head path lookup — `@head.path_at(x, y)` maps a click to a target directory: the username column returns the resolved home directory, each `/`-separated segment of the directory cell returns that ancestor path (padding, action columns and array listings return nil); mouse `x` is adjusted from 1-based terminal columns so boundary clicks resolve to the cell under the cursor
|
|
24
|
+
|
|
25
|
+
## [v1.4.0] — 2026-08-15
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `Browser#pick :directory` confirms the current directory with Return (clearing filters and sorting first), so callers can accept the directory itself; the `pick` type filter only applies to directory and MIME-type picks — `:file` and custom types like `:cmd` show the full listing
|
|
29
|
+
- `sanitize` terminal helper — strips ANSI escape sequences and control characters (except `\n`, `\t`) from shell-generated text
|
|
30
|
+
- Alt+digit hint selection — pressing Alt + a hint digit (sent as `ESC` + digit) selects that hint row and sets a `modifier` accessor on the picker (`:alt`), so callers can detect the modified selection; `Stack#pick` and `Grid#pick` both reset `modifier` to `nil` at the start of each pick
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- `Browser#switch_to` renamed to `change_view` — clearer name for switching column views (callers in `cd` and the `views` popup updated)
|
|
34
|
+
- `read_line` scrolls horizontally instead of wrapping when the query exceeds the terminal width — the cursor stays pinned to the right edge of the line while overflowing
|
|
35
|
+
- `read_line` now shows the terminal cursor while editing and hides it again on exit (previously the cursor stayed invisible after `Typr.init`)
|
|
36
|
+
- `real_size` now measures via `sanitize`, so non-SGR CSI/OSC sequences and control characters count as zero width; the `printables` helper was removed in favor of `sanitize`
|
|
37
|
+
- Sort, filter, and view popups open in `/` search mode — typing narrows the list and a hint digit picks the result; `Line#ask` Array questions can forward pick options as a 4th element (e.g. `{ column: 0 }`)
|
|
38
|
+
- Default hints are digits only (`0-9`) — typing letters in any picker always filters/navigates instead of selecting by hint; explicit `hints:` overrides removed from `Browser`
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- Popup grids keep their height while live-filtering in `/` search mode — the box no longer collapses as rows are filtered out
|
|
42
|
+
- `read_key` assembles split escape sequences — CSI input (cursor keys, SGR mouse) reads on to its final byte, SS3 (`\eO`) completes to three bytes, and a lone `ESC` briefly polls for an Alt+key continuation, so a split key can no longer be mistaken for a plain Escape (which would close a popup or exit)
|
|
43
|
+
- Spec cleanup — merged the duplicated `Text#print` describes, removed the redundant empty-input edge case, dropped stack-spec `#pick` examples that duplicated grid-spec coverage, and updated the browser username specs to stub the passwd lookup (the info bar intentionally shows the real user, not stale `$USER`) plus the grid format-reset spec to assert `@widths` clearing instead of the removed `@maxed` state
|
|
44
|
+
|
|
3
45
|
## [v1.3.0] — 2026-08-03
|
|
4
46
|
|
|
5
47
|
### Added
|
data/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Navigation uses **hint numbers** displayed next to selectable items — rows in
|
|
|
23
23
|
|
|
24
24
|
### Mouse support
|
|
25
25
|
|
|
26
|
-
typr reports and decodes mouse input. `Typr.init` enables mouse reporting and `Typr.exit` disables it; `Typr.read_key` decodes SGR and legacy X10 reports into a `Typr::Mouse` struct (`button`, `x`, `y`, `action`, `modifiers`) with `press?`/`release?`/`wheel?`/`left?`/`middle?`/`right?` predicates. Pickers accept the mouse directly: a left click acts like pressing the row's hint digit,
|
|
26
|
+
typr reports and decodes mouse input. `Typr.init` enables mouse reporting and `Typr.exit` disables it; `Typr.read_key` decodes SGR and legacy X10 reports into a `Typr::Mouse` struct (`button`, `x`, `y`, `action`, `modifiers`) with `press?`/`release?`/`wheel?`/`left?`/`middle?`/`right?` predicates. Pickers accept the mouse directly: a left click acts like pressing the row's hint digit, the wheel scrolls the listing, and a click outside the picker's bounds cancels it like Escape. Any widget can expose clickable elements through `Space#data_at` (a row id, a binding label, or an action) and `Stack#send` dispatches mouse events to them — so `Browser`'s head columns and the bottom binding bar are clickable too, and `Line#help` is `Line#list.pick :row` — the line's keybinding grid, exposed so apps can compose their own help beside it (e.g. Filecon aligns a HELP text panel at `list.right + 2` and mirrors navigation to both panels).
|
|
27
27
|
|
|
28
28
|
### Terminal layer
|
|
29
29
|
|
data/example/grid
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# Usage:
|
|
5
5
|
# cd example && ruby grid [file]
|
|
6
6
|
#
|
|
7
|
-
# Reads
|
|
7
|
+
# Reads a comma-separated file and displays it as an interactive table.
|
|
8
8
|
#
|
|
9
9
|
# Keys:
|
|
10
10
|
# s Sort by a column (prompts for column number)
|
|
@@ -20,54 +20,49 @@
|
|
|
20
20
|
# The header line shows current time, active sort, filters,
|
|
21
21
|
# and selected rows. The user line shows available commands.
|
|
22
22
|
|
|
23
|
-
require 'csv'
|
|
24
23
|
require_relative '../lib/typr.rb'
|
|
25
24
|
|
|
26
25
|
class GridTest
|
|
27
|
-
# extend Typr
|
|
28
26
|
include Typr
|
|
29
|
-
|
|
27
|
+
|
|
30
28
|
def initialize
|
|
31
|
-
Typr.init
|
|
32
|
-
input =
|
|
29
|
+
Typr.init
|
|
30
|
+
input = $<.read.split("\n").map{|l| l.split(',') }
|
|
33
31
|
header = input.shift
|
|
34
|
-
@grid = Grid.new(
|
|
35
|
-
# input: open( "top5000.csv").read.split.map{|l| l.split(',') } ,
|
|
32
|
+
@grid = Grid.new(
|
|
36
33
|
input: input,
|
|
37
34
|
top: 1, right: -1, bottom: -2,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
colors: { columns: [ :yellow, :blue, :red, :green, :magenta] } )
|
|
43
|
-
@user = Line.new( top: -1, default:
|
|
35
|
+
format: [ 4, :max, 1, :min, :min ],
|
|
36
|
+
header: header,
|
|
37
|
+
colors: { columns: [ :yellow, :blue, :red, :green, :magenta] } )
|
|
38
|
+
@user = Line.new( top: -1, default:
|
|
44
39
|
"(s)ort, (f)ilter, (r)eset, select (R)ows/(C)olumns/(F)ields, [esc] quit")
|
|
45
40
|
@head = Line.new( interval: 1,
|
|
46
|
-
default: proc{ draw " %s, sort:%s filter:%s selected:%s" %
|
|
47
|
-
[ Time.now.strftime("%D %T"),
|
|
48
|
-
(@grid.header[@grid.sorted_by
|
|
49
|
-
@grid.filters.join, @grid.selected
|
|
41
|
+
default: proc{ draw " %s, sort:%s filter:%s selected:%s" %
|
|
42
|
+
[ Time.now.strftime("%D %T"),
|
|
43
|
+
(@grid.header[@grid.sorted_by] if @grid.sorted_by),
|
|
44
|
+
@grid.filters.join, @grid.selected ] } )
|
|
50
45
|
end
|
|
51
46
|
|
|
52
47
|
def run
|
|
53
48
|
Typr.clear
|
|
54
|
-
loop do
|
|
49
|
+
loop do
|
|
55
50
|
@grid.show
|
|
56
51
|
@user.show
|
|
57
|
-
@head.start
|
|
58
|
-
key = Typr.
|
|
52
|
+
@head.start
|
|
53
|
+
key = Typr.read_key
|
|
59
54
|
@head.stop
|
|
60
55
|
case key
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
when KEY_ESCAPE; exit
|
|
57
|
+
when ?` then (line = Typr.read_line) and @user.show(eval(line))
|
|
63
58
|
when ?s then @grid.sort_by( @user.ask "sort column": [@grid, :column] )
|
|
64
59
|
when ?F then @user.ask("pick fields": [@grid, :fields])
|
|
65
60
|
when ?R then @user.ask("pick rows": [@grid, :rows])
|
|
66
61
|
when ?C then @user.ask("pick columns": [@grid, :columns])
|
|
67
|
-
when ?f then @grid.add_filter( *@user.ask("filter column":
|
|
62
|
+
when ?f then @grid.add_filter( *@user.ask("filter column":
|
|
68
63
|
[@grid, :column], with: :line))
|
|
69
|
-
when ?r then @grid.reset; @user.reset
|
|
70
|
-
else @grid.send key; @user.reset
|
|
64
|
+
when ?r then @grid.reset; @user.reset
|
|
65
|
+
else @grid.send key; @user.reset
|
|
71
66
|
end
|
|
72
67
|
end
|
|
73
68
|
end
|
|
@@ -77,4 +72,4 @@ begin
|
|
|
77
72
|
GridTest.new.run
|
|
78
73
|
ensure
|
|
79
74
|
Typr.exit
|
|
80
|
-
end
|
|
75
|
+
end
|
data/lib/browser.rb
CHANGED
|
@@ -63,33 +63,25 @@ module Typr
|
|
|
63
63
|
Dir.chdir( @directory = File.expand_path( dir ) )
|
|
64
64
|
end
|
|
65
65
|
data = Dir.new(@directory).children.reject{|f|
|
|
66
|
-
!@show_hidden and f[0] == ?. }.map
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
!@show_hidden and f[0] == ?. }.map { |file| scan_file file }
|
|
67
|
+
|
|
68
|
+
data.each_with_index{ |row, id|
|
|
69
|
+
file = row[NAME]
|
|
70
|
+
ftype = File.ftype(file) rescue next
|
|
71
|
+
ftype = File.stat(file).ftype if ftype == 'link' and File.exist?(file)
|
|
72
|
+
mime = { 'directory' => 'inode/directory', 'characterSpecial' => 'inode/chardevice',
|
|
73
|
+
'blockSpecial' => 'inode/blockdevice', 'link' => 'inode/symlink',
|
|
74
|
+
'fifo' => 'inode/fifo', 'socket' => 'inode/socket' }[ftype]
|
|
75
|
+
unless mime
|
|
76
|
+
if @mimetype_db and ext = file.match(/.+\.(\w+)$/)
|
|
77
|
+
mime = MIMETYPES[ext[1].to_sym]
|
|
78
|
+
end
|
|
79
|
+
missing[file] = id unless mime
|
|
70
80
|
end
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
type = stat.ftype.to_sym
|
|
74
|
-
|
|
75
|
-
mime = { "directory": "inode/directory",
|
|
76
|
-
"characterSpecial": "inode/chardevice",
|
|
77
|
-
"blockSpecial": "inode/blockdevice", "link": "inode/symlink",
|
|
78
|
-
"fifo": "inode/fifo", "socket": "inode/socket"}[type]
|
|
79
|
-
if type == :file and @mimetype_db
|
|
80
|
-
ext = file.match(/.+\.(\w+)$/)
|
|
81
|
-
mime ||= MIMETYPES[ext[1].to_sym] if ext
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
( missing[file] = id ; mime = "?/?" ) unless mime
|
|
85
|
-
[ file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
|
|
86
|
-
"rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
|
|
87
|
-
File.executable?(file), stat.uid, stat.gid, stat.size,
|
|
88
|
-
stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i,
|
|
89
|
-
*mime.to_s.split( ?/ ) ]
|
|
90
|
-
end
|
|
81
|
+
row[TYPE, 2] = mime.to_s.split( ?/ ) if mime
|
|
82
|
+
}
|
|
91
83
|
|
|
92
|
-
if @mimetype_magic and
|
|
84
|
+
if @mimetype_magic and missing.any?
|
|
93
85
|
begin
|
|
94
86
|
out = IO.popen(['file', '--mime-type', *missing.keys]){ |io| io.read }
|
|
95
87
|
rescue Errno::ENOENT
|
|
@@ -98,7 +90,7 @@ module Typr
|
|
|
98
90
|
out.split($/).each{ |line|
|
|
99
91
|
file, mime = line.match(/^(.*):\s+(\w+\/[\w\-\.]+)$/)&.captures
|
|
100
92
|
next unless file
|
|
101
|
-
data[ missing[file] ][
|
|
93
|
+
data[ missing[file] ][TYPE, 2] = mime.split( ?/ ) if missing[file] }
|
|
102
94
|
end
|
|
103
95
|
data.each { |row| row[0] = File.join(@directory, row[0]) }
|
|
104
96
|
self << data
|
|
@@ -106,12 +98,33 @@ module Typr
|
|
|
106
98
|
filter
|
|
107
99
|
unless append
|
|
108
100
|
@directory += ?/ unless @directory == ?/
|
|
109
|
-
|
|
101
|
+
change_view @view
|
|
110
102
|
reset :position
|
|
111
103
|
end
|
|
112
104
|
return
|
|
113
105
|
end
|
|
114
106
|
|
|
107
|
+
##
|
|
108
|
+
# Build a row for a single directory entry from filesystem metadata only.
|
|
109
|
+
# MIME type detection (extension database and +file --mime-type+) happens
|
|
110
|
+
# in a batch pass inside {#cd}; type/subtype are left as +"?"+ here.
|
|
111
|
+
#
|
|
112
|
+
# browser.scan_file "image.png" # => 12-column row
|
|
113
|
+
|
|
114
|
+
def scan_file file
|
|
115
|
+
target = nil
|
|
116
|
+
if File.symlink? file
|
|
117
|
+
target = File.readlink( file ) rescue $!.to_s
|
|
118
|
+
return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless File.exist?(file)
|
|
119
|
+
end
|
|
120
|
+
stat = File.stat(file) rescue nil
|
|
121
|
+
return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless stat
|
|
122
|
+
[ file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
|
|
123
|
+
"rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
|
|
124
|
+
( File.executable?(file) and stat.file? ), stat.uid, stat.gid, stat.size,
|
|
125
|
+
stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i, "?", "?", nil ]
|
|
126
|
+
end
|
|
127
|
+
|
|
115
128
|
##
|
|
116
129
|
# Colors symlink names green/red based on target existence and applies
|
|
117
130
|
# per-MIME-type foreground colors from +@colors[:types]+.
|
|
@@ -127,14 +140,18 @@ module Typr
|
|
|
127
140
|
##
|
|
128
141
|
# Switch to a named or indexed column view. Resets format widths.
|
|
129
142
|
#
|
|
130
|
-
# browser.
|
|
131
|
-
# browser.
|
|
132
|
-
# browser.
|
|
143
|
+
# browser.change_view :full # all columns
|
|
144
|
+
# browser.change_view 0 # first view key
|
|
145
|
+
# browser.change_view :stats # name, size, perms, owner, group, modified
|
|
133
146
|
|
|
134
|
-
def
|
|
147
|
+
def change_view view=nil
|
|
135
148
|
return unless view
|
|
136
149
|
view = @views.keys[ view ] if view.is_a? Integer
|
|
137
150
|
@sequence = @views[ view ].map{|title| @header.index title.to_s}
|
|
151
|
+
if @show_path
|
|
152
|
+
@data.each { |row| row[PATH] = File.dirname(row[NAME]) } if @data.any?
|
|
153
|
+
@sequence.insert(@sequence.index(NAME) || 0, PATH) unless @sequence.include?(PATH)
|
|
154
|
+
end
|
|
138
155
|
reset :format
|
|
139
156
|
@view = view
|
|
140
157
|
return
|
|
@@ -147,6 +164,18 @@ module Typr
|
|
|
147
164
|
# browser.send "1" # => row id if hint "1" is visible
|
|
148
165
|
|
|
149
166
|
def send key
|
|
167
|
+
if key.is_a?(Typr::Mouse) and key.press? and key.left?
|
|
168
|
+
[@head, @user].each do |w|
|
|
169
|
+
next unless w.respond_to?(:data_at)
|
|
170
|
+
value = w.data_at(key.x, key.y)
|
|
171
|
+
next unless value
|
|
172
|
+
key = extract_key(value.to_s)
|
|
173
|
+
while key.is_a?(String)
|
|
174
|
+
key = send(key)
|
|
175
|
+
end
|
|
176
|
+
return key
|
|
177
|
+
end
|
|
178
|
+
end
|
|
150
179
|
if key.is_a?(String) and
|
|
151
180
|
id = @hints[0..[height, rows-1+headspace].min-@hints_start-1].index(key)
|
|
152
181
|
return page[id+@hints_start]
|
|
@@ -227,7 +256,7 @@ module Typr
|
|
|
227
256
|
# browser.filter_view # prompts for filter text
|
|
228
257
|
|
|
229
258
|
def filter_view; add_filter *@user.ask(
|
|
230
|
-
filter: [ popup( type: :"[f]ilter" ), :row, NAME], with: :line ) end
|
|
259
|
+
filter: [ popup( type: :"[f]ilter" ), :row, NAME, { column: 0 }], with: :line ) end
|
|
231
260
|
|
|
232
261
|
## Open live `/` search filtered on the name column.
|
|
233
262
|
#
|
|
@@ -243,13 +272,13 @@ module Typr
|
|
|
243
272
|
#
|
|
244
273
|
# browser.sort_view # pick a column header to sort
|
|
245
274
|
|
|
246
|
-
def sort_view; sort_by @user.ask( "sort by": [ popup( type: :"[s]ort" ), :row] ) end
|
|
275
|
+
def sort_view; sort_by @user.ask( "sort by": [ popup( type: :"[s]ort" ), :row, nil, { column: 0 }] ) end
|
|
247
276
|
|
|
248
277
|
## Open a view-picker popup and switch to the selected view.
|
|
249
278
|
#
|
|
250
279
|
# browser.views # choose :simple, :compact, :stats, or :full
|
|
251
280
|
|
|
252
|
-
def views;
|
|
281
|
+
def views; change_view( popup( type: :"[v]iews", input: @views.keys ).pick( :row, column: 0 ) ) end
|
|
253
282
|
|
|
254
283
|
## True when the row is a directory or passes base Grid filtering.
|
|
255
284
|
#
|
|
@@ -273,32 +302,37 @@ module Typr
|
|
|
273
302
|
type ||= 'file'
|
|
274
303
|
type = type.to_s
|
|
275
304
|
return super(type, **kw) if type[/row|column|field|none/]
|
|
276
|
-
|
|
277
|
-
|
|
305
|
+
types = %w[audio video image text application inode]
|
|
306
|
+
add_filter( types.include?( type ) ? TYPE : SUBTYPE, type ) if type == 'directory' or types.include?( type )
|
|
278
307
|
loop do
|
|
279
308
|
show
|
|
280
309
|
draw_hints :row
|
|
281
310
|
key = Typr.read_key
|
|
282
311
|
if key == @keymap[:exit]
|
|
283
|
-
|
|
312
|
+
@filters.clear
|
|
313
|
+
sort
|
|
284
314
|
return
|
|
285
315
|
end
|
|
316
|
+
if key == @keymap[:confirm] and type == 'directory'
|
|
317
|
+
@filters.clear
|
|
318
|
+
sort
|
|
319
|
+
return @directory
|
|
320
|
+
end
|
|
286
321
|
if key.is_a?(Typr::Mouse)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
next
|
|
290
|
-
elsif key.press? and key.left? and choice = hit( key.y, key.x )
|
|
291
|
-
else next end
|
|
322
|
+
choice = send key
|
|
323
|
+
next if choice == :scroll or choice == :outside
|
|
292
324
|
else
|
|
293
325
|
next unless choice = send( key )
|
|
294
326
|
end
|
|
327
|
+
next unless choice
|
|
295
328
|
case choice
|
|
296
329
|
when Array; choice = choice.map{ |id| @data[id][NAME] }.join('" "')
|
|
297
330
|
when Integer; file = @data[ choice ]
|
|
298
331
|
if file[SUBTYPE] == 'directory'; cd file[TARGET] || file[NAME]; next
|
|
299
332
|
else choice = file[NAME] end
|
|
300
333
|
end
|
|
301
|
-
|
|
334
|
+
@filters.clear
|
|
335
|
+
sort
|
|
302
336
|
return choice
|
|
303
337
|
end
|
|
304
338
|
end
|
|
@@ -394,7 +428,7 @@ module Typr
|
|
|
394
428
|
|
|
395
429
|
def initialize args={}
|
|
396
430
|
@mimetype_db, @mimetype_magic = true, true
|
|
397
|
-
@view, @sort, @views, @positions, @show_hidden = :compact, 0, {}, {}, false
|
|
431
|
+
@view, @sort, @views, @positions, @show_hidden, @show_path = :compact, 0, {}, {}, false, false
|
|
398
432
|
@filter_dirs = args[:filter_dirs]
|
|
399
433
|
super
|
|
400
434
|
@left ||= 0
|
|
@@ -422,7 +456,7 @@ module Typr
|
|
|
422
456
|
toggle_hidden: ?H}.merge @keymap
|
|
423
457
|
|
|
424
458
|
self.header = %w[ name target permissions executable owner group size
|
|
425
|
-
accessed modified created type subtype ]
|
|
459
|
+
accessed modified created type subtype path ]
|
|
426
460
|
|
|
427
461
|
@format[NAME] = :max
|
|
428
462
|
@format[TYPE] = 5
|
|
@@ -455,6 +489,27 @@ module Typr
|
|
|
455
489
|
colors: { columns: [@colors[:default], @colors[:default],
|
|
456
490
|
@colors[:actions][:"[f]ilter"], @colors[:actions][:"[s]ort"],
|
|
457
491
|
@colors[:actions][:"[v]iews"]] } )
|
|
492
|
+
@head.define_singleton_method( :data_at ) do |x, y|
|
|
493
|
+
return unless x and y and y.between?( top + 1, bottom + 1 ) and
|
|
494
|
+
x.between?( left, right )
|
|
495
|
+
offs = positions( 0 )
|
|
496
|
+
col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
|
|
497
|
+
@parent.instance_variable_get( :@positions ).keys[ col - 2 ] if col and col >= 2
|
|
498
|
+
end
|
|
499
|
+
@head.define_singleton_method( :path_at ) do |x, y|
|
|
500
|
+
return unless x and y and y.between?( top + 1, bottom + 1 )
|
|
501
|
+
x -= 1
|
|
502
|
+
return unless x.between?( left, right )
|
|
503
|
+
offs = positions( 0 )
|
|
504
|
+
col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
|
|
505
|
+
return (Etc.getpwuid(Process.euid).dir rescue (Dir.home rescue "~")) if col == 0
|
|
506
|
+
return unless col == 1 and @parent.directory.is_a?( String )
|
|
507
|
+
text = ((@layer and @layer[0] and @layer[0][1]) || @data[0][1].call).to_s
|
|
508
|
+
rel = x - ( left + offs[1] )
|
|
509
|
+
return if rel < 0 or rel >= real_size( text )
|
|
510
|
+
pre = prepare( text, rel + 1 )
|
|
511
|
+
( pre + ( pre.end_with?( "/" ) ? "" : text[pre.length..-1][/\A[^\/]*/] ) ).sub( %r{(?<=.)/$}, "" )
|
|
512
|
+
end
|
|
458
513
|
|
|
459
514
|
@user ||= Line.new( parent:self, left:->{@parent.left},top:->{@parent.bottom+1},
|
|
460
515
|
bindings: %w[ [h]elp [/]:search ] + @colors[:actions].map{|name,color|
|
data/lib/graphical.rb
CHANGED
data/lib/grid.rb
CHANGED
|
@@ -262,17 +262,27 @@ module Typr
|
|
|
262
262
|
@filters = [[column, '']]
|
|
263
263
|
@map = base.select{ |id| check id }
|
|
264
264
|
@start = 0
|
|
265
|
+
@bottom ||= bottom
|
|
265
266
|
show
|
|
266
267
|
draw_hints
|
|
268
|
+
@modifier = nil
|
|
267
269
|
result = Typr.read_line '/', left: left, top: self.bottom + 1,
|
|
268
270
|
&->(key, query, _) {
|
|
269
|
-
if key.is_a?(
|
|
271
|
+
if key.is_a?(Typr::Mouse)
|
|
272
|
+
value = send key
|
|
273
|
+
return value if value.is_a?( Integer )
|
|
274
|
+
return :outside if value == :outside
|
|
275
|
+
elsif key.is_a?(String) and idx = @hints[0..height-@hints_start-1].index(key)
|
|
276
|
+
return page.to_a[idx + @hints_start]
|
|
277
|
+
elsif key.is_a?(String) and digit = key[/\A\e(\d)\z/, 1] and
|
|
278
|
+
idx = @hints[0..height-@hints_start-1].index(digit)
|
|
279
|
+
@modifier = :alt
|
|
270
280
|
return page.to_a[idx + @hints_start]
|
|
271
281
|
end
|
|
272
282
|
if key.is_a?(String) and !key.each_char.all?{ |c| c.ord.between?(32, 126) } and
|
|
273
283
|
@keymap.values.include?(key) and ![KEY_BACKSPACE, "\b"].include?(key)
|
|
274
284
|
send key
|
|
275
|
-
|
|
285
|
+
elsif !key.is_a?(Typr::Mouse)
|
|
276
286
|
@filters = [[column, query]]
|
|
277
287
|
@map = base.select{ |id| check id }
|
|
278
288
|
@start = 0
|
|
@@ -282,7 +292,7 @@ module Typr
|
|
|
282
292
|
nil
|
|
283
293
|
}
|
|
284
294
|
case result
|
|
285
|
-
when nil;
|
|
295
|
+
when nil, :outside; @filters, @map, @start = saved
|
|
286
296
|
when Integer; return result
|
|
287
297
|
else
|
|
288
298
|
@filters = result.empty? ? saved[0] : saved[0] + [[column, result]]
|
|
@@ -299,6 +309,19 @@ module Typr
|
|
|
299
309
|
#
|
|
300
310
|
# @param column [Integer, Symbol]
|
|
301
311
|
|
|
312
|
+
def send key
|
|
313
|
+
if key.is_a?(Typr::Mouse) and key.press? and key.left? and
|
|
314
|
+
@header.is_a?(Array) and key.y == top + 1
|
|
315
|
+
rel = key.x - left - 1 - @margin.size
|
|
316
|
+
if rel >= 0
|
|
317
|
+
col = positions(0).count{|pos| pos <= rel} - 1
|
|
318
|
+
sort_by(sequence[col]) if col >= 0 && col < sequence.size
|
|
319
|
+
end
|
|
320
|
+
return :scroll
|
|
321
|
+
end
|
|
322
|
+
super
|
|
323
|
+
end
|
|
324
|
+
|
|
302
325
|
def sort_by column
|
|
303
326
|
column = @header.index( column.to_s ) if column.is_a? Symbol
|
|
304
327
|
if @sort and (column == @sort) then @reverse = !@reverse
|
|
@@ -382,6 +405,7 @@ module Typr
|
|
|
382
405
|
gaps = @margin.size + @separator.size * (columns - 1)
|
|
383
406
|
sum = @widths.sum
|
|
384
407
|
@max = sum + gaps - 1
|
|
408
|
+
@max = [ @max, @header.length ].max if @header.is_a?(String)
|
|
385
409
|
space = width - gaps
|
|
386
410
|
if (extra = sum - space ) > 0
|
|
387
411
|
extra.times{ |i| @widths[ @widths.index(@widths.max) ] -= 1 }
|
|
@@ -406,7 +430,8 @@ module Typr
|
|
|
406
430
|
header = ( row == :header )
|
|
407
431
|
return draw @header[0..width-1].ljust(width) if
|
|
408
432
|
header and @header.is_a? String
|
|
409
|
-
|
|
433
|
+
return unless row_data = header ? @header : @data[row]
|
|
434
|
+
fields = row_data.dup
|
|
410
435
|
@layer[row].each{ |col,value| fields[col] = value } if
|
|
411
436
|
@layer[row] if @layer unless header
|
|
412
437
|
for col,id in sequence.each_with_index
|
data/lib/line.rb
CHANGED
|
@@ -39,17 +39,43 @@ class Line < Space
|
|
|
39
39
|
def append(str); show @data+str end
|
|
40
40
|
alias :<< :append
|
|
41
41
|
|
|
42
|
-
#
|
|
42
|
+
# The keybinding list as a clickable grid of the line's own bindings. It
|
|
43
|
+
# auto-sizes to its widest binding (floored to fit the header) and starts
|
|
44
|
+
# at column 1, so apps can align side panels at +list.right + 2+.
|
|
43
45
|
#
|
|
44
|
-
# line.
|
|
45
|
-
# line.help
|
|
46
|
+
# line.list # => a Grid whose rows are the bindings
|
|
46
47
|
|
|
47
|
-
def
|
|
48
|
-
Grid.new(
|
|
48
|
+
def list
|
|
49
|
+
Grid.new( input: @bindings, top: 2, left: 1, bottom: -3,
|
|
49
50
|
header: "KEYBINDINGS",
|
|
50
|
-
colors: { header: [:
|
|
51
|
-
alternate: false, border:
|
|
52
|
-
|
|
51
|
+
colors: { header: [:yellow,:black], border:[:white,:grey10] },
|
|
52
|
+
alternate: false, border: :light )
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Pick a keybinding from the list: click a row (or press its hint digit) to
|
|
56
|
+
# return the row id, Escape or Return to return nil.
|
|
57
|
+
#
|
|
58
|
+
# line.help # => 0 (the picked row) or nil
|
|
59
|
+
|
|
60
|
+
def help; list.pick :row end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# The binding label under terminal (x, y) on this line, or nil when the
|
|
64
|
+
# click is outside the row or off the current bindings text (see
|
|
65
|
+
# {Space#data_at}).
|
|
66
|
+
#
|
|
67
|
+
# line.data_at 3, 8 # => "[h]elp"
|
|
68
|
+
|
|
69
|
+
def data_at x, y
|
|
70
|
+
return unless x and y and y == top + 1 and x.between?( left, right ) and
|
|
71
|
+
@bindings and @data == @bindings.join( ' ' )
|
|
72
|
+
pos = left
|
|
73
|
+
@bindings.each do |b|
|
|
74
|
+
width = real_size( b )
|
|
75
|
+
return b if x.between?( pos, pos + width - 1 )
|
|
76
|
+
pos += width + 1
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
53
79
|
end
|
|
54
80
|
|
|
55
81
|
# Reset the line to its default text or prompt.
|
|
@@ -64,7 +90,9 @@ class Line < Space
|
|
|
64
90
|
#
|
|
65
91
|
# The +type+ selects how the answer is collected:
|
|
66
92
|
# Array - interactive pick via <widget>.pick(<type>); an optional third
|
|
67
|
-
# element renders the chosen row's field into the line
|
|
93
|
+
# element renders the chosen row's field into the line, and an
|
|
94
|
+
# optional fourth element is passed as pick keyword arguments
|
|
95
|
+
# (e.g. { column: 0 } to open the pick in / search mode)
|
|
68
96
|
# :key - a single keypress via Typr.read_key
|
|
69
97
|
# :line - interactive line editor via Typr.read_line at the cursor
|
|
70
98
|
#
|
|
@@ -95,7 +123,7 @@ class Line < Space
|
|
|
95
123
|
draw prepare( colored, width, @align, @trim )
|
|
96
124
|
color( @colors[:answer] )
|
|
97
125
|
case object
|
|
98
|
-
when Array then answer << object.first.pick( object[1] )
|
|
126
|
+
when Array then answer << object.first.pick( object[1], **(object[3] || {}) )
|
|
99
127
|
when :key then answer << Typr.read_key
|
|
100
128
|
when :line then answer << Typr.read_line( colored + color_code( @colors[:answer] ),
|
|
101
129
|
left: left, top: top )
|
data/lib/space.rb
CHANGED
|
@@ -46,6 +46,27 @@ eval( method % ([name]*11) ) }
|
|
|
46
46
|
# Viewport height in cells (bottom - top + 1).
|
|
47
47
|
def height; bottom - top + 1 end
|
|
48
48
|
|
|
49
|
+
##
|
|
50
|
+
# The clickable element at terminal (x, y), or nil when outside the widget.
|
|
51
|
+
# Widgets with data override this: Stack returns an Integer row id, Line
|
|
52
|
+
# returns a String binding. Applications dispatch mouse events by iterating
|
|
53
|
+
# their spaces and taking the first non-nil result.
|
|
54
|
+
#
|
|
55
|
+
# space.data_at 3, 8 # => nil (base Space has no data)
|
|
56
|
+
|
|
57
|
+
def data_at x, y; nil end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# The trigger key for a binding label like "[h]elp" or "[/]:search", or nil
|
|
61
|
+
# for named keys ("[up]", "[esc]") which stay keyboard-only.
|
|
62
|
+
#
|
|
63
|
+
# line.extract_key "[R]ecurse" # => "R"
|
|
64
|
+
|
|
65
|
+
def extract_key binding
|
|
66
|
+
key = sanitize( binding.to_s )[/\[([^\]]+)\]/, 1]
|
|
67
|
+
key if key and key.size == 1
|
|
68
|
+
end
|
|
69
|
+
|
|
49
70
|
# Redraw every +interval+ seconds from a background thread.
|
|
50
71
|
def start; @updater = Thread.new{ loop{show; sleep @interval }} end
|
|
51
72
|
# Stop the background refresh thread.
|
data/lib/stack.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Typr
|
|
|
21
21
|
## +alternate+ toggles striped rows (+:grey10+ background alternating with the default bg).
|
|
22
22
|
|
|
23
23
|
attr_accessor :start, :keymap, :selected, :header
|
|
24
|
-
attr_accessor :separator, :hints, :hints_start
|
|
24
|
+
attr_accessor :separator, :hints, :hints_start, :modifier
|
|
25
25
|
|
|
26
26
|
##
|
|
27
27
|
# Returns a +Range+ of internal data indices visible on the current page.
|
|
@@ -45,10 +45,18 @@ module Typr
|
|
|
45
45
|
#
|
|
46
46
|
# stack.hit 3, 5 # => 0 (row 3, col 5 hits data row 0)
|
|
47
47
|
|
|
48
|
-
def hit row, col
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
def hit row, col; data_at col, row end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# The data row id at terminal (x, y), or nil when the click is outside the
|
|
52
|
+
# viewport (see {Space#data_at}).
|
|
53
|
+
#
|
|
54
|
+
# stack.data_at 5, 3 # => 0
|
|
55
|
+
|
|
56
|
+
def data_at x, y
|
|
57
|
+
return unless x and y
|
|
58
|
+
rel = y - 1 - top - headspace
|
|
59
|
+
return unless rel.between?(0, height - 1) and x.between?(left, right)
|
|
52
60
|
page.to_a[rel]
|
|
53
61
|
end
|
|
54
62
|
|
|
@@ -146,6 +154,18 @@ module Typr
|
|
|
146
154
|
# stack.send Typr::KEY_RETURN # confirms selection
|
|
147
155
|
|
|
148
156
|
def send key#, map=nil
|
|
157
|
+
if key.is_a?(Typr::Mouse)
|
|
158
|
+
if key.wheel? and key.press?
|
|
159
|
+
send( key.wheel_up? ? @keymap[:up] : @keymap[:down] )
|
|
160
|
+
return :scroll
|
|
161
|
+
end
|
|
162
|
+
if key.press? and ( key.left? or key.right? )
|
|
163
|
+
return :outside unless key.x and key.y and
|
|
164
|
+
key.x.between?( left, right ) and key.y.between?( top + 1, bottom + 1 )
|
|
165
|
+
return data_at( key.x, key.y )
|
|
166
|
+
end
|
|
167
|
+
return
|
|
168
|
+
end
|
|
149
169
|
response = eval @keymap.invert[key].to_s if @keymap.values.include? key
|
|
150
170
|
@start = rows - height if @start > rows - height
|
|
151
171
|
@start = 0 if @start < 0
|
|
@@ -213,6 +233,7 @@ module Typr
|
|
|
213
233
|
def pick type = :row, row=0 #, key=nil
|
|
214
234
|
type = type.to_s
|
|
215
235
|
multiple = type[-1] == ?s
|
|
236
|
+
@modifier = nil
|
|
216
237
|
loop do
|
|
217
238
|
if type['field']
|
|
218
239
|
row = pick( :row ) or return
|
|
@@ -224,20 +245,25 @@ module Typr
|
|
|
224
245
|
limit = type['row'] ? height : positions(row).count
|
|
225
246
|
key = Typr.read_key
|
|
226
247
|
if key.is_a?(Typr::Mouse)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
end
|
|
234
|
-
elsif key.is_a?(String) and value =
|
|
248
|
+
value = send( key )
|
|
249
|
+
next if value == :scroll
|
|
250
|
+
return if value == :outside
|
|
251
|
+
value = nil unless type['row'] and key.left?
|
|
252
|
+
relative = value if value and type['relative_']
|
|
253
|
+
elsif !type['none'] and key.is_a?(String) and value =
|
|
235
254
|
@hints[0..limit-@hints_start-1].index(key)
|
|
236
255
|
value += @hints_start
|
|
237
256
|
relative = value + @start if type['relative_']
|
|
238
257
|
if type['row'] then value = page.to_a[ value ]
|
|
239
258
|
elsif type['column'] and @sequence; value = @sequence[value] end
|
|
240
|
-
|
|
259
|
+
elsif !type['none'] and key.is_a?(String) and digit = key[/\A\e(\d)\z/, 1] and
|
|
260
|
+
value = @hints[0..limit-@hints_start-1].index(digit)
|
|
261
|
+
@modifier = :alt
|
|
262
|
+
value += @hints_start
|
|
263
|
+
relative = value + @start if type['relative_']
|
|
264
|
+
if type['row'] then value = page.to_a[ value ]
|
|
265
|
+
elsif type['column'] and @sequence; value = @sequence[value] end
|
|
266
|
+
end
|
|
241
267
|
end
|
|
242
268
|
|
|
243
269
|
if value
|
|
@@ -270,6 +296,13 @@ module Typr
|
|
|
270
296
|
return
|
|
271
297
|
end
|
|
272
298
|
|
|
299
|
+
def cycle_back
|
|
300
|
+
max = [height-1, rows-1+headspace].min
|
|
301
|
+
@hints_start -= @hints.size
|
|
302
|
+
@hints_start = (max / @hints.size) * @hints.size if @hints_start < 0
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
|
|
273
306
|
##
|
|
274
307
|
# Constructs a Stack widget.
|
|
275
308
|
#
|
|
@@ -291,10 +324,10 @@ module Typr
|
|
|
291
324
|
@colors = { hints: [255, :black ], header: [232,:grey60 ],
|
|
292
325
|
selected: :grey25, alternate: :grey10, rows: {} }.merge @colors
|
|
293
326
|
@selected = { fields:[], columns:[], rows:[] }.merge @selected
|
|
294
|
-
@keymap = { cycle: KEY_TAB, confirm: KEY_RETURN, exit: KEY_ESCAPE,
|
|
327
|
+
@keymap = { cycle: KEY_TAB, cycle_back: KEY_BACK_TAB, confirm: KEY_RETURN, exit: KEY_ESCAPE,
|
|
295
328
|
page_down: KEY_PAGEDOWN, page_up: KEY_PAGEUP, up: KEY_UP,
|
|
296
329
|
down: KEY_DOWN, to_top: KEY_HOME, to_bottom: KEY_END }.merge @keymap
|
|
297
|
-
@hints ||= "
|
|
330
|
+
@hints ||= "1234567890"
|
|
298
331
|
end
|
|
299
332
|
end
|
|
300
333
|
|
data/lib/terminal.rb
CHANGED
|
@@ -49,6 +49,10 @@ module Typr
|
|
|
49
49
|
KEY_ESCAPE = "\e"
|
|
50
50
|
KEY_RETURN = CARRIAGE_RETURN
|
|
51
51
|
KEY_TAB = "\t"
|
|
52
|
+
KEY_BACK_TAB = "\e[Z"
|
|
53
|
+
KEY_ALT_BACKSPACE = "\e\x7f"
|
|
54
|
+
KEY_ALT_LEFT = "\e[1;3D"
|
|
55
|
+
KEY_ALT_RIGHT = "\e[1;3C"
|
|
52
56
|
KEY_PAGEDOWN = KEY_NPAGE
|
|
53
57
|
KEY_PAGEUP = KEY_PPAGE
|
|
54
58
|
|
|
@@ -56,6 +60,8 @@ module Typr
|
|
|
56
60
|
# terminfo: 1000 = button press/release/wheel, 1006 = SGR coordinates.
|
|
57
61
|
MOUSE_ON = "\e[?1000h\e[?1006h"
|
|
58
62
|
MOUSE_OFF = "\e[?1000l\e[?1006l"
|
|
63
|
+
ALT_SCREEN_ON = "\e[?1049h"
|
|
64
|
+
ALT_SCREEN_OFF = "\e[?1049l"
|
|
59
65
|
|
|
60
66
|
# A mouse event decoded from the input stream. +x+ / +y+ are 1-based
|
|
61
67
|
# terminal coordinates; +button+ is 0=left, 1=middle, 2=right, or 4-7 for
|
|
@@ -166,11 +172,16 @@ module Typr
|
|
|
166
172
|
str[0..-num-1] + chars )
|
|
167
173
|
end
|
|
168
174
|
|
|
169
|
-
# Strip ANSI escape sequences
|
|
170
|
-
|
|
175
|
+
# Strip ANSI escape sequences and control characters (except \n, \t)
|
|
176
|
+
# from shell-generated text, leaving only printable content.
|
|
177
|
+
def sanitize str
|
|
178
|
+
str.scrub
|
|
179
|
+
.gsub(/\e(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=><0A])/, '')
|
|
180
|
+
.gsub(/[\x00-\x08\x0b-\x1f\x7f]/, '')
|
|
181
|
+
end
|
|
171
182
|
|
|
172
|
-
# Display width of +str+ after stripping ANSI escapes.
|
|
173
|
-
def real_size str; Unicode::DisplayWidth.of(
|
|
183
|
+
# Display width of +str+ after stripping ANSI escapes and control chars.
|
|
184
|
+
def real_size str; Unicode::DisplayWidth.of( sanitize(str) ) end
|
|
174
185
|
|
|
175
186
|
# Coerce a string into Integer, Float, or Boolean when it matches those
|
|
176
187
|
# forms (yes/no, true/false); otherwise return it unchanged.
|
|
@@ -250,11 +261,15 @@ module Typr
|
|
|
250
261
|
def self.read_key
|
|
251
262
|
read = ->(tty) do
|
|
252
263
|
str = tty.sysread 6
|
|
253
|
-
if str.start_with?("\e[
|
|
254
|
-
|
|
264
|
+
if str.start_with?("\e[M") and str.size < 6
|
|
265
|
+
str << tty.sysread(6 - str.size) # X10 mouse reports
|
|
266
|
+
elsif str.start_with?("\e[")
|
|
267
|
+
# CSI sequences (arrows, SGR mouse, ...) end with a byte >= 0x40.
|
|
255
268
|
str << tty.sysread(1) until str[-1].ord >= 0x40
|
|
256
|
-
elsif str.start_with?("\
|
|
257
|
-
str << tty.sysread(
|
|
269
|
+
elsif str.start_with?("\eO") and str.size < 3
|
|
270
|
+
str << tty.sysread(3 - str.size) # SS3 (application) cursor keys
|
|
271
|
+
elsif str == "\e" and IO.select([tty], nil, nil, 0.03)
|
|
272
|
+
str << tty.read_nonblock(6) rescue nil # alt+key continuation
|
|
258
273
|
end
|
|
259
274
|
str
|
|
260
275
|
end
|
|
@@ -301,41 +316,80 @@ module Typr
|
|
|
301
316
|
# nil
|
|
302
317
|
# end
|
|
303
318
|
|
|
304
|
-
def self.read_line prompt='', left: 0, top: 0, initial: '', &block
|
|
319
|
+
def self.read_line prompt='', left: 0, top: 0, initial: '', completions: nil, redraw: nil, &block
|
|
305
320
|
return $stdin.gets&.chomp unless $stdin.tty?
|
|
306
321
|
query, cursor = initial.dup, initial.length
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
text_width( prompt + query[0...cursor] )
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
cursor
|
|
322
|
+
$>.print CURSOR_NORMAL
|
|
323
|
+
begin
|
|
324
|
+
loop do
|
|
325
|
+
$>.print "\e[%i;%if" % [ top + 1, left + 1 ]
|
|
326
|
+
$>.print ERASE_LINE
|
|
327
|
+
before = text_width( prompt + query[0...cursor] )
|
|
328
|
+
offset = [ left + before - Typr.width, 0 ].max
|
|
329
|
+
$>.print slice_width( prompt + query, offset, Typr.width - left + 1 )
|
|
330
|
+
$>.print "\e[%i;%if" % [ top + 1, left + 1 + before - offset ]
|
|
331
|
+
key = read_key
|
|
332
|
+
return nil if key.nil? or key == KEY_ESCAPE
|
|
333
|
+
return query if key == KEY_RETURN or key == "\n"
|
|
334
|
+
case key
|
|
335
|
+
when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
|
|
336
|
+
when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
|
|
337
|
+
when KEY_ALT_LEFT, "\eD"; cursor = word_prev query, cursor
|
|
338
|
+
when KEY_ALT_RIGHT, "\eC"; cursor = word_next query, cursor
|
|
339
|
+
when KEY_HOME; cursor = 0
|
|
340
|
+
when KEY_END; cursor = query.length
|
|
341
|
+
when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
|
|
342
|
+
when KEY_ALT_BACKSPACE
|
|
343
|
+
old = cursor
|
|
344
|
+
cursor = word_prev query, cursor
|
|
345
|
+
query.slice!(cursor, old - cursor)
|
|
346
|
+
when KEY_BACKSPACE, "\b"
|
|
347
|
+
if cursor > 0
|
|
348
|
+
query.slice!(cursor - 1, 1)
|
|
349
|
+
cursor -= 1
|
|
350
|
+
end
|
|
351
|
+
else
|
|
352
|
+
if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) }
|
|
353
|
+
query.insert(cursor, key)
|
|
354
|
+
cursor += key.length
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
cursor = 0 if cursor < 0
|
|
358
|
+
cursor = query.length if cursor > query.length
|
|
359
|
+
result = block.call( key, query, cursor ) if block
|
|
360
|
+
return result unless result.nil?
|
|
361
|
+
if completions and key == KEY_TAB
|
|
362
|
+
raw = query[0...cursor]
|
|
363
|
+
cmd = raw[/\w+/]
|
|
364
|
+
if cmd
|
|
365
|
+
items = IO.popen([{ 'COMP_LINE' => raw, 'COMP_POINT' => raw.bytesize.to_s },
|
|
366
|
+
'bash', '-c', <<~SH], err: '/dev/null', &:read)
|
|
367
|
+
_init_completion(){ cur=${COMP_WORDS[COMP_CWORD]}; prev=${COMP_WORDS[COMP_CWORD-1]}; }
|
|
368
|
+
source /usr/share/bash-completion/completions/#{cmd} 2>/dev/null || exit
|
|
369
|
+
COMP_WORDS=($COMP_LINE) COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
|
|
370
|
+
Fn=$(complete -p #{cmd} 2>/dev/null | awk '{print $(NF-1)}')
|
|
371
|
+
eval "$Fn" 2>/dev/null && printf '%s\\n' "${COMPREPLY[@]}"
|
|
372
|
+
SH
|
|
373
|
+
items = items.split("\n").reject(&:empty?).uniq
|
|
374
|
+
if items.any?
|
|
375
|
+
grid = Grid.new(left: left, top: [top - items.size - 3, 0].max,
|
|
376
|
+
input: items.map{ |s| [s] }, border: :light,
|
|
377
|
+
colors: { columns: [[:blue, :default]] })
|
|
378
|
+
picked = grid.pick(:row, column: 0)
|
|
379
|
+
redraw&.call
|
|
380
|
+
$>.print CURSOR_NORMAL
|
|
381
|
+
if picked.is_a?(Integer)
|
|
382
|
+
word_start = cursor
|
|
383
|
+
word_start -= 1 while word_start > 0 and query[word_start - 1] != ' '
|
|
384
|
+
query[word_start...cursor] = items[picked]
|
|
385
|
+
cursor = word_start + items[picked].length
|
|
386
|
+
end
|
|
387
|
+
end
|
|
333
388
|
end
|
|
389
|
+
end
|
|
334
390
|
end
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
result = block.call( key, query, cursor ) if block
|
|
338
|
-
return result unless result.nil?
|
|
391
|
+
ensure
|
|
392
|
+
$>.print CURSOR_INVISIBLE
|
|
339
393
|
end
|
|
340
394
|
end
|
|
341
395
|
|
|
@@ -344,6 +398,28 @@ module Typr
|
|
|
344
398
|
Unicode::DisplayWidth.of( str.gsub(/\x1b\[[^m]+m/, '') )
|
|
345
399
|
end
|
|
346
400
|
|
|
401
|
+
# Visible substring of +str+ starting at display-width +offset+, at most
|
|
402
|
+
# +width+ cells wide. ANSI escapes are zero-width and carried through so
|
|
403
|
+
# color state applies inside the window.
|
|
404
|
+
def self.slice_width str, offset, width
|
|
405
|
+
vis = 0
|
|
406
|
+
slice = +""
|
|
407
|
+
i = 0
|
|
408
|
+
while i < str.length
|
|
409
|
+
if str[i] == "\e"
|
|
410
|
+
seq = str[i..][/\A\e(\[[0-9;?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[()=>0-9])/]
|
|
411
|
+
slice << seq if seq
|
|
412
|
+
i += seq ? seq.length : 1
|
|
413
|
+
next
|
|
414
|
+
end
|
|
415
|
+
cell = Unicode::DisplayWidth.of(str[i])
|
|
416
|
+
vis += cell
|
|
417
|
+
slice << str[i] if vis > offset and vis <= offset + width
|
|
418
|
+
i += 1
|
|
419
|
+
end
|
|
420
|
+
slice
|
|
421
|
+
end
|
|
422
|
+
|
|
347
423
|
# Move the cursor back to the start of the previous word in +str+.
|
|
348
424
|
def self.word_prev str, cursor
|
|
349
425
|
cursor -= 1 while cursor > 0 and str[cursor - 1] == ' '
|
data/lib/text.rb
CHANGED
|
@@ -33,28 +33,40 @@ module Typr
|
|
|
33
33
|
# text << "appended line" # alias for build
|
|
34
34
|
|
|
35
35
|
def build input=nil
|
|
36
|
-
( @lines = [0]
|
|
36
|
+
( input, @data, @lines = @data, "", [0] ) if rebuild = !input
|
|
37
37
|
return self if rebuild && input == ""
|
|
38
|
-
|
|
38
|
+
str = input.respond_to?(:read) ? input.read : input.to_s
|
|
39
39
|
@tail = ""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@lines << @data.length
|
|
40
|
+
pos = 0
|
|
41
|
+
while pos < str.size || !@tail.empty?
|
|
42
|
+
capacity = width - @margin.size - real_size(@tail)
|
|
43
|
+
cap = [capacity, 1].max
|
|
44
|
+
added = 0; cells = 0
|
|
45
|
+
while pos + added < str.size
|
|
46
|
+
c = str[pos + added]
|
|
47
|
+
if c == ?\e
|
|
48
|
+
seq = str[(pos+added)..][/\A\e\[[0-9;]*m/]
|
|
49
|
+
added += seq ? seq.size : 1
|
|
50
|
+
elsif c == ?\n
|
|
51
|
+
added += 1; break
|
|
53
52
|
else
|
|
54
|
-
|
|
53
|
+
cw = Unicode::DisplayWidth.of(c)
|
|
54
|
+
break if cells + cw > cap
|
|
55
|
+
cells += cw; added += 1
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
|
-
|
|
58
|
+
line = @tail + str[pos, added]
|
|
59
|
+
pos += added
|
|
60
|
+
break if line.empty?
|
|
61
|
+
wrap_idx = line.index("\n") || line.rindex(" ") || line.length
|
|
62
|
+
wrap = [wrap_idx, line.length].min + 1
|
|
63
|
+
@data += line[0...wrap]
|
|
64
|
+
if wrap < line.length
|
|
65
|
+
@tail = line[wrap..-1]
|
|
66
|
+
else
|
|
67
|
+
@tail = ""
|
|
68
|
+
end
|
|
69
|
+
@lines << @data.length if wrap < line.length || line.include?("\n")
|
|
58
70
|
end
|
|
59
71
|
until @tail.empty?
|
|
60
72
|
wrap_idx = @tail.index("\n") || @tail.rindex(" ") || @tail.length
|
|
@@ -84,10 +96,10 @@ module Typr
|
|
|
84
96
|
text = ( header ? @header : @page[ row-@start ] )
|
|
85
97
|
if row.is_a?(Integer) and @search[:matches].include? row
|
|
86
98
|
background @colors[:search]
|
|
87
|
-
draw prepare( text, width )
|
|
99
|
+
draw prepare( text, width - @margin.size )
|
|
88
100
|
background
|
|
89
101
|
else
|
|
90
|
-
draw prepare( text, width )
|
|
102
|
+
draw prepare( text, width - @margin.size )
|
|
91
103
|
end
|
|
92
104
|
end
|
|
93
105
|
|
data/typr.gemspec
CHANGED