tui_tui 0.3.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -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 +64 -55
  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/canvas.rb +6 -55
  18. data/lib/tui_tui/canvas_compositor.rb +12 -10
  19. data/lib/tui_tui/canvas_renderer.rb +59 -0
  20. data/lib/tui_tui/cell.rb +4 -3
  21. data/lib/tui_tui/command.rb +21 -0
  22. data/lib/tui_tui/event_stream.rb +42 -7
  23. data/lib/tui_tui/key_reader.rb +1 -1
  24. data/lib/tui_tui/modal/command_palette.rb +193 -0
  25. data/lib/tui_tui/modal/confirm.rb +74 -0
  26. data/lib/tui_tui/modal/help.rb +44 -0
  27. data/lib/tui_tui/modal/pager.rb +106 -0
  28. data/lib/tui_tui/modal/prompt.rb +66 -0
  29. data/lib/tui_tui/modal/select.rb +103 -0
  30. data/lib/tui_tui/modal.rb +4 -0
  31. data/lib/tui_tui/modal_host.rb +2 -2
  32. data/lib/tui_tui/rect.rb +69 -0
  33. data/lib/tui_tui/runtime.rb +46 -23
  34. data/lib/tui_tui/screen.rb +7 -3
  35. data/lib/tui_tui/test_runtime.rb +71 -0
  36. data/lib/tui_tui/text_input.rb +168 -0
  37. data/lib/tui_tui/text_sanitizer.rb +3 -10
  38. data/lib/tui_tui/theme.rb +25 -27
  39. data/lib/tui_tui/version.rb +1 -1
  40. data/lib/tui_tui/widget/list.rb +59 -0
  41. data/lib/tui_tui/widget/progress.rb +33 -0
  42. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  43. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  44. data/lib/tui_tui/widget/spinner.rb +37 -0
  45. data/lib/tui_tui/widget/status_bar.rb +25 -0
  46. data/lib/tui_tui/widget/table.rb +81 -0
  47. data/lib/tui_tui/widget/tabs.rb +67 -0
  48. data/lib/tui_tui/widget/text_view.rb +47 -0
  49. data/lib/tui_tui/widget/toast.rb +83 -0
  50. data/lib/tui_tui.rb +23 -15
  51. metadata +28 -15
  52. data/lib/tui_tui/command_palette.rb +0 -190
  53. data/lib/tui_tui/confirm.rb +0 -74
  54. data/lib/tui_tui/help.rb +0 -44
  55. data/lib/tui_tui/list.rb +0 -57
  56. data/lib/tui_tui/pager.rb +0 -106
  57. data/lib/tui_tui/prompt.rb +0 -108
  58. data/lib/tui_tui/scroll_list.rb +0 -57
  59. data/lib/tui_tui/scrollbar.rb +0 -41
  60. data/lib/tui_tui/select.rb +0 -103
  61. data/lib/tui_tui/status_bar.rb +0 -23
  62. data/lib/tui_tui/text_view.rb +0 -45
  63. data/lib/tui_tui/toast.rb +0 -83
  64. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
@@ -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,20 +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
29
36
  flush_mouse(screen)
30
37
  screen.render(view(screen))
31
38
  end
32
39
  end
33
40
  end
34
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
+
35
68
  private
36
69
 
37
70
  # Pass a RenderContext (size + resolved chrome + canvas factory). It is
@@ -40,12 +73,12 @@ module TuiTui
40
73
  @app.view(RenderContext.new(size: screen.size, chrome: screen.chrome))
41
74
  end
42
75
 
43
- def flush_clipboard(screen)
44
- # Clipboard writes stay an effect of the loop, requested by the app.
45
- return unless @app.respond_to?(:take_clipboard)
46
-
47
- text = @app.take_clipboard
48
- 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
49
82
  end
50
83
 
51
84
  # Apps may release/recapture the mouse per frame (e.g. to allow a native
@@ -55,15 +88,5 @@ module TuiTui
55
88
 
56
89
  screen.mouse = @app.wants_mouse?
57
90
  end
58
-
59
- def wants_redraw?(event)
60
- @app.respond_to?(:redraw?) && @app.redraw?(event)
61
- end
62
-
63
- def inert_tick?(event)
64
- # Ticks are opt-in: a TickEvent is inert unless the app explicitly wants it
65
- # (so a plain app like counter.rb never redraws on the timer).
66
- event.is_a?(TickEvent) && !(@app.respond_to?(:wants_tick?) && @app.wants_tick?)
67
- end
68
91
  end
