tuile 0.1.0 → 0.2.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: 246774aae8809b045b95adef86263642f561d726cb907162101cef1c94135142
4
- data.tar.gz: d6858c0ece1e461a5ff767572ac1fd6255eb338a84caff1b12d0a0db66aec6b0
3
+ metadata.gz: 7e96464e067ccbb78bd11cf6639b53dc4f53de48519f7e47143a594b520bb99c
4
+ data.tar.gz: 668bd3ac1b4130919949dce95867cccfa89dac387b6f164a2c7ea027cf04c1da
5
5
  SHA512:
6
- metadata.gz: 97ecd56ae772b96c8944d78245ee628f56dbcf4a4c0a955319bc8d1bf630132dd6b23ec6bb23899a97f45b6b4b92b3686fd548c056bc9eb107caa06bcaf97a89
7
- data.tar.gz: 6b0f6c0f887ede7a2e7aa3309241b3b39844f5a2819f92fafdee52b1be4e88a2c9fc5a32f18ddcc3e0a671c0dedb4724de833da75d4c391a62619b8d4be58136
6
+ metadata.gz: 6e9e20049c86bab8649b3d53604a61438f27608ae62d009c6a556fe76b146e186072c46a4c5036173c3b40197d8648dee855dce401d4536060f269e159a40c98
7
+ data.tar.gz: 2ddccc6f2d1664935ba7c64b523f09b3a1ba9a57c7c356620beef39fd623b4a76a60d7fd1fda109aa11e9a5693a911cc1736d0f07dc99aad507008726d3fd301
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-05-15
4
+
5
+ - Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
6
+ - Add `Component::Button`.
7
+ - Add Tab / Shift+Tab focus cycling.
8
+ - Add Ctrl+arrow word navigation to `Component::TextField`.
9
+ - Add `Component::List#on_cursor_changed`.
10
+ - Add `examples/sampler.rb`.
11
+ - Paint `TextField` with a colored background.
12
+ - Buffer `Screen#print` into a per-frame buffer during repaint, and release it on exception.
13
+ - Join the key thread after killing it in `run_loop`'s ensure block.
14
+ - Auto-clear gappy children in `Component#repaint`.
15
+ - Inline a minimal truncation helper and drop the `strings-truncation` dependency.
16
+ - Lower the Ruby floor to 3.4; pin CI head to 4.0; fix Ruby 3.4 compatibility.
17
+ - Bump `minitest` to 6.0.
18
+ - Document `TextField` SGR constants; refresh `sig/tuile.rbs`.
19
+
3
20
  ## [0.1.0] - 2026-05-02
4
21
 
5
22
  - Initial release
data/README.md CHANGED
@@ -28,31 +28,28 @@ Ruby that we are aware of.
28
28
 
29
29
  ## Installation
30
30
 
31
- > **Note:** the gem name on RubyGems is being finalised. Until release, install
32
- > from git (see below). Replace
33
- > `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with
34
- > the gem name once published.
35
-
36
31
  Install the gem and add it to the application's Gemfile by executing:
37
32
 
38
33
  ```bash
39
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
34
+ bundle add tuile
40
35
  ```
41
36
 
42
37
  If bundler is not being used to manage dependencies, install the gem by
43
38
  executing:
44
39
 
45
40
  ```bash
46
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
41
+ gem install tuile
47
42
  ```
48
43
 
49
- Until then, point your `Gemfile` at git:
44
+ Or pin to git directly:
50
45
 
51
46
  ```ruby
52
47
  gem "tuile", git: "https://github.com/mvysny/tuile.git"
