typr 1.4.0 → 1.5.1

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: f04e1f9234a97079a70786d6a427c7c6394775515c78a19feee8e17e3dc1370e
4
+ data.tar.gz: b82771f5976e12fabbd56446d347ac8f49739e41d4dbe089b7ffc5738938dd17
5
5
  SHA512:
6
- metadata.gz: 8d651d702b4e5d019636814a021ef4ab62f300b841f482de5ed4aac8f3cc3fe636802ac34551b107bfaef2c19723805e7742fafa95ada76cb6c20f91066e101b
7
- data.tar.gz: 723ef1823511febee158925266361dbe0afda106ad3974fc1f75edd83f31e5a2ed6be272e634762bbf6797df41e6e27d24c908fe48902892fcf1c34271f1d6d1
6
+ metadata.gz: 99278addeb2d176281564448ddb5b34de23aebb1bac5a3e186c0a4735aafad0387274100bd38fa33018988d7672a7959c6c9791fd2bb8bf50c2e2545075552c6
7
+ data.tar.gz: 9efd4720c0cc7c58f41acf31ca8a38827300127b72ffadaf9d18ebd53b8be0500f53de1386eed6255149f858ab3e5a943523ba79630cadf11eddf8c0f8a6e072
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [v1.5.1] — 2026-09-11
4
+
5
+ ## [v1.5.0] — 2026-09-05
6
+
7
+ ### Added
8
+ - 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
9
+ - `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`
10
+ - 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
11
+ - `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
12
+ - 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
13
+
14
+ ### Fixed
15
+ - `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
16
+ - 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
17
+ - `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)
18
+ - `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
19
+ - `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`
20
+ - `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
21
+ - `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)
22
+ - `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
23
+ - `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
24
+ - `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
25
+ - 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
26
+ - `Mouse#alt?` — reports the SGRmeta bit, so callers can treat Alt+click like a right-click (e.g. opening a command list where right-click is hard to send, as on Termux touchscreens)
27
+ - `cd` survives unreadable directories — `chdir` and the listing are probed before any state changes, so `EACCES`/`ENOENT` (e.g. Android's `/data`) shows a warning and stays put instead of crashing on an emptied listing; returns `false` on failure
28
+
3
29
  ## [v1.4.0] — 2026-08-15
4
30
 
5
31
  ### 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
@@ -58,11 +58,22 @@ module Typr
58
58
  return unless dir
59
59
  data, missing = [],{}
60
60
  unless append
61
+ begin
62
+ path = File.expand_path( dir )
63
+ Dir.chdir( path )
64
+ entries = Dir.new( path ).children
65
+ rescue SystemCallError
66
+ @user.show "cannot open directory: #{dir}"
67
+ Typr.read_key
68
+ return false
69
+ end
61
70
  @colors[:fields] = {}
62
71
  clear
63
- Dir.chdir( @directory = File.expand_path( dir ) )
72
+ @directory = path
73
+ else
74
+ entries = Dir.new( @directory ).children
64
75
  end
65
- data = Dir.new(@directory).children.reject{|f|
76
+ data = entries.reject{|f|
66
77
  !@show_hidden and f[0] == ?. }.map { |file| scan_file file }
67
78
 
68
79
  data.each_with_index{ |row, id|
@@ -78,7 +89,7 @@ module Typr
78
89
  end
79
90
  missing[file] = id unless mime
80
91
  end
81
- row[-2,2] = mime.to_s.split( ?/ ) if mime
92
+ row[TYPE, 2] = mime.to_s.split( ?/ ) if mime
82
93
  }
83
94
 
84
95
  if @mimetype_magic and missing.any?
@@ -90,7 +101,7 @@ module Typr
90
101
  out.split($/).each{ |line|
91
102
  file, mime = line.match(/^(.*):\s+(\w+\/[\w\-\.]+)$/)&.captures
92
103
  next unless file
93
- data[ missing[file] ][-2,2] = mime.split( ?/ ) if missing[file] }
104
+ data[ missing[file] ][TYPE, 2] = mime.split( ?/ ) if missing[file] }
94
105
  end
95
106
  data.each { |row| row[0] = File.join(@directory, row[0]) }
96
107
  self << data
@@ -115,14 +126,14 @@ module Typr
115
126
  target = nil
116
127
  if File.symlink? file
117
128
  target = File.readlink( file ) rescue $!.to_s
118
- return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?"] unless File.exist?(file)
129
+ return [file, target, "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless File.exist?(file)
119
130
  end
120
131
  stat = File.stat(file) rescue nil
121
- return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?"] unless stat
132
+ return [file, "", "????", false, "?", "?", 0, 0, 0, 0, "?", "?", nil] unless stat
122
133
  [ file, target, stat.mode.to_s(8)[-3..-1].chars.map{|d|
123
134
  "rwx".chars.map.with_index{|c,i| d.to_i[2-i]==0 ? ?- : c } }.join,
124
135
  ( 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, "?", "?" ]
136
+ stat.atime.to_i, stat.mtime.to_i, stat.ctime.to_i, "?", "?", nil ]
126
137
  end
127
138
 
128
139
  ##
@@ -148,6 +159,10 @@ module Typr
148
159
  return unless view
149
160
  view = @views.keys[ view ] if view.is_a? Integer
150
161
  @sequence = @views[ view ].map{|title| @header.index title.to_s}
162
+ if @show_path
163
+ @data.each { |row| row[PATH] = File.dirname(row[NAME]) } if @data.any?
164
+ @sequence.insert(@sequence.index(NAME) || 0, PATH) unless @sequence.include?(PATH)
165
+ end
151
166
  reset :format
152
167
  @view = view
153
168
  return
@@ -160,6 +175,18 @@ module Typr
160
175
  # browser.send "1" # => row id if hint "1" is visible
161
176
 
162
177
  def send key
178
+ if key.is_a?(Typr::Mouse) and key.press? and key.left?
179
+ [@head, @user].each do |w|
180
+ next unless w.respond_to?(:data_at)
181
+ value = w.data_at(key.x, key.y)
182
+ next unless value
183
+ key = extract_key(value.to_s)
184
+ while key.is_a?(String)
185
+ key = send(key)
186
+ end
187
+ return key
188
+ end
189
+ end
163
190
  if key.is_a?(String) and
164
191
  id = @hints[0..[height, rows-1+headspace].min-@hints_start-1].index(key)
165
192
  return page[id+@hints_start]
@@ -303,14 +330,12 @@ module Typr
303
330
  return @directory
304
331
  end
305
332
  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
333
+ choice = send key
334
+ next if choice == :scroll or choice == :outside
311
335
  else
312
336
  next unless choice = send( key )
313
337
  end
338
+ next unless choice
314
339
  case choice
315
340
  when Array; choice = choice.map{ |id| @data[id][NAME] }.join('" "')
316
341
  when Integer; file = @data[ choice ]
@@ -414,7 +439,7 @@ module Typr
414
439
 
415
440
  def initialize args={}
416
441
  @mimetype_db, @mimetype_magic = true, true
417
- @view, @sort, @views, @positions, @show_hidden = :compact, 0, {}, {}, false
442
+ @view, @sort, @views, @positions, @show_hidden, @show_path = :compact, 0, {}, {}, false, false
418
443
  @filter_dirs = args[:filter_dirs]
419
444
  super
420
445
  @left ||= 0
@@ -442,7 +467,7 @@ module Typr
442
467
  toggle_hidden: ?H}.merge @keymap
443
468
 
444
469
  self.header = %w[ name target permissions executable owner group size
445
- accessed modified created type subtype ]
470
+ accessed modified created type subtype path ]
446
471
 
