rakpak 1.0.0 → 1.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.
data/lib/rakpak/modal.rb CHANGED
@@ -4,8 +4,6 @@ require_relative "theme"
4
4
  require_relative "text"
5
5
 
6
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
7
  class Modal
10
8
  attr_reader :result
11
9
 
@@ -15,7 +13,6 @@ module Rakpak
15
13
  @result = nil
16
14
  end
17
15
 
18
- # Desired [width, height] given the screen.
19
16
  def dims(screen)
20
17
  [[screen.w - 6, 72].min, [screen.h - 4, 20].min]
21
18
  end
@@ -41,11 +38,9 @@ module Rakpak
41
38
 
42
39
  def body(screen, x, y, w, h); end
43
40
 
44
- # :done, :cancel or nil
45
41
  def handle(_key) = nil
46
42
  end
47
43
 
48
- # A vertical list of choices, some of which may be unavailable.
49
44
  class SelectModal < Modal
50
45
  Item = Struct.new(:label, :value, :blurb, :enabled, :why, keyword_init: true)
51
46
 
@@ -122,7 +117,6 @@ module Rakpak
122
117
  end
123
118
  end
124
119
 
125
- # A form of mixed rows: cycling choices, numeric ranges and toggles.
126
120
  class FormModal < Modal
127
121
  Row = Struct.new(:kind, :label, :hint, :get, :set, :values, keyword_init: true)
128
122
 
@@ -214,7 +208,6 @@ module Rakpak
214
208
  end
215
209
  end
216
210
 
217
- # The reason the current settings cannot be run, or nil.
218
211
  def unusable
219
212
  @rows.each do |row|
220
213
  next unless row.kind == :choice
@@ -269,8 +262,6 @@ module Rakpak
269
262
  end
270
263
  end
271
264
 
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
265
  class InputModal < Modal
275
266
  def initialize(title:, value: "", hint: "", footer: "enter accept · esc back", validate: nil)
276
267
  super(title: title, footer: footer)
@@ -294,11 +285,10 @@ module Rakpak
294
285
  field(screen, x, y + (@hint.empty? ? 0 : 2), w, active: true)
295
286
  end
296
287
 
297
- # The text box on its own, for embedding in another panel.
298
288
  def field(screen, x, row, w, active: true)
299
289
  screen.fill(x, row, w, 1, " ", Theme::SEL_BG)
300
290
  width = w - 2
301
- # Scroll so the cursor is visible, counting columns, not characters.
291
+ # Scroll by display columns, not characters.
302
292
  off = 0
303
293
  off += 1 while off < @cur && Text.width(@buf[off...@cur]) >= width
304
294
  shown = +""
@@ -342,7 +332,9 @@ module Rakpak
342
332
  when :ctrl_u then @buf.slice!(0, @cur) && (@cur = 0)
343
333
  when :ctrl_k then @buf.slice!(@cur..)
344
334
  when :ctrl_w
345
- left = @buf[0, @cur].sub(/\S*\s*\z/, "")
335
+ left = @buf[0, @cur].rstrip
336
+ boundary = left.rindex(/\s/)
337
+ left = boundary ? left[0..boundary] : ""
346
338
  @buf = left + (@buf[@cur..] || "")
347
339
  @cur = left.length
348
340
  when :space then insert(" ")
@@ -359,13 +351,11 @@ module Rakpak
359
351
  end
360
352
  end
361
353
 
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
354
  class WhereModal < Modal
365
355
  attr_reader :index
366
356
 
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")
357
+ def initialize(here:, home:, index: 0, text: "", validate: nil, title: "save it where?")
358
+ super(title: title, footer: "1-3 or ↑↓ pick · enter choose · esc back")
369
359
  @choices = [["This directory", here], ["Home directory", home], ["Specify", nil]]
370
360
  @index = index
371
361
  @field = InputModal.new(title: "", value: text, validate: validate)
@@ -417,8 +407,6 @@ module Rakpak
417
407
  nil
418
408
  end
419
409
 
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
410
  def field_key(key)
423
411
  @index = 2
424
412
  @field.handle(key)
