hyperlist 1.0.0 → 1.1.1

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  # Check for help/version BEFORE loading any libraries
8
8
  if ARGV[0] == '-h' || ARGV[0] == '--help'
9
9
  puts <<~HELP
10
- HyperList v1.0.1 - Terminal User Interface for HyperList files
10
+ HyperList v1.1.0 - Terminal User Interface for HyperList files
11
11
 
12
12
  USAGE
13
13
  hyperlist [OPTIONS] [FILE]
@@ -52,22 +52,26 @@ if ARGV[0] == '-h' || ARGV[0] == '--help'
52
52
  HELP
53
53
  exit 0
54
54
  elsif ARGV[0] == '-v' || ARGV[0] == '--version'
55
- puts "HyperList v1.0.1"
55
+ puts "HyperList v1.1.0"
56
56
  exit 0
57
57
  end
58
58
 
59
59
  # Only load libraries if we're actually running the app
60
60
  require 'io/console'
61
61
  require 'date'
62
- require 'rcurses'
62
+ # Use the local fixed version of rcurses instead of the system gem
63
+ require_relative '../rcurses/lib/rcurses'
63
64
  require 'cgi'
65
+ require 'openssl'
66
+ require 'digest'
67
+ require 'base64'
64
68
 
65
69
  class HyperListApp
66
70
  include Rcurses
67
71
  include Rcurses::Input
68
72
  include Rcurses::Cursor
69
73
 
70
- VERSION = "1.0.1"
74
+ VERSION = "1.1.1"
71
75
 
72
76
  def initialize(filename = nil)
73
77
  @filename = filename ? File.expand_path(filename) : nil
@@ -100,6 +104,9 @@ class HyperListApp
100
104
  @macro_register = {} # Store macros by key
101
105
  @macro_buffer = [] # Current macro being recorded
102
106
  @macro_key = nil # Key for current macro
107
+ @encryption_key = nil # Store derived encryption key
108
+ @encrypted_lines = {} # Track which lines are encrypted
109
+ @st_underline_mode = 0 # 0: none, 1: underline states, 2: underline transitions
103
110
  @split_view = false
104
111
  @split_items = [] # Second view items
105
112
  @split_current = 0 # Second view cursor
@@ -144,28 +151,38 @@ class HyperListApp
144
151
  if @split_view
145
152
  # Split view layout - no header, more space for content
146
153
  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)
154
+ # Main content panes - use nil colors to act as pass-through for pre-colored text
155
+ # This is how RTFM works - it passes colored command output to panes with nil colors
156
+ @main = Pane.new(1, 1, split_width - 1, @rows - 1, nil, nil)
157
+ @split_pane = Pane.new(split_width + 1, 1, split_width - 1, @rows - 1, nil, nil)
150
158
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
151
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
159
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
152
160
 
153
- # Add separator
154
- @separator = Pane.new(split_width, 0, 1, @rows - 1, 15, 8)
155
- @separator.text = "│" * (@rows - 1)
161
+ # Add separator with explicit background to overwrite emoji overflow
162
+ @separator = Pane.new(split_width, 1, 1, @rows - 1, 15, 0)
163
+ # Build separator with explicit clearing
164
+ separator_lines = []
165
+ (@rows - 1).times do
166
+ separator_lines << "│"
167
+ end
168
+ @separator.text = separator_lines.join("\n")
156
169
  @separator.refresh
157
170
  else
158
171
  # 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)
172
+ # Use nil colors like RTFM to avoid ANSI wrapping issues
173
+ # This prevents the corruption that happens with narrow terminals
174
+ @main = Pane.new(1, 1, @cols, @rows - 1, nil, nil)
161
175
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
162
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
176
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
163
177
  end
164
178
  end
165
179
 
166
180
  def load_file(file)
167
181
  @items = []
168
- lines = File.readlines(file) rescue []
182
+ @encrypted_lines = {}
183
+
184
+ # Read file content
185
+ content = File.read(file) rescue ""
169
186
 
170
187
  # Clear undo/redo stacks when loading a new file
171
188
  @undo_stack = []
@@ -173,6 +190,26 @@ class HyperListApp
173
190
  @redo_stack = []
174
191
  @redo_position = []
175
192
 
193
+ # Check if file is encrypted (dot file or encrypted content)
194
+ if is_encrypted_file?(file)
195
+ if content.start_with?("ENC:")
196
+ # Whole file is encrypted
197
+ decrypted_content = decrypt_file(content)
198
+ if decrypted_content.nil?
199
+ @message = "Failed to decrypt file"
200
+ @items = [{"text" => "Failed to decrypt file", "level" => 0, "fold" => false}]
201
+ return
202
+ end
203
+ lines = decrypted_content.split("\n")
204
+ @message = "File decrypted successfully"
205
+ else
206
+ # Dot file but not encrypted yet - just load normally
207
+ lines = content.split("\n")
208
+ end
209
+ else
210
+ lines = content.split("\n")
211
+ end
212
+
176
213
  # Check if file is large
177
214
  large_file = lines.length > 10000
178
215
 
@@ -186,6 +223,12 @@ class HyperListApp
186
223
  next if line.strip.empty?
187
224
  level = line[/^\t*/].length
188
225
  text = line.strip
226
+
227
+ # Track encrypted lines
228
+ if text.start_with?("ENC:")
229
+ @encrypted_lines[@items.length] = true
230
+ end
231
+
189
232
  @items << {"text" => text, "level" => level, "fold" => false}
190
233
 
191
234
  # Update progress for large files
@@ -346,13 +389,48 @@ class HyperListApp
346
389
  def save_file
347
390
  return unless @filename
348
391
 
349
- File.open(@filename, 'w') do |f|
350
- @items.each do |item|
351
- f.puts("\t" * item["level"] + item["text"])
392
+ # Prepare content
393
+ content = @items.map do |item|
394
+ "\t" * item["level"] + item["text"]
395
+ end.join("\n")
396
+
397
+ # Check if this should be an encrypted file
398
+ if is_encrypted_file?(@filename) && !content.empty?
399
+ # Check if any lines are already encrypted
400
+ has_encrypted_lines = @items.any? { |item| item["text"].start_with?("ENC:") }
401
+
402
+ if !has_encrypted_lines
403
+ # Ask if user wants to encrypt the whole file
404
+ @footer.text = "Encrypt entire file? (y/n): "
405
+ @footer.refresh
406
+ response = getchr
407
+
408
+ if response.downcase == 'y'
409
+ encrypted_content = encrypt_file(content)
410
+ if encrypted_content
411
+ File.write(@filename, encrypted_content)
412
+ @message = "File saved (encrypted)"
413
+ else
414
+ @message = "Encryption cancelled - file not saved"
415
+ return
416
+ end
417
+ else
418
+ # Save as plain text even though it's a dot file
419
+ File.write(@filename, content)
420
+ @message = "Saved to #{@filename} (unencrypted)"
421
+ end
422
+ else
423
+ # Has encrypted lines, save as is
424
+ File.write(@filename, content)
425
+ @message = "Saved to #{@filename}"
352
426
  end
427
+ else
428
+ # Regular file, save normally
429
+ File.write(@filename, content)
430
+ @message = "Saved to #{@filename}"
353
431
  end
432
+
354
433
  @modified = false
355
- @message = "Saved to #{@filename}"
356
434
  @last_auto_save = Time.now if @auto_save_enabled
357
435
  end
358
436
 
@@ -489,38 +567,28 @@ class HyperListApp
489
567
  level = item["level"]
490
568
  indent = " " * level
