diff --git a/lib/tui.rb b/lib/tui.rb index 5133398..9b95583 100644 --- a/lib/tui.rb +++ b/lib/tui.rb @@ -802,13 +802,140 @@ def apply_style(text, style) end class InputField - attr_accessor :text, :cursor - attr_reader :placeholder + attr_reader :placeholder, :text, :cursor - def initialize(placeholder:, text:, cursor: nil) + def initialize(placeholder: "", text: "", cursor: nil) @placeholder = placeholder @text = text.to_s.dup - @cursor = cursor.nil? ? @text.length : [[cursor, 0].max, @text.length].min + @cursor = cursor.nil? ? @text.length : cursor.to_i + clamp_cursor! + end + + def text=(value) + @text = value.to_s.dup + clamp_cursor! + end + + def cursor=(pos) + @cursor = pos.to_i + clamp_cursor! + end + + # Returns true if consumed as text-editing, false if the selector should handle it. + def handle_key(key) + return false if key.nil? || key.empty? + + case key + when "\x7F", "\x08" + backspace + true + when "\e[3~" + delete_forward + true + when "\x01" + cursor_home + true + when "\x05" + cursor_end + true + when "\x02" + cursor_left + true + when "\x06" + cursor_right + true + when "\x0B" + kill_to_end + true + when "\x15" + kill_to_start + true + when "\x17" + kill_word + true + else + if left_arrow?(key) + cursor_left + true + elsif right_arrow?(key) + cursor_right + true + elsif home_key?(key) + cursor_home + true + elsif end_key?(key) + cursor_end + true + elsif key.length == 1 + code = key.ord + if code >= 32 && code != 127 + insert(key) + true + else + false + end + else + false + end + end + end + + def insert(ch) + s = ch.to_s + return if s.empty? + @text = @text[0...@cursor].to_s + s + @text[@cursor..].to_s + @cursor += 1 + end + + def backspace + return if @cursor <= 0 + @text = @text[0...(@cursor - 1)].to_s + @text[@cursor..].to_s + @cursor -= 1 + end + + def delete_forward + return if @cursor >= @text.length + @text = @text[0...@cursor].to_s + @text[(@cursor + 1)..].to_s + end + + def kill_to_end + @text = @text[0...@cursor].to_s + end + + def kill_to_start + @text = @text[@cursor..].to_s + @cursor = 0 + end + + def kill_word + return if @cursor <= 0 + new_pos = word_boundary_backward(@text, @cursor) + @text = @text[0...new_pos].to_s + @text[@cursor..].to_s + @cursor = new_pos + end + + def cursor_left + @cursor -= 1 if @cursor > 0 + end + + def cursor_right + @cursor += 1 if @cursor < @text.length + end + + def cursor_home + @cursor = 0 + end + + def cursor_end + @cursor = @text.length + end + + # Alphanumeric word boundary (Ctrl-W). Skips separators, then the word. + def word_boundary_backward(buffer, cursor) + pos = cursor - 1 + pos -= 1 while pos >= 0 && !alnum_char?(buffer[pos]) + pos -= 1 while pos >= 0 && alnum_char?(buffer[pos]) + pos + 1 end def to_s @@ -832,5 +959,36 @@ def to_s def render_placeholder Text.dim(placeholder) end + + def clamp_cursor! + @cursor = 0 if @cursor < 0 + @cursor = @text.length if @cursor > @text.length + end + + # Avoid Regexp#match? on control bytes (Spinel can SIGSEGV). + def alnum_char?(ch) + return false if ch.nil? || ch.empty? + c = ch.ord + (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) + end + + def left_arrow?(key) + return true if key == "\e[D" || key == "\eOD" + key.start_with?("\e[") && key.end_with?("D") && key.length > 3 + end + + def right_arrow?(key) + return true if key == "\e[C" || key == "\eOC" + key.start_with?("\e[") && key.end_with?("C") && key.length > 3 + end + + def home_key?(key) + key == "\e[H" || key == "\e[1~" || key == "\e[7~" || key == "\eOH" + end + + def end_key?(key) + key == "\e[F" || key == "\e[4~" || key == "\e[8~" || key == "\eOF" + end end + end diff --git a/spec/tests/test_08_keyboard.sh b/spec/tests/test_08_keyboard.sh index fe3ca97..a77fec3 100644 --- a/spec/tests/test_08_keyboard.sh +++ b/spec/tests/test_08_keyboard.sh @@ -133,3 +133,21 @@ if echo "$output" | grep -q "hello.*beta"; then else fail "Ctrl-W should stop at dash (delete only world)" "hello.*beta in output" "$output" "tui_spec.md#keyboard-input" fi + +# Test: Left arrow moves cursor in the search box +# Type "beta", Left, Left, insert "X" => "beXta" (create-new path) +output=$(try_run --path="$TEST_TRIES" --and-keys="beta"$'\x1b[D\x1b[D'"X"$'\r' exec 2>/dev/null) +if echo "$output" | grep -q "beXta"; then + pass +else + fail "left arrow should move search cursor" "beXta in output" "$output" "tui_spec.md#keyboard-input" +fi + +# Test: Ctrl-U kills to start of line +# Type "xxx", Ctrl-U (cursor at end, clears all), type "beta" +output=$(try_run --path="$TEST_TRIES" --and-keys="xxx"$'\x15'"beta"$'\r' exec 2>/dev/null) +if echo "$output" | grep -E "(touch|mkdir|cd) " | grep -q "beta" && ! echo "$output" | grep -E "(touch|mkdir|cd) " | grep -q "xxx"; then + pass +else + fail "Ctrl-U should kill to start of line" "beta without xxx" "$output" "tui_spec.md#keyboard-input" +fi diff --git a/spec/tests/test_19_input_field.sh b/spec/tests/test_19_input_field.sh index a64e74a..d9a97e0 100644 --- a/spec/tests/test_19_input_field.sh +++ b/spec/tests/test_19_input_field.sh @@ -35,12 +35,14 @@ output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="ab,BACKSPACE,BACKSP # Input should be empty or show placeholder pass # Hard to verify empty input, just ensure no crash -# Test: Cursor position updates with arrow keys (if supported) -# Left arrow should move cursor within input -output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="abc,LEFT,LEFT,d" exec 2>&1) -# Typing 'd' after moving left twice should insert in middle -# Result could be "adbc" if insert mode works -pass # Implementation-dependent +# Test: Left/Right arrows move the search-box cursor (insert in the middle) +output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="TYPE=abc,LEFT,LEFT,d" exec 2>&1) +stripped=$(echo "$output" | strip_ansi) +if echo "$stripped" | grep -q "adbc"; then + pass +else + fail "left arrow should move cursor so d inserts in the middle" "adbc in search" "$output" "tui_spec.md#line-editing" +fi # Test: Input accepts spaces output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="a b" exec 2>&1) @@ -71,10 +73,15 @@ else pass # As long as no crash fi -# Test: Ctrl-U clears input line -output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="testing,CTRL-U" exec 2>&1) -# After Ctrl-U, "testing" should not appear in search line -pass # Implementation-dependent, just verify no crash +# Test: Ctrl-U clears from start of line to cursor (cursor at end => clear all) +output=$(try_run --path="$TEST_TRIES" --and-exit --and-keys="TYPE=testing,CTRL-U" exec 2>&1) +stripped=$(echo "$output" | strip_ansi) +search_line=$(echo "$stripped" | grep "Search:" | tail -1) +if echo "$search_line" | grep -q "testing"; then + fail "Ctrl-U should clear the search text" "no testing on Search: line" "$search_line" "tui_spec.md#line-editing" +else + pass +fi # Test: Empty input shows all entries output=$(try_run --path="$TEST_TRIES" --and-exit exec 2>&1) diff --git a/spec/tui_spec.md b/spec/tui_spec.md index b5a9384..8ae787e 100644 --- a/spec/tui_spec.md +++ b/spec/tui_spec.md @@ -217,14 +217,16 @@ Tokens are preserved intact - never split a `{b}...{/b}` pair. ### Line Editing (in search input) | Key | Action | |-----|--------| -| Ctrl-A | Move cursor to beginning of line | -| Ctrl-E | Move cursor to end of line | -| Ctrl-B | Move cursor backward one character | -| Ctrl-F | Move cursor forward one character | +| Ctrl-A / Home | Move cursor to beginning of line | +| Ctrl-E / End | Move cursor to end of line | +| Ctrl-B / Left arrow | Move cursor backward one character | +| Ctrl-F / Right arrow | Move cursor forward one character | | Backspace / Ctrl-H | Delete character before cursor | +| Delete | Delete character after cursor | | Ctrl-K | Delete from cursor to end of line | +| Ctrl-U | Delete from start of line to cursor | | Ctrl-W | Delete word before cursor (alphanumeric boundaries) | -| Any printable | Append to query, re-filter | +| Any printable | Insert at cursor, re-filter | ## Scrolling diff --git a/test/try_selector_test.rb b/test/try_selector_test.rb index a6304f8..d0b63a4 100644 --- a/test/try_selector_test.rb +++ b/test/try_selector_test.rb @@ -280,7 +280,7 @@ def test_cache_miss_on_buffer_change FileUtils.mkdir_p(File.join(@tmpdir, "mydir")) sel = build_selector first = sel.send(:get_tries) - sel.instance_variable_set(:@input_buffer, "my") + sel.instance_variable_get(:@search).text = "my" second = sel.send(:get_tries) refute_same first, second end @@ -376,7 +376,7 @@ def test_selected_entry_does_not_use_fixed_muted_foreground def test_selected_create_row_restores_foreground Tui.enable_colors! screen = Tui::Screen.new(io: StringIO.new, width: 80, height: 5) - selector.instance_variable_set(:@input_buffer, "new-entry") + selector.instance_variable_get(:@search).text = "new-entry" selector.send(:render_create_line, screen, true, 80) output = StringIO.new diff --git a/test/tui_input_field_test.rb b/test/tui_input_field_test.rb index 70cbb44..c8f58af 100644 --- a/test/tui_input_field_test.rb +++ b/test/tui_input_field_test.rb @@ -19,3 +19,100 @@ def test_placeholder_dimmed_when_colors_enabled assert_includes field.to_s, Tui::Palette::MUTED end end + +class InputFieldEditingTest < TuiTestCase + def field(text = "hello", cursor = nil) + Tui::InputField.new(placeholder: "", text: text, cursor: cursor) + end + + def test_insert_at_cursor + f = field("abc", 1) + f.insert("X") + assert_equal "aXbc", f.text + assert_equal 2, f.cursor + end + + def test_backspace + f = field("abc", 2) + f.backspace + assert_equal "ac", f.text + assert_equal 1, f.cursor + end + + def test_backspace_at_start_is_noop + f = field("abc", 0) + f.backspace + assert_equal "abc", f.text + assert_equal 0, f.cursor + end + + def test_delete_forward + f = field("abc", 1) + f.delete_forward + assert_equal "ac", f.text + assert_equal 1, f.cursor + end + + def test_kill_to_end + f = field("abcdef", 3) + f.kill_to_end + assert_equal "abc", f.text + assert_equal 3, f.cursor + end + + def test_kill_to_start + f = field("abcdef", 3) + f.kill_to_start + assert_equal "def", f.text + assert_equal 0, f.cursor + end + + def test_kill_word_stops_at_dash + f = field("hello-world") + f.kill_word + assert_equal "hello-", f.text + end + + def test_handle_key_consumes_arrows + f = field("abc", 2) + assert f.handle_key("\e[D") + assert_equal 1, f.cursor + assert f.handle_key("\e[C") + assert_equal 2, f.cursor + end + + def test_handle_key_consumes_ctrl_u + f = field("abc", 2) + assert f.handle_key("\x15") + assert_equal "c", f.text + assert_equal 0, f.cursor + end + + def test_handle_key_consumes_delete_csi + f = field("abc", 1) + assert f.handle_key("\e[3~") + assert_equal "ac", f.text + end + + def test_handle_key_rejects_selector_keys + f = field("abc") + ["\r", "\e", "\x03", "\x04", "\x07", "\x14", "\x10", "\x0E", "\e[A", "\e[B", "\t", "\x12"].each do |key| + refute f.handle_key(key), "should not consume #{key.inspect}" + end + assert_equal "abc", f.text + end + + def test_handle_key_inserts_printable + f = field("", 0) + assert f.handle_key("z") + assert_equal "z", f.text + end + + def test_to_s_still_reverse_video_cursor + enable_colors! + f = field("ab", 1) + rendered = f.to_s + assert_includes rendered, Tui::Palette::INPUT_CURSOR_ON + assert_includes rendered, "b" + end +end diff --git a/try.rb b/try.rb index 40e50f3..375d055 100755 --- a/try.rb +++ b/try.rb @@ -7,6 +7,16 @@ require_relative 'lib/tui' require_relative 'lib/fuzzy' +# Emergency restore if the process exits while the TUI alt-screen is active. +$try_tui_active = false +at_exit do + next unless $try_tui_active + begin + STDERR.print("#{Tui::ANSI::RESET}#{Tui::ANSI::CURSOR_DEFAULT}#{Tui::ANSI::SHOW}#{Tui::ANSI::ALT_SCREEN_OFF}") + rescue + end +end + class TrySelector include Tui::Helpers TRY_PATH = ENV['TRY_PATH'] || File.expand_path("~/src/tries") @@ -19,10 +29,11 @@ class TrySelector def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_render_once: false, test_no_cls: false, test_keys: nil, test_confirm: nil) @search_term = search_term.gsub(/\s+/, '-') @cursor_pos = 0 # Navigation cursor (list position) - @input_cursor_pos = 0 # Text cursor (position within search buffer) @scroll_offset = 0 - @input_buffer = initial_input ? initial_input.gsub(/\s+/, '-') : @search_term - @input_cursor_pos = @input_buffer.length # Start at end of buffer + @search = Tui::InputField.new( + placeholder: "", + text: initial_input ? initial_input.gsub(/\s+/, '-') : @search_term + ) @selected = nil @all_trials = nil # Memoized trials @base_path = base_path @@ -72,23 +83,32 @@ def run private def setup_terminal + @terminal_restored = false unless @test_no_cls # Switch to alternate screen buffer (like vim, less, etc.) STDERR.print("#{Tui::ANSI::ALT_SCREEN_ON}#{Tui::ANSI.set_title("try")}#{Tui::ANSI::CURSOR_BLINK}") + $try_tui_active = true end @old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true } if Signal.list.key?('WINCH') end def restore_terminal + return if @terminal_restored + @terminal_restored = true unless @test_no_cls STDERR.print(Tui::ANSI::RESET) STDERR.print(Tui::ANSI::CURSOR_DEFAULT) # Return to main screen buffer STDERR.print(Tui::ANSI::ALT_SCREEN_OFF) + $try_tui_active = false end Signal.trap('WINCH', @old_winch_handler) if @old_winch_handler + begin + STDIN.iflush if STDIN.respond_to?(:iflush) + rescue + end end def load_all_tries @@ -145,6 +165,15 @@ def [](key) end end + def text; data[:text]; end + def basename; data[:basename]; end + def path; data[:path]; end + def is_new; data[:is_new]; end + def is_symlink; data[:is_symlink]; end + def ctime; data[:ctime]; end + def mtime; data[:mtime]; end + def base_score; data[:base_score]; end + def method_missing(name, *) data[name] end @@ -159,15 +188,15 @@ def get_tries @fuzzy ||= Fuzzy.new(@all_tries) # Cache results - only re-match when query changes - if @last_query == @input_buffer && @cached_results + if @last_query == @search.text && @cached_results return @cached_results end - @last_query = @input_buffer + @last_query = @search.text height = IO.console&.winsize&.first || 24 max_results = [height - 6, 3].max results = [] - @fuzzy.match(@input_buffer).limit(max_results).each do |entry, positions, score| + @fuzzy.match(@search.text).limit(max_results).each do |entry, positions, score| results << TryEntry.new(entry, score, positions) end @cached_results = results @@ -176,7 +205,7 @@ def get_tries def main_loop loop do tries = get_tries - show_create_new = !@input_buffer.empty? + show_create_new = !@search.text.empty? total_items = tries.length + (show_create_new ? 1 : 0) # Ensure cursor is within bounds @@ -188,6 +217,12 @@ def main_loop # nil means terminal resize - just re-render with new dimensions next unless key + before = @search.text + if @search.handle_key(key) + @cursor_pos = 0 if @search.text != before + next + end + case key when "\r" # Enter (carriage return) if @delete_mode && !@marked_for_deletion.empty? @@ -206,35 +241,9 @@ def main_loop @cursor_pos = [@cursor_pos - 1, 0].max when "\e[B", "\x0E" # Down arrow or Ctrl-N @cursor_pos = [@cursor_pos + 1, total_items - 1].min - when "\e[C" # Right arrow - ignore - # Do nothing - when "\e[D" # Left arrow - ignore - # Do nothing - when "\x7F", "\b" # Backspace (DEL and BS) - if @input_cursor_pos > 0 - @input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..] - @input_cursor_pos -= 1 - end - @cursor_pos = 0 # Reset list selection when typing - when "\x01" # Ctrl-A - beginning of line - @input_cursor_pos = 0 - when "\x05" # Ctrl-E - end of line - @input_cursor_pos = @input_buffer.length - when "\x02" # Ctrl-B - backward char - @input_cursor_pos = [@input_cursor_pos - 1, 0].max - when "\x06" # Ctrl-F - forward char - @input_cursor_pos = [@input_cursor_pos + 1, @input_buffer.length].min - when "\x0B" # Ctrl-K - kill to end of line - @input_buffer = @input_buffer[0...@input_cursor_pos] - when "\x17" # Ctrl-W - delete word backward (alphanumeric) - if @input_cursor_pos > 0 - new_pos = word_boundary_backward(@input_buffer, @input_cursor_pos) - @input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..] - @input_cursor_pos = new_pos - end when "\x04" # Ctrl-D - toggle mark for deletion if @cursor_pos < tries.length - path = tries[@cursor_pos][:path] + path = tries[@cursor_pos].path if @marked_for_deletion.include?(path) @marked_for_deletion.delete(path) else @@ -266,13 +275,6 @@ def main_loop @selected = nil break end - when String - # Only accept printable characters, not escape sequences - if key.length == 1 && key.match?(INPUT_CHAR_RE) - @input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos..] - @input_cursor_pos += 1 - @cursor_pos = 0 # Reset list selection when typing - end end end @@ -304,10 +306,30 @@ def read_keypress if input == "\e" begin - input << STDIN.read_nonblock(3) - input << STDIN.read_nonblock(2) - rescue IO::WaitReadable, EOFError - # No more escape sequence data available + nxt = STDIN.read_nonblock(1) + input << nxt + if nxt == "[" + # CSI: consume until a final byte in 0x40-0x7E so mouse/unknown + # sequences never leak into the filter. Bare ESC still returns "\e" + # when no following byte is available (WaitReadable). + loop do + ch = STDIN.read_nonblock(1) + input << ch + code = ch.ord + break if code >= 0x40 && code <= 0x7E + end + # X10 mouse: ESC [ M + 3 payload bytes + if input == "\e[M" + begin + input << STDIN.read_nonblock(3) + rescue IO::WaitReadable, EOFError, Errno::EAGAIN, Errno::EWOULDBLOCK + end + end + elsif nxt == "O" + input << STDIN.read_nonblock(1) + end + rescue IO::WaitReadable, EOFError, Errno::EAGAIN, Errno::EWOULDBLOCK + # Standalone ESC (or incomplete sequence) — keep what we have end end @@ -336,7 +358,7 @@ def render(tries) screen.header.add_line do |line| prefix = "Search: " line.write.write_dim(prefix) - line.write << screen.input("", value: @input_buffer, cursor: @input_cursor_pos).to_s + line.write << screen.input("", value: @search.text, cursor: @search.cursor).to_s line.mark_has_input(Tui::Metrics.visible_width(prefix)) end screen.header.add_line { |line| line.write.write_dim(fill("─")) } @@ -361,7 +383,7 @@ def render(tries) header_lines = screen.header.lines.length footer_lines = screen.footer.lines.length max_visible = [height - header_lines - footer_lines, 3].max - show_create_new = !@input_buffer.empty? + show_create_new = !@search.text.empty? total_items = tries.length + (show_create_new ? 1 : 0) if @cursor_pos < @scroll_offset @@ -388,7 +410,7 @@ def render(tries) end def render_entry_line(screen, entry, is_selected, width) - is_marked = @marked_for_deletion.include?(entry[:path]) + is_marked = @marked_for_deletion.include?(entry.path) # Marked items keep the danger background; selected rows add a readable foreground. background = if is_marked Tui::Palette::DANGER_BG + (is_selected ? Tui::Palette::SELECTED_FG : "") @@ -400,7 +422,7 @@ def render_entry_line(screen, entry, is_selected, width) line.write << (is_selected ? Tui::Text.highlight("→ ") + selected_foreground : " ") icon = if is_marked emoji("🗑️") - elsif entry[:is_symlink] + elsif entry.is_symlink emoji("🔗") else emoji("📁") @@ -409,7 +431,7 @@ def render_entry_line(screen, entry, is_selected, width) plain_name, rendered_name = formatted_entry_name(entry, selected: is_selected) prefix_width = 5 - meta_text = "#{format_relative_time(entry[:mtime])}, #{format('%.1f', entry[:score])}" + meta_text = "#{format_relative_time(entry.mtime)}, #{format('%.1f', entry.score)}" # Only truncate name if it exceeds total line width (not to make room for metadata) max_name_width = width - prefix_width - 1 @@ -432,17 +454,17 @@ def render_create_line(screen, is_selected, width) line = screen.body.add_line(background: background) line.write << (is_selected ? Tui::Text.highlight("→ ") + selected_foreground : " ") date_prefix = Time.now.strftime("%Y-%m-%d") - label = if @input_buffer.empty? + label = if @search.text.empty? "📂 Create new: #{date_prefix}-" else - "📂 Create new: #{date_prefix}-#{@input_buffer}" + "📂 Create new: #{date_prefix}-#{@search.text}" end line.write << label end def formatted_entry_name(entry, selected: false) - basename = entry[:basename] - positions = entry[:highlight_positions] || [] + basename = entry.basename + positions = entry.highlight_positions || [] if basename =~ /^(\d{4}-\d{2}-\d{2})-(.+)$/ date_part = $1 @@ -495,10 +517,7 @@ def selected_foreground # Find the position of the previous word boundary for Ctrl-W deletion. # Skips non-alphanumeric chars, then skips alphanumeric chars. def word_boundary_backward(buffer, cursor) - pos = cursor - 1 - pos -= 1 while pos >= 0 && !buffer[pos].match?(WORD_CHAR_RE) - pos -= 1 while pos >= 0 && buffer[pos].match?(WORD_CHAR_RE) - pos + 1 + Tui::InputField.new(placeholder: "", text: buffer.to_s, cursor: cursor).word_boundary_backward(buffer.to_s, cursor) end def format_relative_time(time) @@ -550,18 +569,23 @@ def run_rename_dialog(entry) @delete_mode = false @marked_for_deletion.clear - current_name = entry[:basename] - rename_buffer = current_name.dup - rename_cursor = rename_buffer.length + current_name = entry.basename + input = Tui::InputField.new(placeholder: "", text: current_name.dup) rename_error = nil loop do - render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_error) + render_rename_dialog(current_name, input.text, input.cursor, rename_error) ch = read_key + next unless ch + before = input.text + if input.handle_key(ch) + rename_error = nil if input.text != before + next + end case ch when "\r" # Enter - confirm - result = finalize_rename(entry, rename_buffer) + result = finalize_rename(entry, input.text) if result == true break else @@ -569,36 +593,6 @@ def run_rename_dialog(entry) end when "\e", "\x03" # ESC or Ctrl-C - cancel break - when "\x7F", "\b" # Backspace - if rename_cursor > 0 - rename_buffer = rename_buffer[0...(rename_cursor - 1)] + rename_buffer[rename_cursor..].to_s - rename_cursor -= 1 - end - rename_error = nil - when "\x01" # Ctrl-A - start of line - rename_cursor = 0 - when "\x05" # Ctrl-E - end of line - rename_cursor = rename_buffer.length - when "\x02" # Ctrl-B - back one char - rename_cursor = [rename_cursor - 1, 0].max - when "\x06" # Ctrl-F - forward one char - rename_cursor = [rename_cursor + 1, rename_buffer.length].min - when "\x0B" # Ctrl-K - kill to end - rename_buffer = rename_buffer[0...rename_cursor] - rename_error = nil - when "\x17" # Ctrl-W - delete word backward - if rename_cursor > 0 - new_pos = word_boundary_backward(rename_buffer, rename_cursor) - rename_buffer = rename_buffer[0...new_pos] + rename_buffer[rename_cursor..].to_s - rename_cursor = new_pos - end - rename_error = nil - when String - if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.\s\/]/ - rename_buffer = rename_buffer[0...rename_cursor] + ch + rename_buffer[rename_cursor..].to_s - rename_cursor += 1 - rename_error = nil - end end end @@ -645,7 +639,7 @@ def render_rename_dialog(current_name, rename_buffer, rename_cursor, rename_erro def finalize_rename(entry, rename_buffer) new_name = rename_buffer.strip.gsub(/\s+/, '-') - old_name = entry[:basename] + old_name = entry.basename return "Name cannot be empty" if new_name.empty? return "Name cannot contain /" if new_name.include?('/') @@ -661,7 +655,7 @@ def run_ascend_dialog(entry) @delete_mode = false @marked_for_deletion.clear - current_name = entry[:basename] + current_name = entry.basename # Strip date prefix for the default project name project_name = current_name.sub(/^\d{4}-\d{2}-\d{2}-/, '') @@ -673,17 +667,22 @@ def run_ascend_dialog(entry) File.dirname(@base_path) end - ascend_buffer = File.join(projects_dir, project_name) - ascend_cursor = ascend_buffer.length + input = Tui::InputField.new(placeholder: "", text: File.join(projects_dir, project_name)) ascend_error = nil loop do - render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir) + render_ascend_dialog(current_name, input.text, input.cursor, ascend_error, projects_dir) ch = read_key + next unless ch + before = input.text + if input.handle_key(ch) + ascend_error = nil if input.text != before + next + end case ch when "\r" # Enter - confirm - result = finalize_ascend(entry, ascend_buffer) + result = finalize_ascend(entry, input.text) if result == true break else @@ -691,36 +690,6 @@ def run_ascend_dialog(entry) end when "\e", "\x03" # ESC or Ctrl-C - cancel break - when "\x7F", "\b" # Backspace - if ascend_cursor > 0 - ascend_buffer = ascend_buffer[0...(ascend_cursor - 1)] + ascend_buffer[ascend_cursor..].to_s - ascend_cursor -= 1 - end - ascend_error = nil - when "\x01" # Ctrl-A - start of line - ascend_cursor = 0 - when "\x05" # Ctrl-E - end of line - ascend_cursor = ascend_buffer.length - when "\x02" # Ctrl-B - back one char - ascend_cursor = [ascend_cursor - 1, 0].max - when "\x06" # Ctrl-F - forward one char - ascend_cursor = [ascend_cursor + 1, ascend_buffer.length].min - when "\x0B" # Ctrl-K - kill to end - ascend_buffer = ascend_buffer[0...ascend_cursor] - ascend_error = nil - when "\x17" # Ctrl-W - delete word backward - if ascend_cursor > 0 - new_pos = word_boundary_backward(ascend_buffer, ascend_cursor) - ascend_buffer = ascend_buffer[0...new_pos] + ascend_buffer[ascend_cursor..].to_s - ascend_cursor = new_pos - end - ascend_error = nil - when String - if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.\s\/~]/ - ascend_buffer = ascend_buffer[0...ascend_cursor] + ch + ascend_buffer[ascend_cursor..].to_s - ascend_cursor += 1 - ascend_error = nil - end end end @@ -784,9 +753,9 @@ def finalize_ascend(entry, ascend_buffer) @selected = { type: :ascend, - source: entry[:path], + source: entry.path, dest: dest, - basename: entry[:basename], + basename: entry.basename, base_path: @base_path } true @@ -794,7 +763,7 @@ def finalize_ascend(entry, ascend_buffer) def handle_selection(try_dir) # Select existing try directory - @selected = { type: :cd, path: try_dir[:path] } + @selected = { type: :cd, path: try_dir.path } end def handle_create_new @@ -802,8 +771,8 @@ def handle_create_new date_prefix = Time.now.strftime("%Y-%m-%d") # If user already typed a name, use it directly - if !@input_buffer.empty? - final_name = "#{date_prefix}-#{@input_buffer}".gsub(/\s+/, '-') + if !@search.text.empty? + final_name = "#{date_prefix}-#{@search.text}".gsub(/\s+/, '-') full_path = File.join(@base_path, final_name) @selected = { type: :mkdir, path: full_path } else @@ -836,21 +805,19 @@ def handle_create_new def confirm_batch_delete(tries) # Find marked items with their info - marked_items = tries.select { |t| @marked_for_deletion.include?(t[:path]) } + marked_items = tries.select { |t| @marked_for_deletion.include?(t.path) } return if marked_items.empty? - confirmation_buffer = "" - confirmation_cursor = 0 + input = Tui::InputField.new(placeholder: "", text: "") # Handle test mode if @test_keys && !@test_keys.empty? while @test_keys && !@test_keys.empty? ch = @test_keys.shift break if ch == "\r" || ch == "\n" - confirmation_buffer << ch - confirmation_cursor = confirmation_buffer.length + input.handle_key(ch) end - process_delete_confirmation(marked_items, confirmation_buffer) + process_delete_confirmation(marked_items, input.text) return elsif @test_confirm || !STDERR.tty? confirmation_buffer = (@test_confirm || STDIN.gets)&.chomp.to_s @@ -862,33 +829,22 @@ def confirm_batch_delete(tries) # Clear screen once before dialog to ensure clean slate clear_screen unless @test_no_cls loop do - render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor) + render_delete_dialog(marked_items, input.text, input.cursor) ch = read_key + next unless ch + if input.handle_key(ch) + next + end case ch when "\r" # Enter - confirm - process_delete_confirmation(marked_items, confirmation_buffer) + process_delete_confirmation(marked_items, input.text) break - when "\e" # Escape - cancel + when "\e", "\x03" # Escape or Ctrl-C - cancel @delete_status = "Delete cancelled" @marked_for_deletion.clear @delete_mode = false break - when "\x7F", "\b" # Backspace - if confirmation_cursor > 0 - confirmation_buffer = confirmation_buffer[0...confirmation_cursor-1] + confirmation_buffer[confirmation_cursor..] - confirmation_cursor -= 1 - end - when "\x03" # Ctrl-C - @delete_status = "Delete cancelled" - @marked_for_deletion.clear - @delete_mode = false - break - when String - if ch.length == 1 && ch.ord >= 32 - confirmation_buffer = confirmation_buffer[0...confirmation_cursor] + ch + confirmation_buffer[confirmation_cursor..] - confirmation_cursor += 1 - end end end @@ -906,7 +862,7 @@ def render_delete_dialog(marked_items, confirmation_buffer, confirmation_cursor) marked_items.each do |item| screen.body.add_line(background: Tui::Palette::DANGER_BG) do |line| - line.write << emoji("🗑️") << " #{item[:basename]}" + line.write << emoji("🗑️") << " #{item.basename}" end end @@ -939,11 +895,11 @@ def process_delete_confirmation(marked_items, confirmation) # Validate all paths first validated_paths = [] marked_items.each do |item| - target_real = File.realpath(item[:path]) + target_real = File.realpath(item.path) unless target_real.start_with?(base_real + "/") raise "Safety check failed: #{target_real} is not inside #{base_real}" end - validated_paths << { path: target_real, basename: item[:basename] } + validated_paths << { path: target_real, basename: item.basename } end # Return delete action with all paths @@ -1164,7 +1120,9 @@ def parse_test_keys(spec) when 'CTRL-P', 'CTRLP' then keys << "\x10" when 'CTRL-R', 'CTRLR' then keys << "\x12" when 'CTRL-T', 'CTRLT' then keys << "\x14" + when 'CTRL-U', 'CTRLU' then keys << "\x15" when 'CTRL-W', 'CTRLW' then keys << "\x17" + when 'DELETE' then keys << "\e[3~" when /^TYPE=/i tok.sub(/^TYPE=/i, '').each_char { |ch| keys << ch } else