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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.callgrind*
*.gem
.claude/settings.local.json
dist/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

## Testing Guidelines
- Primary testing is via the spec system: `./spec/tests/runner.sh ./try.rb`
- Native binary (optional, Spinel): `make native-test` / `bash spec/tests/runner.sh dist/try`
- Manual flows for exploratory testing:
- `TRY_PATH=$(mktemp -d) ./try.rb cd` then create/select directories.
- Validate delete confirmation and scoring by changing `mtime`/`ctime`.
Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,18 @@ t: test ## Shortcut for test
l: lint ## Shortcut for lint

.PHONY: i
i: install ## Shortcut for install
i: install ## Shortcut for install

# Native binary via Spinel (optional)
SPINEL ?= spinel
NATIVE = dist/try

.PHONY: native native-test
native: $(NATIVE)

$(NATIVE): try.rb lib/tui.rb lib/fuzzy.rb
mkdir -p dist
$(SPINEL) try.rb -o $(NATIVE)

native-test: $(NATIVE)
bash spec/tests/runner.sh $(NATIVE)
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ echo 'eval "$(ruby ~/.local/try.rb init ~/src/tries)"' >> ~/.zshrc
echo '~/.local/try.rb init ~/src/tries | source' >> ~/.config/fish/config.fish
```


### Native binary (optional)

Compile a native `try` with [Spinel](https://github.com/matz/spinel). Build Spinel from source; [PR 3906](https://github.com/matz/spinel/pull/3906) is required so `IO#tty?` / `#winsize` work on handles that are not statically typed IO.

```bash
make native SPINEL=/path/to/spinel
./dist/try --help
eval "$(./dist/try init)" # wires the shell function to the binary, not MRI
make native-test SPINEL=/path/to/spinel
```

MRI `ruby try.rb` and the gem keep working. `dist/try init` emits the binary path; `ruby try.rb init` still emits `/usr/bin/env ruby '…/try.rb'`.

## The Problem

You're learning Redis. You create `/tmp/redis-test`. Then `~/Desktop/redis-actually`. Then `~/projects/testing-redis-again`. Three weeks later you can't find that brilliant connection pooling solution you wrote at 2am.
Expand Down
25 changes: 19 additions & 6 deletions lib/fuzzy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def limit(n)

# Iterate over matches: yields (entry_data, highlight_positions, score)
def each(&block)
return enum_for(:each) unless block_given?
return enum_for(:each) unless block

results = []

Expand All @@ -68,11 +68,16 @@ def each(&block)
results << [entry.data, positions, score]
end

# Spinel has no Array#max_by(n); full sort is fine at try-directory scale.
results.sort_by! { |_, _, score| -score }
if @limit && @limit < results.length
# Partial sort: O(n log k) via heap selection instead of full O(n log n) sort
results = results.max_by(@limit) { |_, _, score| score }
else
results.sort_by! { |_, _, score| -score }
limited = []
i = 0
while i < @limit
limited << results[i]
i += 1
end
results = limited
end

results.each(&block)
Expand Down Expand Up @@ -102,7 +107,15 @@ def calculate_match(entry)

query_chars.each do |qc|
# Find next occurrence of query char starting from pos
found = text.index(qc, pos)
found = nil
i = pos
while i < text.length
if text[i] == qc
found = i
break
end
i += 1
end
return nil unless found # No match

positions << found
Expand Down
74 changes: 42 additions & 32 deletions lib/tui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
# Usage pattern:
# include Tui::Helpers
# screen = Tui::Screen.new
# screen.header.add_line { |line| line.write << Tui::Text.bold("📁 Try Selector") }
# line = screen.header.add_line
# line.write << Tui::Text.bold("📁 Try Selector")
# search_line = screen.body.add_line
# search_line.write_dim("Search:").write(" ")
# search_line.write << screen.input("Type to filter…", value: query, cursor: cursor)
# list_line = screen.body.add_line(background: Tui::Palette::SELECTED_BG)
# list_line.write << Tui::Text.highlight("→ ") << name
# list_line.right.write_dim(metadata)
# screen.footer.add_line { |line| line.write_dim("↑↓ navigate Enter select Esc cancel") }
# line = screen.footer.add_line
# line.write_dim("↑↓ navigate Enter select Esc cancel")
# screen.flush
#
# The screen owns a single InputField (enforced by #input). Lines support
Expand Down Expand Up @@ -124,8 +126,8 @@ def visible_width(text)

