Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
.env
__pycache__
.keystroke

**/.claude/settings.local.json
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ The `find_matches()` function was refactored to support multi-character patterns
All options are read from tmux options in easymotion.tmux and passed as environment variables to the Python script:

- `@easymotion-key`: Trigger key binding for 1-char search (default: 's', backward compatible)
- `@easymotion-s`: Explicit 1-char search key binding (optional)
- `@easymotion-s`: Explicit 1-char search binding (optional)
- `@easymotion-s2`: 2-char search key binding (optional, e.g., 'f')
- `@easymotion-hints`: Characters used for hints (default: 'asdghklqwertyuiopzxcvbnmfj;')
- `@easymotion-vertical-border`: Character for vertical borders (default: '│')
Expand Down
83 changes: 77 additions & 6 deletions easymotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import functools
import logging
import os
import re
import subprocess
import sys
import termios
Expand Down Expand Up @@ -52,6 +53,52 @@
'TMUX_EASYMOTION_USE_CURSES', 'false').lower() == 'true'


def get_tmux_version():
"""Detect tmux version for compatibility adjustments

Returns:
tuple: (major, minor) version numbers, e.g., (3, 6) for "tmux 3.6"
Returns (0, 0) if detection fails or version is ambiguous

Handles various version formats:
- "tmux 3.5" → (3, 5)
- "tmux 3.0a" → (3, 0)
- "tmux next-3.6" → (3, 6)
- "tmux 3.1-rc2" → (3, 1)
- "tmux master" → (0, 0) - assume latest features, use conservative defaults
- "tmux openbsd-6.6" → (0, 0) - OpenBSD version, not tmux version
"""
try:
result = subprocess.run(
['tmux', '-V'],
capture_output=True,
text=True,
check=True
)
version_str = result.stdout.strip()

# Skip OpenBSD-specific versioning (not actual tmux version)
if 'openbsd-' in version_str:
return (0, 0)

# Handle "tmux master" - development version, use conservative defaults
if 'master' in version_str:
return (0, 0)

# Extract version: matches "3.6", "next-3.6", "3.0a", "3.1-rc2"
# Pattern: find digits.digits anywhere in string, but avoid matching openbsd versions
match = re.search(r'(?:next-)?(\d+)\.(\d+)', version_str)
if match:
return (int(match.group(1)), int(match.group(2)))
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
pass
return (0, 0) # Default to oldest behavior if detection fails


# Detect tmux version at module load time
TMUX_VERSION = get_tmux_version()


class Screen(ABC):
# Common attributes for both implementations
A_NORMAL = 0
Expand Down Expand Up @@ -215,24 +262,48 @@ def wrapper(*args, **kwargs):
return decorator


def calculate_tab_width(position: int, tab_size: int = 8) -> int:
"""Calculate the visual width of a tab based on its position"""
return tab_size - (position % tab_size)

@functools.lru_cache(maxsize=1024)
def get_char_width(char: str) -> int:
"""Get visual width of a single character with caching"""
def get_char_width(char: str, position: int = 0) -> int:
"""Get visual width of a single character with caching

Args:
char: The character to measure
position: The visual position of the character (needed for tabs)
Comment on lines +271 to +275
Copy link

Copilot AI May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] It may be beneficial to update the docstring of get_char_width to explicitly mention how the position parameter affects tab width calculation and caching behavior.

Suggested change
"""Get visual width of a single character with caching
Args:
char: The character to measure
position: The visual position of the character (needed for tabs)
"""Get visual width of a single character with caching.
This function calculates the visual width of a character, taking into account
special cases like tab characters and wide East Asian characters. The result
is cached to improve performance for repeated calls with the same arguments.
Args:
char: The character to measure.
position: The visual position of the character in the line. This is used
to calculate the width of tab characters (`\t`) based on their position,
as tab width depends on alignment. Different `position` values will
result in different cache keys, which can affect performance and memory
usage.
Returns:
The visual width of the character.

Copilot uses AI. Check for mistakes.

Note:
Tab handling differs by tmux version:
- tmux >= 3.6: Position-aware tabs (tab stops at multiples of 8)
- tmux < 3.6: Fixed-width tabs (always 8 spaces)
"""
if char == '\t':
# tmux 3.6+ uses position-aware tab rendering (correct terminal behavior)
# Older versions render tabs as fixed 8-space width
if TMUX_VERSION >= (3, 6):
return calculate_tab_width(position)
else:
return 8 # Fixed width for older tmux versions
return 2 if unicodedata.east_asian_width(char) in 'WF' else 1


