Class: Fatty::InputField

Inherits:
Object
  • Object
show all
Includes:
Actionable
Defined in:
lib/fatty/input_field.rb

Overview

The =InputField= class is a thin controller around an InputBuffer that adds:

  • A prompt (text shown before the editable buffer)
  • Optional command history integration (previous/next)
  • A small set of "editor actions" intended to be bound to keys

=InputField= intentionally does not perform terminal I/O or rendering. Higher-level UI components (e.g., Terminal/Screen/widgets) are responsible for:

  • Decoding keys and dispatching actions
  • Translating buffer/cursor state into screen coordinates
  • Drawing the prompt + buffer text and placing the cursor

Word-motion and word-deletion semantics are delegated to InputBuffer so the definition of "word" can be configured in one place (via =InputBuffer='s word_chars/word_re).

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Actionable

included

Constructor Details

#initialize(prompt:, buffer: nil, completion_proc: nil, history: nil, history_kind: :command, history_ctx: nil) ⇒ InputField

Returns a new instance of InputField.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/fatty/input_field.rb', line 29

def initialize(
  prompt:,
  buffer: nil,
  completion_proc: nil,
  history: nil,
  history_kind: :command,
  history_ctx: nil
)
  @prompt = Prompt.ensure(prompt)
  @history = history
  @history_kind = history_kind
  @history_ctx = history_ctx
  @completion_proc = completion_proc
  @completion_cycle_base = nil
  @completion_cycle_candidates = []
  @completion_cycle_index = nil

  @buffer =
    if buffer
      buffer
    else
      cfg = Fatty::Config.config
      word_chars = cfg.dig(:input_buffer, :word_chars) || Fatty::InputBuffer::DEFAULT_WORD_CHARS
      Fatty::InputBuffer.new(word_chars: word_chars)
    end
end

Instance Attribute Details

#bufferObject (readonly)

Returns the value of attribute buffer.



27
28
29
# File 'lib/fatty/input_field.rb', line 27

def buffer
  @buffer
end

#historyObject (readonly)

Returns the value of attribute history.



27
28
29
# File 'lib/fatty/input_field.rb', line 27

def history
  @history
end

#promptObject

Returns the value of attribute prompt.



27
28
29
# File 'lib/fatty/input_field.rb', line 27

def prompt
  @prompt
end

Instance Method Details

#accept_autosuggestion!Object



433
434
435
436
437
438
# File 'lib/fatty/input_field.rb', line 433

def accept_autosuggestion!
  return unless autosuggestion_visible?

  sync_virtual_suffix!
  buffer.accept_virtual_suffix!
end

#act_on(action, *args, env: nil, **kwargs) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/fatty/input_field.rb', line 139

def act_on(action, *args, env: nil, **kwargs)
  return unless action

  reset_history_cursor_for(action)

  if Fatty::Actions.registered?(action)
    if env
      Fatty::Actions.call(action, env, *args, **kwargs)
    else
      defn = Fatty::Actions.lookup(action)
      target =
        case defn[:on]
        when :field then self
        when :buffer then buffer
        else
          raise Fatty::ActionError, "Cannot dispatch #{action} without env for target #{defn[:on].inspect}"
        end
      target.public_send(defn[:method], *args, **kwargs)
    end
  elsif buffer.respond_to?(action)
    buffer.public_send(action, *args, **kwargs)
  elsif respond_to?(action)
    public_send(action, *args, **kwargs)
  else
    raise Fatty::ActionError, "Unknown action: #{action}"
  end
end

#active_completion_autosuggestionObject



210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/fatty/input_field.rb', line 210

def active_completion_autosuggestion
  text = buffer.text.to_s
  result = nil

  if @completion_cycle_base == text &&
     @completion_cycle_index &&
     !@completion_cycle_candidates.empty?
    result = @completion_cycle_candidates[@completion_cycle_index]
  end

  result
end

#autosuggestionObject

:category: Completion



169
170
171
172
173
# File 'lib/fatty/input_field.rb', line 169

def autosuggestion
  return if buffer.text.empty?

  active_completion_autosuggestion || default_completion_autosuggestion || history_autosuggestion
end

#autosuggestion_suffixObject



427
428
429
430
431
# File 'lib/fatty/input_field.rb', line 427

def autosuggestion_suffix
  return "" unless autosuggestion_visible?

  autosuggestion.to_s.delete_prefix(buffer.text.to_s)
end

#autosuggestion_visible?Boolean

Returns:

  • (Boolean)


418
419
420
421
422
423
424
425
# File 'lib/fatty/input_field.rb', line 418

def autosuggestion_visible?
  suggestion = autosuggestion.to_s
  text = buffer.text.to_s
  return false if suggestion.empty?
  return false unless suggestion.start_with?(text)

  suggestion != text
end

#build_line_with_completion(completion) ⇒ Object



406
407
408
409
410
411
412
413
414
415
416
# File 'lib/fatty/input_field.rb', line 406

def build_line_with_completion(completion)
  current = buffer.text.to_s
  target = path_completion_range || buffer.completion_replace_range
  start_i = target.begin
  end_i   = target.end

  before = current[0...start_i].to_s
  after  = current[end_i..].to_s

  "#{before}#{completion}#{after}"
end

#completion_candidatesObject



175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/fatty/input_field.rb', line 175

def completion_candidates
  return [] unless @completion_proc

  prefix = buffer.completion_prefix.to_s
  return [] if prefix.empty?

  Array(@completion_proc.call(buffer))
    .compact
    .map(&:to_s)
    .reject(&:empty?)
    .select { |s| s.start_with?(prefix) }
    .reject { |s| s == prefix }
    .uniq
end

#completion_suggestionsObject



190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/fatty/input_field.rb', line 190

def completion_suggestions
  raw =
    if path_completion_candidates.any?
      path_completion_candidates
    else
      completion_candidates
    end

  raw
    .map { |candidate| build_line_with_completion(candidate) }
    .reject { |line| line == buffer.text.to_s }
    .uniq
end

#cursor_xObject

Visual cursor X position in the window



70
71
72
73
# File 'lib/fatty/input_field.rb', line 70

def cursor_x
  before_cursor = buffer.text.to_s[0...buffer.cursor].to_s
  prompt_width + Fatty::Ansi.visible_length(before_cursor)
end

#cycle_completion!Object



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/fatty/input_field.rb', line 223

def cycle_completion!
  text = buffer.text.to_s
  suggestions = completion_suggestions
  result = nil

  if suggestions.empty?
    reset_completion_cycle!
  elsif @completion_cycle_base == text &&
        @completion_cycle_candidates == suggestions &&
        @completion_cycle_index
    @completion_cycle_index = (@completion_cycle_index + 1) % @completion_cycle_candidates.length
    result = @completion_cycle_candidates[@completion_cycle_index]
  else
    @completion_cycle_base = text
    @completion_cycle_candidates = suggestions
    @completion_cycle_index =
      if suggestions.length > 1
        1
      else
        0
      end
    result = @completion_cycle_candidates[@completion_cycle_index]
  end

  sync_virtual_suffix!
  result
end

#default_completion_autosuggestionObject



204
205
206
207
208
# File 'lib/fatty/input_field.rb', line 204

def default_completion_autosuggestion
  suggestions = completion_suggestions
  result = suggestions.first
  result
end

#empty?Boolean

:category: Queries

Returns:

  • (Boolean)


65
66
67
# File 'lib/fatty/input_field.rb', line 65

def empty?
  buffer.text == ""
end

#escape_path(path) ⇒ Object



387
388
389
# File 'lib/fatty/input_field.rb', line 387

def escape_path(path)
  path.gsub(/([ \t\n\\'"`$!#&()*;<>?\[\]\{\}|])/) { "\\#{$1}" }
end

#expand_path_prefix(prefix) ⇒ Object



395
396
397
398
399
400
401
402
403
404
# File 'lib/fatty/input_field.rb', line 395

def expand_path_prefix(prefix)
  s = prefix.to_s
  return if s.empty?

  if s.start_with?("~/")
    File.join(Dir.home, s.delete_prefix("~/"))
  else
    s
  end
end

#history_autosuggestionObject



258
259
260
261
262
263
264
265
266
# File 'lib/fatty/input_field.rb', line 258

def history_autosuggestion
  return if history.nil?

  history.suggest_for(
    resolve_history_kind,
    prefix: buffer.text,
    ctx: resolve_history_ctx,
  )
end

#path_completion_candidatesObject



268
269
270
271
272
273
# File 'lib/fatty/input_field.rb', line 268

def path_completion_candidates
  prefix = path_completion_prefix
  return [] if prefix.nil? || prefix.empty?

  rendered_path_candidates(prefix)
end

#path_completion_prefixObject



282
283
284
285
286
287
# File 'lib/fatty/input_field.rb', line 282

def path_completion_prefix
  r = path_completion_range
  return if r.nil?

  buffer.text[r].to_s
end

#path_completion_rangeObject

Use a StringScanner to determine where a pathname occurs before the cursor for purposes of providing completions. It considers escaped characters, including escaped whitespace as part of a plausible pathname.



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/fatty/input_field.rb', line 293

def path_completion_range
  text = buffer.text.to_s
  cur  = buffer.cursor
  return if cur < 0

  before = text[0...cur]
  scanner = StringScanner.new(before)

  last_token = nil
  until scanner.eos?
    next if scanner.scan(/\s+/)

    if (token = scanner.scan(/(?:\\.|[^\s\\])+/))
      last_token = [scanner.pos - token.length, scanner.pos]
    else
      scanner.getch
    end
  end

  return unless last_token

  start_i, end_i = last_token
  prefix = before[start_i...end_i]

  return unless path_like_prefix?(unescape_path(prefix))

  start_i...end_i
end

#path_like_prefix?(prefix) ⇒ Boolean

Returns:

  • (Boolean)


275
276
277
278
279
280
# File 'lib/fatty/input_field.rb', line 275

def path_like_prefix?(prefix)
  s = prefix.to_s
  return false if s.empty?

  s.start_with?("/", "~/", "./", "../") || s.include?("/")
end


440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/fatty/input_field.rb', line 440

def popup_completion_candidates
  path_prefix = popup_path_completion_prefix
  if path_prefix
    path = rendered_path_candidates(path_prefix)
    return path if path.any?
  end

  return [] unless @completion_proc

  Array(@completion_proc.call(buffer))
    .compact
    .map(&:to_s)
    .reject(&:empty?)
    .uniq
end


471
472
473
# File 'lib/fatty/input_field.rb', line 471

def popup_completion_query
  popup_path_completion_prefix || path_completion_prefix || buffer.completion_prefix
end


475
476
477
# File 'lib/fatty/input_field.rb', line 475

def popup_completion_range
  path_completion_range || buffer.completion_replace_range
end


456
457
458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/fatty/input_field.rb', line 456

def popup_path_completion_prefix
  prefix = path_completion_prefix
  return if prefix.nil? || prefix.empty?

  raw_prefix = unescape_path(prefix)
  expanded = expand_path_prefix(raw_prefix)
  return prefix unless File.directory?(expanded)

  if raw_prefix.end_with?("/")
    prefix
  else
    escape_path("#{raw_prefix}/")
  end
end

#prompt_textObject



75
76
77
# File 'lib/fatty/input_field.rb', line 75

def prompt_text
  prompt.text
end

#prompt_widthObject

The prompt might use coloring, so we use the visible length stripped of ANSI controls.



81
82
83
# File 'lib/fatty/input_field.rb', line 81

def prompt_width
  Fatty::Ansi.visible_length(prompt_text.to_s.lines.last.to_s)
end

#rendered_path_candidates(prefix) ⇒ Object



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/fatty/input_field.rb', line 322

def rendered_path_candidates(prefix)
  raw_prefix = unescape_path(prefix)
  expanded = expand_path_prefix(raw_prefix)
  return [] if expanded.nil? || expanded.empty?

  if File.directory?(expanded) && !raw_prefix.end_with?("/")
    return [escape_path("#{raw_prefix}/")]
  end

  if raw_prefix.end_with?("/")
    dir_part = expanded
    base_part = ""
  else
    dir_part  = File.dirname(expanded)
    base_part = File.basename(expanded)
  end

  dir_part = "." if dir_part.nil? || dir_part.empty?
  return [] unless Dir.exist?(dir_part)

  # Sort normal directories, normal files, hidden directories, then hidden
  # files, but not if the prefix starts with '.' or '#', then treat hidden
  # files normally.
  entries =
    Dir.children(dir_part)
      .select { |name| name.start_with?(base_part) }
      .sort_by do |name|
        full = File.join(dir_part, name)
        hide_penalty =
          if base_part.match?(/\A[.#]/)
            0
          else
            name.match?(/\A[.#]/) ? 1 : 0
          end
        file_penalty = File.directory?(full) ? 0 : 1
        [hide_penalty, file_penalty, name]
    end

  return [] if entries.empty?

  entries.map do |chosen|
    full = File.join(dir_part, chosen)

    rendered =
      if raw_prefix.start_with?("~/")
        File.join("~", full.delete_prefix("#{Dir.home}/"))
      elsif raw_prefix.start_with?("./") && dir_part == "."
        "./#{chosen}"
      elsif raw_prefix.start_with?("../") && dir_part.start_with?("..")
        File.join(dir_part, chosen)
      elsif raw_prefix.start_with?("/")
        full
      elsif raw_prefix.end_with?("/")
        "#{raw_prefix}#{chosen}"
      elsif raw_prefix.include?("/")
        File.join(File.dirname(raw_prefix), chosen)
      else
        chosen
      end

    rendered += "/" if File.directory?(full) && !rendered.end_with?("/")
    escape_path(rendered)
  end
end

#reset_completion_cycle!Object



251
252
253
254
255
256
# File 'lib/fatty/input_field.rb', line 251

def reset_completion_cycle!
  @completion_cycle_base = nil
  @completion_cycle_candidates = []
  @completion_cycle_index = nil
  nil
end

#snapshot_input_stateObject



85
86
87
88
89
90
91
92
93
# File 'lib/fatty/input_field.rb', line 85

def snapshot_input_state
  [
    prompt_text.to_s.dup.freeze,
    buffer.text.to_s.dup.freeze,
    buffer.virtual_suffix.to_s.dup.freeze,
    cursor_x,
    (r = buffer.region_range) ? [r.begin, r.end] : nil,
  ]
end

#sync_virtual_suffix!Object



483
484
485
# File 'lib/fatty/input_field.rb', line 483

def sync_virtual_suffix!
  buffer.virtual_suffix = autosuggestion_suffix
end

#to_sObject Also known as: inspect

:category: Inspect



58
59
60
# File 'lib/fatty/input_field.rb', line 58

def to_s
  "<InputField:#{object_id}> Prompt => #{prompt} Buffer => #{buffer}"
end

#unescape_path(path) ⇒ Object



391
392
393
# File 'lib/fatty/input_field.rb', line 391

def unescape_path(path)
  path.to_s.gsub(/\\(.)/, '\1')
end