grmenu 4.1.0 → 5.0.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.
@@ -0,0 +1,533 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ==============================================================================
4
+ # GRmenu::Widgets::Form - Formulario multi-campo interactivo para la terminal.
5
+ # Permite capturar múltiples datos (texto, contraseñas, números, selectores y booleanos)
6
+ # en una sola pantalla con navegación por Tab/flechas, sombra 3D y retorno como Hash.
7
+ # ==============================================================================
8
+
9
+ class GRmenu
10
+ class FormBuilder
11
+ attr_reader :fields
12
+
13
+ def initialize
14
+ @fields = []
15
+ end
16
+
17
+ def text(key, label, default: "", placeholder: "")
18
+ @fields << {
19
+ key: key.to_sym,
20
+ label: label.to_s,
21
+ type: :text,
22
+ value: String.new(default.to_s),
23
+ placeholder: placeholder.to_s,
24
+ cursor: default.to_s.length
25
+ }
26
+ end
27
+
28
+ def password(key, label, default: "", mask: "*")
29
+ @fields << {
30
+ key: key.to_sym,
31
+ label: label.to_s,
32
+ type: :password,
33
+ value: String.new(default.to_s),
34
+ mask: mask.to_s[0] || "*",
35
+ cursor: default.to_s.length
36
+ }
37
+ end
38
+
39
+ def number(key, label, default: 0, min: nil, max: nil, step: 1)
40
+ str = default.to_s
41
+ @fields << {
42
+ key: key.to_sym,
43
+ label: label.to_s,
44
+ type: :number,
45
+ value: default.to_i,
46
+ min: min,
47
+ max: max,
48
+ step: [step.to_i, 1].max,
49
+ raw_str: str,
50
+ cursor: str.length
51
+ }
52
+ end
53
+
54
+ def select(key, label, options = [], default: nil)
55
+ opts = options.is_a?(Array) ? options.map(&:to_s) : []
56
+ opts = ["(Sin opciones)"] if opts.empty?
57
+ initial_val = default ? default.to_s : opts.first
58
+ initial_val = opts.first unless opts.include?(initial_val)
59
+ @fields << {
60
+ key: key.to_sym,
61
+ label: label.to_s,
62
+ type: :select,
63
+ options: opts,
64
+ value: initial_val
65
+ }
66
+ end
67
+
68
+ def boolean(key, label, default: false)
69
+ @fields << {
70
+ key: key.to_sym,
71
+ label: label.to_s,
72
+ type: :boolean,
73
+ value: !!default
74
+ }
75
+ end
76
+ alias_method :checkbox, :boolean
77
+ end
78
+
79
+ # Despliega un formulario interactivo multi-campo unificado en la consola.
80
+ #
81
+ # @example Usando bloque DSL:
82
+ # datos = GRmenu.form("Nuevo Usuario") do |f|
83
+ # f.text :nombre, "Nombre:", placeholder: "Juan Pérez"
84
+ # f.password :pass, "Contraseña:"
85
+ # f.number :puerto, "Puerto:", default: 8080
86
+ # f.select :rol, "Rol:", ["Admin", "Editor", "Lector"]
87
+ # f.boolean :ssl, "¿Habilitar SSL?", default: true
88
+ # end
89
+ # # => { nombre: "...", pass: "...", puerto: 8080, rol: "Admin", ssl: true }
90
+ def self.form(title = "Formulario de Datos", fields: nil, color: nil, border_color: nil, title_color: nil, focus_color: nil, style: 3, width: nil, shadow: true, cursor_char: "│", submit_label: "Guardar", cancel_label: "Cancelar", center: true, &block)
91
+ builder = FormBuilder.new
92
+ if block_given?
93
+ block.call(builder)
94
+ elsif fields.is_a?(Array)
95
+ fields.each do |fld|
96
+ case (fld[:type] || :text).to_sym
97
+ when :password
98
+ builder.password(fld[:key], fld[:label], default: fld[:default] || "", mask: fld[:mask] || "*")
99
+ when :number
100
+ builder.number(fld[:key], fld[:label], default: fld[:default] || 0, min: fld[:min], max: fld[:max], step: fld[:step] || 1)
101
+ when :select
102
+ builder.select(fld[:key], fld[:label], fld[:options] || [], default: fld[:default])
103
+ when :boolean, :checkbox
104
+ builder.boolean(fld[:key], fld[:label], default: fld[:default] || false)
105
+ else
106
+ builder.text(fld[:key], fld[:label], default: fld[:default] || "", placeholder: fld[:placeholder] || "")
107
+ end
108
+ end
109
+ end
110
+
111
+ all_fields = builder.fields
112
+ return {} if all_fields.empty?
113
+
114
+ form_theme = (@@global_theme.is_a?(Hash) && (@@global_theme.dig(:sections, "form") || @@global_theme.dig(:sections, "input"))) || {}
115
+ style_num = (style || form_theme["style"] || 3).to_i
116
+ border_cfg = BORDERS[style_num] || BORDERS[3]
117
+
118
+ brd_col_name = (border_color || color || form_theme["border_color"] || form_theme["color"] || "cyan").to_s
119
+ tit_col_name = (title_color || form_theme["title_color"] || "yellow").to_s
120
+ foc_col_name = (focus_color || form_theme["focus_color"] || form_theme["focus"] || "green").to_s
121
+ cur_char = (cursor_char || form_theme["cursor"] || "│").to_s[0] || "│"
122
+ is_rgb = (brd_col_name.downcase == "rgb" || brd_col_name.downcase == "rainbow" || brd_col_name.downcase == "chroma")
123
+
124
+ term_w = terminal_width
125
+ label_max_w = all_fields.map { |f| display_width(f[:label]) }.max || 10
126
+ label_col_w = [label_max_w, 24].min
127
+
128
+ title_w = display_width(title.to_s) + 8
129
+ hint_text = "Tab/↑↓: Mover | Enter: Siguiente | Esc: Cancelar"
130
+ hint_w = display_width(hint_text) + 6
131
+
132
+ min_inner = [title_w, label_col_w + 34, hint_w, 50].max
133
+ box_w = width ? width.to_i : min_inner + 2
134
+ box_w = [box_w, term_w - 6].min
135
+ inner_w = [box_w - 2, 36].max
136
+ input_box_w = [inner_w - label_col_w - 8, 14].max
137
+
138
+ # 0 .. (all_fields.length - 1) son campos, all_fields.length es Guardar, all_fields.length + 1 es Cancelar
139
+ total_targets = all_fields.length + 2
140
+ current_focus = 0
141
+
142
+ tl = border_cfg[:tl] || "╔"
143
+ tr = border_cfg[:tr] || "╗"
144
+ bl = border_cfg[:bl] || "╚"
145
+ br = border_cfg[:br] || "╝"
146
+ h_char = border_cfg[:ht] || border_cfg[:h] || "═"
147
+ v_char = border_cfg[:vl] || border_cfg[:v] || "║"
148
+
149
+ left_margin_len = center ? [((term_w - (box_w + (shadow ? 2 : 0))) / 2), 0].max : 0
150
+ margin = " " * left_margin_len
151
+
152
+ drawn_lines = 0
153
+
154
+ render_form = lambda do
155
+ brd_code = is_rgb ? "" : ansi_color(brd_col_name, 1)
156
+ tit_code = ansi_color(tit_col_name, 2)
157
+ foc_code = ansi_color(foc_col_name, 2)
158
+ shd_code = ansi_color("gray", 1)
159
+ shd_char = shadow ? "▒" : ""
160
+ rst = ansi_reset
161
+
162
+ lines = []
163
+
164
+ # 1. Título superior
165
+ t_clean = " #{title} "
166
+ t_w = display_width(t_clean)
167
+ if t_w > inner_w
168
+ t_clean = " #{title[0...[inner_w - 6, 1].max]}... "
169
+ t_w = display_width(t_clean)
170
+ end
171
+ l_len = [(inner_w - t_w) / 2, 0].max
172
+ r_len = [inner_w - t_w - l_len, 0].max
173
+ top_title_bar = (h_char * l_len) + tit_code + t_clean + brd_code + (h_char * r_len)
174
+
175
+ top_line = if is_rgb
176
+ Color.rgb("#{tl}#{top_title_bar}#{tr}")
177
+ else
178
+ "#{brd_code}#{tl}#{top_title_bar}#{tr}#{rst}"
179
+ end
180
+ lines << "#{margin}#{top_line}"
181
+
182
+ # Espaciado superior
183
+ lines << "#{margin}#{brd_code}#{v_char}#{rst}#{' ' * inner_w}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
184
+
185
+ # 2. Renderizado de cada campo
186
+ all_fields.each_with_index do |field, f_idx|
187
+ is_focused = (current_focus == f_idx)
188
+ raw_label = field[:label].to_s
189
+ l_pad_len = [label_col_w - display_width(raw_label), 0].max
190
+ formatted_label = (" " * l_pad_len) + raw_label
191
+
192
+ max_text_w = [input_box_w - 3, 1].max
193
+ val_out = nil
194
+
195
+ case field[:type]
196
+ when :text, :password, :number
197
+ full_str = case field[:type]
198
+ when :password
199
+ field[:value].empty? ? "" : (field[:mask] * field[:value].length)
200
+ when :number
201
+ (field[:raw_str] || field[:value]).to_s
202
+ else
203
+ field[:value].to_s
204
+ end
205
+
206
+ cursor_pos = field[:cursor] || full_str.length
207
+ cursor_pos = [[cursor_pos, 0].max, full_str.length].min
208
+
209
+ placeholder = field[:placeholder].to_s
210
+ has_placeholder = full_str.empty? && !placeholder.empty?
211
+
212
+ if is_focused
213
+ cursor_bar = "\e[1m#{foc_code}#{cur_char}#{rst}"
214
+
215
+ if has_placeholder
216
+ vis_ph = placeholder
217
+ vis_ph = vis_ph[0...max_text_w] if display_width(vis_ph) > max_text_w
218
+ content = cursor_bar + Color.gray(vis_ph)
219
+ content_w = 1 + display_width(vis_ph)
220
+ else
221
+ if display_width(full_str) <= max_text_w
222
+ before = full_str[0...cursor_pos]
223
+ after = full_str[cursor_pos..-1]
224
+ content = Color.bright_white(before) + cursor_bar + Color.bright_white(after)
225
+ content_w = display_width(full_str) + 1
226
+ else
227
+ w_start = [cursor_pos - (max_text_w / 2), 0].max
228
+ if w_start + max_text_w > full_str.length
229
+ w_start = [full_str.length - max_text_w, 0].max
230
+ end
231
+ w_end = w_start + max_text_w
232
+ slice = full_str[w_start...w_end]
233
+ rel_c = cursor_pos - w_start
234
+ before = slice[0...rel_c]
235
+ after = slice[rel_c..-1]
236
+ content = Color.bright_white(before) + cursor_bar + Color.bright_white(after)
237
+ content_w = display_width(slice) + 1
238
+ end
239
+ end
240
+
241
+ v_pad_len = [input_box_w - content_w - 2, 0].max
242
+ val_padded = " " + content + (" " * v_pad_len) + " "
243
+ val_out = "#{foc_code}[#{val_padded}#{foc_code}]#{rst}"
244
+ else
245
+ vis_str = has_placeholder ? placeholder : full_str
246
+ if display_width(vis_str) > (input_box_w - 2)
247
+ vis_str = vis_str[-[input_box_w - 2, 1].max..-1]
248
+ end
249
+ v_pad_len = [input_box_w - display_width(vis_str) - 2, 0].max
250
+ content = has_placeholder ? Color.gray(vis_str) : Color.bright_white(vis_str)
251
+ val_padded = " " + content + (" " * v_pad_len) + " "
252
+ val_out = "#{Color.gray('[')}#{val_padded}#{Color.gray(']')}"
253
+ end
254
+
255
+ when :select
256
+ val_str = "< #{field[:value]} >"
257
+ vis_val = val_str
258
+ if display_width(vis_val) > (input_box_w - 2)
259
+ vis_val = vis_val[0...[input_box_w - 2, 1].max]
260
+ end
261
+ v_pad_len = [input_box_w - display_width(vis_val) - 2, 0].max
262
+
263
+ if is_focused
264
+ content = "#{foc_code}<#{rst} #{Color.bright_white(field[:value])} #{foc_code}>#{rst}"
265
+ val_padded = " " + content + (" " * v_pad_len) + " "
266
+ val_out = "#{foc_code}[#{val_padded}#{foc_code}]#{rst}"
267
+ else
268
+ content = Color.gray(vis_val)
269
+ val_padded = " " + content + (" " * v_pad_len) + " "
270
+ val_out = "#{Color.gray('[')}#{val_padded}#{Color.gray(']')}"
271
+ end
272
+
273
+ when :boolean
274
+ val_str = field[:value] ? "[X] Sí" : "[ ] No"
275
+ v_pad_len = [input_box_w - display_width(val_str) - 2, 0].max
276
+
277
+ if is_focused
278
+ bool_text = field[:value] ? Color.bright_green("[X] Sí") : Color.bright_red("[ ] No")
279
+ val_padded = " " + bool_text + (" " * v_pad_len) + " "
280
+ val_out = "#{foc_code}[#{val_padded}#{foc_code}]#{rst}"
281
+ else
282
+ bool_text = field[:value] ? Color.green("[X] Sí") : Color.gray("[ ] No")
283
+ val_padded = " " + bool_text + (" " * v_pad_len) + " "
284
+ val_out = "#{Color.gray('[')}#{val_padded}#{Color.gray(']')}"
285
+ end
286
+ end
287
+
288
+ label_out = is_focused ? "#{foc_code}#{formatted_label}#{rst}" : Color.white(formatted_label)
289
+ field_rendered = " #{label_out} #{val_out}"
290
+ field_vis_w = display_width(field_rendered)
291
+ right_pad = [inner_w - field_vis_w, 0].max
292
+ row_content = field_rendered + (" " * right_pad)
293
+
294
+ lines << "#{margin}#{brd_code}#{v_char}#{rst}#{row_content}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
295
+ end
296
+
297
+ # Espaciado y divisor interior
298
+ lines << "#{margin}#{brd_code}#{v_char}#{rst}#{' ' * inner_w}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
299
+ div_inner = "─" * (inner_w - 4)
300
+ lines << "#{margin}#{brd_code}#{v_char}#{rst} #{Color.gray(div_inner)} #{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
301
+
302
+ # 3. Botones de acción
303
+ btn_save_focus = (current_focus == all_fields.length)
304
+ btn_canc_focus = (current_focus == (all_fields.length + 1))
305
+
306
+ btn_save = btn_save_focus ? Color.bright_green("> [ ✔ #{submit_label} ] <") : Color.gray(" [ ✔ #{submit_label} ] ")
307
+ btn_canc = btn_canc_focus ? Color.bright_red("> [ ✖ #{cancel_label} ] <") : Color.gray(" [ ✖ #{cancel_label} ] ")
308
+
309
+ btns_row_raw = (btn_save_focus ? "> [ ✔ #{submit_label} ] <" : " [ ✔ #{submit_label} ] ") + " " + (btn_canc_focus ? "> [ ✖ #{cancel_label} ] <" : " [ ✖ #{cancel_label} ] ")
310
+ btns_vis_w = display_width(btns_row_raw)
311
+ btns_pad_total = [inner_w - btns_vis_w, 0].max
312
+ btns_l_pad = " " * (btns_pad_total / 2)
313
+ btns_r_pad = " " * (btns_pad_total - (btns_pad_total / 2))
314
+
315
+ btns_line = "#{btns_l_pad}#{btn_save} #{btn_canc}#{btns_r_pad}"
316
+ lines << "#{margin}#{brd_code}#{v_char}#{rst}#{btns_line}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
317
+
318
+ # Guía de atajos rápida al pie
319
+ hint_vis_w = display_width(hint_text)
320
+ hint_pad_total = [inner_w - hint_vis_w, 0].max
321
+ hint_l_pad = " " * (hint_pad_total / 2)
322
+ hint_r_pad = " " * (hint_pad_total - (hint_pad_total / 2))
323
+ lines << "#{margin}#{brd_code}#{v_char}#{rst}#{hint_l_pad}#{Color.gray(hint_text)}#{hint_r_pad}#{brd_code}#{v_char}#{rst}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
324
+
325
+ # 4. Marco inferior y sombra
326
+ bot_line = if is_rgb
327
+ Color.rgb("#{bl}#{h_char * inner_w}#{br}")
328
+ else
329
+ "#{brd_code}#{bl}#{h_char * inner_w}#{br}#{rst}"
330
+ end
331
+ lines << "#{margin}#{bot_line}#{shadow ? "#{shd_code}#{shd_char * 2}#{rst}" : ""}"
332
+
333
+ # Sombra inferior
334
+ if shadow
335
+ lines << "#{margin} #{shd_code}#{shd_char * box_w}#{rst}"
336
+ end
337
+
338
+ frame = lines.join("\r\n") + "\r\n"
339
+ Kernel.print("\e[#{drawn_lines}A\e[J") if drawn_lines > 0
340
+ Kernel.print(frame)
341
+ $stdout.flush
342
+ drawn_lines = lines.length
343
+ end
344
+
345
+ is_tty = $stdin.respond_to?(:tty?) && $stdin.tty?
346
+ result = nil
347
+
348
+ begin
349
+ Kernel.print(HIDE_CURSOR)
350
+ render_form.call
351
+
352
+ reader = lambda do |stream|
353
+ while (key = GRmenu.read_key_raw(stream))
354
+ # Esc cancela el formulario completo
355
+ if key == "\e" || key == "\x03"
356
+ result = nil
357
+ break
358
+
359
+ # Tab o Flecha Abajo: Siguiente elemento
360
+ elsif key == "\t" || key == "\e[B" || key == "\eOB" || key == "\xe0P"
361
+ current_focus = (current_focus + 1) % total_targets
362
+ render_form.call
363
+
364
+ # Shift+Tab o Flecha Arriba: Elemento anterior
365
+ elsif key == "\e[Z" || key == "\e[A" || key == "\eOA" || key == "\xe0H"
366
+ current_focus = (current_focus - 1) % total_targets
367
+ render_form.call
368
+
369
+ # Si el foco está en el botón Guardar
370
+ elsif current_focus == all_fields.length
371
+ if key == "\r" || key == "\n" || key == " "
372
+ res_hash = {}
373
+ all_fields.each { |f| res_hash[f[:key]] = f[:value] }
374
+ result = res_hash
375
+ break
376
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M"
377
+ current_focus = all_fields.length + 1
378
+ render_form.call
379
+ end
380
+
381
+ # Si el foco está en el botón Cancelar
382
+ elsif current_focus == (all_fields.length + 1)
383
+ if key == "\r" || key == "\n" || key == " "
384
+ result = nil
385
+ break
386
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K"
387
+ current_focus = all_fields.length
388
+ render_form.call
389
+ end
390
+
391
+ # Si el foco está en un campo de entrada
392
+ else
393
+ field = all_fields[current_focus]
394
+
395
+ case field[:type]
396
+ when :text, :password
397
+ if key == "\r" || key == "\n"
398
+ current_focus = (current_focus + 1) % total_targets
399
+ render_form.call
400
+ elsif key == "\x7f" || key == "\b" || key == "\x08" # Backspace
401
+ if field[:cursor] > 0
402
+ field[:value].slice!(field[:cursor] - 1)
403
+ field[:cursor] -= 1
404
+ render_form.call
405
+ end
406
+ elsif key == "\e[3~" # Delete
407
+ if field[:cursor] < field[:value].length
408
+ field[:value].slice!(field[:cursor])
409
+ render_form.call
410
+ end
411
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" # Flecha izquierda
412
+ field[:cursor] = [field[:cursor] - 1, 0].max
413
+ render_form.call
414
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" # Flecha derecha
415
+ field[:cursor] = [field[:cursor] + 1, field[:value].length].min
416
+ render_form.call
417
+ elsif key == "\x01" || key == "\e[H" || key == "\eOH" || key == "\e[1~" # Inicio / Home
418
+ field[:cursor] = 0
419
+ render_form.call
420
+ elsif key == "\x05" || key == "\e[F" || key == "\eOF" || key == "\e[4~" # Fin / End
421
+ field[:cursor] = field[:value].length
422
+ render_form.call
423
+ elsif key == "\x15" # Ctrl+U: limpiar campo
424
+ field[:value].clear
425
+ field[:cursor] = 0
426
+ render_form.call
427
+ elsif key =~ /^[[:print:]]$/ # Caracter imprimible
428
+ field[:value].insert(field[:cursor], key)
429
+ field[:cursor] += 1
430
+ render_form.call
431
+ end
432
+
433
+ when :number
434
+ field[:raw_str] ||= field[:value].to_s
435
+ field[:cursor] ||= field[:raw_str].length
436
+
437
+ if key == "\r" || key == "\n"
438
+ current_focus = (current_focus + 1) % total_targets
439
+ render_form.call
440
+ elsif key == "+" || key == "="
441
+ val = field[:value] + field[:step]
442
+ val = [val, field[:max]].min if field[:max]
443
+ field[:value] = val
444
+ field[:raw_str] = val.to_s
445
+ field[:cursor] = field[:raw_str].length
446
+ render_form.call
447
+ elsif key == "-"
448
+ val = field[:value] - field[:step]
449
+ val = [val, field[:min]].max if field[:min]
450
+ field[:value] = val
451
+ field[:raw_str] = val.to_s
452
+ field[:cursor] = field[:raw_str].length
453
+ render_form.call
454
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K" # Flecha izquierda
455
+ field[:cursor] = [field[:cursor] - 1, 0].max
456
+ render_form.call
457
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" # Flecha derecha
458
+ field[:cursor] = [field[:cursor] + 1, field[:raw_str].length].min
459
+ render_form.call
460
+ elsif key == "\x01" || key == "\e[H" || key == "\eOH" || key == "\e[1~" # Inicio / Home
461
+ field[:cursor] = 0
462
+ render_form.call
463
+ elsif key == "\x05" || key == "\e[F" || key == "\eOF" || key == "\e[4~" # Fin / End
464
+ field[:cursor] = field[:raw_str].length
465
+ render_form.call
466
+ elsif key == "\x15" # Ctrl+U: limpiar
467
+ field[:raw_str] = ""
468
+ field[:value] = field[:min] || 0
469
+ field[:cursor] = 0
470
+ render_form.call
471
+ elsif key =~ /^[0-9]$/
472
+ field[:raw_str].insert(field[:cursor], key)
473
+ field[:cursor] += 1
474
+ num_val = field[:raw_str].to_i
475
+ num_val = [num_val, field[:max]].min if field[:max]
476
+ field[:value] = num_val
477
+ render_form.call
478
+ elsif key == "\x7f" || key == "\b" || key == "\x08" # Backspace
479
+ if field[:cursor] > 0
480
+ field[:raw_str].slice!(field[:cursor] - 1)
481
+ field[:cursor] -= 1
482
+ num_val = field[:raw_str].empty? ? (field[:min] || 0) : field[:raw_str].to_i
483
+ field[:value] = num_val
484
+ render_form.call
485
+ end
486
+ elsif key == "\e[3~" # Delete
487
+ if field[:cursor] < field[:raw_str].length
488
+ field[:raw_str].slice!(field[:cursor])
489
+ num_val = field[:raw_str].empty? ? (field[:min] || 0) : field[:raw_str].to_i
490
+ field[:value] = num_val
491
+ render_form.call
492
+ end
493
+ end
494
+
495
+ when :select
496
+ opts = field[:options]
497
+ cur_idx = opts.index(field[:value]) || 0
498
+ if key == "\r" || key == "\n"
499
+ current_focus = (current_focus + 1) % total_targets
500
+ render_form.call
501
+ elsif key == "\e[D" || key == "\eOD" || key == "\xe0K"
502
+ field[:value] = opts[(cur_idx - 1) % opts.length]
503
+ render_form.call
504
+ elsif key == "\e[C" || key == "\eOC" || key == "\xe0M" || key == " "
505
+ field[:value] = opts[(cur_idx + 1) % opts.length]
506
+ render_form.call
507
+ end
508
+
509
+ when :boolean
510
+ if key == " " || key == "s" || key == "S" || key == "y" || key == "Y"
511
+ field[:value] = !field[:value]
512
+ render_form.call
513
+ elsif key == "\r" || key == "\n"
514
+ current_focus = (current_focus + 1) % total_targets
515
+ render_form.call
516
+ end
517
+ end
518
+ end
519
+ end
520
+ end
521
+
522
+ if is_tty
523
+ $stdin.raw { |s| reader.call(s) }
524
+ else
525
+ reader.call($stdin)
526
+ end
527
+ ensure
528
+ Kernel.print(SHOW_CURSOR)
529
+ end
530
+
531
+ result
532
+ end
533
+ end