typr 1.2.0 → 1.3.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: 5a6c88b12b4be9c1fe406898172147d1d874e0353af72d211d2530ef5a48d82f
4
- data.tar.gz: 36dd949f072aa3ab232256d4c7d7c10d6989812c97977f65f72bd25ef7d6124a
3
+ metadata.gz: cdcaef4b1bc2c40e6fab6bd7d1462ddf19aeca03babcdbb9be74629551230647
4
+ data.tar.gz: b4e927b5237f27e2366ed15057054b33d9f070ef5a50c234533699182c7228ee
5
5
  SHA512:
6
- metadata.gz: 87a3993d064a814f0c819a5466c4245029d38aeabd3b6b869a9d5f2f505b60daa45aecea6447dac70cee577605e8ec1d66ee5ce858751d1122614905a9983610
7
- data.tar.gz: f29f2787e8cc2b606bfb215adebe21b1384bcc860b986318bfcff334105ebea72f30844124083dc6471c81a215698dbd3aa2fcfca9b63d92594c09ab89373bdf
6
+ metadata.gz: 889fd403e345e89dc6777c02c6736d0865b7266b16157e13e4cf2469b9a3664d68f4fe672d1f9b5b6d72316d697c2c8e20e1f920853ae495014c9fb049fed6cd
7
+ data.tar.gz: 662df0d3ef61d964cd8a03a1cd1f7caa7fb14a15d87c22afa3a2cdf9115f4780b944446ebcdc9f538c33ee80753e48c362fea7f716d2ef7fca2070f07fe5483f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [v1.3.0] — 2026-08-03
4
+
5
+ ### Added
6
+ - Mouse support — `Typr.init`/`Typr.exit` enable/disable mouse reporting (buttons + SGR coordinates, `CSI ? 1000 h/l` + `CSI ? 1006 h/l`); `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
7
+ - `Stack#hit` — maps a 1-based terminal row/col to the data row id under the cursor (nil for header/margins/outside)
8
+ - Click-to-act in pickers — a left click on a row in `Stack#pick`/`Browser#pick` acts like pressing its hint digit (selects the row; in `Browser` cd's into directories and returns file paths)
9
+ - Wheel-to-scroll in pickers — mouse wheel up/down scrolls the listing in `Stack#pick`/`Browser#pick`
10
+
3
11
  ## [v1.2.0] — 2026-08-02
4
12
 
5
13
  ### Added
data/README.md CHANGED
@@ -1,29 +1,44 @@
1
1
  # typr — Terminal UI Toolkit
2
2
 
3
- typr is a Ruby library for building interactive terminal-based user interfaces. It provides data-object based spaces and keyboard-driven interaction.
3
+ typr is a Ruby library for building interactive, keyboard-driven terminal user interfaces. It is built from data-object based components: grids for arrays, a pager for text, a file picker, and lines for user interaction — each of which renders itself, handles its own keymap, and can be combined into full applications.
4
+
5
+ typr is designed for speed: every interactive choice is one keystroke away. Selectable rows and columns carry number hints, popups open in `/` search mode so long lists can be distilled down and picked from in a couple of keystrokes. It is the toolkit behind the [Filecon](https://codeberg.org/typr/filecon) file manager.
6
+
7
+ typr's terminal layer is self-contained: it ships a precompiled terminfo key table and a MIME-type database inside the gem, so it has no ncurses dependency and works on any raw, ANSI-capable terminal — including Android/Termux.
4
8
 
5
9
  ## Features
6
10
 
7
- ### Spaces
11
+ ### Components
8
12
 
9
- - **Grid** — sortable, filterable table with formatted columns, colors, and row selection
10
- - **Text** — scrollable text viewer with word/line picking, search, and borders
11
- - **Browser** — file-system directory browser with MIME type detection
12
- - **Stack, Space** — layout containers for composing spaces
13
- - **Line** — interactive prompt/input with colored questions and answers
13
+ - **Grid** — sortable, filterable table with formatted columns, per-cell colors, and row selection. Columns can be `:min`/`:max`/fixed-width, with built-in processors for dates, file sizes, and file trees. `Grid#pick column:` adds live `/` search
14
+ - **Text** — scrollable text viewer with word/row picking, `/` search with `n`/`p` navigation and match highlighting, and borders
15
+ - **Browser** — file-system directory browser with MIME-type detection (bundled database, optional magic detection), per-type coloring, and an interactive picker
16
+ - **Line** — interactive prompt/input with colored questions and answers, configurable bindings, and strict `ask` question types (`Array`, `:key`, `:line`)
14
17
 
15
- ### Frontends
18
+ ### Keyboard interaction
16
19
 
17
- typr supports two rendering backends, selectable via `require`:
20
+ Navigation uses **hint numbers** displayed next to selectable items — rows in grids, words in text, and other elements. Press the number to select that item directly. When more items are visible than fit in one set of hints (1–9, 0), press **Tab** to cycle through hint groups. Arrow keys, Page Up/Down, Home/End work as expected, and all keys are dispatched through a configurable keymap.
18
21
 
19
- - **Terminal** (`require 'typr'`) raw terminal IO, no dependencies beyond Ruby stdlib
20
- - **Graphical** (`require 'typr/graphical'`) — SDL2-based, for desktop GUI windows
22
+ **Live `/` search** is built into listings and pickers: popups open in search mode, so you start typing to distill the list down to the matches, then press a number hint to pick from the few remaining results. `Text` adds less-style search (`/`, `n`, `p`) with smart-case matching and wrap-around.
21
23
 
22
- All spaces work identically across frontends.
24
+ ### Mouse support
23
25
 
24
- ### Keyboard interaction
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.
25
27
 
26
- Navigation uses **hint numbers** displayed next to selectable items — rows in grids, columns, or other elements. Press the number to select that item directly. When more items are visible than fit in one set of hints (1-9, 0), press **Tab** to cycle through hint groups. Arrow keys, Page Up/Down, Home/End work as expected. All keys are dispatched through a configurable keymap.
28
+ ### Terminal layer
29
+
30
+ The default terminal frontend is pure ANSI on raw TTY input — **no ncurses**. Key sequences and keypad escapes are precompiled into a bundled terminfo table (`share/terminfo`), and MIME types come from a bundled database (`share/mimetypes`); both ship with the gem, so typr runs anywhere a raw, ANSI-capable terminal exists. The only runtime dependency is `unicode-display_width`.
31
+
32
+ ### Colors
33
+
34
+ Named colors (basic, shades, and `dark_*`), grey shades `grey0`–`grey100`, the terminal's 8-bit palette, and RGB triplets for true-color terminals. Every component takes a per-part `colors:` hash (columns, headers, hints, search matches, …).
35
+
36
+ ### Frontends
37
+
38
+ - **Terminal** (`require 'typr'`) — the default: raw terminal IO, no ncurses, works over SSH and on Termux
39
+ - **Graphical** (`require 'typr/graphical'`) — experimental: SDL2-based windows
40
+
41
+ All components work identically across frontends.
27
42
 
28
43
  ## Installation
29
44
 
@@ -34,7 +49,7 @@ gem install typr
34
49
  Or in your Gemfile:
35
50
 
36
51
  ```ruby
37
- gem 'typr', '~> 1.0'
52
+ gem 'typr', '~> 1.2'
38
53
  ```
39
54
 
40
55
  ## Usage
@@ -59,8 +74,8 @@ Typr.init
59
74
 
60
75
  Typr.clear
61
76
  @grid.show
62
- key = Typr.read(:key) # reads a single keypress
63
- @grid.reset # reset grid to default view
77
+ key = Typr.read_key # reads a single keypress
78
+ @grid.reset # reset grid to default view
64
79
  Typr.exit
65
80
  ```
66
81
 
@@ -82,7 +97,7 @@ Typr.init
82
97
  Typr.clear
83
98
  loop do
84
99
  @text.show
85
- key = Typr.read(:key)
100
+ key = Typr.read_key
86
101
  case key
87
102
  when ?q then break
88
103
  else @text.send(key) # spacebar scrolls, j/k move up/down, etc.
@@ -104,7 +119,7 @@ Typr.init
104
119
  colors: { hints: [:white, :black] }
105
120
  )
106
121
 
107
- selected = @browser.pick("/home") # interactive file picker, returns path
122
+ selected = @browser.pick # interactive file picker, returns the absolute path
108
123
  puts "You selected: #{selected}"
109
124
  Typr.exit
110
125
  ```
@@ -140,7 +155,7 @@ class MyApp
140
155
  loop do
141
156
  @grid.show
142
157
  @prompt.show
143
- key = Typr.read(:key)
158
+ key = Typr.read_key
144
159
  case key
145
160
  when ?s then @grid.sort_by(@prompt.ask("sort column": [@grid, :column]))
146
161
  when ?f then @grid.add_filter(*@prompt.ask("filter column": [@grid, :column], with: :line))
@@ -163,31 +178,46 @@ end
163
178
  ## API Reference
164
179
 
165
180
  ### `Typr.init` / `Typr.exit`
166
- Initialize and tear down the terminal UI. Always call these at the start and end of your program.
181
+ Initialize and tear down the terminal UI (hides the cursor, enables key-mode and mouse reporting; restores them on exit). Always call these at the start and end of your program.
167
182
 
168
183
  ### `Typr.clear([scope])`
169
184
  Clears the full screen or a single line (`:line`).
170
185
 
171
- ### `Typr.read(type)`
172
- Reads input from the terminal. Types:
173
- - `:key` — a single keypress (returns `KEY_ESCAPE`, `?q`, etc.)
174
- - `:line` — a full line of text input
186
+ ### `Typr.read_key`
187
+ Reads a single keypress in raw mode. Returns the key as a String (`KEY_ESCAPE`, `?q`, arrow escape sequences), a `Typr::Mouse` struct for mouse reports, or `nil` when input is unavailable.
188
+
189
+ ### `Typr.read_line(prompt, ...)`
190
+ The shared interactive line editor. Renders a prompt and query at any position with arrow keys, ctrl-arrow word jumps, Home/End/Delete/Backspace; Enter confirms, Escape aborts (`nil`). An optional block runs after each keypress and may short-circuit with a result:
191
+
192
+ ```ruby
193
+ Typr.read_line "/" do |key, query, cursor|
194
+ filter query
195
+ nil
196
+ end
197
+ ```
198
+
199
+ ### `Line#ask`
200
+ Strict question types:
201
+ - `Array` — pick a row from an object/column (hint-driven)
202
+ - `:key` — read a single key
203
+ - `:line` — read a line of text
204
+
205
+ The question and answer are rendered in `@colors[:question]`/`@colors[:answer]`.
175
206
 
176
- ### Layout properties (all spaces)
207
+ ### Layout properties (all components)
177
208
  - `top`, `bottom`, `left`, `right` — placement. Integers for absolute offsets, floats (0-1) for relative sizing. Negative values position from the opposite edge. (`bottom: -1` = last row)
178
209
 
179
210
  ### Grid options
180
211
  | Option | Description |
181
212
  |---|---|
182
213
  | `input` | Array of arrays (data rows) |
183
- | `header` | Column titles array |
214
+ | `header` | Column titles array (symbols define `Typr::NAME`-style constants) |
184
215
  | `format` | Per-column format: `:min`, `:max`, or a number for fixed width |
216
+ | `procs` | Per-column processors (built-ins: `:datetime`, `:magnitudes`, `:filetree`, `:convert`) |
185
217
  | `colors` | `{ columns: [:yellow, :cyan], header: [:white, :black] }` etc. |
186
- | `sorted_by` | Column index the grid is sorted by (setter) |
187
- | `filters` | Active filters array (getter) |
188
- | `selected` | Selected row indices (getter/setter) |
189
- | `.sort_by` | Sort the grid on a given column |
190
- | `.select_rows / select_columns` | Multi-select via index picker |
218
+ | `.sort_by` | Sort the grid on a given column (toggles direction) |
219
+ | `.add_filter` | Add a substring/regex filter on a column (or `:all`) |
220
+ | `.pick :row, column:` | Interactive row picker with live `/` search and number hints |
191
221
 
192
222
  ### Text options
193
223
  | Option | Description |
@@ -195,12 +225,13 @@ Reads input from the terminal. Types:
195
225
  | `input` | String or IO-like stream to display |
196
226
  | `header` | Header bar text |
197
227
  | `border` | Border style: `:round`, `" "`, etc. |
228
+ | `.search` | Search the text (`/`); `.search_next`/`.search_prev` (`n`/`p`) |
198
229
 
199
230
  ### Browser options
200
231
  | Option | Description |
201
232
  |---|---|
202
233
  | `directory` | Root directory to start browsing |
203
- | `.pick(start_dir)` | Interactive file picker, returns the selected path |
234
+ | `.pick([type])` | Interactive file picker, returns the absolute path; `type` filters by MIME family (`"image"`, `"audio"`, `"video"`, `"text"`, `"application"`, `"inode"`) |
204
235
 
205
236
  ## License
206
237
 
data/lib/browser.rb CHANGED
@@ -216,12 +216,7 @@ module Typr
216
216
  def help; @user.help end
217
217
 
218
218
  ##
219
- # Recurse through the directory tree, adding subdirectory entries inline
220
- # via +cd(path, true)+. Sorts by name with the filetree processor.
221
- #
222
- # browser.recurse # flattens tree into leaf names
223
-
224
- ## Reset display, selection, and position state then re-sort.
219
+ # Reset display, selection, and position state then re-sort.
225
220
  #
226
221
  # browser.reset_view
227
222
 
@@ -231,11 +226,13 @@ module Typr
231
226
  #
232
227
  # browser.filter_view # prompts for filter text
233
228
 
234
- def search; pick :none, 0, column: NAME end
235
-
236
229
  def filter_view; add_filter *@user.ask(
237
230
  filter: [ popup( type: :"[f]ilter" ), :row, NAME], with: :line ) end
238
231
 
232
+ ## Open live `/` search filtered on the name column.
233
+ #
234
+ # browser.search # type to narrow, Escape restores, Enter commits
235
+
239
236
  def search
240
237
  @suppress_user = true
241
238
  pick :none, column: NAME
@@ -262,12 +259,14 @@ module Typr
262
259
 
263
260
  ##
264
261
  # Interactive file/directory picker. Blocks until a selection or Escape.
265
- # Return on a file wraps the path in single quotes. Enter on a directory
266
- # triggers cd. +type+ optionally filters by MIME family (audio, video,
267
- # image, text, application, inode).
262
+ # Return on a file yields its raw absolute path; multi-select joins the
263
+ # chosen paths double-quoted with spaces. Enter on a directory triggers
264
+ # cd. +type+ optionally filters by MIME family (audio, video, image,
265
+ # text, application, inode). Mouse: left-click a row to pick it; wheel
266
+ # up/down scrolls the listing.
268
267
  #
269
- # browser.pick # => '/home/user/file.txt'
270
- # browser.pick 'image' # => '/path/to/photo.png' (images only)
268
+ # browser.pick # => /home/user/file.txt
269
+ # browser.pick 'image' # => /path/to/photo.png (images only)
271
270
  # browser.pick nil # => anything, same as 'file'
272
271
 
273
272
  def pick type='file', **kw
@@ -284,7 +283,15 @@ module Typr
284
283
  reset :display
285
284
  return
286
285
  end
287
- next unless choice = send( key )
286
+ 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
292
+ else
293
+ next unless choice = send( key )
294
+ end
288
295
  case choice
289
296
  when Array; choice = choice.map{ |id| @data[id][NAME] }.join('" "')
290
297
  when Integer; file = @data[ choice ]
@@ -303,7 +310,7 @@ module Typr
303
310
  # browser.show # shows browser with info bar
304
311
 
305
312
  def show; super
306
- user = ENV["USER"] || Etc.getlogin
313
+ user = Etc.getpwuid(Process.euid).name rescue ENV["USER"] || Etc.getlogin
307
314
  @head.colors[:columns][0] = user == "root" ? [:red, :black] : [:green, :black]
308
315
  @head.reset :format
309
316
  @head.show
@@ -440,7 +447,7 @@ module Typr
440
447
  @parent = self
441
448
  @head ||= Grid.new( parent:self, left: ->{@parent.left}, margin: '',
442
449
  right: ->{@parent.width + @parent.left - 1}, top: ->{@parent.top-1},
443
- input: [ [->{ENV["USER"] || Etc.getlogin}, ->{@parent.directory},
450
+ input: [ [->{Etc.getpwuid(Process.euid).name rescue ENV["USER"] || Etc.getlogin}, ->{@parent.directory},
444
451
  ->{@parent.filter_display},
445
452
  ->{ (s = @parent.sorted_by(true)) &&
446
453
  "#{s} #{@parent.reverse ? "\u2193" : "\u2191"}"},
data/lib/space.rb CHANGED
@@ -29,6 +29,7 @@ include Typr
29
29
  STR
30
30
  eval( method % ([name]*11) ) }
31
31
 
32
+ # Deep-convert String keys to Symbols so string-keyed config (e.g. YAML) is honored.
32
33
  def symbolize obj
33
34
  case obj
34
35
  when Hash
@@ -40,17 +41,23 @@ eval( method % ([name]*11) ) }
40
41
  attr_writer :left, :top, :right, :bottom
41
42
  attr_accessor :margin, :colors, :interval, :borders
42
43
 
44
+ # Viewport width in cells (right - left + 1).
43
45
  def width; right - left + 1 end
46
+ # Viewport height in cells (bottom - top + 1).
44
47
  def height; bottom - top + 1 end
45
48
 
49
+ # Redraw every +interval+ seconds from a background thread.
46
50
  def start; @updater = Thread.new{ loop{show; sleep @interval }} end
51
+ # Stop the background refresh thread.
47
52
  def stop; @updater.terminate end
48
53
 
54
+ # Draw a border char at screen (x, y); skips nil and off-screen positions.
49
55
  def draw_border x,y,char
50
56
  draw move_code(x, y) + char unless not char or
51
57
  x < 0 or x > Typr.width or y < 0 or y > Typr.height
52
58
  end
53
59
 
60
+ # Draw the widget's border box (no-op when no borders are set).
54
61
  def show
55
62
  return if @borders.empty?
56
63
  color @colors[:border]
@@ -66,6 +73,8 @@ eval( method % ([name]*11) ) }
66
73
  draw_border(right + 1, bottom + 1, @borders[:bottom_right])
67
74
  end
68
75
 
76
+ # Set border style: :light/:heavy/:double/:round, a char repeated across
77
+ # the box, or nil for none.
69
78
  def border=(border)
70
79
  chars = case border
71
80
  when Symbol
@@ -78,6 +87,8 @@ eval( method % ([name]*11) ) }
78
87
  bottom_right ].map.with_index{ |part,id| [part.to_sym, chars[id]] }.to_h if chars
79
88
  end
80
89
 
90
+ # Build a widget from boundary (left/top/right/bottom), border, margin,
91
+ # colors, interval, and keymap.
81
92
  def initialize args={}
82
93
  @max, @keymap, @colors, @margin = 0, {}, {}, ' '
83
94
  @left, @top, @interval, @borders = 0, 0, 1, {}
data/lib/stack.rb CHANGED
@@ -38,6 +38,20 @@ module Typr
38
38
 
39
39
  def headspace; @header ? 1 : 0 end
40
40
 
41
+ ##
42
+ # Map a mouse click at 1-based terminal (row, col) to a data row id on
43
+ # the current page, or nil when the click lands outside the widget (border,
44
+ # header, margins). Row ids match what hint presses return from `pick`.
45
+ #
46
+ # stack.hit 3, 5 # => 0 (row 3, col 5 hits data row 0)
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)
52
+ page.to_a[rel]
53
+ end
54
+
41
55
  ##
42
56
  # The usable height after subtracting any header space.
43
57
  #
@@ -193,6 +207,8 @@ module Typr
193
207
  # stack.pick # single row picker
194
208
  # stack.pick :column, 3 # pick a column in row 3
195
209
  # stack.pick "rows" # multi-select rows
210
+ #
211
+ # Mouse: left-click a row to pick it; wheel up/down scrolls the page.
196
212
 
197
213
  def pick type = :row, row=0 #, key=nil
198
214
  type = type.to_s
@@ -207,7 +223,15 @@ module Typr
207
223
  draw_hints type, row unless type['none']
208
224
  limit = type['row'] ? height : positions(row).count
209
225
  key = Typr.read_key
210
- if key.is_a?(String) and value =
226
+ 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 =
211
235
  @hints[0..limit-@hints_start-1].index(key)
212
236
  value += @hints_start
213
237
  relative = value + @start if type['relative_']
data/lib/terminal.rb CHANGED
@@ -10,22 +10,70 @@ module Typr
10
10
  # Raw /dev/tty used for key input; falls back to $stdin when unavailable.
11
11
  INPUT = (IO.new IO.sysopen("/dev/tty", "r")) rescue $stdin
12
12
 
13
- # KEY_* escape sequences come from the terminfo database (infocmp -L1).
14
- `infocmp -L1`.split.each{ |info| key,value = info[0..-2].split("=")
15
- if value
16
- value.gsub! '\E', '\e'
17
- value += "\\" if value[-1] == "\\"
18
- eval "%s=\"%s\"" % [key.upcase,value] if key
19
- end }
13
+ # KEY_* escapes and keypad sequences are precompiled into share/terminfo by
14
+ # bin/build_terminfo, so the runtime needs no ncurses/infocmp (e.g. Termux).
15
+ TERMINFO = begin
16
+ eval File.read(File.expand_path("../share/terminfo", __dir__))
17
+ rescue StandardError
18
+ {}
19
+ end
20
+ # Known-good fallback so KEY_* are always defined, even for TERM=dumb.
21
+ DEFAULT_KEYS = { "key_up" => "\eOA", "key_down" => "\eOB", "key_left" => "\eOD",
22
+ "key_right" => "\eOC", "key_home" => "\eOH", "key_end" => "\eOF",
23
+ "key_backspace" => "\x7f", "key_dc" => "\e[3~", "key_npage" => "\e[6~",
24
+ "key_ppage" => "\e[5~", "carriage_return" => "\r",
25
+ "keypad_xmit" => "\e[?1h\e=", "keypad_local" => "\e[?1l\e>",
26
+ "cursor_invisible" => "\e[?25l", "cursor_normal" => "\e[?25h",
27
+ "clear_screen" => "\e[H\e[2J", "user7" => "\e[6n" }
28
+ term = ENV["TERM"].to_s
29
+ entry = nil
30
+ [ term, term.sub(/-.*/, ""), "xterm" ].each do |name|
31
+ found = TERMINFO[name]
32
+ if found && found.any? { |cap, _| cap.start_with?("key_") }
33
+ entry = found
34
+ break
35
+ end
36
+ end
37
+ entry ||= {}
38
+ # Fill gaps with safe ANSI defaults, but never fabricate keypad modes:
39
+ # those must match the terminal exactly or arrows get misread.
40
+ DEFAULT_KEYS.each do |cap, seq|
41
+ next if cap.start_with?("keypad_") && !entry[cap]
42
+ const_set cap.upcase, entry[cap] || seq
43
+ end
20
44
 
21
45
  # Common key aliases and the line-erase escape sequence.
22
46
  ERASE_LINE = "\e[K"
47
+ # Reset foreground/background to the terminal defaults.
48
+ ORIG_COLORS = "\e[39;49m"
23
49
  KEY_ESCAPE = "\e"
24
50
  KEY_RETURN = CARRIAGE_RETURN
25
51
  KEY_TAB = "\t"
26
52
  KEY_PAGEDOWN = KEY_NPAGE
27
53
  KEY_PAGEUP = KEY_PPAGE
28
54
 
55
+ # Enable/disable mouse reporting. Universal private modes, independent of
56
+ # terminfo: 1000 = button press/release/wheel, 1006 = SGR coordinates.
57
+ MOUSE_ON = "\e[?1000h\e[?1006h"
58
+ MOUSE_OFF = "\e[?1000l\e[?1006l"
59
+
60
+ # A mouse event decoded from the input stream. +x+ / +y+ are 1-based
61
+ # terminal coordinates; +button+ is 0=left, 1=middle, 2=right, or 4-7 for
62
+ # the wheel (up/down/left/right); +modifiers+ holds the SGR shift/alt/ctrl
63
+ # bits. Non-widget code can test `key.is_a?(Typr::Mouse)`.
64
+ Mouse = Struct.new(:button, :x, :y, :action, :modifiers) do
65
+ def press?; action == :press end
66
+ def release?; action == :release end
67
+ def wheel?; button.between?(4, 7) end
68
+ def wheel_up?; button == 4 end
69
+ def wheel_down?; button == 5 end
70
+ def left?; button == 0 end
71
+ def middle?; button == 1 end
72
+ def right?; button == 2 end
73
+ def to_s; "%s button=%i x=%i y=%i%s" % [ action, button, x, y,
74
+ ( modifiers > 0 ? " modifiers=#{modifiers}" : "" ) ] end
75
+ end
76
+
29
77
  # Text attributes (reset, bold, italic, ...) and named 8/256-color tables.
30
78
  MODES = %i[ reset bold italic underline slow fast invert ]
31
79
  COLORS = %i[ black red green yellow blue magenta cyan white ]
@@ -196,11 +244,47 @@ module Typr
196
244
  ## Reads a single keypress in raw mode.
197
245
  #
198
246
  # Returns the key sequence as a String (e.g. "a" or "\e[A" for up-arrow),
199
- # or nil when stdin is not a tty or input is unavailable.
247
+ # or a Typr::Mouse for a mouse report, or nil when stdin is not a tty or
248
+ # input is unavailable.
200
249
 
201
250
  def self.read_key
202
- return INPUT.raw{ |tty| tty.sysread 6 } rescue nil unless INPUT.tty?
203
- INPUT.raw{ |tty| tty.sysread 6 } rescue return
251
+ read = ->(tty) do
252
+ str = tty.sysread 6
253
+ if str.start_with?("\e[<")
254
+ # SGR mouse reports (CSI < b ; x ; y M/m) can exceed six bytes.
255
+ 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)
258
+ end
259
+ str
260
+ end
261
+ raw = (INPUT.raw{ |tty| read.call tty } rescue nil)
262
+ return unless raw
263
+ decode_mouse(raw) || raw
264
+ end
265
+
266
+ ## Decode a raw input string into a {Typr::Mouse} event, or nil when +raw+
267
+ # is not a mouse report. Handles both SGR (CSI < b ; x ; y M/m) and the
268
+ # legacy X10 (CSI M + three bytes) encodings.
269
+
270
+ def self.decode_mouse raw
271
+ if (m = raw.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/))
272
+ code, x, y, final = m[1].to_i, m[2].to_i, m[3].to_i, m[4]
273
+ elsif raw.start_with?("\e[M")
274
+ code, x, y = raw.getbyte(3) - 32, raw.getbyte(4) - 32, raw.getbyte(5) - 32
275
+ final = code == 3 ? ?m : ?M
276
+ else
277
+ return nil
278
+ end
279
+ modifiers = code & 28
280
+ base = code & ~28
281
+ motion = (base & 32) != 0
282
+ wheel = base >= 64
283
+ button = wheel ? base - 64 + 4 : base & 3
284
+ action = if motion then :motion
285
+ elsif wheel then final == ?m ? :release : :press
286
+ else ( final == ?m or code == 3 ) ? :release : :press end
287
+ Mouse.new button, x, y, action, modifiers
204
288
  end