53
48
  ```
54
49
 
55
- Tuile requires Ruby 4.0+.
50
+ Tuile requires Ruby 3.4+.
51
+
52
+ API documentation: <https://rubydoc.info/gems/tuile>.
56
53
 
57
54
  ## Hello world
58
55
 
@@ -156,22 +156,8 @@ module FileCommanderExample
156
156
  rect.width - half, body_height)
157
157
  end
158
158
 
159
- def handle_key(key)
160
- if key == "\t"
161
- toggle_focus
162
- true
163
- else
164
- super
165
- end
166
- end
167
-
168
159
  private
169
160
 
170
- def toggle_focus
171
- target = @left_window.active? ? @right_window : @left_window
172
- screen.focused = target
173
- end
174
-
175
161
  def refresh_header
176
162
  active_list = @left_list.active? ? @left_list : @right_list
177
163
  @header.text = " #{active_list.cwd}"
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Tuile sampler. Two-pane demo app showcasing the components shipped with
5
+ # the framework. The left pane is a navigation list; moving the cursor
6
+ # loads the highlighted demo into the right pane. Tab / Shift+Tab move
7
+ # focus between the list and the demo's widgets.
8
+ #
9
+ # Run from the gem root:
10
+ # bundle exec ruby -Ilib examples/sampler.rb
11
+ #
12
+ # Keys (global): q or ESC to quit.
13
+
14
+ require "tuile"
15
+
16
+ module SamplerExample
17
+ # Sampler-local container: a {Tuile::Component::Layout::Absolute} that
18
+ # runs a caller-supplied block on `rect=` to position its children.
19
+ # Sampler demos sometimes have a 1-row Label sitting in a tall pane,
20
+ # but the stock layout's auto-clear already handles those gaps for us
21
+ # — Panel just needs the rect-callback to drive child positioning.
22
+ class Panel < Tuile::Component::Layout::Absolute
23
+ def initialize(&layout_block)
24
+ super()
25
+ @layout_block = layout_block
26
+ end
27
+
28
+ def rect=(new_rect)
29
+ super
30
+ @layout_block&.call(rect) unless rect.empty?
31
+ end
32
+ end
33
+
34
+ # Top-level sampler component. Splits the screen into a left entry list
35
+ # and a right demo pane; each `load_entry` rebuilds the demo from
36
+ # scratch so it always starts in a clean state.
37
+ class Sampler < Tuile::Component::Layout::Absolute
38
+ def initialize
39
+ super()
40
+ @entry_list = build_entry_list
41
+ @left_window = Tuile::Component::Window.new("Components").tap { it.content = @entry_list }
42
+ @right_window = Tuile::Component::Window.new
43
+ add(@left_window)
44
+ add(@right_window)
45
+ load_entry(0)
46
+ end
47
+
48
+ attr_reader :left_window, :right_window, :entry_list
49
+
50
+ def rect=(new_rect)
51
+ super
52
+ return if rect.empty?
53
+
54
+ list_width = (rect.width / 3).clamp(20, 40)
55
+ @left_window.rect = Tuile::Rect.new(rect.left, rect.top, list_width, rect.height)
56
+ @right_window.rect = Tuile::Rect.new(rect.left + list_width, rect.top,
57
+ rect.width - list_width, rect.height)
58
+ end
59
+
60
+ private
61
+
62
+ # Ordered list of demo entries: `[caption, builder_method]`. The
63
+ # builder runs at selection time, so every load gets a fresh component
64
+ # tree (an empty TextField, an un-clicked Button, etc.).
65
+ ENTRIES = [
66
+ ["Label", :build_label],
67
+ ["TextField", :build_text_field],
68
+ ["TextArea", :build_text_area],
69
+ ["Button", :build_buttons],
70
+ ["List", :build_list],
71
+ ["Layout", :build_layout],
72
+ ["Popup", :build_popup_launcher],
73
+ ["InfoWindow", :build_info_launcher],
74
+ ["PickerWindow", :build_picker_launcher],
75
+ ["LogWindow", :build_log_window],
76
+ ["Focus & Tab", :build_focus_demo]
77
+ ].freeze
78
+
79
+ def build_entry_list
80
+ list = Tuile::Component::List.new
81
+ list.cursor = Tuile::Component::List::Cursor.new
82
+ list.lines = ENTRIES.map(&:first)
83
+ list.on_cursor_changed = ->(idx, _line) { load_entry(idx) if idx >= 0 }
84
+ list
85
+ end
86
+
87
+ def load_entry(idx)
88
+ caption, builder = ENTRIES[idx]
89
+ @right_window.caption = caption
90
+ @right_window.content = send(builder)
91
+ end
92
+
93
+ # --- Tileable demos ----------------------------------------------------
94
+
95
+ def build_label
96
+ label = Tuile::Component::Label.new
97
+ label.text = "Label paints static text in its rect.\n" \
98
+ "Multiple lines split on \\n.\n" \
99
+ "Long lines are clipped to the rect width.\n\n" \
100
+ "Rainbow formatting works too:\n" \
101
+ " #{Rainbow("* red").red}\n" \
102
+ " #{Rainbow("* green").green}\n" \
103
+ " #{Rainbow("* blue").blue}"
104
+ label
105
+ end
106
+
107
+ def build_text_field
108
+ prompt = Tuile::Component::Label.new
109
+ prompt.text = "Tab here, then type. Arrows, Home/End, Backspace, Delete all work."
110
+ field = Tuile::Component::TextField.new
111
+ panel(prompt, field) do |r|
112
+ inner = inner_rect(r)
113
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 1)
114
+ field.rect = Tuile::Rect.new(inner.left, inner.top + 3, inner.width, 1)
115
+ end
116
+ end
117
+
118
+ def build_text_area
119
+ prompt = Tuile::Component::Label.new
120
+ prompt.text = "Multi-line input. Type to see word wrap; Enter inserts a newline.\n" \
121
+ "Arrows move the caret; Ctrl+Left/Right jump by word; " \
122
+ "Home/End jump to row start/end; Up/Down at the first/last row jumps to text start/end.\n" \
123
+ "Overflowing rows scroll vertically to keep the caret visible."
124
+ area = Tuile::Component::TextArea.new
125
+ area.text = "The quick brown fox jumps over the lazy dog. " \
126
+ "Edit me — the text wraps to the area's width and scrolls vertically " \
127
+ "once the cursor leaves the visible rows."
128
+ panel(prompt, area) do |r|
129
+ inner = inner_rect(r)
130
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
131
+ area_height = [inner.height - 6, 4].max
132
+ area.rect = Tuile::Rect.new(inner.left, inner.top + 5, inner.width, area_height)
133
+ end
134
+ end
135
+
136
+ def build_buttons
137
+ label = Tuile::Component::Label.new
138
+ label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
139
+ counters = { ok: 0, cancel: 0 }
140
+ result = Tuile::Component::Label.new
141
+ refresh = -> { result.text = "Clicks: OK=#{counters[:ok]} Cancel=#{counters[:cancel]}" }
142
+ refresh.call
143
+ ok = Tuile::Component::Button.new("OK") do
144
+ counters[:ok] += 1
145
+ refresh.call
146
+ end
147
+ cancel = Tuile::Component::Button.new("Cancel") do
148
+ counters[:cancel] += 1
149
+ refresh.call
150
+ end
151
+ panel(label, ok, cancel, result) do |r|
152
+ inner = inner_rect(r)
153
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
154
+ ok.rect = Tuile::Rect.new(inner.left, inner.top + 4, ok.content_size.width, 1)
155
+ cancel.rect = Tuile::Rect.new(inner.left + ok.content_size.width + 2, inner.top + 4,
156
+ cancel.content_size.width, 1)
157
+ result.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
158
+ end
159
+ end
160
+
161
+ def build_list
162
+ list = Tuile::Component::List.new
163
+ list.cursor = Tuile::Component::List::Cursor.new
164
+ list.lines = (1..40).map { |i| "Item #{i}" }
165
+ list.scrollbar_visibility = :visible
166
+ list
167
+ end
168
+
169
+ def build_layout
170
+ left = Tuile::Component::Window.new("Left")
171
+ left.content = Tuile::Component::Label.new.tap { it.text = "Nested left window." }
172
+ right = Tuile::Component::Window.new("Right")
173
+ right.content = Tuile::Component::Label.new.tap { it.text = "Nested right window." }
174
+ panel(left, right) do |r|
175
+ half = r.width / 2
176
+ left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
177
+ right.rect = Tuile::Rect.new(r.left + half, r.top, r.width - half, r.height)
178
+ end
179
+ end
180
+
181
+ # --- Modal launchers ---------------------------------------------------
182
+
183
+ def build_popup_launcher
184
+ launcher(
185
+ "Popup is a modal overlay wrapping any Component.\n" \
186
+ "ESC or q closes it.",
187
+ "Open Popup"
188
+ ) do
189
+ list = Tuile::Component::List.new
190
+ list.lines = ["Hello", "from", "a Popup!", "", "Press ESC to close."]
191
+ Tuile::Component::Popup.new(content: list).open
192
+ end
193
+ end
194
+
195
+ def build_info_launcher
196
+ launcher(
197
+ "InfoWindow is a Window of read-only text lines, openable as a popup.",
198
+ "Open InfoWindow"
199
+ ) do
200
+ Tuile::Component::InfoWindow.open(
201
+ "Hello",
202
+ ["InfoWindow displays static text",
203
+ "inside a popup.",
204
+ "",
205
+ "Press ESC or q to close."]
206
+ )
207
+ end
208
+ end
209
+
210
+ def build_picker_launcher
211
+ launcher(
212
+ "PickerWindow asks the user to pick one option by a single keystroke.",
213
+ "Open PickerWindow"
214
+ ) do
215
+ Tuile::Component::PickerWindow.open(
216
+ "Pick a fruit",
217
+ [%w[a Apple], %w[b Banana], %w[c Cherry]]
218
+ ) { |key| Tuile.logger.info("Picked: #{key}") }
219
+ end
220
+ end
221
+
222
+ def build_log_window
223
+ log = Tuile::Component::LogWindow.new("Log")
224
+ log.content.add_lines([
225
+ "LogWindow is a Window wrapping an auto-scrolling List.",
226
+ "Lines are appended via #add_line / #add_lines.",
227
+ "Used with Logger::IO it captures arbitrary log output."
228
+ ])
229
+ log
230
+ end
231
+
232
+ # --- Cross-cutting -----------------------------------------------------
233
+
234
+ def build_focus_demo
235
+ label = Tuile::Component::Label.new
236
+ label.text = "Tab and Shift+Tab cycle focus through the tab stops below.\n" \
237
+ "The active button highlights its background; the field shows a caret."
238
+ a = Tuile::Component::Button.new("Button A")
239
+ b = Tuile::Component::Button.new("Button B")
240
+ field = Tuile::Component::TextField.new
241
+ panel(label, a, b, field) do |r|
242
+ inner = inner_rect(r)
243
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
244
+ a.rect = Tuile::Rect.new(inner.left, inner.top + 4, a.content_size.width, 1)
245
+ b.rect = Tuile::Rect.new(inner.left + a.content_size.width + 2, inner.top + 4,
246
+ b.content_size.width, 1)
247
+ field.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, 1)
248
+ end
249
+ end
250
+
251
+ # --- Helpers -----------------------------------------------------------
252
+
253
+ def panel(*children, &layout_block)
254
+ p = Panel.new(&layout_block)
255
+ p.add(children)
256
+ p
257
+ end
258
+
259
+ def launcher(description, button_caption, &on_click)
260
+ label = Tuile::Component::Label.new
261
+ label.text = description
262
+ button = Tuile::Component::Button.new(button_caption, &on_click)
263
+ panel(label, button) do |r|
264
+ inner = inner_rect(r)
265
+ label.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 3)
266
+ button.rect = Tuile::Rect.new(inner.left, inner.top + 5, button.content_size.width, 1)
267
+ end
268
+ end
269
+
270
+ # Carves a 2-column padding out of the panel rect so the demo content
271
+ # doesn't run flush to the window border.
272
+ def inner_rect(rect)
273
+ pad = 2
274
+ Tuile::Rect.new(rect.left + pad, rect.top, [rect.width - (pad * 2), 0].max, rect.height)
275
+ end
276
+ end
277
+ end
278
+
279
+ screen = Tuile::Screen.new
280
+ sampler = SamplerExample::Sampler.new
281
+ screen.content = sampler
282
+ sampler.entry_list.focus
283
+ begin
284
+ screen.run_event_loop
285
+ ensure
286
+ screen.close
287
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A clickable button. Activated by Enter, Space, or a left mouse click;
6
+ # fires the {#on_click} callback. Renders as `[ caption ]` on a single
7
+ # row; the background is highlighted when the button is focused so the
8
+ # user can see which button is active.
9
+ #
10
+ # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
11
+ # the standard focus cycle. Click-to-focus also works via the inherited
12
+ # {Component#handle_mouse}.
13
+ #
14
+ # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
+ # show `[ caption ]`; {#content_size} reports that natural width.
16
+ class Button < Component
17
+ # @param caption [String] the button's label.
18
+ # @yield optional `on_click` callback; same as assigning {#on_click=}.
19
+ def initialize(caption = "", &on_click)
20
+ super()
21
+ @caption = caption.to_s
22
+ @on_click = on_click
23
+ end
24
+
25
+ # @return [String] the button's label.
26
+ attr_reader :caption
27
+
28
+ # Callback fired when the button is activated (Enter, Space, or
29
+ # left-click). The callable receives no arguments.
30
+ # @return [Proc, Method, nil] no-arg callable, or nil.
31
+ attr_accessor :on_click
32
+
33
+ # Sets a new caption and invalidates the button. No-op if unchanged.
34
+ # @param new_caption [String]
35
+ def caption=(new_caption)
36
+ new_caption = new_caption.to_s
37
+ return if @caption == new_caption
38
+
39
+ @caption = new_caption
40
+ invalidate
41
+ end
42
+
43
+ def focusable? = true
44
+
45
+ def tab_stop? = true
46
+
47
+ # @return [Size] natural width is `caption.length + 4` to fit
48
+ # `[ caption ]`; height is 1.
49
+ def content_size = Size.new(@caption.length + 4, 1)
50
+
51
+ # @param key [String]
52
+ # @return [Boolean]
53
+ def handle_key(key)
54
+ return false unless active?
55
+ return true if super
56
+
57
+ case key
58
+ when Keys::ENTER, " "
59
+ @on_click&.call
60
+ true
61
+ else
62
+ false
63
+ end
64
+ end
65
+
66
+ # @param event [MouseEvent]
67
+ # @return [void]
68
+ def handle_mouse(event)
69
+ super
70
+ return unless event.button == :left && rect.contains?(event.point)
71
+
72
+ @on_click&.call
73
+ end
74
+
75
+ # @return [void]
76
+ def repaint
77
+ super
78
+ return if rect.empty?
79
+
80
+ label = "[ #{@caption} ]"[0, rect.width]
81
+ styled = active? ? Rainbow(label).bg(:darkslategray) : label
82
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), styled
83
+ end
84
+ end
85
+ end
86
+ end
@@ -29,7 +29,7 @@ module Tuile
29
29
 
30
30
  # @return [void]
31
31
  def repaint
32
- clear_background
32
+ super
33
33
  height = rect.height.clamp(0, nil)
34
34
  lines_to_print = @clipped_lines.length.clamp(nil, height)
35
35
  (0..lines_to_print - 1).each do |index|
@@ -51,7 +51,7 @@ module Tuile
51
51
  def update_clipped_text
52
52
  len = rect.width.clamp(0, nil)
53
53
  clipped = @lines.map do |line|
54
- Strings::Truncation.truncate(line, length: len)
54
+ Truncate.truncate(line, length: len)
55
55
  end
56
56
  return if @clipped_lines == clipped
57
57
 
@@ -5,9 +5,11 @@ module Tuile
5
5
  # A layout doesn't paint anything by itself: its job is to position child
6
6
  # components.
7
7
  #
8
- # All children must completely cover the contents of a layout: that way,
9
- # the layout itself doesn't have to draw and no clipping algorithm is
10
- # necessary.
8
+ # Children that fully tile the layout's rect repaint themselves and
9
+ # cover everything; children that leave gaps (e.g. a form with widgets
10
+ # of varying widths) trigger {Component#repaint}'s default behavior —
11
+ # the background is cleared and children are re-invalidated so they
12
+ # paint over a clean surface.
11
13
  class Layout < Component
12
14
  def initialize
13
15
  super
@@ -17,6 +19,16 @@ module Tuile
17
19
  # @return [Array<Component>]
18
20
  def children = @children.to_a
19
21
 
22
+ # Layouts are focusable containers — like {Window} and {Popup}, they
23
+ # don't accept input themselves but they need to participate in the
24
+ # {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
25
+ # {TextField} ends up focusing the field rather than parking focus on
26
+ # the popup. Layouts don't paint any visible chrome of their own
27
+ # (the auto-cleared background is just blank space), so this has no
28
+ # mouse-routing consequences — clicks on a gap area land back on the
29
+ # Layout itself and the on_focus cascade forwards to a tab stop.
30
+ def focusable? = true
31
+
20
32
  # Adds a child component to this layout.
21
33
  # @param child [Component, Array<Component>]
22
34
  # @return [void]
@@ -53,11 +65,6 @@ module Tuile
53
65
  Size.new(right - rect.left, bottom - rect.top)
54
66
  end
55
67
 
56
- # @return [void]
57
- def repaint
58
- clear_background if @children.empty?
59
- end
60
-
61
68
  # Dispatches the event to the child under the mouse cursor.
62
69
  # @param event [MouseEvent]
63
70
  # @return [void]
@@ -83,10 +90,20 @@ module Tuile
83
90
  # @return [void]
84
91
  def on_focus
85
92
  super
86
- # Let the content component receive focus, so that it can immediately
87
- # start responding to key presses.
88
- first_focusable = @children.find(&:focusable?)
89
- screen.focused = first_focusable unless first_focusable.nil?
93
+ # Forward focus to the first interactive widget in the subtree so the
94
+ # user can start typing / cursoring immediately. Prefer a {#tab_stop?}
95
+ # descendant (TextField, List, Button…) so we skip past intermediate
96
+ # containers like a {Window} or another {Layout}. Fall back to the
97
+ # first focusable direct child for the rare case where the layout has
98
+ # focusable but non-tab-stop children (e.g. an empty {Window}).
99
+ first_tab_stop = nil
100
+ on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
101
+ if first_tab_stop
102
+ screen.focused = first_tab_stop
103
+ else
104
+ first_focusable = @children.find(&:focusable?)
105
+ screen.focused = first_focusable unless first_focusable.nil?
106
+ end
90
107
  end
91
108
 
92
109
  # Absolute layout. Extend this class, register any children, and