hyperlist 1.1.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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/hyperlist +756 -374
  4. data/hyperlist.gemspec +1 -1
  5. data/test.hl +4 -19
  6. metadata +1 -1
data/hyperlist CHANGED
@@ -59,7 +59,8 @@ end
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'
64
65
  require 'openssl'
65
66
  require 'digest'
@@ -70,7 +71,7 @@ class HyperListApp
70
71
  include Rcurses::Input
71
72
  include Rcurses::Cursor
72
73
 
73
- VERSION = "1.1.0"
74
+ VERSION = "1.1.1"
74
75
 
75
76
  def initialize(filename = nil)
76
77
  @filename = filename ? File.expand_path(filename) : nil
@@ -105,6 +106,7 @@ class HyperListApp
105
106
  @macro_key = nil # Key for current macro
106
107
  @encryption_key = nil # Store derived encryption key
107
108
  @encrypted_lines = {} # Track which lines are encrypted
109
+ @st_underline_mode = 0 # 0: none, 1: underline states, 2: underline transitions
108
110
  @split_view = false
109
111
  @split_items = [] # Second view items
110
112
  @split_current = 0 # Second view cursor
@@ -149,22 +151,29 @@ class HyperListApp
149
151
  if @split_view
150
152
  # Split view layout - no header, more space for content
151
153
  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)
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)
155
158
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
156
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
159
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
157
160
 
158
- # Add separator
159
- @separator = Pane.new(split_width, 0, 1, @rows - 1, 15, 8)
160
- @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")
161
169
  @separator.refresh
162
170
  else
163
171
  # 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)
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)
166
175
  # Footer: Use @rows for y-position (this puts it at the actual bottom)
167
- @footer = Pane.new(0, @rows, @cols, 1, 15, 8)
176
+ @footer = Pane.new(1, @rows, @cols, 1, 15, 8)
168
177
  end
169
178
  end
170
179
 
@@ -558,38 +567,28 @@ class HyperListApp
558
567
  level = item["level"]
559
568
  indent = " " * level
560
569
 
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
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
593
592
 
594
593
  # Checkboxes at start
595
594
  text = text.sub(/^\[X\]/i, "\u0001CBCHECKED\u0002")
@@ -663,7 +662,6 @@ class HyperListApp
663
662
  .gsub("\u0001UNDEREND\u0002", '</u>')
664
663
 
665
664
  html += indent + text + "\n"
666
- end
667
665
  end
668
666
 
669
667
  html += "</body>\n</html>"
@@ -683,133 +681,129 @@ class HyperListApp
683
681
  render_main
684
682
  render_split_pane if @split_view
685
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
686
693
  end
687
694
 
688
695
 
689
696
  def render_main
690
697
  visible_items = get_visible_items
691
698
 
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
699
+ # Build ALL lines for the pane (like RTFM/IMDB do)
700
+ lines = []
701
701
  in_literal_block = false
702
702
  literal_start_level = -1
703
703
 
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]
704
+ visible_items.each_with_index do |item, idx|
718
705
  next unless item
719
706
 
720
- # Create cache key for this item
721
- cache_key = "#{item['text']}_#{item['level']}_#{item['fold']}"
707
+ line = " " * item["level"] # 4 spaces per level
722
708
 
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]
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) + " "
726
717
  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)
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")
739
733
  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
734
  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
735
  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
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")
743
+ else
744
+ line += process_text(item["text"], has_match)
795
745
  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
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
800
755
  end
801
-
802
- 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
764
+ end
765
+ end
766
+
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
803
784
  end
804
785
  end
805
786
 
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
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
812
804
  end
805
+
806
+ @main.refresh
813
807
  end
814
808
 
815
809
  def process_text(text, highlight_search = false)
@@ -822,6 +816,47 @@ class HyperListApp
822
816
  return result
823
817
  end
824
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
+
825
860
  # Check if this is an encrypted line
826
861
  if result.start_with?("ENC:")
827
862
  # Show encrypted indicator instead of the encrypted data
