tui_tui 0.2.0 → 0.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: 499d4c344b19e2fca357f58d50471d80033b99b5fb94f32943f70383dd4eceaa
4
- data.tar.gz: 31b39eb3d5d3de1361a9f3679277f4ea859b0fc5b7a5bd9a78bb6d69e250adf7
3
+ metadata.gz: 509e482a4d22483da9840ee1ea39021f27f747f2c6919d7782e46f41dbfdfea0
4
+ data.tar.gz: 731b150add99bd21eafef87e2a33c36679eb5998f01a78bf317217a1c7ee428a
5
5
  SHA512:
6
- metadata.gz: 74c01a1dc4fc3d95568bb9c4fde27bb43b6047f2ea17547342df2ec051e159a6b8aabaa7380039d87aa763dea0d61c24e0e7736ce9d9b29091dabe78cec041ab
7
- data.tar.gz: 5241bf9a256248c5da1430f67068d74639dfd44fce1be034b20736f4f55d0334ab4ad296a232a9433b2d53ba9c2054a60c509b9ee765d667e74d67fe9915c6cf
6
+ metadata.gz: '068472d5d8dd0c5ae371ffe2516bbe1f1a548d3d55c97e85b898e821640ed6a64b0e8005fa3e8c219c20cfae79e1e86efafbb2f851f7dbbc6830e5ec2e59c455'
7
+ data.tar.gz: 37e91b9643cc30df3a887e90004859cc37035c52882f353a66148fce585d47c1ba1075af142650fad597e3d34652b9a1983a11f2c19019163740920cff4afadf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-06-22
4
+
5
+ ### Added
6
+ - `Pager` accepts styled lines for coloring: each line may be a plain `String`,
7
+ a `Line`, or an array of `Span`s, so a log / diff / error view can color whole
8
+ lines or runs within them. Unstyled spans fall back to `theme.muted`.
9
+ - `Line.coerce(content, style = nil)`: shared `String` / `Span` / `Span`-array /
10
+ `Line` → `Line` conversion, used by `List`, `TextView`, and `Pager`.
11
+ - `CommandPalette`: a fuzzy-filtered command palette modal (type to narrow,
12
+ arrows or Ctrl-N/Ctrl-P to move, Enter to pick, Esc to cancel). Items are
13
+ arbitrary objects with an optional label block; resolves to the chosen item.
14
+ - Per-frame mouse-reporting toggle: an app may implement `wants_mouse?` and the
15
+ `Runtime` applies it each frame (via `Screen#mouse=` / `TerminalSession#mouse=`),
16
+ so it can release the mouse for a native terminal selection and recapture it.
17
+
18
+ ### Fixed
19
+ - Raw mode now keeps the interrupt/quit/suspend characters live (`raw!(intr:
20
+ true)`), so Ctrl-C raises `SIGINT` (a real force-quit, restored by the INT
21
+ trap) instead of arriving as a byte.
22
+
3
23
  ## [0.2.0] - 2026-06-17
4
24
 
5
25
  ### Added
@@ -10,11 +10,11 @@
10
10
  #
11
11
  # Keys: j/k (or ↑/↓) move, l/Enter/→ open dir, h/←/Backspace up, g/G top/bottom,
12
12
  # Tab switch pane, J/K (or mouse wheel) scroll the preview, w wrap, t theme, </> divider, / fuzzy find,
13
- # y copy the path (OSC 52), m actions menu, ? help, q (or Ctrl-C) quit.
13
+ # y copy the path (OSC 52), : command palette, m actions menu, ? help, q (or Ctrl-C) quit.
14
14
  #
15
15
  # `/` is an incremental fuzzy finder built on TuiTui::Fuzzy (type to narrow,
16
16
  # matched characters highlighted, ↑↓ to navigate, Enter to open, Esc to cancel).
17
- # The m / ? / q modals are TuiTui widgets (Select, Help, Confirm).
17
+ # The : / m / ? / q modals are TuiTui widgets (CommandPalette, Select, Help, Confirm).
18
18
 
