echoes 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class CopyMode
5
+ attr_reader :active, :cursor_row, :cursor_col
6
+ attr_reader :selection_start, :selection_end
7
+
8
+ def initialize(screen)
9
+ @screen = screen
10
+ @active = false
11
+ @cursor_row = 0
12
+ @cursor_col = 0
13
+ @selection_start = nil
14
+ @selection_end = nil
15
+ @search_query = nil
16
+ @search_direction = :forward
17
+ @pending_find = nil # :f, :F, :t, :T
18
+ @last_find = nil # [direction, char] for ; and ,
19
+ @line_selection = false
20
+ end
21
+
22
+ def enter
23
+ @active = true
24
+ @cursor_row = @screen.cursor.row
25
+ @cursor_col = @screen.cursor.col
26
+ @selection_start = nil
27
+ @selection_end = nil
28
+ @search_query = nil
29
+ end
30
+
31
+ def exit
32
+ @active = false
33
+ @selection_start = nil
34
+ @selection_end = nil
35
+ @line_selection = false
36
+ @search_query = nil
37
+ end
38
+
39
+ def selecting?
40
+ @selection_start != nil
41
+ end
42
+
43
+ # Handle a key in copy mode. Returns :exit if copy mode should end,
44
+ # :yank if text was yanked, nil otherwise.
45
+ def handle_key(key)
46
+ # Pending f/F/t/T — next key is the target character
47
+ if @pending_find
48
+ execute_find(@pending_find, key)
49
+ @pending_find = nil
50
+ update_selection_end if selecting?
51
+ return nil
52
+ end
53
+
54
+ case key
55
+ # Basic movement
56
+ when 'h'
57
+ move_cursor(0, -1)
58
+ when 'j'
59
+ move_cursor(1, 0)
60
+ when 'k'
61
+ move_cursor(-1, 0)
62
+ when 'l'
63
+ move_cursor(0, 1)
64
+
65
+ # Line positions
66
+ when '0'
67
+ @cursor_col = 0
68
+ when '^'
69
+ move_first_non_blank
70
+ when '$'
71
+ move_end_of_line
72
+
73
+ # Word motions (vim-style: word = keyword chars)
74
+ when 'w'
75
+ move_word_forward
76
+ when 'e'
77
+ move_word_end_forward
78
+ when 'b'
79
+ move_word_backward
80
+
81
+ # WORD motions (whitespace-delimited)
82
+ when 'W'
83
+ move_bigword_forward
84
+ when 'E'
85
+ move_bigword_end_forward
86
+ when 'B'
87
+ move_bigword_backward
88
+
89
+ # Find in line
90
+ when 'f'
91
+ @pending_find = :f
92
+ when 'F'
93
+ @pending_find = :F
94
+ when 't'
95
+ @pending_find = :t
96
+ when 'T'
97
+ @pending_find = :T
98
+ when ';'
99
+ repeat_find(:same)
100
+ when ','
101
+ repeat_find(:reverse)
102
+
103
+ # Screen-relative jumps
104
+ when 'H'
105
+ @cursor_row = visible_top_row
106
+ when 'M'
107
+ @cursor_row = visible_top_row + @screen.rows / 2
108
+ when 'L'
109
+ @cursor_row = visible_top_row + @screen.rows - 1
110
+
111
+ # Document jumps
112
+ when 'g'
113
+ @cursor_row = -@screen.scrollback.size
114
+ @cursor_col = 0
115
+ when 'G'
116
+ @cursor_row = @screen.rows - 1
117
+ @cursor_col = 0
118
+
119
+ # Paragraph motions
120
+ when '{'
121
+ move_paragraph_backward
122
+ when '}'
123
+ move_paragraph_forward
124
+
125
+ # Scrolling
126
+ when "\x15" # Ctrl-U
127
+ move_cursor(-(@screen.rows / 2), 0)
128
+ when "\x04" # Ctrl-D
129
+ move_cursor(@screen.rows / 2, 0)
130
+ when "\x02" # Ctrl-B
131
+ move_cursor(-@screen.rows, 0)
132
+ when "\x06" # Ctrl-F
133
+ move_cursor(@screen.rows, 0)
134
+
135
+ # Search
136
+ when '/'
137
+ @search_query = +""
138
+ @search_direction = :forward
139
+ when '?'
140
+ @search_query = +""
141
+ @search_direction = :backward
142
+ when 'n'
143
+ search_next
144
+ when 'N'
145
+ search_prev
146
+
147
+ # Selection & yank
148
+ when 'v'
149
+ start_selection
150
+ when 'V'
151
+ start_line_selection
152
+ when 'y'
153
+ return :yank if selecting?
154
+ when 'q', "\e"
155
+ self.exit
156
+ return :exit
157
+ end
158
+ update_selection_end if selecting?
159
+ nil
160
+ end
161
+
162
+ # Extract text from selection range
163
+ def selected_text
164
+ return "" unless @selection_start && @selection_end
165
+
166
+ start_pos, end_pos = [@selection_start, @selection_end].sort_by { |p| [p[0], p[1]] }
167
+ sr, sc = start_pos
168
+ er, ec = end_pos
169
+
170
+ lines = []
171
+ (sr..er).each do |row|
172
+ line = row_text(row)
173
+ if row == sr && row == er
174
+ lines << line[sc..ec]
175
+ elsif row == sr
176
+ lines << line[sc..]
177
+ elsif row == er
178
+ lines << line[0..ec]
179
+ else
180
+ lines << line
181
+ end
182
+ end
183
+ lines.join("\n")
184
+ end
185
+
186
+ # Scroll offset needed to keep cursor visible
187
+ def scroll_offset_for_cursor
188
+ if @cursor_row < 0
189
+ @cursor_row.abs
190
+ else
191
+ 0
192
+ end
193
+ end
194
+
195
+ # Absolute row in the combined scrollback+grid buffer
196
+ def absolute_cursor_row
197
+ @screen.scrollback.size + @cursor_row
198
+ end
199
+
200
+ private
201
+
202
+ def move_cursor(dr, dc)
203
+ new_row = @cursor_row + dr
204
+ new_col = @cursor_col + dc
205
+
206
+ min_row = -@screen.scrollback.size
207
+ max_row = @screen.rows - 1
208
+
209
+ @cursor_row = new_row.clamp(min_row, max_row)
210
+ @cursor_col = new_col.clamp(0, @screen.cols - 1)
211
+ end
212
+
213
+ # --- Character classification ---
214
+
215
+ def char_class(c)
216
+ if c.nil? || c.empty? || c == ' '
217
+ :space
218
+ elsif word_separators.include?(c)
219
+ :separator
220
+ else
221
+ :word
222
+ end
223
+ end
224
+
225
+ def word_separators
226
+ Echoes.config.word_separators
227
+ end
228
+
229
+ # --- Line positions ---
230
+
231
+ def move_first_non_blank
232
+ line = row_text(@cursor_row)
233
+ col = 0
234
+ col += 1 while col < @screen.cols && line[col] == ' '
235
+ @cursor_col = col.clamp(0, @screen.cols - 1)
236
+ end
237
+
238
+ def move_end_of_line
239
+ line = row_text(@cursor_row)
240
+ col = line.rstrip.length - 1
241
+ @cursor_col = col.clamp(0, @screen.cols - 1)
242
+ end
243
+
244
+ # --- Small word motions (vim 'word': same-class sequences) ---
245
+
246
+ def move_word_forward
247
+ line = row_text(@cursor_row)
248
+ col = @cursor_col
249
+ max = @screen.cols - 1
250
+
251
+ if col < max
252
+ cls = char_class(line[col])
253
+ # Skip rest of current word/separator run
254
+ col += 1 while col < max && char_class(line[col]) == cls
255
+ # Skip whitespace
256
+ col += 1 while col < max && char_class(line[col]) == :space
257
+ end
258
+
259
+ if col >= max && @cursor_row < @screen.rows - 1
260
+ # Wrap to next line's first non-blank
261
+ @cursor_row += 1
262
+ line = row_text(@cursor_row)
263
+ col = 0
264
+ col += 1 while col < max && char_class(line[col]) == :space
265
+ end
266
+
267
+ @cursor_col = col.clamp(0, max)
268
+ end
269
+
270
+ def move_word_end_forward
271
+ line = row_text(@cursor_row)
272
+ col = @cursor_col
273
+ max = @screen.cols - 1
274
+
275
+ # Move at least one position
276
+ col += 1 if col < max
277
+ line = row_text(@cursor_row)
278
+
279
+ # Skip whitespace
280
+ col += 1 while col < max && char_class(line[col]) == :space
281
+
282
+ # Move to end of word
283
+ cls = char_class(line[col])
284
+ col += 1 while col < max && char_class(line[col + 1]) == cls
285
+
286
+ @cursor_col = col.clamp(0, max)
287
+ end
288
+
289
+ def move_word_backward
290
+ line = row_text(@cursor_row)
291
+ col = @cursor_col
292
+
293
+ if col > 0
294
+ col -= 1
295
+ # Skip whitespace
296
+ col -= 1 while col > 0 && char_class(line[col]) == :space
297
+ # Skip to beginning of word
298
+ cls = char_class(line[col])
299
+ col -= 1 while col > 0 && char_class(line[col - 1]) == cls
300
+ end
301
+
302
+ if col <= 0 && @cursor_row > -@screen.scrollback.size
303
+ # Wrap to end of previous line
304
+ @cursor_row -= 1
305
+ line = row_text(@cursor_row)
306
+ col = line.rstrip.length - 1
307
+ col = 0 if col < 0
308
+ end
309
+
310
+ @cursor_col = col.clamp(0, @screen.cols - 1)
311
+ end
312
+
313
+ # --- Big WORD motions (whitespace-delimited) ---
314
+
315
+ def move_bigword_forward
316
+ line = row_text(@cursor_row)
317
+ col = @cursor_col
318
+ content_end = line.rstrip.length
319
+
320
+ if col < content_end
321
+ # Skip non-space
322
+ col += 1 while col < content_end && line[col] != ' '
323
+ # Skip space
324
+ col += 1 while col < content_end && line[col] == ' '
325
+ end
326
+
327
+ @cursor_col = col.clamp(0, @screen.cols - 1)
328
+ end
329
+
330
+ def move_bigword_end_forward
331
+ line = row_text(@cursor_row)
332
+ col = @cursor_col
333
+ max = @screen.cols - 1
334
+
335
+ col += 1 if col < max
336
+ # Skip space
337
+ col += 1 while col < max && line[col] == ' '
338
+ # Move to end of WORD
339
+ col += 1 while col < max && line[col + 1] && line[col + 1] != ' '
340
+
341
+ @cursor_col = col.clamp(0, max)
342
+ end
343
+
344
+ def move_bigword_backward
345
+ line = row_text(@cursor_row)
346
+ col = @cursor_col
347
+
348
+ col -= 1 if col > 0
349
+ # Skip space
350
+ col -= 1 while col > 0 && line[col] == ' '
351
+ # Skip to beginning of WORD
352
+ col -= 1 while col > 0 && line[col - 1] && line[col - 1] != ' '
353
+
354
+ @cursor_col = col.clamp(0, @screen.cols - 1)
355
+ end
356
+
357
+ # --- Find in line (f/F/t/T) ---
358
+
359
+ def execute_find(direction, char)
360
+ @last_find = [direction, char]
361
+ line = row_text(@cursor_row)
362
+
363
+ case direction
364
+ when :f
365
+ idx = line.index(char, @cursor_col + 1)
366
+ @cursor_col = idx if idx
367
+ when :F
368
+ idx = line.rindex(char, [@cursor_col - 1, 0].max)
369
+ @cursor_col = idx if idx && idx < @cursor_col
370
+ when :t
371
+ idx = line.index(char, @cursor_col + 1)
372
+ @cursor_col = idx - 1 if idx && idx > @cursor_col + 1
373
+ when :T
374
+ idx = line.rindex(char, [@cursor_col - 1, 0].max)
375
+ @cursor_col = idx + 1 if idx && idx + 1 < @cursor_col
376
+ end
377
+ end
378
+
379
+ def repeat_find(mode)
380
+ return unless @last_find
381
+
382
+ direction, char = @last_find
383
+ if mode == :reverse
384
+ direction = {:f => :F, :F => :f, :t => :T, :T => :t}[direction]
385
+ end
386
+ execute_find(direction, char)
387
+ end
388
+
389
+ # --- Paragraph motions ---
390
+
391
+ def move_paragraph_backward
392
+ row = @cursor_row - 1
393
+ min_row = -@screen.scrollback.size
394
+ # Skip non-blank lines
395
+ row -= 1 while row > min_row && !blank_row?(row)
396
+ # Skip blank lines
397
+ row -= 1 while row > min_row && blank_row?(row)
398
+ @cursor_row = row.clamp(min_row, @screen.rows - 1)
399
+ @cursor_col = 0
400
+ end
401
+
402
+ def move_paragraph_forward
403
+ row = @cursor_row + 1
404
+ max_row = @screen.rows - 1
405
+ # Skip non-blank lines
406
+ row += 1 while row < max_row && !blank_row?(row)
407
+ # Skip blank lines
408
+ row += 1 while row < max_row && blank_row?(row)
409
+ @cursor_row = row.clamp(-@screen.scrollback.size, max_row)
410
+ @cursor_col = 0
411
+ end
412
+
413
+ def blank_row?(row)
414
+ row_text(row).strip.empty?
415
+ end
416
+
417
+ # --- Search ---
418
+
419
+ def search_next
420
+ return unless @search_query && !@search_query.empty?
421
+ if @search_direction == :forward
422
+ search_forward
423
+ else
424
+ search_backward
425
+ end
426
+ end
427
+
428
+ def search_prev
429
+ return unless @search_query && !@search_query.empty?
430
+ if @search_direction == :forward
431
+ search_backward
432
+ else
433
+ search_forward
434
+ end
435
+ end
436
+
437
+ def search_forward
438
+ min_row = -@screen.scrollback.size
439
+ max_row = @screen.rows - 1
440
+
441
+ # Search from current position forward
442
+ ((@cursor_row)..max_row).each do |row|
443
+ line = row_text(row)
444
+ start = (row == @cursor_row) ? @cursor_col + 1 : 0
445
+ idx = line.index(@search_query, start)
446
+ if idx
447
+ @cursor_row = row
448
+ @cursor_col = idx
449
+ return
450
+ end
451
+ end
452
+
453
+ # Wrap around from top
454
+ (min_row...@cursor_row).each do |row|
455
+ line = row_text(row)
456
+ idx = line.index(@search_query)
457
+ if idx
458
+ @cursor_row = row
459
+ @cursor_col = idx
460
+ return
461
+ end
462
+ end
463
+ end
464
+
465
+ def search_backward
466
+ min_row = -@screen.scrollback.size
467
+ max_row = @screen.rows - 1
468
+
469
+ # Search from current position backward
470
+ @cursor_row.downto(min_row) do |row|
471
+ line = row_text(row)
472
+ limit = (row == @cursor_row) ? [@cursor_col - 1, 0].max : line.length
473
+ idx = line.rindex(@search_query, limit)
474
+ if idx && (row != @cursor_row || idx < @cursor_col)
475
+ @cursor_row = row
476
+ @cursor_col = idx
477
+ return
478
+ end
479
+ end
480
+
481
+ # Wrap around from bottom
482
+ max_row.downto(@cursor_row + 1) do |row|
483
+ line = row_text(row)
484
+ idx = line.rindex(@search_query)
485
+ if idx
486
+ @cursor_row = row
487
+ @cursor_col = idx
488
+ return
489
+ end
490
+ end
491
+ end
492
+
493
+ # --- Screen-relative ---
494
+
495
+ def visible_top_row
496
+ @cursor_row - @cursor_row.clamp(0, @screen.rows - 1)
497
+ end
498
+
499
+ # --- Selection ---
500
+
501
+ def start_selection
502
+ if selecting?
503
+ @selection_start = nil
504
+ @selection_end = nil
505
+ @line_selection = false
506
+ else
507
+ @selection_start = [@cursor_row, @cursor_col]
508
+ @selection_end = [@cursor_row, @cursor_col]
509
+ @line_selection = false
510
+ end
511
+ end
512
+
513
+ def start_line_selection
514
+ if selecting?
515
+ @selection_start = nil
516
+ @selection_end = nil
517
+ @line_selection = false
518
+ else
519
+ @selection_start = [@cursor_row, 0]
520
+ @selection_end = [@cursor_row, @screen.cols - 1]
521
+ @line_selection = true
522
+ end
523
+ end
524
+
525
+ def update_selection_end
526
+ if @line_selection
527
+ @selection_start = [[@selection_start[0], @cursor_row].min, 0]
528
+ @selection_end = [[@selection_end[0], @cursor_row].max, @screen.cols - 1]
529
+ else
530
+ @selection_end = [@cursor_row, @cursor_col]
531
+ end
532
+ end
533
+
534
+ def row_text(row)
535
+ if row < 0
536
+ sb_index = @screen.scrollback.size + row
537
+ return "" if sb_index < 0 || sb_index >= @screen.scrollback.size
538
+ @screen.scrollback[sb_index].map(&:char).join
539
+ else
540
+ return "" if row >= @screen.rows
541
+ @screen.grid[row].map(&:char).join
542
+ end
543
+ end
544
+ end
545
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ class Cursor
5
+ attr_accessor :row, :col, :visible
6
+
7
+ def initialize(row: 0, col: 0)
8
+ @row = row
9
+ @col = col
10
+ @visible = true
11
+ end
12
+
13
+ def move_to(row, col)
14
+ @row = row
15
+ @col = col
16
+ end
17
+ end
18
+ end