hyperlist 1.8.0 → 1.9.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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -15
  3. data/hyperlist +694 -145
  4. data/hyperlist.gemspec +2 -2
  5. metadata +6 -7
  6. data/test_visibility.hl +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54c0ace316afeb7e7d109bb319f279c76dd849e098683ded98ccf5c33c68ffbc
4
- data.tar.gz: 06ceebfd8bb666689dbef44939074d4a1aaa7a78fe4c12858a977550b2d38391
3
+ metadata.gz: 1f638bfa971829555f7f078ed1c2e5a498a3e0d4026dea67fd547bc881fdec61
4
+ data.tar.gz: 3cbb6c08d664e5ed7111384ef275293c9616c8c213f3cc189beaf1e3054fbfac
5
5
  SHA512:
6
- metadata.gz: d8e284b9201f5d2f7598c55473936569a99071312a2ab0735cdeae70737b8c47998a5d02cf29dbb2b38ffb1c65d851e49cf19809d514150a5b4764d9e1c4c8ba
7
- data.tar.gz: 5b3e22980a16e467d0308ffd7e5912db3738dda7bde1c74e1a0fe871b8ca2d8e3cb21f782e8edb6a1af4fcf75d917731807fc00c7d4e9f83af0d57316892d19c
6
+ metadata.gz: a71a5cfa04c8e52652f990d970f6fc7a1e65a5a89a9869b558937a016ae7c8e2aa0017910a23941158abbbfb6c8d92a2f0b8d50075cb3cb303d06c04f62c876d
7
+ data.tar.gz: cccf1614ea64a35296d0de28b35d3ca3098fc31d68296cb271ef93ca5e085ffc2801fac04b98df96c72340b00adb39003fbcb55651f5561fe7e3a36ecf63a9e6
data/README.md CHANGED
@@ -29,17 +29,55 @@ For historical context and the original VIM implementation, see: [hyperlist.vim]
29
29
  ### Help Screen
30
30
  ![HyperList Help](img/screenshot_help.png)
31
31
 
32
- ## What's New in v1.4.4
33
-
34
- ### 🔧 Case Conversion Commands
35
- - **`gU`**: Convert current line to UPPERCASE
36
- - **`gu`**: Convert current line to lowercase
37
- - Works with all HyperList elements (checkboxes, operators, etc.)
38
-
39
- ### 🐛 Bug Fixes
40
- - Fixed color code display issues with numbered lists and operators
41
- - Improved handling of qualifiers like `[? conditional]`
42
- - Better coloring when combining numbered lists with operators (e.g., `1. NOT: item`)
32
+ ## What's New in v1.9.0
33
+
34
+ ### 🏷️ Item Tagging & Batch Operations
35
+ - **Tag items**: Press 't' to tag/untag items for batch operations
36
+ - **Auto-advance**: Cursor automatically moves to next item after tagging for fast consecutive tagging
37
+ - **Visual feedback**: Tagged items show dark blue background, lighter blue when selected
38
+ - **Status indicator**: Shows `[T:N]` in status line with count of tagged items
39
+ - **Clear tags**: Press 'u' to clear all tags
40
+ - **Regex tagging**: Press 'C-T' to tag all items matching a regex pattern
41
+ - **Batch operations**: Delete (D/C-D), yank (y/Y), and indent (Tab/S-Tab) work on all tagged items
42
+ - Tag consecutive or non-consecutive items, then perform operations on the entire set
43
+
44
+ ### ✏️ External Editor Support
45
+ - **Edit in $EDITOR**: Press 'E' to spawn your preferred editor (vim, nano, emacs, etc.)
46
+ - **Seamless workflow**: File saved automatically, editor launched, changes reloaded on exit
47
+ - **Terminal management**: Terminal state properly saved and restored
48
+ - Uses `$EDITOR` environment variable (defaults to vi if not set)
49
+
50
+ ### 📋 Enhanced Paste & Navigation
51
+ - **Paste above**: Press 'P' to paste above current item (vim-style)
52
+ - **Paste below**: Press 'p' to paste below current item (existing)
53
+ - **Presentation mode**: Moved to 'C-P' (was 'P') for consistency
54
+ - **Templates**: Moved to 'T' key (was 't')
55
+ - **Undo**: Moved to 'U' key (was 'u') - consistent with RTFM
56
+
57
+ ### 🎯 Smart Modified Flag
58
+ - **Intelligent tracking**: `[+]` indicator automatically removed when undoing back to original file state
59
+ - **Clean status**: No false "modified" indicator after complete undo to original
60
+
61
+ ## Previous Release: v1.8.0
62
+
63
+ ### 📋 Multi-Line Paste Support
64
+ - **Paste multiple lines**: When pasting multi-line content into item insertion prompts ('o', 'O', 'a', 'A'), each line becomes a separate item
65
+ - **Visual feedback**: Shows `[+N lines]` indicator during multi-line paste
66
+ - **Smart insertion**: All pasted lines inserted as siblings at the same level
67
+ - Great for importing bullet lists from PDFs, emails, or other documents
68
+ - Requires rcurses 6.1.5+
69
+
70
+ ### 📄 PDF/LaTeX Export
71
+ - **Export to PDF**: `:export pdf filename.pdf` - Full LaTeX-based PDF generation
72
+ - **Export to LaTeX**: `:export latex filename.tex` - Get the LaTeX source
73
+ - **Professional output**: Color-coded elements, table of contents, headers
74
+ - **Complete HyperList support**: All syntax elements rendered beautifully
75
+ - Requires: texlive-latex-base and texlive-latex-extra packages
76
+
77
+ ### 📋 System Clipboard Integration
78
+ - **Yank to clipboard**: 'y' and 'Y' now copy to system clipboard
79
+ - **Middle-click paste**: Yanked items can be pasted into other terminals
80
+ - **Preserves indentation**: Copied text maintains proper structure
43
81
 
44
82
  ## Previous Version Features (v1.4.0)
45
83
 
@@ -149,6 +187,8 @@ All `:set` commands automatically update the file's configuration line.
149
187
  - **Dates**: `2025-08-12` or `2025-08-12 14:30`
150
188
 
151
189
  ### Export Formats
190
+ - **PDF**: Professional LaTeX-based PDF with color coding and TOC
191
+ - **LaTeX**: Source .tex files for customization
152
192
  - **HTML**: Full-featured HTML with syntax highlighting
153
193
  - **Markdown**: GitHub-flavored Markdown
154
194
  - **Plain Text**: Clean text output
@@ -213,7 +253,17 @@ hyperlist file.txt # Open any text file
213
253
  - `D` - Delete and yank line
214
254
  - `C-D` - Delete and yank item with descendants
215
255
  - `y/Y` - Copy line/tree
216
- - `p` - Paste
256
+ - `p` - Paste below
257
+ - `P` - Paste above
258
+ - `U` - Undo
259
+ - `r` or `C-R` - Redo
260
+ - `E` - Edit in $EDITOR
261
+
262
+ #### Tagging & Batch Operations
263
+ - `t` - Tag/untag current item
264
+ - `u` - Clear all tags
265
+ - `C-T` - Tag items matching regex pattern
266
+ - Operations (D/C-D, y/Y, Tab/S-Tab) work on all tagged items when tags exist
217
267
 
218
268
  #### Folding
219
269
  - `Space` - Toggle fold
@@ -227,8 +277,8 @@ hyperlist file.txt # Open any text file
227
277
  - `C-E` - Encrypt/decrypt current line
228
278
  - `R` - Go to reference