@functools.lru_cache(maxsize=1024)
def get_string_width(s: str) -> int:
"""Calculate visual width of string, accounting for double-width characters"""
return sum(map(get_char_width, s))
"""Calculate visual width of string, accounting for double-width characters and tabs"""
visual_pos = 0
for char in s:
visual_pos += get_char_width(char, visual_pos)
return visual_pos


def get_true_position(line, target_col):
"""Calculate true position accounting for wide characters"""
"""Calculate true position accounting for wide characters and tabs"""
visual_pos = 0
true_pos = 0
while true_pos < len(line) and visual_pos < target_col:
char_width = get_char_width(line[true_pos])
char_width = get_char_width(line[true_pos], visual_pos)
visual_pos += char_width
true_pos += 1
return true_pos
Expand Down
163 changes: 163 additions & 0 deletions test_easymotion.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,75 @@


def test_get_char_width():
import easymotion

assert get_char_width('a') == 1 # ASCII character
assert get_char_width('あ') == 2 # Japanese character (wide)
assert get_char_width('漢') == 2 # Chinese character (wide)
assert get_char_width('한') == 2 # Korean character (wide)
assert get_char_width(' ') == 1 # Space
assert get_char_width('\n') == 1 # Newline

# Tab behavior is version-dependent
assert get_char_width('\t', 0) == 8 # Tab at position 0 (always 8)

if easymotion.TMUX_VERSION >= (3, 6):
# tmux 3.6+: position-aware tabs
assert get_char_width('\t', 1) == 7 # Tab at position 1
assert get_char_width('\t', 7) == 1 # Tab at position 7
else:
# Older tmux: fixed-width tabs
assert get_char_width('\t', 1) == 8 # Tab at position 1
assert get_char_width('\t', 7) == 8 # Tab at position 7


def test_get_string_width():
import easymotion

assert get_string_width('hello') == 5
assert get_string_width('こんにちは') == 10
assert get_string_width('hello こんにちは') == 16
assert get_string_width('') == 0

# Tab behavior is version-dependent
assert get_string_width('\t') == 8 # Tab at position 0 = 8 spaces (always)

if easymotion.TMUX_VERSION >= (3, 6):
# tmux 3.6+: position-aware tabs
assert get_string_width('a\t') == 8 # 'a' (1) + Tab at position 1 (7) = 8
assert get_string_width('1234567\t') == 8 # 7 chars + Tab at position 7 (1) = 8
assert get_string_width('a\tb\t') == 16 # 'a' (1) + Tab at pos 1 (7) + 'b' (1) + Tab at pos 9→1 (7) = 16
else:
# Older tmux: fixed-width tabs
assert get_string_width('a\t') == 9 # 'a' (1) + Tab (8) = 9
assert get_string_width('1234567\t') == 15 # 7 chars + Tab (8) = 15
assert get_string_width('a\tb\t') == 18 # 'a' (1) + Tab (8) + 'b' (1) + Tab (8) = 18


def test_get_true_position():
import easymotion

assert get_true_position('hello', 3) == 3
assert get_true_position('あいうえお', 4) == 2
assert get_true_position('hello あいうえお', 7) == 7
assert get_true_position('', 5) == 0

# Tab behavior is version-dependent
if easymotion.TMUX_VERSION >= (3, 6):
# tmux 3.6+: position-aware tabs
assert get_true_position('\t', 4) == 1 # Halfway through tab
assert get_true_position('\t', 8) == 1 # Full tab width
assert get_true_position('a\tb', 1) == 1 # 'a' ends at visual pos 1
assert get_true_position('a\tb', 5) == 2 # Halfway through tab
assert get_true_position('a\tb', 8) == 2 # 'b' starts at visual pos 8, string index 2
else:
# Older tmux: fixed-width tabs
assert get_true_position('\t', 4) == 1 # Halfway through tab
assert get_true_position('\t', 8) == 1 # Full tab width
assert get_true_position('a\tb', 1) == 1 # 'a'
assert get_true_position('a\tb', 5) == 2 # After 'a', halfway through tab
assert get_true_position('a\tb', 9) == 3 # 'b' (at position 9)