@@ -444,9 +432,71 @@ module Rakpak
444
432
  end
445
433
  end
446
434
 
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".
435
+ # Result is the typed names, or :tag to go mark exclusions in the file view.
436
+ class ExcludeModal < Modal
437
+ def initialize(text:, marked:)
438
+ super(title: "exclude", footer: "↑↓ pick · enter continue · esc back")
439
+ @field = InputModal.new(title: "", value: text)
440
+ @marked = marked
441
+ @index = 0
442
+ end
443
+
444
+ def text = @field.text
445
+
446
+ def dims(screen)
447
+ [[[screen.w - 6, 84].min, 44].max, [9 + [@marked.size, 1].max, screen.h - 2].min]
448
+ end
449
+
450
+ def body(screen, x, y, w, h)
451
+ choice(screen, x, y, w, "1. Names", 0)
452
+ @field.field(screen, x + 12, y, w - 12, active: @index.zero?)
453
+ screen.put(x + 3, y + 1, Text.fit("commas between; each matches at any depth, with all it holds", w - 3),
454
+ Theme::MODAL_BG + Theme::DIM)
455
+ choice(screen, x, y + 2, w, "2. Tag files and directories", 1)
456
+ if @marked.empty?
457
+ screen.put(x, y + 4, "nothing marked", Theme::MODAL_BG + Theme::FAINT)
458
+ return
459
+ end
460
+ screen.put(x, y + 4, "marked", Theme::MODAL_BG + Theme::TITLE)
461
+ rows = h - 5
462
+ return if rows < 1
463
+
464
+ shown = @marked.size > rows ? @marked.first(rows - 1) : @marked
465
+ shown.each_with_index do |m, i|
466
+ screen.put(x + 3, y + 5 + i, Text.fit_left(m, w - 3), Theme::MODAL_BG + Theme::NORMAL)
467
+ end
468
+ return if shown.size == @marked.size
469
+
470
+ screen.put(x + 3, y + 5 + shown.size, "… #{@marked.size - shown.size} more", Theme::MODAL_BG + Theme::FAINT)
471
+ end
472
+
473
+ def choice(screen, x, y, w, label, i)
474
+ sel = i == @index
475
+ bg = sel ? Theme::CUR_BG : Theme::MODAL_BG
476
+ screen.fill(x - 1, y, w + 2, 1, " ", bg)
477
+ screen.put(x, y, label, bg + (sel ? "\e[1;38;5;231m" : Theme::NORMAL))
478
+ end
479
+
480
+ def handle(key)
481
+ case key
482
+ when :esc then return :cancel
483
+ when :enter
484
+ @result = @index.zero? ? text : :tag
485
+ return :done
486
+ when :up, :down, :tab then @index = 1 - @index
487
+ else
488
+ # On row 2, j/k move; anything else is typing, so it lands in the field.
489
+ if @index == 1 && %w[j k].include?(key) then @index = 0
490
+ else
491
+ @index = 0
492
+ @field.handle(key)
493
+ end
494
+ end
495
+ nil
496
+ end
497
+ end
498
+
499
+ # Only enter runs it: a single letter (b reads as "back") is too easy to hit by mistake.
450
500
  class ConfirmModal < Modal
451
501
  def initialize(title:, lines:, warnings: [], errors: [], footer: nil)