491
569
 
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
570
+ # Process the line
571
+ # We'll apply formatting BEFORE escaping HTML entities
572
+ text = original_text
573
+
574
+ # Apply all formatting with placeholder markers
575
+ # Use unique markers that won't appear in normal text
576
+
577
+ # Markdown formatting MUST come first before other replacements
578
+ # Bold *text*
579
+ text = text.gsub(/\*([^*]+)\*/) do
580
+ "\u0001BOLDSTART\u0002#{$1}\u0001BOLDEND\u0002"
581
+ end
582
+
583
+ # Italic /text/
584
+ text = text.gsub(/\/([^\/]+)\//) do
585
+ "\u0001ITALICSTART\u0002#{$1}\u0001ITALICEND\u0002"
586
+ end
587
+
588
+ # Underline _text_
589
+ text = text.gsub(/_([^_]+)_/) do
590
+ "\u0001UNDERSTART\u0002#{$1}\u0001UNDEREND\u0002"
591
+ end
524
592
 
525
593
  # Checkboxes at start
526
594
  text = text.sub(/^\[X\]/i, "\u0001CBCHECKED\u0002")
@@ -594,7 +662,6 @@ class HyperListApp
594
662
  .gsub("\u0001UNDEREND\u0002", '</u>')
595
663
 
596
664
  html += indent + text + "\n"
597
- end
598
665
  end
599
666
 
600
667
  html += "</body>\n</html>"
@@ -614,121 +681,129 @@ class HyperListApp
614
681
  render_main
615
682
  render_split_pane if @split_view
616
683
  render_footer
684
+ # Force redraw separator column to fix emoji overflow
685
+ if @split_view
686
+ split_col = @cols / 2
687
+ # Clear and redraw the entire separator column
688
+ (@rows - 1).times do |row|
689
+ # Move to position and draw separator with background color to overwrite anything
690
+ print "\e[#{row + 1};#{split_col}H\e[48;5;0m│\e[49m"
691
+ end
692
+ end
617
693
  end
618
694
 
619
695
 
620
696
  def render_main
621
697
  visible_items = get_visible_items
622
698
 
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
699
+ # Build ALL lines for the pane (like RTFM/IMDB do)
700
+ lines = []
632
701
  in_literal_block = false
633
702
  literal_start_level = -1
634
703
 
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]
704
+ visible_items.each_with_index do |item, idx|
649
705
  next unless item
650
706
 
651
- # Create cache key for this item
652
- cache_key = "#{item['text']}_#{item['level']}_#{item['fold']}"
707
+ line = " " * item["level"] # 4 spaces per level
653
708
 
654
- # Check cache first
655
- if @processed_cache[cache_key] && idx != @current
656
- lines << @processed_cache[cache_key]
709
+ # Add fold indicator
710
+ real_idx = @items.index(item)
711
+ if real_idx && has_children?(real_idx, @items) && item["fold"]
712
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
713
+ line += "▶".fg(color) + " "
714
+ elsif real_idx && has_children?(real_idx, @items)
715
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
716
+ line += "▷".fg(color) + " "
657
717
  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)
718
+ line += " "
719
+ end
720
+
721
+ # Handle literal blocks and syntax highlighting
722
+ if item["text"].strip == "\\"
723
+ if !in_literal_block
724
+ in_literal_block = true
725
+ literal_start_level = item["level"]
726
+ spaces = item["text"].match(/^(\s*)/)[1]
727
+ line += spaces + "\\".fg("3")
728
+ elsif item["level"] == literal_start_level
729
+ in_literal_block = false
730
+ literal_start_level = -1
731
+ spaces = item["text"].match(/^(\s*)/)[1]
732
+ line += spaces + "\\".fg("3")
666
733
  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
734
  line += item["text"]
735
+ end
736
+ elsif in_literal_block
737
+ line += item["text"]
738
+ else
739
+ # Normal syntax highlighting
740
+ has_match = @search_matches.include?(idx) && @search && !@search.empty?
741
+ if @presentation_mode && !is_item_in_presentation_focus?(item)
742
+ line += item["text"].fg("240")
693
743
  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
744
+ line += process_text(item["text"], has_match)
711
745
  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
746
+ end
747
+
748
+ # Apply current item highlighting
749
+ if idx == @current
750
+ bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
751
+ if bg_color
752
+ bg_code = "\e[48;5;#{bg_color}m"
753
+ reset_bg = "\e[49m"
754
+ line = bg_code + line.gsub(/\e\[49m/, '') + reset_bg
719
755
  end
720
-
721
- lines << line
756
+ end
757
+
758
+ lines << line
759
+
760
+ # Check if exiting literal block
761
+ if in_literal_block && item["level"] <= literal_start_level && !item["text"].strip == "\\"
762
+ in_literal_block = false
763
+ literal_start_level = -1
722
764
  end
723
765
  end
724
766
 
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
767
+ # Add a blank line at the bottom to show end of document
768
+ lines << ""
769
+
770
+ # Set the full content to the pane and let rcurses handle scrolling
771
+ @main.text = lines.join("\n")
772
+
773
+ # Calculate how many extra lines are created by wrapping
774
+ logical_lines = lines.length
775
+ # Estimate wrapped lines by checking line lengths against pane width
776
+ extra_wrapped_lines = 0
777
+ lines[0..-2].each do |line| # Exclude the blank line we added
778
+ # Remove ANSI codes for length calculation
779
+ clean_line = line.gsub(/\e\[[0-9;]*m/, '')
780
+ if clean_line.length > @main.w
781
+ # This line will wrap - calculate how many extra lines it creates
782
+ extra_lines = (clean_line.length.to_f / @main.w).ceil - 1
783
+ extra_wrapped_lines += extra_lines
784
+ end
785
+ end
786
+
787
+ # Calculate scroll position exactly like RTFM does, but account for wrapping
788
+ # Treat the content as having one extra line (the blank line at bottom)
789
+ scrolloff = 3
790
+ total = visible_items.length + 1 # +1 for the blank line
791
+ page = @main.h
792
+
793
+ if total <= page
794
+ # If everything fits, always start from the very top
795
+ @main.ix = 0
796
+ elsif @current - @main.ix < scrolloff
797
+ # If we're too close to the top of the pane, scroll up
798
+ @main.ix = [@current - scrolloff, 0].max
799
+ elsif (@main.ix + page - 1 - @current) < scrolloff
800
+ # If we're too close to the bottom of the pane, scroll down
801
+ # Account for wrapped lines dynamically
802
+ max_off = [total - page + extra_wrapped_lines, 0].max
803
+ @main.ix = [@current + scrolloff - page + 1, max_off].min
731
804
  end
805
+
806
+ @main.refresh
732
807
  end
733
808
 
734
809
  def process_text(text, highlight_search = false)
@@ -741,6 +816,53 @@ class HyperListApp
741
816
  return result
742
817
  end
743
818
 
819
+ # Apply State and Transition underlining FIRST, before any coloring
820
+ if @st_underline_mode == 1
821
+ # Underline states
822
+ if result =~ /^(\s*)(S:\s+)(.*)$/ || result =~ /^(\s*)(\|\s+)(.*)$/
823
+ prefix = $1 || ""
824
+ marker = $2
825
+ content = $3 || ""
826
+ # Apply underline to content after the marker
827
+ result = prefix + marker + content.u
828
+ end
829
+ elsif @st_underline_mode == 2
830
+ # Underline transitions
831
+ if result =~ /^(\s*)(T:\s+)(.*)$/ || result =~ /^(\s*)(\/\s+)(.*)$/
832
+ prefix = $1 || ""
833
+ marker = $2
834
+ content = $3 || ""
835
+ # Apply underline to content after the marker
836
+ result = prefix + marker + content.u
837
+ end
838
+ end
839
+
840
+ # Helper method to safely apply regexes without corrupting ANSI sequences
841
+ def safe_regex_replace(text, pattern, &block)
842
+ # Find all ANSI sequences and replace with placeholders
843
+ ansi_sequences = []
844
+ placeholder_text = text.gsub(/\e\[[0-9;]*m/) do |match|
845
+ ansi_sequences << match
846
+ "⟨ANSI#{ansi_sequences.length - 1}⟩"
847
+ end
848
+
849
+ # Apply the regex to the placeholder text
850
+ result_text = placeholder_text.gsub(pattern, &block)
851
+
852
+ # Restore ANSI sequences
853
+ ansi_sequences.each_with_index do |ansi, index|
854
+ result_text.gsub!("⟨ANSI#{index}⟩", ansi)
855
+ end
856
+
857
+ result_text
858
+ end
859
+
860
+ # Check if this is an encrypted line
861
+ if result.start_with?("ENC:")
862
+ # Show encrypted indicator instead of the encrypted data
863
+ return "🔒 [ENCRYPTED LINE - Press Ctrl-E to decrypt]".fg("196") # Bright red
864
+ end
865
+
744
866
  # Check if this is a literal block marker (single backslash)
745
867
  if result.strip == "\\"
746
868
  return result.fg("3") # Yellow for literal block markers
@@ -750,7 +872,7 @@ class HyperListApp
750
872
  if highlight_search && @search && !@search.empty?
751
873
  # Find all occurrences of the search term (case insensitive)
752
874
  search_regex = Regexp.new(Regexp.escape(@search), Regexp::IGNORECASE)
753
- result.gsub!(search_regex) { |match| match.bg("226").fg("0") } # Yellow background, black text
875
+ result.gsub!(search_regex) { |match| match.bg("220") } # Yellow background, preserve text color
754
876
  end
755
877
 
756
878
  # Handle identifiers at the beginning (like "1.1.1.1" or "1A1A")
@@ -819,7 +941,7 @@ class HyperListApp
819
941
 
820
942
  # Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
821
943
  if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
822
- prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators
944
+ prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators (including S: and T:)
823
945
  elsif text_part.length >= 2 && space_after.include?(" ")
824
946
  # It's a property (mixed case, at least 2 chars, has space after colon)
825
947
  prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
@@ -829,16 +951,24 @@ class HyperListApp
829
951
  end
830
952
  end
831
953
 
954
+
955
+ # Color special state/transition markers (| and /) green
956
+ result.gsub!(/^(\s*)\|\s+/) { $1 + "| ".fg("2") } # Green for pipe (state marker)
957
+ result.gsub!(/^(\s*)\/\s+/) { $1 + "/ ".fg("2") } # Green for slash (transition marker)
958
+
832
959
  # Handle OR: at the beginning of a line (with optional spaces)
833
960
  result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
834
961
 
835
962
  # Handle parentheses content (moved here to avoid conflicts with properties)
836
963
  # Based on hyperlist.vim: '(.\{-})'
837
- result.gsub!(/\(([^)]*)\)/) { "(".fg("6") + $1.fg("6") + ")".fg("6") } # Cyan for parentheses and content
964
+ result = safe_regex_replace(result, /\(([^)]*)\)/) do |match|
965
+ content = match[1..-2] # Extract content between parentheses
966
+ "(".fg("6") + content.fg("6") + ")".fg("6")
967
+ end
838
968
 
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
969
+ # Handle semicolons as separators (they separate items on the same line)
970
+ # Semicolons are green like qualifiers
971
+ result = safe_regex_replace(result, /;/) { ";".fg("2") }
842
972
 
843
973
  # Handle references - color entire reference including brackets
844
974
  # Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
@@ -847,19 +977,13 @@ class HyperListApp
847
977
  # Handle special keywords SKIP and END
848
978
  result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
849
979
 
850
- # Handle quoted strings FIRST, but color ## sequences inside them red
980
+ # Handle quoted strings (only double quotes are special in HyperList)
851
981
  # Based on hyperlist.vim: '".\{-}"'
852
982
  result.gsub!(/"([^"]*)"/) do