229
279
  - `F` - Open file reference
230
- - `P` - Presentation mode (with auto-collapse)
231
- - `t` - Insert template (built-in or custom)
280
+ - `C-P` - Presentation mode (with auto-collapse)
281
+ - `T` - Insert template (built-in or custom)
232
282
  - `?` - Help screen
233
283
 
234
284
  #### File Commands
@@ -236,9 +286,11 @@ hyperlist file.txt # Open any text file
236
286
  - `:q` - Quit
237
287
  - `:wq` or `W` - Save and quit
238
288
  - `:e file` - Open file
289
+ - `:export pdf` - Export to PDF (requires LaTeX)
290
+ - `:export latex` - Export to LaTeX source
239
291
  - `:export html` - Export to HTML
240
292
  - `:export md` - Export to Markdown
241
- - `:graph` - Export to PNG
293
+ - `:graph` - Export to PNG graph
242
294
 
243
295
  #### Template Commands
244
296
  - `:st` - Save current document as template
data/hyperlist CHANGED
@@ -7,7 +7,7 @@
7
7
  # Check for help/version BEFORE loading any libraries
8
8
  if ARGV[0] == '-h' || ARGV[0] == '--help'
9
9
  puts <<~HELP
10
- HyperList v1.5.1 - Terminal User Interface for HyperList files
10
+ HyperList v1.9.0 - Terminal User Interface for HyperList files
11
11
 
12
12
  USAGE
13
13
  hyperlist [OPTIONS] [FILE]
@@ -52,7 +52,7 @@ if ARGV[0] == '-h' || ARGV[0] == '--help'
52
52
  HELP
53
53
  exit 0
54
54
  elsif ARGV[0] == '-v' || ARGV[0] == '--version'
55
- puts "HyperList v1.5.1"
55
+ puts "HyperList v1.9.0"
56
56
  exit 0
57
57
  end
58
58
 
@@ -66,13 +66,14 @@ require 'digest'
66
66
  require 'base64'
67
67
  require 'fileutils'
68
68
  require 'json'
69
+ require 'shellwords'
69
70
 
70
71
  class HyperListApp
71
72
  include Rcurses
72
73
  include Rcurses::Input
73
74
  include Rcurses::Cursor
74
75
 
75
- VERSION = "1.7.0"
76
+ VERSION = "1.9.0"
76
77
 
77
78
  def initialize(filename = nil)
78
79
  @filename = filename ? File.expand_path(filename) : nil
@@ -91,6 +92,8 @@ class HyperListApp
91
92
  @show_numbers = false # Line numbers disabled by default
92
93
  @command_history = load_command_history # Command history for : commands
93
94
  @clipboard = nil
95
+ @tagged_items = [] # Items tagged for batch operations
96
+ @original_items = nil # Track original file state for smart modified flag
94
97
  @undo_stack = []
95
98
  @undo_position = [] # Stack of cursor positions for undo
96
99
  @redo_stack = []
@@ -223,7 +226,8 @@ class HyperListApp
223
226
  @items = []
224
227
  @encrypted_lines = {}
225
228
  @config_line = nil # Reset config line before loading
226
-
229
+ @tagged_items = [] # Clear tags when loading new file
230
+
227
231
  # Read file content
228
232
  content = File.read(file) rescue ""
229
233
 
@@ -354,6 +358,9 @@ class HyperListApp
354
358
 
355
359
  # Update recent files list
356
360
  add_to_recent_files(File.expand_path(file)) if file
361
+
362
+ # Save original state for smart modified flag
363
+ @original_items = @items.map { |item| item.dup }
357
364
  end
358
365
 
359
366
  def auto_fold_deep_levels(max_level)
@@ -703,6 +710,7 @@ class HyperListApp
703
710
  end
704
711
 
705
712
  @modified = false
713
+ @original_items = @items.map { |item| item.dup } # Save state after save
706
714
  @last_auto_save = Time.now if @auto_save_enabled
707
715
  end
708
716
 
@@ -724,10 +732,12 @@ class HyperListApp
724
732
  when 'md', 'markdown' then '.md'
725
733
  when 'html' then '.html'
726
734
  when 'txt', 'text' then '.txt'
735
+ when 'latex', 'tex' then '.tex'
736
+ when 'pdf' then '.pdf'
727
737
  end
728
738
  export_file = base + extension
729
739
  end
730
-
740
+
731
741
  content = case format
732
742
  when 'md', 'markdown'
733
743
  export_to_markdown
@@ -735,10 +745,39 @@ class HyperListApp
735
745
  export_to_html
736
746
  when 'txt', 'text'
737
747
  export_to_text
748
+ when 'latex', 'tex'
749
+ export_to_latex
750
+ when 'pdf'
751
+ export_to_latex
738
752
  end
739
-
753
+
740
754
  File.open(export_file, 'w') { |f| f.write(content) }
741
- @message = "Exported to #{export_file}"
755
+
756
+ # For PDF, compile the LaTeX file
757
+ if format == 'pdf'
758
+ tex_file = export_file.sub(/\.pdf$/, '.tex')
759
+ File.open(tex_file, 'w') { |f| f.write(content) }
760
+
761
+ # Compile LaTeX to PDF
762
+ @message = "Compiling LaTeX to PDF..."
763
+ render_footer
764
+
765
+ result = system("pdflatex -interaction=nonstopmode -output-directory=#{File.dirname(tex_file)} #{tex_file} > /dev/null 2>&1")
766
+ if result
767
+ # Run twice for TOC
768
+ system("pdflatex -interaction=nonstopmode -output-directory=#{File.dirname(tex_file)} #{tex_file} > /dev/null 2>&1")
769
+ @message = "Exported to #{export_file}"
770
+ # Clean up auxiliary files
771
+ ['aux', 'log', 'out', 'toc'].each do |ext|
772
+ aux_file = tex_file.sub(/\.tex$/, ".#{ext}")
773
+ File.delete(aux_file) if File.exist?(aux_file)
774
+ end
775
+ else
776
+ @message = "PDF compilation failed. Check #{tex_file}"
777
+ end
778
+ else
779
+ @message = "Exported to #{export_file}"
780
+ end
742
781
  rescue => e
743
782
  @message = "Export failed: #{e.message}"
744
783
  end
@@ -948,7 +987,151 @@ class HyperListApp
948
987
  end
949
988
  lines.join("\n")
950
989
  end
