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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cdcaef4b1bc2c40e6fab6bd7d1462ddf19aeca03babcdbb9be74629551230647
4
- data.tar.gz: b4e927b5237f27e2366ed15057054b33d9f070ef5a50c234533699182c7228ee
3
+ metadata.gz: 6f464a51f6c8356a1571fcebe51bbe315428ec054d9b1b819753a688764ce347
4
+ data.tar.gz: '0367917e4ce7b2f67c0605bb321f358aa81e17f27ab555ab558a6655b646dc9a'
5
5
  SHA512:
6
- metadata.gz: 889fd403e345e89dc6777c02c6736d0865b7266b16157e13e4cf2469b9a3664d68f4fe672d1f9b5b6d72316d697c2c8e20e1f920853ae495014c9fb049fed6cd
7
- data.tar.gz: 662df0d3ef61d964cd8a03a1cd1f7caa7fb14a15d87c22afa3a2cdf9115f4780b944446ebcdc9f538c33ee80753e48c362fea7f716d2ef7fca2070f07fe5483f
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, and the wheel scrolls the listing.
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 csv file and displays it as an interactive table.
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
- # Typr.frontend :terminal
27
+
30
28
  def initialize
31
- Typr.init
32
- input = CSV.new($<.read).to_a
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
- # hints: "1234",
39
- format: [ 4, :max, 1, :min, :min ],
40
- # procs: { 2 => proc( &:upcase ) },
41
- header: header, # ["rank", "word", "p", "count", "quot"],
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.abs] if @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.read( :key )
52
+ @head.start
53
+ key = Typr.read_key
59
54
  @head.stop
60
55
  case key
61
- when KEY_ESCAPE; exit
62
- when ?` then (line = Typr.read(:line)) and @user.show(eval(line))
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.with_index do |file,id|
67
- if File.symlink? file
68
- target = File.readlink( file ) rescue $!.to_s
69
- next [file, target, "????", false, "?", "?", 0, 0, 0, 0, "inode", "symlink"] unless File.exist?(file)
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
- stat = File.stat(file) rescue nil
72
- next [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?"] unless stat
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 not missing.empty?
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] ][-2,2] = mime.split( ?/ ) if 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
- switch_to @view
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.switch_to :full # all columns
131
- # browser.switch_to 0 # first view key
132
- # browser.switch_to :stats # name, size, perms, owner, group, modified
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 switch_to view=nil
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; switch_to( popup( type: :"[v]iews", input: @views.keys ).pick :row ) end
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
- add_filter ( %w[audio video image text application inode].include?(
277
- type) ? TYPE : SUBTYPE ), type if type != 'file'
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
- reset :display
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
- if key.wheel? and key.press?
288
- send( key.wheel_up? ? @keymap[:up] : @keymap[:down] )
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
- reset :display
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
@@ -1,4 +1,4 @@
1
- require 'sdl2'
1
+ require 'sdl3'
2
2
  require_relative 'frontend'
3
3
 
4
4
  module Visuals
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?(String) and idx = @hints[0..height-@hints_start-1].index(key)
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
- else
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; @filters, @map, @start = saved
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
- fields = (header ? @header : @data[row] ).dup
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
- # Display keybinding help in a floating grid.
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.bindings = ["Ctrl-S: Save", "Ctrl-Q: Quit"]
45
- # line.help
46
+ # line.list # => a Grid whose rows are the bindings
46
47
 
47
- def help conf={}
48
- Grid.new( { input: ['']+@bindings+[''], top: 3, left:0.4,
48
+ def list
49
+ Grid.new( input: @bindings, top: 2, left: 1, bottom: -3,
49
50
  header: "KEYBINDINGS",
50
- colors: { header: [:white,:black], border:[:white,:grey10] },
51
- alternate: false, border: ' ' }.merge conf ).pick :none
52
- return
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
- return unless row and col
50
- rel = row - 1 - top - headspace
51
- return unless rel.between?(0, height - 1) and col.between?(left, right)
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
- if key.wheel? and key.press?
228
- send( key.wheel_up? ? @keymap[:up] : @keymap[:down] )
229
- next
230
- elsif key.press? and key.left? and type['row'] and
231
- value = hit( key.y, key.x )
232
- relative = value if type['relative_']
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
- end unless type['none']
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 ||= "1234567890qwertyuiopasdfghjklzxcvbnm-=[];'\,./"
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 from +str+.
170
- def printables str; str.gsub(/\x1b\[[^m]+m|/,'') end
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( printables(str) ) end
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
- # SGR mouse reports (CSI < b ; x ; y M/m) can exceed six bytes.
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?("\e[M") and str.size < 6
257
- str << tty.sysread(6 - str.size)
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
- loop do
308
- $>.print "\e[%i;%if" % [ top + 1, left + 1 ]
309
- $>.print ERASE_LINE
310
- $>.print prompt + query
311
- $>.print "\e[%i;%if" % [ top + 1, left + 1 +
312
- text_width( prompt + query[0...cursor] ) ]
313
- key = read_key
314
- return nil if key.nil? or key == KEY_ESCAPE
315
- return query if key == KEY_RETURN or key == "\n"
316
- case key
317
- when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
318
- when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
319
- when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor
320
- when "\e[1;5C", "\eOc"; cursor = word_next query, cursor
321
- when KEY_HOME; cursor = 0
322
- when KEY_END; cursor = query.length
323
- when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
324
- when KEY_BACKSPACE, "\b"
325
- if cursor > 0
326
- query.slice!(cursor - 1, 1)
327
- cursor -= 1
328
- end
329
- else
330
- if key.is_a?(String) and key.each_char.all?{ |char| char.ord.between?(32, 126) }
331
- query.insert(cursor, key)
332
- cursor += key.length
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
- cursor = 0 if cursor < 0
336
- cursor = query.length if cursor > query.length
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]; input = "" ) if rebuild = !input
36
+ ( input, @data, @lines = @data, "", [0] ) if rebuild = !input
37
37
  return self if rebuild && input == ""
38
- input = StringIO.new( input.to_s.dup ) unless input.respond_to?(:read)
38
+ str = input.respond_to?(:read) ? input.read : input.to_s
39
39
  @tail = ""
40
- until input.closed? or input.eof?
41
- capacity = width - @margin.size - @tail.length
42
- chunk = (capacity < 1) ? "x" : capacity
43
- line = @tail + input.read(chunk)
44
- if line.empty?
45
- break
46
- else
47
- wrap_idx = line.index("\n") || line.rindex(" ") || line.length
48
- wrap = [wrap_idx, line.length].min + 1
49
- @data += line[0...wrap]
50
- if wrap < line.length
51
- @tail = line[wrap..-1]
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
- @tail = ""
53
+ cw = Unicode::DisplayWidth.of(c)
54
+ break if cells + cw > cap
55
+ cells += cw; added += 1
55
56
  end
56
57
  end
57
- break if rebuild && line[-1] == "\n"
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "typr"
5
- s.version = "1.3.0"
5
+ s.version = "1.5.0"
6
6
  s.date = "2026-07-25"
7
7
  s.homepage = "https://codeberg.org/typr/typr"
8
8
  s.licenses = ["MIT"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typr
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilian Reitmayr