@@ -906,7 +941,7 @@ class HyperListApp
906
941
 
907
942
  # Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
908
943
  if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
909
- 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:)
910
945
  elsif text_part.length >= 2 && space_after.include?(" ")
911
946
  # It's a property (mixed case, at least 2 chars, has space after colon)
912
947
  prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
@@ -916,16 +951,24 @@ class HyperListApp
916
951
  end
917
952
  end
918
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
+
919
959
  # Handle OR: at the beginning of a line (with optional spaces)
920
960
  result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
921
961
 
922
962
  # Handle parentheses content (moved here to avoid conflicts with properties)
923
963
  # Based on hyperlist.vim: '(.\{-})'
924
- 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
925
968
 
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
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") }
929
972
 
930
973
  # Handle references - color entire reference including brackets
931
974
  # Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
@@ -934,19 +977,13 @@ class HyperListApp
934
977
  # Handle special keywords SKIP and END
935
978
  result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
936
979
 
937
- # Handle quoted strings FIRST, but color ## sequences inside them red
980
+ # Handle quoted strings (only double quotes are special in HyperList)
938
981
  # Based on hyperlist.vim: '".\{-}"'
939
982
  result.gsub!(/"([^"]*)"/) do
940
983
  content = $1
941
984
  # Color any ## sequences inside the quotes as red
942
985
  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
986
+ '"'.fg("6") + content.fg("6") + '"'.fg("6") # Cyan for quoted strings
950
987
  end
951
988
 
952
989
  # Handle change markup - all double-hashes should be red
@@ -1108,40 +1145,135 @@ class HyperListApp
1108
1145
  false
1109
1146
  end
1110
1147
 
1111
- def toggle_fold
1112
- visible = get_visible_items
1113
- 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
1114
1151
 
1115
- item = visible[@current]
1116
- 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
1180
+
1181
+ item = visible_items[@split_current]
1182
+ real_idx = @split_items.index(item)
1117
1183
 
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)
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
1121
1229
  end
1122
1230
  end
1123
1231
 
1124
1232
  def move_up
1125
- @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
+
1126
1242
  update_presentation_focus if @presentation_mode
1127
1243
  end
1128
1244
 
1129
1245
  def move_down
1130
1246
  max = get_visible_items.length - 1
1131
- @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
+
1132
1255
  update_presentation_focus if @presentation_mode
1133
1256
  end
1134
1257
 
1135
1258
  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
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
1139
1266
  end
1140
1267
 
1141
1268
  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
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
1145
1277
  end
1146
1278
 
1147
1279
  def go_to_parent
@@ -2016,9 +2148,9 @@ class HyperListApp
2016
2148
  help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
2017
2149
  help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
2018
2150
  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")
2151
+ help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
2020
2152
  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")
2153
+ help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
2022
2154
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
2023
2155
  help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
2024
2156
  help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
@@ -2026,13 +2158,12 @@ class HyperListApp
2026
2158
  help_lines << ""
2027
2159
  help_lines << "#{"FEATURES".fg("14")}"
2028
2160
  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")
2161
+ help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
2162
+ help_lines << help_line("#{"R".fg("10")}", "Go to reference", "#{"F".fg("10")}", "Open file")
2163
+ help_lines << help_line("#{"N".fg("10")}", "Next = marker", "#{"P".fg("10")}", "Presentation mode")
2032
2164
  help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":template".fg("10")}", "Show templates")
2033
2165
  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")
2166
+ help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"w".fg("10")}", "Switch panes")
2036
2167
  help_lines << ""
2037
2168
  help_lines << "#{"FILE OPERATIONS".fg("14")}"
2038
2169
  help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
@@ -2048,8 +2179,8 @@ class HyperListApp
2048
2179
  help_lines << help_line("#{"[ ]".fg("22")}", "Unchecked", "#{"[-]".fg("2")}", "Partial")
2049
2180
  help_lines << help_line("#{"[?]".fg("2")}", "Conditionals", "#{"AND:".fg("4")}", "Operators")
