rakpak 1.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,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "theme"
4
+ require_relative "text"
5
+
6
+ module Rakpak
7
+ # Base class for the centred overlay panels. Subclasses fill the body;
8
+ # geometry, frame, title and footer are handled here.
9
+ class Modal
10
+ attr_reader :result
11
+
12
+ def initialize(title:, footer: "")
13
+ @title = title
14
+ @footer = footer
15
+ @result = nil
16
+ end
17
+
18
+ # Desired [width, height] given the screen.
19
+ def dims(screen)
20
+ [[screen.w - 6, 72].min, [screen.h - 4, 20].min]
21
+ end
22
+
23
+ def draw(screen)
24
+ w, h = dims(screen)
25
+ w = [w, screen.w].min
26
+ h = [h, screen.h].min
27
+ x = (screen.w - w) / 2
28
+ y = (screen.h - h) / 2
29
+ screen.box(x, y, w, h, Theme::ACCENT, Theme::MODAL_BG)
30
+ screen.put(x + 2, y, " #{@title} ", Theme::MODAL_BG + Theme::TITLE)
31
+ ftext = footer_text
32
+ if ftext && !ftext.empty?
33
+ screen.put(x + 2, y + h - 1, " #{Text.fit(ftext, w - 6)} ",
34
+ Theme::MODAL_BG + footer_style)
35
+ end
36
+ body(screen, x + 2, y + 2, w - 4, h - 4)
37
+ end
38
+
39
+ def footer_text = @footer
40
+ def footer_style = Theme::DIM
41
+
42
+ def body(screen, x, y, w, h); end
43
+
44
+ # :done, :cancel or nil
45
+ def handle(_key) = nil
46
+ end
47
+
48
+ # A vertical list of choices, some of which may be unavailable.
49
+ class SelectModal < Modal
50
+ Item = Struct.new(:label, :value, :blurb, :enabled, :why, keyword_init: true)
51
+
52
+ def initialize(title:, items:, footer: "j/k move · enter choose · esc back", index: 0)
53
+ super(title: title, footer: footer)
54
+ @items = items
55
+ @index = first_enabled(index)
56
+ end
57
+
58
+ def dims(screen)
59
+ w = [[screen.w - 6, 76].min, 30].max
60
+ h = [@items.size + 5, screen.h - 2].min
61
+ [w, h]
62
+ end
63
+
64
+ def first_enabled(from)
65
+ return from if @items[from]&.enabled
66
+
67
+ idx = @items.index(&:enabled)
68
+ idx || from
69
+ end
70
+
71
+ def body(screen, x, y, w, _h)
72
+ @items.each_with_index do |item, i|
73
+ row = y + i
74
+ sel = i == @index
75
+ style = if !item.enabled then Theme::MODAL_BG + Theme::FAINT
76
+ elsif sel then Theme::CUR_BG + "\e[1;38;5;231m"
77
+ else Theme::MODAL_BG + Theme::NORMAL
78
+ end
79
+ screen.fill(x - 1, row, w + 2, 1, " ", style)
80
+ screen.put(x, row, sel ? "❯ " : " ", style)
81
+ cx = screen.put(x + 2, row, Text.fit(item.label, 22), style)
82
+ note = item.enabled ? item.blurb.to_s : "(#{item.why})"
83
+ nstyle = if !item.enabled then Theme::MODAL_BG + Theme::FAINT
84
+ elsif sel then Theme::CUR_BG + "\e[38;5;252m"
85
+ else Theme::MODAL_BG + Theme::DIM
86
+ end
87
+ screen.put(x + 24, row, Text.fit(note, w - 25), nstyle) if w > 25 && cx <= x + 24
88
+ end
89
+ end
90
+
91
+ def handle(key)
92
+ case key
93
+ when :up, "k" then move(-1)
94
+ when :down, "j" then move(1)
95
+ when :enter, "l", :right
96
+ return nil unless @items[@index]&.enabled
97
+
98
+ @result = @items[@index].value
99
+ :done
100
+ when :esc, "q", "h", :left then :cancel
101
+ when /\A[1-9]\z/
102
+ i = key.to_i - 1
103
+ if @items[i]&.enabled
104
+ @index = i
105
+ @result = @items[i].value
106
+ :done
107
+ end
108
+ end
109
+ end
110
+
111
+ def move(dir)
112
+ n = @items.size
113
+ i = @index
114
+ n.times do
115
+ i = (i + dir) % n
116
+ next unless @items[i].enabled
117
+
118
+ @index = i
119
+ break
120
+ end
121
+ nil
122
+ end
123
+ end
124
+
125
+ # A form of mixed rows: cycling choices, numeric ranges and toggles.
126
+ class FormModal < Modal
127
+ Row = Struct.new(:kind, :label, :hint, :get, :set, :values, keyword_init: true)
128
+
129
+ def initialize(title:, rows:, footer: "space toggle · h/l adjust · enter accept · esc back")
130
+ super(title: title, footer: footer)
131
+ @rows = rows
132
+ @index = @rows.index { |r| r.kind != :spacer } || 0
133
+ @top = 0
134
+ @error = nil
135
+ end
136
+
137
+ def dims(screen)
138
+ w = [[screen.w - 4, 78].min, 40].max
139
+ h = [@rows.size + 5, screen.h - 2].min
140
+ [w, h]
141
+ end
142
+
143
+ def body(screen, x, y, w, h)
144
+ @top = @index - h + 1 if @index >= @top + h
145
+ @top = @index if @index < @top
146
+ @top = [@top, 0].max
147
+ visible = @rows[@top, h] || []
148
+ visible.each_with_index do |row, i|
149
+ draw_row(screen, x, y + i, w, row, @top + i == @index)
150
+ end
151
+ return unless @rows.size > h
152
+
153
+ screen.put(x + w - 4, y + h - 1, "#{@top + h}/#{@rows.size}", Theme::MODAL_BG + Theme::FAINT)
154
+ end
155
+
156
+ def draw_row(screen, x, y, w, row, sel)
157
+ style = sel ? Theme::CUR_BG + "\e[38;5;231m" : Theme::MODAL_BG + Theme::NORMAL
158
+ dim = sel ? Theme::CUR_BG + "\e[38;5;250m" : Theme::MODAL_BG + Theme::DIM
159
+ screen.fill(x - 1, y, w + 2, 1, " ", sel ? Theme::CUR_BG : Theme::MODAL_BG)
160
+ return if row.kind == :spacer
161
+
162
+ case row.kind
163
+ when :toggle
164
+ on = row.get.call
165
+ screen.put(x, y, on ? " [×] " : " [ ] ", sel ? style : (on ? Theme::MODAL_BG + Theme::OK : Theme::MODAL_BG + Theme::DIM))
166
+ screen.put(x + 5, y, Text.fit(row.label, 24), style)
167
+ screen.put(x + 30, y, Text.fit(row.hint.to_s, w - 31), dim)
168
+ when :choice
169
+ screen.put(x + 1, y, Text.fit(row.label, 13), style)
170
+ val = row.get.call
171
+ entry = row.values.find { |v| v[1] == val }
172
+ ok = entry.nil? || entry[2] != false
173
+ disp = entry&.first || val.to_s
174
+ vstyle = if !ok then (sel ? Theme::CUR_BG : Theme::MODAL_BG) + Theme::ERR
175
+ elsif sel then Theme::CUR_BG + Theme::KEY
176
+ else Theme::MODAL_BG + Theme::TAG
177
+ end
178
+ screen.put(x + 15, y, "#{ok ? '‹' : '✗'} #{Text.fit(disp, 16)} #{ok ? '›' : ''}", vstyle)
179
+ note = ok ? row.hint.to_s : (entry[3] || "unavailable here")
180
+ screen.put(x + 36, y, Text.fit(note, w - 37),
181
+ ok ? dim : (sel ? Theme::CUR_BG : Theme::MODAL_BG) + Theme::ERR)
182
+ when :number
183
+ screen.put(x + 1, y, Text.fit(row.label, 13), style)
184
+ val = row.get.call
185
+ rng = row.values
186
+ screen.put(x + 15, y, "‹ #{Text.pad(val.to_s, 3)}›", sel ? Theme::CUR_BG + Theme::KEY : Theme::MODAL_BG + Theme::TAG)
187
+ bar_w = [[w - 38, 20].min, 8].max
188
+ filled = rng.size <= 1 ? bar_w : ((val - rng.first).to_f / (rng.last - rng.first) * bar_w).round
189
+ screen.put(x + 22, y, "█" * filled, sel ? Theme::CUR_BG + Theme::KEY : Theme::MODAL_BG + Theme::ACCENT)
190
+ screen.put(x + 22 + filled, y, "░" * (bar_w - filled), dim)
191
+ screen.put(x + 24 + bar_w, y, Text.fit("#{rng.first}–#{rng.last} #{row.hint}", w - 25 - bar_w), dim)
192
+ when :label
193
+ screen.put(x + 1, y, Text.fit(row.label, w - 2), Theme::MODAL_BG + Theme::DIM)
194
+ end
195
+ end
196
+
197
+ def handle(key)
198
+ @error = nil
199
+ case key
200
+ when :up, "k" then move(-1)
201
+ when :down, "j" then move(1)
202
+ when :left, "h" then adjust(-1)
203
+ when :right, "l" then adjust(1)
204
+ when :space then toggle
205
+ when :enter
206
+ if (bad = unusable)
207
+ @error = bad
208
+ nil
209
+ else
210
+ @result = true
211
+ :done
212
+ end
213
+ when :esc, "q" then :cancel
214
+ end
215
+ end
216
+
217
+ # The reason the current settings cannot be run, or nil.
218
+ def unusable
219
+ @rows.each do |row|
220
+ next unless row.kind == :choice
221
+
222
+ entry = row.values.find { |v| v[1] == row.get.call }
223
+ next if entry.nil? || entry[2] != false
224
+
225
+ return "#{entry[0]}: #{entry[3] || 'not available here'}"
226
+ end
227
+ nil
228
+ end
229
+
230
+ def footer_text = @error || @footer
231
+ def footer_style = @error ? Theme::ERR : Theme::DIM
232
+
233
+ def move(dir)
234
+ n = @rows.size
235
+ i = @index
236
+ n.times do
237
+ i = (i + dir) % n
238
+ next if %i[spacer label].include?(@rows[i].kind)
239
+
240
+ @index = i
241
+ break
242
+ end
243
+ nil
244
+ end
245
+
246
+ def toggle
247
+ row = @rows[@index]
248
+ return nil unless row&.kind == :toggle
249
+
250
+ row.set.call(!row.get.call)
251
+ nil
252
+ end
253
+
254
+ def adjust(dir)
255
+ row = @rows[@index]
256
+ return nil unless row
257
+
258
+ case row.kind
259
+ when :toggle then row.set.call(!row.get.call)
260
+ when :number
261
+ rng = row.values
262
+ row.set.call((row.get.call + dir).clamp(rng.first, rng.last))
263
+ when :choice
264
+ vals = row.values
265
+ cur = vals.index { |v| v[1] == row.get.call } || 0
266
+ row.set.call(vals[(cur + dir) % vals.size][1])
267
+ end
268
+ nil
269
+ end
270
+ end
271
+
272
+ # Single-line text field with the editing keys people expect. `validate`
273
+ # is given the trimmed text and returns a reason to refuse it, or nil.
274
+ class InputModal < Modal
275
+ def initialize(title:, value: "", hint: "", footer: "enter accept · esc back", validate: nil)
276
+ super(title: title, footer: footer)
277
+ @buf = value.dup
278
+ @cur = @buf.length
279
+ @hint = hint
280
+ @validate = validate
281
+ @error = nil
282
+ end
283
+
284
+ def footer_text = @error || @footer
285
+ def footer_style = @error ? Theme::ERR : Theme::DIM
286
+ def text = @buf.strip
287
+
288
+ def dims(screen)
289
+ [[[screen.w - 6, 74].min, 40].max, @hint.empty? ? 7 : 8]
290
+ end
291
+
292
+ def body(screen, x, y, w, _h)
293
+ screen.put(x, y, Text.fit(@hint, w), Theme::MODAL_BG + Theme::DIM) unless @hint.empty?
294
+ field(screen, x, y + (@hint.empty? ? 0 : 2), w, active: true)
295
+ end
296
+
297
+ # The text box on its own, for embedding in another panel.
298
+ def field(screen, x, row, w, active: true)
299
+ screen.fill(x, row, w, 1, " ", Theme::SEL_BG)
300
+ width = w - 2
301
+ # Scroll so the cursor is visible, counting columns, not characters.
302
+ off = 0
303
+ off += 1 while off < @cur && Text.width(@buf[off...@cur]) >= width
304
+ shown = +""
305
+ @buf[off..].to_s.each_grapheme_cluster do |g|
306
+ break if Text.width(shown) + Text.gw(g) > width
307
+
308
+ shown << g
309
+ end
310
+ screen.put(x + 1, row, shown, Theme::SEL_BG + (active ? "\e[38;5;231m" : Theme::DIM))
311
+ return unless active
312
+
313
+ cx = x + 1 + Text.width(@buf[off...@cur].to_s)
314
+ ch = @buf[@cur] || " "
315
+ screen.put(cx, row, ch, "\e[7m\e[38;5;39m")
316
+ end
317
+
318
+ def handle(key)
319
+ @error = nil
320
+ case key
321
+ when :enter
322
+ text = @buf.strip
323
+ return nil if text.empty?
324
+
325
+ if (bad = @validate&.call(text))
326
+ @error = bad
327
+ return nil
328
+ end
329
+ @result = text
330
+ return :done
331
+ when :esc then return :cancel
332
+ when :backspace
333
+ if @cur.positive?
334
+ @buf.slice!(@cur - 1)
335
+ @cur -= 1
336
+ end
337
+ when :delete then @buf.slice!(@cur) if @cur < @buf.length
338
+ when :left then @cur = [@cur - 1, 0].max
339
+ when :right then @cur = [@cur + 1, @buf.length].min
340
+ when :home, :ctrl_a then @cur = 0
341
+ when :end, :ctrl_e then @cur = @buf.length
342
+ when :ctrl_u then @buf.slice!(0, @cur) && (@cur = 0)
343
+ when :ctrl_k then @buf.slice!(@cur..)
344
+ when :ctrl_w
345
+ left = @buf[0, @cur].sub(/\S*\s*\z/, "")
346
+ @buf = left + (@buf[@cur..] || "")
347
+ @cur = left.length
348
+ when :space then insert(" ")
349
+ when String then insert(key)
350
+ end
351
+ nil
352
+ end
353
+
354
+ def insert(str)
355
+ return unless str.match?(/\A[[:print:]]\z/)
356
+
357
+ @buf.insert(@cur, str)
358
+ @cur += str.length
359
+ end
360
+ end
361
+
362
+ # Where the archive goes: two ready-made folders and a field for any
363
+ # other. Typing anything moves to the field; 1, 2 and 3 pick directly.
364
+ class WhereModal < Modal
365
+ attr_reader :index
366
+
367
+ def initialize(here:, home:, index: 0, text: "", validate: nil)
368
+ super(title: "save it where?", footer: "1-3 or ↑↓ pick · enter choose · esc back")
369
+ @choices = [["This directory", here], ["Home directory", home], ["Specify", nil]]
370
+ @index = index
371
+ @field = InputModal.new(title: "", value: text, validate: validate)
372
+ end
373
+
374
+ def text = @field.text
375
+
376
+ def dims(screen)
377
+ [[[screen.w - 6, 84].min, 44].max, 10]
378
+ end
379
+
380
+ def body(screen, x, y, w, _h)
381
+ @choices.each_with_index do |(label, path), i|
382
+ row = y + i
383
+ sel = i == @index
384
+ bg = sel ? Theme::CUR_BG : Theme::MODAL_BG
385
+ screen.fill(x - 1, row, w + 2, 1, " ", bg)
386
+ screen.put(x, row, "#{i + 1}. #{label}", bg + (sel ? "\e[1;38;5;231m" : Theme::NORMAL))
387
+ next unless path
388
+
389
+ screen.put(x + 20, row, Text.fit_left(path, w - 21), bg + (sel ? "\e[38;5;252m" : Theme::DIM))
390
+ end
391
+ @field.field(screen, x + 3, y + 4, w - 3, active: @index == 2)
392
+ end
393
+
394
+ def footer_text = @error || @footer
395
+ def footer_style = @error ? Theme::ERR : Theme::DIM
396
+
397
+ def handle(key)
398
+ @error = nil
399
+ case key
400
+ when :esc then return :cancel
401
+ when :enter then return choose
402
+ when :up then @index = (@index - 1) % 3
403
+ when :down, :tab then @index = (@index + 1) % 3
404
+ else
405
+ return field_key(key) if @index == 2
406
+
407
+ case key
408
+ when "k" then @index = (@index - 1) % 3
409
+ when "j" then @index = (@index + 1) % 3
410
+ when "1", "2"
411
+ @index = key.to_i - 1
412
+ return choose
413
+ when "3" then @index = 2
414
+ else field_key(key)
415
+ end
416
+ end
417
+ nil
418
+ end
419
+
420
+ # Once the field is active, j, k and digits are text like anything
421
+ # else; arrows and tab still move between the choices.
422
+ def field_key(key)
423
+ @index = 2
424
+ @field.handle(key)
425
+ nil
426
+ end
427
+
428
+ def choose
429
+ if @index < 2
430
+ @result = @choices[@index][1]
431
+ return :done
432
+ end
433
+ if text.empty?
434
+ @error = "type a folder, or pick 1 or 2"
435
+ return nil
436
+ end
437
+ res = @field.handle(:enter)
438
+ if res == :done
439
+ @result = @field.result
440
+ return :done
441
+ end
442
+ @error = @field.footer_text
443
+ nil
444
+ end
445
+ end
446
+
447
+ # Read-only panel: the exact commands, warnings, and a go/no-go. Only
448
+ # enter runs it: a single letter is too easy to hit while meaning
449
+ # something else, and b in particular reads as "back".
450
+ class ConfirmModal < Modal
451
+ def initialize(title:, lines:, warnings: [], errors: [], footer: nil)
452
+ super(title: title,
453
+ footer: footer || (errors.empty? ? "enter run · esc back" : "esc back"))
454
+ @lines = lines
455
+ @warnings = warnings
456
+ @errors = errors
457
+ end
458
+
459
+ def dims(screen)
460
+ w = [screen.w - 4, 92].min
461
+ h = 6 + laid_out(w - 4).size + @warnings.size + @errors.size
462
+ [w, [h, screen.h - 2].min]
463
+ end
464
+
465
+ # Commands are wrapped rather than clipped: the whole point of this
466
+ # panel is that you can read exactly what will run.
467
+ def laid_out(w)
468
+ @lines.flat_map do |style, text|
469
+ if style == :cmd
470
+ Text.wrap(text, w, 5).map { |l| [style, l] }
471
+ else
472
+ [[style, Text.fit(text, w)]]
473
+ end
474
+ end
475
+ end
476
+
477
+ def body(screen, x, y, w, h)
478
+ row = y
479
+ laid_out(w).each do |style, text|
480
+ break if row >= y + h
481
+
482
+ st = case style
483
+ when :cmd then Theme::MODAL_BG + Theme::NORMAL
484
+ when :key then Theme::MODAL_BG + Theme::DIM
485
+ when :head then Theme::MODAL_BG + Theme::TITLE
486
+ else Theme::MODAL_BG + Theme::NORMAL
487
+ end
488
+ screen.put(x, row, text, st)
489
+ row += 1
490
+ end
491
+ @warnings.each do |msg|
492
+ break if row >= y + h
493
+
494
+ screen.put(x, row, Text.fit("! #{msg}", w), Theme::MODAL_BG + Theme::WARN)
495
+ row += 1
496
+ end
497
+ @errors.each do |msg|
498
+ break if row >= y + h
499
+
500
+ screen.put(x, row, Text.fit("✗ #{msg}", w), Theme::MODAL_BG + Theme::ERR)
501
+ row += 1
502
+ end
503
+ end
504
+
505
+ def handle(key)
506
+ return :cancel if key == :esc || key == "q"
507
+
508
+ return nil unless @errors.empty?
509
+
510
+ return nil unless key == :enter
511
+
512
+ @result = :run
513
+ :done
514
+ end
515
+ end
516
+
517
+ # Transient notice panel. Scrolls when the content is taller than the
518
+ # terminal, which the key list usually is on a short screen.
519
+ class MessageModal < Modal
520
+ def initialize(title:, lines:, style: Theme::ERR)
521
+ super(title: title, footer: "any key to dismiss")
522
+ @lines = Array(lines)
523
+ @style = style
524
+ @top = 0
525
+ @rows = 0
526
+ end
527
+
528
+ def dims(screen)
529
+ h = [@lines.size + 5, screen.h - 2].min
530
+ @rows = h - 4 # dims runs before body, and footer_text needs this
531
+ [[[screen.w - 6, 70].min, 30].max, h]
532
+ end
533
+
534
+ def body(screen, x, y, w, h)
535
+ @rows = h
536
+ @top = @top.clamp(0, [@lines.size - h, 0].max)
537
+ @lines[@top, h].to_a.each_with_index do |l, i|
538
+ screen.put(x, y + i, Text.fit(l, w), Theme::MODAL_BG + @style)
539
+ end
540
+ end
541
+
542
+ def footer_text
543
+ return @footer unless scrollable?
544
+
545
+ "#{@top + @rows}/#{@lines.size} · j k scroll · any other key dismisses"
546
+ end
547
+
548
+ def scrollable? = @lines.size > @rows
549
+
550
+ def handle(key)
551
+ return :cancel unless scrollable?
552
+
553
+ case key
554
+ when :down, "j" then (@top += 1) && nil
555
+ when :up, "k" then (@top -= 1) && nil
556
+ when :pgdn, :ctrl_d then (@top += @rows / 2) && nil
557
+ when :pgup, :ctrl_u then (@top -= @rows / 2) && nil
558
+ else :cancel
559
+ end
560
+ end
561
+ end
562
+ end