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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +2 -0
  3. data/.simplecov +23 -0
  4. data/.yardopts +4 -0
  5. data/CHANGELOG.md +34 -0
  6. data/CHANGELOG.org +38 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +31 -0
  9. data/README.org +166 -0
  10. data/Rakefile +15 -0
  11. data/TODO.org +163 -0
  12. data/examples/markdown/native-markdown.md +370 -0
  13. data/examples/markdown/ox-gfm-markdown.md +373 -0
  14. data/examples/markdown/ox-gfm-markdown.org +376 -0
  15. data/exe/fatty +275 -0
  16. data/fatty.gemspec +42 -0
  17. data/lib/fatty/accept_env.rb +32 -0
  18. data/lib/fatty/action.rb +103 -0
  19. data/lib/fatty/action_environment.rb +42 -0
  20. data/lib/fatty/actionable.rb +73 -0
  21. data/lib/fatty/alert.rb +93 -0
  22. data/lib/fatty/ansi/renderer.rb +168 -0
  23. data/lib/fatty/ansi.rb +352 -0
  24. data/lib/fatty/colors/color.rb +379 -0
  25. data/lib/fatty/colors/pairs.rb +73 -0
  26. data/lib/fatty/colors/palette.rb +73 -0
  27. data/lib/fatty/colors/rgb.txt +788 -0
  28. data/lib/fatty/colors.rb +5 -0
  29. data/lib/fatty/config.rb +86 -0
  30. data/lib/fatty/config_files/config.yml +50 -0
  31. data/lib/fatty/config_files/help.md +120 -0
  32. data/lib/fatty/config_files/help.org +124 -0
  33. data/lib/fatty/config_files/keybindings.yml +49 -0
  34. data/lib/fatty/config_files/keydefs.yml +23 -0
  35. data/lib/fatty/config_files/themes/mono.yml +76 -0
  36. data/lib/fatty/config_files/themes/nordic.yml +77 -0
  37. data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
  38. data/lib/fatty/config_files/themes/terminal.yml +90 -0
  39. data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
  40. data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
  41. data/lib/fatty/core_ext/string.rb +21 -0
  42. data/lib/fatty/core_ext.rb +3 -0
  43. data/lib/fatty/counter.rb +81 -0
  44. data/lib/fatty/curses/context.rb +279 -0
  45. data/lib/fatty/curses/curses_coder.rb +684 -0
  46. data/lib/fatty/curses/event_source.rb +230 -0
  47. data/lib/fatty/curses/key_decoder.rb +183 -0
  48. data/lib/fatty/curses/patch.rb +116 -0
  49. data/lib/fatty/curses/window_styling.rb +32 -0
  50. data/lib/fatty/curses.rb +16 -0
  51. data/lib/fatty/env.rb +100 -0
  52. data/lib/fatty/help.rb +41 -0
  53. data/lib/fatty/history/entry.rb +71 -0
  54. data/lib/fatty/history.rb +289 -0
  55. data/lib/fatty/input_buffer.rb +998 -0
  56. data/lib/fatty/input_field.rb +507 -0
  57. data/lib/fatty/key_event.rb +342 -0
  58. data/lib/fatty/key_map.rb +392 -0
  59. data/lib/fatty/keymaps/emacs.rb +189 -0
  60. data/lib/fatty/log_formats/json.rb +47 -0
  61. data/lib/fatty/log_formats/text.rb +67 -0
  62. data/lib/fatty/logger.rb +142 -0
  63. data/lib/fatty/markdown/ansi_renderer.rb +373 -0
  64. data/lib/fatty/markdown/render.rb +22 -0
  65. data/lib/fatty/markdown.rb +4 -0
  66. data/lib/fatty/menu_env.rb +22 -0
  67. data/lib/fatty/mouse_event.rb +32 -0
  68. data/lib/fatty/output_buffer.rb +78 -0
  69. data/lib/fatty/pager.rb +801 -0
  70. data/lib/fatty/prompt.rb +40 -0
  71. data/lib/fatty/renderer/curses.rb +697 -0
  72. data/lib/fatty/renderer/truecolor.rb +607 -0
  73. data/lib/fatty/renderer.rb +419 -0
  74. data/lib/fatty/screen.rb +96 -0
  75. data/lib/fatty/search.rb +43 -0
  76. data/lib/fatty/session/alert_session.rb +52 -0
  77. data/lib/fatty/session/input_session.rb +99 -0
  78. data/lib/fatty/session/isearch_session.rb +172 -0
  79. data/lib/fatty/session/keytest_session.rb +236 -0
  80. data/lib/fatty/session/modal_session.rb +61 -0
  81. data/lib/fatty/session/output_session.rb +105 -0
  82. data/lib/fatty/session/popup_session.rb +540 -0
  83. data/lib/fatty/session/prompt_session.rb +157 -0
  84. data/lib/fatty/session/search_session.rb +136 -0
  85. data/lib/fatty/session/shell_session.rb +566 -0
  86. data/lib/fatty/session.rb +173 -0
  87. data/lib/fatty/sessions.rb +14 -0
  88. data/lib/fatty/terminal/popup_owner.rb +26 -0
  89. data/lib/fatty/terminal/progress.rb +374 -0
  90. data/lib/fatty/terminal.rb +1067 -0
  91. data/lib/fatty/themes/loader.rb +136 -0
  92. data/lib/fatty/themes/manager.rb +71 -0
  93. data/lib/fatty/themes/registry.rb +64 -0
  94. data/lib/fatty/themes/resolver.rb +224 -0
  95. data/lib/fatty/themes/themes.rb +131 -0
  96. data/lib/fatty/themes.rb +6 -0
  97. data/lib/fatty/version.rb +5 -0
  98. data/lib/fatty/view/alert_view.rb +14 -0
  99. data/lib/fatty/view/cursor_view.rb +18 -0
  100. data/lib/fatty/view/input_view.rb +9 -0
  101. data/lib/fatty/view/output_view.rb +9 -0
  102. data/lib/fatty/view/status_view.rb +14 -0
  103. data/lib/fatty/view.rb +33 -0
  104. data/lib/fatty/viewport.rb +90 -0
  105. data/lib/fatty/views.rb +9 -0
  106. data/lib/fatty.rb +55 -0
  107. data/sig/fatty.rbs +4 -0
  108. metadata +250 -0
