bash/.qfc/qfc/core.py
2024-08-18 15:33:19 +02:00

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