src/pathpicker/line_format.py (224 lines of code) (raw):

# Copyright (c) Facebook, Inc. and its affiliates. # # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. import curses import os import subprocess import time from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Optional, Tuple from pathpicker import parse from pathpicker.color_printer import ColorPrinter from pathpicker.formatted_text import FormattedText from pathpicker.parse import MatchResult if TYPE_CHECKING: from pathpicker.screen_control import Controller class LineBase(ABC): def __init__(self) -> None: self.controller: Optional["Controller"] = None def set_controller(self, controller: "Controller") -> None: self.controller = controller @abstractmethod def output(self, printer: ColorPrinter) -> None: pass class SimpleLine(LineBase): def __init__(self, formatted_line: FormattedText, index: int): super().__init__() self.formatted_line = formatted_line self.index = index def output(self, printer: ColorPrinter) -> None: assert self.controller is not None (min_x, min_y, max_x, max_y) = self.controller.get_chrome_boundaries() max_len = min(max_x - min_x, len(str(self))) y_pos = min_y + self.index + self.controller.get_scroll_offset() if y_pos < min_y or y_pos >= max_y: # wont be displayed! return self.formatted_line.print_text(y_pos, min_x, printer, max_len) def __str__(self) -> str: return str(self.formatted_line) class LineMatch(LineBase): ARROW_DECORATOR = "|===>" # this is inserted between long files, so it looks like # ./src/foo/bar/something|...|baz/foo.py TRUNCATE_DECORATOR = "|...|" def __init__( self, formatted_line: FormattedText, result: MatchResult, index: int, validate_file_exists: bool = False, all_input: bool = False, ): super().__init__() self.formatted_line = formatted_line self.index = index self.all_input = all_input path, num, matches = result self.path = ( path if all_input else parse.prepend_dir(path, with_file_inspection=validate_file_exists) ) self.num = num line = str(self.formatted_line) # save a bunch of stuff so we can # pickle self.start = matches.start() self.end = min(matches.end(), len(line)) self.group: str = matches.group() # this is a bit weird but we need to strip # off the whitespace for the matches we got, # since matches like README are aggressive # about including whitespace. For most lines # this will be a no-op, but for lines like # "README " we will reset end to # earlier string_subset = line[self.start : self.end] stripped_subset = string_subset.strip() trailing_whitespace = len(string_subset) - len(stripped_subset) self.end -= trailing_whitespace self.group = self.group[0 : len(self.group) - trailing_whitespace] self.selected = False self.hovered = False self.is_truncated = False # precalculate the pre, post, and match strings (self.before_text, _) = self.formatted_line.breakat(self.start) (_, self.after_text) = self.formatted_line.breakat(self.end) self.decorated_match = FormattedText() self.update_decorated_match() def toggle_select(self) -> None: self.set_select(not self.selected) def set_select(self, val: bool) -> None: self.selected = val self.update_decorated_match() def set_hover(self, val: bool) -> None: self.hovered = val self.update_decorated_match() def get_screen_index(self) -> int: return self.index def get_path(self) -> str: return self.path def get_file_size(self) -> str: size = os.path.getsize(self.path) for unit in ["B", "K", "M", "G", "T", "P", "E", "Z"]: if size < 1024: return f"size: {size}{unit}" size //= 1024 raise AssertionError("Unreachable") def get_length_in_lines(self) -> str: output = subprocess.check_output(["wc", "-l", self.path]) lines_count = output.strip().split()[0].decode("utf-8") lines_caption = "lines" if int(lines_count) > 1 else "line" return f"length: {lines_count} {lines_caption}" def get_time_last_accessed(self) -> str: time_accessed = time.strftime( "%m/%d/%Y %H:%M:%S", time.localtime(os.stat(self.path).st_atime) ) return f"last accessed: {time_accessed}" def get_time_last_modified(self) -> str: time_modified = time.strftime( "%m/%d/%Y %H:%M:%S", time.localtime(os.stat(self.path).st_mtime) ) return f"last modified: {time_modified}" def get_owner_user(self) -> str: user_owner_name = Path(self.path).owner() user_owner_id = os.stat(self.path).st_uid return f"owned by user: {user_owner_name}, {user_owner_id}" def get_owner_group(self) -> str: group_owner_name = Path(self.path).group() group_owner_id = os.stat(self.path).st_gid return f"owned by group: {group_owner_name}, {group_owner_id}" def get_dir(self) -> str: return os.path.dirname(self.path) def is_resolvable(self) -> bool: return not self.is_git_abbreviated_path() def is_git_abbreviated_path(self) -> bool: # this method mainly serves as a warning for when we get # git-abbrievated paths like ".../" that confuse users. parts = self.path.split(os.path.sep) return len(parts) > 0 and parts[0] == "..." def get_line_num(self) -> int: return self.num def get_selected(self) -> bool: return self.selected def get_before(self) -> str: return str(self.before_text) def get_after(self) -> str: return str(self.after_text) def get_match(self) -> str: return self.group def __str__(self) -> str: return ( self.get_before() + "||" + self.get_match() + "||" + self.get_after() + "||" + str(self.num) ) def update_decorated_match(self, max_len: Optional[int] = None) -> None: """Update the cached decorated match formatted string, and dirty the line, if needed""" if self.hovered and self.selected: attributes = ( curses.COLOR_WHITE, curses.COLOR_RED, FormattedText.BOLD_ATTRIBUTE, ) elif self.hovered: attributes = ( curses.COLOR_WHITE, curses.COLOR_BLUE, FormattedText.BOLD_ATTRIBUTE, ) elif self.selected: attributes = ( curses.COLOR_WHITE, curses.COLOR_GREEN, FormattedText.BOLD_ATTRIBUTE, ) elif not self.all_input: attributes = (0, 0, FormattedText.UNDERLINE_ATTRIBUTE) else: attributes = (0, 0, 0) decorator_text = self.get_decorator() # we may not be connected to a controller (during process_input, # for example) if self.controller: self.controller.dirty_line(self.index) plain_text = decorator_text + self.get_match() if max_len and len(plain_text + str(self.before_text)) > max_len: # alright, we need to chop the ends off of our # decorated match and glue them together with our # truncation decorator. We subtract the length of the # before text since we consider that important too. space_allowed = ( max_len - len(self.TRUNCATE_DECORATOR) - len(decorator_text) - len(str(self.before_text)) ) mid_point = int(space_allowed / 2) begin_match = plain_text[0:mid_point] end_match = plain_text[-mid_point : len(plain_text)] plain_text = begin_match + self.TRUNCATE_DECORATOR + end_match self.decorated_match = FormattedText( FormattedText.get_sequence_for_attributes(*attributes) + plain_text ) def get_decorator(self) -> str: if self.selected: return self.ARROW_DECORATOR return "" def print_up_to( self, text: FormattedText, printer: ColorPrinter, y_pos: int, x_pos: int, max_len: int, ) -> Tuple[int, int]: """Attempt to print maxLen characters, returning a tuple (x, maxLen) updated with the actual number of characters printed""" if max_len <= 0: return x_pos, max_len max_printable = min(len(str(text)), max_len) text.print_text(y_pos, x_pos, printer, max_printable) return x_pos + max_printable, max_len - max_printable def output(self, printer: ColorPrinter) -> None: assert self.controller is not None (min_x, min_y, max_x, max_y) = self.controller.get_chrome_boundaries() y_pos = min_y + self.index + self.controller.get_scroll_offset() if y_pos < min_y or y_pos >= max_y: # wont be displayed! return # we dont care about the after text, but we should be able to see # all of the decorated match (which means we need to see up to # the end of the decoratedMatch, aka include beforeText) important_text_length = len(str(self.before_text)) + len( str(self.decorated_match) ) space_for_printing = max_x - min_x if important_text_length > space_for_printing: # hrm, we need to update our decorated match to show # a truncated version since right now we will print off # the screen. lets also dump the beforeText for more # space self.update_decorated_match(max_len=space_for_printing) self.is_truncated = True else: # first check what our expanded size would be: expanded_size = len(str(self.before_text)) + len(self.get_match()) if expanded_size < space_for_printing and self.is_truncated: # if the screen gets resized, we might be truncated # from a previous render but **now** we have room. # in that case lets expand back out self.update_decorated_match() self.is_truncated = False max_len = max_x - min_x so_far = (min_x, max_len) so_far = self.print_up_to(self.before_text, printer, y_pos, *so_far) so_far = self.print_up_to(self.decorated_match, printer, y_pos, *so_far) so_far = self.print_up_to(self.after_text, printer, y_pos, *so_far)