hyperlist 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/hyperlist +754 -373
  4. data/hyperlist.gemspec +1 -1
  5. data/test.hl +4 -19
  6. metadata +2 -2
data/hyperlist CHANGED
@@ -70,7 +70,7 @@ class HyperListApp
70
70
  include Rcurses::Input
71
71
  include Rcurses::Cursor
72
72
 
73
- VERSION = "1.1.0"
73
+ VERSION = "1.1.2"
74
74
 
75
75
  def initialize(filename = nil)
76
76
  @filename = filename ? File.expand_path(filename) : nil
@@ -105,6 +105,7 @@ class HyperListApp
105
105
  @macro_key = nil # Key for current macro
106
106
  @encryption_key = nil # Store derived encryption key
107
107
  @encrypted_lines = {} # Track which lines are encrypted
108
+ @st_underline_mode = 0 # 0: none, 1: underline states, 2: underline transitions
108
109
  @split_view = false
109
110
  @split_items = [] # Second view items
110
111
  @split_current = 0 # Second view cursor
@@ -149,22 +150,29 @@ class HyperListApp
149
150
  if @split_view
150
151
  # Split view layout - no header, more space for content
151
152
  split_width = @cols / 2
152
- # Main content panes - full height minus 1 for footer
153
- @main = Pane.new(0, 0, split_width - 1, @rows - 1, 15, 0)
154
- @split_pane = Pane.new(split_width + 1, 0, split_width - 1, @rows - 1, 15, 0)
153
+ # Main content panes - use nil colors to act as pass-through for pre-colored text
154
+ # This is how RTFM works - it passes colored command output to panes with nil colors
155
+ @main = Pane.new(1, 1, split_width - 1, @rows - 1, nil, nil)
156
+ @split_pane = Pane.new(split_width + 1, 1, split_width - 1, @rows - 1, nil, nil)
155
157
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
156
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
158
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
157
159
 
158
- # Add separator
159
- @separator = Pane.new(split_width, 0, 1, @rows - 1, 15, 8)
160
- @separator.text = "│" * (@rows - 1)
160
+ # Add separator with explicit background to overwrite emoji overflow
161
+ @separator = Pane.new(split_width, 1, 1, @rows - 1, 15, 0)
162
+ # Build separator with explicit clearing
163
+ separator_lines = []
164
+ (@rows - 1).times do
165
+ separator_lines << "│"
166
+ end
167
+ @separator.text = separator_lines.join("\n")
161
168
  @separator.refresh
162
169
  else
163
170
  # Single view layout - no header, more space for content
164
- # Main pane uses full height minus 1 for footer
165
- @main = Pane.new(0, 0, @cols, @rows - 1, 15, 0)
171
+ # Use nil colors like RTFM to avoid ANSI wrapping issues
172
+ # This prevents the corruption that happens with narrow terminals
173
+ @main = Pane.new(1, 1, @cols, @rows - 1, nil, nil)
166
174
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
167
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
175
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
168
176
  end
169
177
  end
170
178
 
@@ -558,38 +566,28 @@ class HyperListApp
558
566
  level = item["level"]
559
567
  indent = " " * level
560
568
 
561
- # Comments (start with semicolon)
562
- if original_text.start_with?(';')
563
- # Escape HTML for comments
564
- text = original_text.gsub('&', '&amp;')
565
- .gsub('<', '&lt;')
566
- .gsub('>', '&gt;')
567
- .gsub('"', '&quot;')
568
- .gsub("'", '&#39;')
569
- html += indent + '<span class="comment">' + text + '</span>' + "\n"
570
- else
571
- # Process non-comment lines
572
- # We'll apply formatting BEFORE escaping HTML entities
573
- text = original_text
574
-
575
- # Apply all formatting with placeholder markers
576
- # Use unique markers that won't appear in normal text
577
-
578
- # Markdown formatting MUST come first before other replacements
579
- # Bold *text*
580
- text = text.gsub(/\*([^*]+)\*/) do
581
- "\u0001BOLDSTART\u0002#{$1}\u0001BOLDEND\u0002"
582
- end
583
-
584
- # Italic /text/
585
- text = text.gsub(/\/([^\/]+)\//) do
586
- "\u0001ITALICSTART\u0002#{$1}\u0001ITALICEND\u0002"
587
- end
588
-
589
- # Underline _text_
590
- text = text.gsub(/_([^_]+)_/) do
591
- "\u0001UNDERSTART\u0002#{$1}\u0001UNDEREND\u0002"
592
- end
569
+ # Process the line
570
+ # We'll apply formatting BEFORE escaping HTML entities
571
+ text = original_text
572
+
573
+ # Apply all formatting with placeholder markers
574
+ # Use unique markers that won't appear in normal text
575
+
576
+ # Markdown formatting MUST come first before other replacements
577
+ # Bold *text*
578
+ text = text.gsub(/\*([^*]+)\*/) do
579
+ "\u0001BOLDSTART\u0002#{$1}\u0001BOLDEND\u0002"
580
+ end
581
+
582
+ # Italic /text/
583
+ text = text.gsub(/\/([^\/]+)\//) do
584
+ "\u0001ITALICSTART\u0002#{$1}\u0001ITALICEND\u0002"
585
+ end
586
+
587
+ # Underline _text_
588
+ text = text.gsub(/_([^_]+)_/) do
589
+ "\u0001UNDERSTART\u0002#{$1}\u0001UNDEREND\u0002"
590
+ end
593
591
 
594
592
  # Checkboxes at start
595
593
  text = text.sub(/^\[X\]/i, "\u0001CBCHECKED\u0002")
@@ -663,7 +661,6 @@ class HyperListApp
663
661
  .gsub("\u0001UNDEREND\u0002", '</u>')
664
662
 
665
663
  html += indent + text + "\n"
666
- end
667
664
  end
668
665
 
669
666
  html += "</body>\n</html>"
@@ -683,133 +680,129 @@ class HyperListApp
683
680
  render_main
684
681
  render_split_pane if @split_view
685
682
  render_footer
683
+ # Force redraw separator column to fix emoji overflow
684
+ if @split_view
685
+ split_col = @cols / 2
686
+ # Clear and redraw the entire separator column
687
+ (@rows - 1).times do |row|
688
+ # Move to position and draw separator with background color to overwrite anything
689
+ print "\e[#{row + 1};#{split_col}H\e[48;5;0m│\e[49m"
690
+ end
691
+ end
686
692
  end
687
693
 
688
694
 
689
695
  def render_main
690
696
  visible_items = get_visible_items
691
697
 
692
- # Calculate window
693
- view_height = @main.h
694
- if @current < @offset
695
- @offset = @current
696
- elsif @current >= @offset + view_height
697
- @offset = @current - view_height + 1
698
- end
699
-
700
- # Track if we're in a literal block
698
+ # Build ALL lines for the pane (like RTFM/IMDB do)
699
+ lines = []
701
700
  in_literal_block = false
702
701
  literal_start_level = -1
703
702
 
704
- # Build display lines only for visible portion
705
- lines = []
706
- start_idx = @offset
707
- end_idx = [@offset + view_height, visible_items.length].min
708
-
709
- # For very large files, limit cache size and clear old entries
710
- if visible_items.length > 5000 && @processed_cache.size > 2000
711
- # Keep only recent entries
712
- @processed_cache.clear
713
- end
714
-
715
- # Only process visible items
716
- (start_idx...end_idx).each do |idx|
717
- item = visible_items[idx]
703
+ visible_items.each_with_index do |item, idx|
718
704
  next unless item
719
705
 
720
- # Create cache key for this item
721
- cache_key = "#{item['text']}_#{item['level']}_#{item['fold']}"
706
+ line = " " * item["level"] # 4 spaces per level
722
707
 
723
- # Check cache first (skip cache in presentation mode to ensure proper rendering)
724
- if @processed_cache[cache_key] && idx != @current && !@presentation_mode
725
- lines << @processed_cache[cache_key]
708
+ # Add fold indicator
709
+ real_idx = @items.index(item)
710
+ if real_idx && has_children?(real_idx, @items) && item["fold"]
711
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
712
+ line += "▶".fg(color) + " "
713
+ elsif real_idx && has_children?(real_idx, @items)
714
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
715
+ line += "▷".fg(color) + " "
726
716
  else
727
- line = " " * item["level"] # 4 spaces per level
728
-
729
- # Add fold indicator
730
- real_idx = @items.index(item) # Get the real index in the full array
731
- if real_idx && has_children?(real_idx, @items) && item["fold"]
732
- # Use darker grey for unfocused items in presentation mode
733
- color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
734
- line += "".fg(color) + " " # Triangle for collapsed (has hidden children)
735
- elsif real_idx && has_children?(real_idx, @items)
736
- # Use darker grey for unfocused items in presentation mode
737
- color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
738
- line += "▷".fg(color) + " " # Triangle for expanded (has visible children)
717
+ line += " "
718
+ end
719
+
720
+ # Handle literal blocks and syntax highlighting
721
+ if item["text"].strip == "\\"
722
+ if !in_literal_block
723
+ in_literal_block = true
724
+ literal_start_level = item["level"]
725
+ spaces = item["text"].match(/^(\s*)/)[1]
726
+ line += spaces + "\\".fg("3")
727
+ elsif item["level"] == literal_start_level
728
+ in_literal_block = false
729
+ literal_start_level = -1
730
+ spaces = item["text"].match(/^(\s*)/)[1]
731
+ line += spaces + "\\".fg("3")
739
732
  else
740
- line += " "
741
- end
742
-
743
- # Check for literal block markers
744
- if item["text"].strip == "\\"
745
- if !in_literal_block
746
- # Starting a literal block
747
- in_literal_block = true
748
- literal_start_level = item["level"]
749
- # Preserve leading spaces and color the backslash
750
- spaces = item["text"].match(/^(\s*)/)[1]
751
- line += spaces + "\\".fg("3") # Yellow for literal marker
752
- elsif item["level"] == literal_start_level
753
- # Ending a literal block
754
- in_literal_block = false
755
- literal_start_level = -1
756
- # Preserve leading spaces and color the backslash
757
- spaces = item["text"].match(/^(\s*)/)[1]
758
- line += spaces + "\\".fg("3") # Yellow for literal marker
759
- else
760
- # Backslash inside literal block - no highlighting
761
- line += item["text"]
762
- end
763
- elsif in_literal_block
764
- # Inside literal block - no syntax highlighting
765
733
  line += item["text"]
766
- else
767
- # Normal text - apply syntax highlighting with caching
768
- # Check if this line has a search match
769
- has_match = @search_matches.include?(idx) && @search && !@search.empty?
770
-
771
- # Check if this is raw text (for help/documentation screens)
772
- if item["raw"]
773
- line += item["text"]
774
- else
775
- # Check if we should grey out this item in presentation mode
776
- if @presentation_mode && !is_item_in_presentation_focus?(item)
777
- # Grey out items not in focus
778
- line += item["text"].fg("240") # Dark grey for unfocused items
779
- else
780
- processed_text_key = has_match ? "search_#{item['text']}_#{@search}" : "text_#{item['text']}"
781
- if @processed_cache[processed_text_key] && !has_match && !@presentation_mode
782
- line += @processed_cache[processed_text_key]
783
- else
784
- processed = process_text(item["text"], has_match)
785
- @processed_cache[processed_text_key] = processed if @processed_cache.size < 1000 && !has_match && !@presentation_mode
786
- line += processed
787
- end
788
- end
789
- end
790
734
  end
791
-
792
- # Cache the line (without highlight) - but not in presentation mode or for focused items
793
- if idx != @current && !@presentation_mode && !item["presentation_focus"]
794
- @processed_cache[cache_key] = line
735
+ elsif in_literal_block
736
+ line += item["text"]
737
+ else
738
+ # Normal syntax highlighting
739
+ has_match = @search_matches.include?(idx) && @search && !@search.empty?
740
+ if @presentation_mode && !is_item_in_presentation_focus?(item)
741
+ line += item["text"].fg("240")
742
+ else
743
+ line += process_text(item["text"], has_match)
795
744
  end
796
-
797
- # Highlight current line with dark gray background
798
- if idx == @current
799
- line = line.bg("237") # Dark gray background to preserve foreground colors
745
+ end
746
+
747
+ # Apply current item highlighting
748
+ if idx == @current
749
+ bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
750
+ if bg_color
751
+ bg_code = "\e[48;5;#{bg_color}m"
752
+ reset_bg = "\e[49m"
753
+ line = bg_code + line.gsub(/\e\[49m/, '') + reset_bg
800
754
  end
801
-
802
- lines << line
755
+ end
756
+
757
+ lines << line
758
+
759
+ # Check if exiting literal block
760
+ if in_literal_block && item["level"] <= literal_start_level && !item["text"].strip == "\\"
761
+ in_literal_block = false
762
+ literal_start_level = -1
763
+ end
764
+ end
765
+
766
+ # Add a blank line at the bottom to show end of document
767
+ lines << ""
768
+
769
+ # Set the full content to the pane and let rcurses handle scrolling
770
+ @main.text = lines.join("\n")
771
+
772
+ # Calculate how many extra lines are created by wrapping
773
+ logical_lines = lines.length
774
+ # Estimate wrapped lines by checking line lengths against pane width
775
+ extra_wrapped_lines = 0
776
+ lines[0..-2].each do |line| # Exclude the blank line we added
777
+ # Remove ANSI codes for length calculation
778
+ clean_line = line.gsub(/\e\[[0-9;]*m/, '')
779
+ if clean_line.length > @main.w
780
+ # This line will wrap - calculate how many extra lines it creates
781
+ extra_lines = (clean_line.length.to_f / @main.w).ceil - 1
782
+ extra_wrapped_lines += extra_lines
803
783
  end
804
784
  end
805
785
 
806
- # Only update if content changed
807
- new_content = lines.join("\n")
808
- if @last_rendered_content != new_content
809
- @main.text = new_content
810
- @main.refresh
811
- @last_rendered_content = new_content
786
+ # Calculate scroll position exactly like RTFM does, but account for wrapping
787
+ # Treat the content as having one extra line (the blank line at bottom)
788
+ scrolloff = 3
789
+ total = visible_items.length + 1 # +1 for the blank line
790
+ page = @main.h
791
+
792
+ if total <= page
793
+ # If everything fits, always start from the very top
794
+ @main.ix = 0
795
+ elsif @current - @main.ix < scrolloff
796
+ # If we're too close to the top of the pane, scroll up
797
+ @main.ix = [@current - scrolloff, 0].max
798
+ elsif (@main.ix + page - 1 - @current) < scrolloff
799
+ # If we're too close to the bottom of the pane, scroll down
800
+ # Account for wrapped lines dynamically
801
+ max_off = [total - page + extra_wrapped_lines, 0].max
802
+ @main.ix = [@current + scrolloff - page + 1, max_off].min
812
803
  end
804
+
805
+ @main.refresh
813
806
  end
814
807
 
815
808
  def process_text(text, highlight_search = false)
@@ -822,6 +815,47 @@ class HyperListApp
822
815
  return result
823
816
  end
824
817
 
818
+ # Apply State and Transition underlining FIRST, before any coloring
819
+ if @st_underline_mode == 1
820
+ # Underline states
821
+ if result =~ /^(\s*)(S:\s+)(.*)$/ || result =~ /^(\s*)(\|\s+)(.*)$/
822
+ prefix = $1 || ""
823
+ marker = $2
824
+ content = $3 || ""
825
+ # Apply underline to content after the marker
826
+ result = prefix + marker + content.u
827
+ end
828
+ elsif @st_underline_mode == 2
829
+ # Underline transitions
830
+ if result =~ /^(\s*)(T:\s+)(.*)$/ || result =~ /^(\s*)(\/\s+)(.*)$/
831
+ prefix = $1 || ""
832
+ marker = $2
833
+ content = $3 || ""
834
+ # Apply underline to content after the marker
835
+ result = prefix + marker + content.u
836
+ end
837
+ end
838
+
839
+ # Helper method to safely apply regexes without corrupting ANSI sequences
840
+ def safe_regex_replace(text, pattern, &block)
841
+ # Find all ANSI sequences and replace with placeholders
842
+ ansi_sequences = []
843
+ placeholder_text = text.gsub(/\e\[[0-9;]*m/) do |match|
844
+ ansi_sequences << match
845
+ "⟨ANSI#{ansi_sequences.length - 1}⟩"
846
+ end
847
+
848
+ # Apply the regex to the placeholder text
849
+ result_text = placeholder_text.gsub(pattern, &block)
850
+
851
+ # Restore ANSI sequences
852
+ ansi_sequences.each_with_index do |ansi, index|
853
+ result_text.gsub!("⟨ANSI#{index}⟩", ansi)
854
+ end
855
+
856
+ result_text
857
+ end
858
+
825
859
  # Check if this is an encrypted line
826
860
  if result.start_with?("ENC:")
827
861
  # Show encrypted indicator instead of the encrypted data
@@ -906,7 +940,7 @@ class HyperListApp
906
940
 
907
941
  # Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
908
942
  if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
909
- prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators
943
+ prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators (including S: and T:)
910
944
  elsif text_part.length >= 2 && space_after.include?(" ")
911
945
  # It's a property (mixed case, at least 2 chars, has space after colon)
912
946
  prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
@@ -916,16 +950,24 @@ class HyperListApp
916
950
  end
917
951
  end
918
952
 
953
+
954
+ # Color special state/transition markers (| and /) green
955
+ result.gsub!(/^(\s*)\|\s+/) { $1 + "| ".fg("2") } # Green for pipe (state marker)
956
+ result.gsub!(/^(\s*)\/\s+/) { $1 + "/ ".fg("2") } # Green for slash (transition marker)
957
+
919
958
  # Handle OR: at the beginning of a line (with optional spaces)
920
959
  result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
921
960
 
922
961
  # Handle parentheses content (moved here to avoid conflicts with properties)
923
962
  # Based on hyperlist.vim: '(.\{-})'
924
- result.gsub!(/\(([^)]*)\)/) { "(".fg("6") + $1.fg("6") + ")".fg("6") } # Cyan for parentheses and content
963
+ result = safe_regex_replace(result, /\(([^)]*)\)/) do |match|
964
+ content = match[1..-2] # Extract content between parentheses
965
+ "(".fg("6") + content.fg("6") + ")".fg("6")
966
+ end
925
967
 
926
- # Handle semicolons as separators - but only at the start of the line or after spaces
927
- # Don't replace semicolons that might be part of ANSI codes
928
- result.gsub!(/^(\s*);/) { $1 + ";".fg("2") } # Green for comment semicolons
968
+ # Handle semicolons as separators (they separate items on the same line)
969
+ # Semicolons are green like qualifiers
970
+ result = safe_regex_replace(result, /;/) { ";".fg("2") }
929
971
 
930
972
  # Handle references - color entire reference including brackets
931
973
  # Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
@@ -934,19 +976,13 @@ class HyperListApp
934
976
  # Handle special keywords SKIP and END
935
977
  result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
936
978
 
937
- # Handle quoted strings FIRST, but color ## sequences inside them red
979
+ # Handle quoted strings (only double quotes are special in HyperList)
938
980
  # Based on hyperlist.vim: '".\{-}"'
939
981
  result.gsub!(/"([^"]*)"/) do
940
982
  content = $1
941
983
  # Color any ## sequences inside the quotes as red
942
984
  content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
943
- '"'.fg("6") + content + '"'.fg("6") # Cyan for quotes
944
- end
945
- result.gsub!(/'([^']*)'/) do
946
- content = $1
947
- # Color any ## sequences inside the quotes as red
948
- content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
949
- "'".fg("6") + content + "'".fg("6") # Cyan for single quotes
985
+ '"'.fg("6") + content.fg("6") + '"'.fg("6") # Cyan for quoted strings
950
986
  end
951
987
 
952
988
  # Handle change markup - all double-hashes should be red
@@ -1108,40 +1144,135 @@ class HyperListApp
1108
1144
  false
1109
1145
  end
1110
1146
 
1111
- def toggle_fold
1112
- visible = get_visible_items
1113
- return if @current >= visible.length
1147
+ def indent_split_right(with_children = false)
1148
+ visible_items = get_visible_split_items
1149
+ return if @split_current >= visible_items.length
1114
1150
 
1115
- item = visible[@current]
1116
- real_idx = @items.index(item)
1151
+ item = visible_items[@split_current]
1152
+ real_idx = @split_items.index(item)
1153
+
1154
+ # Can only indent if there's a previous item at same or higher level
1155
+ if real_idx && real_idx > 0
1156
+ save_undo_state # Save state before modification
1157
+ original_level = item["level"]
1158
+ item["level"] += 1 # Since item is a reference, this updates the object in both arrays
1159
+
1160
+ # Indent children if requested
1161
+ if with_children
1162
+ ((real_idx + 1)...@split_items.length).each do |i|
1163
+ if @split_items[i]["level"] > original_level
1164
+ @split_items[i]["level"] += 1
1165
+ else
1166
+ break
1167
+ end
1168
+ end
1169
+ end
1170
+
1171
+ @modified = true
1172
+ @message = "Indented in split pane"
1173
+ end
1174
+ end
1175
+
1176
+ def indent_split_left(with_children = false)
1177
+ visible_items = get_visible_split_items
1178
+ return if @split_current >= visible_items.length
1179
+
1180
+ item = visible_items[@split_current]
1181
+ real_idx = @split_items.index(item)
1117
1182
 
1118
- if real_idx && has_children?(real_idx, @items)
1119
- @items[real_idx]["fold"] = !@items[real_idx]["fold"]
1120
- record_last_action(:toggle_fold, nil)
1183
+ if real_idx && item["level"] > 0
1184
+ save_undo_state # Save state before modification
1185
+ original_level = item["level"]
1186
+ item["level"] -= 1 # Since item is a reference, this updates the object in both arrays
1187
+
1188
+ # Unindent children if requested
1189
+ if with_children
1190
+ ((real_idx + 1)...@split_items.length).each do |i|
1191
+ if @split_items[i]["level"] > original_level
1192
+ @split_items[i]["level"] -= 1
1193
+ else
1194
+ break
1195
+ end
1196
+ end
1197
+ end
1198
+
1199
+ @modified = true
1200
+ @message = "Unindented in split pane"
1201
+ end
1202
+ end
1203
+
1204
+ def toggle_fold
1205
+ if @split_view && @active_pane == :split
1206
+ # Handle fold toggle in split pane
1207
+ visible_items = get_visible_split_items
1208
+ return if @split_current >= visible_items.length
1209
+
1210
+ item = visible_items[@split_current]
1211
+ real_idx = @split_items.index(item)
1212
+
1213
+ if real_idx && has_children_in_array?(real_idx, @split_items)
1214
+ item["fold"] = !item["fold"] # Since item is a reference, this updates the object in both arrays
1215
+ end
1216
+ else
1217
+ # Handle fold toggle in main pane
1218
+ visible = get_visible_items
1219
+ return if @current >= visible.length
1220
+
1221
+ item = visible[@current]
1222
+ real_idx = @items.index(item)
1223
+
1224
+ if real_idx && has_children?(real_idx, @items)
1225
+ @items[real_idx]["fold"] = !@items[real_idx]["fold"]
1226
+ record_last_action(:toggle_fold, nil)
1227
+ end
1121
1228
  end
1122
1229
  end
1123
1230
 
1124
1231
  def move_up
1125
- @current = [@current - 1, 0].max
1232
+ max_items = get_visible_items.length - 1
1233
+
1234
+ if @current == 0
1235
+ # Wrap around to last item
1236
+ @current = max_items
1237
+ else
1238
+ @current = [@current - 1, 0].max
1239
+ end
1240
+
1126
1241
  update_presentation_focus if @presentation_mode
1127
1242
  end
1128
1243
 
1129
1244
  def move_down
1130
1245
  max = get_visible_items.length - 1
1131
- @current = [@current + 1, max].min
1246
+
1247
+ if @current == max
1248
+ # Wrap around to first item
1249
+ @current = 0
1250
+ else
1251
+ @current = [@current + 1, max].min
1252
+ end
1253
+
1132
1254
  update_presentation_focus if @presentation_mode
1133
1255
  end
1134
1256
 
1135
1257
  def page_up
1136
- @current = [@current - (@main.h - 1), 0].max
1137
- @offset = [@offset - (@main.h - 1), 0].max
1138
- update_presentation_focus if @presentation_mode
1258
+ if @split_view && @active_pane == :split
1259
+ @split_current = [@split_current - (@split_pane.h - 1), 0].max
1260
+ else
1261
+ @current = [@current - (@main.h - 1), 0].max
1262
+ @offset = [@offset - (@main.h - 1), 0].max
1263
+ update_presentation_focus if @presentation_mode
1264
+ end
1139
1265
  end
1140
1266
 
1141
1267
  def page_down
1142
- max = get_visible_items.length - 1
1143
- @current = [@current + (@main.h - 1), max].min
1144
- update_presentation_focus if @presentation_mode
1268
+ if @split_view && @active_pane == :split
1269
+ max = get_visible_split_items.length - 1
1270
+ @split_current = [@split_current + (@split_pane.h - 1), max].min
1271
+ else
1272
+ max = get_visible_items.length - 1
1273
+ @current = [@current + (@main.h - 1), max].min
1274
+ update_presentation_focus if @presentation_mode
1275
+ end
1145
1276
  end
1146
1277
 
1147
1278
  def go_to_parent
@@ -2016,9 +2147,9 @@ class HyperListApp
2016
2147
  help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
2017
2148
  help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
2018
2149
  help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
2019
- help_lines << help_line("#{"y".fg("10")}/#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
2150
+ help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
2020
2151
  help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
2021
- help_lines << help_line("#{"r".fg("10")}, #{"C-R".fg("10")}", "Redo")
2152
+ help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
2022
2153
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
2023
2154
  help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
2024
2155
  help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
@@ -2026,13 +2157,12 @@ class HyperListApp
2026
2157
  help_lines << ""
2027
2158
  help_lines << "#{"FEATURES".fg("14")}"
2028
2159
  help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
2029
- help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"R".fg("10")}", "Go to reference")
2030
- help_lines << help_line("#{"F".fg("10")}", "Open file", "#{"N".fg("10")}", "Next = marker")
2031
- help_lines << help_line("#{"P".fg("10")}", "Presentation mode")
2160
+ help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
2161
+ help_lines << help_line("#{"R".fg("10")}", "Go to reference", "#{"F".fg("10")}", "Open file")
2162
+ help_lines << help_line("#{"N".fg("10")}", "Next = marker", "#{"P".fg("10")}", "Presentation mode")
2032
2163
  help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":template".fg("10")}", "Show templates")
