tuile 0.13.0 → 0.15.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Terminal mouse-mode probe — fills one row of `ideas/hover.md`'s matrix.
5
+ # Research tooling for that idea; it dies with the note at graduation.
6
+ #
7
+ # Deliberately depends on nothing but stdlib: this measures the *terminal*, so
8
+ # Tuile's own parsing (and its fixed 5-byte escape gulp) must stay out of the
9
+ # way. Bytes are read greedily and parsed incrementally, so the log shows what
10
+ # actually arrived rather than what a fixed-width reader would have made of it.
11
+ #
12
+ # Run it in its OWN terminal / tmux window — never in one where another TUI
13
+ # owns stdin, and never in the terminal a coding agent is drawing on: mode 1003
14
+ # would pour motion reports into *its* stdin.
15
+ #
16
+ # ruby ideas/hover/probe.rb [logfile]
17
+ #
18
+ # Rows recorded so far live in `ideas/hover.md` under "Measured". Still wanted:
19
+ # Alacritty bare, Alacritty over ssh, tmux local. The logs themselves are not
20
+ # committed — a re-run regenerates them, and the findings are what graduate.
21
+ #
22
+ # The parser is covered by ideas/hover/probe_spec.rb — run that before trusting
23
+ # an interactive session, since a decode slip wastes the whole run (it already
24
+ # caught an X10 coordinate off-by-one: the button code is byte-32, but the
25
+ # coordinates are 1-based *and* offset, so they decode as byte-33).
26
+
27
+ require "io/console"
28
+
29
+ LOG = if __FILE__ == $PROGRAM_NAME
30
+ File.open(ARGV[0] || "mouse_probe.log", "w").tap { _1.sync = true }
31
+ else
32
+ $stderr # loaded for testing: never open a log, never touch the terminal
33
+ end
34
+
35
+ def say(msg) = ($stdout.print("#{msg}\r\n"); $stdout.flush)
36
+ def log(msg) = LOG.puts(msg)
37
+ def esc(seq) = ($stdout.print(seq); $stdout.flush)
38
+
39
+ def both(msg)
40
+ say msg
41
+ log msg
42
+ end
43
+
44
+ # --- incremental parser -----------------------------------------------------
45
+ # Returns [events, leftover]. Each event is [kind, detail].
46
+ def parse(buf)
47
+ events = []
48
+ until buf.empty?
49
+ if buf.start_with?("\e[M")
50
+ break if buf.bytesize < 6
51
+
52
+ # Button code is offset by 32; coordinates are 1-based *and* offset by 32,
53
+ # so they come back with -33. Mixing these up is the classic X10 slip.
54
+ cb, cx, cy = buf[3].ord - 32, buf[4].ord - 33, buf[5].ord - 33
55
+ events << [:x10, decode_cb(cb) + " at #{cx},#{cy}"]
56
+ buf = buf[6..]
57
+ elsif (m = buf.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])/))
58
+ cb = m[1].to_i
59
+ kind = m[4] == "M" ? "press" : "release"
60
+ events << [:sgr, "#{decode_cb(cb)} [#{kind}] at #{m[2].to_i - 1},#{m[3].to_i - 1}"]
61
+ buf = buf[m[0].bytesize..]
62
+ elsif buf.start_with?("\e[I")
63
+ events << [:focus, "FocusIn"]
64
+ buf = buf[3..]
65
+ elsif buf.start_with?("\e[O")
66
+ events << [:focus, "FocusOut"]
67
+ buf = buf[3..]
68
+ elsif (m = buf.match(/\A\e\[\?(\d+);(\d+)\$y/))
69
+ events << [:decrpm, "mode #{m[1]} => #{decrpm_meaning(m[2].to_i)}"]
70
+ buf = buf[m[0].bytesize..]
71
+ elsif buf.start_with?("\e[<") || buf.start_with?("\e[?")
72
+ break # incomplete, wait for more bytes
73
+ else
74
+ events << [:other, buf[0].inspect]
75
+ buf = buf[1..]
76
+ end
77
+ end
78
+ [events, buf]
79
+ end
80
+
81
+ def decode_cb(code)
82
+ base = code & 0b11
83
+ mods = []
84
+ mods << "shift" if code & 4 != 0
85
+ mods << "meta" if code & 8 != 0
86
+ mods << "ctrl" if code & 16 != 0
87
+ motion = code & 32 != 0
88
+ wheel = code & 64 != 0
89
+ name =
90
+ if wheel
91
+ %w[wheel_up wheel_down wheel_left wheel_right][base] || "wheel?#{base}"
92
+ else
93
+ %w[left middle right release][base]
94
+ end
95
+ "code=#{code} #{motion ? 'MOTION ' : ''}#{name}#{mods.empty? ? '' : " +#{mods.join('+')}"}"
96
+ end
97
+
98
+ def decrpm_meaning(ps)
99
+ { 0 => "NOT RECOGNIZED", 1 => "set", 2 => "reset (supported)",
100
+ 3 => "permanently set", 4 => "permanently reset" }[ps] || "unknown(#{ps})"
101
+ end
102
+
103
+ # --- drain ------------------------------------------------------------------
104
+ # Reads for `seconds`, returning every parsed event. `stop_on` ends it early.
105
+ def drain(seconds, stop_on: nil)
106
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
107
+ buf = +""
108
+ out = []
109
+ loop do
110
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ break if left <= 0
112
+ next unless IO.select([$stdin], nil, nil, [left, 0.2].min)
113
+
114
+ chunk = $stdin.read_nonblock(4096)
115
+ # Timestamp the *read*, and record the chunk size: inter-arrival gaps and
116
+ # multi-event chunks are how we tell whether ssh/tmux coalesced anything.
117
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
+ buf << chunk
119
+ events, buf = parse(buf)
120
+ log format(" [read %+7.3fs %3dB -> %d ev]", now - deadline + seconds, chunk.bytesize, events.size) if events.any?
121
+ events.each do |kind, detail|
122
+ out << [kind, detail]
123
+ log " #{kind}: #{detail}"
124
+ end
125
+ break if stop_on && buf.empty? && out.any? { |(_, d)| d.include?(stop_on) }
126
+ end
127
+ out
128
+ end
129
+
130
+ def wait_for_enter
131
+ say " [press ENTER to start]"
132
+ loop do
133
+ IO.select([$stdin], nil, nil, 60) or break
134
+ break if $stdin.read_nonblock(4096).include?("\r") || false
135
+ rescue IO::WaitReadable, EOFError
136
+ break
137
+ end
138
+ end
139
+
140
+ ALL_OFF = "\e[?1000l\e[?1002l\e[?1003l\e[?1004l\e[?1006l"
141
+
142
+ return unless __FILE__ == $PROGRAM_NAME
143
+
144
+ begin
145
+ $stdin.raw do
146
+ log "=== mouse probe #{Time.now.iso8601 rescue Time.now} ==="
147
+ log "TERM=#{ENV['TERM']} COLORTERM=#{ENV['COLORTERM']} TMUX=#{ENV['TMUX']}"
148
+ log "size=#{IO.console.winsize.reverse.join('x')}"
149
+ log ""
150
+
151
+ # -- Phase 0: DECRQM support ---------------------------------------------
152
+ both "PHASE 0 DECRQM mode-support probe (no mouse needed)"
153
+ [9, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016].each do |mode|
154
+ esc "\e[?#{mode}$p"
155
+ got = drain(0.3)
156
+ both " #{mode}: #{got.empty? ? 'NO REPLY' : got.map(&:last).join(', ')}"
157
+ end
158
+ log ""
159
+
160
+ # -- Phase 1: 1000 + X10 -------------------------------------------------
161
+ both ""
162
+ both "PHASE 1 mode 1000, X10 encoding — click a few times (5s)"
163
+ wait_for_enter
164
+ esc "\e[?1000h"
165
+ got = drain(5)
166
+ esc "\e[?1000l"
167
+ both " => #{got.size} events; releases seen: #{got.count { |(_, d)| d.include?('release') }}"
168
+ log ""
169
+
170
+ # -- Phase 2: 1000 + SGR 1006 -------------------------------------------
171
+ both ""
172
+ both "PHASE 2 mode 1000 + SGR 1006 — click a few times, incl. right-click (5s)"
173
+ wait_for_enter
174
+ esc "\e[?1006h\e[?1000h"
175
+ got = drain(5)
176
+ esc "\e[?1000l"
177
+ sgr = got.count { |(k, _)| k == :sgr }
178
+ both " => #{got.size} events, #{sgr} in SGR form (0 means 1006 was ignored)"
179
+ log ""
180
+
181
+ # -- Phase 3: 1002 -------------------------------------------------------
182
+ both ""
183
+ both "PHASE 3 mode 1002 — FIRST move with no button (expect nothing),"
184
+ both " THEN drag with the left button held (expect motion) (8s)"
185
+ wait_for_enter
186
+ esc "\e[?1002h"
187
+ got = drain(8)
188
+ esc "\e[?1002l"
189
+ motion = got.count { |(_, d)| d.include?("MOTION") }
190
+ both " => #{got.size} events, #{motion} motion"
191
+ log ""
192
+
193
+ # -- Phase 4: 1003 rate --------------------------------------------------
194
+ both ""
195
+ both "PHASE 4 mode 1003 — move the mouse around normally, NON-STOP (10s)"
196
+ wait_for_enter
197
+ esc "\e[?1003h"
198
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
199
+ got = drain(10)
200
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
201
+ esc "\e[?1003l"
202
+ motion = got.count { |(_, d)| d.include?("MOTION") }
203
+ both format(" => %d events (%d motion) in %.1fs = %.1f/s <-- THE NUMBER",
204
+ got.size, motion, elapsed, got.size / elapsed)
205
+ log ""
206
+
207
+ # -- Phase 5: pointer leaves the window ---------------------------------
208
+ both ""
209
+ both "PHASE 5 mode 1003 + 1004 — move the pointer OUT of this window,"
210
+ both " click another app, then come back (10s)"
211
+ wait_for_enter
212
+ esc "\e[?1003h\e[?1004h"
213
+ got = drain(10)
214
+ esc "\e[?1003l\e[?1004l"
215
+ focus = got.select { |(k, _)| k == :focus }
216
+ both " => #{got.size} events; focus events: #{focus.empty? ? 'NONE' : focus.map(&:last).join(', ')}"
217
+ both " (NONE means nothing tells us the pointer left — hover would strand)"
218
+ log ""
219
+
220
+ # -- Phase 6: wide coordinates ------------------------------------------
221
+ cols = IO.console.winsize[1]
222
+ both ""
223
+ if cols > 223
224
+ both "PHASE 6 mode 1003 + 1006 — click PAST column 223 (width=#{cols}) (8s)"
225
+ wait_for_enter
226
+ esc "\e[?1006h\e[?1003h"
227
+ got = drain(8)
228
+ esc "\e[?1003l\e[?1006l"
229
+ both " => #{got.size} events; max x seen: #{got.map { |(_, d)| d[/at (\d+),/, 1].to_i }.max}"
230
+ else
231
+ both "PHASE 6 SKIPPED — terminal is #{cols} cols, need >223 to test the X10 cap"
232
+ log "PHASE 6 skipped: width #{cols}"
233
+ end
234
+
235
+ both ""
236
+ both "done — log written to #{LOG.path}"
237
+ end
238
+ ensure
239
+ esc ALL_OFF
240
+ LOG.close
241
+ end
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Covers probe.rb's byte decoder. Deliberately NOT under spec/ — this is
5
+ # research tooling for ideas/hover.md, not gem code, so it must stay out of
6
+ # `rake spec` and out of CI. Plain assertions, no rspec:
7
+ #
8
+ # ruby ideas/hover/probe_spec.rb
9
+ #
10
+ # Run it before an interactive probe session: a decode slip wastes the whole
11
+ # run, and one already happened (X10 coordinates are byte-33, not byte-32 —
12
+ # the offset differs from the button code's because coordinates are 1-based).
13
+
14
+ require_relative "probe"
15
+
16
+ FAILURES = []
17
+
18
+ def check(input, want)
19
+ events, leftover = parse(input.dup)
20
+ got = events.map { |kind, detail| "#{kind}: #{detail}" }
21
+ if got == want && leftover.empty?
22
+ puts " ok #{input.inspect}"
23
+ else
24
+ FAILURES << input
25
+ puts " FAIL #{input.inspect}\n got #{got.inspect} leftover=#{leftover.inspect}\n want #{want.inspect}"
26
+ end
27
+ end
28
+
29
+ # A fragment must be held for the next read, never half-consumed: real reads
30
+ # do not align to event boundaries (measured: 4 reads in one 10s sample came
31
+ # back at 11.5 bytes/event, i.e. one event split across two reads).
32
+ def check_held(fragment)
33
+ events, leftover = parse(fragment.dup)
34
+ if events.empty? && leftover == fragment
35
+ puts " ok held #{fragment.inspect}"
36
+ else
37
+ FAILURES << fragment
38
+ puts " FAIL #{fragment.inspect} -> #{events.inspect} leftover=#{leftover.inspect}"
39
+ end
40
+ end
41
+
42
+ puts "X10 encoding (button code is byte-32; coordinates are byte-33):"
43
+ check "\e[M !\"", ["x10: code=0 left at 0,1"]
44
+ check "\e[M#!!", ["x10: code=3 release at 0,0"]
45
+ check "\e[M\"!!", ["x10: code=2 right at 0,0"]
46
+ check "\e[M!!!", ["x10: code=1 middle at 0,0"]
47
+ check "\e[M`!!", ["x10: code=64 wheel_up at 0,0"]
48
+ check "\e[Ma!!", ["x10: code=65 wheel_down at 0,0"]
49
+ check "\e[M0!!", ["x10: code=16 left +ctrl at 0,0"]
50
+ puts "X10 motion codes — the set that arrives under 1002/1003:"
51
+ check "\e[M@!!", ["x10: code=32 MOTION left at 0,0"] # drag; all of 1002's traffic
52
+ check "\e[MC!!", ["x10: code=35 MOTION release at 0,0"] # hover; 1003 only
53
+ puts "X10 burst — two reports back to back must split cleanly (a fixed 6-byte"
54
+ puts "read is safe here, which is why a PTY spec may burst X10 but not SGR):"
55
+ check "\e[M !\"\e[M#!\"", ["x10: code=0 left at 0,1", "x10: code=3 release at 0,1"]
56
+
57
+ puts "SGR 1006 — press/release told apart by the final byte, and the release"
58
+ puts "carries its button (X10's release code 3 is anonymous):"
59
+ check "\e[<0;1;1M", ["sgr: code=0 left [press] at 0,0"]
60
+ check "\e[<0;1;1m", ["sgr: code=0 left [release] at 0,0"]
61
+ check "\e[<2;300;40m", ["sgr: code=2 right [release] at 299,39"]
62
+ check "\e[<35;11;6M", ["sgr: code=35 MOTION release [press] at 10,5"]
63
+ check "\e[<64;5;5M", ["sgr: code=64 wheel_up [press] at 4,4"]
64
+ puts "SGR past the X10 cap — the whole reason to request 1006:"
65
+ check "\e[<0;500;9M", ["sgr: code=0 left [press] at 499,8"]
66
+
67
+ puts "focus tracking (mode 1004) and DECRQM replies:"
68
+ check "\e[I", ["focus: FocusIn"]
69
+ check "\e[O", ["focus: FocusOut"]
70
+ check "\e[?1003;2$y", ["decrpm: mode 1003 => reset (supported)"]
71
+ check "\e[?1005;0$y", ["decrpm: mode 1005 => NOT RECOGNIZED"]
72
+
73
+ puts "partial input is held, not consumed:"
74
+ ["\e[M", "\e[M!", "\e[<0;1", "\e[?100"].each { check_held(_1) }
75
+
76
+ puts
77
+ if FAILURES.empty?
78
+ puts "ALL PARSER TESTS PASS"
79
+ else
80
+ puts "#{FAILURES.size} FAILED"
81
+ end
82
+ exit(FAILURES.empty? ? 0 : 1)