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.
data/lib/rakpak/app.rb ADDED
@@ -0,0 +1,662 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "term"
4
+ require_relative "screen"
5
+ require_relative "browser"
6
+ require_relative "modal"
7
+ require_relative "formats"
8
+ require_relative "plan"
9
+ require_relative "job"
10
+ require_relative "sizer"
11
+
12
+ module Rakpak
13
+ # Review panel for the tag set: the one place to see everything picked up
14
+ # across the filesystem, and drop entries without walking back to them.
15
+ class TagsModal < Modal
16
+ def initialize(tags:, sizer:)
17
+ super(title: "tagged", footer: "space/d remove · D clear all · esc back")
18
+ @tags = tags
19
+ @sizer = sizer
20
+ @index = 0
21
+ end
22
+
23
+ def dims(screen)
24
+ [[screen.w - 4, 96].min, [@tags.size + 5, screen.h - 2].min]
25
+ end
26
+
27
+ def body(screen, x, y, w, h)
28
+ if @tags.empty?
29
+ screen.put(x, y, "nothing tagged", Theme::MODAL_BG + Theme::FAINT)
30
+ return
31
+ end
32
+ @index = @index.clamp(0, @tags.size - 1)
33
+ top = [[@index - h + 1, @tags.size - h].min, 0].max
34
+ @tags[top, h].to_a.each_with_index do |path, i|
35
+ row = y + i
36
+ sel = (top + i) == @index
37
+ bg = sel ? Theme::CUR_BG : Theme::MODAL_BG
38
+ screen.fill(x - 1, row, w + 2, 1, " ", bg)
39
+ res = @sizer&.[](path)
40
+ meta = res ? "#{res.partial ? '≥' : ''}#{Text.bytes(res.bytes)}" : "…"
41
+ screen.put(x, row, sel ? "❯ " : " ", bg + Theme::TAG)
42
+ screen.put(x + 2, row, Text.fit_left(Text.tilde(path), w - Text.width(meta) - 5),
43
+ bg + (sel ? "\e[1;38;5;231m" : Theme::NORMAL))
44
+ screen.put(x + w - Text.width(meta) - 1, row, meta, bg + Theme::DIM)
45
+ end
46
+ end
47
+
48
+ def handle(key)
49
+ case key
50
+ when :up, "k" then @index -= 1
51
+ when :down, "j" then @index += 1
52
+ when :space, "d", :delete
53
+ @tags.delete_at(@index) if @tags.any?
54
+ @index = @index.clamp(0, [@tags.size - 1, 0].max)
55
+ when "D" then @tags.clear
56
+ when :esc, "q", :enter then return :cancel
57
+ end
58
+ @index = @index.clamp(0, [@tags.size - 1, 0].max)
59
+ nil
60
+ end
61
+ end
62
+
63
+ class App
64
+ SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
65
+ WIZARD = %i[target options where output confirm].freeze
66
+
67
+ # `pack` is a list of paths to tag before the first frame; when it is
68
+ # non-empty the archive prompts open immediately (rakpak -p).
69
+ def initialize(start_dir = Dir.home, pack: [])
70
+ @sizer = Sizer.new
71
+ @browser = Browser.new(start_dir, sizer: @sizer)
72
+ w, h = Term.size
73
+ @screen = Screen.new(w, h)
74
+ @modal = nil
75
+ @mode = :browse
76
+ @jobs = []
77
+ @reported_jobs = {}.compare_by_identity
78
+ @focus_job = nil
79
+ @quit_confirm = false
80
+ @tags_ref = nil
81
+ @notice = nil
82
+ @notice_until = nil
83
+ @plan = nil
84
+ @wizard_pos = nil
85
+ @quit = false
86
+ return if pack.empty?
87
+
88
+ pack.each { |p| @browser.tag(p) }
89
+ @browser.jump_to(pack.first)
90
+ start_wizard
91
+ end
92
+
93
+ def run
94
+ Term.start
95
+ loop do
96
+ sync_size
97
+ draw
98
+ key = Term.wait_key(tick)
99
+ reap_jobs
100
+ next if key.nil?
101
+
102
+ dispatch(key)
103
+ break if @quit
104
+ end
105
+ ensure
106
+ stop_jobs
107
+ Term.stop
108
+ report
109
+ end
110
+
111
+ private
112
+
113
+ # The quit dialog promises running jobs are stopped. Give each a moment
114
+ # to remove its half-written archive before the terminal is handed back.
115
+ def stop_jobs
116
+ running = @jobs.select(&:running?)
117
+ return if running.empty?
118
+
119
+ running.each(&:cancel)
120
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
121
+ running.each { |j| j.wait([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
122
+ end
123
+
124
+ def tick
125
+ return 0.08 if @jobs.any?(&:running?)
126
+ return 0.4 if @browser.tags.any? { |t| @sizer[t].nil? }
127
+
128
+ 1.0
129
+ end
130
+
131
+ def sync_size
132
+ return unless Term.resized
133
+
134
+ Term.resized = false
135
+ w, h = Term.size
136
+ @screen.resize(w, h)
137
+ end
138
+
139
+ def notify(text, style = Theme::OK, secs = 4)
140
+ @notice = [text, style]
141
+ @notice_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + secs
142
+ end
143
+
144
+ def status_line
145
+ if @notice && @notice_until && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @notice_until
146
+ return @notice
147
+ end
148
+
149
+ @notice = nil
150
+ active = @jobs.select(&:running?)
151
+ return nil if active.empty?
152
+
153
+ j = active.first
154
+ frame = SPINNER[(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 12).to_i % SPINNER.size]
155
+ more = active.size > 1 ? " (+#{active.size - 1} more)" : ""
156
+ ["#{frame} #{j.label} #{File.basename(j.plan.outputs.first)} · #{j.file_count} files · " \
157
+ "#{Text.bytes(j.output_size)} · #{Text.duration(j.elapsed)}#{more} [b] jobs", Theme::ACCENT]
158
+ end
159
+
160
+ # ------------------------------------------------------------- drawing
161
+
162
+ MIN_W = 40
163
+ MIN_H = 10
164
+
165
+ def draw
166
+ @screen.clear
167
+ if too_small?
168
+ draw_too_small
169
+ Term.flush_frame(@screen.render)
170
+ return
171
+ end
172
+ if @mode == :job && @focus_job
173
+ draw_job(@focus_job)
174
+ else
175
+ @browser.draw(@screen, status_line)
176
+ end
177
+ if @modal
178
+ @screen.veil
179
+ @modal.draw(@screen)
180
+ end
181
+ Term.flush_frame(@screen.render)
182
+ end
183
+
184
+ def too_small?
185
+ w, h = Term.size
186
+ w < MIN_W || h < MIN_H
187
+ end
188
+
189
+ def draw_too_small
190
+ w, h = Term.size
191
+ msg = "terminal too small"
192
+ want = "need #{MIN_W}x#{MIN_H}, have #{w}x#{h}"
193
+ @screen.put([(@screen.w - Text.width(msg)) / 2, 0].max, @screen.h / 2, msg, Theme::WARN)
194
+ @screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
195
+ end
196
+
197
+ def draw_job(job)
198
+ s = @screen
199
+ s.fill(0, 0, s.w, 1, " ", Theme::HEAD)
200
+ title = case job.state
201
+ when :running then "archiving"
202
+ when :done then "finished"
203
+ when :failed then "failed"
204
+ when :cancelled then "cancelled"
205
+ else "queued"
206
+ end
207
+ s.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
208
+ s.put(9, 0, title, Theme::HEAD + "\e[38;5;231m")
209
+ s.put(s.w - 20, 0, Text.duration(job.elapsed), Theme::HEAD + Theme::DIM)
210
+
211
+ y = 2
212
+ job.plan.outputs.each do |out|
213
+ size = begin
214
+ File.size(out)
215
+ rescue StandardError
216
+ nil
217
+ end
218
+ s.put(2, y, "→ ", Theme::DIM)
219
+ s.put(4, y, Text.fit_left(out, s.w - 20), Theme::NORMAL)
220
+ s.put(s.w - 12, y, Text.pad(Text.bytes(size), 10), Theme::TAG)
221
+ y += 1
222
+ end
223
+
224
+ y += 1
225
+ bar_w = s.w - 8
226
+ frac = job.fraction
227
+ if job.running?
228
+ spin = SPINNER[(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 12).to_i % SPINNER.size]
229
+ s.put(2, y, spin, Theme::ACCENT)
230
+ else
231
+ s.put(2, y, job.ok? ? "✓" : "✗", job.ok? ? Theme::OK : Theme::ERR)
232
+ end
233
+ if frac
234
+ filled = (frac * bar_w).round.clamp(0, bar_w)
235
+ bar_style = if job.running? then Theme::ACCENT
236
+ elsif job.ok? then Theme::OK
237
+ else Theme::ERR
238
+ end
239
+ s.put(4, y, "█" * filled, bar_style)
240
+ s.put(4 + filled, y, "░" * (bar_w - filled), Theme::FAINT)
241
+ seen = [job.file_count, job.total_files].min
242
+ s.put(4, y + 1, "#{(frac * 100).round}% #{seen}/#{job.total_files} entries",
243
+ Theme::DIM)
244
+ else
245
+ s.put(4, y, "#{job.file_count} files", Theme::DIM)
246
+ end
247
+ y += 3
248
+
249
+ s.put(2, y, Text.fit(job.current_file.to_s, s.w - 4), Theme::DIM)
250
+ y += 2
251
+
252
+ s.hline(0, y, s.w, Theme::BORDER)
253
+ y += 1
254
+ rows = [s.h - y - 1, 0].max # a very short terminal leaves no room at all
255
+ job.tail(rows).each_with_index do |line, i|
256
+ style = if line.start_with?("▸") then Theme::ACCENT
257
+ elsif line.match?(/error|cannot|denied|warning/i) then Theme::WARN
258
+ else Theme::FAINT
259
+ end
260
+ s.put(1, y + i, Text.fit(line, s.w - 2), style)
261
+ end
262
+
263
+ foot = if job.running?
264
+ [["b", "background"], ["x", "cancel"], ["q", "quit"]]
265
+ else
266
+ [["esc", "back"], ["q", "quit"]]
267
+ end
268
+ s.fill(0, s.h - 1, s.w, 1, " ", nil)
269
+ if job.done? && job.error
270
+ s.put(1, s.h - 1, Text.fit(job.error, s.w - 2), Theme::ERR)
271
+ else
272
+ x = 1
273
+ foot.each do |k, label|
274
+ x = s.put(x, s.h - 1, k, Theme::KEY)
275
+ x = s.put(x + 1, s.h - 1, label, Theme::DIM)
276
+ x += 2
277
+ end
278
+ end
279
+ end
280
+
281
+ # ------------------------------------------------------------ dispatch
282
+
283
+ def dispatch(key)
284
+ return modal_key(key) if @modal
285
+ return job_key(key) if @mode == :job
286
+
287
+ case @browser.handle(key)
288
+ when :quit then request_quit
289
+ when :archive then start_wizard
290
+ when :tags then open_tags
291
+ when :help then open_help
292
+ when :jobs then open_jobs
293
+ end
294
+ end
295
+
296
+ def job_key(key)
297
+ job = @focus_job
298
+ case key
299
+ when "b", :esc, :backspace
300
+ @mode = :browse
301
+ notify("running in the background · press b to watch", Theme::ACCENT) if job.running?
302
+ when "x"
303
+ if job.running?
304
+ job.cancel
305
+ notify("cancelling…", Theme::WARN)
306
+ end
307
+ when :enter
308
+ @mode = :browse unless job.running?
309
+ when "q" then request_quit # the footer says quit, so it quits
310
+ end
311
+ end
312
+
313
+ def modal_key(key)
314
+ # ctrl-c backs out of any panel; raw mode delivers it as a keystroke.
315
+ res = key == :ctrl_c ? :cancel : @modal.handle(key)
316
+ return if res.nil?
317
+
318
+ modal = @modal
319
+ @modal = nil
320
+
321
+ if modal.is_a?(TagsModal)
322
+ @browser.replace_tags(@tags_ref)
323
+ @tags_ref = nil
324
+ return
325
+ end
326
+
327
+ if @quit_confirm
328
+ @quit_confirm = false
329
+ @quit = true if res == :done
330
+ return
331
+ end
332
+
333
+ return unless @wizard_pos
334
+
335
+ res == :done ? wizard_forward(modal) : wizard_back
336
+ end
337
+
338
+ # -------------------------------------------------------------- wizard
339
+
340
+ def start_wizard
341
+ sel = @browser.selection
342
+ if sel.empty?
343
+ @modal = MessageModal.new(title: "nothing to archive",
344
+ lines: ["Tag files or folders with space,",
345
+ "or put the cursor on one and press p."],
346
+ style: Theme::WARN)
347
+ return
348
+ end
349
+ sel.each { |p| @sizer.request(p) }
350
+ @plan = Plan.new(paths: sel, outdir: @browser.cwd)
351
+ @name_edited = false
352
+ @where = 0
353
+ @where_text = ""
354
+ @wizard_pos = 0
355
+ open_wizard_step
356
+ end
357
+
358
+ # A lone file loses its extension ("notes.txt" becomes notes.tar.gz)
359
+ # unless the output is that file compressed on its own (notes.txt.gz).
360
+ # A folder keeps its name whole, dots and all. Several items take the
361
+ # folder name.
362
+ def default_basename(sel)
363
+ name = if sel.size > 1 then File.basename(@browser.cwd)
364
+ elsif File.directory?(sel.first) || @plan&.single_compress? then File.basename(sel.first)
365
+ else File.basename(sel.first).sub(/(?<=.)\.[^.]+\z/, "")
366
+ end
367
+ name.empty? || name == "/" ? "archive" : name
368
+ end
369
+
370
+ def open_wizard_step
371
+ loop do
372
+ step = WIZARD[@wizard_pos]
373
+ return finish_wizard if step.nil?
374
+
375
+ modal = build_step(step)
376
+ if modal.nil?
377
+ @wizard_pos += 1
378
+ next
379
+ end
380
+ @modal = modal
381
+ return
382
+ end
383
+ end
384
+
385
+ def wizard_forward(modal)
386
+ apply_step(WIZARD[@wizard_pos], modal)
387
+ return if @wizard_pos.nil? # a step may have ended the wizard itself
388
+
389
+ @wizard_pos += 1
390
+ open_wizard_step
391
+ end
392
+
393
+ def wizard_back
394
+ loop do
395
+ @wizard_pos -= 1
396
+ if @wizard_pos.negative?
397
+ @wizard_pos = nil
398
+ @plan = nil
399
+ return
400
+ end
401
+ modal = build_step(WIZARD[@wizard_pos])
402
+ next if modal.nil?
403
+
404
+ @modal = modal
405
+ return
406
+ end
407
+ end
408
+
409
+ def build_step(step)
410
+ case step
411
+ when :target then target_modal
412
+ when :options then options_modal
413
+ when :where then where_modal
414
+ when :output then output_modal
415
+ when :confirm then confirm_modal
416
+ end
417
+ end
418
+
419
+ def apply_step(step, modal)
420
+ case step
421
+ when :target then @plan.target = modal.result
422
+ when :where
423
+ @where = modal.index
424
+ @where_text = modal.text
425
+ # ~ and $HOME are for typed text; a browsed folder is taken as is,
426
+ # even one with a dollar sign in its name.
427
+ @plan.outdir = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
428
+ when :output
429
+ @name_edited = true
430
+ @plan.basename = modal.result
431
+ when :confirm then launch
432
+ end
433
+ end
434
+
435
+ def target_modal
436
+ tar_ok = Tools.available?("tar")
437
+ comp_ok = @plan.tar_choices.any? { |c| c[2] }
438
+ zip_ok = @plan.compress_choices.any? { |c| c[2] }
439
+ n = @plan.paths.size
440
+ what = n == 1 ? File.basename(@plan.paths.first) : "#{n} items"
441
+ SelectModal.new(
442
+ title: "archive #{what}",
443
+ items: [
444
+ SelectModal::Item.new(label: "compressed tarball", value: :both, enabled: tar_ok && comp_ok,
445
+ why: tar_ok ? "no compressor installed" : "tar not installed",
446
+ blurb: ".tar.gz by default; zstd, xz and friends on offer"),
447
+ SelectModal::Item.new(label: "plain tarball", value: :tar, enabled: tar_ok,
448
+ why: "tar not installed",
449
+ blurb: ".tar, no compression"),
450
+ SelectModal::Item.new(label: "zip archive", value: :zip, enabled: zip_ok,
451
+ why: "no usable compressor here",
452
+ blurb: ".zip, or a lone file gzipped, zstd, xz")
453
+ ],
454
+ index: Plan::TARGETS.index(@plan.target) || 0,
455
+ footer: "j/k move · enter choose · esc cancel"
456
+ )
457
+ end
458
+
459
+ def options_modal
460
+ case @plan.target
461
+ when :both
462
+ codec_form(@plan.tar, title: "compression", label: "compression",
463
+ flags_label: "tar flags")
464
+ when :tar
465
+ rows = [FormModal::Row.new(kind: :label, label: "tar flags")] + toggle_rows(@plan.tar_flags)
466
+ FormModal.new(title: "tarball options", rows: rows)
467
+ else
468
+ codec_form(@plan.compress, title: "compression", label: "method",
469
+ flags_label: "zip flags", flags_when_container: true)
470
+ end
471
+ end
472
+
473
+ # A codec, its level, then that format's switches.
474
+ def codec_form(side, title:, label:, flags_label:, flags_when_container: false)
475
+ rows = [
476
+ FormModal::Row.new(kind: :choice, label: label, hint: "", values: side.choices,
477
+ get: -> { side.codec.id }, set: ->(id) { side.codec = id }),
478
+ FormModal::Row.new(kind: :number, label: "level", hint: "",
479
+ values: side.codec.levels || (0..0),
480
+ get: -> { side.level || 0 }, set: ->(v) { side.level = v })
481
+ ]
482
+ extra = [FormModal::Row.new(kind: :spacer),
483
+ FormModal::Row.new(kind: :label, label: flags_label)] + toggle_rows(side.flags)
484
+ DynamicForm.new(title: title, rows: rows, extra: extra, side: side,
485
+ extra_when_container: flags_when_container)
486
+ end
487
+
488
+ def toggle_rows(flags)
489
+ flags.map do |f|
490
+ FormModal::Row.new(kind: :toggle, label: f.label, hint: f.blurb,
491
+ get: -> { f.on }, set: ->(v) { f.on = v })
492
+ end
493
+ end
494
+
495
+ def where_modal
496
+ WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
497
+ validate: lambda { |text|
498
+ d = Rakpak.expand_dir(text)
499
+ if !File.directory?(d) then "not a folder: #{d}"
500
+ elsif !File.writable?(d) then "not writable: #{d}"
501
+ end
502
+ })
503
+ end
504
+
505
+ def output_modal
506
+ @plan.basename = default_basename(@plan.paths) unless @name_edited
507
+ InputModal.new(title: "output name",
508
+ value: @plan.basename,
509
+ hint: "in #{Text.tilde(@plan.outdir)} · #{@plan.ext} added automatically",
510
+ validate: lambda { |name|
511
+ # The folder was chosen on the previous screen; this is
512
+ # a bare filename, so it can never reach outside it.
513
+ if name.include?("/") then "just a name, no slashes; the folder was picked already"
514
+ elsif name.include?("\0") || [".", ".."].include?(name) then "not a valid name"
515
+ end
516
+ })
517
+ end
518
+
519
+ def confirm_modal
520
+ lines = []
521
+ lines << [:head, "#{@plan.paths.size} item#{'s' unless @plan.paths.size == 1} " \
522
+ "from #{Text.tilde(@plan.base)}"]
523
+ @plan.members.first(6).each { |m| lines << [:key, " #{m}"] }
524
+ lines << [:key, " … #{@plan.members.size - 6} more"] if @plan.members.size > 6
525
+ lines << [:key, ""]
526
+ lines << [:head, "writes #{Text.tilde(@plan.output)}"]
527
+ lines << [:key, ""]
528
+ @plan.preview.each do |label, cmd|
529
+ lines << [:head, "#{label}:"]
530
+ lines << [:cmd, " #{cmd}"]
531
+ end
532
+ lines << [:key, ""]
533
+ ConfirmModal.new(title: "ready", lines: lines,
534
+ warnings: @plan.warnings, errors: @plan.problems)
535
+ end
536
+
537
+ def finish_wizard
538
+ @wizard_pos = nil
539
+ end
540
+
541
+ # Always opens the job view; b from there sends it to the background.
542
+ def launch
543
+ total = @sizer.total(@plan.paths)
544
+ job = Job.new(@plan, total_files: total.files.positive? ? total.files : nil).start
545
+ @jobs << job
546
+ @focus_job = job
547
+ @wizard_pos = nil
548
+ @mode = :job
549
+ end
550
+
551
+ def reap_jobs
552
+ @jobs.each do |j|
553
+ next if j.running? || @reported_jobs[j]
554
+
555
+ @reported_jobs[j] = true
556
+ @browser.refresh!
557
+ next if @mode == :job && @focus_job == j
558
+
559
+ case j.state
560
+ when :done
561
+ sizes = j.summary.map { |n, sz| "#{n} #{Text.bytes(sz)}" }.join(" · ")
562
+ notify("✓ #{sizes}", Theme::OK, 8)
563
+ when :failed then notify("✗ #{j.error}", Theme::ERR, 10)
564
+ when :cancelled then notify("cancelled", Theme::WARN)
565
+ end
566
+ end
567
+ end
568
+
569
+ # --------------------------------------------------------------- panels
570
+
571
+ def open_tags
572
+ list = @browser.tags.to_a.sort
573
+ modal = TagsModal.new(tags: list, sizer: @sizer)
574
+ @modal = modal
575
+ @tags_ref = list
576
+ end
577
+
578
+ def open_jobs
579
+ if @jobs.empty?
580
+ notify("no jobs yet", Theme::DIM)
581
+ return
582
+ end
583
+ @focus_job = @jobs.reverse.find(&:running?) || @jobs.last
584
+ @mode = :job
585
+ end
586
+
587
+ def open_help
588
+ keys = [
589
+ ["j k ↑ ↓", "move"],
590
+ ["l → enter", "enter folder"], ["h ←", "leave folder"],
591
+ ["g g", "top"], ["G", "bottom"], ["ctrl-d ctrl-u", "half page"],
592
+ ["g h", "home"], ["g r", "root"], ["~", "home"],
593
+ ["space", "tag / untag, then move down"],
594
+ ["a", "tag everything here"], ["d", "untag everything here"],
595
+ ["D", "clear all tags"], ["T", "review tagged items"],
596
+ ["t", "show / hide the queue pane"],
597
+ ["/", "filter this folder"], [".", "show hidden"],
598
+ ["ctrl-r", "reload"],
599
+ ["p", "pack: archive the tagged items"],
600
+ ["b", "watch running jobs"], ["q", "quit"]
601
+ ]
602
+ w = keys.map { |k, _| Text.width(k) }.max
603
+ lines = keys.map { |k, v| "#{Text.pad(k, w)} #{v}" }
604
+ @modal = MessageModal.new(title: "keys", lines: lines, style: Theme::NORMAL)
605
+ end
606
+
607
+ def request_quit
608
+ running = @jobs.count(&:running?)
609
+ if running.positive?
610
+ @modal = ConfirmModal.new(
611
+ title: "jobs still running",
612
+ lines: [[:head, "#{running} job#{'s' unless running == 1} still running."],
613
+ [:key, ""],
614
+ [:key, "Quitting stops them and leaves partial archives behind."]],
615
+ footer: "enter quit anyway · esc stay"
616
+ )
617
+ @quit_confirm = true
618
+ else
619
+ @quit = true
620
+ end
621
+ end
622
+
623
+ def report
624
+ done = @jobs.select(&:ok?)
625
+ return if done.empty?
626
+
627
+ done.each do |j|
628
+ # Printed after the TUI has gone, straight to the shell, so a folder
629
+ # or file name carrying an escape sequence must be defanged.
630
+ j.summary.each { |name, size| puts "#{Text.plain(File.join(j.plan.outdir, name))} #{Text.bytes(size)}" }
631
+ end
632
+ end
633
+ end
634
+
635
+ # The option form follows the codec the user just picked: the level row
636
+ # takes that codec's range, and rows that only apply to a zip container
637
+ # (its switches) appear only while a container method is selected.
638
+ class DynamicForm < FormModal
639
+ def initialize(title:, rows:, side:, extra: [], extra_when_container: false)
640
+ super(title: title, rows: rows + extra)
641
+ @base = rows
642
+ @extra = extra
643
+ @side = side
644
+ @conditional = extra_when_container
645
+ end
646
+
647
+ def draw(screen)
648
+ sync_rows
649
+ super
650
+ end
651
+
652
+ def sync_rows
653
+ codec = @side.codec
654
+ @rows = !@conditional || codec.container? ? @base + @extra : @base
655
+ @index = @index.clamp(0, [@rows.size - 1, 0].max)
656
+ @rows[0].hint = codec.blurb.to_s
657
+ level = @rows[1]
658
+ level.values = codec.levels || (0..0)
659
+ level.hint = codec.levels ? "higher = smaller, slower" : "not adjustable"
660
+ end
661
+ end
662
+ end