951
-
990
+
991
+ def escape_latex(text)
992
+ # Escape special LaTeX characters
993
+ text = text.gsub('\\', '\\textbackslash{}')
994
+ text = text.gsub('&', '\\&')
995
+ text = text.gsub('%', '\\%')
996
+ text = text.gsub('$', '\\$')
997
+ text = text.gsub('#', '\\#')
998
+ text = text.gsub('_', '\\_')
999
+ text = text.gsub('^', '\\textasciicircum{}')
1000
+ text = text.gsub('~', '\\textasciitilde{}')
1001
+ text
1002
+ end
1003
+
1004
+ def export_to_latex
1005
+ lines = []
1006
+
1007
+ # LaTeX header
1008
+ lines << "% Generated by HyperList - LaTeX Export"
1009
+ lines << "% Export date: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
1010
+ lines << ""
1011
+ lines << "\\documentclass[11pt,a4paper]{article}"
1012
+ lines << "\\usepackage[utf8]{inputenc}"
1013
+ lines << "\\usepackage[T1]{fontenc}"
1014
+ lines << "\\usepackage[english]{babel}"
1015
+ lines << "\\usepackage[margin=2.5cm]{geometry}"
1016
+ lines << "\\usepackage{xcolor}"
1017
+ lines << "\\usepackage{enumitem}"
1018
+ lines << "\\usepackage{fancyhdr}"
1019
+ lines << "\\usepackage{hyperref}"
1020
+ lines << "\\usepackage{tcolorbox}"
1021
+ lines << "\\usepackage{fontawesome5}"
1022
+ lines << ""
1023
+ lines << "% HyperList color definitions"
1024
+ lines << "\\definecolor{hloperator}{RGB}{41,128,185}"
1025
+ lines << "\\definecolor{hlproperty}{RGB}{231,76,60}"
1026
+ lines << "\\definecolor{hlqualifier}{RGB}{39,174,96}"
1027
+ lines << "\\definecolor{hlhashtag}{RGB}{243,156,18}"
1028
+ lines << "\\definecolor{hlreference}{RGB}{155,89,182}"
1029
+ lines << "\\definecolor{hlcomment}{RGB}{127,140,141}"
1030
+ lines << "\\definecolor{hlquote}{RGB}{22,160,133}"
1031
+ lines << "\\definecolor{hlstate}{RGB}{46,204,113}"
1032
+ lines << "\\definecolor{hltransition}{RGB}{230,126,34}"
1033
+ lines << ""
1034
+ lines << "% HyperList commands"
1035
+ lines << "\\newcommand{\\hloperator}[1]{\\textcolor{hloperator}{\\textbf{#1}}}"
1036
+ lines << "\\newcommand{\\hlproperty}[1]{\\textcolor{hlproperty}{\\textit{#1}}}"
1037
+ lines << "\\newcommand{\\hlqualifier}[1]{\\textcolor{hlqualifier}{\\texttt{#1}}}"
1038
+ lines << "\\newcommand{\\hlhashtag}[1]{\\textcolor{hlhashtag}{\\textbf{#1}}}"
1039
+ lines << "\\newcommand{\\hlreference}[1]{\\textcolor{hlreference}{#1}}"
1040
+ lines << "\\newcommand{\\hlcomment}[1]{\\textcolor{hlcomment}{\\textit{#1}}}"
1041
+ lines << "\\newcommand{\\hlquote}[1]{\\textcolor{hlquote}{#1}}"
1042
+ lines << "\\newcommand{\\hlstate}[1]{\\begin{tcolorbox}[colback=hlstate!10,colframe=hlstate,title=State]#1\\end{tcolorbox}}"
1043
+ lines << "\\newcommand{\\hltransition}[1]{\\begin{tcolorbox}[colback=hltransition!10,colframe=hltransition,title=Action]#1\\end{tcolorbox}}"
1044
+ lines << "\\newcommand{\\hlsubstitution}[1]{\\textcolor{hlqualifier}{#1}}"
1045
+ lines << "\\newcommand{\\checkbox}{\\faSquare[regular]}"
1046
+ lines << "\\newcommand{\\checkboxdone}{\\faCheckSquare}"
1047
+ lines << "\\newcommand{\\checkboxprogress}{\\faMinusSquare[regular]}"
1048
+ lines << ""
1049
+ lines << "% Document setup"
1050
+ title = @filename ? File.basename(@filename, '.*') : 'HyperList Export'
1051
+ lines << "\\title{#{escape_latex(title)}}"
1052
+ lines << "\\author{Generated by HyperList}"
1053
+ lines << "\\date{#{Time.now.strftime('%Y-%m-%d')}}"
1054
+ lines << ""
1055
+ lines << "\\pagestyle{fancy}"
1056
+ lines << "\\fancyhf{}"
1057
+ lines << "\\fancyhead[R]{\\thepage}"
1058
+ lines << "\\fancyhead[L]{#{escape_latex(title)}}"
1059
+ lines << ""
1060
+ lines << "\\begin{document}"
1061
+ lines << "\\maketitle"
1062
+ lines << "\\tableofcontents"
1063
+ lines << "\\newpage"
1064
+ lines << ""
1065
+ lines << "\\begin{itemize}[leftmargin=0pt,itemsep=2pt,parsep=0pt]"
1066
+
1067
+ # Process each item
1068
+ prev_level = 0
1069
+ @items.each do |item|
1070
+ text = item["text"]
1071
+ level = item["level"]
1072
+
1073
+ # Handle level changes
1074
+ if level > prev_level
1075
+ (level - prev_level).times do
1076
+ lines << "\\begin{itemize}"
1077
+ end
1078
+ elsif level < prev_level
1079
+ (prev_level - level).times do
1080
+ lines << "\\end{itemize}"
1081
+ end
1082
+ end
1083
+
1084
+ # Escape LaTeX special characters first
1085
+ text = escape_latex(text)
1086
+
1087
+ # Convert checkboxes
1088
+ text = text.gsub(/\[_\]/, '\\checkbox{}')
1089
+ text = text.gsub(/\[x\]|\[X\]/, '\\checkboxdone{}')
1090
+ text = text.gsub(/\[O\]/, '\\checkboxprogress{}')
1091
+
1092
+ # Convert HyperList markup (must be after escaping)
1093
+ text = text.gsub(/ \*([^*]+)\* /, ' \\textbf{\1} ')
1094
+ text = text.gsub(/ \/([^\/]+)\/ /, ' \\textit{\1} ')
1095
+ text = text.gsub(/ _([^_]+)_ /, ' \\underline{\1} ')
1096
+
1097
+ # Convert HyperList elements
1098
+ text = text.gsub(/\[([^\]]+)\]/, '\\hlqualifier{[\1]}')
1099
+ text = text.gsub(/\{([^\}]+)\}/, '\\hlsubstitution{\{\1\}}')
1100
+ text = text.gsub(/(#[a-zA-Z0-9.:\/\\_&?%=\-*]+)/, '\\hlhashtag{\1}')
1101
+ text = text.gsub(/(<{1,2}[a-zA-Z0-9.:\/\\_&?%=\-* ]+>{1,2})/, '\\hlreference{\1}')
1102
+ text = text.gsub(/(\([^)]*\))/, '\\hlcomment{\1}')
1103
+ text = text.gsub(/(\"[^\"]*\")/, '\\hlquote{\1}')
1104
+
1105
+ # Convert States and Transitions
1106
+ if text =~ /^S: (.+)/
1107
+ text = "\\hlstate{State: #{$1}}"
1108
+ elsif text =~ /^T: (.+)/
1109
+ text = "\\hltransition{Action: #{$1}}"
1110
+ elsif text =~ /^\| (.+)/
1111
+ text = "\\hlstate{State: #{$1}}"
1112
+ elsif text =~ /^\/ (.+)/
1113
+ text = "\\hltransition{Action: #{$1}}"
1114
+ end
1115
+
1116
+ # Convert Operators and Properties
1117
+ text = text.gsub(/([A-Z_\-() \/]{2,}): /, '\\hloperator{\1:} ')
1118
+ text = text.gsub(/([a-zA-Z0-9,._&?%=\-\/+<>#'()*:]{2,}): /, '\\hlproperty{\1:} ')
1119
+
1120
+ lines << "\\item #{text}"
1121
+ prev_level = level
1122
+ end
1123
+
1124
+ # Close any remaining nested lists
1125
+ prev_level.times do
1126
+ lines << "\\end{itemize}"
1127
+ end
1128
+
1129
+ lines << "\\end{itemize}"
1130
+ lines << "\\end{document}"
1131
+
1132
+ lines.join("\n")
1133
+ end
1134
+
952
1135
  def render
953
1136
  render_main
954
1137
  render_split_pane if @split_view
@@ -1122,20 +1305,31 @@ class HyperListApp
1122
1305
  end
1123
1306
  end
1124
1307
 
1125
- # Apply current item highlighting (all lines of wrapped text get bg)
1126
- if idx == @current
1127
- # Skip background highlighting in presentation mode for items in focus
1128
- if !(@presentation_mode && is_item_in_presentation_focus?(item))
1308
+ # Apply background highlighting based on tagged/selected state
1309
+ real_idx = get_real_index(item)
1310
+ is_tagged = real_idx && @tagged_items.include?(real_idx)
1311
+ is_selected = idx == @current
1312
+
1313
+ bg_color = nil
1314
+ if is_tagged && is_selected
1315
+ # Tagged AND selected - lighter blue
1316
+ bg_color = "21" unless @presentation_mode && is_item_in_presentation_focus?(item)
1317
+ elsif is_tagged
1318
+ # Only tagged - dark blue
1319
+ bg_color = "17"
1320
+ elsif is_selected
1321
+ # Only selected - normal gray
1322
+ unless @presentation_mode && is_item_in_presentation_focus?(item)
1129
1323
  bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
1130
- if bg_color
1131
- # Pad line to full width and apply background
1132
- padded_line = line + " " * [@cols - line.pure.length, 0].max
1133
- bg_code = "\e[48;5;#{bg_color}m"
1134
- reset_bg = "\e[49m"
1135
- line = bg_code + padded_line.gsub(/\e\[49m/, '') + reset_bg
1136
- end
1137
1324
  end
1138
1325
  end
1326
+
1327
+ if bg_color
1328
+ padded_line = line + " " * [@cols - line.pure.length, 0].max
1329
+ bg_code = "\e[48;5;#{bg_color}m"
1330
+ reset_bg = "\e[49m"
1331
+ line = bg_code + padded_line.gsub(/\e\[49m/, '') + reset_bg
1332
+ end
1139
1333
 
1140
1334
  lines << line
1141
1335
  end
@@ -1504,16 +1698,19 @@ class HyperListApp
1504
1698
 
1505
1699
  # Auto-save indicator
1506
1700
  auto_save_indicator = @auto_save_enabled ? "[A]" : ""
1507
-
1508
- # Split view indicator
1701
+
1702
+ # Split view indicator
1509
1703
  split_indicator = @split_view ? "[#{@active_pane.upcase}]" : ""
1510
-
1704
+
1705
+ # Tagged items indicator
1706
+ tagged_indicator = @tagged_items.any? ? "[T:#{@tagged_items.length}]" : ""
1707
+
1511
1708
  # Build status line components
1512
1709
  # Use full path with ~ for home directory
1513
1710
  full_path = @filename ? @filename.gsub(ENV['HOME'], '~') : "New HyperList"
1514
1711
  file_part = "#{full_path}#{modified_indicator}"
1515
1712
  stats_part = "L#{pos} W:#{word_count}"
1516
- indicators = "#{auto_save_indicator}#{split_indicator}"
1713
+ indicators = "#{auto_save_indicator}#{split_indicator}#{tagged_indicator}"
1517
1714
  right_side = "? help #{version_text}"
1518
1715
 
1519
1716
  # Combine left elements
@@ -2251,9 +2448,24 @@ class HyperListApp
2251
2448
 
2252
2449
  # Clear cache since items changed
2253
2450
  clear_cache
2254
-
2255
- @modified = true
2256
- @message = "Undone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
2451
+
2452
+ # Check if we've returned to original state
2453
+ if @original_items && items_match?(@items, @original_items)
2454
+ @modified = false
2455
+ @message = "Undone to original state (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
2456
+ else
2457
+ @modified = true
2458
+ @message = "Undone (#{@undo_stack.length} undo levels, #{@redo_stack.length} redo available)"
2459
+ end
2460
+ end
2461
+
2462
+ def items_match?(items1, items2)
2463
+ return false if items1.length != items2.length
2464
+ items1.each_with_index do |item, idx|
2465
+ return false if item["text"] != items2[idx]["text"] ||
2466
+ item["level"] != items2[idx]["level"]
2467
+ end
2468
+ true
2257
2469
  end
2258
2470
 
2259
2471
  def record_last_action(type, data = nil)
@@ -2332,19 +2544,41 @@ class HyperListApp
2332
2544
 
2333
2545
  def insert_line
2334
2546
  @mode = :insert
2335
-
2547
+
2336
2548
  input = @footer.ask("New item: ", "")
2337
-
2549
+
2338
2550
  if input && !input.strip.empty?
2339
- insert_line_with_text(input)
2551
+ # Collect all lines (first line + buffer)
2552
+ all_lines = [input]
2553
+ if @footer.multiline_buffer && !@footer.multiline_buffer.empty?
2554
+ all_lines += @footer.multiline_buffer.reject { |l| l.strip.empty? }
2555
+ @footer.multiline_buffer = []
2556
+ end
2557
+
2558
+ # Insert all lines as siblings at the same level
2559
+ all_lines.each do |line|
2560
+ insert_line_with_text(line.strip)
2561
+ @current -= 1 # Keep cursor at same position so next insert is sibling
2562
+ end
2563
+ @current += 1 # Move to last inserted item
2564
+
2340
2565
  record_last_action(:insert_line, input)
2341
2566
  end
2342
-
2567
+
2343
2568
  @mode = :normal
2344
2569
  @footer.clear # Clear footer immediately
2345
2570
  @footer.refresh
2346
2571
  end
2347
-
2572
+
2573
+ # Alias for 'o' key
2574
+ alias insert_line_below insert_line
2575
+
2576
+ # Method for 'O' key - insert line above
2577
+ def insert_line_above
2578
+ @current -= 1 if @current > 0
2579
+ insert_line
2580
+ end
2581
+
2348
2582
  def insert_line_with_text(text)
2349
2583
  return unless text && !text.strip.empty?
2350
2584
 
@@ -2371,14 +2605,27 @@ class HyperListApp
2371
2605
 
2372
2606
  def insert_child
2373
2607
  @mode = :insert
2374
-
2608
+
2375
2609
  input = @footer.ask("New child item: ", "")
2376
-
2610
+
2377
2611
  if input && !input.strip.empty?
2378
- insert_child_with_text(input)
2612
+ # Collect all lines (first line + buffer)
2613
+ all_lines = [input]
2614
+ if @footer.multiline_buffer && !@footer.multiline_buffer.empty?
2615
+ all_lines += @footer.multiline_buffer.reject { |l| l.strip.empty? }
2616
+ @footer.multiline_buffer = []
2617
+ end
2618
+
2619
+ # Insert all lines as siblings at the same level
2620
+ all_lines.each do |line|
2621
+ insert_child_with_text(line.strip)
2622
+ @current -= 1 # Keep cursor at same position so next insert is sibling
2623
+ end
2624
+ @current += 1 # Move to last inserted item
2625
+
2379
2626
  record_last_action(:insert_child, input)
2380
2627
  end
2381
-
2628
+
2382
2629
  @mode = :normal
2383
2630
  @footer.clear # Clear footer immediately
2384
2631
  @footer.refresh
@@ -2386,14 +2633,43 @@ class HyperListApp
2386
2633
 
2387
2634
  def insert_outdented
2388
2635
  @mode = :insert
2389
-
2636
+
2390
2637
  input = @footer.ask("New outdented item: ", "")
2391
-
2638
+
2392
2639
  if input && !input.strip.empty?
2393
- insert_outdented_with_text(input)
2640
+ # Collect all lines (first line + buffer)
2641
+ all_lines = [input]
2642
+ if @footer.multiline_buffer && !@footer.multiline_buffer.empty?
2643
+ all_lines += @footer.multiline_buffer.reject { |l| l.strip.empty? }
2644
+ @footer.multiline_buffer = []
2645
+ end
2646
+
2647
+ # Calculate target level BEFORE inserting anything
2648
+ visible = get_visible_items
2649
+ if @current < visible.length
2650
+ current_level = visible[@current]["level"]
2651
+ target_level = [current_level - 1, 0].max # Outdented level
2652
+ else
2653
+ target_level = 0
2654
+ end
2655
+
2656
+ # Insert first line normally
2657
+ insert_outdented_with_text(all_lines[0].strip)
2658
+
2659
+ # Insert remaining lines at same level as the first
2660
+ if all_lines.length > 1
2661
+ all_lines[1..-1].each do |line|
2662
+ visible = get_visible_items
2663
+ real_idx = get_real_index(visible[@current])
2664
+ @items.insert(real_idx + 1, {"text" => line.strip, "level" => target_level, "fold" => false})
2665
+ @current += 1
2666
+ end
2667
+ @modified = true
2668
+ end
2669
+
2394
2670
  record_last_action(:insert_outdented, input)
2395
2671
  end
2396
-
2672
+
2397
2673
  @mode = :normal
2398
2674
  @footer.clear # Clear footer immediately
2399
2675
  @footer.refresh
@@ -2523,82 +2799,146 @@ class HyperListApp
2523
2799
  visible = get_visible_items
2524
2800
  return if visible.empty?
2525
2801
  return if @current >= visible.length
2526
-
2802
+
2527
2803
  save_undo_state # Save state before modification
2528
-
2529
- item = visible[@current]
2530
- real_idx = get_real_index(item)
2531
-
2532
- # First, yank the item(s) to clipboard
2533
- @clipboard = []
2534
- @clipboard << item.dup
2535
-
2536
- # Determine what to delete
2537
- level = item["level"]
2538
- delete_count = 1
2539
-
2540
- if with_children
2541
- # Delete item and its children (C-D was used)
2542
- @clipboard_is_tree = true # Mark as tree for paste behavior
2543
- ((real_idx + 1)...@items.length).each do |i|
2544
- if @items[i]["level"] > level
2545
- @clipboard << @items[i].dup # Also add children to clipboard
2546
- delete_count += 1
2804
+
2805
+ # Handle tagged items
2806
+ if @tagged_items.any?
2807
+ @clipboard = []
2808
+ @clipboard_is_tree = false
2809
+
2810
+ # Sort tagged indices in reverse to delete from end to start
2811
+ @tagged_items.sort.reverse.each do |real_idx|
2812
+ next if real_idx >= @items.length
2813
+ @clipboard.unshift(@items[real_idx].dup)
2814
+ if with_children
2815
+ # Delete item and children
2816
+ level = @items[real_idx]["level"]
2817
+ delete_count = 1
2818
+ ((real_idx + 1)...@items.length).each do |i|
2819
+ if @items[i]["level"] > level
2820
+ @clipboard << @items[i].dup
2821
+ delete_count += 1
2822
+ else
2823
+ break
2824
+ end
2825
+ end
2826
+ delete_count.times { @items.delete_at(real_idx) }
2547
2827
  else
2548
- break
2828
+ @items.delete_at(real_idx)
2549
2829
  end
2550
2830
  end
2831
+
2832
+ @message = "Deleted and yanked #{@tagged_items.length} tagged item(s)"
2833
+ @tagged_items = [] # Clear tags after operation
2551
2834
  else
2552
- # For single line delete (D key - delete only the current line)
2553
- @clipboard_is_tree = false # Mark as single/adaptive for paste behavior
2554
- # Don't include children - delete_count stays at 1
2835
+ # Normal single item delete
2836
+ item = visible[@current]
2837
+ real_idx = get_real_index(item)
2838
+
2839
+ # First, yank the item(s) to clipboard
2840
+ @clipboard = []
2841
+ @clipboard << item.dup
2842
+
2843
+ # Determine what to delete
2844
+ level = item["level"]
2845
+ delete_count = 1
2846
+
2847
+ if with_children
2848
+ # Delete item and its children (C-D was used)
2849
+ @clipboard_is_tree = true # Mark as tree for paste behavior
2850
+ ((real_idx + 1)...@items.length).each do |i|
2851
+ if @items[i]["level"] > level
2852
+ @clipboard << @items[i].dup # Also add children to clipboard
2853
+ delete_count += 1
2854
+ else
2855
+ break
2856
+ end
2857
+ end
2858
+ else
2859
+ # For single line delete (D key - delete only the current line)
2860
+ @clipboard_is_tree = false # Mark as single/adaptive for paste behavior
2861
+ # Don't include children - delete_count stays at 1
2862
+ end
2863
+
2864
+ # Delete the items
2865
+ # Remember the level of the deleted item for renumbering
2866
+ deleted_level = item["level"]
2867
+
2868
+ delete_count.times { @items.delete_at(real_idx) }
2869
+
2870
+ # Renumber siblings at the deleted item's level
2871
+ renumber_siblings(deleted_level) unless @items.length == 1
2872
+
2873
+ @message = "Deleted and yanked #{@clipboard.length} item(s)"
2555
2874
  end
2556
-
2557
- # Delete the items
2558
- # Remember the level of the deleted item for renumbering
2559
- deleted_level = item["level"]
2560
-
2561
- delete_count.times { @items.delete_at(real_idx) }
2562
-
2875
+
2563
2876
  @items = [{"text" => "Empty", "level" => 0, "fold" => false}] if @items.empty?
2564
-
2565
- # Renumber siblings at the deleted item's level
2566
- renumber_siblings(deleted_level) unless @items.length == 1
2567
-
2877
+
2568
2878
  @current = [@current, get_visible_items.length - 1].min
2569
2879
  @current = 0 if @current < 0
2570
2880
  @modified = true
2571
-
2572
- # Show message
2573
- @message = "Deleted and yanked #{@clipboard.length} item(s)"
2574
-
2881
+
2575
2882
  record_last_action(:delete_line, with_children)
2576
2883
  end
2577
2884
 
2578
2885
  def yank_line(with_children = false)
2579
2886
  visible = get_visible_items
2580
2887
  return if @current >= visible.length
2581
-
2582
- item = visible[@current]
2583
- real_idx = get_real_index(item)
2584
-
2888
+
2585
2889
  @clipboard = []
2586
- @clipboard << item.dup
2587
- @clipboard_is_tree = with_children # Remember if this is a tree copy (Y) or single (y)
2588
-
2589
- # Copy children if requested
2590
- if with_children
2591
- level = item["level"]
2592
- ((real_idx + 1)...@items.length).each do |i|
2593
- if @items[i]["level"] > level
2594
- @clipboard << @items[i].dup
2595
- else
2596
- break
2890
+ @clipboard_is_tree = with_children
2891
+
2892
+ # Handle tagged items
2893
+ if @tagged_items.any?
2894
+ @tagged_items.sort.each do |real_idx|
2895
+ next if real_idx >= @items.length
2896
+ @clipboard << @items[real_idx].dup
2897
+ if with_children
2898
+ # Copy children too
2899
+ level = @items[real_idx]["level"]
2900
+ ((real_idx + 1)...@items.length).each do |i|
2901
+ if @items[i]["level"] > level
2902
+ @clipboard << @items[i].dup
2903
+ else
2904
+ break
2905
+ end
2906
+ end
2907
+ end
2908
+ end
2909
+
2910
+ @message = "Yanked #{@tagged_items.length} tagged item(s)"
2911
+ @tagged_items = [] # Clear tags after operation
2912
+ else
2913
+ # Normal single item yank
2914
+ item = visible[@current]
2915
+ real_idx = get_real_index(item)
2916
+
2917
+ @clipboard << item.dup
2918
+
2919
+ # Copy children if requested
2920
+ if with_children
2921
+ level = item["level"]
2922
+ ((real_idx + 1)...@items.length).each do |i|
2923
+ if @items[i]["level"] > level
2924
+ @clipboard << @items[i].dup
2925
+ else
2926
+ break
2927
+ end
2597
2928
  end
2598
2929
  end
2930
+
2931
+ @message = "Yanked #{@clipboard.length} item(s)"
2599
2932
  end
2600
-
2601
- @message = "Yanked #{@clipboard.length} item(s)"
2933
+
2934
+ # Copy to system clipboard for middle-click paste
2935
+ begin
2936
+ text_to_copy = @clipboard.map { |it| " " * it["level"] + it["text"] }.join("\n")
2937
+ Clipboard.copy(text_to_copy)
2938
+ rescue => e
2939
+ # Silently fail if clipboard gem not available
2940
+ end
2941
+
2602
2942
  record_last_action(:yank_line, with_children)
2603
2943
  end
2604
2944
 
@@ -2641,6 +2981,137 @@ class HyperListApp
2641
2981
  record_last_action(:paste, nil)
2642
2982
  end
2643
2983
 
2984
+ def paste_above
2985
+ return unless @clipboard && !@clipboard.empty?
2986
+
2987
+ save_undo_state # Save state before modification
2988
+
2989
+ visible = get_visible_items
2990
+ if @current < visible.length
2991
+ real_idx = get_real_index(visible[@current])
2992
+
2993
+ if @clipboard_is_tree
2994
+ # For tree paste (C-D or Y), maintain original indentation structure
2995
+ @clipboard.reverse.each do |item|
2996
+ new_item = item.dup
2997
+ @items.insert(real_idx, new_item)
2998
+ end
2999
+ else
3000
+ # For single/adaptive paste (D or y), adjust to match context
3001
+ base_level = visible[@current]["level"]
3002
+ level_diff = base_level - @clipboard[0]["level"]
3003
+
3004
+ @clipboard.reverse.each do |item|
3005
+ new_item = item.dup
3006
+ new_item["level"] = item["level"] + level_diff
3007
+ @items.insert(real_idx, new_item)
3008
+ end
3009
+ end
3010
+ else
3011
+ # Pasting at end of list - maintain original levels
3012
+ @clipboard.each do |item|
3013
+ @items << item.dup
3014
+ end
3015
+ end
3016
+
3017
+ @modified = true
3018
+ @message = "Pasted #{@clipboard.length} item(s) above"
3019
+ record_last_action(:paste_above, nil)
3020
+ end
3021
+
3022
+ def toggle_tag
3023
+ visible = get_visible_items
3024
+ return if @current >= visible.length
3025
+
3026
+ real_idx = get_real_index(visible[@current])
3027
+
3028
+ if @tagged_items.include?(real_idx)
3029
+ @tagged_items.delete(real_idx)
3030
+ @message = "Untagged item (#{@tagged_items.length} tagged)"
3031
+ else
3032
+ @tagged_items << real_idx
3033
+ @message = "Tagged item (#{@tagged_items.length} tagged)"
3034
+ # Auto-advance cursor for easy consecutive tagging
3035
+ @current += 1 if @current < visible.length - 1
3036
+ end
3037
+ end
3038
+
3039
+ def clear_tags
3040
+ count = @tagged_items.length
3041
+ @tagged_items = []
3042
+ @message = "Cleared #{count} tagged item(s)"
3043
+ end
3044
+
3045
+ def tag_by_regex
3046
+ pattern = @footer.ask("Tag pattern (regex): ", "")
3047
+ return if pattern.nil? || pattern.strip.empty?
3048
+
3049
+ begin
3050
+ regex = Regexp.new(pattern, Regexp::IGNORECASE)
3051
+ count = 0
3052
+
3053
+ @items.each_with_index do |item, idx|
3054
+ if item["text"] =~ regex
3055
+ @tagged_items << idx unless @tagged_items.include?(idx)
3056
+ count += 1
3057
+ end
3058
+ end
3059
+
3060
+ @tagged_items.uniq!
3061
+ @message = "Tagged #{count} item(s) matching /#{pattern}/"
3062
+ rescue RegexpError => e
3063
+ @message = "Invalid regex: #{e.message}"
3064
+ end
3065
+ end
3066
+
3067
+ def spawn_editor
3068
+ return unless @filename
3069
+
3070
+ # Save file before editing
3071
+ save_file
3072
+
3073
+ # Save terminal state
3074
+ system("stty -g < /dev/tty > /tmp/hyperlist_stty_$$")
3075
+
3076
+ # Reset terminal to cooked mode for editor
3077
+ system('stty sane < /dev/tty')
3078
+ system('clear < /dev/tty > /dev/tty')
3079
+ Rcurses::Cursor.show
3080
+
3081
+ # Launch editor
3082
+ editor = ENV.fetch('EDITOR', 'vi')
3083
+ system("#{editor} #{Shellwords.escape(@filename)}")
3084
+
3085
+ # Restore terminal state
3086
+ system("stty $(cat /tmp/hyperlist_stty_$$) < /dev/tty")
3087
+ system("rm -f /tmp/hyperlist_stty_$$")
3088
+
3089
+ # Flush input and reset stdin
3090
+ $stdin.iflush if $stdin.respond_to?(:iflush)
3091
+ system('stty raw -echo isig < /dev/tty')
3092
+ $stdin.raw!
3093
+ $stdin.echo = false
3094
+
3095
+ # Reinitialize rcurses
3096
+ Rcurses.init!
3097
+ Rcurses::Cursor.hide
3098
+ Rcurses.clear_screen
3099
+
3100
+ # Reload the file
3101
+ load_file(@filename)
3102
+ @current = 0
3103
+ @offset = 0
3104
+
3105
+ @message = "File reloaded from $EDITOR"
3106
+
3107
+ # Force complete screen refresh
3108
+ clear_cache
3109
+ @main.full_refresh if @main
3110
+ @footer.full_refresh if @footer
3111
+ @split_pane.full_refresh if @split_pane && @split_view
3112
+ render
3113
+ end
3114
+
2644
3115
  def calculate_level_for_position(target_real_idx)
2645
3116
  # Calculate appropriate level for an item at target_real_idx position
2646
3117
  # based on surrounding items in the @items array
@@ -2905,64 +3376,122 @@ class HyperListApp
2905
3376
  def indent_right(with_children = true)
2906
3377
  visible = get_visible_items
2907
3378
  return if @current >= visible.length
2908
-
2909
- item = visible[@current]
2910
- real_idx = get_real_index(item)
2911
-
2912
- # Can only indent if there's a previous item at same or higher level
2913
- if real_idx > 0
2914
- save_undo_state # Save state before modification
2915
- original_level = @items[real_idx]["level"]
2916
- @items[real_idx]["level"] += 1
2917
-
2918
- # Also indent children if requested
2919
- if with_children
2920
- ((real_idx + 1)...@items.length).each do |i|
2921
- if @items[i]["level"] > original_level
2922
- @items[i]["level"] += 1
2923
- else
2924
- break
3379
+
3380
+ save_undo_state # Save state before modification
3381
+
3382
+ # Handle tagged items
3383
+ if @tagged_items.any?
3384
+ @tagged_items.sort.each do |real_idx|
3385
+ next if real_idx >= @items.length
3386
+ next if real_idx == 0 # Can't indent first item
3387
+
3388
+ original_level = @items[real_idx]["level"]
3389
+ @items[real_idx]["level"] += 1
3390
+
3391
+ # Also indent children if requested
3392
+ if with_children
3393
+ ((real_idx + 1)...@items.length).each do |i|
3394
+ if @items[i]["level"] > original_level
3395
+ @items[i]["level"] += 1
3396
+ else
3397
+ break
3398
+ end
2925
3399
  end
2926
3400
  end
2927
3401
  end
2928
-
2929
- # Ensure the moved item remains visible
2930
- force_item_visible(real_idx)
2931
3402
 
3403
+ @message = "Indented #{@tagged_items.length} tagged item(s)"
3404
+ @tagged_items = [] # Clear tags after operation
2932
3405
  @modified = true
2933
- record_last_action(:indent_right, with_children)
3406
+ else
3407
+ # Normal single item indent
3408
+ item = visible[@current]
3409
+ real_idx = get_real_index(item)
3410
+
3411
+ # Can only indent if there's a previous item at same or higher level
3412
+ if real_idx > 0
3413
+ original_level = @items[real_idx]["level"]
3414
+ @items[real_idx]["level"] += 1
3415
+
3416
+ # Also indent children if requested
3417
+ if with_children
3418
+ ((real_idx + 1)...@items.length).each do |i|
3419
+ if @items[i]["level"] > original_level
3420
+ @items[i]["level"] += 1
3421
+ else
3422
+ break
3423
+ end
3424
+ end
3425
+ end
3426
+
3427
+ # Ensure the moved item remains visible
3428
+ force_item_visible(real_idx)
3429
+
3430
+ @modified = true
3431
+ end
2934
3432
  end
3433
+
3434
+ record_last_action(:indent_right, with_children)
2935
3435
  end
2936
3436
 
2937
3437
  def indent_left(with_children = true)
2938
3438
  visible = get_visible_items
2939
3439
  return if @current >= visible.length
2940
-
2941
- item = visible[@current]
2942
- real_idx = get_real_index(item)
2943
-
2944
- if @items[real_idx]["level"] > 0
2945
- save_undo_state # Save state before modification
2946
- original_level = @items[real_idx]["level"]
2947
- @items[real_idx]["level"] -= 1
2948
-
2949
- # Also unindent children if requested
2950
- if with_children
2951
- ((real_idx + 1)...@items.length).each do |i|
2952
- if @items[i]["level"] > original_level
2953
- @items[i]["level"] -= 1
2954
- else
2955
- break
3440
+
3441
+ save_undo_state # Save state before modification
3442
+
3443
+ # Handle tagged items
3444
+ if @tagged_items.any?
3445
+ @tagged_items.sort.each do |real_idx|
3446
+ next if real_idx >= @items.length
3447
+ next if @items[real_idx]["level"] == 0 # Already at leftmost
3448
+
3449
+ original_level = @items[real_idx]["level"]
3450
+ @items[real_idx]["level"] -= 1
3451
+
3452
+ # Also unindent children if requested
3453
+ if with_children
3454
+ ((real_idx + 1)...@items.length).each do |i|
3455
+ if @items[i]["level"] > original_level
3456
+ @items[i]["level"] -= 1
3457
+ else
3458
+ break
3459
+ end
2956
3460
  end
2957
3461
  end
2958
3462
  end
2959
-
2960
- # Ensure the moved item remains visible
2961
- force_item_visible(real_idx)
2962
3463
 
3464
+ @message = "Unindented #{@tagged_items.length} tagged item(s)"
3465
+ @tagged_items = [] # Clear tags after operation
2963
3466
  @modified = true
2964
- record_last_action(:indent_left, with_children)
3467
+ else
3468
+ # Normal single item unindent
3469
+ item = visible[@current]
3470
+ real_idx = get_real_index(item)
3471
+
3472
+ if @items[real_idx]["level"] > 0
3473
+ original_level = @items[real_idx]["level"]
3474
+ @items[real_idx]["level"] -= 1
3475
+
3476
+ # Also unindent children if requested
3477
+ if with_children
3478
+ ((real_idx + 1)...@items.length).each do |i|
3479
+ if @items[i]["level"] > original_level
3480
+ @items[i]["level"] -= 1
3481
+ else
3482
+ break
3483
+ end
3484
+ end
3485
+ end
3486
+
3487
+ # Ensure the moved item remains visible
3488
+ force_item_visible(real_idx)
3489
+
3490
+ @modified = true
3491
+ end
2965
3492
  end
3493
+
3494
+ record_last_action(:indent_left, with_children)
2966
3495
  end
2967
3496
 
2968
3497
  def toggle_checkbox
@@ -3197,8 +3726,9 @@ class HyperListApp
3197
3726
  help_lines << help_line("#{"gU".fg("10")}", "Uppercase line", "#{"gu".fg("10")}", "Lowercase line")
3198
3727
  help_lines << help_line("#{"I".fg("10")}", "Cycle indent (2-5)")
3199
3728
  help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
3200
- help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
3201
- help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
3729
+ help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste below")
3730
+ help_lines << help_line("#{"P".fg("10")}", "Paste above", "#{"E".fg("10")}", "Edit in $EDITOR")
3731
+ help_lines << help_line("#{"U".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
3202
3732
  help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
3203
3733
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
3204
3734
  help_lines << help_line("#{"C-UP".fg("10")}", "Move up in visible list", "#{"C-DOWN".fg("10")}", "Move down in visible list")
@@ -3209,20 +3739,27 @@ class HyperListApp
3209
3739
  help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
3210
3740
  help_lines << help_line("#{"C-X".fg("10")}", "Remove checkbox", "", "")
3211
3741
  help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
3212
- help_lines << help_line("#{"P".fg("10")}", "Presentation mode", "#{"Tab/S-Tab".fg("10")}", "Next/prev sibling (in P)")
3742
+ help_lines << help_line("#{"C-P".fg("10")}", "Presentation mode", "#{"Tab/S-Tab".fg("10")}", "Next/prev sibling (in P)")
3213
3743
  help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
3214
3744
  help_lines << help_line("#{"w".fg("10")}", "Switch panes (split view)")
3215
3745
  help_lines << ""
3216
3746
  help_lines << "#{"FILE OPERATIONS".fg("14")}"
3217
3747
  help_lines << help_line("#{":w".fg("10")}", "Save", "#{":q".fg("10")}", "Quit")
3218
3748
  help_lines << help_line("#{":wq".fg("10")}", "Save and quit", "#{":e file".fg("10")}", "Open file")
3219
- help_lines << help_line("#{":recent".fg("10")}", "Recent files", "#{":export :ex".fg("10")}", "Export md|html|txt")
3749
+ help_lines << help_line("#{":recent".fg("10")}", "Recent files", "#{":export :ex".fg("10")}", "Export md|html|txt|pdf")
3220
3750
  help_lines << help_line("#{":graph :g".fg("10")}", "Export to PNG graph", "#{":vsplit :vs".fg("10")}", "Split view")
3221
3751
  help_lines << help_line("#{":as on".fg("10")}", "Enable autosave", "#{":as off".fg("10")}", "Disable autosave")
3222
3752
  help_lines << help_line("#{":as N".fg("10")}", "Set interval (secs)", "#{":as".fg("10")}", "Show autosave status")
3223
3753
  help_lines << ""
3754
+ help_lines << "#{"TAGGING & BATCH OPS".fg("14")}"
3755
+ help_lines << help_line("#{"t".fg("10")}", "Tag/untag item", "#{"u".fg("10")}", "Clear all tags")
3756
+ help_lines << help_line("#{"C-T".fg("10")}", "Tag by regex pattern", "", "")
3757
+ help_lines << help_line("#{"[T:N]".fg("245")}", "Status shows N tagged", "#{"Blue bg".fg("245")}", "Tagged items")
3758
+ help_lines << help_line("#{"D/y/Tab".fg("245")}", "Ops work on tagged items")
3759
+ help_lines << ""
3760
+
3224
3761
  help_lines << "#{"TEMPLATES".fg("14")}"
3225
- help_lines << help_line("#{"t".fg("10")}", "Insert template", "#{":st".fg("10")}", "Save as template")
3762
+ help_lines << help_line("#{"T".fg("10")}", "Insert template", "#{":st".fg("10")}", "Save as template")
3226
3763
  help_lines << help_line("#{":dt".fg("10")}", "Delete template", "#{":lt".fg("10")}", "List user templates")
3227
3764
  help_lines << ""
3228
3765
 
@@ -3900,12 +4437,12 @@ class HyperListApp
3900
4437
  @current = 0
3901
4438
  @offset = 0
3902
4439
  @modified = false
3903
- when /^(export|ex)\s+(md|markdown|html|txt|text)\s*(.*)$/
4440
+ when /^(export|ex)\s+(md|markdown|html|txt|text|latex|tex|pdf)\s*(.*)$/
3904
4441
  format = $2
3905
4442
  export_file = $3.empty? ? nil : $3
3906
4443
  export_to(format, export_file)
3907
4444
  when /^(export|ex)$/
3908
- @message = "Usage: :export [md|html|txt] [filename] (or :ex)"
4445
+ @message = "Usage: :export [md|html|txt|latex|pdf] [filename] (or :ex)"
3909
4446
  when "recent", "r"
3910
4447
  show_recent_files
3911
4448
  when "autosave on", "as on"
@@ -4865,7 +5402,7 @@ class HyperListApp
4865
5402
  @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
4866
5403
 
4867
5404
  # Add built-in templates section
4868
- @items << {"text" => "BUILT-IN TEMPLATES:", "level" => 0, "fold" => false, "raw" => true}
5405
+ @items << {"text" => "BUILT-IN TEMPLATES:".fg("39"), "level" => 0, "fold" => false, "raw" => true}
4869
5406
  template_list.select { |t| t[2] == "built-in" }.each_with_index do |(key, desc, type), idx|
4870
5407
  @items << {
4871
5408
  "text" => " #{idx+1}. #{key.capitalize}: #{desc}",
@@ -4879,7 +5416,7 @@ class HyperListApp
4879
5416
  # Add user templates section if any exist
4880
5417
  if user_templates.any?
4881
5418
  @items << {"text" => "", "level" => 0, "fold" => false, "raw" => true}
4882
- @items << {"text" => "USER TEMPLATES:", "level" => 0, "fold" => false, "raw" => true}
5419
+ @items << {"text" => "USER TEMPLATES:".fg("39"), "level" => 0, "fold" => false, "raw" => true}
4883
5420
  template_list.select { |t| t[2] == "user" }.each_with_index do |(key, desc, type), idx|
4884
5421
  @items << {
4885
5422
  "text" => " #{key}: #{desc}",
@@ -5110,6 +5647,8 @@ class HyperListApp
5110
5647
  when " "
5111
5648
  toggle_fold
5112
5649
  when "u"
5650
+ clear_tags
5651
+ when "U"
5113
5652
  undo
5114
5653
  when "/"
5115
5654
  # Skip search in macros for now
@@ -6015,11 +6554,17 @@ class HyperListApp
6015
6554
  # Cycle through indentation sizes (2-5 spaces)
6016
6555
  cycle_indent_size
6017
6556
  when "t"
6557
+ toggle_tag
6558
+ when "T"
6018
6559
  show_templates
6560
+ when "C-T"
6561
+ tag_by_regex
6019
6562
  when "D" # Delete line only (without children)
6020
6563
  delete_line(false) # Delete current line only
6021
6564
  when "C-D" # Delete line and all descendants explicitly
6022
6565
  delete_line(true)
6566
+ when "E"
6567
+ spawn_editor
6023
6568
  when "C-E" # Toggle line encryption
6024
6569
  toggle_line_encryption
6025
6570
  when "y" # Yank/copy single line
@@ -6065,6 +6610,8 @@ class HyperListApp
6065
6610
  end
6066
6611
  end
6067
6612
  when "u"
6613
+ clear_tags
6614
+ when "U"
6068
6615
  undo
6069
6616
  when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
6070
6617
  redo_change
@@ -6083,6 +6630,8 @@ class HyperListApp
6083
6630
  when "N"
6084
6631
  jump_to_next_template_marker
6085
6632
  when "P"
6633
+ paste_above
6634
+ when "C-P"
6086
6635
  toggle_presentation_mode
6087
6636
  when "\x15" # Ctrl-U for State/Transition underline toggle
6088
6637
  # Cycle through underline modes: 0 (none) -> 1 (states) -> 2 (transitions) -> 0
data/hyperlist.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "hyperlist"
3
- spec.version = "1.8.0"
3
+ spec.version = "1.9.0"
4
4
  spec.authors = ["Geir Isene"]
5
5
  spec.email = ["g@isene.com"]
6
6
 
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.require_paths = ["."]
29
29
 
30
30
  # Runtime dependencies
31
- spec.add_runtime_dependency "rcurses", "~> 5.1", ">= 5.1.6"
31
+ spec.add_runtime_dependency "rcurses", "~> 6.1", ">= 6.1.5"
32
32
 
33
33
  # Development dependencies
34
34
  spec.add_development_dependency "minitest", "~> 5.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperlist
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: "."
10
10
  cert_chain: []
11
- date: 2025-09-22 00:00:00.000000000 Z
11
+ date: 2025-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses
@@ -16,20 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '5.1'
19
+ version: '6.1'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 5.1.6
22
+ version: 6.1.5
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - "~>"
28
28
  - !ruby/object:Gem::Version
29
- version: '5.1'
29
+ version: '6.1'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 5.1.6
32
+ version: 6.1.5
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: minitest
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -79,7 +79,6 @@ files:
79
79
  - img/screenshot_help.png
80
80
  - img/screenshot_sample.png
81
81
  - sample.hl
82
- - test_visibility.hl
83
82
  homepage: https://github.com/isene/HyperList
84
83
  licenses:
85
84
  - Unlicense
data/test_visibility.hl DELETED
@@ -1,11 +0,0 @@
1
- Top level item 1
2
- Child 1.1
3
- Grandchild 1.1.1
4
- Grandchild 1.1.2
5
- Child 1.2
6
- Grandchild 1.2.1
7
- Top level item 2
8
- Child 2.1
9
- Grandchild 2.1.1
10
- Child 2.2
11
- Top level item 3