typr 1.4.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: 65f06238b297e2c6f925f139336c89b5606e23c7a08a6fb94ed82848490e9e71
4
- data.tar.gz: d958ec7e5f7cd5857c2b559b3e32327b590652c8fb759f0557effb33d8c27551
3
+ metadata.gz: 6f464a51f6c8356a1571fcebe51bbe315428ec054d9b1b819753a688764ce347
4
+ data.tar.gz: '0367917e4ce7b2f67c0605bb321f358aa81e17f27ab555ab558a6655b646dc9a'
5
5
  SHA512:
6
- metadata.gz: 8d651d702b4e5d019636814a021ef4ab62f300b841f482de5ed4aac8f3cc3fe636802ac34551b107bfaef2c19723805e7742fafa95ada76cb6c20f91066e101b
7
- data.tar.gz: 723ef1823511febee158925266361dbe0afda106ad3974fc1f75edd83f31e5a2ed6be272e634762bbf6797df41e6e27d24c908fe48902892fcf1c34271f1d6d1
6
+ metadata.gz: 6f3f59d78bfc8831542c843de4eb51aa1507774b95436f97d9cf906f12945808f25c1add663945ad3d94ba8f33a543bc8f2cb960eac2adf995e6c6ec62fb715e
7
+ data.tar.gz: bf0a175749a29714fa07e0e8124b72a031dd9606c7d077cdcf7f214dc81783d28a2cce6b0e265afbbee9155a82cef742916c0a4956522c3edf56b66b6c223bbd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## [v1.4.0] — 2026-08-15
4
26
 
5
27
  ### 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
@@ -78,7 +78,7 @@ module Typr
78
78
  end
79
79
  missing[file] = id unless mime
80
80
  end
81
- row[-2,2] = mime.to_s.split( ?/ ) if mime
81
+ row[TYPE, 2] = mime.to_s.split( ?/ ) if mime
82
82
  }
83
83
 
84
84
  if @mimetype_magic and missing.any?
@@ -90,7 +90,7 @@ module Typr
90
90
  out.split($/).each{ |line|
91
91
  file, mime = line.match(/^(.*):\s+(\w+\/[\w\-\.]+)$/)&.captures
92
92
  next unless file
93
- data[ missing[file] ][-2,2] = mime.split( ?/ ) if missing[file] }
93
+ data[ missing[file] ][TYPE, 2] = mime.split( ?/ ) if missing[file] }
94
94
  end
95
95
  data.each { |row| row[0] = File.join(@directory, row[0]) }
96
96
  self << data
@@ -115,14 +115,14 @@ module Typr
115
115
  target = nil
116
116
  if File.symlink? file
117
117
  target = File.readlink( file ) rescue $!.to_s
118
- return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?"] unless File.exist?(file)
118
+ return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless File.exist?(file)
119
119
  end
120
120
  stat = File.stat(file) rescue nil
121
- return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?"] unless stat
121
+ return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless stat
122
122
  [ file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
123
123
  "rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
124
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, "?", "?" ]
125
+ stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i, "?", "?", nil ]
126
126
  end
127
127
 
128
128
  ##
@@ -148,6 +148,10 @@ module Typr
148
148
  return unless view
149
149
  view = @views.keys[ view ] if view.is_a? Integer
150
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
151
155
  reset :format
152
156
  @view = view
153
157
  return
@@ -160,6 +164,18 @@ module Typr
160
164
  # browser.send "1" # => row id if hint "1" is visible
161
165
 
162
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
163
179
  if key.is_a?(String) and
164
180
  id = @hints[0..[height, rows-1+headspace].min-@hints_start-1].index(key)
165
181
  return page[id+@hints_start]
@@ -303,14 +319,12 @@ module Typr
303
319
  return @directory
304
320
  end
305
321
  if key.is_a?(Typr::Mouse)
