hyperlist 1.2.7 → 1.4.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 (7) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +71 -11
  4. data/hyperlist +582 -81
  5. data/hyperlist.gemspec +1 -1
  6. data/sample.hl +84 -82
  7. metadata +2 -2
data/hyperlist CHANGED
@@ -7,7 +7,7 @@
7
7
  # Check for help/version BEFORE loading any libraries
8
8
  if ARGV[0] == '-h' || ARGV[0] == '--help'
9
9
  puts <<~HELP
10
- HyperList v1.2.3 - Terminal User Interface for HyperList files
10
+ HyperList v1.4.1 - Terminal User Interface for HyperList files
11
11
 
12
12
  USAGE
13
13
  hyperlist [OPTIONS] [FILE]
@@ -52,7 +52,7 @@ if ARGV[0] == '-h' || ARGV[0] == '--help'
52
52
  HELP
53
53
  exit 0
54
54
  elsif ARGV[0] == '-v' || ARGV[0] == '--version'
55
- puts "HyperList v1.2.3"
55
+ puts "HyperList v1.4.0"
56
56
  exit 0
57
57
  end
58
58
 
@@ -72,7 +72,7 @@ class HyperListApp
72
72
  include Rcurses::Input
73
73
  include Rcurses::Cursor
74
74
 
75
- VERSION = "1.2.3"
75
+ VERSION = "1.4.1"
76
76
 
77
77
  def initialize(filename = nil)
78
78
  @filename = filename ? File.expand_path(filename) : nil
@@ -85,6 +85,11 @@ class HyperListApp
85
85
  @search = ""
86
86
  @search_matches = [] # Track search match positions
87
87
  @fold_level = 99
88
+ @config_line = nil # Store config line from file
89
+ @theme = "normal" # Default theme
90
+ @wrap = false # Line wrapping disabled by default
91
+ @show_numbers = false # Line numbers disabled by default
92
+ @command_history = load_command_history # Command history for : commands
88
93
  @clipboard = nil
89
94
  @undo_stack = []
90
95
  @undo_position = [] # Stack of cursor positions for undo
@@ -192,6 +197,7 @@ class HyperListApp
192
197
  def load_file(file)
193
198
  @items = []
194
199
  @encrypted_lines = {}
200
+ @config_line = nil # Reset config line before loading
195
201
 
196
202
  # Read file content
197
203
  content = File.read(file) rescue ""
@@ -202,6 +208,9 @@ class HyperListApp
202
208
  @redo_stack = []
203
209
  @redo_position = []
204
210
 
211
+ # Parse config line if present (before processing content)
212
+ parse_config_line(content)
213
+
205
214
  # Check if file is encrypted (dot file or encrypted content)
206
215
  is_encrypted = false
207
216
  if is_encrypted_file?(file)
@@ -239,6 +248,11 @@ class HyperListApp
239
248
  lines.each_with_index do |line, idx|
240
249
  next if line.strip.empty?
241
250
 
251
+ # Skip config lines (don't add them to items)
252
+ if line.strip =~ /^\(\(.+\)\)$/
253
+ next
254
+ end
255
+
242
256
  # Detect level based on leading whitespace
243
257
  if line.start_with?("\t")
244
258
  # Tab-based indentation
@@ -285,6 +299,12 @@ class HyperListApp
285
299
  @message = "Large file loaded. Deep levels auto-folded for performance."
286
300
  end
287
301
 
302
+ # Apply configured fold level if set (after items are loaded)
303
+ if @fold_level != 99 && !is_encrypted # Don't override encrypted file folding
304
+ apply_fold_level(@fold_level)
305
+ @message = "Applied fold level: #{@fold_level}" if @message.nil? || @message.empty?
306
+ end
307
+
288
308
  # Update recent files list
289
309
  add_to_recent_files(File.expand_path(file)) if file
290
310
  end
@@ -297,6 +317,128 @@ class HyperListApp
297
317
  end
298
318
  end
299
319
 
