hyperlist 1.6.0 → 1.7.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/CHANGELOG.md +19 -0
  3. data/hyperlist +246 -121
  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: d21f62ca7a02ec79a5b845d35afe4ba4ce0c28083ae2cac61c0857375e63f73c
4
- data.tar.gz: 4af367432d5a1037d3dabf5fa84f5afe4f1eb1ad9b9d2f56fca21523efb3ba73
3
+ metadata.gz: 014e81876165c457903e5f20e8f848f2c59fce0d2672e700ff909873c03c260a
4
+ data.tar.gz: c60b73c642984f70ba2bfdcceb7ca6a9bc501c46071cb362d9e9343778cd8f40
5
5
  SHA512:
6
- metadata.gz: 00dad920e90a26a443ae81ab3e241e788f3494142aaff21105cc477d21d14cde22515aeb036ccec1647b74a22c9386505dd30e8aa02209ef19a1efd0f1cf2e1c
7
- data.tar.gz: ad4d36624c61337c243fdf43fcfc0e7b55be57d0870dd5328fa7de76c60ad914c6b100615e92db961d5b653fa3df17bce3286466442cd5c923997f0ac5e2afc1
6
+ metadata.gz: 37ba0da349a30af46424cc374ece24e315c32a22ee48bf81f4893c43e20cdf18f42ec71c3b3fe123688f9b97597a29e8aa583884cceb361d44583645546aaf25
7
+ data.tar.gz: 5a8737705ad4d231e6ce1a8a1b8f440cf8b0f3c2ffa7586052dc64be5a55d8bc4491ed721b07adb192e6674f0560a9ac35a6a067ed27831b200066b786456081
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to the HyperList Ruby TUI will be documented in this file.
4
4
 
5
+ ## [1.7.0] - 2025-09-18
6
+
7
+ ### Added
8
+ - **Checkbox removal feature**
9
+ - **C-X key**: Remove checkboxes from items
10
+ - Removes all checkbox patterns: `[ ]`, `[X]`, `[x]`, `[O]`, `[-]`, `[_]`
11
+ - Also removes associated timestamps (YYYY-MM-DD HH.MM: format)
12
+ - Provides feedback messages for successful removal or no checkbox found
13
+ - Added to help documentation under SPECIAL FEATURES
14
+ - Complements existing v/V checkbox toggle functionality
15
+
16
+ ### Enhanced
17
+ - **Cross-parent movement improvements**
18
+ - C-UP/C-DOWN now respect visibility principle (move only item if uncollapsed, item+children if collapsed)
19
+ - Added orphaned children reunification logic
20
+ - Items moved into collapsed areas automatically unfold destination
21
+ - Moved items are guaranteed to remain visible (safety net protection)
22
+ - Single-step movement: C-DOWN moves exactly one position down, not to end of descendants
23
+
5
24
  ## [1.6.0] - 2025-09-18
6
25
 
7
26
  ### BREAKING CHANGES
data/hyperlist CHANGED
@@ -72,7 +72,7 @@ class HyperListApp
72
72
  include Rcurses::Input
73
73
  include Rcurses::Cursor
74
74
 
75
- VERSION = "1.6.0"
75
+ VERSION = "1.7.0"
76
76
 
77
77
  def initialize(filename = nil)
78
78
  @filename = filename ? File.expand_path(filename) : nil
@@ -2632,26 +2632,132 @@ class HyperListApp
2632
2632
  @message = "Pasted #{@clipboard.length} item(s)"
2633
2633
  record_last_action(:paste, nil)
2634
2634
  end
2635
-
2635
+
2636
+ def calculate_level_for_position(target_real_idx)
2637
+ # Calculate appropriate level for an item at target_real_idx position
2638
+ # based on surrounding items in the @items array
2639
+
2640
+ # If at the beginning, level 0
2641
+ return 0 if target_real_idx == 0
2642
+
2643
+ # Look at the previous item
2644
+ prev_item = @items[target_real_idx - 1]
2645
+ prev_level = prev_item["level"]
2646
+
2647
+ # Look at the next item if it exists
2648
+ if target_real_idx < @items.length
2649
+ next_item = @items[target_real_idx]
2650
+ next_level = next_item["level"]
2651
+
2652
+ # If next item is at same or lower level than previous,
2653
+ # we can be at the same level as previous (sibling)
2654
+ if next_level <= prev_level
2655
+ return prev_level
2656
+ else
2657
+ # Next item is deeper, so we could be parent (prev_level)
2658
+ # or child (prev_level + 1), choose to be sibling of previous
2659
+ return prev_level
2660
+ end
2661
+ else
2662
+ # At the end, can be sibling of previous item
2663
+ return prev_level
2664
+ end
2665
+ end
2666
+
2667
+ def find_orphaned_children(item_text, target_real_idx)
2668
+ # Look for children that might belong to the item being moved
2669
+ orphaned_children = []
2670
+
2671
+ # Search from target position onwards for potential children
2672
+ # Look for items at level 2 or higher that could be children
2673
+ (target_real_idx...@items.length).each do |i|
2674
+ item_level = @items[i]["level"]
2675
+
2676
+ # Stop if we hit an item at level 0 or 1 (new parent/sibling)
2677
+ break if item_level <= 1
2678
+
2679
+ # If we find items at level 2+, they could be orphaned children
2680
+ # (In the example: Subtask A1.1, A1.2 are level 2)
2681
+ if item_level >= 2
2682
+ orphaned_children << i
2683
+ else
2684
+ break # Hit a sibling, stop looking
2685
+ end
2686
+ end
2687
+
2688
+ orphaned_children
2689
+ end
2690
+
2691
+ def ensure_destination_visible(target_real_idx, item_level)
2692
+ # Unfold any collapsed areas that would hide an item at target_real_idx with item_level
2693
+ return if target_real_idx >= @items.length || target_real_idx < 0
2694
+
2695
+ # Walk backwards from the target position to find potential ancestor items
2696
+ (target_real_idx - 1).downto(0) do |i|
2697
+ item = @items[i]
2698
+
2699
+ # If this item is at a higher level (lower number) than our item will be, it's a potential ancestor
2700
+ if item["level"] < item_level
2701
+ # If this ancestor is folded, unfold it to make the destination visible
2702
+ if item["fold"] && has_children?(i, @items)
2703
+ item["fold"] = false
2704
+ end
2705
+
2706
+ # Update item_level to continue searching for higher ancestors
2707
+ item_level = item["level"]
2708
+
2709
+ # If we reach level 0, we're done
2710
+ break if item_level == 0
2711
+ end
2712
+ end
2713
+ end
2714
+
2715
+ def force_item_visible(target_real_idx)
2716
+ # Absolutely ensure the item at target_real_idx is visible - last resort safety net
2717
+ return if target_real_idx >= @items.length || target_real_idx < 0
2718
+
2719
+ target_item = @items[target_real_idx]
2720
+ target_level = target_item["level"]
2721
+
2722
+ # Walk backwards and unfold ALL ancestors, no matter what
2723
+ (target_real_idx - 1).downto(0) do |i|
2724
+ item = @items[i]
2725
+
2726
+ # If this item could be an ancestor (higher in hierarchy)
2727
+ if item["level"] < target_level
2728
+ # Force unfold it, regardless of whether it has children
2729
+ item["fold"] = false if item.has_key?("fold")
2730
+
2731
+ # Update target_level to continue searching
2732
+ target_level = item["level"]
2733
+
2734
+ # Continue until we reach level 0
2735
+ break if target_level == 0
2736
+ end
2737
+ end
2738
+
2739
+ # Force a cache clear to ensure visibility updates
2740
+ clear_cache if respond_to?(:clear_cache)
2741
+ end
2742
+
2636
2743
  def move_item_up(with_children = false)