853
983
  content = $1
854
984
  # Color any ## sequences inside the quotes as red
855
985
  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
986
+ '"'.fg("6") + content.fg("6") + '"'.fg("6") # Cyan for quoted strings
863
987
  end
864
988
 
865
989
  # Handle change markup - all double-hashes should be red
@@ -1021,36 +1145,135 @@ class HyperListApp
1021
1145
  false
1022
1146
  end
1023
1147
 
1024
- def toggle_fold
1025
- visible = get_visible_items
1026
- return if @current >= visible.length
1148
+ def indent_split_right(with_children = false)
1149
+ visible_items = get_visible_split_items
1150
+ return if @split_current >= visible_items.length
1027
1151
 
1028
- item = visible[@current]
1029
- real_idx = @items.index(item)
1152
+ item = visible_items[@split_current]
1153
+ real_idx = @split_items.index(item)
1154
+
1155
+ # Can only indent if there's a previous item at same or higher level
1156
+ if real_idx && real_idx > 0
1157
+ save_undo_state # Save state before modification
1158
+ original_level = item["level"]
1159
+ item["level"] += 1 # Since item is a reference, this updates the object in both arrays
1160
+
1161
+ # Indent children if requested
1162
+ if with_children
1163
+ ((real_idx + 1)...@split_items.length).each do |i|
1164
+ if @split_items[i]["level"] > original_level
1165
+ @split_items[i]["level"] += 1
1166
+ else
1167
+ break
1168
+ end
1169
+ end
1170
+ end
1171
+
1172
+ @modified = true
1173
+ @message = "Indented in split pane"
1174
+ end
1175
+ end
1176
+
1177
+ def indent_split_left(with_children = false)
1178
+ visible_items = get_visible_split_items
1179
+ return if @split_current >= visible_items.length
1030
1180
 
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)
1181
+ item = visible_items[@split_current]
1182
+ real_idx = @split_items.index(item)
1183
+
1184
+ if real_idx && item["level"] > 0
1185
+ save_undo_state # Save state before modification
1186
+ original_level = item["level"]
1187
+ item["level"] -= 1 # Since item is a reference, this updates the object in both arrays
1188
+
1189
+ # Unindent children if requested
1190
+ if with_children
1191
+ ((real_idx + 1)...@split_items.length).each do |i|
1192
+ if @split_items[i]["level"] > original_level
1193
+ @split_items[i]["level"] -= 1
1194
+ else
1195
+ break
1196
+ end
1197
+ end
1198
+ end
1199
+
1200
+ @modified = true
1201
+ @message = "Unindented in split pane"
1202
+ end
1203
+ end
1204
+
1205
+ def toggle_fold
1206
+ if @split_view && @active_pane == :split
1207
+ # Handle fold toggle in split pane
1208
+ visible_items = get_visible_split_items
1209
+ return if @split_current >= visible_items.length
1210
+
1211
+ item = visible_items[@split_current]
1212
+ real_idx = @split_items.index(item)
1213
+
1214
+ if real_idx && has_children_in_array?(real_idx, @split_items)
1215
+ item["fold"] = !item["fold"] # Since item is a reference, this updates the object in both arrays
1216
+ end
1217
+ else
1218
+ # Handle fold toggle in main pane
1219
+ visible = get_visible_items
1220
+ return if @current >= visible.length
1221
+
1222
+ item = visible[@current]
1223
+ real_idx = @items.index(item)
1224
+
1225
+ if real_idx && has_children?(real_idx, @items)
1226
+ @items[real_idx]["fold"] = !@items[real_idx]["fold"]
1227
+ record_last_action(:toggle_fold, nil)
1228
+ end
1034
1229
  end
1035
1230
  end
1036
1231
 
1037
1232
  def move_up
1038
- @current = [@current - 1, 0].max
1233
+ max_items = get_visible_items.length - 1
1234
+
1235
+ if @current == 0
1236
+ # Wrap around to last item
1237
+ @current = max_items
1238
+ else
1239
+ @current = [@current - 1, 0].max
1240
+ end
1241
+
1242
+ update_presentation_focus if @presentation_mode
1039
1243
  end
1040
1244
 
1041
1245
  def move_down
1042
1246
  max = get_visible_items.length - 1
1043
- @current = [@current + 1, max].min
1247
+
1248
+ if @current == max
1249
+ # Wrap around to first item
1250
+ @current = 0
1251
+ else
1252
+ @current = [@current + 1, max].min
1253
+ end
1254
+
1255
+ update_presentation_focus if @presentation_mode
1044
1256
  end
1045
1257
 
1046
1258
  def page_up
1047
- @current = [@current - (@main.h - 1), 0].max
1048
- @offset = [@offset - (@main.h - 1), 0].max
1259
+ if @split_view && @active_pane == :split
1260
+ @split_current = [@split_current - (@split_pane.h - 1), 0].max
1261
+ else
1262
+ @current = [@current - (@main.h - 1), 0].max
1263
+ @offset = [@offset - (@main.h - 1), 0].max
1264
+ update_presentation_focus if @presentation_mode
1265
+ end
1049
1266
  end
1050
1267
 
1051
1268
  def page_down
1052
- max = get_visible_items.length - 1
1053
- @current = [@current + (@main.h - 1), max].min
1269
+ if @split_view && @active_pane == :split
1270
+ max = get_visible_split_items.length - 1
1271
+ @split_current = [@split_current + (@split_pane.h - 1), max].min
1272
+ else
1273
+ max = get_visible_items.length - 1
1274
+ @current = [@current + (@main.h - 1), max].min
1275
+ update_presentation_focus if @presentation_mode
1276
+ end
1054
1277
  end
1055
1278
 
1056
1279
  def go_to_parent
@@ -1064,6 +1287,7 @@ class HyperListApp
1064
1287
  (@current - 1).downto(0) do |i|
1065
1288
  if visible[i]["level"] < current_level
1066
1289
  @current = i
1290
+ update_presentation_focus if @presentation_mode
1067
1291
  break
1068
1292
  end
1069
1293
  end
@@ -1076,6 +1300,7 @@ class HyperListApp
1076
1300
  current_level = visible[@current]["level"]