447
472
  @format[NAME] = :max
448
473
  @format[TYPE] = 5
@@ -475,6 +500,27 @@ module Typr
475
500
  colors: { columns: [@colors[:default], @colors[:default],
476
501
  @colors[:actions][:"[f]ilter"], @colors[:actions][:"[s]ort"],
477
502
  @colors[:actions][:"[v]iews"]] } )
503
+ @head.define_singleton_method( :data_at ) do |x, y|
504
+ return unless x and y and y.between?( top + 1, bottom + 1 ) and
505
+ x.between?( left, right )
506
+ offs = positions( 0 )
507
+ col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
508
+ @parent.instance_variable_get( :@positions ).keys[ col - 2 ] if col and col >= 2
509
+ end
510
+ @head.define_singleton_method( :path_at ) do |x, y|
511
+ return unless x and y and y.between?( top + 1, bottom + 1 )
512
+ x -= 1
513
+ return unless x.between?( left, right )
514
+ offs = positions( 0 )
515
+ col = offs.each_index.find { |i| x <= left + offs[i] + width_for( i ) - 1 }
516
+ return (Etc.getpwuid(Process.euid).dir rescue (Dir.home rescue "~")) if col == 0
517
+ return unless col == 1 and @parent.directory.is_a?( String )
518
+ text = ((@layer and @layer[0] and @layer[0][1]) || @data[0][1].call).to_s
519
+ rel = x - ( left + offs[1] )
520
+ return if rel < 0 or rel >= real_size( text )
521
+ pre = prepare( text, rel + 1 )
522
+ ( pre + ( pre.end_with?( "/" ) ? "" : text[pre.length..-1][/\A[^\/]*/] ) ).sub( %r{(?<=.)/$}, "" )
523
+ end
478
524
 