2637
2744
  visible = get_visible_items
2638
2745
  return if @current >= visible.length || @current == 0
2639
-
2746
+
2640
2747
  save_undo_state # Save state before modification
2641
-
2642
- item = visible[@current]
2643
- real_idx = get_real_index(item)
2644
-
2645
- # Can't move if already at the beginning
2646
- return if real_idx == 0
2647
-
2648
- # Collect item(s) to move
2649
- items_to_move = [item]
2650
-
2748
+
2749
+ current_item = visible[@current]
2750
+ current_real_idx = get_real_index(current_item)
2751
+
2752
+ # Get the previous visible item
2753
+ prev_visible_item = visible[@current - 1]
2754
+ prev_real_idx = get_real_index(prev_visible_item)
2755
+
2756
+ # Collect items to move (current item + children if requested)
2757
+ items_to_move = [current_item]
2651
2758
  if with_children
2652
- # Collect all children
2653
- level = item["level"]
2654
- ((real_idx + 1)...@items.length).each do |i|
2759
+ level = current_item["level"]
2760
+ ((current_real_idx + 1)...@items.length).each do |i|
2655
2761
  if @items[i]["level"] > level
2656
2762
  items_to_move << @items[i]
2657
2763
  else
@@ -2659,46 +2765,42 @@ class HyperListApp
2659
2765
  end
2660
2766
  end
2661
2767
  end
2662
-
2663
- # Find the previous sibling at the same level
2664
- target_level = item["level"]
2665
- target_idx = nil
2666
-
2667
- # Search backwards for a sibling at the same level
2668
- (real_idx - 1).downto(0) do |i|
2669
- if @items[i]["level"] == target_level
2670
- target_idx = i
2671
- break
2672
- elsif @items[i]["level"] < target_level
2673
- # Hit a parent, can't move up
2674
- return
2675
- end
2768
+
2769
+ # Calculate what level the moved item should have at the target position
2770
+ new_level = calculate_level_for_position(prev_real_idx)
2771
+ level_diff = new_level - items_to_move.first["level"]
2772
+
2773
+ # Unfold destination area BEFORE moving if needed
2774
+ ensure_destination_visible(prev_real_idx, new_level)
2775
+
2776
+ # Remove the items to move
2777
+ items_to_move.length.times { @items.delete_at(current_real_idx) }
2778
+
2779
+ # Adjust target position since we removed items after it
2780
+ adjusted_target = prev_real_idx
2781
+ if prev_real_idx > current_real_idx
2782
+ adjusted_target -= items_to_move.length
2676
2783
  end
2677
-
2678
- # Can't move if no sibling found
2679
- return if target_idx.nil?
2680
-
2681
- # Remember the first item we're moving (to track it)
2682
- first_moved_item = items_to_move.first
2683
-
2684
- # Remove items from their current position
2685
- items_to_move.length.times { @items.delete_at(real_idx) }
2686
-
2687
- # Insert before the target sibling
2688
- items_to_move.reverse.each do |item_to_move|
2689
- @items.insert(target_idx, item_to_move)
2784
+
2785
+ # Adjust levels of moved items
2786
+ items_to_move.each do |moved_item|
2787
+ moved_item["level"] += level_diff
2788
+ moved_item["level"] = [moved_item["level"], 0].max # Ensure level doesn't go negative
2690
2789
  end
2691
-
2692
- # Update cursor position to follow the moved item
2693
- # The moved item is now at position target_idx in @items
2694
- # Find this position in the visible list
2790
+
2791
+ # Insert items at target position
2792
+ items_to_move.each_with_index do |item_to_move, idx|
2793
+ @items.insert(adjusted_target + idx, item_to_move)
2794
+ end
2795
+
2796
+ # FORCE the moved item to be visible (safety net)
2797
+ force_item_visible(adjusted_target)
2798
+
2799
+ # Update cursor to follow the moved item
2695
2800
  new_visible = get_visible_items