# Slow path: calculate width per codepoint (avoids each_char + ord)
width = 0
stripped.each_codepoint do |code|
width += char_width(code)
stripped.each_char do |ch|
width += char_width(ch.ord)
end
width
end
Expand Down Expand Up @@ -322,24 +324,17 @@ def size(io = $stderr)

begin
s_rows, s_cols = stream.winsize
rows ||= s_rows
cols ||= s_cols
# Spinel returns [0,0] for non-ttys (no exception). 0 is truthy in
# Ruby, so treat non-positive sizes as missing.
rows ||= s_rows if s_rows.to_i > 0
cols ||= s_cols if s_cols.to_i > 0
rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV
next
end
end

if (!rows || !cols)
begin
console = IO.console
if console
c_rows, c_cols = console.winsize
rows ||= c_rows
cols ||= c_cols
end
rescue IOError, Errno::ENOTTY, Errno::EOPNOTSUPP, Errno::ENODEV
end
end
# IO.console is not available under Spinel; STDERR/STDIN#winsize
# (io/console) already covers the TTY case above.

rows ||= 24
cols ||= 80
Expand Down Expand Up @@ -400,7 +395,7 @@ def flush
cursor_row = current_row + 1
cursor_col = line.cursor_column(@input_field, @width)
end
line.render(buf, @width)
buf << line.render(nil, @width)
current_row += 1
end

Expand All @@ -416,7 +411,7 @@ def flush
cursor_row = current_row + 1
cursor_col = line.cursor_column(@input_field, @width)
end
line.render(buf, @width)
buf << line.render(nil, @width)
current_row += 1
body_rendered += 1
end
Expand All @@ -426,14 +421,16 @@ def flush
gap = body_space - body_rendered
blank_line = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}\n"
blank_line_no_newline = "\r#{ANSI::CLEAR_EOL}#{' ' * (@width - 1)}"
gap.times do |i|
i = 0
while i < gap
# Last gap line without newline if no footer follows
if i == gap - 1 && @footer.lines.empty?
buf << blank_line_no_newline
else
buf << blank_line
end
current_row += 1
i += 1
end

# Render footer at the bottom (sticky)
Expand All @@ -444,9 +441,9 @@ def flush
end
# Last line: don't write \n to avoid scrolling
if idx == footer_lines - 1
line.render_no_newline(buf, @width)
buf << line.render_no_newline(nil, @width)
else
line.render(buf, @width)
buf << line.render(nil, @width)
end
current_row += 1
end
Expand Down Expand Up @@ -480,7 +477,7 @@ def initialize(screen)
@lines = []
end

def add_line(background: nil, truncate: true)
def add_line(background = nil, truncate = true)
line = Line.new(@screen, background: background, truncate: truncate)
@lines << line
yield line if block_given?
Expand All @@ -490,7 +487,7 @@ def add_line(background: nil, truncate: true)
def divider(char: '─')
add_line do |line|
span = [@screen.width - 1, 1].max
line.write << char * span
line.write.write(char * span)
end
end

Expand Down Expand Up @@ -632,7 +629,8 @@ def render_line(io, width, trailing_newline:)
buffer << ANSI::RESET
buffer << "\n" if trailing_newline

io << buffer
io << buffer if io
buffer
end
end

Expand All @@ -642,13 +640,13 @@ class SegmentWriter
class FillSegment
attr_reader :char, :style

def initialize(char, style: nil)
def initialize(char, style = nil)
@char = char.to_s
@style = style
end

def with_style(style)
self.class.new(char, style: style)
FillSegment.new(char, style)
end
end

Expand All @@ -661,8 +659,8 @@ def initialize(char)
# Precompute: emoji = 2, variation selectors = 0
@width = 0
@char_count = 0
@char.each_codepoint do |code|
w = Metrics.char_width(code)
@char.each_char do |ch|
w = Metrics.char_width(ch.ord)
@width += w
@char_count += 1 if w > 0 # Don't count zero-width chars
end
Expand Down Expand Up @@ -714,15 +712,27 @@ def has_wide?
alias << write

def write_dim(text)
write(style_segment(text, :dim) { |value| dim(value) })
if text.is_a?(FillSegment)
write(text.with_style(:dim))
else
write(dim(text))
end
end

def write_bold(text)
write(style_segment(text, :bold) { |value| bold(value) })
if text.is_a?(FillSegment)
write(text.with_style(:bold))
else
write(bold(text))
end
end

def write_highlight(text)
write(style_segment(text, :highlight) { |value| highlight(value) })
if text.is_a?(FillSegment)
write(text.with_style(:highlight))
else
write(highlight(text))
end
end

def to_s(width: nil)
Expand Down
Loading