Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 162 additions & 4 deletions lib/tui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
18 changes: 18 additions & 0 deletions spec/tests/test_08_keyboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 17 additions & 10 deletions spec/tests/test_19_input_field.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions spec/tui_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions test/try_selector_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions test/tui_input_field_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading