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