479
525
  @user ||= Line.new( parent:self, left:->{@parent.left},top:->{@parent.bottom+1},
480
526
  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
@@ -70,6 +76,7 @@ module Typr
70
76
  def left?; button == 0 end
71
77
  def middle?; button == 1 end
72
78
  def right?; button == 2 end
79
+ def alt?; ( modifiers & 8 ) != 0 end
73
80
  def to_s; "%s button=%i x=%i y=%i%s" % [ action, button, x, y,
74
81
  ( modifiers > 0 ? " modifiers=#{modifiers}" : "" ) ] end
75
82
  end
@@ -310,7 +317,7 @@ module Typr
310
317
  # nil
311
318
  # end
312
319
 
313
- def self.read_line prompt='', left: 0, top: 0, initial: '', &block
320
+ def self.read_line prompt='', left: 0, top: 0, initial: '', completions: nil, redraw: nil, &block
314
321
  return $stdin.gets&.chomp unless $stdin.tty?
315
322
  query, cursor = initial.dup, initial.length
316
323
  $>.print CURSOR_NORMAL
@@ -328,11 +335,15 @@ module Typr
328
335
  case key
329
336
  when KEY_LEFT, "\e[D"; cursor -= 1 if cursor > 0
330
337
  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
338
+ when KEY_ALT_LEFT, "\eD"; cursor = word_prev query, cursor
339
+ when KEY_ALT_RIGHT, "\eC"; cursor = word_next query, cursor
333
340
  when KEY_HOME; cursor = 0
334
341
  when KEY_END; cursor = query.length
335
342
  when KEY_DC, "\e[3~"; query.slice!(cursor, 1) if cursor < query.length
343
+ when KEY_ALT_BACKSPACE
344
+ old = cursor
345
+ cursor = word_prev query, cursor
346
+ query.slice!(cursor, old - cursor)
336
347
  when KEY_BACKSPACE, "\b"
337
348
  if cursor > 0
338
349
  query.slice!(cursor - 1, 1)
@@ -348,6 +359,35 @@ module Typr
348
359
  cursor = query.length if cursor > query.length
349
360
  result = block.call( key, query, cursor ) if block
350
361
  return result unless result.nil?
362
+ if completions and key == KEY_TAB
363
+ raw = query[0...cursor]
364
+ cmd = raw[/\w+/]
365
+ if cmd
366
+ items = IO.popen([{ 'COMP_LINE' => raw, 'COMP_POINT' => raw.bytesize.to_s },
367
+ 'bash', '-c', <<~SH], err: '/dev/null', &:read)
368
+ _init_completion(){ cur=${COMP_WORDS[COMP_CWORD]}; prev=${COMP_WORDS[COMP_CWORD-1]}; }
369
+ source /usr/share/bash-completion/completions/#{cmd} 2>/dev/null || exit
370
+ COMP_WORDS=($COMP_LINE) COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
371
+ Fn=$(complete -p #{cmd} 2>/dev/null | awk '{print $(NF-1)}')
372
+ eval "$Fn" 2>/dev/null && printf '%s\\n' "${COMPREPLY[@]}"
373
+ SH
374
+ items = items.split("\n").reject(&:empty?).uniq
375
+ if items.any?
376
+ grid = Grid.new(left: left, top: [top - items.size - 3, 0].max,
377
+ input: items.map{ |s| [s] }, border: :light,
378
+ colors: { columns: [[:blue, :default]] })
379
+ picked = grid.pick(:row, column: 0)
380
+ redraw&.call
381
+ $>.print CURSOR_NORMAL
382
+ if picked.is_a?(Integer)
383
+ word_start = cursor
384
+ word_start -= 1 while word_start > 0 and query[word_start - 1] != ' '
385
+ query[word_start...cursor] = items[picked]
386
+ cursor = word_start + items[picked].length
387
+ end
388
+ end
389
+ end
390
+ end
351
391
  end
352
392
  ensure
353
393
  $>.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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kilian Reitmayr