hyperlist 1.2.3 → 1.2.4

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 +18 -6
  3. data/hyperlist +250 -17
  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: cf122ea3e53905dda1673a8a1c79f059fd19bbb55765cff5330ea9adac850aeb
4
- data.tar.gz: c96e2559e357a69fa21d8fce8286b17c32460ce6aac7261035d5da92034c8772
3
+ metadata.gz: 03eb9614c8726c4e597cadde5b213e195a352ccaeae0234cb93b4114dddd9729
4
+ data.tar.gz: 557a147e2874dcb0aa1e0acbe13d28f5de51954661a7fd7e6b3cc4408e73ff94
5
5
  SHA512:
6
- metadata.gz: 007ae293391311a7f13ece074f98fe2fb6a99b3d2b4eb73e59267828c46337a24104a1d7cbf7fb59ead5df282f6baad25a15722d054483774915757dc4c6ba22
7
- data.tar.gz: e065656d916620d74cdb60e0bcb29f7b998fa2f08faaeb742165eaf340aa45d5dc4cb940bc7c8822c7d519b876665b33732b21c7a02f115a16464a61872408c5
6
+ metadata.gz: d3ee01faf4256502806d37700f64217fccd29fea0c4d131d86693adf6f83f18e2b7477597cae4cc8fff61b507a7012f0219b21b24a8795319765f2a455359f9f
7
+ data.tar.gz: 156252aaa0cdc6ba380049b04ae8812c48906cf4fa67ae2e0af060868fba9d8ddbc97af5a6a8fe000714bd5371149e929e2dda4afa3842daaf391383689d7f15
data/README.md CHANGED
@@ -29,18 +29,28 @@ 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.2.0
32
+ ## What's New in v1.2.4
33
33
 
34
- ### 📝 User-Defined Templates
34
+ ### 🎯 Enhanced Item Movement & Editing
35
+ - **Smart Item Movement**: `C-UP`/`C-DOWN` now move items past siblings at the same level
36
+ - **Cursor Tracking**: Cursor follows moved items to their new position
37
+ - **Auto-renumbering**: Numbered lists automatically renumber when items are moved or deleted
38
+ - **Outdented Insert**: Press `A` to insert a new item one level less indented
39
+ - **Quick Save & Quit**: Press `W` as a shortcut for `:wq`
40
+ - **Configurable Indentation**: Press `I` to cycle between 2-5 spaces per indent level
41
+ - **Auto-detect Indentation**: Automatically detects indentation from loaded files
42
+ - **Global Settings**: Indentation preference saved in `~/.hyperlist/config.yml`
43
+
44
+ ## Previous Updates
45
+
46
+ ### v1.2.0 - User-Defined Templates
35
47
  - **Save as Template**: Save any HyperList document as a reusable template (`:st`)
36
48
  - **Template Manager**: List and delete your custom templates (`:lt`, `:dt`)
37
49
  - **Enhanced Template Browser**: Shows both built-in and user templates
38
50
  - **Template Metadata**: Includes description and creation date
39
51
  - Templates stored in `~/.hyperlist/templates/` for easy backup and sharing
40
52
 
41
- ## Previous Updates (v1.1.0)
42
-
43
- ### 🔐 Encryption Support
53
+ ### v1.1.0 - Encryption Support
44
54
  - **File-level encryption** for sensitive files (dot files like `.passwords.hl`)
45
55
  - **Line-level encryption** for individual items (Ctrl-E to toggle)
46
56
  - Secure AES-256-CBC encryption with PBKDF2 key derivation
@@ -150,6 +160,8 @@ hyperlist file.txt # Open any text file
150
160
  - `o` - Insert line below
151
161
  - `O` - Insert line above
152
162
  - `a` - Insert child item
163
+ - `A` - Insert outdented item (one level less)
164
+ - `I` - Cycle indentation size (2-5 spaces)
153
165
  - `D` - Delete and yank line
154
166
  - `C-D` - Delete and yank item with descendants
155
167
  - `y/Y` - Copy line/tree
@@ -174,7 +186,7 @@ hyperlist file.txt # Open any text file
174
186
  #### File Commands
175
187
  - `:w` - Save
176
188
  - `:q` - Quit
177
- - `:wq` - Save and quit
189
+ - `:wq` or `W` - Save and quit
178
190
  - `:e file` - Open file
179
191
  - `:export html` - Export to HTML
180
192
  - `:export md` - Export to Markdown
data/hyperlist CHANGED
@@ -117,6 +117,11 @@ class HyperListApp
117
117
  @active_pane = :main # :main or :split
118
118
  @message_timeout = nil # For timed message display
119
119
 
120
+ # Global configuration
121
+ @config_file = File.expand_path("~/.hyperlist/config.yml")
122
+ @indent_size = 2 # Default indentation (2-5 spaces)
123
+ load_config
124
+
120
125
  # Terminal setup
121
126
  if IO.console
122
127
  @rows, @cols = IO.console.winsize
@@ -219,6 +224,9 @@ class HyperListApp
219
224
  lines = content.split("\n")
220
225
  end
221
226
 
227
+ # Auto-detect indentation from the file
228
+ detect_indentation(lines)
229
+
222
230
  # Check if file is large
223
231
  large_file = lines.length > 10000
224
232
 
@@ -230,7 +238,17 @@ class HyperListApp
230
238
  # Process lines with optional progress updates for large files
231
239
  lines.each_with_index do |line, idx|
232
240
  next if line.strip.empty?
233
- level = line[/^\t*/].length
241
+
242
+ # Detect level based on leading whitespace
243
+ if line.start_with?("\t")
244
+ # Tab-based indentation
245
+ level = line[/^\t*/].length
246
+ else
247
+ # Space-based indentation
248
+ spaces = line[/^ */].length
249
+ level = @indent_size > 0 ? spaces / @indent_size : 0
250
+ end
251
+
234
252
  text = line.strip
235
253
 
236
254
  # Track encrypted lines
@@ -411,7 +429,7 @@ class HyperListApp
411
429
 
412
430
  # Prepare content
413
431
  content = @items.map do |item|
414
- "\t" * item["level"] + item["text"]
432
+ (' ' * @indent_size) * item["level"] + item["text"]
415
433
  end.join("\n")
416
434
 
417
435
  # Check if this should be an encrypted file
@@ -535,7 +553,7 @@ class HyperListApp
535
553
  lines << "## #{text}" unless text.empty?
536
554
  else
537
555
  # Use spaces for indentation in lists
538
- indent = ' ' * [level - 2, 0].max
556
+ indent = ' ' * (@indent_size * [level - 2, 0].max)
539
557
  if text.start_with?('- [')
540
558
  lines << indent + text
541
559
  else
@@ -585,7 +603,7 @@ class HyperListApp
585
603
  @items.each do |item|
586
604
  original_text = item["text"]
587
605
  level = item["level"]
588
- indent = " " * level
606
+ indent = ' ' * (@indent_size * level)
589
607
 
590
608
  # Process the line
591
609
  # We'll apply formatting BEFORE escaping HTML entities
@@ -691,7 +709,7 @@ class HyperListApp
691
709
  def export_to_text
692
710
  lines = []
693
711
  @items.each do |item|
694
- indent = ' ' * item["level"] # 4 spaces per level
712
+ indent = ' ' * (@indent_size * item["level"])
695
713
  lines << indent + item["text"]
696
714
  end
697
715
  lines.join("\n")
@@ -724,7 +742,7 @@ class HyperListApp
724
742
  visible_items.each_with_index do |item, idx|
725
743
  next unless item
726
744
 
727
- line = " " * item["level"] # 4 spaces per level
745
+ line = ' ' * (@indent_size * item["level"])
728
746
 
729
747
  # Add fold indicator
730
748
  real_idx = get_real_index(item)
@@ -1554,6 +1572,8 @@ class HyperListApp
1554
1572
  insert_line_with_text(@last_action)
1555
1573
  when :insert_child
1556
1574
  insert_child_with_text(@last_action)
1575
+ when :insert_outdented
1576
+ insert_outdented_with_text(@last_action)
1557
1577
  when :delete_line
1558
1578
  delete_line
1559
1579
  when :edit_line
@@ -1659,6 +1679,21 @@ class HyperListApp
1659
1679
  @footer.refresh
1660
1680
  end
1661
1681
 
1682
+ def insert_outdented
1683
+ @mode = :insert
1684
+
1685
+ input = @footer.ask("New outdented item: ", "")
1686
+
1687
+ if input && !input.strip.empty?
1688
+ insert_outdented_with_text(input)
1689
+ record_last_action(:insert_outdented, input)
1690
+ end
1691
+
1692
+ @mode = :normal
1693
+ @footer.clear # Clear footer immediately
1694
+ @footer.refresh
1695
+ end
1696
+
1662
1697
  def insert_child_with_text(text)