19
19
  require "strscan"
20
20
  require_relative "../lib/tui_tui"
@@ -177,6 +177,7 @@ module FileBrowserSample
177
177
  ["t", "cycle theme (cool / warm / mono, follows light/dark)"],
178
178
  ["/", "fuzzy find (↑↓ navigate, Enter open, Esc cancel)"],
179
179
  ["y", "copy path to clipboard"],
180
+ [":", "command palette (fuzzy-run any command)"],
180
181
  ["m", "actions menu"],
181
182
  ["?", "this help"],
182
183
  ["q", "quit"],
@@ -184,6 +185,20 @@ module FileBrowserSample
184
185
 
185
186
  ACTIONS = [["Up to parent", :parent], ["Refresh", :refresh], ["Quit", :quit]].freeze
186
187
 
188
+ # Commands surfaced in the ":" command palette. Each is [label, action]; the
189
+ # palette ranks by the label and resolves to the chosen pair (see run_command).
190
+ COMMANDS = [
191
+ ["Open selected entry", :open],
192
+ ["Up to parent directory", :parent],
193
+ ["Refresh listing", :refresh],
194
+ ["Toggle preview wrap", :wrap],
195
+ ["Cycle theme", :theme],
196
+ ["Copy path to clipboard", :copy],
197
+ ["Fuzzy find", :find],
198
+ ["Keyboard help", :help],
199
+ ["Quit", :quit],
200
+ ].freeze
201
+
187
202
  # The app: responds to view(size) -> Canvas and update(event) -> self | :quit,
188
203
  # which is all TuiTui::Runtime asks of it.
189
204
  class Browser
@@ -348,13 +363,39 @@ module FileBrowserSample
348
363
  end
349
364
  end
350
365
 
366
+ def open_help = open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
367
+
368
+ # The ":" command palette: a fuzzy-filtered list of every command. The palette
369
+ # ranks by the label and resolves to the chosen [label, action] pair (or
370
+ # :cancel on Esc), which run_command dispatches.
371
+ def open_palette
372
+ open_modal(TuiTui::CommandPalette.new(COMMANDS, theme: @theme) { |label, _action| label }) do |chosen|
373
+ run_command(chosen.last) if chosen.is_a?(Array)
374
+ end
375
+ end
376
+
377
+ def run_command(action)
378
+ case action
379
+ when :open then open_entry
380
+ when :parent then up_dir
381
+ when :refresh then load_entries
382
+ when :wrap then toggle_preview_wrap
383
+ when :theme then cycle_theme
384
+ when :copy then copy_path
385
+ when :find then enter_finder
386
+ when :help then open_help # palettes can chain into another modal
387
+ when :quit then :quit
388
+ end
389
+ end
390
+
351
391
  # --- input ---
352
392
 
353
393
  def handle_key(key)
354
394
  case key
355
395
  when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
356
- when "?" then open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
396
+ when "?" then open_help
357
397
  when "/" then enter_finder
398
+ when ":" then open_palette
358
399
  when "m" then open_actions
359
400
  when "l", "\r", :right then open_entry
360
401
  when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
@@ -631,7 +672,7 @@ module FileBrowserSample
631
672
 
632
673
  def draw_status(canvas, rect)
633
674
  left = @finder ? " > #{@finder}" : " #{@dir}"
634
- hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find m=menu t=#{THEMES[@theme_i]} q=quit"
675
+ hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find :=cmds m=menu t=#{THEMES[@theme_i]} q=quit"
635
676
  right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
636
677
  TuiTui::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
637
678
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "box_chrome"
4
+ require_relative "clock"
4
5
 
5
6
  module TuiTui
6
7
  # Measures how many columns a string of box-drawing glyphs actually occupies on
@@ -35,10 +36,10 @@ module TuiTui
35
36
  end
36
37
 
37
38
  def read_column(input)
38
- deadline = monotonic + @timeout
39
+ deadline = Clock.monotonic + @timeout
39
40
  buf = +""