320
+ def apply_fold_level(level)
321
+ # Apply fold level: 0 = all folded, 99 = all open
322
+ # Show items up to and including the specified level
323
+ # Fold items at levels greater than specified level
324
+ @items.each_with_index do |item, idx|
325
+ if has_children?(idx, @items)
326
+ # Fold if item level is greater than or equal to the fold level
327
+ # This means: fold_level=1 shows level 0 expanded, level 1 and deeper folded
328
+ # fold_level=2 shows levels 0-1 expanded, level 2 and deeper folded
329
+ item["fold"] = item["level"] >= level
330
+ end
331
+ end
332
+
333
+ # Special cases
334
+ if level == 0
335
+ # Fold everything that has children
336
+ @items.each_with_index do |item, idx|
337
+ item["fold"] = true if has_children?(idx, @items)
338
+ end
339
+ elsif level >= 99
340
+ # Unfold everything
341
+ @items.each { |item| item["fold"] = false }
342
+ end
343
+ end
344
+
345
+ def parse_config_line(content)
346
+ # Look for config line at the bottom of the file
347
+ # Format: ((option1=value, option2=value))
348
+ lines = content.split("\n")
349
+
350
+ # Check last 10 lines for config (in case there are empty lines or other content)
351
+ config_line = nil
352
+ lines.last(10).each do |line|
353
+ # Allow indented config lines
354
+ if line.strip =~ /^\(\((.+)\)\)$/
355
+ @config_line = line.strip # Store the full line to preserve when saving
356
+ config_line = $1
357
+ break
358
+ end
359
+ end
360
+
361
+ return unless config_line
362
+
363
+ # Parse options (comma-separated)
364
+ config_options = {}
365
+ config_line.split(',').each do |option|
366
+ option = option.strip
367
+ if option =~ /(\w+)=(.+)/
368
+ key = $1.strip
369
+ value = $2.strip
370
+ # Convert values to appropriate types
371
+ case key
372
+ when "fold_level", "auto_save_interval", "tab_width"
373
+ config_options[key] = value.to_i
374
+ when "auto_save", "wrap", "show_numbers", "highlight_current", "checkbox_date", "backup", "encrypt"
375
+ config_options[key] = value.downcase == "true" || value.downcase == "yes"
376
+ else
377
+ config_options[key] = value
378
+ end
379
+ end
380
+ end
381
+
382
+ # Apply config options
383
+ apply_config(config_options)
384
+ end
385
+
386
+ def apply_config(options)
387
+ # Apply configuration options from config line
388
+ options.each do |key, value|
389
+ case key
390
+ when "fold_level"
391
+ @fold_level = value if value >= 0 && value <= 99
392
+ when "auto_save"
393
+ @auto_save_enabled = value
394
+ when "auto_save_interval"
395
+ @auto_save_interval = value if value > 0
396
+ when "tab_width", "indent_size"
397
+ @indent_size = value if value >= 2 && value <= 8
398
+ when "presentation_mode", "presentation"
399
+ @presentation_mode = value
400
+ setup_ui if value # Refresh UI if entering presentation mode
401
+ when "default_view"
402
+ case value
403
+ when "split"
404
+ @split_view = true
405
+ setup_ui
406
+ when "presentation"
407
+ @presentation_mode = true
408
+ setup_ui
409
+ end
410
+ when "theme"
411
+ # Apply theme setting
412
+ @theme = value if ["light", "normal", "dark"].include?(value)
413
+ when "wrap"
414
+ @wrap = value
415
+ when "show_numbers"
416
+ @show_numbers = value
417
+ when "search_case"
418
+ @search_case = value # sensitive/insensitive/smart
419
+ when "backup"
420
+ @backup_enabled = value
421
+ when "encrypt"
422
+ # Handle encryption enabling (would need password prompt)
423
+ @encrypt_enabled = value
424
+ end
425
+ end
426
+
427
+ # Show message about applied config
428
+ if options.any?
429
+ # Build message showing what was applied
430
+ msg_parts = []
431
+ msg_parts << "fold_level=#{@fold_level}" if @fold_level != 99
432
+ msg_parts << "theme=#{@theme}" if options["theme"]
433
+ msg_parts << "wrap=#{@wrap ? 'yes' : 'no'}" if options.key?("wrap")
434
+ msg_parts << "auto_save=#{@auto_save_enabled ? 'yes' : 'no'}" if options.key?("auto_save")
435
+
436
+ @message = "Applied config: #{msg_parts.join(', ')}" if msg_parts.any?
437
+ # Set a longer timeout for config message
438
+ @message_timeout = Time.now + 5.0
439
+ end
440
+ end
441
+
300
442
  def add_to_recent_files(filepath)
301
443
  return unless filepath && File.exist?(filepath)
302
444
 
@@ -328,6 +470,24 @@ class HyperListApp
328
470
  []
329
471
  end
330
472
 
473
+ def load_command_history
474
+ history_file = File.expand_path("~/.hyperlist_command_history")
475
+ return [] unless File.exist?(history_file)
476
+
477
+ File.readlines(history_file).map(&:strip).reject(&:empty?).last(100)
478
+ rescue
479
+ []
480
+ end
481
+
482
+ def save_command_history
483
+ history_file = File.expand_path("~/.hyperlist_command_history")
484
+ File.open(history_file, 'w') do |f|
485
+ @command_history.last(100).each { |cmd| f.puts(cmd) }
486
+ end
487
+ rescue
488
+ # Silently fail if can't write history
489
+ end
490
+
331
491
  def show_recent_files
332
492
  recent = load_recent_files
333
493
 
@@ -432,6 +592,23 @@ class HyperListApp
432
592
  (' ' * @indent_size) * item["level"] + item["text"]
433
593
  end.join("\n")
434
594
 
595
+ # Append config line if present
596
+ if @config_line && !@config_line.empty?
597
+ # Ensure there's a blank line before config
598
+ content += "\n" unless content.end_with?("\n")
599
+ content += "\n" + @config_line
600
+ else
601
+ # Debug: Check why config line is missing
602
+ if @fold_level != 99
603
+ # Rebuild config line if we have config settings but no line
604
+ update_config_line
605
+ if @config_line && !@config_line.empty?
606
+ content += "\n" unless content.end_with?("\n")
607
+ content += "\n" + @config_line
608
+ end
609
+ end
610
+ end
611
+
435
612
  # Check if this should be an encrypted file
436
613
  if is_encrypted_file?(@filename) && !content.empty?
437
614
  # Check if any lines are already encrypted
@@ -731,6 +908,61 @@ class HyperListApp
731
908
  end
732
909
 
733
910
 