1663
1698
  return unless text && !text.strip.empty?
1664
1699
 
@@ -1679,6 +1714,35 @@ class HyperListApp
1679
1714
  @current += 1
1680
1715
  end
1681
1716
 
1717
+ def insert_outdented_with_text(text)
1718
+ return unless text && !text.strip.empty?
1719
+
1720
+ save_undo_state # Save state before modification
1721
+ visible = get_visible_items
1722
+ if @current < visible.length
1723
+ current_level = visible[@current]["level"]
1724
+ # Outdent means one level less, but not less than 0
1725
+ level = [current_level - 1, 0].max
1726
+ real_idx = get_real_index(visible[@current])
1727
+
1728
+ # Find where to insert: after current item and all its descendants
1729
+ insert_idx = real_idx + 1
1730
+ while insert_idx < @items.length && @items[insert_idx]["level"] > current_level
1731
+ insert_idx += 1
1732
+ end
1733
+
1734
+ @items.insert(insert_idx, {"text" => text, "level" => level, "fold" => false})
1735
+
1736
+ # Update cursor to point to the new item
1737
+ @current = get_visible_items.index { |item| item.equal?(@items[insert_idx]) } || (@current + 1)
1738
+ else
1739
+ # If at end of list, add at level 0
1740
+ @items << {"text" => text, "level" => 0, "fold" => false}
1741
+ @current = get_visible_items.length - 1
1742
+ end
1743
+ @modified = true
1744
+ end
1745
+
1682
1746
  def edit_line
1683
1747
  visible = get_visible_items
1684
1748
  return if @current >= visible.length
@@ -1756,10 +1820,16 @@ class HyperListApp
1756
1820
  end
1757
1821
 
1758
1822
  # Delete the items
1823
+ # Remember the level of the deleted item for renumbering
1824
+ deleted_level = item["level"]
1825
+
1759
1826
  delete_count.times { @items.delete_at(real_idx) }
1760
1827
 
1761
1828
  @items = [{"text" => "Empty", "level" => 0, "fold" => false}] if @items.empty?
1762
1829
 
1830
+ # Renumber siblings at the deleted item's level
1831
+ renumber_siblings(deleted_level) unless @items.length == 1
1832
+
1763
1833
  @current = [@current, get_visible_items.length - 1].min
1764
1834
  @current = 0 if @current < 0
1765
1835
  @modified = true
@@ -1863,18 +1933,47 @@ class HyperListApp
1863
1933
  end
1864
1934
  end
1865
1935
 
1936
+ # Find the previous sibling at the same level
1937
+ target_level = item["level"]
1938
+ target_idx = nil
1939
+
1940
+ # Search backwards for a sibling at the same level
1941
+ (real_idx - 1).downto(0) do |i|
1942
+ if @items[i]["level"] == target_level
1943
+ target_idx = i
1944
+ break
1945
+ elsif @items[i]["level"] < target_level
1946
+ # Hit a parent, can't move up
1947
+ return
1948
+ end
1949
+ end
1950
+
1951
+ # Can't move if no sibling found
1952
+ return if target_idx.nil?
1953
+
1954
+ # Remember the first item we're moving (to track it)
1955
+ first_moved_item = items_to_move.first
1956
+
1866
1957
  # Remove items from their current position
1867
1958
  items_to_move.length.times { @items.delete_at(real_idx) }
1868
1959
 
1869
- # Insert one position up (before the item that was above us)
1870
- target_idx = real_idx - 1
1960
+ # Insert before the target sibling
1871
1961
  items_to_move.reverse.each do |item_to_move|
1872
1962
  @items.insert(target_idx, item_to_move)
1873
1963
  end
1874
1964
 
1875
- @current -= 1
1965
+ # Update cursor position to follow the moved item
1966
+ # The moved item is now at position target_idx in @items
1967
+ # Find this position in the visible list
1968
+ new_visible = get_visible_items
1969
+ new_item_idx = new_visible.find_index { |v| v["_real_index"] == target_idx }
1970
+ @current = new_item_idx if new_item_idx
1971
+
1972
+ # Renumber siblings at the moved item's level
1973
+ renumber_siblings(first_moved_item["level"])
1974
+
1876
1975
  @modified = true
1877
- @message = "Moved #{items_to_move.length} item(s) up one line"
1976
+ @message = "Moved #{items_to_move.length} item(s) up"
1878
1977
  record_last_action(:move_item_up, with_children)
