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,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
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "key_code"
5
+
6
+ module TuiTui
7
+ # A single-line, grapheme-aware text buffer with a cursor: the editing model
8
+ # shared by the prompt, search bars, command-palette queries, and editable
9
+ # cells. It is Canvas-agnostic — it knows only graphemes, cursor position, and
10
+ # display columns — so the same model drives any chrome around it.
11
+ #
12
+ # Editing happens by grapheme cluster, so the cursor never lands inside an
13
+ # emoji modifier or a base+combining-mark pair, and a combining mark typed
14
+ # after its base merges into one grapheme.
15
+ #
16
+ # The buffer owns only the *editing* keys (text, Backspace/Delete, arrows,
17
+ # Home/End) via #handle_editing; semantic keys like Enter and Escape belong to
18
+ # the host, which decides what they mean.
19
+ class TextInput
20
+ # The cursor as a grapheme index in 0..length.
21
+ attr_reader :cursor
22
+
23
+ def initialize(value: "")
24
+ @graphemes = value.grapheme_clusters
25
+ @cursor = @graphemes.length
26
+ end
27
+
28
+ def value = @graphemes.join
29
+ def empty? = @graphemes.empty?
30
+ def length = @graphemes.length
31
+
32
+ # Move the cursor, clamping into 0..length.
33
+ def cursor=(index)
34
+ @cursor = index.clamp(0, @graphemes.length)
35
+ end
36
+
37
+ # Handle an editing key, returning true if it was consumed. Enter, Escape and
38
+ # any other non-editing key return false so the host can act on them.
39
+ def handle_editing(key)
40
+ case key
41
+ when KeyCode::BACKSPACE, :backspace then delete_back
42
+ when :delete then delete_forward
43
+ when :left then left
44
+ when :right then right
45
+ when :home then to_home
46
+ when :end then to_end
47
+ when String then return type(key)
48
+ else return false
49
+ end
50
+ true
51
+ end
52
+
53
+ # Insert `key` as literal text when it is printable (no control bytes),
54
+ # returning whether it was accepted. The entry point for typed input — hosts
55
+ # that bypass #handle_editing (e.g. append-only queries) call this directly so
56
+ # the printable rule lives in one place rather than at every call site.
57
+ def type(key)
58
+ return false unless printable?(key)
59
+
60
+ insert(key)
61
+ true
62
+ end
63
+
64
+ # Insert a string at the cursor, re-clustering across both boundaries so a
65
+ # combining mark merges into its neighbour. The cursor lands after the text.
66
+ def insert(string)
67
+ head = @graphemes[0...@cursor].join
68
+ @graphemes = (head + string + @graphemes[@cursor..].join).grapheme_clusters
69
+ @cursor = (head + string).grapheme_clusters.length
70
+ end
71
+
72
+ def delete_back
73
+ return false if @cursor.zero?
74
+
75
+ @graphemes.delete_at(@cursor - 1)
76
+ @cursor -= 1
77
+ true
78
+ end
79
+
80
+ def delete_forward
81
+ return false if @cursor >= @graphemes.length
82
+
83
+ @graphemes.delete_at(@cursor)
84
+ true
85
+ end
86
+
87
+ def left = (@cursor = [@cursor - 1, 0].max)
88
+ def right = (@cursor = [@cursor + 1, @graphemes.length].min)
89
+ def to_home = (@cursor = 0)
90
+ def to_end = (@cursor = @graphemes.length)
91
+
92
+ # The grapheme index whose left edge is closest to `rel` columns past the
93
+ # grapheme `from` — for mapping a mouse click onto the cursor, where `from`
94
+ # is the left grapheme of the visible viewport (see #viewport).
95
+ def index_at(rel, from: 0)
96
+ return from if rel <= 0
97
+
98
+ width = 0
99
+ @graphemes[from..].each_with_index do |grapheme, i|
100
+ w = DisplayText.new(grapheme).width
101
+ return from + i if rel < width + ((w + 1) / 2)
102
+
103
+ width += w
104
+ end
105
+ @graphemes.length
106
+ end
107
+
108
+ # The visible region of a horizontally-scrolled value: the slice to draw,
109
+ # scrolled so the cursor (and the cell its block occupies) stays inside `cols`
110
+ # display columns. Lets a long value be drawn in a narrow field without the
111
+ # cursor running off the edge.
112
+ #
113
+ # `text` is the visible substring, `cursor_col` the display columns from the
114
+ # viewport's left edge to the cursor (measured from the slice, not the start
115
+ # of the value), `cursor_grapheme` the grapheme under the cursor (nil at the
116
+ # end), and `start` the left grapheme index (feed it to #index_at as `from:`).
117
+ Viewport = Data.define(:text, :cursor_col, :cursor_grapheme, :start)
118
+
119
+ def viewport(cols)
120
+ cols = [cols, 1].max
121
+ # Reserve room for the block cursor: a real grapheme's width, else one
122
+ # column for the trailing space drawn when the cursor sits at the end.
123
+ cell = grapheme_at_cursor ? DisplayText.new(grapheme_at_cursor).width : 1
124
+ # Scroll right one grapheme at a time until the cursor cell fits, shrinking
125
+ # the running leading width instead of recomputing it from scratch.
126
+ start = 0
127
+ lead = width_before_cursor
128
+ while start < @cursor && lead + cell > cols
129
+ lead -= DisplayText.new(@graphemes[start]).width
130
+ start += 1
131
+ end
132
+ Viewport.new(
133
+ text: visible_slice(start, cols),
134
+ cursor_col: lead,
135
+ cursor_grapheme: grapheme_at_cursor,
136
+ start: start
137
+ )
138
+ end
139
+
140
+ private
141
+
142
+ # Whether `string` is safe to insert as literal text: no C0 control bytes and
143
+ # no DEL. Multibyte UTF-8 passes, since its bytes are all >= 0x80.
144
+ def printable?(string) = string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
145
+
146
+ # Display columns from the start of the value to the cursor. The viewport-
147
+ # relative counterpart is Viewport#cursor_col.
148
+ def width_before_cursor = DisplayText.new(@graphemes[0...@cursor].join).width
149
+
150
+ # The grapheme under the cursor (drawn as a block cursor), or nil when the
151
+ # cursor sits past the last grapheme.
152
+ def grapheme_at_cursor = @graphemes[@cursor]
153
+
154
+ # Graphemes from `start` that fit within `cols` display columns.
155
+ def visible_slice(start, cols)
156
+ text = +""
157
+ width = 0
158
+ @graphemes[start..].each do |grapheme|
159
+ w = DisplayText.new(grapheme).width
160
+ break if width + w > cols
161
+
162
+ text << grapheme
163
+ width += w
164
+ end
165
+ text
166
+ end
167
+ end
168
+ end
@@ -1,8 +1,9 @@
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
+ # Encoding hygiene for the render path: replaces malformed byte sequences so a
5
+ # string can be displayed safely instead of raising encoding errors. (Input
6
+ # acceptance — rejecting control keys — lives with the input model, TextInput.)
6
7
  module TextSanitizer