911
+ def wrap_line(text, width, indent_level)
912
+ # Wrap a line per HyperList spec:
913
+ # Multi-line items start with '+' on first line
914
+ # Continuation lines have just a space prefix
915
+ return [text] unless @wrap
916
+
917
+ # Calculate effective width (account for indent and fold indicators)
918
+ indent_width = @indent_size * indent_level + 2 # +2 for fold indicator
919
+ effective_width = width - indent_width - 5 # Extra margin for readability
920
+
921
+ return [text] if text.length <= effective_width || effective_width <= 10
922
+
923
+ wrapped = []
924
+ remaining = text.dup
925
+ first_line = true
926
+
927
+ # Check if line already starts with + (multi-line indicator)
928
+ has_plus = text.strip.start_with?('+')
929
+ if has_plus
930
+ # Remove the + for processing, we'll add it back
931
+ remaining = remaining.sub(/^\s*\+\s*/, '')
932
+ end
933
+
934
+ while remaining && !remaining.empty?
935
+ if first_line
936
+ # First line gets the multi-line indicator if needed
937
+ if remaining.length <= effective_width
938
+ wrapped << (has_plus || wrapped.any? ? "+ #{remaining}" : remaining)
939
+ break
940
+ else
941
+ # Find a good break point (prefer spaces)
942
+ break_point = remaining[0...effective_width].rindex(' ') || effective_width
943
+ line_text = remaining[0...break_point].rstrip
944
+ # Add + to first line of multi-line items
945
+ wrapped << "+ #{line_text}"
946
+ remaining = remaining[break_point..-1].lstrip
947
+ first_line = false
948
+ end
949
+ else
950
+ # Continuation lines get just a space prefix per HyperList spec
951
+ cont_width = effective_width - 1 # Account for space prefix
952
+ if remaining.length <= cont_width
953
+ wrapped << " #{remaining}"
954
+ break
955
+ else
956
+ break_point = remaining[0...cont_width].rindex(' ') || cont_width
957
+ wrapped << " #{remaining[0...break_point].rstrip}"
958
+ remaining = remaining[break_point..-1].lstrip
959
+ end
960
+ end
961
+ end
962
+
963
+ wrapped
964
+ end
965
+
734
966
  def render_main
735
967
  visible_items = get_visible_items
736
968
 
@@ -742,62 +974,92 @@ class HyperListApp
742
974
  visible_items.each_with_index do |item, idx|
743
975
  next unless item
744
976
 
745
- line = ' ' * (@indent_size * item["level"])
746
-
747
- # Add fold indicator
748
- real_idx = get_real_index(item)
749
- if real_idx && has_children?(real_idx, @items) && item["fold"]
750
- color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
751
- line += "▶".fg(color) + " "
752
- elsif real_idx && has_children?(real_idx, @items)
753
- color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
754
- line += "▷".fg(color) + " "
977
+ # Handle line wrapping if enabled
978
+ if @wrap
979
+ text_lines = wrap_line(item["text"], @cols, item["level"])
755
980
  else
756
- line += " "
981
+ text_lines = [item["text"]]
757
982
  end
758
983
 
759
- # Handle literal blocks and syntax highlighting
760
- if item["text"].strip == "\\"
761
- if !in_literal_block
762
- in_literal_block = true
763
- literal_start_level = item["level"]
764
- spaces = item["text"].match(/^(\s*)/)[1]
765
- line += spaces + "\\".fg("3")
766
- elsif item["level"] == literal_start_level
767
- in_literal_block = false
768
- literal_start_level = -1
769
- spaces = item["text"].match(/^(\s*)/)[1]
770
- line += spaces + "\\".fg("3")
984
+ text_lines.each_with_index do |text_line, line_idx|
985
+ # Get the actual line number from the real index in @items
986
+ real_idx = get_real_index(item)
987
+ actual_line_number = real_idx ? real_idx + 1 : 0 # +1 for 1-based line numbers
988
+
989
+ # Add line number if enabled (only on first line of wrapped text)
990
+ line = ""
991
+ if @show_numbers
992
+ if line_idx == 0
993
+ line = "#{actual_line_number.to_s.rjust(4)} "
994
+ else
995
+ line = " " # Empty space for continuation lines
996
+ end
997
+ end
998
+
999
+ line += ' ' * (@indent_size * item["level"])
1000
+
1001
+ # Add fold indicator only on first line
1002
+ if line_idx == 0
1003
+ real_idx = get_real_index(item)
1004
+ if real_idx && has_children?(real_idx, @items) && item["fold"]
1005
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
1006
+ line += "▶".fg(color) + " "
1007
+ elsif real_idx && has_children?(real_idx, @items)
1008
+ color = (@presentation_mode && !is_item_in_presentation_focus?(item)) ? "240" : "245"
1009
+ line += "▷".fg(color) + " "
1010
+ else
1011
+ line += " "
1012
+ end
771
1013
  else
772
- line += item["text"]
1014
+ # Continuation lines already have their space prefix from wrap_line
1015
+ line += " " # Just add fold indicator spacing
773
1016
  end
774
- elsif in_literal_block
775
- line += item["text"]
776
- else
777
- # Normal syntax highlighting
778
- has_match = @search_matches.include?(idx) && @search && !@search.empty?
779
- if @presentation_mode && !is_item_in_presentation_focus?(item)
780
- line += item["text"].fg("240")
1017
+
1018
+ # Handle literal blocks and syntax highlighting
1019
+ if item["text"].strip == "\\"
1020
+ if !in_literal_block
1021
+ in_literal_block = true
1022
+ literal_start_level = item["level"]
1023
+ spaces = text_line.match(/^(\s*)/)[1] || ""
1024
+ line += spaces + "\\".fg("3")
1025
+ elsif item["level"] == literal_start_level
1026
+ in_literal_block = false
1027
+ literal_start_level = -1
1028
+ spaces = text_line.match(/^(\s*)/)[1] || ""
1029
+ line += spaces + "\\".fg("3")
1030
+ else
1031
+ line += text_line
1032
+ end
1033
+ elsif in_literal_block
1034
+ line += text_line
781
1035
  else
782
- line += process_text(item["text"], has_match)
1036
+ # Normal syntax highlighting
1037
+ has_match = @search_matches.include?(idx) && @search && !@search.empty?
1038
+ if @presentation_mode && !is_item_in_presentation_focus?(item)
1039
+ line += text_line.fg("240")
1040
+ else
1041
+ line += process_text(text_line, has_match)
1042
+ end
783
1043
  end