1077
1301
  if visible[@current + 1]["level"] > current_level
1078
1302
  @current += 1
1303
+ update_presentation_focus if @presentation_mode
1079
1304
  end
1080
1305
  end
1081
1306
 
@@ -1116,52 +1341,102 @@ class HyperListApp
1116
1341
 
1117
1342
  def toggle_presentation_mode
1118
1343
  if @presentation_mode
1119
- # Exit presentation mode - restore all items
1344
+ # Exit presentation mode - restore normal view
1120
1345
  @presentation_mode = false
1121
- @items.each { |item| item["fold"] = false }
1346
+ # Clear all presentation focus flags and unfold everything
1347
+ @items.each do |item|
1348
+ item["presentation_focus"] = false
1349
+ item["fold"] = false # Unfold everything when exiting presentation mode
1350
+ end
1351
+ # Clear cache to force re-rendering
1352
+ @processed_cache.clear
1122
1353
  @message = "Presentation mode disabled"
1123
1354
  else
1124
- # Enter presentation mode - show only current item and ancestors
1355
+ # Enter presentation mode
1356
+ # Remember which item we're on before any folding changes
1357
+ visible_items = get_visible_items
1358
+ if @current < visible_items.length
1359
+ target_item = visible_items[@current]
1360
+ end
1361
+
1125
1362
  @presentation_mode = true
1126
- show_only_current_and_ancestors
1127
- @message = "Presentation mode enabled"
1363
+ # Clear cache to force re-rendering
1364
+ @processed_cache.clear
1365
+ update_presentation_focus
1366
+
1367
+ # Make sure cursor is still on the same item after initial folding
1368
+ if target_item
1369
+ new_visible = get_visible_items
1370
+ new_position = new_visible.index(target_item)
1371
+ @current = new_position if new_position
1372
+ end
1373
+
1374
+ @message = "Presentation mode enabled - focus on current item"
1128
1375
  end
1129
1376
  end
1130
1377
 
1131
- def show_only_current_and_ancestors
1378
+ def update_presentation_focus
1379
+ # This method updates the focus in presentation mode
1380
+ # It will be called whenever the cursor moves
1381
+ return unless @presentation_mode
1382
+
1132
1383
  visible_items = get_visible_items
1133
1384
  return if visible_items.empty? || @current >= visible_items.length
1134
1385
 
1386
+ # Remember which item we're focused on
1135
1387
  current_item = visible_items[@current]
1136
1388
  current_level = current_item["level"]
1137
1389
  current_real_idx = @items.index(current_item)
1138
1390
 
1139
1391
  # First, fold everything
1140
- @items.each { |item| item["fold"] = true }
1392
+ @items.each_with_index do |item, idx|
1393
+ item["fold"] = has_children?(idx, @items)
1394
+ item["presentation_focus"] = false
1395
+ end
1141
1396
 
1142
- # Unfold current item
1397
+ # Mark current item as in focus and unfold it
1398
+ current_item["presentation_focus"] = true
1143
1399
  current_item["fold"] = false
1144
1400
 
1145
- # Unfold all ancestors
1146
- ancestor_level = current_level - 1
1401
+ # Unfold all ancestors of current item
1402
+ ancestor_indices = []
1403
+ search_level = current_level - 1
1147
1404
  idx = current_real_idx - 1
1148
1405
 
1149
- while idx >= 0 && ancestor_level >= 0
1150
- if @items[idx]["level"] == ancestor_level
1406
+ while idx >= 0 && search_level >= 0
1407
+ if @items[idx]["level"] == search_level
1151
1408
  @items[idx]["fold"] = false
1152
- ancestor_level -= 1
1409
+ @items[idx]["presentation_focus"] = false # Ancestors visible but not focused
1410
+ ancestor_indices << idx
1411
+ search_level -= 1
1153
1412
  end
1154
1413
  idx -= 1
1155
1414
  end
1156
1415
 
1157
- # Unfold immediate children of current item
1416
+ # Mark immediate children as in focus (only one level down)
1158
1417
  idx = current_real_idx + 1
1159
1418
  while idx < @items.length && @items[idx]["level"] > current_level
1160
1419
  if @items[idx]["level"] == current_level + 1
1161
- @items[idx]["fold"] = false
1420
+ @items[idx]["presentation_focus"] = true
1421
+ # Don't unfold children - let them stay folded unless user explicitly unfolds
1162
1422
  end
1163
1423
  idx += 1
1164
1424
  end
1425
+
1426
+ # Now recalculate the cursor position to point to the same item
1427
+ new_visible_items = get_visible_items
1428
+ new_position = new_visible_items.index(current_item)
1429
+ if new_position
1430
+ @current = new_position
1431
+ end
1432
+
1433
+ # Clear cache to force re-rendering with new focus
1434
+ @processed_cache.clear
1435
+ end
1436
+
1437
+ def is_item_in_presentation_focus?(item)
1438
+ return true unless @presentation_mode
1439
+ item["presentation_focus"] == true
1165
1440
  end
1166
1441
 
1167
1442
  def save_undo_state
@@ -1873,9 +2148,9 @@ class HyperListApp
1873
2148
  help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
1874
2149
  help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
1875
2150
  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")
2151
+ help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
1877
2152
  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")
2153
+ help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
1879
2154
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
1880
2155
  help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
1881
2156
  help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
@@ -1883,12 +2158,12 @@ class HyperListApp
1883
2158
  help_lines << ""
1884
2159
  help_lines << "#{"FEATURES".fg("14")}"
1885
2160
  help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
2161
+ help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
1886
2162
  help_lines << help_line("#{"R".fg("10")}", "Go to reference", "#{"F".fg("10")}", "Open file")
1887
2163
  help_lines << help_line("#{"N".fg("10")}", "Next = marker", "#{"P".fg("10")}", "Presentation mode")
1888
2164
  help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":template".fg("10")}", "Show templates")
1889
2165
  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")
2166
+ help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"w".fg("10")}", "Switch panes")
1892
2167
  help_lines << ""
1893
2168
  help_lines << "#{"FILE OPERATIONS".fg("14")}"
1894
2169
  help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
@@ -1904,8 +2179,8 @@ class HyperListApp
1904
2179
  help_lines << help_line("#{"[ ]".fg("22")}", "Unchecked", "#{"[-]".fg("2")}", "Partial")
1905
2180
  help_lines << help_line("#{"[?]".fg("2")}", "Conditionals", "#{"AND:".fg("4")}", "Operators")
1906
2181
  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")