7
8
  module_function
8
9
 
data/lib/tui_tui/theme.rb CHANGED
@@ -31,17 +31,6 @@ module TuiTui
31
31
  )
32
32
 
33
33
  class Theme
34
- # Map a symbolic status to its semantic role Style (with common aliases).
35
- def status(kind)
36
- case kind
37
- when :ok, :success then success
38
- when :warn, :warning then warning
39
- when :error, :danger then danger
40
- when :info then info
41
- else text
42
- end
43
- end
44
-
45
34
  # Background-dependent neutral roles.
46
35
  SURFACES = {
47
36
  dark: {
@@ -116,6 +105,21 @@ module TuiTui
116
105
 
117
106
  def self.build(background: :dark, hue: :cool) = TABLE.fetch([background, hue])
118
107
 
108
+ LIGHT = Theme.build(background: :light, hue: :cool)
109
+ WARM = Theme.build(background: :dark, hue: :warm)
110
+ MONO = Theme.build(background: :dark, hue: :mono)
111
+ DARK = Theme.build(background: :dark, hue: :cool)
112
+ DEFAULT = DARK
113
+
114
+ PRESETS = {
115
+ default: DEFAULT,
116
+ cool: DEFAULT,
117
+ dark: DARK,
118
+ light: LIGHT,
119
+ warm: WARM,
120
+ mono: MONO
121
+ }.freeze
122
+
119
123
  # Best-effort terminal background (:light/:dark).
120
124
  def self.detect_background(env: ENV)
121
125
  case env["TUITUI_BACKGROUND"]&.downcase
@@ -134,22 +138,16 @@ module TuiTui
134
138
 
135
139
  # Fetch a preset by name (Symbol/String); unknown names fall back to DEFAULT.
136
140
  def self.named(name) = PRESETS.fetch(name&.to_sym, DEFAULT)
137
- end
138
-
139
- Theme::DARK = Theme.build(background: :dark, hue: :cool)
140
- Theme::LIGHT = Theme.build(background: :light, hue: :cool)
141
- Theme::DEFAULT = Theme::DARK
142
- Theme::WARM = Theme.build(background: :dark, hue: :warm)
143
- Theme::MONO = Theme.build(background: :dark, hue: :mono)
144
141
 
145
- class Theme
146
- PRESETS = {
147
- default: DEFAULT,
148
- cool: DEFAULT,
149
- dark: DARK,
150
- light: LIGHT,
151
- warm: WARM,
152
- mono: MONO
153
- }.freeze
142
+ # Map a symbolic status to its semantic role Style (with common aliases).
143
+ def status(kind)
144
+ case kind
145
+ when :ok, :success then success
146
+ when :warn, :warning then warning
147
+ when :error, :danger then danger
148
+ when :info then info
149
+ else text
150
+ end
151
+ end
154
152
  end
155
153
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TuiTui
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rect"
4
+ require_relative "../line"
5
+ require_relative "scrollbar"
6
+
7
+ module TuiTui
8
+ module Widget
9
+ # Drawing companion for ScrollList.
10
+ # Row content comes from the caller, keeping the list domain-agnostic.
11
+ class List
12
+ def initialize(scroll)
13
+ @scroll = scroll
14
+ end
15
+
16
+ def draw(canvas, rect, highlight: nil, scrollbar: nil, auto: false)
17
+ # With auto:, reserve the gutter only when the content overflows the rect.
18
+ show_bar = scrollbar && !(auto && @scroll.count <= rect.rows)
19
+ body, gutter = show_bar ? rect.split_gutter : [rect, nil]
20
+ @scroll.ensure_visible(body.rows)
21
+ @scroll.each_visible(body.rows) do |index, offset|
22
+ row = body.row + offset
23
+ selected = index == @scroll.cursor
24
+ canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
25
+ canvas.line(row, body.col, Line.coerce(yield(index, selected)).truncate(body.cols))
26
+ end
27
+
28
+ draw_scrollbar(canvas, gutter, scrollbar) if gutter
29
+ canvas
30
+ end
31
+
32
+ # Map a MouseEvent to the list index under it, or nil. Pass the same `rect`
33
+ # and `scrollbar:` used for `draw` so the gutter column is excluded and the
34
+ # scroll offset matches what was rendered. Returns nil for clicks outside the
35
+ # body or below the last item.
36
+ def index_at(rect, event, scrollbar: nil)
37
+ body = scrollbar ? rect.split_gutter.first : rect
38
+ return nil unless body.hit?(event)
39
+
40
+ index = @scroll.top + (event.row - body.row)
41
+ index < @scroll.count ? index : nil
42
+ end
43
+
44
+ private
45
+
46
+ def draw_scrollbar(canvas, gutter, theme)
47
+ Scrollbar.draw(
48
+ canvas,
49
+ gutter,
50
+ top: @scroll.top,
51
+ visible: gutter.rows,
52
+ total: @scroll.count,
53
+ track_style: theme.scroll_track,
54
+ thumb_style: theme.scroll_thumb
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../display_text"
4
+
5
+ module TuiTui
6
+ module Widget
7
+ # A stateless horizontal progress bar for a 1-row rect.
8
+ # The caller passes the ratio and Theme roles (e.g. bar_style:
9
+ # theme.selection, track_style: theme.muted); default glyphs are ASCII
10
+ # (README N7). If the rect is taller than one row, every row gets the bar.
11
+ module Progress
12
+ module_function
13
+
14
+ def draw(canvas, rect, ratio, bar_style:, track_style: nil, char: "#", track_char: "-")
15
+ return canvas if rect.rows <= 0 || rect.cols <= 0
16
+
17
+ filled = (rect.cols * ratio.to_f.clamp(0.0, 1.0)).round
18
+ # char / track_char are meant to be 1 column wide (the ASCII defaults
19
+ # are, per N7); a wider glyph is clipped to the cell budget so the bar
20
+ # never overflows the rect.
21
+ bar = DisplayText.new(char * filled).truncate(filled, marker: "").to_s
22
+ track = DisplayText.new(track_char * (rect.cols - filled)).truncate(rect.cols - filled, marker: "").to_s
23
+ rect.rows.times do |dr|
24
+ row = rect.row + dr
25
+ canvas.text(row, rect.col, bar, bar_style) if filled.positive?
26
+ canvas.text(row, rect.col + filled, track, track_style) if filled < rect.cols
27
+ end
28
+
29
+ canvas
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ module Widget
5
+ # Cursor and viewport arithmetic shared by list-like widgets.
6
+ class ScrollList
7
+ attr_reader :cursor, :top
8
+
9
+ def initialize(count = 0)
10
+ @count = count
11
+ @cursor = 0
12
+ @top = 0
13
+ end
14
+
15
+ def count=(value)
16
+ @count = [value, 0].max
17
+ @cursor = @cursor.clamp(0, last)
18
+ end
19
+
20
+ attr_reader :count
21
+
22
+ def empty? = @count.zero?
23
+ def last = [@count - 1, 0].max
24
+ def at_end? = @cursor == last
25
+
26
+ def move(delta) = go_to(@cursor + delta)
27
+ def page(height) = move(height)
28
+ def to_top = go_to(0)
29
+ def to_end = go_to(last)
30
+
31
+ def go_to(index)
32
+ @cursor = index.clamp(0, last)
33
+ self
34
+ end
35
+
36
+ def ensure_visible(height)
37
+ return self if height <= 0
38
+
39
+ @top = @cursor if @cursor < @top
40
+ @top = @cursor - height + 1 if @cursor >= @top + height
41
+ @top = 0 if @top.negative?
42
+ self
43
+ end
44
+
45
+ def each_visible(height)
46
+ return enum_for(:each_visible, height) unless block_given?
47
+
48
+ height.times do |offset|
49
+ index = @top + offset
50
+ break if index >= @count
51
+
52
+ yield index, offset
53
+ end
54
+
55
+ self
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rect"
4
+ require_relative "../style"
5
+ require_relative "../theme"
6
+
7
+ module TuiTui
8
+ module Widget
9
+ # A vertical scroll indicator for a 1-column gutter.
10
+ # It sizes a thumb from top/visible/total and draws ASCII-only chrome.
11
+ module Scrollbar
12
+ module_function
13
+
14
+ TRACK = Theme::DEFAULT.scroll_track
15
+ THUMB = Theme::DEFAULT.scroll_thumb
16
+
17
+ def draw(canvas, rect, top:, visible:, total:, track: nil, thumb: " ", track_style: TRACK, thumb_style: THUMB)
18
+ return canvas if rect.rows <= 0
19
+
20
+ track ||= canvas.chrome.track
21
+ length, offset = geometry(rect.rows, top, visible, total)
22
+ rect.rows.times do |i|
23
+ in_thumb = i >= offset && i < offset + length
24
+ canvas.text(rect.row + i, rect.col, in_thumb ? thumb : track, in_thumb ? thumb_style : track_style)
25
+ end
26
+
27
+ canvas
28
+ end
29
+
30
+ # The thumb's [length, offset] in rows for a `height`-row track. Returns
31
+ # [0, 0] (no thumb, track only) when everything fits — nothing to scroll.
32
+ def geometry(height, top, visible, total)
33
+ visible = [visible, 1].max
34
+ total = [total, visible].max
35
+ return [0, 0] if total <= visible
36
+
37
+ length = [(height * visible / total.to_f).round, 1].max.clamp(1, height)
38
+ offset = ((height - length) * top.to_f / (total - visible)).round
39
+ [length, offset.clamp(0, height - length)]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ module Widget
5
+ # A tiny frame-cycling state machine for "working..." indicators.
6
+ # It knows nothing about time: the app advances it (typically once per
7
+ # TickEvent) and draws the current glyph wherever it likes.
8
+ class Spinner
9
+ # Default frames are ASCII so every glyph is guaranteed 1 column wide
10
+ # (README N7). FRAMES_BRAILLE is offered for terminals with good glyph
11
+ # coverage — whether to use it is the caller's judgement, not ours.
12
+ FRAMES_ASCII = %w[- \\ | /].freeze
13
+ FRAMES_BRAILLE = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
14
+
15
+ def initialize(frames: FRAMES_ASCII)
16
+ raise ArgumentError, "spinner needs at least one frame" if frames.empty?
17
+
18
+ @frames = frames
19
+ @index = 0
20
+ end
21
+
22
+ # Step to the next frame (wrapping). Returns self for chaining.
23
+ def advance
24
+ @index = (@index + 1) % @frames.length
25
+ self
26
+ end
27
+
28
+ # The current frame's string.
29
+ def glyph = @frames[@index]
30
+
31
+ def draw(canvas, row, col, style: nil)
32
+ canvas.text(row, col, glyph, style)
33
+ canvas
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../display_text"
4
+
5
+ module TuiTui
6
+ module Widget
7
+ # A one-row status/footer bar.
8
+ # It draws left text from the start and optional right text flush right.
9
+ module StatusBar
10
+ module_function
11
+
12
+ def draw(canvas, rect, left: "", right: nil, style: nil)
13
+ canvas.fill(rect, style)
14
+
15
+ right_width = right ? DisplayText.new(right).width : 0
16
+ fits_right = right && right_width < rect.cols
17
+ left_max = fits_right ? rect.cols - right_width : rect.cols
18
+
19
+ canvas.text(rect.row, rect.col, DisplayText.new(left).truncate(left_max), style)
20
+ canvas.text(rect.row, rect.col + rect.cols - right_width, right, style) if fits_right
21
+ canvas
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rect"
4
+ require_relative "../line"
5
+ require_relative "../span"
6
+ require_relative "../display_text"
7
+ require_relative "list"
8
+
9
+ module TuiTui
10
+ module Widget
11
+ # Drawing companion for ScrollList with a fixed header row.
12
+ # The first row of the rect is the header (built from `columns:`, an array
13
+ # of [label, width] pairs); the remaining rows are the scrolled body, drawn
14
+ # by Widget::List. Cell content comes from the caller's block, keeping the
15
+ # table domain-agnostic: `yield(index, selected)` returns one value per
16
+ # column — a String (styled with the row's base style) or a Span (its own
17
+ # style wins, e.g. to accent one cell). Labels may be Spans too.
18
+ #
19
+ # Each cell is truncated/padded to its column width (CJK-safe via
20
+ # DisplayText), columns are joined with a 1-space gap, and anything past
21
+ # the rect's right edge is clipped.
22
+ class Table
23
+ COLUMN_GAP = 1
24
+
25
+ def initialize(scroll)
26
+ @scroll = scroll
27
+ @list = List.new(scroll)
28
+ end
29
+
30
+ def draw(canvas, rect, columns:, header_style: nil, highlight: nil, scrollbar: nil)
31
+ header, body = rect.split_rows(1, :rest)
32
+ draw_header(canvas, header, columns, header_style)
33
+ return canvas if body.rows <= 0
34
+
35
+ @list.draw(canvas, body, highlight: highlight, scrollbar: scrollbar) do |index, selected|
36
+ format_row(yield(index, selected), columns, selected ? highlight : nil)
37
+ end
38
+ end
39
+
40
+ # Map a MouseEvent to the row index under it, or nil. The header row and
41
+ # anything outside the body map to nil. Pass the same `rect` and
42
+ # `scrollbar:` used for `draw`.
43
+ def index_at(rect, event, scrollbar: nil)
44
+ _header, body = rect.split_rows(1, :rest)
45
+ return nil if body.rows <= 0
46
+
47
+ @list.index_at(body, event, scrollbar: scrollbar)
48
+ end
49
+
50
+ private
51
+
52
+ def draw_header(canvas, rect, columns, style)
53
+ return if rect.rows <= 0 || rect.cols <= 0
54
+
55
+ canvas.fill(rect, style)
56
+ line = format_row(columns.map { |label, _width| label }, columns, style)
57
+ canvas.line(rect.row, rect.col, line.truncate(rect.cols))
58
+ end
59
+
60
+ # One Line for a row: each cell fitted to its column width, joined with
61
+ # a base-styled gap so a highlight fill is not punched through.
62
+ def format_row(cells, columns, base_style)
63
+ spans = []
64
+ columns.each_with_index do |(_label, width), index|
65
+ spans << Span[" " * COLUMN_GAP, base_style] unless spans.empty?
66
+ spans << fit(cells[index], width, base_style)
67
+ end
68
+
69
+ Line.new(spans)
70
+ end
71
+
72
+ # Truncate/pad one cell to `width` columns. A Span keeps its own style;
73
+ # anything else is rendered with the row's base style.
74
+ def fit(cell, width, base_style)
75
+ style = cell.is_a?(Span) ? cell.style : base_style
76
+ text = DisplayText.new(cell.is_a?(Span) ? cell.text : cell.to_s).truncate(width)
77
+ Span[text.to_s + (" " * [width - text.width, 0].max), style]
78
+ end
79
+ end
80
+ end
81
+ end