2033
2164
  help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
2034
- help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"ww".fg("10")}", "Switch panes")
2035
- help_lines << help_line("#{"\\u".fg("10")}", "Toggle underline")
2165
+ help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"w".fg("10")}", "Switch panes")
2036
2166
  help_lines << ""
2037
2167
  help_lines << "#{"FILE OPERATIONS".fg("14")}"
2038
2168
  help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
@@ -2048,8 +2178,8 @@ class HyperListApp
2048
2178
  help_lines << help_line("#{"[ ]".fg("22")}", "Unchecked", "#{"[-]".fg("2")}", "Partial")
2049
2179
  help_lines << help_line("#{"[?]".fg("2")}", "Conditionals", "#{"AND:".fg("4")}", "Operators")
2050
2180
  help_lines << help_line("#{"Date:".fg("1")}", "Properties", "#{"<ref>".fg("5")}", "References")
2051
- help_lines << help_line("#{"(info)".fg("6")}", "Parentheses", "#{'"text"'.fg("14")}", "Quoted strings")
2052
- help_lines << help_line("#{"; comment".fg("6")}", "Comments", "#{"#tag".fg("184")}", "Hash tags")
2181
+ help_lines << help_line("#{"(comment)".fg("6")}", "Comments", "#{'"text"'.fg("14")}", "Quoted strings")
2182
+ help_lines << help_line("#{";".fg("2")}", "Separator", "#{"#tag".fg("184")}", "Hash tags")
2053
2183
 
2054
2184
  help = help_lines.join("\n")
2055
2185
 
@@ -2093,7 +2223,8 @@ class HyperListApp
2093
2223
  @current = 0
2094
2224
  @offset = 0
2095
2225
  when "END"
2096
- @current = @items.length - 1
2226
+ visible = get_visible_items
2227
+ @current = [visible.length - 1, 0].max
2097
2228
  else
2098
2229
  # Any other key returns to main view
2099
2230
  break
@@ -2274,13 +2405,14 @@ class HyperListApp
2274
2405
 
2275
2406
  #{"ADDITIVES".b}
2276
2407
 
2277
- #{"Comments".fg("6")} (in parentheses or after semicolon):
2408
+ #{"Comments".fg("6")} (in parentheses):
2278
2409
  (This is a comment) Not executed in transitions
2279
- ; Line comment Everything after ; is a comment
2410
+
2411
+ #{"Separators".fg("2")} (semicolon):
2412
+ Item 1; Item 2; Item 3 Multiple items on same line
2280
2413
 
2281
2414
  #{"Quotes".fg("14")} (in quotation marks):
2282
2415
  "Literal text" Not interpreted as HyperList
2283
- 'Also literal' Single quotes work too
2284
2416
 
2285
2417
  #{"Tags".fg("184")} (hashtags):
2286
2418
  #TODO #important Markers for categorization
@@ -2618,14 +2750,16 @@ class HyperListApp
2618
2750
  @current = 0
2619
2751
  @offset = 0
2620
2752
  when "END"
2621
- @current = @items.length - 1
2753
+ visible = get_visible_items
2754
+ @current = [visible.length - 1, 0].max
2622
2755
  when "g"
2623
2756
  if getchr == "g"
2624
2757
  @current = 0
2625
2758
  @offset = 0
2626
2759
  end
2627
2760
  when "G"
2628
- @current = @items.length - 1
2761
+ visible = get_visible_items
2762
+ @current = [visible.length - 1, 0].max
2629
2763
  end
2630
2764
  end
2631
2765
 
@@ -2710,7 +2844,7 @@ class HyperListApp
2710
2844
  when "autosave", "as"
2711
2845
  status = @auto_save_enabled ? "enabled" : "disabled"
2712
2846
  @message = "Auto-save is #{status} (interval: #{@auto_save_interval}s)"
2713
- when "template", "templates", "t"
2847
+ when "t"
2714
2848
  show_templates
2715
2849
  when "foldlevel"
2716
2850
  level = @footer.ask("Fold to level (0-9): ", "")
@@ -2883,22 +3017,53 @@ class HyperListApp
2883
3017
 
2884
3018
  password = ""
2885
3019
  loop do
2886
- c = getchr
2887
- case c
2888
- when "ENTER"
2889
- break
2890
- when "BACKSPACE", "C-h"
2891
- password.chop! unless password.empty?
2892
- when "ESC", "C-c"
2893
- return nil
2894
- else
2895
- if c.length == 1 && c.ord >= 32 && c.ord <= 126
2896
- password += c
3020
+ begin
3021
+ c = getchr
3022
+
3023
+ # Debug logging
3024
+ if ENV['DEBUG']
3025
+ File.open('/tmp/hyperlist_debug.log', 'a') do |f|
3026
+ f.puts "Password prompt key: #{c.inspect}"
3027
+ end
2897
3028
  end
3029
+
3030
+ case c
3031
+ when "ENTER", "\r", "\n"
3032
+ break
3033
+ when "BACKSPACE", "BACK", "C-h", "\x7F", "\b"
3034
+ password.chop! unless password.empty?
3035
+ when "ESC", "C-c"
3036
+ return nil
3037
+ when nil
3038
+ # Skip nil input
3039
+ next
3040
+ else
3041
+ if c && c.length == 1 && c.ord >= 32 && c.ord <= 126
3042
+ password += c
3043
+ end
3044
+ end
3045
+ @footer.text = prompt + "*" * password.length
3046
+ @footer.refresh
3047
+ rescue => e
3048
+ # If there's an error, return nil
3049
+ @message = "Password input error: #{e.message}"
3050
+ return nil
2898
3051
  end
2899
- @footer.text = prompt + "*" * password.length
2900
- @footer.refresh
2901
3052
  end
3053
+
3054
+ # Clear the password prompt
3055
+ @footer.text = ""
3056
+ @footer.refresh
3057
+
3058
+ # Flush any remaining input to avoid accidental quit
3059
+ begin
3060
+ while IO.select([$stdin], nil, nil, 0)
3061
+ $stdin.read_nonblock(1024)
3062
+ end
3063
+ rescue IO::WaitReadable, EOFError
3064
+ # No more input to flush
3065
+ end
3066
+
2902
3067
  password
2903
3068
  end
2904
3069
 
@@ -2994,40 +3159,44 @@ class HyperListApp
2994
3159
  def toggle_line_encryption
2995
3160
  return if @items.empty?
2996
3161
 
2997
- item = @items[@current]
2998
- current_text = item["text"]
2999
-
3000
- if current_text.start_with?("ENC:")
3001
- # Decrypt the line
3002
- decrypted = decrypt_string(current_text)
3003
- if decrypted
3004
- save_state
3005
- item["text"] = decrypted
3006
- @encrypted_lines.delete(@current)
3007
- @modified = true
3008
- @message = "Line decrypted"
3009
- else
3010
- @message = "Decryption failed"
3011
- end
3012
- else
3013
- # Encrypt the line
3014
- encrypted = encrypt_string(current_text)
3015
- if encrypted
3016
- save_state
3017
- item["text"] = encrypted
3018
- @encrypted_lines[@current] = true
3019
- @modified = true
3020
- @message = "Line encrypted"
3162
+ begin
3163
+ item = @items[@current]
3164
+ current_text = item["text"]
3165
+
3166
+ if current_text.start_with?("ENC:")
3167
+ # Decrypt the line
3168
+ decrypted = decrypt_string(current_text)
3169
+ if decrypted
3170
+ save_undo_state
3171
+ item["text"] = decrypted
3172
+ @encrypted_lines.delete(@current)
3173
+ @modified = true
3174
+ @message = "Line decrypted"
3175
+ else
3176
+ @message = "Decryption failed or cancelled"
3177
+ end
3021
3178
  else
3022
- @message = "Encryption cancelled"
3179
+ # Encrypt the line
3180
+ encrypted = encrypt_string(current_text)
3181
+ if encrypted
3182
+ save_undo_state
3183
+ item["text"] = encrypted
3184
+ @encrypted_lines[@current] = true
3185
+ @modified = true
3186
+ @message = "Line encrypted"
3187
+ else
3188
+ @message = "Encryption cancelled"
3189
+ end
3023
3190
  end
3191
+ rescue => e
3192
+ @message = "Encryption error: #{e.message}"
3024
3193
  end
3025
3194
  end
3026
3195
 
3027
3196
  def load_templates
3028
3197
  {
3029
3198
  "project" => [
3030
- {"text" => "Project: [Project Name]", "level" => 0},
3199
+ {"text" => "Project: =Project Name=", "level" => 0},
3031
3200
  {"text" => "[_] Define project scope", "level" => 1},
3032
3201
  {"text" => "[_] Identify stakeholders", "level" => 1},
3033
3202
  {"text" => "[_] Create timeline", "level" => 1},
@@ -3046,25 +3215,25 @@ class HyperListApp
3046
3215
  {"text" => "[_] Archive project materials", "level" => 2}
3047
3216
  ],
3048
3217
  "meeting" => [
3049
- {"text" => "Meeting: [Title]", "level" => 0},
3218
+ {"text" => "Meeting: =Title=", "level" => 0},
3050
3219
  {"text" => "Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", "level" => 1},
3051
- {"text" => "Location: [Conference Room/Online]", "level" => 1},
3220
+ {"text" => "Location: =Conference Room/Online=", "level" => 1},
3052
3221
  {"text" => "Attendees", "level" => 1},
3053
- {"text" => "[Name 1]", "level" => 2},
3054
- {"text" => "[Name 2]", "level" => 2},
3222
+ {"text" => "=Name 1=", "level" => 2},
3223
+ {"text" => "=Name 2=", "level" => 2},
3055
3224
  {"text" => "Agenda", "level" => 1},
3056
3225
  {"text" => "[_] Opening remarks", "level" => 2},
3057
3226
  {"text" => "[_] Review previous action items", "level" => 2},
3058
3227
  {"text" => "[_] Main topics", "level" => 2},
3059
- {"text" => "Topic 1: [Description]", "level" => 3},
3060
- {"text" => "Topic 2: [Description]", "level" => 3},
3228
+ {"text" => "Topic 1: =Description=", "level" => 3},
3229
+ {"text" => "Topic 2: =Description=", "level" => 3},
3061
3230
  {"text" => "[_] Q&A session", "level" => 2},
3062
3231
  {"text" => "[_] Next steps", "level" => 2},
3063
3232
  {"text" => "Action Items", "level" => 1},
3064
- {"text" => "[_] [Action 1] - Assigned to: [Name] - Due: [Date]", "level" => 2},
3065
- {"text" => "[_] [Action 2] - Assigned to: [Name] - Due: [Date]", "level" => 2},
3233
+ {"text" => "[_] =Action 1= - Assigned to: =Name= - Due: =Date=", "level" => 2},
3234
+ {"text" => "[_] =Action 2= - Assigned to: =Name= - Due: =Date=", "level" => 2},
3066
3235
  {"text" => "Notes", "level" => 1},
3067
- {"text" => "[Add meeting notes here]", "level" => 2}
3236
+ {"text" => "=Add meeting notes here=", "level" => 2}
3068
3237
  ],
3069
3238
  "daily" => [
3070
3239
  {"text" => "Daily Plan: #{Time.now.strftime('%Y-%m-%d')}", "level" => 0},
@@ -3073,19 +3242,19 @@ class HyperListApp
3073
3242
  {"text" => "[_] Check emails", "level" => 2},
3074
3243
  {"text" => "[_] Plan priorities", "level" => 2},
3075
3244
  {"text" => "Priority Tasks", "level" => 1},
3076
- {"text" => "[_] [High Priority Task 1]", "level" => 2},
3077
- {"text" => "[_] [High Priority Task 2]", "level" => 2},
3078
- {"text" => "[_] [High Priority Task 3]", "level" => 2},
3245
+ {"text" => "[_] =High Priority Task 1=", "level" => 2},
3246
+ {"text" => "[_] =High Priority Task 2=", "level" => 2},
3247
+ {"text" => "[_] =High Priority Task 3=", "level" => 2},
3079
3248
  {"text" => "Regular Tasks", "level" => 1},
3080
- {"text" => "[_] [Task 1]", "level" => 2},
3081
- {"text" => "[_] [Task 2]", "level" => 2},
3249
+ {"text" => "[_] =Task 1=", "level" => 2},
3250
+ {"text" => "[_] =Task 2=", "level" => 2},
3082
3251
  {"text" => "Meetings/Appointments", "level" => 1},
3083
- {"text" => "[Time] - [Meeting/Event]", "level" => 2},
3252
+ {"text" => "=Time= - =Meeting/Event=", "level" => 2},
3084
3253
  {"text" => "Notes", "level" => 1},
3085
- {"text" => "[Daily observations and reflections]", "level" => 2}
3254
+ {"text" => "=Daily observations and reflections=", "level" => 2}
3086
3255
  ],
3087
3256
  "checklist" => [
3088
- {"text" => "Checklist: [Title]", "level" => 0},
3257
+ {"text" => "Checklist: =Title=", "level" => 0},
3089
3258
  {"text" => "[_] Item 1", "level" => 1},
3090
3259
  {"text" => "[_] Item 2", "level" => 1},
3091
3260
  {"text" => "[_] Item 3", "level" => 1},
@@ -3093,9 +3262,9 @@ class HyperListApp
3093
3262
  {"text" => "[_] Item 5", "level" => 1}
3094
3263
  ],
3095
3264
  "brainstorm" => [
3096
- {"text" => "Brainstorming: [Topic]", "level" => 0},
3265
+ {"text" => "Brainstorming: =Topic=", "level" => 0},
3097
3266
  {"text" => "Problem Statement", "level" => 1},
3098
- {"text" => "[Define the problem or opportunity]", "level" => 2},
3267
+ {"text" => "=Define the problem or opportunity=", "level" => 2},
3099
3268
  {"text" => "Ideas", "level" => 1},
3100
3269
  {"text" => "Category 1", "level" => 2},
3101
3270
  {"text" => "Idea A", "level" => 3},
@@ -3112,37 +3281,35 @@ class HyperListApp
3112
3281
  {"text" => "[_] Create action plan", "level" => 2}
3113
3282
  ],
3114
3283
  "recipe" => [
3115
- {"text" => "Recipe: [Name]", "level" => 0},
3116
- {"text" => "Servings: [Number]", "level" => 1},
3117
- {"text" => "Prep Time: [Time]", "level" => 1},
3118
- {"text" => "Cook Time: [Time]", "level" => 1},
3284
+ {"text" => "Recipe: =Name=", "level" => 0},
3285
+ {"text" => "Servings: =Number=", "level" => 1},
3286
+ {"text" => "Prep Time: =Time=", "level" => 1},
3287
+ {"text" => "Cook Time: =Time=", "level" => 1},
3119
3288
  {"text" => "Ingredients", "level" => 1},
3120
- {"text" => "[Amount] [Ingredient 1]", "level" => 2},
3121
- {"text" => "[Amount] [Ingredient 2]", "level" => 2},
3122
- {"text" => "[Amount] [Ingredient 3]", "level" => 2},
3289
+ {"text" => "=Amount= =Ingredient 1=", "level" => 2},
3290
+ {"text" => "=Amount= =Ingredient 2=", "level" => 2},
3291
+ {"text" => "=Amount= =Ingredient 3=", "level" => 2},
3123
3292
  {"text" => "Instructions", "level" => 1},
3124
- {"text" => "[_] Step 1: [Description]", "level" => 2},
3125
- {"text" => "[_] Step 2: [Description]", "level" => 2},
3126
- {"text" => "[_] Step 3: [Description]", "level" => 2},
3293
+ {"text" => "[_] Step 1: =Description=", "level" => 2},
3294
+ {"text" => "[_] Step 2: =Description=", "level" => 2},
3295
+ {"text" => "[_] Step 3: =Description=", "level" => 2},
3127
3296
  {"text" => "Notes", "level" => 1},
3128
- {"text" => "[Tips, variations, serving suggestions]", "level" => 2}
3297
+ {"text" => "=Tips, variations, serving suggestions=", "level" => 2}
3129
3298
  ]
3130
3299
  }
3131
3300
  end
3132
3301
 
3133
3302
  def show_templates
3134
- # Save current state
3135
- saved_items = @items.dup
3136
- saved_current = @current
3137
- saved_offset = @offset
3138
- saved_filename = @filename
3139
- saved_modified = @modified
3140
-
3141
- # Create items for template selection
3142
- @items = []
3143
- @items << {"text" => "TEMPLATES (press Enter to insert, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
3144
- @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
3303
+ # Store original state in an array to ensure proper restoration
3304
+ original_state = {
3305
+ items: @items.dup,
3306
+ current: @current,
3307
+ offset: @offset,
3308
+ filename: @filename,
3309
+ modified: @modified
3310
+ }
3145
3311
 
3312
+ # Create template selection view
3146
3313
  template_list = [
3147
3314
  ["project", "Project Plan - Complete project management template"],
3148
3315
  ["meeting", "Meeting Agenda - Structure for meeting notes"],
@@ -3152,61 +3319,102 @@ class HyperListApp
3152
3319
  ["recipe", "Recipe - Cooking recipe structure"]
3153
3320
  ]
3154
3321
 
3322
+ # Build template selection items
3323
+ @items = []
3324
+ @items << {"text" => "TEMPLATES (press Enter to insert, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
3325
+ @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
3326
+
3155
3327
  template_list.each_with_index do |(key, desc), idx|
3156
- @items << {"text" => "#{idx+1}. #{key.capitalize}: #{desc}", "level" => 0, "fold" => false, "raw" => true, "template_key" => key}
3328
+ @items << {
3329
+ "text" => "#{idx+1}. #{key.capitalize}: #{desc}",
3330
+ "level" => 0,
3331
+ "fold" => false,
3332
+ "raw" => true,
3333
+ "template_key" => key
3334
+ }
3157
3335
  end
3158
3336
 
3159
3337
  @current = 2 # Start at first template
3160
3338
  @offset = 0
3161
- @modified = false
3162
3339
 
3163
3340
  selected_template = nil
3341
+ exit_loop = false
3164
3342
 
3165
- # Template viewer loop
3166
- loop do
3167
- render_main
3168
- @footer.text = "Templates | Enter: insert | q: cancel | j/k: navigate"
3169
- @footer.refresh
3170
-
3171
- c = getchr
3172
- case c
3173
- when "q", "ESC"
3174
- # Restore original state
3175
- @items = saved_items
3176
- @current = saved_current
3177
- @offset = saved_offset
3178
- @filename = saved_filename
3179
- @modified = saved_modified
3180
- break
3181
- when "j", "DOWN"
3182
- move_down if @current < @items.length - 1
3183
- when "k", "UP"
3184
- move_up if @current > 2 # Don't go above first template
3185
- when "ENTER", "l"
3186
- if @current >= 2 && @items[@current]["template_key"]
3187
- selected_template = @items[@current]["template_key"]
3188
- break
3189
- end
3190
- when /^[1-6]$/
3191
- # Allow number key selection
3192
- idx = c.to_i - 1
3193
- if idx < template_list.length
3194
- selected_template = template_list[idx][0]
3195
- break
3343
+ # Template selection loop
3344
+ while !exit_loop
3345
+ begin
3346
+ render_main
3347
+ @footer.text = "Templates | Enter: insert | q: cancel | j/k: navigate"
3348
+ @footer.refresh
3349
+
3350
+ c = getchr
3351
+
3352
+ # Skip truly nil input
3353
+ next if c.nil?
3354
+
3355
+ # Debug logging to file
3356
+ File.open('/tmp/hyperlist_debug.log', 'a') do |f|
3357
+ f.puts "Template key received: #{c.inspect} (length: #{c.length}, ord: #{c.bytes.inspect})"
3358
+ end if ENV['DEBUG']
3359
+
3360
+ case c
3361
+ when "q", "ESC", "C-c", "Q"
3362
+ exit_loop = true
3363
+ when "j", "DOWN"
3364
+ @current = [@current + 1, @items.length - 1].min
3365
+ when "k", "UP"
3366
+ @current = [@current - 1, 2].max
3367
+ when "ENTER", "RETURN", "\n", "\r", "l"
3368
+ if @current >= 2 && @current < @items.length
3369
+ item = @items[@current]
3370
+ if item && item.is_a?(Hash) && item["template_key"]
3371
+ selected_template = item["template_key"]
3372
+ exit_loop = true
3373
+ end
3374
+ end
3375
+ when /^[1-6]$/
3376
+ idx = c.to_i - 1
3377
+ if idx >= 0 && idx < template_list.length
3378
+ selected_template = template_list[idx][0]
3379
+ exit_loop = true
3380
+ end
3196
3381
  end
3382
+
3383
+ rescue => e
3384
+ # Log error but continue
3385
+ @message = "Error in template loop: #{e.message}"
3386
+ exit_loop = true
3197
3387
  end
3198
3388
  end
3199
3389
 
3200
- # Insert selected template
3201
- if selected_template
3202
- # Restore original state first
3203
- @items = saved_items
3204
- @current = saved_current
3205
- @offset = saved_offset
3206
- @filename = saved_filename
3207
- @modified = saved_modified
3208
-
3209
- insert_template(selected_template)
3390
+ # Restore original state
3391
+ @items = original_state[:items]
3392
+ @current = original_state[:current]
3393
+ @offset = original_state[:offset]
3394
+ @filename = original_state[:filename]
3395
+ @modified = original_state[:modified]
3396
+
3397
+ # Flush any remaining input to avoid accidental actions
3398
+ begin
3399
+ while IO.select([$stdin], nil, nil, 0)
3400
+ $stdin.read_nonblock(1024)
3401
+ end
3402
+ rescue IO::WaitReadable, EOFError
3403
+ # No more input to flush
3404
+ end
3405
+
3406
+ # Insert template if one was selected
3407
+ if selected_template && !selected_template.empty?
3408
+ begin
3409
+ template_items = @templates[selected_template]
3410
+ if template_items && template_items.is_a?(Array) && !template_items.empty?
3411
+ insert_template(selected_template)
3412
+ else
3413
+ @message = "Template '#{selected_template}' not found or empty"
3414
+ end
3415
+ rescue => e
3416
+ @message = "Error inserting template: #{e.message}"
3417
+ end
3210
3418
  end
3211
3419
  end
3212
3420
 
@@ -3214,10 +3422,15 @@ class HyperListApp
3214
3422
  template_items = @templates[template_key]
3215
3423
  return unless template_items
3216
3424
 
3217
- save_state_for_undo
3425
+ # Ensure we have valid items and current position
3426
+ return if @items.nil? || @items.empty?
3427
+ return if @current.nil? || @current < 0 || @current >= @items.length
3428
+ return unless @items[@current]
3429
+
3430
+ save_undo_state
3218
3431
 
3219
3432
  # Get current item level to adjust template indentation
3220
- current_level = @items[@current]["level"]
3433
+ current_level = @items[@current]["level"] || 0
3221
3434
 
3222
3435
  # Insert template items after current position
3223
3436
  insertion_point = @current + 1
@@ -3317,13 +3530,29 @@ class HyperListApp
3317
3530
  when "V"
3318
3531
  toggle_checkbox_with_date
3319
3532
  when "TAB"
3320
- indent_with_children
3533
+ if @split_view && @active_pane == :split
3534
+ indent_split_right(true) # with children
3535
+ else
3536
+ indent_right(true) # with children
3537
+ end
3321
3538
  when "S-TAB"
3322
- unindent_with_children
3539
+ if @split_view && @active_pane == :split
3540
+ indent_split_left(true) # with children
3541
+ else
3542
+ indent_left(true) # with children
3543
+ end
3323
3544
  when "RIGHT"
3324
- indent_line
3545
+ if @split_view && @active_pane == :split
3546
+ indent_split_right(false)
3547
+ else
3548
+ indent_right(false)
3549
+ end
3325
3550
  when "LEFT"
3326
- unindent_line
3551
+ if @split_view && @active_pane == :split
3552
+ indent_split_left(false)
3553
+ else
3554
+ indent_left(false)
3555
+ end
3327
3556
  when " "
3328
3557
  toggle_fold
3329
3558
  when "u"
@@ -3340,17 +3569,29 @@ class HyperListApp
3340
3569
  def toggle_split_view
3341
3570
  @split_view = !@split_view
3342
3571
  if @split_view
3343
- @split_items = @items.dup # Copy current items to split view
3572
+ # Use the actual items array for full document split view
3573
+ # This ensures changes in either pane affect both views
3574
+ @split_items = @items
3344
3575
  @split_current = @current
3345
3576
  @split_offset = @offset
3346
3577
  @active_pane = :main
3347
- set_message("Split view enabled. Use 'ww' to switch panes.")
3578
+ set_message("Split view enabled. Use Ctrl-w w to switch panes.")
3348
3579
  else
3349
3580
  @split_items = []
3350
3581
  set_message("Split view disabled")
3351
3582
  end
3583
+
3584
+ # Clear cached content to force re-render
3585
+ @last_rendered_content = ""
3586
+ @processed_cache.clear
3587
+
3588
+ # Recreate UI
3352
3589
  setup_ui
3353
- render # Re-render everything after changing the UI layout
3590
+
3591
+ # Force complete re-render
3592
+ render_main
3593
+ render_split_pane if @split_view
3594
+ render_footer
3354
3595
  end
3355
3596
 
3356
3597
  def copy_section_to_split
@@ -3382,7 +3623,8 @@ class HyperListApp
3382
3623
  end_idx = idx
3383
3624
  end
3384
3625
 
3385
- # Copy section to split view
3626
+ # Copy section to split view - use references, not copies
3627
+ # The slice creates a new array but contains references to the same item objects
3386
3628
  @split_items = @items[start_idx..end_idx]
3387
3629
  @split_current = 0
3388
3630
  @split_offset = 0
@@ -3398,6 +3640,10 @@ class HyperListApp
3398
3640
  if @split_view
3399
3641
  @active_pane = (@active_pane == :main) ? :split : :main
3400
3642
  @message = "Switched to #{@active_pane} pane"
3643
+ # Force re-render both panes to update highlighting
3644
+ render_main
3645
+ render_split_pane
3646
+ render_footer
3401
3647
  end
3402
3648
  when "v"
3403
3649
  # Vertical split
@@ -3413,48 +3659,111 @@ class HyperListApp
3413
3659
  end
3414
3660
  end
3415
3661
 
3662
+ def get_visible_split_items
3663
+ return [] unless @split_items
3664
+
3665
+ visible = []
3666
+ skip_until_level = nil
3667
+
3668
+ @split_items.each do |item|
3669
+ # Skip items that are folded
3670
+ if skip_until_level
3671
+ if item["level"] > skip_until_level
3672
+ next
3673
+ else
3674
+ skip_until_level = nil
3675
+ end
3676
+ end
3677
+
3678
+ visible << item
3679
+
3680
+ # Check if this item is folded and has children
3681
+ if item["fold"] && has_children_in_array?(visible.length - 1, @split_items)
3682
+ skip_until_level = item["level"]
3683
+ end
3684
+ end
3685
+
3686
+ visible
3687
+ end
3688
+
3416
3689
  def render_split_pane
3417
3690
  return unless @split_view && @split_pane
3418
3691
 
3419
- # Similar to render_main but for split pane
3420
- view_height = @split_pane.h
3421
- if @split_current < @split_offset
3422
- @split_offset = @split_current
3423
- elsif @split_current >= @split_offset + view_height
3424
- @split_offset = @split_current - view_height + 1
3425
- end
3692
+ # Get visible items respecting fold state
3693
+ visible_items = get_visible_split_items
3426
3694
 
3695
+ # Build ALL lines for the pane (like we do for main pane)
3427
3696
  lines = []
3428
- start_idx = @split_offset
3429
- end_idx = [@split_offset + view_height, @split_items.length].min
3430
-
3431
- (start_idx...end_idx).each do |idx|
3432
- item = @split_items[idx]
3697
+ visible_items.each_with_index do |item, idx|
3433
3698
  next unless item
3434
3699
 
3435
3700
  line = " " * item["level"]
3436
3701
 
3437
- # Add fold indicator
3438
- if has_children_in_array?(idx, @split_items) && item["fold"]
3439
- line += "▶ ".fg("245")
3440
- elsif has_children_in_array?(idx, @split_items)
3441
- line += "".fg("245")
3702
+ # Add fold indicator with colors
3703
+ # Find the item's position in the original split_items array
3704
+ real_idx = @split_items.index(item)
3705
+ if real_idx && has_children_in_array?(real_idx, @split_items)
3706
+ if item["fold"]
3707
+ line += "▶".fg("245") + " "
3708
+ else
3709
+ line += "▷".fg("245") + " "
3710
+ end
3442
3711
  else
3443
3712
  line += " "
3444
3713
  end
3445
3714
 
3446
- # Process text (simplified for split view)
3447
- line += process_text(item["text"], false)
3715
+ # Apply process_text for syntax highlighting
3716
+ processed = process_text(item["text"], false)
3717
+ line += processed
3448
3718
 
3449
- # Highlight current line in split pane
3719
+ # Apply background highlighting for current item in split pane
3450
3720
  if idx == @split_current
3451
- line = line.bg("237") # Dark gray background to preserve foreground colors
3721
+ # Choose background color based on whether this is the active pane
3722
+ bg_color = @active_pane == :split ? "237" : "234"
3723
+ bg_code = "\e[48;5;#{bg_color}m"
3724
+ reset_bg = "\e[49m"
3725
+ line = bg_code + line.gsub(/\e\[49m/, '') + reset_bg
3452
3726
  end
3453
3727
 
3454
3728
  lines << line
3455
3729
  end
3456
3730
 
3731
+ # Add a blank line at the bottom to show end of document
3732
+ lines << ""
3733
+
3734
+ # Set the full content to the pane and let rcurses handle scrolling
3457
3735
  @split_pane.text = lines.join("\n")
3736
+
3737
+ # Calculate how many extra lines are created by wrapping
3738
+ extra_wrapped_lines = 0
3739
+ lines[0..-2].each do |line| # Exclude the blank line we added
3740
+ # Remove ANSI codes for length calculation
3741
+ clean_line = line.gsub(/\e\[[0-9;]*m/, '')
3742
+ if clean_line.length > @split_pane.w
3743
+ # This line will wrap - calculate how many extra lines it creates
3744
+ extra_lines = (clean_line.length.to_f / @split_pane.w).ceil - 1
3745
+ extra_wrapped_lines += extra_lines
3746
+ end
3747
+ end
3748
+
3749
+ # Calculate scroll position exactly like RTFM does, but account for wrapping
3750
+ scrolloff = 3
3751
+ total = visible_items.length + 1 # +1 for the blank line
3752
+ page = @split_pane.h
3753
+
3754
+ if total <= page
3755
+ # If everything fits, always start from the very top
3756
+ @split_pane.ix = 0
3757
+ elsif @split_current - @split_pane.ix < scrolloff
3758
+ # If we're too close to the top of the pane, scroll up
3759
+ @split_pane.ix = [@split_current - scrolloff, 0].max
3760
+ elsif (@split_pane.ix + page - 1 - @split_current) < scrolloff
3761
+ # If we're too close to the bottom of the pane, scroll down
3762
+ # Account for wrapped lines dynamically
3763
+ max_off = [total - page + extra_wrapped_lines, 0].max
3764
+ @split_pane.ix = [@split_current + scrolloff - page + 1, max_off].min
3765
+ end
3766
+
3458
3767
  @split_pane.refresh
3459
3768
  end
3460
3769
 
@@ -3467,11 +3776,22 @@ class HyperListApp
3467
3776
 
3468
3777
  def move_in_active_pane(direction)
3469
3778
  if @split_view && @active_pane == :split
3779
+ visible = get_visible_split_items
3470
3780
  case direction
3471
3781
  when :down
3472
- @split_current = [@split_current + 1, @split_items.length - 1].min if @split_current < @split_items.length - 1
3782
+ if @split_current >= visible.length - 1
3783
+ # Wrap to top
3784
+ @split_current = 0
3785
+ else
3786
+ @split_current = @split_current + 1
3787
+ end
3473
3788
  when :up
3474
- @split_current = [@split_current - 1, 0].max if @split_current > 0
3789
+ if @split_current <= 0
3790
+ # Wrap to bottom
3791
+ @split_current = visible.length - 1
3792
+ else
3793
+ @split_current = @split_current - 1
3794
+ end
3475
3795
  end
3476
3796
  else
3477
3797
  case direction
@@ -3957,28 +4277,57 @@ class HyperListApp
3957
4277
  go_to_first_child
3958
4278
  when "LEFT"
3959
4279
  # Unindent only the current item
3960
- indent_left(false)
4280
+ if @split_view && @active_pane == :split
4281
+ indent_split_left(false)
4282
+ else
4283
+ indent_left(false)
4284
+ end
3961
4285
  when "RIGHT"
3962
4286
  # Indent only the current item
3963
- indent_right(false)
4287
+ if @split_view && @active_pane == :split
4288
+ indent_split_right(false)
4289
+ else
4290
+ indent_right(false)
4291
+ end
3964
4292
  when "PgUP" # Page Up
3965
4293
  page_up
3966
4294
  when "PgDOWN" # Page Down
3967
4295
  page_down
3968
4296
  when "HOME" # Home
3969
- @current = 0
3970
- @offset = 0
3971
- update_presentation_focus if @presentation_mode
4297
+ if @split_view && @active_pane == :split
4298
+ @split_current = 0
4299
+ else
4300
+ @current = 0
4301
+ @offset = 0
4302
+ update_presentation_focus if @presentation_mode
4303
+ end
3972
4304
  when "END" # End
3973
- @current = get_visible_items.length - 1
3974
- update_presentation_focus if @presentation_mode
4305
+ if @split_view && @active_pane == :split
4306
+ visible = get_visible_split_items
4307
+ @split_current = [visible.length - 1, 0].max
4308
+ else
4309
+ visible = get_visible_items
4310
+ @current = [visible.length - 1, 0].max
4311
+ update_presentation_focus if @presentation_mode
4312
+ end
3975
4313
  when "g" # Go to top (was gg)
3976
- @current = 0
3977
- @offset = 0
3978
- update_presentation_focus if @presentation_mode
4314
+ if @split_view && @active_pane == :split
4315
+ @split_current = 0
4316
+ else
4317
+ @current = 0
4318
+ @offset = 0
4319
+ update_presentation_focus if @presentation_mode
4320
+ end
3979
4321
  when "G" # Go to bottom
3980
- @current = get_visible_items.length - 1
3981
- update_presentation_focus if @presentation_mode
4322
+ if @split_view && @active_pane == :split
4323
+ visible = get_visible_split_items
4324
+ @split_current = [visible.length - 1, 0].max
4325
+ else
4326
+ visible = get_visible_items
4327
+ @current = [visible.length - 1, 0].max
4328
+ # Let normal offset logic position the last item
4329
+ update_presentation_focus if @presentation_mode
4330
+ end
3982
4331
  when "R" # Jump to reference (was gr)
3983
4332
  jump_to_reference
3984
4333
  when "F" # Open file reference (was gf)
@@ -4038,6 +4387,8 @@ class HyperListApp
4038
4387
  delete_line(false) # D always deletes with children by default
4039
4388
  when "C-D" # Delete line and all descendants explicitly
4040
4389
  delete_line(true)
4390
+ when "C-E" # Toggle line encryption
4391
+ toggle_line_encryption
4041
4392
  when "y" # Yank/copy single line
4042
4393
  yank_line(false)
4043
4394
  when "Y" # Yank/copy line with all descendants
@@ -4056,10 +4407,18 @@ class HyperListApp
4056
4407
  move_item_up(true)
4057
4408
  when "TAB"
4058
4409
  # Indent with all children
4059
- indent_right(true)
4410
+ if @split_view && @active_pane == :split
4411
+ indent_split_right(true)
4412
+ else
4413
+ indent_right(true)
4414
+ end
4060
4415
  when "S-TAB" # Shift-Tab
4061
4416
  # Unindent with all children
4062
- indent_left(true)
4417
+ if @split_view && @active_pane == :split
4418
+ indent_split_left(true)
4419
+ else
4420
+ indent_left(true)
4421
+ end
4063
4422
  when "u"
4064
4423
  undo
4065
4424
  when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
@@ -4068,8 +4427,6 @@ class HyperListApp
4068
4427
  toggle_checkbox
4069
4428
  when "V"
4070
4429
  toggle_checkbox_with_date
4071
- when "\x05" # Ctrl-E for encryption toggle
4072
- toggle_line_encryption
4073
4430
  when "."
4074
4431
  repeat_last_action
4075
4432
  when "/"
@@ -4080,11 +4437,34 @@ class HyperListApp
4080
4437
  jump_to_next_template_marker
4081
4438
  when "P"
4082
4439
  toggle_presentation_mode
4440
+ when "\x15" # Ctrl-U for State/Transition underline toggle
4441
+ # Cycle through underline modes: 0 (none) -> 1 (states) -> 2 (transitions) -> 0
4442
+ @st_underline_mode = (@st_underline_mode + 1) % 3
4443
+ # Clear cache to force re-rendering
4444
+ @processed_cache.clear
4445
+ case @st_underline_mode
4446
+ when 0
4447
+ @message = "Underline mode: OFF"
4448
+ when 1
4449
+ @message = "Underline mode: STATES (S: and |)"
4450
+ when 2
4451
+ @message = "Underline mode: TRANSITIONS (T: and /)"
4452
+ end
4083
4453
  when "\\"
4084
4454
  next_c = getchr
4085
4455
  case next_c
4086
4456
  when "u"
4087
- @message = "Underline mode toggled"
4457
+ # Alternative for Ctrl-U (backslash-u)
4458
+ @st_underline_mode = (@st_underline_mode + 1) % 3
4459
+ @processed_cache.clear
4460
+ case @st_underline_mode
4461
+ when 0
4462
+ @message = "Underline mode: OFF"
4463
+ when 1
4464
+ @message = "Underline mode: STATES (S: and |)"
4465
+ when 2
4466
+ @message = "Underline mode: TRANSITIONS (T: and /)"
4467
+ end
4088
4468
  end
4089
4469
  when ":"
4090
4470
  handle_command
@@ -4128,15 +4508,16 @@ class HyperListApp
4128
4508
  next_c = getchr
4129
4509
  play_macro(next_c) if next_c && next_c =~ /[a-z]/
4130
4510
  when "w"
4131
- # Check if it's a window command (ww for window switch)
4132
- next_c = getchr
4133
- if next_c == "w" && @split_view
4134
- # Switch active pane
4511
+ # Switch active pane if split view is active
4512
+ if @split_view
4135
4513
  @active_pane = (@active_pane == :main) ? :split : :main
4136
4514
  @message = "Switched to #{@active_pane} pane"
4515
+ # Force re-render both panes to update highlighting
4516
+ render_main
4517
+ render_split_pane
4518
+ render_footer
4137
4519
  else
4138
- # Not a window command, ignore for now
4139
- @message = "Unknown command: w#{next_c}"
4520
+ @message = "Split view not active. Use :vs to enable"
4140
4521
  end
4141
4522
  when "Q" # Force quit
4142
4523
  quit