2696
- new_item_idx = new_visible.find_index { |v| v["_real_index"] == target_idx }
2801
+ new_item_idx = new_visible.find_index { |v| get_real_index(v) == adjusted_target }
2697
2802
  @current = new_item_idx if new_item_idx
2698
-
2699
- # Renumber siblings at the moved item's level
2700
- renumber_siblings(first_moved_item["level"])
2701
-
2803
+
2702
2804
  @modified = true
2703
2805
  @message = "Moved #{items_to_move.length} item(s) up"
2704
2806
  record_last_action(:move_item_up, with_children)
@@ -2707,81 +2809,71 @@ class HyperListApp
2707
2809
  def move_item_down(with_children = false)
2708
2810
  visible = get_visible_items
2709
2811
  return if @current >= visible.length - 1
2710
-
2812
+
2711
2813
  save_undo_state # Save state before modification
2712
-
2713
- item = visible[@current]
2714
- real_idx = get_real_index(item)
2715
-
2716
- # Collect item(s) to move
2717
- items_to_move = [item]
2718
- last_idx = real_idx
2719
-
2814
+
2815
+ current_item = visible[@current]
2816
+ current_real_idx = get_real_index(current_item)
2817
+
2818
+ # Get the next visible item
2819
+ next_visible_item = visible[@current + 1]
2820
+ next_real_idx = get_real_index(next_visible_item)
2821
+
2822
+ # Collect items to move (current item + children if requested)
2823
+ items_to_move = [current_item]
2720
2824
  if with_children
2721
- # Collect all children
2722
- level = item["level"]
2723
- ((real_idx + 1)...@items.length).each do |i|
2825
+ level = current_item["level"]
2826
+ ((current_real_idx + 1)...@items.length).each do |i|
2724
2827
  if @items[i]["level"] > level
2725
2828
  items_to_move << @items[i]
2726
- last_idx = i
2727
2829
  else
2728
2830
  break
2729
2831
  end
2730
2832
  end
2731
2833
  end
2732
-
2733
- # Find the next sibling at the same level
2734
- target_level = item["level"]
2735
- target_idx = nil
2736
- next_sibling_end = nil
2737
-
2738
- # Search forward for a sibling at the same level
2739
- ((last_idx + 1)...@items.length).each do |i|
2740
- if @items[i]["level"] == target_level
2741
- # Found next sibling, now find where it ends (including its children)
2742
- next_sibling_end = i
2743
- ((i + 1)...@items.length).each do |j|
2744
- if @items[j]["level"] > target_level
2745
- next_sibling_end = j
2746
- else
2747
- break
2748
- end
2749
- end
2750
- target_idx = next_sibling_end + 1
2751
- break
2752
- elsif @items[i]["level"] < target_level
2753
- # Hit a parent level, can't move down
2754
- return
2755
- end
2834
+
2835
+ # Target is immediately after the next visible item (not after its children)
2836
+ target_real_idx = next_real_idx + 1
2837
+
2838
+ # Check if there are orphaned children at the target position
2839
+ # If so, position before them to reunite
2840
+ orphaned_children = find_orphaned_children(current_item["text"], target_real_idx)
2841
+ if !orphaned_children.empty?
2842
+ target_real_idx = orphaned_children.first
2756
2843
  end
2757
-
2758
- # Can't move if no sibling found
2759
- return if target_idx.nil?
2760
-
2761
- # Remember the first item we're moving (to track it)
2762
- first_moved_item = items_to_move.first
2763
-
2764
- # Remove items from their current position
2765
- items_to_move.length.times { @items.delete_at(real_idx) }
2766
-
2767
- # Adjust target index since we removed items
2768
- target_idx -= items_to_move.length
2769
-
2770
- # Insert after the target sibling and its children
2844
+
2845
+ # Calculate what level the moved item should have at the target position
2846
+ new_level = calculate_level_for_position(target_real_idx)
2847
+ level_diff = new_level - items_to_move.first["level"]
2848
+
2849
+ # Unfold destination area BEFORE moving if needed
2850
+ ensure_destination_visible(target_real_idx, new_level)
2851
+
2852
+ # Remove the items to move
2853
+ items_to_move.length.times { @items.delete_at(current_real_idx) }
2854
+
2855
+ # Adjust target position since we removed items before it
2856
+ adjusted_target = target_real_idx - items_to_move.length
2857
+
2858
+ # Adjust levels of moved items
2859
+ items_to_move.each do |moved_item|
2860
+ moved_item["level"] += level_diff
2861
+ moved_item["level"] = [moved_item["level"], 0].max # Ensure level doesn't go negative
2862
+ end
2863
+
2864
+ # Insert items at target position
2771
2865
  items_to_move.each_with_index do |item_to_move, idx|
2772
- @items.insert(target_idx + idx, item_to_move)
2866
+ @items.insert(adjusted_target + idx, item_to_move)
2773
2867
  end
2774
-
2775
- # Update cursor position to follow the moved item
2776
- # The moved item is now at position target_idx in @items
2777
- # Find this position in the visible list
2868
+
2869
+ # FORCE the moved item to be visible (safety net)
2870
+ force_item_visible(adjusted_target)
2871
+
2872
+ # Update cursor to follow the moved item
2778
2873
  new_visible = get_visible_items
2779
- new_item_idx = new_visible.find_index { |v| v["_real_index"] == target_idx }
2874
+ new_item_idx = new_visible.find_index { |v| get_real_index(v) == adjusted_target }
2780
2875
  @current = new_item_idx if new_item_idx
2781
-
2782
- # Renumber siblings at the moved item's level
2783
- renumber_siblings(first_moved_item["level"])
2784
-
2876
+
2785
2877
  @modified = true
2786
2878
  @message = "Moved #{items_to_move.length} item(s) down"
2787
2879
  record_last_action(:move_item_down, with_children)
@@ -2930,7 +3022,35 @@ class HyperListApp
2930
3022
  @modified = true
2931
3023
  record_last_action(:toggle_checkbox_with_date, nil)
2932
3024
  end
2933
-
3025
+
3026
+ def remove_checkbox
3027
+ visible = get_visible_items
3028
+ return if @current >= visible.length
3029
+
3030
+ save_undo_state # Save state before modification
3031
+
3032
+ item = visible[@current]
3033
+ real_idx = get_real_index(item)
3034
+ text = @items[real_idx]["text"]
3035
+
3036
+ # Remove checkbox patterns: [ ], [X], [x], [O], [-], [_]
3037
+ if text =~ /^\[[ X_Ox-]\]/
3038
+ # Remove the checkbox and any following space
3039
+ text = text.sub(/^\[[ X_Ox-]\]\s*/, '')
3040
+
3041
+ # Also remove any timestamp that might be associated with checkbox
3042
+ # Format: YYYY-MM-DD HH.MM: or similar
3043
+ text = text.sub(/^\d{4}-\d{2}-\d{2} \d{2}\.\d{2}:\s*/, '')
3044
+
3045
+ @items[real_idx]["text"] = text
3046
+ @modified = true
3047
+ @message = "Removed checkbox"
3048
+ record_last_action(:remove_checkbox, nil)
3049
+ else
3050
+ @message = "No checkbox to remove"
3051
+ end
3052
+ end
3053
+
2934
3054
  def search_forward
2935
3055
  @mode = :search
2936
3056
  @search = @footer.ask("Search: ", @search || "")
@@ -3067,12 +3187,13 @@ class HyperListApp
3067
3187
  help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
3068
3188
  help_lines << help_line("#{"r".fg("10")}" + ", ".fg("10") + "#{"C-R".fg("10")}", "Redo")
3069
3189
  help_lines << help_line("#{"S-UP".fg("10")}", "Move item up", "#{"S-DOWN".fg("10")}", "Move item down")
3070
- help_lines << help_line("#{"C-UP".fg("10")}", "Move item&descendants up", "#{"C-DOWN".fg("10")}", "Move item&descendants down")
3190
+ help_lines << help_line("#{"C-UP".fg("10")}", "Move up in visible list", "#{"C-DOWN".fg("10")}", "Move down in visible list")
3071
3191
  help_lines << help_line("#{"→".fg("10")}", "Uncollapse item", "#{"←".fg("10")}", "Collapse item")
3072
3192
  help_lines << help_line("#{"Tab".fg("10")}", "Indent item+kids", "#{"S-Tab".fg("10")}", "Unindent item+kids")
3073
3193
  help_lines << ""
3074
3194
  help_lines << "#{"SPECIAL FEATURES".fg("14")}"
3075
3195
  help_lines << help_line("#{"v".fg("10")}", "Toggle checkbox", "#{"V".fg("10")}", "Checkbox with date")
3196
+ help_lines << help_line("#{"C-X".fg("10")}", "Remove checkbox", "", "")
3076
3197
  help_lines << help_line("#{"C-E".fg("10")}", "Encrypt/decrypt line", "#{"C-U".fg("10")}", "Toggle State/Trans underline")
3077
3198
  help_lines << help_line("#{"P".fg("10")}", "Presentation mode", "#{"Tab/S-Tab".fg("10")}", "Next/prev sibling (in P)")
3078
3199
  help_lines << help_line("#{"Ma".fg("10")}", "Record macro 'a'", "#{"@a".fg("10")}", "Play macro 'a'")
@@ -4948,6 +5069,8 @@ class HyperListApp
4948
5069
  toggle_checkbox
4949
5070
  when "V"
4950
5071
  toggle_checkbox_with_date
5072
+ when "C-X" # Ctrl-Shift-X for remove checkbox
5073
+ remove_checkbox
4951
5074
  when "TAB"
4952
5075
  # Indent with move conditions (moved from RIGHT key)
4953
5076
  should_move_children = should_move_with_children?
@@ -5895,12 +6018,12 @@ class HyperListApp
5895
6018
  move_item_up(false)
5896
6019
  when "S-DOWN" # Move item down
5897
6020
  move_item_down(false)
5898
- when "C-UP" # Move item and descendants up
5899
- move_item_up(true)
5900
- when "C-DOWN" # Move item and descendants down
5901
- move_item_down(true)
5902
- when "C-K" # Alternative: Move item and descendants up (for terminals that intercept C-UP)
5903
- move_item_up(true)
6021
+ when "C-UP" # Move up in visible list (with children only if collapsed)
6022
+ move_item_up(should_move_with_children?)
6023
+ when "C-DOWN" # Move down in visible list (with children only if collapsed)
6024
+ move_item_down(should_move_with_children?)
6025
+ when "C-K" # Alternative: Move up in visible list (for terminals that intercept C-UP)
6026
+ move_item_up(should_move_with_children?)
5904
6027
  when "TAB"
5905
6028
  if @presentation_mode
5906
6029
  # In presentation mode, Tab goes to next sibling
@@ -5935,6 +6058,8 @@ class HyperListApp
5935
6058
  toggle_checkbox
5936
6059
  when "V"
5937
6060
  toggle_checkbox_with_date
6061
+ when "C-X" # Ctrl-Shift-X for remove checkbox
6062
+ remove_checkbox
5938
6063
  when "."
5939
6064
  repeat_last_action
5940
6065
  when "/"
data/hyperlist.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "hyperlist"
3
- spec.version = "1.6.0"
3
+ spec.version = "1.7.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.6.0
4
+ version: 1.7.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-18 00:00:00.000000000 Z
11
+ date: 2025-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses