tui_tui 0.2.0 → 0.4.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +51 -14
  4. data/examples/README.md +28 -0
  5. data/examples/breakout.rb +225 -0
  6. data/examples/counter.rb +11 -12
  7. data/examples/csv_viewer.rb +28 -50
  8. data/examples/downloads.rb +128 -0
  9. data/examples/file_browser.rb +106 -56
  10. data/examples/form.rb +80 -102
  11. data/examples/life.rb +1 -1
  12. data/examples/log_viewer.rb +118 -0
  13. data/examples/todo.rb +43 -64
  14. data/examples/widgets.rb +4 -4
  15. data/lib/tui_tui/box_chrome.rb +0 -9
  16. data/lib/tui_tui/box_chrome_resolver.rb +21 -0
  17. data/lib/tui_tui/box_prober.rb +3 -4
  18. data/lib/tui_tui/canvas.rb +6 -55
  19. data/lib/tui_tui/canvas_compositor.rb +12 -10
  20. data/lib/tui_tui/canvas_renderer.rb +59 -0
  21. data/lib/tui_tui/cell.rb +4 -3
  22. data/lib/tui_tui/clock.rb +11 -0
  23. data/lib/tui_tui/command.rb +21 -0
  24. data/lib/tui_tui/event_stream.rb +42 -7
  25. data/lib/tui_tui/key_code.rb +3 -0
  26. data/lib/tui_tui/key_reader.rb +1 -1
  27. data/lib/tui_tui/line.rb +11 -0
  28. data/lib/tui_tui/modal/command_palette.rb +193 -0
  29. data/lib/tui_tui/modal/confirm.rb +74 -0
  30. data/lib/tui_tui/modal/help.rb +44 -0
  31. data/lib/tui_tui/modal/pager.rb +106 -0
  32. data/lib/tui_tui/modal/prompt.rb +66 -0
  33. data/lib/tui_tui/modal/select.rb +103 -0
  34. data/lib/tui_tui/modal.rb +4 -0
  35. data/lib/tui_tui/modal_host.rb +2 -2
  36. data/lib/tui_tui/rect.rb +69 -0
  37. data/lib/tui_tui/runtime.rb +52 -20
  38. data/lib/tui_tui/screen.rb +15 -3
  39. data/lib/tui_tui/terminal_session.rb +16 -1
  40. data/lib/tui_tui/test_runtime.rb +71 -0
  41. data/lib/tui_tui/text_input.rb +168 -0
  42. data/lib/tui_tui/text_sanitizer.rb +3 -2
  43. data/lib/tui_tui/theme.rb +25 -27
  44. data/lib/tui_tui/version.rb +1 -1
  45. data/lib/tui_tui/widget/list.rb +59 -0
  46. data/lib/tui_tui/widget/progress.rb +33 -0
  47. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  48. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  49. data/lib/tui_tui/widget/spinner.rb +37 -0
  50. data/lib/tui_tui/widget/status_bar.rb +25 -0
  51. data/lib/tui_tui/widget/table.rb +81 -0
  52. data/lib/tui_tui/widget/tabs.rb +67 -0
  53. data/lib/tui_tui/widget/text_view.rb +47 -0
  54. data/lib/tui_tui/widget/toast.rb +83 -0
  55. data/lib/tui_tui.rb +24 -14
  56. metadata +28 -13
  57. data/lib/tui_tui/confirm.rb +0 -74
  58. data/lib/tui_tui/help.rb +0 -44
  59. data/lib/tui_tui/list.rb +0 -59
  60. data/lib/tui_tui/pager.rb +0 -94
  61. data/lib/tui_tui/prompt.rb +0 -111
  62. data/lib/tui_tui/scroll_list.rb +0 -57
  63. data/lib/tui_tui/scrollbar.rb +0 -41
  64. data/lib/tui_tui/select.rb +0 -104
  65. data/lib/tui_tui/status_bar.rb +0 -23
  66. data/lib/tui_tui/text_view.rb +0 -56
  67. data/lib/tui_tui/toast.rb +0 -82
  68. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../modal"
4
+
5
+ module TuiTui
6
+ class Modal
7
+ # Key-binding cheat sheet modal.
8
+ class Help < Modal
9
+ COLGAP = 2
10
+
11
+ def initialize(title, entries, theme: Theme::DEFAULT)
12
+ @title = DisplayText.new(title)
13
+ @entries = entries.map { |keys, desc| [DisplayText.new(keys), DisplayText.new(desc)] }
14
+ @theme = theme
15
+ end
16
+
17
+ def handle(_key) = :close
18
+
19
+ # Any click dismisses the sheet, like any key does.
20
+ def handle_mouse(event) = event.action == :press ? :close : nil
21
+
22
+ def draw(canvas, size)
23
+ key_w = @entries.map { |keys, _| keys.width }.max || 0
24
+ body_w = @entries.map { |keys, desc| keys.width + COLGAP + desc.width }.max || 0
25
+ inner = [@title.width, body_w].max
26
+
27
+ rect, col = panel(canvas, inner: inner, body_rows: @entries.size + 2)
28
+
29
+ canvas.text(rect.row + 1, col, @title.truncate(inner), theme.title)
30
+ draw_entries(canvas, rect.row + 3, col, key_w)
31
+ canvas
32
+ end
33
+
34
+ private
35
+
36
+ def draw_entries(canvas, row, col, key_w)
37
+ @entries.each_with_index do |(keys, desc), index|
38
+ canvas.text(row + index, col, keys, theme.accent)
39
+ canvas.text(row + index, col + key_w + COLGAP, desc, theme.muted)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../span"
4
+ require_relative "../line"
5
+ require_relative "../rect"
6
+ require_relative "../modal"
7
+ require_relative "../key_intent"
8
+
9
+ module TuiTui
10
+ class Modal
11
+ # Scrollable read-only text modal.
12
+ class Pager < Modal
13
+ MARGIN = 2
14
+ WHEEL = 3
15
+
16
+ # Each line may be a plain String, a `Line`, or an array of `Span`s, so a
17
+ # log/diff/error view can color whole lines or runs within them. Spans
18
+ # without a style fall back to theme.muted.
19
+ def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
20
+ @title = title
21
+ @theme = theme
22
+ @lines = lines.map { |line| normalize_line(line) }
23
+ @top = start
24
+ @page = 1
25
+ @close_keys = close_keys
26
+ end
27
+
28
+ def handle(key)
29
+ return :close if @close_keys.include?(key)
30
+
31
+ case KeyIntent.for(key)
32
+ when :up
33
+ scroll(-1)
34
+ when :down
35
+ scroll(1)
36
+ when :top
37
+ scroll(-@lines.size)
38
+ when :bottom
39
+ scroll(@lines.size)
40
+ when :cancel
41
+ :close
42
+ else
43
+ paginate(key)
44
+ end
45
+ end
46
+
47
+ def handle_mouse(event)
48
+ scroll(event.button == :wheel_up ? -WHEEL : WHEEL) if event.action == :wheel
49
+ end
50
+
51
+ def draw(canvas, size)
52
+ width = [size.cols - (MARGIN * 2), 20].max
53
+ height = [size.rows - (MARGIN * 2), 5].max
54
+ rect = Rect.centered(size, cols: width, rows: height)
55
+ canvas.frame(rect, style: theme.frame)
56
+
57
+ inner = width - 4
58
+ body = [height - 4, 1].max
59
+ @page = body
60
+ clamp(body)
61
+
62
+ canvas.text(rect.row + 1, rect.col + 2, DisplayText.new(title_line(body)).truncate(inner), theme.title)
63
+ body.times do |offset|
64
+ line = @lines[@top + offset]
65
+ next if line.nil?
66
+
67
+ canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
68
+ end
69
+
70
+ canvas
71
+ end
72
+
73
+ private
74
+
75
+ # -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
76
+ # default to theme.muted so a bare line still reads as muted body text.
77
+ def normalize_line(line)
78
+ spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
79
+ Line.new(spans)
80
+ end
81
+
82
+ def paginate(key)
83
+ case key
84
+ when " ", :pgdn
85
+ scroll(@page)
86
+ when "b", :pgup
87
+ scroll(-@page)
88
+ end
89
+ end
90
+
91
+ def scroll(delta)
92
+ @top += delta
93
+ nil
94
+ end
95
+
96
+ def clamp(body)
97
+ @top = @top.clamp(0, [@lines.size - body, 0].max)
98
+ end
99
+
100
+ def title_line(body)
101
+ last = [@top + body, @lines.size].min
102
+ "#{@title} (#{@top + 1}-#{last}/#{@lines.size})"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../text_input"
4
+ require_relative "../modal"
5
+ require_relative "../key_code"
6
+
7
+ module TuiTui
8
+ class Modal
9
+ # Single-line text input modal with terminal-column-aware cursor placement.
10
+ class Prompt < Modal
11
+ MIN_INNER = 24
12
+
13
+ def initialize(label, value: "", theme: Theme::DEFAULT)
14
+ @label = DisplayText.new(label)
15
+ @input = TextInput.new(value: value)
16
+ @theme = theme
17
+ end
18
+
19
+ def value = @input.value
20
+
21
+ def handle(key)
22
+ case key
23
+ when "\r"
24
+ [:ok, value]
25
+ when :escape, KeyCode::CTRL_C
26
+ :cancel
27
+ else
28
+ @input.handle_editing(key)
29
+ nil
30
+ end
31
+ end
32
+
33
+ def handle_mouse(event)
34
+ return nil unless event.action == :press && @text_row == event.row
35
+
36
+ @input.cursor = @input.index_at(event.col - @text_col, from: @scroll)
37
+ nil
38
+ end
39
+
40
+ def draw(canvas, size)
41
+ # Grow the panel to fit the value, but never past the screen: beyond that
42
+ # the value scrolls horizontally inside its column budget (see #viewport).
43
+ max_inner = [size.cols - (PAD * 2) - 2, MIN_INNER].max
44
+ desired = @label.width + 1 + DisplayText.new(value).width
45
+ inner = desired.clamp(MIN_INNER, max_inner)
46
+ rect, col = panel(canvas, inner: inner, body_rows: 1)
47
+
48
+ canvas.text(rect.row + 1, col, @label, theme.title)
49
+ @text_row = rect.row + 1
50
+ @text_col = col + @label.width + 1
51
+ draw_value(canvas, [inner - @label.width - 1, 1].max)
52
+ canvas
53
+ end
54
+
55
+ private
56
+
57
+ def draw_value(canvas, budget)
58
+ viewport = @input.viewport(budget)
59
+ @scroll = viewport.start
60
+ canvas.text(@text_row, @text_col, viewport.text, theme.text)
61
+ cursor_col = @text_col + viewport.cursor_col
62
+ canvas.text(@text_row, cursor_col, viewport.cursor_grapheme || " ", theme.cursor)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../widget/scroll_list"
4
+ require_relative "../widget/list"
5
+ require_relative "../line"
6
+ require_relative "../span"
7
+ require_relative "../rect"
8
+ require_relative "../modal"
9
+ require_relative "../key_intent"
10
+
11
+ module TuiTui
12
+ class Modal
13
+ # Scrollable list picker modal.
14
+ class Select < Modal
15
+ MAX_ROWS = 12
16
+ MIN_INNER = 16
17
+ WHEEL = 3
18
+
19
+ def initialize(title, items, default: 0, theme: Theme::DEFAULT)
20
+ @title = DisplayText.new(title)
21
+ @items = items.map { |item| DisplayText.new(item) }
22
+ @list = Widget::ScrollList.new(@items.size)
23
+ @list.go_to(default)
24
+ @theme = theme
25
+ end
26
+
27
+ def cursor = @list.cursor
28
+
29
+ def handle(key)
30
+ case KeyIntent.for(key)
31
+ when :up
32
+ nudge(-1)
33
+ when :down
34
+ nudge(1)
35
+ when :top
36
+ nudge_to(0)
37
+ when :bottom
38
+ nudge_to(@list.last)
39
+ when :cancel
40
+ :cancel
41
+ else
42
+ @list.cursor if ["\r", " "].include?(key)
43
+ end
44
+ end
45
+
46
+ # Wheel moves the highlighted item; a click on an item picks it. Returns the
47
+ # chosen index on a click, otherwise nil (stay open).
48
+ def handle_mouse(event)
49
+ case event.action
50
+ when :wheel
51
+ nudge(event.button == :wheel_up ? -WHEEL : WHEEL)
52
+ when :press
53
+ click(event)
54
+ end
55
+ end
56
+
57
+ def draw(canvas, size)
58
+ rows = visible_rows(size)
59
+ inner = [MIN_INNER, @title.width, *@items.map(&:width)].max
60
+ rect, text_col = panel(canvas, inner: inner, body_rows: rows + 2)
61
+
62
+ canvas.text(rect.row + 1, text_col, @title.truncate(inner), theme.title)
63
+ draw_items(canvas, rect.row + 3, text_col, inner, rows)
64
+ canvas
65
+ end
66
+
67
+ private
68
+
69
+ def nudge(delta)
70
+ @list.move(delta)
71
+ nil
72
+ end
73
+
74
+ def nudge_to(index)
75
+ @list.go_to(index)
76
+ nil
77
+ end
78
+
79
+ def visible_rows(size)
80
+ room = [size.rows - 4, 1].max
81
+ [@items.size, MAX_ROWS, room].min
82
+ end
83
+
84
+ def draw_items(canvas, row, col, inner, rows)
85
+ @items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
86
+ Widget::List.new(@list).draw(canvas, @items_rect) do |index, focused|
87
+ Line[Span[@items[index].to_s, focused ? theme.selection : theme.text]]
88
+ end
89
+ end
90
+
91
+ # The item under a click, picked, or nil if the click missed the list.
92
+ def click(event)
93
+ return nil unless @items_rect
94
+
95
+ index = Widget::List.new(@list).index_at(@items_rect, event)
96
+ return nil if index.nil?
97
+
98
+ @list.go_to(index)
99
+ @list.cursor
100
+ end
101
+ end
102
+ end
103
+ end
data/lib/tui_tui/modal.rb CHANGED
@@ -3,6 +3,10 @@
3
3
  require_relative "rect"