205
289
 
206
290
  ## Interactive line editor used for string prompts (search, %str, ...).
@@ -303,21 +387,25 @@ module Typr
303
387
  def self.on_resize &block
304
388
  trap(:WINCH, &block)
305
389
  end
306
- # Enter interactive mode: seed the default colors, hide the cursor and
307
- # enable key-mode (application) escapes when stdin is a tty.
390
+ # Enter interactive mode: seed the default colors, hide the cursor, enable
391
+ # key-mode (application) escapes and mouse reporting when stdin is a tty.
308
392
  def self.init default=[ :white, :black ]
309
393
  $default = default.dup
310
394
  $color = $default.dup
311
395
  if $stdin.tty?
312
396
  print CURSOR_INVISIBLE
313
- system "tput smkx"
397
+ print KEYPAD_XMIT if defined?(KEYPAD_XMIT)
398
+ print MOUSE_ON
314
399
  end
315
400
  end
316
- # Restore the terminal: show the cursor, reset colors and clear the screen.
401
+ # Restore the terminal: show the cursor, reset keypad/colors, disable mouse
402
+ # reporting and clear.
317
403
  def self.exit;
318
404
  extend self
319
405
  if $stdin.tty?
320
406
  $>.print CURSOR_NORMAL;
407
+ $>.print KEYPAD_LOCAL if defined?(KEYPAD_LOCAL)
408
+ $>.print MOUSE_OFF
321
409
  $>.print ORIG_COLORS; color; clear