306
- if key.wheel? and key.press?
307
- send( key.wheel_up? ? @keymap[:up] : @keymap[:down] )
308
- next
309
- elsif key.press? and key.left? and choice = hit( key.y, key.x )
310
- else next end
322
+ choice = send key
323
+ next if choice == :scroll or choice == :outside
311
324
  else
312
325
  next unless choice = send( key )
313
326
  end
327
+ next unless choice
314
328
  case choice
315
329
  when Array; choice = choice.map{ |id| @data[id][NAME] }.join('" "')
316
330
  when Integer; file = @data[ choice ]
@@ -414,7 +428,7 @@ module Typr
414
428
 
415
429
  def initialize args={}
416
430
  @mimetype_db, @mimetype_magic = true, true
417
- @view, @sort, @views, @positions, @show_hidden = :compact, 0, {}, {}, false
431
+ @view, @sort, @views, @positions, @show_hidden, @show_path = :compact, 0, {}, {}, false, false
418
432
  @filter_dirs = args[:filter_dirs]
419
433
  super
420
434
  @left ||= 0
@@ -442,7 +456,7 @@ module Typr
442
456
  toggle_hidden: ?H}.merge @keymap
443
457
 
444
458
  self.header = %w[ name target permissions executable owner group size
445
- accessed modified created type subtype ]
459
+ accessed modified created type subtype path ]
446
460
 
447
461
  @format[NAME] = :max
448
462
  @format[TYPE] = 5
