231 lines
8.6 KiB
Python
231 lines
8.6 KiB
Python
import os
|
|
import re
|
|
from . import keys
|
|
from . import ui
|
|
from . import readchar
|
|
from . import dirhandler
|
|
|
|
|
|
def get_selected_command_or_input(search):
|
|
state = State(search)
|
|
# draw the screen (prompt + matched strings)
|
|
ui.refresh(state)
|
|
# wait for user input
|
|
prompt(state)
|
|
# clear the screen
|
|
ui.erase()
|
|
# state.input holds the path selected by the user
|
|
return state.input
|
|
|
|
|
|
def prompt(state):
|
|
while True:
|
|
c = readchar.get_symbol()
|
|
if c == keys.ENTER:
|
|
if state.get_matches():
|
|
state.append_match_to_input()
|
|
break
|
|
elif c == keys.CTRL_F:
|
|
break
|
|
elif c == keys.TAB:
|
|
if state.get_matches():
|
|
state.append_match_to_input()
|
|
elif c == keys.CTRL_C or c == keys.ESC:
|
|
state.reset_input()
|
|
break
|
|
elif c == keys.CTRL_U:
|
|
state.clear_input()
|
|
elif c == keys.BACKSPACE:
|
|
state.set_input(state.input[0:-1])
|
|
elif c == keys.UP or c == keys.CTRL_K:
|
|
state.select_previous()
|
|
elif c == keys.DOWN or c == keys.CTRL_J:
|
|
state.select_next()
|
|
elif c == keys.LEFT or c == keys.CTRL_H:
|
|
state.go_back()
|
|
elif c == keys.RIGHT or c == keys.CTRL_L:
|
|
if state.get_matches():
|
|
state.append_match_to_input()
|
|
elif c < 256 and c >= 32:
|
|
state.set_input(state.input + chr(c))
|
|
ui.refresh(state)
|
|
|
|
|
|
class State(object):
|
|
''' The Current User state, including user written characters, matched commands, and selected one '''
|
|
|
|
def __init__(self, default_input):
|
|
self._selected_command_index = 0
|
|
self.matches = []
|
|
self.default_input = default_input
|
|
self.set_input(default_input)
|
|
|
|
def get_matches(self):
|
|
return self.matches
|
|
|
|
def reset_input(self):
|
|
self.input = self.default_input
|
|
|
|
def set_input(self, input):
|
|
self.input = input if input else ""
|
|
self._update()
|
|
|
|
def append_match_to_input(self):
|
|
self.set_input(os.path.join(os.path.dirname(self.input), self.get_selected_match()))
|
|
|
|
def go_back(self):
|
|
isdir = is_dir(self.input)
|
|
input_stripped = self.input.rstrip(os.sep)
|
|
if not input_stripped:
|
|
return
|
|
input_splitted = input_stripped.split(os.sep)
|
|
entry_name = input_splitted[-1]
|
|
if isdir:
|
|
entry_name += os.sep
|
|
new_input = os.sep.join(input_splitted[0:-1])
|
|
if new_input:
|
|
new_input += os.sep
|
|
self.set_input(new_input)
|
|
self.set_selected_entry(entry_name)
|
|
|
|
def clear_input(self):
|
|
self.set_input("")
|
|
|
|
def clear_selection(self):
|
|
self._selected_command_index = 0
|
|
|
|
def select_next(self):
|
|
self._selected_command_index = (self._selected_command_index + 1) % len(self.matches) if len(self.matches) else 0
|
|
|
|
def select_previous(self):
|
|
self._selected_command_index = (self._selected_command_index - 1) % len(self.matches) if len(self.matches) else 0
|
|
|
|
def _update(self):
|
|
self.matches = get_matches(os.getcwd(),self.input)
|
|
self._selected_command_index = 0
|
|
|
|
def get_output(self):
|
|
return self.input
|
|
|
|
def get_selected_match(self):
|
|
if len(self.matches):
|
|
return self.matches[self._selected_command_index]
|
|
else:
|
|
raise Exception('No matches found')
|
|
|
|
def set_selected_entry(self, entry):
|
|
if not (entry in self.matches):
|
|
return
|
|
self._selected_command_index = self.matches.index(entry)
|
|
|
|
|
|
def get_matches(root_dir, user_input):
|
|
start_dir = join_paths(root_dir, os.path.dirname(user_input))
|
|
search_str = os.path.basename(user_input)
|
|
if not os.path.isdir(start_dir):
|
|
return []
|
|
source_files = dirhandler.get_source_files(start_dir)
|
|
filtered_files = filter_files(source_files, search_str)
|
|
sorted_files = sort_matches(filtered_files, search_str)
|
|
return sorted_files
|
|
|
|
|
|
def filter_files(files, search_str):
|
|
""" Filter a list of files based on a search string
|
|
:param files: list of files to filter (ie ['/a','/a/b', '/a/b/c', '/b']), the order doesn't matter. 'a' and 'a/' are considered different.
|
|
:param search_str, the filtering string (ie 'a')
|
|
|
|
This function return only first level files/dirs that match the given search string. That is, filtering ['/a','/a/b','/a/b/c'] with the string 'a' returns only ['/a'],
|
|
This is to avoid polluting the screen with all files inside a directory when a user look for that directory
|
|
"""
|
|
matched = set()
|
|
if not search_str:
|
|
for f in files:
|
|
# right strip to the first seperator(ie '/')
|
|
f = f[:_index_or_len(f, os.sep)+1]
|
|
matched.add(f)
|
|
else:
|
|
search_str = search_str.lower()
|
|
for f in files:
|
|
if search_str in f.lower():
|
|
index = f.lower().index(search_str)
|
|
trail = f[index:]
|
|
f = f[:index + _index_or_len(trail, os.sep)+1]
|
|
matched.add(f)
|
|
return matched
|
|
|
|
def sort_matches(matches, string):
|
|
""" Sort paths according to a string """
|
|
files = sorted(matches, key=lambda s: s.lower())
|
|
return sorted(files, key=lambda p:get_weight(p, string))
|
|
|
|
def get_weight(path, string):
|
|
""" calculate how much a path matches a given string on a scale of 0->10000, a lower number means a better match.
|
|
The string should be present in the path, or this function will fail
|
|
|
|
The weight is calculated using the following formula:
|
|
0 00 0
|
|
| | |
|
|
Number of elements in the path <----------------| | |--------------------------> is a directory(0) or not(1)
|
|
(ie 2 for 'aa/bb' and 3 for 'aa/bb/cc') |
|
|
|
|
|
v
|
|
There is a word in the matched path element that exactly match the given string? ('aa_bb' matches user input 'aa' but doesn't match 'a')
|
|
|
|
|
No <--------------------|-----------------------------> yes
|
|
| |
|
|
| |
|
|
| |
|
|
v v
|
|
path element starts with the user input? There is only one word in the matched path element ('aa' but not 'aa_bb'),
|
|
| which means the path elements exactly match the string
|
|
| |
|
|
No <-------|-----Yes(2) No -----|----- Yes (0)
|
|
| |
|
|
v v
|
|
index of the string in the path element + 10 (index of the matched word within path element words + 1)
|
|
|
|
|
|
Maximums are added to make sure things doesn't overlap
|
|
"""
|
|
weight = 0
|
|
if not is_dir(path):
|
|
weight += 1
|
|
|
|
string = string.lower()
|
|
p = path.rstrip(os.sep).lower()
|
|
path_elems = p.split(os.sep)
|
|
weight += min(len(path_elems) * 1000, 10000) # Max 10 elements in path(set it to 10 for longer paths)
|
|
|
|
if not string:
|
|
return weight
|
|
|
|
elm = next(e for e in path_elems if string in e)
|
|
elm_words = [_f for _f in re.split('[\W_]+', elm) if _f]
|
|
if string in elm_words:
|
|
if len(elm_words) > 1:
|
|
weight += 10 * (min(elm_words.index(string),8) + 1) # Max 8 words per path entry
|
|
else:
|
|
if elm.startswith(string):
|
|
weight += 10 * 2
|
|
else:
|
|
weight += 10 * (10 + min(elm.index(string), 89)) # Max path element with 89 characters
|
|
return weight
|
|
|
|
# Helper functions:
|
|
|
|
def join_paths(p1, p2):
|
|
p1 = (os.path.expandvars(os.path.expanduser(p1)))
|
|
p2 = (os.path.expandvars(os.path.expanduser(p2)))
|
|
return os.path.normpath(os.path.join(p1, p2))
|
|
|
|
def _index_or_len(s, c):
|
|
if c in s:
|
|
return s.index(c)
|
|
else:
|
|
return len(s)
|
|
|
|
def is_dir(p):
|
|
if p.endswith(os.sep):
|
|
return True
|
|
return False
|