diff --git a/.flake8 b/.flake8 index 346d71bd..d824a046 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -max-line-length = 88 +max-line-length = 120 extend-ignore = E203, W503, E402, E722, F401, F811 exclude = .git, diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bae5af4d..a9aa1586 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,12 +13,12 @@ repos: hooks: - id: black language_version: python3 + args: [--line-length=120] - repo: https://github.com/pycqa/flake8 rev: 6.0.0 hooks: - id: flake8 - args: [--max-line-length=88, --extend-ignore=E203,W503] - repo: local hooks: diff --git a/Agents.md b/Agents.md new file mode 100644 index 00000000..66b366bb --- /dev/null +++ b/Agents.md @@ -0,0 +1,7 @@ + +We love python fire for commands + +Never replace a command that is a binnary like entries_editor edit key for its python prefixed version like python -m pythonsearch.entries_editor edit key + + +We highly prefer to use python fire to otehr ways of interacting with the program in teh cli. Always check if there is a binary to interact with th program already if so use it. diff --git a/docs/adaptive_window_sizing.md b/docs/adaptive_window_sizing.md new file mode 100644 index 00000000..eddca643 --- /dev/null +++ b/docs/adaptive_window_sizing.md @@ -0,0 +1,165 @@ +# Adaptive Window Sizing + +PythonSearch now supports adaptive window sizing that automatically adjusts the terminal window size based on your display characteristics. This ensures the search interface looks consistent and appropriately sized across different monitors and resolutions. + +## Features + +### Automatic Display Detection +- **macOS**: Uses `osascript` and `system_profiler` to detect display resolution and characteristics +- **Linux**: Uses `xrandr` (X11) or `wlr-randr` (Wayland) to get display information +- **Cross-platform**: Falls back to sensible defaults on unsupported platforms + +### Adaptive Sizing Algorithm +The adaptive sizing algorithm considers: +- Display resolution (width × height) +- DPI (dots per inch) when available +- Display scale factor for high-DPI displays +- Maintains reasonable bounds to ensure usability + +### Display Categories +Windows are automatically sized based on display categories: +- **Small displays** (≤1366px width): Laptops, small monitors +- **Standard HD** (≤1920px width): Most desktop monitors +- **QHD displays** (≤2560px width): High-resolution monitors +- **4K+ displays** (>2560px width): Ultra-high resolution displays + +## Configuration Options + +### 1. Automatic Adaptive Sizing (Default) +```python +from python_search.configuration.configuration import PythonSearchConfiguration + +config = PythonSearchConfiguration( + adaptive_window_sizing=True # This is the default +) +``` + +### 2. Preset Sizes +Choose from predefined size categories: +```python +config = PythonSearchConfiguration( + adaptive_window_sizing=True, + window_size_preset="medium" # Options: "small", "medium", "large" +) +``` + +### 3. Custom Fixed Size +Override adaptive sizing with a fixed size: +```python +config = PythonSearchConfiguration( + custom_window_size=(100, 15) # (width_chars, height_chars) +) +``` + +### 4. Disable Adaptive Sizing +```python +config = PythonSearchConfiguration( + adaptive_window_sizing=False +) +# This will use the default size (86c × 10c) +``` + +## Environment Variable Overrides + +You can override window sizing and display detection at runtime using environment variables: + +### Custom Window Size +```bash +export PYTHON_SEARCH_WINDOW_WIDTH='120c' +export PYTHON_SEARCH_WINDOW_HEIGHT='15c' +python_search +``` + +### Preset Size +```bash +export PYTHON_SEARCH_WINDOW_PRESET='large' +python_search +``` + +### Display Detection Override +If automatic display detection fails or you want to override it: +```bash +export DISPLAY_WIDTH='2560' +export DISPLAY_HEIGHT='1440' +python_search +``` + +### Combined Example +```bash +# Force specific display size and use large preset +export DISPLAY_WIDTH='3840' +export DISPLAY_HEIGHT='2160' +export PYTHON_SEARCH_WINDOW_PRESET='large' +python_search +``` + +## Testing Display Detection + +Use the test script to check how your display is detected: + +```bash +cd python_search/host_system +python test_display_detection.py +``` + +This will show: +- Your display resolution and characteristics +- Calculated adaptive window sizes +- Available preset sizes for your display +- Environment variable examples + +## Example Output + +``` +=== Display Detection Test === +Display Resolution: 2560x1440 +DPI: 109.0 +Scale Factor: 1.0 + +=== Adaptive Window Sizing Test === +Adaptive Window Size: 103c x 12c +Small Adaptive Size: 72c x 10c +Large Adaptive Size: 144c x 18c + +=== Preset Sizes === +Small: 80c x 10c +Medium: 110c x 13c +Large: 150c x 18c +``` + +## Troubleshooting + +### Display Detection Issues +If display detection fails, the system will: +1. Log a warning message +2. Fall back to default dimensions (1920×1080) +3. Use standard preset sizes + +### Platform-Specific Notes + +#### macOS +- Requires `osascript` for resolution detection +- May need accessibility permissions for some features +- Works best on macOS 10.14+ + +#### Linux +- Requires `xrandr` for X11 systems +- Requires `wlr-randr` for Wayland systems +- Falls back to environment variables if tools unavailable + +#### Windows +- Currently uses fallback values +- Future versions may add Windows-specific detection + +### Performance +- Display detection runs once per application launch +- Results are cached for the session +- Minimal performance impact on startup + +## Migration from Fixed Sizing + +If you were previously using custom window sizes, your configuration will continue to work unchanged. The adaptive sizing only activates when: +1. No `custom_window_size` is specified, AND +2. `adaptive_window_sizing` is True (default) + +To migrate to adaptive sizing, simply remove the `custom_window_size` parameter from your configuration. diff --git a/fix_flake8.py b/fix_flake8.py deleted file mode 100644 index f9c866bc..00000000 --- a/fix_flake8.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick script to fix common flake8 issues -""" - -import re -import os -import subprocess - -def fix_file(filepath): - """Fix common flake8 issues in a file""" - with open(filepath, 'r') as f: - content = f.read() - - original_content = content - - # Fix E231: missing whitespace after ':' - # But be careful not to break URLs or other legitimate uses - lines = content.split('\n') - fixed_lines = [] - - for line in lines: - # Skip URLs and f-strings with URLs - if 'http://' in line or 'https://' in line: - fixed_lines.append(line) - continue - - # Fix dictionary/list syntax issues - line = re.sub(r':([^\s=])', r': \1', line) - fixed_lines.append(line) - - content = '\n'.join(fixed_lines) - - # Only write if content changed - if content != original_content: - with open(filepath, 'w') as f: - f.write(content) - print(f"Fixed {filepath}") - -# Get list of Python files with issues -result = subprocess.run(['flake8', '--select=E231', '.'], - capture_output=True, text=True, cwd='/Users/jean.machado@getyourguide.com/prj/PythonSearch') - -if result.stdout: - files_to_fix = set() - for line in result.stdout.strip().split('\n'): - if ':' in line: - filepath = line.split(':')[0] - files_to_fix.add(filepath) - - for filepath in files_to_fix: - if os.path.exists(filepath): - fix_file(filepath) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2802be77..97bc938a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ include = ["python_search"] # package names should match these glob patterns ([ exclude = [] # exclude packages matching these glob patterns (empty by default) namespaces = false # to disable scanning PEP 420 namespaces (true by default) +[tool.black] +line-length = 120 + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -65,3 +68,4 @@ run_key = 'python_search.entry_runner:main' term_ui = 'python_search.search.search_ui.terminal_ui:main' register_new_launch_ui = 'python_search.entry_capture.entry_inserter_gui.register_new_gui:launch_ui' google_it = 'python_search.apps.google_it:main' +share_entry = 'python_search.share_entry:main' diff --git a/python_search/configuration/configuration.py b/python_search/configuration/configuration.py index 9e9d299a..2f2a5623 100644 --- a/python_search/configuration/configuration.py +++ b/python_search/configuration/configuration.py @@ -8,7 +8,8 @@ class PythonSearchConfiguration(EntriesGroup): """ The main configuration of Python Search - Everything to customize about the application is configurable via code through this class + Everything to customize about the application is configurable via code + through this class """ APPLICATION_TITLE = "PythonSearchWindow" @@ -34,6 +35,8 @@ def __init__( tags_dependent_inserter_marks: Optional[dict[str, Tuple[str, str]]] = None, default_text_editor: Optional[str] = None, custom_window_size: Optional[Tuple[int, int]] = None, + adaptive_window_sizing: bool = True, + window_size_preset: Optional[Literal["small", "medium", "large"]] = None, collect_data: bool = False, entry_generation=False, privacy_sensitive_terms: Optional[List[str]] = None, @@ -45,9 +48,15 @@ def __init__( :param default_tags: :param tags_dependent_inserter_marks: :param default_text_editor: - :param custom_window_size: the size of the fzf window + :param custom_window_size: the size of the fzf window (overrides + adaptive sizing) + :param adaptive_window_sizing: if True, automatically adjust window + size based on display + :param window_size_preset: preset size category ("small", "medium", + "large") when using adaptive sizing :param use_webservice: if True, the ranking will be generated via a webservice - :param collect_data: if True, we will collect data about the entries you run in your machine + :param collect_data: if True, we will collect data about the entries + you run in your machine """ if entries: self.commands = entries @@ -67,6 +76,9 @@ def __init__( if custom_window_size: self._custom_window_size = custom_window_size + self.adaptive_window_sizing = adaptive_window_sizing + self.window_size_preset = window_size_preset + self.collect_data = collect_data self.entry_generation = entry_generation self.privacy_sensitive_terms = privacy_sensitive_terms @@ -97,3 +109,16 @@ def get_fzf_theme(self) -> str: def get_window_size(self): if hasattr(self, "_custom_window_size"): return self._custom_window_size + + # Return None to indicate adaptive sizing should be used + return None + + def should_use_adaptive_sizing(self) -> bool: + """Check if adaptive window sizing should be used""" + return getattr(self, "adaptive_window_sizing", True) and not hasattr( + self, "_custom_window_size" + ) + + def get_window_size_preset(self) -> Optional[str]: + """Get the window size preset if specified""" + return getattr(self, "window_size_preset", None) diff --git a/python_search/entry_capture/entries_editor.py b/python_search/entry_capture/entries_editor.py index 663d0cec..8a8e9736 100644 --- a/python_search/entry_capture/entries_editor.py +++ b/python_search/entry_capture/entries_editor.py @@ -13,8 +13,6 @@ class EntriesEditor: Open an ide to edit the entries """ - ACK_PATH = "/opt/homebrew/bin/ack" - def __init__(self, configuration=None): if not configuration: from python_search.configuration.loader import ConfigurationLoader @@ -22,6 +20,22 @@ def __init__(self, configuration=None): configuration = ConfigurationLoader().load_config() self.configuration = configuration + # Ensure ripgrep is available for file searching + self._search_cmd = self._get_search_command() + + def _get_search_command(self) -> str: + """ + Ensure ripgrep is available for file searching. + """ + return "/opt/homebrew/bin/rg" + + def _build_search_command(self, key: str) -> str: + """ + Build the ripgrep search command. + """ + project_root = self.configuration.get_project_root() + return f"/opt/homebrew/bin/rg -n -i --type py '{key}' {project_root} || true" + def edit_key(self, key_expr: str): """ Edits the configuration files by searching the text @@ -39,10 +53,7 @@ def edit_key(self, key_expr: str): return # needs to be case-insensitive search - cmd = ( - f"{EntriesEditor.ACK_PATH} -i '{key}' " - f"{self.configuration.get_project_root()} --python || true" - ) + cmd = self._build_search_command(key) logging.info(f"Command: {cmd}") result_shell = subprocess.check_output(cmd, shell=True, text=True) @@ -54,7 +65,7 @@ def edit_key(self, key_expr: str): file, line, *_ = result_shell.split(":") print(f"Editing file and line {file}, {line}") - self._edit_file(file, line) + self._edit_file(file, int(line)) def edit_default(self): import os @@ -88,7 +99,7 @@ def _edit_file(self, file_name: str, line: Optional[int] = 30, dry_run=False): def _get_open_text_editor_command(self, file, line): # vim only supported - return f"vim {file} +{line}'" + return f"vim {file} +{line}" def main(): diff --git a/python_search/entry_capture/filesystem_entry_inserter.py b/python_search/entry_capture/filesystem_entry_inserter.py index 2596a309..cd086fb0 100644 --- a/python_search/entry_capture/filesystem_entry_inserter.py +++ b/python_search/entry_capture/filesystem_entry_inserter.py @@ -37,13 +37,13 @@ def insert(self, key: str, entry: dict): ) row_entry = str(entry) - line_to_add = f" '{key}': {row_entry}," + line_to_add = f" '{key}': {row_entry}," # noqa: E231 self._append_entry(line_to_add) from python_search.events.run_performed.writer import LogRunPerformedClient LogRunPerformedClient(self._configuration).send( - EntryExecuted(key=key, query_input="", shortcut=False) + EntryExecuted(key=key, query_input="") ) from python_search.apps.notification_ui import send_notification diff --git a/python_search/host_system/display_detection.py b/python_search/host_system/display_detection.py new file mode 100644 index 00000000..980da315 --- /dev/null +++ b/python_search/host_system/display_detection.py @@ -0,0 +1,500 @@ +import os +import subprocess +import logging +from typing import Tuple, Optional, NamedTuple +from python_search.environment import is_mac, is_linux + + +class DisplayInfo(NamedTuple): + """Information about the current display""" + + width: int + height: int + dpi: Optional[float] = None + scale_factor: Optional[float] = None + + +class DisplayDetector: + """Utility to detect display resolution and characteristics across + different platforms""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def get_display_info(self) -> DisplayInfo: + """Get display information for the current platform""" + try: + if is_mac(): + return self._get_macos_display_info() + elif is_linux(): + return self._get_linux_display_info() + else: + self.logger.warning("Unsupported platform for display detection") + return self._get_fallback_display_info() + except Exception as e: + self.logger.error(f"Failed to detect display info: {e}") + return self._get_fallback_display_info() + + def _get_macos_display_info(self) -> DisplayInfo: + """Get display information on macOS using multiple methods""" + try: + # Try multiple methods for getting display resolution + width, height = self._get_macos_resolution() + + # Try to get DPI information + dpi = self._get_macos_dpi() + scale_factor = self._get_macos_scale_factor() + + return DisplayInfo( + width=width, height=height, dpi=dpi, scale_factor=scale_factor + ) + + except Exception as e: + self.logger.error(f"Failed to get macOS display info: {e}") + # Fallback to common macOS resolutions + return DisplayInfo(width=1920, height=1080, dpi=110.0, scale_factor=1.0) + + def _get_macos_resolution(self) -> tuple[int, int]: + """Try multiple methods to get macOS screen resolution""" + + # Method 0: Check environment variables first + env_width = os.environ.get("DISPLAY_WIDTH") + env_height = os.environ.get("DISPLAY_HEIGHT") + if env_width and env_height: + try: + return int(env_width), int(env_height) + except ValueError: + self.logger.debug( + "Invalid DISPLAY_WIDTH/DISPLAY_HEIGHT environment variables" + ) + + # Method 1: Try system_profiler first (more reliable) + try: + cmd = ["system_profiler", "SPDisplaysDataType", "-json"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + import json + + data = json.loads(result.stdout) + displays = data.get("SPDisplaysDataType", []) + + for display in displays: + # Look for main display or first available display + if "spdisplays_resolution" in display: + resolution = display["spdisplays_resolution"] + # Parse resolution like "2560 x 1440" + parts = resolution.split(" x ") + if len(parts) == 2: + width = int(parts[0]) + height = int(parts[1]) + return width, height + + # Alternative format in some macOS versions + if "spdisplays_pixelresolution" in display: + resolution = display["spdisplays_pixelresolution"] + parts = resolution.split(" x ") + if len(parts) == 2: + width = int(parts[0]) + height = int(parts[1]) + return width, height + except Exception as e: + self.logger.debug(f"system_profiler method failed: {e}") + + # Method 2: Try osascript with different approach + try: + cmd = [ + "osascript", + "-e", + 'tell application "System Events" to get the size of first desktop', + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + size_str = result.stdout.strip() + # Parse output like "{1920, 1080}" + size_str = size_str.strip("{}") + parts = size_str.split(", ") + if len(parts) == 2: + width = int(parts[0]) + height = int(parts[1]) + return width, height + except Exception as e: + self.logger.debug(f"osascript System Events method failed: {e}") + + # Method 3: Try original osascript method + try: + cmd = [ + "osascript", + "-e", + 'tell application "Finder" to get bounds of window of desktop', + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + bounds = result.stdout.strip().split(", ") + + if len(bounds) >= 4: + width = int(bounds[2]) + height = int(bounds[3]) + return width, height + except Exception as e: + self.logger.debug(f"osascript Finder method failed: {e}") + + # Method 4: Try using Python libraries if available + try: + # Try using Quartz (part of pyobjc) if available + from Quartz import CGDisplayBounds, CGMainDisplayID + + main_display = CGMainDisplayID() + bounds = CGDisplayBounds(main_display) + width = int(bounds.size.width) + height = int(bounds.size.height) + return width, height + except ImportError: + self.logger.debug("Quartz not available") + except Exception as e: + self.logger.debug(f"Quartz method failed: {e}") + + # Method 5: Try to detect common Mac display sizes based on model + try: + model_info = self._get_mac_model_info() + if model_info: + return self._guess_resolution_from_model(model_info) + except Exception as e: + self.logger.debug(f"Model-based detection failed: {e}") + + # If all methods fail, raise an exception + raise RuntimeError("Could not determine macOS display resolution") + + def _get_mac_model_info(self) -> str: + """Get Mac model information""" + try: + cmd = ["system_profiler", "SPHardwareDataType", "-json"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + import json + + data = json.loads(result.stdout) + hardware = data.get("SPHardwareDataType", []) + + if hardware and len(hardware) > 0: + return hardware[0].get("machine_name", "") + except Exception: + pass + + return "" + + def _guess_resolution_from_model(self, model_info: str) -> tuple[int, int]: + """Guess resolution based on Mac model""" + model_lower = model_info.lower() + + # Common Mac display resolutions + if "macbook air" in model_lower: + if "13" in model_lower: + return 1440, 900 # 13" MacBook Air + elif "15" in model_lower: + return 2880, 1864 # 15" MacBook Air + elif "macbook pro" in model_lower: + if "13" in model_lower: + return 2560, 1600 # 13" MacBook Pro + elif "14" in model_lower: + return 3024, 1964 # 14" MacBook Pro + elif "16" in model_lower: + return 3456, 2234 # 16" MacBook Pro + elif "imac" in model_lower: + if "24" in model_lower: + return 4480, 2520 # 24" iMac + elif "27" in model_lower: + return 5120, 2880 # 27" iMac + elif "mac studio" in model_lower or "mac pro" in model_lower: + # These typically use external displays, assume common resolution + return 2560, 1440 + + # Default to common laptop resolution + return 1920, 1080 + + def _get_macos_dpi(self) -> Optional[float]: + """Get DPI on macOS""" + try: + cmd = ["system_profiler", "SPDisplaysDataType", "-json"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + # Parse JSON output to extract DPI if available + import json + + data = json.loads(result.stdout) + + # This is a simplified approach - actual DPI extraction from system_profiler + # can be more complex depending on the display configuration + displays = data.get("SPDisplaysDataType", []) + if displays and len(displays) > 0: + # Look for resolution and physical size to calculate DPI + # This is a rough estimation + return 110.0 # Default DPI for most Mac displays + + except Exception as e: + self.logger.debug(f"Could not get macOS DPI: {e}") + + return None + + def _get_macos_scale_factor(self) -> Optional[float]: + """Get display scale factor on macOS""" + try: + # This is a simplified approach - actual scale factor detection + # would require more complex AppleScript or Cocoa APIs + return 1.0 + except Exception: + return None + + def _get_linux_display_info(self) -> DisplayInfo: + """Get display information on Linux using xrandr or other tools""" + try: + # Try xrandr first (most common on X11) + if self._command_exists("xrandr"): + return self._get_xrandr_info() + + # Try wayland tools if available + if self._command_exists("wlr-randr"): + return self._get_wayland_info() + + # Fallback to environment variables + return self._get_env_display_info() + + except Exception as e: + self.logger.error(f"Failed to get Linux display info: {e}") + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + + def _get_xrandr_info(self) -> DisplayInfo: + """Parse xrandr output to get display information""" + try: + result = subprocess.run( + ["xrandr"], capture_output=True, text=True, check=True + ) + + # Parse xrandr output to find primary display + lines = result.stdout.split("\n") + for line in lines: + if " connected primary" in line or ( + " connected" in line and "primary" not in result.stdout + ): + # Extract resolution from line like: + # "DP-1 connected primary 1920x1080+0+0 (normal left + # inverted right x axis y axis) 510mm x 287mm" + parts = line.split() + for part in parts: + if "x" in part and "+" in part: + resolution = part.split("+")[0] + width, height = map(int, resolution.split("x")) + + # Try to extract physical dimensions for DPI calculation + dpi = self._calculate_dpi_from_xrandr_line( + line, width, height + ) + + return DisplayInfo( + width=width, height=height, dpi=dpi, scale_factor=1.0 + ) + + # If no primary display found, use first connected display + for line in lines: + if " connected" in line and "disconnected" not in line: + parts = line.split() + for part in parts: + if "x" in part and ("+" in part or part.count("x") == 1): + if "+" in part: + resolution = part.split("+")[0] + else: + resolution = part + try: + width, height = map(int, resolution.split("x")) + return DisplayInfo( + width=width, + height=height, + dpi=96.0, + scale_factor=1.0, + ) + except ValueError: + continue + + except Exception as e: + self.logger.error(f"Failed to parse xrandr output: {e}") + + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + + def _calculate_dpi_from_xrandr_line( + self, line: str, width: int, height: int + ) -> Optional[float]: + """Calculate DPI from xrandr line containing physical dimensions""" + try: + # Look for physical dimensions like "510mm x 287mm" + import re + + mm_match = re.search(r"(\d+)mm x (\d+)mm", line) + if mm_match: + width_mm = int(mm_match.group(1)) + height_mm = int(mm_match.group(2)) + + # Calculate DPI (dots per inch) + width_inches = width_mm / 25.4 + height_inches = height_mm / 25.4 + + dpi_x = width / width_inches + dpi_y = height / height_inches + + # Return average DPI + return (dpi_x + dpi_y) / 2 + except Exception: + pass + + return None + + def _get_wayland_info(self) -> DisplayInfo: + """Get display info for Wayland compositors""" + try: + result = subprocess.run( + ["wlr-randr"], capture_output=True, text=True, check=True + ) + + # Parse wlr-randr output + lines = result.stdout.split("\n") + for i, line in enumerate(lines): + if "current" in line: + # Look for resolution in current mode + import re + + match = re.search(r"(\d+)x(\d+)", line) + if match: + width = int(match.group(1)) + height = int(match.group(2)) + return DisplayInfo( + width=width, height=height, dpi=96.0, scale_factor=1.0 + ) + + except Exception as e: + self.logger.error(f"Failed to get Wayland display info: {e}") + + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + + def _get_env_display_info(self) -> DisplayInfo: + """Try to get display info from environment variables""" + try: + # Some systems set these environment variables + if "DISPLAY" in os.environ: + # This is a very basic fallback + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + except Exception: + pass + + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + + def _get_fallback_display_info(self) -> DisplayInfo: + """Fallback display information for unsupported platforms""" + return DisplayInfo(width=1920, height=1080, dpi=96.0, scale_factor=1.0) + + def _command_exists(self, command: str) -> bool: + """Check if a command exists in the system PATH""" + try: + subprocess.run([command, "--version"], capture_output=True, check=False) + return True + except FileNotFoundError: + return False + + +class AdaptiveWindowSizer: + """Calculate appropriate window sizes based on display characteristics""" + + def __init__(self, display_detector: Optional[DisplayDetector] = None): + self.display_detector = display_detector or DisplayDetector() + self.logger = logging.getLogger(__name__) + + def get_adaptive_window_size( + self, + base_width_chars: int = 86, + base_height_chars: int = 10, + target_screen_percentage: float = 0.6, + ) -> Tuple[str, str]: + """ + Calculate adaptive window size based on display characteristics + + Args: + base_width_chars: Base width in characters for reference resolution + base_height_chars: Base height in characters for reference resolution + target_screen_percentage: Target percentage of screen width to occupy + + Returns: + Tuple of (width_str, height_str) suitable for kitty terminal + """ + display_info = self.display_detector.get_display_info() + + # Calculate scale factor based on display resolution + # Use 1920x1080 as reference resolution + reference_width = 1920 + reference_height = 1080 + + width_scale = display_info.width / reference_width + height_scale = display_info.height / reference_height + + # Use geometric mean of width and height scales to avoid extreme ratios + scale_factor = (width_scale * height_scale) ** 0.5 + + # Apply DPI scaling if available + if display_info.dpi: + # Standard DPI is 96, adjust if significantly different + dpi_scale = display_info.dpi / 96.0 + scale_factor *= dpi_scale + + # Apply display scale factor if available (for high-DPI displays) + if display_info.scale_factor and display_info.scale_factor > 1.0: + scale_factor *= display_info.scale_factor + + # Calculate new dimensions + # Clamp scale factor to reasonable bounds + scale_factor = max(0.5, min(3.0, scale_factor)) + + new_width_chars = int(base_width_chars * scale_factor) + new_height_chars = int(base_height_chars * scale_factor) + + # Ensure minimum usable size + new_width_chars = max(40, new_width_chars) + new_height_chars = max(5, new_height_chars) + + # Ensure we don't exceed screen bounds (rough estimation) + max_width_chars = int(display_info.width / 10) # Rough char width estimation + max_height_chars = int(display_info.height / 20) # Rough char height estimation + + new_width_chars = min(new_width_chars, max_width_chars) + new_height_chars = min(new_height_chars, max_height_chars) + + self.logger.debug( + f"Display: {display_info.width}x{display_info.height}, " + f"DPI: {display_info.dpi}, Scale: {scale_factor:.2f}, " + f"Window: {new_width_chars}x{new_height_chars}" + ) + + return f"{new_width_chars}c", f"{new_height_chars}c" + + def get_preset_sizes(self) -> dict: + """Get predefined window sizes for different display categories""" + display_info = self.display_detector.get_display_info() + + # Categorize display size + if display_info.width <= 1366: # Small displays (laptops, etc.) + return { + "small": ("60c", "8c"), + "medium": ("80c", "10c"), + "large": ("100c", "12c"), + } + elif display_info.width <= 1920: # Standard HD displays + return { + "small": ("70c", "9c"), + "medium": ("90c", "11c"), + "large": ("120c", "15c"), + } + elif display_info.width <= 2560: # QHD displays + return { + "small": ("80c", "10c"), + "medium": ("110c", "13c"), + "large": ("150c", "18c"), + } + else: # 4K and larger displays + return { + "small": ("100c", "12c"), + "medium": ("140c", "16c"), + "large": ("180c", "22c"), + } diff --git a/python_search/host_system/test_display_detection.py b/python_search/host_system/test_display_detection.py new file mode 100644 index 00000000..a1e7d40a --- /dev/null +++ b/python_search/host_system/test_display_detection.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Test script for display detection and adaptive window sizing +""" + +import logging +from python_search.host_system.display_detection import ( + DisplayDetector, + AdaptiveWindowSizer, +) + + +def main(): + # Set up logging + logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + + print("=== Display Detection Test ===") + + # Test display detection + detector = DisplayDetector() + display_info = detector.get_display_info() + + print(f"Display Resolution: {display_info.width}x{display_info.height}") + print(f"DPI: {display_info.dpi}") + print(f"Scale Factor: {display_info.scale_factor}") + + print("\n=== Adaptive Window Sizing Test ===") + + # Test adaptive window sizing + sizer = AdaptiveWindowSizer(detector) + + # Test default adaptive sizing + width, height = sizer.get_adaptive_window_size() + print(f"Adaptive Window Size: {width} x {height}") + + # Test with different base sizes + small_width, small_height = sizer.get_adaptive_window_size(60, 8) + print(f"Small Adaptive Size: {small_width} x {small_height}") + + large_width, large_height = sizer.get_adaptive_window_size(120, 15) + print(f"Large Adaptive Size: {large_width} x {large_height}") + + print("\n=== Preset Sizes ===") + + # Test preset sizes + presets = sizer.get_preset_sizes() + for preset_name, (preset_width, preset_height) in presets.items(): + print(f"{preset_name.capitalize()}: {preset_width} x {preset_height}") + + print("\n=== Environment Variable Examples ===") + print("You can override window sizing with these environment variables:") + print("export PYTHON_SEARCH_WINDOW_WIDTH='100c'") + print("export PYTHON_SEARCH_WINDOW_HEIGHT='12c'") + print("# OR") + print("export PYTHON_SEARCH_WINDOW_PRESET='large'") + + +if __name__ == "__main__": + main() diff --git a/python_search/init/install_dependencies.py b/python_search/init/install_dependencies.py index 63b0f2dd..4d6666ad 100644 --- a/python_search/init/install_dependencies.py +++ b/python_search/init/install_dependencies.py @@ -15,13 +15,13 @@ def install_all(self): self._install_fzf() self._install_kitty() - self._install_ack() + self._install_ripgrep() self._install_tk() self._install_zsh_mac() self._install_shortcut_mac() self._install_wctrl() self._install_xsel() - self._install_ack() + self._install_ripgrep() print( """ @@ -29,17 +29,21 @@ def install_all(self): """ ) - def _install_ack(self): - print("Installing ack") + def _install_ripgrep(self): + print("Installing ripgrep (rg) - fast file search tool") if is_mac(): self._install_brew_if_not_present() - os.system("brew install ack") - if is_debian_based(): - os.system("sudo apt-get install ack") + os.system("brew install ripgrep") + elif is_debian_based(): + os.system("sudo apt-get install ripgrep") + elif is_archlinux(): + os.system("sudo pacman -S ripgrep") else: print( - "Dont know how to install ack for your platform, please do so manually" + "Don't know how to install ripgrep for your platform, " + "please install manually" ) + print("Visit: https://github.com/BurntSushi/ripgrep#installation") def _install_tk(self): if is_mac(): @@ -64,14 +68,22 @@ def _install_shortcut_mac(self): branch = "main" print("Downloading keyboard config ") + url = ( + f"https://raw.githubusercontent.com/jeanCarloMachado/" # noqa: E231 + f"PythonSearch/{branch}/docs/config.ini.part1" + ) self.download_file( - f"https://raw.githubusercontent.com/jeanCarloMachado/PythonSearch/{branch}/docs/config.ini.part1", + url, f"{HOME}/.config/iCanHazShortcut/config.ini.part1", ) print("Downloading bom script ") + url = ( + f"https://raw.githubusercontent.com/jeanCarloMachado/" # noqa: E231 + f"PythonSearch/{branch}/add_bom_to_file.sh" + ) self.download_file( - f"https://raw.githubusercontent.com/jeanCarloMachado/PythonSearch/{branch}/add_bom_to_file.sh", + url, "/usr/local/bin/add_bom_to_file.sh", ) os.system("chmod +x /usr/local/bin/add_bom_to_file.sh") @@ -95,9 +107,11 @@ def _install_fzf(self): os.system(f"rm -rf {HOME}/.fzf/") print("Looks like kitty is not installed in your platform. ") - os.system( - f""" git clone --depth 1 https://github.com/junegunn/fzf.git {HOME}/.fzf ; yes | {HOME}/.fzf/install """ + cmd = ( + f"git clone --depth 1 https://github.com/junegunn/fzf.git " # noqa: E231 + f"{HOME}/.fzf && yes | {HOME}/.fzf/install" ) + os.system(cmd) def _install_brew_if_not_present(self): print("Brew checking...") @@ -107,7 +121,8 @@ def _install_brew_if_not_present(self): print("Installing brew for you...") os.system( - '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/' + 'Homebrew/install/HEAD/install.sh)"' ) def _exists(self, cmd: str): @@ -122,7 +137,8 @@ def _install_kitty(self): return print( - "Looks like kitty is not installed in your platform. Installing it for you..." + "Looks like kitty is not installed in your platform. " + "Installing it for you..." ) if is_debian_based(): @@ -142,14 +158,6 @@ def _install_wctrl(self): if is_debian_based(): os.system("sudo apt-get install wmctrl") - def _install_ack(self): - if is_mac(): - os.system("brew install ack") - if is_debian_based(): - os.system("sudo apt-get install ack") - if is_archlinux(): - os.system("sudo pacman -S ack") - def _install_xsel(self): if is_debian_based(): os.system("sudo apt-get install xsel") diff --git a/python_search/search/search_ui/kitty_for_search_ui.py b/python_search/search/search_ui/kitty_for_search_ui.py index 98f509d6..738b7d18 100644 --- a/python_search/search/search_ui/kitty_for_search_ui.py +++ b/python_search/search/search_ui/kitty_for_search_ui.py @@ -4,8 +4,7 @@ import sys from python_search.apps.terminal import KittyTerminal from python_search.host_system.system_paths import SystemPaths -from python_search.environment import is_mac -import sys +from python_search.host_system.display_detection import AdaptiveWindowSizer SOCKET_PATH = "/tmp/mykitty" @@ -30,20 +29,73 @@ def __init__(self, configuration=None): configuration = ConfigurationLoader().load_config() self._configuration = configuration + + # Determine window size based on configuration custom_window_size = configuration.get_window_size() - self._width = ( - custom_window_size[0] - if custom_window_size - else self._DEFAULT_WINDOW_SIZE[0] - ) - self._height = ( - custom_window_size[1] - if custom_window_size - else self._DEFAULT_WINDOW_SIZE[1] - ) + + if custom_window_size: + # Use explicitly configured size + self._width = custom_window_size[0] + self._height = custom_window_size[1] + elif configuration.should_use_adaptive_sizing(): + # Use adaptive sizing based on display characteristics + self._width, self._height = self._get_adaptive_window_size(configuration) + else: + # Fall back to default size + self._width = self._DEFAULT_WINDOW_SIZE[0] + self._height = self._DEFAULT_WINDOW_SIZE[1] self._title = configuration.APPLICATION_TITLE + def _get_adaptive_window_size(self, configuration) -> tuple[str, str]: + """Get adaptive window size based on display characteristics""" + try: + # Check for environment variable override first + env_width = os.environ.get("PYTHON_SEARCH_WINDOW_WIDTH") + env_height = os.environ.get("PYTHON_SEARCH_WINDOW_HEIGHT") + env_preset = os.environ.get("PYTHON_SEARCH_WINDOW_PRESET") + + if env_width and env_height: + self._logger.debug( + f"Using environment variable window size: {env_width}x{env_height}" + ) + return env_width, env_height + + sizer = AdaptiveWindowSizer() + + # Check environment preset override + if env_preset: + presets = sizer.get_preset_sizes() + if env_preset in presets: + self._logger.debug(f"Using environment preset: {env_preset}") + return presets[env_preset] + else: + self._logger.warning( + f"Unknown environment preset '{env_preset}', " + f"using adaptive sizing" + ) + + # Check if a preset is specified in configuration + preset = configuration.get_window_size_preset() + if preset: + presets = sizer.get_preset_sizes() + if preset in presets: + self._logger.debug(f"Using configuration preset: {preset}") + return presets[preset] + else: + self._logger.warning( + f"Unknown preset '{preset}', using adaptive sizing" + ) + + # Use adaptive sizing + self._logger.debug("Using adaptive window sizing") + return sizer.get_adaptive_window_size() + + except Exception as e: + self._logger.error(f"Failed to get adaptive window size: {e}") + # Fall back to default size + return self._DEFAULT_WINDOW_SIZE + def launch(self) -> None: """ Entry point for the application to launch the search ui @@ -60,25 +112,27 @@ def get_kitty_complete_cmd(self) -> str: from python_search.apps.theme.theme import get_current_theme theme = get_current_theme() - return f"""{self.get_kitty_cmd()} \ - --title {self._title} \ - --listen-on unix: {SOCKET_PATH} \ - -o allow_remote_control=yes \ - -o window_padding_width=0 \ - -o placement_strategy=center \ - -o window_border_width=0 \ - -o window_padding_width=0 \ - -o hide_window_decorations=titlebar-only \ - -o background_opacity=0.9 \ - -o active_tab_title_template=none \ - -o initial_window_width={self._width} \ - -o initial_window_height={self._height} \ - -o background={theme.backgroud} \ - -o foreground={theme.text} \ - -o font_size="{theme.font_size}" \ - {terminal.GLOBAL_TERMINAL_PARAMS} \ - {SystemPaths.BINARIES_PATH}/term_ui & - """ + cmd_parts = [ + self.get_kitty_cmd(), + f"--title {self._title}", + f"--listen-on unix:{SOCKET_PATH}", # noqa: E231 + "-o allow_remote_control=yes", + "-o window_padding_width=0", + "-o placement_strategy=center", + "-o window_border_width=0", + "-o window_padding_width=0", + "-o hide_window_decorations=titlebar-only", + "-o background_opacity=0.9", + "-o active_tab_title_template=none", + f"-o initial_window_width={self._width}", + f"-o initial_window_height={self._height}", + f"-o background={theme.backgroud}", + f"-o foreground={theme.text}", + f'-o font_size="{theme.font_size}"', + terminal.GLOBAL_TERMINAL_PARAMS, + f"{SystemPaths.BINARIES_PATH}/term_ui &", + ] + return " ".join(cmd_parts) @staticmethod def try_to_focus() -> bool: @@ -89,7 +143,10 @@ def try_to_focus() -> bool: print(f"File {SOCKET_PATH} not found") return False - cmd = f"{SystemPaths.KITTY_BINNARY} @ --to unix: {SOCKET_PATH} focus-window " + cmd = ( + f"{SystemPaths.KITTY_BINNARY} @ --to " + f"unix:{SOCKET_PATH} focus-window" # noqa: E231 + ) print("Cmd: ", cmd) result = os.system(cmd) print(result, "Type: ", type(result)) diff --git a/python_search/search/search_ui/search_actions.py b/python_search/search/search_ui/search_actions.py index ba5d6268..29473063 100644 --- a/python_search/search/search_ui/search_actions.py +++ b/python_search/search/search_ui/search_actions.py @@ -4,34 +4,63 @@ class Actions: - def search_in_google(self, query): - Popen( - f'{SystemPaths.BINARIES_PATH}/clipboard set_content "{query}" && {SystemPaths.BINARIES_PATH}/run_key "search in google using clipboard content" &>/dev/null', - stdout=None, - stderr=None, - shell=True, - ) + """ + Handles various actions that can be performed on search entries and queries. + + This class provides methods for running entries, editing them, copying values + to clipboard, and performing web searches. All operations are executed + asynchronously using subprocess.Popen. + """ + + def run_key(self, key: str) -> None: + """ + Execute a command associated with the given key. + + Args: + key: The identifier for the entry to run + """ + command = f'{SystemPaths.BINARIES_PATH}/run_key "{key}" &>/dev/null' + Popen(command, stdout=None, stderr=None, shell=True) - def copy_entry_value_to_clipboard(self, entry_key): - Popen( - f'{SystemPaths.BINARIES_PATH}/share_entry share_only_value "{entry_key}" &>/dev/null', - stdout=None, - stderr=None, - shell=True, + def edit_key(self, key: str, block: bool = False) -> None: + """ + Open the entry editor for the specified key. + + Args: + key: The identifier for the entry to edit + block: Whether to block execution (currently unused) + """ + cmd = ( + f"/opt/miniconda3/envs/python312/bin/entries_editor " + f'edit_key "{key}" &>/dev/null' ) + Popen(cmd, stdout=None, stderr=None, shell=True) - def edit_key(self, key, block=False): - # cleanup the number prefix + def copy_entry_value_to_clipboard(self, entry_key: str) -> None: + """ + Copy the value of an entry to the system clipboard. - cmd = f'/opt/miniconda3/envs/python312/bin/entries_editor edit_key "{key}"' - print(f"Editing {cmd}") - Popen( - cmd, - stdout=None, - stderr=None, - shell=True, + Args: + entry_key: The identifier for the entry whose value should be copied + """ + command = ( + f"python -m python_search.share_entry " + f'share_only_value "{entry_key}" &>/dev/null' ) + Popen(command, stdout=None, stderr=None, shell=True) - def run_key(self, key: str) -> None: - command = f'{SystemPaths.BINARIES_PATH}/run_key "{key}" &>/dev/null' + def search_in_google(self, query: str) -> None: + """ + Perform a Google search with the given query. + + Sets the query in clipboard and triggers a Google search action. + + Args: + query: The search query to execute + """ + command = ( + f'{SystemPaths.BINARIES_PATH}/clipboard set_content "{query}" && ' + f"{SystemPaths.BINARIES_PATH}/run_key " + f'"search in google using clipboard content" &>/dev/null' + ) Popen(command, stdout=None, stderr=None, shell=True) diff --git a/python_search/search/search_ui/terminal_ui.py b/python_search/search/search_ui/terminal_ui.py index 7292fcf4..36184a9d 100644 --- a/python_search/search/search_ui/terminal_ui.py +++ b/python_search/search/search_ui/terminal_ui.py @@ -3,6 +3,7 @@ from typing import Any import json import time +import shutil from python_search.core_entities import Entry from python_search.search.search_ui.QueryLogic import QueryLogic @@ -12,9 +13,9 @@ from python_search.apps.theme.theme import get_current_theme from python_search.host_system.system_paths import SystemPaths from python_search.logger import setup_term_ui_logger +from getch import getch logger = setup_term_ui_logger() -from getch import getch startup_time = time.time_ns() statsd = setup_datadog() @@ -24,10 +25,10 @@ class SearchTerminalUi: - MAX_KEY_SIZE = 35 - MAX_CONTENT_SIZE = 46 + MAX_KEY_SIZE = 52 # Enlarged by 7 characters from previous 45 + MAX_CONTENT_SIZE = 53 # Reduced by 7 characters from previous 60 RUN_KEY_EVENT = "python_search_run_key" - DISPLAY_ROWS = 7 # Number of rows to display at once + DEFAULT_DISPLAY_ROWS = 7 # Default number of rows to display at once DEBOUNCE_DELAY_MS = 75 # 75ms debounce delay _documents_future = None @@ -49,9 +50,81 @@ def __init__(self) -> None: self.query = "" self.selected_row = 0 self.selected_query = -1 + self.display_rows = self._calculate_optimal_display_rows() + + # Calculate dynamic sizes based on terminal width + self._calculate_optimal_sizes() self._setup_entries() - self.normal_mode = False + + def _calculate_optimal_display_rows(self) -> int: + """Calculate optimal number of display rows based on terminal height""" + try: + # Get terminal size + terminal_size = shutil.get_terminal_size() + terminal_height = terminal_size.lines + + # Reserve space for: + # - 1 line for the input/query line at the top + # - 1 line for potential spacing/buffer + # - Use remaining space for content rows + reserved_lines = 2 + + # Calculate optimal rows, ensuring we have at least 3 rows for content + # Allow up to 9 rows for larger displays, but use DEFAULT_DISPLAY_ROWS as fallback + max_rows = 9 + optimal_rows = max(3, min(max_rows, terminal_height - reserved_lines)) + + # For very small terminals (height <= 10), reduce by 1 more to ensure typing line visibility + if terminal_height <= 10: + optimal_rows = max(3, optimal_rows - 1) + + logger.debug(f"Terminal height: {terminal_height}, " f"using optimized display rows: {optimal_rows}") + return optimal_rows + + except Exception as e: + logger.warning(f"Failed to calculate optimal display rows: {e}, using default") + return self.DEFAULT_DISPLAY_ROWS + + def _calculate_optimal_sizes(self) -> None: + """Calculate optimal key and content sizes based on terminal width""" + try: + terminal_size = shutil.get_terminal_size() + terminal_width = terminal_size.columns + + # Reserve space for: "99. " (4 chars) + " " (1 char) = 5 chars + available_width = terminal_width - 5 + + # Allocate roughly 40% for keys, 60% for content + key_width = max(30, min(50, int(available_width * 0.4))) + content_width = available_width - key_width + + # Ensure content has a reasonable minimum + if content_width < 40: + key_width = available_width - 40 + content_width = 40 + + # Adjust: enlarge key size by 7, reduce content by 7 + key_width += 7 + content_width -= 7 + + # Ensure we don't go below reasonable minimums after adjustment + if content_width < 33: # Minimum content width after -7 adjustment + adjustment = 33 - content_width + key_width -= adjustment + content_width = 33 + + self.MAX_KEY_SIZE = key_width + self.MAX_CONTENT_SIZE = content_width + + logger.debug( + f"Terminal width: {terminal_width}, " + f"Key size: {self.MAX_KEY_SIZE}, Content size: {self.MAX_CONTENT_SIZE}" + ) + + except Exception as e: + logger.warning(f"Failed to calculate optimal sizes: {e}, using defaults") + # Keep the class defaults if calculation fails def run(self): """ @@ -82,14 +155,24 @@ def run(self): @statsd.timed("ps_render") def render(self): + # Recalculate display rows and sizes in case terminal was resized + new_display_rows = self._calculate_optimal_display_rows() + if new_display_rows != self.display_rows: + self.display_rows = new_display_rows + # Adjust scroll offset if needed + if self.selected_row >= self.scroll_offset + self.display_rows: + self.scroll_offset = max(0, self.selected_row - self.display_rows + 1) + + # Recalculate optimal sizes for dynamic terminal width adjustment + self._calculate_optimal_sizes() + self.print_first_line() logger.info("rendering loop started") # Simple debouncing: only search if enough time has passed or query is different current_time = time.time() * 1000 # Convert to milliseconds should_search = ( - self.query != self.previous_query - or (current_time - self._last_search_time) >= self.DEBOUNCE_DELAY_MS + self.query != self.previous_query or (current_time - self._last_search_time) >= self.DEBOUNCE_DELAY_MS ) if should_search: @@ -100,19 +183,16 @@ def render(self): except Exception as e: logger.error(f"Error during search: {e}") # Keep using previous results or empty list - if ( - not hasattr(self, "all_matched_keys") - or self.all_matched_keys is None - ): + if not hasattr(self, "all_matched_keys") or self.all_matched_keys is None: self.all_matched_keys = [] # If not searching due to debounce, keep using previous results # Calculate visible range based on scroll offset start_idx = self.scroll_offset - end_idx = min(start_idx + self.DISPLAY_ROWS, len(self.all_matched_keys)) + end_idx = min(start_idx + self.display_rows, len(self.all_matched_keys)) # Update matched_keys for backward compatibility - self.matched_keys = self.all_matched_keys[start_idx: end_idx] + self.matched_keys = self.all_matched_keys[start_idx:end_idx] current_display_row = 0 for i in range(start_idx, end_idx): @@ -134,8 +214,7 @@ def _setup_entries(self): import subprocess output = subprocess.getoutput( - SystemPaths.BINARIES_PATH - + "/pys _entries_loader load_entries_as_json 2>/dev/null" + SystemPaths.BINARIES_PATH + "/pys _entries_loader load_entries_as_json 2>/dev/null" ) # print("output", output) self.commands = json.loads(output) @@ -150,16 +229,9 @@ def get_caracter(self) -> str: return " " def print_first_line(self): - if self.normal_mode: - content = self.cf.query_enabled("* " + self.query) - else: - content = self.cf.query(self.query) + content = self.cf.query(self.query) - print( - "\x1b[2J\x1b[H" - + self.cf.cursor(f"({len(self.commands)})> ") - + f"{self.cf.bold(content)}" - ) + print("\x1b[2J\x1b[H" + self.cf.cursor(f"({len(self.commands)})> ") + f"{self.cf.bold(content)}") def process_chars(self, c: str): self.typed_up_to_run += c @@ -167,32 +239,28 @@ def process_chars(self, c: str): # test if the character is a delete (backspace) if ord_c == 127: # backspace - self.query = self.query[: -1] + self.query = self.query[:-1] self.selected_row = 0 self.scroll_offset = 0 elif ord_c == 10: # enter self._run_key() - elif c in ["1", "2", "3", "4", "5", "6"] and self.normal_mode: - self.selected_row = int(c) - 1 - self._run_key() - # elif ord_c == 27: - # swap between modes via esc - # self.normal_mode = not self.normal_mode + elif c in ["1", "2", "3", "4", "5", "6", "7", "8", "9"]: + # Run entry by number (1-9) + entry_index = int(c) - 1 + if entry_index < len(self.all_matched_keys): + self.selected_row = entry_index + self._run_key() elif ord_c == 9: # tab if self.selected_row < len(self.all_matched_keys): - self.actions.edit_key( - self.all_matched_keys[self.selected_row], block=True - ) + self.actions.edit_key(self.all_matched_keys[self.selected_row], block=True) self._setup_entries() self.reloaded = True elif c == "'": # copy to clipboard if self.selected_row < len(self.all_matched_keys): - self.actions.copy_entry_value_to_clipboard( - self.all_matched_keys[self.selected_row] - ) + self.actions.copy_entry_value_to_clipboard(self.all_matched_keys[self.selected_row]) elif ord_c == 47: # ? self.actions.search_in_google(self.query) @@ -201,8 +269,8 @@ def process_chars(self, c: str): if self.selected_row < len(self.all_matched_keys) - 1: self.selected_row = self.selected_row + 1 # Check if we need to scroll down - if self.selected_row >= self.scroll_offset + self.DISPLAY_ROWS: - self.scroll_offset = self.selected_row - self.DISPLAY_ROWS + 1 + if self.selected_row >= self.scroll_offset + self.display_rows: + self.scroll_offset = self.selected_row - self.display_rows + 1 elif ord_c == 65: # Up arrow if self.selected_row > 0: self.selected_row = self.selected_row - 1 @@ -237,9 +305,7 @@ def process_chars(self, c: str): self.selected_row = 0 self.scroll_offset = 0 # remove the last word - self.query = " ".join( - list(filter(lambda x: x, self.query.split(" ")))[0: -1] - ) + self.query = " ".join(list(filter(lambda x: x, self.query.split(" ")))[0:-1]) self.query += " " elif c.isalnum() or c == " ": self.query += c @@ -280,19 +346,18 @@ def _get_data_warehouse(self): return self.tdw def print_highlighted(self, key: str, entry: Any, index: int) -> None: - key_part = self.cf.bold( - self.cf.selected( - f"{index: 2d}. {self.control_size(key, self.MAX_KEY_SIZE - 4)}" - ) - ) - body_part = f" {self.cf.bold(self.color_based_on_type(self.control_size(self.sanitize_content(entry.get_content_str(strip_new_lines=True)), self.MAX_CONTENT_SIZE), entry))} " + key_part = self.cf.bold(self.cf.selected(f"{index: 2d}. {self.control_size(key, self.MAX_KEY_SIZE - 4)}")) + content = self.sanitize_content(entry.get_content_str(strip_new_lines=True), entry) + sized_content = self.control_size(content, self.MAX_CONTENT_SIZE) + colored_content = self.color_based_on_type(sized_content, entry) + body_part = f" {self.cf.bold(colored_content)} " print(key_part + body_part) def print_normal_row(self, key, entry, index): key_input = self.control_size(key, self.MAX_KEY_SIZE - 4) body_part = self.color_based_on_type( self.control_size( - self.sanitize_content(entry.get_content_str(strip_new_lines=True)), + self.sanitize_content(entry.get_content_str(strip_new_lines=True), entry), self.MAX_CONTENT_SIZE, ), entry, @@ -312,12 +377,20 @@ def color_based_on_type(self, content, entry): return content - def sanitize_content(self, line) -> str: + def sanitize_content(self, line, entry=None) -> str: """ Transform content into suitable to display in terminal row """ line = line.strip() line = line.replace("\\r\\n", "") + + # Remove http:// and https:// prefixes for URL entries + if entry and entry.get_type_str() == "url": + if line.startswith("https://"): + line = line[8:] # Remove "https://" + elif line.startswith("http://"): + line = line[7:] # Remove "http://" + return line def control_size(self, a_string, num_chars): diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py new file mode 100644 index 00000000..5d321777 --- /dev/null +++ b/tests/test_clipboard.py @@ -0,0 +1,112 @@ +import pytest +from unittest.mock import patch +from python_search.apps.clipboard import Clipboard + + +class TestClipboard: + def test_set_and_get_content_roundtrip(self): + """ + Test that content set via set_content can be retrieved via get_content. + + This validates the core business rule that the clipboard should preserve + content between set and get operations. This is a happy path test that + exercises the primary workflow end-to-end. + + The test works by mocking the subprocess calls to avoid actual clipboard + interaction, while still testing the full logic flow including file I/O. + + This test can break easily if: + - The temporary file path changes from /tmp/clipboard_content + - The subprocess commands change (pbcopy/pbpaste vs xsel) + - The chomp method logic is modified + - Platform detection logic changes + """ + clipboard = Clipboard() + test_content = "Hello, World! This is a test string.\nWith multiple lines." + + # Mock platform detection and subprocess calls + with patch("python_search.apps.clipboard.is_mac", return_value=True), patch( + "subprocess.getoutput", return_value=test_content + "\n" + ), patch("subprocess.Popen") as mock_popen, patch( + "python_search.apps.notification_ui.send_notification" + ): + # Mock the Popen process for set_content + mock_process = mock_popen.return_value.__enter__.return_value + mock_process.communicate.return_value = (b"", None) + mock_process.returncode = 0 + + # Set content to clipboard + clipboard.set_content(test_content, enable_notifications=False) + + # Get content back from clipboard + retrieved_content = clipboard.get_content() + + # Assert the content matches (accounting for chomp behavior) + assert retrieved_content == test_content + + # Verify the correct commands were used for Mac + mock_popen.assert_called_once() + call_args = mock_popen.call_args[0][0] + assert "pbcopy" in call_args + assert "/tmp/clipboard_content" in call_args + + def test_set_content_validation_errors(self): + """ + Test that set_content properly validates input and raises exceptions. + + This validates the business rule that the clipboard should reject invalid + inputs (empty content, non-string types) with clear error messages. + This is a failure mode test that ensures proper error handling. + + The test works by calling set_content with various invalid inputs and + verifying the expected exceptions are raised with correct messages. + + This test can break easily if: + - The exception messages change + - The validation logic is modified + - New validation rules are added + """ + clipboard = Clipboard() + + # Test empty content raises exception (mock stdin to avoid pytest issues) + with patch("sys.stdin.readlines", return_value=[]): + with pytest.raises(Exception, match="Tryring to set empty to clipboard"): + clipboard.set_content("") + + with patch("sys.stdin.readlines", return_value=[]): + with pytest.raises(Exception, match="Tryring to set empty to clipboard"): + clipboard.set_content(None) + + # Test non-string content raises exception + with pytest.raises(Exception, match="Tryring to set a non string to clipboard"): + clipboard.set_content(123) + + with pytest.raises(Exception, match="Tryring to set a non string to clipboard"): + clipboard.set_content(["list", "content"]) + + def test_chomp_removes_trailing_characters(self): + """ + Test that the chomp method correctly removes trailing newline characters. + + This validates the business rule that clipboard content should be cleaned + of trailing whitespace/newlines for consistent behavior across platforms. + This is a utility function test that ensures proper string processing. + + The test works by calling chomp with various string endings and verifying + the correct characters are removed while preserving the main content. + + This test can break easily if: + - The chomp logic changes (different characters to remove) + - The order of character checking is modified + - New character types are added to the removal list + """ + clipboard = Clipboard() + + # Test different line ending scenarios + assert clipboard.chomp("test\r\n") == "test" + assert clipboard.chomp("test\n") == "test" + assert clipboard.chomp("test\r") == "test" + assert clipboard.chomp("test") == "test" + assert ( + clipboard.chomp("test\r\n\r\n") == "test\r\n" + ) # Only removes one occurrence diff --git a/tests/test_entry_editor.py b/tests/test_entry_editor.py index 7c1a8e53..c5f97e2d 100644 --- a/tests/test_entry_editor.py +++ b/tests/test_entry_editor.py @@ -1,18 +1,46 @@ import os +import shutil import subprocess from python_search.entry_capture.entries_editor import EntriesEditor -def test_ack(): - assert os.system(f"{EntriesEditor.ACK_PATH} --help") == 0 +def test_ripgrep_available(): + """Test that ripgrep is available (required search tool)""" + has_ripgrep = shutil.which("rg") is not None + assert ( + has_ripgrep + ), "ripgrep (rg) is required for file searching. Install with: brew install ripgrep" + + +def test_ripgrep_functionality(): + """Test ripgrep basic functionality""" + assert os.system("rg --help > /dev/null") == 0 + + +def test_entries_editor_initialization(): + """Test that EntriesEditor can initialize with ripgrep""" + editor = EntriesEditor() + assert ( + editor._search_cmd == "/opt/homebrew/bin/rg" + ), f"Expected '/opt/homebrew/bin/rg', got: {editor._search_cmd}" + + +def test_entries_editor_search_command(): + """Test that EntriesEditor generates correct ripgrep commands""" + editor = EntriesEditor() + cmd = editor._build_search_command("test_key") + assert "/opt/homebrew/bin/rg -n -i --type py 'test_key'" in cmd + assert "|| true" in cmd def test_entries_editor_edit_key_help_command_returns_output(): """ - Validates that the entries_editor edit_key -h shell command returns meaningful help output. + Validates that the entries_editor edit_key -h shell command returns + meaningful help output. - Business rule: The CLI must provide help documentation for the edit_key command to assist users. + Business rule: The CLI must provide help documentation for the edit_key + command to assist users. Purpose: Ensures the help system works correctly and returns expected content. How it works: @@ -59,11 +87,13 @@ def test_entries_editor_edit_key_help_command_returns_output(): assert ( "Edits the configuration files" in help_output ), f"Help output should contain the method docstring. Got: {help_output}" - assert ( - "SYNOPSIS" in help_output - ), f"Help output should contain standard Fire help sections. Got: {help_output}" + assert "SYNOPSIS" in help_output, ( + f"Help output should contain standard Fire help sections. " + f"Got: {help_output}" + ) # Ensure output is not empty - assert ( - len(help_output.strip()) > 50 - ), f"Help output should be substantial, not just a brief message. Got: {help_output}" + assert len(help_output.strip()) > 50, ( + f"Help output should be substantial, not just a brief message. " + f"Got: {help_output}" + ) diff --git a/tests/test_share_entry.py b/tests/test_share_entry.py new file mode 100644 index 00000000..d066b410 --- /dev/null +++ b/tests/test_share_entry.py @@ -0,0 +1,138 @@ +import subprocess +from unittest.mock import patch, MagicMock +import pytest + +from python_search.share_entry import ShareEntry + + +class TestShareEntry: + """Tests for the ShareEntry functionality to ensure it's properly installed.""" + + def test_share_entry_module_can_be_imported_and_executed_via_python_module( + self, + ): + """ + Validates that share_entry can be found and executed as a Python module. + + This test ensures that the share_entry functionality is properly installed + and accessible via 'python -m python_search.share_entry' command, which is + critical for the search UI clipboard functionality to work. + + Business rule: The share_entry module must be executable as a Python module + to support copying entry values to clipboard from the terminal UI. + + How it works: Executes the module with --help flag to verify it's accessible + and then tests the specific share_only_value method that's used by the UI. + + This test can break if: + - The share_entry.py module is missing or has import errors + - The module's main() function or fire.Fire() setup is broken + - Python path or module structure changes + - The share_only_value method is renamed or removed + """ + # Setup: Prepare commands to test module accessibility + base_command = ["python", "-m", "python_search.share_entry", "--help"] + method_command = [ + "python", + "-m", + "python_search.share_entry", + "share_only_value", + "--help", + ] + + # Perform: Execute the module commands + base_result = subprocess.run( + base_command, capture_output=True, text=True, timeout=10 + ) + method_result = subprocess.run( + method_command, capture_output=True, text=True, timeout=10 + ) + + # Assert: Module should be accessible and show help output + assert ( + base_result.returncode == 0 + ), f"Module execution failed: {base_result.stderr}" + assert ( + method_result.returncode == 0 + ), f"Method execution failed: {method_result.stderr}" + + base_output = base_result.stdout + base_result.stderr + method_output = method_result.stdout + method_result.stderr + + assert "share_entry.py" in base_output, "Expected module name not found in help" + assert ( + "share_only_value" in method_output + ), "Expected share_only_value method not accessible" + assert "KEY" in method_output, "Expected KEY parameter not found in method help" + + @patch("python_search.share_entry.Clipboard") + @patch("python_search.share_entry.ConfigurationLoader") + def test_share_only_value_copies_entry_content_to_clipboard( + self, mock_loader, mock_clipboard + ): + """ + Validates that share_only_value correctly extracts and copies entry content. + + This test ensures the core business logic of copying entry values works, + which is essential for the terminal UI's copy-to-clipboard feature. + + Business rule: When copying an entry value, only the content should + be copied to clipboard, and the user should be notified of the action. + + How it works: Mocks the configuration loader and clipboard, then verifies that + the correct entry content is extracted and copied with notifications. + + This test can break if: + - Entry content extraction logic changes + - Clipboard interface or notification parameters change + - Key resolution from fzf format fails + - Entry lookup or validation logic is modified + """ + # Setup: Create mock entry data and dependencies + mock_entries = { + "test_key": {"snippet": "test content value", "type": "snippet"} + } + mock_loader.return_value.load_entries.return_value = mock_entries + mock_clipboard_instance = MagicMock() + mock_clipboard.return_value = mock_clipboard_instance + + share_entry = ShareEntry() + + # Perform: Execute share_only_value with test key + result = share_entry.share_only_value("test_key") + + # Assert: Verify correct content is copied with proper settings + assert result == "test content value" + mock_clipboard_instance.set_content.assert_called_once_with( + "test content value", enable_notifications=True, notify=True + ) + + @patch("python_search.share_entry.ConfigurationLoader") + def test_share_only_value_raises_exception_for_nonexistent_entry(self, mock_loader): + """ + Validates that share_only_value properly handles missing entries. + + This test ensures robust error handling when users attempt to copy + non-existent entries, preventing silent failures in the terminal UI. + + Business rule: Attempting to copy a non-existent entry should raise + a clear exception rather than failing silently or causing crashes. + + How it works: Mocks an empty entries configuration and verifies that + attempting to share a non-existent key raises an appropriate exception. + + This test can break if: + - Exception handling logic is removed or changed + - Entry existence validation is modified + - Error message format changes + - Key resolution logic changes behavior for missing keys + """ + # Setup: Create empty entries configuration + mock_loader.return_value.load_entries.return_value = {} + share_entry = ShareEntry() + + # Perform & Assert: Verify exception is raised for missing entry + with pytest.raises(Exception) as exc_info: + share_entry.share_only_value("nonexistent_key") + + assert "Entry nonexistent_key not found" in str(exc_info.value)