452
502
  super(title: title,
@@ -462,8 +512,7 @@ module Rakpak
462
512
  [w, [h, screen.h - 2].min]
463
513
  end
464
514
 
465
- # Commands are wrapped rather than clipped: the whole point of this
466
- # panel is that you can read exactly what will run.
515
+ # Wrap, don't clip: the user must see exactly what will run.
467
516
  def laid_out(w)
468
517
  @lines.flat_map do |style, text|
469
518
  if style == :cmd
@@ -514,8 +563,6 @@ module Rakpak
514
563
  end
515
564
  end
516
565
 
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
566
  class MessageModal < Modal
520
567
  def initialize(title:, lines:, style: Theme::ERR)
521
568
  super(title: title, footer: "any key to dismiss")
data/lib/rakpak/plan.rb CHANGED
@@ -1,21 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "formats"
4
+ require_relative "text"
4
5
 
5
6
  module Rakpak
6
- # Turns a set of tagged paths plus the wizard's answers into one concrete
7
- # command. Nothing here shells out; commands are spawned without a shell,
8
- # so spaces and quotes in filenames are never a hazard.
9
- #
10
- # Three shapes of output, one file each:
11
- # :both tar, then compress name.tar.gz, name.tar.zst, ...
12
- # :tar plain tar, no compression name.tar
13
- # :zip compression only name.zip, or name.txt.gz for one file
7
+ # :both tar then compress, :tar plain tar, :zip compress only (name.zip, or name.txt.gz for one file)
14
8
  class Plan
15
9
  TARGETS = %i[both tar zip].freeze
16
10
 
17
- attr_reader :paths
18
- attr_accessor :outdir, :basename, :target
11
+ attr_reader :paths, :excluded
12
+ attr_accessor :outdir, :basename, :target, :excludes
19
13
  attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
20
14
 
21
15
  def initialize(paths:, outdir:, basename: "archive", target: :both)
@@ -23,7 +17,8 @@ module Rakpak
23
17
  @outdir = outdir
24
18
  @basename = basename
25
19
  @target = target
26
- # gzip is the one every system can read; anything else is opt-in.
20
+ @excludes = []
21
+ @excluded = []
27
22
  @tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
28
23
  @tar_level = @tar_codec.default
29
24
  @tar_flags = Rakpak.tar_flags
@@ -32,12 +27,8 @@ module Rakpak
32
27
  @zip_flags = Rakpak.zip_flags
33
28
  end
34
29
 
35
- # Drop anything already covered by another selection: tagging ~/docs and
36
- # then ~/docs/notes would otherwise store notes twice, silently.
37
30
  def self.prune(paths)
38
- # Sorting by path components puts every descendant right after its
39
- # ancestor ("a/b" before "a-x"), so one pass with a stack of accepted
40
- # ancestors is enough, however many paths there are.
31
+ # Sorting by components puts descendants right after their ancestor ("a/b" before "a-x").
41
32
  sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
42
33
  kept = []
43
34
  stack = []
@@ -55,15 +46,29 @@ module Rakpak
55
46
  path.start_with?(dir == "/" ? "/" : "#{dir}/")
56
47
  end
57
48
 
58
- # "none" needs no tool so it is always available; it is the last
59
- # resort, not the first pick, when gzip is missing.
49
+ def self.parse_excludes(text)
50
+ slash = "/".ord
51
+ text.split(",").map do |name|
52
+ name = name.strip
53
+ finish = name.bytesize
54
+ finish -= 1 while finish.positive? && name.getbyte(finish - 1) == slash
55
+ name.byteslice(0, finish)
56
+ end.reject(&:empty?).uniq
57
+ end
58
+
59
+ # Marked paths are exact, so glob characters in them must not match.
60
+ def self.literal(path) = path.gsub(/[\\*?\[\]]/) { |c| "\\#{c}" }
61
+
62
+ # Only marks strictly inside a packed folder count; a packed item itself is untagged instead.
63
+ def excluded=(list)
64
+ @excluded = list.map { |p| File.expand_path(p) }.uniq.sort
65
+ .select { |x| @paths.any? { |p| Plan.inside?(x, p) } }
66
+ end
67
+
60
68
  def tar_codec_fallback
61
69
  TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
62
70
  end
63
71
 
64
- # One format's knobs behind a uniform face, so the option form does not
65
- # need to know whether it is editing tar or compressor settings. Picking
66
- # a codec resets the level to that codec's default.
67
72
  class Side
68
73
  attr_reader :options, :flags
69
74
 
@@ -82,7 +87,7 @@ module Rakpak
82
87
  @plan.public_send("#{@level_attr}=", v)
83
88
  end
84
89
 
85
- # [label, id, enabled, why] rows for the form's choice list.
90
+ # [label, id, enabled, why] rows.
86
91
  def choices = @plan.public_send(@choices)
87
92
 
88
93
  def codec=(id)
@@ -102,13 +107,10 @@ module Rakpak
102
107
  flags: @zip_flags, choices: :compress_choices)