2050
2181
  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")
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")
2053
2184
 
2054
2185
  help = help_lines.join("\n")
2055
2186
 
@@ -2093,7 +2224,8 @@ class HyperListApp
2093
2224
  @current = 0
2094
2225
  @offset = 0
2095
2226
  when "END"
2096
- @current = @items.length - 1
2227
+ visible = get_visible_items
2228
+ @current = [visible.length - 1, 0].max
2097
2229
  else
2098
2230
  # Any other key returns to main view
2099
2231
  break
@@ -2274,13 +2406,14 @@ class HyperListApp
2274
2406
 
2275
2407
  #{"ADDITIVES".b}
2276
2408
 
2277
- #{"Comments".fg("6")} (in parentheses or after semicolon):
2409
+ #{"Comments".fg("6")} (in parentheses):
2278
2410
  (This is a comment) Not executed in transitions
2279
- ; 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
2280
2414
 
2281
2415
  #{"Quotes".fg("14")} (in quotation marks):
2282
2416
  "Literal text" Not interpreted as HyperList
2283
- 'Also literal' Single quotes work too
2284
2417
 
2285
2418
  #{"Tags".fg("184")} (hashtags):
2286
2419
  #TODO #important Markers for categorization
@@ -2618,14 +2751,16 @@ class HyperListApp
2618
2751
  @current = 0
2619
2752
  @offset = 0
2620
2753
  when "END"
2621
- @current = @items.length - 1
2754
+ visible = get_visible_items
2755
+ @current = [visible.length - 1, 0].max
2622
2756
  when "g"
2623
2757
  if getchr == "g"
2624
2758
  @current = 0
2625
2759
  @offset = 0
2626
2760
  end
2627
2761
  when "G"
2628
- @current = @items.length - 1
2762
+ visible = get_visible_items
2763
+ @current = [visible.length - 1, 0].max
2629
2764
  end
2630
2765
  end
2631
2766
 
@@ -2710,7 +2845,7 @@ class HyperListApp
2710
2845
  when "autosave", "as"
2711
2846
  status = @auto_save_enabled ? "enabled" : "disabled"
2712
2847
  @message = "Auto-save is #{status} (interval: #{@auto_save_interval}s)"
2713
- when "template", "templates", "t"
2848
+ when "t"
2714
2849
  show_templates
2715
2850
  when "foldlevel"
2716
2851
  level = @footer.ask("Fold to level (0-9): ", "")
@@ -2883,22 +3018,53 @@ class HyperListApp
2883
3018
 
2884
3019
  password = ""
2885
3020
  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
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
2897
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
2898
3052
  end
2899
- @footer.text = prompt + "*" * password.length
2900
- @footer.refresh
2901
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
+
2902
3068
  password
2903
3069
  end
2904
3070
 
@@ -2994,40 +3160,44 @@ class HyperListApp
2994
3160
  def toggle_line_encryption
2995
3161
  return if @items.empty?
2996
3162
 
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"
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
3021
3179
  else
3022
- @message = "Encryption cancelled"
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
3023
3191
  end
3192
+ rescue => e
3193
+ @message = "Encryption error: #{e.message}"
3024
3194
  end
3025
3195
  end
3026
3196
 
3027
3197
  def load_templates