40
41
  loop do
41
- remaining = deadline - monotonic
42
+ remaining = deadline - Clock.monotonic
42
43
  break if remaining <= 0
43
44
  break unless @wait.call(input, remaining)
44
45
 
@@ -55,7 +56,5 @@ module TuiTui
55
56
  end
56
57
 
57
58
  def wait_readable(io, timeout) = io.wait_readable(timeout)
58
-
59
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
59
  end
61
60
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # The single source of monotonic time, so timers and timeouts never depend on
5
+ # wall-clock adjustments. Injected as a callable where tests need to control it.
6
+ module Clock
7
+ module_function
8
+
9
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "text_sanitizer"
5
+ require_relative "style"
6
+ require_relative "scroll_list"
7
+ require_relative "list"
8
+ require_relative "line"
9
+ require_relative "span"
10
+ require_relative "rect"
11
+ require_relative "modal"
12
+ require_relative "fuzzy"
13
+ require_relative "key_code"
14
+
15
+ module TuiTui
16
+ # Fuzzy-filtered command palette modal (think Ctrl-P): type to narrow a list of
17
+ # commands, arrows or Ctrl-N/Ctrl-P to move, Enter to pick, Esc to cancel.
18
+ #
19
+ # Items are arbitrary objects; pass a block to derive each one's display label
20
+ # (defaults to #to_s). Resolves to the chosen item on Enter and :cancel on
21
+ # escape; stays open (nil) while the query has no matches.
22
+ #
23
+ # host.open(CommandPalette.new(commands) { |c| c.title }) { |cmd| cmd.run; self }
24
+ class CommandPalette < Modal
25
+ MAX_ROWS = 10
26
+ MIN_INNER = 28
27
+ WHEEL = 3
28
+
29
+ def initialize(items, prompt: "> ", placeholder: "Type to search…", theme: Theme::DEFAULT, &label)
30
+ @items = items.to_a
31
+ @label = label || :to_s.to_proc
32
+ @prompt = DisplayText.new(prompt)
33
+ @placeholder = DisplayText.new(placeholder)
34
+ @theme = theme
35
+ @graphemes = []
36
+ @list = ScrollList.new(0)
37
+ refilter
38
+ end
39
+
40
+ def query = @graphemes.join
41
+
42
+ # The original item under the cursor, or nil when nothing matches.
43
+ def selection = @filtered[@list.cursor]&.first
44
+
45
+ def handle(key)
46
+ case key
47
+ when "\r"
48
+ selection
49
+ when :escape, KeyCode::CTRL_C
50
+ :cancel
51
+ when :up, KeyCode::CTRL_P
52
+ move(-1)
53
+ when :down, KeyCode::CTRL_N
54
+ move(1)
55
+ when :home
56
+ move_to(0)
57
+ when :end
58
+ move_to(@list.last)
59
+ when KeyCode::BACKSPACE, :backspace
60
+ edit { @graphemes.pop }
61
+ when String
62
+ edit { @graphemes.concat(key.grapheme_clusters) if TextSanitizer.printable?(key) }
63
+ end
64
+ end
65
+
66
+ # Wheel scrolls the highlight; a click on a row picks it (returns the item),
67
+ # otherwise nil to stay open.
68
+ def handle_mouse(event)
69
+ case event.action
70
+ when :wheel
71
+ move(event.button == :wheel_up ? -WHEEL : WHEEL)
72
+ when :press
73
+ click(event)
74
+ end
75
+ end
76
+
77
+ def draw(canvas, size)
78
+ rows = visible_rows(size)
79
+ inner = [MIN_INNER, *@filtered.map { |_item, label, _pos| label.width }].max
80
+ rect, col = panel(canvas, inner: inner, body_rows: rows + 2)
81
+
82
+ draw_query(canvas, rect.row + 1, col, inner)
83
+ draw_items(canvas, rect.row + 3, col, inner, rows)
84
+ canvas
85
+ end
86
+
87
+ private
88
+
89
+ def move(delta)
90
+ @list.move(delta)
91
+ nil
92
+ end
93
+
94
+ def move_to(index)
95
+ @list.go_to(index)
96
+ nil
97
+ end
98
+
99
+ # Apply a query edit, then refilter. Returns nil so the modal stays open.
100
+ def edit
101
+ yield
102
+ refilter
103
+ nil
104
+ end
105
+
106
+ # Recompute the visible list: fuzzy-ranked (best first, with matched positions
107
+ # for highlighting) while querying, otherwise the items in their given order.
108
+ # Each entry is [item, DisplayText(label), positions]; the cursor resets so a
109
+ # narrowed query always lands on the top match.
110
+ def refilter
111
+ @filtered =
112
+ if @graphemes.empty?
113
+ @items.map { |item| [item, label_text(item), []] }
114
+ else
115
+ Fuzzy.new(query).rank(@items) { |item| @label.call(item).to_s }
116
+ .map { |item, found| [item, label_text(item), found.positions] }
117
+ end
118
+ @list.count = @filtered.size
119
+ @list.go_to(0)
120
+ end
121
+
122
+ def label_text(item) = DisplayText.new(@label.call(item).to_s)
123
+
124
+ def visible_rows(size)
125
+ room = [size.rows - 4, 1].max
126
+ [[@filtered.size, 1].max, MAX_ROWS, room].min
127
+ end
128
+
129
+ def draw_query(canvas, row, col, inner)
130
+ canvas.text(row, col, @prompt, theme.accent)
131
+ text_col = col + @prompt.width
132
+ budget = inner - @prompt.width
133
+ if @graphemes.empty?
134
+ canvas.text(row, text_col, @placeholder.truncate(budget), theme.muted)
135
+ else
136
+ canvas.text(row, text_col, DisplayText.new(query).truncate(budget), theme.text)
137
+ end
138
+ end
139
+
140
+ def draw_items(canvas, row, col, inner, rows)
141
+ @items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
142
+ if @filtered.empty?
143
+ canvas.text(row, col, DisplayText.new("No matches").truncate(inner), theme.muted)
144
+ return
145
+ end
146
+
147
+ List.new(@list).draw(canvas, @items_rect, highlight: theme.selection) do |index, focused|
148
+ _item, label, positions = @filtered[index]
149
+ base = focused ? theme.selection : theme.text
150
+ # Keep the focused row a single style; highlight matches with accent only
151
+ # on unfocused rows so the selection bar stays legible.
152
+ match = focused ? base : theme.accent
153
+ styled_line(label.to_s, positions, base, match)
154
+ end
155
+ end
156
+
157
+ # The label as a Line with matched graphemes in `match` and the rest in `base`;
158
+ # runs of the same style coalesce into one Span (grapheme indices line up with
159
+ # Fuzzy#positions).
160
+ def styled_line(label, positions, base, match)
161
+ return Line[Span[label, base]] if positions.empty?
162
+
163
+ spans = []
164
+ run = +""
165
+ run_style = nil
166
+ label.grapheme_clusters.each_with_index do |grapheme, i|
167
+ style = positions.include?(i) ? match : base
168
+ if style != run_style && !run.empty?
169
+ spans << Span[run, run_style]
170
+ run = +""
171
+ end
172
+ run_style = style
173
+ run << grapheme
174
+ end
175
+ spans << Span[run, run_style] unless run.empty?
176
+ Line.new(spans)
177
+ end
178
+
179
+ # The item under a click, picked, or nil if the click missed the list.
180
+ def click(event)
181
+ return nil unless @items_rect
182
+
183
+ index = List.new(@list).index_at(@items_rect, event)
184
+ return nil if index.nil?
185
+
186
+ @list.go_to(index)
187
+ selection
188
+ end
189
+ end
190
+ end
@@ -4,6 +4,9 @@ module TuiTui
4
4
  module KeyCode