1879
1978
  end
1880
1979
 
@@ -1904,26 +2003,78 @@ class HyperListApp
1904
2003
  end
1905
2004
  end
1906
2005
 
1907
- # Can't move if we're already at the end
1908
- return if last_idx >= @items.length - 1
2006
+ # Find the next sibling at the same level
2007
+ target_level = item["level"]
2008
+ target_idx = nil
2009
+ next_sibling_end = nil
2010
+
2011
+ # Search forward for a sibling at the same level
2012
+ ((last_idx + 1)...@items.length).each do |i|
2013
+ if @items[i]["level"] == target_level
2014
+ # Found next sibling, now find where it ends (including its children)
2015
+ next_sibling_end = i
2016
+ ((i + 1)...@items.length).each do |j|
2017
+ if @items[j]["level"] > target_level
2018
+ next_sibling_end = j
2019
+ else
2020
+ break
2021
+ end
2022
+ end
2023
+ target_idx = next_sibling_end + 1
2024
+ break
2025
+ elsif @items[i]["level"] < target_level
2026
+ # Hit a parent level, can't move down
2027
+ return
2028
+ end
2029
+ end
2030
+
2031
+ # Can't move if no sibling found
2032
+ return if target_idx.nil?
2033
+
2034
+ # Remember the first item we're moving (to track it)
2035
+ first_moved_item = items_to_move.first
1909
2036
 
1910
2037
  # Remove items from their current position
1911
2038
  items_to_move.length.times { @items.delete_at(real_idx) }
1912
2039
 
1913
- # Insert one position down (after the item that was below us)
1914
- # Since we removed items, the target position is now at real_idx + 1
1915
- target_idx = real_idx + 1
2040
+ # Adjust target index since we removed items
2041
+ target_idx -= items_to_move.length
1916
2042
 
2043
+ # Insert after the target sibling and its children
1917
2044
  items_to_move.each_with_index do |item_to_move, idx|
1918
2045
  @items.insert(target_idx + idx, item_to_move)
1919
2046
  end
1920
2047
 
1921
- @current += 1
2048
+ # Update cursor position to follow the moved item
2049
+ # The moved item is now at position target_idx in @items
2050
+ # Find this position in the visible list
2051
+ new_visible = get_visible_items
2052
+ new_item_idx = new_visible.find_index { |v| v["_real_index"] == target_idx }
2053
+ @current = new_item_idx if new_item_idx
2054
+
2055
+ # Renumber siblings at the moved item's level
2056
+ renumber_siblings(first_moved_item["level"])
2057
+
1922
2058
  @modified = true
1923
- @message = "Moved #{items_to_move.length} item(s) down one line"
2059
+ @message = "Moved #{items_to_move.length} item(s) down"
1924
2060
  record_last_action(:move_item_down, with_children)
1925
2061
  end
1926
2062
 
2063
+ def renumber_siblings(changed_level)
2064
+ # Renumber items at the specified level that start with numbers
2065
+ current_number = 1
2066
+ @items.each do |item|
2067
+ if item["level"] == changed_level
2068
+ # Check if item starts with a number pattern like "1." or "1)"
2069
+ if item["text"] =~ /^\d+[\.\)]\s+(.+)/
2070
+ # Replace the number with the current sequential number
2071
+ item["text"] = "#{current_number}. #{$1}"
2072
+ current_number += 1
2073
+ end
2074
+ end
2075
+ end
2076
+ end
2077
+
1927
2078
  def indent_right(with_children = true)
1928
2079
  visible = get_visible_items
1929
2080
  return if @current >= visible.length
@@ -2179,6 +2330,8 @@ class HyperListApp
2179
2330
  help_lines << "#{"EDITING".fg("14")}"
2180
2331
  help_lines << help_line("#{"i/Enter".fg("10")}", "Edit line", "#{"o".fg("10")}", "Insert line below")
2181
2332
  help_lines << help_line("#{"O".fg("10")}", "Insert line above", "#{"a".fg("10")}", "Insert child")
2333
+ help_lines << help_line("#{"A".fg("10")}", "Insert outdented", "#{"W".fg("10")}", "Save and quit")
2334
+ help_lines << help_line("#{"I".fg("10")}", "Cycle indent (2-5)", "", "")
2182
2335
  help_lines << help_line("#{"D".fg("10")}", "Delete+yank line", "#{"C-D".fg("10")}", "Delete+yank item&descendants")
2183
2336
  help_lines << help_line("#{"y".fg("10")}" + "/".fg("10") + "#{"Y".fg("10")}", "Copy line/tree", "#{"p".fg("10")}", "Paste")
2184
2337
  help_lines << help_line("#{"u".fg("10")}", "Undo", "#{".".fg("10")}", "Repeat last action")
@@ -3282,6 +3435,69 @@ class HyperListApp
3282
3435
  end
3283
3436
  end
3284
3437
 
3438
+ def detect_indentation(lines)
3439
+ # Try to auto-detect indentation size from the file
3440
+ # Find the greatest common divisor of all indentation levels
3441
+ indents = []
3442
+
3443
+ lines.each do |line|
3444
+ next if line.strip.empty?
3445
+
3446
+ # Skip tab-indented lines for space detection
3447
+ next if line.start_with?("\t")
3448
+
3449
+ # Count leading spaces
3450
+ spaces = line[/^ */].length
3451
+ indents << spaces if spaces > 0
3452
+ end
3453
+
3454
+ return if indents.empty?
3455
+
3456
+ # Find the GCD of all indentation levels
3457
+ gcd = indents.reduce(indents.first) { |g, n| n.gcd(g) }
3458
+
3459
+ # Clamp to reasonable range (2-5)
3460
+ if gcd >= 2 && gcd <= 5
3461
+ @indent_size = gcd
3462
+ elsif gcd > 5
3463
+ # If GCD is too large, try to find a divisor between 2-5
3464
+ [5, 4, 3, 2].each do |size|
3465
+ if gcd % size == 0
3466
+ @indent_size = size
3467
+ break
3468
+ end
3469
+ end
3470
+ end
3471
+ # If we can't detect, keep the current setting
3472
+ end
3473
+
3474
+ def load_config
3475
+ # Don't load config, let each file's indentation be detected
3476
+ # Config is only saved when user explicitly changes with 'I'
3477
+ end
3478
+
3479
+ def save_config
3480
+ require 'yaml'
3481
+ config_dir = File.dirname(@config_file)
3482
+ FileUtils.mkdir_p(config_dir) unless Dir.exist?(config_dir)
3483
+
3484
+ config = {
3485
+ 'indent_size' => @indent_size
3486
+ }
3487
+
3488
+ File.write(@config_file, config.to_yaml)
3489
+ rescue => e
3490
+ @message = "Failed to save config: #{e.message}"
3491
+ end
3492
+
3493
+ def cycle_indent_size
3494
+ @indent_size = (@indent_size % 5) + 1 # Cycles through 1,2,3,4,5
3495
+ @indent_size = 2 if @indent_size < 2 # Ensure minimum of 2
3496
+ save_config
3497
+ @message = "Indentation set to #{@indent_size} spaces"
3498
+ @modified = true # Mark as modified since display will change
3499
+ end
3500
+
3285
3501
  def ensure_templates_dir
3286
3502
  FileUtils.mkdir_p(@templates_dir) unless File.exist?(@templates_dir)
3287
3503
  end
@@ -3839,6 +4055,8 @@ class HyperListApp
3839
4055
  insert_line_above
3840
4056
  when "a"
3841
4057
  insert_child
4058
+ when "A"
4059
+ insert_outdented
3842
4060
  when "d"
3843
4061
  # Check if it's dd
3844
4062
  if getchr == "d"
@@ -4728,6 +4946,21 @@ class HyperListApp
4728
4946
  @current += 1
4729
4947
  when "a"
4730
4948
  insert_child
4949
+ when "A"
4950
+ insert_outdented
4951
+ when "W"
4952
+ # Save and quit (shortcut for :wq)
4953
+ if @filename
4954
+ save_file
4955
+ else
4956
+ filename_input = @footer.ask("Save as: ", "")
4957
+ @filename = File.expand_path(filename_input) if filename_input && !filename_input.empty?
4958
+ save_file if @filename && !@filename.empty?
4959
+ end
4960
+ quit if @filename
4961
+ when "I"
4962
+ # Cycle through indentation sizes (2-5 spaces)
4963
+ cycle_indent_size
4731
4964
  when "t"
4732
4965
  show_templates
4733
4966
  when "D" # Delete line (with children)
data/hyperlist.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "hyperlist"
3
- spec.version = "1.2.3"
3
+ spec.version = "1.2.4"
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.2.3
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: "."
10
10
  cert_chain: []
11
- date: 2025-08-22 00:00:00.000000000 Z
11
+ date: 2025-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses