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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/rakpak +10 -0
- data/lib/rakpak/app.rb +662 -0
- data/lib/rakpak/browser.rb +508 -0
- data/lib/rakpak/dir_cache.rb +38 -0
- data/lib/rakpak/entry.rb +78 -0
- data/lib/rakpak/formats.rb +190 -0
- data/lib/rakpak/job.rb +264 -0
- data/lib/rakpak/modal.rb +562 -0
- data/lib/rakpak/plan.rb +281 -0
- data/lib/rakpak/screen.rb +135 -0
- data/lib/rakpak/sizer.rb +114 -0
- data/lib/rakpak/term.rb +147 -0
- data/lib/rakpak/text.rb +142 -0
- data/lib/rakpak/theme.rb +32 -0
- data/lib/rakpak/version.rb +5 -0
- data/lib/rakpak.rb +100 -0
- metadata +66 -0
data/lib/rakpak/modal.rb
ADDED
|
@@ -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
|