5
5
  ESCAPE = "\e"
6
6
  CTRL_C = "\u0003"
7
+ CTRL_L = "\u000C"
8
+ CTRL_N = "\u000E"
9
+ CTRL_P = "\u0010"
7
10
  BACKSPACE = "\u007F"
8
11
  end
9
12
  end
data/lib/tui_tui/line.rb CHANGED
@@ -10,6 +10,17 @@ module TuiTui
10
10
  # Convenience constructor: Line[Span["a", s1], Span["b", s2]].
11
11
  def self.[](*spans) = new(spans)
12
12
 
13
+ # Coerce loose content into a Line: a Line passes through, a Span or an Array
14
+ # of Spans is wrapped, and anything else is one Span (in `style`, when given).
15
+ def self.coerce(content, style = nil)
16
+ case content
17
+ when Line then content
18
+ when Span then new([content])
19
+ when Array then new(content)
20
+ else Line[Span[content.to_s, style]]
21
+ end
22
+ end
23
+
13
24
  def initialize(spans = [])
14
25
  @spans = spans
15
26
  end
data/lib/tui_tui/list.rb CHANGED
@@ -21,7 +21,7 @@ module TuiTui
21
21
  row = body.row + offset
22
22
  selected = index == @scroll.cursor
23
23
  canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
24
- canvas.line(row, body.col, as_line(yield(index, selected)).truncate(body.cols))
24
+ canvas.line(row, body.col, Line.coerce(yield(index, selected)).truncate(body.cols))
25
25
  end
26
26
 
27
27
  draw_scrollbar(canvas, gutter, scrollbar) if gutter
@@ -53,7 +53,5 @@ module TuiTui
53
53
  thumb_style: theme.scroll_thumb
54
54
  )
55
55
  end
56
-
57
- def as_line(content) = content.is_a?(Line) ? content : Line.new(Array(content))
58
56
  end
59
57
  end
data/lib/tui_tui/pager.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "display_text"
4
4
  require_relative "style"
5
+ require_relative "span"
6
+ require_relative "line"
5
7
  require_relative "rect"
6
8
  require_relative "modal"
7
9
  require_relative "key_intent"
@@ -12,13 +14,16 @@ module TuiTui
12
14
  MARGIN = 2
13
15
  WHEEL = 3
14
16
 
17
+ # Each line may be a plain String, a `Line`, or an array of `Span`s, so a
18
+ # log/diff/error view can color whole lines or runs within them. Spans
19
+ # without a style fall back to theme.muted.
15
20
  def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
16
21
  @title = title
17
- @lines = lines.map { |line| DisplayText.new(line) }
22
+ @theme = theme
23
+ @lines = lines.map { |line| normalize_line(line) }
18
24
  @top = start
19
25
  @page = 1
20
26
  @close_keys = close_keys
21
- @theme = theme
22
27
  end
23
28
 
24
29
  def handle(key)
@@ -60,7 +65,7 @@ module TuiTui
60
65
  line = @lines[@top + offset]
61
66
  next if line.nil?
62
67
 
63
- canvas.text(rect.row + 3 + offset, rect.col + 2, line.truncate(inner), theme.muted)
68
+ canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
64
69
  end
65
70
 
66
71
  canvas
@@ -68,6 +73,13 @@ module TuiTui
68
73
 
69
74
  private
70
75
 
76
+ # -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
77
+ # default to theme.muted so a bare line still reads as muted body text.
78
+ def normalize_line(line)
79
+ spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
80
+ Line.new(spans)
81
+ end
82
+
71
83
  def paginate(key)
72
84
  case key
73
85
  when " ", :pgdn
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "display_text"
4
+ require_relative "text_sanitizer"
4
5
  require_relative "style"
5
6
  require_relative "modal"
6
7
  require_relative "key_code"
@@ -38,7 +39,7 @@ module TuiTui
38
39
  when :end
39
40
  edit { @pos = @graphemes.length }
40
41
  when String
41
- edit { insert(key) if printable?(key) }
42
+ edit { insert(key) if TextSanitizer.printable?(key) }
42
43
  end
43
44
  end
44
45
 
@@ -103,9 +104,5 @@ module TuiTui
103
104
  def delete_forward
104
105
  @graphemes.delete_at(@pos) if @pos < @graphemes.length
105
106
  end
106
-
107
- def printable?(string)
108
- string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
109
- end
110
107
  end
111
108
  end
@@ -26,6 +26,7 @@ module TuiTui
26
26
 
27
27
  @app = result
28
28
  flush_clipboard(screen)
29
+ flush_mouse(screen)
29
30
  screen.render(view(screen))
30
31
  end
31
32
  end
@@ -47,6 +48,14 @@ module TuiTui
47
48
  screen.copy(text) if text
48
49
  end
49
50
 
51
+ # Apps may release/recapture the mouse per frame (e.g. to allow a native
52
+ # terminal selection while a read-only pane is open).
53
+ def flush_mouse(screen)
54
+ return unless @app.respond_to?(:wants_mouse?)
55
+
56
+ screen.mouse = @app.wants_mouse?
57
+ end
58
+
50
59
  def wants_redraw?(event)
51
60
  @app.respond_to?(:redraw?) && @app.redraw?(event)
52
61
  end
@@ -52,6 +52,14 @@ module TuiTui
52
52
 
53
53
  attr_reader :events, :chrome
54
54
 
55
+ # Toggle mouse reporting mid-session (so an app can release the mouse for a
56
+ # native terminal selection while a read-only pane is open).
57
+ def mouse=(enabled)
58
+ @session.mouse = enabled
59
+ end
60
+
61
+ def mouse = @session.mouse
62
+
55
63
  def start
56
64
  @session.start
57
65
  # Probe box-drawing support once, after raw mode + alt screen, before the
@@ -91,11 +91,10 @@ module TuiTui
91
91
 
92
92
  # The item under a click, picked, or nil if the click missed the list.
93
93
  def click(event)
94
- rect = @items_rect
95
- return nil unless rect && event.col.between?(rect.col, rect.col + rect.cols - 1)
94
+ return nil unless @items_rect
96
95
 
97
- index = @list.top + (event.row - rect.row)
98
- return nil unless (event.row - rect.row).between?(0, rect.rows - 1) && !@list.empty? && index <= @list.last
96
+ index = List.new(@list).index_at(@items_rect, event)
97
+ return nil if index.nil?
99
98
 
100
99
  @list.go_to(index)
101
100
  @list.cursor
@@ -16,7 +16,8 @@ module TuiTui
16
16
  end
17
17
 
18
18
  def start
19
- @console.raw!
19
+ # `intr: true` keeps the interrupt/quit/suspend characters live in raw mode, so Ctrl-C raises SIGINT.
20
+ @console.raw!(intr: true)
20
21
  @output.write(Ansi::ALT_ON + Ansi::HIDE + Ansi::CLEAR + (@mouse ? Ansi::MOUSE_ON : ""))
21
22
  @output.flush
22
23
  @prev_winch = trap("WINCH") { @events.resized! }
@@ -24,6 +25,20 @@ module TuiTui
24
25
  at_exit { close }
25
26
  end
26
27
 
28
+ attr_reader :mouse
29
+
30
+ # Toggle mouse reporting mid-session. Releasing it (false) lets the user
31
+ # make a native terminal selection (drag to select / copy) over the alternate
32
+ # screen; re-enabling restores in-app mouse events. No-op if unchanged.
33
+ def mouse=(enabled)
34
+ enabled = !!enabled
35
+ return if @closed || enabled == @mouse
36
+
37
+ @output.write(enabled ? Ansi::MOUSE_ON : Ansi::MOUSE_OFF)
38
+ @output.flush
39
+ @mouse = enabled
40
+ end
41
+
27
42
  def close
28
43
  # Close is called from ensure, at_exit, and signal traps.
29
44
  return if @closed