103
108
  end
104
109
 
105
- # "both" means compressed, so "none" is not on offer there.
106
110
  def tar_choices
107
111
  TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
108
112
  end
109
113
 
110
- # Single-file compressors only make sense for exactly one file; zip can
111
- # take anything.
112
114
  def compress_choices
113
115
  COMPRESSORS.map do |c|
114
116
  ok = c.available? && (c.container? || single_file?)
@@ -119,13 +121,8 @@ module Rakpak
119
121
 
120
122
  def single_file? = @paths.size == 1 && File.file?(@paths.first)
121
123
 
122
- # True when the output is a bare compressed file (notes.txt.gz), where
123
- # the original name should be kept whole.
124
124
  def single_compress? = @target == :zip && !@compressor.container?
125
125
 
126
- # Deepest directory containing every tagged path. Members are stored
127
- # relative to it, so the archive has a sane shape no matter how far
128
- # apart the selections were.
129
126
  def base
130
127
  @base ||= begin
131
128
  dirs = @paths.map { |p| File.dirname(p) }
@@ -141,11 +138,11 @@ module Rakpak
141
138
  end
142
139
  end
143
140
 
144
- def members
145
- @paths.map do |p|
146
- rel = p.delete_prefix(base == "/" ? "/" : "#{base}/")
147
- rel.empty? ? File.basename(p) : rel
148
- end
141
+ def members = @paths.map { |p| relative(p) }
142
+
143
+ def relative(path)
144
+ rel = path.delete_prefix(base == "/" ? "/" : "#{base}/")
145
+ rel.empty? ? File.basename(path) : rel
149
146
  end
150
147
 
151
148
  def ext
@@ -159,17 +156,41 @@ module Rakpak
159
156
  def output = File.join(@outdir, ensure_ext(@basename, ext))
160
157
  def outputs = [output]
161
158
 
162
- # Extensions a user might type that we would otherwise double up.
159
+ def prepare; end
160
+ def commit; end
161
+ def rollback; end
162
+
163
+ def gerund = "archiving"
164
+
165
+ def outcome
166
+ outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
167
+ end
168
+
169
+ def report_note = Text.bytes(file_size(output))
170
+
171
+ def file_size(path)
172
+ File.size(path)
173
+ rescue StandardError
174
+ nil
175
+ end
176
+
177
+ # Excluded files are inside the count, so a total would stall the bar; show the count alone.
178
+ def total_members(sizer)
179
+ return nil if @excludes.any? || @excluded.any?
180
+
181
+ files = sizer.total(@paths).files
182
+ files.positive? ? files : nil
183
+ end
184
+
185
+ def clobbers_output? = true
186
+
163
187
  ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
164
188
  .reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
165
189
 
166
190
  def ensure_ext(name, ext)
167
191
  return name if name.downcase.end_with?(ext)
168
- # backup.tar gzipped on its own is backup.tar.gz; the name is the
169
- # point, so nothing is stripped from it.
170
192
  return "#{name}#{ext}" if single_compress?
171
193
 
172
- # Strip a competing archive extension the user may have typed.
173
194
  typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
174
195
  stripped = typed ? name[0...-typed.length] : name
175
196
  stripped = name if stripped.empty?
@@ -182,10 +203,36 @@ module Rakpak
182
203
  argv += ["--use-compress-program", filter]
183
204
  end
184
205
  @tar_flags.each { |f| argv.concat(f.args) if f.on }
206
+ argv += tar_exclude_args
185
207
  argv += ["-f", output, "-C", base, "--"]
186
208
  argv + members
187
209
  end
188
210
 