@@ -475,6 +489,27 @@ module Typr
475
489
  colors: { columns: [@colors[:default], @colors[:default],
476
490
  @colors[:actions][:"[f]ilter"], @colors[:actions][:"[s]ort"],
477
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
478
513
 
479
514
  @user ||= Line.new( parent:self, left:->{@parent.left},top:->{@parent.bottom+1},
480
515
  bindings: %w[ [h]elp [/]:search ] + @colors[:actions].map{|name,color|
data/lib/grid.rb CHANGED
@@ -268,7 +268,11 @@ module Typr
268
268
  @modifier = nil
269
269
  result = Typr.read_line '/', left: left, top: self.bottom + 1,
270
270
  &->(key, query, _) {
271
- 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)
272
276
  return page.to_a[idx + @hints_start]
273
277
  elsif key.is_a?(String) and digit = key[/\A\e(\d)\z/, 1] and
274
278
  idx = @hints[0..height-@hints_start-1].index(digit)
@@ -278,7 +282,7 @@ module Typr
278
282
  if key.is_a?(String) and !key.each_char.all?{ |c| c.ord.between?(32, 126) } and
279
283
  @keymap.values.include?(key) and ![KEY_BACKSPACE, "\b"].include?(key)
280
284
  send key
281
- else
285
+ elsif !key.is_a?(Typr::Mouse)
282
286
  @filters = [[column, query]]
283
287
  @map = base.select{ |id| check id }
284
288
  @start = 0
@@ -288,7 +292,7 @@ module Typr
288
292
  nil
289
293
  }
290
294
  case result
291
- when nil; @filters, @map, @start = saved
295
+ when nil, :outside; @filters, @map, @start = saved
292
296
  when Integer; return result
293
297
  else
294
298
  @filters = result.empty? ? saved[0] : saved[0] + [[column, result]]
@@ -305,6 +309,19 @@ module Typr
305
309
  #
306
310
  # @param column [Integer, Symbol]
307
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
+
308
325
  def sort_by column
309
326
  column = @header.index( column.to_s ) if column.is_a? Symbol
310
327
  if @sort and (column == @sort) then @reverse = !@reverse
@@ -388,6 +405,7 @@ module Typr
388
405
  gaps = @margin.size + @separator.size * (columns - 1)
389
406
  sum = @widths.sum
390
407
  @max = sum + gaps - 1
408
+ @max = [ @max, @header.length ].max if @header.is_a?(String)
391
409
  space = width - gaps
392
410
  if (extra = sum - space ) > 0
393
411
  extra.times{ |i| @widths[ @widths.index(@widths.max) ] -= 1 }
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.
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
@@ -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
@@ -225,27 +245,25 @@ module Typr
225
245
  limit = type['row'] ? height : positions(row).count
226
246
  key = Typr.read_key
227
247
  if key.is_a?(Typr::Mouse)
228
- if key.wheel? and key.press?
229
- send( key.wheel_up? ? @keymap[:up] : @keymap[:down] )
230
- next
231
- elsif key.press? and key.left? and type['row'] and
232
- value = hit( key.y, key.x )
233
- relative = value if type['relative_']
234
- end
235
- 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 =
236
254
  @hints[0..limit-@hints_start-1].index(key)
237
255
  value += @hints_start
238
256
  relative = value + @start if type['relative_']
239
257
  if type['row'] then value = page.to_a[ value ]
240
258
  elsif type['column'] and @sequence; value = @sequence[value] end
241
- elsif key.is_a?(String) and digit = key[/\A\e(\d)\z/, 1] and
259
+ elsif !type['none'] and key.is_a?(String) and digit = key[/\A\e(\d)\z/, 1] and
242
260
  value = @hints[0..limit-@hints_start-1].index(digit)
243
261
  @modifier = :alt
244
262
  value += @hints_start
245
263
  relative = value + @start if type['relative_']
246
264
  if type['row'] then value = page.to_a[ value ]
247
265
  elsif type['column'] and @sequence; value = @sequence[value] end
248
- end unless type['none']
266
+ end
249
267
  end
250
268
 
251
269
  if value
@@ -278,6 +296,13 @@ module Typr
278
296
  return
279
297
  end
280
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
+
281
306
  ##
282
307
  # Constructs a Stack widget.
283
308
  #
@@ -299,7 +324,7 @@ module Typr
299
324
  @colors = { hints: [255, :black ], header: [232,:grey60 ],
300
325
  selected: :grey25, alternate: :grey10, rows: {} }.merge @colors
301
326
  @selected = { fields:[], columns:[], rows:[] }.merge @selected
302
- @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,
303
328
  page_down: KEY_PAGEDOWN, page_up: KEY_PAGEUP, up: KEY_UP,
304
329
  down: KEY_DOWN, to_top: KEY_HOME, to_bottom: KEY_END }.merge @keymap
305
330
  @hints ||= "1234567890"
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
@@ -310,7 +316,7 @@ module Typr
310
316
  # nil
311
317
  # end
312
318
 
313
- 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
314
320
  return $stdin.gets&.chomp unless $stdin.tty?
315
321
  query, cursor = initial.dup, initial.length
316
322
  $>.print CURSOR_NORMAL
@@ -328,11 +334,15 @@ module Typr
328
334
  case key
329
335
  when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
330
336
  when KEY_RIGHT, "\e[C"; cursor += 1 if cursor < query.length
331
- when "\e[1;5D", "\eOd"; cursor = word_prev query, cursor
332
- when "\e[1;5C", "\eOc"; cursor = word_next query, cursor
337
+ when KEY_ALT_LEFT, "\eD"; cursor = word_prev query, cursor
338
+ when KEY_ALT_RIGHT, "\eC"; cursor = word_next query, cursor
333
339
  when KEY_HOME; cursor = 0
334
340
  when KEY_END; cursor = query.length
335
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)
336
346
  when KEY_BACKSPACE, "\b"
337
347
  if cursor > 0
338
348
  query.slice!(cursor - 1, 1)
@@ -348,6 +358,35 @@ module Typr
348
358
  cursor = query.length if cursor > query.length
349
359
  result = block.call( key, query, cursor ) if block
350
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
388
+ end
389
+ end
351
390
  end
352
391
  ensure
353
392
  $>.print CURSOR_INVISIBLE
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.4.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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilian Reitmayr