def test_generate_hints():
test_keys = 'ab'
Expand Down Expand Up @@ -884,3 +932,118 @@ def test_hint_restoration_not_at_line_end():
# Should restore the actual next character 'l'
assert len(calls_at_next_pos) == 1
assert calls_at_next_pos[0]['text'] == 'l'


# ============================================================================
# Tests for Tmux Version Detection and Tab Handling
# ============================================================================

def test_tmux_version_detection():
"""Test that tmux version can be detected"""
import easymotion

# Version should be detected and be a tuple
assert isinstance(easymotion.TMUX_VERSION, tuple)
assert len(easymotion.TMUX_VERSION) == 2

# Version numbers should be non-negative
major, minor = easymotion.TMUX_VERSION
assert major >= 0
assert minor >= 0


def test_version_conditional_tab_behavior():
"""Test that tab behavior is conditional on tmux version"""
import easymotion

major, minor = easymotion.TMUX_VERSION

# Test tab at position 0 (always 8)
assert get_char_width('\t', 0) == 8

# Test tab at position 1 (version-dependent)
if (major, minor) >= (3, 6):
# tmux 3.6+: position-aware tabs
assert get_char_width('\t', 1) == 7 # Position-aware
assert get_char_width('\t', 7) == 1 # Position-aware
else:
# Older tmux: fixed-width tabs
assert get_char_width('\t', 1) == 8 # Fixed width
assert get_char_width('\t', 7) == 8 # Fixed width


def test_tab_in_string_width_version_aware():
"""Test that string width calculation respects version-conditional tab behavior"""
import easymotion

major, minor = easymotion.TMUX_VERSION

if (major, minor) >= (3, 6):
# tmux 3.6+: position-aware tabs
assert get_string_width('a\t') == 8 # 'a' (1) + Tab at position 1 (7)
assert get_string_width('1234567\t') == 8 # 7 chars + Tab at position 7 (1)
else:
# Older tmux: fixed-width tabs
assert get_string_width('a\t') == 9 # 'a' (1) + Tab (8)
assert get_string_width('1234567\t') == 15 # 7 chars + Tab (8)


def test_get_tmux_version_function():
"""Test the get_tmux_version() function directly"""
from easymotion import get_tmux_version

version = get_tmux_version()

# Should return a tuple of two integers
assert isinstance(version, tuple)
assert len(version) == 2
assert isinstance(version[0], int)
assert isinstance(version[1], int)

# If we're running tests, tmux should be available
# Version should be at least (0, 0) but likely higher
assert version >= (0, 0)

# If version was detected (not fallback), it should be reasonable
if version != (0, 0):
major, minor = version
# Tmux versions are typically 1.x to 3.x
assert 1 <= major <= 5 # Reasonable range for tmux major versions
assert 0 <= minor <= 20 # Reasonable range for minor versions


def test_version_string_parsing():
"""Test parsing various tmux version string formats"""
import subprocess
import re

# Mock different version strings and test parsing logic
test_cases = [
("tmux 3.5", (3, 5)),
("tmux 3.0a", (3, 0)),
("tmux 2.9a", (2, 9)),
("tmux next-3.6", (3, 6)),
("tmux next-3.7", (3, 7)),
("tmux 3.1-rc", (3, 1)),
("tmux 3.1-rc2", (3, 1)),
("tmux 2.6", (2, 6)),
("tmux 3.1c", (3, 1)),
("tmux master", (0, 0)), # Conservative default
("tmux openbsd-6.6", (0, 0)), # OpenBSD version, not tmux
("tmux openbsd-7.0", (0, 0)),
]

for version_str, expected in test_cases:
# Replicate the parsing logic from get_tmux_version()
if 'openbsd-' in version_str:
result = (0, 0)
elif 'master' in version_str:
result = (0, 0)
else:
match = re.search(r'(?:next-)?(\d+)\.(\d+)', version_str)
if match:
result = (int(match.group(1)), int(match.group(2)))
else:
result = (0, 0)

assert result == expected, f"Failed to parse '{version_str}': got {result}, expected {expected}"
Loading