322
410
  end
323
411
  end
data/lib/text.rb CHANGED
@@ -191,6 +191,8 @@ module Typr
191
191
  self << @input
192
192
  end
193
193
 
194
+ # Reset state: +:search+ clears the pattern and highlights; +:all+ resets
195
+ # position, selection, and search; else falls through to {Stack#reset}.
194
196
  def reset type=:all
195
197
  case type
196
198
  when :search; highlight_clear
@@ -201,10 +203,13 @@ module Typr
201
203
 
202
204
  private
203
205
 
206
+ # Re-apply search for a live query (empty clears the highlights).
204
207
  def live_search query
205
208
  query.empty? ? highlight_clear : search(query)
206
209
  end
207
210
 
211
+ # Move to the next/previous match from the current position; returns the
212
+ # matched line id or nil when there are none.
208
213
  def step
209
214
  matches = @search[:matches]
210
215
  return nil if matches.empty?
@@ -221,6 +226,8 @@ module Typr
221
226
  return id
222
227
  end
223
228
 
229
+ # Interactive `/` prompt via Typr.read_line; Escape restores the previous
230
+ # search state, Enter searches (empty restores the last pattern).
224
231
  def search_prompt
225
232
  query = @search[:pattern].is_a?(String) ? @search[:pattern].dup : ''