2182
+ help_lines << help_line("#{"(comment)".fg("6")}", "Comments", "#{'"text"'.fg("14")}", "Quoted strings")
2183
+ help_lines << help_line("#{";".fg("2")}", "Separator", "#{"#tag".fg("184")}", "Hash tags")
1909
2184
 
1910
2185
  help = help_lines.join("\n")
1911
2186
 
@@ -1949,7 +2224,8 @@ class HyperListApp
1949
2224
  @current = 0
1950
2225
  @offset = 0
1951
2226
  when "END"
1952
- @current = @items.length - 1
2227
+ visible = get_visible_items
2228
+ @current = [visible.length - 1, 0].max
1953
2229
  else
1954
2230
  # Any other key returns to main view
1955
2231
  break
@@ -2130,13 +2406,14 @@ class HyperListApp
2130
2406
 
2131
2407
  #{"ADDITIVES".b}
2132
2408
 
2133
- #{"Comments".fg("6")} (in parentheses or after semicolon):
2409
+ #{"Comments".fg("6")} (in parentheses):
2134
2410
  (This is a comment) Not executed in transitions
2135
- ; Line comment Everything after ; is a comment
2411
+
2412
+ #{"Separators".fg("2")} (semicolon):
2413
+ Item 1; Item 2; Item 3 Multiple items on same line
2136
2414
 
2137
2415
  #{"Quotes".fg("14")} (in quotation marks):
2138
2416
  "Literal text" Not interpreted as HyperList
2139
- 'Also literal' Single quotes work too
2140
2417
 
2141
2418
  #{"Tags".fg("184")} (hashtags):
2142
2419
  #TODO #important Markers for categorization
@@ -2474,14 +2751,16 @@ class HyperListApp
2474
2751
  @current = 0
2475
2752
  @offset = 0
2476
2753
  when "END"
2477
- @current = @items.length - 1
2754
+ visible = get_visible_items
2755
+ @current = [visible.length - 1, 0].max
2478
2756
  when "g"
2479
2757
  if getchr == "g"
2480
2758
  @current = 0
2481
2759
  @offset = 0
2482
2760
  end
2483
2761
  when "G"
2484
- @current = @items.length - 1
2762
+ visible = get_visible_items
2763
+ @current = [visible.length - 1, 0].max
2485
2764
  end
2486
2765
  end
2487
2766
 
@@ -2566,7 +2845,7 @@ class HyperListApp
2566
2845
  when "autosave", "as"
2567
2846
  status = @auto_save_enabled ? "enabled" : "disabled"
2568
2847
  @message = "Auto-save is #{status} (interval: #{@auto_save_interval}s)"
2569
- when "template", "templates", "t"
2848
+ when "t"
2570
2849
  show_templates
2571
2850
  when "foldlevel"
2572
2851
  level = @footer.ask("Fold to level (0-9): ", "")
@@ -2731,11 +3010,194 @@ class HyperListApp
2731
3010
  @message = "File not found: #{filepath}"
2732
3011
  end
2733
3012
  end
3013
+
3014
+ # Encryption methods
3015
+ def prompt_password(prompt = "Password: ")
3016
+ @footer.text = prompt
3017
+ @footer.refresh
3018
+
3019
+ password = ""
3020
+ loop do
3021
+ begin
3022
+ c = getchr
3023
+
3024
+ # Debug logging
3025
+ if ENV['DEBUG']
3026
+ File.open('/tmp/hyperlist_debug.log', 'a') do |f|
3027
+ f.puts "Password prompt key: #{c.inspect}"
3028
+ end
3029
+ end
3030
+
3031
+ case c
3032
+ when "ENTER", "\r", "\n"
3033
+ break
3034
+ when "BACKSPACE", "BACK", "C-h", "\x7F", "\b"
3035
+ password.chop! unless password.empty?
3036
+ when "ESC", "C-c"
3037
+ return nil
3038
+ when nil
3039
+ # Skip nil input
3040
+ next
3041
+ else
3042
+ if c && c.length == 1 && c.ord >= 32 && c.ord <= 126
3043
+ password += c
3044
+ end
3045
+ end
3046
+ @footer.text = prompt + "*" * password.length
3047
+ @footer.refresh
3048
+ rescue => e
3049
+ # If there's an error, return nil
3050
+ @message = "Password input error: #{e.message}"
3051
+ return nil
3052
+ end
3053
+ end
3054
+
3055
+ # Clear the password prompt
3056
+ @footer.text = ""
3057
+ @footer.refresh
3058
+
3059
+ # Flush any remaining input to avoid accidental quit
3060
+ begin
3061
+ while IO.select([$stdin], nil, nil, 0)
3062
+ $stdin.read_nonblock(1024)
3063
+ end
3064
+ rescue IO::WaitReadable, EOFError
3065
+ # No more input to flush
3066
+ end
3067
+
3068
+ password
3069
+ end
3070
+
3071
+ def derive_key(password, salt = nil)
3072
+ salt ||= OpenSSL::Random.random_bytes(16)
3073
+ iterations = 10000
3074
+ key_len = 32 # 256-bit key
3075
+ key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_len, OpenSSL::Digest::SHA256.new)
3076
+ [key, salt]
3077
+ end
3078
+
3079
+ def encrypt_string(text, password = nil)
3080
+ password ||= @encryption_key || prompt_password("Enter encryption password: ")
3081
+ return nil unless password
3082
+
3083
+ # Store key for session if not already stored
3084
+ @encryption_key ||= password
3085
+
3086
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
3087
+ cipher.encrypt
3088
+
3089
+ salt = OpenSSL::Random.random_bytes(16)
3090
+ key, _ = derive_key(password, salt)
3091
+ cipher.key = key
3092
+
3093
+ iv = cipher.random_iv
3094
+ encrypted = cipher.update(text) + cipher.final
3095
+
3096
+ # Combine salt, iv, and encrypted data, then base64 encode
3097
+ combined = salt + iv + encrypted
3098
+ "ENC:" + Base64.strict_encode64(combined)
3099
+ end
3100
+
3101
+ def decrypt_string(encrypted_text, password = nil)
3102
+ return encrypted_text unless encrypted_text.start_with?("ENC:")
3103
+
3104
+ password ||= @encryption_key || prompt_password("Enter decryption password: ")
3105
+ return nil unless password
3106
+
3107
+ # Store key for session if not already stored
3108
+ @encryption_key ||= password
3109
+
3110
+ begin
3111
+ data = Base64.strict_decode64(encrypted_text[4..-1])
3112
+
3113
+ salt = data[0..15]
3114
+ iv = data[16..31]
3115
+ encrypted = data[32..-1]
3116
+
3117
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
3118
+ cipher.decrypt
3119
+
3120
+ key, _ = derive_key(password, salt)
3121
+ cipher.key = key
3122
+ cipher.iv = iv
3123
+
3124
+ cipher.update(encrypted) + cipher.final
3125
+ rescue => e
3126
+ @message = "Decryption failed. Wrong password?"
3127
+ nil
3128
+ end
3129
+ end
3130
+
3131
+ def is_encrypted_file?(filename)
3132
+ # Check if it's a dot file or has .enc extension
3133
+ basename = File.basename(filename)
3134
+ return true if basename.start_with?(".")
3135
+ return true if filename.end_with?(".enc")
3136
+
3137
+ # Check first line of file for encryption marker
3138
+ begin
3139
+ first_line = File.open(filename, &:readline).strip
3140
+ first_line.start_with?("ENC:")
3141
+ rescue
3142
+ false
3143
+ end
3144
+ end
3145
+
3146
+ def encrypt_file(content, password = nil)
3147
+ password ||= @encryption_key || prompt_password("Enter file encryption password: ")
3148
+ return nil unless password
3149
+
3150
+ encrypt_string(content, password)
3151
+ end
3152
+
3153
+ def decrypt_file(encrypted_content, password = nil)
3154
+ password ||= @encryption_key || prompt_password("Enter file decryption password: ")
3155
+ return nil unless password
3156
+
3157
+ decrypt_string(encrypted_content, password)
3158
+ end
3159
+
3160
+ def toggle_line_encryption
3161
+ return if @items.empty?
3162
+
3163
+ begin
3164
+ item = @items[@current]
3165
+ current_text = item["text"]
3166
+
3167
+ if current_text.start_with?("ENC:")
3168
+ # Decrypt the line
3169
+ decrypted = decrypt_string(current_text)
3170
+ if decrypted
3171
+ save_undo_state
3172
+ item["text"] = decrypted
3173
+ @encrypted_lines.delete(@current)
3174
+ @modified = true
3175
+ @message = "Line decrypted"
3176
+ else
3177
+ @message = "Decryption failed or cancelled"
3178
+ end
3179
+ else
3180
+ # Encrypt the line
3181
+ encrypted = encrypt_string(current_text)
3182
+ if encrypted
3183
+ save_undo_state
3184
+ item["text"] = encrypted
3185
+ @encrypted_lines[@current] = true
3186
+ @modified = true
3187
+ @message = "Line encrypted"
3188
+ else
3189
+ @message = "Encryption cancelled"
3190
+ end
3191
+ end
3192
+ rescue => e
3193
+ @message = "Encryption error: #{e.message}"
3194
+ end
3195
+ end
2734
3196
 
2735
3197
  def load_templates
