fatty 0.99.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/.envrc +2 -0
- data/.simplecov +23 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +34 -0
- data/CHANGELOG.org +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +31 -0
- data/README.org +166 -0
- data/Rakefile +15 -0
- data/TODO.org +163 -0
- data/examples/markdown/native-markdown.md +370 -0
- data/examples/markdown/ox-gfm-markdown.md +373 -0
- data/examples/markdown/ox-gfm-markdown.org +376 -0
- data/exe/fatty +275 -0
- data/fatty.gemspec +42 -0
- data/lib/fatty/accept_env.rb +32 -0
- data/lib/fatty/action.rb +103 -0
- data/lib/fatty/action_environment.rb +42 -0
- data/lib/fatty/actionable.rb +73 -0
- data/lib/fatty/alert.rb +93 -0
- data/lib/fatty/ansi/renderer.rb +168 -0
- data/lib/fatty/ansi.rb +352 -0
- data/lib/fatty/colors/color.rb +379 -0
- data/lib/fatty/colors/pairs.rb +73 -0
- data/lib/fatty/colors/palette.rb +73 -0
- data/lib/fatty/colors/rgb.txt +788 -0
- data/lib/fatty/colors.rb +5 -0
- data/lib/fatty/config.rb +86 -0
- data/lib/fatty/config_files/config.yml +50 -0
- data/lib/fatty/config_files/help.md +120 -0
- data/lib/fatty/config_files/help.org +124 -0
- data/lib/fatty/config_files/keybindings.yml +49 -0
- data/lib/fatty/config_files/keydefs.yml +23 -0
- data/lib/fatty/config_files/themes/mono.yml +76 -0
- data/lib/fatty/config_files/themes/nordic.yml +77 -0
- data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
- data/lib/fatty/config_files/themes/terminal.yml +90 -0
- data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
- data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
- data/lib/fatty/core_ext/string.rb +21 -0
- data/lib/fatty/core_ext.rb +3 -0
- data/lib/fatty/counter.rb +81 -0
- data/lib/fatty/curses/context.rb +279 -0
- data/lib/fatty/curses/curses_coder.rb +684 -0
- data/lib/fatty/curses/event_source.rb +230 -0
- data/lib/fatty/curses/key_decoder.rb +183 -0
- data/lib/fatty/curses/patch.rb +116 -0
- data/lib/fatty/curses/window_styling.rb +32 -0
- data/lib/fatty/curses.rb +16 -0
- data/lib/fatty/env.rb +100 -0
- data/lib/fatty/help.rb +41 -0
- data/lib/fatty/history/entry.rb +71 -0
- data/lib/fatty/history.rb +289 -0
- data/lib/fatty/input_buffer.rb +998 -0
- data/lib/fatty/input_field.rb +507 -0
- data/lib/fatty/key_event.rb +342 -0
- data/lib/fatty/key_map.rb +392 -0
- data/lib/fatty/keymaps/emacs.rb +189 -0
- data/lib/fatty/log_formats/json.rb +47 -0
- data/lib/fatty/log_formats/text.rb +67 -0
- data/lib/fatty/logger.rb +142 -0
- data/lib/fatty/markdown/ansi_renderer.rb +373 -0
- data/lib/fatty/markdown/render.rb +22 -0
- data/lib/fatty/markdown.rb +4 -0
- data/lib/fatty/menu_env.rb +22 -0
- data/lib/fatty/mouse_event.rb +32 -0
- data/lib/fatty/output_buffer.rb +78 -0
- data/lib/fatty/pager.rb +801 -0
- data/lib/fatty/prompt.rb +40 -0
- data/lib/fatty/renderer/curses.rb +697 -0
- data/lib/fatty/renderer/truecolor.rb +607 -0
- data/lib/fatty/renderer.rb +419 -0
- data/lib/fatty/screen.rb +96 -0
- data/lib/fatty/search.rb +43 -0
- data/lib/fatty/session/alert_session.rb +52 -0
- data/lib/fatty/session/input_session.rb +99 -0
- data/lib/fatty/session/isearch_session.rb +172 -0
- data/lib/fatty/session/keytest_session.rb +236 -0
- data/lib/fatty/session/modal_session.rb +61 -0
- data/lib/fatty/session/output_session.rb +105 -0
- data/lib/fatty/session/popup_session.rb +540 -0
- data/lib/fatty/session/prompt_session.rb +157 -0
- data/lib/fatty/session/search_session.rb +136 -0
- data/lib/fatty/session/shell_session.rb +566 -0
- data/lib/fatty/session.rb +173 -0
- data/lib/fatty/sessions.rb +14 -0
- data/lib/fatty/terminal/popup_owner.rb +26 -0
- data/lib/fatty/terminal/progress.rb +374 -0
- data/lib/fatty/terminal.rb +1067 -0
- data/lib/fatty/themes/loader.rb +136 -0
- data/lib/fatty/themes/manager.rb +71 -0
- data/lib/fatty/themes/registry.rb +64 -0
- data/lib/fatty/themes/resolver.rb +224 -0
- data/lib/fatty/themes/themes.rb +131 -0
- data/lib/fatty/themes.rb +6 -0
- data/lib/fatty/version.rb +5 -0
- data/lib/fatty/view/alert_view.rb +14 -0
- data/lib/fatty/view/cursor_view.rb +18 -0
- data/lib/fatty/view/input_view.rb +9 -0
- data/lib/fatty/view/output_view.rb +9 -0
- data/lib/fatty/view/status_view.rb +14 -0
- data/lib/fatty/view.rb +33 -0
- data/lib/fatty/viewport.rb +90 -0
- data/lib/fatty/views.rb +9 -0
- data/lib/fatty.rb +55 -0
- data/sig/fatty.rbs +4 -0
- metadata +250 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fatty
|
|
4
|
+
class PopUpSession < ModalSession
|
|
5
|
+
action_on :session
|
|
6
|
+
|
|
7
|
+
attr_reader :field, :filtered, :displayed, :selected, :title, :message
|
|
8
|
+
|
|
9
|
+
MAX_WIDTH = 120
|
|
10
|
+
DEFAULT_HEIGHT = 12
|
|
11
|
+
MIN_LIST_H = 3
|
|
12
|
+
MAX_LIST_H = 20
|
|
13
|
+
MARGIN = 2
|
|
14
|
+
|
|
15
|
+
SELECTED_GUTTER = '[X] '
|
|
16
|
+
UNSELECTED_GUTTER = '[ ] '
|
|
17
|
+
|
|
18
|
+
# API:
|
|
19
|
+
# - source: Proc that returns the candidate list. May accept (query) or be arity 0.
|
|
20
|
+
# - matcher: Proc (item, query) -> truthy. Defaults to substring match.
|
|
21
|
+
# - order: :as_given (default) or :reverse (presentation order).
|
|
22
|
+
# - selection: :preserve (default), :top, :bottom (how selection behaves after refresh).
|
|
23
|
+
def initialize(
|
|
24
|
+
source:,
|
|
25
|
+
title: nil,
|
|
26
|
+
message: nil,
|
|
27
|
+
prompt: "> ",
|
|
28
|
+
keymap: Keymaps.emacs,
|
|
29
|
+
matcher: nil,
|
|
30
|
+
order: :as_given,
|
|
31
|
+
kind: nil,
|
|
32
|
+
selection: :preserve,
|
|
33
|
+
initial_query: nil,
|
|
34
|
+
selection_mode: :single,
|
|
35
|
+
validate_unique_labels: false
|
|
36
|
+
)
|
|
37
|
+
super(keymap: keymap)
|
|
38
|
+
@source = source
|
|
39
|
+
@title = title&.to_s
|
|
40
|
+
@message = message&.to_s
|
|
41
|
+
@prompt = Prompt.ensure(prompt)
|
|
42
|
+
@matcher = matcher || method(:default_matcher)
|
|
43
|
+
@order = order.to_sym
|
|
44
|
+
@kind = kind&.to_sym
|
|
45
|
+
@selection = selection.to_sym
|
|
46
|
+
@selection_mode = selection_mode.to_sym
|
|
47
|
+
@validate_unique_labels = !!validate_unique_labels
|
|
48
|
+
|
|
49
|
+
@field = InputField.new(prompt: @prompt)
|
|
50
|
+
text = initial_query.to_s
|
|
51
|
+
@field.buffer.replace(text) unless text.empty?
|
|
52
|
+
|
|
53
|
+
@items = []
|
|
54
|
+
@filtered = []
|
|
55
|
+
@displayed = []
|
|
56
|
+
@selected = 0
|
|
57
|
+
@selected_labels = {}
|
|
58
|
+
|
|
59
|
+
@last_query = nil
|
|
60
|
+
@scroll_start = 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#########################################################################################
|
|
64
|
+
# Framework and Session Hooks
|
|
65
|
+
#########################################################################################
|
|
66
|
+
|
|
67
|
+
def init(terminal:)
|
|
68
|
+
refresh_items
|
|
69
|
+
rebuild_windows!
|
|
70
|
+
notify_owner(:popup_changed)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def keymap_contexts
|
|
74
|
+
contexts = [:popup, :text]
|
|
75
|
+
contexts.unshift(:popup_multi) if multi_select?
|
|
76
|
+
contexts
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def view(screen:, renderer:)
|
|
80
|
+
Fatty.debug("PopupSession#view: object_id=#{object_id} win_nil=#{@win.nil?}", tag: :session)
|
|
81
|
+
return unless @win
|
|
82
|
+
|
|
83
|
+
renderer.render_popup(session: self)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Return the outer width and height of the window for this modal,
|
|
87
|
+
# including any padding and borders.
|
|
88
|
+
def geometry(cols:, rows:)
|
|
89
|
+
max_w = max_width(cols: cols, margin: MARGIN, min_width: 10)
|
|
90
|
+
max_h = max_height(rows: rows, margin: MARGIN, min_height: 5)
|
|
91
|
+
|
|
92
|
+
desired_list_h = @filtered.length.clamp(MIN_LIST_H, MAX_LIST_H)
|
|
93
|
+
height = clamp_height(
|
|
94
|
+
desired_list_h + popup_extra_rows,
|
|
95
|
+
max_height: max_h,
|
|
96
|
+
min_height: 6,
|
|
97
|
+
)
|
|
98
|
+
width = clamp_width(
|
|
99
|
+
MAX_WIDTH,
|
|
100
|
+
max_width: max_w,
|
|
101
|
+
)
|
|
102
|
+
[width, height]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
############################################################################################
|
|
106
|
+
# Actions
|
|
107
|
+
############################################################################################
|
|
108
|
+
|
|
109
|
+
action :popup_cancel do
|
|
110
|
+
notify_owner(:popup_cancelled) + [[:terminal, :pop_modal]]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
action :popup_accept do
|
|
114
|
+
accept_selection
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
action :popup_next do
|
|
118
|
+
move_selected_by(1)
|
|
119
|
+
ensure_scroll_visible
|
|
120
|
+
notify_owner(:popup_changed)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
action :popup_prev do
|
|
124
|
+
move_selected_by(-1)
|
|
125
|
+
ensure_scroll_visible
|
|
126
|
+
notify_owner(:popup_changed)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
action :popup_page_down do
|
|
130
|
+
move_selected_by(popup_list_height)
|
|
131
|
+
ensure_scroll_visible
|
|
132
|
+
notify_owner(:popup_changed)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
action :popup_page_up do
|
|
136
|
+
move_selected_by(-popup_list_height)
|
|
137
|
+
ensure_scroll_visible
|
|
138
|
+
notify_owner(:popup_changed)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
action :popup_top do
|
|
142
|
+
unless @filtered.empty?
|
|
143
|
+
@selected = 0
|
|
144
|
+
recenter_scroll
|
|
145
|
+
end
|
|
146
|
+
notify_owner(:popup_changed)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
action :popup_bottom do
|
|
150
|
+
unless @filtered.empty?
|
|
151
|
+
@selected = [@filtered.length - 1, 0].max
|
|
152
|
+
recenter_scroll
|
|
153
|
+
end
|
|
154
|
+
notify_owner(:popup_changed)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
action :popup_recenter do
|
|
158
|
+
recenter_scroll
|
|
159
|
+
[]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
action :popup_toggle_selected do
|
|
163
|
+
if multi_select?
|
|
164
|
+
toggle_selected_current!
|
|
165
|
+
ensure_scroll_visible
|
|
166
|
+
notify_owner(:popup_changed)
|
|
167
|
+
else
|
|
168
|
+
[]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handle_action(action, args, event:)
|
|
173
|
+
env = action_env(event: event)
|
|
174
|
+
|
|
175
|
+
if Fatty::Actions.lookup(action)&.fetch(:on) == :session
|
|
176
|
+
Fatty::Actions.call(action, env, *args)
|
|
177
|
+
else
|
|
178
|
+
@field.act_on(action, *args, env: env)
|
|
179
|
+
refresh_items_if_query_changed
|
|
180
|
+
ensure_scroll_visible
|
|
181
|
+
notify_owner(:popup_changed)
|
|
182
|
+
end
|
|
183
|
+
rescue ActionError => e
|
|
184
|
+
Fatty.error("PopUpSession#handle_action: ActionError #{e.message}", tag: :session)
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def move_selected_by(delta)
|
|
189
|
+
return if @displayed.empty?
|
|
190
|
+
|
|
191
|
+
msg = "PopUpSession#move_selected_by before: selected=#{@selected.inspect} delta=#{delta} len=#{@displayed.length}"
|
|
192
|
+
Fatty.debug(msg)
|
|
193
|
+
@selected = ((@selected || 0) + delta) % @displayed.length
|
|
194
|
+
Fatty.debug("PopUpSession#move_selected_by after: selected=#{@selected.inspect}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def accept_selection
|
|
198
|
+
if multi_select?
|
|
199
|
+
payload = popup_payload(selected_result_hash)
|
|
200
|
+
else
|
|
201
|
+
item = selected_item
|
|
202
|
+
query = @field.buffer.text.to_s
|
|
203
|
+
|
|
204
|
+
return [] if item.nil? && query.empty?
|
|
205
|
+
|
|
206
|
+
item = query if item.nil?
|
|
207
|
+
payload = popup_payload(item)
|
|
208
|
+
end
|
|
209
|
+
[
|
|
210
|
+
[:terminal, :send_modal_owner, [:cmd, :popup_result, payload]],
|
|
211
|
+
[:terminal, :pop_modal]
|
|
212
|
+
]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def selected_item
|
|
216
|
+
@displayed[@selected]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def selected_item_label?(item)
|
|
220
|
+
selected_label?(item_label(item))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def gutter_for(item:, selected:)
|
|
224
|
+
if multi_select?
|
|
225
|
+
selected_item_label?(item) ? SELECTED_GUTTER : UNSELECTED_GUTTER
|
|
226
|
+
else
|
|
227
|
+
' '
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def refresh_items_if_query_changed
|
|
232
|
+
q = @field.buffer.text.to_s
|
|
233
|
+
return if q == @last_query
|
|
234
|
+
|
|
235
|
+
@last_query = q.dup.freeze
|
|
236
|
+
Fatty.debug("popup query changed", tag: :popup, q: q, last: @last_query)
|
|
237
|
+
refresh_items
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def refresh_displayed_items
|
|
241
|
+
if multi_select?
|
|
242
|
+
selected_missing =
|
|
243
|
+
@items.select do |item|
|
|
244
|
+
label = item_label(item)
|
|
245
|
+
selected_label?(label) && !@filtered.include?(item)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
@displayed = selected_missing + @filtered
|
|
249
|
+
else
|
|
250
|
+
@displayed = @filtered.dup
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def refresh_items
|
|
255
|
+
q = @field.buffer.text.to_s
|
|
256
|
+
@items = Array(call_source(q))
|
|
257
|
+
apply_order!
|
|
258
|
+
validate_unique_labels!(@items) if @validate_unique_labels
|
|
259
|
+
|
|
260
|
+
matcher = @matcher || method(:default_matcher)
|
|
261
|
+
|
|
262
|
+
@filtered =
|
|
263
|
+
if q.empty?
|
|
264
|
+
@items
|
|
265
|
+
else
|
|
266
|
+
@items.select { |e| matcher.call(e, q) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
refresh_displayed_items
|
|
270
|
+
apply_selection_policy!
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Renderer calls this to determine which slice of items to display.
|
|
274
|
+
def scroll_start(list_h:)
|
|
275
|
+
max_start = @displayed.length - list_h
|
|
276
|
+
max_start = 0 if max_start < 0
|
|
277
|
+
|
|
278
|
+
@scroll_start = 0 if @scroll_start < 0
|
|
279
|
+
@scroll_start = max_start if @scroll_start > max_start
|
|
280
|
+
@scroll_start
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def toggle_selected_current!
|
|
284
|
+
item = selected_item
|
|
285
|
+
return unless item
|
|
286
|
+
|
|
287
|
+
toggle_selected_item!(item)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def toggle_selected_item!(item)
|
|
291
|
+
label = item_label(item)
|
|
292
|
+
|
|
293
|
+
if selected_label?(label)
|
|
294
|
+
@selected_labels.delete(label)
|
|
295
|
+
else
|
|
296
|
+
@selected_labels[label] = true
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
refresh_displayed_items
|
|
300
|
+
item
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Count methods for display to user.
|
|
304
|
+
|
|
305
|
+
def total_count
|
|
306
|
+
@items.length
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def matching_count
|
|
310
|
+
@filtered.length
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def selected_count
|
|
314
|
+
if multi_select?
|
|
315
|
+
@selected_labels.length
|
|
316
|
+
else
|
|
317
|
+
selected_item ? 1 : 0
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def showing_count
|
|
322
|
+
[@displayed.length - scroll_start(list_h: popup_list_height), popup_list_height].min
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def counts
|
|
326
|
+
{
|
|
327
|
+
total: total_count,
|
|
328
|
+
selected: selected_count,
|
|
329
|
+
matching: matching_count,
|
|
330
|
+
showing: showing_count
|
|
331
|
+
}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def selected_labels
|
|
335
|
+
@selected_labels.keys
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
def validate_unique_labels!(items)
|
|
341
|
+
counts = Hash.new(0)
|
|
342
|
+
items.each do |item|
|
|
343
|
+
counts[item_label(item)] += 1
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
dupes = counts.select { |_label, count| count > 1 }.keys
|
|
347
|
+
return if dupes.empty?
|
|
348
|
+
|
|
349
|
+
shown = dupes.first(5).join(", ")
|
|
350
|
+
raise ArgumentError, "duplicate chooser labels: #{shown}"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def popup_payload(item = selected_item)
|
|
354
|
+
{
|
|
355
|
+
kind: @kind,
|
|
356
|
+
item: item,
|
|
357
|
+
query: @field.buffer.text.to_s,
|
|
358
|
+
index: @selected
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def popup_payload(item = selected_item)
|
|
363
|
+
if multi_select?
|
|
364
|
+
{
|
|
365
|
+
kind: @kind,
|
|
366
|
+
items: item,
|
|
367
|
+
query: @field.buffer.text.to_s,
|
|
368
|
+
index: @selected
|
|
369
|
+
}
|
|
370
|
+
else
|
|
371
|
+
{
|
|
372
|
+
kind: @kind,
|
|
373
|
+
item: item,
|
|
374
|
+
query: @field.buffer.text.to_s,
|
|
375
|
+
index: @selected
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def popup_has_message?
|
|
381
|
+
@message && !@message.empty?
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def popup_extra_rows
|
|
385
|
+
rows = 4
|
|
386
|
+
rows += 1 if popup_has_message?
|
|
387
|
+
rows
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def notify_owner(name)
|
|
391
|
+
return [] unless @kind
|
|
392
|
+
|
|
393
|
+
[[:terminal, :send_modal_owner, [:cmd, name, popup_payload]]]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def popup_list_height
|
|
397
|
+
# Visible list lines = window height minus:
|
|
398
|
+
# - 2 border rows
|
|
399
|
+
# - 1 input row
|
|
400
|
+
# - 1 message row when present
|
|
401
|
+
#
|
|
402
|
+
# If window isn't built yet, fall back to the historical default.
|
|
403
|
+
h =
|
|
404
|
+
begin
|
|
405
|
+
@win ? @win.maxy : DEFAULT_HEIGHT
|
|
406
|
+
rescue RuntimeError
|
|
407
|
+
DEFAULT_HEIGHT
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
list_h = h - popup_extra_rows
|
|
411
|
+
list_h = 1 if list_h < 1
|
|
412
|
+
list_h
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def ensure_scroll_visible
|
|
416
|
+
list_h = popup_list_height
|
|
417
|
+
|
|
418
|
+
# dead-zone is top/bottom 10% of visible list, at least 1 line
|
|
419
|
+
band = (list_h * 0.10).floor
|
|
420
|
+
band = 1 if band < 1
|
|
421
|
+
|
|
422
|
+
top_zone = @scroll_start + band
|
|
423
|
+
bot_zone = (@scroll_start + list_h - 1) - band
|
|
424
|
+
|
|
425
|
+
if @selected < top_zone
|
|
426
|
+
@scroll_start = @selected - band
|
|
427
|
+
elsif @selected > bot_zone
|
|
428
|
+
@scroll_start = @selected - (list_h - 1) + band
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
max_start = @displayed.length - list_h
|
|
432
|
+
max_start = 0 if max_start < 0
|
|
433
|
+
|
|
434
|
+
@scroll_start = 0 if @scroll_start < 0
|
|
435
|
+
@scroll_start = max_start if @scroll_start > max_start
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def recenter_scroll
|
|
439
|
+
list_h = popup_list_height
|
|
440
|
+
@scroll_start = @selected - (list_h / 2)
|
|
441
|
+
|
|
442
|
+
max_start = @displayed.length - list_h
|
|
443
|
+
max_start = 0 if max_start < 0
|
|
444
|
+
|
|
445
|
+
@scroll_start = 0 if @scroll_start < 0
|
|
446
|
+
@scroll_start = max_start if @scroll_start > max_start
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def call_source(q)
|
|
450
|
+
items =
|
|
451
|
+
if @source.respond_to?(:call)
|
|
452
|
+
if @source.arity == 0
|
|
453
|
+
@source.call
|
|
454
|
+
else
|
|
455
|
+
@source.call(q)
|
|
456
|
+
end
|
|
457
|
+
else
|
|
458
|
+
@source
|
|
459
|
+
end
|
|
460
|
+
Array(items)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def apply_order!
|
|
464
|
+
case @order
|
|
465
|
+
when :as_given
|
|
466
|
+
# no-op
|
|
467
|
+
when :reverse
|
|
468
|
+
@items = @items.reverse
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def apply_selection_policy!
|
|
473
|
+
prior = @selected
|
|
474
|
+
last_idx = [@displayed.length - 1, 0].max
|
|
475
|
+
|
|
476
|
+
@selected = if @displayed.empty?
|
|
477
|
+
0
|
|
478
|
+
else
|
|
479
|
+
case @selection
|
|
480
|
+
when :top
|
|
481
|
+
0
|
|
482
|
+
when :bottom
|
|
483
|
+
last_idx
|
|
484
|
+
else
|
|
485
|
+
@selected.clamp(0, last_idx)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
if @selection == :top || @selection == :bottom
|
|
490
|
+
recenter_scroll
|
|
491
|
+
elsif @selected != prior
|
|
492
|
+
ensure_scroll_visible
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def default_matcher(item, query)
|
|
497
|
+
match_all_query_terms?(item, query)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def action_env(event:)
|
|
501
|
+
ActionEnvironment.new(
|
|
502
|
+
session: self,
|
|
503
|
+
counter: counter,
|
|
504
|
+
event: event,
|
|
505
|
+
field: @field,
|
|
506
|
+
buffer: @field.buffer,
|
|
507
|
+
)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def multi_select?
|
|
511
|
+
@selection_mode == :multiple
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def item_label(item)
|
|
515
|
+
item.to_s
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def item_value(item)
|
|
519
|
+
item
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def selected_label?(label)
|
|
523
|
+
@selected_labels.key?(label)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def selected_item?
|
|
527
|
+
!selected_item.nil?
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def selected_items_in_source_order
|
|
531
|
+
@items.select { |item| selected_label?(item_label(item)) }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def selected_result_hash
|
|
535
|
+
selected_items_in_source_order.each_with_object({}) do |item, h|
|
|
536
|
+
h[item_label(item)] = item_value(item)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fatty
|
|
4
|
+
class PromptSession < ModalSession
|
|
5
|
+
attr_reader :field, :title, :message, :history
|
|
6
|
+
|
|
7
|
+
PROMPT_POPUP_MAX_WIDTH = 120
|
|
8
|
+
PROMPT_POPUP_MIN_WIDTH = 20
|
|
9
|
+
PROMPT_POPUP_MARGIN = 2
|
|
10
|
+
|
|
11
|
+
def id = :prompt
|
|
12
|
+
|
|
13
|
+
def initialize(initial: "", prompt: "> ", title: "Prompt", message: nil, kind: nil,
|
|
14
|
+
history_ctx: nil, history_path: :default)
|
|
15
|
+
super(keymap: Keymaps.emacs, views: [])
|
|
16
|
+
@title = title&.to_s
|
|
17
|
+
@message = message&.to_s
|
|
18
|
+
@kind = kind&.to_sym
|
|
19
|
+
|
|
20
|
+
@history = Fatty::History.for_path(history_path)
|
|
21
|
+
@field = Fatty::InputField.new(
|
|
22
|
+
prompt: prompt,
|
|
23
|
+
history: @history,
|
|
24
|
+
history_kind: :prompt,
|
|
25
|
+
history_ctx: history_ctx,
|
|
26
|
+
)
|
|
27
|
+
@field.buffer.replace(initial.to_s)
|
|
28
|
+
|
|
29
|
+
@win = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#########################################################################################
|
|
33
|
+
# Framework and Session Hooks
|
|
34
|
+
#########################################################################################
|
|
35
|
+
|
|
36
|
+
def init(terminal:)
|
|
37
|
+
super
|
|
38
|
+
rebuild_windows!
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def keymap_contexts
|
|
43
|
+
[:prompt, :text, :terminal]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def view(screen:, renderer:)
|
|
47
|
+
return unless @win
|
|
48
|
+
|
|
49
|
+
renderer.render_prompt_popup(session: self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
############################################################################################
|
|
53
|
+
# Actions
|
|
54
|
+
############################################################################################
|
|
55
|
+
|
|
56
|
+
action_on :session
|
|
57
|
+
|
|
58
|
+
desc "Accept prompt input"
|
|
59
|
+
action :prompt_accept do
|
|
60
|
+
text = @field.accept_line.to_s
|
|
61
|
+
[
|
|
62
|
+
[:terminal, :send_modal_owner, [:cmd, :prompt_result, { kind: @kind, text: text }]],
|
|
63
|
+
[:terminal, :pop_modal],
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
desc "Cancel prompt input"
|
|
68
|
+
action :prompt_cancel do
|
|
69
|
+
[
|
|
70
|
+
[:terminal, :send_modal_owner, [:cmd, :prompt_cancelled, prompt_payload]],
|
|
71
|
+
[:terminal, :pop_modal],
|
|
72
|
+
]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc "Cancel prompt if empty, otherwise delete forward"
|
|
76
|
+
action :prompt_cancel_if_empty do
|
|
77
|
+
if @field.buffer.text.to_s.empty?
|
|
78
|
+
prompt_cancel
|
|
79
|
+
else
|
|
80
|
+
with_virtual_suffix_sync { @field.act_on(:delete_char_forward, env: action_env(event: nil)) }
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_action(action, args, event:)
|
|
86
|
+
env = action_env(event: event)
|
|
87
|
+
|
|
88
|
+
result =
|
|
89
|
+
with_virtual_suffix_sync do
|
|
90
|
+
@field.act_on(action, *args, env: env)
|
|
91
|
+
end
|
|
92
|
+
result.is_a?(Array) ? result : []
|
|
93
|
+
rescue ActionError => e
|
|
94
|
+
Fatty.error("PromptSession#handle_action: ActionError #{e.message}", tag: :session)
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Return the outer width and height of the window for this modal,
|
|
99
|
+
# including any padding and borders.
|
|
100
|
+
def geometry(cols:, rows:)
|
|
101
|
+
max_w = max_width(
|
|
102
|
+
cols: cols,
|
|
103
|
+
margin: PROMPT_POPUP_MARGIN,
|
|
104
|
+
min_width: PROMPT_POPUP_MIN_WIDTH,
|
|
105
|
+
)
|
|
106
|
+
max_h = max_height(rows: rows, margin: PROMPT_POPUP_MARGIN, min_height: 5)
|
|
107
|
+
|
|
108
|
+
preferred_w = [(cols * 2 / 3).floor, 50].max
|
|
109
|
+
|
|
110
|
+
message_width = @message.to_s.length
|
|
111
|
+
prompt_width = @field.prompt_text.to_s.length + @field.buffer.text.to_s.length
|
|
112
|
+
content_width = [message_width, prompt_width].max + 4
|
|
113
|
+
|
|
114
|
+
width = clamp_width(
|
|
115
|
+
[preferred_w, content_width].max,
|
|
116
|
+
max_width: max_w,
|
|
117
|
+
hard_max: PROMPT_POPUP_MAX_WIDTH,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
extra_rows = 2
|
|
121
|
+
extra_rows += 1 if @message && !@message.empty?
|
|
122
|
+
height = clamp_height(
|
|
123
|
+
extra_rows,
|
|
124
|
+
max_height: max_h,
|
|
125
|
+
min_height: 4,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
[width, height]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def with_virtual_suffix_sync
|
|
132
|
+
@field.sync_virtual_suffix!
|
|
133
|
+
result = yield
|
|
134
|
+
@field.sync_virtual_suffix!
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def prompt_payload
|
|
141
|
+
{
|
|
142
|
+
kind: @kind,
|
|
143
|
+
text: @field.buffer.text.to_s,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def action_env(event:)
|
|
148
|
+
ActionEnvironment.new(
|
|
149
|
+
session: self,
|
|
150
|
+
counter: counter,
|
|
151
|
+
event: event,
|
|
152
|
+
field: @field,
|
|
153
|
+
buffer: @field.buffer,
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|