211
+ # Names match at any depth. Marks are anchored: GNU tar takes --anchored, bsdtar a leading ^,
212
+ # busybox has neither (warnings says so).
213
+ def tar_exclude_args
214
+ args = @excludes.map { |n| "--exclude=#{n}" }
215
+ marked = @excluded.map { |p| Plan.literal(relative(p)) }
216
+ return args if marked.empty?
217
+
218
+ case Tools.tar_flavor
219
+ when *GNU then args + ["--anchored"] + marked.map { |m| "--exclude=#{m}" }
220
+ when :bsd then args + marked.map { |m| "--exclude=^#{m}" }
221
+ else args + marked.map { |m| "--exclude=#{m}" }
222
+ end
223
+ end
224
+
225
+ # zip matches -x against whole member paths, so a name needs each depth spelled out.
226
+ def zip_exclude_args
227
+ names = @excludes.map { |n| zipsafe(n) }.flat_map { |n| [n, "#{n}/*", "*/#{n}", "*/#{n}/*"] }
228
+ marks = @excluded.map { |p| zipsafe(Plan.literal(relative(p))) }.flat_map { |m| [m, "#{m}/*"] }
229
+ pats = names + marks
230
+ pats.empty? ? [] : ["-x", *pats]
231
+ end
232
+
233
+ # zip would read a leading dash as an option; [-] matches the same name.
234
+ def zipsafe(pat) = pat.start_with?("-") ? "[-]#{pat[1..]}" : pat
235
+
189
236
  def zip_argv
190
237
  argv = ["zip", "-r"]
191
238
  argv << (zip_verbose? ? "-v" : "-q")
@@ -198,11 +245,9 @@ module Rakpak
198
245
  argv.concat(f.args) if f.on
199
246
  end
200
247
  argv << output
201
- argv + members.map { |m| dashsafe(m) }
248
+ argv + members.map { |m| dashsafe(m) } + zip_exclude_args
202
249
  end
203
250
 
204
- # gzip and friends read one file and write to stdout; the job redirects
205
- # that into the output path.
206
251
  def single_argv
207
252
  @compressor.argv(@comp_level) + [dashsafe(members.first)]
208
253
  end
@@ -230,8 +275,7 @@ module Rakpak
230
275
  end
231
276
  end
232
277
 
233
- # Display form of the command. Execution never goes through a shell, so
234
- # this is for the reader's benefit; quote only what needs it.
278
+ # Display only; execution never goes through a shell.
235
279
  def self.show_arg(arg)
236
280
  arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
237
281
  end
@@ -245,7 +289,6 @@ module Rakpak
245
289
  steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
246
290
  end
247
291
 
248
- # Problems worth blocking on, checked right before the run.
249
292
  def problems
250
293
  errs = []
251
294
  errs << "nothing selected" if @paths.empty?
@@ -267,7 +310,6 @@ module Rakpak
267
310
  errs.compact.uniq
268
311
  end
269
312
 
270
- # Non-blocking things the confirm screen should say out loud.
271
313
  def warnings
272
314
  warn = []
273
315
  o = output
@@ -275,6 +317,14 @@ module Rakpak
275
317
  if @paths.any? { |p| Plan.inside?(o, p) }
276
318
  warn << "output sits inside a selected folder, so it may archive itself"
277
319
  end
320
+ @paths.each do |p|
321
+ name = File.basename(p)
322
+ hit = @excludes.find { |pat| File.fnmatch?(pat, name, File::FNM_DOTMATCH) }
323
+ warn << "#{name} matches #{hit}, so it is left out entirely" if hit
324
+ end
325
+ if @excluded.any? && @target != :zip && Tools.tar_flavor == :busybox
326
+ warn << "busybox tar cannot pin a mark to one spot; same-named paths deeper in go too"
327
+ end
278
328
  warn
279
329
  end
280
330
  end
data/lib/rakpak/screen.rb CHANGED
@@ -4,8 +4,6 @@ require_relative "text"
4
4
  require_relative "theme"
5
5
 
6
6
  module Rakpak
7
- # A character grid. Everything draws into cells, so modals overlay the
8
- # browser cleanly and the whole frame ships in one write.
9
7
  class Screen
10
8
  attr_reader :w, :h
11
9
 
@@ -25,7 +23,6 @@ module Rakpak
25
23
  @st.fill(style)
26
24
  end
27
25
 