4
4
  require_relative "theme"
5
5
 
6
+ # used by subclasses
7
+ require_relative "display_text"
8
+ require_relative "style"
9
+
6
10
  module TuiTui
7
11
  # Base protocol and shared centered-panel framing for overlay widgets.
8
12
  class Modal
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "event"
3
+ require_relative "events"
4
4
 
5
5
  module TuiTui
6
6
  # Host-side helper for the app that *owns* the current modal widget.
@@ -11,7 +11,7 @@ module TuiTui
11
11
  # the caller's on_result callback when the modal resolves.
12
12
  #
13
13
  # host = TuiTui::ModalHost.new
14
- # host.open(TuiTui::Confirm.new("Quit?")) { |r| :quit if r == :ok }
14
+ # host.open(TuiTui::Modal::Confirm.new("Quit?")) { |r| :quit if r == :ok }
15
15
  # # in update(event):
16
16
  # if host.open?
17
17
  # outcome = host.handle(event) # nil while open; on_result value once resolved
data/lib/tui_tui/rect.rb CHANGED
@@ -12,6 +12,12 @@ module TuiTui
12
12
  )
13
13
  end
14
14
 
15
+ # The Rect covering a whole screen: row/col 1 over anything with
16
+ # Size-compatible `rows` / `cols` (a Size or a RenderContext).
17
+ def self.of(size)
18
+ new(row: 1, col: 1, rows: size.rows, cols: size.cols)
19
+ end
20
+
15
21
  def split_h(top_rows)
16
22
  top = Rect.new(row: row, col: col, rows: top_rows, cols: cols)
17
23
  bottom = Rect.new(row: row + top_rows, col: col, rows: rows - top_rows, cols: cols)
@@ -33,6 +39,35 @@ module TuiTui
33
39
  [left, right.shift_right(gutter)]
34
40
  end
35
41
 
42
+ # Split into stacked Rects, top to bottom, one per constraint:
43
+ # an Integer is a fixed number of rows, a Float a fraction of the whole
44
+ # (`(rows * f).round`), and :rest shares whatever the fixed and fractional
45
+ # segments leave over (several :rest split it evenly, leading ones taking
46
+ # the indivisible remainder). Sizes resolve front to back, each clamped to
47
+ # what is still left, so an undersized rect squashes trailing segments to
48
+ # 0 rather than raising — the same live-and-let-live rule as split_ratio.
49
+ # Without a :rest, leftover space stays unassigned (no implicit stretch).
50
+ # Every constraint yields a Rect (possibly 0-sized), so destructuring is
51
+ # stable: `header, body, status = rect.split_rows(2, :rest, 2)`.
52
+ def split_rows(*constraints)
53
+ top = row
54
+ resolve_segments(constraints, rows).map do |size|
55
+ segment = Rect.new(row: top, col: col, rows: size, cols: cols)
56
+ top += size
57
+ segment
58
+ end
59
+ end
60
+
61
+ # Same as split_rows, but left to right over the columns.
62
+ def split_cols(*constraints)
63
+ left = col
64
+ resolve_segments(constraints, cols).map do |size|
65
+ segment = Rect.new(row: row, col: left, rows: rows, cols: size)
66
+ left += size
67
+ segment
68
+ end
69
+ end
70
+
36
71
  def shift_right(by)
37
72
  Rect.new(row: row, col: col + by, rows: rows, cols: cols - by)
38
73
  end
@@ -52,5 +87,39 @@ module TuiTui
52
87
 
53
88
  [with(cols: cols - width), Rect.new(row: row, col: col + cols - width, rows: rows, cols: width)]
54
89
  end
90
+
91
+ private
92
+
93
+ # Segment sizes for split_rows / split_cols: nominal sizes first (:rest
94
+ # from the leftover), then a front-to-back pass clamping each to what
95
+ # remains of `total`.
96
+ def resolve_segments(constraints, total)
97
+ rest_count = constraints.count(:rest)
98
+ claimed = constraints.sum { |constraint| nominal_size(constraint, total) }
99
+ base, spare = rest_count.zero? ? [0, 0] : [total - claimed, 0].max.divmod(rest_count)
100
+
101
+ rest_index = -1
102
+ remaining = total
103
+ constraints.map do |constraint|
104
+ size = if constraint == :rest
105
+ rest_index += 1
106
+ base + (rest_index < spare ? 1 : 0)
107
+ else
108
+ nominal_size(constraint, total)
109
+ end
110
+ size = size.clamp(0, remaining)
111
+ remaining -= size
112
+ size
113
+ end
114
+ end
115
+
116
+ def nominal_size(constraint, total)
117
+ case constraint
118
+ when Integer then constraint
119
+ when Float then (total * constraint).round
120
+ when :rest then 0
121
+ else raise ArgumentError, "unknown layout constraint: #{constraint.inspect}"
122
+ end
123
+ end
55
124
  end
56
125
  end
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
3
4
  require_relative "screen"
4
5
  require_relative "render_context"
5
6
 
6
7
  module TuiTui
7
- # Small Elm-style loop: render, read one event, fold it through the app, repeat.
8
+ # Small Elm-style loop: render, read one event, fold it through the app,
9
+ # repeat. `update` returns the next app, `[app, *commands]` to also request
10
+ # one-shot effects (see Command), or `:quit`/nil to stop; `[:quit, *commands]`
11
+ # performs the commands and then stops.
8
12
  class Runtime
9
13
  def initialize(app)
10
14
  @app = app
11
15
  end
12
16
 
17
+ # `tick:` is the TickEvent period in seconds: ticks arrive on that fixed
18
+ # cadence (between input events too), not after idle gaps.
13
19
  def run(input: $stdin, output: $stdout, depth: ColorDepth.detect, tick: 0.1, mouse: Screen.mouse_default, box: ENV["TUITUI_BOX"])
14
20
  Screen.run(input: input, output: output, depth: depth, mouse: mouse, box: box) do |screen|
15
21
  raise "tui_tui: not a terminal" if screen.nil?
@@ -18,19 +24,47 @@ module TuiTui
18
24
  loop do
19
25
  event = screen.events.next_event(tick: tick)
20
26
  break if event.is_a?(EofEvent)
21
- next if inert_tick?(event)
22
27
 
23
- screen.invalidate if wants_redraw?(event)
24
- result = @app.update(event)
25
- break if result == :quit || result.nil?
28
+ folded = Runtime.step(@app, event)
29
+ next if folded.nil?
26
30
 
27
- @app = result
28
- flush_clipboard(screen)
31
+ app, commands = folded
32
+ commands.each { |command| perform(command, screen) }
33
+ break if app.nil?
34
+
35
+ @app = app
36
+ flush_mouse(screen)
29
37
  screen.render(view(screen))
30
38
  end
31
39
  end
32
40
  end
33
41
 
42
+ # Normalizes the update result to [app, commands]; a quit (bare or as the
43
+ # array head) becomes a nil app. An app is never itself an Array. Shared
44
+ # with TestRuntime so both read `update`'s contract the same way.
45
+ def self.unwrap(result)
46
+ app, commands = result.is_a?(Array) ? [result.first, result.drop(1)] : [result, []]
47
+ [app == :quit ? nil : app, commands]
48
+ end
49
+
50
+ # The canonical pure part of one loop iteration: drop an inert tick
51
+ # (returning nil), otherwise fold the event through `update`, validate the
52
+ # requested commands, and return [next_app_or_nil, commands]. Runtime and
53
+ # TestRuntime both fold through this, so their semantics cannot drift.
54
+ def self.step(app, event)
55
+ return nil if inert_tick?(app, event)
56
+
57
+ app, commands = unwrap(app.update(event))
58
+ commands.each { |command| Command.validate!(command) }
59
+ [app, commands]
60
+ end
61
+
62
+ # Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
63
+ # (so a plain app like counter.rb never redraws on the timer).
64
+ def self.inert_tick?(app, event)
65
+ event.is_a?(TickEvent) && !(app.respond_to?(:wants_tick?) && app.wants_tick?)
66
+ end
67
+
34
68
  private
35
69
 
36
70
  # Pass a RenderContext (size + resolved chrome + canvas factory). It is
@@ -39,22 +73,20 @@ module TuiTui
39
73
  @app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
40
74
  end
41
75
 
42
- def flush_clipboard(screen)
43
- # Clipboard writes stay an effect of the loop, requested by the app.
44
- return unless @app.respond_to?(:take_clipboard)
45
-
46
- text = @app.take_clipboard
47
- screen.copy(text) if text
76
+ def perform(command, screen)
77
+ case command
78
+ when Command::Copy then screen.copy(command.text)
79
+ when Command::Invalidate then screen.invalidate
80
+ else raise ArgumentError, "tui_tui: unknown command #{command.inspect}"
81
+ end
48
82
  end
49
83
 
50
- def wants_redraw?(event)
51
- @app.respond_to?(:redraw?) && @app.redraw?(event)
52
- end
84
+ # Apps may release/recapture the mouse per frame (e.g. to allow a native
85
+ # terminal selection while a read-only pane is open).
86
+ def flush_mouse(screen)
87
+ return unless @app.respond_to?(:wants_mouse?)
53
88
 
54
- def inert_tick?(event)
55
- # Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
56
- # (so a plain app like counter.rb never redraws on the timer).
57
- event.is_a?(TickEvent) && !(@app.respond_to?(:wants_tick?) && @app.wants_tick?)
89
+ screen.mouse = @app.wants_mouse?
58
90
  end
59
91
  end
60
92
  end
@@ -6,7 +6,7 @@ require_relative "ansi"
6
6
  require_relative "size"
7
7
  require_relative "color_depth"
8
8
  require_relative "box_chrome"
9
- require_relative "box_prober"
9
+ require_relative "box_chrome_resolver"
10
10
  require_relative "canvas_compositor"
11
11
  require_relative "terminal_size"
12
12
  require_relative "event_stream"
@@ -52,11 +52,19 @@ 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
58
66
  # first render/next_event so the DSR reply never reaches the key reader.
59
- @chrome = BoxChrome.resolve(
67
+ @chrome = BoxChromeResolver.new.resolve(
60
68
  input: @input,
61
69
  output: @output,
62
70
  term_cols: size.cols,
@@ -71,7 +79,7 @@ module TuiTui
71
79
  # is appended only on a full repaint or when the cursor actually moved, so an
72
80
  # idle identical re-render still writes nothing.
73
81
  def render(canvas)
74
- full = @previous.nil? || !@previous.same_size?(canvas)
82
+ full = full_repaint?(canvas)
75
83
  out = @compositor.render(@previous, canvas)
76
84
  out += cursor_directive(canvas) if full || canvas.cursor != @cursor
77
85
  @output.write(out)
@@ -93,6 +101,10 @@ module TuiTui
93
101
 
94
102
  private
95
103
 
104
+ def full_repaint?(canvas)
105
+ @previous.nil? || @previous.rows != canvas.rows || @previous.cols != canvas.cols
106
+ end
107
+
96
108
  def cursor_directive(canvas)
97
109
  pos = canvas.cursor
98
110
  pos ? Ansi.move(pos[0], pos[1]) + Ansi::SHOW : Ansi::HIDE
@@ -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