784
- end
785
-
786
- # Apply current item highlighting (but not in presentation mode for focused items)
787
- if idx == @current
788
- # Skip background highlighting in presentation mode for items in focus
789
- if !(@presentation_mode && is_item_in_presentation_focus?(item))
790
- bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
791
- if bg_color
792
- bg_code = "\e[48;5;#{bg_color}m"
793
- reset_bg = "\e[49m"
794
- line = bg_code + line.gsub(/\e\[49m/, '') + reset_bg
1044
+
1045
+ # Apply current item highlighting (all lines of wrapped text get bg)
1046
+ if idx == @current
1047
+ # Skip background highlighting in presentation mode for items in focus
1048
+ if !(@presentation_mode && is_item_in_presentation_focus?(item))
1049
+ bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
1050
+ if bg_color
1051
+ # Pad line to full width and apply background
1052
+ padded_line = line + " " * [@cols - line.pure.length, 0].max
1053
+ bg_code = "\e[48;5;#{bg_color}m"
1054
+ reset_bg = "\e[49m"
1055
+ line = bg_code + padded_line.gsub(/\e\[49m/, '') + reset_bg
1056
+ end
795
1057
  end
796
1058
  end
1059
+
1060
+ lines << line
797
1061
  end
798
1062
 
799
- lines << line
800
-
801
1063
  # Check if exiting literal block
802
1064
  if in_literal_block && item["level"] <= literal_start_level && !item["text"].strip == "\\"
803
1065
  in_literal_block = false
@@ -847,10 +1109,63 @@ class HyperListApp
847
1109
  @main.refresh
848
1110
  end
849
1111
 
1112
+ def get_theme_colors
1113
+ # Theme definitions based on HyperList spec colors
1114
+ # Using hex RGB colors (RRGGBB format) for rcurses
1115
+ # Per HyperList spec from hyperlist.tex:
1116
+ # red (properties/dates), green (qualifiers/states), blue (operators)
1117
+ # magenta/violet (references), cyan (parentheses/quotes), yellow (literals)
1118
+ # orange (tags)
1119
+ case @theme
1120
+ when "light"
1121
+ # Brighter, more saturated colors for dark terminals
1122
+ {
1123
+ "red" => "FF5050", # Bright red for properties
1124
+ "green" => "50FF50", # Bright green for qualifiers
1125
+ "blue" => "6496FF", # Bright blue for operators
1126
+ "magenta" => "C864FF", # Light purple/violet for references
1127
+ "cyan" => "50FFFF", # Bright cyan for parentheses
1128
+ "yellow" => "FFFF64", # Bright yellow for literals
1129
+ "orange" => "FFB450", # Bright orange for tags
1130
+ "gray" => "C8C8C8" # Light gray
1131
+ }
1132
+ when "dark"
1133
+ # Darker, less saturated colors for light background terminals
1134
+ {
1135
+ "red" => "B40000", # Dark red for properties
1136
+ "green" => "008C00", # Dark green for qualifiers
1137
+ "blue" => "0000B4", # Dark blue for operators
1138
+ "magenta" => "8C008C", # Dark purple for references
1139
+ "cyan" => "008C8C", # Dark cyan for parentheses
1140
+ "yellow" => "8C8C00", # Dark yellow for literals
1141
+ "orange" => "B46400", # Dark orange for tags
1142
+ "gray" => "646464" # Dark gray
1143
+ }
1144
+ else # normal - using 256 color codes for compatibility
1145
+ {
1146
+ "red" => "196", # Red for properties/dates (standard red)
1147
+ "green" => "46", # Green for qualifiers/checkboxes
1148
+ "blue" => "21", # Blue for operators (AND/OR/IF/THEN)
1149
+ "magenta" => "165", # Purple/violet for references
1150
+ "cyan" => "51", # Cyan for parentheses/quotes
1151
+ "yellow" => "226", # Yellow for literals/substitutions
1152
+ "orange" => "208", # Orange for tags
1153
+ "gray" => "245" # Gray
1154
+ }
1155
+ end
1156
+ end
1157
+
850
1158
  def process_text(text, highlight_search = false)
851
1159
  # Work with a clean copy
852
1160
  result = text.dup
853
1161
  processed_checkbox = false
1162
+ colors = get_theme_colors
1163
+
1164
+ # Config lines should never be displayed (they're filtered out)
1165
+ # But if somehow one gets through, don't process it
1166
+ if result =~ /^\(\(.+\)\)$/
1167
+ return ""
1168
+ end
854
1169
 
855
1170
  # If text already contains ANSI codes, return as-is to avoid double-processing
856
1171
  if result.include?("\e[")
@@ -909,7 +1224,7 @@ class HyperListApp
909
1224
 
910
1225
  # Check if this is a literal block marker (single backslash)
911
1226
  if result.strip == "\\"
912
- return result.fg("3") # Yellow for literal block markers
1227
+ return result.fg(colors["yellow"]) # Yellow for literal block markers
913
1228
  end
914
1229
 
915
1230
  # Apply search highlighting if we have an active search
@@ -923,47 +1238,47 @@ class HyperListApp
923
1238
  # Based on hyperlist.vim: '^\(\t\|\*\)*[0-9.]* '
924
1239
  if result =~ /^([0-9][0-9A-Z.]*\s)/
925
1240
  identifier = $1
926
- result = result.sub(/^[0-9][0-9A-Z.]*\s/, identifier.fg("5")) # Magenta for identifiers
1241
+ result = result.sub(/^[0-9][0-9A-Z.]*\s/, identifier.fg(colors["magenta"])) # Magenta for identifiers
927
1242
  end
928
1243
 
929
1244
  # Handle multi-line indicator at the beginning (+ with space)
930
1245
  # Based on hyperlist.vim: '^\(\t\|\*\)*+ '
931
1246
  if result =~ /^\+\s/
932
- result = result.sub(/^(\+\s)/, "+".fg("1") + " ") # Red for multi-line indicator
1247
+ result = result.sub(/^(\+\s)/, "+".fg(colors["red"]) + " ") # Red for multi-line indicator
933
1248
  end
934
1249
 
935
1250
  # Handle continuation markers (+ at start of indented lines in References section)
936
1251
  if result =~ /^\s*\+\s/
937
1252
  spaces = $1 || ""
938
1253
  marker = $2 || "+ "
939
- result = result.sub(/^(\s*)(\+\s)/, spaces + marker.fg("1")) # Red for continuation marker
1254
+ result = result.sub(/^(\s*)(\+\s)/, spaces + marker.fg(colors["red"])) # Red for continuation marker
940
1255
  end
941
1256
 
942
1257
  # Process checkboxes anywhere in the line (can have leading spaces)
943
1258
  if result =~ /^(\s*)(\[X\]|\[x\])/
944
1259
  spaces = $1
945
- colored = "[X]".fg("10")
1260
+ colored = "[X]".fg(colors["green"])
946
1261
  result = result.sub(/^(\s*)(\[X\]|\[x\])/, "#{spaces}#{colored}") # Bright green for completed
947
1262
  processed_checkbox = true
948
1263
  elsif result =~ /^(\s*)(\[O\])/
949
1264
  spaces = $1
950
- colored = "[O]".fg("10").b
1265
+ colored = "[O]".fg(colors["green"]).b
951
1266
  result = result.sub(/^(\s*)(\[O\])/, "#{spaces}#{colored}") # Bold bright green for in-progress
952
1267
  processed_checkbox = true
953
1268
  elsif result =~ /^(\s*)(\[-\])/
954
1269
  spaces = $1
955
- colored = "[-]".fg("2")
1270
+ colored = "[-]".fg(colors["green"])
956
1271
  result = result.sub(/^(\s*)(\[-\])/, "#{spaces}#{colored}") # Green for partial
957
1272
  processed_checkbox = true
958
1273
  elsif result =~ /^(\s*)(\[ \]|\[_\])/
959
1274
  spaces = $1
960
- colored = "[ ]".fg("22")
1275
+ colored = "[ ]".fg(colors["green"])
961
1276
  result = result.sub(/^(\s*)(\[ \]|\[_\])/, "#{spaces}#{colored}") # Dark green for unchecked
962
1277
  processed_checkbox = true
963
1278
  elsif !processed_checkbox
964
1279
  # Only handle other qualifiers if we didn't process a checkbox
965
1280
  # Based on hyperlist.vim: '\[.\{-}\]'
966
- result.gsub!(/\[([^\]]*)\]/) { "[#{$1}]".fg("2") } # Green for all qualifiers
1281
+ result.gsub!(/\[([^\]]*)\]/) { "[#{$1}]".fg(colors["green"]) } # Green for all qualifiers
967
1282
  end
968
1283
 
969
1284
  # We'll handle parentheses AFTER operators/properties to avoid conflicts
@@ -971,7 +1286,7 @@ class HyperListApp
971
1286
  # Handle date timestamps as properties (for checkbox dates)
972
1287
  # Format: YYYY-MM-DD HH.MM:
973
1288
  result.gsub!(/(\d{4}-\d{2}-\d{2} \d{2}\.\d{2}):/) do
974
- "#{$1}:".fg("1") # Red for timestamp properties
1289
+ "#{$1}:".fg(colors["red"]) # Red for timestamp properties
975
1290
  end
976
1291
 
977
1292
  # Handle operators and properties with colon pattern
@@ -985,10 +1300,10 @@ class HyperListApp
985
1300
 
986
1301
  # Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
987
1302
  if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
988
- prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators (including S: and T:)
1303
+ prefix_space + text_part.fg(colors["blue"]) + colon_space.fg(colors["blue"]) # Blue for operators (including S: and T:)
989
1304
  elsif text_part.length >= 2 && space_after.include?(" ")
990
1305
  # It's a property (mixed case, at least 2 chars, has space after colon)
991
- prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
1306
+ prefix_space + text_part.fg(colors["red"]) + colon_space.fg(colors["red"]) # Red for properties
992
1307
  else
993
1308
  # Leave as is
994
1309
  prefix_space + text_part + colon_space
@@ -997,57 +1312,57 @@ class HyperListApp
997
1312
 
998
1313
 
999
1314
  # Color special state/transition markers (| and /) green
1000
- result.gsub!(/^(\s*)\|\s+/) { $1 + "| ".fg("2") } # Green for pipe (state marker)
1001
- result.gsub!(/^(\s*)\/\s+/) { $1 + "/ ".fg("2") } # Green for slash (transition marker)
1315
+ result.gsub!(/^(\s*)\|\s+/) { $1 + "| ".fg(colors["green"]) } # Green for pipe (state marker)
1316
+ result.gsub!(/^(\s*)\/\s+/) { $1 + "/ ".fg(colors["green"]) } # Green for slash (transition marker)
1002
1317
 
1003
1318
  # Handle OR: at the beginning of a line (with optional spaces)
1004
- result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
1319
+ result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg(colors["blue"]) } # Blue for OR: at line start
1005
1320
 
1006
1321
  # Handle parentheses content (moved here to avoid conflicts with properties)
1007
1322
  # Based on hyperlist.vim: '(.\{-})'
1008
1323
  result = safe_regex_replace(result, /\(([^)]*)\)/) do |match|
1009
1324
  content = match[1..-2] # Extract content between parentheses
1010
- "(".fg("6") + content.fg("6") + ")".fg("6")
1325
+ "(".fg(colors["cyan"]) + content.fg(colors["cyan"]) + ")".fg(colors["cyan"])
1011
1326
  end