28
- # Writes `text` at (x, y). Returns the column just past the text.
29
26
  def put(x, y, text, style = nil)
30
27
  return x if y.negative? || y >= @h
31
28
 
@@ -50,17 +47,14 @@ module Rakpak
50
47
  cx
51
48
  end
52
49
 
53
- # A double-width glyph owns two cells: itself and an empty marker after
54
- # it. Writing over either half must blank the other, or the row renders
55
- # one column too wide and the terminal wraps it.
50
+ # A wide glyph owns two cells; overwriting either half must blank the other or the row wraps.
56
51
  def clear_halves(idx)
57
52
  col = idx % @w
58
53
  @ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
59
54
  @ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
60
55
  end
61
56
 
62
- # Last line of defence: a single binary byte reaching @ch would make the
63
- # whole frame fail to concatenate and take the app down mid-render.
57
+ # One binary byte in @ch would break frame concatenation mid-render.
64
58
  def printable(text)
65
59
  str = text.to_s
66
60
  str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
@@ -104,8 +98,6 @@ module Rakpak
104
98
  end
105
99
  end
106
100
 
107
- # Flatten everything to a faint monochrome so a modal reads as the
108
- # foreground layer.
109
101
  def veil(style = Theme::FAINT)
110
102
  @st.fill(style)
111
103
  end
data/lib/rakpak/sizer.rb CHANGED
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # Walks tagged directories on a worker thread so the UI can show a real
5
- # byte total and file count without ever blocking on a huge tree.
6
4
  class Sizer
7
5
  Result = Struct.new(:bytes, :files, :partial)
8
6
 
9
- BUDGET = 4.0 # seconds per path before we report a partial figure
7
+ BUDGET = 4.0 # seconds per path before reporting a partial figure
10
8
 
11
9
  def initialize
12
10
  @cache = {}
@@ -40,7 +38,6 @@ module Rakpak
40
38
  Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
41
39
  end
42
40
 
43
- # Drops the figure, and any measurement in flight for it.
44
41
  def forget(path)
45
42
  @lock.synchronize do
46
43
  @cache.delete(path)
@@ -60,8 +57,7 @@ module Rakpak
60
57
  def work(path, token)
61
58
  res = measure(path)
62
59
  @lock.synchronize do
63
- # Forgotten, invalidated, or re-requested while we were walking:
64
- # only the walk that the current request started may answer it.
60
+ # Only the walk the current request started may answer it.
65
61
  next unless @pending[path].equal?(token)
66
62
 
67
63
  @pending.delete(path)
data/lib/rakpak/term.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "io/console"
4
4
 
5
5
  module Rakpak
6
- # Raw-mode terminal control and key decoding.
7
6
  module Term
8
7
  ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
9
8
  "H" => :home, "F" => :end }.freeze
@@ -52,8 +51,7 @@ module Rakpak
52
51
  $stdout.flush
53
52
  end
54
53
 
55
- # Blocks up to `timeout` seconds. Returns a key symbol, a printable
56
- # String, or nil on timeout.
54
+ # Returns a key symbol, a printable String, or nil on timeout.
57
55
  def wait_key(timeout = nil)
58
56
  return nil unless IO.select([$stdin], nil, nil, timeout)
59
57
 
@@ -78,10 +76,7 @@ module Rakpak
78
76
  end
79
77
  end
80
78
 
81
- # Under a C locale getc yields one byte at a time, and under UTF-8 a
82
- # stray byte arrives as a one-byte invalid string. Gather the rest of
83
- # the sequence when there is one; if the result is still not valid
84
- # text, the key is dropped rather than raised on later.
79
+ # C locale getc yields single bytes; gather the sequence and drop it if still invalid.
85
80
  def complete_utf8(c)
86
81
  s = c.dup.force_encoding(Encoding::UTF_8)
87
82
  return s if s.valid_encoding?
@@ -109,9 +104,7 @@ module Rakpak
109
104
  nil
110
105
  end
111
106
 