3028
3198
  {
3029
3199
  "project" => [
3030
- {"text" => "Project: [Project Name]", "level" => 0},
3200
+ {"text" => "Project: =Project Name=", "level" => 0},
3031
3201
  {"text" => "[_] Define project scope", "level" => 1},
3032
3202
  {"text" => "[_] Identify stakeholders", "level" => 1},
3033
3203
  {"text" => "[_] Create timeline", "level" => 1},
@@ -3046,25 +3216,25 @@ class HyperListApp
3046
3216
  {"text" => "[_] Archive project materials", "level" => 2}
3047
3217
  ],
3048
3218
  "meeting" => [
3049
- {"text" => "Meeting: [Title]", "level" => 0},
3219
+ {"text" => "Meeting: =Title=", "level" => 0},
3050
3220
  {"text" => "Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", "level" => 1},
3051
- {"text" => "Location: [Conference Room/Online]", "level" => 1},
3221
+ {"text" => "Location: =Conference Room/Online=", "level" => 1},
3052
3222
  {"text" => "Attendees", "level" => 1},
3053
- {"text" => "[Name 1]", "level" => 2},
3054
- {"text" => "[Name 2]", "level" => 2},
3223
+ {"text" => "=Name 1=", "level" => 2},
3224
+ {"text" => "=Name 2=", "level" => 2},
3055
3225
  {"text" => "Agenda", "level" => 1},
3056
3226
  {"text" => "[_] Opening remarks", "level" => 2},
3057
3227
  {"text" => "[_] Review previous action items", "level" => 2},
3058
3228
  {"text" => "[_] Main topics", "level" => 2},
3059
- {"text" => "Topic 1: [Description]", "level" => 3},
3060
- {"text" => "Topic 2: [Description]", "level" => 3},
3229
+ {"text" => "Topic 1: =Description=", "level" => 3},
3230
+ {"text" => "Topic 2: =Description=", "level" => 3},
3061
3231
  {"text" => "[_] Q&A session", "level" => 2},
3062
3232
  {"text" => "[_] Next steps", "level" => 2},
3063
3233
  {"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},
3234
+ {"text" => "[_] =Action 1= - Assigned to: =Name= - Due: =Date=", "level" => 2},
3235
+ {"text" => "[_] =Action 2= - Assigned to: =Name= - Due: =Date=", "level" => 2},
3066
3236
  {"text" => "Notes", "level" => 1},
3067
- {"text" => "[Add meeting notes here]", "level" => 2}
3237
+ {"text" => "=Add meeting notes here=", "level" => 2}
3068
3238
  ],
3069
3239
  "daily" => [
3070
3240
  {"text" => "Daily Plan: #{Time.now.strftime('%Y-%m-%d')}", "level" => 0},
@@ -3073,19 +3243,19 @@ class HyperListApp
3073
3243
  {"text" => "[_] Check emails", "level" => 2},
3074
3244
  {"text" => "[_] Plan priorities", "level" => 2},
3075
3245
  {"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},
3246
+ {"text" => "[_] =High Priority Task 1=", "level" => 2},
3247
+ {"text" => "[_] =High Priority Task 2=", "level" => 2},
3248
+ {"text" => "[_] =High Priority Task 3=", "level" => 2},
3079
3249
  {"text" => "Regular Tasks", "level" => 1},
3080
- {"text" => "[_] [Task 1]", "level" => 2},
3081
- {"text" => "[_] [Task 2]", "level" => 2},
3250
+ {"text" => "[_] =Task 1=", "level" => 2},
3251
+ {"text" => "[_] =Task 2=", "level" => 2},
3082
3252
  {"text" => "Meetings/Appointments", "level" => 1},
3083
- {"text" => "[Time] - [Meeting/Event]", "level" => 2},
3253
+ {"text" => "=Time= - =Meeting/Event=", "level" => 2},
3084
3254
  {"text" => "Notes", "level" => 1},
3085
- {"text" => "[Daily observations and reflections]", "level" => 2}
3255
+ {"text" => "=Daily observations and reflections=", "level" => 2}
3086
3256
  ],
3087
3257
  "checklist" => [
3088
- {"text" => "Checklist: [Title]", "level" => 0},
3258
+ {"text" => "Checklist: =Title=", "level" => 0},
3089
3259
  {"text" => "[_] Item 1", "level" => 1},
3090
3260
  {"text" => "[_] Item 2", "level" => 1},
3091
3261
  {"text" => "[_] Item 3", "level" => 1},
@@ -3093,9 +3263,9 @@ class HyperListApp
3093
3263
  {"text" => "[_] Item 5", "level" => 1}
3094
3264
  ],
3095
3265
  "brainstorm" => [
3096
- {"text" => "Brainstorming: [Topic]", "level" => 0},
3266
+ {"text" => "Brainstorming: =Topic=", "level" => 0},
3097
3267
  {"text" => "Problem Statement", "level" => 1},
3098
- {"text" => "[Define the problem or opportunity]", "level" => 2},
3268
+ {"text" => "=Define the problem or opportunity=", "level" => 2},
3099
3269
  {"text" => "Ideas", "level" => 1},
3100
3270
  {"text" => "Category 1", "level" => 2},
3101
3271
  {"text" => "Idea A", "level" => 3},
@@ -3112,37 +3282,35 @@ class HyperListApp
3112
3282
  {"text" => "[_] Create action plan", "level" => 2}
3113
3283
  ],
3114
3284
  "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},
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},
3119
3289
  {"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},
3290
+ {"text" => "=Amount= =Ingredient 1=", "level" => 2},
3291
+ {"text" => "=Amount= =Ingredient 2=", "level" => 2},
3292
+ {"text" => "=Amount= =Ingredient 3=", "level" => 2},
3123
3293
  {"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},
3294
+ {"text" => "[_] Step 1: =Description=", "level" => 2},
3295
+ {"text" => "[_] Step 2: =Description=", "level" => 2},
3296
+ {"text" => "[_] Step 3: =Description=", "level" => 2},
3127
3297
  {"text" => "Notes", "level" => 1},
3128
- {"text" => "[Tips, variations, serving suggestions]", "level" => 2}
3298
+ {"text" => "=Tips, variations, serving suggestions=", "level" => 2}
3129
3299
  ]
3130
3300
  }
3131
3301
  end
3132
3302
 
3133
3303
  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}
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
+ }
3145
3312
 
3313
+ # Create template selection view
3146
3314
  template_list = [
3147
3315
  ["project", "Project Plan - Complete project management template"],
3148
3316
  ["meeting", "Meeting Agenda - Structure for meeting notes"],
@@ -3152,61 +3320,102 @@ class HyperListApp
3152
3320
  ["recipe", "Recipe - Cooking recipe structure"]
3153
3321
  ]
3154
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
+
3155
3328
  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}
3329
+ @items << {
3330
+ "text" => "#{idx+1}. #{key.capitalize}: #{desc}",
3331
+ "level" => 0,
3332
+ "fold" => false,
3333
+ "raw" => true,
3334
+ "template_key" => key
3335
+ }
3157
3336
  end
3158
3337
 
3159
3338
  @current = 2 # Start at first template
3160
3339
  @offset = 0
3161
- @modified = false
3162
3340
 
3163
3341
  selected_template = nil
3342
+ exit_loop = false
3164
3343
 
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
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
3196
3382
  end
3383
+
3384
+ rescue => e
3385
+ # Log error but continue
3386
+ @message = "Error in template loop: #{e.message}"
3387
+ exit_loop = true
3197
3388
  end
3198
3389
  end
3199
3390
 
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)
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
3210
3419
  end
3211
3420
  end
3212
3421
 
@@ -3214,10 +3423,15 @@ class HyperListApp
3214
3423
  template_items = @templates[template_key]
3215
3424
  return unless template_items
3216
3425
 
3217
- 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
3218
3432
 
3219
3433
  # Get current item level to adjust template indentation
3220
- current_level = @items[@current]["level"]
3434
+ current_level = @items[@current]["level"] || 0
3221
3435
 
3222
3436
  # Insert template items after current position
3223
3437
  insertion_point = @current + 1
@@ -3317,13 +3531,29 @@ class HyperListApp
3317
3531
  when "V"
3318
3532
  toggle_checkbox_with_date
3319
3533
  when "TAB"
3320
- 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
3321
3539
  when "S-TAB"
3322
- 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
3323
3545
  when "RIGHT"
3324
- indent_line
3546
+ if @split_view && @active_pane == :split
3547
+ indent_split_right(false)
3548
+ else
3549
+ indent_right(false)
3550
+ end
3325
3551
  when "LEFT"
3326
- unindent_line
3552
+ if @split_view && @active_pane == :split
3553
+ indent_split_left(false)
3554
+ else
3555
+ indent_left(false)
3556
+ end
3327
3557
  when " "
3328
3558
  toggle_fold
3329
3559
  when "u"
@@ -3340,17 +3570,29 @@ class HyperListApp
3340
3570
  def toggle_split_view
3341
3571
  @split_view = !@split_view
3342
3572
  if @split_view
3343
- @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
3344
3576
  @split_current = @current
3345
3577
  @split_offset = @offset
3346
3578
  @active_pane = :main
3347
- set_message("Split view enabled. Use 'ww' to switch panes.")
3579
+ set_message("Split view enabled. Use Ctrl-w w to switch panes.")
3348
3580
  else
3349
3581
  @split_items = []
3350
3582
  set_message("Split view disabled")
3351
3583
  end
3584
+
3585
+ # Clear cached content to force re-render
3586
+ @last_rendered_content = ""
3587
+ @processed_cache.clear
3588
+
3589
+ # Recreate UI
3352
3590
  setup_ui
3353
- 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
3354
3596
  end
3355
3597
 
3356
3598
  def copy_section_to_split
@@ -3382,7 +3624,8 @@ class HyperListApp
3382
3624
  end_idx = idx
3383
3625
  end
3384
3626
 
3385
- # 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
3386
3629
  @split_items = @items[start_idx..end_idx]
3387
3630
  @split_current = 0
3388
3631
  @split_offset = 0
@@ -3398,6 +3641,10 @@ class HyperListApp
3398
3641
  if @split_view
3399
3642
  @active_pane = (@active_pane == :main) ? :split : :main
3400
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
3401
3648
  end
3402
3649
  when "v"
3403
3650
  # Vertical split
@@ -3413,48 +3660,111 @@ class HyperListApp
3413
3660
  end
3414
3661
  end
3415
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
+
3416
3690
  def render_split_pane
3417
3691
  return unless @split_view && @split_pane
3418
3692
 
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
3693
+ # Get visible items respecting fold state
3694
+ visible_items = get_visible_split_items
3426
3695
 
3696
+ # Build ALL lines for the pane (like we do for main pane)
3427
3697
  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]
3698
+ visible_items.each_with_index do |item, idx|
3433
3699
  next unless item
3434
3700
 
3435
3701
  line = " " * item["level"]
3436
3702
 
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")
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
3442
3712
  else
3443
3713
  line += " "
3444
3714
  end
3445
3715
 
3446
- # Process text (simplified for split view)
3447
- line += process_text(item["text"], false)
3716
+ # Apply process_text for syntax highlighting
3717
+ processed = process_text(item["text"], false)
3718
+ line += processed
3448
3719
 
3449
- # Highlight current line in split pane
3720
+ # Apply background highlighting for current item in split pane
3450
3721
  if idx == @split_current
3451
- line = line.bg("237") # Dark gray background to preserve foreground colors
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
3452
3727
  end
3453
3728
 
3454
3729
  lines << line
3455
3730
  end
3456
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
3457
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
+
3458
3768
  @split_pane.refresh
3459
3769
  end
3460
3770
 
@@ -3467,11 +3777,22 @@ class HyperListApp
3467
3777
 
3468
3778
  def move_in_active_pane(direction)
3469
3779
  if @split_view && @active_pane == :split
3780
+ visible = get_visible_split_items
3470
3781
  case direction
3471
3782
  when :down
3472
- @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
3473
3789
  when :up
3474
- @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
3475
3796
  end
3476
3797
  else
3477
3798
  case direction
@@ -3957,28 +4278,57 @@ class HyperListApp
3957
4278
  go_to_first_child
3958
4279
  when "LEFT"
3959
4280
  # Unindent only the current item
3960
- indent_left(false)
4281
+ if @split_view && @active_pane == :split
4282
+ indent_split_left(false)
4283
+ else
4284
+ indent_left(false)
4285
+ end
3961
4286
  when "RIGHT"
3962
4287
  # Indent only the current item
3963
- indent_right(false)
4288
+ if @split_view && @active_pane == :split
4289
+ indent_split_right(false)
4290
+ else
4291
+ indent_right(false)
4292
+ end
3964
4293
  when "PgUP" # Page Up
3965
4294
  page_up
3966
4295
  when "PgDOWN" # Page Down
3967
4296
  page_down
3968
4297
  when "HOME" # Home
3969
- @current = 0
3970
- @offset = 0
3971
- update_presentation_focus if @presentation_mode
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
3972
4305
  when "END" # End
3973
- @current = get_visible_items.length - 1
3974
- update_presentation_focus if @presentation_mode
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
3975
4314
  when "g" # Go to top (was gg)
3976
- @current = 0
3977
- @offset = 0
3978
- update_presentation_focus if @presentation_mode
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
3979
4322
  when "G" # Go to bottom
3980
- @current = get_visible_items.length - 1
3981
- update_presentation_focus if @presentation_mode
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
3982
4332
  when "R" # Jump to reference (was gr)
3983
4333
  jump_to_reference
3984
4334
  when "F" # Open file reference (was gf)
@@ -4038,6 +4388,8 @@ class HyperListApp
4038
4388
  delete_line(false) # D always deletes with children by default
4039
4389
  when "C-D" # Delete line and all descendants explicitly
4040
4390
  delete_line(true)
4391
+ when "C-E" # Toggle line encryption
4392
+ toggle_line_encryption
4041
4393
  when "y" # Yank/copy single line
4042
4394
  yank_line(false)
4043
4395
  when "Y" # Yank/copy line with all descendants
@@ -4056,10 +4408,18 @@ class HyperListApp
4056
4408
  move_item_up(true)
4057
4409
  when "TAB"
4058
4410
  # Indent with all children
4059
- indent_right(true)
4411
+ if @split_view && @active_pane == :split
4412
+ indent_split_right(true)
4413
+ else
4414
+ indent_right(true)
4415
+ end
4060
4416
  when "S-TAB" # Shift-Tab
4061
4417
  # Unindent with all children
4062
- indent_left(true)
4418
+ if @split_view && @active_pane == :split
4419
+ indent_split_left(true)
4420
+ else
4421
+ indent_left(true)
4422
+ end
4063
4423
  when "u"
4064
4424
  undo
4065
4425
  when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
@@ -4068,8 +4428,6 @@ class HyperListApp
4068
4428
  toggle_checkbox
4069
4429
  when "V"
4070
4430
  toggle_checkbox_with_date
4071
- when "\x05" # Ctrl-E for encryption toggle
4072
- toggle_line_encryption
4073
4431
  when "."
4074
4432
  repeat_last_action
4075
4433
  when "/"
@@ -4080,11 +4438,34 @@ class HyperListApp
4080
4438
  jump_to_next_template_marker
4081
4439
  when "P"
4082
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
4083
4454
  when "\\"
4084
4455
  next_c = getchr
4085
4456
  case next_c
4086
4457
  when "u"
4087
- @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
4088
4469
  end
4089
4470
  when ":"
4090
4471
  handle_command
@@ -4128,15 +4509,16 @@ class HyperListApp
4128
4509
  next_c = getchr
4129
4510
  play_macro(next_c) if next_c && next_c =~ /[a-z]/
4130
4511
  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
4512
+ # Switch active pane if split view is active
4513
+ if @split_view
4135
4514
  @active_pane = (@active_pane == :main) ? :split : :main
4136
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
4137
4520
  else
4138
- # Not a window command, ignore for now
4139
- @message = "Unknown command: w#{next_c}"
4521
+ @message = "Split view not active. Use :vs to enable"
4140
4522
  end
4141
4523
  when "Q" # Force quit
4142
4524
  quit