226
233
  saved = { re: @re, pattern: @search[:pattern],
data/lib/typr.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # typr is a Ruby library for building interactive terminal-based user interfaces.
2
2
  # It provides layout primitives, widgets, and event-driven interaction.
3
3
  #
4
- # == Widgets
4
+ # == Classes
5
5
  #
6
6
  # * Typr::Grid - Sortable, filterable table with formatted columns and row selection
7
7
  # * Typr::Text - Scrollable text viewer with word/line picking and search
@@ -12,7 +12,7 @@
12
12
  #
13
13
  # == Terminal Control
14
14
  #
15
- # Typr.init # Initialize terminal (hide cursor, enable key mode)
15
+ # Typr.init # Initialize terminal (hide cursor, enable key mode and mouse reporting)
16
16
  # Typr.clear # Clear entire screen
17
17
  # Typr.clear :line # Clear current line only
18
18
  # key = Typr.read_key # Read a single keypress
@@ -21,7 +21,7 @@
21
21
  #
22
22
  # == Layout Properties
23
23
  #
24
- # All widgets accept +left+, +top+, +right+, +bottom+ as:
24
+ # All classes accept +left+, +top+, +right+, +bottom+ as:
25
25
  # Integer - absolute position
26
26
  # Float - fraction of terminal (0.0..1.0)
27
27
  # Proc - evaluated each layout pass