2736
3198
  {
2737
3199
  "project" => [
2738
- {"text" => "Project: [Project Name]", "level" => 0},
3200
+ {"text" => "Project: =Project Name=", "level" => 0},
2739
3201
  {"text" => "[_] Define project scope", "level" => 1},
2740
3202
  {"text" => "[_] Identify stakeholders", "level" => 1},
2741
3203
  {"text" => "[_] Create timeline", "level" => 1},
@@ -2754,25 +3216,25 @@ class HyperListApp
2754
3216
  {"text" => "[_] Archive project materials", "level" => 2}
2755
3217
  ],
2756
3218
  "meeting" => [
2757
- {"text" => "Meeting: [Title]", "level" => 0},
3219
+ {"text" => "Meeting: =Title=", "level" => 0},
2758
3220
  {"text" => "Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", "level" => 1},
2759
- {"text" => "Location: [Conference Room/Online]", "level" => 1},
3221
+ {"text" => "Location: =Conference Room/Online=", "level" => 1},
2760
3222
  {"text" => "Attendees", "level" => 1},
2761
- {"text" => "[Name 1]", "level" => 2},
2762
- {"text" => "[Name 2]", "level" => 2},
3223
+ {"text" => "=Name 1=", "level" => 2},
3224
+ {"text" => "=Name 2=", "level" => 2},
2763
3225
  {"text" => "Agenda", "level" => 1},
2764
3226
  {"text" => "[_] Opening remarks", "level" => 2},
2765
3227
  {"text" => "[_] Review previous action items", "level" => 2},
2766
3228
  {"text" => "[_] Main topics", "level" => 2},
2767
- {"text" => "Topic 1: [Description]", "level" => 3},
2768
- {"text" => "Topic 2: [Description]", "level" => 3},
3229
+ {"text" => "Topic 1: =Description=", "level" => 3},
3230
+ {"text" => "Topic 2: =Description=", "level" => 3},
2769
3231
  {"text" => "[_] Q&A session", "level" => 2},
2770
3232
  {"text" => "[_] Next steps", "level" => 2},
2771
3233
  {"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},
3234
+ {"text" => "[_] =Action 1= - Assigned to: =Name= - Due: =Date=", "level" => 2},
3235
+ {"text" => "[_] =Action 2= - Assigned to: =Name= - Due: =Date=", "level" => 2},
2774
3236
  {"text" => "Notes", "level" => 1},
2775
- {"text" => "[Add meeting notes here]", "level" => 2}
3237
+ {"text" => "=Add meeting notes here=", "level" => 2}
2776
3238
  ],
2777
3239
  "daily" => [
2778
3240
  {"text" => "Daily Plan: #{Time.now.strftime('%Y-%m-%d')}", "level" => 0},
@@ -2781,19 +3243,19 @@ class HyperListApp
2781
3243
  {"text" => "[_] Check emails", "level" => 2},
2782
3244
  {"text" => "[_] Plan priorities", "level" => 2},
2783
3245
  {"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},
3246
+ {"text" => "[_] =High Priority Task 1=", "level" => 2},
3247
+ {"text" => "[_] =High Priority Task 2=", "level" => 2},
3248
+ {"text" => "[_] =High Priority Task 3=", "level" => 2},
2787
3249
  {"text" => "Regular Tasks", "level" => 1},
2788
- {"text" => "[_] [Task 1]", "level" => 2},
2789
- {"text" => "[_] [Task 2]", "level" => 2},
3250
+ {"text" => "[_] =Task 1=", "level" => 2},
3251
+ {"text" => "[_] =Task 2=", "level" => 2},
2790
3252
  {"text" => "Meetings/Appointments", "level" => 1},
2791
- {"text" => "[Time] - [Meeting/Event]", "level" => 2},
3253
+ {"text" => "=Time= - =Meeting/Event=", "level" => 2},
2792
3254
  {"text" => "Notes", "level" => 1},
2793
- {"text" => "[Daily observations and reflections]", "level" => 2}
3255
+ {"text" => "=Daily observations and reflections=", "level" => 2}
2794
3256
  ],
2795
3257
  "checklist" => [
2796
- {"text" => "Checklist: [Title]", "level" => 0},
3258
+ {"text" => "Checklist: =Title=", "level" => 0},
2797
3259
  {"text" => "[_] Item 1", "level" => 1},
2798
3260
  {"text" => "[_] Item 2", "level" => 1},
2799
3261
  {"text" => "[_] Item 3", "level" => 1},
@@ -2801,9 +3263,9 @@ class HyperListApp
2801
3263
  {"text" => "[_] Item 5", "level" => 1}
2802
3264
  ],
2803
3265
  "brainstorm" => [
2804
- {"text" => "Brainstorming: [Topic]", "level" => 0},
3266
+ {"text" => "Brainstorming: =Topic=", "level" => 0},
2805
3267
  {"text" => "Problem Statement", "level" => 1},
2806
- {"text" => "[Define the problem or opportunity]", "level" => 2},
3268
+ {"text" => "=Define the problem or opportunity=", "level" => 2},
2807
3269
  {"text" => "Ideas", "level" => 1},
2808
3270
  {"text" => "Category 1", "level" => 2},
2809
3271
  {"text" => "Idea A", "level" => 3},
@@ -2820,37 +3282,35 @@ class HyperListApp
2820
3282
  {"text" => "[_] Create action plan", "level" => 2}
2821
3283
  ],
2822
3284
  "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},
3285
+ {"text" => "Recipe: =Name=", "level" => 0},
3286
+ {"text" => "Servings: =Number=", "level" => 1},
3287
+ {"text" => "Prep Time: =Time=", "level" => 1},
3288
+ {"text" => "Cook Time: =Time=", "level" => 1},
2827
3289
  {"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},
3290
+ {"text" => "=Amount= =Ingredient 1=", "level" => 2},
3291
+ {"text" => "=Amount= =Ingredient 2=", "level" => 2},
3292
+ {"text" => "=Amount= =Ingredient 3=", "level" => 2},
2831
3293
  {"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},
3294
+ {"text" => "[_] Step 1: =Description=", "level" => 2},
3295
+ {"text" => "[_] Step 2: =Description=", "level" => 2},
3296
+ {"text" => "[_] Step 3: =Description=", "level" => 2},
2835
3297
  {"text" => "Notes", "level" => 1},
2836
- {"text" => "[Tips, variations, serving suggestions]", "level" => 2}
3298
+ {"text" => "=Tips, variations, serving suggestions=", "level" => 2}
2837
3299
  ]
2838
3300
  }
2839
3301
  end
2840
3302
 
2841
3303
  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}
3304
+ # Store original state in an array to ensure proper restoration
3305
+ original_state = {
3306
+ items: @items.dup,
3307
+ current: @current,
3308
+ offset: @offset,
3309
+ filename: @filename,
3310
+ modified: @modified
3311
+ }
2853
3312
 
3313
+ # Create template selection view
2854
3314
  template_list = [
2855
3315
  ["project", "Project Plan - Complete project management template"],
2856
3316
  ["meeting", "Meeting Agenda - Structure for meeting notes"],
@@ -2860,61 +3320,102 @@ class HyperListApp
2860
3320
  ["recipe", "Recipe - Cooking recipe structure"]
2861
3321
  ]
2862
3322
 
3323
+ # Build template selection items
3324
+ @items = []
3325
+ @items << {"text" => "TEMPLATES (press Enter to insert, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
3326
+ @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
3327
+
2863
3328
  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}
3329
+ @items << {
3330
+ "text" => "#{idx+1}. #{key.capitalize}: #{desc}",
3331
+ "level" => 0,
3332
+ "fold" => false,
3333
+ "raw" => true,
3334
+ "template_key" => key
3335
+ }
2865
3336
  end
2866
3337
 
2867
3338
  @current = 2 # Start at first template
2868
3339
  @offset = 0
2869
- @modified = false
2870
3340
 
2871
3341
  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
3342
+ exit_loop = false
3343
+
3344
+ # Template selection loop
3345
+ while !exit_loop
3346
+ begin
3347
+ render_main
3348
+ @footer.text = "Templates | Enter: insert | q: cancel | j/k: navigate"
3349
+ @footer.refresh
3350
+
3351
+ c = getchr
3352
+
3353
+ # Skip truly nil input
3354
+ next if c.nil?
3355
+
3356
+ # Debug logging to file
3357
+ File.open('/tmp/hyperlist_debug.log', 'a') do |f|
3358
+ f.puts "Template key received: #{c.inspect} (length: #{c.length}, ord: #{c.bytes.inspect})"
3359
+ end if ENV['DEBUG']
3360
+
3361
+ case c
3362
+ when "q", "ESC", "C-c", "Q"
3363
+ exit_loop = true
3364
+ when "j", "DOWN"
3365
+ @current = [@current + 1, @items.length - 1].min
3366
+ when "k", "UP"
3367
+ @current = [@current - 1, 2].max
3368
+ when "ENTER", "RETURN", "\n", "\r", "l"
3369
+ if @current >= 2 && @current < @items.length
3370
+ item = @items[@current]
3371
+ if item && item.is_a?(Hash) && item["template_key"]
3372
+ selected_template = item["template_key"]
3373
+ exit_loop = true
3374
+ end
3375
+ end
3376
+ when /^[1-6]$/
3377
+ idx = c.to_i - 1
3378
+ if idx >= 0 && idx < template_list.length
3379
+ selected_template = template_list[idx][0]
3380
+ exit_loop = true
3381
+ end
2904
3382
  end
3383
+
3384
+ rescue => e
3385
+ # Log error but continue
3386
+ @message = "Error in template loop: #{e.message}"
3387
+ exit_loop = true
2905
3388
  end
2906
3389
  end
2907
3390
 
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)
3391
+ # Restore original state
3392
+ @items = original_state[:items]
3393
+ @current = original_state[:current]
3394
+ @offset = original_state[:offset]
3395
+ @filename = original_state[:filename]
3396
+ @modified = original_state[:modified]
3397
+
3398
+ # Flush any remaining input to avoid accidental actions
3399
+ begin
3400
+ while IO.select([$stdin], nil, nil, 0)
3401
+ $stdin.read_nonblock(1024)
3402
+ end
3403
+ rescue IO::WaitReadable, EOFError
3404
+ # No more input to flush
3405
+ end
3406
+
3407
+ # Insert template if one was selected
3408
+ if selected_template && !selected_template.empty?
3409
+ begin
3410
+ template_items = @templates[selected_template]
3411
+ if template_items && template_items.is_a?(Array) && !template_items.empty?
3412
+ insert_template(selected_template)
3413
+ else
3414
+ @message = "Template '#{selected_template}' not found or empty"
3415
+ end
3416
+ rescue => e
3417
+ @message = "Error inserting template: #{e.message}"
3418
+ end
2918
3419
  end
2919
3420
  end
2920
3421
 
@@ -2922,10 +3423,15 @@ class HyperListApp
2922
3423
  template_items = @templates[template_key]
2923
3424
  return unless template_items
2924
3425
 
2925
- save_state_for_undo
3426
+ # Ensure we have valid items and current position
3427
+ return if @items.nil? || @items.empty?
3428
+ return if @current.nil? || @current < 0 || @current >= @items.length
3429
+ return unless @items[@current]
3430
+
3431
+ save_undo_state
2926
3432
 
2927
3433
  # Get current item level to adjust template indentation
2928
- current_level = @items[@current]["level"]
3434
+ current_level = @items[@current]["level"] || 0
2929
3435
 
2930
3436
  # Insert template items after current position
2931
3437
  insertion_point = @current + 1
@@ -3025,13 +3531,29 @@ class HyperListApp
3025
3531
  when "V"
3026
3532
  toggle_checkbox_with_date
3027
3533
  when "TAB"
3028
- indent_with_children
3534
+ if @split_view && @active_pane == :split
3535
+ indent_split_right(true) # with children
3536
+ else
3537
+ indent_right(true) # with children
3538
+ end
3029
3539
  when "S-TAB"
3030
- unindent_with_children
3540
+ if @split_view && @active_pane == :split
3541
+ indent_split_left(true) # with children
3542
+ else
3543
+ indent_left(true) # with children
3544
+ end
3031
3545
  when "RIGHT"
3032
- indent_line
3546
+ if @split_view && @active_pane == :split
3547
+ indent_split_right(false)
3548
+ else
3549
+ indent_right(false)
3550
+ end
3033
3551
  when "LEFT"
3034
- unindent_line
3552
+ if @split_view && @active_pane == :split
3553
+ indent_split_left(false)
3554
+ else
3555
+ indent_left(false)
3556
+ end
3035
3557
  when " "
3036
3558
  toggle_fold
3037
3559
  when "u"
@@ -3048,17 +3570,29 @@ class HyperListApp
3048
3570
  def toggle_split_view
3049
3571
  @split_view = !@split_view
3050
3572
  if @split_view
3051
- @split_items = @items.dup # Copy current items to split view
3573
+ # Use the actual items array for full document split view
3574
+ # This ensures changes in either pane affect both views
3575
+ @split_items = @items
3052
3576
  @split_current = @current
3053
3577
  @split_offset = @offset
3054
3578
  @active_pane = :main
3055
- set_message("Split view enabled. Use 'ww' to switch panes.")
3579
+ set_message("Split view enabled. Use Ctrl-w w to switch panes.")
3056
3580
  else
3057
3581
  @split_items = []
3058
3582
  set_message("Split view disabled")
3059
3583
  end
3584
+
3585
+ # Clear cached content to force re-render
3586
+ @last_rendered_content = ""
3587
+ @processed_cache.clear
3588
+
3589
+ # Recreate UI
3060
3590
  setup_ui
3061
- render # Re-render everything after changing the UI layout
3591
+
3592
+ # Force complete re-render
3593
+ render_main
3594
+ render_split_pane if @split_view
3595
+ render_footer
3062
3596
  end
3063
3597
 
3064
3598
  def copy_section_to_split
@@ -3090,7 +3624,8 @@ class HyperListApp
3090
3624
  end_idx = idx
3091
3625
  end
3092
3626
 
3093
- # Copy section to split view
3627
+ # Copy section to split view - use references, not copies
3628
+ # The slice creates a new array but contains references to the same item objects
3094
3629
  @split_items = @items[start_idx..end_idx]
3095
3630
  @split_current = 0
3096
3631
  @split_offset = 0
@@ -3106,6 +3641,10 @@ class HyperListApp
3106
3641
  if @split_view
3107
3642
  @active_pane = (@active_pane == :main) ? :split : :main
3108
3643
  @message = "Switched to #{@active_pane} pane"
3644
+ # Force re-render both panes to update highlighting
3645
+ render_main
3646
+ render_split_pane
3647
+ render_footer
3109
3648
  end
3110
3649
  when "v"
3111
3650
  # Vertical split
@@ -3121,48 +3660,111 @@ class HyperListApp
3121
3660
  end
3122
3661
  end
3123
3662
 
3663
+ def get_visible_split_items
3664
+ return [] unless @split_items
3665
+
3666
+ visible = []
3667
+ skip_until_level = nil
3668
+
3669
+ @split_items.each do |item|
3670
+ # Skip items that are folded
3671
+ if skip_until_level
3672
+ if item["level"] > skip_until_level
3673
+ next
3674
+ else
3675
+ skip_until_level = nil
3676
+ end
3677
+ end
3678
+
3679
+ visible << item
3680
+
3681
+ # Check if this item is folded and has children
3682
+ if item["fold"] && has_children_in_array?(visible.length - 1, @split_items)
3683
+ skip_until_level = item["level"]
3684
+ end
3685
+ end
3686
+
3687
+ visible
3688
+ end
3689
+
3124
3690
  def render_split_pane
3125
3691
  return unless @split_view && @split_pane
3126
3692
 
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
3693
+ # Get visible items respecting fold state
3694
+ visible_items = get_visible_split_items
3134
3695
 
3696
+ # Build ALL lines for the pane (like we do for main pane)
3135
3697
  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]