@@ -0,0 +1,998 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Fatty
6
+ # The InputBuffer class maintains an editable line of input together with a
7
+ # cursor position. It is responsible only for text editing semantics —
8
+ # inserting and deleting characters, moving the cursor, and reporting the
9
+ # current contents of the buffer.
10
+ #
11
+ # InputBuffer has no knowledge of keybindings, terminal I/O, history, or
12
+ # rendering. Higher-level components (such as InputField and Terminal)
13
+ # translate user actions into editing operations on the buffer.
14
+ #
15
+ # The buffer is conceptually a single line of text. Newlines are not
16
+ # interpreted specially and are treated as ordinary characters if present.
17
+ #
18
+ # Responsibilities:
19
+ # - Maintain the current text and cursor position
20
+ # - Insert text at the cursor
21
+ # - Delete characters before or after the cursor
22
+ # - Move the cursor within valid bounds
23
+ # - Replace or clear the buffer contents
24
+ #
25
+ # Non-responsibilities:
26
+ # - Keyboard decoding or modifier interpretation
27
+ # - Command history navigation
28
+ # - Screen layout or cursor rendering
29
+ # - Validation of user input
30
+ #
31
+ # The cursor position represents a location *between* characters. It is
32
+ # always an integer between 0 and text.length, inclusive. All editing
33
+ # operations must preserve this invariant.
34
+ class InputBuffer
35
+ include Actionable
36
+
37
+ attr_reader :mark, :kill_ring, :undo_stack
38
+ attr_accessor :text, :cursor, :word_re, :virtual_suffix
39
+
40
+ DEFAULT_WORD_CHARS = "[[:alnum:]_]"
41
+
42
+ def initialize(word_chars: DEFAULT_WORD_CHARS, word_re: nil, undo_limit: 1_000, kill_ring_max: 60)
43
+ @text = +""
44
+ @virtual_suffix = +""
45
+ @cursor = 0
46
+ @mark = nil
47
+ @word_re =
48
+ if word_re
49
+ word_re
50
+ else
51
+ # word_chars is a fragment like "[[:alnum:]_]" or "[[:alnum:]_-]"
52
+ Regexp.new(word_chars)
53
+ end
54
+ @undo_limit = undo_limit
55
+ @undo_stack = []
56
+ @redo_stack = []
57
+ @undo_chain = nil
58
+
59
+ @kill_ring = []
60
+ @kill_ring_max = kill_ring_max
61
+ @last_yank_len = 0
62
+ @last_action = nil
63
+ end
64
+
65
+ # :category: Inspect
66
+
67
+ def to_s
68
+ text_w_cursor =
69
+ if mark
70
+ if mark < cursor
71
+ "#{text[0..mark - 1]}[#{text[mark..cursor - 1]}]|#{text[cursor..]}"
72
+ elsif mark > cursor
73
+ "#{text[0..cursor - 1]}[|#{text[cursor..mark - 1]}]#{text[mark..]}"
74
+ else
75
+ # They're equal, ignore mark
76
+ "#{text[0..cursor - 1]}|#{text[cursor..]}]"
77
+ end
78
+ elsif cursor > 0
79
+ "#{text[0..cursor - 1]}|#{text[cursor..]}"
80
+ else
81
+ "|#{text}"
82
+ end
83
+ v_text =
84
+ if virtual_suffix.empty?
85
+ ''
86
+ else
87
+ "(#{virtual_suffix})"
88
+ end
89
+ "<InputBuffer:#{object_id}> <#{text_w_cursor}>#{v_text} => Kill[#{kill_ring.size}] => Undo[#{undo_stack.size}]"
90
+ end
91
+ alias_method :inspect, :to_s
92
+
93
+ def virtual_text
94
+ @text + @virtual_suffix.to_s
95
+ end
96
+
97
+ def virtual_length
98
+ virtual_text.length
99
+ end
100
+
101
+ # :category: Queries
102
+
103
+ def empty?
104
+ text.empty?
105
+ end
106
+
107
+ def length
108
+ @text.length
109
+ end
110
+
111
+ def bol?
112
+ @cursor.zero?
113
+ end
114
+
115
+ def eol?
116
+ @cursor == @text.length
117
+ end
118
+
119
+ def text_before_cursor
120
+ text[0, @cursor] || ""
121
+ end
122
+
123
+ def text_after_cursor
124
+ text[@cursor..] || ""
125
+ end
126
+
127
+ def can_undo?
128
+ !@undo_stack.empty?
129
+ end
130
+
131
+ def can_redo?
132
+ !@redo_stack.empty?
133
+ end
134
+
135
+ def undo_size
136
+ @undo_stack.size
137
+ end
138
+
139
+ def region_active?
140
+ !!@mark && @mark != @cursor
141
+ end
142
+
143
+ def region_range
144
+ if region_active?
145
+ a = @mark
146
+ b = @cursor
147
+ s = [a, b].min
148
+ e = [a, b].max
149
+ clamp_range(s...e)
150
+ end
151
+ end
152
+
153
+ # Return whether the action with name takes a count: parameter.
154
+ def countable?(name)
155
+ return false unless respond_to?(name)
156
+
157
+ params = method(name).parameters
158
+ params.any? { |kind, key| kind == :key && key == :count } ||
159
+ params.any? { |kind, key| kind == :keyreq && key == :count }
160
+ rescue NameError
161
+ false
162
+ end
163
+
164
+ # category: Actions: Cursor Movement
165
+
166
+ desc "Undo the most recent buffer edit, text and cursor. Returns true/false."
167
+ action :undo do
168
+ undo
169
+ end
170
+
171
+ desc "Redo the most recently undone edit. Returns true/false."
172
+ action :redo do
173
+ redo
174
+ end
175
+
176
+ # category: Actions: Cursor Movement
177
+
178
+ desc "Move cursor to the beginning of the line"
179
+ action :bol do
180
+ break_undo_chain!
181
+ @cursor = 0
182
+ end
183
+
184
+ desc "Move cursor to the end of the line"
185
+ action :eol do
186
+ break_undo_chain!
187
+ @cursor = virtual_length
188
+ promote_to_cursor!
189
+ end
190
+
191
+ desc "Move cursor count words to the right"
192
+ action :move_word_right do |count: 1|
193
+ break_undo_chain!
194
+ repeat(count) { move_word_right_once }
195
+ end
196
+
197
+ desc "Move cursor count words to the left"
198
+ action :move_word_left do |count: 1|
199
+ break_undo_chain!
200
+ repeat(count) { move_word_left_once }
201
+ end
202
+
203
+ desc "Move cursor count characters to the left"
204
+ action :move_left do |count: 1|
205
+ break_undo_chain!
206
+ n = normalize_count(count)
207
+ @cursor = [@cursor - n, 0].max
208
+ end
209
+
210
+ desc "Move cursor count characters to the right"
211
+ action :move_right do |count: 1|
212
+ break_undo_chain!
213
+ n = normalize_count(count)
214
+ @cursor = [@cursor + n, virtual_length].min
215
+ promote_to_cursor!
216
+ end
217
+
218
+ # :category: Actions: Region
219
+
220
+ desc "Set the mark at the current cursor position (activates region)."
221
+ action :set_mark do
222
+ break_undo_chain!
223
+ @mark = @cursor
224
+ end
225
+
226
+ desc "Clear the mark (deactivates region)."
227
+ action :clear_mark do
228
+ break_undo_chain!
229
+ @mark = nil
230
+ @virtual_suffix = +''
231
+ end
232
+
233
+ # :category: Actions: Change Buffer
234
+
235
+ desc "Clear the buffer"
236
+ action :clear do
237
+ return if text.empty? && @cursor.zero?
238
+
239
+ with_undo do
240
+ @mark = nil
241
+ @last_action = nil
242
+ @virtual_suffix = +''
243
+ text.clear
244
+ @cursor = 0
245
+ end
246
+ end
247
+
248
+ desc "Add a count copies of string at the cursor and move cursor after the inserted text"
249
+ action :insert do |str, count: 1|
250
+ s = str.to_s
251
+ n = normalize_count(count)
252
+ return if s.empty?
253
+
254
+ with_undo_coalesced(:insert) do
255
+ @last_action = nil
256
+ payload = (s * n)
257
+
258
+ if region_active?
259
+ r = region_range
260
+ if r && r.begin < r.end
261
+ # Replace the selected text
262
+ text[r] = payload
263
+ @cursor = r.begin + payload.length
264
+ else
265
+ text.insert(@cursor, payload)
266
+ @cursor += payload.length
267
+ end
268
+ clear_mark
269
+ else
270
+ text.insert(@cursor, payload)
271
+ @cursor += payload.length
272
+ end
273
+ end
274
+ end
275
+ action :self_insert, to: :insert
276
+
277
+ desc "Replace buffer contents with a string and move the cursor to the end"
278
+ action :replace do |str|
279
+ str = str.to_s
280
+ with_undo do
281
+ @last_action = nil
282
+ @text = str.dup
283
+ @virtual_suffix = +''
284
+ @cursor = @text.length
285
+ end
286
+ end
287
+ action :set, to: :replace
288
+
289
+ desc "Delete the character before the cursor"
290
+ action :delete_char_backward do |count: 1|
291
+ if region_active?
292
+ delete_region
293
+ else
294
+ n = normalize_count(count)
295
+ return if @cursor.zero?
296
+
297
+ with_undo do
298
+ @last_action = nil
299
+ repeat(n) do
300
+ break if @cursor.zero?
301
+
302
+ text.slice!(@cursor - 1)
303
+ @cursor -= 1
304
+ end
305
+ end
306
+ end
307
+ end
308
+
309
+ desc "Delete count characters after the cursor"
310
+ action :delete_char_forward do |count: 1|
311
+ if region_active?
312
+ delete_region
313
+ else
314
+ n = normalize_count(count)
315
+ return if @cursor == text.length
316
+
317
+ with_undo do
318
+ @last_action = nil
319
+ repeat(n) do
320
+ break if @cursor == text.length
321
+
322
+ text.slice!(@cursor, 1)
323
+ end
324
+ end
325
+ end
326
+ end
327
+
328
+ desc "Delete to the end of the buffer and return deleted string"
329
+ action :kill_to_eol do
330
+ return "" if eol?
331
+
332
+ killed = ""
333
+ with_undo do
334
+ killed = delete_range(cursor...text.length)
335
+ push_kill(killed)
336
+ @last_action = :kill
337
+ end
338
+ killed
339
+ end
340
+
341
+ desc "Delete to the beginning of the buffer and return deleted string"
342
+ action :kill_to_bol do
343
+ return "" if bol?
344
+
345
+ killed = ""
346
+ with_undo do
347
+ killed = delete_range(0...cursor)
348
+ @cursor = 0
349
+ push_kill(killed)
350
+ @last_action = :kill
351
+ end
352
+ killed
353
+ end
354
+
355
+ desc "Kill count words after the cursor and return the deleted string"
356
+ action :kill_word_forward do |count: 1|
357
+ n = normalize_count(count)
358
+ return "" if eol?
359
+
360
+ deleted = ""
361
+ with_undo do
362
+ start = cursor
363
+ finish = start
364
+ repeat(n) do
365
+ break if finish >= text.length
366
+
367
+ span = word_span_forward(finish)
368
+ break if span.begin == span.end
369
+
370
+ finish = span.end
371
+ end
372
+
373
+ deleted = delete_range(start...finish)
374
+ @cursor = start
375
+ push_kill(deleted)
376
+ @last_action = :kill
377
+ end
378
+ deleted
379
+ end
380
+
381
+ desc "Kill count words before the cursor and return the deleted string"
382
+ action :kill_word_backward do |count: 1|
383
+ n = normalize_count(count)
384
+ return "" if bol?
385
+
386
+ deleted = ""
387
+ with_undo do
388
+ finish = cursor
389
+ start = finish
390
+ repeat(n) do
391
+ break if start <= 0
392
+
393
+ span = word_span_backward(start)
394
+ break if span.begin == span.end
395
+
396
+ start = span.begin
397
+ end
398
+
399
+ deleted = delete_range(start...finish)
400
+ @cursor = start
401
+ push_kill(deleted)
402
+ @last_action = :kill
403
+ end
404
+ deleted
405
+ end
406
+
407
+ desc "Transpose the two characters around point."
408
+ action :transpose_chars do |count: 1|
409
+ n = normalize_count(count)
410
+ return if text.length < 2
411
+
412
+ with_undo do
413
+ @last_action = nil
414
+ repeat(n) do
415
+ transpose_chars_once
416
+ end
417
+ end
418
+ end
419
+
420
+ desc "Transpose the word at point with the adjacent word."
421
+ action :transpose_words do |count: 1|
422
+ n = normalize_count(count)
423
+ return if text.empty?
424
+
425
+ with_undo do
426
+ @last_action = nil
427
+ repeat(n) do
428
+ changed = transpose_words_once
429
+ break unless changed
430
+ end
431
+ end
432
+ end
433
+
434
+ desc "Kill the active region and return deleted text; pushes to kill ring."
435
+ action :kill_region do
436
+ r = region_range
437
+ return "" unless r
438
+
439
+ deleted = ""
440
+ with_undo do
441
+ deleted = delete_range(r)
442
+ @cursor = r.begin
443
+ @mark = nil
444
+ push_kill(deleted)
445
+ @last_action = :kill
446
+ end
447
+ deleted
448
+ end
449
+
450
+ desc "Delete the active region without pushing it to the kill ring."
451
+ action :delete_region do
452
+ r = region_range
453
+ return "" unless r
454
+
455
+ deleted = ""
456
+ with_undo do
457
+ deleted = delete_range(r)
458
+ @cursor = r.begin
459
+ @mark = nil
460
+ @last_action = nil
461
+ end
462
+ deleted
463
+ end
464
+
465
+ desc "Copy the active region and return copied text; pushes to kill ring."
466
+ action :copy_region do
467
+ r = region_range
468
+ return "" unless r
469
+
470
+ break_undo_chain!
471
+ copied = text[r] || ""
472
+ @mark = nil
473
+ push_kill(copied)
474
+ @last_action = :copy
475
+ copied
476
+ end
477
+
478
+ desc "Yank (paste) the most recent kill at the cursor."
479
+ action :yank do
480
+ y = @kill_ring.first.to_s
481
+ return "" if y.empty?
482
+
483
+ base = snapshot
484
+ with_undo(before: base) do
485
+ text.insert(@cursor, y)
486
+ @cursor += y.length
487
+ adjust_mark_for_replace_span!(@cursor - y.length, 0, y.length)
488
+ end
489
+
490
+ @yank_undo_snapshot = base
491
+ @last_yank_len = y.length
492
+ @last_action = :yank
493
+ y
494
+ end
495
+
496
+ desc "Replace the last yanked text with the previous kill ring entry."
497
+ action :yank_pop do |count: 1|
498
+ return "" unless @last_action == :yank || @last_action == :yank_pop
499
+ return "" if @kill_ring.length < 2
500
+ return "" if @last_yank_len.to_i <= 0
501
+
502
+ break_undo_chain!
503
+ n = normalize_count(count)
504
+ y = ""
505
+ repeat(n) do
506
+ first = @kill_ring.shift
507
+ @kill_ring << first
508
+ y = @kill_ring.first.to_s
509
+ end
510
+
511
+ start = @cursor - @last_yank_len
512
+ start = 0 if start < 0
513
+ replace_span(start, @last_yank_len, y)
514
+
515
+ # We changed buffer state; redo history is no longer valid.
516
+ @redo_stack.clear
517
+
518
+ # Ensure the undo stack top still represents the "pre-yank" snapshot.
519
+ if @yank_undo_snapshot
520
+ # If the most recent undo entry isn't our yank base, replace it.
521
+ if @undo_stack.empty? || @undo_stack.last != @yank_undo_snapshot
522
+ @undo_stack << @yank_undo_snapshot
523
+ @undo_stack.shift while @undo_stack.length > @undo_limit
524
+ end
525
+ end
526
+ clamp_mark!
527
+ @last_yank_len = y.length
528
+ @last_action = :yank_pop
529
+ y
530
+ end
531
+
532
+ # :category: Undo and Redo helpers
533
+
534
+ def undo
535
+ return if @undo_stack.empty?
536
+
537
+ break_undo_chain!
538
+ @redo_stack << snapshot
539
+ restore(@undo_stack.pop)
540
+ end
541
+
542
+ def redo
543
+ return if @redo_stack.empty?
544
+
545
+ break_undo_chain!
546
+ @undo_stack << snapshot
547
+ restore(@redo_stack.pop)
548
+ end
549
+
550
+ # :category: Utilities
551
+
552
+ def display_width
553
+ Unicode::DisplayWidth.of(text)
554
+ end
555
+
556
+ # Replace a from start, length characters; move cursor to end of insertion
557
+ def replace_span(start, length, str)
558
+ start = start.to_i
559
+ length = length.to_i
560
+ str = str.to_s
561
+
562
+ start = 0 if start.negative?
563
+ start = text.length if start > text.length
564
+ length = 0 if length.negative?
565
+
566
+ max_len = text.length - start
567
+ length = max_len if length > max_len
568
+
569
+ text.slice!(start, length) if length.positive?
570
+ text.insert(start, str) unless str.empty?
571
+ @cursor = start + str.length
572
+ adjust_mark_for_replace_span!(start, length, str.length)
573
+ end
574
+
575
+ # Replace the text in the given Range with str; move cursor to end of
576
+ # insertion
577
+ def replace_range(range, str)
578
+ r = range
579
+ raise ArgumentError, "range required" unless r.is_a?(Range)
580
+ raise ArgumentError, "range must have begin and end" if r.begin.nil? || r.end.nil?
581
+
582
+ start = r.begin.to_i
583
+ finish = r.end.to_i
584
+ finish += 1 unless r.exclude_end?
585
+
586
+ replace_span(start, finish - start, str)
587
+ end
588
+
589
+ # Replace the region with the str
590
+ def replace_region(str)
591
+ s = str.to_s
592
+ r = region_range
593
+ if r && r.begin < r.end
594
+ replace_span(r.begin, r.end - r.begin, s)
595
+ @mark = nil
596
+ else
597
+ text.insert(@cursor, s)
598
+ @cursor += s.length
599
+ end
600
+ s
601
+ end
602
+
603
+ def delete_range(range)
604
+ r = clamp_range(range)
605
+ raise ArgumentError, "range required" unless r.is_a?(Range)
606
+ raise ArgumentError, "range must have begin and end" if r.begin.nil? || r.end.nil?
607
+ return "" if r.begin == r.end
608
+
609
+ deleted = text[r] || ""
610
+ replace_range(r, "")
611
+ deleted
612
+ end
613
+
614
+ # Return a Range that corresponds to the whole word that is "around" the
615
+ # cursor. The whole region if region active, the word cursor is on a if
616
+ # it's on a word, or nothing otherwise.
617
+ def word_at_point_range(from = cursor)
618
+ if region_active?
619
+ a, b = region_range.minmax
620
+ return a...b
621
+ end
622
+
623
+ chars = text.chars
624
+ return from...from if from < 0 || from > chars.length
625
+ return from...from if chars.empty?
626
+
627
+ on_word =
628
+ if from == chars.length
629
+ from.positive? && word_char?(chars[from - 1])
630
+ else
631
+ word_char?(chars[from])
632
+ end
633
+
634
+ return from...from unless on_word
635
+
636
+ left = from
637
+ if left == chars.length
638
+ left -= 1
639
+ end
640
+ left -= 1 while left.positive? && word_char?(chars[left - 1])
641
+
642
+ right = from
643
+ right += 1 while right < chars.length && word_char?(chars[right])
644
+
645
+ left...right
646
+ end
647
+
648
+ # Return the Range of the buffer that a completion should replace when the
649
+ # completion is accepted.
650
+ def completion_replace_range(from = cursor)
651
+ if region_active?
652
+ r = region_range
653
+ return r if r
654
+ end
655
+
656
+ prefix = completion_prefix(from)
657
+ chars = text.chars
658
+ at_word =
659
+ from < chars.length && word_char?(chars[from])
660
+
661
+ unless prefix.empty?
662
+ if at_word
663
+ return word_at_point_range(from)
664
+ end
665
+
666
+ back = word_span_backward(from)
667
+ return back if back.begin < back.end
668
+ end
669
+
670
+ if at_word
671
+ fwd = word_span_forward(from)
672
+ return fwd.end...fwd.end
673
+ end
674
+
675
+ from...from
676
+ end
677
+
678
+ def completion_prefix(from = cursor)
679
+ if region_active?
680
+ r = region_range
681
+ r ? text[r].to_s : ""
682
+ else
683
+ chars = text.chars
684
+ if from.positive? && !chars.empty?
685
+ if from < chars.length && word_char?(chars[from])
686
+ r = word_at_point_range(from)
687
+ r ? text[r.begin...from].to_s : ""
688
+ elsif word_char?(chars[from - 1])
689
+ r = word_span_backward(from)
690
+ r ? text[r].to_s : ""
691
+ else
692
+ ""
693
+ end
694
+ else
695
+ ""
696
+ end
697
+ end
698
+ end
699
+
700
+ def accept_virtual_suffix!
701
+ return if @virtual_suffix.to_s.empty?
702
+
703
+ @text = virtual_text
704
+ @cursor = @text.length
705
+ @virtual_suffix = +""
706
+ end
707
+
708
+ private
709
+
710
+ def move_word_right_once
711
+ break_undo_chain!
712
+ chars = virtual_text.chars
713
+ i = @cursor
714
+ # skip non-word
715
+ i += 1 while i < chars.length && !word_char?(chars[i])
716
+ # skip word
717
+ i += 1 while i < chars.length && word_char?(chars[i])
718
+ @cursor = i
719
+ promote_to_cursor!
720
+ end
721
+
722
+ def move_word_left_once
723
+ break_undo_chain!
724
+ chars = text.chars
725
+ i = @cursor
726
+ # skip non-word chars to the left
727
+ i -= 1 while i > 0 && !word_char?(chars[i - 1])
728
+ # skip word chars to the left
729
+ i -= 1 while i > 0 && word_char?(chars[i - 1])
730
+ @cursor = i
731
+ end
732
+
733
+ def transpose_chars_once
734
+ i =
735
+ if @cursor == text.length
736
+ @cursor - 2
737
+ else
738
+ @cursor - 1
739
+ end
740
+
741
+ return if i < 0
742
+ return if i + 1 >= text.length
743
+
744
+ a = text[i]
745
+ b = text[i + 1]
746
+ text[i] = b
747
+ text[i + 1] = a
748
+
749
+ @cursor = [i + 2, text.length].min
750
+ end
751
+
752
+ def transpose_words_once
753
+ left, right = transpose_word_ranges
754
+ return false unless left && right
755
+ return false if left.begin == left.end || right.begin == right.end
756
+ return false if left.end > right.begin
757
+
758
+ left_text = text[left]
759
+ middle = text[left.end...right.begin].to_s
760
+ right_text = text[right]
761
+
762
+ replace_span(left.begin, right.end - left.begin, right_text + middle + left_text)
763
+ @cursor = left.begin + right_text.length + middle.length + left_text.length
764
+ true
765
+ end
766
+
767
+ def transpose_word_ranges
768
+ here = word_at_point_range(@cursor)
769
+
770
+ if here.begin < here.end
771
+ left = previous_word_range(here.begin)
772
+ right = next_word_range(here.end)
773
+
774
+ if right.begin < right.end
775
+ [here, right]
776
+ elsif left.begin < left.end
777
+ [left, here]
778
+ else
779
+ [nil, nil]
780
+ end
781
+ else
782
+ left = previous_word_range(@cursor)
783
+ right = next_word_range(@cursor)
784
+
785
+ if left.begin < left.end && right.begin < right.end
786
+ [left, right]
787
+ else
788
+ [nil, nil]
789
+ end
790
+ end
791
+ end
792
+
793
+ def previous_word_range(from)
794
+ span = word_span_backward(from)
795
+ if span.begin < span.end
796
+ word_at_point_range(span.begin)
797
+ else
798
+ from...from
799
+ end
800
+ end
801
+
802
+ def next_word_range(from)
803
+ chars = text.chars
804
+ i = from
805
+ i += 1 while i < chars.length && !word_char?(chars[i])
806
+
807
+ if i < chars.length && word_char?(chars[i])
808
+ word_at_point_range(i)
809
+ else
810
+ from...from
811
+ end
812
+ end
813
+
814
+ def with_undo(before: nil)
815
+ break_undo_chain!
816
+ before ||= snapshot
817
+ yield
818
+ after = snapshot
819
+
820
+ if after != before
821
+ @undo_stack << before
822
+ @undo_stack.shift while @undo_stack.length > @undo_limit
823
+ @redo_stack.clear
824
+ end
825
+
826
+ clamp_mark!
827
+ end
828
+
829
+ def promote_to_cursor!
830
+ real_len = @text.length
831
+ return if @cursor <= real_len
832
+
833
+ full = virtual_text
834
+ @cursor = full.length if @cursor > full.length
835
+ promoted = full[0, @cursor] || ""
836
+ remaining = full[@cursor..] || ""
837
+
838
+ @text = promoted
839
+ @virtual_suffix = remaining
840
+ end
841
+
842
+ # This consolidates a number of actions of the same kind into a single
843
+ # undo step, especially insert benefits from this since you don't want
844
+ # each character typed to be a separate undo step.
845
+ def with_undo_coalesced(kind)
846
+ before = snapshot
847
+ chain = @undo_chain
848
+ start_new =
849
+ chain.nil? ||
850
+ chain[:kind] != kind ||
851
+ chain[:cursor0] != @cursor # cursor must be where the chain expects
852
+
853
+ if start_new
854
+ @undo_stack << before
855
+ @undo_stack.shift while @undo_stack.length > @undo_limit
856
+ @redo_stack.clear
857
+ @undo_chain = { kind: kind, cursor0: @cursor }
858
+ end
859
+
860
+ yield
861
+
862
+ # update expected cursor for continued typing
863
+ @undo_chain[:cursor0] = @cursor if @undo_chain
864
+ clamp_mark!
865
+ end
866
+
867
+ def break_undo_chain!
868
+ @undo_chain = nil
869
+ end
870
+
871
+ def snapshot
872
+ [@text.dup, @cursor]
873
+ end
874
+
875
+ def restore(snap)
876
+ @text, @cursor = snap
877
+ clamp_cursor!
878
+ @text
879
+ end
880
+
881
+ def normalize_count(count)
882
+ n =
883
+ begin
884
+ Integer(count)
885
+ rescue StandardError
886
+ 1
887
+ end
888
+ n = 1 if n < 1
889
+ n
890
+ end
891
+
892
+ def repeat(count = 1)
893
+ n = normalize_count(count)
894
+ result = nil
895
+ i = 0
896
+ while i < n
897
+ result = yield
898
+ i += 1
899
+ end
900
+ result
901
+ end
902
+
903
+ def clamp_range(range)
904
+ raise ArgumentError, "range required" unless range.is_a?(Range)
905
+
906
+ len = text.length
907
+ s = range.begin.to_i
908
+ e = range.end.to_i
909
+ e += 1 unless range.exclude_end?
910
+
911
+ s = 0 if s < 0
912
+ s = len if s > len
913
+ e = 0 if e < 0
914
+ e = len if e > len
915
+ e = s if e < s
916
+
917
+ s...e
918
+ end
919
+
920
+ def word_span_forward(from = cursor)
921
+ chars = text.chars
922
+ i = from
923
+
924
+ i += 1 while i < chars.length && !word_char?(chars[i])
925
+ i += 1 while i < chars.length && word_char?(chars[i])
926
+
927
+ from...i
928
+ end
929
+
930
+ def word_span_backward(from = cursor)
931
+ chars = text.chars
932
+ i = from - 1
933
+ return from...from if i < 0
934
+
935
+ # skip non-word
936
+ i -= 1 while i > 0 && !word_char?(chars[i])
937
+ # skip word
938
+ i -= 1 while i > 0 && word_char?(chars[i])
939
+
940
+ start = i == 0 && word_char?(chars[i]) ? 0 : i + 1
941
+ start...from
942
+ end
943
+
944
+ def clamp_cursor!
945
+ @cursor = 0 if @cursor.negative?
946
+ max = @text.length
947
+ @cursor = max if @cursor > max
948
+ end
949
+
950
+ def clamp_mark!
951
+ if @mark
952
+ @mark = 0 if @mark.negative?
953
+ max = @text.length
954
+ @mark = max if @mark > max
955
+ end
956
+ end
957
+
958
+ def adjust_mark_for_replace_span!(start, removed_len, inserted_len)
959
+ return unless @mark
960
+
961
+ s = start.to_i
962
+ rem = removed_len.to_i
963
+ ins = inserted_len.to_i
964
+ delta = ins - rem
965
+
966
+ if s <= @mark
967
+ if @mark < s + rem
968
+ # mark was inside removed region; snap to end of inserted region
969
+ @mark = s + ins
970
+ else
971
+ @mark += delta
972
+ end
973
+ end
974
+
975
+ clamp_mark!
976
+ end
977
+
978
+ def push_kill(str, append: true)
979
+ s = str.to_s
980
+ return if s.empty?
981
+
982
+ if @last_action == :kill && !@kill_ring.empty?
983
+ @kill_ring[0] = if append
984
+ @kill_ring[0] + s
985
+ else
986
+ s + @kill_ring[0]
987
+ end
988
+ else
989
+ @kill_ring.unshift(s)
990
+ end
991
+ @kill_ring.pop while @kill_ring.length > @kill_ring_max
992
+ end
993
+
994
+ def word_char?(ch)
995
+ @word_re.match?(ch)
996
+ end
997
+ end
998
+ end