112
- # An arrow key arrives as several bytes. Over a slow link they can be
113
- # split across reads, so allow a generous window before concluding the
114
- # user pressed a bare Esc. The delay is only ever paid on a real Esc.
107
+ # Escape sequences can split across reads on slow links; only a real Esc pays this wait.
115
108
  ESC_WINDOW = 0.05
116
109
 
117
110
  def read_escape
@@ -139,7 +132,6 @@ module Rakpak
139
132
  :esc
140
133
  end
141
134
  else
142
- # Alt-<char>: the whole sequence is the character.
143
135
  :"alt_#{seq}"
144
136
  end
145
137
  end
data/lib/rakpak/text.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # Grapheme-aware width math. Just enough Unicode to keep columns honest.
5
4
  module Text
6
5
  WIDE = [
7
6
  0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
@@ -14,12 +13,10 @@ module Rakpak
14
13
 
15
14
  module_function
16
15
 
17
- # Display width of a single grapheme cluster.
18
16
  def gw(cluster)
19
17
  cp = cluster.ord
20
- return 1 if cp >= 0x20 && cp < 0x7F # the common case, checked first
21
- # C0, DEL and the C1 controls: a UTF-8 terminal executes U+009B as
22
- # CSI, so these must never reach the frame.
18
+ return 1 if cp >= 0x20 && cp < 0x7F
19
+ # C0, DEL and C1: a UTF-8 terminal executes U+009B as CSI.
23
20
  return 0 if cp < 0x20 || cp == 0x7F || (cp >= 0x80 && cp <= 0x9F)
24
21
  return 0 if ZERO.any? { |r| r.cover?(cp) }
25
22
  return 2 if WIDE.any? { |r| r.cover?(cp) }
@@ -35,7 +32,6 @@ module Rakpak
35
32
  w
36
33
  end
37
34
 
38
- # Truncate to `max` columns, appending an ellipsis when something was cut.
39
35
  def fit(str, max)
40
36
  return "" if max <= 0
41
37
  return str if width(str) <= max
@@ -52,7 +48,6 @@ module Rakpak
52
48
  out << "…"
53
49
  end
54
50
 
55
- # Keep the tail of a path visible instead of the head.
56
51
  def fit_left(str, max)
57
52
  return "" if max <= 0
58
53
  return str if width(str) <= max
@@ -75,7 +70,6 @@ module Rakpak
75
70
  w >= max ? str : str + (" " * (max - w))
76
71
  end
77
72
 
78
- # Greedy wrap on spaces, with a hanging indent for continuations.
79
73
  def wrap(str, max, indent = 0)
80
74
  return [str] if max <= indent + 4 || width(str) <= max
81
75
 
@@ -84,7 +78,6 @@ module Rakpak
84
78
  out = []
85
79
  line = +""
86
80
  words = str[lead.length..].to_s.split(" ").flat_map do |word|
87
- # A path with no spaces can still be wider than the panel; break it.
88
81
  limit = max - indent
89
82
  next word if width(word) <= limit || limit < 8
90
83
 
@@ -103,7 +96,6 @@ module Rakpak
103
96
  out
104
97
  end
105
98
 
106
- # /home/me/x → ~/x. Only a real prefix counts: /home/me2 is left alone.
107
99
  def tilde(path)
108
100
  home = Dir.home
109
101
  return path if home.nil? || home.empty? || home == "/"
@@ -112,8 +104,7 @@ module Rakpak
112
104
  path.start_with?("#{home}/") ? "~#{path[home.length..]}" : path
113
105
  end
114
106
 
115
- # For text that goes to the terminal without passing through Screen:
116
- # control and C1 bytes could otherwise be executed as escape sequences.
107
+ # Control and C1 bytes could be executed as escape sequences.
117
108
  def plain(str)
118
109
  str.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/[\u0000-\u001f\u007f-\u009f]/, "?")
119
110
  end
data/lib/rakpak/theme.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # SGR fragments. Every style is emitted after a reset, so they compose by
5
- # concatenation without needing to be self-contained.
4
+ # Every style is emitted after a reset, so fragments needn't be self-contained.
6
5
  module Theme
7
6
  RESET = "\e[0m"
8
7