69
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"
@@ -64,7 +64,7 @@ module TuiTui
64
64
  @session.start
65
65
  # Probe box-drawing support once, after raw mode + alt screen, before the
66
66
  # first render/next_event so the DSR reply never reaches the key reader.
67
- @chrome = BoxChrome.resolve(
67
+ @chrome = BoxChromeResolver.new.resolve(
68
68
  input: @input,
69
69
  output: @output,
70
70
  term_cols: size.cols,
@@ -79,7 +79,7 @@ module TuiTui
79
79
  # is appended only on a full repaint or when the cursor actually moved, so an
80
80
  # idle identical re-render still writes nothing.
81
81
  def render(canvas)
82
- full = @previous.nil? || !@previous.same_size?(canvas)
82
+ full = full_repaint?(canvas)
83
83
  out = @compositor.render(@previous, canvas)
84
84
  out += cursor_directive(canvas) if full || canvas.cursor != @cursor
85
85
  @output.write(out)
@@ -101,6 +101,10 @@ module TuiTui
101
101
 
102
102
  private
103
103
 
104
+ def full_repaint?(canvas)
105
+ @previous.nil? || @previous.rows != canvas.rows || @previous.cols != canvas.cols
106
+ end
107
+
104
108
  def cursor_directive(canvas)
105
109
  pos = canvas.cursor
106
110
  pos ? Ansi.move(pos[0], pos[1]) + Ansi::SHOW : Ansi::HIDE
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime"
4
+ require_relative "render_context"
5
+ require_relative "events"
6
+ require_relative "size"
7
+ require_relative "box_chrome"
8
+
9
+ module TuiTui
10
+ # The Runtime with the Screen removed, for testing apps: inject events, then
11
+ # assert on the folded model (`app`), the accumulated `commands`, `quit?`,
12
+ # and the rendered view (`screen` / `canvas`). Events fold exactly as in
13
+ # Runtime (via Runtime.step — the shared pure part of the loop, including
14
+ # the inert-tick rule and unknown-command validation). Not loaded by
15
+ # `require "tui_tui"` — opt in with `require "tui_tui/test_runtime"`.
16
+ class TestRuntime
17
+ attr_reader :app, :commands
18
+
19
+ def initialize(app, rows: 24, cols: 80)
20
+ @app = app
21
+ @size = Size.new(rows: rows, cols: cols)
22
+ @commands = []
23
+ @quit = false
24
+ end
25
+
26
+ def quit? = @quit
27
+
28
+ def key(k) = fold(KeyEvent.new(key: k))
29
+
30
+ # One KeyEvent per grapheme cluster, as typing arrives from a terminal.
31
+ def type(str)
32
+ str.each_grapheme_cluster { |grapheme| key(grapheme) }
33
+ self
34
+ end
35
+
36
+ # Same rule as Runtime: a tick reaches `update` only when the app
37
+ # implements `wants_tick?` and it returns true; otherwise Runtime.step
38
+ # treats the tick as inert and nothing is folded.
39
+ def tick = fold(TickEvent.new)
40
+
41
+ def resize(rows:, cols:)
42
+ @size = Size.new(rows: rows, cols: cols)
43
+ fold(ResizeEvent.new(size: @size))
44
+ end
45
+
46
+ def mouse(action:, col:, row:, button: nil)
47
+ fold(MouseEvent.new(action: action, button: button, col: col, row: row))
48
+ end
49
+
50
+ # The current view as Canvas#to_text (a style-free string).
51
+ def screen = canvas.to_text
52
+
53
+ # The current view's Canvas, for cell-level style assertions. Built like
54
+ # Runtime's: a RenderContext, so legacy `view(size)` apps work too.
55
+ def canvas = @app.view(RenderContext.new(size: @size, chrome: BoxChrome::ASCII))
56
+
57
+ private
58
+
59
+ def fold(event)
60
+ raise "tui_tui: app already quit" if @quit
61
+
62
+ folded = Runtime.step(@app, event)
63
+ return self if folded.nil? # inert tick
64
+
65
+ app, commands = folded
66
+ @commands.concat(commands)
67
+ app.nil? ? @quit = true : @app = app
68
+ self
69
+ end
70
+ end
71
+ end