@@ -1,13 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TuiTui
4
- # Normalizes text before rendering so malformed input bytes are displayed
5
- # safely instead of raising encoding errors.
4
+ # Character-level text hygiene: keeps malformed or control bytes out of the
5
+ # render/input pipeline so they are displayed safely (or rejected as input)
6
+ # instead of raising encoding errors or emitting raw control codes.
6
7
  module TextSanitizer
7
8
  module_function
8
9
 
9
10
  def sanitize(string)
10
11
  string.valid_encoding? ? string : string.scrub("?")
11
12
  end
13
+
14
+ # Whether `string` is safe to insert as literal text: every byte is a
15
+ # printable character (no C0 controls and no DEL). Multibyte UTF-8 passes,
16
+ # since its bytes are all >= 0x80.
17
+ def printable?(string)
18
+ string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
19
+ end
12
20
  end
13
21
  end
@@ -21,7 +21,7 @@ module TuiTui
21
21
  content = lines ? lines[index] : yield(index)
22
22
  next if content.nil?
23
23
 
24
- canvas.line(body.row + offset, body.col, as_line(content, style).truncate(body.cols))
24
+ canvas.line(body.row + offset, body.col, Line.coerce(content, style).truncate(body.cols))
25
25
  end
26
26
 
27
27
  draw_scrollbar(canvas, gutter, top, total || lines&.length, body.rows, scrollbar) if gutter
@@ -41,16 +41,5 @@ module TuiTui
41
41
  thumb_style: theme.scroll_thumb
42
42
  )
43
43
  end
44
-
45
- def as_line(content, style)
46
- case content
47
- when Line
48
- content
49
- when Array
50
- Line.new(content)
51
- else
52
- Line[Span[content.to_s, style]]
53
- end
54
- end
55
44
  end
56
45
  end
data/lib/tui_tui/toast.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "display_text"
4
4
  require_relative "style"
5
+ require_relative "clock"
5
6
 
6
7
  module TuiTui
7
8
  # A transient notification overlay.
@@ -22,7 +23,7 @@ module TuiTui
22
23
  bottom_right: [:bottom, :right],
23
24
  center: [:middle, :center]
24
25
  }.freeze
25
- MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
26
+ MONOTONIC = -> { Clock.monotonic }
26
27
 
27
28
  def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
28
29
  @message = DisplayText.new(message)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TuiTui
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/tui_tui.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "tui_tui/version"
4
4
 
5
+ require_relative "tui_tui/clock"
5
6
  require_relative "tui_tui/width"
6
7
  require_relative "tui_tui/text_sanitizer"
7
8
  require_relative "tui_tui/display_text"
@@ -38,6 +39,7 @@ require_relative "tui_tui/modal"
38
39
  require_relative "tui_tui/modal_host"
39
40
  require_relative "tui_tui/confirm"
40
41
  require_relative "tui_tui/select"
42
+ require_relative "tui_tui/command_palette"
41
43
  require_relative "tui_tui/help"
42
44
  require_relative "tui_tui/prompt"
43
45
  require_relative "tui_tui/pager"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tui_tui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -39,7 +39,9 @@ files:
39
39
  - lib/tui_tui/canvas.rb
40
40
  - lib/tui_tui/canvas_compositor.rb
41
41
  - lib/tui_tui/cell.rb
42
+ - lib/tui_tui/clock.rb
42
43
  - lib/tui_tui/color_depth.rb
44
+ - lib/tui_tui/command_palette.rb
43
45
  - lib/tui_tui/confirm.rb
44
46
  - lib/tui_tui/display_text.rb
45
47
  - lib/tui_tui/event.rb
@@ -98,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
100
  - !ruby/object:Gem::Version
99
101
  version: '0'
100
102
  requirements: []
101
- rubygems_version: 4.0.10
103
+ rubygems_version: 3.6.9
102
104
  specification_version: 4
103
105
  summary: A tiny, dependency-free TUI runtime for modern terminals.
104
106
  test_files: []