hyperlist 1.0.0
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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +24 -0
- data/README.md +216 -0
- data/hyperlist +3874 -0
- data/hyperlist.gemspec +36 -0
- data/sample.hl +83 -0
- data/screenshot_help.png +0 -0
- data/screenshot_sample.png +0 -0
- data/test.hl +169 -0
- metadata +103 -0
data/hyperlist
ADDED
@@ -0,0 +1,3874 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# encoding: utf-8
|
3
|
+
|
4
|
+
# HyperList TUI - A terminal interface for HyperList files
|
5
|
+
# Based on hyperlist.vim by Geir Isene
|
6
|
+
|
7
|
+
# Check for help/version BEFORE loading any libraries
|
8
|
+
if ARGV[0] == '-h' || ARGV[0] == '--help'
|
9
|
+
puts <<~HELP
|
10
|
+
HyperList v1.0.1 - Terminal User Interface for HyperList files
|
11
|
+
|
12
|
+
USAGE
|
13
|
+
hyperlist [OPTIONS] [FILE]
|
14
|
+
|
15
|
+
OPTIONS
|
16
|
+
-h, --help Show this help message
|
17
|
+
-v, --version Show version information
|
18
|
+
|
19
|
+
ARGUMENTS
|
20
|
+
FILE Optional HyperList file to open (.hl extension)
|
21
|
+
If not provided, starts with a new empty HyperList
|
22
|
+
|
23
|
+
EXAMPLES
|
24
|
+
hyperlist Start with a new HyperList
|
25
|
+
hyperlist todo.hl Open todo.hl
|
26
|
+
hyperlist ~/lists/work.hl Open work.hl from home directory
|
27
|
+
|
28
|
+
KEY COMMANDS
|
29
|
+
? Show key bindings help
|
30
|
+
?? Show full HyperList documentation
|
31
|
+
q Quit (with save prompt if modified)
|
32
|
+
:w Save file
|
33
|
+
:q Quit
|
34
|
+
:wq Save and quit
|
35
|
+
|
36
|
+
FEATURES
|
37
|
+
• Full HyperList syntax support with color highlighting
|
38
|
+
• Folding/expanding of hierarchical items
|
39
|
+
• Search and navigation
|
40
|
+
• Checkboxes with date tracking
|
41
|
+
• Templates for common list structures
|
42
|
+
• Macro recording and playback
|
43
|
+
• Split view for working with multiple sections
|
44
|
+
• Export to Markdown, HTML, plain text, and PNG graphs
|
45
|
+
• Auto-save capability
|
46
|
+
• Multi-level undo
|
47
|
+
• Vim-style marks and jumping
|
48
|
+
|
49
|
+
For more information and HyperList documentation, visit:
|
50
|
+
https://github.com/isene/hyperlist
|
51
|
+
|
52
|
+
HELP
|
53
|
+
exit 0
|
54
|
+
elsif ARGV[0] == '-v' || ARGV[0] == '--version'
|
55
|
+
puts "HyperList v1.0.1"
|
56
|
+
exit 0
|
57
|
+
end
|
58
|
+
|
59
|
+
# Only load libraries if we're actually running the app
|
60
|
+
require 'io/console'
|
61
|
+
require 'date'
|
62
|
+
require 'rcurses'
|
63
|
+
require 'cgi'
|
64
|
+
|
65
|
+
class HyperListApp
|
66
|
+
include Rcurses
|
67
|
+
include Rcurses::Input
|
68
|
+
include Rcurses::Cursor
|
69
|
+
|
70
|
+
VERSION = "1.0.1"
|
71
|
+
|
72
|
+
def initialize(filename = nil)
|
73
|
+
@filename = filename ? File.expand_path(filename) : nil
|
74
|
+
@items = []
|
75
|
+
@current = 0
|
76
|
+
@offset = 0
|
77
|
+
@modified = false
|
78
|
+
@mode = :normal
|
79
|
+
@message = ""
|
80
|
+
@search = ""
|
81
|
+
@search_matches = [] # Track search match positions
|
82
|
+
@fold_level = 99
|
83
|
+
@clipboard = nil
|
84
|
+
@undo_stack = []
|
85
|
+
@undo_position = [] # Stack of cursor positions for undo
|
86
|
+
@redo_stack = []
|
87
|
+
@redo_position = [] # Stack of cursor positions for redo
|
88
|
+
@last_action = nil # Store the last editing action for '.' repeat
|
89
|
+
@last_action_type = nil # Type of last action
|
90
|
+
@processed_cache = {} # Cache for processed text
|
91
|
+
@presentation_mode = false # Presentation mode flag
|
92
|
+
@last_key = nil # Track last key for double-key combos
|
93
|
+
@last_rendered_lines = [] # Track last rendered lines
|
94
|
+
@recent_files_path = File.expand_path("~/.hyperlist_recent")
|
95
|
+
@auto_save_enabled = false
|
96
|
+
@auto_save_interval = 60 # seconds
|
97
|
+
@last_auto_save = Time.now
|
98
|
+
@templates = load_templates
|
99
|
+
@macro_recording = false
|
100
|
+
@macro_register = {} # Store macros by key
|
101
|
+
@macro_buffer = [] # Current macro being recorded
|
102
|
+
@macro_key = nil # Key for current macro
|
103
|
+
@split_view = false
|
104
|
+
@split_items = [] # Second view items
|
105
|
+
@split_current = 0 # Second view cursor
|
106
|
+
@split_offset = 0 # Second view scroll offset
|
107
|
+
@active_pane = :main # :main or :split
|
108
|
+
@message_timeout = nil # For timed message display
|
109
|
+
|
110
|
+
# Terminal setup
|
111
|
+
if IO.console
|
112
|
+
@rows, @cols = IO.console.winsize
|
113
|
+
else
|
114
|
+
@rows = ENV['LINES']&.to_i || 24
|
115
|
+
@cols = ENV['COLUMNS']&.to_i || 80
|
116
|
+
end
|
117
|
+
|
118
|
+
# Debug: uncomment to see terminal size
|
119
|
+
# puts "Terminal size: #{@rows}x#{@cols}"
|
120
|
+
|
121
|
+
# Load file if provided
|
122
|
+
if @filename && File.exist?(@filename)
|
123
|
+
load_file(@filename)
|
124
|
+
else
|
125
|
+
# Start with empty list
|
126
|
+
@items = [{"text" => "New HyperList", "level" => 0, "fold" => false}]
|
127
|
+
end
|
128
|
+
|
129
|
+
setup_ui
|
130
|
+
end
|
131
|
+
|
132
|
+
def setup_ui
|
133
|
+
Rcurses.clear_screen
|
134
|
+
Cursor.hide
|
135
|
+
|
136
|
+
# Ensure we have the latest terminal size
|
137
|
+
if IO.console
|
138
|
+
@rows, @cols = IO.console.winsize
|
139
|
+
else
|
140
|
+
@rows = ENV['LINES']&.to_i || 24
|
141
|
+
@cols = ENV['COLUMNS']&.to_i || 80
|
142
|
+
end
|
143
|
+
|
144
|
+
if @split_view
|
145
|
+
# Split view layout - no header, more space for content
|
146
|
+
split_width = @cols / 2
|
147
|
+
# Main content panes - full height minus 1 for footer
|
148
|
+
@main = Pane.new(0, 0, split_width - 1, @rows - 1, 15, 0)
|
149
|
+
@split_pane = Pane.new(split_width + 1, 0, split_width - 1, @rows - 1, 15, 0)
|
150
|
+
# Footer: Use @rows for y-position (this puts it at the actual bottom)
|
151
|
+
@footer = Pane.new(0, @rows, @cols, 1, 15, 8)
|
152
|
+
|
153
|
+
# Add separator
|
154
|
+
@separator = Pane.new(split_width, 0, 1, @rows - 1, 15, 8)
|
155
|
+
@separator.text = "│" * (@rows - 1)
|
156
|
+
@separator.refresh
|
157
|
+
else
|
158
|
+
# Single view layout - no header, more space for content
|
159
|
+
# Main pane uses full height minus 1 for footer
|
160
|
+
@main = Pane.new(0, 0, @cols, @rows - 1, 15, 0)
|
161
|
+
# Footer: Use @rows for y-position (this puts it at the actual bottom)
|
162
|
+
@footer = Pane.new(0, @rows, @cols, 1, 15, 8)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def load_file(file)
|
167
|
+
@items = []
|
168
|
+
lines = File.readlines(file) rescue []
|
169
|
+
|
170
|
+
# Clear undo/redo stacks when loading a new file
|
171
|
+
@undo_stack = []
|
172
|
+
@undo_position = []
|
173
|
+
@redo_stack = []
|
174
|
+
@redo_position = []
|
175
|
+
|
176
|
+
# Check if file is large
|
177
|
+
large_file = lines.length > 10000
|
178
|
+
|
179
|
+
if large_file
|
180
|
+
@message = "Loading large file (#{lines.length} lines)..."
|
181
|
+
render_footer
|
182
|
+
end
|
183
|
+
|
184
|
+
# Process lines with optional progress updates for large files
|
185
|
+
lines.each_with_index do |line, idx|
|
186
|
+
next if line.strip.empty?
|
187
|
+
level = line[/^\t*/].length
|
188
|
+
text = line.strip
|
189
|
+
@items << {"text" => text, "level" => level, "fold" => false}
|
190
|
+
|
191
|
+
# Update progress for large files
|
192
|
+
if large_file && idx % 1000 == 0
|
193
|
+
progress = (idx.to_f / lines.length * 100).to_i
|
194
|
+
@message = "Loading... #{progress}%"
|
195
|
+
render_footer
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
@items = [{"text" => "Empty file", "level" => 0, "fold" => false}] if @items.empty?
|
200
|
+
|
201
|
+
# Auto-fold deep levels for large files
|
202
|
+
if large_file
|
203
|
+
auto_fold_deep_levels(3) # Auto-fold everything deeper than level 3
|
204
|
+
@message = "Large file loaded. Deep levels auto-folded for performance."
|
205
|
+
end
|
206
|
+
|
207
|
+
# Update recent files list
|
208
|
+
add_to_recent_files(File.expand_path(file)) if file
|
209
|
+
end
|
210
|
+
|
211
|
+
def auto_fold_deep_levels(max_level)
|
212
|
+
@items.each_with_index do |item, idx|
|
213
|
+
if item["level"] >= max_level && has_children?(idx, @items)
|
214
|
+
item["fold"] = true
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def add_to_recent_files(filepath)
|
220
|
+
return unless filepath && File.exist?(filepath)
|
221
|
+
|
222
|
+
# Load existing recent files
|
223
|
+
recent = load_recent_files
|
224
|
+
|
225
|
+
# Remove if already exists and add to front
|
226
|
+
recent.delete(filepath)
|
227
|
+
recent.unshift(filepath)
|
228
|
+
|
229
|
+
# Keep only last 20 files
|
230
|
+
recent = recent[0...20]
|
231
|
+
|
232
|
+
# Save back to file
|
233
|
+
File.open(@recent_files_path, 'w') do |f|
|
234
|
+
recent.each { |path| f.puts(path) }
|
235
|
+
end
|
236
|
+
rescue => e
|
237
|
+
# Silently fail if can't write recent files
|
238
|
+
end
|
239
|
+
|
240
|
+
def load_recent_files
|
241
|
+
return [] unless File.exist?(@recent_files_path)
|
242
|
+
|
243
|
+
File.readlines(@recent_files_path).map(&:strip).select do |path|
|
244
|
+
File.exist?(path)
|
245
|
+
end
|
246
|
+
rescue
|
247
|
+
[]
|
248
|
+
end
|
249
|
+
|
250
|
+
def show_recent_files
|
251
|
+
recent = load_recent_files
|
252
|
+
|
253
|
+
if recent.empty?
|
254
|
+
@message = "No recent files"
|
255
|
+
return
|
256
|
+
end
|
257
|
+
|
258
|
+
# Save current state
|
259
|
+
saved_items = @items.dup
|
260
|
+
saved_current = @current
|
261
|
+
saved_offset = @offset
|
262
|
+
saved_filename = @filename
|
263
|
+
saved_modified = @modified
|
264
|
+
|
265
|
+
# Create items for recent files display
|
266
|
+
@items = []
|
267
|
+
@items << {"text" => "RECENT FILES (press Enter to open, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
|
268
|
+
@items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
|
269
|
+
|
270
|
+
recent.each_with_index do |path, idx|
|
271
|
+
display_path = path.sub(ENV['HOME'], '~') if ENV['HOME']
|
272
|
+
display_path ||= path
|
273
|
+
@items << {"text" => "#{idx+1}. #{display_path}", "level" => 0, "fold" => false, "path" => path, "raw" => true}
|
274
|
+
end
|
275
|
+
|
276
|
+
@current = 2 # Start at first file
|
277
|
+
@offset = 0
|
278
|
+
@modified = false
|
279
|
+
|
280
|
+
# Recent files viewer loop
|
281
|
+
loop do
|
282
|
+
render_main
|
283
|
+
@footer.text = "Recent Files | Enter: open | q: cancel | j/k: navigate"
|
284
|
+
@footer.refresh
|
285
|
+
|
286
|
+
c = getchr
|
287
|
+
case c
|
288
|
+
when "q", "ESC"
|
289
|
+
# Restore original state
|
290
|
+
@items = saved_items
|
291
|
+
@current = saved_current
|
292
|
+
@offset = saved_offset
|
293
|
+
@filename = saved_filename
|
294
|
+
@modified = saved_modified
|
295
|
+
break
|
296
|
+
when "j", "DOWN"
|
297
|
+
move_down if @current < @items.length - 1
|
298
|
+
when "k", "UP"
|
299
|
+
move_up if @current > 2 # Don't go above first file
|
300
|
+
when "ENTER", "l"
|
301
|
+
if @current >= 2 && @items[@current]["path"]
|
302
|
+
selected_file = @items[@current]["path"]
|
303
|
+
if saved_modified
|
304
|
+
@items = saved_items
|
305
|
+
@current = saved_current
|
306
|
+
@offset = saved_offset
|
307
|
+
@filename = saved_filename
|
308
|
+
@modified = saved_modified
|
309
|
+
@message = "Unsaved changes! Save first before opening another file"
|
310
|
+
break
|
311
|
+
else
|
312
|
+
@filename = File.expand_path(selected_file)
|
313
|
+
load_file(@filename)
|
314
|
+
@current = 0
|
315
|
+
@offset = 0
|
316
|
+
@modified = false
|
317
|
+
break
|
318
|
+
end
|
319
|
+
end
|
320
|
+
when /^[1-9]$/
|
321
|
+
# Allow number key selection
|
322
|
+
idx = c.to_i - 1
|
323
|
+
if idx < recent.length
|
324
|
+
selected_file = recent[idx]
|
325
|
+
if saved_modified
|
326
|
+
@items = saved_items
|
327
|
+
@current = saved_current
|
328
|
+
@offset = saved_offset
|
329
|
+
@filename = saved_filename
|
330
|
+
@modified = saved_modified
|
331
|
+
@message = "Unsaved changes! Save first before opening another file"
|
332
|
+
break
|
333
|
+
else
|
334
|
+
@filename = File.expand_path(selected_file)
|
335
|
+
load_file(@filename)
|
336
|
+
@current = 0
|
337
|
+
@offset = 0
|
338
|
+
@modified = false
|
339
|
+
break
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
def save_file
|
347
|
+
return unless @filename
|
348
|
+
|
349
|
+
File.open(@filename, 'w') do |f|
|
350
|
+
@items.each do |item|
|
351
|
+
f.puts("\t" * item["level"] + item["text"])
|
352
|
+
end
|
353
|
+
end
|
354
|
+
@modified = false
|
355
|
+
@message = "Saved to #{@filename}"
|
356
|
+
@last_auto_save = Time.now if @auto_save_enabled
|
357
|
+
end
|
358
|
+
|
359
|
+
def check_auto_save
|
360
|
+
return unless @filename && @modified
|
361
|
+
|
362
|
+
if Time.now - @last_auto_save >= @auto_save_interval
|
363
|
+
save_file
|
364
|
+
@message = "Auto-saved to #{@filename}"
|
365
|
+
@last_auto_save = Time.now
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def export_to(format, export_file = nil)
|
370
|
+
# Generate default filename if not provided
|
371
|
+
if export_file.nil? || export_file.empty?
|
372
|
+
base = @filename ? File.basename(@filename, '.*') : 'hyperlist'
|
373
|
+
extension = case format
|
374
|
+
when 'md', 'markdown' then '.md'
|
375
|
+
when 'html' then '.html'
|
376
|
+
when 'txt', 'text' then '.txt'
|
377
|
+
end
|
378
|
+
export_file = base + extension
|
379
|
+
end
|
380
|
+
|
381
|
+
content = case format
|
382
|
+
when 'md', 'markdown'
|
383
|
+
export_to_markdown
|
384
|
+
when 'html'
|
385
|
+
export_to_html
|
386
|
+
when 'txt', 'text'
|
387
|
+
export_to_text
|
388
|
+
end
|
389
|
+
|
390
|
+
File.open(export_file, 'w') { |f| f.write(content) }
|
391
|
+
@message = "Exported to #{export_file}"
|
392
|
+
rescue => e
|
393
|
+
@message = "Export failed: #{e.message}"
|
394
|
+
end
|
395
|
+
|
396
|
+
def export_to_markdown
|
397
|
+
lines = []
|
398
|
+
@items.each do |item|
|
399
|
+
text = item["text"]
|
400
|
+
level = item["level"]
|
401
|
+
|
402
|
+
# Convert HyperList bold/italic to Markdown format
|
403
|
+
# In HyperList: *text* is bold, /text/ is italic, _text_ is underline
|
404
|
+
# In Markdown: **text** is bold, *text* is italic, no standard underline
|
405
|
+
|
406
|
+
# First, temporarily replace escaped characters to avoid conflicts
|
407
|
+
text = text.gsub('\\*', '\u0001')
|
408
|
+
.gsub('\\/', '\u0002')
|
409
|
+
.gsub('\\_', '\u0003')
|
410
|
+
|
411
|
+
# Convert HyperList formatting to Markdown
|
412
|
+
# Bold: *text* -> **text**
|
413
|
+
text = text.gsub(/\*([^*]+)\*/, '**\1**')
|
414
|
+
|
415
|
+
# Italic: /text/ -> *text*
|
416
|
+
text = text.gsub(/\/([^\/]+)\//, '*\1*')
|
417
|
+
|
418
|
+
# Underline: _text_ -> ***text*** (bold+italic as markdown doesn't have underline)
|
419
|
+
text = text.gsub(/_([^_]+)_/, '***\1***')
|
420
|
+
|
421
|
+
# Restore escaped characters
|
422
|
+
text = text.gsub('\u0001', '\\*')
|
423
|
+
.gsub('\u0002', '\\/')
|
424
|
+
.gsub('\u0003', '\\_')
|
425
|
+
|
426
|
+
# Convert checkboxes to markdown format
|
427
|
+
text = text.gsub(/^\[X\]/i, '- [x]')
|
428
|
+
.gsub(/^\[O\]/, '- [ ] *(in progress)*')
|
429
|
+
.gsub(/^\[-\]/, '- [ ] *(partial)*')
|
430
|
+
.gsub(/^\[ \]/, '- [ ]')
|
431
|
+
.gsub(/^\[_\]/, '- [ ]')
|
432
|
+
|
433
|
+
# Handle different levels with proper markdown indentation
|
434
|
+
if level == 0 && !text.start_with?('- [')
|
435
|
+
lines << "# #{text}" unless text.empty?
|
436
|
+
elsif level == 1 && !text.start_with?('- [')
|
437
|
+
lines << "## #{text}" unless text.empty?
|
438
|
+
else
|
439
|
+
# Use spaces for indentation in lists
|
440
|
+
indent = ' ' * [level - 2, 0].max
|
441
|
+
if text.start_with?('- [')
|
442
|
+
lines << indent + text
|
443
|
+
else
|
444
|
+
lines << indent + "- #{text}"
|
445
|
+
end
|
446
|
+
end
|
447
|
+
end
|
448
|
+
lines.join("\n")
|
449
|
+
end
|
450
|
+
|
451
|
+
def export_to_html
|
452
|
+
html = <<~HTML
|
453
|
+
<!DOCTYPE html>
|
454
|
+
<html>
|
455
|
+
<head>
|
456
|
+
<meta charset="UTF-8">
|
457
|
+
<title>#{@filename ? File.basename(@filename, '.*') : 'HyperList'}</title>
|
458
|
+
<style>
|
459
|
+
body {
|
460
|
+
font-family: 'Courier New', monospace;
|
461
|
+
margin: 20px;
|
462
|
+
background-color: white;
|
463
|
+
color: #333;
|
464
|
+
white-space: pre;
|
465
|
+
line-height: 1.3;
|
466
|
+
}
|
467
|
+
/* Based on HyperList LaTeX color definitions */
|
468
|
+
.checked { color: #00cc00; } /* bright green */
|
469
|
+
.in-progress { color: #00cc00; } /* bright green */
|
470
|
+
.partial { color: #008800; } /* medium green */
|
471
|
+
.unchecked { color: #004400; } /* dark green */
|
472
|
+
.qualifier { color: #008000; } /* green */
|
473
|
+
.operator { color: #0000cc; font-weight: bold; } /* blue */
|
474
|
+
.property { color: #cc0000; } /* red */
|
475
|
+
.reference { color: #800080; } /* violet */
|
476
|
+
.tag { color: #cc9900; } /* orange */
|
477
|
+
.timestamp { color: #cc0000; } /* red - it's a property */
|
478
|
+
.comment { color: #008080; font-style: italic; } /* turquoise */
|
479
|
+
strong { font-weight: bold; }
|
480
|
+
em { font-style: italic; }
|
481
|
+
u { text-decoration: underline; }
|
482
|
+
</style>
|
483
|
+
</head>
|
484
|
+
<body>
|
485
|
+
HTML
|
486
|
+
|
487
|
+
@items.each do |item|
|
488
|
+
original_text = item["text"]
|
489
|
+
level = item["level"]
|
490
|
+
indent = " " * level
|
491
|
+
|
492
|
+
# Comments (start with semicolon)
|
493
|
+
if original_text.start_with?(';')
|
494
|
+
# Escape HTML for comments
|
495
|
+
text = original_text.gsub('&', '&')
|
496
|
+
.gsub('<', '<')
|
497
|
+
.gsub('>', '>')
|
498
|
+
.gsub('"', '"')
|
499
|
+
.gsub("'", ''')
|
500
|
+
html += indent + '<span class="comment">' + text + '</span>' + "\n"
|
501
|
+
else
|
502
|
+
# Process non-comment lines
|
503
|
+
# We'll apply formatting BEFORE escaping HTML entities
|
504
|
+
text = original_text
|
505
|
+
|
506
|
+
# Apply all formatting with placeholder markers
|
507
|
+
# Use unique markers that won't appear in normal text
|
508
|
+
|
509
|
+
# Markdown formatting MUST come first before other replacements
|
510
|
+
# Bold *text*
|
511
|
+
text = text.gsub(/\*([^*]+)\*/) do
|
512
|
+
"\u0001BOLDSTART\u0002#{$1}\u0001BOLDEND\u0002"
|
513
|
+
end
|
514
|
+
|
515
|
+
# Italic /text/
|
516
|
+
text = text.gsub(/\/([^\/]+)\//) do
|
517
|
+
"\u0001ITALICSTART\u0002#{$1}\u0001ITALICEND\u0002"
|
518
|
+
end
|
519
|
+
|
520
|
+
# Underline _text_
|
521
|
+
text = text.gsub(/_([^_]+)_/) do
|
522
|
+
"\u0001UNDERSTART\u0002#{$1}\u0001UNDEREND\u0002"
|
523
|
+
end
|
524
|
+
|
525
|
+
# Checkboxes at start
|
526
|
+
text = text.sub(/^\[X\]/i, "\u0001CBCHECKED\u0002")
|
527
|
+
.sub(/^\[O\]/, "\u0001CBPROGRESS\u0002")
|
528
|
+
.sub(/^\[-\]/, "\u0001CBPARTIAL\u0002")
|
529
|
+
.sub(/^\[ \]/, "\u0001CBUNCHECKED\u0002")
|
530
|
+
.sub(/^\[_\]/, "\u0001CBUNCHECKED2\u0002")
|
531
|
+
|
532
|
+
# Timestamps
|
533
|
+
text = text.gsub(/(\d{4}-\d{2}-\d{2} \d{2}\.\d{2}):/) do
|
534
|
+
"\u0001TSSTART\u0002#{$1}\u0001TSEND\u0002:"
|
535
|
+
end
|
536
|
+
|
537
|
+
# Qualifiers [text] - but not checkboxes
|
538
|
+
unless text.include?("\u0001CB")
|
539
|
+
text = text.gsub(/\[([^\]]+)\]/) do
|
540
|
+
"\u0001QSTART\u0002[#{$1}]\u0001QEND\u0002"
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
# References <text>
|
545
|
+
text = text.gsub(/<([^>]+)>/) do
|
546
|
+
"\u0001REFSTART\u0002<#{$1}>\u0001REFEND\u0002"
|
547
|
+
end
|
548
|
+
|
549
|
+
# Properties (Word: )
|
550
|
+
text = text.gsub(/\b([A-Z][a-z][a-zA-Z]*): /) do
|
551
|
+
"\u0001PROPSTART\u0002#{$1}: \u0001PROPEND\u0002"
|
552
|
+
end
|
553
|
+
|
554
|
+
# Operators (CAPS:)
|
555
|
+
text = text.gsub(/\b([A-Z][A-Z]+):/) do
|
556
|
+
"\u0001OPSTART\u0002#{$1}:\u0001OPEND\u0002"
|
557
|
+
end
|
558
|
+
|
559
|
+
# Hash tags
|
560
|
+
text = text.gsub(/#(\w+)/) do
|
561
|
+
"\u0001TAGSTART\u0002##{$1}\u0001TAGEND\u0002"
|
562
|
+
end
|
563
|
+
|
564
|
+
# Now escape HTML entities
|
565
|
+
text = text.gsub('&', '&')
|
566
|
+
.gsub('<', '<')
|
567
|
+
.gsub('>', '>')
|
568
|
+
.gsub('"', '"')
|
569
|
+
.gsub("'", ''')
|
570
|
+
|
571
|
+
# Replace markers with HTML tags
|
572
|
+
text = text.gsub("\u0001CBCHECKED\u0002", '<span class="checked">[X]</span>')
|
573
|
+
.gsub("\u0001CBPROGRESS\u0002", '<span class="in-progress">[O]</span>')
|
574
|
+
.gsub("\u0001CBPARTIAL\u0002", '<span class="partial">[-]</span>')
|
575
|
+
.gsub("\u0001CBUNCHECKED\u0002", '<span class="unchecked">[ ]</span>')
|
576
|
+
.gsub("\u0001CBUNCHECKED2\u0002", '<span class="unchecked">[_]</span>')
|
577
|
+
.gsub("\u0001TSSTART\u0002", '<span class="timestamp">')
|
578
|
+
.gsub("\u0001TSEND\u0002", '</span>')
|
579
|
+
.gsub("\u0001QSTART\u0002", '<span class="qualifier">')
|
580
|
+
.gsub("\u0001QEND\u0002", '</span>')
|
581
|
+
.gsub("\u0001REFSTART\u0002", '<span class="reference">')
|
582
|
+
.gsub("\u0001REFEND\u0002", '</span>')
|
583
|
+
.gsub("\u0001PROPSTART\u0002", '<span class="property">')
|
584
|
+
.gsub("\u0001PROPEND\u0002", '</span>')
|
585
|
+
.gsub("\u0001OPSTART\u0002", '<span class="operator">')
|
586
|
+
.gsub("\u0001OPEND\u0002", '</span>')
|
587
|
+
.gsub("\u0001TAGSTART\u0002", '<span class="tag">')
|
588
|
+
.gsub("\u0001TAGEND\u0002", '</span>')
|
589
|
+
.gsub("\u0001BOLDSTART\u0002", '<strong>')
|
590
|
+
.gsub("\u0001BOLDEND\u0002", '</strong>')
|
591
|
+
.gsub("\u0001ITALICSTART\u0002", '<em>')
|
592
|
+
.gsub("\u0001ITALICEND\u0002", '</em>')
|
593
|
+
.gsub("\u0001UNDERSTART\u0002", '<u>')
|
594
|
+
.gsub("\u0001UNDEREND\u0002", '</u>')
|
595
|
+
|
596
|
+
html += indent + text + "\n"
|
597
|
+
end
|
598
|
+
end
|
599
|
+
|
600
|
+
html += "</body>\n</html>"
|
601
|
+
html
|
602
|
+
end
|
603
|
+
|
604
|
+
def export_to_text
|
605
|
+
lines = []
|
606
|
+
@items.each do |item|
|
607
|
+
indent = ' ' * item["level"] # 4 spaces per level
|
608
|
+
lines << indent + item["text"]
|
609
|
+
end
|
610
|
+
lines.join("\n")
|
611
|
+
end
|
612
|
+
|
613
|
+
def render
|
614
|
+
render_main
|
615
|
+
render_split_pane if @split_view
|
616
|
+
render_footer
|
617
|
+
end
|
618
|
+
|
619
|
+
|
620
|
+
def render_main
|
621
|
+
visible_items = get_visible_items
|
622
|
+
|
623
|
+
# Calculate window
|
624
|
+
view_height = @main.h
|
625
|
+
if @current < @offset
|
626
|
+
@offset = @current
|
627
|
+
elsif @current >= @offset + view_height
|
628
|
+
@offset = @current - view_height + 1
|
629
|
+
end
|
630
|
+
|
631
|
+
# Track if we're in a literal block
|
632
|
+
in_literal_block = false
|
633
|
+
literal_start_level = -1
|
634
|
+
|
635
|
+
# Build display lines only for visible portion
|
636
|
+
lines = []
|
637
|
+
start_idx = @offset
|
638
|
+
end_idx = [@offset + view_height, visible_items.length].min
|
639
|
+
|
640
|
+
# For very large files, limit cache size and clear old entries
|
641
|
+
if visible_items.length > 5000 && @processed_cache.size > 2000
|
642
|
+
# Keep only recent entries
|
643
|
+
@processed_cache.clear
|
644
|
+
end
|
645
|
+
|
646
|
+
# Only process visible items
|
647
|
+
(start_idx...end_idx).each do |idx|
|
648
|
+
item = visible_items[idx]
|
649
|
+
next unless item
|
650
|
+
|
651
|
+
# Create cache key for this item
|
652
|
+
cache_key = "#{item['text']}_#{item['level']}_#{item['fold']}"
|
653
|
+
|
654
|
+
# Check cache first
|
655
|
+
if @processed_cache[cache_key] && idx != @current
|
656
|
+
lines << @processed_cache[cache_key]
|
657
|
+
else
|
658
|
+
line = " " * item["level"] # 4 spaces per level
|
659
|
+
|
660
|
+
# Add fold indicator
|
661
|
+
real_idx = @items.index(item) # Get the real index in the full array
|
662
|
+
if real_idx && has_children?(real_idx, @items) && item["fold"]
|
663
|
+
line += "▶".fg("245") + " " # Gray triangle for collapsed (has hidden children)
|
664
|
+
elsif real_idx && has_children?(real_idx, @items)
|
665
|
+
line += "▷".fg("245") + " " # Gray triangle for expanded (has visible children)
|
666
|
+
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
|
+
line += item["text"]
|
693
|
+
else
|
694
|
+
# Normal text - apply syntax highlighting with caching
|
695
|
+
# Check if this line has a search match
|
696
|
+
has_match = @search_matches.include?(idx) && @search && !@search.empty?
|
697
|
+
|
698
|
+
# Check if this is raw text (for help/documentation screens)
|
699
|
+
if item["raw"]
|
700
|
+
line += item["text"]
|
701
|
+
else
|
702
|
+
processed_text_key = has_match ? "search_#{item['text']}_#{@search}" : "text_#{item['text']}"
|
703
|
+
if @processed_cache[processed_text_key] && !has_match
|
704
|
+
line += @processed_cache[processed_text_key]
|
705
|
+
else
|
706
|
+
processed = process_text(item["text"], has_match)
|
707
|
+
@processed_cache[processed_text_key] = processed if @processed_cache.size < 1000 && !has_match
|
708
|
+
line += processed
|
709
|
+
end
|
710
|
+
end
|
711
|
+
end
|
712
|
+
|
713
|
+
# Cache the line (without highlight)
|
714
|
+
@processed_cache[cache_key] = line if idx != @current
|
715
|
+
|
716
|
+
# Highlight current line
|
717
|
+
if idx == @current
|
718
|
+
line = line.r # Always use reverse (white background) for current line
|
719
|
+
end
|
720
|
+
|
721
|
+
lines << line
|
722
|
+
end
|
723
|
+
end
|
724
|
+
|
725
|
+
# Only update if content changed
|
726
|
+
new_content = lines.join("\n")
|
727
|
+
if @last_rendered_content != new_content
|
728
|
+
@main.text = new_content
|
729
|
+
@main.refresh
|
730
|
+
@last_rendered_content = new_content
|
731
|
+
end
|
732
|
+
end
|
733
|
+
|
734
|
+
def process_text(text, highlight_search = false)
|
735
|
+
# Work with a clean copy
|
736
|
+
result = text.dup
|
737
|
+
processed_checkbox = false
|
738
|
+
|
739
|
+
# If text already contains ANSI codes, return as-is to avoid double-processing
|
740
|
+
if result.include?("\e[")
|
741
|
+
return result
|
742
|
+
end
|
743
|
+
|
744
|
+
# Check if this is a literal block marker (single backslash)
|
745
|
+
if result.strip == "\\"
|
746
|
+
return result.fg("3") # Yellow for literal block markers
|
747
|
+
end
|
748
|
+
|
749
|
+
# Apply search highlighting if we have an active search
|
750
|
+
if highlight_search && @search && !@search.empty?
|
751
|
+
# Find all occurrences of the search term (case insensitive)
|
752
|
+
search_regex = Regexp.new(Regexp.escape(@search), Regexp::IGNORECASE)
|
753
|
+
result.gsub!(search_regex) { |match| match.bg("226").fg("0") } # Yellow background, black text
|
754
|
+
end
|
755
|
+
|
756
|
+
# Handle identifiers at the beginning (like "1.1.1.1" or "1A1A")
|
757
|
+
# Based on hyperlist.vim: '^\(\t\|\*\)*[0-9.]* '
|
758
|
+
if result =~ /^([0-9][0-9A-Z.]*\s)/
|
759
|
+
identifier = $1
|
760
|
+
result = result.sub(/^[0-9][0-9A-Z.]*\s/, identifier.fg("5")) # Magenta for identifiers
|
761
|
+
end
|
762
|
+
|
763
|
+
# Handle multi-line indicator at the beginning (+ with space)
|
764
|
+
# Based on hyperlist.vim: '^\(\t\|\*\)*+ '
|
765
|
+
if result =~ /^\+\s/
|
766
|
+
result = result.sub(/^(\+\s)/, "+".fg("1") + " ") # Red for multi-line indicator
|
767
|
+
end
|
768
|
+
|
769
|
+
# Handle continuation markers (+ at start of indented lines in References section)
|
770
|
+
if result =~ /^\s*\+\s/
|
771
|
+
spaces = $1 || ""
|
772
|
+
marker = $2 || "+ "
|
773
|
+
result = result.sub(/^(\s*)(\+\s)/, spaces + marker.fg("1")) # Red for continuation marker
|
774
|
+
end
|
775
|
+
|
776
|
+
# Process checkboxes anywhere in the line (can have leading spaces)
|
777
|
+
if result =~ /^(\s*)(\[X\]|\[x\])/
|
778
|
+
spaces = $1
|
779
|
+
colored = "[X]".fg("10")
|
780
|
+
result = result.sub(/^(\s*)(\[X\]|\[x\])/, "#{spaces}#{colored}") # Bright green for completed
|
781
|
+
processed_checkbox = true
|
782
|
+
elsif result =~ /^(\s*)(\[O\])/
|
783
|
+
spaces = $1
|
784
|
+
colored = "[O]".fg("10").b
|
785
|
+
result = result.sub(/^(\s*)(\[O\])/, "#{spaces}#{colored}") # Bold bright green for in-progress
|
786
|
+
processed_checkbox = true
|
787
|
+
elsif result =~ /^(\s*)(\[-\])/
|
788
|
+
spaces = $1
|
789
|
+
colored = "[-]".fg("2")
|
790
|
+
result = result.sub(/^(\s*)(\[-\])/, "#{spaces}#{colored}") # Green for partial
|
791
|
+
processed_checkbox = true
|
792
|
+
elsif result =~ /^(\s*)(\[ \]|\[_\])/
|
793
|
+
spaces = $1
|
794
|
+
colored = "[ ]".fg("22")
|
795
|
+
result = result.sub(/^(\s*)(\[ \]|\[_\])/, "#{spaces}#{colored}") # Dark green for unchecked
|
796
|
+
processed_checkbox = true
|
797
|
+
elsif !processed_checkbox
|
798
|
+
# Only handle other qualifiers if we didn't process a checkbox
|
799
|
+
# Based on hyperlist.vim: '\[.\{-}\]'
|
800
|
+
result.gsub!(/\[([^\]]*)\]/) { "[#{$1}]".fg("2") } # Green for all qualifiers
|
801
|
+
end
|
802
|
+
|
803
|
+
# We'll handle parentheses AFTER operators/properties to avoid conflicts
|
804
|
+
|
805
|
+
# Handle date timestamps as properties (for checkbox dates)
|
806
|
+
# Format: YYYY-MM-DD HH.MM:
|
807
|
+
result.gsub!(/(\d{4}-\d{2}-\d{2} \d{2}\.\d{2}):/) do
|
808
|
+
"#{$1}:".fg("1") # Red for timestamp properties
|
809
|
+
end
|
810
|
+
|
811
|
+
# Handle operators and properties with colon pattern
|
812
|
+
# Operators: ALL-CAPS followed by colon (with or without space)
|
813
|
+
# Properties: Mixed case followed by colon and space
|
814
|
+
result.gsub!(/(\A|\s+)([a-zA-Z][a-zA-Z0-9_\-() .\/=]*):(\s*)/) do
|
815
|
+
prefix_space = $1
|
816
|
+
text_part = $2
|
817
|
+
space_after = $3 || ""
|
818
|
+
colon_space = ":#{space_after}"
|
819
|
+
|
820
|
+
# Check if it's an operator (ALL-CAPS with optional _, -, (), /, =, spaces)
|
821
|
+
if text_part =~ /^[A-Z][A-Z_\-() \/=]*$/
|
822
|
+
prefix_space + text_part.fg("4") + colon_space.fg("4") # Blue for operators
|
823
|
+
elsif text_part.length >= 2 && space_after.include?(" ")
|
824
|
+
# It's a property (mixed case, at least 2 chars, has space after colon)
|
825
|
+
prefix_space + text_part.fg("1") + colon_space.fg("1") # Red for properties
|
826
|
+
else
|
827
|
+
# Leave as is
|
828
|
+
prefix_space + text_part + colon_space
|
829
|
+
end
|
830
|
+
end
|
831
|
+
|
832
|
+
# Handle OR: at the beginning of a line (with optional spaces)
|
833
|
+
result.sub!(/^(\s*)(OR):/) { $1 + "OR:".fg("4") } # Blue for OR: at line start
|
834
|
+
|
835
|
+
# Handle parentheses content (moved here to avoid conflicts with properties)
|
836
|
+
# Based on hyperlist.vim: '(.\{-})'
|
837
|
+
result.gsub!(/\(([^)]*)\)/) { "(".fg("6") + $1.fg("6") + ")".fg("6") } # Cyan for parentheses and content
|
838
|
+
|
839
|
+
# Handle semicolons as separators - but only at the start of the line or after spaces
|
840
|
+
# Don't replace semicolons that might be part of ANSI codes
|
841
|
+
result.gsub!(/^(\s*);/) { $1 + ";".fg("2") } # Green for comment semicolons
|
842
|
+
|
843
|
+
# Handle references - color entire reference including brackets
|
844
|
+
# Based on hyperlist.vim: '<\{1,2}[...]\+>\{1,2}'
|
845
|
+
result.gsub!(/<{1,2}([^>]+)>{1,2}/) { |match| match.fg("5") } # Magenta for references
|
846
|
+
|
847
|
+
# Handle special keywords SKIP and END
|
848
|
+
result.gsub!(/\b(SKIP|END)\b/) { $1.fg("5") } # Magenta for special keywords (like references)
|
849
|
+
|
850
|
+
# Handle quoted strings FIRST, but color ## sequences inside them red
|
851
|
+
# Based on hyperlist.vim: '".\{-}"'
|
852
|
+
result.gsub!(/"([^"]*)"/) do
|
853
|
+
content = $1
|
854
|
+
# Color any ## sequences inside the quotes as red
|
855
|
+
content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
|
856
|
+
'"'.fg("6") + content + '"'.fg("6") # Cyan for quotes
|
857
|
+
end
|
858
|
+
result.gsub!(/'([^']*)'/) do
|
859
|
+
content = $1
|
860
|
+
# Color any ## sequences inside the quotes as red
|
861
|
+
content.gsub!(/(##[<>-]+)/) { $1.fg("1") }
|
862
|
+
"'".fg("6") + content + "'".fg("6") # Cyan for single quotes
|
863
|
+
end
|
864
|
+
|
865
|
+
# Handle change markup - all double-hashes should be red
|
866
|
+
# First handle ##><Reference>##-> style (with reference in the middle)
|
867
|
+
result.gsub!(/(##[<>-]+)(<[^>]+>)(##[<>-]+)/) do
|
868
|
+
$1.fg("1") + $2.fg("5") + $3.fg("1") # Red markers, magenta reference
|
869
|
+
end
|
870
|
+
|
871
|
+
# Handle ##Text## change info (text between double hashes)
|
872
|
+
result.gsub!(/(##)([^#]+)(##)/) { $1.fg("1") + $2 + $3.fg("1") } # Red for change info markers
|
873
|
+
|
874
|
+
# Then color any remaining ## sequences red
|
875
|
+
result.gsub!(/(##[<>-]*)/) { $1.fg("1") } # Red for all ## markers
|
876
|
+
|
877
|
+
# Handle substitutions {variable}
|
878
|
+
result.gsub!(/\{([^}]+)\}/) { "{".fg("3") + $1.fg("3") + "}".fg("3") } # Yellow for substitutions
|
879
|
+
|
880
|
+
# Handle hash tags
|
881
|
+
# Based on hyperlist.vim: '#[a-zA-Z0-9.:/_&?%=+\-\*]\+'
|
882
|
+
result.gsub!(/#([a-zA-Z0-9.:_\/&?%=+\-*]+)/) { "##{$1}".fg("184") } # Yellow/gold for tags
|
883
|
+
|
884
|
+
# Handle text formatting (bold, italic, underline)
|
885
|
+
# Based on hyperlist.vim patterns with tab/space boundaries
|
886
|
+
# Bold: '\(\t\| \)\@<=\*.\{-}\*\($\| \)\@='
|
887
|
+
result.gsub!(/(?<=\s|\A)\*([^*]+)\*(?=\s|\z)/) { $1.b }
|
888
|
+
|
889
|
+
# Italic: '\(\t\| \)\@<=/.\{-}/\($\| \)\@='
|
890
|
+
result.gsub!(/(?<=\s|\A)\/([^\/]+)\/(?=\s|\z)/) { $1.i }
|
891
|
+
|
892
|
+
# Underline: '\(\t\| \)\@<=_.\{-}_\($\| \)\@='
|
893
|
+
result.gsub!(/(?<=\s|\A)_([^_]+)_(?=\s|\z)/) { $1.u }
|
894
|
+
|
895
|
+
result
|
896
|
+
end
|
897
|
+
|
898
|
+
def render_footer
|
899
|
+
version_text = "v#{VERSION}"
|
900
|
+
|
901
|
+
if @mode == :command
|
902
|
+
base_text = ":" + @command
|
903
|
+
elsif @mode == :search
|
904
|
+
base_text = "/" + @search
|
905
|
+
elsif @mode == :insert
|
906
|
+
base_text = "-- INSERT --"
|
907
|
+
elsif !@message.empty?
|
908
|
+
base_text = @message
|
909
|
+
# Set timeout for message if not already set
|
910
|
+
@message_timeout = Time.now + 3.0 if @message_timeout.nil?
|
911
|
+
# Clear message after timeout
|
912
|
+
if @message_timeout && Time.now >= @message_timeout
|
913
|
+
@message = ""
|
914
|
+
@message_timeout = nil
|
915
|
+
end
|
916
|
+
else
|
917
|
+
# Enhanced status with file info
|
918
|
+
filename_display = @filename ? File.basename(@filename) : "New HyperList"
|
919
|
+
modified_indicator = @modified ? "[+]" : ""
|
920
|
+
pos = "#{@current + 1}/#{get_visible_items.length}"
|
921
|
+
|
922
|
+
# Count words in visible items
|
923
|
+
word_count = count_words_in_items
|
924
|
+
|
925
|
+
# Auto-save indicator
|
926
|
+
auto_save_indicator = @auto_save_enabled ? "[A]" : ""
|
927
|
+
|
928
|
+
# Split view indicator
|
929
|
+
split_indicator = @split_view ? "[#{@active_pane.upcase}]" : ""
|
930
|
+
|
931
|
+
# Build status line components
|
932
|
+
# Use full path with ~ for home directory
|
933
|
+
full_path = @filename ? @filename.gsub(ENV['HOME'], '~') : "New HyperList"
|
934
|
+
file_part = "#{full_path}#{modified_indicator}"
|
935
|
+
stats_part = "L#{pos} W:#{word_count}"
|
936
|
+
indicators = "#{auto_save_indicator}#{split_indicator}"
|
937
|
+
right_side = "? help #{version_text}"
|
938
|
+
|
939
|
+
# Combine left elements
|
940
|
+
left_content = indicators.empty? ? "#{file_part} #{stats_part}" : "#{file_part}#{indicators} #{stats_part}"
|
941
|
+
|
942
|
+
# Calculate spacing between left and right
|
943
|
+
total_length = left_content.length + right_side.length
|
944
|
+
available_space = @cols - total_length
|
945
|
+
|
946
|
+
if available_space > 0
|
947
|
+
# Add spacing between left and right content
|
948
|
+
base_text = "#{left_content}#{' ' * available_space}#{right_side}"
|
949
|
+
else
|
950
|
+
# Terminal too narrow - truncate the path if needed
|
951
|
+
max_path_length = @cols - stats_part.length - right_side.length - modified_indicator.length - indicators.length - 6
|
952
|
+
if max_path_length > 10
|
953
|
+
truncated_path = full_path.length > max_path_length ? "...#{full_path[-(max_path_length-3)..-1]}" : full_path
|
954
|
+
left_content = "#{truncated_path}#{modified_indicator}#{indicators} #{stats_part}"
|
955
|
+
base_text = "#{left_content} #{right_side}"
|
956
|
+
else
|
957
|
+
# Very narrow - show minimal info
|
958
|
+
base_text = "#{stats_part} #{right_side}"
|
959
|
+
end
|
960
|
+
end
|
961
|
+
end
|
962
|
+
|
963
|
+
@footer.text = base_text
|
964
|
+
|
965
|
+
@footer.refresh
|
966
|
+
end
|
967
|
+
|
968
|
+
def count_words_in_items
|
969
|
+
visible = get_visible_items
|
970
|
+
total_words = 0
|
971
|
+
visible.each do |item|
|
972
|
+
# Count words in plain text (remove markup)
|
973
|
+
text = item["text"].gsub(/\[[^\]]*\]/, '') # Remove brackets
|
974
|
+
text = text.gsub(/<[^>]*>/, '') # Remove references
|
975
|
+
text = text.gsub(/[*\/_]/, '') # Remove formatting
|
976
|
+
words = text.split(/\s+/).reject(&:empty?)
|
977
|
+
total_words += words.length
|
978
|
+
end
|
979
|
+
total_words
|
980
|
+
end
|
981
|
+
|
982
|
+
def get_visible_items
|
983
|
+
visible = []
|
984
|
+
skip_until = -1
|
985
|
+
|
986
|
+
@items.each_with_index do |item, idx|
|
987
|
+
if idx <= skip_until
|
988
|
+
next
|
989
|
+
end
|
990
|
+
|
991
|
+
visible << item
|
992
|
+
|
993
|
+
if item["fold"] && has_children?(idx, @items)
|
994
|
+
# Skip children if folded
|
995
|
+
level = item["level"]
|
996
|
+
skip_until = idx
|
997
|
+
((idx + 1)...@items.length).each do |j|
|
998
|
+
if @items[j]["level"] <= level
|
999
|
+
break
|
1000
|
+
end
|
1001
|
+
skip_until = j
|
1002
|
+
end
|
1003
|
+
end
|
1004
|
+
end
|
1005
|
+
|
1006
|
+
visible
|
1007
|
+
end
|
1008
|
+
|
1009
|
+
def has_children?(idx, items)
|
1010
|
+
return false if idx >= items.length - 1
|
1011
|
+
return false if idx < 0
|
1012
|
+
|
1013
|
+
current_item = items[idx]
|
1014
|
+
next_item = items[idx + 1]
|
1015
|
+
|
1016
|
+
# For visible items array
|
1017
|
+
if current_item.is_a?(Hash) && next_item.is_a?(Hash)
|
1018
|
+
return next_item["level"] > current_item["level"]
|
1019
|
+
end
|
1020
|
+
|
1021
|
+
false
|
1022
|
+
end
|
1023
|
+
|
1024
|
+
def toggle_fold
|
1025
|
+
visible = get_visible_items
|
1026
|
+
return if @current >= visible.length
|
1027
|
+
|
1028
|
+
item = visible[@current]
|
1029
|
+
real_idx = @items.index(item)
|
1030
|
+
|
1031
|
+
if real_idx && has_children?(real_idx, @items)
|
1032
|
+
@items[real_idx]["fold"] = !@items[real_idx]["fold"]
|
1033
|
+
record_last_action(:toggle_fold, nil)
|
1034
|
+
end
|
1035
|
+
end
|
1036
|
+
|
1037
|
+
def move_up
|
1038
|
+
@current = [@current - 1, 0].max
|
1039
|
+
end
|
1040
|
+
|
1041
|
+
def move_down
|
1042
|
+
max = get_visible_items.length - 1
|
1043
|
+
@current = [@current + 1, max].min
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
def page_up
|
1047
|
+
@current = [@current - (@main.h - 1), 0].max
|
1048
|
+
@offset = [@offset - (@main.h - 1), 0].max
|
1049
|
+
end
|
1050
|
+
|
1051
|
+
def page_down
|
1052
|
+
max = get_visible_items.length - 1
|
1053
|
+
@current = [@current + (@main.h - 1), max].min
|
1054
|
+
end
|
1055
|
+
|
1056
|
+
def go_to_parent
|
1057
|
+
visible = get_visible_items
|
1058
|
+
return if @current >= visible.length
|
1059
|
+
|
1060
|
+
current_level = visible[@current]["level"]
|
1061
|
+
return if current_level == 0
|
1062
|
+
|
1063
|
+
# Search upward for parent
|
1064
|
+
(@current - 1).downto(0) do |i|
|
1065
|
+
if visible[i]["level"] < current_level
|
1066
|
+
@current = i
|
1067
|
+
break
|
1068
|
+
end
|
1069
|
+
end
|
1070
|
+
end
|
1071
|
+
|
1072
|
+
def go_to_first_child
|
1073
|
+
visible = get_visible_items
|
1074
|
+
return if @current >= visible.length - 1
|
1075
|
+
|
1076
|
+
current_level = visible[@current]["level"]
|
1077
|
+
if visible[@current + 1]["level"] > current_level
|
1078
|
+
@current += 1
|
1079
|
+
end
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
def expand_to_level(level)
|
1083
|
+
@items.each do |item|
|
1084
|
+
item["fold"] = item["level"] >= level
|
1085
|
+
end
|
1086
|
+
end
|
1087
|
+
|
1088
|
+
def jump_to_next_template_marker
|
1089
|
+
visible_items = get_visible_items
|
1090
|
+
return if visible_items.empty?
|
1091
|
+
|
1092
|
+
start_idx = @current
|
1093
|
+
|
1094
|
+
# Search forward from current position
|
1095
|
+
((start_idx + 1)...visible_items.length).each do |idx|
|
1096
|
+
if visible_items[idx]["text"].include?("=")
|
1097
|
+
@current = idx
|
1098
|
+
render # Show the highlighted item first
|
1099
|
+
edit_line # Then edit it
|
1100
|
+
return
|
1101
|
+
end
|
1102
|
+
end
|
1103
|
+
|
1104
|
+
# Wrap around to beginning
|
1105
|
+
(0..start_idx).each do |idx|
|
1106
|
+
if visible_items[idx]["text"].include?("=")
|
1107
|
+
@current = idx
|
1108
|
+
render # Show the highlighted item first
|
1109
|
+
edit_line # Then edit it
|
1110
|
+
return
|
1111
|
+
end
|
1112
|
+
end
|
1113
|
+
|
1114
|
+
@message = "No template markers found"
|
1115
|
+
end
|
1116
|
+
|
1117
|
+
def toggle_presentation_mode
|
1118
|
+
if @presentation_mode
|
1119
|
+
# Exit presentation mode - restore all items
|
1120
|
+
@presentation_mode = false
|
1121
|
+
@items.each { |item| item["fold"] = false }
|
1122
|
+
@message = "Presentation mode disabled"
|
1123
|
+
else
|
1124
|
+
# Enter presentation mode - show only current item and ancestors
|
1125
|
+
@presentation_mode = true
|
1126
|
+
show_only_current_and_ancestors
|
1127
|
+
@message = "Presentation mode enabled"
|
1128
|
+
end
|
1129
|
+
end
|
1130
|
+
|
1131
|
+
def show_only_current_and_ancestors
|
1132
|
+
visible_items = get_visible_items
|
1133
|
+
return if visible_items.empty? || @current >= visible_items.length
|
1134
|
+
|
1135
|
+
current_item = visible_items[@current]
|
1136
|
+
current_level = current_item["level"]
|
1137
|
+
current_real_idx = @items.index(current_item)
|
1138
|
+
|
1139
|
+
# First, fold everything
|
1140
|
+
@items.each { |item| item["fold"] = true }
|
1141
|
+
|
1142
|
+
# Unfold current item
|
1143
|
+
current_item["fold"] = false
|
1144
|
+
|
1145
|
+
# Unfold all ancestors
|
1146
|
+
ancestor_level = current_level - 1
|
1147
|
+
idx = current_real_idx - 1
|
1148
|
+
|
1149
|
+
while idx >= 0 && ancestor_level >= 0
|
1150
|
+
if @items[idx]["level"] == ancestor_level
|
1151
|
+
@items[idx]["fold"] = false
|
1152
|
+
ancestor_level -= 1
|
1153
|
+
end
|
1154
|
+
idx -= 1
|
1155
|
+
end
|
1156
|
+
|
1157
|
+
# Unfold immediate children of current item
|
1158
|
+
idx = current_real_idx + 1
|
1159
|
+
while idx < @items.length && @items[idx]["level"] > current_level
|
1160
|
+
if @items[idx]["level"] == current_level + 1
|
1161
|
+
@items[idx]["fold"] = false
|
1162
|
+
end
|
1163
|
+
idx += 1
|
1164
|
+
end
|
1165
|
+
end
|
1166
|
+
|
1167
|
+
def save_undo_state
|
1168
|
+
# Deep copy the CURRENT items array before modification
|
1169
|
+
state_copy = @items.map { |item| item.dup }
|
1170
|
+
|
1171
|
+
# Add to undo stack
|
1172
|
+
@undo_stack << state_copy
|
1173
|
+
@undo_position << @current
|
1174
|
+
|
1175
|
+
# Clear redo stack when new action is performed
|
1176
|
+
@redo_stack.clear
|
1177
|
+
@redo_position.clear
|
1178
|
+
|
1179
|
+
# Limit undo stack to 100 levels to prevent memory issues
|
1180
|
+
if @undo_stack.length > 100
|
1181
|
+
@undo_stack.shift
|
1182
|
+
@undo_position.shift
|
1183
|
+
end
|
1184
|
+
|
1185
|
+
# Debug output
|
1186
|
+
# @message = "Saved undo state (stack size: #{@undo_stack.length})"
|
1187
|
+
end
|
1188
|
+
|
1189
|
+
def states_equal?(state1, state2)
|
1190
|
+
return false if state1.length != state2.length
|
1191
|
+
state1.each_with_index do |item, idx|
|
1192
|
+
return false if item["text"] != state2[idx]["text"] ||
|
1193
|
+
item["level"] != state2[idx]["level"]
|
1194
|
+
end
|
1195
|
+
true
|
1196
|
+
end
|
1197
|
+
|
1198
|
+
def clear_cache
|
1199
|
+
@processed_cache = {}
|
1200
|
+
@last_rendered_content = nil
|
1201
|
+
end
|
1202
|
+
|
1203
|
+
def undo
|
1204
|
+
# Need at least one state to undo to
|
1205
|
+
if @undo_stack.empty?
|
1206
|
+
@message = "Nothing to undo"
|
1207
|
+
return
|
1208
|
+
end
|
1209
|
+
|
1210
|
+
# Save current state to redo stack before changing
|
1211
|
+
current_state = @items.map { |item| item.dup }
|
1212
|
+
@redo_stack << current_state
|
1213
|
+
@redo_position << @current
|
1214
|
+
|
1215
|
+
# Restore the previous state from undo stack
|
1216
|
+
previous_state = @undo_stack.pop
|
1217
|
+
previous_position = @undo_position.pop
|
1218
|
+
|
1219
|
+
@items = previous_state.map { |item| item.dup }
|
1220
|
+
|
1221
|
+
# Restore cursor position, but ensure it's valid
|
1222
|
+
visible = get_visible_items
|
1223
|
+
if previous_position && visible.length > 0
|
1224
|
+
@current = [previous_position, visible.length - 1].min
|
1225
|
+
@current = 0 if @current < 0
|
1226
|
+
else
|
1227
|
+
@current = 0
|
1228
|
+
end
|
1229
|
+
|
1230
|
+
# Clear cache since items changed
|
1231
|
+
clear_cache
|
1232
|
+
|
1233
|
+
@modified = true
|
1234
|
+
@message = "Undone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
|
1235
|
+
end
|
1236
|
+
|
1237
|
+
def record_last_action(type, data = nil)
|
1238
|
+
@last_action_type = type
|
1239
|
+
@last_action = data
|
1240
|
+
end
|
1241
|
+
|
1242
|
+
def repeat_last_action
|
1243
|
+
return unless @last_action_type
|
1244
|
+
|
1245
|
+
case @last_action_type
|
1246
|
+
when :insert_line
|
1247
|
+
insert_line_with_text(@last_action)
|
1248
|
+
when :insert_child
|
1249
|
+
insert_child_with_text(@last_action)
|
1250
|
+
when :delete_line
|
1251
|
+
delete_line
|
1252
|
+
when :edit_line
|
1253
|
+
if @last_action && @last_action[:new_text]
|
1254
|
+
edit_current_line(@last_action[:new_text])
|
1255
|
+
end
|
1256
|
+
when :toggle_checkbox
|
1257
|
+
toggle_checkbox
|
1258
|
+
when :toggle_checkbox_with_date
|
1259
|
+
toggle_checkbox_with_date
|
1260
|
+
when :toggle_fold
|
1261
|
+
toggle_fold
|
1262
|
+
when :indent_right
|
1263
|
+
indent_right(@last_action || true)
|
1264
|
+
when :indent_left
|
1265
|
+
indent_left(@last_action || true)
|
1266
|
+
when :paste
|
1267
|
+
paste_line
|
1268
|
+
when :yank_line
|
1269
|
+
yank_line(@last_action || false)
|
1270
|
+
else
|
1271
|
+
@message = "No repeatable action"
|
1272
|
+
end
|
1273
|
+
end
|
1274
|
+
|
1275
|
+
def redo_change # Renamed to avoid conflict with Ruby's redo keyword
|
1276
|
+
# Need at least one state to redo
|
1277
|
+
if @redo_stack.empty?
|
1278
|
+
@message = "Nothing to redo"
|
1279
|
+
return
|
1280
|
+
end
|
1281
|
+
|
1282
|
+
# Save current state back to undo stack
|
1283
|
+
current_state = @items.map { |item| item.dup }
|
1284
|
+
@undo_stack << current_state
|
1285
|
+
@undo_position << @current
|
1286
|
+
|
1287
|
+
# Restore the state from redo stack
|
1288
|
+
redo_state = @redo_stack.pop
|
1289
|
+
redo_position = @redo_position.pop
|
1290
|
+
|
1291
|
+
@items = redo_state.map { |item| item.dup }
|
1292
|
+
|
1293
|
+
# Restore cursor position, but ensure it's valid
|
1294
|
+
visible = get_visible_items
|
1295
|
+
if redo_position && visible.length > 0
|
1296
|
+
@current = [redo_position, visible.length - 1].min
|
1297
|
+
@current = 0 if @current < 0
|
1298
|
+
else
|
1299
|
+
@current = 0
|
1300
|
+
end
|
1301
|
+
|
1302
|
+
# Clear cache since items changed
|
1303
|
+
clear_cache
|
1304
|
+
|
1305
|
+
@modified = true
|
1306
|
+
@message = "Redone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
|
1307
|
+
end
|
1308
|
+
|
1309
|
+
def insert_line
|
1310
|
+
@mode = :insert
|
1311
|
+
|
1312
|
+
input = @footer.ask("New item: ", "")
|
1313
|
+
|
1314
|
+
if input && !input.strip.empty?
|
1315
|
+
insert_line_with_text(input)
|
1316
|
+
record_last_action(:insert_line, input)
|
1317
|
+
end
|
1318
|
+
|
1319
|
+
@mode = :normal
|
1320
|
+
@footer.clear # Clear footer immediately
|
1321
|
+
@footer.refresh
|
1322
|
+
end
|
1323
|
+
|
1324
|
+
def insert_line_with_text(text)
|
1325
|
+
return unless text && !text.strip.empty?
|
1326
|
+
|
1327
|
+
save_undo_state # Save state before modification
|
1328
|
+
visible = get_visible_items
|
1329
|
+
if @current < visible.length
|
1330
|
+
level = visible[@current]["level"]
|
1331
|
+
real_idx = @items.index(visible[@current])
|
1332
|
+
@items.insert(real_idx + 1, {"text" => text, "level" => level, "fold" => false})
|
1333
|
+
else
|
1334
|
+
@items << {"text" => text, "level" => 0, "fold" => false}
|
1335
|
+
end
|
1336
|
+
@modified = true
|
1337
|
+
@current += 1
|
1338
|
+
end
|
1339
|
+
|
1340
|
+
def insert_child
|
1341
|
+
@mode = :insert
|
1342
|
+
|
1343
|
+
input = @footer.ask("New child item: ", "")
|
1344
|
+
|
1345
|
+
if input && !input.strip.empty?
|
1346
|
+
insert_child_with_text(input)
|
1347
|
+
record_last_action(:insert_child, input)
|
1348
|
+
end
|
1349
|
+
|
1350
|
+
@mode = :normal
|
1351
|
+
@footer.clear # Clear footer immediately
|
1352
|
+
@footer.refresh
|
1353
|
+
end
|
1354
|
+
|
1355
|
+
def insert_child_with_text(text)
|
1356
|
+
return unless text && !text.strip.empty?
|
1357
|
+
|
1358
|
+
save_undo_state # Save state before modification
|
1359
|
+
visible = get_visible_items
|
1360
|
+
if @current < visible.length
|
1361
|
+
level = visible[@current]["level"] + 1
|
1362
|
+
real_idx = @items.index(visible[@current])
|
1363
|
+
|
1364
|
+
# Unfold parent if needed
|
1365
|
+
@items[real_idx]["fold"] = false
|
1366
|
+
|
1367
|
+
@items.insert(real_idx + 1, {"text" => text, "level" => level, "fold" => false})
|
1368
|
+
else
|
1369
|
+
@items << {"text" => text, "level" => 1, "fold" => false}
|
1370
|
+
end
|
1371
|
+
@modified = true
|
1372
|
+
@current += 1
|
1373
|
+
end
|
1374
|
+
|
1375
|
+
def edit_line
|
1376
|
+
visible = get_visible_items
|
1377
|
+
return if @current >= visible.length
|
1378
|
+
|
1379
|
+
item = visible[@current]
|
1380
|
+
real_idx = @items.index(item)
|
1381
|
+
|
1382
|
+
@mode = :insert
|
1383
|
+
|
1384
|
+
input = @footer.ask("Edit: ", item["text"])
|
1385
|
+
|
1386
|
+
if input && input != item["text"]
|
1387
|
+
edit_current_line(input)
|
1388
|
+
record_last_action(:edit_line, {old_text: item["text"], new_text: input})
|
1389
|
+
end
|
1390
|
+
|
1391
|
+
@mode = :normal
|
1392
|
+
@footer.clear # Clear footer immediately
|
1393
|
+
@footer.refresh
|
1394
|
+
end
|
1395
|
+
|
1396
|
+
def edit_current_line(text)
|
1397
|
+
visible = get_visible_items
|
1398
|
+
return if @current >= visible.length
|
1399
|
+
|
1400
|
+
item = visible[@current]
|
1401
|
+
real_idx = @items.index(item)
|
1402
|
+
|
1403
|
+
save_undo_state # Save state before modification
|
1404
|
+
@items[real_idx]["text"] = text
|
1405
|
+
@modified = true
|
1406
|
+
clear_cache # Clear cache when content changes
|
1407
|
+
end
|
1408
|
+
|
1409
|
+
def delete_line(with_children = false)
|
1410
|
+
visible = get_visible_items
|
1411
|
+
return if visible.empty?
|
1412
|
+
return if @current >= visible.length
|
1413
|
+
|
1414
|
+
save_undo_state # Save state before modification
|
1415
|
+
|
1416
|
+
item = visible[@current]
|
1417
|
+
real_idx = @items.index(item)
|
1418
|
+
|
1419
|
+
# First, yank the item(s) to clipboard
|
1420
|
+
@clipboard = []
|
1421
|
+
@clipboard << item.dup
|
1422
|
+
|
1423
|
+
# Determine what to delete
|
1424
|
+
level = item["level"]
|
1425
|
+
delete_count = 1
|
1426
|
+
|
1427
|
+
if with_children
|
1428
|
+
# Delete item and its children (C-D was used)
|
1429
|
+
@clipboard_is_tree = true # Mark as tree for paste behavior
|
1430
|
+
((real_idx + 1)...@items.length).each do |i|
|
1431
|
+
if @items[i]["level"] > level
|
1432
|
+
@clipboard << @items[i].dup # Also add children to clipboard
|
1433
|
+
delete_count += 1
|
1434
|
+
else
|
1435
|
+
break
|
1436
|
+
end
|
1437
|
+
end
|
1438
|
+
else
|
1439
|
+
# For single line delete, check if it has children (D always deletes with children)
|
1440
|
+
@clipboard_is_tree = false # Mark as single/adaptive for paste behavior
|
1441
|
+
((real_idx + 1)...@items.length).each do |i|
|
1442
|
+
if @items[i]["level"] > level
|
1443
|
+
@clipboard << @items[i].dup
|
1444
|
+
delete_count += 1
|
1445
|
+
else
|
1446
|
+
break
|
1447
|
+
end
|
1448
|
+
end
|
1449
|
+
end
|
1450
|
+
|
1451
|
+
# Delete the items
|
1452
|
+
delete_count.times { @items.delete_at(real_idx) }
|
1453
|
+
|
1454
|
+
@items = [{"text" => "Empty", "level" => 0, "fold" => false}] if @items.empty?
|
1455
|
+
|
1456
|
+
@current = [@current, get_visible_items.length - 1].min
|
1457
|
+
@current = 0 if @current < 0
|
1458
|
+
@modified = true
|
1459
|
+
|
1460
|
+
# Show message
|
1461
|
+
@message = "Deleted and yanked #{@clipboard.length} item(s)"
|
1462
|
+
|
1463
|
+
record_last_action(:delete_line, with_children)
|
1464
|
+
end
|
1465
|
+
|
1466
|
+
def yank_line(with_children = false)
|
1467
|
+
visible = get_visible_items
|
1468
|
+
return if @current >= visible.length
|
1469
|
+
|
1470
|
+
item = visible[@current]
|
1471
|
+
real_idx = @items.index(item)
|
1472
|
+
|
1473
|
+
@clipboard = []
|
1474
|
+
@clipboard << item.dup
|
1475
|
+
@clipboard_is_tree = with_children # Remember if this is a tree copy (Y) or single (y)
|
1476
|
+
|
1477
|
+
# Copy children if requested
|
1478
|
+
if with_children
|
1479
|
+
level = item["level"]
|
1480
|
+
((real_idx + 1)...@items.length).each do |i|
|
1481
|
+
if @items[i]["level"] > level
|
1482
|
+
@clipboard << @items[i].dup
|
1483
|
+
else
|
1484
|
+
break
|
1485
|
+
end
|
1486
|
+
end
|
1487
|
+
end
|
1488
|
+
|
1489
|
+
@message = "Yanked #{@clipboard.length} item(s)"
|
1490
|
+
record_last_action(:yank_line, with_children)
|
1491
|
+
end
|
1492
|
+
|
1493
|
+
def paste_line
|
1494
|
+
return unless @clipboard && !@clipboard.empty?
|
1495
|
+
|
1496
|
+
save_undo_state # Save state before modification
|
1497
|
+
|
1498
|
+
visible = get_visible_items
|
1499
|
+
if @current < visible.length
|
1500
|
+
real_idx = @items.index(visible[@current])
|
1501
|
+
|
1502
|
+
if @clipboard_is_tree
|
1503
|
+
# For tree paste (C-D or Y), maintain original indentation structure
|
1504
|
+
# Just insert after current position without adjusting levels
|
1505
|
+
@clipboard.reverse.each do |item|
|
1506
|
+
new_item = item.dup
|
1507
|
+
@items.insert(real_idx + 1, new_item)
|
1508
|
+
end
|
1509
|
+
else
|
1510
|
+
# For single/adaptive paste (D or y), adjust to match context
|
1511
|
+
base_level = visible[@current]["level"]
|
1512
|
+
level_diff = base_level - @clipboard[0]["level"]
|
1513
|
+
|
1514
|
+
@clipboard.reverse.each do |item|
|
1515
|
+
new_item = item.dup
|
1516
|
+
new_item["level"] = item["level"] + level_diff
|
1517
|
+
@items.insert(real_idx + 1, new_item)
|
1518
|
+
end
|
1519
|
+
end
|
1520
|
+
else
|
1521
|
+
# Pasting at end of list - maintain original levels
|
1522
|
+
@clipboard.each do |item|
|
1523
|
+
@items << item.dup
|
1524
|
+
end
|
1525
|
+
end
|
1526
|
+
|
1527
|
+
@modified = true
|
1528
|
+
@message = "Pasted #{@clipboard.length} item(s)"
|
1529
|
+
record_last_action(:paste, nil)
|
1530
|
+
end
|
1531
|
+
|
1532
|
+
def move_item_up(with_children = false)
|
1533
|
+
visible = get_visible_items
|
1534
|
+
return if @current >= visible.length || @current == 0
|
1535
|
+
|
1536
|
+
save_undo_state # Save state before modification
|
1537
|
+
|
1538
|
+
item = visible[@current]
|
1539
|
+
real_idx = @items.index(item)
|
1540
|
+
|
1541
|
+
# Can't move if already at the beginning
|
1542
|
+
return if real_idx == 0
|
1543
|
+
|
1544
|
+
# Collect item(s) to move
|
1545
|
+
items_to_move = [item]
|
1546
|
+
|
1547
|
+
if with_children
|
1548
|
+
# Collect all children
|
1549
|
+
level = item["level"]
|
1550
|
+
((real_idx + 1)...@items.length).each do |i|
|
1551
|
+
if @items[i]["level"] > level
|
1552
|
+
items_to_move << @items[i]
|
1553
|
+
else
|
1554
|
+
break
|
1555
|
+
end
|
1556
|
+
end
|
1557
|
+
end
|
1558
|
+
|
1559
|
+
# Remove items from their current position
|
1560
|
+
items_to_move.length.times { @items.delete_at(real_idx) }
|
1561
|
+
|
1562
|
+
# Insert one position up (before the item that was above us)
|
1563
|
+
target_idx = real_idx - 1
|
1564
|
+
items_to_move.reverse.each do |item_to_move|
|
1565
|
+
@items.insert(target_idx, item_to_move)
|
1566
|
+
end
|
1567
|
+
|
1568
|
+
@current -= 1
|
1569
|
+
@modified = true
|
1570
|
+
@message = "Moved #{items_to_move.length} item(s) up one line"
|
1571
|
+
record_last_action(:move_item_up, with_children)
|
1572
|
+
end
|
1573
|
+
|
1574
|
+
def move_item_down(with_children = false)
|
1575
|
+
visible = get_visible_items
|
1576
|
+
return if @current >= visible.length - 1
|
1577
|
+
|
1578
|
+
save_undo_state # Save state before modification
|
1579
|
+
|
1580
|
+
item = visible[@current]
|
1581
|
+
real_idx = @items.index(item)
|
1582
|
+
|
1583
|
+
# Collect item(s) to move
|
1584
|
+
items_to_move = [item]
|
1585
|
+
last_idx = real_idx
|
1586
|
+
|
1587
|
+
if with_children
|
1588
|
+
# Collect all children
|
1589
|
+
level = item["level"]
|
1590
|
+
((real_idx + 1)...@items.length).each do |i|
|
1591
|
+
if @items[i]["level"] > level
|
1592
|
+
items_to_move << @items[i]
|
1593
|
+
last_idx = i
|
1594
|
+
else
|
1595
|
+
break
|
1596
|
+
end
|
1597
|
+
end
|
1598
|
+
end
|
1599
|
+
|
1600
|
+
# Can't move if we're already at the end
|
1601
|
+
return if last_idx >= @items.length - 1
|
1602
|
+
|
1603
|
+
# Remove items from their current position
|
1604
|
+
items_to_move.length.times { @items.delete_at(real_idx) }
|
1605
|
+
|
1606
|
+
# Insert one position down (after the item that was below us)
|
1607
|
+
# Since we removed items, the target position is now at real_idx + 1
|
1608
|
+
target_idx = real_idx + 1
|
1609
|
+
|
1610
|
+
items_to_move.each_with_index do |item_to_move, idx|
|
1611
|
+
@items.insert(target_idx + idx, item_to_move)
|
1612
|
+
end
|
1613
|
+
|
1614
|
+
@current += 1
|
1615
|
+
@modified = true
|
1616
|
+
@message = "Moved #{items_to_move.length} item(s) down one line"
|
1617
|
+
record_last_action(:move_item_down, with_children)
|
1618
|
+
end
|
1619
|
+
|
1620
|
+
def indent_right(with_children = true)
|
1621
|
+
visible = get_visible_items
|
1622
|
+
return if @current >= visible.length
|
1623
|
+
|
1624
|
+
item = visible[@current]
|
1625
|
+
real_idx = @items.index(item)
|
1626
|
+
|
1627
|
+
# Can only indent if there's a previous item at same or higher level
|
1628
|
+
if real_idx > 0
|
1629
|
+
save_undo_state # Save state before modification
|
1630
|
+
original_level = @items[real_idx]["level"]
|
1631
|
+
@items[real_idx]["level"] += 1
|
1632
|
+
|
1633
|
+
# Also indent children if requested
|
1634
|
+
if with_children
|
1635
|
+
((real_idx + 1)...@items.length).each do |i|
|
1636
|
+
if @items[i]["level"] > original_level
|
1637
|
+
@items[i]["level"] += 1
|
1638
|
+
else
|
1639
|
+
break
|
1640
|
+
end
|
1641
|
+
end
|
1642
|
+
end
|
1643
|
+
|
1644
|
+
@modified = true
|
1645
|
+
record_last_action(:indent_right, with_children)
|
1646
|
+
end
|
1647
|
+
end
|
1648
|
+
|
1649
|
+
def indent_left(with_children = true)
|
1650
|
+
visible = get_visible_items
|
1651
|
+
return if @current >= visible.length
|
1652
|
+
|
1653
|
+
item = visible[@current]
|
1654
|
+
real_idx = @items.index(item)
|
1655
|
+
|
1656
|
+
if @items[real_idx]["level"] > 0
|
1657
|
+
save_undo_state # Save state before modification
|
1658
|
+
original_level = @items[real_idx]["level"]
|
1659
|
+
@items[real_idx]["level"] -= 1
|
1660
|
+
|
1661
|
+
# Also unindent children if requested
|
1662
|
+
if with_children
|
1663
|
+
((real_idx + 1)...@items.length).each do |i|
|
1664
|
+
if @items[i]["level"] > original_level
|
1665
|
+
@items[i]["level"] -= 1
|
1666
|
+
else
|
1667
|
+
break
|
1668
|
+
end
|
1669
|
+
end
|
1670
|
+
end
|
1671
|
+
|
1672
|
+
@modified = true
|
1673
|
+
record_last_action(:indent_left, with_children)
|
1674
|
+
end
|
1675
|
+
end
|
1676
|
+
|
1677
|
+
def toggle_checkbox
|
1678
|
+
visible = get_visible_items
|
1679
|
+
return if @current >= visible.length
|
1680
|
+
|
1681
|
+
save_undo_state # Save state before modification
|
1682
|
+
|
1683
|
+
item = visible[@current]
|
1684
|
+
real_idx = @items.index(item)
|
1685
|
+
text = @items[real_idx]["text"]
|
1686
|
+
|
1687
|
+
if text =~ /^\[.\]/
|
1688
|
+
# Cycle through states: [ ]/[_] -> [X] -> [O] -> [-] -> [ ]
|
1689
|
+
text = case text[1]
|
1690
|
+
when ' ', '_' then text.sub(/^\[[ _]\]/, '[X]')
|
1691
|
+
when 'X', 'x' then text.sub(/^\[[Xx]\]/, '[O]')
|
1692
|
+
when 'O' then text.sub(/^\[O\]/, '[-]')
|
1693
|
+
when '-' then text.sub(/^\[-\]/, '[ ]')
|
1694
|
+
else text
|
1695
|
+
end
|
1696
|
+
else
|
1697
|
+
# Add checkbox
|
1698
|
+
text = "[ ] #{text}"
|
1699
|
+
end
|
1700
|
+
|
1701
|
+
@items[real_idx]["text"] = text
|
1702
|
+
@modified = true
|
1703
|
+
record_last_action(:toggle_checkbox, nil)
|
1704
|
+
end
|
1705
|
+
|
1706
|
+
def toggle_checkbox_with_date
|
1707
|
+
visible = get_visible_items
|
1708
|
+
return if @current >= visible.length
|
1709
|
+
|
1710
|
+
save_undo_state # Save state before modification
|
1711
|
+
|
1712
|
+
item = visible[@current]
|
1713
|
+
real_idx = @items.index(item)
|
1714
|
+
text = @items[real_idx]["text"]
|
1715
|
+
|
1716
|
+
# First, handle existing timestamp in the hyperlist.vim format
|
1717
|
+
# Format is: [x] YYYY-MM-DD HH.MM: rest of text
|
1718
|
+
text = text.sub(/ \d{4}-\d{2}-\d{2} \d{2}\.\d{2}:/, '')
|
1719
|
+
|
1720
|
+
if text =~ /^\[.\]/
|
1721
|
+
# Toggle checkbox states with date stamping
|
1722
|
+
text = case text[1]
|
1723
|
+
when ' '
|
1724
|
+
# Unchecked -> mark as in progress
|
1725
|
+
text.sub(/^\[ \]/, '[_]')
|
1726
|
+
when '_'
|
1727
|
+
# In progress -> mark as completed with timestamp
|
1728
|
+
time = Time.now.strftime("%Y-%m-%d %H.%M")
|
1729
|
+
text.sub(/^\[_\]/, "[x] #{time}:")
|
1730
|
+
when 'x', 'X'
|
1731
|
+
# Completed -> back to unchecked
|
1732
|
+
text.sub(/^\[[xX]\]/, '[_]')
|
1733
|
+
when 'O'
|
1734
|
+
# In progress (capital O) -> completed with timestamp
|
1735
|
+
time = Time.now.strftime("%Y-%m-%d %H.%M")
|
1736
|
+
text.sub(/^\[O\]/, "[x] #{time}:")
|
1737
|
+
else text
|
1738
|
+
end
|
1739
|
+
else
|
1740
|
+
# Add checkbox in unchecked state
|
1741
|
+
text = "[_] #{text}"
|
1742
|
+
end
|
1743
|
+
|
1744
|
+
@items[real_idx]["text"] = text
|
1745
|
+
@modified = true
|
1746
|
+
record_last_action(:toggle_checkbox_with_date, nil)
|
1747
|
+
end
|
1748
|
+
|
1749
|
+
def search_forward
|
1750
|
+
@mode = :search
|
1751
|
+
@search = @footer.ask("Search: ", @search || "")
|
1752
|
+
@mode = :normal
|
1753
|
+
@footer.clear # Clear footer immediately
|
1754
|
+
@footer.refresh
|
1755
|
+
|
1756
|
+
return if !@search || @search.empty?
|
1757
|
+
|
1758
|
+
# Find all matches for highlighting
|
1759
|
+
find_all_matches
|
1760
|
+
|
1761
|
+
visible = get_visible_items
|
1762
|
+
start = (@current + 1) % visible.length
|
1763
|
+
|
1764
|
+
visible.length.times do |i|
|
1765
|
+
idx = (start + i) % visible.length
|
1766
|
+
if visible[idx]["text"].downcase.include?(@search.downcase)
|
1767
|
+
@current = idx
|
1768
|
+
@message = "Found: #{@search} (#{@search_matches.length} total matches)"
|
1769
|
+
clear_cache # Clear cache to show highlighting
|
1770
|
+
return
|
1771
|
+
end
|
1772
|
+
end
|
1773
|
+
|
1774
|
+
@message = "Not found: #{@search}"
|
1775
|
+
@search_matches = []
|
1776
|
+
end
|
1777
|
+
|
1778
|
+
def find_all_matches
|
1779
|
+
@search_matches = []
|
1780
|
+
return if !@search || @search.empty?
|
1781
|
+
|
1782
|
+
get_visible_items.each_with_index do |item, idx|
|
1783
|
+
if item["text"].downcase.include?(@search.downcase)
|
1784
|
+
@search_matches << idx
|
1785
|
+
end
|
1786
|
+
end
|
1787
|
+
end
|
1788
|
+
|
1789
|
+
def search_next
|
1790
|
+
return if !@search || @search.empty?
|
1791
|
+
|
1792
|
+
visible = get_visible_items
|
1793
|
+
start = (@current + 1) % visible.length
|
1794
|
+
|
1795
|
+
visible.length.times do |i|
|
1796
|
+
idx = (start + i) % visible.length
|
1797
|
+
if visible[idx]["text"].downcase.include?(@search.downcase)
|
1798
|
+
@current = idx
|
1799
|
+
return
|
1800
|
+
end
|
1801
|
+
end
|
1802
|
+
|
1803
|
+
@message = "No more matches"
|
1804
|
+
end
|
1805
|
+
|
1806
|
+
def help_line(cmd1, desc1, cmd2 = nil, desc2 = nil)
|
1807
|
+
# Format help lines with consistent column spacing
|
1808
|
+
# Start with no leading space
|
1809
|
+
line = ""
|
1810
|
+
|
1811
|
+
# First command and description
|
1812
|
+
line += cmd1
|
1813
|
+
# Calculate real length without ANSI codes
|
1814
|
+
cmd1_real_length = cmd1.gsub(/\e\[[0-9;]*m/, '').length
|
1815
|
+
# Longest commands are like ":recent :r" (10 chars), "C-DOWN" (6 chars)
|
1816
|
+
max_cmd_length = 11
|
1817
|
+
spaces_needed = max_cmd_length - cmd1_real_length
|
1818
|
+
line += " " * [spaces_needed, 1].max # At least 1 space after command
|
1819
|
+
|
1820
|
+
# Add first description - don't truncate
|
1821
|
+
line += desc1
|
1822
|
+
|
1823
|
+
# Second command and description (if provided)
|
1824
|
+
if cmd2 && desc2
|
1825
|
+
# Calculate position for second column
|
1826
|
+
# Need to account for ANSI codes in desc1
|
1827
|
+
desc1_real_length = desc1.gsub(/\e\[[0-9;]*m/, '').length
|
1828
|
+
current_pos = cmd1_real_length + spaces_needed + desc1_real_length
|
1829
|
+
|
1830
|
+
# Start second column at position 40
|
1831
|
+
target_pos = 40
|
1832
|
+
padding_needed = target_pos - current_pos
|
1833
|
+
line += " " * [padding_needed, 3].max # At least 3 spaces between columns
|
1834
|
+
|
1835
|
+
line += cmd2
|
1836
|
+
cmd2_real_length = cmd2.gsub(/\e\[[0-9;]*m/, '').length
|
1837
|
+
# Align second column descriptions (longest is ":export :ex" = 11 chars, ":vsplit :vs" = 11 chars)
|
1838
|
+
max_cmd2_length = 13 # 11 chars max + 2 spaces minimum padding
|
1839
|
+
spaces_needed2 = max_cmd2_length - cmd2_real_length
|
1840
|
+
line += " " * spaces_needed2
|
1841
|
+
line += desc2
|
1842
|
+
end
|
1843
|
+
|
1844
|
+
line
|
1845
|
+
end
|
1846
|
+
|
1847
|
+
def show_help
|
1848
|
+
# Build help text using consistent formatting
|
1849
|
+
help_lines = []
|
1850
|
+
help_lines << " Press #{"?".fg("10")} for full documentation, #{"UP/DOWN".fg("10")} to scroll, or any other key to return"
|
1851
|
+
help_lines << ""
|
1852
|
+
help_lines << "#{"HYPERLIST KEY BINDINGS".b}"
|
1853
|
+
help_lines << ""
|
1854
|
+
help_lines << "#{"NAVIGATION".fg("14")}"
|
1855
|
+
help_lines << help_line("#{"j/↓".fg("10")}", "Move down", "#{"k/↑".fg("10")}", "Move up")
|
1856
|
+
help_lines << help_line("#{"h".fg("10")}", "Go to parent", "#{"l".fg("10")}", "Go to first child")
|
1857
|
+
help_lines << help_line("#{"PgUp".fg("10")}", "Page up", "#{"PgDn".fg("10")}", "Page down")
|
1858
|
+
help_lines << help_line("#{"g/Home".fg("10")}", "Go to top", "#{"G/End".fg("10")}", "Go to bottom")
|
1859
|
+
help_lines << help_line("#{"/".fg("10")}", "Search", "#{"n".fg("10")}", "Next match")
|
1860
|
+
help_lines << help_line("#{"?".fg("10")}", "This help", "#{"??".fg("10")}", "Full documentation")
|
1861
|
+
help_lines << help_line("#{"ma".fg("10")}", "Set mark 'a'", "#{"'a".fg("10")}", "Jump to mark 'a'")
|
1862
|
+
help_lines << help_line("#{"''".fg("10")}", "Jump to prev position", "#{"N".fg("10")}", "Next = template marker")
|
1863
|
+
help_lines << ""
|
1864
|
+
help_lines << "#{"FOLDING".fg("14")}"
|
1865
|
+
help_lines << help_line("#{"Space".fg("10")}", "Toggle fold", "#{"za".fg("10")}", "Toggle all folds")
|
1866
|
+
help_lines << help_line("#{"zo".fg("10")}", "Open fold", "#{"zc".fg("10")}", "Close fold")
|
1867
|
+
help_lines << help_line("#{"zR".fg("10")}", "Open all", "#{"zM".fg("10")}", "Close all")
|
1868
|
+
help_lines << help_line("#{"1-9".fg("10")}", "Expand to level", "#{"0".fg("10")}", "Multi-digit fold level")
|
1869
|
+
help_lines << help_line("#{"▶".fg("245")}", "Collapsed (hidden)")
|
1870
|
+
help_lines << help_line("#{"▷".fg("245")}", "Expanded (visible)")
|
1871
|
+
help_lines << ""
|
1872
|
+
help_lines << "#{"EDITING".fg("14")}"
|
1873
|
+
help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
|
1874
|
+
help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
|
1875
|
+
help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
|
1876
|
+
help_lines << help_line("#{"y".fg("10")}/#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
|
1877
|
+
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")
|
1879
|
+
help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
|
1880
|
+
help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
|
1881
|
+
help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
|
1882
|
+
help_lines << help_line("#{"→".fg("10")}", "Indent item only", "#{"←".fg("10")}", "Unindent item only")
|
1883
|
+
help_lines << ""
|
1884
|
+
help_lines << "#{"FEATURES".fg("14")}"
|
1885
|
+
help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
|
1886
|
+
help_lines << help_line("#{"R".fg("10")}", "Go to reference", "#{"F".fg("10")}", "Open file")
|
1887
|
+
help_lines << help_line("#{"N".fg("10")}", "Next = marker", "#{"P".fg("10")}", "Presentation mode")
|
1888
|
+
help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":template".fg("10")}", "Show templates")
|
1889
|
+
help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
|
1890
|
+
help_lines << help_line("#{":vsplit".fg("10")}", "Split view vertically", "#{"ww".fg("10")}", "Switch panes")
|
1891
|
+
help_lines << help_line("#{"\\u".fg("10")}", "Toggle underline")
|
1892
|
+
help_lines << ""
|
1893
|
+
help_lines << "#{"FILE OPERATIONS".fg("14")}"
|
1894
|
+
help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
|
1895
|
+
help_lines << help_line("#{":wq".fg("10")}", "Save and quit", "#{":e file".fg("10")}", "Open file")
|
1896
|
+
help_lines << help_line("#{":recent".fg("10")}", "Recent files", "#{":export :ex".fg("10")}", "Export md|html|txt")
|
1897
|
+
help_lines << help_line("#{":graph :g".fg("10")}", "Export to PNG graph", "#{":vsplit :vs".fg("10")}", "Split view")
|
1898
|
+
help_lines << help_line("#{":as on".fg("10")}", "Enable autosave", "#{":as off".fg("10")}", "Disable autosave")
|
1899
|
+
help_lines << help_line("#{":as N".fg("10")}", "Set interval (secs)", "#{":as".fg("10")}", "Show autosave status")
|
1900
|
+
help_lines << help_line("#{"q".fg("10")}", "Quit (asks to save)", "#{"Q".fg("10")}", "Force quit")
|
1901
|
+
help_lines << ""
|
1902
|
+
help_lines << "#{"COLOR SCHEME".fg("14")}"
|
1903
|
+
help_lines << help_line("#{"[X]".fg("10")}", "Completed", "#{"[O]".fg("10").b}", "In progress")
|
1904
|
+
help_lines << help_line("#{"[ ]".fg("22")}", "Unchecked", "#{"[-]".fg("2")}", "Partial")
|
1905
|
+
help_lines << help_line("#{"[?]".fg("2")}", "Conditionals", "#{"AND:".fg("4")}", "Operators")
|
1906
|
+
help_lines << help_line("#{"Date:".fg("1")}", "Properties", "#{"<ref>".fg("5")}", "References")
|
1907
|
+
help_lines << help_line("#{"(info)".fg("6")}", "Parentheses", "#{'"text"'.fg("14")}", "Quoted strings")
|
1908
|
+
help_lines << help_line("#{"; comment".fg("6")}", "Comments", "#{"#tag".fg("184")}", "Hash tags")
|
1909
|
+
|
1910
|
+
help = help_lines.join("\n")
|
1911
|
+
|
1912
|
+
# Store current state
|
1913
|
+
saved_items = @items.dup
|
1914
|
+
saved_current = @current
|
1915
|
+
saved_offset = @offset
|
1916
|
+
saved_modified = @modified
|
1917
|
+
|
1918
|
+
# Create temporary help items for scrolling
|
1919
|
+
@items = help.split("\n").map { |line| {"text" => line, "level" => 0, "fold" => false, "raw" => true} }
|
1920
|
+
@current = 0
|
1921
|
+
@offset = 0
|
1922
|
+
@modified = false
|
1923
|
+
|
1924
|
+
# Help viewer loop
|
1925
|
+
loop do
|
1926
|
+
render_main
|
1927
|
+
@footer.text = "Help | UP/DOWN: scroll | ?: documentation | any other key: return"
|
1928
|
+
@footer.refresh
|
1929
|
+
|
1930
|
+
c = getchr
|
1931
|
+
case c
|
1932
|
+
when "?"
|
1933
|
+
# Restore state before showing documentation
|
1934
|
+
@items = saved_items
|
1935
|
+
@current = saved_current
|
1936
|
+
@offset = saved_offset
|
1937
|
+
@modified = saved_modified
|
1938
|
+
show_documentation
|
1939
|
+
return
|
1940
|
+
when "j", "DOWN"
|
1941
|
+
move_down
|
1942
|
+
when "k", "UP"
|
1943
|
+
move_up
|
1944
|
+
when "PgUP"
|
1945
|
+
page_up
|
1946
|
+
when "PgDOWN"
|
1947
|
+
page_down
|
1948
|
+
when "HOME"
|
1949
|
+
@current = 0
|
1950
|
+
@offset = 0
|
1951
|
+
when "END"
|
1952
|
+
@current = @items.length - 1
|
1953
|
+
else
|
1954
|
+
# Any other key returns to main view
|
1955
|
+
break
|
1956
|
+
end
|
1957
|
+
end
|
1958
|
+
|
1959
|
+
# Restore original state
|
1960
|
+
@items = saved_items
|
1961
|
+
@current = saved_current
|
1962
|
+
@offset = saved_offset
|
1963
|
+
@modified = saved_modified
|
1964
|
+
end
|
1965
|
+
|
1966
|
+
def show_documentation
|
1967
|
+
doc = <<~DOC
|
1968
|
+
|
1969
|
+
#{"HYPERLIST DOCUMENTATION".b}
|
1970
|
+
#{"Version 2.6 - Complete Specification".fg("245")}
|
1971
|
+
|
1972
|
+
#{"Official HyperList Page:".fg("14")} #{"https://isene.org/hyperlist/".fg("5")}
|
1973
|
+
#{"By Geir Isene".fg("245")}
|
1974
|
+
|
1975
|
+
#{"═" * 60}
|
1976
|
+
|
1977
|
+
#{"OVERVIEW".b}
|
1978
|
+
|
1979
|
+
HyperList represents a way to describe anything - any state or any
|
1980
|
+
transition. It represents data in tree-structure lists with a very
|
1981
|
+
rich set of features. It can be used for any structuring of data:
|
1982
|
+
|
1983
|
+
• Todo lists and project plans
|
1984
|
+
• Data structures and business processes
|
1985
|
+
• Logic breakdowns and outlines of ideas
|
1986
|
+
• Food recipes, instructions, and much more
|
1987
|
+
|
1988
|
+
Everything. Concise and precise.
|
1989
|
+
|
1990
|
+
#{"═" * 60}
|
1991
|
+
|
1992
|
+
#{"HYPERLIST ITEM STRUCTURE".b}
|
1993
|
+
|
1994
|
+
A HyperList Item consists of these parts in sequence:
|
1995
|
+
|
1996
|
+
1. #{"Starter".fg("5")} (optional)
|
1997
|
+
• Identifier: Numbers (1.1.1) or mixed (1A1A)
|
1998
|
+
• Multi-line indicator: + (plus sign)
|
1999
|
+
|
2000
|
+
2. #{"Type".fg("5")} (optional)
|
2001
|
+
• State: S: or |
|
2002
|
+
• Transition: T: or /
|
2003
|
+
|
2004
|
+
3. #{"Content".fg("5")} (required)
|
2005
|
+
• Element: Operator, Qualifier, Property, or Description
|
2006
|
+
• Additive: Reference, Tag, Comment, Quote, Change Markup
|
2007
|
+
|
2008
|
+
4. #{"Separator".fg("5")}
|
2009
|
+
• Newline (for new items)
|
2010
|
+
• Semicolon (for same-line items)
|
2011
|
+
|
2012
|
+
#{"═" * 60}
|
2013
|
+
|
2014
|
+
#{"BASIC STRUCTURE".b}
|
2015
|
+
|
2016
|
+
• Items are organized hierarchically using TAB indentation
|
2017
|
+
• Each level of indentation represents a deeper level
|
2018
|
+
• Children add information to their parents
|
2019
|
+
• A separator between items reads as "then" or "and"
|
2020
|
+
|
2021
|
+
#{"CHECKBOXES".b}
|
2022
|
+
|
2023
|
+
[_] Unchecked item (underscore placeholder)
|
2024
|
+
[ ] Unchecked item (space placeholder)
|
2025
|
+
[X] Completed item
|
2026
|
+
[x] Completed item (lowercase)
|
2027
|
+
[O] Item in progress
|
2028
|
+
[-] Partially completed item
|
2029
|
+
|
2030
|
+
Use 'v' to toggle checkboxes through states.
|
2031
|
+
Use 'V' to toggle with automatic timestamp on completion.
|
2032
|
+
Format: [x] YYYY-MM-DD HH.MM: (timestamp after checkbox)
|
2033
|
+
|
2034
|
+
#{"QUALIFIERS".b}
|
2035
|
+
|
2036
|
+
Qualifiers in square brackets tell how many times, when, or under
|
2037
|
+
what conditions an item exists or is executed:
|
2038
|
+
|
2039
|
+
[?] Optional item
|
2040
|
+
[3] Do item 3 times
|
2041
|
+
[1+] Do item 1 or more times
|
2042
|
+
[2..5] Do item 2 to 5 times
|
2043
|
+
[<4] Do item less than 4 times
|
2044
|
+
[>2] Do item more than 2 times
|
2045
|
+
[Condition] Do item if condition is true
|
2046
|
+
[?Condition] Same as above, with explicit "if"
|
2047
|
+
[3, foo=true] Do item 3 times while foo=true
|
2048
|
+
[A. B. C] Do item in context A, then B, then C
|
2049
|
+
|
2050
|
+
Complex example:
|
2051
|
+
[2..7, Raining, Temp<5°C] Do item 2-7 times while raining and cold
|
2052
|
+
|
2053
|
+
#{"TIMESTAMPS".b}
|
2054
|
+
|
2055
|
+
ISO-8601 format: YYYY-MM-DD HH.MM.SS (can be shortened)
|
2056
|
+
|
2057
|
+
Absolute timestamps:
|
2058
|
+
[2025-08-11] On this date
|
2059
|
+
[2025-08-11 14.30] At this date and time
|
2060
|
+
|
2061
|
+
Relative timestamps:
|
2062
|
+
[+YYYY-MM-DD] Wait this long before doing item
|
2063
|
+
[-YYYY-MM-DD] Wait this long before next item
|
2064
|
+
[+1 week] Wait one week
|
2065
|
+
[<+4 days] Less than 4 days after previous
|
2066
|
+
|
2067
|
+
Recurring items:
|
2068
|
+
[2025-05-01+7] Every 7 days starting from date
|
2069
|
+
[YYYY-MM-03] Every 3rd of every month
|
2070
|
+
[Tue,Fri 12.00] Noon every Tuesday and Friday
|
2071
|
+
|
2072
|
+
#{"PROPERTIES".b}
|
2073
|
+
|
2074
|
+
Any text followed by colon is a property (displayed in red):
|
2075
|
+
|
2076
|
+
Location: Conference room
|
2077
|
+
Priority: High
|
2078
|
+
2025-08-11 14.30: Meeting starts
|
2079
|
+
State: Active
|
2080
|
+
|
2081
|
+
#{"OPERATORS".b}
|
2082
|
+
|
2083
|
+
ALL CAPS followed by colon (displayed in blue):
|
2084
|
+
|
2085
|
+
AND: All children must be true/done
|
2086
|
+
OR: One of the children must be true/done
|
2087
|
+
AND/OR: One or more children
|
2088
|
+
NOT: Negation - don't do the following
|
2089
|
+
THEN: Sequence indicator
|
2090
|
+
IF: Conditional execution
|
2091
|
+
WHEN: Time-based condition
|
2092
|
+
WHILE: Duration condition
|
2093
|
+
UNLESS: Negative condition
|
2094
|
+
UNTIL: End condition
|
2095
|
+
CONTINUOUS: Items run continuously
|
2096
|
+
ENCRYPTION: Following items are encrypted
|
2097
|
+
EXAMPLES: List of examples follows
|
2098
|
+
CHOOSE: Select from options
|
2099
|
+
|
2100
|
+
#{"REFERENCES".b}
|
2101
|
+
|
2102
|
+
Enclosed in angle brackets < > (displayed in magenta):
|
2103
|
+
|
2104
|
+
Hard reference (redirect):
|
2105
|
+
<Item name> Jump to referenced item
|
2106
|
+
<<Item name>> Jump to item and return after
|
2107
|
+
|
2108
|
+
Soft reference (information):
|
2109
|
+
See <Other item> Look for more info at reference
|
2110
|
+
(<Related>) Apropos reference in parentheses
|
2111
|
+
|
2112
|
+
Special references:
|
2113
|
+
<+3> 3 items down
|
2114
|
+
<-5> 5 items up
|
2115
|
+
SKIP End current list level
|
2116
|
+
END End whole list
|
2117
|
+
|
2118
|
+
Path references:
|
2119
|
+
<Parent/Child/Item> Navigate down hierarchy
|
2120
|
+
<https://url.com> External URL
|
2121
|
+
<file:/path/to/file> File reference
|
2122
|
+
|
2123
|
+
#{"TEXT FORMATTING".b}
|
2124
|
+
|
2125
|
+
*bold text* Bold formatting
|
2126
|
+
/italic text/ Italic formatting
|
2127
|
+
_underlined_ Underlined text
|
2128
|
+
|
2129
|
+
Formatting can be combined and nested.
|
2130
|
+
|
2131
|
+
#{"ADDITIVES".b}
|
2132
|
+
|
2133
|
+
#{"Comments".fg("6")} (in parentheses or after semicolon):
|
2134
|
+
(This is a comment) Not executed in transitions
|
2135
|
+
; Line comment Everything after ; is a comment
|
2136
|
+
|
2137
|
+
#{"Quotes".fg("14")} (in quotation marks):
|
2138
|
+
"Literal text" Not interpreted as HyperList
|
2139
|
+
'Also literal' Single quotes work too
|
2140
|
+
|
2141
|
+
#{"Tags".fg("184")} (hashtags):
|
2142
|
+
#TODO #important Markers for categorization
|
2143
|
+
#ProjectAlpha No spaces allowed in tags
|
2144
|
+
|
2145
|
+
#{"SUBSTITUTIONS".b}
|
2146
|
+
|
2147
|
+
Use curly brackets for variable substitution:
|
2148
|
+
|
2149
|
+
[fruit = apples, oranges] Eat {fruit}
|
2150
|
+
→ Eat apples, then oranges
|
2151
|
+
|
2152
|
+
Ask user for input; Use {input}
|
2153
|
+
→ Variable from user interaction
|
2154
|
+
|
2155
|
+
#{"MULTI-LINE ITEMS".b}
|
2156
|
+
|
2157
|
+
Use + prefix for items spanning multiple lines:
|
2158
|
+
|
2159
|
+
+ This is a long item that continues
|
2160
|
+
on the next line with a space prefix
|
2161
|
+
|
2162
|
+
If one item at a level is multi-line, all items at that
|
2163
|
+
level must start with + or an identifier.
|
2164
|
+
|
2165
|
+
#{"SEPARATORS".b}
|
2166
|
+
|
2167
|
+
Newline → "then" (for sequence) or "and" (for children)
|
2168
|
+
Semicolon → Compact multiple items on one line
|
2169
|
+
|
2170
|
+
Examples:
|
2171
|
+
Task A
|
2172
|
+
Subtask 1
|
2173
|
+
Subtask 2
|
2174
|
+
→ Task A consists of Subtask 1 and Subtask 2
|
2175
|
+
|
2176
|
+
Task A; Task B; Task C
|
2177
|
+
→ Task A then Task B then Task C
|
2178
|
+
|
2179
|
+
#{"CHANGE MARKUP".b}
|
2180
|
+
|
2181
|
+
Special tags for document editing:
|
2182
|
+
|
2183
|
+
##< Mark item for deletion
|
2184
|
+
##> Mark item to be moved
|
2185
|
+
##<- Indent item left
|
2186
|
+
##-> Indent item right
|
2187
|
+
##Text## Mark item as changed
|
2188
|
+
|
2189
|
+
#{"LITERAL BLOCKS".b}
|
2190
|
+
|
2191
|
+
Escape HyperList interpretation with backslash blocks:
|
2192
|
+
|
2193
|
+
\\
|
2194
|
+
Everything here is literal text
|
2195
|
+
[This] is not a <qualifier> or reference
|
2196
|
+
Until the closing backslash
|
2197
|
+
\\
|
2198
|
+
|
2199
|
+
#{"EXAMPLES".b}
|
2200
|
+
|
2201
|
+
#{"Simple Todo List:".fg("1")}
|
2202
|
+
[ ] Buy groceries
|
2203
|
+
[ ] Milk
|
2204
|
+
[ ] Bread
|
2205
|
+
[X] Call dentist
|
2206
|
+
[O] Write report
|
2207
|
+
|
2208
|
+
#{"Process with Conditions:".fg("1")}
|
2209
|
+
Morning routine
|
2210
|
+
Check weather
|
2211
|
+
[Raining] Get umbrella
|
2212
|
+
[Cold] Wear jacket
|
2213
|
+
Make coffee
|
2214
|
+
[Weekday] Leave by 8am
|
2215
|
+
|
2216
|
+
#{"Project with References:".fg("1")}
|
2217
|
+
Project Setup
|
2218
|
+
Define requirements <specs.doc>
|
2219
|
+
Create timeline
|
2220
|
+
[2 weeks] Development phase
|
2221
|
+
[1 week] Testing <Testing/Plan>
|
2222
|
+
Deploy to production
|
2223
|
+
|
2224
|
+
#{"Complex Logic:".fg("1")}
|
2225
|
+
System check
|
2226
|
+
IF: Error detected
|
2227
|
+
Log error
|
2228
|
+
NOT: Continue processing
|
2229
|
+
Send alert
|
2230
|
+
ELSE:
|
2231
|
+
Continue normal operation
|
2232
|
+
|
2233
|
+
#{"═" * 60}
|
2234
|
+
|
2235
|
+
#{"COMPLETE HYPERLIST DEFINITION (from specification v2.6)".b}
|
2236
|
+
|
2237
|
+
HyperList
|
2238
|
+
[1+] HyperList Item
|
2239
|
+
[?] Starter; OR:
|
2240
|
+
Identifier (Numbers: Format = "1.1.1.1", Mixed: Format = "1A1A")
|
2241
|
+
[? Multi-line Item] The Identifier acts like the plus sign ("+")
|
2242
|
+
Multi-line Indicator = "+"
|
2243
|
+
+ If one Item on a certain indent is multi-line, all Items on the same indent
|
2244
|
+
(including single-line Items) must start with a plus sign ("+") or <Identifier>
|
2245
|
+
and all lines on the same indent after the first line must start with a space
|
2246
|
+
[?] Type
|
2247
|
+
OR:
|
2248
|
+
State = "S:" or "|"
|
2249
|
+
Transition = "T:" or "/"
|
2250
|
+
Children inherit Type from parent unless marked with different Type
|
2251
|
+
Can be skipped when the Item is obviously a state or transition
|
2252
|
+
Content; AND/OR:
|
2253
|
+
Element; AND/OR:
|
2254
|
+
Operator
|
2255
|
+
Anything operating on an Item or a set of Items
|
2256
|
+
[? Set of Items] Items are indented below the Operator
|
2257
|
+
Can be any of the usual logical operators
|
2258
|
+
Is written in capitals ending in a colon and a space
|
2259
|
+
EXAMPLES: "AND: ", "OR: ", "AND/OR: ", "NOT: ", "IMPLIES: "
|
2260
|
+
Can contain a Comment to specify the Operator
|
2261
|
+
EXAMPLE: "OR(PRIORITY): "
|
2262
|
+
Sub-Items are to be chosen in the order of priority as listed
|
2263
|
+
To make the Item run continuously, use "CONTINUOUS: "
|
2264
|
+
Item is done concurrent with remaining Items
|
2265
|
+
The Operator can be combined with a timestamp Tag
|
2266
|
+
EXAMPLE: "CONTINUOUS: YYYY-MM-07:" = Do the Item weekly
|
2267
|
+
To show that an Item is encrypted or is to be encrypted, use "ENCRYPTION: "
|
2268
|
+
OR:
|
2269
|
+
The encrypted block can be correctly indented
|
2270
|
+
The encrypted block contains indentation and is left justified
|
2271
|
+
This would seem to break the list, but is allowed
|
2272
|
+
A block can be included of "literal text" negating any HyperList markup
|
2273
|
+
Use a HyperList Item containing only the Operator "\\" to mark start/end
|
2274
|
+
EXAMPLE:
|
2275
|
+
\\
|
2276
|
+
This is a block of literal text...
|
2277
|
+
Where nothing counts as HyperList markup
|
2278
|
+
Thus - neither this: [?] nor THIS: <Test> - are seen as markup
|
2279
|
+
...until we end this block with...
|
2280
|
+
\\
|
2281
|
+
Qualifier
|
2282
|
+
Any statement in square brackets that qualifies an Item
|
2283
|
+
Specifies under what conditions an Item is to be executed, exists or is valid
|
2284
|
+
Several Qualifiers can be strung together, separated by commas
|
2285
|
+
All Qualifiers need to be fulfilled for the Item to be valid
|
2286
|
+
EXAMPLE: "[+YYYY-MM-DD 02.30, Button color = Red, 4, ?] Push button"
|
2287
|
+
Successive Qualifiers can be strung together, separated by periods; EXAMPLE:
|
2288
|
+
"[Apples. Oranges. Grapes]"
|
2289
|
+
Do Item in the context of "apples", then "oranges", then "grapes"
|
2290
|
+
EXAMPLES:
|
2291
|
+
Do Item 3 times = "[3]"
|
2292
|
+
Do Item if "the mail has arrived" = "[The mail has arrived]"
|
2293
|
+
Do Item 2 times while "foo=true" = "[2, foo=true]"
|
2294
|
+
Do Item from 3 to 5 times while "bar=false" = "[3..5, bar=false]"
|
2295
|
+
Do Item 1 or more times = "[1+]"
|
2296
|
+
Do Item 1 or more times while "Bob is polite" = "[1+, Bob is polite]"
|
2297
|
+
Do Item up to 4 times only while "zoo=0" = "[<4, zoo=0]"
|
2298
|
+
Optional Item = "[?]"
|
2299
|
+
Timestamp Qualifier = "[YYYY-MM-DD hh.mm.ss]"
|
2300
|
+
Shorten the format to the appropriate granularity
|
2301
|
+
Time relations
|
2302
|
+
Length of time to wait before doing the Item = "[+YYYY-MM-DD]"
|
2303
|
+
Less than a certain length of time after previous Item = "[<+YYYY-MM-DD]"
|
2304
|
+
More than a certain length of time after previous Item = "[>+YYYY-MM-DD]"
|
2305
|
+
Length of time to wait before doing next Item = "[-YYYY-MM-DD]"
|
2306
|
+
Less than a certain length of time before next Item = "[<-YYYY-MM-DD]"
|
2307
|
+
More than a certain length of time before next Item = "[>-YYYY-MM-DD]"
|
2308
|
+
Length of time to wait after doing referenced Item = "[+YYYY-MM-DD<Item>]"
|
2309
|
+
Other obvious time indicators may be used; EXAMPLES:
|
2310
|
+
"[+1 week]"
|
2311
|
+
"[-2 Martian years]"
|
2312
|
+
EXAMPLES:
|
2313
|
+
Wait one month before doing the Item = "[+YYYY-01-DD]"
|
2314
|
+
Do Item less than 4 days before next Item = "[<-YYYY-MM-04]"
|
2315
|
+
Wait one year and two days after Item X = "[+0001-00-02<X>]"
|
2316
|
+
Time repetition
|
2317
|
+
Obvious/intuitive repetition
|
2318
|
+
EXAMPLES:
|
2319
|
+
"[YYYY-MM-03]" = The third of every month
|
2320
|
+
"[YYYY-12-DD]" = Every day in every December
|
2321
|
+
"[2011-MM-05]" = The fifth of every month of 2011
|
2322
|
+
"[Tue,Fri 12.00]" = Noon every Tuesday and Friday
|
2323
|
+
Strict convention
|
2324
|
+
Format = YYYY-MM-DD+X Day hh.mm+Y - YYYY-MM-DD hh.mm; EXAMPLES:
|
2325
|
+
"[2011-05-01+7 13.00]" = 2011-05-01 1pm, repeated every 7 days
|
2326
|
+
"[2011-05-01+2,3,2]" = Every 2, then 3, then 2 days, in repetition
|
2327
|
+
"[2011-05-01+2 - 2012-05-01]" = Every second day for one year
|
2328
|
+
"[2011-05-01 13.00+1]" = 2011-05-01 1pm, repeated every hour
|
2329
|
+
"[2011-05-01 Fri,Sat - 2011-10-01]" = Every Fri & Sat in time interval
|
2330
|
+
Checking off Items
|
2331
|
+
Unchecked Item = "[_]"
|
2332
|
+
Item in progress = "[O]"
|
2333
|
+
Checked Item = "[x]"
|
2334
|
+
[?] Timestamp Tag after ("[x] YYYY-MM-DD hh.mm:ss:")
|
2335
|
+
Substitution
|
2336
|
+
Any statement in curly brackets is a substitution; EXAMPLES:
|
2337
|
+
"[fruit = apples, oranges, bananas] Eat {fruit}"
|
2338
|
+
Eat apples, then oranges, then bananas
|
2339
|
+
"Ask which painting she likes the best; Buy {painting}"
|
2340
|
+
Property
|
2341
|
+
Any attribute to the <Content>, ending in a colon and a space
|
2342
|
+
Gives additional information or description to the Item
|
2343
|
+
EXAMPLES:
|
2344
|
+
"Location = Someplace:", "Color = Green:", "Strength = Medium:" and "In Norway:"
|
2345
|
+
Description
|
2346
|
+
The main body of the HyperList Item, the "meat" of the line
|
2347
|
+
Additive; AND/OR:
|
2348
|
+
Reference
|
2349
|
+
+ An Item name or Identifier, list name or anything else
|
2350
|
+
enclosed in angle brackets ("<>"); EXAMPLES:
|
2351
|
+
Reference to a website = "<http://www.isene.org/>"
|
2352
|
+
Reference to a file = "<file:/path/to/filename>"
|
2353
|
+
+ There are two types of References; OR:
|
2354
|
+
Redirection (hard Reference)
|
2355
|
+
An Item consisting only of a Reference is a redirection
|
2356
|
+
For a transition Item = Jump to referenced Item and execute
|
2357
|
+
+ If the redirect is to jump back after executing
|
2358
|
+
the referenced Item (and its children), then add another
|
2359
|
+
set of angle brackets (<<Referenced Item>>)
|
2360
|
+
+ EXAMPLE: Use this when creating subroutines at
|
2361
|
+
the end of the list
|
2362
|
+
For a state Item = Include the referenced Item
|
2363
|
+
An Item consisting only of the key word "SKIP"
|
2364
|
+
Ends the current HyperList level
|
2365
|
+
An Item consisting only of the key word "END"
|
2366
|
+
Ends the whole HyperList
|
2367
|
+
Soft Reference
|
2368
|
+
Reference is part of an Item
|
2369
|
+
Look at referenced Item for info only
|
2370
|
+
Even softer Reference = have the Reference in parentheses
|
2371
|
+
An Item that is only something apropos
|
2372
|
+
+ A Reference to any Item upward in the HyperList is simply a
|
2373
|
+
Reference to the Item's <Content>
|
2374
|
+
+ A Reference containing several levels down a HyperList needs a "/"
|
2375
|
+
to separate each level, like a "path" (as with a URL) to the Item
|
2376
|
+
+ To make a Reference to a different branch in a HyperList,
|
2377
|
+
start the Reference from the highest common level on the list
|
2378
|
+
and include all Items down to the referenced Item
|
2379
|
+
EXAMPLE: Reference from here to <Hyperlist Item/Starter/Identifier>
|
2380
|
+
+ For long Items in a Reference, concatenation can be used
|
2381
|
+
The concatenation must be unique
|
2382
|
+
EXAMPLE: Reference from here to <Additive/Comment/Anything...>
|
2383
|
+
Tag
|
2384
|
+
A hash sign followed by any letters or numbers, used as a marker (#Tagged)
|
2385
|
+
Is not executed as a HyperList command
|
2386
|
+
Comment
|
2387
|
+
Anything within parentheses is a Comment
|
2388
|
+
Is not executed as a HyperList command
|
2389
|
+
Quote
|
2390
|
+
Anything in quotation marks is a Quote
|
2391
|
+
Is not executed as a HyperList command
|
2392
|
+
Change Markup; OR:
|
2393
|
+
Deletion
|
2394
|
+
Remove the Item by adding "##<" at the end of the Item
|
2395
|
+
Motion; OPTIONS:
|
2396
|
+
Move the Item by adding "##><Reference>"
|
2397
|
+
This moves the Item just below the referenced Item
|
2398
|
+
Move the Item one level in by adding "##<-" at the end of the Item
|
2399
|
+
Move the Item one level out by adding "##->" at the end of the Item
|
2400
|
+
EXAMPLE: Move an Item as a child to referenced Item = "##><Reference>##->"
|
2401
|
+
Changed Item
|
2402
|
+
Prefix an Item with "##Text##" to signify that a change has been made
|
2403
|
+
Add information inside the angle brackets as appropriate
|
2404
|
+
EXAMPLE: To show who changed it and when = "##John 2012-03-21##"
|
2405
|
+
Separator
|
2406
|
+
OR:
|
2407
|
+
Semicolon
|
2408
|
+
A semicolon is used to separate two HyperList Items on the same line
|
2409
|
+
Newline
|
2410
|
+
Used to add another Item on the same level
|
2411
|
+
Indent
|
2412
|
+
A Tab or an asterisk ("*")
|
2413
|
+
Used to add a child
|
2414
|
+
A child adds information to its parent
|
2415
|
+
A child is another regular <HyperList Item>
|
2416
|
+
Definition
|
2417
|
+
A Separator and the same or less indent normally reads "then:"
|
2418
|
+
[? Parent contains <Description>]
|
2419
|
+
The Separator and right indent reads "with:" or "consists of:"
|
2420
|
+
[? NOT: parent contains <Description>]
|
2421
|
+
The Separator and right indent reads "applies to:"
|
2422
|
+
A Separator between the children reads "and:"
|
2423
|
+
|
2424
|
+
#{"═" * 60}
|
2425
|
+
|
2426
|
+
HyperList is self-describing and Turing complete.
|
2427
|
+
It can represent literally anything.
|
2428
|
+
|
2429
|
+
For more information and examples:
|
2430
|
+
#{"https://isene.org/hyperlist/".fg("5")}
|
2431
|
+
|
2432
|
+
#{"═" * 60}
|
2433
|
+
|
2434
|
+
Press 'q' to return to your list, PgUp/PgDn to scroll
|
2435
|
+
DOC
|
2436
|
+
|
2437
|
+
# Store current state
|
2438
|
+
saved_items = @items.dup
|
2439
|
+
saved_current = @current
|
2440
|
+
saved_offset = @offset
|
2441
|
+
saved_filename = @filename
|
2442
|
+
saved_modified = @modified
|
2443
|
+
|
2444
|
+
# Create temporary documentation items
|
2445
|
+
@items = doc.split("\n").map { |line| {"text" => line, "level" => 0, "fold" => false, "raw" => true} }
|
2446
|
+
@current = 0
|
2447
|
+
@offset = 0
|
2448
|
+
@modified = false
|
2449
|
+
|
2450
|
+
# Documentation viewer loop
|
2451
|
+
loop do
|
2452
|
+
render_main
|
2453
|
+
@footer.text = "HyperList Documentation | q: return | PgUp/PgDn: scroll"
|
2454
|
+
@footer.refresh
|
2455
|
+
|
2456
|
+
c = getchr
|
2457
|
+
case c
|
2458
|
+
when "q", "ESC"
|
2459
|
+
break
|
2460
|
+
when "?"
|
2461
|
+
# Allow ?? to exit documentation
|
2462
|
+
if getchr == "?"
|
2463
|
+
break
|
2464
|
+
end
|
2465
|
+
when "j", "DOWN"
|
2466
|
+
move_down
|
2467
|
+
when "k", "UP"
|
2468
|
+
move_up
|
2469
|
+
when "PgUP"
|
2470
|
+
page_up
|
2471
|
+
when "PgDOWN"
|
2472
|
+
page_down
|
2473
|
+
when "HOME"
|
2474
|
+
@current = 0
|
2475
|
+
@offset = 0
|
2476
|
+
when "END"
|
2477
|
+
@current = @items.length - 1
|
2478
|
+
when "g"
|
2479
|
+
if getchr == "g"
|
2480
|
+
@current = 0
|
2481
|
+
@offset = 0
|
2482
|
+
end
|
2483
|
+
when "G"
|
2484
|
+
@current = @items.length - 1
|
2485
|
+
end
|
2486
|
+
end
|
2487
|
+
|
2488
|
+
# Restore original state
|
2489
|
+
@items = saved_items
|
2490
|
+
@current = saved_current
|
2491
|
+
@offset = saved_offset
|
2492
|
+
@filename = saved_filename
|
2493
|
+
@modified = saved_modified
|
2494
|
+
end
|
2495
|
+
|
2496
|
+
def handle_command
|
2497
|
+
@mode = :command
|
2498
|
+
@command = @footer.ask(":", "")
|
2499
|
+
@mode = :normal
|
2500
|
+
@footer.clear # Clear footer immediately
|
2501
|
+
@footer.refresh
|
2502
|
+
|
2503
|
+
return unless @command
|
2504
|
+
|
2505
|
+
case @command
|
2506
|
+
when "w", "write"
|
2507
|
+
if @filename
|
2508
|
+
save_file
|
2509
|
+
else
|
2510
|
+
filename_input = @footer.ask("Save as: ", "")
|
2511
|
+
@filename = File.expand_path(filename_input) if filename_input && !filename_input.empty?
|
2512
|
+
save_file if @filename && !@filename.empty?
|
2513
|
+
end
|
2514
|
+
when "q", "quit"
|
2515
|
+
if @modified
|
2516
|
+
@message = "Unsaved changes! Use :q! or Q to force quit"
|
2517
|
+
else
|
2518
|
+
quit
|
2519
|
+
end
|
2520
|
+
when "q!"
|
2521
|
+
quit
|
2522
|
+
when "wq", "x"
|
2523
|
+
if @filename
|
2524
|
+
save_file
|
2525
|
+
else
|
2526
|
+
filename_input = @footer.ask("Save as: ", "")
|
2527
|
+
@filename = File.expand_path(filename_input) if filename_input && !filename_input.empty?
|
2528
|
+
save_file if @filename && !@filename.empty?
|
2529
|
+
end
|
2530
|
+
quit
|
2531
|
+
when /^w\s+(.+)/
|
2532
|
+
@filename = File.expand_path($1)
|
2533
|
+
save_file
|
2534
|
+
when /^e\s+(.+)/
|
2535
|
+
if @modified
|
2536
|
+
@message = "Unsaved changes! Use :e! to force"
|
2537
|
+
else
|
2538
|
+
@filename = File.expand_path($1)
|
2539
|
+
load_file(@filename)
|
2540
|
+
@current = 0
|
2541
|
+
@offset = 0
|
2542
|
+
end
|
2543
|
+
when /^e!\s+(.+)/
|
2544
|
+
@filename = File.expand_path($1)
|
2545
|
+
load_file(@filename)
|
2546
|
+
@current = 0
|
2547
|
+
@offset = 0
|
2548
|
+
@modified = false
|
2549
|
+
when /^(export|ex)\s+(md|markdown|html|txt|text)\s*(.*)$/
|
2550
|
+
format = $2
|
2551
|
+
export_file = $3.empty? ? nil : $3
|
2552
|
+
export_to(format, export_file)
|
2553
|
+
when /^(export|ex)$/
|
2554
|
+
@message = "Usage: :export [md|html|txt] [filename] (or :ex)"
|
2555
|
+
when "recent", "r"
|
2556
|
+
show_recent_files
|
2557
|
+
when "autosave on", "as on"
|
2558
|
+
@auto_save_enabled = true
|
2559
|
+
@message = "Auto-save enabled (every #{@auto_save_interval} seconds)"
|
2560
|
+
when "autosave off", "as off"
|
2561
|
+
@auto_save_enabled = false
|
2562
|
+
@message = "Auto-save disabled"
|
2563
|
+
when /^(autosave|as) (\d+)$/
|
2564
|
+
@auto_save_interval = $2.to_i
|
2565
|
+
@message = "Auto-save interval set to #{@auto_save_interval} seconds"
|
2566
|
+
when "autosave", "as"
|
2567
|
+
status = @auto_save_enabled ? "enabled" : "disabled"
|
2568
|
+
@message = "Auto-save is #{status} (interval: #{@auto_save_interval}s)"
|
2569
|
+
when "template", "templates", "t"
|
2570
|
+
show_templates
|
2571
|
+
when "foldlevel"
|
2572
|
+
level = @footer.ask("Fold to level (0-9): ", "")
|
2573
|
+
if level =~ /^[0-9]$/
|
2574
|
+
expand_to_level(level.to_i)
|
2575
|
+
@message = "Folded to level #{level}"
|
2576
|
+
end
|
2577
|
+
when "performance", "perf"
|
2578
|
+
show_performance_stats
|
2579
|
+
when "graph", "hypergraph", "g"
|
2580
|
+
export_to_graph
|
2581
|
+
when "vsplit", "vs"
|
2582
|
+
toggle_split_view
|
2583
|
+
when "split"
|
2584
|
+
# Copy current section to split view
|
2585
|
+
copy_section_to_split
|
2586
|
+
else
|
2587
|
+
@message = "Unknown command: #{@command}"
|
2588
|
+
end
|
2589
|
+
end
|
2590
|
+
|
2591
|
+
def jump_to_reference
|
2592
|
+
visible = get_visible_items
|
2593
|
+
return if @current >= visible.length
|
2594
|
+
|
2595
|
+
text = visible[@current]["text"]
|
2596
|
+
|
2597
|
+
# Look for references in the format <reference> or <<reference>>
|
2598
|
+
if text =~ /<{1,2}([^>]+)>{1,2}/
|
2599
|
+
reference = $1
|
2600
|
+
|
2601
|
+
# Check if it's a URL or file reference
|
2602
|
+
if reference =~ /^(https?:\/\/|file:)/
|
2603
|
+
open_external_reference(reference)
|
2604
|
+
return
|
2605
|
+
end
|
2606
|
+
|
2607
|
+
# Search for the referenced item in the list
|
2608
|
+
target_index = find_item_by_reference(reference)
|
2609
|
+
|
2610
|
+
if target_index
|
2611
|
+
@current = target_index
|
2612
|
+
@message = "Jumped to: #{reference}"
|
2613
|
+
else
|
2614
|
+
@message = "Reference not found: #{reference}"
|
2615
|
+
end
|
2616
|
+
else
|
2617
|
+
@message = "No reference found on this line"
|
2618
|
+
end
|
2619
|
+
end
|
2620
|
+
|
2621
|
+
def open_file_reference
|
2622
|
+
visible = get_visible_items
|
2623
|
+
return if @current >= visible.length
|
2624
|
+
|
2625
|
+
text = visible[@current]["text"]
|
2626
|
+
|
2627
|
+
# Look for file references
|
2628
|
+
if text =~ /<file:([^>]+)>/
|
2629
|
+
filepath = $1
|
2630
|
+
open_external_file(filepath)
|
2631
|
+
elsif text =~ /<([^>]+\.[^>]+)>/ # Anything with an extension
|
2632
|
+
filepath = $1
|
2633
|
+
if File.exist?(filepath)
|
2634
|
+
open_external_file(filepath)
|
2635
|
+
else
|
2636
|
+
@message = "File not found: #{filepath}"
|
2637
|
+
end
|
2638
|
+
else
|
2639
|
+
@message = "No file reference found on this line"
|
2640
|
+
end
|
2641
|
+
end
|
2642
|
+
|
2643
|
+
def find_item_by_reference(reference)
|
2644
|
+
# Handle path-style references (e.g., "Item/SubItem/Detail")
|
2645
|
+
parts = reference.split('/')
|
2646
|
+
|
2647
|
+
visible = get_visible_items
|
2648
|
+
|
2649
|
+
# First try exact match
|
2650
|
+
visible.each_with_index do |item, idx|
|
2651
|
+
if item["text"].include?(reference) ||
|
2652
|
+
item["text"].gsub(/\[[^\]]*\]/, '').strip.include?(reference)
|
2653
|
+
return idx
|
2654
|
+
end
|
2655
|
+
end
|
2656
|
+
|
2657
|
+
# Try matching by path components
|
2658
|
+
if parts.length > 1
|
2659
|
+
current_level = 0
|
2660
|
+
found_indices = []
|
2661
|
+
|
2662
|
+
parts.each do |part|
|
2663
|
+
found = false
|
2664
|
+
visible.each_with_index do |item, idx|
|
2665
|
+
next if found_indices.include?(idx)
|
2666
|
+
|
2667
|
+
clean_text = item["text"].gsub(/\[[^\]]*\]/, '').strip
|
2668
|
+
if clean_text.include?(part) && item["level"] >= current_level
|
2669
|
+
found_indices << idx
|
2670
|
+
current_level = item["level"]
|
2671
|
+
found = true
|
2672
|
+
break
|
2673
|
+
end
|
2674
|
+
end
|
2675
|
+
|
2676
|
+
return nil unless found
|
2677
|
+
end
|
2678
|
+
|
2679
|
+
return found_indices.last
|
2680
|
+
end
|
2681
|
+
|
2682
|
+
# Try partial match
|
2683
|
+
visible.each_with_index do |item, idx|
|
2684
|
+
clean_text = item["text"].gsub(/\[[^\]]*\]/, '').strip
|
2685
|
+
if clean_text.downcase.include?(reference.downcase)
|
2686
|
+
return idx
|
2687
|
+
end
|
2688
|
+
end
|
2689
|
+
|
2690
|
+
nil
|
2691
|
+
end
|
2692
|
+
|
2693
|
+
def open_external_reference(url)
|
2694
|
+
# Try to open URL in default browser
|
2695
|
+
if RUBY_PLATFORM =~ /darwin/
|
2696
|
+
system("open", url)
|
2697
|
+
elsif RUBY_PLATFORM =~ /linux/
|
2698
|
+
system("xdg-open", url)
|
2699
|
+
else
|
2700
|
+
@message = "Cannot open URL on this platform"
|
2701
|
+
end
|
2702
|
+
end
|
2703
|
+
|
2704
|
+
def open_external_file(filepath)
|
2705
|
+
# Expand path if needed
|
2706
|
+
filepath = File.expand_path(filepath)
|
2707
|
+
|
2708
|
+
if File.exist?(filepath)
|
2709
|
+
if filepath.end_with?('.hl')
|
2710
|
+
# Open HyperList file in this app
|
2711
|
+
if @modified
|
2712
|
+
@message = "Save current file first"
|
2713
|
+
else
|
2714
|
+
@filename = File.expand_path(filepath)
|
2715
|
+
load_file(@filename)
|
2716
|
+
@current = 0
|
2717
|
+
@offset = 0
|
2718
|
+
@modified = false
|
2719
|
+
end
|
2720
|
+
else
|
2721
|
+
# Open in default application
|
2722
|
+
if RUBY_PLATFORM =~ /darwin/
|
2723
|
+
system("open", filepath)
|
2724
|
+
elsif RUBY_PLATFORM =~ /linux/
|
2725
|
+
system("xdg-open", filepath)
|
2726
|
+
else
|
2727
|
+
@message = "Cannot open file on this platform"
|
2728
|
+
end
|
2729
|
+
end
|
2730
|
+
else
|
2731
|
+
@message = "File not found: #{filepath}"
|
2732
|
+
end
|
2733
|
+
end
|
2734
|
+
|
2735
|
+
def load_templates
|
2736
|
+
{
|
2737
|
+
"project" => [
|
2738
|
+
{"text" => "Project: [Project Name]", "level" => 0},
|
2739
|
+
{"text" => "[_] Define project scope", "level" => 1},
|
2740
|
+
{"text" => "[_] Identify stakeholders", "level" => 1},
|
2741
|
+
{"text" => "[_] Create timeline", "level" => 1},
|
2742
|
+
{"text" => "Planning", "level" => 1},
|
2743
|
+
{"text" => "[_] Resource allocation", "level" => 2},
|
2744
|
+
{"text" => "[_] Risk assessment", "level" => 2},
|
2745
|
+
{"text" => "[_] Budget planning", "level" => 2},
|
2746
|
+
{"text" => "Implementation", "level" => 1},
|
2747
|
+
{"text" => "[_] Phase 1: Foundation", "level" => 2},
|
2748
|
+
{"text" => "[_] Phase 2: Development", "level" => 2},
|
2749
|
+
{"text" => "[_] Phase 3: Testing", "level" => 2},
|
2750
|
+
{"text" => "[_] Phase 4: Deployment", "level" => 2},
|
2751
|
+
{"text" => "Review", "level" => 1},
|
2752
|
+
{"text" => "[_] Gather feedback", "level" => 2},
|
2753
|
+
{"text" => "[_] Document lessons learned", "level" => 2},
|
2754
|
+
{"text" => "[_] Archive project materials", "level" => 2}
|
2755
|
+
],
|
2756
|
+
"meeting" => [
|
2757
|
+
{"text" => "Meeting: [Title]", "level" => 0},
|
2758
|
+
{"text" => "Date: #{Time.now.strftime('%Y-%m-%d %H:%M')}", "level" => 1},
|
2759
|
+
{"text" => "Location: [Conference Room/Online]", "level" => 1},
|
2760
|
+
{"text" => "Attendees", "level" => 1},
|
2761
|
+
{"text" => "[Name 1]", "level" => 2},
|
2762
|
+
{"text" => "[Name 2]", "level" => 2},
|
2763
|
+
{"text" => "Agenda", "level" => 1},
|
2764
|
+
{"text" => "[_] Opening remarks", "level" => 2},
|
2765
|
+
{"text" => "[_] Review previous action items", "level" => 2},
|
2766
|
+
{"text" => "[_] Main topics", "level" => 2},
|
2767
|
+
{"text" => "Topic 1: [Description]", "level" => 3},
|
2768
|
+
{"text" => "Topic 2: [Description]", "level" => 3},
|
2769
|
+
{"text" => "[_] Q&A session", "level" => 2},
|
2770
|
+
{"text" => "[_] Next steps", "level" => 2},
|
2771
|
+
{"text" => "Action Items", "level" => 1},
|
2772
|
+
{"text" => "[_] [Action 1] - Assigned to: [Name] - Due: [Date]", "level" => 2},
|
2773
|
+
{"text" => "[_] [Action 2] - Assigned to: [Name] - Due: [Date]", "level" => 2},
|
2774
|
+
{"text" => "Notes", "level" => 1},
|
2775
|
+
{"text" => "[Add meeting notes here]", "level" => 2}
|
2776
|
+
],
|
2777
|
+
"daily" => [
|
2778
|
+
{"text" => "Daily Plan: #{Time.now.strftime('%Y-%m-%d')}", "level" => 0},
|
2779
|
+
{"text" => "Morning Routine", "level" => 1},
|
2780
|
+
{"text" => "[_] Review calendar", "level" => 2},
|
2781
|
+
{"text" => "[_] Check emails", "level" => 2},
|
2782
|
+
{"text" => "[_] Plan priorities", "level" => 2},
|
2783
|
+
{"text" => "Priority Tasks", "level" => 1},
|
2784
|
+
{"text" => "[_] [High Priority Task 1]", "level" => 2},
|
2785
|
+
{"text" => "[_] [High Priority Task 2]", "level" => 2},
|
2786
|
+
{"text" => "[_] [High Priority Task 3]", "level" => 2},
|
2787
|
+
{"text" => "Regular Tasks", "level" => 1},
|
2788
|
+
{"text" => "[_] [Task 1]", "level" => 2},
|
2789
|
+
{"text" => "[_] [Task 2]", "level" => 2},
|
2790
|
+
{"text" => "Meetings/Appointments", "level" => 1},
|
2791
|
+
{"text" => "[Time] - [Meeting/Event]", "level" => 2},
|
2792
|
+
{"text" => "Notes", "level" => 1},
|
2793
|
+
{"text" => "[Daily observations and reflections]", "level" => 2}
|
2794
|
+
],
|
2795
|
+
"checklist" => [
|
2796
|
+
{"text" => "Checklist: [Title]", "level" => 0},
|
2797
|
+
{"text" => "[_] Item 1", "level" => 1},
|
2798
|
+
{"text" => "[_] Item 2", "level" => 1},
|
2799
|
+
{"text" => "[_] Item 3", "level" => 1},
|
2800
|
+
{"text" => "[_] Item 4", "level" => 1},
|
2801
|
+
{"text" => "[_] Item 5", "level" => 1}
|
2802
|
+
],
|
2803
|
+
"brainstorm" => [
|
2804
|
+
{"text" => "Brainstorming: [Topic]", "level" => 0},
|
2805
|
+
{"text" => "Problem Statement", "level" => 1},
|
2806
|
+
{"text" => "[Define the problem or opportunity]", "level" => 2},
|
2807
|
+
{"text" => "Ideas", "level" => 1},
|
2808
|
+
{"text" => "Category 1", "level" => 2},
|
2809
|
+
{"text" => "Idea A", "level" => 3},
|
2810
|
+
{"text" => "Idea B", "level" => 3},
|
2811
|
+
{"text" => "Category 2", "level" => 2},
|
2812
|
+
{"text" => "Idea C", "level" => 3},
|
2813
|
+
{"text" => "Idea D", "level" => 3},
|
2814
|
+
{"text" => "Evaluation Criteria", "level" => 1},
|
2815
|
+
{"text" => "Feasibility", "level" => 2},
|
2816
|
+
{"text" => "Impact", "level" => 2},
|
2817
|
+
{"text" => "Resources Required", "level" => 2},
|
2818
|
+
{"text" => "Next Steps", "level" => 1},
|
2819
|
+
{"text" => "[_] Research top ideas", "level" => 2},
|
2820
|
+
{"text" => "[_] Create action plan", "level" => 2}
|
2821
|
+
],
|
2822
|
+
"recipe" => [
|
2823
|
+
{"text" => "Recipe: [Name]", "level" => 0},
|
2824
|
+
{"text" => "Servings: [Number]", "level" => 1},
|
2825
|
+
{"text" => "Prep Time: [Time]", "level" => 1},
|
2826
|
+
{"text" => "Cook Time: [Time]", "level" => 1},
|
2827
|
+
{"text" => "Ingredients", "level" => 1},
|
2828
|
+
{"text" => "[Amount] [Ingredient 1]", "level" => 2},
|
2829
|
+
{"text" => "[Amount] [Ingredient 2]", "level" => 2},
|
2830
|
+
{"text" => "[Amount] [Ingredient 3]", "level" => 2},
|
2831
|
+
{"text" => "Instructions", "level" => 1},
|
2832
|
+
{"text" => "[_] Step 1: [Description]", "level" => 2},
|
2833
|
+
{"text" => "[_] Step 2: [Description]", "level" => 2},
|
2834
|
+
{"text" => "[_] Step 3: [Description]", "level" => 2},
|
2835
|
+
{"text" => "Notes", "level" => 1},
|
2836
|
+
{"text" => "[Tips, variations, serving suggestions]", "level" => 2}
|
2837
|
+
]
|
2838
|
+
}
|
2839
|
+
end
|
2840
|
+
|
2841
|
+
def show_templates
|
2842
|
+
# Save current state
|
2843
|
+
saved_items = @items.dup
|
2844
|
+
saved_current = @current
|
2845
|
+
saved_offset = @offset
|
2846
|
+
saved_filename = @filename
|
2847
|
+
saved_modified = @modified
|
2848
|
+
|
2849
|
+
# Create items for template selection
|
2850
|
+
@items = []
|
2851
|
+
@items << {"text" => "TEMPLATES (press Enter to insert, q to cancel)", "level" => 0, "fold" => false, "raw" => true}
|
2852
|
+
@items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
|
2853
|
+
|
2854
|
+
template_list = [
|
2855
|
+
["project", "Project Plan - Complete project management template"],
|
2856
|
+
["meeting", "Meeting Agenda - Structure for meeting notes"],
|
2857
|
+
["daily", "Daily Planner - Daily task and schedule template"],
|
2858
|
+
["checklist", "Simple Checklist - Basic checkbox list"],
|
2859
|
+
["brainstorm", "Brainstorming Session - Idea generation template"],
|
2860
|
+
["recipe", "Recipe - Cooking recipe structure"]
|
2861
|
+
]
|
2862
|
+
|
2863
|
+
template_list.each_with_index do |(key, desc), idx|
|
2864
|
+
@items << {"text" => "#{idx+1}. #{key.capitalize}: #{desc}", "level" => 0, "fold" => false, "raw" => true, "template_key" => key}
|
2865
|
+
end
|
2866
|
+
|
2867
|
+
@current = 2 # Start at first template
|
2868
|
+
@offset = 0
|
2869
|
+
@modified = false
|
2870
|
+
|
2871
|
+
selected_template = nil
|
2872
|
+
|
2873
|
+
# Template viewer loop
|
2874
|
+
loop do
|
2875
|
+
render_main
|
2876
|
+
@footer.text = "Templates | Enter: insert | q: cancel | j/k: navigate"
|
2877
|
+
@footer.refresh
|
2878
|
+
|
2879
|
+
c = getchr
|
2880
|
+
case c
|
2881
|
+
when "q", "ESC"
|
2882
|
+
# Restore original state
|
2883
|
+
@items = saved_items
|
2884
|
+
@current = saved_current
|
2885
|
+
@offset = saved_offset
|
2886
|
+
@filename = saved_filename
|
2887
|
+
@modified = saved_modified
|
2888
|
+
break
|
2889
|
+
when "j", "DOWN"
|
2890
|
+
move_down if @current < @items.length - 1
|
2891
|
+
when "k", "UP"
|
2892
|
+
move_up if @current > 2 # Don't go above first template
|
2893
|
+
when "ENTER", "l"
|
2894
|
+
if @current >= 2 && @items[@current]["template_key"]
|
2895
|
+
selected_template = @items[@current]["template_key"]
|
2896
|
+
break
|
2897
|
+
end
|
2898
|
+
when /^[1-6]$/
|
2899
|
+
# Allow number key selection
|
2900
|
+
idx = c.to_i - 1
|
2901
|
+
if idx < template_list.length
|
2902
|
+
selected_template = template_list[idx][0]
|
2903
|
+
break
|
2904
|
+
end
|
2905
|
+
end
|
2906
|
+
end
|
2907
|
+
|
2908
|
+
# Insert selected template
|
2909
|
+
if selected_template
|
2910
|
+
# Restore original state first
|
2911
|
+
@items = saved_items
|
2912
|
+
@current = saved_current
|
2913
|
+
@offset = saved_offset
|
2914
|
+
@filename = saved_filename
|
2915
|
+
@modified = saved_modified
|
2916
|
+
|
2917
|
+
insert_template(selected_template)
|
2918
|
+
end
|
2919
|
+
end
|
2920
|
+
|
2921
|
+
def insert_template(template_key)
|
2922
|
+
template_items = @templates[template_key]
|
2923
|
+
return unless template_items
|
2924
|
+
|
2925
|
+
save_state_for_undo
|
2926
|
+
|
2927
|
+
# Get current item level to adjust template indentation
|
2928
|
+
current_level = @items[@current]["level"]
|
2929
|
+
|
2930
|
+
# Insert template items after current position
|
2931
|
+
insertion_point = @current + 1
|
2932
|
+
|
2933
|
+
template_items.each_with_index do |template_item, idx|
|
2934
|
+
new_item = {
|
2935
|
+
"text" => template_item["text"],
|
2936
|
+
"level" => current_level + template_item["level"],
|
2937
|
+
"fold" => false
|
2938
|
+
}
|
2939
|
+
@items.insert(insertion_point + idx, new_item)
|
2940
|
+
end
|
2941
|
+
|
2942
|
+
@modified = true
|
2943
|
+
@message = "Template '#{template_key}' inserted"
|
2944
|
+
end
|
2945
|
+
|
2946
|
+
def start_macro_recording(key)
|
2947
|
+
@macro_recording = true
|
2948
|
+
@macro_key = key
|
2949
|
+
@macro_buffer = []
|
2950
|
+
@message = "Recording macro to register '#{key}'..."
|
2951
|
+
end
|
2952
|
+
|
2953
|
+
def stop_macro_recording
|
2954
|
+
if @macro_key && !@macro_buffer.empty?
|
2955
|
+
# Remove the 'q' that stopped recording from the buffer if it's there
|
2956
|
+
@macro_buffer.pop if @macro_buffer.last == "q"
|
2957
|
+
@macro_register[@macro_key] = @macro_buffer.dup
|
2958
|
+
@message = "Macro recorded to register '#{@macro_key}' (#{@macro_buffer.length} actions)"
|
2959
|
+
else
|
2960
|
+
@message = "Macro recording cancelled"
|
2961
|
+
end
|
2962
|
+
@macro_recording = false
|
2963
|
+
@macro_key = nil
|
2964
|
+
@macro_buffer = []
|
2965
|
+
end
|
2966
|
+
|
2967
|
+
def play_macro(key)
|
2968
|
+
macro = @macro_register[key]
|
2969
|
+
if macro && !macro.empty?
|
2970
|
+
@message = "Playing macro '#{key}'..."
|
2971
|
+
render_footer
|
2972
|
+
|
2973
|
+
# Replay each action in the macro
|
2974
|
+
macro.each do |action|
|
2975
|
+
# Simulate the key press
|
2976
|
+
case @mode
|
2977
|
+
when :normal
|
2978
|
+
process_normal_key(action)
|
2979
|
+
when :insert
|
2980
|
+
# Handle insert mode separately if needed
|
2981
|
+
@mode = :normal if action == "ESC"
|
2982
|
+
end
|
2983
|
+
|
2984
|
+
# Update display after each action for visual feedback
|
2985
|
+
render
|
2986
|
+
end
|
2987
|
+
|
2988
|
+
@message = "Macro '#{key}' executed (#{macro.length} actions)"
|
2989
|
+
else
|
2990
|
+
@message = "No macro in register '#{key}'"
|
2991
|
+
end
|
2992
|
+
end
|
2993
|
+
|
2994
|
+
def process_normal_key(c)
|
2995
|
+
# This processes a single key in normal mode for macro replay
|
2996
|
+
case c
|
2997
|
+
when "j", "DOWN"
|
2998
|
+
move_down
|
2999
|
+
when "k", "UP"
|
3000
|
+
move_up
|
3001
|
+
when "h"
|
3002
|
+
go_to_parent
|
3003
|
+
when "l"
|
3004
|
+
go_to_first_child
|
3005
|
+
when "o"
|
3006
|
+
insert_line_below
|
3007
|
+
when "O"
|
3008
|
+
insert_line_above
|
3009
|
+
when "a"
|
3010
|
+
insert_child
|
3011
|
+
when "d"
|
3012
|
+
# Check if it's dd
|
3013
|
+
if getchr == "d"
|
3014
|
+
delete_line
|
3015
|
+
end
|
3016
|
+
when "y"
|
3017
|
+
# Check if it's yy
|
3018
|
+
if getchr == "y"
|
3019
|
+
copy_line
|
3020
|
+
end
|
3021
|
+
when "p"
|
3022
|
+
paste
|
3023
|
+
when "v"
|
3024
|
+
toggle_checkbox
|
3025
|
+
when "V"
|
3026
|
+
toggle_checkbox_with_date
|
3027
|
+
when "TAB"
|
3028
|
+
indent_with_children
|
3029
|
+
when "S-TAB"
|
3030
|
+
unindent_with_children
|
3031
|
+
when "RIGHT"
|
3032
|
+
indent_line
|
3033
|
+
when "LEFT"
|
3034
|
+
unindent_line
|
3035
|
+
when " "
|
3036
|
+
toggle_fold
|
3037
|
+
when "u"
|
3038
|
+
undo
|
3039
|
+
when "/"
|
3040
|
+
# Skip search in macros for now
|
3041
|
+
@message = "Search skipped in macro"
|
3042
|
+
when "i", "ENTER"
|
3043
|
+
# Skip interactive editing in macros
|
3044
|
+
@message = "Text editing skipped in macro"
|
3045
|
+
end
|
3046
|
+
end
|
3047
|
+
|
3048
|
+
def toggle_split_view
|
3049
|
+
@split_view = !@split_view
|
3050
|
+
if @split_view
|
3051
|
+
@split_items = @items.dup # Copy current items to split view
|
3052
|
+
@split_current = @current
|
3053
|
+
@split_offset = @offset
|
3054
|
+
@active_pane = :main
|
3055
|
+
set_message("Split view enabled. Use 'ww' to switch panes.")
|
3056
|
+
else
|
3057
|
+
@split_items = []
|
3058
|
+
set_message("Split view disabled")
|
3059
|
+
end
|
3060
|
+
setup_ui
|
3061
|
+
render # Re-render everything after changing the UI layout
|
3062
|
+
end
|
3063
|
+
|
3064
|
+
def copy_section_to_split
|
3065
|
+
if !@split_view
|
3066
|
+
toggle_split_view
|
3067
|
+
end
|
3068
|
+
|
3069
|
+
# Find the current section (same or less indented level)
|
3070
|
+
current_item = @items[@current]
|
3071
|
+
current_level = current_item["level"]
|
3072
|
+
|
3073
|
+
# Find section boundaries
|
3074
|
+
start_idx = @current
|
3075
|
+
end_idx = @current
|
3076
|
+
|
3077
|
+
# Find start of section (go up to same or less level)
|
3078
|
+
(0...@current).reverse_each do |idx|
|
3079
|
+
if @items[idx]["level"] <= current_level
|
3080
|
+
start_idx = idx
|
3081
|
+
break
|
3082
|
+
end
|
3083
|
+
end
|
3084
|
+
|
3085
|
+
# Find end of section
|
3086
|
+
(@current + 1...@items.length).each do |idx|
|
3087
|
+
if @items[idx]["level"] <= current_level
|
3088
|
+
break
|
3089
|
+
end
|
3090
|
+
end_idx = idx
|
3091
|
+
end
|
3092
|
+
|
3093
|
+
# Copy section to split view
|
3094
|
+
@split_items = @items[start_idx..end_idx]
|
3095
|
+
@split_current = 0
|
3096
|
+
@split_offset = 0
|
3097
|
+
|
3098
|
+
@message = "Section copied to split view (#{@split_items.length} items)"
|
3099
|
+
end
|
3100
|
+
|
3101
|
+
def handle_window_command
|
3102
|
+
c = getchr
|
3103
|
+
case c
|
3104
|
+
when "w"
|
3105
|
+
# Switch active pane
|
3106
|
+
if @split_view
|
3107
|
+
@active_pane = (@active_pane == :main) ? :split : :main
|
3108
|
+
@message = "Switched to #{@active_pane} pane"
|
3109
|
+
end
|
3110
|
+
when "v"
|
3111
|
+
# Vertical split
|
3112
|
+
toggle_split_view
|
3113
|
+
when "o"
|
3114
|
+
# Close split, keep only current pane
|
3115
|
+
@split_view = false
|
3116
|
+
setup_ui
|
3117
|
+
@message = "Split view closed"
|
3118
|
+
when "="
|
3119
|
+
# Make panes equal size (already equal in our implementation)
|
3120
|
+
@message = "Panes are equal size"
|
3121
|
+
end
|
3122
|
+
end
|
3123
|
+
|
3124
|
+
def render_split_pane
|
3125
|
+
return unless @split_view && @split_pane
|
3126
|
+
|
3127
|
+
# Similar to render_main but for split pane
|
3128
|
+
view_height = @split_pane.h
|
3129
|
+
if @split_current < @split_offset
|
3130
|
+
@split_offset = @split_current
|
3131
|
+
elsif @split_current >= @split_offset + view_height
|
3132
|
+
@split_offset = @split_current - view_height + 1
|
3133
|
+
end
|
3134
|
+
|
3135
|
+
lines = []
|
3136
|
+
start_idx = @split_offset
|
3137
|
+
end_idx = [@split_offset + view_height, @split_items.length].min
|
3138
|
+
|
3139
|
+
(start_idx...end_idx).each do |idx|
|
3140
|
+
item = @split_items[idx]
|
3141
|
+
next unless item
|
3142
|
+
|
3143
|
+
line = " " * item["level"]
|
3144
|
+
|
3145
|
+
# Add fold indicator
|
3146
|
+
if has_children_in_array?(idx, @split_items) && item["fold"]
|
3147
|
+
line += "▶ ".fg("245")
|
3148
|
+
elsif has_children_in_array?(idx, @split_items)
|
3149
|
+
line += "▷ ".fg("245")
|
3150
|
+
else
|
3151
|
+
line += " "
|
3152
|
+
end
|
3153
|
+
|
3154
|
+
# Process text (simplified for split view)
|
3155
|
+
line += process_text(item["text"], false)
|
3156
|
+
|
3157
|
+
# Highlight current line in split pane
|
3158
|
+
if idx == @split_current
|
3159
|
+
line = line.r # Always use reverse (white background) for current line
|
3160
|
+
end
|
3161
|
+
|
3162
|
+
lines << line
|
3163
|
+
end
|
3164
|
+
|
3165
|
+
@split_pane.text = lines.join("\n")
|
3166
|
+
@split_pane.refresh
|
3167
|
+
end
|
3168
|
+
|
3169
|
+
def has_children_in_array?(idx, items_array)
|
3170
|
+
return false if idx >= items_array.length - 1
|
3171
|
+
current_level = items_array[idx]["level"]
|
3172
|
+
next_level = items_array[idx + 1]["level"]
|
3173
|
+
next_level > current_level
|
3174
|
+
end
|
3175
|
+
|
3176
|
+
def move_in_active_pane(direction)
|
3177
|
+
if @split_view && @active_pane == :split
|
3178
|
+
case direction
|
3179
|
+
when :down
|
3180
|
+
@split_current = [@split_current + 1, @split_items.length - 1].min if @split_current < @split_items.length - 1
|
3181
|
+
when :up
|
3182
|
+
@split_current = [@split_current - 1, 0].max if @split_current > 0
|
3183
|
+
end
|
3184
|
+
else
|
3185
|
+
case direction
|
3186
|
+
when :down
|
3187
|
+
move_down
|
3188
|
+
when :up
|
3189
|
+
move_up
|
3190
|
+
end
|
3191
|
+
end
|
3192
|
+
end
|
3193
|
+
|
3194
|
+
def show_performance_stats
|
3195
|
+
total_items = @items.length
|
3196
|
+
visible_items = get_visible_items.length
|
3197
|
+
folded_count = @items.count { |item| item["fold"] }
|
3198
|
+
cache_size = @processed_cache.size
|
3199
|
+
split_info = @split_view ? " | Split: #{@split_items.length} items" : ""
|
3200
|
+
|
3201
|
+
@message = "Items: #{total_items} (#{visible_items} visible, #{folded_count} folded) | Cache: #{cache_size}#{split_info}"
|
3202
|
+
end
|
3203
|
+
|
3204
|
+
def export_to_graph
|
3205
|
+
# Ask user for graph options - return nil if cancelled (ESC pressed)
|
3206
|
+
graph_type = @footer.ask("Graph type (state/trans) - ESC to cancel: ", "")
|
3207
|
+
return if graph_type.empty?
|
3208
|
+
return unless graph_type =~ /^(state|trans)$/
|
3209
|
+
|
3210
|
+
direction = @footer.ask("Direction (TB/BT/LR/RL) - ESC to cancel: ", "")
|
3211
|
+
return if direction.empty?
|
3212
|
+
return unless direction =~ /^(TB|BT|LR|RL)$/
|
3213
|
+
|
3214
|
+
line_style = @footer.ask("Line style (spline/ortho/poly) - ESC to cancel: ", "")
|
3215
|
+
return if line_style.empty?
|
3216
|
+
return unless line_style =~ /^(spline|ortho|polyline)$/
|
3217
|
+
line_style = "polyline" if line_style == "poly"
|
3218
|
+
|
3219
|
+
theme = @footer.ask("Theme (default/business/tech/pastel) - ESC to cancel: ", "")
|
3220
|
+
return if theme.empty?
|
3221
|
+
return unless theme =~ /^(default|business|tech|pastel)$/
|
3222
|
+
|
3223
|
+
# Generate default output filename based on current file
|
3224
|
+
default_output = if @filename
|
3225
|
+
File.basename(@filename, File.extname(@filename)) + ".png"
|
3226
|
+
else
|
3227
|
+
"hyperlist_graph.png"
|
3228
|
+
end
|
3229
|
+
|
3230
|
+
output_file = @footer.ask("Output filename (.png) - ESC to cancel: ", default_output)
|
3231
|
+
return if output_file == default_output && output_file.empty? # If default was empty and user pressed ESC
|
3232
|
+
return if !output_file || output_file.strip.empty?
|
3233
|
+
output_file += ".png" unless output_file.end_with?(".png")
|
3234
|
+
|
3235
|
+
open_after = @footer.ask("Open graph after generation? (y/n) - ESC to cancel: ", "")
|
3236
|
+
return if open_after.empty?
|
3237
|
+
open_after = open_after.downcase == "y"
|
3238
|
+
|
3239
|
+
# Generate the graph
|
3240
|
+
begin
|
3241
|
+
dot_content = generate_dot_graph(graph_type, direction, line_style, theme)
|
3242
|
+
|
3243
|
+
# Write DOT file temporarily
|
3244
|
+
dot_file = output_file.sub(/\.png$/, '.dot')
|
3245
|
+
File.write(dot_file, dot_content)
|
3246
|
+
|
3247
|
+
# Generate PNG using Graphviz
|
3248
|
+
system("dot -Tpng #{dot_file} -o #{output_file}")
|
3249
|
+
|
3250
|
+
# Clean up DOT file
|
3251
|
+
File.delete(dot_file) if File.exist?(dot_file)
|
3252
|
+
|
3253
|
+
if File.exist?(output_file)
|
3254
|
+
@message = "Graph exported to #{output_file}"
|
3255
|
+
|
3256
|
+
# Open the graph if requested
|
3257
|
+
if open_after
|
3258
|
+
# Try different methods to open the file
|
3259
|
+
if system("which xdg-open > /dev/null 2>&1")
|
3260
|
+
system("xdg-open #{output_file} 2>/dev/null &")
|
3261
|
+
@message += " (opened)"
|
3262
|
+
elsif system("which runmailcap > /dev/null 2>&1")
|
3263
|
+
system("runmailcap #{output_file} 2>/dev/null &")
|
3264
|
+
@message += " (opened)"
|
3265
|
+
elsif system("which open > /dev/null 2>&1") # macOS
|
3266
|
+
system("open #{output_file} 2>/dev/null &")
|
3267
|
+
@message += " (opened)"
|
3268
|
+
else
|
3269
|
+
@message += " (could not auto-open)"
|
3270
|
+
end
|
3271
|
+
end
|
3272
|
+
else
|
3273
|
+
@message = "Failed to generate graph. Is Graphviz installed?"
|
3274
|
+
end
|
3275
|
+
rescue => e
|
3276
|
+
@message = "Graph export failed: #{e.message}"
|
3277
|
+
end
|
3278
|
+
end
|
3279
|
+
|
3280
|
+
def generate_dot_graph(type, direction, spline, theme)
|
3281
|
+
# Theme definitions
|
3282
|
+
themes = {
|
3283
|
+
'default' => {
|
3284
|
+
node_color: 'black',
|
3285
|
+
edge_color: 'black',
|
3286
|
+
font: 'Helvetica',
|
3287
|
+
bgcolor: 'white'
|
3288
|
+
},
|
3289
|
+
'business' => {
|
3290
|
+
node_color: '#2c3e50',
|
3291
|
+
edge_color: '#34495e',
|
3292
|
+
font: 'Arial',
|
3293
|
+
bgcolor: '#ecf0f1',
|
3294
|
+
node_style: 'rounded,filled',
|
3295
|
+
node_fillcolor: '#3498db',
|
3296
|
+
node_fontcolor: 'white'
|
3297
|
+
},
|
3298
|
+
'tech' => {
|
3299
|
+
node_color: '#00ff00',
|
3300
|
+
edge_color: '#00aa00',
|
3301
|
+
font: 'Courier',
|
3302
|
+
bgcolor: 'black',
|
3303
|
+
node_fontcolor: '#00ff00'
|
3304
|
+
},
|
3305
|
+
'pastel' => {
|
3306
|
+
node_color: '#8b7d6b',
|
3307
|
+
edge_color: '#cdaa7d',
|
3308
|
+
font: 'Georgia',
|
3309
|
+
bgcolor: '#fff8dc',
|
3310
|
+
node_style: 'filled',
|
3311
|
+
node_fillcolor: '#ffe4b5'
|
3312
|
+
}
|
3313
|
+
}
|
3314
|
+
|
3315
|
+
current_theme = themes[theme] || themes['default']
|
3316
|
+
|
3317
|
+
dot = "digraph HyperGraph {\n"
|
3318
|
+
dot << "rankdir=#{direction}\n"
|
3319
|
+
dot << "splines=#{spline}\n"
|
3320
|
+
dot << "bgcolor=\"#{current_theme[:bgcolor]}\"\n" if current_theme[:bgcolor]
|
3321
|
+
dot << "overlap=false\n"
|
3322
|
+
|
3323
|
+
# Edge settings
|
3324
|
+
dot << "edge [fontsize=8 len=1"
|
3325
|
+
dot << " color=\"#{current_theme[:edge_color]}\"" if current_theme[:edge_color]
|
3326
|
+
dot << "]\n"
|
3327
|
+
|
3328
|
+
# Node settings
|
3329
|
+
dot << "node ["
|
3330
|
+
dot << "shape=#{type == 'trans' ? 'box' : 'ellipse'}"
|
3331
|
+
dot << " color=\"#{current_theme[:node_color]}\"" if current_theme[:node_color]
|
3332
|
+
dot << " fontname=\"#{current_theme[:font]}\"" if current_theme[:font]
|
3333
|
+
dot << " fontcolor=\"#{current_theme[:node_fontcolor]}\"" if current_theme[:node_fontcolor]
|
3334
|
+
dot << " style=\"#{current_theme[:node_style]}\"" if current_theme[:node_style]
|
3335
|
+
dot << " fillcolor=\"#{current_theme[:node_fillcolor]}\"" if current_theme[:node_fillcolor]
|
3336
|
+
dot << "]\n\n"
|
3337
|
+
|
3338
|
+
# Process HyperList items into nodes
|
3339
|
+
@items.each_with_index do |item, idx|
|
3340
|
+
next if item["raw"] # Skip raw items like help text
|
3341
|
+
|
3342
|
+
node_id = "node_#{idx}"
|
3343
|
+
label = escape_dot_label(item["text"])
|
3344
|
+
|
3345
|
+
# Check for special item types and set attributes
|
3346
|
+
attributes = []
|
3347
|
+
attributes << "label=\"#{label}\""
|
3348
|
+
|
3349
|
+
# Handle operators (AND:, OR:, etc.)
|
3350
|
+
if item["text"] =~ /^\s*([A-Z][A-Z_\-() \/]*):(.*)$/
|
3351
|
+
operator = $1
|
3352
|
+
if ["AND", "OR", "NOT", "IMPLIES", "CONTINUOUS"].include?(operator)
|
3353
|
+
attributes << "shape=doubleoctagon"
|
3354
|
+
attributes << "width=0.2 height=0.2"
|
3355
|
+
end
|
3356
|
+
end
|
3357
|
+
|
3358
|
+
# Handle qualifiers (items with [])
|
3359
|
+
if item["text"] =~ /\[([^\]]+)\]/
|
3360
|
+
qualifier = $1
|
3361
|
+
case qualifier
|
3362
|
+
when "?"
|
3363
|
+
attributes << "shape=diamond" # Optional items as diamonds
|
3364
|
+
when "_"
|
3365
|
+
label = "☐ #{label}"
|
3366
|
+
attributes[0] = "label=\"#{label}\""
|
3367
|
+
when "x"
|
3368
|
+
label = "☑ #{label}"
|
3369
|
+
attributes[0] = "label=\"#{label}\""
|
3370
|
+
when "O"
|
3371
|
+
label = "⊙ #{label}"
|
3372
|
+
attributes[0] = "label=\"#{label}\""
|
3373
|
+
else
|
3374
|
+
# Check for conditions
|
3375
|
+
if qualifier =~ /^[^,]+$/
|
3376
|
+
attributes << "shape=diamond"
|
3377
|
+
attributes << "tooltip=\"Condition: #{qualifier}\""
|
3378
|
+
end
|
3379
|
+
end
|
3380
|
+
end
|
3381
|
+
|
3382
|
+
# Handle properties (Color:, Location:, etc.)
|
3383
|
+
if item["text"] =~ /^\s*([A-Z][a-z]+.*?):\s+(.+)$/
|
3384
|
+
property = $1
|
3385
|
+
value = $2
|
3386
|
+
if property.downcase == "color" && value =~ /(\w+)/
|
3387
|
+
color_val = $1.downcase
|
3388
|
+
# Map common color names if needed
|
3389
|
+
color_map = {
|
3390
|
+
'red' => 'red', 'green' => 'green', 'blue' => 'blue',
|
3391
|
+
'yellow' => 'yellow', 'orange' => 'orange', 'purple' => 'purple'
|
3392
|
+
}
|
3393
|
+
if color_map[color_val]
|
3394
|
+
attributes << "color=\"#{color_map[color_val]}\""
|
3395
|
+
attributes << "style=filled"
|
3396
|
+
attributes << "fillcolor=\"#{color_map[color_val]}20\"" # Light fill
|
3397
|
+
end
|
3398
|
+
end
|
3399
|
+
end
|
3400
|
+
|
3401
|
+
# Handle tags
|
3402
|
+
if item["text"] =~ /#(\w+)/
|
3403
|
+
tags = item["text"].scan(/#(\w+)/).flatten
|
3404
|
+
attributes << "tooltip=\"Tags: #{tags.join(', ')}\""
|
3405
|
+
end
|
3406
|
+
|
3407
|
+
# Add node to graph
|
3408
|
+
dot << "\"#{node_id}\" [#{attributes.join(', ')}]\n"
|
3409
|
+
end
|
3410
|
+
|
3411
|
+
dot << "\n"
|
3412
|
+
|
3413
|
+
# Add edges based on type
|
3414
|
+
if type == 'state'
|
3415
|
+
# State graph: hierarchical connections
|
3416
|
+
@items.each_with_index do |item, idx|
|
3417
|
+
next if item["raw"]
|
3418
|
+
|
3419
|
+
# Find children
|
3420
|
+
children = find_graph_children(idx)
|
3421
|
+
children.each do |child_idx|
|
3422
|
+
from_node = "node_#{idx}"
|
3423
|
+
to_node = "node_#{child_idx}"
|
3424
|
+
|
3425
|
+
edge_attrs = []
|
3426
|
+
|
3427
|
+
# Style based on operator
|
3428
|
+
if item["text"] =~ /^\s*OR:/
|
3429
|
+
edge_attrs << "style=\"dashed\""
|
3430
|
+
elsif item["text"] =~ /^\s*AND:/
|
3431
|
+
edge_attrs << "color=\"black:black\""
|
3432
|
+
end
|
3433
|
+
|
3434
|
+
dot << "\"#{from_node}\" -> \"#{to_node}\""
|
3435
|
+
dot << " [#{edge_attrs.join(', ')}]" if edge_attrs.any?
|
3436
|
+
dot << "\n"
|
3437
|
+
end
|
3438
|
+
|
3439
|
+
# Handle references (<item>)
|
3440
|
+
if item["text"] =~ /<([^>]+)>/
|
3441
|
+
ref = $1
|
3442
|
+
target_idx = find_reference_target(ref)
|
3443
|
+
if target_idx
|
3444
|
+
dot << "\"node_#{idx}\" -> \"node_#{target_idx}\" [style=\"dotted\"]\n"
|
3445
|
+
end
|
3446
|
+
end
|
3447
|
+
end
|
3448
|
+
else
|
3449
|
+
# Transition graph: sequential flow with special handling
|
3450
|
+
@items.each_with_index do |item, idx|
|
3451
|
+
next if item["raw"]
|
3452
|
+
|
3453
|
+
# Special handling for OR/AND operators
|
3454
|
+
if item["text"] =~ /^\s*(OR|AND):/
|
3455
|
+
operator = $1
|
3456
|
+
children = find_graph_children(idx)
|
3457
|
+
|
3458
|
+
if children.any?
|
3459
|
+
# Connect operator to all its children
|
3460
|
+
children.each do |child_idx|
|
3461
|
+
style = operator == "OR" ? "style=\"dashed\"" : "color=\"black:black\""
|
3462
|
+
dot << "\"node_#{idx}\" -> \"node_#{child_idx}\" [#{style}]\n"
|
3463
|
+
end
|
3464
|
+
|
3465
|
+
# Find convergence point
|
3466
|
+
converge_idx = find_next_at_level_or_above(idx, item["level"])
|
3467
|
+
if converge_idx
|
3468
|
+
# Connect all children to convergence point
|
3469
|
+
children.each do |child_idx|
|
3470
|
+
last_in_branch = find_last_in_branch(child_idx)
|
3471
|
+
dot << "\"node_#{last_in_branch}\" -> \"node_#{converge_idx}\"\n"
|
3472
|
+
end
|
3473
|
+
end
|
3474
|
+
next
|
3475
|
+
end
|
3476
|
+
end
|
3477
|
+
|
3478
|
+
# Normal sequential flow
|
3479
|
+
next_idx = find_next_graph_item(idx)
|
3480
|
+
if next_idx
|
3481
|
+
from_node = "node_#{idx}"
|
3482
|
+
to_node = "node_#{next_idx}"
|
3483
|
+
dot << "\"#{from_node}\" -> \"#{to_node}\"\n"
|
3484
|
+
end
|
3485
|
+
end
|
3486
|
+
end
|
3487
|
+
|
3488
|
+
dot << "}\n"
|
3489
|
+
dot
|
3490
|
+
end
|
3491
|
+
|
3492
|
+
def escape_dot_label(text)
|
3493
|
+
# Clean and escape text for DOT labels
|
3494
|
+
label = text.dup
|
3495
|
+
|
3496
|
+
# Remove ANSI color codes if present
|
3497
|
+
label.gsub!(/\e\[([0-9;]+)m/, '')
|
3498
|
+
label.gsub!(/\[38;5;\d+m/, '')
|
3499
|
+
|
3500
|
+
# Remove leading/trailing whitespace and indentation
|
3501
|
+
label.strip!
|
3502
|
+
|
3503
|
+
# Remove checkboxes at the beginning
|
3504
|
+
label.gsub!(/^\[([x_O])\]\s*/, '')
|
3505
|
+
|
3506
|
+
# Truncate if too long
|
3507
|
+
if label.length > 50
|
3508
|
+
label = label[0..47] + "..."
|
3509
|
+
end
|
3510
|
+
|
3511
|
+
# Escape special characters for DOT
|
3512
|
+
label.gsub('\\', '\\\\').gsub('"', '\"').gsub('#', '\#')
|
3513
|
+
end
|
3514
|
+
|
3515
|
+
def find_graph_children(parent_idx)
|
3516
|
+
children = []
|
3517
|
+
parent_level = @items[parent_idx]["level"]
|
3518
|
+
|
3519
|
+
(parent_idx + 1...@items.length).each do |i|
|
3520
|
+
next if @items[i]["raw"]
|
3521
|
+
if @items[i]["level"] > parent_level
|
3522
|
+
children << i if @items[i]["level"] == parent_level + 1
|
3523
|
+
else
|
3524
|
+
break
|
3525
|
+
end
|
3526
|
+
end
|
3527
|
+
|
3528
|
+
children
|
3529
|
+
end
|
3530
|
+
|
3531
|
+
def find_reference_target(ref)
|
3532
|
+
# Find item containing the reference text
|
3533
|
+
@items.find_index { |item| !item["raw"] && item["text"].include?(ref) }
|
3534
|
+
end
|
3535
|
+
|
3536
|
+
def find_next_at_level_or_above(start_idx, level)
|
3537
|
+
(start_idx + 1...@items.length).each do |i|
|
3538
|
+
next if @items[i]["raw"]
|
3539
|
+
return i if @items[i]["level"] <= level
|
3540
|
+
end
|
3541
|
+
nil
|
3542
|
+
end
|
3543
|
+
|
3544
|
+
def find_last_in_branch(start_idx)
|
3545
|
+
current_level = @items[start_idx]["level"]
|
3546
|
+
last_idx = start_idx
|
3547
|
+
|
3548
|
+
(start_idx + 1...@items.length).each do |i|
|
3549
|
+
next if @items[i]["raw"]
|
3550
|
+
if @items[i]["level"] > current_level
|
3551
|
+
last_idx = i
|
3552
|
+
else
|
3553
|
+
break
|
3554
|
+
end
|
3555
|
+
end
|
3556
|
+
|
3557
|
+
last_idx
|
3558
|
+
end
|
3559
|
+
|
3560
|
+
def find_next_graph_item(idx)
|
3561
|
+
current_level = @items[idx]["level"]
|
3562
|
+
|
3563
|
+
# Find next item at same level or first child
|
3564
|
+
(idx + 1...@items.length).each do |i|
|
3565
|
+
next if @items[i]["raw"]
|
3566
|
+
|
3567
|
+
# Return if same level or immediate child
|
3568
|
+
if @items[i]["level"] == current_level || @items[i]["level"] == current_level + 1
|
3569
|
+
return i
|
3570
|
+
elsif @items[i]["level"] < current_level
|
3571
|
+
# Going back up the hierarchy
|
3572
|
+
break
|
3573
|
+
end
|
3574
|
+
end
|
3575
|
+
|
3576
|
+
nil
|
3577
|
+
end
|
3578
|
+
|
3579
|
+
def set_message(text)
|
3580
|
+
@message = text
|
3581
|
+
@message_timeout = nil # Reset timeout so message shows immediately
|
3582
|
+
end
|
3583
|
+
|
3584
|
+
def set_mark(mark)
|
3585
|
+
@marks ||= {}
|
3586
|
+
visible = get_visible_items
|
3587
|
+
if @current < visible.length
|
3588
|
+
real_idx = @items.index(visible[@current])
|
3589
|
+
@marks[mark] = real_idx
|
3590
|
+
@message = "Mark '#{mark}' set"
|
3591
|
+
end
|
3592
|
+
end
|
3593
|
+
|
3594
|
+
def jump_to_mark(mark)
|
3595
|
+
@marks ||= {}
|
3596
|
+
if @marks[mark]
|
3597
|
+
# Save current position for ''
|
3598
|
+
@previous_position = @current
|
3599
|
+
|
3600
|
+
# Find the marked item in visible items
|
3601
|
+
target_idx = @marks[mark]
|
3602
|
+
visible = get_visible_items
|
3603
|
+
visible_idx = visible.find_index { |item| @items.index(item) == target_idx }
|
3604
|
+
|
3605
|
+
if visible_idx
|
3606
|
+
@current = visible_idx
|
3607
|
+
@message = "Jumped to mark '#{mark}'"
|
3608
|
+
else
|
3609
|
+
@message = "Mark '#{mark}' item is not visible (may be folded)"
|
3610
|
+
end
|
3611
|
+
else
|
3612
|
+
@message = "Mark '#{mark}' not set"
|
3613
|
+
end
|
3614
|
+
end
|
3615
|
+
|
3616
|
+
def jump_to_previous_position
|
3617
|
+
if @previous_position
|
3618
|
+
old_pos = @current
|
3619
|
+
@current = @previous_position
|
3620
|
+
@previous_position = old_pos
|
3621
|
+
@message = "Jumped to previous position"
|
3622
|
+
else
|
3623
|
+
@message = "No previous position"
|
3624
|
+
end
|
3625
|
+
end
|
3626
|
+
|
3627
|
+
def quit
|
3628
|
+
Cursor.show
|
3629
|
+
Rcurses.clear_screen
|
3630
|
+
exit
|
3631
|
+
end
|
3632
|
+
|
3633
|
+
def run
|
3634
|
+
render
|
3635
|
+
|
3636
|
+
loop do
|
3637
|
+
# Check for auto-save
|
3638
|
+
check_auto_save if @auto_save_enabled
|
3639
|
+
|
3640
|
+
c = getchr
|
3641
|
+
|
3642
|
+
# Track last key for double-key combinations
|
3643
|
+
prev_key = @last_key
|
3644
|
+
@last_key = c
|
3645
|
+
|
3646
|
+
# Record macro if recording (but not the 'q' that stops recording)
|
3647
|
+
if @macro_recording && !(c == "q")
|
3648
|
+
@macro_buffer << c
|
3649
|
+
end
|
3650
|
+
|
3651
|
+
case @mode
|
3652
|
+
when :normal
|
3653
|
+
case c
|
3654
|
+
when "r" # Redo
|
3655
|
+
redo_change
|
3656
|
+
when "?"
|
3657
|
+
show_help
|
3658
|
+
when "j", "DOWN"
|
3659
|
+
move_in_active_pane(:down)
|
3660
|
+
when "k", "UP"
|
3661
|
+
move_in_active_pane(:up)
|
3662
|
+
when "h"
|
3663
|
+
go_to_parent
|
3664
|
+
when "l"
|
3665
|
+
go_to_first_child
|
3666
|
+
when "LEFT"
|
3667
|
+
# Unindent only the current item
|
3668
|
+
indent_left(false)
|
3669
|
+
when "RIGHT"
|
3670
|
+
# Indent only the current item
|
3671
|
+
indent_right(false)
|
3672
|
+
when "PgUP" # Page Up
|
3673
|
+
page_up
|
3674
|
+
when "PgDOWN" # Page Down
|
3675
|
+
page_down
|
3676
|
+
when "HOME" # Home
|
3677
|
+
@current = 0
|
3678
|
+
@offset = 0
|
3679
|
+
when "END" # End
|
3680
|
+
@current = get_visible_items.length - 1
|
3681
|
+
when "g" # Go to top (was gg)
|
3682
|
+
@current = 0
|
3683
|
+
@offset = 0
|
3684
|
+
when "G" # Go to bottom
|
3685
|
+
@current = get_visible_items.length - 1
|
3686
|
+
when "R" # Jump to reference (was gr)
|
3687
|
+
jump_to_reference
|
3688
|
+
when "F" # Open file reference (was gf)
|
3689
|
+
open_file_reference
|
3690
|
+
when " "
|
3691
|
+
toggle_fold
|
3692
|
+
when "z"
|
3693
|
+
next_c = getchr
|
3694
|
+
case next_c
|
3695
|
+
when "a"
|
3696
|
+
@items.each { |item| item["fold"] = !item["fold"] }
|
3697
|
+
when "R"
|
3698
|
+
@items.each { |item| item["fold"] = false }
|
3699
|
+
when "M"
|
3700
|
+
@items.each_with_index do |item, idx|
|
3701
|
+
item["fold"] = true if has_children?(idx, @items)
|
3702
|
+
end
|
3703
|
+
when "o"
|
3704
|
+
visible = get_visible_items
|
3705
|
+
if @current < visible.length
|
3706
|
+
real_idx = @items.index(visible[@current])
|
3707
|
+
@items[real_idx]["fold"] = false if real_idx
|
3708
|
+
end
|
3709
|
+
when "c"
|
3710
|
+
visible = get_visible_items
|
3711
|
+
if @current < visible.length
|
3712
|
+
real_idx = @items.index(visible[@current])
|
3713
|
+
if real_idx && has_children?(real_idx, @items)
|
3714
|
+
@items[real_idx]["fold"] = true
|
3715
|
+
end
|
3716
|
+
end
|
3717
|
+
end
|
3718
|
+
when '0'
|
3719
|
+
# Single 0 pressed, ask for fold level
|
3720
|
+
level = @footer.ask("Fold to level: ", "")
|
3721
|
+
if level =~ /^\d+$/
|
3722
|
+
expand_to_level(level.to_i)
|
3723
|
+
@message = "Folded to level #{level}"
|
3724
|
+
end
|
3725
|
+
when /[1-9]/
|
3726
|
+
expand_to_level(c.to_i)
|
3727
|
+
when "C-L" # Move item and descendants down (C-J is newline)
|
3728
|
+
move_item_down(true)
|
3729
|
+
when "i", "ENTER", "\n" # i or ENTER to edit (C-J sends \n)
|
3730
|
+
edit_line
|
3731
|
+
when "o"
|
3732
|
+
insert_line
|
3733
|
+
when "O"
|
3734
|
+
@current -= 1 if @current > 0
|
3735
|
+
insert_line
|
3736
|
+
@current += 1
|
3737
|
+
when "a"
|
3738
|
+
insert_child
|
3739
|
+
when "t"
|
3740
|
+
show_templates
|
3741
|
+
when "D" # Delete line (with children)
|
3742
|
+
delete_line(false) # D always deletes with children by default
|
3743
|
+
when "C-D" # Delete line and all descendants explicitly
|
3744
|
+
delete_line(true)
|
3745
|
+
when "y" # Yank/copy single line
|
3746
|
+
yank_line(false)
|
3747
|
+
when "Y" # Yank/copy line with all descendants
|
3748
|
+
yank_line(true)
|
3749
|
+
when "p"
|
3750
|
+
paste_line
|
3751
|
+
when "S-UP" # Move item up
|
3752
|
+
move_item_up(false)
|
3753
|
+
when "S-DOWN" # Move item down
|
3754
|
+
move_item_down(false)
|
3755
|
+
when "C-UP" # Move item and descendants up
|
3756
|
+
move_item_up(true)
|
3757
|
+
when "C-DOWN" # Move item and descendants down
|
3758
|
+
move_item_down(true)
|
3759
|
+
when "C-K" # Alternative: Move item and descendants up (for terminals that intercept C-UP)
|
3760
|
+
move_item_up(true)
|
3761
|
+
when "TAB"
|
3762
|
+
# Indent with all children
|
3763
|
+
indent_right(true)
|
3764
|
+
when "S-TAB" # Shift-Tab
|
3765
|
+
# Unindent with all children
|
3766
|
+
indent_left(true)
|
3767
|
+
when "u"
|
3768
|
+
undo
|
3769
|
+
when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
|
3770
|
+
redo_change
|
3771
|
+
when "v"
|
3772
|
+
toggle_checkbox
|
3773
|
+
when "V"
|
3774
|
+
toggle_checkbox_with_date
|
3775
|
+
when "."
|
3776
|
+
repeat_last_action
|
3777
|
+
when "/"
|
3778
|
+
search_forward
|
3779
|
+
when "n"
|
3780
|
+
search_next
|
3781
|
+
when "N"
|
3782
|
+
jump_to_next_template_marker
|
3783
|
+
when "P"
|
3784
|
+
toggle_presentation_mode
|
3785
|
+
when "\\"
|
3786
|
+
next_c = getchr
|
3787
|
+
case next_c
|
3788
|
+
when "u"
|
3789
|
+
@message = "Underline mode toggled"
|
3790
|
+
end
|
3791
|
+
when ":"
|
3792
|
+
handle_command
|
3793
|
+
when "q"
|
3794
|
+
# Regular quit
|
3795
|
+
if @modified
|
3796
|
+
@message = "Unsaved changes! Use :q! or Q to force quit"
|
3797
|
+
else
|
3798
|
+
quit
|
3799
|
+
end
|
3800
|
+
when "m"
|
3801
|
+
# Mark setting
|
3802
|
+
c2 = getchr
|
3803
|
+
if c2 && c2 =~ /[a-z]/
|
3804
|
+
# Set mark
|
3805
|
+
set_mark(c2)
|
3806
|
+
end
|
3807
|
+
when "M"
|
3808
|
+
# Macro recording
|
3809
|
+
if @macro_recording
|
3810
|
+
# Stop recording
|
3811
|
+
stop_macro_recording
|
3812
|
+
else
|
3813
|
+
c2 = getchr
|
3814
|
+
if c2 && c2 =~ /[a-z]/
|
3815
|
+
start_macro_recording(c2)
|
3816
|
+
end
|
3817
|
+
end
|
3818
|
+
when "'"
|
3819
|
+
# Jump to mark
|
3820
|
+
c2 = getchr
|
3821
|
+
if c2 == "'"
|
3822
|
+
# Jump to previous position
|
3823
|
+
jump_to_previous_position
|
3824
|
+
elsif c2 && c2 =~ /[a-z]/
|
3825
|
+
# Jump to mark
|
3826
|
+
jump_to_mark(c2)
|
3827
|
+
end
|
3828
|
+
when "@"
|
3829
|
+
# Play macro
|
3830
|
+
next_c = getchr
|
3831
|
+
play_macro(next_c) if next_c && next_c =~ /[a-z]/
|
3832
|
+
when "w"
|
3833
|
+
# Check if it's a window command (ww for window switch)
|
3834
|
+
next_c = getchr
|
3835
|
+
if next_c == "w" && @split_view
|
3836
|
+
# Switch active pane
|
3837
|
+
@active_pane = (@active_pane == :main) ? :split : :main
|
3838
|
+
@message = "Switched to #{@active_pane} pane"
|
3839
|
+
else
|
3840
|
+
# Not a window command, ignore for now
|
3841
|
+
@message = "Unknown command: w#{next_c}"
|
3842
|
+
end
|
3843
|
+
when "Q" # Force quit
|
3844
|
+
quit
|
3845
|
+
when "ESC", "C-C" # ESC or Ctrl-C
|
3846
|
+
if @search && !@search.empty?
|
3847
|
+
# Clear search on first ESC
|
3848
|
+
@search = ""
|
3849
|
+
@search_matches = []
|
3850
|
+
@message = "Search cleared"
|
3851
|
+
clear_cache
|
3852
|
+
elsif !@modified
|
3853
|
+
# Quit on second ESC if not modified
|
3854
|
+
quit
|
3855
|
+
end
|
3856
|
+
end
|
3857
|
+
end
|
3858
|
+
|
3859
|
+
render
|
3860
|
+
end
|
3861
|
+
rescue Interrupt
|
3862
|
+
quit
|
3863
|
+
ensure
|
3864
|
+
Cursor.show
|
3865
|
+
Rcurses.clear_screen
|
3866
|
+
end
|
3867
|
+
end
|
3868
|
+
|
3869
|
+
# Main
|
3870
|
+
if __FILE__ == $0
|
3871
|
+
# Normal operation - help/version already handled at top of file
|
3872
|
+
app = HyperListApp.new(ARGV[0])
|
3873
|
+
app.run
|
3874
|
+
end
|