hyperlist 1.8.1 → 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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -4
  3. data/hyperlist +424 -122
  4. data/hyperlist.gemspec +1 -1
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ab71a37a8c51bc72f5a944172d2fae808da9ed42e4a21e13f937d2a4410596a
4
- data.tar.gz: 346c2edd59e6a6e4a116d05a506ded75b313996baa06f938def3fb0947c05850
3
+ metadata.gz: 1f638bfa971829555f7f078ed1c2e5a498a3e0d4026dea67fd547bc881fdec61
4
+ data.tar.gz: 3cbb6c08d664e5ed7111384ef275293c9616c8c213f3cc189beaf1e3054fbfac
5
5
  SHA512:
6
- metadata.gz: b66f71b652663dbf1fcbcee37fa7478b718813a7dd234081aad77e9110d1e01c472f2ce3d2a325ad6cd1e922b2efacb398c531fab79cd594999c5e2db20ad039
7
- data.tar.gz: 3f2a1573bb24704f3f3df797e8cd3766e78d265aa27c68ca41fc7326976a23ec361c9fc93c814b9a8afc7b9003921b7e0c3111e6bfbbc9df0ebf8efddee3401e
6
+ metadata.gz: a71a5cfa04c8e52652f990d970f6fc7a1e65a5a89a9869b558937a016ae7c8e2aa0017910a23941158abbbfb6c8d92a2f0b8d50075cb3cb303d06c04f62c876d
7
+ data.tar.gz: cccf1614ea64a35296d0de28b35d3ca3098fc31d68296cb271ef93ca5e085ffc2801fac04b98df96c72340b00adb39003fbcb55651f5561fe7e3a36ecf63a9e6
data/README.md CHANGED
@@ -29,7 +29,36 @@ 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.8.0
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
33
62
 
34
63
  ### 📋 Multi-Line Paste Support
35
64
  - **Paste multiple lines**: When pasting multi-line content into item insertion prompts ('o', 'O', 'a', 'A'), each line becomes a separate item
@@ -224,7 +253,17 @@ hyperlist file.txt # Open any text file
224
253
  - `D` - Delete and yank line
225
254
  - `C-D` - Delete and yank item with descendants
226
255
  - `y/Y` - Copy line/tree
227
- - `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
228
267
 
229
268
  #### Folding
230
269
  - `Space` - Toggle fold
@@ -238,8 +277,8 @@ hyperlist file.txt # Open any text file
238
277
  - `C-E` - Encrypt/decrypt current line
239
278
  - `R` - Go to reference
240
279
  - `F` - Open file reference
241
- - `P` - Presentation mode (with auto-collapse)
242
- - `t` - Insert template (built-in or custom)
280
+ - `C-P` - Presentation mode (with auto-collapse)
281
+ - `T` - Insert template (built-in or custom)
243
282
  - `?` - Help screen
244
283
 
245
284
  #### File Commands
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.8.0 - 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.8.0"
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.8.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
 
@@ -1297,20 +1305,31 @@ class HyperListApp
1297
1305
  end
1298
1306
  end
1299
1307
 
1300
- # Apply current item highlighting (all lines of wrapped text get bg)
1301
- if idx == @current
1302
- # Skip background highlighting in presentation mode for items in focus
1303
- 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)
1304
1323
  bg_color = (!@split_view || @active_pane == :main) ? "237" : "234"
1305
- if bg_color
1306
- # Pad line to full width and apply background
1307
- padded_line = line + " " * [@cols - line.pure.length, 0].max
1308
- bg_code = "\e[48;5;#{bg_color}m"
1309
- reset_bg = "\e[49m"
1310
- line = bg_code + padded_line.gsub(/\e\[49m/, '') + reset_bg
1311
- end
1312
1324
  end
1313
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
1314
1333
 
1315
1334
  lines << line
1316
1335
  end
@@ -1679,16 +1698,19 @@ class HyperListApp
1679
1698
 
1680
1699
  # Auto-save indicator
1681
1700
  auto_save_indicator = @auto_save_enabled ? "[A]" : ""
1682
-
1683
- # Split view indicator
1701
+
1702
+ # Split view indicator
1684
1703
  split_indicator = @split_view ? "[#{@active_pane.upcase}]" : ""
1685
-
1704
+
1705
+ # Tagged items indicator
1706
+ tagged_indicator = @tagged_items.any? ? "[T:#{@tagged_items.length}]" : ""
1707
+
1686
1708
  # Build status line components
1687
1709
  # Use full path with ~ for home directory
1688
1710
  full_path = @filename ? @filename.gsub(ENV['HOME'], '~') : "New HyperList"
1689
1711
  file_part = "#{full_path}#{modified_indicator}"
1690
1712
  stats_part = "L#{pos} W:#{word_count}"
1691
- indicators = "#{auto_save_indicator}#{split_indicator}"
1713
+ indicators = "#{auto_save_indicator}#{split_indicator}#{tagged_indicator}"
1692
1714
  right_side = "? help #{version_text}"
1693
1715
 
1694
1716
  # Combine left elements
@@ -2426,9 +2448,24 @@ class HyperListApp
2426
2448
 
2427
2449
  # Clear cache since items changed
2428
2450
  clear_cache
2429
-
2430
- @modified = true
2431
- @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
2432
2469
  end
2433
2470
 
2434
2471
  def record_last_action(type, data = nil)
@@ -2762,55 +2799,86 @@ class HyperListApp
2762
2799
  visible = get_visible_items
2763
2800
  return if visible.empty?
2764
2801
  return if @current >= visible.length
2765
-
2802
+
2766
2803
  save_undo_state # Save state before modification
2767
-
2768
- item = visible[@current]
2769
- real_idx = get_real_index(item)
2770
-
2771
- # First, yank the item(s) to clipboard
2772
- @clipboard = []
2773
- @clipboard << item.dup
2774
-
2775
- # Determine what to delete
2776
- level = item["level"]
2777
- delete_count = 1
2778
-
2779
- if with_children
2780
- # Delete item and its children (C-D was used)
2781
- @clipboard_is_tree = true # Mark as tree for paste behavior
2782
- ((real_idx + 1)...@items.length).each do |i|
2783
- if @items[i]["level"] > level
2784
- @clipboard << @items[i].dup # Also add children to clipboard
2785
- 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) }
2786
2827
  else
2787
- break
2828
+ @items.delete_at(real_idx)
2788
2829
  end
2789
2830
  end
2831
+
2832
+ @message = "Deleted and yanked #{@tagged_items.length} tagged item(s)"
2833
+ @tagged_items = [] # Clear tags after operation
2790
2834
  else
2791
- # For single line delete (D key - delete only the current line)
2792
- @clipboard_is_tree = false # Mark as single/adaptive for paste behavior
2793
- # 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)"
2794
2874
  end
2795
-
2796
- # Delete the items
2797
- # Remember the level of the deleted item for renumbering
2798
- deleted_level = item["level"]
2799
-
2800
- delete_count.times { @items.delete_at(real_idx) }
2801
-
2875
+
2802
2876
  @items = [{"text" => "Empty", "level" => 0, "fold" => false}] if @items.empty?
2803
-
2804
- # Renumber siblings at the deleted item's level
2805
- renumber_siblings(deleted_level) unless @items.length == 1
2806
-
2877
+
2807
2878
  @current = [@current, get_visible_items.length - 1].min
2808
2879
  @current = 0 if @current < 0
2809
2880
  @modified = true
2810
-
2811
- # Show message
2812
- @message = "Deleted and yanked #{@clipboard.length} item(s)"
2813
-
2881
+
2814
2882
  record_last_action(:delete_line, with_children)
2815
2883
  end
2816
2884
 
@@ -2818,23 +2886,49 @@ class HyperListApp
2818
2886
  visible = get_visible_items
2819
2887
  return if @current >= visible.length
2820
2888
 
2821
- item = visible[@current]
2822
- real_idx = get_real_index(item)
2823
-
2824
2889
  @clipboard = []
2825
- @clipboard << item.dup
2826
- @clipboard_is_tree = with_children # Remember if this is a tree copy (Y) or single (y)
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
2827
2909
 
2828
- # Copy children if requested
2829
- if with_children
2830
- level = item["level"]
2831
- ((real_idx + 1)...@items.length).each do |i|
2832
- if @items[i]["level"] > level
2833
- @clipboard << @items[i].dup
2834
- else
2835
- break
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
2836
2928
  end
2837
2929
  end
2930
+
2931
+ @message = "Yanked #{@clipboard.length} item(s)"
2838
2932
  end
2839
2933
 
2840
2934
  # Copy to system clipboard for middle-click paste
@@ -2845,7 +2939,6 @@ class HyperListApp
2845
2939
  # Silently fail if clipboard gem not available
2846
2940
  end
2847
2941
 
2848
- @message = "Yanked #{@clipboard.length} item(s)"
2849
2942
  record_last_action(:yank_line, with_children)
2850
2943
  end
2851
2944
 
@@ -2888,6 +2981,137 @@ class HyperListApp
2888
2981
  record_last_action(:paste, nil)
2889
2982
  end
2890
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
+
2891
3115
  def calculate_level_for_position(target_real_idx)
2892
3116
  # Calculate appropriate level for an item at target_real_idx position
2893
3117
  # based on surrounding items in the @items array
@@ -3152,64 +3376,122 @@ class HyperListApp
3152
3376
  def indent_right(with_children = true)
3153
3377
  visible = get_visible_items
3154
3378
  return if @current >= visible.length
3155
-
3156
- item = visible[@current]
3157
- real_idx = get_real_index(item)
3158
-
3159
- # Can only indent if there's a previous item at same or higher level
3160
- if real_idx > 0
3161
- save_undo_state # Save state before modification
3162
- original_level = @items[real_idx]["level"]
3163
- @items[real_idx]["level"] += 1
3164
-
3165
- # Also indent children if requested
3166
- if with_children
3167
- ((real_idx + 1)...@items.length).each do |i|
3168
- if @items[i]["level"] > original_level
3169
- @items[i]["level"] += 1
3170
- else
3171
- 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
3172
3399
  end
3173
3400
  end
3174
3401
  end
3175
-
3176
- # Ensure the moved item remains visible
3177
- force_item_visible(real_idx)
3178
3402
 
3403
+ @message = "Indented #{@tagged_items.length} tagged item(s)"
3404
+ @tagged_items = [] # Clear tags after operation
3179
3405
  @modified = true
3180
- 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
3181
3432
  end
3433
+
3434
+ record_last_action(:indent_right, with_children)
3182
3435
  end
3183
3436
 
3184
3437
  def indent_left(with_children = true)
3185
3438
  visible = get_visible_items
3186
3439
  return if @current >= visible.length
3187
-
3188
- item = visible[@current]
3189
- real_idx = get_real_index(item)
3190
-
3191
- if @items[real_idx]["level"] > 0
3192
- save_undo_state # Save state before modification
3193
- original_level = @items[real_idx]["level"]
3194
- @items[real_idx]["level"] -= 1
3195
-
3196
- # Also unindent children if requested
3197
- if with_children
3198
- ((real_idx + 1)...@items.length).each do |i|
3199
- if @items[i]["level"] > original_level
3200
- @items[i]["level"] -= 1
3201
- else
3202
- 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
3203
3460
  end
3204
3461
  end
3205
3462
  end
3206
-
3207
- # Ensure the moved item remains visible
3208
- force_item_visible(real_idx)
3209
3463
 
3464
+ @message = "Unindented #{@tagged_items.length} tagged item(s)"
3465
+ @tagged_items = [] # Clear tags after operation
3210
3466
  @modified = true
3211
- 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
3212
3492
  end
3493
+
3494
+ record_last_action(:indent_left, with_children)
3213
3495
  end
3214
3496
 
3215
3497
  def toggle_checkbox
@@ -3444,8 +3726,9 @@ class HyperListApp
3444
3726
  help_lines << help_line("#{"gU".fg("10")}", "Uppercase line", "#{"gu".fg("10")}", "Lowercase line")
3445
3727
  help_lines << help_line("#{"I".fg("10")}", "Cycle indent (2-5)")
3446
3728
  help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
3447
- help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
3448
- 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")
3449
3732
  help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
3450
3733
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
3451
3734
  help_lines << help_line("#{"C-UP".fg("10")}", "Move up in visible list", "#{"C-DOWN".fg("10")}", "Move down in visible list")
@@ -3456,7 +3739,7 @@ class HyperListApp
3456
3739
  help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
3457
3740
  help_lines << help_line("#{"C-X".fg("10")}", "Remove checkbox", "", "")
3458
3741
  help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
3459
- 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)")
3460
3743
  help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
3461
3744
  help_lines << help_line("#{"w".fg("10")}", "Switch panes (split view)")
3462
3745
  help_lines << ""
@@ -3468,8 +3751,15 @@ class HyperListApp
3468
3751
  help_lines << help_line("#{":as on".fg("10")}", "Enable autosave", "#{":as off".fg("10")}", "Disable autosave")
3469
3752
  help_lines << help_line("#{":as N".fg("10")}", "Set interval (secs)", "#{":as".fg("10")}", "Show autosave status")
3470
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
+
3471
3761
  help_lines << "#{"TEMPLATES".fg("14")}"
3472
- 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")
3473
3763
  help_lines << help_line("#{":dt".fg("10")}", "Delete template", "#{":lt".fg("10")}", "List user templates")
3474
3764
  help_lines << ""
3475
3765
 
@@ -5112,7 +5402,7 @@ class HyperListApp
5112
5402
  @items << {"text" => "="*50, "level" => 0, "fold" => false, "raw" => true}
5113
5403
 
5114
5404
  # Add built-in templates section
5115
- @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}
5116
5406
  template_list.select { |t| t[2] == "built-in" }.each_with_index do |(key, desc, type), idx|
5117
5407
  @items << {
5118
5408
  "text" => " #{idx+1}. #{key.capitalize}: #{desc}",
@@ -5126,7 +5416,7 @@ class HyperListApp
5126
5416
  # Add user templates section if any exist
5127
5417
  if user_templates.any?
5128
5418
  @items << {"text" => "", "level" => 0, "fold" => false, "raw" => true}
5129
- @items << {"text" => "USER TEMPLATES:", "level" => 0, "fold" => false, "raw" => true}
5419
+ @items << {"text" => "USER TEMPLATES:".fg("39"), "level" => 0, "fold" => false, "raw" => true}
5130
5420
  template_list.select { |t| t[2] == "user" }.each_with_index do |(key, desc, type), idx|
5131
5421
  @items << {
5132
5422
  "text" => " #{key}: #{desc}",
@@ -5357,6 +5647,8 @@ class HyperListApp
5357
5647
  when " "
5358
5648
  toggle_fold
5359
5649
  when "u"
5650
+ clear_tags
5651
+ when "U"
5360
5652
  undo
5361
5653
  when "/"
5362
5654
  # Skip search in macros for now
@@ -6262,11 +6554,17 @@ class HyperListApp
6262
6554
  # Cycle through indentation sizes (2-5 spaces)
6263
6555
  cycle_indent_size
6264
6556
  when "t"
6557
+ toggle_tag
6558
+ when "T"
6265
6559
  show_templates
6560
+ when "C-T"
6561
+ tag_by_regex
6266
6562
  when "D" # Delete line only (without children)
6267
6563
  delete_line(false) # Delete current line only
6268
6564
  when "C-D" # Delete line and all descendants explicitly
6269
6565
  delete_line(true)
6566
+ when "E"
6567
+ spawn_editor
6270
6568
  when "C-E" # Toggle line encryption
6271
6569
  toggle_line_encryption
6272
6570
  when "y" # Yank/copy single line
@@ -6312,6 +6610,8 @@ class HyperListApp
6312
6610
  end
6313
6611
  end
6314
6612
  when "u"
6613
+ clear_tags
6614
+ when "U"
6315
6615
  undo
6316
6616
  when "\x12" # Ctrl-R for redo (0x12 is Ctrl-R ASCII code)
6317
6617
  redo_change
@@ -6330,6 +6630,8 @@ class HyperListApp
6330
6630
  when "N"
6331
6631
  jump_to_next_template_marker
6332
6632
  when "P"
6633
+ paste_above
6634
+ when "C-P"
6333
6635
  toggle_presentation_mode
6334
6636
  when "\x15" # Ctrl-U for State/Transition underline toggle
6335
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.1"
3
+ spec.version = "1.9.0"
4
4
  spec.authors = ["Geir Isene"]
5
5
  spec.email = ["g@isene.com"]
6
6
 
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.1
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-10-13 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