1012
1327
 
1013
1328
  # Handle semicolons as separators (they separate items on the same line)
1014
1329
  # Semicolons are green like qualifiers
1015
- result = safe_regex_replace(result, /;/) { ";".fg("2") }
1330
+ result = safe_regex_replace(result, /;/) { ";".fg(colors["green"]) }
1016
1331
 
1017
1332
  # Handle references - color entire reference including brackets
1018
1333
  # Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
1019
- result.gsub!(/<{1,2}([^>]+)>{1,2}/) { |match| match.fg("5") } # Magenta for references
1334
+ result.gsub!(/<{1,2}([^>]+)>{1,2}/) { |match| match.fg(colors["magenta"]) } # Magenta for references
1020
1335
 
1021
1336
  # Handle special keywords SKIP and END
1022
- result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
1337
+ result.gsub!(/\b(SKIP|END)\b/) { $1.fg(colors["magenta"]) } # Magenta for special keywords (like references)
1023
1338
 
1024
1339
  # Handle quoted strings (only double quotes are special in HyperList)
1025
1340
  # Based on hyperlist.vim: '".\{-}"'
1026
1341
  result.gsub!(/"([^"]*)"/) do
1027
1342
  content = $1
1028
1343
  # Color any ## sequences inside the quotes as red
1029
- content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
1030
- '"'.fg("6") + content.fg("6") + '"'.fg("6") # Cyan for quoted strings
1344
+ content.gsub!(/(##[<>-]+)/) { $1.fg(colors["red"]) }
1345
+ '"'.fg(colors["cyan"]) + content.fg(colors["cyan"]) + '"'.fg(colors["cyan"]) # Cyan for quoted strings
1031
1346
  end
1032
1347
 
1033
1348
  # Handle change markup - all double-hashes should be red
1034
1349
  # First handle ##><Reference>##-> style (with reference in the middle)
1035
1350
  result.gsub!(/(##[<>-]+)(<[^>]+>)(##[<>-]+)/) do
1036
- $1.fg("1") + $2.fg("5") + $3.fg("1") # Red markers, magenta reference
1351
+ $1.fg(colors["red"]) + $2.fg(colors["magenta"]) + $3.fg(colors["red"]) # Red markers, magenta reference
1037
1352
  end
1038
1353
 
1039
1354
  # Handle ##Text## change info (text between double hashes)
1040
- result.gsub!(/(##)([^#]+)(##)/) { $1.fg("1") + $2 + $3.fg("1") } # Red for change info markers
1355
+ result.gsub!(/(##)([^#]+)(##)/) { $1.fg(colors["red"]) + $2 + $3.fg(colors["red"]) } # Red for change info markers
1041
1356
 
1042
1357
  # Then color any remaining ## sequences red
1043
- result.gsub!(/(##[<>-]*)/) { $1.fg("1") } # Red for all ## markers
1358
+ result.gsub!(/(##[<>-]*)/) { $1.fg(colors["red"]) } # Red for all ## markers
1044
1359
 
1045
1360
  # Handle substitutions {variable}
1046
- result.gsub!(/\{([^}]+)\}/) { "{".fg("3") + $1.fg("3") + "}".fg("3") } # Yellow for substitutions
1361
+ result.gsub!(/\{([^}]+)\}/) { "{".fg(colors["yellow"]) + $1.fg(colors["yellow"]) + "}".fg(colors["yellow"]) } # Yellow for substitutions
1047
1362
 
1048
1363
  # Handle hash tags
1049
1364
  # Based on hyperlist.vim: '#[a-zA-Z0-9.:/_&?%=+\-\*]\+'
1050
- result.gsub!(/#([a-zA-Z0-9.:_\/&?%=+\-*]+)/) { "##{$1}".fg("184") } # Yellow/gold for tags
1365
+ result.gsub!(/#([a-zA-Z0-9.:_\/&?%=+\-*]+)/) { "##{$1}".fg(colors["orange"]) } # Orange for tags
1051
1366
 
1052
1367
  # Handle text formatting (bold, italic, underline)
1053
1368
  # Based on hyperlist.vim patterns with tab/space boundaries
@@ -2479,8 +2794,13 @@ class HyperListApp
2479
2794
  end
2480
2795
 
2481
2796
  def show_help
2482
- # Build help text using consistent formatting
2483
- help_lines = []
2797
+ begin
2798
+ # Build help text using consistent formatting
2799
+ help_lines = []
2800
+
2801
+ # Debug logging
2802
+ debug_log = File.open("/tmp/hyperlist_help_debug.log", "w") rescue nil
2803
+ debug_log.puts "show_help called at #{Time.now}" if debug_log
2484
2804
  help_lines << " Press #{"?".fg("10")} for full documentation, #{"UP/DOWN".fg("10")} to scroll, or any other key to return"
2485
2805
  help_lines << ""
2486
2806
  help_lines << "#{"HYPERLIST KEY BINDINGS".b}"
@@ -2509,7 +2829,7 @@ class HyperListApp
2509
2829
  help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
2510
2830
  help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
2511
2831
  help_lines << help_line("#{"A".fg("10")}", "Insert outdented", "#{"W".fg("10")}", "Save and quit")
2512
- help_lines << help_line("#{"I".fg("10")}", "Cycle indent (2-5)", "", "")
2832
+ help_lines << help_line("#{"I".fg("10")}", "Cycle indent (2-5)")
2513
2833
  help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
2514
2834
  help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
2515
2835
  help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
@@ -2524,7 +2844,7 @@ class HyperListApp
2524
2844
  help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
2525
2845
  help_lines << help_line("#{"P".fg("10")}", "Presentation mode", "#{"Tab/S-Tab".fg("10")}", "Next/prev sibling (in P)")
2526
2846
  help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
2527
- help_lines << help_line("#{"w".fg("10")}", "Switch panes (split view)", "", "")
2847
+ help_lines << help_line("#{"w".fg("10")}", "Switch panes (split view)")
2528
2848
  help_lines << ""
2529
2849
  help_lines << "#{"FILE OPERATIONS".fg("14")}"
2530
2850
  help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
@@ -2538,6 +2858,29 @@ class HyperListApp
2538
2858
  help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":st".fg("10")}", "Save as template")
2539
2859
  help_lines << help_line("#{":dt".fg("10")}", "Delete template", "#{":lt".fg("10")}", "List user templates")
2540
2860
  help_lines << ""
2861
+
2862
+ # Try adding CONFIGURATION section with error handling
2863
+ begin
2864
+ debug_log.puts "Starting CONFIGURATION section" if debug_log
2865
+ help_lines << "#{"CONFIGURATION".fg("14")}"
2866
+ debug_log.puts "Added CONFIGURATION header" if debug_log
2867
+
2868
+ line1 = help_line("#{":set".fg("10")}", "Show all settings", "#{":set option".fg("10")}", "Show option value")
2869
+ debug_log.puts "Line1: #{line1.inspect}" if debug_log
2870
+ help_lines << line1
2871
+
2872
+ line2 = help_line("#{":set option=val".fg("10")}", "Set option")
2873
+ debug_log.puts "Line2: #{line2.inspect}" if debug_log
2874
+ help_lines << line2
2875
+
2876
+ help_lines << ""
2877
+ debug_log.puts "CONFIGURATION section complete" if debug_log
2878
+ rescue => e
2879
+ debug_log.puts "Error in CONFIGURATION: #{e.message}" if debug_log
2880
+ debug_log.puts e.backtrace.join("\n") if debug_log
2881
+ # Fall through and continue without config section
2882
+ end
2883
+
2541
2884
  help_lines << "#{"HELP & QUIT".fg("14")}"
2542
2885
  help_lines << help_line("#{"?".fg("10")}", "This help", "#{"??".fg("10")}", "Full documentation")
2543
2886
  help_lines << help_line("#{"q".fg("10")}", "Quit (asks to save)", "#{"Q".fg("10")}", "Force quit")
@@ -2552,6 +2895,9 @@ class HyperListApp
2552
2895
 
2553
2896
  help = help_lines.join("\n")
2554
2897
 
2898
+ debug_log.puts "Help generation complete, #{help_lines.length} lines" if debug_log
2899
+ debug_log.close if debug_log
2900
+
2555
2901
  # Store current state
2556
2902
  saved_items = @items.dup
2557
2903
  saved_current = @current
@@ -2605,6 +2951,18 @@ class HyperListApp
2605
2951
  @current = saved_current
2606
2952
  @offset = saved_offset
2607
2953
  @modified = saved_modified
2954
+
2955
+ rescue => e
2956
+ # Log the error and show a simple message
2957
+ File.open("/tmp/hyperlist_help_crash.log", "w") do |f|
2958
+ f.puts "Help crashed at #{Time.now}"
2959
+ f.puts "Error: #{e.message}"
2960
+ f.puts "Backtrace:"
2961
+ f.puts e.backtrace.join("\n")
2962
+ end rescue nil
2963
+
2964
+ @message = "Help error logged to /tmp/hyperlist_help_crash.log"
2965
+ end
2608
2966
  end
2609
2967
 
2610
2968
  def show_documentation
@@ -3142,6 +3500,9 @@ class HyperListApp
3142
3500
 
3143
3501
  def handle_command
3144
3502
  @mode = :command
3503
+ # Set command history (reversed for proper UP arrow navigation)
3504
+ @footer.history = @command_history.reverse
3505
+ @footer.record = true
3145
3506
  @command = @footer.ask(":", "")
3146
3507
  @mode = :normal
3147
3508
  @footer.clear # Clear footer immediately
@@ -3149,6 +3510,14 @@ class HyperListApp
3149
3510
 
3150
3511
  return unless @command
3151
3512
 
3513
+ # Add to history if not empty and not duplicate of last entry
3514
+ if !@command.empty? && (@command_history.empty? || @command_history.last != @command)
3515
+ @command_history << @command
3516
+ # Keep only last 100 commands
3517
+ @command_history = @command_history.last(100)
3518
+ save_command_history
3519
+ end
3520
+
3152
3521
  case @command
3153
3522
  when "w", "write"
3154
3523
  if @filename
@@ -3240,11 +3609,134 @@ class HyperListApp
3240
3609
  when "split"
3241
3610
  # Copy current section to split view
3242
3611
  copy_section_to_split
3612
+ when /^set\s+(\w+)=(.+)$/
3613
+ # Handle :set option=value
3614
+ option = $1
3615
+ value = $2.strip
3616
+ set_config_option(option, value)
3617
+ when /^set\s+(\w+)$/
3618
+ # Handle :set option (show current value)
3619
+ option = $1
3620
+ show_config_option(option)
3621
+ when "set"
3622
+ # Show all current settings
3623
+ show_all_config_options
3243
3624
  else
3244
3625
  @message = "Unknown command: #{@command}"
3245
3626
  end
3246
3627
  end
3247
3628
 
3629
+ def set_config_option(option, value)
3630
+ # Convert value to appropriate type
3631
+ case option
3632
+ when "theme"
3633
+ if ["light", "normal", "dark"].include?(value)
3634
+ @theme = value
3635
+ @message = "Theme set to: #{value}"
3636
+ else
3637
+ @message = "Invalid theme. Use: light, normal, or dark"
3638
+ end
3639
+ when "wrap"
3640
+ @wrap = value == "yes" || value == "true"
3641
+ @message = "Line wrapping #{@wrap ? 'enabled' : 'disabled'}"
3642
+ when "show_numbers"
3643
+ @show_numbers = value == "yes" || value == "true"
3644
+ @message = "Line numbers #{@show_numbers ? 'enabled' : 'disabled'}"
3645
+ when "fold_level"
3646
+ level = value.to_i
3647
+ if level >= 0 && level <= 99
3648
+ @fold_level = level
3649
+ apply_fold_level(level)
3650
+ @message = "Fold level set to: #{level}"
3651
+ else
3652
+ @message = "Invalid fold level. Use 0-99"
3653
+ end
3654
+ when "auto_save"
3655
+ @auto_save_enabled = value == "yes" || value == "true"
3656
+ @message = "Auto-save #{@auto_save_enabled ? 'enabled' : 'disabled'}"
3657
+ when "auto_save_interval"
3658
+ interval = value.to_i
3659
+ if interval > 0
3660
+ @auto_save_interval = interval
3661
+ @message = "Auto-save interval set to: #{interval} seconds"
3662
+ else
3663
+ @message = "Invalid interval. Must be > 0"
3664
+ end
3665
+ when "tab_width", "indent_size"
3666
+ width = value.to_i
3667
+ if width >= 2 && width <= 8
3668
+ @indent_size = width
3669
+ @message = "Tab width set to: #{width}"
3670
+ else
3671
+ @message = "Invalid tab width. Use 2-8"
3672
+ end
3673
+ else
3674
+ @message = "Unknown option: #{option}"
3675
+ end
3676
+
3677
+ # Update config line if it exists
3678
+ update_config_line
3679
+ end
3680
+
3681
+ def show_config_option(option)
3682
+ case option
3683
+ when "theme"
3684
+ @message = "theme=#{@theme}"
3685
+ when "wrap"
3686
+ @message = "wrap=#{@wrap ? 'yes' : 'no'}"
3687
+ when "show_numbers"
3688
+ @message = "show_numbers=#{@show_numbers ? 'yes' : 'no'}"
3689
+ when "fold_level"
3690
+ @message = "fold_level=#{@fold_level}"
3691
+ when "auto_save"
3692
+ @message = "auto_save=#{@auto_save_enabled ? 'yes' : 'no'}"
3693
+ when "auto_save_interval"
3694
+ @message = "auto_save_interval=#{@auto_save_interval}"
3695
+ when "tab_width", "indent_size"
3696
+ @message = "tab_width=#{@indent_size}"
3697
+ else
3698
+ @message = "Unknown option: #{option}"
3699
+ end
3700
+ end
3701
+
3702
+ def show_all_config_options
3703
+ options = []
3704
+ options << "theme=#{@theme}"
3705
+ options << "wrap=#{@wrap ? 'yes' : 'no'}"
3706
+ options << "show_numbers=#{@show_numbers ? 'yes' : 'no'}"
3707
+ options << "fold_level=#{@fold_level}"
3708
+ options << "auto_save=#{@auto_save_enabled ? 'yes' : 'no'}"
3709
+ options << "auto_save_interval=#{@auto_save_interval}"
3710
+ options << "tab_width=#{@indent_size}"
3711
+ @message = "Settings: #{options.join(', ')}"
3712
+ end
3713
+
3714
+ def update_config_line
3715
+ # Build new config line based on current settings
3716
+ # This should preserve the config line and update it with current values
3717
+ options = []
3718
+
3719
+ # Include fold_level if it's not the default
3720
+ if @fold_level != 99
3721
+ options << "fold_level=#{@fold_level}"
3722
+ end
3723
+
3724
+ options << "theme=#{@theme}" if @theme != "normal"
3725
+ options << "wrap=yes" if @wrap
3726
+ options << "show_numbers=yes" if @show_numbers
3727
+ options << "auto_save=yes" if @auto_save_enabled
3728
+ options << "auto_save_interval=#{@auto_save_interval}" if @auto_save_interval != 60
3729
+ options << "tab_width=#{@indent_size}" if @indent_size != 2
3730
+
3731
+ if options.any?
3732
+ @config_line = "((#{options.join(', ')}))"
3733
+ else
3734
+ @config_line = nil
3735
+ end
3736
+
3737
+ @modified = true
3738
+ end
3739
+
3248
3740
  def jump_to_reference
3249
3741
  visible = get_visible_items
3250
3742
  return if @current >= visible.length
@@ -4419,11 +4911,19 @@ class HyperListApp
4419
4911
  visible_items.each_with_index do |item, idx|
4420
4912
  next unless item
4421
4913
 
4422
- line = " " * item["level"]
4423
-
4424
- # Add fold indicator with colors
4425
4914
  # Find the item's position in the original split_items array
4426
4915
  real_idx = @split_items.index(item)
4916
+
4917
+ # Add line number if enabled
4918
+ line = ""
4919
+ if @show_numbers
4920
+ actual_line_number = real_idx ? real_idx + 1 : 0 # +1 for 1-based line numbers
4921
+ line = "#{actual_line_number.to_s.rjust(4)} "
4922
+ end
4923
+
4924
+ line += " " * item["level"]
4925
+
4926
+ # Add fold indicator with colors
4427
4927
  if real_idx && has_children_in_array?(real_idx, @split_items)
4428
4928
  if item["fold"]
4429
4929
  line += "▶".fg("245") + " "
@@ -4959,6 +5459,7 @@ class HyperListApp
4959
5459
  end
4960
5460
 
4961
5461
  def quit
5462
+ save_command_history
4962
5463
  Cursor.show
4963
5464
  Rcurses.clear_screen
4964
5465
  exit