hyperlist 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.
data/hyperlist ADDED
@@ -0,0 +1,3874 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ # HyperList TUI - A terminal interface for HyperList files
5
+ # Based on hyperlist.vim by Geir Isene
6
+
7
+ # Check for help/version BEFORE loading any libraries
8
+ if ARGV[0] == '-h' || ARGV[0] == '--help'
9
+ puts <<~HELP
10
+ HyperList v1.0.1 - Terminal User Interface for HyperList files
11
+
12
+ USAGE
13
+ hyperlist [OPTIONS] [FILE]
14
+
15
+ OPTIONS
16
+ -h, --help Show this help message
17
+ -v, --version Show version information
18
+
19
+ ARGUMENTS
20
+ FILE Optional HyperList file to open (.hl extension)
21
+ If not provided, starts with a new empty HyperList
22
+
23
+ EXAMPLES
24
+ hyperlist Start with a new HyperList
25
+ hyperlist todo.hl Open todo.hl
26
+ hyperlist ~/lists/work.hl Open work.hl from home directory
27
+
28
+ KEY COMMANDS
29
+ ? Show key bindings help
30
+ ?? Show full HyperList documentation
31
+ q Quit (with save prompt if modified)
32
+ :w Save file
33
+ :q Quit
34
+ :wq Save and quit
35
+
36
+ FEATURES
37
+ • Full HyperList syntax support with color highlighting
38
+ • Folding/expanding of hierarchical items
39
+ • Search and navigation
40
+ • Checkboxes with date tracking
41
+ • Templates for common list structures
42
+ • Macro recording and playback
43
+ • Split view for working with multiple sections
44
+ • Export to Markdown, HTML, plain text, and PNG graphs
45
+ • Auto-save capability
46
+ • Multi-level undo
47
+ • Vim-style marks and jumping
48
+
49
+ For more information and HyperList documentation, visit:
50
+ https://github.com/isene/hyperlist
51
+
52
+ HELP
53
+ exit 0
54
+ elsif ARGV[0] == '-v' || ARGV[0] == '--version'
55
+ puts "HyperList v1.0.1"
56
+ exit 0
57
+ end
58
+
59
+ # Only load libraries if we're actually running the app
60
+ require 'io/console'
61
+ require 'date'
62
+ require 'rcurses'
63
+ require 'cgi'
64
+
65
+ class HyperListApp
66
+ include Rcurses
67
+ include Rcurses::Input
68
+ include Rcurses::Cursor
69
+
70
+ VERSION = "1.0.1"
71
+
72
+ def initialize(filename = nil)
73
+ @filename = filename ? File.expand_path(filename) : nil
74
+ @items = []
75
+ @current = 0
76
+ @offset = 0
77
+ @modified = false
78
+ @mode = :normal
79
+ @message = ""
80
+ @search = ""
81
+ @search_matches = [] # Track search match positions
82
+ @fold_level = 99
83
+ @clipboard = nil
84
+ @undo_stack = []
85
+ @undo_position = [] # Stack of cursor positions for undo
86
+ @redo_stack = []
87
+ @redo_position = [] # Stack of cursor positions for redo
88
+ @last_action = nil # Store the last editing action for '.' repeat
89
+ @last_action_type = nil # Type of last action
90
+ @processed_cache = {} # Cache for processed text
91
+ @presentation_mode = false # Presentation mode flag
92
+ @last_key = nil # Track last key for double-key combos
93
+ @last_rendered_lines = [] # Track last rendered lines
94
+ @recent_files_path = File.expand_path("~/.hyperlist_recent")
95
+ @auto_save_enabled = false
96
+ @auto_save_interval = 60 # seconds
97
+ @last_auto_save = Time.now
98
+ @templates = load_templates
99
+ @macro_recording = false
100
+ @macro_register = {} # Store macros by key
101
+ @macro_buffer = [] # Current macro being recorded
102
+ @macro_key = nil # Key for current macro
103
+ @split_view = false
104
+ @split_items = [] # Second view items
105
+ @split_current = 0 # Second view cursor
106
+ @split_offset = 0 # Second view scroll offset
107
+ @active_pane = :main # :main or :split
108
+ @message_timeout = nil # For timed message display
109
+
110
+ # Terminal setup
111
+ if IO.console
112
+ @rows, @cols = IO.console.winsize
113
+ else
114
+ @rows = ENV['LINES']&.to_i || 24
115
+ @cols = ENV['COLUMNS']&.to_i || 80
116
+ end
117
+
118
+ # Debug: uncomment to see terminal size
119
+ # puts "Terminal size: #{@rows}x#{@cols}"
120
+
121
+ # Load file if provided
122
+ if @filename && File.exist?(@filename)
123
+ load_file(@filename)
124
+ else
125
+ # Start with empty list
126
+ @items = [{"text" => "New HyperList", "level" => 0, "fold" => false}]
127
+ end
128
+
129
+ setup_ui
130
+ end
131
+
132
+ def setup_ui
133
+ Rcurses.clear_screen
134
+ Cursor.hide
135
+
136
+ # Ensure we have the latest terminal size
137
+ if IO.console
138
+ @rows, @cols = IO.console.winsize
139
+ else
140
+ @rows = ENV['LINES']&.to_i || 24
141
+ @cols = ENV['COLUMNS']&.to_i || 80
142
+ end
143
+
144
+ if @split_view
145
+ # Split view layout - no header, more space for content
146
+ split_width = @cols / 2
147
+ # Main content panes - full height minus 1 for footer
148
+ @main = Pane.new(0, 0, split_width - 1, @rows - 1, 15, 0)
149
+ @split_pane = Pane.new(split_width + 1, 0, split_width - 1, @rows - 1, 15, 0)
150
+ # Footer: Use @rows for y-position (this puts it at the actual bottom)
151
+ @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
152
+
153
+ # Add separator
154
+ @separator = Pane.new(split_width, 0, 1, @rows - 1, 15, 8)
155
+ @separator.text = "│" * (@rows - 1)
156
+ @separator.refresh
157
+ else
158
+ # Single view layout - no header, more space for content
159
+ # Main pane uses full height minus 1 for footer
160
+ @main = Pane.new(0, 0, @cols, @rows - 1, 15, 0)
161
+ # Footer: Use @rows for y-position (this puts it at the actual bottom)
162
+ @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
163
+ end
164
+ end
165
+
166
+ def load_file(file)
167
+ @items = []
168
+ lines = File.readlines(file) rescue []
169
+
170
+ # Clear undo/redo stacks when loading a new file
171
+ @undo_stack = []
172
+ @undo_position = []
173
+ @redo_stack = []
174
+ @redo_position = []
175
+
176
+ # Check if file is large
177
+ large_file = lines.length > 10000
178
+
179
+ if large_file
180
+ @message = "Loading large file (#{lines.length} lines)..."
181
+ render_footer
182
+ end
183
+
184
+ # Process lines with optional progress updates for large files
185
+ lines.each_with_index do |line, idx|
186
+ next if line.strip.empty?
187
+ level = line[/^\t*/].length
188
+ text = line.strip
189
+ @items << {"text" => text, "level" => level, "fold" => false}
190
+
191
+ # Update progress for large files
192
+ if large_file && idx % 1000 == 0
193
+ progress = (idx.to_f / lines.length * 100).to_i
194
+ @message = "Loading... #{progress}%"
195
+ render_footer
196
+ end
197
+ end
198
+
199
+ @items = [{"text" => "Empty file", "level" => 0, "fold" => false}] if @items.empty?
200
+
201
+ # Auto-fold deep levels for large files
202
+ if large_file
203
+ auto_fold_deep_levels(3) # Auto-fold everything deeper than level 3
204
+ @message = "Large file loaded. Deep levels auto-folded for performance."
205
+ end
206
+
207
+ # Update recent files list
208
+ add_to_recent_files(File.expand_path(file)) if file
209
+ end
210
+
211
+ def auto_fold_deep_levels(max_level)
212
+ @items.each_with_index do |item, idx|
213
+ if item["level"] >= max_level && has_children?(idx, @items)
214
+ item["fold"] = true
215
+ end
216
+ end
217
+ end
218
+
219
+ def add_to_recent_files(filepath)
220
+ return unless filepath && File.exist?(filepath)
221
+
222
+ # Load existing recent files
223
+ recent = load_recent_files
224
+
225
+ # Remove if already exists and add to front
226
+ recent.delete(filepath)
227
+ recent.unshift(filepath)
228
+
229
+ # Keep only last 20 files
230
+ recent = recent[0...20]
231
+
232
+ # Save back to file
233
+ File.open(@recent_files_path, 'w') do |f|
234
+ recent.each { |path| f.puts(path) }
235
+ end
236
+ rescue => e
237
+ # Silently fail if can't write recent files
238
+ end
239
+
240
+ def load_recent_files
241
+ return [] unless File.exist?(@recent_files_path)
242
+
243
+ File.readlines(@recent_files_path).map(&:strip).select do |path|
244
+ File.exist?(path)
245
+ end
246
+ rescue
247
+ []
248
+ end
249
+
250
+ def show_recent_files
251
+ recent = load_recent_files
252
+
253
+ if recent.empty?
254
+ @message = "No recent files"
255
+ return
256
+ end
257
+
258
+ # Save current state
259
+ saved_items = @items.dup
260
+ saved_current = @current
261
+ saved_offset = @offset
262
+ saved_filename = @filename
263
+ saved_modified = @modified
264
+
265
+ # Create items for recent files display
266
+ @items = []
267
+ @items << {"text" => "RECENT FILES (press Enter to open, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
268
+ @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
269
+
270
+ recent.each_with_index do |path, idx|
271
+ display_path = path.sub(ENV['HOME'], '~') if ENV['HOME']
272
+ display_path ||= path
273
+ @items << {"text" => "#{idx+1}. #{display_path}", "level" => 0, "fold" => false, "path" => path, "raw" => true}
274
+ end
275
+
276
+ @current = 2 # Start at first file
277
+ @offset = 0
278
+ @modified = false
279
+
280
+ # Recent files viewer loop
281
+ loop do
282
+ render_main
283
+ @footer.text = "Recent Files | Enter: open | q: cancel | j/k: navigate"
284
+ @footer.refresh
285
+
286
+ c = getchr
287
+ case c
288
+ when "q", "ESC"
289
+ # Restore original state
290
+ @items = saved_items
291
+ @current = saved_current
292
+ @offset = saved_offset
293
+ @filename = saved_filename
294
+ @modified = saved_modified
295
+ break
296
+ when "j", "DOWN"
297
+ move_down if @current < @items.length - 1
298
+ when "k", "UP"
299
+ move_up if @current > 2 # Don't go above first file
300
+ when "ENTER", "l"
301
+ if @current >= 2 && @items[@current]["path"]
302
+ selected_file = @items[@current]["path"]
303
+ if saved_modified
304
+ @items = saved_items
305
+ @current = saved_current
306
+ @offset = saved_offset
307
+ @filename = saved_filename
308
+ @modified = saved_modified
309
+ @message = "Unsaved changes! Save first before opening another file"
310
+ break
311
+ else
312
+ @filename = File.expand_path(selected_file)
313
+ load_file(@filename)
314
+ @current = 0
315
+ @offset = 0
316
+ @modified = false
317
+ break
318
+ end
319
+ end
320
+ when /^[1-9]$/
321
+ # Allow number key selection
322
+ idx = c.to_i - 1
323
+ if idx < recent.length
324
+ selected_file = recent[idx]
325
+ if saved_modified
326
+ @items = saved_items
327
+ @current = saved_current
328
+ @offset = saved_offset
329
+ @filename = saved_filename
330
+ @modified = saved_modified
331
+ @message = "Unsaved changes! Save first before opening another file"
332
+ break
333
+ else
334
+ @filename = File.expand_path(selected_file)
335
+ load_file(@filename)
336
+ @current = 0
337
+ @offset = 0
338
+ @modified = false
339
+ break
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+
346
+ def save_file
347
+ return unless @filename
348
+
349
+ File.open(@filename, 'w') do |f|
350
+ @items.each do |item|
351
+ f.puts("\t" * item["level"] + item["text"])
352
+ end
353
+ end
354
+ @modified = false
355
+ @message = "Saved to #{@filename}"
356
+ @last_auto_save = Time.now if @auto_save_enabled
357
+ end
358
+
359
+ def check_auto_save
360
+ return unless @filename && @modified
361
+
362
+ if Time.now - @last_auto_save >= @auto_save_interval
363
+ save_file
364
+ @message = "Auto-saved to #{@filename}"
365
+ @last_auto_save = Time.now
366
+ end
367
+ end
368
+
369
+ def export_to(format, export_file = nil)
370
+ # Generate default filename if not provided
371
+ if export_file.nil? || export_file.empty?
372
+ base = @filename ? File.basename(@filename, '.*') : 'hyperlist'
373
+ extension = case format
374
+ when 'md', 'markdown' then '.md'
375
+ when 'html' then '.html'
376
+ when 'txt', 'text' then '.txt'
377
+ end
378
+ export_file = base + extension
379
+ end
380
+
381
+ content = case format
382
+ when 'md', 'markdown'
383
+ export_to_markdown
384
+ when 'html'
385
+ export_to_html
386
+ when 'txt', 'text'
387
+ export_to_text
388
+ end
389
+
390
+ File.open(export_file, 'w') { |f| f.write(content) }
391
+ @message = "Exported to #{export_file}"
392
+ rescue => e
393
+ @message = "Export failed: #{e.message}"
394
+ end
395
+
396
+ def export_to_markdown
397
+ lines = []
398
+ @items.each do |item|
399
+ text = item["text"]
400
+ level = item["level"]
401
+
402
+ # Convert HyperList bold/italic to Markdown format
403
+ # In HyperList: *text* is bold, /text/ is italic, _text_ is underline
404
+ # In Markdown: **text** is bold, *text* is italic, no standard underline
405
+
406
+ # First, temporarily replace escaped characters to avoid conflicts
407
+ text = text.gsub('\\*', '\u0001')
408
+ .gsub('\\/', '\u0002')
409
+ .gsub('\\_', '\u0003')
410
+
411
+ # Convert HyperList formatting to Markdown
412
+ # Bold: *text* -> **text**
413
+ text = text.gsub(/\*([^*]+)\*/, '**\1**')
414
+
415
+ # Italic: /text/ -> *text*
416
+ text = text.gsub(/\/([^\/]+)\//, '*\1*')
417
+
418
+ # Underline: _text_ -> ***text*** (bold+italic as markdown doesn't have underline)
419
+ text = text.gsub(/_([^_]+)_/, '***\1***')
420
+
421
+ # Restore escaped characters
422
+ text = text.gsub('\u0001', '\\*')
423
+ .gsub('\u0002', '\\/')
424
+ .gsub('\u0003', '\\_')
425
+
426
+ # Convert checkboxes to markdown format
427
+ text = text.gsub(/^\[X\]/i, '- [x]')
428
+ .gsub(/^\[O\]/, '- [ ] *(in progress)*')
429
+ .gsub(/^\[-\]/, '- [ ] *(partial)*')
430
+ .gsub(/^\[ \]/, '- [ ]')
431
+ .gsub(/^\[_\]/, '- [ ]')
432
+
433
+ # Handle different levels with proper markdown indentation
434
+ if level == 0 && !text.start_with?('- [')
435
+ lines << "# #{text}" unless text.empty?
436
+ elsif level == 1 && !text.start_with?('- [')
437
+ lines << "## #{text}" unless text.empty?
438
+ else
439
+ # Use spaces for indentation in lists
440
+ indent = ' ' * [level - 2, 0].max
441
+ if text.start_with?('- [')
442
+ lines << indent + text
443
+ else
444
+ lines << indent + "- #{text}"
445
+ end
446
+ end
447
+ end
448
+ lines.join("\n")
449
+ end
450
+
451
+ def export_to_html
452
+ html = <<~HTML
453
+ <!DOCTYPE html>
454
+ <html>
455
+ <head>
456
+ <meta charset="UTF-8">
457
+ <title>#{@filename ? File.basename(@filename, '.*') : 'HyperList'}</title>
458
+ <style>
459
+ body {
460
+ font-family: 'Courier New', monospace;
461
+ margin: 20px;
462
+ background-color: white;
463
+ color: #333;
464
+ white-space: pre;
465
+ line-height: 1.3;
466
+ }
467
+ /* Based on HyperList LaTeX color definitions */
468
+ .checked { color: #00cc00; } /* bright green */
469
+ .in-progress { color: #00cc00; } /* bright green */
470
+ .partial { color: #008800; } /* medium green */
471
+ .unchecked { color: #004400; } /* dark green */
472
+ .qualifier { color: #008000; } /* green */
473
+ .operator { color: #0000cc; font-weight: bold; } /* blue */
474
+ .property { color: #cc0000; } /* red */
475
+ .reference { color: #800080; } /* violet */
476
+ .tag { color: #cc9900; } /* orange */
477
+ .timestamp { color: #cc0000; } /* red - it's a property */
478
+ .comment { color: #008080; font-style: italic; } /* turquoise */
479
+ strong { font-weight: bold; }
480
+ em { font-style: italic; }
481
+ u { text-decoration: underline; }
482
+ </style>
483
+ </head>
484
+ <body>
485
+ HTML
486
+
487
+ @items.each do |item|
488
+ original_text = item["text"]
489
+ level = item["level"]
490
+ indent = " " * level
491
+
492
+ # Comments (start with semicolon)
493
+ if original_text.start_with?(';')
494
+ # Escape HTML for comments
495
+ text = original_text.gsub('&', '&amp;')
496
+ .gsub('<', '&lt;')
497
+ .gsub('>', '&gt;')
498
+ .gsub('"', '&quot;')
499
+ .gsub("'", '&#39;')
500
+ html += indent + '<span class="comment">' + text + '</span>' + "\n"
501
+ else
502
+ # Process non-comment lines
503
+ # We'll apply formatting BEFORE escaping HTML entities
504
+ text = original_text
505
+
506
+ # Apply all formatting with placeholder markers
507
+ # Use unique markers that won't appear in normal text
508
+
509
+ # Markdown formatting MUST come first before other replacements
510
+ # Bold *text*
511
+ text = text.gsub(/\*([^*]+)\*/) do
512
+ "\u0001BOLDSTART\u0002#{$1}\u0001BOLDEND\u0002"
513
+ end
514
+
515
+ # Italic /text/
516
+ text = text.gsub(/\/([^\/]+)\//) do
517
+ "\u0001ITALICSTART\u0002#{$1}\u0001ITALICEND\u0002"
518
+ end
519
+
520
+ # Underline _text_
521
+ text = text.gsub(/_([^_]+)_/) do
522
+ "\u0001UNDERSTART\u0002#{$1}\u0001UNDEREND\u0002"
523
+ end
524
+
525
+ # Checkboxes at start
526
+ text = text.sub(/^\[X\]/i, "\u0001CBCHECKED\u0002")
527
+ .sub(/^\[O\]/, "\u0001CBPROGRESS\u0002")
528
+ .sub(/^\[-\]/, "\u0001CBPARTIAL\u0002")
529
+ .sub(/^\[ \]/, "\u0001CBUNCHECKED\u0002")
530
+ .sub(/^\[_\]/, "\u0001CBUNCHECKED2\u0002")
531
+
532
+ # Timestamps
533
+ text = text.gsub(/(\d{4}-\d{2}-\d{2} \d{2}\.\d{2}):/) do
534
+ "\u0001TSSTART\u0002#{$1}\u0001TSEND\u0002:"
535
+ end
536
+
537
+ # Qualifiers [text] - but not checkboxes
538
+ unless text.include?("\u0001CB")
539
+ text = text.gsub(/\[([^\]]+)\]/) do
540
+ "\u0001QSTART\u0002[#{$1}]\u0001QEND\u0002"
541
+ end
542
+ end
543
+
544
+ # References <text>
545
+ text = text.gsub(/<([^>]+)>/) do
546
+ "\u0001REFSTART\u0002<#{$1}>\u0001REFEND\u0002"
547
+ end
548
+
549
+ # Properties (Word: )
550
+ text = text.gsub(/\b([A-Z][a-z][a-zA-Z]*): /) do
551
+ "\u0001PROPSTART\u0002#{$1}: \u0001PROPEND\u0002"
552
+ end
553
+
554
+ # Operators (CAPS:)
555
+ text = text.gsub(/\b([A-Z][A-Z]+):/) do
556
+ "\u0001OPSTART\u0002#{$1}:\u0001OPEND\u0002"
557
+ end
558
+
559
+ # Hash tags
560
+ text = text.gsub(/#(\w+)/) do
561
+ "\u0001TAGSTART\u0002##{$1}\u0001TAGEND\u0002"
562
+ end
563
+
564
+ # Now escape HTML entities
565
+ text = text.gsub('&', '&amp;')
566
+ .gsub('<', '&lt;')
567
+ .gsub('>', '&gt;')
568
+ .gsub('"', '&quot;')
569
+ .gsub("'", '&#39;')
570
+
571
+ # Replace markers with HTML tags
572
+ text = text.gsub("\u0001CBCHECKED\u0002", '<span class="checked">[X]</span>')
573
+ .gsub("\u0001CBPROGRESS\u0002", '<span class="in-progress">[O]</span>')
574
+ .gsub("\u0001CBPARTIAL\u0002", '<span class="partial">[-]</span>')
575
+ .gsub("\u0001CBUNCHECKED\u0002", '<span class="unchecked">[ ]</span>')
576
+ .gsub("\u0001CBUNCHECKED2\u0002", '<span class="unchecked">[_]</span>')
577
+ .gsub("\u0001TSSTART\u0002", '<span class="timestamp">')
578
+ .gsub("\u0001TSEND\u0002", '</span>')
579
+ .gsub("\u0001QSTART\u0002", '<span class="qualifier">')
580
+ .gsub("\u0001QEND\u0002", '</span>')
581
+ .gsub("\u0001REFSTART\u0002", '<span class="reference">')
582
+ .gsub("\u0001REFEND\u0002", '</span>')
583
+ .gsub("\u0001PROPSTART\u0002", '<span class="property">')
584
+ .gsub("\u0001PROPEND\u0002", '</span>')
585
+ .gsub("\u0001OPSTART\u0002", '<span class="operator">')
586
+ .gsub("\u0001OPEND\u0002", '</span>')
587
+ .gsub("\u0001TAGSTART\u0002", '<span class="tag">')
588
+ .gsub("\u0001TAGEND\u0002", '</span>')
589
+ .gsub("\u0001BOLDSTART\u0002", '<strong>')
590
+ .gsub("\u0001BOLDEND\u0002", '</strong>')
591
+ .gsub("\u0001ITALICSTART\u0002", '<em>')
592
+ .gsub("\u0001ITALICEND\u0002", '</em>')
593
+ .gsub("\u0001UNDERSTART\u0002", '<u>')
594
+ .gsub("\u0001UNDEREND\u0002", '</u>')
595
+
596
+ html += indent + text + "\n"
597
+ end
598
+ end
599
+
600
+ html += "</body>\n</html>"
601
+ html
602
+ end
603
+
604
+ def export_to_text
605
+ lines = []
606
+ @items.each do |item|
607
+ indent = ' ' * item["level"] # 4 spaces per level
608
+ lines << indent + item["text"]
609
+ end
610
+ lines.join("\n")
611
+ end
612
+
613
+ def render
614
+ render_main
615
+ render_split_pane if @split_view
616
+ render_footer
617
+ end
618
+
619
+
620
+ def render_main
621
+ visible_items = get_visible_items
622
+
623
+ # Calculate window
624
+ view_height = @main.h
625
+ if @current < @offset
626
+ @offset = @current
627
+ elsif @current >= @offset + view_height
628
+ @offset = @current - view_height + 1
629
+ end
630
+
631
+ # Track if we're in a literal block
632
+ in_literal_block = false
633
+ literal_start_level = -1
634
+
635
+ # Build display lines only for visible portion
636
+ lines = []
637
+ start_idx = @offset
638
+ end_idx = [@offset + view_height, visible_items.length].min
639
+
640
+ # For very large files, limit cache size and clear old entries
641
+ if visible_items.length > 5000 && @processed_cache.size > 2000
642
+ # Keep only recent entries
643
+ @processed_cache.clear
644
+ end
645
+
646
+ # Only process visible items
647
+ (start_idx...end_idx).each do |idx|
648
+ item = visible_items[idx]
649
+ next unless item
650
+
651
+ # Create cache key for this item
652
+ cache_key = "#{item['text']}_#{item['level']}_#{item['fold']}"
653
+
654
+ # Check cache first
655
+ if @processed_cache[cache_key] && idx != @current
656
+ lines << @processed_cache[cache_key]
657
+ else
658
+ line = " " * item["level"] # 4 spaces per level
659
+
660
+ # Add fold indicator
661
+ real_idx = @items.index(item) # Get the real index in the full array
662
+ if real_idx && has_children?(real_idx, @items) && item["fold"]
663
+ line += "▶".fg("245") + " " # Gray triangle for collapsed (has hidden children)
664
+ elsif real_idx && has_children?(real_idx, @items)
665
+ line += "▷".fg("245") + " " # Gray triangle for expanded (has visible children)
666
+ else
667
+ line += " "
668
+ end
669
+
670
+ # Check for literal block markers
671
+ if item["text"].strip == "\\"
672
+ if !in_literal_block
673
+ # Starting a literal block
674
+ in_literal_block = true
675
+ literal_start_level = item["level"]
676
+ # Preserve leading spaces and color the backslash
677
+ spaces = item["text"].match(/^(\s*)/)[1]
678
+ line += spaces + "\\".fg("3") # Yellow for literal marker
679
+ elsif item["level"] == literal_start_level
680
+ # Ending a literal block
681
+ in_literal_block = false
682
+ literal_start_level = -1
683
+ # Preserve leading spaces and color the backslash
684
+ spaces = item["text"].match(/^(\s*)/)[1]
685
+ line += spaces + "\\".fg("3") # Yellow for literal marker
686
+ else
687
+ # Backslash inside literal block - no highlighting
688
+ line += item["text"]
689
+ end
690
+ elsif in_literal_block
691
+ # Inside literal block - no syntax highlighting
692
+ line += item["text"]
693
+ else
694
+ # Normal text - apply syntax highlighting with caching
695
+ # Check if this line has a search match
696
+ has_match = @search_matches.include?(idx) && @search && !@search.empty?
697
+
698
+ # Check if this is raw text (for help/documentation screens)
699
+ if item["raw"]
700
+ line += item["text"]
701
+ else
702
+ processed_text_key = has_match ? "search_#{item['text']}_#{@search}" : "text_#{item['text']}"
703
+ if @processed_cache[processed_text_key] && !has_match
704
+ line += @processed_cache[processed_text_key]
705
+ else
706
+ processed = process_text(item["text"], has_match)
707
+ @processed_cache[processed_text_key] = processed if @processed_cache.size < 1000 && !has_match
708
+ line += processed
709
+ end
710
+ end
711
+ end
712
+
713
+ # Cache the line (without highlight)
714
+ @processed_cache[cache_key] = line if idx != @current
715
+
716
+ # Highlight current line
717
+ if idx == @current
718
+ line = line.r # Always use reverse (white background) for current line
719
+ end
720
+
721
+ lines << line
722
+ end
723
+ end
724
+
725
+ # Only update if content changed
726
+ new_content = lines.join("\n")
727
+ if @last_rendered_content != new_content
728
+ @main.text = new_content
729
+ @main.refresh
730
+ @last_rendered_content = new_content
731
+ end
732
+ end
733
+
734
+ def process_text(text, highlight_search = false)
735
+ # Work with a clean copy
736
+ result = text.dup
737
+ processed_checkbox = false
738
+
739
+ # If text already contains ANSI codes, return as-is to avoid double-processing
740
+ if result.include?("\e[")
741
+ return result
742
+ end
743
+
744
+ # Check if this is a literal block marker (single backslash)
745
+ if result.strip == "\\"
746
+ return result.fg("3") # Yellow for literal block markers
747
+ end
748
+
749
+ # Apply search highlighting if we have an active search
750
+ if highlight_search && @search && !@search.empty?
751
+ # Find all occurrences of the search term (case insensitive)
752
+ search_regex = Regexp.new(Regexp.escape(@search), Regexp::IGNORECASE)
753
+ result.gsub!(search_regex) { |match| match.bg("226").fg("0") } # Yellow background, black text
754
+ end
755
+
756
+ # Handle identifiers at the beginning (like "1.1.1.1" or "1A1A")
757
+ # Based on hyperlist.vim: '^\(\t\|\*\)*[0-9.]* '
758
+ if result =~ /^([0-9][0-9A-Z.]*\s)/
759
+ identifier = $1
760
+ result = result.sub(/^[0-9][0-9A-Z.]*\s/, identifier.fg("5")) # Magenta for identifiers
761
+ end
762
+
763
+ # Handle multi-line indicator at the beginning (+ with space)
764
+ # Based on hyperlist.vim: '^\(\t\|\*\)*+ '
765
+ if result =~ /^\+\s/
766
+ result = result.sub(/^(\+\s)/, "+".fg("1") + " ") # Red for multi-line indicator
767
+ end
768
+
769
+ # Handle continuation markers (+ at start of indented lines in References section)
770
+ if result =~ /^\s*\+\s/
771
+ spaces = $1 || ""
772
+ marker = $2 || "+ "
773
+ result = result.sub(/^(\s*)(\+\s)/, spaces + marker.fg("1")) # Red for continuation marker
774
+ end
775
+
776
+ # Process checkboxes anywhere in the line (can have leading spaces)
777
+ if result =~ /^(\s*)(\[X\]|\[x\])/
778
+ spaces = $1
779
+ colored = "[X]".fg("10")
780
+ result = result.sub(/^(\s*)(\[X\]|\[x\])/, "#{spaces}#{colored}") # Bright green for completed
781
+ processed_checkbox = true
782
+ elsif result =~ /^(\s*)(\[O\])/
783
+ spaces = $1
784
+ colored = "[O]".fg("10").b
785
+ result = result.sub(/^(\s*)(\[O\])/, "#{spaces}#{colored}") # Bold bright green for in-progress
786
+ processed_checkbox = true
787
+ elsif result =~ /^(\s*)(\[-\])/
788
+ spaces = $1
789
+ colored = "[-]".fg("2")
790
+ result = result.sub(/^(\s*)(\[-\])/, "#{spaces}#{colored}") # Green for partial
791
+ processed_checkbox = true
792
+ elsif result =~ /^(\s*)(\[ \]|\[_\])/
793
+ spaces = $1
794
+ colored = "[ ]".fg("22")
795
+ result = result.sub(/^(\s*)(\[ \]|\[_\])/, "#{spaces}#{colored}") # Dark green for unchecked
796
+ processed_checkbox = true
797
+ elsif !processed_checkbox
798
+ # Only handle other qualifiers if we didn't process a checkbox
799
+ # Based on hyperlist.vim: '\[.\{-}\]'
800
+ result.gsub!(/\[([^\]]*)\]/) { "[#{$1}]".fg("2") } # Green for all qualifiers
801
+ end
802
+
803
+ # We'll handle parentheses AFTER operators/properties to avoid conflicts
804
+
805
+ # Handle date timestamps as properties (for checkbox dates)
806
+ # Format: YYYY-MM-DD HH.MM:
807
+ result.gsub!(/(\d{4}-\d{2}-\d{2} \d{2}\.\d{2}):/) do
808
+ "#{$1}:".fg("1") # Red for timestamp properties
809
+ end
810
+
811
+ # Handle operators and properties with colon pattern
812
+ # Operators: ALL-CAPS followed by colon (with or without space)
813
+ # Properties: Mixed case followed by colon and space
814
+ result.gsub!(/(\A|\s+)([a-zA-Z][a-zA-Z0-9_\-() .\/=]*):(\s*)/) do
815
+ prefix_space = $1
816
+ text_part = $2
817
+ space_after = $3 || ""
818
+ colon_space = ":#{space_after}"
819
+
820
+ # Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
821
+ if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
822
+ prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators
823
+ elsif text_part.length >= 2 && space_after.include?(" ")
824
+ # It's a property (mixed case, at least 2 chars, has space after colon)
825
+ prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
826
+ else
827
+ # Leave as is
828
+ prefix_space + text_part + colon_space
829
+ end
830
+ end
831
+
832
+ # Handle OR: at the beginning of a line (with optional spaces)
833
+ result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
834
+
835
+ # Handle parentheses content (moved here to avoid conflicts with properties)
836
+ # Based on hyperlist.vim: '(.\{-})'
837
+ result.gsub!(/\(([^)]*)\)/) { "(".fg("6") + $1.fg("6") + ")".fg("6") } # Cyan for parentheses and content
838
+
839
+ # Handle semicolons as separators - but only at the start of the line or after spaces
840
+ # Don't replace semicolons that might be part of ANSI codes
841
+ result.gsub!(/^(\s*);/) { $1 + ";".fg("2") } # Green for comment semicolons
842
+
843
+ # Handle references - color entire reference including brackets
844
+ # Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
845
+ result.gsub!(/<{1,2}([^>]+)>{1,2}/) { |match| match.fg("5") } # Magenta for references
846
+
847
+ # Handle special keywords SKIP and END
848
+ result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
849
+
850
+ # Handle quoted strings FIRST, but color ## sequences inside them red
851
+ # Based on hyperlist.vim: '".\{-}"'
852
+ result.gsub!(/"([^"]*)"/) do
853
+ content = $1
854
+ # Color any ## sequences inside the quotes as red
855
+ content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
856
+ '"'.fg("6") + content + '"'.fg("6") # Cyan for quotes
857
+ end
858
+ result.gsub!(/'([^']*)'/) do
859
+ content = $1
860
+ # Color any ## sequences inside the quotes as red
861
+ content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
862
+ "'".fg("6") + content + "'".fg("6") # Cyan for single quotes
863
+ end
864
+
865
+ # Handle change markup - all double-hashes should be red
866
+ # First handle ##><Reference>##-> style (with reference in the middle)
867
+ result.gsub!(/(##[<>-]+)(<[^>]+>)(##[<>-]+)/) do
868
+ $1.fg("1") + $2.fg("5") + $3.fg("1") # Red markers, magenta reference
869
+ end
870
+
871
+ # Handle ##Text## change info (text between double hashes)
872
+ result.gsub!(/(##)([^#]+)(##)/) { $1.fg("1") + $2 + $3.fg("1") } # Red for change info markers
873
+
874
+ # Then color any remaining ## sequences red
875
+ result.gsub!(/(##[<>-]*)/) { $1.fg("1") } # Red for all ## markers
876
+
877
+ # Handle substitutions {variable}
878
+ result.gsub!(/\{([^}]+)\}/) { "{".fg("3") + $1.fg("3") + "}".fg("3") } # Yellow for substitutions
879
+
880
+ # Handle hash tags
881
+ # Based on hyperlist.vim: '#[a-zA-Z0-9.:/_&?%=+\-\*]\+'
882
+ result.gsub!(/#([a-zA-Z0-9.:_\/&?%=+\-*]+)/) { "##{$1}".fg("184") } # Yellow/gold for tags
883
+
884
+ # Handle text formatting (bold, italic, underline)
885
+ # Based on hyperlist.vim patterns with tab/space boundaries
886
+ # Bold: '\(\t\| \)\@<=\*.\{-}\*\($\| \)\@='
887
+ result.gsub!(/(?<=\s|\A)\*([^*]+)\*(?=\s|\z)/) { $1.b }
888
+
889
+ # Italic: '\(\t\| \)\@<=/.\{-}/\($\| \)\@='
890
+ result.gsub!(/(?<=\s|\A)\/([^\/]+)\/(?=\s|\z)/) { $1.i }
891
+
892
+ # Underline: '\(\t\| \)\@<=_.\{-}_\($\| \)\@='
893
+ result.gsub!(/(?<=\s|\A)_([^_]+)_(?=\s|\z)/) { $1.u }
894
+
895
+ result
896
+ end
897
+
898
+ def render_footer
899
+ version_text = "v#{VERSION}"
900
+
901
+ if @mode == :command
902
+ base_text = ":" + @command
903
+ elsif @mode == :search
904
+ base_text = "/" + @search
905
+ elsif @mode == :insert
906
+ base_text = "-- INSERT --"
907
+ elsif !@message.empty?
908
+ base_text = @message
909
+ # Set timeout for message if not already set
910
+ @message_timeout = Time.now + 3.0 if @message_timeout.nil?
911
+ # Clear message after timeout
912
+ if @message_timeout && Time.now >= @message_timeout
913
+ @message = ""
914
+ @message_timeout = nil
915
+ end
916
+ else
917
+ # Enhanced status with file info
918
+ filename_display = @filename ? File.basename(@filename) : "New HyperList"
919
+ modified_indicator = @modified ? "[+]" : ""
920
+ pos = "#{@current + 1}/#{get_visible_items.length}"
921
+
922
+ # Count words in visible items
923
+ word_count = count_words_in_items
924
+
925
+ # Auto-save indicator
926
+ auto_save_indicator = @auto_save_enabled ? "[A]" : ""
927
+
928
+ # Split view indicator
929
+ split_indicator = @split_view ? "[#{@active_pane.upcase}]" : ""
930
+
931
+ # Build status line components
932
+ # Use full path with ~ for home directory
933
+ full_path = @filename ? @filename.gsub(ENV['HOME'], '~') : "New HyperList"
934
+ file_part = "#{full_path}#{modified_indicator}"
935
+ stats_part = "L#{pos} W:#{word_count}"
936
+ indicators = "#{auto_save_indicator}#{split_indicator}"
937
+ right_side = "? help #{version_text}"
938
+
939
+ # Combine left elements
940
+ left_content = indicators.empty? ? "#{file_part} #{stats_part}" : "#{file_part}#{indicators} #{stats_part}"
941
+
942
+ # Calculate spacing between left and right
943
+ total_length = left_content.length + right_side.length
944
+ available_space = @cols - total_length
945
+
946
+ if available_space > 0
947
+ # Add spacing between left and right content
948
+ base_text = "#{left_content}#{' ' * available_space}#{right_side}"
949
+ else
950
+ # Terminal too narrow - truncate the path if needed
951
+ max_path_length = @cols - stats_part.length - right_side.length - modified_indicator.length - indicators.length - 6
952
+ if max_path_length > 10
953
+ truncated_path = full_path.length > max_path_length ? "...#{full_path[-(max_path_length-3)..-1]}" : full_path
954
+ left_content = "#{truncated_path}#{modified_indicator}#{indicators} #{stats_part}"
955
+ base_text = "#{left_content} #{right_side}"
956
+ else
957
+ # Very narrow - show minimal info
958
+ base_text = "#{stats_part} #{right_side}"
959
+ end
960
+ end
961
+ end
962
+
963
+ @footer.text = base_text
964
+
965
+ @footer.refresh
966
+ end
967
+
968
+ def count_words_in_items
969
+ visible = get_visible_items
970
+ total_words = 0
971
+ visible.each do |item|
972
+ # Count words in plain text (remove markup)
973
+ text = item["text"].gsub(/\[[^\]]*\]/, '') # Remove brackets
974
+ text = text.gsub(/<[^>]*>/, '') # Remove references
975
+ text = text.gsub(/[*\/_]/, '') # Remove formatting
976
+ words = text.split(/\s+/).reject(&:empty?)
977
+ total_words += words.length
978
+ end
979
+ total_words
980
+ end
981
+
982
+ def get_visible_items
983
+ visible = []
984
+ skip_until = -1
985
+
986
+ @items.each_with_index do |item, idx|
987
+ if idx <= skip_until
988
+ next
989
+ end
990
+
991
+ visible << item
992
+
993
+ if item["fold"] && has_children?(idx, @items)
994
+ # Skip children if folded
995
+ level = item["level"]
996
+ skip_until = idx
997
+ ((idx + 1)...@items.length).each do |j|
998
+ if @items[j]["level"] <= level
999
+ break
1000
+ end
1001
+ skip_until = j
1002
+ end
1003
+ end
1004
+ end
1005
+
1006
+ visible
1007
+ end
1008
+
1009
+ def has_children?(idx, items)
1010
+ return false if idx >= items.length - 1
1011
+ return false if idx < 0
1012
+
1013
+ current_item = items[idx]
1014
+ next_item = items[idx + 1]
1015
+
1016
+ # For visible items array
1017
+ if current_item.is_a?(Hash) && next_item.is_a?(Hash)
1018
+ return next_item["level"] > current_item["level"]
1019
+ end
1020
+
1021
+ false
1022
+ end
1023
+
1024
+ def toggle_fold
1025
+ visible = get_visible_items
1026
+ return if @current >= visible.length
1027
+
1028
+ item = visible[@current]
1029
+ real_idx = @items.index(item)
1030
+
1031
+ if real_idx && has_children?(real_idx, @items)
1032
+ @items[real_idx]["fold"] = !@items[real_idx]["fold"]
1033
+ record_last_action(:toggle_fold, nil)
1034
+ end
1035
+ end
1036
+
1037
+ def move_up
1038
+ @current = [@current - 1, 0].max
1039
+ end
1040
+
1041
+ def move_down
1042
+ max = get_visible_items.length - 1
1043
+ @current = [@current + 1, max].min
1044
+ end
1045
+
1046
+ def page_up
1047
+ @current = [@current - (@main.h - 1), 0].max
1048
+ @offset = [@offset - (@main.h - 1), 0].max
1049
+ end
1050
+
1051
+ def page_down
1052
+ max = get_visible_items.length - 1
1053
+ @current = [@current + (@main.h - 1), max].min
1054
+ end
1055
+
1056
+ def go_to_parent
1057
+ visible = get_visible_items
1058
+ return if @current >= visible.length
1059
+
1060
+ current_level = visible[@current]["level"]
1061
+ return if current_level == 0
1062
+
1063
+ # Search upward for parent
1064
+ (@current - 1).downto(0) do |i|
1065
+ if visible[i]["level"] < current_level
1066
+ @current = i
1067
+ break
1068
+ end
1069
+ end
1070
+ end
1071
+
1072
+ def go_to_first_child
1073
+ visible = get_visible_items
1074
+ return if @current >= visible.length - 1
1075
+
1076
+ current_level = visible[@current]["level"]
1077
+ if visible[@current + 1]["level"] > current_level
1078
+ @current += 1
1079
+ end
1080
+ end
1081
+
1082
+ def expand_to_level(level)
1083
+ @items.each do |item|
1084
+ item["fold"] = item["level"] >= level
1085
+ end
1086
+ end
1087
+
1088
+ def jump_to_next_template_marker
1089
+ visible_items = get_visible_items
1090
+ return if visible_items.empty?
1091
+
1092
+ start_idx = @current
1093
+
1094
+ # Search forward from current position
1095
+ ((start_idx + 1)...visible_items.length).each do |idx|
1096
+ if visible_items[idx]["text"].include?("=")
1097
+ @current = idx
1098
+ render # Show the highlighted item first
1099
+ edit_line # Then edit it
1100
+ return
1101
+ end
1102
+ end
1103
+
1104
+ # Wrap around to beginning
1105
+ (0..start_idx).each do |idx|
1106
+ if visible_items[idx]["text"].include?("=")
1107
+ @current = idx
1108
+ render # Show the highlighted item first
1109
+ edit_line # Then edit it
1110
+ return
1111
+ end
1112
+ end
1113
+
1114
+ @message = "No template markers found"
1115
+ end
1116
+
1117
+ def toggle_presentation_mode
1118
+ if @presentation_mode
1119
+ # Exit presentation mode - restore all items
1120
+ @presentation_mode = false
1121
+ @items.each { |item| item["fold"] = false }
1122
+ @message = "Presentation mode disabled"
1123
+ else
1124
+ # Enter presentation mode - show only current item and ancestors
1125
+ @presentation_mode = true
1126
+ show_only_current_and_ancestors
1127
+ @message = "Presentation mode enabled"
1128
+ end
1129
+ end
1130
+
1131
+ def show_only_current_and_ancestors
1132
+ visible_items = get_visible_items
1133
+ return if visible_items.empty? || @current >= visible_items.length
1134
+
1135
+ current_item = visible_items[@current]
1136
+ current_level = current_item["level"]
1137
+ current_real_idx = @items.index(current_item)
1138
+
1139
+ # First, fold everything
1140
+ @items.each { |item| item["fold"] = true }
1141
+
1142
+ # Unfold current item
1143
+ current_item["fold"] = false
1144
+
1145
+ # Unfold all ancestors
1146
+ ancestor_level = current_level - 1
1147
+ idx = current_real_idx - 1
1148
+
1149
+ while idx >= 0 && ancestor_level >= 0
1150
+ if @items[idx]["level"] == ancestor_level
1151
+ @items[idx]["fold"] = false
1152
+ ancestor_level -= 1
1153
+ end
1154
+ idx -= 1
1155
+ end
1156
+
1157
+ # Unfold immediate children of current item
1158
+ idx = current_real_idx + 1
1159
+ while idx < @items.length && @items[idx]["level"] > current_level
1160
+ if @items[idx]["level"] == current_level + 1
1161
+ @items[idx]["fold"] = false
1162
+ end
1163
+ idx += 1
1164
+ end
1165
+ end
1166
+
1167
+ def save_undo_state
1168
+ # Deep copy the CURRENT items array before modification
1169
+ state_copy = @items.map { |item| item.dup }
1170
+
1171
+ # Add to undo stack
1172
+ @undo_stack << state_copy
1173
+ @undo_position << @current
1174
+
1175
+ # Clear redo stack when new action is performed
1176
+ @redo_stack.clear
1177
+ @redo_position.clear
1178
+
1179
+ # Limit undo stack to 100 levels to prevent memory issues
1180
+ if @undo_stack.length > 100
1181
+ @undo_stack.shift
1182
+ @undo_position.shift
1183
+ end
1184
+
1185
+ # Debug output
1186
+ # @message = "Saved undo state (stack size: #{@undo_stack.length})"
1187
+ end
1188
+
1189
+ def states_equal?(state1, state2)
1190
+ return false if state1.length != state2.length
1191
+ state1.each_with_index do |item, idx|
1192
+ return false if item["text"] != state2[idx]["text"] ||
1193
+ item["level"] != state2[idx]["level"]
1194
+ end
1195
+ true
1196
+ end
1197
+
1198
+ def clear_cache
1199
+ @processed_cache = {}
1200
+ @last_rendered_content = nil
1201
+ end
1202
+
1203
+ def undo
1204
+ # Need at least one state to undo to
1205
+ if @undo_stack.empty?
1206
+ @message = "Nothing to undo"
1207
+ return
1208
+ end
1209
+
1210
+ # Save current state to redo stack before changing
1211
+ current_state = @items.map { |item| item.dup }
1212
+ @redo_stack << current_state
1213
+ @redo_position << @current
1214
+
1215
+ # Restore the previous state from undo stack
1216
+ previous_state = @undo_stack.pop
1217
+ previous_position = @undo_position.pop
1218
+
1219
+ @items = previous_state.map { |item| item.dup }
1220
+
1221
+ # Restore cursor position, but ensure it's valid
1222
+ visible = get_visible_items
1223
+ if previous_position && visible.length > 0
1224
+ @current = [previous_position, visible.length - 1].min
1225
+ @current = 0 if @current < 0
1226
+ else
1227
+ @current = 0
1228
+ end
1229
+
1230
+ # Clear cache since items changed
1231
+ clear_cache
1232
+
1233
+ @modified = true
1234
+ @message = "Undone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
1235
+ end
1236
+
1237
+ def record_last_action(type, data = nil)
1238
+ @last_action_type = type
1239
+ @last_action = data
1240
+ end
1241
+
1242
+ def repeat_last_action
1243
+ return unless @last_action_type
1244
+
1245
+ case @last_action_type
1246
+ when :insert_line
1247
+ insert_line_with_text(@last_action)
1248
+ when :insert_child
1249
+ insert_child_with_text(@last_action)
1250
+ when :delete_line
1251
+ delete_line
1252
+ when :edit_line
1253
+ if @last_action && @last_action[:new_text]
1254
+ edit_current_line(@last_action[:new_text])
1255
+ end
1256
+ when :toggle_checkbox
1257
+ toggle_checkbox
1258
+ when :toggle_checkbox_with_date
1259
+ toggle_checkbox_with_date
1260
+ when :toggle_fold
1261
+ toggle_fold
1262
+ when :indent_right
1263
+ indent_right(@last_action || true)
1264
+ when :indent_left
1265
+ indent_left(@last_action || true)
1266
+ when :paste
1267
+ paste_line
1268
+ when :yank_line
1269
+ yank_line(@last_action || false)
1270
+ else
1271
+ @message = "No repeatable action"
1272
+ end
1273
+ end
1274
+
1275
+ def redo_change # Renamed to avoid conflict with Ruby's redo keyword
1276
+ # Need at least one state to redo
1277
+ if @redo_stack.empty?
1278
+ @message = "Nothing to redo"
1279
+ return
1280
+ end
1281
+
1282
+ # Save current state back to undo stack
1283
+ current_state = @items.map { |item| item.dup }
1284
+ @undo_stack << current_state
1285
+ @undo_position << @current
1286
+
1287
+ # Restore the state from redo stack
1288
+ redo_state = @redo_stack.pop
1289
+ redo_position = @redo_position.pop
1290
+
1291
+ @items = redo_state.map { |item| item.dup }
1292
+
1293
+ # Restore cursor position, but ensure it's valid
1294
+ visible = get_visible_items
1295
+ if redo_position && visible.length > 0
1296
+ @current = [redo_position, visible.length - 1].min
1297
+ @current = 0 if @current < 0
1298
+ else
1299
+ @current = 0
1300
+ end
1301
+
1302
+ # Clear cache since items changed
1303
+ clear_cache
1304
+
1305
+ @modified = true
1306
+ @message = "Redone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
1307
+ end
1308
+
1309
+ def insert_line
1310
+ @mode = :insert
1311
+
1312
+ input = @footer.ask("New item: ", "")
1313
+
1314
+ if input && !input.strip.empty?
1315
+ insert_line_with_text(input)
1316
+ record_last_action(:insert_line, input)
1317
+ end
1318
+
1319
+ @mode = :normal
1320
+ @footer.clear # Clear footer immediately
1321
+ @footer.refresh
1322
+ end
1323
+
1324
+ def insert_line_with_text(text)
1325
+ return unless text && !text.strip.empty?
1326
+
1327
+ save_undo_state # Save state before modification
1328
+ visible = get_visible_items
1329
+ if @current < visible.length
1330
+ level = visible[@current]["level"]
1331
+ real_idx = @items.index(visible[@current])
1332
+ @items.insert(real_idx + 1, {"text" => text, "level" => level, "fold" => false})
1333
+ else
1334
+ @items << {"text" => text, "level" => 0, "fold" => false}
1335
+ end
1336
+ @modified = true
1337
+ @current += 1
1338
+ end
1339
+
1340
+ def insert_child
1341
+ @mode = :insert
1342
+
1343
+ input = @footer.ask("New child item: ", "")
1344
+
1345
+ if input && !input.strip.empty?
1346
+ insert_child_with_text(input)
1347
+ record_last_action(:insert_child, input)
1348
+ end
1349
+
1350
+ @mode = :normal
1351
+ @footer.clear # Clear footer immediately
1352
+ @footer.refresh
1353
+ end
1354
+
1355
+ def insert_child_with_text(text)
1356
+ return unless text && !text.strip.empty?
1357
+
1358
+ save_undo_state # Save state before modification
1359
+ visible = get_visible_items
1360
+ if @current < visible.length
1361
+ level = visible[@current]["level"] + 1
1362
+ real_idx = @items.index(visible[@current])
1363
+
1364
+ # Unfold parent if needed
1365
+ @items[real_idx]["fold"] = false
1366
+
1367
+ @items.insert(real_idx + 1, {"text" => text, "level" => level, "fold" => false})
1368
+ else
1369
+ @items << {"text" => text, "level" => 1, "fold" => false}
1370
+ end
1371
+ @modified = true
1372
+ @current += 1
1373
+ end
1374
+
1375
+ def edit_line
1376
+ visible = get_visible_items
1377
+ return if @current >= visible.length
1378
+
1379
+ item = visible[@current]
1380
+ real_idx = @items.index(item)
1381
+
1382
+ @mode = :insert
1383
+
1384
+ input = @footer.ask("Edit: ", item["text"])
1385
+
1386
+ if input && input != item["text"]
1387
+ edit_current_line(input)
1388
+ record_last_action(:edit_line, {old_text: item["text"], new_text: input})
1389
+ end
1390
+
1391
+ @mode = :normal
1392
+ @footer.clear # Clear footer immediately
1393
+ @footer.refresh
1394
+ end
1395
+
1396
+ def edit_current_line(text)
1397
+ visible = get_visible_items
1398
+ return if @current >= visible.length
1399
+
1400
+ item = visible[@current]
1401
+ real_idx = @items.index(item)
1402
+
1403
+ save_undo_state # Save state before modification
1404
+ @items[real_idx]["text"] = text
1405
+ @modified = true
1406
+ clear_cache # Clear cache when content changes
1407
+ end
1408
+
1409
+ def delete_line(with_children = false)
1410
+ visible = get_visible_items
1411
+ return if visible.empty?
1412
+ return if @current >= visible.length
1413
+
1414
+ save_undo_state # Save state before modification
1415
+
1416
+ item = visible[@current]
1417
+ real_idx = @items.index(item)
1418
+
1419
+ # First, yank the item(s) to clipboard
1420
+ @clipboard = []
1421
+ @clipboard << item.dup
1422
+
1423
+ # Determine what to delete
1424
+ level = item["level"]
1425
+ delete_count = 1
1426
+
1427
+ if with_children
1428
+ # Delete item and its children (C-D was used)
1429
+ @clipboard_is_tree = true # Mark as tree for paste behavior
1430
+ ((real_idx + 1)...@items.length).each do |i|
1431
+ if @items[i]["level"] > level
1432
+ @clipboard << @items[i].dup # Also add children to clipboard
1433
+ delete_count += 1
1434
+ else
1435
+ break
1436
+ end
1437
+ end
1438
+ else
1439
+ # For single line delete, check if it has children (D always deletes with children)
1440
+ @clipboard_is_tree = false # Mark as single/adaptive for paste behavior
1441
+ ((real_idx + 1)...@items.length).each do |i|
1442
+ if @items[i]["level"] > level
1443
+ @clipboard << @items[i].dup
1444
+ delete_count += 1
1445
+ else
1446
+ break
1447
+ end
1448
+ end
1449
+ end
1450
+
1451
+ # Delete the items
1452
+ delete_count.times { @items.delete_at(real_idx) }
1453
+
1454
+ @items = [{"text" => "Empty", "level" => 0, "fold" => false}] if @items.empty?
1455
+
1456
+ @current = [@current, get_visible_items.length - 1].min
1457
+ @current = 0 if @current < 0
1458
+ @modified = true
1459
+
1460
+ # Show message
1461
+ @message = "Deleted and yanked #{@clipboard.length} item(s)"
1462
+
1463
+ record_last_action(:delete_line, with_children)
1464
+ end
1465
+
1466
+ def yank_line(with_children = false)
1467
+ visible = get_visible_items
1468
+ return if @current >= visible.length
1469
+
1470
+ item = visible[@current]
1471
+ real_idx = @items.index(item)
1472
+
1473
+ @clipboard = []
1474
+ @clipboard << item.dup
1475
+ @clipboard_is_tree = with_children # Remember if this is a tree copy (Y) or single (y)
1476
+
1477
+ # Copy children if requested
1478
+ if with_children
1479
+ level = item["level"]
1480
+ ((real_idx + 1)...@items.length).each do |i|
1481
+ if @items[i]["level"] > level
1482
+ @clipboard << @items[i].dup
1483
+ else
1484
+ break
1485
+ end
1486
+ end
1487
+ end
1488
+
1489
+ @message = "Yanked #{@clipboard.length} item(s)"
1490
+ record_last_action(:yank_line, with_children)
1491
+ end
1492
+
1493
+ def paste_line
1494
+ return unless @clipboard && !@clipboard.empty?
1495
+
1496
+ save_undo_state # Save state before modification
1497
+
1498
+ visible = get_visible_items
1499
+ if @current < visible.length
1500
+ real_idx = @items.index(visible[@current])
1501
+
1502
+ if @clipboard_is_tree
1503
+ # For tree paste (C-D or Y), maintain original indentation structure
1504
+ # Just insert after current position without adjusting levels
1505
+ @clipboard.reverse.each do |item|
1506
+ new_item = item.dup
1507
+ @items.insert(real_idx + 1, new_item)
1508
+ end
1509
+ else
1510
+ # For single/adaptive paste (D or y), adjust to match context
1511
+ base_level = visible[@current]["level"]
1512
+ level_diff = base_level - @clipboard[0]["level"]
1513
+
1514
+ @clipboard.reverse.each do |item|
1515
+ new_item = item.dup
1516
+ new_item["level"] = item["level"] + level_diff
1517
+ @items.insert(real_idx + 1, new_item)
1518
+ end
1519
+ end
1520
+ else
1521
+ # Pasting at end of list - maintain original levels
1522
+ @clipboard.each do |item|
1523
+ @items << item.dup
1524
+ end
1525
+ end
1526
+
1527
+ @modified = true
1528
+ @message = "Pasted #{@clipboard.length} item(s)"
1529
+ record_last_action(:paste, nil)
1530
+ end
1531
+
1532
+ def move_item_up(with_children = false)
1533
+ visible = get_visible_items
1534
+ return if @current >= visible.length || @current == 0
1535
+
1536
+ save_undo_state # Save state before modification
1537
+
1538
+ item = visible[@current]
1539
+ real_idx = @items.index(item)
1540
+
1541
+ # Can't move if already at the beginning
1542
+ return if real_idx == 0
1543
+
1544
+ # Collect item(s) to move
1545
+ items_to_move = [item]
1546
+
1547
+ if with_children
1548
+ # Collect all children
1549
+ level = item["level"]
1550
+ ((real_idx + 1)...@items.length).each do |i|
1551
+ if @items[i]["level"] > level
1552
+ items_to_move << @items[i]
1553
+ else
1554
+ break
1555
+ end
1556
+ end
1557
+ end
1558
+
1559
+ # Remove items from their current position
1560
+ items_to_move.length.times { @items.delete_at(real_idx) }
1561
+
1562
+ # Insert one position up (before the item that was above us)
1563
+ target_idx = real_idx - 1
1564
+ items_to_move.reverse.each do |item_to_move|
1565
+ @items.insert(target_idx, item_to_move)
1566
+ end
1567
+
1568
+ @current -= 1
1569
+ @modified = true
1570
+ @message = "Moved #{items_to_move.length} item(s) up one line"
1571
+ record_last_action(:move_item_up, with_children)
1572
+ end
1573
+
1574
+ def move_item_down(with_children = false)
1575
+ visible = get_visible_items
1576
+ return if @current >= visible.length - 1
1577
+
1578
+ save_undo_state # Save state before modification
1579
+
1580
+ item = visible[@current]
1581
+ real_idx = @items.index(item)
1582
+
1583
+ # Collect item(s) to move
1584
+ items_to_move = [item]
1585
+ last_idx = real_idx
1586
+
1587
+ if with_children
1588
+ # Collect all children
1589
+ level = item["level"]
1590
+ ((real_idx + 1)...@items.length).each do |i|
1591
+ if @items[i]["level"] > level
1592
+ items_to_move << @items[i]
1593
+ last_idx = i
1594
+ else
1595
+ break
1596
+ end
1597
+ end
1598
+ end
1599
+
1600
+ # Can't move if we're already at the end
1601
+ return if last_idx >= @items.length - 1
1602
+
1603
+ # Remove items from their current position
1604
+ items_to_move.length.times { @items.delete_at(real_idx) }
1605
+
1606
+ # Insert one position down (after the item that was below us)
1607
+ # Since we removed items, the target position is now at real_idx + 1
1608
+ target_idx = real_idx + 1
1609
+
1610
+ items_to_move.each_with_index do |item_to_move, idx|
1611
+ @items.insert(target_idx + idx, item_to_move)
1612
+ end
1613
+
1614
+ @current += 1
1615
+ @modified = true
1616
+ @message = "Moved #{items_to_move.length} item(s) down one line"
1617
+ record_last_action(:move_item_down, with_children)
1618
+ end
1619
+
1620
+ def indent_right(with_children = true)
1621
+ visible = get_visible_items
1622
+ return if @current >= visible.length
1623
+
1624
+ item = visible[@current]
1625
+ real_idx = @items.index(item)
1626
+
1627
+ # Can only indent if there's a previous item at same or higher level
1628
+ if real_idx > 0
1629
+ save_undo_state # Save state before modification
1630
+ original_level = @items[real_idx]["level"]
1631
+ @items[real_idx]["level"] += 1
1632
+
1633
+ # Also indent children if requested
1634
+ if with_children
1635
+ ((real_idx + 1)...@items.length).each do |i|
1636
+ if @items[i]["level"] > original_level
1637
+ @items[i]["level"] += 1
1638
+ else
1639
+ break
1640
+ end
1641
+ end
1642
+ end
1643
+
1644
+ @modified = true
1645
+ record_last_action(:indent_right, with_children)
1646
+ end
1647
+ end
1648
+
1649
+ def indent_left(with_children = true)
1650
+ visible = get_visible_items
1651
+ return if @current >= visible.length
1652
+
1653
+ item = visible[@current]
1654
+ real_idx = @items.index(item)
1655
+
1656
+ if @items[real_idx]["level"] > 0
1657
+ save_undo_state # Save state before modification
1658
+ original_level = @items[real_idx]["level"]
1659
+ @items[real_idx]["level"] -= 1
1660
+
1661
+ # Also unindent children if requested
1662
+ if with_children
1663
+ ((real_idx + 1)...@items.length).each do |i|
1664
+ if @items[i]["level"] > original_level
1665
+ @items[i]["level"] -= 1
1666
+ else
1667
+ break
1668
+ end
1669
+ end
1670
+ end
1671
+
1672
+ @modified = true
1673
+ record_last_action(:indent_left, with_children)
1674
+ end
1675
+ end
1676
+
1677
+ def toggle_checkbox
1678
+ visible = get_visible_items
1679
+ return if @current >= visible.length
1680
+
1681
+ save_undo_state # Save state before modification
1682
+
1683
+ item = visible[@current]
1684
+ real_idx = @items.index(item)
1685
+ text = @items[real_idx]["text"]
1686
+
1687
+ if text =~ /^\[.\]/
1688
+ # Cycle through states: [ ]/[_] -> [X] -> [O] -> [-] -> [ ]
1689
+ text = case text[1]
1690
+ when ' ', '_' then text.sub(/^\[[ _]\]/, '[X]')
1691
+ when 'X', 'x' then text.sub(/^\[[Xx]\]/, '[O]')
1692
+ when 'O' then text.sub(/^\[O\]/, '[-]')
1693
+ when '-' then text.sub(/^\[-\]/, '[ ]')
1694
+ else text
1695
+ end
1696
+ else
1697
+ # Add checkbox
1698
+ text = "[ ] #{text}"
1699
+ end
1700
+
1701
+ @items[real_idx]["text"] = text
1702
+ @modified = true
1703
+ record_last_action(:toggle_checkbox, nil)
1704
+ end
1705
+
1706
+ def toggle_checkbox_with_date
1707
+ visible = get_visible_items
1708
+ return if @current >= visible.length
1709
+
1710
+ save_undo_state # Save state before modification
1711
+
1712
+ item = visible[@current]
1713
+ real_idx = @items.index(item)
1714
+ text = @items[real_idx]["text"]
1715
+
1716
+ # First, handle existing timestamp in the hyperlist.vim format
1717
+ # Format is: [x] YYYY-MM-DD HH.MM: rest of text
1718
+ text = text.sub(/ \d{4}-\d{2}-\d{2} \d{2}\.\d{2}:/, '')
1719
+
1720
+ if text =~ /^\[.\]/
1721
+ # Toggle checkbox states with date stamping
1722
+ text = case text[1]
1723
+ when ' '
1724
+ # Unchecked -> mark as in progress
1725
+ text.sub(/^\[ \]/, '[_]')
1726
+ when '_'
1727
+ # In progress -> mark as completed with timestamp
1728
+ time = Time.now.strftime("%Y-%m-%d %H.%M")
1729
+ text.sub(/^\[_\]/, "[x] #{time}:")
1730
+ when 'x', 'X'
1731
+ # Completed -> back to unchecked
1732
+ text.sub(/^\[[xX]\]/, '[_]')
1733
+ when 'O'
1734
+ # In progress (capital O) -> completed with timestamp
1735
+ time = Time.now.strftime("%Y-%m-%d %H.%M")
1736
+ text.sub(/^\[O\]/, "[x] #{time}:")
1737
+ else text
1738
+ end
1739
+ else
1740
+ # Add checkbox in unchecked state
1741
+ text = "[_] #{text}"
1742
+ end
1743
+
1744
+ @items[real_idx]["text"] = text
1745
+ @modified = true
1746
+ record_last_action(:toggle_checkbox_with_date, nil)
1747
+ end
1748
+
1749
+ def search_forward
1750
+ @mode = :search
1751
+ @search = @footer.ask("Search: ", @search || "")
1752
+ @mode = :normal
1753
+ @footer.clear # Clear footer immediately
1754
+ @footer.refresh
1755
+
1756
+ return if !@search || @search.empty?
1757
+
1758
+ # Find all matches for highlighting
1759
+ find_all_matches
1760
+
1761
+ visible = get_visible_items
1762
+ start = (@current + 1) % visible.length
1763
+
1764
+ visible.length.times do |i|
1765
+ idx = (start + i) % visible.length
1766
+ if visible[idx]["text"].downcase.include?(@search.downcase)
1767
+ @current = idx
1768
+ @message = "Found: #{@search} (#{@search_matches.length} total matches)"
1769
+ clear_cache # Clear cache to show highlighting
1770
+ return
1771
+ end
1772
+ end
1773
+
1774
+ @message = "Not found: #{@search}"
1775
+ @search_matches = []
1776
+ end
1777
+
1778
+ def find_all_matches
1779
+ @search_matches = []
1780
+ return if !@search || @search.empty?
1781
+
1782
+ get_visible_items.each_with_index do |item, idx|
1783
+ if item["text"].downcase.include?(@search.downcase)
1784
+ @search_matches << idx
1785
+ end
1786
+ end
1787
+ end
1788
+
1789
+ def search_next
1790
+ return if !@search || @search.empty?
1791
+
1792
+ visible = get_visible_items
1793
+ start = (@current + 1) % visible.length
1794
+
1795
+ visible.length.times do |i|
1796
+ idx = (start + i) % visible.length
1797
+ if visible[idx]["text"].downcase.include?(@search.downcase)
1798
+ @current = idx
1799
+ return
1800
+ end
1801
+ end
1802
+
1803
+ @message = "No more matches"
1804
+ end
1805
+
1806
+ def help_line(cmd1, desc1, cmd2 = nil, desc2 = nil)
1807
+ # Format help lines with consistent column spacing
1808
+ # Start with no leading space
1809
+ line = ""
1810
+
1811
+ # First command and description
1812
+ line += cmd1
1813
+ # Calculate real length without ANSI codes
1814
+ cmd1_real_length = cmd1.gsub(/\e\[[0-9;]*m/, '').length
1815
+ # Longest commands are like ":recent :r" (10 chars), "C-DOWN" (6 chars)
1816
+ max_cmd_length = 11
1817
+ spaces_needed = max_cmd_length - cmd1_real_length
1818
+ line += " " * [spaces_needed, 1].max # At least 1 space after command
1819
+
1820
+ # Add first description - don't truncate
1821
+ line += desc1
1822
+
1823
+ # Second command and description (if provided)
1824
+ if cmd2 && desc2
1825
+ # Calculate position for second column
1826
+ # Need to account for ANSI codes in desc1
1827
+ desc1_real_length = desc1.gsub(/\e\[[0-9;]*m/, '').length
1828
+ current_pos = cmd1_real_length + spaces_needed + desc1_real_length
1829
+
1830
+ # Start second column at position 40
1831
+ target_pos = 40
1832
+ padding_needed = target_pos - current_pos
1833
+ line += " " * [padding_needed, 3].max # At least 3 spaces between columns
1834
+
1835
+ line += cmd2
1836
+ cmd2_real_length = cmd2.gsub(/\e\[[0-9;]*m/, '').length
1837
+ # Align second column descriptions (longest is ":export :ex" = 11 chars, ":vsplit :vs" = 11 chars)
1838
+ max_cmd2_length = 13 # 11 chars max + 2 spaces minimum padding
1839
+ spaces_needed2 = max_cmd2_length - cmd2_real_length
1840
+ line += " " * spaces_needed2
1841
+ line += desc2
1842
+ end
1843
+
1844
+ line
1845
+ end
1846
+
1847
+ def show_help
1848
+ # Build help text using consistent formatting
1849
+ help_lines = []
1850
+ help_lines << " Press #{"?".fg("10")} for full documentation, #{"UP/DOWN".fg("10")} to scroll, or any other key to return"
1851
+ help_lines << ""
1852
+ help_lines << "#{"HYPERLIST KEY BINDINGS".b}"
1853
+ help_lines << ""
1854
+ help_lines << "#{"NAVIGATION".fg("14")}"
1855
+ help_lines << help_line("#{"j/↓".fg("10")}", "Move down", "#{"k/↑".fg("10")}", "Move up")
1856
+ help_lines << help_line("#{"h".fg("10")}", "Go to parent", "#{"l".fg("10")}", "Go to first child")
1857
+ help_lines << help_line("#{"PgUp".fg("10")}", "Page up", "#{"PgDn".fg("10")}", "Page down")
1858
+ help_lines << help_line("#{"g/Home".fg("10")}", "Go to top", "#{"G/End".fg("10")}", "Go to bottom")
1859
+ help_lines << help_line("#{"/".fg("10")}", "Search", "#{"n".fg("10")}", "Next match")
1860
+ help_lines << help_line("#{"?".fg("10")}", "This help", "#{"??".fg("10")}", "Full documentation")
1861
+ help_lines << help_line("#{"ma".fg("10")}", "Set mark 'a'", "#{"'a".fg("10")}", "Jump to mark 'a'")
1862
+ help_lines << help_line("#{"''".fg("10")}", "Jump to prev position", "#{"N".fg("10")}", "Next = template marker")
1863
+ help_lines << ""
1864
+ help_lines << "#{"FOLDING".fg("14")}"
1865
+ help_lines << help_line("#{"Space".fg("10")}", "Toggle fold", "#{"za".fg("10")}", "Toggle all folds")
1866
+ help_lines << help_line("#{"zo".fg("10")}", "Open fold", "#{"zc".fg("10")}", "Close fold")
1867
+ help_lines << help_line("#{"zR".fg("10")}", "Open all", "#{"zM".fg("10")}", "Close all")
1868
+ help_lines << help_line("#{"1-9".fg("10")}", "Expand to level", "#{"0".fg("10")}", "Multi-digit fold level")
1869
+ help_lines << help_line("#{"▶".fg("245")}", "Collapsed (hidden)")
1870
+ help_lines << help_line("#{"▷".fg("245")}", "Expanded (visible)")
1871
+ help_lines << ""
1872
+ help_lines << "#{"EDITING".fg("14")}"
1873
+ help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
1874
+ help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
1875
+ help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
1876
+ help_lines << help_line("#{"y".fg("10")}/#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
1877
+ help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
1878
+ help_lines << help_line("#{"r".fg("10")}, #{"C-R".fg("10")}", "Redo")
1879
+ help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
1880
+ help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
1881
+ help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
1882
+ help_lines << help_line("#{"→".fg("10")}", "Indent item only", "#{"←".fg("10")}", "Unindent item only")
1883
+ help_lines << ""
1884
+ help_lines << "#{"FEATURES".fg("14")}"
1885
+ help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
1886
+ help_lines << help_line("#{"R".fg("10")}", "Go to reference", "#{"F".fg("10")}", "Open file")
1887
+ help_lines << help_line("#{"N".fg("10")}", "Next = marker", "#{"P".fg("10")}", "Presentation mode")
1888
+ help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":template".fg("10")}", "Show templates")
1889
+ help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
1890
+ help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"ww".fg("10")}", "Switch panes")
1891
+ help_lines << help_line("#{"\\u".fg("10")}", "Toggle underline")
1892
+ help_lines << ""
1893
+ help_lines << "#{"FILE OPERATIONS".fg("14")}"
1894
+ help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
1895
+ help_lines << help_line("#{":wq".fg("10")}", "Save and quit", "#{":e file".fg("10")}", "Open file")
1896
+ help_lines << help_line("#{":recent".fg("10")}", "Recent files", "#{":export :ex".fg("10")}", "Export md|html|txt")
1897
+ help_lines << help_line("#{":graph :g".fg("10")}", "Export to PNG graph", "#{":vsplit :vs".fg("10")}", "Split view")
1898
+ help_lines << help_line("#{":as on".fg("10")}", "Enable autosave", "#{":as off".fg("10")}", "Disable autosave")
1899
+ help_lines << help_line("#{":as N".fg("10")}", "Set interval (secs)", "#{":as".fg("10")}", "Show autosave status")
1900
+ help_lines << help_line("#{"q".fg("10")}", "Quit (asks to save)", "#{"Q".fg("10")}", "Force quit")
1901
+ help_lines << ""
1902
+ help_lines << "#{"COLOR SCHEME".fg("14")}"
1903
+ help_lines << help_line("#{"[X]".fg("10")}", "Completed", "#{"[O]".fg("10").b}", "In progress")
1904
+ help_lines << help_line("#{"[ ]".fg("22")}", "Unchecked", "#{"[-]".fg("2")}", "Partial")
1905
+ help_lines << help_line("#{"[?]".fg("2")}", "Conditionals", "#{"AND:".fg("4")}", "Operators")
1906
+ help_lines << help_line("#{"Date:".fg("1")}", "Properties", "#{"<ref>".fg("5")}", "References")
1907
+ help_lines << help_line("#{"(info)".fg("6")}", "Parentheses", "#{'"text"'.fg("14")}", "Quoted strings")
1908
+ help_lines << help_line("#{"; comment".fg("6")}", "Comments", "#{"#tag".fg("184")}", "Hash tags")
1909
+
1910
+ help = help_lines.join("\n")
1911
+
1912
+ # Store current state
1913
+ saved_items = @items.dup
1914
+ saved_current = @current
1915
+ saved_offset = @offset
1916
+ saved_modified = @modified
1917
+
1918
+ # Create temporary help items for scrolling
1919
+ @items = help.split("\n").map { |line| {"text" => line, "level" => 0, "fold" => false, "raw" => true} }
1920
+ @current = 0
1921
+ @offset = 0
1922
+ @modified = false
1923
+
1924
+ # Help viewer loop
1925
+ loop do
1926
+ render_main
1927
+ @footer.text = "Help | UP/DOWN: scroll | ?: documentation | any other key: return"
1928
+ @footer.refresh
1929
+
1930
+ c = getchr
1931
+ case c
1932
+ when "?"
1933
+ # Restore state before showing documentation
1934
+ @items = saved_items
1935
+ @current = saved_current
1936
+ @offset = saved_offset
1937
+ @modified = saved_modified
1938
+ show_documentation
1939
+ return
1940
+ when "j", "DOWN"
1941
+ move_down
1942
+ when "k", "UP"
1943
+ move_up
1944
+ when "PgUP"
1945
+ page_up
1946
+ when "PgDOWN"
1947
+ page_down
1948
+ when "HOME"
1949
+ @current = 0
1950
+ @offset = 0
1951
+ when "END"
1952
+ @current = @items.length - 1
1953
+ else
1954
+ # Any other key returns to main view
1955
+ break
1956
+ end
1957
+ end
1958
+
1959
+ # Restore original state
1960
+ @items = saved_items
1961
+ @current = saved_current
1962
+ @offset = saved_offset
1963
+ @modified = saved_modified
1964
+ end
1965
+
1966
+ def show_documentation
1967
+ doc = <<~DOC
1968
+
1969
+ #{"HYPERLIST DOCUMENTATION".b}
1970
+ #{"Version 2.6 - Complete Specification".fg("245")}
1971
+
1972
+ #{"Official HyperList Page:".fg("14")} #{"https://isene.org/hyperlist/".fg("5")}
1973
+ #{"By Geir Isene".fg("245")}
1974
+
1975
+ #{"═" * 60}
1976
+
1977
+ #{"OVERVIEW".b}
1978
+
1979
+ HyperList represents a way to describe anything - any state or any
1980
+ transition. It represents data in tree-structure lists with a very
1981
+ rich set of features. It can be used for any structuring of data:
1982
+
1983
+ • Todo lists and project plans
1984
+ • Data structures and business processes
1985
+ • Logic breakdowns and outlines of ideas
1986
+ • Food recipes, instructions, and much more
1987
+
1988
+ Everything. Concise and precise.
1989
+
1990
+ #{"═" * 60}
1991
+
1992
+ #{"HYPERLIST ITEM STRUCTURE".b}
1993
+
1994
+ A HyperList Item consists of these parts in sequence:
1995
+
1996
+ 1. #{"Starter".fg("5")} (optional)
1997
+ • Identifier: Numbers (1.1.1) or mixed (1A1A)
1998
+ • Multi-line indicator: + (plus sign)
1999
+
2000
+ 2. #{"Type".fg("5")} (optional)
2001
+ • State: S: or |
2002
+ • Transition: T: or /
2003
+
2004
+ 3. #{"Content".fg("5")} (required)
2005
+ • Element: Operator, Qualifier, Property, or Description
2006
+ • Additive: Reference, Tag, Comment, Quote, Change Markup
2007
+
2008
+ 4. #{"Separator".fg("5")}
2009
+ • Newline (for new items)
2010
+ • Semicolon (for same-line items)
2011
+
2012
+ #{"═" * 60}
2013
+
2014
+ #{"BASIC STRUCTURE".b}
2015
+
2016
+ • Items are organized hierarchically using TAB indentation
2017
+ • Each level of indentation represents a deeper level
2018
+ • Children add information to their parents
2019
+ • A separator between items reads as "then" or "and"
2020
+
2021
+ #{"CHECKBOXES".b}
2022
+
2023
+ [_] Unchecked item (underscore placeholder)
2024
+ [ ] Unchecked item (space placeholder)
2025
+ [X] Completed item
2026
+ [x] Completed item (lowercase)
2027
+ [O] Item in progress
2028
+ [-] Partially completed item
2029
+
2030
+ Use 'v' to toggle checkboxes through states.
2031
+ Use 'V' to toggle with automatic timestamp on completion.
2032
+ Format: [x] YYYY-MM-DD HH.MM: (timestamp after checkbox)
2033
+
2034
+ #{"QUALIFIERS".b}
2035
+
2036
+ Qualifiers in square brackets tell how many times, when, or under
2037
+ what conditions an item exists or is executed:
2038
+
2039
+ [?] Optional item
2040
+ [3] Do item 3 times
2041
+ [1+] Do item 1 or more times
2042
+ [2..5] Do item 2 to 5 times
2043
+ [<4] Do item less than 4 times
2044
+ [>2] Do item more than 2 times
2045
+ [Condition] Do item if condition is true
2046
+ [?Condition] Same as above, with explicit "if"
2047
+ [3, foo=true] Do item 3 times while foo=true
2048
+ [A. B. C] Do item in context A, then B, then C
2049
+
2050
+ Complex example:
2051
+ [2..7, Raining, Temp<5°C] Do item 2-7 times while raining and cold
2052
+
2053
+ #{"TIMESTAMPS".b}
2054
+
2055
+ ISO-8601 format: YYYY-MM-DD HH.MM.SS (can be shortened)
2056
+
2057
+ Absolute timestamps:
2058
+ [2025-08-11] On this date
2059
+ [2025-08-11 14.30] At this date and time
2060
+
2061
+ Relative timestamps:
2062
+ [+YYYY-MM-DD] Wait this long before doing item
2063
+ [-YYYY-MM-DD] Wait this long before next item
2064
+ [+1 week] Wait one week
2065
+ [<+4 days] Less than 4 days after previous
2066
+
2067
+ Recurring items:
2068
+ [2025-05-01+7] Every 7 days starting from date
2069
+ [YYYY-MM-03] Every 3rd of every month
2070
+ [Tue,Fri 12.00] Noon every Tuesday and Friday
2071
+
2072
+ #{"PROPERTIES".b}
2073
+
2074
+ Any text followed by colon is a property (displayed in red):
2075
+
2076
+ Location: Conference room
2077
+ Priority: High
2078
+ 2025-08-11 14.30: Meeting starts
2079
+ State: Active
2080
+
2081
+ #{"OPERATORS".b}
2082
+
2083
+ ALL CAPS followed by colon (displayed in blue):
2084
+
2085
+ AND: All children must be true/done
2086
+ OR: One of the children must be true/done
2087
+ AND/OR: One or more children
2088
+ NOT: Negation - don't do the following
2089
+ THEN: Sequence indicator
2090
+ IF: Conditional execution
2091
+ WHEN: Time-based condition
2092
+ WHILE: Duration condition
2093
+ UNLESS: Negative condition
2094
+ UNTIL: End condition
2095
+ CONTINUOUS: Items run continuously
2096
+ ENCRYPTION: Following items are encrypted
2097
+ EXAMPLES: List of examples follows
2098
+ CHOOSE: Select from options
2099
+
2100
+ #{"REFERENCES".b}
2101
+
2102
+ Enclosed in angle brackets < > (displayed in magenta):
2103
+
2104
+ Hard reference (redirect):
2105
+ <Item name> Jump to referenced item
2106
+ <<Item name>> Jump to item and return after
2107
+
2108
+ Soft reference (information):
2109
+ See <Other item> Look for more info at reference
2110
+ (<Related>) Apropos reference in parentheses
2111
+
2112
+ Special references:
2113
+ <+3> 3 items down
2114
+ <-5> 5 items up
2115
+ SKIP End current list level
2116
+ END End whole list
2117
+
2118
+ Path references:
2119
+ <Parent/Child/Item> Navigate down hierarchy
2120
+ <https://url.com> External URL
2121
+ <file:/path/to/file> File reference
2122
+
2123
+ #{"TEXT FORMATTING".b}
2124
+
2125
+ *bold text* Bold formatting
2126
+ /italic text/ Italic formatting
2127
+ _underlined_ Underlined text
2128
+
2129
+ Formatting can be combined and nested.
2130
+
2131
+ #{"ADDITIVES".b}
2132
+
2133
+ #{"Comments".fg("6")} (in parentheses or after semicolon):
2134
+ (This is a comment) Not executed in transitions
2135
+ ; Line comment Everything after ; is a comment
2136
+
2137
+ #{"Quotes".fg("14")} (in quotation marks):
2138
+ "Literal text" Not interpreted as HyperList
2139
+ 'Also literal' Single quotes work too
2140
+
2141
+ #{"Tags".fg("184")} (hashtags):
2142
+ #TODO #important Markers for categorization
2143
+ #ProjectAlpha No spaces allowed in tags
2144
+
2145
+ #{"SUBSTITUTIONS".b}
2146
+
2147
+ Use curly brackets for variable substitution:
2148
+
2149
+ [fruit = apples, oranges] Eat {fruit}
2150
+ → Eat apples, then oranges
2151
+
2152
+ Ask user for input; Use {input}
2153
+ → Variable from user interaction
2154
+
2155
+ #{"MULTI-LINE ITEMS".b}
2156
+
2157
+ Use + prefix for items spanning multiple lines:
2158
+
2159
+ + This is a long item that continues
2160
+ on the next line with a space prefix
2161
+
2162
+ If one item at a level is multi-line, all items at that
2163
+ level must start with + or an identifier.
2164
+
2165
+ #{"SEPARATORS".b}
2166
+
2167
+ Newline → "then" (for sequence) or "and" (for children)
2168
+ Semicolon → Compact multiple items on one line
2169
+
2170
+ Examples:
2171
+ Task A
2172
+ Subtask 1
2173
+ Subtask 2
2174
+ → Task A consists of Subtask 1 and Subtask 2
2175
+
2176
+ Task A; Task B; Task C
2177
+ → Task A then Task B then Task C
2178
+
2179
+ #{"CHANGE MARKUP".b}
2180
+
2181
+ Special tags for document editing:
2182
+
2183
+ ##< Mark item for deletion
2184
+ ##> Mark item to be moved
2185
+ ##<- Indent item left
2186
+ ##-> Indent item right
2187
+ ##Text## Mark item as changed
2188
+
2189
+ #{"LITERAL BLOCKS".b}
2190
+
2191
+ Escape HyperList interpretation with backslash blocks:
2192
+
2193
+ \\
2194
+ Everything here is literal text
2195
+ [This] is not a <qualifier> or reference
2196
+ Until the closing backslash
2197
+ \\
2198
+
2199
+ #{"EXAMPLES".b}
2200
+
2201
+ #{"Simple Todo List:".fg("1")}
2202
+ [ ] Buy groceries
2203
+ [ ] Milk
2204
+ [ ] Bread
2205
+ [X] Call dentist
2206
+ [O] Write report
2207
+
2208
+ #{"Process with Conditions:".fg("1")}
2209
+ Morning routine
2210
+ Check weather
2211
+ [Raining] Get umbrella
2212
+ [Cold] Wear jacket
2213
+ Make coffee
2214
+ [Weekday] Leave by 8am
2215
+
2216
+ #{"Project with References:".fg("1")}
2217
+ Project Setup
2218
+ Define requirements <specs.doc>
2219
+ Create timeline
2220
+ [2 weeks] Development phase
2221
+ [1 week] Testing <Testing/Plan>
2222
+ Deploy to production
2223
+
2224
+ #{"Complex Logic:".fg("1")}
2225
+ System check
2226
+ IF: Error detected
2227
+ Log error
2228
+ NOT: Continue processing
2229
+ Send alert
2230
+ ELSE:
2231
+ Continue normal operation
2232
+
2233
+ #{"═" * 60}
2234
+
2235
+ #{"COMPLETE HYPERLIST DEFINITION (from specification v2.6)".b}
2236
+
2237
+ HyperList
2238
+ [1+] HyperList Item
2239
+ [?] Starter; OR:
2240
+ Identifier (Numbers: Format = "1.1.1.1", Mixed: Format = "1A1A")
2241
+ [? Multi-line Item] The Identifier acts like the plus sign ("+")
2242
+ Multi-line Indicator = "+"
2243
+ + If one Item on a certain indent is multi-line, all Items on the same indent
2244
+ (including single-line Items) must start with a plus sign ("+") or <Identifier>
2245
+ and all lines on the same indent after the first line must start with a space
2246
+ [?] Type
2247
+ OR:
2248
+ State = "S:" or "|"
2249
+ Transition = "T:" or "/"
2250
+ Children inherit Type from parent unless marked with different Type
2251
+ Can be skipped when the Item is obviously a state or transition
2252
+ Content; AND/OR:
2253
+ Element; AND/OR:
2254
+ Operator
2255
+ Anything operating on an Item or a set of Items
2256
+ [? Set of Items] Items are indented below the Operator
2257
+ Can be any of the usual logical operators
2258
+ Is written in capitals ending in a colon and a space
2259
+ EXAMPLES: "AND: ", "OR: ", "AND/OR: ", "NOT: ", "IMPLIES: "
2260
+ Can contain a Comment to specify the Operator
2261
+ EXAMPLE: "OR(PRIORITY): "
2262
+ Sub-Items are to be chosen in the order of priority as listed
2263
+ To make the Item run continuously, use "CONTINUOUS: "
2264
+ Item is done concurrent with remaining Items
2265
+ The Operator can be combined with a timestamp Tag
2266
+ EXAMPLE: "CONTINUOUS: YYYY-MM-07:" = Do the Item weekly
2267
+ To show that an Item is encrypted or is to be encrypted, use "ENCRYPTION: "
2268
+ OR:
2269
+ The encrypted block can be correctly indented
2270
+ The encrypted block contains indentation and is left justified
2271
+ This would seem to break the list, but is allowed
2272
+ A block can be included of "literal text" negating any HyperList markup
2273
+ Use a HyperList Item containing only the Operator "\\" to mark start/end
2274
+ EXAMPLE:
2275
+ \\
2276
+ This is a block of literal text...
2277
+ Where nothing counts as HyperList markup
2278
+ Thus - neither this: [?] nor THIS: <Test> - are seen as markup
2279
+ ...until we end this block with...
2280
+ \\
2281
+ Qualifier
2282
+ Any statement in square brackets that qualifies an Item
2283
+ Specifies under what conditions an Item is to be executed, exists or is valid
2284
+ Several Qualifiers can be strung together, separated by commas
2285
+ All Qualifiers need to be fulfilled for the Item to be valid
2286
+ EXAMPLE: "[+YYYY-MM-DD 02.30, Button color = Red, 4, ?] Push button"
2287
+ Successive Qualifiers can be strung together, separated by periods; EXAMPLE:
2288
+ "[Apples. Oranges. Grapes]"
2289
+ Do Item in the context of "apples", then "oranges", then "grapes"
2290
+ EXAMPLES:
2291
+ Do Item 3 times = "[3]"
2292
+ Do Item if "the mail has arrived" = "[The mail has arrived]"
2293
+ Do Item 2 times while "foo=true" = "[2, foo=true]"
2294
+ Do Item from 3 to 5 times while "bar=false" = "[3..5, bar=false]"
2295
+ Do Item 1 or more times = "[1+]"
2296
+ Do Item 1 or more times while "Bob is polite" = "[1+, Bob is polite]"
2297
+ Do Item up to 4 times only while "zoo=0" = "[<4, zoo=0]"
2298
+ Optional Item = "[?]"
2299
+ Timestamp Qualifier = "[YYYY-MM-DD hh.mm.ss]"
2300
+ Shorten the format to the appropriate granularity
2301
+ Time relations
2302
+ Length of time to wait before doing the Item = "[+YYYY-MM-DD]"
2303
+ Less than a certain length of time after previous Item = "[<+YYYY-MM-DD]"
2304
+ More than a certain length of time after previous Item = "[>+YYYY-MM-DD]"
2305
+ Length of time to wait before doing next Item = "[-YYYY-MM-DD]"
2306
+ Less than a certain length of time before next Item = "[<-YYYY-MM-DD]"
2307
+ More than a certain length of time before next Item = "[>-YYYY-MM-DD]"
2308
+ Length of time to wait after doing referenced Item = "[+YYYY-MM-DD<Item>]"
2309
+ Other obvious time indicators may be used; EXAMPLES:
2310
+ "[+1 week]"
2311
+ "[-2 Martian years]"
2312
+ EXAMPLES:
2313
+ Wait one month before doing the Item = "[+YYYY-01-DD]"
2314
+ Do Item less than 4 days before next Item = "[<-YYYY-MM-04]"
2315
+ Wait one year and two days after Item X = "[+0001-00-02<X>]"
2316
+ Time repetition
2317
+ Obvious/intuitive repetition
2318
+ EXAMPLES:
2319
+ "[YYYY-MM-03]" = The third of every month
2320
+ "[YYYY-12-DD]" = Every day in every December
2321
+ "[2011-MM-05]" = The fifth of every month of 2011
2322
+ "[Tue,Fri 12.00]" = Noon every Tuesday and Friday
2323
+ Strict convention
2324
+ Format = YYYY-MM-DD+X Day hh.mm+Y - YYYY-MM-DD hh.mm; EXAMPLES:
2325
+ "[2011-05-01+7 13.00]" = 2011-05-01 1pm, repeated every 7 days
2326
+ "[2011-05-01+2,3,2]" = Every 2, then 3, then 2 days, in repetition
2327
+ "[2011-05-01+2 - 2012-05-01]" = Every second day for one year
2328
+ "[2011-05-01 13.00+1]" = 2011-05-01 1pm, repeated every hour
2329
+ "[2011-05-01 Fri,Sat - 2011-10-01]" = Every Fri & Sat in time interval
2330
+ Checking off Items
2331
+ Unchecked Item = "[_]"
2332
+ Item in progress = "[O]"
2333
+ Checked Item = "[x]"
2334
+ [?] Timestamp Tag after ("[x] YYYY-MM-DD hh.mm:ss:")
2335
+ Substitution
2336
+ Any statement in curly brackets is a substitution; EXAMPLES:
2337
+ "[fruit = apples, oranges, bananas] Eat {fruit}"
2338
+ Eat apples, then oranges, then bananas
2339
+ "Ask which painting she likes the best; Buy {painting}"
2340
+ Property
2341
+ Any attribute to the <Content>, ending in a colon and a space
2342
+ Gives additional information or description to the Item
2343
+ EXAMPLES:
2344
+ "Location = Someplace:", "Color = Green:", "Strength = Medium:" and "In Norway:"
2345
+ Description
2346
+ The main body of the HyperList Item, the "meat" of the line
2347
+ Additive; AND/OR:
2348
+ Reference
2349
+ + An Item name or Identifier, list name or anything else
2350
+ enclosed in angle brackets ("<>"); EXAMPLES:
2351
+ Reference to a website = "<http://www.isene.org/>"
2352
+ Reference to a file = "<file:/path/to/filename>"
2353
+ + There are two types of References; OR:
2354
+ Redirection (hard Reference)
2355
+ An Item consisting only of a Reference is a redirection
2356
+ For a transition Item = Jump to referenced Item and execute
2357
+ + If the redirect is to jump back after executing
2358
+ the referenced Item (and its children), then add another
2359
+ set of angle brackets (<<Referenced Item>>)
2360
+ + EXAMPLE: Use this when creating subroutines at
2361
+ the end of the list
2362
+ For a state Item = Include the referenced Item
2363
+ An Item consisting only of the key word "SKIP"
2364
+ Ends the current HyperList level
2365
+ An Item consisting only of the key word "END"
2366
+ Ends the whole HyperList
2367
+ Soft Reference
2368
+ Reference is part of an Item
2369
+ Look at referenced Item for info only
2370
+ Even softer Reference = have the Reference in parentheses
2371
+ An Item that is only something apropos
2372
+ + A Reference to any Item upward in the HyperList is simply a
2373
+ Reference to the Item's <Content>
2374
+ + A Reference containing several levels down a HyperList needs a "/"
2375
+ to separate each level, like a "path" (as with a URL) to the Item
2376
+ + To make a Reference to a different branch in a HyperList,
2377
+ start the Reference from the highest common level on the list
2378
+ and include all Items down to the referenced Item
2379
+ EXAMPLE: Reference from here to <Hyperlist Item/Starter/Identifier>
2380
+ + For long Items in a Reference, concatenation can be used
2381
+ The concatenation must be unique
2382
+ EXAMPLE: Reference from here to <Additive/Comment/Anything...>
2383
+ Tag
2384
+ A hash sign followed by any letters or numbers, used as a marker (#Tagged)
2385
+ Is not executed as a HyperList command
2386
+ Comment
2387
+ Anything within parentheses is a Comment
2388
+ Is not executed as a HyperList command
2389
+ Quote
2390
+ Anything in quotation marks is a Quote
2391
+ Is not executed as a HyperList command
2392
+ Change Markup; OR:
2393
+ Deletion
2394
+ Remove the Item by adding "##<" at the end of the Item
2395
+ Motion; OPTIONS:
2396
+ Move the Item by adding "##><Reference>"
2397
+ This moves the Item just below the referenced Item
2398
+ Move the Item one level in by adding "##<-" at the end of the Item
2399
+ Move the Item one level out by adding "##->" at the end of the Item
2400
+ EXAMPLE: Move an Item as a child to referenced Item = "##><Reference>##->"
2401
+ Changed Item
2402
+ Prefix an Item with "##Text##" to signify that a change has been made
2403
+ Add information inside the angle brackets as appropriate
2404
+ EXAMPLE: To show who changed it and when = "##John 2012-03-21##"
2405
+ Separator
2406
+ OR:
2407
+ Semicolon
2408
+ A semicolon is used to separate two HyperList Items on the same line
2409
+ Newline
2410
+ Used to add another Item on the same level
2411
+ Indent
2412
+ A Tab or an asterisk ("*")
2413
+ Used to add a child
2414
+ A child adds information to its parent
2415
+ A child is another regular <HyperList Item>
2416
+ Definition
2417
+ A Separator and the same or less indent normally reads "then:"
2418
+ [? Parent contains <Description>]
2419
+ The Separator and right indent reads "with:" or "consists of:"
2420
+ [? NOT: parent contains <Description>]
2421
+ The Separator and right indent reads "applies to:"
2422
+ A Separator between the children reads "and:"
2423
+
2424
+ #{"═" * 60}
2425
+
2426
+ HyperList is self-describing and Turing complete.
2427
+ It can represent literally anything.
2428
+
2429
+ For more information and examples:
2430
+ #{"https://isene.org/hyperlist/".fg("5")}
2431
+
2432
+ #{"═" * 60}
2433
+
2434
+ Press 'q' to return to your list, PgUp/PgDn to scroll
2435
+ DOC
2436
+
2437
+ # Store current state
2438
+ saved_items = @items.dup
2439
+ saved_current = @current
2440
+ saved_offset = @offset
2441
+ saved_filename = @filename
2442
+ saved_modified = @modified
2443
+
2444
+ # Create temporary documentation items
2445
+ @items = doc.split("\n").map { |line| {"text" => line, "level" => 0, "fold" => false, "raw" => true} }
2446
+ @current = 0
2447
+ @offset = 0
2448
+ @modified = false
2449
+
2450
+ # Documentation viewer loop
2451
+ loop do
2452
+ render_main
2453
+ @footer.text = "HyperList Documentation | q: return | PgUp/PgDn: scroll"
2454
+ @footer.refresh
2455
+
2456
+ c = getchr
2457
+ case c
2458
+ when "q", "ESC"
2459
+ break
2460
+ when "?"
2461
+ # Allow ?? to exit documentation
2462
+ if getchr == "?"
2463
+ break
2464
+ end
2465
+ when "j", "DOWN"
2466
+ move_down
2467
+ when "k", "UP"
2468
+ move_up
2469
+ when "PgUP"
2470
+ page_up
2471
+ when "PgDOWN"
2472
+ page_down
2473
+ when "HOME"
2474
+ @current = 0
2475
+ @offset = 0
2476
+ when "END"
2477
+ @current = @items.length - 1
2478
+ when "g"
2479
+ if getchr == "g"
2480
+ @current = 0
2481
+ @offset = 0
2482
+ end
2483
+ when "G"
2484
+ @current = @items.length - 1
2485
+ end
2486
+ end
2487
+
2488
+ # Restore original state
2489
+ @items = saved_items
2490
+ @current = saved_current
2491
+ @offset = saved_offset
2492
+ @filename = saved_filename
2493
+ @modified = saved_modified
2494
+ end
2495
+
2496
+ def handle_command
2497
+ @mode = :command
2498
+ @command = @footer.ask(":", "")
2499
+ @mode = :normal
2500
+ @footer.clear # Clear footer immediately
2501
+ @footer.refresh
2502
+
2503
+ return unless @command
2504
+
2505
+ case @command
2506
+ when "w", "write"
2507
+ if @filename
2508
+ save_file
2509
+ else
2510
+ filename_input = @footer.ask("Save as: ", "")
2511
+ @filename = File.expand_path(filename_input) if filename_input && !filename_input.empty?
2512
+ save_file if @filename && !@filename.empty?
2513
+ end
2514
+ when "q", "quit"
2515
+ if @modified
2516
+ @message = "Unsaved changes! Use :q! or Q to force quit"
2517
+ else
2518
+ quit
2519
+ end
2520
+ when "q!"
2521
+ quit
2522
+ when "wq", "x"
2523
+ if @filename
2524
+ save_file
2525
+ else
2526
+ filename_input = @footer.ask("Save as: ", "")
2527
+ @filename = File.expand_path(filename_input) if filename_input && !filename_input.empty?
2528
+ save_file if @filename && !@filename.empty?
2529
+ end
2530
+ quit
2531
+ when /^w\s+(.+)/
2532
+ @filename = File.expand_path($1)
2533
+ save_file
2534
+ when /^e\s+(.+)/
2535
+ if @modified
2536
+ @message = "Unsaved changes! Use :e! to force"
2537
+ else
2538
+ @filename = File.expand_path($1)
2539
+ load_file(@filename)
2540
+ @current = 0
2541
+ @offset = 0
2542
+ end
2543
+ when /^e!\s+(.+)/
2544
+ @filename = File.expand_path($1)
2545
+ load_file(@filename)
2546
+ @current = 0
2547
+ @offset = 0
2548
+ @modified = false
2549
+ when /^(export|ex)\s+(md|markdown|html|txt|text)\s*(.*)$/
2550
+ format = $2
2551
+ export_file = $3.empty? ? nil : $3
2552
+ export_to(format, export_file)
2553
+ when /^(export|ex)$/
2554
+ @message = "Usage: :export [md|html|txt] [filename] (or :ex)"
2555
+ when "recent", "r"
2556
+ show_recent_files
2557
+ when "autosave on", "as on"
2558
+ @auto_save_enabled = true
2559
+ @message = "Auto-save enabled (every #{@auto_save_interval} seconds)"
2560
+ when "autosave off", "as off"
2561
+ @auto_save_enabled = false
2562
+ @message = "Auto-save disabled"
2563
+ when /^(autosave|as) (\d+)$/
2564
+ @auto_save_interval = $2.to_i
2565
+ @message = "Auto-save interval set to #{@auto_save_interval} seconds"
2566
+ when "autosave", "as"
2567
+ status = @auto_save_enabled ? "enabled" : "disabled"
2568
+ @message = "Auto-save is #{status} (interval: #{@auto_save_interval}s)"
2569
+ when "template", "templates", "t"
2570
+ show_templates
2571
+ when "foldlevel"
2572
+ level = @footer.ask("Fold to level (0-9): ", "")
2573
+ if level =~ /^[0-9]$/
2574
+ expand_to_level(level.to_i)
2575
+ @message = "Folded to level #{level}"
2576
+ end
2577
+ when "performance", "perf"
2578
+ show_performance_stats
2579
+ when "graph", "hypergraph", "g"
2580
+ export_to_graph
2581
+ when "vsplit", "vs"
2582
+ toggle_split_view
2583
+ when "split"
2584
+ # Copy current section to split view
2585
+ copy_section_to_split
2586
+ else
2587
+ @message = "Unknown command: #{@command}"
2588
+ end
2589
+ end
2590
+
2591
+ def jump_to_reference
2592
+ visible = get_visible_items
2593
+ return if @current >= visible.length
2594
+
2595
+ text = visible[@current]["text"]
2596
+
2597
+ # Look for references in the format <reference> or <<reference>>
2598
+ if text =~ /<{1,2}([^>]+)>{1,2}/
2599
+ reference = $1
2600
+
2601
+ # Check if it's a URL or file reference
2602
+ if reference =~ /^(https?:\/\/|file:)/
2603
+ open_external_reference(reference)
2604
+ return
2605
+ end
2606
+
2607
+ # Search for the referenced item in the list
2608
+ target_index = find_item_by_reference(reference)
2609
+
2610
+ if target_index
2611
+ @current = target_index
2612
+ @message = "Jumped to: #{reference}"
2613
+ else
2614
+ @message = "Reference not found: #{reference}"
2615
+ end
2616
+ else
2617
+ @message = "No reference found on this line"
2618
+ end
2619
+ end
2620
+
2621
+ def open_file_reference
2622
+ visible = get_visible_items
2623
+ return if @current >= visible.length
2624
+
2625
+ text = visible[@current]["text"]
2626
+
2627
+ # Look for file references
2628
+ if text =~ /<file:([^>]+)>/
2629
+ filepath = $1
2630
+ open_external_file(filepath)
2631
+ elsif text =~ /<([^>]+\.[^>]+)>/ # Anything with an extension
2632
+ filepath = $1
2633
+ if File.exist?(filepath)
2634
+ open_external_file(filepath)
2635
+ else
2636
+ @message = "File not found: #{filepath}"
2637
+ end
2638
+ else
2639
+ @message = "No file reference found on this line"
2640
+ end
2641
+ end
2642
+
2643
+ def find_item_by_reference(reference)
2644
+ # Handle path-style references (e.g., "Item/SubItem/Detail")
2645
+ parts = reference.split('/')
2646
+
2647
+ visible = get_visible_items
2648
+
2649
+ # First try exact match
2650
+ visible.each_with_index do |item, idx|
2651
+ if item["text"].include?(reference) ||
2652
+ item["text"].gsub(/\[[^\]]*\]/, '').strip.include?(reference)
2653
+ return idx
2654
+ end
2655
+ end
2656
+
2657
+ # Try matching by path components
2658
+ if parts.length > 1
2659
+ current_level = 0
2660
+ found_indices = []
2661
+
2662
+ parts.each do |part|
2663
+ found = false
2664
+ visible.each_with_index do |item, idx|
2665
+ next if found_indices.include?(idx)
2666
+
2667
+ clean_text = item["text"].gsub(/\[[^\]]*\]/, '').strip
2668
+ if clean_text.include?(part) && item["level"] >= current_level
2669
+ found_indices << idx
2670
+ current_level = item["level"]
2671
+ found = true
2672
+ break
2673
+ end
2674
+ end
2675
+
2676
+ return nil unless found
2677
+ end
2678
+
2679
+ return found_indices.last
2680
+ end
2681
+
2682
+ # Try partial match
2683
+ visible.each_with_index do |item, idx|
2684
+ clean_text = item["text"].gsub(/\[[^\]]*\]/, '').strip
2685
+ if clean_text.downcase.include?(reference.downcase)
2686
+ return idx
2687
+ end
2688
+ end
2689
+
2690
+ nil
2691
+ end
2692
+
2693
+ def open_external_reference(url)
2694
+ # Try to open URL in default browser
2695
+ if RUBY_PLATFORM =~ /darwin/
2696
+ system("open", url)
2697
+ elsif RUBY_PLATFORM =~ /linux/
2698
+ system("xdg-open", url)
2699
+ else
2700
+ @message = "Cannot open URL on this platform"
2701
+ end
2702
+ end
2703
+
2704
+ def open_external_file(filepath)
2705
+ # Expand path if needed
2706
+ filepath = File.expand_path(filepath)
2707
+
2708
+ if File.exist?(filepath)
2709
+ if filepath.end_with?('.hl')
2710
+ # Open HyperList file in this app
2711
+ if @modified
2712
+ @message = "Save current file first"
2713
+ else
2714
+ @filename = File.expand_path(filepath)
2715
+ load_file(@filename)
2716
+ @current = 0
2717
+ @offset = 0
2718
+ @modified = false
2719
+ end
2720
+ else
2721
+ # Open in default application
2722
+ if RUBY_PLATFORM =~ /darwin/
2723
+ system("open", filepath)
2724
+ elsif RUBY_PLATFORM =~ /linux/
2725
+ system("xdg-open", filepath)
2726
+ else
2727
+ @message = "Cannot open file on this platform"
2728
+ end
2729
+ end
2730
+ else
2731
+ @message = "File not found: #{filepath}"
2732
+ end
2733
+ end
2734
+
2735
+ def load_templates
2736
+ {
2737
+ "project" => [
2738
+ {"text" => "Project: [Project Name]", "level" => 0},
2739
+ {"text" => "[_] Define project scope", "level" => 1},
2740
+ {"text" => "[_] Identify stakeholders", "level" => 1},
2741
+ {"text" => "[_] Create timeline", "level" => 1},
2742
+ {"text" => "Planning", "level" => 1},
2743
+ {"text" => "[_] Resource allocation", "level" => 2},
2744
+ {"text" => "[_] Risk assessment", "level" => 2},
2745
+ {"text" => "[_] Budget planning", "level" => 2},
2746
+ {"text" => "Implementation", "level" => 1},
2747
+ {"text" => "[_] Phase 1: Foundation", "level" => 2},
2748
+ {"text" => "[_] Phase 2: Development", "level" => 2},
2749
+ {"text" => "[_] Phase 3: Testing", "level" => 2},
2750
+ {"text" => "[_] Phase 4: Deployment", "level" => 2},
2751
+ {"text" => "Review", "level" => 1},
2752
+ {"text" => "[_] Gather feedback", "level" => 2},
2753
+ {"text" => "[_] Document lessons learned", "level" => 2},
2754
+ {"text" => "[_] Archive project materials", "level" => 2}
2755
+ ],
2756
+ "meeting" => [
2757
+ {"text" => "Meeting: [Title]", "level" => 0},
2758
+ {"text" => "Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", "level" => 1},
2759
+ {"text" => "Location: [Conference Room/Online]", "level" => 1},
2760
+ {"text" => "Attendees", "level" => 1},
2761
+ {"text" => "[Name 1]", "level" => 2},
2762
+ {"text" => "[Name 2]", "level" => 2},
2763
+ {"text" => "Agenda", "level" => 1},
2764
+ {"text" => "[_] Opening remarks", "level" => 2},
2765
+ {"text" => "[_] Review previous action items", "level" => 2},
2766
+ {"text" => "[_] Main topics", "level" => 2},
2767
+ {"text" => "Topic 1: [Description]", "level" => 3},
2768
+ {"text" => "Topic 2: [Description]", "level" => 3},
2769
+ {"text" => "[_] Q&A session", "level" => 2},
2770
+ {"text" => "[_] Next steps", "level" => 2},
2771
+ {"text" => "Action Items", "level" => 1},
2772
+ {"text" => "[_] [Action 1] - Assigned to: [Name] - Due: [Date]", "level" => 2},
2773
+ {"text" => "[_] [Action 2] - Assigned to: [Name] - Due: [Date]", "level" => 2},
2774
+ {"text" => "Notes", "level" => 1},
2775
+ {"text" => "[Add meeting notes here]", "level" => 2}
2776
+ ],
2777
+ "daily" => [
2778
+ {"text" => "Daily Plan: #{Time.now.strftime('%Y-%m-%d')}", "level" => 0},
2779
+ {"text" => "Morning Routine", "level" => 1},
2780
+ {"text" => "[_] Review calendar", "level" => 2},
2781
+ {"text" => "[_] Check emails", "level" => 2},
2782
+ {"text" => "[_] Plan priorities", "level" => 2},
2783
+ {"text" => "Priority Tasks", "level" => 1},
2784
+ {"text" => "[_] [High Priority Task 1]", "level" => 2},
2785
+ {"text" => "[_] [High Priority Task 2]", "level" => 2},
2786
+ {"text" => "[_] [High Priority Task 3]", "level" => 2},
2787
+ {"text" => "Regular Tasks", "level" => 1},
2788
+ {"text" => "[_] [Task 1]", "level" => 2},
2789
+ {"text" => "[_] [Task 2]", "level" => 2},
2790
+ {"text" => "Meetings/Appointments", "level" => 1},
2791
+ {"text" => "[Time] - [Meeting/Event]", "level" => 2},
2792
+ {"text" => "Notes", "level" => 1},
2793
+ {"text" => "[Daily observations and reflections]", "level" => 2}
2794
+ ],
2795
+ "checklist" => [
2796
+ {"text" => "Checklist: [Title]", "level" => 0},
2797
+ {"text" => "[_] Item 1", "level" => 1},
2798
+ {"text" => "[_] Item 2", "level" => 1},
2799
+ {"text" => "[_] Item 3", "level" => 1},
2800
+ {"text" => "[_] Item 4", "level" => 1},
2801
+ {"text" => "[_] Item 5", "level" => 1}
2802
+ ],
2803
+ "brainstorm" => [
2804
+ {"text" => "Brainstorming: [Topic]", "level" => 0},
2805
+ {"text" => "Problem Statement", "level" => 1},
2806
+ {"text" => "[Define the problem or opportunity]", "level" => 2},
2807
+ {"text" => "Ideas", "level" => 1},
2808
+ {"text" => "Category 1", "level" => 2},
2809
+ {"text" => "Idea A", "level" => 3},
2810
+ {"text" => "Idea B", "level" => 3},
2811
+ {"text" => "Category 2", "level" => 2},
2812
+ {"text" => "Idea C", "level" => 3},
2813
+ {"text" => "Idea D", "level" => 3},
2814
+ {"text" => "Evaluation Criteria", "level" => 1},
2815
+ {"text" => "Feasibility", "level" => 2},
2816
+ {"text" => "Impact", "level" => 2},
2817
+ {"text" => "Resources Required", "level" => 2},
2818
+ {"text" => "Next Steps", "level" => 1},
2819
+ {"text" => "[_] Research top ideas", "level" => 2},
2820
+ {"text" => "[_] Create action plan", "level" => 2}
2821
+ ],
2822
+ "recipe" => [
2823
+ {"text" => "Recipe: [Name]", "level" => 0},
2824
+ {"text" => "Servings: [Number]", "level" => 1},
2825
+ {"text" => "Prep Time: [Time]", "level" => 1},
2826
+ {"text" => "Cook Time: [Time]", "level" => 1},
2827
+ {"text" => "Ingredients", "level" => 1},
2828
+ {"text" => "[Amount] [Ingredient 1]", "level" => 2},
2829
+ {"text" => "[Amount] [Ingredient 2]", "level" => 2},
2830
+ {"text" => "[Amount] [Ingredient 3]", "level" => 2},
2831
+ {"text" => "Instructions", "level" => 1},
2832
+ {"text" => "[_] Step 1: [Description]", "level" => 2},
2833
+ {"text" => "[_] Step 2: [Description]", "level" => 2},
2834
+ {"text" => "[_] Step 3: [Description]", "level" => 2},
2835
+ {"text" => "Notes", "level" => 1},
2836
+ {"text" => "[Tips, variations, serving suggestions]", "level" => 2}
2837
+ ]
2838
+ }
2839
+ end
2840
+
2841
+ def show_templates
2842
+ # Save current state
2843
+ saved_items = @items.dup
2844
+ saved_current = @current
2845
+ saved_offset = @offset
2846
+ saved_filename = @filename
2847
+ saved_modified = @modified
2848
+
2849
+ # Create items for template selection
2850
+ @items = []
2851
+ @items << {"text" => "TEMPLATES (press Enter to insert, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
2852
+ @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
2853
+
2854
+ template_list = [
2855
+ ["project", "Project Plan - Complete project management template"],
2856
+ ["meeting", "Meeting Agenda - Structure for meeting notes"],
2857
+ ["daily", "Daily Planner - Daily task and schedule template"],
2858
+ ["checklist", "Simple Checklist - Basic checkbox list"],
2859
+ ["brainstorm", "Brainstorming Session - Idea generation template"],
2860
+ ["recipe", "Recipe - Cooking recipe structure"]
2861
+ ]
2862
+
2863
+ template_list.each_with_index do |(key, desc), idx|
2864
+ @items << {"text" => "#{idx+1}. #{key.capitalize}: #{desc}", "level" => 0, "fold" => false, "raw" => true, "template_key" => key}
2865
+ end
2866
+
2867
+ @current = 2 # Start at first template
2868
+ @offset = 0
2869
+ @modified = false
2870
+
2871
+ selected_template = nil
2872
+
2873
+ # Template viewer loop
2874
+ loop do
2875
+ render_main
2876
+ @footer.text = "Templates | Enter: insert | q: cancel | j/k: navigate"
2877
+ @footer.refresh
2878
+
2879
+ c = getchr
2880
+ case c
2881
+ when "q", "ESC"
2882
+ # Restore original state
2883
+ @items = saved_items
2884
+ @current = saved_current
2885
+ @offset = saved_offset
2886
+ @filename = saved_filename
2887
+ @modified = saved_modified
2888
+ break
2889
+ when "j", "DOWN"
2890
+ move_down if @current < @items.length - 1
2891
+ when "k", "UP"
2892
+ move_up if @current > 2 # Don't go above first template
2893
+ when "ENTER", "l"
2894
+ if @current >= 2 && @items[@current]["template_key"]
2895
+ selected_template = @items[@current]["template_key"]
2896
+ break
2897
+ end
2898
+ when /^[1-6]$/
2899
+ # Allow number key selection
2900
+ idx = c.to_i - 1
2901
+ if idx < template_list.length
2902
+ selected_template = template_list[idx][0]
2903
+ break
2904
+ end
2905
+ end
2906
+ end
2907
+
2908
+ # Insert selected template
2909
+ if selected_template
2910
+ # Restore original state first
2911
+ @items = saved_items
2912
+ @current = saved_current
2913
+ @offset = saved_offset
2914
+ @filename = saved_filename
2915
+ @modified = saved_modified
2916
+
2917
+ insert_template(selected_template)
2918
+ end
2919
+ end
2920
+
2921
+ def insert_template(template_key)
2922
+ template_items = @templates[template_key]
2923
+ return unless template_items
2924
+
2925
+ save_state_for_undo
2926
+
2927
+ # Get current item level to adjust template indentation
2928
+ current_level = @items[@current]["level"]
2929
+
2930
+ # Insert template items after current position
2931
+ insertion_point = @current + 1
2932
+
2933
+ template_items.each_with_index do |template_item, idx|
2934
+ new_item = {
2935
+ "text" => template_item["text"],
2936
+ "level" => current_level + template_item["level"],
2937
+ "fold" => false
2938
+ }
2939
+ @items.insert(insertion_point + idx, new_item)
2940
+ end
2941
+
2942
+ @modified = true
2943
+ @message = "Template '#{template_key}' inserted"
2944
+ end
2945
+
2946
+ def start_macro_recording(key)
2947
+ @macro_recording = true
2948
+ @macro_key = key
2949
+ @macro_buffer = []
2950
+ @message = "Recording macro to register '#{key}'..."
2951
+ end
2952
+
2953
+ def stop_macro_recording
2954
+ if @macro_key && !@macro_buffer.empty?
2955
+ # Remove the 'q' that stopped recording from the buffer if it's there
2956
+ @macro_buffer.pop if @macro_buffer.last == "q"
2957
+ @macro_register[@macro_key] = @macro_buffer.dup
2958
+ @message = "Macro recorded to register '#{@macro_key}' (#{@macro_buffer.length} actions)"
2959
+ else
2960
+ @message = "Macro recording cancelled"
2961
+ end
2962
+ @macro_recording = false
2963
+ @macro_key = nil
2964
+ @macro_buffer = []
2965
+ end
2966
+
2967
+ def play_macro(key)
2968
+ macro = @macro_register[key]
2969
+ if macro && !macro.empty?
2970
+ @message = "Playing macro '#{key}'..."
2971
+ render_footer
2972
+
2973
+ # Replay each action in the macro
2974
+ macro.each do |action|
2975
+ # Simulate the key press
2976
+ case @mode
2977
+ when :normal
2978
+ process_normal_key(action)
2979
+ when :insert
2980
+ # Handle insert mode separately if needed
2981
+ @mode = :normal if action == "ESC"
2982
+ end
2983
+
2984
+ # Update display after each action for visual feedback
2985
+ render
2986
+ end
2987
+
2988
+ @message = "Macro '#{key}' executed (#{macro.length} actions)"
2989
+ else
2990
+ @message = "No macro in register '#{key}'"
2991
+ end
2992
+ end
2993
+
2994
+ def process_normal_key(c)
2995
+ # This processes a single key in normal mode for macro replay
2996
+ case c
2997
+ when "j", "DOWN"
2998
+ move_down
2999
+ when "k", "UP"
3000
+ move_up
3001
+ when "h"
3002
+ go_to_parent
3003
+ when "l"
3004
+ go_to_first_child
3005
+ when "o"
3006
+ insert_line_below
3007
+ when "O"
3008
+ insert_line_above
3009
+ when "a"
3010
+ insert_child
3011
+ when "d"
3012
+ # Check if it's dd
3013
+ if getchr == "d"
3014
+ delete_line
3015
+ end
3016
+ when "y"
3017
+ # Check if it's yy
3018
+ if getchr == "y"
3019
+ copy_line
3020
+ end
3021
+ when "p"
3022
+ paste
3023
+ when "v"
3024
+ toggle_checkbox
3025
+ when "V"
3026
+ toggle_checkbox_with_date
3027
+ when "TAB"
3028
+ indent_with_children
3029
+ when "S-TAB"
3030
+ unindent_with_children
3031
+ when "RIGHT"
3032
+ indent_line
3033
+ when "LEFT"
3034
+ unindent_line
3035
+ when " "
3036
+ toggle_fold
3037
+ when "u"
3038
+ undo
3039
+ when "/"
3040
+ # Skip search in macros for now
3041
+ @message = "Search skipped in macro"
3042
+ when "i", "ENTER"
3043
+ # Skip interactive editing in macros
3044
+ @message = "Text editing skipped in macro"
3045
+ end
3046
+ end
3047
+
3048
+ def toggle_split_view
3049
+ @split_view = !@split_view
3050
+ if @split_view
3051
+ @split_items = @items.dup # Copy current items to split view
3052
+ @split_current = @current
3053
+ @split_offset = @offset
3054
+ @active_pane = :main
3055
+ set_message("Split view enabled. Use 'ww' to switch panes.")
3056
+ else
3057
+ @split_items = []
3058
+ set_message("Split view disabled")
3059
+ end
3060
+ setup_ui
3061
+ render # Re-render everything after changing the UI layout
3062
+ end
3063
+
3064
+ def copy_section_to_split
3065
+ if !@split_view
3066
+ toggle_split_view
3067
+ end
3068
+
3069
+ # Find the current section (same or less indented level)
3070
+ current_item = @items[@current]
3071
+ current_level = current_item["level"]
3072
+
3073
+ # Find section boundaries
3074
+ start_idx = @current
3075
+ end_idx = @current
3076
+
3077
+ # Find start of section (go up to same or less level)
3078
+ (0...@current).reverse_each do |idx|
3079
+ if @items[idx]["level"] <= current_level
3080
+ start_idx = idx
3081
+ break
3082
+ end
3083
+ end
3084
+
3085
+ # Find end of section
3086
+ (@current + 1...@items.length).each do |idx|
3087
+ if @items[idx]["level"] <= current_level
3088
+ break
3089
+ end
3090
+ end_idx = idx
3091
+ end
3092
+
3093
+ # Copy section to split view
3094
+ @split_items = @items[start_idx..end_idx]
3095
+ @split_current = 0
3096
+ @split_offset = 0
3097
+
3098
+ @message = "Section copied to split view (#{@split_items.length} items)"
3099
+ end
3100
+
3101
+ def handle_window_command
3102
+ c = getchr
3103
+ case c
3104
+ when "w"
3105
+ # Switch active pane
3106
+ if @split_view
3107
+ @active_pane = (@active_pane == :main) ? :split : :main
3108
+ @message = "Switched to #{@active_pane} pane"
3109
+ end
3110
+ when "v"
3111
+ # Vertical split
3112
+ toggle_split_view
3113
+ when "o"
3114
+ # Close split, keep only current pane
3115
+ @split_view = false
3116
+ setup_ui
3117
+ @message = "Split view closed"
3118
+ when "="
3119
+ # Make panes equal size (already equal in our implementation)
3120
+ @message = "Panes are equal size"
3121
+ end
3122
+ end
3123
+
3124
+ def render_split_pane
3125
+ return unless @split_view && @split_pane
3126
+
3127
+ # Similar to render_main but for split pane
3128
+ view_height = @split_pane.h
3129
+ if @split_current < @split_offset
3130
+ @split_offset = @split_current
3131
+ elsif @split_current >= @split_offset + view_height
3132
+ @split_offset = @split_current - view_height + 1
3133
+ end
3134
+
3135
+ lines = []
3136
+ start_idx = @split_offset
3137
+ end_idx = [@split_offset + view_height, @split_items.length].min
3138
+
3139
+ (start_idx...end_idx).each do |idx|
3140
+ item = @split_items[idx]
3141
+ next unless item
3142
+
3143
+ line = " " * item["level"]
3144
+
3145
+ # Add fold indicator
3146
+ if has_children_in_array?(idx, @split_items) && item["fold"]
3147
+ line += "▶ ".fg("245")
3148
+ elsif has_children_in_array?(idx, @split_items)
3149
+ line += "▷ ".fg("245")
3150
+ else
3151
+ line += " "
3152
+ end
3153
+
3154
+ # Process text (simplified for split view)
3155
+ line += process_text(item["text"], false)
3156
+
3157
+ # Highlight current line in split pane
3158
+ if idx == @split_current
3159
+ line = line.r # Always use reverse (white background) for current line
3160
+ end
3161
+
3162
+ lines << line
3163
+ end
3164
+
3165
+ @split_pane.text = lines.join("\n")
3166
+ @split_pane.refresh
3167
+ end
3168
+
3169
+ def has_children_in_array?(idx, items_array)
3170
+ return false if idx >= items_array.length - 1
3171
+ current_level = items_array[idx]["level"]
3172
+ next_level = items_array[idx + 1]["level"]
3173
+ next_level > current_level
3174
+ end
3175
+
3176
+ def move_in_active_pane(direction)
3177
+ if @split_view && @active_pane == :split
3178
+ case direction
3179
+ when :down
3180
+ @split_current = [@split_current + 1, @split_items.length - 1].min if @split_current < @split_items.length - 1
3181
+ when :up
3182
+ @split_current = [@split_current - 1, 0].max if @split_current > 0
3183
+ end
3184
+ else
3185
+ case direction
3186
+ when :down
3187
+ move_down
3188
+ when :up
3189
+ move_up
3190
+ end
3191
+ end
3192
+ end
3193
+
3194
+ def show_performance_stats
3195
+ total_items = @items.length
3196
+ visible_items = get_visible_items.length
3197
+ folded_count = @items.count { |item| item["fold"] }
3198
+ cache_size = @processed_cache.size
3199
+ split_info = @split_view ? " | Split: #{@split_items.length} items" : ""
3200
+
3201
+ @message = "Items: #{total_items} (#{visible_items} visible, #{folded_count} folded) | Cache: #{cache_size}#{split_info}"
3202
+ end
3203
+
3204
+ def export_to_graph
3205
+ # Ask user for graph options - return nil if cancelled (ESC pressed)
3206
+ graph_type = @footer.ask("Graph type (state/trans) - ESC to cancel: ", "")
3207
+ return if graph_type.empty?
3208
+ return unless graph_type =~ /^(state|trans)$/
3209
+
3210
+ direction = @footer.ask("Direction (TB/BT/LR/RL) - ESC to cancel: ", "")
3211
+ return if direction.empty?
3212
+ return unless direction =~ /^(TB|BT|LR|RL)$/
3213
+
3214
+ line_style = @footer.ask("Line style (spline/ortho/poly) - ESC to cancel: ", "")
3215
+ return if line_style.empty?
3216
+ return unless line_style =~ /^(spline|ortho|polyline)$/
3217
+ line_style = "polyline" if line_style == "poly"
3218
+
3219
+ theme = @footer.ask("Theme (default/business/tech/pastel) - ESC to cancel: ", "")
3220
+ return if theme.empty?
3221
+ return unless theme =~ /^(default|business|tech|pastel)$/
3222
+
3223
+ # Generate default output filename based on current file
3224
+ default_output = if @filename
3225
+ File.basename(@filename, File.extname(@filename)) + ".png"
3226
+ else
3227
+ "hyperlist_graph.png"
3228
+ end
3229
+
3230
+ output_file = @footer.ask("Output filename (.png) - ESC to cancel: ", default_output)
3231
+ return if output_file == default_output && output_file.empty? # If default was empty and user pressed ESC
3232
+ return if !output_file || output_file.strip.empty?
3233
+ output_file += ".png" unless output_file.end_with?(".png")
3234
+
3235
+ open_after = @footer.ask("Open graph after generation? (y/n) - ESC to cancel: ", "")
3236
+ return if open_after.empty?
3237
+ open_after = open_after.downcase == "y"
3238
+
3239
+ # Generate the graph
3240
+ begin
3241
+ dot_content = generate_dot_graph(graph_type, direction, line_style, theme)
3242
+
3243
+ # Write DOT file temporarily
3244
+ dot_file = output_file.sub(/\.png$/, '.dot')
3245
+ File.write(dot_file, dot_content)
3246
+
3247
+ # Generate PNG using Graphviz
3248
+ system("dot -Tpng #{dot_file} -o #{output_file}")
3249
+
3250
+ # Clean up DOT file
3251
+ File.delete(dot_file) if File.exist?(dot_file)
3252
+
3253
+ if File.exist?(output_file)
3254
+ @message = "Graph exported to #{output_file}"
3255
+
3256
+ # Open the graph if requested
3257
+ if open_after
3258
+ # Try different methods to open the file
3259
+ if system("which xdg-open > /dev/null 2>&1")
3260
+ system("xdg-open #{output_file} 2>/dev/null &")
3261
+ @message += " (opened)"
3262
+ elsif system("which runmailcap > /dev/null 2>&1")
3263
+ system("runmailcap #{output_file} 2>/dev/null &")
3264
+ @message += " (opened)"
3265
+ elsif system("which open > /dev/null 2>&1") # macOS
3266
+ system("open #{output_file} 2>/dev/null &")
3267
+ @message += " (opened)"
3268
+ else
3269
+ @message += " (could not auto-open)"
3270
+ end
3271
+ end
3272
+ else
3273
+ @message = "Failed to generate graph. Is Graphviz installed?"
3274
+ end
3275
+ rescue => e
3276
+ @message = "Graph export failed: #{e.message}"
3277
+ end
3278
+ end
3279
+
3280
+ def generate_dot_graph(type, direction, spline, theme)
3281
+ # Theme definitions
3282
+ themes = {
3283
+ 'default' => {
3284
+ node_color: 'black',
3285
+ edge_color: 'black',
3286
+ font: 'Helvetica',
3287
+ bgcolor: 'white'
3288
+ },
3289
+ 'business' => {
3290
+ node_color: '#2c3e50',
3291
+ edge_color: '#34495e',
3292
+ font: 'Arial',
3293
+ bgcolor: '#ecf0f1',
3294
+ node_style: 'rounded,filled',
3295
+ node_fillcolor: '#3498db',
3296
+ node_fontcolor: 'white'
3297
+ },
3298
+ 'tech' => {
3299
+ node_color: '#00ff00',
3300
+ edge_color: '#00aa00',
3301
+ font: 'Courier',
3302
+ bgcolor: 'black',
3303
+ node_fontcolor: '#00ff00'
3304
+ },
3305
+ 'pastel' => {
3306
+ node_color: '#8b7d6b',
3307
+ edge_color: '#cdaa7d',
3308
+ font: 'Georgia',
3309
+ bgcolor: '#fff8dc',
3310
+ node_style: 'filled',
3311
+ node_fillcolor: '#ffe4b5'
3312
+ }
3313
+ }
3314
+
3315
+ current_theme = themes[theme] || themes['default']
3316
+
3317
+ dot = "digraph HyperGraph {\n"
3318
+ dot << "rankdir=#{direction}\n"
3319
+ dot << "splines=#{spline}\n"
3320
+ dot << "bgcolor=\"#{current_theme[:bgcolor]}\"\n" if current_theme[:bgcolor]
3321
+ dot << "overlap=false\n"
3322
+
3323
+ # Edge settings
3324
+ dot << "edge [fontsize=8 len=1"
3325
+ dot << " color=\"#{current_theme[:edge_color]}\"" if current_theme[:edge_color]
3326
+ dot << "]\n"
3327
+
3328
+ # Node settings
3329
+ dot << "node ["
3330
+ dot << "shape=#{type == 'trans' ? 'box' : 'ellipse'}"
3331
+ dot << " color=\"#{current_theme[:node_color]}\"" if current_theme[:node_color]
3332
+ dot << " fontname=\"#{current_theme[:font]}\"" if current_theme[:font]
3333
+ dot << " fontcolor=\"#{current_theme[:node_fontcolor]}\"" if current_theme[:node_fontcolor]
3334
+ dot << " style=\"#{current_theme[:node_style]}\"" if current_theme[:node_style]
3335
+ dot << " fillcolor=\"#{current_theme[:node_fillcolor]}\"" if current_theme[:node_fillcolor]
3336
+ dot << "]\n\n"
3337
+
3338
+ # Process HyperList items into nodes
3339
+ @items.each_with_index do |item, idx|
3340
+ next if item["raw"] # Skip raw items like help text
3341
+
3342
+ node_id = "node_#{idx}"
3343
+ label = escape_dot_label(item["text"])
3344
+
3345
+ # Check for special item types and set attributes
3346
+ attributes = []
3347
+ attributes << "label=\"#{label}\""
3348
+
3349
+ # Handle operators (AND:, OR:, etc.)
3350
+ if item["text"] =~ /^\s*([A-Z][A-Z_\-() \/]*):(.*)$/
3351
+ operator = $1
3352
+ if ["AND", "OR", "NOT", "IMPLIES", "CONTINUOUS"].include?(operator)
3353
+ attributes << "shape=doubleoctagon"
3354
+ attributes << "width=0.2 height=0.2"
3355
+ end
3356
+ end
3357
+
3358
+ # Handle qualifiers (items with [])
3359
+ if item["text"] =~ /\[([^\]]+)\]/
3360
+ qualifier = $1
3361
+ case qualifier
3362
+ when "?"
3363
+ attributes << "shape=diamond" # Optional items as diamonds
3364
+ when "_"
3365
+ label = "☐ #{label}"
3366
+ attributes[0] = "label=\"#{label}\""
3367
+ when "x"
3368
+ label = "☑ #{label}"
3369
+ attributes[0] = "label=\"#{label}\""
3370
+ when "O"
3371
+ label = "⊙ #{label}"
3372
+ attributes[0] = "label=\"#{label}\""
3373
+ else
3374
+ # Check for conditions
3375
+ if qualifier =~ /^[^,]+$/
3376
+ attributes << "shape=diamond"
3377
+ attributes << "tooltip=\"Condition: #{qualifier}\""
3378
+ end
3379
+ end
3380
+ end
3381
+
3382
+ # Handle properties (Color:, Location:, etc.)
3383
+ if item["text"] =~ /^\s*([A-Z][a-z]+.*?):\s+(.+)$/
3384
+ property = $1
3385
+ value = $2
3386
+ if property.downcase == "color" && value =~ /(\w+)/
3387
+ color_val = $1.downcase
3388
+ # Map common color names if needed
3389
+ color_map = {
3390
+ 'red' => 'red', 'green' => 'green', 'blue' => 'blue',
3391
+ 'yellow' => 'yellow', 'orange' => 'orange', 'purple' => 'purple'
3392
+ }
3393
+ if color_map[color_val]
3394
+ attributes << "color=\"#{color_map[color_val]}\""
3395
+ attributes << "style=filled"
3396
+ attributes << "fillcolor=\"#{color_map[color_val]}20\"" # Light fill
3397
+ end
3398
+ end
3399
+ end
3400
+
3401
+ # Handle tags
3402
+ if item["text"] =~ /#(\w+)/
3403
+ tags = item["text"].scan(/#(\w+)/).flatten
3404
+ attributes << "tooltip=\"Tags: #{tags.join(', ')}\""
3405
+ end
3406
+
3407
+ # Add node to graph
3408
+ dot << "\"#{node_id}\" [#{attributes.join(', ')}]\n"
3409
+ end
3410
+
3411
+ dot << "\n"
3412
+
3413
+ # Add edges based on type
3414
+ if type == 'state'
3415
+ # State graph: hierarchical connections
3416
+ @items.each_with_index do |item, idx|
3417
+ next if item["raw"]
3418
+
3419
+ # Find children
3420
+ children = find_graph_children(idx)
3421
+ children.each do |child_idx|
3422
+ from_node = "node_#{idx}"
3423
+ to_node = "node_#{child_idx}"
3424
+
3425
+ edge_attrs = []
3426
+
3427
+ # Style based on operator
3428
+ if item["text"] =~ /^\s*OR:/
3429
+ edge_attrs << "style=\"dashed\""
3430
+ elsif item["text"] =~ /^\s*AND:/
3431
+ edge_attrs << "color=\"black:black\""
3432
+ end
3433
+
3434
+ dot << "\"#{from_node}\" -> \"#{to_node}\""
3435
+ dot << " [#{edge_attrs.join(', ')}]" if edge_attrs.any?
3436
+ dot << "\n"
3437
+ end
3438
+
3439
+ # Handle references (<item>)
3440
+ if item["text"] =~ /<([^>]+)>/
3441
+ ref = $1
3442
+ target_idx = find_reference_target(ref)
3443
+ if target_idx
3444
+ dot << "\"node_#{idx}\" -> \"node_#{target_idx}\" [style=\"dotted\"]\n"
3445
+ end
3446
+ end
3447
+ end
3448
+ else
3449
+ # Transition graph: sequential flow with special handling
3450
+ @items.each_with_index do |item, idx|
3451
+ next if item["raw"]
3452
+
3453
+ # Special handling for OR/AND operators
3454
+ if item["text"] =~ /^\s*(OR|AND):/
3455
+ operator = $1
3456
+ children = find_graph_children(idx)
3457
+
3458
+ if children.any?
3459
+ # Connect operator to all its children
3460
+ children.each do |child_idx|
3461
+ style = operator == "OR" ? "style=\"dashed\"" : "color=\"black:black\""
3462
+ dot << "\"node_#{idx}\" -> \"node_#{child_idx}\" [#{style}]\n"
3463
+ end
3464
+
3465
+ # Find convergence point
3466
+ converge_idx = find_next_at_level_or_above(idx, item["level"])
3467
+ if converge_idx
3468
+ # Connect all children to convergence point
3469
+ children.each do |child_idx|
3470
+ last_in_branch = find_last_in_branch(child_idx)
3471
+ dot << "\"node_#{last_in_branch}\" -> \"node_#{converge_idx}\"\n"
3472
+ end
3473
+ end
3474
+ next
3475
+ end
3476
+ end
3477
+
3478
+ # Normal sequential flow
3479
+ next_idx = find_next_graph_item(idx)
3480
+ if next_idx
3481
+ from_node = "node_#{idx}"
3482
+ to_node = "node_#{next_idx}"
3483
+ dot << "\"#{from_node}\" -> \"#{to_node}\"\n"
3484
+ end
3485
+ end
3486
+ end
3487
+
3488
+ dot << "}\n"
3489
+ dot
3490
+ end
3491
+
3492
+ def escape_dot_label(text)
3493
+ # Clean and escape text for DOT labels
3494
+ label = text.dup
3495
+
3496
+ # Remove ANSI color codes if present
3497
+ label.gsub!(/\e\[([0-9;]+)m/, '')
3498
+ label.gsub!(/\[38;5;\d+m/, '')
3499
+
3500
+ # Remove leading/trailing whitespace and indentation
3501
+ label.strip!
3502
+
3503
+ # Remove checkboxes at the beginning
3504
+ label.gsub!(/^\[([x_O])\]\s*/, '')
3505
+
3506
+ # Truncate if too long
3507
+ if label.length > 50
3508
+ label = label[0..47] + "..."
3509
+ end
3510
+
3511
+ # Escape special characters for DOT
3512
+ label.gsub('\\', '\\\\').gsub('"', '\"').gsub('#', '\#')
3513
+ end
3514
+
3515
+ def find_graph_children(parent_idx)
3516
+ children = []
3517
+ parent_level = @items[parent_idx]["level"]
3518
+
3519
+ (parent_idx + 1...@items.length).each do |i|
3520
+ next if @items[i]["raw"]
3521
+ if @items[i]["level"] > parent_level
3522
+ children << i if @items[i]["level"] == parent_level + 1
3523
+ else
3524
+ break
3525
+ end
3526
+ end
3527
+
3528
+ children
3529
+ end
3530
+
3531
+ def find_reference_target(ref)
3532
+ # Find item containing the reference text
3533
+ @items.find_index { |item| !item["raw"] && item["text"].include?(ref) }
3534
+ end
3535
+
3536
+ def find_next_at_level_or_above(start_idx, level)
3537
+ (start_idx + 1...@items.length).each do |i|
3538
+ next if @items[i]["raw"]
3539
+ return i if @items[i]["level"] <= level
3540
+ end
3541
+ nil
3542
+ end
3543
+
3544
+ def find_last_in_branch(start_idx)
3545
+ current_level = @items[start_idx]["level"]
3546
+ last_idx = start_idx
3547
+
3548
+ (start_idx + 1...@items.length).each do |i|
3549
+ next if @items[i]["raw"]
3550
+ if @items[i]["level"] > current_level
3551
+ last_idx = i
3552
+ else
3553
+ break
3554
+ end
3555
+ end
3556
+
3557
+ last_idx
3558
+ end
3559
+
3560
+ def find_next_graph_item(idx)
3561
+ current_level = @items[idx]["level"]
3562
+
3563
+ # Find next item at same level or first child
3564
+ (idx + 1...@items.length).each do |i|
3565
+ next if @items[i]["raw"]
3566
+
3567
+ # Return if same level or immediate child
3568
+ if @items[i]["level"] == current_level || @items[i]["level"] == current_level + 1
3569
+ return i
3570
+ elsif @items[i]["level"] < current_level
3571
+ # Going back up the hierarchy
3572
+ break
3573
+ end
3574
+ end
3575
+
3576
+ nil
3577
+ end
3578
+
3579
+ def set_message(text)
3580
+ @message = text
3581
+ @message_timeout = nil # Reset timeout so message shows immediately
3582
+ end
3583
+
3584
+ def set_mark(mark)
3585
+ @marks ||= {}
3586
+ visible = get_visible_items
3587
+ if @current < visible.length
3588
+ real_idx = @items.index(visible[@current])
3589
+ @marks[mark] = real_idx
3590
+ @message = "Mark '#{mark}' set"
3591
+ end
3592
+ end
3593
+
3594
+ def jump_to_mark(mark)
3595
+ @marks ||= {}
3596
+ if @marks[mark]
3597
+ # Save current position for ''
3598
+ @previous_position = @current
3599
+
3600
+ # Find the marked item in visible items
3601
+ target_idx = @marks[mark]
3602
+ visible = get_visible_items
3603
+ visible_idx = visible.find_index { |item| @items.index(item) == target_idx }
3604
+
3605
+ if visible_idx
3606
+ @current = visible_idx
3607
+ @message = "Jumped to mark '#{mark}'"
3608
+ else
3609
+ @message = "Mark '#{mark}' item is not visible (may be folded)"
3610
+ end
3611
+ else
3612
+ @message = "Mark '#{mark}' not set"
3613
+ end
3614
+ end
3615
+
3616
+ def jump_to_previous_position
3617
+ if @previous_position
3618
+ old_pos = @current
3619
+ @current = @previous_position
3620
+ @previous_position = old_pos
3621
+ @message = "Jumped to previous position"
3622
+ else
3623
+ @message = "No previous position"
3624
+ end
3625
+ end
3626
+
3627
+ def quit
3628
+ Cursor.show
3629
+ Rcurses.clear_screen
3630
+ exit
3631
+ end
3632
+
3633
+ def run
3634
+ render
3635
+
3636
+ loop do
3637
+ # Check for auto-save
3638
+ check_auto_save if @auto_save_enabled
3639
+
3640
+ c = getchr
3641
+
3642
+ # Track last key for double-key combinations
3643
+ prev_key = @last_key
3644
+ @last_key = c
3645
+
3646
+ # Record macro if recording (but not the 'q' that stops recording)
3647
+ if @macro_recording && !(c == "q")
3648
+ @macro_buffer << c
3649
+ end
3650
+
3651
+ case @mode
3652
+ when :normal
3653
+ case c
3654
+ when "r" # Redo
3655
+ redo_change
3656
+ when "?"
3657
+ show_help
3658
+ when "j", "DOWN"
3659
+ move_in_active_pane(:down)
3660
+ when "k", "UP"
3661
+ move_in_active_pane(:up)
3662
+ when "h"
3663
+ go_to_parent
3664
+ when "l"
3665
+ go_to_first_child
3666
+ when "LEFT"
3667
+ # Unindent only the current item
3668
+ indent_left(false)
3669
+ when "RIGHT"
3670
+ # Indent only the current item
3671
+ indent_right(false)
3672
+ when "PgUP" # Page Up
3673
+ page_up
3674
+ when "PgDOWN" # Page Down
3675
+ page_down
3676
+ when "HOME" # Home
3677
+ @current = 0
3678
+ @offset = 0
3679
+ when "END" # End
3680
+ @current = get_visible_items.length - 1
3681
+ when "g" # Go to top (was gg)
3682
+ @current = 0
3683
+ @offset = 0
3684
+ when "G" # Go to bottom
3685
+ @current = get_visible_items.length - 1
3686
+ when "R" # Jump to reference (was gr)
3687
+ jump_to_reference
3688
+ when "F" # Open file reference (was gf)
3689
+ open_file_reference
3690
+ when " "
3691
+ toggle_fold
3692
+ when "z"
3693
+ next_c = getchr
3694
+ case next_c
3695
+ when "a"
3696
+ @items.each { |item| item["fold"] = !item["fold"] }
3697
+ when "R"
3698
+ @items.each { |item| item["fold"] = false }
3699
+ when "M"
3700
+ @items.each_with_index do |item, idx|
3701
+ item["fold"] = true if has_children?(idx, @items)
3702
+ end
3703
+ when "o"
3704
+ visible = get_visible_items
3705
+ if @current < visible.length
3706
+ real_idx = @items.index(visible[@current])
3707
+ @items[real_idx]["fold"] = false if real_idx
3708
+ end
3709
+ when "c"
3710
+ visible = get_visible_items
3711
+ if @current < visible.length
3712
+ real_idx = @items.index(visible[@current])
3713
+ if real_idx && has_children?(real_idx, @items)
3714
+ @items[real_idx]["fold"] = true
3715
+ end
3716
+ end
3717
+ end
3718
+ when '0'
3719
+ # Single 0 pressed, ask for fold level
3720
+ level = @footer.ask("Fold to level: ", "")
3721
+ if level =~ /^\d+$/
3722
+ expand_to_level(level.to_i)
3723
+ @message = "Folded to level #{level}"
3724
+ end
3725
+ when /[1-9]/
3726
+ expand_to_level(c.to_i)
3727
+ when "C-L" # Move item and descendants down (C-J is newline)
3728
+ move_item_down(true)
3729
+ when "i", "ENTER", "\n" # i or ENTER to edit (C-J sends \n)
3730
+ edit_line
3731
+ when "o"
3732
+ insert_line
3733
+ when "O"
3734
+ @current -= 1 if @current > 0
3735
+ insert_line
3736
+ @current += 1
3737
+ when "a"
3738
+ insert_child
3739
+ when "t"
3740
+ show_templates
3741
+ when "D" # Delete line (with children)
3742
+ delete_line(false) # D always deletes with children by default
3743
+ when "C-D" # Delete line and all descendants explicitly
3744
+ delete_line(true)
3745
+ when "y" # Yank/copy single line
3746
+ yank_line(false)
3747
+ when "Y" # Yank/copy line with all descendants
3748
+ yank_line(true)
3749
+ when "p"
3750
+ paste_line
3751
+ when "S-UP" # Move item up
3752
+ move_item_up(false)
3753
+ when "S-DOWN" # Move item down
3754
+ move_item_down(false)
3755
+ when "C-UP" # Move item and descendants up
3756
+ move_item_up(true)
3757
+ when "C-DOWN" # Move item and descendants down
3758
+ move_item_down(true)
3759
+ when "C-K" # Alternative: Move item and descendants up (for terminals that intercept C-UP)
3760
+ move_item_up(true)
3761
+ when "TAB"
3762
+ # Indent with all children
3763
+ indent_right(true)
3764
+ when "S-TAB" # Shift-Tab
3765
+ # Unindent with all children
3766
+ indent_left(true)
3767
+ when "u"
3768
+ undo
3769
+ when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
3770
+ redo_change
3771
+ when "v"
3772
+ toggle_checkbox
3773
+ when "V"
3774
+ toggle_checkbox_with_date
3775
+ when "."
3776
+ repeat_last_action
3777
+ when "/"
3778
+ search_forward
3779
+ when "n"
3780
+ search_next
3781
+ when "N"
3782
+ jump_to_next_template_marker
3783
+ when "P"
3784
+ toggle_presentation_mode
3785
+ when "\\"
3786
+ next_c = getchr
3787
+ case next_c
3788
+ when "u"
3789
+ @message = "Underline mode toggled"
3790
+ end
3791
+ when ":"
3792
+ handle_command
3793
+ when "q"
3794
+ # Regular quit
3795
+ if @modified
3796
+ @message = "Unsaved changes! Use :q! or Q to force quit"
3797
+ else
3798
+ quit
3799
+ end
3800
+ when "m"
3801
+ # Mark setting
3802
+ c2 = getchr
3803
+ if c2 && c2 =~ /[a-z]/
3804
+ # Set mark
3805
+ set_mark(c2)
3806
+ end
3807
+ when "M"
3808
+ # Macro recording
3809
+ if @macro_recording
3810
+ # Stop recording
3811
+ stop_macro_recording
3812
+ else
3813
+ c2 = getchr
3814
+ if c2 && c2 =~ /[a-z]/
3815
+ start_macro_recording(c2)
3816
+ end
3817
+ end
3818
+ when "'"
3819
+ # Jump to mark
3820
+ c2 = getchr
3821
+ if c2 == "'"
3822
+ # Jump to previous position
3823
+ jump_to_previous_position
3824
+ elsif c2 && c2 =~ /[a-z]/
3825
+ # Jump to mark
3826
+ jump_to_mark(c2)
3827
+ end
3828
+ when "@"
3829
+ # Play macro
3830
+ next_c = getchr
3831
+ play_macro(next_c) if next_c && next_c =~ /[a-z]/
3832
+ when "w"
3833
+ # Check if it's a window command (ww for window switch)
3834
+ next_c = getchr
3835
+ if next_c == "w" && @split_view
3836
+ # Switch active pane
3837
+ @active_pane = (@active_pane == :main) ? :split : :main
3838
+ @message = "Switched to #{@active_pane} pane"
3839
+ else
3840
+ # Not a window command, ignore for now
3841
+ @message = "Unknown command: w#{next_c}"
3842
+ end
3843
+ when "Q" # Force quit
3844
+ quit
3845
+ when "ESC", "C-C" # ESC or Ctrl-C
3846
+ if @search && !@search.empty?
3847
+ # Clear search on first ESC
3848
+ @search = ""
3849
+ @search_matches = []
3850
+ @message = "Search cleared"
3851
+ clear_cache
3852
+ elsif !@modified
3853
+ # Quit on second ESC if not modified
3854
+ quit
3855
+ end
3856
+ end
3857
+ end
3858
+
3859
+ render
3860
+ end
3861
+ rescue Interrupt
3862
+ quit
3863
+ ensure
3864
+ Cursor.show
3865
+ Rcurses.clear_screen
3866
+ end
3867
+ end
3868
+
3869
+ # Main
3870
+ if __FILE__ == $0
3871
+ # Normal operation - help/version already handled at top of file
3872
+ app = HyperListApp.new(ARGV[0])
3873
+ app.run
3874
+ end