3698
+ visible_items.each_with_index do |item, idx|
3141
3699
  next unless item
3142
3700
 
3143
3701
  line = " " * item["level"]
3144
3702
 
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")
3703
+ # Add fold indicator with colors
3704
+ # Find the item's position in the original split_items array
3705
+ real_idx = @split_items.index(item)
3706
+ if real_idx && has_children_in_array?(real_idx, @split_items)
3707
+ if item["fold"]
3708
+ line += "▶".fg("245") + " "
3709
+ else
3710
+ line += "▷".fg("245") + " "
3711
+ end
3150
3712
  else
3151
3713
  line += " "
3152
3714
  end
3153
3715
 
3154
- # Process text (simplified for split view)
3155
- line += process_text(item["text"], false)
3716
+ # Apply process_text for syntax highlighting
3717
+ processed = process_text(item["text"], false)
3718
+ line += processed
3156
3719
 
3157
- # Highlight current line in split pane
3720
+ # Apply background highlighting for current item in split pane
3158
3721
  if idx == @split_current
3159
- line = line.r # Always use reverse (white background) for current line
3722
+ # Choose background color based on whether this is the active pane
3723
+ bg_color = @active_pane == :split ? "237" : "234"
3724
+ bg_code = "\e[48;5;#{bg_color}m"
3725
+ reset_bg = "\e[49m"
3726
+ line = bg_code + line.gsub(/\e\[49m/, '') + reset_bg
3160
3727
  end
3161
3728
 
3162
3729
  lines << line
3163
3730
  end
3164
3731
 
3732
+ # Add a blank line at the bottom to show end of document
3733
+ lines << ""
3734
+
3735
+ # Set the full content to the pane and let rcurses handle scrolling
3165
3736
  @split_pane.text = lines.join("\n")
3737
+
3738
+ # Calculate how many extra lines are created by wrapping
3739
+ extra_wrapped_lines = 0
3740
+ lines[0..-2].each do |line| # Exclude the blank line we added
3741
+ # Remove ANSI codes for length calculation
3742
+ clean_line = line.gsub(/\e\[[0-9;]*m/, '')
3743
+ if clean_line.length > @split_pane.w
3744
+ # This line will wrap - calculate how many extra lines it creates
3745
+ extra_lines = (clean_line.length.to_f / @split_pane.w).ceil - 1
3746
+ extra_wrapped_lines += extra_lines
3747
+ end
3748
+ end
3749
+
3750
+ # Calculate scroll position exactly like RTFM does, but account for wrapping
3751
+ scrolloff = 3
3752
+ total = visible_items.length + 1 # +1 for the blank line
3753
+ page = @split_pane.h
3754
+
3755
+ if total <= page
3756
+ # If everything fits, always start from the very top
3757
+ @split_pane.ix = 0
3758
+ elsif @split_current - @split_pane.ix < scrolloff
3759
+ # If we're too close to the top of the pane, scroll up
3760
+ @split_pane.ix = [@split_current - scrolloff, 0].max
3761
+ elsif (@split_pane.ix + page - 1 - @split_current) < scrolloff
3762
+ # If we're too close to the bottom of the pane, scroll down
3763
+ # Account for wrapped lines dynamically
3764
+ max_off = [total - page + extra_wrapped_lines, 0].max
3765
+ @split_pane.ix = [@split_current + scrolloff - page + 1, max_off].min
3766
+ end
3767
+
3166
3768
  @split_pane.refresh
3167
3769
  end
3168
3770
 
@@ -3175,11 +3777,22 @@ class HyperListApp
3175
3777
 
3176
3778
  def move_in_active_pane(direction)
3177
3779
  if @split_view && @active_pane == :split
3780
+ visible = get_visible_split_items
3178
3781
  case direction
3179
3782
  when :down
3180
- @split_current = [@split_current + 1, @split_items.length - 1].min if @split_current < @split_items.length - 1
3783
+ if @split_current >= visible.length - 1
3784
+ # Wrap to top
3785
+ @split_current = 0
3786
+ else
3787
+ @split_current = @split_current + 1
3788
+ end
3181
3789
  when :up
3182
- @split_current = [@split_current - 1, 0].max if @split_current > 0
3790
+ if @split_current <= 0
3791
+ # Wrap to bottom
3792
+ @split_current = visible.length - 1
3793
+ else
3794
+ @split_current = @split_current - 1
3795
+ end
3183
3796
  end
3184
3797
  else
3185
3798
  case direction
@@ -3665,24 +4278,57 @@ class HyperListApp
3665
4278
  go_to_first_child
3666
4279
  when "LEFT"
3667
4280
  # Unindent only the current item
3668
- indent_left(false)
4281
+ if @split_view && @active_pane == :split
4282
+ indent_split_left(false)
4283
+ else
4284
+ indent_left(false)
4285
+ end
3669
4286
  when "RIGHT"
3670
4287
  # Indent only the current item
3671
- indent_right(false)
4288
+ if @split_view && @active_pane == :split
4289
+ indent_split_right(false)
4290
+ else
4291
+ indent_right(false)
4292
+ end
3672
4293
  when "PgUP" # Page Up
3673
4294
  page_up
3674
4295
  when "PgDOWN" # Page Down
3675
4296
  page_down
3676
4297
  when "HOME" # Home
3677
- @current = 0
3678
- @offset = 0
4298
+ if @split_view && @active_pane == :split
4299
+ @split_current = 0
4300
+ else
4301
+ @current = 0
4302
+ @offset = 0
4303
+ update_presentation_focus if @presentation_mode
4304
+ end
3679
4305
  when "END" # End
3680
- @current = get_visible_items.length - 1
4306
+ if @split_view && @active_pane == :split
4307
+ visible = get_visible_split_items
4308
+ @split_current = [visible.length - 1, 0].max
4309
+ else
4310
+ visible = get_visible_items
4311
+ @current = [visible.length - 1, 0].max
4312
+ update_presentation_focus if @presentation_mode
4313
+ end
3681
4314
  when "g" # Go to top (was gg)
3682
- @current = 0
3683
- @offset = 0
4315
+ if @split_view && @active_pane == :split
4316
+ @split_current = 0
4317
+ else
4318
+ @current = 0
4319
+ @offset = 0
4320
+ update_presentation_focus if @presentation_mode
4321
+ end
3684
4322
  when "G" # Go to bottom
3685
- @current = get_visible_items.length - 1
4323
+ if @split_view && @active_pane == :split
4324
+ visible = get_visible_split_items
4325
+ @split_current = [visible.length - 1, 0].max
4326
+ else
4327
+ visible = get_visible_items
4328
+ @current = [visible.length - 1, 0].max
4329
+ # Let normal offset logic position the last item
4330
+ update_presentation_focus if @presentation_mode
4331
+ end
3686
4332
  when "R" # Jump to reference (was gr)
3687
4333
  jump_to_reference
3688
4334
  when "F" # Open file reference (was gf)
@@ -3742,6 +4388,8 @@ class HyperListApp
3742
4388
  delete_line(false) # D always deletes with children by default
3743
4389
  when "C-D" # Delete line and all descendants explicitly
3744
4390
  delete_line(true)
4391
+ when "C-E" # Toggle line encryption
4392
+ toggle_line_encryption
3745
4393
  when "y" # Yank/copy single line
3746
4394
  yank_line(false)
3747
4395
  when "Y" # Yank/copy line with all descendants
@@ -3760,10 +4408,18 @@ class HyperListApp
3760
4408
  move_item_up(true)
3761
4409
  when "TAB"
3762
4410
  # Indent with all children
3763
- indent_right(true)
4411
+ if @split_view && @active_pane == :split
4412
+ indent_split_right(true)
4413
+ else
4414
+ indent_right(true)
4415
+ end
3764
4416
  when "S-TAB" # Shift-Tab
3765
4417
  # Unindent with all children
3766
- indent_left(true)
4418
+ if @split_view && @active_pane == :split
4419
+ indent_split_left(true)
4420
+ else
4421
+ indent_left(true)
4422
+ end
3767
4423
  when "u"
3768
4424
  undo
3769
4425
  when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
@@ -3782,11 +4438,34 @@ class HyperListApp
3782
4438
  jump_to_next_template_marker
3783
4439
  when "P"
3784
4440
  toggle_presentation_mode
4441
+ when "\x15" # Ctrl-U for State/Transition underline toggle
4442
+ # Cycle through underline modes: 0 (none) -> 1 (states) -> 2 (transitions) -> 0
4443
+ @st_underline_mode = (@st_underline_mode + 1) % 3
4444
+ # Clear cache to force re-rendering
4445
+ @processed_cache.clear
4446
+ case @st_underline_mode
4447
+ when 0
4448
+ @message = "Underline mode: OFF"
4449
+ when 1
4450
+ @message = "Underline mode: STATES (S: and |)"
4451
+ when 2
4452
+ @message = "Underline mode: TRANSITIONS (T: and /)"
4453
+ end
3785
4454
  when "\\"
3786
4455
  next_c = getchr
3787
4456
  case next_c
3788
4457
  when "u"
3789
- @message = "Underline mode toggled"
4458
+ # Alternative for Ctrl-U (backslash-u)
4459
+ @st_underline_mode = (@st_underline_mode + 1) % 3
4460
+ @processed_cache.clear
4461
+ case @st_underline_mode
4462
+ when 0
4463
+ @message = "Underline mode: OFF"
4464
+ when 1
4465
+ @message = "Underline mode: STATES (S: and |)"
4466
+ when 2
4467
+ @message = "Underline mode: TRANSITIONS (T: and /)"
4468
+ end
3790
4469
  end
3791
4470
  when ":"
3792
4471
  handle_command
@@ -3830,15 +4509,16 @@ class HyperListApp
3830
4509
  next_c = getchr
3831
4510
  play_macro(next_c) if next_c && next_c =~ /[a-z]/
3832
4511
  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
4512
+ # Switch active pane if split view is active
4513
+ if @split_view
3837
4514
  @active_pane = (@active_pane == :main) ? :split : :main
3838
4515
  @message = "Switched to #{@active_pane} pane"
4516
+ # Force re-render both panes to update highlighting
4517
+ render_main
4518
+ render_split_pane
4519
+ render_footer
3839
4520
  else
3840
- # Not a window command, ignore for now
3841
- @message = "Unknown command: w#{next_c}"
4521
+ @message = "Split view not active. Use :vs to enable"
3842
4522
  end
3843
4523
  when "Q" # Force quit
3844
4524
  quit