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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formats"
4
+
5
+ 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
14
+ class Plan
15
+ TARGETS = %i[both tar zip].freeze
16
+
17
+ attr_reader :paths
18
+ attr_accessor :outdir, :basename, :target
19
+ attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
20
+
21
+ def initialize(paths:, outdir:, basename: "archive", target: :both)
22
+ @paths = self.class.prune(paths)
23
+ @outdir = outdir
24
+ @basename = basename
25
+ @target = target
26
+ # gzip is the one every system can read; anything else is opt-in.
27
+ @tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
28
+ @tar_level = @tar_codec.default
29
+ @tar_flags = Rakpak.tar_flags
30
+ @compressor = COMPRESSORS.find { |c| c.container? && c.available? } || COMPRESSORS.first
31
+ @comp_level = @compressor.default
32
+ @zip_flags = Rakpak.zip_flags
33
+ end
34
+
35
+ # Drop anything already covered by another selection: tagging ~/docs and
36
+ # then ~/docs/notes would otherwise store notes twice, silently.
37
+ 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.
41
+ sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
42
+ kept = []
43
+ stack = []
44
+ sorted.each do |p|
45
+ stack.pop while stack.any? && !inside?(p, stack.last)
46
+ next if stack.any?
47
+
48
+ kept << p
49
+ stack << p
50
+ end
51
+ kept
52
+ end
53
+
54
+ def self.inside?(path, dir)
55
+ path.start_with?(dir == "/" ? "/" : "#{dir}/")
56
+ end
57
+
58
+ # "none" needs no tool so it is always available; it is the last
59
+ # resort, not the first pick, when gzip is missing.
60
+ def tar_codec_fallback
61
+ TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
62
+ end
63
+
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
+ class Side
68
+ attr_reader :options, :flags
69
+
70
+ def initialize(plan, options:, codec:, level:, flags:, choices:)
71
+ @plan = plan
72
+ @options = options
73
+ @codec_attr = codec
74
+ @level_attr = level
75
+ @flags = flags
76
+ @choices = choices
77
+ end
78
+
79
+ def codec = @plan.public_send(@codec_attr)
80
+ def level = @plan.public_send(@level_attr)
81
+ def level=(v)
82
+ @plan.public_send("#{@level_attr}=", v)
83
+ end
84
+
85
+ # [label, id, enabled, why] rows for the form's choice list.
86
+ def choices = @plan.public_send(@choices)
87
+
88
+ def codec=(id)
89
+ c = @options.find { |o| o.id == id } or raise ArgumentError, "unknown codec #{id}"
90
+ @plan.public_send("#{@codec_attr}=", c)
91
+ self.level = c.default
92
+ end
93
+ end
94
+
95
+ def tar
96
+ Side.new(self, options: TAR_CODECS, codec: :tar_codec, level: :tar_level,
97
+ flags: @tar_flags, choices: :tar_choices)
98
+ end
99
+
100
+ def compress
101
+ Side.new(self, options: COMPRESSORS, codec: :compressor, level: :comp_level,
102
+ flags: @zip_flags, choices: :compress_choices)
103
+ end
104
+
105
+ # "both" means compressed, so "none" is not on offer there.
106
+ def tar_choices
107
+ TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
108
+ end
109
+
110
+ # Single-file compressors only make sense for exactly one file; zip can
111
+ # take anything.
112
+ def compress_choices
113
+ COMPRESSORS.map do |c|
114
+ ok = c.available? && (c.container? || single_file?)
115
+ why = c.why_not || "compresses one file only; choose both for folders"
116
+ [c.label, c.id, ok, ok ? nil : why]
117
+ end
118
+ end
119
+
120
+ def single_file? = @paths.size == 1 && File.file?(@paths.first)
121
+
122
+ # True when the output is a bare compressed file (notes.txt.gz), where
123
+ # the original name should be kept whole.
124
+ def single_compress? = @target == :zip && !@compressor.container?
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
+ def base
130
+ @base ||= begin
131
+ dirs = @paths.map { |p| File.dirname(p) }
132
+ common = dirs.first.to_s.split("/")
133
+ dirs.each do |d|
134
+ parts = d.split("/")
135
+ i = 0
136
+ i += 1 while i < common.size && i < parts.size && common[i] == parts[i]
137
+ common = common[0...i]
138
+ end
139
+ c = common.join("/")
140
+ c.empty? ? "/" : c
141
+ end
142
+ end
143
+
144
+ def members
145
+ @paths.map do |p|
146
+ rel = p.delete_prefix(base == "/" ? "/" : "#{base}/")
147
+ rel.empty? ? File.basename(p) : rel
148
+ end
149
+ end
150
+
151
+ def ext
152
+ case @target
153
+ when :both then @tar_codec.ext
154
+ when :tar then ".tar"
155
+ else @compressor.container? ? ".zip" : @compressor.single_ext
156
+ end
157
+ end
158
+
159
+ def output = File.join(@outdir, ensure_ext(@basename, ext))
160
+ def outputs = [output]
161
+
162
+ # Extensions a user might type that we would otherwise double up.
163
+ ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
164
+ .reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
165
+
166
+ def ensure_ext(name, ext)
167
+ 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
+ return "#{name}#{ext}" if single_compress?
171
+
172
+ # Strip a competing archive extension the user may have typed.
173
+ typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
174
+ stripped = typed ? name[0...-typed.length] : name
175
+ stripped = name if stripped.empty?
176
+ "#{stripped}#{ext}"
177
+ end
178
+
179
+ def tar_argv
180
+ argv = ["tar", "-c"]
181
+ if @target == :both && (filter = @tar_codec.filter(@tar_level))
182
+ argv += ["--use-compress-program", filter]
183
+ end
184
+ @tar_flags.each { |f| argv.concat(f.args) if f.on }
185
+ argv += ["-f", output, "-C", base, "--"]
186
+ argv + members
187
+ end
188
+
189
+ def zip_argv
190
+ argv = ["zip", "-r"]
191
+ argv << (zip_verbose? ? "-v" : "-q")
192
+ argv << "-Z" << @compressor.flag if @compressor.flag != "deflate"
193
+ argv << "-#{@comp_level.clamp(0, 9)}" if @compressor.levels && @comp_level
194
+ argv << "-D" unless flag_on?(@zip_flags, :dirs)
195
+ @zip_flags.each do |f|
196
+ next if %i[verbose dirs].include?(f.id)
197
+
198
+ argv.concat(f.args) if f.on
199
+ end
200
+ argv << output
201
+ argv + members.map { |m| dashsafe(m) }
202
+ end
203
+
204
+ # gzip and friends read one file and write to stdout; the job redirects
205
+ # that into the output path.
206
+ def single_argv
207
+ @compressor.argv(@comp_level) + [dashsafe(members.first)]
208
+ end
209
+
210
+ def dashsafe(name) = name.start_with?("-") ? "./#{name}" : name
211
+
212
+ def zip_verbose? = flag_on?(@zip_flags, :verbose)
213
+ def tar_verbose? = flag_on?(@tar_flags, :verbose)
214
+
215
+ def flag_on?(list, id)
216
+ f = list.find { |x| x.id == id }
217
+ f ? f.on : false
218
+ end
219
+
220
+ # [label, argv, expects_verbose_output, stdout_path]
221
+ def steps
222
+ case @target
223
+ when :both, :tar then [["tar", tar_argv, tar_verbose?, nil]]
224
+ else
225
+ if @compressor.container?
226
+ [["zip", zip_argv, zip_verbose?, nil]]
227
+ else
228
+ [[@compressor.label, single_argv, false, output]]
229
+ end
230
+ end
231
+ end
232
+
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.
235
+ def self.show_arg(arg)
236
+ arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
237
+ end
238
+
239
+ def self.show_cmd(argv, stdout = nil)
240
+ cmd = argv.map { |a| show_arg(a) }.join(" ")
241
+ stdout ? "#{cmd} > #{show_arg(stdout)}" : cmd
242
+ end
243
+
244
+ def preview
245
+ steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
246
+ end
247
+
248
+ # Problems worth blocking on, checked right before the run.
249
+ def problems
250
+ errs = []
251
+ errs << "nothing selected" if @paths.empty?
252
+ errs << "destination is not a folder: #{@outdir}" unless File.directory?(@outdir)
253
+ errs << "destination is not writable: #{@outdir}" if File.directory?(@outdir) && !File.writable?(@outdir)
254
+ case @target
255
+ when :both
256
+ errs << "tar is not installed" unless Tools.available?("tar")
257
+ errs << @tar_codec.why_not if @tar_codec.why_not
258
+ errs << "no compressor installed; choose tarball" if @tar_codec.id == :none
259
+ when :tar
260
+ errs << "tar is not installed" unless Tools.available?("tar")
261
+ else
262
+ errs << @compressor.why_not if @compressor.why_not
263
+ if !@compressor.container? && !single_file?
264
+ errs << "#{@compressor.label} compresses one file only; choose both for folders"
265
+ end
266
+ end
267
+ errs.compact.uniq
268
+ end
269
+
270
+ # Non-blocking things the confirm screen should say out loud.
271
+ def warnings
272
+ warn = []
273
+ o = output
274
+ warn << "#{File.basename(o)} already exists and will be replaced" if File.exist?(o)
275
+ if @paths.any? { |p| Plan.inside?(o, p) }
276
+ warn << "output sits inside a selected folder, so it may archive itself"
277
+ end
278
+ warn
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+ require_relative "theme"
5
+
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
+ class Screen
10
+ attr_reader :w, :h
11
+
12
+ def initialize(w, h)
13
+ resize(w, h)
14
+ end
15
+
16
+ def resize(w, h)
17
+ @w = [w, 20].max
18
+ @h = [h, 6].max
19
+ @ch = Array.new(@w * @h, " ")
20
+ @st = Array.new(@w * @h, nil)
21
+ end
22
+
23
+ def clear(style = nil)
24
+ @ch.fill(" ")
25
+ @st.fill(style)
26
+ end
27
+
28
+ # Writes `text` at (x, y). Returns the column just past the text.
29
+ def put(x, y, text, style = nil)
30
+ return x if y.negative? || y >= @h
31
+
32
+ row = y * @w
33
+ cx = x
34
+ printable(text).each_grapheme_cluster do |g|
35
+ cw = Text.gw(g)
36
+ break if cx >= @w
37
+
38
+ if cx >= 0 && cw.positive?
39
+ clear_halves(row + cx)
40
+ clear_halves(row + cx + 1) if cw == 2 && cx + 1 < @w
41
+ @ch[row + cx] = g
42
+ @st[row + cx] = style
43
+ if cw == 2 && cx + 1 < @w
44
+ @ch[row + cx + 1] = ""
45
+ @st[row + cx + 1] = style
46
+ end
47
+ end
48
+ cx += cw
49
+ end
50
+ cx
51
+ end
52
+
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.
56
+ def clear_halves(idx)
57
+ col = idx % @w
58
+ @ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
59
+ @ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
60
+ end
61
+
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.
64
+ def printable(text)
65
+ str = text.to_s
66
+ str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
67
+ str.valid_encoding? ? str : str.scrub("·")
68
+ end
69
+
70
+ def fill(x, y, w, h, char = " ", style = nil)
71
+ h.times do |dy|
72
+ yy = y + dy
73
+ next if yy.negative? || yy >= @h
74
+
75
+ row = yy * @w
76
+ w.times do |dx|
77
+ xx = x + dx
78
+ next if xx.negative? || xx >= @w
79
+
80
+ clear_halves(row + xx)
81
+ @ch[row + xx] = char
82
+ @st[row + xx] = style
83
+ end
84
+ end
85
+ end
86
+
87
+ def hline(x, y, w, style = nil, char = "─")
88
+ fill(x, y, w, 1, char, style)
89
+ end
90
+
91
+ def vline(x, y, h, style = nil, char = "│")
92
+ fill(x, y, 1, h, char, style)
93
+ end
94
+
95
+ def box(x, y, w, h, style = nil, fill_style = nil)
96
+ return if w < 2 || h < 2
97
+
98
+ fill(x, y, w, h, " ", fill_style) if fill_style
99
+ put(x, y, "╭#{'─' * (w - 2)}╮", style)
100
+ put(x, y + h - 1, "╰#{'─' * (w - 2)}╯", style)
101
+ (1...(h - 1)).each do |dy|
102
+ put(x, y + dy, "│", style)
103
+ put(x + w - 1, y + dy, "│", style)
104
+ end
105
+ end
106
+
107
+ # Flatten everything to a faint monochrome so a modal reads as the
108
+ # foreground layer.
109
+ def veil(style = Theme::FAINT)
110
+ @st.fill(style)
111
+ end
112
+
113
+ def render
114
+ out = +"\e[H"
115
+ cur = :none
116
+ @h.times do |y|
117
+ row = y * @w
118
+ @w.times do |x|
119
+ st = @st[row + x]
120
+ if st != cur
121
+ out << Theme::RESET
122
+ out << st if st
123
+ cur = st
124
+ end
125
+ out << @ch[row + x]
126
+ end
127
+ out << Theme::RESET
128
+ cur = nil
129
+ out << "\r\n" unless y == @h - 1
130
+ end
131
+ out << Theme::RESET
132
+ out
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
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
+ class Sizer
7
+ Result = Struct.new(:bytes, :files, :partial)
8
+
9
+ BUDGET = 4.0 # seconds per path before we report a partial figure
10
+
11
+ def initialize
12
+ @cache = {}
13
+ @queue = Queue.new
14
+ @lock = Mutex.new
15
+ @pending = {}
16
+ @worker = Thread.new { loop { work(*@queue.pop) } }
17
+ @worker.abort_on_exception = false
18
+ end
19
+
20
+ # nil means "still counting".
21
+ def [](path)
22
+ @lock.synchronize { @cache[path] }
23
+ end
24
+
25
+ def request(path)
26
+ token = nil
27
+ @lock.synchronize do
28
+ return if @cache.key?(path) || @pending[path]
29
+
30
+ token = Object.new
31
+ @pending[path] = token
32
+ end
33
+ @queue << [path, token]
34
+ end
35
+
36
+ def total(paths)
37
+ known = paths.map { |p| self[p] }
38
+ bytes = known.compact.sum { |r| r.bytes }
39
+ files = known.compact.sum { |r| r.files }
40
+ Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
41
+ end
42
+
43
+ # Drops the figure, and any measurement in flight for it.
44
+ def forget(path)
45
+ @lock.synchronize do
46
+ @cache.delete(path)
47
+ @pending.delete(path)
48
+ end
49
+ end
50
+
51
+ def invalidate!
52
+ @lock.synchronize do
53
+ @cache.clear
54
+ @pending.clear
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def work(path, token)
61
+ res = measure(path)
62
+ @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.
65
+ next unless @pending[path].equal?(token)
66
+
67
+ @pending.delete(path)
68
+ @cache[path] = res
69
+ end
70
+ rescue StandardError
71
+ @lock.synchronize { @pending.delete(path) if @pending[path].equal?(token) }
72
+ end
73
+
74
+ def measure(path)
75
+ st = File.lstat(path)
76
+ return Result.new(st.size, 1, false) unless st.directory?
77
+
78
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUDGET
79
+ bytes = 0
80
+ files = 1 # the folder itself is an archive member
81
+ partial = false
82
+ stack = [path]
83
+ until stack.empty?
84
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
85
+ partial = true
86
+ break
87
+ end
88
+ dir = stack.pop
89
+ begin
90
+ Dir.children(dir).each do |name|
91
+ child = File.join(dir, name)
92
+ s = begin
93
+ File.lstat(child)
94
+ rescue StandardError
95
+ next
96
+ end
97
+ if s.directory?
98
+ stack << child
99
+ files += 1 # tar and zip both record directory entries
100
+ else
101
+ bytes += s.size
102
+ files += 1
103
+ end
104
+ end
105
+ rescue StandardError
106
+ next
107
+ end
108
+ end
109
+ Result.new(bytes, files, partial)
110
+ rescue StandardError
111
+ Result.new(0, 0, true)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Rakpak
6
+ # Raw-mode terminal control and key decoding.
7
+ module Term
8
+ ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
9
+ "H" => :home, "F" => :end }.freeze
10
+
11
+ TILDE = { "1" => :home, "3" => :delete, "4" => :end,
12
+ "5" => :pgup, "6" => :pgdn, "7" => :home, "8" => :end }.freeze
13
+
14
+ class << self
15
+ attr_accessor :resized
16
+ end
17
+ self.resized = false
18
+
19
+ module_function
20
+
21
+ def size
22
+ h, w = $stdout.winsize
23
+ [w.to_i.positive? ? w : 80, h.to_i.positive? ? h : 24]
24
+ rescue StandardError
25
+ [80, 24]
26
+ end
27
+
28
+ def start
29
+ @stty = `stty -g 2>/dev/null`.chomp
30
+ $stdin.raw!
31
+ $stdout.write("\e[?1049h\e[?25l\e[2J")
32
+ $stdout.flush
33
+ trap("WINCH") { Term.resized = true }
34
+ end
35
+
36
+ def stop
37
+ $stdout.write("\e[?25h\e[?1049l")
38
+ $stdout.flush
39
+ if @stty && !@stty.empty?
40
+ system("stty", @stty, out: File::NULL, err: File::NULL)
41
+ else
42
+ begin
43
+ $stdin.cooked!
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+
50
+ def flush_frame(str)
51
+ $stdout.write(str)
52
+ $stdout.flush
53
+ end
54
+
55
+ # Blocks up to `timeout` seconds. Returns a key symbol, a printable
56
+ # String, or nil on timeout.
57
+ def wait_key(timeout = nil)
58
+ return nil unless IO.select([$stdin], nil, nil, timeout)
59
+
60
+ read_key
61
+ end
62
+
63
+ def read_key
64
+ c = getc_raw
65
+ return nil if c.nil?
66
+
67
+ c = complete_utf8(c)
68
+ return nil if c.nil?
69
+
70
+ case c
71
+ when "\e" then read_escape
72
+ when "\r", "\n" then :enter
73
+ when "\t" then :tab
74
+ when "\x7f", "\b" then :backspace
75
+ when " " then :space
76
+ when "\x00".."\x1f" then :"ctrl_#{(c.ord + 96).chr}"
77
+ else c
78
+ end
79
+ end
80
+
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.
85
+ def complete_utf8(c)
86
+ s = c.dup.force_encoding(Encoding::UTF_8)
87
+ return s if s.valid_encoding?
88
+
89
+ lead = s.getbyte(0)
90
+ need = if lead.between?(0xC2, 0xDF) then 1
91
+ elsif lead.between?(0xE0, 0xEF) then 2
92
+ elsif lead.between?(0xF0, 0xF4) then 3
93
+ else 0
94
+ end
95
+ need.times do
96
+ more = getc_raw(ESC_WINDOW)
97
+ break if more.nil?
98
+
99
+ s = (s.b + more.b).force_encoding(Encoding::UTF_8)
100
+ end
101
+ s.valid_encoding? ? s : nil
102
+ end
103
+
104
+ def getc_raw(timeout = nil)
105
+ return nil if timeout && !IO.select([$stdin], nil, nil, timeout)
106
+
107
+ $stdin.getc
108
+ rescue IOError, Errno::EINTR
109
+ nil
110
+ end
111
+
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.
115
+ ESC_WINDOW = 0.05
116
+
117
+ def read_escape
118
+ seq = +""
119
+ 12.times do
120
+ ch = getc_raw(ESC_WINDOW)
121
+ break if ch.nil?
122
+
123
+ seq << ch
124
+ break if seq.match?(/\A\[[0-9;]*[A-Za-z~]\z/) || seq.match?(/\AO[A-Za-z]\z/)
125
+ end
126
+ return :esc if seq.empty?
127
+
128
+ body = seq[1..] || ""
129
+ if seq.start_with?("O")
130
+ ARROWS[body] || :esc
131
+ elsif seq.start_with?("[")
132
+ if (m = body.match(/\A([0-9;]*)([A-Za-z~])\z/))
133
+ num, fin = m[1], m[2]
134
+ return ARROWS[fin] if ARROWS.key?(fin)
135
+ return TILDE[num.split(";").first.to_s] || :esc if fin == "~"
136
+
137
+ :esc
138
+ else
139
+ :esc
140
+ end
141
+ else
142
+ # Alt-<char>: the whole sequence is the character.
143
+ :"alt_#{seq}"
144
+ end
145
+ end
146
+ end
147
+ end